Dylib Injection and Hijacking: Getting Your Code Into Someone Else's Process
The dynamic loader is the most trusting component in a running program. Its whole job is to find
libraries by name or by path and map them into the process before main runs. If you can
influence which file it loads (by setting an environment variable, by planting a file where it
searches, or by exploiting a missing library it expects but never finds) your code runs inside the
target with the target’s identity, before the application’s own logic gets a single instruction.
This post covers the two classic macOS code-injection families that abuse the loader:
DYLD_INSERT_LIBRARIES injection and dylib hijacking. Both predate the hardened runtime, and the
interesting part is not that they exist but exactly which conditions Apple’s mitigations let them
survive in. The previous post built shellcode; these are two of the cleanest ways to get a payload
running inside a process you do not own.
DYLD_INSERT_LIBRARIES: The Loader Will Load Anything
DYLD_INSERT_LIBRARIES is an environment variable, the macOS analogue of Linux’s LD_PRELOAD.
Set it to a colon-separated list of dylib paths and dyld loads every one of them into the new
process before the program’s own dependencies, and crucially, it runs each library’s
constructor function before main.
A dylib constructor is any function tagged __attribute__((constructor)). dyld calls it
automatically at load time:
// filename: example.c
#include <stdio.h>
#include <syslog.h>
__attribute__((constructor))
static void myconstructor(int argc, const char **argv) {
printf("[+] dylib constructor called from %s\n", argv[0]);
syslog(LOG_ERR, "[+] dylib constructor called from %s\n", argv[0]);
}
Compile that to example.dylib, then run any program with the variable set:
# filename: inject.txt
% DYLD_INSERT_LIBRARIES=example.dylib ./hello
[+] dylib constructor called from ./hello
Hello World
The constructor fires before the target prints its own output. The same one-liner works against a
GUI app: point it at MachOView.app’s binary and the constructor reports being called from
/Applications/MachOView.app/Contents/MacOS/MachOView. You are executing arbitrary code inside an
application you did not write, with nothing more than an environment variable. That is why Apple
spent years narrowing when this is allowed.
The Restrictions, and Where They Leak
If DYLD_INSERT_LIBRARIES worked everywhere, every setuid root binary on the system would be a
trivial local privilege escalation. So dyld marks certain processes as restricted and ignores
the variable for them. Reading dyld’s own source (dyld2.cpp) the logic is explicit: a process
is restricted for one of three reasons, captured in an enum:
// filename: dyld-restrictions.txt
restrictedBySetGUid // the executable has setuid and/or setgid bits set
restrictedBySegment // the Mach-O has a __RESTRICT/__restrict section
restrictedByEntitlements// the code signature makes it restricted
Walking those in turn:
- setuid/setgid binaries are restricted. The moment a binary runs with elevated privilege via the suid/sgid bits, dyld refuses to honor injection environment variables and disables fallback library paths. This is the obvious one: it closes the trivial root escalation.
- A
__RESTRICTsegment with a__restrictsection restricts the binary. A developer can opt out of injection by linking in this special, empty segment. dyld’shasRestrictedSegmentwalks the load commands looking for exactlystrcmp(sect->sectname, "__restrict") == 0. - Entitlements restrict the binary. This is the modern mechanism. The kernel tells dyld whether
the code signature makes the process restricted, surfaced through code-signing flags like
CS_RESTRICTand the hardened-runtime flagCS_RUNTIME. System binaries and hardened-runtime apps land here.
Here is the leak, and it is the whole reason this technique still matters. The hardened runtime blocks injection by default, but an application can carry entitlements that explicitly re-open the door:
com.apple.security.cs.allow-dyld-environment-variables: the app asks dyld to honorDYLD_*variables despite hardened runtime.com.apple.security.cs.disable-library-validation: the app drops the requirement that loaded libraries be signed by the same team. This one is gold: it means you can inject a dylib you signed yourself.
Plenty of real applications ship one or both: Electron apps and anything with a plugin model are
recurring offenders, because they genuinely need to load third-party code. So the hunt becomes:
enumerate installed apps, dump entitlements with codesign -d --entitlements:-, and find a
privileged or interesting target that opted back into injectability. The mitigation is real; the
exceptions are where you live.
disable-library-validationis more dangerous thanallow-dyld-environment-variables, because library validation is what forces an injected dylib to be signed by the app’s own team. Disable it and your self-signed dylib is acceptable. Many bug reports are nothing more than “this privileged app shipped with library validation disabled.”
Dylib Hijacking: Exploiting Libraries the Loader Can’t Find
The second family does not need any environment variable. It exploits the search the loader
performs to locate a dependency: specifically dependencies declared with @rpath. This is the
direct macOS cousin of Windows DLL hijacking, first laid out properly in Patrick Wardle’s 2015
work.
Two Mach-O load commands set it up. LC_LOAD_DYLIB declares a required dependency.
LC_LOAD_WEAK_DYLIB declares an optional one, and the difference is decisive:
// filename: dyld-weak.txt
lib->required = (cmd->cmd != LC_LOAD_WEAK_DYLIB);
// A weak dylib that isn't found does NOT crash the process.
A required dylib that is missing aborts the process. A weak dylib that is missing is silently
skipped, and the program runs on. That is the first hijacking scenario: find a binary with an
LC_LOAD_WEAK_DYLIB pointing at a file that is not actually present, and supply the missing file
yourself. dyld loads your dylib, runs your constructor, and the application never notices a library
it was prepared to live without.
The second scenario abuses @rpath. Many apps declare dependencies as
@rpath/SomeLib.framework/.../SomeLib and then list several LC_RPATH commands giving the loader
an ordered list of directories to substitute for @rpath. otool -l shows the shape:
# filename: otool-rpath.txt
% otool -l "DB Browser for SQLite" | grep -A2 "LC_RPATH\|LC_LOAD_DYLIB"
name @rpath/QtXml.framework/Versions/5/QtXml (offset 24)
cmd LC_RPATH
path @executable_path/Frameworks (offset 12)
cmd LC_RPATH
path @executable_path/../Frameworks (offset 12)
dyld tries each LC_RPATH directory in order until it finds the library. If the real library lives
in the second search path, the first path is an empty slot: drop a malicious dylib of the
right name there and dyld finds yours first and loads it instead of the legitimate one. You have
hijacked the dependency by winning the search order, no missing file required.
There is one catch that makes a naive hijack crash the target: the application still expects all the symbols the real library exported. If your replacement does not provide them, the process dies on the first unresolved symbol. The fix is re-exporting (proxying): your dylib declares the real library as a re-export target, so every symbol request transparently forwards to the genuine library while your constructor still runs. The application sees a complete, working library; you see code execution. This proxy pattern is what turns dylib hijacking from a crash into a clean, invisible injection.
dlopen Hijacking
A variant targets runtime loading rather than launch-time. When an app calls dlopen("libfoo.dylib",
...) with a non-absolute path, dyld runs a search through @rpath, the working directory, and
fallback paths depending on context. If any directory earlier in that search order than the real
library is writable, you plant your dylib there and dlopen hands the app yours. The principle is
identical to the load-command case; only the trigger differs: an explicit runtime call instead of
a startup dependency.
Restrictions Apply Here Too
Hijacking is not unconstrained either. dyld’s LC_RPATH handling refuses @loader_path-relative
rpaths in restricted programs, warning that you should “Codesign main executable with Library
Validation.” Library validation, again, is the backstop: with it enabled, an injected or hijacked
dylib must be signed by the app’s team, and a self-signed replacement is rejected. So the same
entitlement that unlocked DYLD_INSERT_LIBRARIES, disable-library-validation, is what makes a
hijack against a hardened app viable. The two techniques converge on the same weakness: an
application that, for its own convenience, told the system to stop checking who signed the code it
loads.
Why This Matters
Dylib injection and hijacking are the entry-level macOS code-injection primitives, and they remain useful precisely because the mitigations are opt-out, not absolute. The recurring lesson is that Apple’s defenses here are conditional: hardened runtime blocks injection unless the app carries an unlocking entitlement; library validation rejects foreign dylibs unless the app disabled it; a required dylib crashes on absence unless it was declared weak. Every real-world abuse is the discovery of an unless.
Both techniques also share a ceiling. Setting DYLD_INSERT_LIBRARIES requires you to control how
the target is launched; planting a hijack dylib requires a writable directory in the search path.
They are excellent when you already have local code execution and want to move into a more
privileged or more interesting process: which is exactly the situation in most of the
privilege-escalation chains later in this series. When the environment is too locked down for
either, you reach for something that does not rely on the loader at all: injecting straight into a
running process through its Mach task port, which is the next post.
References
- Wardle, P. Writing Bad@ss Malware for OS X: Dylib Hijacking. Virus Bulletin, 2015.
- Apple. dyld source (dyld2.cpp), Hardened Runtime Entitlements. opensource.apple.com / developer.apple.com.