425 lines
16 KiB
Lua

local ipOps = require "ipOps"
local nmap = require "nmap"
local shortport = require "shortport"
local ssh1 = require "ssh1"
local ssh2 = require "ssh2"
local stdnse = require "stdnse"
local string = require "string"
local stringaux = require "stringaux"
local table = require "table"
local tableaux = require "tableaux"
local base64 = require "base64"
local comm = require "comm"
local openssl = stdnse.silent_require "openssl"
description = [[
Shows SSH hostkeys.
Shows the target SSH server's key fingerprint and (with high enough
verbosity level) the public key itself. It records the discovered host keys
in <code>nmap.registry</code> for use by other scripts. Output can be
controlled with the <code>ssh_hostkey</code> script argument.
You may also compare the retrieved key with the keys in your known-hosts
file using the <code>known-hosts</code> argument.
The script also includes a postrule that check for duplicate hosts using the
gathered keys.
]]
---
--@usage
-- nmap host --script ssh-hostkey --script-args ssh_hostkey=full
-- nmap host --script ssh-hostkey --script-args ssh_hostkey=all
-- nmap host --script ssh-hostkey --script-args ssh_hostkey='visual bubble'
--
--@args ssh_hostkey Controls the output format of keys. Multiple values may be
-- given, separated by spaces. Possible values are
-- * <code>"full"</code>: The entire key, not just the fingerprint.
-- * <code>"sha256"</code>: Base64-encoded SHA256 fingerprint.
-- * <code>"md5"</code>: hex-encoded MD5 fingerprint (the default).
-- * <code>"bubble"</code>: Bubble Babble output,
-- * <code>"visual"</code>: Visual ASCII art representation.
-- * <code>"all"</code>: All of the above.
-- @args ssh-hostkey.known-hosts If this is set, the script will check if the
-- known hosts file contains a key for the host being scanned and will compare
-- it with the keys that have been found by the script. The script will try to
-- detect your known-hosts file but you can, optionally, pass the path of the
-- file to this option.
--
-- @args ssh-hostkey.known-hosts-path. Path to a known_hosts file.
--@output
-- 22/tcp open ssh
-- | ssh-hostkey: 2048 f0:58:ce:f4:aa:a4:59:1c:8e:dd:4d:07:44:c8:25:11 (RSA)
-- 22/tcp open ssh
-- | ssh-hostkey: 2048 f0:58:ce:f4:aa:a4:59:1c:8e:dd:4d:07:44:c8:25:11 (RSA)
-- | +--[ RSA 2048]----+
-- | | .E*+ |
-- | | oo |
-- | | . o . |
-- | | O . . |
-- | | o S o . |
-- | | = o + . |
-- | | . * o . |
-- | | = . |
-- | | o . |
-- |_ +-----------------+
-- 22/tcp open ssh syn-ack
-- | ssh-hostkey: Key comparison with known_hosts file:
-- | GOOD Matches in known_hosts file:
-- | L7: 199.19.117.60
-- | L11: foo
-- | L15: bar
-- | L19: <unknown>
-- | WRONG Matches in known_hosts file:
-- | L3: 199.19.117.60
-- | ssh-hostkey: 2048 xuvah-degyp-nabus-zegah-hebur-nopig-bubig-difeg-hisym-rumef-cuxex (RSA)
-- |_ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAwVuv2gcr0maaKQ69VVIEv2ob4OxnuI64fkeOnCXD1lUx5tTA+vefXUWEMxgMuA7iX4irJHy2zer0NQ3Z3yJvr5scPgTYIaEOp5Uo/eGFG9Agpk5wE8CoF0e47iCAPHqzlmP2V7aNURLMODb3jVZuI07A2ZRrMGrD8d888E2ORVORv1rYeTYCqcMMoVFmX9l3gWEdk4yx3w5sD8v501Iuyd1v19mPfyhrI5E1E1nl/Xjp5N0/xP2GUBrdkDMxKaxqTPMie/f0dXBUPQQN697a5q+5lBRPhKYOtn6yQKCd9s1Q22nxn72Jmi1RzbMyYJ52FosDT755Qmb46GLrDMaZMQ==
--
--@output
-- Post-scan script results:
-- | ssh-hostkey: Possible duplicate hosts
-- | Key 1024 60:ac:4d:51:b1:cd:85:09:12:16:92:76:1d:5d:27:6e (DSA) used by:
-- | 192.168.1.1
-- | 192.168.1.2
-- | Key 2048 2c:22:75:60:4b:c3:3b:18:a2:97:2c:96:7e:28:dc:dd (RSA) used by:
-- | 192.168.1.1
-- |_ 192.168.1.2
--
--@xmloutput
-- <table>
-- <elem key="key">ssh-dss AAAAB3NzaC1kc3MAAACBANraqxAILTygMTgFu/0snrJck8BkhOpBbN61DAZENgeulLMaJdmNFWZpvhLOJVXSqHt2TCrspbMyvpBH4Fnv7Kb+QBAhXyzeCNnOQ7OVBfqNzkfezoFrQJgOQZSEenP6sCVDqcW2j0KVumnYdPU7FGa8SLfNqA+hUOR2HSSluynFAAAAFQDWKNq4PVbpDA7UExE8JSHnWxv4AwAAAIAWEDdNu5mWfTz52IdxELNjsmn5FvKRmnhPqq/PrTkYqAADL5WYazg7POQZ4yI2nqTq++47ONDK87Wke3qbeIhMrV13Mrgf2JuCUSNqrfEmvzZ2l9x3QyZrj+bJRPRuhwYq8rFup01qaANJ0p4WS/7voNbRhh+l57FkJF+XAJRRTAAAAIEAts1Se+u+hV9ZedXopzfXv1I5ZOSONxZanM10wjM2GRWygCYsHqDM315swBPkzhmB73oBesnhDW3bq0dmW3wvk4gzQZ2E2SHhzVGjlgDpjEahlQ+XGpDZsvqqFGGGx8lvKYFUxBR+UkqMRGmjkHw5sK5ydO1n4R3XJ4FfQFqmoyU=</elem>
-- <elem key="bits">1024</elem>
-- <elem key="fingerprint">18782fd3be7178a38e584b5a83bd60a8</elem>
-- <elem key="type">ssh-dss</elem>
-- </table>
-- <table>
-- <elem key="key">ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAwVuv2gcr0maaKQ69VVIEv2ob4OxnuI64fkeOnCXD1lUx5tTA+vefXUWEMxgMuA7iX4irJHy2zer0NQ3Z3yJvr5scPgTYIaEOp5Uo/eGFG9Agpk5wE8CoF0e47iCAPHqzlmP2V7aNURLMODb3jVZuI07A2ZRrMGrD8d888E2ORVORv1rYeTYCqcMMoVFmX9l3gWEdk4yx3w5sD8v501Iuyd1v19mPfyhrI5E1E1nl/Xjp5N0/xP2GUBrdkDMxKaxqTPMie/f0dXBUPQQN697a5q+5lBRPhKYOtn6yQKCd9s1Q22nxn72Jmi1RzbMyYJ52FosDT755Qmb46GLrDMaZMQ==</elem>
-- <elem key="bits">2048</elem>
-- <elem key="fingerprint">f058cef4aaa4591c8edd4d0744c82511</elem>
-- <elem key="type">ssh-rsa</elem>
-- </table>
-- <table key="Key comparison with known_hosts file">
-- <table key="GOOD Matches in known_hosts file">
-- <table>
-- <elem key="lnumber">5</elem>
-- <elem key="name">localhost</elem>
-- <elem key="key">ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAwVuv2gcr0maaKQ69VVIEv2ob4OxnuI64fkeOnCXD1lUx5tTA+vefXUWEMxgMuA7iX4irJHy2zer0NQ3Z3yJvr5scPgTYIaEOp5Uo/eGFG9Agpk5wE8CoF0e47iCAPHqzlmP2V7aNURLMODb3jVZuI07A2ZRrMGrD8d888E2ORVORv1rYeTYCqcMMoVFmX9l3gWEdk4yx3w5sD8v501Iuyd1v19mPfyhrI5E1E1nl/Xjp5N0/xP2GUBrdkDMxKaxqTPMie/f0dXBUPQQN697a5q+5lBRPhKYOtn6yQKCd9s1Q22nxn72Jmi1RzbMyYJ52FosDT755Qmb46GLrDMaZMQ==</elem>
-- </table>
-- </table>
-- </table>
--
--@xmloutput
-- <table>
-- <table key="hosts">
-- <elem>192.168.1.1</elem>
-- <elem>192.168.1.2</elem>
-- </table>
-- <table key="key">
-- <elem key="fingerprint">2c2275604bc33b18a2972c967e28dcdd</elem>
-- <elem key="bits">2048</elem>
-- <elem key="type">ssh-rsa</elem>
-- </table>
-- </table>
-- <table>
-- <table key="hosts">
-- <elem>192.168.1.1</elem>
-- <elem>192.168.1.2</elem>
-- </table>
-- <table key="key">
-- <elem key="fingerprint">60ac4d51b1cd8509121692761d5d276e</elem>
-- <elem key="bits">1024</elem>
-- <elem key="type">ssh-dss</elem>
-- </table>
-- </table>
author = {"Sven Klemm", "Piotr Olma", "George Chatzisofroniou"}
license = "Same as Nmap--See https://nmap.org/book/man-legal.html"
categories = {"safe","default","discovery"}
portrule = shortport.ssh
postrule = function() return (nmap.registry.sshhostkey ~= nil) end
--- put hostkey in the nmap registry for usage by other scripts
--@param host nmap host table
--@param key host key table
local add_key_to_registry = function( host, key )
nmap.registry.sshhostkey = nmap.registry.sshhostkey or {}
nmap.registry.sshhostkey[host.ip] = nmap.registry.sshhostkey[host.ip] or {}
table.insert( nmap.registry.sshhostkey[host.ip], key )
end
--- check if there is a key in known_hosts file for the host that's being scanned
--- and if there is, compare the keys
local function check_keys(host, keys, f)
local keys_found = {}
for _,k in ipairs(keys) do
table.insert(keys_found, k.full_key)
end
local keys_from_file = {}
local same_key, same_key_hashed = {}, {}
local hostname = host.name == "" and nil or host.name
local possible_host_names = {hostname or nil, host.ip or nil, (hostname and host.ip) and ("%s,%s"):format(hostname, host.ip) or nil}
for _p, parts in ipairs(f) do
local lnumber = parts.linenumber
parts = parts.entry
local foundhostname = false
if #parts >= 3 then
-- the line might be hashed
if string.match(parts[1], "^|") then
-- split the first part of the line - it contains base64'ed salt and hashed hostname
local parts_hostname = stringaux.strsplit("|", parts[1])
if #parts_hostname == 4 then
-- check if the hash corresponds to the host being scanned
local salt = base64.dec(parts_hostname[3])
for _,name in ipairs(possible_host_names) do
local hash = base64.enc(openssl.hmac("SHA1", salt, name))
if parts_hostname[4] == hash then
stdnse.debug2("found a hash that matches: %s for hostname: %s", hash, name)
foundhostname = true
table.insert(keys_from_file, {name=name, key=("%s %s"):format(parts[2], parts[3]), lnumber=lnumber})
end
end
-- Is the key the same but the hashed hostname isn't?
if not foundhostname then
for _, k in ipairs(keys_found) do
if ("%s %s"):format(parts[2], parts[3]) == k then
table.insert(same_key_hashed, {name="<unknown>", key=k, lnumber = lnumber})
end
end
end
end
else
if tableaux.contains(possible_host_names, parts[1]) then
stdnse.debug2("Found an entry that matches: %s", parts[1])
table.insert(keys_from_file, ("%s %s"):format(parts[2], parts[3]))
else
-- Is the key the same but the clear text hostname isn't?
for _, k in ipairs(keys_found) do
if ("%s %s"):format(parts[2], parts[3]) == k then
table.insert(same_key, {name=parts[1], key=("%s %s"):format(parts[2], parts[3]), lnumber=lnumber})
end
end
end
end
end
end
local matched_keys, different_keys = {}, {}
local matched
-- Compare the keys found for this hostname and update the counts.
for _,k in ipairs(keys_from_file) do
matched = false
for __,l in ipairs(keys_found) do
if l == k.key then
table.insert(matched_keys, k)
matched = true
end
end
if not matched then
table.insert(different_keys, k)
end
end
-- Start making output.
local out
if #keys_from_file == 0 then
out = "No entry for scanned host found in known_hosts file."
else
out = stdnse.output_table()
local match_mt = {
__tostring = function(self)
return string.format("L%d: %s", self.lnumber, self.name)
end
}
local good = {}
for __, gm in ipairs(matched_keys) do
setmetatable(gm, match_mt)
good[#good+1] = gm
end
for __, gm in ipairs(same_key) do
setmetatable(gm, match_mt)
good[#good+1] = gm
end
for __, gm in ipairs(same_key_hashed) do
setmetatable(gm, match_mt)
good[#good+1] = gm
end
if #good > 0 then
out["GOOD Matches in known_hosts file"] = good
end
local wrong = {}
for __, gm in ipairs(different_keys) do
setmetatable(gm, match_mt)
wrong[#wrong+1] = gm
end
if #wrong > 0 then
out["WRONG Matches in known_hosts file"] = wrong
end
end
return out
end
--- gather host keys
--@param host nmap host table
--@param port nmap port table of the currently probed port
local function portaction(host, port)
if port.version.name_confidence < 8 or port.version.name ~= "ssh" then
-- additional check if version scan was not done or if it doesn't think it's SSH.
-- Since the fetch_host_key functions don't indicate what failed, we could
-- waste a lot of time on e.g. tcpwrapped port 22
-- Using opencon instead of get_banner to avoid trying SSL first in some cases
local status, banner = comm.opencon(host, port, nil, {recv_before=true})
if not string.match(banner, "^SSH") then
stdnse.debug1("Service does not appear to be SSH: quitting.")
return nil
end
end
local output_tab = {}
local keys = {}
local key
local format = nmap.registry.args.ssh_hostkey or "md5"
local format_bits = {
md5 = 1,
hex = 1, -- compatibility alias for md5
sha256 = 1 << 1,
bubble = 1 << 2,
visual = 1 << 3,
full = 1 << 4,
all = 0xffff,
}
local format_mask = 0
for word in format:gmatch("%w+") do
format_mask = format_mask | (format_bits[word] or 0)
end
key = ssh1.fetch_host_key( host, port )
if key then table.insert( keys, key ) end
key = ssh2.fetch_host_key( host, port, "ssh-dss" )
if key then table.insert( keys, key ) end
key = ssh2.fetch_host_key( host, port, "ssh-rsa" )
if key then table.insert( keys, key ) end
key = ssh2.fetch_host_key( host, port, "ecdsa-sha2-nistp256" )
if key then table.insert( keys, key ) end
key = ssh2.fetch_host_key( host, port, "ecdsa-sha2-nistp384" )
if key then table.insert( keys, key ) end
key = ssh2.fetch_host_key( host, port, "ecdsa-sha2-nistp521" )
if key then table.insert( keys, key ) end
key = ssh2.fetch_host_key( host, port, "ssh-ed25519" )
if key then table.insert( keys, key ) end
if #keys == 0 then
return nil
end
for _, key in ipairs( keys ) do
add_key_to_registry( host, key )
local output = {}
local out = {
fingerprint=stdnse.tohex(key.fingerprint),
type=key.key_type,
bits=key.bits,
key=key.key,
}
if format_mask & format_bits.md5 ~= 0 then
table.insert( output, ssh1.fingerprint_hex( key.fingerprint, key.algorithm, key.bits ) )
end
if format_mask & format_bits.sha256 ~= 0 then
table.insert( output, ssh1.fingerprint_base64( key.fp_sha256, "SHA256", key.algorithm, key.bits ) )
end
if format_mask & format_bits.bubble ~= 0 then
table.insert( output, ssh1.fingerprint_bubblebabble( openssl.sha1(key.fp_input), key.algorithm, key.bits ) )
end
if format_mask & format_bits.visual ~= 0 then
table.insert( output, ssh1.fingerprint_visual( key.fingerprint, key.algorithm, key.bits ) )
end
if nmap.verbosity() > 1 or format_mask & format_bits.full ~= 0 then
table.insert( output, key.full_key )
end
setmetatable(out, {
__tostring = function(self)
return table.concat(output, "\n")
end
})
table.insert(output_tab, out)
end
-- if a known_hosts file was given, then check if it contains a key for the host being scanned
local known_hosts = stdnse.get_script_args("ssh-hostkey.known-hosts") or false
if known_hosts then
known_hosts = ssh1.parse_known_hosts_file(known_hosts)
output_tab["Key comparison with known_hosts file"] = check_keys(
host, keys, known_hosts)
end
return output_tab
end
--- iterate over the list of gathered keys and look for duplicate hosts (sharing the same hostkeys)
local function postaction()
local hostkeys = {}
local output = {}
local output_tab = {}
local revmap = {}
-- create a reverse mapping key_fingerprint -> host(s)
for ip, keys in pairs(nmap.registry.sshhostkey) do
for _, key in ipairs(keys) do
local fp = ssh1.fingerprint_hex(key.fingerprint, key.algorithm, key.bits)
if not hostkeys[fp] then
hostkeys[fp] = {}
revmap[fp] = {
fingerprint=stdnse.tohex(key.fingerprint,{separator=":"}),
type=key.key_type,
bits=key.bits
}
end
-- discard duplicate IPs
if not tableaux.contains(hostkeys[fp], ip) then
table.insert(hostkeys[fp], ip)
end
end
end
-- look for hosts using the same hostkey
for key, hosts in pairs(hostkeys) do
if #hostkeys[key] > 1 then
table.sort(hostkeys[key], function(a, b) return ipOps.compare_ip(a, "lt", b) end)
local str = {'Key ' .. key .. ' used by:'}
local tab = {key=revmap[key], hosts={}}
for _, host in ipairs(hostkeys[key]) do
str[#str+1] = host
table.insert(tab.hosts, host)
end
table.insert(output, table.concat(str, "\n "))
table.insert(output_tab, tab)
end
end
if #output > 0 then
return output_tab, 'Possible duplicate hosts\n' .. table.concat(output, '\n')
end
end
local ActionsTable = {
-- portrule: retrieve ssh hostkey
portrule = portaction,
-- postrule: look for duplicate hosts (same hostkey)
postrule = postaction
}
-- execute the action function corresponding to the current rule
action = function(...) return ActionsTable[SCRIPT_TYPE](...) end