Безопасность
Это отчёт по issue #381:
исходники эмулятора были проверены на уязвимости, достижимые из загружаемых данных, все
подтверждённые ошибки исправлены, а отсутствовавшие проверки собраны в небольшой слой с
тестами — src/verify.h. Аудит и исправления сделаны только по этому дереву
исходников: в другие эмуляторы не подглядывали.
Полные таблицы по каждому артефакту приведены на английской странице отчёта; здесь — суть, модель угроз и перечень исправленного по подсистемам.
Модель угроз
Эмулятор — это программа, которая разбирает файлы. Эмулятор GameCube разбирает файлы, сделанные третьими лицами (образы игр, homebrew, сохранения), исполняет написанный этими лицами код и работает с правами пользователя. Реальный атакующий — это:
- испорченный файл, который пользователю предлагают открыть: «демка»
.dol, пропатченный.gcm/.rvz, положенный рядом.map, сценарий.cmd, сохранение карты памяти, дамп ROM; - код гостя внутри эмулятора, который управляет всеми движками DMA (EXI, DSP, DI, AI, PI/CP) значениями, которым доверять нельзя.
Чаще всего найденные дефекты приводили к падению, но многие из них — переполнение буфера в куче или на стеке с подконтрольными атакующему данными, что хуже падения.
Что эмулятор берёт извне
| Артефакт | Кто читает | Точка входа |
|---|---|---|
JSON настроек (Data/DefaultSettings*.json, Data/Settings*.json) | src/config.cpp → src/json.cpp | первый GetConfig*, то есть запуск |
Образ Bootrom (Data/bootrom.bin) | src/bootrtc.cpp | BootROM() при каждой загрузке |
DSP IROM/DROM (Data/dsp_irom.bin, Data/dsp_drom.bin) | src/flipper.cpp → src/dspcore.cpp | Flipper::Flipper() |
Исполняемые файлы (.dol, .elf) | src/main.cpp | LoadFile(): командная строка, селектор, load |
Образы дисков (.iso, .gcm, .rvz) | src/dvd.cpp, src/rvz.cpp, src/dvddebug.cpp | DVD::MountFile(), dvd_fs_init() |
| Сохранения карт памяти | src/memcard.cpp | MCConnect() при запуске, обмены EXI от гостя |
| Командная строка | src/main.cpp | EMUParseCmdLine() |
| Память IPL ROM и регистры устройств гостя | src/pi.cpp, src/mem.cpp, src/exi.cpp, src/bootrtc.cpp, src/dsparam.cpp, src/dspdma.cpp, src/cp.cpp | ловушки памяти, FIFO CP и все движки DMA |
Консольные сценарии (autoexec.cmd, любые script <файл>.cmd) | src/debug.cpp | CallJdi("script autoexec.cmd") при каждой загрузке |
Таблицы символов (*.map, Data/makemap.dat) | src/sym.cpp | AutoloadMap() при каждой загрузке |
Метод
- Входные поверхности разобраны по очереди, и каждая найденная ошибка перепроверена вторым проходом, который пытался её опровергнуть (номера строк, достижимость и серьёзность сверялись с исходником).
- Проверка, существующая только в виде
assert(), здесь считается ошибкой: в Release-сборках определёнNDEBUGи проверки нет вовсе, а в Debug-сборке сработавший assert открывает модальное окно и блокирует процесс. Именно такими оказались большинство найденных дефектов. - Каждое исправление — это проверка там, где используется недоверенное значение. Повторяющиеся
проверки (окно в основной памяти, секция образа, элемент FST, обмен с картой памяти, строка
сценария) вынесены в
src/verify.h, чтобы допустимый диапазон каждого поля был записан один раз. testing/security_test.cppпревращает каждое правило в модульный тест, включая те испорченные артефакты, которые раньше переполняли буфер, зацикливались или исчерпывали стек.
Верификаторы
Правила живут в src/verify.h, и все входные пути ими пользуются:
| Верификатор | Что проверяет |
|---|---|
Verify::Range(offset, size, limit) | единственное место, где складываются два недоверенных значения; вычитание вместо сложения — ни одна пара 32-битных полей не может обойти проверку переполнением |
Verify::MainMemory(phys, size, ramSize) | окно в основной памяти с маской адреса, как её декодирует MI (маска допускает 64 МБ, а выделено 24 или 48 МБ) |
Verify::ImageSection(...) | секция исполняемого образа: есть в файле и помещается в основную память |
Verify::DiscRead(position, length, imageSize) | чтение с диска со знаковым смещением, которым управляет гость |
Verify::FstRoot / FstEntry / FstName | таблица файловой системы диска, записи которой берутся прямо из образа |
Verify::MemcardWindow(cardSize, offset, length) | обмен с картой памяти, включая саму длину |
Verify::ScriptLine / ScriptTrim | безопасное чтение строк autoexec.cmd и прочих консольных сценариев |
У интерфейса памяти появились аксессоры, знающие длину (MIGetMemoryPointerForIO(phys, size),
...ForDSP, ...ForPI, ...ForDebug, MIGetMemorySize()),
поэтому копирование блока больше не может начаться внутри RAM, а закончиться за её пределами.
Что найдено и исправлено
Ниже — по подсистемам; в скобках указано, сколько записей в полной таблице на английской странице.
JSON настроек (src/json.cpp, src/config.cpp) — 13
- Строковый токен складывался в
wchar_t str[0x1000], а числовой — вchar number[0x100], и единственной границей былassert()(переполнение стека; критично и высоко). Теперь это настоящие ограничения. - У разбора не было ограничения глубины рекурсии, а ограничение в сериализаторе было assert'ом — испорченный файл исчерпывал стек. Добавлен счётчик глубины.
- Незавершённый объект (
{,{"a":1,) зацикливал разбор на 100% CPU — добавлены отсутствующие веткиEndOfStream/default. - Пропущенное двоеточие было assert'ом: в Debug-сборке это модальное окно и блокировка, в Release — молчаливый неверный разбор. Теперь это настоящая проверка.
- Просмотр литералов вычислял
maxSize - 4вsize_tи при файле в 1–4 байта читал за границей буфера; продолжения UTF-8 читались за концом строки; у числовых токенов «остаток» переполнялся. Всё закрыто проверками. - Ограничение числа элементов контейнера было assert'ом — стало реальным предохранителем от исчерпания памяти (достаточно большим, чтобы не ломать поставляемые спецификации JDI).
- Целые числа принимали
-1и молча заворачивались при выходе из диапазона; теперь проверяются каноническая форма и диапазон. - Обращения к настройкам разыменовывали отсутствующую секцию (assert) и переинтерпретировали значение другого типа (дикая ссылка/утечка указателя). Теперь проверяются секция и тип, а ошибка приводит к безопасному значению по умолчанию.
- Испорченный пользовательский файл настроек теперь сообщается и игнорируется — эмулятор остаётся на поставляемых значениях по умолчанию.
Исполняемые файлы и командная строка (src/main.cpp, src/utils.cpp) — 12
- Загрузчики DOL и ELF копировали секции по адресу, смещению и размеру из заголовка файла без
проверки и файла, и конца оперативной памяти: переполнение кучи (критично) и запись по
нулевому указателю (высоко). Оба конца теперь проверяются
Verify::ImageSection. - У ELF длина
p_fileszбыла знаковой, а таблица программных заголовков не проверялась — исправлено. wcsrchrмог вернуть NULL, и он попадал в_wcsicmp— падение при имени файла без расширения (в обоих фронтендах).- Образ диска, который не смонтировался, игнорировался: загрузка читала нули с пустого привода и зависала. Результат монтирования теперь проверяется.
- Неудачная загрузка оставляла наполовину созданный объект Flipper, и последующее завершение
падало (вплоть до core dump).
EMUOpenтеперь убирает за собой и пробрасывает исключение. Report/Haltформатировали вchar buf[0x1000]черезvsprintf; в UIsprintfсобирал консольную команду из имени файла в буфер 4 КБ/512 байт — оба переполнения стека закрыты.Util::SplitPathкопировал компоненты пути без ограничения длины;FileSaveв Linux открывал файл на чтение;FileLoad/FileSizeигнорировали ошибки — исправлено.- Обработчики команд читали
args[n]без проверки числа аргументов — добавлены проверки.
Образы дисков (src/dvd.cpp, src/rvz.cpp, src/dvddebug.cpp) — 9
nextOffsetкорневой записи FST вёл цикл байт-свопа без ограничения по буферу, прочитанному из образа, — перезапись кучи (критично).- Отрицательное смещение чтения с диска проходило проверки начала, а длина в смешанной
арифметике
int/size_tпревращалась в многогигабайтноеfread— переполнение кучи (критично). - Размер FST из загрузочной информации диска читался по адресу у самого конца оперативной памяти — до ~255 МБ за выделенным блоком (критично).
- Обход записей FST и указатель в таблицу имён не ограничивались; поле данных компрессора в RVZ читалось из заголовка ровно минимального размера; размер чанка RVZ не сравнивался с диском (можно было заставить выделить ~4 ГБ); дескриптор файла RVZ утекал при неудачном открытии.
- Пути хоста копировались в поля
wchar_t[0x1000]без ограничения длины. DumpFstобходил FST образа, байт-свопя по 12 байт на запись без границ, и копировал таблицу имён вchar name[0x200]— переполнение кучи и стека.
Карты памяти и шина EXI (src/memcard.cpp, src/bootrtc.cpp) — 12
- Запись страницы проверяла
offset >= size + size(проверка начала с переполнением) и не ограничивала длину — переполнение кучи (критично). - Чтение массива имело ту же проверку и копировало кучу хоста в память гостя — утечка содержимого памяти хоста (высоко).
- Стирание сектора проверяло только начало блока 8 КБ — переполнение кучи.
- Указатель DMA не проверялся на NULL, и даже корректное начало могло вывести обмен за конец основной памяти.
- Немедленное чтение сдвигало на отрицательную величину (undefined behaviour); форма адреса «extra bytes» сообщала об ошибке и продолжала вычислять смещение; файл карты больше 4 ГиБ обрезался до 32 бит и принимался за корректный.
- Отказ слота A мешал подключению (и сохранению) слота B.
- DMA микросхемы MX использовал длину обмена как есть для окна шрифта/ROM и для основной памяти (переполнение кучи, критично); DMA-чтение SRAM проверяло длину, но всегда копировало 64 байта; немедленное чтение SRAM индексировало 64-байтовый SRAM 8-битной маской; индекс приёмного буфера UART не ограничивался вовсе (запись за пределами объекта — высоко).
Движки DMA и командный процессор (dsparam.cpp, dspdma.cpp, pi.cpp, cp.cpp) — 6
- ARAM DMA проверял только начало обмена относительно 16-мегабайтного буфера — переполнение кучи (критично).
- DSP DMA обрезал сторону DSP, но не сторону основной памяти.
- Указатель записи FIFO командного процессора не сравнивался ни с чем: 32-байтовый burst мог
попасть в любое место 64-мегабайтного диапазона (переполнение кучи, критично). Проверены
также указатель чтения FIFO, список отображения
CALL_DLи выборка массивов вершин (там же был разыменован нулевой указатель).
Консольные сценарии и команды отладчика (debug.cpp, jdiserver.cpp, gekkodebug.cpp) — 11
- Буфер строки сценария заполнялся без ограничения, а сценарий без перевода строки в конце
уходил за конец файла — переполнение стека (критично). Теперь строку читает
Verify::ScriptLine, а слишком длинная строка пропускается целиком. - Обрезка пустой или состоящей из пробелов строки уходила указателем ниже буфера.
- Сценарий мог бесконечно вызывать сам себя через
script/load. - Незакрытая кавычка бросала исключение, которое никто не ловил (в сценарии и в JDI-сервере) — процесс завершался; теперь ошибка сообщается, а разбор продолжается.
- Предварительная нормализация управляющих символов меняла локальную копию, а не буфер; ограничение интервала профайлера было перевёрнуто.
- Команды регистров индексировали
gpr/fpr/ps1/spr/srнепроверенным аргументом;r r0 << 40сдвигало на непроверенную величину; команды дизассемблера разыменовывали отсутствующее железо послеunload;sprintf64-битного номера вchar def[0x10]/def[8]переполнял стек.
Таблицы символов и текст баннеров (sym.cpp, ui.cpp, uisdl.cpp) — 10
- Чтение RAW-карты копировало строку без ограничения в
char line[0x1000]— переполнение стека (критично); пустые строки и строки-комментарии уходили указателем ниже буфера; строка с адресом без имени символа уходила за конец строки. - Чтение карт форматов CodeWarrior и GCC использовало
sscanf("%s")без ограничения ширины — переполнение стека. - Число функций в
makemap.datиспользовалось для индексации файла без проверки размера; имена функций расширялись вwchar_t[0x100]без ограничения. - Сохранение карты приводило широкий путь к
char*и создавало файл с неверным именем. - Заголовок баннера DVD копировался в фиксированные буферы без ограничения (куча в селекторе и стек при загрузке) в обоих фронтендах; преобразование SJIS→Unicode продолжало читать строку, заканчивающуюся ведущим байтом; имена в списке последних файлов короче трёх символов вызывали переполнение длины.
Как теперь ловятся падения при запуске
Раньше сбой при запуске выглядел снаружи одинаково: процесс просто исчезал. Добавлено два инструмента, чтобы это было видно и воспроизводимо.
--selftest— эмулятор полностью проходит свой запуск (спецификации отладочного интерфейса, настройки, эмулируемое железо, файлы ROM и карт памяти) без создания окна, печатает отчёт по шагам и завершается с числом неудачных шагов в качестве кода возврата. Каждый шаг выполняется в своёмcatch (...), поэтому испорченный файл настроек или битый образ сообщается, а не роняет процесс. Файл из командной строки (если он есть) загружается в рамках проверки.testing/startup_cases.sh— та же проверка на заведомо испорченных данных: файл настроек, оборванный после{, файл с пропущенным двоеточием, слишком длинная строка, испорченный файл настроек по умолчанию и случайные файлы с расширениями.dolи.rvz. Каждый случай должен быть сообщён и не должен ронять процесс (до исправлений случай с.rvzзависал навсегда, а случай с.dolзавершался core dump при выключении).
Для модульных тестов vstest.console <тест.dll> /Blame называет тест, который
упал или завис, вместо запуска без итогов.
Тесты
testing/security_test.cpp входит в обычный набор модульных тестов
(scripts/VS2026/pureikyubu_test.slnx) и покрывает: сами верификаторы (включая
свойство-тест, сравнивающий Verify::Range с арифметикой по переносу на 200 000
случайных входов), чтение сценариев (в том числе свойство-тест на 3000 случайных бинарных
буферов с канарейками вокруг буфера строки), разбор настроек (все испорченные документы,
которые раньше переполняли буфер, зацикливались или исчерпывали стек, должны отклоняться) и
поставляемые спецификации JDI (все одиннадцать разбираются ужесточённым парсером, чтобы
слишком строгое ограничение не сломало отладочный интерфейс).
Что осталось за рамками
- Осознанные упрощения. Список мест, где эмулятор сознательно упрощает
железо, приведён в
testing/Readme.md; ни одно из них не связано с безопасностью памяти и не менялось. - Коду гостя по-прежнему доверена его собственная консольная память. Гость может заставить эмулятор заполнить основную память данными DMA — так и работает железо; проверки гарантируют лишь то, что обмен не выйдет за пределы имеющейся памяти.
- Консоль JDI — не граница между локальными пользователями. Команда консоли по замыслу читает и пишет память гостя и файлы; исправления убирают лишь случаи, когда короткая или испорченная команда калечила эмулятор вместо выполнения сказанного.
- Остались на
assert()некоторые внутренние инварианты графического конвейера («хватает ли байт» вFifoProcessor, переполнение и опустошение FIFO): Debug-сборка всё ещё может прерваться на испорченном списке отображения, но в Release соответствующие чтения остаются внутри проверенного буфера, поэтому небезопасности памяти здесь нет. PITranslatePhysicalAddressигнорирует аргумент длины, поэтому команда отладчика, обходящая структуру у конца RAM, может прочитать за её пределами. Путь доступен только из отладчика.
Как воспроизвести проверку
- Правила верификаторов описаны в
src/verify.h, каждое упражняетсяtesting/security_test.cpp. - Проверки запуска —
--selftestиtesting/startup_cases.sh. - Сборку с AddressSanitizer можно получить, добавив
-DCMAKE_CXX_FLAGS="-fsanitize=address,undefined"в CMake; описанные здесь испорченные артефакты — хороший начальный корпус.