只要写过几年代码,几乎没人没踩过SQL注入的坑。我自己第一次真正意识到SQL注入的杀伤力,是在一个已经上线两年的后台管理系统上,登录框里随手输了一个单引号,整个页面直接抛出一段完整的数据库语法错误,那一瞬间后背发凉——你的数据库到底暴露在多大的风险之下?后来我又在不少企业内网系统、外包项目甚至一些知名数字化系统的公开漏洞信息里反复看到同一个词:SQL注入。比如热词里那个“喰星云·数字化餐饮服务系统 not_out_depot 接口 SQL 注入漏洞”,本质上就是我当年在后台登录框里遇到的那类问题的工业化版本。
这篇文章我打算一次性把SQL注入讲透:它到底是什么、为什么能穿破层层防御、手工注入的完整套路是什么、盲注场景怎么应对,以及最关键的——到底怎么样才能真正防住它。适合刚入门安全测试的开发者、正在学 Web 安全的学生,以及所有写 SQL 或调接口的后端程序员阅读。我会把原理掰开揉碎,配靶场实操,最后给出一套可落地的防御方案。
1. 漏洞原理:从一条“很普通”的登录SQL说起
1.1 一条登录语句是怎么被攻破的
先看最基本的情况。几乎所有新手项目里,登录功能都长这样:
SELECT * FROM users WHERE username = 'admin' AND password = '123456'这段 SQL 逻辑很清晰:如果查到了记录,说明用户名和密码匹配,允许登录。问题出在 username 和 password 的值是从前端表单接收的,后端代码通常把接收到的字符串直接拼接进 SQL。用伪代码表示就是:
username = request.POST['username'] password = request.POST['password'] sql = "SELECT * FROM users WHERE username = '" + username + "'" + " AND password = '" + password + "'" result = db.query(sql)这时候,如果用户输入的 username 不是admin,而是一段精心构造的字符串,比如:
admin' or '1'='1拼进 SQL 之后,原本的语句就变成了:
SELECT * FROM users WHERE username = 'admin' or '1'='1' AND password = '123456'注意,or的优先级低于and,所以这条语句的判定结果取决于三者组合后的布尔值。因为1=1恒为真,整个 where 条件恒为真,数据库就会把users表里的第一行记录返回。如果恰好第一行是管理员账号,那攻击者就什么都不用知道,直接以管理员的身份登录后台了。这就是热词里反复出现的“SQL注入万能密码绕过”的底层原理。
1.2 为什么“拼字符串”是万恶之源
很多人会把 SQL 注入归咎于“没有过滤特殊字符”,这是一种常见的误解。真正的根源不是某个单引号,而是代码把用户的输入当成了 SQL 代码的一部分来执行。正常情况下,用户在登录框输入的admin应该被当作“数据”来处理;但当它被直接拼接进 SQL 文本之后,admin' or '1'='1里的引号、or、等号都被 SQL 解析器当成了“代码”来理解。
你可以把 SQL 引擎想象成一个严格执行命令的机器人。你给它一段指令,它不理解“这段是用户数据”和“这段是业务逻辑”的边界,只要语法合法,它就会照单全收。开发者如果没有人为制造这层边界,用户输入就能混进指令区。
这也是为什么“过滤单引号”不是根治方案。你过滤了单引号,攻击者可以用宽字节、编码绕过、注释符/**/来代替空格,甚至用数据库内置函数来替代被过滤的关键字。道高一尺,魔高一丈。真正要让用户输入始终以“数据”身份参与 SQL 执行,需要的是预编译和参数化查询,这个我在后面防御部分会展开讲。
1.3 SQL注入的危害到底有多大
SQL 注入的后果远不止“后台被绕过”这么简单。以 MySQL 为例,一旦注入点存在,攻击者可以做的事情包括:
- 越权读取数据:通过联合查询读取任意表的数据,包括用户表、订单表、支付记录。
- 篡改数据:通过注入执行
update、insert、delete,比如把自己的余额改大,或者把别人的订单改成已支付。 - 拖库:配合
information_schema元数据库,把整个数据库的表结构、数据全部导出。 - 写文件/读文件:在部分配置下,可以通过
into outfile往服务器写入 WebShell,获得服务器权限。也可以读取服务器上的敏感文件。 - 拒绝服务:通过堆叠注入或复杂查询拖垮数据库,导致业务不可用。
用一个生活化的类比:你家的保险柜门上留了一条缝,攻击者不仅能透过缝看到里面的东西,还能伸进一根足够长的铁丝,把你放在保险柜旁边的钥匙也勾出来。这条缝就是未参数化的 SQL 拼接点。
2. SQL注入的常见分类与识别特征
2.1 字符型、数字型、搜索型怎么区分
不同注入点在拼接方式上有所差异,所以检测和利用方式也不同。最基本的分类:
数字型注入。后端直接把参数拼进数字比较中,例如:
SELECT * FROM products WHERE id = 1如果输入1 and 1=1还能正常返回,输入1 and 1=2返回为空,说明这里的参数是直接参与数值运算的,不需要闭合引号,判断相对简单。
字符型注入。后端把参数用引号包裹,例如:
SELECT * FROM products WHERE name = 'phone'攻击者需要先闭合前面的引号,再构造后续逻辑。比如输入phone' or '1'='1,拼进去后变成:
SELECT * FROM products WHERE name = 'phone' or '1'='1'搜索型注入。后端在参数前后都加了模糊匹配的通配符,例如:
SELECT * FROM products WHERE name LIKE '%phone%'这种注入点的闭合方式通常是:
phone%' or '1'='1' or '%'='拼接后变成:
SELECT * FROM products WHERE name LIKE '%phone%' or '1'='1' or '%'='%'判断一个注入点是哪种类型,核心方法是看输入额外符号后页面的反应。输入单引号后页面报错、白屏、或返回结果异常,那八成存在字符型注入;输入1 and 1=1与1 and 1=2返回结果不一致,则是数字型或可闭合的数字型注入。
2.2 联合查询、报错、布尔、时间、堆叠的区别
确认注入点之后,接下来的问题是如何把数据“取出来”。不同场景下数据回显的方式不同:
联合查询注入(Union Based)。目的是通过union select把查询结果和开发者原本的查询结果合并到一起,然后让数据直接展示在页面上。前提是页面存在回显位置,且前后查询的列数一致。
报错注入(Error Based)。适用于页面没有直接数据回显,但会把数据库错误信息显示出来的场景。通过updatexml()、extractvalue()等函数,人为构造一个让数据库报错的表达式,让错误信息里携带子查询的数据。核心思路是:让数据库自己把答案念出来。
布尔盲注(Boolean Based)。页面不回显数据,也不回显错误,但可以通过and 1=1与and 1=2时页面内容的差异来判断条件真假。通过逐字符比较,把数据库信息一位一位地猜出来。
时间盲注(Time Based)。比布尔盲注更极端,无论真假页面内容都几乎完全一样。这时候利用sleep()等函数,让数据库在条件成立时延迟返回,通过响应时间的差异来确认条件是否成立。
堆叠注入(Stacked Queries)。在部分数据库环境中,分号可以分隔多条 SQL 语句。攻击者可以在原查询后追加; drop table xxx;这类语句,实现多条语句的执行。危害很大但利用条件也高,很多数据库驱动默认不允许在一条执行语句里包含多条 SQL。
给一张表理清这几个类型的关系:
| 注入类型 | 判断条件 | 利用方式 | 适用场景 |
|---|---|---|---|
| 联合查询 | 页面有回显位置 | union select 语句 | 最优先尝试 |
| 报错注入 | 页面显示数据库错误 | updatexml、extractvalue | 无回显但报错可见 |
| 布尔盲注 | 真假条件页面有差异 | 逐个字符判断 | 无回显无错误 |
| 时间盲注 | 真条件延迟返回 | sleep() 与条件组合 | 页面几乎无差异 |
| 堆叠注入 | 支持多语句执行 | 分号追加语句 | 危害最大但限制也多 |
2.3 一条通用判断注入点的思路链路
我在实战和带新人时,总结出一条判断注入点的固定套路,适合用在靶场和授权测试场景:
第一步,给参数增加一个单引号',看是否报错或页面异常。 第二步,输入1' and '1'='1与1' and '1'='2,看返回结果是否有差异。 第三步,根据差异判断是数字型还是字符型,或者是否存在其他闭合方式。 第四步,尝试用order by 数字判断查询列数,从 1 往上加,直到报错为止。 第五步,用union select 1,2,3,...找到回显位。 第六步,基于回显位查询库名、表名、字段名、数据。
这套流程对应到实际靶场里,就是热词里 pikachu 靶场通关、ctfshow 入门题 221 这类题目的核心思路。
3. 手工注入实战全流程:从判断注入点到拿到数据
3.1 手工注入的完整思路链路
以 pikachu 靶场的字符型注入为例,演示一遍完整流程。注意:以下操作仅在本地靶场或授权测试环境中进行。
打开 pikachu 靶场的“SQL-Inject > 字符型注入”关卡,存在一个正常的查询入口,输入kobe,页面会返回对应球员的信息。
第一步,先输入一个单引号:
kobe'页面直接报错,说明输入的引号被拼接进了 SQL 语句,破坏了原有语法。
第二步,判断闭合与真假条件:
kobe' and '1'='1 kobe' and '1'='2前者正常返回数据,后者返回空,说明这里存在字符型注入点,我们的输入可以通过引号闭合后修改 SQL 逻辑。
第三步,用 order by 猜列数。这里表达的是在原有查询基础上增加排序字段:
kobe' order by 2 -- ' kobe' order by 3 -- '注意,MySQL 中的--后面必须带一个空格(或者用#注释符)。当 order by 后面的数字超过实际列数时,页面会报错。通过二分法快速定位出当前查询的列数。假设第 3 列报错,说明只有 2 列。
第四步,构造 union 查询确认回显位:
kobe' union select 1,2 -- '如果页面中出现数字 1 和 2,说明这两个位置是回显点。接下来就可以用这些回显点查询数据库信息。
3.2 一步步爆库、爆表、爆字段、爆数据
拿到回显位后,接下来的路径非常固定。以 MySQL 为例:
查询当前数据库名:
kobe' union select 1,database() -- '查询当前数据库下所有表名:
kobe' union select 1,group_concat(table_name) from information_schema.tables where table_schema=database() -- '查询指定表中的字段名:
kobe' union select 1,group_concat(column_name) from information_schema.columns where table_name='users' -- '查询数据:
kobe' union select 1,group_concat(username,password) from users -- 'group_concat()是 MySQL 的一个聚合函数,能把多行结果合并成一行,用逗号分隔。这在手工注入里非常实用,因为大多数注入点在页面上只回显一行内容,不用它的话就得用limit一条一条地看。
这里补充一个细节:为什么查询表名、字段名要依赖information_schema?因为它是 MySQL 自带的元数据库,里面记录了所有数据库、表、字段的元信息。对攻击者来说,这就是数据库的“地图”;对防御者来说,这也是为什么不能给数据库账号过多权限的原因之一。
3.3 手工注入常用的几个核心语句
整理一下手工注入过程中必须熟练的几个语句,写顺手了基本可以应对大多数靶场题目:
-- 判断注入点与闭合方式 1' and '1'='1 1' and '1'='2 1' or '1'='1 -- 判断列数 1' order by 1 -- ' 1' order by 2 -- ' 1' order by 3 -- ' -- 判断回显位 1' union select 1,2,3 -- ' -- 查询库名 1' union select 1,database(),3 -- ' -- 查询表名 1' union select 1,group_concat(table_name),3 from information_schema.tables where table_schema=database() -- ' -- 查询字段名 1' union select 1,group_concat(column_name),3 from information_schema.columns where table_name='xxx' -- ' -- 查询数据 1' union select 1,group_concat(username,0x3a,password),3 from xxx -- '0x3a是冒号的十六进制表示,在 SQL 里充当分隔符,这样用户名和密码在回显时就不会糊成一团。这个小技巧看着不起眼,实际调试时会帮你省很多事。
4. 盲注场景下的手工注入思路
4.1 什么是盲注,和显错注入怎么区分
前面讲的联合查询注入依赖页面回显,但在实际渗透测试里,真正常见的场景是“页面什么数据都不显示”。你输入合法数据,页面返回正常;你输入单引号,页面也不报错,只是返回不正常的内容。这种“看不见答案”的注入就是盲注。题目和真实系统里,盲注往往比联合注入更常见,因为代码能撑到上线,大多做了最简单的错误隐藏,但底层 SQL 拼接问题还在。
区分是不是盲注,一个常用的方法是:在参数后面分别拼接and 1=1和and 1=2,观察页面内容是否一致。不一致,大概率是布尔盲注;完全一致但响应时间有明显差异,可能是时间盲注;既无差异又无延迟,可能这个点本身不存在注入,或者有更复杂的过滤。
4.2 布尔盲注:通过页面真假来“猜”数据
布尔盲注的思路是:把查询条件变成一个一个的“是非题”,让页面替我们回答“对”或“错”。
例如,当前用户对应的数据库名第一个字符的 ASCII 码是否大于 100:
1' and ascii(substr(database(),1,1))>100 -- '页面返回正常,说明大于;再试是不是大于 110:
1' and ascii(substr(database(),1,1))>110 -- '页面返回异常,说明小于等于 110。继续二分,最后确定第一个字符的 ASCII 码,转换成字符,就得到了数据库名的第一个字母。接着改substr的第二个参数,依次猜第二个、第三个字符,直到把整个库名猜完。
手工猜字符当然慢,但这个过程让我对“盲注的本质就是二分查找”有了很深的理解。在自动化工具里,sqlmap跑布尔盲注用的就是这个逻辑,只不过把人工二分换成了脚本循环。学会手工,你才不会在工具误报时束手无策。
4.3 时间盲注:用延迟让数据库“说话”
如果布尔盲注也行不通,比如and 1=1和and 1=2页面表现完全一样,那只能通过时间差异来判断。MySQL 中可以直接用sleep():
1' and if(ascii(substr(database(),1,1))>100,sleep(3),0) -- '如果数据库名的第一个字符 ASCII 码大于 100,这条 SQL 会执行sleep(3),页面响应时间会明显拉长到 3 秒左右;否则立即返回。通过反复比较响应时间,就能像布尔盲注一样逐字符猜数据。
时间盲注在实际网络中会受网络延迟影响,所以判断阈值一定要设置得比正常响应时间大足够多。我在靶场练习时习惯用 3 秒,真实环境下建议到 5 秒,避免误判。另外,测试时间盲注时尽量用curl配合-w "%{time_total}"看耗时,人工盯着浏览器转圈远不如命令行数值直观。
4.4 报错注入与常用函数原理
报错注入是盲注里相对省力的一种——如果页面会把数据库错误显示出来的话。以 MySQL 为例,最常见的是updatexml和extractvalue。它们的原始功能是解析 XML 字符串,但传入了非法的 XPath 表达式时,数据库会报错,并把错误信息返回在页面中。于是人们故意构造:
1' and updatexml(1,concat(0x7e,database(),0x7e),1) -- 'concat(0x7e,database(),0x7e)的作用是把数据库名和波浪号拼接起来。因为~不是合法 XPath 路径的起始字符,函数执行就会报错,错误信息里包含了完整的拼接结果——数据库名就这样被“报”出来了。
报错注入能查到数据,但要执行多条查询得一条条构造,比较繁琐。不少初学者总想找“万能的报错语句”,其实没有。不同版本的数据库函数不同,过滤规则也不同,理解原理比背 payload 有用得多。
5. 从真实漏洞看不安全编码的常见成因
5.1 一个典型的接口 SQL 注入漏洞长什么样
热词里提到的“喰星云·数字化餐饮服务系统 not_out_depot SQL 注入漏洞”,是最近比较典型的一类真实企业漏洞。这类系统的代码通常不是新手写的,能交付到生产环境,说明已经过了功能和验收测试,但依然爆出 SQL 注入,问题出在哪?
核心原因几乎总是同一个:某个接口接收了前端传入的参数,经过很浅的校验后,直接拼接进了 SQL。比如:
String depotId = request.getParameter("depotId"); String sql = "SELECT * FROM out_depot WHERE id = " + depotId;这个not_out_depot接口大概率就是类似写法——接收入库单、出库单编号一类的参数,开发者认为这个编号是内部系统生成的数字,不会有人恶意构造,于是既没做类型校验,也没用参数化查询。但对外暴露的 HTTP 接口意味着任何人都能提交任意字符串。攻击者提交1 and 1=1和1 and 1=2就能测出差异,后续就是查库、脱敏数据,甚至进一步提权。
如果你看过大量真实漏洞报告,会发现接近三分之一的注入漏洞来自这种“想当然”的参数。程序员脑子里的假设是:“这个参数是我自己系统传的,不会有人乱填。”但攻击者根本不看你的页面,他直接抓包改请求。
5.2 开发阶段“就近改一下”埋下的隐患
还有一种很常见的成因是系统演进过程中的“快捷方式”。原本系统用 MyBatis 的#{}参数化方式写得好好的,后来某个需求要求排序字段动态传入,开发者想省事,直接把列名用${}拼了进去。
${}和#{}在 MyBatis 里的差异,恰好就是 SQL 注入的分水岭。#{}会生成预编译参数占位符,用户输入永远只是参数值;${}则是直接把字符串拼进 SQL 文本,和当年用字符串拼接登录 SQL 没有本质区别。
不少团队做代码扫描时,发现的问题清单一拉,大量都是${}误用,还有相当一部分是“之前没想起来要改的预留功能”。所以排查 SQL 注入时,除了看当前代码,还要重点看历史迭代中哪些地方人为拼过 SQL,尤其是排序、批量插入、动态表名这一类追求灵活动态的操作。
5.3 修补这类漏洞的通用思路和注意点
遇到跑在真实系统上的 SQL 注入漏洞,修复顺序和操作要点如下:
第一,确认影响范围。先查这个接口被多少人调用过,日志里有没有异常参数,数据库里有哪些表可能被拖取。不要一上来就急着改代码,先判断有没有数据泄露,有没有被植入恶意命令。
第二,定位所有 SQL 拼接点。不仅修报出来的那个接口,还要把同样写法、同类参数的所有接口一并排查。用 IDE 搜索字符串拼接加 SQL 关键字的代码模式,或者直接用静态扫描工具扫一遍。
第三,改成参数化查询。最高优先级是把 SQL 语句改为预编译方式。Java 中用PreparedStatement,Python 中%s占位符配合 execute 传参,Go 中?占位符。这是换掉病根,而不是贴创可贴。
第四,补充输入校验与最小权限。接口层做白名单校验,比如depotId必须是正整数;数据库账号尽量只授予业务所需的最小权限,堵住利用注入去读information_schema或者写文件的路径。
第五,灰度验证并观察。修完后先跑一遍正常业务,再用注入 payload 做回归验证。别一改完就发版,可能漏掉一些边界场景。
6. 防御方案:别只在输入框上做文章
6.1 参数化查询:根治 SQL 注入的第一选择
SQL 注入的根本原因是“代码与数据不分家”,而参数化查询做的事情,恰恰是把代码和数据在语法层面彻底分开。以 Python 为例:
# 错误写法:字符串拼接 sql = "SELECT * FROM users WHERE username = '" + username + "'" # 正确写法:参数化查询 sql = "SELECT * FROM users WHERE username = %s" cursor.execute(sql, (username,))第二种写法里,%s是占位符,数据库驱动会把username作为纯参数值传给数据库,而不是先拼接成 SQL 文本再解析。无论用户输入里有什么引号、or、注释符,数据库都把它当作一个字符串值来比较,根本没有机会变成 SQL 语法的一部分。
Java 的PreparedStatement、PHP 的PDO prepare、Go 的database/sql也都有对应的参数化方式。如果只用一句话总结 SQL 注入防御,我优先选择:凡是用户可控的数据,一律不允许直接拼接进 SQL 语句,必须走参数化查询。
6.2 输入校验:白名单永远比黑名单可靠
很多人一谈防御,第一反应是“过滤关键字”,比如把select、union、单引号都过滤掉。这种黑名单思路的最大问题是:你根本猜不全攻击者的全部变形手法。大小写绕过、注释符绕过、十六进制编码、双写关键字……随便翻翻安全工具的词库,就够过滤逻辑手忙脚乱了。
更稳妥的做法是白名单校验:先明确这个参数本身应该是什么格式。比如depotId就应该是数字,直接在接口层用正则限制成^\d+$;用户名虽然格式复杂一些,也可以限定字符集合和长度。判断规则越明确,攻击者越没有发挥空间。
当然,白名单校验是第二道防线,不能替代参数化查询。举个例子:如果一个排序字段order_column被白名单校验成只能是create_time或update_time,再拼接进 SQL 也不会引入注入;但如果第一个参数能过白名单但第二个参数没校验,照样出问题。防御纵深从来不是某一层做到位就够的。
6.3 权限控制与数据库账号最小化
SQL 注入能造成多大危害,很大程度上取决于数据库账号的权限。很多项目为了方便,直接用最高权限账号连接业务库,等于把整个数据库的钥匙挂在门口。一旦注入点被攻破,攻击者可以直接into outfile写 WebShell、删除所有表、读取服务器敏感文件。
正确的做法是权限分层:
- 业务读写账号:只具备业务库表的增删改查权限,禁止
FILE、PROCESS等敏感权限。 - 只读账号:供报表、查询类功能使用,只允许
SELECT。 - 管理账号:仅供 DBA 日常运维使用,绝不允许被业务代码调用。
给权限设限,本质上是在假设“所有代码都可能存在漏洞”的前提下,把每个漏洞能引爆的最大炸药量控制在最小范围。就算攻击者找到了注入点,也会发现连information_schema都查不了,连文件都写不了,利用链直接断掉。
6.4 纵深防御:从代码到数据库中间的几道关卡
除了参数化查询和权限控制,我建议在系统架构层面补上这几道关卡:
- ORM 框架的默认防护:使用 MyBatis、Hibernate、Django ORM 等框架时,默认生成的查询就是参数化的,但这不代表可以随意用原生 SQL。框架本身是盾牌,不要主动把它卸了。
- WAF 或网关过滤:在应用层前面加一层 Web 应用防火墙,对明显的 SQL 注入特征做拦截。它挡不住所有绕过,但能挡住绝大多数自动化扫描和攻击脚本。
- 数据库审计与日志监控:开启慢查询日志、审计日志,对异常 SQL 特征做监控告警,比如大量
union select、sleep、information_schema查询。事后发现总比永远不发现好。 - 敏感数据加密存储:即使库被拖了,密码字段有哈希加盐,银行卡号有加密或脱敏,也能把损失降到可接受范围。
这里说句大实话:WAF 不是银弹。攻击者完全可以想出一种 WAF 规则库里没有的编码方式绕过过滤。把防御希望寄托在一个外部产品上,不如先把代码层面能确定的变量先做好。
7. 常见问题与排查技巧实录
7.1 手工注入时最容易卡住的几个点
我经常看到初学者在靶场里卡在同一个地方,这里整理一份高频卡点清单:
卡点一:order by 报错但 union 也报错。多数情况是前后查询的列数不一致。解决方法是先用order by精确确认列数,再用同样数量的union select 1,2,3...构造。注意union前后 SELECT 列数必须完全相同。
卡点二:union 查询没回显。先确认前面的参数是否限制了结果集为空。在靶场里经常会用到让原始查询查不到数据、走 union 查询结果的技巧,比如把参数值写成一个不存在的 id,或者用and 1=2让前面查询返回空集。
卡点三:注释符没用。MySQL 的--注释符后面必须跟一个空格,不然会被认为是普通文本。实际上很多实战环境并不需要注释符,闭合引号后继续构造条件即可,比如kobe' and '1'='1就不需要注释。
卡点四:页面显示了数据库错误但不显示数据。优先尝试报错注入,用updatexml、extractvalue这类函数,而不是转过头去死磕联合查询。回显形式决定利用方式,报错也是回显的一种。
卡点五:盲注猜了好久没猜对。可以先猜库名长度,再逐字符猜内容,这样能少走很多弯路。另外养成脚本思维:手工确认前两个字符的规律后,直接用脚本循环代替手点,效率完全不一样。
7.2 授权测试中的行为红线
在靶场里怎么操作都行,但在真实系统上做安全测试,底线必须守牢:
- 必须有书面授权。未授权测试是违法行为,不管你的初衷是“帮忙看看漏洞”还是“练练技术”。
- 不拖真实数据。确认存在注入点、能读取到少量数据验证危害即可,绝不应该把整个用户表拖下来。
- 不做破坏性操作。禁止通过堆叠注入删表、改数据、写文件,即使你在测试时也不行。
- 控制测试频率。时间盲注会压测数据库响应,单次测试请求过多可能影响正常业务,要控制频率。
这些红线不仅保护系统,也是在保护你自己。做安全测试,技术只是基础,职业操守和判断力才是立身之本。
7.3 开发者自查清单:从代码中发现 SQL 注入
如果你是开发者,想提前排查项目里的 SQL 注入隐患,可以从这几个维度自查:
- 全局搜索字符串拼接加 SQL 关键字的代码,重点看
+、${}、%s、concat等模式。 - 检查所有
order by、group by、动态表名、动态字段名是否用白名单校验。 - 检查数据库账号权限,是否只有业务需要的增删改查。
- 查看日志和监控,有没有异常的 SQL 查询特征:
- 大量
union select sleep()、benchmark()函数information_schema元数据查询- 单引号密集出现的参数
- 大量
现在很多 CI/CD 流程里已经集成了 SAST 静态扫描,能自动识别大部分危险拼接模式。但工具不是万能,我最推荐的还是人工 code review 时重点关注两条线:一处是“用户输入到 SQL 的传递链路”,另一处是“非参数化查询的所有使用位置”。
写在最后
做了这么多年安全工作,踩过不少坑,也看过不少被拖库后的复盘报告。SQL 注入这个漏洞,从原理上说一点都不难,难的是开发者始终在“相信用户输入”的思维惯性里打转。每次看到定位是not_out_depot这种业务接口出现注入漏洞,我都不意外——只要还有人图省事直接拼 SQL,这个老对手就不会消失。
我个人的实操体会是:防御 SQL 注入,不要寄希望于某个单一手段。参数化查询解决病根,白名单校验兜住异常,权限控制压缩影响面,审计监控负责事后发现。四个环节都做到,就算某个环节失效,其他环节也能把损失控制住。如果你现在正在写 SQL 拼接代码,听我一句劝:停手,改用参数化。这可能是你今天做的成本最低、收益最高的一次安全投资。