Содержание статьи
- Идентифицируем уязвимость
- Patch diffing
- Исследуем механизм аутентификации
- Инициализация соединения WebSocket
- Метод dispatch()
- Проверка сессии с помощью _getSession()
- Проверка на основе токенов или куки с помощью getValidatedSession()
- Переход к _getAdminSession()
- REST API запрос через ApiFetch()
- Шоу начинается
- Proof of Concept
14 января компания Fortinet раскрыла подробности критической уязвимости CVE-2024-55591 (CVSS 9,6) в продуктах FortiOS и FortiProxy. Эта новость сразу же привлекла мое внимание, потому что FortiOS — основная операционная система для межсетевых экранов FortiGate, которые повсеместно используются для защиты корпоративных сетей и организации удаленного доступа. Появление уязвимости подобного класса предвещало интересный ресерч, а также возможность попрактиковаться в реверс‑инжиниринге и анализе исходного кода.
Как и всегда в подобных ситуациях, между исследователями со всего мира возникает соревнование за первенство в публикации PoC и подробного описания процесса эксплуатации уязвимости, и я не смог отказать себе в возможности поучаствовать в этой гонке умов.
Идентифицируем уязвимость
В анализе уже опубликованных уязвимостей есть огромное преимущество — как правило, вендор любезно предоставляет общее описание и тем самым обозначает для нас приблизительный вектор эксплуатации, значительно сужая область поиска и экономя кучу времени.
Итак, из бюллетеня безопасности мы знаем следующее:
- уязвимость позволяет обойти аутентификацию путем отправки специально сконструированных запросов в модуль WebSocket Node.js;
- уязвимость каким‑то образом связана с взаимодействием через jsconsole (это CLI, который доступен из интерфейса администрирования прямо в браузере);
- для успешной эксплуатации уязвимости необходимо знать имя действующей учетной записи администратора.
Patch diffing
Вероятно, самый популярный и простой способ найти исправленную уязвимость — это patch diffing. По своей сути он представляет собой сравнение двух разных «состояний» ПО — до и после того, как был выпущен патч. Как правило, для этого применяются различные методы реверс‑инжиниринга, и даже существуют специальные утилиты, позволяющие автоматизировать этот процесс (например, BinDiff).
FortiOS — проприетарное ПО с закрытым исходным кодом, поэтому просто покопаться в файлах ОС у нас не получится. К счастью, в открытом доступе полно статей, описывающих методы дешифрования и распаковки прошивок FortiGate. В моем случае понадобилось лишь незначительно отступить от этих алгоритмов, чтобы получить полноценный доступ к файловой системе, но эти операции выходят за рамки сегодняшней статьи.
Файловая система FortiOS представляет собой стандартную структуру директорий, характерную для основанных на Unix операционных систем. Здесь мое внимание сразу же привлекла папка node-scripts
, которая недвусмысленно намекает, что именно здесь расположена логика Node.js.

Внутри этой директории лежит файл index.
, в котором примерно на 50 тысяч строк описана вся логика модуля Node.js. В FortiOS 7.0.17 разработчики решили немного усложнить жизнь ресерчерам (или хакерам?) и удалили комментарии из кода. Теперь он представляет собой только одну строку без переносов и отступов. Однако в уязвимой версии 7.0.16 комментарии все еще имеются, а код можно свободно читать. Поэтому вооружаемся плагином Prettier для VS Code, пропускаем через него код из версии 7.0.17 и начинаем поиски.
Из описания уязвимости мы знаем, что она связана с обходом аутентификации в модуле WebSocket Node.js, поэтому очевидным будет поискать изменения где‑то в окрестности методов аутентификации и обработки WebSocket. Поэтому добавляем index.
из обеих версий в сравнение в VS Code и визуально изучаем. Здесь в глаза бросается удаленный после патча параметр local_access_token
, который проверяется в методе _getAdminSession
класса WebAuth
. Разработчики удалили всю логику, связанную с параметром, который обрабатывается в методе получения сессии администратора, — уже звучит интересно, не так ли?


Продолжаем наше путешествие по тысячам строк кода и натыкаемся на еще одну зацепку. В уязвимой версии строка
ws.on("message", (msg) => cli.write(msg));
находилась в основном потоке выполнения класса CliConnection
. Теперь же ее перенесли в отдельный метод setup(
. Несложно догадаться, что этот класс отвечает за взаимодействие пользователя с CLI из интерфейса администратора в браузере (помнишь jsconsole из введения?). Очевидно, этот код отвечает за отправку сообщений, полученных по WebSocket в этот самый CLI. Похоже, это именно то, что рассказали нам Fortinet в своем бюллетене безопасности.
Теперь мы приблизительное представляем, что именно было изменено разработчиками Fortinet для исправления этой уязвимости. Осталось понять, как воспользоваться полученными знаниями, чтобы ее проэксплуатировать. Для этого нам необходимо разобраться в механизме аутентификации пользователей.
Исследуем механизм аутентификации
Веб‑интерфейс FortiGate предоставляет аутентифицированным пользователям возможность взаимодействия с CLI прямо из окна браузера. Простым нажатием кнопки администратору становится доступен стандартный интерфейс терминала для взаимодействия с FortiGate.
Самый очевидный способ разобраться в механизме аутентификации — посмотреть на то, как выглядит легитимный процесс. Передаем большое спасибо Fortinet за любезно предоставленные в свободном доступе виртуальные машины FortiGate, качаем себе одну и разворачиваем. Затем запускаем Burp Suite, переходим в веб‑интерфейс и открываем окно CLI. Перед нашим взором предстает обширный процесс клиент‑серверного взаимодействия, но нам интересно одно — эндпоинт, расположенный по следующему адресу:
https://fortigate.example/ws/cli/open/
Именно сюда обращается браузер пользователя перед тем, как открывается окно CLI. В запросе передаются полученные при первичной аутентификации куки, а также параметр Upgrade
, указывающий серверу, что дальнейшее общение с клиентом будет проходить по протоколу WebSocket.

Никакого упоминания параметра local_access_token
тут нет, поэтому вернемся к исходному коду модуля Node.js и посмотрим на процесс аутентификации там.
Инициализация соединения WebSocket
Для взаимодействия посредством WebSocket в логике Node.js предусмотрен класс WebsocketDispatcher
, которому передается управление после инициализации соединения. Здесь определен метод dispatch(
, который занимается обработкой пользовательских запросов:
this._server.on('connection', (ws, request) => { const dispatcher = new WebsocketDispatcher(ws, request); dispatcher.dispatch();});
Метод dispatch()
Метод dispatch(
отвечает за проверку пользовательской сессии и определяет, как обрабатывать WebSocket-запрос:
async dispatch() { [...] const { session, isCsfAdmin } = await this._getSession(); if (!session) { this.ws.send('Unauthorized'); this.ws.close(); return null; } [...] if (this.path.startsWith('/ws/cli/')) { return new CliConnection(this.ws, { headers }, this.searchParams, this.groupContext); }}
Метод _getSession(
получает сессию и проверяет, обладает ли пользователь необходимыми правами.
Если сессия недействительна, соединение разрывается. В противном случае создается экземпляр CliConnection
для обработки взаимодействий с CLI. Именно сюда нам хотелось бы попасть, а для этого метод _getSession(
должен вернуть True
.
Проверка сессии с помощью _getSession()
Метод _getSession(
— ключевой элемент процесса аутентификации:
async _getSession() { const isConnectionFromCsf = this.request.headers['user-agent'] === CSF_USER_AGENT && this.localIpAddress === '127.0.0.1'; let isCsfAdmin = false; let session; if (!isConnectionFromCsf) { session = await webAuth.getValidatedSession(this.request, { authFor: 'websocket', groupContext: this.groupContext, }); [...] return { session, isCsfAdmin };}
Метод проверяет, исходит ли запрос от локального подключения CSF (Security Fabric — экосистема продуктов Fortinet), сопоставляя CSF_USER_AGENT
и локальный IP-адрес 127.
. Если это так, создается предопределенный объект сессии.
Для удаленных запросов вызывается метод webAuth.
, который выполняет валидацию сессии на основе токенов или куки.
Проверка на основе токенов или куки с помощью getValidatedSession()
Этот метод управляет извлечением токена и поиском уже существующей сессии. Помнишь наш легитимный сценарий аутентификации? Именно этот метод проверял, что у нас есть права на доступ к CLI:
async getValidatedSession(request, options = {}) { [...] const authToken = await this._extractToken(request); let session = null; [...] if (authToken) { const sessionEntry = webSession.get(authToken); if (sessionEntry) { session = sessionEntry.session; } } [...] if (!session) { session = await this._getAdminSession(request, options); [...] } if (authToken && !(await this._csrfValidation(request))) { session = null; } return session;}
Метод _extractToken(
извлекает токен или API-ключ из запроса.
Если действительная сессия не найдена в кеше (webSession.
), происходит переход к _getAdminSession(
для дальнейшей проверки.
Переход к _getAdminSession()
Если сессия не найдена в кеше, метод _getAdminSession(
пытается проверить уже знакомый нам local_access_token
, переданный в качестве параметра в URL:
async _getAdminSession(request, options = {}) { [...] const query = querystring.parse(request.url.replace(/.*\?/, '')); const localToken = query.local_access_token; const authParams = ["monitor", "web-ui", "node-auth"]; let authParamsFound = false; [...] if (localToken) { authParams[authParams.length - 1] += `?local_access_token=${localToken}`; authParamsFound = true; } if (!authParamsFound) { return null; } return await new ApiFetch(...authParams);}
Параметр local_access_token
извлекается из строки запроса. Если токен предоставлен, он добавляется к параметру node-auth
предопределенного массива authParams
. Затем вызывается метод ApiFetch
, который передает массив authParams
([
) для дальнейшей обработки.
Здесь мы прервемся на небольшую паузу и немного отдохнем от чтения кода. Итак, мы остановились на методе _getAdminSession(
. Как можно заметить, на текущий момент бэкенд FortiGate никаким образом не валидировал local_access_token
, а лишь проверил его существование в запросе. Звучит странно, не правда ли?
Мы успешно прошли следующую цепочку аутентификации:
dispatch() → _getSession() → getValidatedSession() → _getAdminSession()
Можно предположить, что local_access_token
будет проверен на стороне REST API. Но не спеши этого делать.
REST API запрос через ApiFetch()
Класс ApiFetch(
отправляет запрос REST API с параметрами, которые предоставляет _getAdminSession(
. Давай рассмотрим все эти параметры по очереди.
Для начала массив authParams
формирует API-эндпоинт:
https://fortigate.example/api/v2/monitor/web-ui/node-auth?local_access_token=TOKEN
Затем конструктор в ApiFetch
определяет стандартные HTTP-заголовки:
[...]const defaultHeaders = { 'user-agent': SYMBOLS.NODE_USER_AGENT, // Предопределенный User-Agent Node.js 'accept-encoding': 'gzip, deflate'};[...]
После этого функция fetch
отправляет запрос на сформированный URL с использованием стандартных заголовков. Сервер обрабатывает этот запрос и возвращает информацию о сессии.
Итак, мы разобрались с происходящим в модуле Node.js. Важно отметить, что сейчас совершенно ясно: запрос к REST API не содержит никаких параметров, позволяющих аутентифицировать клиента (например, заголовка X-Forwarded-For
), кроме пресловутого local_access_token
.
С точки зрения внутреннего устройства все выглядит так, как будто сам Node.js обращается к REST API. Дальнейшая обработка запроса выполняется на стороне основного приложения FortiOS. Будем держать в голове, что для успешной аутентификации запрос к REST API должен вернуть объект валидной сессии, что позволит нам пройти по цепочке аутентификации в обратную сторону и в итоге вернуться к созданию объекта CliConnection(
.
Шоу начинается
Дорогой читатель, я искренне благодарен тебе за то, что ты смог дойти до этой главы. Понимаю, выше расположен огромный пласт нудной технической информации, но, поверь мне, все это потребуется нам, чтобы понять, в чем же кроется проблема. Дальше будет интересно, обещаю!
Продолжение доступно только участникам
Материалы из последних выпусков становятся доступны по отдельности только через два месяца после публикации. Чтобы продолжить чтение, необходимо стать участником сообщества «Xakep.ru».
Присоединяйся к сообществу «Xakep.ru»!
Членство в сообществе в течение указанного срока откроет тебе доступ ко ВСЕМ материалам «Хакера», позволит скачивать выпуски в PDF, отключит рекламу на сайте и увеличит личную накопительную скидку! Подробнее