<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://twili8.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://twili8.github.io/" rel="alternate" type="text/html" /><updated>2026-10-17T20:10:28+00:00</updated><id>https://twili8.github.io/feed.xml</id><title type="html">twili8t</title><subtitle>twili8t&apos;s blog</subtitle><author><name>twili8t</name></author><entry><title type="html">Auto-darksword: automating iOS offset discovery</title><link href="https://twili8.github.io/2026/10/17/Auto-darksword-automating-iOS-offset-discovery.html" rel="alternate" type="text/html" title="Auto-darksword: automating iOS offset discovery" /><published>2026-10-17T12:00:00+00:00</published><updated>2026-10-17T12:00:00+00:00</updated><id>https://twili8.github.io/2026/10/17/Auto-darksword-automating-iOS-offset-discovery</id><content type="html" xml:base="https://twili8.github.io/2026/10/17/Auto-darksword-automating-iOS-offset-discovery.html"><![CDATA[<p>Why do kernel structs move, how to find them without a device, is green tea actually good for health?</p>

<p>Every kernel exploit eventually dies of the same disease: it works on the version
someone developed it against and then iOS ships and the struct offsets move.
The exploit is fine but the hardcoded hex you wrote one year ago are not. And now you are
staring at a kernel late at night trying to work out whether <code class="language-plaintext highlighter-rouge">so_usecount</code> moved
by eight bytes or sixteen, with nothing to grep for.</p>

<p>That is where I ended up, and it is why I wrote <a href="https://github.com/twili8/auto-darksword">auto-darksword</a>. Go star it!</p>

<h2 id="the-lab-problem">The lab problem</h2>

<p>I test on a many iOS devices. Different chips, different iOS versions,
including betas. The darksword chain — an iOS kernel exploit path targeting IOSurface and ICMPv6 — is small and elegant: an out-of-bounds read and write on an IOSurface backing store, used to corrupt the
ICMPv6 filter pointer inside a sprayed <code class="language-plaintext highlighter-rouge">inpcb</code>, after which the <code class="language-plaintext highlighter-rouge">ICMP6_FILTER</code>
<code class="language-plaintext highlighter-rouge">setsockopt</code> path does arbitrary kernel copies on your behalf. It needs exactly
five kernel offsets to get from “nothing” to “kernel read/write”:</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">#define OFFSET_PCB_SOCKET     0x40
#define OFFSET_SOCKET_SO_COUNT 0x228
#define OFFSET_ICMP6FILT      (0x138 + 0x18)
#define OFFSET_SO_PROTO       0x18
#define OFFSET_PR_INPUT       0x28
</span></code></pre></div></div>

<p>Four of them pinned the device, the fifth was written as an
arithmetic expression, which tells you the author was already tracking version
drift by hand and losing. Multiply that by every device and every beta
possible and the arithmetic stops being fun.</p>

<p>The manual process for each new version is: boot the device, get a working
exploit, use whatever kernel read primitive you already have to dump the
interesting structs, read the offsets off the dump, patch the header, rebuild.
That loop is circular. To find the offsets you need a working
exploit, and the exploit needs the offsets. So every version starts with a
manual archaeology pass over a stripped 30 MB kernel image.</p>

<h2 id="why-this-is-worse-on-ios-than-anywhere-else">Why this is worse on iOS than anywhere else</h2>

<p>The general shape of this problem is common, but iOS makes it unusually
unpleasant, and the jailbreak scene makes it worse in a specific, cultural way.</p>

<p>Structurally, there are no symbols. Unlike on Windows, where you can fetch a PDB for any system binary, the iOS kernel Mach-O has a stripped symbol table, so
<code class="language-plaintext highlighter-rouge">inp_socket</code> does not exist as a name anywhere in the file. What you get is 34 MB of arm64e machine code, a few hundred
thousand C strings and the occasional <code class="language-plaintext highlighter-rouge">__PRELINK_INFO</code> blob that is only
sometimes useful. Static analysis of an Apple kernel is not reverse engineering
in the usual sense; it is closer to reading tea leaves.</p>

<p>Culturally, and this is the part that actually pushed me to build the tool: the
public knowledge is mostly magic numbers passed hand to hand. The original
<code class="language-plaintext highlighter-rouge">DarkSword-RCE</code> JavaScript that everyone forks is the canonical example. It
hardcodes <code class="language-plaintext highlighter-rouge">0x148</code> for the ICMPv6 filter field and <code class="language-plaintext highlighter-rouge">0x254</code> for the socket
usecount. Those numbers are correct for <em>some</em> iOS version. Not all.</p>

<p>That is exactly the failure mode automation fixes. Not by being cleverer than
the folklore, but by deriving the numbers from the actual binary every time, and
by making it cheap to notice when the derivation and the folklore disagree.</p>

<h2 id="dynamic-discovery-and-why-i-went-static-anyway">Dynamic discovery and why I went static anyway</h2>

<p>There are two real ways to get kernel offsets.</p>

<p><strong>Dynamic</strong> is what most people reach for first: run code on the device, walk
the structures at runtime, print the offsets. It is accurate by construction,
because it reads the live layout rather than inferring it. If you already have a
kernel read primitive on the target, this is a twenty line program.</p>

<p>The problem is the bootstrap. Dynamic discovery needs a working exploit, and the
exploit needs the offsets. It also needs a device, a signature, a trust cache, a
free slot on your desk, and about forty minutes of ceremony per version. It does
not work at all for a version where you do not yet have a chain, and it is
awkward to run on more than one device per hour.</p>

<p><strong>Static</strong> reads the kernel out of the IPSW and derives the offsets offline. It is deterministic so you will get the same answers every time, which means it is testable. It runs
in about eight seconds, so it fits in a pre-commit hook or a CI job. And it works
on a version where you have no exploit yet, which is precisely the case where you
need the offsets.</p>

<p>I went static. The dynamic path is still the better oracle if you have a device
to hand.</p>

<h2 id="parsing-the-kernel-is-the-easy-part">Parsing the kernel is the easy part</h2>

<p>To be clear about where the difficulty lives. Reading the Mach-O, walking
sections, decoding arm64, finding <code class="language-plaintext highlighter-rouge">__TEXT</code> and <code class="language-plaintext highlighter-rouge">__TEXT_EXEC</code>: that is all
afternoon work and a hundred lines of Python. Every one of the hard parts below
happened <em>after</em> I had a working parser.</p>

<p>The real problem is this: a 34 MB kernel contains on the order of a million
signed-offset memory accesses. Several thousand of them use an immediate in the
range where <code class="language-plaintext highlighter-rouge">so_usecount</code> lives. Nothing in the instruction stream says which
ones are the interesting field.</p>

<h2 id="what-didnt-work">What didn’t work?</h2>

<p>I want to write these down because the failure modes are the interesting part,
and because two of them are tricks I would have expected to work.</p>

<h3 id="pattern-matching-the-call-and-store-idiom">Pattern matching the call-and-store idiom</h3>

<p>The obvious first move. <code class="language-plaintext highlighter-rouge">inp-&gt;in6p_icmp6filt = kalloc_type(...)</code> compiles to
something like <code class="language-plaintext highlighter-rouge">bl &lt;alloc&gt;; str x0, [x19, #0x150]</code>. Scan for calls whose result
is stored at an immediate offset and collect the offsets.</p>

<p>I ran it. It produced about ten thousand candidate offsets and a scrollback I
could not read, because <code class="language-plaintext highlighter-rouge">bl</code> followed by a store to a structure is simply the
shape of most of the kernel. Narrowing it meant adding conjuncts (the stored
value must be a call result, the offset must be in a plausible range, the
function must be the right one), and once I had added enough conjuncts to make it
precise I had basically written the detector I wanted anyway, minus the
confidence that it was the right one.</p>

<h3 id="anchoring-on-__file__">Anchoring on <code class="language-plaintext highlighter-rouge">__FILE__</code></h3>

<p>XNU stringifies <code class="language-plaintext highlighter-rouge">__FILE__</code> into every <code class="language-plaintext highlighter-rouge">VERIFY</code>, so the kernel is full of strings
like <code class="language-plaintext highlighter-rouge">/Library/Caches/com.apple.xbs/Sources/xnu/xnu-8019.42.4/bsd/netinet6/icmp6.c</code>.
If those strings are referenced from the functions compiled out of that file, you
have a source-level map of the binary, and every offset search can be scoped to
“functions in in_pcb.c” or “functions in icmp6.c”.</p>

<p>In practice, mostly they are not referenced. The short forms are live, but the long
<code class="language-plaintext highlighter-rouge">__FILE__</code> paths are, in the images I looked at, unreferenced leftovers that
somebody linked in without a single load. Worse, the full-path strings that <em>are</em>
referenced point at functions that are not the ones I cared about; in 15.1.1 the
two <code class="language-plaintext highlighter-rouge">icmp6.c</code> file-string references are both deep inside <code class="language-plaintext highlighter-rouge">icmp6_dgram_attach</code>,
not in the filter-handling path I wanted.</p>

<p>This was a useful discovery anyway. It is what made me notice that the kernel is full of
anchors <em>other</em> than file paths, and that the better ones are the stringified
assert expressions.</p>

<h3 id="scanning-protosw-for-its-self-pointer">Scanning <code class="language-plaintext highlighter-rouge">protosw</code> for its self-pointer</h3>

<p>The <code class="language-plaintext highlighter-rouge">protosw</code> struct has a <code class="language-plaintext highlighter-rouge">pr_protosw</code> field that points at the struct itself,
which sounds like a gift: scan data for a self-pointer, and you have found the
protocol switch table, and <code class="language-plaintext highlighter-rouge">pr_input</code> is a fixed distance from it.</p>

<p>It is NULL in the image. <code class="language-plaintext highlighter-rouge">net_add_proto</code> fills it in at boot
(<code class="language-plaintext highlighter-rouge">VERIFY(pp-&gt;pr_protosw == NULL); pp-&gt;pr_protosw = pp;</code>). The self-pointer that
would make this trivial exists only at runtime. I found that out by grepping the
xnu source after the scan came back empty, which is the correct order of
operations and also made me feel slightly foolish.</p>

<h3 id="range-checking-pointers-in-const-data">Range-checking pointers in const data</h3>

<p>The refined version of the same idea. Find records where a few fields are small
integers that look like <code class="language-plaintext highlighter-rouge">pr_type</code> and <code class="language-plaintext highlighter-rouge">pr_protocol</code>, then check that the
following qwords point into <code class="language-plaintext highlighter-rouge">__TEXT</code>. That is a very strong signature, and it
returns nothing, because kernel const data holds PAC-signed pointers. Range
checks on the raw value fail.</p>

<p>This is where I spent an embarrassing amount of time, because the fix appears
obvious in hindsight: strip the PAC and check again. It half works. On 12.5.8 (I know the exploit doesn’t work on this version),
masking the low 48 bits and restoring the kernel’s high bits lands every hook
pointer in <code class="language-plaintext highlighter-rouge">__TEXT</code>. On 15.1.1 the same treatment produces addresses that are
not in the image, and the low byte of the address is not even right.</p>

<p>The thing that actually resolved it was cross-checking one hypothesis against a
neighbouring field in the same struct. In the 15.1.1 record, the field at
<code class="language-plaintext highlighter-rouge">+0x48</code> is a data pointer (it points at <code class="language-plaintext highlighter-rouge">udp_usrreqs</code> in <code class="language-plaintext highlighter-rouge">__DATA</code>), and it
decodes perfectly with the low-48 rule. Same struct, same alignment, same
linker: one pointer class follows one scheme and the other follows a different
one. So the signing scheme is per-pointer-class, not global, and there is no
single decode that works for both. That realisation came from one instruction of
extra scepticism applied to a <em>different field</em>, which is the general lesson I
kept coming back to: whenever a hypothesis fails, look for a nearby fact that
should have been consistent with it and was not.</p>

<p>The offset itself I ended up deriving structurally instead: find the records by
the <code class="language-plaintext highlighter-rouge">pr_type</code>/<code class="language-plaintext highlighter-rouge">pr_protocol</code>/<code class="language-plaintext highlighter-rouge">pr_flags</code> triple that the code-derived
<code class="language-plaintext highlighter-rouge">pr_protocol</code> offset predicts, and take the first hook pointer slot after it.</p>

<h3 id="trusting-the-disassembler">Trusting the disassembler</h3>

<p>This one is a gift and it cost me an hour.</p>

<p>capstone 5.0.7 mis-decodes the immediate on <code class="language-plaintext highlighter-rouge">LDR</code>/<code class="language-plaintext highlighter-rouge">STR</code> pre-index and post-index
forms. Feed it the single most common function prologue instruction in arm64e
code:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>0xf81b63b3  -&gt;  stur x19, [x29, #-0x4a]
</code></pre></div></div>

<p>That word is <code class="language-plaintext highlighter-rouge">str x19, [x29, #-0x10]!</code>. The writeback flag is mis-decoded as an
unscaled-immediate form, and the immediate comes out wrong.</p>

<p>I found this because one instruction in my results contradicted the surrounding
code, and instead of assuming the code was wrong I decoded the word by hand, in
two different ways, and got two different answers. The third oracle was
llvm-objdump, which agreed with neither of my formulas until I noticed that I was
using a made-up bit position.</p>

<p>I have not filed this upstream yet so treat it as a caveat rather than a fixed
bug: if you are writing your own arm64 decoder, check the pre-index and
post-index <code class="language-plaintext highlighter-rouge">LDR</code>/<code class="language-plaintext highlighter-rouge">STR</code> forms against llvm-objdump before you trust them, because
nothing about the bad output looks wrong in isolation.</p>

<p>Two things came out of it. First, the tool now only decodes instruction forms
whose bit layout I verified against known words from the same kernel, which is
why every detector is written against the unsigned-offset forms, where
<code class="language-plaintext highlighter-rouge">imm12 = bits 21:10</code> scaled by access size, and why the one place I wanted a
pre-index store I found a signature that does not need it. Second, and more
generally: a security toolchain is full of tooling that looks
authoritative and is not, and the only defence is a second opinion at the point
where a result looks surprising.</p>

<h2 id="what-worked-asserts-as-anchors-semantics-as-the-decoder">What worked: asserts as anchors, semantics as the decoder</h2>

<p>The realisation was that XNU’s <code class="language-plaintext highlighter-rouge">VERIFY</code> macro stringifies the assertion
expression, so the kernel contains hundreds of English-language descriptions of
its own invariants:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>so-&gt;so_usecount &gt; 0
so-&gt;so_proto != NULL
SOCK_PROTO(inp-&gt;inp_socket) == IPPROTO_UDP
icmp6_dgram_attach @%s:%d
</code></pre></div></div>

<p>If a string describes a condition, the code near its reference has to
materialise the operands of that condition, and the field offsets are the
immediates in those loads. That turns “find the offset” into “find the place
where this sentence is true and read the constant”, which is a much easier
question.</p>

<p>Two engineering pieces made it practical.</p>

<p><strong>One reference index, built once.</strong> All of this hinges on resolving
<code class="language-plaintext highlighter-rouge">adrp</code> + <code class="language-plaintext highlighter-rouge">add</code> pairs, and doing that per-string meant a full scan of 34 MB of
text per anchor. Instead I decode the ADRP page of every instruction once, keep a
small set of pending candidates per register, and build a map from target
address to reference sites. One pass, eight seconds, 122,000 distinct targets
for the 15.1.1 kernel. After that, asking “who references this string” is a
dictionary lookup, and I could iterate on detectors in seconds instead of
minutes.</p>

<p><strong>Semantic decoding, not pattern matching.</strong> For each offset I wrote down what
the source says the code must do, then wrote a detector that checks for that
sequence. This is the opposite of a generic scanner: each detector is a tiny
theorem prover about one function.</p>

<p><code class="language-plaintext highlighter-rouge">inp_socket</code>, <code class="language-plaintext highlighter-rouge">so_proto</code> and <code class="language-plaintext highlighter-rouge">pr_protocol</code> come out of one assert. The UDP input
path asserts that the socket’s protocol matches IPPROTO_UDP, so the code has to
load <code class="language-plaintext highlighter-rouge">inp-&gt;inp_socket</code>, chase it to the socket, load <code class="language-plaintext highlighter-rouge">so-&gt;so_proto</code>, chase that
to the protosw, load <code class="language-plaintext highlighter-rouge">pr_protocol</code>, and compare it with 0x11. Walking that chain
backwards yields three offsets at once, and they check each other: three
independently extracted constants that have to be consistent with the same
pointer chase is much stronger evidence than one constant alone.</p>

<p>Schematically, the code runs top to bottom and the detector walks it bottom to
top, from the compare back to the first load:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>    x19 = inp
     |
     |  ldr  x9, [x19, #inp_socket]        &lt;- OFFSET_PCB_SOCKET
     v
    x9  = socket
     |
     |  ldr  x8, [x9,  #so_proto]          &lt;- OFFSET_SO_PROTO
     v
    x8  = protosw
     |
     |  ldrh w0, [x8,  #pr_protocol]       &lt;- third offset, same single assert
     v
    w0 == 0x11                             &lt;- IPPROTO_UDP: the sentence is true here
</code></pre></div></div>

<p>The part worth showing is how little “knowing” is in there. The detector does
not understand sockets; it knows that a base register came from a load, and that
a field offset is the immediate in that load:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># tools/auto_darksword/finder.py.
</span><span class="k">def</span> <span class="nf">walk_loads</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">idx</span><span class="p">,</span> <span class="n">reg</span><span class="p">,</span> <span class="n">depth</span><span class="p">):</span>
    <span class="s">"""Follow ldr xT,[xB,#imm] producers backwards from reg.

    Returns [imm_0, imm_1, ...]  the field that produced reg, then the one
    that produced *its* base. None if a hop is missing.
    """</span>
    <span class="n">chain</span><span class="p">,</span> <span class="n">cur</span> <span class="o">=</span> <span class="p">[],</span> <span class="n">reg</span>
    <span class="k">for</span> <span class="n">_</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">depth</span><span class="p">):</span>
        <span class="n">nxt</span> <span class="o">=</span> <span class="bp">None</span>
        <span class="k">for</span> <span class="n">j</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">idx</span> <span class="o">-</span> <span class="mi">1</span><span class="p">,</span> <span class="nb">max</span><span class="p">(</span><span class="o">-</span><span class="mi">1</span><span class="p">,</span> <span class="n">idx</span> <span class="o">-</span> <span class="mi">16</span><span class="p">),</span> <span class="o">-</span><span class="mi">1</span><span class="p">):</span>  <span class="c1"># bounded lookback window
</span>            <span class="n">l</span> <span class="o">=</span> <span class="n">arm64</span><span class="p">.</span><span class="n">ldr_uoff</span><span class="p">(</span><span class="n">ctx</span><span class="p">[</span><span class="n">j</span><span class="p">][</span><span class="mi">1</span><span class="p">])</span>
            <span class="k">if</span> <span class="n">l</span> <span class="ow">and</span> <span class="n">l</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span> <span class="o">==</span> <span class="n">cur</span><span class="p">:</span>      <span class="c1"># something loaded into the register we want
</span>                <span class="n">nxt</span> <span class="o">=</span> <span class="p">(</span><span class="n">j</span><span class="p">,</span> <span class="n">l</span><span class="p">)</span>           <span class="c1"># its base register is the next link up
</span>                <span class="k">break</span>
        <span class="k">if</span> <span class="n">nxt</span> <span class="ow">is</span> <span class="bp">None</span><span class="p">:</span>
            <span class="k">return</span> <span class="n">chain</span> <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">chain</span><span class="p">)</span> <span class="o">&gt;=</span> <span class="n">depth</span> <span class="k">else</span> <span class="bp">None</span>
        <span class="n">idx</span><span class="p">,</span> <span class="p">(</span><span class="n">cur</span><span class="p">,</span> <span class="n">_rn</span><span class="p">,</span> <span class="n">imm</span><span class="p">)</span> <span class="o">=</span> <span class="n">nxt</span>
        <span class="n">chain</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="n">imm</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">chain</span>
</code></pre></div></div>

<p>The call site is two lines. <code class="language-plaintext highlighter-rouge">rn</code> is the base register of the <code class="language-plaintext highlighter-rouge">ldrh</code> and <code class="language-plaintext highlighter-rouge">rt</code> is
what it compared, so by the time we get there we have already checked that the
instruction is a 16-bit load and that a <code class="language-plaintext highlighter-rouge">cmp</code> within the next four instructions
compares it against <code class="language-plaintext highlighter-rouge">0x11</code>:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">chain</span> <span class="o">=</span> <span class="n">ctx</span><span class="p">.</span><span class="n">walk_loads</span><span class="p">(</span><span class="n">ctxw</span><span class="p">,</span> <span class="n">i</span><span class="p">,</span> <span class="n">rn</span><span class="p">,</span> <span class="mi">2</span><span class="p">)</span>   <span class="c1"># -&gt; [so_proto, inp_socket]
</span><span class="n">so_proto_off</span><span class="p">,</span> <span class="n">inp_socket_off</span> <span class="o">=</span> <span class="n">chain</span><span class="p">[</span><span class="mi">0</span><span class="p">],</span> <span class="n">chain</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">so_usecount</code> is a straight vote. <code class="language-plaintext highlighter-rouge">so-&gt;so_usecount &gt; 0</code> appears 18 times in
15.1.1 and 27 times in 12.5.8. Each site loads a field and compares it with
zero. On 15.1.1 that is twelve votes for <code class="language-plaintext highlighter-rouge">0x228</code> and one for <code class="language-plaintext highlighter-rouge">0x22c</code>; on 12.5.8,
nine for <code class="language-plaintext highlighter-rouge">0x240</code>. Majority voting is not exciting, but a field that the compiler
touches from eighteen different call sites is not going to be misread
systematically.</p>

<p><code class="language-plaintext highlighter-rouge">in6p_icmp6filt</code> comes from <code class="language-plaintext highlighter-rouge">icmp6_dgram_attach</code>, which allocates the per-pcb
ICMPv6 filter, stores the pointer into the pcb, and then fills the object with
0xff. The detector looks for that store inside the function that the
<code class="language-plaintext highlighter-rouge">icmp6_dgram_attach</code> string points at, following <code class="language-plaintext highlighter-rouge">so-&gt;so_pcbdata</code> to the pcb
first so it knows which base register it is storing into.</p>

<p><code class="language-plaintext highlighter-rouge">inp_gencnt</code> has a lovely subtlety. <code class="language-plaintext highlighter-rouge">in_pcballoc</code> does
<code class="language-plaintext highlighter-rouge">inp-&gt;inp_gencnt = ++pcbinfo-&gt;ipi_gencnt</code>, which is two stores: one back into the
pcbinfo, one into the pcb. My first detector took the first store and confidently
reported the pcbinfo’s generation counter offset instead. The rule that works is
“the store whose base register differs from the register the increment came
from”, which is a one-line fix and the kind of thing that would have quietly
shipped as a wrong number if I had not been reading the generated evidence.</p>

<p><code class="language-plaintext highlighter-rouge">inp_list</code> falls out of <code class="language-plaintext highlighter-rouge">LIST_INSERT_HEAD</code> semantics: the new pcb’s <code class="language-plaintext highlighter-rouge">le_prev</code> is
set to the address of the list head, and <code class="language-plaintext highlighter-rouge">le_next</code> sits 8 bytes lower.</p>

<p><code class="language-plaintext highlighter-rouge">pr_input</code> is the data-side one described above, calibrated by the
code-derived <code class="language-plaintext highlighter-rouge">pr_protocol</code> offset rather than by a hardcoded layout.</p>

<p>One caveat on that last one, because it is the weakest link in the set. The
<code class="language-plaintext highlighter-rouge">pr_protocol</code> the detector recovers does not sit where the xnu headers say it
should: I got <code class="language-plaintext highlighter-rouge">0x22</code> where <code class="language-plaintext highlighter-rouge">struct protosw</code> has it at <code class="language-plaintext highlighter-rouge">0x02</code>. Feeding the wrong
offset into the calibration still lands on <code class="language-plaintext highlighter-rouge">0x28</code>, which is the value the
exploit wants, so the number is right but the reasoning behind it is not. The
honest description is that <code class="language-plaintext highlighter-rouge">pr_input</code> is matched by table shape and agrees with
the code-derived <code class="language-plaintext highlighter-rouge">pr_protocol</code> arithmetic, not that it is derived from it. Worth
fixing before anyone relies on the calibration on a version I have not tested.</p>

<h2 id="two-bugs-worth-writing-down">Two bugs worth writing down</h2>

<p>Both were the same species of error, and both would have produced plausible
wrong answers.</p>

<p><strong>Anchor sites are not where the code is.</strong> My first version scanned backwards
from the string reference, on the assumption that the assert’s operands are
computed before the panic call. For 15.1.1 that happens to be true and it worked.
For 12.5.8 the <code class="language-plaintext highlighter-rouge">__func__</code> string reference sits at the top of the function and
the interesting store is forty instructions later, so the detector found
nothing. The fix is to look both ways, which is what the code does now.</p>

<p><strong>The address of a substring is not the address of the string.</strong> XNU stringifies
<code class="language-plaintext highlighter-rouge">__func__</code>, so the bytes in the binary are literally <code class="language-plaintext highlighter-rouge">"icmp6_dgram_attach"</code>
including quote characters. Searching for <code class="language-plaintext highlighter-rouge">icmp6_dgram_attach</code> finds the needle
one byte into the string, and no ADRP in the kernel points at offset+1, so the
reference count came back zero. The fix is to walk back to the NUL terminator and
use the start of the string. I hit this twice, once during development and once
again in a different detector before I generalised it.</p>

<p>Neither of these is a subtle reverse-engineering problem. Both are the kind of
thing that makes an automated tool quietly worse than the person who would have
done it by hand, which is the real argument for writing evidence next to every
value.</p>

<h2 id="what-the-tool-outputs">What the tool outputs</h2>

<p>The point of all this evidence is that it should be checkable by a human who
does not trust the tool. Every derived value carries the anchor string, its
address, the instruction site, and the reasoning:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"in6p_icmp6filt"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"value"</span><span class="p">:</span><span class="w"> </span><span class="s2">"0x150"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"confidence"</span><span class="p">:</span><span class="w"> </span><span class="s2">"medium"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"evidence"</span><span class="p">:</span><span class="w"> </span><span class="p">[{</span><span class="w">
    </span><span class="nl">"anchor"</span><span class="p">:</span><span class="w"> </span><span class="s2">"icmp6_dgram_attach @%s:%d"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"anchor_va"</span><span class="p">:</span><span class="w"> </span><span class="s2">"0xfffffff00732a42f"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"site"</span><span class="p">:</span><span class="w"> </span><span class="s2">"0xfffffff007fa4ea4"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"detail"</span><span class="p">:</span><span class="w"> </span><span class="s2">"so-&gt;so_pcbdata+0x10 -&gt; inp; str &lt;alloc&gt;, [inp+0x150] (after call)"</span><span class="w">
  </span><span class="p">}]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Note <code class="language-plaintext highlighter-rouge">"confidence": "medium"</code> with one hit. That offset rests on a single
signature site, and the tool says so instead of rounding up. Confidence is
derived from hit counts and from cross-checks: <code class="language-plaintext highlighter-rouge">pr_input</code> gets downgraded if it
disagrees with the layout implied by the code-derived <code class="language-plaintext highlighter-rouge">pr_protocol</code>, <code class="language-plaintext highlighter-rouge">inp_socket</code>
gets downgraded if it is not 8-byte aligned.</p>

<p>The other half of failing loudly is the build. If any required offset is
unresolved, the generated header gets written with a <code class="language-plaintext highlighter-rouge">#error</code> in it. The
practical effect is that you cannot accidentally compile a binary with a guessed
offset in it, which is the failure mode that produces an unexplained kernel
panic on a device two days later.</p>

<h2 id="cross-validation">Cross-validation</h2>

<p>I tested against two kernels:</p>

<ul>
  <li><strong>15.1.1 on A15</strong>, where the offsets are already known to work because that is what the exploit I forked from targets.</li>
  <li><strong>12.5.8 on iPhone7,2</strong>, validated against the xnu-4903 headers.</li>
</ul>

<p>Both columns were checked by running the tool and diffing against those
expectations by hand. That is not the same as a test suite, and the repo does
not ship one, so nothing here stops a future change from quietly moving a
number.</p>

<table>
  <thead>
    <tr>
      <th>offset</th>
      <th>12.5.8</th>
      <th>15.1.1</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">inp_socket</code></td>
      <td>0x40</td>
      <td>0x40</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">so_usecount</code></td>
      <td>0x240</td>
      <td>0x228</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">in6p_icmp6filt</code></td>
      <td>0x140</td>
      <td>0x150</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">so_proto</code></td>
      <td>0x18</td>
      <td>0x18</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">pr_input</code></td>
      <td>0x28</td>
      <td>0x28</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">inp_gencnt</code></td>
      <td>0x78</td>
      <td>0x78</td>
    </tr>
  </tbody>
</table>

<p>The tool reproduces the published 15.1.1 constants exactly, on a binary it has
never seen, with no version-specific special cases.
Note that only two of the six values differ between the two kernels, and both of
them are the ones with any real variation. <code class="language-plaintext highlighter-rouge">struct socket</code> changed by 0x18 between
12.5.8 and 15.1.1; <code class="language-plaintext highlighter-rouge">struct inpcb</code> did not move.</p>

<h2 id="what-is-still-manual">What is still manual</h2>

<p>The <code class="language-plaintext highlighter-rouge">struct socket_info</code> field the <code class="language-plaintext highlighter-rouge">socket_info</code> syscall fills with
<code class="language-plaintext highlighter-rouge">inp_gencnt</code>. That code path has no assert strings anywhere near it, no
identifiers, and when I tried to find it by looking for stores of <code class="language-plaintext highlighter-rouge">inp_gencnt</code>
into a caller-supplied buffer, I got several hundred candidate sites and no
signal. I left it as a per-version constant, marked as manual in the header and
in the JSON, overridable with <code class="language-plaintext highlighter-rouge">--set socket_info_gencnt=0x...</code>, and documented as
a known gap.</p>

<p>I mention it because the temptation with automation is to round a “probably
right” up to “right”.</p>

<h2 id="using-it">Using it</h2>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>./auto-darksword kernelcache.release.iPhone14,2
./auto-darksword ~/Downloads/iPhone_4.7_12.5.8_16H88_Restore.ipsw
make offsets <span class="nv">KERNEL</span><span class="o">=</span>/path/to/kernel
</code></pre></div></div>

<p>Python 3 only. An IPSW whose kernelcache uses
Apple’s <code class="language-plaintext highlighter-rouge">complzss</code> compression needs the <code class="language-plaintext highlighter-rouge">ipsw</code> tool, which is auto-detected; a
plain decompressed kernel Mach-O needs nothing.</p>

<p>For a new beta the workflow is: download the IPSW, run the tool,
read the eight lines of output, rebuild. If a detector comes up empty, the JSON
says which one and what it was looking for, which turns “the exploit broke” into
a ten-minute question instead of a night.</p>

<h2 id="what-i-would-do-next">What I would do next</h2>

<p>The detectors are not darksword-specific, they are about <code class="language-plaintext highlighter-rouge">inpcb</code>, <code class="language-plaintext highlighter-rouge">socket</code> and
<code class="language-plaintext highlighter-rouge">protosw</code>, and most of them need nothing from the exploit except a reason to
exist. The obvious next step is running the same detectors across every kernel
I can get my hands on, iOS 16 through the current betas, and treating each new
disagreement as either a detector improvement or a genuine layout change worth
writing down.</p>

<p>The second step is harder and more interesting. On 12.x the kernelcache carries
<code class="language-plaintext highlighter-rouge">__PRELINK_INFO</code>, which may be enough to recover real symbol names for the older
devices and retire the string anchors entirely. If that pans out, the detectors
stop being clever and start being lookups.</p>

<h2 id="notes-on-process">Notes on process</h2>

<p>A few things I did that I would keep doing, because they are what stopped this
from being a pile of guesses:</p>

<p><strong>Derive and validate through different paths.</strong> The 15.1.1 expectations come
from the exploit working on real hardware; the 12.5.8 expectations come from
reading xnu headers. The tool has never seen either source. Agreement between a
binary-derived answer and a source-derived or empirically-derived answer is
evidence; agreement between two copies of the same guess is not.</p>

<p><strong>Read the evidence, do not just read the answer.</strong> The generated JSON is not
decoration. Both the <code class="language-plaintext highlighter-rouge">inp_gencnt</code> bug and the anchor-window bug were caught by
looking at a site address and going to read the surrounding instructions, not by
looking at the number and deciding it looked plausible.</p>

<p><strong>Distrust the toolchain at the point of surprise.</strong> The capstone bug, the
substring address bug, the two anchor sites pointing somewhere unexpected: every
one of them looked like a fact about the kernel until I checked what was actually
producing the fact.</p>

<p>If you take one thing from this: just automate whatever is tedious!</p>

<h1 id="afterthoughts">Afterthoughts</h1>
<p>This was a fun project. I really liked it.</p>

<p>Twili8t.</p>]]></content><author><name>twili8t</name></author><summary type="html"><![CDATA[Why do kernel structs move, how to find them without a device, is green tea actually good for health?]]></summary></entry><entry><title type="html">iCloud locks on iOS 12</title><link href="https://twili8.github.io/2026/10/09/iCloud-locks-on-iOS-12.html" rel="alternate" type="text/html" title="iCloud locks on iOS 12" /><published>2026-10-09T12:00:00+00:00</published><updated>2026-10-09T12:00:00+00:00</updated><id>https://twili8.github.io/2026/10/09/iCloud-locks-on-iOS-12</id><content type="html" xml:base="https://twili8.github.io/2026/10/09/iCloud-locks-on-iOS-12.html"><![CDATA[<p>What are iCloud locks, why should I care, how to remove them, is human nature materialistically dialectical?</p>

<h3 id="background">Background</h3>
<ul>
  <li>An iCloud Activation Lock is the check that appears after an iPhon gets erased with Find My enabled. It’s enforced in part by <code class="language-plaintext highlighter-rouge">mobileactivationd</code>, a <code class="language-plaintext highlighter-rouge">launchd</code> daemon that reports activation state as strings like “Unactivated”, “Activated”, and “FactoryActivated”.</li>
  <li>An IPSW is Apple’s firmware archive.</li>
  <li>The jailbreak is checkra1n, which uses the checkm8 bootrom exploit for A7-A11 devices. It leaves SSH reachable.</li>
</ul>

<p>I have an old iOS 12.5.8 iPhone 6 I gave to someone. When they returned it, they had forgotten their iCloud password, leaving me with a slight problem. Apple products have these locks on them, iCloud activation locks, which are triggered when you reset a phone without logging out of your iCloud account. In this article, we will look at how I can successfully bypass it and use my phone for its camera plus a few pictures I took!</p>

<h1 id="app-bundles">App Bundles</h1>

<p>My first instinct was to look online for tools that already do the job. Reddit was not helpful in the slightest for once but some exist however they’re all paid! 😞. A friend tossed me an app bundle, one of those sketchy commercial “remove iCloud activation lock” utilities that float around download sites. I downloaded it and started working.</p>

<blockquote>
  <p>Mac apps are folders with a .app extension. The application’s entry binary is specified in the .app’s Contents/Info.plist’s CFBundleExecutable entry, which points to a Mach-O in Contents/MacOS/.</p>
</blockquote>

<p>So this write-up follows how the tool gets past the activation lock on an iPhone running <strong>iOS 12.5.8 (build 16H88)</strong>, and, once I got hold of a stock 16H88 filesystem for diffing, exactly which bytes it patches in Apple’s software to do it and why.</p>

<h1 id="triage">Triage</h1>

<hr />

<p>The <code class="language-plaintext highlighter-rouge">Resources/</code> folder is basically a graveyard of half-finished ideas, but three tiny shell scripts (<code class="language-plaintext highlighter-rouge">systemA.dat</code>, <code class="language-plaintext highlighter-rouge">systemB.dat</code>, <code class="language-plaintext highlighter-rouge">systemC.dat</code>, the <code class="language-plaintext highlighter-rouge">.dat</code> extension appears to be an attempt at not-looking-like-scripts) stick out. A fourth file, <code class="language-plaintext highlighter-rouge">launchProxy.dat</code>, boots an SSH tunnel:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>./../MacOS/iproxy 12345 44 2&gt; /dev/null &amp;
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">iproxy 12345 44</code> forwards local port 12345 to the device’s usbmuxd port 44, the SSH service that checkra1n leaves behind. So the tool assumes the device is <strong>already jailbroken, SSH on port 44, root password <code class="language-plaintext highlighter-rouge">alpine</code></strong>.</p>

<p><code class="language-plaintext highlighter-rouge">systemC.dat</code> is an iOS iCloud bypass that only works from iOS 12.3 downward, deleting Setup.app (the app that opens when you first turn the phone on and asks for your apple ID and all)</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>./passssh <span class="nt">-p</span> <span class="s1">'alpine'</span> ssh ... <span class="s2">"mount -o rw,union,update / &amp;&amp;
  mv /Applications/Setup.app /Applications/Setup.app.crae &amp;&amp;
  uicache --all &amp;&amp; killall backboardd"</span>
</code></pre></div></div>

<p>Rename <code class="language-plaintext highlighter-rouge">Setup.app</code> away, rebuild the icon cache, restart SpringBoard. The device boots straight to the home screen instead of the Setup Assistant. This was patched in iOS 12.5.4+.</p>

<p><code class="language-plaintext highlighter-rouge">systemB.dat</code> is the real payload:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>mount <span class="nt">-o</span> rw,union,update /
scp ./741c.dat root@localhost:/usr/libexec/741c.dat <span class="c"># send 741c.dat to the device</span>
<span class="nb">mv</span> /usr/libexec/mobileactivationd /usr/libexec/mobileactivationdbak <span class="c"># backup mobileactivationd</span>
<span class="nb">mv</span> /usr/libexec/741c.dat /usr/libexec/mobileactivationd <span class="c"># rename 741c.dat to mobileactivationd</span>
<span class="nb">chmod </span>755 /usr/libexec/mobileactivationd <span class="c"># make it +executable</span>
launchctl unload /System/Library/LaunchDaemons/com.apple.mobileactivationd.plist
launchctl load   /System/Library/LaunchDaemons/com.apple.mobileactivationd.plist <span class="c"># reload it in memory</span>
killall backboardd
</code></pre></div></div>

<p>In other words: <strong>replace the system activation daemon with a patched copy, restart it, restart the UI</strong>. The file is written to the disk, but due to an invalid signature it needs a jailbreak to restart; more on this later.</p>

<p>Two patched daemons ship in the bundle, and picking the wrong one would be embarrassing and probably not even work:</p>

<table>
  <thead>
    <tr>
      <th>File</th>
      <th>Size</th>
      <th>Self-identification</th>
      <th>Era</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">741c.dat</code></td>
      <td>2 261 712</td>
      <td><code class="language-plaintext highlighter-rouge">MobileActivation-353.260.2</code>, built Aug 19 2019</td>
      <td>iOS 12.x</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">c502.dat</code></td>
      <td>2 160 848</td>
      <td><code class="language-plaintext highlighter-rouge">MobileActivation-489.80.1</code>, built Jan 9 2020</td>
      <td>iOS 13.x</td>
    </tr>
  </tbody>
</table>

<p>That <code class="language-plaintext highlighter-rouge">353.260.2</code> number is corroborated externally: iOS 12.5.7 user-agent strings to Albert contain <code class="language-plaintext highlighter-rouge">iOS Device Activator (Mobile-Activation-353.260.2)</code>. So <code class="language-plaintext highlighter-rouge">741c.dat</code> is the iOS 12 payload, and <code class="language-plaintext highlighter-rouge">systemB</code>, the one that pushes <code class="language-plaintext highlighter-rouge">741c.dat</code>, is the script that fires on our 12.5.8 target. <code class="language-plaintext highlighter-rouge">systemA</code> pushes <code class="language-plaintext highlighter-rouge">c502.dat</code> instead, which is the iOS 13 path.</p>

<p>Everything else in <code class="language-plaintext highlighter-rouge">Resources/</code> (<code class="language-plaintext highlighter-rouge">checkra.tar.gz</code>, <code class="language-plaintext highlighter-rouge">bypass.tar.gz</code>, <code class="language-plaintext highlighter-rouge">ldAIO.zip</code>, <code class="language-plaintext highlighter-rouge">palera1*.tar.gz</code>) belongs to other flows (A12+/iOS 15+ and so on). For a 12.5.8 iPhone (A7/A8, iPhone 5s through 6 Plus), the relevant jailbreak is the <code class="language-plaintext highlighter-rouge">checkra.tar.gz</code> payload: checkra1n, PongoOS, and a rooted ramdisk. checkra1n officially supports iOS 12.0+ on exactly these devices, so the tool is relying on checkm8, an unpatchable bootrom exploit.</p>

<hr />

<h1 id="the-diff">The Diff</h1>

<p>Once I had the stock daemon out of an iOS firmware archive (IPSW) that was a pain to extract (<code class="language-plaintext highlighter-rouge">.../16H88__iPhone7,2/root/usr/libexec/mobileactivationd</code>), the diff was comically small:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>sizes:    both 2 261 712 bytes  (no code added, no code removed)
SHA-256:  53ee2751...  (stock)   vs   04aead66...  (patched)
cmp -l:   95 differing bytes total
</code></pre></div></div>

<p>95 bytes, and several of them aren’t even interesting:</p>

<table>
  <thead>
    <tr>
      <th>Offset</th>
      <th>What it is</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">0xBB1</code></td>
      <td><code class="language-plaintext highlighter-rouge">LC_UUID</code> load command, regenerated</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">0xBCE</code>, <code class="language-plaintext highlighter-rouge">0xBD2</code></td>
      <td><code class="language-plaintext highlighter-rouge">LC_BUILD_VERSION</code>: <code class="language-plaintext highlighter-rouge">minos</code>/<code class="language-plaintext highlighter-rouge">sdk</code> <code class="language-plaintext highlighter-rouge">12.5</code> -&gt; <code class="language-plaintext highlighter-rouge">12.4</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">0xCD2</code>, <code class="language-plaintext highlighter-rouge">0xD32</code></td>
      <td>dylib install-timestamp tweaks</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">0x203EF6</code></td>
      <td>cstring: <code class="language-plaintext highlighter-rouge">built on Aug 19 2022</code> -&gt; <code class="language-plaintext highlighter-rouge">built on Aug 19 2019</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">0x223671+</code></td>
      <td><code class="language-plaintext highlighter-rouge">__LINKEDIT</code> code-signature blob, so garbage either way</td>
    </tr>
  </tbody>
</table>

<p>The build-date rewrite is a nice touch: the stock binary says 2022, the patched one backdates itself to 2019 to match the era the patch was written. Either way, the code signature is invalid now; the device doesn’t care because the jailbreak takes care of it, but on an unjailbroken device it wouldn’t work.</p>

<p>That leaves <strong>five bytes.</strong></p>

<p>All five are single-byte changes at non-zero offsets inside <code class="language-plaintext highlighter-rouge">__text</code> (section VA base <code class="language-plaintext highlighter-rouge">0x1000034C8</code>, file offset <code class="language-plaintext highlighter-rouge">13512</code>):</p>

<hr />

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>fileoff    VA             original word        patched word         instruction
------    -------------   -----------------    -----------------    ------------------------------
80960     0x100013C40     0xF9421D03           0xF9422503           ldr x3,  [x8, #0x438] -&gt; #0x448
119524    0x10001D2E4     0xF9421D00           0xF9422500           ldr x0,  [x8, #0x438] -&gt; #0x448
188412    0x10002DFFC     0xF9421D00           0xF9422500           ldr x0,  [x8, #0x438] -&gt; #0x448
196384    0x10002FF20     0xF9421D17           0xF9422517           ldr x23, [x8, #0x438] -&gt; #0x448
197372    0x1000302FC     0xF9421D00           0xF9422500           ldr x0,  [x8, #0x438] -&gt; #0x448
</code></pre></div></div>

<p>A 64-bit load from a <code class="language-plaintext highlighter-rouge">__DATA</code> address table, with the immediate offset nudged from <code class="language-plaintext highlighter-rouge">0x438</code> to <code class="language-plaintext highlighter-rouge">0x448</code>: sixteen bytes, two slots over. Decoding the immediates properly (which required catching myself after an off-by-one alignment bug the first time I tried:</p>

<p><code class="language-plaintext highlighter-rouge">LDR Xt, [Xn, #imm12*8]</code> -&gt; <code class="language-plaintext highlighter-rouge">0x438/8 = 135</code>, <code class="language-plaintext highlighter-rouge">0x448/8 = 137</code>):</p>

<ul>
  <li>In every case, <code class="language-plaintext highlighter-rouge">x8</code> comes from <code class="language-plaintext highlighter-rouge">adrp x8, 0x10020D000</code> (with the usual <code class="language-plaintext highlighter-rouge">nop</code> inserted by a re-signing tool between <code class="language-plaintext highlighter-rouge">adrp</code> and <code class="language-plaintext highlighter-rouge">ldr</code>).</li>
  <li>So the load is from <code class="language-plaintext highlighter-rouge">__DATA.__const</code> at <strong><code class="language-plaintext highlighter-rouge">0x10020D438</code></strong>, redirected to <strong><code class="language-plaintext highlighter-rouge">0x10020D448</code></strong>.</li>
</ul>

<p>Both slots hold pointers into <code class="language-plaintext highlighter-rouge">__DATA.__cfstring</code>, and following them:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>0x10020D438 -&gt; 0x100213918 -&gt; cstring 0x100204256, len 0x0B  "Unactivated"
0x10020D440 -&gt; 0x100213938 -&gt; cstring 0x100204262, len 0x09  "Activated"
0x10020D448 -&gt; 0x100213958 -&gt; cstring 0x10020426C, len 0x10  "FactoryActivated"
</code></pre></div></div>

<h1 id="the-trick">The Trick</h1>

<p>The patch is one logical operation:</p>

<blockquote>
  <p><strong>Wherever the daemon would report the activation state as <code class="language-plaintext highlighter-rouge">"Unactivated"</code>, report <code class="language-plaintext highlighter-rouge">"FactoryActivated"</code> instead.</strong></p>
</blockquote>

<p>You could have injected dylibs, hooked libraries, or patched if statements, but instead it’s just a string-table redirect at five call sites. Less is more. The stock <code class="language-plaintext highlighter-rouge">__const</code> layout (<code class="language-plaintext highlighter-rouge">"Unactivated"</code>, <code class="language-plaintext highlighter-rouge">"Activated"</code>, <code class="language-plaintext highlighter-rouge">"FactoryActivated"</code>) sits in that table in order, which is why the patcher only had to slide the immediate two slots right: the string it wants was already adjacent in memory.</p>

<p>Why <code class="language-plaintext highlighter-rouge">"FactoryActivated"</code>? Because on iOS 12 that’s a <em>real, privileged</em> state. Factory activation is what Apple’s own factory tools write for devices that never go through the normal <a href="http://albert.apple.com/">albert.apple.com</a> handshake; software like SpringBoard’s purplebuddy, <code class="language-plaintext highlighter-rouge">commcenter</code>, and the setup flow treat a factory-activated device as trustworthy by default: no iCloud account, no activation ticket, and no <code class="language-plaintext highlighter-rouge">"this iPhone is locked to an owner"</code> screen.</p>

<p>Loading the patched binary (<code class="language-plaintext highlighter-rouge">741c.dat</code>) as <strong>ARM64</strong>, the five modified functions, in VA order:</p>

<table>
  <thead>
    <tr>
      <th>#</th>
      <th>Function start</th>
      <th>Patch site</th>
      <th>Notes</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td><code class="language-plaintext highlighter-rouge">sub_100013A9C</code> (<code class="language-plaintext highlighter-rouge">sub sp, #0x50</code>)</td>
      <td><code class="language-plaintext highlighter-rouge">0x100013C40</code> <code class="language-plaintext highlighter-rouge">ldr x3, [x8, #0x438]</code></td>
      <td>value lands in <code class="language-plaintext highlighter-rouge">x3</code> for a call to <code class="language-plaintext highlighter-rouge">sub_100004864</code> (5-arg setter pattern, <code class="language-plaintext highlighter-rouge">w4=1</code>)</td>
    </tr>
    <tr>
      <td>2</td>
      <td><code class="language-plaintext highlighter-rouge">sub_10001D004</code> (<code class="language-plaintext highlighter-rouge">sub sp, #0xD0</code>)</td>
      <td><code class="language-plaintext highlighter-rouge">0x10001D2E4</code> <code class="language-plaintext highlighter-rouge">ldr x0, [x8, #0x438]</code></td>
      <td>then <code class="language-plaintext highlighter-rouge">bl 0x1000…</code> <code class="language-plaintext highlighter-rouge">objc_retain</code>-style helper into <code class="language-plaintext highlighter-rouge">x16</code>-ish flow, value passed onward</td>
    </tr>
    <tr>
      <td>3</td>
      <td><code class="language-plaintext highlighter-rouge">sub_10002DF58</code> (<code class="language-plaintext highlighter-rouge">sub sp, #0x60</code>)</td>
      <td><code class="language-plaintext highlighter-rouge">0x10002DFFC</code> <code class="language-plaintext highlighter-rouge">ldr x0, [x8, #0x438]</code></td>
      <td>on the <code class="language-plaintext highlighter-rouge">cbnz x20, 0x10002E014</code> fallback (dark-mode / null result path), pairs with <code class="language-plaintext highlighter-rouge">x21 = [x8, #0x590]</code> (“Activated”-adjacent key) at <code class="language-plaintext highlighter-rouge">0x10002DFB4</code></td>
    </tr>
    <tr>
      <td>4</td>
      <td><code class="language-plaintext highlighter-rouge">sub_10002FBF4</code> (<code class="language-plaintext highlighter-rouge">sub sp, #0x70</code>)</td>
      <td><code class="language-plaintext highlighter-rouge">0x10002FF20</code> <code class="language-plaintext highlighter-rouge">ldr x23, [x8, #0x438]</code></td>
      <td>block invoked from <code class="language-plaintext highlighter-rouge">-[MobileActivationDaemon createActivationInfoWithCompletionBlock:]</code>; this is the activation-info reply path</td>
    </tr>
    <tr>
      <td>5</td>
      <td><code class="language-plaintext highlighter-rouge">sub_100030234</code> (<code class="language-plaintext highlighter-rouge">sub sp, #0x90</code>)</td>
      <td><code class="language-plaintext highlighter-rouge">0x1000302FC</code> <code class="language-plaintext highlighter-rouge">ldr x0, [x8, #0x438]</code></td>
      <td>after <code class="language-plaintext highlighter-rouge">-[x0 isEntitled:]</code> gate and <code class="language-plaintext highlighter-rouge">-[x0 dark]</code>; falls through toward <code class="language-plaintext highlighter-rouge">0x1000303C4</code> on entitlement failure</td>
    </tr>
  </tbody>
</table>

<p>Site 4 is the money shot: <code class="language-plaintext highlighter-rouge">createActivationInfoWithCompletionBlock:</code> is the XPC method clients (lockdownd, the setup flow) call to fetch activation info. With the patch, the <code class="language-plaintext highlighter-rouge">"what state am I in?"</code> answer it hands back is <code class="language-plaintext highlighter-rouge">FactoryActivated</code>.</p>

<h1 id="notes">Notes</h1>

<ul>
  <li>The five <code class="language-plaintext highlighter-rouge">ldr</code> sites are marked by <code class="language-plaintext highlighter-rouge">adrp x8, 0x10020D000</code> + <code class="language-plaintext highlighter-rouge">nop</code> + <code class="language-plaintext highlighter-rouge">ldr</code>. In IDA, look up <code class="language-plaintext highlighter-rouge">0x10020D438</code> as a qword array of CFString pointers; cross-refs will show you every consumer, patched or not.</li>
  <li>The <code class="language-plaintext highlighter-rouge">nop</code> between <code class="language-plaintext highlighter-rouge">adrp</code> and <code class="language-plaintext highlighter-rouge">ldr</code> appears to be an artifact of whatever re-sign/repack tool touched the binary; it’s present in both binaries at the patched sites, and the patched immediate is the only real change.</li>
  <li>Comparing stock vs. patched is best done with <code class="language-plaintext highlighter-rouge">cmp -l</code> first: 95 bytes means you can eyeball the whole delta in a minute and spend your IDA time only on the five code sites.</li>
</ul>

<h1 id="epilogue">Epilogue</h1>

<h1 id="epilogue-1">Epilogue</h1>

<p>What I like about this bypass is how little it changes. There is no injected dylib, no hooked function, no long exploit chain. The patcher finds five places where the daemon loads the string <code class="language-plaintext highlighter-rouge">"Unactivated"</code> and points them at <code class="language-plaintext highlighter-rouge">"FactoryActivated"</code> instead.</p>

<p>The phone boots to the home screen. I could open the camera and take pictures. For an old iPhone 6 on iOS 12.5.8, that was exactly what I needed.</p>

<p>Still, as a learning exercise it was hard to beat. It worked on my device and it gave me a much better understanding of how activation state is passed around inside iOS.</p>

<p>Thank you for reading! - Twili8t</p>]]></content><author><name>twili8t</name></author><summary type="html"><![CDATA[What are iCloud locks, why should I care, how to remove them, is human nature materialistically dialectical?]]></summary></entry><entry><title type="html">Hello world</title><link href="https://twili8.github.io/2026/09/17/hello-world.html" rel="alternate" type="text/html" title="Hello world" /><published>2026-09-17T12:00:00+00:00</published><updated>2026-09-17T12:00:00+00:00</updated><id>https://twili8.github.io/2026/09/17/hello-world</id><content type="html" xml:base="https://twili8.github.io/2026/09/17/hello-world.html"><![CDATA[<p>hello world</p>]]></content><author><name>twili8t</name></author><summary type="html"><![CDATA[hello world]]></summary></entry></feed>