The Dark Side of JWT: Exploiting Token Vulnerabilities

Escrito por  Murilo, Maiky Jhony

Many modern applications use JSON Web Tokens (JWTs) to authenticate and authorize users in their functionalities. In this article, we will explore vulnerabilities in JWTs that can compromise the signature system, allowing an attacker to authenticate and escalate their privileges in a vulnerable application.

Understanding the JSON Web Token

Before exploiting a vulnerability in an artifact, it is essential to study it and understand its functionality in detail.

A JWT is composed of three parts (all base64url encoded):

  • Header – used to store the token’s properties.
  • Payload – used to store the data.
  • Signature – used to ensure the integrity and security of the token.

The signature of a JWT is generated from the combination of the “Header” and the “Payload,” ensuring the integrity of the data contained in both fields. In the presented example, the algorithm used for the signature is “HS256.” However, there are various algorithms available that employ both symmetric and asymmetric encryption.

See below the algorithms used in the signature of a JWT:

Algorithms with symmetric encryption:

  • HS256
  • HS384
  • HS512

Algorithms with asymmetric encryption:

  • RS/ES/PS256
  • RS/ES/PS384
  • RS/ES/PS512

Algorithm without encryption:

  • None

 

Creating and verifying JWT manually

Using JavaScript to create a JWT:

const crypto = require('crypto');

// JWT Header
const header = btoa(JSON.stringify({
    typ: 'JWT',
    alg: 'HS256'
}));

// JWT Payload
const payload = btoa(JSON.stringify({
    id: 1,
    username: 'guest',
    roles: ['guest']
}));

// JWT Signature
const signature = crypto.createHmac('sha256', 'secretKey').update(`${header}.${payload}`).digest('base64url');

const jwt = `${header}.${payload}.${signature}`;

console.log(jwt)

Using JavaScript to verify the JWT signature:

const crypto = require('crypto');

const jwt = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJndWVzdCIsInJvbGVzIjpbImd1ZXN0Il19.7Rli0HzIbFTUtv1uRcJjnjuNzzg0eNjXpqCQfJTd39Y";

// Extracting header, payload and signature from JWT
const [header,payload,signature] = jwt.split('.');

// Creating signature using secretKey
const signature_verify = crypto.createHmac('sha256', 'secretKey').update(`${header}.${payload}`).digest('base64url');

// Comparing signatures
if(signature == signature_verify) {
    console.log('Valid token');
} else {
    console.log('Invalid token');
}

Creating and verifying JWT using external libraries

Using the “jsonwebtoken” library to create a JWT:

const jwt = require('jsonwebtoken');

// JWT Payload
const payload = {
    id: 1,
    username: 'guest',
    roles: ['guest']
};

console.log(jwt.sign(payload, 'secretKey'));

Using the “jsonwebtoken” library to verify the JWT signature:

const jwt = require('jsonwebtoken');

const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJndWVzdCIsInJvbGVzIjpbImd1ZXN0Il0sImlhdCI6MTcyNTA1MjQ1Nn0.oDaFyyt9k8302zekM6eYR_5B9dGbHKPPzRj7J7-pow8';

// Checking JWT
if(jwt.verify(token, 'secretKey')) {
    console.log('Valid token')
} else {
    console.log('Invalid token')
}

Exploring vulnerabilities in JSON Web Tokens

Now that we understand how JWTs work, we can proceed to the study of the vulnerabilities associated with them.

In this article, we will address the following vulnerabilities:

  • Weak secret
  • None attack
  • KID Header injection
  • JKU Header injection
  • Algorithm Confusion

Note: All the challenges presented in this article are available on our GitHub.

https://github.com/hakaioffsec/jwt-vulnerabilities-lab

 

Weak secret attack

When using symmetric encryption algorithms that employ the same key for encrypting and decrypting data, JWTs (JSON Web Tokens) may be vulnerable to brute-force attacks. If an application uses algorithms like “HS256,” “HS384,” or “HS512” and has a weak signing key, it is possible for an attacker to discover this key through a brute-force attack.

Example of vulnerable code:

const jwt = require('jsonwebtoken');

const payload = {
    id: 1,
    username: 'guest',
    roles: ['guest']
};

const token = jwt.sign(payload, '1234567890');

console.log(token);

We can use “hashcat” for the brute-force attack:

With the JWT signing key in hand, it is possible to alter any data contained in the token’s payload, thereby compromising its integrity.

Solving the challenge – Weak secret attack

Obtaining JWT:

Using hashcat to perform the brute-force attack:

With the signing key in hand, we can alter the token data. Changing the “admin” field to “true”:

Accessing the API using a token with admin permissions:

Mitigating the vulnerability – Weak Secret

To mitigate the “Weak Secret” vulnerability, it is necessary to use a strong key or employ some asymmetric algorithm. This way, attackers are prevented from carrying out brute-force attacks against the JWT.

None attack

When a system accepts JWTs with the “None” algorithm, it is possible to send tokens without the signature field, as this algorithm does not perform signature validation. This allows an attacker to authenticate and escalate privileges within the application, even without knowledge of the key used to verify the integrity of the token.

For an application to be vulnerable to the “None attack,” it needs to meet three requirements:

  • Allow the user to control the algorithm used for token verification.
  • Accept the “None” algorithm.
  • Not using any secret during the token verification or using an “undefined” value.

Example of vulnerable code:

app.get('/none-attack', async(req,res) => {
    let secret;
    const token = req.headers['authorization'];

    if(!token) {
        return res.sendStatus(401);
    }

    try {
        const { header } = jwt.decode(token, { complete: true });
        const alg = header.alg;

        if(alg.startsWith('HS')) {
            secret = "s1m3tr1c_s3cr3t_k333y"
        }

        if(alg.startsWith('RS')) {
            secret = fs.readFileSync('./keys/public.pem');
        }

        const payload = jwt.verify(token, secret, { algorithms: [alg] })
        
        if(payload.admin) {
            return res.json({ message: 'Challenge completed' });
        }

        return res.json(payload);
    } catch(e) {
        return res.sendStatus(500);
    }
});

When analyzing the source code above, it is observed that it uses the algorithm specified in the token during the signature validation process. However, there is a logical error in the code: if the algorithm defined by the user does not start with “HS” or “RS,” the secret variable will remain unset, meaning it will be “undefined.” Additionally, the “jsonwebtoken” library accepts the “none” algorithm by default, which fulfills the three requirements necessary for the application to be vulnerable to a “None attack.”

Solving the challenge – None attack

To exploit the “None attack” vulnerability, it is necessary to create a JWT token using the “None” algorithm and omit the signature field. Unfortunately, jwt.io does not offer the “None” option among the available algorithms, which requires us to generate the token manually. For this, we can use Bash to generate the “malicious” token:

export PART1=$(echo -n '{"typ":"JWT","alg":"none"}' | base64 -w0)
export PART2=$(echo -n '{"username":"admin","admin":true}' | base64 -w0)
echo "$PART1.$PART2."

JWT Hunter – The magic tool

To simplify the process during pentests and red team operations, we developed a web tool that generates tokens with the necessary payloads to carry out attacks. Using jwthunter.io, it is possible to easily forge a JWT containing the payload for the “None attack”:

Mitigating the vulnerability – None attack

To mitigate the “None attack,” it is essential to prevent the user from controlling the algorithm used in the token signature verification process. Additionally, it is crucial to ensure that the key used for token verification has a defined value.

KID Header injection

The “kid” header is commonly used to specify the “id” of the key that will be used to verify the token’s signature.

Large-scale applications often use this header, as they may have multiple endpoints that, for security reasons, utilize tokens associated with different keys.

The two main ways applications can obtain the secret key or RSA key through the “KID” are:

  • Searching for the secret key on the server’s disk.
  • Searching for the secret key in the database.

JWT verification flow using the “KID” header:

Database

Storage

 

Attacks on the “KID” header

In scenarios where the application retrieves the key based on the “kid” directly from the file system, it is possible to exploit path traversal vulnerabilities. This way, we can specify other files whose contents are known, allowing us to sign the JWT without needing to know any secret keys stored on the server.

In scenarios where the application retrieves the key based on the “kid” directly from the database, it is possible to exploit SQL Injection vulnerabilities. This allows for manipulating the database response or even extracting the secret keys stored within it.

 

Path traversal in the “KID” header

As mentioned earlier, if the application is vulnerable to path traversal in the “kid” header, it is possible to specify files whose contents we know and use them as the secret key to sign the token.

In the Linux file system, there are several files whose contents are known:

  • /proc/sys/kernel/randomize_va_space (its value is the number “2”)
  • /proc/sys/kernel/timer_migration (its value is the number “1”)

See below an example of code that exhibits the path traversal vulnerability in the “kid” header.:

app.get('/path-traversal', async(req,res) => {
    const token = req.headers['authorization'];
    
    if(!token) {
        return res.sendStatus(400);
    }

    try {
        const { header } = jwt.decode(token, { complete: true });
        
        if(!header.kid) {
            return res.sendStatus(401);
        }

        // PATH TRAVERSAL HERE
        const publicKey = fs.readFileSync(__dirname + '/keys/' + header.kid);
        
        const payload = jwt.verify(token, publicKey, { algorithms: ['HS256'] });
        
        if(payload.admin) {
            return res.json({ message: 'Challenge completed' });
        }

        return res.json(payload);

    } catch(e) {
        return res.sendStatus(500);
    }
})

 

Solving the challenge – KID Path traversal

By sending the following JWT, we were able to authenticate in the application:

By sending a path traversal payload in the “kid” header, we were able to traverse directories in the system and specify the file “/proc/sys/kernel/randomize_va_space”. This way, we can sign the token with the secret key “2”. However, there is an important detail: the content of the file is “2” followed by a newline. To sign the token, we will use “base64 encoding” on the signature:

Note: be aware of the newline.

Sending the payload to the application:

 

Solving the challenge using JWT Hunter – KID Path traversal

By accessing jwthunter.io, we can quickly solve the challenge since the tool automatically adds the “kid” header with the payload and performs the signing.

 

Mitigating vulnerability – Path Traversal in the “KID” header

To mitigate the vulnerability, it is essential to perform a check on the “kid” header, such as sanitizing it to prevent a user from traversing directories. Additionally, it is recommended to implement a whitelist to restrict the accepted “kids.”

 

SQL Injection in the “KID” header

As mentioned earlier, if the application retrieves the secret key from the database using the “kid” header, it may be vulnerable to SQL Injection.

Exploiting this vulnerability, it is possible to extract all the information stored in the database. Additionally, we can manipulate the response of the query executed by the application using “UNION SELECT,” allowing us to forge a fake secret key and sign the token with it.

See below an example of code that exhibits the SQL Injection vulnerability in the “kid” header:

app.get('/sql-injection', async(req,res) => {
    const token = req.headers['authorization'];

    if(!token) {
        return res.sendStatus(401);
    }

    try {
        const { header } = jwt.decode(token, { complete: true });
        
        if(!header.kid) {
            return res.sendStatus(401);
        }

        // SQL INJECTION HERE
        const [results, metadata] = await sequelize.query(`SELECT * FROM secrets WHERE uuid = '${header.kid}'`);

        if(results.length <= 0) {
            return res.sendStatus(401);
        }

        const secretKey = results[0].secret;

        const payload = jwt.verify(token, secretKey, { algorithms: ['HS256'] });

        if(payload.admin) {
            return res.json({ message: 'Challenge completed' });
        }

        return res.json(payload);

    } catch(e) {
        return res.sendStatus(500);
    }
})

As mentioned earlier, it is possible to extract data from the database through SQL Injection. However, since this article focuses on bypassing the token signature verification system, only the “UNION SELECT” technique will be discussed.

Before we proceed with exploiting the vulnerability, let’s analyze how “UNION SELECT” works:

We note that “UNION SELECT” adds an item to the query result, allowing manipulation of the database response. With this, we can provide a “fake” secret key and use the JWT signature.

 

Solving the challenge – KID SQL Injection

By sending the following JWT, we were able to authenticate ourselves in the application:

By sending a SQL Injection payload in the “kid” header, we were able to manipulate the query response using “UNION SELECT” and forge a “fake” secret key. This way, it is possible to change the token’s payload and set the value “true” in the “admin” field.

 

Sending token to the application:

Unfortunately, the JWT Hunter tool cannot help us with this type of attack, as it is specific to each scenario.

 

Mitigating vulnerability – SQL Injection into “KID” header

To mitigate this vulnerability, it is essential to sanitize the “kid” header value, preventing an attacker from executing SQL Injection. Another recommendation is to implement a whitelist, controlling the values ​​allowed in this header.

 

Asymmetric encryption in JWTs

Before we proceed with the next vulnerabilities, it is essential to understand the concept of asymmetric encryption.

Asymmetric encryption uses a pair of keys: one for encryption and one for decryption. In each pair, we have a private key and a public key.

When we encrypt a value using the private key, the only way to decrypt it is with the public key, and vice versa.

See an example image below:

In scenarios where we allow the client to perform data encryption, generally the public key is sent to it, while the server performs decryption using the private key. An example of this is HTTPS: in this protocol, the client encrypts the web request using the public key and sends it to the server, which decrypts the data with the private key, protecting against Man-in-the-Middle (MITM) attacks. .

In scenarios where we do not allow the client to perform data encryption, the public key must be used on the server during the decryption process. This way, the client will only be able to encrypt the data if they are in possession of the private key.

Therefore, when using asymmetric encryption in JWT, we must use the public key to verify the signature. Therefore, the client will only be able to sign the token if they are in possession of the private key, which normally does not happen.

 

JKU Header

The “JKU” (JSON Key URL) header is used to specify the URL of a JSON Web Key (JWK). A JSON Web Key (JWK) is a list of RSA keys, usually public keys, in JSON format. This format makes it easier to host keys on endpoints when necessary, such as when verifying a JWT signature using the “JKU” header.

See the flow of an application when the JKU header is used:

Basically, the flow works like this: the user sends the JWT to the application, which looks for the “JKU” header in the “header” field. Then, a request is made to the URL specified in this header, and the JWK obtained in the response is used to validate the JWT signature.

Example of JWT with “JKU” header:

 

Example of a public key in JSON Web Key (JWK) format:

 

JKU Header Injection – The attack

At first glance, many people may think of SSRF attacks, as the application makes an HTTP request to the URL specified in the “JKU” header. This would allow you to specify internal application URLs. However, this type of attack would be extremely limited and unlikely to have a significant impact.

What many don’t consider is that we can generate an RSA key pair, host the public key in a JWK, specify the URL of that JWK in the “JKU” header, and then use the private key to sign the JWT.

Note: If this attack seems confusing and complex, don’t worry, it really is! I recommend that you re-read the “Asymmetric encryption in JWTs” section and re-analyze the application flow demonstrated in the images above.

See the “JKU Header Injection” attack flow below:

Vulnerable code example:

app.get('/jku-header', async(req,res) => {
    const token = req.headers['authorization'];
    
    if(!token) {
        return res.sendStatus(401);
    }

    try {
        const { header } = jwt.decode(token, { complete: true });
        const jku = header.jku;

        // Step 2: Make HTTP request to JKU
        const jkuRequest = await fetch(jku);
        const jwks = await jkuRequest.json();

        // Step 3: Extract RSA from JWK (using external library)
        const publicKey = jwkToPem(jwks[0]);

        // Step 4: Verify JWT using RSA extracted from JWK
        const payload = jwt.verify(token, publicKey, { algorithms: ['RS256'] });

        if(payload.admin) {
            return res.json({ message: 'Challenge completed' });
        }

        return res.json(payload);

    } catch(e) {
        return res.sendStatus(500);
    }
});

 

Solving the challenge – JKU Header Injection

The first steps in this challenge are to create the key pair, generate a JWK derived from the public key and host it.

To generate the key pair, we can use the following commands:

openssl genrsa -out private.pem 2048
openssl rsa -in private.pem -outform PEM -pubout -out public.pem

With the key pair in hand, let’s generate the JWK derived from the public key using the “pem-jwk” tool:

sudo npm install -g pem-jwk
cat public.pem | pem-jwk > jwk.json

Note: When analyzing the code, we see that it downloads a list of JWK (known as JWKS) and gets the first item in that list. So we need to define an array and insert the JWK inside that array.

Hosting JWK on a web server using python3:

sudo python3 -m http.server 80

In the last step, we must create a JWT with the “JKU” header that specifies the URL of the JWK hosted on our server and sign the token using the private key.

Sending request using malicious JWT:

Fun fact: If we go back to the terminal where our web server is running, we will see that we have received an HTTP request from the server where the target application is hosted.

Unfortunately, jwthunter.io does not have functionality to “automatically” exploit this vulnerability. However, in the future we will launch new features, and exploring JKU Header Injection will be one of them!

 

Mitigating vulnerability – JKU Header Injection

To mitigate this vulnerability, simply create a whitelist of trusted domains and endpoints. Therefore, a user or attacker will not be able to specify other URLs whose domains or endpoints are not trusted.

 

Algorithm Confusion

If you understand the difference between symmetric encryption (which uses a secret key) and asymmetric encryption (which uses a pair of keys), you will understand this attack better.

The “Algorithm Confusion” vulnerability occurs when the application expects the JWT to use an asymmetric encryption algorithm, but a symmetric encryption algorithm is used instead.

As we saw previously, when the application uses asymmetric encryption, the signature verification is done with the public key (if you don’t understand, re-read the “Asymmetric Cryptography” section). If we can send a JWT with a symmetric encryption algorithm, we can sign it using the public key instead of the private key, since symmetric encryption requires the keys to be identical.

Flow expected by the application:

Algorithm Confusion attack flow:

You may be wondering: how to access the public key?

Many applications make their public keys available in JWK format on API endpoints (as we saw in the “JKU Header Injection” attack), which allows you to easily obtain these public keys to carry out the “Algorithm Confusion” attack.

See below the requirements for an application to be vulnerable to the “Algorithm Confusion” attack:

  • The application expects a JWT with an asymmetric encryption algorithm.
  • The system allows the user to control the algorithm used to validate the JWT.

 

Vulnerable code example:

app.get('/algorithm-confusion', async(req,res) => {
    const token = req.headers['authorization'];
    
    if(!token) {
        return res.sendStatus(401);
    }

    try {
        const { header } = jwt.decode(token, { complete: true });
    
        // JKU Header injection patch
        const jku_whitelist = ['localhost'];
        const jku_url = new URL(header.jku);
        if(!jku_whitelist.includes(jku_url.hostname)) {
            return res.sendStatus(401);
        }

        // Downloading remote JWK
        const request = await fetch(header.jku);
        const jwk = (await request.json())[0];
        
        // Extract public key from JWK
        const publicKey = jwkToPem(jwk)

        // Verify JWT using public key
        const payload = jwt.verify(token, publicKey, { algorithms: [header.alg] });
        
        if(payload.admin) {
           return res.json({ message: 'Challenge completed' }); 
        }

        return res.json(payload);

    } catch(e) {
        return res.sendStatus(500);
    }
});

 

Solving the challenge – Algorithm Confusion

By sending the following JWT, we are able to authenticate ourselves in the application:

The first step of this attack is to obtain the public key used to verify the JWT signature. Analyzing the code, we noticed that it obtains the public key of a JWK (it is not vulnerable to JKU Header Injection). So we can download this JWK and extract the public key from it.

For this step, we can create JavaScript code that uses the fetch function to download the JWK and the “jwk-to-pem” library to extract the public key:

(async () => {
  const jwkToPem = require('jwk-to-pem');
  const request = await fetch('http://localhost:8000/jwks.json');
  const jwk = (await request.json())[0];

  const publicKey = jwkToPem(jwk);

  console.log(publicKey);
})();

With the public key in hand, we can create a JWT using a symmetric algorithm, such as “HS256”, and sign the token using the public key as if it were a secret key.

First, let’s base64 encode the public key (this is necessary to ensure that all characters, including spaces and line breaks, are included):

By sending this JWT to the application, we were able to solve the challenge:

 

Solving the challenge using JWT Hunter – Algorithm Confusion

As this attack involves complex steps, such as converting a JWK to a public key and signing the token with that key, we developed functionality in jwthunter.io to perform this attack “automatically”.

Creating malicious JWT using JWT Hunter:

Note: If you do not have the JWK, but only the public key, you can uncheck the “Use JWK to sign token” option and paste the public key in the same text field.

 

Security implementation in the “jsonwebtoken” library

On December 21, 2022, the developers of the “jsonwebtoken” library implemented a new layer of security against the “Algorithm Confusion” attack. This implementation prevents a JWT from being signed or verified using a PEM key when its algorithm is symmetric encryption. This change was made in version “9.0.0”.

Note: In the Algorithm Confusion laboratory, the “jsonwebtoken” library in version “8.5.1” was used.

 

Mitigating vulnerability – Algorithm Confusion

To mitigate this vulnerability, it is necessary to define the algorithm used during the JWT signature validation process directly in the code, preventing the user from having control over it. Additionally, it is important to keep libraries updated to their latest stable versions.

 

Conclusion

In this article, we saw that there are several vulnerabilities in JWTs; however, most of them arise from poor handling of data obtained from headers. Therefore, even if you use the best library in the world, the most secure, you may still be vulnerable to attacks such as KID Header Injection, JKU Header Injection, among others, due to poor data handling in the application.

 

References

 

Logo da Hakai.