JWT, or JSON Web Tokens, is the defacto standard in modern web authentication. It is used literally everywhere: from sessions to token-based authentication in OAuth, to custom authentication of all shapes and forms. There is actually a pretty good reason for this wide adoption and that is, for the most part, security and resilience. However, just like any technology, JWT is not immune to hacking. In this post I will show you exactly how this can be done using our painless online security toolkit - of course.
Before we begin, I need to mention that the attack technique that you will experience here comes from Tim McLean's research, originally published on the auth0 blog over here. It is an excellent read that you should dig into as soon as you finish with this post. I will put another link at the bottom of this article for easy access and follow up.
Chapter I: The API
Our journey begins with a piece of vulnerable code of an example service (use your imagination) written in node with ExpressJS (the latest version at the time of writing).
var fs = require('fs') var express = require('express') var jwtSimple = require('jwt-simple') var app = express() app.get('/', function (req, res) { res.set('Content-Type', 'text/plain') res.send('API') }) app.get('/key.pem', function (req, res) { res.set('Content-Type', 'text/plain') res.send(fs.readFileSync('key.pem')) }) app.get('/get_token', function (req, res) { res.set('Content-Type', 'text/plain') res.send( jwtSimple.encode( { message: 'Hello world!' }, fs.readFileSync('key'), 'RS256' ) ) }) app.get('/verify_token', function (req, res) { var token = req.query.token var decoded try { var decoded = jwtSimple.decode(token, fs.readFileSync('key.pem')) res.set('Content-Type', 'text/plain') res.send(decoded) } catch (e) { console.log(e) res.set('Content-Type', 'text/plain') res.send('Error') } }) app.listen(8080, function () { console.log('App listening on port 8080!') })
This so-called API does two things. First, it generates a signed JWT token with a static message via a call to /get_token endpoint. For the signature we use a proper public and private key pair. The JWT token can be validated and the message payload decoded using the /verify_token endpoint. If the JWT token is not tampered, the verification endpoint will return the payload to the user. Given that the private key is kept private and the public key is, well public and served by /key.pem endpoint, how can we exploit this service so that we can forge our own tokens?
As usual, examples in security-related blog posts and tutorials are conveniently easy to hack. The service that we face contains a subtle bug. Can you see it?
Chapter II: The Setup
Let's start the service and see how it works. Create index.js with the code above and package.json with the following list of dependencies.
{ "dependencies": { "express": "^4.14.1", "jwt-simple": "^0.5.1" } }
We also need to install the dependencies and setup our public/private key pair for use for this service. As per the service code above, the private key is a file called key while the public key is in the PEM format and called key.pem. To setup both files we need to execute the following commands:
$ ssh-keygen -t rsa -N '' -f key $ ssh-keygen -f key.pub -m PEM -e > key.pem
Once the service is up and running. Let's use Rest to see how it works.
The setup is straightforward. Once we fire the request we get a token. In order to validate the token, open Rest in another tab with a token URI query parameter configured like the screenshot bellow.
As expected when we verify the token, we get the static message back, which is Hello world! - so possitive. If we are to change the token with our own forged token, we will get an error response as illustrated by the following screenshot.
Notice how we conveniently turned off the original token parameter without deleting it and added a new token with the new JWT token value. This is what happens when you have proper tools.
Chapter III: The Bug
Up to this point we have a service that employs JWT which we proved that is not susceptible to tampering by conveniently using the HMAC signing algorithm to forge tokens, which should not work - or should it. Most of these types of articles are like magic - not very impressive once you know the trick.
To make me look needlessly 31337, let's dissect trivial code see how tokens are generated by the jwt-simple dependency. The encode method has the following signature:
/** * Encode jwt * * @param {Object} payload * @param {String} key * @param {String} algorithm * @param {Object} options * @return {String} token * @api public */ jwt.encode = function jwt_encode(payload, key, algorithm, options) {
Further inside the code we learn that the signing algorithm defaults to HS256 if no algorithm parameter is provided.
// Check algorithm, default is HS256 if (!algorithm) { algorithm = 'HS256' }
This is why we specifically tell the encode function in our API to use RS256 which, instead of using a symmetric secret, will utilise our key pair generated earlier in the article - and everyone knows that asymmetric cryptography is better than symmetric cryptography.
Next step is to look into the decode function, which happens to have the following signature:
/** * Decode jwt * * @param {Object} token * @param {String} key * @param {Boolean} noVerify * @param {String} algorithm * @return {Object} payload * @api public */ jwt.decode = function jwt_decode(token, key, noVerify, algorithm) {
This function signature is slightly different from the previous one due to the noVerify parameter. The function still expects an algorithm to be supplied but perhaps its value also defaults to something like the previous function. Let's have a look.
var signingMethod = algorithmMap[algorithm || header.alg] var signingType = typeMap[algorithm || header.alg] if (!signingMethod || !signingType) { throw new Error('Algorithm not supported') }
So it looks like that unlike the encode method which uses default algorithm set to HS256, the decode method defaults to the algorithm specified in the JWT header, which we have complete control of - classic arbitrage. This means that if we do not happen to explicitly specify the algorithm when we call the decode function, we will end up verifying the token with the algorithm specified by the attacker. As it turns out our service does exactly this - I told you it is way too convenient.
Chapter IV: The Exploit
So up to this point all we know is that the attacker with the hoodie controls the signing algorithm that is used to verify the signature of the token. But earlier on we forged our own token using the HMAC algorithm and that did not work.
Clearly the header contains HS256 which the jwt-simple library will use because the developer did not provide their own defaults. However, notice that when we generated the token using HS256 we also used arbitrary signing key with the value secret. Of course this is not going to work because the decode method does not have the same key and there is no way we can force-feed it.
However, if the verification algorithm is HS256 than what is the key? Clearly, in our example the key is the public key as you would expect if we are using the RS256: private key signs - public key verifies. Aha! So in fact, when we forge our own token with HS256, the secret key for the verification method is nothing else but the public key which as you can imagine is - public!
Equipped with this awesome information let's try again but this time when we forge the token we will use the public key instead of the arbitrary string we used earlier. We can retrieve the public key from /key.pem.
If you just copy and paste the key into the token parameter that has the JWT generator, it may not work. The reason for this is because both strings need to be exact in order for the HS256 to work. Spaces, line-endings and all kinds of other characters that you may not visually see, or be able to copy with CTRL+C, are required for this to work. It needs to be 1-to-1. This is where we will use our file retriever to make this perfect.
Voila! Magic happened. We managed to forge our own token using a simple trick on a conveniently vulnerable API.
Chapter V: Finale
I hope you have noticed that I was a bit cynical about how conveniently vulnerable this service was. It is easy, indeed. However, these types of scenarios are far too common to call them impractical bugs. In fact, larger code-base tend to complicate the nature of all of this so much that it becomes very difficult to spot subtle bugs.
As promised, here is the link to Tim McLean's article. It is a good read which I think will give you further insights of how JWT actually works if all of this is new to you.
I have some final words for people who are saying that JWT generally sucks and all that. It doesn't! It is actually very clever what it does and it does what it is supposed to do very well. However, crypto bugs are not always obvious even when you deal with them at such high level.
Check us out on twitter. We like to post pictures of robots in ASCII from time to time.