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

14 янва­ря ком­пания Fortinet рас­кры­ла под­робнос­ти кри­тичес­кой уяз­вимос­ти CVE-2024-55591 (CVSS 9,6) в про­дук­тах FortiOS и FortiProxy. Эта новость сра­зу же прив­лекла мое вни­мание, потому что FortiOS — основная опе­раци­онная сис­тема для меж­сетевых экра­нов FortiGate, которые пов­семес­тно исполь­зуют­ся для защиты кор­поратив­ных сетей и орга­низа­ции уда­лен­ного дос­тупа. Появ­ление уяз­вимос­ти подоб­ного клас­са пред­вещало инте­рес­ный ресерч, а так­же воз­можность поп­ракти­ковать­ся в реверс‑инжи­нирин­ге и ана­лизе исходно­го кода.

Как и всег­да в подоб­ных ситу­ациях, меж­ду иссле­дова­теля­ми со все­го мира воз­ника­ет сорев­нование за пер­венс­тво в пуб­ликации PoC и под­робно­го опи­сания про­цес­са экс­плу­ата­ции уяз­вимос­ти, и я не смог отка­зать себе в воз­можнос­ти поучас­тво­вать в этой гон­ке умов.

Идентифицируем уязвимость

В ана­лизе уже опуб­ликован­ных уяз­вимос­тей есть огромное пре­иму­щес­тво — как пра­вило, вен­дор любез­но пре­дос­тавля­ет общее опи­сание и тем самым обоз­нача­ет для нас приб­лизитель­ный век­тор экс­плу­ата­ции, зна­читель­но сужая область поис­ка и эко­номя кучу вре­мени.

Итак, из бюл­летеня безопас­ности мы зна­ем сле­дующее:

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().

Шоу начинается

До­рогой читатель, я искрен­не бла­года­рен тебе за то, что ты смог дой­ти до этой гла­вы. Понимаю, выше рас­положен огромный пласт нуд­ной тех­ничес­кой информа­ции, но, поверь мне, все это пот­ребу­ется нам, что­бы понять, в чем же кро­ется проб­лема. Даль­ше будет инте­рес­но, обе­щаю!

Мы оста­нови­лись на момен­те переда­чи зап­роса к REST API с парамет­ром local_access_token. Никакой информа­ции в докумен­тации к FortiOS о том, для чего он пред­назна­чен, я не нашел. Поэто­му пой­дем эмпи­ричес­ким путем и прос­то поп­робу­ем передать слу­чай­ное зна­чение.

Вновь вер­немся к Burp Suite, сконс­тру­ируем зап­рос по ранее обна­ружен­ному нами адре­су, передав заголов­ки Connection: Upgrade и Upgrade: websocket, и пос­мотрим на реак­цию сис­темы. Отправ­ляем его и видим радос­тный ответ сер­вера — 101 Switching Protocols. Даль­ше можем общать­ся по WebSocket.

Запрос со случайным local_access_token
Зап­рос со слу­чай­ным local_access_token

На этом момен­те я силь­но обра­довал­ся и подумал, что тай­на рас­кры­та: сер­вер отве­тил мне успешным перехо­дом на WebSocket, а зна­чит, оста­лось толь­ко отпра­вить туда какую‑нибудь коман­ду и радовать­ся све­жень­кому PoC (спой­лер: даль­ше я точ­но так же силь­но расс­тро­ился).

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

Успешная аутентификация в REST API
Ус­пешная аутен­тифика­ция в REST API

Здесь у меня воз­ник воп­рос. Пусть local_access_token не валиди­рует­ся модулем Node.js, но почему API успешно аутен­тифици­рует нас, нес­мотря на то что этот самый токен — совер­шенно слу­чай­ное зна­чение? Что­бы отве­тить на него, при­дет­ся запус­тить твой любимый дизас­сем­блер (у меня это BinaryNinja) и пос­мотреть на код основно­го при­ложе­ния FortiOS — /bin/init.

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

api_access_check_for_trusted_access()
api_access_check_for_trusted_access()

Фун­кция is_trusted_ip_and_useragent() игра­ет прос­тую роль: срав­нива­ет IP-адрес и User-Agent кли­ента с фик­сирован­ными зна­чени­ями — 127.0.0.1 и Node.js (его переда­ет 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: 127.0.0.1 и User-Agent: Report Runner. Под­робнее об этой уяз­вимос­ти мож­но почитать в бло­ге Horizon3.ai.

is_trusted_ip_and_useragent()
is_trusted_ip_and_useragent()

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

Логи CLI
Ло­ги CLI

Яс­но, что раз­работ­чики добави­ли допол­нитель­ный этап аутен­тифика­ции перед пре­дос­тавле­нием поль­зовате­лю дос­тупа к интерфей­су тер­минала. Оста­лось разоб­рать­ся, что и каким обра­зом отправ­ляет­ся в CLI. Вновь воз­вра­щаем­ся к коду модуля Node.js и ищем стро­ку Sending login context.

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 в бра­узе­ре и смот­рим, что мы там смог­ли наловить.

Легитимный loginContext
Ле­гитим­ный loginContext

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

Нелегитимный loginContext
Не­леги­тим­ный loginContext

Дей­стви­тель­но, наш зап­рос при­ходит от поль­зовате­ля Local_Process_Access, который, судя по все­му, прос­то не име­ет необ­ходимых прав для вза­имо­дей­ствия с CLI.

Proof of Concept

На­конец я смог уже по‑нас­тояще­му обра­довать­ся: век­тор экс­плу­ата­ции был пол­ностью опре­делен, оста­лось толь­ко написать неболь­шой скрипт, который выпол­нит все, что было выяс­нено ранее. В этой статье я не буду рас­смат­ривать про­цесс написа­ния PoC — у нас тут исто­рия сов­сем не про это. Лишь крат­ко опи­шу логику и покажу резуль­тат.

Итак, нам тре­бует­ся: отпра­вить зап­рос в эндпо­инт /ws/cli, ука­зав в качес­тве local_access_token слу­чай­ную стро­ку и тре­бова­ние перевес­ти обще­ние на WebSocket.

Пос­ле ини­циали­зации WebSocket-соеди­нения нуж­но отпра­вить в него сле­дующий loginContext (ука­зав имя сущес­тву­ющей учет­ной записи адми­нис­тра­тора):

"admin" "admin" "root" "super_admin" "root" "none" [IP]:PORT [IP]:PORT

Вслед за loginContext мы дол­жны отпра­вить коман­ду для исполне­ния (нап­ример, get system status). Учи­тывая, что это уяз­вимость типа «сос­тояние гон­ки», — обра­ботать исклю­чения и, если выпол­нить коман­ду не уда­лось, поп­робовать сно­ва.

CVE-2024-55591 PoC
CVE-2024-55591 PoC

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