Skip to content

Pack and unpack words struct cannot describe - #721

Open
zardus wants to merge 4 commits into
masterfrom
feature/fix-word-width
Open

Pack and unpack words struct cannot describe#721
zardus wants to merge 4 commits into
masterfrom
feature/fix-word-width

Conversation

@zardus

@zardus zardus commented Aug 9, 2026

Copy link
Copy Markdown
Member

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Problem

Opening any ELF on a 24-bit architecture fails before analysis begins. Building the ELF TLS thread for tests/avr/isqrt_atmega128.o under avr8:LE:16:extended, whose word is three bytes:

I AVR 24-bit ELF TLS thread (isqrt_atmega128.o): RAISED error: bad char in struct format
I AVR 24-bit ELF TLS thread (isqrt_atmega128.o):   at memory.py:pack

ELFThreadManager writes the DTV pointer with pack_word, so no thread can be created and the load never completes. The same call fails for any width struct has no format character for:

A pack/unpack 3-byte word LE:  RAISED error: bad char in struct format
D pack/unpack 10-byte word:    RAISED ValueError: Invalid size: Must be a integer power of 2 less than 16

Root cause

ClemoryBase.unpack_word and pack_word are the only path a word of arbitrary width takes, and both handed the width straight to archinfo's struct_fmt. Only widths above 8 were handled at all, by halving:

if size is not None and size > 8:
    subsize = size >> 1
    if size != subsize << 1:
        raise ValueError("Cannot unpack non-power-of-two sizes")

struct has integer format characters for 1, 2, 4 and 8 bytes only, so everything at or below 8 was assumed to have one and 3 does not; pack_word had no recursion at all. Separately, both error handlers classified the failure with struct.calcsize(fmt) inside the except struct.error block, so a format string struct cannot parse raised a second struct.error from within the handler, replacing the original and chaining the classification failure on as __context__ — and a word running off the end of a backer was reported as struct.error rather than the KeyError that means "not mapped".

Fix

Widths outside frozenset((1, 2, 4, 8)) are read and written as bytes and recombined with int.from_bytes/to_bytes, so any positive size works, signed or unsigned, in either endianness. pack_word checks the whole word is backed before writing anything, so a short write leaves memory untouched. Classification moves to _classify_struct_error, which returns the exception for the caller to raise outside the handler and returns the original unchanged when calcsize itself fails.

A pack/unpack 3-byte word LE: stored=563412 read_back=0x123456
C pack/unpack 3-byte word signed: stored=feffff read_back=-0x2
E pack/unpack 16-byte word: stored=100f0e...030201 read_back=0x102030405060708090a0b0c0d0e0f10
F 3-byte word off the end of a 2-byte backer: unpack_word->KeyError pack_word->KeyError memory_after=0000
G Clemory.unpack malformed format: error: bad char in struct format | __context__=None
I AVR 24-bit ELF TLS thread: arch=avr8:LE:16:extended bits=24 bytes=3 thread=True dtv_offset=0x4006

Testing

tests/test_tls_resiliency.py::TestTlsResiliency::test_tls_24bit_arch loads tests/avr/isqrt_atmega128.o under the 24-bit p-code language and asserts thread.memory.unpack_word(offset + thread.tcb_offset) == thread.mapped_base + thread.dtv_offset, which is the address the relocation writes; tests/test_unpackword.py round-trips odd widths and pins the KeyError; tests/test_clemory.py pins the unchained struct.error. The fixture is on angr/binaries master. The test names the p-code language explicitly rather than relying on autodetection.

angr/archinfo#364 is the other half of this: each alone leaves the same objects failing at the other's defect, so the two have to land together, in either order.

Validation: #721 (comment)

sync: angr/archinfo#364

session: sharpen

@zardus

zardus commented Aug 9, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Validation record for head b5a0326b30f0c3fdfba508241ff24e372ee53f8a against baseline eac0e5540516b9199dd6a91933e80dc774ea3eac.

Re-keyed from cc251dd869f54a0c10b13781d755fcd16211ea5c. The branch was rebased and gained a fourth commit, b5a0326b "Compare the DTV pointer against the DTV's address", which changes one assertion in tests/test_tls_resiliency.py and nothing else. cle/memory.py is byte-identical to the version every figure below was measured on, and master changed none of cle/memory.py, cle/backends/tls/ or cle/backends/elf/ between d2ecea06 and this baseline. The test rows were re-run at this head anyway, and one of them corrects the earlier record.

Measured configuration: a detached worktree of cle at the revision named, this workspace's pinned Python 3.12 environment with archinfo at b8ffe85, nice -n 19, no xdist, -p no:randomly. The baseline arm reverts only cle/memory.py to eac0e554 and keeps the branch's tests, so the two arms differ by the production change alone. pylint is run with the CI configuration from angr/ci-settings, ci-image/conf/pylintrc, because cle declares no [tool.pylint] table and the bare defaults score about two points lower on every file.

  • Correction to the reproducer. The earlier record said cle.Loader on binaries/tests/avr/isqrt_atmega128.o with main_opts={"arch": archinfo.ArchPcode("avr8:LE:16:extended")} raises on the baseline. At this baseline it does not: the load succeeds on both arms, and it is ld.tls.new_thread() that raises struct.error: bad char in struct format, because that is where the DTV pointer is written a word at a time, through elf_tls.py:82 to elf_tls.py:118 _drop_int to memory.py:171 pack_word. archinfo.ArchPcode("avr8:LE:16:extended").struct_fmt() returns '<Z' on both. On this head new_thread() returns a thread and the DTV pointer reads back 0x4006, equal to mapped_base + dtv_offset. The defect and the fix are unchanged; the entry point named for them was wrong
  • Regression: python -m pytest tests/test_tls_resiliency.py2 passed on this head
  • Regression, wider: python -m pytest tests/test_tls_resiliency.py tests/test_unpackword.py tests/test_clemory.py10 passed on this head; with cle/memory.py at the baseline, 5 failed, 5 passed, the failures being test_tls_24bit_arch, test_word_sizes_struct_cannot_express, test_word_off_the_end_of_a_backer, test_clemory_malformed_format and test_clemory_read_only_view_malformed_format. test_cclemory runs here rather than being deselected, because this shell has a C compiler; the earlier record's "9 passed, 1 deselected" was that deselection, not a different result
  • Full suite: python -m pytest tests249 passed, 9 skipped in 27.8 s. The earlier record's 207 passed is master's growth, not this branch's. The nine skips are the pre-existing test_macho_bindinghelper.py ones
  • Lint: pylint per changed file, this head against the baseline — cle/memory.py flat at 10.00, tests/test_clemory.py 5.12 -> 6.43, tests/test_tls_resiliency.py 9.33 -> 9.68, tests/test_unpackword.py flat at 10.00. No file regresses

What the fourth commit is for, stated plainly. On this fixture the thread lands at mapped_base 0x0, so mapped_base + dtv_offset and dtv_offset are the same number and the old assertion passes here too — measured, not assumed, and both the previous heads cc251dd8 and d124129e were green on hosted CI. The commit exists so the test keeps asserting the invariant rather than the placement, for the same reason as cle#730: cle#765 moves the loader's rebase search off the null page, and a test pinned to 0 would then fail on a correct layout.

Behaviour outside the reported failure, compared against baseline by running the same matrix of sizes, signedness and endness under both revisions: pack_word and unpack_word still raise KeyError at the same address for an unmapped or short access, and a failed odd-width write now leaves memory untouched instead of storing the bytes that fit. unpack_word returns the same values for the one, two, four, eight, sixteen and thirty-two byte cases tests/test_unpackword.py already covered. Sizes above eight that are not powers of two used to raise ValueError and now work, as does pack_word above eight. Two differences show up only at the widths struct cannot name: a signed value that does not fit reports OverflowError from int.to_bytes rather than the struct.error the other widths report, and a word spanning two adjacent backers is composed rather than refused, because the composed path reads through load.

Scope: 14 of the 183 p-code languages pypcode 4.0.1.dev0 exposes report a three-byte word, and ArchPcode gives every p-code architecture a dtv_offsets of [0], so the DTV write happens for every ELF on one of them whether or not it has TLS data. It surfaced in a corpus sweep, where every AVR object loaded under one of the three 24-bit AVR languages failed to open.

Caveats: test_tls_24bit_arch skips without pypcode, which the testing dependency group installs. The test names avr8:LE:16:extended rather than letting the loader pick it, because opinion matching in cle/backends/elf/elf.py compares an opinion's secondary constraint against e_type where Ghidra means e_flags, so every EM_AVR ELF autodetects as the 16-bit default; the fixture's own e_flags do select the extended variant, and fixing the matching is separate work. The other struct_fmt callers in cle, elfcore note parsing and StaticWord externs, still have no way to describe a three-byte word and are left alone here; neither is reached by loading an ELF that has no core notes and no glibc imports. This fixes loading; CFGFast on a 24-bit architecture still hits unrelated defects further in, inside angr's p-code lifter.

Hosted CI at head b5a0326b30f0c3fdfba508241ff24e372ee53f8a is not green yet, read live 2026-08-29T20:36Z. Of 17 check runs, 4 have completed and all 4 are successci / Build, Test (Pyodide), Test windows-2022 and Test macos-15 — and 13 are still queued: all ten ci / Test shards, ci / Lint, ci / Typecheck and ci / Decompiler Snapshot Testing (0). Nothing has failed. Both legacy commit statuses are green, pre-commit.ci - pr and docs/readthedocs.org:cle. The workflow run is https://github.com/angr/cle/actions/runs/33270611794, still queued. This head was pushed minutes before this record was written; the two heads before it, cc251dd869f54a0c10b13781d755fcd16211ea5c and d124129e25e1eae5a352be54aa8fdc6100036954, were each 18 of 18 green, and the only branch change since is the one assertion described above. That is a reason to expect green, not evidence of it.

The sections below were measured on earlier heads whose cle/memory.py is byte-identical to this one's, against the cle master revisions each names.


Corpus measurement of the open queue, 2026-08-15 — 1 of 24 alone; a prerequisite, not an independent recovery

Correcting the record. This change clears almost none of its own class on its own.

The open pull-request queue was scored against 733 objects drawn from a sweep's own failing units (35 error classes, 49 architectures, 16 containers), with each repository's current master as the baseline rather than the revisions the sweep pinned. Each object is loaded with auto_load_libs=False, use_sim_procedures=False and then run through CFGFast(normalize=True, data_references=False, resolve_indirect_jumps=True, force_complete_scan=False) with a 120-second timeout.

Four of the narrow-p-code-architecture changes form a chain. Applied alone, each one clears part of its own class and leaves the rest of it standing on the next change's defect:

Applied alone Its own failure class Objects that complete CFGFast Where the remainder lands
archinfo#363 ValueError: negative shift count constructing ArchPcode — 1,673 units 18 of 30 RecursionError from state creation — angr#6807's defect
angr#6807 RecursionError from state creation — 1,125 units 20 of 30 KeyError: 24 at lift — angr#6793's defect
angr#6793 KeyError: 24 at lift — 1,423 units 20 of 30 struct.error in pack_word — cle#721's defect
cle#721 struct.error in pack_word — 772 units 1 of 24 KeyError: 24 at lift — back to angr#6793

Applied together — angr#6793 + cle#721 + cle#717 + archinfo#363 — the KeyError: 24 class goes from 20 of 30 to 30 of 30. Under the whole open queue all four classes are complete: 30 of 30, 30 of 30, 30 of 30 and 24 of 24, with no object lost in any class. Merging any one of them on its own therefore moves a fraction of the corpus units its own validation record describes, and the remainder only looks like a new failure.

One qualification about cle#717, which is included in the combination above: since its rebase onto cle#735 it carries no production change, only regression coverage, so nothing in the movement here is attributable to it. It guards the placement rule the other three depend on rather than supplying it.

For this PR specifically: of the 24 measured objects in the struct.error class, 1 completes CFGFast with this change alone. The other 23 move to KeyError: 24 out of Arch.struct_fmt at liftangr/angr#6793's defect, which the last caveat above anticipates in words ("This fixes loading; CFGFast on a 24-bit architecture still hits unrelated defects further in, inside angr's p-code lifter"). The number that belongs next to that sentence is 23 of 24. Under the whole open queue the class is complete at 24 of 24.

The loading claim is untouched: loading is what this PR fixes, and the 23 objects above do load. It is the corpus-recovery reading that has to be qualified — merged alone, this change converts one failure into a completed CFG, and 23 into a different failure.


Merge dependency: angr/angr#6932 and #721 are required together

Measured 2026-08-28, one variable at a time, on a private corpus; objects are cited by architecture, container and sha256 only.

The population is 3,452 objects — PIC-18, AVR and HCS12 firmware images loaded as blobs under a 24-bit SLEIGH language — all failing at angr/engines/pcode/lifter.py:lift with KeyError: 24. 0% of them is fixed on master: git log 0c293dc0d..origin/master -- angr/engines/pcode/ is empty, so the site is byte-identical between the revision the sweep pinned and current master, and a rerun at master reproduces it on every sampled object.

A/B over a stratified sample of 107 of those objects, 15 per SLEIGH language, one object per process under the invocation the failures came from — angr.Project(auto_load_libs=False, use_sim_procedures=False) then CFGFast(normalize=True, data_references=False, resolve_indirect_jumps=True, force_complete_scan=False), RLIMIT_AS 2.5 GB, 300 s:

Build ok error
angr b0feae57a, cle d2ecea06, archinfo f92307b3, all master 0 107 × KeyError: 24 at lifter.py:lift:961
+ angr/angr#6932 39 68 × struct.error: bad char in struct format at cle/memory.py:unpack:63
+ angr/angr#6932 + #721 107 0

With both applied the sample recovers 7,868 blocks and 909 functions, against 273 blocks and 89 functions from the 39 objects the lifter change alone gets through, and nothing at all on master.

So the lifter change alone moves 64% of the sample one layer down rather than closing it. The next failure is the read side of the same 3-byte word: CFGFast._next_code_addr_core_scan_for_consecutive_pointersCFGBase._fast_memory_load_pointerClemoryBase.unpack_wordClemoryBase.unpack, whose format string comes from Arch.struct_fmt(). Confirmed independently of the corpus, on archinfo master: archinfo.ArchPcode('HCS12:BE:24:default').struct_fmt() returns '>Z', and struct.unpack('>Z', b'\x00' * 3) raises struct.error: bad char in struct format.

Neither order is destructive and neither change breaks the other, but the population stays broken until both land, at whichever of the two defects is left. Example objects, each KeyError: 24 on master, struct.error with the lifter change alone, and analysed with both: HCS12 blob b01d5675622b68417d093b2ce24245e63e5f15b281fdd89e24cd449896379efd (21 blocks, 6 functions) and HCS-12X blob 95e51188ac8594c5a71afefa88b1e6bccf4406ad9ff2aea8df8b0d8c0b1b649d (21 blocks, 5 functions); the largest in the sample is PIC-18 blob 55d6904ba8d6dcf72adfe3efd039758dfec5c0ffe940b857298e65c2c4eb4573 at 1,752 blocks and 124 functions.

angr/archinfo#364 is not required, and should not be added to this pair. With both changes applied and archinfo left at master — fmt_size = "Z" still in arch.py — the same sample is 107/107 with the identical 7,868 blocks and 909 functions, because #721 composes a 3-byte word from its bytes and never asks struct_fmt for a size of 3. It is correctness hygiene for other callers, not a blocker; its head also predates the narrow-stack-pointer fix that has since merged to archinfo master.

Caveat: the sample is stratified by SLEIGH language rather than uniform, so the per-build counts describe the sample, and the population claim is the class the two changes close between them rather than a projection of block counts onto all 3,452 objects.

Note on the cle baseline named above. d2ecea06 was cle master when these runs happened; master has since moved one commit, to a4fb800, which rewrites the Mach-O magic, function-starts and entry-point error messages and changes nothing on the paths measured here.


Corpus evidence, 2026-08-28

Load-only measurement over a private corpus, so objects are cited by architecture, container format and sha256 only. Each object is loaded in its own process as angr.Project(path, auto_load_libs=False, use_sim_procedures=False) with the backend its recipe names; no analysis runs. Populations are deduplicated by object digest, because a ledger row is a run and a retry gives one object several rows. The master arm is cle d2ecea068794d20b1f14d90eecc1bc4bc4cfa431; master has since moved one commit, to a4fb800, which touches only cle/backends/macho/.

1,810 distinct objects terminate in cle/memory.py:pack:133, all AVR ELF — 1,578 relocatable and 232 executable. Each is loaded under one of three SLEIGH languages, and archinfo reports a 24-bit word for all three, so Arch.struct_fmt() returns '<Z': avr8:LE:16:atmega256 706, avr8:LE:24:xmega 586, avr8:LE:16:extended 518. (Two of the three ids read :16:; the width that matters is the one archinfo computes.) The frame is line 133, the handler, rather than the struct.pack_into call above it, because the handler's own struct.calcsize(fmt) raises the same error again.

None of them is fixed on master: 45 of a 45-object sample still terminate there.

On this head, cc251dd869f54a0c10b13781d755fcd16211ea5c, 45 of 45 load. That is this pull request alone, with archinfo at master — the archinfo half is not needed to get past load, because this change composes a three-byte word from its bytes instead of asking struct_fmt for a size of three.

@angr-bot

angr-bot commented Aug 9, 2026

Copy link
Copy Markdown
Member

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

@zardus

zardus commented Aug 26, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

This came out of assembling the angr/vibr preview snapshot (every green open PR merged together, then each component's full test suite run against the result with an angr/binaries checkout). Both PRs are green on their own.

With #765 applied, this PR's tests/test_tls_resiliency.py::TestTlsResiliency::test_tls_24bit_arch fails with assert 5259270 == 16390: AVR is 24-bit, so it takes #765's rewritten rebase branch, the TLS object maps at 0x500000, and InternalTLSRelocation.value (cle/backends/tls/tls_object.py:69) adds mapped_base, giving 0x504006 where the test expects the bare dtv_offset. Comparing against dtv_offset + tls_obj.mapped_base would keep the test valid under either placement. Details in the comment on #765, which the preview currently excludes.

@zardus
zardus force-pushed the feature/fix-word-width branch 2 times, most recently from 79eb856 to cc251dd Compare August 27, 2026 04:25
@zardus

zardus commented Aug 28, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Full word-width report before and after this change. Rows A-E pack and unpack words of widths struct has no format character for, F reads and writes one off the end of a two-byte backer, G-H pass a format string struct cannot parse, and I builds the ELF TLS thread for tests/avr/isqrt_atmega128.o under avr8:LE:16:extended, whose word is three bytes.

Before — every width struct cannot name fails, a short access is misreported as struct.error, a malformed format carries a second exception as its __context__, and the AVR object gets no thread at all:

cle at the merge base, d2ecea0
User specified <Arch avr8:LE:16:extended (LE)> but autodetected <Arch avr8:LE:16:default (LE)>. Proceed with caution.
cle: <cle at the merge base>/cle/__init__.py
A pack/unpack 3-byte word LE: RAISED error: bad char in struct format
A pack/unpack 3-byte word LE:   at memory.py:pack  |  if len(backer) - (addr - start) >= struct.calcsize(fmt):
B pack/unpack 3-byte word BE: RAISED error: bad char in struct format
B pack/unpack 3-byte word BE:   at memory.py:pack  |  if len(backer) - (addr - start) >= struct.calcsize(fmt):
C pack/unpack 3-byte word signed: RAISED error: bad char in struct format
C pack/unpack 3-byte word signed:   at memory.py:pack  |  if len(backer) - (addr - start) >= struct.calcsize(fmt):
D pack/unpack 10-byte word: RAISED ValueError: Invalid size: Must be a integer power of 2 less than 16
D pack/unpack 10-byte word:   at arch.py:struct_fmt  |  raise ValueError("Invalid size: Must be a integer power of 2 less than 16")
E pack/unpack 16-byte word: RAISED ValueError: Invalid size: Must be a integer power of 2 less than 16
E pack/unpack 16-byte word:   at arch.py:struct_fmt  |  raise ValueError("Invalid size: Must be a integer power of 2 less than 16")
F 3-byte word off the end of a 2-byte backer: unpack_word->error pack_word->error memory_after=0000
G Clemory.unpack malformed format: error: bad char in struct format | __context__=error
H ClemoryReadOnlyView.unpack malformed format (cached backer): error: bad char in struct format | __context__=error
I AVR 24-bit ELF TLS thread (isqrt_atmega128.o): RAISED error: bad char in struct format
I AVR 24-bit ELF TLS thread (isqrt_atmega128.o):   at memory.py:pack  |  if len(backer) - (addr - start) >= struct.calcsize(fmt):

After — every width round-trips in both endiannesses, a short access raises KeyError and leaves memory untouched, the malformed format is unchained, and the AVR thread's DTV slot reads back its own offset:

with this change, cc251dd
User specified <Arch avr8:LE:16:extended (LE)> but autodetected <Arch avr8:LE:16:default (LE)>. Proceed with caution.
cle: <cle with this change>/cle/__init__.py
A pack/unpack 3-byte word LE: stored=563412 read_back=0x123456
B pack/unpack 3-byte word BE: stored=123456 read_back=0x123456
C pack/unpack 3-byte word signed: stored=feffff read_back=-0x2
D pack/unpack 10-byte word: stored=0a090807060504030201 read_back=0x102030405060708090a
E pack/unpack 16-byte word: stored=100f0e0d0c0b0a090807060504030201 read_back=0x102030405060708090a0b0c0d0e0f10
F 3-byte word off the end of a 2-byte backer: unpack_word->KeyError pack_word->KeyError memory_after=0000
G Clemory.unpack malformed format: error: bad char in struct format | __context__=None
H ClemoryReadOnlyView.unpack malformed format (cached backer): error: bad char in struct format | __context__=None
I AVR 24-bit ELF TLS thread (isqrt_atmega128.o): arch=avr8:LE:16:extended bits=24 bytes=3 thread=True dtv_offset=0x4006 dtv_reads=['0x0->0x4006']

@zardus

zardus commented Aug 29, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Interaction with #765, and the assertion it moved

This branch and #765 are each green alone and cannot both be in a rollup: the
mono selection has been dropping #765 for it. The failure is
tests/test_tls_resiliency.py::TestTlsResiliency::test_tls_24bit_arch, and it is
an assertion that pinned an address rather than a disagreement about behaviour.

The test read the DTV pointer back out of the new thread and compared it against
thread.dtv_offset. That pointer is written through an InternalTLSRelocation,
so what is in memory after loading is mapped_base + dtv_offset -- which equals
dtv_offset only while the thread object is mapped at 0. #765 takes the objects
cle invents off the null page, so the thread lands at 0x500000 and the
assertion reads:

E   assert 5259270 == 16390
E    +  where 5259270 = unpack_word((0 + 16376))
E    +  and   16390 = <ELFTLSObjectV1 Object cle##tls, maps [0x500000:0x504605]>.dtv_offset

0x504006 against 0x4006: the DTV pointer is correct and points at the DTV,
and the comparand was the offset.

d124129 compares against thread.mapped_base + thread.dtv_offset, which is what
the relocation writes and what the pointer has to hold for the DTV to be
reachable. It is unchanged wherever mapped_base is 0, so it still passes on
this branch alone, and it still fails on this branch's merge base (d2ecea0) with
the error it was written for:

E   struct.error: bad char in struct format
cle/memory.py:131

Verification

master + #765 + #730 + #721, all three at the heads below, cle's whole suite:

257 passed, 9 skipped

Heads: #765 d160d975, #730 6c31c31, #721 d124129 (this push).

session: sharpen

zardus and others added 4 commits August 29, 2026 19:19
Clemory.unpack() and pack() catch struct.error to tell an access that ran off
the end of its backer, which is a KeyError, from an operation that genuinely
failed. They made that decision by calling struct.calcsize() on the same format
string, which raises the same error when the format is one struct cannot parse.
That exception escaped from inside the handler, so neither answer was reported
and callers saw a bare struct.error from a line that was only meant to be
measuring. ClemoryReadOnlyView.unpack() carries two more copies of that handler,
one for the cached backer and one for the general lookup.

Work out which exception to report first and raise it afterwards, outside the
handler, so a short access no longer carries the struct error as its context
either.
struct has integer format characters for 1, 2, 4 and 8 bytes only, so pack_word
had no way to write the 3-byte word of a 24-bit architecture, and unpack_word
covered wider words with a halving recursion that rejected every size that was
not a power of two. ELF TLS setup writes the DTV pointer with pack_word, so
opening any ELF on a 24-bit architecture failed with "bad char in struct format"
before analysis began.

Build a word of any width struct cannot name from its bytes instead. The write
checks that the whole word is backed first, because store() writes the bytes
that fit before it reports the overrun.
test_tls_24bit_arch assembled its own ELF32 header with struct.pack to reach an
architecture whose word is three bytes wide, so it exercised a container shape
no toolchain emits.

Load tests/avr/isqrt_atmega128.o from angr/binaries instead. Its e_flags name
the extended-address AVR variant, which Ghidra maps to avr8:LE:16:extended, a
24-bit language; name that language explicitly because cle picks the 16-bit
default for every EM_AVR ELF today.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The regression read the relocated DTV pointer out of the new thread and
compared it against thread.dtv_offset, which only agrees while the thread
object is mapped at 0. That is the loader's placement policy rather than
anything this test is about: cle#765 moves an invented object above the
image instead of onto the null page, and the assertion then reads
0x504006 against 0x4006 and fails on a thread that was set up correctly.

Compare against thread.mapped_base + thread.dtv_offset, which is what the
InternalTLSRelocation writes and what the pointer has to hold for the DTV
to be reachable. The assertion is unchanged where mapped_base is 0, and it
still fails on this branch's merge base with struct.error: bad char in
struct format, from cle/memory.py.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DqcAcuGLrNJViJrpdtFYCS
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