diff --git a/scripts/ldap-search.nse b/scripts/ldap-search.nse new file mode 100644 index 000000000..efb7d2340 --- /dev/null +++ b/scripts/ldap-search.nse @@ -0,0 +1,242 @@ +description = [[ +Attempts to perform an LDAP search and returns all matches. +]] + +--- +-- @usage +-- nmap -p 389 --script ldap-search --script-args ldap.username="'cn=ldaptest,cn=users,dc=cqure,dc=net'",ldap.password=ldaptest, +-- ldap.qfilter=users,ldap.attrib=sAMAccountName +-- +-- @output +-- PORT STATE SERVICE REASON +-- 389/tcp open ldap syn-ack +-- | ldap-search: +-- | DC=cqure,DC=net +-- | dn: CN=Administrator,CN=Users,DC=cqure,DC=net +-- | sAMAccountName: Administrator +-- | dn: CN=Guest,CN=Users,DC=cqure,DC=net +-- | sAMAccountName: Guest +-- | dn: CN=SUPPORT_388945a0,CN=Users,DC=cqure,DC=net +-- | sAMAccountName: SUPPORT_388945a0 +-- | dn: CN=EDUSRV011,OU=Domain Controllers,DC=cqure,DC=net +-- | sAMAccountName: EDUSRV011$ +-- | dn: CN=krbtgt,CN=Users,DC=cqure,DC=net +-- | sAMAccountName: krbtgt +-- | dn: CN=Patrik Karlsson,CN=Users,DC=cqure,DC=net +-- | sAMAccountName: patrik +-- | dn: CN=VMABUSEXP008,CN=Computers,DC=cqure,DC=net +-- | sAMAccountName: VMABUSEXP008$ +-- | dn: CN=ldaptest,CN=Users,DC=cqure,DC=net +-- |_ sAMAccountName: ldaptest +-- +-- +-- @args ldap.username If set, the script will attempt to perform an LDAP bind using the username and password +-- @args ldap.password If set, used together with the username to authenticate to the LDAP server +-- @args ldap.qfilter If set, specifies a quick filter. The library does not support parsing real LDAP filters. +-- The following values are valid for the filter parameter: computer, users or all. If no value is specified it defaults to all. +-- @args ldap.base If set, the script will use it as a base for the search. By default the defaultNamingContext is retrieved and used. +-- If no defaultNamingContext is available the script iterates over the available namingContexts +-- @args ldap.attrib If set, the search will include only the attributes specified. For a single attribute a string value can be used, if +-- multiple attributes need to be supplied a table should be used instead. +-- @args ldap.maxobjects If set, overrides the number of objects returned by the script (default 20). +-- The value -1 removes the limit completely. +-- +-- +-- Authentication +-- -------------- +-- If no username and password is supplied to the script the Nmap registry is consulted. +-- If the ldap-brute script has been selected and it found a valid account, this account will be used. +-- If not anonymous bind will be used as a last attempt. +-- +-- Credit +-- ------ +-- o Martin Swende who provided me with the initial code that got me started writing this. +-- + +-- Version 0.4 +-- Created 01/12/2010 - v0.1 - created by Patrik Karlsson +-- Revised 01/20/2010 - v0.2 - added SSL support +-- Revised 01/26/2010 - v0.3 - Changed SSL support to comm.tryssl, prefixed arguments with ldap, changes in determination of namingContexts +-- Revised 02/17/2010 - v0.4 - Added dependencie to ldap-brute and the abilitity to check for ldap accounts (credentials) stored in nmap registry +-- Capped output to 20 entries, use ldap.maxObjects to override + +author = "Patrik Karlsson" +license = "Same as Nmap--See http://nmap.org/book/man-legal.html" +categories = {"discovery", "safe"} + +require "ldap" +require 'shortport' +require 'comm' + +dependencies = {"ldap-brute"} + +portrule = shortport.port_or_service({389,636}, {"ldap","ldapssl"}) + +function action(host,port) + + local status + local socket, opt + local args = nmap.registry.args + local username = args['ldap.username'] + local password = args['ldap.password'] + local qfilter = args['ldap.qfilter'] + local base = args['ldap.base'] + local attribs = args['ldap.attrib'] + local accounts + local objCount = 0 + local maxObjects = nmap.registry.args['ldap.maxobjects'] and tonumber(nmap.registry.args['ldap.maxobjects']) or 20 + + -- 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 + + -- Check if ldap-brute stored us some credentials + if ( not(username) and nmap.registry.ldapaccounts~=nil ) then + accounts = nmap.registry.ldapaccounts + 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) + + local req + local searchResEntries + local contexts = {} + local result = {} + local filter + + if base == nil then + req = { baseObject = "", scope = ldap.SCOPE.base, derefPolicy = ldap.DEREFPOLICY.default, attributes = { "defaultNamingContext", "namingContexts" } } + status, searchResEntries = ldap.searchRequest( socket, req ) + + if not status then + socket:close() + return + end + + contexts = ldap.extractAttribute( searchResEntries, "defaultNamingContext" ) + + -- OpenLDAP does not have a defaultNamingContext + if not contexts then + contexts = ldap.extractAttribute( searchResEntries, "namingContexts" ) + end + else + table.insert(contexts, base) + end + + if #contexts == 0 then + stdnse.print_debug( "Failed to retrieve namingContexts" ) + end + + -- perform a bind only if we have valid credentials + if ( username ) then + local bindParam = { version=3, ['username']=username, ['password']=password} + local status, errmsg = ldap.bindRequest( socket, bindParam ) + + if not status then + stdnse.print_debug( string.format("ldap-search failed to bind: %s", errmsg) ) + return + end + -- or if ldap-brute found us something + elseif ( accounts ) then + for username, password in pairs(accounts) do + local bindParam = { version=3, ['username']=username, ['password']=password} + local status, errmsg = ldap.bindRequest( socket, bindParam ) + + if status then + break + end + end + end + + if qfilter == "users" then + filter = { op=ldap.FILTER._or, val= + { + { op=ldap.FILTER.equalityMatch, obj='objectClass', val='user' }, + { op=ldap.FILTER.equalityMatch, obj='objectClass', val='posixAccount' } + } + } + elseif qfilter == "computers" or qfilter == "computer" then + filter = { op=ldap.FILTER.equalityMatch, obj='objectClass', val='computer' } + elseif qfilter == "all" or qfilter == nil then + filter = nil -- { op=ldap.FILTER} + else + return " \n\nERROR: Unsupported Quick Filter: " .. qfilter + end + + if type(attribs) == 'string' then + local tmp = attribs + attribs = {} + table.insert(attribs, tmp) + end + + for _, context in ipairs(contexts) do + + req = { + baseObject = context, + scope = ldap.SCOPE.sub, + derefPolicy = ldap.DEREFPOLICY.default, + filter = filter, + attributes = attribs, + ['maxObjects'] = maxObjects } + status, searchResEntries = ldap.searchRequest( socket, req ) + + if not status then + if ( searchResEntries:match("DSID[-]0C090627") and not(username) ) then + return "ERROR: Failed to bind as the anonymous user" + else + stdnse.print_debug( string.format( "ldap.searchRequest returned: %s", searchResEntries ) ) + return + end + end + + local result_part = ldap.searchResultToTable( searchResEntries ) + objCount = objCount + (result_part and #result_part or 0) + result_part.name = "" + + if ( context ) then + result_part.name = ("Context: %s"):format(context) + end + if ( qfilter ) then + result_part.name = result_part.name .. ("; QFilter: %s"):format(qfilter) + end + if ( attribs ) then + result_part.name = result_part.name .. ("; Attributes: %s"):format(stdnse.strjoin(",", attribs)) + end + + table.insert( result, result_part ) + + -- catch any softerrors + if searchResEntries.resultCode ~= 0 then + local output = stdnse.format_output(true, result ) + output = output .. string.format(" \n\n\n=========== %s ===========", searchResEntries.errorMessage ) + + return output + end + + end + + -- perform a unbind only if we have valid credentials + if ( username ) then + status = ldap.unbindRequest( socket ) + end + + socket:close() + + -- if taken a way and ldap returns a single result, it ain't shown.... + --result.name = "LDAP Results" + + local output = stdnse.format_output(true, result ) + + if ( maxObjects ~= -1 and objCount == maxObjects ) then + output = output .. (" \n\nResult limited to %d objects"):format(maxObjects) + end + + return output +end \ No newline at end of file