线下赛patch实战

使用call指令进行hook

程序在用call指令调用存在漏洞的函数时,我们需要对这条call指令进行hook,例如off-by-one漏洞,可以对其进行hook,对参数进行修改之后再继续调用原来的函数

目标

这里以ctfwiki上的一道b00ks为例,题目中读取数据的函数会多读入2个字节的数据,程序在调用该函数时都将size减去了1,所以这里仍然会多读入一个字节,导致off-by-null

所以,这里的目标就是在调用该函数之前,把它的第二个参数再次减去1,这样就不存在off-by-null漏洞了,调用该函数的地方有好几个,这里仅对其中的一个进行hook,就选取edit功能中调用该函数的地方进行hook

这里选择这个地方进行patch只是为了试验该patch方法,线下具体该选择哪里需要视情况而定

开始hook

hook的call指令地址为0xF2B

要将第二个参数减一,而第二个参数是存放在rsi寄存器中的,所以只需要将rsi的值减去一,接着直接调用函数read_ndata即可,而我们在hook.c中写hook函数时是不知道增加了segment之后函数read_ndata的地址是多少的,所以这里先采取用5个nop指令来占位置的方法为call指令占位

1
2
3
4
5
6
7
8
9
10
11
int myread(char *ptr,int num){
asm(
"sub $0x1,%rsi\n"
"nop\n"
"nop\n"
"nop\n"
"nop\n"
"nop\n"
);
return 0;
}

将上述的hook.c进行编译,得到hook文件:

gcc -nostdlib -nodefaultlibs -fPIC -Wl,-shared hook.c -o hook

接着使用如下脚本将0xF2B处的call指令进行hook

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import lief
from pwn import *

def patch_call(file,srcaddr,dstaddr,arch = "amd64"):
print hex(dstaddr)
length = p32((dstaddr - (srcaddr + 5 )) & 0xffffffff)
order = '\xe8'+length
print disasm(order,arch=arch)
file.patch_address(srcaddr,[ord(i) for i in order])

binary = lief.parse("./b00ks")
hook = lief.parse('./hook')
print hook.get_section('.text').content
# inject hook program to binary
segment_added = binary.add(hook.segments[0])
hook_fun = hook.get_symbol("myread")

# hook b00k's call
dstaddr = segment_added.virtual_address + hook_fun.value
srcaddr = 0xf2b
patch_call(binary,srcaddr,dstaddr)

binary.write('b00ks-patched')

patch之后,把得到的b00ks-patched文件拖到IDA中进行分析,发现并没有将edit功能中的call指令指向我们的hook函数,而且该call指令的地址也由原来的0xf2b变成了0x1f2b

是怎么回事呢?对比一下两个程序的段信息,发现patch之后的程序第一个段的大小增加了0x1000,导致后面的地址都增加了0x1000

所以在增加了段之后我们需要修改的call指令地址已经不再是0xf2b,而变成了0x1f2b,所以对脚本稍作修改,把脚本中的srcaddr = 0xf2b改成srcaddr = 0x1f2b,再次查看patch的结果:

可以看到该出的函数调用确实指向了我们的hook函数sub_4042d8

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
LOAD:00000000004042D8 sub_4042D8      proc near               ; CODE XREF: sub_1E17+114↑p
LOAD:00000000004042D8
LOAD:00000000004042D8 var_C = dword ptr -0Ch
LOAD:00000000004042D8 var_8 = qword ptr -8
LOAD:00000000004042D8
LOAD:00000000004042D8 push rbp
LOAD:00000000004042D9 mov rbp, rsp
LOAD:00000000004042DC mov [rbp+var_8], rdi
LOAD:00000000004042E0 mov [rbp+var_C], esi
LOAD:00000000004042E3 sub rsi, 1
LOAD:00000000004042E7 nop
LOAD:00000000004042E8 nop
LOAD:00000000004042E9 nop
LOAD:00000000004042EA nop
LOAD:00000000004042EB nop
LOAD:00000000004042EC mov eax, 0
LOAD:00000000004042F1 pop rbp
LOAD:00000000004042F2 retn
LOAD:00000000004042F2 sub_4042D8 endp

继续完善

经过上面的操作,我们已经能够将call劫持到我们的hook函数来执行了,还差的就是把hook函数中占位的nop指令修改成call read_ndata函数,所以接下来将对其进行修改

观察上面patch后的结果,可以知道nop指令的起始地址为0x4042E7,我们要调用的函数read_ndata地址则变成了0x19f5

所以直接如下设置patch_call的参数就能实现最终的patch:

1
2
dstaddr = 0x19f5
srcaddr = 0x4042e7

完整的脚本如下:

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

def patch_call(file,srcaddr,dstaddr,arch = "amd64"):
print hex(dstaddr)
length = p32((dstaddr - (srcaddr + 5 )) & 0xffffffff)
order = '\xe8'+length
print disasm(order,arch=arch)
file.patch_address(srcaddr,[ord(i) for i in order])

binary = lief.parse("./b00ks")
hook = lief.parse('./hook')
print hook.get_section('.text').content
# inject hook program to binary
segment_added = binary.add(hook.segments[0])
hook_fun = hook.get_symbol("myread")

# hook b00k's call
dstaddr = segment_added.virtual_address + hook_fun.value
srcaddr = 0x1f2b
patch_call(binary,srcaddr,dstaddr)

dstaddr = 0x19f5
srcaddr = 0x4042e7
patch_call(binary,srcaddr,dstaddr)

binary.write('b00ks-patched')

patch的效果如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
LOAD:00000000004042D8 ; Attributes: bp-based frame
LOAD:00000000004042D8
LOAD:00000000004042D8 sub_4042D8 proc near ; CODE XREF: sub_1E17+114↑p
LOAD:00000000004042D8
LOAD:00000000004042D8 var_C = dword ptr -0Ch
LOAD:00000000004042D8 var_8 = qword ptr -8
LOAD:00000000004042D8
LOAD:00000000004042D8 push rbp
LOAD:00000000004042D9 mov rbp, rsp
LOAD:00000000004042DC mov [rbp+var_8], rdi
LOAD:00000000004042E0 mov [rbp+var_C], esi
LOAD:00000000004042E3 sub rsi, 1
LOAD:00000000004042E7 call sub_19F5
LOAD:00000000004042EC mov eax, 0
LOAD:00000000004042F1 pop rbp
LOAD:00000000004042F2 retn
LOAD:00000000004042F2 sub_4042D8 endp
1
2
3
4
5
__int64 __fastcall sub_4042D8(__int64 a1, __int64 a2)
{
sub_19F5(a1, a2 - 1);
return 0LL;
}

通过.eh_frame段实现patch

前面介绍的方法是通过在程序中增加一个段的方式来实现patch的,经过这种方法patch后虽然正常的执行都没有问题,但是程序的第一个段的大小增加了0x1000,这导致了程序中各个函数的地址也都增加了0x1000,对程序的改动较大,这里可以通过往.eh_frame段写入hook代码,然后跳转到这里执行的方式

过程和前面介绍的差不多,这里直接贴patch成功的脚本了

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

def patch_call(file,srcaddr,dstaddr,arch = "amd64"):
print hex(dstaddr)
length = p32((dstaddr - (srcaddr + 5 )) & 0xffffffff)
order = '\xe8'+length
print disasm(order,arch=arch)
file.patch_address(srcaddr,[ord(i) for i in order])

binary = lief.parse("./b00ks")
hook = lief.parse('./hook')

# write hook's .text content to binary's .eh_frame content
sec_ehrame = binary.get_section('.eh_frame')
# print sec_ehrame.content
sec_text = hook.get_section('.text')
sec_ehrame.content = sec_text.content


# hook target call
dstaddr = sec_ehrame.virtual_address
srcaddr = binary.get_section('.text').virtual_address+(0xf2b-0x8e0)
print 'srcaddr:'+hex(srcaddr)
print 'dstaddr:'+hex(dstaddr)

patch_call(binary,srcaddr,dstaddr)

# modify nop to call
dstaddr = binary.get_section('.text').virtual_address+(0x9f5-0x8e0)
srcaddr = sec_ehrame.virtual_address+0xf
patch_call(binary,srcaddr,dstaddr)

binary.write('b00ks-patched-frame')

patch的效果如下:

可以看到这种方式对程序的影响确实很小,在hook的代码很少的情况下,可以首选这种方法

使用jmp指令进行hook

使用call指令一般都是在程序原有的call指令处进行修改,让其call我们的hook函数,这样其实是不太方便的,例如我们需要对程序中的某一处开始的地方进行修改,增加一部分的逻辑,使用call指令可能需要把原来的函数功能重新实现一遍。

使用jmp指令则不会有这种问题,在函数中间找一个合适的指令,把他修改成jmp指令,使其跳转到我们的hook代码处,执行完之后再jmp回来即可

还是以b00ks为例,如果修改call指令,那么可能需要在所有调用该函数的地方进行修改,这里直接在read_ndata函数内部进行操作,在该函数内部将其第二个参数的值减去1,这样就可以不用在多处进行patch了

查了一下资料jmp指令的短跳转为EB xxxxx,近跳转为E9 xxxxxx,远跳转为EA xxxxxx,这里首先尝试近跳转

通过.eh_frame段实现patch

近跳转需要的指令长度为5个字节,所以在原来的函数中找一个长度为5字节的指令,将其修改为jmp指令即可,jmp近跳转指令偏移的计算方式与call指令偏移的计算方式是一致的。

使用objdump -d b00ks > objout可以将程序中所有的汇编代码输出到文件中,这样在编写hook函数的汇编代码时可以作为我们的重要参考,主要包括如何取得栈中的值,进行各种寄存器操作的汇编指令如何编写等,下面的示例是我们要hook的read_ndata函数的汇编代码:

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
9f5:	55                   	push   %rbp
9f6: 48 89 e5 mov %rsp,%rbp
9f9: 48 83 ec 20 sub $0x20,%rsp
9fd: 48 89 7d e8 mov %rdi,-0x18(%rbp)
a01: 89 75 e4 mov %esi,-0x1c(%rbp)
a04: 83 7d e4 00 cmpl $0x0,-0x1c(%rbp)
a08: 7f 07 jg a11 <__cxa_finalize@plt+0x141>
a0a: b8 00 00 00 00 mov $0x0,%eax
a0f: eb 64 jmp a75 <__cxa_finalize@plt+0x1a5>
a11: 48 8b 45 e8 mov -0x18(%rbp),%rax
a15: 48 89 45 f8 mov %rax,-0x8(%rbp)
a19: c7 45 f4 00 00 00 00 movl $0x0,-0xc(%rbp)
a20: 48 8b 45 f8 mov -0x8(%rbp),%rax
a24: ba 01 00 00 00 mov $0x1,%edx
a29: 48 89 c6 mov %rax,%rsi
a2c: bf 00 00 00 00 mov $0x0,%edi
a31: b8 00 00 00 00 mov $0x0,%eax
a36: e8 35 fe ff ff callq 870 <read@plt>
a3b: 83 f8 01 cmp $0x1,%eax
a3e: 74 07 je a47 <__cxa_finalize@plt+0x177>
a40: b8 01 00 00 00 mov $0x1,%eax
a45: eb 2e jmp a75 <__cxa_finalize@plt+0x1a5>
a47: 48 8b 45 f8 mov -0x8(%rbp),%rax
a4b: 0f b6 00 movzbl (%rax),%eax
a4e: 3c 0a cmp $0xa,%al
a50: 75 02 jne a54 <__cxa_finalize@plt+0x184>
a52: eb 15 jmp a69 <__cxa_finalize@plt+0x199>
a54: 48 83 45 f8 01 addq $0x1,-0x8(%rbp)
a59: 8b 45 f4 mov -0xc(%rbp),%eax
a5c: 3b 45 e4 cmp -0x1c(%rbp),%eax
a5f: 75 02 jne a63 <__cxa_finalize@plt+0x193>
a61: eb 06 jmp a69 <__cxa_finalize@plt+0x199>
a63: 83 45 f4 01 addl $0x1,-0xc(%rbp)
a67: eb b7 jmp a20 <__cxa_finalize@plt+0x150>
a69: 48 8b 45 f8 mov -0x8(%rbp),%rax
a6d: c6 00 00 movb $0x0,(%rax)
a70: b8 00 00 00 00 mov $0x0,%eax
a75: c9 leaveq
a76: c3 retq

观察上面的汇编代码,可以发现好几个长度为5字节的指令例如mov $0x1,%edx,这里选取0xa24处的指令进行修改,patch_jmp函数只需要在patch_call函数的基础上稍作修改即可

1
2
3
4
5
6
7
# 近跳转的patch函数
def patch_jmp(file,srcaddr,dstaddr,arch="amd64"):
print hex(dstaddr)
length = p32((dstaddr - (srcaddr + 5 )) & 0xffffffff)
order = '\xe9'+length
print disasm(order,arch=arch)
file.patch_address(srcaddr,[ord(i) for i in order])

使用上面的函数对地址0xa0a处的指令进行hook,让其jmp.eh_frame中的hook代码中执行,hook函数的代码如下:

1
2
3
4
5
6
7
8
9
asm(
"sub $0x1,-0x1c(%rbp)\n"
"mov $0x1,%edx\n"
"nop\n"
"nop\n"
"nop\n"
"nop\n"
"nop\n"
);

hook函数中nop指令的地址是多少呢?该地址就是我们第二次要patch的地址,获取该地址可以用下列方式

使用下列命令获取nop指令之前的指令占用了多少个字节:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ objdump -d hook-jmp

hook-jmp: file format elf64-x86-64


Disassembly of section .text:

0000000000000279 <.text>:
279: 83 6d e4 01 subl $0x1,-0x1c(%rbp)
27d: ba 01 00 00 00 mov $0x1,%edx
282: 90 nop
283: 90 nop
284: 90 nop
285: 90 nop
286: 90 nop

可以看到一共有9个字节,所以该地址可以通过下列代码获取:

srcaddr = sec_ehrame.virtual_address+9

完整的patch代码如下:

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

def patch_jmp(file,srcaddr,dstaddr,arch="amd64"):
print hex(dstaddr)
length = p32((dstaddr - (srcaddr + 5 )) & 0xffffffff)
order = '\xe9'+length
print disasm(order,arch=arch)
file.patch_address(srcaddr,[ord(i) for i in order])

binary = lief.parse("./b00ks")
hook = lief.parse('./hook-jmp')

# write hook's .text content to binary's .eh_frame content
sec_ehrame = binary.get_section('.eh_frame')
# print sec_ehrame.content
sec_text = hook.get_section('.text')
sec_ehrame.content = sec_text.content

# hook read_ndata
dstaddr = sec_ehrame.virtual_address
srcaddr = binary.get_section('.text').virtual_address+(0xa24-0x8e0)
patch_jmp(binary,srcaddr,dstaddr)

# jmp back to read_ndata
dstaddr = binary.get_section('.text').virtual_address+(0xa29-0x8e0)
# 这里加上了nop指令的9字节偏移
srcaddr = sec_ehrame.virtual_address+9
patch_jmp(binary,srcaddr,dstaddr)

binary.write('b00ks-patched-jmp')

patch的效果如下:

1
2
3
4
5
6
7
8
9
10
.text:0000000000000A20 loc_A20:                                ; CODE XREF: sub_9F5+72↓j
.text:0000000000000A20 mov rax, [rbp+buf]
; 在该指令处确实跳转到了hook代码处
.text:0000000000000A24 jmp loc_1700
.text:0000000000000A29 ; ---------------------------------------------------------------------------
.text:0000000000000A29
.text:0000000000000A29 loc_A29: ; CODE XREF: sub_9F5+D14↓j
.text:0000000000000A29 mov rsi, rax ; buf
.text:0000000000000A2C mov edi, 0 ; fd
.text:0000000000000A31 mov eax, 0

hook代码处如下:

1
2
3
4
5
6
7
.eh_frame:0000000000001700 loc_1700:                               ; CODE XREF: sub_9F5+2F↑j
.eh_frame:0000000000001700 sub [rbp+var_1C], 1
.eh_frame:0000000000001704 mov edx, 1 ; nbytes
; 这里跳转到了原来指令的下一条指令处
.eh_frame:0000000000001709 jmp loc_A29
.eh_frame:0000000000001709 ; END OF FUNCTION CHUNK FOR sub_9F5
.eh_frame:0000000000001709 _eh_frame ends

通过增加新段实现patch

前面讲了通过将hook代码写入.eh_frame段来实现patch,这种方式下近跳转jmp即可满足要求。这里介绍一下通过增加新段来实现patch

需要注意的是jmp指令的后四字节表示的是jmp地址相对于该地址的偏移,4字节大小的偏移基本上都够用了,所以这里都是在近跳转的基础上来试验的

流程和之前增加新段进行patch的流程差不多,稍微有点区别的就是这里寻址的方式不是通过hook.get_symbol("myread")来实现,而是直接以新段的起始地址作为跳转的目标地址,同时将增加的新段的content设置成hook程序的.text节的content

完整代码如下:

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

def patch_jmp(file,srcaddr,dstaddr,arch="amd64"):
print hex(dstaddr)
length = p32((dstaddr - (srcaddr + 5 )) & 0xffffffff)
order = '\xe9'+length
print disasm(order,arch=arch)
file.patch_address(srcaddr,[ord(i) for i in order])

binary = lief.parse("./b00ks")
hook = lief.parse('./hook-jmp')
# 增加的新段
segment_added = binary.add(hook.segments[0])
# hook的代码
sec_text = hook.get_section('.text')
# 将增加的新段的内容设成hook代码
segment_added.content = sec_text.content

# hook read_ndata
dstaddr = segment_added.virtual_address
srcaddr = binary.get_section('.text').virtual_address+(0xa24-0x8e0)
patch_jmp(binary,srcaddr,dstaddr)

# jmp back to read_ndata
dstaddr = binary.get_section('.text').virtual_address+(0xa29-0x8e0)
srcaddr = segment_added.virtual_address+9
patch_jmp(binary,srcaddr,dstaddr)

binary.write('b00ks-patched-jmp')

patch的效果如图:

经过上面的.eh_frame/增加新段patch之后,程序的流程确实变成了jmp到hook函数-->jmp原来指令的下一条指令处,但是由于选取的patch地方不对(选到了for循环的内部),导致patch后的程序和原程序在功能上产生了较大的偏差,在实际情况下选择patch地方的时候需要仔细考虑一下。由于这里的主要目的是实现通过jmp来patch的流程,所以这里也就不再深究这个问题了!可自行选择一个合适的地方进行patch。

32位程序的patch

32位程序下的patch和64位是一样的吗?随便拿一道32位的程序,使用objdump -d pwn1 > pwn1.txt来获取程序的反汇编代码

call指令

1
2
3
4
5
6
7
8
9
10
11
12
0804840c <.init>:
804840c: 53 push %ebx
804840d: 83 ec 08 sub $0x8,%esp
8048410: e8 1b 01 00 00 call 8048530 <memset@plt+0x40>
8048415: 81 c3 77 1d 00 00 add $0x1d77,%ebx
804841b: 8b 83 fc ff ff ff mov -0x4(%ebx),%eax
8048421: 85 c0 test %eax,%eax
8048423: 74 05 je 804842a <setbuf@plt-0x16>
8048425: e8 86 00 00 00 call 80484b0 <__gmon_start__@plt>
804842a: 83 c4 08 add $0x8,%esp
804842d: 5b pop %ebx
804842e: c3 ret

可以看到call指令的格式和64位下的call指令是一样的,同样占5个字节e8 1b 01 00 00,所以对call进行patch的函数是一样的

jmp指令

1
2
3
4
08048460 <read@plt>:
8048460: ff 25 a0 a1 04 08 jmp *0x804a1a0
8048466: 68 10 00 00 00 push $0x10
804846b: e9 c0 ff ff ff jmp 8048430 <setbuf@plt-0x10>

可以看到近跳转指令jmp的指令格式和64位下也是一样的e9表示近跳转指令,后面的四字节数据c0 ff ff ff表示相对于当前指令的偏移

综上所述,32位程序的patch在jmp和call这两种指令下没有任何区别,直接按照64位程序的patch方式即可。