1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
|
local dns = require "dns"
local ipOps = require "ipOps"
local nmap = require "nmap"
local stdnse = require "stdnse"
local string = require "string"
local table = require "table"
description = [[
Maps IP addresses to autonomous system (AS) numbers.
The script works by sending DNS TXT queries to a DNS server which in
turn queries a third-party service provided by Team Cymru
(https://www.team-cymru.org/Services/ip-to-asn.html) using an in-addr.arpa
style zone set up especially for
use by Nmap. The responses to these queries contain both Origin and Peer
ASNs and their descriptions, 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.
Be aware that any targets against which this script is run will be sent
to and potentially recorded by one or more DNS servers and Team Cymru.
In addition your IP address will be sent along with the ASN to a DNS
server (your default DNS server, or whichever one you specified with the
<code>dns</code> script argument).
]]
---
-- @usage
-- nmap --script asn-query [--script-args dns=<DNS server>] <target>
-- @args dns The address of a recursive nameserver to use (optional).
-- @output
-- Host script results:
-- | asn-query:
-- | BGP: 64.13.128.0/21 | Country: US
-- | Origin AS: 10565 SVCOLO-AS - Silicon Valley Colocation, Inc.
-- | Peer AS: 3561 6461
-- | BGP: 64.13.128.0/18 | Country: US
-- | Origin AS: 10565 SVCOLO-AS - Silicon Valley Colocation, Inc.
-- |_ Peer AS: 174 2914 6461
author = {"jah", "Michael Pattrick"}
license = "Same as Nmap--See https://nmap.org/book/man-legal.html"
categories = {"discovery", "external", "safe"}
local mutex = nmap.mutex( "ASN" )
if not nmap.registry.asn then
nmap.registry.asn = {}
nmap.registry.asn.cache = {}
nmap.registry.asn.descr = {}
end
---
-- This script will run for any non-private IP address.
hostrule = function( host )
return not ipOps.isPrivate( host.ip )
end
---
-- Cached results are checked before sending a query for the target and extracting the
-- relevant 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.
-- @param host Host table.
-- @return Formatted answers or <code>nil</code> on errors.
action = function( host )
mutex "lock"
local output, records, combined_records = {}, {}, {}
-- check for cached data
local in_cache, cache_data = check_cache( host.ip )
if in_cache and type( cache_data ) == "table" then
combined_records = cache_data
elseif in_cache and type( cache_data ) == "string" and cache_data ~= "Unknown Error" then
output = cache_data
end
if not in_cache then
local dname = dns.reverse( host.ip )
local zone_repl, IPv = "%.in%-addr%.arpa", 4
if host.ip:match( ":" ) then
zone_repl, IPv = "%.ip6%.arpa", 6
end
---
-- Team Cymru zones for rDNS-like queries. The zones are as follows:
-- * nmap.asn.cymru.com for IPv4 to Origin AS lookup.
-- * peer-nmap.asn.cymru.com for IPv4 to Peer AS lookup.
-- * nmap6.asn.cymru.com for IPv6 to Origin AS lookup.
-- @class table
-- @name cymru
local cymru = { [4] = { ".nmap.asn.cymru.com", ".peer-nmap.asn.cymru.com" },
[6] = { ".nmap6.asn.cymru.com" }
}
-- perform queries for each applicable zone
for _, zone in ipairs( cymru[IPv] ) do
local asn_type = ( zone:match( "peer" ) and "Peer" ) or "Origin"
-- replace arpa with cymru zone
local temp = dname
dname = dname:gsub( zone_repl, zone )
-- send query
local success, response = ip_to_asn( dname )
if not success then
records = {}
output = ( type( response ) == "string" and response ) or output
break
end
-- recognize and organise fields from response
local success = result_recog( response, asn_type, records, host.ip )
-- un-replace arpa zone
dname = temp
end
-- combine records into unique BGP, cache and format for output
combined_records = process_answers( records, output, host.ip )
end -- if not in_cache
mutex "done"
return nice_output( output, combined_records )
end -- action
---
-- Checks whether the target IP address is within any BGP prefixes for which a query has
-- already been performed and returns a pointer to the HOST SCRIPT RESULT displaying the 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 <code>nil</code> if there are none.
function check_cache( ip )
local ret = {}
-- collect any applicable answers
for _, cache_entry in ipairs( nmap.registry.asn.cache ) do
if ipOps.ip_in_range( ip, cache_entry.cache_bgp ) then
ret[#ret+1] = cache_entry
end
end
if #ret < 1 then return false, nil end
-- /0 signals that we want to kill this thread (all threads in fact)
if #ret == 1 and type( ret[1].cache_bgp ) == "string" and ret[1].cache_bgp:match( "/0" ) then return true, nil end
-- should return pointer unless there are more than one unique pointer
local dirty, last_ip = false
for _, entry in ipairs( ret ) do
if last_ip and last_ip ~= entry.pointer then
dirty = true; break
end
last_ip = entry.pointer
end
if not dirty then
return true, ( "See the result for %s" ):format( last_ip )
else
return true, ret
end
return false, nil
end
---
-- Performs an IP address to ASN lookup. See http://www.team-cymru.org/Services/ip-to-asn.html#dns.
-- @param query String - PTR-like DNS query.
-- @return Boolean true for a successful DNS query resulting in an answer, otherwise false.
-- @return Table of answers or a string error message.
function ip_to_asn( query )
if type( query ) ~= "string" or query == "" then
return false, nil
end
-- error codes from dns.lua that we want to display.
local err_code = {}
err_code[3] = "No Such Name"
-- dns query options
local options = {}
options.dtype = "TXT"
options.retAll = true
options.sendCount = 1
if type( nmap.registry.args.dns ) == "string" and nmap.registry.args.dns ~= "" then
options.host = nmap.registry.args.dns
options.port = 53
end
-- send the query
local status, decoded_response = dns.query( query, options)
if not status then
stdnse.debug1("Error from dns.query(): %s", decoded_response )
end
return status, decoded_response
end
---
-- Extracts fields from the supplied DNS answer sections and generates a records entry for each.
-- @param answers Table containing string DNS answers.
-- @param asn_type String denoting whether the query is for Origin or Peer ASN.
-- @param recs Table of existing recognized answers to which to add (refer to the <code>records</code> table inside <code>action</code>.
-- @return Boolean true if successful otherwise false.
function result_recog( answers, asn_type, recs, discoverer_ip )
if type( answers ) ~= "table" or #answers == 0 then return false end
for _, answer in ipairs( answers ) do
local t = {}
t.pointer = discoverer_ip
-- break the answer up into fields and strip whitespace
local fields = { answer:match( ("([^|]*)|" ):rep(3) ) }
for i, field in ipairs( fields ) do
fields[i] = field:gsub( "^%s*(.-)%s*$", "%1" )
end
-- assign fields with labels to table
t.cache_bgp = fields[2]
t.asn_type = asn_type
t.asn = { asn_type .. " AS: " .. fields[1] }
t.bgp = "BGP: " .. fields[2]
if fields[3] ~= "" then t.co = "Country: " .. fields[3] end
recs[#recs+1] = t
-- lookup AS descriptions for Origin AS numbers
local asn_descr = nmap.registry.asn.descr
local u = {}
if asn_type == "Origin" then
for num in fields[1]:gmatch( "%d+" ) do
if not asn_descr[num] then
asn_descr[num] = asn_description( num )
end
u[#u+1] = ( "%s AS: %s%s%s" ):format( asn_type, num, ( asn_descr[num] ~= "" and " - " ) or "", asn_descr[num] )
end
t.asn = { table.concat(u, "\n " ) }
end
end
return true
end
---
-- Performs an AS Number to AS Description lookup.
-- @param asn String AS number.
-- @return String description or <code>""</code>.
function asn_description( asn )
if type( asn ) ~= "string" or asn == "" then
return ""
end
-- dns query options
local options = {}
options.dtype = "TXT"
options.sendCount = 1
if type( nmap.registry.args.dns ) == "string" and nmap.registry.args.dns ~= "" then
options.host = nmap.registry.args.dns
options.port = 53
end
-- send query
local query = ( "AS%s.asn.cymru.com" ):format( asn )
local status, decoded_response = dns.query( query, options )
if not status then
return ""
end
return decoded_response:match( "|%s*([^|$]+)%s*$" ) or ""
end
---
-- Processes records which are recognized DNS answers by combining them into unique BGPs before caching
-- them in the registry and returning <code>combined_records</code>. If there aren't any records (No Such Name message
-- or DNS failure) we signal this fact to other threads by using the cache and return with an empty table.
-- @param records Table of recognized answers (may be empty).
-- @param output String non-answer message or an empty table.
-- @param ip String <code>host.ip</code>.
-- @return Table containing combined records for the target (or an empty table).
function process_answers( records, output, ip )
local combined_records = {}
-- if records empty and no error message (output) then assume catastrophic dns failure and have all threads fail without trying.
if #records == 0 and type( output ) ~= "string" then
nmap.registry.asn.cache = { {["cache_bgp"] = "0/0"}, {["cache_bgp"] = "::/0"} }
return {}
end
if #records == 0 and type( output ) == "string" then
table.insert( nmap.registry.asn.cache, { ["pointer"] = ip, ["cache_bgp"] = get_assignment( ip, ( ip:match(":") and 48 ) or 29 ) } )
return {}
end
if type( records ) ~= "table" or #records == 0 then
return {}
end
-- combine fields for unique BGP
for _, record in ipairs( records ) do
if not combined_records[record.cache_bgp] then
combined_records[record.cache_bgp] = record
elseif combined_records[record.cache_bgp].asn_type ~= record.asn_type then
-- origin before peer.
if record.asn_type == "Origin" then
combined_records[record.cache_bgp].asn = { table.unpack( record.asn ), table.unpack( combined_records[record.cache_bgp].asn ) }
else
combined_records[record.cache_bgp].asn = { table.unpack( combined_records[record.cache_bgp].asn ), table.unpack( record.asn ) }
end
end
end
-- cache combined records
for _, rec in pairs( combined_records ) do
table.insert( nmap.registry.asn.cache, rec )
end
return combined_records
end
---
-- Calculates the prefix length for the given IP address range.
-- @param range String representing an IP address range.
-- @return Number - prefix length of the range.
function get_prefix_length( range )
if type( range ) ~= "string" or range == "" then return nil end
local first, last, err = ipOps.get_ips_from_range( range )
if err then return nil end
first = ipOps.ip_to_bin(first)
last = ipOps.ip_to_bin(last)
for pos = 1, #first do
if first:byte(pos) ~= last:byte(pos) then
return pos - 1
end
end
return #first
end
---
-- Given an IP address and a prefix length, returns a string representing a
-- valid IP address assignment (size is not checked) which contains the
-- supplied IP address. For example, with
-- <code>ip</code> = <code>"192.168.1.187"</code> and
-- <code>prefix</code> = <code>24</code> the return value will be
-- <code>"192.168.1.1-192.168.1.255"</code>
-- @param ip String representing an IP address.
-- @param prefix String or number representing a prefix length. Should be of the same address family as <code>ip</code>.
-- @return String representing a range of addresses from the first to the last hosts (or <code>nil</code> in case of an error).
-- @return <code>nil</code> or error message in case of an error.
function get_assignment( ip, prefix )
local some_ip, err = ipOps.ip_to_bin( ip )
if err then return nil, err end
prefix = tonumber( prefix )
if not prefix or ( prefix < 0 ) or ( prefix > # some_ip ) then
return nil, "Error in get_assignment: Invalid prefix length."
end
local hostbits = string.sub( some_ip, prefix + 1 )
hostbits = string.gsub( hostbits, "1", "0" )
local first = string.sub( some_ip, 1, prefix ) .. hostbits
local last
err = {}
first, err[#err+1] = ipOps.bin_to_ip( first )
last, err[#err+1] = ipOps.get_last_ip( ip, prefix )
if #err > 0 then return nil, table.concat( err, " " ) end
return first .. "-" .. last
end
---
-- Decides what to output based on the content of the supplied parameters and formats it for return by <code>action</code>.
-- @param output String non-answer message to be returned as is or an empty table.
-- @param combined_records Table containing combined records.
-- @return Formatted nice output string.
function nice_output( output, combined_records )
-- return a string message
if type( output ) == "string" and output ~= "" then
return output
end
-- return nothing (dns failure)
if type( output ) ~= "table" then return nil end
-- format each combined_record for output
for _, rec in pairs( combined_records ) do
local r = {}
if rec.bgp then r[#r+1] = rec.bgp end
if rec.co then r[#r+1] = rec.co end
if rec.asn then output[#output+1] = ( "%s\n %s" ):format( table.concat( r, " | " ), table.concat( rec.asn, "\n " ) ) end
end
-- return nothing
if #output == 0 then return nil end
-- sort BGP asc. and combine BGP when ASN info is duplicated
local first, second
table.sort( output, function(a,b) return (get_prefix_length(a) or 0) > (get_prefix_length(b) or 0) end )
for i=1,#output,1 do
for j=1,#output,1 do
-- does everything after the first pipe match for i ~= j?
if i ~= j and output[i]:match( "[^|]+|([^$]+$)" ) == output[j]:match( "[^|]+|([^$]+$)" ) then
first = output[i]:match( "([%x%d:%.]+/%d+)%s|" ) -- the lastmost BGP before the pipe in i.
second = output[j]:match( "([%x%d:%.]+/%d+)" ) -- first BGP in j
-- add in the new BGP from j and delete j
if first and second then
output[i] = output[i]:gsub( first, ("%s and %s"):format( first, second ) )
output[j] = ""
end
end
end
end
-- return combined and formatted answers
return "\n" .. table.concat( output, "\n" )
end
|