Diagram of corrupting the FILE struct with four partial-in-place relocations to set up the House of Apple 2 control flow

OOBdump: Single-Shot Heap-OOB Exploitation of objdump -g via FR30 Relocations

Original text: “OOBdump: Relocation Oriented Programming”Calif, blog.calif.io (08 Jun 2026, no individual byline). PoCs and writeups: github.com/califio/publications/…/oobdump. Short illustrative code excerpts and the original article’s diagrams are reproduced with attribution; the prose is paraphrased.

Executive Summary

The Calif team has been quietly collecting trophy bugs in reverse-engineering tooling for a while — IDA Pro, Ghidra, Binja Sidekick, radare2 — and turned their attention to objdump from GNU binutils after some good-natured prodding. The result is OOBdump: a missing-bounds-check heap out-of-bounds write in the BFD library’s FR30 relocation handler that, when reached through objdump -g, becomes a single-shot code-execution exploit defeating ASLR, PIE, and modern glibc heap hardening — all from one crafted FR30 object file, with no information leak ever required.

The bug itself is unremarkable: a relocation handler trusts an attacker-supplied offset and writes a 32-bit value out of bounds on the heap. The exploit is the interesting part. Each step is built from existing pointers nudged by constant deltas, never an absolute address synthesised from a leak. A 64-bit arithmetic wrap converts a forward-only OOB write into a backward write. A 2-byte partial-overwrite under 64 KiB page alignment swaps the BFD endianness vector, then swaps the relocation howto, upgrading the primitive from “32-bit OOB write” to “32-bit OOB increment”. Four of those increments retarget a heap-allocated FILE struct into a House of Apple 2 trigger that fires system(...) from glibc’s _IO_wfile_overflow path on exit. The Calif team coordinated disclosure through binutils’ normal public-bug process — binutils explicitly excludes BFD bugs of this class from security-treatment — and the upstream fix is a one-line bounds check.

Arbitrary Code Execution in objdump -g

On paper objdump -g is one of the quieter binutils commands: parse an object file, dump its debug information, exit. The bug lets a carefully shaped FR30 object turn the same command into a code-execution sink.

The vulnerable code path lives in the BFD library — binutils’ container-agnostic object-file backend — in the FR30 ELF-32 relocation handler. The realistic exposure is narrow: stock host-focused binutils builds rarely include the FR30 backend, so the attack surface is mostly cross-toolchain SDKs, CI worker images, multi-arch reverse-engineering distributions, and any build configured with --enable-targets=all. The interesting story is not who gets hit but how cleanly the exploit chains, because the chain is one of the cleanest single-shot heap exploits to land in a long time.

The forgotten target

FR30 is a 32-bit Fujitsu embedded RISC core from the late nineties, part of the proprietary FR family. Binutils still carries support for it because binutils carries support for everything. Most hosts don’t compile that backend, but the targets that do — multi-arch builds, embedded SDK toolchains, build farms, “one tool to look at any object file” reverse-engineering boxes — are exactly the contexts where untrusted object files are the norm.

Why relocate at all?

It’s worth pausing on why objdump performs relocation work at all when it’s just trying to print debug info. A relocatable object file (.o) is not finished. The compiler emits placeholder bytes for any address it doesn’t yet know (cross-section references, addresses inside debug sections, etc.) and records a list of relocations that tell the linker where to patch up later. Debug sections are no different. Consider .debug_addr with two empty address slots:

.debug_addr
  offset 0x00: header
  offset 0x08: address slot 0 = 0x0
  offset 0x10: address slot 1 = 0x0

And the companion .rela.debug_addr records that those two slots should resolve to .text and .text + 0x10:

.rela.debug_addr:
  offset 0x08 -> .text
  offset 0x10 -> .text + 0x10

A linker would apply the patches into the final binary. objdump -g isn’t building a binary, so it leans on BFD to do the same thing in-memory before the DWARF reader runs. Each backend implements its own family of relocation types, and each type is a small handler function — lots of similar but subtly different code, easy place to forget a check.

The missing check

The Calif team credits Anthropic with finding the bug. It is in fr30_elf_i32_reloc in bfd/elf32-fr30.c — the handler for the R_FR30_48 relocation type:

typedef uint64_t bfd_vma;

static bfd_reloc_status_type
fr30_elf_i32_reloc (bfd *abfd, arelent *reloc_entry,
                    asymbol *symbol,
                    void *data, asection *input_section, ...)
{
  /* first three terms = virtual (mapped) address of the symbol (eg .text) */
  bfd_vma relocation = symbol->value
    + symbol->section->output_section->vma
    + symbol->section->output_offset
    // addend, or offset from the base symbol
    + reloc_entry->addend;

  /* bfd_put_32 (bfd *abfd, bfd_vma value_to_write, void *destination_pointer) */
  bfd_put_32 (abfd, relocation, (char *) data + reloc_entry->address + 2);

  return bfd_reloc_ok;
}

The handler computes a value (the attacker controls both the symbol and the addend, and the section state is predictable here) and writes 32 bits of it into data + reloc_entry->address + 2. data is the heap buffer holding the contents of the section being patched — in this exploit, .debug_info. There is no check that reloc_entry->address falls inside that buffer. Since the attacker controls both address and the written value, this is an arbitrary-offset 32-bit OOB write — and because the handler fires once per relocation record, the file can contain as many records as it wants. One file, as many writes as the exploit needs.

The choice of .debug_info as the destination section is deliberate: objdump‘s DWARF reader loads and relocates it before trying to parse any actual debug information, so a section full of zeros plus a long relocation list is sufficient to drive the entire exploit.

The heap layout

Powerful as the primitive is, two practical obstacles remain. First, the write lands at data + r_offset + 2 with r_offset an unsigned 32-bit field — it only points forward. Second, the exploit has no information leak, so ASLR keeps both the PIE base and the libc base hidden.

The heap around the .debug_info buffer showing the bfd struct, data buffer and arelent array
The heap layout around the .debug_info buffer that BFD allocates for the OOB-vulnerable handler — the bfd struct sits 8 400 bytes behind data, the arelent array sits 47 440 bytes ahead. Source: original article.

Two heap neighbours rescue the situation. The bfd handle — the struct BFD allocates when it opens the object — sits behind data. It carries the xvec pointer (which leads to a bfd_target table stuffed with function pointers) and an iostream field pointing at the heap-allocated FILE for the open object file. Behind data is unreachable for now, but the second neighbour — the arelent array, the in-memory form of the file’s relocation records — sits 47 440 bytes ahead of data, fully in reach. Every objdump -g invocation allocates the same chunks in the same order, so all the deltas are constants.

The arelent struct itself is straightforward:

typedef struct reloc_cache_entry {
  asymbol **sym_ptr_ptr;    // +0
  bfd_vma   address;        // +8   <- 64-bit
  bfd_vma   addend;         // +16
  reloc_howto_type *howto;  // +24
} arelent;                  // 32 bytes on aarch64

Step 1: wrap the offset for a backward write

The on-disk FR30 relocation offset is 32 bits, but BFD expands it into the 64-bit arelent.address field shown above. That gap is the wedge. If one relocation writes 0xFFFFFFFF into the high dword of a later relocation’s address field, the later relocation’s offset is now an enormous 64-bit value, and data + 0xFFFFFFFF_xxxxxxxx + 2 wraps in pointer arithmetic to a target that is effectively behind data — exactly the neighbourhood where the bfd struct lives.

Animation showing how wrapping a 64-bit arelent address reaches memory before the buffer
Wrapping a 64-bit arelent.address to reach memory before the data buffer with a forward-only primitive. Source: original article.

That packs into a two-relocation primitive — one to poison the upper dword of the next relocation’s address, one to do the actual backwards write:

def write_backward(target, value):
    """Write `value` at a negative offset from data (a backward write)."""
    next_index = len(relocations) + 1
    # sizeof entry is 32 bytes, high bytes of address field is at offset 12
    address_hi = R + next_index * 32 + 12
    relocations.append((address_hi - 2, 0xFFFFFFFF))
    relocations.append(((target - 2) & 0xFFFFFFFF, value))

Step 2: flip the writer’s byte order

The exploit is one-shot: objdump -g runs once over the file and prints nothing useful back. There is no leak, so absolute pointer values are out of reach. The chain compensates by turning the OOB write into an OOB increment — nudging an existing pointer by a known constant rather than constructing it from scratch. That needs two things: an in-place relocation type that does read-add-write rather than overwrite, and a writer that doesn’t byte-swap on the way out.

Step 2 fixes the byte order. FR30 is big-endian; aarch64 is little-endian. bfd_put_32 dispatches through abfd->xvec->bfd_putx32, so swapping xvec swaps the endian. The lucky structural fact: every candidate bfd_target struct sits in .data.rel.ro, and on this build nine little-endian vectors share the same 64 KiB page as FR30’s. Since PIE only randomises above the page boundary, the low 16 bits of any in-binary pointer are pinned regardless of where the image lands — a 2-byte write is enough to retarget xvec from FR30 to whichever neighbour comes first in the page. crx_elf32_vec is conveniently at offset 0x00b0 and that’s the one the exploit picks.

A 2-byte write retargets xvec low 2 bytes from the FR30 vector to the little-endian CRX vector
A 2-byte partial overwrite repoints abfd->xvec from the FR30 vector to the little-endian CRX vector in the same 64 KiB page — no leak required because page alignment pins the low bits. Source: original article.

Step 3: borrow a better relocation

Step 3 applies the same partial-overwrite trick to the howto pointer inside each arelent. The reloc_howto_type describes how a single relocation type applies its patch — width, target offset, and the handler that performs the read-modify-write. All backends’ howto tables live together in .data.rel.ro, so once again a 2-byte write swaps one howto for another in the same page.

The target is i386’s R_386_PC32, which sets partial_inplace = true. With that bit set BFD treats the existing word at the target as part of the relocation: it reads, adds, then writes back. That is exactly the OOB-increment primitive the chain needs:

bfd_vma val = read_reloc (abfd, data, howto);
val = val + relocation;
write_reloc (abfd, val, data, howto);

The catch is that i386’s handlers do bounds-check, so naively swapping howto would make subsequent OOB writes fail. The escape hatch: the section-size field that the bounds check consults lives on the heap, and the exploit already has a heap OOB write. Inflating the section size to something absurd before the howto swap turns the bounds check into a tautology.

Step 4: hijack the FILE (House of Apple 2)

With an OOB increment available, the chain pivots to the heap-allocated glibc FILE struct that abfd->iostream points at — the open backing of the object file. From here it is House of Apple 2, a standard FSOP technique: corrupt FILE-struct fields so that the final exit() walks _IO_list_all, picks the corrupted FILE for flushing, dispatches through the wide-character vtable path, and ends in system() with attacker-chosen arguments.

Only four partial-in-place increments are required:

pi_relocs = [
    (IO + 216, DW),              # _IO_file_jumps -> _IO_wfile_jumps
    (IO + 184, DS + 8),          # &_IO_list_all  -> system
    (IO + 136, (IO + 80) - LV),  # _lock          -> fp+80
    (IO + 160, (IO - 88) - WV),  # _wide_data     -> fp-88
]
Diagram of corrupting the FILE struct with four partial-in-place relocations to set up the House of Apple 2 control flow
The four partial-in-place increments that retarget FILE-struct fields for the House of Apple 2 dispatch — two libc pointers nudged inside libc, two heap pointers nudged inside the heap, all expressed as constant deltas. Source: original article.

The first two increments move libc-resident pointers (the vtable and the linked-list head pointer) by known intra-libc offsets; the second two move heap-resident pointers by known intra-heap offsets. Because libc and heap layouts are fixed relative to themselves, every delta is a compile-time constant, and the chain stays deterministic without ever holding an absolute address.

The slick part is the third and fourth increments. They are picked so that the _wide_data pointer and the _lock pointer end up referring to the same heap word. Setting that shared word to fp + 80 gives _lock a zero lock value (which keeps the FILE flushable) and simultaneously gives _wide_vtable a fake vtable whose __doallocate slot has been pre-incremented to point at system. One nudged pointer drives both fields.

_wide_data overlaps the FILE so _wide_vtable and _lock share one slot
Choosing _wide_data = fp - 88 aligns the wide-vtable field exactly onto the FILE’s own _lock slot, so a single nudged pointer can satisfy both. Source: original article.

A handful of direct OOB writes fill in the remaining state the wide-character path needs: write_ptr > write_base so the flush check fires, zeroed wide-data fields where the vtable lookup expects them, and the literal command string at the top of the FILE struct (where it ends up as system‘s argument). One final write sets abfd->iostream = NULL so that bfd_close skips calling fclose, leaving the corrupted FILE on _IO_list_all.

At exit(), glibc walks _IO_list_all and reaches the corrupted FILE. The narrow flush check (_mode <= 0 && write_ptr > write_base) selects it. Because vtable now points at _IO_wfile_jumps, _IO_OVERFLOW dispatches to the wide-character handler _IO_wfile_overflow, which calls into _IO_wdoallocbuf, which calls through the fake wide vtable. The __doallocate slot has been pre-set to system, the this-pointer is the FILE itself, and the FILE’s first bytes are the command. The expression evaluates to system(fp) and the planted command runs.

One housekeeping note from the Calif team: .debug_info is sized to 144 bytes. Smaller payloads place tcache metadata over fake _wide_data fields that need to stay zero, and the chain breaks.

The fix

Upstream binutils added the bounds check the handler always should have had. Before writing, the FR30 handlers now validate the write offset against the section size:

+  if (reloc_entry->address + 2 < 2
+      || !bfd_reloc_offset_in_range (reloc_entry->howto, abfd,
+                    input_section, reloc_entry->address + 2))
+    return bfd_reloc_outofrange;

The guard is against reloc_entry->address + 2 — the actual write offset — with the leading comparison protecting against integer overflow when the field is near the top of its range. With the check in place, the crash PoC makes objdump reject the relocation and exit cleanly instead of writing out of bounds.

The lesson

The chain never really defeats ASLR, PIE, or modern heap hardening — it sidesteps them. Nothing in any of the four exploitation steps holds an absolute address. The two partial-pointer overwrites (xvec and howto) only touch the low bits that 64 KiB page alignment guarantees are fixed under PIE randomisation. The four FILE-struct increments only nudge existing libc / heap pointers by constant deltas inside their own region, so libc pointers stay in libc and heap pointers stay on the heap. The chain does not synthesise pointers; it routes through pointers already lying around the heap.

The Calif team frames the irony nicely: the machinery doing the routing is BFD’s own relocation engine — precisely the kind of machinery that makes ASLR and PIE work in the first place.

Key Takeaways

  • Tool exposure surface matters as much as the bug. BFD is shared across the binutils universe; FR30 ships everywhere binutils ships, but the relocation handler only runs in multi-arch builds — SDK / CI / RE-distro contexts.
  • A 32-bit unsigned offset expanded into a 64-bit field is a wrap-around primitive waiting to happen. Forward-only becomes forward-and-backward as soon as the attacker controls the high 32 bits.
  • 64 KiB page alignment makes ASLR not actually random in the low 16 bits, and that’s enough to retarget a function-table pointer to a chosen sibling in the same page.
  • Read-add-write relocation types are dangerous OOB-increment primitives on a heap with predictable structure — especially when the relocation tables themselves live in mutable heap memory.
  • House of Apple 2 remains the cleanest FSOP technique on modern glibc because the wide-character path lets the exploit avoid vtable verification on the narrow path.
  • Single-shot heap exploits without info leaks are possible when every required state change is a delta on an existing pointer — deliberately giving up “build a pointer from a leak” buys “no leak ever needed”.
  • binutils’ security policy explicitly excludes BFD-via-malicious-input bugs from CVE treatment. Operators of binary-analysis pipelines should not assume “no CVE” means “no fix needed in your CI image”.

Defensive Recommendations

  • Never invoke objdump, nm, readelf or any other BFD-backed tool on untrusted inputs without isolation. Sandbox the analysis worker, drop networking, drop write access to anything outside a scratch directory, treat the binutils tool as a parser of attacker-controlled input.
  • Audit your multi-arch toolchain images. If a CI worker is built with --enable-targets=all — or if it pulls in cross-toolchain binutils packages for FR30, CRX, V850, or other rare embedded targets — ensure binutils is patched to a version that includes the FR30 fix.
  • Subscribe to binutils mailing-list disclosures, not just CVE feeds. Because binutils excludes BFD parser bugs from CVE treatment, the canonical channel is upstream commit + mailing-list announcement.
  • For binary-analysis SaaS: assume every analysis tool you ship can be coerced into code execution by a crafted input. Isolate per-job, treat parsers as untrusted, never share state between analysis runs of different customers.
  • For detection engineers: hunt for objdump / nm / readelf processes spawning unexpected children (especially sh, bash, curl, wget) — the House-of-Apple-2 sink lands a system(...) call, which is a very loud syscall pattern to monitor.
  • For glibc hardening teams: the wide-character FSOP path remains the cleanest FILE-struct primitive. Strengthening _IO_wfile_overflow / _IO_wdoallocbuf vtable checks (parallel to what the narrow path now does) would meaningfully raise the cost of this technique.
  • For RE-tool authors: any code that parses an attacker-controlled object file should run behind a per-process sandbox by default, not as a library linked into a long-running analyst process where one bad object kills the session.

Conclusion

OOBdump is a textbook demonstration that “we have ASLR / PIE / heap hardening” is not the same as “the chain is dead”. When every step in the exploit is a constant-delta nudge of an existing pointer inside its own region, the mitigations have nothing to defend — there is no leak to discover, no base to guess, no fresh address to forge. The bug is dull; the exploit is genuinely elegant; the lesson for defenders is that single-shot heap exploits with no info leak are not the rarity they used to be, and untrusted-binary-parser pipelines need real isolation rather than mitigation faith.

Original text: “OOBdump: Relocation Oriented Programming” by Calif at blog.calif.io. PoCs and writeups: github.com/califio/publications/…/oobdump.

Comments are closed.