Nmap Development mailing list archives

[NSE] ASN made more robust and documented - much more to do.


From: jah <jah () zadkiel plus com>
Date: Sat, 16 Aug 2008 04:21:06 +0100

Hi folks,

Attached is a bit of an update to the version of asn.nse in the current
svn.  It was rather prone to failure and now it is less so.

Yesterday I began another version using Philip's dns library and
system_dns patch and while I was playing I noticed that a DNS response
contained two answers (and that the order in which they appear is
seemingly random).  At first, I thought this was some strangeness, but
this turns out not to be quite the case.

The answers to a query for the nmap.asn.cymru.com zone are one each from
what would be obtained by queries for both origin.asn.cymru and
peer.asn.cymru.com zones [1] and this fact prompted me to return to the
original version of the script and update the response decoding routine
to handle the extra answers.  Further, there's a pair of answers for
each BGP prefix in which the target resides which can result in four
answers (and perhaps more).  So once I'd handled the extraction of
multiple answers, I changed the output to suit (not perfectly, mind) and
the caching and cache checking code as well.

Whilst doing all of this, I noted down several challenges to be solved
and thus grew the sizeable TODO section in the script comments which you
can probably see below.  The upshot of all this rambling is that even
though this version of the script is better (it's no longer a throw of
the dice which determines the answer outputted - they all are), there's
a fair bit more to do to make it reliable and more
experimentation/testing needed.

Your thoughts on any of the TODO items much appreciated.

Example Output:

Host script results:
|  ASN: 4 records found.
|  ASN: 10565 | BGP: 64.13.128.0/18 | Country: US
|  ASN: 10565 | BGP: 64.13.128.0/21 | Country: US
|  ASN: 3561 6461 | BGP: 64.13.128.0/21 | Country: US
|_ ASN: 174 2914 6461 | BGP: 64.13.128.0/18 | Country: US


Regards,

jah

[1] http://www.team-cymru.org/Services/ip-to-asn.html#dns
id = "ASN"
description =
"This script performs IP address to Autonomous System Numbers (ASN) lookups.  It   \
sends DNS TXT queries to the recursive DNS server you provide which in turn        \
queries a 3rd party service provided by team-cymru.org using an in-addr.arpa       \
style zone set-up especially for Nmap (nmap.asn.cymru.com). The respnses to these  \
queries contain both Origin and Peer ASNs and these will be displayed along with   \
the BGP Prefix and Country Code.  The script caches results to reduce the number   \
of queries and should perform a single query for all scanned targets in            \
a BGP prefix present in Team Cymru's database.  Please be aware that any targets   \
for which a query is performed will be revealed to Team Cymru.                     \n\n\
Usage:                                                                             \n\n\
nmap <target> --script asn --script-args dns=<recursion_enabled_dns_server>"

---
-- @args dns Required. A recursive nameserver.
--
-- @output
-- Host script results:
-- \n|  ASN: 4 records found.
-- \n|  ASN: 38661 | BGP: 122.99.128.0/18 | Country: KR
-- \n|  ASN: 38661 | BGP: 122.99.144.0/20 | Country: KR
-- \n|  ASN: 3786 4766 9318 15412 | BGP: 122.99.128.0/18 | Country: KR
-- \n|_ ASN: 3786 4766 9318 15412 | BGP: 122.99.144.0/20 | Country: KR

--[[

TODO

Length of answers - use the length field or the length byte immediately after that field?
   aka what happens when the length of the answer > 256...
Combine origin and peer ASN numbers for each bgp and label accordingly - IF we can determine a
   difference between an origin answer and a peer answer (is there always only a single ASN
   in an origin answer? what about when there's only one peer - confirm using origin.asn...?).
Can we send multiple questions in a single packet?  Reliably?
Does cymru provide IPv6 lookups? Will they in the future?
Cache records and possibly miss information or do no caching and hammer the dns server?
Pointers for same records - troublesome.
Ordering of answers - why aren't they consistent? Is it cymru or caching server that reorders?
Lots of peers = untidy output.
Check for and report NXDOMAIN?
Random TXID?? Random src port??
Use better ipOps.lua functions instead of the shit ip_in_net and two_dwords functions.
Use dns.lua instead of most of this script!

--]]

author = "jah, Michael"
license = "Same as Nmap--See http://nmap.org/book/man-legal.html";
categories = {"discovery"}
runlevel = 1

local comm  = require "comm"
local ipOps = require "ipOps"

if not nmap.registry.asn then
  nmap.registry.asn = {}
  nmap.registry.asn.cache = {}
end

local mutex = nmap.mutex( id )


---
-- This script will run for any non-private IPv4 address only if the required arguments are
-- supplied on the Nmap command line.

hostrule = function( host )
  if not nmap.registry.args.dns then return false end
  if host.ip:match( ":" ) then return false end
  return ( not ipOps.isPrivate( host.ip ) )
end


---
-- Cached results are checked before sending a query for the target and extracting the
-- relevent information from the response.  Mutual exclusion is used so that results can be
-- cached and so a single thread will be active at any time.

action = function( host )

  mutex "lock"

  -- get dns server.
  local dns_server = nmap.registry.args.dns

  -- check for cached data
  local in_cache, output = check_cache( host.ip )

  local query, data
  if not in_cache then
    -- constuct dns query
    query, data = make_query( host.ip )

    -- send dns query
    local result = send_query( data, dns_server )

    -- decode response
    local status
    if result then
      status, output = pcall( decode_result, result, query )
    end

    if not status then output = nil end

  end

  mutex "done"

  if type( output ) ~= "table" or #output == 0 then return nil end

  -- return result
  return ("%s records found.\n%s"):format( #output, table.concat( output, "\n" ) )

end




---
-- Checks whether the target IP address is within any BGP prefixes for which a query has
-- already been performed and returns any applicable answers.
-- @param ip String representing the target IP address.
-- @return   Boolean True if there are cached answers for the supplied target, otherwise
--           false.
-- @return   Table containing a string for each answer or nil if there are none.

function check_cache( ip )
  local output = {}
  for _, cache_entry in ipairs( nmap.registry.asn.cache ) do
    if ip_in_net( ip, cache_entry.range ) then
      output[#output+1] = cache_entry.output
    end
  end
  if #output > 0 then return true, output end
  return false, nil
end


---
-- Constructs a PTR-like TXT DNS query packet for the supplied IP address.
-- Both the entire packet and the Query part are returned.  The Query part may then be
-- searched for in the DNS response.
-- @param ip String representing the target IP address.
-- @return   String representing the DNS query section.
-- @return   String packet ready for transmission to a DNS service.

function make_query( ip )

  local t = {}
  t[4], t[3], t[2], t[1] = ip:match( "(%d+)%.(%d+)%.(%d+)%.(%d+)" )
  local tsoh = labels( t )
  local z = { "nmap", "asn", "cymru", "com" }
  local zone = labels( z )
  local t_id = string.char( tonumber( t[2] ), tonumber( t[3] ) ) -- not at all random...
  local dns_std = string.char( 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 )
  local null_char = string.char( 0x00 )
  local qtype = string.char( 0x00, 0x10 )
  local qclass = string.char( 0x00, 0x01 )
  local query = tsoh .. zone .. null_char .. qtype .. qclass
  return query, t_id .. dns_std .. query

end


---
-- Sends the supplied DNS query data to the supplied DNS service by UDP port 53.
-- @param dns_query String DNS TXT IN data.
-- @param dns_srv   String representing an IP address or host name of a recursive DNS server.
-- @return          String received in response to the DNS query or nil.

function send_query( dns_query, dns_srv )

  local catch = function() mutex "done" end
  local try = nmap.new_try( catch )
  local options = {}
  options.proto = "udp"
  options.lines = 1
  options.timeout = 1000
  local result, err = try( comm.exchange( dns_srv, 53, dns_query, options ) )
  --if not status then error() end
  return result

end


---
-- Extracts any DNS answer sections, relating to the supplied DNS query, from the supplied
-- DNS response string.  The answers are cached in the registry before being returned.
-- @param result String DNS response.
-- @param query  String DNS Query section used to locate relevent answers in the response.
-- @return       Table containing a printable string for each answer in the response or nil in
--               the case of an error.

function decode_result( result, query )

  -- read result
  local answers = {}
  local start, offset = string.find( result, query )

  -- extract one or more queries
  while start do
    -- From offset+1 we should have:
    -- C00C pointer - 2 bytes
    -- Type         - 2 bytes
    -- Class        - 2 bytes
    -- TTL          - 4 bytes
    -- Length       - 2 bytes
    -- Answer       - Length bytes
    -- The first byte of the answer is actually the length of the rest of the answer (Length - 1) what happens if 
length > 256
    offset = offset+1 + 12
    local _, len = bin.unpack( "H", string.sub( result, offset, offset ) )
    len = tonumber( len, 16 )
    offset = offset + 1 -- start of the answer
    answers[#answers+1] = string.sub( result, offset, offset + len -1 )
    offset = offset + len -1

    -- another answer?
    local _, cooc = bin.unpack( "H2", string.sub( result, offset+1, offset+2 ) )
    if cooc:lower() ~= "c00c" then break end

  end

  if #answers == 0 then return nil end

  local output = {}
  for _, answer in ipairs( answers ) do
    local record = {}
    fields = { answer:match( ("([^|]*)|" ):rep(3) ) }
    record.bgp = fields[2]:gsub( "^%s*(.-)%s*$", "%1" )
    fields[1] = "ASN: "    .. fields[1]
    fields[2] = "BGP:"     .. fields[2]
    fields[3] = "Country:" .. fields[3]
    record.output = table.concat( fields, "| " )
    output[#output+1] = record.output
    -- cache result
    table.insert( nmap.registry.asn.cache, { range = record.bgp, output = record.output } )
  end

  return output

end


---
-- Given a table of strings, returns a string made up of concateneted labels where each label
-- consists of a length value (cast as char) followed by that number of characters.
-- @param t Array style Table containing strings.
-- @return  String.

function labels( t )
 local ret = ""
 for _, v in ipairs(t) do
     ret = ret .. string.char( string.len(v) ) .. v
 end
 return ret
end


---
-- Checks whether the supplied IP address is within the supplied range of IP addresses.
-- @param ip  String representing an IPv4 address.
-- @param net String representing a range of IPv4 addresses in either A-B or CIDR notation.
-- @return    Boolean True if the supplied ip address falls inside the supplied range,
--            otherwise false.

function ip_in_net(ip, net)
 local i, j, net_lo, net_hi, dw_ip
 local m_dotted = "(%d+%.%d+%.%d+%.%d+)[%s]*[-][%s]*(%d+%.%d+%.%d+%.%d+)"
 local m_cidr = "(%d+)[.]*(%d*)[.]*(%d*)[.]*(%d*)[/]+(%d+)"

 if net:match(m_dotted) then
   net_lo, net_hi = net:match(m_dotted)
   net_lo = ipOps.todword(net_lo)
   net_hi = ipOps.todword(net_hi)
 elseif net:match(m_cidr) then
   net_lo, net_hi = two_dwords(net)
 end

 dw_ip = ipOps.todword(ip)
 if net_lo <= dw_ip  and dw_ip <= net_hi then return true end
 return false
end


---
-- Calculates the last IP address from the supplied CIDR range and returns the first and last
-- IP addresses in that range as DWORDS.
-- @param cidr String representing an IPv4 address range in CIDR notation.
-- @return     Number representing the first IP address in the supplied range.
-- @return     Number representing the last IP address in the supplied range.

function two_dwords(cidr)
 local patt = "(%d+)[.]*(%d*)[.]*(%d*)[.]*(%d*)[/]+(%d+)"
 local a, b, c, d, e, lo_net, host
 a, b, c, d, e = cidr:match(patt)
 local ipt = {b, c, d}
 local strip = ""
 for _, cap in ipairs(ipt) do
   if cap == "" then cap = "0" end
   strip = strip .. "." .. cap
 end
 lo_net = a .. strip
 if e ~= "" then e = tonumber(e)
   if e and e <=32 then
     host = 32 - e end
 end
 return ipOps.todword(lo_net), ipOps.todword(lo_net) + 2^host - 1
end

_______________________________________________
Sent through the nmap-dev mailing list
http://cgi.insecure.org/mailman/listinfo/nmap-dev
Archived at http://SecLists.Org

Current thread: