Flare-On 12 Writeups
DrillBabyDrill
Solve time:
September 30th, 4:34:15 PM
Another pygame like last year, binary and source provided! I found the following function in the source:
def GenerateFlagText(sum):
key = sum >> 8
encoded = "\xd0\xc7\xdf\xdb\xd4\xd0\xd4\xdc\xe3\xdb\xd1\xcd\x9f\xb5\xa7\xa7\xa0\xac\xa3\xb4\x88\xaf\xa6\xaa\xbe\xa8\xe3\xa0\xbe\xff\xb1\xbc\xb9"
plaintext = []
for i in range(0, len(encoded)):
plaintext.append(chr(ord(encoded[i]) ^ (key+i)))
return ''.join(plaintext)
...
if player.hitBear():
player.drill.retract()
bear_sum *= player.x
bear_mode = True
...
if current_level == len(LevelNames) - 1 and not victory_mode:
victory_mode = True
flag_text = GenerateFlagText(bear_sum)
print("Your Flag: " + flag_text)
...
The property player.x is actually bounded by the tiles_width variable:
screen_width = 800
screen_height = 600
tile_size = 40
tiles_width = screen_width // tile_size
def AttemptPlayerMove(dx, dy):
newx = player.x + dx
# Can only move within screen bounds
if newx < 0 or newx >= tiles_width:
return False
...
Given we know the maximum values for x, \(20^5\) is simple to bruteforce to pass into GenerateFlagText:
from tqdm import tqdm
from itertools import product
print(next(
flag
for i, j, k, l, m in tqdm(
product(
*(
range(20)
for i in range(5)
)
)
)
if "@flare-on.com" in (flag := GenerateFlagText(i * j * k * l * m))
))
Easy.
Flag: drilling_for_teddies@flare-on.com
project_chimera
Solve time:
September 30th, 5:30:25 PM
The provided project_chimera.py Python file was obfuscated:
# ================================================================= #
# == PROJECT CHIMERA - Dr. Alistair Khem's Journal == #
# == -- EYES ONLY -- == #
# ================================================================= #
#
# Journal Entry 734:
#
# Success is within my grasp! After years of research, I have finally
# synthesized the two key components. The first, my 'Genetic Sequencer,'
# is stable and ready. It's designed to read and execute the final,
# most crucial part of my experiment: the 'Catalyst Serum.'
#
# The Catalyst is the key to creating a true digital lifeform.
# However, it is keyed to my specific biometric signature to prevent
# my research from falling into the wrong hands. Only I, the Lead
# Researcher, can successfully run this final protocol.
#
# If anyone else finds this, I urge you: DO NOT RUN THIS SCRIPT.
# The results could be... unpredictable.
#
# - Dr. A. Khem
#
import zlib
import marshal
# These are my encrypted instructions for the Sequencer.
encrypted_sequencer_data = b'x\x9cm\x96...'
print(f"Booting up {f"Project Chimera"} from Dr. Khem's journal...")
# Activate the Genetic Sequencer. From here, the process is automated.
sequencer_code = zlib.decompress(encrypted_sequencer_data)
exec(marshal.loads(sequencer_code))
I was unsure about marshal loading some unknown code, so for extra safety, I did everything in a Docker container, and attempted to just decompress the file for the time being:
...
sequencer_code = zlib.decompress(encrypted_sequencer_data)
with open("decompressed", "wb") as f:
f.write(sequencer_code)
pycdc is an excellent tool for working with Python bytecode, and in this case, since it is supposedly executing a code object that’s been unmarshalled, I used pycdc -c -v 3.12 decompressed to look at the decompiled output:
import base64
import zlib
import marshal
import types
encoded_catalyst_strand = b'c$|e+O>7&-6`m!Rzak~llE|2<;!(^*VQn#qEH||xE2b$*W=zw8NW~2mgIMj3sFjzy%<NJQ84^$vqeTG&mC+yhlE677j-8)F4nD>~?<GqL64olvBs$bZ4{qE;{|=p@M4Abeb^*>CzIprJ_rCXLX1@k)54$HHULnIe5P-l)Ahj!*6w{D~l%XMwDPu#jDYhX^DN{q5Q|5-Wq%1@lBx}}|vN1p~UI8h)0U&nS13Dg}x8K^E-(q$p0}4!ly-%m{0Hd>^+3*<O{*s0K-lk|}BLHWKJweQrNz5{%F-;@E_{d+ImTl7-o7&}O{%uba)w1RL*UARX*79t+0<^B?zmlODX9|2bzp_ztwjy_TdKb)1%eP4d-Xti0Ygjk_%w!^%1xuMNv4Z8&(*Ue7_^Fby1n3;+G<VDAfqi^h1>0@=Eki5!M~rms%afx`+uxa0*;FzudpqNln5M<@!OqndZ)R<vh4u&gpmmnaMewbT0RJby?(fa7XW#r>ZQ4UE&u|~lZsEY~-lpfWMf0_+pV-H`PXInpwmyo~mZ`tfUK?($KHa%mvNlovZ;Y)D+e6uw+mY6LNB2Y9&akbWpZ@lh=Si<!J@t|CG86E`)jp!l4xEY(h7@$llA4}B9dpL*j)eL{vVcbyMx5_{b13)N@wa~epS8Zfo&V_Y#fM*g9;@6%j=%i%WB0=QS3ewj@0~B!iibu<MqrrJIH{m&FoAGB3#0Nf;x!~dvQ|9#3c})IL6kEvhByJvA{B9%UqX0Tg*-+Ak~NW&RJbB?a6weENW&rzRi2ZB!647HWlA^rG4gvj3Yteo30&*};59;7nJF7eh7vjEXwwxPWWzD*3<IvZS#lIL(l*?u$;EGifKfLDpVb*rXLyw!AP~ZT^-S=4X{31tqe<O1kwG$gBZnu8eva3~6;4CxrcH1{Qg{M;GT5@Bdqt%s{xkT;DyaBk)v>cTr#=XM@cQ-VZZJ1azh{1Df~fwf(mdYk_cEC``#zrevUuf1-I7DHKqx9c7Me?*iNur9a3~o)A1AmHbK!6#k<d+QmXjoUlrAc=R-8EfEvn$TP%?Zb2%`-;wF2Z7c~Qh!QUp%@F7d(Q;It@nl31iwc^NCTTrj*OW)bEH>BYlQ$YmihSV2QDxrCsKNToEmsNif~;-ILG+l$@~sMDcnEHYIbjb?L-swo%>NNY60QJ5`2LX(&$CFf*W(cl7t80939@QH+>;!kK4jMTiOQA}zM@dS+wmk4?RtsqIs(NtuZr(Ewj<zxXaVots!6<}UP5>nNp1gfkes4T*zd{)6h-GF4>NSQO}R*91{c`k!=D-D}baN$1fuVNrUDvGiYVXWYBI456{mCG`ukuZfpN)A<xyb=s}byE(DvZfmpRkvo4CMg+F*3C%f6#?m{g@T4u-G<~mB~wGXg;NVMFDj&f5<)qG1#7xlYdFEQ_jHRu*e&FUmQ1J<Gp}4$xq@yalC(x)S-FIEgQe+IxARLJPRm@DXx&t+<h5L0ORJ<E<cw}6ln6?exLHy}9_dE4pz17oL(~E`{a`E-no7?`5)pDEpNY(-6VaJ?C^<J9(GN!A;n`PTPDZBE;WN>5k=ams`uyy<xmZYd@Og|04{1U(*1PGLR>h3WX?aZWQf~69?j-FsmL^GvInrgidoM2}r1u&}XB+q}oGg-NR#n^X*4uqBy?1qY$4<jzMBhXA);zPfx3*xU!VW$#fFa&MCOfRHVn0%6k8aaRw9dY?)7!uP!nGHEb#k+JxY|2h>kX{N{%!`IfvPX|S@e!nA3Iy~#cKVr)%cFx{mYSGj9h1H_Q6edkhuGk)3Z9gWp`~mJzG74m7(!J^o(!2de`mO?3IDzcV;$RQ`@foiYHlj%{3;+>#iT|K>v-`YH)PTx#fRu(|@AsKT#P^)cna!|9sUyU-MtAxP}M>w|Cc1s4_KI9hlp2y|UAEJ$C2$4Oh6~@uj-!Y-5tEyI$Y%KECN4u6l<*?fcwR_fD^|+djDIJ5u!>A&1N9itm{<3o-un;-)89^#pIPd{VwyzH_1WOyqZ$H)k$XXD-xcUafgjb=N#i!+Onn-Tj-cEob+(!(BOWa>FtC;21DH{%^IHo=c%;r;jstN15qS_U^F=Ab$c5Oh5W?fY!%^vdXfE>5Yf!rHF^<aF`B*be*L=(CF(%-E<?)%b0$BJ)|f2ZjG%ISw+Z8XcC`j+)bpk<79YXWEkdaV7mwG_kiObaNYym&C&ix(EpA7N#?}|aRxAsRm;!2e%e)a4AvZnHUPvwCa?b&OiHoo'
print('--- Calibrating Genetic Sequencer ---')
print('Decoding catalyst DNA strand...')
compressed_catalyst = base64.b85decode(encoded_catalyst_strand)
marshalled_genetic_code = zlib.decompress(compressed_catalyst)
catalyst_code_object = marshal.loads(marshalled_genetic_code)
print('Synthesizing Catalyst Serum...')
catalyst_injection_function = types.FunctionType(catalyst_code_object, globals())
catalyst_injection_function()
It’s another layer of packing, though this time we can just do the same thing with writing to a file instead of executing it:
...
compressed_catalyst = base64.b85decode(encoded_catalyst_strand)
marshalled_genetic_code = zlib.decompress(compressed_catalyst)
with open("stage2-decompressed", "wb") as f:
f.write(marshalled_genetic_code)
import os
import sys
import emoji
import random
import asyncio
import cowsay
import pyjokes
import art
from arc4 import ARC4
async def activate_catalyst():
Unsupported opcode: RETURN_GENERATOR (109)
pass
# WARNING: Decompyle incomplete
asyncio.run(activate_catalyst())
Looks like pycdc fails on some modern opcodes, but we can still rely on pycdas, which has a much nicer output to analyse than xdis:
$ ./pycdas -c -v 3.12 ./stage2-decompressed
[Code]
File Name: <catalyst_core>
Object Name: <module>
Qualified Name: <module>
Arg Count: 0
Pos Only Arg Count: 0
KW Only Arg Count: 0
Stack Size: 4
Flags: 0x00000000
[Names]
'os'
'sys'
'emoji'
'random'
'asyncio'
'cowsay'
'pyjokes'
'art'
'arc4'
'ARC4'
'activate_catalyst'
'run'
[Locals+Names]
...
It looks like within this code object, under co_consts[3], there’s another code object which basically means there is a nested function, apparently named activate_catalyst:
[Code]
File Name: <catalyst_core>
Object Name: activate_catalyst
Qualified Name: activate_catalyst
...
[Constants]
None
b'm\x1b@I\x1dAoe@\x07ZF[BL\rN\n\x0cS'
b'r2b-\r\x9e\xf2\x1fp\x185\x82\xcf\xfc\x90\x14\xf1O\xad#]\xf3\xe2\xc0L\xd0\xc1e\x0c\xea\xec\xae\x11b\xa7\x8c\xaa!\xa1\x9d\xc2\x90'
'--- Catalyst Serum Injected ---'
"Verifying Lead Researcher's credentials via biometric scan..."
[Code]
File Name: <catalyst_core>
Object Name: <genexpr>
Qualified Name: activate_catalyst.<locals>.<genexpr>
Arg Count: 1
...
0.01
'pending'
'AUTHENTICATION SUCCESS'
'small'
(
'font'
)
'Biometric scan MATCH. Identity confirmed as Lead Researcher.'
'Finalizing Project Chimera...'
'I am alive! The secret formula is:\n'
'AUTHENTICATION FAILED'
'Impostor detected, my genius cannot be replicated!'
'The resulting specimen has developed an unexpected, and frankly useless, sense of humor.'
'en'
'all'
(
'language'
'category'
)
1
'System error: Unknown experimental state.'
[Disassembly]
0 RETURN_GENERATOR
2 POP_TOP
4 RESUME 0
6 LOAD_CONST 1: b'm\x1b@I\x1dAoe@\x07ZF[BL\rN\n\x0cS'
8 STORE_FAST 0: LEAD_RESEARCHER_SIGNATURE
10 LOAD_CONST 2: b'r2b-\r\x9e\xf2\x1fp\x185\x82\xcf\xfc\x90\x14\xf1O\xad#]\xf3\xe2\xc0L\xd0\xc1e\x0c\xea\xec\xae\x11b\xa7\x8c\xaa!\xa1\x9d\xc2\x90'
12 STORE_FAST 1: ENCRYPTED_CHIMERA_FORMULA
14 LOAD_GLOBAL 1: NULL + print
24 LOAD_CONST 3: '--- Catalyst Serum Injected ---'
26 CALL 1
34 POP_TOP
36 LOAD_GLOBAL 1: NULL + print
46 LOAD_CONST 4: "Verifying Lead Researcher's credentials via biometric scan..."
48 CALL 1
56 POP_TOP
58 LOAD_GLOBAL 3: NULL + os
68 LOAD_ATTR 4: getlogin
88 CALL 0
96 LOAD_ATTR 7: encode
116 CALL 0
124 STORE_FAST 2: current_user
126 LOAD_GLOBAL 9: NULL + bytes
136 LOAD_CONST 5: <CODE> <genexpr>
138 MAKE_FUNCTION 0
140 LOAD_GLOBAL 11: NULL + enumerate
150 LOAD_FAST 2: current_user
152 CALL 1
160 GET_ITER
162 CALL 0
170 CALL 1
178 STORE_FAST 3: user_signature
180 LOAD_GLOBAL 13: NULL + asyncio
190 LOAD_ATTR 14: sleep
210 LOAD_CONST 6: 0.01
212 CALL 1
220 GET_AWAITABLE 0
222 LOAD_CONST 0: None
224 SEND 3 (to 232)
228 YIELD_VALUE 2
230 RESUME 3
232 JUMP_BACKWARD_NO_INTERRUPT 5 (to 224)
234 END_SEND
236 POP_TOP
238 LOAD_CONST 7: 'pending'
240 STORE_FAST 4: status
242 LOAD_FAST 4: status
244 LOAD_CONST 7: 'pending'
246 COMPARE_OP 40 (==)
250 POP_JUMP_IF_FALSE 294 (to 842)
254 LOAD_FAST 3: user_signature
256 LOAD_FAST 0: LEAD_RESEARCHER_SIGNATURE
258 COMPARE_OP 40 (==)
262 POP_JUMP_IF_FALSE 112 (to 488)
264 LOAD_GLOBAL 17: NULL + art
274 LOAD_ATTR 18: tprint
294 LOAD_CONST 8: 'AUTHENTICATION SUCCESS'
296 LOAD_CONST 9: 'small'
298 KW_NAMES 10: ('font',)
300 CALL 2
308 POP_TOP
310 LOAD_GLOBAL 1: NULL + print
320 LOAD_CONST 11: 'Biometric scan MATCH. Identity confirmed as Lead Researcher.'
322 CALL 1
...
Just a quick glance through and we can see that:
- there is a
LEAD_RESEARCHER_SIGNATURE = b'm\x1b@I\x1dAoe@\x07ZF[BL\rN\n\x0cS', - there is a
ENCRYPTED_CHIMERA_FORMULA = b'r2b-\r\x9e\xf2\x1fp\x185\x82\xcf\xfc\x90\x14\xf1O\xad#]\xf3\xe2\xc0L\xd0\xc1e\x0c\xea\xec\xae\x11b\xa7\x8c\xaa!\xa1\x9d\xc2\x90', probably a flag encrypted with ARC4 as that was referenced in the parent function, - the
LEAD_RESEARCHER_SIGNATUREis compared against thecurrent_user = os.getlogin()transformed by a certain generator expression, i.e.(<genexpr> for i, c in enumerate(current_user))
The generator expression is also in this function’s co_consts[5] and is fairly trivial:
[Code]
File Name: <catalyst_core>
Object Name: <genexpr>
Qualified Name: activate_catalyst.<locals>.<genexpr>
Arg Count: 1
Pos Only Arg Count: 0
KW Only Arg Count: 0
Stack Size: 4
Flags: 0x00000033 (CO_OPTIMIZED | CO_NEWLOCALS | CO_NESTED | CO_GENERATOR)
[Names]
[Locals+Names]
'.0'
'i'
'c'
[Constants]
42
None
[Disassembly]
0 RETURN_GENERATOR
2 POP_TOP
4 RESUME 0
6 LOAD_FAST 0: .0
8 FOR_ITER 15 (to 40)
12 UNPACK_SEQUENCE 2
16 STORE_FAST 1: i
18 STORE_FAST 2: c
20 LOAD_FAST 2: c
22 LOAD_FAST 1: i
24 LOAD_CONST 0: 42
26 BINARY_OP 0 (+)
30 BINARY_OP 12 (^)
34 YIELD_VALUE 1
36 RESUME 1
38 POP_TOP
40 JUMP_BACKWARD 17 (to 8)
42 END_FOR
44 RETURN_CONST 1: None
46 CALL_INTRINSIC_1 3 (INTRINSIC_STOPITERATION_ERROR)
48 RERAISE 1
[Exception Table]
4 to 46 -> 46 [0] lasti
Note that the above code works like a stack machine, so working backwards from the YIELD_VALUE instruction, we can see a XOR BINARY_OP against the last two elements on the stack - the first one is the direct result of the previous addition BINARY_OP of, again, the last two elements on the stack, which has just been stored with LOAD_FAST: i + 42. Finally, there’s the second operand of the parent XOR instruction, which is the c variable which has also been stored with LOAD_FAST.
In summary, the above excerpts are equivalent to the following Python code:
current_user = os.get_login()
user_signature = list(c ^ (i + 42) for i, c in enumerate(current_user))
if user_signature == LEAD_RESEARCHER_SIGNATURE:
...
Since the transform above is invertible, we can simply get the username required to decrypt the flag:
LEAD_RESEARCHER_SIGNATURE = b'm\x1b@I\x1dAoe@\x07ZF[BL\rN\n\x0cS'
ENCRYPTED_CHIMERA_FORMULA = b'r2b-\r\x9e\xf2\x1fp\x185\x82\xcf\xfc\x90\x14\xf1O\xad#]\xf3\xe2\xc0L\xd0\xc1e\x0c\xea\xec\xae\x11b\xa7\x8c\xaa!\xa1\x9d\xc2\x90'
lead_researcher_user = bytes(c ^ (i + 42) for i, c in enumerate(LEAD_RESEARCHER_SIGNATURE))
print(f"{lead_researcher_user = }")
from Crypto.Cipher import ARC4
print(ARC4.new(lead_researcher_user).decrypt(ENCRYPTED_CHIMERA_FORMULA))
lead_researcher_user = b'G0ld3n_Tr4nsmut4t10n'
b'Th3_Alch3m1sts_S3cr3t_F0rmul4@flare-on.com'
Flag: Th3_Alch3m1sts_S3cr3t_F0rmul4@flare-on.com
pretty_devilish_file
Solve time:
September 30th, 5:41:44 PM
I knew PDF files were basically text commands with compressed binary interleaved in between - I wasn’t sure exactly what that /EncryptMetadata command did but it borked the PDF.js in my Firefox browser. I wanted to be sure to decompress all of the /FlateDecode streams before I started poking at it:
$ qpdf pretty_devilish_file.pdf --compress-streams=n pretty_devilish_file.qpdf.pdf
Something not visibly present on the PDF file was identified in a plain old text editor, at the object 4 0:
4 0 obj
<< /Length 576 >>
stream
q 612 0 0 10 0 -10 cm
BI /W 37/H 1/CS/G/BPC 8/L 458/F[
/AHx
/DCT
]ID
ffd8ffe000104a46494600010100000100010000ffdb00430001010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101ffc0000b080001002501011100ffc40017000100030000000000000000000000000006040708ffc400241000000209050100000000000000000000000702050608353776b6b7030436747577ffda0008010100003f00c54d3401dcbbfb9c38db8a7dd265a2159e9d945a086407383aabd52e5034c274e57179ef3bcdfca50f0af80aff00e986c64568c7ffd9
EI Q
q
BT
/ 140 Tf
10 10 Td
(Flare-On!)'
ET
Q
endstream
endobj
ÿØÿà! Or ffd8ffe0, was the magic number for a JPG file. Time to explore using Python’s PIL:
>>> from PIL import Image
>>> from io import BytesIO
>>>
>>> img = Image.open(BytesIO(bytes.fromhex("ffd8ffe000104a46494600010100000100010000ffdb00430001010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101ffc0000b080001002501011100ffc40017000100030000000000000000000000000006040708ffc400241000000209050100000000000000000000000702050608353776b6b7030436747577ffda0008010100003f00c54d3401dcbbfb9c38db8a7dd265a2159e9d945a086407383aabd52e5034c274e57179ef3bcdfca50f0af80aff00e986c64568c7ffd9")))
>>> img
<PIL.JpegImagePlugin.JpegImageFile image mode=L size=37x1 at 0x7F27EC3B8D90>
A monochrome single-row image of 37 pixels, around the length of a flag. Hmmm.
>>> img.getpixel((0, 0))
80
>>> chr(_)
'P'
Hmmmmmm.
>>> bytes(img.getpixel((i, 0)) for i in range(img.size[0]))
b'Puzzl1ng-D3vilish-F0rmat@flare-on.com'
By the way, you can solve this using CyberChef.
Flag: Puzzl1ng-D3vilish-F0rmat@flare-on.com
UnholyDragon
Solve time:
October 1st, 12:51:31 AM
This was an embarassing solve that I’d really like to not talk about. But in essence, I recognised TwinBasic, and I recognised some decompression routine during debugging, which actually outputted a corrupted PNG file, which only Windows could view and subsequently export to BMP.

I had stared at this file which had the (corrupted) flag text in a black font on white, and visually cribbed the text with an educated guess that it was something about a “denial of service” due to the binary continuously spawning children, until I got the flag.
Flag: dr4g0n_d3n1al_of_s3rv1ce@flare-on.com
ntfsm
Solve time:
October 1st, 6:10:52 PM
The provided PE binary ntfsm.exe had an extremely large main function (@ 0x14000c0b0), consisting of a switch statement with 90780 cases. I had interrupted auto-analysis on binary ninja and elected to define each case handler (stored as RVA pointers in a jump table @ 0x140c687b8)as its own function to speed things along.
Looking at the initial code in main, we can see std::string constructors called with the constants "state", "input", "position", and "transitions". The strings "position" and "transitions" are eventually passed into a function @ 0x140ff0fe0 I’ve labeled bool ads_read_int64(std::wstring& stream_name, int64_t* data_out), which essentially opens the file path GetModuleFileNameA() + ":" + stream_name, and outputs 8 bytes read from the file handle. This makes use of an interesting NTFS-specific mechanism called “Alternate Data Streams”, where one logical file can actually have multiple named streams of data.
In this program, ADS data is used to store state in what looks to be a Finite State Machine, where the "state" stream is used to dictate what case, or rule to execute, which in turn may further modify the next state to be executed, may increment the position or transitions variable, depending on the the character of input at the current value of position, which again, may be modified by previous rules.
The program first initialises all four variables, with state, position and transitions starting at 0, and the input stream being cleared and written with the user-specified password. Additional functions for ADS I/O are as follows:
bool ads_read_int64(std::wstring& stream_name, int64_t* data_out)@0x140ff0fe0bool ads_write_int64(std::wstring& stream_name, int64_t data)@0x140ff1190bool ads_read_bytes(std::wstring& stream_name, uint8_t* data_out, uint32_t len)@0x14000af00bool ads_write_bytes(std::wstring& stream_name, uint8_t* data, uint32_t len)@0x14000ade0bool ads_clear_all(std::wstring& state_stream_name, std::wstring& position_stream_name, std::wstring& input_stream_name, std::wstring& transitions_stream_name)@0x14000b100- This just sets
positionandtransitionsto 0,stateto -1, and theinputbytearray to all zeroes.
- This just sets
The reason why the binary does this is to maintain state between program executions. Each switch case or “rule” in the FSM may compare the value of current input[position] char, and may modify the transitions index and another next_state variable (initialised to 0 prior in the main function) before jumping to the end of the main function at 0x140c685ee, which will take the transitions and potentially the next_state variable if non-zero, along with the previous position variable previously incremented by 1, and update the ADSes of the binary, before calling WAIT_EVENT spawn_next(PSTR module_filename) @ 0x14000b050, which essentially calls a CreateProcess on itself before allowing the parent process to exit. The relevant stack variables are as follows, as they will become important later in the solve:
int64_t transitions@[rsp+0x58ab8]uint8_t current_input@[rsp+0x30]int64_t next_state@[rsp+0x58d30]
After the 16th iteration, e.g. the 16th process execution where position == 0x10, it will ensure that transitions == 0x10, essentially excluding any excluding any inputs which leads to a state that doesn’t increment the transitions variable (e.g. an infinite loop). After which, it will do the usual AES_CBC(SHA256(input)).decrypt(ciphertext) @ 0x14000b2a0, where the ciphertext is hard-coded and initialised on the stack. I figured that there must be no cycles in the solve’s state transition, so I began writing some code to execute within Binary Ninja itself:
STATE_JUMP_TABLE = 0x140c687b8
MAX_STATE = 0x1629c
NEXT_STATE_STORAGE_OFFSET = 0x58d30
TRANSITIONS_STORAGE_OFFSET = 0x58ab8
CURRENT_INPUT_STORAGE_OFFSET = 0x30
def get_current_input_uses(func):
return func.hlil.get_var_uses(Variable(func, VariableSourceType.StackVariableSourceType, 0, CURRENT_INPUT_STORAGE_OFFSET))
def handler_func_from_state(state):
offset = bv.read_int(STATE_JUMP_TABLE + (state * 4), 4, sign=False)
if (func := bv.get_function_at(bv.image_base + offset) or bv.add_function(bv.image_base + offset)) is None:
return None
if len(func.mlil.get_var_uses(Variable(func, 0, 0, TRANSITIONS_STORAGE_OFFSET))) == 0:
return None
return func
handler_funcs = {}
for state in range(MAX_STATE):
if state % 0x1000 == 0:
print(hex(state))
handler_funcs[state] = handler_func_from_state(state)
def extract_transitions(handler_func):
cases = {}
for use in get_current_input_uses(handler_func):
if isinstance(parent := use.parent, HighLevelILTailcall):
continue
match parent.parent:
case HighLevelILIf(
condition=HighLevelILCmpE(
left=left,
right=HighLevelILConst(
value=ConstantRegisterValue(value=compare_value),
),
),
true=consequent
) if left == use:
transitions_assigned = False
state_assign = None
for il in consequent.body:
match il:
case HighLevelILAssign(
dest=HighLevelILVar(
var=Variable(
core_variable=CoreVariable(
source_type=VariableSourceType.StackVariableSourceType,
index=0,
storage=storage,
),
),
),
) if storage == TRANSITIONS_STORAGE_OFFSET:
transitions_assigned = True
continue
case HighLevelILAssign(dest=HighLevelILVar(var=var), src=HighLevelILConst(value=ConstantRegisterValue(value=value))):
pass
case HighLevelILVarInit(dest=var, src=HighLevelILConst(value=ConstantRegisterValue(value=value))):
pass
case _:
continue
match var:
case Variable(
core_variable=CoreVariable(
source_type=VariableSourceType.StackVariableSourceType,
index=0,
storage=storage,
),
) if storage == NEXT_STATE_STORAGE_OFFSET:
state_assign = value
if transitions_assigned:
cases[chr(compare_value)] = state_assign
elif state_assign is not None:
raise ValueError("bruh")
case _:
raise ValueError(f"Unexpected ancestor {parent.parent} in {handler_func}")
return cases
from collections import deque
visited_states = set()
candidates = []
queue = deque([(handler_funcs[0], "")])
while queue:
handler, known_input = queue.popleft()
if handler is None:
continue
if handler in visited_states:
continue
visited_states.add(handler.start)
if len(visited_states) % 0x1000 == 0:
print(hex(handler.start), hex(len(visited_states)))
current_transitions = extract_transitions(handler)
if len(known_input) == 0x10 - 1 and len(current_transitions) > 0:
for char in current_transitions.keys():
candidates.append(known_input + char)
break
for char, target_state in current_transitions.items():
if (target := handler_funcs.get(target_state)) is not None and target.start not in visited_states:
queue.append((target, known_input + char))
iv = bytes.fromhex("81 a8 29 12 4f a6 2d 0d fb 28 e5 f1 78 3d 5c 69")
ciphertext = bytes.fromhex("9c af ad 1c 6f 8e f5 23 f1 b8 90 ad b4 d7 1e 66 62 5f 85 f8 0f f6 1e 27 d3 90 9c 0d a8 a0 5d ee 12 55 5f d4 e6 72 6c 22 0b 22 70 9f f1 67 67 21")
from Crypto.Cipher import AES
from Crypto.Hash import SHA256
from Crypto.Util.Padding import unpad
for s in candidates:
print(s, unpad(
AES.new(
SHA256.new(s.encode()).digest(),
AES.MODE_CBC,
iv=iv
).decrypt(ciphertext),
16
))
iqg0nSeCHnOMPm2Q b'f1n1t3_st4t3_m4ch1n3s_4r3_fun@flare-on.com'
Flag: f1n1t3_st4t3_m4ch1n3s_4r3_fun@flare-on.com
Chain Of Demands
Solve time:
October 1st, 9:02:53 PM
The provided ELF binary chat_client is a PyInstaller binary, which pyinstxtractor-ng makes quick work of. Once extracted, there exists a challenge_to_compile.pyc. pycdc fails here, but pylingual worked better for a complete decompilation. Within the source code there is class SmartContracts, a wrapper around a deployed Ethereum contract on the Sepolia testnet, and two contracts abstracted by additional Python classes:
contract LCGOracle {
function nextVal(
uint256 LCG_MULTIPLIER,
uint256 LCG_INCREMENT,
uint256 LCG_MODULUS,
uint256 _currentState,
uint256 _counter
) public pure returns (uint256) { ... }
}
contract TripleXOROracle {
function encrypt(
uint256 _primeFromLcg,
uint256 _conversationTime,
string _plaintext,
) public pure returns (bytes32) { ... }
}
Upon initialisation, the chat client initialises a seed, then feeds this into a prime generator to retrieve the LCG triplet for use with the contract:
class ChatLogic:
...
def _initialize_crypto_backend(self):
self.seed_hash = self._get_system_artifact_hash()
m, c, n = self._generate_primes_from_hash(self.seed_hash)
self.lcg_oracle = LCGOracle(m, c, n, self.seed_hash)
self.lcg_oracle.deploy_lcg_contract()
print('[SETUP] LCG Oracle is on-chain...')
self.xor_oracle = TripleXOROracle()
self.xor_oracle.deploy_triple_xor_contract()
print('[SETUP] Triple XOR Oracle is on-chain...')
print('[SETUP] Crypto backend initialized...')
def generate_rsa_key_from_lcg(self):
print('[RSA] Generating RSA key from on-chain LCG primes...')
lcg_for_rsa = LCGOracle(self.lcg_oracle.multiplier, self.lcg_oracle.increment, self.lcg_oracle.modulus, self.seed_hash)
lcg_for_rsa.deploy_lcg_contract()
...
These two contracts are later called to encrypt messages:
prime_from_lcg = self.lcg_oracle.get_next(self.message_count)
ciphertext = self.xor_oracle.encrypt(prime_from_lcg, conversation_time, plaintext)
encryption_mode = 'LCG-XOR'
Retrieving the LCG values should be simple enough given some known plaintexts - though I had way over engineered the approach. There was a provided chat_log.json within the JSON itself:
[
{
"conversation_time": 0,
"mode": "LCG-XOR",
"plaintext": "Hello",
"ciphertext": "e934b27119f12318fe16e8cd1c1678fd3b0a752eca163a7261a7e2510184bbe9"
},
{
"conversation_time": 4,
"mode": "LCG-XOR",
"plaintext": "How are you?",
"ciphertext": "25bf2fd1198392f4935dcace7d747c1e0715865b21358418e67f94163513eae4"
},
...
{
"conversation_time": 242,
"mode": "RSA",
"plaintext": "[ENCRYPTED]",
"ciphertext": "680a65364a498aa87cf17c934ab308b2aee0014aee5b0b7d289b5108677c7ad1eb3bcfbcad7582f87cb3f242391bea7e70e8c01f3ad53ac69488713daea76bb3a524bd2a4bbbc2cfb487477e9d91783f103bd6729b15a4ae99cb93f0db22a467ce12f8d56acaef5d1652c54f495db7bc88aa423bc1c2b60a6ecaede2f4273f6dce265f6c664ec583d7bd75d2fb849d77fa11d05de891b5a706eb103b7dbdb4e5a4a2e72445b61b83fd931cae34e5eaab931037db72ba14e41a70de94472e949ca3cf2135c2ccef0e9b6fa7dd3aaf29a946d165f6ca452466168c32c43c91f159928efb3624e56430b14a0728c52f2668ab26f837120d7af36baf48192ceb3002"
},
{
"conversation_time": 249,
"mode": "RSA",
"plaintext": "[ENCRYPTED]",
"ciphertext": "6f70034472ce115fc82a08560bd22f0e7f373e6ef27bca6e4c8f67fedf4031be23bf50311b4720fe74836b352b34c42db46341cac60298f2fa768f775a9c3da0c6705e0ce11d19b3cbdcf51309c22744e96a19576a8de0e1195f2dab21a3f1b0ef5086afcffa2e086e7738e5032cb5503df39e4bf4bdf620af7aa0f752dac942be50e7fec9a82b63f5c8faf07306e2a2e605bb93df09951c8ad46e5a2572e333484cae16be41929523c83c0d4ca317ef72ea9cde1d5630ebf6c244803d2dc1da0a1eefaafa82339bf0e6cf4bf41b1a2a90f7b2e25313a021eafa6234643acb9d5c9c22674d7bc793f1822743b48227a814a7a6604694296f33c2c59e743f4106"
}
]
However, I was unsure of how the message was padded, how exactly the conversationTime input came into play, etc., so I took an embarassingly long time (about an entire night) to research and figure out how to emulate the bytecode, especially using Ethereum’s execution-specs, before I settled on simular:
# values from getPrime(256)
multiplier = 102071934753261371363480831763963912296935818979398236074835117491370294870587
increment = 62020268059486849480723618823300363338869614099807573403733171228430828638859
modulo = 114113586372500030512076694606633409843716057673442420011657497939456062993669
state = 80483781250781573318277562084167278298706379140439532389136741790597325512823
state = LCGContract.nextVal.call(multiplier, increment, modulo, state, 0)
print(state) # 80483781250781573318277562084167278298706379140439532389136741790597325512823
state = LCGContract.nextVal.call(multiplier, increment, modulo, state, 1)
print(state) # 95606001601330623762251724050712231395501913871262946730666830379839575530155
state = LCGContract.nextVal.call(multiplier, increment, modulo, state, 2)
print(state) # 27573972318792315832323768382453833410332797085231415728678501620150816194517
print(TripleXORContract.encrypt.call(11, 0xFFFF, "A"*16))
# b'AAAAAAAAAAAAAAAA\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xf4'
# >>> 0xff ^ 11 == 0xf4
# True
# >>>
So from this, we can cleanly determine that the simple contract can be reimplemented with the following code:
def lcg(multiplier, increment, modulo, initial_state):
def *_lcg():
state = initial_state
while True:
yield state
state = ((state * multiplier) + increment) % modulo
return _lcg
from Crypto.Util.strxor import strxor
def tripleXOR(primeFromLcg, conversationTime, plaintext):
return strxor(
plaintext.encode().ljust(32, b'\0'),
strxor(
prime_from_lcg.to_bytes(32, "big"),
conversation_time.to_bytes(32, "big"),
),
)
The “triple” XOR itself is actually invertible, and we can trivially recover the values for primeFromLcg using the provided chat plaintext values and metadata:
def recover_xor_key(log):
return strxor(
bytes.fromhex(log["ciphertext"]),
strxor(
log["plaintext"].encode().ljust(32, b'\0'),
log["conversation_time"].to_bytes(32, "big")
)
)
states = [int.from_bytes(recover_xor_key(log), "big") for log in chat_log[:7]]
And since we know the first of the yielded LCG states is the initial seed_hash given by ChatLogic._get_system_artifact_hash, all that’s left is the unknown values for the LCG, which we can trivially recover:
diffs = [s1 - s0 for s0, s1 in zip(states, states[1:])]
zeroes = [t2*t0 - t1*t1 for t0, t1, t2 in zip(diffs, diffs[1:], diffs[2:])]
modulus = abs(reduce(gcd, zeroes))
print(f"{modulus = }")
x0, x1, x2 = states[0], states[1], states[2]
a = ((x2 - x1) * pow(x1 - x0, -1, modulus)) % modulus
multiplier = (states[3] - states[2]) * pow(states[2] - states[1], -1, modulus) % modulus
multiplier += modulus
print(f"{multiplier = }")
increment = (states[1] - states[0] * multiplier) % modulus
print(f"{increment = }")
# modulus = 98931271253110664660254761255117471820360598758511684442313187065390755933409
# multiplier = 110283618870338064626531490251795414335143054807338925132407172237502097193299
# increment = 61077733451871028544335625522563534065222147972493076369037987394712960199707
With all of the above, we can then borrow the RSA key generation logic to decrypt the last “super-encrypted” messages:
lcg_for_rsa = lcg(multiplier, increment, modulus, seed_hash)
primes_arr = []
rsa_msg_count = 0
iteration_limit = 10000
iterations = 0
while iterations < iteration_limit:
if len(primes_arr) < 8 and iterations < iteration_limit:
candidate = next(lcg_for_rsa)
rsa_msg_count += 1
iterations += 1
if candidate.bit_length() == 256 and isPrime(candidate):
primes_arr.append(candidate)
print(f'''[RSA] - Found 256-bit prime #{len(primes_arr)}''')
if len(primes_arr) < 8 and iterations < iteration_limit:
continue
print('Primes Array: ', primes_arr)
if len(primes_arr) < 8:
error_msg = '[RSA] Error: Could not find 8 primes within iteration limit.'
print('Current Primes: ', primes_arr)
print(error_msg)
sys.exit(1)
n = 1
for p_val in primes_arr:
n *= p_val
phi = 1
for p_val in primes_arr:
phi *= p_val - 1
e = 65537
if math.gcd(e, phi) != 1:
error_msg = '[RSA] Error: Public exponent e is not coprime with phi(n). Cannot generate key.'
print(error_msg)
sys.exit(1)
d = pow(e, -1, phi)
break
print(f"{d = }")
print(f"{n = }")
print(states)
def decrypt_rsa(log):
return long_to_bytes(pow(int.from_bytes(bytes.fromhex(log['ciphertext']), 'little'), d, n)).decode()
print(decrypt_rsa(chat_log[7])) # Actually what's your email?
print(decrypt_rsa(chat_log[8])) # It's W3b3_i5_Gr8@flare-on.com
Flag: W3b3_i5_Gr8@flare-on.com
The Boss Needs Help
Solve time:
October 2nd, 10:26:05 PM
The provided PE binary hopesanddreams.exe came with an accompanying packets.pcapng, which contains several encrypted HTTP payloads.
While the HTTP requests user-agent indicated rustc-hyper/0.25.0, I actually did not observe any use of Rust at all. After some intense string-matching and OSINT, it was identified that the HTTP library the implant was actually using was cpp-httplib.
It was from here that the following functions were identified:
httplib::Client::Client(std::string& scheme_host_port)@0x14000cf30httplib::Result httplib::Client::Get(const std::string &path)@0x14000cf30httplib::Result httplib::Client::Get(const std::string &path, const Headers &headers, DownloadProgress progress)@0x14000d790httplib::Result httplib::Client::Post(const std::string &path, const std::string &body, const std::string &content_type, UploadProgress progress)@0x14000d820
Furthermore, since we can observe in the plaintext HTTP packets that JSON was being sent back and forth, there needed to be a JSON parser as well, and it was identified as JSON for Modern C++ with the following functions:
nlohmann::detail::lexer::lexer@0x140439a70nlohmann::json::parse@0x1404319d0nlohmann::json::count@0x1402b3ed0- used to determine the existence of a property by keynlohmann::json::dump@0x1402b40e0
While the code had some string XOR obfuscation on the Thread Local Stack and junk code inserted, there actually wasn’t much getting in the way of analysis.
From there, the logic of the implant was identified:
struct state
{
std::string username;
std::string computername;
std::string kernel_versionex;
std::string cpu_details;
std::string memory_details;
std::string host_information;
std::string password;
std::string new_remote_host;
int64_t field_100;
int64_t field_108;
int64_t field_110;
std::string remote_host;
int32_t remote_port;
};
- Initialise the above state @
0x14002e020- set
remote_hostastwelve.flare-on.com, andremote_portas 8000 - populate
username,computernameand other fields - set
passwordandnext_computernametoN/A
- set
- Retrieve the actual
next_remote_hostandpassword@0x140081590- GET
remote_host:remote_port/good - Parse and decode property value
d - Decrypt the value for
dusing thehost_informationvalue (USERNAME@COMPUTERNAME) as the key:- Substitute each input byte using the forward AES S-Box
- Subtract from each byte the current index minus 1
- XOR with the key
- Parse plaintext as JSON and retrieve the property key “ack”, and set
passwordandnew_remote_hostaccordingly
- GET
- Initialise implant @
0x1400d3e60- Serialise some fields of
stateand encrypt with the AES:- Key:
SHA256(host_information) ^ SHA256(password || dt)where dt is initially the strformat%Hof the current time
- Key:
- POST to
new_remote_host/
- Serialise some fields of
- Loop main-logic @
14012c6a0- GET
new_remote_host/get - Decrypt and execute commands from JSON property
d - POST output to
/re
- GET
Since we knew some property keys from the above reverse-engineering, I had attempted to derive the initial XOR key using a potential known-plaintext {"ack": ":
This yielded a potential key of ThwUess@T, but recall a humorous hint hidden in the plaintext packets:
Host: theannualtraditionofstaringatdisassemblyforweeks.torealizetheflagwasjustxoredwiththefilenamethewholetime.com:8080
Hence, I figured the start of the key was TheBoss@T, that is, the username was TheBoss, and the computername starts with T. Deriving this was simple enough:
def crack_good_sbox(ciphertext):
intermediate = bytearray()
for i, c in enumerate(ciphertext):
intermediate.append((AES_SBox[c] + 0xFF - i) & 0xFF)
cribbed_key = bytes([c ^ b'{"sta": "'[i] for i, c in enumerate(intermediate[:len('{"sta": "')])])
key = cribbed_key.ljust(19)
import string
print(bytes(intermediate).hex())
target_charset = string.printable.encode()
for i in range(len(cribbed_key), 0x20):
key = cribbed_key.ljust(i)
outputs = []
for i in range(0, len(intermediate), len(key)):
outputs.append(
bytes([
c ^ key[i % len(key)]
for i, c in enumerate(intermediate[i:i+len(key)], start=i)
])[:len(cribbed_key)]
)
if any(c not in target_charset for o in outputs for c in o):
continue
break
# From pcap:
# Host: theannualtraditionofstaringatdisassemblyforweeks.torealizetheflagwasjustxoredwiththefilenamethewholetime.com:8080
for o in outputs:
print(o.decode())
key = bytes([
c ^ b'eannualtraditionofs'[i]
for i, c in enumerate(
intermediate[len(key) * 2:(len(key) * 2) + len('eannualtraditionofs')])
])
# print(chr(((AES_SBox[ciphertext[-1]] + 0xff - (len(ciphertext) - 1)) & 0xFF) ^ b'}'[0]))
# print((len(ciphertext) - 1) % 0x18)
print(f"{key = }")
output = bytearray()
for i, c in enumerate(intermediate):
output.append(c ^ key[i % len(key)])
print(output)
key = key.decode()
password = json.loads(output.decode())["ack"].split("@")[0]
return key, password
From there, parsing and decryption was rather easy with pyshark:
cap = pyshark.FileCapture("packets.pcapng", display_filter='http')
for i, packet in enumerate(cap):
if hasattr(packet, 'http'):
# Access HTTP layer fields
if hasattr(packet.http, 'request_method'):
if packet.http.request_method == "GET" and packet.http.request_uri == "/good":
continue
if packet.http.request_method == "GET" and packet.http.request_uri == "/get":
continue
# print(f"HTTP Request: {packet.http.request_method} {packet.http.request_uri}")
if hasattr(packet.http, 'file_data'):
if key is None:
sbox_ciphertext = bytes.fromhex(json.loads(bytes.fromhex(packet.http.file_data.replace(':', '')))["d"])
hi, remote_password = crack_good_sbox(sbox_ciphertext)
from datetime import datetime
breakpoint()
hour = str(datetime.strptime(packet.http.date, "%a, %d %b %Y %H:%M:%S GMT").hour).rjust(2, "0")
key = derive_key(hi, remote_password, hour)
continue
data = json.loads(bytes.fromhex(packet.http.file_data.replace(':', '')))
if "d" not in data:
continue
d = bytes.fromhex(data["d"])
if (decrypted := decrypt(d)) == b'{"msg": "no_op"}':
continue
print(decrypted.decode())
{"sta": "excellent", "ack": "peanut@theannualtraditionofstaringatdisassemblyforweeks.torealizetheflagwasjustxoredwiththefilenamethewholetime.com:8080"}
{"ci":"Architecture: x64, Cores: 2","cn":"THUNDERNODE","hi":"TheBoss@THUNDERNODE","mI":"6143 MB","ov":"Windows 6.2 (Build 9200)","un":"TheBoss"}
{"sta": "ok"}
{"msg": "cmd", "d": {"cid": 2, "line": "whoiam"}}
{"op":""}
{"msg": "cmd", "d": {"cid": 6, "dt": 20, "np": "TheBoss@THUNDERNODE"}}
The decryption past the above C2 command was garbage, indicating some key-rotation was going on. Going back to the main loop and scrolling through a bunch of junk code later, I had identified the following cid values dispatched by 0x140137960:
- 2: Execute command @
0x140173c4b - 3: Shutdown implant @
0x1401da90e - 5: Get file contents @
0x140173c27- Reads file contents and returns as Base64
- 6: Rotate key @
0x1401e0ca2- Replaces the
passwordfield in the state and re-derives AES key
- Replaces the
try:
decrypted_json = json.loads(decrypted)
inner_d = decrypted_json.get("d")
if inner_d is None:
continue
if inner_d.get("cid") == 6:
key = derive_key(hi, inner_d["np"], hour)
except:
pass
After this was fixed, we can see more commands:
{"msg": "cmd", "d": {"cid": 2, "line": "dir /b /s C:\\\\Users\\\\%USERNAME%\\\\Documents\\\\Studio_Masters_Vault\\\\"}}
...
...
{"msg": "cmd", "d": {"cid": 5, "lp": "C:\\Users\\TheBoss\\Documents\\Studio_Masters_Vault\\The_Vault\\rocknroll.zip"}}
{"fc":"UEsDBBQAAQAIAERqEltA1H/...}
{"msg": "cmd", "d": {"cid": 6, "dt": 25, "np": "miami"}}
{"msg": "cmd", "d": {"cid": 6, "dt": 25, "np": "miami"}}
{"msg": "cmd", "d": {"cid": 2, "line": "dir /b /s C:\\Users\\%USERNAME%\\Documents\\Personal_Stuff"}}
{"op":"C:\\Users\\TheBoss\\Documents\\Personal_Stuff\\Financial\nC:\\Users\\TheBoss\\Documents\\Personal_Stuff\\passwords.txt\nC:\\Users\\TheBoss\\Documents\\Personal_Stuff\\Photos\nC:\\Users\\TheBoss\\Documents\\Personal_Stuff\\Financial\\tax_info_2023.pdf\nC:\\Users\\TheBoss\\Documents\\Personal_Stuff\\Photos\\Asbury_Park_sunset.jpg\nC:\\Users\\TheBoss\\Documents\\Personal_Stuff\\Photos\\old_guitar.jpg\n"}
{"msg": "cmd", "d": {"cid": 6, "dt": 1, "np": "miami"}}
{"msg": "cmd", "d": {"cid": 5, "lp": "C:\\Users\\TheBoss\\Documents\\Personal_Stuff\\passwords.txt"}}
{"fc":"RW1haWw6IEJvcm5Ub1J1biE3NQ0KQmFuazogVGhlUml2ZXIjIzE5ODANCkNvbXB1dGVyTG9naW46IFRoZUJvc3NNYW4NCk90aGVyOiBUaGVCaWdNQG4xOTQyIQ0K","sta":"success"}
{"msg": "cmd", "d": {"cid": 2, "line": "echo \"BRUUUUUUUUUUUUUUUUUUUCCCCCEEEEEEEEEEEEEEEEEEEEEEEEE\" > C:\\Users\\%USERNAME%\\Desktop\\thanks.txt"}}
{"op":""}
{"msg": "cmd", "d": {"cid": 3}}
We managed to retrieve the file C:\Users\TheBoss\Documents\Studio_Masters_Vault\The_Vault\rocknroll.zip, but it’s actually password encrypted. However, we can also see passwords.txt being exfiltrated:
Email: BornToRun!75
Bank: TheRiver##1980
ComputerLogin: TheBossMan
Other: TheBigM@n1942!
Iterating through each of these passwords, the password TheBigM@n1942! managed to decrypt the compressed guitar_sax_flag.jpg:

Flag: C4N7_ST4R7_A_FLAR3_WITHOUT_4_$PARK@FLARE-ON.COM
FlareAuthenticator
Solve time:
October 6th, 4:06:48 AM
The provided PE binary is based on Qt, and is heavily obfuscated with Mixed-Boolean Arithmetic expressions throughout the code. Experimenting with the binary, the user is prompted with a 25-digit long input for numeric digits.
Starting @ 0x14008f028, we can see some interesting strings relating to Qt window titles and event handler names:
AuthenticatorWindowonNumberClickedonDeleteClickedonSubmitClicked
Moreover, nearby @ 0x14008f140, there was a pointer to a function, which had a switch case comparing the third argument - further debugging using x64dbg confirmed my suspicion; I had found the event dispatcher and can easily find the relevant functions:
void sub_140001000(int64_t arg1, int32_t arg2, int32_t arg3) {
int64_t r9;
int64_t var_8 = r9;
if (arg2)
return;
if (!arg3)
onNumberClicked(arg1); // @ 0x140012e50
else if (arg3 == 1)
onDeleteClicked(arg1); // @ 0x14002f8c0
else if (arg3 == 2)
onSubmitClicked(arg1); // @ 0x1400202b0
}
One obfuscation that was trivial to work around in Binary Ninja was the use of synthetic QWORDs, like in the following example:
(data_1400ad070 + 0x2a003092123ff2bd)(
*(uint64_t*)(data_1400a1be8 + 0x749a6a68402a5cc0)(
*(uint64_t*)(data_1400ba938 + 0x68e7ed683f65c868)((char*)arg1 + 0xd40), 0x749a6a68402a5cc0),
0x2a003092123ff2bd);
void* rax_8 = data_14009ef18 - 0x13650583dc72fb0e;
*(uint64_t*)((char*)arg1 + 0x338) = (char*)arg1 + 0x13a0;
(data_1400b3828 + 0x6c3c353b2092aafa)(
*(uint64_t*)(data_1400c2928 - 0x569430851393b854)(*(uint64_t*)rax_8((char*)arg1 + 0x13a0), -0x569430851393b854),
0x6c3c353b2092aafa);
(data_1400b7268 + 0x72bb5e7b03ff4fe6)((char*)arg1 + 0x18e0);
(data_1400a8fe0 - 0x24d0c3ff04fc124b)((char*)arg1 + 0x1eb0);
(data_1400bef70 - 0xff55b220a18fd82)(
*(uint64_t*)(data_1400ba250 - 0x4efcecc0d50b6f0d)(
*(uint64_t*)(data_1400c4f28 + 0x170dad45a08db71a)(
*(uint64_t*)(data_1400b5f60 - 0x561c765d6a915e84)((char*)arg1 + 0x1250), 0x170dad45a08db71a),
-0x4efcecc0d50b6f0d),
-0xff55b220a18fd82);
All of the data_ references are simply uint64_ts in the writable .data, though these don’t appear to ever be modified in this program, but since the IL lifter assumes that these are non-constant, these references are not constant-propagated which would cause the above expressions to resolve to actual pointers. This can be fixed with the following script:
for i in range(0x14009c0e0, 0x1400c65d0, 0x8):
bv.define_data_var(i, Type.pointer(bv.arch, Type.void(), True))
The result is the following - note that many calls to pure functions are also inlined by Binary Ninja:
*(uint64_t*)((char*)arg1 + 0x338) = (char*)arg1 + 0x13a0;
*(uint64_t*)((char*)arg1 + 0x340) =
*(uint64_t*)sub_14007c010(*(uint64_t*)sub_140073b80(*(uint64_t*)((char*)arg1 + 0x338))) + 0x18;
int64_t* rax_14 = sub_14005d390(*(uint64_t*)sub_14007ce80((char*)arg1 + 0x14c0));
This tiny deobfuscation allowed immensely in determining what is genuine code or not.
I’ve assumed that the first argument must be some sort of QWidget instance for AuthenticatorWindow, so I experimented more in my Windows VM, observing and modifying the program memory, and further tracing, and have identified the following:
- an
std::stringatAuthenticatorWindow+0x60which is synchronised with the user input - a
uint64_tatAuthenticatorWindow+0x78, which appears to be a checksum for the current user input- modified by
onNumberClicked - appears to be an additive sum of each input digit’s hash, since modifying the value and then adding or removing input digits causes an inconsistency between the expected hash and the value, with the difference being the modification made to the value before changes
- the additive components of the hash appears to be dependent on the position of the characters, as
hash("99") != hash("9") * 2
- modified by
uint32_t h(uint16_t n)@0x140081760, called byonNumberClicked- a checksum comparison against the target value of
0x0bc42d5779fec401
Additionally, I figured out how the checksum is constructed from further experimentation.
- The checksum for input
0is0x19B3240445AA06- Observed the following calls for this input
h(1) == 0x279342Fh(0x130) == 0xA63E6DA
- The checksum is divisible by the results of both calls, meaning that
checksum("0") == h(1) * h(0x130)
- Observed the following calls for this input
- The checksum for input
09is0xABCF667DF753AE- Observed the following calls for the last character
h(2) == 0xC678DB8h(0x239) == 0xBC76073- `checksum(“09”) - checksum(“0”) == 0xB959E250DB2798 == h(2) * h(0x239)
- Observed the following calls for the last character
So for every character entered or deleted there are calls to this function to determine the checksum delta:
- one call with n = input_length
- another call with n = (input_length * 0x100) + c, c being the uint8_t value of the new digit (e.g. “0” is 0x30)
I had attempted to use something like msynth on processed decompiled pseudocode and while it worked well for some expressions, I was too lazy to automate something with a Binary Ninja script. I also did write some (embarassing) peephole optimisation scripts by pattern-matching the disassembly text of the specified function. After some painstaking dynamic analysis to extract obfuscated constants from the MBA expressions, I finally came to the following Python equivalent which matched my observed plaintext-hash pairs:
def h(n):
# xored = n ^ 0x3d
# rax_97 = ((xored << 0x03) + xored) & 0xFFFFFFFF
rax_97 = (((n & 0xFFFF) ^ 0x3d) * 9) & 0xFFFFFFFF
var_f0 = (((rax_97 ^ (rax_97 >> 0x04)) * 0x27d4eb79)) & 0xFFFFFFFF
return (((var_f0 >> 0x0f) ^ var_f0) >> 0x04) & 0xFFFFFFFF
def checksum(user_input):
output = 0x0
for i, c in enumerate(user_input, start=1):
output += h(i) * h((i * 0x100) + ord(c))
return output & ((1 << 64) - 1)
The above hash borrows from Thomas Wang’s hash32shiftmult. And then… I was stuck again - I wasn’t sure how to go about hitting the target. Since the checksum itself is additive, I guessed that this was probably a subset sum problem, where the set is the output of h for every digit in every position - a set with \(10^25\) unique elements, from which we have to pick only 25.
Attempting to ask ChatGPT for a potential solve script, the resulting code either took too long or demanded a large amount of resources, and I spent more than 3 hours trying to implement a parallel C++ version with bitsets for space optimization.
In between optimising for the subset sum problem and further research into the problem, I did a quick sanity check where I selected the digit which had the maximum checksum term for every position:
>>> M = [
... [h(i) * h((i * 0x100) + ord(str(j))) for j in range(10)]
... for i in range(1, 26)
... ]
>>> max_inputs = [max(enumerate(row), key=lambda t: t[1]) for row in M]
>>> sum(part for _, part in max_inputs)
847852483584640001
“Okay that works then…” “Oh wait,” “OH WAIT”
>>> "".join(str(digit) for digit, _ in max_inputs)
4498291314891210521449296
A satisfying reversing game, but a frustratingly stupid solve. In hindsight, if I had known(?) or guessed that I needed the maximum component for each position, I could have easily emulated it with Qiling:
from qiling import Qiling
ql = Qiling(["./rootfs/x8664_windows/FlareAuthenticator.exe"], "./rootfs/x8664_windows")
H_FUNCTION = 0x140081760
def emulate_hash(n: int):
ql.arch.regs.write("dx", n)
ql.arch.stack_push(0x0)
ql.run(begin = H_FUNCTION, end=0x0)
return ql.arch.regs.read("eax")
def emulate_decrypt(ciphertext: bytes, key: str):
plaintext_mem = ql.os.heap.alloc(len(ciphertext))
ql.arch.regs.write("rdx", plaintext_mem)
ciphertext_mem = ql.os.heap.alloc(len(ciphertext))
ql.mem.write(ciphertext_mem, ciphertext)
ql.arch.regs.write("r8", ciphertext_mem)
ql.arch.regs.write("r9", len(ciphertext))
key_mem = ql.os.heap.alloc(len(key) + 1)
ql.mem.write(key_mem, key.encode() + b"\0")
ql.arch.stack_push(key_mem)
for _ in range(4):
ql.arch.stack_push(0x0)
ql.arch.stack_push(0x0)
ql.run(begin=0x14005ef60, end=0x0)
plaintext = ql.mem.read(plaintext_mem, len(ciphertext))
ql.os.heap.free(plaintext_mem)
ql.os.heap.free(ciphertext_mem)
ql.os.heap.free(key_mem)
return plaintext
from tqdm import tqdm
M = [
[emulate_hash(i) * emulate_hash((i * 0x100) + ord(str(j))) for j in tqdm(range(10), leave=False)]
for i in tqdm(range(1, 26))
]
max_inputs = [
max(enumerate(row), key=lambda t: t[1])
for row in M
]
print("Target:", hex(0x0bc42d5779fec401))
print("State:", hex(sum(n for _, n in max_inputs)))
print("\n".join("".join(str(digit) for digit, _ in max_inputs[i:i+5]) for i in range(0, 25, 5)))
print(emulate_decrypt(
bytes.fromhex("0E 55 A3 F6 09 14 49 C2 56 17 7C E6 2D 0F 8A 61 B4 15 E8 5A 49 43 9F F9 4B BF 89 6D 6D 44 90 BE C4 F5 BB 3A"),
"".join(str(digit) for digit, _ in max_inputs),
))
[=] Done loading vcruntime140.dll
100%|███████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 25/25 [00:03<00:00, 6.84it/s]
Target: 0xbc42d5779fec401
State: 0xbc42d5779fec401
44982
91314
89121
05214
49296
[=] strlen(str = "4498291314891210521449296") = 0x19
bytearray(b's0m3t1mes_1t_do3s_not_m4ke_any_s3n5e')
10000
Solve time:
October 15th, 4:48:25 PM(blocked by ECSC)
Provided was a very large (> 1GB) PE file with 10000 resources. The main function (@ 0x140001e87) would open a license.bin file, expected to be 34 * 10000 bytes, and ran some checks dependent on each 34-byte chunk before hashing the license file contents using SHA256 and using the digest as a key in an AES-CBC cipher to decrypt the flag.
The function to load and attach PE DLLs (@ 0x140001482) loaded resource data by the user-specified index, decompressed it, and loaded the file into virtual memory, parsing and handling exports and imports (also from other resource-packed DLLs), while ensuring that relocations were fixed, before calling its entrypoint.
The resources, while obviously PE files at this point, were slightly corrupted - I could identify the very recognisable MZ header in the files, but it didn’t look readable as-is, indicating there was some encryption or compression being used. Identifying the other libraries like picosha2 (via embedded strings) and plusaes CBC (from error strings) was simple enough, but the license checker used some form of compression algorithm I had never seen before, nor was it easily attributable through strings. From what I could gather in the PE loading, there was one function (@ 0x140002690) which appeared to calculate the expected decompressed size, and another (@ 0x1400035e8) which actually handled the given resource’s data pointer (returned by LockResource) and what appeared to be the decompressed output buffer.
Looking at the decompressed length function, I had noticed the following comparison in the decompilation:
if (rax_111 <= 0x7f || rax_111 > 0x7cff) {
var_1c_1 += 2;
} else if (rax_111 > 0x4ff) {
var_1c_1 += 1;
}
I had trawled through GitHub for these constants and off-by-one variants since there were greater-or-equal comparisons, and searching for "0x7d00" "0x500" lead me to the following code fragment from aplib-ripper:
def lengthdelta(offset):
if offset < 0x80 or 0x7D00 <= offset:
return 2
elif 0x500 <= offset:
return 1
return 0
Looks like we have our decompression algorithm, aPLib. Time to test this, while working on some Python dumping code ourselves. Funnily enough, the usual pefile module that I use has a hard-coded limit of 4096 resource entries before it bails out, meaning I’d actually have to rewrite some of the resource directory parsing code myself:
import pefile
pe = pefile.PE("./10000.exe", fast_load=True)
resources = {}
def read_image_resource_directory(rva):
try:
data = pe.get_data(
rva, pefile.Structure(pe.__IMAGE_RESOURCE_DIRECTORY_format__).sizeof()
)
return pe.__unpack_data__(
pe.__IMAGE_RESOURCE_DIRECTORY_format__,
data,
file_offset=pe.get_offset_from_rva(rva),
)
except:
raise ValueError()
def recurse_resources(current_table, base_rva = None, parent_id=None):
current_rva = pe.get_rva_from_offset(current_table.get_file_offset())
if not base_rva:
base_rva = current_rva
current_rva += current_table.sizeof()
for i in range(current_table.NumberOfIdEntries):
directory_table_entry = pe.parse_resource_entry(current_rva)
current_rva += directory_table_entry.sizeof()
child_rva = base_rva + directory_table_entry.OffsetToDirectory
if directory_table_entry.DataIsDirectory:
recurse_resources(
read_image_resource_directory(child_rva),
base_rva=base_rva,
parent_id=directory_table_entry.Id,
)
else:
resources[parent_id] = pe.parse_resource_data_entry(base_rva + directory_table_entry.OffsetToDirectory)
recurse_resources(read_image_resource_directory(pe.OPTIONAL_HEADER.DATA_DIRECTORY[2].VirtualAddress))
And in conjunction with the aplib module…
> /home/void/ctf/flareon-12/9-10000/solve/extract.py(49)<module>()
(Pdb) import aplib
(Pdb) p aplib.decompress(pe.get_data(resources[0].OffsetToData, resources[0].Size))[:200]
b'MZ\x90\x00\x03\x00\x00\x00\x04\x00\x00\x00\xff\xff\x00\x00\xb8\x00\x00\x00\x00\x00\x00\x00@\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x80\x00\x00\x00\x0e\x1f\xba\x0e\x00\xb4\t\xcd!\xb8\x01L\xcd!This program cannot be run in DOS mode.\r\r\n$\x00\x00\x00\x00\x00\x00\x00PE\x00\x00d\x86\n\x00\xea\xd2\xc7h\x00\x00\x00\x00\x00\x00\x00\x00\xf0\x00."\x0b\x02\x02,\x00\xe8\x04\x00\x00\xe0\x00\x00\x00\x02\x00\x000\x13\x00\x00\x00\x10\x00\x00\x00\x00\x13$\x02\x00\x00\x00\x00\x10\x00\x00\x00\x02\x00\x00\x04\x00\x00\x00\x00\x00\x00\x00'
(Pdb)
Nice. Ten thousand input checkers. There’s actually a bit more going on here if we go back to the main program logic:
- Each license part consists of a 2-byte integer which specifies which DLL to load and call its
bool check(unsigned char*)function- During load, the entrypoint is called with a
uint32_t[10000]array we’ll callcall_info(discussed later), stores the pointer of that array in the main program memory, to the DLL’s virtual memory.
- During load, the entrypoint is called with a
- There exists a
vector<bool>and the program checks against it and updates it to ensure each DLL is called at most one time the entire runtime. call_infois modified after each DLL call and is later compared to an expected target array after the entire license file is processed:- After every license part and subsequent DLL
checkcall, at the indices of every DLL file that was loaded - that is, the license-specified DLL and every one of its dependency, and every one of their dependencies, and so on - those array elements are added by the index of the current license chunk.
- After every license part and subsequent DLL
Dependency order
I went for the easier route of cracking DLL call order rather than going through the thousand-cuts problem. I assumed that the total dependency tree of all of the packed DLLs would not have any cycles and basically be a directed acyclic graph - of which there would be “roots” with no predecessors, i.e. DLL files which don’t have any other DLL files importing it. Since there are 10000 license parts and every DLL is called once at most, it would follow that every DLL must be called only once, and thus the “root” DLLs would have their call_info element incremented only once, by its own license part index. From there, we can reverse the increment for every one of its descendant dependencies, and repeat until we have the full order of DLL parts:
import numpy as np
import rustworkx as rx
def solve_order(dependency_graph: rx.PyDiGraph, call_info: np.ndarray[tuple[int], np.dtype[np.uint32]]):
operations: list[int | None] = [None for _ in range(10000)]
successors = set(list(dependency_graph.node_indices()))
while (parent_dlls := [n for n in successors if dependency_graph.in_degree(n) == 0]):
print(f"{len(dependency_graph):5} - {parent_dlls}")
next_successors = set()
for parent_dll in parent_dlls:
call_number = call_info[parent_dll]
if call_number >= 10000:
raise ValueError()
call_info[np.array(list(rx.descendants(dependency_graph, parent_dll)), dtype=np.uint64)] -= call_number
if operations[call_number] is not None:
raise ValueError()
operations[call_number] = parent_dll
next_successors.update(
child
for child in dependency_graph.successor_indices(parent_dll)
if call_info[child] < 10000
)
dependency_graph.remove_node(parent_dll)
successors = next_successors
return operations
This basically-a-BFS-search actually returned an order of operations without any errors. We still don’t have the input data for each of these operations to assemble the valid license file, so it was time to look at the packed DLLs. The code to actually generate the dependency graph is also now integrated in the solve scripts discussed later.
The check function
Within each DLL there was the previously mentioned check function called by the main binary, and several hundred functions prefixed with f and and a 20-digit numeric ID. After some manual analysis, it was determined that these f functions were transforms over the input bytearray which performed the following:
- XOR the first two bytes of the array with
call_info[resourceIndexOfTheContainingDLL] - One of the three following:
- Byte substitution with a
uint8_t[0x100]array initialised on the stack - Array permutation with a
uint8_t[0x20]array of indices initialised on the stack - Modular exponentiation (using square-and-multiply) using a
uint8_t[0x1f]little-endian exponent initialised on the stack.- This does modify the input bytearray first, by ensuring the LSB of the input (treated as a little-endian 128-bit integer) is set to 1, and flipping the bit of the output by XORing with the original LSB to ensure that this operation is bijective and hence invertible
- Byte substitution with a
The check function calls several of these transformation functions (all of which are trivially invertible), either from within the same module or imported from one of its dependencies. After which, it will initialise the following variables on the stack:
- a
uint128_t\(p\) a prime to act as the field for operations discussed later - a
uint64_t\(E\), an odd exponent - a
uint128_t[4][4]\(T\), a \(F_{p}^{4\times4}\), serving as a target matrix - a
uint128_t[4][4]\(X\), another matrix which is XORed against the transformed input - another
uint128_t[4][4]\(M\), initialised from transformed 32-byte input
With the input-derived matrix, it does a series of addition, subtraction, multiplication from several elements under \(M\) before comparing the result to 0, as an initial check, returning early if it fails.
Curiously after that, it does what appeared to be the same square-and-multiply technique but over the input matrix, which I deduced from the identity matrix 4x4 stack-initialised before a for-loop iterating through \(E\) as a bitstring. The result of the exponentiation is compared to \(T\) element-wise - hence checking that \(M^{E} = T\) - with equality returning the required true from the check function.
Linear algebra Basic Math 10101
Given we have an odd exponent \(E\), and a field with a singular prime \(p\) which we can trivially deduce the modular multiplicative inverse, I thought of simply taking \(T^{d}\) to get the prerequisite \(M\) before I can reverse the final XOR and then all of the previous f transforms. I figured it wouldn’t be as simple as that since the operand was a matrix rather than an integer like in plain RSA, and indeed it had not worked, using a manually extracted case from one of the DLLs under SageMath:
p = 0xfb7ace0d1348cae5
R = IntegerModRing(p)
e = 0x157b2b1df4fb54bb
d = inverse_mod(e, p)
target = Matrix(R, [
[0xf1c28e85bbb1b987, 0xa6f6278b3e1e0f6e, 0x7af37868715b8cc , 0x8d7f735fc0a8c523],
[0xe18c967fb8bd0c3c, 0xb87cde9d558c0b4b, 0x247f23341f7e80cd, 0xc1b678a391eb8672],
[0x79114176a65411ad, 0xbf61eb9166eb202e, 0x567a63f58e433587, 0x6448ba999c560bd2],
[0xab76c5a704cad032, 0xe8f39678b76d7d16, 0x6f9f716c36ebc5e4, 0x11031a7fa83442d1],
])
(target ** d) ** e == target # False
Stumped, I had attempted to ask ChatGPT how I would approach this, and I was directed to look at Jordan normal forms… which didn’t work.
p = 0xfb7ace0d1348cae5
R = IntegerModRing(p)
e = 0x157b2b1df4fb54bb
d = inverse_mod(e, p)
target = Matrix(R, [
[0xf1c28e85bbb1b987, 0xa6f6278b3e1e0f6e, 0x7af37868715b8cc , 0x8d7f735fc0a8c523],
[0xe18c967fb8bd0c3c, 0xb87cde9d558c0b4b, 0x247f23341f7e80cd, 0xc1b678a391eb8672],
[0x79114176a65411ad, 0xbf61eb9166eb202e, 0x567a63f58e433587, 0x6448ba999c560bd2],
[0xab76c5a704cad032, 0xe8f39678b76d7d16, 0x6f9f716c36ebc5e4, 0x11031a7fa83442d1],
])
J, P = target.jordan_form(transformation=True)
J_root = J.apply_map(lambda x: x ** inverse_mod(e, p))
T = P * J * ~P
RuntimeError: Some eigenvalue does not exist in Ring of integers modulo 18121022606232046309.
I had spent hours debugging this with ChatGPT again, but I eventually gave up. After a sleep quickly interrupted by frustration, I just simply searched for “Matrix RSA”, which lead to the paper A Matrix Extension of the RSA Cryptosystem by Andrew Pangia (December 2014). Skimming through the paper, I realised I was working taking the multiplicative inverse from the wrong modular group - instead of an integer ring modulo ring \(\mathbb{Z}/p\mathbb{Z}\), I should have been using the general linear group for matrices \(GL_{4}(F_p)\):
p = 0xfb7ace0d1348cae5
R = IntegerModRing(p)
e = 0x157b2b1df4fb54bb
d = inverse_mod(e, GL(4, R).order())
target = Matrix(R, [
[0xf1c28e85bbb1b987, 0xa6f6278b3e1e0f6e, 0x7af37868715b8cc , 0x8d7f735fc0a8c523],
[0xe18c967fb8bd0c3c, 0xb87cde9d558c0b4b, 0x247f23341f7e80cd, 0xc1b678a391eb8672],
[0x79114176a65411ad, 0xbf61eb9166eb202e, 0x567a63f58e433587, 0x6448ba999c560bd2],
[0xab76c5a704cad032, 0xe8f39678b76d7d16, 0x6f9f716c36ebc5e4, 0x11031a7fa83442d1],
])
(target ** d) ** e == target # True
🤦.
After I had solved this challenge, I was told that since the determinant of the target matrix was 1, one can simply invert against the multiplicative subgroup of the general linear group specific to \(T\), to have optimised my solve script a little faster.
sage: inverse_mod(e, GL(4, R).order()) % target.multiplicative_order() == inverse_mod(e,
....: target.multiplicative_order())
True
sage: (target ** inverse_mod(e, target.multiplicative_order())) ** e == target
True
🤦. Subgroups are fun.
Extraction
I had experimented a little to circumvent memory limitations, improve iteration times over binary analysis, and on-disk caching, but here’s what I settled on:
extract.py which lifts every binary into a sort of symbolic IR for every transformation and matrix exponentiation check.
It uses the shelve module, a sort of on-disk pickle KV database as a storage for lifted functions, and extracts the file offset and length of every resource-packed DLL file and passes everything to a concurrent.futures.ProcessPoolExecutor which processes each compressed DLL file in the following manner:
- Uses
aplibto decompress - Uses
pefileto parse only the exports, imports and exception data of the DLL:- Imports are used to determine the side effects of loading this DLL on
call_info, while also mapping importedffunction to their RVA- Output the imported DLLs in the
shelvedatabase
- Output the imported DLLs in the
- Exports are used to map the names of relevant functions to their RVA
- Exception data is used to determine the bounds of each relevant function that was exported
- Imports are used to determine the side effects of loading this DLL on
- Analyses exported transform functions using
iced_x86:- Emulate any
mov r64, imm64by storing the value in astate: dict[Register, int] - Identify
mov [rbp-offset], r64and lift it to theStackStoredataclass, which storesoffset: intandvalue: int - Stop once we are finished with the stack stores, and identify the length of the stack array using lowest stored offset (highest on the stack) and the end of the highest stored offset (e.g.
max(stor.offset for store in stores) + 8). - Recreate the array as a
const: bytearrayand determine the type of transform from the length of the stack array:0x1fbytes for modular exponentiation0x20bytes for permutation0x100bytes for substitution
- Calculate and store inverse constants for each operation
- Output each transform
ffunctions as a dict to be merged in theshelvedatabase.
- Emulate any
- Analyse the
checkfunction:- Expect and parse every
callinstruction to anffunction.- This required handling an “edge case” where it may call an imported function, in which case it will do
call [rax], where theraxregister was previously set (and consequently relocated in an actual PE load)
- This required handling an “edge case” where it may call an imported function, in which case it will do
- Extract every
StackStorepossible until the end of the function - From the array of
StackStores, shift and extract the stores foruint128_t modulo,uint64_t exponent, anduint128_t xors[4][4] - From the same array for
StackStores, take the last 32 64-bit values and extractuint128_t target[4][4]. - Determine the required post-transform input matrix \(M\):
- Invert the matrix
targetusing the knownexponentandmodulo - XOR element-wise with
xors
- Invert the matrix
- Output the lifted check function as an instance of the dataclass
CheckFunctionstoring required input matrix \(M\), and integer list of the ID of every operation it calls to save space on-disk in theshelvedatabase.
- Expect and parse every
After analysing all of the DLL files, the script will use the previous BFS graph algorithm to determine a call order using every DLL, and also output that as a rustworkx graph in the shelve database.
The extraction script outputs a database around 2.8 gigabytes worth of pickled dataclasses. From this the solve script can use the database concurrently by opening it as read-only:
futures = {}
for i, dll in enumerate(tqdm(order)):
futures[executor.submit(process_one, dll, i, call_info.copy())] = dll
call_info[[dll] + list(rx.descendants(dependency_graph, dll))] += i
- Prepare tasks for a
concurrent.futures.ProcessPoolExecutor:- Initialise a
call_infousing NumPy:np.zeros(10000, dtype=np.uint32) - Iterate through the call order:
- Submit a task to determine the input parameter for the current DLL, using a copy of
call_info - Modify
call_infoin the same manner as the binary using the current DLL, its dependencies and all of its transitive dependencies by incrementing by the current position in the order
- Submit a task to determine the input parameter for the current DLL, using a copy of
- Initialise a
Determining the uint8_t input[0x20] for each DLL and the current call_info is trivial, only a bit tedious for the CPU:
- Open a local handle to the
shelvedb and load theCheckFunctiondata for the current DLL, and all of its transformation operations - Using the precomputed post-transform input and the inverse transformations, iterate through the reversed transformations inverting the state, outputting the expected argument bytearray for the current DLL as part of the expected license part bytearray for the given position:
def process_one(dll, i, call_info): with shelve.open("lifted_dlls", "r") as db: check = db[f'{dll}.dll'] operations = [db[str(op)] for op in check.operations] target = b"".join(n.to_bytes(8, "little") for n in check.target) argument = target for op in reversed(operations): argument = op.invert(argument) argument = strxor(argument, int(call_info[op.xor_offset]).to_bytes(0x20, "little")) return i, dll.to_bytes(2, "little") + argument
Once we have every argument, it’s as simple as concatenating the different license parts and using it as it was intended:
...
for future in tqdm(as_completed(futures), total=len(futures)):
i, part = future.result()
license_parts[i] = part
if any(part is None for part in license_parts):
raise ValueError()
license = b"".join(license_parts)
if len(license) != 34 * 10000:
raise ValueError()
with open("license.bin", "wb") as f:
f.write(license)
with shelve.open("lifted_dlls", "r") as db:
from Crypto.Hash import SHA256
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
print(
unpad(
AES.new(
SHA256.new(data=license).digest(),
AES.MODE_CBC,
iv=db["iv"]
).decrypt(
db["ciphertext"]
),
16
).decode()
)
Flag: Its_l1ke_10000_spooO0o0O0oOo0o0O0O0OoOoOOO00o0o0Ooons@flare-on.com