靶场打多了会有一种错觉:能用 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 行,真正花时间的是三件事:
- 搞清目标实际的请求形态(GET 还是 POST、参数在哪)
- 把响应存下来看,而不是猜
- 想清楚「凭什么算命中」,并且能解释差异的来源
第三件事是分水岭。能把判据说清楚的人,才算会写 PoC;只会跑脚本的人,换一个站就废了。