PE Morphing (UPX section rename)

← pe index · docs/index

TL;DR

Replace UPX section names (UPX0, UPX1, UPX2) with random bytes so off-the-shelf static unpackers (CFF Explorer, x64dbg's UPX plugin, IDA's UPX preprocessor) fail to recognise the input. The runtime UPX stub keeps working because it references offsets, not the magic. UPXFix reverses the morph for debugging.

Primer

UPX is the most popular executable packer — it compresses binaries to reduce size. Every UPX-packed binary carries well-known section names that every antivirus and EDR fingerprint on contact. pe/morph rewrites those names with random non-zero bytes, breaking the signature-based unpack pipeline while leaving the runtime behaviour intact.

The morph is a 24-byte change (three 8-byte section name fields). That is enough to break SHA-256 blocklists entirely, but similarity-hash scans (ssdeep, TLSH) still pin the variant to its parent in the ~95th percentile range — the morph is genuinely shallow, defeating only signature-based static unpacker matching.

How It Works

flowchart LR
    INPUT["UPX-packed binary<br>UPX0 / UPX1 / UPX2"] --> SCAN[Scan section table<br>for 'UPX' substring]
    SCAN --> RAND[Generate 8 random<br>printable bytes per section]
    RAND --> PATCH[Overwrite Name field<br>40-byte section header offset]
    PATCH --> OUT[Morphed binary<br>random section names]

The section name field lives at offset 0 of every 40-byte section header in the section table. The section table itself starts at COFF_offset + 20 + SizeOfOptionalHeader; each header is 40 bytes; the Name field is the first 8 bytes. UPXMorph walks the table, matches names containing "UPX", and overwrites the 8 bytes in place. UPXFix walks the same table, matches the random bytes against the expected layout (3 sections, sequential), and restores the canonical names.

API Reference

UPXMorph(peData []byte) ([]byte, error)

godoc

Replace UPX section names with random bytes. Returns the input unchanged when the PE is not UPX-packed; returns an error on malformed PE input.

UPXFix(peData []byte) ([]byte, error)

godoc

Restore canonical UPX0 / UPX1 / UPX2 section names. The morphed binary becomes unpackable with upx -d again.

Examples

Simple — morph an existing UPX binary

import (
    "os"

    "github.com/oioio-space/maldev/pe/morph"
)

raw, _ := os.ReadFile("payload.upx.exe")
morphed, _ := morph.UPXMorph(raw)
_ = os.WriteFile("payload.morph.exe", morphed, 0o644)

Composed — restore for debugging

restored, _ := morph.UPXFix(morphed)
// upx -d on restored now succeeds

Advanced — fuzzy-hash before/after

Demonstrate the morph defeats SHA-256 but not similarity hashes:

import (
    "fmt"
    "os"

    "github.com/oioio-space/maldev/hash"
    "github.com/oioio-space/maldev/pe/morph"
)

raw, _ := os.ReadFile("payload.upx.exe")
sha256Before := hash.SHA256(raw)
ssBefore, _ := hash.Ssdeep(raw)
tlBefore, _ := hash.TLSH(raw)

morphed, _ := morph.UPXMorph(raw)

ssAfter, _ := hash.Ssdeep(morphed)
tlAfter, _ := hash.TLSH(morphed)
ssScore, _ := hash.SsdeepCompare(ssBefore, ssAfter)
tlDist, _ := hash.TLSHCompare(tlBefore, tlAfter)

fmt.Printf("SHA-256 same?    %v\n", sha256Before == hash.SHA256(morphed)) // false
fmt.Printf("ssdeep score:    %d / 100\n", ssScore)                        // ~97
fmt.Printf("TLSH distance:   %d\n", tlDist)                               // ~12

Pipeline — build → pack → strip → morph

exec.Command("garble", "-literals", "-tiny", "build", "-o", "step1.exe", "./cmd/implant").Run()
exec.Command("upx", "--best", "-o", "step2.exe", "step1.exe").Run()

raw, _ := os.ReadFile("step2.exe")
raw = strip.Sanitize(raw)
raw, _ = morph.UPXMorph(raw)
_ = os.WriteFile("final.exe", raw, 0o644)

See ExampleUPXMorph.

OPSEC & Detection

ArtefactWhere defenders look
UPX0 / UPX1 / UPX2 literal section namesYARA / EDR static rules — defeated by morph
Sequential 24KB+ executable sections + decompression stubHeuristic UPX detection — not defeated
File entropy ~7.99 bits/byte (compressed payload)Anti-malware entropy scans — unchanged
Runtime: VirtualAlloc(RWX) + decompression in-placeBehavioural EDR — outside scope; UPX morph only touches the on-disk file
ssdeep / TLSH similarity to a known UPX-packed family memberFuzzy-hash blocklists — only ~24 bytes change, similarity stays high

D3FEND counters:

  • D3-SEA — section-table inspection.
  • D3-FCA — entropy + fuzzy-hash similarity.

Hardening for the operator:

  • Pair with pe/strip (pclntab + section rename) for both Go and UPX scrub in a single pass.
  • The UPX runtime stub itself is detectable — for higher-effort scenarios swap the stub via a custom packer.

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1027.002Obfuscated Files or Information: Software Packingpartial — UPX header morph defeats signature-based unpackers; entropy + stub remainD3-SEA, D3-FCA

Limitations

  • UPX-specific. Only targets UPX section names; other packers (Themida, VMProtect, ASPack) are out of scope.
  • Superficial. The UPX decompression stub is still present and recognisable by deep analysis — heuristic detectors win.
  • Entropy unchanged. High-entropy compressed sections remain detectable by entropy scans.
  • Fuzzy hash leak. ssdeep / TLSH similarity stays in the ~95th-percentile range; not safe against family-similarity blocklists.
  • Requires valid PE. Malformed input returns an error; no best-effort partial morph.

See also