PortSwigger: Apprentice Level JSON Web Tokens
In this post, I will cover the Apprentice level JSON Web Token labs located at PortSwigger Academy as well as providing some context regarding what JSON Web Tokens are and the vulnerabilities associated with them.
JSON Web Tokens
JSON Web Tokens (JWTs) are a standardized format for sending cryptographically signed JSON data between systems. They can theoretically contain any kind of data, but most commonly are used to send information (also known as “claims) about users as part of authentication, session handling, and access control mechanisms.
All of the data that a server needs is stored client-side within the JWT itself, making them a popular choice for highly distributed websites where users need to interact seamlessly with multiple back-end servers.
A JWT consists of 3 partS:
Header
Payload
Signature
The header, called the JOSE header (JSON Object Signing and Encryption) specifies the algorithm used to sign and encrypt the token. Most JWTs are just signed and not encrypted. The most common algorithms are:
HS256 (HMACH + SHA256)
RS256 (RSASSA=PKCS1-v1_5 + SHA256)
ES256 (ECDSA + P-256 + SHA256)
A typical JWT header looks like this:
The payload contains a set of claims in JavaScript Object Notation. There are some claims that are recommended such as iss (issuer), exp (expiration time), sub (subject), aud (audience) and others, but they are optional.
For example, a JWT that allows the user "jane" to authenticate to the API over at https://secure.website until the year 2030 might look like:
The last part of the token is the signature. For HS256, this is calculated as follows:
HMAC-SHA256( base64urlEncoding(header) + '.' + base64urlEncoding(payload), secret)
A typical JWT in full looks like the following:
In conclusion, a typical JWT in web request headers looks like this:
The header and payload parts of a JWT are just base64url-encoded JSON objects. The header contains metadata about the token itself, while the payload contains the actual "claims" about the user. For example, you can decode the payload from the token above to reveal the following claims:
In most cases, this data can be easily read or modified by anyone with access to the token. Therefore, the security of any JWT-based mechanism is heavily reliant on the cryptographic signature.
JWT Signature
The server that issues the token typically generates the signature by hashing the header and payload. In some cases, they also encrypt the resulting hash. Either way, this process involves a secret signing key. This mechanism provides a way for servers to verify that none of the data within the token has been tampered with.
As the signature is directly derived from the rest of the token, changing a single byte of the header or payload results in a mismatched signature. Without knowing the server’s secret signing key, it should not be possible to generate the correct signature for a given header or payload.
JWT vs JWS vs JWE
The JWT specification is very limited. It only defines a format for representing information as a JSON object that can be transferred between two parties. In practice, JWTs are not really used as a standalone entry. The JWT specification is extended by both the JSON Web Signature (JWS) and JSON Web Encryption (JWE) specs, which define concrete ways of actually implementing JWTs.
A JWT is usually either a JWS or JWE token. When people refer to JWTs, they almost always mean a JWS token. JWEs are very similiar, except that the actual contents of the token are encrypted instead of encoded.
JWT Attacks
JWT attacks involve a user sending modified JWTs to the server in order to achieve a malicious goal. Typically, this goal is to bypass authentication and access controls by impersonating another user who is already authenticated.
The impact is severe. If an attacker is able to create their own valid tokens with arbitrary values, they may be able to escalate their own privileges or impersonate other users, taking full control of their accounts.
JWT vulnerabilities typically arise due to flawed JWT handling within the app itself. The various specs related to JWTs are relatively flexible by design, allowing website developers to decide many implementation details for themselves. This can result in them accidentally introducing vulnerabilities even when using secure libraries.
These implementation flaws usually means that the signature of the JWT is not verified properly. This enables an attacker to tamper with the values passed to the application via the token’s payload. Even if the signature is robustly verified, whether it can be trusted relies heavily on the server’s secret key remaining a secret.
If the key is leaked, or can be guessed/brute-forced, an attacker can generate a valid signature for any arbitrary token, compromising the entire mechanism.
JWT Attack - Flawed Signature Verification
By design, servers do not usually store any information about the JWTs that they issue. Instead, each token is entirely self-contained, having several advantages but also introducing a fundamental problem - the server does not actually know anything about the original contents of the token or even what the original signature was.
Therefore, if the server does not verify the signature properly, there is nothing to stop an attacker from making arbitrary changes to the rest of the token.
As an example, imaging a JWT that contains two things - username value and isAdmin value:
If the server identifies the session based on this username value, modifying its value might enable an attacker to impersonate other logged in users. Similiarly, if the isAdmin value is used for access control, this could provide privilege escalation by changing this value to True or False.
JWT libraries typically provide one method for verifying tokens and another that just decodes them. For example, the Node.js library jsonwebtoken has verify() and decode(). Occasionally, developers confuse these two methods and only pass incoming tokens to the decode() method. This means that the app does not verify the signature at all.
Additionally, the JWT header normally contains an “alg” parameter that tells the server which algorithm was used to sign the token and therefore which algorithm it needs to use when verifying the signature.
Sounds good right? Well, this is flawed because the server has no option but to implicitly trust user-controllable input from the token, which, at this point has not been verified at all. In short, an attacker can directly influence how the server checks whether the token is trustworthy.
JWTs can be signed using a range of different algorithms, but can also be left unsigned. In the unsigned case, the “alg” parameter is set to “none” which indicates a so called “unsecured JWT”. Due to the dangers of this, servers usually reject tokens with no signature.
However, as this kind of filtering relies on string parsing, you can sometimes bypass these filters using classic obfuscation techniques, such as mixed capitalization and unexpected encodings.
Even if the token is unsigned, the payload part must still be terminated with a trailing dot.
Lab 1 - JWT authentication bypass via unverified signature
The description for this lab states that it uses a JWT-based mechanism for handling sessions and that the server does not verify the signature of any JWTs it receives. Our end goal is to gain access to the admin panel located at /admin and delete the Carlos account.
As with previous labs in this PortSwigger series, the first thing to do is map out the application, click buttons, navigate around, log in, log out and just do everything you can think of to work out how the website normally functions. Once done, we can see the various directories and pages in Burp Suite:
However, with regards to JWTs, it’s a good idea to focus on POST requests, specifically ones relating to login request. Navigating to the HTTP History tab, and using the JWT Editor Burp Suite plugin, we notice that some requests are highlighted:
These requests, highlighted in green, contain 1 JWT as noted by the column field on the far right. We can see that these requests are a POST request for /login, then after we login, a GET request is made for the /my-account page (we can ignore the academyLabHeader).
Clicking into the GET /my-account request, we can see that once we are authenticated, we send an additional HTTP Header title “Cookie” that contains our JWT.
Right now, we have no idea of any of the parameters or values it is using. To decode this, we can use a website like JWT.io and paste it in and see the different decoded parts:
The most interesting part here is the payload section. We can see the issuer is set to PortSwigger, the subject/user is set to wiener (who we logged in as) and the expiration date in Epoch time which translates to 10th February 2023 at 3:41pm (when I did this lab).
As we know this lab does not verify the signature of any JWTs, we don’t need to worry about making sure it matches. What happens if we simply change the user to another user we know exists on the server? Say something like administrator?
After we modify the payload, the entire JWT encoded string changes as expected. With this newly modified token that paints us as the administrator user, we can try issuing a GET request to the /my-account page again, but this time replacing the JWT set automatically with this newly modified one:
This time, we get the same 200 OK response, but in the response, we can see a link to the admin panel and it states that our username is administrator, indicating it worked. Now, we can simply modify this GET request to instead grab the /admin page:
Once it again, it worked and this time we see the options to delete the two users and their associated links found inside the <a> HTML tags. Finally, to delete the Carlos user, we can modify the request once again and this time specify the link found under the <a> tag to delete Carlos - /admin/delete?username=carlos
We get a response 302 Found, indicating something happened without error. Looking back at the web page itself, we can see the banner drop down indicating that we completed the lab:
Another way to confirm this is by re-requesting the /admin panel once again with the modified JWT and we can see there is no option to delete Carlos anymore, indicating the user no longer exists.
In addition, we can see the message stating that the user was deleted successfully.
Lab 2 - JWT authentication bypass via flawed signature verification
The description for this lab states that it uses a JWT-based mechanism for handling sessions and that the server is insecurely configured to accept unsigned JWTs. . Our end goal is to gain access to the admin panel located at /admin and delete the Carlos account.
As with previous labs in this PortSwigger series, the first thing to do is map out the application, click buttons, navigate around, log in, log out and just do everything you can think of to work out how the website normally functions. Once done, we can see the various directories and pages in Burp Suite:
As before, nothing really stands out. On the lookout for JWTs, a good idea is to look for POST requests, login requests or by using the JWT Editor plugin for Burp Suite that will highlight requests in HTTP History that contain JWTs:
Here, there are many requests that contain JWTs as seen in the comment field. Taking one of these into repeater such as the GET /my-account page, we can investigate it:
In any authenticated request we send to the server, we include the additional HTTP Header “Cookie” that contains the JWT itself. From here, we can use the same website as before to decode it and take a look at the contents:
It looks similiar to the first lab, so what happens if we do the same thing and change the “sub” to administrator and send the request to /my-account?
It seems we just get redirected back to the login page itself, indicating some kind of signature check on the server side and when we changed the values, it no longer matched, therefore essentially booting us out.
However, there is another attack we can perform - setting the algorithm to “none”. All JSON Web Tokens should contain the "alg" header parameter, which specifies the algorithm that the server should use to verify the signature of the token. In addition to cryptographically strong algorithms, the JWT specification also defines the "none" algorithm, which can be used with "unsecured" (unsigned) JWTs.
When this algorithm is supported on the server, it may accept tokens that have no signature at all. As the JWT header can be tampered with client-side, a malicious user could change the "alg" header to "none", then remove the signature and check whether the server still accepts the token.
Let’s try it. First, we simply modify the original JWT by setting the alg claim to “none” and changing the sub to “administrator”.
Then, we grab this token, add a trailing dot (it’s required) and paste it into the GET /my-account request and see what happens:
It works. We get a 200 OK response and, as you can see, in the response we see the HTML code stating that our username is administrator meaning it worked. From here, we can request the /admin page to get access to the admin panel:
It still works and we can see the delete button for Carlos’s account. As we did in the previous lab, we can simply grab this URL linked in the <a> HTML called and modify the request to that URL to delete the account.
We successfully complete the lab. As you can see in the 200 OK response, the message “User deleted successfully” appears, indicating the Carlos account delete request went through and completed without any error.