Skip to content

Commit

Permalink
Remove the --key/cipher bytecode encryption.
Browse files Browse the repository at this point in the history
Bytecode encryption, given that the decryption key has to be stored somewhere in
the built application for the application to be able to function, was only ever
a mild deterrent against prying eyes. It could be cracked by anyone willing to
dig around PyInstaller's source code for the exact layout of the executable
archive and a quick hexdump to get the key once you know where to look.

These days however, PyInstaller reverse engineering tools like PyExtractor have
this all built in. For example, in the steps below, our would be prying user
doesn't even need to know that the application they are trying to break open is
encrypted, let alone have to do anything clever to decrypt it.

    git clone https://github.com/Rdimo/PyExtractor.git
    cd PyExtractor
    pip install -r requirements.txt
    python main.py some/pyinstaller/application

So since the knowledge barrier to reverse engineer an encrypted build is now
identical to that of a regular one, and because users are being misled into
thinking that an encrypted PyInstaller build is a safe place to put things like
API keys, and since adding further code obfuscation will eventually lead to the
same outcome, remove the encryption feature entirely.

Users looking for a replacement should look for code obfuscation methods that
don't require lossless de-obfuscation at runtime in order for the code to be
runable. This means PyArmour or any DIY bytecode encryption scheme should be
avoided for the same reasons that this feature is being dropped. Instead, you
can use pyminifier's obfuscation feature which mangles variable names or if (and
only if) you understand the perils of Linux ABI compatibility, are aware of the
macOS deployment target and understand that PyInstaller can't detect imports
made by C extensions (i.e. you will need to use
--hidden-import/--collect-submodules a lot) then you may consider running Cython
on the more confidential Python files in your project.
  • Loading branch information
bwoodsend committed Jun 29, 2023
1 parent 57c472a commit f5adf29
Show file tree
Hide file tree
Showing 13 changed files with 32 additions and 198 deletions.
36 changes: 2 additions & 34 deletions PyInstaller/archive/pyz_crypto.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,40 +9,8 @@
# SPDX-License-Identifier: (GPL-2.0-or-later WITH Bootloader-exception)
#-----------------------------------------------------------------------------

import os

from PyInstaller import log as logging

BLOCK_SIZE = 16
logger = logging.getLogger(__name__)


class PyiBlockCipher:
"""
This class is used only to encrypt Python modules.
"""
def __init__(self, key=None):
logger.log(
logging.DEPRECATION,
"Bytecode encryption will be removed in PyInstaller v6. Please remove cipher and block_cipher parameters "
"from your spec file to avoid breakages on upgrade. For the rationale/alternatives see "
"https://github.com/pyinstaller/pyinstaller/pull/6999"
)
assert type(key) is str
if len(key) > BLOCK_SIZE:
self.key = key[0:BLOCK_SIZE]
else:
self.key = key.zfill(BLOCK_SIZE)
assert len(self.key) == BLOCK_SIZE

import tinyaes
self._aesmod = tinyaes

def encrypt(self, data):
iv = os.urandom(BLOCK_SIZE)
return iv + self.__create_cipher(iv).CTR_xcrypt_buffer(data)

def __create_cipher(self, iv):
# The 'AES' class is stateful, and this factory method is used to re-initialize the block cipher class with
# each call to xcrypt().
return self._aesmod.AES(self.key.encode(), iv)
from PyInstaller.exceptions import RemovedCipherFeatureError
raise RemovedCipherFeatureError("Please remove cipher and block_cipher parameters from your spec file.")
12 changes: 3 additions & 9 deletions PyInstaller/archive/writers.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ class ZlibArchiveWriter:
_HEADER_LENGTH = 12 + 5
_COMPRESSION_LEVEL = 6 # zlib compression level

def __init__(self, filename, entries, code_dict=None, cipher=None):
def __init__(self, filename, entries, code_dict=None):
"""
filename
Target filename of the archive.
Expand All @@ -44,8 +44,6 @@ def __init__(self, filename, entries, code_dict=None, cipher=None):
`DATA`).
code_dict
Optional code dictionary containing code objects for analyzed/collected python modules.
cipher
Optional `Cipher` object for bytecode encryption.
"""
code_dict = code_dict or {}

Expand All @@ -56,7 +54,7 @@ def __init__(self, filename, entries, code_dict=None, cipher=None):
# Write entries' data and collect TOC entries
toc = []
for entry in entries:
toc_entry = self._write_entry(fp, entry, code_dict, cipher)
toc_entry = self._write_entry(fp, entry, code_dict)
toc.append(toc_entry)

# Write TOC
Expand All @@ -68,17 +66,15 @@ def __init__(self, filename, entries, code_dict=None, cipher=None):
# - PYZ magic pattern (4 bytes)
# - python bytecode magic pattern (4 bytes)
# - TOC offset (32-bit int, 4 bytes)
# - encryption flag (1 byte)
# - 4 unused bytes
fp.seek(0, os.SEEK_SET)

fp.write(self._PYZ_MAGIC_PATTERN)
fp.write(BYTECODE_MAGIC)
fp.write(struct.pack('!i', toc_offset))
fp.write(struct.pack('!B', cipher is not None))

@classmethod
def _write_entry(cls, fp, entry, code_dict, cipher):
def _write_entry(cls, fp, entry, code_dict):
name, src_path, typecode = entry

if typecode == 'PYMODULE':
Expand All @@ -102,8 +98,6 @@ def _write_entry(cls, fp, entry, code_dict, cipher):

# First compress, then encrypt.
obj = zlib.compress(data, cls._COMPRESSION_LEVEL)
if cipher:
obj = cipher.encrypt(obj)

# Create TOC entry
toc_entry = (name, (typecode, fp.tell(), len(obj)))
Expand Down
18 changes: 6 additions & 12 deletions PyInstaller/building/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,16 +61,18 @@ def __init__(self, *tocs, **kwargs):
name
A filename for the .pyz. Normally not needed, as the generated name will do fine.
cipher
The block cipher that will be used to encrypt Python bytecode.
"""
if kwargs.get("cipher"):
from PyInstaller.exceptions import RemovedCipherFeatureError
raise RemovedCipherFeatureError(
"Please remove the 'cipher' arguments to PYZ() and Analysis() in your spec file."
)

from PyInstaller.config import CONF

super().__init__()

name = kwargs.get('name', None)
cipher = kwargs.get('cipher', None)

self.name = name
if name is None:
Expand All @@ -79,14 +81,6 @@ def __init__(self, *tocs, **kwargs):
# PyInstaller bootstrapping modules.
bootstrap_dependencies = get_bootstrap_modules()

# Bundle the crypto key.
self.cipher = cipher
if cipher:
key_file = ('pyimod00_crypto_key', os.path.join(CONF['workpath'], 'pyimod00_crypto_key.py'), 'PYMODULE')
# Insert the key as the first module in the list. The key module contains just variables and does not depend
# on other modules.
bootstrap_dependencies.insert(0, key_file)

# Compile the python modules that are part of bootstrap dependencies, so that they can be collected into the
# CArchive and imported by the bootstrap script.
self.dependencies = []
Expand Down Expand Up @@ -156,7 +150,7 @@ def assemble(self):
self.code_dict = {name: strip_paths_in_code(code) for name, code in self.code_dict.items()}

# Create the archive
ZlibArchiveWriter(self.name, archive_toc, code_dict=self.code_dict, cipher=self.cipher)
ZlibArchiveWriter(self.name, archive_toc, code_dict=self.code_dict)
logger.info("Building PYZ (ZlibArchive) %s completed successfully.", self.name)


Expand Down
20 changes: 5 additions & 15 deletions PyInstaller/building/build_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@

from PyInstaller import DEFAULT_DISTPATH, DEFAULT_WORKPATH, HOMEPATH, compat
from PyInstaller import log as logging
from PyInstaller.archive import pyz_crypto
from PyInstaller.building.api import COLLECT, EXE, MERGE, PYZ
from PyInstaller.building.datastruct import TOC, Target, Tree, _check_guts_eq, normalize_toc, normalize_pyz_toc
from PyInstaller.building.osx import BUNDLE
Expand Down Expand Up @@ -305,8 +304,6 @@ def __init__(
ignored (as though they were not found).
runtime_hooks
An optional list of scripts to use as users' runtime hooks. Specified as file names.
cipher
Add optional instance of the pyz_crypto.PyiBlockCipher class (with a provided key).
win_no_prefer_redirects
If True, prefer not to follow version redirects when searching for Windows SxS Assemblies.
win_private_assemblies
Expand All @@ -317,6 +314,11 @@ def __init__(
An optional dict of package/module names and collection mode strings. Valid collection mode strings:
'pyz' (default), 'pyc', 'py', 'pyz+py' (or 'py+pyz')
"""
if cipher is not None:
from PyInstaller.exceptions import RemovedCipherFeatureError
raise RemovedCipherFeatureError(
"Please remove the 'cipher' arguments to PYZ() and Analysis() in your spec file."
)
super().__init__()
from PyInstaller.config import CONF

Expand Down Expand Up @@ -377,15 +379,6 @@ def __init__(
# Custom runtime hook files that should be included and started before any existing PyInstaller runtime hooks.
self.custom_runtime_hooks = runtime_hooks or []

if cipher:
logger.info('Will encrypt Python bytecode with provided cipher key')
# Create a Python module which contains the decryption key which will be used at runtime by
# pyi_crypto.PyiBlockCipher.
pyi_crypto_key_path = os.path.join(CONF['workpath'], 'pyimod00_crypto_key.py')
with open(pyi_crypto_key_path, 'w', encoding='utf-8') as f:
f.write('# -*- coding: utf-8 -*-\nkey = %r\n' % cipher.key)
self.hiddenimports.append('tinyaes')

self._input_binaries = []
self._input_datas = []

Expand Down Expand Up @@ -442,8 +435,6 @@ def __init__(
('_input_binaries', _check_guts_toc),
('_input_datas', _check_guts_toc),

# 'cipher': no need to check as it is implied by an additional hidden import

# calculated/analysed values
('_python_version', _check_guts_eq),
('scripts', _check_guts_toc_mtime),
Expand Down Expand Up @@ -929,7 +920,6 @@ def build(spec, distpath, workpath, clean_build):
'Splash': Splash,
# Python modules available for .spec.
'os': os,
'pyi_crypto': pyz_crypto,
}

# Execute the specfile. Read it as a binary file...
Expand Down
32 changes: 5 additions & 27 deletions PyInstaller/building/makespec.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,10 @@

import argparse
import os
import sys

from PyInstaller import DEFAULT_SPECPATH, HOMEPATH
from PyInstaller import log as logging
from PyInstaller.building.templates import (
bundleexetmplt, bundletmplt, cipher_absent_template, cipher_init_template, onedirtmplt, onefiletmplt, splashtmpl
)
from PyInstaller.building.templates import bundleexetmplt, bundletmplt, onedirtmplt, onefiletmplt, splashtmpl
from PyInstaller.compat import expand_path, is_darwin, is_win

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -91,13 +88,9 @@ def make_variable_path(filename, conversions=path_conversions):
return None, filename


def deprecated_key_option(x):
logger.log(
logging.DEPRECATION,
"Bytecode encryption will be removed in PyInstaller v6. Please remove your --key=xxx argument to avoid "
"breakages on upgrade. For the rationale/alternatives see https://github.com/pyinstaller/pyinstaller/pull/6999"
)
return x
def removed_key_option(x):
from PyInstaller.exceptions import RemovedCipherFeatureError
raise RemovedCipherFeatureError("Please remove your --key=xxx argument.")


# An object used in place of a "path string", which knows how to repr() itself using variable names instead of
Expand Down Expand Up @@ -356,7 +349,7 @@ def __add_options(parser):
'--key',
dest='key',
help=argparse.SUPPRESS,
type=deprecated_key_option,
type=removed_key_option,
)
g.add_argument(
'--splash',
Expand Down Expand Up @@ -732,20 +725,6 @@ def main(
# With absolute paths replace prefix with variable HOMEPATH.
scripts = list(map(Path, scripts))

if key:
# Try to import tinyaes as we need it for bytecode obfuscation.
try:
import tinyaes # noqa: F401 (test import)
except ImportError:
logger.error(
'We need tinyaes to use byte-code obfuscation but we could not find it. You can install it '
'with pip by running:\n pip install tinyaes'
)
sys.exit(1)
cipher_init = cipher_init_template % {'key': key}
else:
cipher_init = cipher_absent_template

# Translate the default of ``debug=None`` to an empty list.
if debug is None:
debug = []
Expand Down Expand Up @@ -788,7 +767,6 @@ def main(
'upx_exclude': upx_exclude,
'runtime_tmpdir': runtime_tmpdir,
'exe_options': exe_options,
'cipher_init': cipher_init,
# Directory with additional custom import hooks.
'hookspath': hookspath,
# List with custom runtime hook files.
Expand Down
16 changes: 2 additions & 14 deletions PyInstaller/building/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@

onefiletmplt = """# -*- mode: python ; coding: utf-8 -*-
%(preamble)s
%(cipher_init)s
a = Analysis(
%(scripts)s,
Expand All @@ -28,10 +27,9 @@
excludes=%(excludes)s,
win_no_prefer_redirects=%(win_no_prefer_redirects)s,
win_private_assemblies=%(win_private_assemblies)s,
cipher=block_cipher,
noarchive=%(noarchive)s,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
pyz = PYZ(a.pure, a.zipped_data)
%(splash_init)s
exe = EXE(
pyz,
Expand All @@ -58,7 +56,6 @@

onedirtmplt = """# -*- mode: python ; coding: utf-8 -*-
%(preamble)s
%(cipher_init)s
a = Analysis(
%(scripts)s,
Expand All @@ -72,10 +69,9 @@
excludes=%(excludes)s,
win_no_prefer_redirects=%(win_no_prefer_redirects)s,
win_private_assemblies=%(win_private_assemblies)s,
cipher=block_cipher,
noarchive=%(noarchive)s,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
pyz = PYZ(a.pure, a.zipped_data)
%(splash_init)s
exe = EXE(
pyz,
Expand Down Expand Up @@ -106,14 +102,6 @@
)
"""

cipher_absent_template = """
block_cipher = None
"""

cipher_init_template = """
block_cipher = pyi_crypto.PyiBlockCipher(key=%(key)r)
"""

bundleexetmplt = """app = BUNDLE(
exe,
name='%(name)s.app',
Expand Down
6 changes: 6 additions & 0 deletions PyInstaller/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,9 @@ def __str__(self):
"exists and whether the hook is compatible with your version of {1}: You might want to read more about "
"hooks in the manual and provide a pull-request to improve PyInstaller.".format(self.args[0], self.args[1])
)


class RemovedCipherFeatureError(SystemExit):
def __str__(self):
return f"Bytecode encryption was removed in PyInstaller v6.0. {self.args[0]}" \
" For the rationale and alternatives see https://github.com/pyinstaller/pyinstaller/pull/6999"

0 comments on commit f5adf29

Please sign in to comment.