AMSI CTF 2025 - Cavormice

Context
This challenge was a gameboy file about finding one of the possible exit of the labyrinth.
You can move up to 4 directions :
- Up :
U - Down :
D - Left :
L - Right :
R
I used bgb to emulate the game and debug.
I used Ghidra and GhidraBoy extension to decompile SM83 assembly language to C language.
Memory map
I didn’t know where to start so i looked at memory mapping of gameboy from gbdev.
It is said that WRAM has a mapped range of 0xC000 – 0xDFFF : this is the internal RAM of the gameboy where all temporary data are stored. Maybe interesting stuff are saved here.
I right clicked on the bgb screen to open the debugger, i looked at 0xc000 address (WRA0) and this is what i found out :

0xc000: Y position of little dude on screen0xc001: X position of little dude on screen0xc002: ON / OFF animation sprite
Interesting… I used Ghidra to know all the references of 0xc000 and i saw that the address 0x0b55 wrote DAT_c805 to DAT_c000 :
So it means that :
- Something is written in X position at
0xc805 - I have to look at
0xc800 - 0xc900address range because it seems that there is some data too
ULDR array
I restarted the game and looked at 0xc800 - 0xc900 address range in debugger :

It seems that 0xc808 is the address of a movements array ! If we go up, the map changes and U is stored in this array.
Because a value different of 0x00 is stored at 0xc828, i assume that the array has a length of 32 bytes (probably the length of the flag inside AMSI{...} flag format).
Let’s look at references of 0xc808 in Ghidra :
Very cool informations in FUN_06fc function :
- Line 7 confirms that
0xc808is an array and obviously0xc838is the index of the array (incremented at line 8). - Line 9 does stuff while its value is lower than 32 : it means that 32 is the length of our array
Let’s rename these Ghidra decompilation variables :
DAT_c808:ULDR_arrayDAT_c838:index_of_ULDR_array
Another function at 0x729 uses ULDR_array :
Let me do some function cleaning :
undefined1 FUN_0729(void)
{
return_value = 0;
if (index_of_ULDR_array == 0x20) {
for (int i = 0; i < 16; i++) {
if ((&DAT_c828)[i] != ((&ULDR_array)[(i * 2 + 1)] ^ (&ULDR_array)[i * 2])) {
return_value = 0;
return 0;
}
}
return_value = 1;
}
return return_value;
}This FUN_0729 function is crucial :
- It checks if the index of
ULDR arrayis equal to 32 bytes (end of the array) - If the end of the array is reached, it will compare 16 times if
ULDR_array[i * 2 + 1] ^ ULDR_array[i * 2]is equal toDAT_c828[i] - If everything is good, it returns 0 otherwise it returns 1
It means that DAT_c828 is the array that probably compares the flag, and FUN_0729 function is the final checking flag function.
Winning message
To be sure of our assumption, let’s see all the references to FUN_0729.
There are two calls of FUN_0729 : FUN_0789 and FUN_09a7.
Because FUN_09a7 calls FUN_0729 once without checking the return value, i assume that this is not the one we’re looking at. But FUN_0789 does !
Indeed, it compares the return value with 0x00 at line 89 (address 0x8ff), and then does stuff.
So…. if i set PC register at 0x8ff… i should get a winning message, right ?

Bingo !
Because we know that FUN_0729 is cheking the flag, we have to look at DAT_C828 array to retrieve the XORed key from the intended flag : 19 19 08 16 07 00 19 11 08 16 11 19 00 1e 07 11
Retrieve the flag
Let’s use some Python code to retrieve all possible combinations of flag. We have multiple combinations because XOR function is non-injective (different inputs can give same output).
xored_key = [0x19,0x19,0x08,0x16,0x07,0x00,0x19,0x11,0x08,0x16,0x11,0x19,0x00,0x1e,0x07,0x11]
inputs = [0] * 32
movements = ['U','D','L','R'] # UP, DOWN, LEFT, RIGHT
all_possibilities = []
# Generate all possibilities
for x in xored_key:
tmp_list = []
for i in range(len(movements)):
for j in range(len(movements)):
if(ord(movements[i]) ^ ord(movements[j]) == x):
tmp_list.append([movements[i]+movements[j]])
all_possibilities.append(tmp_list)
# Show all possibilities
for _ in all_possibilities:
p = ""
for y in _:
p += "".join(y)
p += ','
print(p)And here is the result :
UL,LU,
UL,LU,
DL,LD,
DR,RD,
UR,RU,
UU,DD,LL,RR,
UL,LU,
UD,DU,
DL,LD,
DR,RD,
UD,DU,
UL,LU,
UU,DD,LL,RR,
LR,RL,
UR,RU,
UD,DU,That’s great : each line has two or four possibilities of movements. But there is another problem…
If we go up, we can’t go up anymore or even to the left / right : we can only go down (but we can infinitely go down/left/right).
Furthermore, when we go up, it will trigger the flag checking function (FUN_0729) and we flag only if this is the last movement of the array : so we know that the last movement will be U.
Thanks to our python script, we know that the last move is DU.
In, this same pattern, we know that the first move can only be U : thanks to our python script, we know that the first move is UL.
Let’s arrange our Python script to generate all possible flags :
xored_key = [0x19,0x19,0x08,0x16,0x07,0x00,0x19,0x11,0x08,0x16,0x11,0x19,0x00,0x1e,0x07,0x11]
inputs = [0] * 32
movements = ['U','D','L','R'] # UP, DOWN, LEFT, RIGHT
all_possibilities = []
# Generate all possibilities
for x in xored_key:
tmp_list = []
for i in range(len(movements)):
for j in range(len(movements)):
if(ord(movements[i]) ^ ord(movements[j]) == x):
tmp_list.append([movements[i]+movements[j]])
all_possibilities.append(tmp_list)
# Remove all forbidden moves and reduce the number of possibilities before itertools cartesian product
forbidden_moves = ["UU","UL","UR"]
for i in range(0, len(all_possibilities)):
for j in range(len(all_possibilities[i])):
if(i == 0):
if(all_possibilities[i][j][0] not in ["UU","UL","UR"]):
# Check for first move because we can only go up
del all_possibilities[i][j][0]
elif(i == 15):
if(all_possibilities[i][j][0] not in ["DU"]):
# Check for the last move to be only "DU"
del all_possibilities[i][j][0]
else:
if(all_possibilities[i][j][0] in forbidden_moves):
del all_possibilities[i][j][0]
all_possibilities[i] = [_ for _ in all_possibilities[i] if _] # Remove empty lists
from itertools import product
# Use cartesian product to list all final possibilities
combinations = [list(map(list, p)) for p in product(*all_possibilities)]
# Final filter of 1152 possible moves
for i in range(len(combinations)):
combinations[i] = ''.join(sub[0] for sub in combinations[i])
flags = []
for i in range(len(combinations)):
up = 0
for j in range(len(combinations[i])):
if(combinations[i][j] == 'U'):
up += 1
j += 1
if(j < len(combinations[i]) and combinations[i][j] == 'D'):
up = 1
j += 1
if(up > 2): # Impossible
break
if(up == 2):
if(j < len(combinations[i]) and combinations[i][j] in ['U','L','R']): # Impossible
break
if(j == len(combinations[i]) - 1):
flags.append("AMSI{" + combinations[i] + '}')
break
for _ in flags:
print(_)And here are the 8 possible flags
AMSI{ULLUDLDRRUDDLUDUDLDRUDLUDDLRRUDU}
AMSI{ULLUDLDRRUDDLUDUDLDRUDLUDDRLRUDU}
AMSI{ULLUDLDRRUDDLUDUDLRDUDLUDDLRRUDU}
AMSI{ULLUDLDRRUDDLUDUDLRDUDLUDDRLRUDU}
AMSI{ULLUDLRDRUDDLUDUDLDRUDLUDDLRRUDU}
AMSI{ULLUDLRDRUDDLUDUDLDRUDLUDDRLRUDU}
AMSI{ULLUDLRDRUDDLUDUDLRDUDLUDDLRRUDU}
AMSI{ULLUDLRDRUDDLUDUDLRDUDLUDDRLRUDU}