diff --git a/CHANGELOG b/CHANGELOG index 7bcd1bf37..e895f7429 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,9 @@ # Nmap Changelog ($Id$); -*-text-*- +o [NSE][GH362] Added support for LDAP over udp to ldap-rootdse.nse. + Also added version detection and information extraction to match the + new LDAP LDAPSearchReq and LDAPSearchReqUDP probes. [Tom Sellers] + o [GH#354] Added new version detection Probes for LDAP services, LDAPSearchReq and LDAPSearchReqUDP. The second is Microsoft Active Directory specific. The Probes will elicit responses from target services that allow better finger diff --git a/nselib/ldap.lua b/nselib/ldap.lua index 0c29fda0c..7ec24108b 100644 --- a/nselib/ldap.lua +++ b/nselib/ldap.lua @@ -6,7 +6,7 @@ -- -- Credit goes out to Martin Swende who provided me with the initial code that got me started writing this. -- --- Version 0.6 +-- Version 0.7 -- Created 01/12/2010 - v0.1 - Created by Patrik Karlsson -- Revised 01/28/2010 - v0.2 - Revised to fit better fit ASN.1 library -- Revised 02/02/2010 - v0.3 - Revised to fit OO ASN.1 Library @@ -14,6 +14,7 @@ -- formats -- Revised 10/29/2011 - v0.5 - Added support for performing wildcard searches via the substring filter. -- Revised 10/30/2011 - v0.6 - Added support for the ldap extensibleMatch filter type for searches +-- Revised 04/04/2016 - v0.7 - Added support for searchRequest over upd ( udpSearchRequest ) - Tom Sellers -- local asn1 = require "asn1" @@ -24,6 +25,7 @@ local os = require "os" local stdnse = require "stdnse" local string = require "string" local table = require "table" +local comm = require "comm" _ENV = stdnse.module("ldap", stdnse.seeall) local ldapMessageId = 1 @@ -221,7 +223,7 @@ end -- @param params table containing at least scope, derefPolicy, baseObject -- the field maxObjects may also be included to restrict the amount of records returned -- @return success true or false. --- @return err string containing error message +-- @return searchResEntries containing results or a string containing error message function searchRequest( socket, params ) local searchResEntries = { errorMessage="", resultCode = 0} @@ -334,6 +336,123 @@ function searchRequest( socket, params ) return true, searchResEntries end +--- Performs an LDAP Search request over UDP +-- +-- This function has a concept of softerrors which populates the return tables error information +-- while returning a true status. The reason for this is that LDAP may return a number of records +-- and then finish off with an error like SIZE LIMIT EXCEEDED. We still want to return the records +-- that were received prior to the error. In order to achieve this and not terminating the script +-- by returning a false status a true status is returned together with a table containing all searchentries. +-- This table has the errorMessage and resultCode entries set with the error information. +-- As a try won't catch this error it's up to the script to do so. See ldap-search.nse for an example. +-- +-- @param host The host to connect to +-- @param port The port on the host +-- @param params table containing at least scope, derefPolicy, baseObject +-- the field maxObjects may also be included to restrict the amount of records returned +-- @return success true or false. +-- @return searchResEntries containing results or a string containing error message + +function udpSearchRequest( host, port, params ) + + local searchResEntries = { errorMessage="", resultCode = 0} + local catch = function() stdnse.debug1("udpSearchRequest failed") end + local try = nmap.new_try(catch) + local attributes = params.attributes + local request = encode(params.baseObject) + local attrSeq = '' + local requestData, messageSeq, data + local maxObjects = params.maxObjects or -1 + + local encoder = asn1.ASN1Encoder:new() + local decoder = asn1.ASN1Decoder:new() + + encoder:registerTagEncoders(tagEncoder) + decoder:registerTagDecoders(tagDecoder) + + request = request .. encode( { _ldap='0A', params.scope } )--scope + request = request .. encode( { _ldap='0A', params.derefPolicy } )--derefpolicy + request = request .. encode( params.sizeLimit or 0)--sizelimit + request = request .. encode( params.timeLimit or 0)--timelimit + request = request .. encode( params.typesOnly or false)--TypesOnly + + if params.filter then + request = request .. createFilter( params.filter ) + else + request = request .. encode( { _ldaptype='87', "objectclass" } )-- filter : string, presence + end + if attributes~= nil then + for _,attr in ipairs(attributes) do + attrSeq = attrSeq .. encode(attr) + end + end + + request = request .. encoder:encodeSeq(attrSeq) + requestData = encodeLDAPOp(APPNO.SearchRequest, true, request) + messageSeq = encode(ldapMessageId) + ldapMessageId = ldapMessageId +1 + messageSeq = messageSeq .. requestData + data = encoder:encodeSeq(messageSeq) + local status, response = comm.exchange(host, port, data) + + while true do + local len, pos, messageId = 0, 0, -1 + local tmp = "" + local _, objectName, attributes, ldapOp + local attributes + local searchResEntry = {} + + if ( maxObjects == 0 ) then + break + elseif ( maxObjects > 0 ) then + maxObjects = maxObjects - 1 + end + + pos, tmp = bin.unpack("C", response, pos) + pos, len = decoder.decodeLength( response, pos ) + pos, messageId = decode( response, pos ) + pos, tmp = bin.unpack("C", response, pos) + pos, len = decoder.decodeLength( response, pos ) + ldapOp = asn1.intToBER( tmp ) + searchResEntry = {} + + if ldapOp.number == APPNO.SearchResDone then + pos, searchResEntry.resultCode = decode( response, pos ) + -- errors may occur after a large amount of response has been received (eg. size limit exceeded) + -- we want to be able to return the response received prior to this error to the user + -- however, we also need to alert the user of the error. This is achieved through "softerrors" + -- softerrors populate the error fields of the table while returning a true status + -- this allows for the caller to output response while still being able to catch the error + if ( searchResEntry.resultCode ~= 0 ) then + local error_msg + pos, searchResEntry.matchedDN = decode( response, pos ) + pos, searchResEntry.errorMessage = decode( response, pos ) + error_msg = ERROR_MSG[searchResEntry.resultCode] + -- if the table is empty return a hard error + if #searchResEntries == 0 then + return false, string.format("Code: %d|Error: %s|Details: %s", searchResEntry.resultCode, error_msg or "", searchResEntry.errorMessage or "" ) + else + searchResEntries.errorMessage = string.format("Code: %d|Error: %s|Details: %s", searchResEntry.resultCode, error_msg or "", searchResEntry.errorMessage or "" ) + searchResEntries.resultCode = searchResEntry.resultCode + return true, searchResEntries + end + end + break + end + + pos, searchResEntry.objectName = decode( response, pos ) + if ldapOp.number == APPNO.SearchResponse then + pos, searchResEntry.attributes = decode( response, pos ) + table.insert( searchResEntries, searchResEntry ) + end + if response:len() > pos then + response = response:sub(pos) + else + response = "" + end + end + return true, searchResEntries +end --- Attempts to bind to the server using the credentials given -- diff --git a/scripts/ldap-rootdse.nse b/scripts/ldap-rootdse.nse index 1813958b7..f7255f122 100644 --- a/scripts/ldap-rootdse.nse +++ b/scripts/ldap-rootdse.nse @@ -84,9 +84,10 @@ Retrieves the LDAP root DSA-specific Entry (DSE) -- Credit goes out to Martin Swende who provided me with the initial code that got me started writing this. -- --- Version 0.2 +-- Version 0.3 -- Created 01/12/2010 - v0.1 - created by Patrik Karlsson -- Revised 01/20/2010 - v0.2 - added SSL support +-- Revised 04/09/2016 - v0.3 - added support for LDAP over UDP - Tom Sellers author = "Patrik Karlsson" copyright = "Patrik Karlsson" @@ -94,63 +95,112 @@ license = "Same as Nmap--See https://nmap.org/book/man-legal.html" categories = {"discovery", "safe"} dependencies = {"ldap-brute"} +-- Map domainControllerFunctionality to OS - https://msdn.microsoft.com/en-us/library/cc223272.aspx +-- Tested to be valid even when Active Directory functional level is lower than target ADC's OS version +DC_FUNCT_ID = {} +DC_FUNCT_ID["0"] = "Windows 2000" +DC_FUNCT_ID["2"] = "Windows 2003" +DC_FUNCT_ID["3"] = "Windows 2008" +DC_FUNCT_ID["4"] = "Windows 2008 R2" +DC_FUNCT_ID["5"] = "Windows 2012" +DC_FUNCT_ID["6"] = "Windows 2012 R2" -portrule = shortport.port_or_service({389,636}, {"ldap","ldapssl"}) +portrule = shortport.port_or_service({389,636}, {"ldap","ldapssl"},{'tcp','udp'}) function action(host,port) - - local socket = nmap.new_socket() local status, searchResEntries, req, result, opt - -- In order to discover what protocol to use (SSL/TCP) we need to send a few bytes to the server - -- An anonymous bind should do it - local ldap_anonymous_bind = "\x30\x0c\x02\x01\x01\x60\x07\x02\x01\x03\x04\x00\x80\x00" - local _ - socket, _, opt = comm.tryssl( host, port, ldap_anonymous_bind, nil ) + if port.protocol == 'tcp' then - if not socket then - return - end + local socket = nmap.new_socket() - -- We close and re-open the socket so that the anonymous bind does not distract us - socket:close() - status = socket:connect(host, port, opt) - socket:set_timeout(10000) + -- In order to discover what protocol to use (SSL/TCP) we need to send a few bytes to the server + -- An anonymous bind should do it + local ldap_anonymous_bind = "\x30\x0c\x02\x01\x01\x60\x07\x02\x01\x03\x04\x00\x80\x00" + local _ + socket, _, opt = comm.tryssl( host, port, ldap_anonymous_bind, nil ) - -- Searching for an empty argument list against LDAP on W2K3 returns all attributes - -- This is not the case for OpenLDAP, so we do a search for an empty attribute list - -- Then we compare the results against some known and expected returned attributes - req = { baseObject = "", scope = ldap.SCOPE.base, derefPolicy = ldap.DEREFPOLICY.default } - status, searchResEntries = ldap.searchRequest( socket, req ) + if not socket then + return + end - -- Check if we were served all the results or not? - if not ldap.extractAttribute( searchResEntries, "namingContexts" ) and - not ldap.extractAttribute( searchResEntries, "supportedLDAPVersion" ) then - - -- The namingContexts was not there, try to query all attributes instead - -- Attributes extracted from Windows 2003 and complemented from RFC - local attribs = {"_domainControllerFunctionality","configurationNamingContext","currentTime","defaultNamingContext", - "dnsHostName","domainFunctionality","dsServiceName","forestFunctionality","highestCommittedUSN", - "isGlobalCatalogReady","isSynchronized","ldap-get-baseobject","ldapServiceName","namingContexts", - "rootDomainNamingContext","schemaNamingContext","serverName","subschemaSubentry", - "supportedCapabilities","supportedControl","supportedLDAPPolicies","supportedLDAPVersion", - "supportedSASLMechanisms", "altServer", "supportedExtension"} - - req = { baseObject = "", scope = ldap.SCOPE.base, derefPolicy = ldap.DEREFPOLICY.default, attributes = attribs } - status, searchResEntries = ldap.searchRequest( socket, req ) - end - - if not status then + -- We close and re-open the socket so that the anonymous bind does not distract us socket:close() - return + status = socket:connect(host, port, opt) + socket:set_timeout(10000) + + -- Searching for an empty argument list against LDAP on W2K3 returns all attributes + -- This is not the case for OpenLDAP, so we do a search for an empty attribute list + -- Then we compare the results against some known and expected returned attributes + req = { baseObject = "", scope = ldap.SCOPE.base, derefPolicy = ldap.DEREFPOLICY.default } + status, searchResEntries = ldap.searchRequest( socket, req ) + + -- Check if we were served all the results or not? + if not ldap.extractAttribute( searchResEntries, "namingContexts" ) and + not ldap.extractAttribute( searchResEntries, "supportedLDAPVersion" ) then + + -- The namingContexts was not there, try to query all attributes instead + -- Attributes extracted from Windows 2003 and complemented from RFC + local attribs = {"_domainControllerFunctionality","configurationNamingContext","currentTime","defaultNamingContext", + "dnsHostName","domainFunctionality","dsServiceName","forestFunctionality","highestCommittedUSN", + "isGlobalCatalogReady","isSynchronized","ldap-get-baseobject","ldapServiceName","namingContexts", + "rootDomainNamingContext","schemaNamingContext","serverName","subschemaSubentry", + "supportedCapabilities","supportedControl","supportedLDAPPolicies","supportedLDAPVersion", + "supportedSASLMechanisms", "altServer", "supportedExtension"} + + req = { baseObject = "", scope = ldap.SCOPE.base, derefPolicy = ldap.DEREFPOLICY.default, attributes = attribs } + status, searchResEntries = ldap.searchRequest( socket, req ) + end + + socket:close() + else + -- Port protocol is UDP, indicating that this is an Active Directory Controller LDAP service + req = { baseObject = "", scope = ldap.SCOPE.base, derefPolicy = ldap.DEREFPOLICY.default} + status, searchResEntries = ldap.udpSearchRequest( host, port, req ) end + if not status or not searchResEntries then return end result = ldap.searchResultToTable( searchResEntries ) - socket:close() - + -- if taken a way and ldap returns a single result, it ain't shown.... result.name = "LDAP Results" + local scriptResult = stdnse.format_output(true, result ) + + -- Start extracting target information + -- The following works on Windows AD LDAP as well as VMware's LDAP, VMware uses lower case cn vs AD ucase CN + local serverName = string.match(scriptResult,"serverName: [cC][nN]=([^,]+),[cC][nN]=Servers,[cC][nN]=") + if serverName then port.version.hostname = serverName end + + -- Check to see if this is Active Directory vs some other product or ADAM + -- https://msdn.microsoft.com/en-us/library/cc223359.aspx + if string.match(scriptResult,"1.2.840.113556.1.4.800") then + port.version.product = 'Microsoft Windows Active Directory LDAP' + port.version.name_confidence = 10 + + -- Determine Windows version + if not port.version.ostype or port.version.ostype == 'Windows' then + local DC_Func = string.match(scriptResult,"domainControllerFunctionality: (%d)") + if DC_FUNCT_ID[DC_Func] then + port.version.ostype = DC_FUNCT_ID[DC_Func] + else + port.version.ostype = 'Windows' + stdnse.debug(1,"Unmatched OS lookup for domainControllerFunctionality: %d", DC_Func) + end + end - return stdnse.format_output(true, result ) - + local siteName = string.match(scriptResult,"serverName: CN=[^,]+,CN=Servers,CN=([^,]+),CN=Sites,") + local domainName = string.match(scriptResult,"rootDomainNamingContext: ([^\n]*)") + domainName = string.gsub(domainName,",DC=",".") + domainName = string.gsub(domainName,"DC=","") + if domainName and siteName then + port.version.extrainfo = string.format("Domain: %s, Site: %s", domainName, siteName) + end + end + + -- Set port information + port.version.name = "ldap" + nmap.set_port_version(host, port, "hardmatched") + nmap.set_port_state(host, port, "open") + + return scriptResult end