summaryrefslogtreecommitdiffstats
path: root/nselib/dnsbl.lua
blob: b55179905a67bd11a235a647995f4e1b2d304c81 (plain)
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
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
--- A minimalistic DNS BlackList library implemented to facilitate querying
-- various DNSBL services. The current list of services has been implemented
-- based on the following compilations of services:
-- * http://en.wikipedia.org/wiki/Comparison_of_DNS_blacklists
-- * http://www.robtex.com
-- * http://www.sdsc.edu/~jeff/spam/cbc.html
--
-- The library implements a helper class through which script may access
-- the BL services. A typical script implementation could look like this:
--
-- <code>
-- local helper = dnsbl.Helper:new("SPAM", "short")
-- helper:setFilter('dnsbl.inps.de')
-- local status, result = helper:checkBL(host.ip)
-- ... formatting code ...
-- </code>
--
-- @author Patrik Karlsson <patrik@cqure.net>
--

local coroutine = require "coroutine"
local dns = require "dns"
local ipOps = require "ipOps"
local nmap = require "nmap"
local stdnse = require "stdnse"
local stringaux = require "stringaux"
local table = require "table"
_ENV = stdnse.module("dnsbl", stdnse.seeall)


-- Creates a new Service instance
-- @param ip host that needs to be checked
-- @param mode string (short|long) specifying whether short or long
--        results are to be returned
-- @param config service configuration in case this service provider
--        needs user supplied configuration
-- @return o instance of Helper
local function service_new (self, ip, mode, config)
  local o = { ip = ip, mode = mode, config = config }
  setmetatable(o, self)
  self.__index = self
  return o
end

-- The services table contains a list of valid DNSBL providers
-- Providers are categorized in categories that should contain services that
-- do DNS blacklist checks for that particular category.
--
-- Each service should be stored under a key that specifies the service name
-- and should contain:
-- <code>ns_type</code> - A table with a record type as key and mode as value
--   eg: { ["A"] = "short", ["TXT"] = "long" }.
--   If only short queries are supported using A records, this argument may be
--   omitted.
--
-- <code>resp_parser</code> - A function to parse the response received from
--   the DNS query. The function should take two arguments:
--     * <code>response</code> - the DNS response received by the server,
--       typically a code represented by an IP.
--     * <code>mode</code> - a string representing what mode (long|short) that
--       the function should parse. If <code>ns_type</code> does not contain
--       the TXT record, this argument and check can be omitted.
--   When the short mode is used, the function should return a table containing
--   the <code>state</code> field, or nil if the IP wasn't listed. When long
--   mode is used, the function should return additional information using the
--  <code>details</code> field. Eg:
--     return { state = "SPAM" } -- short mode
--     return { state = "PROXY", details = {
--                           "Proxy is working",
--                           "Proxy was scanned"
--                          } -- long mode
--
-- <code>fmt_query</code> - A function responsible for formatting the DNS
--   query. When the default format is being used <reverse ip>.<servicename>
--   eg: 4.3.2.1.spam.dnsbl.sorbs.net, this function can be omitted. But if
--   this function is defined, it must return the query to be executed,
--   otherwise the library will assume that the provider needs configuration
--   that failed to be provided.
--
-- <code>configuration</code> - If the service requires the user to provide
--   configurations, this function will have to return a list with the name
--   and description of the arguments that provide the configuration/options.
--   If this function isn't specified, the library will assume the service
--   doesn't require configuration.
--
SERVICES = {

  SPAM = {

    ["dnsbl.inps.de"] = {
      -- This service supports both long and short <code>mode</code>
      ns_type = {
        ["short"] = "A",
        ["long"] = "TXT",
      },
      new = service_new,
      -- Sample fmt_query function, if no function is specified, the library
      -- will assume that the IP should be reversed add suffixed with the
      -- service name.
      fmt_query = function(self)
        local rev_ip = dns.reverse(self.ip):match("^(.*)%.in%-addr%.arpa$")
        return ("%s.spam.dnsbl.sorbs.net"):format(rev_ip)
      end,
      -- This function parses the response and supports both long and
      -- short mode.
      resp_parser = function(self, r)
        local responses = {
          ["127.0.0.2"] = "SPAM",
        }
        if ( ("short" == self.mode and r[1]) ) then
          return responses[r[1]]
        else
          return { state = "SPAM", details = { r[1] } }
        end
      end,
    },

    ["spam.dnsbl.sorbs.net"] = {
      ns_type = {
        ["short"] = "A"
      },
      new = service_new,
      resp_parser = function(self, r)
        return ( r[1] == "127.0.0.6" and { state = "SPAM" } )
      end,
    },

    ["bl.nszones.com"] = {
      new = service_new,
      resp_parser = function(self, r)
        local responses = {
          ["127.0.0.2"] = "SPAM",
          ["127.0.0.3"] = "DYNAMIC"
        }
        return ( r[1] and responses[r[1]] ) and { state = responses[r[1]] }
      end,
    },

    ["all.spamrats.com"] = {
      new = service_new,
      resp_parser = function(self, r)
        local responses = {
          ["127.0.0.36"] = "DYNAMIC",
          ["127.0.0.38"] = "SPAM",
        }
        return ( r[1] and responses[r[1]] ) and { state = responses[r[1]] }
      end,
    },

    ["list.quorum.to"] = {
      new = service_new,
      resp_parser = function(self, r)
        -- this service appears to return 127.0.0.0 when the service is
        -- "blocked because it has never been seen to send mail".
        -- This would essentially return every host as SPAM and we
        -- don't want that.
        return ( ( r[1] and r[1] ~= "127.0.0.0" ) and { state = "SPAM" } )
      end
    },

    ["sbl.spamhaus.org"] = {
      new = service_new,
      resp_parser = function(self, r)
        local responses = {
          ["127.0.0.2"] = "SPAM",
          ["127.0.0.3"] = "SPAM",
        }
        return ( r[1] and responses[r[1]] ) and { state = responses[r[1]] }
      end,
    },

    ["bl.spamcop.net"] = {
      new = service_new,
      resp_parser = function(self, r)
        local responses = {
          ["127.0.0.2"] = "SPAM",
        }
        return ( r[1] and responses[r[1]] ) and { state = responses[r[1]] }
      end,
    },

    ["l2.apews.org"] = {
      new = service_new,
      resp_parser = function(self, r)
        local responses = {
          ["127.0.0.2"] = "SPAM",
        }
        return ( r[1] and responses[r[1]] ) and { state = responses[r[1]] }
      end,
    },

  },

  PROXY = {

    ["dnsbl.tornevall.org"] = {
      new = service_new,
      resp_parser = function(self, r)
        if ( "short" == self.mode and r[1] ) then
          return { state = "PROXY" }
        elseif ( "long" == self.mode ) then
          local responses = {
            [1] = "Proxy has been scanned",
            [2] = "Proxy is working",
            [4] = "?",
            [8] = "Proxy was tested, but timed out on connection",
            [16] = "Proxy was tested but failed at connection",
            [32] = "Proxy was tested but the IP was different",
            [64] = "IP marked as \"abusive host\"",
            [128] = "Proxy has a different anonymous-state"
          }

          local code = tonumber(r[1]:match("%.(%d*)$"))
          local result = {}

          for k, v in pairs(responses) do
            if ( ( code & k ) == k ) then
              table.insert(result, v)
            end
          end
          return { state = "PROXY", details = result }
        end
      end,
    },

    ["ip-port.exitlist.torproject.org"] = {
      configuration = {
        ["port"] = "the port to which the target can relay to",
        ["ip"] = "the IP address to which the target can relay to"
      },
      new = service_new,
      fmt_query = function(self)
        if ( not(self.config.port) or not(self.config.ip) ) then
          return
        end

        local rev_ip = dns.reverse(self.ip):match("^(.*)%.in%-addr%.arpa$")
        return ("%s.%s.%s.ip-port.exitlist.torproject.org"):format(rev_ip,
          self.config.port, self.config.ip)
      end,
      resp_parser = function(self, r)
        local responses = {
          ["127.0.0.2"] = "PROXY",
        }
        return ( r[1] and responses[r[1]] ) and { state = responses[r[1]] }
      end,
    },

    ["tor.dan.me.uk"] = {
      ns_type = {
        ["short"] = "A",
        ["long"] = "TXT",
      },
      new = service_new,
      resp_parser = function(self, r)
        local responses = {
          ["127.0.0.100"] = "PROXY",
        }
        if ( "short" == self.mode and r[1] ) then
          return { state = responses[r[1]] }
        else
          local flagsinfo = {
            ["E"] = "Exit",
            ["A"] = "Authority",
            ["B"] = "BadExit",
            ["D"] = "V2Dir",
            ["F"] = "Fast",
            ["G"] = "Guard",
            ["H"] = "HSDir",
            ["N"] = "Named",
            ["R"] = "Running",
            ["S"] = "Stable",
            ["U"] = "Unnamed",
            ["V"] = "Valid"
          }

          local name, ports, flagsfound = r[1]:match(
            "N:(.+)/P:([%d,]+)/F:([EABDFGHNRSUV]+)")

          local flags = {}
          flags['name'] = "Flags"

          for k, v in pairs(flagsinfo) do
            if flagsfound:match(k) then
              table.insert(flags, v)
            end
          end

          local result = {
            ("Name: %s"):format(name),
            ("Ports: %s"):format(ports),
            flags
          }

          return { state = "PROXY", details = result }
        end
      end,
    },

    ["http.dnsbl.sorbs.net"] = {
      new = service_new,
      resp_parser = function(self, r)
        local responses = {
          ["127.0.0.2"] = "PROXY",
        }
        return ( r[1] and responses[r[1]] ) and { state = responses[r[1]] }
      end,
    },

    ["socks.dnsbl.sorbs.net"] = {
      new = service_new,
      resp_parser = function(self, r)
        local responses = {
          ["127.0.0.3"] = "PROXY",
        }
        return ( r[1] and responses[r[1]] ) and { state = responses[r[1]] }
      end,
    },

    ["misc.dnsbl.sorbs.net"] = {
      new = service_new,
      resp_parser = function(self, r)
        local responses = {
          ["127.0.0.4"] = "PROXY",
        }
        return ( r[1] and responses[r[1]] ) and { state = responses[r[1]] }
      end,
    }

  },

  ATTACK = {
    ["dnsbl.httpbl.org"] = {
      configuration = {
        ["apikey"] = "the http:BL API key"
      },
      new = service_new,
      fmt_query = function(self)
        if ( not(self.config.apikey) ) then
          return
        end

        local rev_ip = dns.reverse(self.ip):match("^(.*)%.in%-addr%.arpa$")
        return ("%s.%s.dnsbl.httpbl.org"):format(self.config.apikey, rev_ip)
      end,
      resp_parser = function(self, r)
        if ( not(r[1]) ) then
          return
        end

        local parts, err = ipOps.get_parts_as_number(r[1])

        if ( not(parts) or err ) then
          -- TODO Should we return failure in the result?
          stdnse.debug1("The dnsbl.httpbl.org provider failed to return a valid address")
          return
        end

        local octet1, octet2, octet3, octet4 = table.unpack(parts)

        if ( octet1 ~= 127 ) then
          -- This shouldn't happen :P
          stdnse.debug1(
            "The request made to dnsbl.httpbl.org was considered invalid (%i)",
            octet1)
        elseif ( "short" == self.mode ) then
          return { state = "ATTACK" }
        else
          local search = {
            [0] = "Undocumented",
            [1] = "AltaVista",
            [2] = "Ask",
            [3] = "Baidu",
            [4] = "Excite",
            [5] = "Google",
            [6] = "Looksmart",
            [7] = "Lycos",
            [8] = "MSN",
            [9] = "Yahoo",
            [10] = "Cuil",
            [11] = "InfoSeek",
            [12] = "Miscellaneous"
          }

          local result = {}

          -- Search engines are a special case.
          if ( octet4 == 0 ) then
            table.insert(result, ("Search engine: %s"):format(
            search[octet3]))
          else
            table.insert(result, ("Last activity: %i days"):format(
              octet2))
            table.insert(result, ("Threat score: %i"):format(
              octet3))

            local activity = {}
            activity['name'] = "Activity"
            -- Suspicious activity
            if ( (octet4 & 1) == 1) then
              table.insert(activity, "Suspicious")
            end

            -- Harvester
            if ( (octet4 & 2) == 2) then
              table.insert(activity, "Harvester")
            end

            -- Comment spammer
            if ( (octet4 & 4)  == 4) then
              table.insert(activity, "Comment spammer")
            end

            table.insert(result, activity)
          end

          return { state = "ATTACK", details = result }
        end
      end,
    },

    ["all.bl.blocklist.de"] = {
      new = service_new,
      resp_parser = function(self, r)
        local responses = {
          ["127.0.0.2"] = "Amavis",
          ["127.0.0.3"] = "DDoS",
          ["127.0.0.4"] = "Asterisk, SIP, VoIP",
          ["127.0.0.5"] = "Badbot",
          ["127.0.0.6"] = "FTP",
          ["127.0.0.7"] = "IMAP",
          ["127.0.0.8"] = "IRC bot",
          ["127.0.0.9"] = "Mail",
          ["127.0.0.10"] = "POP3",
          ["127.0.0.11"] = "Registration bot",
          ["127.0.0.12"] = "Remote file inclusion",
          ["127.0.0.13"] = "SASL",
          ["127.0.0.14"] = "SSH",
          ["127.0.0.15"] = "w00tw00t",
          ["127.0.0.16"] = "Port flood",
        }
        if ( "short" == self.mode and r[1] ) then
          return "ATTACK"
        else
          return ( r[1] and responses[r[1]] ) and { state = "ATTACK",
          details = {
            ("Type: %s"):format(responses[r[1]])
          }
        }
      end
    end,
  }
},

}



Helper = {

  -- Creates a new Helper instance
  -- @param category string containing a valid DNSBL service category
  -- @param mode string (short|long) specifying whether short or long
  --        results are to be returned
  -- @return o instance of Helper
  new = function(self, category, mode)
    local o = { category = category:upper(), mode = mode }
    assert(category and SERVICES[category:upper()], "Invalid category was supplied, aborting")
    setmetatable(o, self)
    self.__index = self
    return o
  end,

  -- Lists all DNSBL services for the category
  -- @return services table of service names
  listServices = function(self)
    local services = {}
    for name, svc in pairs(SERVICES[self.category]) do
      if ( svc.configuration ) then
        local service = {}
        service['name'] = name

        for config, description in pairs(svc.configuration) do
          table.insert(service, ("config: %s.%s - %s"):format(
            name, config, description))
        end

        table.insert(services, service )
      else
        table.insert(services, name)
      end
    end
    return services
  end,

  -- Validates the filter set by setFilter to make sure it contains only
  -- valid service names.
  -- @return status boolean, true on success false on failure
  -- @return err string containing an error message on failure
  validateFilter = function(self)

    if ( not(self.filterstr) ) then
      return true
    end

    local all = SERVICES[self.category]
    self.filter = {}
    for _, f in pairs(stringaux.strsplit(",%s*", self.filterstr)) do
      if ( not(SERVICES[self.category][f]) ) then
        self.filter = nil
        return false, ("Service does not exist '%s'"):format(f)
      end
      self.filter[f] = true
    end
    return true
  end,

  -- Sets a new service filter to choose only a limited subset of services
  -- within a category.
  -- @param filter string containing a comma separated list of service names
  setFilter = function(self, filter) self.filterstr = filter end,

  -- Gets a list of filtered services, or all services if no filter is in use
  -- @return services table containing a list of services
  getServices = function(self)
    if ( not(self:validateFilter()) ) then
      return nil
    end

    if ( self.filter ) then
      local filtered = {}
      for name, svc in pairs(SERVICES[self.category]) do
        if ( self.filter[name] ) then
          filtered[name] = svc
        end
      end
      return filtered
    else
      return SERVICES[self.category]
    end
  end,

  doQuery = function(self, ip, name, svc, answers)

    local condvar = nmap.condvar(answers)
    local config = {}

    if ( svc.configuration ) then
      for key in pairs(svc.configuration) do
        config[key] = stdnse.get_script_args(("%s.%s"):format(name, key))
      end
    end

    svc = svc:new(ip, self.mode, config)

    local ns_type = ( svc.ns_type and svc.ns_type[self.mode] ) and svc.ns_type[self.mode] or "A"
    local query

    if ( not(svc.fmt_query) ) then
      local rev_ip = dns.reverse(ip):match("^(.*)%.in%-addr%.arpa$")
      query = ("%s.%s"):format(rev_ip, name)
    else
      query = svc:fmt_query()
    end

    if ( query ) then
      local status, answer = dns.query(query, {dtype=ns_type, retAll=true} )
      answers[name] = { status = status, answer = answer, svc = svc }
    else
      stdnse.debug1("Query function returned nothing, skipping '%s'", name)
    end

    condvar "signal"
  end,

  -- Runs the DNS blacklist check for the given IP against all non-filtered
  -- services in the given category.
  -- @param ip string containing the IP address to check
  -- @return result table containing the results of the BL checks
  checkBL = function(self, ip)
    local result, answers, threads = {}, {}, {}
    local condvar = nmap.condvar(answers)

    for name, svc in pairs(self:getServices()) do
      local co = stdnse.new_thread(self.doQuery, self, ip, name, svc, answers)
      threads[co] = true
    end

    repeat
      for t in pairs(threads) do
        if ( coroutine.status(t) == "dead" ) then threads[t] = nil end
      end
      if ( next(threads) ) then
        condvar "wait"
      end
    until( next(threads) == nil )

    for name, answer in pairs(answers) do
      local status, answer, svc = answer.status, answer.answer, answer.svc
      if ( status ) then
        local svc_result = svc:resp_parser(answer)
        if ( not(svc_result) ) then
          local resp = ( #answer > 0 and ("UNKNOWN (%s)"):format(answer[1]) or "UNKNOWN" )
          stdnse.debug2("%s received %s", name, resp)
        end

        if ( svc_result ) then
          table.insert(result, { name = name, result = svc_result })
        end

        -- if status is false, and the response was "No Such Name", it
        -- simply means that the IP isn't listed, we haven't failed at
        -- this point. It would obviously be better to check this against
        -- an error code, or in some other way, but this is what we've got.
      elseif ( answer ~= "No Such Name" ) then
        table.insert(result, { name = name, result = { state = "FAIL" }})
      end
    end
    return result
  end,

}

return _ENV;