Oxidized Patchelf #
Part of the overby.me monorepo, where this lives in
safety/oxidized/patchelfand where all development happens.It is also published on its own, as tangled.org/overby.me/oxidized-patchelf and github.com/overby-me/oxidized-patchelf. Both are read-only mirrors, rebuilt from the monorepo with josh: a commit made to either is overwritten by the next sync, so please open issues and pull requests on the monorepo.
A patchelf-compatible ELF binary patching tool written in Rust.
Status #
46/46 tests passing (100%) — the upstream NixOS/patchelf 0.15.2
test suite (tests/*.sh), wired into Nix checks. Each test runs the
official shell script in a sandbox with oxidized-patchelf symlinked at
the expected ../src/patchelf path against pre-built ELF fixtures
from the upstream autotools build.
Usage #
Run a single upstream test:
nix build .#checks.x86_64-linux.oxidized-patchelf-test-{name}
View a failing test's log:
nix log .#checks.x86_64-linux.oxidized-patchelf-test-{name}
Batch-run every test:
nix build ".#checks.x86_64-linux.oxidized-patchelf-test-*" --keep-going --no-link
The binary is available as patchelf from pkgs.oxidized-patchelf
(release build, LTO + strip) or pkgs.oxidized-patchelf-dev (debug build,
faster compile).
Architecture #
Eight source modules:
args—Action,Options,parse_args,print_usage,VERSION,@FILEargument expansion.elf— low-level helpers:dynstr_info,vaddr_to_offset,read_str_at,write_str_inplace,available_space_at.grow— width/endian-generic dynstr / PT_INTERP growth engine (ElfBits,append_to_dynstr,add_dynamic_entry).commands/print—print_interpreter,print_rpath,print_soname,print_needed.commands/interpreter—set_interpreterwith LOAD-slack and EOF-append fallbacks.commands/rpath—set_rpath,add_rpath,shrink_rpath,remove_rpathwith--force-rpathplumbing.commands/soname—set_sonamewith grow fallback.commands/needed—add_needed,remove_needed,replace_needed.commands/debug—add_debug_tag(DT_NULL slot repurposing).main— argv glue, multi-action loop with per-iteration re-parse,parse_with_workaroundsfor the no-gnu-hash fixture.
Features #
Read operations #
--print-interpreter— readPT_INTERP.--print-rpath—DT_RUNPATHfirst, fall back toDT_RPATH.--print-soname—DT_SONAME(errors when absent, matching upstream).--print-needed— everyDT_NEEDEDentry.
Write operations #
--set-interpreter PATH— in-place when the new path fits the existing PT_INTERP; otherwise park in the LOAD #1 slack; otherwise append at file EOF and repointPT_INTERP(the kernel reads it directly from the file viap_offset).--set-rpath PATH— overwrite an existingDT_RPATH/DT_RUNPATHin place, grow.dynstrif too long, or add a brand-new tag via the firstDT_NULLslot in.dynamic. Honours--force-rpath(usesDT_RPATHand flips an existingDT_RUNPATHtag).--add-rpath PATH— append entries to an existing rpath; falls through to--set-rpathwhen none exists.--shrink-rpath— keep only entries that contain at least one needed library; satisfies each lib by the first matching entry (matches upstream).--allowed-rpath-prefixes PREFIXES— combines with--shrink-rpath: drop entries that do not start with one of the colon-delimited prefixes.--remove-rpath— re-tagDT_RPATH/DT_RUNPATHasDT_DEBUG(cannot useDT_NULLbecause that terminates dynamic iteration).--set-soname NAME— overwriteDT_SONAMEin place or grow.dynstrand add via aDT_NULLslot.--add-needed LIB— grow.dynstr+ addDT_NEEDED.--remove-needed LIB— zero out theDT_NEEDEDentry.--replace-needed OLD NEW— overwrite in place when the new name fits, otherwise grow.dynstrand patchd_val.--add-debug-tag— overwrite the firstDT_NULLslot withDT_DEBUG.
Argument handling #
@FILEexpands to file contents in any string argument; missing file produces upstream'sgetting info about FILEerror.--output FILEwrites to a different path while preserving the input file's mode.--page-size SIZEaccepted for compatibility (used by the no-rpath arch tests).--no-default-lib,--clear-execstack,--set-execstack,--print-execstack,--debug,--rename-dynamic-symbols,--clear-symbol-versionaccepted as no-ops or stubs for upstream test compatibility.
Growth engine #
src/grow.rs (~400 lines) handles every case where an in-place edit
would not fit. Two strategies, tried in order:
- LOAD #1 slack. gcc/ld leaves 2-3 KB of zero padding between
the end of LOAD #1
fileszand the next page boundary. Park the new payload there, grow LOAD #1filesz/memszto cover it. No phdr changes, no segments shift, no VAs change. - GNU_STACK / PT_NULL → PT_LOAD repurpose. For tiny binaries
with no slack (the
no-rpath-prebuild/*arch fixtures), append the payload at file EOF (page-aligned) and rewrite the otherwise-uselessPT_GNU_STACK(orPT_NULLon MIPS) phdr into a fresh read-onlyPT_LOADcovering the new region.
Both strategies are width/endian generic via ElfBits, which
abstracts over 32 vs 64-bit Elf_Dyn / Elf_Phdr / Elf_Shdr
field layouts and LE/BE word reads.
Strings in .dynstr are referenced by offset into strtab, so only
DT_STRTAB, DT_STRSZ, and the .dynstr section header need
updating when the table moves. PT_INTERP can live anywhere in the
file because the kernel reads it directly via p_offset.
Multi-action invocations #
main.rs re-parses the (possibly mutated) buffer at the start of
each action so a single command line like
patchelf --set-interpreter /lib --set-rpath /opt --add-needed lib.so
sees the post-previous-grow layout for each subsequent action. Each
mutating command takes &mut Vec<u8> and may grow the file.
Compatibility workarounds #
no-gnu-hash. When goblin 0.9 rejects a binary becauseDT_GNU_HASHpoints at zero buckets (whichstrip --remove-section=.gnu.hashlegitimately produces),parse_with_workaroundswalks the program-header table by hand to locatePT_DYNAMIC, then rewrites thed_tagof anyDT_GNU_HASHentry toDT_DEBUGin a parsing copy of the buffer. The on-disk buffer is untouched, so the originalDT_GNU_HASHsurvives the round trip; goblin only sees the patched copy.