Unpacking
The KeRanger ransomware sample in the corpus is a 139,264-byte Mach-O named General.rtf. Analyzed as delivered it has zero imports, zero exports, two sections (__text at 136,611 bytes and a zero-length __xhdr), no capabilities, and one URL — the packer's own advertisement:
$ strings -a -n 6 sample | wc -l
377
$ strings -a sample | grep -i http
HTTP/1.0
$Info: This file is packed with the UPX executable packer http://upx.sf.net $
Static unpacking turns those 139,264 bytes into 304,416 bytes of original Mach-O. The same file, after:
$ strings -a keranger.bin | grep -iE 'onion|README_FOR' | sort -u
bmacyzmea723xyaz.onion.link
bmacyzmea723xyaz.onion.nu
lclebb6kvohlkcml.onion.link
lclebb6kvohlkcml.onion.nu
nejdtkok7oz5kjoc.onion.link
nejdtkok7oz5kjoc.onion.nu
README_FOR_DECRYPT.txt
Three Tor gateway hosts and a ransom-note filename, none of which exist as bytes in the file that was uploaded. Everything an analysis produces about a wrapped input is a statement about the wrapper until the wrapper comes off.
This layer takes bytes apart statically — no emulation, no detonation — and re-analyzes what falls out as if it had been uploaded directly.
| Section | Answers |
|---|---|
| Payload-to-parent verdict promotion | how a child's evidence changes the uploaded file's verdict, and what is barred from crossing |
| Container extraction vs packer unpacking | why the two halves have opposite failure requirements |
| Worked example: Zyxel firmware extraction | what one real image yields, what it declines to open, and where it does the same work twice |
| The extraction pipeline | capability routing, format identification, budgets, path safety, rescan rules |
| The UPX unpacker | how the block chain is located and validated against decoy headers |
| Known limits, promotion misses, out of scope | which formats and methods are unsupported or unconfirmed, and what is never attempted |
Payload-to-parent verdict promotion
A packed sample's verdict is computed from its payload, not its wrapper: child evidence crosses to the parent through a closed curated table, and can only raise the parent's tier. Measured across the 12 UPX-packed Mach-O samples in the corpus, parent and payload each scored by the shipped analyzer and folded through the promotion table, 11 of 12 parents move:
| Sample | Parent alone | Recovered payload | Parent after promotion | What crossed |
|---|---|---|---|---|
31b6adb6 KeRanger | Suspicious | Malicious | Malicious | child tier |
4cd7c5ee | Suspicious | Malicious | Malicious | child tier, 24 C2 rows, 7 capabilities |
6ed282e6 | LikelyMalicious | Malicious | Malicious | child tier |
b117f042 | Suspicious | Malicious | Malicious | child tier, 14 proven ledger rows, 38 capabilities |
d7cd18d3 | Suspicious | Malicious | Malicious | child tier, 1 proven row (TCC bypass), 3 capabilities |
ee947ac9 | Suspicious | Malicious | Malicious | child tier |
78318061 | Suspicious | LikelyMalicious | Malicious | 1 C2 row, 1 capability, accumulation — 81 promoted points |
ee07dfd6 | Suspicious | Suspicious | Malicious | 18 C2 rows, 3 capabilities — 864 promoted points, on a child that never reached a malicious tier itself |
3f12fd85 | Suspicious | LikelyMalicious | LikelyMalicious | accumulation only, 18 points |
8fc36d2d | Suspicious | LikelyMalicious | LikelyMalicious | 1 capability, accumulation — 36 points |
d263d4cf | Benign | LikelyMalicious | Suspicious | accumulation only, 18 points, on a 41,516-byte payload |
f2c974de Mabouia | Suspicious | Suspicious | Suspicious | nothing — see known promotion misses |
Three rules govern what may cross:
- Packing is never itself promoted. A benign UPX-compressed installer whose payload is an ordinary program produces no promoted rows. Being packed is not evidence of malice.
- Only floor-bearing child evidence sets a floor. Weaker child signals score points and set none.
- Indicators are gated on classification, not presence. Fusion is a
maxover the parent's own tier, the floor, and the point total against the 15 / 35 / 70 tier boundaries, so payload evidence can raise a parent and never lower it, and the child's evidence class survives the crossing — an emulation-derived heuristic does not become a deductive fact by being promoted.
| Child evidence | Floor it sets | Points |
|---|---|---|
| Child verdict Malicious | Definitive | 80 |
| Proven or Definitive ledger row on the child | that row's class | 45 or 80, by class |
| Classified C2 indicator | Proven | 45 |
| Malicious capability, throwaway-gTLD host, or a child that only accumulated to LikelyMalicious | none | 18 each |
One corroboration lever crosses that line in code: the mangled-UPX evasion tell may combine with an otherwise non-flooring Suggestive child signal to reach a floor. Stock UPX never contributes to one, and the mangled variant has never fired on the corpus — see mangled UPX! magic.
C2 endpoint classification
An endpoint in a payload floors the parent only if it is positively classified, and a benign denylist is consulted first. The same Go payloads that carry a real beacon also litter their string tables with module-path fragments (eq.github.com, 0github.com, ip6.int), XML namespace URIs (http://www.w3.org/XML/1998/namespace), SOAP schema URLs, and library homepages. A rule reading "hardcoded endpoint means C2" promotes all of it.
| Endpoint | Floors the parent | Why | Measured across the 12 payloads |
|---|---|---|---|
| Standards and schema host, code-forge doc link, package-registry or project homepage | no | denylist, checked before any positive signal | suppressed golang.org, github.com/…/wiki, pkg.go.dev |
| Public routable IP literal | yes | positive signal | 40 flooring rows across 3 payloads — the dominant false-positive source, below |
| Known external-IP-discovery beacon host | yes | positive signal | 3 rows on one payload (icanhazip.com, myexternalip.com, checkip.amazonaws.com) |
| Dynamic-DNS host | yes | positive signal | 0 |
.onion address | yes | positive signal | 0 — the check is a literal .onion suffix, so the Tor2Web gateway forms …onion.link and …onion.nu do not match it |
Throwaway gTLD — .top, .xyz, .gq, .tk, .ml, .cf, .ga | no, but 18 points | deliberate: abc.xyz is Alphabet. Not inert — it is a scoring Communication row, and the one row the mangled-UPX lever can lift to a floor | 0 |
| Reserved or private IP literal | no | 127.0.0.1 and 192.168.1.1 are what a benign installer embeds | suppressed 127.0.0.1, ::1, :: |
The IP-literal arm is the one that false-fires. Its public-routable test excludes loopback, RFC 1918, link-local, unspecified, broadcast, multicast, 0.0.0.0/8 and CGNAT and nothing else, so the RFC 5737 documentation ranges (192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24) are floor-eligible. The measured damage is Go string-table byte noise that happens to parse as a dotted quad: 4cd7c5ee yields 24 flooring rows including 5.4.32.5, 4.52.5.4, 62.5.4.72, 4.14.2.1 and ::20, and ee07dfd6 yields 18 of the same shape and is floored to Malicious on them despite a Suspicious child. Genuine addresses are the minority — public resolvers (1.1.1.1), libp2p bootstrap nodes (104.131.131.82, 147.75.70.221), and exactly one plausible real beacon, 46.137.201.254 on 78318061.
Container extraction vs packer unpacking
The two halves of this layer have opposite failure requirements, and the same code cannot satisfy both.
- Container extraction — a squashfs, a tar, a Zyxel firmware carrier — is inventory work. Recall is what matters and a declared skip is an acceptable outcome, because the reader can see the hole.
- Packer unpacking is adversarial. The wrapper exists to defeat analysis, so a wrong decode must produce nothing rather than plausible-wrong bytes. Every decoder here is gated on the same check: the first decompressed block must be exactly its declared length and must begin with a valid executable magic.
Worked example: Zyxel firmware extraction
The Zyxel VMG8825-T50K firmware image is 26,758,973 bytes, and its first 32 bytes are a proprietary carrier rather than any recognizable filesystem:
$ xxd -l 32 V550ABOM8.1C0.bin
00000000: 3252 4448 0000 0174 0198 4f3d f3b0 5a38 2RDH...t..O=..Z8
^^^^^^^^^ ^^^^^^^^^ ^^^^^^^^^ ^^^^^^^^^
"2RDH" header image len crc32
= 372 B 0x01984f3d
00000010: 372e 332e 3234 352e 3330 305f 7630 3037 7.3.245.300_v007
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
build version string
2RDH is a proprietary Zyxel carrier: a 372-byte (0x174) header over an image whose declared length, 0x01984F3D, is the file's own size. Strip the header and re-identify what is left; keep going until nothing identifies. The run writes 3,391 file entries, skips 775, and identifies 2,437 more without descending:
V550ABOM8.1C0.bin 26,758,973 B
├─ lzma @ 0x174 → d0_00000174.bin 6,354,880 B
│ └─ Linux kernel 3.18.21 4 files
├─ squashfs @ 0x204F39 1,678 files ← raw-image walk
│ ├─ bin/busybox, bin/cfg, sbin/… usr/sbin/smbd is 7,868,132 B
│ ├─ ar → 7 files · csman → 3 · png/jpeg/bmp → 12
│ └─ (a .gz entry)
│ └─ gzip → 204,800 B and 153,600 B
│ └─ tar → zcfg_config.json 143,115 B
│ zcfg_predef.json 53,512 B
│ zcfg_schema.bin 151,552 B
└─ zyxel2_rdh (372-byte header stripped)
└─ d0_00000000.2rdh.payload 26,758,601 B
└─ squashfs 1,678 files ← the SAME filesystem again
Maximum depth reached: 2.
The duplicated squashfs walk
The squashfs is extracted twice, and cycle detection does not catch it. The superblock at 0x204F39 is found once by scanning the raw image and once by scanning the 2RDH payload after the 372-byte header strip — two byte ranges holding the same filesystem, so the cycle check sees two distinct sources. Both walks emit the same 1,678 relative paths.
| Consequence | Cost on this image |
|---|---|
| File entries in the ledger | 3,391 written, over 1,704 distinct output paths — every squashfs file is recorded twice |
| Bytes attributed to squashfs | 217,338,799 across both walks — the second pass re-emits what the first already produced |
| Symlink rejections | 764, of which 382 are the second pass repeating the first |
| Downstream analysis | 1,085 ELF scans over 545 distinct ELF binaries on disk |
| Disk | no doubling — the second walk overwrites the first at the same path, byte-identically |
The skip ledger
The same run records what did not come out, because recognizing a container and declining to open it is a different outcome from never seeing it, and an image with three silently undescended containers otherwise looks identical to a fully covered one.
| Ledger row | Count | Detail |
|---|---|---|
| Archive entries rejected as symlinks | 764 | refused before any path was resolved |
| Corrupt streams | 6 | |
| Unsupported entry kind | 5 | filesystem metadata pseudo-entries |
| Identified but not descended into | 2,437 | the four rows below |
| — re-sighting of an already-walked container | 1,765 | abandoned as cycles |
| — no extractor for the format | 501 | |
| — speculative interior probe that failed to decode | 106 | a short magic matched mid-file and the decoder rejected it; nothing emitted |
| — already extracted at this path | 65 |
Six reasons are carried through to the readout: encrypted, unsupported compression, no extractor, depth exceeded, budget truncated, and cycle. Rejections that are hygiene rather than coverage loss — symlinks, path traversal, failed speculative probes, re-extractions — are deliberately excluded from that rollup.
Low-confidence magic hits are not in the ledger at all: a match the validator could not confirm is dropped without a row unless a diagnostic flag is set, because in multi-MiB binary scans they are nearly all false positives — a 143 MiB ext2 image in another fixture produced 3,402 spurious JFFS2 magic hits alone. A production readout of this image contains zero low-confidence rows.
Panics in the vendored squashfs reader
The squashfs reader panics on unimplemented metadata cases 21,438 times during this run. Every one is caught at the extractor boundary; the process exits 0, produces the full SBOM, and books 6 of the failures as corrupt_stream skips. The zero-crash posture is catch-and-degrade, not a complete reader.
The extraction pipeline
Extraction runs to fixpoint: identify the format, decide what kind of thing it is, extract under budget through a sanitized output path, rescan every output, and identify again until nothing identifies.
Capability routing
One four-valued property on each format identity decides what happens next, and it is the only fork in the pipeline.
| Capability | Meaning | Examples |
|---|---|---|
| Extractable | descend — hand it to an extractor and rescan the outputs | squashfs, tar, gzip, UPX |
| Analyzable | stop unpacking, hand it to the analysis engine | ELF, PE, UEFI TE |
| Inert | leaf content — neither analyzed nor extracted | PNG, JPEG, other media |
| Marker | in-binary evidence pattern, not a container | AES S-box, CRC32 table, RSA blob |
Format identification
Identification is a single authority: a closed enum of format identities, with the unpack side mapping each identity to an extractor through one exhaustive match, so adding an identity fails to compile until the extractor question is answered explicitly. No format falls through by omission.
| Count | What it counts | How it relates to the row above |
|---|---|---|
| 170 | format identities in the closed enum | the full closed set |
| 161 | of those identities backed by signature rows | each with a per-format validator and two confidence tiers — magic-only versus header-validated |
| 111 | formats the vendored binwalk baseline identifies | the external comparison point |
| 72 | of those 111 that binwalk extracts | all 72 extract here too, once four binwalk aliases are resolved — ext/ext2, tarball/tar, uefi_pi_volume/uefi_ffs, yaffs/yaffs2; an unaliased name-for-name intersection scores 68/72 |
| 2 | binwalk magic rows with no counterpart identity here | deb, which arrives through its ar outer container anyway, and binhdr |
The per-format catalog is in Architectures.
Budgets
Ceilings are set where real inputs live, because a budget set too low does not error — it truncates, and truncation looks like a small firmware image. These are per-invocation defaults, not constants: a caller overrides them per run, and the server raises the file count and per-file byte budget for .ipsw uploads while untrusted uploads keep the defaults.
| Budget | Ceiling | What a lower ceiling costs |
|---|---|---|
| Per extracted file | 4 GiB | a 256 MiB cap downgrades an IPFire x86 image — a 480 MiB .img.xz expanding to a 3.36 GiB raw image, a benign 7× ratio — to raw carving |
| Total per invocation | 16 GiB | |
| File count | 1,000,000 | a 100,000-file ceiling silently loses the remainder of the walk on a single Apple iOS SystemOS volume, which holds more than 180,000 regular files |
| Nesting depth | top level plus 3 layers | |
| Decompression ratio | an output-to-input ratio above 1024×, once a stream has emitted 16 MiB, fails the stream | this ratio guard, not any absolute cap, is the actual bomb defense |
Path sanitization
Every output path passes one chokepoint that rejects absolute paths, .. components after normalization, NUL bytes, zero-length entries, and components longer than 255 bytes, then asserts the resolved path still starts with the output root. canonicalize is deliberately not used: the target does not exist yet, and even if it did, an attacker could rewrite the path between resolve and create. Symlink-typed archive entries are refused the moment the archive emits them, before any path resolution — the 764 rejections in the Zyxel image above are that rule firing.
Rescan heads
An extracted file is rescanned for embedded formats over its first 16 MiB only — a separate limit from the 4 GiB cap on what may be written — except when the file is itself a filesystem, disk image, archive, or compression carrier, in which case it is rescanned whole. Without that exemption a 40 MiB rootfs.squashfs inside a vendor zip lands on disk intact and its nested walk sees nothing, because the superblock content it needs sits past the 16 MiB head. The exemption is derived from the extractor dispatch rather than an allowlist, so a newly added filesystem or carrier is exempt by construction.
Streams never routed by signature
Four stream formats are deliberately unreachable from a scan hit, because their magics carry too little information to trust.
- Raw zlib and
compress(1)have signature rows but map to no extractor: their two-byte magics false-fire too heavily for a scan hit to be trusted, so the hit is dropped and their extractors are reachable only by name from carrier-aware code paths. - Raw LZMA1 and raw xz — an LZMA2 stream without container framing — have no identity row at all, because a headerless stream offers nothing to match on. Only the framed
.xzand.lzmacontainers are discovered by scanning.
The UPX unpacker
UPX gets a real decoder rather than a heuristic — a deterministic static implementation of what upx -d does, with no emulation and no execution of the stub. Twenty corpus samples carry a real UPX layout: 12 Mach-O and 8 Windows PE. All 12 Mach-O samples unpack; none of the 8 PEs do.
A UPX-packed Unix executable prepends a self-decompressing stub and stores the original image as a chain of compressed blocks anchored by an l_info record:
l_info (12B) u32 checksum | "UPX!" | u16 lsize | u8 version | u8 format
p_info (12B) u32 progid | u32 filesize | u32 blocksize
b_info (12B) u32 sz_unc | u32 sz_cpr | u8 method | u8 ftid | u8 cto8 | u8 _
[sz_cpr compressed bytes] … repeated per block …
Advancing by 12 + sz_cpr walks the chain. A block with sz_cpr == sz_unc is stored verbatim. The chain ends at a 0xffffffff terminator, at an implausible b_info, or when the total reaches filesize.
Windows PE UPX is unsupported
A UPX-packed PE is neither unpacked nor even identified as UPX. The parser accepts any executable magic and reads no Mach-O structure, but its 36-byte fixed pre-payload span hard-codes the Unix l_info + p_info + b_info layout above, and the same assumption sits in the identifier's validator. The single UPX! in each of the eight corpus PEs is a pack-header record instead: after the magic come version, format, method, level as four single bytes, then two Adler-32 checksums, then the lengths. Read as l_info, three fields land wrong:
| Field the parser reads | What it really is |
|---|---|
version | method |
format | level |
| the original file size | the compressed stream's Adler-32 checksum |
A checksum read as a size lands above the 512 MiB plausibility ceiling in all eight, so no candidate validates:
| Sample | Section names | p_filesize the parser reads |
|---|---|---|
000efde7 | UPX0 UPX1 | 901,648,312 |
000502f0 | UPX0 UPX1 | 1,034,280,659 |
0004058a, 00067257 | UPX0 UPX1 | 1,871,152,573 |
00069fdf | UPX0 UPX1 | 2,249,292,901 |
00054622 | UPX0 UPX1 | 2,361,453,760 |
00005694 | UPX0 UPX1 UPX2 | 3,666,723,466 |
0008619e | UPX0 UPX1 | 4,099,688,964 |
Failing closed rather than emitting wrong bytes is the design intent, but the PE path is a hole, not an untested branch.
Decoy UPX! records
The unpacker takes no UPX! on faith — which record you believe is the part that decides whether this survives real samples. It enumerates every occurrence and accepts the first candidate whose first block decompresses to exactly its declared sz_unc and starts with a Mach-O, ELF, PE, or fat magic. Wrong candidates produce nothing.
The KeRanger sample has four:
UPX! at 708 version=13 format=34 filesize=304,416 method=2 (NRV2B)
UPX! at 135157 version=0 format=0 filesize=3,892,314,637
UPX! at 135996 version=47 format=125 filesize=4,135,912,683
UPX! at 136875 version=2 format=9 filesize=2,165,397,699
The record at 708 sits at the head of __text, whose 136,611 bytes of compressed blocks run to offset 137,315 — so the other three matches lie inside compressed data, and each claims a multi-gigabyte image. Other samples invert the order — three corpus samples share an identical decoy as their first UPX!, at offset 2922 claiming a 259 MB image, with the real record second at 4100:
d263d4cf… UPX! at 2922 version=72 format=139 filesize=259,326,008 ← decoy, shared verbatim
UPX! at 4100 version=13 format=34 filesize=41,516 ← the real one
UPX! at 16498 version=0 format=0 filesize=0 ← packheader
UPX! at 21056 version=2 format=8 filesize=3,111,824,656 ← chance match, 40 B from EOF
Position is therefore no guide — the real record is first in one sample and second in the other three — and only the decompress-and-validate test separates them.
Decoder safety
Every read is bounds-checked. A hostile filesize or sz_unc trips the byte budget before allocation, with an independent 512 MiB plausibility guard behind it. That guard is a capability limit as well as a bomb defense: a legitimately larger UPX payload is rejected before any decode, as is any real record whose version or format byte happens to be zero. A malformed block returns a typed corruption error, and a panic anywhere in the decoder is caught rather than taking the process with it.
Recovered images
Across the corpus's 12 UPX-packed Mach-O samples this recovers 41,561,288 bytes, each recovered image beginning with a valid Mach-O magic and being exactly its declared filesize. The recovered image is emitted as a child, re-identified, and analyzed like any direct upload — imports, strings, capabilities, call graph.
Fan-out on a clean packed sample is not zero. On KeRanger the run emits a second child alongside the 304,416-byte payload f8b89c6c: the arcadyan vendor de-obfuscation carrier firing speculatively inside the recovered image, producing 33,565 bytes of data.
Known limits of the UPX unpacker
Method support is uneven and the gap is corpus-shaped: two methods are implemented with nothing to confirm them against. Counts are over the 12 Mach-O samples — the 8 PE samples never reach a decoder.
| Method | Corpus samples | Status |
|---|---|---|
| NRV2B | 6 | validated byte-exact against real corpus blocks |
| NRV2E | 5 | validated byte-exact against real corpus blocks |
| LZMA | 1 | validated against the single fat Mach-O sample that uses it, on both slices |
| NRV2D | 0 | implemented, no fixture and no corpus sample |
| deflate | 0 | implemented, no fixture and no corpus sample |
NRV2D and deflate are gated by the same decompress-to-valid-magic check, so a wrong decode emits nothing rather than garbage, but nothing has confirmed they are right.
Mangled UPX! magic
The samples flagged for defeating upx -d are precisely the ones this cannot unpack, because the unpacker locates the block chain by searching for a literal UPX!. Commodity Linux malware routinely zeroes exactly that magic and renames the UPX0/UPX1 sections so that upx -d and signature scanners fail while the layout still runs.
A separate detector recognizes the packed layout from the ELF program headers after both surface markers are gone — two PT_LOAD segments, an RWE second segment, the entry point inside the stub — but never asserts UPX on that shape alone, because plenty of static ELFs share it; a surviving loader-stub byte remnant is required first. When both markers are absent and the remnant is there, the deliberate defeat of upx -d is the evasion tell, and that is the one signal permitted to corroborate a weak child signal into a floor.
Nothing in the corpus exercises it: the detector abstains on any input that is not an ELF, and the corpus's single ELF sample carries no UPX! magic and no stub remnant, so it produces no packer row at all. The corroboration lever — the only route by which a parent's packing signal reaches a tier — is verified in code with zero measured instances.
Known promotion misses
One corpus parent is unmoved by its payload, and the reason is a missing capability rather than a missing rule. The recovered Mabouia payload contains its command-and-control host in plain text:
Host: creativecode.com.br
POST /mabouia/catcher.php HTTP/1.1
Access http://creativecode.com.br/mabouia for detailed instructions.
Both endpoints are captured. Neither is an IP literal, .onion, a known IP-echo or dynamic-DNS host, or a throwaway gTLD, so both classify as unknown and neither floors; the payload's own tier is Suspicious, so no tier crosses either. The parent stays Suspicious.
The string classifier is not the only door. An endpoint promotes as C2 regardless of its intrinsic string class when a scoring-eligible, non-library Communication capability of the child names that same text — the child's own analysis is trusted over the string heuristic. Mabouia's payload fires no Communication capability at all, so there is nothing to tie its endpoint to; four of the 12 payloads do fire them, at 38, 13, 3 and 1 capabilities.
Widening the string rule instead is not the answer. The measured alternative — promote any hardcoded non-boilerplate endpoint — false-fires heavily on precisely the same Go payloads, where the "unclassified endpoints" are module-path fragments and reflection strings, and the IP-literal arm already demonstrates the failure mode at 40 flooring rows to 1 plausible beacon. The correct fix is family-aware structured config extraction, which knows what a given family's configuration looks like, rather than a generic endpoint heuristic.
Out of scope
- No dynamic unpacking. No emulation of packer stubs, no sandbox detonation. A packer with no static unpacker stays a declared skip plus an entropy and packing signal. Emulator covers the bounded interpreter that handles in-code obfuscation instead.
- No decryption without keys. Encrypted containers are parsed and reported; only a supplied symmetric key decrypts, and no passphrase or cipher is ever guessed.
- Nothing speculative is ever emitted. Decoders are invoked speculatively on weak interior magics — 106 such probes on the Zyxel image — but a probe that cannot validate its header emits nothing and is booked as a failed probe. The confidence tiers exist so a consumer can tell a validated container from a magic-byte coincidence. btrfs, logfs, Seama, eCos, Wind River kernel images and Inno Setup installers have identities and signature rows but no extractor behind them, and are reported as identified rather than carved.
Encrypted containers
Each is identified and its unencrypted metadata parsed; decryption happens only where a symmetric key is already in hand.
| Encrypted input | Read with no key | Decrypted |
|---|---|---|
| Apple Archive container | header and key/value metadata | only on the symmetric path, from a supplied key |
| Apple OTA cryptex | header and key/value metadata | only from a recipient key fetched through a single host allowlist and HPKE-unwrapped; opt-in, and off by default the container degrades to metadata-only with zero network calls |
| Apple Archive password profile | parsed and reported | never — the passphrase-derived key is not attacked |
| LUKS blob, DPAPI blob | identified and nothing more | never |
OpenSSL Salted__ blob | two entries: the whole input verbatim, plus its 8-byte salt. The pair overlaps rather than partitions — the ciphertext is never split out | never — no cipher is guessed and no passphrase is attacked |
A wrong key never yields plaintext presented as success, but the guarantee is per-segment, not per-file. The large-input path verifies each segment's MAC and checksum and writes that segment's plaintext the instant it passes, so a key failing on segment 40 has already written 39; the caller must discard everything written on any error, as the IPSW path does by dropping its temporary file.
Related briefs
Everything downstream assumes this ran.
- CVEs & SBOM — inventories the extracted tree.
- Engine — analyzes every extracted executable.
- Malware — fuses payload verdicts upward.
- Outputs — package readouts render the extraction ledger, including what was skipped and why.