#!/bin/sh
# =============================================================================
#  pydecode.sh - Python EXE decompiler
#
#  Recovers readable Python source from packed executables (PyInstaller,
#  py2exe, cx_Freeze layouts, UPX-packed bootloaders), including bytecode
#  from modern Python versions up to 3.13/3.14 that classic decompilers
#  (uncompyle6, decompyle3) cannot handle.
#
#  Usage:
#      ./pydecode.sh [options] <packed-executable> [output-parent]
#
#  Options:
#      --sidecar   additionally scan the exe directory for side-car payloads
#                  (.pyc/.pyz/.zip beside the exe, e.g. cx_Freeze builds)
#      -h, --help  show help
#
#  The used packer/compiler is detected automatically (PyInstaller, py2exe,
#  cx_Freeze, Nuitka, UPX) and reported before extraction.
#
#  Results land in <script-dir>/extracted/<binary-name-without-suffix>/ by
#  default (second argument overrides the parent directory):
#
#    extracted/hello/PYZ.pyz_extracted/...   raw unpacked payload (.pyc)
#    extracted/hello/hello.py                decompiled source
#    extracted/hello/hello.dis               bytecode disassembly (fallback)
#
#  Pipeline:
#    1. pyinstxtractor-ng  unpacks the PyInstaller archive (.pyc payload)
#    2. fallbacks when the MEI cookie is missing:
#         a. UPX unpack + retry          (packed bootloader)
#         b. appended-zip probe          (py2exe / self-extracting)
#         c. raw .pyc magic carving      (embedded code objects, 3.8-3.13)
#         d. zlib stream carving         (inflated pyc / marshal payloads)
#         e. side-car scan               (opt-in via --sidecar)
#    3. pycdc (zrsx fork)  primary decompiler, Python 2.0-3.14
#       pycdc-alt          upstream zrax/pycdc second chance
#    4. pycdas             last resort: bytecode disassembly
#
#  Requirements:
#    - bin/pycdc, bin/pycdc-alt, bin/pycdas  (prebuilt, kept in this repo)
#    - python3; a .venv is auto-created on first run and installs
#      pyinstxtractor-ng into it
#    - optional: upx binary for automatic UPX-unpacking
#
#  Notes:
#    - Nuitka/Cython binaries contain no bytecode and cannot be decompiled.
#    - For byte-perfect output of difficult files upload recovered .pyc to
#      https://pylingual.io
#
#  AI assistance: Ox Alpha Free (Unlimited)
# =============================================================================

set -eu

ROOT=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)

usage() {
    cat >&2 <<EOF
usage: $0 [options] <packed-executable> [output-parent]

options:
  --sidecar   also scan the exe directory for side-car payloads
              (.pyc/.pyz/.zip beside the exe, e.g. cx_Freeze builds)
  -h, --help  show this help

output:
  <script-dir>/extracted/<binary-name-without-suffix>/
EOF
    exit "${1:-1}"
}

SIDECAR=0
while [ $# -gt 0 ]; do
    case $1 in
        --sidecar) SIDECAR=1 ;;
        -h|--help) usage 0 ;;
        *) break ;;
    esac
    shift
done

[ $# -ge 1 ] && [ $# -le 2 ] || usage
EXE=$1

[ -f "$EXE" ] || { echo "error: no such file: $EXE" >&2; exit 1; }
[ -x "$ROOT/bin/pycdc" ] && [ -x "$ROOT/bin/pycdas" ] || {
    echo "error: bin/pycdc or bin/pycdas missing; build them first:" >&2
    echo "  git clone https://github.com/zrax/pycdc /tmp/pycdc" >&2
    echo "  cd /tmp/pycdc && cmake . && make && cp pycdc pycdas '$ROOT/bin/'" >&2
    exit 1
}

BASE=$(basename -- "$EXE")

# ---- venv bootstrap --------------------------------------------------------
if [ ! -x "$ROOT/.venv/bin/python" ]; then
    echo "[*] creating virtualenv"
    python3 -m venv "$ROOT/.venv"
fi
if ! "$ROOT/.venv/bin/python" -c 'import pyinstxtractor' >/dev/null 2>&1; then
    echo "[*] installing pyinstxtractor-ng"
    "$ROOT/.venv/bin/pip" install -q pyinstxtractor-ng
fi

# ---- packer detection ---------------------------------------------------------
# exact byte matching (cookie contains 0x0A which grep line-splitting cannot match)
PACKER=$("$ROOT/.venv/bin/python" - "$EXE" <<'EOF'
import sys
data = open(sys.argv[1], "rb").read()
if b"MEI\x0c\x0b\x0a\x0b\x0e" in data:
    print("PyInstaller")
elif b"UPX!" in data:
    print("UPX-packed-unknown-inner")
elif b"nuitka" in data.lower():
    print("Nuitka")
elif b"PYTHONSCRIPT" in data or b"py2exe" in data:
    print("py2exe")
elif b"cx_Freeze" in data:
    print("cx_Freeze")
else:
    print("unknown")
EOF
)
echo "[*] packer: $PACKER"

case $PACKER in
    Nuitka)
        echo "error: Nuitka compiles to machine code - there is no bytecode to decompile." >&2
        echo "       Options: reverse the binary with Ghidra/IDA, or extract strings/constants." >&2
        exit 1
        ;;
    cx_Freeze)
        if [ "$SIDECAR" = 0 ]; then
            echo "[!] cx_Freeze stores .pyc files beside the exe - rerun with --sidecar" >&2
        fi
        ;;
esac

NAME=${BASE%.*}

PARENT=${2:-"$ROOT/extracted"}
OUT=$PARENT/$NAME
mkdir -p "$OUT"

# ---- unpack -----------------------------------------------------------------
# workdir sits beside OUT (same filesystem) so the final move is a rename
WORK=$(mktemp -d "$PARENT/.pydec.XXXXXX")
trap 'rm -rf "$WORK"' EXIT INT TERM

cp -- "$EXE" "$WORK/target"
echo "[*] extracting $EXE"
( cd "$WORK" && "$ROOT/.venv/bin/pyinstxtractor-ng" target ) || true

[ -d "$WORK/target_extracted" ] || {
    echo "[*] no PyInstaller cookie - trying fallbacks"

    if grep -qa 'UPX!' "$EXE"; then
        if command -v upx >/dev/null 2>&1; then
            echo "[*] UPX-compressed: unpacking and retrying"
            cp -- "$EXE" "$WORK/unpacked"
            upx -d -q "$WORK/unpacked" >/dev/null 2>&1 || true
            ( cd "$WORK" && "$ROOT/.venv/bin/pyinstxtractor-ng" unpacked ) >/dev/null || true
        else
            echo "[!] UPX payload found but 'upx' not installed (sudo apt install upx-ucl)"
        fi
    fi

    if [ ! -d "$WORK/target_extracted" ] && [ ! -d "$WORK/unpacked_extracted" ]; then
        echo "[*] probing embedded zip / carving bytecode streams"
        "$ROOT/.venv/bin/python" "$ROOT/carve.py" "$EXE" "$OUT/carved"

        if [ "$SIDECAR" = 1 ]; then
            echo "[*] scanning exe directory for side-car payloads"
            mkdir -p "$OUT/sidecar"
            find "$(dirname -- "$EXE")" -maxdepth 3 -type f -size -64M \
                \( -name '*.pyc' -o -name '*.pyz' -o -name '*.zip' \) \
                -exec cp -- {} "$OUT/sidecar/" \;
            for z in "$OUT"/sidecar/*.zip "$OUT"/sidecar/*.pyz; do
                [ -f "$z" ] || continue
                "$ROOT/.venv/bin/python" "$ROOT/carve.py" "$z" "${z%.*}_unpacked"
            done
        fi
    fi
}

for d in "$WORK/target_extracted" "$WORK/unpacked_extracted"; do
    [ -d "$d" ] || continue
    for item in "$d"/*; do
        mv -- "$item" "$OUT"/
    done
    rmdir "$d"
done

# ---- decompile --------------------------------------------------------------
find "$OUT" -name '*.pyc' -type f | sort > "$WORK/list"

if [ ! -s "$WORK/list" ]; then
    echo "warning: no .pyc payloads found (Nuitka/cx_Freeze/binary-only app?)" >&2
    exit 1
fi

FIRST_PYC=$(head -n 1 "$WORK/list")
"$ROOT/.venv/bin/python" - "$FIRST_PYC" <<'EOF'
import sys, pathlib
MAGIC_TO_VER = {0x42: "3.7", 0x55: "3.8", 0x61: "3.9", 0x6F: "3.10",
                0xA7: "3.11", 0xCB: "3.12", 0xF3: "3.13"}
b = pathlib.Path(sys.argv[1]).read_bytes()[:4]
print(f"[*] bytecode: Python {MAGIC_TO_VER.get(b[0], f'unknown (magic {b[0]:02X} {b[1]:02X})')}")
EOF

try_decomp() { # $1=decompiler $2=pyc $3=out-file; rc=0 iff clean source
    "$1" "$2" > "$3" 2>/dev/null || true
    if [ -s "$3" ] && ! grep -q 'WARNING: Decompyle incomplete' "$3"; then
        return 0
    fi
    return 1
}

ok=0; partial=0; failed=0
while IFS= read -r f; do
    rel=${f#"$OUT"/}
    dest=${f%.pyc}.py

    rm -f "$dest"
    clean=
    if try_decomp "$ROOT/bin/pycdc"     "$f" "$dest";     then clean=yes
    elif try_decomp "$ROOT/bin/pycdc-alt" "$f" "$dest";   then clean=yes
    fi

    if [ "$clean" = yes ]; then
        ok=$((ok + 1))
        echo "  [+] $rel"
        continue
    fi

    # no clean source anywhere: keep whatever partial output exists ($dest
    # holds the last decompiler attempt), add bytecode disassembly alongside
    if [ -s "$dest" ] && grep -qv '^# \(Source Generated\|File:\)' "$dest"; then
        partial=$((partial + 1))
        echo "  [~] $rel (partial source, bytecode disassembly added)"
        "$ROOT/bin/pycdas" "$f" > "${dest%.py}.dis" 2>/dev/null || true
    else
        rm -f "$dest"
        "$ROOT/bin/pycdas" "$f" > "${f%.pyc}.dis" 2>/dev/null || true
        failed=$((failed + 1))
        echo "  [!] $rel -> decompilation unsupported, wrote bytecode disassembly"
    fi
done < "$WORK/list"

echo "[*] done: $ok decompiled, $partial partial, $failed fallback(s)"
echo "[*] output: $OUT"
