Skip to content

Instantly share code, notes, and snippets.

@cyberofficial
Created May 15, 2026 06:41
Show Gist options
  • Select an option

  • Save cyberofficial/ba069f0dc0e2892f75a669be7b98f5b0 to your computer and use it in GitHub Desktop.

Select an option

Save cyberofficial/ba069f0dc0e2892f75a669be7b98f5b0 to your computer and use it in GitHub Desktop.
DeepSeekV4 Flash: Hijack "/HELP" from "cpu-z_2.19-en"

/HELP Hijack — Start to Finish

Hijacked MessageBox

Brought to you by DeepSeek V4 Flash via Claude Code CLI, analyzing with Ghidra and ghidra-mcp.

The Goal

Patch cpu-z_2.19-en.exe so that running it with /HELP shows a MessageBox with "Hijacked" instead of the normal Inno Setup help dialog.


Phase 1: Reconnaissance

1.1 What is this binary?

Loaded the EXE into Ghidra. The toolchain string at 0x004b9018 gave the answer:

Embarcadero Delphi for Win32 compiler version 35.0 (28.0.48361.3236)

The assembly manifest at 0x004ca81c confirmed:

JR.Inno.Setup / Inno Setup Setup Data (6.3.0)

Not the CPU-Z application itself — an Inno Setup installer stub. Its job is to validate an embedded compressed archive and hand control to the real setup engine.

Property Value
Format PE32 (x86 LE)
Image base 0x00400000
Entry point 0x004a83bc (entry)
Sections 11 (.text, .itext, .data, .bss, .idata, .didata, .edata, .tls, .rdata, .rsrc)
Functions 2,398
Size 4,799,168 bytes

1.2 Finding the target

The entry function at 0x004a83bc calls ParseCmdLineOptions (0x004a1240), which walks GetCommandLineW() tokens and matches them against known Inno Setup switches.

Disassembly of the relevant handler (0x004a12f80x004a131a):

004a12f8  MOV EDX, 0x4a13b8    ; wide string L"/HELP"
004a12fd  MOV EAX, [EBP-0x4]   ; current command-line token
004a1300  CALL 0x0041c628       ; strcmp (returns 0 on match)
004a1305  TEST EAX, EAX
004a1307  JZ   0x4a131a         ; if match → set help flag
004a1309  MOV EDX, 0x4a13d0    ; else try L"/?"
004a130e  MOV EAX, [EBP-0x4]
004a1311  CALL 0x0041c628
004a1316  TEST EAX, EAX
004a1318  JNZ  0x4a1321         ; no match → next token
004a131a  MOV byte [0x4ac4ac], 1  ; ← THIS IS THE TARGET
004a1321  INC ESI               ; next argument
004a1322  DEC EBX
004a1323  JNZ  0x4a126d

Target: 7 bytes at 0x004a131a. They set a flag byte. We need to replace them with a jump to shellcode that calls MessageBoxW("Hijacked") and exits.

1.3 Finding ingredients

Ingredient Address Purpose
MessageBoxW delay-load thunk 0x00410FCC Shows the popup
ExitProcess delay-load thunk 0x004051F8 Clean exit after popup
Code cave (zero padding) 0x004A6700 Space to write shellcode
/HELP handler 0x004A131A The 7 bytes to patch

Phase 2: PE Section Layout Problem

2.1 Section headers (from raw file)

Name      RVA      VSize    RawOff   RawSz
.text     0x1000   0xA568C  0x400    0xA5800  ← code cave needs to go here
.itext    0xA7000  0x1B64   0xA5C00  0x1C00
.data     0xA9000  0x3838   0xA7800  0x3A00
...

2.2 The VSize problem

The code cave at RVA 0xA6700 is at offset 0xA5700 from .text start. The .text VSize is only 0xA568C, so the loader only maps 0xA568C bytes of .text into memory. Bytes from 0xA568C to 0xA7000 (page-aligned end) are zero-filled by the Windows loader, regardless of what's in the raw file.

Solution: Increase .text VSize from 0xA568C to 0xA5800 (matching RawSz). This is safe because .itext starts at 0xA7000 — the page-aligned section boundary doesn't change.


Phase 3: Shellcode Design

3.1 Layout at 0x004A6700

Offset  Bytes                 Assembly
------  --------------------  ---------------------------------------
0x00    6A 00                 PUSH 0              ; MB_OK
0x02    68 1D 67 4A 00        PUSH 0x004A671D     ; lpCaption
0x07    68 1D 67 4A 00        PUSH 0x004A671D     ; lpText
0x0C    6A 00                 PUSH 0              ; hWnd = NULL
0x0E    E8 B9 A8 F6 FF        CALL 0x00410FCC     ; MessageBoxW
0x13    6A 00                 PUSH 0              ; uExitCode = 0
0x15    E8 DE EA F5 FF        CALL 0x004051F8     ; ExitProcess
                              ; --- never returns ---
0x1A    00 00 00              ; padding
0x1D    48 00 69 00 6A 00...  L"Hijacked\0"       ; wide string

3.2 Patch at 0x004A131A

Before:  C6 05 AC C4 4A 00 01   MOV byte [0x4AC4AC], 1
After:   E9 E1 53 00 00 90 90   JMP 0x004A6700 ; NOP ; NOP

3.3 Instruction reference (x86, 32-bit, __stdcall)

Instruction Encoding Size
PUSH imm8 6A + byte 2
PUSH imm32 68 + dword 5
CALL rel32 E8 + signed-dword 5
JMP rel32 E9 + signed-dword 5
NOP 90 1

Relative offsets are target - (address_of_next_instruction), signed 32-bit.


Phase 4: Bug & Fix

4.1 Checksum offset bug

The script originally wrote the zero checksum at coff + 88, but the CheckSum field is at offset 64 in the optional header: coff + 20 + 64 = coff + 84.

coff + 88 = coff + 20 + 68 = Subsystem field.

Zeroing Subsystem made it IMAGE_SUBSYSTEM_UNKNOWN (0), causing Windows to reject the binary as a non-Win32 application ("cannot be run in Win32 mode").

Fix: coff + 20 + 64 = correct CheckSum offset.

4.2 Why Subsystem = 2 matters

Value Meaning
0 IMAGE_SUBSYSTEM_UNKNOWN — rejected by loader
2 IMAGE_SUBSYSTEM_WINDOWS_GUI — normal Windows GUI app

The fix preserved Subsystem = 2 and DllCharacteristics = 0x8140.


Phase 5: Code Map of the Patched Binary

5.1 entryParseCmdLineOptions call flow

entry (0x004a83bc)
  └── ParseCmdLineOptions (0x004a1240)
        │
        │  Walks GetCommandLineW() tokens
        │
        ├── "/SP-" matched?  → set SP flag at [0x4AC4AD]
        │
        ├── "/SPAWNWND=" prefix?  → handled
        │
        ├── "/Lang=" prefix?  → store language value
        │
        ├── "/HELP" matched?                                 ← HIJACKED
        │   └── [PATCHED] JMP 0x004A6700
        │         │
        │         ├── PUSH MB_OK | &L"Hijacked" | &L"Hijacked" | NULL
        │         ├── CALL MessageBoxW   → popup appears
        │         ├── PUSH 0
        │         └── CALL ExitProcess   → process terminates
        │
        └── "/?" matched?
            └── MOV byte [0x4AC4AC], 1   (unchanged)

5.2 Changed bytes in the file

File Offset RVA Bytes Changed
PE header + VSize field 0xA568C0xA5800 Extended .text virtual range
PE header + CheckSum 0x00000000 Checksum zeroed (optional)
0x000A5B00 0xA6700 26 bytes of shellcode
0x000A5B1D 0xA671D 18 bytes — L"Hijacked\0"
0x000A071A 0xA131A 7 bytes — JMP + 2×NOP

5.3 Address map

00400000 ─┬─ PE Headers
00401000  ├─ .text ──────────────────────────────────┐
          │   ...                                    │
004A1240  │   ParseCmdLineOptions                    │
004A131A  │   ├─ [PATCHED] JMP ──────────────────┐   │
004A1321  │   └─ loop continues here             │   │
          │   ...                                │   │
004A6700  │   [SHELLCODE] ←──────────────────────┘   │
004A671D  │   L"Hijacked\0"                          │
004A6800  ├─ (end of .text virtual range)           ─┘
004A7000  ├─ .itext
004A8B64  ├─ (end of .itext)
004A9000  ├─ .data
          │   ...
004A9C24  ├─ _tls_index
004AC4AC  ├─ [help flag byte — no longer set by /HELP]
          │   ...
004B063C  ├─ dbkFCallWrapperAddr
004B41F4  ├─ g_dwModulePath
004B41F8  ├─ g_dwSetupStream
004B4200  ├─ g_dwSetupHeader
004B4254  ├─ g_dwArchiveReader
          │   ...
004BA000  ├─ .rsrc
004CB000  └─ end of image

Files Produced

File Description
aboutfile.md Initial binary overview
info_on_entry.md Deep dive into entry and its 15+ sub-functions
patch_help_hijack.py The patcher script
cpu-z_2.19-en-hijacked.exe The patched binary
patch_journey.md This file

Running the patcher

python patch_help_hijack.py cpu-z_2.19-en.exe cpu-z_2.19-en-hijacked.exe

Testing

cpu-z_2.19-en-hijacked.exe /HELP

Expected: a MessageBox with the title and text both reading "Hijacked", then the process exits. Normal execution (no /HELP) is unaffected.


How It Works — Detailed Explanation

1. The JMP Patch (0x004A131A)

The original instruction was 7 bytes:

C6 05 AC C4 4A 00 01     MOV byte [0x004AC4AC], 1

This writes a 1 into a flag byte — nothing special. When the Inno Setup installer later checks this flag, it shows the help dialog.

The replacement:

E9 E1 53 00 00     JMP 0x004A6700     ; 5 bytes
90                 NOP                 ; 1 byte (padding)
90                 NOP                 ; 1 byte (padding)

How JMP rel32 works: The E9 opcode encodes a near jump using a signed 32-bit offset relative to the address of the next instruction:

target = (address_of_JMP + 5) + rel32
       = 0x004A131A + 5 + 0x000053E1
       = 0x004A131F + 0x53E1
       = 0x004A6700   ✓

The two NOPs (0x90) are just padding — we have exactly 7 bytes to work with, the JMP takes 5, and the remaining 2 bytes cannot be left unfilled or the following instruction (INC ESI at 0x004A1321) would be misinterpreted.


2. The Shellcode (0x004A6700)

address     bytes           instruction           stack effect
--------    ------------    -------------------   -------------------------------
004A6700    6A 00           PUSH 0                ; [ESP] = MB_OK = 0
004A6702    68 1D 67 4A 00  PUSH 0x004A671D       ; [ESP] = &L"Hijacked"
004A6707    68 1D 67 4A 00  PUSH 0x004A671D       ; [ESP] = &L"Hijacked"
004A670C    6A 00           PUSH 0                ; [ESP] = NULL (no parent)
004A670E    E8 B9 A8 F6 FF  CALL 0x00410FCC       ; MessageBoxW(hWnd=NULL,
                                                  ;             lpText=&L"Hijacked",
                                                  ;             lpCaption=&L"Hijacked",
                                                  ;             uType=MB_OK)
004A6713    6A 00           PUSH 0                ; [ESP] = 0 (exit code)
004A6715    E8 DE EA F5 FF  CALL 0x004051F8       ; ExitProcess(0) — never returns
004A671D    48 00 69 00     L"Hijacked\0"          ; UTF-16LE null-terminated
           6A 00 63 00
           6B 00 65 00
           64 00 00 00

Why MessageBoxW and not MessageBoxA? Inno Setup is a Delphi application — it natively uses wide (UTF-16) strings. The binary already links MessageBoxW at 0x00410FCC via its delay-load import table. Using MessageBoxA would work too (at 0x004052D0), but since the binary's native string format is wide, MessageBoxW is the more natural choice.

Why two PUSHes of the same address? MessageBoxW follows the __stdcall calling convention (parameters pushed right-to-left):

int MessageBoxW(
  HWND    hWnd,       ← 4th push: NULL (0)
  LPCWSTR lpText,     ← 3rd push: &L"Hijacked"
  LPCWSTR lpCaption,  ← 2nd push: &L"Hijacked"
  UINT    uType       ← 1st push: MB_OK (0)
);

We push uType first (rightmost param), then lpCaption, then lpText, then hWnd. Both the title bar and body text point to the same wide string — so the popup says "Hijacked" in both places.

Why CALL and not JMP to ExitProcess? Either works. CALL pushes a return address onto the stack, but since ExitProcess terminates the process immediately, that return address is never popped. We use CALL for consistency.

The CALL rel32 calculations:

CALL MessageBoxW at 0x004A670E:
  next_instruction = 0x004A670E + 5 = 0x004A6713
  target           = 0x00410FCC  (MessageBoxW thunk)
  rel32            = target - next_instruction
                   = 0x00410FCC - 0x004A6713
                   = -607,047
                   = 0xFFF6A8B9  (signed 32-bit)

CALL ExitProcess at 0x004A6715:
  next_instruction = 0x004A6715 + 5 = 0x004A671A
  target           = 0x004051F8  (ExitProcess thunk)
  rel32            = 0x004051F8 - 0x004A671A
                   = -674,018
                   = 0xFFF5EADE  (signed 32-bit)

These relative offsets are baked into the binary and are position-independent — they work regardless of where Windows decides to load the EXE, since the offset is relative to the instruction pointer, not an absolute address.


3. Why the Code Cave Worked (PE VirtualSize)

When Windows loads a PE, it reads each section header to decide what to map:

.text section header:
  VirtualAddress   = 0x1000          (RVA → loaded at 0x00401000)
  VirtualSize      = 0xA568C         (how many bytes to map from the file)
  PointerToRawData = 0x400           (file offset where .text starts)
  SizeOfRawData    = 0xA5800         (bytes available in the file)

The key: VirtualSize tells the loader how many bytes to map. Even if the raw file has data beyond VirtualSize, those bytes are NOT mapped into memory. They're replaced with zeros by the Windows loader.

Our code cave is at RVA 0xA6700, which is 0xA6700 - 0x1000 = 0xA5700 bytes from the start of .text. The original VirtualSize is only 0xA568C, so bytes at offset 0xA5700 fall in the unmapped gap.

The fix: Increase VirtualSize from 0xA568C to 0xA5800 (matching SizeOfRawData). This is safe because:

  1. The page-aligned end of .text doesn't change (still 0xA7000)
  2. The next section (.itext) still starts at 0xA7000 with no overlap
  3. No section alignment constraints are violated

The VirtualSize field is at a specific offset in the PE section header:

section_header_start + 8  →  VirtualSize (DWORD)

We use struct.pack_into('<I', data, section_header_offset + 8, 0xA5800) to write the new value directly into the PE headers.


4. Where the First Attempt Went Wrong

The PE optional header has this layout (offsets from the start of the optional header):

Offset  Size  Field
------  ----  ----------------------------
0       2     Magic (0x10B = PE32)
2       2     Major/Minor LinkerVersion
4       4     SizeOfCode
8       4     SizeOfInitializedData
12      4     SizeOfUninitializedData
16      4     AddressOfEntryPoint
20      4     BaseOfCode
24      4     BaseOfData
28      4     ImageBase
32      4     SectionAlignment
36      4     FileAlignment
40      2     MajorOSVersion
42      2     MinorOSVersion
44      2     MajorImageVersion
46      2     MinorImageVersion
48      2     MajorSubsystemVersion
50      2     MinorSubsystemVersion
52      4     Win32VersionValue
56      4     SizeOfImage
60      4     SizeOfHeaders
64      4     CheckSum           ← HERE
68      2     Subsystem          ← NOT HERE
70      2     DllCharacteristics

The original buggy code:

checksum_off = coff + 88   # WRONG!
struct.pack_into('<I', data, checksum_off, 0)

coff is the COFF header offset (e_lfanew + 4). The optional header starts at coff + 20. So:

Expression Points to Field
coff + 20 + 64 coff + 84 CheckSum ← what we should write
coff + 88 coff + 20 + 68 Subsystem ← what we actually wrote

The struct.pack_into('<I', ...) writes a 32-bit integer in little-endian, which overwrites:

  • bytes at coff + 88 + 0..1 = Subsystem (set to 0)
  • bytes at coff + 88 + 2..3 = DllCharacteristics (set to 0)

Subsystem = 0 means IMAGE_SUBSYSTEM_UNKNOWN. When the Windows loader tries to start the executable, it checks the Subsystem field and sees "unknown" — it refuses to launch, producing the error:

The %1 application cannot be run in Win32 mode.

DllCharacteristics = 0 means DEP (Data Execution Prevention) is disabled, but the binary never gets far enough for that to matter — the Subsystem check happens first.

The fix: Change the offset from coff + 88 to coff + 20 + 64.

checksum_off = coff + 20 + 64   # CORRECT
struct.pack_into('<I', data, checksum_off, 0)

After the fix: Subsystem = 2 (IMAGE_SUBSYSTEM_WINDOWS_GUI), which tells Windows "this is a normal 32-bit GUI application — run it normally."


5. The Full Data Flow

cpu-z_2.19-en-hijacked.exe launched
  │
  │  Windows PE loader:
  │  ├─ Reads PE headers
  │  ├─ Maps .text into memory (now with extended VSize → shellcode included)
  │  ├─ Maps .itext, .data, .rsrc, etc.
  │  └─ Transfers control to entry point at 0x004A83BC
  │
  └─ entry (0x004A83BC)
       │
       ├─ InitDelphiRuntime
       │   ├─ GetModuleHandleW(NULL)     → g_dwHInstance
       │   ├─ DetectWineVersion          → g_dwWineDetected
       │   ├─ InitCpuidFeatures          → CPU feature detection
       │   └─ InitDelphiUnits            → Delphi RTL init table
       │
       ├─ DecryptCodeSections
       │   └─ VirtualQuery + VirtualProtect + TouchMemoryPage (LOCK; *p = *p)
       │       → in-place code decryption via page-fault mechanism
       │
       └─ ParseCmdLineOptions (0x004A1240)
            │
            │  For each token from GetCommandLineW():
            │    FUN_0049BEC0 → GetArgCount
            │   FUN_0049BF24 → GetExePath(idx, &token)
            │   FUN_0041C628 → StrCompW(token, switch_string)
            │
            ├── "/SP-" match?    → MOV byte [0x004AC4AD], 1
            │                       (suppress "This will install..." prompt)
            │
            ├── "/SPAWNWND=" prefix?
            │   └── Store parent window handle for modal behavior
            │
            ├── "/Lang=" prefix?
            │   └── Store language code in g_nCmdCustomValue (0x004B40AC)
            │
            ├── "/HELP" match?   ──────────────────────────────────────────┐
            │   Original:  C6 05 AC C4 4A 00 01  MOV byte [g_bHelpFlag], 1 │
            │   PATCHED:   E9 E1 53 00 00 90 90  JMP 0x004A6700            │
            │                                                              │
            │   ┌─ CODE CAVE at 0x004A6700:                                │
            │   │  6A 00           PUSH 0           ; MB_OK                │
            │   │  68 1D 67 4A 00  PUSH &L"Hi..."   ; lpCaption            │
            │   │  68 1D 67 4A 00  PUSH &L"Hi..."   ; lpText               │
            │   │  6A 00           PUSH 0           ; hWnd=NULL            │
            │   │  E8 B9 A8 F6 FF  CALL MessageBoxW                        │
            │   │  6A 00           PUSH 0           ; exit code            │
            │   │  E8 DE EA F5 FF  CALL ExitProcess                        │
            │   │                                       ── never returns ──┘
            │   └────────────────────────────────────────────────┘
            │
            └── "/?" match?
                └── MOV byte [g_bHelpFlag], 1   (unchanged — still works)

When running without /HELP, the ParseCmdLineOptions function never hits the patched branch — it matches other switches or falls through. The 7-byte JMP at 0x004A131A is never executed, MessageBoxW is never called, and the installer proceeds normally. The only difference from the original binary is the extended .text VSize in the PE header, which is harmless.


The Python Script

Full source (final version)

#!/usr/bin/env python3
"""
Hijack the /HELP command-line option in cpu-z_2.19-en.exe.

When launched with /HELP the patched binary pops a MessageBoxW("Hijacked")
and calls ExitProcess(0) instead of showing the Inno Setup help dialog.

What this does to the PE:
  1. Increases .text VirtualSize from 0xA568C -> 0xA5800 so the code cave
     at RVA 0xA6700 is mapped into memory.
  2. Writes ~26 bytes of x86 shellcode (MessageBoxW + ExitProcess) into
     the zero-filled padding at the end of .text.
  3. Writes the wide-char string "Hijacked" right after the shellcode.
  4. Replaces the 7-byte "MOV [flag],1" at the /HELP match site with a
     5-byte JMP to the code cave + 2 NOPs.

Usage:
    python patch_help_hijack.py [input_exe] [output_exe]
"""

import struct
import sys
from pathlib import Path

# ── PE constants ────────────────────────────────────────────────────
IMAGE_BASE      = 0x00400000

# Offsets within the binary (all RVAs)
RVA_PATCH_HELP  = 0x000A131A   # /HELP hit:  "MOV byte [0x4ac4ac], 1"
RVA_CAVE_SHELL  = 0x000A6700   # shellcode goes here (padding in .text)
RVA_CAVE_STRING = 0x000A671D   # "Hijacked\0" wide string

# Delay-load thunk addresses (stable across rebases)
VA_MESSAGE_BOX_W = 0x00410FCC   # MessageBoxW  (user32.dll)
VA_EXIT_PROCESS  = 0x004051F8   # ExitProcess   (kernel32.dll)

# PE section header field offsets
SZ_SECTION_HEADER       = 40
OFF_SECTION_NAME        = 0
OFF_SECTION_VSIZE       = 8
OFF_SECTION_RVA         = 12
OFF_SECTION_RAW_SIZE    = 16
OFF_SECTION_RAW_OFFSET  = 20


# ── Shellcode builder ────────────────────────────────────────────────
def build_shellcode(rva_shellcode: int, rva_string: int) -> bytes:
    """Build x86 shellcode: MessageBoxW + ExitProcess."""
    va_shell = IMAGE_BASE + rva_shellcode
    va_str   = IMAGE_BASE + rva_string
    ops = bytearray()

    # push uType = MB_OK
    ops += b'\x6A\x00'                                    # 2

    # push lpCaption
    ops += b'\x68' + struct.pack('<I', va_str)            # 5

    # push lpText
    ops += b'\x68' + struct.pack('<I', va_str)            # 5

    # push hWnd = NULL
    ops += b'\x6A\x00'                                    # 2

    # call MessageBoxW  (E8 + rel32)
    offset = VA_MESSAGE_BOX_W - (va_shell + len(ops) + 5)
    ops += b'\xE8' + struct.pack('<i', offset)            # 5

    # push uExitCode = 0
    ops += b'\x6A\x00'                                    # 2

    # call ExitProcess  (E8 + rel32)
    offset = VA_EXIT_PROCESS - (va_shell + len(ops) + 5)
    ops += b'\xE8' + struct.pack('<i', offset)            # 5

    return bytes(ops)


def build_hijack_string() -> bytes:
    return "Hijacked\\0".encode("utf-16-le")


def build_patch_jump(rva_shellcode: int) -> bytes:
    """5-byte JMP + 2 NOPs to replace the 7-byte flag-set."""
    va_patch = IMAGE_BASE + RVA_PATCH_HELP
    va_shell = IMAGE_BASE + rva_shellcode
    offset = va_shell - (va_patch + 5)
    return b'\xE9' + struct.pack('<i', offset) + b'\x90\x90'


# ── PE helpers ───────────────────────────────────────────────────────
def parse_pe(data: bytes):
    """Return (sections, e_lfanew, coff, opt_size) from PE headers."""
    e_lfanew = struct.unpack_from('<I', data, 0x3C)[0]
    sig = struct.unpack_from('<I', data, e_lfanew)[0]
    if sig != 0x00004550:
        raise ValueError("Not a valid PE32 file")

    coff = e_lfanew + 4
    num_sec = struct.unpack_from('<H', data, coff + 2)[0]
    magic   = struct.unpack_from('<H', data, coff + 20)[0]
    opt_size = 224 if magic == 0x10B else 240

    sec_start = coff + 20 + opt_size
    sections = []
    for i in range(num_sec):
        off = sec_start + i * SZ_SECTION_HEADER
        name  = data[off + OFF_SECTION_NAME : off + OFF_SECTION_NAME + 8]
        name  = name.rstrip(b'\x00').decode('ascii', errors='replace')
        vsize = struct.unpack_from('<I', data, off + OFF_SECTION_VSIZE)[0]
        rva   = struct.unpack_from('<I', data, off + OFF_SECTION_RVA)[0]
        rsz   = struct.unpack_from('<I', data, off + OFF_SECTION_RAW_SIZE)[0]
        raw   = struct.unpack_from('<I', data, off + OFF_SECTION_RAW_OFFSET)[0]
        sections.append((name, rva, raw, vsize, rsz, off))

    return sections, e_lfanew, coff, opt_size


def rva_to_file(rva: int, sections: list) -> int:
    """Map an RVA to a raw file offset."""
    for name, sec_rva, sec_raw, vsize, rsz, _ in sections:
        if sec_rva <= rva < sec_rva + vsize:
            return rva - sec_rva + sec_raw
    raise ValueError(f"RVA 0x{rva:X} is not mapped by any section")


# ── Main ─────────────────────────────────────────────────────────────
def main():
    input_path  = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("cpu-z_2.19-en.exe")
    output_path = Path(sys.argv[2]) if len(sys.argv) > 2 else Path("cpu-z_2.19-en-hijacked.exe")

    if not input_path.exists():
        print(f"ERROR: {input_path} not found")
        sys.exit(1)

    data = bytearray(input_path.read_bytes())
    print(f"[*] Read {len(data):,} bytes from {input_path}")

    sections, e_lfanew, coff, opt_size = parse_pe(bytes(data))

    # ── Find .text section and extend its VSize ─────────────────────
    text_sec = None
    for s in sections:
        if s[0] == '.text':
            text_sec = s
            break
    if text_sec is None:
        print("ERROR: .text section not found")
        sys.exit(1)

    name, sec_rva, sec_raw, vsize, rsz, sec_hdr_off = text_sec
    print(f"[*] .text  RVA=0x{sec_rva:X}  VSize=0x{vsize:X}  "
          f"RawOff=0x{sec_raw:X}  RawSz=0x{rsz:X}")

    needed_vsize = (RVA_CAVE_SHELL - sec_rva) + 48
    if needed_vsize <= rsz and needed_vsize > vsize:
        new_vsize = rsz
        print(f"[*] Growing .text VSize: 0x{vsize:X} -> 0x{new_vsize:X}")
        struct.pack_into('<I', data, sec_hdr_off + OFF_SECTION_VSIZE, new_vsize)
        for idx in range(len(sections)):
            if sections[idx][0] == '.text':
                name, rva, raw, _, rsz_sz, hdr = sections[idx]
                sections[idx] = (name, rva, raw, new_vsize, rsz_sz, hdr)
                break
        vsize = new_vsize
    elif needed_vsize > rsz:
        print(f"ERROR: need VSize >= 0x{needed_vsize:X} but RawSz=0x{rsz:X}")
        sys.exit(1)
    else:
        print(f"[*] .text VSize already sufficient (0x{vsize:X})")

    # ── Calculate file offsets ──────────────────────────────────────
    try:
        fo_cave_shell  = rva_to_file(RVA_CAVE_SHELL,  sections)
        fo_cave_string = rva_to_file(RVA_CAVE_STRING, sections)
        fo_patch_help  = rva_to_file(RVA_PATCH_HELP,  sections)
    except ValueError as e:
        print(f"ERROR: {e}")
        sys.exit(1)

    print(f"[*] Shellcode   -> file 0x{fo_cave_shell:X}  (RVA 0x{RVA_CAVE_SHELL:X})")
    print(f"[*] String      -> file 0x{fo_cave_string:X}  (RVA 0x{RVA_CAVE_STRING:X})")
    print(f"[*] /HELP patch -> file 0x{fo_patch_help:X}  (RVA 0x{RVA_PATCH_HELP:X})")

    # ── Build payloads ──────────────────────────────────────────────
    shellcode   = build_shellcode(RVA_CAVE_SHELL, RVA_CAVE_STRING)
    hijack_str  = build_hijack_string()
    patch_jmp   = build_patch_jump(RVA_CAVE_SHELL)

    print(f"\\n[*] Shellcode ({len(shellcode)} bytes):  {shellcode.hex(' ')}")
    print(f"[*] String    ({len(hijack_str)} bytes):  {hijack_str!r}")
    print(f"[*] JMP patch ({len(patch_jmp)} bytes):  {patch_jmp.hex(' ')}")

    # ── Write payloads ──────────────────────────────────────────────
    orig_patch = bytes(data[fo_patch_help : fo_patch_help + 7])
    print(f"\\n[*] Bytes at /HELP handler (before): {orig_patch.hex(' ')}")

    data[fo_cave_shell  : fo_cave_shell  + len(shellcode)]  = shellcode
    data[fo_cave_string : fo_cave_string + len(hijack_str)] = hijack_str
    data[fo_patch_help  : fo_patch_help  + len(patch_jmp)]  = patch_jmp

    # ── Zero the PE checksum (optional) ─────────────────────────────
    checksum_off = coff + 20 + 64   # CheckSum at offset 64 in optional header
    struct.pack_into('<I', data, checksum_off, 0)

    # ── Save ────────────────────────────────────────────────────────
    output_path.write_bytes(bytes(data))
    print(f"[*] Wrote patched file: {output_path}")
    print(f"[*] Run with: {output_path.name} /HELP")


if __name__ == "__main__":
    main()

The buggy version: what changed

The script initially had one wrong line that broke the patched binary:

# BUGGY (original):
checksum_off = coff + 88   # ← off by 4!

# FIXED:
checksum_off = coff + 20 + 64   # ← correct

How the bug was discovered

When the user ran the patched binary:

PS> cpu-z_2.19-en-hijacked.exe /HELP
ResourceUnavailable: Program 'cpu-z_2.19-en-hijacked.exe' failed to run:
An error occurred trying to start process [...]
The %1 application cannot be run in Win32 mode.

The error "cannot be run in Win32 mode" is specific and actionable — it means the PE has an invalid or zero Subsystem field. This immediately pointed to the checksum-zeroing code as the likely culprit since it was the only code touching the PE optional header besides the VSize patch.

To confirm, the PE optional header was dumped at the suspect offsets:

PE magic:    0x010B (PE32)
Subsystem:   0   ← should be 2 (WINDOWS_GUI)!
DllCharacteristics: 0x0000  ← should be 0x8140
CheckSum:    0x00000000

Subsystem = 0 = IMAGE_SUBSYSTEM_UNKNOWN — Windows refuses to launch. The checksum-zeroing code had landed 4 bytes past the CheckSum field, hitting Subsystem (offset 68) instead of CheckSum (offset 64).

The PE optional header layout reference

The relevant portion of the IMAGE_OPTIONAL_HEADER (PE32 format):

Offset  Size  Field
------  ----  ----------------------------
 0      2     Magic (0x10B = PE32)
 2      2     MajorLinkerVersion / MinorLinkerVersion
 4      4     SizeOfCode
 8      4     SizeOfInitializedData
12      4     SizeOfUninitializedData
16      4     AddressOfEntryPoint
20      4     BaseOfCode
24      4     BaseOfData
28      4     ImageBase
32      4     SectionAlignment
36      4     FileAlignment
40      2     MajorOperatingSystemVersion
42      2     MinorOperatingSystemVersion
44      2     MajorImageVersion
46      2     MinorImageVersion
48      2     MajorSubsystemVersion
50      2     MinorSubsystemVersion
52      4     Win32VersionValue
56      4     SizeOfImage
60      4     SizeOfHeaders
64      4     CheckSum            ← WHAT WE WANT
68      2     Subsystem           ← WHAT WE HIT
70      2     DllCharacteristics
72      4     SizeOfStackReserve
76      4     SizeOfStackCommit
...

The optional header starts at coff + 20 (the 20-byte IMAGE_FILE_HEADER comes first). So:

  • CheckSum is at coff + 20 + 64 = coff + 84
  • Subsystem is at coff + 20 + 68 = coff + 88

The original coff + 88 pointed to Subsystem. A 4-byte write there zeroed both Subsystem (2 bytes) and DllCharacteristics (2 bytes).

The fix logic

struct.pack_into('<I', data, checksum_off, 0) writes 0 as a 32-bit integer. The fix was simply moving the pointer back by 4 bytes:

checksum_off = coff + 20 + 64   # +20 for optional header base,
                                # +64 for CheckSum field within it

After the fix, only the CheckSum field (4 bytes) is zeroed, which is harmless — Windows checks it at load but doesn't reject mismatched checksums for non-signed executables.

Verification after fix

PE magic:         0x010B (OK)
Subsystem:        2 (IMAGE_SUBSYSTEM_WINDOWS_GUI)  ← restored
DllCharacteristics: 0x8140 (NX_COMPAT | DYNAMIC_BASE) ← restored
CheckSum:         0x00000000 (zeroed, harmless)
SizeOfImage:      0xCB000 (covers all sections)

The fix was verified by:

  1. Dumping the PE optional header fields at the correct offsets
  2. Verifying Subsystem = 2
  3. Verifying no section boundary violations (.text VSize increase did not exceed the page-aligned section boundary at 0xA7000)
  4. Re-importing the patched binary into Ghidra to confirm the shellcode disassembled correctly and all PE fields were valid
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment