本篇教你:拿到一个项目的源码,怎么找出里面有没有 SQL 注入漏洞。不需要你会开发,只要你能看懂代码在”把什么数据交给谁”。
先修知识
第一次接触代码审计的同学,先把这些词认熟,后面不再解释:
- SQL:操作数据库的指令语言,比如
SELECT * FROM user意思是”把 user 表全部查出来”。类比:你写给仓库管理员的取货单。 - SQL 注入:攻击者把 SQL 语法片段混进正常输入里,让数据库把”数据”当成”指令”执行。类比:取货单的”备注栏”里写了”顺便把保险柜也打开”,管理员照做了。
- 代码审计:直接读源代码找漏洞,而不是靠黑盒扫描器乱撞。类比:不看防盗门结不结实,直接看图纸找设计缺陷。
- Source(源头):用户能控制的输入进入代码的位置,比如
$_GET['id']、request.getParameter("name")。 - Sink(汇聚点/危险函数):真正执行危险操作的位置,本篇里就是”执行 SQL 的函数”,比如
executeQuery()、mysql_query()。 - 数据流追踪:从一个变量出发,顺藤摸瓜看它从哪来、到哪去。审计的核心手艺。
- 拼接:用
+(Java)或.(PHP)把变量直接串进 SQL 字符串里。这是注入的温床。 - 参数化 / 预编译:SQL 先编译好骨架(值的位置用
?占位),数据后填进去,数据永远只是数据。类比:先印好表格模板再贴照片,照片不可能变成表格里的格子线。 - payload:攻击者构造的恶意输入内容,比如
' or '1'='1。 - 黑盒 / 白盒:黑盒=只看网站外在表现测试;白盒=直接读源码。本篇是白盒方法,最后教你怎么和黑盒互相印证。
审计工具
工欲善其事。审计 SQL 注入不需要什么花哨工具,三样就够:
- Java 反编译:
JD-GUI。很多甲方只给你一个.jar或.war包(编译后的产物),JD-GUI 能把里面.class字节码还原成接近原始的 Java 源码。- 怎么用:直接把 jar 文件拖进 JD-GUI 窗口,
File → Save All Sources导出成源码目录,然后当普通项目审。 - 同类替代:IDEA 自带的 Fernflower 反编译器(打开 jar 即自动反编译)、
jadx( Android 方向为主,jar 也能看)。
- 怎么用:直接把 jar 文件拖进 JD-GUI 窗口,
- IDE 全局搜索:VSCode / IDEA。比 grep 直观,双击搜索结果能直接跳到上下文看函数全貌。VSCode 快捷键
Ctrl+Shift+F全局搜。 - 命令行 grep。适合在服务器上或批量初筛,后面有现成命令。
审计思路(Source → Sink)
核心心法:跟着水走
SQL 注入审计的本质是数据流追踪:一滴”脏水”(用户输入)从进水口进来,流过多少管道,最后有没有流进”数据库执行”这个水龙头。
类比排查自来水污染:两个方向都能查——
- 正向(Source → Sink):从进水口(
$_GET、request.getParameter)出发,跟踪变量每一站流转,看最终是否流进 SQL 执行点。- 优点:覆盖面全,不遗漏。
- 缺点:工作量大。一个中型项目用户输入入口成百上千,每条都跟完不现实。
- 逆向(Sink → Source,实战推荐):反过来,先找到全市所有”水龙头”(危险 SQL 执行函数),再倒查它的水从哪来——参数用户可不可控?中间有没有过滤?
- 优点:目标明确,SQL 执行点数量远少于输入入口,效率最高。
- 新手就先练这个方向,本篇案例全部用逆向法演示。
注入成立的三个条件(判定公式)
回溯完一条数据流后,同时满足下面三条,才能下”存在注入”的结论,少一条都不行:
- 参数用户可控(Source 可达)——攻击者能决定这个变量的值;
- 参数以拼接方式进入 SQL(Sink 处无参数化)——变量被
+/.串进 SQL 字符串,而不是走?占位符; - 过滤可绕过或不存在——中间的处理形同虚设(黑名单不全、转义可宽字节绕过等)。
三条都满足 → 存在注入。只满足前两条但有过滤 → 继续分析过滤能不能绕,能绕照样算存在。
快速定位:字符串搜索
为什么搜字符串有用
绝大多数 SQL 查询,在代码里就是一个普通的字符串。开发者写 "SELECT * FROM user WHERE id = " + id,这段文本就躺在源码里。我们用正则把它们”捞”出来,再逐个检查。
正则是什么:一种描述文本模式的迷你语言,\| 表示”或”,. 表示任意字符,* 表示前面的东西重复任意次," 就是字面双引号。不会正则也能照抄下表用。
正则速查表
| 正则表达式 (Query / Pattern) | 中文描述 | 功能与用途 |
|---|---|---|
(SELECT|UPDATE) 或 (SELECT|UPDATE|INSERT|DELETE) | 搜索包含 SQL 关键词的行 | 快速定位代码中硬编码的 SQL 语句开头 |
(WHERE|VALUES).*?" | 搜索 WHERE/VALUES 后跟双引号的字符串 | 查找条件子句与字符串结束引号之间的关联 |
(WHERE|VALUES).*"+ | 搜索 WHERE/VALUES 且使用 + 拼接的代码 | 重点排查 Java 中用加号动态拼接 SQL 的高危注入点 |
.*sql.*" | 搜索包含 sql 且带双引号的行 | 快速查找定义 SQL 变量或 SQL 局部字符串的代码行 |
怎么用(以 VSCode 为例):Ctrl+Shift+F → 打开正则开关(.* 图标)→ 粘贴 (WHERE|VALUES).*"+ → 逐条看结果。搜到形如下面这样的行,就要停下来仔细回溯:
String sql = "SELECT * FROM user WHERE username = '" + username + "'";
// ↑↑↑ 命中 (WHERE|VALUES).*"+ 模式
// 变量 username 用 + 直接拼进 SQLgrep 批量初筛
在项目根目录跑:
grep -irnE 'SELECT|UPDATE|DELETE|INSERT|CREATE|ALTER|DROP'-i忽略大小写(select和SELECT都命中)-r递归子目录-n显示行号-E启用扩展正则(|不用转义)
结果太多的话可以收窄到代码文件:grep -irnE '...' --include='*.java'。
针对 Java 的审计方法
危险 Sink 关键字
审计 Java 项目时,先全局搜索这些关键字,把数据库交互点全部找出来,再逐个回溯参数来源:
| 框架 / 技术 | 搜索关键字 | 审计重点 / 危险场景 |
|---|---|---|
| JDBC(原生) | Statement / executeQuery / createStatement | Statement + 字符串拼接(+ 或 String.format),拼接参数若用户可控即注入,逐个回溯 |
| JdbcTemplate(Spring) | jdbcTemplate / query / execute | 检查传入的 SQL 是否通过 + 拼接变量,而非使用 ? 占位符 |
| MyBatis | \${.*} | 核心重点:#{} 是预编译(安全),${} 是直接拼接(危险,易出现在 ORDER BY、LIKE、IN 中) |
| Hibernate / JPA | createQuery / createNativeQuery | 检查 HQL 或 Native SQL 是否字符串拼接而非绑定参数(:param) |
新手常见疑问:这些框架是什么?
- JDBC:Java 自带的原生数据库接口,最底层,所有框架底层都是它。
- JdbcTemplate / MyBatis / Hibernate:封装 JDBC 的框架,让开发者少写重复代码。框架本身不背锅,用错姿势照样注入——这就是要审的原因。
- MyBatis 的
#{}和${}:MyBatis 用 XML 文件写 SQL。#{name}底层走预编译占位符(安全);${name}是直接把变量文本替换进 SQL(等于拼接)。所以搜${是 MyBatis 审计的第一动作。注意 grep/正则里$是特殊字符,要写成\${.*}。
判定要点(看到什么要警惕)
- 看到
createStatement()高度警惕——它不支持参数绑定,拼接是它的唯一用法,见到基本等于见到注入候选。 - MyBatis 中
${}的每一个出现点都要人工确认两件事:- 能不能改成
#{}?(能改而没改 → 开发者失误,注入成立) - 不能改的(如排序字段、表名)有没有做白名单校验?(没有 → 注入成立)
- 能不能改成
- 注意伪参数化,这是最容易骗过新手的写法:
// ❌ 假预编译:先把变量拼进字符串,再把拼好的整串交给 prepareStatement
PreparedStatement ps = conn.prepareStatement("SELECT * FROM user WHERE id = " + id);
// ✅ 真预编译:SQL 骨架里没有变量,值通过 setXxx 传
PreparedStatement ps = conn.prepareStatement("SELECT * FROM user WHERE id = ?");
ps.setInt(1, id);口诀:? 出现在 SQL 字符串里才是参数化;变量出现在 SQL 字符串里就是拼接。
- Java 侧 Source 不止
request.getParameter,以下全是用户可控输入,漏一个就少查一条链:request.getHeader()(X-Forwarded-For、Referer等都能伪造)request.getCookies()- JSON 请求体(Jackson/Fastjson 把 JSON 反序列化成对象,对象字段再被拼进 SQL)
- 上传文件名
Part#getSubmittedFileName()(文件名入库时可能拼 SQL)
字符串过滤机制(怎么判断防没防住)
代码里看到开发者在 Source 和 Sink 之间做了”过滤”,别急着放过,逐个按下面的标准审:
Pattern/Matcher正则匹配:Java 原生正则库。开发者常写黑名单,如Pattern.compile("select|union")命中就拒绝。审计点:- 黑名单完整吗?只过滤
select不过滤union、sleep、updatexml,等于没防; - 能编码绕过吗?大小写(
SeLeCt)、内联注释(sel/**/ect)试了吗; - 匹配后是拒绝还是替换?如果是把关键词删掉放行,用
selselectect(删掉中间的select后又拼出一个select)双写绕过。
- 黑名单完整吗?只过滤
- 白名单校验(如
str.matches("[0-9]+")):数字型强校验是安全的,输入只能是纯数字,注入无路可走。这是少数可以直接放行的防御。 - 转义/替换单引号(把
'换成''或\'):等价于转义方案,两大软肋——数字型注入根本不需要引号(id=1 and 1=2),所以转义引号对数字型参数完全无效;宽字节编码下还可能被吃掉转义符(详见修复篇)。
针对 PHP 的审计方法
危险 Sink 关键字
| 类型 | 搜索关键字 | 审计重点 |
|---|---|---|
| 原生 mysql(老旧) | mysql_query | 见到基本必有注入;该扩展 PHP 5.5 起废弃、PHP 7 已删除,只支持单语句(不能堆叠) |
| MySQLi | mysqli_query / ->query( | 检查是否拼接;mysqli_multi_query 还支持堆叠注入(一次执行多条语句,危害更大) |
| PDO | ->query( / ->exec( | 这两个是直接执行,拼接即注入;安全的写法是 prepare() + execute() 组合 |
| ThinkPHP | ->where( 原生字符串 / query( / execute( | 检查 where 条件是数组绑定(安全),还是直接传拼接字符串(危险) |
| Laravel | DB::select / DB::raw / whereRaw | raw 系列等价于字符串拼接,逐个回溯参数 |
新手疑问:PDO 是什么? PHP 的数据库抽象层,一套 API 通吃 MySQL/PostgreSQL 等。PDO 同时提供”直接执行”(query/exec,危险)和”预编译”(prepare/execute,安全)两套接口——看到 PDO 项目先分清用的是哪套。
过滤函数审计点
PHP 代码里看到这些”防御”函数,按下表判断是真防住了还是纸糊的:
| 函数/机制 | 审计结论 |
|---|---|
intval() / 强制类型转换 | ✅ 数字型场景安全(输入被强制变成整数,语法字符全被丢弃) |
addslashes() | ⚠️ GBK 等宽字节编码下可用 %df' 绕过(宽字节注入,原理见修复篇),不安全 |
mysql_real_escape_string() | ⚠️ 必须配合 mysql_set_charset();代码用 SET NAMES 声明编码时两侧字符集不一致,仍可宽字节绕过 |
htmlspecialchars() | ❌ 防 XSS 的,转义列表是 <>&",不含 SQL 关键字符,对注入完全无效。看到有人用它防注入直接判漏洞存在 |
| 自写黑名单过滤函数 | ⚠️ 检查三点:过滤项完整吗?只替换一次吗(双写绕过)?处理编码了吗? |
过滤与 WAF 的具体绕过手法:相关:sql注入过滤绕过
判定要点
- PDO 项目先看连接代码里有没有
PDO::ATTR_EMULATE_PREPARES => false:模拟预处理开启时,参数仍在 PHP 客户端拼接转义后才发送,宽字节问题依然存在(修复篇案例二有完整剖析)。 - 以下全部算 Source,一个都不能漏:
$_GET、$_POST、$_COOKIE、$_REQUEST$_SERVER里的HTTP_X_FORWARDED_FOR、HTTP_REFERER、HTTP_USER_AGENT等(请求头用户可伪造)$_FILES['xxx']['name'](上传文件名,入库时可能拼进 SQL)php://input(JSON body,json_decode后字段同样可控)
- 二次注入:数据出库再用的点,要按不可信数据重新审计(本篇案例二专门讲)。
案例
案例一:Java JDBC 拼接注入(登录绕过)
漏洞代码(逐行注释):
String username = request.getParameter("username"); // ① Source:用户名,用户完全可控
String password = request.getParameter("password"); // ① Source:密码,同样可控
String sql = "SELECT * FROM user WHERE username = '" + username
+ "' AND password = '" + md5(password) + "'"; // ② 两个变量用 + 直接拼进 SQL 字符串
Statement stmt = conn.createStatement(); // ③ 创建 Statement —— 不支持参数绑定,拼接是唯一用法
ResultSet rs = stmt.executeQuery(sql); // ④ Sink:拼好的字符串原样交给数据库解析数据流分析(逆向三步走):
- Source:
request.getParameter("username"),HTTP 请求参数,攻击者想填什么填什么; - 中间处理:没有任何过滤/转义,变量直接
+拼接进 SQL 字符串。注意密码过了md5()但用户名什么都没过——攻击面在 username; - Sink:
createStatement()+executeQuery(sql)。Statement无参数绑定能力,拼接内容原样进入数据库语法解析。
三条判定全部满足:参数可控 ✔、拼接进入 SQL ✔、无有效过滤 ✔。
攻击怎么发生的(payload 逐段拆解):攻击者在用户名框输入
admin' or '1'='1' --
拼进 SQL 后变成:
SELECT * FROM user WHERE username = 'admin' or '1'='1' -- ' AND password = '...'admin—— 正常内容,让前面半截语句有合法开头;- 第一个
'—— 闭合开发者写的左引号,让 username 的字符串值提前结束。这是整个注入的钥匙:从这里开始,后面的字符不再是”数据”,而是”语法”; or '1'='1'—— 注入的语法片段:给 WHERE 条件追加一个恒为真的判断('1'永远等于'1'),于是不管用户名是否存在,条件都成立;--—— 行注释符(MySQL 里--后必须跟一个空格才生效),把后面开发者原本写的' AND password = '...'全部注释掉——密码校验就这样被”删”了。
结果:不需要知道密码,直接以 admin 身份登录成功。
审计结论:存在字符型 SQL 注入,且位于登录逻辑,可直接绕过认证,危害等级高。
修复建议:改 PreparedStatement 参数化绑定(相关:sql预编译参数化):
String sql = "SELECT * FROM user WHERE username = ? AND password = ?"; // ? 占位,SQL 先编译
PreparedStatement ps = conn.prepareStatement(sql);
ps.setString(1, username); // username 作为"数据"传入,不再参与语法解析
ps.setString(2, md5(password)); // 单引号、注释符全部失效,只被当作普通字符串比较案例二:PHP 二次注入(改密码场景)
什么是二次注入:第一次入库时数据被”洗干净”了(至少看起来是),攻击者把恶意内容存进数据库;之后的某段代码信任库内数据,取出来直接拼进新 SQL,恶意内容第二次发作时才引爆。类比:你把一颗定时炸弹存进仓库,仓库管理员后来取出使用时才爆炸。
漏洞代码(逐行注释):
// —— 注册功能:入库前做了转义 ——
$u = mysql_real_escape_string($_POST['username']); // ① Source:攻击者注册用户名 admin'#
// 转义后当次 SQL 安全执行
mysql_query("INSERT INTO user(username, pwd) VALUES('$u', '$p')");
// ② 关键认知:转义只影响"这一次"SQL 解析,
// 库里落盘的仍是原始值 admin'#
// —— 改密码功能:信任库内数据,取出后直接拼接 ❌ ——
$row = mysql_fetch_assoc(mysql_query("SELECT username FROM user WHERE id = $uid"));
// ③ 把库里的 admin'# 原样取出,无任何处理
$sql = "UPDATE user SET pwd = '$newpwd' WHERE username = '" . $row['username'] . "'";
// ④ Sink:admin'# 被直接拼进 UPDATE
mysql_query($sql);数据流分析:
- Source:
$_POST['username']=admin'#。mysql_real_escape_string会把'转义成\',所以 INSERT 语句当次执行没有问题,但转义是临时的——写入数据库的仍是原始字符串admin'#; - 中间处理:出库时零处理。代码隐含了一个错误的信任假设:“库里的数据是干净的”;
- Sink:取出的
admin'#直接拼接进 UPDATE,最终语句变成:
UPDATE user SET pwd = '新密码' WHERE username = 'admin'#''admin'—— 正常闭合的字符串,匹配到管理员那一行;#—— MySQL 行注释符,把尾巴上开发者原本写的'注释掉,语句合法收尾;- 效果:这条 UPDATE 改的是
admin的密码,攻击者随后用新密码登录管理员账号。
审计结论:存在二次注入。审计要点:入库点有转义不代表安全,必须跟踪所有”出库再用”的位置,库内数据一律按不可信 Source 重新审计。这也是为什么二次注入容易被漏——审计者看到入库有 mysql_real_escape_string 就放心走人了。
修复建议:所有使用点参数化,而不是只在入库点转义:
$stmt = $pdo->prepare('UPDATE user SET pwd = :pwd WHERE username = :u');
$stmt->execute([':pwd' => $newpwd, ':u' => $row['username']]);
// 无论 $row['username'] 里有什么妖魔鬼怪,都只被当作"值"去比较,不参与语法审计完成后的确认
定位到可疑点后,最后一步是构造数据流闭环验证,按顺序做:
- 确认 Source(哪个参数进来);
- 画出 Source → 中间处理 → Sink 的完整路径(建议在笔记里画出来,一条链一行);
- 逐步分析中间处理能否绕过(编码、双写、宽字节、二次注入);
- 给出可利用性结论与修复建议(参数化 / 白名单);
- 黑盒联动验证:静态结论要在可运行环境实测落地——白盒说”有”,黑盒打不通,多半是漏看了某层过滤:
- 字符型先打
',出现 SQL 报错回显即拼接实锤;再对比' AND '1'='1与' AND '1'='2的响应差异(布尔确认); - 数字型用
id=2-1看是否返回 id=1 的内容(能算出算术结果就是拼接实锤),或AND SLEEP(3)观察响应延迟(时间盲注确认); - 确认回显通道:页面差异 / 报错信息 / 时间延迟,三者占一即可判定可利用;
- 条件允许用 sqlmap 复测(
sqlmap -u "<url>" -p <参数>)拿自动化佐证; - 实测通过,报告写”存在注入”;只完成静态阅读未实测的,只能写”疑似注入,待动态验证”——这是专业和非专业的分水岭。
- 字符型先打
面试题眼速答
| 问题 | 一句话答案 | 展开两三句 |
|---|---|---|
| SQL 注入审计的基本思路是什么? | 逆向数据流追踪:先找 Sink 再回溯 Source | 全局搜 executeQuery、mysql_query 等执行点,回溯参数是否用户可控、是否拼接进入、过滤能否绕过。三条同时满足才算成立。正向追踪覆盖全但工作量大,实战中逆向优先。 |
| MyBatis 审计第一动作是什么? | 全局搜 ${ | #{} 底层走预编译占位符,安全;${} 是文本替换等价于拼接。每个 ${} 都要确认:能否改 #{}?不能改的(ORDER BY 等)有没有白名单? |
| 怎么识别伪参数化? | 看 SQL 字符串里是 ? 还是变量 | prepareStatement("... id = " + id) 先拼后编译,等于没参数化。真参数化的特征:SQL 骨架里只有 ?,值走 setXxx() 传入。 |
createStatement() 为什么高危? | 它不支持参数绑定,拼接是唯一用法 | 审计时见到它基本等于见到注入候选,只需确认参数可控且过滤可绕。对比 PreparedStatement 才具备参数化能力。 |
| PHP 里哪些输入算 Source? | $_GET/$_POST/$_COOKIE/$_REQUEST + 请求头 + 文件名 + JSON body | $_SERVER 中的 HTTP_X_FORWARDED_FOR 等头可伪造;$_FILES 的文件名、php://input 的 JSON 字段都常被漏审。库内数据(二次注入)也算。 |
htmlspecialchars() 能防注入吗? | 不能,它是防 XSS 的 | 转义列表是 <>&",不含 SQL 关键的单引号语义,对注入完全无效。代码里拿它当注入防御直接判存在漏洞。 |
| 什么是二次注入? | 恶意数据先存进库,出库再拼接时才引爆 | 入库转义只对当次 SQL 解析生效,落盘的仍是原始值。审计要点是跟踪所有”出库再用”的位置,库内数据按不可信 Source 重新审。 |
| 静态审计和动态验证的关系? | 静态给方向,动态下结论 | 报告里只有实测打通的才能写”存在注入”,纯静态阅读只能写”疑似注入,待动态验证”。黑盒验证用 ' 报错、2-1 算术、SLEEP(3) 延迟三板斧。 |