trustzone
ctf
pwn
ARM
https://github.com/Nautilus-Institute/quals-2022/blob/main/teedium-wallet/teedium_wallet_dist.tar.gz
中身は以下の通り.
ブートローダの構成は以下の通り.
今回のケースではBL31が同梱されていないので,セキュアモニタはない.
BL32は同梱されているので,セキュアワールドが動作していることがわかる.
BL33では,特にハイパーバイザらしきものはなかった.つまりノーマルワールドは,素直にカーネル+ユーザランドの構成である.
Trusted App(以下TA)である7dc089d2-883b-4f7b-8154-ea1db9f1e7c3.ta
は,ELFに特殊なヘッダがくっついたものだ.ヘッダの構造は下記の通り(P41).
https://ispranproceedings.elpub.ru/jour/article/download/1495/1325
但し頑張って切り出す必要はなく,binwalkで取り出すほうが早い.
以降は,_7dc089d2-883b-4f7b-8154-ea1db9f1e7c3.ta.extracted/148
をextracted.elf
として扱っていく.
qemu_run.sh
を起動してみる.起動すると一般ユーザ権限でログインでき,/dev
配下にtee0
/teepriv0
というデバイスファイルが存在する.またtee-supplicant
も動いている.
tee-supplicant
はOP-TEEプロジェクトが開発しているTrustZoneとの通信用アプリ.tee0
やteepriv0
を使って通信する.
以下に配布ファイルのどれがどんな役割を持つのかまとめてみた.
レイヤ | 概要 | どの配布ファイルか | ソース |
---|---|---|---|
Normal-PL0 | TZ通信アプリ | rootfs.cpio.gz 内の tee-supplicant |
optee_client |
Normal-PL1 | TZ通信ドライバ | zImage 内に組み込まれている(関数名:optee_* ) |
linux-kernel |
Secure-PL0 | Trusted App | rootfs.cpio.gz 内の 7dc089d2-883b-4f7b-8154-ea1db9f1e7c3.ta |
(雛形が)optee_os |
Secure-PL1 | Trusted OS | bl32.bin , bl32_extra1.bin , bl32_extra2.bin |
optee_os |
OPTEEは,ARM32とARM64のTrustZoneを扱うためのフレームワーク.Linux kernelにも4.12からメインラインに取り込まれている.
Qemu環境での構築にも対応しており,以下のURLにあるコマンドを叩けば,簡単に環境が構築できる.
https://optee.readthedocs.io/en/latest/building/devices/qemu.html
OPTEE-OSを基にビルドしたqemuイメージは,シリアル出力を2つ持つ.1つはノーマルワールドで,もう1つはセキュアワールド用らしい.
qemu_run.sh
の起動コマンドでは2つ目のシリアル出力が-serial /dev/null
と指定されているが,どこかに情報を吐き出してもらったほうがデバッグしやすくなるのは明白だ.
2つのシリアル出力を両方ともstdio
に出力することはできないので,-serial telnet::4444,server,nowait
などに書き換えよう.ついでに-S -s
も付与しておくと良い.
変更前
変更後
得られる情報
セキュアワールドのデバッグにおいては,以下3つの問題が存在する.
セキュアワールドはノーマルワールドとはまた別のASLRを持っている.
OPTEE-OSの場合,CFG_CORE_ASLR
とCFG_TA_ASLR
というビルドコンフィグが該当する.それぞれTrusted OSのASLRと,TAのASLRである.
CFG_CORE_ASLR
CFG_TA_ASLR
ldelf
のコードやデータの仮想アドレスは固定OPTEE-OSを自分でビルドする場合はこれらをオフにすればよいのだが(make run CFG_CORE_ASLR=n CFG_TA_ASLR=n
),CTFの問題のように環境一式が与えられた場合は,簡単には無効化することができない.何故ならASLRの有効無効はビルド時に完全にコード中に埋め込まれるようで,後から切り替える仕組みが存在しないからだ.
理論的には,ASLRに関するシードを固定値になるよう動的にパッチを当てれば良いが(Write-upの記事ではCFG_CORE_ASLR
つまりOSのASLRのみこの方法で無効化している模様),今回はOSもTAもASLR有効な状態でのデバッグ手法の確立を考えた.
ASLRの実現自体はノーマルワールドと同じで,ランダム要素を含むアドレスをTrusted OSが生成し,それを管理するページテーブルを作成してMMUに登録することで実現している.ただしpagewalk用のページテーブル自体もセキュアメモリに配置されている点が異なる.
尚,MMUのpagewalkベースとなるレジスタは,ARMv7の仕様書上はノーマルワールドでもセキュアワールドでもTTBR0_EL1
やTTBR1_EL1
である.しかしQemuでは少し実装が異なるようで,ノーマルワールドではTTBR0_EL1
やTTBR1_EL1
を使い,セキュアワールドではTTBR0_EL1_S
やTTBR1_EL1_S
という_S
サフィックス付きレジスタを使っているように見受けられる.
このようなレジスタ分離があるのはARMv7だけで,ARMv8では統一されたらしく_S
付きレジスタは見当たらない.このあたり全く情報がない上に,Qemuのソースを見てもよくわからないため,識者の解説を求む.
公式記事では,Trusted OSやTAのデバッグ手法についてはASLRを無効化した上で,シンボルを付与してデバッグするようなことが書いてある.
しかし我々はASLRが有効な状態のターゲットを,シンボル無しにデバッグしたいので,このままでは上手くいかず,少し手順を修正する必要がある.
尚,遷移の全体像を最初に知っておくと後の流れがわかりやすい.
今回はあまり使わないが,Trusted OSのデバッグ手法を書いておく.
Trusted OSに処理が渡った瞬間から追いかけたいなら,tee_entry_std
にブレークポイントを仕掛ければ良い.
tee_entry_std
は以下のようなコードである.
https://github.com/OP-TEE/optee_os/blob/master/core/tee/entry_std.c
今回のケースではtee_entry_std
はbl32_extra1.bin
にある.
(BL32関連のファイルは3つあるが,bl32.bin
もbl32_extra2.bin
もファイルサイズは明らかに小さいので,Trusted OSは含まれない.従ってbl32_extra1.bin
だけを見れば良い)
IDAで探すには,文字列検索からtee_entry_std
を探し,その参照を調べれば一発で見つかる.今回の環境ではオフセット0x1e3d0
だ.
ではBL32のベースアドレスはどこだろうか.これは起動時のログと,実際のファイルサイズを突き合わせれば良い.
BL32(bl32_extra1.bin
)は0xe100000
にロードされているので,tee_entry_std
は0xe11e3d0
にあると計算できる.しかしこのアドレスは調べたところ物理アドレス(もしくは仮想アドレス=物理アドレスとなっている起動初期の段階)なので,ここにブレークポイントを仕掛けても,MMUが有効になりASLRが適用されたタイミング以降では停止しない.
さてASLRのアドレスをどうやって知るかだが,セキュアワールド向けのシリアル出力を有効にしていると,起動時に幾つかの情報が得られると述べた.ここにはASLR適用後のメモリマップ情報も出力されているので,ここからブレークポイントを仕掛けるべきアドレスが分かる.
17行目のsmallpg
と書かれているところがそれで,上のケースでは仮想メモリ0x0a2ee000
が物理メモリ0x0e100000
に対応していることがわかる(ASLRが有効だと毎回変わる).後はオフセットを計算してからブレークポイントを仕掛ければ良い.
これでシンボルがなくてもTrusted OSに処理が渡った瞬間に停止できるようになる.
尚,オフセットさえ求められればtee_entry_std
以外にブレークポイントを仕掛けることももちろん可能である.
TAをデバッグするには,Trusted OSのthread_enter_user_mode
にブレークポイントを仕掛け,TAがどこにロードされたかを調べた上で改めてブレークポイントを仕掛け直すことになる.
bl32_extra1.bin
にある関数のうち,文字列の!have_spinlock()
を参照している点から追跡し,sub_E137C78
がthread_enter_user_mode
であると推測した.オフセットは0x37c78
である.
!have_spinlock
からthread_enter_user_mode
を求める流れつまり,まずthread_enter_user_mode
にブレークポイントを仕掛けるには以下のようにすれば良い.
このときTrusted OS/Appのシリアル出力の結果は以下の通りである.
TAがロードされたアドレスは0x173000
であると表示されているので,あとはこれをベースとして計算すれば良い.例えばTAのTA_InvokeCommandEntryPoint
にブレークポイントを仕掛けるのなら,extracted.elf
を解析すればオフセット0x2784
にあることが分かるので,以下のようにすれば良い.
なお,厳密にはTAのエントリポイントはTA_InvokeCommandEntryPoint
ではないが,ノーマルワールドからの呼び出しがコマンドによって分岐する箇所なので,実質的にTA_InvokeCommandEntryPoint
に仕掛けるのが良いと思われる.
シリアル出力の結果を都度参照するのは,毎回つなぎ直す必要も有り,また手計算も発生するので若干手間である.gdb内部からなんとかしてTrusted OSのマップされるASLRを特定することができれば,ブレークポイントを仕掛ける手間を減らせるはずである.これを達成できたのでメモしておく.
まずqemu-systemが保持する仮想メモリ中のどこかにはセキュアワールド用のメモリが存在するはずである.直接そちらを読み書きすることで,ノーマルワールドにいながらセキュアワールドのメモリの読み書きを擬似的に達成することができる.アドレスの特定にはmonitor info mtree
コマンドとmonitor gpa2hva
コマンドを使えば良い.
あとはホスト(qemu-system)の仮想メモリを/proc/<PID>/mem
経由で読み取れば良い.この手法は自動化できるので,xsm
/wsm
コマンドとして,https://github.com/bata24/gef に追加しておいた.
xsm
によって,ノーマルワールドにいながらセキュアワールドのメモリが読めるようになったことから,セキュアメモリ向けのpagewalk
も実現できるようになり,この結果ノーマルワールドにいてもセキュアワールドのメモリマップが特定できるようになる.つまりシリアル出力の結果を利用しなくても,メモリマップがわかるようになったということだ.
さらにこれによって仮想アドレス/物理アドレスの相互変換(v2p
/p2v
)も実現することができるため,物理アドレスさえ知っていれば,ブレークポイントを仕掛けるべき仮想アドレスが分かるようになる.
ここまでの一連の流れを使って,物理アドレスを使って仮想アドレスにブレークポイントを仕掛ける機能を,bsm
コマンドとして,https://github.com/bata24/gef に追加しておいた.
尚,内部的にp2v
で仮想アドレスを求めてブレークポイントを仕掛けるこの手法は,ARMv7(ARM32)でしか使えないことに注意する.
これが実現できているのは,ARMv7だとqemuがTTBR0_EL1_S
/ TTBR1_EL1_S
といったセキュアワールド用のレジスタをノーマルワールドでも見れるように提供してくれるからである.しかしARMv8(AArch64)ではそれらが提供されないため,ノーマルワールドにいながらセキュアワールドのpagewalk
を行うことができない.よってp2v
も動かないからである.
→少し実行に時間がかかるものの,ARMv8にも対応した.qemu-systemのセキュアワールド向けメモリを走査することで,ARMv8でもpagewalk結果を擬似的に得ることができたためである.
ノーマルワールドにいるとき,セキュアワールド向けのpagewalk
をしてもTrusted OS用のメモリマップしか表示されない.
しかしthread_enter_user_mode
で停止したときにpagewalk
を行うとTA用のメモリも見える.R-X
のついている領域も見えるが,最初に停止したタイミングではTAをロードするためのldelf
が実行されているだけであり,まだTAはロードされていない.
尚ldelf
はTrusted OSが提供する機能で,コード自体はTrusted OS(bl32_extra1.bin
)に埋め込まれている.
thread_enter_user_mode
は何度もヒットするのだが,2回目に停止したときにTAがロードされていた.以下の例ではTAのロードされたアドレスが0x178000
であるが,ASLRが有効だとこのアドレスは毎回変わる.
ということは,thread_enter_user_mode
にブレークポイントを仕掛けて2度目のヒット時にはpagewalk
でTAのアドレスが分かることになる.thread_enter_user_mode
のアドレスさえわかればこれは自動化できるので,optee-break-ta
コマンドとして https://github.com/bata24/gef に追加しておいた.
以下がテスト用プログラムの雛形,ビルド手順である.
#include <err.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <time.h>
#include "optee_client/public/tee_client_api.h"
// ファイル名より取得
#define TA_DEFCON_UUID { 0x7dc089d2, 0x883b, 0x4f7b, { 0x81, 0x54, 0xea, 0x1d, 0xb9, 0xf1, 0xe7, 0xc3} }
/*
enum TEEC_PARAM_TYPE {
TEEC_NONE = 0x00000000,
TEEC_VALUE_INPUT = 0x00000001,
TEEC_VALUE_OUTPUT = 0x00000002,
TEEC_VALUE_INOUT = 0x00000003,
TEEC_MEMREF_TEMP_INPUT = 0x00000005,
TEEC_MEMREF_TEMP_OUTPUT = 0x00000006,
TEEC_MEMREF_TEMP_INOUT = 0x00000007,
TEEC_MEMREF_WHOLE = 0x0000000C,
TEEC_MEMREF_PARTIAL_INPUT = 0x0000000D,
TEEC_MEMREF_PARTIAL_OUTPUT = 0x0000000E,
TEEC_MEMREF_PARTIAL_INOUT = 0x0000000F,
};
* @param p0 The first param type.
* @param p1 The second param type.
* @param p2 The third param type.
* @param p3 The fourth param type.
#define TEEC_PARAM_TYPES(p0, p1, p2, p3) ((p0) | ((p1) << 4) | ((p2) << 8) | ((p3) << 12))
*/
int op_cmd0(TEEC_Session* sess) {
/* TAに渡すパラメータとそのタイプの設定 */
TEEC_Operation op = {};
op.paramTypes = TEEC_PARAM_TYPES(TEEC_VALUE_INOUT, TEEC_MEMREF_WHOLE, TEEC_MEMREF_TEMP_INOUT, TEEC_NONE);
op.params[0].value.a = 0xdead;
op.params[0].value.b = 0xbeef;
op.params[1].memref.parent = NULL;
op.params[1].memref.size = 0x0;
op.params[1].memref.offset = 0x0;
op.params[2].tmpref.buffer = NULL;
op.params[2].tmpref.size = 0x0;
/* コマンド発行 */
uint32_t err_origin;
int cmd_id = 0;
TEEC_Result res = TEEC_InvokeCommand(sess, cmd_id, &op, &err_origin);
if (res != TEEC_SUCCESS)
return -1;
return op.params[0].value.a;
}
int main(void) {
TEEC_Context ctx;
TEEC_Session sess;
TEEC_UUID uuid = TA_DEFCON_UUID;
TEEC_Result res;
uint32_t err_origin;
/* 初期化してセッションを開く */
res = TEEC_InitializeContext(NULL, &ctx);
if (res != TEEC_SUCCESS)
errx(1, "TEEC_InitializeContext failed with code 0x%x", res);
res = TEEC_OpenSession(&ctx, &sess, &uuid, TEEC_LOGIN_PUBLIC, NULL, NULL, &err_origin);
if (res != TEEC_SUCCESS)
errx(1, "TEEC_OpenSession failed with code 0x%x origin 0x%x", res, err_origin);
op_cmd0(&sess);
/* 終了処理としてセッションを閉じる */
TEEC_CloseSession(&sess);
TEEC_FinalizeContext(&ctx);
return 0;
}
rootfs.cpio.gz
を一時的に解凍し,exploitを含めた新しいrootfs_new.cpio.gz
を作る方法.
生成されたrootfs_new.cpio.gz
をqemu_run.sh
と同じディレクトリにrootfs.cpio.gz
という名前で配置し直して起動すれば良い.
#!/bin/bash -eux
# install arm-gcc
which arm-linux-gnueabihf-gcc >/dev/null || apt install -y arm-linux-gnueabihf-gcc
# optee-client build
ls -d /tmp/optee_client >/dev/null 2>&1 || (git clone https://github.com/OP-TEE/optee_client.git /tmp/optee_client && (cd /tmp/optee_client; make))
# cpio extract
ls /tmp/rootfs >/dev/null 2>&1 || mkdir -p /tmp/rootfs
ls /tmp/rootfs/init >/dev/null 2>&1 || (cd /tmp/rootfs; zcat $OLDPWD/rootfs.cpio.gz | cpio -id)
arm-linux-gnueabihf-gcc exploit.c -o /tmp/rootfs/exploit -I/tmp -L/tmp/optee_client/out/libteec -lteec -lpthread -static
(cd /tmp/rootfs; find . -print0 | cpio -o --null --format=newc |gzip -c > $OLDPWD/rootfs_new.cpio.gz)
私の場合は,qemuの起動に必要なファイル一式があるディレクトリにrev
ディレクトリを作成し,その中にrootfs.cpio.gz
のコピーとbuild.sh
, exploit.c
を配置した上で,以下のコマンドを実行することで実現している.
今回の環境では,ゲストにvirtio-9p-device
が存在するため,ホストのディレクトリをマウントすることもできる.
まず最初にrootfs.cpio.gz
内の/init
にパッチを当てて,rootでログインするようにする.
以下のコマンドでrootfs_new.cpio.gz
が生成されるので,それを使うように差し替えておく.
#!/bin/bash -eux
# cpio extract
ls /tmp/rootfs >/dev/null 2>&1 || mkdir -p /tmp/rootfs
ls /tmp/rootfs/init >/dev/null 2>&1 || (cd /tmp/rootfs; zcat $OLDPWD/rootfs.cpio.gz | cpio -id)
sed -i -e 's/setuidgid test/setuidgid root/' /tmp/rootfs/init
(cd /tmp/rootfs; find . -print0 | cpio -o --null --format=newc |gzip -c > $OLDPWD/rootfs_new.cpio.gz)
qemu_run.sh
の起動コマンドに以下を追加する(パスは適宜変更する).
尚,VMwareホストのディレクトリをマウントしたディレクトリは,この方法では共有できない.あくまでQemuホストのネイティブディレクトリを指定すること.
ゲストが起動したら,root権限があるので以下を実行すれば良い.
TEE ClientのAPI仕様が書かれたドキュメントもあるので,目を通しておくと良い.
https://globalplatform.org/wp-content/uploads/2010/07/TEE_Client_API_Specification-V1.0.pdf