Dedicated to Kumu Cotton, spark and sunshine in my life.
Summary
With slopping 0days becoming a thing; I decided to try my hand at dyld. I’ve always been fascinated with the linkers and loaders of Unix based operating systems, coming from a place where I’d toyed with LD_PRELOAD and LD_AUDIT libraries for… far too long for my own good. So I decided to have Opus assist me in a full audit of Apple’s dyld. This is currently a 0day vulnerability but needs a chain; Apple themselves do not consider it a vulnerability because the method of attack is “drop a dylib on disk and .dylib hijack something sensitive” in the most simple case.
Cottou is capable of far, far more.
We’re talking bypassing every security mitigation my M1 Max chip has, security and hardware, that is bypassable from userland. PAC, AMFI, Lockdown Mode, all of it can be handwaved away. It makes really good userland rootkit material. And the source code, of course, will be released alongside this post.
Initial Investigation
I initially just wanted to see, if with a Ralph loop, if I could get Opus to get Apple’s OSS dyld building. It did. So then I figured - why not do a code audit on it instead? I’ve found numerous vulnerabilities, but “Cottou”, or more technically “dyld Rebase Opcode Out-of-Bounds Segment Index ACE Vulnerability” is the gem I managed to find. ZDI wasn’t interested either; so I figured I’d go full disclosure with this one.
So in short, you forge a malicious dylib - or you tamper with an existing one to make it malicious. Then, the result is something like this.

Big art for someone I really care about - who means my life to me. You can remove the ANSI art header if you want.
Regardless, the implications from this may seem small. Sure, it’s per process.
Enter launchctl
launchctl is a command-line utility on macOS that allows you to manage launchd. It’s a simple command line program. And well, while macOS and it’s bretheren unfortunately lack the /etc/ld.so.preload deal; we do have a substitute.
launchctl setenv DYLD_INSERT_LIBRARIES=/tmp/cottou.dylib
Assuming you have a CSR bypass - hard to come by, nowadays; or something that gets around AMFI, you’ll be increasing the attack surface of just about every newly spawned binary on the system should it accept cottou. You’re degrading the security posture of every process that spawns after that command is set - sure, it needs sudo, but refer to one of my previous blogposts for some words on sudo.
Per-process, Lockdown Mode is disabled if cottou is allowed into the process. Pointer authentication can be bypassed; ASLR trivially defeated. Every userspace program, especially in the case of a CSR bypass, will have the dylib loaded into it. Think of something like pam_tid.so, a Pluggable Authentication Module that ships on Macs that support Touch ID. Now you can debug it. Now you can potentially inject code into it, assuming you have a userland rootkit position. The possibilities, effectively, are endless.
It works on iOS, too. PAC bypass and all. Doesn’t bypass MIE, or rather, MTE.
The Technical Details
I’d tell you to read the slop code and figure it out, just how I did. But that’s not very nice, so here’s a generalized example of how it works.
First of all - a disclaimer. You are not first-mapper with this exploit. Don’t rely on it if you need to be first mapper for a chain.
Second of all, the vulnerability is in the core of dyld. Namely, MachOAnalyzer::forEachRebase_Opcodes().
To be more specific, the culprit lies here: common/MachOAnalyzer.cpp:5025-5117
// common/MachOAnalyzer.cpp:5068 — EXECUTION PATH (called during loading)
case REBASE_OPCODE_SET_SEGMENT_AND_OFFSET_ULEB:
segIndex = immediate; // 4-bit immediate, range 0-15, NO BOUNDS CHECK
segOffset = read_uleb128(diag, p, end);
segIndexSet = true;
break;
The immediate value is extracted from the low 4 bits of the opcode byte (opcode & 0x0F), giving an attacker-controlled range of 0–15. For a typical 3-segment dylib, valid indices are 0–2. Indices 3–15 produce an out-of-bounds array access.
From my ZDI submission:
Function MachOAnalyzer::forEachRebaseLocation_Opcodes() (line 5005) allocates a variable-length array of Header::SegmentInfo structures on the stack, sized to the number of segments in the loaded Mach-O:
// common/MachOAnalyzer.cpp:5012
BLOCK_ACCCESSIBLE_ARRAY(Header::SegmentInfo, segmentsInfo, leInfo.layout.lastSegIndex+1);
BLOCK_ACCCESSIBLE_ARRAY (defined in common/Array.h:400) expands to a true stack-allocated VLA:
Header::SegmentInfo __segmentsInfo_array_alloc[lastSegIndex+1];
Header::SegmentInfo* segmentsInfo = __segmentsInfo_array_alloc;
When forEachRebase_Opcodes() processes the REBASE_OPCODE_SET_SEGMENT_AND_OFFSET_ULEB opcode, it sets segIndex = immediate without bounds checking. The handler callback then dereferences:
// common/MachOAnalyzer.cpp:5019
uint64_t rebaseVmOffset = segments[segmentIndex].vmaddr + segmentOffset;
This reads segmentsInfo[attacker_index].vmaddr — an out-of-bounds read from the stack. The resulting value is combined with segmentOffset (also attacker-controlled, encoded as ULEB128 in the opcode stream) to compute a write target. Loader::applyFixupsGeneric() then performs *loc += slide at that target address, producing an arbitrary write.
--
Which leads to ACE! :)
Nothingburger or Not?
Depends on if you have something to chain it with. dyld is a very fragile butterfly, very malleable. By far, this is the diamond in the coal mine of dyld and dyld related bugs I’ve found. It doesn’t exactly stop here, though. It can potentially affect other tools in the dyld toolset - but I’ll leave that up to your imagination, eh?
Slop PoCs will be on https://github.com/impost0r/Cottou. It was a fun bug to sit on, but aside from the userland kit I have no real use for it. I look forward to seeing what creative uses you all will have for it.