Skip to content

Commit 4571d56

Browse files
ci: Patch VC6 game executable with NXCOMPAT to enhance security
1 parent 7ce6eeb commit 4571d56

2 files changed

Lines changed: 109 additions & 0 deletions

File tree

.github/workflows/build-toolchain.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,16 @@ jobs:
180180
run: |
181181
cmake --build --preset ${{ inputs.preset }}
182182
183+
- name: Patch VC6 game executable with NXCOMPAT
184+
if: startsWith(inputs.preset, 'vc6')
185+
shell: pwsh
186+
run: |
187+
$buildDir = "build\${{ inputs.preset }}"
188+
$targetExe = "${{ inputs.game == 'Generals' && 'generalsv.exe' || 'generalszh.exe' }}"
189+
$targetPath = "$buildDir\${{ inputs.game }}\$targetExe"
190+
191+
python scripts/pe_set_nxcompat.py $targetPath
192+
183193
- name: Save vcpkg binary cache
184194
# Only one job should save to avoid "Unable to reserve cache" conflicts.
185195
if: ${{ startsWith(inputs.preset, 'win32') && steps.vcpkg_cache.outputs.cache-hit != 'true' && inputs.game == 'Generals' && inputs.preset == 'win32-vcpkg-debug' }}

scripts/pe_set_nxcompat.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Set NX compatibility bit (IMAGE_DLLCHARACTERISTICS_NX_COMPAT) in PE files.
4+
5+
Usage:
6+
python scripts/pe_set_nxcompat.py <file> [<file> ...]
7+
python scripts/pe_set_nxcompat.py --dry-run <file> [<file> ...]
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import argparse
13+
import struct
14+
import sys
15+
from pathlib import Path
16+
17+
18+
PE_SIGNATURE = b"PE\x00\x00"
19+
PE32_MAGIC = 0x10B
20+
PE32_PLUS_MAGIC = 0x20B
21+
DLL_CHARACTERISTICS_OFFSET = 0x46
22+
NX_COMPAT_BIT = 0x0100
23+
24+
25+
def set_nx_compat(path: Path, dry_run: bool) -> tuple[bool, bool]:
26+
data = bytearray(path.read_bytes())
27+
28+
if len(data) < 0x40 or data[0:2] != b"MZ":
29+
raise ValueError("not a valid MZ executable")
30+
31+
pe_offset = struct.unpack_from("<I", data, 0x3C)[0]
32+
if pe_offset + 24 > len(data):
33+
raise ValueError("invalid PE header offset")
34+
35+
if data[pe_offset:pe_offset + 4] != PE_SIGNATURE:
36+
raise ValueError("missing PE signature")
37+
38+
coff_header_offset = pe_offset + 4
39+
optional_header_size = struct.unpack_from("<H", data, coff_header_offset + 16)[0]
40+
optional_header_offset = coff_header_offset + 20
41+
if optional_header_offset + optional_header_size > len(data):
42+
raise ValueError("truncated optional header")
43+
if optional_header_size <= DLL_CHARACTERISTICS_OFFSET + 1:
44+
raise ValueError("optional header is too small for DllCharacteristics")
45+
46+
magic = struct.unpack_from("<H", data, optional_header_offset)[0]
47+
if magic not in (PE32_MAGIC, PE32_PLUS_MAGIC):
48+
raise ValueError(f"unsupported optional header magic: 0x{magic:04X}")
49+
50+
dll_char_offset = optional_header_offset + DLL_CHARACTERISTICS_OFFSET
51+
old_value = struct.unpack_from("<H", data, dll_char_offset)[0]
52+
new_value = old_value | NX_COMPAT_BIT
53+
changed = new_value != old_value
54+
55+
if changed and not dry_run:
56+
struct.pack_into("<H", data, dll_char_offset, new_value)
57+
path.write_bytes(data)
58+
59+
return changed, bool(new_value & NX_COMPAT_BIT)
60+
61+
62+
def main() -> int:
63+
parser = argparse.ArgumentParser(
64+
description="Set the PE NX compatibility bit (DllCharacteristics 0x0100)."
65+
)
66+
parser.add_argument("files", nargs="+", help="PE files (.exe/.dll) to patch")
67+
parser.add_argument(
68+
"--dry-run",
69+
action="store_true",
70+
help="Validate and report changes without modifying files",
71+
)
72+
args = parser.parse_args()
73+
74+
had_error = False
75+
changed_count = 0
76+
77+
for file_name in args.files:
78+
path = Path(file_name)
79+
if not path.is_file():
80+
print(f"[ERROR] {path}: file not found", file=sys.stderr)
81+
had_error = True
82+
continue
83+
84+
try:
85+
changed, nx_enabled = set_nx_compat(path, args.dry_run)
86+
if changed:
87+
changed_count += 1
88+
status = "patched" if changed and not args.dry_run else "would-patch" if changed else "already-set"
89+
print(f"[{status}] {path} (nx_compat={'on' if nx_enabled else 'off'})")
90+
except Exception as exc:
91+
print(f"[ERROR] {path}: {exc}", file=sys.stderr)
92+
had_error = True
93+
94+
print(f"Processed {len(args.files)} file(s), updated {changed_count}.")
95+
return 1 if had_error else 0
96+
97+
98+
if __name__ == "__main__":
99+
raise SystemExit(main())

0 commit comments

Comments
 (0)