SQL注入至今仍是Web应用最危险、出现频率最高的安全漏洞之一。代码审计如果只停留在“看有没有拼接SQL”的层面,会漏掉大量真实场景中的注入点。真正有效的审计方法,是把数据从入口到SQL执行位置的全链路拆开,逐段验证每一处拼接、预编译、动态排序、框架查询构造器以及存储过程调用是否可能被用户输入污染。下面直接进入具体的审计思路和判断方法。
从输入点反向追踪,而不是从SQL语句正向搜索很多审计人员习惯用正则搜索select、update、delete、insert等关键字,再检查附近有没有拼接变量。这种方法效率低,而且容易漏掉框架ORM、查询构造器和存储过程里的注入。更可靠的做法是先把所有用户可控的输入点列出来,包括GET、POST、Cookie、Header、文件上传文件名、接口路径参数、JSON字段,甚至二次写入数据库后再读出的数据。然后从这些输入点出发,追踪变量经过过滤、转义、拼接、传入方法、进入模型查询的完整路径。只要最终到达SQL执行位置时仍保留用户原始语义,就存在注入可能。
判断拼接发生的位置比判断是否使用预编译更重要预编译语句能防住大部分SQL注入,但前提是SQL语句结构在预编译阶段已经完全固定,用户输入只作为绑定参数传入。如果代码里出现类似“select * from users where id=” + id这样的字符串拼接,即使外层使用了PreparedStatement,也无法防止注入,因为拼接发生在预编译之前。审计时必须确认SQL模板中的任何部分都没有被用户输入影响。特别是表名、字段名、排序字段、排序方向、limit偏移量、group by字段等位置,很多框架不允许使用绑定参数,开发人员往往会退回到字符串拼接,这些位置是高频注入点。
动态排序和分页参数必须白名单校验以Java为例,下面这段代码看似使用了预编译,但order by字段直接拼接,仍然存在注入:
String sortField = request.getParameter("sort");
String sql = "select id,username,email from users where status=1 order by " + sortField + " desc limit ?,?";
PreparedStatement ps = conn.prepareStatement(sql);
ps.setInt(1, start);
ps.setInt(2, pageSize);
攻击者传入sort=extractvalue(1,concat(0x7e,database()))或者sort=if(1=1,id,username)时,就能执行报错注入或布尔盲注。审计这类代码时,要检查排序字段是否来自一个固定集合,例如只允许id、username、create_time,并且使用switch或Map映射到白名单值,而不是直接使用用户传入的字符串。分页参数虽然通常绑定为整数,但如果开发人员将limit拼接成字符串,同样会出问题。
框架查询构造器不是绝对安全边界很多人认为使用MyBatis、Hibernate、ThinkPHP、Laravel等框架的查询构造器或ORM就不会有SQL注入,这是错误认知。MyBatis中如果使用${}而不是#{},就等同于字符串拼接。Hibernate的HQL如果拼接用户输入,同样存在注入。Laravel的DB::raw()、whereRaw()、orderByRaw()方法接收原始SQL片段,如果参数未经处理直接传入,就是注入点。审计时要重点检查这些raw方法、表达式方法以及框架中允许传入SQL片段的位置。例如:
$sort = $_GET['sort'];
$users = DB::table('users')
->where('status', 1)
->orderByRaw($sort . ' desc')
->paginate(10);
这里的orderByRaw直接拼接用户输入,属于高危漏洞。正确做法是先验证$sort是否在允许的字段列表中,或者使用orderBy方法并传入白名单字段名。
二次注入需要关注数据入库与出库的完整链路有些数据在写入数据库时做了转义,但读取出来后再次拼接到新的SQL语句时没有重新处理,就会形成二次注入。典型场景是用户注册时用户名包含单引号,注册功能使用了addslashes或参数化查询,数据被原样存入数据库。之后管理员后台查看用户列表时,点击用户名触发查询,代码将数据库里读出的用户名直接拼接到SQL语句中。由于数据在第一次写入时已经“安全”存储,开发人员往往认为从数据库读出的数据是可信的,从而放松了后续处理。审计时要特别追踪那些从数据库读出的字段是否再次进入SQL语句,尤其是后台管理功能、报表导出、批量操作等位置。
存储过程内部拼接同样属于注入面存储过程并不能天然免疫SQL注入。如果存储过程内部使用动态SQL,例如拼接字符串后执行EXEC或sp_executesql,而传入的参数参与了拼接,就存在注入。审计时需要查看存储过程定义,确认动态SQL中的变量是否经过严格校验。例如SQL Server中:
CREATE PROCEDURE GetUser
@username NVARCHAR(50)
AS
BEGIN
DECLARE @sql NVARCHAR(200)
SET @sql = 'select * from users where username=''' + @username + ''''
EXEC(@sql)
END
调用方即使使用参数化方式调用存储过程,也无法阻止内部拼接造成的注入。审计存储过程时,要重点检查EXEC、EXECUTE、sp_executesql以及MySQL中的PREPARE、EXECUTE语句。
宽字节注入与字符集问题需要结合环境判断在GBK、GB2312等宽字节字符集环境下,如果PHP等语言使用addslashes或mysql_real_escape_string对单引号进行转义,攻击者可以通过构造特殊字节序列吃掉转义反斜杠,使单引号重新生效。审计时要确认数据库连接字符集与应用层转义函数是否匹配。如果连接使用了GBK而转义函数按单字节处理,就存在宽字节注入风险。现代PHP版本中推荐使用PDO并设置charset=utf8mb4,同时使用预编译语句,可以从根本上规避这类问题。审计老系统时,需要特别留意mysql_query、mysql_real_escape_string与SET NAMES GBK的组合。
报错信息泄露与盲注可利用性判断SQL注入漏洞的严重程度与是否回显错误信息、是否返回差异数据有关。审计时如果发现注入点,要进一步判断攻击者能否通过报错、布尔差异、时间延迟等方式获取数据。如果应用关闭了错误回显,但存在布尔差异,例如登录成功与失败返回不同页面,攻击者仍可通过布尔盲注逐字节猜解数据。如果应用对异常统一返回相同页面,但SQL语句执行时间受条件影响,攻击者可以利用时间盲注。审计报告应明确注入点的利用条件和影响范围,而不是笼统地写“存在SQL注入”。
代码审计中需要关注的危险函数与模式不同语言和框架下,危险函数和模式有所不同。Java中要关注Statement、createStatement、${}、StringBuilder拼接SQL、orderBy拼接。PHP中要关注mysql_query、mysqli_query、PDO::query、DB::raw、whereRaw、orderByRaw。Python中要关注cursor.execute直接拼接、Django的extra()、raw()方法。C#中要关注SqlCommand拼接、string.Format构造SQL。Node.js中要关注mysql.query、sequelize.query、knex.raw。审计时可以将这些函数和模式整理成检索清单,配合数据流分析工具进行批量定位。
过滤与转义不能替代参数化查询很多代码试图通过过滤关键字、替换单引号、添加反斜杠等方式来防注入。这些做法在特定场景下能提高攻击难度,但不能作为主要防线。过滤关键字容易被大小写、双重编码、注释符、等价函数绕过。例如过滤了select,攻击者可以使用sel//ect;过滤了空格,可以使用//或%0a;过滤了or,可以使用||。审计时如果发现代码依赖黑名单过滤,应判定为防护不足,即使当前过滤规则看起来覆盖了常见攻击向量,也要指出其绕过风险,并建议改用参数化查询或白名单校验。
结合数据流分析工具提升审计效率人工追踪大型项目的数据流效率较低,可以借助静态代码分析工具辅助定位。例如Fortify、Checkmarx、SpotBugs配合FindSecBugs插件、PHPStan配合安全扩展、Bandit等。这些工具能自动标记从用户输入到危险函数的路径,但误报率较高,需要人工确认。审计时可以先使用工具筛选出可疑点,再逐个验证是否真正可控、是否经过有效过滤、是否到达SQL执行位置。工具只能作为辅助,最终判断必须基于对代码逻辑和运行环境的理解。
修复建议要具体到代码层面审计报告中的修复建议不能只写“使用参数化查询”,而要给出针对当前代码的具体改法。对于值类型的参数,改为预编译绑定参数;对于表名、字段名、排序方向等无法绑定的部分,使用白名单映射;对于框架查询构造器,避免使用raw方法,改用框架提供的安全API;对于存储过程,移除内部动态SQL拼接,改为静态SQL加参数;对于二次注入,在所有从数据库读取数据再次拼入SQL的位置重新进行参数化处理;对于字符集问题,统一使用UTF-8并设置正确的连接字符集。修复后应进行回归测试,验证原有注入点不再可利用。
审计结论需要区分真实漏洞与理论风险并非所有拼接都等于可利用的SQL注入。有些变量虽然来自用户输入,但经过严格的类型转换、白名单校验或正则限制后,已经不具备注入能力。审计时要分析变量在到达SQL语句前经历了哪些转换。例如intval($_GET['id'])之后的值只能是整数,拼接进SQL也不存在注入。再如从固定数组中取值,用户只能选择预设值,也无法注入。审计结论应基于实际可利用性,避免将理论风险全部列为高危漏洞,否则会降低报告的可信度。
SQL注入防护的代码审计核心在于追踪数据流、识别拼接位置、判断过滤有效性、验证利用条件。只有把每一条用户输入到SQL执行的路径都走通,才能准确发现漏洞并给出可落地的修复方案。审计人员需要熟悉不同语言和框架的查询机制,同时保持对绕过手法的敏感度,才能在复杂业务代码中定位真正的注入风险。
