Binary anatomy
/bin/ls on an Apple Silicon Mac is 154,624 bytes. The machine instructions in it come to 30,363 — 19.6% of the file, spread across two architecture slices:
$ size -m -arch arm64e /bin/ls
Segment __PAGEZERO: 4294967296 (zero fill)
Segment __TEXT: 32768
Section __text: 15268 ← the instructions
Section __auth_stubs: 1344
Section __const: 261
Section __cstring: 1283
Section __unwind_info: 208
total 18364
Segment __DATA_CONST: 16384
Section __auth_got: 672
Section __got: 48
Section __const: 616
total 1336
Segment __DATA: 16384
Section __data: 32
Section __common: 176 (zerofill)
Section __bss: 344 (zerofill)
total 552
Segment __LINKEDIT: 32768 ← symbols, fixups, code signature
total 4295065600
size -m -arch x86_64 /bin/ls reports another 15,095 bytes of __text for the second slice. Four fifths of the file is the structure that tells the loader what to map, what to link, what to trust — plus a __PAGEZERO segment claiming 4 GiB of address space while occupying zero bytes on disk. That structure is what you read when you reverse a binary, and it is what this covers.
The model
A binary is three things: instructions for the CPU, data those instructions use, and metadata telling the loader how to map the first two into memory.
- Compile — source becomes object files: machine code per translation unit, unresolved references, debug records.
- Link — objects merge into one image: sections laid out, addresses assigned, imports recorded, symbol table optionally emitted.
- Load — the OS maps segments, applies relocations, resolves imports, jumps to the entry point.
Reversing runs this backwards, and it works because the loader needs the structure present. Names can be stripped and bytes packed, but the entry point, the segment map, and anything resolved externally must survive. That is the floor of truth in every file.
The map
Before any field, the shape of the whole file. All three formats put a header first, a table describing regions second, and the regions themselves third.
PE — a DOS program from 1985 wrapping a modern executable:
┌────────────────────────┐ 0x00
│ DOS header "MZ" │──── e_lfanew ────┐
│ DOS stub │ │
│ Rich header │ MSVC toolchain │
├────────────────────────┤ ←────────────────┘
│ PE signature "PE\0\0" │
│ COFF header │ machine, #sections, timestamp
│ Optional header │ entry point, image base, hardening
│ └ data directories │──── RVAs to import/export/rsrc/reloc/cert
├────────────────────────┤
│ Section table │ name, RVA, file offset, size, flags
├────────────────────────┤
│ .text .rdata .data │
│ .rsrc .reloc │
├────────────────────────┤
│ certificate table │ Authenticode
│ overlay │ anything appended past here
└────────────────────────┘
ELF — two independent tables over one set of bytes:
┌────────────────────────┐ 0x00
│ ELF header (64 bytes) │──── e_phoff ──┐ e_shoff ────┐
├────────────────────────┤ ←─────────────┘ │
│ Program headers │ LOAD VIEW — what the loader maps
│ PT_LOAD, PT_DYNAMIC │ │
├────────────────────────┤ │
│ .text .rodata .data │ │
│ .bss (no file bytes) │ │
│ .dynamic .got .plt │ │
├────────────────────────┤ ←──────────────────────────────┘
│ Section headers │ LINK VIEW — optional at runtime
└────────────────────────┘
Mach-O — a list of commands rather than a table of sections:
┌────────────────────────┐
│ fat header "cafebabe" │ offsets to each arch slice (universal only)
├────────────────────────┤
│ Mach-O header │ cputype, ncmds, sizeofcmds
├────────────────────────┤
│ Load commands │ LC_SEGMENT_64, LC_MAIN, LC_LOAD_DYLIB,
│ │ LC_UUID, LC_CODE_SIGNATURE …
├────────────────────────┤
│ __PAGEZERO (---) │ 4 GiB of nothing, catches null derefs
│ __TEXT (r-x) │ __text __cstring __unwind_info
│ __DATA (rw-) │ __got __data __bss
├────────────────────────┤
│ __LINKEDIT │ symbol table, chained fixups, signature
└────────────────────────┘
Each format's header chain, in real bytes.
PE. MZ, a DOS stub, and a pointer at offset 0x3c to where the real header starts:
00000000: 4d5a 9000 0300 0000 0400 0000 ffff 0000 MZ..............
00000030: 0000 0000 0000 0000 0000 0000 8000 0000 ................
^^^^^^^^^ e_lfanew = 0x80
00000080: 5045 0000 4c01 0300 af09 6ba6 0000 0000 PE..L.....k.....
^^^^^^^^^ ^^^^ ^^^^ ^^^^^^^^^
"PE\0\0" | | TimeDateStamp
| NumberOfSections = 3
Machine = 0x014c (i386)
00000090: 0000 0000 e000 2200 0b01 3000 0008 0000 ......"...0.....
^^^^ ^^^^ ^^^^
| | OptionalHeader magic 0x010b = PE32
| Characteristics 0x0022
SizeOfOptionalHeader = 0xe0
That TimeDateStamp of 0xa66b09af decodes to the year 2058. It is not corruption — this is a deterministic Roslyn build, where the field is repurposed as part of a content hash. Every build stamp is a claim, and this one isn't even claiming a time.
ELF. A 16-byte identity block, then type, machine, entry point, and the offsets to both tables:
00000000: 7f45 4c46 0201 0100 0000 0000 0000 0000 .ELF............
^^^^^^^^^ ^^ ^^ ^^
magic | | version
| endianness: 01 = little
class: 02 = 64-bit
00000010: 0200 3e00 0100 0000 8014 0001 0000 0000 ..>.............
^^^^ ^^^^ ^^^^^^^^^^^^^^^^^^^
| machine 0x3e e_entry = 0x01001480
e_type = 2 (EXEC)
00000020: 4000 0000 0000 0000 2017 0000 0000 0000 @....... .......
^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^
e_phoff = 0x40 e_shoff = 0x1720
00000030: 0000 0000 4000 3800 0b00 4000 1d00 1b00 ....@.8...@.....
^^^^ ^^^^ ^^^^
| 11 program headers, 29 sections
each 0x38 bytes
Follow e_phoff to 0x40 and the first program header is right there:
00000040: 0600 0000 0400 0000 4000 0000 0000 0000 ........@.......
^^^^^^^^^ ^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^
p_type=6 p_flags=4 p_offset = 0x40
PT_PHDR R
Mach-O. cafebabe is not a Mach-O at all — it is an archive of them. This is the same /bin/ls:
00000000: cafe babe 0000 0002 0100 0007 0000 0003 ................
^^^^^^^^^ ^^^^^^^^^ ^^^^^^^^^
fat magic 2 slices cputype 0x01000007 = x86_64
00000010: 0000 4000 0000 bc00 0000 000e 0100 000c ..@.............
^^^^^^^^^ ^^^^^^^^^ ^^^^^^^^^
offset size cputype 0x0100000c = arm64
00000020: 8000 0002 0001 0000 0001 5c00 0000 000e ..........\.....
^^^^^^^^^
subtype 0x80000002 — the high bit is PTRAUTH_ABI: arm64e
Each slice is a complete Mach-O at its own offset. Analyzing one says nothing about the other — a fact worth holding onto, because malicious code has shipped in one slice of a universal binary and not the others. Deep analysis runs on one host-preferred slice; the others get an identity-only probe — UUID, signing status, cdhash, team ID, entitlement count — before being split out and re-analyzed in full as separate children. Slices disagreeing on cdhash, team ID or entitlement count are flagged divergent, a repackaging signal no single-slice read can produce.
What every binary contains
Header — magic, target architecture, file type (executable, shared library, object, core), and the entry point. The entry point is not main; it is runtime startup code that eventually calls main.
Segments and sections — sections are the link view: named regions (.text, .data, .rodata) for the linker and debugger. Segments are the load view: coarse ranges with r/w/x permissions the loader maps. The generalization stops there:
| ELF | PE | Mach-O | |
|---|---|---|---|
| Load view | program headers | the section table itself | LC_SEGMENT commands |
| Link view | section headers | same table | sections nested in segments |
| Can the link view be dropped? | yes — sstripped ELFs run on program headers alone | no, it is the load view | no, sections live inside segments |
So "reason from segments, not sections" is ELF advice. In PE the section table is load-bearing and mandatory, with permissions in per-section IMAGE_SCN_MEM_* bits.
Section names are conventions, not contracts. Nothing stops code living in a section called .data, or a packer inventing .xyz123. Each section carries two sizes — file and memory — and memory-greater-than-file means zero-fill. __PAGEZERO above is that taken to its limit: 4 GiB of memory, zero bytes of file, existing only so that dereferencing a null pointer hits an unmapped page.
Code — machine instructions. The file records only a handful of roots: entry point, exports, and unwind/exception tables. Every other function boundary, the control-flow graph, and calling conventions are recovered by analysis, not read from the file.
Data — initialized (bytes in the file), zero-fill (.bss, no file bytes, which is why image size exceeds file size), and read-only (.rodata: constants, strings, jump tables).
Imports and exports — imports name external functions and the library each lives in; the loader patches an address table at startup (PE: IAT; ELF: GOT/PLT; Mach-O: chained fixups). Exports name what this binary offers others. The import list is a behavioral summary available before reading a single instruction — which is exactly why it gets laundered.
Symbols — names for addresses. Two tiers: the static symbol table (debug leftovers, fully strippable) and the dynamic symbol table (needed for linking, survives stripping). Stripping cannot remove what the loader needs. A statically linked binary (most Go, musl Rust) has no import table at all, so its runtime metadata becomes the naming layer instead.
Relocations — two kinds, routinely conflated. Symbol relocations wire up references to imported symbols. Base relocations patch absolute addresses when the image loads away from its preferred base — these are what make ASLR possible, so a PE with no .reloc cannot be rebased.
Resources, debug info, signatures — embedded structured data; DWARF/PDB/dSYM records, usually shipped separately but often leaving a path or GUID behind; and a cryptographic seal over some subset of bytes.
Hides: alignment padding between sections is real file bytes no section claims — room for a stub, and where a patcher writes a hook. An overlay past the last section is invisible to any tool that walks sections. A section table that disagrees with what the segments map — every sub-parse the parser had to recover from is recorded with its offset and structure (truncated, count_overflow, bad_offset, bad_magic), and a compiler-produced binary yields an empty ledger, so any entry at all is a structural anomaly. That ledger exists for ELF and PE only; Mach-O has none. The inverse of a hiding place is .bss: no file bytes, so nothing can hide there, and a section with no on-disk extent produces no entropy row at all rather than a spurious high reading.
Identity — the hashes
The only question that matters is which bytes the hash covered. Every hash trades "detects any change" against "survives benign rebuilds."
| Hash | Covers | Survives | Answers |
|---|---|---|---|
| sha256 / sha1 / md5 | every byte | nothing; one flipped bit changes it | is this the exact file |
| per-section sha256 | one section | changes elsewhere in the file | which part changed |
| imphash / telfhash / symhash | import or dynamic-symbol names (PE / ELF / Mach-O) | rebuilds keeping the same import set | family kinship |
| import_md5 (ELF); dylib / import / export / entitlement hash (Mach-O) | the named set (needed libraries, bound imports, export trie, entitlement strings), sorted and joined | rebuilds keeping that set | narrower kinship than one blanket symbol hash; byte-exact with yara-x's same-named function, so they pivot into other corpora |
| hardening hash | the mitigation feature map | the same build flags | build-policy kinship |
| rich-header hash (PE) | the MSVC build-tool fingerprint | the same build environment | toolchain kinship |
| ssdeep / TLSH | rolling window over content | edits of a few percent | near-duplicate pivots; the keys other corpora index on |
| function / quad hashes | per-function code, normalized | relinking, rebasing, address churn — not different codegen | shared code across different files |
| DNA vector | 106 structural features | recompiles, superficial change | behavioral similarity, not byte similarity |
Build IDs tie a stripped shipped binary back to its debug artifacts:
- PDB GUID + age (PE) — the symbol-server key.
- MVID (.NET) — module version id, new on every compile.
- UUID (Mach-O) —
LC_UUID, links to the dSYM. - cdhash (Mach-O) — the code-signature digest. Not the UUID: the UUID names the build, the cdhash seals the bytes.
- build-id (ELF) —
.note.gnu.build-id. - Go build id — written by the Go linker.
Everything in the version-info block — original filename, product name, company, version — is typed by whoever built the file. A dropper claiming to be v1.0.0.0 of a trusted product costs nothing.
Hides: flip one byte and every whole-file hash misses, which is the entire reason the other rows exist. A leaked PDB path hands over the build machine, the project name, and the author's directory layout — the .NET sample hexdumped above carries C:\Users\pzixe\Documents\Repos\test\obj\Debug\netcoreapp2.1\test.pdb, down to the account name and the fact that a Debug build shipped.
PE
- RVAs, not file offsets — every address in the headers is a relative virtual address, an offset from the image base once mapped. The section table is the translation between the two views; corrupting that translation is a classic anti-analysis trick.
- Rich header — an undocumented MSVC block between the DOS stub and the PE signature, recording the toolchain version of every object linked in. Survives everything short of deliberate scrubbing.
- Data directories — pointers to the import, export, resource, TLS, relocation, and certificate tables.
.rsrc— the resource tree:RT_VERSION(the version block),RT_MANIFEST(requested privileges, UAC level), icons. Entropy is computed per leaf, not just for the section, and each leaf carries a head-bytes content sniff, so a payload parked in a resource is named directly — type, size, entropy, and that its first bytes read aspe. Leaves sniffing as executable or archive (pe,elf,macho,zip,gzip,cab,7z) are counted per binary, but nothing scores that count: it is a data surface, not a detector, so the coarser reading — a.rsrcimplausibly large for the icons it claims to hold — is still done by hand. Modern icons are PNG-compressed and legitimately read near 7.9; an XML manifest reads 4–5 because it is text.- TLS callbacks — code that runs before the entry point and again on every thread start. Presence is not a signal: both the MSVC and MinGW CRTs emit them. Measured, benign
jqandcurlfor Windows carry 3 and 2; corpus PE malware carries 2. No count separates them. The callback target — whether it points into a packed or non-code region — is what would. TimeDateStamp— a claim, scored only where the reading is causally impossible.- PE32 versus PE32+ — both parse; sections, imports, signing, hardening and resources work on both. PE32 has no
.pdataexception directory and therefore no authoritative function map: discovery falls back to seeds (entry point, exports, TLS callbacks) plus an MSVC x86 prologue scan, so every function boundary in a 32-bit PE is recovered by pattern, not read. - Authenticode — four states, not two: absent; present with a matching digest (bytes intact relative to what was signed); present with a mismatched digest (modified after signing, the interesting one); and unverifiable, which is never a tamper claim. The digest answers integrity, not trust — a valid digest on a stolen certificate is still just a valid digest.
The three impossible TimeDateStamp readings, each with a measured margin:
| Reading | Fires when | Why that margin |
|---|---|---|
| Signed before built | the stamp postdates a TSA-attested Authenticode signing time by more than 24 hours | causally impossible; needs a cryptographically trusted timestamp, not merely a signature |
| Future | more than 2 years past the analysis year | a build farm with a clock skewed across a New Year cannot fire it |
| Impossibly old | nonzero and below 1992-01-01 | the PE format's own floor |
Zero is not scored at all: GNU ld/MinGW default the field to 0, most packers zero it, and .NET assemblies routinely carry 0. A deterministic build does not zero it either — it writes a content hash, which is why the 0xa66b09af above decodes to 2058. Reproducible-build, platform-signed and managed binaries are excluded outright.
Build hardening:
| Flag | On means | Off costs you |
|---|---|---|
aslr (DYNAMICBASE) | image loads at a randomized base | fixed addresses make exploits portable |
nx_compat (DEP) | data pages non-executable | code runs from the stack or heap |
cfg | indirect-call targets validated | call sites hijackable |
gs_cookie | stack canary present | stack overflows go undetected |
safeseh | exception handlers validated (x86) | SEH-overwrite exploitation |
cet_compat | CET: shadow stack + indirect-branch tracking | ROP and return-address tampering |
force_integrity | signature checked at load | modified code loads anyway |
high_entropy_va | 64-bit ASLR entropy | weaker randomization |
authenticode_signed | an embedded signature is present | nothing seals the bytes |
Two of those nine rows do not mean what the headings promise:
| Row | What it actually reads | So the value means |
|---|---|---|
safeseh | nothing — it is hard-wired to n/a on every PE; the handler table's address and count are parsed out of the load-config directory and then go unused | never a real 32-bit image's SEH posture |
gs_cookie | the names __security_cookie / _security_cookie, in the import table and export directory — a lookup, not a probe | Off = "no exported cookie symbol", not "no canary". Statically-linked CRTs carry neither name, so AnyDesk.exe, curl and jq for Windows all read Off and each raises a "no stack cookie" finding, with /GS on by default in all three |
Elsewhere n/a means the flag was not evaluated rather than off: high_entropy_va applies to x86_64 only, so a 32-bit or arm64 PE reads n/a.
Hides: an overlay past the last section. The certificate table legitimately lives out there too, and the two readers disagree — the header-level overlay detector excludes the certificate range, the one feeding the entropy row and the packing signal does not, so a signed PE reports its Authenticode blob as overlay bytes at compressed-blob entropy. Imports by ordinal (a number, no name to read) or delay-loaded — a separate table, absent from the main import list. That directory is parsed and then read by nothing, so a delay-loaded name reaches no rule, no hash and no capability — the analyzed import surface is the import directory plus .NET P/Invoke only. Code caves in alignment slack, which no section walker shows — though caving a signed PE produces exactly the digest mismatch above, because slack falls inside the Authenticode digest range.
ELF
.dynamic— the loader's instruction list:DT_NEEDEDlibraries, symbol tables, relocation tables driving lazy binding.- GOT / PLT — the indirection making dynamic linking work, the classic hijack point, and where you resolve which library a stub really reaches.
.note.gnu.build-id, and.comment, which usually preserves the compiler version string.- Hardening — nine features. RELRO (Full maps the whole GOT read-only after relocation; Partial leaves the function GOT writable), stack canary, PIE, NX stack and FORTIFY (
_chkvariants of risky libc calls) are all derived rather than declared as PE's header bits are — canary and FORTIFY from imported symbols, RELRO and NX from segment layout. The other four are read from GNU-property notes: CET indirect-branch tracking, CET shadow stack, arm64 BTI, arm64 PAC-signed returns. A fully static binary has no imports to probe, so several checks read "off" when the honest answer is "cannot tell."
Hides: a PT_LOAD mapped rwx — writable and executable is a self-modifying or unpacking tell. An RPATH/RUNPATH pointing at an attacker-writable directory is a library-search hijack baked into the header. Data after the last section header.
Mach-O
- Load commands — the spine. Every segment to map, every library to link, the entry point, where the signature lives.
otool -lprints them in order; read them and you have read the file's intent. - Chained fixups — the modern rebase/bind format replacing classic relocations; where imports and relocations now live.
- Code signature — a SuperBlob at the end of
__LINKEDIT: per-page code hashes (digested into the cdhash), the certificate chain, and entitlements, the specific capabilities the OS grants — camera, keychain, JIT. A high-signal capability list obtained without running anything. - ObjC / Swift metadata — method names, class layouts, protocol data the runtime requires, surviving stripping and providing a naming layer for free.
- arm64e — pointer authentication signs return addresses and function pointers in hardware.
- Hardening — fourteen features, mostly policy rather than compiler flags: hardened runtime, library validation (only Apple-signed or same-team code may load), PAC, PIE, NX heap, stack canaries, FORTIFY,
data_const, anyrwxsegment, FairPlay encryption, therestrictsegment, ad-hoc versus real signing, signed at all, and whether the minimum OS version is recent enough for the modern defaults to apply.
Hides: ad-hoc or absent signatures; over-broad entitlements (a "calculator" requesting com.apple.security.cs.allow-jit); malicious code in one slice of a fat binary and not the others.
Language runtimes
- .NET / CLR — a PE whose code is IL bytecode described by metadata: CLI header, the streams
#~(compressed tables),#Strings,#US(user strings),#GUID,#Blob; the MVID; referenced assemblies with public-key tokens; flags likeIL_ONLYandREQUIRES_32BIT. Because IL decompiles cleanly, .NET malware leans on obfuscators and packers rather than on the format. - Go — statically links nearly everything, so imports are almost useless. But pclntab and buildinfo embed function names, file paths, module versions, and the toolchain version, and they survive stripping because the runtime needs them. A "stripped" Go binary is far more legible than its author assumes.
- Rust — monomorphization inflates the binary; panic and format machinery leak type names and source paths into
.rodata; symbols are mangled but demanglable. - ObjC / Swift — the Mach-O runtime metadata is the naming layer.
Obfuscation and evasion
Packing and entropy. Compiled code lands around 4.5–6.5 bits per byte on a 0–8 scale.
| Reading | Where | Means |
|---|---|---|
| above 6.8 | a code section | flagged high-entropy |
| above 7.5 | a data section | flagged high-entropy |
| above 7.7 | any region | compressed or encrypted rather than code |
| under 256 bytes | any region | reports 0.0 and is never flagged — a 256-bucket histogram over fewer than 256 samples is noise, so a sub-256-byte encrypted config is invisible here by construction |
Two conditions in practice mark a binary packed: under 30 imports together with code entropy above 6.8, or maximum entropy anywhere in the file above 7.7 while some code section is already flagged at 6.8. That maximum is taken across every section and the synthetic overlay row, so the second trigger does not need a hot code section — a .text at 6.9 with a 7.9 overlay fires it with nothing above 7.7 in any code section at all. Packing is never a verdict on its own; compressed installers are legitimate.
Two consequences. The interesting band is 6.8–7.5, so a "look for 7.9" reflex misses where packer stubs and encrypted configs live. And entropy is defeatable downward — base64 or single-byte XOR parks a payload at 4–6 bits per byte and raises nothing. High entropy hides content while being conspicuous, so the careful adversary avoids it.
Section names are a claim. UPX0/UPX1 are strings a packer wrote. Malware routinely zeroes the UPX! magic and renames sections so upx -d and signature scanners fail while the packed layout still runs. The layout is recognizable from the program headers regardless, in two bands: markers intact is an identity row that carries points but never elevates a tier on its own, because packing is dual-use; both surface markers gone while a UPX loader-stub remnant survives is the evasion tell, because the defacement — not the packing — is what was chosen. That two-band reading is ELF only, so a mangled UPX PE gets nothing, and the native unpacker still anchors on the UPX! magic, so a defaced sample is labelled but never unpacked — its payload stays unread.
Same trick a layer up: overwriting four bytes of Go's pclntab magic makes every tool report a Go binary as stripped, while the runtime, which reaches the table through moduledata, never notices. Measured: 33 of 115 corpus Go executables patched, 0 of 23 benign.
API hashing — the import table's endgame. Rather than naming VirtualAlloc, the code walks the export table hashing every name (ror13, djb2, FNV) and calls whichever matches a constant. Zero imports, zero strings, one indirect call. The first artifact is the shape: a tight bitwise-dense loop with a single back edge that loads bytes and ends in an indirect call, inside a function making almost no direct API calls. The constant is also invertible: emulating the recovered recurrence over a bundled export-name catalog builds a reverse hash → name map, so the constant resolves back to VirtualAlloc and the indirect call becomes a named call-graph edge with recorded provenance. That catalog is a hand-seeded kernel32-only first cut, so a resolver targeting ntdll or ws2_32 names resolves nothing, and a hash colliding onto two or more names resolves to neither, by policy.
Stack strings — a string that never exists in the file. Code writes it onto its own stack frame, so strings returns nothing. Reconstruction from the IL puts it on the same matching surface as real strings, so a rule keyed on kernel32.dll fires on one assembled by mov qword [rsp], 0x…. That surface is a three-way union — ordinary strings, reconstructed stack strings, strings recovered by a decoder — and each hit carries its own evidence class, so a decoded match reports itself as recovered rather than as a literal in the file. Store width decides whether the pass produces anything at all: reading only 1-byte stores recovered one stack string across 679 corpus samples and 100,639 functions, because 1-byte stores are not the idiom real x86-64 and Mach-O code uses; 1/2/4/8-byte stores are what make it work. Benign jq and curl build stack strings too, so the discriminator is whether the run's address is ever taken — passed to a callee, stored, dereferenced. A run nothing points at is a numeric constant that happens to look like text.
String encryption. Everything interesting is XOR'd or base64'd in read-only data and decoded on first use. Two ways in: recover the plaintext by brute-forcing single-byte XOR and base64 against anchors — tokens an obfuscator would plausibly be hiding — or detect the decoder, since a tight, flat, bitwise-dense loop making almost no API calls is a hand-rolled deobfuscator, and having one is intent. The anchor search costs one pass for all 255 keys: what it scans for is the delta signature token[i] ^ token[i+1], which single-byte XOR leaves unchanged. Eight anchors (http://, .onion, https://, User-Agent, Mozilla/5.0, Content-Type, and two macOS support paths) produce 311 hits across 32 of 1,416 malware samples and 0 across 121 benign files. Anchor choice is the whole game — kernel32.dll, CreateProcess, VirtualAlloc, powershell, cmd.exe and a dozen other obvious guesses measured at zero yield and were dropped.
Anti-debug and anti-VM. The cheap primitives are visible statically: PTRACE_TRACEME or ptrace(PT_DENY_ATTACH) self-tracing, TracerPid parsed from /proc/self/status, ThreadHideFromDebugger, CheckRemoteDebuggerPresent, PEB BeingDebugged and NtGlobalFlag reads, hardware-breakpoint register checks. Anti-VM is louder: a binary carrying the exact 12-byte CPUID hypervisor brand (VMwareVMware, KVMKVMKVM) is comparing against it, and scanning for analysis-tool or security-product process names is unambiguous. What is not a tell: IsDebuggerPresent and OutputDebugStringA in the import table are MSVC CRT boilerplate — benign jq for Windows imports both.
Direct syscalls. Skip ntdll and issue syscall with the service number inline, so user-mode EDR hooks never see the call. Statically this surfaces as the toolkit that generated it — SysWhispers, Hell's/Halo's/Tartarus Gate, FreshyCalls — rather than as an import.
Injection. Visible without running anything: VirtualAllocEx + WriteProcessMemory + CreateRemoteThread co-occurring, or the constant 0x40 (PAGE_EXECUTE_READWRITE) resolved into an allocation call in one function. On macOS DYLD_INSERT_LIBRARIES; on Linux an LD_PRELOAD= assignment. Require the co-occurrence — each API alone is dual-use with JIT and managed runtimes.
Timestomping. Beyond the header field, SetFileTime and friends backdate dropped files. This one demands discipline in the other direction: SetFileTime in an import table is a fact, not a technique, because curl imports it to implement --remote-time.
Obfuscated names. garble rewrites Go symbols to random identifiers (internal/bytealg/Se1IQnZ0I44f); ConfuserEx, Dotfuscator and friends do the same to .NET metadata. The names are gone but the shape is loud. On Go it is measurable: 34 of 114 corpus Go images recover only 13–24 stdlib package names, against the 30 packages present in at least 95% of Go binaries and the 259 distinct stdlib packages seen across the corpus — a full runtime with a near-empty package list. On .NET the tell is the protector's own signature rather than a name-shape metric: ConfusedByAttribute, DotfuscatorAttribute, SmartAssembly.Attributes.PoweredByAttribute, EazfuscatorAttribute and BabelObfuscatorAttribute are marker attributes stamped into the assembly, each naming its product outright. A protector that strips its own marker is not identified.
Control-flow flattening and opaque predicates. The CFG is replaced by a dispatch loop over a state variable, or branches are inserted whose outcome is constant but not provably so. Behavior is unchanged; decompilation becomes unreadable. The manual tell is one enormous switch over a state variable and a basic-block count out of all proportion to what the function does. An opaque predicate is proved, not merely noticed: if the flow-sensitive value-range fixpoint shows the compared variable's range on entry to the block already lies entirely inside the range the true edge would impose, the branch cannot go the other way, so it is rewritten to an unconditional jump and the dead arm dropped — which exposes the junk behind it to a dead-code sweep. A dispatcher whose jump table is built at runtime, on the stack or heap, is out of reach of a read-only-image table reader; value-set analysis supplies the target set instead and splices the real edges into the CFG, behind soundness gates and tagged with its origin so a VSA-derived edge is never mistaken for one read from the file.
Absence is a signal. A 400 KB Mach-O with an empty __cstring, forty imports, and posix_spawn on the list is not a small program — it is a program that reconstructs everything at runtime and delegates the work to a shell. Reading structure instead of content is immune to any string cipher, which is why that one shape separates 106 of 119 AmosStealer samples from 0 of 1,424 benign Apple binaries.
Claims versus measurements
Every field is one or the other, and conflating them is the standard analytical error.
- Claims, controlled by whoever built the file: section names, sizes, addresses; imports and exports; version info and original filename; resources; PE's
DllCharacteristicsbits; referenced assemblies; PDB path;TimeDateStamp; .NET obfuscator marker attributes. - Measurements, which cannot be typed: entropy; hardening probes derived from segment layout and code (RELRO, NX, PIE, the Mach-O policy features); recovered capabilities and behaviors; internal consistency; findings; similarity.
gs_cookie looks like a measurement and is not: it reads a name out of the import or export table, and names are claims. FORTIFY on ELF has the same character. A probe that reads names inherits the trustworthiness of names.
Measurements can still be engineered against — entropy most of all, which is why it is paired with structural eligibility rather than trusted alone. But the gap between a claim and a measurement is where the finding lives: a section named .text with data entropy, a signature present with a mismatched digest, a benign product name over process-injection primitives.
Self-check
- Segments versus sections — and why is "the section table is optional" true only for ELF?
/bin/lsis 154,624 bytes and 30,363 of them are instructions, across two slices. What is the rest?- What is
__PAGEZEROfor, and how can it occupy 4 GiB while adding nothing to the file? - Why can imphash match when sha256 differs completely?
- An icon resource and a version-info block both read entropy 7.9. Which is suspicious — and what does a
.rsrcleaf that sniffs aspeestablish that entropy alone does not? - Why can nothing hide in
.bss, and what does a section with no on-disk bytes report for entropy? - What can stripping never remove — and what does a statically linked binary not have at all?
high_entropy_vashowsn/a. What does that say about the architecture?- What does a leaked PDB path give you?
- Why do Go function names survive stripping, and which four bytes does an author patch to break that?
- A PE imports only
LoadLibraryandGetProcAddress. What does that suggest, and why is it not a verdict? - Absent, digest-match, digest-mismatch, unverifiable — which is not a tamper claim?
- Where does an overlay live, and what legitimately lives out there too?
- What do entitlements tell you without running the binary?
- What are
#~,#US, and#Blob? - Why is the number of TLS callbacks not a signal, and what would be?
- RELRO Full versus Partial — what is the difference?
- High entropy is conspicuous. What does a careful adversary use instead, and what happens to the number?
- What two conditions actually mark a binary as packed — and why can the second fire with no code section above 7.7?
- A binary has almost no imports and almost no strings. What single technique explains both, and what has to be bundled before the hidden name can be recovered rather than merely suspected?
- What is a stack string, and why does
stringsnever show one? - Why is
SetFileTimein an import table a fact rather than a technique? - Mach-O UUID versus cdhash — which names the build, which seals the bytes?
- Why can a fully static ELF show every hardening probe "off" and still be hardened?
cafebabe— what have you actually got, and what has analyzing it told you about the rest of the file?
Reference
- PE Format — Microsoft's normative specification.
- System V gABI — the ELF specification.
- A dive into the PE file format — 0xRick's eight-part walkthrough, header by header, ending in a working parser.
- Anatomy of a Binary Executable — Matt Oswalt traces one small Rust program through
readelfandobjdump. - corkami/pics — Ange Albertini's visual format posters, including ELF 101 and the PE series.