Post

Cracking the Flare-On 11 CTF 2024: Challenge 2 - Checksum

We recently came across a silly executable that appears benign. It just asks us to do some math… From the strings found in the sample, we suspect there are more to the sample than what we are seeing. Please investigate and let us know what you find!

Challenge description

2 - checksum

We recently came across a silly executable that appears benign. It just asks us to do some math… From the strings found in the sample, we suspect there are more to the sample than what we are seeing. Please investigate and let us know what you find!

Run the Challenge

Run checksum.exe, which will display a console application and prompt us with a series of math questions. If the answers are correct, it will repeat the questions a few times, followed by a “Checksum” prompt for input. If any math answers or the checksum input are incorrect, the application terminates without a trace.

Analysis with Ghidra

For this challenge, we’ll use Ghidra for static analysis, as it yields better results than IDA. Once we load checksum.exe into Ghidra, it displays the Ghidra Import Results Summary dialog as shown below:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Project File Name:                  checksum.exe
Language ID:                        x86:LE:64:default (4.1)
Compiler ID:                        golang
Processor:                          x86
Endian:                             Little
# of Symbols:                       2368
Golang BuildId:                     xFQuXGyZW2wTW9jhnCpK/61yJviL_t4EUhqtApiAn/8Fno1G69FrZCFysLpoB_/sO-mbcYoNv-6X3MFMSBk
Golang app path:                    flareon/chuong/checksum
Golang dep[   0]:                   golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
Golang dep[   1]:                   golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
Golang go version:                  1.22.2
Golang main package path:           flareon/chuong/checksum
Golang main package version:        (devel)
...

As we can see, this console app is written in the Go language, version 1.22.2, and the author of this challenge is chuong (more precisely, chuong.dong, as you’ll later see in Ghidra with a path like Golang source: C:/Users/chuong.dong/Exclusions/check_sum/checksum.go:40).

Once Ghidra completes its analysis, let’s hop into the Strings window (Window -> Defined Strings) and look for where the Checksum: string is being used.

Checksum string XREFs Figure: 2 - Checksum string XREFs

We can see that the Checksum: string in the app console is referenced in the main.main method. In the Symbol Tree on the left panel, alongside the main.main function, there are also main.a and main.b functions. Let’s take a quick look at these as well.

main.b function

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
void main::main.b(error err,string errorString)
{
  internal/abi.Type *piVar1;
  undefined8 uVar2;
  io.Writer w;
  []interface_{} a;
  error err_spill;
  string errorString_spill;
  internal/abi.Type *local_18;
  void *pvStack_10;
  
  piVar1 = (internal/abi.Type *)0x0;
  uVar2 = 0;
  while (&stack0x00000000 <= CURRENT_G.stackguard0) {
    runtime::runtime.morestack_noctxt();
  }
  if (err.tab != (runtime.itab *)0x0) {
    local_18 = piVar1;
    pvStack_10 = (void *)uVar2;
    pvStack_10 = runtime::runtime.convTstring(errorString);
    local_18 = &string___internal/abi.Type;
    w.data = os.Stdout;
    w.tab = &*os.File__implements__io.Writer___runtime.itab;
    a.len = 1;
    a.array = (interface_{} *)&local_18;
    a.cap = 1;
    fmt::fmt.Fprintln(w,a);
    os::os.Exit(0xdeadbeef);
  }
  return;
}

Thanks to the Ghidra decompiler, we can clearly see that this is a helper function that checks if the error err passed in is not nil. If it’s not nil, it prints the string errorString and exits the console app; otherwise, it does nothing. We can rename this function to main.b_exitIfHasError for readability.

main.a function

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
41
42
43
44
45
46
47
bool main::main.a(string checksum)

{
  bool is_correct_checksum;
  uintptr *puVar2_checksum_str_ptr;
  uint xor_key_index;
  int len;
  int loop_counter;
  string checksum_xored_base64;
  []uint8 checksum_xored_array;
  []uint8 src;
  string checksum_spill;
  
  len = checksum.len;
  puVar2_checksum_str_ptr = (uintptr *)checksum.str;
  
  checksum_xored_array = runtime::runtime.makeslice(&uint8___internal/abi.Type,len,len);
  loop_counter = 0;
  while( true ) {
    if (len <= loop_counter) {
      src.len = len;
      src.array = checksum_xored_array.array;
      src.cap = len;
      checksum_xored_base64 =
           encoding/base64::encoding/base64.(*Encoding).EncodeToString
                     (encoding/base64.StdEncoding,src);
      if (checksum_xored_base64.len == 0x58) {
        is_correct_checksum =
             runtime::runtime.memequal
                       ((undefined (*) [32])checksum_xored_base64.str,
                        (undefined (*) [32])
                        "cQoFRQErX1YAVw1zVQdFUSxfAQNRBXUNAxBSe15QCVRVJ1pQEwd/WFBUAlElCFBFUnlaB1ULByR dBEFdfVtWVA=="
                        ,0x58);
      }
      else {
        is_correct_checksum = false;
      }
      return is_correct_checksum;
    }
    xor_key_index = loop_counter + (loop_counter / 0xb + (loop_counter >> 0x3f)) * -0xb;
    if (10 < xor_key_index) break;
    checksum_xored_array.array[loop_counter] =
         *(byte *)((int)puVar2_checksum_str_ptr + loop_counter) ^
         (&DAT_004c8035_xor_key)[xor_key_index];
    loop_counter = loop_counter + 1;
  }
}

This function (with some variables renamed for readability) accepts the checksum as a string, performs transformations, and validates it against a hardcoded base64 value to determine if it’s a valid checksum, returning the result as a boolean.

First, it enters a loop and calculates xor_key_index based on the formula xor_key_index = loop_counter + (loop_counter / 0xb + (loop_counter >> 0x3f)) * -0xb;, where loop_counter increments by 1 on each iteration. This can be simplified to xor_key_index = loop_counter + (loop_counter // 0xb) * -0xb; // loop_counter >> 0x3f will always be zero, resulting in values 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 0, 1, 2, and so on.

Next, it retrieves a byte from the XOR key array DAT_004c8035_xor_key. Navigating to this address, we see it’s a hardcoded string: FlareOn2024. Each byte from the XOR key is used to XOR with each character in checksum input string.

After XOR-ing all characters in the input checksum, it base64-encodes the result and compares it to cQoFRQErX1YAVw1zVQdFUSxfAQNRBXUNAxBSe15QCVRVJ1pQEwd/WFBUAlElCFBFUnlaB1ULByRdBEFdfVtWVA== to determine validity.

To simplify this process, we can rewrite this logic in Python to find the expected checksum string that satisfies the function’s requirements.

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
# decode_checksum.py
import base64

# Constants
target_base64 = "cQoFRQErX1YAVw1zVQdFUSxfAQNRBXUNAxBSe15QCVRVJ1pQEwd/WFBUAlElCFBFUnlaB1ULByRdBEFdfVtWVA=="
xor_key = b"FlareOn2024"  # ASCII values of the string 'FlareOn2024'

def decode_checksum(transformed):
    checksum = bytearray(len(transformed))
    for i, byte in enumerate(transformed):
        # Apply the reverse transformation logic (same index calculation but reverse XOR)
        xor_key_index = i + (i // 0xb + (i >> 0x3f)) * -0xb        
        if xor_key_index >= len(xor_key):
            raise IndexError("Index out of range in XOR key")
        checksum[i] = byte ^ xor_key[xor_key_index]
    return checksum

def recover_checksum():
    # Step 1: Decode the target base64 string
    transformed_bytes = base64.b64decode(target_base64)

    # Step 2: Reverse the transformation to get the original checksum string
    original_checksum_bytes = decode_checksum(transformed_bytes)

    # Step 3: Convert the byte array back to a string
    original_checksum = original_checksum_bytes.decode('utf-8')

    return original_checksum

# Run the function
checksum = recover_checksum()
print(f"Recovered checksum: {checksum}")

Now, let’s run the Python script to recover the checksum input string that we need to enter:

$ python3 decode_checksum.py
Recovered checksum: 7fd7dd1d0e959f74c133c13abb740b9faa61ab06bd0ecd177645e93b1e3825dd

If we use this input to test the console app again, it still exits after the checksum input without showing any output. It’s time to trace back how main.a is being used. Before that, let’s rename this method to main.a_validate_checksum.

main.main Function

Let’s trace the XREFs of main.a_validate_checksum. It’s being called from the main.main function. Below is the snippet where it’s called:

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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
if (local_188 <= iVar2) {
    file_full_path = runtime::runtime.slicebytetostring((runtime.tmpBuf *)local_1e8,ptr,local_168)
    ;
    if (file_full_path.len == local_10->len) {
        is_valid_checksum =
                runtime::runtime.memequal
                        ((undefined (*) [32])file_full_path.str,(undefined (*) [32])local_10->str,
                        local_10->len);
        if (is_valid_checksum) {
            is_valid_checksum = main.a_validate_checksum(*local_10);
        }
        else {
            is_valid_checksum = false;
        }
    }
    else {
        is_valid_checksum = false;
    }
    if (is_valid_checksum == false) {
        local_88 = &string___internal/abi.Type;
        psStack_80 = &gostr_Maybe_it's_time_to_analyze_the_b;
        w_01.data = os.Stdout;
        w_01.tab = &*os.File__implements__io.Writer___runtime.itab;
        a_03.len = 1;
        a_03.array = (interface_{} *)&local_88;
        a_03.cap = 1;
        fmt::fmt.Fprintln(w_01,a_03);
    }

    userCacheDir = os::os.UserCacheDir();
    userCacheDir_len = userCacheDir.~r0.len;
    local_a8 = userCacheDir.~r0;
    errorString_04.len = 0x13;
    errorString_04.str = (uint8 *)"Fail to get path...";
    main.b_exitIfHasError(userCacheDir.~r1,errorString_04);
    flag_file_name.len = 0x16;
    flag_file_name.str = (uint8 *)"\\REAL_FLAREON_FLAG.JPG";
    a0.len = userCacheDir_len;
    a0.str = local_a8;
    file_full_path = runtime::runtime.concatstring2((runtime.tmpBuf *)0x0,a0,flag_file_name);
    file_data.len = local_180;
    file_data.array = (uint8 *)local_b0;
    file_data.cap = local_178;
    eVar8 = os::os.WriteFile(file_full_path,file_data,0x1a4);

    errorString_05.len = 0x15;
    errorString_05.str = (uint8 *)"Fail to write file...";
    main.b_exitIfHasError(eVar8,errorString_05);
    local_98 = &string___internal/abi.Type;
    psStack_90 = &gostr_Noice!!;
    w_02.data = os.Stdout;
    w_02.tab = &*os.File__implements__io.Writer___runtime.itab;
    a_04.len = 1;
    a_04.array = (interface_{} *)&local_98;
    a_04.cap = 1;
    fmt::fmt.Fprintln(w_02,a_04);
    return;
}

As we can see, once the checksum is validated, it concatenates the os::os.UserCacheDir() path with the file name REAL_FLAREON_FLAG.JPG and writes the file at that location. On Windows, you can find it at C:\Users\[CURRENT_USER]\AppData\Local\REAL_FLAREON_FLAG.JPG.

Navigate to that location, and you’ll find the newly created image with the FLAGGGGGGGGG: Th3_M4tH_Do_b3_mAth1ng@flare-on.com

The flag Figure: 3 - The flag

How about the remaining logic of main.main function?

A top-down approach isn’t always ideal; this time, we used a bottom-up approach, tracing back to avoid unnecessary logic while still retrieving the flag. If you want to understand the entire app’s logic, I’ll leave that exploration up to you! :P

Conclusion

This challenge involves a bit more reversing compared to the first one, as we need to dig into the binary without source code. Additionally, the app is written in Golang, but luckily, the symbols aren’t entirely stripped, and Ghidra provides excellent decompilation, making the reversing process smooth.

This post is licensed under CC BY 4.0 by the author.