本篇教你:拿到一个项目的源码,怎么找出里面有没有 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 也能看)。
  • IDE 全局搜索:VSCode / IDEA。比 grep 直观,双击搜索结果能直接跳到上下文看函数全貌。VSCode 快捷键 Ctrl+Shift+F 全局搜。
  • 命令行 grep。适合在服务器上或批量初筛,后面有现成命令。

审计思路(Source → Sink)

核心心法:跟着水走

SQL 注入审计的本质是数据流追踪:一滴”脏水”(用户输入)从进水口进来,流过多少管道,最后有没有流进”数据库执行”这个水龙头。

类比排查自来水污染:两个方向都能查——

  • 正向(Source → Sink):从进水口($_GET、request.getParameter)出发,跟踪变量每一站流转,看最终是否流进 SQL 执行点。
    • 优点:覆盖面全,不遗漏。
    • 缺点:工作量大。一个中型项目用户输入入口成百上千,每条都跟完不现实。
  • 逆向(Sink → Source,实战推荐):反过来,先找到全市所有”水龙头”(危险 SQL 执行函数),再倒查它的水从哪来——参数用户可不可控?中间有没有过滤?
    • 优点:目标明确,SQL 执行点数量远少于输入入口,效率最高。
    • 新手就先练这个方向,本篇案例全部用逆向法演示。

注入成立的三个条件(判定公式)

回溯完一条数据流后,同时满足下面三条,才能下”存在注入”的结论,少一条都不行:

  1. 参数用户可控(Source 可达)——攻击者能决定这个变量的值;
  2. 参数以拼接方式进入 SQL(Sink 处无参数化)——变量被 + / . 串进 SQL 字符串,而不是走 ? 占位符;
  3. 过滤可绕过或不存在——中间的处理形同虚设(黑名单不全、转义可宽字节绕过等)。

三条都满足 → 存在注入。只满足前两条但有过滤 → 继续分析过滤能不能绕,能绕照样算存在。

快速定位:字符串搜索

为什么搜字符串有用

绝大多数 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 用 + 直接拼进 SQL

grep 批量初筛

在项目根目录跑:

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 / createStatementStatement + 字符串拼接(+ 或 String.format),拼接参数若用户可控即注入,逐个回溯
JdbcTemplate(Spring)jdbcTemplate / query / execute检查传入的 SQL 是否通过 + 拼接变量,而非使用 ? 占位符
MyBatis\${.*}核心重点:#{} 是预编译(安全),${} 是直接拼接(危险,易出现在 ORDER BY、LIKE、IN 中)
Hibernate / JPAcreateQuery / createNativeQuery检查 HQL 或 Native SQL 是否字符串拼接而非绑定参数(:param)

新手常见疑问:这些框架是什么?

  • JDBC:Java 自带的原生数据库接口,最底层,所有框架底层都是它。
  • JdbcTemplate / MyBatis / Hibernate:封装 JDBC 的框架,让开发者少写重复代码。框架本身不背锅,用错姿势照样注入——这就是要审的原因。
  • MyBatis 的 #{} 和 ${}:MyBatis 用 XML 文件写 SQL。#{name} 底层走预编译占位符(安全);${name} 是直接把变量文本替换进 SQL(等于拼接)。所以搜 ${ 是 MyBatis 审计的第一动作。注意 grep/正则里 $ 是特殊字符,要写成 \${.*}。

判定要点(看到什么要警惕)

  • 看到 createStatement() 高度警惕——它不支持参数绑定,拼接是它的唯一用法,见到基本等于见到注入候选。
  • MyBatis 中 ${} 的每一个出现点都要人工确认两件事:
    1. 能不能改成 #{}?(能改而没改 → 开发者失误,注入成立)
    2. 不能改的(如排序字段、表名)有没有做白名单校验?(没有 → 注入成立)
  • 注意伪参数化,这是最容易骗过新手的写法:
// ❌ 假预编译:先把变量拼进字符串,再把拼好的整串交给 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") 命中就拒绝。审计点:
    1. 黑名单完整吗?只过滤 select 不过滤 union、sleep、updatexml,等于没防;
    2. 能编码绕过吗?大小写(SeLeCt)、内联注释(sel/**/ect)试了吗;
    3. 匹配后是拒绝还是替换?如果是把关键词删掉放行,用 selselectect(删掉中间的 select 后又拼出一个 select)双写绕过。
  • 白名单校验(如 str.matches("[0-9]+")):数字型强校验是安全的,输入只能是纯数字,注入无路可走。这是少数可以直接放行的防御。
  • 转义/替换单引号(把 ' 换成 '' 或 \'):等价于转义方案,两大软肋——数字型注入根本不需要引号(id=1 and 1=2),所以转义引号对数字型参数完全无效;宽字节编码下还可能被吃掉转义符(详见修复篇)。

针对 PHP 的审计方法

危险 Sink 关键字

类型搜索关键字审计重点
原生 mysql(老旧)mysql_query见到基本必有注入;该扩展 PHP 5.5 起废弃、PHP 7 已删除,只支持单语句(不能堆叠)
MySQLimysqli_query / ->query(检查是否拼接;mysqli_multi_query 还支持堆叠注入(一次执行多条语句,危害更大)
PDO->query( / ->exec(这两个是直接执行,拼接即注入;安全的写法是 prepare() + execute() 组合
ThinkPHP->where( 原生字符串 / query( / execute(检查 where 条件是数组绑定(安全),还是直接传拼接字符串(危险)
LaravelDB::select / DB::raw / whereRawraw 系列等价于字符串拼接,逐个回溯参数

新手疑问: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:拼好的字符串原样交给数据库解析

数据流分析(逆向三步走):

  1. Source:request.getParameter("username"),HTTP 请求参数,攻击者想填什么填什么;
  2. 中间处理:没有任何过滤/转义,变量直接 + 拼接进 SQL 字符串。注意密码过了 md5() 但用户名什么都没过——攻击面在 username;
  3. 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);

数据流分析:

  1. Source:$_POST['username'] = admin'#。mysql_real_escape_string 会把 ' 转义成 \',所以 INSERT 语句当次执行没有问题,但转义是临时的——写入数据库的仍是原始字符串 admin'#;
  2. 中间处理:出库时零处理。代码隐含了一个错误的信任假设:“库里的数据是干净的”;
  3. 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'] 里有什么妖魔鬼怪,都只被当作"值"去比较,不参与语法

审计完成后的确认

定位到可疑点后,最后一步是构造数据流闭环验证,按顺序做:

  1. 确认 Source(哪个参数进来);
  2. 画出 Source → 中间处理 → Sink 的完整路径(建议在笔记里画出来,一条链一行);
  3. 逐步分析中间处理能否绕过(编码、双写、宽字节、二次注入);
  4. 给出可利用性结论与修复建议(参数化 / 白名单);
  5. 黑盒联动验证:静态结论要在可运行环境实测落地——白盒说”有”,黑盒打不通,多半是漏看了某层过滤:
    • 字符型先打 ',出现 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) 延迟三板斧。