I was writing some challenges for PacketWars at TROOPERS22. One was intended to be a JWT key confusion challenge where the public key from an RSA JWT should be recovered and used to sign a symmetric JWT. For that, I was searching for a library vulnerable to JWT key confusion by default and found lua-resty-jwt. The original repository by SkyLothar is not maintained and different from the library that is installed with the LuaRocks package manager. The investigated library is a fork of the original repository, maintained by cdbattags in version 0.2.3 and was downloaded more than 4.8 million times according to LuaRocks.

While looking at the source code I found a way to circumvent authentication entirely.

I was looking at the verify_jwt_obj function, which verifies the JWTs. The alg_whitelist is nil by default, and since the secret is a string, this function is vulnerable to JWT key confusion. But that is not the only issue here. The library also supports JWE objects, which are verified by verify_jwe_obj. You can spot the issue in the following code:

function _M.verify_jwt_obj(self, secret, jwt_obj, ...)
  if not jwt_obj.valid then
    return jwt_obj
  end

  -- validate any claims that have been passed in
  if not validate_claims(self, jwt_obj, ...) then
    return jwt_obj
  end

  -- if jwe, invoked verify jwe
  if jwt_obj[str_const.header][str_const.enc]  then
    return verify_jwe_obj(jwt_obj)
  end

  local alg = jwt_obj[str_const.header][str_const.alg]

  local jwt_str = string_format(str_const.regex_jwt_join_str, jwt_obj.raw_header , jwt_obj.raw_payload , jwt_obj.signature)

  if self.alg_whitelist ~= nil then
    if self.alg_whitelist[alg] == nil then
      return {verified=false, reason="whitelist unsupported alg: " .. alg}
    end
  end

The problem is that it distinguishes JWTs from JWEs by the enc header, but what stops us from creating a JWT object with an enc header? However, the verify_jwe_obj may do more checks. Let’s have a look at it.

local function verify_jwe_obj(jwt_obj)

  if jwt_obj[str_const.header][str_const.enc]  ~= str_const.A256GCM then -- tag gets authenticated during decryption
    local _, mac_key, _ = derive_keys(jwt_obj.header.enc, jwt_obj.internal.key)
    local encoded_header = jwt_obj.internal.encoded_header

    local encoded_header_length = binlen(encoded_header)
    local mac_input = table_concat({encoded_header , jwt_obj.internal.iv, jwt_obj.internal.cipher_text,
                                    encoded_header_length})
    local mac = hmac_digest(jwt_obj.header.enc, mac_key,  mac_input)
    local auth_tag = string_sub(mac, 1, #mac/2)

    if auth_tag ~= jwt_obj.signature then
      jwt_obj[str_const.reason] = "signature mismatch: " ..
      tostring(jwt_obj[str_const.signature])
    end
  end

  jwt_obj.internal = nil
  jwt_obj.signature = nil

  if not jwt_obj[str_const.reason] then
    jwt_obj[str_const.verified] = true
    jwt_obj[str_const.reason] = str_const.everything_awesome
  end

  return jwt_obj
end

A closer look into verify_jwe_obj shows that there is a special enc value A256GCM. During decryption this value will lead to token verification. However, this only happens if the token was parsed by the JWE parser, not by the JWT parser. If this special check did not exist, the jwt_obj does not have an internal field, and an error would occur when accessing the key from a nil value and we could not exploit the issue.

These conditions result in an attack path that bypasses all signature checks when parsing a JWT with an enc header with the value A256GCM. The verifier checks the JWT as if it was a JWE and assumes that the parser did check the JWT and its signature. As no signature is checked, we can bypass token verification and, consequently, authentication.

03.06.2022 – First contact to cdbattags and start of 90-day disclosure period.
28.07.2022 – First response by cdbattags
29.07.2022 – ERNW provides a patch
13.06.2023 – ERNW issues a public Github issue – first public disclosure of the issue.
10.07.2023 – ERNW opens a public pull request for the patch
02.08.2023 – Pull request merged into master
10.10.2023 – Publication of this blog post

A release of lua-resty-jwt v0.2.4 is still awaited. We will update this blog post accordingly.

Open Source and dependencies are a common occurrence in professional development these days. Development teams seldomly perform code reviews for third-party dependencies, even though software vulnerabilities may also originate from the libraries and toolkits incorporated.

Unfortunately, often open-source developers are not paid for their effort. Consequently, they sometimes require more time to fix issues. In such cases, it causes the resolution of security issues to take longer as more complex issues need to be reproduced and potential fixes be discussed. After this, it’s important to verify that the proposed fix effectively mitigates the vulnerability without introducing new problems. This involves extensive testing, which can be time-consuming, even when security researchers supply respective patches. Furthermore, researchers as well as maintainers may have other commitments, and a backlog of issues to address.

Best,
Nils