Physical Address
304 North Cardinal St.
Dorchester Center, MA 02124
Physical Address
304 North Cardinal St.
Dorchester Center, MA 02124
В этой статье я расскажу, как искал недавно раскрытую компанией Fortinet уязвимость CVE-2024-55591 в продуктах FortiOS и FortiProxy. Уязвимость позволяет обходить аутентификацию с использованием альтернативного пути или канала (CWE-288), а еще дает возможность удаленному злоумышленнику получить привилегии суперпользователя и выполнить произвольные команды.
14 января компания Fortinet раскрыла подробности критической уязвимости CVE-2024-55591 (CVSS 9,6) в продуктах FortiOS и FortiProxy. Эта новость сразу же привлекла мое внимание, потому что FortiOS — основная операционная система для межсетевых экранов FortiGate, которые повсеместно используются для защиты корпоративных сетей и организации удаленного доступа. Появление уязвимости подобного класса предвещало интересный ресерч, а также возможность попрактиковаться в реверс‑инжиниринге и анализе исходного кода.
Как и всегда в подобных ситуациях, между исследователями со всего мира возникает соревнование за первенство в публикации PoC и подробного описания процесса эксплуатации уязвимости, и я не смог отказать себе в возможности поучаствовать в этой гонке умов.
В анализе уже опубликованных уязвимостей есть огромное преимущество — как правило, вендор любезно предоставляет общее описание и тем самым обозначает для нас приблизительный вектор эксплуатации, значительно сужая область поиска и экономя кучу времени.
Итак, из бюллетеня безопасности мы знаем следующее:
Вероятно, самый популярный и простой способ найти исправленную уязвимость — это patch diffing. По своей сути он представляет собой сравнение двух разных «состояний» ПО — до и после того, как был выпущен патч. Как правило, для этого применяются различные методы реверс‑инжиниринга, и даже существуют специальные утилиты, позволяющие автоматизировать этот процесс (например, BinDiff).
FortiOS — проприетарное ПО с закрытым исходным кодом, поэтому просто покопаться в файлах ОС у нас не получится. К счастью, в открытом доступе полно статей, описывающих методы дешифрования и распаковки прошивок FortiGate. В моем случае понадобилось лишь незначительно отступить от этих алгоритмов, чтобы получить полноценный доступ к файловой системе, но эти операции выходят за рамки сегодняшней статьи.
Файловая система FortiOS представляет собой стандартную структуру директорий, характерную для основанных на Unix операционных систем. Здесь мое внимание сразу же привлекла папка node-scripts
, которая недвусмысленно намекает, что именно здесь расположена логика Node.js.
Структура файловой системы FortiOS
Внутри этой директории лежит файл index.js
, в котором примерно на 50 тысяч строк описана вся логика модуля Node.js. В FortiOS 7.0.17 разработчики решили немного усложнить жизнь ресерчерам (или хакерам?) и удалили комментарии из кода. Теперь он представляет собой только одну строку без переносов и отступов. Однако в уязвимой версии 7.0.16 комментарии все еще имеются, а код можно свободно читать. Поэтому вооружаемся плагином Prettier для VS Code, пропускаем через него код из версии 7.0.17 и начинаем поиски.
Из описания уязвимости мы знаем, что она связана с обходом аутентификации в модуле WebSocket Node.js, поэтому очевидным будет поискать изменения где‑то в окрестности методов аутентификации и обработки WebSocket. Поэтому добавляем index.js
из обеих версий в сравнение в VS Code и визуально изучаем. Здесь в глаза бросается удаленный после патча параметр local_access_token
, который проверяется в методе _getAdminSession
класса WebAuth
. Разработчики удалили всю логику, связанную с параметром, который обрабатывается в методе получения сессии администратора, — уже звучит интересно, не так ли?
Функция getAdminSession
Продолжаем наше путешествие по тысячам строк кода и натыкаемся на еще одну зацепку. В уязвимой версии строка
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.
Запрос для открытия web CLI
Никакого упоминания параметра local_access_token
тут нет, поэтому вернемся к исходному коду модуля Node.js и посмотрим на процесс аутентификации там.
Для взаимодействия посредством WebSocket в логике Node.js предусмотрен класс WebsocketDispatcher
, которому передается управление после инициализации соединения. Здесь определен метод dispatch()
, который занимается обработкой пользовательских запросов:
this._server.on('connection', (ws, request) => { const dispatcher = new WebsocketDispatcher(ws, request); dispatcher.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()
— ключевой элемент процесса аутентификации:
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.0.0.1
. Если это так, создается предопределенный объект сессии.
Для удаленных запросов вызывается метод 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.get()
), происходит переход к _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
(["monitor", "web-ui", "node-auth?local_access_token=TOKEN"]
) для дальнейшей обработки.
Здесь мы прервемся на небольшую паузу и немного отдохнем от чтения кода. Итак, мы остановились на методе _getAdminSession()
. Как можно заметить, на текущий момент бэкенд FortiGate никаким образом не валидировал local_access_token
, а лишь проверил его существование в запросе. Звучит странно, не правда ли?
Мы успешно прошли следующую цепочку аутентификации:
dispatch() → _getSession() → getValidatedSession() → _getAdminSession()
Можно предположить, что local_access_token
будет проверен на стороне REST API. Но не спеши этого делать.
Класс 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