目錄

將 Python script 轉換成 exe 的姿勢

不知道為什麼,將 Python 轉換成 exe 這個問題在網路上有無數個不完整的資訊,只好自己寫一份紀錄留著。 下面的資訊是基於 Python 2.7.173.8.3 為基礎。

在開始之前,先列一下有用到的 package 版本

  • PyInstaller 3.6
  • virtualenv 20.0.21
  • pyaes 1.6.1

PyInstaller 基本操作

網路上搜尋一圈,PyInstaller 算是目前對使用者最友善操作最簡單的對 script 包裝成 exe 的 package。

先來安裝 PyInstaller

1
2
pip install PyInstaller --upgrade
# pip install PyInstaller==3.6

為了測試,先寫一隻最基本的 HelloWorld.py

1
print('Hello World')

然後用 PyInstaller 看看能不能包裝成執行檔

1
pyinstaller -F --clean HelloWorld.py

然後,你可以在同一個目錄下的 dist 目錄下看到執行檔,但我的執行檔大小高達 8.3 MB,我只是印一行 Hello World 的檔案要這麼大也太誇張了。

使用 UPX 來縮小執行檔大小

UPX 是一個老牌執行檔壓縮的 open source 軟體,而 PyInstaller 也支援使用它來壓縮。

首先先去 UPX 的網站下載軟體,目前最新版本是upx-3.96-win32.zip,解開壓縮後將裡面的 upx.1upx.exe 複製到 HelloWorld.py 同一個目錄後重新執行一遍 PyInstaller。

這次執行會看到 PyInstaller 會輸出

INFO: UPX is available.
INFO: Executing - upx --lzma -q ...\bincache01_py38_32bit\python38.dll
INFO: Executing - upx --lzma -q ...\vcruntime140.dll
INFO: Executing - upx --lzma -q ...\_ssl.pyd
INFO: Executing - upx --lzma -q ...\_elementtree.pyd
INFO: Executing - upx --lzma -q ...\_multiprocessing.pyd
INFO: Executing - upx --lzma -q ...\pyexpat.pyd
INFO: Executing - upx --lzma -q ...\_ctypes.pyd
INFO: Executing - upx --lzma -q ...\_tkinter.pyd
INFO: Executing - upx --lzma -q ...\_testcapi.pyd
INFO: Executing - upx --lzma -q ...\_decimal.pyd
INFO: Executing - upx --lzma -q ...\_queue.pyd
INFO: Executing - upx --lzma -q ...\_asyncio.pyd
INFO: Executing - upx --lzma -q ...\_overlapped.pyd
INFO: Executing - upx --lzma -q ...\_bz2.pyd
INFO: Executing - upx --lzma -q ...\_lzma.pyd
INFO: Executing - upx --lzma -q ...\_hashlib.pyd
INFO: Executing - upx --lzma -q ...\_socket.pyd
INFO: Executing - upx --lzma -q ...\select.pyd
INFO: Executing - upx --lzma -q ...\unicodedata.pyd
INFO: Executing - upx --lzma -q ...\libcrypto-1_1.dll
INFO: Executing - upx --lzma -q ...\libssl-1_1.dll
INFO: Executing - upx --lzma -q ...\libffi-7.dll
INFO: Executing - upx --lzma -q ...\tk86t.dll
INFO: Executing - upx --lzma -q ...\tcl86t.dll
小記
上面的 log 經過裁減

這樣的資訊,大致可以看的出來,的確 PyInstaller 使用了 UPX 來壓縮執行檔了,而且也看的到到底壓縮了哪些東西,然後實際查看產生的執行檔大小也縮小到了 7.1 MB,縮小了約 1.2 MB,雖然還是很大但至少有變小了。

接著我們執行看看產生的執行檔卻發現檔案壞掉了。

這是因為 Microsoft 的 vcrruntime140.dll 有經過簽名,而從上面執行 PyInstaller 的 log 看來, vcruntime140.dll 有經過 UPX 壓縮,所以被破壞了完整性造成 Windows 不允許 dll 被使用。解決的方法就是將這個檔案加入 upx_exclude list.

檢查一下執行 PyInstaller 的目錄會發現有一個 HelloWorld.spec,這個檔案是 PyInstaller 自動產生的設定檔,我們可以藉由修改裡面的設定來改變 PyInstaller 的行為。

提示
因為若沒有指定 .spec 檔,每次執行 PyInstaller 都會產生與 .py 檔同名的 .spec 檔案,請務必修改 .spec 的主檔名讓它不要與 .py 同樣的主檔名,下面的範例會用 custom.spec 為例。

首先我們複製原本自動產生的 HelloWorld.spec 為 custom.spec,接著用文字編輯器打開修改它。

1
2
# upx_exclude=[],
upx_exclude=['vcruntime140.dll'],

透過編輯 upx_exclude 這個 list 將 vcruntime140.dll 設為不要使用 UPX 壓縮,然後我們再用以下命令來重建一次執行檔。

1
pyinstaller -F --clean -D custom.spec HelloWorld.py
提示
使用 -D 來帶入指定的 .spec 檔

這次你會發現,輸出的 log 就不會顯示它有去壓縮 vcruntime140.dll 而且產生的執行檔也能正常執行了。

現在我們回頭來看看檔案為什麼這麼大

從上面的被壓縮的 upx log 裡面可以發現,它壓縮了不少個 .pyd 檔案,問題是我只有輸出一行 Hello World 阿,真的需要用到這麼多 .pyd 檔案嗎?? 上面第一個列著 ssl.pyd,而我真的需要嗎?

由於 PyInstaller 為了簡化使用者的操作,你的 .py 到底需要什麼 .pyd 其實它是自己偵測產生的,所以若沒有特別設定需要什麼,其實它自動判斷並加進執行檔案的 .pyd 會有很多是不需要的,所以我們可以透過上述的 .spec 來指定移除實際上不需要的 .pyd 檔案。

再次打開 custom.spec 並修改 excludes 這個 list

1
2
# excludes=[],
excludes=['ssl'],

然後再次重新執行 PyInstaller 來產生執行檔,再次觀察 log 與執行檔案大小就會發現 ssl.pyd 真的不會被包進去了。 重複這個動作將不需要的 .pyd 移除,並重複驗證自己要寫的程式就可以大幅度的縮小最後的執行檔大小了。

提示
Python2 與 Python3 需要被 exclude 的列表並不完全相同,需要分開實驗才能得到最佳的結果

由於 Hello World 只有一行 print, 所以我直接將所有的 .pyd 都移除試試,結果的確可以而且最後的執行檔縮小到了 2.7 MB,足足縮小到了原本的 32% 大小。這個大小總算是比較能夠接受了。

1
2
# excludes=[],
excludes=['ssl', 'xml', 'lzma', 'multiprocessing', 'bz2', 'hashlib', 'socket', 'select', 'unicodedata', 'queue', 'asyncio', 'overlapped'],

使用 virtualenv 來避免不乾淨的 Python 環境

virtualenv 是一個用來產生全新環境的 package,這在不同的系統上想要取得同樣的 Python 環境是一個很方便的工具。

首先我們還是透過 pip 來安裝它

1
pip install virtualenv --upgrade

然後建立一個空目錄,輸入

1
2
3
4
# -p 是用來指定想要使用的 python, 後面是指定這個環境要建立在哪個路徑, 這邊範例是建立在當前目錄下 .
virtualenv -p c:\Python38\python.exe .
cd Scripts
call activate.bat

這時候你就會進入 virtualenv 全新 python 環境了,全新的 python 意味著你必須重新安裝所有開發所需的 package 喔。 操作完成可以再到 Scripts 目錄下輸入 deactivate 來離開 virtualenv 環境,然後直接將這個目錄刪除就可以清除乾淨了。

若想要全自動化使用 virtualenv + PyInstaller 怎麼辦呢? 下面是一個範例。

首先我們先回顧一下應準備好的檔案

  • HelloWorld.py
  • custom.spec
  • upx.1
  • upx.exe

然後建立並執行下面這個 batch file 放在同一個目錄如下

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
set _main_py_file_=HelloWorld
set _virtualDIR_=virenv

virtualenv -p c:\Python38\python.exe %_virtualDIR_%
call %_virtualDIR_%\Scripts\activate.bat
pip install pyinstaller==3.6
if exist %_main_py_file_%.exe del /f /q %_main_py_file_%.exe
pyinstaller -F --clean -D custom.spec %_main_py_file_%.py
call %_virtualDIR_%\Scripts\deactivate.bat
copy /y Dist\%_main_py_file_%.exe %_main_py_file_%.exe
rd /s /q Dist
rd /s /q build
rd /s /q __pycache__
rd /s /q %_virtualDIR_%

大功告成

我想要加密我的 script

既然會想做成執行檔,當然也有可能不想要讓別人看到你是怎麼寫的,但網路上很多教學都是說 PyInstaller 支援 PyCrypto,而 PyCrypto 已經不繼續開發很久了,加上用 pip 安裝時若沒有 Visual Studio 也裝不起來,更不要說就算友還是裝不起來用到快瘋掉,最後找到一個相對簡單的方法,就是改用 pyaes 這個 package。

首先,請先確認 PyInstaller 是 3.6 版,這個版本還沒有將 pyaes 加入支援,所以必須自行修改程式,以下提供 git diff 檔方便 apply patch.

將下面這整段存成 pyaes.diff 放在同一個目錄下

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
 archive/pyz_crypto.py      | 51 +++++++++++++-------------
 building/api.py            | 11 ++++++
 building/build_main.py     |  5 ++-
 building/makespec.py       | 22 +++++++-----
 loader/pyimod02_archive.py | 89 ++++++++++++++++++++++++----------------------
 5 files changed, 102 insertions(+), 76 deletions(-)

diff --git a/archive/pyz_crypto.py b/archive/pyz_crypto.py
index 47d2f1c..7003fd3 100644
--- a/archive/pyz_crypto.py
+++ b/archive/pyz_crypto.py
@@ -14,30 +14,30 @@ import os
 BLOCK_SIZE = 16
 
 
-def import_aes(module_name):
-    """
-    Tries to import the AES module from PyCrypto.
+# def import_aes(module_name):
+#     """
+#     Tries to import the AES module from PyCrypto.
 
-    PyCrypto 2.4 and 2.6 uses different name of the AES extension.
-    """
-    return __import__(module_name, fromlist=[module_name.split('.')[-1]])
+#     PyCrypto 2.4 and 2.6 uses different name of the AES extension.
+#     """
+#     return __import__(module_name, fromlist=[module_name.split('.')[-1]])
 
 
-def get_crypto_hiddenimports():
-    """
-    These module names are appended to the PyInstaller analysis phase.
-    :return: Name of the AES module.
-    """
-    try:
-        # The _AES.so module exists only in PyCrypto 2.6 and later. Try to import
-        # that first.
-        modname = 'Crypto.Cipher._AES'
-        import_aes(modname)
-    except ImportError:
-        # Fallback to AES.so, which should be there in PyCrypto 2.4 and earlier.
-        modname = 'Crypto.Cipher.AES'
-        import_aes(modname)
-    return modname
+# def get_crypto_hiddenimports():
+#     """
+#     These module names are appended to the PyInstaller analysis phase.
+#     :return: Name of the AES module.
+#     """
+#     try:
+#         # The _AES.so module exists only in PyCrypto 2.6 and later. Try to import
+#         # that first.
+#         modname = 'Crypto.Cipher._AES'
+#         import_aes(modname)
+#     except ImportError:
+#         # Fallback to AES.so, which should be there in PyCrypto 2.4 and earlier.
+#         modname = 'Crypto.Cipher.AES'
+#         import_aes(modname)
+#     return modname
 
 
 class PyiBlockCipher(object):
@@ -45,6 +45,8 @@ class PyiBlockCipher(object):
     This class is used only to encrypt Python modules.
     """
     def __init__(self, key=None):
+        import pyaes
+        self._aes = pyaes
         assert type(key) is str
         if len(key) > BLOCK_SIZE:
             self.key = key[0:BLOCK_SIZE]
@@ -52,8 +54,8 @@ class PyiBlockCipher(object):
             self.key = key.zfill(BLOCK_SIZE)
         assert len(self.key) == BLOCK_SIZE
 
-        # Import the right AES module.
-        self._aesmod = import_aes(get_crypto_hiddenimports())
+        # # Import the right AES module.
+        # self._aesmod = import_aes(get_crypto_hiddenimports())
 
     def encrypt(self, data):
         iv = os.urandom(BLOCK_SIZE)
@@ -63,4 +65,5 @@ class PyiBlockCipher(object):
         # The 'BlockAlgo' class is stateful, this factory method is used to
         # re-initialize the block cipher class with each call to encrypt() and
         # decrypt().
-        return self._aesmod.new(self.key.encode(), self._aesmod.MODE_CFB, iv)
+        # return self._aesmod.new(self.key.encode(), self._aesmod.MODE_CFB, iv)
+        return self._aes.AESModeOfOperationCFB(self.key.encode(), iv=iv)
diff --git a/building/api.py b/building/api.py
index 8c62749..62957ef 100644
--- a/building/api.py
+++ b/building/api.py
@@ -94,6 +94,17 @@ class PYZ(Target):
             # Insert the key as the first module in the list. The key module contains
             # just variables and does not depend on other modules.
             self.dependencies.insert(0, key_file)
+            import copy
+            copy_file = ('copy',
+                         copy.__file__,
+                         'PYMODULE')
+
+            pyaes_file = ('pyimod00_pyaes',
+                          os.path.join(CONF['workpath'], 'pyimod00_pyaes.pyc'),
+                          'PYMODULE')
+
+            self.dependencies.insert(2, copy_file)
+            self.dependencies.insert(3, pyaes_file)
         # Compile the top-level modules so that they end up in the CArchive and can be
         # imported by the bootstrap script.
         self.dependencies = misc.compile_py_files(self.dependencies, CONF['workpath'])
diff --git a/building/build_main.py b/building/build_main.py
index d1cab2c..25d9a27 100644
--- a/building/build_main.py
+++ b/building/build_main.py
@@ -225,7 +225,10 @@ class Analysis(Target):
                 f.write(text_type('# -*- coding: utf-8 -*-\n'
                                   'key = %r\n' % cipher.key))
             logger.info('Adding dependencies on pyi_crypto.py module')
-            self.hiddenimports.append(pyz_crypto.get_crypto_hiddenimports())
+            # self.hiddenimports.append(pyz_crypto.get_crypto_hiddenimports())
+            pyaes_path = os.path.join(CONF['workpath'], 'pyimod00_pyaes.py')
+            import pyaes.aes
+            shutil.copy(pyaes.aes.__file__, pyaes_path)
 
         self.excludes = excludes or []
         self.scripts = TOC()
diff --git a/building/makespec.py b/building/makespec.py
index fc0cc1d..bd69b37 100644
--- a/building/makespec.py
+++ b/building/makespec.py
@@ -396,15 +396,21 @@ def main(scripts, name=None, onefile=None,
         # Tries to import PyCrypto since we need it for bytecode obfuscation. Also make sure its
         # version is >= 2.4.
         try:
-            import Crypto
-            is_version_acceptable = LooseVersion(Crypto.__version__) >= LooseVersion('2.4')
-            if not is_version_acceptable:
-                logger.error('PyCrypto version must be >= 2.4, older versions are not supported.')
-                sys.exit(1)
+            import pyaes  # noqa: F401
+        #     import PyCrypto
+        #     is_version_acceptable = LooseVersion(Crypto.__version__) >= LooseVersion('2.4')
+        #     if not is_version_acceptable:
+        #         logger.error('PyCrypto version must be >= 2.4, older versions are not supported.')
+        #         sys.exit(1)
         except ImportError:
-            logger.error('We need PyCrypto >= 2.4 to use byte-code obfuscation but we could not')
-            logger.error('find it. You can install it with pip by running:')
-            logger.error('  pip install PyCrypto')
+            logger.error('We need pyaes to use byte-code obfuscation but ')
+            logger.error('we could not find it. ')
+            logger.error('You can install it with pip by running:')
+            logger.error('  pip install pyaes')
+        #     logger.error('We need PyCrypto >= 2.4 to use byte-code obfuscation but we could not')
+        #     logger.error('find it. You can install it with pip by running:')
+        #     logger.error('  pip install PyCrypto')
+        
             sys.exit(1)
         cipher_init = cipher_init_template % {'key': key}
     else:
diff --git a/loader/pyimod02_archive.py b/loader/pyimod02_archive.py
index 2f372d9..781b81a 100644
--- a/loader/pyimod02_archive.py
+++ b/loader/pyimod02_archive.py
@@ -249,7 +249,9 @@ class Cipher(object):
         # the generated 'pyi_crypto_key' module.
         import pyimod00_crypto_key
         key = pyimod00_crypto_key.key
+        import pyimod00_pyaes
 
+        self._aes = pyimod00_pyaes
         assert type(key) is str
         if len(key) > CRYPT_BLOCK_SIZE:
             self.key = key[0:CRYPT_BLOCK_SIZE]
@@ -257,53 +259,54 @@ class Cipher(object):
             self.key = key.zfill(CRYPT_BLOCK_SIZE)
         assert len(self.key) == CRYPT_BLOCK_SIZE
 
-        # Import the right AES module.
-        self._aes = self._import_aesmod()
-
-    def _import_aesmod(self):
-        """
-        Tries to import the AES module from PyCrypto.
-
-        PyCrypto 2.4 and 2.6 uses different name of the AES extension.
-        """
-        # The _AES.so module exists only in PyCrypto 2.6 and later. Try to import
-        # that first.
-        modname = 'Crypto.Cipher._AES'
-
-        if sys.version_info[0] == 2:
-            # Not-so-easy way: at bootstrap time we have to load the module from the
-            # temporary directory in a manner similar to pyi_importers.CExtensionImporter.
-            from pyimod03_importers import CExtensionImporter
-            importer = CExtensionImporter()
-            # NOTE: We _must_ call find_module first.
-            mod = importer.find_module(modname)
-            # Fallback to AES.so, which should be there in PyCrypto 2.4 and earlier.
-            if not mod:
-                modname = 'Crypto.Cipher.AES'
-                mod = importer.find_module(modname)
-                if not mod:
-                    # Raise import error if none of the AES modules is found.
-                    raise ImportError(modname)
-            mod = mod.load_module(modname)
-        else:
-            kwargs = dict(fromlist=['Crypto', 'Cipher'])
-            try:
-                mod = __import__(modname, **kwargs)
-            except ImportError:
-                modname = 'Crypto.Cipher.AES'
-                mod = __import__(modname, **kwargs)
-
-        # Issue #1663: Remove the AES module from sys.modules list. Otherwise
-        # it interferes with using 'Crypto.Cipher' module in users' code.
-        if modname in sys.modules:
-            del sys.modules[modname]
-        return mod
+        # # Import the right AES module.
+        # self._aes = self._import_aesmod()
+
+    # def _import_aesmod(self):
+    #     """
+    #     Tries to import the AES module from PyCrypto.
+
+    #     PyCrypto 2.4 and 2.6 uses different name of the AES extension.
+    #     """
+    #     # The _AES.so module exists only in PyCrypto 2.6 and later. Try to import
+    #     # that first.
+    #     modname = 'Crypto.Cipher._AES'
+
+    #     if sys.version_info[0] == 2:
+    #         # Not-so-easy way: at bootstrap time we have to load the module from the
+    #         # temporary directory in a manner similar to pyi_importers.CExtensionImporter.
+    #         from pyimod03_importers import CExtensionImporter
+    #         importer = CExtensionImporter()
+    #         # NOTE: We _must_ call find_module first.
+    #         mod = importer.find_module(modname)
+    #         # Fallback to AES.so, which should be there in PyCrypto 2.4 and earlier.
+    #         if not mod:
+    #             modname = 'Crypto.Cipher.AES'
+    #             mod = importer.find_module(modname)
+    #             if not mod:
+    #                 # Raise import error if none of the AES modules is found.
+    #                 raise ImportError(modname)
+    #         mod = mod.load_module(modname)
+    #     else:
+    #         kwargs = dict(fromlist=['Crypto', 'Cipher'])
+    #         try:
+    #             mod = __import__(modname, **kwargs)
+    #         except ImportError:
+    #             modname = 'Crypto.Cipher.AES'
+    #             mod = __import__(modname, **kwargs)
+
+    #     # Issue #1663: Remove the AES module from sys.modules list. Otherwise
+    #     # it interferes with using 'Crypto.Cipher' module in users' code.
+    #     if modname in sys.modules:
+    #         del sys.modules[modname]
+    #     return mod
 
     def __create_cipher(self, iv):
         # The 'BlockAlgo' class is stateful, this factory method is used to
         # re-initialize the block cipher class with each call to encrypt() and
         # decrypt().
-        return self._aes.new(self.key, self._aes.MODE_CFB, iv)
+        # return self._aes.new(self.key, self._aes.MODE_CFB, iv)
+        return self._aes.AESModeOfOperationCFB(self.key.encode(), iv=iv)
 
     def decrypt(self, data):
         return self.__create_cipher(data[:CRYPT_BLOCK_SIZE]).decrypt(data[CRYPT_BLOCK_SIZE:])
@@ -345,7 +348,7 @@ class ZlibArchiveReader(ArchiveReader):
         # Try to import the key module. If the key module is not available
         # then it means that encryption is disabled.
         try:
-            import pyimod00_crypto_key
+            # import pyimod00_crypto_key
             self.cipher = Cipher()
         except ImportError:
             self.cipher = None
提示
存檔時務必存成 UNIX 格式,不能是 windows 的 EOL 格式

我們再修改一下 PyInstaller 的 custom.spec 來支援加密

1
2
# block_cipher = None
block_cipher = pyi_crypto.PyiBlockCipher(key="E$dm~?%?]U4z';5")
提示
E$dm~?%?]U4z';5 是你可以自己決定要用來作為密鑰的值,我這邊是隨機產生一組值來當密鑰

接著我們修改一下完整的 batch file使其可以在 virtualenv 環境下打 git patch

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
set _main_py_file_=HelloWorld
set _virtualDIR_=virenv
set _diff_file_=pyaes.diff

virtualenv -p c:\Python38\python.exe %_virtualDIR_%
call %_virtualDIR_%\Scripts\activate.bat
pip install pyinstaller==3.6 pyaes==1.6.1
git apply --directory=%_virtualDIR_%/Lib/site-packages/PyInstaller %_diff_file_%
if exist %_main_py_file_%.exe del /f /q %_main_py_file_%.exe
pyinstaller -F --clean -D custom.spec %_main_py_file_%.py
call %_virtualDIR_%\Scripts\deactivate.bat
copy /y Dist\%_main_py_file_%.exe %_main_py_file_%.exe
rd /s /q Dist
rd /s /q build
rd /s /q __pycache__
rd /s /q %_virtualDIR_%
提示
加密後的檔案執行速度真的是很不敢恭維,可以自己考慮是否需要。 若你的程式可以只用 Python2 就可以完成,在經過 PyInstaller 的包裝後的檔案大小通常會比 Python3 小一點

Reference