好友
阅读权限25
听众
最后登录1970-1-1
|
fnv1c
发表于 2021-3-28 13:30
本帖最后由 fnv1c 于 2021-3-29 14:23 编辑
0x01 概述
inline hook是一项改变函数调用控制流的技术。与传统的PLT(或IAT)表劫持,库打桩等技术不同,inline hook会修改函数本身(通常是头部的数个字节)来进行函数的hook。inline hook较PLT劫持更复杂,但是不会受到PLT劫持技术的诸多限制。例如PLT劫持/库打桩只能控制ELF导入的外部动态库函数,对于库内函数调用和本身程序内部函数调用无法hook。而inline hook几乎能适应所有情况。
本文通过运行时库打桩引入,主要介绍Linux X64平台下inline hook的原理和实现,windows平台的相关实现其实异曲同工。
0x02 前排说明
这应该是高考前发的最后一篇文章了,主要为了给高考攒点人品
因为精力有限,时间仓促,文章着重讲述实现原理和边界细节,给出的代码片段主要是为了概念演示而不能直接运行。如果文章内容有疏漏,还请您多多指教。
0x03 情境导入
X同学硕招简历填了一个自己的github项目,项目主要是通过hook来给某闭源的软件扩展功能和修bug。硕招面试时,导师对他的项目非常感兴趣,问他hook的具体实现。X同学懵了,他用的就是一个现成的库啊,应该关注的重点难道不是如何靠逆向来修复软件bug吗。
一阵沉默...X同学只想着:hook是什么?能吃吗?好吃吗?回家后,X同学打开了电脑,搜索了自己用的库的介绍,发现它用了一个叫做inline hook的神奇玩意。用某搜索软件搜一下,发现第一页全部都是复读机(一个"转载"一个),而且都是描述x86下实现的远古文章了。于是X同学决定自(qiu)己(zhu)动(wang)手(you)研究。
0x04 大道至简——从库打桩开始
(限于篇幅原因,这里不介绍库打桩的背景知识了,因为库打桩和inline hook也没有关系。)X同学十分庆幸自己曾经读过CSAPP,里面有一章就是介绍库打桩技术的,看起来也和hook相关。他马上着手尝试,搞出来一个测试环境。
目标就是劫持rand函数,让他输出自己的幸运数字
根据书上的指示,他又写了一个假的rand函数,编译到a.so里面
最后通过LD_PRELOAD=./a.so ./a.out 成功劫持了a.out里面对rand()的调用。
"这就是hook吗?爱了爱了" X同学以为自己已经掌握了核心技术。然而当他尝试用同样的方法改写a.out里面的其他函数时却失败了。
经过查(zi)阅(xun)资(da)料(lao),他得到了一个非常通俗的原理描述。最开始的a.out是动态链接的产物,有的需要用的函数其实都没有编译进去。只有程序装载时(RTLD_NOW/RELRO)或者真正用到这个函数时(lazy binding),才会由动态链接器去寻找这个函数并且提供给a.out
这就是为什么rand可以偷梁换柱,而main不能了。因为rand是一个外部动态链接库提供的,可以通过哄骗动态链接器加载自己的实现。而main函数是被编译到a.out里面的,不涉及动态链接,所以无法用这种方法hook。
0x05 狸猫换太子——实现inline hook
为了避免第二天面试的尴尬,X同学只能继续硬杠这个问题。他忽然想到,为什么不能直接修改被调用的函数呢?翻了翻指令手册,他发现,其实把函数头部改成jmp ->到自己的函数就可以了。他直接手写汇编,把函数头部覆盖成
jmp *0(%rip) 跳转到64位地址.... (指令ff 25 00 00 00 00)
.dq 0x7ffffff123456 跳转地址
memcpy(&victim,"\xff\x25\x00\x00\x00\x00......",14)一共花了14个字节,就完成了任务。当他兴奋地直接覆盖时,喜提段错误。
!上图为32位系统的示意图,和64位系统不同,仅供类比理解
原来他把一个只能读和执行的text段修改了,所以会段错误。
可以通过mprotect来修改指定内存页的保护属性,先允许写,写完之后再禁止。
很轻松,成功了。然而最关键的问题是,hook的精髓不是直接暴力修改函数,而是修改后还能调用原函数。他都把原函数前14字节覆盖了,还怎么调用?
聪明的他想到了:直接把前14个字节内包含的指令复制出来,然后在最后面跳回原函数,不就做成了一个original (下文称为shadow function)
任务完成,全文完。
0x06 知识回敲——为什么我的inline hook挂掉了
如果文章只在0x05就完了,那么和网上的绝大多数介绍就基本没啥区别了。"如果hook这么简单,为什么还有那么多人用现成的库呢?"X同学嘀咕。
实际上,很多文章/很多实现很粗暴的库都先做了一个假定,x86函数的开头都是由
mov %edi,%edi
push %ebp
mov %esp,%ebp
构成的,恰好足够放一个32位相对地址jmp。
而在64位平台下一般是
push %rbp
mov %rbp,%rsp
一共四个字节,还需要再改一个函数指令才能放得下一个32位相对地址jmp。
在这种情况下,直接把开头的指令复制到shadow function依然能执行。因为开头的指令是rip地址无关,且不会引用函数体。
感性地理解,开头的指令无论放到哪里,放到哪个程序,他都执行了同一件事情。
但是实际情况真的这么好吗?众所周知,push rbp,rbp=rsp其实是带有frame pointer的函数的经典入口操作,gcc只需要开到O2及以上就会打开-fomit-frame-pointer,函数开头的这几个指令就被删除了。
没有了这4字节的固定指令,意味着我们必须得处理函数的前5字节指令来放置自己的jmp。如果你觉得问题依然和上面一样简单的话你就大错特错了。
以此函数为例说明
即使还存在push rbp,rbp=rsp的固定结构,第三条指令却是一个rip地址相关指令(rip相对寻址),无法继续复制到shadow function执行
下面的描述可能有点难懂,建议搭配文末的图片食用。
首先需要考虑rip相关指令,
例如 lea 0x4(%rip),%rax就是一个rip相关指令。因为他的操作数的计算用到了rip寄存器的值。也就是说,这个指令在0x100000和0x700000处执行的结果是不同的。如果还按照上面的方法暴力复制出来,很显然要挂掉。
0x10000 lea 0x4(%rip),%rax === rax=0x4+当前指令之后的rip =0x10000+4+指令长度
0x70000 lea 0x4(%rip),%rax === rax=0x4+当前指令之后的rip =0x70000+4+指令长度
如果你愿意翻手册,会发现能把rip当作寻址基址寄存器的指令大把大把....
但没有关系,因为大多数指令的寻址偏移量都是32位的,留给了我们足够的空间去修复
比如把上面0x10000的指令移动到0x70000的时候,就可以改成
0x70000 lea (0x4+0x10000-0x70000)(%rip),%rax === rax=0x4+当前指令之后的rip-(0x70000-0x10000) =0x10000+4+指令长度
针对这种偏移量的修改,需要用到一些反汇编库,这里推荐ZyDis,这个库可以直接给出操作数对应偏移量在指令里面的具体位置,修改很方便。
再考虑一下rip相关指令的特例
如果开头是一个跳转指令,情况会变得复杂,因为很多函数内跳转指令都是short jmp。也就是说jmp的相对偏移量是一个8位数字。比如
0x1000 jmp <test+9>
.....
0x1009 ret
这里的jmp指令就没办法用上面的办法patch了。因为偏移空间只有8位,是没办法从0x70000一下子跳回去的。一部分hook库遇到了这种情况选择直接拒绝hook。
下面给出一种处理这种指令的方法。尽管这样的short jmp指令无法修改偏移,但我们可以通过jmpbed来破解限制。
现在直接从shadow function开始修改
0x700000 jmp +9 //假设这里对应0x1009,忽略指令长度
..... //其他shadow function指令
0x700009 jmp test+n //跳回原函数执行
第一行的jmp +9显然需要修改,他原本要跳到0x1009。我们考虑给shadow function后面追加一个far jmp,来跳到0x1009
0x70000e jmp test+9 //跳回原地址 (这里是jmpbed)
再修改jmp +9的偏移,让他跳到+e处的jmpbed,就完成了short jmp的patch
0x07 以子之矛,陷子之盾——inline hook的防范
上面已经讨论了inline hook能遇到的绝大多数情况,然而inline hook不是万能的,仍然可以进行反hook。一种直接想到的办法就是检测函数的开头数个字节,如果被修改那么就被hook了,但是这种方法容易被patch(因为有明显的特征),再者每次调用都要检测,非常麻烦。
下面介绍一种比较巧妙的方法,来导致hook后无法调用original从而反hook这牵扯到inline hook的第二种边界情况——回跳(第一种即上文描述的rip相关指令)。
我们已经明白了,inlinehook的原理就是覆盖目标函数的前几个字节。但是我们无法保证前几个字节没有被函数内部引用。
比如下面的例子
LOC_ENTRY:
xor %rax,%rax; #48 31 c0,用来初始化循环计数,并且使第二条指令处在jmp HOOKED指令的中间
LOC_ANTIHOOK:
add $1,%rax; #回跳,若被hook此处会被覆盖从而引发段错误
LOC_CONFUSE_CF: jmp LOC_ANTIHOOK_LP; #控制流混淆,骗过hook库对于回跳的分析,认为此处函数结束
ret;
LOC_ANTIHOOK_LP: cmp $2,%rax;
jne LOC_ANTIHOOK; #回跳
LOC_REAL: #真实函数体
mov $1,%rax;
ret;
由于函数体引用了LOC_ANTIHOOK处的指令,而我们的inline hook又恰好覆盖了这里,所以调用original会导致指令错误,程序终止。
这里是hook前后的对比
后:(可以看到ANTIHOOK被覆盖了)
尽管有的hook库做了回跳检测,但是由于是纯裸的反汇编而不是控制流分析,所以通过上面的LOC_CONFUSE_CF就能导致hook库误认为函数结束而忽略下面的回跳
测试一下,会发现original调用后段错误。成功反hook。
反hook实验的源代码见附件,需要linux平台。在funchook 1.1.0下测试通过。
0x08 简略实现&FAQ
为了便于读者理解本文,我写了一个简单的实现(当然也涵盖了文中所描述的全部状况)。实现稍长,已放至附件。
Q:为什么需要辅助长跳。
A:linux平台下主文件和动态库文件装入地址差异在2GB以上,所以不能直接用32位相对跳实现hook。可以用32位indirect jmp来跳到64位任意地址(需要6字节),也可以先32位相对jmp(5字节)跳到一个64位indirect jmp。一般来说覆盖字节越少hook遇到复杂情况的概率越小。本实现采用后者5字节的实现
(如果你极端追求性能的话,也可以用一个indirect jmp+64位地址,一共14字节)
Q:inline hook好像不仅仅这一种
A:是的。比如还有分析整个文件,对目标函数做xref然后修改跳到目标函数的指令的。但是这个方法一方面过于依赖静态分析,一方面无法处理indirect call。本文介绍的方法是最流行的且成功率最高的。
Q:大多数情况下并没有遇到文中写到的RIP相关指令的特殊情况
A:一些windows的dll为了方便hook,会在函数头放一个空指令便于替换(不了解windows机制,个人推测)。比如上文的mov %edi,%edi,就可以替换成一个short jmp而不影响原函数逻辑。
但是inline hook现在更多的被用在动态调试辅助和二进制热修改上,实际遇到的很多函数都不会有那么优越的条件供hook使用。比如libc的一些函数开头就是lea,必须要进行32位相对patch。
0x09 结语
如王安石在《游褒禅山记》中所言:"而世之奇伟、瑰怪,非常之观,常在于险远,而人之所罕至焉,故非有志者不能至也。"
做学问更要懂得深入的探究,知其然,也要知其所以然。在此分享一些技术上的探究过程,愿与诸君共勉。 |
-
-
inline_hook.7z
303.01 KB, 阅读权限: 10, 下载次数: 118, 下载积分: 吾爱币 -1 CB
linux下测试
免费评分
-
查看全部评分
|
发帖前要善用【论坛搜索】功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。 |
|
|
|
|