Skip to content

Commit dd57fd9

Browse files
committed
Adding switches --odata and --sparql
1 parent fb5339d commit dd57fd9

18 files changed

Lines changed: 2505 additions & 62 deletions

File tree

doc/CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
* Added the switch `--ssti`. It tests for server-side template injection. It also covers Struts2 and OGNL.
1111
* Added the switch `--graphql`. It tests for GraphQL injection.
1212
* Added the switch `--hql`. It tests for HQL and JPQL (Hibernate ORM) injection.
13-
* Added the switch `--xslt`. It tests for XSLT injection. The engine names itself in the response. sqlmap then dumps the XML document that the stylesheet transforms. It also reads the files that the engine can reach.
13+
* Added the switch `--sparql`. It tests for SPARQL injection in triple stores (Apache Jena, Virtuoso, Blazegraph, GraphDB). It confirms the finding with a SPARQL-only construct and then blindly dumps the predicates and the triple objects of the default graph.
14+
* Added the switch `--odata`. It tests for OData `$filter` injection (Microsoft OData, Apache Olingo). It confirms the finding with an OData-only function, tells the version apart, and blindly dumps the entities, including the properties that the endpoint does not return.
15+
* Added the switch `--xslt`. It tests for XSLT injection. The engine names itself in the response. sqlmap then dumps the XML document that the stylesheet transforms. It also reads the files that the engine can reach. When the engine exposes an extension bridge (PHP `php:function` or the Xalan `java:` namespace), sqlmap reads any file through it, and with `--os-cmd` or `--os-shell` it runs operating system commands.
1416
* Added the switch `--xxe`. It tests for XML External Entity injection. It uses in-band, error-based, and out-of-band channels.
1517
* Added the switch `--jwt`. It examines JSON Web Tokens for weak keys and for injection in the claims.
1618

extra/vulnserver/vulnserver.py

Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ def _jwt_parse(token):
6565
JWT_TOKEN = _jwt_forge({"alg": "HS256", "typ": "JWT", "kid": "key1"}, {"user": "guest", "role": "user", "exp": 9999999999}, JWT_SECRET)
6666

6767
if PY3:
68+
from http.client import BAD_REQUEST
6869
from http.client import FORBIDDEN
6970
from http.client import INTERNAL_SERVER_ERROR
7071
from http.client import NOT_FOUND
@@ -77,6 +78,7 @@ def _jwt_parse(token):
7778
else:
7879
from BaseHTTPServer import BaseHTTPRequestHandler
7980
from BaseHTTPServer import HTTPServer
81+
from httplib import BAD_REQUEST
8082
from httplib import FORBIDDEN
8183
from httplib import INTERNAL_SERVER_ERROR
8284
from httplib import NOT_FOUND
@@ -349,6 +351,255 @@ def hql_evaluate(value):
349351
clause = "name = '%s'" % value
350352
return any(all(_hql_atom(a) for a in term.split(" AND ")) for term in clause.split(" OR "))
351353

354+
# --- SPARQL endpoint (vulnerable name search over a tiny in-memory triple store) ------------------
355+
356+
class _SparqlError(Exception):
357+
pass
358+
359+
# (subject, predicate, object) triples of the default graph. Objects are what a blind dump recovers.
360+
SPARQL_TRIPLES = (
361+
("http://example.org/p1", "http://xmlns.com/foaf/0.1/name", "luther"),
362+
("http://example.org/p1", "http://xmlns.com/foaf/0.1/mbox", "luther@example.org"),
363+
("http://example.org/secret", "http://example.org/flag", "S3CR3Tvalue"),
364+
)
365+
_SPARQL_PREDICATES = sorted(set(_[1] for _ in SPARQL_TRIPLES))
366+
_SPARQL_OBJECTS = sorted(_[2] for _ in SPARQL_TRIPLES)
367+
368+
369+
def _sparql_bind(inner, offset):
370+
"""The string/integer a sub-pattern binds to ?v, or None when the OFFSET is past the end."""
371+
372+
if "COUNT(*)" in inner:
373+
return len(SPARQL_TRIPLES)
374+
if "COUNT(DISTINCT ?p)" in inner:
375+
return len(_SPARQL_PREDICATES)
376+
if "DISTINCT ?p" in inner:
377+
return _SPARQL_PREDICATES[offset] if offset < len(_SPARQL_PREDICATES) else None
378+
if "SELECT ?o" in inner:
379+
return _SPARQL_OBJECTS[offset] if offset < len(_SPARQL_OBJECTS) else None
380+
return None
381+
382+
383+
def _sparql_cmp(value, cmp):
384+
"""Evaluate one comparison on the bound ?v, mirroring SPARQL semantics (an out-of-range SUBSTR is
385+
the empty string, which is lexicographically below any real character)."""
386+
387+
match = re.match(r"^\?v >= (\d+)$", cmp)
388+
if match:
389+
return isinstance(value, int) and value >= int(match.group(1))
390+
match = re.match(r"^STRLEN\(STR\(\?v\)\) >= (\d+)$", cmp)
391+
if match:
392+
return len("%s" % value) >= int(match.group(1))
393+
# a quote or a backslash arrives ECHAR-escaped, the way a real store receives it inside a literal
394+
match = re.match(r'^SUBSTR\(STR\(\?v\),(\d+),1\) >= "(\\.|.)"$', cmp)
395+
if match:
396+
pos, ch = int(match.group(1)), match.group(2)
397+
ch = {'\\"': '"', "\\\\": "\\"}.get(ch, ch)
398+
text = "%s" % value
399+
return (text[pos - 1] if pos <= len(text) else "") >= ch
400+
return False
401+
402+
403+
def _sparql_predicate(pred):
404+
"""Evaluate one injected FILTER predicate against the store."""
405+
406+
pred = pred.strip()
407+
if pred in ("1=1", "(1=1)"):
408+
return True
409+
if pred in ("1=2", "(1=2)"):
410+
return False
411+
if "FILTER(!isIRI(?zo))" in pred: # the confirm contradiction (two FILTERs)
412+
return False
413+
if pred == "EXISTS { ?zs ?zp ?zo }": # the confirm positive
414+
return bool(SPARQL_TRIPLES)
415+
match = re.match(r"^EXISTS \{ SELECT \?v WHERE \{ (.*) FILTER\((.*)\) \} \}$", pred)
416+
if match:
417+
inner, cmp = match.group(1).strip(), match.group(2).strip()
418+
offset = 0
419+
off = re.search(r"OFFSET (\d+)", inner)
420+
if off:
421+
offset = int(off.group(1))
422+
value = _sparql_bind(inner, offset)
423+
return value is not None and _sparql_cmp(value, cmp)
424+
return False
425+
426+
427+
def sparql_evaluate(value):
428+
"""Evaluate the injected FILTER of SELECT ... FILTER(?name = "<value>"). A well-formed boundary
429+
reduces to its injected predicate; anything that leaves the string literal unbalanced raises a
430+
Jena-style parser error (the fingerprint surface)."""
431+
432+
# recognised OR-style boundaries: <base><quote> || (<PRED>) || <tail>
433+
for quote, tail in (('"', '""!="'), ("'", "''!='")):
434+
marker = '%s || (' % quote
435+
suffix = ') || %s' % tail
436+
if marker in value and value.endswith(suffix):
437+
pred = value.split(marker, 1)[1][:-len(suffix)]
438+
return _sparql_predicate(pred)
439+
# numeric boundary: <base>) || (<PRED>) || (1=1
440+
if ") || (" in value and value.endswith(") || (1=1"):
441+
pred = value.split(") || (", 1)[1][:-len(") || (1=1")]
442+
return _sparql_predicate(pred)
443+
# a bare, unbalanced break-out (the error probe) trips the parser
444+
if value.count('"') % 2 or value.rstrip().endswith(("'", ")", ".")):
445+
raise _SparqlError("Parse error: Lexical error at line 1, column %d. Encountered: <EOF>" % (len(value) + 40))
446+
# the untouched original value simply matches its row
447+
return any(o == value for _s, p, o in SPARQL_TRIPLES if p.endswith("name"))
448+
449+
# --- OData endpoint (vulnerable $filter over a tiny in-memory entity set) --------------------------
450+
451+
class _ODataError(Exception):
452+
pass
453+
454+
# entities of the "Products" set. 'Secret' is readable via $filter yet never $select-ed, so a blind dump
455+
# recovers a property the endpoint does not otherwise expose.
456+
ODATA_ENTITIES = (
457+
{"Id": 1, "Name": "luther", "Secret": "S3CR3Tvalue"},
458+
{"Id": 2, "Name": "fluffy", "Secret": "hunter2"},
459+
{"Id": 3, "Name": "wu", "Secret": "letmein"},
460+
)
461+
_ODATA_FIELDS = ("Id", "Name", "Secret")
462+
463+
464+
def _odata_depths(expr):
465+
"""Paren depth after each character, IGNORING parens that sit inside a string literal (OData escapes
466+
an inner quote by doubling it). Counting them blind made this evaluator reject filters that a real
467+
OData service accepts - `substring(Name,0,1) eq '('` returned 400 here and 200 from ASP.NET Core -
468+
which would let a genuine client-side bug hide behind a target-side one."""
469+
470+
depths = []
471+
depth, inside, index = 0, False, 0
472+
while index < len(expr):
473+
ch = expr[index]
474+
if inside:
475+
if ch == "'":
476+
if expr[index:index + 2] == "''":
477+
depths.append(depth) # a doubled quote stays inside the literal
478+
index += 1
479+
else:
480+
inside = False
481+
elif ch == "'":
482+
inside = True
483+
elif ch == "(":
484+
depth += 1
485+
elif ch == ")":
486+
depth -= 1
487+
depths.append(depth)
488+
index += 1
489+
return depths
490+
491+
492+
def _odata_split(expr, sep):
493+
"""Split on `sep` at paren depth zero (so 'a and (b or c)' is not broken inside the parentheses)."""
494+
parts, buf = [], []
495+
for token in expr.split(sep):
496+
buf.append(token)
497+
chunk = sep.join(buf)
498+
depths = _odata_depths(chunk)
499+
if not depths or depths[-1] == 0:
500+
parts.append(chunk)
501+
buf = []
502+
if buf:
503+
parts.append(sep.join(buf))
504+
return parts
505+
506+
507+
def _odata_wrapped(expr):
508+
"""True when the whole expression is enclosed by one matching paren pair."""
509+
if not (expr.startswith("(") and expr.endswith(")")):
510+
return False
511+
depths = _odata_depths(expr)
512+
return depths[-1] == 0 and all(_ > 0 for _ in depths[:-1])
513+
514+
515+
def _odata_eval(entity, expr):
516+
"""Recursively evaluate an OData boolean expression for one entity ('or' lowest precedence, then
517+
'and', then a leaf atom), so parenthesised sub-expressions nest correctly."""
518+
expr = expr.strip()
519+
while _odata_wrapped(expr):
520+
expr = expr[1:-1].strip()
521+
ors = _odata_split(expr, " or ")
522+
if len(ors) > 1:
523+
return any(_odata_eval(entity, o) for o in ors)
524+
ands = _odata_split(expr, " and ")
525+
if len(ands) > 1:
526+
return all(_odata_eval(entity, a) for a in ands)
527+
return _odata_atom(entity, expr)
528+
529+
530+
def _odata_atom(entity, atom):
531+
"""Evaluate one leaf OData boolean atom against one entity, mirroring the shapes sqlmap emits. Raises
532+
_ODataError on an unknown property (a 400 surface)."""
533+
534+
atom = atom.strip()
535+
while _odata_wrapped(atom):
536+
atom = atom[1:-1].strip()
537+
538+
match = re.match(r"^length\('([^']*)'\) eq (\d+)$", atom)
539+
if match:
540+
return len(match.group(1)) == int(match.group(2))
541+
match = re.match(r"^startswith\('([^']*)','([^']*)'\)$", atom)
542+
if match:
543+
return match.group(1).startswith(match.group(2))
544+
match = re.match(r"^contains\('([^']*)','([^']*)'\)$", atom)
545+
if match:
546+
return match.group(2) in match.group(1)
547+
if atom.startswith("substringof("):
548+
raise _ODataError("substringof is not a v4 function")
549+
match = re.match(r"^'([^']*)' eq '([^']*)'$", atom)
550+
if match:
551+
return match.group(1) == match.group(2)
552+
match = re.match(r"^(\d+) eq (\d+)$", atom)
553+
if match:
554+
return match.group(1) == match.group(2)
555+
match = re.match(r"^(\w+) eq '([^']*)'$", atom) # <strprop> eq '<lit>'
556+
if match:
557+
if match.group(1) not in _ODATA_FIELDS:
558+
raise _ODataError("Could not find a property named '%s' on type 'Default.Product'." % match.group(1))
559+
return "%s" % entity.get(match.group(1)) == match.group(2)
560+
match = re.match(r"^(\w+) ne null$", atom) # existence probe
561+
if match:
562+
if match.group(1) not in _ODATA_FIELDS:
563+
raise _ODataError("Could not find a property named '%s' on type 'Default.Product'." % match.group(1))
564+
return entity.get(match.group(1)) is not None
565+
match = re.match(r"^(\w+) (eq|ge|gt|le|lt) (-?\d+)$", atom) # <intprop> <op> <int>
566+
if match:
567+
prop, op, num = match.group(1), match.group(2), int(match.group(3))
568+
if prop not in _ODATA_FIELDS:
569+
raise _ODataError("Could not find a property named '%s' on type 'Default.Product'." % prop)
570+
val = entity.get(prop)
571+
if not isinstance(val, int):
572+
return False
573+
return {"eq": val == num, "ge": val >= num, "gt": val > num, "le": val <= num, "lt": val < num}[op]
574+
match = re.match(r"^length\((\w+)\) (eq|ge) (\d+)$", atom) # length(<prop>) <op> N
575+
if match:
576+
prop, op, num = match.group(1), match.group(2), int(match.group(3))
577+
if prop not in _ODATA_FIELDS:
578+
raise _ODataError("Could not find a property named '%s' on type 'Default.Product'." % prop)
579+
length = len("%s" % entity.get(prop, ""))
580+
return length == num if op == "eq" else length >= num
581+
# substring(<prop>,pos,1) eq 'c' - an inner quote arrives DOUBLED, the way the OData spec escapes it
582+
match = re.match(r"^substring\((\w+),(\d+),1\) eq '(''|.)'$", atom)
583+
if match:
584+
prop, pos, ch = match.group(1), int(match.group(2)), match.group(3)
585+
ch = "'" if ch == "''" else ch
586+
if prop not in _ODATA_FIELDS:
587+
raise _ODataError("Could not find a property named '%s' on type 'Default.Product'." % prop)
588+
text = "%s" % entity.get(prop, "")
589+
return pos < len(text) and text[pos] == ch # 0-indexed, ordinal (case-sensitive)
590+
raise _ODataError("Syntax error at position 0 in '%s'." % atom)
591+
592+
593+
def odata_evaluate(name):
594+
"""Return the entities matched by $filter=Name eq '<name>'. A balanced break-out reduces to its
595+
injected predicate; an unbalanced string literal raises a Microsoft-OData-style parser error."""
596+
597+
expr = "Name eq '%s'" % name
598+
if expr.count("'") % 2:
599+
raise _ODataError("The query specified in the URI is not valid. There is an unterminated string "
600+
"literal at position 8 in '%s'." % expr)
601+
return [entity for entity in ODATA_ENTITIES if _odata_eval(entity, expr)]
602+
352603
# --- XPath endpoint (vulnerable search and login, backed by an in-memory XML document) ------------
353604

354605
XSLT_DOC = """<?xml version="1.0"?><catalog><item><name>luther</name><price>10</price></item>\
@@ -1194,6 +1445,52 @@ def do_REQUEST(self):
11941445
self.wfile.write(output.encode(UNICODE_ENCODING))
11951446
return
11961447

1448+
if self.url == "/sparql/search":
1449+
# VULNERABLE: the parameter is concatenated into a FILTER string literal of a SPARQL query,
1450+
# SELECT ?name WHERE { ?p foaf:name ?name . FILTER(?name = "<q>") }. A broken-out FILTER
1451+
# becomes an attacker-controlled boolean (boolean-based blind); a syntax break surfaces a
1452+
# Jena-style parser error.
1453+
q = self.params.get("q", "luther")
1454+
try:
1455+
matched = sparql_evaluate(q)
1456+
rows = "".join("<li>%s</li>" % o for _s, p, o in SPARQL_TRIPLES
1457+
if p.endswith("name") and matched)
1458+
self.send_response(OK)
1459+
self.send_header("Content-type", "text/html; charset=%s" % UNICODE_ENCODING)
1460+
self.send_header("Connection", "close")
1461+
self.end_headers()
1462+
self.wfile.write(("<html><body><ul>%s</ul></body></html>" % rows).encode(UNICODE_ENCODING))
1463+
except _SparqlError as ex:
1464+
self.send_response(INTERNAL_SERVER_ERROR)
1465+
self.send_header("Content-type", "text/html; charset=%s" % UNICODE_ENCODING)
1466+
self.send_header("Connection", "close")
1467+
self.end_headers()
1468+
self.wfile.write(("<html><body><pre>%s</pre></body></html>" % str(ex)).encode(UNICODE_ENCODING))
1469+
return
1470+
1471+
if self.url == "/odata/search":
1472+
# VULNERABLE: the parameter is concatenated into an OData $filter string literal,
1473+
# $filter=Name eq '<name>'. A broken-out filter becomes an attacker-controlled boolean
1474+
# (boolean-based blind); an unbalanced literal surfaces a Microsoft-OData parser error (400).
1475+
# The response only shows Id and Name (as if $select=Id,Name), yet 'Secret' stays reachable
1476+
# through the injected filter - the property a blind dump recovers.
1477+
name = self.params.get("name", "luther")
1478+
try:
1479+
matched = odata_evaluate(name)
1480+
rows = "".join("<li>%s: %s</li>" % (e["Id"], e["Name"]) for e in matched)
1481+
self.send_response(OK)
1482+
self.send_header("Content-type", "text/html; charset=%s" % UNICODE_ENCODING)
1483+
self.send_header("Connection", "close")
1484+
self.end_headers()
1485+
self.wfile.write(("<html><body><ul>%s</ul></body></html>" % rows).encode(UNICODE_ENCODING))
1486+
except _ODataError as ex:
1487+
self.send_response(BAD_REQUEST)
1488+
self.send_header("Content-type", "application/json; charset=%s" % UNICODE_ENCODING)
1489+
self.send_header("Connection", "close")
1490+
self.end_headers()
1491+
self.wfile.write(json.dumps({"error": {"message": str(ex)}}).encode(UNICODE_ENCODING))
1492+
return
1493+
11971494
if self.url == "/echo":
11981495
# A pure reflector: no engine of any kind behind it, it only shows the parameter back. Every
11991496
# non-SQL switch must stay silent here. A differential built on "the page changed" is

lib/controller/checks.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@
8686
from lib.core.settings import HQL_ERROR_REGEX
8787
from lib.core.settings import INFERENCE_EQUALS_CHAR
8888
from lib.core.settings import LDAP_ERROR_REGEX
89+
from lib.core.settings import ODATA_ERROR_REGEX
90+
from lib.core.settings import SPARQL_ERROR_REGEX
8991
from lib.core.settings import SSTI_ERROR_REGEX
9092
from lib.core.settings import XPATH_ERROR_REGEX
9193
from lib.core.settings import XSLT_ERROR_REGEX
@@ -1278,6 +1280,20 @@ def _(page):
12781280
if conf.beep:
12791281
beep()
12801282

1283+
if not conf.sparql and re.search(SPARQL_ERROR_REGEX, page or ""):
1284+
infoMsg = "heuristic (SPARQL) test shows that %sparameter '%s' might be vulnerable to SPARQL injection (rerun with switch '--sparql')" % ("%s " % paramType if paramType != parameter else "", parameter)
1285+
logger.info(infoMsg)
1286+
1287+
if conf.beep:
1288+
beep()
1289+
1290+
if not conf.odata and re.search(ODATA_ERROR_REGEX, page or ""):
1291+
infoMsg = "heuristic (OData) test shows that %sparameter '%s' might be vulnerable to OData $filter injection (rerun with switch '--odata')" % ("%s " % paramType if paramType != parameter else "", parameter)
1292+
logger.info(infoMsg)
1293+
1294+
if conf.beep:
1295+
beep()
1296+
12811297
if not conf.xxe and kb.postHint in (POST_HINT.XML, POST_HINT.SOAP) and re.search(XXE_ERROR_REGEX, page or ""):
12821298
infoMsg = "heuristic (XXE) test shows that the XML request body might be vulnerable to XML External Entity injection (rerun with switch '--xxe')"
12831299
logger.info(infoMsg)

0 commit comments

Comments
 (0)