GHSA-JC38-X7X8-2XC8

Vulnerability from github – Published: 2026-06-18 21:09 – Updated: 2026-07-06 16:29
VLAI
Summary
PHP JWT Framework: JWSVerifier uses algorithm from unprotected header, enabling algorithm confusion attacks
Details

Summary

JWSVerifier::getAlgorithm() in src/Library/Signature/JWSVerifier.php (line 144) merges protected and unprotected headers using PHP's spread operator:

$completeHeader = [...$signature->getProtectedHeader(), ...$signature->getHeader()];

In PHP, when spreading arrays with duplicate string keys, the last array's values take precedence. Since the unprotected header (getHeader()) is spread second, an attacker can override the integrity-protected alg parameter by placing a different value in the unprotected header.

This creates a Time-of-Check/Time-of-Use (TOCTOU) vulnerability: 1. HeaderCheckerManager validates alg from the protected header 2. JWSVerifier uses alg from the unprotected header for actual verification

The same issue exists in JWEDecrypter.php (lines 120-124) where array_merge() exhibits the same last-wins behavior for alg and enc.

Affected Code

JWSVerifier.php line 144 — Spread operator merge order allows unprotected header to override alg:

$completeHeader = [...$signature->getProtectedHeader(), ...$signature->getHeader()];

JWEDecrypter.php lines 120-124array_merge() with same last-wins behavior:

$completeHeader = array_merge(
    $jwe->getSharedProtectedHeader(),
    $jwe->getSharedHeader(),
    $recipient->getHeader()
);

Attack Vectors

Vector A — Mixed key sets (HIGH probability)

If the application uses a JWKSet containing keys of different types (common in multi-tenant or federation scenarios), the JWSVerifier iterates all keys (line 86). An attacker can force a different algorithm that matches a different key in the set.

Vector B — alg ONLY in unprotected header (HIGH probability)

If alg is placed EXCLUSIVELY in the unprotected header (not in the protected header at all), HeaderCheckerManager::checkDuplicatedHeaderParameters() does NOT trigger. The JSON Flattened/General serializers allow tokens with no protected header or a protected header without alg. RFC 7515 Section 4.1.1 states alg MUST be integrity-protected, but the library does not enforce this.

Vector C — Direct JWSVerifier usage (HIGH probability)

JWSLoader takes ?HeaderCheckerManager (nullable). If developers use JWSVerifier directly or create JWSLoader without a HeaderCheckerManager, the duplicate header check never runs.

Contrast with JWSBuilder (safe)

JWSBuilder::findSignatureAlgorithm() (line 196) uses [...$header, ...$protectedHeader] where protected wins. It also has checkDuplicatedHeaderParameters() (line 218). The JWSVerifier has neither safeguard.

Proof of Concept

<?php
// Demonstrate algorithm override via unprotected header
$protected = ["alg" => "RS256", "typ" => "JWT"];
$unprotected = ["alg" => "HS256"];
$merged = [...$protected, ...$unprotected];
// $merged["alg"] === "HS256" — unprotected wins!

// JSON Flattened JWS with algorithm override:
$maliciousJws = json_encode([
    'payload' => base64url_encode($payload),
    'protected' => base64url_encode('{"alg":"RS256"}'),
    'header' => ['alg' => 'HS256'],  // OVERRIDE
    'signature' => base64url_encode($sig),
]);
// HeaderCheckerManager validates RS256 from protected header -> PASS
// JWSVerifier uses HS256 from unprotected header -> attacker's algorithm choice

A full working PoC demonstrating HS512-to-HS256 downgrade with mixed keysets is available upon request.

Suggested Fix

In JWSVerifier::getAlgorithm(), read alg exclusively from the protected header:

private function getAlgorithm(Signature $signature): Algorithm
{
    $protectedHeader = $signature->getProtectedHeader();
    if (! isset($protectedHeader['alg'])) {
        throw new InvalidArgumentException('The "alg" parameter must be in the protected header.');
    }
    return $this->signatureAlgorithmManager->get($protectedHeader['alg']);
}

For JWEDecrypter, reverse the merge order so protected header wins, or extract alg/enc exclusively from the protected header.

Résolution

Un correctif a été préparé sur une branche dédiée basée sur 3.4.x, avec des tests anti-régression dédiés (fork privé temporaire de cette advisory, PR #1).

JWS algorithm confusionJWSVerifier lit le paramètre alg exclusivement dans le header protégé en intégrité (RFC 7515 §4.1.1) ; un alg placé dans le header non protégé ne peut plus surcharger l'algorithme signé.

Validation : php -l OK, PHPUnit vert, aucune nouvelle erreur PHPStan introduite (différentiel nul vs 3.4.x), aucun commentaire ajouté dans le code source. Après merge, cascade prévue 3.4.x → 4.0.x → 4.1.x.

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "Packagist",
        "name": "web-token/jwt-library"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "3.4.10"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    },
    {
      "package": {
        "ecosystem": "Packagist",
        "name": "web-token/jwt-library"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "4.0.0"
            },
            {
              "fixed": "4.0.7"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    },
    {
      "package": {
        "ecosystem": "Packagist",
        "name": "web-token/jwt-library"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "4.1.0"
            },
            {
              "fixed": "4.1.7"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    },
    {
      "package": {
        "ecosystem": "Packagist",
        "name": "web-token/jwt-bundle"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "3.4.10"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    },
    {
      "package": {
        "ecosystem": "Packagist",
        "name": "web-token/jwt-bundle"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "4.0.0"
            },
            {
              "fixed": "4.0.7"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    },
    {
      "package": {
        "ecosystem": "Packagist",
        "name": "web-token/jwt-bundle"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "4.1.0"
            },
            {
              "fixed": "4.1.7"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    },
    {
      "package": {
        "ecosystem": "Packagist",
        "name": "web-token/jwt-experimental"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "3.4.10"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    },
    {
      "package": {
        "ecosystem": "Packagist",
        "name": "web-token/jwt-experimental"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "4.0.0"
            },
            {
              "fixed": "4.0.7"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    },
    {
      "package": {
        "ecosystem": "Packagist",
        "name": "web-token/jwt-experimental"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "4.1.0"
            },
            {
              "fixed": "4.1.7"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    },
    {
      "package": {
        "ecosystem": "Packagist",
        "name": "web-token/jwt-framework"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "3.4.10"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    },
    {
      "package": {
        "ecosystem": "Packagist",
        "name": "web-token/jwt-framework"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "4.0.0"
            },
            {
              "fixed": "4.0.7"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    },
    {
      "package": {
        "ecosystem": "Packagist",
        "name": "web-token/jwt-framework"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "4.1.0"
            },
            {
              "fixed": "4.1.7"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [],
  "database_specific": {
    "cwe_ids": [
      "CWE-345"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-06-18T21:09:17Z",
    "nvd_published_at": null,
    "severity": "HIGH"
  },
  "details": "## Summary\n\n`JWSVerifier::getAlgorithm()` in `src/Library/Signature/JWSVerifier.php` (line 144) merges protected and unprotected headers using PHP\u0027s spread operator:\n\n```php\n$completeHeader = [...$signature-\u003egetProtectedHeader(), ...$signature-\u003egetHeader()];\n```\n\nIn PHP, when spreading arrays with duplicate string keys, the **last array\u0027s values take precedence**. Since the unprotected header (`getHeader()`) is spread second, an attacker can override the integrity-protected `alg` parameter by placing a different value in the unprotected header.\n\nThis creates a Time-of-Check/Time-of-Use (TOCTOU) vulnerability:\n1. `HeaderCheckerManager` validates `alg` from the **protected** header\n2. `JWSVerifier` uses `alg` from the **unprotected** header for actual verification\n\nThe same issue exists in `JWEDecrypter.php` (lines 120-124) where `array_merge()` exhibits the same last-wins behavior for `alg` and `enc`.\n\n## Affected Code\n\n**JWSVerifier.php line 144** \u2014 Spread operator merge order allows unprotected header to override `alg`:\n```php\n$completeHeader = [...$signature-\u003egetProtectedHeader(), ...$signature-\u003egetHeader()];\n```\n\n**JWEDecrypter.php lines 120-124** \u2014 `array_merge()` with same last-wins behavior:\n```php\n$completeHeader = array_merge(\n    $jwe-\u003egetSharedProtectedHeader(),\n    $jwe-\u003egetSharedHeader(),\n    $recipient-\u003egetHeader()\n);\n```\n\n## Attack Vectors\n\n### Vector A \u2014 Mixed key sets (HIGH probability)\nIf the application uses a JWKSet containing keys of different types (common in multi-tenant or federation scenarios), the JWSVerifier iterates all keys (line 86). An attacker can force a different algorithm that matches a different key in the set.\n\n### Vector B \u2014 alg ONLY in unprotected header (HIGH probability)\nIf `alg` is placed EXCLUSIVELY in the unprotected header (not in the protected header at all), `HeaderCheckerManager::checkDuplicatedHeaderParameters()` does NOT trigger. The JSON Flattened/General serializers allow tokens with no protected header or a protected header without `alg`. RFC 7515 Section 4.1.1 states `alg` MUST be integrity-protected, but the library does not enforce this.\n\n### Vector C \u2014 Direct JWSVerifier usage (HIGH probability)\n`JWSLoader` takes `?HeaderCheckerManager` (nullable). If developers use `JWSVerifier` directly or create `JWSLoader` without a `HeaderCheckerManager`, the duplicate header check never runs.\n\n## Contrast with JWSBuilder (safe)\n\n`JWSBuilder::findSignatureAlgorithm()` (line 196) uses `[...$header, ...$protectedHeader]` where protected wins. It also has `checkDuplicatedHeaderParameters()` (line 218). The JWSVerifier has **neither** safeguard.\n\n## Proof of Concept\n\n```php\n\u003c?php\n// Demonstrate algorithm override via unprotected header\n$protected = [\"alg\" =\u003e \"RS256\", \"typ\" =\u003e \"JWT\"];\n$unprotected = [\"alg\" =\u003e \"HS256\"];\n$merged = [...$protected, ...$unprotected];\n// $merged[\"alg\"] === \"HS256\" \u2014 unprotected wins!\n\n// JSON Flattened JWS with algorithm override:\n$maliciousJws = json_encode([\n    \u0027payload\u0027 =\u003e base64url_encode($payload),\n    \u0027protected\u0027 =\u003e base64url_encode(\u0027{\"alg\":\"RS256\"}\u0027),\n    \u0027header\u0027 =\u003e [\u0027alg\u0027 =\u003e \u0027HS256\u0027],  // OVERRIDE\n    \u0027signature\u0027 =\u003e base64url_encode($sig),\n]);\n// HeaderCheckerManager validates RS256 from protected header -\u003e PASS\n// JWSVerifier uses HS256 from unprotected header -\u003e attacker\u0027s algorithm choice\n```\n\nA full working PoC demonstrating HS512-to-HS256 downgrade with mixed keysets is available upon request.\n\n## Suggested Fix\n\nIn `JWSVerifier::getAlgorithm()`, read `alg` exclusively from the protected header:\n\n```php\nprivate function getAlgorithm(Signature $signature): Algorithm\n{\n    $protectedHeader = $signature-\u003egetProtectedHeader();\n    if (! isset($protectedHeader[\u0027alg\u0027])) {\n        throw new InvalidArgumentException(\u0027The \"alg\" parameter must be in the protected header.\u0027);\n    }\n    return $this-\u003esignatureAlgorithmManager-\u003eget($protectedHeader[\u0027alg\u0027]);\n}\n```\n\nFor `JWEDecrypter`, reverse the merge order so protected header wins, or extract `alg`/`enc` exclusively from the protected header.\n\n## R\u00e9solution\n\nUn correctif a \u00e9t\u00e9 pr\u00e9par\u00e9 sur une branche d\u00e9di\u00e9e bas\u00e9e sur `3.4.x`, avec des tests anti-r\u00e9gression d\u00e9di\u00e9s (fork priv\u00e9 temporaire de cette advisory, PR #1).\n\n**JWS algorithm confusion** \u2014 `JWSVerifier` lit le param\u00e8tre `alg` exclusivement dans le header prot\u00e9g\u00e9 en int\u00e9grit\u00e9 (RFC 7515 \u00a74.1.1) ; un `alg` plac\u00e9 dans le header non prot\u00e9g\u00e9 ne peut plus surcharger l\u0027algorithme sign\u00e9.\n\n**Validation :** `php -l` OK, PHPUnit vert, aucune nouvelle erreur PHPStan introduite (diff\u00e9rentiel nul vs `3.4.x`), aucun commentaire ajout\u00e9 dans le code source. Apr\u00e8s merge, cascade pr\u00e9vue `3.4.x \u2192 4.0.x \u2192 4.1.x`.",
  "id": "GHSA-jc38-x7x8-2xc8",
  "modified": "2026-07-06T16:29:56Z",
  "published": "2026-06-18T21:09:17Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/web-token/jwt-framework/security/advisories/GHSA-jc38-x7x8-2xc8"
    },
    {
      "type": "WEB",
      "url": "https://github.com/FriendsOfPHP/security-advisories/blob/master/web-token/jwt-library/GHSA-jc38-x7x8-2xc8.yaml"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/web-token/jwt-framework"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:4.0/AV:N/AC:H/AT:N/PR:N/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N/E:P",
      "type": "CVSS_V4"
    }
  ],
  "summary": "PHP JWT Framework: JWSVerifier uses algorithm from unprotected header, enabling algorithm confusion attacks"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Loading…

Loading…

Related by attack behaviour

Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.


Loading…