40 minute read

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_SIGNATURE is compared against the current_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.

The corrupted unpacked image file

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) @ 0x140ff0fe0
  • bool ads_write_int64(std::wstring& stream_name, int64_t data) @ 0x140ff1190
  • bool ads_read_bytes(std::wstring& stream_name, uint8_t* data_out, uint32_t len) @ 0x14000af00
  • bool ads_write_bytes(std::wstring& stream_name, uint8_t* data, uint32_t len) @ 0x14000ade0
  • bool 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 position and transitions to 0, state to -1, and the input bytearray to all zeroes.

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) @ 0x14000cf30
  • httplib::Result httplib::Client::Get(const std::string &path) @ 0x14000cf30
  • httplib::Result httplib::Client::Get(const std::string &path, const Headers &headers, DownloadProgress progress) @ 0x14000d790
  • httplib::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 @ 0x140439a70
  • nlohmann::json::parse @ 0x1404319d0
  • nlohmann::json::count @ 0x1402b3ed0 - used to determine the existence of a property by key
  • nlohmann::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_host as twelve.flare-on.com, and remote_port as 8000
    • populate username, computername and other fields
    • set password and next_computername to N/A
  • Retrieve the actual next_remote_host and password @ 0x140081590
    • GET remote_host:remote_port/good
    • Parse and decode property value d
    • Decrypt the value for d using the host_information value (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 password and new_remote_host accordingly
  • Initialise implant @ 0x1400d3e60
    • Serialise some fields of state and encrypt with the AES:
      • Key: SHA256(host_information) ^ SHA256(password || dt) where dt is initially the strformat %H of the current time
    • POST to new_remote_host/
  • Loop main-logic @ 14012c6a0
    • GET new_remote_host/get
    • Decrypt and execute commands from JSON property d
    • POST output to /re

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 password field in the state and re-derives AES key
            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 image

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:

  • AuthenticatorWindow
  • onNumberClicked
  • onDeleteClicked
  • onSubmitClicked

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::string at AuthenticatorWindow+0x60 which is synchronised with the user input
  • a uint64_t at AuthenticatorWindow+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
  • uint32_t h(uint16_t n) @ 0x140081760, called by onNumberClicked
  • 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 0 is 0x19B3240445AA06
    • Observed the following calls for this input
      • h(1) == 0x279342F
      • h(0x130) == 0xA63E6DA
    • The checksum is divisible by the results of both calls, meaning that checksum("0") == h(1) * h(0x130)
  • The checksum for input 09 is 0xABCF667DF753AE
    • Observed the following calls for the last character
      • h(2) == 0xC678DB8
      • h(0x239) == 0xBC76073
      • `checksum(“09”) - checksum(“0”) == 0xB959E250DB2798 == h(2) * h(0x239)

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 call call_info (discussed later), stores the pointer of that array in the main program memory, to the DLL’s virtual memory.
  • 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_info is 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 check call, 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.

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

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 aplib to decompress
  • Uses pefile to 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 imported f function to their RVA
      • Output the imported DLLs in the shelve database
    • 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
  • Analyses exported transform functions using iced_x86:
    • Emulate any mov r64, imm64 by storing the value in a state: dict[Register, int]
    • Identify mov [rbp-offset], r64 and lift it to the StackStore dataclass, which stores offset: int and value: 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: bytearray and determine the type of transform from the length of the stack array:
      • 0x1f bytes for modular exponentiation
      • 0x20 bytes for permutation
      • 0x100 bytes for substitution
    • Calculate and store inverse constants for each operation
    • Output each transform f functions as a dict to be merged in the shelve database.
  • Analyse the check function:
    • Expect and parse every call instruction to an f function.
      • This required handling an “edge case” where it may call an imported function, in which case it will do call [rax], where the rax register was previously set (and consequently relocated in an actual PE load)
    • Extract every StackStore possible until the end of the function
    • From the array of StackStores, shift and extract the stores for uint128_t modulo, uint64_t exponent, and uint128_t xors[4][4]
    • From the same array for StackStores, take the last 32 64-bit values and extract uint128_t target[4][4].
    • Determine the required post-transform input matrix \(M\):
    • Output the lifted check function as an instance of the dataclass CheckFunction storing required input matrix \(M\), and integer list of the ID of every operation it calls to save space on-disk in the shelve database.

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_info using 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_info in 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

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 shelve db and load the CheckFunction data 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

Updated: