local coroutine = require "coroutine"
local math = require "math"
local nmap = require "nmap"
local outlib = require "outlib"
local shortport = require "shortport"
local sslcert = require "sslcert"
local stdnse = require "stdnse"
local string = require "string"
local table = require "table"
local tls = require "tls"
description = [[
This script repeatedly initiates SSLv3/TLS connections, each time trying a new
cipher or compressor while recording whether a host accepts or rejects it. The
end result is a list of all the ciphersuites and compressors that a server accepts.
Each ciphersuite is shown with a letter grade (A through F) indicating the
strength of the connection. The grade is based on the cryptographic strength of
the key exchange and of the stream cipher. The message integrity (hash)
algorithm choice is not a factor. The output line beginning with
Least strength
shows the strength of the weakest cipher offered.
The scoring is based on the Qualys SSL Labs SSL Server Rating Guide, but does
not take protocol support (TLS version) into account, which makes up 30% of the
SSL Labs rating.
SSLv3/TLSv1 requires more effort to determine which ciphers and compression
methods a server supports than SSLv2. A client lists the ciphers and compressors
that it is capable of supporting, and the server will respond with a single
cipher and compressor chosen, or a rejection notice.
Some servers use the client's ciphersuite ordering: they choose the first of
the client's offered suites that they also support. Other servers prefer their
own ordering: they choose their most preferred suite from among those the
client offers. In the case of server ordering, the script makes extra probes to
discover the server's sorted preference list. Otherwise, the list is sorted
alphabetically.
The script will warn about certain SSL misconfigurations such as MD5-signed
certificates, low-quality ephemeral DH parameters, and the POODLE
vulnerability.
This script is intrusive since it must initiate many connections to a server,
and therefore is quite noisy.
It is recommended to use this script in conjunction with version detection
(-sV
) in order to discover SSL/TLS services running on unexpected
ports. For the most common SSL ports like 443, 25 (with STARTTLS), 3389, etc.
the script is smart enough to run on its own.
References:
* Qualys SSL Labs Rating Guide - https://www.ssllabs.com/projects/rating-guide/
]]
---
-- @usage
-- nmap -sV --script ssl-enum-ciphers -p 443
--
-- @output
-- PORT STATE SERVICE REASON
-- 443/tcp open https syn-ack
-- | ssl-enum-ciphers:
-- | TLSv1.0:
-- | ciphers:
-- | TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA (secp256r1) - A
-- | TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA (secp256r1) - A
-- | TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA (secp256r1) - A
-- | TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA (secp256r1) - A
-- | TLS_RSA_WITH_AES_128_CBC_SHA (rsa 2048) - A
-- | TLS_RSA_WITH_AES_256_CBC_SHA (rsa 2048) - A
-- | TLS_ECDHE_ECDSA_WITH_3DES_EDE_CBC_SHA (secp256r1) - C
-- | TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA (secp256r1) - C
-- | TLS_RSA_WITH_3DES_EDE_CBC_SHA (rsa 2048) - C
-- | TLS_ECDHE_ECDSA_WITH_RC4_128_SHA (secp256r1) - C
-- | TLS_ECDHE_RSA_WITH_RC4_128_SHA (secp256r1) - C
-- | TLS_RSA_WITH_RC4_128_SHA (rsa 2048) - C
-- | TLS_RSA_WITH_RC4_128_MD5 (rsa 2048) - C
-- | compressors:
-- | NULL
-- | cipher preference: server
-- | warnings:
-- | 64-bit block cipher 3DES vulnerable to SWEET32 attack
-- | Broken cipher RC4 is deprecated by RFC 7465
-- | Ciphersuite uses MD5 for message integrity
-- | Weak certificate signature: SHA1
-- | TLSv1.2:
-- | ciphers:
-- | TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 (secp256r1) - A
-- | TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 (secp256r1) - A
-- | TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA (secp256r1) - A
-- | TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA (secp256r1) - A
-- | TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 (secp256r1) - A
-- | TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 (secp256r1) - A
-- | TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA (secp256r1) - A
-- | TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA (secp256r1) - A
-- | TLS_RSA_WITH_AES_128_GCM_SHA256 (rsa 2048) - A
-- | TLS_RSA_WITH_AES_256_GCM_SHA384 (rsa 2048) - A
-- | TLS_RSA_WITH_AES_128_CBC_SHA (rsa 2048) - A
-- | TLS_RSA_WITH_AES_256_CBC_SHA (rsa 2048) - A
-- | TLS_ECDHE_ECDSA_WITH_3DES_EDE_CBC_SHA (secp256r1) - C
-- | TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA (secp256r1) - C
-- | TLS_RSA_WITH_3DES_EDE_CBC_SHA (rsa 2048) - C
-- | TLS_ECDHE_ECDSA_WITH_RC4_128_SHA (secp256r1) - C
-- | TLS_ECDHE_RSA_WITH_RC4_128_SHA (secp256r1) - C
-- | TLS_RSA_WITH_RC4_128_SHA (rsa 2048) - C
-- | TLS_RSA_WITH_RC4_128_MD5 (rsa 2048) - C
-- | compressors:
-- | NULL
-- | cipher preference: server
-- | warnings:
-- | 64-bit block cipher 3DES vulnerable to SWEET32 attack
-- | Broken cipher RC4 is deprecated by RFC 7465
-- | Ciphersuite uses MD5 for message integrity
-- |_ least strength: C
--
-- @xmloutput
--
--
--
-- secp256r1
-- TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA
-- A
--
--
-- secp256r1
-- TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA
-- A
--
--
-- secp256r1
-- TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA
-- A
--
--
-- secp256r1
-- TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA
-- A
--
--
-- rsa 2048
-- TLS_RSA_WITH_AES_128_CBC_SHA
-- A
--
--
-- rsa 2048
-- TLS_RSA_WITH_AES_256_CBC_SHA
-- A
--
--
-- secp256r1
-- TLS_ECDHE_ECDSA_WITH_3DES_EDE_CBC_SHA
-- C
--
--
-- secp256r1
-- TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA
-- C
--
--
-- rsa 2048
-- TLS_RSA_WITH_3DES_EDE_CBC_SHA
-- C
--
--
-- secp256r1
-- TLS_ECDHE_ECDSA_WITH_RC4_128_SHA
-- C
--
--
-- secp256r1
-- TLS_ECDHE_RSA_WITH_RC4_128_SHA
-- C
--
--
-- rsa 2048
-- TLS_RSA_WITH_RC4_128_SHA
-- C
--
--
-- rsa 2048
-- TLS_RSA_WITH_RC4_128_MD5
-- C
--
--
--
-- server
--
-- 64-bit block cipher 3DES vulnerable to SWEET32 attack
-- Broken cipher RC4 is deprecated by RFC 7465
-- Ciphersuite uses MD5 for message integrity
-- Weak certificate signature: SHA1
--
--
--
--
--
-- secp256r1
--
-- TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
-- A
--
--
-- secp256r1
--
-- TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
-- A
--
--
-- secp256r1
-- TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA
-- A
--
--
-- secp256r1
-- TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA
-- A
--
--
-- secp256r1
-- TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
-- A
--
--
-- secp256r1
-- TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
-- A
--
--
-- secp256r1
-- TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA
-- A
--
--
-- secp256r1
-- TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA
-- A
--
--
-- rsa 2048
-- TLS_RSA_WITH_AES_128_GCM_SHA256
-- A
--
--
-- rsa 2048
-- TLS_RSA_WITH_AES_256_GCM_SHA384
-- A
--
--
-- rsa 2048
-- TLS_RSA_WITH_AES_128_CBC_SHA
-- A
--
--
-- rsa 2048
-- TLS_RSA_WITH_AES_256_CBC_SHA
-- A
--
--
-- secp256r1
-- TLS_ECDHE_ECDSA_WITH_3DES_EDE_CBC_SHA
-- C
--
--
-- secp256r1
-- TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA
-- C
--
--
-- rsa 2048
-- TLS_RSA_WITH_3DES_EDE_CBC_SHA
-- C
--
--
-- secp256r1
-- TLS_ECDHE_ECDSA_WITH_RC4_128_SHA
-- C
--
--
-- secp256r1
-- TLS_ECDHE_RSA_WITH_RC4_128_SHA
-- C
--
--
-- rsa 2048
-- TLS_RSA_WITH_RC4_128_SHA
-- C
--
--
-- rsa 2048
-- TLS_RSA_WITH_RC4_128_MD5
-- C
--
--
--
-- server
--
-- 64-bit block cipher 3DES vulnerable to SWEET32 attack
-- Broken cipher RC4 is deprecated by RFC 7465
-- Ciphersuite uses MD5 for message integrity
--
--
-- C
author = {"Mak Kolybabi ", "Gabriel Lawrence"}
license = "Same as Nmap--See https://nmap.org/book/man-legal.html"
categories = {"discovery", "intrusive"}
dependencies = {"https-redirect"}
-- Test at most this many ciphersuites at a time.
-- http://seclists.org/nmap-dev/2012/q3/156
-- http://seclists.org/nmap-dev/2010/q1/859
local CHUNK_SIZE = 64
local have_ssl, openssl = pcall(require,'openssl')
-- Add additional context (protocol) to debug output
local function ctx_log(level, protocol, fmt, ...)
return stdnse.debug(level, "(%s) " .. fmt, protocol, ...)
end
-- returns a function that yields a new tls record each time it is called
local function get_record_iter(sock)
local buffer = ""
local i = 1
local fragment
return function ()
local record
i, record = tls.record_read(buffer, i, fragment)
if record == nil then
local status, err
status, buffer, err = tls.record_buffer(sock, buffer, i)
if not status then
return nil, err
end
i, record = tls.record_read(buffer, i, fragment)
if record == nil then
return nil, "done"
end
end
fragment = record.fragment
return record
end
end
local function try_params(host, port, t)
-- Use Nmap's own discovered timeout plus 5 seconds for host processing
-- Default to 10 seconds total.
local timeout = ((host.times and host.times.timeout) or 5) * 1000 + 5000
-- Create socket.
local status, sock, err
local specialized = sslcert.getPrepareTLSWithoutReconnect(port)
if specialized then
status, sock = specialized(host, port)
if not status then
ctx_log(1, t.protocol, "Can't connect: %s", sock)
return nil
end
else
sock = nmap.new_socket()
sock:set_timeout(timeout)
status, err = sock:connect(host, port)
if not status then
ctx_log(1, t.protocol, "Can't connect: %s", err)
sock:close()
return nil
end
end
sock:set_timeout(timeout)
-- Send request.
local req = tls.client_hello(t)
status, err = sock:send(req)
if not status then
ctx_log(1, t.protocol, "Can't send: %s", err)
sock:close()
return nil
end
-- Read response.
local get_next_record = get_record_iter(sock)
local records = {}
while true do
local record
record, err = get_next_record()
if not record then
ctx_log(1, t.protocol, "Couldn't read a TLS record: %s", err)
sock:close()
return records
end
-- Collect message bodies into one record per type
records[record.type] = records[record.type] or record
local done = false
for j = 1, #record.body do -- no ipairs because we append below
local b = record.body[j]
done = ((record.type == "alert" and b.level == "fatal") or
(record.type == "handshake" and (b.type == "server_hello_done" or
-- TLSv1.3 does not have server_hello_done
(t.protocol == "TLSv1.3" and b.type == "server_hello")))
)
table.insert(records[record.type].body, b)
end
if done then
sock:close()
return records
end
end
end
local function sorted_keys(t)
local ret = {}
for k, _ in pairs(t) do
ret[#ret+1] = k
end
table.sort(ret)
return ret
end
local function in_chunks(t, size)
size = math.floor(size)
if size < 1 then size = 1 end
local ret = {}
for i = 1, #t, size do
local chunk = {}
for j = i, i + size - 1 do
chunk[#chunk+1] = t[j]
end
ret[#ret+1] = chunk
end
return ret
end
local function remove(t, e)
for i, v in ipairs(t) do
if v == e then
table.remove(t, i)
return i
end
end
return nil
end
local function slice(t, i, j)
local output = {}
while i <= j do
output[#output+1] = t[i]
i = i + 1
end
return output
end
local function merge(a, b, cmp)
local output = {}
local i = 1
local j = 1
while i <= #a and j <= #b do
local winner, err = cmp(a[i], b[j])
if not winner then
return nil, err
end
if winner == a[i] then
output[#output+1] = a[i]
i = i + 1
else
output[#output+1] = b[j]
j = j + 1
end
end
while i <= #a do
output[#output+1] = a[i]
i = i + 1
end
while j <= #b do
output[#output+1] = b[j]
j = j + 1
end
return output
end
local function merge_recursive(chunks, cmp)
if #chunks == 0 then
return {}
elseif #chunks == 1 then
return chunks[1]
else
local m = math.floor(#chunks / 2)
local a, b = slice(chunks, 1, m), slice(chunks, m+1, #chunks)
local am, err = merge_recursive(a, cmp)
if not am then
return nil, err
end
local bm, err = merge_recursive(b, cmp)
if not bm then
return nil, err
end
return merge(am, bm, cmp)
end
end
-- https://bugzilla.mozilla.org/show_bug.cgi?id=946147
local function remove_high_byte_ciphers(t)
local output = {}
for i, v in ipairs(t) do
if tls.CIPHERS[v] <= 255 then
output[#output+1] = v
end
end
return output
end
-- Get TLS extensions
local function base_extensions(host)
local tlsname = tls.servername(host)
return {
-- Claim to support common elliptic curves
-- TODO: Determine desire to comply with RFC 4492, section 4:
-- "The client MUST NOT include these extensions in the ClientHello
-- message if it does not propose any ECC cipher suites."
-- OTOH, OpenSSL 1.1.1 sends them always so it is probably safe.
["elliptic_curves"] = tls.EXTENSION_HELPERS["elliptic_curves"](tls.DEFAULT_ELLIPTIC_CURVES),
-- Some servers require Supported Point Formats Extension
["ec_point_formats"] = tls.EXTENSION_HELPERS["ec_point_formats"]({"uncompressed"}),
-- Enable SNI if a server name is available
["server_name"] = tlsname and tls.EXTENSION_HELPERS["server_name"](tlsname),
}
end
-- Get a message body from a record which has the specified property set to value
local function get_body(record, property, value)
for i, b in ipairs(record.body) do
if b[property] == value then
return b
end
end
return nil
end
-- Score a ciphersuite implementation (including key exchange info)
local function score_cipher (kex_strength, cipher_info)
local kex_score, cipher_score
if not kex_strength or not cipher_info.size then
return "unknown"
end
if kex_strength <= 0 then
return 0
elseif kex_strength < 512 then
kex_score = 0.2
elseif kex_strength < 1024 then
kex_score = 0.4
elseif kex_strength < 2048 then
kex_score = 0.8
elseif kex_strength < 4096 then
kex_score = 0.9
else
kex_score = 1.0
end
if cipher_info.size <= 0 then
return 0
elseif cipher_info.size < 128 then
cipher_score = 0.2
elseif cipher_info.size < 256 then
cipher_score = 0.8
else
cipher_score = 1.0
end
-- Based on SSL Labs' 30-30-40 rating without the first 30% (protocol support)
return 0.43 * kex_score + 0.57 * cipher_score
end
local function letter_grade (score)
if not tonumber(score) then return "unknown" end
if score >= 0.80 then
return "A"
elseif score >= 0.65 then
return "B"
elseif score >= 0.50 then
return "C"
elseif score >= 0.35 then
return "D"
elseif score >= 0.20 then
return "E"
else
return "F"
end
end
local tls13proto = tls.PROTOCOLS["TLSv1.3"]
local tls13supported = tls.EXTENSION_HELPERS.supported_versions({"TLSv1.3"})
local function get_hello_table(host, protocol)
local t = {
protocol = protocol,
record_protocol = protocol, -- improve chances of immediate rejection
extensions = base_extensions(host),
}
-- supported_versions extension required for TLSv1.3
if (tls.PROTOCOLS[protocol] >= tls13proto) then
t.extensions.supported_versions = tls13supported
end
return t
end
-- Find which ciphers out of group are supported by the server.
local function find_ciphers_group(host, port, protocol, group, scores)
local results = {}
local t = get_hello_table(host, protocol)
-- This is a hacky sort of tristate variable. There are three conditions:
-- 1. false = either ciphers or protocol is bad. Keep trying with new ciphers
-- 2. nil = The protocol is bad. Abandon thread.
-- 3. true = Protocol works, at least some cipher must be supported.
local protocol_worked = false
while (next(group)) do
t["ciphers"] = group
local records = try_params(host, port, t)
if not records then
return nil
end
local handshake = records.handshake
if handshake == nil then
local alert = records.alert
if alert then
ctx_log(2, protocol, "Got alert: %s", alert.body[1].description)
if not tls.record_version_ok(alert["protocol"], protocol) then
ctx_log(1, protocol, "Protocol mismatch (received %s)", alert.protocol)
-- Sometimes this is not an actual rejection of the protocol. Check specifically:
if get_body(alert, "description", "protocol_version") then
protocol_worked = nil
end
break
elseif get_body(alert, "description", "handshake_failure")
or get_body(alert, "description", "insufficient_security") then
protocol_worked = true
ctx_log(2, protocol, "%d ciphers rejected.", #group)
break
end
elseif protocol_worked then
ctx_log(2, protocol, "%d ciphers rejected. (No handshake)", #group)
else
ctx_log(1, protocol, "%d ciphers and/or protocol rejected. (No handshake)", #group)
end
break
else
local server_hello = get_body(handshake, "type", "server_hello")
if not server_hello then
ctx_log(2, protocol, "Unexpected record received.")
break
end
if server_hello.protocol ~= protocol then
ctx_log(1, protocol, "Protocol rejected. cipher: %s", server_hello.cipher)
-- Some implementations will do this if a cipher is supported in some
-- other protocol version but not this one. Gotta keep trying.
if not remove(group, server_hello.cipher) then
-- But if we didn't even offer this cipher, then give up. Crazy!
protocol_worked = protocol_worked or nil
end
break
else
protocol_worked = true
local name = server_hello.cipher
ctx_log(2, protocol, "Cipher %s chosen.", name)
if not remove(group, name) then
ctx_log(1, protocol, "chose cipher %s that was not offered.", name)
ctx_log(1, protocol, "removing high-byte ciphers and trying again.")
local size_before = #group
group = remove_high_byte_ciphers(group)
ctx_log(1, protocol, "removed %d high-byte ciphers.", size_before - #group)
if #group == size_before then
-- No changes... Server just doesn't like our offered ciphers.
break
end
else
-- Add cipher to the list of accepted ciphers.
table.insert(results, name)
if scores then
local info = tls.cipher_info(name)
-- Some warnings:
if info.hash and info.hash == "MD5" then
scores.warnings["Ciphersuite uses MD5 for message integrity"] = true
end
if info.mode and info.mode == "CBC" and info.block_size <= 64 then
scores.warnings[("64-bit block cipher %s vulnerable to SWEET32 attack"):format(info.cipher)] = true
end
if protocol == "SSLv3" and info.mode and info.mode == "CBC" then
scores.warnings["CBC-mode cipher in SSLv3 (CVE-2014-3566)"] = true
elseif info.cipher == "RC4" then
scores.warnings["Broken cipher RC4 is deprecated by RFC 7465"] = true
end
if protocol == "TLSv1.3" and not info.tls13ok then
scores.warnings["Non-TLSv1.3 ciphersuite chosen for TLSv1.3"] = true
end
local kex = tls.KEX_ALGORITHMS[info.kex]
scores.any_pfs_ciphers = kex.pfs or scores.any_pfs_ciphers
local extra, kex_strength
if kex.export then
scores.warnings["Export key exchange"] = true
if info.kex:find("1024$") then
kex_strength = 1024
else
kex_strength = 512
end
end
if kex.anon then
scores.warnings["Anonymous key exchange, score capped at F"] = true
kex_strength = 0
elseif have_ssl and kex.pubkey then
local certs = get_body(handshake, "type", "certificate")
-- Assume RFC compliance:
-- "The sender's certificate MUST come first in the list."
-- This may not always be the case, so
-- TODO: reorder certificates and validate entire chain
-- TODO: certificate validation (date, self-signed, etc)
local c, err
if certs == nil then
err = "no certificate message"
else
c, err = sslcert.parse_ssl_certificate(certs.certificates[1])
end
if not c then
ctx_log(1, protocol, "Failed to parse certificate: %s", err)
elseif c.pubkey.type == kex.pubkey then
local sigalg = c.sig_algorithm:match("([mM][dD][245])") or c.sig_algorithm:match("([sS][hH][aA]1)")
if sigalg then
kex_strength = 0
scores.warnings[("Insecure certificate signature (%s), score capped at F"):format(string.upper(sigalg))] = true
end
local rsa_bits = tls.rsa_equiv(kex.pubkey, c.pubkey.bits)
kex_strength = math.min(kex_strength or rsa_bits, rsa_bits)
if c.pubkey.exponent then
if openssl.bignum_bn2dec(c.pubkey.exponent) == "1" then
kex_strength = 0
scores.warnings["Certificate RSA exponent is 1, score capped at F"] = true
end
end
if c.pubkey.ecdhparams then
if c.pubkey.ecdhparams.curve_params.ec_curve_type == "namedcurve" then
extra = c.pubkey.ecdhparams.curve_params.curve
else
extra = string.format("%s %d", c.pubkey.ecdhparams.curve_params.ec_curve_type, c.pubkey.bits)
end
else
extra = string.format("%s %d", kex.pubkey, c.pubkey.bits)
end
end
end
local ske
if protocol == "TLSv1.3" then
ske = server_hello.extensions.key_share
elseif kex.server_key_exchange then
ske = get_body(handshake, "type", "server_key_exchange")
if ske then
ske = ske.data
end
end
if ske then
local kex_info = kex.server_key_exchange(ske, protocol)
if kex_info.strength then
local kex_type = kex_info.type or kex.type
if kex_info.ecdhparams then
if kex_info.ecdhparams.curve_params.ec_curve_type == "namedcurve" then
extra = kex_info.ecdhparams.curve_params.curve
else
extra = string.format("%s %d", kex_info.ecdhparams.curve_params.ec_curve_type, kex_info.strength)
end
else
extra = string.format("%s %d", kex_type, kex_info.strength)
end
local rsa_bits = tls.rsa_equiv(kex_type, kex_info.strength)
if kex_strength and kex_strength > rsa_bits then
kex_strength = rsa_bits
scores.warnings[(
"Key exchange (%s) of lower strength than certificate key"
):format(extra)] = true
end
kex_strength = math.min(kex_strength or rsa_bits, rsa_bits)
end
if kex_info.rsa and kex_info.rsa.exponent == 1 then
kex_strength = 0
scores.warnings["Certificate RSA exponent is 1, score capped at F"] = true
end
end
scores[name] = {
cipher_strength=info.size,
kex_strength = kex_strength,
extra = extra,
letter_grade = letter_grade(score_cipher(kex_strength, info))
}
end
end
end
end
end
return results, protocol_worked
end
local function get_chunk_size(host, protocol)
-- Try to make sure we don't send too big of a handshake
-- https://github.com/ssllabs/research/wiki/Long-Handshake-Intolerance
local len_t = get_hello_table(host, protocol)
len_t.ciphers = {}
local cipher_len_remaining = 255 - #tls.client_hello(len_t)
-- if we're over 255 anyway, just go for it.
-- Each cipher adds 2 bytes
local max_chunks = cipher_len_remaining > 1 and cipher_len_remaining // 2 or CHUNK_SIZE
-- otherwise, use the min
return max_chunks < CHUNK_SIZE and max_chunks or CHUNK_SIZE
end
-- Break the cipher list into chunks of CHUNK_SIZE (for servers that can't
-- handle many client ciphers at once), and then call find_ciphers_group on
-- each chunk.
local function find_ciphers(host, port, protocol)
local candidates = {}
-- TLSv1.3 ciphers are different, though some are shared (ECCPWD)
local tls13 = protocol == "TLSv1.3"
for _, c in ipairs(sorted_keys(tls.CIPHERS)) do
local info = tls.cipher_info(c)
if (not tls13 and not info.tls13only)
or (tls13 and info.tls13ok) then
candidates[#candidates+1] = c
end
end
local ciphers = in_chunks(candidates, get_chunk_size(host, protocol))
local results = {}
local scores = {warnings={}}
-- Try every cipher.
for _, group in ipairs(ciphers) do
local chunk, protocol_worked = find_ciphers_group(host, port, protocol, group, scores)
if protocol_worked == nil then return nil end
for _, name in ipairs(chunk) do
table.insert(results, name)
end
end
if not next(results) then return nil end
scores.warnings["Forward Secrecy not supported by any cipher"] = (not scores.any_pfs_ciphers) or nil
scores.any_pfs_ciphers = nil
return results, scores
end
local function find_compressors(host, port, protocol, good_ciphers)
local compressors = sorted_keys(tls.COMPRESSORS)
local t = get_hello_table(host, protocol)
t.ciphers = good_ciphers
local results = {}
-- Try every compressor.
local protocol_worked = false
while (next(compressors)) do
-- Create structure.
t["compressors"] = compressors
-- Try connecting with compressor.
local records = try_params(host, port, t)
local handshake = records.handshake
if handshake == nil then
local alert = records.alert
if alert then
ctx_log(2, protocol, "Got alert: %s", alert.body[1].description)
if not tls.record_version_ok(alert["protocol"], protocol) then
ctx_log(1, protocol, "Protocol rejected.")
protocol_worked = nil
break
elseif get_body(alert, "description", "handshake_failure") then
protocol_worked = true
ctx_log(2, protocol, "%d compressors rejected.", #compressors)
-- Should never get here, because NULL should be good enough.
-- The server may just not be able to handle multiple compressors.
if #compressors > 1 then -- Make extra-sure it's not crazily rejecting the NULL compressor
compressors[1] = "NULL"
for i = 2, #compressors, 1 do
compressors[i] = nil
end
-- try again.
else
break
end
end
elseif protocol_worked then
ctx_log(2, protocol, "%d compressors rejected. (No handshake)", #compressors)
else
ctx_log(1, protocol, "%d compressors and/or protocol rejected. (No handshake)", #compressors)
end
break
else
local server_hello = get_body(handshake, "type", "server_hello")
if not server_hello then
ctx_log(2, protocol, "Unexpected record received.")
break
end
if server_hello.protocol ~= protocol then
ctx_log(1, protocol, "Protocol rejected.")
protocol_worked = (protocol_worked == nil) and nil or false
break
else
protocol_worked = true
local name = server_hello.compressor
ctx_log(2, protocol, "Compressor %s chosen.", name)
remove(compressors, name)
-- Add compressor to the list of accepted compressors.
table.insert(results, name)
if name == "NULL" then
break -- NULL is always last choice, and must be included
end
end
end
end
return results
end
-- Offer two ciphers and return the one chosen by the server. Returns nil and
-- an error message in case of a server error.
local function compare_ciphers(host, port, protocol, cipher_a, cipher_b)
local t = get_hello_table(host, protocol)
t.ciphers = {cipher_a, cipher_b}
local records = try_params(host, port, t)
local server_hello = records.handshake and get_body(records.handshake, "type", "server_hello")
if server_hello then
ctx_log(2, protocol, "compare %s %s -> %s", cipher_a, cipher_b, server_hello.cipher)
return server_hello.cipher
else
ctx_log(2, protocol, "compare %s %s -> error", cipher_a, cipher_b)
return nil, string.format("Error when comparing %s and %s", cipher_a, cipher_b)
end
end
-- Try to find whether the server prefers its own ciphersuite order or that of
-- the client.
--
-- The return value is (preference, err). preference is a string:
-- "server": the server prefers its own order. In this case ciphers is non-nil.
-- "client": the server follows the client preference. ciphers is nil.
-- "indeterminate": returned when there are only 0 or 1 ciphers. ciphers is nil.
-- nil: an error occurred during the test. err is non-nil.
-- err is an error message string that is non-nil when preference is nil or
-- indeterminate.
--
-- The algorithm tries offering two ciphersuites in two different orders. If
-- the server makes a different choice each time, "client" preference is
-- assumed. Otherwise, "server" preference is assumed.
local function find_cipher_preference(host, port, protocol, ciphers)
-- Too few ciphers to make a decision?
if #ciphers < 2 then
return "indeterminate", "Too few ciphers supported"
end
-- Do a comparison in both directions to see if server ordering is consistent.
local cipher_a, cipher_b = ciphers[1], ciphers[2]
ctx_log(1, protocol, "Comparing %s to %s", cipher_a, cipher_b)
local winner_forwards, err = compare_ciphers(host, port, protocol, cipher_a, cipher_b)
if not winner_forwards then
return nil, err
end
local winner_backward, err = compare_ciphers(host, port, protocol, cipher_b, cipher_a)
if not winner_backward then
return nil, err
end
if winner_forwards ~= winner_backward then
return "client", nil
end
return "server", nil
end
-- Sort ciphers according to server preference with a modified merge sort
local function sort_ciphers(host, port, protocol, ciphers)
local chunks = {}
for _, group in ipairs(in_chunks(ciphers, get_chunk_size(host, protocol))) do
local size = #group
local chunk = find_ciphers_group(host, port, protocol, group)
if not chunk then
return nil, "Network error"
end
if #chunk ~= size then
ctx_log(1, protocol, "warning: %d ciphers offered but only %d accepted", size, #chunk)
end
table.insert(chunks, chunk)
end
-- The comparison operator for the merge is a 2-cipher ClientHello.
local function cmp(cipher_a, cipher_b)
return compare_ciphers(host, port, protocol, cipher_a, cipher_b)
end
local sorted, err = merge_recursive(chunks, cmp)
if not sorted then
return nil, err
end
return sorted
end
local function try_protocol(host, port, protocol, upresults)
local condvar = nmap.condvar(upresults)
local results = stdnse.output_table()
-- Find all valid ciphers.
local ciphers, scores = find_ciphers(host, port, protocol)
if ciphers == nil then
condvar "signal"
return nil
end
if #ciphers == 0 then
results = {ciphers={},compressors={}}
setmetatable(results,{
__tostring=function(t) return "No supported ciphers found" end
})
upresults[protocol] = results
condvar "signal"
return nil
end
-- Find all valid compression methods.
local compressors
-- RFC 8446: "For every TLS 1.3 ClientHello, this vector MUST contain exactly
-- one byte, set to zero"
if (tls.PROTOCOLS[protocol] < tls13proto) then
-- Reduce chunk size by 1 to allow extra room for the extra compressors (2 bytes)
for _, c in ipairs(in_chunks(ciphers, get_chunk_size(host, protocol) - 1)) do
compressors = find_compressors(host, port, protocol, c)
-- I observed a weird interaction between ECDSA ciphers and DEFLATE compression.
-- Some servers would reject the handshake if no non-ECDSA ciphers were available.
-- Sending 64 ciphers at a time should be sufficient, but we'll try them all if necessary.
if compressors and #compressors ~= 0 then
break
end
end
end
-- Note the server's cipher preference algorithm.
local cipher_pref, cipher_pref_err = find_cipher_preference(host, port, protocol, ciphers)
-- Order ciphers according to server preference, if possible
if cipher_pref == "server" then
local sorted, err = sort_ciphers(host, port, protocol, ciphers)
if sorted then
ciphers = sorted
else
-- Can't sort, fall back to alphabetical order
table.sort(ciphers)
cipher_pref_err = err
end
else
-- fall back to alphabetical order
table.sort(ciphers)
end
-- Add rankings to ciphers
for i, name in ipairs(ciphers) do
local outcipher = {name=name, kex_info=scores[name].extra, strength=scores[name].letter_grade}
setmetatable(outcipher,{
__tostring=function(t)
if t.kex_info then
return string.format("%s (%s) - %s", t.name, t.kex_info, t.strength)
else
return string.format("%s - %s", t.name, t.strength)
end
end
})
ciphers[i]=outcipher
end
results["ciphers"] = ciphers
-- Format the compressor table.
if compressors then
table.sort(compressors)
end
results["compressors"] = compressors
results["cipher preference"] = cipher_pref
results["cipher preference error"] = cipher_pref_err
if next(scores.warnings) then
results["warnings"] = sorted_keys(scores.warnings)
end
upresults[protocol] = results
condvar "signal"
return nil
end
portrule = function (host, port)
return port.protocol == "tcp" and (shortport.ssl(host, port) or sslcert.getPrepareTLSWithoutReconnect(port))
end
action = function(host, port)
if not have_ssl then
stdnse.verbose("OpenSSL not available; some cipher scores will be marked as unknown.")
end
local results = {}
local condvar = nmap.condvar(results)
local threads = {}
for name, _ in pairs(tls.PROTOCOLS) do
stdnse.debug1("Trying protocol %s.", name)
local co = stdnse.new_thread(try_protocol, host, port, name, results)
threads[co] = true
end
repeat
for thread in pairs(threads) do
if coroutine.status(thread) == "dead" then threads[thread] = nil end
end
if ( next(threads) ) then
condvar "wait"
end
until next(threads) == nil
if not next(results) then
return nil
end
local least = "A"
for p, r in pairs(results) do
for i, c in ipairs(r.ciphers) do
-- counter-intuitive: "A" < "B", so really looking for max
least = least < c.strength and c.strength or least
end
end
results["least strength"] = least
return outlib.sorted_by_key(results)
end