替换一个已经在内存中的函数,使得执行流流入我们自己的逻辑,然后再调用原始的函数,这是一个很古老的话题了。比如有个函数叫做funcion,而你希望统计一下调用function的次数,最直接的方法就是 如果有谁调用function的时候,调到下面这个就好了 :
<code>void
new_function() { count++;return
function
(); }/<code>
网上很多文章给出了实现这个思路的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函数保存为要调用的函数指针。
是不是有点意思呢?下面是一个步骤示意图:
下面是一个代码,我稍后会针对这个代码,说几个细节方面的东西:
<code>char
saved_op[OPTSIZE] = {0
};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);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 conntrack\n"
);return
0
; }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 conntrack\n"
);return
ptr_orig_conntrack_in(ops, skb,in
,out
, state); }static
void
*(*ptr_poke_smp)(void
*addr,const
void
*opcode, size_t len);static
__initint
hook_conn_init(void
) { s32 hook_offset, orig_offset; ptr_poke_smp = kallsyms_lookup_name("text_poke_smp"
);if
(!ptr_poke_smp) { printk("err"
);return
-1
; } ptr_ipv4_conntrack_in = kallsyms_lookup_name("ipv4_conntrack_in"
);if
(!ptr_ipv4_conntrack_in) { printk("err"
);return
-1
; } jump_op[0
] =0xe9
; hook_offset = (s32)((long
)hook_ipv4_conntrack_in - (long
)ptr_ipv4_conntrack_in - OPTSIZE); (*(s32*)(&jump_op[1
])) = hook_offset; 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
__exitvoid
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"
);/<code>
测试是OK的。
在上面的代码中,saved_op中为什么没有old inst呢?直接就是一个jmp y,这岂不是将原始函数中的头几个字节的指令给遗漏了吗?
其实说到这里,还真有个不好玩的Trick,起初我真的就是老老实实保存了前5个自己的指令,然后当需要调用原始ipv4_conntrack_in时,就先执行那5个保存的指令,也是OK的。随后我objdump这个函数发现了下面的代码:
<code>0000000000000380
:
380:
e8
00
00
00
00
callq
385
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
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
/<code>
注意前5个指令: e8 00 00 00 00 callq 385
可以看到,这个是可以忽略的。因为不管怎么说都是紧接着执行下面的指令。所以说,我就省去了inst的保存。
如果按照我的图示中常规的方法的话,代码稍微改一下即可:
<code>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; .../<code>
但是以上的只是玩具。
有个非常现实的问题。在我保存原始函数的头n条指令的时候,n到底是多少呢?在本例中,显然n是5,符合如今Linux内核函数第一条指令几乎都是callq xxx的惯例。
然而,如果一个函数的第一条指令是下面的样子:
<code>op
d1 d2 d3 d4 d5/<code>
即一个操作码需要5个操作数,我要是只保存5个字节,最后在stub中的指令将会是下面的样子:
<code>op
d1 d2 d3 d4 0xe9off
1off
2off
3off
4/<code>
这显然是错误的,op操作码会将jmp指令0xe9解释成操作数。
解药呢?当然有咯。
我们不能鲁莽地备份固定长度的指令,而是应该这样做:
<code>curr
=0
if
orig[0] 为单字节操作码
=orig[curr];
curr++;
else
if orig[0] 携带1个1字节操作数
orig, 2);
curr
+= 2;
else
if orig[0] 携带2字节操作数
orig, 3);
curr
+= 3;
...
=0xe9; // jmp
offset
=...
=offset;
/<code>
这是正确的做法。
需要C/C++ Linux服务器架构师学习资料私信“资料”(资料包括C/C++,Linux,golang技术,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK,ffmpeg等),免费分享