Skip to content

Encode R_ARM_THM_CALL over its full 25-bit displacement - #733

Open
zardus wants to merge 2 commits into
masterfrom
feature/fix-cle-arm-thm-call
Open

Encode R_ARM_THM_CALL over its full 25-bit displacement#733
zardus wants to merge 2 commits into
masterfrom
feature/fix-cle-arm-thm-call

Conversation

@zardus

@zardus zardus commented Aug 10, 2026

Copy link
Copy Markdown
Member

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Loading an ARM static archive aborts with CLEOperationError: Jump target out of range for reloc R_ARM_THM_CALL and nothing loads. The relocation encodes its displacement as x[24:1], a signed 25-bit field reaching ±16 MiB, but decodes the implicit addend from 24 bits and range-checks against 24. CLE maps each archive member on its own rebase_granularity boundary and the extern object last, so a ten-member archive already puts the extern stubs out of that range.

This widens both to the 25 bits the encoder writes, and warns rather than raises when a displacement really does not fit, matching R_AARCH64_CALL26. Mapping the extern object within reach would avoid the truncation, but that is a loader-wide layout change.

The regression loads libmbed.a from tests_src/i2c_master_read-nucleol152re in angr/binaries, the 43-member mbed GCC ARM static library built alongside tests/armel/i2c_api.o. Spread over its members, 251 Thumb branches land within reach of their extern stub and 57 of those are past the 8 MiB the old sign extension allowed, while 219 are past 16 MiB, so the widened range and the out-of-range warning both come out of one real load. That library is already on angr/binaries master, so this needs no binaries PR.

Validation: #733 (comment)

The encoder writes the displacement as x[24:1], a signed 25-bit field
reaching +-16 MiB, but the addend decoder read back only 24 of those bits
and the range check rejected anything wider than a signed 24-bit value.
Loading an ARM static archive therefore aborted with CLEOperationError,
returning no object at all, once CLE had spread the members and the extern
object more than 8 MiB apart -- which it does by default past nine members.

Decode the addend over the same 25 bits the encoder writes, widen the range
check to match, and warn instead of raising when a displacement genuinely
does not fit, as the AArch64 branch relocations already do. A call CLE
cannot reach is no more fatal than any other unresolved external call.
@zardus

zardus commented Aug 10, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Validation record for head 76c057758cb8425938c667a6524e4bc194b960e0 against baseline b58ea02a446106647cdaae32bdf91b7062404cc1. The production hunk in cle/backends/elf/relocation/arm.py is byte-identical to the one recorded for 85de635; what changed since is the regression, which now loads a real static library instead of building an ar container at run time.

  • Regression: python -m pytest tests/test_arm_relocations.py — 3 passed on head, 3 failed with the production hunk reverted. Reverted, the two archive tests raise CLEOperationError: Jump target out of range for reloc R_ARM_THM_CALL (+- 2^23) from cle/backends/elf/relocation/arm.py:352 and the loader returns no object at all, while test_wide_implicit_addend reads an implicit addend of -0x800004 back as -4 and so lands on the extern stub itself, 0x1100000, instead of 8 MiB short of it at 0x900000.
  • Hunk necessity: baseline plus the widened range check alone still fails test_wide_implicit_addend with that same off-by-8-MiB target; baseline plus the addend sign extension alone still fails both archive tests with that same CLEOperationError.
  • Full suite: cle 205 passed, 9 skipped, against 202 passed, 9 skipped on the baseline. The delta is the three new tests; the 9 skips are pre-existing TODO markers in tests/test_macho_bindinghelper.py.
  • Lint/type: pylint and pyright against the merge base, per changed file — cle/backends/elf/relocation/arm.py 9.42 -> 9.42 and pyright badness 0.3139 -> 0.3125; tests/test_arm_relocations.py scores 10.00/10.00 with badness 0.0.
  • Pre-commit: all 24 configured hooks over all files, no rewrites, tree unchanged.
  • Test inputs: the regression assembles no container and adds no binary to cle; both files it loads are already in angr/binaries.
  • No collateral change: all 75 ARM binaries under tests/armel and tests/armhf in angr/binaries load on head with byte-identical relocated memory to the baseline, and the same 7 unrelated load errors on both.
  • Hosted checks: green on this head, Linux, macOS and Windows. The first Windows attempt errored during collection on all 52 test files, before any of them ran, because pytest -n auto raced several xdist workers through pyvex.native._parse_ffi_str, where os.replace onto the shared %TEMP%\pyvex_ffi_parser_cache... fails with PermissionError: [WinError 5] while another worker has it open. That is a pyvex bug on Windows, unrelated to this change; the re-run passed.

Reproducer. tests_src/i2c_master_read-nucleol152re/mbed/TARGET_NUCLEO_L152RE/TOOLCHAIN_GCC_ARM/libmbed.a on angr/binaries master (sha256 208ef1c3de547201b37ecb8c76960542ed0dae28a2818ec579c0bd41aad6e737) is the 43-member mbed GCC ARM static library from the build that also produced tests/armel/i2c_api.o; its members are Thumb ET_REL objects whose calls into the mbed HAL are REL R_ARM_THM_CALL and R_ARM_THM_JUMP24, so the addends come out of the instructions. Load it with default options:

cle.Loader("libmbed.a", auto_load_libs=False)

The baseline raises CLEOperationError and returns nothing. Head loads all 43 members. CLE gives each its own rebase_granularity boundary and maps the extern object last, which spreads the 470 resolved R_ARM_THM_CALL sites over displacements from a few hundred KiB to 42 MiB — both sides of a Thumb branch's reach in one load. Decoding every patched instruction back with an independent Thumb BL decoder, all 251 branches within ±16 MiB land exactly on their resolved symbol, 57 of them beyond the ±8 MiB the old sign extension allowed, and the 219 genuinely out of reach are truncated with one warning each.

Caveats: the finding came from a corpus sweep of real ARM static archives that cannot be published, but the case it found reproduces on a library already in angr/binaries, so no binaries PR is needed. R_ARM_THM_JUMP19 and R_ARM_THM_JUMP6 subclass R_ARM_THM_CALL and inherit its BL encoding, which is wrong for their narrower fields; nothing in the sweep exercises them and this change does not address it.

@angr-bot

Copy link
Copy Markdown
Member

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

The archive test wrote its own ar container at run time, stacking copies of
one object until the extern stubs landed out of reach. That is a test input
committed to the wrong repository with extra steps, and a container no
toolchain emits: the members were identical, the symbol table was absent,
and the member spacing was whatever the test chose rather than whatever a
real library happens to have.

binaries already carries a real one. libmbed.a is the static library from
the mbed GCC ARM build that produced tests/armel/i2c_api.o, and loading it
puts 251 Thumb branches in reach of their extern stub -- 57 of them past the
8 MiB the old sign extension allowed -- and 219 past 16 MiB, so both the
widened range and the out-of-reach warning come from one real load.

Classify each branch by the displacement it has to encode rather than by
the distance to its symbol, so the boundary between the two cases is the
25-bit field itself and not a megabyte threshold that happens to work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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