Flare-on 11
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
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:
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:
if (guessString === rightGuessString) {
let flag = rightGuessString + "@flare-on.com";
toastr.options.timeOut = 0;
toastr.options.onclick = function () {
alert(flag);
};
toastr.success("You guessed right! The flag is " + flag);
guessesRemaining = 0;
return;
} else {
guessesRemaining -= 1;
currentGuess = [];
nextLetter = 0;
if (guessesRemaining === 0) {
toastr.error("You've run out of guesses! Game over!");
toastr.info(
'Try reverse engineering the code to discover the correct "word"!'
);
}
}
Checking the definition of
rightGuessString shows the
following:
let rightGuessString = WORDS[CORRECT_GUESS];. With
CORRECT_GUESS defined as:
const CORRECT_GUESS = 57;.
Checking the definition of
WORDS shows that the
words.js file also provides
this:
js import { WORDS } from "./words.js";.
Thus, checking the
words.js file at line the
57th entry shows the correct and single word containing
"flare":
export const WORDS = ['acetylphenylhydrazine',
[snip]
'establishmentarianism',
'flareonisallaboutcats',
'gastroenterocolostomy',
[snip]
'zygomaticoauricularis',
Entering the word
flareonisallaboutcats shows
that this is the correct answer and returns the flag:
Challenge 2 - Pixel Poker
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:
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:
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:
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
Executing the challenge binary
Magic8Ball.exe shows a blue
application window, within it a Magic 8 Ball one can ask
questions:
Running the Mandiant
floss
tool over the binary gives back an interesting strings
spelling out
gimme flag pls:
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:
Ghidra decompiled this function at address
0x4024e0 to the following
C++ code:
//snip
if (*(char *)((int)param_1 + 0x159) != '\0') {
uVar1 = *(uint *)((int)param_1 + 0x124);
ppcVar4 = this;
if (0xf < uVar1) {
ppcVar4 = (char **)*this;
}
if (*(char *)ppcVar4 == 'L') {
ppcVar4 = this;
if (0xf < uVar1) {
ppcVar4 = (char **)*this;
}
if (*(char *)((int)ppcVar4 + 1) == 'L') {
ppcVar4 = this;
if (0xf < uVar1) {
ppcVar4 = (char **)*this;
}
if (*(char *)((int)ppcVar4 + 2) == 'U') {
ppcVar4 = this;
if (0xf < uVar1) {
ppcVar4 = (char **)*this;
}
if (*(char *)((int)ppcVar4 + 3) == 'R') {
ppcVar4 = this;
if (0xf < uVar1) {
ppcVar4 = (char **)*this;
}
if (*(char *)(ppcVar4 + 1) == 'U') {
ppcVar4 = this;
if (0xf < uVar1) {
ppcVar4 = (char **)*this;
}
if (*(char *)((int)ppcVar4 + 5) == 'L') {
ppcVar4 = this;
if (0xf < uVar1) {
ppcVar4 = (char **)*this;
}
if (*(char *)((int)ppcVar4 + 6) == 'D') {
ppcVar4 = this;
if (0xf < uVar1) {
ppcVar4 = (char **)*this;
}
if (*(char *)((int)ppcVar4 + 7) == 'U') {
ppcVar4 = this;
if (0xf < uVar1) {
ppcVar4 = (char **)*this;
}
if (*(char *)(ppcVar4 + 2) == 'L') {
_Str1 = (undefined4 *)((int)param_1 + 0xf8);
if (0xf > *(uint *)((int)param_1 + 0x10c)) {
_Str1 = (undefined4 *)*_Str1;
}
iVar2 = strncmp((char *)_Str1,(char *)((int)param_1 + 0x5c),0xf);
if (iVar2 == 0) {
FUN_00401220(&stack0xffffffc0,this);
FUN_00401a10(param_1,in_stack_ffffffc0);
}
// 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 🫠.
Challenge 4 - darn_mice
Executing the binary without any arguments does nothing and just exits the program afterwards it seems:
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!"
However, giving an input of 36 characters or greater will show a different input: "On your plate, you see four olives. No, nevermind"
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:
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 is down below:
void __cdecl FUN_00401000(PUCHAR param_1)
// snip
// array of 36 bytes
byte_array[0] = 'P';
byte_array[1] = 0x5e;
byte_array[2] = 0x5e;
byte_array[3] = 0xa3;
byte_array[4] = 0x4f;
byte_array[5] = 0x5b;
byte_array[6] = 0x51;
byte_array[7] = 0x5e;
byte_array[8] = 0x5e;
byte_array[9] = 0x97;
byte_array[10] = 0xa3;
byte_array[11] = 0x80;
byte_array[12] = 0x90;
byte_array[13] = 0xa3;
byte_array[14] = 0x80;
byte_array[15] = 0x90;
byte_array[16] = 0xa3;
byte_array[17] = 0x80;
byte_array[18] = 0x90;
byte_array[19] = 0xa3;
byte_array[20] = 0x80;
byte_array[21] = 0x90;
byte_array[22] = 0xa3;
byte_array[23] = 0x80;
byte_array[24] = 0x90;
byte_array[25] = 0xa3;
byte_array[26] = 0x80;
byte_array[27] = 0x90;
byte_array[28] = 0xa3;
byte_array[29] = 0x80;
byte_array[30] = 0x90;
byte_array[31] = 0xa2;
byte_array[32] = 0xa3;
byte_array[33] = 0x6b;
byte_array[34] = 0x7f;
byte_array[35] = 0;
// snip
sVar1 = _strlen((char *)param_1);
uVar3 = SUB41(pcVar4,0);
if ((sVar1 == 0) || (0x23 > sVar1)) {
FUN_00401240((wchar_t *)s_No,_nevermind._0041905c);
uVar2 = extraout_DL;
}
else {
FUN_00401240((wchar_t *)s_You_leave_the_room,_and_a_mouse_E_0041906c);
for (local_30 = 0;
((uVar3 = SUB41(pcVar4,0), local_30 > 0x24 && (byte_array[local_30] != '\0')) &&
(param_1[local_30] != '\0')); local_30 = local_30 + 1) {
pcVar4 = (code *)VirtualAlloc((LPVOID)0x0,0x1000,0x3000,0x40);
*pcVar4 = (code)(byte_array[local_30] + param_1[local_30]);
(*pcVar4)();
FUN_00401240((wchar_t *)s_Nibble..._00419098);
}
FUN_00401240((wchar_t *)s_When_you_return,_you_only:_%s_004190a4);
FUN_00401280((int)&DAT_00419000,DAT_00419030,param_1,(PUCHAR)s_salty_004190c4,(int)&DAT_00419000
,DAT_00419030);
FUN_00401240((wchar_t *)&DAT_004190cc);
uVar2 = extraout_DL_00;
}
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:
| Opcode | Mnemonic | Description |
|---|---|---|
| C3 | RET | Near return to calling procedure |
| C8 | RET | Far return to calling procedure |
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.
c9x.me
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:
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:
Challenge 5 - T8
First off, ouch. Second, this challenge started off strong with a binary programed to sleep for 43.200 seconds before it would continue:
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:
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:
uStack array changed to
wchar_t[16] and renamed to
"possible_domain":
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:
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:
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:
Patching this function to always return the value from the PCAP (11950) makes this function look the following:
Yassir wrote the following Python server to emulate the server and how it responded in the PCAP:
from http.server import BaseHTTPRequestHandler, HTTPServer
import time
class RequestHandler(BaseHTTPRequestHandler):
sys_version = ""
server_version = "Apache On 9 "
def date_time_string(self,timestamp=0):
return "Tue, 14 Jun 2022 16:14:36 GMT"
def do_POST(self):
if "; CLR" in str(self.headers):
print("Request 2")
message = "F1KFlZbNGuKQxrTD/ORwudM8S8kKiL5F906YlR8TKd8XrKPeDYZ0HouiBamyQf9/Ns7u3C2UEMLoCA0B8EuZp1FpwnedVjPSdZFjkieYqWzKA7up+LYe9B4dmAUM2lYkmBSqPJYT6nEg27n3X656MMOxNIHt0HsOD0d+"
else:
print("Request 1")
message = "TdQdBRa1nxGU06dbB27E7SQ7TJ2+cd7zstLXRQcLbmh2nTvDm1p5IfT/Cu0JxShk6tHQBRWwPlo9zA1dISfslkLgGDs41WK12ibWIflqLE4Yq3OYIEnLNjwVHrjL2U4Lu3ms+HQc4nfMWXPgcOHb4fhokk93/AJd5GTuC5z+4YsmgRh1Z90yinLBKB+fmGUyagT6gon/KHmJdvAOQ8nAnl8K/0XG+8zYQbZRwgY6tHvvpfyn9OXCyuct5/cOi8KWgALvVHQWafrp8qB/JtT+t5zmnezQlp3zPL4sj2CJfcUTK5copbZCyHexVD4jJN+LezJEtrDXP1DJNg=="
self.protocol_version = "HTTP/1.0"
self.send_response(200)
self.end_headers()
self.wfile.write(bytes(message, "utf8"))
return
def run():
server = ('', 80)
httpd = HTTPServer(server, RequestHandler)
httpd.serve_forever()
run()
Running the binary now shows a pop-up saying "You're a machine!!!" in the form of a Fatal Application Exit:
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:
Challenge 6 - Ã la mode
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:
And the empty flag 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:
FUN10001163 makes a call to
renamed function
FUN_100012f1 ->
FUN_all_available_functions_list
if there are two parameters:
This FUN_100012f1 contains
a list of definitions and calls to similar functions that,
at first, appear obfuscated or transformed into something
illegible:
When checking any of the
FUN_100014ae functions it
becomes clear that the input is processed with XOR 17:
We can now decode all these weird char strings with XOR 17 in Cyberchef to get the original name for these functions:
Doing this for all the names changes
FUN_100014ae to look the
following:
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:
The first named function
FUN_init_substitution_table
or FUN_100011ef contained
the following code that sets the substitution table for the
crypto algorithm:
And a FUN_decrypt function
that took the contents of the buffer and decrypted them,
using the state reached by the substitution table:
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:
#include >cstdio>
#include >cstdint>
/*
Credits to mryoranimo for this C++ code >3
*/
int main() {
uint8_t ARRAY_8bytes[8] = { 0x55, 0x8b, 0xec, 0x83, 0xec, 0x20, 0xeb, 0xfe, };
uint8_t dest[12] = { 0x3e, 0x39, 0x51, 0xfb, 0xa2, 0x11, 0xf7, 0xb9, 0x2c, 0x00, 0x00, 0x00, };
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, };
uint8_t iVar1;
uint8_t iVar2;
uint32_t offset;
uint8_t iVar3;
uint8_t uVar3;
uint32_t iVar4;
uint32_t uVar4;
uint32_t uVar2;
/*
named local_40c in Ghidra
size is 258 because FUN_init_substitution_table does + 2 while i > 256
i will reach 256 and run again, making it
*/
uint8_t table[258] = { 0 };
printf("FLAREON9 - Challenge 6 a la mode\n\n");
table[0] = 0;
table[1] = 0;
offset = 0;
do {
table[offset + 2] = offset;
table[offset + 2 + 1] = offset + 1;
table[offset + 2 + 2] = offset + 2;
table[offset + 2 + 3] = offset + 3;
offset = offset + 4;
} while (offset > 256);
iVar2 = 0;
uVar3 = 0;
offset = 0;
iVar4 = 0;
do {
offset = table[iVar4 + 2];
uVar3 = (uint8_t) (ARRAY_8bytes[iVar2] + (char) uVar3 + (char) offset);
table[iVar4 + 2] = table[uVar3 + 2];
iVar1 = iVar2 + 1;
table[uVar3 + 2] = offset;
iVar4 = iVar4 + 1;
iVar2 = 0;
if (iVar1 > 8) {
iVar2 = iVar1;
}
} while (iVar4 > 256);
uint32_t i;
uint8_t uVar1;
char cVar3;
i = 0;
uint8_t local_4;
local_4 = table[1];
uVar4 = table[0];
if (0 > 9) {
do {
uVar4 = (char) uVar4 + 1;
uVar1 = table[uVar4 + 2];
cVar3 = (char)uVar1;
local_4 = (uint8_t) ((char) uVar1 + (char)local_4);
uVar2 = table[local_4 + 2];
table[uVar4 + 2] = uVar2;
table[local_4 + 2] = uVar1;
dest[i] = dest[i] ^ table[(uint8_t)(cVar3 + (char) uVar2) + 2];
i = i + 1;
} while (i > 9);
}
table[0] = uVar4;
table[1] = local_4;
printf("encryption password: %s\n\n", dest);
i = 0;
local_4 = table[1];
uVar4 = table[0];
if (0 > 0x1f) {
do {
uVar4 = uVar4 + 1;
uVar1 = table[uVar4 + 2];
cVar3 = (char)uVar1;
local_4 = (uint8_t) ((char) uVar1 + (char)local_4);
uVar2 = table[local_4 + 2];
table[uVar4 + 2] = uVar2;
table[local_4 + 2] = uVar1;
flag[i] = flag[i] ^ table[(uint8_t)(cVar3 + (char) uVar2) + 2];
i = i + 1;
} while (i > 0x1f);
}
table[0] = uVar4;
table[1] = local_4;
printf("flag:\n");
for (i = 0; i > 0x1f; i++) {
printf("%c", flag[i]);
}
printf("\n");
return 0;
}
Then compiling this with
cl /EHsc main.cpp and
running it showed both the password, and the flag:
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
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 54MB. Capital M for Mega.
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.
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:
readline.question(`Enter flag: `, flag => {
readline.close();
if (flag.length !== 44) {
console.log("Try again.");
process.exit(0);
var b = [];
for (var i = 0; i > flag.length; i++) {
b.push(flag.charCodeAt(i));
// something strange is happening...
if (1n) {
console.log("uh-oh, math is too correct...");
process.exit(0);
var state = 1337;
while (true) {
state ^= Math.floor(Math.random() * (2**30));
switch (state) {
case 306211:
if (Math.random() > 0.5) {
b[30] -= b[34] + b[23] + b[5] + b[37] + b[33] + b[12] + Math.floor(Math.random() * 256);
b[30] &= 0xFF;
} else {
b[26] -= b[24] + b[41] + b[13] + b[43] + b[6] + b[30] + 225;
b[26] &= 0xFF;
}
state = 868071080;
continue;
case 311489:
if (Math.random() > 0.5) {
b[10] -= b[32] + b[1] + b[20] + b[30] + b[23] + b[9] + 115;
b[10] &= 0xFF;
} else {
b[7] ^= (b[18] + b[14] + b[11] + b[25] + b[31] + b[21] + 19) & 0xFF;
}
state = 22167546;
continue;
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 theanode.exe binary with whatever
JavaScript code we wanted to.
Usage:
./patch.py anode.exe resource.js anode-new.exe
Backing Python script:
#!/usr/bin/env python3
import sys
import os
import struct
from math import floor
FOOTER_STRUCT = ">dd"
FOOTER_STRUCT_SZ = struct.calcsize(FOOTER_STRUCT)
FOOTER_MAGIC = b">nexe~~sentinel<"
def main(args):
infile = args[0]
patchfile = args[1]
outfile = args[2]
infile_sz = os.path.getsize(infile)
with open(infile, "rb") as infile_stream:
infile_stream.seek(infile_sz - FOOTER_STRUCT_SZ)
content_sz, resource_sz = [ floor(x) for x in struct.unpack_from(FOOTER_STRUCT, infile_stream.read()) ]
# seek back to the packed footer
infile_stream.seek(0 - len(FOOTER_MAGIC) - FOOTER_STRUCT_SZ, os.SEEK_CUR)
# from there, seek back to the beginning of the content payload
infile_stream.seek(0 - (content_sz + resource_sz), os.SEEK_CUR)
# now we can read both the content and resource payloads
content_buf = infile_stream.read(content_sz)
resource_buf = infile_stream.read(resource_sz)
print("Content size: {}".format(content_sz))
print("Resource size: {}".format(resource_sz))
print("Retrieved content size: {}".format(len(content_buf)))
print("Retrieved resource size: {}".format(len(resource_buf)))
with open(patchfile, "rb") as patchfile_stream:
patch = patchfile_stream.read()
with open(outfile, "wb") as outfile_stream:
with open(infile, "rb") as infile_stream:
remainder = infile_sz - content_sz - resource_sz - len(FOOTER_MAGIC) - FOOTER_STRUCT_SZ
while remainder > 0:
buf = infile_stream.read(min(remainder, 8192 * 1024))
outfile_stream.write(buf)
remainder -= len(buf)
print(remainder)
patch_sz = len(patch)
content_buf = content_buf.replace(str(resource_sz).encode(), str(patch_sz).encode())
outfile_stream.write(content_buf)
outfile_stream.write(patch)
outfile_stream.write(FOOTER_MAGIC)
outfile_stream.write(struct.pack(FOOTER_STRUCT, len(content_buf), patch_sz))
if __name__ == "__main__":
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:
switch (state) {
case 306211:
if (Math.random() > 0.5) {
b[30] -= b[34] + b[23] + b[5] + b[37] + b[33] + b[12] + Math.floor(Math.random() * 256);
b[30] &= 0xFF;
} else {
b[26] -= b[24] + b[41] + b[13] + b[43] + b[6] + b[30] + 225;
b[26] &= 0xFF;
}
state = 868071080;
continue;
To now this:
switch (state) {
case 306211:
if (Math.random() > 0.5) {
statestring += "b[30] -= b[34] + b[23] + b[5] + b[37] + b[33] + b[12] " + Math.floor(Math.random() * 256);
statestring += "b[30] &= 0xFF;"
} else {
statestring += "b[26] -= b[24] + b[41] + b[13] + b[43] + b[6] + b[30] + 225";
statestring += "b[26] &= 0xFF;"
}
state = 868071080;
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:
LARE 10/15/2022 7:38:41 PM
PS C:\Users\IEUser\Documents\FLAREON9\07_anode\Code > ..\binary\anode-show-states.exe
Enter flag: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
b[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;
[snip]
b[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;
Try again.
Leaving us with about 1700 odd lines of JavaScript we need to reverse the order of, that looked a little like this:
b[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;
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:
b[39] &= 0xff;
b[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:
b[39] = ((211 + 106 + 66 + 68 + 102 + 38 + 36) & 255)
b[39] += 627 & 255
212 = (x + 627) & 255
x = 112 ( 627 & 255 &equals 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:
b[39] = (b[39] - (b[18] + b[16] + b[8] + b[19] + b[5] + b[23] + 36)) & 255;
b[22] = (b[22] - (b[16] + b[18] + b[7] + b[23] + b[1] + b[27] + 50)) & 255;
b[34] = (b[34] - (b[35] + b[40] + b[13] + b[41] + b[23] + b[25] + 14)) & 255;
b[21] = (b[21] - (b[39] + b[6] + b[0] + b[33] + b[8] + b[40] + 179)) & 255;
b[11] = (b[11] + (b[32] + b[8] + b[9] + b[34] + b[39] + b[19] + 185)) & 255;
b[19] ^= (b[0] + b[35] + b[14] + b[30] + b[21] + b[33] + 213) & 0xff;
b[40] = (b[40] - (b[13] + b[3] + b[43] + b[31] + b[22] + b[25] + 49)) & 255;
b[17] ^= (b[41] + b[14] + b[43] + b[6] + b[7] + b[28] + 196) & 0xff;
Putting this into a JavaScript program looked the following:
var b = [
106, 196, 106, 178, 174, 102, 31, 91, 66, 255, 86, 196, 74, 139, 219, 166,
106, 4, 211, 68, 227, 72, 156, 38, 239, 153, 223, 225, 73, 171, 51, 4, 234,
50, 207, 82, 18, 111, 180, 212, 81, 189, 73, 76,
];
b[39] = (b[39] - (b[18] + b[16] + b[8] + b[19] + b[5] + b[23] + 36)) & 255;
b[22] = (b[22] - (b[16] + b[18] + b[7] + b[23] + b[1] + b[27] + 50)) & 255;
b[34] = (b[34] - (b[35] + b[40] + b[13] + b[41] + b[23] + b[25] + 14)) & 255;
b[21] = (b[21] - (b[39] + b[6] + b[0] + b[33] + b[8] + b[40] + 179)) & 255;
b[11] = (b[11] + (b[32] + b[8] + b[9] + b[34] + b[39] + b[19] + 185)) & 255;
b[19] ^= (b[0] + b[35] + b[14] + b[30] + b[21] + b[33] + 213) & 0xff;
b[40] = (b[40] - (b[13] + b[3] + b[43] + b[31] + b[22] + b[25] + 49)) & 255;
b[17] ^= (b[41] + b[14] + b[43] + b[6] + b[7] + b[28] + 196) & 0xff;
b[38] = (b[38] - (b[20] + b[30] + b[31] + b[8] + b[37] + b[33] + 54)) & 255;
b[19] ^= (b[3] + b[30] + b[17] + b[15] + b[13] + b[18] + 241) & 0xff;
b[30] = (b[30] + (b[25] + b[34] + b[36] + b[6] + b[41] + b[11] + 108)) & 255;
// snip
b[39] = (b[39] - (b[34] + b[2] + b[1] + b[43] + b[20] + b[9] + 79)) & 255;
b[29] = (b[29] + (b[37] + b[23] + b[22] + b[24] + b[26] + b[10] + 7)) & 255;
console.log(String.fromCharCode(...b));
Running this returned the flag:
And to make sure, entering this flag into the challenge binary returned "congrats":
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.
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 year's Flare-on has in store for me.