hxp2022 wp

在ctftime看到的,题目有深度的

Disclaimer

This challenge offers an individual instance for you and therefore runs behind a proxy requesting login credentials. Locally use hxp:hxp.

每个题的docker-compose.yml文件需要修改一下

1
2
3
   build:
+ context: .
dockerfile: Dockerfile

valentine

WEB

Difficulty estimate: - easy

Description:

Create an awesome template for your valentine and share it with the world!

开始页面一看就知道是node.js的ssti

image-20230406222548469

image-20230406222817323

尝试直接用payload解决

1
<%= global.process.mainModule.require('child_process').execSync('cat /flag.txt') %>

发现输入的数据实际上就是实现一个前端的效果,并不被模板化image-20230406223708029

看看app.js

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
var express = require('express');
var bodyParser = require('body-parser')
const crypto = require("crypto");
var path = require('path');
const fs = require('fs');

var app = express();
viewsFolder = path.join(__dirname, 'views');

if (!fs.existsSync(viewsFolder)) {
fs.mkdirSync(viewsFolder);
}

app.set('views', viewsFolder);
app.set('view engine', 'ejs');

app.use(bodyParser.urlencoded({ extended: false }))

app.post('/template', function(req, res) {
let tmpl = req.body.tmpl;
let i = -1;
while((i = tmpl.indexOf("<%", i+1)) >= 0) {
if (tmpl.substring(i, i+11) !== "<%= name %>") {
res.status(400).send({message:"Only '<%= name %>' is allowed."});
return;
}
}
let uuid;
do {
uuid = crypto.randomUUID();
} while (fs.existsSync(`views/${uuid}.ejs`))

try {
fs.writeFileSync(`views/${uuid}.ejs`, tmpl);
} catch(err) {
res.status(500).send("Failed to write Valentine's card");
return;
}
let name = req.body.name ?? '';
return res.redirect(`/${uuid}?name=${name}`);
});

app.get('/:template', function(req, res) {
let query = req.query;
let template = req.params.template
if (!/^[0-9A-F]{8}-[0-9A-F]{4}-[4][0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i.test(template)) {
res.status(400).send("Not a valid card id")
return;
}
if (!fs.existsSync(`views/${template}.ejs`)) {
res.status(400).send('Valentine\'s card does not exist')
return;
}
if (!query['name']) {
query['name'] = ''
}
return res.render(template, query);
});

app.get('/', function(req, res) {
return res.sendFile('./index.html', {root: __dirname});
});

app.listen(process.env.PORT || 3000);

在这里

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
app.post('/template', function(req, res) {
let tmpl = req.body.tmpl;
let i = -1;
while((i = tmpl.indexOf("<%", i+1)) >= 0) {
if (tmpl.substring(i, i+11) !== "<%= name %>") {
res.status(400).send({message:"Only '<%= name %>' is allowed."});
return;
}
}
let uuid;
do {
uuid = crypto.randomUUID();
} while (fs.existsSync(`views/${uuid}.ejs`))

try {
fs.writeFileSync(`views/${uuid}.ejs`, tmpl);
} catch(err) {
res.status(500).send("Failed to write Valentine's card");
return;
}
let name = req.body.name ?? '';
return res.redirect(`/${uuid}?name=${name}`);
});

发现它会将输入的tmpl写入views/${uuid}.ejs当作模板

1
2
3
4
5
6
try {
fs.writeFileSync(`views/${uuid}.ejs`, tmpl);
} catch(err) {
res.status(500).send("Failed to write Valentine's card");
return;
}

但是存在校验

1
2
3
4
5
6
while((i = tmpl.indexOf("<%", i+1)) >= 0) {
if (tmpl.substring(i, i+11) !== "<%= name %>") {
res.status(400).send({message:"Only '<%= name %>' is allowed."});
return;
}
}

这段语句限制了如果输入的字段开头为<%,那么从<%开头后的字段必须是<%= name %>

开始尝试各种方法绕过,但确实不行,因为仅能是<%= name %>,里面也不能加东西

后面只有看wp才有思路,既然是从<%进行匹配限制,如果可以修改模板语句让其不是<%的开头,

所以绕过的思路就是能用其他的标签进行绕过

但是在文档中查看时会发现,所有都是以<%开头的标签

image-20230411181542931

delimiter

mde/ejs: Embedded JavaScript templates delimiter

可以看到标签几乎都是以<%开头和结尾的,

后面看官方wp时

In his analysis, Eslam even scratches the possibility to overwrite options which are passed with data. He specifically mentions the delimiter in the context of abusing it for catastrophic regex.

In what scenario does anybody even want to pass options with untrusted user data?

Especially: Options which greatly affect the parsing of the template like ?delimiter

And: Could this “feature” maybe be abused in any way?

Thus, the idea for this challenge was born.

大致意思就是因为大都ssti过滤就只是对分隔符进行正则匹配过滤,但是如果有选项比如delimiter,可以利用其以不受信任的用户身份发生数据或者以其他形式滥用

delimiter就是对标签的分隔符定义的选项,这样我们就可以通过其修改覆盖标签,从<%=变成<?=或者<==等等

image-20230411185506095

但是如何传参使得标签覆盖并且同时传入我们的payload

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
app.get('/:template', function(req, res) {
let query = req.query;
let template = req.params.template
if (!/^[0-9A-F]{8}-[0-9A-F]{4}-[4][0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i.test(template)) {
res.status(400).send("Not a valid card id")
return;
}
if (!fs.existsSync(`views/${template}.ejs`)) {
res.status(400).send('Valentine\'s card does not exist')
return;
}
if (!query['name']) {
query['name'] = ''
}
return res.render(template, query);
});

其中看到关键代码

1
return res.render(template, query);

这里会将template和query内容渲染,将query中的值把模板中变量的值替换,从而可以通过在query中重新定义delimiter参数,然后实现渲染覆盖原有的?分割符,将其替换成其他可以绕过的分隔符

但是这里还有过滤,对template进行的过滤,而template取自:template

:template 是一个动态路由参数,它可以匹配 URL 中的任何值。例如,如果请求的 URL 是 http://example.com/about,那么 :template 就会被设置为 about

而template的正则匹配内容

1
!/^[0-9A-F]{8}-[0-9A-F]{4}-[4][0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}

搜索发现是 UUID version 4 的格式,而uuid在上面代码出现

1
return res.redirect(`/${uuid}?name=${name}`);

这是重定向时生成的uuid,代表当我们被重定向后,

app会自动渲染下面内容

1
http://x.x.x.x:9086/365c9839-b1a3-4df9-8c1a-142378b79cd5?name=1

所以可以先抓包,得到重定向的uuid,

然后在后面添加delimiter参数,重新发包,从而实现利用渲染覆盖原有分隔符的目的,然后在按上面的代码POST发送tmpl参数【包含的是修改了分隔符的payload


解题流程

先打算将覆盖%换成?,先发送到/template,这里因为没有黑名单限制字符,于是我们的内容成功写入views/${uuid}.ejs文件中,而uuid就是响应包中的

77354ee1-466d-402f-a829-ec5e859c223e

image-20230411215254139

但是在views/${uuid}.ejs文件中是以%为分隔符,于是需要修改分隔符为payload?

image-20230411220246020

得到flag


docker看看

image-20230411220931414