F0ndueSav0yarde

AMSI CTF 2025 - Cavormice

Category : REV

Phreaks 2600

You can find this writeup at Phreaks 2600 website

Context

This challenge was a gameboy file about finding one of the possible exit of the labyrinth.
You can move up to 4 directions :

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 :

memory_map_1

ghidra_decompile_1

So it means that :

ULDR array

I restarted the game and looked at 0xc800 - 0xc900 address range in debugger :

uldr_list

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 :

ghidra_decompile_2

Very cool informations in FUN_06fc function :

Let’s rename these Ghidra decompilation variables :

Another function at 0x729 uses ULDR_array :

ghidra_decompile_3

Let me do some function cleaning :

 11
 2undefined1 FUN_0729(void)
 3{
 4        return_value = 0;
 5        if (index_of_ULDR_array == 0x20) {
 6                for (int i = 0; i < 16; i++) {
 7                        if ((&DAT_c828)[i] != ((&ULDR_array)[(i * 2 + 1)] ^ (&ULDR_array)[i * 2])) {
 8                        return_value = 0;
 9                        return 0;
10                        }
11                }
12                return_value = 1;
13        }
14        return return_value;
15}

This FUN_0729 function is crucial :

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.

ghidra_decompile_4

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 ?

winning_message

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).

 11
 2xored_key = [0x19,0x19,0x08,0x16,0x07,0x00,0x19,0x11,0x08,0x16,0x11,0x19,0x00,0x1e,0x07,0x11]
 3inputs = [0] * 32
 4movements = ['U','D','L','R'] # UP, DOWN, LEFT, RIGHT
 5all_possibilities = []
 6
 7# Generate all possibilities
 8for x in xored_key:
 9        tmp_list = []
10        for i in range(len(movements)):
11                for j in range(len(movements)):
12                        if(ord(movements[i]) ^ ord(movements[j]) == x):
13                                tmp_list.append([movements[i]+movements[j]])
14        all_possibilities.append(tmp_list)
15
16# Show all possibilities
17for _ in all_possibilities:
18        p = ""
19        for y in _:
20                p += "".join(y)
21                p += ','
22        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 :

 11
 2xored_key = [0x19,0x19,0x08,0x16,0x07,0x00,0x19,0x11,0x08,0x16,0x11,0x19,0x00,0x1e,0x07,0x11]
 3inputs = [0] * 32
 4
 5movements = ['U','D','L','R'] # UP, DOWN, LEFT, RIGHT
 6all_possibilities = []
 7
 8# Generate all possibilities
 9for x in xored_key:
10    tmp_list = []
11    for i in range(len(movements)):
12        for j in range(len(movements)):
13            if(ord(movements[i]) ^ ord(movements[j]) == x):
14                tmp_list.append([movements[i]+movements[j]])
15    all_possibilities.append(tmp_list)
16
17# Remove all forbidden moves and reduce the number of possibilities before itertools cartesian product
18forbidden_moves = ["UU","UL","UR"]
19for i in range(0, len(all_possibilities)):
20    for j in range(len(all_possibilities[i])):
21        if(i == 0):
22            if(all_possibilities[i][j][0] not in ["UU","UL","UR"]):
23                # Check for first move because we can only go up
24                del all_possibilities[i][j][0]
25        elif(i == 15):
26            if(all_possibilities[i][j][0] not in ["DU"]):
27                # Check for the last move to be only "DU"
28                del all_possibilities[i][j][0]
29        else:
30            if(all_possibilities[i][j][0] in forbidden_moves):
31                del all_possibilities[i][j][0]
32    all_possibilities[i] = [_ for _ in all_possibilities[i] if _] # Remove empty lists
33
34from itertools import product
35
36# Use cartesian product to list all final possibilities
37combinations = [list(map(list, p)) for p in product(*all_possibilities)]
38
39# Final filter of 1152 possible moves
40for i in range(len(combinations)):
41    combinations[i] = ''.join(sub[0] for sub in combinations[i])
42
43flags = []
44
45for i in range(len(combinations)):
46    up = 0
47    for j in range(len(combinations[i])):
48        if(combinations[i][j] == 'U'):
49            up += 1
50            j += 1
51        if(j < len(combinations[i]) and combinations[i][j] == 'D'):
52            up = 1
53            j += 1
54        if(up > 2): # Impossible
55            break
56        if(up == 2):
57            if(j < len(combinations[i]) and  combinations[i][j] in ['U','L','R']): # Impossible
58                break
59        if(j == len(combinations[i]) - 1):
60            flags.append("AMSI{" + combinations[i] + '}')
61            break
62        
63for _ in flags:
64    print(_)

And here are the 8 possible flags

AMSI{ULLUDLDRRUDDLUDUDLDRUDLUDDLRRUDU}
AMSI{ULLUDLDRRUDDLUDUDLDRUDLUDDRLRUDU}
AMSI{ULLUDLDRRUDDLUDUDLRDUDLUDDLRRUDU}
AMSI{ULLUDLDRRUDDLUDUDLRDUDLUDDRLRUDU}
AMSI{ULLUDLRDRUDDLUDUDLDRUDLUDDLRRUDU}
AMSI{ULLUDLRDRUDDLUDUDLDRUDLUDDRLRUDU}
AMSI{ULLUDLRDRUDDLUDUDLRDUDLUDDLRRUDU}
AMSI{ULLUDLRDRUDDLUDUDLRDUDLUDDRLRUDU}