425 lines
16 KiB
Lua
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
|
|
|