Sign in

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.

SectionAnswers
Payload-to-parent verdict promotionhow a child's evidence changes the uploaded file's verdict, and what is barred from crossing
Container extraction vs packer unpackingwhy the two halves have opposite failure requirements
Worked example: Zyxel firmware extractionwhat one real image yields, what it declines to open, and where it does the same work twice
The extraction pipelinecapability routing, format identification, budgets, path safety, rescan rules
The UPX unpackerhow the block chain is located and validated against decoy headers
Known limits, promotion misses, out of scopewhich 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:

SampleParent aloneRecovered payloadParent after promotionWhat crossed
31b6adb6 KeRangerSuspiciousMaliciousMaliciouschild tier
4cd7c5eeSuspiciousMaliciousMaliciouschild tier, 24 C2 rows, 7 capabilities
6ed282e6LikelyMaliciousMaliciousMaliciouschild tier
b117f042SuspiciousMaliciousMaliciouschild tier, 14 proven ledger rows, 38 capabilities
d7cd18d3SuspiciousMaliciousMaliciouschild tier, 1 proven row (TCC bypass), 3 capabilities
ee947ac9SuspiciousMaliciousMaliciouschild tier
78318061SuspiciousLikelyMaliciousMalicious1 C2 row, 1 capability, accumulation — 81 promoted points
ee07dfd6SuspiciousSuspiciousMalicious18 C2 rows, 3 capabilities — 864 promoted points, on a child that never reached a malicious tier itself
3f12fd85SuspiciousLikelyMaliciousLikelyMaliciousaccumulation only, 18 points
8fc36d2dSuspiciousLikelyMaliciousLikelyMalicious1 capability, accumulation — 36 points
d263d4cfBenignLikelyMaliciousSuspiciousaccumulation only, 18 points, on a 41,516-byte payload
f2c974de MabouiaSuspiciousSuspiciousSuspiciousnothing — see known promotion misses

Three rules govern what may cross:

Child evidenceFloor it setsPoints
Child verdict MaliciousDefinitive80
Proven or Definitive ledger row on the childthat row's class45 or 80, by class
Classified C2 indicatorProven45
Malicious capability, throwaway-gTLD host, or a child that only accumulated to LikelyMaliciousnone18 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.

EndpointFloors the parentWhyMeasured across the 12 payloads
Standards and schema host, code-forge doc link, package-registry or project homepagenodenylist, checked before any positive signalsuppressed golang.org, github.com/…/wiki, pkg.go.dev
Public routable IP literalyespositive signal40 flooring rows across 3 payloads — the dominant false-positive source, below
Known external-IP-discovery beacon hostyespositive signal3 rows on one payload (icanhazip.com, myexternalip.com, checkip.amazonaws.com)
Dynamic-DNS hostyespositive signal0
.onion addressyespositive signal0 — 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, .gano, but 18 pointsdeliberate: abc.xyz is Alphabet. Not inert — it is a scoring Communication row, and the one row the mangled-UPX lever can lift to a floor0
Reserved or private IP literalno127.0.0.1 and 192.168.1.1 are what a benign installer embedssuppressed 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.

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.

ConsequenceCost on this image
File entries in the ledger3,391 written, over 1,704 distinct output paths — every squashfs file is recorded twice
Bytes attributed to squashfs217,338,799 across both walks — the second pass re-emits what the first already produced
Symlink rejections764, of which 382 are the second pass repeating the first
Downstream analysis1,085 ELF scans over 545 distinct ELF binaries on disk
Diskno 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 rowCountDetail
Archive entries rejected as symlinks764refused before any path was resolved
Corrupt streams6
Unsupported entry kind5filesystem metadata pseudo-entries
Identified but not descended into2,437the four rows below
— re-sighting of an already-walked container1,765abandoned as cycles
— no extractor for the format501
— speculative interior probe that failed to decode106a short magic matched mid-file and the decoder rejected it; nothing emitted
— already extracted at this path65

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.

CapabilityMeaningExamples
Extractabledescend — hand it to an extractor and rescan the outputssquashfs, tar, gzip, UPX
Analyzablestop unpacking, hand it to the analysis engineELF, PE, UEFI TE
Inertleaf content — neither analyzed nor extractedPNG, JPEG, other media
Markerin-binary evidence pattern, not a containerAES 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.

CountWhat it countsHow it relates to the row above
170format identities in the closed enumthe full closed set
161of those identities backed by signature rowseach with a per-format validator and two confidence tiers — magic-only versus header-validated
111formats the vendored binwalk baseline identifiesthe external comparison point
72of those 111 that binwalk extractsall 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
2binwalk magic rows with no counterpart identity heredeb, 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.

BudgetCeilingWhat a lower ceiling costs
Per extracted file4 GiBa 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 invocation16 GiB
File count1,000,000a 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 depthtop level plus 3 layers
Decompression ratioan output-to-input ratio above 1024×, once a stream has emitted 16 MiB, fails the streamthis 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.

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 readsWhat it really is
versionmethod
formatlevel
the original file sizethe 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:

SampleSection namesp_filesize the parser reads
000efde7UPX0 UPX1901,648,312
000502f0UPX0 UPX11,034,280,659
0004058a, 00067257UPX0 UPX11,871,152,573
00069fdfUPX0 UPX12,249,292,901
00054622UPX0 UPX12,361,453,760
00005694UPX0 UPX1 UPX23,666,723,466
0008619eUPX0 UPX14,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.

MethodCorpus samplesStatus
NRV2B6validated byte-exact against real corpus blocks
NRV2E5validated byte-exact against real corpus blocks
LZMA1validated against the single fat Mach-O sample that uses it, on both slices
NRV2D0implemented, no fixture and no corpus sample
deflate0implemented, 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

Encrypted containers

Each is identified and its unencrypted metadata parsed; decryption happens only where a symmetric key is already in hand.

Encrypted inputRead with no keyDecrypted
Apple Archive containerheader and key/value metadataonly on the symmetric path, from a supplied key
Apple OTA cryptexheader and key/value metadataonly 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 profileparsed and reportednever — the passphrase-derived key is not attacked
LUKS blob, DPAPI blobidentified and nothing morenever
OpenSSL Salted__ blobtwo entries: the whole input verbatim, plus its 8-byte salt. The pair overlaps rather than partitions — the ciphertext is never split outnever — 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.

Everything downstream assumes this ran.