--- 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:
--
--
-- local helper = dnsbl.Helper:new("SPAM", "short")
-- helper:setFilter('dnsbl.inps.de')
-- local status, result = helper:checkBL(host.ip)
-- ... formatting code ...
--
--
-- @author Patrik Karlsson
--
local bit = require "bit"
local coroutine = require "coroutine"
local dns = require "dns"
local ipOps = require "ipOps"
local nmap = require "nmap"
local stdnse = require "stdnse"
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:
-- ns_type
- 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.
--
-- resp_parser
- A function to parse the response received from
-- the DNS query. The function should take two arguments:
-- * response
- the DNS response received by the server,
-- typically a code represented by an IP.
-- * mode
- a string representing what mode (long|short) that
-- the function should parse. If ns_type
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 state
field, or nil if the IP wasn't listed. When long
-- mode is used, the function should return additional information using the
-- details
field. Eg:
-- return { state = "SPAM" } -- short mode
-- return { state = "PROXY", details = {
-- "Proxy is working",
-- "Proxy was scanned"
-- } -- long mode
--
-- fmt_query
- A function responsible for formatting the DNS
-- query. When the default format is being used .
-- 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.
--
-- configuration
- 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 mode
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 ( bit.band( 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 ( bit.band(octet4, 1) == 1) then
table.insert(activity, "Suspicious")
end
-- Harvester
if ( bit.band(octet4, 2) == 2) then
table.insert(activity, "Harvester")
end
-- Comment spammer
if ( bit.band(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(stdnse.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;