SiberSiaga 2023: Vacine

Well done, you've successfully reached the Final stage of SiberSiaga CTF. To obtain the flag, try reversing this challenge.

TL;DR

Manually unpack executable via the Tail Jump trick, followed by shellcode extraction from process memory.

Initial Analysis

Attempting to debug the binary in IDA Pro shows that there's only 1 function available. This indicates the executable is packed to makes reverse engineering harder.

Analyzing the executable in DetectItEasy shows that it is UPX-packed.

However, attempting the unpack it with the UPX command-line utility shows that the executable is tampered, and was not able to be unpacked automatically.

 ┌──(kali💀JesusCries)-[~/…/CTF/SiberSiaga2023 (Finals)/Rev/Vacine]
 └─$ upx -d vacine.exe 
                        Ultimate Packer for eXecutables
                           Copyright (C) 1996 - 2020
 UPX 3.96        Markus Oberhumer, Laszlo Molnar & John Reiser   Jan 23rd 2020
 ​
         File size         Ratio      Format      Name
    --------------------   ------   -----------   -----------
 upx: vacine.exe: CantUnpackException: file is modified/hacked/protected; take care!!!
 ​
 Unpacked 0 files.

Manual Unpacking

When attaching the executable in x32dbg, it automatically jumps to the nearest DLL imports. Click on Run to user code to return to the program's entry point, until pushad is reached.

On the entry point of pushad, scroll down until the pushad equivalent assembly of popad is located.

A few instructions below popad is a jmp instruction directly to the program's Original Entry Point (OEP). This is the initial Entry Point of the executable before packing. Place a breakpoint on the following address, then Step Into until a function call is reached.

Use OllyDumpEx to fix the OEP address and dump the executable. The resulting executable is now semi-unpacked.

The newly dumped executable will not execute normally as it is still missing dependencies/imports from the Import Address Table (IAT). We can fix it using Scylla's IAT Autosearch:

Remove any corrupted thunks that still exists, then continue fixing the executable by correcting the imports:

Once all the fixes above are completed, dump the executable for a second time. When prompted, select the previously dumped file vacine_dump.exe. This will produce the final executable vacine_dump_SCY.exe:

After manual unpacking, IDA will now show all the original functions:

Finding Main Function

If you use IDA to disassemble the unpacked executable, it should point you to the main function directly!

Decompiling the unpacked binary in Ghidra shows that the entry point is FUN_0006136A(). However, this is not the main function. We can instead find out the real main function using the ret trick.

 void entry(void)
 ​
 {
   ___security_init_cookie();
   FUN_0006136a();
   return;
 }

This trick is fairly simple, as it only requires us to locate the return value. In this case, the return value of unaff_ESI is dictated by the function real_main(), so we know this is the actual/real main function.

 int FUN_0006136a(void)
 ​
 {
   if ((char)uVar3 != '\0') {
     uVar3 = ___scrt_acquire_startup_lock();
     if (DAT_00063334 != 1) {
     }
       unaff_ESI = real_main();
       uVar7 = FUN_00061a9c();
       if ((char)uVar7 != '\0') {
         ___scrt_uninitialize_crt(1,'\0');
         return unaff_ESI;
       }
       goto LAB_000614dd;
     }
   }
   FUN_0006197c(7);
 LAB_000614dd:
 ​
   exit(unaff_ESI);
 }

At a glance, the executable is performing a classic Process Injection procedure with the following Win32 APIs:

VirtualAllocEx -> WriteProcessMemory -> CreateRemoteThread -> WaitForSingleObject

 void real_main(void)
 ​
 {  
   puVar6 = (undefined4 *)s_Well_done,_you've_successfully_r_00062128;
   BVar1 = CreateProcessA(s_C:\Windows\System32\notepad.exe_000621b4,(LPSTR)0x0,
                          (LPSECURITY_ATTRIBUTES)0x0,(LPSECURITY_ATTRIBUTES)0x0,0,0,(LPVOID)0x0,
                          (LPCSTR)0x0,(LPSTARTUPINFOA)local_1e8,&local_200);
   if (BVar1 != 0) {
     Sleep(1000);
     local_204 = OpenProcess(0x1fffff,0,local_200.dwProcessId);
     if (local_204 != (HANDLE)0x0) {
       puVar9 = SHELLCODE;
       lpStartAddress =
            (LPTHREAD_START_ROUTINE)VirtualAllocEx(local_204,(LPVOID)0x0,0x101,0x1000,0x40);
       for (; uVar5 < 0x101; uVar5 = uVar5 + 1) {
         *(byte *)((int)SHELLCODE + uVar5) = *(byte *)((int)SHELLCODE + uVar5) ^ 0x12;
       }
       WriteProcessMemory(local_204,lpStartAddress,SHELLCODE,0x101,(SIZE_T *)0x0);
       pvVar3 = CreateRemoteThread(pvVar3,(LPSECURITY_ATTRIBUTES)0x0,0,lpStartAddress,(LPVOID)0x0,0,
                                   (LPDWORD)0x0);
       WaitForSingleObject(pvVar3,0xffffffff);
       return;
     }
     DVar2 = GetLastError();
     FUN_00061010(s_Failed_to_open_process_(%d)._000621f4,(char)DVar2);
     return;
   }
 }

Unintended Solution

After spending a long time trying to find interesting code for dissecting, it turns out that we can easily extract the flag using Floss:

 ┌──(kali💀JesusCries)-[~/Desktop]
 └─$ floss vacine_dump_SCY.exe 
 INFO: floss: extracting static strings...
 INFO: floss.results: echo c2liZXJzaWFnYXsxZTZkMmVhODBhMjhkYTUzZTUzMDhkNWFhNjY0ZjFmOH0= > nul                     
 ​
  ──────────────────── 
   FLOSS TIGHT STRINGS  
  ───────────────────── 
 ​
 D$$[[aYZQ
 echo c2liZXJzaWFnYXsxZTZkMmVhODBhMjhkYTUzZTUzMDhkNWFhNjY0ZjFmOH0= > nul

Decode the base64 encoded string.

 ┌──(kali💀JesusCries)-[~/Desktop]
 └─$ echo "c2liZXJzaWFnYXsxZTZkMmVhODBhMjhkYTUzZTUzMDhkNWFhNjY0ZjFmOH0" | base64 -d
 sibersiaga{1e6d2ea80a28da53e5308d5aa664f1f8}

Flag: sibersiaga{1e6d2ea80a28da53e5308d5aa664f1f8}

Intended Solution

Static

Looking back at the decompiled code, we noticed a XOR decryption routine shortly before WriteProcessMemory is called. The odds are, the shellcode is being decrypted at runtime, before it is copied from vacine.exe to notepad.exe:

By tracing the flow of execution back to the origin where the encrypted shellcode is stored, we know that dword_62218 holds the shellcode in the .data section of the PE file.

Export the encrypted shellcode using IDA's Export data function.

Write a simple solver in C to decrypt the shellcode.

 #include <stdio.h>
 ​
 int main() {
     unsigned char ida_chars[] =
     {
         0xEE, 0xFA, 0x90, 0x12, 0x12, 0x12, 0x72, 0x9B, 0xF7, 0x23,
         0xD2, 0x76, 0x99, 0x42, 0x22, 0x99, 0x40, 0x1E, 0x99, 0x40,
         0x06, 0x99, 0x60, 0x3A, 0x1D, 0xA5, 0x58, 0x34, 0x23, 0xED,
         0xBE, 0x2E, 0x73, 0x6E, 0x10, 0x3E, 0x32, 0xD3, 0xDD, 0x1F,
         0x13, 0xD5, 0xF0, 0xE0, 0x40, 0x45, 0x99, 0x40, 0x02, 0x99,
         0x58, 0x2E, 0x99, 0x5E, 0x03, 0x6A, 0xF1, 0x5A, 0x13, 0xC3,
         0x43, 0x99, 0x4B, 0x32, 0x13, 0xC1, 0x99, 0x5B, 0x0A, 0xF1,
         0x28, 0x5B, 0x99, 0x26, 0x99, 0x13, 0xC4, 0x23, 0xED, 0xBE,
         0xD3, 0xDD, 0x1F, 0x13, 0xD5, 0x2A, 0xF2, 0x67, 0xE4, 0x11,
         0x6F, 0xEA, 0x29, 0x6F, 0x36, 0x67, 0xF6, 0x4A, 0x99, 0x4A,
         0x36, 0x13, 0xC1, 0x74, 0x99, 0x1E, 0x59, 0x99, 0x4A, 0x0E,
         0x13, 0xC1, 0x99, 0x16, 0x99, 0x13, 0xC2, 0x9B, 0x56, 0x36,
         0x36, 0x49, 0x49, 0x73, 0x4B, 0x48, 0x43, 0xED, 0xF2, 0x4D,
         0x4D, 0x48, 0x99, 0x00, 0xF9, 0x9F, 0x4F, 0x78, 0x13, 0x9F,
         0x97, 0xA0, 0x12, 0x12, 0x12, 0x42, 0x7A, 0x23, 0x99, 0x7D,
         0x95, 0xED, 0xC7, 0xA9, 0xE2, 0xA7, 0xB0, 0x44, 0x7A, 0xB4,
         0x87, 0xAF, 0x8F, 0xED, 0xC7, 0x2E, 0x14, 0x6E, 0x18, 0x92,
         0xE9, 0xF2, 0x67, 0x17, 0xA9, 0x55, 0x01, 0x60, 0x7D, 0x78,
         0x12, 0x41, 0xED, 0xC7, 0x77, 0x71, 0x7A, 0x7D, 0x32, 0x71,
         0x20, 0x7E, 0x7B, 0x48, 0x4A, 0x58, 0x68, 0x73, 0x45, 0x54,
         0x7C, 0x4B, 0x4A, 0x61, 0x6A, 0x48, 0x46, 0x48, 0x79, 0x5F,
         0x7F, 0x44, 0x7A, 0x5D, 0x56, 0x50, 0x7A, 0x5F, 0x78, 0x7A,
         0x79, 0x4B, 0x46, 0x47, 0x68, 0x48, 0x46, 0x47, 0x68, 0x5F,
         0x56, 0x7A, 0x79, 0x5C, 0x45, 0x54, 0x7A, 0x5C, 0x78, 0x4B,
         0x22, 0x48, 0x78, 0x54, 0x7F, 0x5D, 0x5A, 0x22, 0x2F, 0x32,
         0x2C, 0x32, 0x7C, 0x67, 0x7E, 0x12, 0x00, 0x00, 0x00, 0x00,
         0x00, 0x00, 0x00, 0x00
     };
 ​
     size_t ida_chars_size = sizeof(ida_chars) / sizeof(ida_chars[0]);
 ​
     for (size_t i = 0; i < ida_chars_size; i++) {
         ida_chars[i] ^= 0x12;
     }
 ​
     for (size_t i = 0; i < ida_chars_size; i++) {
         printf("%c", ida_chars[i]);
     }
 ​
     return 0;
 }

Compile the C code and execute the solver to get the flag.

 ┌──(kali💀JesusCries)-[~/Desktop]
 └─$ ./solve 
 ���`��1�d�P0�R
 �8�u�}�;}$u�X�X$�f� ӋI▒�:I�4��1����
                    K�XӋ�ЉD$$[[aYZQ��__Z���]j���Ph1�o��ջ���Vh������<|
 ���u�GrojS��echo c2liZXJzaWFnYXsxZTZkMmVhODBhMjhkYTUzZTUzMDhkNWFhNjY0ZjFmOH0= > nul 

Flag: sibersiaga{1e6d2ea80a28da53e5308d5aa664f1f8}

Dynamic

Understanding that the unpacked executable is just merely performing Process Injection on a notepad.exe process, there is a high chance that the flag is living in disguise, within the virtual memory space of notepad.exe. To debug the executable on runtime, we will need the following tools:

  1. API Monitor 32-bit - To place inline hooks on API calls.

  2. Process Hacker - To inspect memory region of processes.

Firstly, place a detour or inline hook to monitor the API CreateRemoteThread. This will halt program execution right before the shellcode is executed on the notepad process.

Secondly, use Process Hacker to look up the victim process. If our theory was correct, the decrypted shellcode containing the flag should be visible in the memory region of notepad as plaintext.

Since VirtualAllocEx was called with MemoryProtection = 0x40, this should resolves to a memory region of RWX. This makes it easier to locate the exact memory region containing the shellcode, as not a single legitimate memory region in notepad will be RWX other than the one allocated by vacine.exe:

Flag: sibersiaga{1e6d2ea80a28da53e5308d5aa664f1f8}

Last updated