This was one of my favorite challenges. It required me to really understand JSON Web Tokens (JWT) and how their signing works. I had to look at it one time at the start and came back later to solve it. In the end the learning was very much worth it.

The Challenge

The challenge description tells us we need to "Gain access to the /admin API endpoint". When we start the challenge we get a URL to the website. Trying to access /admin at any point gives us a simple {"Error": "Something went wrong"} response. Looks like we need to somehow gain more access.
Loading this website shows a simple Login form and a link to the Register page. We can simply create an account for ourselves and then log in.

One thing I noticed was that the filter for valid usernames was very strict. Even numbers weren't allowed. It seems like only [A-z] characters are allowed.

When we log in we are greeted with a page that allows us to upload files. We can select a file and simply upload it. It also tells us files are stored in /uploads/[username], so we can find them back later.

When trying to upload something like a .txt file we see it's then also listed on the same page. We can try and access the file via the directory we see on the page: /uploads/jorian/sample.txt. When we do, it actually shows us the content of the file back. This seems dangerous...

Trying to break uploads

From looking at the page it's not clear what backend it's running, but PHP is a pretty common one, especially in CTFs. So let's try uploading a .php file, with some PHP code as the content.


<?php echo "Hello, world!" ?>

Uploading this we get an alert:

File upload failed: Extension not in allowed list

It's talking about an allow list, which our first .txt is probably in, and .php is not. We can try uploading lots of different extensions but it's not likely that they allowed a dangerous extension that would allow us to run code. We can also try some techniques of fooling the check into thinking some file is in the allow list, while it's actually saved as say a .php file. But any trick I tried failed, and made me pretty sure that this filter is safe.

JSON Web Tokens

When we are logged in, we can't see any cookies. This is weird because often authentication and sessions are saved as a cookie. How does the server know who we are?

Looking at the requests that are sent to the server using a proxy like Burp Suite, we see a couple of requests happening when we load /listFiles. The first is just fetching the HTML, and then this HTML references a script /static/js/listFiles.js. This is loaded and contains the interesting code we're after.


function getFiles() {
    var jwt = localStorage.getItem("auth");
    if(!jwt) {
        document.location.href = "/";

    ..."POST", "/listFiles");
    xmlhttp.setRequestHeader("Authorization", "Bearer " + jwt);

There it is! It gets a jwt from Local Storage. This is a place where the browser can save things, that are not automatically sent to the server. To do that, this piece of code uses the Authorization header. This is known as a Bearer token. But now that we know our session is stored in Local Storage, we can check this in the DevTools to see an auth token indeed:



Like the code says this is a JSON Web Token (JWT). These are commonly used when a server wants the client to hold on to their session and data, while still making sure that it can't be messed with. Too bad we're still gonna mess with it :)

Pasting our token into a site like we can see the content, it's simply base64 encoded:


  "typ": "JWT",
  "alg": "RS256",
  "ISS": ""

We can also see the content or "payload":


  "username": "jorian",
  "admin": false

There are a few interesting things to notice here. In the first header part, we see the alg is set to RS256. This means RSA, the asymmetric cryptosystem used almost everywhere. It works with Public keys and Private keys. One can sign data with their private key to prove to everyone with their public key that they signed it. The web server can use this to make sure you can't change the data.

Whenever you log in, the server creates a token with some data and signs it with its "signature". This means that it uses the private key on the token. Then after that, anyone with the public key can verify that the server indeed signed that token and its content. Whenever you change a token as a user the signature won't be correct anymore, and you can't forge your own signature because you don't have the private key.

We see "admin": false in the token content. Thinking back at the challenge our goal is to gain access to the /admin endpoint. So this means we probably need to set "admin" to be true for us to be authorized.

The last interesting thing that I did not recognize was the ISS value. It's set to a localhost URL, with what looks to be something related to the RSA key signing with its name being Trying to access this /static/ URL on the challenge website actually gives us the file!


-----END PUBLIC KEY-----

This seems to be the public key the server uses. We can even verify this idea by going back to the website, pasting our token, and then pasting this public key into the "Public Key" box on the bottom. then tells us in the bottom left "Signature Verified", because using the public key we can verify that the signature is correct.

This is nice to have, but we still can't forge our token because we would need the private key, we can't find this anywhere on the website.

Using our own Private Key

The ISS URL being visible and changeable for us users seems weird. The server probably uses this URL to get the public key it needs to verify our token. But what if we change this URL? Would the server get the public key from that other URL? Now we can really try an attack.

We start by logging in to the site. We can then intercept the POST /listFiles in Burp Suite to edit and test things. I like to use the Send to Repeater function to be able to send the request over and over again with different attempts. We can then see the Authorization header with the JWT as we expect.

Now we can try to forge our own JWT with a public key. Let's generate a new RSA keypair of our own with ssh-keygen. We just need to make sure that this will be an RSA key, not an SSH key. This can be done with the -t rsa argument. Then to get a format like the we need to use the -m PEM argument to save it in a PEM format.


$ mkdir rsa

$ ssh-keygen -t rsa -m PEM
Generating public/private rsa key pair.
Enter file in which to save the key (~/.ssh/id_rsa): rsa/rs256
Enter passphrase (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in rsa/rs256
Your public key has been saved in rsa/

The generated public key in rsa/ is still not in the correct format however, when looking at it, it starts with ssh-rsa and doesn't really look like the public key from the server. To fix this we can use ssh-keygen again to convert the public key to a PEM format as well:


$ ssh-keygen -f rsa/ -e -m pem > rsa/rs256.pem

Now we have a public key similar to the one from the server in rsa/rs256.pem. The cool thing is that because we just generated this ourselves, we also have the private key in rsa/rs256. This means we can sign it ourselves, and if we provide our own public key in the ISS value we might trick the server into verifying the token with our key as opposed to the key from the server.

We can easily do this with the website. Just past in the original token, and we can then edit whatever we want on the right. Let's set the admin value to true, to see if that has any effect. We can now set the ISS URL to a server we host ourselves. With ngrok you can quickly expose a local HTTP server to the public internet. We'll use two terminals to execute the following commands:


1$ python -m http.server -d rsa  # Host a simple HTTP server with the rsa directory containing our keys

2$ ngrok http 8000

In the ngrok terminal, we now see a URL: This URL now points to the webserver we created with Python. Visiting will now download the public key we generated. Now we can put this URL into the ISS value:


  "typ": "JWT",
  "alg": "RS256",
  "ISS": ""


  "username": "jorian",
  "admin": true

In the left panel, the token will probably disappear but don't worry, we just need to add our private key to make it able to sign tokens for us. Just print out your generated private key with cat rsa/rs256, and copy the key into the Private Key box. We get a new token on the left now! We can even verify it ourselves again to make sure everything went well. Just copy the public key in the PEM format (rsa/rs256.pem) into the Public Key box. If everything went right you should now see "Signature Verified" again.

Now we can finally use the token to try and authenticate as admin. Just copy the JWT from the site into Burp Suite, replacing the original token. We send it and... {"Error": "ISS not localhost"}! Dangit. We need to try a little harder.

The final step

But we have one more trick up our sleeves. If you remember the original public key was on localhost, and accessible on the public page. Now, what if we try to do the inverse and upload our public key to the web server, would it also become accessible on localhost? Let's try it!

We can only upload certain whitelisted extensions, and .pem is not one of them. But the server only needs to download the content for the public key, so we can just rename it to something like rs256.txt to upload it as we did with the sample.txt earlier. When it's uploaded we can access it via the /uploads/jorian/rs256.txt URL. Now we can append this to the localhost address to bypass the "ISS not localhost" check. Just change the header in again:


  "typ": "JWT",
  "alg": "RS256",
  "ISS": ""

Now we can copy the new JWT again and paste it into Burp Suite. When we now send it our exploit works! The server verifies our token successfully and shows us our files.


HTTP/1.1 200 OK
Content-Type: application/json


Now for the final step, we just need to visit the /admin endpoint as the challenge told us to. A nice trick in Burp Suite is to right-click on the request to POST /listFiles that worked, and then click "Change request method" to automatically make it a GET request. Now we just change the path to /admin and send it.

The response is our flag! We bypassed the JWT verification by signing it with our own key, and the server used our key to verify it as well.