TL博士:我正在编写一个内核模块,即使您的 SSH 崩溃,它也会从 ICMP 负载中读取命令并在服务器上执行它们。 对于最不耐烦的人来说,所有代码都是 .
注意! 经验丰富的 C 程序员可能会热泪盈眶! 我什至可能在术语上是错误的,但欢迎任何批评。 这篇文章是针对那些对 C 编程有非常粗略了解并想要深入了解 Linux 内部的人的。
在我的第一条评论中 提到 SoftEther VPN,它可以模仿一些“常规”协议,特别是 HTTPS、ICMP 甚至 DNS。 我可以想象只有第一个可以工作,因为我非常熟悉 HTTP(S),而且我必须学习 ICMP 和 DNS 上的隧道。

是的,在 2020 年,我了解到可以在 ICMP 数据包中插入任意有效负载。 但迟到总比不到好! 既然可以对此采取行动,那么就需要采取行动。 由于在日常生活中我最常使用命令行,包括通过 SSH,因此我首先想到了 ICMP shell 的想法。 为了组装一个完整的 bullshield bingo,我决定用一种我只是粗略了解的语言将其编写为 Linux 模块。 这样的 shell 在进程列表中将不可见,您可以将其加载到内核中,并且它不会在文件系统上,您不会在侦听端口列表中看到任何可疑的内容。 就其功能而言,这是一个成熟的rootkit,但我希望对其进行改进,并将其用作平均负载过高而无法通过SSH登录并至少执行时的最后手段 echo i > /proc/sysrq-trigger无需重新启动即可恢复访问。
我们需要一个文本编辑器、Python 和 C 的基本编程技能、Google 和 如果一切都坏了,你不介意把它放在刀下(可选 - 本地 VirtualBox/KVM/等),然后开始吧!
客户端
在我看来,对于客户端部分,我必须编写一个大约 80 行的脚本,但是有好心的人为我做了这件事 。 代码出乎意料地简单,只有 10 行重要的代码:
import sys
from scapy.all import sr1, IP, ICMP
if len(sys.argv) < 3:
print('Usage: {} IP "command"'.format(sys.argv[0]))
exit(0)
p = sr1(IP(dst=sys.argv[1])/ICMP()/"run:{}".format(sys.argv[2]))
if p:
p.show() 该脚本采用两个参数:一个地址和一个有效负载。 发送之前,有效负载前面有一个密钥 run:,我们将需要它来排除具有随机负载的包。
内核需要权限来制作包,因此脚本必须以超级用户身份运行。 不要忘记授予执行权限并安装 scapy 本身。 Debian 有一个名为 python3-scapy。 现在您可以检查这一切是如何运作的。
运行并输出命令
morq@laptop:~/icmpshell$ sudo ./send.py 45.11.26.232 "Hello, world!"
Begin emission:
.Finished sending 1 packets.
*
Received 2 packets, got 1 answers, remaining 0 packets
###[ IP ]###
version = 4
ihl = 5
tos = 0x0
len = 45
id = 17218
flags =
frag = 0
ttl = 58
proto = icmp
chksum = 0x3403
src = 45.11.26.232
dst = 192.168.0.240
options
###[ ICMP ]###
type = echo-reply
code = 0
chksum = 0xde03
id = 0x0
seq = 0x0
###[ Raw ]###
load = 'run:Hello, world!
这就是嗅探器中的样子
morq@laptop:~/icmpshell$ sudo tshark -i wlp1s0 -O icmp -f "icmp and host 45.11.26.232"
Running as user "root" and group "root". This could be dangerous.
Capturing on 'wlp1s0'
Frame 1: 59 bytes on wire (472 bits), 59 bytes captured (472 bits) on interface wlp1s0, id 0
Internet Protocol Version 4, Src: 192.168.0.240, Dst: 45.11.26.232
Internet Control Message Protocol
Type: 8 (Echo (ping) request)
Code: 0
Checksum: 0xd603 [correct] [Checksum Status: Good] Identifier (BE): 0 (0x0000)
Identifier (LE): 0 (0x0000)
Sequence number (BE): 0 (0x0000)
Sequence number (LE): 0 (0x0000)
Data (17 bytes)
0000 72 75 6e 3a 48 65 6c 6c 6f 2c 20 77 6f 72 6c 64 运行:Hello, world
0010 21!
Data: 72756e3a48656c6c6f2c20776f726c6421
[长度:17]
帧 2:线路上有 59 个字节(472 位),在接口 wlp59s472、id 1 上捕获了 0 个字节(0 位)
Internet 协议版本 4,源:45.11.26.232,目标:192.168.0.240
Internet控制消息协议
类型:0(回显(ping)回复)
代号:0
校验和:0xde03 [正确] [校验和状态:良好] 标识符(BE):0(0x0000)
标识符(LE):0(0x0000)
序列号(BE):0(0x0000)
序列号(LE):0(0x0000)
[请求帧:1] [响应时间:19.094 ms] 数据(17个字节)
0000 72 75 6e 3a 48 65 6c 6c 6f 2c 20 77 6f 72 6c 64 运行:Hello, world
0010 21!
Data: 72756e3a48656c6c6f2c20776f726c6421
[长度:17]
捕获的^C2数据包
响应包中的负载不会改变。
内核模块
要构建 Debian 虚拟机,您至少需要 make и linux-headers-amd64,其余的将以依赖关系的形式出现。 我不会在文章中提供完整的代码;您可以在 Github 上克隆它。
挂钩设置
首先,我们需要两个函数来加载和卸载模块。 卸载的功能不是必须的,但是接下来 rmmod 它不会工作;该模块只有在关闭时才会卸载。
#include <linux/module.h>
#include <linux/netfilter_ipv4.h>
static struct nf_hook_ops nfho;
static int __init startup(void)
{
nfho.hook = icmp_cmd_executor;
nfho.hooknum = NF_INET_PRE_ROUTING;
nfho.pf = PF_INET;
nfho.priority = NF_IP_PRI_FIRST;
nf_register_net_hook(&init_net, &nfho);
return 0;
}
static void __exit cleanup(void)
{
nf_unregister_net_hook(&init_net, &nfho);
}
MODULE_LICENSE("GPL");
module_init(startup);
module_exit(cleanup);这里发生了什么:
- 引入两个头文件来操作模块本身和网络过滤器。
- 所有的操作都会经过一个netfilter,你可以在里面设置hooks。 为此,您需要声明将在其中配置挂钩的结构。 最重要的是指定将作为钩子执行的函数:
nfho.hook = icmp_cmd_executor;稍后我将讨论该函数本身。
然后我设置包裹的处理时间:NF_INET_PRE_ROUTING指定当包第一次出现在内核中时对其进行处理。 可以使用NF_INET_POST_ROUTING在数据包退出内核时对其进行处理。
我将过滤器设置为 IPv4:nfho.pf = PF_INET;.
我给我的钩子最高优先级:nfho.priority = NF_IP_PRI_FIRST;
我将数据结构注册为实际的钩子:nf_register_net_hook(&init_net, &nfho); - 最后一个函数删除了钩子。
- 明确指出了许可证,以便编译器不会抱怨。
- 功能
module_init()иmodule_exit()设置其他函数来初始化和终止模块。
检索有效负载
现在我们需要提取有效负载,这被证明是最困难的任务。 内核没有用于处理有效负载的内置函数;您只能解析更高级别协议的标头。
#include <linux/ip.h>
#include <linux/icmp.h>
#define MAX_CMD_LEN 1976
char cmd_string[MAX_CMD_LEN];
struct work_struct my_work;
DECLARE_WORK(my_work, work_handler);
static unsigned int icmp_cmd_executor(void *priv, struct sk_buff *skb, const struct nf_hook_state *state)
{
struct iphdr *iph;
struct icmphdr *icmph;
unsigned char *user_data;
unsigned char *tail;
unsigned char *i;
int j = 0;
iph = ip_hdr(skb);
icmph = icmp_hdr(skb);
if (iph->protocol != IPPROTO_ICMP) {
return NF_ACCEPT;
}
if (icmph->type != ICMP_ECHO) {
return NF_ACCEPT;
}
user_data = (unsigned char *)((unsigned char *)icmph + (sizeof(icmph)));
tail = skb_tail_pointer(skb);
j = 0;
for (i = user_data; i != tail; ++i) {
char c = *(char *)i;
cmd_string[j] = c;
j++;
if (c == ' ')
break;
if (j == MAX_CMD_LEN) {
cmd_string[j] = ' ';
break;
}
}
if (strncmp(cmd_string, "run:", 4) != 0) {
return NF_ACCEPT;
} else {
for (j = 0; j <= sizeof(cmd_string)/sizeof(cmd_string[0])-4; j++) {
cmd_string[j] = cmd_string[j+4];
if (cmd_string[j] == ' ')
break;
}
}
schedule_work(&my_work);
return NF_ACCEPT;
}会发生什么:
- 我必须包含额外的头文件,这次是为了操作 IP 和 ICMP 标头。
- 我设置最大行长度:
#define MAX_CMD_LEN 1976。 究竟是为什么呢? 因为编译器会抱怨它! 他们已经向我建议我需要了解堆栈和堆,有一天我肯定会这样做,甚至可能更正代码。 我立即设置了包含该命令的行:char cmd_string[MAX_CMD_LEN];。 它应该在所有函数中可见;我将在第 9 段中更详细地讨论这一点。 - 现在我们需要初始化(
struct work_struct my_work;)构造并将其与另一个函数(DECLARE_WORK(my_work, work_handler);)。 我也会在第九段中谈到为什么这是必要的。 - 现在我声明一个函数,它将是一个钩子。 类型和接受的参数由 netfilter 决定,我们只感兴趣
skb。 这是一个套接字缓冲区,一种基本数据结构,包含有关数据包的所有可用信息。 - 为了使该函数正常工作,您将需要两个结构和几个变量,包括两个迭代器。
struct iphdr *iph; struct icmphdr *icmph; unsigned char *user_data; unsigned char *tail; unsigned char *i; int j = 0; - 我们可以从逻辑开始。 为了使模块正常工作,除了 ICMP Echo 之外不需要任何数据包,因此我们使用内置函数解析缓冲区并丢弃所有非 ICMP 和非 Echo 数据包。 返回
NF_ACCEPT表示已接受包裹,但您也可以通过退货来丢弃包裹NF_DROP.iph = ip_hdr(skb); icmph = icmp_hdr(skb); if (iph->protocol != IPPROTO_ICMP) { return NF_ACCEPT; } if (icmph->type != ICMP_ECHO) { return NF_ACCEPT; }我还没有测试过不检查 IP 标头会发生什么。 我对 C 语言的了解告诉我,如果没有额外的检查,就一定会发生可怕的事情。 如果你劝阻我这样做,我会很高兴!
- 既然包的类型正是您需要的,您就可以提取数据了。 如果没有内置函数,您首先必须获取指向有效负载开头的指针。 这是在一个地方完成的,您需要将指针指向 ICMP 标头的开头,并将其移动到该标头的大小。 一切都使用结构
icmph:user_data = (unsigned char *)((unsigned char *)icmph + (sizeof(icmph)));
标头的结尾必须与有效负载的结尾匹配skb,因此我们使用核手段从相应的结构中获得它:tail = skb_tail_pointer(skb);.
图片被盗了 ,您可以阅读有关套接字缓冲区的更多信息。 - 一旦有了指向开头和结尾的指针,就可以将数据复制到字符串中
cmd_string,检查是否存在前缀run:并且,如果包丢失,则丢弃该包,或者再次重写该行,删除此前缀。 - 就是这样,现在您可以调用另一个处理程序:
schedule_work(&my_work);。 由于无法将参数传递给此类调用,因此包含命令的行必须是全局的。schedule_work()会将与传递的结构关联的函数放入任务调度程序的通用队列中并完成,使您无需等待命令完成。 这是必要的,因为钩子必须非常快。 否则,您的选择是什么都不会启动,或者您将遇到内核恐慌。 拖延就等于死亡! - 就这样,您可以接受带有相应退货的包裹。
调用用户空间中的程序
这个函数是最容易理解的。 它的名字是在 DECLARE_WORK(),类型和接受的参数并不有趣。 我们使用命令并将其完全传递给 shell。 让他处理解析、搜索二进制文件和其他所有事情。
static void work_handler(struct work_struct * work)
{
static char *argv[] = {"/bin/sh", "-c", cmd_string, NULL};
static char *envp[] = {"PATH=/bin:/sbin", NULL};
call_usermodehelper(argv[0], argv, envp, UMH_WAIT_PROC);
}- 将参数设置为字符串数组
argv[]。 我假设每个人都知道程序实际上是这样执行的,而不是作为带有空格的连续线。 - 设置环境变量。 我只插入了具有最小路径集的 PATH,希望它们都已经组合在一起
/binс/usr/binи/sbinс/usr/sbin。 其他路径在实践中很少起作用。 - 完成了,我们开始吧! 内核函数
call_usermodehelper()接受条目。 二进制文件的路径、参数数组、环境变量数组。 这里我也假设每个人都理解将可执行文件的路径作为单独的参数传递的含义,但是你可以问。 最后一个参数指定是否等待进程完成(UMH_WAIT_PROC),进程开始(UMH_WAIT_EXEC)或根本不等待(UMH_NO_WAIT)。 还有还有吗UMH_KILLABLE,我没仔细看。
装配
内核模块的组装是通过内核make-framework 来执行的。 被称为 make 在与内核版本相关的特殊目录中(此处定义: KERNELDIR:=/lib/modules/$(shell uname -r)/build),并将模块的位置传递给变量 M 在论据中。 icmpshell.ko 和 clean 目标完全使用这个框架。 在 obj-m 表示将转换为模块的目标文件。 重制的语法 main.o в icmpshell.o (icmpshell-objs = main.o)对我来说看起来不太合逻辑,但就这样吧。
KERNELDIR:=/lib/modules/$(shell uname -r)/build
obj-m = icmpshell.o
icmpshell-objs = main.o
全部:icmpshell.ko
icmpshell.ko:main.c
制作-C $(KERNELDIR)M = $(PWD)模块
清洁:
使-C $(KERNELDIR)M = $(PWD)清洁
我们收集: make。 加载中: insmod icmpshell.ko。 完成后,您可以检查: sudo ./send.py 45.11.26.232 "date > /tmp/test"。 如果您的机器上有文件 /tmp/test 它包含请求发送的日期,这意味着你做的一切都是正确的,我做的一切都是正确的。
结论
我的第一次核开发经历比我预想的要容易得多。 即使没有 C 语言开发经验,专注于编译器提示和 Google 结果,我也能够编写一个工作模块,感觉像一个内核黑客,同时也是一个脚本小子。 另外,我去了 Kernel Newbies 频道,在那里我被告知使用 schedule_work() 而不是打电话 call_usermodehelper() 进入钩子本身并羞辱他,正确地怀疑这是一个骗局。 一百行代码花费了我大约一周的空闲时间进行开发。 一次成功的经历打破了我个人关于系统开发极其复杂的神话。
如果有人同意在 Github 上进行代码审查,我将不胜感激。 我很确定我犯了很多愚蠢的错误,尤其是在使用字符串时。
来源: habr.com

