diff --git a/nselib/dhcp.lua b/nselib/dhcp.lua new file mode 100644 index 000000000..873e4bcea --- /dev/null +++ b/nselib/dhcp.lua @@ -0,0 +1,623 @@ +---Implement a Dynamic Host Configuration Protocol (DHCP) client. +-- +-- DHCP, defined in rfc2132 and rfc2131, is a protocol for hosts to automatically +-- configure themselves on a network (that is, obtain an ip address). This library, +-- which have a trivial one-function interface, can send out DHCP packets of many +-- types and parse the responses. +-- + +module(... or "dhcp", package.seeall) + +require 'bin' +require 'bit' +require 'ipOps' +require 'stdnse' + +request_types = +{ + DHCPDISCOVER = 1, + DHCPOFFER = 2, + DHCPREQUEST = 3, + DHCPDECLINE = 4, + DHCPACK = 5, + DHCPNAK = 6, + DHCPRELEASE = 7, + DHCPINFORM = 8 +} + +request_types_str = {} +request_types_str[1] = "DHCPDISCOVER" +request_types_str[2] = "DHCPOFFER" +request_types_str[3] = "DHCPREQUEST" +request_types_str[4] = "DHCPDECLINE" +request_types_str[5] = "DHCPACK" +request_types_str[6] = "DHCPNAK" +request_types_str[7] = "DHCPRELEASE" +request_types_str[8] = "DHCPINFORM" + +-- This pulls back 4 bytes in the packet that correspond to the transaction id. This should be randomly +-- generated and different for every instance of a script (to prevent collisions) +pcap_callback = function(packetsz, layer2, layer3) + return string.sub(layer3, 33, 36) +end + +---Read an IP address or a list of IP addresses. Print an error if the length isn't a multiple of 4. +-- +--@param data The packet. +--@param pos The position in the packet. +--@param length The length that the server claims the field is. +--@return The new position (will always be pos + length, no matter what we think it should be) +--@return The value of the field, or nil if the field length was wrong. +local function read_ip(data, pos, length) + if(length ~= 4) then + if((length % 4) ~= 0) then + stdnse.print_debug(1, "dhcp-discover: Invalid length for an ip address (%d)", length) + pos = pos + length + + return pos, nil + else + local results = {} + for i=1, length, 4 do + local value + pos, value = bin.unpack("request_types_str +-- table. Print an error if the length isn't 1. +-- +--@param data The packet. +--@param pos The position in the packet. +--@param length The length that the server claims the field is. +--@return The new position (will always be pos + length, no matter what we think it should be) +--@return The value of the field, or nil if the field length was wrong. +local function read_message_type(data, pos, length) + local value + + pos, value = read_1_byte(data, pos, length) + if(value == nil) then + stdnse.print_debug(1, "dhcp-discover: Couldn't read the 1-byte message type") + return pos, nil + end + + return pos, request_types_str[value] +end + +---Read a single byte, and return 'false' if it's 0, or 'true' if it's non-zero. Print an error if the +-- length isn't 1. +-- +--@param data The packet. +--@param pos The position in the packet. +--@param length The length that the server claims the field is. +--@return The new position (will always be pos + length, no matter what we think it should be) +--@return The value of the field, or nil if the field length was wrong. +local function read_boolean(data, pos, length) + local result + pos, result = read_1_byte(data, pos, length) + + if(result == nil) then + stdnse.print_debug(1, "dhcp-discover: Couldn't read the 1-byte boolean") + return pos, nil + elseif(result == 0) then + return pos, "false" + else + return pos, "true" + end +end + +---Read a 2-byte unsigned little endian value. Print an error if the length isn't 2. +-- +--@param data The packet. +--@param pos The position in the packet. +--@param length The length that the server claims the field is. +--@return The new position (will always be pos + length, no matter what we think it should be) +--@return The value of the field, or nil if the field length was wrong. +local function read_2_bytes(data, pos, length) + if(length ~= 2) then + stdnse.print_debug(1, "dhcp-discover: Invalid length for data (%d; should be %d)", length, 2) + pos = pos + length + return pos, nil + end + return bin.unpack(">S", data, pos) +end + + +---Read a list of 2-byte unsigned little endian values. Print an error if the length isn't a multiple +-- of 2. +-- +--@param data The packet. +--@param pos The position in the packet. +--@param length The length that the server claims the field is. +--@return The new position (will always be pos + length, no matter what we think it should be) +--@return The value of the field, or nil if the field length was wrong. +local function read_2_bytes_list(data, pos, length) + if((length % 2) ~= 0) then + stdnse.print_debug(1, "dhcp-discover: Invalid length for data (%d; should be multiple of %d)", length, 2) + pos = pos + length + + return pos, nil + else + local results = {} + for i=1, length, 2 do + local value + pos, value = bin.unpack(">S", data, pos) + table.insert(results, value) + end + + return pos, results + end +end + + +---Read a 4-byte unsigned little endian value. Print an error if the length isn't 4. +-- +--@param data The packet. +--@param pos The position in the packet. +--@param length The length that the server claims the field is. +--@return The new position (will always be pos + length, no matter what we think it should be) +--@return The value of the field, or nil if the field length was wrong. +local function read_4_bytes(data, pos, length) + if(length ~= 4) then + stdnse.print_debug(1, "dhcp-discover: Invalid length for data (%d; should be %d)", length, 4) + pos = pos + length + return pos, nil + end + return bin.unpack(">I", data, pos) +end + +---Read a 4-byte unsigned little endian value, and interpret it as a time offset value. Print an +-- error if the length isn't 4. +-- +--@param data The packet. +--@param pos The position in the packet. +--@param length The length that the server claims the field is. +--@return The new position (will always be pos + length, no matter what we think it should be) +--@return The value of the field, or nil if the field length was wrong. +local function read_time(data, pos, length) + local result + if(length ~= 4) then + stdnse.print_debug(1, "dhcp-discover: Invalid length for data (%d; should be %d)", length, 4) + pos = pos + length + return pos, nil + end + pos, result = bin.unpack(">I", data, pos) + + -- This code was mostly taken from snmp-sysdescr.nse. It should probably be abstracted into stdnse.lua [TODO] + local days, hours, minutes, seconds, htime, mtime, stime + days = math.floor(result / 86400) + htime = math.fmod(result, 86400) + hours = math.floor(htime / 3600) + mtime = math.fmod(htime, 3600) + minutes = math.floor(mtime / 60) + seconds = math.fmod(mtime, 60) + + local dayLabel + + if days == 1 then + dayLabel = "day" + else + dayLabel = "days" + end + + return pos, string.format("%d %s, %d:%02d:%02d", days, dayLabel, hours, minutes, seconds) +end + +---Read a list of static routes. Each of them are a pair of IP addresses, a destination and a +-- router. Print an error if the length isn't a multiple of 8. +-- +--@param data The packet. +--@param pos The position in the packet. +--@param length The length that the server claims the field is. +--@return The new position (will always be pos + length, no matter what we think it should be) +--@return The value of the field, or nil if the field length was wrong. +local function read_static_route(data, pos, length) + if((length % 8) ~= 0) then + stdnse.print_debug(1, "dhcp-discover: Invalid length for data (%d; should be multiple of %d)", length, 8) + pos = pos + length + + return pos, nil + else + local results = {} + for i=1, length, 8 do + local destination, router + pos, destination = read_ip(data, pos, 4) + pos, router = read_ip(data, pos, 4) + table.insert(results, {destination=destination, router=router}) + end + + return pos, results + end +end + +---Read a list of policy filters. Each of them are a pair of IP addresses, an address and a +-- mask. Print an error if the length isn't a multiple of 8. +-- +--@param data The packet. +--@param pos The position in the packet. +--@param length The length that the server claims the field is. +--@return The new position (will always be pos + length, no matter what we think it should be) +--@return The value of the field, or nil if the field length was wrong. +local function read_policy_filter(data, pos, length) + if((length % 8) ~= 0) then + stdnse.print_debug(1, "dhcp-discover: Invalid length for data (%d; should be multiple of %d)", length, 8) + pos = pos + length + + return pos, nil + else + local results = {} + for i=1, length, 8 do + local address, router + pos, address = read_ip(data, pos, 4) + pos, mask = read_ip(data, pos, 4) + table.insert(results, {address=address, mask=mask}) + end + + return pos, results + end +end + +---These are the different fields for DHCP. These have to come after the read_* function +-- definitions. +local actions = {} +actions[1] = {name="Subnet Mask", func=read_ip, default=true} +actions[2] = {name="Time Offset", func=read_4_bytes, default=false} +actions[3] = {name="Router", func=read_ip, default=true} +actions[4] = {name="Time Server", func=read_ip, default=true} +actions[5] = {name="Name Server", func=read_ip, default=true} +actions[6] = {name="Domain Name Server", func=read_ip, default=true} +actions[7] = {name="Log Server", func=read_ip, default=true} +actions[8] = {name="Cookie Server", func=read_ip, default=true} +actions[9] = {name="LPR Server", func=read_ip, default=true} +actions[10] = {name="Impress Server", func=read_ip, default=true} +actions[11] = {name="Resource Location Server", func=read_ip, default=true} +actions[12] = {name="Hostname", func=read_string, default=true} +actions[13] = {name="Boot File Size", func=read_2_bytes, default=false} +actions[14] = {name="Merit Dump File", func=read_string, default=false} +actions[15] = {name="Domain Name", func=read_string, default=true} +actions[16] = {name="Swap Server", func=read_ip, default=true} +actions[17] = {name="Root Path", func=read_string, default=false} +actions[18] = {name="Extensions Path", func=read_string, default=false} +actions[19] = {name="IP Forwarding", func=read_boolean, default=false} +actions[20] = {name="Non-local Source Routing", func=read_boolean, default=true} +actions[21] = {name="Policy Filter", func=read_policy_filter, default=false} +actions[22] = {name="Maximum Datagram Reassembly Size",func=read_2_bytes, default=false} +actions[23] = {name="Default IP TTL", func=read_1_byte, default=false} +actions[24] = {name="Path MTU Aging Timeout", func=read_time, default=false} +actions[25] = {name="Path MTU Plateau", func=read_2_bytes_list, default=false} +actions[26] = {name="Interface MTU", func=read_2_bytes, default=false} +actions[27] = {name="All Subnets are Local", func=read_boolean, default=false} +actions[28] = {name="Broadcast Address", func=read_ip, default=true} +actions[29] = {name="Perform Mask Discovery", func=read_boolean, default=false} +actions[30] = {name="Mask Supplier", func=read_boolean, default=false} +actions[31] = {name="Perform Router Discovery", func=read_boolean, default=false} +actions[32] = {name="Router Solicitation Address", func=read_ip, default=true} +actions[33] = {name="Static Route", func=read_static_route, default=true} +actions[34] = {name="Trailer Encapsulation", func=read_boolean, default=false} +actions[35] = {name="ARP Cache Timeout", func=read_time, default=false} +actions[36] = {name="Ethernet Encapsulation", func=read_boolean, default=false} +actions[37] = {name="TCP Default TTL", func=read_1_byte, default=false} +actions[38] = {name="TCP Keepalive Interval", func=read_4_bytes, default=false} +actions[39] = {name="TCP Keepalive Garbage", func=read_boolean, default=false} +actions[40] = {name="NIS Domain", func=read_string, default=true} +actions[41] = {name="NIS Servers", func=read_ip, default=true} +actions[42] = {name="NTP Servers", func=read_ip, default=true} +actions[43] = {name="Vendor Specific Information", func=read_string, default=false} +actions[44] = {name="NetBIOS Name Server", func=read_ip, default=true} +actions[45] = {name="NetBIOS Datagram Server", func=read_ip, default=true} +actions[46] = {name="NetBIOS Node Type", func=read_1_byte, default=false} +actions[47] = {name="NetBIOS Scope", func=read_string, default=false} +actions[48] = {name="X Window Font Server", func=read_ip, default=true} +actions[49] = {name="X Window Display Manager", func=read_ip, default=true} +actions[50] = {name="Requested IP Address (client)", func=read_ip, default=false} +actions[51] = {name="IP Address Lease Time", func=read_time, default=false} +actions[52] = {name="Option Overload", func=read_1_byte, default=false} +actions[53] = {name="DHCP Message Type", func=read_message_type, default=false} +actions[54] = {name="Server Identifier", func=read_ip, default=true} +actions[55] = {name="Parameter Request List (client)", func=read_string, default=false} +actions[56] = {name="Error Message", func=read_string, default=true} +actions[57] = {name="Maximum DHCP Message Size", func=read_2_bytes, default=false} +actions[58] = {name="Renewal Time Value", func=read_time, default=false} +actions[59] = {name="Rebinding Time Value", func=read_time, default=false} +actions[60] = {name="Class Identifier", func=read_string, default=false} +actions[61] = {name="Client Identifier (client)", func=read_string, default=false} + +--- Does the send/receive, doesn't build/parse anything. +local function dhcp_send(interface, host, packet, transaction_id) + local socket + local status, err, data + local result + local results = {} + + + -- Create a pcap socket to listen for the response. I used to consider this a hack, but + -- it really isn't -- it's kinda how this has to be done. + local pcap = nmap.new_socket() + pcap:pcap_open(interface, 590, 0, pcap_callback, "udp port 68") + pcap:set_timeout(5000) + pcap:pcap_register(transaction_id) + stdnse.print_debug(1, "dhcp: Starting listener") + + -- Create the UDP socket (TODO: enable SO_BROADCAST if we need to) + socket = nmap.new_socket() + status, err = socket:connect(host, 67, "udp") + if(status == false) then + return false, "Couldn't create socket: " .. err + end + stdnse.print_debug(1, "dhcp: Created UDP socket") + + -- Send out the packet + socket:send(packet) + + -- Read the response + local status, err, _, data = pcap:pcap_receive() + if(status == false) then + stdnse.print_debug(1, "dhcp: Error calling pcap_receive(): %s", err) + return false, "Error calling pcap_receive(): " .. err + end + + -- If no data was captured (ie, a timeout), return an error + if(data == nil) then + stdnse.print_debug(1, "dhcp: Error calling pcap_receive(): TIMEOUT") + return false, "TIMEOUT" + end + + -- Cut off the address/transport headers + data = string.sub(data, 29) -- I doubt this is the right way to do this, but since we're only supporting IPv4 + UDP, maybe it'll work + + -- Close our sockets + socket:close() + pcap:close() + + -- Finally, return the data + return true, data +end + +local function dhcp_build(request_type, ip_address, mac_address, request_options, overrides, lease_time, transaction_id) + local packet = '' + + -- Set up the default overrides + if(overrides == nil) then + overrides = {} + end + + if(request_options == nil) then + -- Request the defaults, or there's no verbosity; otherwise, request everything! + request_options = '' + for i = 1, 61, 1 do + if(nmap.verbosity() > 0) then + request_options = request_options .. string.char(i) + else + if(actions[i] and actions[i].default) then + request_options = request_options .. string.char(i) + end + end + end + end + + -- Header + packet = packet .. bin.pack(">CCCC", overrides['op'] or 1, overrides['htype'] or 1, overrides['hlen'] or 6, overrides['hops'] or 0) -- BOOTREQUEST, 10mb ethernet, 6 bytes long, 0 hops + packet = packet .. transaction_id -- Transaction ID + packet = packet .. bin.pack(">SS", overrides['secs'] or 0, overrides['flags'] or 0x0000) -- Secs, flags + packet = packet .. bin.pack("A", ip_address) -- Client address + packet = packet .. bin.pack("I", overrides['cookie'] or 0x63825363) -- Magic cookie + + -- Options + packet = packet .. bin.pack(">CCC", 0x35, 1, request_type) -- Request type + packet = packet .. bin.pack(">CCA", 0x37, #request_options, request_options) -- Request options + packet = packet .. bin.pack(">CCI", 0x33, 4, lease_time or 1) -- Lease time + + packet = packet .. bin.pack(">C", 0xFF) -- Termination + + return true, packet +end + +---Parse a DHCP packet (either a request or a response) and return the results as a table. The +-- table at the top of this function (actions) defines the name of each field, as +-- laid out in rfc2132, and the function that parses it. +-- +-- In theory, this should be able to parse any valid DHCP packet. +-- +--@param data The DHCP packet data. Any padding at the end of the packet will be ignored (by default, +-- DHCP packets are padded with \x00 bytes). +local function dhcp_parse(data, transaction_id) + local pos = 1 + local result = {} + + -- Receive the first bit and make sure we got the correct operation back + pos, result['op'], result['htype'], result['hlen'], result['hops'] = bin.unpack(">CCCC", data, pos) + if(result['op'] ~= 2) then + return false, string.format("DHCP server returned invalid reply ('op' wasn't BOOTREPLY (it was 0x%02x))", result['op']) + end + + -- Confirm the transaction id + pos, result['xid'] = bin.unpack("A4", data, pos) + if(result['xid'] ~= transaction_id) then + return false, string.format("DHCP server returned invalid reply (transaction id didn't match (%s != %s))", result['xid'], transaction_id) + end + + -- Unpack the secs, flags, addresses, sname, and file + pos, result['secs'], result['flags'] = bin.unpack(">SS", data, pos) + pos, result['ciaddr'] = bin.unpack("I", data, pos) + if(result['cookie'] ~= 0x63825363) then + return false, "DHCP server returned invalid reply (the magic cookie was invalid)" + end + + -- Parse the options + result['options'] = {} + while true do + local option, length + pos, option, length = bin.unpack(">CC", data, pos) + + -- Check for termination condition + if(option == 0xFF) then + break; + end + + -- Get the action from the array, based on the code + local action = actions[option] + + -- Verify we got a valid code (if we didn't, we're probably in big trouble) + if(action == nil) then + stdnse.print_debug(1, "dhcp-discover: Unknown option: %d", option) + pos = pos + length + else + -- Call the function to parse the option, and insert the result into our results table + local value + + stdnse.print_debug(2, "dhcp-discover: Attempting to parse %s", action['name']) + pos, value = action['func'](data, pos, length) + + if(nmap.verbosity() == 0 and action.default == false) then + stdnse.print_debug(1, "dhcp-discover: Server returned unrequested option (%s => %s)", action['name'], value) + + else + if(value) then + table.insert(result['options'], {name=action['name'], value=value}) + else + stdnse.print_debug(1, "dhcp-discover: Couldn't determine value for %s", action['name']); + end + end + end + + -- Handle the 'Option Overload' option specially -- if it's set, it tells us to use the file and/or sname values after we + -- run out of data. + if(option == 52) then + if(value == 1) then + data = data .. result['file'] + elseif(value == 2) then + data = data .. result['sname'] + elseif(value == 3) then + data = data .. result['file'] .. result['sname'] + else + stdnse.print_debug(1, "dhcp-discover: Warning: 'Option Overload' gave an unsupported value: %d", value) + end + end + end + + return true, result +end + +---Build and send any kind of DHCP packet, and parse the response. This is the only interface +-- to the DHCP library, and should be the only one necessary. +-- +-- All DHCP packet have the same structure, but different fields. It is therefore easy to build +-- any of the possible request types: +-- * DHCPDISCOVER +-- * DHCPOFFER +-- * DHCPREQUEST +-- * DHCPDECLINE +-- * DHCPACK +-- * DHCPNAK +-- * DHCPRELEASE +-- * DHCPINFORM +-- +-- Although these will all build a valid packet with any option, and the default options (that can be +-- overridden with the overrides argument) won't necessarily work with every request +-- type. If you're going to build some DHCP code on your own, I recommend reading rfc2131. +-- +--@param request_type The type of request as an integer (use the request_types table at the +-- top of this file). +--@param ip_address Your ip address (as a dotted-decimal string). This tells the DHCP server where to +-- send the response. Setting it to "255.255.255.255" or "0.0.0.0" is generally acceptable (if not, +-- host.ip_src can work). +--@param mac_address Your mac address (as a string up to 16 bytes) where the server will send the response. Like +-- ip_address, setting to the broadcast address (FF:FF:FF:FF:FF:FF) is +-- common (host.mac_addr_src works). +--@param request_options [optional] The options to request from the server, as an array of integers. For the +-- acceptable options, see the actions table above or have a look at rfc2132. +-- Some DHCP servers (such as my Linksys WRT54g) will ignore this list and send whichever +-- information it wants. Default: all options marked as 'default' in the actions +-- table above are requested (the typical interesting ones) if no verbosity is given. +-- If any level of verbosity is on, get all types. +--@param overrides [optional] A table of overrides. If a field in the table matches a field in the DHCP +-- packet (see rfc2131 section 2 for a list of possible fields), the value in the table +-- will be sent instead of the default value. +--@param lease_time [optional] The lease time used when requestint an IP. Default: 1 second. +--@return status (true or false) +--@return The parsed response, as a table. +function make_request(target, interface, request_type, ip_address, mac_address, request_options, overrides, lease_time) + -- A unique id that identifies this particular session (and lets us filter out what we don't want to see) + local transaction_id = bin.pack("I", ipOps.todword(ip_address)), mac_address, request_options, overrides, lease_time, transaction_id) + if(not(status)) then + stdnse.print_debug(1, "dhcp: Couldn't build packet: " .. packet) + return false, "Couldn't build packet: " .. packet + end + + -- Send the packet and get the response + local status, response = dhcp_send(interface, target, packet, transaction_id) + if(not(status)) then + stdnse.print_debug(1, "dhcp: Couldn't send packet: " .. response) + return false, "Couldn't send packet: " .. response + end + + -- Parse the response + local status, parsed = dhcp_parse(response, transaction_id) + if(not(status)) then + stdnse.print_debug(1, "dhcp: Couldn't parse response: " .. parsed) + return false, "Couldn't parse response: " .. parsed + end + + return true, parsed +end + diff --git a/scripts/dhcp-discover.nse b/scripts/dhcp-discover.nse index faa567b03..476d9d0ec 100644 --- a/scripts/dhcp-discover.nse +++ b/scripts/dhcp-discover.nse @@ -27,24 +27,18 @@ Some of the more useful fields: ]] --- --- @args dhcptype The type of DHCP request to make. By default, --- DHCPDISCOVER is sent, but this argument can change it to DHCPOFFER, --- DHCPREQUEST, DHCPDECLINE, DHCPACK, DHCPNAK, DHCPRELEASE or --- DHCPINFORM. Not all types will evoke a response from all servers. --- @args randomize_mac Set to true or 1 to --- send a random MAC address with the request (keep in mind that you may --- not see the response). This should cause the router to reserve a new --- IP address each time. @args requests Set to an integer to make up to --- that many requests (and display the results). --- @args fake_requests Set to an integer to make that many fake requests --- before the real one(s). This could be useful, for example, if you --- also use randomize_mac and you want to try exhausting --- all addresses. --- @args timeout Set to an integer to use it for a timeout. My router --- responds to fake_requests rate limited, at about 1 --- response/second. Therefore, timeout has to be at least --- fake_requests * 1000. Default: 5000. --- +-- @args dhcptype The type of DHCP request to make. By default, DHCPDISCOVER is sent, but this +-- argument can change it to DHCPOFFER, DHCPREQUEST, DHCPDECLINE, DHCPACK, DHCPNAK, +-- DHCPRELEASE or DHCPINFORM. Not all types will evoke a response from all servers, +-- and many require different fields to contain specific values. +-- @args randomize_mac Set to true or 1 to send a random MAC address with +-- the request (keep in mind that you may not see the response). This should +-- cause the router to reserve a new IP address each time. +-- @args requests Set to an integer to make up to that many requests (and display the results). +-- @args fake_requests Set to an integer to make that many fake requests before the real one(s). +-- This could be useful, for example, if you also use randomize_mac +-- and you want to try exhausting all addresses. + -- @output -- Interesting ports on 192.168.1.1: -- PORT STATE SERVICE @@ -73,575 +67,41 @@ categories = {"default", "discovery", "intrusive"} require 'bin' require 'bit' +require 'dhcp' require 'ipOps' require 'shortport' require 'stdnse' -local request_types = -{ - DHCPDISCOVER = 1, - DHCPOFFER = 2, - DHCPREQUEST = 3, - DHCPDECLINE = 4, - DHCPACK = 5, - DHCPNAK = 6, - DHCPRELEASE = 7, - DHCPINFORM = 8 -} - - -local request_types_str = {} -request_types_str[1] = "DHCPDISCOVER" -request_types_str[2] = "DHCPOFFER" -request_types_str[3] = "DHCPREQUEST" -request_types_str[4] = "DHCPDECLINE" -request_types_str[5] = "DHCPACK" -request_types_str[6] = "DHCPNAK" -request_types_str[7] = "DHCPRELEASE" -request_types_str[8] = "DHCPINFORM" - - +-- We want to run against a specific host if UDP/67 is open portrule = shortport.portnumber(67, "udp") -callback = function(packetsz, layer2, layer3) - return string.sub(layer3, 33, 36) -end - ----Read an IP address or a list of IP addresses. Print an error if the length isn't a multiple of 4. --- ---@param data The packet. ---@param pos The position in the packet. ---@param length The length that the server claims the field is. ---@return The new position (will always be pos + length, no matter what we think it should be) ---@return The value of the field, or nil if the field length was wrong. -local function read_ip(data, pos, length) - if(length ~= 4) then - if((length % 4) ~= 0) then - stdnse.print_debug(1, "dhcp-discover: Invalid length for an ip address (%d)", length) - pos = pos + length - - return pos, nil - else - local results = {} - for i=1, length, 4 do - local value - pos, value = bin.unpack("request_types_str --- table. Print an error if the length isn't 1. --- ---@param data The packet. ---@param pos The position in the packet. ---@param length The length that the server claims the field is. ---@return The new position (will always be pos + length, no matter what we think it should be) ---@return The value of the field, or nil if the field length was wrong. -local function read_message_type(data, pos, length) - local value - - pos, value = read_1_byte(data, pos, length) - if(value == nil) then - stdnse.print_debug(1, "dhcp-discover: Couldn't read the 1-byte message type") - return pos, nil - end - - return pos, request_types_str[value] -end - ----Read a single byte, and return 'false' if it's 0, or 'true' if it's non-zero. Print an error if the --- length isn't 1. --- ---@param data The packet. ---@param pos The position in the packet. ---@param length The length that the server claims the field is. ---@return The new position (will always be pos + length, no matter what we think it should be) ---@return The value of the field, or nil if the field length was wrong. -local function read_boolean(data, pos, length) - local result - pos, result = read_1_byte(data, pos, length) - - if(result == nil) then - stdnse.print_debug(1, "dhcp-discover: Couldn't read the 1-byte boolean") - return pos, nil - elseif(result == 0) then - return pos, "false" - else - return pos, "true" - end -end - ----Read a 2-byte unsigned little endian value. Print an error if the length isn't 2. --- ---@param data The packet. ---@param pos The position in the packet. ---@param length The length that the server claims the field is. ---@return The new position (will always be pos + length, no matter what we think it should be) ---@return The value of the field, or nil if the field length was wrong. -local function read_2_bytes(data, pos, length) - if(length ~= 2) then - stdnse.print_debug(1, "dhcp-discover: Invalid length for data (%d; should be %d)", length, 2) - pos = pos + length - return pos, nil - end - return bin.unpack(">S", data, pos) -end - - ----Read a list of 2-byte unsigned little endian values. Print an error if the length isn't a multiple --- of 2. --- ---@param data The packet. ---@param pos The position in the packet. ---@param length The length that the server claims the field is. ---@return The new position (will always be pos + length, no matter what we think it should be) ---@return The value of the field, or nil if the field length was wrong. -local function read_2_bytes_list(data, pos, length) - if((length % 2) ~= 0) then - stdnse.print_debug(1, "dhcp-discover: Invalid length for data (%d; should be multiple of %d)", length, 2) - pos = pos + length - - return pos, nil - else - local results = {} - for i=1, length, 2 do - local value - pos, value = bin.unpack(">S", data, pos) - table.insert(results, value) - end - - return pos, results - end -end - - ----Read a 4-byte unsigned little endian value. Print an error if the length isn't 4. --- ---@param data The packet. ---@param pos The position in the packet. ---@param length The length that the server claims the field is. ---@return The new position (will always be pos + length, no matter what we think it should be) ---@return The value of the field, or nil if the field length was wrong. -local function read_4_bytes(data, pos, length) - if(length ~= 4) then - stdnse.print_debug(1, "dhcp-discover: Invalid length for data (%d; should be %d)", length, 4) - pos = pos + length - return pos, nil - end - return bin.unpack(">I", data, pos) -end - ----Read a 4-byte unsigned little endian value, and interpret it as a time offset value. Print an --- error if the length isn't 4. --- ---@param data The packet. ---@param pos The position in the packet. ---@param length The length that the server claims the field is. ---@return The new position (will always be pos + length, no matter what we think it should be) ---@return The value of the field, or nil if the field length was wrong. -local function read_time(data, pos, length) - local result - if(length ~= 4) then - stdnse.print_debug(1, "dhcp-discover: Invalid length for data (%d; should be %d)", length, 4) - pos = pos + length - return pos, nil - end - pos, result = bin.unpack(">I", data, pos) - - -- This code was mostly taken from snmp-sysdescr.nse. It should probably be abstracted into stdnse.lua [TODO] - local days, hours, minutes, seconds, htime, mtime, stime - days = math.floor(result / 86400) - htime = math.fmod(result, 86400) - hours = math.floor(htime / 3600) - mtime = math.fmod(htime, 3600) - minutes = math.floor(mtime / 60) - seconds = math.fmod(mtime, 60) - - local dayLabel - - if days == 1 then - dayLabel = "day" - else - dayLabel = "days" - end - - return pos, string.format("%d %s, %d:%02d:%02d", days, dayLabel, hours, minutes, seconds) -end - ----Read a list of static routes. Each of them are a pair of IP addresses, a destination and a --- router. Print an error if the length isn't a multiple of 8. --- ---@param data The packet. ---@param pos The position in the packet. ---@param length The length that the server claims the field is. ---@return The new position (will always be pos + length, no matter what we think it should be) ---@return The value of the field, or nil if the field length was wrong. -local function read_static_route(data, pos, length) - if((length % 8) ~= 0) then - stdnse.print_debug(1, "dhcp-discover: Invalid length for data (%d; should be multiple of %d)", length, 8) - pos = pos + length - - return pos, nil - else - local results = {} - for i=1, length, 8 do - local destination, router - pos, destination = read_ip(data, pos, 4) - pos, router = read_ip(data, pos, 4) - table.insert(results, {destination=destination, router=router}) - end - - return pos, results - end -end - ----Read a list of policy filters. Each of them are a pair of IP addresses, an address and a --- mask. Print an error if the length isn't a multiple of 8. --- ---@param data The packet. ---@param pos The position in the packet. ---@param length The length that the server claims the field is. ---@return The new position (will always be pos + length, no matter what we think it should be) ---@return The value of the field, or nil if the field length was wrong. -local function read_policy_filter(data, pos, length) - if((length % 8) ~= 0) then - stdnse.print_debug(1, "dhcp-discover: Invalid length for data (%d; should be multiple of %d)", length, 8) - pos = pos + length - - return pos, nil - else - local results = {} - for i=1, length, 8 do - local address, router - pos, address = read_ip(data, pos, 4) - pos, mask = read_ip(data, pos, 4) - table.insert(results, {address=address, mask=mask}) - end - - return pos, results - end -end - ----These are the different fields for DHCP -local actions = {} -actions[1] = {name="Subnet Mask", func=read_ip, default=true} -actions[2] = {name="Time Offset", func=read_4_bytes, default=false} -actions[3] = {name="Router", func=read_ip, default=true} -actions[4] = {name="Time Server", func=read_ip, default=true} -actions[5] = {name="Name Server", func=read_ip, default=true} -actions[6] = {name="Domain Name Server", func=read_ip, default=true} -actions[7] = {name="Log Server", func=read_ip, default=true} -actions[8] = {name="Cookie Server", func=read_ip, default=true} -actions[9] = {name="LPR Server", func=read_ip, default=true} -actions[10] = {name="Impress Server", func=read_ip, default=true} -actions[11] = {name="Resource Location Server", func=read_ip, default=true} -actions[12] = {name="Hostname", func=read_string, default=true} -actions[13] = {name="Boot File Size", func=read_2_bytes, default=false} -actions[14] = {name="Merit Dump File", func=read_string, default=false} -actions[15] = {name="Domain Name", func=read_string, default=true} -actions[16] = {name="Swap Server", func=read_ip, default=true} -actions[17] = {name="Root Path", func=read_string, default=false} -actions[18] = {name="Extensions Path", func=read_string, default=false} -actions[19] = {name="IP Forwarding", func=read_boolean, default=false} -actions[20] = {name="Non-local Source Routing", func=read_boolean, default=true} -actions[21] = {name="Policy Filter", func=read_policy_filter, default=false} -actions[22] = {name="Maximum Datagram Reassembly Size",func=read_2_bytes, default=false} -actions[23] = {name="Default IP TTL", func=read_1_byte, default=false} -actions[24] = {name="Path MTU Aging Timeout", func=read_time, default=false} -actions[25] = {name="Path MTU Plateau", func=read_2_bytes_list, default=false} -actions[26] = {name="Interface MTU", func=read_2_bytes, default=false} -actions[27] = {name="All Subnets are Local", func=read_boolean, default=false} -actions[28] = {name="Broadcast Address", func=read_ip, default=true} -actions[29] = {name="Perform Mask Discovery", func=read_boolean, default=false} -actions[30] = {name="Mask Supplier", func=read_boolean, default=false} -actions[31] = {name="Perform Router Discovery", func=read_boolean, default=false} -actions[32] = {name="Router Solicitation Address", func=read_ip, default=true} -actions[33] = {name="Static Route", func=read_static_route, default=true} -actions[34] = {name="Trailer Encapsulation", func=read_boolean, default=false} -actions[35] = {name="ARP Cache Timeout", func=read_time, default=false} -actions[36] = {name="Ethernet Encapsulation", func=read_boolean, default=false} -actions[37] = {name="TCP Default TTL", func=read_1_byte, default=false} -actions[38] = {name="TCP Keepalive Interval", func=read_4_bytes, default=false} -actions[39] = {name="TCP Keepalive Garbage", func=read_boolean, default=false} -actions[40] = {name="NIS Domain", func=read_string, default=true} -actions[41] = {name="NIS Servers", func=read_ip, default=true} -actions[42] = {name="NTP Servers", func=read_ip, default=true} -actions[43] = {name="Vendor Specific Information", func=read_string, default=false} -actions[44] = {name="NetBIOS Name Server", func=read_ip, default=true} -actions[45] = {name="NetBIOS Datagram Server", func=read_ip, default=true} -actions[46] = {name="NetBIOS Node Type", func=read_1_byte, default=false} -actions[47] = {name="NetBIOS Scope", func=read_string, default=false} -actions[48] = {name="X Window Font Server", func=read_ip, default=true} -actions[49] = {name="X Window Display Manager", func=read_ip, default=true} -actions[50] = {name="Requested IP Address (client)", func=read_ip, default=false} -actions[51] = {name="IP Address Lease Time", func=read_time, default=false} -actions[52] = {name="Option Overload", func=read_1_byte, default=false} -actions[53] = {name="DHCP Message Type", func=read_message_type, default=false} -actions[54] = {name="Server Identifier", func=read_ip, default=true} -actions[55] = {name="Parameter Request List (client)", func=read_string, default=false} -actions[56] = {name="Error Message", func=read_string, default=true} -actions[57] = {name="Maximum DHCP Message Size", func=read_2_bytes, default=false} -actions[58] = {name="Renewal Time Value", func=read_time, default=false} -actions[59] = {name="Rebinding Time Value", func=read_time, default=false} -actions[60] = {name="Class Identifier", func=read_string, default=false} -actions[61] = {name="Client Identifier (client)", func=read_string, default=false} - - - ----Build a DHCP packet. --- ---@param request_type The type of request (such as DHCPINFORM). See the request_types --- table. ---@param ip_address The ip address (as a 4-byte string) where the server will send the response. --- Generally, it'll be host.bin_ip_src or 255.255.255.255. ---@param mac_address The mac address (as a string no more than 16 bytes) where the server will --- send the response. Generally, this will be host.mac_addr_src or --- simply a blank string (""). The field will be padded to 16 bytes with null bytes. ---@param request_options [optional] The options to request from the server, as a string of bytes where each --- byte represents a single option. For the types of options, see rfc2132. Some DHCP --- servers (such as my Linksys WRT54g) will ignore this list and send whichever options --- it wants. Options won't necessarily be honoured, it's up to the server what it sends --- back. By default, all options (1..61) are requested. ---@param overrides [optional] A table of overrides. If a field in the table matches a field in the DHCP --- packet (see rfc2131 section 2 for a list of possible fields. Or, just look at the --- code. ---@param lease_time [optional] The lease time for which to request an IP. Default: 1 second. ---@return The packet, as a string. It should be sent to the server on UDP/67. -local function dhcp_build(request_type, ip_address, mac_address, request_options, overrides, lease_time) - local packet = '' - - if(overrides == nil) then - overrides = {} - end - - if(request_options == nil) then - -- Request the defaults, or there's no verbosity; otherwise, request everything! - request_options = '' - for i = 1, 61, 1 do - if(nmap.verbosity() > 0) then - request_options = request_options .. string.char(i) - else - if(actions[i] and actions[i].default) then - request_options = request_options .. string.char(i) - end - end - end - end - - -- Header - packet = packet .. bin.pack(">CCCC", overrides['op'] or 1, overrides['htype'] or 1, overrides['hlen'] or 6, overrides['hops'] or 0) -- BOOTREQUEST, 10mb ethernet, 6 bytes long, 0 hops - packet = packet .. bin.pack(">I", overrides['xid'] or 0x4e4d4150) -- Transaction ID - packet = packet .. bin.pack(">SS", overrides['secs'] or 0, overrides['flags'] or 0x0000) -- Secs, flags - packet = packet .. bin.pack("A", ip_address) -- Client address - packet = packet .. bin.pack("I", overrides['cookie'] or 0x63825363) -- Magic cookie - - -- Options - packet = packet .. bin.pack(">CCC", 0x35, 1, request_type) -- Request type - packet = packet .. bin.pack(">CCA", 0x37, #request_options, request_options) -- Request options - packet = packet .. bin.pack(">CCI", 0x33, 4, lease_time or 1) -- Lease time - - - packet = packet .. bin.pack(">C", 0xFF) -- Termination - - return packet -end - ----Parse a DHCP packet (either a request or a response) and return the results as a table. The --- table at the top of this function (actions) defines the name of each field, as --- laid out in rfc2132, and the function that parses it. --- --- In theory, this should be able to parse any valid DHCP packet. --- ---@param data The DHCP packet data. Any padding at the end of the packet will be ignored (by default, --- DHCP packets are padded with \x00 bytes). -local function dhcp_parse(data) - local pos = 1 - local result = {} - - -- Receive the first bit and make sure we got the correct operation back - pos, result['op'], result['htype'], result['hlen'], result['hops'] = bin.unpack(">CCCC", data, pos) - if(result['op'] ~= 2) then - return false, string.format("DHCP server returned invalid reply ('op' wasn't BOOTREPLY (it was 0x%02x))", result['op']) - end - - -- Confirm the transaction id - pos, result['xid'] = bin.unpack(">I", data, pos) - if(result['xid'] ~= 0x4e4d4150) then - return false, string.format("DHCP server returned invalid reply (transaction id didn't match (0x%08x != ))", result['xid'], 0x4e4d4150) - end - - -- Unpack the secs, flags, addresses, sname, and file - pos, result['secs'], result['flags'] = bin.unpack(">SS", data, pos) - pos, result['ciaddr'] = bin.unpack("I", data, pos) - if(result['cookie'] ~= 0x63825363) then - return false, "DHCP server returned invalid reply (the magic cookie was invalid)" - end - - -- Parse the options - result['options'] = {} - while true do - local option, length - pos, option, length = bin.unpack(">CC", data, pos) - - -- Check for termination condition - if(option == 0xFF) then - break; - end - - -- Get the action from the array, based on the code - local action = actions[option] - - -- Verify we got a valid code (if we didn't, we're probably in big trouble) - if(action == nil) then - stdnse.print_debug(1, "dhcp-discover: Unknown option: %d", option) - pos = pos + length - else - -- Call the function to parse the option, and insert the result into our results table - local value - - stdnse.print_debug(2, "dhcp-discover: Attempting to parse %s", action['name']) - pos, value = action['func'](data, pos, length) - - if(nmap.verbosity() == 0 and action.default == false) then - stdnse.print_debug(1, "dhcp-discover: Server returned unrequested option (%s => %s)", action['name'], value) - - else - if(value) then - table.insert(result['options'], {name=action['name'], value=value}) - else - stdnse.print_debug(1, "dhcp-discover: Couldn't determine value for %s", action['name']); - end - end - end - - -- Handle the 'Option Overload' option specially -- if it's set, it tells us to use the file and/or sname values after we - -- run out of data. - if(option == 52) then - if(value == 1) then - data = data .. result['file'] - elseif(value == 2) then - data = data .. result['sname'] - elseif(value == 3) then - data = data .. result['file'] .. result['sname'] - else - stdnse.print_debug(1, "dhcp-discover: Warning: 'Option Overload' gave an unsupported value: %d", value) - end - end - end - - return true, result -end +-- We will want to run as a prerule any time +--prerule = function() +-- return true +--end local function go(host, port) - local pcap, socket - local status, err, data - local result - local results = {} - - local timeout = 5000 - if(nmap.registry.args.timeout) then - timeout = tonumber(nmap.registry.args.timeout) - end - - local requests = 1 - if(nmap.registry.args.requests) then - requests = tonumber(nmap.registry.args.requests) - end - - -- Verify we have a IPv4 address - if(string.len(host.bin_ip_src) ~= 4) then - return false, "Sorry, dhcp-discover only supports IPv4!" - end - - -- Verify we have a MAC address - if(string.len(host.mac_addr_src) ~= 6) then - return false, "Sorry, dhcp-discover only supports Ethernet!" - end - - -- Create a pcap socket to listen for the response (this is a HUGE hack. TODO: Fix once I can set the source port) - pcap = nmap.new_socket() - pcap:pcap_open(host.interface, 590, 0, callback, "udp port 68") - stdnse.print_debug(1, "dhcp-discover: Setting socket timeout to %ds", timeout) - pcap:set_timeout(timeout) - - -- Create the UDP socket - socket = nmap.new_socket() - status, err = socket:connect(host, port) - if(status == false) then - return false, "Couldn't create socket: " .. err - end - -- We're going to need some low quality random numbers math.randomseed(os.time()) + -- Set up a fake host for prerule + if(not(host)) then + host = {} + host.mac_addr_src = string.char(0xFF) .. string.char(0xFF) .. string.char(0xFF) .. string.char(0xFF) .. string.char(0xFF) .. string.char(0xFF) + host.ip = "255.255.255.255" + host.interface = "eth0" -- TODO: I'd like to have a better way of doing this + end + -- Create fake requests if the user asked to. These are fired and forgotten, we ignore the responses. if(nmap.registry.args.fake_requests) then for i=1, tonumber(nmap.registry.args.fake_requests), 1 do -- Build and send a DHCP request using the specified request type, or DHCPDISCOVER - local request_type = request_types[nmap.registry.args.dhcptype or "DHCPDISCOVER"] + local request_type = dhcp.request_types[nmap.registry.args.dhcptype or "DHCPDISCOVER"] if(request_type == nil) then - return false, "Valid request types: " .. stdnse.strjoin(", ", request_types_str) + return false, "Valid request types: " .. stdnse.strjoin(", ", dhcp.request_types_str) end - -- Generate the MAC address, if it's random + -- Generate the MAC address, if it's random (TODO: if I can enumerate interfaces, I should fall back to that instead) local mac_addr = host.mac_addr_src if(nmap.registry.args.randomize_mac == 'true' or nmap.registry.args.randomize_mac == '1') then stdnse.print_debug(2, "dhcp-discover: Generating a random MAC address") @@ -651,22 +111,22 @@ local function go(host, port) end end - -- Build and send the packet - local response = dhcp_build(request_type, host.bin_ip_src, mac_addr, nil, {xid=i}) - socket:send(response) - + local status, result = dhcp.make_request(host.ip, host.interface, request_type, "0.0.0.0", mac_addr) + if(status == false) then + stdnse.print_debug(1, "dhcp-discover: Couldn't send DHCP request: %s", result) + return false, "Couldn't send DHCP request: " .. result + end end end -- Build and send a DHCP request using the specified request type, or DHCPDISCOVER + local requests = tonumber(nmap.registry.args.requests or 1) + local results = {} for i = 1, requests, 1 do - -- Register the packet cap - pcap:pcap_register("NMAP") - -- Decide which type of request to make - local request_type = request_types[nmap.registry.args.dhcptype or "DHCPDISCOVER"] + local request_type = dhcp.request_types[nmap.registry.args.dhcptype or "DHCPDISCOVER"] if(request_type == nil) then - return false, "Valid request types: " .. stdnse.strjoin(", ", request_types_str) + return false, "Valid request types: " .. stdnse.strjoin(", ", dhcp.request_types_str) end -- Generate the MAC address, if it's random @@ -679,42 +139,15 @@ local function go(host, port) end end - -- Build and send the packet - stdnse.print_debug(2, "dhcp-discover: Sending DHCP request #%d", i) - local response = dhcp_build(request_type, host.bin_ip_src, mac_addr) - socket:send(response) - -- Receive the result - status, err, _, data = pcap:pcap_receive() + local status, result = dhcp.make_request(host.ip, host.interface, request_type, "0.0.0.0", mac_addr) if(status == false) then - stdnse.print_debug(1, "dhcp-discover: Error calling pcap_receive(): %s", err) - return false, "Error calling pcap_receive(): " .. err - end - - -- If no data was captured (ie, a timeout), return what, if anything, we have - if(data == nil) then - stdnse.print_debug(1, "dhcp-discover: Error calling pcap_receive(): TIMEOUT") - if(#results > 0) then - return true, results - else - return false, "Error calling pcap_receive(): TIMEOUT" - end - end - - -- Cut off the address/transport headers - data = string.sub(data, 29) -- I doubt this is the right way to do this, but since we're only supporting IPv4 + UDP, maybe it'll work? - - -- Parse the result - - status, result = dhcp_parse(data) - if(status == false) then - stdnse.print_debug(1, "dhcp-discover: Couldn't parse DHCP packet: %s", result) - return false, "Couldn't parse DHCP packet: " .. result + stdnse.print_debug(1, "dhcp-discover: Couldn't send DHCP request: %s", result) + return false, "Couldn't send DHCP request: " .. result end table.insert(results, result) end - socket:close() -- Done! return true, results @@ -733,24 +166,31 @@ action = function(host, port) end -- Set the port state to open - nmap.set_port_state(host, port, "open") + if(host) then + nmap.set_port_state(host, port, "open") + end local response = {} -- Display the results for i, result in ipairs(results) do - if(#results ~= 1) then - table.insert(response, string.format("Result %d", i)) - end + local result_table = {} - table.insert(response, string.format("IP Offered: %s", result.yiaddr_str)) + table.insert(result_table, string.format("IP Offered: %s", result.yiaddr_str)) for _, v in ipairs(result.options) do if(type(v['value']) == 'table') then - table.insert(response, string.format("%s: %s", v['name'], stdnse.strjoin(", ", v['value']))) + table.insert(result_table, string.format("%s: %s", v['name'], stdnse.strjoin(", ", v['value']))) else - table.insert(response, string.format("%s: %s\n", v['name'], v['value'])) + table.insert(result_table, string.format("%s: %s\n", v['name'], v['value'])) end end + + if(#results == 1) then + response = result_table + else + result_table['name'] = string.format("Result %d of %d", i, #results) + table.insert(response, result_table) + end end return stdnse.format_output(true, response)