ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SQL注入攻防实战:从原理、盲注到WAF绕过与防御加固

SQL注入攻防实战:从原理、盲注到WAF绕过与防御加固

我把这些年做渗透测试和帮企业做加固的经验,浓缩成了一条从“看懂”到“跑通”再到“防住”的完整链路:SQL注入到底是怎么一回事、常规注入和盲注怎么判断、WAF来了之后常见的拆解思路,以及更重要的——作为防守方,你到底应该在代码和架构的哪个位置把洞堵上。这篇文章不会只贴Payload,我会把每一步背后的判断依据也一起写出来,这样你拿到测试环境里能复现,换到真实业务里也能举一反三。

先说一句最重要的话:这篇文章里所有涉及攻击手法的内容,都只能在授权后的靶场、测试环境或合规的众测/渗透测试项目里复现。未经授权去碰别人的网站,那不是挖洞,是违法,而且现在日志和流量追溯能力都很强,别拿前途开玩笑。下面进入正题。

1. 从零认识SQL注入:它到底是什么

1.1 一条URL暴露的秘密

很多首次接触SQL注入的人,会把问题想得太神秘。其实它本质上就是一件事:程序把用户输入的内容,当成了SQL语句的一部分去拼接执行。

假设某个站点有一个商品详情页,URL长这样:

https://example.com/goods.php?id=58

后端代码大概是这个思路:

$id = $_GET['id']; $sql = "SELECT * FROM goods WHERE id = $id";

输入58时,语句是SELECT * FROM goods WHERE id = 58,很正常。如果我把58改成58 OR 1=1,语句就变成:

SELECT * FROM goods WHERE id = 58 OR 1=1

因为1=1恒为真,整条WHERE条件对全表都成立——如果权限没限制,返回的就是整个表的数据。到这一步,你可能还没感觉到危害,但这其实就是SQL注入的核心模型:你的输入穿越了“数据边界”,进入了“命令边界”。

1.2 注入点判断的“三步法”

拿到一个参数,怎么判断它背后是不是SQL拼接?我常用的方法是三步,过程很朴素,但效率极高。

第一步,输入一个单引号',观察响应是否出现异常。比如报错信息、页面空白、状态码500,这些都是线索。如果原本能正常显示的商品列表直接崩了,说明这个参数很可能被拼到了SQL中,且没有过滤单引号。

第二步,输入58' AND '1'='1和58' AND '1'='2,对比两个响应是否一致。前者恒真,应该能正常返回数据;后者恒假,应该返回空。如果两者页面表现不同,那基本可以确认这里存在字符型注入,而且布尔状态会被页面直接反映出来。

第三步,用报错函数或注释符进一步确认数据库类型。比如MySQL下输入58' AND extractvalue(1,concat(0x7e,database()))--+,如果页面回显了版本信息或库名,那注入点和数据库类型就一并确认了。

整个流程听起来不复杂,但实际测试里最大的变数是中途可能被WAF拦、被CDN缓存混淆、被开发者在代码里写了一个字符集转换函数。所以三步法不是背下来就能用,重点是理解每一步的目的:前两步判断“有没有拼SQL”,第三步判断“拼进去之后能不能利用”。

1.3 为什么有些注入一眼就能看出来

做漏洞测试久了,你会发现一个问题:有些注入点位,拿工具扫半天扫不出来,但手工看几秒就能确认。

原因是工具判断“是否存在注入”的时候,依赖的是特定的特征模型,比如和标准正则匹配。而真实业务里的SQL拼接千奇百怪,有的参数外面套了括号,有的字段被函数包裹,比如WHERE id = intval($id)看起来防住了,但如果代码写的是WHERE id = intval($id) + $_GET['extra'],那extra依然是拼接点。

我之前在一次授权测试中遇到过一个搜索接口,参数在代码里的拼接方式是:

SELECT * FROM articles WHERE title LIKE '%$keyword%'

这种场景下,工具很容易因为关键字被%包裹而判断为“过滤严格”,但实际上只要把关键词构造成%' UNION SELECT ... --就能完美逃逸。所以人工测试的核心价值,是理解代码的真实上下文,而不是机械地往URL里塞一堆特征字符串。

2. 入门阶段:经典注入类型与判断逻辑

2.1 数字型、字符型、搜索型:先分清参数性格

SQL注入的利用方式,很大程度取决于参数在SQL中处于什么位置。我在实战中习惯先把参数分三类:数字型、字符型、搜索型。

数字型参数,典型特征是SQL中直接拼接数字,比如WHERE id = $id。测试时不需要考虑引号闭合问题,直接id=58 AND 1=1或id=58 AND 1=2就能判断。字符型参数则不同,它在SQL里带着引号,比如WHERE username = '$name',你必须先考虑如何闭合引号,比如输入admin' --这样把后面的引号注释掉。搜索型参数更特殊,很多站点会用LIKE加通配符拼接,比如WHERE title LIKE '%$kw%',这种位置的引号和百分号闭合都要精心设计,通常的Payload是%kw%' UNION SELECT ... --这样的组合。

判断参数类型,只靠猜不靠谱。最实用的一招:提交一个输入,看数据库报错信息,或者用AND 1=1/AND 1=2做布尔对比。如果偶数和奇数页面的返回有明显差异,说明这里对布尔条件敏感,然后根据闭合方式判断用引号还是直接拼接。

2.2 联合查询注入,入门最快的一条路

联合查询(UNION SELECT)是SQL注入里最直白的利用方式。它的逻辑很简单:原本的查询结果会显示在页面上,UNION SELECT可以将你自己的查询结果拼接到原始结果后面,一起回显到页面。

经典操作分四步。

第一步,确认原查询的字段数,用ORDER BY n不断调整n值,直到页面报错为止。比如ORDER BY 5正常,ORDER BY 6报错,说明查询返回5个字段。第二步,用UNION SELECT 1,2,3,4,5替换出页面上哪些位置是回显位。第三步,把回显位换成敏感查询,比如版本、当前库名、表名。第四步,从表名筛选到列名,再到具体数据内容。

这套流程跑起来非常快,也是各类自动化工具的底层逻辑。但你可能不知道的是,联合查询有个前题条件:前后两个查询的字段数必须一致,而且数据库得允许UNION操作。遇到MySQL 8某些情况下的严格模式,或者订单表查询时关联了太多字段,手工调整字段数反而更灵活。

我见过不少人在这一步浪费大量时间,原因不是不会写UNION,而是没有搞清楚“页面到底回显了第几列”。有的页面会把所有字段循环输出,有的只显示第一个非空值,因此你必须先构造UNION SELECT 1,2,3,4,5看数字出现在什么位置,而不是直接把敏感查询写进去。

2.3 报错注入:把数据库的抱怨变成输出

不是所有页面都会回显数据列表。有些功能登录后只显示“操作成功”或“密码错误”,这时候UNION注入基本没法用,因为攻击者看不到结果集。但数据库很诚实,遇到语法错误时会把详细信息抛出来——报错注入的思路,就是故意制造一个错误,把要查询的数据带到错误信息里。

MySQL下最经典的两种报错函数是updatexml()和extractvalue(),它们的SQL语句长这样:

AND updatexml(1,concat(0x7e,(SELECT database())),1) AND extractvalue(1,concat(0x7e,(SELECT table_name FROM information_schema.tables WHERE table_schema=database() LIMIT 0,1)))

这里0x7e是波浪号~的十六进制,作用是作为分隔符,让报错信息更直观。SQL Server下常用convert(int,@@version)这类类型转换报错;Oracle则用CTXSYS.DRITHSX.SN,但那套语法比较绕,新手可以先放一放。

报错注入虽然好用,但限制也明显:单条报错回显的字符串长度通常有限,MySQL下一般是32个字符左右。所以碰到抽取大量数据时,需要配合substr()函数一段段截取,非常耗时。我在测试中更愿意优先看有没有UNION或者布尔盲注的条件,实在没路走了才用报错注入。

3. 深入阶段:盲注与必然要面对的执行面

3.1 布尔盲注与时间盲注

页面不显示数据,也不报错,这类注入最折磨人。但“不显示”不等于“无状态”,我们用布尔盲注的思路:构造一个恒真和恒假的SQL条件,观察页面在两种条件下的反应是否不同。比如某个页面,条件为真时正常显示数据,条件为假时显示“暂无数据”,哪怕只是一个字数差异,也能作为判断依据。

id=1 AND SUBSTRING((SELECT database()),1,1)='t'

如果页面正常,说明库名的第一个字符是t,然后逐步调整位置和字符,就像拆盲盒一样把数据一位一位拆出来。

时间盲注则是给布尔盲注的一种兜底方案——当页面无论真假都返回同样内容、甚至完全空白时,用延时函数来制造时间差。

id=1 AND IF(SUBSTRING((SELECT database()),1,1)='t',SLEEP(3),0)

如果响应时间明显超过3秒,说明猜测正确。这种方式很稳定,但速度极慢,而且在高并发业务上很容易触发WAF的速率检测。所以我的建议是,时间盲注只用于确认“这里还有路”,真正跑数据还得靠工具,或者干脆换一个输出点。

3.2 堆积查询与写文件:执行面从哪里打通

很多刚入门的测试者会在某个环节卡死:明明确认了注入点,但既没有回显,也不能用布尔判断。这时候就要考虑,当前这个数据库连接是否支持多语句执行。如果支持,就可以使用堆积查询(堆叠注入)。在MySQL中,PDO连接如果设置了对多语句的支持,攻击者就能在一条语句后追加任意SQL:

id=1; SELECT sleep(3)-- id=1; INSERT INTO test_table(col) VALUES ('payload')--

堆叠注入的价值不在于查数据,而在于操作数据甚至操作系统。比如在SQL Server里执行EXEC xp_cmdshell,或者在MySQL里通过SELECT ... INTO OUTFILE向网站目录写入webshell。这些都属于执行面打通。

但这类操作的限制同样明显:MySQL的--注释符后面必须紧跟一个空格,有些版本对注释要求很严格;另外,现在大部分线上数据库用户权限都不高,写文件的前提是具备FILE权限且知道网站绝对路径。所以我在做测试时,不会把“写文件”当作必现路径,而是先确认数据库连接用户到底有没有这个资格。

3.3 编码、大小写、空格:基础静态绕过手法

在还没遇到真正WAF的时候,很多开发者也会做一些简单的关键词过滤,比如把SELECT、union等字符串直接替换为空。这些过滤看起来很吓人,但绕过其实不难。

最基础的思路是大小写混写,Union SeLeCt,因为早期过滤只比对小写。接着是注释符拆分,MySQL支持/*!*/这种内联注释,例如/*!50000UNION*/,把关键字中间切开,开发者的正则就很难匹配。还有一种常见手法是用制表符、换行符代替空格,因为很多过滤规则只关注空格和关键字的组合,比如%0b、%09、%0a都能在一些场景中替代空格生效。

这里我要提醒一句:静态绕过只是“预赛”。真正复杂的是遇到WAF之后,它会做语法分析、行为检测,会和解码、规范化过程结合着判断。所以在了解静态绕过时,与其背一堆Payload,不如理解一个核心:WAF和过滤规则本质都在“匹配”,而匹配就有误报和漏报,绕过就是在它的匹配盲区里找可被后端解析的机会。

4. 进阶阶段:常见WAF的拦与拆

4.1 WAF到底在拦什么

先说结论:市面上绝大多数WAF,拦截SQL注入的核心逻辑并不是“我看不懂SQL”,而是通过规则匹配和少量行为分析,找到高度疑似攻击的特征。

它关注的点无非是这几类:

  • 敏感关键字:union、select、sleep、extractvalue、updatexml等
  • 特殊符号:单引号、注释符、括号、分号
  • 异常URL编码:%27、%2527这种双重编码
  • 请求参数的数量和大小
  • 头部其他字段里的注入特征

理解了这一点,你就明白为什么同一个Payload在不同时期、不同WAF下的结果会完全不同。因为WAF的规则集是动态更新的,你今天能绕过的点,可能三天后就被那条规则堵上了。所以与其追求“万能绕过包”,不如掌握拆解思路。

4.2 等价函数替换与谓词变换

WAF会对关键字做正则匹配,比如extractvalue命中了,那就换一个功能相近的函数。MySQL里报错函数不止一个,updatexml被拦可以试ST_XPointFromWKB这类小众函数;sleep被拦可以用BENCHMARK(5000000,SHA1('test'))顶上去,虽然不是精确延时,但计算量足够让响应慢一个量级。

谓词变换的思路更常用。原始条件IF(1=1,SLEEP(3),0)里有IF和SLEEP两个特征,可以改成天然等价但有不同写法的逻辑,比如:

CASE WHEN (SELECT 1 FROM dual WHERE 1=1) THEN 1 ELSE 0 END

或者用位运算、乘法运算来构造恒真、恒假:

id=1 AND 1=1 id=1 AND 2-1=1 id=1 AND 1-1=0 id=1 AND 1>0

这些变换相当于用另一种“方言”表达同一个逻辑,如果WAF的规则里没有覆盖这种语法变体,就会直接放行。需要强调的是,变换之后的Payload必须经过本地数据库验证,不能想当然,因为不同数据库对运算符的优先级和函数支持并不相同。

4.3 大数据包、分块传输与协议层绕过

关键词和逻辑变体层面的绕过,属于“内容对抗”。还有一个方向是“传输对抗”,核心思路是:WAF的解析引擎感知到的内容,和后端业务程序接收到的内容不完全一致。

最典型的是分块传输编码(Chunked Transfer Coding)。HTTP协议里,请求体可以被拆成多个分块,每块自带长度标记。很多WAF默认只解析第一个分块,或者因为分块组装超时直接放行。于是可以在第一个分块里放一个正常值,第二个分块里放真正的攻击语句,实现绕过。

另一种思路是“参数污染”和“参数冗余”。同一个参数名出现多次,不同中间件取值的逻辑不同。比如Nginx取第一个,Tomcat取最后一个,如果WAF检查的是第一个而业务取的是最后一个,那攻击语句放在后面就能逃过检测。

还有一种容易被忽略的点:内容类型混淆。WAF一般会按Content-Type决定是否做SQL注入解析。如果业务本身需要接收JSON,WAF可能只解析JSON格式;但如果你把请求体改成multipart/form-data或application/x-www-form-urlencoded的混合体,而业务后端同样能解析,就可能出现检测规则被绕过的情况。

这类协议层绕过对WAF的对抗要求很高,但对测试者也提出了一个问题:你能否完全理解目标中间件的解析差异?如果不能,建议先在本地搭建相同中间件做针对性验证,而不是直接在真实环境里试。

4.4 从绕过给防御带来的启示

聊完绕过思路,想回到防御端说一点真实的感受。很多企业上WAF,是为了“合规”或“省心”,但实际上,WAF永远只是最后一道外部防线,真正决定系统安全性的,还是代码层是否使用了安全的SQL执行方式。

我在一次真实测试中观察到,某个系统已经上了一款主流云WAF,但业务代码里是用字符串拼接SQL,而且接口参数直接被传入存储过程。测试Payload被WAF拦了十几次后,我把Payload做了分块传输处理,结果WAF直接放行,后端一次查询就把全库数据带出来了。这个案例充分说明一个残酷的现实:攻击者只要花时间研究WAF的漏洞,绕过的概率并不低。

所以防御不能只靠WAF。合理的纵深防御应该是:参数化查询兜底,输入校验做辅助,数据库账号按最小权限设计,WAF负责拦截已知攻击模式,日志审计负责发现绕过后的异常行为。这四层各司其职,才是一个合格系统的样子。

5. 防御视角:从源头堵住注入

5.1 参数化查询为什么能根治问题

很多人问,SQL注入到底能不能彻底防御?答案是能,前提是所有SQL语句都使用参数化查询或预编译机制。这里的原理,我在入门时一直理解不透,直到有一天用生活常识想明白了。

你要是把SQL语句看成“填空题”,把用户输入看成需要填入的答案。字符串拼接的做法,是先把答案和题目混合成一句话,再让数据库去执行——所以攻击者输入的“伪答案”能改变整句话的意思。而参数化查询的做法,是先告诉数据库:这句话的格式是这样,这里、那里是参数位,然后你再把参数值传过去。数据库在执行时,只会把参数当作数据,而不是命令的一部分。数据和命令从一出生就被分离了。

以Java为例:

String sql = "SELECT * FROM users WHERE username = ? AND password = ?"; PreparedStatement ps = conn.prepareStatement(sql); ps.setString(1, username); ps.setString(2, password);

Python版本类似:

cursor.execute("SELECT * FROM users WHERE username = %s AND password = %s", (username, password))

无论用户输入什么,都只会被当作字符串字面量处理,不存在引号逃逸和关键字拼接的余地。这就是为什么我总说,只要全项目严格使用参数化查询,SQL注入这个漏洞类型基本可以从自家系统里“清零”。

5.2 硬编码白名单与最小权限原则

参数化查询解决了“怎么执行”的问题,但还有两类场景需要额外注意:一类是动态列名、排序字段名、表名有时无法参数化,另一类是业务需要执行动态拼接的存储过程。这两个场景是防御中最容易被忽略的口子。

我的建议是,能白名单就白名单。列名、表名、排序字段这些结构性的标识,不要在用户输入的字符串里直接拼。可以在代码里建立一个映射表,比如:

sortable_columns = {"time": "create_time", "hot": "view_count", "price": "price"} sort_col = sortable_columns.get(request_args.get("sort"), "create_time")

用户只能从有限的键里选择,值本身是代码里写死的SQL片段,这样即使传入的内容再离谱,也不会改变查询结构。另一个原则是数据库账号的权限最小化。业务连接数据库的账号,不应该拥有DROP、ALTER、FILE这类高危权限。应用账号只需要CRUD加连接权限就够了。这样就算注入真的发生,攻击者也只能在数据库连接账号的能力范围内操作,后续影响会被压制到最小。

5.3 三类典型的防注入代码剖面

实战里见过很多号称“防住了”的代码,但写法其实分三档,效果差别很大。

第一档:黑名单过滤。典型做法是把select、union、sleep这些关键词替换成空或加上反斜杠。我见过的很多自研防护代码都停在这一档。问题也很明显:过滤覆盖面有限,正则写得稍微差点就会被编码绕过;而且维护成本高,规则跟不上攻击手法的变化。

第二档:转义函数。比如MySQL的mysql_real_escape_string(),以及框架自带的转义工具。这种做法能让输入里的单引号、双引号、反斜杠得到转义,因此单条语句拼接时,攻击者很难再“逃出”引号。但它的局限在于,如果输入点同时出现在其他上下文,比如LIKE查询里、存储过程里,或者数据流的某个解码环节里,就可能被绕过。

第三档:参数化查询加白名单校验。这是我推荐所有团队最终落位的做法。SQL语句结构固定,参数值输入通过预编译接口传递,动态标识符通过白名单映射。再配合数据库账号的最小权限,这套方案能应对绝大多数真实攻击场景。

我理解很多老项目迁移到参数化要改不少代码,但这事儿不能偷懒。如果项目短期改不完,至少给存量高危接口做一次参数化改造排期,新接口一律强制预编译,这是最小成本、最大收益的路径。

6. 常见问题与实战排查

6.1 明明有注入点但工具扫不出来

做授权测试时,有时候手工已经确认了某个GET参数存在布尔盲注,但流行的自动化工具却报告“未发现注入”。原因大概有三种。

第一种,工具自带Payload被WAF拦截。主流的工具检测时会发送大量特征明显的Payload,遇到WAF直接就被打了,所以工具识别不到。第二种,目标站点对频率有限制,工具并发过快触发风控,导致返回结果异常。第三种,工具对数据库类型的指纹识别失败,比如默认用MySQL的注入语句去测SQL Server,自然无效。

这种情况我的建议是,先手工确认注入类型和数据库类型,然后在工具里写自定义Payload,或者干脆自己写一版基于布尔盲注的脚本,配合代理工具做流量调整。补充说明一下,sqlmap这类工具其实也支持--prefix和--suffix参数,你可以把手工试出的闭合方式传给它,再让它在那个基础上跑,效果会好很多。

6.2 注入后的“隐形坑”

即使确认了注入可以利用,测试过程中还有几个很容易让人翻车的隐秘问题。

第一个是编码问题。有些数据库连接层设置了GBK或UTF-8的转换,可能导致你输入的中文Payload在数据库侧被解析成完全不一样的字符。著名的宽字节注入就是利用这一点,在GBK编码下让转义符本身失效。应对方法是先确认连接的字符集,再看过滤函数实际的作用位置。

第二个是注释符和语句截断。MySQL的#注释符和--(后面必须带空格)各有适用场景。部分情况下,末尾注释符没写对会导致SQL语法错误,页面直接崩了,你以为是注入不存在,其实是语法不对。在遇到末尾有括号或子查询时,注释符的选择和括号补齐格外重要。

第三个是脱裤不是终点。很多人拿到数据库后就停下来了,但在授权测试里,你要评估的是危害链路。权限能不能拓展、能不能横向移动、数据库服务器是否能连外网,这些都是后续影响分析的一部分,也是完整报告里不可缺少的内容。

6.3 一场授权测试的完整复盘

最后分享一段最近做的授权测试复盘,省略敏感的站点信息和具体数据,只保留攻击路径和判断过程。

某业务系统提供用户查询功能,输入手机号可以查询订单。我刚开始按常规思路提交13800138000' AND '1'='1,结果响应一片空白,连业务错误码都没返回。第一反应是有WAF拦截,但没有返回拦截页,可能是WAF直接静默丢弃请求。

随后我调整思路,尝试用JSON格式提交,把内容放在一个嵌套数组里。第一次提交被拦,第二次把请求头的Content-Type改成text/xml但内容仍是JSON,响应居然正常了。我立刻用布尔条件测试,确认存在注入点。进一步构造UNION查询时,发现目标数据库是SQL Server,单一引号已经闭合,于是调整表名和列名语法,成功读取到当前库的表结构,最终在授权范围内验证了数据泄露风险。

全程最耗时的环节不是构造Payload,而是定位中间件的解析差异。这个案例也再次印证了我开头的观点:注入本身是代码问题,但能不能被发现,往往取决于环境对输入流的处理方式。作为测试者,你的任务就是把自己放在开发、运维、安全设备三方都能接受的夹角里,找到被忽略的那条缝。

根据个人经验,我还想专门提醒一点:报告里除了要写清楚漏洞位置和利用方式,更要说明修复优先级和配合方案。很多企业不是不想修,而是不知道自己哪个系统高危、哪个要先改、怎么改。你给出可落地的修复建议,远比冷冰冰的POC截图更有价值。

最后再分享一点检测侧的经验

这个内容讲到这里,差不多可以收尾了。但还有一件事想单独拎出来说,因为它和“从入门到绕过WAF”这个主题紧密相关,却经常被测试者忽略——日志侧的证据留存。

做攻防对抗时,所有Payload的最终归宿都是服务器和数据库日志。你写进去的UNION语句、报错函数、延时条件,都会成为后续应急响应中定位攻击来源的关键线索。我见过不少测试人员只关心能不能绕过去,忽略了自己留下的痕迹和业务侧日志记录的情况。其实,在授权范围内做一次完整测试,应该在开始前就确定好日志保存范围、测试时间窗口,并在结束后将特定记录传给防守方进行复盘。这样才能让一次演练真正产生安全价值,而不只是“攻击者爽了一把”。

另外,如果你正在学习这类技术,我建议你准备一套本地靶场环境,把常见的WAF容器镜像、不同数据库版本、不同中间件都搭进去,反复练习。理解“数据是如何被解析的”,比背一百个绕过Payload有用得多。等你哪天能在靶场里连续绕过三个不同厂商的防护策略,你回去再看业务代码,很多一眼就能发现的洞,就不需要工具帮你确认了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进