☰
SQL注入专项排查实战:从代码审计到参数化修复的完整流程
2026/10/20 23:13:05 网站建设 项目流程

接到“检查SQL是否存在SQL注入问题”这种需求,通常背后就两种场景:系统要过安全验收,或者漏扫刚刚报了一屏高危告警。SQL注入这个词,搞技术的都不陌生,可真正接手一堆业务代码时,很多人第一反应是全局搜“select”再人工看两眼,看完就没了下文。我最近刚好完整做了一次SQL注入专项排查,从确定排查范围、代码审计、动态验证到修复复测,全程走了一遍。这篇就把这次排查流程和心得完整记录下来,希望能给需要做同类检查的人一份能直接照着走的参考。

1. 排查思路:先理清检查覆盖的范围

在动任何工具之前,先想清楚这次检查到底是人工代码审计,还是配合自动工具做扫描。两者目的不一样:工具能拉出疑似点,但真正判断“能不能利用”以及“修复影响有多大”,还是得落到人工。我这次是按“先摸清攻击面,再定位代码,再动态验证,最后修复复测”的顺序执行的。顺序不能乱,因为跳过攻击面直接搜代码,容易漏掉藏在底层公共方法里的问题。

1.1 检查对象:先分清哪些代码必须看

第一次做这种检查,别把整个代码仓库不分主次地翻一遍,那样效率太低。先按下面的清单圈定重点:

  • 业务接口层代码:常规RESTful接口、老旧的Servlet、WebService这类入口,尤其是带增删改查的接口。
  • 报表和统计分析模块:为了支持灵活查询,这个模块几乎都会动态拼条件,是重灾区。
  • 管理后台接口:后台容易被忽略,可后台的权限边界往往更宽松,一旦出了问题等级只高不低。
  • 存储过程、函数、触发器:这些数据库对象内部也允许动态SQL,尤其是一些老系统为了兼容多处调用,把复杂逻辑包在存储过程里。
  • 定时任务和批处理程序:处理外部导入文件后再拼接SQL入库的情况,我见过不止一次。
  • 日志采集与数据同步程序:它们本身也连库,如果拼接了外部输入,一样要排查。

有人会问,为什么不把全部代码都看一遍?因为SQL注入的本质是“外部数据变成了SQL语句的一部分”,排查范围应该跟着外部数据能够流入SQL语句的路径走,而不是追踪所有代码。完全不触碰外部输入的内部工具代码,优先级很低,可以放在最后,也可以不看。

1.2 从入口到出口追踪SQL链路

我习惯把一条SQL从请求到数据库之间的路径叫“链路”。入口一般有这些:URL的GET参数、POST的queryString和body、请求头里的Cookie和自定义Header、上传的文件名、第三方回调报文、定时任务读取的文件内容。出口则集中在几个地方:JDBC的Statement和PreparedStatement、MyBatis的Mapper XML、JPA的JPQL和NativeQuery、Python的cursor.execute、PHP的mysqli和PDO、Node.js的mysql库。只要能从某个入口绕到某个出口,就可能存在注入链路。

这里有个很实用的办法:先找到底层那个执行SQL的方法,再倒着往上看参数来源。比如我发现一张报表SQL里有硬拼进去的“ORDER BY”,顺着这个参数往上追,到了页面筛选器的一个下拉选项,那这个参数就可能是缺陷点。之所以倒着追,是因为正着看代码很难覆盖所有调用路径,而底层出口往往就那几个,从这里反向梳理可以做到不漏。SQL注入的原理也可以简化成一个类比:本来你只是把参数当“值”传给SQL,因为拼接,这个“值”混进了SQL的“语法”里,等于把用户输入变成了代码的一部分。

2. 手工排查:直接在代码里找问题

2.1 字符串拼接的典型特征

手工阶段不需要高深工具,先直观看代码。最典型的危险写法,就是把外部值直接拼进SQL文本。拿几种常见语言举例:

String sql = "SELECT * FROM product WHERE name = '" + keyword + "'";
sql = "SELECT * FROM users WHERE email = '%s'" % email
$sql = "SELECT * FROM user WHERE username = '" . $_GET['name'] . "'";

这种以字符串拼接方式构造、再拿给数据库执行的代码,基本可以第一时间标记为待确认。但要注意,不能一看到拼接就报漏洞,关键判定标准是可执行SQL的内容里,是否包含了可能受外部影响的部分。如果只有表和字段名是常量拼接、没有外部输入参与,那就是安全的;如果有变量、有集合里动态取出的值、有从请求参数直接带入的内容,就要慎重。我还习惯用一条命令先暴力捞一遍危险API,凡是出现executeQuery、executeUpdate、prepareStatement的地方全部列出来,再人工过滤,这个动作虽然粗糙但很省事。

grep -rn "executeQuery\|executeUpdate\|createQuery" --include='*.java' . | grep "+\|concat\|format"

这条命令会筛出Java代码里靠近拼接操作的SQL执行点,虽然会误报,但作为第一轮排查的漏斗挺好用。Python项目我会换成搜cursor.execute配合f-string、%占位符。PHP项目则搜mysqli_query配合点号连接。

2.2 框架使用中$与#的天壤之别

以Java生态里最常见的MyBatis为例,多数注入都出在${}这个符号上。简单说,${}是文本替换,把传入参数原封不动拼进SQL;#{}是预编译参数占位符,解析成问号后由绑定变量提供值。检查Mapper XML时,我的标准动作是搜索所有包含${的片段,然后逐个区分用途。

常见的危险写法是这样的:

<select id="findByKeyword" resultType="map"> SELECT * FROM article WHERE title LIKE '%${keyword}%' </select>

改成参数化之后是这样:

<select id="findByKeyword" resultType="map"> SELECT * FROM article WHERE title LIKE CONCAT('%', #{keyword}, '%') </select>

这个差异对开发同学来说天天见,但真到排查时,量一大就容易看漏。我见过有人在order by ${sortField}和group by ${groupField}里用${},理由是字段名没法参数化,这种说法本身没错,但解决方案不是放任拼接,而是做字段白名单校验。这一块很多扫描工具会报,但误报率也高,因为工具分不清这个字段名到底来自常量还是来自请求参数,所以还是要人来判断。另外,如果项目里用Hibernate或JPA,也要注意有没有用createNativeQuery却不调用setParameter的情况,以及把参数直接拼进JPQL的写法,这两类问题同样致命。

2.3 容易被忽略的拼接来源

除了常规URL参数,有几个非常规入口经常被人忽略。第一个是请求头,比如X-Forwarded-For这个字段,很多系统会把它写进审计日志,日志再同步到数仓做分析,分析SQL如果直接拼了这个字段,等于注入链路绕了一大圈还是被打了。第二个是登录态里的用户信息,比如从session或token里取userId、username然后直接拼SQL,这里的问题在于开发默认登录态是可信的,但一个系统内部不同角色之间也会有越权场景,攻击者完全可能操纵自己的登录信息。第三个是上传文件的文件名和路径,文件入库时如果文件名是用UUID加原始名拼接的,原始名到了SQL里就成了注入点。第四个是回调报文的JSON字段,比如支付回调里的订单号、第三方接口的原始报文,这些数据来自外部系统,一样不可信。

3. 动态验证:用实际请求确认漏洞

代码层发现问题后,不能直接写“存在SQL注入”就结束,还要做动态验证,证明这个问题真的能在运行环境里被触发。这一步的目的是排除那些代码看着有问题、但实际链路到不了的“假漏洞”,同时也给后续修复提供复现样本。

3.1 构造可复现测试样例的步骤

动态验证要在测试环境做,千万别直接在生产库上跑。我一般的做法是先用一个正常请求把接口调用通,拿到正常返回结果;再构造几组差异明显的请求去对比。以常见的商品查询接口为例,正常请求是这样的:

http://127.0.0.1:8080/product/detail?id=10

接着分别请求下面这四组:

http://127.0.0.1:8080/product/detail?id=10 AND 1=1 http://127.0.0.1:8080/product/detail?id=10 AND 1=2 http://127.0.0.1:8080/product/detail?id=10' OR '1'='1' -- http://127.0.0.1:8080/product/detail?id=10 UNION SELECT 1,2,3

构造样例时有个细节:数字型参数直接加“AND 1=1”就行,字符型参数要闭合前面的单引号,所以需要用到' OR '1'='1' --这种写法。这里的空格和注释符号很关键,之前看过有人把1=1写成了1=1后面没空格,结果被SQL注释符吃掉,整个请求变成正常请求,误判成没问题,这个坑一定要避开。

3.2 看响应差异判断是否存在注入

请求发出去之后,要观察响应差异。核心对比方式是看页面内容、状态码、响应时间三者是否出现规律性变化。我把这次排查时列的一张对比表简化一下放在这里:

请求预期正常响应存在漏洞时的表现
id=10商品10详情正常返回正常返回
id=10 AND 1=1商品10详情正常返回商品10详情正常返回
id=10 AND 1=2商品10详情正常返回返回空、报错或不同数据
id=10' OR '1'='1' --商品10详情正常返回可能返回所有商品或报错

如果第二行和第三行的结果明显不同,基本可以确认数字型注入存在。AND 1=1恒为真,所以返回正常,AND 1=2恒为假,整个条件为假,返回的内容就会变空或者被过滤。字符型的闭合写法一旦生效,会把整条SQL的条件变成恒真,导致接口吐出预期之外的数据集合。这里要注意,有些接口做了缓存,第一遍请求和后续请求结果可能不一致,所以每轮测试前最好加一个随机参数或者清一下缓存,避免被缓存干扰判断。另外,接口如果要求登录态,要先在请求里带上有效的Cookie或Token,否则所有请求都可能是一个统一的登录跳转,看不出任何差异。

3.3 盲注场景的验证细节

有些接口在查询出错时不会把异常信息返回给前端,页面永远是“查询失败”或者干脆返回空。这时候常规报错和页面差异可能不明显,就需要用盲注思路。盲注分布尔盲注和时间盲注两种。布尔盲注靠的是“条件为真和条件为假时页面内容有细微差异”,比如多了一行数据、少了一个字段,这种差异要仔细看响应体才能发现。时间盲注则靠数据库执行延时函数来判断,比如MySQL里可以用AND SLEEP(3)。如果请求正常返回是0.5秒,加上SLEEP(3)之后变成了3.5秒,说明注入点生效了。

用时间盲注时我有两个比较稳的做法。一个是用curl直接观察耗时,避免浏览器各种缓存干扰,命令是这样:

curl -o /dev/null -s -w '%{http_code} %{time_total}\n' 'http://127.0.0.1:8080/product/detail?id=10 AND SLEEP(3)'

另一个是测完一次之后隔几秒再测一次,看一下整体接口的基准耗时。因为数据库本身的慢查询、网络抖动都可能造成秒级延迟,单独一次3秒不能充分说明问题,多确认两次更稳。还要注意一个隐患:如果这个接口被很多请求打中,而SQL里真的有SLEEP,它会占用数据库连接,测试时要在低峰期或者专门的联调环境做。

4. 工具辅助:静态扫描与自动化探测

手工排查能找到确定性问题,但面对几千个文件的工程,单靠肉眼效率太低,工具辅助是必须的。我一般会把工具分成静态分析和动态探测两类,两类工具定位不同,配合使用能覆盖得更全。

4.1 静态分析工具怎么选、怎么用

静态分析工具的选型跟语言强相关,没有一把万能钥匙。Java项目我常用的是SpotBugs搭配find-sec-bugs插件,以及Semgrep;Python项目用Bandit;PHP项目可以用phpstan基础上的安全规则;Node.js项目用ESLint的security插件;Go项目用gosec。这些工具的共同点是能从数据流层面追踪“外部输入”是否流入了危险的SQL执行函数,比单纯的grep正则要聪明。

工具选好之后,规则集也很重要,建议直接引用CWE-89相关的规则,这是SQL注入的官方编号。把扫描结果导出来后,我会按严重级别排序,先看高危,再看中危。这里要提醒一下,静态工具的误报率高是常态,因为它无法准确理解所有业务上下文,经常会把“常量类拼接”当成漏洞报出来。所以工具结果只能当线索,最终确认还是要回到手工那一套流程。如果项目足够重要,预算也够,可以用CodeQL做污点分析,它能把source到sink的完整调用链画出来,省掉很多前后跳转看代码的功夫。但CodeQL的学习成本不低,而且扫描时间也长,一般我是在大型项目或者重点系统里才用。

4.2 动态探测工具的典型用法

动态探测环节,SQLMap是绕不开的。用它的好处是有很多内置的注入payload和检测技术,能自动识别布尔盲注、时间盲注、报错注入、联合查询等类型。我在拿到一个可疑接口后,会先用SQLMap跑一轮确认,典型的命令是这样:

sqlmap -u "http://127.0.0.1:8080/product/detail?id=10" --batch --level=3 --risk=2 --dbms=mysql

--batch表示自动选择默认答案,省得它在交互式提问时卡住;--level=3和--risk=2是用来增加检测深度和危险测试项的,正常排查够用。如果接口需要登录,还要加上--cookie参数把登录态带进去,不然全部请求都会被拦在认证外面,白白浪费时间。

用SQLMap有两个容易忽视的注意点。一个是工具探测payload时会生成大量异常请求,如果是生产环境,会直接影响线上接口可用性,所以必须先确认跑的是测试环境或者专门的联调环境。另一个是工具报出来的注入点类型,要跟手工构造的样例互相印证。我见过SQLMap报“可能存在时间盲注”,但实际是数据库连接池满了造成的延迟,这种就需要用手工复测确认,不能直接把工具输出当成最终结论。Burp Suite在这个环节的作用是抓包、改包、重放,用来补SQLMap覆盖不到的复杂场景,比如需要多步签名验签的接口、或者参数经过了JSON序列化的接口。

5. 修复方案:按风险等级逐个击破

排查确认问题后,修复方案不能一把梭。我通常是按改动量从下往上推进:优先修能参数化的,对无法参数化的排序字段做白名单,再配合权限和网络层防御做兜底。这样即使前端的防护被绕过,后面的权限控制也能限制损失范围。

5.1 第一步:改成参数化查询

参数化查询是根治SQL注入最核心的手段,它把用户输入当作纯数据而不是SQL语法的一部分,数据库驱动会负责转义和类型处理。以Java JDBC为例,修复前是这样:

Statement stmt = conn.createStatement(); ResultSet rs = stmt.executeQuery("SELECT * FROM user WHERE id = " + id);

修复后是这样:

PreparedStatement ps = conn.prepareStatement("SELECT * FROM user WHERE id = ?"); ps.setString(1, id); ResultSet rs = ps.executeQuery();

这里有一点要说清楚:仅仅把Statement换成PreparedStatement还不够,如果你仍然用字符串拼接去构造SQL给preparedStatement,等于白改。必须要用?占位符,再用setString、setInt去赋值,才能真正起到防护作用。对于动态的IN子句,占位符数量是变化的,常见做法是手动拼接问号的数量,但值依然通过set参数传入,这样拼出来的部分是固定占位符语法,不是拼接用户输入,属于安全写法。

5.2 第二步:输入校验和边界处理

参数化能解决绝大多数问题,但像order by、group by、表名和字段名这类SQL结构部分,没法用占位符绑定。这类场景我的处理方式是白名单校验,而不是黑名单过滤。比如前端传排序字段过来,先把这个字段名拿去跟允许的字段列表比对,不在列表里就直接拒绝请求,返回参数错误。

String[] allowColumns = {"id", "price", "created_at"}; if (!Arrays.asList(allowColumns).contains(orderColumn)) { throw new IllegalArgumentException("非法排序字段"); }

还要注意类型强校验这个细节。很多SQL注入出现在数字型参数上,就是因为程序拿着字符串当数字用,前端传id=abc也能拼进SQL。修复时要按照字段类型做转换,比如id就统一用整数解析,解析失败就拒绝。给参数加长度限制很有必要,比如用户名最多30个字符,超过这个长度的大概率不是正常业务请求,直接拦截能减少很多无谓的拼接风险。

5.3 第三步:权限与纵深防御

我之前就强调过,安全不能单点依赖。代码改了参数化之后,还建议做一次权限收缩。应用连数据库的账号,只需要给它增删改查的权限就够了,不要用拥有DDL权限的账号,更不要用数据库管理员账号跑业务。这样即使某个点没修干净,攻击者也干不了drop table、读文件这类破坏性操作。

数据库端的错误信息要尽量隐藏,不要把完整的堆栈和SQL语句回显给前端。有些系统和SQL日志里会把完整SQL打出来,包括参数值,这也是一种信息泄露,建议在日志输出层做脱敏。网络层还能加一层数据库防火墙或者WAF,不过我的态度是,防护层层追加可以,但不能因为有了WAF就觉得代码不用修。WAF规则绕过案例太多了,参数化修复才是根本。

6. 排查记录与避坑清单

排查工作做完之后,输出一份记录很重要,不光是给团队看,也是给自己留个底。我习惯把排查过程中遇到的典型问题整理成一张速查表,后续做复测或者同类项目检查时直接拿出来对着看,效率高很多。

6.1 常见问题排查速查表

排查现象可能原因处理方式
MyBatis的XML里出现${}参数被文本替换进SQL改为#{}绑定;无法改的排序字段走白名单
接口参数直接拼进order by排序字段不可参数化,被裸拼限定字段白名单,拒绝非法值
后端用字符串连接SQL再执行未使用参数化查询一律改用PreparedStatement
LIKE查询拼接%参数%LIKE占位符在MySQL中不能直接绑定用CONCAT拼接占位符
IN子句动态拼接列表占位符数量动态变化按列表长度生成?,参数逐条绑定
存储过程内部使用动态SQL外部输入传入存储过程内部在过程内部使用绑定变量或重写逻辑
请求头或文件名字段进入SQL非常规入口被忽略增加入口字段WAF校验,并参数化处理

这张表只是起点,每个团队的项目结构不同,最常见的几种问题基本都能覆盖住。我在做排查记录时,还会把每个漏洞对应的请求样例、响应差异截图、代码位置一起归档,这样后面复测时直接拿同一组样例再跑一遍,可以快速验证修复是否生效。

6.2 实测踩过的几个坑

第一坑是只搜static开头的查询语句,忽略了insert、update、delete语句。SQL注入不止发生在查询里,更新语句被注入后果同样严重。有一次我在一个后台用户编辑功能里找到update语句拼接了登录用户名,攻击者可以直接修改其他用户的密码字段,这种危害比单纯数据泄露更直接。

第二坑是测试时没有带登录态。有些接口鉴权在网关层做,不带Cookie的请求会在网关被统一重定向,你测出来的“正常响应”其实是登录页,跟SQL没关系。这会导致两种情况:一种是明明有注入却因为登录页没变化而漏报,另一种是误报了其实根本不存在的漏洞。所以动态验证前,先确认请求真的到达了业务处理逻辑。

第三坑是忽视二次编码问题。有些接口会做URL解码、JSON转义、Base64解码,用户输入可能经过两次编码之后再进SQL。你测试时直接发' OR '1'='1' --没起作用,但在某一段编码逻辑里又被还原成了注入语句。排查这类问题要看参数从请求到SQL之间经历了多少层转换,每层转换都可能绕过简单的关键字拦截。

第四坑是修完不回归。我见过团队花了很大力气把SQL改成参数化,但由于代码里还有其他地方调用同一个方法,导致改完之后功能直接报错。修复之后一定要把业务主流程、边界条件、异常分支都回归一遍,尤其要测试空值、超长值、特殊字符这些用例,参数化之后有些隐式类型转换行为会变化。

7. 排查后的几点个人体会

做SQL注入排查做得多了,我最大的体会是:排查本身不难,难的是把排查方法沉淀成团队规范。这次从审计、验证到修复的整套流程走下来,最耗时间的反而是“说服自己某个点真的修干净了”这件事。建议每次排查完,都把典型的漏洞代码和修复后的代码存成对照样本,下一次再遇到同类问题,直接套用。

最后再分享一个小技巧。给所有对外接口统一加一层请求日志,把参数原文、处理链路、数据库执行时间都记录下来,这个动作看似是运维的活,但在安全排查时帮了大忙。一次SQL注入排查里,我就是靠日志里的SQL文本比对,锁定了三个原本肉眼根本注意不到的问题点。代码审计、动态验证做完之后,这些日志还能当作修复后的复测依据,一举多得。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询