Skip to content

About

Ultimate Alternative To Nuitka since it's defeated by Hexpyrion

Resources

Stars

2 stars

Watchers

1 watching

Forks

Latest commit

 

History

29 Commits

Folders and files

Repository files navigation

CPythonizer

Ultimate Alternative To Nuitka since it's defeated by Hexpyrion

What Is The Problem?

How It Works

  1. Cython 3.0.11 transpiles your .py into real C (/* Generated by Cython */, ~270+ KB of C per demo).
  2. Local Modules / Self Imports (e.g. import utils, from core.math import multiply): All internal .py modules and packages in the project are automatically discovered, transpiled with Cython, and compiled with MSVC into native .pyd extension modules. Zero Python bytecode is exposed.
  3. A genuine Visual Studio 2026 project (.vcxproj + .sln, toolset v145, x64 Release) wraps the application and all local .pyd extension modules. Visual Studio 2022 (v143) is used as-is when 2026 is not installed.
  4. MSBuild compiles the entire solution — Visual Studio auto-generates the debug binaries (.pdb) via /Zi + /DEBUG unless --release is passed.
  5. The stdlib is staged as python314.zip plus the required .pyd/.dll files next to the EXE, so it boots on a PC with no Python installed. No Debug CRT (*_d.pyd) is ever shipped.
python -m cpythonizer vs-build examples/single_hello.py --name Hello --dist dist --onefile

Options:

  • --onefile: Folds the program, runtime (python314.dll), stdlib (python314.zip), extension modules, and third-party packages into a single self-contained .exe (see below).
  • --zombie: Zombie loader (implies --onefile): the runtime is dropped to %TEMP% as real files, but the program itself is mapped in RAM and never written to disk - the dropped EXE is an empty shell (see below).
  • --encrypt / --no-encrypt: Encrypts the onefile payload with a unique random AES-256 key per build (default: enabled).
  • --no-antidump: Zombie mode: keep SizeOfImage intact instead of blanking it (anti-dump off, default: on).
  • --guard <off|basic|full>: Zombie mode: obfuscate the loader itself — compile-time string encryption, anti-debug probes and a flattened role dispatch (full adds hardware-breakpoint detection and fake protector sections). Default: off.
  • --lzma2 / --compress lzma2: Compresses the onefile payload using LZMA2 compression.
  • --lzma-preset <0..9>: Configurable LZMA2 compression level (default: 9).
  • --lzma-extreme / --no-lzma-extreme: Enable/disable LZMA2 extreme preset for extra compression (default: enabled).
  • --obfuscate: Strips inline and line comments from all Python sources (entry & local modules) before Cython transpilation without touching strings or regexes.
  • --release: Strips debug symbols / PDB, enables max compiler optimizations, and applies Cython speedup directives.
  • --keep-pdb: Retains debug symbols (.pdb) even when building with --release.
  • --noconsole / --windowed: Windows GUI subsystem — hides the black console/CMD window at launch (for Tkinter, PyQt, etc.).
  • --embed / --no-embed: Generate standalone main() entrypoint via cython --embed (default: enabled).
  • --icon <path>: Executable icon (.ico or .png; PNG files are converted automatically).
  • --include-package <name>: Explicitly bundle a third-party package (third-party imports like requests are also detected and bundled automatically).
  • --vs 2026 / --vs 2022: Pin a specific Visual Studio version (default: auto = newest installed).

Obfuscator & Source Protection

The built-in obfuscator safely transforms Python sources before or during Cython compilation:

  • Comment Stripper: Tokenize-based stripping of inline (# ...) and standalone comments without altering strings, raw strings, docstrings or regexes.
  • AST Function Name Randomization: Automatically renames function definitions and internal callers to randomized identifiers (_cpx_fn_<hex>) via Python AST.
  • Random Decoy Step Comments: Injects randomized decoy step comment lines (# [cpx-step: <token>]) before statements.
  • AES-256-CBC Self-Decrypting Wrapper (Encrypt class): Automatically generates fresh 256-bit AES keys & 128-bit IVs, encrypts source codes, and outputs self-executing exec(decrypt_text(...)) scripts.
  • Base64 Exec Wrapper: Compresses/encodes sources into self-executing import base64; exec(base64.b64decode(...)) scripts.
# Strip comments
python -m cpythonizer obfuscate myfile.py --in-place

# Full obfuscation (strip comments + rename functions via AST + insert random step comments)
python -m cpythonizer obfuscate myfile.py --all --in-place

# AES-256-CBC self-decrypting wrapper (auto key & IV generation)
python -m cpythonizer obfuscate myfile.py --aes

# Base64 exec wrapper
python -m cpythonizer obfuscate myfile.py --b64

# Interactive Encrypt CLI
python -m cpythonizer.obfuscator

# Integrated with vs-build:
python -m cpythonizer vs-build main.py --onefile --obfuscate --release

Python API:

from cpythonizer.obfuscator import remove_inline_comments, obfuscate_source, Encrypt

# Strip comments:
remove_inline_comments("myfile.py", in_place=True)

# Full AST + step comment obfuscation:
cleaned_code = obfuscate_source(source_text, rename_funcs=True, add_comments=True)

# Base64 encrypt:
Encrypt().encrypt("myfile.py")

Onefile Internals & Security

The onefile EXE is [ stub ][ AES-256 encrypted payload ZIP / XZ ][ 24-byte trailer ], where the trailer is CPYONE1 (stored) or CPYLZM2 (LZMA2 max) + payload size + uncompressed size.

  • Per-Build Random Encryption (Polymorphic): Every build generates a fresh 256-bit AES key (os.urandom(32)), initialization vector (os.urandom(16)), and key mask array (os.urandom(32)). The payload is encrypted with native Windows BCrypt (AES-256-CBC with PKCS#7 padding). The stub stores the key masked (KEY_MASK ^ MASKED_KEY), derives the key on startup, decrypts the payload in RAM via hardware-accelerated AES-NI, and wipes the key memory (SecureZeroMemory). No static binary signature or static file offsets are identical across builds.
  • LZMA2 Max Compression (--lzma2): Compresses the payload with LZMA2 extreme preset 9, shrinking the onefile binary by ~55% (from ~35 MB down to ~15 MB). The embedded 0BSD xz-embedded decoder decompresses the stream entirely in RAM.
  • Dependency-Free Launcher: The stub is pure Win32 C (build/…/<app>_stub.c, /MT, static CRT, no Python dependency at all).

On startup the stub creates %TEMP%\<app>-<8 random hex>\, decrypts the payload in RAM, unpacks it there, runs the program from it under its original name, waits for it, deletes the folder and exits with the program's own exit code:

tasklist  ->  Hello.exe   (never the temp path)
sys.argv[0] / sys.executable  ->  C:\...\Hello.exe

The command line is forwarded verbatim, argv[0] is rewritten to the shipped EXE, and the working directory is left alone. Unpacking ~29 MB costs about 0.25 s per launch. Debug switches: CPYTHONIZER_ONEFILE_VERBOSE=1 prints the temp folder, and --cpythonizer-keep (or CPYTHONIZER_ONEFILE_KEEP=1) keeps it for inspection.

Zombie Loader (--zombie): the program is never written to disk

--onefile drops the whole staged folder to %TEMP%, the program EXE included. --zombie drops only what genuinely must exist as a file and keeps the program in RAM:

python -m cpythonizer vs-build main.py --name Hello --zombie --lzma2 --release

The shipped EXE is [ stub ][ runtime blob ][ program blob ][ 56 byte trailer ], and the stub is one binary with two roles, picked by the trailer magic (CPYZMB1 / CPYZMB2):

  1. Droper (the shipped EXE) - decrypts + unpacks the runtime blob (python314.dll, every .pyd, python314.zip, vcruntime140.dll, libcrypto-3.dll, …) into %TEMP%\<app>-<random>\, then writes the zombie next to it: its own stub bytes
    • the untouched program blob + a CPYZMB2 trailer, named Hello.exe. It runs the zombie, waits, deletes the folder and forwards the exit code.
  2. Zombie (%TEMP%\...\<app>.exe) - decrypts the program PE, maps it in memory (headers, sections, relocations, imports, section protections, .pdata for x64 unwinding) and calls its wmainCRTStartup.

Because the zombie is produced by copying the droper's own prefix, no second payload is embedded anywhere, and the dropped EXE holds zero instructions of the real program — only the loader plus the still-encrypted blob:

build/onefile_ProbeZ/ProbeZ.exe   125 KB  real program: "Py_Initialize", the .text, the imports
%TEMP%\ProbeZ-XXXX\ProbeZ.exe      228 KB  zombie:       no program code, no program imports
dist/ProbeZ/ProbeZ.exe             15 MB   shipped:       no program code either

Why the DLLs are dropped but the EXE is not. A manually mapped module is invisible to the loader, so everything that expects a real on-disk module breaks: the import graph, extension module loads, sys.prefix discovery (CPython looks for python314.zip relative to the process image, which is why the zombie must run from %TEMP%), TLS and CRT bookkeeping. Do that to every DLL and the program cannot start at all. The EXE is the one image nothing else loads, so it is the one that can live purely in RAM - and once its DLLs sit next to the zombie, the mapped program boots exactly like the ordinary staged build. tasklist still shows Hello.exe and sys.argv[0] / sys.executable still point at the distributed file.

Anti-dump — SizeOfImage only. After mapping, the loader blanks just OptionalHeader.SizeOfImage. That is the field every memory dumper and GetModuleInformation uses to size an image, so the dump comes out empty. Nothing else is touched: the DOS/NT headers, the section table and .pdata must stay valid for the CRT, the x64 unwinder and CPython's own module bookkeeping - wiping the header (as most loaders do) breaks the program instead of protecting it. --no-antidump disables it at build time, CPYTHONIZER_ONEFILE_NOANTIDUMP=1 at runtime.

Both blobs are AES-256-CBC encrypted with a fresh random key/IV/key-mask per build (two independent sets, one per blob) and CRC32-checked after decryption; --lzma2 compresses them with LZMA2 preset 9. The debug switches of --onefile work unchanged (CPYTHONIZER_ONEFILE_VERBOSE=1, --cpythonizer-keep), and in verbose mode the loader prints what it maps, which imports it resolved, and — if the mapped program ever faults — the faulting address and the module it belongs to.

Bundling data files (--add-data)

python -m cpythonizer vs-build shadowfox.py --name shadowfox_onefile --dist dist `
    --onefile --icon shadowfox.ico --release --noconsole `
    --add-data "shadowfox_logo.png;." --add-data "shadowfox.ico;." --add-data "assets;images"

The flag is repeatable and takes PyInstaller's SOURCE;DEST syntax (a SOURCE:DEST form is accepted too, and a Windows drive letter does not confuse the parser). DEST is always a folder: a file keeps its own name inside it, a directory becomes a subfolder of it. .. and absolute destinations are rejected, and a missing source is reported before the build starts rather than after a two-minute MSBuild run.

Where the files end up, and how your program finds them:

mode location lookup
plain next to the EXE in dist/<app>/ os.path.dirname(sys.executable)
--onefile / --zombie inside the payload, extracted to the %TEMP% folder sys._MEIPASS
import os, sys
BASE = getattr(sys, "_MEIPASS", os.path.dirname(sys.executable))
logo = os.path.join(BASE, "shadowfox_logo.png")

For the frozen modes sys._MEIPASS is set for you: the loaders export _MEIPASS/CPYTHONIZER_TEMP pointing at the extraction folder, and the staged python314.zip carries a sitecustomize.py that copies it onto sys during start-up. Without it, sys._MEIPASS — the one attribute PyInstaller-frozen code usually asks for directly — would be missing, and bundled files would be unreachable. Under --zombie this needed a fix of its own: the loader set the variables to the path of the zombie file rather than to its folder, so every bundle lookup failed there while working under --onefile.

Security note. With --zombie the program itself is still never written to disk, but anything passed to --add-data is, in the clear, in %TEMP% for as long as the app runs. Do not ship secrets that way.

An --icon that the resource compiler rejects (for example RC2176: old DIB) no longer kills the build: the project is regenerated without the icon, the build continues, and a warning is printed. A missing icon beats no binary.

Console windows: --noconsole vs --hide-console

A GUI-subsystem build (--noconsole) has no console at all, so Windows gives every child process that is itself a console application a brand new console window. That is why a Tkinter app whose screens each shell out once (git, pip, where, ...) flashes a black rectangle on every navigation. Measured inside a packaged build, launched the way Explorer launches it:

flags PE subsystem own console console a child cmd.exe gets
--noconsole GUI none new window, visible
--hide-console Console present, hidden inherits it, hidden
--noconsole --hide-console GUI hidden, self-allocated inherits it, hidden
neither Console visible inherits it, visible

--hide-console keeps the console and hides its window (GetConsoleWindow + ShowWindow), so children inherit something invisible to draw into instead of being handed a window of their own. Combined with --noconsole — the mode to use for a GUI app — the PE stays GUI, so Windows creates nothing at start-up and sys.stdout stays None exactly as with --noconsole, and the program allocates one hidden console (AllocConsole + hide) for children to inherit. The policy is applied to the loader stub and to the program, so neither of them can show a window the other hid.

What it costs, honestly:

  • Windows creates the console before main() runs, so --hide-console alone can flash once at start-up. Combined with --noconsole there is no such flash, since nothing is created for us.
  • A console object survives, so another process could AttachConsole and read what the program printed. Matters only if the app prints secrets; drop --hide-console if that is a concern.
  • AV/EDR heuristics classify the binary as a console application rather than a GUI one.

The code-level fix is still the better one where you own the source: subprocess.Popen(cmd, creationflags=subprocess.CREATE_NO_WINDOW).

Guard layers (--guard basic|full)

The loader is C, so it is the softest target in the package. --guard compiles the stub with cpythonizer/obfuscate/obfus_msvc.h — an MSVC port of the usable parts of DosX/obfus.h:

level what it adds
off (default) plain loader, every literal visible
basic compile-time string encryption, hidden imports, anti-debug at both decrypt sites, flattened role dispatch
full basic + hardware-breakpoint probe (DR7) and fake protector sections (.aspack, .adata, __wibu00)

Why a port instead of the upstream header. obfus.h does not support MSVC by design: it rewrites every if/while/for/switch with GNU statement expressions and builds its control-flow and junk layers on GNU inline AT&T asm, __builtin_choose_expr and __typeof__. None of those exist for x64 cl.exe (#warning obfus.h doesn't support Visual C/C++), and there is no flag that changes that. The port keeps what translates — the RND() mixing, stack and encrypted strings, opaque predicates, the math VM, the anti-debug probe set with its response behind an obfuscated sink, fake signatures — and replaces what does not: __rdtsc and __readgsqword intrinsics instead of inline asm, and an explicit OBF_DISPATCH_* dispatcher instead of the automatic if/while rewriting. The result in the binary is the same shape OLLVM's flattening produces: one switch inside one loop with an opaque state value.

String encryption is real, not cosmetic. obfus.h's HIDE_STRING only keeps a literal out of the data sections — MSVC still emits the bytes as .text immediates, so a raw grep over the file finds them anyway. The port therefore encrypts at build time: CPY_HSTR("x") is rewritten into a per-site XOR array decoded at the call site, so the plaintext never reaches the binary.

Hidden imports. basic/full resolve all 39 Win32 entry points the loader uses at run time instead of importing them: PEB->ImageBaseAddress gives the loader's own base, its own import directory yields GetModuleHandleW/LoadLibraryExW (forced references — the linker drops an import nobody uses), and every other function is found by walking kernel32's and bcrypt.dll's export directories and matching a keyed hash of "<module>!<function>". No function name exists anywhere in the file, and the expected-hash table is itself stored encrypted. Import table of the packed stub, counted by parsing the import directory (not by grepping strings):

bare /MT hello-world   1 DLL,  75 functions   <- the static CRT, nothing else
guard basic / full     1 DLL,  76 functions   <- 75 + GetFileSizeEx, no bcrypt at all
guard off              2 DLLs, 103 functions   <- 96 KERNEL32 + 7 bcrypt

So basic/full leave the loader with no Win32 imports of its own: the set difference against a bare /MT binary is one name, GetFileSizeEx, and it is not something the code calls directly - all three call sites go through the hash table (checked in the preprocessed output). Where it comes from is not explained, and it is harmless: a file-size API that appears in most CRT programs anyway. None of the interesting ones are there: no CreateProcessW, no VirtualAlloc, no BCryptDecrypt, no LoadLibraryW, no GetThreadContext, no AddVectoredExceptionHandler, no VirtualProtect. What remains is the static CRT's own list (heap, TLS, stdio), which no work on the loader can remove - only dropping the CRT entirely could, and the stub needs malloc/memcpy/fwprintf.

Fingerprints found in the first 200 KB of the packed EXE:

off    CPYZMB1  CPYZMB2  RtlAddFunctionTable  IsDebuggerPresent
full   IsDebuggerPresent

The survivor is the static CRT's own KERNEL32!IsDebuggerPresent import, not loader code.

Forwarded exports. On Windows 11, 211 of kernel32's 1697 exports are forwarders: the export's RVA points at an ASCII "NTDLL.RtlAddVectoredExceptionHandler" string inside the export directory instead of at code. The resolver follows them (and bounds the export directory by SizeOfImage rather than a fixed cap, because ntdll's alone exceeds 64 KiB). Handling this is not cosmetic — AddVectoredExceptionHandler is one of the forwarded ones, so before this was fixed the loader stored a string address in that slot and jumped into kernel32's string table when the verbose crash locator was installed. The bug was invisible to a matrix that only checked exit codes, because the call is reached only when CPYTHONIZER_ONEFILE_VERBOSE=1; see tests/ below.

Never writing an EXE to %TEMP% (--no-temp-exe). By default the loader drops a copy of its own stub into the extraction folder, and that copy is what runs the program: the stub is a real file, so CPython finds python314.zip next to its own executable, and when the program exits the parent can delete the whole folder. --no-temp-exe removes that file entirely - the zombie role runs as a second instance of the shipped EXE instead, told apart by an environment variable rather than by its name, and the parent relocates the interpreter by hand (PYTHONHOME for sys.prefix, PYTHONPATH for the flat embeddable layout, which has no Lib/ directory). Measured, both modes:

default      temp folder contains: python314.zip, python314.dll, *.pyd, *.dll, <app>.exe
--no-temp-exe temp folder contains: python314.zip, python314.dll, *.pyd, *.dll
             sys._MEIPASS / sys.prefix / __file__ : identical in both
             temp folder removed on exit          : yes, both

Without the relocation the child silently fell back to whatever Python was installed on the machine, and on a clean target it would have died with "Could not find platform independent libraries" - the test asserts the temp folder holds no *.exe and that the program still runs.

Anti-debug probes PEB.BeingDebugged, ProcessDebugPort / ProcessDebugObjectHandle, CheckRemoteDebuggerPresent and the DR7 hardware-breakpoint bits, hides the thread with NtSetInformationThread(ThreadHideFromDebugger) first, and reacts through a computed spin sink rather than ExitProcess — a kill is a one-byte patch target, a sink is not. It is armed at the two points worth protecting: before the runtime blob is decrypted and before the program blob is decrypted and mapped. CPYTHONIZER_ONEFILE_NOAD=1 disables it at runtime so you can debug your own build under a debugger.

python -m cpythonizer vs-build main.py --name Hello --zombie --lzma2 --release --guard full

Tests

python tests\test_resolver.py            # ~10 s, no build needed
CPXP_ZOMBIE_TESTS=1 python tests\test_zombie_switches.py   # ~3 min, four MSBuild runs

test_resolver.py compiles obfuscate/resolve.h on its own, resolves every hidden import through the real table, and compares each pointer against GetProcAddress — an oracle that shares no code with the resolver. It skips cleanly when no MSVC toolchain is present.

test_zombie_switches.py builds four variants (plain, guard full, guard full --no-antidump, guard off) and asserts the behaviour, not just exit codes:

  • the program's output is byte-identical to the unprotected build
  • CPYTHONIZER_ONEFILE_VERBOSE=1 really prints loader diagnostics and still exits 0
  • CPYTHONIZER_ONEFILE_KEEP=1 leaves the dropped runtime behind; a normal run removes it
  • --no-temp-exe leaves no *.exe in that folder while the default mode does, and the program still lands on the same sys._MEIPASS / __file__ (with nothing dropped next to the child, the interpreter has to be relocated by hand)
  • the default build reports blanking SizeOfImage, --no-antidump does not, and the dropped stub on disk keeps a valid one (Windows has to be able to load it)
  • guard off imports bcrypt.dll, guard full does not

The verbose assertion is the one that matters: a loader whose verbose path crashes still runs the program perfectly in every other configuration, so any test that only looks at exit codes reports it as healthy.

Proof: Better Than Nuitka (Decompiler Test)

A memory-dump / hook-based Python decompiler (HydraDragonAntivirus python_hook_backend) was run against a CPythonizer build (Hello.exe, built from examples/single_hello.py). Result — no Python bytecode recoverable, only stubs:

Module: single_hello
Type: <class 'module'>
File: built-in

def factorial(n: 'int') -> 'int':
    # COMPILED CODE - Metadata extraction:
    # Local variables:
    #   n
    #   result
    #   i
    # Argument count: 1
    # Keyword-only args: 0
    # Local variables count: 3
    # AG: STUB_IDENTIFIED - Compiled code - no Python bytecode available

def main() -> 'int':
    # COMPILED CODE - Metadata extraction:
    # Local variables:
    #   cmd
    # Argument count: 0
    # Keyword-only args: 0
    # Local variables count: 1
    # AG: STUB_IDENTIFIED - Compiled code - no Python bytecode available

Function names and metadata remain visible (Cython keeps them as module attributes), but no bytecode, no source logic, no constants can be reconstructed — the actual code is native machine code produced by MSVC. Static decompilers (Hexpyrion-style) get nothing at all, since there is no frozen .pyc to extract — unlike Nuitka onefiles, which ship recoverable bytecode.

Even this library-free minimal build is already hard to reverse: there is no embedded .py, no bytecode header, no co_code to carve — only MSVC-compiled machine code plus metadata stubs. A reverser is reduced to plain native reverse engineering (IDA/x64dbg level), with zero Python semantics to latch onto.

Add any real library and it becomes a nightmare: each dependency (e.g. requests, numpy, cryptography) is itself transpiled into thousands of additional Cython functions inside the same binary. The module list explodes, control flow is buried under generated __pyx_ helpers, error paths and type dispatchers — while still yielding zero bytecode. What was one small stub table becomes tens of thousands of native functions with inlined, optimized logic. Automated Python tooling stays blind; manual analysis must fight through MSVC-optimized (/O2 + LTCG) machine code with no source-level landmarks.

About

Ultimate Alternative To Nuitka since it's defeated by Hexpyrion

Resources

Stars

2 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages