共计 5276 个字符,预计需要花费 14 分钟才能阅读完成。
替换一个曾经在内存中的函数,使得执行流流入咱们本人的逻辑,而后再调用原始的函数,这是一个很古老的话题了。比方有个函数叫做 funcion,而你心愿统计一下调用 function 的次数,最间接的办法就是 如果有谁调用 function 的时候,调到上面这个就好了:
void new_function()
{
count++;
return function();}
网上很多文章给出了实现这个思路的 Trick,而且始终以来计算机病毒也都采纳了这种移花接木的手腕来实现本人的目标。然而,当你亲自去测试时,发现事件并不那么简略。
网上给出的许多办法均不再实用了,起因是在晚期,这样做的人比拟少,处理器和操作系统大可不必理睬一些不合乎惯例的做法,然而随着这类 Trick 开始做好事影响到失常的业务逻辑时,处理器厂商以及操作系统厂商或者社区便不得不在底层减少一些限制性机制,以避免这类 Trick 持续起作用。
常见的措施有两点:
- 可执行代码段不可写
这个措施便封堵住了你想通过简略 memcpy 的形式替换函数指令的计划。
- 内存 buffer 不可执行
这个措施便封堵住了你想把执行流 jmp 到你的一个保留指令的 buffer 的计划。
- stack 不可执行
别看这些措施都比拟 low,一看谁都懂,它们却防止了大量的缓冲区溢出带来的危害。
那么如果咱们想用替换函数的 Trick 做失常的事件,怎么办?
我来简略谈一下我的办法。首先我不会去 HOOK 用户态的过程的函数,因为这样意义不大,改一下重启服务会好很多。所以说,本文特指 HOOK 内核函数的做法。毕竟内核从新编译,重启设施代价十分大。
咱们晓得,咱们目前所应用的简直所有计算机都是冯诺伊曼式的对立存储式计算机,即指令和数据是存在一起的,这就意味着咱们必然能够在操作系统层面随便解释内存空间的含意。
咱们在做正当的事件,所以我假如咱们曾经拿到了零碎的 root 权限并且能够编译和插入内核模块。那么接下来的事件仿佛就是一个流程了。
是的,批改页表项即可,即使无奈简略地通过 memcpy 来替换函数指令,咱们还是能够用以下的步骤来进行指令替换:
- 从新将函数地址对应的物理内存映射成可写;
- 用本人的 jmp 指令替换函数指令;
- 解除可写映射。
十分侥幸,内核曾经有了现成的 text_poke/text_poke_smp 函数来实现下面的事件。
同样的,针对一个堆上或者栈上调配的 buffer 不可执行,咱们仍然有方法。方法如下:
- 编写一个 stub 函数,实现随便,其代码指令和 buffer 相当;
- 用下面重映射函数地址为可写的办法用 buffer 重写 stub 函数;
- 将 stub 函数保留为要调用的函数指针。
是不是有点意思呢?上面是一个步骤示意图:
上面是一个代码,我稍后会针对这个代码,说几个细节方面的货色:
#include <linux/kernel.h>
#include <linux/kprobes.h>
#include <linux/cpu.h>
#include <linux/module.h>
#include <net/tcp.h>
#define OPTSIZE 5
// saved_op 保留跳转到原始函数的指令
char saved_op[OPTSIZE] = {0};
// jump_op 保留跳转到 hook 函数的指令
char jump_op[OPTSIZE] = {0};
static unsigned int (*ptr_orig_conntrack_in)(const struct nf_hook_ops *ops, struct sk_buff *skb, const struct net_device *in, const struct net_device *out, const struct nf_hook_state *state);
static unsigned int (*ptr_ipv4_conntrack_in)(const struct nf_hook_ops *ops, struct sk_buff *skb, const struct net_device *in, const struct net_device *out, const struct nf_hook_state *state);
// stub 函数,最终将会被保留指令的 buffer 笼罩掉
static unsigned int stub_ipv4_conntrack_in(const struct nf_hook_ops *ops, struct sk_buff *skb, const struct net_device *in, const struct net_device *out, const struct nf_hook_state *state)
{printk("hook stub conntrackn");
return 0;
}
// 这是咱们的 hook 函数,当内核在调用 ipv4_conntrack_in 的时候,将会达到这个函数。static unsigned int hook_ipv4_conntrack_in(const struct nf_hook_ops *ops, struct sk_buff *skb, const struct net_device *in, const struct net_device *out, const struct nf_hook_state *state)
{printk("hook conntrackn");
// 仅仅打印一行信息后,调用原始函数。return ptr_orig_conntrack_in(ops, skb, in, out, state);
}
static void *(*ptr_poke_smp)(void *addr, const void *opcode, size_t len);
static __init int hook_conn_init(void)
{
s32 hook_offset, orig_offset;
// 这个 poke 函数实现的就是重映射,写 text 段的事
ptr_poke_smp = kallsyms_lookup_name("text_poke_smp");
if (!ptr_poke_smp) {printk("err");
return -1;
}
// 嗯,咱们就是要 hook 住 ipv4_conntrack_in,所以要先找到它!ptr_ipv4_conntrack_in = kallsyms_lookup_name("ipv4_conntrack_in");
if (!ptr_ipv4_conntrack_in) {printk("err");
return -1;
}
// 第一个字节当然是 jump
jump_op[0] = 0xe9;
// 计算指标 hook 函数到以后地位的绝对偏移
hook_offset = (s32)((long)hook_ipv4_conntrack_in - (long)ptr_ipv4_conntrack_in - OPTSIZE);
// 前面 4 个字节为一个绝对偏移
(*(s32*)(&jump_op[1])) = hook_offset;
// 事实上,咱们并没有保留原始 ipv4_conntrack_in 函数的头几条指令,// 而是间接 jmp 到了 5 条指令后的指令,对应上图,应该是指令 buffer 里没
// 有 old inst,间接就是 jmp y 了,为什么呢?前面细说。saved_op[0] = 0xe9;
// 计算指标原始函数将要执行的地位到以后地位的偏移
orig_offset = (s32)((long)ptr_ipv4_conntrack_in + OPTSIZE - ((long)stub_ipv4_conntrack_in + OPTSIZE));
(*(s32*)(&saved_op[1])) = orig_offset;
get_online_cpus();
// 替换操作!ptr_poke_smp(stub_ipv4_conntrack_in, saved_op, OPTSIZE);
ptr_orig_conntrack_in = stub_ipv4_conntrack_in;
barrier();
ptr_poke_smp(ptr_ipv4_conntrack_in, jump_op, OPTSIZE);
put_online_cpus();
return 0;
}
module_init(hook_conn_init);
static __exit void hook_conn_exit(void)
{get_online_cpus();
ptr_poke_smp(ptr_ipv4_conntrack_in, saved_op, OPTSIZE);
ptr_poke_smp(stub_ipv4_conntrack_in, stub_op, OPTSIZE);
barrier();
put_online_cpus();}
module_exit(hook_conn_exit);
MODULE_DESCRIPTION("hook test");
MODULE_LICENSE("GPL");
MODULE_VERSION("1.1");
测试是 OK 的。
须要 C /C++ Linux 服务器架构师学习材料加群 812855908(材料包含 C /C++,Linux,golang 技术,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK,ffmpeg 等),收费分享
在下面的代码中,saved_op 中为什么没有 old inst 呢?间接就是一个 jmp y,这岂不是将原始函数中的头几个字节的指令给脱漏了吗?
其实说到这里,还真有个不好玩的 Trick,起初我真的就是老老实实保留了前 5 个本人的指令,而后当须要调用原始 ipv4_conntrack_in 时,就先执行那 5 个保留的指令,也是 OK 的。随后我 objdump 这个函数发现了上面的代码:
0000000000000380 <ipv4_conntrack_in>:
380: e8 00 00 00 00 callq 385 <ipv4_conntrack_in+0x5>
385: 55 push %rbp
386: 49 8b 40 18 mov 0x18(%r8),%rax
38a: 48 89 f1 mov %rsi,%rcx
38d: 8b 57 2c mov 0x2c(%rdi),%edx
390: be 02 00 00 00 mov $0x2,%esi
395: 48 89 e5 mov %rsp,%rbp
398: 48 8b b8 e8 03 00 00 mov 0x3e8(%rax),%rdi
39f: e8 00 00 00 00 callq 3a4 <ipv4_conntrack_in+0x24>
3a4: 5d pop %rbp
3a5: c3 retq
3a6: 66 2e 0f 1f 84 00 00 nopw %cs:0x0(%rax,%rax,1)
3ad: 00 00 00
留神前 5 个指令:e8 00 00 00 00 callq 385 <ipv4_conntrack_in+0x5>
能够看到,这个是能够疏忽的。因为不管怎么说都是紧接着执行上面的指令。所以说,我就省去了 inst 的保留。
如果依照我的图示中惯例的办法的话,代码略微改一下即可:
char saved_op[OPTSIZE+OPTSIZE] = {0};
...
// 减少一个指令拷贝的操作
memcpy(saved_op, (unsigned char *)ptr_ipv4_conntrack_in, OPTSIZE);
saved_op[OPTSIZE] = 0xe9;
orig_offset = (s32)((long)ptr_ipv4_conntrack_in + OPTSIZE - ((long)stub_ipv4_conntrack_in + OPTSIZE + OPTSIZE));
(*(s32*)(&saved_op[OPTSIZE+1])) = orig_offset;
...
然而以上的只是玩具。
有个十分事实的问题。在我保留原始函数的头 n 条指令的时候,n 到底是多少呢?在本例中,显然 n 是 5,合乎现在 Linux 内核函数第一条指令简直都是 callq xxx 的常规。
然而,如果一个函数的第一条指令是上面的样子:
op d1 d2 d3 d4 d5
即一个操作码须要 5 个操作数,我要是只保留 5 个字节,最初在 stub 中的指令将会是上面的样子:
op d1 d2 d3 d4 0xe9 off1 off2 off3 off4
这显然是谬误的,op 操作码会将 jmp 指令 0xe9 解释成操作数。
解药呢?当然有咯。
咱们不能鲁莽地备份固定长度的指令,而是应该这样做:
curr = 0
if orig[0] 为单字节操作码
saved_op[curr] = orig[curr];
curr++;
else if orig[0] 携带 1 个 1 字节操作数
memcpy(saved_op, orig, 2);
curr += 2;
else if orig[0] 携带 2 字节操作数
memcpy(saved_op, orig, 3);
curr += 3;
...
saved_op[curr] = 0xe9; // jmp
offset = ...
(*(s32*)(&saved_op[curr+1])) = offset;
这是正确的做法。