Jasper's blog

Flare-on 9 CTF

·Jasper

Introduction

The best and worst events in life are often the ones you didn’t plan for or went into unprepared for. This about sums up my first experience with Flare-on and actual reverse engineering in general.

Going into this years Flare-on I didn’t have any actual reversing knowledge, having touched Ghidra maybe a handful of times and having toyed with some basic binaries for OSCP’s buffer overflow. Now, with Flare-on behind me, I’m looking forward to next year’s Flare-on and finishing it in its entirety, having achieved exponentially more than I could have ever dreamed during this year’s edition!

First and foremost, a huge thanks to Yassir a.k.a. @kladblokje88, and an unnamed friend which will go by his username mryoranimo with whom I participated in these challenges with. mryoranimo is a lot more experienced in reversing than me and helped explain things when my peanut brain couldn’t comprehend something. I probably wouldn’t have made it this far without you, so a big thank you for that <3.

With the life story covered, let’s dig into this years Flare-on!

Challenge #1: Flaredle

Flare-on 9 Flardle challenge description

Upon clicking the play live link the website shows a Wordle clone as the challenge description hinted at. Unlike the official Wordle, this clone asks for a lot more characters for each guess. This is probably where the “is too hard to beat without cheating” part of the challenge description comes into play:

Flare-on 9 Flaredle with two guesses being shown

After entering some guesses it appears to act just like the original Wordle game, but due the increased word length it will much more difficult to brute-force shared letter combinations among English words. Onto the source code we go instead.

Looking in script.js the following section of code checks whether we have entered the desired word:

 1if (guessString === rightGuessString) {
 2  let flag = rightGuessString + "@flare-on.com";
 3  toastr.options.timeOut = 0;
 4  toastr.options.onclick = function () {
 5    alert(flag);
 6  };
 7  toastr.success("You guessed right! The flag is " + flag);
 8
 9  guessesRemaining = 0;
10  return;
11} else {
12  guessesRemaining -= 1;
13  currentGuess = [];
14  nextLetter = 0;
15
16  if (guessesRemaining === 0) {
17    toastr.error("You've run out of guesses! Game over!");
18    toastr.info(
19      'Try reverse engineering the code to discover the correct "word"!'
20    );
21  }
22}

Checking the definition of rightGuessString shows the following:

1let rightGuessString = WORDS[CORRECT_GUESS];

With CORRECT_GUESS defined as:

1const CORRECT_GUESS = 57;

Checking the definition of WORDS shows that the words.js file also provides this:

1import { WORDS } from "./words.js";

Thus, checking the words.js file at line the 57th entry shows the correct and single word containing “flare”:

1export const WORDS = ['acetylphenylhydrazine',
2    [snip]
3    'establishmentarianism',
4    'flareonisallaboutcats',
5    'gastroenterocolostomy',
6    [snip]
7    'zygomaticoauricularis',
8	]

Entering the word flareonisallaboutcats shows that this is the correct answer and returns the flag:

Flare-on 9 Flaredle flag returned by correct answer

Challenge #2: Pixel Poker

Flare-on 9 Pixel Poker description

This challenge consisted of two files: an executable named PixelPoker.exe and a text file named readme.txt with the following in it:

Welcome to PixelPoker ^_^, the pixel game that’s sweeping the nation!

Your goal is simple: find the correct pixel and click it

Good luck!

Opening the provided PixelPoker.exe shows an application window with a random array of colours with the challenge being to click the correct one. Clicking around to see what happens the following pop-up got displayed, informing that one needs to try again:

Flare-on 9 Pixel Poker Womp Womp please play again

Seeing as the last challenge took inspiration from the popular Wordle game, perhaps this took inspiration from elsewhere too 😉.

Jokes aside this challenge proved much simpler than initially thought, not requiring tools such as IDA, Ghidra, et al to solve. This “easier than expected” outcome was something that continued throughout Flare-on as I started understanding the hints in the challenge name.

Opening the PixelPoker.exe binary with 7-zip showed a series of directories, including one containing two bitmaps:

Flare-on 9 Pixel Poker 7-zip showing two bitmaps

mryoranimo found some code within the decompiled source in Ghidra doing XOR operations on something we did not quite get just yet. Later, Yassir compared the two images on diffchecker.com and noticed the flag at the bottom of the image:

Flare-on 9 Pixel Poker diffchecker.com with the flag visible

The almost illegible text at the bottom reads w1nN3r_W!NneR_cHick3n_d1nNer@flare-on.com, the flag for this challenge.

Challenge #3: Magic 8 Ball

Flare-on 9 Magic 8 Ball challenge description

Executing the challenge binary Magic8Ball.exe shows a blue application window, within it a Magic 8 Ball one can ask questions:

Flare-on 9 Magic 8 Ball challenge binary

Running the Mandiant FLOSS tool over the binary gives back an interesting strings spelling out gimme flag pls:

Flare-on 9 Magic 8 Ball FLOSS output

Messing around a bit in IDA, trying to figure out how it even all worked, I found a series of if-else statements maybe checking for letters such as L, R, U, and D:

Flare-on 9 IDA showing a series of If Else statements with character codes in them

Ghidra decompiled this function at address 0x4024e0 to the following C++ code:

 1    //snip
 2    if (*(char *)((int)param_1 + 0x159) != '\0') {
 3    uVar1 = *(uint *)((int)param_1 + 0x124);
 4    ppcVar4 = this;
 5    if (0xf < uVar1) {
 6      ppcVar4 = (char **)*this;
 7    }
 8    if (*(char *)ppcVar4 == 'L') {
 9      ppcVar4 = this;
10      if (0xf < uVar1) {
11        ppcVar4 = (char **)*this;
12      }
13      if (*(char *)((int)ppcVar4 + 1) == 'L') {
14        ppcVar4 = this;
15        if (0xf < uVar1) {
16          ppcVar4 = (char **)*this;
17        }
18        if (*(char *)((int)ppcVar4 + 2) == 'U') {
19          ppcVar4 = this;
20          if (0xf < uVar1) {
21            ppcVar4 = (char **)*this;
22          }
23          if (*(char *)((int)ppcVar4 + 3) == 'R') {
24            ppcVar4 = this;
25            if (0xf < uVar1) {
26              ppcVar4 = (char **)*this;
27            }
28            if (*(char *)(ppcVar4 + 1) == 'U') {
29              ppcVar4 = this;
30              if (0xf < uVar1) {
31                ppcVar4 = (char **)*this;
32              }
33              if (*(char *)((int)ppcVar4 + 5) == 'L') {
34                ppcVar4 = this;
35                if (0xf < uVar1) {
36                  ppcVar4 = (char **)*this;
37                }
38                if (*(char *)((int)ppcVar4 + 6) == 'D') {
39                  ppcVar4 = this;
40                  if (0xf < uVar1) {
41                    ppcVar4 = (char **)*this;
42                  }
43                  if (*(char *)((int)ppcVar4 + 7) == 'U') {
44                    ppcVar4 = this;
45                    if (0xf < uVar1) {
46                      ppcVar4 = (char **)*this;
47                    }
48                    if (*(char *)(ppcVar4 + 2) == 'L') {
49                      _Str1 = (undefined4 *)((int)param_1 + 0xf8);
50                      if (0xf < *(uint *)((int)param_1 + 0x10c)) {
51                        _Str1 = (undefined4 *)*_Str1;
52                      }
53                      iVar2 = strncmp((char *)_Str1,(char *)((int)param_1 + 0x5c),0xf);
54                      if (iVar2 == 0) {
55                        FUN_00401220(&stack0xffffffc0,this);
56                        FUN_00401a10(param_1,in_stack_ffffffc0);
57                      }
58                      // snip

Putting the entire chain into order shows the following pattern: L L U R U L D U L.

mryoranimo and I tried to enter the pattern of arrow-keys in combination with “gimme flag pls?” to no avail. The next day doing it all again it somehow worked this time, ain’t that fun 🫠.

Doing the shuffle with the pattern, entering the phrase gimme flag pls?, and pressing enter finally returned the flag:

Flare-on 9 Magic 8 Ball Challenge flag returned by challenge

Challenge #4: darn_mice

Flare-on 9 darn_mice challenge description

Executing the binary without any arguments does nothing and just exists the program afterwards it seems:

Flare-on 9 darn_mice binary output with no input

Entering a random input as argument returns an output stating “On your plate, you see four olives. You leave the room, and a mouse EATS one!”

Flare-on 9 darn_mice binary output with random input

However, giving an input of 36 characters or greater will show a different input: “On your plate, you see four olives. No, nevermind”

Flare-on 9 darn_mice 36 character input

I did notice some strange behaviour when solely entering the letter s. The binary returned the following new strings nibble..., When you return, you only: s followed by a random string of characters:

Flare-on 9 darn_mice binary output with 's' as input

This “strange behaviour” that Yassir and I had discovered when entering the letter s turned out to be part of getting to the flag for this challenge. What we did not yet fully grasp was the why for why this specific character returned this gibberish of characters. What Yassir and I had manually brute-forced was the first of 36 bytes that needed to be looped through to get to the challenge flag. This array of 36 bytes is located at offset 0x40100 and is used in a function which takes the value at the current offset, adds our input to it, and then executes it as if it were a valid instruction. The decompiled and annotated code produced by Ghidra can are down below:

 1void __cdecl FUN_00401000(PUCHAR param_1)
 2// snip
 3
 4// array of 36 bytes
 5  byte_array[0] = 'P';
 6  byte_array[1] = 0x5e;
 7  byte_array[2] = 0x5e;
 8  byte_array[3] = 0xa3;
 9  byte_array[4] = 0x4f;
10  byte_array[5] = 0x5b;
11  byte_array[6] = 0x51;
12  byte_array[7] = 0x5e;
13  byte_array[8] = 0x5e;
14  byte_array[9] = 0x97;
15  byte_array[10] = 0xa3;
16  byte_array[11] = 0x80;
17  byte_array[12] = 0x90;
18  byte_array[13] = 0xa3;
19  byte_array[14] = 0x80;
20  byte_array[15] = 0x90;
21  byte_array[16] = 0xa3;
22  byte_array[17] = 0x80;
23  byte_array[18] = 0x90;
24  byte_array[19] = 0xa3;
25  byte_array[20] = 0x80;
26  byte_array[21] = 0x90;
27  byte_array[22] = 0xa3;
28  byte_array[23] = 0x80;
29  byte_array[24] = 0x90;
30  byte_array[25] = 0xa3;
31  byte_array[26] = 0x80;
32  byte_array[27] = 0x90;
33  byte_array[28] = 0xa3;
34  byte_array[29] = 0x80;
35  byte_array[30] = 0x90;
36  byte_array[31] = 0xa2;
37  byte_array[32] = 0xa3;
38  byte_array[33] = 0x6b;
39  byte_array[34] = 0x7f;
40  byte_array[35] = 0;
41
42  // snip
43  sVar1 = _strlen((char *)param_1);
44  uVar3 = SUB41(pcVar4,0);
45  if ((sVar1 == 0) || (0x23 < sVar1)) {
46    FUN_00401240((wchar_t *)s_No,_nevermind._0041905c);
47    uVar2 = extraout_DL;
48  }
49  else {
50    FUN_00401240((wchar_t *)s_You_leave_the_room,_and_a_mouse_E_0041906c);
51    for (local_30 = 0;
52        ((uVar3 = SUB41(pcVar4,0), local_30 < 0x24 && (byte_array[local_30] != '\0')) &&
53        (param_1[local_30] != '\0')); local_30 = local_30 + 1) {
54      pcVar4 = (code *)VirtualAlloc((LPVOID)0x0,0x1000,0x3000,0x40);
55      *pcVar4 = (code)(byte_array[local_30] + param_1[local_30]);
56      (*pcVar4)();
57      FUN_00401240((wchar_t *)s_Nibble..._00419098);
58    }
59    FUN_00401240((wchar_t *)s_When_you_return,_you_only:_%s_004190a4);
60    FUN_00401280((int)&DAT_00419000,DAT_00419030,param_1,(PUCHAR)s_salty_004190c4,(int)&DAT_00419000
61                 ,DAT_00419030);
62    FUN_00401240((wchar_t *)&DAT_004190cc);
63    uVar2 = extraout_DL_00;
64  }

I thought I had made a screenshot of the cleaned up function, but apparently I don’t have my Ghidra project nor any screenshots 🥲

The significance of the letter s in this function now became clear to us. The letter S is equivalent to 0xC3 or 195, which is the same as the OP code RET or return:

OpcodeMnemonicDescription
C3RETNear return to calling procedure
CBRETFar return to calling procedure

Using the documentation under c9x.me the RET operation is described as the following:

Transfers program control to a return address located on the top of the stack. The address is usually placed on the stack by a CALL instruction, and the return is made to the instruction that follows the CALL instruction.

The RET OP code doesn’t cause any exceptions or errors as it returns the program flow to the calling procedure. Different OP codes such as ADD or INC change the state and would cause an exception, thus exiting the program. Now, with the behaviour of the character s clarified the challenge description “If it crashes its user error” and the binary output “when you return you only:” now made sense to me. To ensure the program can loop through all 36 bytes in its array, we need to have it execute the RET instruction for each of the 26 steps.

To achieve the above, I used a Cyberchef recipe that took a string of C3s and subtracted that with each item in the array to get the required value for each entry. The Cyberchef recipe and it’s output can be seen down below:

Flare-on 9 darn mice Cyberchef formula

With this recipe the magic string we need to enter to have everything return C3 / RET is: see three, C3 C3 C3 C3 C3 C3 C3! XD. Entering this into the challenge binary spits out a fitting flag of i_w0uld_l1k3_to_RETurn_this_joke@flare-on.com:

Flare-on 9 darn mice flag output

Challenge #5: T8

Flare-on 9 T8 challenge description

First off, ouch. Second, this challenge started off strong with a binary programed to sleep for 43.200 seconds before it would continue:

Flare-on 9 T8 sleep syscall
Flare-on 9 T8 sleep syscall assembly view

As can be seen by my comment, the sleep syscall here didn’t do much for the program itself, and can thus be patched out by changing the OP code to a JMP from a JZ instead:

Flare-on 9 T8 patched sleep syscall assembly view

However, with this patched sleep I wasn’t able to get much further. Looking at the code following this showed a series of uStack variable containing some hex with 0x00 between them. These turned out to be a wchar_t[16] array as Windows uses UTF-16, rather than 8 making any UTF-8 string a series of characters with 0x00 to pad the length to the next character.

Doing this type change changed the data from the following to a neat uStack72 array which I later renamed to possible_domain as it contained flareon.com in the name:

Original uStack array:

Flare-on 9 ustack array before any changes

uStack array changed to wchar_t[16] and renamed to possible_domain:

Flare-on 9 ustack array after changing typse to wchar_t[16] in Ghidra

How I managed to get flareon.com from this was a hint below this array that did an XOR operation over these values.

The straight_up_eight value is decremented by 2 and uses it as its array index to do an XOR 17 operation over it. Doing this operation in Cyberchef with the string 0x7c007e0072003f showed a string of .com:

Flare-on 9 XOR 17 recipe in Cyberchef

This is where I/we got stuck for a while, messing around with the binary and the PCAPs it would create. It seemed that the PCAP showed random numbers for each POST request. We tracked this random number generator back to the function FUN_00421ff4:

Flare-on 9 UA number generator

Looking up those two hex values as their decimal counter parts (214013 & 2531011 respectively) returned a hit by Wikipedia for a linear congruential generator with the following table of parameters in use:

Screenshot of Wikipedia article regarding commonly used values in Linear congruential generators

Source: en.wikipedia.org

Patching this function to always return the value from the PCAP (11950) makes this function look the following:

Flare-on 9 patched LCG value

Yassir wrote the following Python server to emulate the server and how it responded in the PCAP:

 1from http.server import BaseHTTPRequestHandler, HTTPServer
 2import time
 3
 4class RequestHandler(BaseHTTPRequestHandler):
 5    sys_version = ""
 6    server_version = "Apache On 9 "
 7    def date_time_string(self,timestamp=0):
 8        return "Tue, 14 Jun 2022 16:14:36 GMT"
 9    def do_POST(self):
10        if "; CLR" in str(self.headers):
11            print("Request 2")
12            message = "F1KFlZbNGuKQxrTD/ORwudM8S8kKiL5F906YlR8TKd8XrKPeDYZ0HouiBamyQf9/Ns7u3C2UEMLoCA0B8EuZp1FpwnedVjPSdZFjkieYqWzKA7up+LYe9B4dmAUM2lYkmBSqPJYT6nEg27n3X656MMOxNIHt0HsOD0d+"
13        else:
14            print("Request 1")
15            message = "TdQdBRa1nxGU06dbB27E7SQ7TJ2+cd7zstLXRQcLbmh2nTvDm1p5IfT/Cu0JxShk6tHQBRWwPlo9zA1dISfslkLgGDs41WK12ibWIflqLE4Yq3OYIEnLNjwVHrjL2U4Lu3ms+HQc4nfMWXPgcOHb4fhokk93/AJd5GTuC5z+4YsmgRh1Z90yinLBKB+fmGUyagT6gon/KHmJdvAOQ8nAnl8K/0XG+8zYQbZRwgY6tHvvpfyn9OXCyuct5/cOi8KWgALvVHQWafrp8qB/JtT+t5zmnezQlp3zPL4sj2CJfcUTK5copbZCyHexVD4jJN+LezJEtrDXP1DJNg=="
16        self.protocol_version = "HTTP/1.0"
17        self.send_response(200)
18        self.end_headers()
19        self.wfile.write(bytes(message, "utf8"))
20        return
21def run():
22    server = ('', 80)
23    httpd = HTTPServer(server, RequestHandler)
24    httpd.serve_forever()
25run()

Running the binary now shows a pop-up saying “You’re a machine!!!” in the form of a Fatal Application Exit:

Flare-on 9 T8 application error

And this is where we then got stuck again for a long time, trying to figure out what the binary did with the base 64 string sent by the server.

Turns out, we hade solved the challenge at this point an we hadn’t figured that out just yet. The flag got stored somewhere in the application memory when it ran and triggered the exception, all you had to do was look for it:

Flare-on 9 T8 challenge flag in binary memory

Challenge #6: à la mode

Flare-on 9 alamode challenge description

At first glance this was a pure .NET challenge for which you had to perhaps develop some of your own code as the section that did something with the flag was missing:

Flare-on 9 alamode dnspy overview

And the empty flag section:

Flare-on 9 alamode empty function section

The other section of this challenge, and how I solved it, was dissecting the binary inside Ghidra and figuring out what it did during its runtime.

First, the function named dllmain_dispatch at 0x100016e4 makes a call to the function FUN_10001163:

Flare-on 9 alamode Ghidra dllmain_dispatch

FUN10001163 makes a call to renamed function FUN_100012f1 -> FUN_all_available_functions_list if there are two parameters:

Flare-on 9 alamode Ghidra call to function index

This FUN_100012f1 contains a list of definitions and calls to similar functions that, at first, appear obfuscated or transformed into something illegible:

Flare-on 9 alamode Ghidra function index overview

When checking any of the FUN_100014ae functions it becomes clear that the input is processed with XOR 17:

Flare-on 9 alamode Ghidra XOR 17 function FUN_100014ae

We can now decode all these weird char strings with XOR 17 in Cyberchef to get the original name for these functions:

Flare-on 9 alamode Cyberchef XOR 17 formula

Doing this for all the names changes FUN_100014ae to look the following:

Flare-on 9 alamode Ghidra function names FUN_100014ae

The following hours I spent renaming variables in various functions according to Microsoft documentation to match their originals.

In the end, the function and sub-functions of value were stored at FUN_1000100 renamed as FUN_processes_buffer containing the following code:

Flare-on 9 alamode Ghidra buffer processor code of FUN_1000100

The first named function FUN_init_substitution_table or FUN_100011ef contained the following code that sets the substitution table for the crypto algorithm:

Flare-on 9 alamode Ghidra init substitution table code

And a FUN_decrypt function that took the contents of the buffer and decrypted them, using the state reached by the substitution table:

Flare-on 9 alamode Ghidra decrypt function

There were now two ways of solving this, either patching enough of the binary to run it and somehow get the password out of it. Or, option two: rewriting the functions this binary did to decode the buffer and the return us the contents. I opted for number two since it seemed easier to me at the time. Little did I know, that attempting to reconstruct working C++ code from a decompiled source sucks and is much more difficult than I first thought.

In the end, with some help from mryoranimo explaining compiler errors, I used the following C++ code to solve this challenge, emulating its actions and not having to check for a password at all:

  1#include <cstdio>
  2#include <cstdint>
  3
  4/*
  5    Credits to mryoranimo for this C++ code <3
  6*/
  7
  8int main() {
  9    uint8_t ARRAY_8bytes[8] = { 0x55, 0x8b, 0xec, 0x83, 0xec, 0x20, 0xeb, 0xfe, };
 10	uint8_t dest[12] = { 0x3e, 0x39, 0x51, 0xfb, 0xa2, 0x11, 0xf7, 0xb9, 0x2c, 0x00, 0x00, 0x00, };
 11	uint8_t flag[32] = { 0xe1, 0x60, 0xa1, 0x18, 0x93, 0x2e, 0x96, 0xad, 0x73, 0xbb, 0x4a, 0x92, 0xde, 0x18, 0x0a, 0xaa, 0x41, 0x74, 0xad, 0xc0, 0x1d, 0x9f, 0x3f, 0x19, 0xff, 0x2b, 0x02, 0xdb, 0xd1, 0xcd, 0x1a, 0x00, };
 12
 13    uint8_t iVar1;
 14	uint8_t iVar2;
 15    uint32_t offset;
 16    uint8_t iVar3;
 17	uint8_t uVar3;
 18	uint32_t iVar4;
 19	uint32_t uVar4;
 20	uint32_t uVar2;
 21
 22    /*
 23        named local_40c in Ghidra
 24        size is 258 because FUN_init_substitution_table does + 2 while i < 256
 25        i will reach 256 and run again, making it
 26    */
 27    uint8_t table[258] = { 0 };
 28
 29    printf("FLAREON9 - challenge #6 a la mode\n\n");
 30
 31    table[0] = 0;
 32    table[1] = 0;
 33
 34    offset = 0;
 35
 36    do {
 37        table[offset + 2] = offset;
 38        table[offset + 2 + 1] = offset + 1;
 39        table[offset + 2 + 2] = offset + 2;
 40        table[offset + 2 + 3] = offset + 3;
 41        offset = offset + 4;
 42    } while (offset < 256);
 43
 44	iVar2 = 0;
 45    uVar3 = 0;
 46    offset = 0;
 47	iVar4 = 0;
 48
 49	do {
 50		offset = table[iVar4 + 2];
 51		uVar3 = (uint8_t) (ARRAY_8bytes[iVar2] + (char) uVar3 + (char) offset);
 52		table[iVar4 + 2] = table[uVar3 + 2];
 53		iVar1 = iVar2 + 1;
 54		table[uVar3 + 2] = offset;
 55		iVar4 = iVar4 + 1;
 56		iVar2 = 0;
 57		if (iVar1 < 8) {
 58			iVar2 = iVar1;
 59		}
 60	} while (iVar4 < 256);
 61
 62	uint32_t i;
 63	uint8_t uVar1;
 64	char cVar3;
 65
 66	i = 0;
 67	uint8_t local_4;
 68	local_4 = table[1];
 69	uVar4 = table[0];
 70
 71	if (0 < 9) {
 72		do {
 73			uVar4 = (char) uVar4 + 1;
 74			uVar1 = table[uVar4 + 2];
 75			cVar3 = (char)uVar1;
 76			local_4 = (uint8_t) ((char) uVar1 + (char)local_4);
 77			uVar2 = table[local_4 + 2];
 78			table[uVar4 + 2] = uVar2;
 79			table[local_4 + 2] = uVar1;
 80			dest[i] = dest[i] ^ table[(uint8_t)(cVar3 + (char) uVar2) + 2];
 81			i = i + 1;
 82		} while (i < 9);
 83	}
 84
 85	table[0] = uVar4;
 86	table[1] = local_4;
 87
 88	printf("encryption password: %s\n\n", dest);
 89
 90	i = 0;
 91	local_4 = table[1];
 92	uVar4 = table[0];
 93
 94	if (0 < 0x1f) {
 95		do {
 96			uVar4 = uVar4 + 1;
 97			uVar1 = table[uVar4 + 2];
 98			cVar3 = (char)uVar1;
 99			local_4 = (uint8_t) ((char) uVar1 + (char)local_4);
100			uVar2 = table[local_4 + 2];
101			table[uVar4 + 2] = uVar2;
102			table[local_4 + 2] = uVar1;
103			flag[i] = flag[i] ^ table[(uint8_t)(cVar3 + (char) uVar2) + 2];
104			i = i + 1;
105		} while (i < 0x1f);
106	}
107
108	table[0] = uVar4;
109	table[1] = local_4;
110
111    printf("flag:\n");
112	for (i = 0; i < 0x1f; i++) {
113		printf("%c", flag[i]);
114	}
115    printf("\n");
116
117    return 0;
118}

Then compiling this with cl /EHsc main.cpp and running it showed both the password, and the flag:

Flare-on 9 alamode challenge flag output

I would later find out that the challenge name is a reference to mixed mode assembly, a programming concept I hadn’t come across until then.

Challenge #7: anode

Flare-on 9 anode challenge description

This challenge did my head in for a while. Cryptography is a field that fascinates me, but I don’t grasp a lot of the math in it, let alone enough to break or reverse that done by it to get back to the original state/input.

At first there was a big binary that took a while for Ghidra to process; its size was 54 MB. Capital M for Mega.

Flare-on 9 anode big boy binary

When running the binary it asked for a flag, and would return try again when the flag wasn’t valid, I assumed at the time.

Flare-on 9 anode binary output

Running FLOSS over the binary returned an error that there were too many lines for FLOSS to process; a good indicator of what was to come later down the line. Within the output of FLOSS a section of JavaScript handling the flag you entered into it came to my attention:

 1readline.question(`Enter flag: `, flag => {
 2  readline.close();
 3  if (flag.length !== 44) {
 4    console.log("Try again.");
 5    process.exit(0);
 6  var b = [];
 7  for (var i = 0; i < flag.length; i++) {
 8    b.push(flag.charCodeAt(i));
 9  // something strange is happening...
10  if (1n) {
11    console.log("uh-oh, math is too correct...");
12    process.exit(0);
13  var state = 1337;
14  while (true) {
15    state ^= Math.floor(Math.random() * (2**30));
16    switch (state) {
17      case 306211:
18        if (Math.random() < 0.5) {
19          b[30] -= b[34] + b[23] + b[5] + b[37] + b[33] + b[12] + Math.floor(Math.random() * 256);
20          b[30] &= 0xFF;
21        } else {
22          b[26] -= b[24] + b[41] + b[13] + b[43] + b[6] + b[30] + 225;
23          b[26] &= 0xFF;
24        }
25        state = 868071080;
26        continue;
27      case 311489:
28        if (Math.random() < 0.5) {
29          b[10] -= b[32] + b[1] + b[20] + b[30] + b[23] + b[9] + 115;
30          b[10] &= 0xFF;
31        } else {
32          b[7] ^= (b[18] + b[14] + b[11] + b[25] + b[31] + b[21] + 19) & 0xFF;
33        }
34        state = 22167546;
35        continue;
36      case 755154:

With the above JavaScript we can now deduct that the flag needs to be 44 characters in length to pass the initial check, but we also need to patch it to prevent the binary from exiting and skipping its actions.

mryoranimo and his galaxy-brain spent that evening reading the Nexe compiler documentation and creating a Python script via which we would could replace the user code section of the anode.exe binary with whatever JavaScript code we wanted to.

Usage:

Usage: ./patch.py anode.exe resource.js anode-new.exe

Python script:

 1#!/usr/bin/env python3
 2
 3import sys
 4import os
 5import struct
 6from math import floor
 7
 8FOOTER_STRUCT = "<dd"
 9FOOTER_STRUCT_SZ = struct.calcsize(FOOTER_STRUCT)
10
11FOOTER_MAGIC = b"<nexe~~sentinel>"
12
13def main(args):
14    infile = args[0]
15    patchfile = args[1]
16    outfile = args[2]
17
18    infile_sz = os.path.getsize(infile)
19
20    with open(infile, "rb") as infile_stream:
21        infile_stream.seek(infile_sz - FOOTER_STRUCT_SZ)
22        content_sz, resource_sz = [ floor(x) for x in struct.unpack_from(FOOTER_STRUCT, infile_stream.read()) ]
23
24        # seek back to the packed footer
25        infile_stream.seek(0 - len(FOOTER_MAGIC) - FOOTER_STRUCT_SZ, os.SEEK_CUR)
26
27        # from there, seek back to the beginning of the content payload
28        infile_stream.seek(0 - (content_sz + resource_sz), os.SEEK_CUR)
29
30        # now we can read both the content and resource payloads
31        content_buf = infile_stream.read(content_sz)
32        resource_buf = infile_stream.read(resource_sz)
33
34    print("Content size: {}".format(content_sz))
35    print("Resource size: {}".format(resource_sz))
36
37    print("Retrieved content size: {}".format(len(content_buf)))
38    print("Retrieved resource size: {}".format(len(resource_buf)))
39
40    with open(patchfile, "rb") as patchfile_stream:
41        patch = patchfile_stream.read()
42
43    with open(outfile, "wb") as outfile_stream:
44        with open(infile, "rb") as infile_stream:
45            remainder = infile_sz - content_sz - resource_sz - len(FOOTER_MAGIC) - FOOTER_STRUCT_SZ
46            while remainder > 0:
47                buf = infile_stream.read(min(remainder, 8192 * 1024))
48                outfile_stream.write(buf)
49                remainder -= len(buf)
50                print(remainder)
51
52        patch_sz = len(patch)
53        content_buf = content_buf.replace(str(resource_sz).encode(), str(patch_sz).encode())
54        outfile_stream.write(content_buf)
55        outfile_stream.write(patch)
56        outfile_stream.write(FOOTER_MAGIC)
57        outfile_stream.write(struct.pack(FOOTER_STRUCT, len(content_buf), patch_sz))
58
59if __name__ == "__main__":
60    main(sys.argv[1:])

Another thing that we concluded from the JavaScript is that the PRNG used in this binary is probably seeded, and will thus always produce the same results with the same inputs. we concluded this from the JavaScript because it contains statements for the math either being “too correct” or “math.random() is too random”.

mryoranimo explained that it would now be possible to revert the mutations done by the binary if we’d get all the mutations that it did in order, and then walked those back. To get all the states the binary went through we changed the JavaScript case code from executing anything to printing it to STDOUT instead.

Going from this:

 1switch (state) {
 2    case 306211:
 3        if (Math.random() < 0.5) {
 4            b[30] -= b[34] + b[23] + b[5] + b[37] + b[33] + b[12] + Math.floor(Math.random() * 256);
 5            b[30] &= 0xFF;
 6        } else {
 7            b[26] -= b[24] + b[41] + b[13] + b[43] + b[6] + b[30] + 225;
 8            b[26] &= 0xFF;
 9        }
10        state = 868071080;
11        continue;

To now this:

 1switch (state) {
 2      case 306211:
 3        if (Math.random() < 0.5) {
 4          statestring += "b[30] -= b[34] + b[23] + b[5] + b[37] + b[33] + b[12] " + Math.floor(Math.random() * 256);
 5          statestring += "b[30] &= 0xFF;"
 6        } else {
 7          statestring += "b[26] -= b[24] + b[41] + b[13] + b[43] + b[6] + b[30] + 225";
 8          statestring += "b[26] &= 0xFF;"
 9        }
10        state = 868071080;
11        continue;

To do this for all 1024 states I used some Regex magic ^(\s+)(b[\d+].*;)$ and substituting it with $1state_transition_string += "$2";. For the remaining substitutions a find-and-replace for Math.floor(Math.random() * 256 with " + Math.floor(Math.random() * 256) + " would suffice. Now running the binary in a terminal returned the following:

1LARE 10/15/2022 7:38:41 PM
2PS C:\Users\IEUser\Documents\FLAREON9\07_anode\Code > ..\binary\anode-show-states.exe
3Enter flag: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
4b[29] -= b[37] + b[23] + b[22] + b[24] + b[26] + b[10] + 7;b[29] &= 0xFF;b[39] += b[34] + b[2] + b[1] + b[43] + b[20] + b[9] + 79;b[39] &= 0xFF;b[19] ^= (b[26] + b[0] + b[40] + b[37] + b[23] + b[32] + 255) & 0xFF;b[28] ^= (b[1] + b[23] + b[37] + b[31] + b[43] + b[42] + 245) & 0xFF;b[39] += b[42] + b[10] + b[3] + b[41] + b[14] + b[26] + 177;b[39] &= 0xFF;b[9] -= b[20] + b[19] + b[22] + b[5] + b[32] + b[35] + 151;b[9] &= 0xFF;b[14] -= b[4] + b[5] + b[31] + b[15] + b[36] + b[40] + 67;b[14] &= 0xFF;b[33] += b[25] + b[12] + b[14] + b[34] + b[4] + b[36] + 185;b[33] &= 0xFF;
5[snip]
6b[22] += b[16] + b[18] + b[7] + b[23] + b[1] + b[27] + 50;b[22] &= 0xFF;b[39] += b[18] + b[16] + b[8] + b[19] + b[5] + b[23] + 36;b[39] &= 0xFF;
7Try again.

Leaving us with about 1700 odd lines of JavaScript we need to reverse the order of, that looked a little like this:

 1b[29] -= b[37] + b[23] + b[22] + b[24] + b[26] + b[10] + 7;
 2b[29] &= 0xff;
 3b[39] += b[34] + b[2] + b[1] + b[43] + b[20] + b[9] + 79;
 4b[39] &= 0xff;
 5b[19] ^= (b[26] + b[0] + b[40] + b[37] + b[23] + b[32] + 255) & 0xff;
 6b[28] ^= (b[1] + b[23] + b[37] + b[31] + b[43] + b[42] + 245) & 0xff;
 7b[39] += b[42] + b[10] + b[3] + b[41] + b[14] + b[26] + 177;
 8b[39] &= 0xff;
 9b[9] -= b[20] + b[19] + b[22] + b[5] + b[32] + b[35] + 151;
10b[9] &= 0xff;
11b[14] -= b[4] + b[5] + b[31] + b[15] + b[36] + b[40] + 67;
12b[14] &= 0xff;
13b[33] += b[25] + b[12] + b[14] + b[34] + b[4] + b[36] + 185;
14b[33] &= 0xff;

Reversing the document itself was easy with the tac command. Reversing the math done in the document took a little while longer but we got there in the end.

Say we have the following mutation that happens at the end of the chain:

1b[39] &= 0xff;
2b[39] += b[18] + b[16] + b[8] + b[19] + b[5] + b[23] + 36;

The math done here can be reversed/undone by doing the math in reverse order:

1b[39] = ((211 + 106 + 66 + 68 + 102 + 38 + 36) & 255)
2b[39] += 627 & 255
3212 = (x + 627) & 255
4x = 112 ( 627 & 255 = 100) ((100 +112) & 255 = 212)

To then do this for all the 1024 or so cases can be done with the following two regex patterns:

  # flip - with +
  search: ^(b\[\d+\]) -= (.*);$
  replace: $1 = ($1 + ($2)) & 255;

  # flip + with -
  search: ^(b\[\d+\]) += (.*);$
  replace: $1 = ($1 - ($2)) & 255;

Which creates something a little like this:

1b[39] = (b[39] - (b[18] + b[16] + b[8] + b[19] + b[5] + b[23] + 36)) & 255;
2b[22] = (b[22] - (b[16] + b[18] + b[7] + b[23] + b[1] + b[27] + 50)) & 255;
3b[34] = (b[34] - (b[35] + b[40] + b[13] + b[41] + b[23] + b[25] + 14)) & 255;
4b[21] = (b[21] - (b[39] + b[6] + b[0] + b[33] + b[8] + b[40] + 179)) & 255;
5b[11] = (b[11] + (b[32] + b[8] + b[9] + b[34] + b[39] + b[19] + 185)) & 255;
6b[19] ^= (b[0] + b[35] + b[14] + b[30] + b[21] + b[33] + 213) & 0xff;
7b[40] = (b[40] - (b[13] + b[3] + b[43] + b[31] + b[22] + b[25] + 49)) & 255;
8b[17] ^= (b[41] + b[14] + b[43] + b[6] + b[7] + b[28] + 196) & 0xff;

Putting this into a JavaScript program looked the following:

 1var b = [
 2  106, 196, 106, 178, 174, 102, 31, 91, 66, 255, 86, 196, 74, 139, 219, 166,
 3  106, 4, 211, 68, 227, 72, 156, 38, 239, 153, 223, 225, 73, 171, 51, 4, 234,
 4  50, 207, 82, 18, 111, 180, 212, 81, 189, 73, 76,
 5];
 6b[39] = (b[39] - (b[18] + b[16] + b[8] + b[19] + b[5] + b[23] + 36)) & 255;
 7b[22] = (b[22] - (b[16] + b[18] + b[7] + b[23] + b[1] + b[27] + 50)) & 255;
 8b[34] = (b[34] - (b[35] + b[40] + b[13] + b[41] + b[23] + b[25] + 14)) & 255;
 9b[21] = (b[21] - (b[39] + b[6] + b[0] + b[33] + b[8] + b[40] + 179)) & 255;
10b[11] = (b[11] + (b[32] + b[8] + b[9] + b[34] + b[39] + b[19] + 185)) & 255;
11b[19] ^= (b[0] + b[35] + b[14] + b[30] + b[21] + b[33] + 213) & 0xff;
12b[40] = (b[40] - (b[13] + b[3] + b[43] + b[31] + b[22] + b[25] + 49)) & 255;
13b[17] ^= (b[41] + b[14] + b[43] + b[6] + b[7] + b[28] + 196) & 0xff;
14b[38] = (b[38] - (b[20] + b[30] + b[31] + b[8] + b[37] + b[33] + 54)) & 255;
15b[19] ^= (b[3] + b[30] + b[17] + b[15] + b[13] + b[18] + 241) & 0xff;
16b[30] = (b[30] + (b[25] + b[34] + b[36] + b[6] + b[41] + b[11] + 108)) & 255;
17// snip
18b[39] = (b[39] - (b[34] + b[2] + b[1] + b[43] + b[20] + b[9] + 79)) & 255;
19b[29] = (b[29] + (b[37] + b[23] + b[22] + b[24] + b[26] + b[10] + 7)) & 255;
20
21console.log(String.fromCharCode(...b));

Running this returned the flag:

Flare-on 9 anode flag output

And to make sure, entering this flag into the challenge binary returned congrats:

Flare-on 9 anode lag entered in original challenge binary

Challenge #8: Backdoor

From my time spent on Twitter lurking at what others had to mention about the challenges, it seemed that challenge #8 was the hardest one of the bunch. This turned out correct as neither Yassir, mryoranimo nor I managed to solve it.

I didn’t actually look at this challenge a whole lot, as at the time when we got to it I lost a bit of motivation to work on only Flare-on day-in-and-out.

Flare-on 9 Backdoor challenge description

Conclusions

All in all. I made it way further than I could have ever dreamed of going into flare-on pretty much blind and without any prior experience. Battling my way through the 7 challenges that I managed to complete felt rewarding once getting the hints in the challenge name or description and understanding what I had been looking at.With this positive experience behind me, I am looking forward to what next years Flare-on has in store for me.