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 __RESTRICT segment with a __restrict section restricts the binary. A developer can opt out of injection by linking in this special, empty segment. dyld’s hasRestrictedSegment walks the load commands looking for exactly strcmp(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_RESTRICT and the hardened-runtime flag CS_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 honor DYLD_* 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-validation is more dangerous than allow-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

  1. Wardle, P. Writing Bad@ss Malware for OS X: Dylib Hijacking. Virus Bulletin, 2015.
  2. Apple. dyld source (dyld2.cpp), Hardened Runtime Entitlements. opensource.apple.com / developer.apple.com.