In this mini-series I describe the solutions of my favorite tasks from this year’s Flare-On competition. To those of you who are not familiar, Flare-On is a marathon of reverse engineering. This year it ran for 4 weeks, and consisted of 9 tasks of increasing difficulty. Collection of my sourcecodes created in the process of solving can be found in my Github repository flareon_2025.
This post covers Task 8 – FlareAuthenticator, which is an obfuscated Windows binary.
Overview
Task 8 is a GUI application written in C++/Qt 6. We are instructed to launch it via a batch script that sets the path to the appropriate DLLs.
@echo off
set QT_QPA_PLATFORM_PLUGIN_PATH=%~dp0
start %~dp0\FlareAuthenticator.exe
For convenience, I simply added this directory to the system PATH.
When we run the application, we see the following window:

At this point, the goal of the task becomes clear: by pressing the on-screen buttons, we are supposed to enter a code that will be verified by the authenticator. If we manage to type the correct value, we will obtain the flag. Otherwise the “Wrong Password” popup shows:

It is worth noticing that button “DEL” activates only when there is an input filled, and the button “OK” – only if the input is of expected size (25 characters).
IDA
Looking inside the application in IDA, we can see that the code is obfuscated. The patterns suggest that some OLLVM-style (LLVM-based) obfuscator was used.
In most functions, attempts to decompile the code do not give us the full result. Only a small fragment is decompiled, and the basic block ends with a jump whose target is calculated on-the-fly.
Below is the first attempt at decompiling the main function:

This is how the calculation of the next block looks at the assembly level:

Similarly, calls to other functions are obfuscated: instead of direct calls, the targets are dynamically computed at runtime.

On top of this, the executable is relatively large, relies on asynchronous GUI-driven actions, and calls into various Qt libraries. All of this makes the control flow very hard to follow.
TinyTracer
In order to grasp this complexity more quickly, I decided to trace the binary’s execution. For this purpose I used my tool TinyTracer (v3.2). As my understanding improved, I kept tweaking its settings.
The goal at this stage was to pinpoint how the input is collected and processed, and what exact condition decides whether the code is considered correct.
Reducing noise
The executable calls many functions from the Qt DLLs. Most of them are related to setting up the GUI and handling event loops, and are irrelevant to the main objective. A preview of the first trace log looks as follows:

The only interesting record in this log is the one where a QMessageBox displaying “Wrong Password” is shown.
8e030;qt6widgets.?warning@QMessageBox@@SA?AW4StandardButton@1@PEAVQWidget@@AEBVQString@@1V?$QFlags@W4StandardButton@QMessageBox@@@@W421@@Z
The rest is dominated by cyclical GUI repaint events, which are not really of interest here.
TinyTracer allows us to reduce this noise by setting exclusions. The latest version can filter out not only individual functions, but also entire libraries, which is especially helpful in this case, where many different APIs are called from the same Qt modules. I set the following list in excluded.txt:
qt6gui
qt6widgets
qt6core
After applying these exclusions, the trace log becomes much cleaner and easier to analyze (example here).
Finding the input collection
While observing the trace log in real time (using baretail), I noticed that each button click caused multiple calls to memcpy and strlen to be appended. I suspected that this behavior must be related to the input collection.
A relevant fragment of the trace log looks like this:
[...]
8e280;vcruntime140.memcpy
8e450;ucrtbase.strlen
8e290;vcruntime140.memmove
8e450;ucrtbase.strlen
[...]
TinyTracer allows us to observe both the input and output of selected functions. To configure which functions we want to watch, we add them to params.txt in the tracer’s installation directory. For this step, I filled it with the following definitions:
ucrtbase;strlen;1
vcruntime140;memmove;3
By default, only the input parameters are watched. If we want to watch the output as well, it can be enabled by editing TinyTracer.ini (details on how to do it are extensively documented on Wiki). Monitoring the log in real time confirms that those calls are used to copy the input content into some storage buffer.

(Full tracelog from this session available here).
We can seek via IDA how those interesting APIs were referenced. It leads to two functions that IDA recognizes as "append_to_string” (append_to_string_0, append_to_string1):

However, IDA can’t follow statically where exactly those functions are called. One option would be to set breakpoints on these functions in a debugger and manually inspect every hit. Instead, we can use TinyTracer to log all occurrences in a more convenient way.
Adding local functions
While we loaded the program into IDA, and allowed for its analysis, IDA recognized by signatures some local functions: statically linked, or wrappers for the Qt functions, and automatically renamed them.
We can export that list with IFL plugin, and feed the CSV to TinyTracer, so that they will appear in the tracelog the same way as the external API calls. Exact instructions on how to do it are given on TinyTracer’s Wiki. The listing should be renamed to: “executable_name.func.csv” (in our case: “FlareAuthenticator.exe.func.csv”).
(The resulting tracelog available here).
Finding the input fetching function
With this configuration, we can clearly see how the text of the clicked button travels through a series of Qt calls and eventually ends up in a memmove that appends a character to an internal buffer. The relevant part of the trace includes calls such as:
[...]
8e190;QObject::sender
8e010;QAbstractButton::text
8df90;QLineEdit::setText
8e0d0;QString::~QString
8e010;QAbstractButton::text
8e1c0;QString::toUtf8
8e0e0;QByteArray::operator char const *
8c820;append_to_string_1
8e450;strlen
8e450;ucrtbase.strlen
strlen:
Arg[0] = ptr 0x000001d295007970 -> L"1"
strlen returned:
0x0000000000000001 = 1
8e290;memmove
8e290;vcruntime140.memmove
memmove:
Arg[0] = ptr 0x0000006f7b1dfd88 -> {\x00\x00\x00\x00\x00\x00\x00\x00}
Arg[1] = ptr 0x000001d295007970 -> L"1"
Arg[2] = 0x0000000000000001 = 1
memmove changed:
Arg[0] = ptr 0x0000006f7b1dfd88 -> L"1"
memmove returned:
ptr 0x0000006f7b1dfd88 -> L"1"
8e0c0;QByteArray::~QByteArray
8e0d0;QString::~QString
8e100;QByteArray::at
8e0c0;QByteArray::~QByteArray
8e0d0;QString::~QString
8ddf0;QWidget::isEnabled
8df20;QWidget::setEnabled
[...]
However, IDA cannot easily determine all the locations from which these functions are called. Because the call targets are calculated on-the-fly in an obfuscated way, following cross-references only leads us to small jump stubs.
In order to pinpoint where that call is really coming from, we can use another TinyTracer’s feature: logging indirect calls (it can be set by modifying the option in TinyTracer.ini: LOG_INDIRECT_CALLS=True). In this case, our log will be enriched with information about all the jumps or calls using registers. In our case of interest, the part of the log that includes input collection starts with the following:
[...]
8dd30;QWidget::event
8dec0;QWidget::paintEvent
89845;[jmp rax] to: 7ff616229872 [7ff6161a0000 + 89872]
8855b;[call rax] to: 7ff6161b2e50 [7ff6161a0000 + 12e50]
8cf70;__alloca_probe
13280;[call rax] to: 7ff6161bfcd0 [7ff6161a0000 + 1fcd0]
132a8;[call rax] to: 7ff6161fdc40 [7ff6161a0000 + 5dc40]
[...]
149de;[call rax] to: 7ff61621aad0 [7ff6161a0000 + 7aad0]
14a02;[call rax] to: 7ff61622df90 [7ff6161a0000 + 8df90]
8df90;QLineEdit::setText
14a28;[call rax] to: 7ff6161d6750 [7ff6161a0000 + 36750]
14a41;[call rax] to: 7ff616224da0 [7ff6161a0000 + 84da0]
[...]
15912;[jmp rax] to: 7ff6161b5914 [7ff6161a0000 + 15914]
15a47;[call rax] to: 7ff61622c820 [7ff6161a0000 + 8c820]
8c820;append_to_string_1
[...]
(Full log available here).
The logged VAs are relative to the base at which our application was loaded, so to make it more convenient to read, and reproducible, we can just disable the dynamic base (PE Optional Header -> Dll Characteristics -> Dll Can move).
The first call after the paint event (QWidget::paintEvent) is at 0x8855b and it leads to the function with RVA = 0x12e50. After that, most of the logged instructions follow in a linear order. We can see calls to other functions, but they return to the addresses that are close to their call-site. At this point, we can suspect that the function collecting the input starts exactly at RVA=0x12e50.
The other way is to pinpoint it is to see the callstack under the debugger – we will use it to confirm the above observations.
First, I set the breakpoint on the offset of QObject::sender function (0x8e190). This function is a convenient anchor because it’s called from the Qt event handling path each time a signal handler runs.

Now, following [RSP] to see where this function was called:

The function that references it is strongly obfuscated, using the patterns that were mentioned earlier. It is split into chunks, connected by jumps that are calculated on the fly. However, from the flow it seems that each jump actually leads to the next line, the function is linear. This observation can be confirmed by the last log produced by TinyTracer. Going up in the code we can view the actual start of the function, that is at RVA = 0x12E50.

Repeating the same steps with the other functions from the list show that all the collected calls lead to the same function. So, at this point, we can assume that this is indeed the function fetching the input (I labeled it as fetch_input).
Reaching the dispatcher
Next, we look at references to fetch_input in IDA. This finally brings us to a function that is not obfuscated and looks like an action dispatcher that routes GUI events to the appropriate handlers:

It is a good start, but it isn’t sufficient to fully understand what is going on inside the application.
My next step will be to connect all the relevant code blocks so that the function can be decompiled as one unit. I also want to resolve at least some of the dynamically computed calls to get a more coherent view of how the components are interconnected and to locate the final verification logic that decides whether the entered code is correct.
Connecting the blocks
At this point we have a tracelog from TinyTracer, that logs all the indirect transitions, including calls and jumps to the calculated addresses that are part of the task’s obfuscation. We can load them into IDA using IFL, and, if the executable was rebased to the same base as the one where the executable was loaded during the tracing session, the VAs will be clickable.
But this is still not a satisfactory result. What I want to achieve is, to have the function that fetches the input fully decompiled, and at least partially cleaned to make the analysis easier. So, just setting labels that illustrate where the particular indirect call leads to, is not enough. I want to have the code patched in IDA, so that the decompiler can reconstruct the pseudocode.
Recognizing the obfuscation patterns
First, I created a script that parsed my tracelog, and obtained comments identifying where the indirect branching leads to, and where the obfuscation pattern begins and ends. To save the time, I used the help of an AI to generate it, describing it the problem in details, including how the obfuscation patterns look like. It resulted in the following script:
The result of the script being run on the file:

As we can see, the script properly identified where the obfuscation pattern begins, and where does it lead to. Now we can make another script, that will patch out those patterns.
Patching obfuscated jumps
I decided to make separate scripts for patching jumps and for patching calls, so that each step will be self-contained and easier to debug. Both scripts relied on the information from the comments, rather than the tag file, so the above script should be run first.
The script for patching jumps:
And the result:

Linearizing the .text section
After running the script, much bigger portion of the function of our interest got decompiled. However, still there were some places where although the jump target was resolved properly, the block where it lead to was not included in the decompiled code, for example:

It was because the target was interpreted as a data:

To avoid such issues, I created another script that just forced interpreting all content of the .text section as a linear code (in case of this executable there are no jumps mid-instruction, so it is reasonable to treat it all as linear content). The script:
Then, I applied both deobfuscating scripts again.
As a result, I’ve got much longer, and possibly complete, decompilation output of the function. You can find it here.
Patching the obfuscated calls
At this point we can see some mathematical operations, and some if/else blocks, that may possibly be the part of input processing logic. However, the view is far from being clear – there are still plenty of calls to offsets calculated on fly. To correlate our tracelog with the output, it would be good to have at least some of them resolved.
I decided to do similar patching as I did with the jumps, but this time apply them on calls, starting with simple calls with one argument, because the related assembly was easy to parse.
The script:
After applying the script:

After this operation, many calls got resolved, but the decompiled version of the function got truncated, because of the issues with interpretation where the function begins and ends. To fix this problem I just re-run the ida_force_linear.py script.
As a result, I’ve got the function fully decompiled, and with some interesting calls filled in. The decompiled code of this stage can be found [here].
Although there is still a lot of obfuscation, we can slowly see the bigger picture emerging. For example, we can see the part of the logic that decides if the full password (that is supposed to be 25 characters long) is inserted:

Also, now looking at the recovered code, we can see where the logged functions that we previously saw are really referenced.
8e190;QObject::sender
8e010;QAbstractButton::text
8df90;QLineEdit::setText
8e0d0;QString::~QString
8e010;QAbstractButton::text
8e1c0;QString::toUtf8
8e0e0;QByteArray::operator char const *
[...]
The previously recorded offset led to the stub of the function:

Now we can finally see the actual references:

Also in case of the function “append_to_string_1” that we tried to locate earlier:

The first argument of the call is some object variable, and the second one – a char buffer that was previously allocated. We will follow how exactly they are used later on.
Finding the accept condition
During the initial trace, we found a call to the “Wrong Password” popup. Now, having the flow less fragmented, we can revisit it, and see where exactly it is called.
The call to the “warning” message happened at RVA = 0x8e030. Unfortunately, this function didn’t get resolved by our earlier script, because it has multiple arguments. So we will just find the caller by setting the breakpoint at the offset, and looking at the callstack. It leads to the RVA = 0x2A500:

Let’s look at this offset in IDA, in the decompiler view:

Having now all the blocks connected, we can easily see, that the caller function is not the fetch_input, but sub_1400202B0. It was another function in the same dispatcher where the fetch_input was called:

Let’s call this function check_input.
Let’s see its decompiled code, and try to look up, what was the condition that lead to the code block including the “Wrong Password” popup to show. There is one if statement:

Inside the first block, that is executed if the condition was set to true, there is an unresolved jump. Keep in mind, that we resolved the jumps basing on the tracelog. That means, this part of the code was not executed during the tracing session. By flipping this flag under the debugger, we can confirm that indeed this is the final check that will lead to displaying the flag:

The condition is set to true a bit above, in the following line:

Now it is clear what is happening. Basically, some value is calculated basing on the input, and stored in the object. Then, in another function it is fetched from the object, and compared to the hardcoded value:
valid_flag = *(_QWORD *)(obj + 0x78) == 0xBC42D5779FEC401LL;// valid input result
Finding out how the object was filled
Still, the code is messy, and it is hard to find out where does this object come from, and how exactly was it filled. We can grab its address at the comparison point, but it is its final state, and we need to go backwards to see how it is filled. My first thought was, that maybe Time Travel Debugging is the way to go… But then, I realized that it can be done with a very simple trick, just using VM snapshots.
- Let’s load the exe under the x64dbg, at the beginning of the password verification. We may fill in one character of the password, just to be sure that all the global objects used for storage of states got initialized.
- Let’s set the breakpoint at the offset where the stored result was fetched (at RVA =
0x21E29), just before being compared with the hardcoded value - Time to make the VM Snapshot
- Now, let’s fill in all the characters, and press OK. The breakpoint got hit. Time to write down the address of the object
- Roll back the snapshot. Go to the address of the object that we previously found. Set the hardware breakpoint on the QWORD.
- Now our breakpoint gets fired in all the lines that modify the object somehow.
Step 4:

Setting the Hardware Breakpoint on access on the noted address (0x014FE28) allowed me to note the following VAs where the buffer was accessed:
0000000140016AD4
0000000140016B04
Both of them are inside the function fetch_input :

We can see that the value stored at this offset changes after each character of the password is added.
The same object was also referenced in the call to QObject::sender:

As well as in append_to_string_1:

And in the length check:

Although we won’t reconstruct its full layout, we have enough information to notice that this is the main object where the input state is stored.
Understanding the verification function
At this point we know that the calculations done on the input happen in the fetch_input function. The value that is calculated basing on the input, is compared to the hardcoded value 0xBC42D5779FEC401LL:
// compare the value from the context with the correct result:
bool is_valid = false;
if (result == 0xBC42D5779FEC401LL) is_valid = true;
Most likely, it will be some equation to solve. In such cases we usually use Z3 solvers. But first we need to precisely reconstruct all the operations and the used variables.
Following the semi-deobfuscated code in IDA we can find where the object, used to store the result, is referenced. The part updating the result, at the end of the single round, can be reconstructed as:
operant_res2 = new_res + result;
operant_res3 = (~new_res | ~result + operant_res2 + 1);
result = (operant_res3 | (operant_res2 - (new_res | result)))
+ (operant_res3 & (operant_res2 - (new_res | result)));
There are also two references to a strongly obfuscated function at VA = 0x140081760 . This function takes two arguments – the first is our object holding the input, and the second is an index. The output is some DWORD. I denoted this function as get_translated. Since deobfuscating it would be tedious, I am gonna try to treat it like a blackbox.
I started by copying from the decompiled view all the lines that seem relevant for input verification. Those are the operations for a single character of input:
translated_out = ((__int64 (__fastcall *)(QObject *, __int64))get_translated)(obj1, _inp_len);
v298 = *(_QWORD *)(*(_QWORD *)((__int64 (__fastcall *)(_QWORD *))((char *)off_1400B6B00 + 0x7B5B49EDE4FB46BDLL))(v349) + 48LL);
v159 = (_QWORD *)((__int64 (*)(void))((char *)off_1400C51F0 + 0x3D096B0D04F9B81ALL))();
v160 = (_QWORD *)sub_14001BEA0(*v159);
v161 = *(_QWORD *)(*(_QWORD *)sub_140079C00(*v160) + 16LL);
v162 = (~(_BYTE)v298 | ~(_BYTE)v161) + v298 + v161 + 1;
res1 = (_QWORD)_inp_len_1 << ((v162 | (unsigned __int8)(v298 + v161 - (v298 | v161))) + (v162 & (unsigned __int8)(v298 + v161 - (v298 | v161))));
v293 = (__int64)((char *)off_1400AB740 + 0x7A26A0DA498380EBLL))(*v182 + 48LL, 0);
v183 = v293 + (_DWORD)res1;
v184 = (~v293 | ~(_DWORD)res1) + v183 + 1;
res2 = v184 | (v183 - (v293 | (unsigned int)res1));
LOWORD(res2) = (v184 | (v183 - (v293 | (unsigned __int16)res1))) + (v184 & (v183 - (v293 | (unsigned __int16)res1)));
res3 = (__int64)get_translated)(obj1, res2) * translated_out;
res4 = res3 + *((_QWORD *)obj1 + 15);
res5 = (~res3 | ~*((_QWORD *)obj1 + 15)) + res4 + 1;
*((_QWORD *)obj1 + 15) = (res5 | (res4 - (res3 | *((_QWORD *)obj1 + 15)))) + (res5 & (res4 - (res3 | *((_QWORD *)obj1 + 15))));
There are some value in this view that are dynamically resolved, so I still don’t have the full picture. Also, I don’t yet know exactly how a single character of the input is processed.
Since the version 3.2, TinyTracer allows for dumping defined disassembly ranges (defined by file: [app_name].disasm_range.csv, optionally with full register context, enabled in the INI file by option: DISASM_CTX=True). I will use this feature to create a log registering what values were in the registers at particular steps.
During this part of the solution, I used the following TinyTracer settings:
DISASM_CTX=True
DISASM_DEPTH=1
Before the second call to get_translated some values are dynamically retrieved from the obfuscated functions, or calculated. We need to dump the arguments that were dynamically retrieved to get the better idea of how they are constructed.
They can be found in the following range (FlareAuthenticator.exe.disasm_range.csv):
1670A,1671E,fetch_operands1
After observing the fragment fetch_operands1 across two different runs, with different inputs:
memmove changed:
Arg[0] = ptr 0x000000000014fe08 -> L"0"
[...]
1670a;[0] mov rcx, qword ptr [rbp+0x678] # disasm start: fetch_operands1
{ rcx = 0x14fdb0; }
16711;[0] mov rdx, qword ptr [rbp+0x400]
{ rdx = 0x100; }
16718;[0] mov al, byte ptr [rbp+0x3bf]
{ rax = 0x140016730; }
1671e;[0] movsx r9d, al # disasm end: fetch_operands1
[...]
memmove changed:
Arg[0] = ptr 0x000000000014fe09 -> L"1"
[...]
1670a;[0] mov rcx, qword ptr [rbp+0x678] # disasm start: fetch_operands1
{ rcx = 0x14fdb0; }
16711;[0] mov rdx, qword ptr [rbp+0x400]
{ rdx = 0x200; }
16718;[0] mov al, byte ptr [rbp+0x3bf]
{ rax = 0x140016731; }
1671e;[0] movsx r9d, al # disasm end: fetch_operands1
[...]
memmove changed:
Arg[0] = ptr 0x000000000014fe0a -> L"2"
[...]
1670a;[0] mov rcx, qword ptr [rbp+0x678] # disasm start: fetch_operands1
{ rcx = 0x14fdb0; }
16711;[0] mov rdx, qword ptr [rbp+0x400]
{ rdx = 0x300; }
16718;[0] mov al, byte ptr [rbp+0x3bf]
{ rax = 0x140016732; }
1671e;[0] movsx r9d, al # disasm end: fetch_operands1
[...]
We can infer that RDX holds index * 0x100, and AL – a character of the input. Pseudocode of the second call to get_translated:
_WORD operand1 = 0x100 * (i + 1);
_BYTE operand2 = inp[i];
operand_1_2_sum = operand2 + (_WORD)operand1;
operant_res1 = (~operand2 | ~(_WORD)operand1) + operand_1_2_sum + 1;
new_res = get_translated(
object1,
(operant_res1 | (unsigned __int16)(operand_1_2_sum - (operand2 | (unsigned __int16)operand1)))
+ (operant_res1 & (unsigned __int16)(operand_1_2_sum - (operand2 | (unsigned __int16)operand1))))
* prev_value;
Overall, the input checking loop can be summarized as (t8_algo.cpp):
result = 0;
for (size_t i = 0; i < 25; i++) {
prev_value = get_translated(object1, (i + 1)); //position-dependent constant (depends only on i, not on the input digit)
_WORD operand1 = 0x100 * (i + 1);
_BYTE operand2 = inp[i];
operand_1_2_sum = operand2 + (_WORD)operand1;
operant_res1 = (~operand2 | ~(_WORD)operand1) + operand_1_2_sum + 1;
new_res = get_translated(
object1,
(operant_res1 | (unsigned __int16)(operand_1_2_sum - (operand2 | (unsigned __int16)operand1)))
+ (operant_res1 & (unsigned __int16)(operand_1_2_sum - (operand2 | (unsigned __int16)operand1))))
* prev_value;
operant_res2 = new_res + result;
operant_res3 = (~new_res | ~result + operant_res2 + 1);
result = (operant_res3 | (operant_res2 - (new_res | result)))
+ (operant_res3 & (operant_res2 - (new_res | result)));
}
// compare the value from the context with the correct result:
bool is_valid = false;
if (result == 0xBC42D5779FEC401LL) is_valid = true;
As I mentioned earlier, I will also try to treat the get_translated function as a blackbox, and dump its input and output.
Let’s start by dumping the context before and after the first call to get_translated. The range that allows for it is defined as following (FlareAuthenticator.exe.disasm_range.csv):
15E99,15E9B,get_translated1
As we can conclude, comparing the first call to get_translated output is always predictable, because it depends only on the index of the character checked. It is easy to dump it. While dumping the context, we get the output in RAX:
{ [rsp] -> 0xa667119fe8; rdi = 0xa66711a3e0; rsi = 0xa66711a3a0; rbp = 0xa6671194d0; rsp = 0xa667119450; rbx = 0xa667119fe8; rdx = 0x2000440400001; rcx = 0xa66711f720; rax = 0x7ff616221760; r8 = 0xa2b91db25ee5355d; r9 = 0x3db0a2bc5bcfa875; r10 = 0x8000; r11 = 0xa6671193b0; r12 = 0xa66711a0a8; r13 = 0xa667119ef8; r14 = 0xa667119ec8; r15 = 0xa66711a3c8; flags = 0x217 [ C=1 P=1 A=1 I=1 ]; }
15e99;[0] call rax # disasm start: get_translated1
{ rdx = 0x60656c99e9c3cadd; rcx = 0x0; rax = 0x279342f; r8 = 0xc53fbd32de138089; r9 = 0x3ac042cd21ec7f77; r10 = 0x4; r11 = 0xfffffffd; flags = 0x202 [ C=0 P=0 A=0 ]; }
15e9b;[0] mov rcx, qword ptr [rbp+0x678] # disasm end: get_translated1
The full dumped list of 25 records can be found here:
However, the second call to get_translated is more problematic. This time the output depends not just on the index, but also on the input. For each 25 indexes there are 10 possible input characters (0 to 9).
Let’s dump it using (FlareAuthenticator.exe.disasm_range.csv):
16766,16768,get_translated2
After dumping arguments to the second call we can see what exactly is passed as an input to the second call (in RDX). Tracelog fragment below:
{ [rsp] -> 0x2f9935a5e8; rdi = 0x2f9935a9e0; rsi = 0x2; rbp = 0x2f99359ad0; rsp = 0x2f99359a50; rbx = 0x2f9935a5e8; rdx = 0x131; rcx = 0x2f9935fd20; rax = 0x7ff616221760; r8 = 0x64ed705730bc6591; r9 = 0x31; r10 = 0x131; r11 = 0xffffffff; r12 = 0x2f9935a6a8; r13 = 0x2f9935a4f8; r14 = 0x2f9935a4c8; r15 = 0x2f9935a9c8; flags = 0x217 [ C=1 P=1 A=1 I=1 ]; }
16766;[0] call rax # disasm start: get_translated2
{ rdx = 0x60656c99e9c3cadd; rcx = 0x0; rax = 0x6235f14; r8 = 0xc53fbd32de138089; r9 = 0x3ac042cd21ec7f77; r10 = 0x4; r11 = 0xfffffffd; flags = 0x202 [ C=0 P=0 A=0 ]; }
[...]
{ rdx = 0x232; rcx = 0x2f9935fd20; rax = 0x7ff616221760; r8 = 0x64ed705730bc6591; r9 = 0x32; r10 = 0x232; r11 = 0xffffffff; flags = 0x217 [ C=1 P=1 A=1 ]; }
16766;[0] call rax # disasm start: get_translated2
{ rdx = 0x60656c99e9c3cadd; rcx = 0x0; rax = 0x806e2b; r8 = 0xc53fbd32de138089; r9 = 0x3ac042cd21ec7f77; r10 = 0x4; r11 = 0xfffffffd; flags = 0x202 [ C=0 P=0 A=0 ]; }
[...]
//last chunk:
{ rdx = 0x1934; rcx = 0x2f9935fd20; rax = 0x7ff616221760; r8 = 0x64ed705730bc6591; r9 = 0x34; r10 = 0x1934; r11 = 0xffffffff; flags = 0x217 [ C=1 P=1 A=1 ]; }
16766;[0] call rax # disasm start: get_translated2
{ rdx = 0x60656c99e9c3cadd; rcx = 0x0; rax = 0x5c643be; r8 = 0xc53fbd32de138089; r9 = 0x3ac042cd21ec7f77; r10 = 0x4; r11 = 0xfffffffd; flags = 0x202 [ C=0 P=0 A=0 ]; }
The second argument of get_translated (after the obj) can be found in RDX. What we can read in the tracelog:
rdx = 0x131 (iteration: 1, input[0] = '1' = 0x31)
rdx = 0x232 (iteration: 2, input[1] = '2' = 0x32)
[...]
rdx = 0x1934 (iteration: 0x19 = 25, input[24] = '4' = 0x34)
When observed across different tracing sessions, we can see the pattern:
arg1 = ((i + 1) * 0x100) | input[i]
We know that at the end of the chunk processing, the output of the above call to get_translated will be multiplied with the result of the previous call to the same function. That means, we can as well dump the ready-made products for each input/index combination. Example (the output is in RAX):
Ranges (FlareAuthenticator.exe.disasm_range.csv):
16772,16776,mul_product
Tracelog fragment:
{ rdx = 0x1933; rcx = 0x30ff94f8d0; rax = 0x7ff616221760; r8 = 0x64ed705730bc6591; r9 = 0x33; r10 = 0x1933; r11 = 0xffffffff; flags = 0x217 [ C=1 P=1 A=1 ]; }
16766;[0] call rax # disasm start: get_translated2
{ rdx = 0x60656c99e9c3cadd; rcx = 0x0; rax = 0x1e2ab7f; r8 = 0xc53fbd32de138089; r9 = 0x3ac042cd21ec7f77; r10 = 0x4; r11 = 0xfffffffd; flags = 0x206 [ C=0 A=0 ]; }
16768;[0] mov rcx, rax # disasm end: get_translated2
{ rcx = 0x1e2ab7f; rax = 0x4775803; }
16772;[0] imul rax, rcx # disasm start: mul_product
{ rax = 0x86bb1a4a4aa7d; }
16776;[0] mov qword ptr [rbp+0x348], rax # disasm end: mul_product
Dumping the values
Dumping all possible values requires the function get_translated to be called 250 times, so of course it has to be done by some script. This function has the following prototype:
__int64 __fastcall get_translated (QObject*, __int64);
When calling function from an exe is required, I usually do it by exporting the function from the original binary, and using it in my own loader. It can be done by converting the executable to a DLL (using exe_to_dll utility), or with the help of libPEConv.
But in this case I was concerned about the first argument: QObject. So far my knowledge on what this object represents is just approximated. I don’t really know how it has to be laid out, so reconstructing it to use in the independent loader may cause problems. But I made a quick experiment in x64dbg, and set the value of this argument to NULL. The function didn’t crash, and it gave the expected value as an output. It means, we can safely skip it. Conversion of the original EXE to DLL also was successful (result here). It means we are ready to import the function. We will just use its simplified prototype, and its RVA known from IDA (0x81760).
This is the tiny dumper that I wrote:
#include <windows.h>
#include <iostream>
#include <string>
#define FUNC_OFFSET 0x81760
__int64 __fastcall get_translated (void*, __int64);
int main(int argc, char* argv[])
{
const char* dll_name = "FlareAuthenticator.dll";
HMODULE mod = LoadLibraryA(dll_name);
if (!mod) {
std::cout << "Failed to load the DLL: " << dll_name << std::endl;
return 1;
}
ULONG_PTR func_ptr = (ULONG_PTR)mod + FUNC_OFFSET;
auto _get_translated = reinterpret_cast<decltype(&get_translated)>(func_ptr);
for (size_t dig = 0; dig < 10; dig++) {
std::cout << "#Digit: " << dig << std::endl;
std::cout << "[" << std::endl;
for (size_t pos = 1; pos <= 25; pos++) {
char inp = dig + '0';
WORD arg = inp | (0x100 * pos);
uint64_t val0 = _get_translated(nullptr, pos);
uint64_t val1 = _get_translated(nullptr, arg);
std::cout << std::hex << "0x" << val0 * val1;
if (pos != 25) std::cout << ", ";
if ((pos % 5) == 0) std::cout << "\n";
}
std::cout << "]";
if (dig < 9) std::cout << ", ";
std::cout << std::endl;
}
return 0;
}
And the result (complete listing can be found here):
#Digit: 0
[
19b3240445aa06, 6f63394844df78, 6df6a4586e71c0, 4ea15fc542c9c0, 3ac57453ace252,
6402164c9fdb19, 69b5253875b96, 9c0d47eac35d2d, 30b9da3c1bfe7, 3a03c1d1d02f29,
1d392355df459c, 8484a22a795e4, be331dd3107ad, 19c7c11da4e4a2, 1796e76685e997,
9bdc1f78073127, cce53b2df56140, 1dc6931c286db2, 139d946e9d6d82, 72a31cfde71ef6,
40a5db3578d586, c427156a9e2860, 537869c92a42d0, 8cc856e432bc50, 20ccd008ad41a
]
[...]
Crafting and solving the final equation
At this point we have the intermediate results dumped, and the reconstructed equation can be simplified to the following form:
bool is_valid = false;
uint64_t result = 0;
for (size_t i = 0; i < 25; i++) {
// inp[i] is digit 0..9
uint64_t new_res = dumped_table[inp[i]][i]; // [digit][pos]
uint64_t operant_res2 = new_res + result;
uint64_t operant_res3 = (~new_res | ~result) + operant_res2 + 1;
uint64_t tmp = operant_res2 - (new_res | result);
result = (operant_res3 | tmp) + (operant_res3 & tmp);
}
if (result == 0xBC42D5779FEC401ULL)
is_valid = true;
Still, the operations at the end can be simplified. Again, we can log those values with TinyTracer, and see what they really are.
By logging result and new_res for several iterations, we can see that the complicated bitwise expression is equivalent to a simple 64-bit addition, so we can safely replace it with the form below.
bool is_valid = false;
uint64_t result = 0;
for (size_t i = 0; i < 25; i++) {
result += dumped_table[inp[i]][i];
}
is_valid = (result == 0xBC42D5779FEC401ULL);
Finally, it is the time to implement the Z3 solver :
from z3 import *
digit_constants = [
# digit_constants[d][pos] : dumped 10x25 table
]
NUM_POS = 25
NUM_DIGIT = 10
target = 0xBC42D5779FEC401
s = Solver()
# unknown digits d[0..24]
digits = [Int(f"d{i}") for i in range(NUM_POS)]
for d in digits:
s.add(d >= 0, d < NUM_DIGIT)
# contrib[pos] = digit_constants[digits[pos]][pos]
contribs = []
for pos in range(NUM_POS):
term = IntVal(0)
for dig in range(NUM_DIGIT):
term = If(digits[pos] == dig,
IntVal(digit_constants[dig][pos]),
term)
contribs.append(term)
total = Sum(contribs)
s.add(total == target)
print("Solving...")
if s.check() == sat:
m = s.model()
sol = [m[d].as_long() for d in digits]
print("Code:", "".join(str(x) for x in sol))
else:
print("No solution")
And the solver’s output:
Solving...
Code: 4498291314891210521449296
It turns out to be the valid code!

s0m3t1mes_1t_do3s_not_m4ke_any_s3n5e@flare-on.com
















































































































































































































