CythonでPythonコードのフリーで安全な難読化

この記事は GO Inc. Advent Calendar 2024 の7日目です。

こんにちは、KUUグループの宮園です。仕事は何でも屋さんです。 今回は、業務とは全く関係ない話をします。趣味のコードの話です。社内で使っている技術ではありません。

Pythonで難読化したくありませんか?

Pythonでつくられたプロダクトのソースコードを見られたくなかったり、改ざんされたくないケースがあります。 例えば、

  • Pythonソフトウェアをハードウェアに組み込んでアプライアンスとして販売する場合
  • 有料で買い切りのソフトウェアの販売をする場合
  • ライセンスを更新し続けないと使えないソフトウェアの販売をする場合
  • 無償で期限付きの試用版を頒布する場合

など、知的財産の保護をしたいときです。(悪意のある用途は触れません)

私は趣味でアプライアンス1を作成し、同人で有償頒布することがあります。

また、これも趣味の領域ですが、依頼されたソフトウェア制作で、有償で納品し、さらに年額で支払ってもらう約束のものがありました。

これらについて、保護するための手段を調査しました。

難読化とは何かをあらためて

まず、今回の要件で「難読化(obfuscatation)」とは何かを定義します。

  • ロジックの把握が困難である
  • プロテクト、有効期限など、何らかの制約をつけている場合、それを回避されないように、ロジックの改ざんが困難である
  • 難読化前後で全く同じ挙動をする

上記の要件を満たしていれば、「難読化」されているとします。

既存Pythonコード難読化ツールの調査

オープンソースの難読化ツールの調査

まず、オープンソースPythonコード難読化ツールについて調査しました。その結果、いくつか課題があることがわかりました。

  • 難読化レベルが低いものがある
    • コードをBASE64エンコードしてデコードするだけのもの
    • コードの一部をROT13暗号化して復号するだけのもの
    • コードのシンボル名(変数名など)をランダムに変えるだけのもの
  • 過去に、難読化と称して、実行したコンピュータを乗っ取る悪性コードを注入する「サプライチェーン攻撃」があった2
    • BlazeStealer と呼ばれており、 Pyobfuse といったもっともらしい名前で複数のライブラリが PyPI (Python向けライブラリのリポジトリ)に登録されていた

商用の難読化ツールの調査

次に、商用Pythonコード難読化ツール「PyArmor」について調査しました。

  • 無償のTrialライセンスは、営利商品に使用してはならない
    • (今更)最新のLICENSE3 を読むと、 If the total sale income of this product is less than 100 x license fees, this software could be used temporarily. とあるので、小規模な同人程度のアプリケーションには使って良いことに気づいた
      • しかし、temporarilyとあるので、今後変更されることは十分にありえること
    • Trialライセンスでは、高度な難読化機能が無効化されている
  • 有償ライセンスは、個人レベルでは元が取れないので使いたくない
  • オープンソースソフトウェアでない(当たり前)

既存難読化ツールの調査結果

そのため、どちらも趣味レベルの有償頒布用途にはあまり適さないと判断しました。

Cythonの調査

そこで、難読化ツールではなく、アーキテクチャは制約される(クロスコンパイルは可能)ものの、バイナリにビルドするCython(オープンソース)を使用するのはどうか検討してみることにしました。

Cythonの本来の用途についてごく簡単に説明すると、PythonC言語拡張モジュールを書きやすくしたプログラミング言語Cythonと、その周辺ツール群です。

Pythonソースコードの上位互換に当たる文法のため、Pure Pythonのコードでも同様に周辺ツール群を使うことができ、Python拡張モジュール(バイナリ)とすることができます。

インストール方法は割愛しますが、Cythonをインストールすると cython コマンドと cythonize コマンドが実行できます。

cython コマンドは、Cython(またはPython)ソースコードを、C言語またはC++ソースコードに変換するコマンドです。

cythonize コマンドは、

をバイナリに、gccを用いてコンパイルするコマンドです。

cythonコマンドにより生成されるC言語ソースコードの調査

cythonコマンドでは、どのようなC言語ソースコードが生成されるのでしょうか。 試しに、2026年になるとライセンスが切れて秘密のキーが取得できなくなるようなコード license.py を書いてみます。

import time

def get_secret_key() -> str:
  # 2026-01-01T00:00:00+09:00 unix time
  if time.time() > 1767193200:
      raise RuntimeError('License is expired')

  secret_key = 'very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_long_key'
  return secret_key

$ cython -3 license.py

を実行すると、Python3のコードをC言語ソースコードに変換することができます。

コンパイル結果はtest.c として出力されます。254KiBもあるので、その中で、Pythonのオブジェクトやリテラル(即値)が定義されている箇所を例示します。

/* Implementation of "license" */
/* #### Code section: global_var ### */
static PyObject *__pyx_builtin_RuntimeError;
/* #### Code section: string_decls ### */
static const char __pyx_k__2[] = "*";
static const char __pyx_k__5[] = "?";
static const char __pyx_k_str[] = "str";
static const char __pyx_k_main[] = "__main__";
static const char __pyx_k_name[] = "__name__";
static const char __pyx_k_spec[] = "__spec__";
static const char __pyx_k_test[] = "__test__";
static const char __pyx_k_time[] = "time";
static const char __pyx_k_import[] = "__import__";
static const char __pyx_k_return[] = "return";
static const char __pyx_k_license[] = "license";
static const char __pyx_k_license_py[] = "license.py";
static const char __pyx_k_secret_key[] = "secret_key";
static const char __pyx_k_RuntimeError[] = "RuntimeError";
static const char __pyx_k_initializing[] = "_initializing";
static const char __pyx_k_is_coroutine[] = "_is_coroutine";
static const char __pyx_k_get_secret_key[] = "get_secret_key";
static const char __pyx_k_License_is_expired[] = "License is expired";
static const char __pyx_k_asyncio_coroutines[] = "asyncio.coroutines";
static const char __pyx_k_cline_in_traceback[] = "cline_in_traceback";
static const char __pyx_k_very_very_very_very_very_very_ve[] = "very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_long_key";

cythonizeコマンドにより生成されるバイナリの調査

cythonizeコマンドでコンパイルしたバイナリは、どのような内容になっているのでしょうか。

cythonize -3 -b license.py

Linux(x86_64 / Python3.12)で実行すると、license.cpython-312-x86_64-linux-gnu.soが生成されます。

まずは、正常に実行できるか試します。普通にimportして使えます。

$ python3
Python 3.12.3 (main, Nov  6 2024, 18:32:19) [GCC 13.2.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import license
>>> print(license.get_secret_key())
very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_very_long_key

実行できました。

fileコマンドで内容を見てみましょう。

$ file license.cpython-312-x86_64-linux-gnu.so 
license.cpython-312-x86_64-linux-gnu.so: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked,
BuildID[sha1]=18152b0bab5392e8c2fc0282c9ff52b61a2ffe88, with debug_info, not stripped

ELFになっていることは分かりましたが、ちょっと気になるところがあります。with debug_info と not stripped です。

これは、それぞれバイナリにデバッグ情報が付加されていること、シンボルテーブルが存在することを意味しています。

objdump コマンドで見てみましょう。

$ LANG=C objdump -x license.cpython-312-x86_64-linux-gnu.so > dump.txt
SYMBOL TABLE:
0000000000000000 l    df *ABS*  0000000000000000              license.c
(省略)
00000000000088a8 l     O .rodata    000000000000000b              __pyx_k_secret_key
00000000000088f8 l     O .rodata    0000000000000009              __pyx_k_spec
0000000000008921 l     O .rodata    0000000000000004              __pyx_k_str
00000000000088e8 l     O .rodata    0000000000000009              __pyx_k_test
00000000000088e3 l     O .rodata    0000000000000005              __pyx_k_time
00000000000087a0 l     O .rodata    000000000000006d              __pyx_k_very_very_very_very_very_very_ve
(省略)

バイナリにもオブジェクト名やリテラル、特に秘密のキーがシンボルテーブルに入ってしまっていました。 cythonizeコマンドのコンパイルオプションは、ドキュメントによると

gcc -shared -pthread -fPIC -fwrapv -O2 -Wall -fno-strict-aliasing \
      -I/usr/include/python3.xx(環境依存) -o yourmod.so yourmod.c

です。

特にdebug情報を付加するオプションなどは付いていません。

シンボルテーブルやデバッグ情報を消す対策をする

調べたところ、いにしえからある strip コマンドを使用することで、シンボルテーブルやデバッグ情報を消せることがわかりました。

$ strip -s license.cpython-312-x86_64-linux-gnu.so 
$ file license.cpython-312-x86_64-linux-gnu.so 
license.cpython-312-x86_64-linux-gnu.so: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked,
BuildID[sha1]=18152b0bab5392e8c2fc0282c9ff52b61a2ffe88, stripped

結果、with debug_info, not stripped を消すことができました。

SYMBOL TABLE:
no symbols

objdump コマンドでもシンボル名やリテラルが残っていないことが確認できました。

バイナリの詳細な調査

まだ安心はできません。

バイナリエディタ(わたしはOktetaを使います)で開いてみてsecret_keyで検索してみたところ、バイナリ内にはまだ変数名が残ってしまっていました。

考えると当たり前で、importするとPythonのモジュールとして使えるため、変数名や関数名はどうしても残ってしまいます。

また、肝心の秘密のキー文字列も、そのまま存在していました。これも仕方がないことでしょう。

秘密のキーが数値型ではどうか

ところで、ある程度大きな数値はシンボルテーブルには乗らなかったと前述しました。

今度は、ある程度以上大きな数値がバイナリとしてそのまま検索可能かどうか試してみましょう。 license.get_secret_key() の返り値をintとして、12345678901234567890(0xab54a98ceb1f0ad2) をシークレットキーとします

結果、数値として検索可能な状態にはありませんでした。

高機能デバッガ IDAで解析する

さらに詳細に調べるために、IDAという高機能デバッガを使用して .so ファイルをロードしてみました。 結果、シークレットキーは0xab54a98ceb1f0ad2という文字列としてデータ領域にあることが判明しました。

理由としては、long型に収まらない数値の場合、文字列として保持するというサブルーチン _PyLong_FromString があるためです。

long型に収まるライセンス期限のUNIX time 1767193200(0x69553a70) は、数値としてデータ領域にあります。

知見: ある程度以上大きい数値をCythonでバイナリにすると、文字列でバイナリに残る

バイナリの解析を邪魔する

これに対して、小手先のような対策ですが、細かく数値を分割した場合4、以下のように複雑になりました。(ここではUNIX timeには対策を施していません)

さらに見られたくないリテラル(例えば数値、文字列)は、バイト列にして、暗号化した状態で記述して、復号するコードにしておくと解析しづらくなります。 その際は、元の値をコメント(消えます)で残しておくと良いでしょう。

これはバイナリの長さに比例するので当たり前かもしれませんが、処理ごとにファイルは分けず、できるだけまとめる(Makefileなどを使っても良い)ことが解析の難しさに直結しそうです。

なお、今回はパッカー(バイナリに圧縮などを施してそのままでは解析できなくする)については触れませんでした。

Cythonで難読化をするときの良さそうな方法

Cythonを難読化に使う場合は、

  • コードの特に守りたい部分をあえて複雑なロジックにすることでバイナリ解析の難しさを上げる
  • 文字列はバイナリにそのまま露出するのでわかりやすい。あらかじめ暗号化をすると難しくなる。
  • できるだけ処理をまとめて1つのバイナリにする
  • Cythonを使用して動的ライブラリとする
  • stripコマンドでシンボルテーブル情報を除去する

とよいことがわかりました。

記事全体のまとめ

  • 業務など重要な用途で難読化をしたい場合は、迷わず商用ソリューションを使用する
  • Cythonによる難読化は、OSSで完結する方法としては最も良い部類になりそう
  • バイナリを解析されてしまうと秘密情報を見つけることはもちろんできるが、対策で邪魔はできる

おしまい。


  1. コンピュータにソフトを組み込んで、コンピュータであることを意識せずに単体の機械として動くものとして提供する形。ネットワークセキュリティ製品などがわかりやすい。最近はRaspberry Piをベースにしたものもある。
  2. Python obfuscation traps - Checkmarx Blog
  3. pyarmor/LICENSE at master · dashingsoft/pyarmor
  4. リテラルで単に細かく分けると cythonize 時に計算結果にされてしまうため、変数へ代入して、実行時に計算させる必要がある。