Static UPX Unpacking: Rebuilding the Original PE Without Running It

Malware Research Team 22 min read

UPX is probably the most common executable packer in the malware ecosystem. It is open source, free, fast, and it produces a working executable that is often half the size of the original. For an analyst, it also means that what lands in the triage queue is not the malware itself but a small decompression stub sitting on top of an opaque, high-entropy blob.

Malva.RE now handles UPX-packed Windows executables end to end: detection of x86 and x64 UPX binaries, static decompression, and reconstruction of the original PE file, which is then fed back into the rest of the analysis pipeline. Nothing is executed, emulated or sandboxed at any point: the packed file is treated purely as data.

This article explains how the UPX format works on Windows, what has to be undone to get the original program back, which pitfalls a static unpacker has to deal with, and how malware authors modify UPX to break the official upx -d.


Why this matters for analysts

When a sample is UPX-packed, almost every static signal is hidden:

The usual workaround is to run the sample in a sandbox and dump it from memory, or to run upx -d locally. The first option requires execution infrastructure and carries its own evasion problems. The second often fails on malware, precisely because attackers tamper with the UPX metadata to make it fail.

With static unpacking, the reconstructed PE becomes a child file in the Malva.RE report tree, typed as a Windows executable or DLL. From there it goes through exactly the same analysis as any uploaded binary: PE parsing, YARA, family-specific collectors, configuration extraction, IOC aggregation. The packed file and the unpacked payload are both visible, with the relationship between them.


Why malware uses UPX (and modifies it)

UPX was designed as a legitimate size optimiser, and it is still widely used by legitimate software, which is part of why it is attractive to attackers:

The obvious drawback for an attacker is that upx -d restores the original file in one command. So a large share of UPX-packed malware is deliberately modified after packing, with changes that keep the stub working (the stub does not care about most metadata) but break the official decompressor:

The packed program still runs, because the stub only uses constants embedded in its own code. But the official tool relies on that metadata and refuses to unpack. A static unpacker that wants to be useful on real malware has to cope with this.


Anatomy of a UPX-packed PE

Sections before and after

UPX rewrites the section table completely. The original sections are merged into a single virtual region that the stub fills at runtime.

 Original PE                         UPX-packed PE
 ┌──────────────────────┐            ┌──────────────────────────────┐
 │ DOS header + stub    │            │ DOS header + stub            │
 │ NT headers           │            │ NT headers (rewritten)       │
 │ Section table        │            │ Section table (3 entries)    │
 │                      │            │ "x.yz" version + PackHeader  │
 ├──────────────────────┤            ├──────────────────────────────┤
 │ .text                │            │ UPX0  raw size = 0           │
 │ .rdata               │            │       virtual size = size of │
 │ .data                │   ─────►   │       all original sections  │
 │ .rsrc                │            ├──────────────────────────────┤
 │ .reloc               │            │ UPX1  compressed image       │
 │ ...                  │            │       + decompression stub   │
 └──────────────────────┘            │       (entry point is here)  │
                                     ├──────────────────────────────┤
                                     │ UPX2 / .rsrc  stub imports,  │
                                     │       uncompressed resources │
                                     └──────────────────────────────┘

Key properties of this layout:

The PackHeader

In a Windows PE, UPX stores its metadata in a fixed 32-byte structure called the PackHeader, placed in the header area just after the section table and usually preceded by a plain-text version string. Its layout for the revisions found in Win32/Win64 files is:

OffsetSizeFieldMeaning
04magic"UPX!"
41versionHeader format revision (not the UPX release number)
51formatTarget format (Win32 PE, Win64 PE…)
61methodCompression method identifier
71levelCompression level (1 to 10)
84u_adlerAdler-32 of the uncompressed data
124c_adlerAdler-32 of the compressed data
164u_lenUncompressed length
204c_lenCompressed length
244u_file_sizeSize of the original file
281filterFilter identifier (call/jump transform)
291filter_ctoFilter parameter (marker byte)
301(reserved)
311header checksumSum of bytes 4..30, modulo 251

A few remarks that matter in practice:

What about l_info and p_info?

UPX documentation and source often mention the l_info and p_info structures. These belong to the Unix-like formats (ELF and related loaders), where they describe the loader and the program blocks. They are not part of the Windows PE packing format: PE files rely on the PackHeader plus a restoration trailer inside the compressed image, described below. Since this work targets Windows PE files, l_info / p_info are not involved.


Step 1: detecting UPX on x86 and x64

The gap we had to close

Before this work, the UPX signatures loaded in Malva.RE’s YARA set targeted ARM decompression stubs (Windows CE flavours). They never matched an x86 or x64 Windows executable, which means that, in practice, standard UPX-packed PE files were not tagged as UPX at all.

The new detection rule combines independent indicators, any of which is enough on a valid PE with at least two sections:

The entry-point patterns are what make detection resilient to renamed sections and erased magic: an attacker can rewrite metadata freely, but the stub has to keep its code to work.

Structural scoring

On top of the YARA rule, the unpacker performs its own structural analysis before trying anything. It looks for:

  1. The UPX layout: an empty virtual section (no raw data, virtual size of at least one page) immediately followed by a stored section that contains the entry point.
  2. Conventional UPX section names.
  3. A stub-like byte pattern at the entry point.
  4. High entropy in the stored section (only counted together with the layout, since entropy alone means nothing).
  5. A PackHeader, searched in bounded windows: the beginning of the file, the end of the file, and the start of each section. The file is never scanned byte by byte across hundreds of megabytes of overlay.

Each indicator contributes to a score, and inconsistencies are recorded as anomalies: magic erased, header checksum mismatch, declared lengths impossible for the file size, UPX-like layout with renamed sections, UPX-like layout with no readable header. These anomalies are surfaced in the report, because a tampered UPX header is a meaningful signal in itself: legitimate software has no reason to do it. When such tampering is found, the sample is also tagged accordingly.


Step 2: decompressing the image

Locating the compressed stream

The PackHeader gives the compressed and uncompressed lengths, but not the file offset of the compressed stream: in a PE, the header lives in the header area while the data lives in UPX1, and the stub simply knows where to look. The unpacker therefore builds a short, bounded list of candidate offsets:

The candidate list is deduplicated and capped, so an adversarial file cannot make the unpacker try an unbounded number of positions.

Compression methods

UPX encodes the compression algorithm in the method byte of the PackHeader:

IDMethodBit-buffer widthStatus
2NRV2B32-bit little-endianSupported, validated against upx -d
3NRV2B8-bitSupported, validated with grammar test vectors
4NRV2B16-bit little-endianSupported, validated with grammar test vectors
5NRV2D32-bit little-endianSupported, validated against upx -d
6NRV2D8-bitSupported, validated with grammar test vectors
7NRV2D16-bit little-endianSupported, validated with grammar test vectors
8NRV2E32-bit little-endianSupported, validated against upx -d
9NRV2E8-bitSupported, validated with grammar test vectors
10NRV2E16-bit little-endianSupported, validated with grammar test vectors
14LZMAn/aSupported, validated against upx -d and an independent 7-Zip vector

Any other method identifier is rejected explicitly. In particular, an unknown method byte is never silently interpreted as another algorithm.

NRV2B, NRV2D, NRV2E

The NRV family (from the UCL library) consists of simple LZ77 variants with a bit-oriented control stream. Control bits are fetched in words of 8, 16 or 32 bits depending on the variant, most significant bit first, and interleaved with literal bytes:

Because the stream is self-terminated, a decoder can check that it consumed exactly c_len bytes and produced exactly u_len bytes. Both checks are enforced.

LZMA

For LZMA, UPX does not use the standard 13-byte .lzma header. It prepends a compact 2-byte property header:

The range-coded stream follows immediately, without an end marker; the expected output size comes from u_len. Property values are validated (bounds on lc, lp, pb and consistency between the two bytes) before any model is allocated. The decoder is a full LZMA implementation (literal coding with match-byte prediction, rep0 to rep3 distances, short reps, length coders, distance slots and align bits).

Integrity checks

When the header is intact, the decompression is accepted only if:

  1. the Adler-32 of the compressed bytes matches c_adler;
  2. the decoder consumes the declared compressed length (for NRV methods);
  3. the output has exactly the declared length u_len;
  4. the Adler-32 of the decompressed bytes matches u_adler.

An important subtlety: u_adler is computed on the still-filtered image, that is, before the call/jump transform is reversed. Verifying it after undoing the filter fails on every filtered sample.


Step 3: reversing the call/jump filters

Why UPX filters code

x86 call rel32 (E8) and jmp rel32 (E9) instructions store a relative displacement. Two calls to the same function from different places have different operands, which compresses poorly. Before compression, UPX rewrites these operands into absolute targets, so repeated calls to the same function become identical byte sequences. The stub reverses the transform after decompression.

The family of filters used on Windows PE files is often referred to as the CTO filters, after the filter_cto parameter stored in the header.

FilterInstructions transformedEncoding of the operand
0x24call (E8)absolute target, big-endian, marker byte on top
0x25jmp (E9)absolute target, big-endian, marker byte on top
0x26call + jmpabsolute target, big-endian, marker byte on top
0x46call + jmpsame idea, marker-based variant
0x49call + jmp + conditional jcc (0F 8x)same idea, also covers conditional near jumps

How the reversal works

For each opcode of interest in the code range, the first byte of the operand is compared with the marker (filter_cto). If it matches, the operand is a transformed one: the remaining 24 bits are read as a big-endian absolute position, converted back to a displacement relative to the end of the instruction, and written back in little-endian form.

The filter is applied only to the original code range, taken from BaseOfCode and SizeOfCode in the original optional header, which UPX saves inside the compressed image.

A classic pitfall: the marker is not always zero

A tempting shortcut is to assume the marker byte is 0. It is not. UPX chooses the marker per file, to minimise false positives on data that happens to look like E8/E9. Some binaries (Visual Basic 6 executables are a common example) end up with a non-zero marker. With the wrong marker, two things go wrong at once: real calls stay transformed, and unrelated data bytes are “un-filtered” into garbage. The result is a PE that looks plausible but contains broken code, which silently degrades every downstream analysis.

On our reference corpus, UPX selected filter 0x49 for x64 DLLs with branches regardless of the filter option requested, which illustrates why the unpacker must read the effective filter from the file and never assume it from the command line.

Filter 0x11 and any other unknown filter identifier are refused explicitly.


Step 4: rebuilding the original PE

The decompressed buffer is not a PE file yet. It is the memory image of the original sections, followed by metadata that UPX needs to restore everything else. Rebuilding a valid file requires several distinct operations.

The restoration trailer

Two layout facts are essential:

Right after that saved section table, UPX writes compact records used to restore imports, relocations and resources. Which records are present depends on what the original file contained. The unpacker reads exactly the records it expects, and requires the trailer to end where it should: any unexplained trailing bytes mean an unfamiliar layout, and the file is refused rather than reconstructed approximately.

Headers and section layout

The output file is assembled as follows:

Imports

The packed file only imports what the stub needs. The original import table is replaced, inside the compressed image, by a compact import record stream. For each imported DLL it stores:

Two producer profiles exist and both are handled:

  1. Rebuilt names: the record gives a destination where DLL names must be written. The unpacker lays out the DLL names, then the hint/name entries (two-byte aligned, with the zero hint UPX uses), fills each IAT thunk with either a hint/name RVA or an ordinal with the high bit set, and writes the import descriptors.
  2. Preserved addresses: later UPX versions keep the original thunks and hint/name addresses in the image. The unpacker restores DLL names and function names at their original locations, and checks that every preserved ordinal matches the compact record.

The number of rebuilt descriptors must fit the original import directory size. Every count (modules, functions, string lengths) is bounded.

Relocations

Relocations are also stored in compressed form:

The unpacker parses the whole stream first, then converts each target back into a little-endian absolute address based on the original ImageBase. Finally, it regenerates a standard .reloc table: entries grouped by 4 KiB page into base relocation blocks, typed IMAGE_REL_BASED_HIGHLOW for PE32 or IMAGE_REL_BASED_DIR64 for PE32+, each block padded to a 4-byte boundary. The rebuilt table must have exactly the size declared by the original relocation directory. Files with stripped relocations (IMAGE_FILE_RELOCS_STRIPPED) carry no relocation record and need none. Only the relocation record flavour observed on our corpus (flags 0) is accepted.

Exports and TLS

For DLLs, UPX may move the export directory out of the compressed area so that the loader can still resolve exports before the stub runs. In that case, the export directory, its function table, name table and ordinal table are copied back to their original location and their RVAs rebased. When exports were left in place, they are taken from the decompressed image.

TLS data and callbacks are validated, never invoked: the template range, index address and each callback address must lie within the rebuilt image.

Resources

Resources are the most intricate part. UPX keeps a resource directory in the packed file because Windows needs some resources (icon, version information, manifest) without running the program. That directory can describe a mix of payloads:

The unpacker walks the stored resource tree (with cycle detection and depth and node limits), copies directory nodes, names and data entries to the original resource location, moves the uncompressed payloads back to their original addresses, and fixes the data entries accordingly.

Icons need special care. Depending on the --compress-icons setting, UPX may expose only the first image of the first icon group, so that Explorer still shows an icon, and lower the image count in the group directory. The original count is stored in the trailer; the unpacker restores it in the first GRPICONDIR. All four icon settings (0 to 3) are covered by our tests. A resource tree that is itself only partially compressed, and would need merging, is refused.

Final validation

Before a reconstructed file is accepted, it is parsed again as a PE and validated: every non-empty data directory must map into the file, import descriptors must be terminated and their thunks valid, relocation blocks well formed with targets inside the image, export counts and ordinals consistent, TLS addresses inside the image, and the resource tree free of cycles. If any check fails, the candidate is discarded.

Nested packing

Some samples are packed several times. After a successful layer, the result is examined again, and further UPX layers are unpacked up to a depth of three. Deeper nesting is reported as a failure rather than silently stopping halfway.


Step 5: recovering tampered samples

This is where a static unpacker earns its keep on real malware. When the PackHeader is damaged or missing, the process becomes a bounded search, always ending with the full reconstruction and validation above.

Damaged but present header

A header with an erased magic is still recognisable: the revision byte, format, method, level and lengths must all be plausible, and the method must be one we support. Such headers are used with the same integrity checks as intact ones, when those checks still hold.

Header lengths that lie

If decoding with the declared lengths fails, the unpacker stops trusting them and tries the supported methods (header method first) at each candidate offset, letting the self-terminated NRV streams define their own length. A note records the method, offset and candidate number used, and a mismatch between the declared and actual decompressed size is reported as an anomaly.

LZMA without any size

LZMA streams in UPX carry no end marker, so without u_len the decoder does not know where to stop. During recovery only, the decoder stops at a point where the range coder is fully flushed and the output ends with a plausible restoration trailer: a valid saved NT header referenced by the final four bytes. This is only a candidate size; the full reconstruction and validation still have to succeed.

Header wiped entirely

Without a header, the filter type and marker are unknown, and getting them wrong produces silently broken code. The unpacker therefore reads them from the stub itself: the end of each known decompression routine (NRV and LZMA, 32 and 64 bits) is followed by the filter-reversal code, whose shape identifies the filter and whose embedded constant is the marker. These are matched as bounded byte profiles, collected from files produced by the official UPX tools, for older and newer stub generations. No instruction is decoded, emulated or executed.

If the stub profile is unknown and the code section contains branch opcodes that might have been filtered, the unpacker refuses to produce an output rather than guessing. A wrong guess would produce a plausible but corrupted PE, which is worse than no PE.

What we test against

Every packed reference file is also unpacked after four in-memory alterations, mirroring what is seen in the wild:

  1. the four UPX! bytes erased;
  2. all section names replaced;
  3. the uncompressed length replaced by an absurd value, which also invalidates the header checksum;
  4. the 32 bytes of the PackHeader wiped.

An additional mutation checks that an unknown filter profile is refused even when the data decompresses correctly.


Safety and resource limits

The unpacker processes hostile input inside a shared analysis worker, so it is designed defensively:


Validation and current scope

The reconstruction is validated against the official upx -d (with relocation stripping disabled) on a corpus of benign test programs we compiled and packed with the official UPX 3.91 and 3.95 tools: EXE and DLL, x86 and x64, all four method families, five filter options, resources kept or mixed, and the four icon settings. Success requires the same architecture, file type, entry point, SizeOfImage, section count and section attributes, and byte-identical raw data for every section, padding included. Accepted differences are limited to headers: retained DOS stub, zeroed checksum, cleared certificate, debug, bound import and IAT directories. The decoders are written from format descriptions; no UPX or UCL source code is reused.

Known limits, stated plainly:


In the Malva.RE report

When a UPX-packed sample is submitted, the report now shows:

This closes a frustrating blind spot: a commodity stealer or RAT wrapped in UPX, with its header damaged to defeat upx -d, is now analysed as the stealer or RAT it actually is, without ever being run.


Try it on your own samples

You can submit UPX-packed samples to Malva.RE and inspect the reconstructed payload directly in the report tree. Start a free trial. Please keep in mind that reports produced on the free plan are public.

Tags: Malva.RE UPX Packers Unpacking PE Format Static Analysis Malware Analysis
// Built in Savoie

Designed and built in Savoie by Yneos

Malva.RE is published by Yneos, a French cybersecurity and secure software development company based at Savoie Technolac (Le Bourget-du-Lac, France).

Discover Yneos →

// Malva.RE

Try Malva.RE on a real malware sample

Understand malware structure and behavior without execution

Try for free

Free plan: public reports

Request a demo →