diff --git a/lib/core/settings.py b/lib/core/settings.py index a4920899f..5e7293fb3 100644 --- a/lib/core/settings.py +++ b/lib/core/settings.py @@ -20,7 +20,7 @@ from lib.core.enums import OS from thirdparty import six # sqlmap version (...) -VERSION = "1.10.8.15" +VERSION = "1.10.8.16" 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) diff --git a/lib/techniques/ldap/inject.py b/lib/techniques/ldap/inject.py index 106e72fb3..a4a1cc2a7 100644 --- a/lib/techniques/ldap/inject.py +++ b/lib/techniques/ldap/inject.py @@ -22,6 +22,7 @@ from lib.utils.nonsql import InconclusiveError from lib.utils.nonsql import INCONCLUSIVE_MARK from lib.utils.nonsql import userDecision from lib.utils.nonsql import resolveBit +from lib.utils.nonsql import stripReflection from lib.utils.nonsql import sqlErrorPresent from lib.utils.nonsql import blockedStatus from lib.utils.nonsql import ratio as _ratio @@ -179,7 +180,9 @@ def _send(place, parameter, value): # true/false differential without a single expression being evaluated - that reported plain # reflective search pages as injectable. On a blind target the payload is not in the page, so # this is a no-op. - return removeReflectiveValues(page, value, suppressWarning=True) or "" + # Two layers on purpose: sqlmap's scan-wide heuristic (which can switch itself off) AND a plain + # deterministic strip that cannot. See stripReflection(). + return stripReflection(removeReflectiveValues(page, value, suppressWarning=True), value) or "" except Exception as ex: logger.debug("LDAP probe request failed: %s" % getUnicode(ex)) return None diff --git a/lib/techniques/nosql/inject.py b/lib/techniques/nosql/inject.py index 573203397..c39f0cc30 100644 --- a/lib/techniques/nosql/inject.py +++ b/lib/techniques/nosql/inject.py @@ -23,7 +23,9 @@ from lib.utils.nonsql import ratio as _ratio from lib.utils.nonsql import userOracleActive from lib.utils.nonsql import InconclusiveError from lib.utils.nonsql import INCONCLUSIVE_MARK +from thirdparty import six from lib.utils.nonsql import resolveBit +from lib.utils.nonsql import stripReflection from lib.core.data import conf from lib.core.data import kb from lib.core.data import logger @@ -372,7 +374,15 @@ def _send(place, parameter, segment=None, jsonValue=_UNSET): # differential - and the wildcard always-true check - without a single operator being interpreted. # That reported a plain reflective search page as a "Lucene query_string-compatible back-end". On a # blind target the payload is not in the page, so this is a no-op. - return removeReflectiveValues(page, payload, suppressWarning=True) or "" + # Two layers on purpose: sqlmap's scan-wide heuristic (which can switch itself off) AND a plain + # deterministic strip that cannot. See stripReflection(). + # Strip the VALUE as well as the whole parameter string: `payload` here is "q=*", while the page + # echoes just "*", so removing only the former leaves the reflection in place. + page = removeReflectiveValues(page, payload, suppressWarning=True) + for _reflected in (payload, segment, jsonValue if jsonValue is not _UNSET else None): + if isinstance(_reflected, six.string_types) and _reflected: + page = stripReflection(page, _reflected) + return page or "" def _isError(page): # a server-error status, a recognizable NoSQL error body, OR a recognized SQL/DBMS error marks a diff --git a/lib/techniques/xpath/inject.py b/lib/techniques/xpath/inject.py index a274bcf51..4df434b63 100644 --- a/lib/techniques/xpath/inject.py +++ b/lib/techniques/xpath/inject.py @@ -24,6 +24,7 @@ from lib.utils.nonsql import INCONCLUSIVE_MARK from lib.utils.nonsql import userDecision from lib.utils.nonsql import InconclusiveError from lib.utils.nonsql import resolveBit +from lib.utils.nonsql import stripReflection from lib.utils.nonsql import sqlErrorPresent from lib.utils.nonsql import blockedStatus from lib.utils.nonsql import ratio as _ratio @@ -165,7 +166,9 @@ def _send(place, parameter, value): # true/false differential, and even the XPath-only confirm battery, without a single expression # ever being evaluated. That reported XSLT (and plain reflective) endpoints as XPath-injectable. # On a genuinely blind target the payload is not in the page, so this is a no-op. - page = removeReflectiveValues(page, value, suppressWarning=True) + # Two layers on purpose: sqlmap's scan-wide heuristic (which can switch itself off) AND a plain + # deterministic strip that cannot. See stripReflection(). + page = stripReflection(removeReflectiveValues(page, value, suppressWarning=True), value) # A transport failure or a BLOCKED/ERROR status (5xx, 403/429 WAF/rate-limit) is NOT a usable # oracle sample: returning "" for it would let a one-sided failure fake a true/false divergence # (an empty body cannot be told apart from a dead connection). Signal it as None -> the boolean diff --git a/lib/utils/nonsql.py b/lib/utils/nonsql.py index 23e38d363..5bab6ae75 100644 --- a/lib/utils/nonsql.py +++ b/lib/utils/nonsql.py @@ -14,7 +14,11 @@ in one place rather than drifting across six files. import difflib import re +from lib.core.common import urldecode +from lib.core.common import urlencode +from lib.core.convert import getUnicode from lib.core.data import conf +from lib.core.settings import REFLECTED_VALUE_MARKER from lib.core.settings import UPPER_RATIO_BOUND from lib.parse.html import htmlParser @@ -38,6 +42,42 @@ def ratio(first, second): return difflib.SequenceMatcher(None, first or "", second or "").quick_ratio() +def stripReflection(page, payload): + """ + Remove the payload from the page before any two responses are compared. + + An endpoint that merely ECHOES the parameter returns a different page for every different payload, so + a true/false differential is satisfied without a single expression, filter or operator ever being + interpreted. That is not injection, and it is how plain reflective search pages were reported as + XPath / LDAP / NoSQL injectable. + + Deliberately NOT lib.core.common.removeReflectiveValues: that one is a scan-wide heuristic which + switches ITSELF OFF after REFLECTIVE_MISS_THRESHOLD misses, after a regex timeout, and during + heuristic mode - so a detection guard built on it silently stops guarding mid-scan. This is a plain, + deterministic substring removal with no global state and no failure mode. The two are complementary, + and the engines apply both. + + The raw, URL-decoded and URL-encoded forms are all removed: a payload travels encoded, and an + application may echo whichever of the three it happened to hold. + """ + + if not page or not payload: + return page + + retVal = getUnicode(page) + forms = set() + for form in (payload, urldecode(payload, convall=True), urlencode(payload, safe="")): + try: + forms.add(getUnicode(form)) + except Exception: + pass + # longest first, so a shorter form cannot chop a longer one into unremovable pieces + for form in sorted(filter(None, forms), key=len, reverse=True): + if form in retVal: + retVal = retVal.replace(form, REFLECTED_VALUE_MARKER) + return retVal + + def blockedStatus(code): """True when an HTTP status means the response is blocked/errored (a 5xx, or a WAF/rate-limit 403/429) and so is not a usable oracle sample. `_send()` implementations return None for these diff --git a/tests/test_reflection.py b/tests/test_reflection.py new file mode 100644 index 000000000..28c741d39 --- /dev/null +++ b/tests/test_reflection.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Adversarial coverage for reflection removal. + +Two independent mechanisms exist and the non-SQL engines rely on BOTH: + + lib.core.common.removeReflectiveValues - the scan-wide heuristic. Powerful (it reassembles a payload + that the page broke apart) but it has global state and + SWITCHES ITSELF OFF: after REFLECTIVE_MISS_THRESHOLD misses, + after a regex timeout, and during heuristic mode. + lib.utils.nonsql.stripReflection - a plain deterministic removal with no global state, added + precisely because a detection guard must not stop guarding + halfway through a scan. + +The tests below pin the behaviour of each, and - more importantly - pin the blind spots of the first one, +so nobody builds another guard on it without knowing where it does nothing. + +stdlib unittest only (no pytest / no pip); works on Python 2.7 and 3.x. +""" + +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.core.common import removeReflectiveValues +from lib.core.data import kb +from lib.core.settings import REFLECTED_VALUE_MARKER +from lib.core.settings import REFLECTIVE_MISS_THRESHOLD +from lib.utils.nonsql import stripReflection + +PAYLOAD = u"x') or true() or ('" + + +def _reset(): + kb.reflectiveMechanism = True + kb.heuristicMode = False + kb.reflectiveCounters = {"HIT": 0, "MISS": 0} + + +def _removed(content, payload): + out = removeReflectiveValues(content, payload, suppressWarning=True) + return out != content and REFLECTED_VALUE_MARKER in (out or "") + + +class RemoveReflectiveValuesTest(unittest.TestCase): + def setUp(self): + _reset() + + def test_plain_reflection_is_removed(self): + self.assertTrue(_removed(u"you searched for: %s" % PAYLOAD, PAYLOAD)) + + def test_reflection_survives_case_change(self): + self.assertTrue(_removed(u"YOU SEARCHED FOR: %s" % PAYLOAD.upper(), PAYLOAD)) + + def test_reflection_split_by_markup_is_removed(self): + """The heuristic's real strength: it reassembles a payload the page broke apart.""" + self.assertTrue(_removed(u"search: x') or true() or ('", PAYLOAD)) + + def test_html_encoded_reflection_is_removed(self): + self.assertTrue(_removed(u"you searched for: x') or true() or ('", PAYLOAD)) + + def test_absent_payload_leaves_content_untouched(self): + content = u"nothing to see here" + self.assertEqual(removeReflectiveValues(content, PAYLOAD, suppressWarning=True), content) + + def test_empty_inputs_are_safe(self): + self.assertEqual(removeReflectiveValues(None, PAYLOAD), None) + self.assertEqual(removeReflectiveValues(u"abc", None), u"abc") + self.assertEqual(removeReflectiveValues(u"", u""), u"") + + def test_word_only_payload_cannot_explode_the_page(self): + """A payload of pure word characters yields an empty needle; replacing on it would insert the + marker between every character.""" + out = removeReflectiveValues(u"aaa bbb aaa", u"aaa", suppressWarning=True) + self.assertNotIn("%s%s" % (REFLECTED_VALUE_MARKER, REFLECTED_VALUE_MARKER), out or "") + + +class RemoveReflectiveValuesBlindSpotTest(unittest.TestCase): + """These document where the scan-wide heuristic does NOTHING. They are the reason a second, + deterministic guard exists - not bugs to be 'fixed' here.""" + + def setUp(self): + _reset() + + def test_blind_spot_purely_alphanumeric_payload(self): + """filterStringValue() leaves such a payload unchanged, so the whole routine short-circuits.""" + self.assertFalse(_removed(u"you searched for: abcdefghij", u"abcdefghij")) + + def test_blind_spot_bytes_content(self): + """It requires text. A byte string is returned untouched - identical behaviour on py2 and py3, + and the reason every caller must hand it a decoded page.""" + self.assertFalse(_removed(b"you searched for: x", u"x")) + + def test_blind_spot_disabled_mechanism(self): + """It disables itself on a regex timeout and after REFLECTIVE_MISS_THRESHOLD misses.""" + kb.reflectiveMechanism = False + self.assertFalse(_removed(u"you searched for: %s" % PAYLOAD, PAYLOAD)) + + def test_blind_spot_heuristic_mode(self): + kb.heuristicMode = True + self.assertFalse(_removed("you searched for: %s" % PAYLOAD, PAYLOAD)) + + def test_miss_threshold_is_finite(self): + """A guard built only on this stops guarding after this many non-reflective responses.""" + self.assertTrue(0 < REFLECTIVE_MISS_THRESHOLD < 1000) + + +class StripReflectionTest(unittest.TestCase): + """The deterministic guard: no global state, no timeout, no self-disabling.""" + + def setUp(self): + _reset() + + def test_removes_the_payload(self): + out = stripReflection(u"you searched for: %s" % PAYLOAD, PAYLOAD) + self.assertNotIn(PAYLOAD, out) + self.assertIn(REFLECTED_VALUE_MARKER, out) + + def test_removes_the_url_encoded_form(self): + out = stripReflection(u"you searched for: x%27%29%20or%20true%28%29", u"x') or true()") + self.assertIn(REFLECTED_VALUE_MARKER, out) + + def test_two_different_payloads_collapse_to_the_same_page(self): + """This is the whole point: a pure echo endpoint must stop looking like a boolean oracle.""" + true_page = stripReflection(u"you searched for: x') or true() or ('", u"x') or true() or ('") + false_page = stripReflection(u"you searched for: x') and false() and ('", u"x') and false() and ('") + self.assertEqual(true_page, false_page) + + def test_a_real_differential_is_preserved(self): + """A genuine oracle differs in the APPLICATION's output, not in the echoed payload, so stripping + must not erase it.""" + true_page = stripReflection(u"results: luther, fluffy, wu [%s]" % PAYLOAD, PAYLOAD) + false_page = stripReflection(u"results: none [%s]" % PAYLOAD, PAYLOAD) + self.assertNotEqual(true_page, false_page) + + def test_does_not_depend_on_global_state(self): + kb.reflectiveMechanism = False + kb.heuristicMode = True + kb.reflectiveCounters = {"HIT": 0, "MISS": REFLECTIVE_MISS_THRESHOLD * 10} + self.assertIn(REFLECTED_VALUE_MARKER, stripReflection(u"echo: %s" % PAYLOAD, PAYLOAD)) + + def test_covers_the_alphanumeric_blind_spot(self): + self.assertIn(REFLECTED_VALUE_MARKER, stripReflection(u"you searched for: abcdefghij", u"abcdefghij")) + + def test_empty_and_missing_inputs_are_safe(self): + self.assertEqual(stripReflection(None, PAYLOAD), None) + self.assertEqual(stripReflection(u"abc", None), u"abc") + self.assertEqual(stripReflection(u"abc", u""), u"abc") + self.assertEqual(stripReflection(u"", PAYLOAD), u"") + + def test_absent_payload_leaves_content_identical(self): + content = u"nothing to see here" + self.assertEqual(stripReflection(content, PAYLOAD), content) + + def test_every_occurrence_is_removed(self): + out = stripReflection(u"%s middle %s" % (PAYLOAD, PAYLOAD), PAYLOAD) + self.assertNotIn(PAYLOAD, out) + self.assertEqual(out.count(REFLECTED_VALUE_MARKER), 2) + + +class EnginesUseBothGuardsTest(unittest.TestCase): + """Pins the wiring. A guard that only one engine applies is how this class of false positive spread + across three shipped engines unnoticed.""" + + def _source(self, *parts): + with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", *parts)) as f: + return f.read() + + def test_reflective_engines_apply_both(self): + for engine in ("xpath", "ldap", "nosql"): + source = self._source("lib", "techniques", engine, "inject.py") + self.assertIn("removeReflectiveValues", source, engine) + self.assertIn("stripReflection", source, engine) + + def test_xslt_proves_evaluation_instead(self): + """--xslt needs no reflection filter: its sentinel only exists once concat() has run, so an echo + can never satisfy it. That is the stronger design.""" + source = self._source("lib", "techniques", "xslt", "inject.py") + self.assertIn("def _captured", source) + self.assertIn("reflected verbatim", source) + + +if __name__ == "__main__": + unittest.main()