TL; DR : I am writing a kernel module that will read commands from the ICMP payload and execute them on the server even if your SSH crashed. For the most impatient, all the code is on github .
Caution! Experienced C programmers run the risk of bursting into tears of bloody tears! I can be wrong even in terminology, but any criticism is welcome. The post is intended for those who have the most rough idea of ββC programming and want to look into the internals of Linux.
In the comments to my first articlementioned SoftEther VPN, which can mimic some "normal" protocols, in particular HTTPS, ICMP and even DNS. I can imagine the work of only the first of them, since I am well acquainted with HTTP (S), and I had to learn tunneling over ICMP and DNS.
Yes, I learned in 2020 that you can insert an arbitrary payload into ICMP packets. But better late than never! And since you can do something about it, then you need to do it. Since in my everyday life I most often use the command line, including via SSH, the idea of ββan ICMP shell came to my mind first. And in order to put together a complete bullshit bingo, I decided to write it as a Linux module in a language that I have only a rough idea of. Such a shell will not be visible in the list of processes, you can load it into the kernel and it will not lie on the file system, you will not see anything suspicious in the list of listening ports. In terms of its capabilities, this is a full-fledged rootkit, but I hope to modify it and use it as a shell of last resort, when the Load Average is too high to log in via SSH and execute at least
echo i > /proc/sysrq-trigger
to restore access without rebooting.
We take a text editor, basic programming skills in Python and C, google and a virtual machine that you don't mind putting under the knife if everything breaks (optional - local VirtualBox / KVM / etc) and let's go!
Client part
It seemed to me that for the client side I would have to write a script for 80 lines, but there were kind people who did all the work for me . The code turned out to be surprisingly simple, it fits into 10 significant lines:
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()
The script takes two arguments, an address and a payload. Before sending, the payload is preceded by a key
run:
, we will need it to exclude packets with a random payload.
The kernel requires privileges in order to craft packages, so the script will have to be run with superuser rights. Don't forget to give execute permission and install scapy itself. Debian has a package called
python3-scapy
. Now you can check how it all works.
Running and outputting a command
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!
This is how it looks in the sniffer
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 run:Hello, world
0010 21 !
Data: 72756e3a48656c6c6f2c20776f726c6421
[Length: 17]
Frame 2: 59 bytes on wire (472 bits), 59 bytes captured (472 bits) on interface wlp1s0, id 0
Internet Protocol Version 4, Src: 45.11.26.232, Dst: 192.168.0.240
Internet Control Message Protocol
Type: 0 (Echo (ping) reply)
Code: 0
Checksum: 0xde03 [correct]
[Checksum Status: Good]
Identifier (BE): 0 (0x0000)
Identifier (LE): 0 (0x0000)
Sequence number (BE): 0 (0x0000)
Sequence number (LE): 0 (0x0000)
[Request frame: 1]
[Response time: 19.094 ms]
Data (17 bytes)
0000 72 75 6e 3a 48 65 6c 6c 6f 2c 20 77 6f 72 6c 64 run:Hello, world
0010 21 !
Data: 72756e3a48656c6c6f2c20776f726c6421
[Length: 17]
^C2 packets captured
The payload in the response packet does not change.
Kernel module
To build in a virtual machine with Debian, you will need at least
make
and the linux-headers-amd64
rest will be tightened up in the form of dependencies. I will not give the entire code in the article, you can clone it on github.
Hook setup
First, we need two functions to load the module and to unload it. The function for unloading is not required, but then it
rmmod
will not work, the module will be unloaded only when it is turned off.
#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);
What's going on here:
- Two header files are pulled in to manipulate the module itself and the netfilter.
- , . , . β , :
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()
.
Now we need to extract the payload, which turned out to be the most difficult task. The kernel does not have built-in functions for working with payload, you can only parse the headers of higher-level protocols.
#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 == '\0')
break;
if (j == MAX_CMD_LEN) {
cmd_string[j] = '\0';
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] == '\0')
break;
}
}
schedule_work(&my_work);
return NF_ACCEPT;
}
What's happening:
- I had to include additional header files, this time to manipulate IP and ICMP headers.
- Specifies the maximum length of a string:
#define MAX_CMD_LEN 1976
. Why exactly this? Because the compiler swears at the big one! They already told me that I need to deal with the stack and the heap, someday I will definitely do this and maybe even correct the code. Immediately sets a string in which the team will be based:char cmd_string[MAX_CMD_LEN];
. It should be visible in all functions, I'll talk about this in more detail in paragraph 9. - (
struct work_struct my_work;
) (DECLARE_WORK(my_work, work_handler);
). , , . - , . ,
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()
, . . , , kernel panic. ! - , .
This function is the most straightforward. Its name was given in
DECLARE_WORK()
, type and accepted arguments are not interesting. We take the command line and pass it entirely to the shell. Let him deal with parsing, searching for binaries and everything else himself.
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);
}
- We set the arguments to an array of strings
argv[]
. I will assume that everyone knows that programs are actually executed in this way, and not a solid line with spaces. - Setting environment variables. I inserted only PATH with a minimal set of paths, expecting that all have already combined
/bin
with/usr/bin
and/sbin
with/usr/sbin
. Other paths rarely matter in practice. - , !
call_usermodehelper()
. , , . , , . , (UMH_WAIT_PROC
), (UMH_WAIT_EXEC
) (UMH_NO_WAIT
).UMH_KILLABLE
, .
Building kernel modules is done through a kernel make framework. It is called
make
inside a special directory linked to the kernel version (defined here:) KERNELDIR:=/lib/modules/$(shell uname -r)/build
, and the location of the module is passed to the variable M
in the arguments. The icmpshell.ko and clean targets use this framework entirely. In obj-m
specifies the object file that will be converted into a module. The syntax that he removes main.o
in icmpshell.o
( icmpshell-objs = main.o
) doesn't look very logical to me, but let it be so.
Putting: . Load: . Done, you can check: . If a file appears on your machine and it contains the date the request was sent, then you did everything right and I did everything right.
KERNELDIR:=/lib/modules/$(shell uname -r)/build
obj-m = icmpshell.o
icmpshell-objs = main.o
all: icmpshell.ko
icmpshell.ko: main.c
make -C $(KERNELDIR) M=$(PWD) modules
clean:
make -C $(KERNELDIR) M=$(PWD) clean
make
insmod icmpshell.ko
sudo ./send.py 45.11.26.232 "date > /tmp/test"
/tmp/test
Conclusion
My first experience with nuclear engineering was much simpler than I expected. Even without experience in C development, focusing on compiler hints and Google output, I was able to write a working module and feel like a kernel hacker, and at the same time a script kiddie. In addition, I went to the Kernel Newbies channel, where they told me to use
schedule_work()
instead of calling call_usermodehelper()
inside the hook itself and shamed me , rightly suspecting a scam. A hundred lines of code cost me about a week of development in my spare time. A successful experience that destroyed my personal myth about the overwhelming complexity of system development.
If someone agrees to do a code review on github, I would be grateful. I'm pretty sure I've made a lot of stupid mistakes, especially when dealing with strings.