local coroutine = require "coroutine" local io = require "io" 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 ciphers and compressors that a server accepts. Each cipher is shown with a strength rating: one of strong, weak, or unknown strength. The output line beginning with Least strength shows the strength of the weakest cipher offered. If you are auditing for weak ciphers, you would want to look more closely at any port where Least strength is not strong. The cipher strength database is in the file nselib/data/ssl-ciphers, or you can use a different file through the script argument ssl-enum-ciphers.rankedcipherlist. 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. 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 -- -- @args ssl-enum-ciphers.rankedcipherlist A path to a file of cipher names and strength ratings -- -- @output -- PORT STATE SERVICE REASON -- 443/tcp open https syn-ack -- | ssl-enum-ciphers: -- | SSLv3: -- | ciphers: -- | TLS_RSA_WITH_RC4_128_MD5 - strong -- | TLS_RSA_WITH_RC4_128_SHA - strong -- | TLS_RSA_WITH_3DES_EDE_CBC_SHA - strong -- | compressors: -- | NULL -- | cipher preference: server -- | TLSv1.0: -- | ciphers: -- | TLS_RSA_WITH_RC4_128_MD5 - strong -- | TLS_RSA_WITH_RC4_128_SHA - strong -- | TLS_RSA_WITH_3DES_EDE_CBC_SHA - strong -- | TLS_RSA_WITH_AES_256_CBC_SHA - strong -- | TLS_RSA_WITH_AES_128_CBC_SHA - strong -- | compressors: -- | NULL -- | cipher preference: server -- |_ least strength: strong -- -- @xmloutput -- --
--
-- TLS_RSA_WITH_RC4_128_MD5 -- strong --
-- -- TLS_RSA_WITH_RC4_128_SHA -- strong --
-- -- TLS_RSA_WITH_3DES_EDE_CBC_SHA -- strong --
-- -- -- NULL --
-- server -- -- --
--
-- TLS_RSA_WITH_RC4_128_MD5 -- strong --
-- -- TLS_RSA_WITH_RC4_128_SHA -- strong --
-- -- TLS_RSA_WITH_3DES_EDE_CBC_SHA -- strong --
-- -- TLS_RSA_WITH_AES_256_CBC_SHA -- strong --
-- -- TLS_RSA_WITH_AES_128_CBC_SHA -- strong --
-- -- -- NULL --
-- server -- -- strong 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 cipherstrength = { ["broken"] = 0, ["weak"] = 1, ["unknown strength"] = 2, ["strong"] = 3 } local rankedciphers={} local mincipherstrength=9999 --artificial "highest value" local rankedciphersfilename=false -- Add additional context (protocol) to debug output local function ctx_log(level, protocol, fmt, ...) return stdnse.debug(level, "(%s) " .. fmt, protocol, ...) end local function try_params(host, port, t) local buffer, err, i, record, req, resp, sock, status -- Use Nmap's own discovered timeout, doubled for safety -- Default to 10 seconds. local timeout = ((host.times and host.times.timeout) or 5) * 1000 * 2 -- Create socket. local specialized = sslcert.getPrepareTLSWithoutReconnect(port) if specialized then local status status, sock = specialized(host, port) if not status then ctx_log(1, t.protocol, "Can't connect: %s", err) return nil end else sock = nmap.new_socket() sock:set_timeout(timeout) local status = 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. 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. buffer = "" record = nil while true do local status status, buffer, err = tls.record_buffer(sock, buffer, 1) if not status then ctx_log(1, t.protocol, "Couldn't read a TLS record: %s", err) return nil end -- Parse response. i, record = tls.record_read(buffer, 1) if record and record.type == "alert" and record.body[1].level == "warning" then ctx_log(1, t.protocol, "Ignoring warning: %s", record.body[1].description) -- Try again. elseif record then sock:close() return record end buffer = buffer:sub(i+1) end end local function keys(t) local ret = {} for k, _ in pairs(t) do ret[#ret+1] = k end return ret 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 -- Find which ciphers out of group are supported by the server. local function find_ciphers_group(host, port, protocol, group) local name, protocol_worked, record, results 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. protocol_worked = false while (next(group)) do t["ciphers"] = group record = try_params(host, port, t) if record == nil then if 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 elseif record["protocol"] ~= protocol then ctx_log(1, protocol, "Protocol rejected.") protocol_worked = nil break elseif record["type"] == "alert" and record["body"][1]["description"] == "handshake_failure" then protocol_worked = true ctx_log(2, protocol, "%d ciphers rejected.", #group) break elseif record["type"] ~= "handshake" or record["body"][1]["type"] ~= "server_hello" then ctx_log(2, protocol, "Unexpected record received.") break else protocol_worked = true name = record["body"][1]["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) 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 name, protocol_worked, results, chunk local ciphers = in_chunks(sorted_keys(tls.CIPHERS), CHUNK_SIZE) results = {} -- Try every cipher. for _, group in ipairs(ciphers) do chunk, protocol_worked = find_ciphers_group(host, port, protocol, group) if protocol_worked == nil then return nil end for _, name in ipairs(chunk) do table.insert(results, name) end end if not protocol_worked then return nil end return results end local function find_compressors(host, port, protocol, good_ciphers) local name, protocol_worked, record, results, t 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 results = {} -- Try every compressor. protocol_worked = false while (next(compressors)) do -- Create structure. t["compressors"] = compressors -- Try connecting with compressor. record = try_params(host, port, t) if record == nil then if protocol_worked then ctx_log(2, protocol, "%d compressors rejected. (No handshake)", #compressors) else ctx_log(1, protocol, "%d compressors and/or protocol %s rejected. (No handshake)", #compressors, protocol) end break elseif record["protocol"] ~= protocol then ctx_log(1, protocol, "Protocol rejected.") break elseif record["type"] == "alert" and record["body"][1]["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 elseif record["type"] ~= "handshake" or record["body"][1]["type"] ~= "server_hello" then ctx_log(2, protocol, "Unexpected record received.") break else protocol_worked = true name = record["body"][1]["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 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 record = try_params(host, port, t) if record and record["type"] == "handshake" and record["body"][1]["type"] == "server_hello" then ctx_log(2, protocol, "compare %s %s -> %s", cipher_a, cipher_b, record["body"][1]["cipher"]) return record["body"][1]["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 ciphers, compressors, results local condvar = nmap.condvar(upresults) results = stdnse.output_table() -- Find all valid ciphers. ciphers = 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. 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 if rankedciphersfilename and rankedciphers[name] then cipherstr=rankedciphers[name] else cipherstr="unknown strength" end stdnse.debug2("Strength of %s rated %d.",cipherstr,cipherstrength[cipherstr]) if mincipherstrength>cipherstrength[cipherstr] then stdnse.debug2("Downgrading min cipher strength to %d.",cipherstrength[cipherstr]) mincipherstrength=cipherstrength[cipherstr] end local outcipher = {name=name, strength=cipherstr} setmetatable(outcipher,{ __tostring=function(t) return string.format("%s - %s", t.name, t.strength) 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 upresults[protocol] = results condvar "signal" return nil end -- Shamelessly stolen from nselib/unpwdb.lua and changed a bit. (Gabriel Lawrence) local filltable = function(filename,table) if #table ~= 0 then return true end local file = io.open(filename, "r") if not file then return false end while true do local l = file:read() if not l then break end -- Comments takes up a whole line if not l:match("#!comment:") then local lsplit=stdnse.strsplit("%s+", l) if cipherstrength[lsplit[2]] then table[lsplit[1]] = lsplit[2] else stdnse.debug1("Strength not defined, ignoring: %s:%s",lsplit[1],lsplit[2]) end end end file:close() return true 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 = keys(t) table.sort(order) 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 name, result, results rankedciphersfilename=stdnse.get_script_args("ssl-enum-ciphers.rankedcipherlist") if rankedciphersfilename then filltable(rankedciphersfilename,rankedciphers) else rankedciphersfilename = nmap.fetchfile( "nselib/data/ssl-ciphers" ) stdnse.debug1("Ranked ciphers filename: %s", rankedciphersfilename) filltable(rankedciphersfilename,rankedciphers) end 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 #( keys(results) ) == 0 then return nil end if rankedciphersfilename then for k, v in pairs(cipherstrength) do if v == mincipherstrength then -- Should sort before or after SSLv3, TLSv* results["least strength"] = k end end end return sorted_by_key(results) end