In this post I’ll explain how I discoverd several vulnerabilities in Western Digital NAS devices and used them together to execute code remotely, as root.

To take control of the NAS an attacker needs to be in the same network and know its IP address.

The PoC is available here.

a smelly smell that smells… smelly

It all began after I decided to splurge and purchase the rather techy-oriented NAS option available by WD - the EX2 Ultra. Unlike other models this one gives the consumer the option to switch out the hard-drives, lets you use it without installing dedicated applications, and even works without internet connectivity.

While setting up the device I decided to open up the browser inspector and discovered an authentication bypass(by setting "isAdmin" cookie to 1 ). But alas, after updating my NAS I realized it had been fixed in a firmware update .

I wanted to dig a little further because this bug had quite a stentch to it. After downloading the source code from WD’s website and foraging for the logic in charge of checking users’ credentials I located the coveted piece of code- It no longer relies on the user-supplied cookie:

/* fixed code in firmware/module/crfs/web/pages/lib/login_checker.php */ function login_check () { $ret = 0 ; if ( ! csrf_token_check ()) /* this check can be bypassed easily as well */ return $ret ; if ( isset ( $_SESSION [ 'username' ])) { if ( isset ( $_SESSION [ 'username' ]) && $_SESSION [ 'username' ] != "" ) $ret = 2 ; //login, normal user if ( $_SESSION [ 'isAdmin' ] == 1 ) $ret = 1 ; //login, admin } return $ret ; }

Unauthenticated file upload (CVE-2019-9951)

By looking around all that “lovely” PHP code I stumbled upon one web-accessible file that failed to use the above login_check function correctly, allowing unauthenticated file uploads to the device:

/* found in: firmware/module/crfs/web/pages/jquery/uploader/uploadify.php */ include ( "../../lib/login_checker.php" ); if ( login_check () != 1 ) /* i.e not-authenticated / admin */ { /* user-controlled */ if ( $_SERVER [ 'HTTP_USER_AGENT' ] == 'Shockwave Flash' ) { $headers = getallheaders (); /* also user-controlled */ if ( $_GET [ 'WD-CSRF-TOKEN' ] !== $_POST [ 'X-CSRF-Token' ] || strrpos ( $headers [ 'Content-Type' ], 'multipart/form-data;' )) { echo json_encode ( $r ); exit ; } /* >> by reaching this line, you bypassed the authentication for this page*/ } else { echo json_encode ( $r ); exit ; } } /* upload logic here */

However, this is not enough for exploitation since the aptly named uploadify.php will perform a (somewhat buggy) check on the target path and will only allow writes to specific locations:

/* found in: firmware/module/crfs/web/pages/lib/login_checker.php */ function check_path ( $path ) { $file_path = realpath ( $path ); if ( ! $file_path ) return false ; if ( strncmp ( $file_path , "/mnt/HD" , 7 ) != 0 && strncmp ( $file_path , "/mnt/USB" , 8 ) != 0 && strncmp ( $file_path , "/mnt/isoMount" , 13 ) != 0 ) return false ; return true ; }

Authentication bypass (CVE-2019-9950)

The management of credentials is handled by firmware/module/crfs/cgi/login_mgr.cgi in the cgiMain exported function.

As it turns out the control-panel utilizes the built-in OS credentials management. This lets the control panel check the supplied username and password against the ones stored in /etc/shadow :

Taking a look at said file shows that the nobody account’s password is encrypted using the old CRYPT algorithm:

nobody:pACwI1fCXYNw6:0:0:99999:7:::

One could use John the Ripper or hashcat to try and crack that hash, but nothing beats a good ol’ fashioned hunch: Ironically, thanks to the fact that the user is configured on the NAS with an empty password by default, anybody could easily receive a session token:

POST http://WD/cgi-bin/login_mgr.cgi { "cmd" : "wd_login" , "username" : "nobody" , "pwd" : "" , "port" : "" , }

Root-RCE using low-privilege token (CVE-2019-9949)

Armed with a standard privileges token, one can now access a broader set of features attack vectors. One such feature is a file-manager (implemented mostly by webfile_mgr.cgi ), and whilst limited in functionality, it does support opening zip/tar archives.

Clicking the button showed in the picture above triggers an HTTP request to webfile_mgr.cgi , with the sub-commnad cgi_unzip :

Request URL : "http://wd/cgi-bin/webfile_mgr.cgi" Request method : "POST" cmd : "cgi_unzip" path : "/mnt/HD/HD_a2/Public/test" name : "myfile.zip"

After passing through the necessary routing and permission checks in firmware/module/crfs/web/pages/cgi_api.php the cgi module forces some validation on the input by escaping the paths and executing unzip , passing it the -t command-line option:

/* ... in webfile_mgr.cgi */ fix_path_special_char_inline ( archive_name ); // user controlled // target_dir is checked to exist in the PHP code sprintf ( cmdline , "cd %s;/usr/bin/unzip -t %s" , target_dir , archive_name ); ret = popen ( cmdline , "r" ); /* ... */

The fix_path_special_char_inline function loosely translates to:

def fix_path_special_char_inline (input_str): out_str = '' for c in input_str: if c in '`$#%^&()+{};[]\=' : # much secure out_str += ' \\ ' out_str += c return out_str

One character, egregiously missing from the above code, is the pipe( | ) character. For those less verse in the bash/batch world, When double pipe ( || ) is used inbetween two commands the shell will execute the latter command if the former fails.

In this scenario, by using an archive “name” that would result in unzip returning an error code (e.g. a non-existent path), an attacker could inject a shell command that would run as root. For example:

cd %s ; /usr/bin/unzip -t 1 || %MY_EVIL_COMMAND%

zip me baby one more time

As it turns out the file manager is susceptible to other attacks. Specifically, extracting an archive with symbolic links would create them with no further validation, allowing future filesystem operations to abuse them with impunity.

For example: if two consecutive cgi_untar commands are performed on two specially crafted archives, an attacker can cause the file-manager to write anywhere(as root). This vulnerability also requires a normal user-session.

This happens because the code that extracts archives will simply untar the archive using cgi_untar command, even if it contains symlinks. Then, when extracting a second archive, an attacker can use the previously created symlink to write into any arbitrary path.

Since the NAS uses squashfs not all the paths are actually writeable, however, /bin/ is. For maximum amusement I decided to overwrite tar itself, thus achieving RCE by performing the following:

upload first archive, containing symlink

send cgi_untar command: create symlink to /bin/

command: upload second archive, containing payload

send cgi_untar command: overwrite /bin/tar

command: send cgi_untar command: execute my payload 😈

This second vulnerability was not deemed worthy of its own CVE-ID despite its severity.

Disclosure Timeline