September 13, 2026
PwnSec CTF 2026 (Zigerions[ Hard Rev])
Hello everyone Itβs Omar Goda aka ( G0daPwN.exe ), long time no see cuz of military lmao

By G0daPwN.exe
12 min read
- 1 First of all let's see what DIE would say about this file:
- 2 After running the script we will find the original PE file
- 3 First of all let's put everything together and plan ahead first before unpacking:
- 4 There is an easier way we can find vmprotect's section mapping table or packer info and used it
- 5 THAT'S IT WHAT WE ARE LOOKING FOR
This is my authored challenge in PwnSec CTF 2026, I hope you enjoyed and learned something new from my challenge
I would love to thank all contestants specially the human teams you did great depending only on your mind and skills
And I'm giving credits too to all authors, admins and leaders for this great event
Let's begin with the challenge
First of all let's see what DIE would say about this file:
looks like a normal ELF File written and compiled in C and not packed as well after looking at the entropy:
Now Let's open IDA and see what does it actually do
Now I'm gonna dig more in the main function
As you can notice it creates a directory and if it did that successfully it writes a payload to a file called ( update.exe ) and if not it outputs this:
So the payload seems to be a PE32 File and it goes in the [ /.cache/.icons/.hidden ] directory
As you can see here:
It calls the _IO_fwrite function to write the payload into update.exe and if you checked the payload in the heap data you will find this:
It matches with the PE magic header ( MZ ) so it's most likely an exe file
when running the elf file it prints the following statement and writes the payload in the update.exe and puts it in the directory [ /.cache/.icons/.hidden ]
After getting the exe file we extracted let's dig into IDA and see the extracted exe inside
When using SameBoy it seems like a ROM gameboy game:
Looks like it's Pyinstaller packaging and now we gotta get the original exe out of this using this script It extracts:
- The PyInstaller CArchive
- The nested PYZ archive where possible
- The custom payload after <<<PAYLOAD_START>>>
import argparse
import marshal
import pathlib
import struct
import sys
import zlib
PYI_MAGIC = b"MEI\014\013\012\013\016"
PAYLOAD_MARKER = b"<<<PAYLOAD_START>>>"
XOR_KEY = [165, 60, 255, 0, 85, 170]
def safe_join(root: pathlib.Path, name: str) -> pathlib.Path:
name = name.replace("\\", "/").lstrip("/")
parts = [
p
for p in name.split("/")
if p not in ("", ".", "..")
]
return root.joinpath(*parts)
def read_at(f, offset: int, size: int) -> bytes:
f.seek(offset)
data = f.read(size)
if len(data) != size:
raise EOFError(f"short read at {offset:#x}")
return data
def pyc_header(pyver: int) -> bytes:
magic_by_ver = {
310: bytes.fromhex("6f0d0d0a"),
311: bytes.fromhex("a70d0d0a"),
312: bytes.fromhex("cb0d0d0a"),
}
return (
magic_by_ver.get(
pyver,
bytes.fromhex("6f0d0d0a"),
)
+ b"\0" * 12
)
def parse_pyinstaller_cookie(
f,
cookie_pos: int,
) -> dict:
raw = read_at(f, cookie_pos, 88)
magic, pkg_len, toc_offset, toc_len, pyver, pylib = struct.unpack(
"!8siiii64s",
raw,
)
if magic != PYI_MAGIC:
raise ValueError("invalid PyInstaller cookie")
return {
"cookie_pos": cookie_pos,
"pkg_len": pkg_len,
"toc_offset": toc_offset,
"toc_len": toc_len,
"pyver": pyver,
"pylib": pylib.split(b"\0", 1)[0].decode(
"utf-8",
"replace",
),
"archive_start": cookie_pos + 88 - pkg_len,
}
def parse_pyinstaller_toc(
f,
meta: dict,
) -> list[dict]:
entries = []
pos = (
meta["archive_start"]
+ meta["toc_offset"]
)
end = pos + meta["toc_len"]
while pos < end:
entry_size = struct.unpack(
"!i",
read_at(f, pos, 4),
)[0]
raw = read_at(
f,
pos,
entry_size,
)
(
entry_pos,
comp_size,
uncomp_size,
comp_flag,
typecode,
) = struct.unpack(
"!iiiBc",
raw[4:18],
)
name = raw[18:].split(
b"\0",
1,
)[0].decode(
"utf-8",
"replace",
)
entries.append(
{
"name": name,
"pos": entry_pos,
"comp_size": comp_size,
"uncomp_size": uncomp_size,
"comp_flag": comp_flag,
"typecode": typecode.decode("latin1"),
}
)
pos += entry_size
return entries
def extract_pyz(
pyz_file: pathlib.Path,
out_dir: pathlib.Path,
pyver: int,
) -> list[tuple]:
data = pyz_file.read_bytes()
if not data.startswith(b"PYZ\0"):
return []
out_dir.mkdir(
parents=True,
exist_ok=True,
)
toc_offset = struct.unpack(
"!i",
data[8:12],
)[0]
toc = marshal.loads(
data[toc_offset:]
)
items = (
toc.items()
if isinstance(toc, dict)
else toc
)
header = pyc_header(pyver)
extracted = []
for name, value in items:
try:
typecode, pos, length = value[:3]
blob = zlib.decompress(
data[pos:pos + length]
)
rel = str(name).replace(
".",
"/",
)
suffix = (
".pyc"
if typecode in (0, 1, 3)
else ".bin"
)
dest = safe_join(
out_dir,
rel + suffix,
)
dest.parent.mkdir(
parents=True,
exist_ok=True,
)
if (
suffix == ".pyc"
and not blob.startswith(header[:4])
):
blob = header + blob
dest.write_bytes(blob)
extracted.append(
(
name,
typecode,
str(dest),
)
)
except Exception as exc:
extracted.append(
(
name,
"error",
str(exc),
)
)
return extracted
def extract_pyinstaller(
exe: pathlib.Path,
out_dir: pathlib.Path,
) -> tuple[dict, list[dict], list[tuple]]:
data = exe.read_bytes()
cookie_pos = data.rfind(PYI_MAGIC)
if cookie_pos < 0:
raise ValueError(
"PyInstaller cookie not found"
)
out_dir.mkdir(
parents=True,
exist_ok=True,
)
with exe.open("rb") as f:
meta = parse_pyinstaller_cookie(
f,
cookie_pos,
)
entries = parse_pyinstaller_toc(
f,
meta,
)
manifest = []
for entry in entries:
blob = read_at(
f,
meta["archive_start"] + entry["pos"],
entry["comp_size"],
)
method = "stored"
if entry["comp_flag"]:
blob = zlib.decompress(blob)
method = "zlib"
dest = safe_join(
out_dir,
entry["name"],
)
dest.parent.mkdir(
parents=True,
exist_ok=True,
)
dest.write_bytes(blob)
manifest.append(
{
"name": entry["name"],
"type": entry["typecode"],
"method": method,
"compressed": entry["comp_size"],
"uncompressed": len(blob),
"path": str(dest),
}
)
trailing_start = cookie_pos + 88
trailing = data[trailing_start:]
meta["trailing_start"] = trailing_start
meta["trailing_len"] = len(trailing)
if trailing:
(
out_dir
/ "TRAILING_AFTER_PYINSTALLER.bin"
).write_bytes(trailing)
pyz_results = []
for item in manifest:
if (
item["type"] == "z"
or item["name"].endswith(".pyz")
):
pyz_results.extend(
extract_pyz(
pathlib.Path(item["path"]),
out_dir
/ (
pathlib.Path(
item["path"]
).name
+ "_extracted"
),
meta["pyver"],
)
)
return (
meta,
manifest,
pyz_results,
)
def extract_custom_payload(
exe: pathlib.Path,
out_dir: pathlib.Path,
) -> dict | None:
data = exe.read_bytes()
marker_pos = data.find(
PAYLOAD_MARKER
)
if marker_pos < 0:
return None
rest = data[
marker_pos + len(PAYLOAD_MARKER):
]
if len(rest) < 4:
raise ValueError(
"payload marker found, "
"but length field is missing"
)
payload_len = struct.unpack(
"<I",
rest[:4],
)[0]
scrambled = rest[
4:4 + payload_len
]
if len(scrambled) != payload_len:
raise ValueError(
f"truncated payload: "
f"got {len(scrambled)}, "
f"expected {payload_len}"
)
decoded = bytes(
b ^ XOR_KEY[i % len(XOR_KEY)]
for i, b in enumerate(scrambled)
)[::-1]
raw_path = (
out_dir
/ "CUSTOM_PAYLOAD_AFTER_MARKER.bin"
)
decoded_path = (
out_dir
/ "DECODED_INNER_PAYLOAD.exe"
)
raw_path.write_bytes(rest)
decoded_path.write_bytes(decoded)
return {
"marker_offset": marker_pos,
"payload_len": payload_len,
"decoded_path": str(decoded_path),
"raw_path": str(raw_path),
"ignored_trailing_after_payload": (
len(rest) - 4 - payload_len
),
}
def write_manifest(
out_dir: pathlib.Path,
source: pathlib.Path,
meta: dict,
manifest: list[dict],
pyz_results: list[tuple],
payload: dict | None,
) -> None:
manifest_path = (
out_dir / "UNPACK_MANIFEST.txt"
)
with manifest_path.open(
"w",
encoding="utf-8",
) as f:
f.write(
f"source={source}\n"
)
for key in (
"cookie_pos",
"pkg_len",
"toc_offset",
"toc_len",
"pyver",
"pylib",
"archive_start",
"trailing_start",
"trailing_len",
):
f.write(
f"{key}={meta[key]}\n"
)
if payload:
f.write(
"\n[custom payload]\n"
)
for key, value in payload.items():
f.write(
f"{key}={value}\n"
)
f.write(
"\n[PyInstaller CArchive entries]\n"
)
for item in manifest:
f.write(
f"{item['type']}\t"
f"{item['method']}\t"
f"{item['uncompressed']}\t"
f"{item['name']}\t"
f"{item['path']}\n"
)
f.write(
"\n[PYZ entries]\n"
)
for name, typecode, path in pyz_results:
f.write(
f"{typecode}\t"
f"{name}\t"
f"{path}\n"
)
def main() -> int:
parser = argparse.ArgumentParser(
description=(
"Statically unpack this "
"PyInstaller wrapper sample."
)
)
parser.add_argument(
"exe",
type=pathlib.Path,
help="Path to update.exe",
)
parser.add_argument(
"-o",
"--out-dir",
type=pathlib.Path,
default=pathlib.Path("unpacked"),
help=(
"Output directory, "
"default: ./unpacked"
),
)
args = parser.parse_args()
meta, manifest, pyz_results = (
extract_pyinstaller(
args.exe,
args.out_dir,
)
)
payload = extract_custom_payload(
args.exe,
args.out_dir,
)
write_manifest(
args.out_dir,
args.exe,
meta,
manifest,
pyz_results,
payload,
)
print(
f"Extracted {len(manifest)} "
"PyInstaller entries"
)
print(
f"Extracted "
f"{len([x for x in pyz_results if x[1] != 'error'])} "
"PYZ entries"
)
if payload:
print(
"Decoded inner payload: "
f"{payload['decoded_path']}"
)
print(
"Manifest: "
f"{args.out_dir / 'UNPACK_MANIFEST.txt'}"
)
return 0
if __name__ == "__main__":
sys.exit(main())import argparse
import marshal
import pathlib
import struct
import sys
import zlib
PYI_MAGIC = b"MEI\014\013\012\013\016"
PAYLOAD_MARKER = b"<<<PAYLOAD_START>>>"
XOR_KEY = [165, 60, 255, 0, 85, 170]
def safe_join(root: pathlib.Path, name: str) -> pathlib.Path:
name = name.replace("\\", "/").lstrip("/")
parts = [
p
for p in name.split("/")
if p not in ("", ".", "..")
]
return root.joinpath(*parts)
def read_at(f, offset: int, size: int) -> bytes:
f.seek(offset)
data = f.read(size)
if len(data) != size:
raise EOFError(f"short read at {offset:#x}")
return data
def pyc_header(pyver: int) -> bytes:
magic_by_ver = {
310: bytes.fromhex("6f0d0d0a"),
311: bytes.fromhex("a70d0d0a"),
312: bytes.fromhex("cb0d0d0a"),
}
return (
magic_by_ver.get(
pyver,
bytes.fromhex("6f0d0d0a"),
)
+ b"\0" * 12
)
def parse_pyinstaller_cookie(
f,
cookie_pos: int,
) -> dict:
raw = read_at(f, cookie_pos, 88)
magic, pkg_len, toc_offset, toc_len, pyver, pylib = struct.unpack(
"!8siiii64s",
raw,
)
if magic != PYI_MAGIC:
raise ValueError("invalid PyInstaller cookie")
return {
"cookie_pos": cookie_pos,
"pkg_len": pkg_len,
"toc_offset": toc_offset,
"toc_len": toc_len,
"pyver": pyver,
"pylib": pylib.split(b"\0", 1)[0].decode(
"utf-8",
"replace",
),
"archive_start": cookie_pos + 88 - pkg_len,
}
def parse_pyinstaller_toc(
f,
meta: dict,
) -> list[dict]:
entries = []
pos = (
meta["archive_start"]
+ meta["toc_offset"]
)
end = pos + meta["toc_len"]
while pos < end:
entry_size = struct.unpack(
"!i",
read_at(f, pos, 4),
)[0]
raw = read_at(
f,
pos,
entry_size,
)
(
entry_pos,
comp_size,
uncomp_size,
comp_flag,
typecode,
) = struct.unpack(
"!iiiBc",
raw[4:18],
)
name = raw[18:].split(
b"\0",
1,
)[0].decode(
"utf-8",
"replace",
)
entries.append(
{
"name": name,
"pos": entry_pos,
"comp_size": comp_size,
"uncomp_size": uncomp_size,
"comp_flag": comp_flag,
"typecode": typecode.decode("latin1"),
}
)
pos += entry_size
return entries
def extract_pyz(
pyz_file: pathlib.Path,
out_dir: pathlib.Path,
pyver: int,
) -> list[tuple]:
data = pyz_file.read_bytes()
if not data.startswith(b"PYZ\0"):
return []
out_dir.mkdir(
parents=True,
exist_ok=True,
)
toc_offset = struct.unpack(
"!i",
data[8:12],
)[0]
toc = marshal.loads(
data[toc_offset:]
)
items = (
toc.items()
if isinstance(toc, dict)
else toc
)
header = pyc_header(pyver)
extracted = []
for name, value in items:
try:
typecode, pos, length = value[:3]
blob = zlib.decompress(
data[pos:pos + length]
)
rel = str(name).replace(
".",
"/",
)
suffix = (
".pyc"
if typecode in (0, 1, 3)
else ".bin"
)
dest = safe_join(
out_dir,
rel + suffix,
)
dest.parent.mkdir(
parents=True,
exist_ok=True,
)
if (
suffix == ".pyc"
and not blob.startswith(header[:4])
):
blob = header + blob
dest.write_bytes(blob)
extracted.append(
(
name,
typecode,
str(dest),
)
)
except Exception as exc:
extracted.append(
(
name,
"error",
str(exc),
)
)
return extracted
def extract_pyinstaller(
exe: pathlib.Path,
out_dir: pathlib.Path,
) -> tuple[dict, list[dict], list[tuple]]:
data = exe.read_bytes()
cookie_pos = data.rfind(PYI_MAGIC)
if cookie_pos < 0:
raise ValueError(
"PyInstaller cookie not found"
)
out_dir.mkdir(
parents=True,
exist_ok=True,
)
with exe.open("rb") as f:
meta = parse_pyinstaller_cookie(
f,
cookie_pos,
)
entries = parse_pyinstaller_toc(
f,
meta,
)
manifest = []
for entry in entries:
blob = read_at(
f,
meta["archive_start"] + entry["pos"],
entry["comp_size"],
)
method = "stored"
if entry["comp_flag"]:
blob = zlib.decompress(blob)
method = "zlib"
dest = safe_join(
out_dir,
entry["name"],
)
dest.parent.mkdir(
parents=True,
exist_ok=True,
)
dest.write_bytes(blob)
manifest.append(
{
"name": entry["name"],
"type": entry["typecode"],
"method": method,
"compressed": entry["comp_size"],
"uncompressed": len(blob),
"path": str(dest),
}
)
trailing_start = cookie_pos + 88
trailing = data[trailing_start:]
meta["trailing_start"] = trailing_start
meta["trailing_len"] = len(trailing)
if trailing:
(
out_dir
/ "TRAILING_AFTER_PYINSTALLER.bin"
).write_bytes(trailing)
pyz_results = []
for item in manifest:
if (
item["type"] == "z"
or item["name"].endswith(".pyz")
):
pyz_results.extend(
extract_pyz(
pathlib.Path(item["path"]),
out_dir
/ (
pathlib.Path(
item["path"]
).name
+ "_extracted"
),
meta["pyver"],
)
)
return (
meta,
manifest,
pyz_results,
)
def extract_custom_payload(
exe: pathlib.Path,
out_dir: pathlib.Path,
) -> dict | None:
data = exe.read_bytes()
marker_pos = data.find(
PAYLOAD_MARKER
)
if marker_pos < 0:
return None
rest = data[
marker_pos + len(PAYLOAD_MARKER):
]
if len(rest) < 4:
raise ValueError(
"payload marker found, "
"but length field is missing"
)
payload_len = struct.unpack(
"<I",
rest[:4],
)[0]
scrambled = rest[
4:4 + payload_len
]
if len(scrambled) != payload_len:
raise ValueError(
f"truncated payload: "
f"got {len(scrambled)}, "
f"expected {payload_len}"
)
decoded = bytes(
b ^ XOR_KEY[i % len(XOR_KEY)]
for i, b in enumerate(scrambled)
)[::-1]
raw_path = (
out_dir
/ "CUSTOM_PAYLOAD_AFTER_MARKER.bin"
)
decoded_path = (
out_dir
/ "DECODED_INNER_PAYLOAD.exe"
)
raw_path.write_bytes(rest)
decoded_path.write_bytes(decoded)
return {
"marker_offset": marker_pos,
"payload_len": payload_len,
"decoded_path": str(decoded_path),
"raw_path": str(raw_path),
"ignored_trailing_after_payload": (
len(rest) - 4 - payload_len
),
}
def write_manifest(
out_dir: pathlib.Path,
source: pathlib.Path,
meta: dict,
manifest: list[dict],
pyz_results: list[tuple],
payload: dict | None,
) -> None:
manifest_path = (
out_dir / "UNPACK_MANIFEST.txt"
)
with manifest_path.open(
"w",
encoding="utf-8",
) as f:
f.write(
f"source={source}\n"
)
for key in (
"cookie_pos",
"pkg_len",
"toc_offset",
"toc_len",
"pyver",
"pylib",
"archive_start",
"trailing_start",
"trailing_len",
):
f.write(
f"{key}={meta[key]}\n"
)
if payload:
f.write(
"\n[custom payload]\n"
)
for key, value in payload.items():
f.write(
f"{key}={value}\n"
)
f.write(
"\n[PyInstaller CArchive entries]\n"
)
for item in manifest:
f.write(
f"{item['type']}\t"
f"{item['method']}\t"
f"{item['uncompressed']}\t"
f"{item['name']}\t"
f"{item['path']}\n"
)
f.write(
"\n[PYZ entries]\n"
)
for name, typecode, path in pyz_results:
f.write(
f"{typecode}\t"
f"{name}\t"
f"{path}\n"
)
def main() -> int:
parser = argparse.ArgumentParser(
description=(
"Statically unpack this "
"PyInstaller wrapper sample."
)
)
parser.add_argument(
"exe",
type=pathlib.Path,
help="Path to update.exe",
)
parser.add_argument(
"-o",
"--out-dir",
type=pathlib.Path,
default=pathlib.Path("unpacked"),
help=(
"Output directory, "
"default: ./unpacked"
),
)
args = parser.parse_args()
meta, manifest, pyz_results = (
extract_pyinstaller(
args.exe,
args.out_dir,
)
)
payload = extract_custom_payload(
args.exe,
args.out_dir,
)
write_manifest(
args.out_dir,
args.exe,
meta,
manifest,
pyz_results,
payload,
)
print(
f"Extracted {len(manifest)} "
"PyInstaller entries"
)
print(
f"Extracted "
f"{len([x for x in pyz_results if x[1] != 'error'])} "
"PYZ entries"
)
if payload:
print(
"Decoded inner payload: "
f"{payload['decoded_path']}"
)
print(
"Manifest: "
f"{args.out_dir / 'UNPACK_MANIFEST.txt'}"
)
return 0
if __name__ == "__main__":
sys.exit(main())After running the script we will find the original PE file
After scanning DECODED.exe file using DIE we will find that it uses VmProtect and we need to unpack it:
First of all let's put everything together and plan ahead first before unpacking:
PE64 GUI, image base: 0x140000000 Entry point: 0x140751C45 Entry point is inside .vmp1 .vmp1 : 0x140394000β0x1408E8FFF , contains almost all raw bytes .vmp0 : 0x140014000β0x140393FFF , executable virtual area with no raw bytes Normal sections like .text , .data , .idata , .tls have zero raw file content Imports are very small: LoadLibraryA , GetProcAddress , Sleep , ShellExecuteA , affinity APIs Watch for unpacking behavior and Set breakpoints on APIs like:
VirtualAlloc
VirtualProtect
NtProtectVirtualMemory
LoadLibraryA/W
GetProcAddressVirtualAlloc
VirtualProtect
NtProtectVirtualMemory
LoadLibraryA/W
GetProcAddressWe need to find the real OEP and once the main code is decrypted we need to dump using Scylla
The Entry point .vmp1 is a very important thing to know if you are new to VmProtect packing
In x64dbg we will make some breakpoints like bp 140751C45
bp ntdll.LdrLoadDll
bp ntdll.LdrGetProcedureAddress
bp ntdll.NtAllocateVirtualMemory
bp ntdll.NtProtectVirtualMemory
bp kernel32.LoadLibraryA
bp kernel32.GetProcAddress
Now those are the breakpoints we need to get the real OEP and dump the original exe
There is an easier way we can find vmprotect's section mapping table or packer info and used it
to recover the original sections directly, then decompressed the packed data, you can try dump it manually it's not that hard, but this makes the process faster
before doing anything let's take a look at the vmprotected exe file in IDA
So many obfuscated strings you can't understand a thing, now let's dig into decompressing and getting the exe we want, VMProtect stores a PACKER_INFO table (array of {Src RVA, Dst RVA} pairs) that describes the LZMA compressed blocks the table is located by matching the sequence of original section RVAs (those with SizeOfRawData == 0 / PointerToRawData == 0 and not BSS) the first entry holds the LZMA properties subsequent entries map compressed source data to target RVA inside the image and using that table the packed data was decompressed directly into the correct virtual locations and the section headers were updated with proper raw sizes I made a script that simulate this process:
import sys
import struct
import lzma
from dataclasses import dataclass
from typing import List, Optional
try:
import pefile
except ImportError:
print("pip install pefile")
sys.exit(1)
IMAGE_SCN_CNT_UNINITIALIZED_DATA = 0x00000080
LZMA_PROPERTIES_SIZE = 5
@dataclass
class PackerInfo:
src: int
dst: int
def find_pattern(
data: bytes,
pattern: bytes,
) -> Optional[int]:
"""Find pattern, 0xFF = wildcard."""
plen = len(pattern)
for i in range(len(data) - plen + 1):
if all(
p == 0xFF or data[i + j] == p
for j, p in enumerate(pattern)
):
return i
return None
def unpack_vmp(packed: bytes) -> bytes:
pe = pefile.PE(data=packed)
size_of_image = pe.OPTIONAL_HEADER.SizeOfImage
size_of_headers = pe.OPTIONAL_HEADER.SizeOfHeaders
rva_patterns = []
for sec in pe.sections:
if (
sec.SizeOfRawData == 0
and sec.PointerToRawData == 0
and not (
sec.Characteristics
& IMAGE_SCN_CNT_UNINITIALIZED_DATA
)
):
pat = struct.pack(
"<Q",
(sec.VirtualAddress << 32)
| 0xFFFFFFFF,
)
rva_patterns.append(pat)
if not rva_patterns:
raise RuntimeError(
"No candidate sections for "
"PACKER_INFO pattern"
)
pattern = b"".join(rva_patterns)
pos = find_pattern(
packed,
pattern,
)
if pos is None or pos < 8:
raise RuntimeError(
"PACKER_INFO table not found"
)
info_off = pos - 8
num_entries = len(rva_patterns) + 1
packer_info: List[PackerInfo] = []
for i in range(num_entries):
off = info_off + i * 8
src, dst = struct.unpack_from(
"<II",
packed,
off,
)
packer_info.append(
PackerInfo(src, dst)
)
print(
f"Found PACKER_INFO table @ "
f"0x{info_off:x} "
f"({len(packer_info)} entries)"
)
image = bytearray(size_of_image)
image[:size_of_headers] = packed[
:size_of_headers
]
for i, sec in enumerate(pe.sections):
hdr_off = (
pe.sections[0].get_file_offset()
+ i * 40
)
struct.pack_into(
"<I",
image,
hdr_off + 20,
sec.VirtualAddress,
)
vsize = sec.Misc_VirtualSize
if vsize:
struct.pack_into(
"<I",
image,
hdr_off + 16,
vsize,
)
if len(packer_info) < 2:
raise RuntimeError(
"Not enough PACKER_INFO entries"
)
props = packer_info[0]
props_off = pe.get_offset_from_rva(
props.src
)
props_data = packed[
props_off:props_off + props.dst
]
print(
f"LZMA props @ RVA "
f"0x{props.src:x} "
f"(size {props.dst})"
)
for idx, block in enumerate(
packer_info[1:],
1,
):
try:
raw_off = pe.get_offset_from_rva(
block.src
)
except Exception as e:
print(
f"Block {idx}: bad RVA "
f"0x{block.src:x}: {e}"
)
continue
compressed = packed[raw_off:]
target_rva = block.dst
if target_rva >= size_of_image:
print(
f"Block {idx}: target RVA "
f"0x{target_rva:x} "
"out of bounds"
)
continue
try:
filters = [
{
"id": lzma.FILTER_LZMA1,
"lc": props_data[0] % 9,
"lp": (props_data[0] // 9) % 5,
"pb": props_data[0] // 45,
"dict_size": 1 << 24,
}
]
decompressor = lzma.LZMADecompressor(
format=lzma.FORMAT_RAW,
filters=[
{
"id": lzma.FILTER_LZMA1,
"lc": props_data[0] % 9,
"lp": (
props_data[0] // 9
) % 5,
"pb": (
props_data[0] // 45
),
}
],
)
if len(props_data) >= 5:
try:
decompressor = (
lzma.LZMADecompressor(
format=lzma.FORMAT_RAW,
filters=[
{
"id": lzma.FILTER_LZMA1
}
],
)
)
stream = (
props_data[:5]
+ compressed
)
decompressor = (
lzma.LZMADecompressor(
format=lzma.FORMAT_ALONE
)
)
decompressed = (
decompressor.decompress(
stream
)
)
except Exception:
decompressed = (
decompressor.decompress(
compressed
)
)
else:
decompressed = (
decompressor.decompress(
compressed
)
)
except Exception as e:
try:
stream = (
props_data[:5]
+ compressed
)
decompressor = (
lzma.LZMADecompressor(
format=lzma.FORMAT_ALONE
)
)
decompressed = (
decompressor.decompress(
stream
)
)
except Exception as e2:
print(
f"Block {idx} "
f"decompress failed: "
f"{e} / {e2}"
)
continue
end = (
target_rva
+ len(decompressed)
)
if end > size_of_image:
decompressed = decompressed[
: size_of_image - target_rva
]
image[
target_rva:
target_rva + len(decompressed)
] = decompressed
print(
f"Block {idx}: "
f"0x{block.src:x} β "
f"0x{target_rva:x} "
f"({len(decompressed)} bytes)"
)
return bytes(image)
def main():
if len(sys.argv) != 3:
print(
f"Usage: {sys.argv[0]} "
"<packed.exe> <unpacked.exe>"
)
sys.exit(1)
packed_path = sys.argv[1]
out_path = sys.argv[2]
with open(packed_path, "rb") as f:
packed = f.read()
print(
f"[*] Loaded {packed_path} "
f"({len(packed)} bytes)"
)
unpacked = unpack_vmp(packed)
with open(out_path, "wb") as f:
f.write(unpacked)
print(
f"[*] Wrote {out_path} "
f"({len(unpacked)} bytes)"
)
if __name__ == "__main__":
main()
python3 unpack.py compressed.exe decompressed.exeimport sys
import struct
import lzma
from dataclasses import dataclass
from typing import List, Optional
try:
import pefile
except ImportError:
print("pip install pefile")
sys.exit(1)
IMAGE_SCN_CNT_UNINITIALIZED_DATA = 0x00000080
LZMA_PROPERTIES_SIZE = 5
@dataclass
class PackerInfo:
src: int
dst: int
def find_pattern(
data: bytes,
pattern: bytes,
) -> Optional[int]:
"""Find pattern, 0xFF = wildcard."""
plen = len(pattern)
for i in range(len(data) - plen + 1):
if all(
p == 0xFF or data[i + j] == p
for j, p in enumerate(pattern)
):
return i
return None
def unpack_vmp(packed: bytes) -> bytes:
pe = pefile.PE(data=packed)
size_of_image = pe.OPTIONAL_HEADER.SizeOfImage
size_of_headers = pe.OPTIONAL_HEADER.SizeOfHeaders
rva_patterns = []
for sec in pe.sections:
if (
sec.SizeOfRawData == 0
and sec.PointerToRawData == 0
and not (
sec.Characteristics
& IMAGE_SCN_CNT_UNINITIALIZED_DATA
)
):
pat = struct.pack(
"<Q",
(sec.VirtualAddress << 32)
| 0xFFFFFFFF,
)
rva_patterns.append(pat)
if not rva_patterns:
raise RuntimeError(
"No candidate sections for "
"PACKER_INFO pattern"
)
pattern = b"".join(rva_patterns)
pos = find_pattern(
packed,
pattern,
)
if pos is None or pos < 8:
raise RuntimeError(
"PACKER_INFO table not found"
)
info_off = pos - 8
num_entries = len(rva_patterns) + 1
packer_info: List[PackerInfo] = []
for i in range(num_entries):
off = info_off + i * 8
src, dst = struct.unpack_from(
"<II",
packed,
off,
)
packer_info.append(
PackerInfo(src, dst)
)
print(
f"Found PACKER_INFO table @ "
f"0x{info_off:x} "
f"({len(packer_info)} entries)"
)
image = bytearray(size_of_image)
image[:size_of_headers] = packed[
:size_of_headers
]
for i, sec in enumerate(pe.sections):
hdr_off = (
pe.sections[0].get_file_offset()
+ i * 40
)
struct.pack_into(
"<I",
image,
hdr_off + 20,
sec.VirtualAddress,
)
vsize = sec.Misc_VirtualSize
if vsize:
struct.pack_into(
"<I",
image,
hdr_off + 16,
vsize,
)
if len(packer_info) < 2:
raise RuntimeError(
"Not enough PACKER_INFO entries"
)
props = packer_info[0]
props_off = pe.get_offset_from_rva(
props.src
)
props_data = packed[
props_off:props_off + props.dst
]
print(
f"LZMA props @ RVA "
f"0x{props.src:x} "
f"(size {props.dst})"
)
for idx, block in enumerate(
packer_info[1:],
1,
):
try:
raw_off = pe.get_offset_from_rva(
block.src
)
except Exception as e:
print(
f"Block {idx}: bad RVA "
f"0x{block.src:x}: {e}"
)
continue
compressed = packed[raw_off:]
target_rva = block.dst
if target_rva >= size_of_image:
print(
f"Block {idx}: target RVA "
f"0x{target_rva:x} "
"out of bounds"
)
continue
try:
filters = [
{
"id": lzma.FILTER_LZMA1,
"lc": props_data[0] % 9,
"lp": (props_data[0] // 9) % 5,
"pb": props_data[0] // 45,
"dict_size": 1 << 24,
}
]
decompressor = lzma.LZMADecompressor(
format=lzma.FORMAT_RAW,
filters=[
{
"id": lzma.FILTER_LZMA1,
"lc": props_data[0] % 9,
"lp": (
props_data[0] // 9
) % 5,
"pb": (
props_data[0] // 45
),
}
],
)
if len(props_data) >= 5:
try:
decompressor = (
lzma.LZMADecompressor(
format=lzma.FORMAT_RAW,
filters=[
{
"id": lzma.FILTER_LZMA1
}
],
)
)
stream = (
props_data[:5]
+ compressed
)
decompressor = (
lzma.LZMADecompressor(
format=lzma.FORMAT_ALONE
)
)
decompressed = (
decompressor.decompress(
stream
)
)
except Exception:
decompressed = (
decompressor.decompress(
compressed
)
)
else:
decompressed = (
decompressor.decompress(
compressed
)
)
except Exception as e:
try:
stream = (
props_data[:5]
+ compressed
)
decompressor = (
lzma.LZMADecompressor(
format=lzma.FORMAT_ALONE
)
)
decompressed = (
decompressor.decompress(
stream
)
)
except Exception as e2:
print(
f"Block {idx} "
f"decompress failed: "
f"{e} / {e2}"
)
continue
end = (
target_rva
+ len(decompressed)
)
if end > size_of_image:
decompressed = decompressed[
: size_of_image - target_rva
]
image[
target_rva:
target_rva + len(decompressed)
] = decompressed
print(
f"Block {idx}: "
f"0x{block.src:x} β "
f"0x{target_rva:x} "
f"({len(decompressed)} bytes)"
)
return bytes(image)
def main():
if len(sys.argv) != 3:
print(
f"Usage: {sys.argv[0]} "
"<packed.exe> <unpacked.exe>"
)
sys.exit(1)
packed_path = sys.argv[1]
out_path = sys.argv[2]
with open(packed_path, "rb") as f:
packed = f.read()
print(
f"[*] Loaded {packed_path} "
f"({len(packed)} bytes)"
)
unpacked = unpack_vmp(packed)
with open(out_path, "wb") as f:
f.write(unpacked)
print(
f"[*] Wrote {out_path} "
f"({len(unpacked)} bytes)"
)
if __name__ == "__main__":
main()
python3 unpack.py compressed.exe decompressed.exeNow we got decompressed.exe let's see now the sections of it in DIE:
When you compare it to the compressed one you will notice something
You will notice that VA is the same and that makes sense but the raw address increased in the decompressed file that means that VA = RS means you can see .text and the actual code Let's check now decompressed.exe in IDA and see the strings in it:
Now you can see a normal game's strings and a custom VM opcodes too, let's dig into the main function and check the behavior of this game:
After digging you will find a weird behavior once you dig deeply in the heap data you will find this:
There are Write and Open functions but the question here open what and write what?
Seems you can't benefit from the import section
AURA.gb seems like the gameboy file, I don't think we can use that it's just a configuration file to open the ROM gameboy game Let's check the %s\svchost it means there is a string inserted before svchost kinda interesting let's dig deep into that
After digging in so many functions, when I opened sub_140001A59 I found interesting things
Oh there is some registry keys manipulation, let's see the rest of the functions and see what else left to know
THAT'S IT WHAT WE ARE LOOKING FOR
It created a key and obviously the last word of it is svchost , Let's see the rest of the functions
First let's see what sub_140001BC9 contains:
and in the function sub_140001651 we can see the subkey that it's written into:
That's the registry key that is written, now as we can see when we open the game and close it there is some writing happening in the registry keys:
The file is written in the AppData\Local\Temp directory let's see the file created
Let's see what kinda file is this:
It's an ELF File LOL We started with an ELF and most likely ended with an ELF too what a maze huh
Seems like corrupted ELF File or something Let's try some other tool like objdump and see what are all sections holding
Ain't that obvious there is an AES KEY called M68K_AES_FLAGKEY and you will see
__3 β 0x401000 (key)
__2 β 0x401010 (Rcon) __1 β 0x401020 (S-box) __0 β 0x401120 (inv S-box) __4 β 0x402220 (ciphertext)
So now let's make a script to decrypt the ciphertext:
from cryptography.hazmat.primitives.ciphers import Cipher
from cryptography.hazmat.primitives.ciphers import algorithms, modes
from cryptography.hazmat.backends import default_backend
KEY = b"M68K_AES_FLAGKEY"
CIPHERTEXT = bytes.fromhex(
"674e0e339bc75891878e9418bb3fb91a"
"f8fa389587016dfbe91db26b39b41c51"
"bc8a191fbdc5ad5fb30b22aa6c0d35b3"
)
def unpad_pkcs7(data: bytes) -> bytes:
pad_len = data[-1]
if pad_len < 1 or pad_len > 16:
raise ValueError("Invalid PKCS#7 padding")
if data[-pad_len:] != bytes([pad_len] * pad_len):
raise ValueError("Invalid PKCS#7 padding")
return data[:-pad_len]
def decrypt():
cipher = Cipher(
algorithms.AES(KEY),
modes.ECB(),
backend=default_backend(),
)
decryptor = cipher.decryptor()
plaintext = (
decryptor.update(CIPHERTEXT)
+ decryptor.finalize()
)
flag = unpad_pkcs7(plaintext)
return flag
if __name__ == "__main__":
flag = decrypt()
print(f"Flag: {flag.decode()}")from cryptography.hazmat.primitives.ciphers import Cipher
from cryptography.hazmat.primitives.ciphers import algorithms, modes
from cryptography.hazmat.backends import default_backend
KEY = b"M68K_AES_FLAGKEY"
CIPHERTEXT = bytes.fromhex(
"674e0e339bc75891878e9418bb3fb91a"
"f8fa389587016dfbe91db26b39b41c51"
"bc8a191fbdc5ad5fb30b22aa6c0d35b3"
)
def unpad_pkcs7(data: bytes) -> bytes:
pad_len = data[-1]
if pad_len < 1 or pad_len > 16:
raise ValueError("Invalid PKCS#7 padding")
if data[-pad_len:] != bytes([pad_len] * pad_len):
raise ValueError("Invalid PKCS#7 padding")
return data[:-pad_len]
def decrypt():
cipher = Cipher(
algorithms.AES(KEY),
modes.ECB(),
backend=default_backend(),
)
decryptor = cipher.decryptor()
plaintext = (
decryptor.update(CIPHERTEXT)
+ decryptor.finalize()
)
flag = unpad_pkcs7(plaintext)
return flag
if __name__ == "__main__":
flag = decrypt()
print(f"Flag: {flag.decode()}")Flag: psctf{U_sh0u1dvebeen_@_g@m3r_0rHighIQ!}