Most developers will never jailbreak a phone. That is fine. This article is not a how-to, and there is no install guide here.
What makes Dopamine worth reading as an engineer is the constraint it operates under: it has to run a full package ecosystem on an operating system that was explicitly designed to make that impossible, without modifying a single byte of the system partition, and it has to survive every process launch on the device.
Dopamine is a rootless, semi-untethered jailbreak by opa334 and évelyne, written mostly in C and Objective-C. Version support depends heavily on chip and iOS version, so check the README rather than trusting any number in a blog post.
Let's walk the architecture.
The rules of the game
Every design decision downstream falls out of what iOS enforces. Five things matter:
The system volume is sealed. Since iOS 15, the root filesystem is a cryptographically sealed snapshot. You cannot write to /usr/lib and have the device boot. So the entire jailbreak has to live somewhere else.
Code must be signed. The kernel refuses to execute pages that do not carry a valid signature chain. There is also a trust cache, a kernel-side list of hashes that are allowed to run. A tweak you compiled ten seconds ago is in neither.
Processes are sandboxed. Even as root, a process only sees what its sandbox profile allows.
Libraries are validated. Library validation means a process will typically only load libraries signed by the same team as the main binary. That is the single biggest obstacle to loading third-party code into Apple's own processes.
Memory is protected below the kernel. On modern arm64e chips there are protection layers beneath the kernel itself (PPL, and later SPTM) that guard page tables, plus pointer authentication on function pointers. Kernel read/write alone is no longer enough.
So: no writable system directory, no way to sign code, no way to load unsigned libraries, and a kernel you cannot fully trust yourself inside. Everything below is a response to one of those five facts.
Two layers
The repo splits cleanly in two:
Application/ Objective-C, UIKit. The app you tap. Orchestration, UI, logs.
BaseBin/ C, Mach, assembly. The runtime that lives on the device afterwards.
Application is the installer and control panel. BaseBin is the actual jailbreak. Almost everything interesting is in BaseBin, and it keeps running long after the app is closed.
Phase 1: exploits as plugins
The app has a DOExploitManager that selects an exploit based on the device's chip family and OS build, and a DOJailbreaker that drives the whole sequence.
The part worth stealing here is not the exploits, it is the plugin boundary. Each exploit ships as a bundle with an Info.plist declaring what it supports. The repo has several of them side by side, and the wiki has a page on adding new ones. The orchestration code does not care which one runs. It asks the manager for something compatible with this device, runs it, and gets a set of capabilities back.
This is a hardware abstraction layer, applied to bugs. When a new technique appears, you add a bundle instead of rewriting the jailbreak. Given how quickly individual entry points get patched, that boundary is the reason the project survived across four-plus years of OS releases.
Phase 2: from a bug to a stable primitive
Raw exploitation gives you something awkward and fragile. What the rest of the system wants is a clean interface: read kernel memory, write kernel memory, call a kernel function, mark this page executable.
That translation lives in libjailbreak. It also holds a table of kernel structure offsets that vary per Darwin version, because struct layouts change between iOS releases and there are no headers for the ones that matter.
That table is why version support is enumerated so precisely, and why "it should probably work on the next point release" is never true. A wrong offset is not a bug report, it is a kernel panic.
Note the layering discipline: exploitation is one module, primitives are another, and every consumer above talks to the primitive API only. A large percentage of the codebase never has to know how privileges were obtained.
Phase 3: the rootless bootstrap
The system volume is sealed, so Dopamine installs into a randomized path under /private/preboot, and exposes it at /var/jb.
If you have ever built software that must be relocatable, this will look familiar. It is /usr/local versus /usr, or a container volume mount versus the base image. Every package is compiled to reference /var/jb/... instead of /, the actual location is randomized per install, and the symlink hides that indirection from everything above.
The environment itself is a Procursus bootstrap: a proper Debian-style userland with dpkg, so packages install through Sileo or Zebra using ordinary .deb semantics.
Two things fall out of this design. Restoring the device is mostly a matter of deleting a directory, not repairing a system partition. And system updates do not fight with a modified root. "Rootless" sounds like a limitation; in practice it made jailbreaks dramatically less destructive.
Phase 4: a capability server inside PID 1
Here is the design decision I find most interesting.
You have kernel read/write. The naive approach is to hand that to every process that needs it. That is a disaster: any of them can panic the kernel, and every one of them is now a privilege escalation target.
Dopamine does the opposite. It injects a hook into launchd (PID 1), and inside it runs jbserver, a Mach service that owns the privileged primitives. Everything else is a client that sends requests over Mach or XPC via libjailbreak. Requests are organized into domains, and callers are checked for what they are allowed to ask for.
That is a broker pattern, straight out of browser sandbox design. One privileged component, a narrow typed API, everyone else unprivileged. The clients cannot corrupt the kernel because they never touch it.
Putting it inside launchd also solves persistence: PID 1 never dies while userspace is alive, so the jailbreak state outlives the app entirely.
Phase 5: getting into every process
For tweaks to work, code has to load into arbitrary system processes. Two components handle this.
dyldhook patches the dynamic linker itself. It runs before the process's main, checks the process in with jbserver to receive its sandbox extensions and environment info, and handles library validation by making sure a library's signature is registered in the trust cache before the kernel evaluates it.
systemhook.dylib is inserted via DYLD_INSERT_LIBRARIES and does the ongoing work: loading tweaks, and hooking posix_spawn and execve so that every child process inherits the injection.
That last detail is the whole trick. Think LD_PRELOAD, except it re-preloads itself into everything it spawns. Inject once into PID 1, and the property propagates down the entire process tree by induction. You never have to enumerate processes or race a launch.
Actual tweak hooking is delegated to ElleKit, an open-source hooking library that replaced the old proprietary Substrate.
Phase 6: making the system not notice
This is where most of the engineering hours actually went, and it is the least glamorous part.
Once you modify a running process, the OS starts noticing. csops reports the process as invalid. On iOS 16 the networking policy layer began checking code signing validity, which meant modified processes silently lost network access. So systemhook hooks those paths and re-validates.
On arm64e, fork() breaks, because the child needs to inherit memory protections and signing state that the kernel will not copy for it. The fix, forkfix, is a small masterpiece of pragmatism: hook __fork, use a pipe pair to freeze the child immediately after it appears, have the parent ask jbserver to apply the necessary fixups to the child PID, then let it continue.
There is also a jetsam multiplier, because processes carrying a stack of injected tweaks blow through memory limits and get killed.
None of this is the exciting part of a jailbreak. All of it is why it is usable.
Semi-untethered, and the userspace reboot
Everything above lives in memory. A real reboot wipes it, which is what "semi-untethered" means: the device boots stock, and you reopen the app to re-apply.
There is a middle option, though. A userspace reboot tears down and restarts userland without a kernel boot, which means the jailbreak state can be re-established without re-running an exploit. Practically, it turns "something broke, reboot and start over" into a thirty-second operation. launchdhook is what makes it possible, because PID 1 is where the state lives.
The map
Dopamine.app
|
| selects + runs
v
Exploit bundle ---> libjailbreak (kernel primitives, version offsets)
|
v
launchdhook in PID 1
|
+-----+-----+
| jbserver | <---- Mach / XPC ---- every process
+-----+-----+
|
/var/jb ------------------+ trust cache, sandbox extensions, fixups
(Procursus bootstrap)
|
dyldhook + systemhook injected into each process on spawn
|
ElleKit -> tweaks
What transfers to normal software
Strip away the iOS specifics and there are four patterns here worth borrowing:
- Isolate the volatile part behind an interface. Exploits are plugins with declared compatibility. When your dependency on the outside world is guaranteed to break, make replacing it a config change.
-
Centralize dangerous capability, distribute access. One broker owns the primitive, everyone else gets a narrow API. This is browser sandboxing, syscall filtering, and
jbserver, all the same shape. - Make your install root relocatable and additive. Never modify what you do not own. Add a prefix and indirect through it.
- Propagate through inheritance, not enumeration. Hooking spawn beats scanning for processes, in the same way that fixing a base image beats patching running containers.
Notes
Jailbreaking is legal in many jurisdictions but not all, it voids your warranty, and the same mechanisms described here are why a jailbroken device is a weaker security boundary than a stock one. Read the code for the engineering. That is where the value is.
Top comments (0)