--- A minimalistic PPPoE (Point-to-point protocol over Ethernet) -- library, implementing basic support for PPPoE -- Discovery and Configuration requests. The PPPoE protocol is ethernet based -- and hence does not use any IPs or port numbers. -- -- The library contains a number of classes to support packet creation, -- parsing and sending/receiving responses. The classes are: -- o LCP - Contains classes to build and parse PPPoE LCP requests and -- responses. -- -- o PPPoE - Contains classes to build and parse PPPoE requests and -- responses. -- -- o Comm - Contains some basic functions for sending and receiving -- LCP and PPPoE requests and responses. -- -- o Helper- The Helper class serves as the main interface between scripts -- and the library. -- -- -- @author Patrik Karlsson -- local rand = require "rand" local nmap = require "nmap" local packet = require "packet" local stdnse = require "stdnse" local string = require "string" local table = require "table" _ENV = stdnse.module("pppoe", stdnse.seeall) -- A Class to handle the Link Control Protocol LCP LCP = { ConfigOption = { RESERVED = 0, MRU = 1, AUTH_PROTO = 3, QUAL_PROTO = 4, MAGIC_NUMBER = 5, PROTO_COMPR = 7, ACFC = 8, -- Value has already been encoded, treat it as a byte stream RAW = -1, -- Creates a new config option -- @param option number containing the configuration option -- @param value containing the configuration option value -- @param raw string containing the configuration options raw value -- @return o new instance of ConfigOption new = function(self, option, value, raw) local o = { option = option, value = value, raw = raw, } setmetatable(o, self) self.__index = self return o end, -- Parses a byte stream and builds a new instance of the ConfigOption -- class -- @param data string containing raw bytes to parse -- @return o instance of ConfigOption parse = function(data) local opt, pos, len = {}, 1, 0 opt.option, len, pos = string.unpack("BB", data, pos) opt.raw, pos = string.unpack("c" .. ( len - 2 ), data, pos) -- MRU if ( 1 == opt.option ) then opt.value = string.unpack(">I2", opt.raw) end return LCP.ConfigOption:new(opt.option, opt.value, opt.raw) end, -- Converts the class instance to string -- @return string containing the raw config option __tostring = function(self) -- MRU if ( self.raw ) then return string.pack(">BB", self.option, #self.raw + 2) .. self.raw elseif( 1 == self.option ) then return string.pack(">BBI2", 1, 4, self.value) else error( ("Unsupported configuration option %d"):format(self.option) ) end end, }, -- A class to hold multiple ordered config options ConfigOptions = { new = function(self, options) local o = { options = options or {}, } setmetatable(o, self) self.__index = self return o end, -- Adds a new config option to the table -- @param option instance of ConfigOption add = function(self, option) table.insert(self.options, option) end, -- Gets a config option by ID -- @param opt number containing the configuration option to retrieve -- @return v instance of ConfigOption getById = function(self, opt) for _, v in ipairs(self.options) do if ( v.option == opt ) then return v end end end, -- Returns all config options in an ordered table -- @return tab table containing all configuration options getTable = function(self) local tab = {} for _, v in ipairs(self.options) do table.insert(tab, v) end return tab end, -- Parses a byte stream and builds a new instance of the ConfigOptions -- class -- @param data string containing raw bytes to parse -- @return o instance of ConfigOption parse = function(data) local options = LCP.ConfigOptions:new() local pos, opt, opt_val, len repeat opt, len, pos = string.unpack(">BB", data, pos) if ( 0 == opt ) then break end opt_val, pos = string.unpack("c"..len, data, (pos - 2)) options:add(LCP.ConfigOption.parse(opt_val)) until( pos == #data ) return options end, -- Converts the class instance to string -- @return string containing the raw config option __tostring = function(self) local str = "" for _, v in ipairs(self.options) do str = str .. tostring(v) end return str end, }, ConfigOptionName = { [0] = "Reserved", [1] = "Maximum receive unit", [3] = "Authentication protocol", [4] = "Quality protocol", [5] = "Magic number", [7] = "Protocol field compression", [8] = "Address and control field compression", }, Code = { CONFIG_REQUEST = 1, CONFIG_ACK = 2, CONFIG_NAK = 3, TERMINATE_REQUEST = 5, TERMINATE_ACK = 6, }, -- The LCP Header Header = { -- Creates a new instance of the LCP header -- @param code number containing the LCP code of the request -- @param identifier number containing the LCP identifier new = function(self, code, identifier) local o = { code = code, identifier = identifier or 1, length = 0, } setmetatable(o, self) self.__index = self return o end, -- Parses a byte stream and builds a new instance of the Header class -- @param data string containing raw bytes to parse -- @return o instance of ConfigOption parse = function(data) local header = LCP.Header:new() header.code, header.identifier, header.length = string.unpack(">BBI2", data) return header end, -- Converts the class instance to string -- @return string containing the raw config option __tostring = function(self) return string.pack(">BBI2", self.code, self.identifier, self.length) end, }, ConfigRequest = { -- Creates a new instance of the ConfigRequest class -- @param identifier number containing the LCP identifier -- @param options table of LCP.ConfigOption options -- @return o instance of ConfigRequest new = function(self, identifier, options) local o = { header = LCP.Header:new(LCP.Code.CONFIG_REQUEST, identifier), options = LCP.ConfigOptions:new(options) } setmetatable(o, self) self.__index = self return o end, -- Parses a byte stream and builds a new instance of the ConfigRequest -- class -- @param data string containing raw bytes to parse -- @return o instance of ConfigRequest parse = function(data) local req = LCP.ConfigRequest:new() req.header = LCP.Header.parse(data) req.options = LCP.ConfigOptions.parse(data:sub(#tostring(req.header) + 1)) return req end, -- Converts the class instance to string -- @return string containing the raw config option __tostring = function(self) self.header.length = 4 + #tostring(self.options) return tostring(self.header) .. tostring(self.options) end, }, ConfigNak = { -- Creates a new instance of the ConfigNak class -- @param identifier number containing the LCP identifier -- @param options table of LCP.ConfigOption options -- @return o instance of ConfigNak new = function(self, identifier, options) local o = { header = LCP.Header:new(LCP.Code.CONFIG_NAK, identifier), options = LCP.ConfigOptions:new(options), } setmetatable(o, self) self.__index = self return o end, -- Converts the class instance to string -- @return string containing the raw config option __tostring = function(self) self.header.length = 4 + #tostring(self.options) return tostring(self.header) .. tostring(self.options) end, }, ConfigAck = { -- Creates a new instance of the ConfigAck class -- @param identifier number containing the LCP identifier -- @param options table of LCP.ConfigOption options -- @return o instance of ConfigNak new = function(self, identifier, options) local o = { header = LCP.Header:new(LCP.Code.CONFIG_ACK, identifier), options = LCP.ConfigOptions:new(options), } setmetatable(o, self) self.__index = self return o end, -- Parses a byte stream and builds a new instance of the ConfigAck class -- @param data string containing raw bytes to parse -- @return o instance of ConfigRequest parse = function(data) local ack = LCP.ConfigAck:new() ack.header = LCP.Header.parse(data) ack.options = LCP.ConfigOptions.parse(data:sub(#tostring(ack.header) + 1)) return ack end, -- Converts the class instance to string -- @return string containing the raw config option __tostring = function(self) self.header.length = 4 + #tostring(self.options) return tostring(self.header) .. tostring(self.options) end, }, TerminateRequest = { -- Creates a new instance of the TerminateRequest class -- @param identifier number containing the LCP identifier -- @return o instance of ConfigNak new = function(self, identifier, data) local o = { header = LCP.Header:new(LCP.Code.TERMINATE_REQUEST, identifier), data = data or "", } setmetatable(o, self) self.__index = self return o end, -- Converts the class instance to string -- @return string containing the raw config option __tostring = function(self) self.header.length = 4 + #self.data return tostring(self.header) .. self.data end, } } -- The PPPoE class PPPoE = { -- Supported PPPoE codes (requests/responses) Code = { SESSION_DATA = 0x00, PADO = 0x07, PADI = 0x09, PADR = 0x19, PADS = 0x65, PADT = 0xa7, }, -- Support PPPoE Tag types TagType = { SERVICE_NAME = 0x0101, AC_NAME = 0x0102, HOST_UNIQUE = 0x0103, AC_COOKIE = 0x0104, }, -- Table used to convert table IDs to Names TagName = { [0x0101] = "Service-Name", [0x0102] = "AC-Name", [0x0103] = "Host-Uniq", [0x0104] = "AC-Cookie", }, Header = { -- Creates a new instance of the PPPoE header class -- @param code number containing the PPPoE code -- @param session number containing the PPPoE session -- @return o instance of Header new = function(self, code, session) local o = { version = 1, type = 1, code = code, session = session or 0, length = 0, } setmetatable(o, self) self.__index = self return o end, -- Parses a byte stream and builds a new instance of the class -- @param data string containing raw bytes to parse -- @return o instance of Header parse = function(data) local header = PPPoE.Header:new() local vertyp vertyp, header.code, header.session, header.length = string.unpack(">BBI2I2", data) header.version = (vertyp >> 4) header.type = (vertyp & 0x0F) return header end, -- Converts the instance to string -- @return string containing the raw config option __tostring = function(self) local vertype = (self.version << 4) + self.type return string.pack(">BBI2I2", vertype, self.code, self.session, self.length) end, }, -- The TAG NVP Class Tag = { -- Creates a new instance of the Tag class -- @param tag number containing the tag type -- @param value string/number containing the tag value -- @return o instance of Tag new = function(self, tag, value) local o = { tag = tag, value = value or "" } setmetatable(o, self) self.__index = self return o end, -- Converts the instance to string -- @return string containing the raw config option __tostring = function(self) return string.pack(">I2s2", self.tag, self.value) end, }, PADI = { -- Creates a new instance of the PADI class -- @param tags table of PPPoE.Tag instances -- @param value string/number containing the tag value -- @return o instance of ConfigNak new = function(self, tags) local c = rand.random_string(8) local o = { header = PPPoE.Header:new(PPPoE.Code.PADI), tags = tags or { PPPoE.Tag:new(PPPoE.TagType.SERVICE_NAME), PPPoE.Tag:new(PPPoE.TagType.HOST_UNIQUE, c) } } setmetatable(o, self) self.__index = self return o end, -- Converts the instance to string -- @return string containing the raw config option __tostring = function(self) local tags = "" for _, tag in ipairs(self.tags) do tags = tags .. tostring(tag) end self.header.length = #tags return tostring(self.header) .. tags end, }, PADO = { -- Creates a new instance of the PADO class -- @return o instance of PADO new = function(self) local o = { tags = {} } setmetatable(o, self) self.__index = self return o end, -- Parses a byte stream and builds a new instance of the class -- @param data string containing raw bytes to parse -- @return o instance of PADO parse = function(data) local pado = PPPoE.PADO:new() pado.header = PPPoE.Header.parse(data) local pos = #tostring(pado.header) + 1 pado.data = data:sub(pos) repeat local tag, decoded, raw tag, raw, pos = string.unpack(">I2s2", pos) if ( PPPoE.TagDecoder[tag] ) then decoded = PPPoE.TagDecoder[tag](raw) else stdnse.debug1("PPPoE: Unsupported tag (%d)", tag) end local t = PPPoE.Tag:new(tag, raw) t.decoded = decoded table.insert(pado.tags, t) until( pos >= #data ) return pado end, }, PADR = { -- Creates a new instance of the PADR class -- @param tags table of PPPoE.Tag instances -- @return o instance of PADR new = function(self, tags) local o = { tags = tags or {}, header = PPPoE.Header:new(PPPoE.Code.PADR) } setmetatable(o, self) self.__index = self return o end, -- Converts the instance to string -- @return string containing the raw config option __tostring = function(self) local tags = "" for _, tag in ipairs(self.tags) do tags = tags .. tostring(tag) end self.header.length = #tags return tostring(self.header) .. tags end, }, PADS = { -- Creates a new instance of the PADS class -- @return o instance of PADS new = function(self) local o = { tags = {} } setmetatable(o, self) self.__index = self return o end, -- Parses a byte stream and builds a new instance of the class -- @param data string containing raw bytes to parse -- @return o instance of PADS parse = function(data) local pads = PPPoE.PADS:new() pads.header = PPPoE.Header.parse(data) local pos = #tostring(pads.header) + 1 pads.data = data:sub(pos) return pads end, }, PADT = { -- Creates a new instance of the PADT class -- @param session number containing the PPPoE session -- @return o instance of PADT new = function(self, session) local o = { header = PPPoE.Header:new(PPPoE.Code.PADT) } setmetatable(o, self) o.header.session = session self.__index = self return o end, -- Parses a byte stream and builds a new instance of the class -- @param data string containing raw bytes to parse -- @return o instance of PADI parse = function(data) local padt = PPPoE.PADT:new() padt.header = PPPoE.Header.parse(data) return padt end, -- Converts the instance to string -- @return string containing the raw config option __tostring = function(self) return tostring(self.header) end, }, SessionData = { -- Creates a new instance of the SessionData class -- @param session number containing the PPPoE session -- @param data string containing the LCP data to send -- @return o instance of ConfigNak new = function(self, session, data) local o = { data = data or "", header = PPPoE.Header:new(PPPoE.Code.SESSION_DATA) } setmetatable(o, self) o.header.session = session self.__index = self return o end, -- Parses a byte stream and builds a new instance of the class -- @param data string containing raw bytes to parse -- @return o instance of SessionData parse = function(data) local sess = PPPoE.SessionData:new() sess.header = PPPoE.Header.parse(data) local pos = #tostring(sess.header) + 1 + 2 sess.data = data:sub(pos) return sess end, -- Converts the instance to string -- @return string containing the raw config option __tostring = function(self) -- 2 for the encapsulation self.header.length = 2 + 4 + #self.data return tostring(self.header) .. "\xC0\x21" .. self.data end, } } -- A bunch of tag decoders PPPoE.TagDecoder = {} PPPoE.TagDecoder.decodeHex = stdnse.tohex PPPoE.TagDecoder.decodeStr = function(data) return data end PPPoE.TagDecoder[PPPoE.TagType.SERVICE_NAME] = PPPoE.TagDecoder.decodeStr PPPoE.TagDecoder[PPPoE.TagType.AC_NAME] = PPPoE.TagDecoder.decodeStr PPPoE.TagDecoder[PPPoE.TagType.AC_COOKIE] = PPPoE.TagDecoder.decodeHex PPPoE.TagDecoder[PPPoE.TagType.HOST_UNIQUE] = PPPoE.TagDecoder.decodeHex -- The Comm class responsible for communication with the PPPoE server Comm = { -- Creates a new instance of the Comm class -- @param iface string containing the interface name -- @param src_mac string containing the source MAC address -- @param dst_mac string containing the destination MAC address -- @return o new instance of Comm new = function(self, iface, src_mac, dst_mac) local o = { iface = iface, src_mac = src_mac, dst_mac = dst_mac, } setmetatable(o, self) self.__index = self return o end, -- Sets up the pcap receiving socket -- @return status true on success connect = function(self) self.socket = nmap.new_socket() self.socket:set_timeout(10000) local mac = stdnse.format_mac(self.src_mac) -- let's set a filter on PPPoE we can then check what packet is ours, -- based on the HOST_UNIQUE tag, if we need to self.socket:pcap_open(self.iface, 1500, false, "ether[0x0c:2] == 0x8863 or ether[0x0c:2] == 0x8864 and ether dst " .. mac) return true end, -- Sends a packet -- @param data class containing the request to send -- @return status true on success, false on failure send = function(self, data) local eth_type = ( data.header.code == PPPoE.Code.SESSION_DATA ) and 0x8864 or 0x8863 local ether = self.dst_mac .. self.src_mac .. string.pack(">I2", eth_type) local p = packet.Frame:new(ether .. tostring(data)) local sock = nmap.new_dnet() if ( not(sock) ) then return false, "Failed to create raw socket" end local status = sock:ethernet_open(self.iface) -- we don't actually need to do this as the script simply crashes -- if we don't have the right permissions at this point if ( not(status) ) then return false, "Failed to open raw socket" end status = sock:ethernet_send(p.frame_buf) if ( not(status) ) then return false, "Failed to send data" end sock:ethernet_close() return true end, -- Receive a response from the server -- @return status true on success, false on failure -- @return response class containing the response or -- err string on error recv = function(self) local status, _, l2, l3 = self.socket:pcap_receive() -- if we got no response, just return false as there's -- probably not really an error if ( not(status) ) then return false, "Did not receive any packets" end local header = PPPoE.Header.parse(l3) local p = packet.Frame:new(l2..l3) -- there's probably a more elegant way of doing this if ( packet.ETHER_TYPE_PPPOE_DISCOVERY == p.ether_type ) then if ( header.code == PPPoE.Code.PADO ) then local pado = PPPoE.PADO.parse(l3) pado.mac_srv = p.mac_src return true, pado elseif ( header.code == PPPoE.Code.PADS ) then local pads = PPPoE.PADS.parse(l3) return true, pads elseif ( header.code == PPPoE.Code.PADT ) then local pads = PPPoE.PADT.parse(l3) return true, pads end elseif ( packet.ETHER_TYPE_PPPOE_SESSION == p.ether_type ) then return true, PPPoE.SessionData.parse(l3) end return false, ("Received unsupported response, can't decode code (%d)"):format(header.code) end, -- Does an "exchange", ie, sends a request and waits for a response -- @param data class containing the request to send -- @return status true on success, false on failure -- @return response class containing the response or -- err string on error exch = function(self, data) local status, err = self:send(data) if ( not(status) ) then return false, err end local retries, resp = 3, nil repeat status, resp = self:recv() if ( data.header and 0 == data.header.session ) then return true, resp elseif ( data.header and data.header.session == resp.header.session ) then return true, resp end retries = retries - 1 until(retries == 0) return false, "Failed to retrieve proper PPPoE response" end, } -- The Helper class is the main script interface Helper = { -- Creates a new instance of Helper -- @param iface string containing the name of the interface to use -- @return o new instance on success, nil on failure new = function(self, iface) local o = { iface = iface, -- set the LCP identifier to 0 identifier = 0, } setmetatable(o, self) self.__index = self if ( not(nmap.is_privileged()) ) then return nil, "The PPPoE library requires Nmap to be run in privileged mode" end -- get src_mac local info = nmap.get_interface_info(iface) if ( not(info) or not(info.mac) ) then return nil, "Failed to get source MAC address" end o.comm = Comm:new(iface, info.mac) return o end, -- Sets up the pcap socket for listening and does some other preparations -- @return status true on success, false on failure connect = function(self) return self.comm:connect() end, -- Performs a PPPoE discovery initiation by sending a PADI request to the -- ethernet broadcast address -- @return status true on success, false on failure -- @return pado instance of PADO on success, err string on failure discoverInit = function(self) local padi = PPPoE.PADI:new() self.comm.dst_mac = ("\xFF"):rep(6) local status, err = self.comm:send(padi) if ( not(status) ) then return false, err end -- wait for a pado local pado, retries = nil, 3 repeat status, pado = self.comm:recv() if ( not(status) ) then return status, pado end retries = retries - 1 until( pado.tags or retries == 0 ) if ( not(pado.tags) ) then return false, "PADO response contained no tags" end local pado_host_unique for _, tag in ipairs(pado.tags) do if ( PPPoE.TagType.HOST_UNIQUE == tag.tag ) then pado_host_unique = tag.raw end end -- store the tags for later use self.tags = pado.tags self.comm.dst_mac = pado.mac_srv if ( pado_host_unique and pado_host_unique ~= padi.tags[PPPoE.TagType.HOST_UNIQUE] ) then -- currently, we don't handle this, we probably should -- in order to do so, we need to split the function exch -- to recv and send return false, "Got incorrect answer" end return true, pado end, -- Performs a Discovery Request by sending PADR to the PPPoE ethernet -- address -- @return status true on success, false on failure -- @return pads instance of PADS on success discoverRequest = function(self) -- remove the AC-Name tag if there is one local function getTag(tag) for _, t in ipairs(self.tags) do if ( tag == t.tag ) then return t end end end local taglist = { PPPoE.TagType.SERVICE_NAME, PPPoE.TagType.HOST_UNIQUE, PPPoE.TagType.AC_COOKIE } local tags = {} for _, t in ipairs(taglist) do if ( getTag(t) ) then table.insert(tags, getTag(t)) end end local padr = PPPoE.PADR:new(tags) local status, pads = self.comm:exch(padr) if ( status ) then self.session = pads.header.session end return status, pads end, -- Attempts to specify a method for authentication -- If the server responds with another method it's NAK:ed and we try to set -- our requested method instead. If this fails, we return a failure -- @param method string containing one of the following methods: -- MSCHAPv1, MSCHAPv2 or PAP -- @return status true on success, false on failure -- err string containing error message on failure setAuthMethod = function(self, method) local AuthMethod = { methods = { { name = "EAP", value = "\xC2\x27" }, { name = "MSCHAPv1", value = "\xC2\x23\x80" }, { name = "MSCHAPv2", value = "\xC2\x23\x81" }, { name = "PAP", value = "\xC0\x23" }, } } AuthMethod.byName = function(name) for _, m in ipairs(AuthMethod.methods) do if ( m.name == name ) then return m end end end AuthMethod.byValue = function(value) for _, m in ipairs(AuthMethod.methods) do if ( m.value == value ) then return m end end end local auth_data = ( AuthMethod.byName(method) and AuthMethod.byName(method).value ) if ( not(auth_data) ) then return false, ("Unsupported authentication mode (%s)"):format(method) end self.identifier = self.identifier + 1 -- First do a Configuration Request local options = { LCP.ConfigOption:new(LCP.ConfigOption.MRU, 1492) } local lcp_req = LCP.ConfigRequest:new(self.identifier, options) local sess_req = PPPoE.SessionData:new(self.session, tostring(lcp_req)) local status, resp = self.comm:exch(sess_req) if ( not(status) or PPPoE.Code.SESSION_DATA ~= resp.header.code ) then return false, "Unexpected packet type was received" end -- Make sure we got a Configuration Request in return local lcp_header = LCP.Header.parse(resp.data) if ( LCP.Code.CONFIG_REQUEST ~= lcp_header.code ) then return false, ("Unexpected packet type was received (%d)"):format(lcp_header.code) end local config_req = LCP.ConfigRequest.parse(resp.data) if ( not(config_req.options) ) then return false, "Failed to retrieve any options from response" end local auth_proposed = config_req.options:getById(LCP.ConfigOption.AUTH_PROTO) if ( auth_proposed.raw ~= auth_data ) then local options = { LCP.ConfigOption:new(LCP.ConfigOption.AUTH_PROTO, nil, auth_data) } local lcp_req = LCP.ConfigNak:new(self.identifier, options) local sess_req = PPPoE.SessionData:new(self.session, tostring(lcp_req)) local status, resp = self.comm:exch(sess_req) if ( not(status) or PPPoE.Code.SESSION_DATA ~= resp.header.code ) then return false, "Unexpected packet type was received" end -- Make sure we got a Configuration Request in return local lcp_header = LCP.Header.parse(resp.data) if ( LCP.Code.CONFIG_REQUEST ~= lcp_header.code ) then return false, ("Unexpected packet type was received (%d)"):format(lcp_header.code) end config_req = LCP.ConfigRequest.parse(resp.data) -- if the authentication methods match, send an ACK if ( config_req.options:getById(LCP.ConfigOption.AUTH_PROTO).raw == auth_data ) then -- The ACK is essential the Config Request, only with a different code -- Do a dirty attempt to just replace the code and send the request back as an ack self.identifier = self.identifier + 1 local lcp_req = LCP.ConfigAck:new(config_req.header.identifier, config_req.options:getTable()) local sess_req = PPPoE.SessionData:new(self.session, tostring(lcp_req)) local status, resp = self.comm:send(sess_req) return true end return false, "Authentication method was not accepted" end return false, "Failed to negotiate authentication mechanism" end, -- Sends a LCP Terminate Request and waits for an ACK -- Attempts to do so 10 times before aborting -- @return status true on success false on failure close = function(self) local tries = 10 repeat if ( 0 == self.session ) then break end local lcp_req = LCP.TerminateRequest:new(self.identifier) local sess_req = PPPoE.SessionData:new(self.session, tostring(lcp_req)) local status, resp = self.comm:exch(sess_req) if ( status and resp.header and resp.header.code ) then if ( PPPoE.Code.SESSION_DATA == resp.header.code ) then local lcp_header = LCP.Header.parse(resp.data) if ( LCP.Code.TERMINATE_ACK == lcp_header.code ) then break end end end tries = tries - 1 until( tries == 0 ) self.comm:exch(PPPoE.PADT:new(self.session)) return true end, } return _ENV;