sqlmap/tests/test_odata.py
Miroslav Štampar 90b14df46c Minor update
2026-08-11 23:07:22 +02:00

471 lines
20 KiB
Python

#!/usr/bin/env python
"""
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
See the file 'LICENSE' for copying permission
Offline, deterministic tests for the OData '$filter' injection engine. A mock oracle mirrors the
boolean-blind semantics of a real OData service (a broken-out filter reduced to its injected predicate),
so detection, OData-only confirmation, version fingerprinting and per-entity blind extraction are
exercised without a live OData endpoint.
"""
import os
import re
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _testutils import bootstrap
bootstrap()
import lib.techniques.odata.inject as odata
from lib.core.settings import ODATA_CHARSET_BLOCK
from lib.core.settings import ODATA_ERROR_REGEX
_ENTITIES = (
{"Id": 1, "Name": "luther", "Secret": "S3CR3Tvalue"},
{"Id": 2, "Name": "fluffy", "Secret": "hunter2"},
{"Id": 3, "Name": "wu", "Secret": "letmein"},
)
_FIELDS = ("Id", "Name", "Secret")
def _wrapped(expr):
if not (expr.startswith("(") and expr.endswith(")")):
return False
depth = 0
for i, ch in enumerate(expr):
depth += (ch == "(") - (ch == ")")
if depth == 0 and i < len(expr) - 1:
return False
return True
def _split(expr, sep):
parts, buf = [], []
for token in expr.split(sep):
buf.append(token)
chunk = sep.join(buf)
if chunk.count("(") == chunk.count(")"):
parts.append(chunk)
buf = []
if buf:
parts.append(sep.join(buf))
return parts
class _ODErr(Exception):
pass
def _atom(e, a):
a = a.strip()
while _wrapped(a):
a = a[1:-1].strip()
m = re.match(r"^length\('([^']*)'\) eq (\d+)$", a)
if m:
return len(m.group(1)) == int(m.group(2))
m = re.match(r"^startswith\('([^']*)','([^']*)'\)$", a)
if m:
return m.group(1).startswith(m.group(2))
m = re.match(r"^contains\('([^']*)','([^']*)'\)$", a)
if m:
return m.group(2) in m.group(1)
if a.startswith("substringof("):
raise _ODErr("v4 has no substringof")
m = re.match(r"^'([^']*)' eq '([^']*)'$", a)
if m:
return m.group(1) == m.group(2)
m = re.match(r"^(\d+) eq (\d+)$", a)
if m:
return m.group(1) == m.group(2)
m = re.match(r"^(\w+) eq '([^']*)'$", a)
if m:
if m.group(1) not in _FIELDS:
raise _ODErr("unknown property")
return "%s" % e.get(m.group(1)) == m.group(2)
m = re.match(r"^(\w+) ne null$", a)
if m:
if m.group(1) not in _FIELDS:
raise _ODErr("unknown property")
return e.get(m.group(1)) is not None
m = re.match(r"^(\w+) (eq|ge|gt|le|lt) (-?\d+)$", a)
if m:
p, op, num = m.group(1), m.group(2), int(m.group(3))
if p not in _FIELDS:
raise _ODErr("unknown property")
v = e.get(p)
if not isinstance(v, int):
return False
return {"eq": v == num, "ge": v >= num, "gt": v > num, "le": v <= num, "lt": v < num}[op]
m = re.match(r"^length\((\w+)\) (eq|ge) (\d+)$", a)
if m:
p, op, num = m.group(1), m.group(2), int(m.group(3))
if p not in _FIELDS:
raise _ODErr("unknown property")
n = len("%s" % e.get(p, ""))
return n == num if op == "eq" else n >= num
# an inner single quote arrives DOUBLED, the way the OData spec escapes it
m = re.match(r"^substring\((\w+),(\d+),1\) eq '(''|.)'$", a)
if m:
p, pos, ch = m.group(1), int(m.group(2)), m.group(3)
ch = "'" if ch == "''" else ch
if p not in _FIELDS:
raise _ODErr("unknown property")
t = "%s" % e.get(p, "")
return pos < len(t) and t[pos] == ch
raise _ODErr("syntax error")
def _eval(e, expr):
expr = expr.strip()
while _wrapped(expr):
expr = expr[1:-1].strip()
ors = _split(expr, " or ")
if len(ors) > 1:
return any(_eval(e, o) for o in ors)
ands = _split(expr, " and ")
if len(ands) > 1:
return all(_eval(e, a) for a in ands)
return _atom(e, expr)
_EMPTY = "<html><body><ul></ul></body></html>"
def _render(matched):
return "<html><body><ul>%s</ul></body></html>" % "".join("<li>%s: %s</li>" % (e["Id"], e["Name"]) for e in matched)
def _mockSend(place, parameter, value, raw=False):
expr = "Name eq '%s'" % value
if expr.count("'") % 2:
return "<pre>The query specified in the URI is not valid. There is an unterminated string literal at position 8</pre>" if raw else None
try:
matched = [e for e in _ENTITIES if _eval(e, expr)]
except _ODErr:
return "<pre>Could not find a property named 'x' on type 'Default.Product'. Microsoft.OData</pre>" if raw else None
return _render(matched)
class TestHelpers(unittest.TestCase):
def test_is_error_and_backend(self):
self.assertTrue(odata._isError("The query specified in the URI is not valid. Microsoft.OData"))
self.assertFalse(odata._isError(_EMPTY))
self.assertEqual(odata._backendFromError("Could not find a property named 'X'"), "Microsoft OData (WebAPI/.NET)")
def test_error_regex_matches_real(self):
self.assertIsNotNone(re.search(ODATA_ERROR_REGEX, "There is an unterminated string literal at position 17 in 'Name eq'"))
class TestNoAmpersandInvariant(unittest.TestCase):
"""A raw '&' in a GET value is the parameter delimiter; OData uses the 'or'/'and' keywords, so no
payload may carry one."""
def test_boundaries_and_predicates_have_no_ampersand(self):
for row in odata._BOUNDARY_TABLE:
for field in row:
self.assertNotIn("&", field)
for t, f in odata._ODATA_PREDICATES:
self.assertNotIn("&", t)
self.assertNotIn("&", f)
def test_send_url_encodes_the_payload(self):
"""The boundaries being '&'-free was never enough: the character scan emits '&' (and '+') as
DATA. _send() must URL-encode, or those probes split the request, the truncated $filter 400s,
and the resulting InconclusiveError aborts the WHOLE property (it dumped as '?')."""
sent = []
savedParams, odata.conf.parameters = odata.conf.parameters, {odata.PLACE.GET: "name=luther"}
savedGet = odata.Request.getPage
odata.Request.getPage = staticmethod(
lambda **kwargs: (sent.append(odata.conf.parameters[odata.PLACE.GET]), "", None, 200)[1:])
try:
odata._send(odata.PLACE.GET, "name", odata._literal(ord("&")))
finally:
odata.Request.getPage = savedGet
odata.conf.parameters = savedParams
self.assertEqual(len(sent), 1)
self.assertNotIn("&", sent[0].split("=", 1)[1], sent[0])
self.assertIn("%26", sent[0])
class TestCharsetCoverage(unittest.TestCase):
"""The scan is EXACT equality, not a bisection, so the ORDER is free but the COVERAGE is not: an
excluded codepoint is simply never recoverable. The one character an OData literal cannot carry
raw, the single quote, is doubled per the spec rather than dropped.
(This replaces an earlier assertion pinning 0x27/0x5c as EXCLUDED, which cost coverage of two
characters common in real names and paths for no correctness gain.)"""
def test_charset_covers_every_printable(self):
self.assertEqual(sorted(odata._CS_ORDS),
list(range(odata.ODATA_CHAR_MIN, odata.ODATA_CHAR_MAX + 1)))
def test_quote_is_doubled(self):
self.assertEqual(odata._literal(0x27), "''''")
self.assertEqual(odata._literal(0x5c), "'\\'")
self.assertEqual(odata._literal(ord("a")), "'a'")
def _mockSendV23(place, parameter, value, raw=False):
"""A v2/v3 service: contains() is an unknown function (400), substringof() is the one that parses."""
if "contains(" in value:
return "<pre>Syntax error at position 0. Microsoft.OData</pre>" if raw else None
return _mockSend(place, parameter, value.replace("substringof('sql','sqlmap')", "1 eq 1"), raw)
def _mockSendQuiet(place, parameter, value, raw=False):
"""An endpoint that SWALLOWS the service's 400: same boolean oracle, no error surface at all - a
failed $filter is rendered as the ordinary empty result page, error body and status included."""
expr = "Name eq '%s'" % value
if expr.count("'") % 2:
return _EMPTY
try:
return _render([e for e in _ENTITIES if _eval(e, expr)])
except _ODErr:
return _EMPTY
class TestVersionFingerprintDoesNotCrash(unittest.TestCase):
"""Both version probes are EXPECTED to fail on the dialect that does not own them. An unknown
$filter function is a 400 the oracle can only call inconclusive, and that must read as a negative
answer - not as an exception that aborts the scan before the finding is even reported, on exactly
the v2/v3 services the second branch exists to name."""
def setUp(self):
self.saved, self.savedParams = odata._send, odata.conf.parameters
odata.conf.parameters = {odata.PLACE.GET: "name=luther"}
odata.SENTINEL = "zzsentinelzz"
def tearDown(self):
odata._send, odata.conf.parameters = self.saved, self.savedParams
def test_v23_service_is_named_not_fatal(self):
odata._send = _mockSendV23
_t, _p, boundary = odata._detectBoolean(odata.PLACE.GET, "name")
oracle = odata._makeOracle(odata.PLACE.GET, "name", boundary)
self.assertIsNotNone(oracle)
self.assertEqual(odata._fingerprintVersion(oracle), "v2/v3")
def test_service_speaking_neither_returns_none(self):
odata._send = lambda place, parameter, value, raw=False: _mockSend(
place, parameter, value.replace("contains('sqlmap','sql')", "unknownfn()"), raw)
_t, _p, boundary = odata._detectBoolean(odata.PLACE.GET, "name")
oracle = odata._makeOracle(odata.PLACE.GET, "name", boundary)
self.assertIsNone(odata._fingerprintVersion(oracle))
class TestErrorSurfaceGate(unittest.TestCase):
"""_fieldExists() 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. _hasErrorSurface() tells the two apart in one request."""
def setUp(self):
self.saved, self.savedParams = odata._send, odata.conf.parameters
odata.conf.parameters = {odata.PLACE.GET: "name=luther"}
odata.SENTINEL = "zzsentinelzz"
def tearDown(self):
odata._send, odata.conf.parameters = self.saved, self.savedParams
def _boundary(self):
return odata._detectBoolean(odata.PLACE.GET, "name")[2]
def test_error_surface_present(self):
odata._send = _mockSend
boundary = self._boundary()
self.assertTrue(odata._hasErrorSurface(odata.PLACE.GET, "name", boundary))
def test_error_surface_absent(self):
odata._send = _mockSendQuiet
boundary = self._boundary()
self.assertFalse(odata._hasErrorSurface(odata.PLACE.GET, "name", boundary))
# the error-based check is useless here - every unknown name looks real
self.assertTrue(odata._fieldExists(odata.PLACE.GET, "name", boundary, "TotallyMadeUp"))
# ...so existence falls through to the boolean oracle, which still separates them. The dump
# must keep working on a blind target, not be surrendered because one probe went blind.
oracle = odata._makeOracle(odata.PLACE.GET, "name", boundary, truePredicate="(Id eq 1)")
self.assertIsNotNone(oracle)
self.assertTrue(odata._fieldExistsBlind(oracle, "Id", 1, "Secret"))
self.assertFalse(odata._fieldExistsBlind(oracle, "Id", 1, "TotallyMadeUp"))
class TestDetectionAndExtraction(unittest.TestCase):
def setUp(self):
self.saved = odata._send
odata._send = _mockSend
self.savedParams = odata.conf.parameters
odata.conf.parameters = {odata.PLACE.GET: "name=luther"}
odata.SENTINEL = "zzsentinelzz"
def tearDown(self):
odata._send = self.saved
odata.conf.parameters = self.savedParams
def test_boolean_detection(self):
template, payload, boundary = odata._detectBoolean(odata.PLACE.GET, "name")
self.assertIsNotNone(template)
def test_confirms_odata(self):
_t, _p, boundary = odata._detectBoolean(odata.PLACE.GET, "name")
self.assertTrue(odata._confirmOData(odata.PLACE.GET, "name", boundary))
def test_version_fingerprint_v4(self):
_t, _p, boundary = odata._detectBoolean(odata.PLACE.GET, "name")
oracle = odata._makeOracle(odata.PLACE.GET, "name", boundary)
self.assertEqual(odata._fingerprintVersion(oracle), "v4")
def test_field_existence(self):
_t, _p, boundary = odata._detectBoolean(odata.PLACE.GET, "name")
self.assertTrue(odata._fieldExists(odata.PLACE.GET, "name", boundary, "Secret"))
self.assertFalse(odata._fieldExists(odata.PLACE.GET, "name", boundary, "Nope"))
def test_key_and_entities(self):
_t, _p, boundary = odata._detectBoolean(odata.PLACE.GET, "name")
key, keys = odata._findKeyAndEntities(odata.PLACE.GET, "name", boundary, _EMPTY)
self.assertEqual(key, "Id")
self.assertEqual(keys, [1, 2, 3])
def test_blind_field_extraction_reaches_secret(self):
_t, _p, boundary = odata._detectBoolean(odata.PLACE.GET, "name")
oracle = odata._makeOracle(odata.PLACE.GET, "name", boundary, truePredicate="(Id eq 1)")
self.assertIsNotNone(oracle)
self.assertEqual(odata._inferField(oracle, "Id", 1, "Name"), "luther")
self.assertEqual(odata._inferField(oracle, "Id", 1, "Secret"), "S3CR3Tvalue")
def test_plain_sql_endpoint_not_confirmed(self):
odata._send = lambda place, parameter, value, raw=False: _render(_ENTITIES) # always same
template, _p, _b = odata._detectBoolean(odata.PLACE.GET, "name")
self.assertIsNone(template)
class TestCapsAreDisclosed(unittest.TestCase):
"""Every bound here is a bisection bound, so a result sitting ON it is a FLOOR, not a measurement.
Handing back a capped prefix (or a capped entity list) with no word said reads exactly like a
complete answer - the one failure mode a blind dumper must never have."""
def setUp(self):
self.saved, self.savedParams = odata._send, odata.conf.parameters
odata._send = _mockSend
odata.conf.parameters = {odata.PLACE.GET: "name=luther"}
odata.SENTINEL = "zzsentinelzz"
self.savedWarning, self.warnings = odata.logger.warning, []
odata.logger.warning = lambda message, *args: self.warnings.append(message % args if args else message)
def tearDown(self):
odata._send, odata.conf.parameters = self.saved, self.savedParams
odata.logger.warning = self.savedWarning
def _oracle(self):
_t, _p, boundary = odata._detectBoolean(odata.PLACE.GET, "name")
oracle = odata._makeOracle(odata.PLACE.GET, "name", boundary, truePredicate="(Id eq 1)")
self.assertIsNotNone(oracle)
return oracle
def test_truncated_value_is_reported(self):
value = odata._inferField(self._oracle(), "Id", 1, "Secret", maxLen=4)
self.assertEqual(value, "S3CR")
self.assertTrue(any("at least 4 characters" in _ for _ in self.warnings), self.warnings)
def test_value_that_fits_is_silent(self):
value = odata._inferField(self._oracle(), "Id", 1, "Name", maxLen=64)
self.assertEqual(value, "luther")
self.assertEqual(self.warnings, [])
def test_entity_cap_is_reported(self):
savedCap, odata.ODATA_MAX_RECORDS = odata.ODATA_MAX_RECORDS, 2
try:
_t, _p, boundary = odata._detectBoolean(odata.PLACE.GET, "name")
key, keys = odata._findKeyAndEntities(odata.PLACE.GET, "name", boundary, _EMPTY)
finally:
odata.ODATA_MAX_RECORDS = savedCap
self.assertEqual(key, "Id")
self.assertEqual(keys, [1, 2]) # the third entity exists but is beyond the cap...
self.assertTrue(any("2-entity cap" in _ for _ in self.warnings), self.warnings)
_LITERAL = r"'(?:[^']|'')*'"
_IN_REGEX = re.compile(r"(?P<lhs>substring\(\w+,\d+,1\)|%s) in \((?P<items>%s(?:,%s)*)\)"
% (_LITERAL, _LITERAL, _LITERAL))
def _expandIn(expr):
"""Rewrite a v4.01 "X in (a,b,c)" into the equivalent v4.0 disjunction - all the mock parser needs
in order to model a service that offers the operator, without teaching _atom() a second syntax."""
def expand(match):
lhs = match.group("lhs")
return "(%s)" % " or ".join("%s eq %s" % (lhs, _) for _ in re.findall(_LITERAL, match.group("items")))
return _IN_REGEX.sub(expand, expr)
def _mockSendIn(place, parameter, value, raw=False):
"""A v4.01 service: identical semantics, but it also parses the 'in' operator."""
return _mockSend(place, parameter, _expandIn(value), raw)
class TestSetMembershipRecovery(unittest.TestCase):
"""Character recovery asks about a whole candidate SET per request. .NET string comparison is
culture-aware and case-folding, so a lexicographic bisection is unusable - but set membership needs
no ordering and halves the space just the same. This pins the cost: the linear scan this replaced
spent one request per candidate, up to the whole charset for a single character."""
def setUp(self):
self.saved, self.savedParams = odata._send, odata.conf.parameters
odata.conf.parameters = {odata.PLACE.GET: "name=luther"}
odata.SENTINEL = "zzsentinelzz"
self.sent = []
def tearDown(self):
odata._send, odata.conf.parameters = self.saved, self.savedParams
def _extract(self, service, field="Secret"):
def counting(place, parameter, value, raw=False):
self.sent.append(value)
return service(place, parameter, value, raw)
odata._send = counting
_t, _p, boundary = odata._detectBoolean(odata.PLACE.GET, "name")
oracle = odata._makeOracle(odata.PLACE.GET, "name", boundary, truePredicate="(Id eq 1)")
self.assertIsNotNone(oracle)
before = len(self.sent)
return odata._inferField(oracle, "Id", 1, field), len(self.sent) - before
def test_v40_service_extracts_under_a_linear_scan(self):
value, cost = self._extract(_mockSend)
self.assertEqual(value, "S3CR3Tvalue")
self.assertLess(cost, len(value) * len(odata._CS_ORDS))
def test_v401_service_extracts_the_same_value(self):
value, cost = self._extract(_mockSendIn)
self.assertEqual(value, "S3CR3Tvalue")
self.assertLess(cost, len(value) * len(odata._CS_ORDS))
def test_in_encoding_is_used_only_when_offered(self):
self._extract(_mockSendIn)
self.assertTrue(any(" in (" in _ for _ in self.sent))
self.sent = []
self._extract(_mockSend)
self.assertFalse(any("substring(Secret,0,1) in (" in _ for _ in self.sent))
def test_disjunction_stays_inside_the_node_budget(self):
"""Every 'or' term costs the service's parser ~8 of the 100 nodes ASP.NET Core OData allows by
default (MaxNodeCount); an 11-term disjunction was measured being rejected outright."""
self._extract(_mockSend)
for value in self.sent:
self.assertLessEqual(value.count("substring("), ODATA_CHARSET_BLOCK)
def test_split_point_never_degenerates(self):
"""A cut of 0 or of len(candidates) would leave the set unchanged and loop forever."""
for size in range(2, len(odata._CS_ORDS) + 1):
cut = odata._splitPoint(odata._CS_ORDS[:size])
self.assertTrue(0 < cut < size, "size %d cut %d" % (size, cut))
if __name__ == "__main__":
unittest.main(verbosity=2)