local coroutine = require "coroutine"
local math = require "math"
local nmap = require "nmap"
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.
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.
]]
---
-- @usage
-- nmap --script ssl-enum-ciphers -p 443
--
-- @output
-- PORT STATE SERVICE REASON
-- 443/tcp open https syn-ack
-- | ssl-enum-ciphers:
-- | SSLv3:
-- | ciphers:
-- | TLS_RSA_WITH_RC4_128_MD5 (rsa 2048) - A
-- | TLS_RSA_WITH_RC4_128_SHA (rsa 2048) - A
-- | TLS_RSA_WITH_3DES_EDE_CBC_SHA (rsa 2048) - C
-- | compressors:
-- | NULL
-- | cipher preference: server
-- | TLSv1.0:
-- | ciphers:
-- | TLS_RSA_WITH_RC4_128_MD5 (rsa 2048) - A
-- | TLS_RSA_WITH_RC4_128_SHA (rsa 2048) - A
-- | TLS_RSA_WITH_3DES_EDE_CBC_SHA (rsa 2048) - C
-- | TLS_DHE_RSA_WITH_AES_256_CBC_SHA (dh 256) - A
-- | TLS_DHE_RSA_WITH_AES_128_CBC_SHA (dh 256) - A
-- | compressors:
-- | NULL
-- | cipher preference: server
-- |_ least strength: C
--
-- @xmloutput
--
--
--
-- TLS_RSA_WITH_RC4_128_MD5
-- A
-- rsa 2048
--
--
-- TLS_RSA_WITH_RC4_128_SHA
-- A
-- rsa 2048
--
--
-- TLS_RSA_WITH_3DES_EDE_CBC_SHA
-- C
-- rsa 2048
--
--
--
-- server
--
--
--
--
-- TLS_RSA_WITH_RC4_128_MD5
-- A
-- rsa 2048
--
--
-- TLS_RSA_WITH_RC4_128_SHA
-- A
-- rsa 2048
--
--
-- TLS_RSA_WITH_3DES_EDE_CBC_SHA
-- C
-- rsa 2048
--
--
-- TLS_DHE_RSA_WITH_AES_256_CBC_SHA
-- A
-- dh 256
--
--
-- TLS_DHE_RSA_WITH_AES_128_CBC_SHA
-- A
-- dh 256
--
--
--
-- server
--
-- C
author = "Mak Kolybabi , Gabriel Lawrence"
license = "Same as Nmap--See http://nmap.org/book/man-legal.html"
categories = {"discovery", "intrusive"}
-- Test 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
-- 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
return function ()
local record
i, record = tls.record_read(buffer, i)
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)
if record == nil then
return nil, "done"
end
end
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"))
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)
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
-- Claim to support every elliptic curve and EC point format
local base_extensions = {
-- Claim to support every elliptic curve
["elliptic_curves"] = tls.EXTENSION_HELPERS["elliptic_curves"](sorted_keys(tls.ELLIPTIC_CURVES)),
-- Claim to support every EC point format
["ec_point_formats"] = tls.EXTENSION_HELPERS["ec_point_formats"](sorted_keys(tls.EC_POINT_FORMATS)),
}
-- Recursively copy a table.
-- Only recurs when a value is a table, other values are copied by assignment.
local function tcopy (t)
local tc = {};
for k,v in pairs(t) do
if type(v) == "table" then
tc[k] = tcopy(v);
else
tc[k] = v;
end
end
return tc;
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
-- 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 = {
["protocol"] = protocol,
["extensions"] = tcopy(base_extensions),
}
if host.targetname then
t["extensions"]["server_name"] = tls.EXTENSION_HELPERS["server_name"](host.targetname)
end
-- 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)
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 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 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)
protocol_worked = (protocol_worked == nil) and nil or false
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 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" and tls.PROTOCOLS[protocol] >= 0x0302 then
scores.warnings["Weak cipher RC4 in TLSv1.1 or newer not needed for BEAST mitigation"] = true
end
local kex = tls.KEX_ALGORITHMS[info.kex]
local extra, kex_strength
if kex.anon then
kex_strength = 0
elseif kex.export then
if info.kex:find("1024$") then
kex_strength = 1024
else
kex_strength = 512
end
else
if 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)
-- TODO: Handle this gracefully when OpenSSL is not compiled in
-- (throws error otherwise)
local c = sslcert.parse_ssl_certificate(certs.certificates[1])
if c.pubkey.type == kex.pubkey then
local sigalg = c.sig_algorithm:match("([mM][dD][245])")
if sigalg then
-- MD2 and MD5 are broken
kex_strength = 0
scores.warnings["Insecure certificate signature: " .. string.upper(sigalg)] = true
else
sigalg = c.sig_algorithm:match("([sS][hH][aA]1)")
if sigalg then
-- TODO: Update this when SHA-1 is deprecated in 2016
-- kex_strength = 0
scores.warnings["Weak certificate signature: SHA1"] = true
end
kex_strength = tls.rsa_equiv(kex.pubkey, c.pubkey.bits)
extra = string.format("%s %d", kex.pubkey, c.pubkey.bits)
end
end
end
local ske = get_body(handshake, "type", "server_key_exchange")
if kex.server_key_exchange and ske then
local kex_info = kex.server_key_exchange(ske.data)
if kex_info.strength then
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 parameters of lower strength than certificate key"] = true
end
kex_strength = kex_strength or rsa_bits
extra = string.format("%s %d", kex.type, kex_info.strength)
end
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
-- 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 ciphers = in_chunks(sorted_keys(tls.CIPHERS), CHUNK_SIZE)
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
return results, scores
end
local function find_compressors(host, port, protocol, good_ciphers)
local compressors = sorted_keys(tls.COMPRESSORS)
local t = {
["protocol"] = protocol,
["ciphers"] = good_ciphers,
["extensions"] = tcopy(base_extensions),
}
if host.targetname then
t["extensions"]["server_name"] = tls.EXTENSION_HELPERS["server_name"](host.targetname)
end
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 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 = {
["protocol"] = protocol,
["ciphers"] = {cipher_a, cipher_b},
["extensions"] = tcopy(base_extensions),
}
if host.targetname then
t["extensions"]["server_name"] = tls.EXTENSION_HELPERS["server_name"](host.targetname)
end
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 ocurred 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, CHUNK_SIZE)) 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
for _, c in ipairs(in_chunks(ciphers, CHUNK_SIZE)) 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
-- 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
local cipherstr
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.
table.sort(compressors)
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 shortport.ssl(host, port) or sslcert.getPrepareTLSWithoutReconnect(port)
end
--- Return a table that yields elements sorted by key when iterated over with pairs()
-- Should probably put this in a formatting library later.
-- Depends on keys() function defined above.
--@param t The table whose data should be used
--@return out A table that can be passed to pairs() to get sorted results
function sorted_by_key(t)
local out = {}
setmetatable(out, {
__pairs = function(_)
local order = sorted_keys(t)
return coroutine.wrap(function()
for i,k in ipairs(order) do
coroutine.yield(k, t[k])
end
end)
end
})
return out
end
action = function(host, port)
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 sorted_by_key(results)
end