Skip to content

Commit 39d22d2

Browse files
committed
Making boolean inference some more robust against jitter
1 parent 3067385 commit 39d22d2

5 files changed

Lines changed: 94 additions & 5 deletions

File tree

lib/controller/checks.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -818,6 +818,12 @@ def genCmpPayload():
818818
injection.data[stype].trueCode = trueCode
819819
injection.data[stype].falseCode = falseCode
820820

821+
# reference bodies for inference.py's "resembles neither TRUE nor FALSE model"
822+
# anomaly guard (runtime-only; lets a transient same-HTTP-code junk response
823+
# trigger a validateChar re-check during boolean extraction)
824+
if method == PAYLOAD.METHOD.COMPARISON:
825+
kb.trueTemplate, kb.falseTemplate = truePage, falsePage
826+
821827
injection.conf.textOnly = conf.textOnly
822828
injection.conf.titles = conf.titles
823829
injection.conf.code = conf.code

lib/core/option.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2305,6 +2305,10 @@ def _setKnowledgeBaseAttributes(flushAll=True):
23052305
kb.pageTemplate = None
23062306
kb.pageTemplates = dict()
23072307
kb.pageEncoding = DEFAULT_PAGE_ENCODING
2308+
2309+
# calibrated TRUE/FALSE reference bodies for the boolean same-HTTP-code anomaly guard (inference.py)
2310+
kb.trueTemplate = None
2311+
kb.falseTemplate = None
23082312
kb.pageStable = None
23092313
kb.pageStructurallyStable = None
23102314
kb.partRun = None

lib/core/settings.py

Lines changed: 6 additions & 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.241"
23+
VERSION = "1.10.7.242"
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)
@@ -103,6 +103,11 @@
103103
LOWER_RATIO_BOUND = 0.02
104104
UPPER_RATIO_BOUND = 0.98
105105

106+
# Minimum similarity at which a boolean extraction response is judged to "resemble" the calibrated
107+
# TRUE or FALSE model. A response resembling NEITHER (a transient same-HTTP-code junk page: WAF/CDN
108+
# interstitial, captcha, maintenance, empty/truncated body) triggers an extra validateChar re-check.
109+
BOOLEAN_MODEL_MATCH_RATIO = 0.9
110+
106111
# Number of candidate names probed per request while mining for hidden parameters ('--mine-params')
107112
PARAMETER_MINING_BUCKET_SIZE = 25
108113

lib/techniques/blind/inference.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from __future__ import division
99

10+
import difflib
1011
import heapq
1112
import re
1213
import time
@@ -26,6 +27,7 @@
2627
from lib.core.common import getTechniqueData
2728
from lib.core.common import getText
2829
from lib.core.common import predictValue
30+
from lib.core.common import removeDynamicContent
2931
from lib.core.common import hashDBRetrieve
3032
from lib.core.common import hashDBWrite
3133
from lib.core.common import incrementCounter
@@ -45,6 +47,7 @@
4547
from lib.core.exception import SqlmapThreadException
4648
from lib.core.exception import SqlmapUnsupportedFeatureException
4749
from lib.core.wordlist import Wordlist
50+
from lib.core.settings import BOOLEAN_MODEL_MATCH_RATIO
4851
from lib.core.settings import CHAR_INFERENCE_MARK
4952
from lib.core.settings import HUFFMAN_PROBE_LIMIT
5053
from lib.core.settings import HUFFMAN_PRIOR_WEIGHTS
@@ -256,6 +259,31 @@ def oracleReliabilityLitmus(expressionUnescaped, value, timeBasedCompare):
256259

257260
return bool(mustBeTrue) and not bool(mustBeFalse)
258261

262+
def _resemblesNeitherModel(page):
263+
"""
264+
Returns True when a boolean extraction response resembles NEITHER the calibrated TRUE nor FALSE
265+
model (kb.trueTemplate / kb.falseTemplate). A transient response that keeps the expected HTTP code
266+
but swaps the body for junk (WAF/CDN interstitial, captcha, maintenance banner, empty/truncated
267+
page) is invisible to the HTTP-code check, so it is flagged here for an extra validateChar re-check.
268+
269+
This only ever ADDS a re-validation - it never changes a decided bit - so it is a safe no-op on a
270+
clean target (every response resembles its own model) and when no models were recorded (a resumed
271+
session, or a non-boolean technique).
272+
"""
273+
274+
refs = [_ for _ in (kb.trueTemplate, kb.falseTemplate) if _]
275+
if not refs:
276+
return False
277+
if page is None:
278+
return True
279+
280+
cleaned = removeDynamicContent(page)
281+
for ref in refs:
282+
if difflib.SequenceMatcher(None, removeDynamicContent(ref), cleaned).quick_ratio() >= BOOLEAN_MODEL_MATCH_RATIO:
283+
return False
284+
285+
return True
286+
259287
def bisection(payload, expression, length=None, charsetType=None, firstChar=None, lastChar=None, dump=False):
260288
"""
261289
Bisection algorithm that can be used to perform blind SQL injection
@@ -714,6 +742,7 @@ def getChar(idx, charTbl=None, continuousOrder=True, expand=charsetType is None,
714742
firstCheck = False
715743
lastCheck = False
716744
unexpectedCode = False
745+
unexpectedResponse = False
717746

718747
if continuousOrder:
719748
while len(charTbl) > 1:
@@ -787,6 +816,13 @@ def getChar(idx, charTbl=None, continuousOrder=True, expand=charsetType is None,
787816

788817
singleTimeWarnMessage(warnMsg)
789818

819+
# same-HTTP-code body anomaly (WAF/CDN interstitial, captcha, maintenance, empty
820+
# or truncated body) - invisible to the code check above, so re-validate when the
821+
# response resembles neither calibrated model
822+
elif not unexpectedResponse and not kb.nullConnection and _resemblesNeitherModel(threadData.lastPage):
823+
unexpectedResponse = True
824+
singleTimeWarnMessage("unexpected response content detected. Will use (extra) validation step in similar cases")
825+
790826
if result:
791827
minValue = posValue
792828

@@ -826,7 +862,7 @@ def getChar(idx, charTbl=None, continuousOrder=True, expand=charsetType is None,
826862
retVal = minValue + 1
827863

828864
if retVal in originalTbl or (retVal == ord('\n') and CHAR_INFERENCE_MARK in payload):
829-
if (timeBasedCompare or unexpectedCode) and kb.get("timeless") is None and not validateChar(idx, retVal):
865+
if (timeBasedCompare or unexpectedCode or unexpectedResponse) and kb.get("timeless") is None and not validateChar(idx, retVal):
830866
if restricted:
831867
# the character fell outside this column's observed range - re-extract
832868
# over the full charset (not timing noise, so no delay increase / retry count)

tests/test_boolean_jitter.py

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,14 @@
5151
_PARSE = re.compile(r"IDX=(\d+) CMP(!=|=|>)(\d+)")
5252
_SECRET = "Str0ng!"
5353
_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>"
54+
# realistic-size bodies (shared nav/footer boilerplate) so the "resembles neither model" anomaly guard
55+
# behaves as on a real page: benign dynamic noise is proportionally tiny (stays a match), while a junk
56+
# interstitial/maintenance/empty body clearly matches neither
57+
_BOILER = "<html><head><title>Acme Portal</title></head><body><nav>home about contact help terms privacy account</nav><div class=main>" * 8
58+
_FOOT = "</div><footer>(c) Acme Corp - all rights reserved - support@acme.example - v4.2</footer></body></html>" * 8
59+
_TRUE_BODY = _BOILER + "welcome %s, dashboard: orders profile settings billing (12 items)" % _STRING + _FOOT
60+
_FALSE_BODY = _BOILER + "invalid credentials, no such record found, please retry" + _FOOT
61+
_INTERSTITIAL = "<html><body>Just a moment... checking your browser before access (DDoS protection)</body></html>"
5662
_STRESS = os.environ.get("SQLMAP_JITTER_STRESS")
5763

5864

@@ -97,7 +103,7 @@ class _BooleanJitterBase(unittest.TestCase):
97103
_KB = ("negativeLogic", "nullConnection", "errorIsNone", "pageTemplate", "matchRatio", "heavilyDynamic",
98104
"pageStructurallyStable", "skipSeqMatcher", "pageEncoding", "partRun", "safeCharEncode",
99105
"bruteMode", "fileReadMode", "disableShiftTable", "prependFlag", "timeless", "counters",
100-
"originalCode", "originalPage")
106+
"originalCode", "originalPage", "trueTemplate", "falseTemplate", "dynamicMarkings")
101107

102108
def setUp(self):
103109
self._saved_conf = {k: conf.get(k) for k in self._CONF}
@@ -129,6 +135,8 @@ def _configure(self):
129135
kb.partRun = None; kb.safeCharEncode = False; kb.bruteMode = False; kb.fileReadMode = False
130136
kb.disableShiftTable = False; kb.prependFlag = False; kb.timeless = None; kb.counters = {}
131137
kb.originalCode = None; kb.originalPage = None
138+
# calibrated reference bodies for the same-code anomaly guard (Fix B); no learned dynamic markings
139+
kb.trueTemplate = _TRUE_BODY; kb.falseTemplate = _FALSE_BODY; kb.dynamicMarkings = []
132140
kb.injection.data = {PAYLOAD.TECHNIQUE.BOOLEAN: _vector()}
133141
setTechnique(PAYLOAD.TECHNIQUE.BOOLEAN)
134142
kb.data.processChar = None
@@ -224,6 +232,36 @@ def respond(payload, cond):
224232
self._configure()
225233
self.assertEqual(self._extract(respond), _SECRET)
226234

235+
def test_anomaly_classifier_flags_only_junk(self):
236+
# Fix B core: a response resembling NEITHER calibrated model is flagged; the models themselves
237+
# and a benign dynamic variant are not. Deterministic, no network.
238+
self._configure()
239+
self.assertFalse(inf._resemblesNeitherModel(_TRUE_BODY))
240+
self.assertFalse(inf._resemblesNeitherModel(_FALSE_BODY))
241+
self.assertFalse(inf._resemblesNeitherModel(_TRUE_BODY.replace("dashboard", "dashboard <b>7 new</b> tok=abc123")))
242+
for junk in (_INTERSTITIAL, "", "<html><h1>502 Bad Gateway</h1></html>", _TRUE_BODY[:60]):
243+
self.assertTrue(inf._resemblesNeitherModel(junk), msg="must flag junk %r" % junk[:40])
244+
245+
def test_same_code_body_jitter_is_ridden_out(self):
246+
# Fix B guard: a transient same-HTTP-code junk page that resembles NEITHER model makes a
247+
# character mis-resolve to a wrong (valid) value; the anomaly guard triggers validateChar to
248+
# re-extract it. The junk here carries the --string token (so it reads True and pushes the char
249+
# HIGH -> a wrong valid char, the case validateChar covers), and is unlike both models -> flagged.
250+
junk = "<html><body>notice: %s service temporarily degraded, retry</body></html>" % _STRING
251+
poisoned = {"n": 0}
252+
253+
def respond(payload, cond):
254+
m = _PARSE.search(payload)
255+
idx = int(m.group(1)) if m else 0
256+
if idx == 4 and "!=" not in payload and poisoned["n"] < 3:
257+
poisoned["n"] += 1
258+
return junk, 200, "text/html"
259+
return (_TRUE_BODY if cond else _FALSE_BODY), 200, "text/html"
260+
261+
self._configure()
262+
self.assertTrue(inf._resemblesNeitherModel(junk)) # precondition: the junk IS anomalous
263+
self.assertEqual(self._extract(respond), _SECRET)
264+
227265

228266
@unittest.skipUnless(_STRESS, "creative boolean-jitter sweep is opt-in (set SQLMAP_JITTER_STRESS=1)")
229267
class TestBooleanJitterSweep(_BooleanJitterBase):

0 commit comments

Comments
 (0)