凌晨两点四十,我还在盯着屏幕逐行翻 ThinkPHP 3.2 的老代码。grep -n "\$_GET\[" 跳出来两百多处,每改完一个文件就手动跑一遍 SQLLINT。心里清楚这种修法今天顶多搞定二十个。
那堆字符串拼 SQL 的写法看得人只想叹气:DB::table('user')->where("id = " . I('get.id')) 这类句子遍布全库,安全审计报告把“高危”标得通红。ThinkPHP 3.2 已于 2015 年停止维护,官方文档早就提醒升级,可业务还在跑。I('get.id') 只是框架对 $_GET 的薄封装,不过滤就进 SQL,参数一变立刻被插。
最初想着手写脚本批量替换,写到一半发现情况复杂:引号嵌套、魔术方法、视图拼接样样坑。偶然搜到 Rector 这个 PHP 静态分析与重构引擎,才意识到与其造轮子不如让机器自个儿学规则。
凌晨三点,我还在手动查SQL注入——直到遇见Rector
Rector 这名字听着像教区牧师,不过它干的是拆旧庙的活儿。一个 PHP 静态分析与自动重构引擎,能把抽象语法树拆了再拼。官方仓库在 GitHub 上开源,维护了四五年,社区活跃度还行。
安装就一行:。然后项目根目录丢个 rector.php 配置文件,里面声明你要扫描哪些目录、加载哪些规则集。
<?php
// rector.php
use Rector\Config\RectorConfig;
use Rector\Set\ValueObject\SetList;
return RectorConfig::configure()
->withPaths([__DIR__ . '/Application'])
->withSets([
SetList::CODE_QUALITY,
SetList::DEAD_CODE,
]);
跑起来也简单:。Rector 会逐文件解析 AST,匹配到规则就改,改完直接写回文件。默认还带 --dry-run 模式让你预览差异,不用怕搞炸。
官方给了几百条现成规则,比如 Rector\Transform\Rector\FuncCall\FuncCallToMethodCallRector 这种,专门处理函数调用转方法调用。但 ThinkPHP 3.2 的 I() 函数、M() 模型、以及模板里直接输出 {$data} 这种 XSS 重灾区,官方没空管。得自己写规则。
自己写规则听着玄乎,其实就两步:先写一个 Rector 类继承 Rector\Rector\AbstractRector,重写 getRuleDefinition() 和 refactor();然后在 refactor() 里判断节点类型,匹配到 I('get.id') 就替换成 input('get.id', '', 'addslashes') 或者干脆 intval(I('get.id'))。
写一条规则大概半小时,但跑一次全库只要十几秒。凌晨三点手动改一个文件要三分钟,两百处就是十小时。机器跑完,泡杯咖啡的功夫就改完了。
自定义Rector规则:识别ThinkPHP的查询构建器与模板输出
上一章程把 Rector 的架子搭起来了,但这一刀切下去,得知道往哪儿落。ThinkPHP 3.2 这类老项目,安全问题往往集中在两块:一是数据库查询时手拼 SQL,二是模板里把变量裸着往外抛。只要抓住这两个口子,规则就能有的放矢。
遗留代码里最常见的是 M()->query($sql) 或 M()->execute($sql),而 $sql 字符串里藏着 $_GET、$_POST 的影子。Rector 遍历 AST 时,可以锁定 MethodCall 节点:若对象是 $this->db(或由 M() 返回的模型实例)、方法名为 query 或 execute,再看第一个实参是否为 BinaryOp/String 并与超全局变量做拼接。
<?php
// 典型靶点
$id = $_GET['id'];
$sql = "SELECT * FROM user WHERE id = {$id}";
M('User')->query($sql);
视图层的 XSS 也别放过。经常看见 {$content} 或 <?= $content ?> 直接输出。若是原生 echo/print,就检查其参数是否来自 I()、S() 这类未经 htmlspecialchars 的处理;若是 ThinkPHP 模板引擎占位输出,则匹配变量赋值到模板变量的那步,看有没有配套的过滤。Rector 能认出这些节点,并在缺失过滤时给出替换建议。
写 Rector 规则这事儿,说白了就是手把手教机器识别“这写法有坑”。规则一跑通,执行一条 ,它就把那些危险调用全给你标红,顺带提示你该换成参数绑定或者预处理语句。你甚至能腾出手喝口茶——等回来一看,上百处隐患已经老老实实排好队,等着被一键改写。
自动修复:将字符串拼接替换为参数绑定与安全输出
前一章的规则能把危险调用标出来,但标红不是终点——你得让它自己动手改。Rector 的 Rector\Rector\AbstractRector 提供了 getNodeTypes() 和 refactor() 两个钩子,前者告诉工具“我要处理哪类 AST 节点”,后者说“找到以后怎么换”。拿 SQL 注入来说,我写了个 ,专门盯 MethodCall 里方法名为 query 或 execute 的调用。
逻辑其实不复杂:遍历到那个节点,检查第一个参数是不是字符串拼接(BinaryOp\Concat 或 BinaryOp\Plus),并且拼接的某一段来自 $_GET、$_POST、$_REQUEST。如果是,就把整个字符串拆开,把变量提取出来放到第二个参数数组里,第一个参数改成带问号的占位串。ThinkPHP 3.2 的 M()->query() 本身就支持参数绑定,只是没人用罢了。
<?php
// 改前
$sql = "SELECT * FROM user WHERE id = " . $_GET['id'];
M('User')->query($sql);
// 改后
M('User')->query("SELECT * FROM user WHERE id = ?", [$_GET['id']]);
XSS 那边稍微绕一点。模板里的 {$var} 得先找到模板文件——Rector 默认只处理 PHP 文件,但 ThinkPHP 的视图文件是 .html 后缀,里面嵌着 PHP 标签。我加了个 ,用正则匹配 {$var} 这种占位输出,然后检查同一个模板里有没有对应的 htmlspecialchars() 调用。如果没找到,就在输出语句外面套一层 |htmlspecialchars。注意别把 {$var|default=''} 这种已有过滤的给覆盖了,得先解析管道符列表。
边界情况最坑。有的变量来自数据库查询结果,比如 {$user.username},如果数据库里存的是富文本,你一棍子打死加个 htmlspecialchars(),前端直接显示乱码。我加了个白名单机制:让规则跳过那些赋值时经过 strip_tags 或 purify 的变量。还有个坑是 I('get.') 已经带了安全过滤,再用 htmlspecialchars() 就双重转义了——规则里得判断参数来源,如果来自 I() 且第二个参数是 'get' 或 'post',就跳过。
跑一次 ,输出里能看到每个文件的改动行数。我那个项目跑完,SQL 注入改了 43 处,XSS 改了 87 处,还有 12 处被标记为“需人工复核”——都是因为变量来源不明确。机器能做的就到这了,剩下的得靠人眼看。
写规则比修漏洞慢,但一条规则写好,全仓库几百个文件一次性解决。这账怎么算都不亏。
第一次运行:修复了80%的漏洞,但差点删了业务逻辑
命令行里敲下 ,屏幕滚得飞快。结束后统计面板显示:SQL 注入改掉 180 处,XSS 补了 95 处。再往下扫,发现三个文件的变量名被统一改成了 $safeValue,连带把 $isAdmin 也干掉了,登录权限直接失灵。
赶紧回滚,盯着那段 Rector 规则找原因。AST 查询写得太贪心:只要看到 VariableName|String 拼接进 SQL,就一律替换占位符并重命名变量;而 ThinkPHP 旧项目里偏偏有大量 $orderField = 'is_admin'; 这类静态字符串映射,也被当成用户输入一把抓。
// 原规则片段
if ($node instanceof BinaryOp\Plus && $this->isUserInput($node->left)) {
$this->changeToPreparedStatement($node);
}
问题出在 isUserInput 只检测左侧是不是 $_GET 之类,却没继续向上追溯赋值节点。于是加了两层过滤:先查变量是否出现在 $_* 超全局,再通过 VarDumper 反向定位定义位置;若发现其值为固定字符串或数据库字段,则跳过改写。
调整后再跑,误杀从 7 降到 0,速度只慢了一秒多点。我把这段“别乱动业务常量”的逻辑单独拆成 ,顺手写进团队规范:任何自动重构脚本都必须附带最小可用上下文白名单。工具只管替换字符,真正理解业务含义的还是人脑。
那次之后我可算明白了——Rector 哪是什么一键通关神器,它就是个不知疲倦但有点莽撞的实习生。活干得是真快,但每次上线之前,非得拽着它坐到 Code Review 的会议桌旁边,好好过一遍才敢放出去。
从手动搬砖到自动排雷:重构后的效果与维护建议
跑完那轮扫描,SonarQube 上之前标红的 SQL 注入和 XSS 项全灭了。43 处和 87 处归零,剩下的 12 个“人工复核”标记挨个看下来——8 个是模版里直接输出 $this->get('title') 但没做 htmlspecialchars(),4 个是参数绑定后变量名与 SQL 列名冲突,属于命名问题而非漏洞。安全扫一遍全绿。
我把那条自定义 Rector 规则提进了项目仓库 rector.php,配了 GitHub Actions。每次 Push 自动跑一遍,新代码如果又出现 $_POST['id'] 直接拼 SQL,Action 直接标红阻断合并。三个月了,没放过一条新漏洞进来——至少 CI 日志里是这么说的。有一次同事写了个 $where = "id={$_GET['id']}",CI 报错后他改成了参数绑定还特地来道谢。这比事后人工审计省心太多。
ThinkPHP 3.2 的 I() 函数和自动参数绑定机制,放在今天看就是筛子。Rector 能修掉 90% 的显式注入,但框架底层那些 Query Builder 的字符串拼接逻辑它管不了。我的建议是:拿 Rector 清完现有漏洞后,顺手把数据库操作层往 ThinkPHP 5.0 或 6.0 迁移。5.0 开始强制参数绑定,where() 方法里如果传字符串直接报错,能从源头上掐死 SQL 注入。XSS 那边,把 assign() 传到模版的变量统一过 htmlspecialchars(),或者直接上 Blade 那种自动转义的模板引擎。步子不用太大,一次迁一个模块,Rector 负责清理旧代码,手工负责设计新结构。
重构完那天,我关掉 IDE,屏幕反光里瞥见自己嘴角居然翘着。真不是夸张——代码清清爽爽,机器替你守门,人脑腾出来干点正经事。这状态,就对了。
评论