使用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
11int 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
23import 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 | LOAD:00000000004042D8 sub_4042D8 proc near ; CODE XREF: sub_1E17+114↑p |
继续完善
经过上面的操作,我们已经能够将call
劫持到我们的hook函数来执行了,还差的就是把hook函数中占位的nop指令
修改成call read_ndata
函数,所以接下来将对其进行修改
观察上面patch后的结果,可以知道nop
指令的起始地址为0x4042E7
,我们要调用的函数read_ndata
地址则变成了0x19f5
所以直接如下设置patch_call
的参数就能实现最终的patch:
1 | dstaddr = 0x19f5 |
完整的脚本如下: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
27import 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 | LOAD:00000000004042D8 ; Attributes: bp-based frame |
1 | __int64 __fastcall sub_4042D8(__int64 a1, __int64 a2) |
通过.eh_frame段实现patch
前面介绍的方法是通过在程序中增加一个段的方式来实现patch的,经过这种方法patch后虽然正常的执行都没有问题,但是程序的第一个段的大小增加了0x1000,这导致了程序中各个函数的地址也都增加了0x1000,对程序的改动较大,这里可以通过往.eh_frame
段写入hook代码,然后跳转到这里执行的方式
过程和前面介绍的差不多,这里直接贴patch成功的脚本了
1 | import lief |
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 | 9f5: 55 push %rbp |
观察上面的汇编代码,可以发现好几个长度为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
9asm(
"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
31import 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
30import 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 | 0804840c <.init>: |
可以看到call指令的格式和64位下的call指令是一样的,同样占5个字节e8 1b 01 00 00
,所以对call进行patch的函数是一样的
jmp指令
1 | 08048460 <read@plt>: |
可以看到近跳转指令jmp的指令格式和64位下也是一样的e9
表示近跳转指令,后面的四字节数据c0 ff ff ff
表示相对于当前指令的偏移
综上所述,32位程序的patch在jmp和call这两种指令下没有任何区别,直接按照64位程序的patch方式即可。