diff --git a/CHANGELOG b/CHANGELOG index 4f158d2fa..2eedf5508 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -2,6 +2,11 @@ [NOT YET RELEASED] +o [NSE] Added a new library for LDAP and two new scripts: + - ldap-brute uses the unpwdb library to guess credentials for LDAP + - ldap-rootdse retrieves the LDAP root DSA-specific Entry (DSE) + [Patrik] + o The -v and -d options are now handled in the same way. The three forms are equivalent: -v -v -v -vvv -v3 diff --git a/nselib/ldap.lua b/nselib/ldap.lua new file mode 100644 index 000000000..beafd082c --- /dev/null +++ b/nselib/ldap.lua @@ -0,0 +1,475 @@ +--- Library methods for handling LDAP. +-- +-- @author Patrik Karlsson +-- @copyright Same as Nmap--See http://nmap.org/book/man-legal.html +-- +-- Credit goes out to Martin Swende who provided me with the initial code that got me started writing this. +-- +-- Version 0.3 +-- 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 + +module("ldap", package.seeall) + +require("asn1") + +local ldapMessageId = 1 + +ERROR_MSG = {} +ERROR_MSG[1] = "Intialization of LDAP library failed." +ERROR_MSG[4] = "Size limit exceeded." +ERROR_MSG[32] = "No such object" +ERROR_MSG[49] = "The supplied credential is invalid." + +ERRORS = { + LDAP_SUCCESS = 0, + LDAP_SIZELIMIT_EXCEEDED = 4 +} + +-- Application constants +APPNO = { + BindRequest = 0, + BindResponse = 1, + UnbindRequest = 2, + SearchRequest = 3, + SearchResponse = 4, + SearchResDone = 5 +} + +-- Filter operation constants +FILTER = { + _and = 0, + _or = 1, + _not = 2, + equalityMatch = 3, + substrings = 4, + greaterOrEqual = 5, + lessOrEqual = 6, + present = 7, + approxMatch = 8, + extensibleMatch = 9 +} + +-- Scope constants +SCOPE = { + base=0, + one=1, + sub= 2, + children=3, + default = 0 +} + +-- Deref policy constants +DEREFPOLICY = { + never=0, + searching=1, + finding = 2, + always=3, + default = 0 +} + +-- LDAP specific tag encoders +local tagEncoder = {} + +tagEncoder['table'] = function(self, val) + if (val._ldap == '0A') then + local ival = self.encodeInt(val[1]) + local len = self.encodeLength(string.len(ival)) + return bin.pack('HAA', '0A', len, ival) + end + if (val._ldaptype) then + local len + if val[1] == nil or string.len(val[1]) == 0 then + return bin.pack('HC', val._ldaptype, 0) + else + len = self.encodeLength(string.len(val[1])) + return bin.pack('HAA', val._ldaptype, len, val[1]) + end + end + + local encVal = "" + for _, v in ipairs(val) do + encVal = encVal .. encode(v) -- todo: buffer? + end + local tableType = bin.pack("H", "30") + if (val["_snmp"]) then + tableType = bin.pack("H", val["_snmp"]) + end + return bin.pack('AAA', tableType, self.encodeLength(string.len(encVal)), encVal) + +end + +--- +-- Encodes a given value according to ASN.1 basic encoding rules for SNMP +-- packet creation. +-- @param val Value to be encoded. +-- @return Encoded value. +function encode(val) + + local encoder = asn1.ASN1Encoder:new() + local encValue + + encoder:registerTagEncoders(tagEncoder) + encValue = encoder:encode(val) + + if encValue then + return encValue + end + + return '' +end + + +-- LDAP specific tag decoders +local tagDecoder = {} + +tagDecoder["0A"] = function( self, encStr, elen, pos ) + return self.decodeInt(encStr, elen, pos) +end + +-- null decoder +tagDecoder["31"] = function( self, encStr, elen, pos ) + return pos, nil +end + + +--- +-- Decodes an LDAP packet or a part of it according to ASN.1 basic encoding +-- rules. +-- @param encStr Encoded string. +-- @param pos Current position in the string. +-- @return The position after decoding +-- @return The decoded value(s). +function decode(encStr, pos) + -- register the LDAP specific tag decoders + local decoder = asn1.ASN1Decoder:new() + decoder:registerTagDecoders( tagDecoder ) + return decoder:decode( encStr, pos ) +end + + +--- +-- Decodes a sequence according to ASN.1 basic encoding rules. +-- @param encStr Encoded string. +-- @param len Length of sequence in bytes. +-- @param pos Current position in the string. +-- @return The position after decoding. +-- @return The decoded sequence as a table. +local function decodeSeq(encStr, len, pos) + local seq = {} + local sPos = 1 + local sStr + pos, sStr = bin.unpack("A" .. len, encStr, pos) + if(sStr==nil) then + return pos,seq + end + while (sPos < len) do + local newSeq + sPos, newSeq = decode(sStr, sPos) + table.insert(seq, newSeq) + end + return pos, seq +end + +-- Encodes an LDAP Application operation and its data as a sequence +-- +-- @param appno LDAP application number @see APPNO +-- @param isConstructed boolean true if constructed, false if primitive +-- @param data string containing the LDAP operation content +-- @return string containing the encoded LDAP operation +function encodeLDAPOp( appno, isConstructed, data ) + local encoded_str = "" + local asn1_type = asn1.BERtoInt( asn1.BERCLASS.Application, isConstructed, appno ) + + encoded_str = encode( { _ldaptype = bin.pack("A", string.format("%X", asn1_type)), data } ) + return encoded_str +end + +--- Performs an LDAP Search request +-- +-- 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 finnish of 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 socket socket already connected to the ldap server +-- @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 +function searchRequest( socket, params ) + + local searchResEntries = { errorMessage="", resultCode = 0} + local catch = function() socket:close() stdnse.print_debug(string.format("SearchRequest 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) + try( socket:send( data ) ) + data = "" + + while true do + local len, pos, messageId = 0, 2, -1 + local tmp = "" + local _, objectName, attributes, ldapOp + local attributes + local searchResEntry = {} + + if ( maxObjects == 0 ) then + break + elseif ( maxObjects > 0 ) then + maxObjects = maxObjects - 1 + end + + if data:len() > 6 then + pos, len = decoder.decodeLength( data, pos ) + else + data = data .. try( socket:receive() ) + pos, len = decoder.decodeLength( data, pos ) + end + -- pos should be at the right position regardless if length is specified in 1 or 2 bytes + while ( len + pos - 1 > data:len() ) do + data = data .. try( socket:receive() ) + end + + pos, messageId = decode( data, pos ) + pos, tmp = bin.unpack("C", data, pos) + pos, len = decoder.decodeLength( data, pos ) + ldapOp = asn1.intToBER( tmp ) + searchResEntry = {} + + if ldapOp.number == APPNO.SearchResDone then + pos, searchResEntry.resultCode = decode( data, pos ) + -- errors may occur after a large amount of data has been received (eg. size limit exceeded) + -- we want to be able to return the data 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 data while still being able to catch the error + if ( searchResEntry.resultCode ~= 0 ) then + local error_msg + pos, searchResEntry.matchedDN = decode( data, pos ) + pos, searchResEntry.errorMessage = decode( data, 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( data, pos ) + if ldapOp.number == APPNO.SearchResponse then + pos, searchResEntry.attributes = decode( data, pos ) + + table.insert( searchResEntries, searchResEntry ) + end + if data:len() > pos then + data = data:sub(pos) + else + data = "" + end + end + return true, searchResEntries +end + + +--- Attempts to bind to the server using the credentials given +-- +-- @param socket socket already connected to the ldap server +-- @param params table containing version, username and password +-- @return success true or false +-- @return err string containing error message +function bindRequest( socket, params ) + + local catch = function() socket:close() stdnse.print_debug(string.format("bindRequest failed")) end + local try = nmap.new_try(catch) + local ldapAuth = encode( { _ldaptype = 80, params.password } ) + local bindReq = encode( params.version ) .. encode( params.username ) .. ldapAuth + local ldapMsg = encode(ldapMessageId) .. encodeLDAPOp( APPNO.BindRequest, true, bindReq ) + local packet + local pos, packet_len, resultCode, tmp, len, _ + local response = {} + + local encoder = asn1.ASN1Encoder:new() + local decoder = asn1.ASN1Decoder:new() + + encoder:registerTagEncoders(tagEncoder) + decoder:registerTagDecoders(tagDecoder) + + packet = encoder:encodeSeq( ldapMsg ) + ldapMessageId = ldapMessageId +1 + try( socket:send( packet ) ) + packet = try( socket:receive() ) + + pos, packet_len = decoder.decodeLength( packet, 2 ) + pos, response.messageID = decode( packet, pos ) + pos, tmp = bin.unpack("C", packet, pos) + pos, len = decoder.decodeLength( packet, pos ) + response.protocolOp = asn1.intToBER( tmp ) + + if response.protocolOp.number ~= APPNO.BindResponse then + return false, string.format("Recieved incorrect Op in packet: %d, expected %d", response.protocolOp.number, APPNO.BindResponse) + end + + pos, response.resultCode = decode( packet, pos ) + + if ( response.resultCode ~= 0 ) then + local error_msg + pos, response.matchedDN = decode( packet, pos ) + pos, response.errorMessage = decode( packet, pos ) + error_msg = ERROR_MSG[response.resultCode] + return false, string.format("Error: %s\nDetails: %s", error_msg or "", response.errorMessage or "" ) + else + return true, "Success" + end +end + +--- Performs an LDAP Unbind +-- +-- @param socket socket already connected to the ldap server +-- @return success true or false +-- @return err string containing error message +function unbindRequest( socket ) + + local ldapMsg, packet + local catch = function() socket:close() stdnse.print_debug(string.format("bindRequest failed")) end + local try = nmap.new_try(catch) + + local encoder = asn1.ASN1Encoder:new() + encoder:registerTagEncoders(tagEncoder) + + ldapMessageId = ldapMessageId +1 + ldapMsg = encode( ldapMessageId ) + ldapMsg = ldapMsg .. encodeLDAPOp( APPNO.UnbindRequest, false, nil) + packet = encoder:encodeSeq( ldapMsg ) + try( socket:send( packet ) ) + return true, "" +end + + +--- Creates an ASN1 structure from a filter table +-- +-- @param filter table containing the filter to be created +-- @return string containing the ASN1 byte sequence +function createFilter( filter ) + local asn1_type = asn1.BERtoInt( asn1.BERCLASS.ContextSpecific, true, filter.op ) + local filter_str = "" + + if type(filter.val) == 'table' then + for _, v in ipairs( filter.val ) do + filter_str = filter_str .. createFilter( v ) + end + else + local obj = encode( filter.obj ) + local val = encode( filter.val ) + + filter_str = filter_str .. obj .. val + end + return encode( { _ldaptype=bin.pack("A", string.format("%X", asn1_type)), filter_str } ) +end + +--- Converts a search result as received from searchRequest to a "result" table +-- +-- Does some limited decoding of LDAP attributes +-- +-- TODO: Add decoding of missing attributes +-- TODO: Add decoding of userParameters +-- TODO: Add decoding of loginHours +-- +-- @param searchEntries table as returned from searchRequest +-- @return table suitable for stdnse.format_output +function searchResultToTable( searchEntries ) + local result = {} + for _, v in ipairs( searchEntries ) do + local result_part = {} + if v.objectName and v.objectName:len() > 0 then + result_part.name = string.format("dn: %s", v.objectName) + else + result_part.name = "" + end + + local attribs = {} + if ( v.attributes ~= nil ) then + for _, attrib in ipairs( v.attributes ) do + for i=2, #attrib do + -- do some additional Windows decoding + if ( attrib[1] == "objectSid" ) then + table.insert( attribs, string.format( "%s: %d", attrib[1], decode( attrib[i] ) ) ) + elseif ( attrib[1] == "objectGUID") then + local _, o1, o2, o3, o4, o5, o6, o7, o8, o9, oa, ob, oc, od, oe, of = bin.unpack("C16", attrib[i] ) + table.insert( attribs, string.format( "%s: %x%x%x%x-%x%x-%x%x-%x%x-%x%x%x%x%x", attrib[1], o4, o3, o2, o1, o5, o6, o7, o8, o9, oa, ob, oc, od, oe, of ) ) + else + table.insert( attribs, string.format( "%s: %s", attrib[1], attrib[i] ) ) + end + end + end + table.insert( result_part, attribs ) + end + table.insert( result, result_part ) + end + return result +end + + +--- Extract naming context from a search response +-- +-- @param searchEntries table containing searchEntries from a searchResponse +-- @param attributeName string containing the attribute to extract +-- @return table containing the attribute values +function extractAttribute( searchEntries, attributeName ) + local attributeTbl = {} + for _, v in ipairs( searchEntries ) do + if ( v.attributes ~= nil ) then + for _, attrib in ipairs( v.attributes ) do + local attribType = attrib[1] + for i=2, #attrib do + if attribType == attributeName then + table.insert( attributeTbl, attrib[i]) + end + end + end + end + end + return ( #attributeTbl > 0 and attributeTbl or nil ) +end \ No newline at end of file diff --git a/scripts/ldap-brute.nse b/scripts/ldap-brute.nse new file mode 100644 index 000000000..2fca2ce83 --- /dev/null +++ b/scripts/ldap-brute.nse @@ -0,0 +1,255 @@ +description = [[ +Performs password guessing against LDAP +]] + +--- +-- @usage +-- nmap -p 389 --script ldap-brute --script-args +-- ldap.base='"cn=users,dc=cqure,dc=net"' +-- +-- @output +-- 389/tcp open ldap +-- | ldap-brute: +-- |_ ldaptest:ldaptest => Login Correct +-- +-- @args ldap.base If set, the script will use it as a base for the password +-- guessing attempts. If unset the user list must either contain the +-- distinguished name of each user or the server must support +-- authentication using a simple user name. See AD discussion below. +-- +-- Additional information +-- ---------------------- +-- This script makes attempts to brute force LDAP authentication. By default +-- it uses the builtin user- and password-list to do so. In order to use your +-- own lists use the userdb and passdb script arguments. +-- +-- WARNING: This script does not make ANY attempt to prevent account lockout! +-- If the number of passwords in the dictionary exceed the amount of +-- allowed tries, accounts will be locked out. This usually happens +-- *VERY* quickly. +-- +-- Active Directory and LDAP +-- ------------------------- +-- Note: Authenticating against Active Directory using LDAP does not use the +-- Windows user name but the user accounts distinguished name. LDAP on Windows +-- 2003 allows authentication using a simple user name rather than using the +-- fully distinguished name. Eg: +-- - Patrik Karlsson vs. cn=Patrik Karlsson,cn=Users,dc=cqure,dc=net +-- This type of authentication is not supported on eg. OpenLDAP +-- +-- This script uses some AD specific support and optimizations: +-- +-- o LDAP on Windows 2003 reports different error messages depending on whether +-- an account exists or not. If the script recieves an error indicating that +-- the username does not exist it simply stops guessing passwords for this +-- account and moves on to the next. +-- +-- o The script attempts to authenticate with the username only if no LDAP base +-- is specified. The benefit of authenticating this way is that the LDAP path +-- of each account does not need to be known in advance as it's looked up by +-- the server. +-- +-- Credits +-- ------- +-- o The get_random_string function was borrowed from the smb-psexec script. +-- + +author = "Patrik Karlsson" +license = "Same as Nmap--See http://nmap.org/book/man-legal.html" +categories = {"intrusive", "auth"} + +require 'shortport' +require 'stdnse' +require 'ldap' +require 'unpwdb' +require 'comm' + +-- Version 0.3 +-- Created 01/20/2010 - v0.1 - created by Patrik Karlsson +-- Revised 01/26/2010 - v0.2 - cleaned up unpwdb related code, fixed ssl stuff +-- Revised 02/17/2010 - v0.3 - added AD specific checks and fixed bugs related to LDAP base + +portrule = shortport.port_or_service({389,636}, {"ldap","ldapssl"}) + +--- Tries to determine a valid naming context to use to validate credentials +-- +-- @param socket socket already connected to LDAP server +-- @return string containing a valid naming context +function get_naming_context( socket ) + + local req = { baseObject = "", scope = ldap.SCOPE.base, derefPolicy = ldap.DEREFPOLICY.default, attributes = { "defaultNamingContext", "namingContexts" } } + local status, searchResEntries = ldap.searchRequest( socket, req ) + + if not status then + return nil + end + + local contexts = ldap.extractAttribute( searchResEntries, "defaultNamingContext" ) + + -- OpenLDAP does not have a defaultNamingContext + if not contexts then + contexts = ldap.extractAttribute( searchResEntries, "namingContexts" ) + end + + if #contexts > 0 then + return contexts[1] + end + + return nil +end + +--- Attempts to validate the credentials by requesting the base object of the supplied context +-- +-- @param socket socket already connected to the LDAP server +-- @param context string containing the context to search +-- @return true if credentials are valid and search was a success, false if not. +function is_valid_credential( socket, context ) + local req = { baseObject = context, scope = ldap.SCOPE.base, derefPolicy = ldap.DEREFPOLICY.default, attributes = nil } + local status, searchResEntries = ldap.searchRequest( socket, req ) + + return status +end + + +action = function( host, port ) + + local result, response, status, context, valid_accounts = {}, nil, nil, nil, {} + local usernames, passwords, username, password, fq_username + local user_cnt, invalid_account_cnt, tot_tries = 0, 0, 0 + local aborted + + local max_time = unpwdb.timelimit() ~= nil and unpwdb.timelimit() * 1000 or -1 + local clock_start = nmap.clock_ms() + + local ldap_anonymous_bind = string.char( 0x30, 0x0c, 0x02, 0x01, 0x01, 0x60, 0x07, 0x02, 0x01, 0x03, 0x04, 0x00, 0x80, 0x00 ) + local socket, _, opt = comm.tryssl( host, port, ldap_anonymous_bind, nil ) + + local base_dn = nmap.registry.args['ldap.base'] + + if not socket then + return + end + + -- We close and re-open the socket so that the anonymous bind does not distract us + socket:close() + -- set a reasonable timeout value + socket:set_timeout(5000) + status = socket:connect(host.ip, port.number, opt) + if not status then + return + end + + context = get_naming_context(socket) + + if not context then + stdnse.print_debug("Failed to retrieve namingContext") + socket:close() + return + end + + status, usernames = unpwdb.usernames() + if not status then + return + end + + status, passwords = unpwdb.passwords() + if not status then + return + end + + for username in usernames do + -- is the aborted flag set + if ( aborted ) then + break + end + + -- if a base DN was set append our username (CN) to the base + if base_dn then + fq_username = ("cn=%s,%s"):format(username, base_dn) + else + fq_username = username + end + user_cnt = user_cnt + 1 + for password in passwords do + + -- Should we abort? + if max_time>0 and nmap.clock_ms() - clock_start > max_time then + aborted=true + break + end + + tot_tries = tot_tries + 1 + + -- handle special case where we want to guess the username as password + if password == "%username%" then + password = username + end + + stdnse.print_debug( "Trying %s/%s ...", fq_username, password ) + status, response = ldap.bindRequest( socket, { version=3, ['username']=fq_username, ['password']=password} ) + + -- if the DN (username) does not exist, break loop + if not status and response:match("invalid DN") then + stdnse.print_debug( "%s returned: \"Invalid DN\"", fq_username ) + invalid_account_cnt = invalid_account_cnt + 1 + break + end + + -- Is AD telling us the account does not exist? + if not status and response:match("AcceptSecurityContext error, data 525, vece") then + invalid_account_cnt = invalid_account_cnt + 1 + break + end + + -- Account Locked Out + if not status and response:match("AcceptSecurityContext error, data 775, vece") then + table.insert( valid_accounts, string.format("%s => Account locked out", fq_username ) ) + break + end + + -- Login correct, account disabled + if not status and response:match("AcceptSecurityContext error, data 533, vece") then + table.insert( valid_accounts, string.format("%s:%s => Login correct, account disabled", fq_username, password:len()>0 and password or "" ) ) + break + end + + -- Login correct, user must change password + if not status and response:match("AcceptSecurityContext error, data 773, vece") then + table.insert( valid_accounts, string.format("%s:%s => Login correct, user must change password", fq_username, password:len()>0 and password or "" ) ) + break + end + + --Login, correct + if status then + status = is_valid_credential( socket, context ) + if status then + table.insert( valid_accounts, string.format("%s:%s => Login correct", fq_username, password:len()>0 and password or "" ) ) + + -- Add credentials for other ldap scripts to use + if nmap.registry.ldapaccounts == nil then + nmap.registry.ldapaccounts = {} + end + nmap.registry.ldapaccounts[fq_username]=password + + break + end + end + end + passwords("reset") + end + + stdnse.print_debug( "Finnished brute against LDAP, total tries: %d, tps: %d", tot_tries, ( tot_tries / ( ( nmap.clock_ms() - clock_start ) / 1000 ) ) ) + + if ( invalid_account_cnt == user_cnt and base_dn ~= nil ) then + return "WARNING: All usernames were invalid. Invalid LDAP base?" + end + + local output = stdnse.format_output(true, valid_accounts) or "" + + if ( max_time > 0 and aborted ) then + output = output .. string.format(" \n\nNOTE: script aborted execution after %d seconds", max_time/1000 ) + end + + return output + +end diff --git a/scripts/ldap-rootdse.nse b/scripts/ldap-rootdse.nse new file mode 100644 index 000000000..85ff3ed30 --- /dev/null +++ b/scripts/ldap-rootdse.nse @@ -0,0 +1,151 @@ +description = [[ +Retrieves the LDAP root DSA-specific Entry (DSE) +]] + +--- +-- +-- @usage +-- nmap -p 389 --script ldap-rootdse +-- +-- @output +-- PORT STATE SERVICE +-- 389/tcp open ldap +-- | ldap-rootdse: +-- | currentTime: 20100112092616.0Z +-- | subschemaSubentry: CN=Aggregate,CN=Schema,CN=Configuration,DC=cqure,DC=net +-- | dsServiceName: CN=NTDS Settings,CN=LDAPTEST001,CN=Servers,CN=Default-First-Site,CN=Sites,CN=Configuration,DC=cqure,DC=net +-- | namingContexts: DC=cqure,DC=net +-- | namingContexts: CN=Configuration,DC=cqure,DC=net +-- | namingContexts: CN=Schema,CN=Configuration,DC=cqure,DC=net +-- | namingContexts: DC=DomainDnsZones,DC=cqure,DC=net +-- | namingContexts: DC=ForestDnsZones,DC=cqure,DC=net +-- | namingContexts: DC=TAPI3Directory,DC=cqure,DC=net +-- | defaultNamingContext: DC=cqure,DC=net +-- | schemaNamingContext: CN=Schema,CN=Configuration,DC=cqure,DC=net +-- | configurationNamingContext: CN=Configuration,DC=cqure,DC=net +-- | rootDomainNamingContext: DC=cqure,DC=net +-- | supportedControl: 1.2.840.113556.1.4.319 +-- | . +-- | . +-- | supportedControl: 1.2.840.113556.1.4.1948 +-- | supportedLDAPVersion: 3 +-- | supportedLDAPVersion: 2 +-- | supportedLDAPPolicies: MaxPoolThreads +-- | supportedLDAPPolicies: MaxDatagramRecv +-- | supportedLDAPPolicies: MaxReceiveBuffer +-- | supportedLDAPPolicies: InitRecvTimeout +-- | supportedLDAPPolicies: MaxConnections +-- | supportedLDAPPolicies: MaxConnIdleTime +-- | supportedLDAPPolicies: MaxPageSize +-- | supportedLDAPPolicies: MaxQueryDuration +-- | supportedLDAPPolicies: MaxTempTableSize +-- | supportedLDAPPolicies: MaxResultSetSize +-- | supportedLDAPPolicies: MaxNotificationPerConn +-- | supportedLDAPPolicies: MaxValRange +-- | highestCommittedUSN: 126991 +-- | supportedSASLMechanisms: GSSAPI +-- | supportedSASLMechanisms: GSS-SPNEGO +-- | supportedSASLMechanisms: EXTERNAL +-- | supportedSASLMechanisms: DIGEST-MD5 +-- | dnsHostName: EDUSRV011.cqure.local +-- | ldapServiceName: cqure.net:edusrv011$@CQURE.NET +-- | serverName: CN=EDUSRV011,CN=Servers,CN=Default-First-Site,CN=Sites,CN=Configuration,DC=cqure,DC=net +-- | supportedCapabilities: 1.2.840.113556.1.4.800 +-- | supportedCapabilities: 1.2.840.113556.1.4.1670 +-- | supportedCapabilities: 1.2.840.113556.1.4.1791 +-- | isSynchronized: TRUE +-- | isGlobalCatalogReady: TRUE +-- | domainFunctionality: 0 +-- | forestFunctionality: 0 +-- |_ domainControllerFunctionality: 2 +-- +-- +-- The root DSE object may contain a number of different attributes as described in RFC 2251 section 3.4: +-- * namingContexts: naming contexts held in the server +-- * subschemaSubentry: subschema entries (or subentries) known by this server +-- * altServer: alternative servers in case this one is later unavailable. +-- * supportedExtension: list of supported extended operations. +-- * supportedControl: list of supported controls. +-- * supportedSASLMechanisms: list of supported SASL security features. +-- * supportedLDAPVersion: LDAP versions implemented by the server. +-- +-- The above example, which contains a lot more information is from Windows 2003 accessible without authentication. +-- The same request against OpenLDAP will result in significantly less information. +-- +-- The ldap-search script queries the root DSE for the namingContexts and/or defaultNamingContexts, which it sets as base +-- if no base object was specified +-- +-- Credit goes out to Martin Swende who provided me with the initial code that got me started writing this. +-- + +-- Version 0.2 +-- Created 01/12/2010 - v0.1 - created by Patrik Karlsson +-- Revised 01/20/2010 - v0.2 - added SSL support + +author = "Patrik Karlsson" +copyright = "Patrik Karlsson" +license = "Same as Nmap--See http://nmap.org/book/man-legal.html" +categories = {"discovery", "safe"} + +require "ldap" +require 'shortport' +require 'comm' + +portrule = shortport.port_or_service({389,636}, {"ldap","ldapssl"}) + +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 = string.char( 0x30, 0x0c, 0x02, 0x01, 0x01, 0x60, 0x07, 0x02, 0x01, 0x03, 0x04, 0x00, 0x80, 0x00 ) + socket, _, opt = comm.tryssl( host, port, ldap_anonymous_bind, nil ) + + if not socket then + return + end + + -- We close and re-open the socket so that the anonymous bind does not distract us + socket:close() + status = socket:connect(host.ip, port.number, 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 + + if not status then + socket:close() + 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" + + return stdnse.format_output(true, result ) + +end \ No newline at end of file diff --git a/scripts/script.db b/scripts/script.db index a9330b8a3..4112ad15e 100644 --- a/scripts/script.db +++ b/scripts/script.db @@ -39,6 +39,8 @@ Entry { filename = "http-vmware-path-vuln.nse", categories = { "default", "safe" Entry { filename = "iax2-version.nse", categories = { "version", } } Entry { filename = "imap-capabilities.nse", categories = { "default", "safe", } } Entry { filename = "irc-info.nse", categories = { "default", "discovery", "safe", } } +Entry { filename = "ldap-brute.nse", categories = { "auth", "intrusive", } } +Entry { filename = "ldap-rootdse.nse", categories = { "discovery", "safe", } } Entry { filename = "lexmark-config.nse", categories = { "discovery", "safe", } } Entry { filename = "mongodb-databases.nse", categories = { "default", "discovery", "safe", } } Entry { filename = "mongodb-info.nse", categories = { "default", "discovery", "safe", } }