summaryrefslogtreecommitdiffstats
path: root/nselib/ldap.lua
diff options
context:
space:
mode:
Diffstat (limited to 'nselib/ldap.lua')
-rw-r--r--nselib/ldap.lua917
1 files changed, 917 insertions, 0 deletions
diff --git a/nselib/ldap.lua b/nselib/ldap.lua
new file mode 100644
index 0000000..ffaded2
--- /dev/null
+++ b/nselib/ldap.lua
@@ -0,0 +1,917 @@
+---
+-- Library methods for handling LDAP.
+--
+-- @author Patrik Karlsson
+-- @copyright Same as Nmap--See https://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.8
+-- Created 01/12/2010 - v0.1 - Created by Patrik Karlsson <patrik@cqure.net>
+-- 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
+-- Revised 09/05/2011 - v0.4 - Revised to include support for writing output to file, added decoding certain time
+-- 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
+-- Revised 07/11/2017 - v0.8 - Added support for decoding the objectSID Active Directory attribute - Tom Sellers
+--
+
+local asn1 = require "asn1"
+local datetime = require "datetime"
+local io = require "io"
+local nmap = require "nmap"
+local stdnse = require "stdnse"
+local string = require "string"
+local stringaux = require "stringaux"
+local table = require "table"
+local comm = require "comm"
+_ENV = stdnse.module("ldap", stdnse.seeall)
+
+local ldapMessageId = 1
+
+ERROR_MSG = {}
+ERROR_MSG[1] = "Initialization of LDAP library failed."
+ERROR_MSG[4] = "Size limit exceeded."
+ERROR_MSG[13] = "Confidentiality required"
+ERROR_MSG[32] = "No such object"
+ERROR_MSG[34] = "Invalid DN"
+ERROR_MSG[49] = "The supplied credential is invalid."
+
+ERRORS = {
+ LDAP_SUCCESS = 0,
+ LDAP_SIZELIMIT_EXCEEDED = 4
+}
+
+--- Application constants
+-- @class table
+-- @name APPNO
+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 == '\x0A') then
+ local ival = self.encodeInt(val[1])
+ local len = self.encodeLength(#ival)
+ return val._ldap .. len .. ival
+ end
+ if (val._ldaptype) then
+ local len
+ if val[1] == nil or #val[1] == 0 then
+ return val._ldaptype .. '\0'
+ else
+ len = self.encodeLength(#val[1])
+ return val._ldaptype .. len .. val[1]
+ end
+ end
+
+ local encVal = ""
+ for _, v in ipairs(val) do
+ encVal = encVal .. encode(v) -- todo: buffer?
+ end
+ local tableType = val._snmp or "\x30"
+ return tableType .. self.encodeLength(#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["\x0A"] = function( self, encStr, elen, pos )
+ return self.decodeInt(encStr, elen, pos)
+end
+
+tagDecoder["\x8A"] = function( self, encStr, elen, pos )
+ return string.unpack("c" .. elen, encStr, pos)
+end
+
+-- null decoder
+tagDecoder["\x31"] = function( self, encStr, elen, pos )
+ return nil, pos
+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 decoded value(s).
+-- @return The position after decoding
+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 decoded sequence as a table.
+-- @return The position after decoding.
+local function decodeSeq(encStr, len, pos)
+ local seq = {}
+ local sPos = 1
+ if #encStr - pos + 1 < len then
+ return seq, nil
+ end
+ local sStr, newpos = string.unpack("c" .. len, encStr, pos)
+ while (sPos < len) do
+ local newSeq
+ newSeq, sPos = decode(sStr, sPos)
+ table.insert(seq, newSeq)
+ end
+ return seq, newpos
+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 = string.pack("B", 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 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 <code>errorMessage</code> and <code>resultCode</code> entries set with the error information.
+-- As a <code>try</code> 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 <code>scope</code>, <code>derefPolicy</code>, <code>baseObject</code>
+-- the field <code>maxObjects</code> 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 searchRequest( socket, params )
+
+ local searchResEntries = { errorMessage="", resultCode = 0}
+ local catch = function() socket:close() stdnse.debug1("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='\x0A', params.scope } )--scope
+ request = request .. encode( { _ldap='\x0A', 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='\x87', "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
+ len, pos = decoder.decodeLength( data, pos )
+ else
+ data = data .. try( socket:receive() )
+ len, pos = 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
+
+ messageId, pos = decode( data, pos )
+ tmp, pos = string.unpack("B", data, pos)
+ len, pos = decoder.decodeLength( data, pos )
+ ldapOp = asn1.intToBER( tmp )
+ searchResEntry = {}
+
+ if ldapOp.number == APPNO.SearchResDone then
+ searchResEntry.resultCode, pos = 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
+ searchResEntry.matchedDN, pos = decode( data, pos )
+ searchResEntry.errorMessage, pos = 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
+
+ searchResEntry.objectName, pos = decode( data, pos )
+ if ldapOp.number == APPNO.SearchResponse then
+ searchResEntry.attributes, pos = 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
+
+--- 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 <code>errorMessage</code> and <code>resultCode</code> entries set with the error information.
+-- As a <code>try</code> 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 <code>scope</code>, <code>derefPolicy</code>, <code>baseObject</code>
+-- the field <code>maxObjects</code> 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='\x0A', params.scope } )--scope
+ request = request .. encode( { _ldap='\x0A', 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='\x87', "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
+
+ tmp, pos = string.unpack("B", response, pos)
+ len, pos = decoder.decodeLength( response, pos )
+ messageId, pos = decode( response, pos )
+ tmp, pos = string.unpack("B", response, pos)
+ len, pos = decoder.decodeLength( response, pos )
+ ldapOp = asn1.intToBER( tmp )
+ searchResEntry = {}
+
+ if ldapOp.number == APPNO.SearchResDone then
+ searchResEntry.resultCode, pos = 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
+ searchResEntry.matchedDN, pos = decode( response, pos )
+ searchResEntry.errorMessage, pos = 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
+
+ searchResEntry.objectName, pos = decode( response, pos )
+ if ldapOp.number == APPNO.SearchResponse then
+ searchResEntry.attributes, pos = 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
+--
+-- @param socket socket already connected to the ldap server
+-- @param params table containing <code>version</code>, <code>username</code> and <code>password</code>
+-- @return success true or false
+-- @return err string containing error message
+function bindRequest( socket, params )
+
+ local catch = function() socket:close() stdnse.debug1("bindRequest failed") end
+ local try = nmap.new_try(catch)
+ local ldapAuth = encode( { _ldaptype = '\x80', 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() )
+
+ packet_len, pos = decoder.decodeLength( packet, 2 )
+ response.messageID, pos = decode( packet, pos )
+ tmp, pos = string.unpack("B", packet, pos)
+ len, pos = decoder.decodeLength( packet, pos )
+ response.protocolOp = asn1.intToBER( tmp )
+
+ if response.protocolOp.number ~= APPNO.BindResponse then
+ return false, string.format("Received incorrect Op in packet: %d, expected %d", response.protocolOp.number, APPNO.BindResponse)
+ end
+
+ response.resultCode, pos = decode( packet, pos )
+
+ if ( response.resultCode ~= 0 ) then
+ local error_msg
+ response.matchedDN, pos = decode( packet, pos )
+ response.errorMessage, pos = decode( packet, pos )
+ error_msg = ERROR_MSG[response.resultCode]
+ return false, string.format("\n Error: %s\n Details: %s",
+ error_msg or "Unknown error occurred (code: " .. response.resultCode ..
+ ")", 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.debug1("bindRequest failed") end
+ local try = nmap.new_try(catch)
+
+ local encoder = asn1.ASN1Encoder:new()
+ encoder:registerTagEncoders(tagEncoder)
+
+ ldapMessageId = ldapMessageId +1
+ ldapMsg = encode( ldapMessageId ) .. 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 = ''
+ if ( filter.op == FILTER['substrings'] ) then
+
+ local tmptable = stringaux.strsplit('*', filter.val)
+ local tmp_result = ''
+
+ if (#tmptable <= 1 ) then
+ -- 0x81 = 10000001 = 10 0 00001
+ -- hex binary Context Primitive value Field: Sequence Value: 1 (any / any position in string)
+ tmp_result = string.pack('Bs1', 0x81, filter.val)
+ else
+ for indexval, substr in ipairs(tmptable) do
+ if (indexval == 1) and (substr ~= '') then
+ -- 0x81 = 10000000 = 10 0 00000
+ -- hex binary Context Primitive value Field: Sequence Value: 0 (initial / match at start of string)
+ tmp_result = '\x80' .. string.char(#substr) .. substr
+ end
+
+ if (indexval ~= #tmptable) and (indexval ~= 1) and (substr ~= '') then
+ -- 0x81 = 10000001 = 10 0 00001
+ -- hex binary Context Primitive value Field: Sequence Value: 1 (any / match in any position in string)
+ tmp_result = tmp_result .. string.pack('Bs1', 0x81, substr)
+ end
+
+ if (indexval == #tmptable) and (substr ~= '') then
+ -- 0x82 = 10000010 = 10 0 00010
+ -- hex binary Context Primitive value Field: Sequence Value: 2 (final / match at end of string)
+ tmp_result = tmp_result .. string.pack('Bs1', 0x82, substr)
+ end
+ end
+ end
+
+ val = asn1.ASN1Encoder:encodeSeq( tmp_result )
+
+ elseif ( filter.op == FILTER['extensibleMatch'] ) then
+
+ local tmptable = stringaux.strsplit(':=', filter.val)
+ local tmp_result = ''
+ local OID, bitmask
+
+ if ( tmptable[1] ~= nil ) then
+ OID = tmptable[1]
+ else
+ return false, ("ERROR: Invalid extensibleMatch query format")
+ end
+
+ if ( tmptable[2] ~= nil ) then
+ bitmask = tmptable[2]
+ else
+ return false, ("ERROR: Invalid extensibleMatch query format")
+ end
+
+ tmp_result = string.pack('Bs1 Bs1 Bs1 Bs1',
+ -- Format and create matchingRule using OID
+ -- 0x81 = 10000001 = 10 0 00001
+ -- hex binary Context Primitive value Field: matchingRule Value: 1
+ 0x81, OID,
+
+ -- Format and create type using ldap attribute
+ -- 0x82 = 10000010 = 10 0 00010
+ -- hex binary Context Primitive value Field: Type Value: 2
+ 0x82, filter.obj,
+
+ -- Format and create matchValue using bitmask
+ -- 0x83 = 10000011 = 10 0 00011
+ -- hex binary Context Primitive value Field: matchValue Value: 3
+ 0x83, bitmask,
+
+ -- Format and create dnAttributes, defaulting to false
+ -- 0x84 = 10000100 = 10 0 00100
+ -- hex binary Context Primitive value Field: dnAttributes Value: 4
+ -- 0x00 = boolean value, in this case false
+ 0x84, '\x00')
+
+ -- Format the overall extensibleMatch block
+ -- 0xa9 = 10101001 = 10 1 01001
+ -- hex binary Context Constructed Field: Filter Value: 9 (extensibleMatch)
+ return '\xa9' .. asn1.ASN1Encoder.encodeLength(#tmp_result) .. tmp_result
+
+ else
+ val = encode( filter.val )
+ end
+
+ filter_str = filter_str .. obj .. val
+
+ end
+ return encode( { _ldaptype=string.pack("B", 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 <code>stdnse.format_output</code>
+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 = "<ROOT>"
+ 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: %s", attrib[1], convertObjectSid( attrib[i] ) ) )
+ elseif ( attrib[1] == "objectGUID") then
+ local o = {string.unpack(("B"):rep(16), attrib[i] )}
+ table.insert( attribs, string.format( "%s: %x%x%x%x-%x%x-%x%x-%x%x-%x%x%x%x%x%x",
+ attrib[1], o[4], o[3], o[2], o[1], table.unpack(o, 5, 16)))
+ elseif ( attrib[1] == "lastLogon" or attrib[1] == "lastLogonTimestamp" or attrib[1] == "pwdLastSet" or attrib[1] == "accountExpires" or attrib[1] == "badPasswordTime" ) then
+ table.insert( attribs, string.format( "%s: %s", attrib[1], convertADTimeStamp(attrib[i]) ) )
+ elseif ( attrib[1] == "whenChanged" or attrib[1] == "whenCreated" or attrib[1] == "dSCorePropagationData" ) then
+ table.insert( attribs, string.format( "%s: %s", attrib[1], convertZuluTimeStamp(attrib[i]) ) )
+ 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
+
+--- Saves a search result as received from searchRequest to a file
+--
+-- 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
+-- @param filename the name of a save to save results to
+-- @return table suitable for <code>stdnse.format_output</code>
+function searchResultToFile( searchEntries, filename )
+
+ local f = io.open( filename, "w")
+
+ if ( not(f) ) then
+ return false, ("ERROR: Failed to open file (%s)"):format(filename)
+ end
+
+ -- Build table structure. Using a multi pass approach ( build table then populate table )
+ -- because the objects returned may not necessarily have the same number of attributes
+ -- making single pass CSV output generation problematic.
+ -- Unfortunately the searchEntries table passed to this function is not organized in a
+ -- way that make particular attributes for a given hostname directly addressable.
+ --
+ -- At some point restructuring the searchEntries table may be a good optimization target
+
+ -- build table of attributes
+ local attrib_table = {}
+ for _, v in ipairs( searchEntries ) do
+ if ( v.attributes ~= nil ) then
+ for _, attrib in ipairs( v.attributes ) do
+ for i=2, #attrib do
+ if ( attrib_table[attrib[1]] == nil ) then
+ attrib_table[attrib[1]] = ''
+ end
+ end
+ end
+ end
+ end
+
+ -- build table of hosts
+ local host_table = {}
+ for _, v in ipairs( searchEntries ) do
+ if v.objectName and v.objectName:len() > 0 then
+ local host = {}
+
+ if v.objectName and v.objectName:len() > 0 then
+ -- use a copy of the table here, assigning attrib_table into host_table
+ -- links the values so setting it for one host changes the specific attribute
+ -- values for all hosts.
+ host_table[v.objectName] = {attributes = copyTable(attrib_table) }
+ end
+ end
+ end
+
+ -- populate the host table with values for each attribute that has valid data
+ for _, v in ipairs( searchEntries ) do
+ 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
+ host_table[string.format("%s", v.objectName)].attributes[attrib[1]] = string.format( "%s", convertObjectSid(attrib[i]))
+
+ elseif ( attrib[1] == "objectGUID") then
+ local o = {string.unpack(("B"):rep(16), attrib[i] )}
+ host_table[string.format("%s", v.objectName)].attributes[attrib[1]] = string.format(
+ "%s: %x%x%x%x-%x%x-%x%x-%x%x-%x%x%x%x%x%x",
+ attrib[1], o[4], o[3], o[2], o[1], table.unpack(o, 5, 16))
+
+ elseif ( attrib[1] == "lastLogon" or attrib[1] == "lastLogonTimestamp" or attrib[1] == "pwdLastSet" or attrib[1] == "accountExpires" or attrib[1] == "badPasswordTime" ) then
+ host_table[string.format("%s", v.objectName)].attributes[attrib[1]] = convertADTimeStamp(attrib[i])
+
+ elseif ( attrib[1] == "whenChanged" or attrib[1] == "whenCreated" or attrib[1] == "dSCorePropagationData" ) then
+ host_table[string.format("%s", v.objectName)].attributes[attrib[1]] = convertZuluTimeStamp(attrib[i])
+
+ else
+ host_table[v.objectName].attributes[attrib[1]] = string.format( "%s", attrib[i] )
+ end
+
+ end
+ end
+ end
+ end
+
+ -- write the new, fully populated table out to CSV
+
+ -- initialize header row
+ local output = "\"name\""
+ for attribute, value in pairs(attrib_table) do
+ output = output .. ",\"" .. attribute .. "\""
+ end
+ output = output .. "\n"
+
+ -- gather host data from fields, add to output.
+ for name, attribs in pairs(host_table) do
+ output = output .. "\"" .. name .. "\""
+ local host_attribs = attribs.attributes
+ for attribute, value in pairs(attrib_table) do
+ output = output .. ",\"" .. host_attribs[attribute] .. "\""
+ end
+ output = output .. "\n"
+ end
+
+ -- write the output to file
+ if ( not(f:write( output .."\n" ) ) ) then
+ f:close()
+ return false, ("ERROR: Failed to write file (%s)"):format(filename)
+ end
+
+ f:close()
+ return true
+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:upper() == attributeName:upper() ) then
+ table.insert( attributeTbl, attrib[i])
+ end
+ end
+ end
+ end
+ end
+ return ( #attributeTbl > 0 and attributeTbl or nil )
+end
+
+--- Convert Microsoft Active Directory timestamp format to a human readable form
+-- These values store time values in 100 nanoseconds segments from 01-Jan-1601
+--
+-- @param timestamp Microsoft Active Directory timestamp value
+-- @return string containing human readable form
+function convertADTimeStamp(timestamp)
+
+ local result = 0
+ -- Windows cannot represent this time, so we pre-calculated it:
+ -- seconds since 1601/1/1 adjusted for local offset
+ local base_time = -11644473600 - datetime.utc_offset()
+
+ timestamp = tonumber(timestamp)
+
+ if (timestamp and timestamp > 0) then
+
+ -- The result value was 3036 seconds off what Microsoft says it should be.
+ -- I have been unable to find an explanation for this, and have resorted to
+ -- manually adjusting the formula.
+
+ result = ( timestamp // 10000000 ) - 3036
+ result = result + base_time
+ result = datetime.format_timestamp(result, 0)
+ else
+ result = 'Never'
+ end
+
+ return result
+
+end
+
+--- Converts a non-delimited Zulu timestamp format to a human readable form
+-- For example 20110904003302.0Z becomes 2001/09/04 00:33:02 UTC
+--
+--
+-- @param timestamp in Zulu format without separators
+-- @return string containing human readable form
+function convertZuluTimeStamp(timestamp)
+
+ if ( type(timestamp) == 'string' and string.sub(timestamp,-3) == '.0Z' ) then
+
+ local year = string.sub(timestamp,1,4)
+ local month = string.sub(timestamp,5,6)
+ local day = string.sub(timestamp,7,8)
+ local hour = string.sub(timestamp,9,10)
+ local mins = string.sub(timestamp,11,12)
+ local secs = string.sub(timestamp,13,14)
+ local result = year .. "/" .. month .. "/" .. day .. " " .. hour .. ":" .. mins .. ":" .. secs .. " UTC"
+
+ return result
+
+ else
+ return 'Invalid date format'
+ end
+
+end
+
+--- Converts the objectSid Active Directory attribute
+-- from hex to a human readable string
+--
+-- Example: 1-5-21-542885397-2936741293-3965599772-500
+--
+-- @param hex string form of objectSid from LDAP response
+-- @return string containing human readable form
+function convertObjectSid(data)
+
+ local pos, revision, auth, sub_auth_size, sub_auth, result
+
+ revision, pos = string.unpack('I1', data, 1)
+ sub_auth_size, pos = string.unpack('I1', data, pos)
+ auth, pos = string.unpack('>I6', data, pos)
+
+ sub_auth = ''
+ local tmp
+ local cnt = 0
+ while (cnt < sub_auth_size) do
+ tmp, pos = string.unpack('<I4', data, pos)
+ sub_auth = sub_auth .. '-' .. tmp
+ cnt = cnt + 1
+ end
+
+ result = revision .. '-' .. auth .. sub_auth
+ return result
+
+end
+
+--- Creates a copy of a table
+--
+--
+-- @param targetTable table object to copy
+-- @return table object containing copy of original
+function copyTable(targetTable)
+ local temp = { }
+ for key, val in pairs(targetTable) do
+ temp[key] = val
+ end
+ return setmetatable(temp, getmetatable(targetTable))
+end
+
+return _ENV;