mirror of
https://github.com/sqlmapproject/sqlmap.git
synced 2026-06-30 13:31:08 +00:00
Adding switch --xpath
This commit is contained in:
parent
4c869817d4
commit
8ff5d3811a
12 changed files with 1242 additions and 9 deletions
407
tests/test_xpath.py
Normal file
407
tests/test_xpath.py
Normal file
|
|
@ -0,0 +1,407 @@
|
|||
#!/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)
|
||||
|
||||
|
||||
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
|
||||
builder = 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"
|
||||
builder = 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))
|
||||
Loading…
Add table
Add a link
Reference in a new issue