Skip to content

PE TLS: do not crash on a TLS directory the object does not back - #830

Open
zardus wants to merge 1 commit into
masterfrom
feature/pe-tls-range
Open

PE TLS: do not crash on a TLS directory the object does not back#830
zardus wants to merge 1 commit into
masterfrom
feature/pe-tls-range

Conversation

@zardus

@zardus zardus commented Sep 7, 2026

Copy link
Copy Markdown
Member

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Problem

Loader.tls.new_thread() raises KeyError on a PE whose TLS directory points at
memory the image does not map. SimWindows.configure_project calls it unguarded,
so angr.Project fails on the binary even though cle.Loader itself succeeded.

Truncating a fixture angr/binaries already tracks reproduces it. Run from the
directory holding the binaries checkout, at 44a93d4f:

import logging, os, tempfile, cle
logging.basicConfig(level=logging.WARNING, format="%(levelname)s | %(name)s | %(message)s")
fd, p = tempfile.mkstemp(suffix=".exe")
os.write(fd, open("binaries/tests/x86/windows/TLS.exe", "rb").read()[:0x8000]); os.close(fd)
ld = cle.Loader(p, auto_load_libs=False)
th = ld.tls.new_thread()
  File "cle/backends/tls/pe_tls.py", line 100, in __init__
    image = thread_manager.initialization_image(obj)
  File "cle/backends/tls/tls_object.py", line 46, in initialization_image
    return obj.memory.load(obj.tls_data_start, obj.tls_data_size).ljust(obj.tls_block_size, b"\0")
  File "cle/memory.py", line 422, in load
    raise KeyError(addr)
KeyError: 110592

110592 is 0x1b000, which is tls_data_start exactly. The output comment has
both sides in full.

Root cause

initialization_image copies a module's initial TLS data out of the module's own
memory with obj.memory.load(tls_data_start, tls_data_size). For a PE both values
come from the TLS directory's StartAddressOfRawData and EndAddressOfRawData
(cle/backends/pe/pe.py, _register_tls). Clemory.load ends with
raise KeyError(addr) when no backer covers the start.

cle maps a PE as one blob built by PE._get_memory_mapped_image, which ends at the
last section's raw data, so an object's backed memory can stop well short of its
virtual extent. That method replicates pefile's equivalent but adds handling for
partially mapped sections, and it is what skips a section whose raw data runs past
the end of the file. A TLS directory pointing into that gap — the zero-fill tail of a
section whose VirtualSize exceeds its SizeOfRawData is the usual case — reads as
unbacked while the rest of the file is ordinary. Packed and damaged binaries reach
the same line from further out, naming a start hundreds of megabytes outside.

A range that merely runs off the end of a backer does not raise: Clemory.load
clips and returns a short buffer, which the caller zero-pads. Only an unbacked
start raises, and the two cases mean the same thing.

Fix

Two additions to initialization_image, beside the guards that already reject a
negative start and a negative size:

  • Zero-fill when the range is inside the object but nothing backs it. That gap
    reads as zeroes at run time, so the module gets the template it should have. On
    the reproducer above, new_thread() now returns and the image is the full 520 bytes
    the directory asks for. cle#538 gave pack_word the same treatment in this package. Nothing
    reached on that path is real data, so a tls_block_size that does not fit in the
    object is skipped there rather than allocated.

  • Skip TLS for the module when the range to be read ends past the object. It
    cannot then be describing the object's data. This is the disposition the two
    existing guards already use: initialization_image returns None,
    PETLSObject.__init__ skips the module, and get_tls_data_addr() for its index
    returns 0 rather than a pointer.

That second guard bounds only the range that is read, never tls_block_size. The
difference between the two is zero fill, which need not lie inside the object at all —
an ELF's .tbss is exactly that. The bound is exact: tls_data_start + tls_data_size == span
still yields an image, span + 1 yields None.

Deliberately not done: a directory naming a valid, backed range and a
multi-gigabyte zero fill still allocates it. That is master's behaviour and this
change neither widens nor narrows it.

Testing

No new regression test. The reproducer is a truncated copy of a tracked file rather
than compiler output, and I would rather give you the recipe than commit a fixture no
toolchain emits — say the word and I will add one to angr/binaries.

tests/test_tls_resiliency.py::test_tls_pe_incorrect_tls_data_start, which covers the
negative-start guard on i386/windows/2.exe, still passes, and the cle suite is green.

Measured on 18 Windows PE objects from a corpus that cannot be redistributed:
angr.Project(path) raises on all 18 before and constructs on all 18 after.
initialization_image is unchanged for every object with TLS reachable from
binaries/tests, and for every one of the 3,616 TLS-bearing objects in a
72,664-object public PE survey — none of which reproduces this, because a linker puts
the TLS template inside a section's raw data.

Validation: #830 (comment)

session: sharpen

Building the thread-local storage initialisation image read the module's
initial data with `obj.memory.load(tls_data_start, tls_data_size)`. Clemory
raises `KeyError` when nothing backs the start address, so a PE whose TLS
directory names memory the image does not map took `Loader.tls.new_thread()`
down, and with it `angr.Project` on that binary, because
`SimWindows.configure_project` calls it unguarded.

cle maps a PE as one blob built by `PE._get_memory_mapped_image`, which ends at
the last section's raw data. An object's backed memory can therefore end well
before its virtual extent, and a TLS directory pointing into that gap -- the
zero-fill tail of a section whose VirtualSize exceeds its SizeOfRawData, say --
reads as unbacked even though the rest of the file is ordinary. Packed and
damaged binaries reach the same line from further out, naming a start nowhere
near the image at all.

Two changes in `ThreadManager.initialization_image`, beside the guards that
already reject a negative start and a negative size:

- Zero-fill when the range is inside the object but nothing backs it. That gap
  reads as zeroes at run time. `Clemory.load` already returns a short read for
  a range that starts in mapped memory and runs out of it; it raises only when
  the start itself is unbacked, and the two mean the same thing here. Nothing
  reached on that path is real data, so a tls_block_size that does not fit in
  the object is skipped rather than allocated.

- Skip TLS for the module when the range to be read ends past the object, since
  it cannot then be describing the object's data. That guard bounds only the
  range that is read, never tls_block_size: the difference between them is zero
  fill, which need not lie inside the object at all, and an ELF's .tbss is
  exactly that.
@zardus

zardus commented Sep 7, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Validation record for head c55c706a9522fb1f24cfe9710a7db0e1ae170281, one commit on 0e77ade3c39a3cee05f65051e57955675e1ac21b.
Working tree clean. The gate below ran at 2d93395168b407dddfe831341e84bcb4a271cc1d. That commit and this
one have the same tree, 317ffdf3a8fbd70af5a478cb4ddc54379bef07aa, and git diff
between them is empty; only the commit message changed, twice, to correct wording. cle/backends/tls/tls_object.py sha256
70dc57958d2109e9cfe6523af7a9bc25ab0eafb49998116ae7176c6715eb20e9.

Local gate

All ten suites pass; isolation afterwards reports 72 shared objects unchanged in
content, inode and mtime.

suite result
workspace checks pass
test inputs in angr/binaries pass, 5 checkouts
tests import from a package pass, 6 checkouts
mono pipeline 134 tests, OK, 2 skipped
archinfo 38 passed, 34 subtests
pypcode 46 passed, 187 subtests
pyvex 65 passed
cle 261 passed, 9 skipped
angr (Python) 2722 passed, 68 skipped, 2 xfailed, 275 subtests
angr (Rust) 9 test targets, 0 failed

A green gate that skipped a suite is green over less than it appears to be.
Not run: pysoot and angr-management (absent from this tree), tests/llm and
tests/mcp (no pydantic_ai, no fastmcp), pre-commit and feature-instances
(need the network). The pre-commit hooks did run on the commit itself. The
merge-base Lint and Typecheck jobs were not run
; the tree that produced this gate
has no such suite, and the workspace runner installs packages to build one, which I
would not do beside other running jobs. ruff format --check and ruff check are
clean on the changed file.

Dependency boundary. The gate runs against a nix store environment, not the set
CI resolves from the pins, and package versions differ between them. What this change
depends on was checked rather than assumed. cle pins pefile==2024.8.26 exactly, and
that is the version in both environments, so every PE measurement below is on the
version CI uses. pyelftools is pinned only as >=0.29; it resolves to 0.33 in the
primary virtualenv and 0.32 here, so the ELF measurements were run under both — the
branch scan output is byte-identical between them, and ffs reports the same four
numbers.

The objects that fail today

18 Windows PE objects from a corpus that cannot be redistributed. Same interpreter,
patch stashed and unstashed:

  • before — 18/18 raise KeyError out of Clemory.load, its value equal to
    tls_data_start in all 18
  • after — 18/18 construct an angr.Project

11 take the repair path and get an image of 5 to 52 bytes; 7 have a directory whose
read range ends outside the object and are skipped. Their StartAddressOfRawData lands 380 MB to 2.33 GB past the end of the image, and
their tls_block_size runs to 5,413,028,670 bytes.

Nothing already tracked changes

initialization_image recorded for every object with tls_used reachable from
binaries/tests at 44a93d4f: 873 MZ or ELF files walked, 89 of which load and
yield a TLS record — ELF 56, PE 30, ExternObject 3. Zero differ. Nine
further files fail to load at all, identically in both arms, and yield no record.

This is what caught a wrong version of the patch, which bounded
max(tls_data_size, tls_block_size) and changed tests/x86_64/decompiler/ffs, whose
.tbss runs 16 bytes past its object (0xac38 + 1064 against a span of 0xb050).
Note that ffs no longer reaches that guard at all: its tls_data_size is 0, so it
returns earlier. It is evidence that the field relationship is real, not that the
guard as it now stands would misjudge it — over the 89 records there is no object
with a non-zero size whose tls_data_start + tls_block_size exceeds its span.

Reachability of the unbacked path

Over all 873 MZ/ELF candidates under binaries/tests, no ELF reaches the
except KeyError branch: 47 take the ordinary load and 9 return at
tls_data_size == 0. The mechanism is cle's own loader rather than the ELF format:
ELF._load_segment backs each PT_LOAD across its full page-aligned p_memsz,
zero-padding beyond p_filesz, so backed memory is the whole segment set and an
in-range address is unbacked only if it falls in a hole between segments. No file
measured here does that. That is why bounding tls_block_size on that path does not
reach .tbss.

On the truncated public fixture, with the start unbacked:

tls_block_size master this branch peak RSS
520 (real) KeyError image, 520 bytes 0 MB
2,000,000,000 (corrupt) KeyError skipped 0 MB

So the change trades no KeyError for an allocation: the second row is why the bound
sits inside the handler rather than only above it.

Public corpus survey

72,664 distinct public PE objects by content hash — 5,784 from a corpus of generated
Rust and Go cross-compilations and 66,880 from a mixed corpus, of which 61,135 are
generated loader- and UEFI-matrix objects carrying no TLS directory at all. The
12 decbench objects are byte-identical to 12 already inside the mixed corpus and are
not counted twice. tls.new_thread()
returns on all 72,528 that load; of the 136 that do not, 134 are ArchNotFound and
2 are an unrelated IndexError out of cle.Loader.

3,616 carry a TLS directory. Across those, tls_data_size runs 0 to 848 bytes, and
the maximum of (tls_data_start + tls_data_size) - span is -4160, so nothing in
public data comes within a page of the bound this change adds. The single negative
start is binaries/tests/i386/windows/2.exe, which the pre-existing guard covers.

@zardus

zardus commented Sep 7, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

The reproducer, before and after

binaries/tests/x86/windows/TLS.exe at angr/binaries 44a93d4f, truncated to
0x8000 bytes. cle skips the four sections whose raw data is now past the end of the file, and
.tls is one of them. The TLS directory still points where it always did:
StartAddressOfRawData 0x41b000, RVA 0x1b000, inside the object's virtual extent
of 0x1e43c — but now with nothing backing it.

Save as repro.py beside the binaries checkout and run it:

import logging, os, tempfile, cle
logging.basicConfig(level=logging.WARNING, format="%(levelname)s | %(name)s | %(message)s")
fd, p = tempfile.mkstemp(suffix=".exe")
os.write(fd, open("binaries/tests/x86/windows/TLS.exe", "rb").read()[:0x8000]); os.close(fd)
ld = cle.Loader(p, auto_load_libs=False)
o = ld.main_object
print("tls_data_start = %#x  tls_data_size = %d  object span = %#x"
      % (o.tls_data_start, o.tls_data_size, o.max_addr - o.mapped_base + 1))
th = ld.tls.new_thread()
print("new_thread() -> %r" % (th,))
print("module TLS data address = %#x" % th.get_tls_data_addr(o.tls_module_id))

Before — the KeyError out of Clemory.load:

full output
WARNING | cle.backends.pe.pe | Section b'.tls\x00\x00\x00\x00' has PointerToRawData 0x8200 and SizeOfRawData 0x400, which is out of bounds for the file size. Skipping this section.
WARNING | cle.backends.pe.pe | Section b'.gfids\x00\x00' has PointerToRawData 0x8600 and SizeOfRawData 0x200, which is out of bounds for the file size. Skipping this section.
WARNING | cle.backends.pe.pe | Section b'.00cfg\x00\x00' has PointerToRawData 0x8800 and SizeOfRawData 0x200, which is out of bounds for the file size. Skipping this section.
WARNING | cle.backends.pe.pe | Section b'.rsrc\x00\x00\x00' has PointerToRawData 0x8a00 and SizeOfRawData 0x600, which is out of bounds for the file size. Skipping this section.
tls_data_start = 0x1b000  tls_data_size = 520  object span = 0x1e43c
Traceback (most recent call last):
  File "repro.py", line 9, in <module>
    th = ld.tls.new_thread()
         ^^^^^^^^^^^^^^^^^^^
  File "cle/backends/tls/tls_object.py", line 49, in new_thread
    thread = self._thread_cls(self)
             ^^^^^^^^^^^^^^^^^^^^^^
  File "cle/backends/tls/pe_tls.py", line 100, in __init__
    image = thread_manager.initialization_image(obj)
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "cle/backends/tls/tls_object.py", line 46, in initialization_image
    return obj.memory.load(obj.tls_data_start, obj.tls_data_size).ljust(obj.tls_block_size, b"\0")
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "cle/memory.py", line 422, in load
    raise KeyError(addr)
KeyError: 110592

110592 is 0x1b000, so the address Clemory could not find a backer for is
tls_data_start itself.

After — the module gets its TLS block:

full output
WARNING | cle.backends.pe.pe | Section b'.tls\x00\x00\x00\x00' has PointerToRawData 0x8200 and SizeOfRawData 0x400, which is out of bounds for the file size. Skipping this section.
WARNING | cle.backends.pe.pe | Section b'.gfids\x00\x00' has PointerToRawData 0x8600 and SizeOfRawData 0x200, which is out of bounds for the file size. Skipping this section.
WARNING | cle.backends.pe.pe | Section b'.00cfg\x00\x00' has PointerToRawData 0x8800 and SizeOfRawData 0x200, which is out of bounds for the file size. Skipping this section.
WARNING | cle.backends.pe.pe | Section b'.rsrc\x00\x00\x00' has PointerToRawData 0x8a00 and SizeOfRawData 0x600, which is out of bounds for the file size. Skipping this section.
WARNING | cle.backends.tls.tls_object | The provided object's TLS data at 0x1b000 is not mapped. Zero-filling.
tls_data_start = 0x1b000  tls_data_size = 520  object span = 0x1e43c
new_thread() -> <PETLSObject Object cle##tls, maps [0x600000:0x600607]>
module TLS data address = 0x600400

The image is the full 520 bytes the directory asks for, zero-filled, and the
module's slot in the TLS array points at it.

Absolute paths in both tracebacks were shortened to repository-relative ones; the
text is otherwise as printed.

The other branch

The function's other new path — skipping TLS when the range to be read ends past
the object — cannot be shown on a public binary, because no public PE I tested has
a TLS directory that reaches it. It fires on packed samples whose
StartAddressOfRawData lands between 380 MB and 2.33 GB past the end of the image. The
validation record has the numbers.

@angr-bot

angr-bot commented Sep 7, 2026

Copy link
Copy Markdown
Member

Corpus decompilation diffs can be found at angr/dec-snapshots@master...angr/cle_830

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants