
Приветствую! Есть такая славная традиция — запускать DOOM на всём подряд. Причём часто целевое железо для этого совершенно не предназначено: домофоны, калькуляторы, электронные книги, осциллографы и многое другое. О самом феномене «думозапуска» уже подробно написано в статье от @shiru8bit — советую почитать.

Лично я часть таких «запусков» не признаю, если они попадают в одну из категорий:
Заменено оригинальное железо, либо приделано другое, реально способное запустить игру (привет, хайп с тестом на беременность);
Запуск не потребовал от автора особых усилий.
Второй пункт стоит пояснить. Под «особыми усилиями» я понимаю:
Создание собственной системы сборки из-за уникальности архитектуры (если другого готового и работающего компилятора не найти);
Адаптация систем ввода-вывода устройства под игру (подключение USB-клавиатуры, мыши или монитора к имеющимся портам — не в счёт);
Получение шелла на устройстве (получение root-прав на смартфоне по типовой инструкции и запуск APK-файла — тоже не считается).
Теперь, когда с моим снобизмом всё ясно, расскажу, как мне удалось запустить DOOM на осциллографе Siglent SDS5034X. Для этого пришлось найти шелл (вендор получил мой репорт), адаптировать систему ввода игры под энкодеры на лицевой панели и вывести звук на встроенную «пищалку». Приступим.
Получение шелла

Самый простой вариант что-либо запустить на осциллографе — telnet или SSH. Но в последней доступной версии прошивки их нет — выпилили.
Есть вариант посложнее: разобрать устройство, выпаять флешку, записать туда файл игры и запаять обратно. Но это совсем безбашенно. Осциллограф новый, срывать гарантию не хочется, да и морального удовлетворения от результата не будет. Поэтому я пошёл ещё более сложным путём — стал изучать систему обновлений.
Файлы обновлений
Доступные для скачивания файлы обновлений под SDS5034X (как и под другие модели) имеют расширение ADS. При беглом сравнении бинарей SDS2000X и SDS5000X оказалось, что структура у них разная. Но я решил попробовать найденную в интернете утилиту, написанную для SDS2000X.

Нашёл я не саму программу, а лишь её хеш — в сообщении, где пользователь жаловался на реакцию антивирусов при проверке файла на VirusTotal. Как бывший сотрудник AV-компании я знал: если что-то залито на VirusTotal, это можно скачать через VirusTotal Intelligence. Немногим позже файл оказа��ся у меня.
Бодание с Nuitka
Найденной программой оказался скрипт на Python, упакованный с помощью Nuitka в исполняемый файл. Я мог бы применить этот скрипт на ADS-файл и посмотреть результат, но какие бы знания я получил? Правильно, никакие. Поэтому решил пореверсить.

На OFFZONE 2025 (где мне тоже довелось выступить) Андрей Чижов из BI.ZONE уже рассказывал про Nuitka, но о докладе я узнал поздно. К тому же мне хотелось самому разобраться в принципах работы утилиты. Вот что я выяснил:
Верхний слой — контейнер, в который упакован основной исполняемый файл с преобразованным кодом, а также необходимые runtime-библиотеки Python. Снимается скриптом nuitka-extractor;
Код можно восстановить почти один в один, но вручную (исходник не хранится);
Дополнительные модули восстанавливаются также и лежат в главном исполняемом файле;
Все строки хранятся в секции ресурсов без шифрования. Чтобы понять, где какая используется, нужно запускать бинарь. Для малвари это тоже приемлемо, если ставить бряки, которые точно сработают до запуска файла. Шучу — никогда не запускайте малварь на рабочем ПК, даже под отладкой.
Весь процесс восстановления кода описывать я, пожалуй, не буду, но вот главное что мне удалось извлечь:
Ключ шифрования заголовка ADS-файла;
Алгоритм шифрования заголовка — модифицированный 3DES на основе публичного кода pyDes.py ( ссылка на изменённый код для сравнения);
И — забегая вперёд — функция деобфускации содержимого. Она оказалась идентичной SDS2000X (хоть алгоритм разбора прошивки и не подошёл).

Извлечение прошивки
Теперь, когда у меня был необходимый код, я применил его к первым 0x70 байтам файла прошивки (заголовка):


Очевидно, что расшифровались они правильно. На скриншоте видны строки с версией файла обновления, что-то похожее на контрольную сумму, размер и так далее. Следующие байты расшифровать не удалось (я пытался найти в расшифрованных байтах нули), но скрипт из Nuitka выдавал некорректный результат.
Ранее, разбирая ADS-структуру вручную, я заметил, что в конце файла обновления много нулей и при некоторой сноровке просматриваются перевёрнутые строки путей к файлам:

Я развернул файл банальным data[::-1] и получил следующую картину:

Пути к файлам стали видны чётко, но часть содержала лишние байты. Байты PK\x01\x02 показались знакомыми…
Не разобравшись с кодировкой, я переключился на скрипт с VirusTotal, из которого восстановил алгоритм деобфускации. По механизму работы он, судя по всему, подходил к инвертированным байтам. Почему бы его не применить и не посмотреть, что будет? Сказано — сделано.
В итоге после нескольких дней приседаний с Nuitka и благодаря «насмотренности» на бинарные файлы в hex-редакторе я восстановил оригинальный ZIP-файл. Всё-таки не зря меня триггернули знакомые байты.
Формирование файлов обновлений
Поковыряв архив, я решил заняться крафтом ADS-файлов обратно из содержимого ZIP. Но, прежде чем рассказать об этом, небольшой каминг-аут:
Я впервые воспользовался ИИ для написания кода, и жизнь разделилась на «до» и «после». Ещё вчера я смеялся над теми, кто ленится писать его сам и идёт в условные «жыпити» что-то спросить или сгенерировать код. А сегодня сам всё чаще прошу ИИ поработать за меня. Мне стыдно, я деградирую, но… так проще. Спасибо.
Итак, я попросил ИИ написать код, обратный к деобфускатору. И он справился. Я добавил свой код шифрования заголовка статичным ключом 3DES и получил файл, почти идентичный оригиналу. «Почти» заключалось в четырёх (!) байтах заголовка. Именно из-за них пришлось разбирать осциллограф.
Чексумма
Разобрать осциллограф SDS5034X не сложно: открутить винты, найти флешку где-то на платах, выпаять, сдампить, запаять обратно, собрать — и изучать дамп.

Но, как оказалось позже, разбирать аппарат и повреждать гарантийную наклейку было не обязательно. Всё нужное я получил после распаковки файла обновления, о чём заранее не догадывался. Осознание этого расстроило, но что сделано, то сделано.
Изучая структуру ADS-файлов и то, как они парсятся, я понял, что первые четыре байта заголовка — это контрольная сумма. Не CRC32, а что-то вроде суммы байт со знаком минус, зависящей от размера прошивки. Но было непонятно: учитывались ли байты заголовка, и если да, то до или после расшифровки 3DES, нужно ли деобфусцировать содержимое или суммировать как есть.
В файле обновления ADS ответы найти не удалось. Однако после получения дампа с осциллографа я понял, что просто плохо искал. Всего-то нужно было:
извлечь из ZIP-архива файл
uImage;из
uImage— файл ядра Linux;из ядра — gz-архив;
распаковать этот gz-архив;
внутри найти ещё один gz-архив;
распаковать и второй gz-архив;
внутри найти третий gz-архив;
в третьем обнаружится файловая система с исполняемым файлом
adsdec, который проверяет ADS-файлы.
Из adsdec я извлёк алгоритм формирования контрольной суммы и смог создать свой файл обновления. Осциллограф принял его где-то с 5–6 раза:

Почему не с первого? ZIP-архивы, собранные на Python, C# или в Windows, осциллографу не понравились. В оригинальном архиве дополнительно указывались права для файлов ( rwx) и владелец, а мои архиваторы эти сведения пропускали. Пришлось собирать ZIP-архив нативно в Linux.

Шелл на осциллографе
Осталось указать в стартовых скриптах запуск telnetd и надеяться на открытие порта после перезагрузки. Сделал, залил, перезагрузил — порт не поднялся. Посовещавшись с товарищем, параллельно колдующим над SDS2104X, сменил порт для телнета на что-то выше 10000 — помогло. Опасался, что железка запросит логин и пароль из /etc/shadow, который я не смог заранее сбрутить, но шелл открылся сразу:

Шелл есть — пора запускать DOOM! :)
Сборка и запуск DOOM: пошаговая инструкция
Эту часть статьи можно воспринимать не просто как инструкцию о том, как собрать DOOM под конкретный осциллограф. Её вполне реально использовать и как пошаговое руководство по сборке игры под разные другие устройства.
До первого запуска DOOM II на видеодомофоне мне казалось, что это просто: собрал игру через arm-gcc, забросил файлы, запустил. На деле всё сложнее.
Вот с какими ограничениями я тогда столкнулся:
Свободное место на устройстве, необходимое для хранения PCM-файлов MIDI. Не каждый девайс в принципе умеет воспроизводить MIDI;
Ввод. У домофона сенсорный экран (как и у осциллографа), и единственный способ получать ввод от игрока — нажимать на экран. Я понадеялся, что на осциллографе смогу использовать «крутилки» и кнопки;
Вывод графики на экран. Современные порты DOOM делают в основном на базе фреймворка SDL2, которого ни на видеодомофоне, ни на осциллографе нет. Ставить лишние библиотеки — несерьёзно;
Вывод звука. SDL2 из коробки поддерживает кучу устройств, а мне оказалась доступна только «пищалка» для клавиш.

Сборка
Для решения задачи мы будем использовать форк DOOM под названием fbDOOM. Сначала нужно выяснить, с какой платформой мы имеем дело. Для этого выполняем команду file на одном из исполняемых файлов прошивки. Далее нужно следить, чтобы скомпилированные файлы имели похожий вывод:
file ./bin/busybox ./bin/busybox: ELF 32-bit LSB executable, ARM, EABI5 version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.3, for GNU/Linux 2.6.16, stripped
После этого нужно определиться, как воспроизводить звук. У осциллографа из устройств, способных его вывести, есть только buzzer_zynq (это название из символов в sds2000hsr — основного исполняемого файла на SDS5034X). Выяснилось, что сам «баззер» маппится в Linux на адрес 0xF8001000, а включение и выключение звука нажатия задаётся через запись значения 0x0A/0x0B в адрес 0xF800100C. Поковырявшись в коде, я понял, как менять частоту, на которой звучит писк. Для этого нужно привести значение частоты к диапазону 14000-65536, который был подобран экспериментально с применением утилиты devmem. Как через это воспроизводить звуки из игры — пока было не понятно.
Давайте для начала просто соберём fbDOOM и запустим его на осциллографе (собираю на Linux — так проще). Для этого клонируем репозиторий, переходим в подкаталог fbdoom и открываем Makefile на редактирование:
В начало файла добавляем строку
NOSDL=1, чтобы файл не линковался с SDL-библиотеками;Редактируем строку с
CROSS_COMPILE, заменив#arm-linux-gnueabihf-наarm-linux-gnueabi-;В
CFLAGS+=добавляем-static.
Сохраняем файл и запускаем make. На выходе получается исполняемый файл fbdoom, который нужно забросить на осциллограф вместе с файлом DOOM2.WAD. Уже на железке запускаем fbdoom -iwad DOOM2.WAD. Когда всё сделано правильно, в telnet-терминале мы видим:
I_InitGraphics: framebuffer: x_res: 1024, y_res: 600, x_virtual: 1024, y_virtual: 600, bpp: 32, grayscale: 0 I_InitGraphics: framebuffer: RGBA: 8888, red_off: 16, green_off: 8, blue_off: 0, transp_off: 24 I_InitGraphics: DOOM screen size: w x h: 320 x 200 I_InitGraphics: Auto-scaling factor: 3 101-key keyboard found. Using keyboard on /dev/tty0. Ready to read keycodes. Press Backspace to exit.
Ввод
Разобраться с вводом, думаю, будет проще всего. В коде файла sds2000hsr нужно найти место, где обрабатываются «крутилки», и добавить работу с ними в код игры. Непродолжительные поиски привели меня к устройству /dev/siglent_kb, которое, судя по всему, и обрабатывает нажатия клавиш, а также к его драйверу — siglentkb.ko.
Из кода основного приложения я понял, что работа с лицевой панелью происходит через открытие файла устройства на чтение и последующее чтение «интов» в отдельном потоке. Вот пример тестового приложения для проверки моих наблюдений:
#include <stdio.h> #include <fcntl.h> #include <unistd.h> int main(int argc, char* argv[]) { int fd = open("/dev/siglent_kb", O_RDONLY); if (fd == -1) { perror("bad"); return -1; } printf("opened\n"); while (1) { int val = 0; if (read(fd, &val, 4) > 0) { printf("%08X\n", val); } } return 0; }

Выхлоп в консоль происходит ровно тогда, когда мы нажимаем что-либо на лицевой панели. Пришлось заморочиться, чтобы завести эту логику в код файла i_input_tty.c, но результат мне понравился. Основной проблемой оказалась сломанная логика игры, из-за которой два подряд отправленных события keydown/keyup не засчитывались. Пришлось вставлять логику задержки, благодаря чему удалось заодно реализовать и чувствительность нажатий.
Звук
Больше всего мучений у вас возникнет со звуком. Для начала нужно определиться, какая из существующих звуковых схем хоть как-то подходит, или же нужно будет писать свою. Для этого открываем файл исходников i_sound.h и смотрим на enum snddevice_t:
typedef enum { SNDDEVICE_NONE = 0, SNDDEVICE_PCSPEAKER = 1, SNDDEVICE_ADLIB = 2, SNDDEVICE_SB = 3, SNDDEVICE_PAS = 4, SNDDEVICE_GUS = 5, SNDDEVICE_WAVEBLASTER = 6, SNDDEVICE_SOUNDCANVAS = 7, SNDDEVICE_GENMIDI = 8, SNDDEVICE_AWE32 = 9, SNDDEVICE_CD = 10, } snddevice_t;
Очень похоже, что SNDDEVICE_PCSPEAKER может подойти, но его реализацию определённо придётся заменить. Поэтому вместо этого создадим новый элемент в перечислении: SNDDEVICE_SIGLENT = 11, а также подготовим новые файлы i_sdsmusic.c и i_sdssound.c для реализации системы воспроизведения музыки и звуков соответственно.
Музыка
За музыку в DOOM отвечают файлы с названиями в стиле i_*music.c. При написании кода проще всего подглядывать в соседние файлы, например в i_sdlmusic.c. Основной элемент в таких файлах — структура music_module_t, а также функции-колбэки для неё. Следующей «рыбы» окажется вполне достаточно:
#include <stdio.h> #include "config.h" #include "doomtype.h" #include "i_sound.h" #include "i_system.h" static boolean I_SIGLENT_InitMusic(void) {} static void I_SIGLENT_ShutdownMusic(void) {} static void I_SIGLENT_SetMusicVolume(int volume) {} static void I_SIGLENT_PauseMusic(void) {} static void I_SIGLENT_ResumeMusic(void) {} static void* I_SIGLENT_RegisterSong(void *data, int len) {} static void I_SIGLENT_UnregisterSong(void *handle) {} static void I_SIGLENT_PlaySong(void *handle, boolean looping) {} static void I_SIGLENT_StopSong(void) {} static boolean I_SIGLENT_MusicIsPlaying(void) {} static void I_SIGLENT_Poll(void) {} static snddevice_t music_siglent_devices[] = { SNDDEVICE_SIGLENT, }; music_module_t music_siglent_module = { music_siglent_devices, arrlen(music_siglent_devices), I_SIGLENT_InitMusic, I_SIGLENT_ShutdownMusic, I_SIGLENT_SetMusicVolume, I_SIGLENT_PauseMusic, I_SIGLENT_ResumeMusic, I_SIGLENT_RegisterSong, I_SIGLENT_UnregisterSong, I_SIGLENT_PlaySong, I_SIGLENT_StopSong, I_SIGLENT_MusicIsPlaying, I_SIGLENT_Poll, };
Полную реализацию можно посмотреть по ссылке.
Из интересного: нам потребуется преобразовать MIDI-файлы игры в набор команд для «баззера», каждая из который имеет вид {частота, длительность}, и отправить музыку уровня играть в отдельный поток.
Далее нужно подключить новый модуль музыки. Для этого в файле doomfeatures.h активируем дефайн FEATURE_SOUND, открываем файл i_sound.c и вносим правки:
значения переменных
snd_musicdeviceиsnd_sfxdeviceустанавливаем вSNDDEVICE_SIGLENT;в список
extern music_module_t music_*_moduleдобавляемmusic_siglent_module.
И всё, музыка заработает.
Звуковые эффекты
Со звуками будет сложнее и интереснее. Просто так преобразовать встроенный формат звуковых эффектов в MIDI не получится. Мне пришлось обращаться к ИИ за помощью в конвертации (результат меня порадовал), правда я не очень понял, как он работает. Ссылки на все скрипты я указал в конце статьи.
Кодовая составляющая реализации звука делается во многом по аналогии с музыкой:
создаём файл (.c);
реализуем структуру
sound_module_tи функции для неё;пишем логику для воспроизведения звуков в отдельном потоке.
Так выглядит цикл отправки команд «баззеру»:
while (current_song->is_playing) { pthread_testcancel(); if (ptr->freq == 0xFFFFFFFF && ptr->length == 0.000f) { current_song->is_playing = false; printf("song end\n"); break; } if (ptr->freq == 0xFFFFFFFF) { int cnt = (int)(ptr->length * 1000.0f); for (int i = 0; current_song->is_playing && i < cnt; ++i) { I_Sleep(1); } } else { set_buzzer_freq(&buzzer_handle, ptr->freq); start_buzzer_freq(&buzzer_handle); int cnt = (int)(ptr->length * 1000.0f); for (int i = 0; current_song->is_playing && i < cnt; ++i) { I_Sleep(1); } stop_buzzer_freq(&buzzer_handle); } ptr++; }
Во многом я просто взял свой модуль музыки и адаптировал его, но с нюансами. Если музыкальная композиция на уровне в моменте может играть только одна, то звуков может быть много (соседние модули реализуют 16 каналов). «Баззер» у нас один — и на звук, и на музыку, — так что пришлось оборачивать всю логику в мьютексы.
Но это была не единственная проблема. Приблизительно на третьем уровне воспроизведения демо-игры, звуки переставали проигрываться. Перепробовал много всего — всё бестолку. Пока не осознал, что потоки хоть и создаются, хэндлы от них позже не закрываются. Казалось бы, используй pthread_join() — и не будет проблем. Но нет.
Звуковое ядро игры DOOM после отправки звука на воспроизведение может не захотеть его остановить. А может и захотеть. Моя логика была завязана лишь на то, что звуки всегда останавливаются через колбэк StopSound(). Отсюда и возникла проблема: pthread_create() не создавал новые потоки, возвращая ошибку EAGAIN (большое число ранее открытых тредов). Добавление вызова pthread_detach() сразу после создания потока исправило ситуацию.
Небольшая оптимизация
Во время тестовых запусков игры выяснилось, что оригинальный код fbDOOM для отрисовки во фреймбуфер захлёбывается, из-за чего экран перерисовывается медленно. Поэтому пришлось заменить запись в /dev/fb0 через write() на запись через mmap()/memcpy() — так оказалось быстрее.
Заключение
Вот мы и добрались до финальной сборки и тестов на осциллографе! Прокидываем через nc/netcat файлы в /tmp, чтобы не захламлять основную файловую систему, делаем chmod +x ./fbdoom и запускаем. Выглядит, на мой взгляд, впечатляюще! Всем спасибо за внимание.
Ссылка на мой форк fbDOOM: https://github.com/lab313ru/fbDOOM_SDS5000X (советую посмотреть папку scripts).
Спойлер
P.S. У нашей исследовательской лаборатории есть телеграм-канал: [@bizone_hwlab](https://t.me/bizone_hwlab), в который мы периодически выкладываем разные интересные штуки, связанные с аппаратной безопасностью. Заходите, всем будем рады:)
