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.4---56# Bytecode Decompiler & Obfuscation Assistant78## 1. System Architecture & Prerequisites910- 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.11- Standard library only: `dis`, `marshal`, `importlib.util`, `io`, `json`, `argparse`, `re`, `sys`, `pathlib`, `types.CodeType`, `dataclasses`, `typing`.12- 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.13- 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.1415## 2. Input/Output Data Contracts1617### 2.1 CLI input options (JSON Schema)1819```json20{21 "$schema": "http://json-schema.org/draft-07/schema#",22 "title": "unpyc_assistant_cli_options",23 "type": "object",24 "properties": {25 "pyc": { "type": "string", "description": "Path to the .pyc file to analyze." },26 "out": { "type": "string", "description": "Path where reconstructed pseudo-Python is written." },27 "report": { "type": "string", "description": "Path where the JSON diagnostics/show report is written." },28 "dis": { "type": "boolean", "default": false, "description": "Print raw disassembly for the module and nested code objects." },29 "demo": { "type": "boolean", "default": false, "description": "Reconstruct a compiled built-in sample instead of --pyc." }30 },31 "oneOf": [32 { "required": ["pyc"] },33 { "required": ["demo"] }34 ]35}36```3738### 2.2 Output artifacts3940- **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.41- **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.42- No input files are modified by this skill.4344## 3. Production Reference Implementation4546```python47"""Bytecode Decompiler & Obfuscation Assistant.4849Loads Python ``.pyc`` bytecode, disassembles it, rebuilds a structured50basic-block control-flow graph, renames mangled single-letter variables by51usage heuristics, renders readable pseudo-Python, and flags obfuscation52signatures.5354Pipeline:55``load_pyc()`` -> CodeType (importlib.header-aware marshal load)56``disassemble()``-> raw ``dis.dis`` text57``flow_reconstruct()`` -> CFG of basic blocks with loop-head / try-except58 annotations and edge lists59``usage_stats()`` + ``rename_mangled()`` -> semantic variable names60``render_source()`` -> readable pseudo-Python outline (NOT byte-exact)61``detect_obfuscation()`` -> report of stripped-string and eval-style payloads6263Section on obfuscation: obfuscated/decompiler-hostile ``.pyc`` files are64detected by (a) an absence of string constants (string-stripped pycs),65(b) oversized int/bytes constants (payload blobs), and (c) module-level66references to ``eval``/``exec``/``marshal``/``base64``/``zlib`` used to67decode a payload at runtime.6869When ``uncompyle6`` (PyPI) is installed and the CPython version matches the70pyc, byte-exact source can be produced externally:71 python -m uncompyle6 file.pyc72The structured reconstruction in this module remains the dependency-free73fallback.74"""7576from __future__ import annotations7778import argparse79import dis80import importlib.util81import io82import json83import marshal84import re85import sys86from dataclasses import dataclass, field87from pathlib import Path88from types import CodeType89from typing import Any, Dict, List, Optional, Set, Tuple, Union909192# --------------------------------------------------------------------------93# loading94# --------------------------------------------------------------------------95def load_pyc(path: Union[str, Path]) -> CodeType:96 """Load a .pyc file into a code object.9798 When the on-disk magic matches the running interpreter, the marshaled99 code object starts right after the fixed 16-byte header (magic, flags,100 timestamp/size, or PEP 552 hash). Otherwise a raw marshal attempt is101 made against every plausible header offset so legacy pycs without a102 recognized magic can still load when their marshal stream aligns.103 """104 data = Path(path).read_bytes()105 if not data:106 raise ValueError(f"{path} is empty")107 magic = data[:4]108 if magic == importlib.util.MAGIC_NUMBER:109 code = _code_from_marshal(data[16:], str(path), magic)110 return code111 for offset in (8, 12, 16, 0):112 try:113 code = marshal.loads(data[offset:])114 except (ValueError, TypeError, EOFError, MemoryError):115 continue116 if isinstance(code, CodeType):117 return code118 raise ValueError(119 f"{path}: unrecognized pyc magic {magic!r}; this interpreter expects "120 f"{importlib.util.MAGIC_NUMBER!r}. Recompile with the matching "121 f"Python or point load_pyc at the writer's runtime."122 )123124125def _code_from_marshal(blob: bytes, label: str, magic: bytes) -> CodeType:126 try:127 code = marshal.loads(blob)128 except (ValueError, TypeError, EOFError, MemoryError) as exc:129 raise ValueError(f"{label}: magic matches but marshal load failed: {exc}") from exc130 if not isinstance(code, CodeType):131 raise ValueError(f"{label}: marshal payload is not a code object")132 return code133134135# --------------------------------------------------------------------------136# dis137# --------------------------------------------------------------------------138def disassemble(code: CodeType, show_caches: bool = False) -> str:139 out = io.StringIO()140 dis.dis(code, file=out, show_caches=show_caches)141 return out.getvalue()142143144# --------------------------------------------------------------------------145# control-flow reconstruction146# --------------------------------------------------------------------------147@dataclass148class Block:149 index: int150 start: int151 instructions: List[dis.Instruction] = field(default_factory=list)152 kind: str = "plain"153 exits: List[int] = field(default_factory=list)154 ends_with: str = "fall" # fall | jump | return | raise155 loop_head: bool = False156 try_root: bool = False157 handler: bool = False158159160@dataclass161class CFG:162 code: CodeType163 blocks: List[Block] = field(default_factory=list)164 vacuous: bool = False165166 def index_of(self, target: int) -> Optional[int]:167 for b in self.blocks:168 if b.start == target:169 return b.index170 return None171172173_TERMINATORS = {"RETURN_VALUE", "RETURN_CONST", "RAISE_VARARGS", "RAISE_CONST",174 "POP_EXCEPT", "END_FINALLY", "BREAK_LOOP", "CONTINUE_LOOP",175 "EXCEPT_HANDLER"}176177178def _is_jump(ins: dis.Instruction) -> bool:179 return ins.opname.startswith("JUMP") or ins.opname == "FOR_ITER"180181182_COND_JUMPS = {183 "POP_JUMP_IF_FALSE", "POP_JUMP_IF_TRUE",184 "POP_JUMP_FORWARD_IF_FALSE", "POP_JUMP_FORWARD_IF_TRUE",185 "POP_JUMP_BACKWARD_IF_FALSE", "POP_JUMP_BACKWARD_IF_TRUE",186 "JUMP_IF_FALSE_OR_POP", "JUMP_IF_TRUE_OR_POP",187 "JUMP_IF_FALSE", "JUMP_IF_TRUE",188}189190191def _is_conditional_jump(ins: dis.Instruction) -> bool:192 return ins.opname in _COND_JUMPS193194195def flow_reconstruct(code: CodeType) -> CFG:196 """Split the instruction stream into basic blocks and annotate loops197 and try/except regions."""198 instrs = list(dis.get_instructions(code))199 cfg = CFG(code=code)200 if not instrs:201 cfg.vacuous = True202 return cfg203204 starts = {instrs[0].offset}205 for ins in instrs:206 if _is_jump(ins) or ins.opname in {"SETUP_LOOP", "SETUP_EXCEPT", "SETUP_FINALLY"}:207 if isinstance(ins.argval, int):208 starts.add(ins.argval)209 for i, ins in enumerate(instrs[:-1]):210 if _is_jump(ins) or ins.opname in _TERMINATORS:211 starts.add(instrs[i + 1].offset)212 starts = sorted(starts)213214 i = 0215 while i < len(instrs):216 block_start = instrs[i].offset217 block_ins: List[dis.Instruction] = []218 while i < len(instrs):219 ins = instrs[i]220 block_ins.append(ins)221 barrier = _is_jump(ins) or ins.opname in _TERMINATORS222 nxt_offset = instrs[i + 1].offset if i + 1 < len(instrs) else None223 if barrier or (nxt_offset is not None and nxt_offset in starts):224 i += 1225 break226 i += 1227 cfg.blocks.append(Block(index=len(cfg.blocks), start=block_start,228 instructions=block_ins))229230 _compute_edges(cfg)231 _annotate(cfg)232 return cfg233234235def _compute_edges(cfg: CFG) -> None:236 for block in cfg.blocks:237 last = block.instructions[-1]238 name = last.opname239 if name in {"RETURN_VALUE", "RETURN_CONST"}:240 block.ends_with = "return"241 elif name in {"RAISE_VARARGS", "RAISE_CONST", "RERAISE"}:242 block.ends_with = "raise"243 elif _is_jump(last):244 block.ends_with = "jump"245 if isinstance(last.argval, int):246 t = cfg.index_of(last.argval)247 if t is not None:248 block.exits.append(t)249 if (name == "FOR_ITER" or "JUMP_IF" in name250 or name.startswith("POP_JUMP")):251 if block.index + 1 < len(cfg.blocks):252 block.exits.append(block.index + 1)253254255def _annotate(cfg: CFG) -> None:256 for block in cfg.blocks:257 if any(ins.opname == "FOR_ITER" for ins in block.instructions):258 block.loop_head = True259 for ins in block.instructions:260 if ins.opname in {"SETUP_EXCEPT", "SETUP_FINALLY"}:261 block.try_root = True262 if ins.opname in {"POP_EXCEPT", "END_FINALLY", "EXCEPT_HANDLER", "RERAISE"}:263 block.handler = True264 for src in cfg.blocks:265 for tgt_idx in src.exits:266 tgt = cfg.blocks[tgt_idx]267 if tgt.start < src.start:268 tgt.loop_head = True269270271# --------------------------------------------------------------------------272# graph helpers273# --------------------------------------------------------------------------274def _reachable(cfg: CFG, start: int) -> Set[int]:275 seen = {start}276 stack = [start]277 while stack:278 cur = stack.pop()279 for e in cfg.blocks[cur].exits:280 if e not in seen:281 seen.add(e)282 stack.append(e)283 return seen284285286def _preds(cfg: CFG, nodes: Set[int]) -> Dict[int, Set[int]]:287 preds: Dict[int, Set[int]] = {n: set() for n in nodes}288 for n in nodes:289 for e in cfg.blocks[n].exits:290 if e in preds:291 preds[e].add(n)292 return preds293294295def _loop_body(cfg: CFG, head: int) -> Set[int]:296 reach = _reachable(cfg, head)297 preds = _preds(cfg, reach)298 back: Set[int] = set()299 work = [head]300 while work:301 n = work.pop()302 if n in back:303 continue304 back.add(n)305 for p in preds.get(n, ()):306 if p not in back:307 work.append(p)308 return reach & back309310311def _loop_exit(cfg: CFG, head: int, body: Set[int]) -> Optional[int]:312 for b in body:313 for e in cfg.blocks[b].exits:314 if e not in body:315 return e316 return None317318319# --------------------------------------------------------------------------320# expression / statement translator321# --------------------------------------------------------------------------322_BIN = {0: "+", 1: "-", 2: "*", 3: "/", 4: "//", 5: "%", 6: "**",323 7: "<<", 8: ">>", 9: "&", 10: "^", 11: "|"}324_CMP = {0: "<", 1: "<=", 2: "==", 3: "!=", 4: ">", 5: ">=",325 6: "is", 7: "is not", 8: "in", 9: "not in"}326_OLD_BIN = {"BINARY_ADD": "+", "BINARY_SUBTRACT": "-", "BINARY_MULTIPLY": "*",327 "BINARY_TRUE_DIVIDE": "/", "BINARY_FLOOR_DIVIDE": "//",328 "BINARY_MODULO": "%", "BINARY_POWER": "**",329 "BINARY_LSHIFT": "<<", "BINARY_RSHIFT": ">>",330 "BINARY_AND": "&", "BINARY_XOR": "^", "BINARY_OR": "|"}331332333def _clean_sym(s: str) -> str:334 s = s.strip()335 if s.startswith("bool(") and s.endswith(")"):336 return s[5:-1]337 if s.startswith("(") and s.endswith(")"):338 return s[1:-1]339 return s340341342def _op_sym(ins: dis.Instruction) -> str:343 av = getattr(ins, "argval", None)344 if isinstance(av, str) and av.strip():345 return _clean_sym(av)346 if ins.arg is not None and isinstance(ins.arg, int):347 if "COMPARE" in ins.opname:348 return _CMP.get(ins.arg, "?")349 return _BIN.get(ins.arg, "?")350 return _OLD_BIN.get(ins.opname, "?")351352353def _expr(ins: dis.Instruction, stack: List[str], names: Dict[str, str]) -> Optional[str]:354 """Translate one instruction. Statement-producing instructions return a355 line; all others push/pop the expression stack and return None."""356 name = ins.opname357 if (name.startswith("LOAD_CONST") or name == "PUSH_CONST"358 or "SMALL_INT" in name or "MEDIUM_INT" in name or "SMALL_STR" in name):359 if isinstance(ins.argval, CodeType):360 return None # nested code objects are rendered as their own section361 stack.append(repr(ins.argval))362 return None363 if (name.startswith("LOAD_FAST") or name in {"LOAD_NAME", "LOAD_DEREF",364 "LOAD_GLOBAL", "LOAD_CLASSDEREF"}):365 if isinstance(ins.argval, str):366 stack.append(names.get(ins.argval, ins.argval))367 return None368 if name.startswith("LOAD_ATTR") and isinstance(ins.argval, str):369 if stack:370 stack.append(f"{stack.pop()}.{ins.argval}")371 return None372 if name in {"CALL", "CALL_FUNCTION", "CALL_FUNCTION_KW"}:373 argc = ins.arg if isinstance(ins.arg, int) else 0374 kw = 1 if name == "CALL_FUNCTION_KW" else 0375 n_args = max(0, argc - kw)376 if len(stack) >= n_args + 1:377 args = stack[-n_args:] if n_args else []378 if n_args:379 del stack[-n_args:]380 func = stack.pop()381 stack.append(f"{func}({', '.join(args)})")382 return None383 if name.startswith("IMPORT_NAME") and isinstance(ins.argval, str):384 stack.append(ins.argval)385 return None386 if name in {"BINARY_SUBSCR", "BINARY_OP", "COMPARE_OP", "COMPARISON_OP"}:387 if name == "BINARY_SUBSCR" and len(stack) >= 2:388 idx = stack.pop()389 obj = stack.pop()390 stack.append(f"{obj}[{idx}]")391 return None392 if len(stack) >= 2:393 rhs = stack.pop()394 lhs = stack.pop()395 stack.append(f"{lhs} {_op_sym(ins)} {rhs}")396 return None397 if name in _OLD_BIN and len(stack) >= 2:398 rhs = stack.pop()399 lhs = stack.pop()400 stack.append(f"{lhs} {_op_sym(ins)} {rhs}")401 return None402 if name.startswith("UNARY_NOT"):403 if stack:404 stack.append(f"not ({stack.pop()})")405 return None406 if name.startswith("UNARY_NEGATIVE"):407 if stack:408 stack.append(f"-({stack.pop()})")409 return None410 if name in {"STORE_FAST", "STORE_NAME", "STORE_DEREF"}:411 var = ins.argval if isinstance(ins.argval, str) else "?"412 value = stack.pop() if stack else "..."413 return f"{names.get(var, var)} = {value}"414 if name.startswith("STORE_ATTR") and isinstance(ins.argval, str):415 if len(stack) >= 2:416 attr = ins.argval417 obj = stack.pop()418 value = stack.pop()419 return f"{obj}.{attr} = {value}"420 return None421 if name == "POP_TOP":422 if stack:423 stack.pop()424 return None425 if name == "RETURN_VALUE":426 value = stack.pop() if stack else "None"427 return f"return {value}"428 if name == "RETURN_CONST":429 return f"return {repr(ins.argval)}"430 if name.startswith("RAISE"):431 return "raise <exception>"432 if name in {"GET_ITER", "PUSH_NULL", "PRECALL", "KW_NAMES",433 "RESUME", "NOT_TAKEN", "END_FOR", "POP_ITER",434 "MAKE_FUNCTION", "RETURN_GENERATOR"}:435 return None436 return None437438439# --------------------------------------------------------------------------440# variable renaming441# --------------------------------------------------------------------------442def _fresh() -> Dict[str, int]:443 return {"stores": 0, "loads": 0, "iter": 0, "incremented": 0}444445446def usage_stats(code: CodeType) -> Dict[str, Dict[str, int]]:447 """Heuristic per-variable usage: how often each name is stored/loaded,448 whether it is the target of a FOR_ITER (loop variable) and whether it449 is fed straight into a BINARY_OP (increment-ish)."""450 stats: Dict[str, Dict[str, int]] = {}451 insns = list(dis.get_instructions(code))452 for i, ins in enumerate(insns):453 v = ins.argval454 if not isinstance(v, str):455 continue456 d = stats.setdefault(v, _fresh())457 if (ins.opname.startswith("STORE_FAST") or ins.opname.startswith("STORE_NAME")458 or ins.opname.startswith("STORE_DEREF")):459 d["stores"] += 1460 elif (ins.opname.startswith("LOAD_FAST") or ins.opname.startswith("LOAD_NAME")461 or ins.opname.startswith("LOAD_DEREF") or ins.opname == "LOAD_GLOBAL"):462 d["loads"] += 1463 if "FOR_ITER" in ins.opname:464 for nxt in insns[i + 1:]:465 if nxt.opname.startswith("STORE_FAST") or nxt.opname.startswith("STORE_NAME"):466 if isinstance(nxt.argval, str):467 stats.setdefault(nxt.argval, _fresh())["iter"] += 1468 break469 if (ins.opname.startswith("BINARY_") or ins.opname.startswith("INPLACE_")):470 if i - 1 >= 0 and insns[i - 1].opname.startswith("LOAD_") \471 and isinstance(insns[i - 1].argval, str):472 stats.setdefault(insns[i - 1].argval, _fresh())["incremented"] += 1473 return stats474475476def rename_mangled(varnames: List[str], usage: Dict[str, Dict[str, int]]) -> Dict[str, str]:477 """Map single-letter/underscore names to semantic names based on the478 usage statistics (i -> index when incremented, l1/iter vars -> item,479 n -> count, and so on), guaranteeing unique outputs."""480 preferred = {481 "i": "index", "j": "index2", "k": "index3",482 "n": "count", "c": "char", "v": "value", "d": "data",483 "t": "tmp", "x": "coord_x", "y": "coord_y",484 "l": "item", "ln": "line", "idx": "index", "s": "string",485 }486 mapping: Dict[str, str] = {}487 used: Set[str] = set()488 for v in varnames:489 if not isinstance(v, str) or not v:490 continue491 base = v.lstrip("_") or "var"492 u = usage.get(v, {})493 if u.get("iter", 0) > 0 and u.get("incremented", 0) == 0:494 suggested = "item"495 elif u.get("incremented", 0) > 0:496 suggested = "index"497 elif base in preferred:498 suggested = preferred[base]499 else:500 suggested = base501 candidate = suggested502 suffix = 2503 while candidate in used:504 candidate = f"{suggested}_{suffix}"505 suffix += 1506 used.add(candidate)507 mapping[v] = candidate508 return mapping509510511# --------------------------------------------------------------------------512# pseudo-source rendering513# --------------------------------------------------------------------------514INDENT = " "515516517def _cond_expr(block: Block, names: Dict[str, str]) -> str:518 stack: List[str] = []519 for ins in block.instructions:520 if _is_conditional_jump(ins):521 break522 _expr(ins, stack, names)523 return stack[-1] if stack else "<condition>"524525526def _for_iterable(block: Block, names: Dict[str, str]) -> str:527 stack: List[str] = []528 for ins in block.instructions:529 if ins.opname == "FOR_ITER":530 break531 _expr(ins, stack, names)532 return stack[-1] if stack else "<iterable>"533534535def _for_loop_var(cfg: CFG, head: int, names: Dict[str, str]) -> Optional[str]:536 body = _loop_body(cfg, head)537 cands = [(cfg.blocks[b].start, b) for b in body if b != head]538 if not cands:539 return None540 first = min(cands)[1]541 for ins in cfg.blocks[first].instructions:542 if ins.opname in {"STORE_FAST", "STORE_NAME", "STORE_DEREF"}:543 if isinstance(ins.argval, str):544 return names.get(ins.argval, ins.argval)545 return None546 return None547548549def _loop_cond(cfg: CFG, head: int, body: Set[int], names: Dict[str, str]) -> str:550 for b in sorted(body):551 c = cfg.blocks[b]552 last = c.instructions[-1]553 if _is_conditional_jump(last):554 e = _cond_expr(c, names)555 if "TRUE" in last.opname and "FALSE" not in last.opname:556 return f"not ({e})"557 return e558 return "True"559560561def _ends_in_forward_jump(cfg: CFG, ids: List[int]) -> Optional[int]:562 if not ids:563 return None564 lastb = ids[-1]565 last = cfg.blocks[lastb].instructions[-1]566 if _is_jump(last) and not _is_conditional_jump(last) \567 and isinstance(last.argval, int):568 tgt = cfg.index_of(last.argval)569 if tgt is not None and tgt > lastb:570 return tgt571 return None572573574def render_source(cfg: CFG, names: Optional[Dict[str, str]] = None) -> str:575 """Render a faithful structured pseudo-Python outline of the CFG."""576 if names is None:577 usage = usage_stats(cfg.code)578 names = rename_mangled(list(cfg.code.co_varnames), usage)579 if cfg.vacuous:580 return "# (no bytecode)"581582 out: List[str] = []583 emitted: Set[int] = set()584 DEPTH_LIMIT = 60585 LINE_LIMIT = 12000586587 def pad(level: int) -> str:588 return INDENT * level589590 def flush_block(block: Block, level: int) -> None:591 stack: List[str] = []592 for ins in block.instructions:593 if _is_jump(ins):594 break595 stmt = _expr(ins, stack, names)596 if stmt:597 out.append(pad(level) + stmt)598 if ins.opname in {"RETURN_VALUE", "RETURN_CONST",599 "RAISE_VARARGS", "RAISE_CONST"}:600 break601602 def emit_blocks(ids: List[int], level: int, depth: int) -> None:603 for rid in ids:604 emit(rid, level, depth)605606 def emit(idx: Optional[int], level: int, depth: int) -> None:607 while idx is not None:608 if len(out) > LINE_LIMIT or depth > DEPTH_LIMIT:609 return610 if idx >= len(cfg.blocks) or idx in emitted:611 return612 block = cfg.blocks[idx]613614 # --- loop head --------------------------------------------------615 if block.loop_head:616 emitted.add(idx)617 body = _loop_body(cfg, idx)618 body_ids = sorted(b for b in body if b != idx)619 if any(i.opname == "FOR_ITER" for i in block.instructions):620 var = _for_loop_var(cfg, idx, names) or "item"621 it = _for_iterable(block, names)622 out.append(f"{pad(level)}for {var} in {it}:")623 else:624 out.append(f"{pad(level)}while {_loop_cond(cfg, idx, body, names)}:")625 for b in body_ids:626 emit(b, level + 1, depth + 1)627 idx = _loop_exit(cfg, idx, body)628 continue629630 # --- try root ---------------------------------------------------631 if block.try_root:632 emitted.add(idx)633 out.append(f"{pad(level)}try: # setup at offset {block.start}")634 handler_idx = None635 for ins in block.instructions:636 if ins.opname in {"SETUP_EXCEPT", "SETUP_FINALLY"} \637 and isinstance(ins.argval, int):638 handler_idx = cfg.index_of(ins.argval)639 j = idx + 1640 guard = 0641 while j is not None and j < len(cfg.blocks) and guard < 2000:642 guard += 1643 if j == handler_idx or cfg.blocks[j].handler:644 break645 if cfg.blocks[j].loop_head or cfg.blocks[j].try_root:646 break647 emit(j, level + 1, depth + 1)648 last = cfg.blocks[j].instructions[-1]649 if _is_jump(last) or last.opname in _TERMINATORS:650 nxt = [e for e in cfg.blocks[j].exits651 if e is not None and e > j]652 j = nxt[0] if nxt else None653 if j is None:654 break655 if cfg.blocks[j].start < cfg.blocks[idx].start + 1:656 break657 else:658 j += 1659 if j is not None and j < len(cfg.blocks) \660 and (j == handler_idx or cfg.blocks[j].handler):661 out.append(f"{pad(level)}except <handler> as exc: # block {j}")662 emit(j, level + 1, depth + 1)663 idx = j + 1664 else:665 out.append(f"{pad(level)}# except handler nested or 3.11+-style")666 idx = j667 continue668669 # --- conditional jump -> if/else --------------------------------670 last = block.instructions[-1]671 if _is_conditional_jump(last) and isinstance(last.argval, int):672 t = cfg.index_of(last.argval)673 expr = _cond_expr(block, names)674 cond = f"not ({expr})" if ("TRUE" in last.opname675 and "FALSE" not in last.opname) else expr676 emitted.add(idx)677 fall_ids: List[int] = []678 j = idx + 1679 guard = 0680 while j is not None and j < len(cfg.blocks) and guard < 2000:681 guard += 1682 if j == t:683 break684 if cfg.blocks[j].loop_head or cfg.blocks[j].try_root:685 break686 fall_ids.append(j)687 last_j = cfg.blocks[j].instructions[-1]688 if _is_jump(last_j) or last_j.opname in _TERMINATORS:689 nxt = [e for e in cfg.blocks[j].exits690 if e is not None and e > j]691 j = nxt[0] if nxt else None692 if j is None or j == t:693 break694 if cfg.blocks[j].start < cfg.blocks[idx].start + 1:695 break696 else:697 j += 1698 out.append(f"{pad(level)}if {cond}:")699 emit_blocks(fall_ids, level + 1, depth + 1)700 if t is not None and t < len(cfg.blocks):701 join = _ends_in_forward_jump(cfg, fall_ids)702 if join is not None and join != t and join > t:703 out.append(f"{pad(level)}else:")704 emit_blocks(list(range(t, join)), level + 1, depth + 1)705 idx = join706 else:707 out.append(f"{pad(level)}# else/goto -> block {t}")708 idx = t709 continue710 idx = j711 continue712713 # --- straight-line block ----------------------------------------714 emitted.add(idx)715 flush_block(block, level)716 if block.ends_with == "jump" and block.exits:717 if block.exits[0] != idx + 1:718 out.append(f"{pad(level)}# -> block {block.exits[0]}")719 idx = block.exits[0]720 continue721 if block.ends_with in {"return", "raise"}:722 return723 idx = idx + 1724725 emit(0, 0, 0)726 while out and not out[-1].strip():727 out.pop()728 return "\n".join(out)729730731# --------------------------------------------------------------------------732# obfuscation detection733# --------------------------------------------------------------------------734def _walk_consts(code: CodeType):735 stack = [code]736 while stack:737 c = stack.pop()738 for const in c.co_consts:739 yield const740 if isinstance(const, CodeType):741 stack.append(const)742743744def detect_obfuscation(code: CodeType) -> List[Dict[str, Any]]:745 flags: List[Dict[str, Any]] = []746 consts = list(_walk_consts(code))747 strings = [c for c in consts if isinstance(c, str)]748 if not strings:749 flags.append({750 "kind": "no-string-constants",751 "detail": "code carries no string constants (string-stripped pyc "752 "or heavy obfuscation)",753 })754 huge = [c for c in consts755 if (isinstance(c, bytes) and len(c) > (1 << 22))756 or (isinstance(c, int) and (c.bit_length() + 7) // 8 > (1 << 22))]757 if huge:758 flags.append({759 "kind": "oversized-constants",760 "count": len(huge),761 "detail": f"{len(huge)} constants each exceed 4 MiB (payload blobs)",762 })763 names = set(code.co_names)764 dyn = [n for n in ("eval", "exec", "marshal", "base64", "zlib",765 "builtins") if n in names]766 if "eval" in dyn or "exec" in dyn:767 flags.append({768 "kind": "dynamic-execution",769 "detail": f"module references {', '.join(dyn)} likely for "770 f"runtime payload decoding",771 })772 return flags773774775def all_codes(code: CodeType) -> List[CodeType]:776 """Module code plus every nested code object, depth-first, deduped."""777 seen_ids: Set[int] = set()778 order: List[CodeType] = []779780 def walk(c: CodeType) -> None:781 if id(c) in seen_ids:782 return783 seen_ids.add(id(c))784 order.append(c)785 for const in c.co_consts:786 if isinstance(const, CodeType):787 walk(const)788789 walk(code)790 return order791792793def reconstruct_source(code: CodeType) -> Tuple[str, Dict[str, Any]]:794 parts: List[str] = []795 report: Dict[str, Any] = {796 "file": getattr(code, "co_filename", "<unknown>"),797 "magic_ok": True,798 "obfuscation": detect_obfuscation(code),799 "functions": [],800 "nested_code_objects": 0,801 }802 for chunk in all_codes(code):803 cfg = flow_reconstruct(chunk)804 usage = usage_stats(chunk)805 names = rename_mangled(list(chunk.co_varnames), usage)806 body = render_source(cfg, names=names)807 if chunk.co_name and chunk.co_name != "<module>":808 params = ", ".join(chunk.co_varnames[: chunk.co_argcount]) or ""809 parts.append(f"def {chunk.co_name}({params}):")810 if body.strip():811 parts.append("\n".join(INDENT + ln for ln in body.splitlines()))812 else:813 parts.append(INDENT + "pass # reconstructed outline")814 elif body.strip():815 parts.append(body)816 else:817 parts.append("# empty code object")818 report["functions"].append({819 "name": chunk.co_name,820 "argc": chunk.co_argcount,821 "varnames": list(chunk.co_varnames),822 "rendered_lines": len(body.splitlines()),823 })824 report["nested_code_objects"] += 1825 return "\n".join(parts).strip() + "\n", report826827828def _sample_code_object() -> CodeType:829 src = (830 "def find(items, target):\n"831 " for i in range(len(items)):\n"832 " if items[i] == target:\n"833 " return i\n"834 " return -1\n"835 "marks = find([3, 1, 4, 1, 5], 4)\n"836 "print(marks)\n"837 )838 return compile(src, "<demo>", "exec")839840841# --------------------------------------------------------------------------842# CLI843# --------------------------------------------------------------------------844def main(argv=None) -> int:845 parser = argparse.ArgumentParser(846 prog="unpyc_assistant",847 description="Disassemble and reconstruct Python .pyc bytecode; "848 "flag obfuscation heuristics.",849 )850 parser.add_argument("--pyc", help="path to the .pyc file to analyze")851 parser.add_argument("--out", default="", help="write reconstructed source here")852 parser.add_argument("--report", default="",853 help="write the JSON diagnostics report here")854 parser.add_argument("--dis", action="store_true",855 help="print raw disassembly first")856 parser.add_argument("--demo", action="store_true",857 help="reconstruct a built-in compiled sample instead of --pyc")858 args = parser.parse_args(argv)859860 if args.demo:861 code = _sample_code_object()862 label = "<demo>"863 elif args.pyc:864 try:865 code = load_pyc(Path(args.pyc))866 except (ValueError, OSError, MemoryError) as exc:867 print(f"unpyc_assistant: {exc}", file=sys.stderr)868 return 2869 label = args.pyc870 else:871 parser.error("--pyc is required (or pass --demo)")872 return 2873874 if args.dis:875 print(f"# disassembly of {label}")876 print(disassemble(code))877878 text, report = reconstruct_source(code)879 print(text)880 if args.out:881 Path(args.out).write_text(text, encoding="utf-8")882 report["source_file"] = args.out or None883 payload = json.dumps(report, indent=2, sort_keys=True)884 print("# " + payload)885 if args.report:886 Path(args.report).write_text(payload + "\n", encoding="utf-8")887 return 0888889890if __name__ == "__main__":891 sys.exit(main())892```893894## 4. Execution Protocol & Step-by-Step Workflow8958961. 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).8972. Preflight the file: `file sample.pyc` (or `xxd sample.pyc | head`) to confirm the 4-byte magic and the header shape before loading.8983. 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.8994. 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.9005. Read the report's `obfuscation` array:901 - `no-string-constants` — the pyc had all text stripped (common in packed malware/obfuscated deployables); treat every reconstructed call accordingly.902 - `oversized-constants` — 4 MiB+ int/bytes blobs; inspect via `marshal` and un-pickling offline, never by executing them.903 - `dynamic-execution` — module references `eval`/`exec`/`marshal`/`base64`; flag the pyc as potentially self-decoding and refuse to run it in a privileged context.904 - 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.9056. When byte-exact source is required and `uncompyle6` is available (matches the CPython version of the pyc), use the external pipeline:906 ```907 python -m pip install uncompyle6908 python -m uncompyle6 sample.pyc # stdout909 python -m uncompyle6 -o out/ sample.pyc # one .py per module910 ```911 Cross-check its output against this module's reconstruction; agreement on framing is strong evidence the reconstruction is faithful.9127. 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.9138. Iterate: render → compare with `--dis` output → adjust nothing in the module (it is intentionally heuristic) but record block-of-interest offsets in the ticket.914915## 5. Edge Cases & Error Handling916917- **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.918- **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).919- **Bare marshaled blobs**: files that are *just* a marshaled code object (no header) are recovered by the offset-`0` fallback.920- **`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`.921- **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`.922- **`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.923- **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.924- **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.925- **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.926- **Variable rename collisions**: `rename_mangled` guarantees uniqueness by suffixing (`index`, `index_2`, ...); names that already look semantic (`result`, `data`) pass through unchanged.927- **Imperfect hearsay**: the module explicitly does NOT claim byte-exact output; treat `uncompyle6` as the authoritative second opinion whenever framing disagreements matter.