1. 1. 环境安装
    1. 1.1. 稳定版本下载
  2. 2. 基础
  3. 3. ThinkPHP 2.x
    1. 3.1. preg_replace /e 模式带来的代码执行风险
  4. 4. ThinkPHP 3.x
    1. 4.1. ThinkPHP 3.x 查询构造器中的注入风险
      1. 4.1.1. where 条件数组
      2. 4.1.2. exp 表达式模式
      3. 4.1.3. bind 参数绑定模式
      4. 4.1.4. orderby 排序参数
      5. 4.1.5. update注入
    2. 4.2. 3.2.3 反序列化风险
    3. 4.3. ThinkPHP 2.x/3.0 远程代码执行风险
    4. 4.4. ThinkPHP 缓冲区函数相关问题
    5. 4.5. ThinkPHP 3.2.4 SQL 注入风险与 CVE 记录
      1. 4.5.1. CVE-2018-18546
      2. 4.5.2. CVE-2018-18529
      3. 4.5.3. CVE-2018-10225
  5. 5. ThinkPHP 5.x
    1. 5.1. ThinkPHP 5.0.24 反序列化风险
      1. 5.1.1. 链条形态
      2. 5.1.2. 不同版本差异
      3. 5.1.3. 链条原理
    2. 5.2. ThinkPHP 5.0.10 cacheFile 变量覆盖与文件包含风险
    3. 5.3. ThinkPHP 5.x 控制器调用校验缺失导致的风险(一)
      1. 5.3.1. 5.0.13 远程代码执行
      2. 5.3.2. 5.1.x 远程代码执行
    4. 5.4. ThinkPHP 5.x 控制器调用校验缺失导致的风险(二)
      1. 5.4.1. 5.0.x
      2. 5.4.2. 5.1.x
  6. 6. ThinkPHP 5 查询构造器注入风险
    1. 6.0.1. paraData 方法
    2. 6.0.2. paraArrayData 方法
    3. 6.0.3. parseWhereItem 方法
    4. 6.0.4. parseOrder 方法
  • 7. ThinkPHP 6.x
    1. 7.1. ThinkPHP 6.x 任意文件包含风险(6.0.1~6.0.13,5.0.x,5.1.x)
    2. 7.2. ThinkPHP 6.0.0/6.0.1 任意文件写入风险
    3. 7.3. ThinkPHP 6.0.0/6.0.15 反序列化风险
  • 8. 反序列化风险归纳
    1. 8.1. ThinkPHP 5.0.x 核心链条
    2. 8.2. ThinkPHP 5.0.24 反序列化
    3. 8.3. ThinkPHP 5.1.x / 6.0.x 链条
  • 9. 排查、修复与加固清单
    1. 9.1. 一、确认系统是否落在受影响区间
    2. 9.2. 二、修复与配置收敛
    3. 9.3. 三、检测方法与流量特征
    4. 9.4. 四、分层防护与运行环境
  • 10. References
  • ThinkPHP Framework: A Review of Historical Risks and Remediation

    免责声明:本文为个人技术学习与工程实践笔记,所涉操作仅应在获得授权的环境中进行。因不当使用造成的后果由使用者自行承担。

    在开始前,学习ThinkPHP相关漏洞需要掌握10.PHP反序列化漏洞和9.PHP代码审计,否则审计起来会非常吃力。

    本文按版本分支整理 ThinkPHP 历史上公开披露过的风险点,重点落在三件事上:每类风险的成因是什么、如何判断在用系统是否落在受影响区间、以及在生产环境里应当怎么排查和加固。文中引用的代码片段来自框架自身,用于说明问题机制;原稿中出现过的可直接使用的利用数据(请求地址、注入语句、完整利用链代码与攻击载荷)已全部移除,原因很直接——它们可以被照抄执行,对防守没有增量价值。

    环境安装

    稳定版本下载

    本地比对版本或搭建分析环境时,可以用 Composer 拉起一份官方骨架:

    1
    composer create-project topthink/think tp

    需要注意,实际业务系统的框架版本应当以 composer.lock 与 vendor/topthink/framework 为准,而不是以骨架默认拉取的版本为准。后文的排查清单会反复用到这一点。

    基础

    URL和路由:https://blog.csdn.net/lthirdonel/article/details/88775620 thinkphp内置了几种方法,在 ThinkPHP/Common/functions.php,比如I(),M()等等

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    A 快速实例化Action类库 
    B 执行行为类
    C 配置参数存取方法
    D 快速实例化Model类库
    F 快速简单文本数据存取方法
    I http获取参数
    L 语言参数存取方法
    M 快速高性能实例化模型
    R 快速远程调用Action类方法
    S 快速缓存存取方法
    U URL动态生成和重定向方法
    W 快速Widget输出方法

    其中 I()、L()、S() 这几个直接与请求参数、语言标记、缓存交互的入口,是后文多处风险的共同起点:它们本身没有问题,问题在于调用方是否把这些数据当作可信输入继续向下传递。

    ThinkPHP 2.x

    preg_replace /e 模式带来的代码执行风险

    代码需要定位到 ThinkPHP\Lib\Think\Util\Dispatcher.class.php Pasted image 20260304191734.png 这里会发现

    1
    $res = preg_replace('@(\w+)\/([^,\/]+)@e', '$var[\'\\1\']="\\2";', str_replace($matches[0],'',$regx));

    在 ThinkPHP/Lib/Think/Util/Dispatcher.class.php 中,Dispatcher 类的 dispatch 方法负责接收用户发送的请求,并根据路由规则将请求分发到相应的控制器(Controller)和方法(Action):它会解析 URL、匹配定义好的路由规则,然后确定要执行的控制器和方法。下面按框架解析路由的顺序看这段代码。

    请求的常规形态是「模块/控制器/操作」:

    1
    2
    http://xx.xx.xx.xx/index.php/模块/控制器/操作
    http://127.0.0.1/index.php/a/b/c/d

    首先,通过 C(‘URL_MODEL’) 获取 URL 的模式,然后根据不同的模式进行不同的处理,这里是默认模式 Pasted image 20260304192408.png 接下来,如果配置文件中开启了子域名部署(APP_SUB_DOMAIN_DEPLOY 为真),则会根据规则对子域名进行路由处理;此处为 false,直接跳过 Pasted image 20260304192422.png 然后根据配置文件中的设置获取 URL 的分隔符(URL_PATHINFO_DEPR),并调用 getPathInfo() 函数来分析 URL 的 PATHINFO 信息 Pasted image 20260304192453.png 接下来是路由检测和解析的部分:先调用 routerCheck() 函数检测是否有自定义的路由规则,如果没有,则按默认规则进行调度,先根据 URL 分隔符将 $_SERVER['PATH_INFO'] 切割成路径数组 $paths Pasted image 20260304192533.png 然后进入 preg_replace 这一步 Pasted image 20260304192543.png

    问题的根源在于正则表达式使用了 /e 修饰符。该修饰符要求 preg_replace() 把「替换字符串」当作 PHP 代码求值,并用求值结果替换搜索到的内容。上面这条表达式可以简化理解为匹配分隔符两侧的两个片段,替换串再以 $var['\1']="\2"; 的形式把第一个片段用作数组键、第二个片段用作值——也就是说,第二个片段会进入一段被当作代码求值的字符串。正常的路由段只应当由字母、数字和分隔符组成,一旦其中出现变量变量语法(${...})这类结构,求值过程就会顺带执行其中的函数调用,这正是该问题能够从「路由解析缺陷」升级为远程代码执行的原因。

    1
    $res = preg_replace('@(\w+)'.$depr.'([^'.$depr.'\/]+)@e', '$var[\'\\1\']="\\2";', implode($depr,$paths));

    在后续版本中,官方修改了替换串,对匹配到的内容调用 strip_tags(),使被拼接进去的内容不再作为代码求值:

    1
    $res = preg_replace('@(\w+)'.$depr.'([^'.$depr.'\/]+)@e', '$var[\'\\1\']=strip_tags(\'\\2\');', implode($depr,$paths));

    排查与加固

    • 版本判定:2.x 分支已停止维护。仍在运行 2.x 的系统应先确认框架大版本,再评估迁移成本;因历史原因无法迁移的,至少要保证请求无法抵达这段路由解析逻辑(收敛入口、关闭兼容模式路由)。
    • 检测特征:正常的路由段只会包含字母、数字、下划线与分隔符,不会出现 ${、(、)、; 这类字符。在网关或 Web 访问日志中对 PATHINFO 做字符白名单校验,命中即告警。
    • 加固方向:/e 修饰符在 PHP 5.5 起被废弃、PHP 7 起移除,因此运行环境的 PHP 版本本身也是判断依据。升级 PHP 会让这类写法直接报错,效果比逐个修补路由代码更彻底。

    ThinkPHP 3.x

    ThinkPHP 3.x 查询构造器中的注入风险

    注意:ThinkPHP < 3.2.4,与 l() 方法有关

    这一分支的风险集中在查询构造器对数组形式参数的处理上:I() 会把请求中的数组原样收集为 PHP 数组,如果业务代码把这个数组直接交给 where()、order()、save() 等方法,数组的键和值都会参与 SQL 片段拼装。原稿记录了几个具体入口,下面按成因分别说明。

    where 条件数组

    where() 支持数组形式的条件。当数组的某个值被识别为表达式而不是普通值时,该值会被写入 SQL 的表达式位置,绕开参数绑定的保护。

    exp 表达式模式

    数组的第一项如果被解析为 exp,第二项就会被当作原始表达式片段拼进查询。这是「表达式模式」在缺少输入校验时代价最高的地方——它的设计目的本身就是跳过转义。

    bind 参数绑定模式

    第一项为 bind 时,本该走绑定通道的值同样会被当作表达式处理。

    orderby 排序参数

    从 3.2.4 的版本更新看,parseOrder 存在大量改动,问题大概率出现在这里 Pasted image 20260304200504.png。对应的调用链是 find() -> select() -> buildSelectSql() -> parseSql() -> parseOrder,在源码中定位到该方法 Pasted image 20260304200545.png

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    protected function parseOrder($order)
    {
    if (is_array($order)) {
    $array = array();
    foreach ($order as $key => $val) {
    if (is_numeric($key)) {
    $array[] = $this->parseKey($val);
    } else {
    $array[] = $this->parseKey($key) . ' ' . $val;
    }
    }
    $order = implode(',', $array);
    }
    return !empty($order) ? ' ORDER BY ' . $order : '';
    }

    数组分支里,键名经过 parseKey() 处理,而值部分只是被直接追加在键名之后,最终整段拼进 ORDER BY。值部分没有过滤,排序参数因此成为注入点。

    update注入

    实际上就是bind注入,见上一节。

    排查与加固

    • 代码检查:搜索业务代码中把 I() 的返回值直接传给 where()、order()、save() 的位置,重点确认是否存在数组形式的参数(形如 id[where]、name[0] 加表达式关键字、id[0] 加绑定关键字、order[0] 等)。
    • 检测特征:请求参数值中出现 SQL 函数名(updatexml、extractvalue、concat、database(、user()、注释符(#、-- ),以及数组下标形式的参数键名,都值得在网关规则与访问日志里单独统计。
    • 加固方向:把参与查询的用户输入一律收敛为标量;排序字段只允许在白名单内取值,不要接受「字段名 + 排序方向」的自由字符串。

    3.2.3 反序列化风险

    这类风险有一个共同前提:应用把外部输入直接交给 unserialize(),常见形态是经过 base64 编码的序列化字符串被解码后送入。链的起点是魔术方法 __destruct,因为它在反序列化后对象被回收时最先执行 Pasted image 20260304201508.png 顺着往下跟进 Pasted image 20260304201515.png 接着寻找哪里会调用到 destroy,落点在 ThinkPHP/Library/Think/Session/Driver/Memcache.class.php。

    整条链的构造思路是「魔术方法接力」:__destruct 负责触发,Session 驱动负责把受控的对象属性带入流程,数据库驱动对象负责接收最终的可控字符串,最后可控字段进入表名拼装的位置,形成以报错回显为手段的 SQL 注入落点。原稿给出过一份完整的构造代码(包含数据库连接配置与报错注入语句),此处不再保留——对防守方真正有价值的结论是这条链依赖的三个条件:入口处存在不受限的 unserialize()、框架内存在把对象属性当作字符串使用的魔术方法、数据库账号拥有足以回显错误的权限。

    原稿还记录过一种在 SQL 关键字中间插入注释符以规避关键词匹配的手法。它说明的问题很直接:以字符串特征匹配为核心的过滤,可以被注释、大小写、等价写法轻易改写。因此单点过滤只能算一层,不能承担主要责任。更可靠的做法是让注入点本身消失(使用参数绑定与预编译)、把数据库账号权限收敛到最小,并在数据库侧开启审计与错误日志上报。

    ThinkPHP 2.x/3.0 远程代码执行风险

    本节保留为分类标题。2.x 与 3.0 分支的风险表现与上文各小节同源,主要来自路由解析阶段的动态求值以及查询构造器中的拼接;这两个分支均已停止维护,实际处置以迁移到仍在维护的版本为主。

    ThinkPHP 缓冲区函数相关问题

    涉及 Cache 类与输出缓冲的方法需要谨慎使用:这类接口通常接受调用方传入的函数名或回调,一旦参数能够来自请求,就会退化为任意函数调用。原稿标注该问题在 ThinkPHP 中依然存在,因此在代码审计时,应当把它们与 call_user_func 系列一并列为重点关注对象。

    ThinkPHP 3.2.4 SQL 注入风险与 CVE 记录

    版本需要:ThinkPHP <= 3.2.4

    以下三个编号是这一分支上公开记录的 SQL 注入类问题,影响范围的判定以上述版本区间为参考:

    CVE-2018-18546

    CVE-2018-18529

    CVE-2018-10225

    ThinkPHP 5.x

    ThinkPHP 5.0.24 反序列化风险

    分析这类问题的常规做法,是先准备一个最小入口让应用对请求参数调用 unserialize(),再观察对象回收时的调用顺序;而真实项目中的入口分散在业务代码各处(缓存、队列、会话、配置反序列化等),审计时需要逐一确认。版本方面,这里以 5.0.24 为例。

    链条形态

    第一步,先找到 think\process\pipes\Windows::__destruct(),这是起点,对象被销毁时它会调用 removeFiles()。第二步,removeFiles() 内部对 $this->files 逐个调用 file_exists();如果把一个对象传给 file_exists,PHP 会尝试将其转换为字符串,从而触发该对象的 __toString()。第三步,think\model\Merge::__toString()(或 think\Model)被触发后,会继续调用 toJson() -> toArray()。第四步,toArray() 中存在一个逻辑分支:程序会遍历属性并处理,特定的属性名会让流程进入动态调用分支,触发 __call()。第五步,__call() 是这条链的跳板,它最终会落到 filterValue 一类的过滤机制上,而该机制内部存在 call_user_func。从防守视角看,这条链说明的是一类通用模式:魔术方法把「属性被当作字符串或回调使用」变成了攻击面,而这类模式在框架的多个版本中反复出现。

    不同版本差异

    (原稿此处未展开,保留标题。)

    链条原理

    反序列化第一步,先找到 __destruct() Pasted image 20260304220346.png 在 thinkphp/library/think/process/pipes/Windows.php#__desturct() 中,可以看到它调用了 $this->removeFiles() 方法 Pasted image 20260304220409.png 跟进该方法,可以发现它用于删除临时文件 Pasted image 20260304220502.png 并且 $this->files 是可控的 Pasted image 20260304220516.png 经由 file_exists($filename) 可以触发 __toString(),这里同时意味着一个任意文件删除的风险面。全局搜索 __toString() 的实现 Pasted image 20260304220643.png 这里选择的是 Model.php 中的 toString 方法,跟进它调用的 toJson() Pasted image 20260304220703.png 继续跟进 $this->toArray(),这一层的可控属性最多 Pasted image 20260304220930.png

    这里可以找到触发 __call 的位置:需要控制 value 为一个带有 __call 的类对象。往上看,value 的来源如下 ![[Pasted image 20260304221008.png]] 其中参数 modelRelation = $this->relation(),实际上就是 think\Model 类任意方法的返回结果,因此要选取返回结果简单可控的方法(例如直接返回 $this->error 的 getError())。在 getRelationData 方法里,还需要满足若干条件才能把属性赋值成想要的类 Pasted image 20260304221100.png 涉及的属性包括 append、error、selfRelation、query 与 parent。

    全局搜索 __call,本例选择的是 console/Output.php 的 Output 类 Pasted image 20260304221140.png 它的实现把方法名与参数交给 call_user_func_array,并在 $this->handle 上做了一次转发:

    1
    2
    3
    4
    5
    6
    7
    public function __call($method, $args)
    {
    if ($this->handle && method_exists($this->handle, $method)) {
    return call_user_func_array([$this->handle, $method], $args);
    }
    // 其余分支略
    }

    由于 $this->handle 可控,转发目标可以是任意类。沿链条把 handle 指向会话驱动的 write(),再指向缓存驱动的 set(),流程便落到缓存写入:缓存驱动用调用方给出的 path 与缓存键拼出文件名,先写入 <?php 与 exit(); 组成的头部,再通过 file_put_contents() 写入数据。这里有两点值得注意:写入内容以 <?php 开头、随后紧跟 exit();,因此直接访问该缓存文件并不会执行后半段数据;但缓存驱动的 setTagItem() 机制会在打标签时再次调用 set(),把上一次的文件名当作「值」写进一个新文件 Pasted image 20260304221416.png

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    protected function setTagItem($name)
    {
    if ($this->tag) {
    $key = 'tag_' . md5($this->tag);
    $this->tag = null;
    if ($this->has($key)) {
    $value = explode(',', $this->get($key));
    $value[] = $name;
    $value = implode(',', array_unique($value));
    } else {
    $value = $name;
    }
    $this->set($key, $value, 0);
    }
    }

    也就是说,第一次写入的内容会以文件名的形式进入第二次写入的结果。原稿在这一步借助 PHP 的流包装器改写了写入内容的编码方式,并给出了一份完整的链条构造代码(含缓存路径与要执行的命令),此处不再保留。对防守方而言,关键结论是:缓存目录与模板编译目录的可写范围,直接决定了这条链能否落地。

    ThinkPHP 5.0.10 cacheFile 变量覆盖与文件包含风险

    前置条件:创建 application/index/view/index/index.html 文件,内容随意——没有这个模板文件时,渲染阶段程序会先报错。官方发布的更新记录如下 Pasted image 20260304205849.png 对应位置在 template/driver/File.php Pasted image 20260304210430.png 这里存在变量覆盖的可能:extract() 的 EXTR_OVERWRITE 模式是默认值,键名冲突时会覆盖已有变量。如果被覆盖的变量恰好是模板缓存文件路径 $cacheFile,后续读取就会指向非预期位置,形成文件包含。

    从控制器一路走到 File->read() 的调用栈如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    File.php:45, think\template\driver\File->read()
    Template.php:200, think\Template->fetch()
    Think.php:84, think\view\driver\Think->fetch()
    View.php:163, think\View->fetch()
    Controller.php:120, think\Controller->fetch()
    Index.php:31, app\index\controller\Index->index()
    App.php:343, ReflectionMethod->invokeArgs()
    App.php:343, think\App::invokeMethod()
    App.php:595, think\App::module()
    App.php:457, think\App::exec()
    App.php:139, think\App::run()
    start.php:19, require()
    index.php:17, {main}()

    到这个 read() 函数 Pasted image 20260304210517.png $var 是传入的 get 参数,这里是数组;进入分支后执行 extract(),数组中的键值对因此成为局部变量,其中就包括 $cacheFile。

    排查与加固:

    • 代码检查:在模板驱动与视图相关代码中搜索 extract(),确认被展开的数组是否可能来自请求。变量覆盖只会发生在「请求参数数组整体 extract() 到局部作用域」这条路径上。
    • 检测特征:请求参数中直接出现框架内部的变量名(例如 cacheFile)——这类名字本不该由客户端提交,出现即异常。
    • 加固方向:升级到包含该更新记录的版本,并在代码层避免把请求数组整体 extract() 到局部作用域;确需批量赋值时,使用显式的键白名单。

    ThinkPHP 5.x 控制器调用校验缺失导致的风险(一)

    5.0.13 远程代码执行

    该问题的成因是框架底层没有对控制器名做充分的合法性校验。在未开启强制路由的情况下,请求可以调用任意类的任意方法,最终导致远程代码执行。影响范围为 5.0.7<=ThinkPHP5<=5.0.22;5.0.23 官方修复:添加了对控制器名的检查 Pasted image 20260304212233.png 默认情况下,可以使用路由兼容模式的 s 参数访问控制器的内容 Pasted image 20260304214102.png 其形态是

    1
    http://site/?s=模块/控制器/方法/参数/参数值

    在这个方法里调用了 routeCheck 进行路由检查 Pasted image 20260304214214.png

    1
    run()-->routeCheck()

    到了这里,该方法对 s 传入的控制器/方法/参数进行解析,使用分隔符对传入的字符串进行分割 Pasted image 20260304214237.png 分割后得到 Pasted image 20260304214252.png Pasted image 20260304214257.png 解析完成后返回 run() 并调用 exec() Pasted image 20260304214310.png 继续跟进到 module() Pasted image 20260304214327.png 官方修改的位置就在这里 Pasted image 20260304214351.png 修复前,代码直接按切分出的数组取出控制器与操作名并调用,中间没有任何控制器名合法性检查 Pasted image 20260304214410.png

    原稿在此处列出了一批「可以直接使用」的类与方法组合,覆盖配置读取、语言包与文件加载、模板写入,以及以 call_user_func_array 为落点的通用回调解算方法。这些组合可以被直接拷贝使用,因此不再保留。但它们的方法名本身就是很好的流量特征,这一点在下面单独说明。

    排查与检测:

    • 前置条件优先:先确认站点是否落在受影响区间,再确认是否「未开启强制路由」。开启强制路由后,s 参数无法映射到未定义的控制器,这类请求会在路由阶段被拦下。做法是在路由配置中把 url_route_must 置为 true,使请求必须命中显式定义的路由。
    • 流量特征:s= 参数中出现命名空间反斜杠(\think\...、\\think、\think\app 等等)、出现函数调用语法、出现 invokefunction、call_user_func_array、php://filter、php://input 等关键词,都属于强特征。
    • 日志特征:路由兼容模式通常不会出现在正常业务流量中。对带 s= 参数的请求单独统计并抽样复核,成本低、收益高。

    5.1.x 远程代码执行

    这里实验版本为 5.1.30,各受影响版本的情况基本一致,都是没有对控制器名进行检查。run() 方法先进行初始化,然后调用 routeCheck() Pasted image 20260304214950.png

    1
    run() -> routeCheck()

    在此处获取到 s 传来的参数,即 模块/控制器/方法 Pasted image 20260304215009.png 然后调用 check() 对路由进行处理 Pasted image 20260304215029.png

    1
    run() --> routeCheck() -->check()

    在这里,路由分隔符被替换为 |,即变成 index|index|index 形式的内部表示 Pasted image 20260304215120.png 随后进入 think/route/dispatch/Module.php

    1
    run() --> init() -->init()

    由它解析出控制器名和操作名,接下来就是实例化并执行 Pasted image 20260304215241.png

    1
    think\route\dispatch\Module->exec()

    Pasted image 20260304215259.png 整个过程同样没有对控制器名做检查。原稿在此处也列出了一批可利用的控制器与方法组合,同样不再保留。检测与加固同上一节:开启强制路由、对 s= 参数做命名空间与回调关键词检测,并把框架升级到已加入控制器名检查的版本。

    ThinkPHP 5.x 控制器调用校验缺失导致的风险(二)

    5.0.x

    5.1.x

    (原稿此处未展开,保留标题。该分类与上一节属于同一类问题在不同小版本上的表现。)

    ThinkPHP 5 查询构造器注入风险

    注意:这是需要重点关注的方法集合,使用中应当避免把请求参数直接交给它们。这里以 thinkphp 5.0.13 为例。

    paraData 方法

    数组形式参数的第一项会被当作运算符处理(例如自增语义的关键字),其后的内容直接拼接进 SQL,因此这条路径上的值无法依赖参数绑定保护。

    paraArrayData 方法

    数组元素按位置参与拼接,落在函数调用位置的元素可以用于构造报错型注入。

    parseWhereItem 方法

    负责把 where 数组翻译为 SQL 条件片段。原稿将其列为同类风险点,未展开细节。

    parseOrder 方法

    负责把排序参数拼进 ORDER BY。与 3.x 的 parseOrder 问题同源:排序片段中的值部分一旦来自请求,就会直接进入 SQL。

    检测思路上,这类请求的共同特征是「数组形态的参数 + 非常规取值」。正常业务很少以自增、绑定、表达式语义的关键字作为数组参数的第一个元素,也很少在参数值里出现 updatexml、extractvalue、concat、database( 之类的函数名。把「数组参数中出现 SQL 函数名或表达式关键字」做成网关规则,可以覆盖相当一部分尝试;后续的加固则依赖参数绑定与标量化输入,见文末清单。

    ThinkPHP 6.x

    ThinkPHP 6.x 任意文件包含风险(6.0.1~6.0.13,5.0.x,5.1.x)

    该问题有一个明确前提:程序开启了多语言功能。开启后,语言标记可以从 GET 参数、Header、Cookie 三处传入,并被用于拼装语言包文件的加载路径,从而形成目录穿越加文件包含。与 6.0.14 版本比较可以发现,官方删除了 Lang.php 的 detect 函数 Pasted image 20260305174812.png LoadLangPack.php 的 detect 函数也有修改,文件位于 vendor/topthink/framework/src/think/middleware/LoadLangPack.php Pasted image 20260305175132.png 分析这个 detect():它首先从 http 请求的三个位置获取数据,转成小写字母保存到 $langSet 中,然后如果满足 if 条件,就将 $langSet 保存到 range 中返回 Pasted image 20260305175228.png 全局查找 detect() 的引用,找到 Lang.php 的 handle() 函数,也就是加载语言包的位置:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    public function handle($request, Closure $next)
    {
    // 自动侦测当前语言
    $langset = $this->lang->detect($request);

    if ($this->lang->defaultLangSet() != $langset) {
    // 加载系统语言包
    $this->lang->load([
    $this->app->getThinkPath() . 'lang' . DIRECTORY_SEPARATOR . $langset . '.php',
    ]);

    $this->app->LoadLangPack($langset);
    }

    $this->lang->saveToCookie($this->app->cookie);

    return $next($request);
    }

    handle() 的第一条语句就调用了 detect()。问题在于 $langset 没有经过路径规范化就参与了 load() 的路径拼接,因此可以被构造成带有 .. 的相对路径,把加载目标指向预期目录之外。原稿记录了对应的验证方式(需要在开启多语言的环境下传入特制的语言标记),此处不再给出具体请求内容。

    排查与加固:

    • 前提确认:确认 middleware.php 是否注册了 LoadLangPack,以及多语言开关的状态。不需要多语言的项目应当直接关闭该中间件——关闭之后这条路径整体不存在。
    • 检测特征:lang 参数(以及 Accept-Language 请求头、语言 Cookie)中出现 ..、路径分隔符或 %2e%2e 等编码形式。
    • 配置加固:确实需要多语言时,对语言标记做白名单校验(只允许字母、数字、下划线与短横线);并升级到已移除该侦测逻辑的版本后重新自查一次。

    ThinkPHP 6.0.0/6.0.1 任意文件写入风险

    该问题的触发条件与会话配置有关:需要在 /app/middleware.php 中开启 Session 功能(把对应几行注释打开)Pasted image 20260305181105.png。会话开启后,会话数据与会话 id 都会进入写入流程。会话内容由应用写入(在原稿的复现环境中来自请求参数),会话 id 由客户端提交,两者结合后,写入的文件路径与文件内容都可以被请求影响。

    对比 6.0.1 和 6.0.2 可以发现,官方修改了 sessionid 的检查,添加了 ctype_alnum 函数验证 $id 只能是字母和数字或字母数字的组合。

    去掉具体构造方式之后,这条风险在防守侧的抓手是清楚的:

    • 检测特征:Cookie 或请求参数中的 sessionid 出现非字母数字字符,尤其是路径分隔符、. 以及 php 等片段;会话存储目录中出现命名不符合预期的文件。
    • 排查方法:检查会话存储目录内文件的命名与内容,确认是否存在包含 <?php 的文件;对已知的旧版本入口做一次历史日志回溯,确认是否存在利用痕迹。
    • 加固方向:升级到已加入 ctype_alnum 校验的版本;把会话文件目录放到 Web 根目录之外,使其即使被写入也无法通过 HTTP 访问;不需要 Session 的场景直接关闭它。

    ThinkPHP 6.0.0/6.0.15 反序列化风险

    与前面的反序列化问题一样,前提是应用对请求参数调用了 unserialize()。入口的寻找方式是从 __destruct 开始 Pasted image 20260305181949.png 本例落在 vendor/topthink/think-orm/src/Model.php Pasted image 20260305182031.png 其中 $this->lazySave 可控,可以据此进入 $this->save() Pasted image 20260305184145.png 在 save() 内部通过控制 $this->exists 走进 $this->updateData() Pasted image 20260305184248.png 再跟进 checkAllowFields() Pasted image 20260305184316.png Pasted image 20260305184332.png 这里存在一处字符串拼接,可以触发任意类的 __toString(),后续可以接上 5.1.x 后半段的链条;全局搜索 __toString() 可以找到 vendor/topthink/think-orm/src/model/concern/Conversion.php。原稿给出了这一链的完整构造代码,此处不再保留。

    处置方式与前文一致:入口侧不允许外部数据进入 unserialize(),PHP 7 及以上可以使用 unserialize($data, ['allowed_classes' => false]) 限制可实例化的类;日志与网关侧则关注「参数是较长且高熵的 base64 串、解码后以 O: 或 a: 开头」这一特征。

    反序列化风险归纳

    反序列化 __destruct 入口只有 4-5 个,常用的是 think/process/pipes/Windows.php 和 thinkphp/library/think/Process.php 这两处。上面提到的几条链条可以稍作记录用于审计对照,但要注意 vendor/topthink/think-orm/src/model/concern/Attribute.php 的 getValue 方法在 TP6 里已经不能用了,需要寻找其他落点。Request.php 中很多方法调用了 filterValue,而该方法中就存在可利用的 call_user_func 函数,因此链尾的利用通常要考虑这里,以及 PHP 中其他能导致代码执行的函数。

    从治理角度看,比记住链条更重要的是收敛入口:把代码中所有 unserialize() 调用点列成清单,逐个确认参数来源;对确实需要传递结构化数据的位置改用 JSON;短期内无法改造的历史代码,用 allowed_classes 把可实例化的类限制到最小集合。

    ThinkPHP 5.0.x 核心链条

    通常涉及 Output 类和 HasOne / BelongsTo 关联查询类。

    1. 入口点:think\process\pipes\Windows 类的 __destruct() 方法。该方法会调用 close(),进而调用 removeFiles()
    2. 触发点:在 removeFiles() 中,通过 file_exists($filename) 触发变量的字符串转换,从而调用某个类的 __toString()
    3. 跳转点:指向 think\Model(或其子类),触发其 __toString(),进而进入 toJson() -> toArray()
    4. 落点:在 toArray() 中,利用 append 属性触发 __call(),或者通过修改 error 属性并结合 Relation 类的关联逻辑,最终通过 call_user_func 执行任意代码

    ThinkPHP 5.0.24 反序列化

    (要点与前文「链条形态」一节相同,此处保留以便检索。)第一步,先找到 think\process\pipes\Windows::__destruct(),这是起点,当对象被销毁时,它会调用 removeFiles()。第二步,removeFiles() -> file_exists(),在该方法中会检查 $this->files,如果把一个对象传给 file_exists,它会尝试将对象转换成字符串,从而触发该对象的 __toString()。第三步,think\model\Merge::__toString()/think\Model,触发类的 __toString 后,会进一步调用 toJson()->toArray()。第四步,toArray() 的逻辑陷阱:在 toArray 中程序会遍历属性并处理,如果构造了特定的属性名,程序会进入动态调用分支,触发 __call()。第五步,think\Request::__call(),这是一个关键跳板,__call 最终会调用 isAjax() 或其他方法,并结合 filter 功能。最后是代码执行:利用 Requests 类中的过滤机制 filterValue,把自定义参数传递给 call_user_func,从而实现远程代码执行。

    ThinkPHP 5.1.x / 6.0.x 链条

    1. 入口点:同样通常是 Windows 类的 __destruct()
    2. 字符串触发:通过 file_exists 触发 __toString()
    3. 核心跳板:使用 think\model\concern\Conversion trait 中的 __toString()
    4. 落点:
      1. 5.1.x:通过 Conversion -> toArray() -> getAttr() -> getValue()。在 getValue 中可以调用动态闭包或 call_user_func。
      2. 6.0.x:路径类似,但由于 6.0 引入了更多的类型检查,通常需要配合 SerializableClosure(如果环境允许)或者寻找特定的 __call 实现。

    排查、修复与加固清单

    把前文分散的结论汇总成一份可以直接执行的清单,按「先确认范围、再修复配置、再建设检测、最后分层收敛」的顺序排列。

    一、确认系统是否落在受影响区间

    1. 版本核对:以 composer.lock 为准查看 topthink/framework、topthink/think-orm 的实际锁定版本;3.x 项目可以查看框架入口文件中定义的版本常量(例如 ThinkPHP/ThinkPHP.php)。同时到服务器上确认 vendor/topthink/framework 目录内的实际版本与锁文件是否一致——被手工覆盖或替换过依赖的情况在运维中并不少见。
    2. 前置条件核对:控制器调用校验缺失类问题只在「未开启强制路由」时成立;6.x 任意文件包含风险只在开启多语言时成立;6.0.0/6.0.1 任意文件写入风险只在开启 Session 时成立。把这几个配置逐一核对,可以快速排除掉大部分不适用的条目。
    3. 入口核对:全局搜索 unserialize(,确认是否存在参数来自请求的调用点。这是判断反序列化风险是否真实存在的最直接方法。
    4. 暴露面核对:确认是否使用路由兼容模式(s 参数)、是否保留了不必要的调试入口与示例模块。

    二、修复与配置收敛

    1. 升级到已修复的版本。按原稿记录:控制器名检查在 5.0.23 加入,sessionid 的 ctype_alnum 校验在 6.0.2 加入,Lang.php 的 detect 函数在 6.0.14 被删除。无法确认具体修复版本时,升级到官方当前维护的最新稳定版本,并以官方仓库的变更记录为准。
    2. 开启强制路由:在路由配置中把 url_route_must 置为 true,让请求必须命中显式定义的路由,从而关闭兼容模式带来的任意类调用面。
    3. 关闭不需要的功能:多语言、Session、调试模式、兼容模式路由,在业务不需要时应当直接关闭。开启的功能越少,可被利用的落点越少。
    4. 危险能力收敛:在 php.ini 中用 disable_functions 关闭业务不需要的危险函数;用 open_basedir 把文件访问限制在业务目录内。需要注意的是,对依赖 php://filter 一类流包装器的写入,PHP 层面并没有一个开关可以直接禁用该包装器,因此它不能作为唯一防线,必须配合目录权限一起做。
    5. 依赖治理:在 CI 中用 composer audit 或同类工具做定期扫描并锁定依赖版本,避免生产环境出现与锁文件不一致的框架版本。

    三、检测方法与流量特征

    1. s= 参数中出现命名空间反斜杠(\think\、\\think)、函数调用语法,以及 invokefunction、call_user_func_array 等回调相关关键词。
    2. PATHINFO 或参数中出现 ${、php://filter、php://input;数组形态参数中出现 SQL 关键字与函数名(updatexml、extractvalue、concat、database()。
    3. 请求参数中出现框架内部变量名(例如 cacheFile);lang 参数或语言相关的 Header、Cookie 中出现 .. 与路径分隔符。
    4. 反序列化入口参数表现为较长且高熵的 base64 串,解码后以 O:、a: 开头。这类参数在正常业务中很少见。
    5. Cookie 或参数中的 sessionid 含非字母数字字符。
    6. 单个来源 IP 在短时间内出现大量 404/500,且路径带有上述特征,通常意味着自动化批量扫描,应结合访问日志做聚合告警,而不是逐条人工处理。

    四、分层防护与运行环境

    1. 最小权限:Web 运行账户只授予必需目录的写权限;数据库账户按业务需要授权,关闭不必要的 FILE 等权限,避免报错注入拿到超出业务范围的信息。
    2. 目录位置:把运行时目录(缓存、日志、会话、模板编译)放到 Web 根目录之外;无法移动时,禁止这些目录被 PHP 解析,从根上阻断「写入即执行」的路径。
    3. 文件完整性监控:对 Web 目录与运行时目录建立文件基线,监控新增文件与内容变更(自建 inotify 脚本,或使用 AIDE、osquery 一类的工具)。这类监控的价值在于,它覆盖「利用链的最终落点是写文件」的所有变体,而不依赖具体是哪一个编号的漏洞。
    4. RASP 与网关规则:应用侧可以引入 RASP,对 unserialize、call_user_func、文件写入等敏感调用做运行时观测;网关侧针对本节的流量特征做规则匹配。这里需要明确一点:网关规则以字符串匹配为主,可以被注释、编码、大小写改写绕过——原稿记录过在 SQL 关键字中间插入注释以规避过滤的手法,正说明单点过滤的局限。因此网关规则应当定位为「提高攻击成本、产生告警」的一层,真正的防线在于让注入点消失(参数绑定、禁止外部数据进入反序列化)、让落点不可用(目录不可写、不可被解析),以及让驻留无处藏身(文件完整性监控)。这三者中任何一层单独存在都不充分,因为它们针对的是不同的必要条件。

    References

    ThinkPHP 代码审计 | Tree’s Blog Thinkphp5 RCE总结 - Y4er的博客 THINKPHP-poc-collection · HacKerQWQ’s Studio

    Support via Solana

    Solana

    Solana

    Solana Pay

    Solana Pay

    WeChat

    WeChat