Skip to content
cilkiPublic

About

Fuse shared libraries into ELFs

Resources

Stars

6 stars

Watchers

1 watching

Forks

Repository files navigation

solder

Don't rebuild it, solder it!

Actually, you should probably try rebuilding it first, but if that doesn't work out, then you can solder it.

solder is a post-link static merger for ELF executables. It extracts symbols from shared libraries and fuses them directly into executables, removing runtime dependencies altogether.

Partial static linking

The primary use case for solder is partial static linking.

Depending on what build tool you're using, you can have vastly different experiences. Sometimes you can simply add -l:libexample.a to your LDFLAGS and it works first try. Sometimes it's not that easy. If you find yourself hacking configure scripts or generated Makefiles to accomplish this, consider just post-processing your binary with solder instead.

You don't have to recompile anything. Just give it your executable and the shared objects you want statically linked. Just note the following libraries are excluded because static linking them can cause unpleasant problems (even when you properly static link them at build time).

  • ld-linux
  • ld-musl
  • linux-vdso
  • linux-gate
  • libc.so
  • libm.so
  • librt.so
  • libpthread
  • libdl.so
  • libresolv
  • libnss_
  • libgcc_s.so

Those are matched as prefixes of the DT_NEEDED soname, so the libc.so entry excludes libc.so.6 but leaves something like libcrypto.so.3 mergeable. A dependency recorded as a path rather than a soname is matched on its last component, so spelling glibc /usr/lib64/libc.so.6 does not get it past the list.

Only the symbols the executable actually reaches are merged, together with their transitive closure inside the library. What the closure costs is bounded less by how interconnected the library is than by how precisely the merge can bound a data object: an address no symbol covers is bounded by the reference that reached it, and where the reference says nothing — lea hands out an address without saying what is at it — by the next object the library does name, which the unnamed one cannot run into. A library that exports its variables leaves that bound something to work with; a stripped one names nothing at all in its data sections, and solder falls back to copying the whole containing section and extracting everything the pointers in it name. One such reference into a pointer-dense section pulls in most of the library. The fixtures under test/ show both shapes:

executable merged library before after
grep libpcre2-8.so.0 (695K) 198K 871K
md5sum libcrypto.so.3 (6.2M) 54K 76K
bash libtinfo.so.6 (215K) 1.2M 1.5M

md5sum's three MD5 entry points reach about 3K of libcrypto's code and one 16-byte constant, and that is all its merge copies: every data object they touch is bounded by the instruction that reads it. grep is the genuine closure of the 19 pcre2_* entry points it calls, plus libpcre2's 158K .rodata copied whole for one lea into it — an address handed out without saying what is at it, which is the case nothing bounds yet and what is left of issue #66.

A copy is built anchored at its section's first byte, so that a reference further in can extend it without moving it and invalidating the references already resolved against it, and is moved up to the lowest byte anything references once the closure is complete. It stops short of a pointer it carries: whatever that pointer names is in the merge because of it, so dropping the bytes holding it would leave that extraction reachable from nothing rather than remove it.

Point --dry-run at a binary to see how much a merge would pull in before committing to it.

Each data section copied past what the merged code reads is reported as it happens — on a real merge as much as under --dry-run — with what it cost and what reached into it. md5sum produces no such line, because every copy it makes is bounded; grep produces four and bash three, wrapped here and with their timestamps dropped:

$ solder bash -L ./libs
WARN solder: ./libs/libtinfo.so.6 .data.rel.ro: merged 96 of 12168 bytes from
  offset 4032, up to the next symbol the library defines in it, and 9 pointers
  that root further extraction — because no symbol and no unwind record bounds
  the object at 0x327a0 that '.solder.anon.0x11cc0' references, and the
  reference says nothing about its size either

What a line attributes the copy to is whatever was being extracted when the reference was found: a symbol, a .solder.anon.<address> unit standing in for a stripped library's local helper, or another section whose own pointers reached in. The lines therefore read as a chain, outermost first — above, a symbol-less local helper in libtinfo touches .data.rel.ro 4,032 bytes in, and the copy runs from there to the next table the library exports, rooting the nine pointers in between. A copy that is exactly the bytes the merged code reads gets no line; --dry-run adds the totals to the plan it prints.

Inherited dependencies

Merging a library moves its code into the executable but not its own dependencies. libcrypto.so.3 resolves ZSTD_compress through its DT_NEEDED on libzstd.so.1, so once libcrypto is gone from the executable's DT_NEEDED nothing in the link chain provides that symbol — and the merged binary would not start at all.

So a dependency of a merged library is added to the executable's own DT_NEEDED when all of the following hold:

  • the executable does not already link it,
  • it is not itself being merged away, and
  • it exports at least one symbol the extracted code refers to.

A dependency nothing in the extracted code calls stays dropped. One that solder cannot find on disk is inherited without that last check, since guessing it away risks an executable that will not load; you'll get a warning saying so. --dry-run lists whatever gets inherited, so you can see up front which dependencies survive the merge.

Constructors and destructors

A library initializes itself from its DT_INIT_ARRAY and tears itself down from its DT_FINI_ARRAY, and the loader runs neither once the library is gone from DT_NEEDED. So the merge carries those entries over, in the dependency order of the libraries they came from, so a library that sets up state another one needs still goes first:

  • Constructors are appended to the executable's DT_PREINIT_ARRAY, behind whatever it already had there, rather than to its DT_INIT_ARRAY. Either array would run them ahead of the executable's own constructors, which is the order the loader used, but only the preinit phase runs before _dl_fini is registered with __cxa_atexit. That is what keeps __cxa_atexit-registered C++ static destructors in the exit order they had while the library was dynamic, since where they land in the exit-handler LIFO depends on which phase registered them.
  • Destructors go in front of the executable's own in a rebuilt DT_FINI_ARRAY. The loader walks that array backwards, so the executable still tears itself down first, then each merged library, dependents before dependencies — the same order as before the merge.

Only the libraries that actually leave DT_NEEDED are treated this way. One the extraction reached into without removing — nothing imported a symbol from it directly, so its soname stays — keeps running its own constructors, and copying them here as well would run them twice.

Nixpkgs

For binaries built from nixpkgs, you can make them fully portable to other systems by first solder-ing in the libraries, then patchelf-ing the library path back to normal. I've used this to take advantage of the vast quantity of software available in nixpkgs on embedded systems where I can't install nix, etc.

Usage

# Merge all possible libraries into the executable (in-place)
solder ./myapp

# Merge only specific libraries
solder ./myapp -m libfoo.so.1 -m libbar.so.2

# A prefix of the soname works too, if you'd rather not spell out the version
solder ./myapp -m libfoo

# Add additional library search paths
solder ./myapp -L /opt/mylibs -L ./libs

# Preview what would happen without writing output
solder ./myapp --dry-run

# Report what solder is doing (only warnings and errors are printed by default)
RUST_LOG=info solder ./myapp

A -m entry that matches no DT_NEEDED soname, or that matches only never-mergeable ones, is an error rather than a silent no-op — quietly merging a subset of what you asked for is almost never what you wanted.

The executable is replaced in place and no backup is kept, so hold on to a copy of anything you can't rebuild. The replacement itself is atomic — the merged output is written beside the executable and renamed over it — so a merge that fails for any reason, solder's own errors included, leaves the input exactly as it was. Two consequences worth knowing about:

  • A copy of the executable that is already running is unaffected, since the rename gives the path a new file rather than rewriting the one that process is executing from.
  • A hard-linked executable is only merged under the name you passed. The other names keep pointing at the original file.

Library resolution

Each DT_NEEDED soname is looked up in the first of these that contains it:

  1. DT_RPATH of the executable, unless it also has a DT_RUNPATH
  2. $SYSROOT/lib, if SYSROOT is set in the environment
  3. -L directories, in the order given
  4. LD_LIBRARY_PATH
  5. DT_RUNPATH of the executable
  6. /etc/ld.so.cache
  7. /lib64, /usr/lib64, /lib, /usr/lib, and the multiarch pair for the executable's architecture: /lib/x86_64-linux-gnu and /usr/lib/x86_64-linux-gnu, or /lib/aarch64-linux-gnu and /usr/lib/aarch64-linux-gnu

That is the dynamic linker's own order with $SYSROOT/lib and -L spliced in after DT_RPATH. Note that -L therefore does not override an executable that was linked with an RPATH. As with the loader, a DT_RUNPATH supersedes the DT_RPATH of the same object entirely rather than being searched after it.

That order applies to a soname the executable names. A dependency of a library being merged away (see Inherited dependencies) is looked up the way the loader looks it up for that library: the library's own DT_RPATH leads, ahead of the executable's, and DT_RUNPATH is the library's own. The executable's DT_RUNPATH is not searched for it at all — a DT_RUNPATH governs the lookups of the object that records it and not those of its dependencies — and $ORIGIN throughout is the merged library's directory rather than the executable's. Both matter because the file solder finds is the one whose exports decide whether the dependency has to be inherited: reading those off some other copy of the soname can drop a DT_NEEDED entry the merged code still needs.

DT_RPATH and DT_RUNPATH entries go through the same dynamic string token substitution the loader applies, so an executable that ships its libraries beside itself (-Wl,-rpath,'$ORIGIN/libs') resolves them from there:

  • $ORIGIN / ${ORIGIN} — the directory holding the executable, symlinks resolved
  • $LIB / ${LIB} — lib64, which is what glibc uses on both x86-64 and aarch64

$PLATFORM is deliberately not substituted: it stands for whatever the loader makes of the CPU the merged binary eventually runs on, which is not knowable at merge time. An entry containing it is skipped with a warning, the same way the loader skips it when it has no platform string.

/etc/ld.so.cache is filtered the way the loader filters it: only libc6 entries for the executable's own architecture are considered, so the i386 or x32 build of a soname (or the ILP32 one on aarch64) is never picked, and a glibc-hwcaps variant loses to the baseline library — whether the machine that will run the merged executable implements the instructions that variant was built for is not knowable from here.

A soname none of that turns up is only an error when you asked for it. Named with -m, it fails the merge, like any other -m entry that cannot take effect. Reached because solder ./myapp considers every DT_NEEDED entry it finds, it is left alone with a warning and the rest are still merged: the entry stays in DT_NEEDED, nothing is taken out of that library, and the merged binary resolves it wherever it runs exactly as the input did. So a dependency that only exists on the machine the binary ships to — a plugin library, a sysroot that doesn't carry all of them — no longer costs you the merge of the libraries that are on the search path.

The copy that turns up is not always the right one

Whichever file the search finds is the one the merge takes code out of, and the executable was linked against whatever copy of that soname was on the machine that built it. When those differ — a sysroot a release behind, a -L pointing at another build of the same library — the copy here can be missing a symbol the executable imports from it. Nothing defines that symbol, so no GOT slot is pre-filled for it and its relocation stays live, against a soname the merge has just taken out of DT_NEEDED. solder forces DF_BIND_NOW, so the loader resolves that relocation before main and the binary dies there: symbol lookup error: undefined symbol.

So the merge checks, before writing anything, that every symbol it leaves to the loader is defined by something the executable is still linked against: the DT_NEEDED entries that survive, the ones inherited from the merged libraries, and the interpreter, which is loaded without ever being named in DT_NEEDED. A symbol none of them defines fails the merge, naming the symbols, so the executable that is there keeps running and you can point -L or $SYSROOT at the copy it was built against — or leave that library out of the merge with -m. Undefined weak symbols are not counted: the loader resolves those to zero rather than failing, which is how __gmon_start__ sits undefined in nearly every binary.

The check needs to read every library that stays, so one it cannot find — the usual case being a sysroot that carries the libraries you are merging but not libc — leaves the question open. Then it says so and the merge goes ahead unjudged, rather than refusing over a symbol the file it could not read may well define.

Dependencies recorded as a path

A DT_NEEDED entry containing a slash — what the linker writes when the library it was handed has no DT_SONAME — names a file rather than a soname, and the loader opens it directly instead of searching for it. solder does the same: none of the directories above are consulted for such an entry, it is only expanded for dynamic string tokens ($ORIGIN here being the directory of the object that declared the dependency) and used as given.

A relative path entry is rejected with an error, -m or no -m. The loader resolves it against the working directory of the running process, so which file it names is not something the merge can know; merging whatever solder's own working directory happens to point at would be a guess, and so would deciding it isn't there. Use -m to merge the other libraries and leave that one dynamic, or relink against a soname.

How It Works

  • Parses the executable's dynamic section to identify imported symbols
  • Resolves which shared libraries provide those symbols (see Library resolution)
  • Extracts the minimal set of code/data needed
    • Uses symbolic execution to identify jump tables in .rodata
  • Applies relocations and creates trampolines for any remaining external calls
  • Appends new PT_LOAD segments containing the merged code: read-execute for the code and trampolines, read-only for the constants, read-write for the data and the slots the dynamic loader fills in, read-only for the rebuilt symbol and relocation tables. Extracted read-only data only lands in the writable mapping when the dynamic loader still has to write to it — a pointer it rebases at startup, or a GOT slot it resolves
  • Places those mappings' bytes at the file offset that gives them the same p_vaddr - p_offset the executable's own program header table has. The rebuilt program header table lives in the merged region, and a linker only ever emits that table in the first PT_LOAD, so tools that rewrite it — patchelf among them — read its difference as the one that holds for the start of the file. The merged region therefore starts past the end of the memory image in the file as well as in memory, which leaves the executable's .bss worth of unmapped zero padding in between
  • Patches GOT entries to point directly to the merged symbols
  • Neutralizes the merged symbols' own relocations so the loader leaves those slots alone: their R_X86_64_JUMP_SLOT entries become R_X86_64_NONE, and eager binding is forced (DF_BIND_NOW in DT_FLAGS, unless the executable already asked for it) because glibc's lazy PLT path rejects a type-0 relocation outright — "unexpected PLT reloc type 0x00" — while its eager path treats it as the no-op it is. A merged binary therefore always binds eagerly, even if the original did not
  • Removes the merged libraries from DT_NEEDED along with the .gnu.version_r requirements recorded against them — a version requirement naming a library that is no longer there aborts the loader on Assertion 'needed != NULL' failed — and moves onto the executable any DT_NEEDED of theirs that still provides a symbol the extracted code calls (see Inherited dependencies). Unlinking a requirement takes everything that referred to it along: the .gnu.version index of each symbol that asked for one of its versions becomes VER_NDX_GLOBAL, since the symbol is undefined and unreferenced after the merge, and .gnu.version_r's own section header follows DT_VERNEED/DT_VERNEEDNUM onto the shortened list. Left behind, those pointed at an entry that no longer exists — readelf prints the symbol as tputs@@<corrupt> and goes on reporting a requirement against the removed library
  • Carries the merged libraries' constructors and destructors onto the executable's DT_PREINIT_ARRAY and DT_FINI_ARRAY (see Constructors and destructors)
  • Recomputes the .note.gnu.property entries that describe the program as a whole, the way a static link recomputes them across its inputs: the CET features (GNU_PROPERTY_X86_FEATURE_1_AND — IBT and the shadow stack) become the intersection of the executable's and those of every merged library, and the required x86-64 ISA levels (GNU_PROPERTY_X86_ISA_1_NEEDED) their union. Merging a library built without -fcf-protection otherwise leaves the executable advertising control-flow protection its merged-in code does not have, and the loader enables IBT over code with no endbr64 where solder routes calls into it through the GOT
  • Rewrites the section header table so it describes the result: the headers of the rebuilt .dynsym/.dynstr/.gnu.version/.rela.dyn/.fini_array are repointed at the copies the loader now reads, and .solder.text/.solder.rodata/.solder.data are added over the new mappings. .preinit_array is added rather than repointed: a linker has no reason to give an executable one, so the array holding the merged constructors is a section the merge introduces along with the DT_PREINIT_ARRAY naming it. readelf, nm and gdb read section headers rather than PT_DYNAMIC, so without all this the merged binary still looks exactly like the input to all of them — down to readelf -x .fini_array printing the destructor list the merge replaced and readelf -x .preinit_array answering that there is no such section

Limitations

  • x86-64 and aarch64 only. On aarch64 a function's addresses are built from an adrp page and a low-twelve-bit immediate in separate instructions, which solder pairs by following the page's register through the function. Where it cannot follow it (a page spilled to the stack through an sp it cannot track, say) it refuses the merge rather than guess
  • The executable has to still have its section header table. The loader never reads one, but the merge does, for the two things nothing else records: .dynsym and .gnu.version have no length anywhere in .dynamic — there is no DT_SYMSZ and no DT_VERSYMSZ — and .gnu.version_r has no extent either, which is what bounds the Verneed list a merged library's version requirements have to be unlinked from. strip keeps the table; sstrip and friends do not, and solder refuses such an input rather than merging part of the way into it
  • We can't merge dlopen libraries
  • Extracted code may not contain relocations that need the library's own GOT (R_X86_64_GOTPCREL and friends); solder refuses the merge rather than producing a broken binary
  • A copy-relocated data symbol (R_X86_64_COPY) coming from a library we're removing has to be zero-initialized, since its initial value currently can't be carried over. The storage itself stays the executable's, as it already was: such a symbol is defined in the executable's own .bss, and the executable comes first in the global scope, so the loader resolved even the defining library's references to that copy. The merged code keeps reaching it through a GLOB_DAT of its own rather than carrying a copy of the library's definition along — test/bash assigns ncurses' PC, UP and BC while readline sets the terminal up and merged tputs reads them back, which only works while the two sides are looking at the same bytes
  • We can't merge a library that defines an ifunc that extracted code reaches: an R_X86_64_IRELATIVE slot holds the address of a resolver only ld.so can call, so there is nothing the merge can write into it. solder refuses the merge when one turns up inside extracted code or data
  • A merged library's thread-local storage joins the executable's: its PT_TLS block goes into the executable's (a new PT_TLS if it had none), and each copied TLS slot keeps a dynamic relocation against the executable's own module, so ld.so fills it in as it did for the library. The executable's own thread-local variables stay where its code expects them. Some cases are still refused: a TLS variable defined by another object, or by the executable as well; one the executable reaches by name itself; and local-dynamic code (which writes offsets into the block in its instructions) in a library whose block cannot start the merged one, which is any library after the first on x86-64, and every one on aarch64 when the executable has thread-local variables of its own
  • Merged code carries no unwind information. A library's .eh_frame is read during extraction — it is how a function's exact bounds are recovered when the symbol table records no size for it — but none of it is written back out. .eh_frame_hdr and PT_GNU_EH_FRAME come through the merge byte for byte, still describing the executable's own code and nothing else, so no FDE covers .solder.text: merging libpcre2-8.so.0 into test/grep leaves 28K of libpcre2's .eh_frame behind. Anything that walks the stack through _Unwind_* therefore gives up at the first merged frame — glibc's backtrace() truncates there, a C++ exception propagating out of merged code gets _URC_FATAL_PHASE1_ERROR and std::terminate, and a pthread_cancel forced unwind cannot pass it. gdb is reduced to guessing its way through that code from the prologues. Don't merge a library the program unwinds through
  • Merged code carries no symbols either. Nothing names the extracted functions in any symbol table, so a debugger or profiler sees .solder.text as one unnamed blob. The names the executable imported from the merged library do stay behind in .dynsym, as undefined entries that no relocation refers to any more, so nm -D on a merged binary still lists exactly the symbols that were merged in as undefined. That part is cosmetic rather than a load failure — their GOT slots are pre-filled and their relocations neutralized, so nothing asks the loader to resolve them — but it does mean the symbol tables are not a way to tell whether a merge worked. Read the .solder.* section headers, or DT_NEEDED, instead
  • RELRO does not reach the merged region, and the rebuilt DT_FINI_ARRAY leaves it. PT_GNU_RELRO names the bytes ld.so makes read-only once it has finished relocating, which is why the linker puts .got, .init_array and .fini_array in there. The merged region's writable run has exactly that property — solder forces DF_BIND_NOW, so its GOT slots, its init/fini arrays and the pointers in its data are each written once, by the loader, before any code runs — and none of it is protected, because the merged region is laid out past the end of the image and so cannot fall inside the range the linker already named. Appending a second PT_GNU_RELRO would not help: glibc keeps one RELRO range per object and takes the last entry it sees, so that would unprotect the executable's own range rather than protect the merged one. The combined DT_FINI_ARRAY has to be rebuilt in the merged region as well, which moves the executable's own destructor pointer out of the range that was protecting it. Properly protecting the run needs code in the merged binary — an mprotect from a preinit constructor, once the loader is done — which solder does not emit yet, so for now it warns about what the merge costs, naming the unprotected range, how many words in it only the loader writes, and the arrays that leave the protected one. Merging libpcre2-8.so.0 into test/grep reports 47 such words in 0xd6000..0xd7000 while PT_GNU_RELRO still covers only 0x30f50..0x32000, and DT_FINI_ARRAY moving from 0x30f58 to 0xd6420
  • .note.gnu.property is edited in place, so a property the executable does not already carry can't be added. If a merged library needs an x86-64 ISA level the executable has no entry for, you get a warning instead of a note entry

About

Fuse shared libraries into ELFs

Resources

Stars

6 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages