GHSA-F9J6-8P6X-R9J6
Vulnerability from github – Published: 2026-11-02 17:54 – Updated: 2026-11-02 17:54Summary
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:
- Genuinely non-decodable Disclosure (broken base64url -> bytes that are not valid UTF-8, e.g. an injected
0xF2lead byte). RFC 9901 §7.1 step 3 / §4.2.1 require rejection.@sd-jwt/decodeinstead lossy-decodes it: theTextDecoder/Bufferpath substitutes U+FFFD (REPLACEMENT CHARACTER) for the invalid byte and recovers a syntactically valid disclosure (thesaltgains a U+FFFD, claimfamily_name="Smith"). The recovered Disclosure then hashes to a digest absent from the signed_sdarray, so it is silently skipped. - 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
_sdaccepted (§7.1 step 3). MEDIUM (conformance, issuer-side). Fix: reject if the claim name is a reserved key (_sd,_sd_alg, ...). _sd_algnot validated case-sensitively / against the registry (§4.1.1, §7.1 step 2). MEDIUM (defense-in-depth). Acceptssha-1andSHA-256(wrong case, silently normalized tosha-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.
{
"affected": [
{
"package": {
"ecosystem": "npm",
"name": "@sd-jwt/decode"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"last_affected": "0.19.0"
}
],
"type": "ECOSYSTEM"
}
]
},
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 0.19.0"
},
"package": {
"ecosystem": "npm",
"name": "@sd-jwt/core"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "0.20.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-62374"
],
"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"
},
"details": "## Summary\n\nWhen 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.\n\nThis 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\u0027s bytes; where key binding **is** enforced, on-path tampering instead breaks the holder\u0027s KB-JWT `sd_hash` binding (so it is rejected), and the downgrade is then exercisable by the key-bound holder \u2014 confirmed end-to-end with KB required.\n\nTwo distinct tampering inputs converge on the same silent-drop sink:\n\n1. **Genuinely non-decodable Disclosure** (broken base64url -\u003e bytes that are not valid UTF-8, e.g. an injected `0xF2` lead byte). RFC 9901 \u00a77.1 step 3 / \u00a74.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.\n2. **Tampered-but-still-valid-JSON Disclosure** (digest no longer matches `_sd`): same end state \u2014 the unmatched Disclosure is silently skipped.\n\nTwo RFC 9901 MUSTs compound:\n- **\u00a77.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.\" \u2014 instead silently dropped.\n- **\u00a77.1 step 3 / \u00a74.2.1 (secondary):** a non-base64url-JSON Disclosure MUST be rejected \u2014 the lenient lossy decode skips this check.\n\nConfirmed 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`).\n\n## Impact\n\nSilent claim-set reduction is a downgrade/omission primitive. If a relying party\u0027s 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/*`.\n\n## Proof of concept (minimized \u2014 NOT a weaponized exploit)\n\nA 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 \u2014 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.\n\nThe 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` \u2014 shared privately with the maintainer on first reply. Structure: 5 Disclosures, only index 1 (`family_name`) tampered; all other segments byte-for-byte valid.\n\nObserved on the byte-identical credential (three independent oracles):\n\n| Oracle | Version | Verdict | Detail |\n|---|---|---|---|\n| RFC 9901 reference (pure Python) | reference impl | **REJECT** | `R_DISCLOSURE_DECODE` |\n| `sd-jwt` (Daniel Fett\u0027s reference impl) | pip 0.10.4 | **REJECT** | `UnicodeDecodeError: 0xf2 ... invalid continuation byte` |\n| `@sd-jwt/decode` (this package) | 0.19.0 | **ACCEPT** | reduced set `{given_name, email, vct, iss}` \u2014 `family_name` silently dropped, no error |\n\n**Control (untampered, same body):** all three oracles ACCEPT the full 5-claim set including `family_name: \"Smith\"` \u2014 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.\n\n## Suggested fix\n\nIn the disclosure-decode loop of `@sd-jwt/decode`:\n1. **Enforce \u00a77.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 \u2014 do not silently skip it.\n2. **Enforce \u00a77.1 step 3 / \u00a74.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).\n3. Surface both rejections from the public `verify()` / `decodeSdJwt()` entry points, not only from an internal helper.\n\n## Related findings in the same library (MEDIUM \u2014 context for one combined patch)\n\n- **Reserved claim name `_sd` accepted** (\u00a77.1 step 3). MEDIUM (conformance, issuer-side). Fix: reject if the claim name is a reserved key (`_sd`, `_sd_alg`, ...).\n- **`_sd_alg` not validated case-sensitively / against the registry** (\u00a74.1.1, \u00a77.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.\n\nThe maintainer may prefer to address all three in one advisory/patch.\n\n## Credit\n\nFound 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.",
"id": "GHSA-f9j6-8p6x-r9j6",
"modified": "2026-11-02T17:54:39Z",
"published": "2026-11-02T17:54:39Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/openwallet-foundation/sd-jwt-js/security/advisories/GHSA-f9j6-8p6x-r9j6"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-62374"
},
{
"type": "WEB",
"url": "https://github.com/openwallet-foundation/sd-jwt-js/pull/380"
},
{
"type": "WEB",
"url": "https://github.com/openwallet-foundation/sd-jwt-js/commit/3d8a72a0a279d67439db038c9c2b52621568b866"
},
{
"type": "PACKAGE",
"url": "https://github.com/openwallet-foundation/sd-jwt-js"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "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",
"type": "CVSS_V4"
}
],
"summary": "SD JWT JS: Tampered disclosures silently dropped instead of rejected"
}
Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.
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.
Vulnerability timeline
Sightings and recorded dates, grouped by day · UTC
How durations are calculated
Following the VTEM framework (Black Hat USA 2015), time to patch starts when the vendor or a coordinator was informed, total time to patch at discovery, and disclosure to patch at public disclosure; vendor response time runs from the report to its acknowledgement. Time to exploit and time unpatched start at the earliest discovery or report, falling back to public disclosure. Unpatched time ends at the first recorded patch, or the calculation time when no patch is recorded. Exploitation to patch shows the interval between those two events; a published proof of concept is a public exploit, not exploitation.
Each result shows its source dates. A negative interval means the end event came first; when date-only inputs leave the order open, the result says so. Date-only inputs are approximate. The earliest evidence wins: KEV report dates and sightings are labelled as such. Records describing another vulnerability are listed but never calculated from, and a PoC alone does not establish exploitation. Missing patch evidence does not prove that no patch exists. Filters below change the event list only.
Loading timeline…
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
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.