GHSA-JC38-X7X8-2XC8

Vulnerability from github – Published: 2026-06-18 21:09 – Updated: 2026-06-18 21:09
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-framework"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "last_affected": "4.2.99"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    },
    {
      "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"
        }
      ]
    }
  ],
  "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-06-18T21:09:18Z",
  "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…

Detection rules are retrieved from Rulezet.

Loading…

Loading…