Flare-On 2024: checksum
Introduction
This reversing challenge was part of the yearly Flare-On 2024 CTF by Mandiant. The challenge contained a Windows executable which required the user to fill in several checksums, which would then lead to decrypting and writing the flag as a .JPG to your host. The executable had anti-tampering techniques in place in order to prevent easier solutions than intended.
Initial behaviour
Running file checksum.exe
gives us the following output:
1
2
└─$ file checksum.exe
checksum.exe: PE32+ executable (console) x86-64, for MS Windows, 15 sections
We are dealing with a 64 bit Windows PE which we will run to see it’s initial behaviour:
The executable prompts the user for several simple math equations, followed by a checksum without an equation. Upon entering any (wrong) checksum, the application terminates, which is the same result as submitting an incorrect equation.
IDA
Import table
A good habit to have is to check the import table as the first thing you do. This will help you understand the behaviour of the executable, and what it’s capable of doing.
In this import table we notice several interesting functions being used, CreateFileA
(used to create or open files on disk) such as WriteFile
(usually writes data to files on disk) and VirtualAlloc
(used to allocate memory). With this info, we can assume the executable will create a create a new or modify an existing file on our host.
Function table
It’s also a good habit to inspect the function table at the beginning, this can give away behaviour in case the functions aren’t obfuscated. In this case, when Go compiles a binary, it links all the code directly, including functions from the Go runtime and any external packages used.
As you can see in the image above, there are +- 1378 functions present in the executable, where the majority are just Go runtime functions. However, usually it is good to search for main
, start
or entry
to find our starting point. For Go, the entry point is usually called main_main
.
In this case, there is also main_a
and main_b
. We will come back later to this.
Executable flow
main_main
Navigating to main_main
we notice the behaviour we encountered upon running the executable. The amount of times the checksum equation gets prompted is a random number ranging from 1 to 5, plus 3.
When debugging, we can notice our checksum input follows the right code block; which, long story short, copies our data from one memory location to another using Golang multithreading.
Next, once we pass the checksum equations, our checksum input is being compared to see if its 32 bytes
long. If it’s not, the executable terminates.
main_b
So how do we find the correct checksum? When we look back at our function table, we can tell main_b
is used as a function to terminate our executable.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
if ( result )
{
v24[0] = &RTYPE_string;
v24[1] = runtime_convTstring(a3, a4, a3, a4, a5, a6, a7, a8, a9);
v9 = os_Stdout;
fmt_Fprintln(
(unsigned int)go_itab__os_File_io_Writer,
os_Stdout,
(unsigned int)v24,
1,
1,
v10,
v11,
v12,
v13,
v19,
v21,
v22,
v23);
return os_Exit(-559038737, v9, v14, 1, 1, v15, v16, v17, v18, v20);
}
return result;
}
That leaves us with main_a
.
main_a
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
if ( !a1 )
a1 = &runtime_noptrbss;
v26 = a1;
v9 = runtime_makeslice((unsigned int)&RTYPE_uint8, a2, a2, a4, a5, a6, a7, a8, a9);
v15 = v26;
for ( i = 0LL; a2 > i; ++i )
{
a5 = v9;
v17 = v15;
v18 = i - 11 * ((__int64)((unsigned __int128)(i * (__int128)0x5D1745D1745D1746LL) >> 64) >> 2);
v19 = v15[i];
if ( v18 >= 0xB )
runtime_panicIndex(v18, i, 11LL);
v10 = "FlareOn2024bad verb '%0123456789_/dev/stdout/dev/stderrCloseHandleOpenProcessGetFileTypeshort write30517578125bad argSizemethodargs(reflect.SetProcessPrngMoveFileExWNetShareAddNetShareDeluserenv.dllassistQueuenetpollInitreflectOffsglobalAllocmSpanManualstart traceclobberfreegccheckmarkscheddetailcgocall nilunreachable s.nelems= of size runtime: p ms clock, nBSSRoots=runtime: P exp.) for minTrigger=GOMEMLIMIT=bad m value, elemsize= freeindex= span.list=, npages = tracealloc( p->status= in status idleprocs= gcwaiting= schedtick= timerslen= mallocing=bad timedivfloat64nan1float64nan2float64nan3float32nan2GOTRACEBACK) at entry+ (targetpc= , plugin: runtime: g : frame.sp=created by broken pipebad messagefile existsbad addressRegCloseKeyCreateFileWDeleteFileWExitProcessFreeLibrarySetFileTimeVirtualLockWSARecvFromclosesocketgetpeernamegetsocknamecrypt32.dllmswsock.dllsecur32.dllshell32.dlli/o timeoutavx512vnniwavx512vbmi2LocalAppDatashort buffer152587890625762939453125OpenServiceWRevertToSelfCreateEventWGetConsoleCPUnlockFileExVirtualQueryadvapi32.dlliphlpapi.dllkernel32.dllnetapi32.dllsweepWaiterstraceStringsspanSetSpinemspanSpecialgcBitsArenasmheapSpecialgcpacertracemadvdontneedharddecommitdumping heapchan receivelfstack.push span.limit= span.state=bad flushGen MB stacks, worker mode nDataRoots= nSpanRoots= wbuf1=<nil> wbuf2=<nil> gcscandone runtime: gp= found at *( s.elemsize= B (";
v11 = (unsigned __int8)aTrueeeppfilepi[v18 + 3060];
*(_BYTE *)(a5 + i) = v11 ^ v19;
v9 = a5;
v15 = v17;
}
v20 = v9;
v21 = encoding_base64__ptr_Encoding_EncodeToString(
runtime_bss,
v9,
a2,
a2,
a5,
(_DWORD)v10,
v11,
v12,
v13,
v23,
v24,
v25);
if ( v20 == 88 )
return runtime_memequal(
v21,
"cQoFRQErX1YAVw1zVQdFUSxfAQNRBXUNAxBSe15QCVRVJ1pQEwd/WFBUAlElCFBFUnlaB1ULByRdBEFdfVtWVA==");
else
return 0LL;
}
This function looks much more promising. At first we can notice a really strong string which contains “junk”. This is due to Golang not using null-terminated strings, and IDA can’t really tell where the real string ends. In our case, we can make an educated guess and say the real string is v10 = FlareOn2024
main_a
appears to take an argument, which is then being used to perform operations such as bitshifting and a XOR operation.
At the end of the function, the processed data is being compared to a base64
string: cQoFRQErX1YAVw1zVQdFUSxfAQNRBXUNAxBSe15QCVRVJ1pQEwd/WFBUAlElCFBFUnlaB1ULByRdBEFdfVtWVA==
Using a python script, we decode the b64 encoded string XOR the result with the key.
1
2
3
4
5
6
7
8
9
10
11
12
import base64
b64string = "cQoFRQErX1YAVw1zVQdFUSxfAQNRBXUNAxBSe15QCVRVJ1pQEwd/WFBUAlElCFBFUnlaB1ULByRdBEFdfVtWVA=="
key = "FlareOn2024"
decoded = base64.b64decode(b64string)
result = bytearray()
for i in range(len(decoded)):
result.append(decoded[i] ^ ord(key[i % len(key)]))
print(result.decode(errors='ignore'))
This gives us the following result, which also appears to be 32 bytes
in size (remember the checksum input check!): 7fd7dd1d0e959f74c133c13abb740b9faa61ab06bd0ecd177645e93b1e3825dd
Retrieving the flag
Upon entering this string into the checksum prompt, the executable terminates. However, further inspecting IDA, we can see what really happens.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
v226.len = os_UserCacheDir();
v216 = len;
main_b(v111, v89, (unsigned int)"Fail to get path...", 19, v100, v112, v113, v114, v115, v151, v170, *(__int64 *)v186);
v116 = v226.len;
v121 = runtime_concatstring2(
0,
v226.len,
v216,
(unsigned int)"\\REAL_FLAREON_FLAG.JPG",
22,
v117,
v118,
v119,
v120,
v152,
v171,
*(__int64 *)v186,
*(__int64 *)&v186[8]);
v122 = v215[0];
v126 = os_WriteFile(
v121,
v116,
(int)v226.ptr,
v214[0],
v215[0],
420,
v123,
v124,
v125,
v153,
v172,
*(_slice_uint8 *)v186,
0);
The application retrieves os_UserCacheDir
, which on Windows equals to C:\Users\<username>\AppData\Local
. -> If it errors retrieving the directory, the application terminates. -> If it retrieves the directory succesful, it writes the decrypted flag as a .jpg file to the folder.
Upon opening the .jpg, we find the flag:
FLAG: [email protected]
Extra
Since entering the initial checksums was very tedious, I decided to patch it so it only had to be entered once. This actually made the executable work improperly, which I suspect is either due to CRC, or possibly the ChaCha cipher (which I didn’t fully look into). So patching was not helpful unfortunately :@