Don't rebuild it,
solderit!Actually, you should probably try rebuilding it first, but if that doesn't work out, then you can
solderit.
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.
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 eitherWhat 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.
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.
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 itsDT_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_finiis 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.
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.
# 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 ./myappA -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.
Each DT_NEEDED soname is looked up in the first of these that contains it:
DT_RPATHof the executable, unless it also has aDT_RUNPATH$SYSROOT/lib, ifSYSROOTis set in the environment-Ldirectories, in the order givenLD_LIBRARY_PATHDT_RUNPATHof the executable/etc/ld.so.cache/lib64,/usr/lib64,/lib,/usr/lib, and the multiarch pair for the executable's architecture:/lib/x86_64-linux-gnuand/usr/lib/x86_64-linux-gnu, or/lib/aarch64-linux-gnuand/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.
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.
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.
- 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_LOADsegments 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_offsetthe 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 firstPT_LOAD, so tools that rewrite it —patchelfamong 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.bssworth 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_SLOTentries becomeR_X86_64_NONE, and eager binding is forced (DF_BIND_NOWinDT_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_NEEDEDalong with the.gnu.version_rrequirements recorded against them — a version requirement naming a library that is no longer there aborts the loader onAssertion 'needed != NULL' failed— and moves onto the executable anyDT_NEEDEDof 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.versionindex of each symbol that asked for one of its versions becomesVER_NDX_GLOBAL, since the symbol is undefined and unreferenced after the merge, and.gnu.version_r's own section header followsDT_VERNEED/DT_VERNEEDNUMonto the shortened list. Left behind, those pointed at an entry that no longer exists —readelfprints the symbol astputs@@<corrupt>and goes on reporting a requirement against the removed library - Carries the merged libraries' constructors and destructors onto the
executable's
DT_PREINIT_ARRAYandDT_FINI_ARRAY(see Constructors and destructors) - Recomputes the
.note.gnu.propertyentries 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-protectionotherwise leaves the executable advertising control-flow protection its merged-in code does not have, and the loader enables IBT over code with noendbr64wheresolderroutes 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_arrayare repointed at the copies the loader now reads, and.solder.text/.solder.rodata/.solder.dataare added over the new mappings..preinit_arrayis 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 theDT_PREINIT_ARRAYnaming it.readelf,nmandgdbread section headers rather thanPT_DYNAMIC, so without all this the merged binary still looks exactly like the input to all of them — down toreadelf -x .fini_arrayprinting the destructor list the merge replaced andreadelf -x .preinit_arrayanswering that there is no such section
- x86-64 and aarch64 only. On aarch64 a function's addresses are built from an
adrppage and a low-twelve-bit immediate in separate instructions, whichsolderpairs by following the page's register through the function. Where it cannot follow it (a page spilled to the stack through anspit 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:
.dynsymand.gnu.versionhave no length anywhere in.dynamic— there is noDT_SYMSZand noDT_VERSYMSZ— and.gnu.version_rhas no extent either, which is what bounds the Verneed list a merged library's version requirements have to be unlinked from.stripkeeps the table;sstripand friends do not, andsolderrefuses such an input rather than merging part of the way into it - We can't merge
dlopenlibraries - Extracted code may not contain relocations that need the library's own GOT
(
R_X86_64_GOTPCRELand friends);solderrefuses 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 aGLOB_DATof its own rather than carrying a copy of the library's definition along —test/bashassigns ncurses'PC,UPandBCwhile readline sets the terminal up and mergedtputsreads 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_IRELATIVEslot holds the address of a resolver onlyld.socan call, so there is nothing the merge can write into it.solderrefuses the merge when one turns up inside extracted code or data - A merged library's thread-local storage joins the executable's: its
PT_TLSblock goes into the executable's (a newPT_TLSif it had none), and each copied TLS slot keeps a dynamic relocation against the executable's own module, sold.sofills 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_frameis 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_hdrandPT_GNU_EH_FRAMEcome through the merge byte for byte, still describing the executable's own code and nothing else, so no FDE covers.solder.text: merginglibpcre2-8.so.0intotest/grepleaves 28K oflibpcre2's.eh_framebehind. Anything that walks the stack through_Unwind_*therefore gives up at the first merged frame — glibc'sbacktrace()truncates there, a C++ exception propagating out of merged code gets_URC_FATAL_PHASE1_ERRORandstd::terminate, and apthread_cancelforced unwind cannot pass it.gdbis 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.textas 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, sonm -Don 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, orDT_NEEDED, instead - RELRO does not reach the merged region, and the rebuilt
DT_FINI_ARRAYleaves it.PT_GNU_RELROnames the bytesld.somakes read-only once it has finished relocating, which is why the linker puts.got,.init_arrayand.fini_arrayin there. The merged region's writable run has exactly that property —solderforcesDF_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 secondPT_GNU_RELROwould 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 combinedDT_FINI_ARRAYhas 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 — anmprotectfrom a preinit constructor, once the loader is done — whichsolderdoes 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. Merginglibpcre2-8.so.0intotest/grepreports 47 such words in0xd6000..0xd7000whilePT_GNU_RELROstill covers only0x30f50..0x32000, andDT_FINI_ARRAYmoving from0x30f58to0xd6420 .note.gnu.propertyis 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