Brought to you by DeepSeek V4 Flash via Claude Code CLI, analyzing with Ghidra and ghidra-mcp.
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.
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 |
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 (0x004a12f8 – 0x004a131a):
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 0x4a126dTarget: 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.
| 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 |
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
...
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.
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
Before: C6 05 AC C4 4A 00 01 MOV byte [0x4AC4AC], 1
After: E9 E1 53 00 00 90 90 JMP 0x004A6700 ; NOP ; NOP
| 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.
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.
| 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.
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)
| File Offset | RVA | Bytes Changed |
|---|---|---|
| PE header + VSize field | 0xA568C → 0xA5800 |
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 |
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
| 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 |
python patch_help_hijack.py cpu-z_2.19-en.exe cpu-z_2.19-en-hijacked.execpu-z_2.19-en-hijacked.exe /HELPExpected: a MessageBox with the title and text both reading "Hijacked", then
the process exits. Normal execution (no /HELP) is unaffected.
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.
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.
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:
- The page-aligned end of
.textdoesn't change (still0xA7000) - The next section (
.itext) still starts at0xA7000with no overlap - 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.
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."
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.
#!/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 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 # ← correctWhen 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 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).
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.
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:
- Dumping the PE optional header fields at the correct offsets
- Verifying Subsystem = 2
- Verifying no section boundary violations (
.textVSize increase did not exceed the page-aligned section boundary at0xA7000) - Re-importing the patched binary into Ghidra to confirm the shellcode disassembled correctly and all PE fields were valid