漏洞介绍:
2020年1月10日,ThinkPHP团队发布一个补丁更新,修复了一处由不安全的SessionId导致的任意文件操作漏洞。该漏洞允许攻击者在目标环境启用session的条件下创建任意文件以及删除任意文件,在特定情况下还可以getshell。
该漏洞已经有分析文章:https://mp.weixin.qq.com/s/UPu6cE20l24T6fkYOlSUJw 但分析的不够详细,本文是对该漏洞点进行一个更详细的分析。
搭环境:
1.安装composer,ubuntu下 apt install composer
2.使用composer安装thinkphp
3.解决composer下载速度慢问题
composer config -g repo.packagist composer https://packagist.phpcomposer.com
4.使用composer安装指定版本的thinkphp
composer create-project topthink/think=6.0.1 tp6 --prefer-dist
composer require topthink/framework:6.0.1
5.进入tp目录运行 php think run
注意点:
1.上述下载的代码是修复后的,可以手工修改会原先漏洞点。
2.不能直接使用git clone 的源码。
官方commit:https://github.com/top-think/framework/commit/1bbe75019ce6c8e0101a6ef73706217e406439f2
漏洞代码点:
./vendor/topthink/framework/src/think/session/Store.php 此文件中定义了Store类,class Store{}。
public function setId($id = null): void
{
//$this->id = is_string($id) && strlen($id) === 32 ? $id : md5(microtime(true) . session_create_id()); //修复前
$this->id = is_string($id) && strlen($id) === 32 && ctype_alnum($id) ? $id : md5(microtime(true) . session_create_id());
}
区别:
修复前 检查$id的内容是32位的字符串即可。
修复后 增加了$id必须是数字字母组合的检查。
解疑:
这里用到了三目运算符 ?:
microtime() 函数返回当前 Unix 时间戳的微秒数。
ctype_alnum() 检查提供的string,text 是否全部为字母和(或)数字字符。
查看前后的代码,发现存在save函数有文件操作,进行分析,调用了write和delete方法。
由于是write是属于handler的方法,在此Store类中找一下hander在哪,发在咋构造函数中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();
}
解疑:
SessionHandlerInterface是一个 接口,用于创建自定义会话。
参考:https://www.php.net/manual/zh/class.sessionhandlerinterface.php
https://blog.csdn.net/zyddj123/article/details/78906530
除此以外:
拉到Store.php最上面可以看到看到
use think\contract\SessionHandlerInterface;
其内容如下,声明了接口,我们可以看一下,当然和追踪漏洞关系不大。
/**
* Session驱动接口
*/
interface SessionHandlerInterface
{
public function read(string $sessionId): string;
public function delete(string $sessionId): bool;
public function write(string $sessionId, string $data): bool;
}
接下来我们继续追踪write函数,因为接口SessionHandlerInterface是需要被类继承使用的,因此可以全局搜索implements SessionHandlerInterface,定位到相关的文件及代码。(当然了,最简便的方法是直接在Stroe.php所在的session目录下发现File.php,里面有很多注释,表明是对session的相关操作,可以顺利找到相关代码)
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);
}
可以看到write函数调用了writeFile函数,继续看writeFile函数,注意到的$filename变成了$path
protected function writeFile($path, $content): bool
{
//写文件加锁, LOCK_EX 标记可以防止多人同时写入
return (bool) file_put_contents($path, $content, LOCK_EX);
}
因此反向看调用关系,去找看看$path是否可控,最开始就是由原先的Store类中的setid方法产生,未对其做严格过滤。但在这一过程中发现与原文章内容不符,原文未考虑getFileName()对传入参数的影响。
我将整个调用的过程,简化成了下面这种形式:
调用关系:
class Store -->setid()-->getId()-->save() --> class File --> write() -->getFileName()-->writeFile()
简明代码:
class Store{
protected $id;
public function setId($id = null): void
{
$this->id = is_string($id) && strlen($id) === 32 && ctype_alnum($id) ? $id : md5(microtime(true) . session_create_id());
}
public function getId(): string
{
return $this->id;
}
public function save(): void
{
$sessionId = $this->getId();
$this->handler->write($sessionId, $data);
}
}
class File implements SessionHandlerInterface {
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 ($e) {}
}
return $filename;
}
public function write(string $sessID, string $sessData): bool
{
$filename = $this->getFileName($sessID, true);
return $this->writeFile($filename, $data);
}
protected function writeFile($path, $content): bool
{
return (bool) file_put_contents($path, $content, LOCK_EX);
}
}
显然 ,这里存在getFileName会处理sessid,会在文件名前面添上“sess_”前缀,且该文件默认情况会被保存在./runtime/session目录下,该目录一般是访问不到的。可以构造/../../xxx这样的参数绕过限制。从而导致写入的文件名可控,实际的参数名称为PHPSESSID.
PHPSESSID=/runtime/session/sess_/../../xxx
扩展知识:
PHP默认的session配置就能做到控制部分session名称,发送PHPSESSID=xxx内容,就会在session缓存目录创建sess_xxx 。
测试代码:
请求:
在服务器上查看session文件,成功创建:
那能否通过插入/../../xxx来穿越目录并构造任意文件呢? 答案是否,会提示存在非法字符。
那么目前为止,可以穿越目录创建任意文件了,接下来再看$DATA从哪来。发现其内容依赖于后端代码的具体实现。
public function setData(array $data): void
{
$this->data = $data;
}
至此分析结束。
欢迎批评和交流。
------后来又看到了别人的分析文章,可以互相借鉴----
https://blog.csdn.net/zhangchensong168/article/details/104106869
https://blog.csdn.net/god_zzZ/article/details/104275241