背景
- 仕事で PyInstaller で exe 配布する機会があり、PyInstaller の仕様を確認していた
- 確認したところ、ビジネスロジックの漏えいにつながる可能性があり、実際に起こりうるかどうかを調査した
本稿では、PyInstaller で生成した実行ファイルがどのように Python のロジックを保持しているのか、そしてそのロジックがどこまで逆解析可能なのかを、実例を交えて確認する。
はじめに
PyInstaller は、Python スクリプトを実行可能な Windows の exe に変換するための便利なツールだ。だが、PyInstaller で生成した exe には Python のバイトコードが埋め込まれており、展開や逆アセンブルによってロジックを再構成できる場合がある。
本記事では、PyInstaller で生成した exe を対象に、どのような手順でリバースエンジニアリングが進むのか、そしてどこにリスクがあるのかを整理する。
1. PyInstaller の基本動作
PyInstaller は、Python プログラムとその依存ライブラリを 1 つの実行ファイルにまとめる。onefile 形式では、実行時に一時展開用ディレクトリが作成され、そこに Python ランタイムや zip アーカイブが展開される。
このとき、ユーザーの Python ソースそのものがそのまま見えるわけではないが、.pyc や PYZ アーカイブとして一時ディレクトリやメモリ上に出力される。
2. 逆解析に使われる典型的な手順
PyInstaller の exe を解析する流れは、実際には「EXE 本体を直接デコンパイルする」よりも、EXE 内に埋め込まれた Python アーカイブを展開してから pyc を取り出すというものが中心になる。
今回の実例では、抽出ツールとして C:\SourceCode\Python\pyinstaller_binaly_dump\pyinstxtractor.py を使った。このスクリプトの取得元は、GitHub の extremecoders-re/pyinstxtractor リポジトリである(https://github.com/extremecoders-re/pyinstxtractor)。
- EXE から PyInstaller アーカイブの抽出
-
pyinstxtractor.pyを使って、exe 内に埋め込まれたPYZやpycを取り出す
- エントリポイントの特定
-
main.pyc/calc.pycを探し、どれが実際の処理本体かを特定する -
pyinstxtractor.pyはPossible entry point:というログを出すため、pyiboot01_bootstrap.pycやpyi_rth_inspect.pycのような PyInstaller 実行時補助ファイルと、実際のアプリ本体であるmain.pycを見分ける - 実際の抽出ログでは、次のような出力が現れる
[+] Possible entry point: pyiboot01_bootstrap.pyc
[+] Possible entry point: pyi_rth_inspect.pyc
[+] Possible entry point: main.pyc
このログは、PyInstaller のランタイム補助ファイルとアプリ本体コードを区別する手がかりになる。pyiboot01_bootstrap.pyc や pyi_rth_inspect.pyc は PyInstaller 自体の起動用コードであり、main.pyc が実際の処理本体であると推定しやすい。
- Python バイトコードの逆アセンブル
-
marshal.load()でコードオブジェクトを取り出し、dis.dis()によって CPython VM 命令列を表示する
- 実行フローの復元
- 関数呼び出し、比較演算、ジャンプ命令を追って、元のロジックを人間が読める形に再構成する
ここで重要なのは、PyInstaller の exe は「そのままの機械語を逆コンパイルする」のではなく、Python の bytecode を取り出して解析することがほとんどだという点だ。
したがって、エントリポイントの特定は、単にファイルが見つかっただけでなく、「どれが PyInstaller の補助コードで、どれがアプリの処理本体か」を見分ける作業でもある。
3. なぜ PyInstaller で exe 化しても秘匿にならないのか
PyInstaller は「配布用の実行ファイル化」を目的としており、秘密保持を前提にした難読化ではない。結果として、次のような特徴がある。
- Python のバイトコードが内部に残る
-
pycファイルが展開可能である -
disで opcode を読むことができる - Python 3.11 / 3.12 のようなバージョン差分を合わせて解析できる
つまり、ロジック自体が完全に隠蔽されるわけではなく、読み解く人がいれば再構成可能だ。
4. 実際に起こりうるリスク
4.1 ビジネスロジックの漏えい
PyInstaller で exe 化したアプリは、見た目は実行ファイルに見えていても、内部には Python の bytecode としてロジックが残る。抽出と逆アセンブルができれば、単なる計算処理だけでなく、業務ルール、閾値、判定条件、補正式、価格計算の仕組みまで再構成される可能性がある。
これは「アプリのロジックが外部に漏れる」という意味に直結する。四則演算などの単純な機能であっても、そこにビジネスルールが混ざっていれば、たとえ一見して普通の計算アプリに見えても、その判定基準や計算モデルは追跡可能だ。
特に、次のような内容は逆解析で取り出されやすい。
- 閾値や境界条件
- 誤差補正や変換式
- 料金計算や割引のロジック
- 判定の分岐条件
- 重要な係数や定数
つまり、PyInstaller でクライアントに配布した exe に業務ロジックを埋め込むことは、事実上、ロジックをユーザー側に見せているのと同じだ。攻撃者が一度解析を始めれば、現実的には「元のソースを読む」よりも簡単に、実行ロジックを再構築できるケースが多い。
このため、PyInstaller で製品機能そのものをクライアント配布物に入れてしまう設計は、ビジネスロジックの秘匿性という観点では非常に危険である。業務上重要な判定や計算は、クライアント側ではなくサーバー側で処理し、クライアントには UI や入力補助程度の役割に留めるべきだ。
5. 実例: magic_fomul_calc を対象にした逆コンパイル
以下は、作成した magic_fomul_calc.exe を対象にして行った逆解析の例だ。
5.1 解析の流れ
今回の検証では、まず pyinstxtractor.py で EXE 内の PYZ / pyc を抽出し、main.pyc と calc.pyc を特定したうえで、marshal.load() と dis.dis() により Python VM バイトコードを出力した。
この手順は、PyInstaller の EXE をそのまま逆コンパイルするのではなく、埋め込まれた Python バイトコードを取り出して解析するという流れに一致する。
5.2 実際に出力された VM コード(全件)
以下は、pyinstxtractor.py により抽出した main.pyc と calc.pyc から marshal.load() と dis.dis() によって出力された VM コードの全体である。
#### C:\SourceCode\Python\pyinstaller_binaly_dump\magic_fomul_calc.exe_extracted\main.pyc ####
== <module> ==
0 0 RESUME 0
1 2 LOAD_CONST 0 (0)
4 LOAD_CONST 1 (('calculate',))
6 IMPORT_NAME 0 (calc)
8 IMPORT_FROM 1 (calculate)
10 STORE_NAME 1 (calculate)
12 POP_TOP
4 14 LOAD_CONST 5 (('return', None))
16 LOAD_CONST 3 (<code object run at 0x000001A470C3E0D0, file "main.py", line 4>)
18 MAKE_FUNCTION 4 (annotations)
20 STORE_NAME 2 (run)
20 22 LOAD_NAME 3 (__name__)
24 LOAD_CONST 4 ('__main__')
26 COMPARE_OP 40 (==)
30 POP_JUMP_IF_FALSE 8 (to 48)
21 32 PUSH_NULL
34 LOAD_NAME 2 (run)
36 CALL 0
44 POP_TOP
46 RETURN_CONST 2 (None)
20 >> 48 RETURN_CONST 2 (None)
Disassembly of <code object run at 0x000001A470C3E0D0, file "main.py", line 4>:
4 0 RESUME 0
5 2 LOAD_GLOBAL 1 (NULL + print)
12 LOAD_CONST 1 ('Simple Calculator')
14 CALL 1
22 POP_TOP
6 24 LOAD_GLOBAL 1 (NULL + print)
34 LOAD_CONST 2 ('入力例: 12 + 3 / 0.1 mf 3000 / 終了: q')
36 CALL 1
44 POP_TOP
8 46 NOP
9 >> 48 LOAD_GLOBAL 3 (NULL + input)
58 LOAD_CONST 3 ('> ')
60 CALL 1
68 LOAD_ATTR 5 (NULL|self + strip)
88 CALL 0
96 STORE_FAST 0 (text)
10 98 LOAD_FAST 0 (text)
100 LOAD_ATTR 7 (NULL|self + lower)
120 CALL 0
128 LOAD_CONST 4 ('q')
130 COMPARE_OP 40 (==)
134 POP_JUMP_IF_FALSE 1 (to 138)
11 136 RETURN_CONST 0 (None)
13 >> 138 LOAD_FAST 0 (text)
140 LOAD_ATTR 9 (NULL|self + split)
160 CALL 0
168 UNPACK_SEQUENCE 3
172 STORE_FAST 1 (left)
174 STORE_FAST 2 (operator)
176 STORE_FAST 3 (right)
14 178 LOAD_GLOBAL 11 (NULL + float)
188 LOAD_FAST 1 (left)
190 CALL 1
198 STORE_FAST 4 (a)
15 200 LOAD_GLOBAL 11 (NULL + float)
210 LOAD_FAST 3 (right)
212 CALL 1
220 STORE_FAST 5 (b)
16 222 LOAD_GLOBAL 13 (NULL + calculate)
232 LOAD_FAST 4 (a)
234 LOAD_FAST 2 (operator)
236 LOAD_FAST 5 (b)
238 CALL 3
246 STORE_FAST 6 (result)
17 248 LOAD_GLOBAL 1 (NULL + print)
258 LOAD_FAST 6 (result)
260 CALL 1
268 POP_TOP
8 270 JUMP_BACKWARD 112 (to 48)
== run ==
4 0 RESUME 0
5 2 LOAD_GLOBAL 1 (NULL + print)
12 LOAD_CONST 1 ('Simple Calculator')
14 CALL 1
22 POP_TOP
6 24 LOAD_GLOBAL 1 (NULL + print)
34 LOAD_CONST 2 ('入力例: 12 + 3 / 0.1 mf 3000 / 終了: q')
36 CALL 1
44 POP_TOP
8 46 NOP
9 >> 48 LOAD_GLOBAL 3 (NULL + input)
58 LOAD_CONST 3 ('> ')
60 CALL 1
68 LOAD_ATTR 5 (NULL|self + strip)
88 CALL 0
96 STORE_FAST 0 (text)
10 98 LOAD_FAST 0 (text)
100 LOAD_ATTR 7 (NULL|self + lower)
120 CALL 0
128 LOAD_CONST 4 ('q')
130 COMPARE_OP 40 (==)
134 POP_JUMP_IF_FALSE 1 (to 138)
11 136 RETURN_CONST 0 (None)
13 >> 138 LOAD_FAST 0 (text)
140 LOAD_ATTR 9 (NULL|self + split)
160 CALL 0
168 UNPACK_SEQUENCE 3
172 STORE_FAST 1 (left)
174 STORE_FAST 2 (operator)
176 STORE_FAST 3 (right)
14 178 LOAD_GLOBAL 11 (NULL + float)
188 LOAD_FAST 1 (left)
190 CALL 1
198 STORE_FAST 4 (a)
15 200 LOAD_GLOBAL 11 (NULL + float)
210 LOAD_FAST 3 (right)
212 CALL 1
220 STORE_FAST 5 (b)
16 222 LOAD_GLOBAL 13 (NULL + calculate)
232 LOAD_FAST 4 (a)
234 LOAD_FAST 2 (operator)
236 LOAD_FAST 5 (b)
238 CALL 3
246 STORE_FAST 6 (result)
17 248 LOAD_GLOBAL 1 (NULL + print)
258 LOAD_FAST 6 (result)
260 CALL 1
268 POP_TOP
8 270 JUMP_BACKWARD 112 (to 48)
#### C:\SourceCode\Python\pyinstaller_binaly_dump\magic_fomul_calc.exe_extracted\PYZ.pyz_extracted\calc.pyc ####
== <module> ==
0 0 RESUME 0
1 2 LOAD_CONST 0 (0)
4 LOAD_CONST 1 (None)
6 IMPORT_NAME 0 (math)
8 STORE_NAME 0 (math)
4 10 LOAD_CONST 2 ('a')
12 LOAD_NAME 1 (float)
14 LOAD_CONST 3 ('b')
16 LOAD_NAME 1 (float)
18 LOAD_CONST 4 ('return')
20 LOAD_NAME 1 (float)
22 BUILD_TUPLE 6
24 LOAD_CONST 5 (<code object add at 0x000001A470F9E3F0, file "calc.py", line 4>)
26 MAKE_FUNCTION 4 (annotations)
28 STORE_NAME 2 (add)
8 30 LOAD_CONST 2 ('a')
32 LOAD_NAME 1 (float)
34 LOAD_CONST 3 ('b')
36 LOAD_NAME 1 (float)
38 LOAD_CONST 4 ('return')
40 LOAD_NAME 1 (float)
42 BUILD_TUPLE 6
44 LOAD_CONST 6 (<code object sub at 0x000001A470F9E4C0, file "calc.py", line 8>)
46 MAKE_FUNCTION 4 (annotations)
48 STORE_NAME 3 (sub)
12 50 LOAD_CONST 2 ('a')
52 LOAD_NAME 1 (float)
54 LOAD_CONST 3 ('b')
56 LOAD_NAME 1 (float)
58 LOAD_CONST 4 ('return')
60 LOAD_NAME 1 (float)
62 BUILD_TUPLE 6
64 LOAD_CONST 7 (<code object mul at 0x000001A470F9E590, file "calc.py", line 12>)
66 MAKE_FUNCTION 4 (annotations)
68 STORE_NAME 4 (mul)
16 70 LOAD_CONST 2 ('a')
72 LOAD_NAME 1 (float)
74 LOAD_CONST 3 ('b')
76 LOAD_NAME 1 (float)
78 LOAD_CONST 4 ('return')
80 LOAD_NAME 1 (float)
82 BUILD_TUPLE 6
84 LOAD_CONST 8 (<code object div at 0x000001A470F9E660, file "calc.py", line 16>)
86 MAKE_FUNCTION 4 (annotations)
88 STORE_NAME 5 (div)
20 90 LOAD_CONST 9 ('slip')
92 LOAD_NAME 1 (float)
94 LOAD_CONST 10 ('b_factor')
96 LOAD_NAME 1 (float)
98 LOAD_CONST 11 ('c_factor')
100 LOAD_NAME 1 (float)
102 LOAD_CONST 12 ('d_factor')
104 LOAD_NAME 1 (float)
106 LOAD_CONST 13 ('e_factor')
108 LOAD_NAME 1 (float)
110 LOAD_CONST 4 ('return')
112 LOAD_NAME 1 (float)
114 BUILD_TUPLE 12
116 LOAD_CONST 14 (<code object pacejka_magic_formula at 0x000001A470C61370, file "calc.py", line 20>)
118 MAKE_FUNCTION 4 (annotations)
120 STORE_NAME 6 (pacejka_magic_formula)
33 122 LOAD_CONST 9 ('slip')
124 LOAD_NAME 1 (float)
126 LOAD_CONST 15 ('peak_force')
128 LOAD_NAME 1 (float)
130 LOAD_CONST 4 ('return')
132 LOAD_NAME 1 (float)
134 BUILD_TUPLE 6
136 LOAD_CONST 16 (<code object magic_formula_with_default_tire at 0x000001A470FA0300, file "calc.py", line 33>)
138 MAKE_FUNCTION 4 (annotations)
140 STORE_NAME 7 (magic_formula_with_default_tire)
44 142 LOAD_CONST 2 ('a')
144 LOAD_NAME 1 (float)
146 LOAD_CONST 17 ('operator')
148 LOAD_NAME 8 (str)
150 LOAD_CONST 3 ('b')
152 LOAD_NAME 1 (float)
154 LOAD_CONST 4 ('return')
156 LOAD_NAME 1 (float)
158 BUILD_TUPLE 8
160 LOAD_CONST 18 (<code object calculate at 0x000001A471000030, file "calc.py", line 44>)
162 MAKE_FUNCTION 4 (annotations)
164 STORE_NAME 9 (calculate)
166 RETURN_CONST 1 (None)
Disassembly of <code object add at 0x000001A470F9E3F0, file "calc.py", line 4>:
4 0 RESUME 0
5 2 LOAD_FAST 0 (a)
4 LOAD_FAST 1 (b)
6 BINARY_OP 0 (+)
10 RETURN_VALUE
Disassembly of <code object sub at 0x000001A470F9E4C0, file "calc.py", line 8>:
8 0 RESUME 0
9 2 LOAD_FAST 0 (a)
4 LOAD_FAST 1 (b)
6 BINARY_OP 10 (-)
10 RETURN_VALUE
Disassembly of <code object mul at 0x000001A470F9E590, file "calc.py", line 12>:
12 0 RESUME 0
13 2 LOAD_FAST 0 (a)
4 LOAD_FAST 1 (b)
6 BINARY_OP 5 (*)
10 RETURN_VALUE
Disassembly of <code object div at 0x000001A470F9E660, file "calc.py", line 16>:
16 0 RESUME 0
17 2 LOAD_FAST 0 (a)
4 LOAD_FAST 1 (b)
6 BINARY_OP 11 (/)
10 RETURN_VALUE
Disassembly of <code object pacejka_magic_formula at 0x000001A470C61370, file "calc.py", line 20>:
20 0 RESUME 0
27 2 LOAD_FAST 1 (b_factor)
4 LOAD_FAST 0 (slip)
6 BINARY_OP 5 (*)
10 STORE_FAST 5 (bx)
28 12 LOAD_FAST 3 (d_factor)
14 LOAD_GLOBAL 1 (NULL + math)
24 LOAD_ATTR 2 (sin)
29 44 LOAD_FAST 2 (c_factor)
46 LOAD_GLOBAL 1 (NULL + math)
56 LOAD_ATTR 4 (atan)
76 LOAD_FAST 5 (bx)
78 LOAD_FAST 4 (e_factor)
80 LOAD_FAST 5 (bx)
82 LOAD_GLOBAL 1 (NULL + math)
92 LOAD_ATTR 4 (atan)
112 LOAD_FAST 5 (bx)
114 CALL 1
122 BINARY_OP 10 (-)
126 BINARY_OP 5 (*)
130 BINARY_OP 10 (-)
134 CALL 1
142 BINARY_OP 5 (*)
28 146 CALL 1
154 BINARY_OP 5 (*)
158 RETURN_VALUE
Disassembly of <code object magic_formula_with_default_tire at 0x000001A470FA0300, file "calc.py", line 33>:
33 0 RESUME 0
35 2 LOAD_GLOBAL 1 (NULL + pacejka_magic_formula)
36 12 LOAD_FAST 0 (slip)
37 14 LOAD_CONST 1 (10.0)
38 16 LOAD_CONST 2 (1.9)
39 18 LOAD_FAST 1 (peak_force)
40 20 LOAD_CONST 3 (0.97)
35 22 KW_NAMES 4 (('slip', 'b_factor', 'c_factor', 'd_factor', 'e_factor'))
24 CALL 5
32 RETURN_VALUE
Disassembly of <code object calculate at 0x000001A471000030, file "calc.py", line 44>:
44 0 RESUME 0
45 2 LOAD_FAST 1 (operator)
4 LOAD_CONST 1 ('+')
6 COMPARE_OP 40 (==)
10 POP_JUMP_IF_FALSE 12 (to 36)
46 12 LOAD_GLOBAL 1 (NULL + add)
22 LOAD_FAST 0 (a)
24 LOAD_FAST 2 (b)
26 CALL 2
34 RETURN_VALUE
47 >> 36 LOAD_FAST 1 (operator)
38 LOAD_CONST 2 ('-')
40 COMPARE_OP 40 (==)
44 POP_JUMP_IF_FALSE 12 (to 70)
48 46 LOAD_GLOBAL 3 (NULL + sub)
56 LOAD_FAST 0 (a)
58 LOAD_FAST 2 (b)
60 CALL 2
68 RETURN_VALUE
49 >> 70 LOAD_FAST 1 (operator)
72 LOAD_CONST 3 ('*')
74 COMPARE_OP 40 (==)
78 POP_JUMP_IF_FALSE 12 (to 104)
50 80 LOAD_GLOBAL 5 (NULL + mul)
90 LOAD_FAST 0 (a)
92 LOAD_FAST 2 (b)
94 CALL 2
102 RETURN_VALUE
51 >> 104 LOAD_FAST 1 (operator)
106 LOAD_CONST 4 ('/')
108 COMPARE_OP 40 (==)
112 POP_JUMP_IF_FALSE 12 (to 138)
52 114 LOAD_GLOBAL 7 (NULL + div)
124 LOAD_FAST 0 (a)
126 LOAD_FAST 2 (b)
128 CALL 2
136 RETURN_VALUE
53 >> 138 LOAD_FAST 1 (operator)
140 LOAD_CONST 5 ('mf')
142 COMPARE_OP 40 (==)
146 POP_JUMP_IF_FALSE 12 (to 172)
54 148 LOAD_GLOBAL 9 (NULL + magic_formula_with_default_tire)
158 LOAD_FAST 0 (a)
160 LOAD_FAST 2 (b)
162 CALL 2
170 RETURN_VALUE
55 >> 172 LOAD_GLOBAL 11 (NULL + ValueError)
182 LOAD_CONST 6 ('unsupported operator')
184 CALL 1
192 RAISE_VARARGS 1
== add ==
4 0 RESUME 0
5 2 LOAD_FAST 0 (a)
4 LOAD_FAST 1 (b)
6 BINARY_OP 0 (+)
10 RETURN_VALUE
== sub ==
8 0 RESUME 0
9 2 LOAD_FAST 0 (a)
4 LOAD_FAST 1 (b)
6 BINARY_OP 10 (-)
10 RETURN_VALUE
== mul ==
12 0 RESUME 0
13 2 LOAD_FAST 0 (a)
4 LOAD_FAST 1 (b)
6 BINARY_OP 5 (*)
10 RETURN_VALUE
== div ==
16 0 RESUME 0
17 2 LOAD_FAST 0 (a)
4 LOAD_FAST 1 (b)
6 BINARY_OP 11 (/)
10 RETURN_VALUE
== pacejka_magic_formula ==
20 0 RESUME 0
27 2 LOAD_FAST 1 (b_factor)
4 LOAD_FAST 0 (slip)
6 BINARY_OP 5 (*)
10 STORE_FAST 5 (bx)
28 12 LOAD_FAST 3 (d_factor)
14 LOAD_GLOBAL 1 (NULL + math)
24 LOAD_ATTR 2 (sin)
29 44 LOAD_FAST 2 (c_factor)
46 LOAD_GLOBAL 1 (NULL + math)
56 LOAD_ATTR 4 (atan)
76 LOAD_FAST 5 (bx)
78 LOAD_FAST 4 (e_factor)
80 LOAD_FAST 5 (bx)
82 LOAD_GLOBAL 1 (NULL + math)
92 LOAD_ATTR 4 (atan)
112 LOAD_FAST 5 (bx)
114 CALL 1
122 BINARY_OP 10 (-)
126 BINARY_OP 5 (*)
130 BINARY_OP 10 (-)
134 CALL 1
142 BINARY_OP 5 (*)
28 146 CALL 1
154 BINARY_OP 5 (*)
158 RETURN_VALUE
== magic_formula_with_default_tire ==
33 0 RESUME 0
35 2 LOAD_GLOBAL 1 (NULL + pacejka_magic_formula)
36 12 LOAD_FAST 0 (slip)
37 14 LOAD_CONST 1 (10.0)
38 16 LOAD_CONST 2 (1.9)
39 18 LOAD_FAST 1 (peak_force)
40 20 LOAD_CONST 3 (0.97)
35 22 KW_NAMES 4 (('slip', 'b_factor', 'c_factor', 'd_factor', 'e_factor'))
24 CALL 5
32 RETURN_VALUE
== calculate ==
44 0 RESUME 0
45 2 LOAD_FAST 1 (operator)
4 LOAD_CONST 1 ('+')
6 COMPARE_OP 40 (==)
10 POP_JUMP_IF_FALSE 12 (to 36)
46 12 LOAD_GLOBAL 1 (NULL + add)
22 LOAD_FAST 0 (a)
24 LOAD_FAST 2 (b)
26 CALL 2
34 RETURN_VALUE
47 >> 36 LOAD_FAST 1 (operator)
38 LOAD_CONST 2 ('-')
40 COMPARE_OP 40 (==)
44 POP_JUMP_IF_FALSE 12 (to 70)
48 46 LOAD_GLOBAL 3 (NULL + sub)
56 LOAD_FAST 0 (a)
58 LOAD_FAST 2 (b)
60 CALL 2
68 RETURN_VALUE
49 >> 70 LOAD_FAST 1 (operator)
72 LOAD_CONST 3 ('*')
74 COMPARE_OP 40 (==)
78 POP_JUMP_IF_FALSE 12 (to 104)
50 80 LOAD_GLOBAL 5 (NULL + mul)
90 LOAD_FAST 0 (a)
92 LOAD_FAST 2 (b)
94 CALL 2
102 RETURN_VALUE
51 >> 104 LOAD_FAST 1 (operator)
106 LOAD_CONST 4 ('/')
108 COMPARE_OP 40 (==)
112 POP_JUMP_IF_FALSE 12 (to 138)
52 114 LOAD_GLOBAL 7 (NULL + div)
124 LOAD_FAST 0 (a)
126 LOAD_FAST 2 (b)
128 CALL 2
136 RETURN_VALUE
53 >> 138 LOAD_FAST 1 (operator)
140 LOAD_CONST 5 ('mf')
142 COMPARE_OP 40 (==)
146 POP_JUMP_IF_FALSE 12 (to 172)
54 148 LOAD_GLOBAL 9 (NULL + magic_formula_with_default_tire)
158 LOAD_FAST 0 (a)
160 LOAD_FAST 2 (b)
162 CALL 2
170 RETURN_VALUE
55 >> 172 LOAD_GLOBAL 11 (NULL + ValueError)
182 LOAD_CONST 6 ('unsupported operator')
184 CALL 1
192 RAISE_VARARGS 1
5.3 対応する演算ロジックの出力例
上記の出力から、main.pyc が input() で入力を受け取り、split() で left operator right を分割していることが確認できる。また、calc.pyc 側では mf という演算子が calculate() の分岐に含まれていることが確認できる。さらに、ここで pacejka_magic_formula や magic_formula_with_default_tire といった関数名と係数が確認できる。これはタイヤ特性の数式モデルとして有名なマジックフォーミュラであると推測でき、タイヤ特性の数式モデルそのものが、実装コードと同じように読み取れていることが分かる。
もしこの数式がビジネス上の重要な評価式であった場合、あるいはほかに他社に知られたくないアルゴリズムであった場合、実行ファイルの中に埋め込まれているだけで、その式や係数、その計算ルールが流出していたということを意味する。
5.4 元コードとの比較: AI 復元結果と実在のソースの一致
ここでは、実際に元になった calc.py と、VM バイトコードから AI が復元したコードを比較する。ポイントは、「意味としてほぼ同等のロジックが読み取れる」という点だ。
元のソースコード(実際の calc.py):
import math
def add(a: float, b: float) -> float:
return a + b
def sub(a: float, b: float) -> float:
return a - b
def mul(a: float, b: float) -> float:
return a * b
def div(a: float, b: float) -> float:
return a / b
def pacejka_magic_formula(
slip: float,
b_factor: float,
c_factor: float,
d_factor: float,
e_factor: float,
) -> float:
bx = b_factor * slip
return d_factor * math.sin(
c_factor * math.atan(bx - e_factor * (bx - math.atan(bx)))
)
def magic_formula_with_default_tire(slip: float, peak_force: float) -> float:
# 代表的な係数例: B=10.0, C=1.9, E=0.97
return pacejka_magic_formula(
slip=slip,
b_factor=10.0,
c_factor=1.9,
d_factor=peak_force,
e_factor=0.97,
)
def calculate(a: float, operator: str, b: float) -> float:
if operator == "+":
return add(a, b)
if operator == "-":
return sub(a, b)
if operator == "*":
return mul(a, b)
if operator == "/":
return div(a, b)
if operator == "mf":
return magic_formula_with_default_tire(a, b)
raise ValueError("unsupported operator")
AI で VM バイトコードから復元したコード:
import math
def add(a: float, b: float) -> float:
return a + b
def sub(a: float, b: float) -> float:
return a - b
def mul(a: float, b: float) -> float:
return a * b
def div(a: float, b: float) -> float:
return a / b
def pacejka_magic_formula(slip: float, b_factor: float, c_factor: float, d_factor: float, e_factor: float) -> float:
bx = b_factor * slip
return d_factor * math.sin(c_factor * math.atan(bx - e_factor * (bx - math.atan(bx))))
def magic_formula_with_default_tire(slip: float, peak_force: float) -> float:
return pacejka_magic_formula(
slip=slip,
b_factor=10.0,
c_factor=1.9,
d_factor=peak_force,
e_factor=0.97
)
def calculate(a: float, operator: str, b: float) -> float:
if operator == '+':
return add(a, b)
elif operator == '-':
return sub(a, b)
elif operator == '*':
return mul(a, b)
elif operator == '/':
return div(a, b)
elif operator == 'mf':
return magic_formula_with_default_tire(a, b)
else:
raise ValueError('unsupported operator')
比較すると、次の点でほぼ一致していることが分かる。
-
add,sub,mul,divが四則演算として定義されている -
mf演算子がmagic_formula_with_default_tire()にマッピングされている -
pacejka_magic_formula()が、b_factor,c_factor,d_factor,e_factorを使って計算している -
main.py側がtext.split()でleft operator rightを分割し、float()で数値化している
もちろん、AI による復元コードは元のソースの空白やコメント、引数の書式、if と elif の書き方などが多少異なるが、ロジックの本体と制御構造は実際のソースと同じものを指していることが分かる。
この比較から、PyInstaller で配布された EXE 内の Python バイトコードが、適切な手法で逆解析されれば、コードの骨格をかなり正確に再構成できることが確認できる。逆に言えば、ビジネスロジックをクライアント配布物に埋め込むことは、攻撃者にとって十分に可視化できる対象になるということだ。
6. なぜ対策が難しいのか
次のような対策はあるが、完全な防止は難しい。
- 難読化をかける
- Cython / Nuitka でネイティブ化する
- 実行時にサーバー側で判定する
- 重要ロジックをクライアントに置かない
ただし、いずれも「完全に安全」とまでは言えない。特に PyInstaller で配布する方式は、逆解析の観点で見れば、秘匿目的の保護には向いていない。
7. まとめ
PyInstaller による exe 化は開発効率が高い一方、「実行可能にした」だけで「秘匿した」わけではないという設計上の前提がある。
特に Python では、pyc と dis によってロジックを読み解くことが可能であり、ビジネスロジックを含む exe をクライアント配布物に置くことはリスクが高い。
業務上重要なロジックは、クライアント側に埋め込まず、サーバー側で管理・判定する設計に切り替えるのが適切だ。
8. 参考
- Python
disモジュール - Python
opcodeモジュール - CPython の bytecodes / ceval 実装
- PyInstaller の onefile 展開方式
- Pacejka Magic Formula / Tire and Vehicle Dynamics
- H.B. Pacejka, "Tire and Vehicle Dynamics"
- https://shop.elsevier.com/books/tire-and-vehicle-dynamics/pacejka/978-0-08-097016-5