Эта статья завершает цикл статьей про формат сегментных NE файлов
для Microsoft Windows 1.x-3x и OS/2 1.x.
Эта часть содержит значительно больше информации, о несостыковках
с официальными документами. Это не только обзор, сколько
попытка открыть глаза на то, что "Не все так просто, как кажется на первый взгляд."
Я специально собираюсь писать обо всём, строго после эксперементов
и фиксаций. Анализ импортов оказался сложнее ожидаемого, но результаты стоили усилий.
Теперь после эксперементов с Microsoft LINK.EXE
и Watcom, я готов объявить и описать,
всё с чем я столкнулся.
Содержание
Таблица импортируемых имен | Обзор
Таблица Импортируемых имён имеет собственное смещение относительно заголовка - e_imptab
.
Соответственно, чтобы узнать абсолютное положение следует действовать по следующей надоевшей логике:
let real_imptab: u16 = e_lfanew + e_imptab;
Импортируемые модули, как строки, это именно названия проектов
или сборник библиотек, имена которых определяет @0
в таблице резидентных имён.
Например, сама библиотека, как файл может быть DOSCA11S.DLL
, но её имя в таблице
резидентных имен по @0
- DOSCALLS
. И это имя, как раз является "именем модуля".
Согласно документации, таблица импортируемых имен это последовательность Pascal
-строк.
То есть строк, которые начинаются байта-длины строки и не имеют \0
символ.
Вот отрывок документации Microsoft сразу же после заголовка "Importing Names Table".
The imported-name table follows the module-reference table. This table
contains the names of modules and procedures that are imported by the
executable file. Each entry is composed of a 1-byte field that
contains the length of the string, followed by any number of
characters. The strings are not null-terminated and are case
sensitive.
Я переведу этот отрывок:
Таблица импортирумеых имен следует за таблицей ссылок на модули.
Эта таблица содержит имена модулей и процедур, которые импортируются
в исполняемый файл. Каждая сущность содержит однобайтовое поле
длины строки и следующее количество байт, кодержащее символы.
Строки не имеют завершающего символа.
А запись в таблице и так все знают, как устроена:
BYTE Length of the name string that follows.
BYTE ASCII text of the name string.
Чудесно! Вы можете подумать, что теперь вы можете прочесть все импорты в исполняемом файле.
Не так быстро, я же не просто так об этом решил писать?
К сожалению, это определение снова не верно. Если вы пробовали читать предыдущую часть
про экспорты - там вскрылось много недосказанных моментов из официальных документов.
Таблица импортируемых имён | Молчать, говорит LINK.EXE
Я специально решил не начинать с таблицы ссылок на модули, потому что
хотел показать неоднозначность описания таблицы импортов.
Если бы я не начал досконально изучать поведение линкера, я бы ни за что в жизни
не смог бы прочесть эту бедную таблицу.
Первая проблема, которая стала каноном - это повреждение или
специальная обработка имен. Они снова только в верхнем регистре.
Логично, что из-за линковки KERNEL
, USER
, GDI
, KEYBOARD
, предоставляющих функции
для экспорта, все импорты этих функций по имени тоже в высоком регистре.
К слову, использование менеджера DOS из OS/2 (т.е. SYSMGR
модуля)
тоже черевато выгоранием глаз из-за только верхнего регистра.
Вторая проблема, которую я очень хотел бы озвучить - это неоднозначное
заполнение таблицы импортов.
Прочтите внимательно оригинальный текст на английском про таблицу импортов
и заметьте, что это _последовательность Pascal
-строк.
А теперь я скажу вам, что это снова неправда.
Компановщик Microsoft следует совершенно другой логике, когда собирает
импорты модулей и импорты процедур по имени. Я моментально это докажу.
Импортируемые модули | Как их найти?
Во-первых не дайте себя обмануть! Часто случается так, что
таблица импортов и таблица резидентных имен стоят рядом.
Их очень легко перепутать, поэтому я сразу же обращаюсь к Sunflower отчёту.
Во-вторых, доказываю вам, что таблица импортов это вообще не последовательность
Pascal-строк. Я приведу пример из жизни (вызову SunFlower для LINK.EXE
).
Мой подопытный это небезызвестный Microsoft LINK.EXE
.
Это начало
|
00 00 04 4D 41 53 4D 03 4E 45 53 00 00 00 00 00 | ._.MASM.NLS.....
00 00 00 00 00 00 00 00 00 00 06 4B 52 4E 45 4C | ..........KERNEL
00 | _
|
Это конец таблицы
А что за пробелы между NLS и KERNEL ?!
Это на самом деле рабочий пример таблицы импортов.
А вот это заполнение между NLS
и KERNEL
стало бы
причиной неправильной интерпретации байт, и вы получили бы в итоге
нечитаемые строки, в лучшем случае.
Чтобы избежать таких проблем - читайте следующий раздел.
Ссылки на модули | Обзор
Главный инструмент в ваших руках, который спасёт вас
от внезапных заполнений - это таблица ссылок на модули.
Она имеет положение, обозначенное в заголовке - e_modtab
и оно так же относительно заголовка. То есть вы поняли:
let real_modtab: u16 = e_lfanew + e_modtab;
Количество записей в таблице ссылок на модули тоже обозначено в заголовке - e_cbmod
.
Следовательно - если количество модулей равно нулю - читать таблицу ненадо.
Это поле, (полагаю), проверяется загрузчиком.
Но, снова вопреки вернегской нотации это не количество байт, а количество
записей.
Таблица ссылок на модули в представлении не нуждается. Это
список WORD
значений, следуемых друг за другом. А значения являются
смещениями названий модулей относительно начала таблицы импортов.
Sunflower подставляет каждому модулю ещё и его порядок в таблице.
Но это опицонально.
|
|
---|---|
1 |
0x0001 |
2 |
0x0006 |
3 |
0x0013 |
Следовательно, чтобы прочесть Pascal-строку,
сначала надо настроиться:
let real_mod_by_index: u16 = e_lfanew + e_imptab + modtab[index - 1];
Теперь вы можете узнать только о модулях, но при этом избежать внезапных
заполнений и нечитаемых небезопасных строк.
Где прячутся названия процедур? | Microsoft LINK.EXE умалчивает
Полагаю, эта часть статьи самая интересная. Ради этого я и шел на такие подвиги.
Так-то во времена, которые я проживаю, весьма просто отправить
исходные документы по формату сегментации в LLM и узнать ответ
в удобной для себя форме. Но, сразу же обрадую вас.
Таким образом у Вас никогда образом не получится узнать правду.
И это не проблема глубокого поиска. Ваш ответ всегда будет неправильным из-за
маленького изъяна в логике Microsoft.
На самом деле есть два способа узнать все импорты в файле.
Путь "в лоб", через статический анализ и примерение маски
0x80
для записей импорта;Путь через полный разбор таблицы релокаций.
Первый путь, я советую избегать, так как о нем известно мало,
и любое решение линкера для вашей "жертвы анализа" заставит вас потерпеть неудачи.
Предлагаю вам второй путь. Он сложнее, но гарантирует вам безопасную
обработку импортируемых записей.
Во втором случае, на самом деле было бы здорово знать, как устроены по-сегментные
релокации. Так как это напрямую зависит от правильно заполненной таблицы.
Советую прочесть часть 2 по разбору NE сегментного формата.
Я вызываю Sunflower для SYSINST2.EXE
, так как уже знаю, что у него есть
импорты как по ординалу, так и по имени.
Type:s |
#Segment:4 |
Offset:2 |
Length:2 |
Flags:2 |
Minimum Allocation:2 |
Characteristics:s |
---|---|---|---|---|---|---|
.CODE |
0x1 |
0x1 |
0x5BCA |
0xD00 |
0x5BCA |
|
.CODE |
0x2 |
0x30 |
0x6388 |
0xD00 |
0x6388 |
|
.CODE |
0x3 |
0x63 |
0x41A4 |
0xD00 |
0x41A4 |
|
.CODE |
0x4 |
0x85 |
0x1FB9 |
0xD00 |
0x1FB9 |
|
.CODE |
0x5 |
0x96 |
0x1CBF |
0xD00 |
0x1CBF |
|
.DATA |
0x6 |
0xA5 |
0x1191 |
0xD41 |
0x3430 |
HAS_MASK PRELOAD |
Это таблица сегментов для этого файла. Нас интересует хотя-бы первый сегмент.
Вы видите, что это сегмент с правилами код-сегмента (поэтому назван как CODE
). И в его характеристиках
нет флага WITHIN_RELOCS
, что означает счастье для нас. У этого сегмента есть личная таблица релокаций.
Вот (урезанная) таблица релокаций для 1 сегмента:
ATP:1 |
RTP:1 |
RTP:s |
IsAdditive:f |
OffsetInSeg:2 |
SegType:2 |
Target:2 |
TargetType:s |
Mod#:2 |
Name:2 |
Ordinal:2 |
Fixup:s |
---|---|---|---|---|---|---|---|---|---|---|---|
0x2 |
0x0 |
[Internal] |
[False] |
0x598A |
1 |
0x0 |
[MOVABLE] |
0x0 |
@0 |
0x0 |
|
0x2 |
0x0 |
[Internal] |
[False] |
0x5B13 |
2 |
0x0 |
[MOVABLE] |
0x0 |
@0 |
0x0 |
|
0x3 |
0x1 |
[Import] |
[False] |
0x57AB |
0 |
0x0 |
[] |
0x2 |
0x0 |
||
0x2 |
0x0 |
[Internal] |
[False] |
0x24C5 |
3 |
0x0 |
[MOVABLE] |
0x0 |
@0 |
0x0 |
|
0x2 |
0x0 |
[Internal] |
[False] |
0x5AB5 |
4 |
0x0 |
[MOVABLE] |
0x0 |
@0 |
0x0 |
|
0x3 |
0x2 |
[Import] |
[False] |
0x2F62 |
0 |
0x0 |
[] |
0x1 |
@0 |
0x8 |
|
0x3 |
0x1 |
[Import] |
[False] |
0x4967 |
0 |
0x0 |
[] |
0x1 |
@8 |
0x0 |
|
0x3 |
0x1 |
[Import] |
[False] |
0x4DB1 |
0 |
0x0 |
[] |
0x2 |
0x0 |
||
0x3 |
0x1 |
[Import] |
[False] |
0x49A9 |
0 |
0x0 |
[] |
0x2 |
@2 |
0x0 |
|
0x2 |
0x0 |
[Internal] |
[False] |
0x4E11 |
5 |
0x0 |
[MOVABLE] |
0x0 |
@0 |
0x0 |
Посмотрите на нее внимательнее. Вы видите импорты по ординалу? А импорты по имени?
В понимании Sunflower, все записи, что стоят с пометкой [Import]
и имеют @0
ординал,
являются импортами по имени.
Главная идея алгоритма заключается в том, что ординал процедуры искать не нужно.
Он уже установлен в таблице релокаций. И это очень срерьёзно облегчает работу.
Остается только две проблемы, которые все хотят решить:
Позиция названия библиотеки;
Позиция названия процедуры/функции.
Не подумайте, что таблица релокаций выглядит точь-в-точь, как
трактуется в документации Microsoft.
Здесь есть поля, которые я лично ввёл, для своего удобства.
Например в канонической таблице нет поля RTP:s
, и вообще нет терминов ATP
и RTP
.
Тем не менее, первое, что вы можете сделать - это оставить всё, что имеет флаг импорта.
Остальное не нужно.
Чтобы найти название модуля:
Расчитайте смещение
e_lfanew + e_modtab + 2 * (relocation.ModuleIndex - 1)
и сохраните;Прочтите
u16
смещение до строки. Пусть это будетdll_name_offset
;Расчитайте смещение
e_lfanew + e_imptab + dll_name_offset
и сохраните;Прочитайте Pascal-строку (помните, что это не Си-строка);
Чтобы найти название процедуры:
Отделите все релокации, которые имеют флаг импорта по имени;
Посчитайте смещение
e_lfanew + e_imptab + relocation.NameOffset
и найдите его;Прочитайте Pascal-строку (один
u8
всей структуры это длина строки) в этом положении;Найдите положение, посчитанное на втором шаге.
Вы можете представить это таким образом:
Ищем импорт Считаем смещение до названия модуля
+----------------+ +----------------+ modtab[i] +----------------+
| Relocation | -> | ModuleIndex (i)| ---------->| Import Table |
| ModuleIndex=_ | | Offset=0x0006 | | "DOSCALLS" |
| Record | | | | Name Entry |
| NameOffset=0x__| +----------------+ +----------------+
+----------------+ |
v Считаем смещение до позиции имени
+----------------+
| Procedure Name |
| "DosWrite" |
+----------------+
Имя: DOSCALLS
Имя: DosWrite (хотелось бы такое видеть).
Для импортов по-ординалу ситуация выглядит ещё проще:
Считаем смещение до названия модуля
+----------------+ +----------------+ +----------------+
| Relocation | -> | Module Table | -> | Import Table |
| ModuleIndex=2 | | Offset=0x0006 | | "DOSCALLS" |
| Record | | | | Name Entry |
| ImpOdrinal=0x10| +----------------+ +----------------+
+----------------+
Имя: DOSCALLS
Ординал: @16
Как вы видите, эта идея требует уже заполненную таблицу релокаций.
Ещё и правильно заполненную таблицу релокаций.
Sunflower в версии 2.0.0.0 вышел в релиз с ошибкой при чтении релокаций, что уже исправлено,
и в версии 3.0.0.0 будет доступно в сборке.
Давайте, снова возьмем SYSINST2.EXE
из установочного носителя OS/2 1.1.
Здесь определённо есть на что посмотреть.
Это таблица импортов для SESMGR
:
Name |
Ordinal |
---|---|
|
@14 |
|
@17 |
|
@8 |
|
@0 |
|
@0 |
Это таблица импортов для MSG
:
Name |
Ordinal |
---|---|
|
@1 |
|
@2 |
А это таблица для QUECALLS
:
Name |
Ordinal |
---|---|
|
@1 |
|
@8 |
Заметьте, что импорты по ординалу так-то совпадают. То есть подтверждается
заведомо истинное утверждение, что разные модули содержат разные процедуры, но по одному порядковому номеру.
При линковке это сохраняется.
Последний вдох, и...
Вы не сможете просто так отличить функции от названий библиотек | Злой трюк от LINK.EXE
Если вы решили "в лоб" посмотреть двоичное представление SYSINST2.EXE
, как я,
вы найдете интересную состыковку с документацией.
Я специально держу ваше внимание на этом экземляре, и для вас специально
выделю отрезок HEX-дампа таблицы импортируемых имен.
00 06 53 45 53 4D 47 52 0D 44 4F 53 53 4D 53 45 | _.SESMGR.DOSSMSE
54 54 49 54 4C 45 08 44 4F 53 43 41 4C 4C 53 08 | TTITLE.DOSCALLS.
4B 42 44 43 41 4C 4C 53 08 56 49 4F 43 41 4C 4C | KBDCALLS.VIOCALL
53 03 4E 4C 53 03 4D 53 47 00 | S.NLS.MSG_
'_' Это не символ, а обозначение начала и конца таблицы.
Если вы посмотрите в дамп повнимательнее, то заметите очень странные дела.
Во-первых, DOSSMSETTITLE
это название процедуры, а не модуля.
Но это уже обговаривалось в документации. Если вы помните, Microsoft пишет, что это последовательность имен модулей и процедур.
Но не обговаривалось следующее;
Если вы попытаетесь прочесть таблицу импортов "в лоб", то реинтерпретируете все символы там как названия модулей, вдобавок вас будет преследовать риск внезапных заполнений.
Если вы прочтете таблицу ссылок на модули и используя её фиксации найдёте адреса на названия модулей - вы проигнорируете бедный
DOSSMSETTITLE
.
Как это исправить - было написано ранее.
Просто это очень похоже на попытку сделать формат сегментации
более компанктным. Нет таблицы импортов как таковой, а есть много указателей
на какие-то области файла, которые, должны вместе образовать таблицу.
Тем не менее, таблицей не пахнет.
Наверное стоит всё-таки это обозначить. Это вроде бы и логично, но мало кто придает этому значение.
Во-вторых, в таблице импортируемых имен только имена.
Это замечательно, так как не противоречит документации.
В-третьих, помните DOSSMPMPRESENT
?! А в дампе вы его видите?
Конечно, вы его не видите! Его же там нет!
Я не с проста писал, что таблицей тут "вовсе не пахнет".
Таблица закончилась давным давно, а записи о импортируемой процедуре DOSSMPMPRESENT
там нет. И найти её без встроенного поиска вообще нереально.
И меня это наталкивает на мысль, что:
Без должной подготовки или более подробных знаниях
о DOS и Win16 (и OS/2) окружении, вы таблицу импортов прочесть не сможете!
А Microsoft LINK.EXE
не строго различает символы модуля и символы процедуры,
а следует уже подготовленным записям о релокациях по каждому сегменту.
Следовательно, если вы не знаете про DOS Session Manager,
то и процедуры его вы от библиотек отличить просто так не сможете.
Обозначусь: "Эти наблюдения можно проделать не используя Ghidra или IDA. Декомпиляция
и дизассемблирование не нужно, и вовсе запрещено законом для некоторых программ."
Собственно, я так и поступал. Sunflower не имеет при себе плагинов для дизассемблирования
и агрессивного анализа рабочей среды, но плагины для анализа структур есть, и я их почти доделал.
За пределелами динамической компановки | Вставки рантайма?
Помните, что проект слинкованный в любом формате для
настольных архитектур и ОС, может содержать как
динамические импорты, так и статические.
Это напрямую зависит то того, "Каким образом вы хотите вызвать внешнюю функцию?"
Используя extern
для функций в языке C/++ или используя инструкции Declare
в
семействе Basic, вы явно говорите компилятору, (и линкеру заодно),
что вы хотите позаимствовать процедуру для своего проекта.
extern "C" int printf(const char *fmt, ...);
Но заимствуете её как-бы вместе со всем телом и составляющими.
То есть при компановке, в файл исполняемого кода будет вставлена printf
с её кодом.
В данном случае функция printf
из (c)stdio
будет помещена в статическую библиотеку
и слинкована чуть позже вместе с вашим проектом. (Я много деталей пропускаю специально).
Пример из жизни: я на Windows 11, используя Visual Studio Code и Open Watcom 1.9 в связке,
хочу собрать программу наC++98
.
Всем известно, что C++ имеет рантайм. Всмысле, не понятие времени выполнения программы,
а специальный слой поддержки, который помогает жить слинкованной программе.
Собирая эту программу, я укажу флаги линкеру, что я хочу собрать её для Win16
# bt - build target <platform>
# l - link as <platform>
# Не смотря на то, что каталог "bin NT" - я могу собрать NE сегментный файл.
# binnt каталог говорит о том, что сами инструменты слинкованы для Windows NT
D:\WATCOM\binnt\> ./wcl.exe "D:\VM\_windows\main.cpp" -bt=windows
Символы, которые статически слинкованы в таком случае - тоже существуют, на самом деле!
Просто вы не сможете явно найти printf
при вызове HEX-дампа,
но вы определённо найдёте её упоминания.
Статические вызовы хранятся уже внутри сегментов и загрузчиком
возможно не проверяются, как ожидалось. Возможно [Internal]
типы
релокаций могут указывать на далекие вызовы статических импортируемых процедур.
Но вот это уже требует дизассемблирования.
А вы в поисках импортов, узнаете, что Watcom вставляет runtime в блок DOS-заглушки,
и в импортируемых процедурах, вы закономерно не увидите printf
,
но увидите много вызовов по ординалам из KERNEL
и KBDCALLS
.
Которые как раз использует printf
, если что.
Итого:
Теперь вы видели, что
Импортируемые модули и процедуры прочесть не так просто, как кажется;
Таблица импортируемых имен это не таблица, а сборище имен в какой-то области файла;
Зная таблицу релокаций для каждого сегмента вы в праве узнать куда больше, чем только импорты;
Можно жить, используя Open Watcom 1.8, в современной среде;
Не зная броду - можно лезть в воду.
Много источников, которые не полностью копируют данные
с официальных документов Microsoft смогут вам помочь в неожиданных непредвиденных ситуациях.
Вы можете посмотреть кодовую базу плагинов Sunflower,
и убедиться в том, что "всё, что я описал здесь соответствует тому, что я сделал в плагине для анализа".
Эти материалы, которые я сделал основываются и на официальных документах
и на забытых статьях из интернета, и на отчетах Sunflower, и на сырых двоичных данных
тоже. Но никогда не прибегал к дизассемблированию.