Книги: [Классика] [Базы данных] [Internet/WWW] [Сети] [Программирование] [UNIX] [Windows] [Безопасность] [Графика] [Software Engineering] [ERP-системы] [Hardware]
Структура команд Intel 80x86
(рабочий фрагмент главы из второго издания "Фундаментальных основ хакерства")
Современные ассемблеры, абстрагируясь от несущественных деталей архитектуры компьютера, значительно упрощают процесс своего освоения и написания низкоуровневых программ, скрывая истинный формат машинных команд процессора. Если кто-то считает программирование на ассемблере "прямым" общением с компьютером, то он ошибается. Ассемблер - это язык высокого уровня (пускай и самый низкоуровневый среди остальных высокоуровневых языков).
Программируя непосредственно в машинных кодах, можно создавать чрезвычайно хитрый код, умело сопротивляющийся всяким попыткам его отладить или "прожевать" дизассемблером. Соответственно и изучить такой код будет можно лишь разобравшись в нем "вручную". Знаний одних лишь опкодов машинных команд, подчеркнутых из технической документации от разработчиков процессоров, может оказаться недостаточно. Документация не раскрывает всех тонкостей интерпретации команд микропроцессором и особенностей его поведения.
Наконец, владение машинными командами как нельзя кстати пригодится для написания собственных дизассемблеров, отладчиков и другого хакерского инструментария.
Структура машинных инструкций микропроцессов серии Intel 80x86 в общем случае выглядит как показано на рис. 1.5. Давайте рассмотрим его повнимательнее.
Рис. 1.5 Структура машинных инструкций микропроцессоров серии Intel 80x86
Префиксы
В начале каждой команды может располагаться до четырех префиксов (от лат. pre – впереди и fix – прикреплений) - особых однобайтовых кодов, уточняющих поведение команды, - а может не быть ни одного - тогда команда ведет себя "по умолчанию".
Условно префиксы делятся на четыре группы:
- 1. Префиксы блокировки и повторения:
- 0xF0 LOCK
- 0хF2 REPNZ (только для строковых инструкций).
- 0xF3 REP (только для строковых инструкций).
- 2. Префиксы переопределения сегмента:
- 0х2E CS:
- 0х36 SS:
- 0х3E DS:
- 0х26 ES:
- 0х64 FS:
- 0х65 GS:
- 3. Префикс переопределения размеров операндов:
- 0х66
- 4. Префикс переопределения размеров адреса:
- 0х67
Если используется более одного префикса из той же самой группы, то действие команды не определено и по-своему реализовано на разных типах процессоров.
Префиксы блокировки и повторения
Префикс "LOCK" указывает процессору на необходимость монопольного захвата шины на время выполнения следующей машинной команды. Это позволяет синхронизовать обращения к памяти, предотвращая одновременный доступ нескольких процессоров (или устройств - контроллера DMA например) к одной ячейке памяти.
Префикс LOCK функционирует исключительно со следующими командами: ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD и XCHG, причем один из них операндов должен непременно находится в памяти. Во всех остальных случаях процессор генерирует исключение #UD (INVALID_OPCODE). Это обстоятельство может использоваться разработчикам защитных механизмов, для "завуалированного" вызова обработчика соответствующего исключения. В обработчик же можно поместить "секретную" процедуру, скрыто выполняющую некоторые ответственные действия (например, сверяющую серийный номер). Конечно, зная, что LOCK вызывает исключение, можно найти его обработчик, а найдя - проанализировать, но идентификация структурных исключений не такое простое занятие и оно "по зубам" далеко не всем хакерам.
Замечание: Операционная система Windows 2000 содержит ошибку - при встрече с некорректным LOCK функция GetExceptionCode возвращает не STATUS_ILLEGAL_INSTRUCTION (код C000001D) - как того следовало бы ожидать, а никому не известный код C000001E, определение которого в заголовочных файлах SDK вообще отсутствует! Поэтому, разработчикам защит следует "вручную" обрабатывать такую ситуацию.
Дополнение: Как вы думаете, что произойдет если указать несколько префиксов LOCK перед одной командой? Документация от Intel об этой тонкости умалчивает, ссылаясь на неопределенное поведение, однако, тривиальный эксперимент убеждает, что несколько префиксов LOCK работают точно так, как один.
main()
{
static char s0[]="Hello, World!\n";
static char s1[]="Hello, Sailor!\n";
struct exception_pointers *zzz;
int eip;
hakamaka()
{
printf(s0);
__asm{lock nop}
printf(s1);
}
main()
{
struct exception_pointers *zzz;
int eip;
__try{ hakamaka();}
__except((zzz=_exception_info(),
eip=zzz->ContextRecord->Eip,
zzz->ContextRecord->Eip=++eip,
strcpy(s1,"Hello, Hacker!\n"))?-1:-1){}
}
Листинг 44 Использование префикса LOCK для вызова структурного исключения
Результат дизассемблирования файла, сгенерированного компилятором Microsoft Visual C++ 6.0 в режиме максимальной оптимизации будет выглядеть так (ниже приведен лишь ключевой фрагмент):
hakamaka proc near ; CODE XREF: main+2Dp
push offset aHelloWorld ; "Hello, World!\n"
call _printf
; printf("Hello, World!\n");
lock nop
; При беглом просмотре листинга эту "фишку"
; можно и пропустить. Ну lock nop ну и что? А не
; захочет выполнять процессор эту инструкцию и
; сгенерирует исключение. Но даже если на LOCK NOP
; и обратить внимание – попробуй-ка, разберись какой
; код получит управление! Идентификация обработчиков
; структурных исключений чрезвычайной сложна и уже
; одна она представляет неслабую головоломку,
; посильную только умудренным кодокопателям
push offset aHelloSailor ; "Hello, Sailor!\n"
call _printf
add esp, 8
; Кажется, что данный код выводит строку
; printf("Hello, Sailor!\n"), но это не так!
; Префикс LOCK вызывая обработчик структурного
; исключения, передает ему "бразды правления" и
; не факт, что он "захочет" возвратить управление.
; В данном случае управление все же возвращается
; на команду, следующую за LOCK, но предварительно
; строка "Hello, Sailor!" замещается строкой
; "Hello, Hacker!"
; А в реальных защитах таким образом скрыто
; выполняется расшифровка кода или другие критичные
; к раскрытию манипуляции.
retn
hakamaka endp
Листинг 45 Фрагмент дизассемблированной защиты, основанной на префиксе LOOK
Префиксы повторений
Два префикса повторений REP (синонимы - REPE, REPZ) и REPNE (синоним - REPNZ) повторяют следующую за ними строковую инструкцию ECX [CX] раз, каждый раз уменьшая ECX [CX] на единицу, но не выставляя флаг нуля (ZF) при обращении ECX [CX] в нуль, - вернее, префиксы повторения вообще не воздействуют на флаги.
Префиксы повторений могут функционировать лишь со следующими командами: INS, MOVS, OUTS, LOADS, STOS, CMPS и SCAS, причем, проверка условия продолжения цикла осуществляется только с двумя командами – CMPS и SCAS. Т. е. "REPNE LOADS" работает аналогично "REPE LOADS" и независимо от рода считываемых данных всегда повторяется ECX [CX] раз.
Когда же префиксы REPE или REPNE используется не со строковыми инструкциями, они выполняют следующую за ней команду всего лишь раз, не уменьшая при этом содержимого регистра ECX [CX]! Так, например, на первый взгляд следующий код "REP SHL EAX, CL" должен сдвинуть содержимое EAX на бит влево, однако же на самом деле сдвиг равен CL бит!
бит влево, однако же на самом деле сдвиг равен CL бит!
Префиксы переопределения сегмента
Префиксы переопределения сегмента указывают какой именно сегментной регистр следует использовать для обращения к ячейке памяти. Префиксы переопределения сегмента применимы только к командам явно обращающимся к памяти, - т. е. имеющими адресное поле mod, содержимое которого не равно "11", а в остальных случаях они игнорируются. Так, конструкции "DS:REP" и "CS:PUSH AX" означают то же самое, что и "REP" и "PUSH EAX", хотя начинающие хакеры могут предположить, что префикс "DS" заставляет инструкцию REP искать адрес возврата по указателю "DS:ESP", а "PUSH", соответственно, заталкивать EAX по адресу CS:ESP-4. Такой трюк, действительно, встречается в некоторых защитных механизмах, создатели которых стремились сбить взломщиков с толку.
Другой примем - использование нескольких префиксов с одной командной, например, "DS:FS:FG:CS:MOV AX,[100]". Микропроцессоры Intel и AMD используют для адресации ячейки последний префикс в цепочке (в данном случае CS), однако их разработчики этого не гарантируют (официально, как сказано выше, использование двух и более префиксов одной и той же категории запрещено). Вполне возможно, что следующие модели процессоров будут при встрече такой конструкции выбрасывать исключение или обрабатывать ее по-иному, поэтому, крайне не рекомендуется использовать этот трюк в собственных защитах.
Замечание: дизассемблер IDA Pro вплоть до версии 4.17 включительно не "переваривает" множественные префиксы и вряд ли начнет "переваривать" их в последующих версиях.
В частности, "CS:DS:ES:MOV [EAX], 0x666" IDA Pro дизассемблирует так:
seg000:00000100 db 2Eh, 3Eh
; ; Это ^^^^^^^^^^
; префиксы CS и DS соответственно
seg000:00000100 mov dword ptr es:[eax], 66666666h
; ^^
; IDA Pro извлекла последний префикс в цепочке
; и использовала его для адресации
Листинг 46 Использование множественных префиксов сбивает дизассемблер с толку
Вообще-то, это не создает никаких проблем, ибо в "высшем смысле" IDA Pro поступает правильно, т.к. действительно процессор "съедает" только последний префикс в цепочке, а остальные - игнорирует. Просто, не надо бояться, увидев в коде странные "DB", - правда убедитесь, что это именно префиксы, а не что-то другое!
Префикс переопределения размера операндов
Префикс переопределения размера операндов работает как триггер - если он встречается в 32-разрядном коде, то процессор интерпретирует операнды следующий за ним инструкции как 16-разнядные и, соответственно, наоборот.
Дело в том, что схема адресации, принятая в микропроцессорах серии Intel 80x86 не позволяет отличать 16- и 32- разрядные регистры: EAX кодируется точно как и его "младший брат" - AX. Не стоит кидать камни в разработчиков и обвинять Intel в "кривизне". Во-первых, такая ситуация вызвана требованием обратной совместимости, а во-вторых, в адресных полях битам и так тесно, как селедкам в бочке и ни одной свободной комбинации нулей и единичек для кодирования разрядности операндов там нет. К тому же, 386+ микропроцессоры оптимизированные именно на работу с 32-разрядными данными и 32-разрядным кодом: 16-разрядные операнды в 32-разрядном режиме или 16-разрядный режим сегодня - почтенное достояние старины. Поэтому, использование префикса для выбора разрядности операндов - вполне оправданно.
Префикс переопределения размера работает и с командами, имеющие неявные операнды. Рассмотрим, как работает конструкция "66:RETN". Казалось бы, коль инструкция RETN не имеет операндов, то префикс 66 можно игнорировать. На самом деле RETN работает с неявным операндом и, судя, по распечатке микрокода работает она так:
IF instruction = near return
THEN;
IF OperandSize = 32
THEN
IF top 12 bytes of stack not within stack limits
THEN #SS(0); FI;
EIPPop();
ELSE (* OperandSize = 16 *)
IF top 6 bytes of stack not within stack limits
THEN #SS(0) FI;
tempEIPPop();
tempEIPtempEIP AND 0000FFFFH;
IF tempEIP not within code segment limits
THEN #GP(0); FI;
EIPtempEIP;
FI;
FI
Листинг 47 Псевдокод, раскрывающий алгоритм работы машинной команды RETN
Ага, говорим мы голос Тиггера вдруг вышедшего на свет из чащи чертополоха, - если это не ошибка разработчиков процессора, то классная фишка! Правда, в среде 32-разрядного кода она уже не такая и классная, какой была под MS-DOS, но все по порядку.
В реальном и 16-разрядном режиме указатель команд обрезается до 16 бит, поэтому на первый взгляд 66:RET сработает корректно. Корректно-то, оно корректно, да не совсем - стек ведь оказывается несбалансированным! Из него вместо одного слова RET снимает целых два! Так нетрудно получить и исключение 0xC - исчерпание стека. Если это сделать умышленно, поручив обработку исключения нечто полезное, то такое приложение не удаться отладить ни Soft-Ice, ни даже эмулирующим отладчиком cup386! Остальные отладчики так же либо виснут, либо совершают очень далекий переход по EIP и, попав в "космос", так же виснут, либо снимают одно слово и не вызывают ни исключения ни его обработчика, а если этот обработчик расшифровывает некоторые части программы, то под отладчиком они остаются зашифрованными и все вновь виснет. В общем труба
В 32-разрядном режиме префикс 66h приводит к переходу по адресу (word ptr SS:[ESP]), со снятием одного слова из стека вместо двух. А в Windows NT первые 64 килобайта адресного пространства закрыты для обращения и при попытке исполнения первой же команды в этом регионе тут же вылетит знаменитое "access violation". В принципе, его можно перехватить и поручить обработчику нечто полезное, но - один нюанс - в Windows 9x закрыты лишь первые 4 Кб адресного пространства, а в остальные можно читать, писать и даже исполнять в них свой код (правда, Microsoft категорически не приветствует это делать), поэтому, необходимо удостовериться, что биты 815 адреса возврата равны нулю. Это ограничивает свободу разработчика защитного механизма, кроме того, слишком заметно, - потому, практически никем не используется.
Рассмотрим следующий пример:
func();
main()
{
func();
}
.586
.MODEL FLAT
.CODE
_func proc
DB 66h
RET
_func endp
END
Листинг 48 Пример защиты, основанный на некорректной обработке инструкции возврата
Результат его прогона отладчиком "SED", запущенным под управлением Windows NT должен выглядеть приблизительно так:
F:\.PHCK\src>debugprocess disasm.66ret.exe
SED - Simple Experimental Debuger By KPNC
Exception "STATUS ACCESS VIOLATION" (C0000005) @01028h
CONTINUABLE EXCEPTION ARISE! TERMINATE...
Если префикс переопределения размера стоит перед инструкцией, не имеющей операндов, то он игнорируется. Некоторые дизассемблеры, в том числе и IDA Pro не дизассемблируют такие префиксы, оставляя их в виде "сырого" дампа. Например:"0x66:CLI" будет дизассемблировано так:
seg000:0100 db 66h
seg000:0100 cli
Это не создает серьезных препятствий для анализа, т.к. префикс переопределения размера операндов, в данном случае действительно лишен всякого смысла, но ведь анализирующему требуется еще убедиться, что это именной игнорирующийся префикс переопределения размера, а не нечто иное.
Префикс переопределения размеров адреса
Префикс переопределения размеров адреса очень похож на префикс переопределения размеров операнда, за тем исключением, что указывает на длину адресного поля, а не операнда.
Например, если команду 32-разрядного режима "MOV [0x00000666], AX" записать как "DB 67;MOV [0x0666], AX" ее адресное поле сократится с 4-х до 2-х байт, и, учитывая расход одного байта на префикс, мы выиграем один байт. Правда, значительно проиграем в скорости, ибо код с префиксом исполняется по крайней мере на один такт медленнее - так стоит ли игра свеч?
Другое дело - 16-разрядный режим. Если войти в protect mode, увеличить лимиты размера сегментов и, "забыв" восстановить их, вернуться обратно в real-mode, мы сможем адресовать все 4 гигабайта оперативной памяти любым из сегментных регистров! Правда, это, так называемый, "unreal mode" особой популярности не сыскал, ибо требовал девственно - чистой MS-DOS без EMS-драйверов, скрытно переводящий процессор в защищенный режим, и запускающих DOS на виртуальной машине под строгим надзором драйвера. Сами же EMS-драйвера требовались многим программам и отказ от их использования был в большинстве случаев невозможен.
Ограничения количества префиксов
В документации Intel встречается упоминание об ограничении количества префиксов на команду - официально их должно быть не более четырех. Что ж, это стыкуется с другим ограничением - не более одного префикса из каждой группы. А групп всего четыре - стало быть и префиксов не должно быть более четырех. На самом же деле, как мы уже могли убедиться, использование двух и более префиксов из одной и той же группы вполне допустимо (хотя и не рекомендуется для критически ответственных программ), но если ограничение на это "больше" или количество префиксов может достигать и миллиона?
Ограничение, действительно есть. Поскольку, размер самой длинной команды со всеми четырьмя префиксами равен 16 байтам, то декодер процессора и "заглатывает" код кускам по 16-байт. Если же команда до того "разжиреет" за счет префиксов, что не сместиться ему в "рот", процессор "подавится" и сгенерирует общее исключение защиты.
Разумеется, это обстоятельство можно использовать для преднамеренного вызова обработчика исключения.
Опкод
О поле "опкод" официальная документация не сообщает практически ничего - говориться лишь что оно занимает от одного до двух байт, а при необходимости "оттяпывает" и три бита поля "Reg/Opcode". И все? Не густо. И это при том, что коды машинных инструкций имеют вполне определенную, регулярную структуру, пускай со сложным сводом правил и исключений но все же Хотя бы из чистого любопытства можно провести сравнительный анализ и выявить общие закономерности.
Ну, вот например, взгляните на разновидности инструкции ADD (имеются ввиду разновидности опкода, а не полей адресации):
Таблица 1 Под инструкцией ADD "прячется" отнюдь не одна машинная команда
инструкция
|
опкод
|
инструкция
|
опкод
|
ADD AL, imm8
|
04
|
ADD AL, 66h
|
04 66
|
ADD AX, immD
|
05
|
ADD AX, 666h
|
05 66 06
|
ADD r/m8, r8
|
00
|
ADD DL, CL
|
00 CA
|
ADD r/md,rd
|
01
|
ADD EDX, ECX
|
01 CA
|
ADD r8, r/m8
|
02
|
ADD CL, DL
|
02 CA
|
ADD rd, r/md
|
03
|
ADD ECX, EDX
|
03 CA
|
ADD r/m8,imm8
|
80 /0
|
ADD byte ptr [DX], 66
|
80 00000001 66
|
ADD r/md,immd
|
81 /0
|
ADD dword prt [EDX], 666
|
81 00000001 66060000
|
ADD r/md,imm8
|
83 /0
|
ADD byte ptr [EDX], 66
|
83 00000001 66
|
Если расписать таким же образом и остальные команды, а затем проанализировать их, то обнаружатся некоторые закономерности:
- если размер операнда инструкции - 8 бит, то ее опкод всегда четный, т.е. младший бит сброшен, напротив, если размер операнда соответствует разрядности сегмента, то младший бит равен единице, - значит, младший бит - есть бит размера операндов!
- если инструкция имеет два операнда, ни один из которых не является непосредственным значением, то возникает естественная необходимость уточнить какой из них операнд-источник, а какой - операнд-приемник. (В случае непосредственного значения все вопросы отпадают - операнд, представленный непосредственным значением, может быть только источником). Легко видеть, что порядок операндов задается не только полем адресации, но и самим опкодом. Сравните: ADD r/m8, r8 [опкод – 00] и ADDD r8, r/m8 [опкод - 02]. Похоже, что первый, считая от нуля, бит - есть бит порядка операндов и это предположение подтверждается на остальных инструкциях!
Сказанное, однако, справедливо не для всех команд процессора, но верно для некоторого контингента, который не так уж и мал: ADD, ADC, AND, XOR, OR, SUB, SBB, CMP и др.
Вообще-то, данная функция не входит в библиотеку стандартного Cи, но может быть реализована самостоятельно, путем создания собственного менеджера динамической памяти.
Аннотация
Статьи из книги
[Заказать книгу в магазине "Мистраль"]