Штурмуем крепость
Обходим аутентификацию в FortiOS и FortiProxy
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(
.
Шоу начинается
Дорогой читатель, я искренне благодарен тебе за то, что ты смог дойти до этой главы. Понимаю, выше расположен огромный пласт нудной технической информации, но, поверь мне, все это потребуется нам, чтобы понять, в чем же кроется проблема. Дальше будет интересно, обещаю!
Мы остановились на моменте передачи запроса к REST API с параметром local_access_token
. Никакой информации в документации к FortiOS о том, для чего он предназначен, я не нашел. Поэтому пойдем эмпирическим путем и просто попробуем передать случайное значение.
Вновь вернемся к Burp Suite, сконструируем запрос по ранее обнаруженному нами адресу, передав заголовки Connection:
и Upgrade:
, и посмотрим на реакцию системы. Отправляем его и видим радостный ответ сервера — 101
. Дальше можем общаться по WebSocket.

На этом моменте я сильно обрадовался и подумал, что тайна раскрыта: сервер ответил мне успешным переходом на WebSocket, а значит, осталось только отправить туда какую‑нибудь команду и радоваться свеженькому PoC (спойлер: дальше я точно так же сильно расстроился).
Все мои попытки хоть как‑то проэксплуатировать обнаруженный недостаток нещадно отвергались сервером — любая отправленная в WebSocket команда просто оставалась без ответа, а затем соединение обрывалось. Чтобы разобраться, в чем же проблема, обратимся к инструментам отладки, которые существуют в FortiOS. Подключаемся по SSH, включаем дебаг на приложения httpsd и node и снова отправляем запрос. Первое, что мы видим, — успешная аутентификация в REST API с IP-адреса 127.
(именно эту проблему мы видели в коде модуля Node.js).

Здесь у меня возник вопрос. Пусть local_access_token
не валидируется модулем Node.js, но почему API успешно аутентифицирует нас, несмотря на то что этот самый токен — совершенно случайное значение? Чтобы ответить на него, придется запустить твой любимый дизассемблер (у меня это BinaryNinja) и посмотреть на код основного приложения FortiOS — /
.
Ориентируясь на дебаг записи, можно предположить, что процесс аутентификации выполняется функцией (или связан с ней), которая выводит нам сообщение api_access_check_for_trusted_access
. Потому просто ищем эту строку: к счастью, она используется лишь в одной функции. В ней описана огромная логика аутентификации в REST API на все случаи жизни, но наш случай ведь особенный — мы знаем, что используется User-Agent:
и запрос приходит с IP-адреса 127.
. Что‑то подходящее нашлось в самом верху — функция api_access_check_for_trusted_access(
вызывает то, что я назвал is_trusted_ip_and_useragent(
, передавая в качестве аргументов два параметра: заголовки запроса и строку Node.
.

Функция is_trusted_ip_and_useragent(
играет простую роль: сравнивает IP-адрес и User-Agent клиента с фиксированными значениями — 127.
и Node.
(его передает api_access_check_for_trusted_access(
).
Бинго! Это именно то, что мы видели в коде модуля Node.js в самом начале статьи, и именно то, почему мы можем получить доступ к REST API, — local_access_token
попросту никак не проверяется. Передав его в запросе, мы получили возможность достучаться до REST API — дальше отработала штатная логика аутентификации локальных клиентов.
info
К слову, подобная проблема была обнаружена в FortiOS и ранее. В октябре 2022 года была раскрыта уязвимость CVE-2022-40684, которая точно так же позволяла обойти аутентификацию в REST API, передав параметры client_ip:
и User-Agent:
. Подробнее об этой уязвимости можно почитать в блоге Horizon3.ai.

Что ж, мы поняли, почему нам удалось обойти аутентификацию в REST API. Но что там с CLI и эксплуатацией уязвимости? Вернемся к дебагу FortiOS и посмотрим, что происходит после предоставления нам прав доступа и начала взаимодействия с CLI. В логе видно, что интерфейс CLI инициализируется, затем происходит мистический Sending
и соединение закрывается со стороны CLI.

Ясно, что разработчики добавили дополнительный этап аутентификации перед предоставлением пользователю доступа к интерфейсу терминала. Осталось разобраться, что и каким образом отправляется в CLI. Вновь возвращаемся к коду модуля Node.js и ищем строку Sending
.
class CliConnection { constructor(ws, request, options, groupContext) { const args = [ `"${request.headers["x-auth-login-name"]}"`, `"${request.headers["x-auth-admin-name"]}"`, `"${request.headers["x-auth-vdom"]}"`, `"${request.headers["x-auth-profile"]}"`, `"${request.headers["x-auth-admin-vdoms"]}"`, `"${request.headers["x-auth-sso"] || SYMBOLS.SSO_LOGIN_TYPE_NONE_STR}"`, request.headers["x-forwarded-client"], request.headers["x-forwarded-local"],]; [...] this.logInfo("CLI websocket initialized."); const cli = (this.cli = connect({ port: 8023, // Обрати внимание сюда host: "127.0.0.1", localAddress: "127.0.0.2", })); this.logInfo("CLI connection established."); [...] this.loginContext = args.join(" "); [...] ws.on("message", (msg) => cli.write(msg)); // Сюда cli.setNoDelay().on("data", (data) => this.processData(data)); processData(buf) { [...] if (data) { const ws = this.ws; if (this.expectedGreetings) { if (data.toString().match(this.expectedGreetings)) { this.logInfo("Parsed expected greeting"); this.expectedGreetings = null; this.telnetCommand(CMD.DONT, OPT.ECHO); this.logInfo("Sending login context"); cli.write(`${this.loginContext}\n`); // И сюда this.setup(); } [...]
Искомая строка находится в классе CliConnection(
. Я обрезал большую часть, оставив только самое важное. loginContext
представляет собой строку, формируемую из заголовков запроса, который возвращает REST API. Судя по всему, у нашего пользователя просто нет прав на взаимодействие с CLI, и именно поэтому соединение обрывается.
Но важно здесь другое. Вспомни второе изменение, которое разработчики Fortinet внесли в рамках исправления уязвимости. Строка
ws.on("message", (msg) => cli.write(msg));
была перемещена в отдельную функцию setup(
.
Снова удача! То, что мы видим, — уязвимость типа «состояние гонки». Внимательно посмотри на код этого класса. Все сообщения, полученные по WebSocket, могут быть доставлены в CLI еще до отправки легитимного loginContext
. Это означает лишь одно: если успеть отправить нужную строку раньше, чем это сделает Node.js, CLI обработает ее и передаст нам ответ.
Но что именно нам нужно отправить? В который раз благодарим разработчиков Fortinet за удобные инструменты для анализа поведения системы и запускаем встроенный сниффер пакетов, указывая все интерфейсы и порт 8023
(именно он используется для взаимодействия с CLI). Затем открываем CLI в браузере и смотрим, что мы там смогли наловить.

Вуаля! Та самая строка легитимной аутентификации. Ради интереса снова попробуем отправить наш «хакерский» запрос и посмотрим, чем он отличается.

Действительно, наш запрос приходит от пользователя Local_Process_Access
, который, судя по всему, просто не имеет необходимых прав для взаимодействия с CLI.
Proof of Concept
Наконец я смог уже по‑настоящему обрадоваться: вектор эксплуатации был полностью определен, осталось только написать небольшой скрипт, который выполнит все, что было выяснено ранее. В этой статье я не буду рассматривать процесс написания PoC — у нас тут история совсем не про это. Лишь кратко опишу логику и покажу результат.
Итак, нам требуется: отправить запрос в эндпоинт /
, указав в качестве local_access_token
случайную строку и требование перевести общение на WebSocket.
После инициализации WebSocket-соединения нужно отправить в него следующий loginContext
(указав имя существующей учетной записи администратора):
"admin" "admin" "root" "super_admin" "root" "none" [IP]:PORT [IP]:PORT
Вслед за loginContext
мы должны отправить команду для исполнения (например, get
). Учитывая, что это уязвимость типа «состояние гонки», — обработать исключения и, если выполнить команду не удалось, попробовать снова.

Ну а вот итоговый результат работы.