skywalker-1/tools/wine_memdump.py
Ryan Malloy 4447d2c0e7 Extract firmware from official Genpix updater EXEs via Wine memory dump
Updater EXEs are packed (RWX sections, near-random entropy) with anti-debug
protection (IsDebuggerPresent/SoftICE check). Bypassed by running under plain
Wine and reading /proc/PID/mem with elevated privileges.

SW1 v2.13.x updater contains 3 firmware variants (likely .1/.2/.3):
  - All use LJMP 0x170D entry, 9322-9377 bytes, 10 C2 records each
  - FW2 vs FW3 differ by 1525 bytes (most similar pair)

Rev.2 v2.10.4 updater contains 1 firmware image:
  - PID=0x0202 (vs SW1's 0x0203), LJMP 0x155F, 8843 bytes, 9 C2 records

All images use standard Cypress C2 EEPROM format with entry at 0xE600 (CPUCS).
2026-02-11 06:05:13 -07:00

347 lines
13 KiB
Python

#!/usr/bin/env python3
"""
Run a Windows PE under Wine and dump its process memory after unpacking.
Launches the EXE, waits for it to unpack, then reads /proc/PID/mem
guided by /proc/PID/maps to capture the unpacked code and data sections.
Searches the dump for FX2 firmware signatures.
"""
import subprocess
import time
import os
import sys
import signal
import struct
import re
import glob
def find_wine_pid(exe_basename, timeout=10):
"""Find the Wine process PID by looking for the .exe in /proc."""
deadline = time.time() + timeout
while time.time() < deadline:
for pid_dir in glob.glob('/proc/[0-9]*'):
try:
cmdline = open(f'{pid_dir}/cmdline', 'rb').read()
if exe_basename.lower().encode() in cmdline.lower():
pid = int(os.path.basename(pid_dir))
# Skip if it's our own python process
if pid == os.getpid():
continue
return pid
except (PermissionError, FileNotFoundError, ProcessLookupError):
continue
time.sleep(0.2)
return None
def dump_process_memory(pid, output_dir):
"""Dump all readable memory regions of a process."""
maps_path = f'/proc/{pid}/maps'
mem_path = f'/proc/{pid}/mem'
regions = []
try:
with open(maps_path, 'r') as f:
for line in f:
parts = line.split()
addr_range = parts[0]
perms = parts[1]
# Only dump readable regions
if 'r' not in perms:
continue
start_s, end_s = addr_range.split('-')
start = int(start_s, 16)
end = int(end_s, 16)
size = end - start
# Skip huge regions (> 64MB) and tiny ones
if size > 64 * 1024 * 1024 or size < 64:
continue
pathname = parts[5].strip() if len(parts) > 5 else ""
regions.append((start, end, perms, pathname))
except (PermissionError, FileNotFoundError) as e:
print(f" Cannot read maps: {e}")
return None
print(f" Found {len(regions)} readable memory regions")
all_data = bytearray()
region_info = []
try:
with open(mem_path, 'rb') as mem:
for start, end, perms, pathname in regions:
size = end - start
try:
mem.seek(start)
chunk = mem.read(size)
offset_in_dump = len(all_data)
all_data.extend(chunk)
region_info.append({
'va_start': start,
'va_end': end,
'perms': perms,
'pathname': pathname,
'dump_offset': offset_in_dump,
'size': len(chunk)
})
except (OSError, ValueError):
pass
except PermissionError as e:
print(f" Cannot read mem: {e}")
print(" Try running with sudo or as the same user as Wine")
return None
# Save full dump
dump_file = os.path.join(output_dir, 'wine_memdump.bin')
with open(dump_file, 'wb') as f:
f.write(all_data)
print(f" Saved {len(all_data)} bytes to {dump_file}")
# Save region map
map_file = os.path.join(output_dir, 'wine_memdump_regions.txt')
with open(map_file, 'w') as f:
for r in region_info:
f.write(f"0x{r['va_start']:08X}-0x{r['va_end']:08X} "
f"{r['perms']:5s} dump_off=0x{r['dump_offset']:08X} "
f"size=0x{r['size']:06X} {r['pathname']}\n")
print(f" Saved region map to {map_file}")
return all_data, region_info
def search_firmware(data, region_info):
"""Search dumped memory for FX2 firmware signatures."""
print(f"\n{'=' * 50}")
print("Searching for firmware signatures...")
print(f"{'=' * 50}")
# 1. C2 EEPROM header with Genpix VID
print("\n[1] C2 EEPROM headers (C2 C0 09 03 02):")
c2_genpix = bytes([0xC2, 0xC0, 0x09, 0x03, 0x02])
pos = 0
while True:
idx = data.find(c2_genpix, pos)
if idx < 0:
break
region = find_region(region_info, idx)
ctx = bytes(data[idx:idx + 32])
print(f" 0x{idx:08X} (VA: {region}): {ctx.hex(' ')}")
# Parse the full C2 header
if idx + 8 <= len(data):
vid = data[idx + 1] | (data[idx + 2] << 8)
pid = data[idx + 3] | (data[idx + 4] << 8)
did = data[idx + 5] | (data[idx + 6] << 8)
config = data[idx + 7]
print(f" VID=0x{vid:04X} PID=0x{pid:04X} DID=0x{did:04X} Config=0x{config:02X}")
pos = idx + 1
# 2. FX2 RAM clear init sequence
print("\n[2] FX2 init sequence (78 7F E4 F6 D8 FD 75 81):")
fx2_init = bytes([0x78, 0x7F, 0xE4, 0xF6, 0xD8, 0xFD, 0x75, 0x81])
pos = 0
while True:
idx = data.find(fx2_init, pos)
if idx < 0:
break
region = find_region(region_info, idx)
ctx = bytes(data[max(0, idx - 4):idx + 16])
print(f" 0x{idx:08X} (VA: {region}): {ctx.hex(' ')}")
pos = idx + 1
# 3. Partial RAM clear pattern
print("\n[3] RAM clear pattern (78 7F E4 F6 D8 FD):")
ram_clear = bytes([0x78, 0x7F, 0xE4, 0xF6, 0xD8, 0xFD])
pos = 0
hits = 0
while True:
idx = data.find(ram_clear, pos)
if idx < 0:
break
region = find_region(region_info, idx)
ctx = bytes(data[max(0, idx - 4):idx + 16])
print(f" 0x{idx:08X} (VA: {region}): {ctx.hex(' ')}")
hits += 1
if hits >= 10:
break
pos = idx + 1
# 4. LJMP at what could be code address 0x0000 (start of firmware)
# Look for 02 XX XX where XX XX is 0x0100-0x3FFF
print("\n[4] C2 load records (LEN_H LEN_L 00 00 02 = record at addr 0x0000):")
for off in range(len(data) - 8):
rec_len = (data[off] << 8) | data[off + 1]
if 0x0100 <= rec_len <= 0x4000:
if data[off + 2] == 0x00 and data[off + 3] == 0x00 and data[off + 4] == 0x02:
target = (data[off + 5] << 8) | data[off + 6]
if 0x0100 <= target <= 0x3FFF:
# Check if this looks like a valid C2 record chain
region = find_region(region_info, off)
ctx = bytes(data[off:off + 16])
# Also check 8 bytes before for C2 header
has_c2_header = (off >= 8 and data[off - 8] == 0xC2)
header_note = " ** C2 HEADER 8 BYTES BEFORE! **" if has_c2_header else ""
print(f" 0x{off:08X} (VA: {region}): len={rec_len} "
f"addr=0x0000 LJMP 0x{target:04X} -- {ctx.hex(' ')}{header_note}")
# 5. Known VID/PID bytes near potential firmware data
print("\n[5] VID 0x09C0 references:")
vid_bytes = b'\xC0\x09'
pos = 0
hits = 0
while True:
idx = data.find(vid_bytes, pos)
if idx < 0:
break
# Check if followed by PID within 4 bytes
if idx + 4 < len(data):
nearby = data[idx:idx + 8]
if b'\x03\x02' in nearby:
region = find_region(region_info, idx)
ctx = bytes(data[max(0, idx - 4):idx + 16])
print(f" 0x{idx:08X} (VA: {region}): {ctx.hex(' ')}")
hits += 1
if hits >= 20:
break
pos = idx + 1
# 6. Search for the firmware version string "2.13"
print("\n[6] Version strings:")
for pattern in [b'2.13', b'2.06', b'2.10', b'SkyWalker', b'Genpix',
b'8PSK', b'EEPROM', b'firmware', b'I2C']:
pos = 0
while True:
idx = data.find(pattern, pos)
if idx < 0:
break
region = find_region(region_info, idx)
# Get surrounding context as ascii
start = max(0, idx - 16)
end = min(len(data), idx + 48)
ctx_bytes = bytes(data[start:end])
ctx_ascii = ctx_bytes.decode('ascii', errors='replace')
ctx_ascii = re.sub(r'[^\x20-\x7e]', '.', ctx_ascii)
print(f" 0x{idx:08X} (VA: {region}): '{ctx_ascii}'")
pos = idx + 1
# 7. Look for USB vendor request setup patterns
# The updater will set bRequest=0x83 (I2C_WRITE) or 0xA0 to write firmware
print("\n[7] USB transfer setup (IOCTL/vendor request patterns):")
# WinUSB_ControlTransfer uses WINUSB_SETUP_PACKET:
# RequestType(1), Request(1), Value(2), Index(2), Length(2)
# For vendor OUT: RequestType=0x40, Request=0x83/0xA0
for req_type, req, desc in [(0x40, 0xA0, "FX2 RAM write"),
(0x40, 0x83, "I2C_WRITE"),
(0x40, 0x84, "I2C_READ")]:
pattern = bytes([req_type, req])
pos = 0
hits_count = 0
while True:
idx = data.find(pattern, pos)
if idx < 0:
break
# Check if followed by reasonable wValue/wIndex
if idx + 8 <= len(data):
wval = struct.unpack_from('<H', data, idx + 2)[0]
widx = struct.unpack_from('<H', data, idx + 4)[0]
wlen = struct.unpack_from('<H', data, idx + 6)[0]
if wlen > 0 and wlen < 0x4000:
region = find_region(region_info, idx)
print(f" 0x{idx:08X} ({desc}): "
f"ReqType=0x{req_type:02X} Req=0x{req:02X} "
f"wVal=0x{wval:04X} wIdx=0x{widx:04X} wLen=0x{wlen:04X} "
f"(VA: {region})")
hits_count += 1
if hits_count >= 10:
break
pos = idx + 1
def find_region(region_info, dump_offset):
"""Find the VA region for a given dump offset."""
for r in region_info:
if r['dump_offset'] <= dump_offset < r['dump_offset'] + r['size']:
va = r['va_start'] + (dump_offset - r['dump_offset'])
return f"0x{va:08X} [{r['pathname'] or 'anon'}]"
return "unknown"
def main():
import argparse
parser = argparse.ArgumentParser(description="Wine memory dump for firmware extraction")
parser.add_argument('exe', help='Windows PE executable to run under Wine')
parser.add_argument('-o', '--output-dir', default='.',
help='Output directory for dumps')
parser.add_argument('--wait', type=float, default=3.0,
help='Seconds to wait after launch for unpacking (default: 3)')
parser.add_argument('--skip-launch', action='store_true',
help='Skip launching Wine, just attach to existing process')
args = parser.parse_args()
exe_path = os.path.abspath(args.exe)
exe_basename = os.path.basename(exe_path)
os.makedirs(args.output_dir, exist_ok=True)
wine_proc = None
pid = None
if not args.skip_launch:
print(f"Launching {exe_basename} under Wine...")
# Use WINEDEBUG=-all to reduce noise
env = os.environ.copy()
env['WINEDEBUG'] = '-all'
wine_proc = subprocess.Popen(
['wine', exe_path],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=env
)
print(f" Wine wrapper PID: {wine_proc.pid}")
# Wait for the actual .exe process to appear
print(f" Waiting {args.wait}s for unpacking...")
time.sleep(args.wait)
# Find the Windows process PID
print(f" Looking for {exe_basename} process...")
pid = find_wine_pid(exe_basename, timeout=5)
if pid is None:
# Try looking for wine-preloader or wine64-preloader
print(" Couldn't find by exe name, searching all wine processes...")
for pid_dir in glob.glob('/proc/[0-9]*'):
try:
cmdline = open(f'{pid_dir}/cmdline', 'rb').read()
if b'wine' in cmdline.lower() and pid_dir != f'/proc/{os.getpid()}':
p = int(os.path.basename(pid_dir))
if wine_proc and p == wine_proc.pid:
continue
print(f" Found wine process PID {p}: {cmdline[:100]}")
except:
pass
if pid is None and wine_proc:
pid = wine_proc.pid
print(f" Using Wine wrapper PID: {pid}")
if pid:
print(f"\n Target PID: {pid}")
result = dump_process_memory(pid, args.output_dir)
if result:
data, region_info = result
search_firmware(data, region_info)
else:
print(" ERROR: Could not find process")
# Cleanup
if wine_proc:
print("\nTerminating Wine process...")
try:
wine_proc.terminate()
wine_proc.wait(timeout=5)
except:
wine_proc.kill()
if __name__ == '__main__':
main()