memcpy 引出的 chunk size 计算与内存对齐

在堆分配中,最为常见的 malloc(x) 是如何按照指定的内存对齐方式进行内存分配?而实际分配的空间大小与 x 又有什么样的关系?这是本次要讲的重点内容。同时,如何实现纳秒级的 CPU 周期计算?这就涉及到特定 CPU 平台的相关指令了。整个过程会给出一个使用 SSE 指令集加快 memcpy 拷贝速度的实际案例,结合实例,能够得到更好的理解。

0x10 前置基础

0x11 MMX/XMM 寄存器

MMX

为了加速多媒体程序的执行,Intel 在微处理器中引入了 MMX 指令集,MMX 也用于 FPU 浮点寄存器。MMX 使用 SIMD(Single Instruction Multiple Data,单指令多数据) 流水线技术,即可通过一条指令执行多个数据运算,MMX 一共有 8 个 64 位寄存器,mm0 - mm7。与其他普通 64 位寄存器的区别在于,通过 MMX 指令运算,可以同时计算 2 个 32 位数据,或者 4 个 16 位数据,或者 8 个 8 位数据。可以作为图像处理过程中颜色的计算。

XMM

Pentium III 增强了这种 SIMD 能力,引入SSE(Streaming SIMD Extensions,单指令多数据流扩展)扩展。于是出现了 XMM 寄存器,与 MMX 一样,只是 XMM 拥有 8 个 128 位寄存器,分别为 xmm0 - xmm7。

0x12 SSE 指令集

movdqa,转移指令,类似于普通汇编中的 mov 指令。movdaq 全称是 move double quadword aligned,移动双个 4 字(16 字节),128 位按位对齐的数据转移指令。

movntps,要求 CPU 在写入数据的时候,不要将数据写到 cache,而是直接将数据写到主记忆体中。因为很多数据并不是立即要用到的,放在 cache 会浪费空间。因此 movntps 属于 cache 控制指令

0x13 RDTSC 指令

rdtsc 指令,得到 CPU 的时间戳,这个值在每个指令周期中,都是不断增加的,根据 CPU 的频率,计算出时间,可以实现纳秒级空间,可以用来测量程序的运行速度。

typedef unsigned long long cycles_t;
cycles_t rdtsc()
{
    
    
        asm("rdtsc");
}
inline cycles_t rdtsc()
{
    
    
    cycles_t result;
    __asm__ __volatile__ ("rdtsc" : "=A" (result));
        
    return result;
}

以上两个函数都可以用来计算 CPU 的时间戳,结合 sleep 函数,可以实现 CPU 频率的计算

cycles_t t1 = rdtsc()
sleep(1)
cycles_t t2 = rdtsc()
printf("cpu MHz : %lld\n", (t2-t1)/1000000);

0x14 chunk 内存大小计算

关于字节对齐:如果一个变量的地址,刚好等于这个变量长度的整数倍,我们称之为自然对齐。 在 CTF Wiki 中,笔者并没有看到关于堆字节对齐的相关说明,经过实际测试发现,32 位系统中,堆栈是 8 字节对齐的,而 64 位系统是 16 字节对齐的

什么是 chunk?由 malloc 函数分配的内存空间,我们称之为 chunk,chunk 典型的结构如下所示
在这里插入图片描述
前两个字段为 chunk header,分别是上个 chunk 和当前 chunk 的大小。chunk 的大小必须是 2 * SIZE_SZ,SIZE_SZ 是处理器一次性可以处理的数据长度,32 位系统为 4 字节,64 位系统为 8 字节。也就是说,32 位系统中,chunk size 必然是 8 的整数倍,那么 chunk size 末尾三位必然是 0,派不上用场,正因如此,末尾三位用来表示 A、M、P

  • NON_MAIN_ARENA,记录当前 chunk 是否不属于主线程,1表示不属于,0表示属于
  • IS_MAPPED,记录当前 chunk 是否是由 mmap 分配的
  • PREV_INUSE,记录前一个 chunk 块是否被分配

当 P = 1 时,chunk header 第一个字段 size of previos chunk 不生效(只有物理相邻的上一个 chunk 没有被使用,当前 chunk 的第一个字段才能表示上个 chunk 的大小),这时候,size of previos chunk 字段可以用来表示上一个 chunk 的内容,这就是 chunk 的空间复用

例如,如果我们在代码中,写入 malloc(8),那么实际分配的 chunk 为多大呢?malloc 的参数,指的是 user data 段的大小,因此在 32 位系统中

size of chunk = 8 + 8 = 16

16 符合 8 字节对齐,也满足 2 * SIZE_SZ 整数倍。假设 malloc(9),分配的 chunk 又是多大呢?你可能理解为 8 + 9 = 17,17 不满足 8 字节对齐,因此,应当分配 24,但是别忘了 chunk 的空间复用!

8 + 9 < 16(size of chunk) + 4

这里,16 代表分配的 chunk 的大小,4 表示下一个 chunk 的第一个字段,可以在这里作为 user data。对于不同的系统,不同的对齐方式,malloc 都会有不同的表现形式。有什么样的的方法,可以确定 malloc(x) 分配的 chunk 的大小呢? 经过总结,对于 32 位系统

8 + x <= 8n (size of chunk) + 4

64 位系统
16 + x <= 16n (size of chunk) + 8

chunk 实例分析

还是举个实际的例子,这里以 malloc(9) 为例,我们的虚拟机是 64 位,按照上面的分析,16 + 9 <= 16n + 8,计算得出 n = 2,那么 size of chunk = 32

为了验证我们的结论,实际调试一下

// gcc test.c -o test
include<stdlib.h>

int main(int argc, char * argv[])
{
    
    
    void * ptr = malloc(9);
    return 0;
}

反汇编

objdump -d test -M intel

确定 malloc 分配的堆 chunk 地址
在这里插入图片描述
调试
在这里插入图片描述
得到 chunk 的计算:0x7fffffffe1d0 - 0x8 = 0x7fffffffe1c8,栈0x7fffffffe1c8, 存放的值是 0x5555555592a0
在这里插入图片描述
size of chunk 字段 = 0x21 = 33,末尾 1 是 PREV_INUSE,不影响 chunk size,所以 size = 32,与我们刚刚推算的结果一样,说明推算 malloc(x) 公式准确无误。

0x20 memcpy 案例分析

0x21 问题描述

Are you tired of hacking?, take some rest here.
Just help me out with my small experiment regarding memcpy performance.
after that, flag is yours.
http://pwnable.kr/bin/memcpy.c
ssh [email protected] -p2222 (pw:guest)

查看程序源码,整个源码也不多,大体上就是让用户输入几个 size,程序会执行 malloc(size),即分配一块堆内存,分别执行下面两个内存拷贝函数,计算时间差。

char* slow_memcpy(char* dest, const char* src, size_t len){
    
    
	int i;
	for (i=0; i<len; i++) {
    
    
		dest[i] = src[i];
	}
	return dest;
}

char* fast_memcpy(char* dest, const char* src, size_t len){
    
    
	size_t i;
	// 64-byte block fast copy
	if(len >= 64){
    
    
		i = len / 64;
		len &= (64-1);
		while(i-- > 0){
    
    
			__asm__ __volatile__ (
			"movdqa (%0), %%xmm0\n"
			"movdqa 16(%0), %%xmm1\n"
			"movdqa 32(%0), %%xmm2\n"
			"movdqa 48(%0), %%xmm3\n"
			"movntps %%xmm0, (%1)\n"
			"movntps %%xmm1, 16(%1)\n"
			"movntps %%xmm2, 32(%1)\n"
			"movntps %%xmm3, 48(%1)\n"
			::"r"(src),"r"(dest):"memory");
			dest += 64;
			src += 64;
		}
	}

	// byte-to-byte slow copy
	if(len) slow_memcpy(dest, src, len);
	return dest;
}

slow_memcpy 是传统的 memcpy,逐个字节复制;fast_memcpy 是使用 intel CPU 的 XMM 寄存器,无 cache 复制,大大压缩了内存复制的时间。其实整个程序逻辑上没有太大问题,如果你在 64 位程序上编译并运行此程序,并不会出错。

0x22 出错分析

但是 pwnable 平台上,目标程序应该是在 32 位系统上运行的,所以,会出现以下问题,当用户输入的 size 为 128 时,出现了错误,程序终止运行。
在这里插入图片描述
原因在于,xmm 寄存器是 16 字节对齐的,一次性操作 128 位的数据;我们在前面已经分析过,SSE 指令集的很多 mov 操作都是 16 字节对齐。而 malloc 中分配的 chunk,在 32 位系统中,是 8 字节对齐的,这就造成了对齐方式不一致

解决方案也比较简单,既然我们没办法决定 fast_memcpy,那么就要使得分配的 chunk 是 16 字节对齐,就是用户每次输入的 size,要保证堆的地址是 16 的整数倍。

0x23 编译和调试

本地主机是 64 位系统,编译成 32 位程序

gcc -o memcpy memcpy.c -m32 -lm

调试程序
在这里插入图片描述
malloc(16) 如果按照 8 字节对齐,chunk size = 25(24 + 1) = 0x19,而调试的程序中,为 0x21,经过推算,是 16 字节对齐,满足要求。因此,本地 64 位系统运行该程序不会报错。

现在只能用 32 位系统来编译该程序,进行相应的调试即可。不过,这里不用调试,我们也能进行相应的推算

address		content
pwndbg> x/100wx 0x804c400
0x804c400:	0x00000000	0x00000000	0x00000000	0x00000011 //8
0x804c410:	0x00000000	0x00000000	0x00000000	0x00000019 //16
0x804c420:	0x00000000	0x00000000	0x00000000	0x00000000
0x804c430:	0x00000000	0x00000029	0x00000000	0x00000000 //32
0x804c440:	0x00000000	0x00000000	0x00000000	0x00000000
0x804c450:	0x00000000	0x00000000	0x00000000	0x00000049 //64
0x804c460:	0x00000000	0x00000000	0x00000000	0x00000000
0x804c470:	0x00000000	0x00000000	0x00000000	0x00000000
0x804c480:	0x00000000	0x00000000	0x00000000	0x00000000
0x804c490:	0x00000000	0x00000000	0x00000000	0x00000000
0x804c4a0:	0x00000000	0x00000089	0x00000000	0x00000000 //128
0x804c4b0:	0x00000000	0x00000000	0x00000000	0x00000000
0x804c4c0:	0x00000000	0x00000000	0x00000000	0x00000000
...

问题的解决在于,让 32 位系统分配的 chunk 都是 16 字节对齐的。根据我们在 0x14 节提出的公式,进一步简化

原本分配:4 + x <= 8n (size of chunk)
实际改为:4 + x <= 16n (size of chunk)

通过观察堆的结构,我们发现,size of chunk 必须是 16 的整数倍,才能满足 16 字节对齐的要求,这样问题就简单许多了,编写的代码如下

for e in range(4, 14):
	low = pow(2, e - 1);
	high = pow(2, e);
	for x in range(low, high):
		if (4 + x) % 8 != 0:
			n = (4 + x) / 8 + 1
		else:
			n = (4 + x) / 8
		if 8 * n % 16 == 0:
			print "%d ~ %d: %d" % (low, high, x)
			break

输出的结果为

8 ~ 16: 8
16 ~ 32: 21
32 ~ 64: 37
64 ~ 128: 69
128 ~ 256: 133
256 ~ 512: 261
512 ~ 1024: 517
1024 ~ 2048: 1029
2048 ~ 4096: 2053
4096 ~ 8192: 4101

按照这个在实验平台[ssh [email protected] -p2222 (pw:guest)]上输入 size 即可。

....
experiment 8 : memcpy with buffer size 1029
ellapsed CPU cycles for slow_memcpy : 7092
ellapsed CPU cycles for fast_memcpy : 662

experiment 9 : memcpy with buffer size 2053
ellapsed CPU cycles for slow_memcpy : 13958
ellapsed CPU cycles for fast_memcpy : 856

experiment 10 : memcpy with buffer size 4101
ellapsed CPU cycles for slow_memcpy : 30556
ellapsed CPU cycles for fast_memcpy : 59108

thanks for helping my experiment!
flag : 1_w4nn4_br34K_th3_m3m0ry_4lignm3nt

0x30 总结

本次由 memcpy 案例引出的一些列知识点值得关注,SSE 指令集的对齐方式与 32 位 chunk 大小有所差异导致了程序无法正常运行。因此,在这里,我们需要明确内存对齐的一些特点,以及 malloc 实际分配的空间计算,这样才能计算出符合 memcpy 要求的内存对齐。

猜你喜欢

转载自blog.csdn.net/song_lee/article/details/106431931
今日推荐