# Critical Database Vulnerability Puts Company Data at Risk
Dgraph is a database tool that many companies use to organize and store their information. Researchers have discovered a serious security flaw that lets attackers read everything in these databases without needing a password.
Think of it like finding a side door to a warehouse that the owner forgot to lock. Anyone who knows where that door is can walk in and see everything inside, even if the front entrance has security guards.
The vulnerability exists in how Dgraph processes certain requests. When someone sends a specially crafted message to the database, it bypasses the normal authentication checks entirely. The attacker doesn't need to steal credentials or trick anyone — they just send the right request and gain instant access to the entire database contents.
This matters because companies using Dgraph might store sensitive information like customer data, financial records, or personal details. If a malicious person discovers this vulnerability and exploits it, they could steal that information without leaving obvious traces.
Companies running older versions of Dgraph are most at risk, particularly those that haven't updated to version 25.3.3 or newer. Small to medium-sized businesses are especially vulnerable because they often have fewer security resources to monitor threats.
Here's what you should do: First, if your company uses Dgraph, check what version you're running and update immediately to 25.3.3 or later. Second, contact your IT department or database administrator and ask them specifically about this vulnerability — don't assume they know. Third, if you're not sure whether your company uses Dgraph, ask your technical team to audit what databases you rely on. Taking action now prevents attackers from stealing your data later.
Want the full technical analysis? Click "Technical" above.
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:
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.
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.