Bytecode Decompiler & Obfuscation Assistant
1. System Architecture & Prerequisites
- Runtime binary: Python 3.9 or newer (uses
dis.get_instructions, ast.unparse-free rendering, importlib.util.MAGIC_NUMBER, marshal). Tested primarily on CPython 3.9–3.13 .pyc files.
- Standard library only:
dis, marshal, importlib.util, io, json, argparse, re, sys, pathlib, types.CodeType, dataclasses, typing.
- Optional (documented in Section 4, never required by the ship'ed module):
uncompyle6 from PyPI for byte-exact decompilation of matching CPython versions, and the xdis package it wraps. When the installed interpreter matches the .pyc version, uncompyle6 file.pyc produces exact source.
- Input: a single
.pyc (Python bytecode) file — either a timestamp pyc (16-byte header: magic + flags + mtime + source size), a PEP 552 hash-based pyc (magic + flags + 8-byte hash), or a bare marshal-dumped code object. Loading is not a security boundary: marshal can craft hostile objects; analyze only trusted files or run inside a throwaway sandbox.
2. Input/Output Data Contracts
2.1 CLI input options (JSON Schema)
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "unpyc_assistant_cli_options",
"type": "object",
"properties": {
"pyc": { "type": "string", "description": "Path to the .pyc file to analyze." },
"out": { "type": "string", "description": "Path where reconstructed pseudo-Python is written." },
"report": { "type": "string", "description": "Path where the JSON diagnostics/show report is written." },
"dis": { "type": "boolean", "default": false, "description": "Print raw disassembly for the module and nested code objects." },
"demo": { "type": "boolean", "default": false, "description": "Reconstruct a compiled built-in sample instead of --pyc." }
},
"oneOf": [
{ "required": ["pyc"] },
{ "required": ["demo"] }
]
}
2.2 Output artifacts
- Reconstructed source (
--out): readable pseudo-Python with def headers, for/while/if/try framing, renamed variables, and # block annotations. Documented as a faithful structured reconstruction, not a byte-exact decompilation.
- Diagnostics report (
--report): JSON with one entry per code object (name, argc, varnames, rendered_lines) plus obfuscation findings (no-string-constants, oversized-constants, dynamic-execution) and nested_code_objects count.
- No input files are modified by this skill.
3. Production Reference Implementation
"""Bytecode Decompiler & Obfuscation Assistant.
Loads Python ``.pyc`` bytecode, disassembles it, rebuilds a structured
basic-block control-flow graph, renames mangled single-letter variables by
usage heuristics, renders readable pseudo-Python, and flags obfuscation
signatures.
Pipeline:
``load_pyc()`` -> CodeType (importlib.header-aware marshal load)
``disassemble()``-> raw ``dis.dis`` text
``flow_reconstruct()`` -> CFG of basic blocks with loop-head / try-except
annotations and edge lists
``usage_stats()`` + ``rename_mangled()`` -> semantic variable names
``render_source()`` -> readable pseudo-Python outline (NOT byte-exact)
``detect_obfuscation()`` -> report of stripped-string and eval-style payloads
Section on obfuscation: obfuscated/decompiler-hostile ``.pyc`` files are
detected by (a) an absence of string constants (string-stripped pycs),
(b) oversized int/bytes constants (payload blobs), and (c) module-level
references to ``eval``/``exec``/``marshal``/``base64``/``zlib`` used to
decode a payload at runtime.
When ``uncompyle6`` (PyPI) is installed and the CPython version matches the
pyc, byte-exact source can be produced externally:
python -m uncompyle6 file.pyc
The structured reconstruction in this module remains the dependency-free
fallback.
"""
from __future__ import annotations
import argparse
import dis
import importlib.util
import io
import json
import marshal
import re
import sys
from dataclasses import dataclass, field
from pathlib import Path
from types import CodeType
from typing import Any, Dict, List, Optional, Set, Tuple, Union
# --------------------------------------------------------------------------
# loading
# --------------------------------------------------------------------------
def load_pyc(path: Union[str, Path]) -> CodeType:
"""Load a .pyc file into a code object.
When the on-disk magic matches the running interpreter, the marshaled
code object starts right after the fixed 16-byte header (magic, flags,
timestamp/size, or PEP 552 hash). Otherwise a raw marshal attempt is
made against every plausible header offset so legacy pycs without a
recognized magic can still load when their marshal stream aligns.
"""
data = Path(path).read_bytes()
if not data:
raise ValueError(f"{path} is empty")
magic = data[:4]
if magic == importlib.util.MAGIC_NUMBER:
code = _code_from_marshal(data[16:], str(path), magic)
return code
for offset in (8, 12, 16, 0):
try:
code = marshal.loads(data[offset:])
except (ValueError, TypeError, EOFError, MemoryError):
continue
if isinstance(code, CodeType):
return code
raise ValueError(
f"{path}: unrecognized pyc magic {magic!r}; this interpreter expects "
f"{importlib.util.MAGIC_NUMBER!r}. Recompile with the matching "
f"Python or point load_pyc at the writer's runtime."
)
def _code_from_marshal(blob: bytes, label: str, magic: bytes) -> CodeType:
try:
code = marshal.loads(blob)
except (ValueError, TypeError, EOFError, MemoryError) as exc:
raise ValueError(f"{label}: magic matches but marshal load failed: {exc}") from exc
if not isinstance(code, CodeType):
raise ValueError(f"{label}: marshal payload is not a code object")
return code
# --------------------------------------------------------------------------
# dis
# --------------------------------------------------------------------------
def disassemble(code: CodeType, show_caches: bool = False) -> str:
out = io.StringIO()
dis.dis(code, file=out, show_caches=show_caches)
return out.getvalue()
# --------------------------------------------------------------------------
# control-flow reconstruction
# --------------------------------------------------------------------------
@dataclass
class Block:
index: int
start: int
instructions: List[dis.Instruction] = field(default_factory=list)
kind: str = "plain"
exits: List[int] = field(default_factory=list)
ends_with: str = "fall" # fall | jump | return | raise
loop_head: bool = False
try_root: bool = False
handler: bool = False
@dataclass
class CFG:
code: CodeType
blocks: List[Block] = field(default_factory=list)
vacuous: bool = False
def index_of(self, target: int) -> Optional[int]:
for b in self.blocks:
if b.start == target:
return b.index
return None
_TERMINATORS = {"RETURN_VALUE", "RETURN_CONST", "RAISE_VARARGS", "RAISE_CONST",
"POP_EXCEPT", "END_FINALLY", "BREAK_LOOP", "CONTINUE_LOOP",
"EXCEPT_HANDLER"}
def _is_jump(ins: dis.Instruction) -> bool:
return ins.opname.startswith("JUMP") or ins.opname == "FOR_ITER"
_COND_JUMPS = {
"POP_JUMP_IF_FALSE", "POP_JUMP_IF_TRUE",
"POP_JUMP_FORWARD_IF_FALSE", "POP_JUMP_FORWARD_IF_TRUE",
"POP_JUMP_BACKWARD_IF_FALSE", "POP_JUMP_BACKWARD_IF_TRUE",
"JUMP_IF_FALSE_OR_POP", "JUMP_IF_TRUE_OR_POP",
"JUMP_IF_FALSE", "JUMP_IF_TRUE",
}
def _is_conditional_jump(ins: dis.Instruction) -> bool:
return ins.opname in _COND_JUMPS
def flow_reconstruct(code: CodeType) -> CFG:
"""Split the instruction stream into basic blocks and annotate loops
and try/except regions."""
instrs = list(dis.get_instructions(code))
cfg = CFG(code=code)
if not instrs:
cfg.vacuous = True
return cfg
starts = {instrs[0].offset}
for ins in instrs:
if _is_jump(ins) or ins.opname in {"SETUP_LOOP", "SETUP_EXCEPT", "SETUP_FINALLY"}:
if isinstance(ins.argval, int):
starts.add(ins.argval)
for i, ins in enumerate(instrs[:-1]):
if _is_jump(ins) or ins.opname in _TERMINATORS:
starts.add(instrs[i + 1].offset)
starts = sorted(starts)
i = 0
while i < len(instrs):
block_start = instrs[i].offset
block_ins: List[dis.Instruction] = []
while i < len(instrs):
ins = instrs[i]
block_ins.append(ins)
barrier = _is_jump(ins) or ins.opname in _TERMINATORS
nxt_offset = instrs[i + 1].offset if i + 1 < len(instrs) else None
if barrier or (nxt_offset is not None and nxt_offset in starts):
i += 1
break
i += 1
cfg.blocks.append(Block(index=len(cfg.blocks), start=block_start,
instructions=block_ins))
_compute_edges(cfg)
_annotate(cfg)
return cfg
def _compute_edges(cfg: CFG) -> None:
for block in cfg.blocks:
last = block.instructions[-1]
name = last.opname
if name in {"RETURN_VALUE", "RETURN_CONST"}:
block.ends_with = "return"
elif name in {"RAISE_VARARGS", "RAISE_CONST", "RERAISE"}:
block.ends_with = "raise"
elif _is_jump(last):
block.ends_with = "jump"
if isinstance(last.argval, int):
t = cfg.index_of(last.argval)
if t is not None:
block.exits.append(t)
if (name == "FOR_ITER" or "JUMP_IF" in name
or name.startswith("POP_JUMP")):
if block.index + 1 < len(cfg.blocks):
block.exits.append(block.index + 1)
def _annotate(cfg: CFG) -> None:
for block in cfg.blocks:
if any(ins.opname == "FOR_ITER" for ins in block.instructions):
block.loop_head = True
for ins in block.instructions:
if ins.opname in {"SETUP_EXCEPT", "SETUP_FINALLY"}:
block.try_root = True
if ins.opname in {"POP_EXCEPT", "END_FINALLY", "EXCEPT_HANDLER", "RERAISE"}:
block.handler = True
for src in cfg.blocks:
for tgt_idx in src.exits:
tgt = cfg.blocks[tgt_idx]
if tgt.start < src.start:
tgt.loop_head = True
# --------------------------------------------------------------------------
# graph helpers
# --------------------------------------------------------------------------
def _reachable(cfg: CFG, start: int) -> Set[int]:
seen = {start}
stack = [start]
while stack:
cur = stack.pop()
for e in cfg.blocks[cur].exits:
if e not in seen:
seen.add(e)
stack.append(e)
return seen
def _preds(cfg: CFG, nodes: Set[int]) -> Dict[int, Set[int]]:
preds: Dict[int, Set[int]] = {n: set() for n in nodes}
for n in nodes:
for e in cfg.blocks[n].exits:
if e in preds:
preds[e].add(n)
return preds
def _loop_body(cfg: CFG, head: int) -> Set[int]:
reach = _reachable(cfg, head)
preds = _preds(cfg, reach)
back: Set[int] = set()
work = [head]
while work:
n = work.pop()
if n in back:
continue
back.add(n)
for p in preds.get(n, ()):
if p not in back:
work.append(p)
return reach & back
def _loop_exit(cfg: CFG, head: int, body: Set[int]) -> Optional[int]:
for b in body:
for e in cfg.blocks[b].exits:
if e not in body:
return e
return None
# --------------------------------------------------------------------------
# expression / statement translator
# --------------------------------------------------------------------------
_BIN = {0: "+", 1: "-", 2: "*", 3: "/", 4: "//", 5: "%", 6: "**",
7: "<<", 8: ">>", 9: "&", 10: "^", 11: "|"}
_CMP = {0: "<", 1: "<=", 2: "==", 3: "!=", 4: ">", 5: ">=",
6: "is", 7: "is not", 8: "in", 9: "not in"}
_OLD_BIN = {"BINARY_ADD": "+", "BINARY_SUBTRACT": "-", "BINARY_MULTIPLY": "*",
"BINARY_TRUE_DIVIDE": "/", "BINARY_FLOOR_DIVIDE": "//",
"BINARY_MODULO": "%", "BINARY_POWER": "**",
"BINARY_LSHIFT": "<<", "BINARY_RSHIFT": ">>",
"BINARY_AND": "&", "BINARY_XOR": "^", "BINARY_OR": "|"}
def _clean_sym(s: str) -> str:
s = s.strip()
if s.startswith("bool(") and s.endswith(")"):
return s[5:-1]
if s.startswith("(") and s.endswith(")"):
return s[1:-1]
return s
def _op_sym(ins: dis.Instruction) -> str:
av = getattr(ins, "argval", None)
if isinstance(av, str) and av.strip():
return _clean_sym(av)
if ins.arg is not None and isinstance(ins.arg, int):
if "COMPARE" in ins.opname:
return _CMP.get(ins.arg, "?")
return _BIN.get(ins.arg, "?")
return _OLD_BIN.get(ins.opname, "?")
def _expr(ins: dis.Instruction, stack: List[str], names: Dict[str, str]) -> Optional[str]:
"""Translate one instruction. Statement-producing instructions return a
line; all others push/pop the expression stack and return None."""
name = ins.opname
if (name.startswith("LOAD_CONST") or name == "PUSH_CONST"
or "SMALL_INT" in name or "MEDIUM_INT" in name or "SMALL_STR" in name):
if isinstance(ins.argval, CodeType):
return None # nested code objects are rendered as their own section
stack.append(repr(ins.argval))
return None
if (name.startswith("LOAD_FAST") or name in {"LOAD_NAME", "LOAD_DEREF",
"LOAD_GLOBAL", "LOAD_CLASSDEREF"}):
if isinstance(ins.argval, str):
stack.append(names.get(ins.argval, ins.argval))
return None
if name.startswith("LOAD_ATTR") and isinstance(ins.argval, str):
if stack:
stack.append(f"{stack.pop()}.{ins.argval}")
return None
if name in {"CALL", "CALL_FUNCTION", "CALL_FUNCTION_KW"}:
argc = ins.arg if isinstance(ins.arg, int) else 0
kw = 1 if name == "CALL_FUNCTION_KW" else 0
n_args = max(0, argc - kw)
if len(stack) >= n_args + 1:
args = stack[-n_args:] if n_args else []
if n_args:
del stack[-n_args:]
func = stack.pop()
stack.append(f"{func}({', '.join(args)})")
return None
if name.startswith("IMPORT_NAME") and isinstance(ins.argval, str):
stack.append(ins.argval)
return None
if name in {"BINARY_SUBSCR", "BINARY_OP", "COMPARE_OP", "COMPARISON_OP"}:
if name == "BINARY_SUBSCR" and len(stack) >= 2:
idx = stack.pop()
obj = stack.pop()
stack.append(f"{obj}[{idx}]")
return None
if len(stack) >= 2:
rhs = stack.pop()
lhs = stack.pop()
stack.append(f"{lhs} {_op_sym(ins)} {rhs}")
return None
if name in _OLD_BIN and len(stack) >= 2:
rhs = stack.pop()
lhs = stack.pop()
stack.append(f"{lhs} {_op_sym(ins)} {rhs}")
return None
if name.startswith("UNARY_NOT"):
if stack:
stack.append(f"not ({stack.pop()})")
return None
if name.startswith("UNARY_NEGATIVE"):
if stack:
stack.append(f"-({stack.pop()})")
return None
if name in {"STORE_FAST", "STORE_NAME", "STORE_DEREF"}:
var = ins.argval if isinstance(ins.argval, str) else "?"
value = stack.pop() if stack else "..."
return f"{names.get(var, var)} = {value}"
if name.startswith("STORE_ATTR") and isinstance(ins.argval, str):
if len(stack) >= 2:
attr = ins.argval
obj = stack.pop()
value = stack.pop()
return f"{obj}.{attr} = {value}"
return None
if name == "POP_TOP":
if stack:
stack.pop()
return None
if name == "RETURN_VALUE":
value = stack.pop() if stack else "None"
return f"return {value}"
if name == "RETURN_CONST":
return f"return {repr(ins.argval)}"
if name.startswith("RAISE"):
return "raise <exception>"
if name in {"GET_ITER", "PUSH_NULL", "PRECALL", "KW_NAMES",
"RESUME", "NOT_TAKEN", "END_FOR", "POP_ITER",
"MAKE_FUNCTION", "RETURN_GENERATOR"}:
return None
return None
# --------------------------------------------------------------------------
# variable renaming
# --------------------------------------------------------------------------
def _fresh() -> Dict[str, int]:
return {"stores": 0, "loads": 0, "iter": 0, "incremented": 0}
def usage_stats(code: CodeType) -> Dict[str, Dict[str, int]]:
"""Heuristic per-variable usage: how often each name is stored/loaded,
whether it is the target of a FOR_ITER (loop variable) and whether it
is fed straight into a BINARY_OP (increment-ish)."""
stats: Dict[str, Dict[str, int]] = {}
insns = list(dis.get_instructions(code))
for i, ins in enumerate(insns):
v = ins.argval
if not isinstance(v, str):
continue
d = stats.setdefault(v, _fresh())
if (ins.opname.startswith("STORE_FAST") or ins.opname.startswith("STORE_NAME")
or ins.opname.startswith("STORE_DEREF")):
d["stores"] += 1
elif (ins.opname.startswith("LOAD_FAST") or ins.opname.startswith("LOAD_NAME")
or ins.opname.startswith("LOAD_DEREF") or ins.opname == "LOAD_GLOBAL"):
d["loads"] += 1
if "FOR_ITER" in ins.opname:
for nxt in insns[i + 1:]:
if nxt.opname.startswith("STORE_FAST") or nxt.opname.startswith("STORE_NAME"):
if isinstance(nxt.argval, str):
stats.setdefault(nxt.argval, _fresh())["iter"] += 1
break
if (ins.opname.startswith("BINARY_") or ins.opname.startswith("INPLACE_")):
if i - 1 >= 0 and insns[i - 1].opname.startswith("LOAD_") \
and isinstance(insns[i - 1].argval, str):
stats.setdefault(insns[i - 1].argval, _fresh())["incremented"] += 1
return stats
def rename_mangled(varnames: List[str], usage: Dict[str, Dict[str, int]]) -> Dict[str, str]:
"""Map single-letter/underscore names to semantic names based on the
usage statistics (i -> index when incremented, l1/iter vars -> item,
n -> count, and so on), guaranteeing unique outputs."""
preferred = {
"i": "index", "j": "index2", "k": "index3",
"n": "count", "c": "char", "v": "value", "d": "data",
"t": "tmp", "x": "coord_x", "y": "coord_y",
"l": "item", "ln": "line", "idx": "index", "s": "string",
}
mapping: Dict[str, str] = {}
used: Set[str] = set()
for v in varnames:
if not isinstance(v, str) or not v:
continue
base = v.lstrip("_") or "var"
u = usage.get(v, {})
if u.get("iter", 0) > 0 and u.get("incremented", 0) == 0:
suggested = "item"
elif u.get("incremented", 0) > 0:
suggested = "index"
elif base in preferred:
suggested = preferred[base]
else:
suggested = base
candidate = suggested
suffix = 2
while candidate in used:
candidate = f"{suggested}_{suffix}"
suffix += 1
used.add(candidate)
mapping[v] = candidate
return mapping
# --------------------------------------------------------------------------
# pseudo-source rendering
# --------------------------------------------------------------------------
INDENT = " "
def _cond_expr(block: Block, names: Dict[str, str]) -> str:
stack: List[str] = []
for ins in block.instructions:
if _is_conditional_jump(ins):
break
_expr(ins, stack, names)
return stack[-1] if stack else "<condition>"
def _for_iterable(block: Block, names: Dict[str, str]) -> str:
stack: List[str] = []
for ins in block.instructions:
if ins.opname == "FOR_ITER":
break
_expr(ins, stack, names)
return stack[-1] if stack else "<iterable>"
def _for_loop_var(cfg: CFG, head: int, names: Dict[str, str]) -> Optional[str]:
body = _loop_body(cfg, head)
cands = [(cfg.blocks[b].start, b) for b in body if b != head]
if not cands:
return None
first = min(cands)[1]
for ins in cfg.blocks[first].instructions:
if ins.opname in {"STORE_FAST", "STORE_NAME", "STORE_DEREF"}:
if isinstance(ins.argval, str):
return names.get(ins.argval, ins.argval)
return None
return None
def _loop_cond(cfg: CFG, head: int, body: Set[int], names: Dict[str, str]) -> str:
for b in sorted(body):
c = cfg.blocks[b]
last = c.instructions[-1]
if _is_conditional_jump(last):
e = _cond_expr(c, names)
if "TRUE" in last.opname and "FALSE" not in last.opname:
return f"not ({e})"
return e
return "True"
def _ends_in_forward_jump(cfg: CFG, ids: List[int]) -> Optional[int]:
if not ids:
return None
lastb = ids[-1]
last = cfg.blocks[lastb].instructions[-1]
if _is_jump(last) and not _is_conditional_jump(last) \
and isinstance(last.argval, int):
tgt = cfg.index_of(last.argval)
if tgt is not None and tgt > lastb:
return tgt
return None
def render_source(cfg: CFG, names: Optional[Dict[str, str]] = None) -> str:
"""Render a faithful structured pseudo-Python outline of the CFG."""
if names is None:
usage = usage_stats(cfg.code)
names = rename_mangled(list(cfg.code.co_varnames), usage)
if cfg.vacuous:
return "# (no bytecode)"
out: List[str] = []
emitted: Set[int] = set()
DEPTH_LIMIT = 60
LINE_LIMIT = 12000
def pad(level: int) -> str:
return INDENT * level
def flush_block(block: Block, level: int) -> None:
stack: List[str] = []
for ins in block.instructions:
if _is_jump(ins):
break
stmt = _expr(ins, stack, names)
if stmt:
out.append(pad(level) + stmt)
if ins.opname in {"RETURN_VALUE", "RETURN_CONST",
"RAISE_VARARGS", "RAISE_CONST"}:
break
def emit_blocks(ids: List[int], level: int, depth: int) -> None:
for rid in ids:
emit(rid, level, depth)
def emit(idx: Optional[int], level: int, depth: int) -> None:
while idx is not None:
if len(out) > LINE_LIMIT or depth > DEPTH_LIMIT:
return
if idx >= len(cfg.blocks) or idx in emitted:
return
block = cfg.blocks[idx]
# --- loop head --------------------------------------------------
if block.loop_head:
emitted.add(idx)
body = _loop_body(cfg, idx)
body_ids = sorted(b for b in body if b != idx)
if any(i.opname == "FOR_ITER" for i in block.instructions):
var = _for_loop_var(cfg, idx, names) or "item"
it = _for_iterable(block, names)
out.append(f"{pad(level)}for {var} in {it}:")
else:
out.append(f"{pad(level)}while {_loop_cond(cfg, idx, body, names)}:")
for b in body_ids:
emit(b, level + 1, depth + 1)
idx = _loop_exit(cfg, idx, body)
continue
# --- try root ---------------------------------------------------
if block.try_root:
emitted.add(idx)
out.append(f"{pad(level)}try: # setup at offset {block.start}")
handler_idx = None
for ins in block.instructions:
if ins.opname in {"SETUP_EXCEPT", "SETUP_FINALLY"} \
and isinstance(ins.argval, int):
handler_idx = cfg.index_of(ins.argval)
j = idx + 1
guard = 0
while j is not None and j < len(cfg.blocks) and guard < 2000:
guard += 1
if j == handler_idx or cfg.blocks[j].handler:
break
if cfg.blocks[j].loop_head or cfg.blocks[j].try_root:
break
emit(j, level + 1, depth + 1)
last = cfg.blocks[j].instructions[-1]
if _is_jump(last) or last.opname in _TERMINATORS:
nxt = [e for e in cfg.blocks[j].exits
if e is not None and e > j]
j = nxt[0] if nxt else None
if j is None:
break
if cfg.blocks[j].start < cfg.blocks[idx].start + 1:
break
else:
j += 1
if j is not None and j < len(cfg.blocks) \
and (j == handler_idx or cfg.blocks[j].handler):
out.append(f"{pad(level)}except <handler> as exc: # block {j}")
emit(j, level + 1, depth + 1)
idx = j + 1
else:
out.append(f"{pad(level)}# except handler nested or 3.11+-style")
idx = j
continue
# --- conditional jump -> if/else --------------------------------
last = block.instructions[-1]
if _is_conditional_jump(last) and isinstance(last.argval, int):
t = cfg.index_of(last.argval)
expr = _cond_expr(block, names)
cond = f"not ({expr})" if ("TRUE" in last.opname
and "FALSE" not in last.opname) else expr
emitted.add(idx)
fall_ids: List[int] = []
j = idx + 1
guard = 0
while j is not None and j < len(cfg.blocks) and guard < 2000:
guard += 1
if j == t:
break
if cfg.blocks[j].loop_head or cfg.blocks[j].try_root:
break
fall_ids.append(j)
last_j = cfg.blocks[j].instructions[-1]
if _is_jump(last_j) or last_j.opname in _TERMINATORS:
nxt = [e for e in cfg.blocks[j].exits
if e is not None and e > j]
j = nxt[0] if nxt else None
if j is None or j == t:
break
if cfg.blocks[j].start < cfg.blocks[idx].start + 1:
break
else:
j += 1
out.append(f"{pad(level)}if {cond}:")
emit_blocks(fall_ids, level + 1, depth + 1)
if t is not None and t < len(cfg.blocks):
join = _ends_in_forward_jump(cfg, fall_ids)
if join is not None and join != t and join > t:
out.append(f"{pad(level)}else:")
emit_blocks(list(range(t, join)), level + 1, depth + 1)
idx = join
else:
out.append(f"{pad(level)}# else/goto -> block {t}")
idx = t
continue
idx = j
continue
# --- straight-line block ----------------------------------------
emitted.add(idx)
flush_block(block, level)
if block.ends_with == "jump" and block.exits:
if block.exits[0] != idx + 1:
out.append(f"{pad(level)}# -> block {block.exits[0]}")
idx = block.exits[0]
continue
if block.ends_with in {"return", "raise"}:
return
idx = idx + 1
emit(0, 0, 0)
while out and not out[-1].strip():
out.pop()
return "\n".join(out)
# --------------------------------------------------------------------------
# obfuscation detection
# --------------------------------------------------------------------------
def _walk_consts(code: CodeType):
stack = [code]
while stack:
c = stack.pop()
for const in c.co_consts:
yield const
if isinstance(const, CodeType):
stack.append(const)
def detect_obfuscation(code: CodeType) -> List[Dict[str, Any]]:
flags: List[Dict[str, Any]] = []
consts = list(_walk_consts(code))
strings = [c for c in consts if isinstance(c, str)]
if not strings:
flags.append({
"kind": "no-string-constants",
"detail": "code carries no string constants (string-stripped pyc "
"or heavy obfuscation)",
})
huge = [c for c in consts
if (isinstance(c, bytes) and len(c) > (1 << 22))
or (isinstance(c, int) and (c.bit_length() + 7) // 8 > (1 << 22))]
if huge:
flags.append({
"kind": "oversized-constants",
"count": len(huge),
"detail": f"{len(huge)} constants each exceed 4 MiB (payload blobs)",
})
names = set(code.co_names)
dyn = [n for n in ("eval", "exec", "marshal", "base64", "zlib",
"builtins") if n in names]
if "eval" in dyn or "exec" in dyn:
flags.append({
"kind": "dynamic-execution",
"detail": f"module references {', '.join(dyn)} likely for "
f"runtime payload decoding",
})
return flags
def all_codes(code: CodeType) -> List[CodeType]:
"""Module code plus every nested code object, depth-first, deduped."""
seen_ids: Set[int] = set()
order: List[CodeType] = []
def walk(c: CodeType) -> None:
if id(c) in seen_ids:
return
seen_ids.add(id(c))
order.append(c)
for const in c.co_consts:
if isinstance(const, CodeType):
walk(const)
walk(code)
return order
def reconstruct_source(code: CodeType) -> Tuple[str, Dict[str, Any]]:
parts: List[str] = []
report: Dict[str, Any] = {
"file": getattr(code, "co_filename", "<unknown>"),
"magic_ok": True,
"obfuscation": detect_obfuscation(code),
"functions": [],
"nested_code_objects": 0,
}
for chunk in all_codes(code):
cfg = flow_reconstruct(chunk)
usage = usage_stats(chunk)
names = rename_mangled(list(chunk.co_varnames), usage)
body = render_source(cfg, names=names)
if chunk.co_name and chunk.co_name != "<module>":
params = ", ".join(chunk.co_varnames[: chunk.co_argcount]) or ""
parts.append(f"def {chunk.co_name}({params}):")
if body.strip():
parts.append("\n".join(INDENT + ln for ln in body.splitlines()))
else:
parts.append(INDENT + "pass # reconstructed outline")
elif body.strip():
parts.append(body)
else:
parts.append("# empty code object")
report["functions"].append({
"name": chunk.co_name,
"argc": chunk.co_argcount,
"varnames": list(chunk.co_varnames),
"rendered_lines": len(body.splitlines()),
})
report["nested_code_objects"] += 1
return "\n".join(parts).strip() + "\n", report
def _sample_code_object() -> CodeType:
src = (
"def find(items, target):\n"
" for i in range(len(items)):\n"
" if items[i] == target:\n"
" return i\n"
" return -1\n"
"marks = find([3, 1, 4, 1, 5], 4)\n"
"print(marks)\n"
)
return compile(src, "<demo>", "exec")
# --------------------------------------------------------------------------
# CLI
# --------------------------------------------------------------------------
def main(argv=None) -> int:
parser = argparse.ArgumentParser(
prog="unpyc_assistant",
description="Disassemble and reconstruct Python .pyc bytecode; "
"flag obfuscation heuristics.",
)
parser.add_argument("--pyc", help="path to the .pyc file to analyze")
parser.add_argument("--out", default="", help="write reconstructed source here")
parser.add_argument("--report", default="",
help="write the JSON diagnostics report here")
parser.add_argument("--dis", action="store_true",
help="print raw disassembly first")
parser.add_argument("--demo", action="store_true",
help="reconstruct a built-in compiled sample instead of --pyc")
args = parser.parse_args(argv)
if args.demo:
code = _sample_code_object()
label = "<demo>"
elif args.pyc:
try:
code = load_pyc(Path(args.pyc))
except (ValueError, OSError, MemoryError) as exc:
print(f"unpyc_assistant: {exc}", file=sys.stderr)
return 2
label = args.pyc
else:
parser.error("--pyc is required (or pass --demo)")
return 2
if args.dis:
print(f"# disassembly of {label}")
print(disassemble(code))
text, report = reconstruct_source(code)
print(text)
if args.out:
Path(args.out).write_text(text, encoding="utf-8")
report["source_file"] = args.out or None
payload = json.dumps(report, indent=2, sort_keys=True)
print("# " + payload)
if args.report:
Path(args.report).write_text(payload + "\n", encoding="utf-8")
return 0
if __name__ == "__main__":
sys.exit(main())
4. Execution Protocol & Step-by-Step Workflow
- Verify the runtime and the pyc's origin:
python -c "import importlib.util, sys; print(sys.version, importlib.util.MAGIC_NUMBER.hex())". The loader accepts only the running interpreter's magic plus raw-marshal fallbacks; cross-version .pyc needs the matching runtime or uncompyle6 (see step 6).
- Preflight the file:
file sample.pyc (or xxd sample.pyc | head) to confirm the 4-byte magic and the header shape before loading.
- Load and disassemble first:
python unpyc_assistant.py --pyc sample.pyc --dis. Read the raw dis output to sanity-check the reconstructed control flow you are about to trust.
- Produce the structured reconstruction:
python unpyc_assistant.py --pyc sample.pyc --out sample_recon.py --report sample_report.json. The rendered source is a structured outline: loops/try/if framing is real, but expression details (attribute chains, keyword args, comprehensions) are approximated and annotated with block indices in comments.
- Read the report's
obfuscation array:
no-string-constants — the pyc had all text stripped (common in packed malware/obfuscated deployables); treat every reconstructed call accordingly.
oversized-constants — 4 MiB+ int/bytes blobs; inspect via marshal and un-pickling offline, never by executing them.
dynamic-execution — module references eval/exec/marshal/base64; flag the pyc as potentially self-decoding and refuse to run it in a privileged context.
- Deeper checks: for suspected bytecode-level tampering, compare
code.co_code length and magic against a clean build of the same source (python -m compileall) and diff the two .pycs.
- When byte-exact source is required and
uncompyle6 is available (matches the CPython version of the pyc), use the external pipeline:python -m pip install uncompyle6
python -m uncompyle6 sample.pyc # stdout
python -m uncompyle6 -o out/ sample.pyc # one .py per module
Cross-check its output against this module's reconstruction; agreement on framing is strong evidence the reconstruction is faithful.
- For the JavaScript/Node bytecode case, the same decomposition applies with the V8 disassembler flags (
node --print-bytecode or js2pyc-style tooling); this skill's CFG math (basic blocks, back-edge loop heads, exception handler regions) is language-agnostic.
- Iterate: render → compare with
--dis output → adjust nothing in the module (it is intentionally heuristic) but record block-of-interest offsets in the ticket.
5. Edge Cases & Error Handling
- Magic mismatch:
load_pyc raises ValueError naming both magics; never force-load foreign-version bytecode — marshal format alone can corrupt or mis-tag. Recompile with the writer's Python or use a matching toolchain.
- Truncated / corrupt
.pyc: EOSError/ValueError from marshal.loads inside the loop fall through all offsets and produce a clear ValueError; truncated files never partially mutate state (loading is read-only).
- Bare marshaled blobs: files that are just a marshaled code object (no header) are recovered by the offset-
0 fallback.
PEP 552 hash-based pycs: header is still 16 bytes (4 magic + 4 flags + 8 hash), so data[16:] is correct; confirmed by matching importlib.util.MAGIC_NUMBER.
- Python 3.11+ exception tables:
SETUP_EXCEPT/POP_EXCEPT no longer exist as raw opcodes; flow_reconstruct falls back to co_exceptiontable-agnostic bounds and the renderer emits a # except handler: nested or 3.11+-style comment rather than guessing a wrong except.
FOR_ITER variants and cache instructions: dis.get_instructions hides caches by default; the block splitter tolerates variant opcodes because it keys on offset containment, not opcode identity.
- Pathologically nested code objects:
all_codes dedupes by id() and render_source caps depth (60) and lines (12000); pathological payloads produce truncated-but-valid outlines with # comments, never an exception.
- Recursion in
gc-style back-links / malicious constants: rendering only ever calls repr and str on constants; a __repr__-abusing object is not possible in raw marshal constants (marshaled objects are immutable primitives and code), so there is no __repr__ re-entry risk.
- Sandbox note:
marshal.loads can deserialize hostile graphs (e.g. deep recursion causing MemoryError); catch MemoryError explicitly (done) and run suspicious pycs inside a subprocess with ulimit -v/container limits.
- Variable rename collisions:
rename_mangled guarantees uniqueness by suffixing (index, index_2, ...); names that already look semantic (result, data) pass through unchanged.
- Imperfect hearsay: the module explicitly does NOT claim byte-exact output; treat
uncompyle6 as the authoritative second opinion whenever framing disagreements matter.
1---2name: bytecode-decompiler-obfuscation-assistant3description: Loads Python pyc bytecode via marshal after importlib header validation, pretty-prints raw dis, reconstructs a basic-block control-flow graph annotated with loop heads and try/except regions, renames mangled single-letter variables using usage heuristics, renders a readable structured pseudo-Python outline, flags obfuscation signatures such as missing strings, oversized constants and dynamic eval execution, and documents the uncompyle6 byte-exact pipeline when it is installable.4metadata:5 source: skills/bytecode-decompiler-assistant/bytecode-decompiler-assistant.md6---78# Bytecode Decompiler & Obfuscation Assistant910## 1. System Architecture & Prerequisites1112- Runtime binary: **Python 3.9 or newer** (uses `dis.get_instructions`, `ast.unparse`-free rendering, `importlib.util.MAGIC_NUMBER`, `marshal`). Tested primarily on CPython 3.9–3.13 `.pyc` files.13- Standard library only: `dis`, `marshal`, `importlib.util`, `io`, `json`, `argparse`, `re`, `sys`, `pathlib`, `types.CodeType`, `dataclasses`, `typing`.14- Optional (documented in Section 4, never required by the ship'ed module): **`uncompyle6`** from PyPI for byte-exact decompilation of matching CPython versions, and the `xdis` package it wraps. When the installed interpreter matches the `.pyc` version, `uncompyle6 file.pyc` produces exact source.15- Input: a single `.pyc` (Python bytecode) file — either a timestamp pyc (16-byte header: magic + flags + mtime + source size), a PEP 552 hash-based pyc (magic + flags + 8-byte hash), or a bare `marshal`-dumped code object. Loading is **not** a security boundary: `marshal` can craft hostile objects; analyze only trusted files or run inside a throwaway sandbox.1617## 2. Input/Output Data Contracts1819### 2.1 CLI input options (JSON Schema)2021```json22{23 "$schema": "http://json-schema.org/draft-07/schema#",24 "title": "unpyc_assistant_cli_options",25 "type": "object",26 "properties": {27 "pyc": { "type": "string", "description": "Path to the .pyc file to analyze." },28 "out": { "type": "string", "description": "Path where reconstructed pseudo-Python is written." },29 "report": { "type": "string", "description": "Path where the JSON diagnostics/show report is written." },30 "dis": { "type": "boolean", "default": false, "description": "Print raw disassembly for the module and nested code objects." },31 "demo": { "type": "boolean", "default": false, "description": "Reconstruct a compiled built-in sample instead of --pyc." }32 },33 "oneOf": [34 { "required": ["pyc"] },35 { "required": ["demo"] }36 ]37}38```3940### 2.2 Output artifacts4142- **Reconstructed source** (`--out`): readable pseudo-Python with `def` headers, `for`/`while`/`if`/`try` framing, renamed variables, and `#` block annotations. Documented as a faithful structured reconstruction, not a byte-exact decompilation.43- **Diagnostics report** (`--report`): JSON with one entry per code object (`name`, `argc`, `varnames`, `rendered_lines`) plus `obfuscation` findings (`no-string-constants`, `oversized-constants`, `dynamic-execution`) and `nested_code_objects` count.44- No input files are modified by this skill.4546## 3. Production Reference Implementation4748```python49"""Bytecode Decompiler & Obfuscation Assistant.5051Loads Python ``.pyc`` bytecode, disassembles it, rebuilds a structured52basic-block control-flow graph, renames mangled single-letter variables by53usage heuristics, renders readable pseudo-Python, and flags obfuscation54signatures.5556Pipeline:57``load_pyc()`` -> CodeType (importlib.header-aware marshal load)58``disassemble()``-> raw ``dis.dis`` text59``flow_reconstruct()`` -> CFG of basic blocks with loop-head / try-except60 annotations and edge lists61``usage_stats()`` + ``rename_mangled()`` -> semantic variable names62``render_source()`` -> readable pseudo-Python outline (NOT byte-exact)63``detect_obfuscation()`` -> report of stripped-string and eval-style payloads6465Section on obfuscation: obfuscated/decompiler-hostile ``.pyc`` files are66detected by (a) an absence of string constants (string-stripped pycs),67(b) oversized int/bytes constants (payload blobs), and (c) module-level68references to ``eval``/``exec``/``marshal``/``base64``/``zlib`` used to69decode a payload at runtime.7071When ``uncompyle6`` (PyPI) is installed and the CPython version matches the72pyc, byte-exact source can be produced externally:73 python -m uncompyle6 file.pyc74The structured reconstruction in this module remains the dependency-free75fallback.76"""7778from __future__ import annotations7980import argparse81import dis82import importlib.util83import io84import json85import marshal86import re87import sys88from dataclasses import dataclass, field89from pathlib import Path90from types import CodeType91from typing import Any, Dict, List, Optional, Set, Tuple, Union929394# --------------------------------------------------------------------------95# loading96# --------------------------------------------------------------------------97def load_pyc(path: Union[str, Path]) -> CodeType:98 """Load a .pyc file into a code object.99100 When the on-disk magic matches the running interpreter, the marshaled101 code object starts right after the fixed 16-byte header (magic, flags,102 timestamp/size, or PEP 552 hash). Otherwise a raw marshal attempt is103 made against every plausible header offset so legacy pycs without a104 recognized magic can still load when their marshal stream aligns.105 """106 data = Path(path).read_bytes()107 if not data:108 raise ValueError(f"{path} is empty")109 magic = data[:4]110 if magic == importlib.util.MAGIC_NUMBER:111 code = _code_from_marshal(data[16:], str(path), magic)112 return code113 for offset in (8, 12, 16, 0):114 try:115 code = marshal.loads(data[offset:])116 except (ValueError, TypeError, EOFError, MemoryError):117 continue118 if isinstance(code, CodeType):119 return code120 raise ValueError(121 f"{path}: unrecognized pyc magic {magic!r}; this interpreter expects "122 f"{importlib.util.MAGIC_NUMBER!r}. Recompile with the matching "123 f"Python or point load_pyc at the writer's runtime."124 )125126127def _code_from_marshal(blob: bytes, label: str, magic: bytes) -> CodeType:128 try:129 code = marshal.loads(blob)130 except (ValueError, TypeError, EOFError, MemoryError) as exc:131 raise ValueError(f"{label}: magic matches but marshal load failed: {exc}") from exc132 if not isinstance(code, CodeType):133 raise ValueError(f"{label}: marshal payload is not a code object")134 return code135136137# --------------------------------------------------------------------------138# dis139# --------------------------------------------------------------------------140def disassemble(code: CodeType, show_caches: bool = False) -> str:141 out = io.StringIO()142 dis.dis(code, file=out, show_caches=show_caches)143 return out.getvalue()144145146# --------------------------------------------------------------------------147# control-flow reconstruction148# --------------------------------------------------------------------------149@dataclass150class Block:151 index: int152 start: int153 instructions: List[dis.Instruction] = field(default_factory=list)154 kind: str = "plain"155 exits: List[int] = field(default_factory=list)156 ends_with: str = "fall" # fall | jump | return | raise157 loop_head: bool = False158 try_root: bool = False159 handler: bool = False160161162@dataclass163class CFG:164 code: CodeType165 blocks: List[Block] = field(default_factory=list)166 vacuous: bool = False167168 def index_of(self, target: int) -> Optional[int]:169 for b in self.blocks:170 if b.start == target:171 return b.index172 return None173174175_TERMINATORS = {"RETURN_VALUE", "RETURN_CONST", "RAISE_VARARGS", "RAISE_CONST",176 "POP_EXCEPT", "END_FINALLY", "BREAK_LOOP", "CONTINUE_LOOP",177 "EXCEPT_HANDLER"}178179180def _is_jump(ins: dis.Instruction) -> bool:181 return ins.opname.startswith("JUMP") or ins.opname == "FOR_ITER"182183184_COND_JUMPS = {185 "POP_JUMP_IF_FALSE", "POP_JUMP_IF_TRUE",186 "POP_JUMP_FORWARD_IF_FALSE", "POP_JUMP_FORWARD_IF_TRUE",187 "POP_JUMP_BACKWARD_IF_FALSE", "POP_JUMP_BACKWARD_IF_TRUE",188 "JUMP_IF_FALSE_OR_POP", "JUMP_IF_TRUE_OR_POP",189 "JUMP_IF_FALSE", "JUMP_IF_TRUE",190}191192193def _is_conditional_jump(ins: dis.Instruction) -> bool:194 return ins.opname in _COND_JUMPS195196197def flow_reconstruct(code: CodeType) -> CFG:198 """Split the instruction stream into basic blocks and annotate loops199 and try/except regions."""200 instrs = list(dis.get_instructions(code))201 cfg = CFG(code=code)202 if not instrs:203 cfg.vacuous = True204 return cfg205206 starts = {instrs[0].offset}207 for ins in instrs:208 if _is_jump(ins) or ins.opname in {"SETUP_LOOP", "SETUP_EXCEPT", "SETUP_FINALLY"}:209 if isinstance(ins.argval, int):210 starts.add(ins.argval)211 for i, ins in enumerate(instrs[:-1]):212 if _is_jump(ins) or ins.opname in _TERMINATORS:213 starts.add(instrs[i + 1].offset)214 starts = sorted(starts)215216 i = 0217 while i < len(instrs):218 block_start = instrs[i].offset219 block_ins: List[dis.Instruction] = []220 while i < len(instrs):221 ins = instrs[i]222 block_ins.append(ins)223 barrier = _is_jump(ins) or ins.opname in _TERMINATORS224 nxt_offset = instrs[i + 1].offset if i + 1 < len(instrs) else None225 if barrier or (nxt_offset is not None and nxt_offset in starts):226 i += 1227 break228 i += 1229 cfg.blocks.append(Block(index=len(cfg.blocks), start=block_start,230 instructions=block_ins))231232 _compute_edges(cfg)233 _annotate(cfg)234 return cfg235236237def _compute_edges(cfg: CFG) -> None:238 for block in cfg.blocks:239 last = block.instructions[-1]240 name = last.opname241 if name in {"RETURN_VALUE", "RETURN_CONST"}:242 block.ends_with = "return"243 elif name in {"RAISE_VARARGS", "RAISE_CONST", "RERAISE"}:244 block.ends_with = "raise"245 elif _is_jump(last):246 block.ends_with = "jump"247 if isinstance(last.argval, int):248 t = cfg.index_of(last.argval)249 if t is not None:250 block.exits.append(t)251 if (name == "FOR_ITER" or "JUMP_IF" in name252 or name.startswith("POP_JUMP")):253 if block.index + 1 < len(cfg.blocks):254 block.exits.append(block.index + 1)255256257def _annotate(cfg: CFG) -> None:258 for block in cfg.blocks:259 if any(ins.opname == "FOR_ITER" for ins in block.instructions):260 block.loop_head = True261 for ins in block.instructions:262 if ins.opname in {"SETUP_EXCEPT", "SETUP_FINALLY"}:263 block.try_root = True264 if ins.opname in {"POP_EXCEPT", "END_FINALLY", "EXCEPT_HANDLER", "RERAISE"}:265 block.handler = True266 for src in cfg.blocks:267 for tgt_idx in src.exits:268 tgt = cfg.blocks[tgt_idx]269 if tgt.start < src.start:270 tgt.loop_head = True271272273# --------------------------------------------------------------------------274# graph helpers275# --------------------------------------------------------------------------276def _reachable(cfg: CFG, start: int) -> Set[int]:277 seen = {start}278 stack = [start]279 while stack:280 cur = stack.pop()281 for e in cfg.blocks[cur].exits:282 if e not in seen:283 seen.add(e)284 stack.append(e)285 return seen286287288def _preds(cfg: CFG, nodes: Set[int]) -> Dict[int, Set[int]]:289 preds: Dict[int, Set[int]] = {n: set() for n in nodes}290 for n in nodes:291 for e in cfg.blocks[n].exits:292 if e in preds:293 preds[e].add(n)294 return preds295296297def _loop_body(cfg: CFG, head: int) -> Set[int]:298 reach = _reachable(cfg, head)299 preds = _preds(cfg, reach)300 back: Set[int] = set()301 work = [head]302 while work:303 n = work.pop()304 if n in back:305 continue306 back.add(n)307 for p in preds.get(n, ()):308 if p not in back:309 work.append(p)310 return reach & back311312313def _loop_exit(cfg: CFG, head: int, body: Set[int]) -> Optional[int]:314 for b in body:315 for e in cfg.blocks[b].exits:316 if e not in body:317 return e318 return None319320321# --------------------------------------------------------------------------322# expression / statement translator323# --------------------------------------------------------------------------324_BIN = {0: "+", 1: "-", 2: "*", 3: "/", 4: "//", 5: "%", 6: "**",325 7: "<<", 8: ">>", 9: "&", 10: "^", 11: "|"}326_CMP = {0: "<", 1: "<=", 2: "==", 3: "!=", 4: ">", 5: ">=",327 6: "is", 7: "is not", 8: "in", 9: "not in"}328_OLD_BIN = {"BINARY_ADD": "+", "BINARY_SUBTRACT": "-", "BINARY_MULTIPLY": "*",329 "BINARY_TRUE_DIVIDE": "/", "BINARY_FLOOR_DIVIDE": "//",330 "BINARY_MODULO": "%", "BINARY_POWER": "**",331 "BINARY_LSHIFT": "<<", "BINARY_RSHIFT": ">>",332 "BINARY_AND": "&", "BINARY_XOR": "^", "BINARY_OR": "|"}333334335def _clean_sym(s: str) -> str:336 s = s.strip()337 if s.startswith("bool(") and s.endswith(")"):338 return s[5:-1]339 if s.startswith("(") and s.endswith(")"):340 return s[1:-1]341 return s342343344def _op_sym(ins: dis.Instruction) -> str:345 av = getattr(ins, "argval", None)346 if isinstance(av, str) and av.strip():347 return _clean_sym(av)348 if ins.arg is not None and isinstance(ins.arg, int):349 if "COMPARE" in ins.opname:350 return _CMP.get(ins.arg, "?")351 return _BIN.get(ins.arg, "?")352 return _OLD_BIN.get(ins.opname, "?")353354355def _expr(ins: dis.Instruction, stack: List[str], names: Dict[str, str]) -> Optional[str]:356 """Translate one instruction. Statement-producing instructions return a357 line; all others push/pop the expression stack and return None."""358 name = ins.opname359 if (name.startswith("LOAD_CONST") or name == "PUSH_CONST"360 or "SMALL_INT" in name or "MEDIUM_INT" in name or "SMALL_STR" in name):361 if isinstance(ins.argval, CodeType):362 return None # nested code objects are rendered as their own section363 stack.append(repr(ins.argval))364 return None365 if (name.startswith("LOAD_FAST") or name in {"LOAD_NAME", "LOAD_DEREF",366 "LOAD_GLOBAL", "LOAD_CLASSDEREF"}):367 if isinstance(ins.argval, str):368 stack.append(names.get(ins.argval, ins.argval))369 return None370 if name.startswith("LOAD_ATTR") and isinstance(ins.argval, str):371 if stack:372 stack.append(f"{stack.pop()}.{ins.argval}")373 return None374 if name in {"CALL", "CALL_FUNCTION", "CALL_FUNCTION_KW"}:375 argc = ins.arg if isinstance(ins.arg, int) else 0376 kw = 1 if name == "CALL_FUNCTION_KW" else 0377 n_args = max(0, argc - kw)378 if len(stack) >= n_args + 1:379 args = stack[-n_args:] if n_args else []380 if n_args:381 del stack[-n_args:]382 func = stack.pop()383 stack.append(f"{func}({', '.join(args)})")384 return None385 if name.startswith("IMPORT_NAME") and isinstance(ins.argval, str):386 stack.append(ins.argval)387 return None388 if name in {"BINARY_SUBSCR", "BINARY_OP", "COMPARE_OP", "COMPARISON_OP"}:389 if name == "BINARY_SUBSCR" and len(stack) >= 2:390 idx = stack.pop()391 obj = stack.pop()392 stack.append(f"{obj}[{idx}]")393 return None394 if len(stack) >= 2:395 rhs = stack.pop()396 lhs = stack.pop()397 stack.append(f"{lhs} {_op_sym(ins)} {rhs}")398 return None399 if name in _OLD_BIN and len(stack) >= 2:400 rhs = stack.pop()401 lhs = stack.pop()402 stack.append(f"{lhs} {_op_sym(ins)} {rhs}")403 return None404 if name.startswith("UNARY_NOT"):405 if stack:406 stack.append(f"not ({stack.pop()})")407 return None408 if name.startswith("UNARY_NEGATIVE"):409 if stack:410 stack.append(f"-({stack.pop()})")411 return None412 if name in {"STORE_FAST", "STORE_NAME", "STORE_DEREF"}:413 var = ins.argval if isinstance(ins.argval, str) else "?"414 value = stack.pop() if stack else "..."415 return f"{names.get(var, var)} = {value}"416 if name.startswith("STORE_ATTR") and isinstance(ins.argval, str):417 if len(stack) >= 2:418 attr = ins.argval419 obj = stack.pop()420 value = stack.pop()421 return f"{obj}.{attr} = {value}"422 return None423 if name == "POP_TOP":424 if stack:425 stack.pop()426 return None427 if name == "RETURN_VALUE":428 value = stack.pop() if stack else "None"429 return f"return {value}"430 if name == "RETURN_CONST":431 return f"return {repr(ins.argval)}"432 if name.startswith("RAISE"):433 return "raise <exception>"434 if name in {"GET_ITER", "PUSH_NULL", "PRECALL", "KW_NAMES",435 "RESUME", "NOT_TAKEN", "END_FOR", "POP_ITER",436 "MAKE_FUNCTION", "RETURN_GENERATOR"}:437 return None438 return None439440441# --------------------------------------------------------------------------442# variable renaming443# --------------------------------------------------------------------------444def _fresh() -> Dict[str, int]:445 return {"stores": 0, "loads": 0, "iter": 0, "incremented": 0}446447448def usage_stats(code: CodeType) -> Dict[str, Dict[str, int]]:449 """Heuristic per-variable usage: how often each name is stored/loaded,450 whether it is the target of a FOR_ITER (loop variable) and whether it451 is fed straight into a BINARY_OP (increment-ish)."""452 stats: Dict[str, Dict[str, int]] = {}453 insns = list(dis.get_instructions(code))454 for i, ins in enumerate(insns):455 v = ins.argval456 if not isinstance(v, str):457 continue458 d = stats.setdefault(v, _fresh())459 if (ins.opname.startswith("STORE_FAST") or ins.opname.startswith("STORE_NAME")460 or ins.opname.startswith("STORE_DEREF")):461 d["stores"] += 1462 elif (ins.opname.startswith("LOAD_FAST") or ins.opname.startswith("LOAD_NAME")463 or ins.opname.startswith("LOAD_DEREF") or ins.opname == "LOAD_GLOBAL"):464 d["loads"] += 1465 if "FOR_ITER" in ins.opname:466 for nxt in insns[i + 1:]:467 if nxt.opname.startswith("STORE_FAST") or nxt.opname.startswith("STORE_NAME"):468 if isinstance(nxt.argval, str):469 stats.setdefault(nxt.argval, _fresh())["iter"] += 1470 break471 if (ins.opname.startswith("BINARY_") or ins.opname.startswith("INPLACE_")):472 if i - 1 >= 0 and insns[i - 1].opname.startswith("LOAD_") \473 and isinstance(insns[i - 1].argval, str):474 stats.setdefault(insns[i - 1].argval, _fresh())["incremented"] += 1475 return stats476477478def rename_mangled(varnames: List[str], usage: Dict[str, Dict[str, int]]) -> Dict[str, str]:479 """Map single-letter/underscore names to semantic names based on the480 usage statistics (i -> index when incremented, l1/iter vars -> item,481 n -> count, and so on), guaranteeing unique outputs."""482 preferred = {483 "i": "index", "j": "index2", "k": "index3",484 "n": "count", "c": "char", "v": "value", "d": "data",485 "t": "tmp", "x": "coord_x", "y": "coord_y",486 "l": "item", "ln": "line", "idx": "index", "s": "string",487 }488 mapping: Dict[str, str] = {}489 used: Set[str] = set()490 for v in varnames:491 if not isinstance(v, str) or not v:492 continue493 base = v.lstrip("_") or "var"494 u = usage.get(v, {})495 if u.get("iter", 0) > 0 and u.get("incremented", 0) == 0:496 suggested = "item"497 elif u.get("incremented", 0) > 0:498 suggested = "index"499 elif base in preferred:500 suggested = preferred[base]501 else:502 suggested = base503 candidate = suggested504 suffix = 2505 while candidate in used:506 candidate = f"{suggested}_{suffix}"507 suffix += 1508 used.add(candidate)509 mapping[v] = candidate510 return mapping511512513# --------------------------------------------------------------------------514# pseudo-source rendering515# --------------------------------------------------------------------------516INDENT = " "517518519def _cond_expr(block: Block, names: Dict[str, str]) -> str:520 stack: List[str] = []521 for ins in block.instructions:522 if _is_conditional_jump(ins):523 break524 _expr(ins, stack, names)525 return stack[-1] if stack else "<condition>"526527528def _for_iterable(block: Block, names: Dict[str, str]) -> str:529 stack: List[str] = []530 for ins in block.instructions:531 if ins.opname == "FOR_ITER":532 break533 _expr(ins, stack, names)534 return stack[-1] if stack else "<iterable>"535536537def _for_loop_var(cfg: CFG, head: int, names: Dict[str, str]) -> Optional[str]:538 body = _loop_body(cfg, head)539 cands = [(cfg.blocks[b].start, b) for b in body if b != head]540 if not cands:541 return None542 first = min(cands)[1]543 for ins in cfg.blocks[first].instructions:544 if ins.opname in {"STORE_FAST", "STORE_NAME", "STORE_DEREF"}:545 if isinstance(ins.argval, str):546 return names.get(ins.argval, ins.argval)547 return None548 return None549550551def _loop_cond(cfg: CFG, head: int, body: Set[int], names: Dict[str, str]) -> str:552 for b in sorted(body):553 c = cfg.blocks[b]554 last = c.instructions[-1]555 if _is_conditional_jump(last):556 e = _cond_expr(c, names)557 if "TRUE" in last.opname and "FALSE" not in last.opname:558 return f"not ({e})"559 return e560 return "True"561562563def _ends_in_forward_jump(cfg: CFG, ids: List[int]) -> Optional[int]:564 if not ids:565 return None566 lastb = ids[-1]567 last = cfg.blocks[lastb].instructions[-1]568 if _is_jump(last) and not _is_conditional_jump(last) \569 and isinstance(last.argval, int):570 tgt = cfg.index_of(last.argval)571 if tgt is not None and tgt > lastb:572 return tgt573 return None574575576def render_source(cfg: CFG, names: Optional[Dict[str, str]] = None) -> str:577 """Render a faithful structured pseudo-Python outline of the CFG."""578 if names is None:579 usage = usage_stats(cfg.code)580 names = rename_mangled(list(cfg.code.co_varnames), usage)581 if cfg.vacuous:582 return "# (no bytecode)"583584 out: List[str] = []585 emitted: Set[int] = set()586 DEPTH_LIMIT = 60587 LINE_LIMIT = 12000588589 def pad(level: int) -> str:590 return INDENT * level591592 def flush_block(block: Block, level: int) -> None:593 stack: List[str] = []594 for ins in block.instructions:595 if _is_jump(ins):596 break597 stmt = _expr(ins, stack, names)598 if stmt:599 out.append(pad(level) + stmt)600 if ins.opname in {"RETURN_VALUE", "RETURN_CONST",601 "RAISE_VARARGS", "RAISE_CONST"}:602 break603604 def emit_blocks(ids: List[int], level: int, depth: int) -> None:605 for rid in ids:606 emit(rid, level, depth)607608 def emit(idx: Optional[int], level: int, depth: int) -> None:609 while idx is not None:610 if len(out) > LINE_LIMIT or depth > DEPTH_LIMIT:611 return612 if idx >= len(cfg.blocks) or idx in emitted:613 return614 block = cfg.blocks[idx]615616 # --- loop head --------------------------------------------------617 if block.loop_head:618 emitted.add(idx)619 body = _loop_body(cfg, idx)620 body_ids = sorted(b for b in body if b != idx)621 if any(i.opname == "FOR_ITER" for i in block.instructions):622 var = _for_loop_var(cfg, idx, names) or "item"623 it = _for_iterable(block, names)624 out.append(f"{pad(level)}for {var} in {it}:")625 else:626 out.append(f"{pad(level)}while {_loop_cond(cfg, idx, body, names)}:")627 for b in body_ids:628 emit(b, level + 1, depth + 1)629 idx = _loop_exit(cfg, idx, body)630 continue631632 # --- try root ---------------------------------------------------633 if block.try_root:634 emitted.add(idx)635 out.append(f"{pad(level)}try: # setup at offset {block.start}")636 handler_idx = None637 for ins in block.instructions:638 if ins.opname in {"SETUP_EXCEPT", "SETUP_FINALLY"} \639 and isinstance(ins.argval, int):640 handler_idx = cfg.index_of(ins.argval)641 j = idx + 1642 guard = 0643 while j is not None and j < len(cfg.blocks) and guard < 2000:644 guard += 1645 if j == handler_idx or cfg.blocks[j].handler:646 break647 if cfg.blocks[j].loop_head or cfg.blocks[j].try_root:648 break649 emit(j, level + 1, depth + 1)650 last = cfg.blocks[j].instructions[-1]651 if _is_jump(last) or last.opname in _TERMINATORS:652 nxt = [e for e in cfg.blocks[j].exits653 if e is not None and e > j]654 j = nxt[0] if nxt else None655 if j is None:656 break657 if cfg.blocks[j].start < cfg.blocks[idx].start + 1:658 break659 else:660 j += 1661 if j is not None and j < len(cfg.blocks) \662 and (j == handler_idx or cfg.blocks[j].handler):663 out.append(f"{pad(level)}except <handler> as exc: # block {j}")664 emit(j, level + 1, depth + 1)665 idx = j + 1666 else:667 out.append(f"{pad(level)}# except handler nested or 3.11+-style")668 idx = j669 continue670671 # --- conditional jump -> if/else --------------------------------672 last = block.instructions[-1]673 if _is_conditional_jump(last) and isinstance(last.argval, int):674 t = cfg.index_of(last.argval)675 expr = _cond_expr(block, names)676 cond = f"not ({expr})" if ("TRUE" in last.opname677 and "FALSE" not in last.opname) else expr678 emitted.add(idx)679 fall_ids: List[int] = []680 j = idx + 1681 guard = 0682 while j is not None and j < len(cfg.blocks) and guard < 2000:683 guard += 1684 if j == t:685 break686 if cfg.blocks[j].loop_head or cfg.blocks[j].try_root:687 break688 fall_ids.append(j)689 last_j = cfg.blocks[j].instructions[-1]690 if _is_jump(last_j) or last_j.opname in _TERMINATORS:691 nxt = [e for e in cfg.blocks[j].exits692 if e is not None and e > j]693 j = nxt[0] if nxt else None694 if j is None or j == t:695 break696 if cfg.blocks[j].start < cfg.blocks[idx].start + 1:697 break698 else:699 j += 1700 out.append(f"{pad(level)}if {cond}:")701 emit_blocks(fall_ids, level + 1, depth + 1)702 if t is not None and t < len(cfg.blocks):703 join = _ends_in_forward_jump(cfg, fall_ids)704 if join is not None and join != t and join > t:705 out.append(f"{pad(level)}else:")706 emit_blocks(list(range(t, join)), level + 1, depth + 1)707 idx = join708 else:709 out.append(f"{pad(level)}# else/goto -> block {t}")710 idx = t711 continue712 idx = j713 continue714715 # --- straight-line block ----------------------------------------716 emitted.add(idx)717 flush_block(block, level)718 if block.ends_with == "jump" and block.exits:719 if block.exits[0] != idx + 1:720 out.append(f"{pad(level)}# -> block {block.exits[0]}")721 idx = block.exits[0]722 continue723 if block.ends_with in {"return", "raise"}:724 return725 idx = idx + 1726727 emit(0, 0, 0)728 while out and not out[-1].strip():729 out.pop()730 return "\n".join(out)731732733# --------------------------------------------------------------------------734# obfuscation detection735# --------------------------------------------------------------------------736def _walk_consts(code: CodeType):737 stack = [code]738 while stack:739 c = stack.pop()740 for const in c.co_consts:741 yield const742 if isinstance(const, CodeType):743 stack.append(const)744745746def detect_obfuscation(code: CodeType) -> List[Dict[str, Any]]:747 flags: List[Dict[str, Any]] = []748 consts = list(_walk_consts(code))749 strings = [c for c in consts if isinstance(c, str)]750 if not strings:751 flags.append({752 "kind": "no-string-constants",753 "detail": "code carries no string constants (string-stripped pyc "754 "or heavy obfuscation)",755 })756 huge = [c for c in consts757 if (isinstance(c, bytes) and len(c) > (1 << 22))758 or (isinstance(c, int) and (c.bit_length() + 7) // 8 > (1 << 22))]759 if huge:760 flags.append({761 "kind": "oversized-constants",762 "count": len(huge),763 "detail": f"{len(huge)} constants each exceed 4 MiB (payload blobs)",764 })765 names = set(code.co_names)766 dyn = [n for n in ("eval", "exec", "marshal", "base64", "zlib",767 "builtins") if n in names]768 if "eval" in dyn or "exec" in dyn:769 flags.append({770 "kind": "dynamic-execution",771 "detail": f"module references {', '.join(dyn)} likely for "772 f"runtime payload decoding",773 })774 return flags775776777def all_codes(code: CodeType) -> List[CodeType]:778 """Module code plus every nested code object, depth-first, deduped."""779 seen_ids: Set[int] = set()780 order: List[CodeType] = []781782 def walk(c: CodeType) -> None:783 if id(c) in seen_ids:784 return785 seen_ids.add(id(c))786 order.append(c)787 for const in c.co_consts:788 if isinstance(const, CodeType):789 walk(const)790791 walk(code)792 return order793794795def reconstruct_source(code: CodeType) -> Tuple[str, Dict[str, Any]]:796 parts: List[str] = []797 report: Dict[str, Any] = {798 "file": getattr(code, "co_filename", "<unknown>"),799 "magic_ok": True,800 "obfuscation": detect_obfuscation(code),801 "functions": [],802 "nested_code_objects": 0,803 }804 for chunk in all_codes(code):805 cfg = flow_reconstruct(chunk)806 usage = usage_stats(chunk)807 names = rename_mangled(list(chunk.co_varnames), usage)808 body = render_source(cfg, names=names)809 if chunk.co_name and chunk.co_name != "<module>":810 params = ", ".join(chunk.co_varnames[: chunk.co_argcount]) or ""811 parts.append(f"def {chunk.co_name}({params}):")812 if body.strip():813 parts.append("\n".join(INDENT + ln for ln in body.splitlines()))814 else:815 parts.append(INDENT + "pass # reconstructed outline")816 elif body.strip():817 parts.append(body)818 else:819 parts.append("# empty code object")820 report["functions"].append({821 "name": chunk.co_name,822 "argc": chunk.co_argcount,823 "varnames": list(chunk.co_varnames),824 "rendered_lines": len(body.splitlines()),825 })826 report["nested_code_objects"] += 1827 return "\n".join(parts).strip() + "\n", report828829830def _sample_code_object() -> CodeType:831 src = (832 "def find(items, target):\n"833 " for i in range(len(items)):\n"834 " if items[i] == target:\n"835 " return i\n"836 " return -1\n"837 "marks = find([3, 1, 4, 1, 5], 4)\n"838 "print(marks)\n"839 )840 return compile(src, "<demo>", "exec")841842843# --------------------------------------------------------------------------844# CLI845# --------------------------------------------------------------------------846def main(argv=None) -> int:847 parser = argparse.ArgumentParser(848 prog="unpyc_assistant",849 description="Disassemble and reconstruct Python .pyc bytecode; "850 "flag obfuscation heuristics.",851 )852 parser.add_argument("--pyc", help="path to the .pyc file to analyze")853 parser.add_argument("--out", default="", help="write reconstructed source here")854 parser.add_argument("--report", default="",855 help="write the JSON diagnostics report here")856 parser.add_argument("--dis", action="store_true",857 help="print raw disassembly first")858 parser.add_argument("--demo", action="store_true",859 help="reconstruct a built-in compiled sample instead of --pyc")860 args = parser.parse_args(argv)861862 if args.demo:863 code = _sample_code_object()864 label = "<demo>"865 elif args.pyc:866 try:867 code = load_pyc(Path(args.pyc))868 except (ValueError, OSError, MemoryError) as exc:869 print(f"unpyc_assistant: {exc}", file=sys.stderr)870 return 2871 label = args.pyc872 else:873 parser.error("--pyc is required (or pass --demo)")874 return 2875876 if args.dis:877 print(f"# disassembly of {label}")878 print(disassemble(code))879880 text, report = reconstruct_source(code)881 print(text)882 if args.out:883 Path(args.out).write_text(text, encoding="utf-8")884 report["source_file"] = args.out or None885 payload = json.dumps(report, indent=2, sort_keys=True)886 print("# " + payload)887 if args.report:888 Path(args.report).write_text(payload + "\n", encoding="utf-8")889 return 0890891892if __name__ == "__main__":893 sys.exit(main())894```895896## 4. Execution Protocol & Step-by-Step Workflow8978981. Verify the runtime and the pyc's origin: `python -c "import importlib.util, sys; print(sys.version, importlib.util.MAGIC_NUMBER.hex())"`. The loader accepts only the running interpreter's magic plus raw-marshal fallbacks; cross-version `.pyc` needs the matching runtime or `uncompyle6` (see step 6).8992. Preflight the file: `file sample.pyc` (or `xxd sample.pyc | head`) to confirm the 4-byte magic and the header shape before loading.9003. Load and disassemble first: `python unpyc_assistant.py --pyc sample.pyc --dis`. Read the raw `dis` output to sanity-check the reconstructed control flow you are about to trust.9014. Produce the structured reconstruction: `python unpyc_assistant.py --pyc sample.pyc --out sample_recon.py --report sample_report.json`. The rendered source is a *structured outline*: loops/try/if framing is real, but expression details (attribute chains, keyword args, comprehensions) are approximated and annotated with block indices in comments.9025. Read the report's `obfuscation` array:903 - `no-string-constants` — the pyc had all text stripped (common in packed malware/obfuscated deployables); treat every reconstructed call accordingly.904 - `oversized-constants` — 4 MiB+ int/bytes blobs; inspect via `marshal` and un-pickling offline, never by executing them.905 - `dynamic-execution` — module references `eval`/`exec`/`marshal`/`base64`; flag the pyc as potentially self-decoding and refuse to run it in a privileged context.906 - Deeper checks: for suspected bytecode-level tampering, compare `code.co_code` length and `magic` against a clean build of the same source (`python -m compileall`) and diff the two `.pyc`s.9076. When byte-exact source is required and `uncompyle6` is available (matches the CPython version of the pyc), use the external pipeline:908 ```909 python -m pip install uncompyle6910 python -m uncompyle6 sample.pyc # stdout911 python -m uncompyle6 -o out/ sample.pyc # one .py per module912 ```913 Cross-check its output against this module's reconstruction; agreement on framing is strong evidence the reconstruction is faithful.9147. For the JavaScript/Node bytecode case, the same decomposition applies with the V8 disassembler flags (`node --print-bytecode` or `js2pyc`-style tooling); this skill's CFG math (basic blocks, back-edge loop heads, exception handler regions) is language-agnostic.9158. Iterate: render → compare with `--dis` output → adjust nothing in the module (it is intentionally heuristic) but record block-of-interest offsets in the ticket.916917## 5. Edge Cases & Error Handling918919- **Magic mismatch**: `load_pyc` raises `ValueError` naming both magics; never force-load foreign-version bytecode — `marshal` format alone can corrupt or mis-tag. Recompile with the writer's Python or use a matching toolchain.920- **Truncated / corrupt `.pyc`**: `EOSError`/`ValueError` from `marshal.loads` inside the loop fall through all offsets and produce a clear `ValueError`; truncated files never partially mutate state (loading is read-only).921- **Bare marshaled blobs**: files that are *just* a marshaled code object (no header) are recovered by the offset-`0` fallback.922- **`PEP 552` hash-based pycs**: header is still 16 bytes (4 magic + 4 flags + 8 hash), so `data[16:]` is correct; confirmed by matching `importlib.util.MAGIC_NUMBER`.923- **Python 3.11+ exception tables**: `SETUP_EXCEPT`/`POP_EXCEPT` no longer exist as raw opcodes; `flow_reconstruct` falls back to `co_exceptiontable`-agnostic bounds and the renderer emits a `# except handler: nested or 3.11+-style` comment rather than guessing a wrong `except`.924- **`FOR_ITER` variants and cache instructions**: `dis.get_instructions` hides caches by default; the block splitter tolerates variant opcodes because it keys on offset containment, not opcode identity.925- **Pathologically nested code objects**: `all_codes` dedupes by `id()` and `render_source` caps depth (60) and lines (12000); pathological payloads produce truncated-but-valid outlines with `#` comments, never an exception.926- **Recursion in `gc`-style back-links / malicious constants**: rendering only ever calls `repr` and `str` on constants; a `__repr__`-abusing object is not possible in raw marshal constants (marshaled objects are immutable primitives and code), so there is no `__repr__` re-entry risk.927- **Sandbox note**: `marshal.loads` can deserialize hostile graphs (e.g. deep recursion causing `MemoryError`); catch `MemoryError` explicitly (done) and run suspicious pycs inside a subprocess with `ulimit -v`/container limits.928- **Variable rename collisions**: `rename_mangled` guarantees uniqueness by suffixing (`index`, `index_2`, ...); names that already look semantic (`result`, `data`) pass through unchanged.929- **Imperfect hearsay**: the module explicitly does NOT claim byte-exact output; treat `uncompyle6` as the authoritative second opinion whenever framing disagreements matter.