July 27

Пост охраны (io_hrana) — Writeup

Категория: Reverse / Linux

Сложность: Hard

Автор: Ксения @g1inko, SPbCTF

Описание задания:
Дан файл io_hrana.elf — «сторожевой шершень», который аттестует систему перед тем, как впустить в улей. Нужно пройти аттестацию и доказать, что «ты не робот, не верблюд и не медоед».

Разведка

$ file io_hrana.elf
io_hrana.elf: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked,
BuildID[...]=..., for GNU/Linux 3.2.0, stripped

Статически слинкованный, stripped, ~2 МБ. Первый запуск:

$ ./io_hrana.elf

--=[ I WILL ONLY REVEAL MY SECRETS TO THE WORTHY ]=--

[*] Initializing.
system name = Linux
node name   = vm
...
[+] I am now ready to test your system

[*] Starting attestation
    5
    4
    3
    2
    1
    0
CHECK [1/4]
[+] init ok
CHECK [2/4]
...

Дальше идут четыре последовательные проверки. Обычный strings по бинарю не находит ни одной из выводимых фраз ("attestation", "CHECK", "dedicated user" и т.д.) — то есть все текстовые константы намеренно спрятаны от статического анализа.
Первая попытка воспользоваться strace, чтобы посмотреть, какие файлы/сокеты трогает программа на каждой проверке, тоже почти ничего не даёт: в трассе видно буквально пару системных вызовов (uname, readlink /proc/self/exe, один openat("/proc/1/comm")) — а весь остальной ввод-вывод, включая все write в stdout, в strace просто отсутствует, хотя текст реально печатается на экран.
Причина: io_uring + SQPOLL как анти-трассировочный приём
В трассе, однако, виден характерный вызов:

io_uring_setup(16, {flags=IORING_SETUP_SQPOLL|IORING_SETUP_R_DISABLED, ...}) = 3
io_uring_register(3, IORING_REGISTER_ENABLE_RINGS, NULL, 0) = 0

IORING_SETUP_SQPOLL поднимает отдельный поток ядра, который сам вычитывает Submission Queue из shared-memory кольца и выполняет операции (open, read, write, connect...) — приложению для этого не нужно делать системный вызов io_uring_enter вообще. Именно поэтому strace/ltrace, работающие через ptrace на пользовательском потоке, не видят ни файловых операций проверок, ни даже обычных write() в терминал: с точки зрения трассировщика поток программы почти ничего не делает, всю грязную работу выполняет невидимый kernel-thread. Это и есть «сторожевой шершень» из легенды задания.
Дополнительно все строковые константы в бинаре не лежат в .rodata, а побайтово записываются в data-сегмент прямо в коде main — тысячи инструкций вида:

movb $0x13, 0x5e3520(%rip)
movb $0x22, 0x5e3521(%rip)
movb $0xf3, 0x5e3522(%rip)
...

Это классический анти-strings приём: раз строка никогда не лежит в бинаре одним куском, strings/grep по файлу её не находят.
Стратегия: раз статика и обычная трассировка бессильны — смотрим живую память
Раз строки всё равно оказываются расшифрованными (или просто дозаписанными) в адресном пространстве процесса к моменту вывода/сравнения, самый быстрый путь — не реверсить вручную тысячи movb, а поймать процесс в нужный момент через gdb и сдампить память:

import gdb
gdb.execute("catch syscall exit_group")
gdb.execute("run < /tmp/enters.txt")
gdb.execute("dump memory seg.bin 0x5e4000 0x60b000")

Как только процесс доходит до exit_group() (падение очередной проверки), дампим все rw--регионы и прогоняем через strings — там уже лежит расшифрованный текст, который не найти в самом файле.
Проверка за проверкой
CHECK 1/4 — инициализация
Ничего интересного, просто uname().
CHECK 2/4 — дистрибутив
Дамп памяти в момент ошибки содержит:

/etc/os-release
PROpolisOS

Программа читает /etc/os-release и требует ID=PROpolisOS (POPolis — привет прополису от пчёл). Патчим файл:

sed -i 's/^ID=.*/ID=PROpolisOS/' /usr/lib/os-release

→ [+] release ok
CHECK 3/4 — «выделенный пользователь»
В следующем дампе находится:

/home/apis_mellifera
[-] The dedicated user not found, cannot proceed

apis_mellifera — латинское название пчелы медоносной. Создаём каталог:

mkdir -p /home/apis_mellifera

Проверка идёт дальше, но теперь ругается на время:

[i] user creation time: 13:9 MSK
[-] A special user wasn't created at 13:37 MSK. Not cool!!

Программа берёт время создания каталога (ctime/birth time инода) и сравнивает его с 13:37 по MSK (UTC+3) — «leet time». ctime нельзя выставить через touch (он всегда «сейчас» при любом изменении метаданных inode), поэтому единственный надёжный способ — временно перевести системные часы и создать каталог именно в этот момент:

date -s "2026-07-18 10:37:00 UTC"      # 10:37 UTC = 13:37 MSK
rm -rf /home/apis_mellifera && mkdir -p /home/apis_mellifera

→ [i] user creation time: 13:37 MSK → [+] user ok
CHECK 4/4 — окружение
Проходит автоматически (там же в дампе виден systemd — похоже на проверку /proc/1/comm, то есть что init-процесс контейнера называется systemd, а не что-то подозрительное).
После всех четырёх проверок:

[+] All good!
Grab your prize! https://prizering-gaxqsak4.avitoctf.ru/
[*] Press ENTER...

Финальный этап: приватный сокет
После нажатия Enter:

Well... Now as for those secrets I claimed to have...

Since you've come this far, I'm sure you know how to find a special private socket
that you need to set up, so that I can share a passcode for you to get into.
[*] Press ENTER...

[-] Failed to talk to a special confidential socket, cannot hand my secret to you...

Снова дампим память в момент этой ошибки и находим строку:

h0n3ycOmb

(с нулевыми байтами вокруг — характерно для sockaddr_un в abstract namespace, т.е. путь вида \0h0n3ycOmb, не привязанный к файловой системе). Поднимаем такой сокет заранее и слушаем его:

import socket

s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.bind("\0h0n3ycOmb")     # abstract socket, начинается с NUL
s.listen(1)
conn, _ = s.accept()
print(conn.recv(4096))

Запускаем сервер, затем сам io_hrana.elf (с уже пропатченными os-release и временем создания /home/apis_mellifera) — бинарь коннектится к абстрактному сокету h0n3ycOmb и присылает в него флаг открытым текстом.

Итог: что проверялось задачей


Узнавание io_uring/SQPOLL как техники, из-за которой strace/ltrace оказываются бесполезны (I/O выполняется kernel-thread'ом без «человеческих» системных вызовов на стороне трассируемого процесса).
Приём анти-strings через побайтовую запись строк в память в рантайме — и обход через дамп живой памяти вместо ручной дизассемблировки каждого movb.
Внимательность к деталям окружения: /etc/os-release, время создания файла (ctime vs mtime, и что ctime подделать можно только через системные часы), и abstract Unix-сокеты как канал передачи секрета.