Skip to content

Commit 3067385

Browse files
committed
Making boolean inference more robust against jitter
1 parent 023d85f commit 3067385

3 files changed

Lines changed: 273 additions & 6 deletions

File tree

lib/core/settings.py

Lines changed: 1 addition & 1 deletion
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.7.240"
23+
VERSION = "1.10.7.241"
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)

lib/techniques/blind/inference.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -513,11 +513,20 @@ def validateChar(idx, value):
513513

514514
result = not Request.queryPage(forgedPayload, timeBasedCompare=timeBasedCompare, raise404=False)
515515

516-
if result and timeBasedCompare and getTechniqueData().trueCode:
517-
result = threadData.lastCode == getTechniqueData().trueCode
518-
if not result:
519-
warnMsg = "detected HTTP code '%s' in validation phase is differing from expected '%s'" % (threadData.lastCode, getTechniqueData().trueCode)
520-
singleTimeWarnMessage(warnMsg)
516+
if result and getTechniqueData() is not None:
517+
trueCode, falseCode = getTechniqueData().trueCode, getTechniqueData().falseCode
518+
if timeBasedCompare:
519+
if trueCode:
520+
result = threadData.lastCode == trueCode
521+
if not result:
522+
warnMsg = "detected HTTP code '%s' in validation phase is differing from expected '%s'" % (threadData.lastCode, trueCode)
523+
singleTimeWarnMessage(warnMsg)
524+
# A boolean validation confirmed under an UNEXPECTED HTTP code (a transient 5xx/403/429/..
525+
# landing on the validation request itself) is not trustworthy - fail it so the character is
526+
# re-extracted, riding out the blip. On a clean target every code is true/false -> no-op.
527+
elif threadData.lastCode is not None and any((trueCode, falseCode)) and threadData.lastCode not in (trueCode, falseCode):
528+
result = False
529+
singleTimeWarnMessage("unexpected HTTP code '%s' during validation phase; will re-extract" % threadData.lastCode)
521530

522531
incrementCounter(getTechnique())
523532

tests/test_boolean_jitter.py

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
#!/usr/bin/env python
2+
# -*- coding: utf-8 -*-
3+
4+
"""
5+
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
6+
See the file 'LICENSE' for copying permission
7+
8+
Adversarial "shitty response" JITTER harness for BOOLEAN-based blind extraction.
9+
10+
Boolean-blind decides each bit through the REAL comparison() oracle (--string / --not-string /
11+
--regexp / --code / page-ratio). In the wild a target throws transient junk between good responses -
12+
gateway 5xx, WAF/rate-limit pages, a Cloudflare "just a moment" interstitial, a captcha, a
13+
maintenance banner, a truncated or empty body, an A/B variant, even a page that COINCIDENTALLY
14+
contains the --string token (a direction-flipping false positive). This drives the REAL bisection() +
15+
REAL comparison() + REAL validateChar() re-validation against a mock oracle that injects that catalog
16+
(IID or in bursts) at controllable rates, with NO network, fully deterministic per seed.
17+
18+
The template is PAYLOAD_DELIMITER-wrapped so validateChar's per-char '!=' re-check actually fires
19+
(the same fidelity trap the time-based harness hit), and the mock sets threadData.lastCode so the
20+
unexpectedCode -> validateChar defense engages exactly as in a live run.
21+
22+
Two tiers (mirrors tests/test_jitter_stress.py):
23+
* TestBooleanJitterRegression - ALWAYS runs. Deterministic, non-flaky guards: clean extraction is
24+
perfect, benign dynamic content never corrupts, and a transient
25+
unexpected-code response landing on a validation request is ridden out.
26+
* TestBooleanJitterSweep - OPT-IN (SQLMAP_JITTER_STRESS=1). The creative failure-surface sweep
27+
(IID + bursty), informational + loose bounds, kept out of normal CI.
28+
"""
29+
30+
import os
31+
import random
32+
import re
33+
import sys
34+
import unittest
35+
36+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
37+
from _testutils import bootstrap, set_dbms, reset_dbms
38+
bootstrap()
39+
40+
from lib.core.data import conf, kb
41+
from lib.core.common import getCurrentThreadData, setTechnique
42+
from lib.core.datatype import AttribDict
43+
from lib.core.enums import HTTP_HEADER, PAYLOAD
44+
from lib.core.settings import PAYLOAD_DELIMITER
45+
from lib.request.comparison import comparison
46+
from lib.request.connect import Connect
47+
import lib.techniques.blind.inference as inf
48+
49+
_D = PAYLOAD_DELIMITER
50+
_TEMPLATE = "%sEXPR=%%s IDX=%%d CMP>%%d%s" % (_D, _D) # delimiter-wrapped -> validateChar '!=' fires
51+
_PARSE = re.compile(r"IDX=(\d+) CMP(!=|=|>)(\d+)")
52+
_SECRET = "Str0ng!"
53+
_STRING = "luther"
54+
_TRUE_BODY = "<html><body>welcome %s, here is your dashboard with 12 private items</body></html>" % _STRING
55+
_FALSE_BODY = "<html><body>invalid credentials, no such record, please retry</body></html>"
56+
_STRESS = os.environ.get("SQLMAP_JITTER_STRESS")
57+
58+
59+
class _Headers(object):
60+
def __init__(self, ct="text/html"):
61+
self.headers = ["Content-Type: %s\r\n" % ct]
62+
self._d = {HTTP_HEADER.CONTENT_TYPE: ct}
63+
64+
def get(self, key, default=None):
65+
return self._d.get(key, default)
66+
67+
68+
# ---- creative jitter catalog: each maps the clean intended body -> a transient junk response --------
69+
def _gw502(intended, rng): return "<html><h1>502 Bad Gateway</h1></html>", 502, "text/html"
70+
def _gw504(intended, rng): return "<html><h1>504 Gateway Time-out</h1></html>", 504, "text/html"
71+
def _rate429(intended, rng): return "{\"error\":\"rate limited\"}", 429, "application/json"
72+
def _waf403(intended, rng): return "<html>Request blocked by security policy #%d</html>" % rng.randint(1, 9), 403, "text/html"
73+
def _cf(intended, rng): return "<html><title>Just a moment...</title>Checking your browser (Cloudflare)</html>", 200, "text/html"
74+
def _captcha(intended, rng): return "<html>Please complete the CAPTCHA to continue</html>", 200, "text/html"
75+
def _maintenance(intended, rng):return "<html>We'll be back shortly - scheduled maintenance</html>", 200, "text/html"
76+
def _empty(intended, rng): return "", 200, "text/html"
77+
def _truncated(intended, rng): return intended[:rng.randint(10, 30)], 200, "text/html"
78+
def _lang(intended, rng): return "<html><body>bienvenue, voici votre tableau de bord</body></html>", 200, "text/html"
79+
def _dynamic(intended, rng): return intended.replace("</body>", "<span id=csrf>%d</span><span>%d views</span></body>" % (rng.getrandbits(32), rng.randint(1, 999))), 200, "text/html"
80+
def _coincidence(intended, rng):return "<html><body>system message from %s: degraded, retry later</body></html>" % _STRING, 200, "text/html"
81+
82+
_CODE_CHANGING = (_gw502, _gw504, _rate429, _waf403)
83+
_SAME_CODE = (_cf, _captcha, _maintenance, _empty, _truncated, _lang)
84+
85+
86+
def _vector():
87+
d = AttribDict()
88+
d.payload = _TEMPLATE; d.where = 1; d.vector = _TEMPLATE; d.comment = ""
89+
d.templatePayload = None; d.matchRatio = None; d.trueCode = 200; d.falseCode = 200
90+
return d
91+
92+
93+
class _BooleanJitterBase(unittest.TestCase):
94+
_CONF = ("threads", "api", "verbose", "direct", "string", "notString", "regexp", "code", "lengths",
95+
"titles", "textOnly", "predictOutput", "hexConvert", "charset", "firstChar", "lastChar",
96+
"ignoreCode", "ignoreTimeouts")
97+
_KB = ("negativeLogic", "nullConnection", "errorIsNone", "pageTemplate", "matchRatio", "heavilyDynamic",
98+
"pageStructurallyStable", "skipSeqMatcher", "pageEncoding", "partRun", "safeCharEncode",
99+
"bruteMode", "fileReadMode", "disableShiftTable", "prependFlag", "timeless", "counters",
100+
"originalCode", "originalPage")
101+
102+
def setUp(self):
103+
self._saved_conf = {k: conf.get(k) for k in self._CONF}
104+
self._saved_kb = {k: kb.get(k) for k in self._KB}
105+
self._saved_inj = kb.injection.data
106+
self._saved_qp = Connect.queryPage
107+
self._saved_technique = getCurrentThreadData().technique
108+
109+
def tearDown(self):
110+
for k, v in self._saved_conf.items():
111+
conf[k] = v
112+
for k, v in self._saved_kb.items():
113+
kb[k] = v
114+
kb.injection.data = self._saved_inj
115+
Connect.queryPage = self._saved_qp
116+
inf.Request.queryPage = self._saved_qp
117+
setTechnique(self._saved_technique)
118+
119+
def _configure(self):
120+
set_dbms("MySQL")
121+
conf.threads = 1; conf.api = False; conf.verbose = 0; conf.direct = False
122+
conf.string = _STRING; conf.notString = None; conf.regexp = None; conf.code = None
123+
conf.lengths = None; conf.titles = None; conf.textOnly = None; conf.predictOutput = False
124+
conf.hexConvert = False; conf.charset = None; conf.firstChar = None; conf.lastChar = None
125+
conf.ignoreCode = []; conf.ignoreTimeouts = False
126+
kb.negativeLogic = False; kb.nullConnection = False; kb.errorIsNone = True
127+
kb.pageTemplate = _FALSE_BODY; kb.matchRatio = None; kb.heavilyDynamic = False
128+
kb.pageStructurallyStable = False; kb.skipSeqMatcher = False; kb.pageEncoding = None
129+
kb.partRun = None; kb.safeCharEncode = False; kb.bruteMode = False; kb.fileReadMode = False
130+
kb.disableShiftTable = False; kb.prependFlag = False; kb.timeless = None; kb.counters = {}
131+
kb.originalCode = None; kb.originalPage = None
132+
kb.injection.data = {PAYLOAD.TECHNIQUE.BOOLEAN: _vector()}
133+
setTechnique(PAYLOAD.TECHNIQUE.BOOLEAN)
134+
kb.data.processChar = None
135+
getCurrentThreadData().validationRun = 0
136+
137+
def _extract(self, respond):
138+
"""`respond(payload, cond, rng)` returns (body, code, contentType); drives real bisection ->
139+
real comparison(). Bit truth `cond` is derived from the parseable delimiter-wrapped payload."""
140+
def oracle(payload=None, timeBasedCompare=False, **kwargs):
141+
td = getCurrentThreadData()
142+
m = _PARSE.search(payload or "")
143+
if not m:
144+
td.lastPage = _FALSE_BODY; td.lastCode = 200
145+
return comparison(_FALSE_BODY, _Headers(), 200)
146+
idx, op, thr = int(m.group(1)), m.group(2), int(m.group(3))
147+
ch = ord(_SECRET[idx - 1]) if 0 <= idx - 1 < len(_SECRET) else 0
148+
cond = (ch > thr) if op == ">" else (ch != thr) if op == "!=" else (ch == thr)
149+
if "NOT(" in (payload or ""):
150+
cond = not cond
151+
body, code, ct = respond(payload or "", cond)
152+
td.lastPage = body; td.lastCode = code
153+
return comparison(body, _Headers(ct), code)
154+
155+
Connect.queryPage = staticmethod(oracle)
156+
inf.Request.queryPage = staticmethod(oracle) # staticmethod on BOTH (py2 unbound-method guard)
157+
td = getCurrentThreadData()
158+
td.shared.value = ""; td.shared.index = [0]; td.shared.start = 0; td.shared.count = 0
159+
_, value = inf.bisection(_TEMPLATE, "SELECT secret", length=len(_SECRET), charsetType=None)
160+
return value
161+
162+
def _rate(self, arrival, trials=40, seed0=3000):
163+
ok = 0
164+
for t in range(trials):
165+
rng = random.Random(seed0 + t)
166+
167+
def respond(payload, cond, rng=rng):
168+
intended = _TRUE_BODY if cond else _FALSE_BODY
169+
jitter = arrival(rng)
170+
return jitter(intended, rng) if jitter is not None else (intended, 200, "text/html")
171+
172+
self._configure()
173+
try:
174+
ok += (self._extract(respond) == _SECRET)
175+
except Exception:
176+
pass
177+
return ok, trials
178+
179+
180+
def _iid(p, kinds):
181+
ks = list(kinds)
182+
return lambda rng: rng.choice(ks) if rng.random() < p else None
183+
184+
185+
def _burst(p_enter, mean_len, kinds):
186+
ks = list(kinds); state = {"bad": 0}
187+
188+
def f(rng):
189+
if state["bad"] > 0:
190+
state["bad"] -= 1; return rng.choice(ks)
191+
if rng.random() < p_enter:
192+
state["bad"] = max(0, int(rng.expovariate(1.0 / mean_len))) - 1
193+
return rng.choice(ks)
194+
return None
195+
return f
196+
197+
198+
class TestBooleanJitterRegression(_BooleanJitterBase):
199+
"""Always-on, deterministic, non-flaky guards for the boolean decision stack."""
200+
201+
def test_clean_extraction_is_perfect(self):
202+
ok, n = self._rate(lambda rng: None)
203+
self.assertEqual(ok, n, "clean boolean extraction must be flawless (%d/%d)" % (ok, n))
204+
205+
def test_benign_dynamic_content_does_not_corrupt(self):
206+
# csrf tokens / view counters / timestamps churn every response body; with the --string oracle
207+
# they must never flip a bit. A regression that starts trusting raw-body noise fails here.
208+
ok, n = self._rate(_iid(1.0, (_dynamic,)))
209+
self.assertEqual(ok, n, "benign dynamic content must not corrupt extraction (%d/%d)" % (ok, n))
210+
211+
def test_unexpected_code_during_validation_is_ridden_out(self):
212+
# Fix A guard: a transient unexpected-code response (503) landing on validateChar's own
213+
# re-check request must not confirm a bit - the char is re-extracted. Here EVERY validation
214+
# ('!=') request returns 503 once, deterministically; extraction must still be exact.
215+
fired = {"n": 0}
216+
217+
def respond(payload, cond):
218+
if "!=" in payload and fired["n"] < 3: # poison the first few validation re-checks
219+
fired["n"] += 1
220+
return "<html><h1>503 Service Unavailable</h1></html>", 503, "text/html"
221+
body = _TRUE_BODY if cond else _FALSE_BODY
222+
return body, 200, "text/html"
223+
224+
self._configure()
225+
self.assertEqual(self._extract(respond), _SECRET)
226+
227+
228+
@unittest.skipUnless(_STRESS, "creative boolean-jitter sweep is opt-in (set SQLMAP_JITTER_STRESS=1)")
229+
class TestBooleanJitterSweep(_BooleanJitterBase):
230+
"""Opt-in creative failure-surface map (IID + bursty). Informational; asserts only the clean case."""
231+
232+
def _sweep(self, label, factory, kinds, rates=(0.0, 0.02, 0.05, 0.10, 0.20)):
233+
print("\n[bool-jitter] %s:" % label)
234+
for r in rates:
235+
ok, n = self._rate(factory(r, kinds))
236+
print(" rate=%.2f -> %d/%d (%3.0f%%)" % (r, ok, n, 100.0 * ok / n))
237+
if r == 0.0:
238+
self.assertEqual(ok, n)
239+
240+
def test_iid_code_changing(self):
241+
self._sweep("IID code-changing (502/504/429/403)", _iid, _CODE_CHANGING)
242+
243+
def test_iid_same_code_body(self):
244+
self._sweep("IID same-code 200 body (cf/captcha/maint/empty/trunc/lang)", _iid, _SAME_CODE)
245+
246+
def test_iid_string_coincidence(self):
247+
self._sweep("IID string-coincidence (fake page contains --string)", _iid, (_coincidence,))
248+
249+
def test_burst_same_code_body(self):
250+
self._sweep("BURST(mean=4) same-code 200 body", lambda r, k: _burst(r, 4, k), _SAME_CODE)
251+
252+
253+
if __name__ == "__main__":
254+
unittest.main(verbosity=2)
255+
256+
257+
def tearDownModule():
258+
reset_dbms()

0 commit comments

Comments
 (0)