|
| 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