Обходим аутентификацию FortiOS и FortyProxy

SpecIT

Well-known member
Пользователь
Регистрация
3 Фев 2025
Сообщения
58
Реакции
12
В этой статье я расскажу, как искал недавно раскрытую компанией 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.
Вы должны быть зарегистрированы для просмотра вложений

Внутри этой директории лежит файл 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. Разработчики удалили всю логику, связанную с параметром, который обрабатывается в методе получения сессии администратора, — уже звучит интересно, не так ли?
Вы должны быть зарегистрированы для просмотра вложений

Продолжаем наше путешествие по тысячам строк кода и натыкаемся на еще одну зацепку. В уязвимой версии строка
Код:
ws.on("message", (msg) => cli.write(msg));
находилась в основном потоке выполнения класса CliConnection. Теперь же ее перенесли в отдельный метод setup(). Несложно догадаться, что этот класс отвечает за взаимодействие пользователя с CLI из интерфейса администратора в браузере (помнишь jsconsole из введения?). Очевидно, этот код отвечает за отправку сообщений, полученных по WebSocket в этот самый CLI. Похоже, это именно то, что рассказали нам Fortinet в своем бюллетене безопасности.
Теперь мы приблизительное представляем, что именно было изменено разработчиками Fortinet для исправления этой уязвимости. Осталось понять, как воспользоваться полученными знаниями, чтобы ее проэксплуатировать. Для этого нам необходимо разобраться в механизме аутентификации пользователей.

Исследуем механизм аутентификации​

Веб‑интерфейс FortiGate предоставляет аутентифицированным пользователям возможность взаимодействия с CLI прямо из окна браузера. Простым нажатием кнопки администратору становится доступен стандартный интерфейс терминала для взаимодействия с FortiGate.
Самый очевидный способ разобраться в механизме аутентификации — посмотреть на то, как выглядит легитимный процесс. Передаем большое спасибо Fortinet за любезно предоставленные в свободном доступе виртуальные машины FortiGate, качаем себе одну и разворачиваем. Затем запускаем Burp Suite, переходим в веб‑интерфейс и открываем окно CLI. Перед нашим взором предстает обширный процесс клиент‑серверного взаимодействия, но нам интересно одно — эндпоинт, расположенный по следующему адресу:
Именно сюда обращается браузер пользователя перед тем, как открывается окно 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.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-эндпоинт:
Затем конструктор в 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.
Вы должны быть зарегистрированы для просмотра вложений

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

Здесь у меня возник вопрос. Пусть 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.
Вы должны быть зарегистрированы для просмотра вложений

Функция 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 — дальше отработала штатная логика аутентификации локальных клиентов.
К слову, подобная проблема была обнаружена в FortiOS и ранее. В октябре 2022 года была раскрыта уязвимость CVE-2022-40684, которая точно так же позволяла обойти аутентификацию в REST API, передав параметры client_ip: 127.0.0.1 и User-Agent: Report Runner.
Вы должны быть зарегистрированы для просмотра вложений

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

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

Действительно, наш запрос приходит от пользователя 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). Учитывая, что это уязвимость типа «состояние гонки», — обработать исключения и, если выполнить команду не удалось, попробовать снова.
Вы должны быть зарегистрированы для просмотра вложений

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