GHSA-f9j6-8p6x-r9j6

Suggest an improvement
Source
https://github.com/advisories/GHSA-f9j6-8p6x-r9j6
Import Source
https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/11/GHSA-f9j6-8p6x-r9j6/GHSA-f9j6-8p6x-r9j6.json
JSON Data
https://api.osv.dev/v1/vulns/GHSA-f9j6-8p6x-r9j6
Aliases
Published
2026-11-02T17:54:39Z
Modified
2026-11-02T18:00:04Z
Severity
  • 7.1 (High) CVSS_V4 - 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 CVSS Calculator
Summary
SD JWT JS: Tampered disclosures silently dropped instead of rejected
Details

Summary

When one Disclosure in an SD-JWT presentation is tampered so its digest no longer matches the Issuer-signed _sd array, @sd-jwt/decode 0.19.0 does not reject the presentation. It silently drops the unmatched Disclosure and returns a reduced claim set, reporting success. The Issuer signature is untouched and valid.

This is a downgrade/omission primitive. A malicious or curious holder can present a tampered Disclosure and have the relying party receive fewer claims than the credential carries, with no error surfaced. Where the relying party does not enforce key binding, an in-transit (on-path) attacker can achieve the same by mangling one Disclosure's bytes; where key binding is enforced, on-path tampering instead breaks the holder's KB-JWT sd_hash binding (so it is rejected), and the downgrade is then exercisable by the key-bound holder — confirmed end-to-end with KB required.

Two distinct tampering inputs converge on the same silent-drop sink:

  1. Genuinely non-decodable Disclosure (broken base64url -> bytes that are not valid UTF-8, e.g. an injected 0xF2 lead byte). RFC 9901 §7.1 step 3 / §4.2.1 require rejection. @sd-jwt/decode instead lossy-decodes it: the TextDecoder/Buffer path substitutes U+FFFD (REPLACEMENT CHARACTER) for the invalid byte and recovers a syntactically valid disclosure (the salt gains a U+FFFD, claim family_name = "Smith"). The recovered Disclosure then hashes to a digest absent from the signed _sd array, so it is silently skipped.
  2. Tampered-but-still-valid-JSON Disclosure (digest no longer matches _sd): same end state — the unmatched Disclosure is silently skipped.

Two RFC 9901 MUSTs compound:

  • §7.1 step 5 (primary): "If any Disclosure was not referenced by digest value in the Issuer-signed JWT (directly or recursively via other Disclosures), the SD-JWT MUST be rejected." — instead silently dropped.
  • §7.1 step 3 / §4.2.1 (secondary): a non-base64url-JSON Disclosure MUST be rejected — the lenient lossy decode skips this check.

Confirmed end-to-end through SDJwtInstance.verify() with a real ES256 issuer key, and again with key binding REQUIRED (a valid holder ES256 KB-JWT over aud+nonce+sd_hash).

Impact

Silent claim-set reduction is a downgrade/omission primitive. If a relying party's authorization logic is "default-allow when a constraining claim is absent" (an age / role / restriction attribute), dropping that one Disclosure flips the decision, with no error surfaced. The decoder reporting success on a tampered presentation is the core problem; tampering should be a hard reject. Because SD-JWT VC and the EU EUDIW ARF profiles reuse this disclosure layer, the exposure is to wallet/verifier deployments built on @sd-jwt/*.

Proof of concept (minimized — NOT a weaponized exploit)

A clean single-mutation credential: lowercase _sd_alg: "sha-256", an honest Issuer body whose _sd digests are computed over the valid wire Disclosures, and exactly one wire byte-segment tampered — the family_name Disclosure made genuinely non-decodable (an invalid-UTF-8 0xF2 byte injected into its base64url). Everything else is byte-for-byte valid.

The byte-exact credential cannot be faithfully pasted as text (it deliberately contains the invalid 0xF2 byte that triggers the lossy decode). It is provided as bug3_clean_tampered.cred with an untampered twin bug3_clean_control.cred, plus the minting script mint_bug3_clean.py and a three-oracle harness run_three_oracles.py — shared privately with the maintainer on first reply. Structure: 5 Disclosures, only index 1 (family_name) tampered; all other segments byte-for-byte valid.

Observed on the byte-identical credential (three independent oracles):

Oracle Version Verdict Detail
RFC 9901 reference (pure Python) reference impl REJECT R_DISCLOSURE_DECODE
sd-jwt (Daniel Fett's reference impl) pip 0.10.4 REJECT UnicodeDecodeError: 0xf2 ... invalid continuation byte
@sd-jwt/decode (this package) 0.19.0 ACCEPT reduced set {given_name, email, vct, iss} — family_name silently dropped, no error

Control (untampered, same body): all three oracles ACCEPT the full 5-claim set including family_name: "Smith" — confirming the single tampered byte is the only difference. Liveness: the @sd-jwt/decode harness REJECTs pure garbage ("not-a-jwt", "~~~~", "aaa.bbb.ccc~ZZZZ~", "") and ACCEPTs the clean control, so the ACCEPT above is a genuine behavioral divergence, not an accept-everything stub. No ready-to-run exploit against any deployed verifier is included.

Suggested fix

In the disclosure-decode loop of @sd-jwt/decode:

  1. Enforce §7.1 step 5: if any provided Disclosure is not referenced by a digest in the signed _sd array (directly or recursively), reject the entire presentation — do not silently skip it.
  2. Enforce §7.1 step 3 / §4.2.1: treat a Disclosure that fails base64url/JSON decode, or that is not a 3-element [salt, claimName, claimValue] array, as a fatal error. Replace the lenient TextDecoder/Buffer decode with a strict UTF-8 decode that throws on invalid bytes (do not substitute U+FFFD).
  3. Surface both rejections from the public verify() / decodeSdJwt() entry points, not only from an internal helper.

Related findings in the same library (MEDIUM — context for one combined patch)

  • Reserved claim name _sd accepted (§7.1 step 3). MEDIUM (conformance, issuer-side). Fix: reject if the claim name is a reserved key (_sd, _sd_alg, ...).
  • _sd_alg not validated case-sensitively / against the registry (§4.1.1, §7.1 step 2). MEDIUM (defense-in-depth). Accepts sha-1 and SHA-256 (wrong case, silently normalized to sha-256). Fix: exact, case-sensitive membership test against the supported registered set; no case normalization, no silent default.

The maintainer may prefer to address all three in one advisory/patch.

Credit

Found and reported by Anton Sokolov, Tyche Institute (Tallinn, Estonia), ORCID 0000-0003-2452-7096. Discovered via differential fuzzing of the SD-JWT disclosure layer against an RFC 9901 reference oracle and two reference libraries.

Database specific
{
    "cwe_ids":  [
        "CWE-176"
    ],
    "github_reviewed":  true,
    "github_reviewed_at":  "2026-11-02T17:54:39Z",
    "nvd_published_at":  "2026-10-29T17:18:10Z",
    "severity":  "HIGH"
}
References

Affected packages

npm / @sd-jwt/decode

Package

Name
@sd-jwt/decode
View open source insights on deps.dev
Purl
pkg:npm/%40sd-jwt/decode

Affected ranges

Type
SEMVER
Events
Introduced
0 Unknown introduced version / All previous versions are affected
Last Affected
0.19.0

Database specific

source
"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/11/GHSA-f9j6-8p6x-r9j6/GHSA-f9j6-8p6x-r9j6.json"

npm / @sd-jwt/core

Package

Name
@sd-jwt/core
View open source insights on deps.dev
Purl
pkg:npm/%40sd-jwt/core

Affected ranges

Type
SEMVER
Events
Introduced
0 Unknown introduced version / All previous versions are affected
Fixed
0.20.0

Database specific

last_known_affected_version_range
"<= 0.19.0"
source
"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/11/GHSA-f9j6-8p6x-r9j6/GHSA-f9j6-8p6x-r9j6.json"