Best Cyber Warrior ‘22 CTF
The Best Cyber Warrior (BCW) ‘22 was a CTF that was available to U.S. Army cyber personnel, hosted by the Army’s Central Command. Our team, PWN@VT, comprised of ROTC cadets came in 2nd place!
Best Cyber Warrior 22 - Fileless Writeup
fileless
was a hard reverse engineering challenge. The binary to reverse engineer was an ELF executable, that dynamically decrypted another ELF file and executed it with memfd_create
and fexecve
. The decrypted ELF simply decrypted a flag, without doing anything else.
Initial Recon
The first step was running file
on the binary the challenge provided, called final
.
$ file final
final: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=0c4dc88c6da0c4dd02beff916952a24699164317, stripped
From this, it was an ELF file, so it was time to pop this into Ghidra.
Ghidra Reverse Engineering
After analysis, and finding the main function, we’re granted with the following disassembly:
int main(int argc,char **argv,char **env)
{
int __fd;
void *pvVar1;
long i;
undefined8 *puVar2;
undefined8 *puVar3;
long in_FS_OFFSET;
byte bVar4;
undefined8 local_17f8 [765];
long stack_cookie;
bVar4 = 0;
stack_cookie = *(long *)(in_FS_OFFSET + 0x28);
pvVar1 = malloc(0x5fa0);
puVar2 = &DAT_00100ec0;
puVar3 = local_17f8;
for (i = 0x2fd; i != 0; i = i + -1) {
*puVar3 = *puVar2;
puVar2 = puVar2 + (ulong)bVar4 * -2 + 1;
puVar3 = puVar3 + (ulong)bVar4 * -2 + 1;
}
FUN_00100b0c("joezid",(long)local_17f8,(long)pvVar1);
__fd = memfd_create("[kworker/1:1]",0);
if (__fd == -1) {
FUN_00100b8f(-1,"cannot create in-memory fd for code");
}
FUN_00100bc0(__fd,(long)pvVar1,0x17e7);
*argv = "[kworker/1:1]";
fexecve(__fd,argv,env);
close(__fd);
if (stack_cookie != *(long *)(in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
return 0;
}
From this disassembly, we can first see that we’re allocating 0x5fa0 (24,480) bytes of (memory with malloc
, doing some initial copying with some bad obfuscation. Then memfd_create
is called, which creates a new file descriptor, that can be written into with write
.
Expanding on the “bad obfuscation`:
uVar2 = puVar2 + (ulong)bVar4 * -2 + 1;
puVar3 = puVar3 + (ulong)bVar4 * -2 + 1;
Can be simplified, since bVar2
is always 0.
DAT_00100ec0
seems to be the location of the encrypted data. Let’s clean up this disassembly by renaming and correctly setting types in Ghidra.
int main(int argc, char **argv, char **env)
{
int __fd;
byte *mem;
long i;
undefined8 *enc_data;
undefined8 *buffer;
byte _0;
byte stack_enc_data [765];
_0 = 0;
mem = (byte *)malloc(0x5fa0);
enc_data = &g_encrypted_data;
buffer = (undefined8 *)stack_enc_data;
for (i = 0x2fd; i != 0; i = i + -1) {
*buffer = *enc_data;
enc_data = enc_data + (ulong)_0 * -2 + 1;
buffer = buffer + (ulong)_0 * -2 + 1;
}
FUN_00100b0c("joezid",stack_enc_data,mem);
__fd = memfd_create("[kworker/1:1]",0);
if (__fd == -1) {
print_err(-1,"cannot create in-memory fd for code");
}
FUN_00100bc0(__fd,(long)mem,0x17e7);
*argv = "[kworker/1:1]";
fexecve(__fd,argv,env);
close(__fd);
return 0;
}
Now, all that’s left is to reverse engineer the following calls:
FUN_00100b0c("joezid", stack_enc_data, mem);
...
FUN_00100bc0(__fd,(long)mem,0x17e7);
What’s that Decryption?
Let’s take a look at the first function, FUN_00100b0c
, and first rename some parameters based on our above reverse engineering work. I’m guessing that the string "joezid"
is some decryption key.
undefined8 maybe_decrypt(char *key, byte *enc_data, byte *mem)
{
undefined local_118 [264];
FUN_0010091e(key,(long)local_118);
FUN_001009f7((long)local_118,(long)enc_data,(long)mem);
}
That’s not very helpful, lets dive into the first call, FUN_0010091e(key,(long)local_118);
. This wasn’t too hard to reverse engineer, so I’ve renamed types and annotated a simple swap
function, that swaps two bytes, given two byte pointers.
undefined8 FUN_0010091e(char *key, byte *param_2)
{
uint _0;
size_t key_len;
int j;
int equiv_j;
int i;
int k;
key_len = strlen(key);
equiv_j = 0;
for (i = 0; i < 256; i = i + 1) {
param_2[i] = (byte)i;
}
for (k = 0; k < 256; k = k + 1) {
j = (uint)param_2[k] + equiv_j + (int)key[k % (int)key_len];
_0 = (uint)(j >> 31) >> 24;
equiv_j = (j + _0 & 0xff) - _0;
swap(param_2 + k,param_2 + equiv_j);
}
return 0;
}
We can see that this “equivalent to zero” trick from the main function appears again. (j >> 31) >> 24 == j >> (31+24)
on a 32 bit type always results in zero.
From this function, its clear that this is the Key Schedule Algorithmn (KSA) for RC4. The Wikipedia psuedocode for the KSA is:
for i from 0 to 255
S[i] := i
endfor
j := 0
for i from 0 to 255
j := (j + S[i] + key[i mod keylength]) mod 256
swap values of S[i] and S[j]
endfor
Which matches exactly to the decompiled code.
Lets see if the other function call, FUN_001009f7
is in fact RC4 decryption.
int rc4_dec(byte *rc4_s, byte *enc_data, byte *out)
{
int rc4_i;
int j;
ulong i;
rc4_i = 0;
j = 0;
for (i = 0; i < 0x17e8; i = i + 1) {
rc4_i = rc4_i + 1U & 0xff;
j = (uint)rc4_s[rc4_i] + j & 0xff;
swap(rc4_s + rc4_i,rc4_s + j);
out[i] = enc_data[i] ^ rc4_s[(rc4_s[j] + rc4_s[rc4_i])];
}
return 0;
}
Yep! This is RC4!
There’s still one more function call to reverse, FUN_00100bc0(__fd,(long)mem,0x17e7);
. But since this call is after the RC4 decryption, and before memexec_fd
, we can probably guess this is some write
call.
void FUN_00100bc0(int fd, byte *data, long size)
{
ssize_t bytes_out;
size_t __n;
long written_sz;
written_sz = 0;
do {
__n = 0x7ffff000;
if ((ulong)(size - written_sz) < 0x7ffff001) {
__n = size - written_sz;
}
bytes_out = write(fd,data + written_sz,__n);
if (bytes_out == -1) {
print_err(-1,"writing to memfd failed\n");
}
written_sz = written_sz + bytes_out;
} while (written_sz != size);
return;
}
This is a write
with some funny business going on. Give this a closer look, and you’ll see that since written_sz
is zero, then__n == size - 0
, so only 1 write will occur.
Of course in theory. The call to write
may not write all of the available data, so this will look until all of the data has been written into the file descriptor.
Extracting the ELF
If you’ve been paying attention, numerous sizes have been used to decrypt and write the data. So, to make our lives easier, and prevent mistakes, we’ll go with the following approach:
- Set a breakpoint on
fexecve
- Determine what File Descriptor is being used
- Dump the data from the file descriptor, using
/proc/xxx/fd/y
- Reverse engineer the ELF
So, lets launch gdb!
$ gdb fileless
First, we’ll set a breakpoint on fexecve
:
gef➤ break fexecve
Breakpoint 1 at 0x7a0
Run the program until we hit our breakpoint:
From the screenshot, we can see that the file descriptor is passed in through rdi
, so our file descriptor is 3.
Running info proc
, we get the process ID:
gef➤ info proc
process 10787
cmdline = '/home/justin/ctf/bcw22/final'
cwd = '/home/justin/ctf/bcw22'
exe = '/home/justin/ctf/bcw22/final'
So, lets pipe that data written into a file for analysis:
$ cat /proc/10787/fd/3 > final_dumped
Lets run file on this and ensure that it’s an ELF:
$ file final_dumped
final_dumped: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, missing section headers at 6056
Uh-oh. missing section headers at 6056
The section headers are stripped! These aren’t needed for proper execution of the binary however. Running readelf -S final_dumped
should confirm this:
$ readelf -S final_dumped
There are 27 section headers, starting at offset 0x1128:
readelf: Error: Reading 1728 bytes extends past end of file for section headers
Yikes. Luckily, we can still somewhat disassemble this with Ghidra.
Not good. We can import this manually, and manually disassembly the program by finding the entry point. Load this into Ghidra as a raw file.
Next, let’s use the “Aggressive Instruction Finder” to find the instructions:
Scrolling down, we find a long block of move instructions, that looks like a decryption routine:
PUSH RBP
MOV RBP,RSP
SUB RSP,0x70
MOV RAX,qword ptr FS:[0x28]
MOV qword ptr [RBP + local_10],RAX
XOR EAX,EAX
MOV byte ptr [RBP + local_68],0xcc
MOV byte ptr [RBP + local_67],0x8d
MOV byte ptr [RBP + local_66],0x2c
MOV byte ptr [RBP + local_65],0xec
MOV byte ptr [RBP + local_64],0x6f
MOV byte ptr [RBP + local_63],0xcc
MOV byte ptr [RBP + local_62],0x26
MOV byte ptr [RBP + local_61],0x89
MOV byte ptr [RBP + local_60],0x66
MOV byte ptr [RBP + local_5f],0x89
MOV byte ptr [RBP + local_5e],0x66
MOV byte ptr [RBP + local_5d],0x6e
MOV byte ptr [RBP + local_5c],0x6e
MOV byte ptr [RBP + local_5b],0xeb
MOV byte ptr [RBP + local_5a],0xad
MOV byte ptr [RBP + local_59],0x86
MOV byte ptr [RBP + local_58],0x89
MOV byte ptr [RBP + local_57],0xee
MOV byte ptr [RBP + local_56],0x86
MOV byte ptr [RBP + local_55],0x4e
MOV byte ptr [RBP + local_54],0x66
MOV byte ptr [RBP + local_53],0xeb
MOV byte ptr [RBP + local_52],0x4d
MOV byte ptr [RBP + local_51],0xae
MOV byte ptr [RBP + local_50],0x6e
MOV byte ptr [RBP + local_4f],0x8e
MOV byte ptr [RBP + local_4e],0xeb
MOV byte ptr [RBP + local_4d],0xcc
MOV byte ptr [RBP + local_4c],0x6
MOV byte ptr [RBP + local_4b],0x4e
MOV byte ptr [RBP + local_4a],0xeb
MOV byte ptr [RBP + local_49],0x2f
MOV byte ptr [RBP + local_48],0x6
MOV byte ptr [RBP + local_47],0xae
MOV byte ptr [RBP + local_46],0xaf
MOV qword ptr [RBP + local_38],0x0
MOV qword ptr [RBP + local_30],0x0
MOV qword ptr [RBP + local_28],0x0
MOV qword ptr [RBP + local_20],0x0
MOV word ptr [RBP + local_18],0x0
MOV byte ptr [RBP + local_16],0x0
MOV dword ptr [RBP + local_6c],0x0
JMP LAB_00000799
LAB_00000771 XREF[1]: 0000079d(j)
MOV EAX,dword ptr [RBP + local_6c]
CDQE
MOVZX EAX,byte ptr [RBP + RAX*0x1 + -0x60]
MOVZX EAX,AL
MOV ESI,0x3
MOV EDI,EAX
CALL FUN_0000066a ; decryption functioon undefined FUN_0000066a()
MOV EDX,EAX
MOV EAX,dword ptr [RBP + local_6c]
CDQE
MOV byte ptr [RBP + RAX*0x1 + -0x30],DL
ADD dword ptr [RBP + local_6c],0x1
LAB_00000799 XREF[1]: 0000076f(j)
CMP dword ptr [RBP + local_6c],0x19
JLE LAB_00000771
MOV EAX,0x0
MOV RCX,qword ptr [RBP + local_10]
XOR RCX,qword ptr FS:[0x28]
JZ LAB_000007b8
CALL FUN_00000540 undefined FUN_00000540()
I decided to manually analyze this assembly because Ghidra incorrectly computed the length of the array in the disassembly.
The function at FUN_0000066a
is a simple decryption routine:
uint decrypt(byte data, byte key)
{
return (int)(uint)data >> (-key & 7) | (uint)data << (key & 0x1f);
}
Armed with this information, we can craft a simple program in C to decrypt this data:
#include <stdint.h>
#include <string.h>
#include <stdio.h>
typedef unsigned char byte;
typedef unsigned int uint;
#define SZ (35)
byte decrypt(byte x, int key)
{
return (uint)x >> (-key & 7) | (uint)x << (key & 0x1f);
}
int main()
{
int i;
byte local_68[35];
byte local_38[35];
local_68[0] = 0xcc;
local_68[1] = 0x8d;
local_68[2] = 0x2c;
local_68[3] = 0xec;
local_68[4] = 0x6f;
local_68[5] = 0xcc;
local_68[6] = 0x26;
local_68[7] = 0x89;
local_68[8] = 0x66;
local_68[9] = 0x89;
local_68[10] = 0x66;
local_68[11] = 0x6e;
local_68[12] = 0x6e;
local_68[13] = 0xeb;
local_68[14] = 0xad;
local_68[15] = 0x86;
local_68[16] = 0x89;
local_68[17] = 0xee;
local_68[18] = 0x86;
local_68[19] = 0x4e;
local_68[20] = 0x66;
local_68[21] = 0xeb;
local_68[22] = 0x4d;
local_68[23] = 0xae;
local_68[24] = 0x6e;
local_68[25] = 0x8e;
local_68[26] = 0xeb;
local_68[27] = 0xcc;
local_68[28] = 6;
local_68[29] = 0x4e;
local_68[30] = 0xeb;
local_68[31] = 0x2f;
local_68[32] = 6;
local_68[33] = 0xae;
local_68[34] = 0xaf;
memset(local_38, 0, SZ);
byte uVar1;
for (i = 0; i < SZ; i = i + 1)
{
uVar1 = decrypt(local_68[i], 3);
local_38[i] = (byte)uVar1;
}
printf("%s\n", local_38);
}
Running this, we get the flag: flag{f1L3L3ss_m4Lw4r3_just_f0r_y0u}