В этой статье я рас­ска­жу, как искал недав­но рас­кры­тую ком­пани­ей Fortinet уяз­вимость CVE-2024-55591 в про­дук­тах FortiOS и FortiProxy. Уяз­вимость поз­воля­ет обхо­дить аутен­тифика­цию с исполь­зовани­ем аль­тер­натив­ного пути или канала (CWE-288), а еще дает воз­можность уда­лен­ному зло­умыш­ленни­ку получить при­виле­гии супер­поль­зовате­ля и выпол­нить про­изволь­ные коман­ды.

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.

Структура файловой системы FortiOS
Струк­тура фай­ловой сис­темы 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
Фун­кция 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
Зап­рос для откры­тия web CLI

Ни­како­го упо­мина­ния парамет­ра 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.0.0.1. Если это так, соз­дает­ся пре­доп­ределен­ный объ­ект сес­сии.

Для уда­лен­ных зап­росов вызыва­ется метод webAuth.getValidatedSession(), который выпол­няет валида­цию сес­сии на осно­ве токенов или куки.

 

Проверка на основе токенов или куки с помощью 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()

Ес­ли сес­сия не най­дена в кеше, метод _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. Но не спе­ши это­го делать.

 

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, отключит рекламу на сайте и увеличит личную накопительную скидку! Подробнее

  • Подпишись на наc в Telegram!

    Только важные новости и лучшие статьи

    Подписаться

  • Подписаться
    Уведомить о
    0 комментариев
    Межтекстовые Отзывы
    Посмотреть все комментарии