ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

二次SQL注入:藏在数据库里的定时炸弹如何被引爆与防御

二次SQL注入:藏在数据库里的定时炸弹如何被引爆与防御 我先直接说结论都21世纪20年代了问“现在还存在SQL注入漏洞吗”这种问题的人要么是没挖过src要么是没看最近几个月的漏洞公告。SQL注入不但存在还换着花样卷土重来。普通注入大家都会测尖括号、单引号往参数里一塞报错或延时一出就能判断个七七八八。真正阴险的是二次注入——你测第一遍的时候干干净净数据存进去像没事人一样结果等业务功能把数据库里的脏数据捞出来再次拼接成SQL时漏洞才炸开。最近舆论场上那个“喰星云·数字化餐饮服务系统not_out_depot sql注入漏洞”本质就是二次注入在真实业务系统里的典型发作。这篇我就把二次注入的原理、利用链和防御完整拆一遍适合刚接触安全测试的入门者也适合写业务代码时没太在意数据生命周期的后端开发。1. 二次注入原理拆解为什么“存进去无害查出来致命”1.1 与普通SQL注入的核心差异普通SQL注入和二次注入归根结底都是“数据和代码边界被混淆”的问题。区别在于混淆发生的时机和位置。普通注入是一次性的你往参数里塞一个 or 11 --后台直接把这个payload拼进SQL数据库当场执行注入发生、漏洞利用、数据泄露一气呵成。整个过程发生在同一次请求里。二次注入则把攻击拆成了两步。第一步攻击者构造一个看起来人畜无害的payload比如注册一个用户名为admin--的账号。这个时候程序可能做了针对性的输入过滤比如addslashes()、mysql_real_escape_string()或者干脆用转义把单引号变成了\。于是恶意字符被“消毒”后乖乖进了数据库。这一步数据库没有报错程序没有异常一切看似正常。真正的杀招在第二步。当某个业务功能从数据库里把这个用户名读出来不做任何处理直接拼接到新的SQL语句里比如执行密码重置、用户信息更新、订单查询时数据库才意识到原来你存的是个定时炸弹。因为数据库里存的是已经过滤后的形态还是原始形态取决于开发者的写法。对比维度普通SQL注入二次注入攻击时序单次请求完成分两次或多次请求触发环节参数直接入SQL数据库脏数据二次拼接排查难度扫描器较易发现需要先构造存储数据再触发过滤绕过需针对过滤规则编码绕过过滤可能形同虚设重点在二次使用1.2 二次注入的触发条件与攻击链路分析二次注入要成立必须同时满足几个条件缺一个都炸不起来。理解这些条件比背十遍payload都有用。第一数据库写入端存在“看似安全”的过滤。这个过滤可能不是完全无效的比如addslashes()确实把单引号转义了导致第一次拼接时payload不生效。第二数据在数据库里以可还原的形态存储。很多数据库字段类型、字符集设置不当或者应用层在读取后丢失了转义符号导致拿出来的数据和存进去的原始payload一模一样。举一个最常见的翻车现场addslashes()把变成\存入数据库这没有问题但如果程序从数据库取出\后又经过了一次stripslashes()或者框架层的自动反转义恶意字符就原地复活了。第三读取端把存储数据直接拼接SQL。这是最关键的一环。很多开发者默认数据库里的数据是“安全可信的”因为他们觉得入库时已经过滤过了。这个思维定势就是二次注入能得手的心理基础。拼接这一步一旦发生之前精心埋下的payload就被激活了。我们可以把二次注入链路画成四个节点污染源用户可控的存储型输入→ 被动存储经过初级过滤入库存→ 二次读取业务功能从库中取出→ 触发执行拼进SQL产生危害。这四个节点中任何一个环节做了严格的白名单校验或参数化查询整条链都会断掉。2. 完整利用链拆解从埋点、发酵到触发2.1 从漏洞代码看“如何埋雷”用户注册场景剖析先从一个最常见的业务场景——用户注册——来看二次注入的“埋点”。很多系统注册接口的SQL写法是直接拼接字符串比如?php $username $_POST[username]; $password $_POST[password]; // 模拟简单的输入过滤 $username addslashes($username); $sql INSERT INTO users (username, password) VALUES ($username, $password); $result mysqli_query($conn, $sql); ?看着没什么问题对吧addslashes()把单引号转义了变成\INSERT语句不会断开。攻击者在这一步注册一个admin--的用户名最终库里存的是admin\--注意反斜杠和单引号都是可见字符都存进去了。关键问题来了这个存储层的数据在后续被读取时很多程序员会习惯性做一次清理或者框架/中间件会自动做实体反转义。比如有的老式PHP项目里接收post数据时会统一经过stripslashes()处理如果读取页面也做同样的处理admin\--就被还原成了admin--。这时候改变密码的UPDATE语句写成?php $username $_POST[username]; // 假设这里读的是用户提交的登录名 $newpass $_POST[newpassword]; // 从数据库查出该用户的完整信息 $query SELECT * FROM users WHERE username $username; // 假设这里用的是同一个过滤函数又转义了一次 $result mysqli_query($conn, $query); $row mysqli_fetch_assoc($result); // 拿数据库中的用户名去拼接更新语句 $update UPDATE users SET password $newpass WHERE username {$row[username]}; mysqli_query($conn, $update); ?当$row[username]里存的是被还原的admin--拼进SQL后实际变成UPDATE users SET password hacked WHERE username admin-- 从第一个单引号开始原来闭合的字符串被破坏--把后面所有内容注释掉。这条UPDATE语句真正执行的效果是把所有用户名为admin的记录密码修改为攻击者指定的值。一次完美的账户接管。2.2 完整利用演示以SQLi-Labs靶场第29关为例SQLi-Labs是学习二次注入最直观的靶场。第29到31关专门演示这种漏洞。以第29关为例它的逻辑是把客户端IP或者UA头存入数据库然后在管理后台的日志查看功能里查询并展示查询时直接把存储的IP拼进了SQL。-- 第一步攻击者提交一个特殊的IP比如 X-Forwarded-For: 1.1.1.1 and updatexml(1,concat(0x7e,database(),0x7e),1)--这个值被代码原样写进了一个日志表。注意这里可能没有做过滤或者过滤规则只针对常规GET参数没有覆盖HTTP头字段。所以脏数据完整入库。-- 第二步管理员打开日志查询页面后端从日志表读出IP SELECT * FROM logs WHERE ip 1.1.1.1 and updatexml(1,concat(0x7e,database(),0x7e),1)--这条SQL执行时updatexml报错错误信息里带出数据库名典型的报错注入。整个过程看起来就像后台程序自己犯错一样攻击者可能根本不需要登录管理后台只要诱导管理员查看日志即可。靶场实验建议这样操作先在SQLi-Labs里用普通注入手法测一遍确认当前闭合方式然后正常注册一个带有payload的用户名再切换到会读取该用户名的功能模块观察是否报错或产生延时。这套流程走下来你对二次注入的整个数据流就会有肌肉记忆了。2.3 真实业务场景里的“藏污纳垢”以喰星云not_out_depot漏洞为例热词里提到的“喰星云·数字化餐饮服务系统not_out_depot sql注入漏洞”是一个非常典型的“登录后才可触发的存储型注入”。喰星云是面向连锁餐饮企业的数字化系统核心功能涵盖采购、库存、出库、门店报货。not_out_depot这个接口名直译过来就是“未出库单据”属于库存管理模块里的查询功能。这类系统出问题高概率点在于开发团队为了赶业务进度把查询条件用字符串拼接的方式塞进SQL。攻击者在正常业务操作中发现某个数据字段比如门店编号、商品编码、单据备注会被完整存入数据库字典表然后在打开“未出库单据查询”时这个字段的值会被取出来作为查询条件拼接SQL。这类漏洞的实际危害比普通注入更严重。普通注入至少需要攻击者直接面对目标接口可能触发WAF或日志告警二次注入的攻击者可以伪装成正常业务用户第一波写入时所有行为都在业务规则之内安全设备根本不会告警。等脏数据被二次利用时攻击者本人可能已经“隐身”了。这给防御方提了个醒安全审计不能只看入口参数校验还必须追踪数据在系统内部的流转路径。一个输入点在写入时看似安全不代表它在未来所有读取场景里都安全。3. 防御体系设计三层防线保证业务安全3.1 第一道防线参数化查询直接切断拼接路径参数化查询是防御SQL注入的根本解这个结论在安全圈已经喊了十几年但实际落地依然参差不齐。根本原因不是技术做不到而是太多开发者在思想意识上觉得“拼接SQL也没出过事”。要理解参数化查询为什么能防二次注入得先理解数据库执行SQL的方式。预编译语句在数据库端会做两步第一步数据库把SQL骨架编译成执行计划第二步把实际参数值绑定进去执行。这里的关键在于参数值是在编译完成后才传入的数据库已经用“值”的视角看待参数不会把它解析成SQL结构的一部分。这就意味着不管参数里塞的是还是or 11在数据库看来都只是一个普通的字符串值。// 反例拼接SQL存在二次注入隐患 String sql UPDATE users SET password newPass WHERE username userName ; // 正例参数化查询彻底隔离数据与代码 PreparedStatement ps conn.prepareStatement( UPDATE users SET password? WHERE username? ); ps.setString(1, newPass); ps.setString(2, userName); ps.executeUpdate();Java开发者要尤其注意Statement这个类本身不支持参数化只有PreparedStatement才是预编译的另外很多团队用的ORM框架如MyBatis如果写法的${}是字符串替换#{}才是占位符。一行${}就能把参数化查询的防御全部击穿。3.2 第二道防线纵深防御纵深不靠单点参数化查询是终极解但并不代表可以放弃其他层面。数据库层面的最小权限原则非常关键业务账号只授予增删改查所需的最小表权限不授予FILE、SUPER等高危权限存储过程只开放白名单调用接口避免应用层直接操作基表。这样做的好处是即使注入发生攻击者能做的事也被严格限制在业务允许范围内。另一个常被忽略的层面是“输入输出双重编码”。存储到数据库之前对字段按业务语义做严格的白名单校验比如仅允许数字、字母、指定符号每次从数据库读出数据、准备拼接或展示之前再做一次输出编码。这个习惯可以防二次注入也能防存储型XSS一箭双雕。字符串截断和类型转换的坑也要留意。比如某个字段声明为VARCHAR(10)攻击者提交的是admin--数据库可能截断到admin--某些数据库在特定字符集下还会出现GBK宽字节注入%bf%27这类经典payload在set names gbk的配置下能绕过转义——这些都是二次注入的“帮凶”。生产环境字符集统一使用utf8mb4并且关闭magic_quotes_gpc能从底层减少不必要的转义混乱。3.3 第三道防线存量系统修复与上线管控新系统从架构上做参数化不难难的是存量系统。很多跑了好多年的老项目SQL遍布业务代码的各个角落不可能一夜之间全部重写。这时候要分清轻重缓急。建议按“数据敏感度”和“字段复用度”两个维度排优先级。第一优先级是用户信息、订单信息、支付信息相关表的所有读写SQL这些表一旦被二次注入泄露的是直接可套现的数据。第二优先级是日志类、操作记录类表这类表虽然不直接存敏感数据但往往会被后台功能读取拼接成为报错注入的跳板。第三优先级才是配置类、字典类表。修复方法上优先把高危SQL改为存储过程封装。存储过程内部仍然使用参数占位符应用层只负责传参给存储过程。这样既能约束SQL写法又方便DBA做统一审计。实在没法改的地方再加一层应用层WAF兜底——注意WAF只是缓解手段不是根治方案payload的编码变体能绕过的WAF太多了别把宝全押在WAF上。4. 实战排查记录常见问题与修复经验4.1 二次注入为什么扫不出来排查方法论很多朋友遇到过这种困惑用SQLMap扫了全站页面干干净净结果被人一梭子打穿。原因在于多数自动化工具默认只做“一次性”探测。SQLMap提供了--second-order参数用来告诉工具“payload先提交到这个URL然后到另一个URL看效果”但实战中很多人没用对。我第一次用SQLMap测一个二次注入点时也翻过车。当时把一个用户名带payload注册进去然后指定登录接口观察报错结果一直没反应。后来排查发现payload注册进去时程序对单引号做了过滤实际存库的是\而读取端程序没有做反转义导致二次拼接时转义符还把引号保护得好好的注入自然不生效。这个案例说明一个道理探测二次注入要跟着数据流走先确认存储侧能不能原样保留payload再确认读取侧有没有还原操作。人工排查时可以借助数据库日志。把数据库的general_log暂时打开操作一遍业务功能然后去日志里看实际的SQL语句。如果发现某个SQL的WHERE条件里有疑似可控的存储字段拼进来了这个点就值得重点测。这个方法虽然土但在面对几十个接口的业务系统时比盲扫高效得多。4.2 修复后为何还会复发三个容易遗漏的角落修复二次注入最怕“按下一个葫芦浮起一个瓢”。我见过一个项目团队把主业务SQL全部改成参数化之后没过多久又被通报漏洞定位后发现是报表模块漏了。这类问题通常出在三个角落。第一个角落是导出功能。Excel导出、CSV导出模块为了拼接查询条件方便经常直接字符串拼SQL。这类接口往往没有入参校验完全是内部逻辑安全人员测试时很容易忽略“非标准入口”。修复时检查清单里至少要加一项所有涉及文件导出、批量任务调度的SQL是否也做了参数化。第二个角落是后台管理端的模糊搜索。后台系统通常只对内部员工开放开发时默认“内网可信”于是搜索框的参数基本都是拼接进LIKE语句的。攻击者一旦通过其他方式拿到后台入口权限二次注入就能在这些搜索框里玩出花。修复时可以把后台与前台同等对待所有搜索参数统一走白名单加参数化。第三个角落是定时任务和消息队列的消费者。这类模块在运行时从数据库读取一批“待处理”数据再根据其中某个字段做下一轮SQL操作。如果中间某个环节没有参数化脏数据就会在无人值守的情况下自动触发注入而且由于是异步执行排查问题时要翻很多日志才能找到根因。修复时建议把所有消息消费方的数据库操作也拉入统一DAO层管理禁止在业务逻辑里直接写SQL字符串。4.3 安全编码习惯与团队落地经验防御最终还是要落到“写代码的人”身上。我见过不少团队安全部门出了报告开发改完就完事下个项目照样踩同一个坑。要打破这个循环建议从三个层面入手。制度层面把SQL注入检查写进代码评审的Checklist评审时重点盯${}、字符串拼接、executeQuery这类模式看到就直接打回不需要等安全测试阶段再发现。技术层面给团队搭一个统一的DAO层或者ORM约定业务代码一律不允许裸写SQL。这个约定要写进项目规范文档同时在CI流程里加一个静态扫描规则自动检测代码里的SELECT、UPDATE、DELETE拼接模式命中就终止构建。工欲善其事必先利其器这一点自动化起来效率远高于人工苦口婆心。兜底层面。即使前面所有措施都做到位也不能保证万无一失。数据库账号的权限回收一定要定期做特别是那些长期不用的存储过程、没人维护的报表账号该删就删。我在实际排查中遇到过不止一次主业务系统已经修复完毕因为一个为了“临时查询”创建的只读账号配置文件里带着库名密码被人脱库。安全工作从来不是做好一个点就完事而是每一层都得守住。我的实操心得回到开头那个“现在还存在SQL注入漏洞吗”的问题。我的看法是只要还有人写拼接SQLSQL注入就不会消失。二次注入这种形态尤其隐蔽它不像普通注入那样明刀明枪而是把杀意藏在数据库的某个角落里等着某个业务功能去“点炮”。防御二次注入没有捷径唯一可靠的路就是全面参数化、最小权限、定期审计这三板斧真正落地。最后分享一个小技巧如果你接手了一个二手项目不确定历史代码里有没有雷可以在测试环境打开数据库的SQL日志把业务跑一遍然后搜SQL日志里所有包含字符串拼接痕迹的语句再把对应代码揪出来改掉。这个方法虽然费时间但比拿着扫描器瞎撞要准得多。安全这个东西从来都是主动发现比被动挨打强。
返回列表