漏洞修复概述
在具体分析一个漏洞之前,我先将漏洞简单的分一下类,根据漏洞修复的难度,可以把漏洞分为以下四类
- 后门函数、危险的字符串(/bin/sh)、输入函数长度溢出(硬编码)等
- 格式化字符串等
- 指针悬挂、堆栈溢出(动态长度)等
- 逻辑漏洞
这几类漏洞在 CTF、AWD 比赛中很常见,也是二进制漏洞利用的主要考察点,按照不同的漏洞又可以总结出几种修复方式
- 暴力 nop、修改硬编码数据
- 替换 GOT 表条目、符号解析信息
- 第三方工具替换系统函数、添加代码
- 手动添加代码
实际上,无论是何种修复手段,无非是对程序的代码进行添加、删除或者修改,虽然没有源代码,但是开发者们制作出了很多实用工具,灵活实用这些工具,就算没有源代码也可以实现对 binary 的 patch。
Patch 的核心思想:在不破坏程序原有功能的情况下,加入或者删除部分代码,修复程序的漏洞。
删除代码容易实现,但是插入代码难度就比较高了,具体我们在下面讨论。
工具简介
在 patch 二进制文件时几个常用工具:
- IDA
- keypatch
- LIEF
IDA 无需多言,keypatch 是 IDA 的一个插件,项目地址:https://github.com/keystone-engine/keypatch ,虽然 IDA 自带了一个 patch 工具,但是 keypatch 的功能要远远强于它。
LIEF 是一个支持多个平台的二进制工具,通过 LIEF 可以实现对 binary 的 patch、hook、以及导入、导出函数等操作, 灵活使用能够达到意想不到的效果。
LIEF 有详细的官方教程以及 API 文档,大家可以自行了解用法。
patch 实战
本文会以几个二进制程序为例,演示如何 patch 漏洞的同时不干扰程序正常功能。
程序的每条指令都有一定的长度,指令与指令之间没有多余字节,当 patch 代码时,可能会遇到添加或删除代码的情况,删除代码比较容易实现,直接使用 nop 指令替代原始代码即可,但是当需要添加、修改代码的时候,经常会遇到字节数不够用的情况,为了保证程序正常运行,我们又不能修改掉正常代码,这时就需要寻找一个合适的空间来保存 shellcode,并且这块空间需要具有可执行权限。
大部分 ELF 程序都有一个 .eh_frame 段,功能描述如下
1 | When gcc generates code that handles exceptions, it produces tables that describe how to unwind the stack. These tables are found in the .eh_frame section. |
简单来讲,这个段的是编译器自己添加进去的,当代码中包含异常处理操作时就会生成,它的主要作用时描述如何卸载 stack。
一般情况下,程序正常运行的时候是不会触发异常处理代码的,于是这个段就可以作为保存 patch 代码的空间。
一个 binary 的漏洞通常出现在某个函数调用前后,例如指针悬挂漏洞是由于 free 一个 chunk 没有清空它的指针导致的,再如格式化字符串漏洞是参数问题导致的。patch 漏洞的时候一个核心思路是保持程序正常功能的同时,加入检测、修复代码,而最适于实现这个操作的位置就是 call 指令。第一,call 指令长度为 5 个字节,空间充裕,第二,通过 call 跳转的功能可以劫持程序的控制流到我们的 patch 代码上,完成修复之后再通过强制跳转指令回到正确的控制流,对程序的修改很小,相对于增加一个段或者是 libc 的方法来说更加稳定。
Patch 1:
很明显的格式化字符串漏洞,造成漏洞的主要原因是 printf 函数参数用户可控。
patch 格式化字符串类的漏洞比较简单,通常直接使用 keypatch 修改 call printf 前后代码,满足 puts(buf) 即可。
patch前:
1 | .text:0000000000000DEF call read |
patch后
1 | .text:0000000000000DEF call read |
反编译结果:
ps:某些情况下程序中可能没有 puts 函数,此时需要对 printf 函数进行一定程度的修改,在真正输出字符串之前先过滤,去掉非法字符串(例如 %p 等),如何 patch 参考下面的内容。
Patch2:
1 | unsigned __int64 delete() |
这是一个 UAF 漏洞,堆块指针保存在全局数组中,但是 free 的时候没有清除指针,导致 UAF。
patch 这种漏洞就不能直接在原来的代码基础上修改了,因为直接添加代码会导致指令被覆盖,破坏程序的正常功能。
这里用到上面提到的技巧,通过修改 call 指令劫持程序的控制流到 .eh_frame 段即添加的 fix 代码处,对 chunk_list 执行清空操作,然后正常调用 free 函数完成程序功能,最后通过强制跳转指令回到正常的控制流。
patch前:
1 | .text:0000000000000D79 lea rax, chunk_list |
Patch后(在 .eh_frame 段添加了代码):
1 | .eh_frame:00000000000011F9 ; START OF FUNCTION CHUNK FOR delete |
patch 后反编译
1 | unsigned __int64 delete() |
Patch3:
1 | puts("Input your Plaintext to be encrypted"); |
gets 栈溢出,如法炮制,修改 call gets 指令劫持控制流到 .eh_frame 段。但是这里有一个问题,此程序中只有 gets 函数能够接受用户输入,难道就无法 patch 了吗?
其实不然,因为还有 syscall 可以使用,通过 syscall 构造 read 函数,就能控制输入数据的长度,完成修复。
patch 前:
1 | .text:00000000004009D1 lea rax, [rbp+s] |
patch 后:
1 | .eh_frame:0000000000400F7D ; START OF FUNCTION CHUNK FOR encrypt |
反编译代码:
1 | puts("Input your Plaintext to be encrypted"); |
总结
Patch binary 的时候要注意不能使文件体积变动过大,否则很容易被判宕机,另外,针对不同的漏洞有不同的修复手段,但是总体上来看就是添加、删除代码的过程。patch 过程中注意不要破坏原有的功能(特别是堆栈、寄存器等运行环境),防止 check 不过,当遇到比较复杂的漏洞或者文件空间不足等情况,应该考虑使用如 LIEF 等工具直接替换整个函数。
- 本文作者: Catalpa
- 本文链接: https://wzt.ac.cn/2019/06/16/binary_patch/
-
版权声明:
本作品采用知识共享署名-相同方式共享 4.0 国际许可协议进行许可。