前阵子做代码审计时,遇到一套老后台系统,里面有个不起眼的搜索接口,参数一传进去就直接拼进 LIKE。我当时心里就想,这种写法要是还能跑通,说明这套系统至少十年没动过核心代码。结果一个单引号下去,日志里就留下了一条完整的查询语句——一条原本只应该由后端自己拼出来的语句,被文本框改写了。这个现象,就是 SQL 注入。
SQL 注入这四个字,在 Web 开发和安全圈里几乎没人不知道,但真正能把“它为什么还在、它长什么样、如何判断、怎么收尾”讲清楚的人并不多。大多数资料要么停留在原理层面,要么就是工具使用手册。这篇笔记是我这几年做代码审计和测试时整理的思路,不刻意堆名词,也不追求什么都讲,只讲那些我在真实项目里反复用到的判断框架。
适合两类读者:一类是刚接触 Web 安全、想建立稳定排查思路的学习者;另一类是平常写业务代码、需要自查项目的开发者。读完你至少能回答三个问题:为什么一个文本框可以变成数据库指令;同样的注入在不同场景下形态差异有多大;拿到一个可疑参数之后,接下来该怎么一步步确认并收尾。
1. 为什么这个“老掉牙”的问题到现在还在出现
很多人以为 SQL 注入是早期的老漏洞,现在大家都会用参数化查询了,应该已经绝迹。这种想法我在不少团队里见过。可现实是,只要你去翻那些活了八年十年的业务系统,或者看看新项目里那些“赶工期”的临时接口,拼接 SQL 的场景依然大量存在。SQL 注入并不是被消灭了,它只是悄悄退到了更多人不太注意的角落。
1.1 SQL 注入的本质:数据和代码没分开
先回到原理本身。SQL 是一种解释型语言,数据库服务器拿到 SQL 文本之后,会先做词法分析和语法分析,把语句解析成可执行的逻辑结构。问题在于:如果应用把用户的输入直接拼进 SQL 文本,那么数据库在解析时根本没有办法区分哪一段是程序员的代码、哪一段是用户的数据。用户的输入,就这样变成了代码的一部分。
举个例子。正常查询可能是这样的:
SELECT id, username FROM user WHERE id = 1如果用户输入的不是一个简单的数字,而是1 OR 1=1,拼接之后就变成:
SELECT id, username FROM user WHERE id = 1 OR 1=1因为OR 1=1恒为真,这条语句会把整张表都查出来。输入不再是输入,它成了 SQL 语法树里的一截。这就是“数据和代码没有分离”带来的后果。你可以理解为:门禁系统本来只该检查访客的申请单,结果访客在申请单备注里写了一句“把门禁权限也给我”,系统没分辨出来,直接照办了。
1.2 参数化查询为什么没能彻底消灭它
参数化查询(PreparedStatement)确实是目前最有效的防线,但它只解决了一部分问题。参数化能绑定的是“值”,也就是WHERE id = ?里问号对应的那个数据。可是 SQL 语句里还有一类东西叫标识符:表名、列名、排序字段、运算符、LIMIT 子句,这些是不能用问号去绑定的。
这意味着很多场景下开发被迫走拼接路线。比如:用户点表格列头排序,前端传一个sort=price,后端需要根据这个值决定ORDER BY哪一列。这个字段没法参数化,一旦直接拼接,就留下了一个 SQL 注入点。再比如报表系统里根据用户选择的维度拼 WHERE 条件、批处理里根据参数拼动态更新语句、ORM 框架处理复杂查询时生成的动态 SQL,都是同样的高风险区域。
还有一个现实因素:很多老代码在参数化普及之前就已经写好了,后来的人只敢在核心接口上改,边边角角的模块几十年没人动。我见过不止一次,某个系统主查询全参数化了,唯独一个没人注意的辅助查询还在拼字符串。修的人漏掉了它,安全测试的人又不可能每次都把所有接口翻个底朝天。这部分遗留代码,就是注入长期存在的土壤。
2. 识别注入类型:先判型再动手
很多新手拿到一个可疑参数就急着套 payload,实际效果很差。我的习惯是先判断它属于什么类型,再决定下一步。注入类型的判断可以从两个角度切:一个是我说的“数据交互位置”,另一个是“数据怎么出来”。
2.1 按与数据库交互的位置判断类型
第一类是数字型注入。后端 SQL 通常是WHERE id = $input这种写法,参数没有被引号包裹。判断思路很简单:传1和2返回的内容通常不一样,再传1 AND 1=1跟1 AND 1=2对比,如果前者结果正常、后者结果不同,大概率可以确认数字型注入。
第二类是字符串型注入。SQL 里参数的写法是WHERE username = '$input',输入被一对单引号包裹。这时候我的第一步是输入一个单引号,观察页面是否报错或行为异常。如果条件成立,单引号会提前闭合原来的字符串,把后面的结构搅乱。
第三类是搜索型注入,也就是开篇说的 LIKE 场景。SQL 结构往往长这样:WHERE name LIKE '%$input%'。前端输入会被前后两个百分号包裹,看起来似乎很安全,但只要输入一个%或单引号,照样能改变整个条件结构。搜索接口因为业务上必须有模糊匹配,往往更容易被开发忽略。
2.2 按数据获取方式分类
判断完交互位置之后,还需要知道“数据能不能看到、能不能直接拿到”。这决定了后面验证路径。
| 类型 | 典型特征 | 我的使用判断 |
|---|---|---|
| 联合查询 | 页面有数据回显位置,可以让查询结果直接显示在页面上 | 优先选择,效率最高 |
| 报错型 | 数据库错误信息被返回到了页面或日志 | 无回显但有详细错误时可用 |
| 布尔盲注 | 真条件下页面内容/长度不同,假条件下相同 | 无回显无错误,只能靠差异判断 |
| 时间盲注 | 真条件下响应时间明显变长 | 页面完全无差异时最后考虑 |
| 堆叠查询 | 支持一次执行多条语句 | 很少见,驱动层通常默认关闭 |
这个表格看起来简单,但真在测试时我会先想一个问题:当前页面上哪些内容是从数据库里指定的字段输出的。如果是联合查询,需要知道原查询返回的列数,然后找到数据回显的位置,才能把它显示出来。如果页面没有回显位,那就退到报错型或盲注路线。判断类型的意义就在于不绕路,直接在正确的通道里做验证。
2.3 别忽略宽字节和旧编码问题
还有一个历史包袱,就是宽字节注入。早期系统普遍用 GBK/GB2312 编码,后端为了防注入,会对单引号做转义——把它变成\'。看起来没问题,但 GBK 这类多字节字符集里,某些字节和反斜杠组合之后,反斜杠会被当成汉字的一部分“吃掉”,后面的单引号就逃逸出来了。现在的新系统大多用 UTF-8,这类问题少了,但涉及老系统改造时一定不能漏掉这一层。换句话说:看到转义函数就直接判定安全,是不成立的。
3. 从单个参数开始的手动检测流程
拿到一个可疑参数之后,如果没有自动化工具,我能完全不慌,靠的就是一套比较固定的手动探测流程。这套流程不依赖工具,逻辑上环环相扣,适合用来理解注入的本质。
3.1 第一轮试探:单引号与三个信号
我对每一个可能触碰数据库的参数,第一件事都是输入一个单引号,同时观察三个信号:响应正文是否异常、HTTP 状态码是否变化、响应时间是否有明显波动。注意,不只是看页面长什么样,状态码和耗时都要看。
为什么第一步必须是单引号?因为它是最直接的“结构破坏器”。如果后端把输入拼在引号包裹的字符串里,一个单引号就会提前结束这个字符串,导致后面的语法错乱,大概率会触发异常。但这里有个坑:很多应用框架有统一的异常处理,任何错误都返回同一个通用错误页。这时候单引号“没反应”,不代表安全,只说明你还没拿到有效的信号,得继续往下试探。
3.2 用条件表达式确认,而不是靠猜
单引号之后,第二步是找两个结果可对比的条件表达式。我常用的是1=1和1=2这种恒真/恒假的结构。比如数字型参数,传1 AND 1=1,返回的内容和正常传1基本一致;再传1 AND 1=2,如果查询范围被改变了,返回内容就会明显不同。
这个方法的本质是制造一个可观察的差异。真条件下查得到、假条件下查不到,这样的差异一旦出现,基本就能认定条件拼接存在。需要提醒的是,有些后端会做基础过滤,比如把AND、OR、空格过滤掉一部分,导致你的测试语句直接被改写或者被拦截。这时候你看到的可能不是“无差异”,而是“所有条件表达式都一样”。我在这种场景下会先想:过滤到底滤了什么、有没有绕过空间,而不是立刻取消判断。
3.3 盲注场景:没有差异就去制造差异
如果页面完全不回显任何数据库相关内容,也没有任何可对比的输出,那就进入盲注判断阶段。布尔盲注依赖的是“真条件和假条件下页面内容是否不同”;如果页面连这个差异都没有,还可以考虑时间盲注。
时间盲注我一般放在最后,因为它依赖响应时间差,网络抖动、数据库负载都可能造成干扰。我的经验是同一条判断语句重复多次,看差值是否稳定存在,而不是一次成功就下结论。另外,能用二分法就尽量别逐字符猜:通过确认“首字母是否大于某个值”“是否等于某个值”这类问题,每轮能把可能性砍掉一半,效率完全不同。纯粹的时间盲注很容易让人等得失去耐心。
3.4 确认之后的第一件事:收窄而不是扩大
一旦确认了注入点、并且已经能够判断出可以取数,这里我会打住,不再继续往下取数。有不少人走到这一步会忍不住,想要把整个表的内容都拉出来看看,或者更进一步做提权尝试。我的看法是:安全验证的目的是证明漏洞存在、说明影响范围,不是把数据库洗一遍。走到“能够取数”这个证据链就够了,再往下就是破坏性测试,性质就变了。这也是我在团队里反复强调的一条边界。
4. 案例复盘:一个登录接口的完整排查记录
理论说得再多,不如完整走一遍排查过程。这个案例经过脱敏处理,技术细节保留,但绝不能拿去套用在不属于自己的系统上。
4.1 一个单引号之后的异常响应
当时排查的是一套老后台系统的登录接口。登录请求是标准的POST方式,提交username和password。我按照流程给username字段传了单引号,正常情况下预期会得到一个“用户名不存在”之类的提示,但这次直接收到了一条业务异常文案,大致意思是“数据处理失败,请稍后再试”。
这个响应和正常登录失败明显不是一个分支。按照我的判断框架,第一步就有信号的迹象,说明单引号很可能被拼进了 SQL,导致语法或运行时异常。但异常提示不是数据库原始错误,需要进一步确认。
4.2 从日志里找到完整的拼接语句
好在这套系统把后端日志保存得很完整,我在日志里看到了报错前后的 SQL 记录。查询语句大概是这样的结构:
SELECT id, username, role FROM user WHERE username = '$input' AND password = '$pwd' LIMIT 1日志里清清楚楚地显示:我提交的单引号闭合了username原有的引号,后面紧跟着的AND password = '这一段被迫变成了字符串内容的一部分,整个查询逻辑已经彻底混乱。到这里,注入点的存在基本实锤了,因为我能直接从日志里确认拼接行为,不需要再用各种表达式去猜。
4.3 过滤逻辑并没有真正形成边界
再往下验证的时候,我发现这套系统并不是完全没防御,它有一个很老的过滤函数,会把输入里的空格、AND、OR等关键字替换掉。第一次尝试条件表达式时,我确实碰了壁——构造出来的语句被过滤器改写得面目全非。
但继续翻代码发现,这个过滤只覆盖了小写关键字,而且没有处理注释符和等价语法写法。实际验证中,通过改写关键字的书写形式,条件表达式可以正常执行。老实说,到了这一步我已经不想再深入了,因为这个系统的问题已经很清晰:过滤函数给了开发一个“我在防御”的假象,但实际上它既不如参数化可靠,也没有覆盖到所有等价写法。这个结论比“能不能把数据拉出来”更重要。
4.4 为什么参数化没救回来
我后来看了一眼修复建议,发现这个模块也不是完全没有参数化代码。新写的部分用了预处理语句,但登录这个分支是十年前写的老逻辑,当年没有参数化的概念。后来的人改过一次登录逻辑,只改了新的校验分支,老的拼接分支一直留在那里没人敢动。
这其实是很多老系统的通病:问题不是“不知道要用参数化”,而是“改不动、不敢改、没人改”。修复这样的模块,不能只是在当前分支上补一个PreparedStatement,还要全局搜索同类写法,把同模块里的其他拼接点一并处理掉,否则就是“补了东墙漏了西墙”。
5. 防御复盘:修复不是把参数改成问号就行
很多人觉得防 SQL 注入就是“把字符串拼接改成问号占位符”,这话只说对了一半。参数化是核心,但它不是全部。真正完整的修复,要覆盖到所有不规则的角落。
5.1 一个完整修复该覆盖的边界
先看最常见的值参数化。这段代码是安全的改法:
// 修复前 String sql = "SELECT * FROM user WHERE name LIKE '%" + name + "%'"; ResultSet rs = stmt.executeQuery(sql); // 修复后 PreparedStatement ps = conn.prepareStatement("SELECT * FROM user WHERE name LIKE ?"); ps.setString(1, "%" + name + "%"); ResultSet rs = ps.executeQuery();注意一个细节:修复后%是在参数里拼的,不是在 SQL 文本里拼的。很多新人会把代码写成LIKE '%?%',这是错误的,因为问号只能替代整个值,不能作为字符串片段嵌在 SQL 文本里。同样的原则也适用于排序字段、表名、列名这类不能参数化的标识符。我的做法是做一个白名单映射:前端传sort=name,后端只允许name、time、id这几个固定值,其他输入一律拒绝。这样即使标识符不能参数化,也没有拼接空间。
5.2 架构层面的第二道防线
防御不能只靠某一层。数据库账号的权限设计在实际项目中经常被忽略:应用连数据库的账号,往往拥有整个库的SELECT、UPDATE、DELETE权限。一旦注入点被利用,攻击者拿到的是一个“合法账号”,而不是提权之后的特权。我的建议是:应用账号只保留业务真正需要的权限,删除权限能不给就不给;如果业务拆分得细,就按模块拆账号,一个前台查询服务不需要拥有后台管理表的权限。
错误信息脱敏也是很重要的一环。很多注入能被快速利用,靠的是页面把数据库原始报错信息直接返回给用户。开发的时候这些信息能帮你调试,但生产环境必须统一换成通用的异常提示。对安全测试者来说,看到错误信息能加速判断;反过来,作为防御方,你要做的就是让对方判断变慢。
5.3 让检测和监控有迹可循
修复之后还不能算完。注入攻击的特征会反映在多个层面:慢查询日志里可能突然出现大量异常条件查询;应用错误日志里可能残留 SQL 异常堆栈;安全网关的拦截记录里可能有明显的注入特征。我的习惯是修复完成后,把这几类日志的规则配好,并定期人工抽查,而不是等漏洞被真正利用之后才去翻日志。
这里多说一句:MySQL 之类数据库默认的日志级别不一定能记录下所有 SQL,需要确认配置是否开启。很多系统问题不在于没有日志,而在于日志量太大、没人看。防御链路里,“看日志的人”往往是最缺的一环。
6. 笔记最后:测试不是破坏
到了这篇笔记的收尾部分,我想再把自己这几年反复遇到的事情梳理一遍。SQL 注入涉及的核心知识点其实不复杂,但它牵扯到测试伦理、边界判断,经验越多人越会谨慎。
6.1 授权是唯一的前提
任何针对 SQL 注入的验证,都必须在有明确授权的前提下进行。这里的“授权”不是口头答应就行,而是要落实到文档和流程里:测试范围写清楚、测试时间段明确、允许使用的手段边界划好。我在自己的团队里有一条铁律:没有书面授权的情况下,不对任何不属于自己管理的系统做注入测试。自己练手就搭一个本地靶场或者授权测试环境,这不是死板,是对自己和他人负责。
6.2 三类容易误判的情况
第一类是“加了单引号没反应就等于安全”。前面说过,统一异常处理、WAF 拦截、输入过滤都可能导致单引号测试没有可见反馈。没反应只代表当前探测方式无效,不代表漏洞不存在。第二类是“自动化工具报告了漏洞就直接写报告”。工具的误报率并不低,尤其是碰到了后端编码转换、JSON 解析差异、参数类型校验这类场景,很容易把普通异常当成注入。工具是辅助,最终确认还是要靠人工看请求和响应之间的关系。第三类是“报错信息越多,漏洞越真实”。实际上有些框架会把任何非法输入都报成同一个数据库错误模板,这种信息并不能证明拼接存在。只有当你看到 SQL 片段跟着输入变化而变化时,才算拿到了有效的信号。
6.3 一个我保留到现在的工作习惯
最后说一个我自己养成的习惯:拿到修复后的代码,不急着看方法名,先全局搜索字符串拼接 SQL 的位置,无论是+拼接、format、还是concat,全部搜出来过一遍。因为绝大多数修复遗漏都发生在“看起来无关紧要”的代码位置。翻完一遍,再按列表逐条核对修复状态。这个习惯帮我挡掉了好几次事故,也让我意识到,SQL 注入的防御不是一次性动作,而是每个项目周期都值得做一次的动作。
总结成一句话就是:别把安全测试当成一场炫耀技巧的游戏,把它当成一项需要收尾、需要交付、需要负责的工作。这样多年下来,你积累的不只是绕过经验,还有一整套判断和防御的完整思路。