Description
This is a follow-up to the HS256/RS256 Type Confusion attack against the JWT protocol.
Now, firebase/php-jwt attempts to side-step this risk by forcing the user to hard-code the algorithms they wish to support.
Lines 103 to 108 in d2113d9
If $key
is an array, and $header
contains a kid
field, the key used to verify a token is determined by the kid
header.
Lines 114 to 123 in d2113d9
Reintroducing the Vulnerability
EDIT: 2021-08-11 - This example is a bit misleading. See the attached Proof of Concept file instead. php-jwt-poc.zip
Let's say you're a service that wants to check HS256
tokens against one key type and RS256
tokens against another. Your HS256
key has {"kid":"gandalf0"}
, while your RS256
public key has {"kid":"legolas1"}
.
You might call php-jwt like so:
<?php
$validated = JWT::decode(
$attackerControlledString,
[
'galdalf0' => '256-bit key goes here',
'legolas1' => 'RSA public key goes here'
],
['RS256', 'HS256']
);
If anyone ever sets up JWT like this:
Congratulations! you've just reintroduced the critical vulnerability in your usage of the app.
All you have to do is set {"alg":"HS256","kid":"legolas1"}
and use the SHA256 hash of the RSA public key as an HMAC key, and you can mint tokens all day long.
Another Way To Setup This Vulnerability
Let's say you have two different endpoints that only each accept one JWT signature algorithm.
/oauth
only allowsRS256
tokens/rpc
only allowsHS256
tokens
This is clearly a safer usage of the firebase/php-jwt library than the previous canned example.
Suppose you have a universal array that your application uses that maps keys--as is common with PHP frameworks.
<?php
return [
'galdalf0' => '256-bit key goes here',
'legolas1' => 'RSA public key goes here'
];
In this setup, once again, you've introduced a critical vulnerability into your application.
All an attacker needs to do is target your /rpc
endpoint and swap the Key ID from gandalf0
to legolas1
and they can mint tokens.
What's going on here?
The fundamental problem is that the keys passed to firebase/php-jwt are just strings. This flies in the face of cryptography engineering best practices: A key should always be considered to be the raw key material alongside its parameter choices.
Is this a security vulnerability?
This is not a vulnerability in the firebase/php-jwt library. It is, however, a very sharp edge that an unsuspecting developer could cut themselves on.
Cryptography should be easy to use, hard to misuse, and secure by default.
Whether the JOSE authors want to acknowledge it or not, what they published was a cryptographic protocol--one that fails to live up to these tenets. It's worth noting that PASETO mitigates this in its specification, so library authors don't have to even worry about it.
Any application that uses this library in the way described above has a critical vulnerability, so it may be prudent to publish a security advisory and/or obtain a CVE identifier. Update: This was assigned CVE-2021-46743
The good news is: This can be easily fixed.
The bad news is: It constitutes a backwards compatibility break.
How to Fix This Library
If you were to update the API to require keys to be a Keyring
object, which maps a string KeyID (kid
) to a JWTKey
object--and that JWTKey
object had a hard-coded algorithm that it could be used with--then this issue would be easily avoided.
Pseudocode
<?php
class JWTKey {
protected string $alg;
protected string $keyMaterial;
public function __construct(string $keyMaterial, string $alg) {}
public function isValidFor(string $headerAlg): bool
{
return hash_equals($this->alg, $headerAlg);
}
public function getKeyMaterial(): string
{
return $this->keyMaterial;
}
public function __toString()
{
return $this->keyMaterial;
}
}
<?php
declare(strict_types=1);
final class Keyring implements ArrayAccess {
/** @var array<string, JWTKey> $mapping */
private array $mapping;
public function mapKeyId(string $keyId, JWTKey $key): self
{
$this->mapping[$keyId] = $key;
return $this;
}
public offsetExists($offset): bool {
return array_key_exists($offset, $this->mapping);
}
public offsetGet($offset): JWTKey {
return $this->mapping[$offset];
}
public offsetSet($offset, $value): void {
$this->mapKeyId($offset, $value);
}
public offsetUnset(mixed $offset): void {
unset($this->mapping[$offset]);
}
}
- public static function decode($jwt, $key, array $allowed_algs = array())
+ public static function decode($jwt, JWTKey|Keyring $key, array $allowed_algs = array())
- if (\is_array($key) || $key instanceof \ArrayAccess) {
+ if ($key instanceof Keyring) {
if (isset($header->kid)) {
+ if (!$key->isValidFor($header->alg)) {
+ throw new UnexpectedValueException('This key cannot be used with ' . $header->alg);
+ }
// Check the signature
if (!static::verify("$headb64.$bodyb64", $sig, $key, $header->alg)) {
Edited to clarify the value of a security advisory and/or CVE to ensure users of this library remain safe against attack.