@@ -65,6 +65,7 @@ def _jwt_parse(token):
6565JWT_TOKEN = _jwt_forge ({"alg" : "HS256" , "typ" : "JWT" , "kid" : "key1" }, {"user" : "guest" , "role" : "user" , "exp" : 9999999999 }, JWT_SECRET )
6666
6767if 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):
7778else :
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
354605XSLT_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
0 commit comments