Fixing --hex in boolean-based blind

This commit is contained in:
Miroslav Štampar 2026-07-28 11:12:51 +02:00
parent 5dd91ba8ca
commit cd911acb24
4 changed files with 96 additions and 3 deletions

1
.gitignore vendored
View file

@ -16,3 +16,4 @@ plugins/.DS_Store
thirdparty/.DS_Store
CLAUDE.md
.coverage
.codegraph/

View file

@ -20,7 +20,7 @@ from lib.core.enums import OS
from thirdparty import six
# sqlmap version (<major>.<minor>.<month>.<monthly commit>)
VERSION = "1.10.7.213"
VERSION = "1.10.7.214"
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)

View file

@ -268,6 +268,7 @@ def bisection(payload, expression, length=None, charsetType=None, firstChar=None
finalValue = None
retrievedLength = 0
columnKey = None
hexEncoded = False
if payload is None:
return 0, None
@ -369,6 +370,10 @@ def bisection(payload, expression, length=None, charsetType=None, firstChar=None
# rows for low-cardinality guessing and for its own per-column online Huffman model.
columnKey = normalizedExpression(expression) if dump else None
# Whole-value equality shortcuts (low-card guess, litmus) compare a hex-DECODED value against a
# HEX()-wrapped expression -> always miss; skip them when hex-encoding (--hex / --binary-fields).
hexEncoded = bool(conf.hexConvert or kb.binaryField)
# Low-cardinality whole-value guessing: when the distinct values already seen for this column are
# few (<= LOW_CARDINALITY_THRESHOLD), confirm the current cell by equality against each of them
# (one request on a hit) before per-character extraction - a large win on the enum/flag/status/
@ -376,7 +381,7 @@ def bisection(payload, expression, length=None, charsetType=None, firstChar=None
# Especially valuable for TIME-BASED blind: a hit confirms the whole value in a single delayed
# request instead of ~7 delays/char x N chars. The repetition gate below ensures it only ever fires
# on genuinely low-cardinality columns, so unique identifier names never pay a wasted probe/delay.
if columnKey is not None and not partialValue:
if columnKey is not None and not partialValue and not hexEncoded:
# Snapshot the shared cache under the lock (value-parallel workers may mutate it concurrently).
with kb.locks.prediction:
seen = dict(kb.lowCardCache.get(columnKey) or ())
@ -1188,7 +1193,7 @@ def bisection(payload, expression, length=None, charsetType=None, firstChar=None
# known-answer differential so an always-true / flaky / degraded channel that would otherwise dump
# SILENT garbage instead raises a one-time "results may be unreliable" warning. First value is always
# checked (catch it before a whole bad dump), then every ORACLE_LITMUS_CHECK_EVERY-th.
if (ORACLE_LITMUS_CHECK_EVERY and finalValue and not kb.reliabilityAlarm and not kb.bruteMode
if (ORACLE_LITMUS_CHECK_EVERY and finalValue and not kb.reliabilityAlarm and not kb.bruteMode and not hexEncoded
and (columnKey is not None or kb.partRun in NAME_PREDICTION_CONTEXTS)):
with kb.locks.prediction:
kb.litmusCounter += 1

View file

@ -1522,6 +1522,93 @@ class TestHexConvert(_InferenceCase):
self.assertEqual(value, decodeDbmsHexValue(hexed))
class TestHexEncodedShortcutsGated(_InferenceCase):
"""Under --hex / --binary-fields the expression is HEX()-wrapped, so the whole-value equality
shortcuts (low-card guess, oracle litmus) compare a hex-DECODED value against it and always miss -
wasting a probe per cell and tripping a spurious "unreliable" alarm. They must be skipped there.
Spying valueMatchCondition (the guess calls it first, 'continue's on None) and the litmus lets us
see whether each shortcut was reached without a full injection/agent context."""
_EXTRA_KB = ("lowCardCache", "dumpCharset", "dumpCharsetStable", "litmusCounter",
"reliabilityAlarm", "huffmanModel", "multiThreadMode", "commonOutputs")
def setUp(self):
_InferenceCase.setUp(self)
self._saved_extra_kb = {k: kb.get(k) for k in self._EXTRA_KB}
self._saved_noHuffman = conf.get("noHuffman")
self._saved_binaryFields = conf.get("binaryFields")
self._saved_dbms = kb.get("dbms")
self._saved_vmc = inf.valueMatchCondition
self._saved_litmus = inf.oracleReliabilityLitmus
conf.noHuffman = True # keep extraction on the classic '>' bisection path
conf.binaryFields = None
# bisection only builds the nulled/casted (and HEX-wrapped) expression - the step that sets
# kb.binaryField - when Backend.getDbms() is truthy; set_dbms() only forces getIdentifiedDbms()
kb.dbms = "MySQL"
kb.lowCardCache = {}
kb.dumpCharset = {}
kb.dumpCharsetStable = {}
kb.litmusCounter = 0
kb.reliabilityAlarm = False
kb.huffmanModel = {}
kb.multiThreadMode = False
kb.commonOutputs = None
def tearDown(self):
inf.valueMatchCondition = self._saved_vmc
inf.oracleReliabilityLitmus = self._saved_litmus
conf.noHuffman = self._saved_noHuffman
conf.binaryFields = self._saved_binaryFields
kb.dbms = self._saved_dbms
for k, v in self._saved_extra_kb.items():
kb[k] = v
_InferenceCase.tearDown(self)
_HEXED = "48656C6C6F" # hex of "Hello"; all-ASCII so the shortcuts are applicable
def _run(self, hexConvert, expression="SELECT secret", binaryField=False):
conf.hexConvert = hexConvert
if binaryField:
conf.binaryFields = [agent.getFields(expression)[6]] # the field bisection will hex-wrap
# arm the low-cardinality cache for this column so the guess WOULD fire if not gated
kb.lowCardCache[inf.normalizedExpression(expression)] = {"Hello": 3}
calls = {"guess": 0, "litmus": 0}
def vmc_spy(*args, **kwargs):
calls["guess"] += 1
return None # None -> guess loop 'continue's: no probe, no agent context needed
def litmus_spy(*args, **kwargs):
calls["litmus"] += 1
return True
inf.valueMatchCondition = vmc_spy
inf.oracleReliabilityLitmus = litmus_spy
_, value = self._bisect(self._HEXED, expression=expression, length=len(self._HEXED), dump=True)
return value, calls
def test_shortcuts_fire_without_hex(self):
# control: on a normal dump both shortcuts are reached (proves the test can actually see them,
# so the skips below are the gate doing its job, not a dead assertion)
value, calls = self._run(hexConvert=False)
self.assertEqual(value, self._HEXED) # no decode without --hex
self.assertGreater(calls["guess"], 0, "low-card guess should run on a normal dump")
self.assertEqual(calls["litmus"], 1, "oracle litmus should run on a normal dump")
def test_shortcuts_skipped_under_hex(self):
value, calls = self._run(hexConvert=True)
self.assertEqual(value, "Hello") # extraction still correct (decoded on the way out)
self.assertEqual(calls["guess"], 0, "low-card guess must be skipped when the expression is HEX-wrapped")
self.assertEqual(calls["litmus"], 0, "oracle litmus must be skipped when the expression is HEX-wrapped")
self.assertFalse(kb.reliabilityAlarm, "no false 'unreliable' alarm on a correct --hex dump")
def test_shortcuts_skipped_under_binary_field(self):
value, calls = self._run(hexConvert=False, binaryField=True)
self.assertEqual(value, self._HEXED)
self.assertEqual(calls["guess"], 0, "shortcuts must be skipped for a --binary-fields column")
self.assertEqual(calls["litmus"], 0)
class TestProcessCharHook(_InferenceCase):
def test_process_char_applied_to_each_char(self):
# kb.data.processChar transforms every assembled character