Skip to content
All posts
webrevised June 19, 20266 min read

Injection beyond SQL: NoSQL, LDAP, XPath, CRLF and SSI

Every query language is injectable, and each one has a different syntax for always-true. Mongo operator injection, LDAP filter injection, XPath blind extraction, header injection through CRLF, and the server-side includes nobody remembers exist.

SQL injection gets the attention, but the underlying mistake - building a query by concatenating untrusted input - is language-agnostic. Anywhere the application constructs a query, a filter, a path expression, or a header from your input, the same class exists with different syntax.

The method transfers exactly. Find the injection point, break the syntax to confirm it, construct an always-true expression to bypass, then build an extraction channel - visible, blind, or timing. Only the vocabulary changes, and this post is the vocabulary.

NoSQL: injecting objects, not strings

MongoDB queries are documents, not text, so the classic quote-breaking does not apply. The bug instead is that a JSON body or a PHP query string can supply an *object* where the developer expected a *string* - and MongoDB's operators are just keys in that object.

{"user": "admin", "pass": "guess"}

{"user": "admin", "pass": {"$ne": null}}      // password is not null: true
{"user": {"$regex": "^a"}, "pass": {"$ne": 1}} // enumerate usernames by prefix
{"user": "admin", "pass": {"$gt": ""}}         // any string sorts above ""
The first is what the developer expected. The rest are what happens when req.body.pass is passed to the driver without a type check.
  • In a JSON body: substitute an object for any string value. $ne, $gt, $regex, $in, $exists are the useful ones.
  • In a form body or query string: PHP and some Node parsers build arrays from bracket syntax, so pass[$ne]=1 produces the same object. This is the version that catches people out, because the request looks like plain form data.
  • Blind extraction with `$regex`: you cannot see the password, but you can see whether login succeeded. {"$regex": "^a"} narrows one character at a time - the same bisection as blind SQL injection.
  • `$where` and `$function`: if the application uses them, the query contains server-side JavaScript and injection there is direct code execution. {"$where": "sleep(5000) || true"} both proves it and gives you a timing channel.
  • Operator injection in aggregation: $lookup reaches other collections, which is how a query scoped to one tenant reads another's.

LDAP: the filter is a parenthesised expression

LDAP authentication builds a filter like (&(uid=USER)(password=PASS)). The syntax uses parentheses, &, |, ! and *, and none of them are escaped by string concatenation.

uid = *            ->  (&(uid=*)(password=PASS))        any user
uid = *)(uid=*     ->  (&(uid=*)(uid=*))(password=PASS))  password clause discarded
uid = admin)(&     ->  (&(uid=admin)(&))(password=PASS))  the (&) is always true

Blind extraction uses * as a wildcard: uid=admin)(description=A* succeeds only if the description starts with A. Walk the alphabet, one character at a time, and you read attributes you were never authorised to see. LDAP shows up in CTFs as an authentication backend and in enterprise-flavoured challenges as a directory search.

XPath: querying the XML document itself

When user records live in an XML file, authentication is often //user[name='USER' and pass='PASS']. Quote-breaking works exactly as in SQL:

' or '1'='1                    classic bypass
' or 1=1 or ''='                 for numeric contexts
'] | //* | //foo['               select every node in the document

That last payload is the reason XPath injection is often worse than SQL injection: XPath has no access control and no notion of tables you are allowed to query. A union-style payload reads the *entire* document, credentials included. Blind extraction uses string-length() and substring():

' or string-length(//user[1]/pass)=8 or ''='
' or substring(//user[1]/pass,1,1)='a' or ''='

If the XML is parsed rather than queried, you may have XXE instead - and it is worth testing for both, since they live in the same code.

CRLF injection: writing your own headers

Any value reflected into a response header - a redirect Location, a Set-Cookie, a custom X- header - is a header injection point if \r\n survives. Two newlines end the header block entirely and everything after is a response body you control.

/redirect?to=/home%0d%0aSet-Cookie:%20session=attacker

/redirect?to=%0d%0a%0d%0a<script>alert(1)</script>
The first sets a cookie in the victim's browser - session fixation. The second terminates the headers and injects a body, which is XSS delivered through a header rather than through HTML.
  • Response splitting is the severe form: inject a complete second response, and if a cache is in front, it may be stored and served to other users. Same outcome as request smuggling, reached differently.
  • Log injection is the same character in a different sink. A \n in a logged field forges log lines, which matters when the challenge asks you to hide from a log-analysis step.
  • SMTP header injection is CRLF in an email field. A \nBcc: in a contact form adds recipients; a \n\n starts the message body.
  • Encoding matters: try %0d%0a, %0a, %E5%98%8A%E5%98%8D (Unicode characters some parsers narrow into CR and LF), and a raw newline where the transport allows it.

Server-side includes and their relatives

SSI is a directive syntax that some web servers still process in .shtml files, and it executes commands:

<!--#exec cmd="id" -->
<!--#include virtual="/etc/passwd" -->
<!--#echo var="DOCUMENT_ROOT" -->

It is rare in production and common in CTFs, precisely because it is forgotten. Test it wherever your input ends up in a page served by Apache or nginx with SSI enabled, and note the related ESI (Edge Side Includes), processed by some CDNs and proxies, which offers <esi:include src="..."/> - an SSRF primitive that fires at the edge rather than at the origin.

The others, briefly

LanguageInjection pointAlways-true / probe
GraphQLA filter or ordering argument passed to an ORMIntrospection first - see hacking APIs.
Elasticsearch / LuceneA search box passed to query_string*:*, or _source field selection to read excluded fields.
Regex (ReDoS)A user-supplied pattern(a+)+$ against a long non-matching string - a denial of service, and a timing oracle.
Format strings in Python"...".format(user) or an f-string on user input{0.__class__.__init__.__globals__} - a sandbox escape, not a memory bug.
Log4j-style lookupsAny logged string${jndi:ldap://...} and ${env:SECRET} - the second works even where JNDI is patched.
Mail / sendmail argumentsA From address concatenated into a command-X/var/www/html/x.php - argument injection.

The method that transfers between languages

  1. Work out what the input becomes. A filter, a document, a path expression, a header, a template. That single question selects the syntax.
  2. Break it. Send the metacharacter for that language and look for an error, a different length, or a changed behaviour. A quote for SQL and XPath, a parenthesis for LDAP, an object for Mongo, a CR for a header.
  3. Neutralise the rest of the expression. Every language has a comment, a wildcard, or a trailing clause that swallows what follows.
  4. Construct always-true. '1'='1', (&), {"$ne": null}, *. Same idea, five spellings.
  5. Build an extraction channel. Visible output if you are lucky; otherwise a boolean condition and bisection, or a timing primitive.
  6. Check the type before assuming the type. Half of NoSQL injection is realising the parameter can be an object, and half of CRLF injection is realising the value reaches a header.

The reason to learn the whole family rather than just SQL is recognition speed. When a login form rejects ' OR 1=1 and you notice the backend is Node with Mongo, the right next request is {"$ne": null} and not another forty SQL payloads. Knowing which language you are injecting into is most of the work.