Write‑up “Аудит” CTF Кубок Федерации 2026

Участвовала в CTF Кубок Федерации 2026. Здесь хотелось бы написать свое решение задания «Аудит» из категории Reverse. Разработчики уже выложили решение всех заданий, но мое решение отличается от решения создателей, так что хочу поделиться своим решением, рассказать о методах, ошибках, способах и внести обучающую часть для тех, кто только входит в реверс‑инженеринг в CTF.
Вот и само задание. Нам предоставлен контекст задания и exe файл.
Перед CTF я подготовилась и скачала kali linux и запустила на виртуальной машине. Свой первый CTF я пыталась решить без линукса, сейчас не понимаю как без него тогда обходилась.
Использованные утилиты:
file
radare2 (r2)
rabin2
strings
grep
objdump
xxd
hexdump
dd
gdb
ghidra
Первое что я сделала, и что советую делать всем, это команда file. Тут мы узнаем какой файл перед нами.
file WorkstationAudit.exe

file довольно простая Linux‑утилита, которая пытается понять, что вообще лежит перед нами. И тут я предпочитаю смотреть именно на содержимое файла, а не только на его расширение. Потому что .exe в названии еще не говорит нам, что внутри действительно Windows‑программа.
В ответ получили PE32+ executable, то есть перед нами Windows‑программа для 64-битной архитектуры. Уже на этом этапе становится понятно, что просто открыть файл как текст и поискать flag{}, скорее всего, не получится.
Смотрим структуру файла
Теперь хотелось понять, из чего вообще состоит этот файл. Для этого использовала rabin2.
rabin2 ‑I WorkstationAudit.exe

rabin2 — утилита из набора radare2. Она нужна для быстрого просмотра информации о бинарнике: архитектуры, секций, импортов, символов и других характеристик. Сам radare2 мы чуть позже будем использовать уже непосредственно для разбора кода.
У этой команды несколько аргументов. Вот довольно показательная таблица:
Флаг | Что делает | Основная цель | Что вы увидите в выводе |
| Выводит общую информацию о бинарнике | Быстрый обзор архитектуры и защиты | Архитектура (x86/x64), ОС, язык (C++, Go,.NET), тип (PE/ELF), наличие защит (NX, Canary, ASLR). |
| Выводит данные из заголовков (Headers) | Низкоуровневый анализ структуры файла | PE‑заголовки (DOS header, NT header), контрольные суммы, точки входа, выравнивание разделов. |
| Выводит список секций (Sections) | Понимание структуры памяти и поиск упакованных файлов | Таблица секций ( |
| Выводит список импортируемых функций | Понимание базового поведения программы | Имена внешних DLL (например, |
Примеры вывода:


В списке были обычные .text,.data,.rdata,.pdata и другие, но также были довольно подозрительные .meta и .pak.
Сразу оговорюсь: само название секции еще ничего не означает. Можно назвать свою секцию практически как угодно. Но .meta и .pak явно отличались от стандартных секций, поэтому я решила посмотреть, что же там находится.
Тут, кстати, полезно хотя бы примерно понимать, что мы вообще смотрим.
text — обычно основной машинный код программы.
data — обычно изменяемые данные программы.
rdata — обычно данные только для чтения: константы, строки и тому подобное
Смотрим строки
Я решила просто посмотреть, какие читаемые строки вообще есть внутри файла.
strings ‑a WorkstationAudit.exe
strings ищет в бинарном файле последовательности байтов, которые похожи на обычный читаемый текст.

Вывода здесь, конечно, было очень много. Поэтому я решила отфильтровать его через grep:
strings ‑a WorkstationAudit.exe | grep ‑Ei “powershell|service|firewall|audit|bitlocker|policy”

grep здесь просто берет вывод предыдущей команды и оставляет строки, в которых встречается хотя бы одно из указанных слов. Но этот поиск ничего полезного не показал. Поэтому я отложила строки и продолжила разбирать структуру и машинный код программы.
Для этого открыла файл в radare2:
r2 ‑A WorkstationAudit.exe
r2 — это сокращенное название radare2.
Если rabin2 нам в основном нужен для того, чтобы быстро посмотреть информацию о файле, то radare2 уже позволяет нормально залезть внутрь: переходить по адресам, смотреть байты, дизассемблировать инструкции, искать функции и разбираться в логике программы.
-A говорит radare2 автоматически проанализировать файл при открытии.

После запуска мы оказываемся внутри консоли radare2.
Несколько команд, которые пригодились:
iz — показывает найденные строки.
afl — показывает список функций, которые radare2 смог найти.
ie — она показывает Entry Point.
Entry Point
Entry Point — это точка, с которой программа начинает выполняться. В этом случае: 0×140111000

Чтобы перейти туда:
s 0×140111000
s — seek — просто перейти по указанному адресу.
После этого:
pdf здесь это не формат файла, а print disassembly function.
То есть radare2 берет функцию по текущему адресу и показывает ее дизассемблированный машинный код.

Я ожидала увидеть код, который сразу начинает проверять антивирус, firewall и все остальное, что мы нашли через strings. Но вместо этого увидела инструкции, которые что‑то расшифровывают.
А дальше шли операции с xor, imul, add, shr. Адрес 0×140110000 оказался связан с нашей подозрительной секцией .meta. То есть программа при запуске сначала работает не с основным кодом, а с какими‑то своими внутренними данными. На этом моменте появилась первая нормальная гипотеза: .meta может содержать информацию, которая нужна собственному загрузчику программы. И дальше уже пришлось разбираться, что именно этот загрузчик делает.
Разбираемся с.meta
После pdf стало понятно, что программа при запуске сначала что‑то делает с секцией .meta. Поэтому я решила посмотреть на нее отдельно.
Из rabin2 -S мы уже знали, что .meta находится по RVA 0×110000.
RVA (Relative Virtual Address) — это адрес относительно базового адреса, по которому программа загружается в память. В этом случае ImageBase был: 0×140000000
Поэтому для .meta: 0×140000000 + 0×110000 = 0×140110000
Чтобы посмотреть содержимое:
s 0×140110000
px 256
px в radare2 показывает байты в hex‑виде.

И тут ничего особо понятного не было. Просто набор байтов. Но мы уже видели в Entry Point код, который эти байты изменяет через XOR. Поэтому вместо того, чтобы пытаться угадывать, что здесь находится, я решила повторить алгоритм программы.
Расшифровываем.meta
В коде загрузчика использовался алгоритм, в котором программа берет текущее состояние, получает из него один байт ключа и XOR‑ит им очередной байт .meta.
Здесь как раз пригодился Python. Сам алгоритм простой, но вручную считать несколько сотен байтов точно не хотелось.
Я создала файл с помощью команды: nano decrypt_meta.py
(я называла по‑другому и абсолютно примитивно, но для наглядности давайте будем давать соответствующие названия)
from pathlib import Path
data = bytearray(
Path("WorkstationAudit.exe").read_bytes()[
0x107800:0x107800 + 0x200
]
)
state = 0x7A3B1D5F
for i in range(len(data)):
if 0x40 <= i <= 0x43:
continue
state = (state * 0x19660D + 0x3C6EF35F) & 0xffffffff
key = state >> 24
data[i] ^= key
Path("meta_decrypted.bin").write_bytes(data)
print(data.hex())Этот код берет зашифрованную секцию .meta из EXE, проходит по ее байтам и для каждого байта вычисляет очередной ключ с помощью того же математического алгоритма, который использует программа. Затем каждый байт расшифровывается через XOR с этим ключом. В конце получаем расшифрованное содержимое .meta, которое можно дальше разбирать как структуру данных.
Запускаем:
python3 decrypt_meta.py

Здесь важно понимать, что .meta — это бинарная структура, поэтому ее нужно читать не как текст, а как набор байтов. Дальше разбиваем эти данные на группы по 8 байт, потому что программа работает с 64-битными значениями.
Условно: байты с 0x00 по 0x07 образуют первое 8-байтовое число 0x0000000140000000, с 0x08 по 0x0F — второе, и так далее
А вот здесь внимательно.. Здесь понадобиться новое объяснение. Тут вроде понятно объяснено:
Сначала hex‑строку нужно разбить по 2 символа, потому что каждые 2 hex‑символа = 1 байт:
00 00 00 40 01 00 00 00
50 f0 10 40 01 00 00 00
58 f0 10 40 01 00 00 00
d0 11 11 40 01 00 00 00
...Теперь первые 8 байт:
00 00 00 40 01 00 00 00хранят одно 64-битное число. Поскольку x86-64 использует little‑endian, байты читаются в обратном порядке:
00 00 00 40 01 00 00 00
↓
00 00 00 01 40 00 00 00
↓
0x0000000140000000В итоге В 0×48 находится число 3. Получается, .meta содержит информацию о трех кусках данных, которые программа должна обработать.
Где‑то на этом моменте я полезла в Ghidra. Но там ничего интересного не показало.
Что такое chunk?
Chunk — это просто отдельный кусок данных, с которым программа работает отдельно.
После расшифровки.meta мы видим, что программа работает с тремя такими кусками. В этой секции для каждого записано, где он находится, какого он размера и куда его нужно поместить. Нам не обязательно сейчас разбираться в каждом числе. Главное — понять саму идею: .meta описывает, как загрузчику работать с данными.
Получается, что .meta— это инструкция, в которой сказано взять кусок данных, расшифровать и куда‑то положить. А значит, следующий логичный шаг — посмотреть, где находятся сами данные. И здесь нас интересует секция .pak.
Переходим к.pak
Из .meta мы узнали, где находятся наши chunks. Теперь нужно взять первый chunk и расшифровать его.
Для него используется тот же принцип, что и раньше: программа генерирует ключ с помощью LCG и применяет XOR к каждому байту
LCG — это простой генератор псевдослучайных чисел. Он по формуле получает новое число из предыдущего, а затем часть этого числа используется как ключ для XOR‑расшифровки. Только данных теперь намного больше — 0xd3340 байт.
Я немного переделала предыдущий скрипт (сделала новый файл и назвала decrypt_text.py:
from pathlib import Path
data = bytearray(
Path("WorkstationAudit.exe").read_bytes()
)
src = 0x107bd0
size = 0xd3340
chunk = data[src:src + size]
state = 0x1000 + 0x9E3779B9
out = bytearray()
for b in chunk:
state = (state * 0x19660D + 0x3C6EF35F) & 0xffffffff
key = state >> 24
out.append(b ^ key)
Path("decrypted_text.bin").write_bytes(out)Здесь src — место, откуда берём chunk, а size — его размер. После расшифровки результат сохраняем в отдельный файл.
Запускаем:
python3 decrypt_text.py

‑g 1 показывает данные побайтно, а ‑l 64 ограничивает вывод первыми 64 байтами.
Вместо случайного набора байтов мы снова видим нормальный машинный код. Значит, расшифровка сработала, и мы восстановили основной код программы.
Ищем функцию
В процессе анализа я нашла функцию: 0×1400d2e44
Для просмотра машинного кода использовала objdump
objdump ‑D ‑b binary ‑m i386:x86-64 \
‑start‑address=0xd1e44 \
‑stop‑address=0xd1f44 \
decrypted_text.bin

Здесь:
‑D — дизассемблировать файл;
‑b binary — считать файл обычным бинарным файлом;
‑m i386:x86-64 — использовать архитектуру x86-64;
‑start‑address и ‑stop‑address — показать только нужный участок.
В выводе появились знакомые инструкции и две большие константы:
0×5851f42d4c957f2d и 0×14057b7ef767814f. А дальше программа делает умножение, сложение, сдвиг и XOR. Это уже очень похоже на ещё один генератор ключа. Кроме того, рядом находились 34 байта, которые выглядели как зашифрованные данные. Так как эти данные находятся в .rdata, сначала я расшифровала соответствующий chunk, а затем посмотрела нужный участок:
xxd ‑g 1 ‑s 0×1160 ‑l 34 decrypted_rdata.bin
Здесь ‑s задаёт смещение внутри расшифрованного .rdata, а ‑l 34 говорит показать 34 байта.

Эти данные не похожи на обычный текст, поэтому, скорее всего, перед нами зашифрованное значение. Теперь нужно понять, откуда программа получает начальное значение для второго генератора.
Находим FNV-1a
FNV-1a — это функция, которая превращает любые данные или текст в число. Суть функции в том, что сначала она XOR‑ит, а потом умножает. Так что это 64-битное хеширование.
После расшифровки .pak я получила большой кусок восстановленного машинного кода. Теперь уже можно было нормально продолжать анализ программы.
Но появилась новая проблема: я всё ещё не понимала, где именно находятся проверки, о которых говорилось в условии. В начале я уже пыталась найти их через strings, но обычный поиск по исходному EXE ничего полезного не показал. Поэтому пришлось идти от восстановленного кода.
Во время анализа .text я нашла функцию, которая работала не просто с отдельными значениями, а с таблицей строк. В коде был цикл, который последовательно обращался к этим строкам и обрабатывал их. Поэтому я решила посмотреть, что именно находится в этой таблице.
В результате удалось восстановить восемь строк. И вот здесь наконец стало понятно, что именно делает программа. Все восемь строк оказались PowerShell‑командами, каждая из которых проверяет определённое состояние Windows:
Get-MpComputerStatus
Get-BitLockerVolume
Get-NetFirewallProfile
auditpol /get /subcategory:'Process Creation'
Get-Service 'CorpDLPAgent'
gpresult /r
Get-Service 'SecureGuard'
Get-WinEvent ... Id=4719Если посмотреть на них по смыслу, программа проверяет довольно много вещей: состояние антивируса, BitLocker, Firewall, настройки аудита создания процессов, наличие и состояние определённых служб, применённые групповые политики и события безопасности Windows.
То есть теперь стало понятно, откуда вообще берётся тот самый аудит рабочей станции из условия. Программа последовательно выполняет несколько проверок и получает результаты OK или FAIL.
Но на этом возник следующий вопрос: зачем программа вообще обрабатывает эти строки через найденную мной функцию? Просто выполнить PowerShell‑команды было бы недостаточно, потому что дальше в коде эти строки ещё каким‑то образом преобразуются.
nano fnv.py
from pathlib import Path
strings = Path("strings.txt").read_text().splitlines()
data = b""
for s in strings:
data += s.encode() + b"\x00"
h = 0xcbf29ce484222325
for b in data:
h ^= b
h = (h * 0x100000001b3) & 0xffffffffffffffff
print(hex(h))python3 fnv.py

В результате получаю 64-битное значение хеша. Его я использовала как начальное состояние для следующего генератора псевдослучайных чисел, который как раз используется для расшифровки найденных 34 байт. В восстановленном коде для этого генератора я уже видела две другие константы: 0×5851f42d4c957f2d и 0×14057b7ef767814f
Здесь логика снова похожа на предыдущий этап: берется текущее состояние, умножается на первую константу, к результату прибавляется вторая, после чего из старших байтов состояния получается ключ для XOR.
Берём те самые 34 байта из расшифрованного.rdata, о которых я говорила выше, и используем их как шифротекст для следующего этапа расшифровки.
nano decrypted_bytes.py
MULT = 0x5851f42d4c957f2d
ADD = decrypted_bytes.py0x14057b7ef767814f
state = fnv_hash
plain = bytearray()
for c in cipher:
state = (state * MULT + ADD) & 0xffffffffffffffff
key = state >> 56
plain.append(c ^ key)
print(plain.hex())
print(plain.decode())KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.