Logo
Break the Syntax 2025 Writeups

Break the Syntax 2025 Writeups

tlsbollei tlsbollei
May 11, 2025
14 min read
index

Break the Syntax was a pretty cool CTF. Sitting comfortably around intermediate level difficulty, however I really loved the rev challenges, they were so unique and such a nice breath of fresh air. I managed to full solve all rev tasks quite quickly, and moved on to help my teammates with other categories. We competed as part of rakuz4n, which is a fun team made up of friends that I captain.

Full Solve

rev - the Last Xor in Lithuania (452 points)

we are given a binary called a.out and an assignment which hints towards XOR (Exclusive OR) operations. from the Break the Syntax 2025 CTF, this was the rev task with the least amount of solves, which is surprising due to its simple nature, as opposed to other rev tasks.

control flow architecture

(i used Binja with Pseudo C decompilation)

the binary uses a tightly-scoped entrypoint, passing all logic through a single function:

int main(int argc, char** argv) {
sub_40119d(argv[1]);
return 0;
}

this is a single entry point into decryption and validation logic. the function sub_40119d sets up the xor-based stage exec.

inside of the sub_40119d function, we observe :

  1. the first input byte is extracted and used as a decryption key input[0]
  2. the function sub_401149 is called to xor-decrypt a 0xc7-byte block at 0x4073d2
  3. the binary then enters a loop, calling each entry in a function pointer table stored at 0x404c60 , incrementing through the table

simplified , cleaned up pseudocode (i renamed the function sub_401149 that was mentioned in step 2 as xor_decrypt

void sub_40119d(char* input) {
uint8_t xor_key = input[0];
xor_decrypt(0x4073d2, 0xc7, xor_key);
void (**table)() = (void**)0x404c60;
size_t i = 1;
while (1) {
table[i - 1](input[i]);
i++;
}
}

sub_401149 , or as we renamed it, xor_decrypt , applies a basic in-place xor over a fixed-size buffer:

void xor_decrypt(uint8_t* data, size_t len, uint8_t key) {
for (size_t i = 0; i < len; ++i) {
data[i] ^= key;
}
}

function pointer chain

starting at 0x404c60, the binary defines a flat array of 48 function pointers. each points to an encrypted memory section (for example, f_0_section, f_1_section, …). these regions contain encrypted functions that validate one byte of input each.

0x404c600x4073d2
0x404c680x40749b
...

none of these function blocks are valid code until decrypted. after decryption, the first few bytes conform to a typical function prologue. this is the key to decryption, remember

decryption and flag

to decrypt the first block at 0x4073d2 , we brute-force the xor key until we find a valid x86 function signature. one such example ->

55 push rbp
48 89 e5 mov rbp, rsp

== mentioned prologue.

only one key yields this result: 0x42 (ascii ‘B’). so the correct first byte of input is B. each stage uses a predictable function header and the validation logic is simple. so our plan is to :

  1. extract the encrypted block for each function pointer
  2. brute force xor keys 0x00-0xff until the decrypted version starts with :
55 48 89 e5
  1. record the key — it corresponds to one flag byte.

this approach yields a sequence of 48 xor keys, each independently discoverable :

}3M1T_4_T4_R0X_3NO_ZSU3D4T_N4P_GN1TPYRC3D{FTCStB

oh wait, its reversed? O_o (think about why)

final flag :

BtSCTF{D3CRYPT1NG_P4N_T4D3USZ_ON3_X0R_4T_4_T1M3}

rev - c64 (441 points)

the challenge presents a .d64 commodore 64 disk image containing two files:

decrypt — a basic v2 program that loads and executes a decryption routine secret — a sequential file containing an encrypted message

we are informed that the message in secret has been encrypted using the logic implemented in decrypt, and that a passphrase is required to unlock it. our objective is to reverse-engineer the decryption process and recover the flag, which follows the format BTSCTF<...>.

disk image and decrypt analysis

we begin by examining the disk image using c1541 (a tool from the VICE emulator suite)

Terminal window
c1541 disk.d64 -list
0 "bts_disk" 2a
1 "secret" seq
1 "decrypt" prg

we extract the files for offline inspection

Terminal window
c1541 disk.d64 -read "secret" secret.seq
c1541 disk.d64 -read "decrypt" decrypt.prg

decrypt is a commodore basic v2 program that loads and runs automatically. it performs the following actions:

  1. opens and reads secret into a variable a$

  2. asks the user to enter an 8-character key

  3. checks that:

  • the key is exactly 8 characters long

  • the 8th character is equal to character 6 XOR character 7

  1. runs a small embedded 6502 machine code routine to calculate a starting “decrypt key” (we’ll call it dk, as in derived/decrypt key)

  2. uses that dk in a loop to decrypt the contents of a$, byte by byte

program control flow analysis

execution starts at line 2 with:

2 gosub 1000
4 input b$
10 gosub 4000
  • line 2 loads the file secret into string variable a$
  • line 4 prompts the user to input an 8-character decryption key, stored in b$
  • line 10 initiates validation and transformation routines

the subroutine at line 1000 opens and reads the secret file:

1000 print "searching for a secret file on drive 8.."
1001 open 2,8,2,"secret,s,r"
1002 input#2,a$
1003 if st<>0 then print "cannot find a secret file": close 2: end
1004 print "secret file found!"
1005 print "enter decryption key (8 chars)"
1006 poke 6, len(a$)
1008 lli = 0
1009 return

the important part here is that the encrypted contents are read into the string a$

key validation is performed at line 4000:

4002 if len(b$) <> 8 then print "invalid key length!": goto 3000
4020 y = asc(mid$(b$,7,1))
4025 gosub 1500
4026 dk = peek(250)
4030 if y <> asc(mid$(b$,8,1)) then print "invalid key!": goto 3000
  • the key must be exactly 8 characters
  • y is set to the ascii value of the 7th character
  • the subroutine at line 1500 uses b[0]..b[0]..b[6] to compute a derived value (dk)
  • finally, a condition checks that b[7]==b[7] == b[5] xor b$[6]

-> this is the core of the key (secret passphrase) validation. only keys where the 8th byte equals the xor of characters 6 and 7 are accepted.

the program defines two short 6502 subroutines via a data block at line 10000 and loads them into memory address $c000 (decimal 49152) using

2020 for i=0 to 17
2030 read a: poke 49152+i, a
2040 next

the first subroutine (at$c000) performs xor:

lda $fb ; load operand1
eor $fc ; xor with operand2
sta $fd ; store result
rts

the second subroutine (at $c007) performs a 1-bit left shift and merges in a new lsb:

asl $fa ; shift accumulator
lda $fb
and #$01
ora $fa
sta $fa
rts

computing the initial decrypt key (dk)

subroutine 1500 computes the initial value used for decryption, stored at $fa (memory address 250):

1501 poke 250,0
1505 poke 251,y ; y = key[6]
1506 sys 49159 ; perform initial shift with key[6]
1510 for x=1 to 6
1515 z = asc(mid$(b$,x,1))
1520 poke 251,z
1530 poke 252,y
1540 sys 49152 ; xor key[x] with y
1546 sys 49159 ; shift result into $fa
1550 y = peek(253)
1560 next x

this builds a 7-bit value by combining the least significant bits (LSB) of the first 7 key characters.this is VERY important and a blatant flaw. due to its importance im also inserting equivalent pseudocode :

def derive_dk(key):
y = key[6]
result = y & 1
for i in range(6):
x = key[i]
tmp = x ^ y
result = ((result << 1) | (x & 1)) & 0x7f
y = tmp
return result

this function determines the initial decryption key, dk ^_^

decryption logic

the main loop begins at line 30:

13 poke 11, tt - 47 ; tt was 165 → value 118 stored at $0b
30 lli = lli + 1
31 xd = asc(mid$(a$,lli,1))
35 if xd > 127 then xd = xd - 160
40 poke 251, xd
41 poke 252, dk
50 sys 49152 ; dk = xd xor dk
51 dk = peek(253)
52 poke 252, dk
53 poke 251, peek(11) ; constant 118
54 sys 49152
98 print chr$(peek(253));
99 if peek(6) <= lli then goto 3000
100 goto 30

this loop processes one byte of a$ at a time and decrypts it. inserting equivalent pseudocode :

dk = initial_dk
for byte in secret_data:
if byte > 127:
byte -= 160
temp = byte ^ dk
dk = temp
char = temp ^ 118
print(chr(char), end='')

we can see that each character is decrypted by applying two xor operations:

  1. temp = byte xor dk
  2. output = temp xor 0x76

then dk is updated to the last temp for the next iteration. this is a simple xor-based stream cipher seeded by the dk (decrypted key) from the user supplied key see it now?

  • only the least significant bit of each of the first 7 characters affects dk
  • this gives only 2⁷ = 128 possible dk values

only constraint on characters 6–8 is:

key[7] == key[5] xor key[6]

the other characters can be freely selected to match a desired lsb pattern

brute-force and flag

i tested all 128 possible dk values until printable ASCII text was found.

after some bruteforcing… derived dk turns out to be 0x51

i interpreted dk = 0x51 as the LSB pattern of the key. this meant that the LSBs of characters 1–7 in the key must be: 1, 0, 1, 0, 0, 0, 1

this gives us a constructed key 122211Ap

running :

with open("secret.seq","rb") as f:
ct = bytearray(b & 0x7f for b in f.read())
key = b"122211Ap"
flag = decrypt(ct, key).decode("ascii", "replace")
print(flag)

prints out the flag

BTSCTF<M0N3Y-I$-HIDDEN-1NSI0E-THE-4M1GA>

rev - Translator (250)

We are given 2 artifacts, the first one being “translator” :

Terminal window
$ file translator
translator: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=a74a5a606cb44b6f38ae465b6710af822ecfd022, for GNU/Linux 4.4.0, stripped

and the other one being a .txt file, called “text”, which consists of a string made up of CJK graphemes - unicode characters. The binary file, translator, works as follows:

Terminal window
$ ./translator HIEVERYONE
婄坙噜橀幍
$ ./translator YAYY
橄楓$

From our findings we can immediately derive that the binary file intakes an ASCII string character, makes it undergo multiple arithmetic operations until it eventually turns into an unicode string. The mission is clear - we have an encoded message, that we have to decode. In order to do this, we have to first understand the underlying functionality of the “encoder”, reverse the logic and decode our flag. The main function looks as follows :

main.c
int main(int argc, char **argv)
{
setlocale(LC_ALL, "");
if (argc == 2) {
char *p = argv[1];
if (*p == '\0') { putwc(L'\n', stdout); exit(0); }
int S = operations((uint8_t*)p + 1);
uint32_t mix = (((uint8_t)*p >> 4) & 0xF) + S;
putwc(((mix >> 4 & 0xF) + *p & 0xF | *p & 0xF0) * 0x100 + 0x1000 +
(mix + p[1] & 0xF | p[1] & 0xF0U), stdout);
if (p[1] == '\0') { putwc(L'\n', stdout); exit(0); }
argv[1] += 2;
return main(2, (void**)argv);
}
fprintf(stderr, "USAGE: %s <text>\n", argv[0]);
return 1;
}

The entry point, main, is simple. It :

  1. Reads the input string
  2. Processes it as 2 characters (bytes) at a time
  3. Converts each pair into a Unicode character
  4. Calls itself again with the next 2 characters. This is what is referred to as “tail recursion” - we can see it in the code, namely :
return main(2, (void**)argv);

However, it’s not all sunshine and rainbows - it wouldn’t have been a reverse engineering task if so after all :D The trick lies in how the script modifies the high and low nibbles of our string. Main truly functions more like this :

  1. Reads 2 characters from input
  2. Calculates a suffix checksum on the rest of the string using operations()
  3. Computes a wide character (wchar_t) using a combination of high nibbles a and b (which are copied directly from input) and low nibbles of a and b, modified by the checksum.
  4. Prints wide character.
  5. Recurse with the next 2.

Decompiled operations()

uint operations(byte *param_1)
{
byte bVar1;
byte bVar2;
byte bVar3;
int iVar4;
uint uVar5;
byte bVar6;
bVar1 = *param_1;
if (bVar1 == 0) {
return (uint)bVar1;
}
bVar2 = param_1[1];
uVar5 = (uint)bVar2;
if (bVar2 != 0) {
bVar6 = param_1[2];
if (bVar6 != 0) {
bVar3 = param_1[3];
if (bVar3 != 0) {
iVar4 = operations(param_1 + 4);
return (uint)(bVar1 >> 4) +
(uint)(bVar2 >> 4) + (uint)(bVar6 >> 4) + (uint)(bVar3 >> 4) + iVar4;
}
bVar6 = bVar6 >> 4;
}
uVar5 = (uint)(bVar2 >> 4) + (uint)bVar6;
}
return (bVar1 >> 4) + uVar5;
}

operations() is a recursive suffix-sum function. Given a pointer to a byte string, it returns the sum of the upper 4 bits (high nibbles) of every byte from the ptr to the end. It processes 4 bytes at a time for performance, but logically it’s equivalent to :

sum = 0;
while (*ptr != '\0') {
sum += *ptr >> 4;
ptr++;
}

This sum - S in the binary - is used to add controlled “bias” to the lower nibbles, which as we already mentioned, are being modified.

Let’s call the two input bytes a and b.

Let hi(x) = high nibble = x >> 4
Let lo(x) = low nibble = x & 0xF
Let S = suffix sum = sum of hi(x) for every x to the right of a
  1. Calculate the mix sum
mix = hi(a) + S;
  1. Modify the lower nibble of a
new_lo_a = ((mix >> 4) & 0xF) + lo(a);
  1. Modify the lower nibble of b
new_lo_b = (mix & 0xF) + lo(b);
  1. Reconstruct the original bytes, adding the low and high nibbles back together
byte1 = (hi(a) << 4) | (new_lo_a & 0xF);
byte2 = (hi(b) << 4) | (new_lo_b & 0xF);
  1. Construct the unicode value :
wchar = 0x1000 + (byte1 << 8) + byte2;

This will print out the CJK grapheme unicode character.

We go right to left, since the suffix sum depends on what comes after. Given a wchar W :

  1. Subtract offset:
W' = W - 0x1000;
  1. Extract:
byte1 = W' >> 8;
byte2 = W' & 0xFF;
hi1 = byte1 >> 4;
lo1 = byte1 & 0xF;
hi2 = byte2 >> 4;
lo2 = byte2 & 0xF;
  1. Knowing suffix sum S , recover original bytes:
a = (hi1 << 4) | ((lo1 - ((S >> 4) & 0xF)) & 0xF)
b = (hi2 << 4) | ((lo2 - (S & 0xF)) & 0xF)
S += hi1 + hi2

solve script and flag

def decrypt(encrypted_flag):
prev_sum = 0
decrypted = []
for c in reversed(encrypted_flag):
code_point = ord(c)
code_point -= 0x1000
upper_byte = (code_point >> 8) & 0xFF
lower_byte = code_point & 0xFF
U1 = (upper_byte & 0xF0) >> 4
A = upper_byte & 0x0F
U2 = (lower_byte & 0xF0) >> 4
B = lower_byte & 0x0F
checksum = U2 + prev_sum
L2 = (B - checksum - U1) % 16
temp = checksum + U1
L1 = (A - (temp >> 4)) % 16
v1 = (U1 << 4) | L1
v2 = (U2 << 4) | L2
decrypted.insert(0, v2)
decrypted.insert(0, v1)
prev_sum = U1 + U2 + prev_sum
return bytes(decrypted)
if __name__ == "__main__":
encrypted_flag = "幾湂潌蕔䩘桢豝詧䭡䝵敯䡨剱挧䍩硷穏罣㈡䨥贇"
decrypted_bytes = decrypt(encrypted_flag)
print(decrypted_bytes.decode('utf-8', errors='replace'))

Why does this work? Because the entire transformation is bijective, meaning that the high/upper nibbles are preserved, lower nibbles are altered only based off of known information, and the checksum S can be rebuilt.

flag

BtSCTF{W0W_it_re4l1y_m3aNs_$0methIng!!:)}

rev - Rainbombash Adventure (50 points)

we’re given a folder “rainbombashadventure-1.0-pc”.

the folder reveals 3 directories and an .exe file, .py file, .sh file. dissasembly and decompilation of the .exe file reveals nothing, and so does the analysis of the .py and .sh file.

the /game directory reveals a file called script.rpy , which we can open in a text editor. we see something interesting at the end of the file ->

label ending:
python:
import hashlib
flag = b""
def xor(target, key):
out = [c ^ key[i % len(key)] for i, c in enumerate(target)]
return bytearray(out)
def key_from_path(path):
return hashlib.sha256(str(path).encode()).digest()
def check_path(path, enc_flag):
global flag
flag1 = xor(enc_flag, key_from_path(path))
flag2 = xor(enc_flag, key_from_path(list(reversed(path))))
if flag1.startswith(b"BtSCTF"):
flag = flag1
print(flag)
flag = bytes(flag).replace(b"{", b"{{").decode('ascii')
return True
if flag2.startswith(b"BtSCTF"):
flag = flag2
print(flag)
flag = bytes(flag).replace(b"{", b"{{").decode('ascii')
return True
return False
is_correct = check_path(nodes, bytearray(b'\xc2\x92\xf9\xf66\xe8\xa5\xa6\x17\xb6mGE\xcfQ\x90Mk:\x9a\xbb\x905&\x19\x8e\xc4\x9a\x0b\x1f\xf8C\xf4\xb9\xc9\x85R\xc2\xbb\x8d\x07\x94[R_\xf5z\x9fAl\x11\x9c\xbb\x9255\x08\x8e\xf6\xd6\x04'))
if is_correct:
rb "all cloudz smashed im the queen"
rb "i got 100% swag"
"[flag]"
else:
"Sadly, Rainbom Bash was too slow and wasn't able to smash all clouds."
return

above in script.rpy we’re given a complete graph of 20 clouds. the cost of going from cloud i to cloud j is in dist[i][j]. we need to find all possible tours that start at cloud 0, visit every other cloud exactly once and end back at cloud 0.

this is a classic TSP - Travelling Salesman Problem.

“Given a list of cities and the distances between each pair of cities, what is the shortest possible route that visits each city exactly once and returns to the origin city?”

i chose to do this the hard way - dynamic programming with bitmasking, specifically the [Held-Karp Algorithm]https://en.wikipedia.org/wiki/Held–Karp_algorithm)

state definition

we define a 2d dp table ->

dp[mask][v]

where : mask is a 20 bit integer, each bit i is 1 if cloud i has been visited v is the current cloud youre standing on and dp[mask][v] is the shortest distance to reach cloud v, having visited all clouds in mask, starting from cloud 0

this table stores partial solutions to subproblems

now i computed all possible previous clouds u that couldve reached v and take the shortest one ->

dp[mask][v] = min over u in (mask - {v}) of [dp[mask ^ (1 << v)][u] + dist[u][v]]

best case

dp[1 << 0][0] = 0

== starting at cloud 0, only cloud 0 visited, cost is 0

now using ai i filled up the dp table and that computed every possible subset path efficiently

with the filled dp table in our hands we can compute the shortest complete cycle ->

min(dp[full_mask][v] + dist[v][0]) for all v ≠ 0

this gives us the shortest tour that returns to the starting point - cloud 0

to get the actual path, not just the cost, we store backpointers

the result ->

[0, 12, 15, 2, 1, 5, 11, 14, 17, 7, 19, 13, 9, 10, 3, 8, 16, 18, 4, 6, 0]

this is our tsp result

from the game code ->

key = sha256(str(path).encode()).digest()

but since we already know the path (tsp derived) ->

path = [0,12,15,2,1,5,11,14,17,7,19,13,9,10,3,8,16,18,4,6]

from the ending source code linked above we can identify that the encrypted blob (enc_flag) is a bytearray. we decrypt using ->

plaintext[i] = cipher_blob[i] ^ key[i % len(key)]

this means “for every byte in the encrypted blob, xor it with the corresponding byte in the sha-derived key, repeat the key if the blob is longer than 32 bytes”

final output of decryption is consequently ourlag.

BtSCTF{YOU_are_getting_20_percent_c00ler_with_this_one_!!_B)}