Skip to content

Instantly share code, notes, and snippets.

@UserUnknownFactor
Created August 27, 2026 20:57
Show Gist options
  • Select an option

  • Save UserUnknownFactor/9a6f607340ed3ff91cd5e1cf139b4ce0 to your computer and use it in GitHub Desktop.

Select an option

Save UserUnknownFactor/9a6f607340ed3ff91cd5e1cf139b4ce0 to your computer and use it in GitHub Desktop.
Unpacks NSISBI Unity Editor installers with file mask support
import argparse
import io
import logging
import lzma
import mmap
from pathlib import Path
import re
import struct
from typing import BinaryIO, List, Optional, Tuple
from colorama import Fore, Style, init as colorama_init
# ---------------------------------------------------------------------------
# Console
# ---------------------------------------------------------------------------
colorama_init(autoreset=True)
log = logging.getLogger("nsis-extract")
log.setLevel(logging.DEBUG)
_console = logging.StreamHandler()
_console.setFormatter(logging.Formatter("%(message)s"))
log.addHandler(_console)
log.propagate = False
def status(message: str, *args) -> None:
if args:
message = message % args
print(f"{Fore.CYAN}>{Style.RESET_ALL} {message}")
def success(message: str, *args) -> None:
if args:
message = message % args
print(f"{Fore.GREEN}√{Style.RESET_ALL} {message}")
def warning(message: str, *args) -> None:
if args:
message = message % args
print(f"{Fore.YELLOW}!{Style.RESET_ALL} {message}")
def error(message: str, *args) -> None:
if args:
message = message % args
print(f"{Fore.RED}x{Style.RESET_ALL} {message}")
def debug(message: str, *args) -> None:
if not VERBOSE:
return
if args:
message = message % args
print(
f"{Style.DIM}{Fore.WHITE}· {message}"
f"{Style.RESET_ALL}"
)
VERBOSE = False
# ---------------------------------------------------------------------------
# NSIS constants
# ---------------------------------------------------------------------------
NSIS_HEADER_MAGIC = b"NullsoftInst"
DEFAULT_FILTER_REGEX = r"windowsstandalonesupport/Variations"
EW_NOP = 2
EW_CREATEDIR = 11
EW_EXTRACTFILE = 20
EW_ASSIGNVAR = 25
NS_3_CODE_SHELL = 2
NS_3_CODE_VAR = 3
NS_3_CODE_SKIP = 4
VAR_R4 = 14
VAR_INSTDIR = 21
VAR_OUTDIR = 22
SPECIAL_EXTRACT_VAR = 10 # $R0
# ---------------------------------------------------------------------------
# Struct definitions
# ---------------------------------------------------------------------------
NSIS_FIRST_HEADER_FMT = "<II12sII"
NSIS_FIRST_HEADER_SIZE = struct.calcsize(NSIS_FIRST_HEADER_FMT)
NSIS_BLOCK_FMT = "<II"
NSIS_BLOCK_SIZE = struct.calcsize(NSIS_BLOCK_FMT)
class NsisFirstHeader:
def __init__(self, data: bytes):
(
self.flags,
self.siginfo,
self.magic,
self.length_of_header,
self.length_of_all_following_data,
) = struct.unpack(NSIS_FIRST_HEADER_FMT, data)
def is_nsisbi(self) -> bool:
return (self.flags & 0x30) != 0
def has_crc(self) -> bool:
return (self.flags & 0x04) != 0
class NsisBlock:
def __init__(self, offset: int, num: int):
self.offset = offset
self.num = num
class NsisCommand:
def __init__(self, cmd_id: int, params: List[int]):
self.cmd_id = cmd_id
self.params = params
# ---------------------------------------------------------------------------
# Basic helpers
# ---------------------------------------------------------------------------
def clear_int_flag(i: int) -> Tuple[int, bool]:
has_flag = (i & 0x80000000) != 0
return i & 0x7FFFFFFF, has_flag
def to_signed(val: int) -> int:
return val - 0x100000000 if val >= 0x80000000 else val
def read_u32(stream: BinaryIO) -> int:
data = stream.read(4)
if len(data) < 4:
raise EOFError("Unexpected end of stream")
return struct.unpack("<I", data)[0]
def read_u16_from_bytes(data: bytes, offset: int) -> int:
return struct.unpack_from("<H", data, offset)[0]
def normalize_path(in_path: str) -> str:
cleaned = in_path.replace("\\", "/")
while cleaned.startswith("/"):
cleaned = cleaned[1:]
return cleaned
def strip_instdir(path: str) -> str:
path = normalize_path(path)
upper = path.upper()
if upper == "$INSTDIR":
return ""
prefix = "$INSTDIR/"
if upper.startswith(prefix):
return path[len(prefix):]
return path
def join_nsis_path(base: str, suffix: str) -> str:
base = normalize_path(base).rstrip("/")
suffix = normalize_path(suffix).lstrip("/")
if not base:
return suffix
if not suffix:
return base
return f"{base}/{suffix}"
def human_size(size: int) -> str:
value = float(size)
for unit in ("B", "KB", "MB", "GB"):
if value < 1024.0 or unit == "GB":
if unit == "B":
return f"{int(value)} {unit}"
return f"{value:.1f} {unit}"
value /= 1024.0
return f"{size} B"
def is_absolute_nsis_path(path: str) -> bool:
"""
Determines whether an NSIS path string is absolute.
Absolute means:
- starts with a known variable ($INSTDIR, $TEMP, $PLUGINSDIR, $EXEDIR, ...)
- has a drive letter (e.g. C:\\)
Relative means:
- starts with \\ or / but NO drive letter (e.g. \\windowsstandalonesupport)
- has no leading separator at all
"""
if not path:
return False
upper = path.upper()
# Known NSIS variable prefixes
for var in ("$INSTDIR", "$TEMP", "$PLUGINSDIR", "$EXEDIR", "$APPDATA",
"$COMMONAPPDATA", "$DESKTOP", "$DOCUMENTS", "$MUSIC",
"$PICTURES", "$PROFILES", "$PROGRAMFILES", "$PROGRAMFILESX86",
"$SYSTEM", "$SYSDIR", "$TEMP", "$USERPROFILE", "$WINDIR"):
if upper.startswith(var):
return True
# Drive letter: X:\
if len(path) >= 3 and path[1] == ":" and path[2] in ("\\", "/"):
return True
# Bare drive letter without path: X:
if len(path) >= 2 and path[1] == ":":
return True
return False
# ---------------------------------------------------------------------------
# NSIS strings and variables
# ---------------------------------------------------------------------------
def decode_ns3_unicode_number(n: int) -> int:
return (n & 0x7F) | (((n >> 8) & 0x7F) << 7)
def decode_ns_number(c0: int, c1: int) -> int:
return (c0 & 0x7F) | ((c1 & 0x7F) << 7)
def read_string(block: bytes, is_unicode: bool, pos: int, variables: dict) -> str:
"""
Reads an NSIS string from the string table and evaluates any variable
codes using the provided variables dictionary to simulate runtime state.
"""
if pos < 0:
return ""
if is_unicode:
offset = pos * 2
if offset >= len(block):
return ""
result: List[str] = []
for _ in range(0xFFFF):
if offset + 2 > len(block):
break
c = read_u16_from_bytes(block, offset)
offset += 2
if c == 0:
break
if c > NS_3_CODE_SKIP:
result.append(chr(c))
continue
if offset + 2 > len(block):
break
n = read_u16_from_bytes(block, offset)
offset += 2
if n == 0:
break
if c == NS_3_CODE_SKIP:
result.append(chr(n))
continue
if c == NS_3_CODE_VAR:
index = decode_ns3_unicode_number(n)
result.append(variables.get(index, ""))
continue
if c == NS_3_CODE_SHELL:
index1 = n & 0xFF
index2 = n >> 8
result.append(f"$SHELL_{index1}_{index2}")
continue
lang_index = decode_ns3_unicode_number(n)
result.append(f"$(LSTR_{lang_index})")
return "".join(result)
# ANSI NSIS 3.x logic
offset = pos
if offset >= len(block):
return ""
result_bytes = bytearray()
for _ in range(0xFFFF):
if offset >= len(block):
break
c = block[offset]
offset += 1
if c == 0:
break
if c > NS_3_CODE_SKIP:
result_bytes.append(c)
continue
if offset >= len(block):
break
c0 = block[offset]
offset += 1
if c0 == 0:
break
if c == NS_3_CODE_SKIP:
result_bytes.append(c0)
continue
if offset >= len(block):
break
c1 = block[offset]
offset += 1
if c1 == 0:
break
if c == NS_3_CODE_VAR:
index = decode_ns_number(c0, c1)
val = variables.get(index, "")
result_bytes.extend(val.encode("latin-1", errors="replace"))
continue
if c == NS_3_CODE_SHELL:
result_bytes.extend(f"$SHELL_{c0}_{c1}".encode("ascii"))
continue
lang_index = decode_ns_number(c0, c1)
result_bytes.extend(f"$(LSTR_{lang_index})".encode("ascii"))
try:
return result_bytes.decode("utf-8")
except UnicodeDecodeError:
return result_bytes.decode("latin-1", errors="replace")
def is_var_string(
strings: bytes,
is_unicode: bool,
pos: int,
wanted_var: int,
) -> bool:
"""Checks the raw string table bytes to see if it is exactly a specific variable."""
if pos < 0:
return False
if is_unicode:
offset = pos * 2
if offset + 6 > len(strings):
return False
code = read_u16_from_bytes(strings, offset)
n = read_u16_from_bytes(strings, offset + 2)
end = read_u16_from_bytes(strings, offset + 4)
if code != NS_3_CODE_VAR or n == 0 or end != 0:
return False
return decode_ns3_unicode_number(n) == wanted_var
# ANSI NSIS 3.x logic
offset = pos
if offset + 4 > len(strings):
return False
if strings[offset] != NS_3_CODE_VAR:
return False
c0 = strings[offset + 1]
c1 = strings[offset + 2]
end = strings[offset + 3]
if c0 == 0 or c1 == 0 or end != 0:
return False
return decode_ns_number(c0, c1) == wanted_var
# ---------------------------------------------------------------------------
# LZMA
# ---------------------------------------------------------------------------
def decompress_lzma(
data: bytes,
unpacked_size: Optional[int] = None,
) -> bytes:
if not data:
return b""
if len(data) < 5:
raise lzma.LZMAError("NSIS LZMA stream is too short")
props = data[:5]
payload = data[5:]
if unpacked_size is not None and unpacked_size >= 0:
size_bytes = struct.pack("<Q", unpacked_size)
else:
size_bytes = b"\xff" * 8
alone_data = props + size_bytes + payload
try:
return lzma.decompress(alone_data, format=lzma.FORMAT_ALONE)
except lzma.LZMAError:
pass
props_byte = props[0]
lc = props_byte % 9
remainder = props_byte // 9
lp = remainder % 5
pb = remainder // 5
if pb > 4:
raise lzma.LZMAError(f"Invalid LZMA properties: 0x{props_byte:02X}")
dict_size = struct.unpack_from("<I", props, 1)[0]
filters = [
{
"id": lzma.FILTER_LZMA1,
"lc": lc,
"lp": lp,
"pb": pb,
"dict_size": dict_size,
}
]
return lzma.decompress(payload, format=lzma.FORMAT_RAW, filters=filters)
# ---------------------------------------------------------------------------
# Header discovery
# ---------------------------------------------------------------------------
def find_nsis_header(mmap_data: mmap.mmap) -> int:
idx = mmap_data.find(NSIS_HEADER_MAGIC)
if idx == -1 or idx < 8:
raise ValueError("NSIS header not found")
return idx - 8
# ---------------------------------------------------------------------------
# Special filename recovery
# ---------------------------------------------------------------------------
def recover_extract_filename_id(
commands: List[NsisCommand],
command_index: int,
strings_data: bytes,
is_unicode: bool,
original_name_id: int,
) -> int:
# Heuristic: Find if an NSIS macro populated $R4 just before calling `File` on $R0
if not is_var_string(strings_data, is_unicode, original_name_id, SPECIAL_EXTRACT_VAR):
return original_name_id
back_offset = 28
if command_index > 1 and commands[command_index - 1].cmd_id == EW_NOP:
back_offset -= 2
if command_index <= back_offset:
return original_name_id
candidate = commands[command_index - back_offset]
if candidate.cmd_id != EW_ASSIGNVAR:
return original_name_id
p = candidate.params
if len(p) < 4:
return original_name_id
if p[0] == VAR_R4 and p[2] == 0 and p[3] == 0:
return p[1]
return original_name_id
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> None:
global VERBOSE
parser = argparse.ArgumentParser(description="Extract files from NSISBI installers")
parser.add_argument("file", type=Path, help="Unity Editor installer")
parser.add_argument("out_dir", type=Path, help="Output directory")
parser.add_argument("-r", "--regex", default=DEFAULT_FILTER_REGEX, help="Path filter regex")
parser.add_argument("-a", "--all", action="store_true", help="Extract all files")
parser.add_argument("-v", "--verbose", action="store_true", help="Show parser details")
args = parser.parse_args()
VERBOSE = args.verbose
if not args.file.is_file():
error("File not found: %s", args.file)
return
file_filter = None
if not args.all:
try:
file_filter = re.compile(args.regex, re.IGNORECASE)
except re.error as e:
error("Invalid regex: %s", e)
return
args.out_dir.mkdir(parents=True, exist_ok=True)
status(
"%s %s→%s %s",
Fore.WHITE + Style.BRIGHT,
args.file.name,
Style.RESET_ALL + Fore.CYAN,
args.out_dir,
)
with open(args.file, "rb") as f:
mm = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)
try:
try:
header_offset = find_nsis_header(mm)
except ValueError as e:
error("%s", e)
return
debug("NSIS header @ 0x%X", header_offset)
if header_offset + NSIS_FIRST_HEADER_SIZE > len(mm):
error("Truncated NSIS header")
return
first_header = NsisFirstHeader(mm[header_offset: header_offset + NSIS_FIRST_HEADER_SIZE])
debug(
"NSISBI=%s CRC=%s header=%d bytes",
first_header.is_nsisbi(),
first_header.has_crc(),
first_header.length_of_header,
)
pos = header_offset + NSIS_FIRST_HEADER_SIZE
if first_header.is_nsisbi():
pos += 8
if pos + 4 > len(mm):
error("Truncated compressed header")
return
compressed_header_len_raw = struct.unpack_from("<I", mm, pos)[0]
pos += 4
(compressed_header_len, header_is_compressed) = clear_int_flag(compressed_header_len_raw)
if pos + compressed_header_len > len(mm):
error("NSIS header extends past end of file")
return
data_offset = pos + compressed_header_len
debug(
"Header storage=%s, size=%s",
"LZMA" if header_is_compressed else "raw",
human_size(compressed_header_len),
)
header_data_slice = mm[pos: pos + compressed_header_len]
try:
if header_is_compressed:
decompressed_header = decompress_lzma(
header_data_slice, unpacked_size=first_header.length_of_header
)
else:
decompressed_header = bytes(header_data_slice)
except lzma.LZMAError as e:
error("Could not decompress NSIS header: %s", e)
return
if len(decompressed_header) < 4 + 8 * NSIS_BLOCK_SIZE:
error("Invalid NSIS header")
return
header_io = io.BytesIO(decompressed_header)
_header_flags = read_u32(header_io)
blocks: List[NsisBlock] = []
for _ in range(8):
block_bytes = header_io.read(NSIS_BLOCK_SIZE)
if len(block_bytes) != NSIS_BLOCK_SIZE:
error("Truncated NSIS block table")
return
offset_b, num_b = struct.unpack(NSIS_BLOCK_FMT, block_bytes)
blocks.append(NsisBlock(offset_b, num_b))
entries_block = blocks[2]
strings_block = blocks[3]
lang_block = blocks[4]
if not (0 <= entries_block.offset <= len(decompressed_header) and 0 <= strings_block.offset <= len(decompressed_header)):
error("Invalid NSIS block offsets")
return
if strings_block.offset <= lang_block.offset <= len(decompressed_header):
strings_data = decompressed_header[strings_block.offset: lang_block.offset]
else:
strings_data = decompressed_header[strings_block.offset:]
if len(strings_data) < 2:
error("Invalid NSIS string table")
return
is_unicode = read_u16_from_bytes(strings_data, 0) == 0
debug(
"Strings=%s, commands=%d",
"Unicode" if is_unicode else "ANSI",
entries_block.num,
)
cmd_io = io.BytesIO(decompressed_header[entries_block.offset:])
num_params = 6 + (2 if first_header.is_nsisbi() else 0)
commands: List[NsisCommand] = []
for command_index in range(entries_block.num):
try:
cmd_id = read_u32(cmd_io)
params = [read_u32(cmd_io) for _ in range(num_params)]
except EOFError:
warning("Command table ended early at %d/%d", command_index, entries_block.num)
break
commands.append(NsisCommand(cmd_id, params))
# Initialize variables state mimicking NSIS defaults
variables = {
VAR_INSTDIR: "$INSTDIR",
25: "$TEMP", # VAR_TEMP
26: "$PLUGINSDIR", # VAR_PLUGINSDIR
23: "$EXEDIR", # VAR_EXEDIR
}
# Track the last ABSOLUTE outdir. Relative SetOutPath calls
# (leading "\" with no drive letter) resolve against this base,
base_outdir = "$INSTDIR"
extracted_count = 0
skipped_count = 0
indirect_count = 0
extracted_bytes = 0
for command_index, command in enumerate(commands):
cmd_id = command.cmd_id
params = command.params
# ------------------------------------------------------------
# EW_ASSIGNVAR (StrCpy etc)
# ------------------------------------------------------------
if cmd_id == EW_ASSIGNVAR:
dst_var = params[0]
src_text = read_string(strings_data, is_unicode, params[1], variables)
# Implement substring copying (MaxLen and StartOffset)
max_len = to_signed(params[2])
start_offset = to_signed(params[3])
if start_offset != 0 or max_len != 0:
if start_offset > 0:
src_text = src_text[start_offset:]
elif start_offset < 0:
src_text = src_text[start_offset:]
if max_len > 0:
src_text = src_text[:max_len]
elif max_len < 0:
src_text = src_text[:max_len]
variables[dst_var] = src_text
continue
# ------------------------------------------------------------
# EW_CREATEDIR (SetOutPath)
# ------------------------------------------------------------
if cmd_id == EW_CREATEDIR:
if params[1] == 0:
continue
out_path = read_string(strings_data, is_unicode, params[0], variables)
if is_absolute_nsis_path(out_path):
base_outdir = out_path
variables[VAR_OUTDIR] = out_path
else:
# Relative path resolves against the last absolute base
variables[VAR_OUTDIR] = join_nsis_path(base_outdir, out_path)
debug("OutPath → %s", variables[VAR_OUTDIR])
continue
# ------------------------------------------------------------
# EW_EXTRACTFILE
# ------------------------------------------------------------
if cmd_id != EW_EXTRACTFILE:
continue
original_name_str_id = params[1]
name_str_id = recover_extract_filename_id(
commands,
command_index,
strings_data,
is_unicode,
original_name_str_id,
)
if name_str_id != original_name_str_id:
indirect_count += 1
# Evaluates string using current memory state
name_str = read_string(strings_data, is_unicode, name_str_id, variables)
if not name_str:
warning("Skipped unnamed file at command %d", command_index)
continue
# Check if the filename itself is absolute
is_absolute = is_absolute_nsis_path(name_str)
if is_absolute:
full_path = name_str
else:
current_outdir = variables.get(VAR_OUTDIR, "")
full_path = join_nsis_path(current_outdir, name_str)
normalized_path = normalize_path(strip_instdir(full_path))
# Prevent arbitrary writes across directories/drives
if len(normalized_path) > 1 and normalized_path[1] == ':':
normalized_path = normalized_path[2:]
normalized_path = normalized_path.lstrip("/")
if normalized_path.startswith("../") or normalized_path.startswith("..\\"):
warning("Attempted path traversal, skipping: %s", normalized_path)
continue
if file_filter and not file_filter.search(normalized_path):
skipped_count += 1
continue
file_size_offset = data_offset + params[2]
if file_size_offset + 4 > len(mm):
error("Invalid data offset for %s", normalized_path)
continue
raw_file_size = struct.unpack_from("<I", mm, file_size_offset)[0]
(file_size, is_compressed) = clear_int_flag(raw_file_size)
file_data_offset = file_size_offset + 4
if file_data_offset + file_size > len(mm):
error("Payload extends past EOF: %s", normalized_path)
continue
out_file_abs = args.out_dir / Path(normalized_path)
out_file_abs.parent.mkdir(parents=True, exist_ok=True)
if file_size == 0:
out_file_abs.write_bytes(b"")
success(
"%s %s%s%s",
normalized_path,
Style.DIM,
"0 B",
Style.RESET_ALL,
)
extracted_count += 1
continue
file_raw_bytes = mm[file_data_offset: file_data_offset + file_size]
if not is_compressed:
output_data = bytes(file_raw_bytes)
else:
try:
output_data = decompress_lzma(file_raw_bytes, unpacked_size=None)
except lzma.LZMAError as e:
error("%s — %s", normalized_path, e)
continue
out_file_abs.write_bytes(output_data)
extracted_count += 1
extracted_bytes += len(output_data)
print(
f"{Fore.GREEN} +{Style.RESET_ALL} "
f"{normalized_path} "
f"{Style.DIM}{human_size(len(output_data))}"
f"{Style.RESET_ALL}"
)
print()
success(
"%d files %s•%s %s",
extracted_count,
Style.DIM,
Style.RESET_ALL,
human_size(extracted_bytes),
)
if VERBOSE:
debug("Filtered=%d, indirect names=%d", skipped_count, indirect_count)
finally:
mm.close()
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment