From d02dafb6300bbe1caa7eadc1e75cff6e75d1ad2d Mon Sep 17 00:00:00 2001 From: patrik Date: Mon, 7 May 2012 18:49:22 +0000 Subject: [PATCH] o [NSE] Added the Apache JServer Protocol (AJP) library and the scripts ajp-methods, ajp-headers and ajp-auth. [Patrik Karlsson] --- CHANGELOG | 3 + nselib/ajp.lua | 430 ++++++++++++++++++++++++++++++++++++++++ scripts/ajp-auth.nse | 72 +++++++ scripts/ajp-headers.nse | 46 +++++ scripts/ajp-methods.nse | 80 ++++++++ scripts/script.db | 3 + 6 files changed, 634 insertions(+) create mode 100644 nselib/ajp.lua create mode 100644 scripts/ajp-auth.nse create mode 100644 scripts/ajp-headers.nse create mode 100644 scripts/ajp-methods.nse diff --git a/CHANGELOG b/CHANGELOG index 9f1310342..575d228e2 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,8 @@ # Nmap Changelog ($Id$); -*-text-*- +o [NSE] Added the Apache JServer Protocol (AJP) library and the scripts + ajp-methods, ajp-headers and ajp-auth. [Patrik Karlsson] + o In XML output, elements are now child elements of the they belong to. Old output was thus: ...... diff --git a/nselib/ajp.lua b/nselib/ajp.lua new file mode 100644 index 000000000..960645ba1 --- /dev/null +++ b/nselib/ajp.lua @@ -0,0 +1,430 @@ +--- +-- A basic AJP 1.3 implementation based on documentation available from Apache +-- mod_proxy_ajp; http://httpd.apache.org/docs/2.2/mod/mod_proxy_ajp.html +-- +-- @author "Patrik Karlsson " +-- +module(... or "ajp",package.seeall) + +local bin = require('bin') +local match = require('match') +local url = require('url') + +AJP = { + + -- The magic prefix that has to be present in all requests + Magic = 0x1234, + + -- Methods encoded as numeric values + Method = { + ['OPTIONS'] = 1, + ['GET'] = 2, + ['HEAD'] = 3, + ['POST'] = 4, + ['PUT'] = 5, + ['DELETE'] = 6, + ['TRACE'] = 7, + ['PROPFIND'] = 8, + ['PROPPATCH'] = 9, + ['MKCOL'] = 10, + ['COPY'] = 11, + ['MOVE'] = 12, + ['LOCK'] = 13, + ['UNLOCK'] = 14, + ['ACL'] = 15, + ['REPORT'] = 16, + ['VERSION-CONTROL'] = 17, + ['CHECKIN'] = 18, + ['CHECKOUT'] = 19, + ['UNCHECKOUT'] = 20, + ['SEARCH'] = 21, + ['MKWORKSPACE'] = 22, + ['UPDATE'] = 23, + ['LABEL'] = 24, + ['MERGE'] = 25, + ['BASELINE_CONTROL'] = 26, + ['MKACTIVITY'] = 27, + }, + + -- Request codes + Code = { + FORWARD_REQUEST = 2, + SEND_BODY = 3, + SEND_HEADERS = 4, + END_RESPONSE = 5, + SHUTDOWN = 7, + PING = 8, + CPING = 10, + }, + + -- Request attributes + Attribute = { + CONTEXT = 0x01, + SERVLET_PATH = 0x02, + REMOTE_USER = 0x03, + AUTH_TYPE = 0x04, + QUERY_STRING = 0x05, + JVM_ROUTE = 0x06, + SSL_CERT = 0x07, + SSL_CIPHER = 0x08, + SSL_SESSION = 0x09, + REQ_ATTRIBUTE= 0x0A, + SSL_KEY_SIZE = 0x0B, + ARE_DONE = 0xFF, + }, + + ForwardRequest = { + + -- Common headers encoded as numeric values + Header = { + ['accept'] = 0xA001, + ['accept-charset'] = 0xA002, + ['accept-encoding'] = 0xA003, + ['accept-language'] = 0xA004, + ['authorization'] = 0xA005, + ['connection'] = 0xA006, + ['content-type'] = 0xA007, + ['content-length'] = 0xA008, + ['cookie'] = 0xA009, + ['cookie2'] = 0xA00A, + ['host'] = 0xA00B, + ['pragma'] = 0xA00C, + ['referer'] = 0xA00D, + ['user-agent'] = 0xA00E, + }, + + new = function(self, host, port, method, uri, headers, attributes, options) + local o = { + host = host, + magic = 0x1234, + length = 0, + code = AJP.Code.FORWARD_REQUEST, + method = AJP.Method[method], + version = "HTTP/1.1", + uri = uri, + raddr = options.raddr or "127.0.0.1", + rhost = options.rhost or "", + srv = host.ip, + port = port.number, + is_ssl = (port.service == "https"), + headers = headers or {}, + attributes = attributes or {}, + } + setmetatable(o, self) + self.__index = self + return o + end, + + __tostring = function(self) + + -- encodes a string, prefixing it with a 2-byte length + -- and suffixing it with a zero. P-encoding can't be used + -- as the zero terminator should not be counted in the length + local function encstr(str) + if ( not(str) or #str == 0 ) then + return bin.pack(">S", 0xFFFF) + end + return bin.pack(">Sz", #str, str) + end + + -- count the number of headers + local function headerCount() + local i = 0 + for _, _ in pairs(self.headers) do i = i + 1 end + return i + end + + -- add host header if it's missing + if ( not(self.headers['host']) ) then + self.headers['host'] = self.host.ip + end + + -- add keep-alive connection header if missing + if ( not(self.headers['connection']) ) then + self.headers['connection'] = "keep-alive" + end + + local p_url = url.parse(self.uri) + + -- save the magic and data for last + local data = bin.pack(">CCAAAAASCS", self.code, self.method, + encstr(self.version), encstr(p_url.path), encstr(self.raddr), + encstr(self.rhost), encstr(self.srv), + self.port, (self.is_ssl and 1 or 0), + headerCount()) + + -- encode headers + for k, v in pairs(self.headers) do + local header = AJP.ForwardRequest.Header[k:lower()] or k + if ( "string" == type(header) ) then + data = data .. bin.pack(">Sz", #header, header) + else + data = data .. bin.pack(">S", header) + end + + data = data .. encstr(v) + end + + -- encode attributes + if ( p_url.query ) then + data = data .. bin.pack("C", AJP.Attribute.QUERY_STRING) + data = data .. encstr(p_url.query) + end + + -- terminate the attribute list + data = data .. bin.pack("C", AJP.Attribute.ARE_DONE) + + -- returns the AJP request as a string + return bin.pack(">SSA", AJP.Magic, #data, data) + end, + + }, + + Response = { + + Header = { + ['Content-Type'] = 0xA001, + ['Content-Language'] = 0xA002, + ['Content-Length'] = 0xA003, + ['Date'] = 0xA004, + ['Last-Modified'] = 0xA005, + ['Location'] = 0xA006, + ['Set-Cookie'] = 0xA007, + ['Set-Cookie2'] = 0xA008, + ['Servlet-Engine'] = 0xA009, + ['Status'] = 0xA00A, + ['WWW-Authenticate'] = 0xA00B, + }, + + SendHeaders = { + + new = function(self) + local o = { headers = {}, rawheaders = {} } + setmetatable(o, self) + self.__index = self + return o + end, + + parse = function(data) + local sh = AJP.Response.SendHeaders:new() + local pos = 6 + local status_msg, hdr_count + + pos, sh.status = bin.unpack(">S", data, pos) + pos, status_msg = bin.unpack(">P", data, pos) + pos = pos + 1 + sh['status-line'] = ("AJP1.3 %d %s"):format(sh.status, status_msg) + + pos, hdr_count = bin.unpack(">S", data, pos) + + local function headerById(id) + for k, v in pairs(AJP.Response.Header) do + if ( v == id ) then return k end + end + end + + + for i=1, hdr_count do + local key, val, len + pos, len = bin.unpack(">S", data, pos) + + if ( len < 0xA000 ) then + pos, key = bin.unpack("A"..len, data, pos) + pos = pos + 1 + else + key = headerById(len) + end + + pos, val = bin.unpack(">P", data, pos) + pos = pos + 1 + + sh.headers[key:lower()] = val + + -- to keep the order, in which the headers were received, + -- add them to the rawheader table as well. This is based + -- on the same principle as the http library, however the + -- difference being that we have to "construct" the "raw" + -- format of the header, as we're receiving kvp's. + table.insert(sh.rawheaders, ("%s: %s"):format(key,val)) + end + return sh + end, + + }, + + }, + +} + +-- The Comm class handles sending and receiving AJP requests/responses +Comm = { + + -- Creates a new Comm instance + new = function(self, host, port, options) + local o = { host = host, port = port, options = options or {}} + setmetatable(o, self) + self.__index = self + return o + end, + + -- Connects to the AJP server + -- + -- @return status true on success, false on failure + -- @return err string containing error message on failure + connect = function(self) + self.socket = nmap.new_socket() + self.socket:set_timeout(self.options.timeout or 5000) + return self.socket:connect(self.host, self.port) + end, + + -- Sends a request to the server + -- + -- @param req instance of object that can be serialized with tostring + -- @return status true on succes, false on failure + -- @return err string containing error message on failure + send = function(self, req) + return self.socket:send(tostring(req)) + end, + + -- Receives an AJP response from the server + -- + -- @return status true on succes, false on failure + -- @return response table containing the following fields, or string + -- containing error message on failure + -- status - status of response (see HTTP status codes) + -- status-line - the complete status line (eg. 200 OK) + -- body - the response body as string + -- headers - table of response headers + -- + receive = function(self) + local response = {} + while(true) do + local status, buf = self.socket:receive_buf(match.numbytes(4), true) + if ( not(status) ) then + return false, "Failed to receive response from server" + end + local pos, magic, length = bin.unpack(">A2S", buf) + if ( magic ~= "AB" ) then + return false, ("Invalid magic received from server (%s)"):format(magic) + end + local status, data = self.socket:receive_buf(match.numbytes(length), true) + if ( not(status) ) then + return false, "Failed to receive response from server" + end + + local pos, code = bin.unpack("C", data) + if ( AJP.Code.SEND_HEADERS == code ) then + local sh = AJP.Response.SendHeaders.parse(buf .. data) + response = sh + elseif( AJP.Code.SEND_BODY == code ) then + response.body = select(2, bin.unpack(">P", data, pos)) + elseif( AJP.Code.END_RESPONSE == code ) then + break + end + end + return true, response + end, + + -- Closes the socket + close = function(self) + return self.socket:close() + end, + +} + + +Helper = { + + --- Creates a new AJP Helper instance + -- + -- @param host table + -- @param port table + -- @param opt + -- @return o new Helper instance + new = function(self, host, port, opt) + local o = { host = host, port = port, opt = opt } + setmetatable(o, self) + self.__index = self + return o + end, + + --- Connects to the AJP server + -- + -- @return status true on success, false on failure + -- @return err string containing error message on failure + connect = function(self) + self.comm = Comm:new(self.host, self.port, self.opt) + return self.comm:connect() + end, + + --- Sends an AJP request to the server + -- + -- @param url string containing the URL to query + -- @param headers table containing optional headers + -- @param attributes table containing optional attributes + -- @return status true on succes, false on failure + -- @return response table (@see Comm.receive), or string containing error + -- message on failure + request = function(self, method, url, headers, attributes) + local status, lhost, lport, rhost, rport = self.comm.socket:get_info() + local options = {} + + if ( status ) then + options.raddr = rhost + end + + local request = AJP.ForwardRequest:new(self.host, self.port, method, url, headers, attributes, options) + if ( not(self.comm:send(request)) ) then + return false, "Failed to send request to server" + end + return self.comm:receive() + end, + + --- Sends an AJP GET request to the server + -- + -- @param url string containing the URL to query + -- @param headers table containing optional headers + -- @param attributes table containing optional attributes + -- @return status true on succes, false on failure + -- @return response table (@see Comm.receive), or string containing error + -- message on failure + get = function(self, url, headers, attributes) + return self:request("GET", url, headers, attributes) + end, + + --- Sends an AJP HEAD request to the server + -- + -- @param url string containing the URL to query + -- @param headers table containing optional headers + -- @param attributes table containing optional attributes + -- @return status true on succes, false on failure + -- @return response table (@see Comm.receive), or string containing error + -- message on failure + head = function(self, url, headers, attributes) + return self:request("HEAD", url, headers, attributes) + end, + + --- Sends an AJP OPTIONS request to the server + -- + -- @param url string containing the URL to query + -- @param headers table containing optional headers + -- @param attributes table containing optional attributes + -- @return status true on succes, false on failure + -- @return response table (@see Comm.receive), or string containing error + -- message on failure + options = function(self, url, headers, attributes) + return self:request("OPTIONS", url, headers, attributes) + end, + + -- should only work against 127.0.0.1 + shutdownContainer = function(self) + self.comm:send(bin.pack("H", "1234000107")) + self.comm:receive() + end, + + --- Disconnects from the server + close = function(self) + return self.comm:close() + end, + +} \ No newline at end of file diff --git a/scripts/ajp-auth.nse b/scripts/ajp-auth.nse new file mode 100644 index 000000000..8e1dc305d --- /dev/null +++ b/scripts/ajp-auth.nse @@ -0,0 +1,72 @@ +description = [[ +Retrieves the authentication scheme and realm of an AJP service that requires +authentication. +]] + +--- +-- @usage +-- nmap -p 8009 --script ajp-auth [--script-args ajp-auth.path=/login] +-- +-- @output +-- PORT STATE SERVICE +-- 8009/tcp open ajp13 +-- | ajp-auth: +-- |_ Digest opaque=GPui3SvCGBoHrRMMzSsgaYBV qop=auth nonce=1336063830612:935b5b389696b0f67b9193e19f47e037 realm=example.org +-- +-- @args ajp-auth.path Define the request path +-- + +author = "Patrik Karlsson" +license = "Same as Nmap--See http://nmap.org/book/man-legal.html" +categories = {"default", "auth", "safe"} + +local ajp = require('ajp') +local http = require('http') +local shortport = require('shortport') + +portrule = shortport.port_or_service(8009, 'ajp13', 'tcp') + +local arg_path = stdnse.get_script_args(SCRIPT_NAME .. ".path") + +local function fail(err) return ("\n ERROR: %s"):format(err or "") end + +action = function(host, port) + local helper = ajp.Helper:new(host, port) + + if ( not(helper:connect()) ) then + return fail("Failed to connect to AJP server") + end + + local status, answer = helper:get(arg_path or "/") + + --- check for 401 response code + if ( not(status) or answer.status ~= 401 ) then + return + end + + local result = { name = answer["status-line"]:match("^(.*)\r?\n$") } + + local www_authenticate = answer.headers["www-authenticate"] + if not www_authenticate then + table.insert( result, ("Server returned status %d but no WWW-Authenticate header."):format(answer.status) ) + return stdnse.format_output(true, result) + end + + local challenges = http.parse_www_authenticate(www_authenticate) + if ( not(challenges) ) then + table.insert( result, ("Server returned status %d but the WWW-Authenticate header could not be parsed."):format(answer.status) ) + table.insert( result, ("WWW-Authenticate: %s"):format(www_authenticate) ) + return stdnse.format_output(true, result) + end + + for _, challenge in ipairs(challenges) do + local line = challenge.scheme + if ( challenge.params ) then + for name, value in pairs(challenge.params) do + line = line .. (" %s=%s"):format(name, value) + end + end + table.insert(result, line) + end + return stdnse.format_output(true, result) +end diff --git a/scripts/ajp-headers.nse b/scripts/ajp-headers.nse new file mode 100644 index 000000000..2cf53efe8 --- /dev/null +++ b/scripts/ajp-headers.nse @@ -0,0 +1,46 @@ +description = [[ +Performs a HEAD or GET request against either the root directory or any +optional directory and returns the server response headers. +]] + +--- +-- @usage +-- nmap -p 8009 --script ajp-headers +-- +-- @output +-- PORT STATE SERVICE +-- 8009/tcp open ajp13 +-- | ajp-headers: +-- | X-Powered-By: JSP/2.2 +-- | Set-Cookie: JSESSIONID=goTHax+8ktEcZsBldANHBAuf.undefined; Path=/helloworld +-- | Content-Type: text/html;charset=ISO-8859-1 +-- |_ Content-Length: 149 +-- +-- @args ajp-headers.path The path to request, such as /index.php. Default /. + +local ajp = require('ajp') +local shortport = require('shortport') + +portrule = shortport.port_or_service(8009, 'ajp13', 'tcp') + +author = "Patrik Karlsson" +license = "Same as Nmap--See http://nmap.org/book/man-legal.html" +categories = {"discovery", "safe"} + +local arg_path = stdnse.get_script_args(SCRIPT_NAME .. '.path') or "/" + +local function fail(err) return ("\n ERROR: %s"):format(err or "") end + +action = function(host, port) + local method + local helper = ajp.Helper:new(host, port) + helper:connect() + + local status, response = helper:get(arg_path) + helper:close() + + if ( not(status) ) then + return fail("Failed to retrieve server headers") + end + return stdnse.format_output(true, response.rawheaders) +end \ No newline at end of file diff --git a/scripts/ajp-methods.nse b/scripts/ajp-methods.nse new file mode 100644 index 000000000..c1a27edf0 --- /dev/null +++ b/scripts/ajp-methods.nse @@ -0,0 +1,80 @@ +description = [[ +Finds out what options are supported by the AJP server by sending an OPTIONS +request and lists potentially risky methods. + +In this script, "potentially risky" methods are anything except GET, +HEAD, POST, and OPTIONS. If the script reports potentially risky +methods, they may not all be security risks, but you should check to +make sure. This page lists the dangers of some common methods: + +http://www.owasp.org/index.php/Testing_for_HTTP_Methods_and_XST_%28OWASP-CM-008%29 +]] + +--- +-- @usage +-- nmap -p 8009 --script ajp-methods +-- +-- @output +-- PORT STATE SERVICE +-- 8009/tcp open ajp13 +-- | ajp-methods: +-- | Supported methods: GET HEAD POST PUT DELETE TRACE OPTIONS +-- | Potentially risky methods: PUT DELETE TRACE +-- |_ See http://nmap.org/nsedoc/scripts/ajp-methods.html +-- +-- @args ajp-methods.path the path to check or / if none was given +-- + +author = "Patrik Karlsson" +license = "Same as Nmap--See http://nmap.org/book/man-legal.html" +categories = {"default", "safe"} + +local ajp = require('ajp') +local shortport = require('shortport') + +portrule = shortport.port_or_service(8009, 'ajp13', 'tcp') + +local arg_url = stdnse.get_script_args(SCRIPT_NAME .. ".path") or "/" +local UNINTERESTING_METHODS = { "GET", "HEAD", "POST", "OPTIONS" } + +local function contains(t, elem) + for _, e in ipairs(t) do if e == elem then return true end end + return false +end + +local function filter_out(t, filter) + local result = {} + for _, e in ipairs(t) do + if ( not(contains(filter, e)) ) then + result[#result + 1] = e + end + end + return result +end + +action = function(host, port) + + local helper = ajp.Helper:new(host, port) + if ( not(helper:connect()) ) then + return fail("Failed to connect to server") + end + + local status, response = helper:options(arg_url) + helper:close() + if ( not(status) or response.status ~= 200 or + not(response.headers) or not(response.headers['allow']) ) then + return "Failed to get a valid response for the OPTION request" + end + + local methods = stdnse.strsplit(",%s", response.headers['allow']) + + local output = {} + table.insert(output, ("Supported methods: %s"):format(stdnse.strjoin(" ", methods))) + + local interesting = filter_out(methods, UNINTERESTING_METHODS) + if ( #interesting > 0 ) then + table.insert(output, "Potentially risky methods: " .. stdnse.strjoin(" ", interesting)) + table.insert(output, "See http://nmap.org/nsedoc/scripts/ajp-methods.html") + end + return stdnse.format_output(true, output) +end \ No newline at end of file diff --git a/scripts/script.db b/scripts/script.db index c9df20254..bdd9859de 100644 --- a/scripts/script.db +++ b/scripts/script.db @@ -5,6 +5,9 @@ Entry { filename = "afp-ls.nse", categories = { "discovery", "safe", } } Entry { filename = "afp-path-vuln.nse", categories = { "exploit", "intrusive", "vuln", } } Entry { filename = "afp-serverinfo.nse", categories = { "default", "discovery", "safe", } } Entry { filename = "afp-showmount.nse", categories = { "discovery", "safe", } } +Entry { filename = "ajp-auth.nse", categories = { "auth", "default", "safe", } } +Entry { filename = "ajp-headers.nse", categories = { "discovery", "safe", } } +Entry { filename = "ajp-methods.nse", categories = { "default", "safe", } } Entry { filename = "amqp-info.nse", categories = { "default", "discovery", "safe", "version", } } Entry { filename = "asn-query.nse", categories = { "discovery", "external", "safe", } } Entry { filename = "auth-owners.nse", categories = { "default", "safe", } }