SPACE 01 / 技术 · 2026.11.01

两个 PoC 的诞生:从回显判定到盲注判定

用 Go 写了两个 PoC,一个靠关键字、一个靠布尔差异。真正花时间的不是写代码,是搞清「凭什么算命中」。

靶场打多了会有一种错觉:能用 Burp 手工打出来,就等于会了。

直到我想把「怎么判断打中了」这件事交给程序——才发现自己说不清判据。手工的时候,眼睛一扫就知道页面变了;写成代码,就得回答一个具体问题:凭什么说这次命中了?

这两个 PoC 就是回答这个问题的过程。

先想清楚四件事

写任何 PoC 之前,先填一张表:

空 要定什么
① 请求怎么发 方法 + URL
② 参数放哪 query 还是 body,怎么编码
③ 带什么身份 Cookie / Token
④ 怎么算命中 关键字 / 长度差 / 耗时差

骨架永远不变,变的只有这四个空。我把每个 PoC 都按这张表先写一遍,再动代码。

PoC #1:回显型

目标是 DVWA 的命令注入模块,payload 是 127.0.0.1 & whoami。

空 答案
① POST http://.../dvwa/vulnerabilities/exec/
② body 里 ip=127.0.0.1+%26+whoami&Submit=Submit
③ PHPSESSID=...; security=low
④ 响应含 administrator

坑一:以为是 GET,其实是 POST

第一版按 GET 写,跑出来 4106 字节、没有 administrator。存下响应一看,页面里是空的表单:

<form name="ping" action="#" method="post">

表单是 POST。 我按常见写法猜了 GET,猜错了。

这不是「写错代码」,这是「没看目标」。很多教程里 DVWA 这关是 GET,版本不同就变了——PoC 不能照抄,得看实际请求。

坑二:& 必须编码

改成 POST 之后,body 里的 payload 是这么拼的:

ip=127.0.0.1+%26+whoami&Submit=Submit

+ 是空格,%26 是 &。不编码的话,& 会被当成表单字段分隔符,payload 直接被截断。

手工在浏览器里打没事——浏览器提交表单时会自动编码。写代码就得自己管,或者用 url.Values。

坑三:判定词要能区分「被踢回登录页」

最开始只判断 administrator,结果失败时分不清是「命令没执行」还是「Cookie 过期被踢回登录页」。

后来加了三个检查:

for _, kw := range []string{"administrator", "Pinging", "Login"} {
    fmt.Printf("  含 %-14s : %v\n", kw, strings.Contains(text, kw))
}
  • 含 Login → 被踢回登录页,是 Cookie 问题
  • 含 Pinging 但不含 administrator → ping 执行了,是命令本身的问题
  • 都含 → 命中

结果

状态码: 200 | 响应长度: 4577
  含 administrator  : true
  含 Pinging        : true
  含 Login          : false
[HIT] 命令执行成功

关键证据是长度:第一次 4106 字节,注入成功是 4577——多出来的 471 字节就是 whoami 的输出。

PoC #2:布尔型

目标是 sqli-labs Less-8,注入点是 id=1' and 1=1 --+。这关没有回显,只有「有」和「没有」两种页面。

空 答案
① GET http://.../sqli-labs/Less-8/
② query 里 id=1' and 1=1 --
③ 不需要(独立页面,无登录)
④ 两次请求的响应不同

注释的空格会被编码吃掉

-- 后面必须有空格才能注释。但直接写空格,url.Values 编码后就不是空格了。

试出来一个土办法:用 q 当填充。

payloads := []string{"1' and 1=1 -- q", "1' and 1=2 -- q"}

-- q 里 -- 后面紧跟一个空格再跟 q,注释语法成立,编码也不会破坏结构。

长度判定是个陷阱

第一版用长度判定:1=1 是 706 字节,1=2 是 722 字节,两个不一样——按逻辑该判命中。

但假分支反而更长,这不对。正常情况下 1=1 会显示 You are in...,应该更长才对。

把两次响应都存下来 diff:

- <font size="5" color="#FFFF00">You are in...........<br></font>
+ <font size="5" color="#FFFF00"></br></font><font color= "#0000ff" font size= 3>

假分支多了一段 <font color="#0000ff">——那是 DVWA 用来显示 SQL 报错的空框,因为报错为空,只剩标签。

这 16 字节来自页面模板,不是来自数据。 用长度差判定,这次是碰巧对了,理由却是错的;换个模板结论可能反过来。

改成关键字判定

hit0 := strings.Contains(bodies[0], "You are in")
hit1 := strings.Contains(bodies[1], "You are in")

if hit0 && !hit1 {
    fmt.Println("[HIT] 布尔盲注成立:条件为真时有回显,为假时没有")
}
判据 稳不稳
长度不同 受模板、动态内容影响
真分支有特征串、假分支没有 语义明确
长度不同且差异片段可解释 最严谨

跑出来:

请求 1:状态码 200,长度 706
请求 2:状态码 200,长度 722
1=1 含回显: true | 1=2 含回显: false
[HIT] 布尔盲注成立:条件为真时有回显,为假时没有

三种判定,一套骨架

两个 PoC 的代码结构几乎一样,只有判定段不同。再加一个时间盲注,判定就凑齐三种:

类型 判据 适用
回显型 响应里出现特征串 有回显的注入、RCE
布尔型 真分支有特征串、假分支没有 布尔盲注
时间型 注入前后响应耗时差 完全无回显

写在最后

这两个 PoC 加起来不到 130 行,真正花时间的是三件事:

  1. 搞清目标实际的请求形态(GET 还是 POST、参数在哪)
  2. 把响应存下来看,而不是猜
  3. 想清楚「凭什么算命中」,并且能解释差异的来源

第三件事是分水岭。能把判据说清楚的人,才算会写 PoC;只会跑脚本的人,换一个站就废了。