20251025011530 - BLG - Flare-on 12 Chall 7 Dead Store Deobfuscation with Binja

Description
This challenge, as well as challenge 8 of Flare-on 12 had been quite the learning journey. As the control flow graph is not as disgusting as those with Control Flow Flattening, I took this opportunity to learn more about Binary Ninja’s Scripting.
This post will mainly focus on the de-obfuscation portion with a quick summary of how to solve each of the challenge after as I am sure there will be many writeups on it. Things won’t be as coherent after this portion.
For challenge 7, participants are provided an executable as well as pcap file. The pcap file contains encrypted command and control like traffic. The traffic contains packets containing things like command ID and commands. There are commands that did things like system recovery, changing key of encryption and exfiltration of password. The goal expected is to extract a password protected zip file, leak the password to the zip and extract the image flag.
Intermediate Language (IL) of Binary Ninja
LIke many decompilers, Binary Ninja lifts binary using their own IR. With different levels of analysis, it achieves different level of abstraction which leads to different level of IL. We can look at the different ILs easily by selecting from the drop down box!

Low Level IL
For Disassembly (not an IL), it just binja disassembling instructions. Low Level IL (LLIL) is the next stage of mostly one to one mapping and more readable (code like).

Medium Level IL
Now, when it comes to Medium Level IL (MLIL), it becomes much easier to track stack locations as we now treat stack location as registers without needing to ever worry about remembering offsets! I believe that this helps with use-def chains and dead store elimination. The following shows the difference between LLIL and MLIL.

High Level IL
With a lot more analysis going on that I intend to take for granted, the translation from MLIL to HLIL becomes very close to pseudocode level. One main observation is that it is not just one operation per line. In HLIL, operations can be chained into single line, make things easier to track even further. By “track”, I refer to either patterns in the displayed instruction or the Abstract Syntax Tree like structure of the different ILs.

Use-Def in Cross Reference
Another feature that I love a lot in Binary Ninja is the ability to see all the use and def in a view on the lower left in the Cross Reference! The example below shows the cross reference for rax_384 which has both definition and usage.

Yet example of cross reference but this time a Dead Store Elimination-able statement.

Key Observations
Control Flow Graph (CFG)
The following shows a zoomed out version of the main function which required force analysis since this was larger than the default settings. We observe that there seem to be many little stubs of basic blocks.

Mixed Boolean Arithmetic Noise (Dead Store Elimination)
In many basic blocks, there seem to contain many boolean arithmetic with huge 32 bits constants. They usually appear in bulks with original code hidden among them. This makes it difficult for reverse engineers to understand algorithms like how Authentication Bearer Token was created. The good news is that the MBA and scary looking operations are noise which can be removed. In the image below in High Level IL (HLIL), Binary Ninja grayed out some lines which indicates variable var_2f4 and var_368 are defined but never used which hints strongly at Dead Store Elimination. I found this weird at first because if it can determine that this is DSE, then why not continue to eliminate but there is an explanation for that.

To perform Dead Store Elimination, we can right click ON the unused variable, go to Dead Store Elimination and click on allow.

Notice how binary ninja would re-analyze before showing the eliminated statement. Naturally, if there is a button for a functionality, you can count on there being an API for automation :D Just flipping to allow for every statement is not sufficient. It will become more apparent after looking at the next observation (Junk conditional statements) and removing them.

Junk Conditional Statements
To observe this a lot better, it is better to switch to HLIL because these little junk conditional statements varies a little which is what I think made Binary Ninja shine. The orange arrow points to “stubs” where a variable is being defined.

At first glance, it seems like these variables are important because they are being used somewhere as seen in the Cross Reference panel/view.

The trick here is that these places of use for those variables are part of the noise from the Mixed Boolean Arithmetic which means whether that variable incremented or decremented, it doesnt matter at all! This makes the parent basic block of those stubs junk!
Deobfuscation
Understanding BNIL Instruction graph
BNIL Instruction Graph is actually a plugin that you can install from the plugin manager. It is super useful in telling you the AST like structure for an instruction. With this, we can do pattern matching with very high confidence, compared to parsing disassembly.

The tree that we are interested in is the HLIL one, which is the left most of the following image. It describes that the current instruction is of operation HLIL_ASSIGN with both dest and src. Not just that, we know that the destination contains a var along with its name and type. As for the src, we know that the src has operation of HLIL_ADD which has attribute of left and right where left is HLIL_VAR and right has constant of 0x1.

Removing Junk Conditional Statements
We can now start writing code. To help with auto-completion in Visual Studio Code, we can add the following two lines:
from binaryninja import BinaryViewbv:BinaryViewBinaryView contains details about the loaded binary. When we load the binary, we can access these information via the python console in Binja and do not need to create a new binary view.
Conditions
- Work in HLIL because those stub would be highly simplified to just one statement
- Check for basic block with just 1 incoming and outgoing edge
- Check that the basic block has just one instruction
- If we find such BB, then check its parent
- If the parent has 1 outgoing edge, continue searching
- If the parent has 2, we want the parent to “never branch”
- This is binja’s way of doing unconditional jump to the false branch
Script
Python API is super easy to read and understand from binary ninja’s documentation :D They are very digestable and intuitive after some practice!
Getting Current Function
If the cursor is not active in the UI, then let it use the hardcoded address. This shows two methods in which we can get our function object.
funcs = Nonef = current_functionif f is None: funcs = bv.get_functions_containing(current_address) f = funcs[0] if funcs else Nonefunc = fObtaining HLIL Function
Here is how we can easily obtain the HLIL version of the function!
func = func.hlilif not func: print("HLIL not available for the chosen function.") raise SystemExitAfter obtaining function, each function contains a list of basic blocks. Each basic blocks contains instructions.
for basic_block in func: bb:BasicBlock = basic_block ### CHECK FOR 1 INCOMING and 1 OUTGOING EDGE if len(bb.incoming_edges) != 1 or len(bb.outgoing_edges) != 1: continue ### ONLY ONE INSTRTUCTION IN THE STUB THAT WE ARE INTERESTED IN if bb.instruction_count != 1: continue
### OBTAIN THE LIST OF INSTRUCTION AND GETTING THE FIRST (and ONLY) INSTRUCTION instr = list(bb)[0]
### CHECKING OPERATIONS if instr.operation != HighLevelILOperation.HLIL_ASSIGN and instr.operation != HighLevelILOperation.HLIL_VAR_INIT: continue print(instr)So far, these are some of the instructions that we get back.
var_3ec_15 -= 1var_448_14 += 1var_378_13 -= 1var_3ec_15 -= 1var_448_14 -= 1var_35c_11 += 1var_450_19 += 1var_394_11 += 1var_438_13 += 1var_430_14 -= 1var_394_11 -= 1var_464_31 += 1var_468_52 -= 1var_468_52 += 1var_460_46 -= 1var_464_32 += 1var_468_52 += 1data_14047a3b0 -= 1var_460_47 += 1var_468_54 += 1var_464_33 += 1var_464_33 -= 1var_468_54 -= 1var_468_54 += 1data_14047a3b0 += 1var_460_48 -= 1var_460_49 = divs.dp.d(sx.q(var_468_54), var_468_54)var_468_54 -= 1var_468_54 += 1var_460_50 = divs.dp.d(sx.q(var_460_50), var_468_54)var_464_37 -= 1data_14047a3ac += 1Now, lets continue to check the parent basic block. We want to make sure that it has two outgoing edges. Just to be safe, I added extra check to make sure to reduce false positives along the way. Also, I added the never branch, forcing it to be an unconditional jump to that false branch.
in_edge = bb.incoming_edges[0]
parent_bb = in_edge.source incoming_type = in_edge.type print(incoming_type)
# find the opposite branch target in parent's outgoing edge # find the opposite branch target in parent's outgoing edge (robust) opposite_target = None # require parent to have exactly 2 outgoing edges (typical if/else) outs = list(parent_bb.outgoing_edges) if len(outs) == 2: # child_target is the edge that points to this child block child_target = bb.start # pick the other outgoing edge target other = None for out in outs: if out.target.start != child_target: other = out break if other is None: # both outgoing edges point to child? skip continue opposite_target = other.target.start
# get the parent's last HLIL instruction (branch site) parent_last = last_hlil_instr(parent_bb) if parent_last is None: continue branch_addr = parent_last.address
try: if incoming_type == BranchType.TrueBranch: print("INCOMING : TRUE") # child reached by TrueBranch -> force False (never take True) bv.never_branch(branch_addr)
except Exception: # on any failure, skip silently pass else: # parent doesn't have exactly 2 outgoing edges -> skip (conservative) continueWe can simulate the never branch statement by right clicking on the “if” statement, click on patch and click on never branch.
And we can see the changes it made to the CFG

Running Script
Running that would immediately yield huge basic blocks this time, leaving huge chunks of MBA noises with original logic hidden. 
Not just do we see bigger chunks but also more grayed out statements after further analysis which means that can be removed via dead store elimination!

Here is the example of relevant code vs noise:

Removing MBA Noise
Global Variables Blocking
The challenge author most likely purposefully add many of these junks. However, to avoid compiler optimization (Dead Store Elimination), global variables are introduced to different functions. This means that this is not function scoped and therefore the compiler cannot simply perform optimization if other functions are using it as well!

Studying many functions boils it down to just three global variables
14047a3b4 int32_t data_14047a3b4 = 0x014047a3b8 int64_t data_14047a3b8 = 0x014047a3c0 int64_t data_14047a3c0 = 0x0The strategy is now simple. Find all code references to these three global variables and perform a NOP on these instructions. We can easily get code refs by bv.get_code_refs(target_address)

NOP Patching Instruction Using 1 of 3 Globals
After getting those code references, we can perform NOP Patching for those instructions and let binary ninja handle the analysis. The following shows how NOP can be patched. The way used here is more complex (there is a bv.create_nop which was discovered after) but it shows what are some things that are worth taking note of. bv.begin_undo_actions() and bv.commit_undo_actions() allows us to remember the actions that is done, so that we can undo those bulk of actions when we press ctrl-z. Not necessary but it saves time in case the script goes haywire. bv.write is super convenient for us to write into the binary view. The f.reanalyze cause Binary Ninja to reanalyze the function.
def nop_addresses(f, addrs): if not addrs: return 0 bv.begin_undo_actions() for a in sorted(addrs): bv.write(a, b"\x90" * instr_len(a)) bv.commit_undo_actions() f.reanalyze() return len(addrs)Running Script
As for Dead Store Elimination, I passed ChatGPT the documentation and it helped me create the following script. I am not sure how to recursively perform DSE and so I have to make do with pressing the hotkey for running script a few times till all the MBA noise are removed.
# --- config: set these ---FUNC_ADDR = 0x1402cbc00
TARGET_GLOBALS = { 0x14047a3ac, 0x14047a3b0, 0x14047a3b4,}# -------------
from binaryninja import BinaryViewfrom binaryninja.enums import DeadStoreElimination
def get_function_by_addr(addr): if isinstance(addr, str): addr = int(addr, 0) f = bv.get_function_at(addr) if f: return f hits = bv.get_functions_containing(addr) return hits[0] if hits else None
def func_contains_addr(f, a): if hasattr(f, "address_ranges") and f.address_ranges: return any(r.start <= a < r.end for r in f.address_ranges) return any(bb.start <= a < bb.end for bb in f.basic_blocks)
def instr_len(a): n = bv.get_instruction_length(a) return n if n and n > 0 else 1
def collect_code_ref_sites_in_function(f, targets): sites = set() for g in targets: for ref in bv.get_code_refs(g): a = getattr(ref, "address", ref) if func_contains_addr(f, a): sites.add(a) return sites
def nop_addresses(f, addrs): if not addrs: return 0 bv.begin_undo_actions() for a in sorted(addrs): bv.write(a, b"\x90" * instr_len(a)) bv.commit_undo_actions() f.reanalyze() return len(addrs)
def dse_fixed_point(f, verbose=True): total, rounds = 0, 0 while True: rounds += 1 flips = 0 for bb in f.hlil: for instr in bb: for v in getattr(instr, "vars_written", []): var_obj = getattr(v, "var", v) # SSAVariable -> Variable try: if var_obj.dead_store_elimination != DeadStoreElimination.AllowDeadStoreElimination: var_obj.dead_store_elimination = DeadStoreElimination.AllowDeadStoreElimination flips += 1 except Exception: pass f.reanalyze() total += flips if verbose: print(f"[DSE] pass {rounds}: new flips={flips}") if flips == 0: if verbose: print(f"[DSE] {f.name}: converged after {rounds-1} additional pass(es); total flips={total}") return total
def run_full_fixed_point(f): """Repeat: (NOP code-refs) -> (DSE to fixed point) until both make no progress.""" outer = 0 while True: outer += 1 sites = collect_code_ref_sites_in_function(f, TARGET_GLOBALS) patched = nop_addresses(f, sites) print(f"[NOP] round {outer}: patched {patched} instruction(s)") flips = dse_fixed_point(f, verbose=False) print(f"[DSE] round {outer}: total flips this round={flips}") if flips == 0: print(f"[DONE] {f.name}: fully converged after {outer} round(s).") break
def last_hlil_instr(bb): l = list(bb) return l[-1] if l else None
bv:BinaryView = bv
f = current_functionif f is None: funcs = bv.get_functions_containing(current_address) f = funcs[0] if funcs else None
func = fprint(func)func = func.hlilif not func: print("HLIL not available for the chosen function.") raise SystemExit
for basic_block in func: bb:BasicBlock = basic_block
if len(bb.incoming_edges) != 1 or len(bb.outgoing_edges) != 1: continue
if bb.instruction_count != 1: continue
instr = list(bb)[0]
if instr.operation != HighLevelILOperation.HLIL_ASSIGN and instr.operation != HighLevelILOperation.HLIL_VAR_INIT: continue print(instr) in_edge = bb.incoming_edges[0]
parent_bb = in_edge.source incoming_type = in_edge.type print(incoming_type)
# find the opposite branch target in parent's outgoing edge # find the opposite branch target in parent's outgoing edge (robust) opposite_target = None # require parent to have exactly 2 outgoing edges (typical if/else) outs = list(parent_bb.outgoing_edges) if len(outs) == 2: # child_target is the edge that points to this child block child_target = bb.start # pick the other outgoing edge target other = None for out in outs: if out.target.start != child_target: other = out break if other is None: # both outgoing edges point to child? skip continue opposite_target = other.target.start
# get the parent's last HLIL instruction (branch site) parent_last = last_hlil_instr(parent_bb) if parent_last is None: continue branch_addr = parent_last.address try: if incoming_type == BranchType.TrueBranch: print("INCOMING : TRUE") # child reached by TrueBranch -> force False (never take True) bv.never_branch(branch_addr)
except Exception: # on any failure, skip silently pass else: # parent doesn't have exactly 2 outgoing edges -> skip (conservative) continue
run_full_fixed_point(f)After running the script several times to remove MBA noise, the main function is now almost completely deobfuscated.
There is a tail call at 0x14021d524. Right clicking on it and turning off tail call heristic combines other functions which we can then continue to run the script to further deobfuscate it!

There would also be times where after deobfuscation, some function might suddenly think that it takes in another argument which breaks the DSE optimization. To solve that, we can just change the function prototype of the function and remove the last argument.
This is the example (even after inital expected deobfuscation) of a mess up after deobfuscating sub_1402d8440
Just press ‘y’ and remove the last parameter

and it will be fixed again 
Testing Deobfuscator
Before #1

After #1

Before #2

After #2

Brief Summary of Solve
The rest of the post is not meant to exactly be coherent but to jot down some memory when I come back . This may or may not make sense for those who did not attempt the challenge so feel free to skip to the conclusion XD
Debugging
Change time-zone and time to utc+0 and 7AM to set the hour to “06” for my VM. Also changing computer and host name to match TheBoss@THUNDERNODE. After these are set, we can feed back the same inputs and replay the traffic basically. For the replay, I requested for Sir ChatGPT to write a server that allows me to paste the base64 response. Copied a whole bunch of important ones including command to change key. For each of those commands, I went back to look for the newly created keys without needing to analyze how the new keys are created.
Challenge Packet
The first packet sent over is sent to a twelve.flare-on.com:8000, a verification server of sorts. Note that the authentication bearer is required to be able to receive the C2 connection information. However, since this is the first packet, we will need to decrypt the data from the response.

The following shows the de-obfuscated de-compilation of function responsible for creating the authentication bearer token. This shows that the bearer token is actually created via Substitution Box (SBOX).
void xor_5a_plus_tableselection_sub_140058a00(struct_string* arg1, char* arg2, int64_t arg3, uint32_t arg4 @ rax)
if (arg3 == 0) return
int64_t rbx_1 = 0
do arg2[rbx_1] = *((get_byte_contents_sub_1402cedd0(arg1)->.byte_contents[rbx_1] ^ 0x5a) + (rbx_1 + 1).d.b + &data_14046a540) rbx_1 += 1 while (rbx_1 u< arg3)The first parameter contains the string object of the <datetime><username>@<ComputerName>

We can confirm that at this location. This is quite important because later on, we need to understand that the hour at that point of generation is important when creating AES key.

Decrypting Authenticaiton Bearer Token
Successful decryption reveals 2025082006TheBoss@THUNDERNODE where:
- Date: 20250820
- Hour: 06
- Username: TheBoss
- ComputerName: THUNDERNODE
The algorithm around encrypting the d value for the challenge 
With ChatGPT, the inverse of this function is generated perfectly
SBOX = bytes.fromhex( "52 09 6a d5 30 36 a5 38 bf 40 a3 9e 81 f3 d7 fb 7c e3 39 82 9b 2f ff 87 34 8e 43 44 c4 de e9 cb " "54 7b 94 32 a6 c2 23 3d ee 4c 95 0b 42 fa c3 4e 08 2e a1 66 28 d9 24 b2 76 5b a2 49 6d 8b d1 25 " "72 f8 f6 64 86 68 98 16 d4 a4 5c cc 5d 65 b6 92 6c 70 48 50 fd ed b9 da 5e 15 46 57 a7 8d 9d 84 " "90 d8 ab 00 8c bc d3 0a f7 e4 58 05 b8 b3 45 06 d0 2c 1e 8f ca 3f 0f 02 c1 af bd 03 01 13 8a 6b " "3a 91 11 41 4f 67 dc ea 97 f2 cf ce f0 b4 e6 73 96 ac 74 22 e7 ad 35 85 e2 f9 37 e8 1c 75 df 6e " "47 f1 1a 71 1d 29 c5 89 6f b7 62 0e aa 18 be 1b fc 56 3e 4b c6 d2 79 20 9a db c0 fe 78 cd 5a f4 " "1f dd a8 33 88 07 c7 31 b1 12 10 59 27 80 ec 5f 60 51 7f a9 19 b5 4a 0d 2d e5 7a 9f 93 c9 9c ef " "a0 e0 3b 4d ae 2a f5 b0 c8 eb bb 3c 83 53 99 61 17 2b 04 7e ba 77 d6 26 e1 69 14 63 55 21 0c 7d")
# Build inverse SBOXINV = [0]*256for i, v in enumerate(SBOX): INV[v] = iINV = bytes(INV)
def encrypt(plain: bytes) -> bytes: out = bytearray(len(plain)) for i, b in enumerate(plain): out[i] = SBOX[((b ^ 0x5A) + (i + 1)) & 0xFF] return bytes(out)
def decrypt(cipher: bytes) -> bytes: out = bytearray(len(cipher)) for i, c in enumerate(cipher): idx = INV[c] pre = (idx - (i + 1)) & 0xFF out[i] = pre ^ 0x5A return bytes(out)from binascii import unhexlifyprint(decrypt(unhexlify(b"e4b8058f06f7061e8f0f8ed15d23865ba2427b23a695d9b27bc308a26d")))
# b'2025082006TheBoss@THUNDERNODE'Script to decrypt data from challenge
SBOX = [0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, 0x2b, 0xfe, 0xd7, 0xab, 0x76, 0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, 0x47, 0xf0, 0xad, 0xd4, 0xa2, 0xaf, 0x9c, 0xa4, 0x72, 0xc0, 0xb7, 0xfd, 0x93, 0x26, 0x36, 0x3f, 0xf7, 0xcc, 0x34, 0xa5, 0xe5, 0xf1, 0x71, 0xd8, 0x31, 0x15, 0x04, 0xc7, 0x23, 0xc3, 0x18, 0x96, 0x05, 0x9a, 0x07, 0x12, 0x80, 0xe2, 0xeb, 0x27, 0xb2, 0x75, 0x09, 0x83, 0x2c, 0x1a, 0x1b, 0x6e, 0x5a, 0xa0, 0x52, 0x3b, 0xd6, 0xb3, 0x29, 0xe3, 0x2f, 0x84, 0x53, 0xd1, 0x00, 0xed, 0x20, 0xfc, 0xb1, 0x5b, 0x6a, 0xcb, 0xbe, 0x39, 0x4a, 0x4c, 0x58, 0xcf, 0xd0, 0xef, 0xaa, 0xfb, 0x43, 0x4d, 0x33, 0x85, 0x45, 0xf9, 0x02, 0x7f, 0x50, 0x3c, 0x9f, 0xa8, 0x51, 0xa3, 0x40, 0x8f, 0x92, 0x9d, 0x38, 0xf5, 0xbc, 0xb6, 0xda, 0x21, 0x10, 0xff, 0xf3, 0xd2, 0xcd, 0x0c, 0x13, 0xec, 0x5f, 0x97, 0x44, 0x17, 0xc4, 0xa7, 0x7e, 0x3d, 0x64, 0x5d, 0x19, 0x73, 0x60, 0x81, 0x4f, 0xdc, 0x22, 0x2a, 0x90, 0x88, 0x46, 0xee, 0xb8, 0x14, 0xde, 0x5e, 0x0b, 0xdb, 0xe0, 0x32, 0x3a, 0x0a, 0x49, 0x06, 0x24, 0x5c, 0xc2, 0xd3, 0xac, 0x62, 0x91, 0x95, 0xe4, 0x79, 0xe7, 0xc8, 0x37, 0x6d, 0x8d, 0xd5, 0x4e, 0xa9, 0x6c, 0x56, 0xf4, 0xea, 0x65, 0x7a, 0xae, 0x08, 0xba, 0x78, 0x25, 0x2e, 0x1c, 0xa6, 0xb4, 0xc6, 0xe8, 0xdd, 0x74, 0x1f, 0x4b, 0xbd, 0x8b, 0x8a, 0x70, 0x3e, 0xb5, 0x66, 0x48, 0x03, 0xf6, 0x0e, 0x61, 0x35, 0x57, 0xb9, 0x86, 0xc1, 0x1d, 0x9e, 0xe1, 0xf8, 0x98, 0x11, 0x69, 0xd9, 0x8e, 0x94, 0x9b, 0x1e, 0x87, 0xe9, 0xce, 0x55, 0x28, 0xdf, 0x8c, 0xa1, 0x89, 0x0d, 0xbf, 0xe6, 0x42, 0x68, 0x41, 0x99, 0x2d, 0x0f, 0xb0, 0x54, 0xbb, 0x16]
def model(src_hex, count, user): s = bytes.fromhex(src_hex); u = user.encode(); L = len(u) return bytes((((SBOX[s[i]] + 0xFF - i) & 0xFF) ^ u[i % L]) for i in range(count))x = model("085d8ea282da6cf76bb2765bc3b26549a1f6bdf08d8da2a62e05ad96ea645c685da48d66ed505e2e28b968d15dabed15ab1500901eb9da4606468650f72550483f1e8c58ca13136bb8028f976bedd36757f705ea5f74ace7bd8af941746b961c45bcac1eaf589773cecf6f1c620e0e37ac1dfc9611aa8ae6e6714bb79a186f47896f18203eddce97f496b71a630779b136d7bf0c82d560", 0x95, "TheBoss@THUNDERNODE")
print(x)
"""b'{"sta": "excellent", "ack": "peanut@theannualtraditionofstaringatdisassemblyforweeks.torealizetheflagwasjustxoredwiththefilenamethewholetime.com:8080'"""AES Encryption
We can see that ComputeHashAndXorHashPair_sub_140081300 is responsible for the creation of AES Key.
void output_data memset_zero(&output_data, 0x18) void var_ca0 int128_t* input_data_2 = struct_string_copy_sub_140037090(dest: arg2, src: &var_ca0, rsi_82 - rax_5085 ^ rdi_81 ^ rax_4670 - 0x1e7c + rax_4675 * divs.dp.d( (((not.d(rcx_1940 + 0x78f544b8) ^ 0x9461c43e) - 1) | 0x5bf5) ^ 0x159b, 0xfc6d)) void var_c80 ComputeHashAndXorHashPair_sub_140081300(&output_data, input_data_1: retrieve_user_computername_sub_140037060(arg2, &var_c80), input_data_2, &input_data_3) ... ... rax_7998, rcx_3304 = aes_encrypt(&var_358, sub_140001f60(&output_data), &var_58) ...Generation of AES Key
Note that the hour during my testing was 17. To generate the proper key, we can manually set the timezone and time.

Below shows the decompilation of the function responsible for generation of AES Key
int128_t* ComputeHashAndXorHashPair_sub_140081300(int128_t* output_data, struct_string* input_data_1, struct_string* input_data_2, struct_string* input_data_3)
... int64_t r8_1 = SHA256_HASH_sub_140439850(output_hash: input_data_1_, output_length: output_hash__, input_buffer: input_buffer_1, input_length: &input_buffer_1[1])
uint64_t size = input_datda_2_->size uint64_t size_1 = input_data_3_->size
if (0x7fffffffffffffff - size u< size_1) cleanup() noreturn
if (input_datda_2_->capacity u> 0xf) input_datda_2_ = input_datda_2_->.byte_contents.q
if (input_data_3_->capacity u> 0xf) input_data_3_ = input_data_3_->.byte_contents.q
void** output_hash_1 sub_1404354a0(&output_hash_1, size_1, r8_1, input_datda_2_, size, input_data_3_, size_1) int128_t var_98 __builtin_memset(dest: &var_98, ch: 0, count: 0x18) buffer_span(&var_98, 0x20) uint8_t* input_buffer = var_98.q __builtin_memset(dest: input_buffer, ch: 0, count: 0x20) var_98:8.q = &input_buffer[0x20] void** output_hash_2 = &output_hash_1 int64_t var_50
if (var_50 u> 0xf) output_hash_2 = output_hash_1
char* output_hash = &output_hash_1
if (var_50 u> 0xf) output_hash = output_hash_1
void* var_58 SHA256_HASH_sub_140439850(output_hash, output_length: var_58 + output_hash_2, input_buffer, input_length: &input_buffer[0x20])
*output_data = zx.o(0) __builtin_memset(dest: output_data, ch: 0, count: 0x18) buffer_span(output_data, 0x20) int64_t rax_3 = *output_data __builtin_memset(dest: rax_3, ch: 0, count: 0x20) *(output_data + 8) = rax_3 + 0x20 int128_t* rcx_5 = *output_data
if ((rcx_5 u> &input_buffer_1->capacity + 7 || rcx_5 + 0x1f u< input_buffer_1) && (rcx_5 u> &input_buffer[0x1f] || rcx_5 + 0x1f u< input_buffer) && (rcx_5 u> output_data || rcx_5 + 0x1f u< output_data)) *rcx_5 = *input_buffer ^ input_buffer_1->.byte_contents int128_t zmm0_1 zmm0_1.q = input_buffer_1->size zmm0_1:8.q = input_buffer_1->capacity rcx_5[1] = *(input_buffer + 0x10) ^ zmm0_1 else do void* rax_7 = input_buffer_1 + i i[*output_data] = *(input_buffer - input_buffer_1 + rax_7) ^ *rax_7 i = &i[1] while (i u< 0x20)
if (input_buffer != 0) int64_t var_88 void* rdx_8 = var_88 - input_buffer uint8_t* input_buffer_2 = input_buffer
if (rdx_8 u>= 0x1000) rdx_8 += 0x27 input_buffer = *(input_buffer - 8)
if (input_buffer_2 - input_buffer - 8 u> 0x1f) _invalid_parameter_noinfo_noreturn() noreturn
free_(input_buffer, rdx_8)
_free(&output_hash_1)
if (input_buffer_1 != 0) int64_t var_70 void* rdx_10 = var_70 - input_buffer_1 struct_string* input_buffer_3 = input_buffer_1
if (rdx_10 u>= 0x1000) rdx_10 += 0x27 input_buffer_1 = input_buffer_1->__offset(0xfffffffffffffff8).q
if (input_buffer_3 - input_buffer_1 - 8 u> 0x1f) _invalid_parameter_noinfo_noreturn() noreturn
free_(input_buffer_1, rdx_10)
__security_check_cookie(rax_1 ^ &var_e8) return output_dataThe following are renamed to help with understanding
AES256_EncryptBlock_Tbl(forsub_140050560)AES256_CBC_EncryptUpdate(forsub_140050d20)
int128_t* ENCRYPT_USER_COMPUTER_NAME_WITH_VALUE_D_JSON_sub_140066ba0(int128_t* arg1, struct_string* JSON_D_VALUE_FROM_REQUEST, int64_t* USER_COMPUTER_NAME)
void var_278 int64_t rax_1 = __security_cookie ^ &var_278 sub_1402cf340(arg1) struct_string* USER_COMPUTERNAME_stringobject USER_COMPUTERNAME_stringobject.o = zx.o(0) void* var_68 void** rax_1692 = std::basic_string<char,s...ar>,class std::allocator<char> >::end( USER_COMPUTER_NAME, &var_68) void var_60 int32_t rax_1694 int32_t rcx_781 rax_1694, rcx_781 = sub_140431900(&USER_COMPUTERNAME_stringobject, *sub_1402ceea0(USER_COMPUTER_NAME, &var_60), *rax_1692) int64_t i = 0 var_50 void* username_length = var_50.q - USER_COMPUTERNAME_stringobject
if (JSON_D_VALUE_FROM_REQUEST->.byte_contents.__offset(0x8).q != JSON_D_VALUE_FROM_REQUEST->.byte_contents.q) do int32_t rax_3393 rax_3393.b = 0xff - i.b struct_string_append_sub_1402cee00(arg1, (*(*(JSON_D_VALUE_FROM_REQUEST->.byte_contents.q + i) + data_14047a390) + rax_3393.b) ^ USER_COMPUTERNAME_stringobject->.byte_contents[modu.dp .q(0:i, username_length)]) i += 1 while (i u< JSON_D_VALUE_FROM_REQUEST->.byte_contents.__offset(0x8).q - JSON_D_VALUE_FROM_REQUEST->.byte_contents.q)
sub_1402b4a30(&USER_COMPUTERNAME_stringobject) __security_check_cookie(rax_1 ^ &var_278) return arg1When first replaying the packet’s response, I start to see “beaconing”. The first packet is used as a challenge response, checking if server is responding with a decryptable json which is parsed by the binary.
Sample Server
from http.server import BaseHTTPRequestHandler, HTTPServerimport time
class MyHandler(BaseHTTPRequestHandler): def do_GET(self): if self.path == "/good": # Prepare headers self.send_response(200, "OK") self.send_header("Server", "SimpleHTTP/0.6 Python/3.10.11") self.send_header("Date", time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime())) self.send_header("Content-type", "application/json") self.end_headers()
# JSON body body = { "d": "085d8ea282da6cf76bb2765bc3b26549a1f6bdf08d8da2a62e05ad96ea645c685da48d66ed505e2e28b968d15dabed15ab1500901eb9da4606468650f72550483f1e8c58ca13136bb8028f976bedd36757f705ea5f74ace7bd8af941746b961c45bcac1eaf589773cecf6f1c620e0e37ac1dfc9611aa8ae6e6714bb79a186f47896f18203eddce97f496b71a630779b136d7bf0c82d560" }
import json self.wfile.write(json.dumps(body).encode()) else: self.send_error(404, "Not Found")
if __name__ == "__main__": server_address = ("", 8000) # Listen on all interfaces, port 8000 httpd = HTTPServer(server_address, MyHandler) print("Serving on port 8000...") httpd.serve_forever()Decryption
Note that INPUT 3 is the hour and in the pcap, it is hour 06 so peanut06 and the input is TheBoss@THUNDERNODE which gives us
Computing AES Key with sha256(“TheBoss@THUNDERNODE”) XOR sha256(“peanut06”) and IV is 000102030405060708090a0b0c0d0e0f

Decrypting in Cyberchef

Here is the example of a successful decryption:

AES Key Change
As the command continues down the pcap, we would be faced with command id 6:
{"msg": "cmd", "d": {"cid": 6, "dt": 25, "np": "miami"}}We can as easily calculate the different keys (Using “miami06” instead of “peanut06”) but I have TTD with packet replay done earlier, I just extracted the bytes and decrypted the traffic necessary.
Eventually, we find the zip and password.
Getting Password for Flag
tcp stream 74Key : cf 92 3b e8 da 52 63 11 13 75 2d 5b 32 ce f8 0b 9d 2b da da c8 51 30 81 1b ee 86 86 8f e9 72 04which decrypts the traffic to:
{"fc":"RW1haWw6IEJvcm5Ub1J1biE3NQ0KQmFuazogVGhlUml2ZXIjIzE5ODANCkNvbXB1dGVyTG9naW46IFRoZUJvc3NNYW4NCk90aGVyOiBUaGVCaWdNQG4xOTQyIQ0K","sta":"success"}Decoding the fc (File content), we get the password list.
=========================Email: BornToRun!75Bank: TheRiver##1980ComputerLogin: TheBossManOther: TheBigM@n1942! <---- use this one=========================Unzipping the file with the password reveals the image:

Server Scripts
Remember to set the host file to route to localhost as well as time and its timezone.
Verification Server
When setup properly, we should get the
from http.server import BaseHTTPRequestHandler, HTTPServerimport time
class MyHandler(BaseHTTPRequestHandler): def do_GET(self): if self.path == "/good": # Prepare headers self.send_response(200, "OK") self.send_header("Content-type", "application/json") self.send_header("Server", "SimpleHTTP/0.6 Python/3.10.11") self.end_headers()
# JSON body body = { "d": "085d8ea282da6cf76bb2765bc3b26549a1f6bdf08d8da2a62e05ad96ea645c685da48d66ed505e2e28b968d15dabed15ab1500901eb9da4606468650f72550483f1e8c58ca13136bb8028f976bedd36757f705ea5f74ace7bd8af941746b961c45bcac1eaf589773cecf6f1c620e0e37ac1dfc9611aa8ae6e6714bb79a186f47896f18203eddce97f496b71a630779b136d7bf0c82d560" }
import json self.wfile.write(json.dumps(body).encode()) else: self.send_error(404, "Not Found")
if __name__ == "__main__": server_address = ("", 8000) # Listen on all interfaces, port 8000 httpd = HTTPServer(server_address, MyHandler) print("Serving on port 8000...") httpd.serve_forever()C2 Server
Command and Control Server with user inputs to be recorded with TTD to capture key changes
from http.server import BaseHTTPRequestHandler, HTTPServerimport json
LAST_JSON = '{"d":"ok"}' # default; reused if you just press Enter
def get_user_json(tag: str) -> str: """ Prompt the operator for a JSON payload to return. - Enter to reuse LAST_JSON - Type <<< to start multiline mode; finish with >>> on its own line """ global LAST_JSON try: first = input(f"[{tag}] Enter JSON (blank=reuse; '<<<' for multiline)> ").strip() except EOFError: first = ""
if not first: return LAST_JSON
if first == "<<<": print("[multiline] Paste JSON. End with a line containing only >>>") lines = [] while True: try: line = input() except EOFError: break if line.strip() == ">>>": break lines.append(line) payload = "\n".join(lines) else: payload = first
LAST_JSON = payload return payload
class MyHandler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.0"
def _send_json_headers(self, status=200, reason="OK"): self.send_response(status, reason) self.send_header("Content-type", "application/json") self.send_header("Server", "SimpleHTTP/0.6 Python/3.10.11") self.send_header("Connection", "close") self.end_headers()
def do_GET(self): if self.path == "/good": payload = get_user_json("GET /good") elif self.path == "/get": payload = get_user_json("GET /get") else: self.send_error(404, "Not Found") return
self._send_json_headers(200, "OK") # Return exactly what you typed (no extra json.dumps) self.wfile.write(payload.encode("utf-8"))
def _read_body(self) -> bytes: length = int(self.headers.get("Content-Length", "0")) return self.rfile.read(length) if length > 0 else b""
def do_POST(self): if self.path not in ("/", "/re"): self.send_error(404, "Not Found") return
body = self._read_body() # Print incoming POST JSON if possible try: req = json.loads(body.decode("utf-8")) if body else {} print(f"[POST {self.path} from {self.client_address[0]}] recv: {json.dumps(req)}") except Exception: print(f"[POST {self.path} from {self.client_address[0]}] raw body: {body!r}")
payload = get_user_json(f"POST {self.path}") self._send_json_headers(200, "OK") self.wfile.write(payload.encode("utf-8"))
if __name__ == "__main__": httpd = HTTPServer(("", 8080), MyHandler) print("Serving on port 8080...") httpd.serve_forever()Conclusion
This challenge is quite fun as it helps me practice some binary ninja scripting which I find to be really useful and intuitive. The Dead Store Elimination button was something I wanted to try in the past which is now presented as a button. While I have not tried microcode from IDA yet, I am looking forward to learn more about it in different tools.
The use of TTD was immensely helpful since I can just replay the server and take my time to capture all the different keys during the 2-3 times of key changes. Once that is figured out, its just cyberchef, copy pasting and base64!