Самая новая версия текста (а также англоязычная версия) доступна на сайте
https://beginners.re/.
Нужны переводчики!
Возможно, вы захотите мне помочь с переводом этой работы на другие языки,
кроме английского и русского. Просто пришлите мне любой фрагмент переведенного текста (не важно, насколько короткий), и я добавлю его в исходный
код на LaTeX.
Не спрашивайте, нужно ли переводить. Просто делайте хоть что-нибудь. Я уже
перестал отвечать на емейлы вроде “что нужно сделать?”
Также, прочитайте это.
Посмотреть статистику языков можно прямо здесь: https://beginners.re/.
Скорость не важна, потому что это опен-сорсный проект все-таки. Ваше имя будет указано в числе участников проекта. Корейский, китайский и персидский
языки зарезервированы издателями. Английскую и русскую версии я делаю
сам, но английский у меня все еще ужасный, так что я буду очень признателен
за коррективы, итд. Даже мой русский несовершенный, так что я благодарен
за коррективы и русского текста!
Не стесняйтесь писать мне: .
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
xvi
Предисловие
Почему два названия?
В 2014-2018 книга называлась “Reverse Engineering для начинающих”, но я всегда подозревал что это слишком сужает аудиторию.
Люди от инфобезопасности знают о “reverse engineering”, но я от них редко
слышу слово “ассемблер”.
Точно также, термин “reverse engineering” слишком незнакомый для общей
аудитории программистов, но они знают про “ассемблер”.
В июле 2018, для эксперимента, я заменил название на “Assembly Language for
Beginners” и запостил ссылку на сайт Hacker News8 , и книгу приняли, в общем,
хорошо.
Так что, пусть так и будет, у книги будет два названия.
Хотя, я поменял второе название на “Understanding Assembly Language” (“Понимание языка ассемблера”), потому что кто-то уже написал книгу “Assembly
Language for Beginners”. Также, люди говорят что “для начинающих” уже звучит немного саркастично для книги объемом в ~1000 страниц.
Книги отличаются только названием, именем файла (UAL-XX.pdf и RE4B-XX.pdf),
URL-ом и парой первых страниц.
О reverse engineering
У термина «reverse engineering» несколько популярных значений: 1) исследование скомпилированных программ; 2) сканирование трехмерной модели для
последующего копирования; 3) восстановление структуры СУБД.
Настоящая книга связана с первым значением.
Желательные знания перед началом чтения
Очень желательно базовое знание ЯП9 Си. Рекомендуемые материалы: 11.1.3
(стр. 1289).
Упражнения и задачи
…все перемещены на отдельный сайт: http://challenges.re.
Отзывы об этой книге
https://beginners.re/#praise.
8 https://news.ycombinator.com/item?id=17549050
9 Язык
Программирования
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
xvii
Университеты
Эта книга рекомендуется по крайне мере в этих университетах: https://beginners.
re/#uni.
Благодарности
Тем, кто много помогал мне отвечая на массу вопросов: SkullC0DEr.
Тем, кто присылал замечания об ошибках и неточностях: Александр Лысенко,
Федерико Рамондино, Марк Уилсон, Разихова Мейрамгуль Кайратовна, Анатолий Прокофьев, Костя Бегунец, Валентин “netch” Нечаев, Александр Плахов,
Артем Метла, Александр Ястребов, Влад Головкин10 , Евгений Прошин, Александр Мясников, Алексей Третьяков, Олег Песков, Zhu Ruijin, Changmin Heo,
Vitor Vidal, Stijn Crevits, Jean-Gregoire Foulon11 , Ben L., Etienne Khan, Norbert Szetei12 ,
Marc Remy, Michael Hansen, Derk Barten, The Renaissance13 , Hugo Chan, Emil
Mursalimov, Tanner Hoke, Tan90909090@GitHub, Ole Petter Orhagen, Sourav Punoriyar,
Vitor Oliveira, Alexis Ehret, Maxim Shlochiski, Greg Paton, Pierrick Lebourgeois, Abdullah
Alomair..
Просто помогали разными способами: Андрей Зубинский, Arnaud Patard (rtp
на #debian-arm IRC), noshadow на #gcc IRC, Александр Автаев, Mohsen Mostafa
Jokar, Пётр Советов, Миша “tiphareth” Вербицкий.
Переводчикам на китайский язык: Antiy Labs (antiy.cn), Archer.
Переводчику на корейский язык: Byungho Min.
Переводчику на голландский язык: Cedric Sambre (AKA Midas).
Переводчикам на испанский язык: Diego Boy, Luis Alberto Espinosa Calvo, Fernando
Guida, Diogo Mussi, Patricio Galdames, Emiliano Estevarena.
Переводчикам на португальский язык: Thales Stevan de A. Gois, Diogo Mussi, Luiz
Filipe, Primo David Santini.
Переводчикам на итальянский язык: Federico Ramondino14 , Paolo Stivanin15 , twyK,
Fabrizio Bertone, Matteo Sticco, Marco Negro16 , bluepulsar.
Переводчикам на французский язык: Florent Besnard17 , Marc Remy18 , Baudouin
Landais, Téo Dacquet19 , BlueSkeye@GitHub20 .
10 goto-vlad@github
11 https://github.com/pixjuan
12 https://github.com/73696e65
13 https://github.com/TheRenaissance
14 https://github.com/pinkrab
15 https://github.com/paolostivanin
16 https://github.com/Internaut401
17 https://github.com/besnardf
18 https://github.com/mremy
19 https://github.com/T30rix
20 https://github.com/BlueSkeye
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
xviii
Переводчикам на немецкий язык: Dennis Siekmeier21 , Julius Angres22 , Dirk Loser23 ,
Clemens Tamme, Philipp Schweinzer.
Переводчикам на польский язык: Kateryna Rozanova, Aleksander Mistewicz, Wiktoria
Lewicka, Marcin Sokołowski.
Переводчикам на японский язык: shmz@github24 ,4ryuJP@github25 .
Корректорам: Владимир Ботов, Андрей Бражук, Марк “Logxen” Купер, Yuan
Jochen Kang, Mal Malakov, Lewis Porter, Jarle Thorsen, Hong Xie.
Васил Колев26 сделал очень много исправлений и указал на многие ошибки.
И ещё всем тем на github.com кто присылал замечания и исправления.
Было использовано множество пакетов LATEX. Их авторов я также хотел бы поблагодарить.
Жертвователи
Тем, кто поддерживал меня во время написания этой книги:
2 * Oleg Vygovsky (50+100 UAH), Daniel Bilar ($50), James Truscott ($4.5), Luis
Rocha ($63), Joris van de Vis ($127), Richard S Shultz ($20), Jang Minchang ($20),
Shade Atlas (5 AUD), Yao Xiao ($10), Pawel Szczur (40 CHF), Justin Simms ($20),
Shawn the R0ck ($27), Ki Chan Ahn ($50), Triop AB (100 SEK), Ange Albertini (e10+50),
Sergey Lukianov (300 RUR), Ludvig Gislason (200 SEK), Gérard Labadie (e40), Sergey
Volchkov (10 AUD), Vankayala Vigneswararao ($50), Philippe Teuwen ($4), Martin
Haeberli ($10), Victor Cazacov (e5), Tobias Sturzenegger (10 CHF), Sonny Thai ($15),
Bayna AlZaabi ($75), Redfive B.V. (e25), Joona Oskari Heikkilä (e5), Marshall Bishop
($50), Nicolas Werner (e12), Jeremy Brown ($100), Alexandre Borges ($25), Vladimir
Dikovski (e50), Jiarui Hong (100.00 SEK), Jim Di (500 RUR), Tan Vincent ($30), Sri
Harsha Kandrakota (10 AUD), Pillay Harish (10 SGD), Timur Valiev (230 RUR), Carlos
Garcia Prado (e10), Salikov Alexander (500 RUR), Oliver Whitehouse (30 GBP), Katy
Moe ($14), Maxim Dyakonov ($3), Sebastian Aguilera (e20), Hans-Martin Münch (e15),
Jarle Thorsen (100 NOK), Vitaly Osipov ($100), Yuri Romanov (1000 RUR), Aliaksandr
Autayeu (e10), Tudor Azoitei ($40), Z0vsky (e10), Yu Dai ($10), Anonymous ($15),
Vladislav Chelnokov ($25), Nenad Noveljic ($50), Ryan Smith ($25), Andreas Schommer
(e5), Nikolay Gavrilov ($300), Ernesto Bonev Reynoso ($30).
Огромное спасибо каждому!
mini-ЧаВО
Q: Эта книга проще/легче других?
21 https://github.com/DSiekmeier
22 https://github.com/JAngres
23 https://github.com/PolymathMonkey
24 https://github.com/shmz
25 https://github.com/4ryuJP
26 https://vasil.ludost.net/
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
xix
A: Нет, примерно на таком же уровне, как и остальные книги посвященные
этой теме.
Q: Мне страшно начинать читать эту книгу, здесь более 1000 страниц. ”... для
начинающих” в названии звучит слегка саркастично.
A: Основная часть книги это масса разных листингов. И эта книга действительно для начинающих, тут многого (пока) не хватает.
Q: Что необходимо знать перед чтением книги?
A: Желательно иметь базовое понимание Си/Си++.
Q: Должен ли я изучать сразу x86/x64/ARM и MIPS? Это не многовато?
A: Для начала, вы можете читать только о x86/x64, пропуская/пролистывая части о ARM/MIPS.
Q: Возможно ли купить русскую/английскую бумажную книгу?
A: К сожалению нет, пока ни один издатель не заинтересовался в издании русской или английской версии. А пока вы можете распечатать/переплести её в
вашем любимом копи-шопе/копи-центре. https://yurichev.com/news/20200222_
printed_RE4B/.
Q: Существует ли версия epub/mobi?
A: Книга очень сильно завязана на специфические для TeX/LaTeX хаки, поэтому
преобразование в HTML (epub/mobi это набор HTML) легким не будет.
Q: Зачем в наше время нужно изучать язык ассемблера?
A: Если вы не разработчик ОС27 , вам наверное не нужно писать на ассемблере:
современные компиляторы (2010-ые) оптимизируют код намного лучше человека 28 .
К тому же, современные CPU29 это крайне сложные устройства и знание ассемблера вряд ли поможет узнать их внутренности.
Но все-таки остается по крайней мере две области, где знание ассемблера
может хорошо помочь: 1) исследование malware (зловредов) с целью анализа; 2) лучшее понимание вашего скомпилированного кода в процессе отладки.
Таким образом, эта книга предназначена для тех, кто хочет скорее понимать
ассемблер, нежели писать на нем, и вот почему здесь масса примеров, связанных с результатами работы компиляторов.
Q: Я кликнул на ссылку внутри PDF-документа, как теперь вернуться назад?
A: В Adobe Acrobat Reader нажмите сочетание Alt+LeftArrow. В Evince кликните
на “Save. Демо-версия может поставляться с отключенным пунктом меню,
но даже если кракер разрешит этот пункт, будет вызываться пустая функция,
в которой полезного кода нет.
IDA маркирует такие функции именами вроде nullsub_00, nullsub_01, итд.
1.4. Возврат значения
Еще одна простейшая функция это та, что возвращает некоторую константу:
Вот, например:
Листинг 1.8: Код на Си/Си++
int f()
{
return 123;
};
Скомпилируем её.
1.4.1. x86
И вот что делает оптимизирующий GCC:
Листинг 1.9: Оптимизирующий GCC/MSVC (вывод на ассемблере)
f:
mov
ret
eax, 123
Здесь только две инструкции. Первая помещает значение 123 в регистр EAX,
который используется для передачи возвращаемых значений. Вторая это RET,
которая возвращает управление в вызывающую функцию.
Вызывающая функция возьмет результат из регистра EAX.
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
11
1.4.2. ARM
А что насчет ARM?
Листинг 1.10: Оптимизирующий Keil 6/2013 (Режим ARM) ASM Output
f
PROC
MOV
BX
ENDP
r0,#0x7b ; 123
lr
ARM использует регистр R0 для возврата значений, так что здесь 123 помещается в R0.
Нужно отметить, что название инструкции MOV в x86 и ARM сбивает с толку.
На самом деле, данные не перемещаются, а скорее копируются.
1.4.3. MIPS
Вывод на ассемблере в GCC показывает регистры по номерам:
Листинг 1.11: Оптимизирующий GCC 4.4.5 (вывод на ассемблере)
j
li
$31
$2,123
# 0x7b
…а IDA— по псевдоименам:
Листинг 1.12: Оптимизирующий GCC 4.4.5 (IDA)
jr
li
$ra
$v0, 0x7B
Так что регистр $2 (или $V0) используется для возврата значений. LI это “Load
Immediate”, и это эквивалент MOV в MIPS.
Другая инструкция это инструкция перехода (J или JR), которая возвращает
управление в вызывающую ф-цию.
Но почему инструкция загрузки (LI) и инструкция перехода (J или JR) поменяны
местами? Это артефакт RISC и называется он “branch delay slot”.
На самом деле, нам не нужно вникать в эти детали. Нужно просто запомнить: в
MIPS инструкция после инструкции перехода исполняется перед инструкцией
перехода.
Таким образом, инструкция перехода всегда поменяна местами с той, которая
должна быть исполнена перед ней.
1.4.4. На практике
На практике крайне часто встречаются ф-ции, которые возвращают 1 (true)
или 0 (false).
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
12
Самые маленькие утилиты UNIX, /bin/true и /bin/false возвращают 0 и 1 соответственно, как код возврата (ноль как код возврата обычно означает успех, не
ноль означает ошибку).
1.5. Hello, world!
Продолжим, используя знаменитый пример из книги [Брайан Керниган, Деннис Ритчи, Язык программирования Си, второе издание, (1988, 2009)]:
Листинг 1.13: код на Си/Си++
#include
int main()
{
printf("hello, world\n");
return 0;
}
1.5.1. x86
MSVC
Компилируем в MSVC 2010:
cl 1.cpp /Fa1.asm
(Ключ /Fa означает сгенерировать листинг на ассемблере)
Листинг 1.14: MSVC 2010
CONST
SEGMENT
$SG3830 DB
'hello, world', 0AH, 00H
CONST
ENDS
PUBLIC _main
EXTRN
_printf:PROC
; Function compile flags: /Odtp
_TEXT
SEGMENT
_main
PROC
push
ebp
mov
ebp, esp
push
OFFSET $SG3830
call
_printf
add
esp, 4
xor
eax, eax
pop
ebp
ret
0
_main
ENDP
_TEXT
ENDS
MSVC выдает листинги в синтаксисе Intel. Разница между синтаксисом Intel и
AT&T будет рассмотрена немного позже:
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
13
Компилятор сгенерировал файл 1.obj, который впоследствии будет слинкован линкером в 1.exe. В нашем случае этот файл состоит из двух сегментов:
CONST (для данных-констант) и _TEXT (для кода).
Строка hello, world в Си/Си++имеет тип const char[][Bjarne Stroustrup, The
C++ Programming Language, 4th Edition, (2013)p176, 7.3.2], однако не имеет
имени. Но компилятору нужно как-то с ней работать, поэтому он дает ей внутреннее имя $SG3830.
Поэтому пример можно было бы переписать вот так:
#include
const char $SG3830[]="hello, world\n";
int main()
{
printf($SG3830);
return 0;
}
Вернемся к листингу на ассемблере. Как видно, строка заканчивается нулевым
байтом — это требования стандарта Си/Си++для строк. Больше о строках в
Си/Си++: 5.4.1 (стр. 917).
В сегменте кода _TEXT находится пока только одна функция: main(). Функция
main(), как и практически все функции, начинается с пролога и заканчивается
эпилогом 16 .
Далее следует вызов функции printf(): CALL _printf. Перед этим вызовом
адрес строки (или указатель на неё) с нашим приветствием (“Hello, world!”)
при помощи инструкции PUSH помещается в стек.
После того, как функция printf() возвращает управление в функцию main(),
адрес строки (или указатель на неё) всё ещё лежит в стеке. Так как он больше
не нужен, то указатель стека (регистр ESP) корректируется.
ADD ESP, 4 означает прибавить 4 к значению в регистре ESP.
Почему 4? Так как это 32-битный код, для передачи адреса нужно 4 байта. В
x64-коде это 8 байт.
ADD ESP, 4 эквивалентно POP регистр, но без использования какого-либо регистра17 .
Некоторые компиляторы, например, Intel C++ Compiler, в этой же ситуации могут вместо ADD сгенерировать POP ECX (подобное можно встретить, например,
в коде Oracle RDBMS, им скомпилированном), что почти то же самое, только
портится значение в регистре ECX. Возможно, компилятор применяет POP ECX,
потому что эта инструкция короче (1 байт у POP против 3 у ADD).
Вот пример использования POP вместо ADD из Oracle RDBMS:
16 Об
этом смотрите подробнее в разделе о прологе и эпилоге функции (1.6 (стр. 40)).
процессора, впрочем, модифицируются
17 Флаги
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
Впрочем, MSVC был замечен в подобном же.
Листинг 1.16: MineSweeper из Windows 7 32-bit
.text:0102106F
.text:01021071
.text:01021077
push
call
pop
0
ds:time
ecx
После вызова printf() в оригинальном коде на Си/Си++указано return 0 —
вернуть 0 в качестве результата функции main().
В сгенерированном коде это обеспечивается инструкцией
XOR EAX, EAX.
XOR, как легко догадаться — «исключающее ИЛИ»18 , но компиляторы часто
используют его вместо простого MOV EAX, 0 — снова потому, что опкод короче
(2 байта у XOR против 5 у MOV).
Некоторые компиляторы генерируют SUB EAX, EAX, что значит отнять значение в EAX от значения в EAX, что в любом случае даст 0 в результате.
Самая последняя инструкция RET возвращает управление в вызывающую функцию. Обычно это код Си/Си++CRT19 , который, в свою очередь, вернёт управление в ОС.
GCC
Теперь скомпилируем то же самое компилятором GCC 4.4.1 в Linux: gcc 1.c
-o 1. Затем при помощи IDA посмотрим как скомпилировалась функция main().
IDA, как и MSVC, показывает код в синтаксисе Intel20 .
Листинг 1.17: код в IDA
main
Runtime library
также можем заставить GCC генерировать листинги в этом формате при помощи ключей
-S -masm=intel.
20 Мы
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
15
mov
leave
retn
endp
main
eax, 0
Почти то же самое. Адрес строки hello, world, лежащей в сегменте данных,
вначале сохраняется в EAX, затем записывается в стек. А ещё в прологе функции мы видим AND ESP, 0FFFFFFF0h — эта инструкция выравнивает значение
в ESP по 16-байтной границе, делая все значения в стеке также выровненными по этой границе (процессор более эффективно работает с переменными,
расположенными в памяти по адресам кратным 4 или 16).
SUB ESP, 10h выделяет в стеке 16 байт. Хотя, как будет видно далее, здесь
достаточно только 4.
Это происходит потому, что количество выделяемого места в локальном стеке
тоже выровнено по 16-байтной границе.
Адрес строки (или указатель на строку) затем записывается прямо в стек без
помощи инструкции PUSH. var_10 одновременно и локальная переменная и аргумент для printf(). Подробнее об этом будет ниже.
Затем вызывается printf().
В отличие от MSVC, GCC в компиляции без включенной оптимизации генерирует MOV EAX, 0 вместо более короткого опкода.
Последняя инструкция LEAVE — это аналог команд MOV ESP, EBP и POP EBP —
то есть возврат указателя стека и регистра EBP в первоначальное состояние.
Это необходимо, т.к. в начале функции мы модифицировали регистры ESP и
EBP
(при помощи MOV EBP, ESP / AND ESP, …).
GCC: Синтаксис AT&T
Попробуем посмотреть, как выглядит то же самое в синтаксисе AT&T языка
ассемблера. Этот синтаксис больше распространен в UNIX-мире.
Листинг 1.18: компилируем в GCC 4.7.3
gcc −S 1_1.c
Здесь много макросов (начинающихся с точки). Они нам пока не интересны.
Пока что, ради упрощения, мы можем их игнорировать (кроме макроса .string,
при помощи которого кодируется последовательность символов, оканчивающихся нулем — такие же строки как в Си). И тогда получится следующее 21 :
Листинг 1.20: GCC 4.7.3
.LC0:
.string "hello, world\n"
main:
pushl
movl
andl
subl
movl
call
movl
leave
ret
Основные отличия синтаксиса Intel и AT&T следующие:
• Операнды записываются наоборот.
В Intel-синтаксисе:
.
В AT&T-синтаксисе:
.
21 Кстати, для уменьшения генерации «лишних» макросов, можно использовать такой ключ GCC:
-fno-asynchronous-unwind-tables
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
17
Чтобы легче понимать разницу, можно запомнить следующее: когда вы
работаете с синтаксисом Intel — можете в уме ставить знак равенства
(=) между операндами, а когда с синтаксисом AT&T — мысленно ставьте
стрелку направо (→) 22 .
• AT&T: Перед именами регистров ставится символ процента (%), а перед
числами символ доллара ($). Вместо квадратных скобок используются круглые.
• AT&T: К каждой инструкции добавляется специальный символ, определяющий тип данных:
– q — quad (64 бита)
– l — long (32 бита)
– w — word (16 бит)
– b — byte (8 бит)
Возвращаясь к результату компиляции: он идентичен тому, который мы посмотрели в IDA. Одна мелочь: 0FFFFFFF0h записывается как $-16. Это то же
самое: 16 в десятичной системе это 0x10 в шестнадцатеричной. -0x10 будет
как раз 0xFFFFFFF0 (в рамках 32-битных чисел).
Возвращаемый результат устанавливается в 0 обычной инструкцией MOV, а не
XOR. MOV просто загружает значение в регистр. Её название не очень удачное
(данные не перемещаются, а копируются). В других архитектурах подобная
инструкция обычно носит название «LOAD» или «STORE» или что-то в этом роде.
Коррекция (патчинг) строки (Win32)
Мы можем легко найти строку “hello, world” в исполняемом файле при помощи
Hiew:
Рис. 1.1: Hiew
Можем перевести наше сообщение на испанский язык:
22 Кстати, в некоторых стандартных функциях библиотеки Си (например, memcpy(), strcpy()) также применяется расстановка аргументов как в синтаксисе Intel: вначале указатель в памяти на
блок назначения, затем указатель на блок-источник.
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
18
Рис. 1.2: Hiew
Испанский текст на 1 байт короче английского, так что добавляем в конце байт
0x0A (\n) и нулевой байт.
Работает.
Что если мы хотим вставить более длинное сообщение? После оригинального
текста на английском есть какие-то нулевые байты. Трудно сказать, можно ли
их перезаписывать: они могут где-то использоваться в CRT-коде, а может и нет.
Так или иначе, вы можете их перезаписывать, только если вы действительно
знаете, что делаете.
Коррекция строки (Linux x64)
Попробуем пропатчить исполняемый файл для Linux x64 используя rada.re:
Листинг 1.21: Сессия в rada.re
dennis@bigbox ~/tmp % gcc hw.c
dennis@bigbox ~/tmp % radare2 a.out
−− SHALL WE PLAY A GAME?
[0x00400430]> / hello
Searching 5 bytes from 0x00400000 to 0x00601040: 68 65 6c 6c 6f
Searching 5 bytes in [0x400000−0x601040]
hits: 1
0x004005c4 hit0_0 .HHhello, world;0.
[0x00400430]> s 0x004005c4
[0x004005c4]> px
− offset −
0 1
0x004005c4 6865
0x004005d4 011b
0x004005e4 7c00
0x004005f4 a400
0x00400604 0c01
0x00400614 0178
0x00400624 1c00
0x00400634 0000
[0x004005c4]> oo+
File a.out reopened in read−write mode
[0x004005c4]> w hola, mundo\x00
[0x004005c4]> q
dennis@bigbox ~/tmp % ./a.out
hola, mundo
Что я здесь делаю: ищу строку «hello» используя команду /, я затем я выставляю курсор (seek в терминах rada.re) на этот адрес. Потом я хочу удостовериться, что это действительно нужное место: px выводит байты по этому адресу.
oo+ переключает rada.re в режим чтения-записи. w записывает ASCII-строку на
месте курсора (seek). Нужно отметить \00 в конце — это нулевой байт. q заканчивает работу.
Это реальная история взлома ПО
Некое ПО обрабатывало изображения, и когда не было зарегистрированно, оно
добавляло водяные знаки, вроде “This image was processed by evaluation version
of [software name]”, поперек картинки. Мы попробовали от балды: нашли эту
строку в исполняемом файле и забили пробелами. Водяные знаки пропали. Технически, они продолжали добавляться. При помощи соответствующих ф-ций
Qt, надпись продолжала добавляться в итоговое изображение. Но добавление
пробелов не меняло само изображение...
Локализация ПО во времена MS-DOS
Описанный способ был очень распространен для перевода ПО под MS-DOS на
русский язык в 1980-е и 1990-е. Эта техника доступна даже для тех, кто вовсе не разбирается в машинном коде и форматах исполняемых файлов. Новая
строка не должна быть длиннее старой, потому что имеется риск затереть
какую-то другую переменную или код. Русские слова и предложения обычно
немного длиннее английских, так что локализованное ПО содержало массу
странных акронимов и труднопонятных сокращений.
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
20
Рис. 1.3: Русифицированный Norton Commander 5.51
Вероятно, так было и с другими языками в других странах.
В строках в Delphi, длина строки также должна быть поправлена, если нужно.
1.5.2. x86-64
MSVC: x86-64
Попробуем также 64-битный MSVC:
Листинг 1.22: MSVC 2012 x64
$SG2989 DB
main
В x86-64 все регистры были расширены до 64-х бит и теперь имеют префикс R-.
Чтобы поменьше задействовать стек (иными словами, поменьше обращаться к
кэшу и внешней памяти), уже давно имелся довольно популярный метод передачи аргументов функции через регистры (fastcall) 6.1.3 (стр. 958). Т.е. часть
аргументов функции передается через регистры и часть —через стек. В Win64
первые 4 аргумента функции передаются через регистры RCX, RDX, R8, R9. Это
мы здесь и видим: указатель на строку в printf() теперь передается не через
стек, а через регистр RCX. Указатели теперь 64-битные, так что они передаются через 64-битные части регистров (имеющие префикс R-). Но для обратной
совместимости можно обращаться и к нижним 32 битам регистров используя
префикс E-. Вот как выглядит регистр RAX/EAX/AX/AL в x86-64:
7-й
6-й
Номер байта:
5-й 4-й 3-й 2-й 1-й 0-й
RAXx64
EAX
AX
AH AL
Функция main() возвращает значение типа int, который в Си/Си++, надо полагать, для лучшей совместимости и переносимости, оставили 32-битным. Вот
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
21
почему в конце функции main() обнуляется не RAX, а EAX, т.е. 32-битная часть
регистра. Также видно, что 40 байт выделяются в локальном стеке. Это «shadow
space» которое мы будем рассматривать позже: 1.14.2 (стр. 135).
GCC: x86-64
Попробуем GCC в 64-битном Linux:
Листинг 1.23: GCC 4.4.6 x64
.string "hello,
main:
sub
mov
xor
call
xor
add
ret
В Linux, *BSD и Mac OS X для x86-64 также принят способ передачи аргументов
функции через регистры [Michael Matz, Jan Hubicka, Andreas Jaeger, Mark Mitchell,
System V Application Binary Interface. AMD64 Architecture Processor Supplement,
(2013)] 23 .
6 первых аргументов передаются через регистры RDI, RSI, RDX, RCX, R8, R9, а
остальные — через стек.
Так что указатель на строку передается через EDI (32-битную часть регистра).
Но почему не через 64-битную часть, RDI?
Важно запомнить, что в 64-битном режиме все инструкции MOV, записывающие
что-либо в младшую 32-битную часть регистра, обнуляют старшие 32-бита (это
можно найти в документации от Intel: 11.1.4 (стр. 1289)). То есть, инструкция
MOV EAX, 011223344h корректно запишет это значение в RAX, старшие биты
сбросятся в ноль.
Если посмотреть в IDA скомпилированный объектный файл (.o), увидим также
опкоды всех инструкций 24 :
Листинг 1.24: GCC 4.4.6 x64
.text:00000000004004D0
.text:00000000004004D0
.text:00000000004004D4
world\n"
.text:00000000004004D9
.text:00000000004004DB
.text:00000000004004E0
.text:00000000004004E2
.text:00000000004004E6
.text:00000000004004E6
48 83 EC 08
BF E8 05 40 00
31
E8
31
48
C3
C0
D8 FE FF FF
C0
83 C4 08
main
sub
mov
xor
call
xor
add
retn
main
proc near
rsp, 8
edi, offset format ; "hello,
eax, eax
_printf
eax, eax
rsp, 8
endp
23 Также доступно здесь: https://software.intel.com/sites/default/files/article/402129/
mpx-linux64-abi.pdf
24 Это нужно задать в Options → Disassembly → Number of opcode bytes
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
22
Как видно, инструкция, записывающая в EDI по адресу 0x4004D4, занимает 5
байт. Та же инструкция, записывающая 64-битное значение в RDI, занимает 7
байт. Возможно, GCC решил немного сэкономить. К тому же, вероятно, он уверен, что сегмент данных, где хранится строка, никогда не будет расположен
в адресах выше 4GiB.
Здесь мы также видим обнуление регистра EAX перед вызовом printf(). Это
делается потому что по упомянутому выше стандарту передачи аргументов
в *NIX для x86-64 в EAX передается количество задействованных векторных
регистров.
Коррекция (патчинг) адреса (Win64)
Если наш пример скомпилирован в MSVC 2013 используя опцию /MD (подразумевая меньший исполняемый файл из-за внешнего связывания файла MSVCR*.DLL),
ф-ция main() идет первой, и её легко найти:
Рис. 1.4: Hiew
В качестве эксперимента, мы можем инкрементировать адрес на 1:
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
23
Рис. 1.5: Hiew
Hiew показывает строку «ello, world». И когда мы запускаем исполняемый файл,
именно эта строка и выводится.
Выбор другой строки из исполняемого файла (Linux x64)
Исполняемый файл, если скомпилировать используя GCC 5.4.0 на Linux x64,
имеет множество других строк: в основном, это имена импортированных фций и имена библиотек.
Запускаю objdump, чтобы посмотреть содержимое всех секций скомпилированного файла:
% objdump −s a.out
a.out:
Не проблема передать адрес текстовой строки «/lib64/ld-linux-x86-64.so.2» в
вызов printf():
#include
int main()
{
printf(0x400238);
return 0;
}
Трудно поверить, но этот код печатает вышеуказанную строку.
Измените адрес на 0x400260, и напечатается строка «GNU». Адрес точен для
конкретной версии GCC, GNU toolset, итд. На вашей системе, исполняемый
файл может быть немного другой, и все адреса тоже будут другими. Также,
добавление/удаление кода из исходных кодов, скорее всего, сдвинет все адреса вперед или назад.
1.5.3. ARM
Для экспериментов с процессором ARM было использовано несколько компиляторов:
• Популярный в embedded-среде Keil Release 6/2013.
• Apple Xcode 4.6.3 с компилятором LLVM-GCC 4.2
25
.
• GCC 4.9 (Linaro) (для ARM64), доступный в виде исполняемого файла для
win32 на http://www.linaro.org/projects/armv8/.
Везде в этой книге, если не указано иное, идет речь о 32-битном ARM (включая
режимы Thumb и Thumb-2). Когда речь идет о 64-битном ARM, он называется
здесь ARM64.
Неоптимизирующий Keil 6/2013 (Режим ARM)
Для начала скомпилируем наш пример в Keil:
armcc.exe −−arm −−c90 −O0 1.c
Компилятор armcc генерирует листинг на ассемблере в формате Intel. Этот листинг содержит некоторые высокоуровневые макросы, связанные с ARM 26 , а
25 Это действительно так: Apple Xcode 4.6.3 использует опен-сорсный GCC как компилятор переднего плана и кодогенератор LLVM
26 например, он показывает инструкции PUSH/POP, отсутствующие в режиме ARM
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
25
нам важнее увидеть инструкции «как есть», так что посмотрим скомпилированный результат в IDA.
Листинг 1.25: Неоптимизирующий Keil 6/2013 (Режим ARM) IDA
.text:00000000
.text:00000000
.text:00000004
.text:00000008
.text:0000000C
.text:00000010
В вышеприведённом примере можно легко увидеть, что каждая инструкция
имеет размер 4 байта. Действительно, ведь мы же компилировали наш код
для режима ARM, а не Thumb.
Самая первая инструкция, STMFD SP!, {R4,LR}27 , работает как инструкция PUSH
в x86, записывая значения двух регистров (R4 и LR) в стек. Действительно, в
выдаваемом листинге на ассемблере компилятор armcc для упрощения указывает здесь инструкцию PUSH {r4,lr}. Но это не совсем точно, инструкция
PUSH доступна только в режиме Thumb, поэтому, во избежание путаницы, я
предложил работать в IDA.
Итак, эта инструкция уменьшает SP29 , чтобы он указывал на место в стеке,
свободное для записи новых значений, затем записывает значения регистров
R4 и LR по адресу в памяти, на который указывает измененный регистр SP.
Эта инструкция, как и инструкция PUSH в режиме Thumb, может сохранить
в стеке одновременно несколько значений регистров, что может быть очень
удобно. Кстати, такого в x86 нет. Также следует заметить, что STMFD — генерализация инструкции PUSH (то есть расширяет её возможности), потому что
может работать с любым регистром, а не только с SP. Другими словами, STMFD
можно использовать для записи набора регистров в указанном месте памяти.
Инструкция ADR R0, aHelloWorld прибавляет или отнимает значение регистра
PC30 к смещению, где хранится строка hello, world. Причем здесь PC, можно
спросить? Притом, что это так называемый «адресно-независимый код» 31 . Он
предназначен для исполнения будучи не привязанным к каким-либо адресам
в памяти. Другими словами, это относительная от PC адресация. В опкоде инструкции ADR указывается разница между адресом этой инструкции и местом,
где хранится строка. Эта разница всегда будет постоянной, вне зависимости
от того, куда был загружен ОС наш код. Поэтому всё, что нужно — это прибавить адрес текущей инструкции (из PC), чтобы получить текущий абсолютный
адрес нашей Си-строки.
27 STMFD28
29 указатель
стека. SP/ESP/RSP в x86/x64. SP в ARM.
Counter. IP/EIP/RIP в x86/64. PC в ARM.
31 Читайте больше об этом в соответствующем разделе (6.4.1 (стр. 975))
30 Program
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
26
Инструкция BL __2printf32 вызывает функцию printf(). Работа этой инструкции состоит из двух фаз:
• записать адрес после инструкции BL (0xC) в регистр LR;
• передать управление в printf(), записав адрес этой функции в регистр
PC.
Ведь когда функция printf() закончит работу, нужно знать, куда вернуть
управление, поэтому закончив работу, всякая функция передает управление
по адресу, записанному в регистре LR.
В этом разница между «чистыми» RISC-процессорами вроде ARM и CISC33 -процессорами
как x86, где адрес возврата обычно записывается в стек (1.9 (стр. 41)).
Кстати, 32-битный абсолютный адрес (либо смещение) невозможно закодировать в 32-битной инструкции BL, в ней есть место только для 24-х бит. Поскольку все инструкции в режиме ARM имеют длину 4 байта (32 бита) и инструкции
могут находится только по адресам кратным 4, то последние 2 бита (всегда
нулевых) можно не кодировать. В итоге имеем 26 бит, при помощи которых
можно закодировать current_P C ± ≈ 32M .
Следующая инструкция MOV R0, #034 просто записывает 0 в регистр R0. Ведь
наша Си-функция возвращает 0, а возвращаемое значение всякая функция
оставляет в R0.
Последняя инструкция LDMFD SP!, R4,PC35 . Она загружает из стека (или любого другого места в памяти) значения для сохранения их в R4 и PC, увеличивая
указатель стека SP. Здесь она работает как аналог POP.
N.B. Самая первая инструкция STMFD сохранила в стеке R4 и LR, а восстанавливаются во время исполнения LDMFD регистры R4 и PC.
Как мы уже знаем, в регистре LR обычно сохраняется адрес места, куда нужно
всякой функции вернуть управление. Самая первая инструкция сохраняет это
значение в стеке, потому что наша функция main() позже будет сама пользоваться этим регистром в момент вызова printf(). А затем, в конце функции,
это значение можно сразу записать прямо в PC, таким образом, передав управление туда, откуда была вызвана наша функция.
Так как функция main() обычно самая главная в Си/Си++, управление будет
возвращено в загрузчик ОС, либо куда-то в CRT или что-то в этом роде.
Всё это позволяет избавиться от инструкции BX LR в самом конце функции.
DCB — директива ассемблера, описывающая массивы байт или ASCII-строк, аналог директивы DB в x86-ассемблере.
Неоптимизирующий Keil 6/2013 (Режим Thumb)
Скомпилируем тот же пример в Keil для режима Thumb:
32 Branch
with Link
Instruction Set Computing
34 Означает MOVe
35 LDMFD36 — это инструкция, обратная STMFD
33 Complex
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
27
armcc.exe −−thumb −−c90 −O0 1.c
Получим (в IDA):
Листинг 1.26: Неоптимизирующий Keil 6/2013 (Режим Thumb) + IDA
.text:00000000
.text:00000000
.text:00000002
.text:00000004
.text:00000008
.text:0000000A
Сразу бросаются в глаза двухбайтные (16-битные) опкоды — это, как уже было
отмечено, Thumb.
Кроме инструкции BL. Но на самом деле она состоит из двух 16-битных инструкций. Это потому что в одном 16-битном опкоде слишком мало места для
задания смещения, по которому находится функция printf(). Так что первая
16-битная инструкция загружает старшие 10 бит смещения, а вторая — младшие 11 бит смещения.
Как уже было упомянуто, все инструкции в Thumb-режиме имеют длину 2 байта (или 16 бит). Поэтому невозможна такая ситуация, когда Thumb-инструкция
начинается по нечетному адресу.
Учитывая сказанное, последний бит адреса можно не кодировать. Таким образом, в Thumb-инструкции BL можно закодировать адрес current_P C ± ≈ 2M .
Остальные инструкции в функции (PUSH и POP) здесь работают почти так же,
как и описанные STMFD/LDMFD, только регистр SP здесь не указывается явно.
ADR работает так же, как и в предыдущем примере. MOVS записывает 0 в регистр R0 для возврата нуля.
Оптимизирующий Xcode 4.6.3 (LLVM) (Режим ARM)
Xcode 4.6.3 без включенной оптимизации выдает слишком много лишнего кода, поэтому включим оптимизацию компилятора (ключ -O3), потому что там
меньше инструкций.
Листинг 1.27: Оптимизирующий Xcode 4.6.3 (LLVM) (Режим ARM)
__text:000028C4
__text:000028C4
__text:000028C8
__text:000028CC
__text:000028D0
__text:000028D4
__text:000028D8
__text:000028DC
__text:000028E0
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
28
__cstring:00003F62 48 65 6C 6C+aHelloWorld_0
DCB "Hello world!",0
Инструкции STMFD и LDMFD нам уже знакомы.
Инструкция MOV просто записывает число 0x1686 в регистр R0 — это смещение,
указывающее на строку «Hello world!».
Регистр R7 (по стандарту, принятому в [iOS ABI Function Call Guide, (2010)]37 )
это frame pointer, о нем будет рассказано позже.
Инструкция MOVT R0, #0 (MOVe Top) записывает 0 в старшие 16 бит регистра.
Дело в том, что обычная инструкция MOV в режиме ARM может записывать
какое-либо значение только в младшие 16 бит регистра, ведь в ней нельзя
закодировать больше. Помните, что в режиме ARM опкоды всех инструкций
ограничены длиной в 32 бита. Конечно, это ограничение не касается перемещений данных между регистрами.
Поэтому для записи в старшие биты (с 16-го по 31-й включительно) существует
дополнительная команда MOVT. Впрочем, здесь её использование избыточно,
потому чтоинструкция MOV R0, #0x1686 выше и так обнулила старшую часть
регистра. Возможно, это недочет компилятора.
Инструкция ADD R0, PC, R0 прибавляет PC к R0 для вычисления действительного адреса строки «Hello world!». Как нам уже известно, это «адресно-независимый
код», поэтому такая корректива необходима.
Инструкция BL вызывает puts() вместо printf().
LLVM заменил вызов printf() на puts(). Действительно, printf() с одним аргументом это почти аналог puts().
Почти, если принять условие, что в строке не будет управляющих символов
printf(), начинающихся со знака процента. Тогда эффект от работы этих двух
функций будет разным 38 .
Зачем компилятор заменил один вызов на другой? Наверное потому что puts()
работает быстрее 39 . Видимо потому что puts() проталкивает символы в stdout
не сравнивая каждый со знаком процента.
Далее уже знакомая инструкция MOV R0, #0, служащая для установки в 0 возвращаемого значения функции.
Оптимизирующий Xcode 4.6.3 (LLVM) (Режим Thumb-2)
По умолчанию Xcode 4.6.3 генерирует код для режима Thumb-2 примерно в
такой манере:
37 Также
доступно здесь: http://developer.apple.com/library/ios/documentation/Xcode/
Conceptual/iPhoneOSABIReference/iPhoneOSABIReference.pdf
38 Также нужно заметить, что puts() не требует символа перевода строки ‘\n’ в конце строки,
поэтому его здесь нет.
39 ciselant.de/projects/gcc_printf/gcc_printf.html
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
Инструкции BL и BLX в Thumb, как мы помним, кодируются как пара 16-битных
инструкций, а в Thumb-2 эти суррогатные опкоды расширены так, что новые
инструкции кодируются здесь как 32-битные инструкции. Это можно заметить
по тому что опкоды Thumb-2 инструкций всегда начинаются с 0xFx либо с 0xEx.
Но в листинге IDA байты опкода переставлены местами. Это из-за того, что
в процессоре ARM инструкции кодируются так: в начале последний байт, потом первый (для Thumb и Thumb-2 режима), либо, (для инструкций в режиме
ARM) в начале четвертый байт, затем третий, второй и первый (т.е. другой
endianness).
Вот так байты следуют в листингах IDA:
• для режимов ARM и ARM64: 4-3-2-1;
• для режима Thumb: 2-1;
• для пары 16-битных инструкций в режиме Thumb-2: 2-1-4-3.
Так что мы видим здесь что инструкции MOVW, MOVT.W и BLX начинаются с 0xFx.
Одна из Thumb-2 инструкций это MOVW R0, #0x13D8 — она записывает 16-битное
число в младшую часть регистра R0, очищая старшие биты.
Ещё MOVT.W R0, #0 — эта инструкция работает так же, как и MOVT из предыдущего примера, но она работает в Thumb-2.
Помимо прочих отличий, здесь используется инструкция BLX вместо BL. Отличие в том, что помимо сохранения адреса возврата в регистре LR и передаче управления в функцию puts(), происходит смена режима процессора
с Thumb/Thumb-2 на режим ARM (либо назад). Здесь это нужно потому, что
инструкция, куда ведет переход, выглядит так (она закодирована в режиме
ARM):
__symbolstub1:00003FEC _puts
__symbolstub1:00003FEC 44 F0 9F E5
; CODE XREF: _hello_world+E
LDR PC, =__imp__puts
Это просто переход на место, где записан адрес puts() в секции импортов.
Итак, внимательный читатель может задать справедливый вопрос: почему бы
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
30
не вызывать puts() сразу в том же месте кода, где он нужен? Но это не очень
выгодно из-за экономии места и вот почему.
Практически любая программа использует внешние динамические библиотеки
(будь то DLL в Windows, .so в *NIX либо .dylib в Mac OS X). В динамических библиотеках находятся часто используемые библиотечные функции, в том числе
стандартная функция Си puts().
В исполняемом бинарном файле (Windows PE .exe, ELF либо Mach-O) имеется
секция импортов, список символов (функций либо глобальных переменных)
импортируемых из внешних модулей, а также названия самих модулей. Загрузчик ОС загружает необходимые модули и, перебирая импортируемые символы в основном модуле, проставляет правильные адреса каждого символа. В
нашем случае, __imp__puts это 32-битная переменная, куда загрузчик ОС запишет правильный адрес этой же функции во внешней библиотеке. Так что
инструкция LDR просто берет 32-битное значение из этой переменной, и, записывая его в регистр PC, просто передает туда управление. Чтобы уменьшить
время работы загрузчика ОС, нужно чтобы ему пришлось записать адрес каждого символа только один раз, в соответствующее, выделенное для них, место.
К тому же, как мы уже убедились, нельзя одной инструкцией загрузить в регистр 32-битное число без обращений к памяти. Так что наиболее оптимально
выделить отдельную функцию, работающую в режиме ARM, чья единственная
цель — передавать управление дальше, в динамическую библиотеку. И затем
ссылаться на эту короткую функцию из одной инструкции (так называемую
thunk-функцию) из Thumb-кода.
Кстати, в предыдущем примере (скомпилированном для режима ARM), переход при помощи инструкции BL ведет на такую же thunk-функцию, однако режим процессора не переключается (отсюда отсутствие «X» в мнемонике инструкции).
Еще о thunk-функциях
Thunk-функции трудновато понять, скорее всего, из-за путаницы в терминах.
Проще всего представлять их как адаптеры-переходники из одного типа разъемов в другой. Например, адаптер, позволяющий вставить в американскую розетку британскую вилку, или наоборот. Thunk-функции также иногда называются wrapper-ами. Wrap в английском языке это обертывать, завертывать. Вот
еще несколько описаний этих функций:
“A piece of coding which provides an address:”, according to
P. Z. Ingerman, who invented thunks in 1961 as a way of binding
actual parameters to their formal definitions in Algol-60 procedure
calls. If a procedure is called with an expression in the place of a
formal parameter, the compiler generates a thunk which computes
the expression and leaves the address of the result in some standard
location.
…
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
31
Microsoft and IBM have both defined, in their Intel-based systems,
a “16-bit environment” (with bletcherous segment registers and 64K
address limits) and a “32-bit environment” (with flat addressing and
semi-real memory management). The two environments can both be
running on the same computer and OS (thanks to what is called, in the
Microsoft world, WOW which stands for Windows On Windows). MS and
IBM have both decided that the process of getting from 16- to 32-bit
and vice versa is called a “thunk”; for Windows 95, there is even a tool,
THUNK.EXE, called a “thunk compiler”.
( The Jargon File )
Еще один пример мы можем найти в библиотеке LAPACK — (“Linear Algebra
PACKage”) написанная на FORTRAN. Разработчики на Си/Си++также хотят использовать LAPACK, но переписывать её на Си/Си++, а затем поддерживать
несколько версий, это безумие. Так что имеются короткие функции на Си вызываемые из Си/Си++-среды, которые, в свою очередь, вызывают функции на
FORTRAN, и почти ничего больше не делают:
double Blas_Dot_Prod(const LaVectorDouble &dx, const LaVectorDouble &dy)
{
assert(dx.size()==dy.size());
integer n = dx.size();
integer incx = dx.inc(), incy = dy.inc();
return F77NAME(ddot)(&n, &dx(0), &incx, &dy(0), &incy);
}
Такие ф-ции еще называют “wrappers” (т.е., “обертка”).
ARM64
GCC
Компилируем пример в GCC 4.8.1 для ARM64:
Листинг 1.29: Неоптимизирующий GCC 4.8.1 + objdump
1
2
3
4
5
6
7
8
9
10
11
12
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
32
13
14
Contents of section .rodata:
400640 01000200 00000000 48656c6c 6f210a00
........Hello!..
В ARM64 нет режима Thumb и Thumb-2, только ARM, так что тут только 32битные инструкции.
Регистров тут в 2 раза больше: .2.4 (стр. 1327). 64-битные регистры теперь
имеют префикс X-, а их 32-битные части — W-.
Инструкция STP (Store Pair) сохраняет в стеке сразу два регистра: X29 и X30.
Конечно, эта инструкция может сохранять эту пару где угодно в памяти, но
здесь указан регистр SP, так что пара сохраняется именно в стеке.
Регистры в ARM64 64-битные, каждый имеет длину в 8 байт, так что для хранения двух регистров нужно именно 16 байт.
Восклицательный знак (“!”) после операнда означает, что сначала от SP будет
отнято 16 и только затем значения из пары регистров будут записаны в стек.
Это называется pre-index. Больше о разнице между post-index и pre-index описано здесь: 1.39.2 (стр. 565).
Таким образом, в терминах более знакомого всем процессора x86, первая инструкция — это просто аналог пары инструкций PUSH X29 и PUSH X30. X29 в
ARM64 используется как FP40 , а X30 как LR, поэтому они сохраняются в прологе функции и восстанавливаются в эпилоге.
Вторая инструкция копирует SP в X29 (или FP). Это нужно для установки стекового фрейма функции.
Инструкции ADRP и ADD нужны для формирования адреса строки «Hello!» в регистре X0, ведь первый аргумент функции передается через этот регистр. Но в
ARM нет инструкций, при помощи которых можно записать в регистр длинное
число (потому что сама длина инструкции ограничена 4-я байтами. Больше об
этом здесь: 1.39.3 (стр. 567)). Так что нужно использовать несколько инструкций. Первая инструкция (ADRP) записывает в X0 адрес 4-килобайтной страницы
где находится строка, а вторая (ADD) просто прибавляет к этому адресу остаток. Читайте больше об этом: 1.39.4 (стр. 569).
0x400000 + 0x648 = 0x400648, и мы видим, что в секции данных .rodata по
этому адресу как раз находится наша Си-строка «Hello!».
Затем при помощи инструкции BL вызывается puts(). Это уже рассматривалось ранее: 1.5.3 (стр. 28).
Инструкция MOV записывает 0 в W0. W0 это младшие 32 бита 64-битного регистра X0:
Старшие 32 бита
младшие 32 бита
X0
W0
А результат функции возвращается через X0, и main() возвращает 0, так что
вот так готовится возвращаемый результат.
40 Frame
Pointer
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
33
Почему именно 32-битная часть? Потому что в ARM64, как и в x86-64, тип int
оставили 32-битным, для лучшей совместимости.
Следовательно, раз уж функция возвращает 32-битный int, то нужно заполнить только 32 младших бита регистра X0.
Для того, чтобы удостовериться в этом, немного отредактируем этот пример
и перекомпилируем его.
Теперь main() возвращает 64-битное значение:
Листинг 1.30: main() возвращающая значение типа uint64_t
#include
#include
uint64_t main()
{
printf ("Hello!\n");
return 0;
}
Результат точно такой же, только MOV в той строке теперь выглядит так:
Листинг 1.31: Неоптимизирующий GCC 4.8.1 + objdump
4005a4:
d2800000
mov
x0, #0x0
// #0
Далее при помощи инструкции LDP (Load Pair) восстанавливаются регистры X29
и X30.
Восклицательного знака после инструкции нет. Это означает, что сначала значения достаются из стека, и только потом SP увеличивается на 16.
Это называется post-index.
В ARM64 есть новая инструкция: RET. Она работает так же как и BX LR, но там
добавлен специальный бит, подсказывающий процессору, что это именно выход из функции, а не просто переход, чтобы процессор мог более оптимально
исполнять эту инструкцию.
Из-за простоты этой функции оптимизирующий GCC генерирует точно такой
же код.
1.5.4. MIPS
О «глобальном указателе» («global pointer»)
«Глобальный указатель» («global pointer») — это важная концепция в MIPS. Как
мы уже возможно знаем, каждая инструкция в MIPS имеет размер 32 бита, поэтому невозможно закодировать 32-битный адрес внутри одной инструкции.
Вместо этого нужно использовать пару инструкций (как это сделал GCC для
загрузки адреса текстовой строки в нашем примере). С другой стороны, используя только одну инструкцию, возможно загружать данные по адресам в
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
34
пределах register − 32768...register + 32767, потому что 16 бит знакового смещения можно закодировать в одной инструкции). Так мы можем выделить какойто регистр для этих целей и ещё выделить буфер в 64KiB для самых часто
используемых данных. Выделенный регистр называется «глобальный указатель» («global pointer») и он указывает на середину области 64KiB. Эта область
обычно содержит глобальные переменные и адреса импортированных функций вроде printf(), потому что разработчики GCC решили, что получение адреса функции должно быть как можно более быстрой операцией, исполняющейся за одну инструкцию вместо двух. В ELF-файле эта 64KiB-область находится частично в секции .sbss («small BSS41 ») для неинициализированных данных и в секции .sdata («small data») для инициализированных данных. Это значит что программист может выбирать, к чему нужен как можно более быстрый
доступ, и затем расположить это в секциях .sdata/.sbss. Некоторые программисты «старой школы» могут вспомнить модель памяти в MS-DOS 10.6 (стр. 1274)
или в менеджерах памяти вроде XMS/EMS, где вся память делилась на блоки
по 64KiB.
Эта концепция применяется не только в MIPS. По крайней мере PowerPC также
использует эту технику.
Оптимизирующий GCC
Рассмотрим следующий пример, иллюстрирующий концепцию «глобального
указателя».
Листинг 1.32: Оптимизирующий GCC 4.4.5 (вывод на ассемблере)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
$LC0:
; \000 это ноль в восьмеричной системе:
.ascii "Hello, world!\012\000"
main:
; пролог функции
; установить GP:
lui
$28,%hi(__gnu_local_gp)
addiu
$sp,$sp,−32
addiu
$28,$28,%lo(__gnu_local_gp)
; сохранить RA в локальном стеке:
sw
$31,28($sp)
; загрузить адрес функции puts() из GP в $25:
lw
$25,%call16(puts)($28)
; загрузить адрес текстовой строки в $4 ($a0):
lui
$4,%hi($LC0)
; перейти на puts(), сохранив адрес возврата в link-регистре:
jalr
$25
addiu
$4,$4,%lo($LC0) ; branch delay slot
; восстановить RA:
lw
$31,28($sp)
; скопировать 0 из $zero в $v0:
move
$2,$0
; вернуть управление сделав переход по адресу в RA:
j
$31
41 Block
Started by Symbol
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
Как видно, регистр $GP в прологе функции выставляется в середину этой области. Регистр RA сохраняется в локальном стеке. Здесь также используется
puts() вместо printf(). Адрес функции puts() загружается в $25 инструкцией LW («Load Word»). Затем адрес текстовой строки загружается в $4 парой
инструкций LUI («Load Upper Immediate») и ADDIU («Add Immediate Unsigned
Word»). LUI устанавливает старшие 16 бит регистра (поэтому в имени инструкции присутствует «upper») и ADDIU прибавляет младшие 16 бит к адресу. ADDIU
следует за JALR (помните о branch delay slots?). Регистр $4 также называется
$A0, который используется для передачи первого аргумента функции 42 . JALR
(«Jump and Link Register») делает переход по адресу в регистре $25 (там адрес puts()) при этом сохраняя адрес следующей инструкции (LW) в RA. Это так
же как и в ARM. И ещё одна важная вещь: адрес сохраняемый в RA это адрес
не следующей инструкции (потому что это delay slot и исполняется перед инструкцией перехода), а инструкции после неё (после delay slot). Таким образом
во время исполнения JALR в RA записывается P C + 8. В нашем случае это адрес
инструкции LW следующей после ADDIU.
LW («Load Word») в строке 20 восстанавливает RA из локального стека (эта инструкция скорее часть эпилога функции).
MOVE в строке 22 копирует значение из регистра $0 ($ZERO) в $2 ($V0).
В MIPS есть константный регистр, всегда содержащий ноль. Должно быть, разработчики MIPS решили, что 0 это самая востребованная константа в программировании, так что пусть будет использоваться регистр $0, всякий раз, когда
будет нужен 0. Другой интересный факт: в MIPS нет инструкции, копирующей
значения из регистра в регистр. На самом деле, MOVE DST, SRC это ADD DST,
SRC, $ZERO (DST = SRC + 0), которая делает тоже самое. Очевидно, разработчики MIPS хотели сделать как можно более компактную таблицу опкодов. Это
не значит, что сложение происходит во время каждой инструкции MOVE. Скорее всего, эти псевдоинструкции оптимизируются в CPU и АЛУ43 никогда не
используется.
J в строке 24 делает переход по адресу в RA, и это работает как выход из
функции. ADDIU после J на самом деле исполняется перед J (помните о branch
delay slots?) и это часть эпилога функции.
Вот листинг сгенерированный IDA. Каждый регистр имеет свой псевдоним:
Листинг 1.33: Оптимизирующий GCC 4.4.5 (IDA)
1
2
3
4
5
6
; установить GP:
.text:00000000
lui
$gp, (__gnu_local_gp >> 16)
.text:00000004
addiu
$sp, −0x20
.text:00000008
la
$gp, (__gnu_local_gp & 0xFFFF)
; сохранить RA в локальном стеке:
.text:0000000C
sw
$ra, 0x20+var_4($sp)
; сохранить GP в локальном стеке:
; по какой-то причине, этой инструкции не было в ассемблерном выводе в GCC:
.text:00000010
sw
$gp, 0x20+var_10($sp)
; загрузить адрес функции puts() из GP в $t9:
.text:00000014
lw
$t9, (puts & 0xFFFF)($gp)
; сформировать адрес текстовой строки в $a0:
.text:00000018
lui
$a0, ($LC0 >> 16) # "Hello, world!"
; перейти на puts(), сохранив адрес возврата в link-регистре:
.text:0000001C
jalr
$t9
.text:00000020
la
$a0, ($LC0 & 0xFFFF) # "Hello,
world!"
; восстановить RA:
.text:00000024
lw
$ra, 0x20+var_4($sp)
; скопировать 0 из $zero в $v0:
.text:00000028
move
$v0, $zero
; вернуть управление сделав переход по адресу в RA:
.text:0000002C
jr
$ra
; эпилог функции:
.text:00000030
addiu
$sp, 0x20
Инструкция в строке 15 сохраняет GP в локальном стеке. Эта инструкция мистическим образом отсутствует в листинге от GCC, может быть из-за ошибки в
самом GCC44 . Значение GP должно быть сохранено, потому что всякая функция
может работать со своим собственным окном данных размером 64KiB. Регистр,
содержащий адрес функции puts() называется $T9, потому что регистры с
префиксом T- называются «temporaries» и их содержимое можно не сохранять.
Неоптимизирующий GCC
Неоптимизирующий GCC более многословный.
Листинг 1.34: Неоптимизирующий GCC 4.4.5 (вывод на ассемблере)
1
2
3
4
5
6
7
8
9
10
11
12
$LC0:
.ascii "Hello, world!\012\000"
main:
; пролог функции
; сохранить RA ($31) и FP в стеке:
addiu
$sp,$sp,−32
sw
$31,28($sp)
sw
$fp,24($sp)
; установить FP (указатель стекового фрейма):
move
$fp,$sp
; установить GP:
lui
$28,%hi(__gnu_local_gp)
44 Очевидно, функция вывода листингов не так критична для пользователей GCC, поэтому там
вполне могут быть неисправленные косметические ошибки.
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
addiu
$28,$28,%lo(__gnu_local_gp)
; загрузить адрес текстовой строки:
lui
$2,%hi($LC0)
addiu
$4,$2,%lo($LC0)
; загрузить адрес функции puts() используя GP:
lw
$2,%call16(puts)($28)
nop
; вызвать puts():
move
$25,$2
jalr
$25
nop ; branch delay slot
; восстановить GP из локального стека:
lw
$28,16($fp)
; установить регистр $2 ($V0) в ноль:
move
$2,$0
; эпилог функции.
; восстановить SP:
move
$sp,$fp
; восстановить RA:
lw
$31,28($sp)
; восстановить FP:
lw
$fp,24($sp)
addiu
$sp,$sp,32
; переход на RA:
j
$31
nop ; branch delay slot
Мы видим, что регистр FP используется как указатель на фрейм стека. Мы также видим 3 NOP-а. Второй и третий следуют за инструкциями перехода. Видимо, компилятор GCC всегда добавляет NOP-ы (из-за branch delay slots) после
инструкций переходов и затем, если включена оптимизация, от них может избавляться. Так что они остались здесь.
Вот также листинг от IDA:
Листинг 1.35: Неоптимизирующий GCC 4.4.5 (IDA)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.text:00000000 main:
.text:00000000
.text:00000000 var_10
= −0x10
.text:00000000 var_8
= −8
.text:00000000 var_4
= −4
.text:00000000
; пролог функции
; сохранить RA и FP в стеке:
.text:00000000
addiu
$sp, −0x20
.text:00000004
sw
$ra, 0x20+var_4($sp)
.text:00000008
sw
$fp, 0x20+var_8($sp)
; установить FP (указатель стекового фрейма):
.text:0000000C
move
$fp, $sp
; установить GP:
.text:00000010
la
$gp, __gnu_local_gp
.text:00000018
sw
$gp, 0x20+var_10($sp)
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
; загрузить адрес текстовой строки:
.text:0000001C
lui
$v0, (aHelloWorld >> 16) # "Hello,
world!"
.text:00000020
addiu
$a0, $v0, (aHelloWorld & 0xFFFF) #
"Hello, world!"
; загрузить адрес функции puts() используя GP:
.text:00000024
lw
$v0, (puts & 0xFFFF)($gp)
.text:00000028
or
$at, $zero ; NOP
; вызвать puts():
.text:0000002C
move
$t9, $v0
.text:00000030
jalr
$t9
.text:00000034
or
$at, $zero ; NOP
; восстановить GP из локального стека:
.text:00000038
lw
$gp, 0x20+var_10($fp)
; установить регистр $2 ($V0) в ноль:
.text:0000003C
move
$v0, $zero
; эпилог функции.
; восстановить SP:
.text:00000040
move
$sp, $fp
; восстановить RA:
.text:00000044
lw
$ra, 0x20+var_4($sp)
; восстановить FP:
.text:00000048
lw
$fp, 0x20+var_8($sp)
.text:0000004C
addiu
$sp, 0x20
; переход на RA:
.text:00000050
jr
$ra
.text:00000054
or
$at, $zero ; NOP
Интересно что IDA распознала пару инструкций LUI/ADDIU и собрала их в одну псевдоинструкцию LA («Load Address») в строке 15. Мы также видим, что
размер этой псевдоинструкции 8 байт! Это псевдоинструкция (или макрос),
потому что это не настоящая инструкция MIPS, а скорее просто удобное имя
для пары инструкций.
Ещё кое что: IDA не распознала NOP-инструкции в строках 22, 26 и 41.
Это OR $AT, $ZERO. По своей сути это инструкция, применяющая операцию
ИЛИ к содержимому регистра $AT с нулем, что, конечно же, холостая операция.
MIPS, как и многие другие ISA, не имеет отдельной NOP-инструкции.
Роль стекового фрейма в этом примере
Адрес текстовой строки передается в регистре. Так зачем устанавливать локальный стек? Причина в том, что значения регистров RA и GP должны быть
сохранены где-то (потому что вызывается printf()) и для этого используется
локальный стек.
Если бы это была leaf function, тогда можно было бы избавиться от пролога и
эпилога функции. Например: 1.4.3 (стр. 11).
Оптимизирующий GCC: загрузим в GDB
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
39
Листинг 1.36: пример сессии в GDB
root@debian−mips:~# gcc hw.c −O3 −o hw
root@debian−mips:~# gdb hw
GNU gdb (GDB) 7.0.1−debian
...
Reading symbols from /root/hw...(no debugging symbols found)...done.
(gdb) b main
Breakpoint 1 at 0x400654
(gdb) run
Starting program: /root/hw
Breakpoint 1, 0x00400654 in main ()
(gdb) set step−mode on
(gdb) disas
Dump of assembler code for function main:
0x00400640 :
lui
gp,0x42
0x00400644 :
addiu
sp,sp,−32
0x00400648 :
addiu
gp,gp,−30624
0x0040064c :
sw
ra,28(sp)
gp,16(sp)
0x00400650 :
sw
0x00400654 :
lw
t9,−32716(gp)
0x00400658 :
lui
a0,0x40
0x0040065c :
jalr
t9
0x00400660 :
addiu
a0,a0,2080
0x00400664 :
lw
ra,28(sp)
0x00400668 :
move
v0,zero
0x0040066c :
jr
ra
0x00400670 :
addiu
sp,sp,32
End of assembler dump.
(gdb) s
0x00400658 in main ()
(gdb) s
0x0040065c in main ()
(gdb) s
0x2ab2de60 in printf () from /lib/libc.so.6
(gdb) x/s $a0
0x400820:
"hello, world"
(gdb)
1.5.5. Вывод
Основная разница между кодом x86/ARM и x64/ARM64 в том, что указатель на
строку теперь 64-битный. Действительно, ведь для того современные CPU и
стали 64-битными, потому что подешевела память, её теперь можно поставить
в компьютер намного больше, и чтобы её адресовать, 32-х бит уже недостаточно. Поэтому все указатели теперь 64-битные.
1.5.6. Упражнения
• http://challenges.re/48
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
40
• http://challenges.re/49
1.6. Пролог и эпилог функций
Пролог функции это инструкции в самом начале функции. Как правило, это
что-то вроде такого фрагмента кода:
push
mov
sub
ebp
ebp, esp
esp, X
Эти инструкции делают следующее: сохраняют значение регистра EBP на будущее, выставляют EBP равным ESP, затем подготавливают место в стеке для
хранения локальных переменных.
EBP сохраняет свое значение на протяжении всей функции, он будет использоваться здесь для доступа к локальным переменным и аргументам. Можно
было бы использовать и ESP, но он постоянно меняется и это не очень удобно.
Эпилог функции аннулирует выделенное место в стеке, восстанавливает значение EBP на старое и возвращает управление в вызывающую функцию:
mov
pop
ret
esp, ebp
ebp
0
Пролог и эпилог функции обычно находятся в дизассемблерах для отделения
функций друг от друга.
1.6.1. Рекурсия
Наличие эпилога и пролога может несколько ухудшить эффективность рекурсии.
Больше о рекурсии в этой книге: 3.5.3 (стр. 622).
1.7. Еще кое-что о пустой ф-ции
Вернемся к примеру с пустой ф-цией 1.3 (стр. 8). Теперь, когда мы уже знаем о
прологе и эпилоге ф-ции, вот эта пустая ф-ция 1.1 (стр. 8) скомпилированная
неоптимизирующим GCC:
Листинг 1.37: Неоптимизирующий GCC 8.2 x64 (вывод на ассемблере)
f:
push
mov
nop
pop
ret
rbp
rbp, rsp
rbp
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
41
Это RET, но пролог и эпилог ф-ции, вероятно, не был соптимизирован и остался
как есть. NOP это, похоже, артефакт компилятора. Так или иначе, единственная
здесь рабочая инструкция это RET. Остальные инструкции могут быть убраны
(или соптимизированы).
1.8. Еще кое-что о возвращаемых значениях
Теперь, когда мы уже знаем о прологе и эпилоге ф-ции, попробуем скомпилировать пример, возвращающий значение (1.4 (стр. 10), 1.8 (стр. 10)), используя
неоптимизирующий GCC:
Листинг 1.38: Неоптимизирующий GCC 8.2 x64 (вывод на ассемблере)
f:
push
mov
mov
pop
ret
rbp
rbp, rsp
eax, 123
rbp
Рабочие здесь инструкции это MOV и RET, остальные – пролог и эпилог.
1.9. Стек
Стек в компьютерных науках — это одна из наиболее фундаментальных структур данных 45 . AKA46 LIFO47 .
Технически это просто блок памяти в памяти процесса + регистр ESP в x86 или
RSP в x64, либо SP в ARM, который указывает где-то в пределах этого блока.
Часто используемые инструкции для работы со стеком — это PUSH и POP (в x86
и Thumb-режиме ARM). PUSH уменьшает ESP/RSP/SP на 4 в 32-битном режиме
(или на 8 в 64-битном), затем записывает по адресу, на который указывает
ESP/RSP/SP, содержимое своего единственного операнда.
POP это обратная операция — сначала достает из указателя стека значение и
помещает его в операнд (который очень часто является регистром) и затем
увеличивает указатель стека на 4 (или 8).
В самом начале регистр-указатель указывает на конец стека. Конец стека находится в начале блока памяти, выделенного под стек. Это странно, но это так.
PUSH уменьшает регистр-указатель, а POP — увеличивает.
В процессоре ARM, тем не менее, есть поддержка стеков, растущих как в сторону уменьшения, так и в сторону увеличения.
Например, инструкции STMFD/LDMFD, STMED48 /LDMED49 предназначены для descending45 wikipedia.org/wiki/Call_stack
46
Also Known As — Также известный как
In First Out (последним вошел, первым вышел)
48 Store Multiple Empty Descending (инструкция ARM)
49 Load Multiple Empty Descending (инструкция ARM)
47 Last
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
42
стека (растет назад, начиная с высоких адресов в сторону низких).
Инструкции STMFA50 /LDMFA51 , STMEA52 /LDMEA53 предназначены для ascendingстека (растет вперед, начиная с низких адресов в сторону высоких).
1.9.1. Почему стек растет в обратную сторону?
Интуитивно мы можем подумать, что, как и любая другая структура данных,
стек мог бы расти вперед, т.е. в сторону увеличения адресов.
Причина, почему стек растет назад, видимо, историческая. Когда компьютеры
были большие и занимали целую комнату, было очень легко разделить сегмент на две части: для кучи и для стека. Заранее было неизвестно, насколько
большой может быть куча или стек, так что это решение было самым простым.
Начало кучи
Вершина стека
Куча
Стэк
В [D. M. Ritchie and K. Thompson, The UNIX Time Sharing System, (1974)]54 можно
прочитать:
The user-core part of an image is divided into three logical
segments. The program text segment begins at location 0 in the virtual
address space. During execution, this segment is write-protected and
a single copy of it is shared among all processes executing the
same program. At the first 8K byte boundary above the program text
segment in the virtual address space begins a nonshared, writable
data segment, the size of which may be extended by a system call.
Starting at the highest address in the virtual address space is a stack
segment, which automatically grows downward as the hardware’s
stack pointer fluctuates.
Это немного напоминает как некоторые студенты пишут два конспекта в одной тетрадке: первый конспект начинается обычным образом, второй пишется
с конца, перевернув тетрадку. Конспекты могут встретиться где-то посредине,
в случае недостатка свободного места.
50 Store
Multiple Full Ascending (инструкция ARM)
Multiple Full Ascending (инструкция ARM)
52 Store Multiple Empty Ascending (инструкция ARM)
53 Load Multiple Empty Ascending (инструкция ARM)
54 Также доступно здесь: URL
51 Load
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
43
1.9.2. Для чего используется стек?
Сохранение адреса возврата управления
x86
При вызове другой функции через CALL сначала в стек записывается адрес,
указывающий на место после инструкции CALL, затем делается безусловный
переход (почти как JMP) на адрес, указанный в операнде.
CALL — это аналог пары инструкций PUSH address_after_call / JMP.
RET вытаскивает из стека значение и передает управление по этому адресу —
это аналог пары инструкций POP tmp / JMP tmp.
Крайне легко устроить переполнение стека, запустив бесконечную рекурсию:
void f()
{
f();
};
MSVC 2008 предупреждает о проблеме:
c:\tmp6>cl ss.cpp /Fass.asm
Microsoft (R) 32−bit C/C++ Optimizing Compiler Version 15.00.21022.08 for ⤦
Ç 80x86
Copyright (C) Microsoft Corporation. All rights reserved.
ss.cpp
c:\tmp6\ss.cpp(4) : warning C4717: 'f' : recursive on all control paths, ⤦
Ç function will cause runtime stack overflow
…но, тем не менее, создает нужный код:
?f@@YAXXZ PROC
; Line 2
push
mov
; Line 3
call
; Line 4
pop
ret
?f@@YAXXZ ENDP
; f
ebp
ebp, esp
?f@@YAXXZ
; f
ebp
0
; f
…причем, если включить оптимизацию (/Ox), то будет даже интереснее, без
переполнения стека, но работать будет корректно55 :
?f@@YAXXZ PROC
; Line 2
$LL3@f:
55 здесь
; f
ирония
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
44
; Line 3
jmp
?f@@YAXXZ ENDP
SHORT $LL3@f
; f
GCC 4.4.1 генерирует точно такой же код в обоих случаях, хотя и не предупреждает о проблеме.
ARM
Программы для ARM также используют стек для сохранения RA, куда нужно
вернуться, но несколько иначе. Как уже упоминалось в секции «Hello, world!» (1.5.3
(стр. 24)), RA записывается в регистр LR (link register). Но если есть необходимость вызывать какую-то другую функцию и использовать регистр LR ещё раз,
его значение желательно сохранить.
Обычно это происходит в прологе функции, часто мы видим там инструкцию
вроде PUSH {R4-R7,LR}, а в эпилоге POP {R4-R7,PC} — так сохраняются регистры, которые будут использоваться в текущей функции, в том числе LR.
Тем не менее, если некая функция не вызывает никаких более функций, в терминологии RISC она называется leaf function56 . Как следствие, «leaf»-функция
не сохраняет регистр LR (потому что не изменяет его). А если эта функция
небольшая, использует мало регистров, она может не использовать стек вообще. Таким образом, в ARM возможен вызов небольших leaf-функций не используя стек. Это может быть быстрее чем в старых x86, ведь внешняя память для
стека не используется 57 . Либо это может быть полезным для тех ситуаций,
когда память для стека ещё не выделена, либо недоступна,
Некоторые примеры таких функций: 1.14.3 (стр. 138), 1.14.3 (стр. 139), 1.282
(стр. 403), 1.298 (стр. 425), 1.28.5 (стр. 425), 1.192 (стр. 272), 1.190 (стр. 270),
1.209 (стр. 292).
Передача параметров функции
Самый распространенный способ передачи параметров в x86 называется «cdecl»:
push arg3
push arg2
push arg1
call f
add esp, 12 ; 4*3=12
Вызываемая функция получает свои параметры также через указатель стека.
Следовательно, так расположены значения в стеке перед исполнением самой
первой инструкции функции f():
56 infocenter.arm.com/help/index.jsp?topic=/com.arm.doc.faqs/ka13785.html
57 Когда-то,
очень давно, на PDP-11 и VAX на инструкцию CALL (вызов других функций) могло тратиться вплоть до 50% времени (возможно из-за работы с памятью), поэтому считалось, что много
небольших функций это анти-паттерн [Eric S. Raymond, The Art of UNIX Programming, (2003)Chapter
4, Part II].
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
45
ESP
ESP+4
ESP+8
ESP+0xC
…
адрес возврата
аргумент#1, маркируется в IDA как arg_0
аргумент#2, маркируется в IDA как arg_4
аргумент#3, маркируется в IDA как arg_8
…
См. также в соответствующем разделе о других способах передачи аргументов через стек (6.1 (стр. 956)).
Кстати, вызываемая функция не имеет информации о количестве переданных ей аргументов. Функции Си с переменным количеством аргументов (как
printf()) могут определять их количество по спецификаторам строки формата (начинающиеся со знака %).
Если написать что-то вроде:
printf("%d %d %d", 1234);
printf() выведет 1234, затем ещё два случайных числа58 , которые волею случая оказались в стеке рядом.
Вот почему не так уж и важно, как объявлять функцию main():
как main(), main(int argc, char *argv[])
либо main(int argc, char *argv[], char *envp[]).
В реальности, CRT-код вызывает main() примерно так:
push
push
push
call
...
envp
argv
argc
main
Если вы объявляете main() без аргументов, они, тем не менее, присутствуют в
стеке, но не используются. Если вы объявите main() как main(int argc, char
*argv[]), вы можете использовать два первых аргумента, а третий останется
для вашей функции «невидимым». Более того, можно даже объявить main(int
argc), и это будет работать.
Альтернативные способы передачи аргументов
Важно отметить, что, в общем, никто не заставляет программистов передавать параметры именно через стек, это не является требованием к исполняемому коду. Вы можете делать это совершенно иначе, не используя стек вообще.
В каком-то смысле, популярный метод среди начинающих использовать язык
ассемблера, это передавать аргументы в глобальных переменных, например:
Листинг 1.39: Код на ассемблере
58 В
строгом смысле, они не случайны, скорее, непредсказуемы: 1.9.4 (стр. 51)
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
46
...
mov
mov
call
X, 123
Y, 456
do_something
...
X
Y
dd
dd
?
?
do_something proc near
; take X
; take Y
; do something
retn
do_something endp
Но у этого метода есть очевидный недостаток: ф-ция do_something() не сможет вызвать саму себя рекурсивно (либо, через какую-то стороннюю ф-цию),
потому что тогда придется затереть свои собственные аргументы. Та же история с локальными переменными: если хранить их в глобальных переменных,
ф-ция не сможет вызывать сама себя. К тому же, этот метод не безопасный
для мультитредовой среды59 . Способ хранения подобной информации в стеке
заметно всё упрощает — он может хранить столько аргументов ф-ций и/или
значений вообще, сколько в нем есть места.
В [Donald E. Knuth, The Art of Computer Programming, Volume 1, 3rd ed., (1997),
189] можно прочитать про еще более странные схемы передачи аргументов,
которые были очень удобны на IBM System/360.
В MS-DOS был метод передачи аргументов через регистры, например, этот
фрагмент кода для древней 16-битной MS-DOS выводит “Hello, world!”:
mov
mov
int
dx, msg
ah, 9
21h
; адрес сообщения
; 9 означает ф-цию "вывод строки"
; DOS "syscall"
mov
int
ah, 4ch
21h
; ф-ция "закончить программу"
; DOS "syscall"
msg
db 'Hello, World!\$'
Это очень похоже на метод 6.1.3 (стр. 958). И еще на метод вызовов сисколлов
в Linux (6.3.1 (стр. 974)) и Windows.
Если ф-ция в MS-DOS возвращает булево значение (т.е., один бит, обычно сигнализирующий об ошибке), часто использовался флаг CF.
Например:
59 При корректной реализации, каждый тред будет иметь свой собственный стек со своими
аргументами/переменными.
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
В случае ошибки, флаг CF будет выставлен. Иначе, хэндл только что созданного файла возвращается в AX.
Этот метод до сих пор используется программистами на ассемблере. В исходных кодах Windows Research Kernel (который очень похож на Windows 2003) мы
можем найти такое
(файл base/ntos/ke/i386/cpu.asm):
public
Get386Stepping
Get386Stepping
proc
call
jnc
mov
ret
MultiplyTest
short G3s00
ax, 0
; Perform multiplication test
; if nc, muttest is ok
call
jnc
mov
ret
Check386B0
short G3s05
ax, 100h
; Check for B0 stepping
; if nc, it's B1/later
; It is B0/earlier stepping
call
jc
mov
ret
Check386D1
short G3s10
ax, 301h
; Check for D1 stepping
; if c, it is NOT D1
; It is D1/later stepping
mov
ret
ax, 101h
; assume it is B1 stepping
G3s00:
G3s05:
G3s10:
...
MultiplyTest
mlt00:
xor
push
call
pop
jc
loop
clc
proc
cx,cx
cx
Multiply
cx
short mltx
mlt00
; 64K times is a nice round number
; does this chip's multiply work?
; if c, No, exit
; if nc, YEs, loop to try again
mltx:
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
48
ret
MultiplyTest
endp
Хранение локальных переменных
Функция может выделить для себя некоторое место в стеке для локальных
переменных, просто отодвинув указатель стека глубже к концу стека.
Это очень быстро вне зависимости от количества локальных переменных. Хранить локальные переменные в стеке не является необходимым требованием.
Вы можете хранить локальные переменные где угодно. Но по традиции всё
сложилось так.
x86: Функция alloca()
Интересен случай с функцией alloca() 60 . Эта функция работает как malloc(),
но выделяет память прямо в стеке. Память освобождать через free() не нужно, так как эпилог функции (1.6 (стр. 40)) вернет ESP в изначальное состояние
и выделенная память просто выкидывается. Интересна реализация функции
alloca(). Эта функция, если упрощенно, просто сдвигает ESP вглубь стека на
столько байт, делая так, что ESP указывает на выделенный блок.
Попробуем:
#ifdef __GNUC__
#include // GCC
#else
#include // MSVC
#endif
#include
void f()
{
char ∗buf=(char∗)alloca (600);
#ifdef __GNUC__
snprintf (buf, 600, "hi! %d, %d, %d\n", 1, 2, 3); // GCC
#else
_snprintf (buf, 600, "hi! %d, %d, %d\n", 1, 2, 3); // MSVC
#endif
puts (buf);
};
Функция _snprintf() работает так же, как и printf(), только вместо выдачи
результата в stdout (т.е. на терминал или в консоль), записывает его в буфер
buf. Функция puts() выдает содержимое буфера buf в stdout. Конечно, можно
было бы заменить оба этих вызова на один printf(), но здесь нужно проиллюстрировать использование небольшого буфера.
60 В MSVC, реализацию функции можно посмотреть в файлах alloca16.asm и chkstk.asm в
C:\Program Files (x86)\Microsoft Visual Studio 10.0\VC\crt\src\intel
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
3
2
1
OFFSET $SG2672
600
; 00000258H
esi
__snprintf
push
call
add
esi
_puts
esp, 28
...
Единственный параметр в alloca() передается через EAX, а не как обычно
через стек 61 .
GCC + Синтаксис Intel
А GCC 4.4.1 обходится без вызова других функций:
Листинг 1.41: GCC 4.7.3
.LC0:
.string "hi! %d, %d, %d\n"
f:
push
mov
push
sub
lea
and
границе
mov
mov
ebp
ebp,
ebx
esp,
ebx,
ebx,
esp
660
[esp+39]
−16
DWORD PTR [esp], ebx
DWORD PTR [esp+20], 3
; выровнять указатель по 16-байтной
; s
61 Это потому, что alloca() — это не сколько функция, сколько т.н. compiler intrinsic (10.3
(стр. 1268)) Одна из причин, почему здесь нужна именно функция, а не несколько инструкций
прямо в коде в том, что в реализации функции alloca() от MSVC62 есть также код, читающий из
только что выделенной памяти, чтобы ОС подключила физическую память к этому региону VM63 .
После вызова alloca() ESP указывает на блок в 600 байт, который мы можем использовать под
buf.
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
Всё то же самое, что и в прошлом листинге.
Кстати, movl $3, 20(%esp) — это аналог mov DWORD PTR [esp+20], 3 в синтаксисе Intel. Адресация памяти в виде регистр+смещение записывается в синтаксисе AT&T как смещение(%регистр).
(Windows) SEH
В стеке хранятся записи SEH64 для функции (если они присутствуют). Читайте
больше о нем здесь: (6.5.3 (стр. 998)).
64 Structured
Exception Handling
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
51
Защита от переполнений буфера
Здесь больше об этом (1.26.2 (стр. 347)).
Автоматическое освобождение данных в стеке
Возможно, причина хранения локальных переменных и SEH-записей в стеке в
том, что после выхода из функции, всё эти данные освобождаются автоматически, используя только одну инструкцию корректирования указателя стека
(часто это ADD). Аргументы функций, можно сказать, тоже освобождаются автоматически в конце функции. А всё что хранится в куче (heap) нужно освобождать явно.
1.9.3. Разметка типичного стека
Разметка типичного стека в 32-битной среде перед исполнением самой первой
инструкции функции выглядит так:
…
ESP-0xC
ESP-8
ESP-4
ESP
ESP+4
ESP+8
ESP+0xC
…
…
локальная переменная#2, маркируется в IDA как var_8
локальная переменная#1, маркируется в IDA как var_4
сохраненное значениеEBP
Адрес возврата
аргумент#1, маркируется в IDA как arg_0
аргумент#2, маркируется в IDA как arg_4
аргумент#3, маркируется в IDA как arg_8
…
1.9.4. Мусор в стеке
When one says that something seems
random, what one usually means in practice
is that one cannot see any regularities in it.
Stephen Wolfram, A New Kind of Science.
Часто в этой книге говорится о «шуме» или «мусоре» в стеке или памяти. Откуда он берется? Это то, что осталось там после исполнения предыдущих функций.
Короткий пример:
#include
void f1()
{
int a=1, b=2, c=3;
};
void f2()
{
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
52
int a, b, c;
printf ("%d, %d, %d\n", a, b, c);
};
int main()
{
f1();
f2();
};
Компилируем…
Листинг 1.43: Неоптимизирующий MSVC 2010
$SG2752 DB
'%d, %d, %d', 0aH, 00H
_c$ = −12
_b$ = −8
_a$ = −4
_f1
PROC
push
mov
sub
mov
mov
mov
mov
pop
ret
_f1
ENDP
; size = 4
; size = 4
; size = 4
_c$ = −12
_b$ = −8
_a$ = −4
_f2
PROC
push
mov
sub
mov
push
mov
push
mov
push
push
call
add
mov
pop
ret
_f2
ENDP
_main
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
53
_main
mov
call
call
xor
pop
ret
ENDP
ebp, esp
_f1
_f2
eax, eax
ebp
0
Компилятор поворчитнемного…
c:\Polygon\c>cl st.c /Fast.asm /MD
Microsoft (R) 32−bit C/C++ Optimizing Compiler Version 16.00.40219.01 for ⤦
Ç 80x86
Copyright (C) Microsoft Corporation. All rights reserved.
st.c
c:\polygon\c\st.c(11) : warning C4700: uninitialized local variable 'c' ⤦
Ç used
c:\polygon\c\st.c(11) : warning C4700: uninitialized local variable 'b' ⤦
Ç used
c:\polygon\c\st.c(11) : warning C4700: uninitialized local variable 'a' ⤦
Ç used
Microsoft (R) Incremental Linker Version 10.00.40219.01
Copyright (C) Microsoft Corporation. All rights reserved.
/out:st.exe
st.obj
Но когда мы запускаем…
c:\Polygon\c>st
1, 2, 3
Ох. Вот это странно. Мы ведь не устанавливали значения никаких переменных
в f2(). Эти значения — это «привидения», которые всё ещё в стеке.
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
54
Загрузим пример в OllyDbg:
Рис. 1.6: OllyDbg: f1()
Когда f1() заполняет переменные a, b и c они сохраняются по адресу 0x1FF860,
итд.
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
55
А когда исполняется f2():
Рис. 1.7: OllyDbg: f2()
... a, b и c в функции f2() находятся по тем же адресам! Пока никто не перезаписал их, так что они здесь в нетронутом виде. Для создания такой странной
ситуации несколько функций должны исполняться друг за другом и SP должен
быть одинаковым при входе в функции, т.е. у функций должно быть равное
количество аргументов). Тогда локальные переменные будут расположены в
том же месте стека. Подводя итоги, все значения в стеке (да и памяти вообще)
это значения оставшиеся от исполнения предыдущих функций. Строго говоря,
они не случайны, они скорее непредсказуемы. А как иначе? Можно было бы
очищать части стека перед исполнением каждой функции, но это слишком
много лишней (и ненужной) работы.
MSVC 2013
Этот пример был скомпилирован в MSVC 2010. Но один читатель этой книги
сделал попытку скомпилировать пример в MSVC 2013, запустил и увидел 3
числа в обратном порядке:
c:\Polygon\c>st
3, 2, 1
Почему? Я также попробовал скомпилировать этот пример в MSVC 2013 и увидел это:
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
В отличии от MSVC 2010, MSVC 2013 разместил переменные a/b/c в функции
f2() в обратном порядке. И это полностью корректно, потому что в стандартах
Си/Си++нет правила, в каком порядке локальные переменные должны быть
размещены в локальном стеке, если вообще. Разница есть из-за того что MSVC
2010 делает это одним способом, а в MSVC 2013, вероятно, что-то немного
изменили во внутренностях компилятора, так что он ведет себя слегка иначе.
1.10. Почти пустая ф-ция
Это фрагмент реального кода найденного в Boolector65 :
// forward declaration. the function is residing in some other module:
int boolector_main (int argc, char ∗∗argv);
// executable
int main (int argc, char ∗∗argv)
{
return boolector_main (argc, argv);
}
Зачем кому-то это делать? Не ясно, но можно предположить что boolector_main()
может быть скомпилирована в виде DLL или динамической библиотеки, и может вызываться во время тестов. Конечно, тесты могут подготовить переменные argc/argv так же, как это сделал бы CRT.
65 https://boolector.github.io/
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
57
Интересно, как это компилируется:
Листинг 1.45: Неоптимизирующий GCC 8.2 x64 (вывод на ассемблере)
main:
push
mov
sub
mov
mov
mov
mov
mov
mov
call
leave
ret
Здесь у нас: пролог, ненужная (не соптимизированная) перетасовка двух аргументов, CALL, эпилог, RET. Посмотрим на оптимизированную версию:
Листинг 1.46: Оптимизирующий GCC 8.2 x64 (вывод на ассемблере)
main:
jmp
boolector_main
Вот так просто: стек/регистры остаются как есть, а ф-ция boolector_main()
имеет тот же набор аргументов. Так что всё что нужно, это просто передать
управление по другому адресу.
Это близко к thunk function.
Кое-что посложнее мы увидим позже: 1.11.2 (стр. 73), 1.21.1 (стр. 203).
1.11. printf() с несколькими аргументами
Попробуем теперь немного расширить пример Hello, world! (1.5 (стр. 12)), написав в теле функции main():
#include
int main()
{
printf("a=%d; b=%d; c=%d", 1, 2, 3);
return 0;
};
1.11.1. x86
x86: 3 целочисленных аргумента
MSVC
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
58
Компилируем при помощи MSVC 2010 Express, и в итоге получим:
$SG3830 DB
'a=%d; b=%d; c=%d', 00H
...
push
push
push
push
call
add
3
2
1
OFFSET $SG3830
_printf
esp, 16
Всё почти то же, за исключением того, что теперь видно, что аргументы для
printf() заталкиваются в стек в обратном порядке: самый первый аргумент
заталкивается последним.
Кстати, вспомним, что переменные типа int в 32-битной системе, как известно,
имеют ширину 32 бита, это 4 байта.
Итак, у нас всего 4 аргумента. 4 ∗ 4 = 16 — именно 16 байт занимают в стеке
указатель на строку плюс ещё 3 числа типа int.
Когда при помощи инструкции ADD ESP, X корректируется указатель стека
ESP после вызова какой-либо функции, зачастую можно сделать вывод о том,
сколько аргументов у вызываемой функции было, разделив X на 4.
Конечно, это относится только к cdecl-методу передачи аргументов через стек,
и только для 32-битной среды.
См. также в соответствующем разделе о способах передачи аргументов через
стек (6.1 (стр. 956)).
Иногда бывает так, что подряд идут несколько вызовов разных функций, но
стек корректируется только один раз, после последнего вызова:
push a1
push a2
call ...
...
push a1
call ...
...
push a1
push a2
push a3
call ...
add esp, 24
Вот пример из реальной жизни:
Листинг 1.47: x86
.text:100113E7
.text:100113E9
.text:100113EE
push
call
call
3
sub_100018B0 ; берет один аргумент (3)
sub_100019D0 ; не имеет аргументов вообще
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
sub_10006A90 ; не имеет аргументов вообще
1
sub_100018B0 ; берет один аргумент (1)
esp, 8
; выбрасывает из стека два аргумента
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
60
MSVC и OllyDbg
Попробуем этот же пример в OllyDbg. Это один из наиболее популярных win32отладчиков пользовательского режима. Мы можем компилировать наш пример в MSVC 2012 с опцией /MD что означает линковать с библиотекой MSVCR*.DLL,
чтобы импортируемые функции были хорошо видны в отладчике.
Затем загружаем исполняемый файл в OllyDbg. Самая первая точка останова в
ntdll.dll, нажмите F9 (запустить). Вторая точка останова в CRT-коде. Теперь
мы должны найти функцию main().
Найдите этот код, прокрутив окно кода до самого верха (MSVC располагает
функцию main() в самом начале секции кода):
Рис. 1.8: OllyDbg: самое начало функции main()
Кликните на инструкции PUSH EBP, нажмите F2 (установка точки останова) и
нажмите F9 (запустить). Нам нужно произвести все эти манипуляции, чтобы
пропустить CRT-код, потому что нам он пока не интересен.
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
61
Нажмите F8 (сделать шаг, не входя в функцию) 6 раз, т.е. пропустить 6 инструкций:
Рис. 1.9: OllyDbg: перед исполнением printf()
Теперь PC указывает на инструкцию CALL printf. OllyDbg, как и другие отладчики, подсвечивает регистры со значениями, которые изменились. Поэтому
каждый раз когда мы нажимаем F8, EIP изменяется и его значение подсвечивается красным. ESP также меняется, потому что значения заталкиваются в
стек.
Где находятся эти значения в стеке? Посмотрите на правое нижнее окно в
отладчике:
Рис. 1.10: OllyDbg: стек с сохраненными значениями (красная рамка добавлена
в графическом редакторе)
Здесь видно 3 столбца: адрес в стеке, значение в стеке и ещё дополнительный
комментарий от OllyDbg. OllyDbg может находить указатели на ASCII-строки в
стеке, так что он показывает здесь printf()-строку.
Можно кликнуть правой кнопкой мыши на строке формата, кликнуть на «Follow
in dump» и строка формата появится в окне слева внизу, где всегда виден
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
62
какой-либо участок памяти. Эти значения в памяти можно редактировать. Можно изменить саму строку формата, и тогда результат работы нашего примера
будет другой. В данном случае пользы от этого немного, но для упражнения
это полезно, чтобы начать чувствовать как тут всё работает.
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
63
Нажмите F8 (сделать шаг, не входя в функцию).
В консоли мы видим вывод:
a=1; b=2; c=3
Посмотрим как изменились регистры и состояние стека:
Рис. 1.11: OllyDbg после исполнения printf()
Регистр EAX теперь содержит 0xD (13). Всё верно: printf() возвращает количество выведенных символов. Значение EIP изменилось. Действительно, теперь
здесь адрес инструкции после CALL printf. Значения регистров ECX и EDX также изменились. Очевидно, внутренности функции printf() используют их для
каких-то своих нужд.
Очень важно то, что значение ESP не изменилось. И аргументы-значения в стеке также! Мы ясно видим здесь и строку формата и соответствующие ей 3
значения, они всё ещё здесь. Действительно, по соглашению вызовов cdecl,
вызываемая функция не возвращает ESP назад. Это должна делать вызывающая функция (caller).
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
64
Нажмите F8 снова, чтобы исполнилась инструкция ADD ESP, 10:
Рис. 1.12: OllyDbg: после исполнения инструкции ADD ESP, 10
ESP изменился, но значения всё ещё в стеке! Конечно, никому не нужно заполнять эти значения нулями или что-то в этом роде. Всё что выше указателя
стека (SP) это шум или мусор и не имеет особой ценности. Было бы очень затратно по времени очищать ненужные элементы стека, к тому же, никому это
и не нужно.
GCC
Скомпилируем то же самое в Linux при помощи GCC 4.4.1 и посмотрим на результат в IDA:
main
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
65
call
mov
leave
retn
endp
main
_printf
eax, 0
Можно сказать, что этот короткий код, созданный GCC, отличается от кода
MSVC только способом помещения значений в стек. Здесь GCC снова работает
со стеком напрямую без PUSH/POP.
GCC и GDB
Попробуем также этот пример и в GDB66 в Linux.
-g означает генерировать отладочную информацию в выходном исполняемом
файле.
$ gcc 1.c −g −o 1
$ gdb 1
GNU gdb (GDB) 7.6.1−ubuntu
...
Reading symbols from /home/dennis/polygon/1...done.
Листинг 1.48: установим точку останова на printf()
(gdb) b printf
Breakpoint 1 at 0x80482f0
Запускаем. У нас нет исходного кода функции, поэтому GDB не может его показать.
(gdb) run
Starting program: /home/dennis/polygon/1
Breakpoint 1, __printf (format=0x80484f0 "a=%d; b=%d; c=%d") at printf.c:29
29
printf.c: No such file or directory.
Выдать 10 элементов стека. Левый столбец — это адрес в стеке.
(gdb) x/10w $esp
0xbffff11c:
0x0804844a
0xbffff12c:
0x00000003
0xbffff13c:
0xb7e29905
0x080484f0
0x08048460
0x00000001
0x00000001
0x00000000
0x00000002
0x00000000
Самый первый элемент это RA (0x0804844a). Мы можем удостовериться в этом,
дизассемблируя память по этому адресу:
66 GNU
Debugger
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
Две инструкции XCHG это холостые инструкции, аналогичные NOP.
Второй элемент (0x080484f0) это адрес строки формата:
(gdb) x/s 0x080484f0
0x80484f0:
"a=%d; b=%d; c=%d"
Остальные 3 элемента (1, 2, 3) это аргументы функции printf(). Остальные
элементы это может быть и мусор в стеке, но могут быть и значения от других
функций, их локальные переменные, итд. Пока что мы можем игнорировать
их.
Исполняем «finish». Это значит исполнять все инструкции до самого конца
функции. В данном случае это означает исполнять до завершения printf().
(gdb) finish
Run till exit from #0 __printf (format=0x80484f0 "a=%d; b=%d; c=%d") at ⤦
Ç printf.c:29
main () at 1.c:6
6
return 0;
Value returned is $2 = 13
GDB показывает, что вернула printf() в EAX (13). Это, так же как и в примере
с OllyDbg, количество напечатанных символов.
А ещё мы видим «return 0;» и что это выражение находится в файле 1.c в строке 6. Действительно, файл 1.c лежит в текущем директории и GDB находит
там эту строку. Как GDB знает, какая строка Си-кода сейчас исполняется? Компилятор, генерируя отладочную информацию, также сохраняет информацию
о соответствии строк в исходном коде и адресов инструкций. GDB это всё-таки
отладчик уровня исходных текстов.
Посмотрим регистры. 13 в EAX:
(gdb) info registers
eax
0xd
13
ecx
0x0
0
edx
0x0
0
ebx
0xb7fc0000
esp
0xbffff120
ebp
0xbffff138
esi
0x0
0
edi
0x0
0
eip
0x804844a
...
−1208221696
0xbffff120
0xbffff138
0x804844a
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
67
Попробуем дизассемблировать текущие инструкции. Стрелка указывает на инструкцию, которая будет исполнена следующей.
(gdb) disas
Dump of assembler code for function main:
0x0804841d :
push
%ebp
0x0804841e :
mov
%esp,%ebp
0x08048420 :
and
$0xfffffff0,%esp
0x08048423 :
sub
$0x10,%esp
0x08048426 :
movl
$0x3,0xc(%esp)
0x0804842e :
movl
$0x2,0x8(%esp)
0x08048436 :
movl
$0x1,0x4(%esp)
0x0804843e :
movl
$0x80484f0,(%esp)
0x08048445 :
call
0x80482f0
=> 0x0804844a :
mov
$0x0,%eax
0x0804844f :
leave
0x08048450 :
ret
End of assembler dump.
По умолчанию GDB показывает дизассемблированный листинг в формате AT&T.
Но можно также переключиться в формат Intel:
(gdb) set disassembly−flavor intel
(gdb) disas
Dump of assembler code for function main:
0x0804841d :
push
ebp
0x0804841e :
mov
ebp,esp
0x08048420 :
and
esp,0xfffffff0
0x08048423 :
sub
esp,0x10
0x08048426 :
mov
DWORD PTR [esp+0xc],0x3
0x0804842e :
mov
DWORD PTR [esp+0x8],0x2
0x08048436 :
mov
DWORD PTR [esp+0x4],0x1
0x0804843e :
mov
DWORD PTR [esp],0x80484f0
0x08048445 :
call
0x80482f0
=> 0x0804844a :
mov
eax,0x0
0x0804844f :
leave
0x08048450 :
ret
End of assembler dump.
Исполняем следующую строку Си/Си++-кода. GDB покажет закрывающуюся
скобку, означая, что это конец блока в функции.
(gdb) step
7
};
Посмотрим регистры после исполнения инструкции MOV EAX, 0. EAX здесь уже
действительно ноль.
(gdb) info registers
eax
0x0
0
ecx
0x0
0
edx
0x0
0
ebx
0xb7fc0000
−1208221696
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
68
esp
ebp
esi
edi
eip
...
0xbffff120
0xbffff138
0x0
0
0x0
0
0x804844f
0xbffff120
0xbffff138
0x804844f
x64: 8 целочисленных аргументов
Для того чтобы посмотреть, как остальные аргументы будут передаваться через стек, изменим пример ещё раз, увеличив количество передаваемых аргументов до 9 (строка формата printf() и 8 переменных типа int):
#include
int main()
{
printf("a=%d; b=%d; c=%d; d=%d; e=%d; f=%d; g=%d; h=%d\n", 1, 2, 3,⤦
Ç 4, 5, 6, 7, 8);
return 0;
};
MSVC
Как уже было сказано ранее, первые 4 аргумента в Win64 передаются в регистрах RCX, RDX, R8, R9, а остальные — через стек. Здесь мы это и видим. Впрочем,
инструкция PUSH не используется, вместо неё при помощи MOV значения сразу
записываются в стек.
Листинг 1.49: MSVC 2012 x64
$SG2923 DB
main
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
69
main
_TEXT
END
ret
ENDP
ENDS
0
Наблюдательный читатель может спросить, почему для значений типа int отводится 8 байт, ведь нужно только 4? Да, это нужно запомнить: для значений
всех типов более коротких чем 64-бита, отводится 8 байт. Это сделано для
удобства: так всегда легко рассчитать адрес того или иного аргумента. К тому же, все они расположены по выровненным адресам в памяти. В 32-битных
средах точно также: для всех типов резервируется 4 байта в стеке.
GCC
В *NIX-системах для x86-64 ситуация похожая, вот только первые 6 аргументов передаются через RDI, RSI, RDX, RCX, R8, R9. Остальные — через стек. GCC
генерирует код, записывающий указатель на строку в EDI вместо RDI — это
мы уже рассмотрели чуть раньше: 1.5.2 (стр. 22).
Почему перед вызовом printf() очищается регистр EAX мы уже рассмотрели
ранее 1.5.2 (стр. 22).
Листинг 1.50: Оптимизирующий GCC 4.4.6 x64
.LC0:
.string "a=%d; b=%d; c=%d; d=%d; e=%d; f=%d; g=%d; h=%d\n"
main:
sub
Самый первый элемент стека, как и в прошлый раз, это RA. Через стек также
передаются 3 значения: 6, 7, 8. Видно, что 8 передается с неочищенной старшей 32-битной частью: 0x00007fff00000008. Это нормально, ведь передаются
числа типа int, а они 32-битные. Так что в старшей части регистра или памяти
стека остался «случайный мусор».
GDB показывает всю функцию main(), если попытаться посмотреть, куда вернется управление после исполнения printf().
(gdb) set disassembly−flavor intel
(gdb) disas 0x0000000000400576
Dump of assembler code for function main:
0x000000000040052d :
push
rbp
0x000000000040052e :
mov
rbp,rsp
0x0000000000400531 :
sub
rsp,0x20
0x0000000000400535 :
mov
DWORD PTR [rsp+0x10],0x8
0x000000000040053d :
mov
DWORD PTR [rsp+0x8],0x7
0x0000000000400545 :
mov
DWORD PTR [rsp],0x6
0x000000000040054c :
mov
r9d,0x5
0x0000000000400552 :
mov
r8d,0x4
0x0000000000400558 :
mov
ecx,0x3
0x000000000040055d :
mov
edx,0x2
0x0000000000400562 :
mov
esi,0x1
0x0000000000400567 :
mov
edi,0x400628
0x000000000040056c :
mov
eax,0x0
0x0000000000400571 :
call
0x400410
0x0000000000400576 :
mov
eax,0x0
0x000000000040057b :
leave
0x000000000040057c :
ret
End of assembler dump.
Заканчиваем исполнение printf(), исполняем инструкцию обнуляющую EAX,
удостоверяемся что в регистре EAX именно ноль. RIP указывает сейчас на инструкцию LEAVE, т.е. предпоследнюю в функции main().
(gdb) finish
Run till exit from #0 __printf (format=0x400628 "a=%d; b=%d; c=%d; d=%d; e⤦
Ç =%d; f=%d; g=%d; h=%d\n") at printf.c:29
a=1; b=2; c=3; d=4; e=5; f=6; g=7; h=8
main () at 2.c:6
6
return 0;
Value returned is $1 = 39
(gdb) next
7
};
(gdb) info registers
rax
0x0
0
rbx
0x0
0
rcx
0x26
38
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1.11.2. ARM
ARM: 3 целочисленных аргумента
В ARM традиционно принята такая схема передачи аргументов в функцию: 4
первых аргумента через регистры R0-R3; а остальные — через стек. Это немного похоже на то, как аргументы передаются в fastcall (6.1.3 (стр. 958)) или
win64 (6.1.5 (стр. 960)).
32-битный ARM
Итак, первые 4 аргумента передаются через регистры R0-R3, по порядку: указатель на формат-строку для printf() в R0, затем 1 в R1, 2 в R2 и 3 в R3.
Инструкция на 0x18 записывает 0 в R0 — это выражение в Си return 0. Пока что
здесь нет ничего необычного. Оптимизирующий Keil 6/2013 генерирует точно
такой же код.
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
Здесь нет особых отличий от неоптимизированного варианта для режима ARM.
Оптимизирующий Keil 6/2013 (Режим ARM) + убираем return
Немного переделаем пример, убрав return 0:
#include
void main()
{
printf("a=%d; b=%d; c=%d", 1, 2, 3);
};
Это оптимизированная версия (-O3) для режима ARM, и здесь мы видим последнюю инструкцию B вместо привычной нам BL. Отличия между этой оптимизированной версией и предыдущей, скомпилированной без оптимизации, ещё и
в том, что здесь нет пролога и эпилога функции (инструкций, сохраняющих
состояние регистров R0 и LR). Инструкция B просто переходит на другой адрес, без манипуляций с регистром LR, то есть это аналог JMP в x86. Почему это
работает нормально? Потому что этот код эквивалентен предыдущему.
Основных причин две: 1) стек не модифицируется, как и указатель стека SP; 2)
вызов функции printf() последний, после него ничего не происходит. Функция printf(), отработав, просто возвращает управление по адресу, записанному в LR.
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
74
Но в LR находится адрес места, откуда была вызвана наша функция! А следовательно, управление из printf() вернется сразу туда.
Значит нет нужды сохранять LR, потому что нет нужды модифицировать LR. А
нет нужды модифицировать LR, потому что нет иных вызовов функций, кроме
printf(), к тому же, после этого вызова не нужно ничего здесь больше делать!
Поэтому такая оптимизация возможна.
Эта оптимизация часто используется в функциях, где последнее выражение —
это вызов другой функции.
Ещё один похожий пример описан здесь: 1.21.1 (стр. 204).
ARM64
Неоптимизирующий GCC (Linaro) 4.9
Листинг 1.56: Неоптимизирующий GCC (Linaro) 4.9
.LC1:
.string "a=%d; b=%d; c=%d"
f2:
; сохранить FP и LR в стековом фрейме:
stp
x29, x30, [sp, −16]!
; установить стековый фрейм (FP=SP):
add
x29, sp, 0
adrp
x0, .LC1
add
x0, x0, :lo12:.LC1
mov
w1, 1
mov
w2, 2
mov
w3, 3
bl
printf
mov
w0, 0
; восстановить FP и LR
ldp
x29, x30, [sp], 16
ret
Итак, первая инструкция STP (Store Pair) сохраняет FP (X29) и LR (X30) в стеке.
Вторая инструкция ADD X29, SP, 0 формирует стековый фрейм. Это просто
запись значения SP в X29.
Далее уже знакомая пара инструкций ADRP/ADD формирует указатель на строку.
lo12 означает младшие 12 бит, т.е., линкер запишет младшие 12 бит адреса
метки LC1 в опкод инструкции ADD. 1, 2 и 3 это 32-битные значения типа int,
так что они загружаются в 32-битные части регистров 67
Оптимизирующий GCC (Linaro) 4.9 генерирует почти такой же код.
67 Изменение 1 на 1L изменит эту константу на 64-битную и она будет загружаться в 64-битный
регистр. См. о целочисленных константах/литералах: 1, 2.
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
Этот код можно условно разделить на несколько частей:
• Пролог функции:
Самая первая инструкция STR LR, [SP,#var_4]! сохраняет в стеке LR, ведь
нам придется использовать этот регистр для вызова printf(). Восклицательный знак в конце означает pre-index. Это значит, что в начале SP
должно быть уменьшено на 4, затем по адресу в SP должно быть записано
значение LR.
Это аналог знакомой в x86 инструкции PUSH. Читайте больше об этом:
1.39.2 (стр. 565).
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
76
Вторая инструкция SUB SP, SP, #0x14 уменьшает указатель стека SP, но,
на самом деле, эта процедура нужна для выделения в локальном стеке
места размером 0x14 (20) байт. Действительно, нам нужно передать 5
32-битных значений через стек в printf(). Каждое значение занимает
4 байта, все вместе — 5 ∗ 4 = 20. Остальные 4 32-битных значения будут
переданы через регистры.
• Передача 5, 6, 7 и 8 через стек: они записываются в регистры R0, R1, R2 и
R3 соответственно.
Затем инструкция ADD R12, SP, #0x18+var_14 записывает в регистр R12
адрес места в стеке, куда будут помещены эти 4 значения. var_14 — это
макрос ассемблера, равный -0x14. Такие макросы создает IDA, чтобы удобнее было показывать, как код обращается к стеку.
Макросы var_?, создаваемые IDA, отражают локальные переменные в стеке. Так что в R12 будет записано SP+4.
Следующая инструкция STMIA R12, R0-R3 записывает содержимое регистров R0-R3 по адресу в памяти, на который указывает R12.
Инструкция STMIA означает Store Multiple Increment After.
Increment After означает, что R12 будет увеличиваться на 4 после записи
каждого значения регистра.
• Передача 4 через стек: 4 записывается в R0, затем инструкция STR R0,
[SP,#0x18+var_18] записывает его в стек. var_18 равен -0x18, смещение
будет 0, так что значение из регистра R0 (4) запишется туда, куда указывает SP.
• Передача 1, 2 и 3 через регистры:
Значения для первых трех чисел (a, b, c) (1, 2, 3 соответственно) передаются в регистрах R1, R2 и R3 перед самим вызовом printf().
• Вызов printf().
• Эпилог функции:
Инструкция ADD SP, SP, #0x14 возвращает SP на прежнее место, аннулируя таким образом всё, что было записано в стек. Конечно, то что было
записано в стек, там пока и останется, но всё это будет многократно перезаписано во время исполнения последующих функций.
Инструкция LDR PC, [SP+4+var_4],#4 загружает в PC сохраненное значение LR из стека, обеспечивая таким образом выход из функции.
Здесь нет восклицательного знака — действительно, сначала PC загружается из места, куда указывает SP (4 + var_4 = 4 + (−4) = 0, так что эта
инструкция аналогична LDR PC, [SP],#4), затем SP увеличивается на 4.
Это называется post-index68 . Почему IDA показывает инструкцию именно
так? Потому что она хочет показать разметку стека и тот факт, что var_4
выделена в локальном стеке именно для сохраненного значения LR. Эта
инструкция в каком-то смысле аналогична POP PC в x86 69 .
68 Читайте
69 В
больше об этом: 1.39.2 (стр. 565).
x86 невозможно установить значение IP/EIP/RIP используя POP, но будем надеяться, вы
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
Это почти то же самое что и в предыдущем примере, только код для Thumb и
значения помещаются в стек немного иначе: сначала 8 за первый раз, затем 5,
6, 7 за второй раз и 4 за третий раз.
Оптимизирующий Xcode 4.6.3 (LLVM): Режим ARM
__text:0000290C
__text:0000290C
__text:0000290C
__text:0000290C
__text:0000290C
__text:0000290C
__text:00002910
__text:00002914
__text:00002918
__text:0000291C
__text:00002920
__text:00002924
__text:00002928
Почти то же самое, что мы уже видели, за исключением того, что STMFA (Store
Multiple Full Ascending) — это синоним инструкции STMIB (Store Multiple Increment
Before). Эта инструкция увеличивает SP и только затем записывает в память
значение очередного регистра, но не наоборот.
Далее бросается в глаза то, что инструкции как будто бы расположены случайно. Например, значение в регистре R0 подготавливается в трех местах, по
адресам 0x2918, 0x2920 и 0x2928, когда это можно было бы сделать в одном
месте. Однако, у оптимизирующего компилятора могут быть свои доводы о
том, как лучше составлять инструкции друг с другом для лучшей эффективности исполнения. Процессор обычно пытается исполнять одновременно идущие друг за другом инструкции. К примеру, инструкции MOVT R0, #0 и ADD R0,
PC, R0 не могут быть исполнены одновременно, потому что обе инструкции
модифицируют регистр R0. А вот инструкции MOVT R0, #0 и MOV R2, #4 легко
можно исполнить одновременно, потому что эффекты от их исполнения никак
не конфликтуют друг с другом. Вероятно, компилятор старается генерировать
код именно таким образом там, где это возможно.
Оптимизирующий Xcode 4.6.3 (LLVM): Режим Thumb-2
__text:00002BA0
__text:00002BA0
__text:00002BA0
__text:00002BA0
__text:00002BA0
__text:00002BA0
__text:00002BA0
__text:00002BA2
__text:00002BA4
__text:00002BA6
__text:00002BAA
__text:00002BAE
__text:00002BB2
__text:00002BB4
__text:00002BB6
__text:00002BB8
__text:00002BBA
__text:00002BBE
Почти то же самое, что и в предыдущем примере, лишь за тем исключением,
что здесь используются Thumb/Thumb-2-инструкции.
ARM64
Неоптимизирующий GCC (Linaro) 4.9
Листинг 1.57: Неоптимизирующий GCC (Linaro) 4.9
.LC2:
.string "a=%d; b=%d; c=%d; d=%d; e=%d; f=%d; g=%d; h=%d\n"
f3:
; выделить больше места в стеке:
sub
sp, sp, #32
; сохранить FP и LR в стековом фрейме:
stp
x29, x30, [sp,16]
; установить указатель фрейма (FP=SP+16):
add
x29, sp, 16
adrp
x0, .LC2 ; "a=%d; b=%d; c=%d; d=%d; e=%d; f=%d; g=%d; h=%d\n"
add
x0, x0, :lo12:.LC2
mov
w1, 8
; 9-й аргумент
str
w1, [sp]
; сохранить 9-й аргумент в стеке
mov
w1, 1
mov
w2, 2
mov
w3, 3
mov
w4, 4
mov
w5, 5
mov
w6, 6
mov
w7, 7
bl
printf
sub
sp, x29, #16
; восстановить FP и LR
ldp
x29, x30, [sp,16]
add
sp, sp, 32
ret
Первые 8 аргументов передаются в X- или W-регистрах: [Procedure Call Standard
for the ARM 64-bit Architecture (AArch64), (2013)]70 . Указатель на строку требует
70 Также
доступно здесь:
IHI0055B_aapcs64.pdf
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
80
64-битного регистра, так что он передается в X0. Все остальные значения имеют 32-битный тип int, так что они записываются в 32-битные части регистров
(W-). Девятый аргумент (8) передается через стек. Действительно, невозможно передать большое количество аргументов в регистрах, потому что количество регистров ограничено.
Оптимизирующий GCC (Linaro) 4.9 генерирует почти такой же код.
1.11.3. MIPS
3 целочисленных аргумента
Оптимизирующий GCC 4.4.5
Главное отличие от примера «Hello, world!» в том, что здесь на самом деле
вызывается printf() вместо puts() и ещё три аргумента передаются в регистрах $5…$7 (или $A0…$A2). Вот почему эти регистры имеют префикс A-. Это
значит, что они используются для передачи аргументов.
Листинг 1.58: Оптимизирующий GCC 4.4.5 (вывод на ассемблере)
$LC0:
.ascii "a=%d; b=%d; c=%d\000"
main:
; пролог функции:
lui
$28,%hi(__gnu_local_gp)
addiu
$sp,$sp,−32
addiu
$28,$28,%lo(__gnu_local_gp)
sw
$31,28($sp)
; загрузить адрес printf():
lw
$25,%call16(printf)($28)
; загрузить адрес текстовой строки и установить первый аргумент printf():
lui
$4,%hi($LC0)
addiu
$4,$4,%lo($LC0)
; установить второй аргумент printf():
li
$5,1
# 0x1
; установить третий аргумент printf():
li
$6,2
# 0x2
; вызов printf():
jalr
$25
; установить четвертый аргумент printf() (branch delay slot):
li
$7,3
# 0x3
; эпилог функции:
lw
$31,28($sp)
; установить возвращаемое значение в 0:
move
$2,$0
; возврат
j
$31
addiu
$sp,$sp,32 ; branch delay slot
Листинг 1.59: Оптимизирующий GCC 4.4.5 (IDA)
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
81
.text:00000000 main:
.text:00000000
.text:00000000 var_10
= −0x10
.text:00000000 var_4
= −4
.text:00000000
; пролог функции:
.text:00000000
lui
$gp, (__gnu_local_gp >> 16)
.text:00000004
addiu
$sp, −0x20
.text:00000008
la
$gp, (__gnu_local_gp & 0xFFFF)
.text:0000000C
sw
$ra, 0x20+var_4($sp)
.text:00000010
sw
$gp, 0x20+var_10($sp)
; загрузить адрес printf():
.text:00000014
lw
$t9, (printf & 0xFFFF)($gp)
; загрузить адрес текстовой строки и установить первый аргумент printf():
.text:00000018
la
$a0, $LC0
# "a=%d; b=%d; c=%d"
; установить второй аргумент printf():
.text:00000020
li
$a1, 1
; установить третий аргумент printf():
.text:00000024
li
$a2, 2
; вызов printf():
.text:00000028
jalr
$t9
; установить четвертый аргумент printf() (branch delay slot):
.text:0000002C
li
$a3, 3
; эпилог функции:
.text:00000030
lw
$ra, 0x20+var_4($sp)
; установить возвращаемое значение в 0:
.text:00000034
move
$v0, $zero
; возврат
.text:00000038
jr
$ra
.text:0000003C
addiu
$sp, 0x20 ; branch delay slot
IDA объединила пару инструкций LUI и ADDIU в одну псевдоинструкцию LA. Вот
почему здесь нет инструкции по адресу 0x1C: потому что LA занимает 8 байт.
Неоптимизирующий GCC 4.4.5
Неоптимизирующий GCC более многословен:
Листинг 1.60: Неоптимизирующий GCC 4.4.5 (вывод на ассемблере)
$LC0:
.ascii "a=%d; b=%d; c=%d\000"
main:
; пролог функции:
addiu
$sp,$sp,−32
sw
$31,28($sp)
sw
$fp,24($sp)
move
$fp,$sp
lui
$28,%hi(__gnu_local_gp)
addiu
$28,$28,%lo(__gnu_local_gp)
; загрузить адрес текстовой строки:
lui
$2,%hi($LC0)
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
82
;
;
;
;
;
;
addiu
$2,$2,%lo($LC0)
установить первый аргумент printf():
move
$4,$2
установить второй аргумент printf():
li
$5,1
# 0x1
установить третий аргумент printf():
li
$6,2
# 0x2
установить четвертый аргумент printf():
li
$7,3
# 0x3
получить адрес printf():
lw
$2,%call16(printf)($28)
nop
вызов printf():
move
$25,$2
jalr
$25
nop
; эпилог функции:
lw
$28,16($fp)
; установить возвращаемое значение в 0:
move
$2,$0
move
$sp,$fp
lw
$31,28($sp)
lw
$fp,24($sp)
addiu
$sp,$sp,32
; возврат
j
$31
nop
Листинг 1.61: Неоптимизирующий GCC 4.4.5 (IDA)
.text:00000000 main:
.text:00000000
.text:00000000 var_10
= −0x10
.text:00000000 var_8
= −8
.text:00000000 var_4
= −4
.text:00000000
; пролог функции:
.text:00000000
addiu
$sp,
.text:00000004
sw
$ra,
.text:00000008
sw
$fp,
.text:0000000C
move
$fp,
.text:00000010
la
$gp,
.text:00000018
sw
$gp,
; загрузить адрес текстовой строки:
.text:0000001C
la
$v0,
; установить первый аргумент printf():
.text:00000024
move
$a0,
; установить второй аргумент printf():
.text:00000028
li
$a1,
; установить третий аргумент printf():
.text:0000002C
li
$a2,
; установить четвертый аргумент printf():
.text:00000030
li
$a3,
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
83
; получить адрес printf():
.text:00000034
lw
$v0, (printf & 0xFFFF)($gp)
.text:00000038
or
$at, $zero
; вызов printf():
.text:0000003C
move
$t9, $v0
.text:00000040
jalr
$t9
.text:00000044
or
$at, $zero ; NOP
; эпилог функции:
.text:00000048
lw
$gp, 0x20+var_10($fp)
; установить возвращаемое значение в 0:
.text:0000004C
move
$v0, $zero
.text:00000050
move
$sp, $fp
.text:00000054
lw
$ra, 0x20+var_4($sp)
.text:00000058
lw
$fp, 0x20+var_8($sp)
.text:0000005C
addiu
$sp, 0x20
; возврат
.text:00000060
jr
$ra
.text:00000064
or
$at, $zero ; NOP
8 целочисленных аргументов
Снова воспользуемся примером с 9-ю аргументами из предыдущей секции:
1.11.1 (стр. 68).
#include
int main()
{
printf("a=%d; b=%d; c=%d; d=%d; e=%d; f=%d; g=%d; h=%d\n", 1, 2, 3,⤦
Ç 4, 5, 6, 7, 8);
return 0;
};
Оптимизирующий GCC 4.4.5
Только 4 первых аргумента передаются в регистрах $A0 …$A3, так что остальные передаются через стек.
Это соглашение о вызовах O32 (самое популярное в мире MIPS). Другие соглашения о вызовах, или вручную написанный код на ассемблере, могут наделять
регистры другими функциями.
SW означает «Store Word» (записать слово из регистра в память). В MIPS нет инструкции для записи значения в память, так что для этого используется пара
инструкций (LI/SW).
Листинг 1.62: Оптимизирующий GCC 4.4.5 (вывод на ассемблере)
$LC0:
.ascii
main:
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
84
; пролог функции:
lui
$28,%hi(__gnu_local_gp)
addiu
$sp,$sp,−56
addiu
$28,$28,%lo(__gnu_local_gp)
sw
$31,52($sp)
; передать 5-й аргумент в стеке:
li
$2,4
# 0x4
sw
$2,16($sp)
; передать 6-й аргумент в стеке:
li
$2,5
# 0x5
sw
$2,20($sp)
; передать 7-й аргумент в стеке:
li
$2,6
# 0x6
sw
$2,24($sp)
; передать 8-й аргумент в стеке:
li
$2,7
# 0x7
lw
$25,%call16(printf)($28)
sw
$2,28($sp)
; передать 1-й аргумент в $a0:
lui
$4,%hi($LC0)
; передать 9-й аргумент в стеке:
li
$2,8
# 0x8
sw
$2,32($sp)
addiu
$4,$4,%lo($LC0)
; передать 2-й аргумент в $a1:
li
$5,1
# 0x1
; передать 3-й аргумент в $a2:
li
$6,2
# 0x2
; вызов printf():
jalr
$25
; передать 4-й аргумент в $a3 (branch delay slot):
li
$7,3
# 0x3
; эпилог функции:
lw
$31,52($sp)
; установить возвращаемое значение в 0:
move
$2,$0
; возврат
j
$31
addiu
$sp,$sp,56 ; branch delay slot
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
85
.text:00000000
lui
$gp,
.text:00000004
addiu
$sp,
.text:00000008
la
$gp,
.text:0000000C
sw
$ra,
.text:00000010
sw
$gp,
; передать 5-й аргумент в стеке:
.text:00000014
li
$v0,
.text:00000018
sw
$v0,
; передать 6-й аргумент в стеке:
.text:0000001C
li
$v0,
.text:00000020
sw
$v0,
; передать 7-й аргумент в стеке:
.text:00000024
li
$v0,
.text:00000028
sw
$v0,
; передать 8-й аргумент в стеке:
.text:0000002C
li
$v0,
.text:00000030
lw
$t9,
.text:00000034
sw
$v0,
; готовить 1-й аргумент в $a0:
.text:00000038
lui
$a0,
c=%d; d=%d; e=%d; f=%d; g=%"...
; передать 9-й аргумент в стеке:
.text:0000003C
li
$v0,
.text:00000040
sw
$v0,
; передать 1-й аргумент в $a0:
.text:00000044
la
$a0,
c=%d; d=%d; e=%d; f=%d; g=%"...
; передать 2-й аргумент в $a1:
.text:00000048
li
$a1,
; передать 3-й аргумент в $a2:
.text:0000004C
li
$a2,
; вызов printf():
.text:00000050
jalr
$t9
; передать 4-й аргумент в $a3 (branch delay
.text:00000054
li
$a3,
; эпилог функции:
.text:00000058
lw
$ra,
; установить возвращаемое значение в 0:
.text:0000005C
move
$v0,
; возврат
.text:00000060
jr
$ra
.text:00000064
addiu
$sp,
Неоптимизирующий GCC 4.4.5
Неоптимизирующий GCC более многословен:
Листинг 1.64: Неоптимизирующий GCC 4.4.5 (вывод на ассемблере)
$LC0:
.ascii "a=%d; b=%d; c=%d; d=%d; e=%d; f=%d; g=%d; h=%d\012\000"
main:
; пролог функции:
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
86
;
;
;
;
;
;
;
;
;
;
;
;
;
addiu
$sp,$sp,−56
sw
$31,52($sp)
sw
$fp,48($sp)
move
$fp,$sp
lui
$28,%hi(__gnu_local_gp)
addiu
$28,$28,%lo(__gnu_local_gp)
lui
$2,%hi($LC0)
addiu
$2,$2,%lo($LC0)
передать 5-й аргумент в стеке:
li
$3,4
# 0x4
sw
$3,16($sp)
передать 6-й аргумент в стеке:
li
$3,5
# 0x5
sw
$3,20($sp)
передать 7-й аргумент в стеке:
li
$3,6
# 0x6
sw
$3,24($sp)
передать 8-й аргумент встеке:
li
$3,7
# 0x7
sw
$3,28($sp)
передать 9-й аргумент в стеке:
li
$3,8
# 0x8
sw
$3,32($sp)
передать 1-й аргумент в $a0:
move
$4,$2
передать 2-й аргумент в $a1:
li
$5,1
# 0x1
передать 3-й аргумент в $a2:
li
$6,2
# 0x2
передать 4-й аргумент в $a3:
li
$7,3
# 0x3
вызов printf():
lw
$2,%call16(printf)($28)
nop
move
$25,$2
jalr
$25
nop
эпилог функции:
lw
$28,40($fp)
установить возвращаемое значение в 0:
move
$2,$0
move
$sp,$fp
lw
$31,52($sp)
lw
$fp,48($sp)
addiu
$sp,$sp,56
возврат
j
$31
nop
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
88
; возврат
.text:00000088
.text:0000008C
jr
or
$ra
$at, $zero ; NOP
1.11.4. Вывод
Вот примерный скелет вызова функции:
Листинг 1.66: x86
...
PUSH третий аргумент
PUSH второй аргумент
PUSH первый аргумент
CALL функция
; модифицировать указатель стека (если нужно)
Листинг 1.67: x64 (MSVC)
MOV RCX, первый аргумент
MOV RDX, второй аргумент
MOV R8, третий аргумент
MOV R9, 4-й аргумент
...
PUSH 5-й, 6-й аргумент, и т.д. (если нужно)
CALL функция
; модифицировать указатель стека (если нужно)
Листинг 1.68: x64 (GCC)
MOV RDI, первый аргумент
MOV RSI, второй аргумент
MOV RDX, третий аргумент
MOV RCX, 4-й аргумент
MOV R8, 5-й аргумент
MOV R9, 6-й аргумент
...
PUSH 7-й, 8-й аргумент, и т.д. (если нужно)
CALL функция
; модифицировать указатель стека (если нужно)
Листинг 1.69: ARM
MOV R0, первый аргумент
MOV R1, второй аргумент
MOV R2, третий аргумент
MOV R3, 4-й аргумент
; передать 5-й, 6-й аргумент, и т.д., в стеке (если нужно)
BL функция
; модифицировать указатель стека (если нужно)
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
89
Листинг 1.70: ARM64
MOV X0, первый аргумент
MOV X1, второй аргумент
MOV X2, третий аргумент
MOV X3, 4-й аргумент
MOV X4, 5-й аргумент
MOV X5, 6-й аргумент
MOV X6, 7-й аргумент
MOV X7, 8-й аргумент
; передать 9-й, 10-й аргумент, и т.д., в стеке (если нужно)
BL функция
; модифицировать указатель стека (если нужно)
Листинг 1.71: MIPS (соглашение о вызовах O32)
LI \$4, первый аргумент ; AKA $A0
LI \$5, второй аргумент ; AKA $A1
LI \$6, третий аргумент ; AKA $A2
LI \$7, 4-й аргумент ; AKA $A3
; передать 5-й, 6-й аргумент, и т.д., в стеке (если нужно)
LW temp_reg, адрес функции
JALR temp_reg
1.11.5. Кстати
Кстати, разница между способом передачи параметров принятая в x86, x64,
fastcall, ARM и MIPS неплохо иллюстрирует тот важный момент, что процессору, в общем, всё равно, как будут передаваться параметры функций. Можно
создать компилятор, который будет передавать их при помощи указателя на
структуру с параметрами, не пользуясь стеком вообще.
Регистры $A0…$A3 в MIPS так названы только для удобства (это соглашение
о вызовах O32). Программисты могут использовать любые другие регистры
(может быть, только кроме $ZERO) для передачи данных или любое другое
соглашение о вызовах.
CPU не знает о соглашениях о вызовах вообще.
Можно также вспомнить, что начинающие программисты на ассемблере передают параметры в другие функции обычно через регистры, без всякого явного
порядка, или даже через глобальные переменные. И всё это нормально работает.
1.12. scanf()
Теперь попробуем использовать scanf().
1.12.1. Простой пример
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
Использовать scanf() в наши времена для того, чтобы спросить у пользователя что-то — не самая хорошая идея. Но так мы проиллюстрируем передачу
указателя на переменную типа int.
Об указателях
Это одна из фундаментальных вещей в программировании. Часто большой массив, структуру или объект передавать в другую функцию путем копирования
данных невыгодно, а передать адрес массива, структуры или объекта куда
проще. Например, если вы собираетесь вывести в консоль текстовую строку,
достаточно только передать её адрес в ядро ОС.
К тому же, если вызываемая функция (callee) должна изменить что-то в этом
большом массиве или структуре, то возвращать её полностью так же абсурдно. Так что самое простое, что можно сделать, это передать в функцию-callee
адрес массива или структуры, и пусть callee что-то там изменит.
Указатель в Си/Си++— это просто адрес какого-либо места в памяти.
В x86 адрес представляется в виде 32-битного числа (т.е. занимает 4 байта), а
в x86-64 как 64-битное число (занимает 8 байт). Кстати, отсюда негодование
некоторых людей, связанное с переходом на x86-64 — на этой архитектуре
все указатели занимают в 2 раза больше места, в том числе и в “дорогой” кэшпамяти.
При некотором упорстве можно работать только с безтиповыми указателями
(void*), например, стандартная функция Си memcpy(), копирующая блок из одного места памяти в другое принимает на вход 2 указателя типа void*, потому
что нельзя заранее предугадать, какого типа блок вы собираетесь копировать.
Для копирования тип данных не важен, важен только размер блока.
Также указатели широко используются, когда функции нужно вернуть более
одного значения (мы ещё вернемся к этому в будущем (3.21 (стр. 779)) ).
Функция scanf()—это как раз такой случай.
Помимо того, что этой функции нужно показать, сколько значений было прочитано успешно, ей ещё и нужно вернуть сами значения.
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
91
Тип указателя в Си/Си++нужен только для проверки типов на стадии компиляции.
Внутри, в скомпилированном коде, никакой информации о типах указателей
нет вообще.
x86
MSVC
Что получаем на ассемблере, компилируя в MSVC 2010:
CONST
SEGMENT
$SG3831
DB
'Enter X:', 0aH, 00H
$SG3832
DB
'%d', 00H
$SG3833
DB
'You entered %d...', 0aH, 00H
CONST
ENDS
PUBLIC
_main
EXTRN
_scanf:PROC
EXTRN
_printf:PROC
; Function compile flags: /Odtp
_TEXT
SEGMENT
_x$ = −4
; size = 4
_main
PROC
push
ebp
mov
ebp, esp
push
ecx
push
OFFSET $SG3831 ; 'Enter X:'
call
_printf
add
esp, 4
lea
eax, DWORD PTR _x$[ebp]
push
eax
push
OFFSET $SG3832 ; '%d'
call
_scanf
add
esp, 8
mov
ecx, DWORD PTR _x$[ebp]
push
ecx
push
OFFSET $SG3833 ; 'You entered %d...'
call
_printf
add
esp, 8
; возврат 0
xor
eax, eax
mov
esp, ebp
pop
ebp
ret
0
_main
ENDP
_TEXT
ENDS
Переменная x является локальной.
По стандарту Си/Си++она доступна только из этой же функции и нигде более. Так получилось, что локальные переменные располагаются в стеке. МоЕсли вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
92
жет быть, можно было бы использовать и другие варианты, но в x86 это традиционно так.
Следующая после пролога инструкция PUSH ECX не ставит своей целью сохранить значение регистра ECX. (Заметьте отсутствие соответствующей инструкции POP ECX в конце функции).
Она на самом деле выделяет в стеке 4 байта для хранения x в будущем.
Доступ к x будет осуществляться при помощи объявленного макроса _x$ (он
равен -4) и регистра EBP указывающего на текущий фрейм.
Во всё время исполнения функции EBP указывает на текущий фрейм и через
EBP+смещение можно получить доступ как к локальным переменным функции,
так и аргументам функции.
Можно было бы использовать ESP, но он во время исполнения функции часто
меняется, а это не удобно. Так что можно сказать, что EBP это замороженное
состояние ESP на момент начала исполнения функции.
Разметка типичного стекового фрейма в 32-битной среде:
…
EBP-8
EBP-4
EBP
EBP+4
EBP+8
EBP+0xC
EBP+0x10
…
…
локальная переменная #2, маркируется в IDA как var_8
локальная переменная #1, маркируется в IDA как var_4
сохраненное значение EBP
адрес возврата
аргумент#1, маркируется в IDA как arg_0
аргумент#2, маркируется в IDA как arg_4
аргумент#3, маркируется в IDA как arg_8
…
У функции scanf() в нашем примере два аргумента.
Первый — указатель на строку, содержащую %d и второй — адрес переменной
x.
Вначале адрес x помещается в регистр EAX при помощи инструкции lea eax,
DWORD PTR _x$[ebp].
Инструкция LEA означает load effective address, и часто используется для формирования адреса чего-либо (.1.6 (стр. 1308)).
Можно сказать, что в данном случае LEA просто помещает в EAX результат
суммы значения в регистре EBP и макроса _x$.
Это тоже что и lea eax, [ebp-4].
Итак, от значения EBP отнимается 4 и помещается в EAX. Далее значение EAX
заталкивается в стек и вызывается scanf().
После этого вызывается printf(). Первый аргумент вызова строка: You entered
%d...\n.
Второй аргумент: mov ecx, [ebp-4]. Эта инструкция помещает в ECX не адрес
переменной x, а её значение.
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
93
Далее значение ECX заталкивается в стек и вызывается printf().
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
94
MSVC + OllyDbg
Попробуем этот же пример в OllyDbg. Загружаем, нажимаем F8 (сделать шаг,
не входя в функцию) до тех пор, пока не окажемся в своем исполняемом файле,
а не в ntdll.dll. Прокручиваем вверх до тех пор, пока не найдем main(). Щелкаем на первой инструкции (PUSH EBP), нажимаем F2 (set a breakpoint), затем
F9 (Run) и точка останова срабатывает на начале main().
Трассируем до того места, где готовится адрес переменной x:
Рис. 1.13: OllyDbg: вычисляется адрес локальной переменной
На EAX в окне регистров можно нажать правой кнопкой и далее выбрать «Follow
in stack». Этот адрес покажется в окне стека.
Смотрите, это переменная в локальном стеке. Там дорисована красная стрелка. И там сейчас какой-то мусор (0x6E494714). Адрес этого элемента стека сейчас, при помощи PUSH запишется в этот же стек рядом. Трассируем при помощи F8 вплоть до конца исполнения scanf(). А пока scanf() исполняется, в
консольном окне, вводим, например, 123:
Enter X:
123
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
95
Вот тут scanf() отработал:
Рис. 1.14: OllyDbg: scanf() исполнилась
scanf() вернул 1 в EAX, что означает, что он успешно прочитал одно значение.
В наблюдаемом нами элементе стека теперь 0x7B (123).
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
96
Чуть позже это значение копируется из стека в регистр ECX и передается в
printf():
Рис. 1.15: OllyDbg: готовим значение для передачи в printf()
GCC
Попробуем тоже самое скомпилировать в Linux при помощи GCC 4.4.1:
main
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
97
leave
retn
endp
main
GCC заменил первый вызов printf() на puts(). Почему это было сделано, уже
было описано ранее (1.5.3 (стр. 28)).
Далее всё как и прежде — параметры заталкиваются через стек при помощи
MOV.
Кстати
Этот простой пример иллюстрирует то обстоятельство, что компилятор преобразует список выражений в Си/Си++-блоке просто в последовательный набор
инструкций. Между выражениями в Си/Си++ничего нет, и в итоговом машинном коде между ними тоже ничего нет, управление переходит от одной инструкции к следующей за ней.
x64
Всё то же самое, только используются регистры вместо стека для передачи
аргументов функций.
MSVC
Листинг 1.72: MSVC 2012 x64
_DATA
$SG1289
$SG1291
$SG1292
_DATA
SEGMENT
DB
'Enter X:', 0aH, 00H
DB
'%d', 00H
DB
'You entered %d...', 0aH, 00H
ENDS
_TEXT
SEGMENT
x$ = 32
main
PROC
$LN3:
sub
lea
call
lea
lea
call
mov
lea
call
Чтобы scanf() мог вернуть значение, ему нужно передать указатель на переменную типа int. int — 32-битное значение, для его хранения нужно только 4
байта, и оно помещается в 32-битный регистр.
Место для локальной переменной x выделяется в стеке, IDA наименовала её
var_8. Впрочем, место для неё выделять не обязательно, т.к. указатель стека
SP уже указывает на место, свободное для использования. Так что значение
указателя SP копируется в регистр R1, и вместе с format-строкой, передается
в scanf().
Инструкции PUSH/POP в ARM работают иначе, чем в x86 (тут всё наоборот). Это
синонимы инструкций STM/STMDB/LDM/LDMIA. И инструкция PUSH в начале записывает в стек значение, затем вычитает 4 из SP. POP в начале прибавляет
4 к SP, затем читает значение из стека. Так что после PUSH, SP указывает на
неиспользуемое место в стеке. Его и использует scanf(), а затем и printf().
LDMIA означает Load Multiple Registers Increment address After each transfer. STMDB
означает Store Multiple Registers Decrement address Before each transfer.
Позже, при помощи инструкции LDR, это значение перемещается из стека в
регистр R1, чтобы быть переданным в printf().
ARM64
Листинг 1.74: Неоптимизирующий GCC 4.9.1 ARM64
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
.LC0:
.string "Enter X:"
.LC1:
.string "%d"
.LC2:
.string "You entered %d...\n"
scanf_main:
; вычесть 32 из SP, затем сохранить FP и LR в стековом фрейме:
stp
x29, x30, [sp, −32]!
; установить стековый фрейм (FP=SP)
add
x29, sp, 0
; загрузить указатель на строку "Enter X:"
adrp
x0, .LC0
add
x0, x0, :lo12:.LC0
; X0=указатель на строку "Enter X:"
; вывести её:
bl
puts
; загрузить указатель на строку "%d":
adrp
x0, .LC1
add
x0, x0, :lo12:.LC1
; найти место в стековом фрейме для переменной "x" (X1=FP+28):
add
x1, x29, 28
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
; X1=адрес переменной "x"
; передать адрес в scanf() и вызвать её:
bl
__isoc99_scanf
; загрузить 32-битное значение из переменной в стековом фрейме:
ldr
w1, [x29,28]
; W1=x
; загрузить указатель на строку "You entered %d...\n"
; printf() возьмет текстовую строку из X0 и переменную "x" из X1 (или W1)
adrp
x0, .LC2
add
x0, x0, :lo12:.LC2
bl
printf
; возврат 0
mov
w0, 0
; восстановить FP и LR, затем прибавить 32 к SP:
ldp
x29, x30, [sp], 32
ret
Под стековый фрейм выделяется 32 байта, что больше чем нужно. Может быть,
это связано с выравниваем по границе памяти? Самая интересная часть — это
поиск места под переменную x в стековом фрейме (строка 22). Почему 28?
Почему-то, компилятор решил расположить эту переменную в конце стекового фрейма, а не в начале. Адрес потом передается в scanf(), которая просто
сохраняет значение, введенное пользователем, в памяти по этому адресу. Это
32-битное значение типа int. Значение загружается в строке 27 и затем передается в printf().
MIPS
Для переменной x выделено место в стеке, и к нему будут производиться обращения как $sp + 24.
Её адрес передается в scanf(), а значение прочитанное от пользователя загружается используя инструкцию LW («Load Word» — загрузить слово) и затем
оно передается в printf().
Листинг 1.75: Оптимизирующий GCC 4.4.5 (вывод на ассемблере)
$LC0:
.ascii
"Enter X:\000"
.ascii
"%d\000"
$LC1:
$LC2:
.ascii "You entered %d...\012\000"
main:
; пролог функции:
lui
$28,%hi(__gnu_local_gp)
addiu
$sp,$sp,−40
addiu
$28,$28,%lo(__gnu_local_gp)
sw
$31,36($sp)
; вызов puts():
lw
$25,%call16(puts)($28)
lui
$4,%hi($LC0)
jalr
$25
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
101
addiu
$4,$4,%lo($LC0) ; branch delay slot
; вызов scanf():
lw
$28,16($sp)
lui
$4,%hi($LC1)
lw
$25,%call16(__isoc99_scanf)($28)
; установить второй аргумент для scanf(), $a1=$sp+24:
addiu
$5,$sp,24
jalr
$25
addiu
$4,$4,%lo($LC1) ; branch delay slot
; вызов printf():
lw
$28,16($sp)
; установить второй аргумент для printf(),
; загрузить слово по адресу $sp+24:
lw
$5,24($sp)
lw
$25,%call16(printf)($28)
lui
$4,%hi($LC2)
jalr
$25
addiu
$4,$4,%lo($LC2) ; branch delay slot
; эпилог функции:
lw
$31,36($sp)
; установить возвращаемое значение в 0:
move
$2,$0
; возврат:
j
$31
addiu
$sp,$sp,40
; branch delay slot
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
102
; установить второй аргумент для scanf(), $a1=$sp+24:
.text:00000030
addiu
$a1, $sp, 0x28+var_10
.text:00000034
jalr
$t9 ; branch delay slot
.text:00000038
la
$a0, ($LC1 & 0xFFFF) # "%d"
; вызов printf():
.text:0000003C
lw
$gp, 0x28+var_18($sp)
; установить второй аргумент для printf(),
; загрузить слово по адресу $sp+24:
.text:00000040
lw
$a1, 0x28+var_10($sp)
.text:00000044
lw
$t9, (printf & 0xFFFF)($gp)
.text:00000048
lui
$a0, ($LC2 >> 16) # "You entered %d...\n"
.text:0000004C
jalr
$t9
.text:00000050
la
$a0, ($LC2 & 0xFFFF) # "You entered %d...\n"
; branch delay slot
; эпилог функции:
.text:00000054
lw
$ra, 0x28+var_4($sp)
; установить возвращаемое значение в 0:
.text:00000058
move
$v0, $zero
; возврат:
.text:0000005C
jr
$ra
.text:00000060
addiu
$sp, 0x28 ; branch delay slot
1.12.2. Классическая ошибка
Это очень популярная ошибка (и/или опечатка) — передать значение x вместо
указателя на x:
#include
int main()
{
int x;
printf ("Enter X:\n");
scanf ("%d", x); // BUG
printf ("You entered %d...\n", x);
return 0;
};
Что тут происходит? x не инициализирована и содержит случайный шум из
локального стека. Когда вызывается scanf(), ф-ция берет строку от пользователя, парсит её, получает число и пытается записать в x, считая его как адрес
в памяти. Но там случайный шум, так что scanf() будет пытаться писать по
случайному адресу. Скорее всего, процесс упадет.
Интересно что некоторые CRT-библиотеки, в отладочной сборке, заполняют
только что выделенную память визуально различимыми числами вроде 0xCCCCCCCC
или 0x0BADF00D, итд. В этом случае, x может содержать 0xCCCCCCCC, и scanf()
попытается писать по адресу 0xCCCCCCCC. Если вы заметите что что-то в вашем процессе пытается писать по адресу 0xCCCCCCCC, вы поймете, что неиниЕсли вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
103
циализированная переменная (или указатель) используется без инициализации. Это лучше, чем если только что выделенная память очищается нулевыми
байтами.
1.12.3. Глобальные переменные
А что если переменная x из предыдущего примера будет глобальной переменной, а не локальной? Тогда к ней смогут обращаться из любого другого
места, а не только из тела функции. Глобальные переменные считаются антипаттерном, но ради примера мы можем себе это позволить.
#include
// теперь x это глобальная переменная
int x;
int main()
{
printf ("Enter X:\n");
scanf ("%d", &x);
printf ("You entered %d...\n", x);
return 0;
};
MSVC: x86
_DATA
SEGMENT
COMM
_x:DWORD
$SG2456
DB
'Enter X:', 0aH, 00H
$SG2457
DB
'%d', 00H
$SG2458
DB
'You entered %d...', 0aH, 00H
_DATA
ENDS
PUBLIC
_main
EXTRN
_scanf:PROC
EXTRN
_printf:PROC
; Function compile flags: /Odtp
_TEXT
SEGMENT
_main
PROC
push
ebp
mov
ebp, esp
push
OFFSET $SG2456
call
_printf
add
esp, 4
push
OFFSET _x
push
OFFSET $SG2457
call
_scanf
add
esp, 8
mov
eax, DWORD PTR _x
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
В целом ничего особенного. Теперь x объявлена в сегменте _DATA. Память для
неё в стеке более не выделяется. Все обращения к ней происходит не через
стек, а уже напрямую. Неинициализированные глобальные переменные не занимают места в исполняемом файле (и действительно, зачем в исполняемом
файле нужно выделять место под изначально нулевые переменные?), но тогда, когда к этому месту в памяти кто-то обратится, ОС подставит туда блок,
состоящий из нулей71 .
Попробуем изменить объявление этой переменной:
int x=10; // значение по умолчанию
Выйдет в итоге:
_DATA
_x
SEGMENT
DD
0aH
...
Здесь уже по месту этой переменной записано 0xA с типом DD (dword = 32
бита).
Если вы откроете скомпилированный .exe-файл в IDA, то увидите, что x находится в начале сегмента _DATA, после этой переменной будут текстовые строки.
А вот если вы откроете в IDA.exe скомпилированный в прошлом примере, где
значение x не определено, то вы увидите:
Листинг 1.77: IDA
.data:0040FA80
.data:0040FA80
.data:0040FA84
.data:0040FA84
.data:0040FA88
.data:0040FA88
.data:0040FA8C
.data:0040FA8C
.data:0040FA8C
.data:0040FA90
.data:0040FA90
.data:0040FA94
71 Так
_x
dd ?
dword_40FA84
dd ?
dword_40FA88
dd ?
; LPVOID lpMem
lpMem
dd ?
dword_40FA90
dd ?
dword_40FA94
dd ?
;
;
;
;
;
;
DATA XREF: _main+10
_main+22
DATA XREF: _memset+1E
unknown_libname_1+28
DATA XREF: ___sbh_find_block+5
___sbh_free_block+2BC
;
;
;
;
;
DATA XREF: ___sbh_find_block+B
___sbh_free_block+2CA
DATA XREF: _V6_HeapAlloc+13
__calloc_impl+72
DATA XREF: ___sbh_free_block+2FE
работает VM
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
105
_x обозначен как ?, наряду с другими переменными не требующими инициализации. Это означает, что при загрузке .exe в память, место под всё это выделено будет и будет заполнено нулевыми байтами [ISO/IEC 9899:TC3 (C C99
standard), (2007)6.7.8p10]. Но в самом .exe ничего этого нет. Неинициализированные переменные не занимают места в исполняемых файлах. Это удобно
для больших массивов, например.
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
106
MSVC: x86 + OllyDbg
Тут даже проще:
Рис. 1.16: OllyDbg: после исполнения scanf()
Переменная хранится в сегменте данных. Кстати, после исполнения инструкции PUSH (заталкивающей адрес x) адрес появится в стеке, и на этом элементе
можно нажать правой кнопкой, выбрать «Follow in dump». И в окне памяти слева появится эта переменная.
После того как в консоли введем 123, здесь появится 0x7B.
Почему самый первый байт это 7B? По логике вещей, здесь должно было бы
быть 00 00 00 7B. Это называется endianness, и в x86 принят формат littleendian. Это означает, что в начале записывается самый младший байт, а заканчивается самым старшим байтом. Больше об этом: 2.8 (стр. 599).
Позже из этого места в памяти 32-битное значение загружается в EAX и передается в printf().
Адрес переменной x в памяти 0x00C53394.
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
107
В OllyDbg мы можем посмотреть карту памяти процесса (Alt-M) и увидим, что
этот адрес внутри PE-сегмента .data нашей программы:
Рис. 1.17: OllyDbg: карта памяти процесса
GCC: x86
В Linux всё почти также. За исключением того, что если значение x не определено, то эта переменная будет находиться в сегменте _bss. В ELF72 этот
сегмент имеет такие атрибуты:
; Segment type: Uninitialized
; Segment permissions: Read/Write
Ну а если сделать статическое присвоение этой переменной какого-либо значения, например, 10, то она будет находиться в сегменте _data, это сегмент с
такими атрибутами:
; Segment type: Pure data
; Segment permissions: Read/Write
72 Executable and Linkable Format: Формат исполняемых файлов, использующийся в Linux и некоторых других *NIX
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
Почти такой же код как и в x86. Обратите внимание что для scanf() адрес
переменной x передается при помощи инструкции LEA, а во второй printf()
передается само значение переменной при помощи MOV. DWORD PTR — это часть
языка ассемблера (не имеющая отношения к машинным кодам) показывающая,
что тип переменной в памяти именно 32-битный, и инструкция MOV должна
быть здесь закодирована соответственно.
ARM: Оптимизирующий Keil 6/2013 (Режим Thumb)
Листинг 1.79: IDA
.text:00000000 ; Segment type: Pure code
.text:00000000
AREA .text, CODE
...
.text:00000000 main
.text:00000000
PUSH
{R4,LR}
.text:00000002
ADR
R0, aEnterX
.text:00000004
BL
__2printf
.text:00000008
LDR
R1, =x
.text:0000000A
ADR
R0, aD
; "Enter X:\n"
; "%d"
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
aEnterX DCB "Enter X:",0xA,0 ; DATA XREF: main+2
DCB
0
DCB
0
off_2C DCD x
; DATA XREF: main+8
; main+10
aD
DCB "%d",0
; DATA XREF: main+A
DCB
0
aYouEnteredD___ DCB "You entered %d...",0xA,0 ; DATA XREF:
DCB 0
; .text ends
; Segment type: Pure data
AREA .data, DATA
; ORG 0x48
EXPORT x
x
DCD 0xA
; DATA XREF: main+8
; main+10
; .data ends
Итак, переменная x теперь глобальная, и она расположена, почему-то, в другом сегменте, а именно сегменте данных (.data). Можно спросить, почему текстовые строки расположены в сегменте кода (.text), а x нельзя было разместить тут же?
Потому что эта переменная, и как следует из определения, она может меняться. И может быть, меняться часто.
Ну а текстовые строки имеют тип констант, они не будут меняться, поэтому
они располагаются в сегменте .text.
Сегмент кода иногда может быть расположен в ПЗУ микроконтроллера (не
забывайте, мы сейчас имеем дело с встраиваемой (embedded) микроэлектроникой, где дефицит памяти — обычное дело), а изменяемые переменные — в
ОЗУ.
Хранить в ОЗУ неизменяемые данные, когда в наличии есть ПЗУ, не экономно.
К тому же, сегмент данных в ОЗУ с константами нужно инициализировать перед работой, ведь, после включения ОЗУ, очевидно, она содержит в себе случайную информацию.
Далее мы видим в сегменте кода хранится указатель на переменную x (off_2C)
и все операции с переменной происходят через этот указатель.
Это связано с тем, что переменная x может быть расположена где-то довольно
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
110
далеко от данного участка кода, так что её адрес нужно сохранить в непосредственной близости к этому коду.
Инструкция LDR в Thumb-режиме может адресовать только переменные в пределах вплоть до 1020 байт от своего местоположения.
Эта же инструкция в ARM-режиме — переменные в пределах ±4095 байт.
Таким образом, адрес глобальной переменной x нужно расположить в непосредственной близости, ведь нет никакой гарантии, что компоновщик73 сможет разместить саму переменную где-то рядом, она может быть даже в другом чипе памяти!
Ещё одна вещь: если переменную объявить, как const, то компилятор Keil разместит её в сегменте .constdata.
Должно быть, впоследствии компоновщик и этот сегмент сможет разместить
в ПЗУ вместе с сегментом кода.
ARM64
Листинг 1.80: Неоптимизирующий GCC 4.9.1 ARM64
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
.comm
x,4,4
.LC0:
.string "Enter X:"
.LC1:
.string "%d"
.LC2:
.string "You entered %d...\n"
f5:
; сохранить FP и LR в стековом фрейме:
stp
x29, x30, [sp, −16]!
; установить стековый фрейм (FP=SP)
add
x29, sp, 0
; загрузить указатель на строку "Enter X:":
adrp
x0, .LC0
add
x0, x0, :lo12:.LC0
bl
puts
; загрузить указатель на строку "%d":
adrp
x0, .LC1
add
x0, x0, :lo12:.LC1
; сформировать адрес глобальной переменной x:
adrp
x1, x
add
x1, x1, :lo12:x
bl
__isoc99_scanf
; снова сформировать адрес глобальной переменной x:
adrp
x0, x
add
x0, x0, :lo12:x
; загрузить значение из памяти по этому адресу:
ldr
w1, [x0]
; загрузить указатель на строку "You entered %d...\n":
adrp
x0, .LC2
73 linker
в англоязычной литературе
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
111
31
32
33
34
35
36
37
add
x0, x0, :lo12:.LC2
bl
printf
; возврат 0
mov
w0, 0
; восстановить FP и LR:
ldp
x29, x30, [sp], 16
ret
Теперь x это глобальная переменная, и её адрес вычисляется при помощи пары
инструкций ADRP/ADD (строки 21 и 25).
MIPS
Неинициализированная глобальная переменная
Так что теперь переменная x глобальная. Сделаем исполняемый файл вместо
объектного и загрузим его в IDA. IDA показывает присутствие переменной x в
ELF-секции .sbss (помните о «Global Pointer»? 1.5.4 (стр. 33)), так как переменная не инициализируется в самом начале.
Листинг 1.81: Оптимизирующий GCC 4.4.5 (IDA)
.text:004006C0 main:
.text:004006C0
.text:004006C0 var_10
.text:004006C0 var_4
.text:004006C0
; пролог функции:
.text:004006C0
.text:004006C4
.text:004006C8
.text:004006CC
.text:004006D0
; вызов puts():
.text:004006D4
.text:004006D8
.text:004006DC
.text:004006E0
slot
; вызов scanf():
.text:004006E4
.text:004006E8
.text:004006EC
; подготовить адрес x:
.text:004006F0
.text:004006F4
.text:004006F8
; вызов printf():
.text:004006FC
.text:00400700
; взять адрес x:
.text:00400704
.text:00400708
$a1, x
$t9 ; __isoc99_scanf
$a0, aD
# "%d" ; branch delay slot
lw
lui
$gp, 0x20+var_10($sp)
$a0, 0x40
la
la
$v0, x
$t9, printf
# "Enter X:" ; branch delay
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
112
; загрузить значение из переменной "x" и передать его в printf() в $a1:
.text:0040070C
lw
$a1, (x − 0x41099C)($v0)
.text:00400710
jalr
$t9 ; printf
.text:00400714
la
$a0, aYouEnteredD___ # "You entered %d...\n"
; branch delay slot
; эпилог функции:
.text:00400718
lw
$ra, 0x20+var_4($sp)
.text:0040071C
move
$v0, $zero
.text:00400720
jr
$ra
.text:00400724
addiu
$sp, 0x20 ; branch delay slot
...
.sbss:0041099C # Segment type: Uninitialized
.sbss:0041099C
.sbss
.sbss:0041099C
.globl x
.sbss:0041099C x:
.space 4
.sbss:0041099C
IDA уменьшает количество информации, так что сделаем также листинг используя objdump и добавим туда свои комментарии:
Листинг 1.82: Оптимизирующий GCC 4.4.5 (objdump)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
004006c0 :
; пролог функции:
4006c0: 3c1c0042 lui
gp,0x42
4006c4: 27bdffe0 addiu
sp,sp,−32
4006c8: 279c8940 addiu
gp,gp,−30400
4006cc: afbf001c sw
ra,28(sp)
4006d0: afbc0010 sw
gp,16(sp)
; вызов puts():
4006d4: 8f998034 lw
t9,−32716(gp)
4006d8: 3c040040 lui
a0,0x40
4006dc: 0320f809 jalr
t9
4006e0: 248408f0 addiu
a0,a0,2288 ; branch delay slot
; вызов scanf():
4006e4: 8fbc0010 lw
gp,16(sp)
4006e8: 3c040040 lui
a0,0x40
4006ec: 8f998038 lw
t9,−32712(gp)
; подготовить адрес x:
4006f0: 8f858044 lw
a1,−32700(gp)
4006f4: 0320f809 jalr
t9
4006f8: 248408fc addiu
a0,a0,2300 ; branch delay slot
; вызов printf():
4006fc: 8fbc0010 lw
gp,16(sp)
400700: 3c040040 lui
a0,0x40
; взять адрес x:
400704: 8f828044 lw
v0,−32700(gp)
400708: 8f99803c lw
t9,−32708(gp)
; загрузить значение из переменной "x" и передать его в printf() в $a1:
40070c: 8c450000 lw
a1,0(v0)
400710: 0320f809 jalr
t9
400714: 24840900 addiu
a0,a0,2304 ; branch delay slot
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
113
31
32
33
34
35
36
37
38
; эпилог функции:
400718: 8fbf001c lw
ra,28(sp)
40071c: 00001021 move
v0,zero
400720: 03e00008 jr
ra
400724: 27bd0020 addiu
sp,sp,32
; branch delay slot
; набор NOP-ов для выравнивания начала следующей ф-ции по 16-байтной границе:
400728: 00200825 move
at,at
40072c: 00200825 move
at,at
Теперь мы видим, как адрес переменной x берется из буфера 64KiB, используя
GP и прибавление к нему отрицательного смещения (строка 18).
И даже более того: адреса трех внешних функций, используемых в нашем примере (puts(), scanf(), printf()) также берутся из буфера 64KiB используя GP
(строки 9, 16 и 26).
GP указывает на середину буфера, так что такие смещения могут нам подсказать, что адреса всех трех функций, а также адрес переменной x расположены
где-то в самом начале буфера. Действительно, ведь наш пример крохотный.
Ещё нужно отметить что функция заканчивается двумя NOP-ами (MOVE $AT,$AT —
это холостая инструкция), чтобы выровнять начало следующей функции по
16-байтной границе.
Инициализированная глобальная переменная
Немного изменим наш пример и сделаем, чтобы у x было значение по умолчанию:
int x=10; // значение по умолчанию
Теперь IDA показывает что переменная x располагается в секции .data:
Листинг 1.83: Оптимизирующий GCC 4.4.5 (IDA)
.text:004006A0 main:
.text:004006A0
.text:004006A0 var_10
.text:004006A0 var_8
.text:004006A0 var_4
.text:004006A0
.text:004006A0
.text:004006A4
.text:004006A8
.text:004006AC
.text:004006B0
.text:004006B4
.text:004006B8
.text:004006BC
.text:004006C0
.text:004006C4
.text:004006C8
; подготовить старшую
= −0x10
= −8
= −4
lui
$gp, 0x42
addiu
$sp, −0x20
li
$gp, 0x418930
sw
$ra, 0x20+var_4($sp)
sw
$s0, 0x20+var_8($sp)
sw
$gp, 0x20+var_10($sp)
la
$t9, puts
lui
$a0, 0x40
jalr
$t9 ; puts
la
$a0, aEnterX
# "Enter X:"
lw
$gp, 0x20+var_10($sp)
часть адреса x:
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
114
.text:004006CC
lui
$s0, 0x41
.text:004006D0
la
$t9, __isoc99_scanf
.text:004006D4
lui
$a0, 0x40
; прибавить младшую часть адреса x:
.text:004006D8
addiu
$a1, $s0, (x − 0x410000)
; теперь адрес x в $a1.
.text:004006DC
jalr
$t9 ; __isoc99_scanf
.text:004006E0
la
$a0, aD
# "%d"
.text:004006E4
lw
$gp, 0x20+var_10($sp)
; загрузить слово из памяти:
.text:004006E8
lw
$a1, x
; значение x теперь в $a1.
.text:004006EC
la
$t9, printf
.text:004006F0
lui
$a0, 0x40
.text:004006F4
jalr
$t9 ; printf
.text:004006F8
la
$a0, aYouEnteredD___ # "You entered %d...\n"
.text:004006FC
lw
$ra, 0x20+var_4($sp)
.text:00400700
move
$v0, $zero
.text:00400704
lw
$s0, 0x20+var_8($sp)
.text:00400708
jr
$ra
.text:0040070C
addiu
$sp, 0x20
...
.data:00410920
.data:00410920 x:
.globl x
.word 0xA
Почему не .sdata? Может быть, нужно было указать какую-то опцию в GCC? Тем
не менее, x теперь в .data, а это уже общая память и мы можем посмотреть как
происходит работа с переменными там.
Адрес переменной должен быть сформирован парой инструкций. В нашем случае это LUI («Load Upper Immediate» — загрузить старшие 16 бит) и ADDIU («Add
Immediate Unsigned Word» — прибавить значение). Вот так же листинг сгенерированный objdump-ом для лучшего рассмотрения:
Листинг 1.84: Оптимизирующий GCC 4.4.5 (objdump)
004006a0 :
4006a0: 3c1c0042 lui
gp,0x42
4006a4: 27bdffe0 addiu
sp,sp,−32
4006a8: 279c8930 addiu
gp,gp,−30416
4006ac: afbf001c sw
ra,28(sp)
4006b0: afb00018 sw
s0,24(sp)
4006b4: afbc0010 sw
gp,16(sp)
4006b8: 8f998034 lw
t9,−32716(gp)
4006bc: 3c040040 lui
a0,0x40
4006c0: 0320f809 jalr
t9
4006c4: 248408d0 addiu
a0,a0,2256
4006c8: 8fbc0010 lw
gp,16(sp)
; подготовить старшую часть адреса x:
4006cc: 3c100041 lui
s0,0x41
4006d0: 8f998038 lw
t9,−32712(gp)
4006d4: 3c040040 lui
a0,0x40
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
115
; прибавить младшую часть адреса x:
4006d8: 26050920 addiu
a1,s0,2336
; теперь адрес x в $a1.
4006dc: 0320f809 jalr
t9
4006e0: 248408dc addiu
a0,a0,2268
4006e4: 8fbc0010 lw
gp,16(sp)
; старшая часть адреса x всё еще в $s0.
; прибавить младшую часть к ней и загрузить слово из памяти:
4006e8: 8e050920 lw
a1,2336(s0)
; значение x теперь в $a1.
4006ec: 8f99803c lw
t9,−32708(gp)
4006f0: 3c040040 lui
a0,0x40
4006f4: 0320f809 jalr
t9
4006f8: 248408e0 addiu
a0,a0,2272
4006fc: 8fbf001c lw
ra,28(sp)
400700: 00001021 move
v0,zero
400704: 8fb00018 lw
s0,24(sp)
400708: 03e00008 jr
ra
40070c: 27bd0020 addiu
sp,sp,32
Адрес формируется используя LUI и ADDIU, но старшая часть адреса всё ещё в
регистре $S0, и можно закодировать смещение в инструкции LW («Load Word»),
так что одной LW достаточно для загрузки значения из переменной и передачи
его в printf(). Регистры хранящие временные данные имеют префикс T-, но
здесь есть также регистры с префиксом S-, содержимое которых должно быть
сохранено в других функциях (т.е. «saved»).
Вот почему $S0 был установлен по адресу 0x4006cc и затем был использован
по адресу 0x4006e8 после вызова scanf().
Функция scanf() не изменяет это значение.
1.12.4. Проверка результата scanf()
Как уже было упомянуто, использовать scanf() в наше время слегка старомодно. Но если уж пришлось, нужно хотя бы проверять, сработал ли scanf()
правильно или пользователь ввел вместо числа что-то другое, что scanf() не
смог трактовать как число.
#include
int main()
{
int x;
printf ("Enter X:\n");
if (scanf ("%d", &x)==1)
printf ("You entered %d...\n", x);
else
printf ("What you entered? Huh?\n");
return 0;
};
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
116
По стандарту,scanf()74 возвращает количество успешно полученных значений.
В нашем случае, если всё успешно и пользователь ввел таки некое число, scanf()
вернет 1. А если нет, то 0 (или EOF75 ).
Добавим код, проверяющий результат scanf() и в случае ошибки он сообщает
пользователю что-то другое.
Это работает предсказуемо:
C:\...>ex3.exe
Enter X:
123
You entered 123...
C:\...>ex3.exe
Enter X:
ouch
What you entered? Huh?
MSVC: x86
Вот что выходит на ассемблере (MSVC 2010):
lea
push
push
call
add
cmp
jne
mov
push
push
call
add
jmp
$LN2@main:
push
call
add
$LN1@main:
xor
Для того чтобы вызывающая функция имела доступ к результату вызываемой
функции, вызываемая функция (в нашем случае scanf()) оставляет это значение в регистре EAX.
74 scanf,
75 End
wscanf: MSDN
of File (конец файла)
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
117
Мы проверяем его инструкцией CMP EAX, 1 (CoMPare), то есть сравниваем значение в EAX с 1.
Следующий за инструкцией CMP: условный переход JNE. Это означает Jump if
Not Equal, то есть условный переход если не равно.
Итак, если EAX не равен 1, то JNE заставит CPU перейти по адресу указанном в
операнде JNE, у нас это $LN2@main. Передав управление по этому адресу, CPU
начнет исполнять вызов printf() с аргументом What you entered? Huh?. Но
если всё нормально, перехода не случится и исполнится другой printf() с
двумя аргументами:
'You entered %d...' и значением переменной x.
Для того чтобы после этого вызова не исполнился сразу второй вызов printf(),
после него есть инструкция JMP, безусловный переход, который отправит процессор на место после второго printf() и перед инструкцией XOR EAX, EAX,
которая реализует return 0.
Итак, можно сказать, что в подавляющих случаях сравнение какой-либо переменной с чем-то другим происходит при помощи пары инструкций CMP и Jcc,
где cc это condition code. CMP сравнивает два значения и выставляет флаги
процессора76 . Jcc проверяет нужные ему флаги и выполняет переход по указанному адресу (или не выполняет).
Но на самом деле, как это не парадоксально поначалу звучит, CMP это почти
то же самое что и инструкция SUB, которая отнимает числа одно от другого.
Все арифметические инструкции также выставляют флаги в соответствии с
результатом, не только CMP. Если мы сравним 1 и 1, от единицы отнимется единица, получится 0, и выставится флаг ZF (zero flag), означающий, что последний полученный результат был 0. Ни при каких других значениях EAX, флаг
ZF не может быть выставлен, кроме тех, когда операнды равны друг другу.
Инструкция JNE проверяет только флаг ZF, и совершает переход только если
флаг не поднят. Фактически, JNE это синоним инструкции JNZ (Jump if Not Zero).
Ассемблер транслирует обе инструкции в один и тот же опкод. Таким образом,
можно CMP заменить на SUB и всё будет работать также, но разница в том, что
SUB всё-таки испортит значение в первом операнде. CMP это SUB без сохранения результата, но изменяющая флаги.
MSVC: x86: IDA
Наверное, уже пора делать первые попытки анализа кода в IDA. Кстати, начинающим полезно компилировать в MSVC с ключом /MD, что означает, что все
эти стандартные функции не будут скомпонованы с исполняемым файлом, а
будут импортироваться из файла MSVCR*.DLL. Так будет легче увидеть, где какая стандартная функция используется.
Анализируя код в IDA, очень полезно делать пометки для себя (и других). Например, разбирая этот пример, мы сразу видим, что JNZ срабатывает в случае
ошибки. Можно навести курсор на эту метку, нажать «n» и переименовать метку в «error». Ещё одну метку — в«exit». Вот как у меня получилось в итоге:
76 См.
также о флагах x86-процессора: wikipedia.
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
Так понимать код становится чуть легче. Впрочем, меру нужно знать во всем
и комментировать каждую инструкцию не стоит.
В IDA также можно скрывать части функций: нужно выделить скрываемую
часть, нажать Ctrl-«–» на цифровой клавиатуре и ввести текст.
Скроем две части и придумаем им названия:
.text:00401000 _text segment para public 'CODE' use32
.text:00401000
assume cs:_text
.text:00401000
;org 401000h
.text:00401000 ; ask for X
.text:00401012 ; get X
.text:00401024
cmp eax, 1
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
jnz short error
; print result
jmp short exit
error: ; CODE XREF: _main+27
push offset aWhat ; "What you entered? Huh?\n"
call ds:printf
add esp, 4
exit: ; CODE XREF: _main+3B
xor eax, eax
mov esp, ebp
pop ebp
retn
_main endp
Раскрывать скрытые части функций можно при помощи Ctrl-«+» на цифровой
клавиатуре.
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
120
Нажав «пробел», мы увидим, как IDA может представить функцию в виде графа:
Рис. 1.18: Отображение функции в IDA в виде графа
После каждого условного перехода видны две стрелки: зеленая и красная. Зеленая ведет к тому блоку, который исполнится если переход сработает, а красная — если не сработает.
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
121
В этом режиме также можно сворачивать узлы и давать им названия («group
nodes»). Сделаем это для трех блоков:
Рис. 1.19: Отображение в IDA в виде графа с тремя свернутыми блоками
Всё это очень полезно делать. Вообще, очень важная часть работы реверсера (да и любого исследователя) состоит в том, чтобы уменьшать количество
имеющейся информации.
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
122
MSVC: x86 + OllyDbg
Попробуем в OllyDbg немного хакнуть программу и сделать вид, что scanf()
срабатывает всегда без ошибок. Когда в scanf() передается адрес локальной
переменной, изначально в этой переменной находится некий мусор. В данном
случае это 0x6E494714:
Рис. 1.20: OllyDbg: передача адреса переменной в scanf()
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
123
Когда scanf() запускается, вводим в консоли что-то непохожее на число, например «asdasd». scanf() заканчивается с 0 в EAX, что означает, что произошла ошибка.
Вместе с этим мы можем посмотреть на локальную переменную в стеке — она
не изменилась. Действительно, ведь что туда записала бы функция scanf()?
Она не делала ничего кроме возвращения нуля. Попробуем ещё немного «хакнуть» нашу программу. Щелкнем правой кнопкой на EAX, там, в числе опций,
будет также «Set to 1». Это нам и нужно.
В EAX теперь 1, последующая проверка пройдет как надо, и printf() выведет
значение переменной из стека.
Запускаем (F9) и видим в консоли следующее:
Листинг 1.85: консоль
Enter X:
asdasd
You entered 1850296084...
Действительно, 1850296084 это десятичное представление числа в стеке (0x6E494714)!
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
124
MSVC: x86 + Hiew
Это ещё может быть и простым примером исправления исполняемого файла.
Мы можем попробовать исправить его таким образом, что программа всегда
будет выводить числа, вне зависимости от ввода.
Исполняемый файл скомпилирован с импортированием функций из MSVCR*.DLL
(т.е. с опцией /MD)77 , поэтому мы можем отыскать функцию main() в самом
начале секции .text. Откроем исполняемый файл в Hiew, найдем самое начало
секции .text (Enter, F8, F6, Enter, Enter).
Мы увидим следующее:
Рис. 1.21: Hiew: функция main()
Hiew находит ASCIIZ78 -строки и показывает их, также как и имена импортируемых функций.
77 то,
что ещё называют «dynamic linking»
Zero (ASCII-строка заканчивающаяся нулем )
78 ASCII
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
125
Переведите курсор на адрес .00401027 (с инструкцией JNZ, которую мы хотим
заблокировать), нажмите F3, затем наберите «9090» (что означает два NOP-а):
Рис. 1.22: Hiew: замена JNZ на два NOP-а
Затем F9 (update). Теперь исполняемый файл записан на диск. Он будет вести
себя так, как нам надо.
Два NOP-а, возможно, не так эстетично, как могло бы быть. Другой способ изменить инструкцию это записать 0 во второй байт опкода (смещение перехода),
так что JNZ всегда будет переходить на следующую инструкцию.
Можно изменить и наоборот: первый байт заменить на EB, второй байт (смещение перехода) не трогать. Получится всегда срабатывающий безусловный
переход. Теперь сообщение об ошибке будет выдаваться всегда, даже если
мы ввели число.
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
126
MSVC: x64
Так как здесь мы работаем с переменными типа int, а они в x86-64 остались
32-битными, то мы здесь видим, как продолжают использоваться регистры с
префиксом E-. Но для работы с указателями, конечно, используются 64-битные
части регистров с префиксом R-.
Листинг 1.86: MSVC 2012 x64
_DATA
$SG2924
$SG2926
$SG2927
$SG2929
_DATA
Здесь для нас есть новые инструкции: CMP и BEQ79 .
CMP аналогична той что в x86: она отнимает один аргумент от второго и сохраняет флаги.
BEQ совершает переход по другому адресу, если операнды при сравнении были равны, либо если результат последнего вычисления был 0, либо если флаг
Z равен 1. То же что и JZ в x86.
Всё остальное просто: исполнение разветвляется на две ветки, затем они сходятся там, где в R0 записывается 0 как возвращаемое из функции значение и
происходит выход из функции.
ARM64
Листинг 1.88: Неоптимизирующий GCC 4.9.1 ARM64
1
2
3
4
5
6
7
8
9
10
11
12
13
14
.LC0:
.string "Enter X:"
.LC1:
.string "%d"
.LC2:
.string "You entered %d...\n"
.LC3:
.string "What you entered? Huh?"
f6:
; сохранить FP и LR в стековом фрейме:
stp
x29, x30, [sp, −32]!
; установить стековый фрейм (FP=SP)
add
x29, sp, 0
; загрузить указатель на строку "Enter X:"
79 (PowerPC,
ARM) Branch if Equal
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
adrp
x0, .LC0
add
x0, x0, :lo12:.LC0
bl
puts
; загрузить указатель на строку "%d":
adrp
x0, .LC1
add
x0, x0, :lo12:.LC1
; вычислить адрес переменной x в локальном стеке
add
x1, x29, 28
bl
__isoc99_scanf
; scanf() возвращает результат в W0.
; проверяем его:
cmp
w0, 1
; BNE это Branch if Not Equal (переход, если не равно)
; так что если W00, произойдет переход на L2
bne
.L2
; в этот момент W0=1, означая, что ошибки не было
; загрузить значение x из локального стека
ldr
w1, [x29,28]
; загрузить указатель на строку "You entered %d...\n":
adrp
x0, .LC2
add
x0, x0, :lo12:.LC2
bl
printf
; пропустить код, печатающий строку "What you entered? Huh?":
b
.L3
.L2:
; загрузить указатель на строку "What you entered? Huh?":
adrp
x0, .LC3
add
x0, x0, :lo12:.LC3
bl
puts
.L3:
; возврат 0
mov
w0, 0
; восстановить FP и LR:
ldp
x29, x30, [sp], 32
ret
Исполнение здесь разветвляется, используя пару инструкций CMP/BNE (Branch
if Not Equal: переход если не равно).
MIPS
Листинг 1.89: Оптимизирующий GCC 4.4.5 (IDA)
.text:004006A0
.text:004006A0
.text:004006A0
.text:004006A0
.text:004006A0
.text:004006A0
.text:004006A0
.text:004006A4
.text:004006A8
.text:004006AC
main:
var_18
var_10
var_4
= −0x18
= −0x10
= −4
lui
addiu
li
sw
$gp,
$sp,
$gp,
$ra,
0x42
−0x28
0x418960
0x28+var_4($sp)
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
scanf() возвращает результат своей работы в регистре $V0 и он проверяется
по адресу 0x004006E4 сравнивая значения в $V0 и $V1 (1 записан в $V1 ранее,
на 0x004006DC). BEQ означает «Branch Equal» (переход если равно). Если значения равны (т.е. в случае успеха), произойдет переход по адресу 0x0040070C.
Упражнение
Как мы можем увидеть, инструкцию JNE/JNZ можно вполне заменить на JE/JZ
или наоборот (или BNE на BEQ и наоборот). Но при этом ещё нужно переставить
базовые блоки местами. Попробуйте сделать это в каком-нибудь примере.
1.12.5. Упражнение
• http://challenges.re/53
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
130
1.13. Стоит отметить: глобальные и локальные переменные
Теперь вы знаете, что глобальные переменные обнуляются в начале в ОС (1.12.3
(стр. 105), [ISO/IEC 9899:TC3 (C C99 standard), (2007)6.7.8p10]), а локальные –
нет (1.9.4 (стр. 51)).
Иногда, у вас есть глобальная переменная, которую вы забыли проинициализировать, и исполнение вашей программы зависит от того факта, что в начале
исполнения там ноль. Потом вы редактируете вашу программу и перемещаете глобальную переменную внутрь ф-ции, делая её локальной. Она не будет
более инициализироваться в ноль, и это может в итоге приводить к труднонаходимым ошибкам.
1.14. Доступ к переданным аргументам
Как мы уже успели заметить, вызывающая функция передает аргументы для
вызываемой через стек. А как вызываемая функция получает к ним доступ?
Листинг 1.90: простой пример
#include
int f (int a, int b, int c)
{
return a∗b+c;
};
int main()
{
printf ("%d\n", f(1, 2, 3));
return 0;
};
Итак, здесь видно: в функции main() заталкиваются три числа в стек и вызывается функция
f(int,int,int).
Внутри f() доступ к аргументам, также как и к локальным переменным, происходит через макросы: _a$ = 8, но разница в том, что эти смещения со знаком
плюс, таким образом если прибавить макрос _a$ к указателю на EBP, то адресуется внешняя часть фрейма стека относительно EBP.
Далее всё более-менее просто: значение a помещается в EAX. Далее EAX умножается при помощи инструкции IMUL на то, что лежит в _b, и в EAX остается
произведение этих двух значений.
Далее к регистру EAX прибавляется то, что лежит в _c.
Значение из EAX никуда не нужно перекладывать, оно уже лежит где надо.
Возвращаем управление вызывающей функции — она возьмет значение из EAX
и отправит его в printf().
MSVC + OllyDbg
Проиллюстрируем всё это в OllyDbg. Когда мы протрассируем до первой инструкции в f(), которая использует какой-то из аргументов (первый), мы увидим, что EBP указывает на фрейм стека. Он выделен красным прямоугольником.
Самый первый элемент фрейма стека — это сохраненное значение EBP, затем
RA. Третий элемент это первый аргумент функции, затем второй аргумент и
третий.
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
132
Для доступа к первому аргументу функции нужно прибавить к EBP 8 (2 32битных слова).
OllyDbg в курсе этого, так что он добавил комментарии к элементам стека вроде «RETURN from» и «Arg1 = …», итд.
N.B.: аргументы функции являются членами фрейма стека вызывающей функции, а не текущей. Поэтому OllyDbg отметил элементы «Arg» как члены другого
фрейма стека.
Рис. 1.23: OllyDbg: внутри функции f()
GCC
Скомпилируем то же в GCC 4.4.1 и посмотрим результат в IDA:
Листинг 1.92: GCC 4.4.1
f
public f
proc near
arg_0
arg_4
arg_8
= dword ptr
= dword ptr
= dword ptr
f
push
mov
mov
imul
add
pop
retn
endp
ebp
ebp,
eax,
eax,
eax,
ebp
8
0Ch
10h
esp
[ebp+arg_0] ; первый аргумент
[ebp+arg_4] ; второй аргумент
[ebp+arg_8] ; третий аргумент
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
133
main
public main
proc near
var_10
var_C
var_8
= dword ptr −10h
= dword ptr −0Ch
= dword ptr −8
main
push
mov
and
sub
mov
mov
mov
call
mov
mov
mov
call
mov
leave
retn
endp
ebp
ebp, esp
esp, 0FFFFFFF0h
esp, 10h
[esp+10h+var_8], 3 ; третий аргумент
[esp+10h+var_C], 2 ; второй аргумент
[esp+10h+var_10], 1 ; первый аргумент
f
edx, offset aD ; "%d\n"
[esp+10h+var_C], eax
[esp+10h+var_10], edx
_printf
eax, 0
Практически то же самое, если не считать мелких отличий описанных ранее.
После вызова обоих функций указатель стека не возвращается назад, потому
что предпоследняя инструкция LEAVE (.1.6 (стр. 1308)) делает это за один раз,
в конце исполнения.
1.14.2. x64
В x86-64 всё немного иначе, здесь аргументы функции (4 или 6) передаются
через регистры, а callee из читает их из регистров, а не из стека.
MSVC
Оптимизирующий MSVC:
Листинг 1.93: Оптимизирующий MSVC 2012 x64
$SG2997 DB
main
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
134
main
f
add
ret
ENDP
rsp, 40
0
PROC
; ECX - первый аргумент
; EDX - второй аргумент
; R8D - третий аргумент
imul
ecx, edx
lea
eax, DWORD PTR [r8+rcx]
ret
0
ENDP
f
Как видно, очень компактная функция f() берет аргументы прямо из регистров.
Инструкция LEA используется здесь для сложения чисел. Должно быть компилятор посчитал, что это будет эффективнее использования ADD.
В самой main() LEA также используется для подготовки первого и третьего
аргумента: должно быть, компилятор решил, что LEA будет работать здесь
быстрее, чем загрузка значения в регистр при помощи MOV.
Попробуем посмотреть вывод неоптимизирующего MSVC:
Листинг 1.94: MSVC 2012 x64
f
Немного путанее: все 3 аргумента из регистров зачем-то сохраняются в стеке.
Это называется «shadow space» 80 : каждая функция в Win64 может (хотя и не
обязана) сохранять значения 4-х регистров там.
Это делается по крайней мере из-за двух причин: 1) в большой функции отвести целый регистр (а тем более 4 регистра) для входного аргумента слишком
расточительно, так что к нему будет обращение через стек;
2) отладчик всегда знает, где найти аргументы функции в момент останова 81 .
Так что, какие-то большие функции могут сохранять входные аргументы в
«shadow space» для использования в будущем, а небольшие функции, как наша, могут этого и не делать.
Место в стеке для «shadow space» выделяет именно caller.
GCC
Оптимизирующий GCC также делает понятный код:
Листинг 1.95: Оптимизирующий GCC 4.4.6 x64
f:
; EDI - первый аргумент
; ESI - второй аргумент
; EDX - третий аргумент
imul
esi, edi
lea
eax, [rdx+rsi]
ret
main:
sub
mov
mov
mov
call
mov
mov
xor
call
xor
add
ret
rsp, 8
edx, 3
esi, 2
edi, 1
f
edi, OFFSET FLAT:.LC0 ; "%d\n"
esi, eax
eax, eax ; количество переданных векторных регистров
printf
eax, eax
rsp, 8
80 MSDN
81 MSDN
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
136
Неоптимизирующий GCC:
Листинг 1.96: GCC 4.4.6 x64
f:
; EDI - первый аргумент
; ESI - второй аргумент
; EDX - третий аргумент
push
rbp
mov
rbp, rsp
mov
DWORD PTR [rbp−4], edi
mov
DWORD PTR [rbp−8], esi
mov
DWORD PTR [rbp−12], edx
mov
eax, DWORD PTR [rbp−4]
imul
eax, DWORD PTR [rbp−8]
add
eax, DWORD PTR [rbp−12]
leave
ret
main:
push
mov
mov
mov
mov
call
mov
mov
mov
mov
mov
call
mov
leave
ret
В соглашении о вызовах System V *NIX ([Michael Matz, Jan Hubicka, Andreas Jaeger,
Mark Mitchell, System V Application Binary Interface. AMD64 Architecture Processor
Supplement, (2013)] 82 ) нет «shadow space», но callee тоже иногда должен сохранять где-то аргументы, потому что, опять же, регистров может и не хватить
на все действия. Что мы здесь и видим.
GCC: uint64_t вместо int
Наш пример работал с 32-битным int, поэтому использовались 32-битные части
регистров с префиксом E-.
Его можно немного переделать, чтобы он заработал с 64-битными значениями:
#include
#include
82 Также доступно здесь: https://software.intel.com/sites/default/files/article/402129/
mpx-linux64-abi.pdf
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
137
uint64_t f (uint64_t a, uint64_t b, uint64_t c)
{
return a∗b+c;
};
int main()
{
printf ("%lld\n", f(0x1122334455667788,
0x1111111122222222,
0x3333333344444444));
return 0;
};
Листинг 1.97: Оптимизирующий GCC 4.4.6 x64
f
proc near
imul
rsi, rdi
lea
rax, [rdx+rsi]
retn
endp
f
main
main
proc near
sub
rsp, 8
mov
rdx, 3333333344444444h ; третий аргумент
mov
rsi, 1111111122222222h ; второй аргумент
mov
rdi, 1122334455667788h ; первый аргумент
call
f
mov
edi, offset format ; "%lld\n"
mov
rsi, rax
xor
eax, eax ; количество переданных векторных регистров
call
_printf
xor
eax, eax
add
rsp, 8
retn
endp
Собствено, всё то же самое, только используются регистры целиком, с префиксом R-.
В функции main() просто вызываются две функции, в первую (f()) передается
три значения. Как уже было упомянуто, первые 4 значения в ARM обычно передаются в первых 4-х регистрах (R0-R3). Функция f(), как видно, использует
три первых регистра (R0-R2) как аргументы.
Инструкция MLA (Multiply Accumulate) перемножает два первых операнда (R3
и R1), прибавляет к произведению третий операнд (R2) и помещает результат
в нулевой регистр (R0), через который, по стандарту, возвращаются значения
функций.
Умножение и сложение одновременно (Fused multiply–add) это часто применяемая операция. Кстати, аналогичной инструкции в x86 не было до появления
FMA-инструкций в SIMD 83 .
Самая первая инструкция MOV R3, R0, по-видимому, избыточна (можно было
бы обойтись только одной инструкцией MLA). Компилятор не оптимизировал
её, ведь, это компиляция без оптимизации.
Инструкция BX возвращает управление по адресу, записанному в LR и, если
нужно, переключает режимы процессора с Thumb на ARM или наоборот. Это
может быть необходимым потому, что, как мы видим, функции f() неизвестно,
из какого кода она будет вызываться, из ARM или Thumb. Поэтому, если она
будет вызываться из кода Thumb, BX не только возвращает управление в вызывающую функцию, но также переключает процессор в режим Thumb. Либо
не переключит, если функция вызывалась из кода для режима ARM: [ARM(R)
Architecture Reference Manual, ARMv7-A and ARMv7-R edition, (2012)A2.3.2].
Оптимизирующий Keil 6/2013 (Режим ARM)
.text:00000098
f
.text:00000098 91 20 20 E0
.text:0000009C 1E FF 2F E1
MLA
BX
R0, R1, R0, R2
LR
А вот и функция f(), скомпилированная компилятором Keil в режиме полной
оптимизации (-O3). Инструкция MOV была оптимизирована: теперь MLA использует все входящие регистры и помещает результат в R0, где вызываемая функция будет его читать и использовать.
Оптимизирующий Keil 6/2013 (Режим Thumb)
83 wikipedia
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
В режиме Thumb инструкция MLA недоступна, так что компилятору пришлось
сгенерировать код, делающий обе операции по отдельности.
Первая инструкция MULS умножает R0 на R1, оставляя результат в R0. Вторая
(ADDS) складывает результат и R2, оставляя результат в R0.
ARM64
Оптимизирующий GCC (Linaro) 4.9
Тут всё просто. MADD это просто инструкция, производящая умножение и сложение одновременно (как MLA, которую мы уже видели). Все 3 аргумента передаются в 32-битных частях X-регистров. Действительно, типы аргументов это
32-битные int’ы. Результат возвращается в W0.
Листинг 1.98: Оптимизирующий GCC (Linaro) 4.9
f:
madd
ret
w0, w0, w1, w2
main:
; сохранить FP и LR в стековом фрейме:
stp
x29, x30, [sp, −16]!
mov
w2, 3
mov
w1, 2
add
x29, sp, 0
mov
w0, 1
bl
f
mov
w1, w0
adrp
x0, .LC7
add
x0, x0, :lo12:.LC7
bl
printf
; возврат 0
mov
w0, 0
; восстановить FP и LR
ldp
x29, x30, [sp], 16
ret
.LC7:
.string "%d\n"
Также расширим все типы данных до 64-битных uint64_t и попробуем:
#include
#include
uint64_t f (uint64_t a, uint64_t b, uint64_t c)
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
140
{
return a∗b+c;
};
int main()
{
printf ("%lld\n", f(0x1122334455667788,
0x1111111122222222,
0x3333333344444444));
return 0;
};
f:
madd
ret
Функция f() точно такая же, только теперь используются полные части 64битных X-регистров. Длинные 64-битные значения загружаются в регистры по
частям, это описано здесь: 1.39.3 (стр. 567).
Неоптимизирующий GCC (Linaro) 4.9
Неоптимизирующий компилятор выдает немного лишнего кода:
f:
sub
str
str
str
ldr
ldr
mul
ldr
add
add
ret
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
141
Код сохраняет входные аргументы в локальном стеке на случай если кому-то
(или чему-то) в этой функции понадобится использовать регистры W0...W2, перезаписывая оригинальные аргументы функции, которые могут понадобится
в будущем. Это называется Register Save Area. [Procedure Call Standard for the
ARM 64-bit Architecture (AArch64), (2013)]84 . Вызываемая функция не обязана
сохранять их. Это то же что и «Shadow Space»: 1.14.2 (стр. 135).
Почему оптимизирующий GCC 4.9 убрал этот, сохраняющий аргументы, код?
Потому что он провел дополнительную работу по оптимизации и сделал вывод,
что аргументы функции не понадобятся в будущем и регистры W0...W2 также
не будут использоваться.
Также мы видим пару инструкций MUL/ADD вместо одной MADD.
1.14.4. MIPS
Листинг 1.99: Оптимизирующий GCC 4.4.5
.text:00000000 f:
; $a0=a
; $a1=b
; $a2=c
.text:00000000
mult
$a1,
.text:00000004
mflo
$v0
.text:00000008
jr
$ra
.text:0000000C
addu
$v0,
; результат в $v0 во время выхода
.text:00000010 main:
.text:00000010
.text:00000010 var_10 = −0x10
.text:00000010 var_4
= −4
.text:00000010
.text:00000010
lui
$gp,
.text:00000014
addiu
$sp,
.text:00000018
la
$gp,
.text:0000001C
sw
$ra,
.text:00000020
sw
$gp,
; установить c:
.text:00000024
li
$a2,
; установить a:
.text:00000028
li
$a0,
.text:0000002C
jal
f
; установить b:
.text:00000030
li
$a1,
; результат сейчас в $v0
.text:00000034
lw
$gp,
.text:00000038
lui
$a0,
.text:0000003C
lw
$t9,
.text:00000040
la
$a0,
.text:00000044
jalr
$t9
84 Также
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
142
; взять результат ф-ции f() и передать его
; как второй аргумент в printf():
.text:00000048
move
$a1, $v0
; branch delay slot
.text:0000004C
lw
$ra, 0x20+var_4($sp)
.text:00000050
move
$v0, $zero
.text:00000054
jr
$ra
.text:00000058
addiu
$sp, 0x20
; branch delay slot
Первые 4 аргумента функции передаются в четырех регистрах с префиксами
A-.
В MIPS есть два специальных регистра: HI и LO, которые выставляются в 64битный результат умножения во время исполнения инструкции MULT.
К регистрам можно обращаться только используя инструкции MFLO и MFHI. Здесь
MFLO берет младшую часть результата умножения и записывает в $V0. Так что
старшая 32-битная часть результата игнорируется (содержимое регистра HI
не используется). Действительно, мы ведь работаем с 32-битным типом int.
И наконец, ADDU («Add Unsigned» — добавить беззнаковое) прибавляет значение третьего аргумента к результату.
В MIPS есть две разных инструкции сложения: ADD и ADDU. На самом деле, дело не в знаковых числах, а в исключениях: ADD может вызвать исключение во
время переполнения. Это иногда полезно85 и поддерживается, например, в ЯП
Ada.
ADDU не вызывает исключения во время переполнения. А так как Си/Си++не
поддерживает всё это, мы видим здесь ADDU вместо ADD.
32-битный результат оставляется в $V0.
В main() есть новая для нас инструкция: JAL («Jump and Link»). Разница между
JAL и JALR в том, что относительное смещение кодируется в первой инструкции, а JALR переходит по абсолютному адресу, записанному в регистр («Jump
and Link Register»).
Обе функции f() и main() расположены в одном объектном файле, так что
относительный адрес f() известен и фиксирован.
1.15. Ещё о возвращаемых результатах
Результат выполнения функции в x86 обычно возвращается 86 через регистр
EAX, а если результат имеет тип байт или символ (char), то в самой младшей
части EAX — AL. Если функция возвращает число с плавающей запятой, то будет использован регистр FPU ST(0). В ARM обычно результат возвращается в
регистре R0.
85 http://blog.regehr.org/archives/1154
86 См.
также: MSDN: Return Values (C++): MSDN
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
143
1.15.1. Попытка использовать результат функции возвращающей void
Кстати, что будет, если возвращаемое значение в функции main() объявлять
не как int, а как void? Т.н. startup-код вызывает main() примерно так:
push
push
push
call
push
call
envp
argv
argc
main
eax
exit
Иными словами:
exit(main(argc,argv,envp));
Если вы объявите main() как void, и ничего не будете возвращать явно (при
помощи выражения return), то в единственный аргумент exit() попадет то, что
лежало в регистре EAX на момент выхода из main(). Там, скорее всего, будет
какие-то случайное число, оставшееся от работы вашей функции. Так что код
завершения программы будет псевдослучайным.
Мы можем это проиллюстрировать. Заметьте, что у функции main() тип возвращаемого значения именно void:
#include
void main()
{
printf ("Hello, world!\n");
};
Скомпилируем в Linux.
GCC 4.8.1 заменила printf() на puts() (мы видели это прежде: 1.5.3 (стр. 28)),
но это нормально, потому что puts() возвращает количество выведенных символов, так же как и printf(). Обратите внимание на то, что EAX не обнуляется
перед выходом из main(). Это значит что EAX перед выходом из main() содержит то, что puts() оставляет там.
Листинг 1.100: GCC 4.8.1
.LC0:
.string "Hello, world!"
main:
push
mov
and
sub
mov
call
leave
ret
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
144
Напишем небольшой скрипт на bash, показывающий статус возврата («exit
status» или «exit code»):
Листинг 1.101: tst.sh
#!/bin/sh
./hello_world
echo $?
И запустим:
$ tst.sh
Hello, world!
14
14 это как раз количество выведенных символов. Количество выведенных символов проскальзывает из printf() через EAX/RAX в «exit code».
Кстати, когда в Hex-Rays мы разбираем C++ код, нередко можно наткнуться
на ф-цию, которая заканчивается деструктором какого-либо класса:
...
call
mov
pop
pop
mov
add
retn
??1CString@@QAE@XZ ; CString::~CString(void)
ecx, [esp+30h+var_C]
edi
ebx
large fs:0, ecx
esp, 28h
По стандарту С++ деструкторы ничего не возвращают, но когда Hex-Rays об
этом не знает и думает, что и деструктор и эта ф-ция по умолчанию возвращает int, то на выходе получается такой код:
...
return CString::~CString(&Str);
}
1.15.2. Что если не использовать результат функции?
printf() возвращает количество успешно выведенных символов, но результат
работы этой функции редко используется на практике.
Можно даже явно вызывать функции, чей смысл именно в возвращаемых значениях, но явно не использовать их:
int f()
{
// пропускаем первые 3 случайных значения:
rand();
rand();
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
145
rand();
// и используем 4-е:
return rand();
};
Результат работы rand() остается в EAX во всех четырех случаях. Но в первых
трех случаях значение, лежащее в EAX, просто не используется.
1.15.3. Возврат структуры
Вернемся к тому факту, что возвращаемое значение остается в регистре EAX.
Вот почему старые компиляторы Си не способны создавать функции, возвращающие нечто большее, нежели помещается в один регистр (обычно тип int),
а когда нужно, приходится возвращать через указатели, указываемые в аргументах. Так что, как правило, если функция должна вернуть несколько значений, она возвращает только одно, а остальные — через указатели. Хотя позже
и стало возможным, вернуть, скажем, целую структуру, но этот метод до сих
пор не очень популярен. Если функция должна вернуть структуру, вызывающая функция должна сама, скрыто и прозрачно для программиста, выделить
место и передать указатель на него в качестве первого аргумента. Это почти
то же самое что и сделать это вручную, но компилятор прячет это.
Небольшой пример:
struct s
{
int a;
int b;
int c;
};
struct s get_some_values (int a)
{
struct s rt;
rt.a=a+1;
rt.b=a+2;
rt.c=a+3;
return rt;
};
$T3853 это имя внутреннего макроса для передачи указателя на структуру.
Этот пример можно даже переписать, используя расширения C99:
struct s
{
int a;
int b;
int c;
};
struct s get_some_values (int a)
{
return (struct s){.a=a+1, .b=a+2, .c=a+3};
};
Листинг 1.102: GCC 4.8.1
_get_some_values proc near
ptr_to_struct
a
Как видно, функция просто заполняет поля в структуре, выделенной вызывающей функцией. Как если бы передавался просто указатель на структуру. Так
что никаких проблем с эффективностью нет.
1.16. Указатели
1.16.1. Возврат значений
Указатели также часто используются для возврата значений из функции (вспомните случай со scanf() (1.12 (стр. 89))).
Например, когда функции нужно вернуть сразу два значения.
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
147
Пример с глобальными переменными
#include
void f1 (int x, int y, int ∗sum, int ∗product)
{
∗sum=x+y;
∗product=x∗y;
};
int sum, product;
void main()
{
f1(123, 456, &sum, &product);
printf ("sum=%d, product=%d\n", sum, product);
};
Это компилируется в:
Листинг 1.103: Оптимизирующий MSVC 2010 (/Ob0)
COMM
_product:DWORD
COMM
_sum:DWORD
$SG2803 DB
'sum=%d, product=%d', 0aH, 00H
_x$ = 8
_y$ = 12
_sum$ = 16
_product$ = 20
_f1
PROC
mov
mov
lea
imul
mov
push
mov
mov
mov
pop
ret
_f1
ENDP
_main
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
149
Посмотрим это в OllyDbg:
Рис. 1.24: OllyDbg: передаются адреса двух глобальных переменных в f1()
В начале адреса обоих глобальных переменных передаются в f1(). Можно нажать «Follow in dump» на элементе стека и в окне слева увидим место в сегменте данных, выделенное для двух переменных.
Эти переменные обнулены, потому что по стандарту неинициализированные
данные (BSS) обнуляются перед началом исполнения: [ISO/IEC 9899:TC3 (C C99
standard), (2007)6.7.8p10].
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
150
И они находятся в сегменте данных, о чем можно удостовериться, нажав Alt-M
и увидев карту памяти:
Рис. 1.25: OllyDbg: карта памяти
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
151
Трассируем (F7) до начала исполнения f1():
Рис. 1.26: OllyDbg: начало работы f1()
В стеке видны значения 456 (0x1C8) и 123 (0x7B), а также адреса двух глобальных переменных.
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
152
Трассируем до конца f1(). Мы видим в окне слева, как результаты вычисления
появились в глобальных переменных:
Рис. 1.27: OllyDbg: f1() заканчивает работу
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
153
Теперь из глобальных переменных значения загружаются в регистры для передачи в printf():
Рис. 1.28: OllyDbg: адреса глобальных переменных передаются в printf()
Пример с локальными переменными
Немного переделаем пример:
Листинг 1.104: теперь переменные локальные
void main()
{
int sum, product; // теперь эти переменные для этой ф-ции --локальные
f1(123, 456, &sum, &product);
printf ("sum=%d, product=%d\n", sum, product);
};
Код функции f1() не изменится. Изменится только main():
Листинг 1.105: Оптимизирующий MSVC 2010 (/Ob0)
_product$ = −8
_sum$ = −4
_main
PROC
; Line 10
sub
; Line 13
lea
push
lea
push
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
155
Снова посмотрим в OllyDbg. Адреса локальных переменных в стеке это 0x2EF854
и 0x2EF858. Видно, как они заталкиваются в стек:
Рис. 1.29: OllyDbg: адреса локальных переменных заталкиваются в стек
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
156
Начало работы f1(). В стеке по адресам 0x2EF854 и 0x2EF858 пока находится
случайный мусор:
Рис. 1.30: OllyDbg: f1() начинает работу
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
157
Конец работы f1():
Рис. 1.31: OllyDbg: f1() заканчивает работу
В стеке по адресам 0x2EF854 и 0x2EF858 теперь находятся значения 0xDB18 и
0x243, это результаты работы f1().
Вывод
f1() может одинаково хорошо возвращать результаты работы в любые места
памяти. В этом суть и удобство указателей. Кстати, references в Си++работают
точно так же. Читайте больше об этом: (3.19.3 (стр. 728)).
1.16.2. Обменять входные значения друг с другом
Вот так:
#include
#include
void swap_bytes (unsigned char∗ first, unsigned char∗ second)
{
unsigned char tmp1;
unsigned char tmp2;
tmp1=∗first;
tmp2=∗second;
∗first=tmp2;
∗second=tmp1;
};
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
158
int main()
{
// копируем строку в кучу, чтобы у нас была возможность эту самую
строку модифицировать
char ∗s=strdup("string");
// меняем 2-й и 3-й символы
swap_bytes (s+1, s+2);
printf ("%s\n", s);
};
Как видим, байты загружаются в младшие 8-битные части регистров ECX и EBX
используя MOVZX (так что старшие части регистров очищаются), затем байты
записываются назад в другом порядке.
Листинг 1.106: Optimizing GCC 5.4
swap_bytes:
push
mov
mov
movzx
movzx
mov
mov
pop
ret
Адреса обоих байтов берутся из аргументов и во время исполнения ф-ции находятся в регистрах EDX и EAX.
Так что исопльзуем указатели — вероятно, без них нет способа решить эту
задачу лучше.
1.17. Оператор GOTO
Оператор GOTO считается анти-паттерном, см: [Edgar Dijkstra, Go To Statement
Considered Harmful (1968)87 ]. Но тем не менее, его можно использовать в разумных пределах, см: [Donald E. Knuth, Structured Programming with go to Statements
(1974)88 ] 89 .
Вот простейший пример:
#include
int main()
{
87 http://yurichev.com/mirrors/Dijkstra68.pdf
88 http://yurichev.com/mirrors/KnuthStructuredProgrammingGoTo.pdf
89 В
[Денис Юричев, Заметки о языке программирования Си/Си++] также есть примеры.
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
Выражение goto заменяется инструкцией JMP, которая работает точно также:
безусловный переход в другое место. Вызов второго printf() может исполнится только при помощи человеческого вмешательства, используя отладчик
или модифицирование кода.
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
160
Это также может быть простым упражнением на модификацию кода.
Откроем исполняемый файл в Hiew:
Рис. 1.32: Hiew
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
161
Поместите курсор по адресу JMP (0x410), нажмите F3 (редактирование), нажмите два нуля, так что опкод становится EB 00:
Рис. 1.33: Hiew
Второй байт опкода JMP это относительное смещение от перехода. 0 означает
место прямо после текущей инструкции. Теперь JMP не будет пропускать следующий вызов printf(). Нажмите F9 (запись) и выйдите. Теперь мы запускаем
исполняемый файл и видим это:
Листинг 1.108: Результат
C:\...>goto.exe
begin
skip me!
end
Подобного же эффекта можно достичь, если заменить инструкцию JMP на две
инструкции NOP. NOP имеет опкод 0x90 и длину в 1 байт, так что нужно 2 инструкции для замены.
1.17.1. Мертвый код
Вызов второго printf() также называется «мертвым кодом» («dead code») в
терминах компиляторов. Это значит, что он никогда не будет исполнен. Так
что если вы компилируете этот пример соптимизацией, компилятор удаляет
«мертвый код» не оставляя следа:
Листинг 1.109: Оптимизирующий MSVC 2012
$SG2981 DB
'begin', 0aH, 00H
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
Впрочем, строку «skip me!» компилятор убрать забыл.
1.17.2. Упражнение
Попробуйте добиться того же самого в вашем любимом компиляторе и отладчике.
1.18. Условные переходы
1.18.1. Простой пример
#include
void f_signed (int a, int b)
{
if (a>b)
printf ("a>b\n");
if (a==b)
printf ("a==b\n");
if (ab)
printf ("a>b\n");
if (a==b)
printf ("a==b\n");
if (ab\n, а BLGT вызывает printf(). Следовательно, эти
инструкции с суффиксом -GT исполнятся только в том случае, если значение
в R0 (там a) было больше, чем значение в R4 (там b).
Далее мы увидим инструкции ADREQ и BLEQ. Они работают так же, как и ADR и
BL, но исполнятся только если значения при последнем сравнении были равны.
Перед ними расположен ещё один CMP, потому что вызов printf() мог испортить состояние флагов.
Далее мы увидим LDMGEFD. Эта инструкция работает так же, как и LDMFD90 , но
сработает только если в результате сравнения одно из значений было больше
или равно второму (Greater or Equal). Смысл инструкции LDMGEFD SP!, {R4-R6,PC}
в том, что это как бы эпилог функции, но он сработает только если a >= b, только тогда работа функции закончится.
Но если это не так, то есть a < b, то исполнение дойдет до следующей инструкции LDMFD SP!, {R4-R6,LR}. Это ещё один эпилог функции. Эта инструкция
восстанавливает состояние регистров R4-R6, но и LR вместо PC, таким образом, пока что, не делая возврата из функции.
Последние две инструкции вызывают printf() со строкой «ab)
beq
.L20
; Branch if Equal (переход, если равно) (a==b)
bge
.L15
; Branch if Greater than or Equal (переход, если
больше или равно) (a>=b) (здесь это невозможно)
; ab)
cmp
w19, w1
beq
.L26
; Branch if Equal (переход, если равно) (a==b)
.L23:
bcc
.L27
; Branch if Carry Clear (если нет переноса)(если
меньше, чем) (a 16) # "a>b"
.text:00000040
addiu
$a0, $v0, (unk_230 & 0xFFFF) # "a>b"
.text:00000044
lw
$v0, (puts & 0xFFFF)($gp)
.text:00000048
or
$at, $zero ; NOP
.text:0000004C
move
$t9, $v0
.text:00000050
jalr
$t9
.text:00000054
or
$at, $zero ; branch delay slot, NOP
.text:00000058
lw
$gp, 0x20+var_10($fp)
.text:0000005C
.text:0000005C loc_5C:
# CODE XREF: f_signed+34
.text:0000005C
lw
$v1, 0x20+arg_0($fp)
.text:00000060
lw
$v0, 0x20+arg_4($fp)
.text:00000064
or
$at, $zero ; NOP
; проверить a==b, перейти на loc_90, если это не так:
.text:00000068
bne
$v1, $v0, loc_90
.text:0000006C
or
$at, $zero ; branch delay slot, NOP
; условие верно, вывести "a==b" и закончить:
.text:00000070
lui
$v0, (aAB >> 16) # "a==b"
.text:00000074
addiu
$a0, $v0, (aAB & 0xFFFF) # "a==b"
.text:00000078
lw
$v0, (puts & 0xFFFF)($gp)
.text:0000007C
or
$at, $zero ; NOP
.text:00000080
move
$t9, $v0
.text:00000084
jalr
$t9
.text:00000088
or
$at, $zero ; branch delay slot, NOP
.text:0000008C
lw
$gp, 0x20+var_10($fp)
.text:00000090
.text:00000090 loc_90:
# CODE XREF: f_signed+68
.text:00000090
lw
$v1, 0x20+arg_0($fp)
.text:00000094
lw
$v0, 0x20+arg_4($fp)
.text:00000098
or
$at, $zero ; NOP
; проверить условие $v1 16) # "a_
Посмотрим внутри исходного кода CRT компилятора Borland C++ 3.1, файл
c0.asm:
; _checknull()
check for null pointer zapping copyright message
...
; Check for null pointers before exit
__checknull
PROC
PUBLIC
DIST
__checknull
IF
LDATA EQ false
__TINY__
push
si
push
di
mov
es, cs:DGROUP@@
xor
ax, ax
mov
si, ax
mov
cx, lgth_CopyRight
ComputeChecksum label
near
add
al, es:[si]
adc
ah, 0
inc
si
loop
ComputeChecksum
sub
ax, CheckSum
jz
@@SumOK
mov
cx, lgth_NullCheck
mov
dx, offset DGROUP: NullCheck
call
ErrorDisplay
@@SumOK:
pop
di
pop
si
ENDIF
ENDIF
IFNDEF
_DATA
SEGMENT
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
792
; Magic symbol used by the debug info to locate the data segment
public DATASEG@
DATASEG@
label
byte
; The CopyRight string must NOT be moved or changed without
; changing the null pointer check logic
CopyRight
lgth_CopyRight
Модель памяти в MS-DOS крайне странная (10.6 (стр. 1274)), и, вероятно, её и
не нужно изучать, если только вы не фанат ретрокомпьютинга или ретрогейминга. Одну только вещь можно держать в памяти, это то, что сегмент памяти
(включая сегмент данных) в MS-DOS это место где хранится код или данные,
но в отличие от “серьезных” ОС, он начинается с нулевого адреса.
И в Borland C++ CRT, сегмент данных начинается с 4-х нулевых байт и строки
копирайта “Borland C++ - Copyright 1991 Borland Intl.”. Целостность 4-х нулевых байт и текстовой строки проверяется в конце, и если что-то нарушено,
выводится сообщение об ошибке.
Но зачем? Запись по нулевому указателю это распространенная ошибка в Си/Си++, и если вы делаете это в *NIX или Windows, ваше приложение упадет. В
MS-DOS нет защиты памяти, так что это приходится проверять в CRT во время
выхода, пост-фактум. Если вы видите это сообщение, значит ваша программа
в каком-то месте что-то записала по нулевому адресу.
Наша программа это сделала. И вот почему число 1234 было прочитано корректно: потому что оно было записано на месте первых 4-х байт. Контрольная
сумма во время выхода неверна (потому что наше число там осталось), так что
сообщение было выведено.
Прав ли я? Я переписал программу для проверки моих предположений:
#include
int main()
{
int ∗ptr=NULL;
∗ptr=1234;
printf ("Now let's read at NULL\n");
printf ("%d\n", ∗ptr);
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
793
∗ptr=0; // psst, cover our tracks!
};
Программа исполняется без ошибки во время выхода.
Хотя и метод предупреждать о записи по нулевому указателю имел смысл в
MS-DOS, вероятно, это всё может использоваться и сегодня, на маломощных
MCU без защиты памяти и/или MMU40 .
Почему кому-то может понадобиться писать по нулевому адресу?
Но почему трезвомыслящему программисту может понадобиться записывать
что-то по нулевому адресу? Это может быть сделано случайно, например, указатель должен быть инициализирован и указывать на только что выделенный
блок в памяти, а затем должен быть передан в какую-то ф-цию, возвращающую данные через указатель.
int ∗ptr=NULL;
... мы забыли выделить память и инициализировать ptr
strcpy (ptr, buf); // strcpy() завершает работу молча, потому что в MS-DOS
нет защиты памяти
И даже хуже:
int ∗ptr=malloc(1000);
... мы забыли проверить, действительно ли память была выделена: это же MS−⤦
Ç DOS и у тогдашних компьютеров было мало памяти,
... и нехватка памяти была обычной ситуацией.
... если malloc() вернул NULL, тогда ptr будет тоже NULL.
strcpy (ptr, buf); // strcpy() завершает работу молча, потому что в MS-DOS
нет защиты памяти
Писать по нулевому адресу намеренно
Вот пример из dmalloc41 , портабельный (переносимый) способ сгенерировать
core dump, в отсутствии иных способов:
3.4 Generating a Core File on Errors
====================================
If the `error−abort' debug token has been enabled, when the library
detects any problems with the heap memory, it will immediately attempt
to dump a core file. ∗Note Debug Tokens::. Core files are a complete
copy of the program and it's state and can be used by a debugger to see
specifically what is going on when the error occurred. ∗Note Using
With a Debugger::. By default, the low, medium, and high arguments to
40 Memory
Management Unit
41 http://dmalloc.com/
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
794
the library utility enable the `error−abort' token. You can disable
this feature by entering `dmalloc −m error−abort' (−m for minus) to
remove the `error−abort' token and your program will just log errors
and continue. You can also use the `error−dump' token which tries to
dump core when it sees an error but still continue running. ∗Note
Debug Tokens::.
When a program dumps core, the system writes the program and all of
its memory to a file on disk usually named `core'. If your program is
called `foo' then your system may dump core as `foo.core'. If you are
not getting a `core' file, make sure that your program has not changed
to a new directory meaning that it may have written the core file in a
different location. Also insure that your program has write privileges
over the directory that it is in otherwise it will not be able to dump
a core file. Core dumps are often security problems since they contain
all program memory so systems often block their being produced. You
will want to check your user and system's core dump size ulimit
settings.
The library by default uses the `abort' function to dump core which
may or may not work depending on your operating system. If the
following program does not dump core then this may be the problem. See
`KILL_PROCESS' definition in `settings.dist'.
main()
{
abort();
}
If `abort' does work then you may want to try the following setting
in `settings.dist'. This code tries to generate a segmentation fault
by dereferencing a `NULL' pointer.
#define KILL_PROCESS
{ int ∗_int_p = 0L; ∗_int_p = 1; }
NULL в Си/Си++
NULL в C/C++ это просто макрос, который часто определяют так:
#define NULL
((void∗)0)
( libio.h file )
void* это тип данных, отражающий тот факт, что это указатель, но на значение
неизвестного типа (void).
NULL обычно используется чтобы показать отсутствие объекта. Например, у
вас есть односвязный список, и каждый узел имеет значение (или указатель
на значение) и указатель вроде next. Чтобы показать, что следующего узла
нет, в поле next записывается 0. (Остальные решения просто хуже.) Вероятно, вы можете использовать какую-то крайне экзотическую среду, где можно
выделить память по нулевому адресу. Как вы будете показывать отсутствие
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
795
следующего узла? Какой-нибудь magic number? Может быть -1? Или дополнительным битом?
В Википедии мы можем найти это:
In fact, quite contrary to the zero page’s original preferential use,
some modern operating systems such as FreeBSD, Linux and Microsoft
Windows[2] actually make the zero page inaccessible to trap uses of
NULL pointers.
( https://en.wikipedia.org/wiki/Zero_page )
Нулевой указатель на ф-цию
Можно вызывать ф-ции по их адресу. Например, я компилирую это при помощи
MSVC 2010 и запускаю в Windows 7:
#include
#include
int main()
{
printf ("0x%x\n", &MessageBoxA);
};
Результат 0x7578feae, и он не меняется и после того, как я запустил это несколько раз, потому что user32.dll (где находится ф-ция MessageBoxA) всегда загружается по одному и тому же адресу. И потому что ASLR42 не включено (тогда
результат был бы всё время разным).
Вызовем ф-цию MessageBoxA() по адресу:
#include
#include
typedef int (∗msgboxtype)(HWND hWnd, LPCTSTR lpText, LPCTSTR lpCaption,
Ç UINT uType);
int main()
{
msgboxtype msgboxaddr=0x7578feae;
// заставить загрузиться DLL в память процесса,
// т.к., наш код не использует никакую ф-цию из user32.dll,
// и DLL не импортируется
LoadLibrary ("user32.dll");
msgboxaddr(NULL, "Hello, world!", "hello", MB_OK);
};
42 Address
Space Layout Randomization
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
⤦
796
Странно выглядит, но работает в Windows 7 x86.
Это часто используется в шеллкодах, потому что оттуда трудно вызывать фции из DLL по их именам. А ASLR это контрмера.
И вот теперь что по-настоящему странно выглядит, некоторые программисты
на Си для встраиваемых (embedded) систем, могут быть знакомы с таким кодом:
int reset()
{
void (∗foo)(void) = 0;
foo();
};
Кому понадобится вызывать ф-цию по адресу 0? Это портабельный способ перейти на нулевой адрес. Множество маломощных микроконтроллеров не имеют защиты памяти или MMU, и после сброса, они просто начинают исполнять
код по нулевому адресу, где может быть записан инициализирующий код. Так
что переход по нулевому адресу это способ сброса. Можно использовать и
inline-ассемблер, но если это неудобно, тогда можно использовать этот портабельный метод.
Это даже корректно компилируется при помощи GCC 4.8.4 на Linux x64:
reset:
sub
xor
call
add
ret
rsp, 8
eax, eax
rax
rsp, 8
То обстоятельство, что указатель стека сдвинут, это не проблема: инициализирующий код в микроконтроллерах обычно полностью игнорирует состояние
регистров и памяти и загружает всё “с чистого листа”.
И конечно, этот код упадет в *NIX или Windows, из-за защиты памяти, и даже
если бы её не было, по нулевому адресу нет никакого кода.
В GCC даже есть нестандартное расширение, позволяющее перейти по определенному адресу, вместо того чтобы вызывать ф-цию: http://gcc.gnu.org/
onlinedocs/gcc/Labels-as-Values.html.
3.21.5. Массив как аргумент функции
Кто-то может спросить, какая разница между объявлением аргумента ф-ции
как массива и как указателя?
Как видно, разницы вообще нет:
void write_something1(int a[16])
{
a[5]=0;
};
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
797
void write_something2(int ∗a)
{
a[5]=0;
};
int f()
{
int a[16];
write_something1(a);
write_something2(a);
};
Оптимизирующий GCC 4.8.4:
write_something1:
mov
DWORD PTR [rdi+20], 0
ret
write_something2:
mov
DWORD PTR [rdi+20], 0
ret
Но вы можете объявлять массив вместо указателя для самодокументации, если размер массива известен зараннее и определен. И может быть, какой-нибудь
инструмент для статического анализа выявит возможное переполнение буфера. Или такие инструменты есть уже сегодня?
Некоторые люди, включая Линуса Торвальдса, критикуют эту возможность Си/Си++: https://lkml.org/lkml/2015/9/3/428.
В стандарте C99 имеется также ключевое слово static [ISO/IEC 9899:TC3 (C C99
standard), (2007) 6.7.5.3]:
If the keyword static also appears within the [ and ] of the array
type derivation, then for each call to the function, the value of
the corresponding actual argument shall provide access to the first
element of an array with at least as many elements as specified by
the size expression.
3.21.6. Указатель на функцию
Имя ф-ции в Си/Си++ без скобок, как “printf” это указатель на ф-цию типа void
(*)(). Попробуем прочитать содержимое ф-ции и пропатчить его:
#include
#include
void print_something ()
{
printf ("we are in %s()\n", __FUNCTION__);
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
798
};
int main()
{
print_something();
printf ("first 3 bytes: %x %x %x...\n",
∗(unsigned char∗)print_something,
∗((unsigned char∗)print_something+1),
∗((unsigned char∗)print_something+2));
∗(unsigned char∗)print_something=0xC3; // RET's opcode
printf ("going to call patched print_something():\n");
print_something();
printf ("it must exit at this point\n");
};
При запуске видно что первые 3 байта ф-ции это 55 89 e5. Действительно,
это опкоды инструкций PUSH EBP и MOV EBP, ESP (это опкоды x86). Но потом
процесс падает, потому что секция text доступна только для чтения.
Мы можем перекомпилировать наш пример и сделать так, чтобы секция text
была доступна для записи 43 :
gcc −−static −g −Wl,−−omagic −o example example.c
Это работает!
we are in print_something()
first 3 bytes: 55 89 e5...
going to call patched print_something():
it must exit at this point
3.21.7. Указатель на функцию: защита от копирования
Взломщик может найти ф-цию, проверяющую защиту и возвращать true или
false. Он(а) может вписать там XOR EAX,EAX / RETN или MOV EAX, 1 / RETN.
Может ли проверить целостность ф-ции? Оказывается, сделать это легко.
Судя по objdump, первые 3 байта ф-ции check_protection() это 0x55 0x89 0xE5
(учитывая, что это неоптимизирующий GCC):
#include
#include
int check_protection()
{
// do something
return 0;
// or return 1;
43 http://stackoverflow.com/questions/27581279/make-text-segment-writable-elf
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
799
};
int main()
{
if (check_protection()==0)
{
printf ("no protection installed\n");
exit(0);
};
// ...and then, at some very important point...
if (∗(((unsigned char∗)check_protection)+0) != 0x55)
{
printf ("1st byte has been altered\n");
// do something mean, add watermark, etc
};
if (∗(((unsigned char∗)check_protection)+1) != 0x89)
{
printf ("2nd byte has been altered\n");
// do something mean, add watermark, etc
};
if (∗(((unsigned char∗)check_protection)+2) != 0xe5)
{
printf ("3rd byte has been altered\n");
// do something mean, add watermark, etc
};
};
0000054d :
54d:
55
54e:
89 e5
550:
e8 b7 00 00 00
555:
05 7f 1a 00 00
55a:
b8 00 00 00 00
55f:
5d
560:
c3
push
mov
call
add
mov
pop
ret
%ebp
%esp,%ebp
60c
$0x1a7f,%eax
$0x0,%eax
%ebp
Если кто-то пропатчит начало ф-ции check_protection(), ваша программа может совершить что-то подлое, например, внезапно закончить работу. Чтобы
разобраться с таким трюком, взломщик может установить брякпоинт на чтение памяти, по адресу начала ф-ции. (В tracer-е для этого есть опция BPMx.)
3.21.8. Указатель на ф-цию: частая ошибка (или опечатка)
Печально известная ошибка/опечатка:
int expired()
{
// check license key, current date/time, etc
};
int main()
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
800
{
if (expired) // must be expired() here
{
print ("expired\n");
exit(0);
}
else
{
// do something
};
};
Т.к. имя ф-ции само по себе трактуется как указатель на ф-цию, или её адрес,
выражение if(function_name) работает как if(true).
К сожалению, компилятор с Си/Си++не выдает предупреждение об этом.
3.21.9. Указатель как идентификатор объекта
В ассемблере и Си нет возможностей ООП, но там вполне можно писать код в
стиле ООП (просто относитесь к структуре, как к объекту).
Интересно что, иногда, указатель на объект (или его адрес) называется идентификатором (в смысле сокрытия данных/инкапсуляции).
Например, LoadLibrary(), судя по MSDN44 , возвращает “handle” модуля 45 . Затем вы передаете этот “handle” в другую ф-цию вроде GetProcAddress(). Но
на самом деле, LoadLibrary() возвращает указатель на DLL-файл загруженный
(mapped) в памяти 46 . Вы можете прочитать два байта по адресу возвращенному LoadLibrary(), и это будет “MZ” (первые два байта любого файла типа
.EXE/.DLL в Windows).
Очевидно, Microsoft “скрывает” этот факт для обеспечения лучшей совместимости в будущем. Также, типы данных HMODULE и HINSTANCE имели другой
смысл в 16-битной Windows.
Возможно, это причина, почему printf() имеет модификатор “%p”, который
используется для вывода указателей (32-битные целочисленные на 32-битных
архитектурах, 64-битные на 64-битных, итд) в шестнадцатеричной форме. Адрес структуры сохраненный в отладочном протоколе может помочь в поисках
такого же в том же протоколе.
Вот например из исходного кода SQLite:
...
struct Pager {
sqlite3_vfs ∗pVfs;
44 Microsoft
/∗ Boolean. True if locking_mode==EXCLUSIVE ⤦
/∗ One of the PAGER_JOURNALMODE_∗ values ∗/
/∗ Use a rollback journal on this file ∗/
/∗ Do not sync the journal if true ∗/
3.22. Оптимизации циклов
3.22.1. Странная оптимизация циклов
Это самая простая (из всех возможных) реализация memcpy():
void memcpy (unsigned char∗ dst, unsigned char∗ src, size_t cnt)
{
size_t i;
for (i=0; ib=2;
strcpy (s−>s, STRING);
f(s);
};
Если коротко, это работает, потому что в Си нет проверок границ массивов. К
любому массиву относятся так, будто он бесконечный.
Проблема: после выделения, полный размер выделенного блока для структуры неизвестен (хотя известен менеджеру памяти), так что вы не можете заменить строку бо́ льшей строкой. Но вы бы смогли делать это, если бы поле было
определено как что-то вроде s[MAX_NAME].
Другими словами, вы имеете структуру плюс массив (или строку) спаянных
вместе в одном выделенном блоке памяти. Другая проблема еще в том, что вы
не можете объявить два таких массива в одной структуре, или объявить еще
одно поле после такого массива.
Более старые компиляторы требуют объявить массив хотя бы с одним элементом: s[1], более новые позволяют определять его как массив с переменной
длиной: s[]. В стандарте C99 это также называется flexible array member.
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
809
Читайте об этом больше в документации GCC47 , в документации MSDN48 .
Деннис Ритчи (один из создателей Си) называет этот трюк «unwarranted chumminess
with the C implementation» (вероятно, подтверждая хакерскую природу трюка).
Вам это может нравиться, или нет, вы можете использовать это или нет: но
это еще одна демонстрация того, как структуры располагаются в памяти, вот
почему я написал об этом.
3.23.3. Версия структуры в Си
Многие программисты под Windows видели это в MSDN:
SizeOfStruct
The size of the structure, in bytes. This member must be set to sizeof(⤦
Ç SYMBOL_INFO).
( https://msdn.microsoft.com/en-us/library/windows/desktop/ms680686(v=
vs.85).aspx )
Некоторые структуры вроде SYMBOL_INFO действительно начинаются с такого
поля. Почему? Это что-то вроде версии структуры.
Представьте, что у вас есть ф-ция, рисующая круги. Она берет один аргумент
- указатель на структуру с двумя полями: X, Y и радиус. И затем цветные дисплеи наводнили рынок, где-то в 80-х. И вы хотите добавить аргумент цвет в
ф-цию. Но, скажем так, вы не можете добавить еще один аргумент в нее (множество ПО используют ваше API49 и его нельзя перекомпилировать). И если
какое-то старое ПО использует ваше API с цветным дисплеем, пусть ваша фция рисует круг в цветах по умолчанию (черный и белый).
Позже вы добавляете еще одну возможность: круг может быть закрашен, и
можно выбирать тип заливки.
Вот одно из решений проблемы:
#include
struct ver1
{
size_t SizeOfStruct;
int coord_X;
int coord_Y;
int radius;
};
struct ver2
{
size_t SizeOfStruct;
int coord_X;
int coord_Y;
47 https://gcc.gnu.org/onlinedocs/gcc/Zero-Length.html
48 https://msdn.microsoft.com/en-us/library/b6fae073.aspx
49 Application
Programming Interface
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
810
int radius;
int color;
};
struct ver3
{
size_t SizeOfStruct;
int coord_X;
int coord_Y;
int radius;
int color;
int fill_brush_type; // 0 - не заливать круг
};
void draw_circle(struct ver3 ∗s) // здесь используется самая последняя версия
структуры
{
// мы полагаем что в SizeOfStruct всегда присутствуют поля coord_X и
coord_Y
printf ("Собираемся рисовать круг на %d:%d\n", s−>coord_X, s−>⤦
Ç coord_Y);
if (s−>SizeOfStruct>=sizeof(int)∗5)
{
// это минимум ver2, поле цвета присутствует
printf ("Собираемся установить цвет %d\n", s−>color);
}
if (s−>SizeOfStruct>=sizeof(int)∗6)
{
// это минимум ver3, присутствует поле с типом заливки
printf ("Мы собираемся залить его используя тип заливки %d\⤦
Ç n", s−>fill_brush_type);
}
};
// раннее ПО
void call_as_ver1()
{
struct ver1 s;
s.SizeOfStruct=sizeof(s);
s.coord_X=123;
s.coord_Y=456;
s.radius=10;
printf ("∗∗ %s()\n", __FUNCTION__);
draw_circle(&s);
};
// следующая версия
void call_as_ver2()
{
struct ver2 s;
s.SizeOfStruct=sizeof(s);
s.coord_X=123;
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
Другими словами, поле SizeOfStruct берет на себя роль поля версия структуры.
Это может быть перечисляемый тип (1, 2, 3, итд.), но установка поля SizeOfStruct
равным sizeof(struct...), это лучше защищено от ошибок: в вызываемом коде мы
просто пишем s.SizeOfStruct=sizeof(...).
В Си++ эта проблема решается наследованием (3.19.1 (стр. 713)). Просто расширяете ваш базовый класс (назовем его Circle), и затем вам нужен ColoredCircle,
а потом FilledColoredCircle, и так далее. Текущая версия объекта (или более
точно, текущий тип) будет определяться при помощи RTTI в Си++.
Так что если вы где-то в MSDN видите SizeOfStruct — вероятно, эта структура
уже расширялась в прошлом, как минимум один раз.
3.23.4. Файл с рекордами в игре «Block out» и примитивная
сериализация
Многие видеоигры имеют файл с рекордами, иногда называемый «Зал славы».
Древняя игра «Block out»50 (трехмерный тетрис из 1989) не исключение, вот
что мы можем увидеть в конце:
50 http://www.bestoldgames.net/eng/old-games/blockout.php
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
812
Рис. 3.4: Таблица рекордов
Мы можем увидеть, что после того как мы всякий раз добаяем свое имя, этот
файл меняется: BLSCORE.DAT.
% xxd −g 1 BLSCORE.DAT
00000000:
00000010:
00000020:
00000030:
00000040:
00000050:
00000060:
00000070:
00000080:
00000090:
000000a0:
000000b0:
000000c0:
000000d0:
000000e0:
000000f0:
00000100:
0a
00
2e
2d
00
4a
33
65
30
00
69
32
2e
38
00
2e
2d
00
30
2e
32
46
61
2d
2e
31
00
6c
37
2e
00
30
2e
32
58
33
2e
30
01
6d
32
2e
38
00
2e
2d
00
54
33
2e
30
65
2d
2e
31
00
65
37
2e
00
30
2e
32
7b
6f
2d
2e
31
6e
32
2e
38
00
73
2d
00
4d
33
2e
30
00
6d
32
2e
38
69
37
2e
00
30
2e
32
ea
69
2d
2e
31
00
2e
37
2e
00
Все записи и так хорошо видны. Самый первый байт, вероятно, это количество
записей. Второй это 0, и, на самом деле, число записей может быть 16-битным
значением, которое простирается на 2 байта.
После имени «Xenia» мы видим байты 0xDF и 0x01. У Xenia 479 очков, и это
именно 0x1DF в шестнадцатеричной системе. Так что значение рекорда, вероятно, 16-битное целочисленное, а может и 32-битное: после каждого по два
нулевых байта.
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
813
Подумаем теперь о том факте, что и элементы массива, и элементы структуры
всегда располагаются в памяти друг к другу впритык. Это позволяет там
записывать весь массив/структуру в файл используя простую ф-цию write() или
fwrite(), а затем восстанавливать его используя read() или fread(), настолько
всё просто. Это то, что сейчас называется сериализацией.
Чтение
Напишем программу на Си для чтения файла рекордов:
#include
#include
#include
#include
memcpy(), которая копирует 32-битные или 64-битные слова за раз, или даже
SIMD, здесь очевидно не сработают, потому как нужно использовать ф-цию
копирования работающую побайтово.
Теперь даже более сложный пример, вставьте 2 байта впереди строки:
`|h|e|l|l|o|...` −> `|.|.|h|e|l|l|o|...`
Теперь даже ф-ция работающая побайтово не сработает, нужно копировать
байты с конца.
Это тот редкий случай, когда x86 флаг DF нужно выставлять перед инструкцией REP MOVSB: DF определяет направление, и теперь мы должны двигаться
назад.
Обычная процедура memmove() работает примерно так: 1) если источник ниже назначения, копируем вперед; 2) если источник над назначением, копируем назад.
Это memmove() из uClibc:
void ∗memmove(void ∗dest, const
{
int eax, ecx, esi, edi;
__asm__ __volatile__(
"
movl
"
cmpl
"
je
В первом случае, REP MOVSB вызывается со сброшенным флагом DF. Во втором,
DF в начале выставляется, но потом сбрасывается.
Более сложный алгоритм имеет такую часть:
«если разница между источником и назначением больше чем ширина слова, копируем используя слова нежели байты, и используем побитовое копирование
для копирования невыровненных частей».
Так происходит в неоптимизированной части на Си в Glibc 2.24.
Учитывая всё это, memmove() может работать медленнее, чем memcpy(). Но
некоторые люди, включая Линуса Торвальдса, спорят53 что memcpy() должна быть синонимом memmove(), а последняя ф-ция должна в начале проверять, пересекаются ли буферы или нет, и затем вести себя как memcpy() или
memmove(). Все же, в наше время, проверка на пересекающиеся буферы это
очень дешевая операция.
3.24.1. Анти-отладочный прием
Я слышал об анти-отладочном приеме, где всё что вам нужно для падения
процесса это выставить DF: следующий вызов memcpy() приведет к падению,
потому что будет копировать назад. Но я не могу это проверить: похоже, все
процедуры копирования сбрасывают/выставляют DF, как им надо. С другой стороны, memmove() из uClibc, код которой я цитировал здесь, не имеет явного
сброса DF (он подразумевает, что DF всегда сброшен?), так что он может и
упасть.
3.25. setjmp/longjmp
setjmp/longjmp это механизм в Си, очень похожий на throw/catch в Си++ и других высокоуровневых ЯП. Вот пример из zlib:
...
53 https://bugzilla.redhat.com/show_bug.cgi?id=638477#c132
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
819
/∗ return if bits() or decode() tries to read past available input ∗/
if (setjmp(s.env) != 0)
/∗ if came back here via longjmp(),⤦
Ç ∗/
err = 2;
/∗ then skip decomp(), return ⤦
Ç error ∗/
else
err = decomp(&s); /∗ decompress ∗/
...
/∗ load at least need bits into val ∗/
val = s−>bitbuf;
while (s−>bitcnt < need) {
if (s−>left == 0) {
s−>left = s−>infun(s−>inhow, &(s−>in));
if (s−>left == 0) longjmp(s−>env, 1); /∗ out of input ∗/
...
if (s−>left == 0) {
s−>left = s−>infun(s−>inhow, &(s−>in));
if (s−>left == 0) longjmp(s−>env, 1); /∗ out of input ∗/
( zlib/contrib/blast/blast.c )
Вызов setjmp() сохраняет текущие PC, SP и другие регистры в структуре env,
затем возвращает 0.
В слуае ошибки, longjmp() телепортирует вас в место точно после вызова
setjmp(), как если бы вызов setjmp() вернул ненулевое значение (которое
было передано в longjmp()). Это напоминает нам сисколл fork() в UNIX.
Посмотрим в более дистиллированный пример:
#include
#include
jmp_buf env;
void f2()
{
printf ("%s() begin\n", __FUNCTION__);
// something odd happened here
longjmp (env, 1234);
printf ("%s() end\n", __FUNCTION__);
};
void f1()
{
printf ("%s() begin\n", __FUNCTION__);
f2();
printf ("%s() end\n", __FUNCTION__);
};
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
820
int main()
{
int err=setjmp(env);
if (err==0)
{
f1();
}
else
{
printf ("Error %d\n", err);
};
};
Если запустим, то увидим:
f1() begin
f2() begin
Error 1234
Структура jmp_buf обычно недокументирована, чтобы сохранить прямую совместимость.
Посмотрим, как setjmp() реализован в MSVC 2013 x64:
...
; RCX = address of jmp_buf
mov
mov
mov
mov
mov
mov
mov
mov
mov
lea
mov
mov
mov
stmxcsr
fnstcw
movdqa
movdqa
movdqa
movdqa
movdqa
movdqa
movdqa
movdqa
movdqa
movdqa
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
821
retn
Она просто заполняет структуру jmp_buf текущими значениями почти всех регистров. Также, текущее значение RA берется из стека и сохраняется в jmp_buf:
В будущем, оно будет испоьзовано как новое значение PC.
Теперь longjmp():
...
; RCX = address of jmp_buf
mov
mov
mov
mov
mov
mov
mov
mov
ldmxcsr
fnclex
fldcw
movdqa
movdqa
movdqa
movdqa
movdqa
movdqa
movdqa
movdqa
movdqa
movdqa
mov
mov
mov
jmp
Она просто восстанавливает (почти) все регистры, берет из структуры RA и
переходит туда. Эффект такой же, как если бы setjmp() вернула управление в
вызывающую ф-цию. Также, RAX выставляется такой же, как и второй аргумент
longjmp(). Это работает, как если бы setjmp() вернуло ненулевое значение в
самом начале.
Как побочный эффект восстановления SP, все значения в стеке, которые были
установлены и использованы между вызовами setjmp() и longjmp(), просто выкидываются. Они больше не будут использоваться. Следовательно, longjmp()
обычно делает переход назад 54 .
54 Впрочем,
существуют люди, которые используют всё это для куда более сложных вещей, вклю-
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
822
Это подразумевает, что в отличии от механизма throw/catch в Си++, память не
будет освобождаться, деструкторы не будут вызываться, итд. Следовательно,
эта техника иногда опасна. Тем не менее, всё это довольно популярно, до сих
пор. Это все еще используется в Oracle RDBMS.
Это также имеет неожиданный побочный эффект: если некий буфер был перезаписан внутри ф-ции (может даже из-за удаленной атаки), и ф-ция хочет
сообщить об ошибке, и вызывает longjmp(), перезаписанная часть стека становится просто неиспользованной.
В качестве упражнения, попробуйте понять, почему не все регистры сохраняются. Почему пропускаются регистры XMM0-XMM5 и другие?
3.26. Другие нездоровые хаки связанные со стеком
3.26.1. Доступ к аргументам и локальным переменным вызывающей ф-ции
Из основ Си/Си++мы знаем, что иметь доступ к аргументам ф-ции или её локальным переменным — невозможно.
Тем не менее, при помощи грязных хаков это возможно. Например:
#include
void f(char ∗text)
{
// распечатать стек
int ∗tmp=&text;
for (int i=0; itm_mon, t−>tm_mday,
t−>tm_hour, t−>tm_min, t−>tm_sec);
MessageBox (NULL, strbuf, "caption", MB_OK);
return 0;
};
WinMain
proc near
var_4
var_2
= word ptr −4
= word ptr −2
push
bp
mov
bp, sp
push
ax
push
ax
xor
ax, ax
call
time_
mov
[bp+var_4], ax
; low part of UNIX time
mov
[bp+var_2], dx
; high part of UNIX time
lea
ax, [bp+var_4]
; take a pointer of high part
call
localtime_
mov
bx, ax
; t
push
word ptr [bx]
; second
push
word ptr [bx+2] ; minute
push
word ptr [bx+4] ; hour
push
word ptr [bx+6] ; day
push
word ptr [bx+8] ; month
mov
ax, [bx+0Ah]
; year
add
ax, 1900
push
ax
mov
ax, offset a04d02d02d02d02 ;
"%04d-%02d-%02d %02d:%02d:%02d"
push
ax
mov
ax, offset strbuf
push
ax
call
sprintf_
add
sp, 10h
xor
ax, ax
; NULL
push
ax
push
ds
mov
ax, offset strbuf
push
ax
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
854
WinMain
push
mov
push
xor
push
call
xor
mov
pop
retn
endp
ds
ax, offset aCaption ; "caption"
ax
ax, ax
; MB_OK
ax
MESSAGEBOX
ax, ax
sp, bp
bp
0Ah
Время в формате UNIX это 32-битное значение, так что оно возвращается в паре регистров DX:AX и сохраняется в двух локальных 16-битных переменных.
Потом указатель на эту пару передается в функцию localtime(). Функция
localtime() имеет структуру struct tm расположенную у себя где-то внутри,
так что только указатель на нее возвращается. Кстати, это также означает,
что функцию нельзя вызывать еще раз, пока её результаты не были использованы.
Для функций time() и localtime() используется Watcom-соглашение о вызовах: первые четыре аргумента передаются через регистры AX, DX, BX и CX,
а остальные аргументы через стек. Функции, использующие это соглашение,
маркируется символом подчеркивания в конце имени.
Для вызова функции sprintf() используется обычное соглашение cdecl (6.1.1
(стр. 956)) вместо PASCAL или Watcom, так что аргументы передаются привычным образом.
Глобальные переменные
Это тот же пример, только переменные теперь глобальные:
#include
#include
#include
char strbuf[256];
struct tm ∗t;
time_t unix_time;
int PASCAL WinMain( HINSTANCE hInstance,
HINSTANCE hPrevInstance,
LPSTR lpCmdLine,
int nCmdShow )
{
unix_time=time(NULL);
t=localtime (&unix_time);
sprintf (strbuf, "%04d−%02d−%02d %02d:%02d:%02d", t−>tm_year+1900, ⤦
Ç t−>tm_mon, t−>tm_mday,
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
proc near
push
bp
mov
bp, sp
xor
ax, ax
call
time_
mov
unix_time_low, ax
mov
unix_time_high, dx
mov
ax, offset unix_time_low
call
localtime_
mov
bx, ax
mov
t, ax
; will not be used in future...
push
word ptr [bx]
; seconds
push
word ptr [bx+2]
; minutes
push
word ptr [bx+4]
; hour
push
word ptr [bx+6]
; day
push
word ptr [bx+8]
; month
mov
ax, [bx+0Ah]
; year
add
ax, 1900
push
ax
mov
ax, offset a04d02d02d02d02 ;
"%04d-%02d-%02d %02d:%02d:%02d"
push
ax
mov
ax, offset strbuf
push
ax
call
sprintf_
add
sp, 10h
xor
ax, ax
; NULL
push
ax
push
ds
mov
ax, offset strbuf
push
ax
push
ds
mov
ax, offset aCaption ; "caption"
push
ax
xor
ax, ax
; MB_OK
push
ax
call
MESSAGEBOX
xor
ax, ax
; return 0
pop
bp
retn
0Ah
WinMain
endp
t не будет использоваться, но компилятор создал код, записывающий в эту
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
856
переменную.
Потому что он не уверен, может быть это значение будет прочитано где-то в
другом модуле.
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
Глава 4
Java
4.1. Java
4.1.1. Введение
Есть немало известных декомпиляторов для Java (или для JVM-байткода вообще) 1 .
Причина в том что декомпиляция JVM-байткода проще чем низкоуровневого
x86-кода:
• Здесь намного больше информации о типах.
• Модель памяти в JVM более строгая и очерченная.
• Java-компилятор не делает никаких оптимизаций (это делает JVM JIT2 во
время исполнения), так что байткод в class-файлах легко читаем.
Когда знания JVM-байткода могут быть полезны?
• Мелкая/несложная работа по патчингу class-файлов без необходимости
снова компилировать результаты декомпилятора.
• Анализ обфусцированного кода.
• Анализ кода сгенерированного более новым Java-компилятором, для которого еще пока нет обновленного декомпилятора.
• Создание вашего собственного обфускатора.
• Создание кодегенератора компилятора (back-end), создающего код для
JVM (как Scala, Clojure, итд 3 ).
Начнем с простых фрагментов кода.
Если не указано иное, везде используется JDK 1.7.
1 Например,
JAD: http://varaneckas.com/jad/
compilation
3 Полный список: http://en.wikipedia.org/wiki/List_of_JVM_languages
2 Just-In-Time
857
858
Эта команда использовалась везде для декомпиляции class-файлов:
javap -c -verbose.
Эта книга использовалась мною для подготовки всех примеров: [Tim Lindholm,
Frank Yellin, Gilad Bracha, Alex Buckley, The Java(R) Virtual Machine Specification /
Java SE 7 Edition] 4 .
4.1.2. Возврат значения
Наверное, самая простая из всех возможных функций на Java это та, что возвращает некоторое значение.
О, и мы не должны забывать, что в Java нет «свободных» функций в общем
смысле, это «методы».
Каждый метод принадлежит какому-то классу, так что невозможно объявить
метод вне какого-либо класса.
Но мы все равно будем называть их «функциями», для простоты.
public class ret
{
public static int main(String[] args)
{
return 0;
}
}
Компилируем это:
javac ret.java
…и декомпилирую используя стандартную утилиту в Java:
javap −c −verbose ret.class
И получаем:
Листинг 4.1: JDK 1.7 (excerpt)
public static int main(java.lang.String[]);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=1, args_size=1
0: iconst_0
1: ireturn
Разработчики Java решили, что 0 это самая используемая константа в программировании, так что здесь есть отдельная однобайтная инструкция iconst_0,
заталкивающая 0 в стек 5 .
4 Также доступно здесь: https://docs.oracle.com/javase/specs/jvms/se7/jvms7.pdf; http://
docs.oracle.com/javase/specs/jvms/se7/html/
5 Так же как и в MIPS, где для нулевой константы имеется отдельный регистр: 1.5.4 (стр. 35).
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
859
Здесь есть также iconst_1 (заталкивающая 1), iconst_2, итд, вплоть до iconst_5.
Есть также iconst_m1 заталкивающая -1.
Стек также используется в JVM для передачи данных в вызывающие ф-ции, и
также для возврата значений. Так что iconst_0 заталкивает 0 в стек. ireturn
возвращает целочисленное значение (i в названии означает integer) из TOS6 .
Немного перепишем наш пример, теперь возвращаем 1234:
public class ret
{
public static int main(String[] args)
{
return 1234;
}
}
sipush (short integer) заталкивает значение 1234 в стек. short в имени означает,
что 16-битное значение будет заталкиваться в стек.
Число 1234 действительно помещается в 16-битное значение.
Как насчет бо́ льших значений?
public class ret
{
public static int main(String[] args)
{
return 12345678;
}
}
Листинг 4.3: Constant pool
...
#2 = Integer
12345678
...
public static int main(java.lang.String[]);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=1, args_size=1
6 Top
of Stack (вершина стека)
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
860
0: ldc
2: ireturn
#2
// int 12345678
Невозможно закодировать 32-битное число в опкоде какой-либо JVM-инструкции,
разработчики не оставили такой возможности.
Так что 32-битное число 12345678 сохранено в так называемом «constant pool»
(пул констант), который, так скажем, является библиотекой наиболее используемых констант (включая строки, объекты, итд).
Этот способ передачи констант не уникален для JVM.
MIPS, ARM и прочие RISC-процессоры не могут кодировать 32-битные числа в
32-битных опкодах, так что код для RISC-процессоров (включая MIPS и ARM)
должен конструировать значения в несколько шагов, или держать их в сегменте данных: 1.39.3 (стр. 567), 1.40.1 (стр. 571).
Код для MIPS также традиционно имеет пул констант, называемый «literal pool»,
это сегменты с названиями «.lit4» (для хранения 32-битных чисел с плавающей
точкой одинарной точности) и «.lit8»(для хранения 64-битных чисел с плавающей точкой двойной точности).
Попробуем некоторые другие типы данных!
Boolean:
public class ret
{
public static boolean main(String[] args)
{
return true;
}
}
public static boolean main(java.lang.String[]);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=1, args_size=1
0: iconst_1
1: ireturn
Этот JVM-байткод не отличается от того, что возвращает целочисленную 1.
32-битные слоты данных в стеке также используются для булевых значений,
как в Си/Си++.
Но нельзя использовать возвращаемое значение булевого типа как целочисленное и наоборот — информация о типах сохраняется в class-файлах и проверяется при запуске.
Та же история с 16-битным short:
public class ret
{
public static short main(String[] args)
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
…и char!
public class ret
{
public static char main(String[] args)
{
return 'A';
}
}
public static char main(java.lang.String[]);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=1, args_size=1
0: bipush
65
2: ireturn
bipush означает «push byte».
Нужно сказать, что char в Java, это 16-битный символ в кодировке UTF-16, и он
эквивалентен short, но ASCII-код символа «A» это 65, и можно воспользоваться
инструкцией для передачи байта в стек.
Попробуем также byte:
public class retc
{
public static byte main(String[]args)
{
return 123;
}
}
public static byte main(java.lang.String[]);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=1, args_size=1
0: bipush
123
2: ireturn
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
862
Кто-то может спросить, зачем заморачиваться использованием 16-битного типа short, который внутри все равно 32-битный integer?
Зачем использовать тип данных char, если это то же самое что и тип short?
Ответ прост: для контроля типов данных и читабельности исходников.
char может быть эквивалентом short, но мы быстро понимаем, что это ячейка
для символа в кодировке UTF-16, а не для какого-то другого целочисленного
значения.
Когда используем short, мы можем показать всем, что диапазон этой переменной ограничен 16-ю битами.
Очень хорошая идея использовать тип boolean где нужно, вместо int для тех
же целей, как это было в Си.
В Java есть также 64-битный целочисленный тип:
public class ret3
{
public static long main(String[] args)
{
return 1234567890123456789L;
}
}
Листинг 4.4: Constant pool
...
#2 = Long
1234567890123456789l
...
public static long main(java.lang.String[]);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: ldc2_w
#2
// long 1234567890123456789l
3: lreturn
64-битное число также хранится в пуле констант, ldc2_w загружает его и lreturn
(long return) возвращает его.
Инструкция ldc2_w также используется для загрузки чисел с плавающей точкой двойной точности (которые также занимают 64 бита) из пула констант:
public class ret
{
public static double main(String[] args)
{
return 123.456d;
}
}
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
dreturn означает «return double».
И наконец, числа с плавающей точкой одинарной точности:
public class ret
{
public static float main(String[] args)
{
return 123.456f;
}
}
Используемая здесь инструкция ldc та же, что и для загрузки 32-битных целочисленных чисел из пула констант.
freturn означает «return float».
А что насчет тех случаев, когда функция ничего не возвращает?
public class ret
{
public static void main(String[] args)
{
return;
}
}
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
Это означает, что инструкция return используется для возврата управления
без возврата какого-либо значения.
Зная все это, по последней инструкции очень легко определить тип возвращаемого значения функции (или метода).
4.1.3. Простая вычисляющая функция
Продолжим с простой вычисляющей функцией.
public class calc
{
public static int half(int a)
{
return a/2;
}
}
Это тот случай, когда используется инструкция iconst_2:
public static int half(int);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: iload_0
1: iconst_2
2: idiv
3: ireturn
iload_0 Берет нулевой аргумент функции и заталкивает его в стек. iconst_2
заталкивает в стек 2.
Вот как выглядит стек после исполнения этих двух инструкций:
+−−−+
TOS −>| 2 |
+−−−+
| a |
+−−−+
idiv просто берет два значения на вершине стека (TOS), делит одно на другое
и оставляет результат на вершине (TOS):
+−−−−−−−−+
TOS −>| result |
+−−−−−−−−+
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
865
ireturn берет его и возвращает.
Продолжим с числами с плавающей запятой, двойной точности:
public class calc
{
public static double half_double(double a)
{
return a/2.0;
}
}
Почти то же самое, но инструкция ldc2_w используется для загрузки константы 2.0 из пула констант.
Также, все три инструкции имеют префикс d, что означает, что они работают
с переменными типа double.
Теперь перейдем к функции с двумя аргументами:
public class calc
{
public static int sum(int a, int b)
{
return a+b;
}
}
public static int sum(int, int);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=2
0: iload_0
1: iload_1
2: iadd
3: ireturn
iload_0 загружает первый аргумент функции (a), iload_1 — второй (b).
Вот так выглядит стек после исполнения обоих инструкций:
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
866
+−−−+
TOS −>| b |
+−−−+
| a |
+−−−+
iadd складывает два значения и оставляет результат на TOS:
+−−−−−−−−+
TOS −>| result |
+−−−−−−−−+
Расширим этот пример до типа данных long:
public static long lsum(long a, long b)
{
return a+b;
}
Вторая инструкция lload берет второй аргумент из второго слота.
Это потому что 64-битное значение long занимает ровно два 32-битных слота.
Немного более сложный пример:
public class calc
{
public static int mult_add(int a, int b, int c)
{
return a∗b+c;
}
}
public static int mult_add(int, int, int);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=3
0: iload_0
1: iload_1
2: imul
3: iload_2
4: iadd
5: ireturn
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
867
Первый шаг это умножение. Произведение остается на TOS:
+−−−−−−−−−+
TOS −>| product |
+−−−−−−−−−+
iload_2 загружает третий аргумент (c) в стек:
+−−−−−−−−−+
TOS −>|
c
|
+−−−−−−−−−+
| product |
+−−−−−−−−−+
Теперь инструкция iadd может сложить два значения.
4.1.4. Модель памяти в JVM
x86 и другие низкоуровневые среды используют стек для передачи аргументов и как хранилище локальных переменных. JVM устроена немного иначе.
Тут есть:
• Массив локальных переменных (LVA7 ).
Используется как хранилище для аргументов функций и локальных переменных.
Инструкции вроде iload_0 загружают значения оттуда. istore записывает значения туда.
В начале идут аргументы функции: начиная с 0, или с 1 (если нулевой
аргумент занят указателем this.
Затем располагаются локальные переменные.
Каждый слот имеет размер 32 бита.
Следовательно, значения типов long и double занимают два слота.
• Стек операндов (или просто «стек»).
Используется для вычислений и для передачи аргументов во время вызова других функций.
В отличие от низкоуровневых сред вроде x86, здесь невозможно работать
со стеком без использования инструкций, которые явно заталкивают или
выталкивают значения туда/оттуда.
• Куча (heap). Используется как хранилище для объектов и массивов.
Эти 3 области изолированы друг от друга.
7 (Java)
Local Variable Array (массив локальных переменных)
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
868
4.1.5. Простой вызов функций
Math.random() возвращает псевдослучайное число в пределах [0.0 …1.0), но
представим, по какой-то причине, нам нужна функция, возвращающая число
в пределах [0.0 …0.5):
public class HalfRandom
{
public static double f()
{
return Math.random()/2;
}
}
invokestatic вызывает функцию Math.random() и оставляет результат на TOS.
Затем результат делится на 2.0 и возвращается.
Но как закодировано имя функции?
Оно закодировано в пуле констант используя выражение Methodref.
Оно определяет имена класса и метода.
Первое поле Methodref указывает на выражение Class, которое, в свою очередь, указывает на обычную текстовую строку («java/lang/Math»).
Второе выражение Methodref указывает на выражение NameAndType, которое
также имеет две ссылки на строки.
Первая строка это «random», это имя метода.
Вторая строка это «()D», которая кодирует тип функции. Это означает, что
возвращаемый тип — double (отсюда D в строке).
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
869
Благодаря этому 1) JVM проверяет корректность типов данных; 2) Java-декомпиляторы
могут восстанавливать типы данных из class-файлов.
Наконец попробуем пример «Hello, world!»:
public class HelloWorld
{
public static void main(String[] args)
{
System.out.println("Hello, World");
}
}
ldc по смещению 3 берет указатель (или адрес) на строку «Hello, World» в пуле
констант и заталкивает его в стек.
В мире Java это называется reference, но это скорее указатель или просто адрес
8
.
8О
разнице между указателями и reference в С++: 3.19.3 (стр. 728).
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
870
Уже знакомая нам инструкция invokevirtual берет информацию о функции
(или методе) println из пула констант и вызывает её.
Как мы можем знать, есть несколько методов println, каждый предназначен
для каждого типа данных.
В нашем случае, используется та версия println, которая для типа данных
String.
Что насчет первой инструкции getstatic?
Эта инструкция берет reference (или адрес) поля объекта System.out и заталкивает его в стек.
Это значение работает как указатель this для метода println.
Таким образом, внутри, метод println берет на вход два аргумента: 1) this, т.е.
указатель на объект 9 ; 2) адрес строки «Hello, World».
Действительно, println() вызывается как метод в рамках инициализированного объекта System.out.
Для удобства, утилита javap пишет всю эту информацию в комментариях.
4.1.6. Вызов beep()
Вот простейший вызов двух функций без аргументов:
public static void main(String[] args)
{
java.awt.Toolkit.getDefaultToolkit().beep();
};
public static void main(java.lang.String[]);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=1, args_size=1
0: invokestatic #2
// Method java/awt/Toolkit.⤦
Ç getDefaultToolkit:()Ljava/awt/Toolkit;
3: invokevirtual #3
// Method java/awt/Toolkit.beep:()V
6: return
Первая invokestatic по смещению 0 вызывает
java.awt.Toolkit.getDefaultToolkit(), которая возвращает
reference (указатель) на объект класса Toolkit.
Инструкция invokevirtual по смещению 3 вызывает метод beep() этого класса.
9 Или
«экземпляр класса» в некоторой русскоязычной литературе.
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
871
4.1.7. Линейный конгруэнтный ГПСЧ
Попробуем простой генератор псевдослучайных чисел, который мы однажды
уже рассматривали в этой книге (1.29 (стр. 432)):
public class LCG
{
public static int rand_state;
public void my_srand (int init)
{
rand_state=init;
}
public static int RNG_a=1664525;
public static int RNG_c=1013904223;
public int my_rand ()
{
rand_state=rand_state∗RNG_a;
rand_state=rand_state+RNG_c;
return rand_state & 0x7fff;
}
}
Здесь пара полей класса, которые инициализируются в начале. Но как?
В выводе javap мы можем найти конструктор класса:
static {};
flags: ACC_STATIC
Code:
stack=1, locals=0, args_size=0
0: ldc
#5
// int 1664525
2: putstatic
#3
// Field RNG_a:I
5: ldc
#6
// int 1013904223
7: putstatic
#4
// Field RNG_c:I
10: return
Так инициализируются переменные.
RNG_a занимает третий слот в классе и RNG_c — четвертый, и putstatic записывает туда константы.
Функция my_srand() просто записывает входное значение в
rand_state:
public void my_srand(int);
flags: ACC_PUBLIC
Code:
stack=1, locals=2, args_size=2
0: iload_1
1: putstatic
#2
// Field rand_state:I
4: return
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
872
iload_1 берет входное значение и заталкивает его в стек. Но почему не iload_0?
Это потому что эта функция может использовать поля класса, а переменная
this также передается в эту функцию как нулевой аргумент.
Поле rand_state занимает второй слот в классе, так что putstatic копирует
переменную из TOS во второй слот.
Теперь my_rand():
public int my_rand();
flags: ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: getstatic
#2
// Field rand_state:I
3: getstatic
#3
// Field RNG_a:I
6: imul
7: putstatic
#2
// Field rand_state:I
10: getstatic
#2
// Field rand_state:I
13: getstatic
#4
// Field RNG_c:I
16: iadd
17: putstatic
#2
// Field rand_state:I
20: getstatic
#2
// Field rand_state:I
23: sipush
32767
26: iand
27: ireturn
Она просто загружает все переменные из полей объекта, производит с ними
операции и обновляет значение rand_state, используя инструкцию putstatic.
По смещению 20, значение rand_state перезагружается снова (это потому что
оно было выброшено из стека перед этим, инструкцией putstatic).
Это выглядит как неэффективный код, но можете быть уверенными, JVM обычно достаточно хорош, чтобы хорошо оптимизировать подобные вещи.
4.1.8. Условные переходы
Перейдем к условным переходам.
public class abs
{
public static int abs(int a)
{
if (ab)
return b;
return a;
}
if_icmple выталкивает два значения и сравнивает их.
Если второе меньше первого (или равно), происходит переход на смещение 7.
Когда мы определяем функцию max() …
public static int max (int a, int b)
{
if (a>b)
return a;
return b;
}
…итоговый код точно такой же, только последние инструкции iload (на смещениях 5 и 7) поменяны местами:
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
Field java/lang/System.out:Ljava/io/⤦
String 100
// Method java/io/PrintStream.print:(⤦
// Field java/lang/System.out:Ljava/io/⤦
// String ==0
// Method java/io/PrintStream.print:(⤦
if_icmpge Выталкивает два значения и сравнивает их.
Если второй больше первого, или равен первому, происходит переход на смещение 14.
if_icmpne и if_icmple работают одинаково, но используются разные условия.
По смещению 43 есть также инструкция ifne.
Название неудачное, её было бы лучше назвать ifnz (переход если переменная на TOS не равна нулю).
И вот что она делает: производит переход на смещение 54, если входное значение не ноль.
Если ноль, управление передается на смещение 46, где выводится строка «==0».
N.B.: В JVM нет беззнаковых типов данных, так что инструкции сравнения работают только со знаковыми целочисленными значениями.
4.1.9. Передача аргументов
Теперь расширим пример min()/max():
public class minmax
{
public static int min (int a, int b)
{
if (a>b)
return b;
return a;
}
public static int max (int a, int b)
{
if (a>b)
return a;
return b;
}
public static void main(String[] args)
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
876
{
int a=123, b=456;
int max_value=max(a, b);
int min_value=min(a, b);
System.out.println(min_value);
System.out.println(max_value);
}
}
В другую функцию аргументы передаются в стеке, а возвращаемое значение
остается на TOS.
4.1.10. Битовые поля
Все побитовые операции работают также, как и в любой другой ISA:
public static int set (int a, int b)
{
return a | 1ExceptionAddress);
42 Также
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1001
if (ExceptionRecord−>ExceptionCode==0xE1223344)
{
printf ("That's for us\n");
// yes, we "handled" the exception
return ExceptionContinueExecution;
}
else if (ExceptionRecord−>ExceptionCode==EXCEPTION_ACCESS_VIOLATION⤦
Ç )
{
printf ("ContextRecord−>Eax=0x%08X\n", ContextRecord−>Eax);
// will it be possible to 'fix' it?
printf ("Trying to fix wrong pointer address\n");
ContextRecord−>Eax=(DWORD)&new_value;
// yes, we "handled" the exception
return ExceptionContinueExecution;
}
else
{
printf ("We do not handle this\n");
// someone else's problem
return ExceptionContinueSearch;
};
}
int main()
{
DWORD handler = (DWORD)except_handler; // take a pointer to our
handler
// install exception handler
__asm
{
record:
push
handler
push
FS:[0]
mov
FS:[0],ESP
}
// make EXCEPTION_REGISTRATION
// address of handler function
// address of previous handler
// add new EXECEPTION_REGISTRATION
RaiseException (0xE1223344, 0, 0, NULL);
// now do something very bad
int∗ ptr=NULL;
int val=0;
val=∗ptr;
printf ("val=%d\n", val);
// deinstall exception handler
__asm
{
record
mov
eax,[ESP]
mov
FS:[0], EAX
add
esp, 8
off stack
}
// remove our EXECEPTION_REGISTRATION
// get pointer to previous record
// install previous record
// clean our EXECEPTION_REGISTRATION
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1002
return 0;
}
Сегментный регистр FS: в win32 указывает на TIB. Самый первый элемент TIB
это указатель на последний обработчик в цепочке. Мы сохраняем его в стеке и записываем туда адрес своего обработчика. Эта структура называется
_EXCEPTION_REGISTRATION, это простейший односвязный список, и эти элементы хранятся прямо в стеке.
Листинг 6.22: MSVC/VC/crt/src/exsup.inc
_EXCEPTION_REGISTRATION struc
prev
dd
?
handler dd
?
_EXCEPTION_REGISTRATION ends
Так что каждое поле «handler» указывает на обработчик, а каждое поле «prev»
указывает на предыдущую структуру в цепочке обработчиков. Самая последняя структура имеет 0xFFFFFFFF (-1) в поле «prev».
Стек
TIB
…
+0: __except_list
FS:0
+4: …
Prev=0xFFFFFFFF
+8: …
Handle
функцияобработчик
…
Prev
функцияобработчик
Handle
…
Prev
функцияобработчик
Handle
…
После инсталляции своего обработчика, вызываем RaiseException()43 . Это пользовательские исключения. Обработчик проверяет код.
Если код 0xE1223344, то он возвращает ExceptionContinueExecution, что сигнализирует системе что обработчик скорректировал состояние CPU (обычно
это регистры EIP/ESP) и что ОС может возобновить исполнение треда. Если вы
немного измените код так что обработчик будет возвращать ExceptionContinueSearch,
то ОС будет вызывать остальные обработчики в цепочке, и вряд ли найдется
43 MSDN
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1003
тот, кто обработает ваше исключение, ведь информации о нем (вернее, его коде) ни у кого нет. Вы увидите стандартное окно Windows о падении процесса.
Какова разница между системными исключениями и пользовательскими? Вот
системные:
как определен в WinBase.h
EXCEPTION_ACCESS_VIOLATION
EXCEPTION_DATATYPE_MISALIGNMENT
EXCEPTION_BREAKPOINT
EXCEPTION_SINGLE_STEP
EXCEPTION_ARRAY_BOUNDS_EXCEEDED
EXCEPTION_FLT_DENORMAL_OPERAND
EXCEPTION_FLT_DIVIDE_BY_ZERO
EXCEPTION_FLT_INEXACT_RESULT
EXCEPTION_FLT_INVALID_OPERATION
EXCEPTION_FLT_OVERFLOW
EXCEPTION_FLT_STACK_CHECK
EXCEPTION_FLT_UNDERFLOW
EXCEPTION_INT_DIVIDE_BY_ZERO
EXCEPTION_INT_OVERFLOW
EXCEPTION_PRIV_INSTRUCTION
EXCEPTION_IN_PAGE_ERROR
EXCEPTION_ILLEGAL_INSTRUCTION
EXCEPTION_NONCONTINUABLE_EXCEPTION
EXCEPTION_STACK_OVERFLOW
EXCEPTION_INVALID_DISPOSITION
EXCEPTION_GUARD_PAGE
EXCEPTION_INVALID_HANDLE
EXCEPTION_POSSIBLE_DEADLOCK
CONTROL_C_EXIT
как определен в ntstatus.h
STATUS_ACCESS_VIOLATION
STATUS_DATATYPE_MISALIGNMENT
STATUS_BREAKPOINT
STATUS_SINGLE_STEP
STATUS_ARRAY_BOUNDS_EXCEEDED
STATUS_FLOAT_DENORMAL_OPERAND
STATUS_FLOAT_DIVIDE_BY_ZERO
STATUS_FLOAT_INEXACT_RESULT
STATUS_FLOAT_INVALID_OPERATION
STATUS_FLOAT_OVERFLOW
STATUS_FLOAT_STACK_CHECK
STATUS_FLOAT_UNDERFLOW
STATUS_INTEGER_DIVIDE_BY_ZERO
STATUS_INTEGER_OVERFLOW
STATUS_PRIVILEGED_INSTRUCTION
STATUS_IN_PAGE_ERROR
STATUS_ILLEGAL_INSTRUCTION
STATUS_NONCONTINUABLE_EXCEPTION
STATUS_STACK_OVERFLOW
STATUS_INVALID_DISPOSITION
STATUS_GUARD_PAGE_VIOLATION
STATUS_INVALID_HANDLE
STATUS_POSSIBLE_DEADLOCK
STATUS_CONTROL_C_EXIT
S это код статуса: 11 — ошибка; 10 — предупреждение; 01 — информация; 00
— успех. U —- является ли этот код пользовательским, а не системным.
Вот почему мы выбрали 0xE1223344 — E16 (11102 ) 0xE (1110b) означает, что
это 1) пользовательское исключение; 2) ошибка. Хотя, если быть честным, этот
пример нормально работает и без этих старших бит.
Далее мы пытаемся прочитать значение из памяти по адресу 0. Конечно, в
win32 по этому адресу обычно ничего нет, и сработает исключение. Однако,
первый обработчик, который будет заниматься этим делом — ваш, и он узнает
об этом первым, проверяя код на соответствие с константной EXCEPTION_ACCESS_VIOLATION.
А если заглянуть в то что получилось на ассемблере, то можно увидеть, что
код читающий из памяти по адресу 0, выглядит так:
Листинг 6.23: MSVC 2010
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1004
...
xor
mov
push
push
call
add
...
eax, eax
eax, DWORD PTR [eax] ; exception will occur here
eax
OFFSET msg
_printf
esp, 8
Возможно ли «на лету» исправить ошибку и предложить программе исполняться далее? Да, наш обработчик может изменить значение в EAX и предложить
ОС исполнить эту же инструкцию еще раз. Что мы и делаем. printf() напечатает 1234, потому что после работы нашего обработчика, EAX будет не 0, а
будет содержать адрес глобальной переменной new_value. Программа будет
исполняться далее.
Собственно, вот что происходит: срабатывает защита менеджера памяти в
CPU, он останавливает работу треда, отыскивает в ядре Windows обработчик
исключений, тот, в свою очередь, начинает вызывать обработчики из цепочки
SEH, по одному.
Мы компилируем это всё в MSVC 2010, но конечно же, нет никакой гарантии
что для указателя будет использован именно регистр EAX.
Этот трюк с подменой адреса эффектно выглядит, и мы рассматриваем его
здесь для наглядной иллюстрации работы SEH.
Тем не менее, трудно припомнить, применяется ли где-то подобное на практике для исправления ошибок «на лету».
Почему SEH-записи хранятся именно в стеке а не в каком-то другом месте? Возможно, потому что ОС не нужно заботиться об освобождении этой информации, эти записи просто пропадают как ненужные когда функция заканчивает
работу.
Это чем-то похоже на alloca(): (1.9.2 (стр. 48)).
Теперь вспомним MSVC
Должно быть, программистам Microsoft были нужны исключения в Си, но не
в Си++(для использования в ядре Windows NT, которое написано на Си), так
что они добавили нестандартное расширение Си в MSVC 44 . Оно не связано с
исключениями в Си++.
__try
{
...
}
__except(filter code)
{
handler code
}
44 MSDN
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1005
Блок «finally» может присутствовать вместо код обработчика:
__try
{
...
}
__finally
{
...
}
Код-фильтр — это выражение, отвечающее на вопрос, соответствует ли код
этого обработчика к поднятому исключению. Если ваш код слишком большой
и не помещается в одно выражение, отдельная функция-фильтр может быть
определена.
Таких конструкций много в ядре Windows. Вот несколько примеров оттуда (WRK):
Листинг 6.24: WRK-v1.2/base/ntos/ob/obwait.c
try {
KeReleaseMutant( (PKMUTANT)SignalObject,
MUTANT_INCREMENT,
FALSE,
TRUE );
} except((GetExceptionCode () == STATUS_ABANDONED ||
GetExceptionCode () == STATUS_MUTANT_NOT_OWNED)?
EXCEPTION_EXECUTE_HANDLER :
EXCEPTION_CONTINUE_SEARCH) {
Status = GetExceptionCode();
goto WaitExit;
}
Вот пример кода-фильтра:
Листинг 6.26: WRK-v1.2/base/ntos/cache/copysup.c
LONG
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1006
CcCopyReadExceptionFilter(
IN PEXCEPTION_POINTERS ExceptionPointer,
IN PNTSTATUS ExceptionCode
)
/∗++
Routine Description:
This routine serves as an exception filter and has the special job of
extracting the "real" I/O error when Mm raises STATUS_IN_PAGE_ERROR
beneath us.
Arguments:
ExceptionPointer − A pointer to the exception record that contains
the real Io Status.
ExceptionCode − A pointer to an NTSTATUS that is to receive the real
status.
Return Value:
EXCEPTION_EXECUTE_HANDLER
−−∗/
{
∗ExceptionCode = ExceptionPointer−>ExceptionRecord−>ExceptionCode;
if ( (∗ExceptionCode == STATUS_IN_PAGE_ERROR) &&
(ExceptionPointer−>ExceptionRecord−>NumberParameters >= 3) ) {
∗ExceptionCode = (NTSTATUS) ExceptionPointer−>ExceptionRecord−>⤦
Ç ExceptionInformation[2];
}
ASSERT( !NT_SUCCESS(∗ExceptionCode) );
return EXCEPTION_EXECUTE_HANDLER;
}
Внутри, SEH это расширение исключений поддерживаемых OS.
Но функция обработчик теперь или _except_handler3 (для SEH3) или _except_handler4
(для SEH4). Код обработчика от MSVC, расположен в его библиотеках, или же в
msvcr*.dll. Очень важно понимать, что SEH это специфичное для MSVC. Другие
win32-компиляторы могут предлагать что-то совершенно другое.
SEH3
SEH3 имеет _except_handler3 как функцию-обработчик, и расширяет структуЕсли вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1007
ру
_EXCEPTION_REGISTRATION добавляя указатель на scope table и переменную previous
try level. SEH4 расширяет scope table добавляя еще 4 значения связанных с защитой от переполнения буфера.
scope table это таблица, состоящая из указателей на код фильтра и обработчика, для каждого уровня вложенности try/except.
TIB
Stack
…
+0: __except_list
FS:0
+4: …
Prev=0xFFFFFFFF
+8: …
Handle
функцияобработчик
…
таблица scope
0xFFFFFFFF (-1)
информация для
первого блока
try/except
функция-фильтр
функцияобработчик/finallyобработчик
… остальные
элементы …
И снова, очень важно понимать, что OS заботится только о полях prev/handle,
и больше ничего. Это работа функции _except_handler3 читать другие поля,
читать scope table и решать, какой обработчик исполнять и когда.
Исходный код функции _except_handler3 закрыт. Хотя, Sanos OS, имеющая
слой совместимости с win32, имеет некоторые функции написанные заново,
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1008
которые в каком-то смысле эквивалентны тем что в Windows 45 . Другие попытки реализации имеются в Wine46 и ReactOS47 .
Если указатель filter ноль, handler указывает на код finally .
Во время исполнения, значение previous try level в стеке меняется, чтобы функция _except_handler3 знала о текущем уровне вложенности, чтобы знать, какой элемент таблицы scope table использовать.
SEH3: пример с одним блоком try/except
#include
#include
#include
int main()
{
int∗ p = NULL;
__try
{
printf("hello #1!\n");
∗p = 13;
// causes an access violation exception;
printf("hello #2!\n");
}
__except(GetExceptionCode()==EXCEPTION_ACCESS_VIOLATION ?
EXCEPTION_EXECUTE_HANDLER : EXCEPTION_CONTINUE_SEARCH)
{
printf("access violation, can't recover\n");
}
}
1010
xor
mov
mov
pop
pop
pop
mov
pop
ret
_main
_TEXT
END
eax, eax
ecx, DWORD PTR __$SEHRec$[ebp+8]
DWORD PTR fs:__except_list, ecx
edi
esi
ebx
esp, ebp
ebp
0
ENDP
ENDS
Здесь мы видим, как структура SEH конструируется в стеке. Scope table расположена в сегменте CONST — действительно, эти поля не будут меняться. Интересно, как меняется переменная previous try level. Исходное значение 0xFFFFFFFF
(−1). Момент, когда тело try открывается, обозначен инструкцией, записывающей 0 в эту переменную. В момент, когда тело try закрывается, −1 возвращается в нее назад. Мы также видим адреса кода фильтра и обработчика. Так
мы можем легко увидеть структуру конструкций try/except в функции.
Так как код инициализации SEH-структур в прологе функций может быть общим для нескольких функций, иногда компилятор вставляет в прологе вызов
функции SEH_prolog(), которая всё это делает. А код для деинициализации
SEH в функции SEH_epilog().
Запустим этот пример в tracer:
tracer.exe −l:2.exe −−dump−seh
Мы видим, что цепочка SEH состоит из 4-х обработчиков.
Первые два расположены в нашем примере. Два? Но ведь мы же сделали только один? Да, второй был установлен в CRT-функции _mainCRTStartup(), и судя
по всему, он обрабатывает как минимум исключения связанные с FPU. Его код
можно посмотреть в инсталляции MSVC: crt/src/winxfltr.c.
Третий это SEH4 в ntdll.dll, и четвертый это обработчик, не имеющий отношения к MSVC, расположенный в ntdll.dll, имеющий «говорящее» название функции.
Как видно, в цепочке присутствуют обработчики трех типов: один не связан
с MSVC вообще (последний) и два связанных с MSVC: SEH3 и SEH4.
SEH3: пример с двумя блоками try/except
#include
#include
#include
int filter_user_exceptions (unsigned int code, struct _EXCEPTION_POINTERS ∗⤦
Ç ep)
{
printf("in filter. code=0x%08X\n", code);
if (code == 0x112233)
{
printf("yes, that is our exception\n");
return EXCEPTION_EXECUTE_HANDLER;
}
else
{
printf("not our exception\n");
return EXCEPTION_CONTINUE_SEARCH;
};
}
int main()
{
int∗ p = NULL;
__try
{
__try
{
printf ("hello!\n");
RaiseException (0x112233, 0, 0, NULL);
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1012
printf ("0x112233 raised. now let's crash\n");
∗p = 13;
// causes an access violation exception;
}
__except(GetExceptionCode()==EXCEPTION_ACCESS_VIOLATION ?
EXCEPTION_EXECUTE_HANDLER : EXCEPTION_CONTINUE_SEARCH)
{
printf("access violation, can't recover\n");
}
}
__except(filter_user_exceptions(GetExceptionCode(), ⤦
Ç GetExceptionInformation()))
{
// the filter_user_exceptions() function answering to the question
// "is this exception belongs to this block?"
// if yes, do the follow:
printf("user exception caught\n");
}
}
Теперь здесь два блока try. Так что scope table теперь содержит два элемента,
один элемент на каждый блок. Previous try level меняется вместе с тем, как
исполнение доходит до очередного try-блока, либо выходит из него.
Листинг 6.29: MSVC 2003
$SG74606
$SG74608
$SG74610
$SG74617
$SG74619
$SG74621
$SG74623
1015
add
esp, 4
mov
DWORD PTR __$SEHRec$[ebp+20], −1 ; both try blocks exited. set
previous try level back to -1
$L74633:
xor
eax, eax
mov
ecx, DWORD PTR __$SEHRec$[ebp+8]
mov
DWORD PTR fs:__except_list, ecx
pop
edi
pop
esi
pop
ebx
mov
esp, ebp
pop
ebp
ret
0
_main
ENDP
Если установить точку останова на функцию printf() вызываемую из обработчика, мы можем увидеть, что добавился еще один SEH-обработчик. Наверное,
это еще какая-то дополнительная механика, скрытая внутри процесса обработки исключений. Тут мы также видим scope table состоящую из двух элементов.
tracer.exe −l:3.exe bpx=3.exe!printf −−dump−seh
SEH4
Во время атаки переполнения буфера (1.26.2 (стр. 347)) адрес scope table может быть перезаписан, так что начиная с MSVC 2005, SEH3 был дополнен защитой от переполнения буфера, до SEH4. Указатель на scope table теперь проXOR-ен с security cookie.
Scope table расширена, теперь имеет заголовок, содержащий 2 указателя на
security cookies. Каждый элемент имеет смещение внутри стека на другое значение: это адрес фрейма (EBP) также про-XOR-еный с security_cookie расположенный в стеке. Это значение будет прочитано во время обработки исключения и проверено на правильность.
Security cookie в стеке случайное каждый раз, так что атакующий, как мы надеемся, не может предсказать его.
Изначальное значение previous try level это −2 в SEH4 вместо −1.
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1017
TIB
Stack
…
+0: __except_list
FS:0
+4: …
Prev=0xFFFFFFFF
+8: …
Handle
функцияобработчик
…
таблица scope
смещение GS Cookie
Prev
смещение GS Cookie
XOR
информация для
первого блока
try/except
информация для
второго блока
try/except
функцияобработчик
Handle
…
смещение EH Cookie
Prev
смещение EH Cookie
XOR
Handle
_except_handler4
0xFFFFFFFF (-1)
таблица scope
⊕security_cookie
функция-фильтр
предыдущий уровень try
функцияобработчик/finallyобработчик
EBP
0
EBP⊕security_cookie
…
функция-фильтр
…
функцияобработчик/finallyобработчик
1
информация для
третьего блока
try/except
функция-фильтр
функцияобработчик/finallyобработчик
… остальные
элементы …
Оба примера скомпилированные в MSVC 2012 с SEH4:
Листинг 6.31: MSVC 2012: one try block example
$SG85485 DB
$SG85486 DB
$SG85488 DB
Вот значение cookies: Cookie Offset это разница между адресом записанного в стеке значения EBP и значения EBP ⊕ security_cookie в стеке. Cookie XOR
Offset это дополнительная разница между значением EBP ⊕ security_cookie и
тем что записано в стеке. Если это уравнение не верно, то процесс остановится из-за разрушения стека:
security_cookie ⊕ (CookieXOROf f set + address_of _saved_EBP ) ==
stack[address_of _saved_EBP + CookieOf f set]
Если Cookie Offset равно −2, это значит, что оно не присутствует.
Windows x64
Как видно, это не самая быстрая штука, устанавливать SEH-структуры в каждом прологе функции. Еще одна проблема производительности — это менять
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1023
переменную previous try level много раз в течении исполнении функции. Так
что в x64 всё сильно изменилось, теперь все указатели на try-блоки, функции фильтров и обработчиков, теперь записаны в другом PE-сегменте .pdata,
откуда обработчик исключений ОС берет всю информацию.
Вот два примера из предыдущей секции, скомпилированных для x64:
Листинг 6.33: MSVC 2012
$SG86276 DB
$SG86277 DB
$SG86279 DB
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1027
add
rsp, 32
pop
rbx
ret
0
$LN2@filter_use:
lea
rcx, OFFSET FLAT:$SG86281 ; 'not our exception'
call
printf
xor
eax, eax
add
rsp, 32
pop
rbx
ret
0
filter_user_exceptions ENDP
_TEXT
ENDS
Смотрите [Igor Skochinsky, Compiler Internals: Exceptions and RTTI, (2012)]
более детального описания.
48
для
Помимо информации об исключениях, секция .pdata также содержит начала
и концы почти всех функций, так что эту информацию можно использовать в
каких-либо утилитах, предназначенных для автоматизации анализа.
Больше о
SEH
[Matt Pietrek, A Crash Course on the Depths of Win32™ Structured Exception Handling,
(1997)]49 , [Igor Skochinsky, Compiler Internals: Exceptions and RTTI, (2012)] 50 .
6.5.4. Windows NT: Критические секции
Критические секции в любой ОС очень важны в мультитредовой среде, используются в основном для обеспечения гарантии что только один тред будет
иметь доступ к данным в один момент времени, блокируя остальные треды и
прерывания.
Вот как структура CRITICAL_SECTION объявлена в линейке OS Windows NT:
Листинг 6.35: (Windows Research Kernel v1.2) public/sdk/inc/nturtl.h
typedef struct _RTL_CRITICAL_SECTION {
PRTL_CRITICAL_SECTION_DEBUG DebugInfo;
//
// The following three fields control entering and exiting the critical
// section for the resource
//
LONG LockCount;
LONG RecursionCount;
HANDLE OwningThread;
// from the thread's ClientId->UniqueThread
48 Также доступно здесь: http://yurichev.com/mirrors/RE/Recon-2012-Skochinsky-Compiler-Internals.
pdf
49 Также доступно здесь: http://www.microsoft.com/msj/0197/Exception/Exception.aspx
50 Также доступно здесь: http://yurichev.com/mirrors/RE/Recon-2012-Skochinsky-Compiler-Internals.
pdf
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1028
HANDLE LockSemaphore;
ULONG_PTR SpinCount;
// force size on 64-bit systems when packed
} RTL_CRITICAL_SECTION, ∗PRTL_CRITICAL_SECTION;
Вот как работает функция EnterCriticalSection():
Листинг 6.36: Windows 2008/ntdll.dll/x86 (begin)
_RtlEnterCriticalSection@4
var_C
var_8
var_4
arg_0
=
=
=
=
dword
dword
dword
dword
ptr −0Ch
ptr −8
ptr −4
ptr 8
mov
edi, edi
push
ebp
mov
ebp, esp
sub
esp, 0Ch
push
esi
push
edi
mov
edi, [ebp+arg_0]
lea
esi, [edi+4] ; LockCount
mov
eax, esi
lock btr dword ptr [eax], 0
jnb
wait ; jump if CF=0
loc_7DE922DD:
mov
mov
mov
mov
pop
xor
pop
mov
pop
retn
eax, large fs:18h
ecx, [eax+24h]
[edi+0Ch], ecx
dword ptr [edi+8], 1
edi
eax, eax
esi
esp, ebp
ebp
4
... skipped
Самая важная инструкция в этом фрагменте кода — это BTR (с префиксом
LOCK): нулевой бит сохраняется в флаге CF и очищается в памяти . Это атомарная операция, блокирующая доступ всех остальных процессоров к этому
значению в памяти (обратите внимание на префикс LOCK перед инструкцией
BTR.
Если бит в LockCount является 1, хорошо, сбросить его и вернуться из функции:
мы в критической секции . Если нет — критическая секция уже занята другим
тредом, тогда ждем .
Ожидание там сделано через вызов WaitForSingleObject().
А вот как работает функция LeaveCriticalSection():
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1029
Листинг 6.37: Windows 2008/ntdll.dll/x86 (begin)
_RtlLeaveCriticalSection@4 proc near
arg_0
= dword ptr
8
mov
edi, edi
push
ebp
mov
ebp, esp
push
esi
mov
esi, [ebp+arg_0]
add
dword ptr [esi+8], 0FFFFFFFFh ; RecursionCount
jnz
short loc_7DE922B2
push
ebx
push
edi
lea
edi, [esi+4]
; LockCount
mov
dword ptr [esi+0Ch], 0
mov
ebx, 1
mov
eax, edi
lock xadd [eax], ebx
inc
ebx
cmp
ebx, 0FFFFFFFFh
jnz
loc_7DEA8EB7
loc_7DE922B0:
pop
pop
edi
ebx
xor
pop
pop
retn
eax, eax
esi
ebp
4
loc_7DE922B2:
... skipped
XADD это «обменять и прибавить». В данном случае, это значит прибавить 1 к
значению в LockCount, при этом сохранить изначальное значение LockCount
в регистре EBX. Впрочем, значение в EBX позже инкрементируется при помощи последующей инструкции INC EBX, и оно также будет равно обновленному
значению LockCount.
Эта операция также атомарная, потому что также имеет префикс LOCK, что
означает, что другие CPU или ядра CPU в системе не будут иметь доступа к
этой ячейке памяти .
Префикс LOCK очень важен: два треда, каждый из которых работает на разных
CPU или ядрах CPU, могут попытаться одновременно войти в критическую секцию, одновременно модифицируя значение в памяти, и это может привести к
непредсказуемым результатам.
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
Глава 7
Инструменты
7.1. Дизассемблеры
7.1.1. IDA
Старая бесплатная версия доступна для скачивания 1 .
Краткий справочник горячих клавиш: .6.1 (стр. 1331)
7.2. Отладчики
7.2.1. OllyDbg
Очень популярный отладчик пользовательской среды win32: ollydbg.de.
Краткий справочник горячих клавиш: .6.2 (стр. 1331)
7.2.2. GDB
Не очень популярный отладчик у реверсеров, тем не менее, крайне удобный.
Некоторые команды: .6.5 (стр. 1332).
7.2.3. tracer
Автор часто использует tracer
2
вместо отладчика.
Со временем, автор этих строк отказался использовать отладчик, потому что
всё что ему нужно от него это иногда подсмотреть какие-либо аргументы какойлибо функции во время исполнения или состояние регистров в определенном
1 hex-rays.com/products/ida/support/download_freeware.shtml
2 yurichev.com
1030
1031
месте. Каждый раз загружать отладчик для этого это слишком, поэтому родилась очень простая утилита tracer. Она консольная, запускается из командной
строки, позволяет перехватывать исполнение функций, ставить точки останова на произвольные места, смотреть состояние регистров, модифицировать
их, итд.
Но для учебы очень полезно трассировать код руками в отладчике, наблюдать как меняются значения регистров (например, как минимум классический
SoftICE, OllyDbg, WinDbg подсвечивают измененные регистры), флагов, данные,
менять их самому, смотреть реакцию, итд.
7.3. Трассировка системных вызовов
strace / dtruss
Позволяет показать, какие системные вызовы (syscalls(6.3 (стр. 974))) прямо
сейчас вызывает процесс.
Например:
# strace df −h
...
access("/etc/ld.so.nohwcap", F_OK)
= −1 ENOENT (No such file or ⤦
Ç directory)
open("/lib/i386−linux−gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF⤦
Ç \1\1\1\0\0\0\0\0\0\0\0\0\3\0\3\0\1\0\0\0\220\232\1\0004\0\0\0"..., ⤦
Ç 512) = 512
fstat64(3, {st_mode=S_IFREG|0755, st_size=1770984, ...}) = 0
mmap2(NULL, 1780508, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) ⤦
Ç = 0xb75b3000
В Mac OS X для этого же имеется dtruss.
В Cygwin также есть strace, впрочем, насколько известно, он показывает результаты только для .exe-файлов скомпилированных для среды самого cygwin.
7.4. Декомпиляторы
Пока существует только один публично доступный декомпилятор в Си высокого качества: Hex-Rays: hex-rays.com/products/decompiler/
Чиайте больше о нем: 10.8 (стр. 1278).
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1032
7.5. Прочие инструменты
• Microsoft Visual Studio Express3 : Усеченная бесплатная версия Visual Studio,
пригодная для простых экспериментов. Некоторые полезные опции: .6.3
(стр. 1332).
• Hiew4 : для мелкой модификации кода в исполняемых файлах.
• binary grep: небольшая утилита для поиска констант (либо просто последовательности байт) в большом количестве файлов, включая неисполняемые: GitHub. В rada.re имеется также rafind2 для тех же целей.
7.5.1. Калькуляторы
Хороший калькулятор для нужд реверс-инженера должен поддерживать как
минимум десятичную, шестнадцатеричную и двоичную системы счисления, а
также многие важные операции как “исключающее ИЛИ” и сдвиги.
• В IDA есть встроенный калькулятор (“?”).
• В rada.re есть rax2.
• https://yurichev.com/progcalc/
• Стандартный калькулятор в Windows имеет режим программистского калькулятора.
7.6. Чего-то здесь недостает?
Если вы знаете о хорошем инструменте, которого не хватает здесь в этом списке, пожалуйста сообщите мне об этом:
.
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
Глава 8
Примеры из практики
Вместо эпиграфа:
Питер Сейбел: Как вы читаете исходный код? Ведь непросто
читать даже то, что написано на известном вам языке программирования.
Дональд Кнут: Но это действительно того стоит, если говорить о том, что выстраивается в вашей голове. Как я читаю код?
Когда-то была машина под названием Bunker Ramo 300, и кто-то
мне однажды сказал, что компилятор Фортрана для этой машины
работает чрезвычайно быстро, но никто не понимает почему. Я заполучил копию его исходного кода. У меня не было руководства
по этому компьютеру, поэтому я даже не был уверен, какой это
был машинный язык.
Но я взялся за это, посчитав интересной задачей. Я нашел
BEGIN и начал разбираться. В кодах операций есть ряд двухбуквенных мнемоник, поэтому я мог начать анализировать: “Возможно, это инструкция загрузки, а это, возможно, инструкция перехода”. Кроме того, я знал, что это компилятор Фортрана, и иногда
он обращался к седьмой колонке перфокарты - там он мог определить, комментарий это или нет.
Спустя три часа я кое-что понял об этом компьютере. Затем
обнаружил огромные таблицы ветвлений. То есть это была своего
рода головоломка, и я продолжал рисовать небольшие схемы, как
разведчик, пытающийся разгадать секретный шифр. Но я знал,
что программа работает, и знал, что это компилятор Фортрана —
это не был шифр, в том смысле что программа не была написана
с сознательной целью запутать. Все дело было в коде, поскольку
у меня не было руководства по компьютеру.
В конце концов мне удалось выяснить, почему компилятор работал так быстро. К сожалению, дело было не в гениальных алгоритмах - просто там применялись методы неструктурированного
1033
1034
программирования и код был максимально оптимизирован вручную.
По большому счету, именно так и должна решаться головоломка: составляются таблицы, схемы, информация извлекается по
крупицам, выдвигается гипотеза. В общем, когда я читаю техническую работу, это такая же сложная задача. Я пытаюсь влезть в
голову автора, понять, в чем состоял его замысел. Чем больше вы
учитесь читать вещи, написанные другими, тем более способны
изобретать что-то свое - так мне кажется.
( Сейбел Питер — Кодеры за работой. Размышления о ремесле программиста )
8.1. Шутка с Маджонгом (Windows 7)
Маджонг это замечательная игра, однако, можем ли мы усложнить её, запретив пункт меню Hint (подсказка)?
В Windows 7, я могу найти файлы Mahjong.dll и Mahjong.exe в:
C:\Windows\winsxs\
x86_microsoft-windows-s..inboxgames-shanghai_31bf3856ad364e35_6.1.7600.16385_none\
c07a51d9507d9398.
Также, файл Mahjong.exe.mui в:
C:\Windows\winsxs\
x86_microsoft-windows-s..-shanghai.resources_31bf3856ad364e35_6.1.7600.16385_en-us
_c430954533c66bf3
ив
C:\Windows\winsxs\
x86_microsoft-windows-s..-shanghai.resources_31bf3856ad364e35_6.1.7600.16385_ru-ru
_0d51acf984cb679a.
Я использую англоязычную Windows, но с поддержкой русского языка, так что
тут могут быть файлы ресурсов для двух языков. Открыв файл Mahjong.exe.mui
в Resource Hacker, можно увидеть определения меню:
Листинг 8.1: Ресурсы меню в Mahjong.exe.mui
103 MENU
LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US
{
POPUP "&Game"
{
MENUITEM "&New Game\tF2", 40000
MENUITEM SEPARATOR
MENUITEM "&Undo\tCtrl+Z", 40001
MENUITEM "&Hint\tH", 40002
MENUITEM SEPARATOR
MENUITEM "&Statistics\tF4", 40003
MENUITEM "&Options\tF5", 40004
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
Подменю Hint имеет код 40002. Открываю Mahjong.exe в IDA и ищу значение
40002.
(Я пишу это в ноябре 2019. Почему-то, IDA не может выкачать PDB-файлы с
серверов Microsoft. Может быть, Windows 7 больше не поддерживается? Так
или иначе, имен ф-ций я не смог установить...)
Листинг 8.2: Mahjong.exe
.text:010205C8 6A 03
.text:010205CA 85 FF
.text:010205CC 5B
push
test
pop
3
edi, edi
ebx
push
push
call
cmp
mov
jnz
edi
; uIDEnableItem
hmenu ; hMenu
esi
; EnableMenuItem
[ebp+arg_0], 1
edi, 40002
short loc_1020651 ; переход должен
push
push
push
call
push
push
push
call
jmp
0
; uEnable
edi
; uIDEnableItem
hMenu ; hMenu
esi
; EnableMenuItem
0
; uEnable
edi
; uIDEnableItem
hmenu ; hMenu
esi
; EnableMenuItem
short loc_102066B
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1036
Эта часть кода разрешает или запрещает пункт меню Hint.
И согласно MSDN1 :
MF_DISABLED | MF_GRAYED = 3 и MF_ENABLED = 0.
Я думаю, эта ф-ция разрешает или запрещает несколько пунктов меню (Hint,
Undo, итд), исходя из значения в arg_0. Потому что при старте, когда пользователь выбирает тип игры, пункты Hint и Undo запрещены. Они разрешены,
когда игра начинается.
Так что я изменяю в файле Mahjong.exe по адресу 0x01020637 байт 0x75 на
0xEB, делая так, что переход JNZ всегда будет работать. Эффект в том, что фция всегда будет вызываться как EnableMenuItem(..., ..., 3). Теперь подменю Hint всё время запрещено.
Также, почему-то, ф-ция EnableMenuItem() вызывается дважды, для hMenu и
для hmenu. Может быть, в программе два меню, и может быть, они переключаются?
В качестве домашней работы, попробуйте запретить подменю Undo, чтобы сделать игру еще труднее.
8.2. Шутка с task manager (Windows Vista)
Посмотрим, сможем ли мы немного хакнуть Task Manager, чтобы он находил
больше ядер в CPU, чем присутствует.
В начале задумаемся, откуда Task Manager знает количество ядер?
В win32 имеется функция GetSystemInfo(), при помощи которой можно узнать.
Но она не импортируется в taskmgr.exe. Есть еще одна в NTAPI, NtQuerySystemInformation(),
которая используется в taskmgr.exe в ряде мест.
Чтобы узнать количество ядер, нужно вызвать эту функцию с константной
SystemBasicInformation в первом аргументе (а это ноль
2
).
Второй аргумент должен указывать на буфер, который примет всю информацию.
Так что нам нужно найти все вызовы функции
NtQuerySystemInformation(0, ?, ?, ?).
Откроем taskmgr.exe в IDA. Что всегда хорошо с исполняемыми файлами от
Microsoft, это то что IDA может скачать соответствующий PDB-файл именно
для этого файла и добавить все имена функций.
Видимо, Task Manager написан на Си++и некоторые функции и классы имеют
говорящие за себя имена.
1 https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-enablemenuitem
2 MSDN
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1037
Тут есть классы CAdapter, CNetPage, CPerfPage, CProcInfo, CProcPage, CSvcPage,
CTaskPage, CUserPage. Должно быть, каждый класс соответствует каждой вкладке в Task Manager.
Пройдемся по всем вызовам и добавим комментарий с числом, передающимся
как первый аргумент.
В некоторых местах напишем «not zero», потому что значение в тех местах
однозначно не ноль, но что-то другое (больше об этом во второй части главы).
А мы все-таки ищем ноль передаваемый как аргумент.
Рис. 8.1: IDA: вызовы функции NtQuerySystemInformation()
Да, имена действительно говорящие сами за себя.
Когда мы внимательно изучим каждое место, где вызывается
NtQuerySystemInformation(0, ?, ?, ?), то быстро найдем то что нужно в функции InitPerfInfo():
Листинг 8.3: taskmgr.exe (Windows Vista)
.text:10000B4B3
xor
.text:10000B4B6
lea
.text:10000B4BB
xor
.text:10000B4BD
lea
.text:10000B4C1
mov
.text:10000B4C4
call
.text:10000B4CA
xor
.text:10000B4CC
cmp
.text:10000B4CE
jge
.text:10000B4D0
.text:10000B4D0 loc_10000B4D0:
InitPerfInfo(void)+97
g_cProcessors это глобальная переменная и это имя присвоено IDA в соответствии с PDB-файлом, скачанным с сервера символов Microsoft.
Байт берется из var_C20. И var_C58 передается в
NtQuerySystemInformation() как указатель на принимающий буфер. Разница
между 0xC20 и 0xC58 это 0x38 (56). Посмотрим на формат структуры, который
можно найти в MSDN:
typedef struct _SYSTEM_BASIC_INFORMATION {
BYTE Reserved1[24];
PVOID Reserved2[4];
CCHAR NumberOfProcessors;
} SYSTEM_BASIC_INFORMATION;
Это система x64, так что каждый PVOID занимает здесь 8 байт.
Так что все reserved-поля занимают 24 + 4 ∗ 8 = 56.
О да, это значит, что var_C20 в локальном стеке это именно поле NumberOfProcessors
структуры SYSTEM_BASIC_INFORMATION.
Проверим нашу догадку. Скопируем taskmgr.exe из C:\Windows\System32 в
какую-нибудь другую папку (чтобы Windows Resource Protection не пыталась
восстанавливать измененный taskmgr.exe).
Откроем его в Hiew и найдем это место:
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1039
Рис. 8.2: Hiew: найдем это место
Заменим инструкцию MOVZX на нашу.
Сделаем вид что у нас 64 ядра процессора. Добавим дополнительную инструкцию NOP (потому что наша инструкция короче чем та что там сейчас):
Рис. 8.3: Hiew: меняем инструкцию
И это работает! Конечно же, данные в графиках неправильные. Иногда, Task
Manager даже показывает общую загрузку CPU более 100%.
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1040
Рис. 8.4: Обманутый Windows Task Manager
Самое большое число, при котором Task Manager не падает, это 64.
Должно быть, Task Manager в Windows Vista не тестировался на компьютерах
с большим количеством ядер.
И, наверное, там есть внутри какие-то статичные структуры данных, ограниченные до 64-х ядер.
8.2.1. Использование LEA для загрузки значений
Иногда, LEA используется в taskmgr.exe вместо MOV для установки первого аргумента
NtQuerySystemInformation():
Листинг 8.4: taskmgr.exe (Windows Vista)
xor
div
lea
lea
mov
mov
r9d, r9d
dword ptr [rsp+4C8h+WndClass.lpfnWndProc]
rdx, [rsp+4C8h+VersionInformation]
ecx, [r9+2]
; put 2 to ECX
r8d, 138h
ebx, eax
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1041
; ECX=SystemPerformanceInformation
call
cs:__imp_NtQuerySystemInformation ; 2
...
mov
r8d, 30h
lea
r9, [rsp+298h+var_268]
lea
rdx, [rsp+298h+var_258]
lea
ecx, [r8−2Dh]
; put 3 to ECX
; ECX=SystemTimeOfDayInformation
call
cs:__imp_NtQuerySystemInformation ; not zero
...
mov
rbp, [rsi+8]
mov
r8d, 20h
lea
r9, [rsp+98h+arg_0]
lea
rdx, [rsp+98h+var_78]
lea
ecx, [r8+2Fh]
; put 0x4F to ECX
mov
[rsp+98h+var_60], ebx
mov
[rsp+98h+var_68], rbp
; ECX=SystemSuperfetchInformation
call
cs:__imp_NtQuerySystemInformation ; not zero
Должно быть, MSVC сделал так, потому что код инструкции LEA короче чем MOV
REG, 5 (было бы 5 байт вместо 4).
LEA со смещением в пределах −128..127 (смещение будет занимать 1 байт в опкоде) с 32-битными регистрами даже еще короче (из-за отсутствия REX-префикса)
— 3 байта.
Еще один пример подобного: 6.1.5 (стр. 962).
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1042
8.3. Шутка с игрой Color Lines
Это очень популярная игра с большим количеством реализаций. Возьмем одну
из них, с названием BallTriX, от 1997, доступную бесплатно на https://archive.
org/details/BallTriX_1020 3 . Вот как она выглядит:
Рис. 8.5: Обычный вид игры
3 Или
на https://web.archive.org/web/20141110053442/http://www.download-central.ws/
Win32/Games/B/BallTriX/ или http://www.benya.com/balltrix/.
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1043
Посмотрим, сможем ли мы найти генератор псевдослучайных чисел и и сделать с ним одну шутку.
IDA быстро распознает стандартную функцию _rand в balltrix.exe по адресу 0x00403DA0. IDA также показывает, что она вызывается только из одного
места:
.text:00402C9C
CODE XREF:
.text:00402C9C
.text:00402C9C
.text:00402C9C
.text:00402C9C
.text:00402C9C
.text:00402C9D
.text:00402C9F
.text:00402CA0
.text:00402CA1
.text:00402CA2
.text:00402CA7
.text:00402CAE
.text:00402CB4
.text:00402CB9
.text:00402CBA
.text:00402CBC
.text:00402CC2
.text:00402CC7
.text:00402CC8
.text:00402CCB
.text:00402CD1
.text:00402CD6
.text:00402CDB
.text:00402CDC
.text:00402CDD
.text:00402CDE
.text:00402CDF
.text:00402CDF
sub_402C9C
sub_402ACA+52
proc near
;
; sub_402ACA+64 ...
arg_0
= dword ptr
sub_402C9C
push
mov
push
push
push
mov
imul
add
mov
cdq
idiv
mov
call
cdq
idiv
mov
mov
jmp
pop
pop
pop
leave
retn
endp
ebp
ebp,
ebx
esi
edi
eax,
eax,
eax,
ecx,
8
esp
dword_40D430
dword_40D440
dword_40D5C8
32000
ecx
dword_40D440, edx
_rand
[ebp+arg_0]
dword_40D430, edx
eax, dword_40D430
$+5
edi
esi
ebx
Назовем её «random». Пока не будем концентрироваться на самом коде функции.
Эта функция вызывается из трех мест.
Вот первые два:
.text:00402B16
.text:00402B1B
.text:00402B1C
.text:00402B21
.text:00402B24
.text:00402B25
.text:00402B28
.text:00402B2D
.text:00402B2E
.text:00402B33
mov
push
call
add
inc
mov
mov
push
call
add
eax, dword_40C03C ; 10 here
eax
random
esp, 4
eax
[ebp+var_C], eax
eax, dword_40C040 ; 10 here
eax
random
esp, 4
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1044
Вот третье:
.text:00402BBB
.text:00402BC0
.text:00402BC1
.text:00402BC6
.text:00402BC9
mov
push
call
add
inc
eax, dword_40C058 ; 5 here
eax
random
esp, 4
eax
Так что у функции только один аргумент. 10 передается в первых двух случаях
и 5 в третьем.
Мы также можем заметить, что размер доски 10*10 и здесь 5 возможных цветов. Это оно! Стандартная функция rand() возвращает число в пределах 0..0x7FFF
и это неудобно, так что многие программисты пишут свою функцию, возвращающую случайное число в некоторых заданных пределах. В нашем случае,
предел это 0..n − 1 и n передается как единственный аргумент в функцию. Мы
можем быстро проверить это в отладчике.
Сделаем так, чтобы третий вызов функции всегда возвращал ноль. В начале
заменим три инструкции (PUSH/CALL/ADD) на NOPs. Затем добавим инструкцию
XOR EAX, EAX, для очистки регистра EAX.
.00402BB8:
.00402BBB:
.00402BC0:
.00402BC2:
.00402BC3:
.00402BC4:
.00402BC5:
.00402BC6:
.00402BC7:
.00402BC8:
.00402BC9:
.00402BCA:
.00402BCD:
.00402BD0:
Что мы сделали, это заменили вызов функции random() на код, всегда возвращающий ноль.
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1045
Теперь запустим:
Рис. 8.6: Шутка сработала
О да, это работает4 .
Но почему аргументы функции random() это глобальные переменные? Это просто потому что в настройках игры можно изменять размер доски, так что эти
параметры не фиксированы. 10 и 5 это просто значения по умолчанию.
8.4. Сапёр (Windows XP)
Для тех, кто не очень хорошо играет в Сапёра (Minesweeper), можно попробовать найти все скрытые мины в отладчике.
Как мы знаем, Сапёр располагает мины случайным образом, так что там должен быть генератор случайных чисел или вызов стандартной функции Си rand().
Вот что хорошо в реверсинге продуктов от Microsoft, так это то что часто есть
PDB-файл со всеми символами (имена функций, и т.д.).
Когда мы загружаем winmine.exe в IDA, она скачивает PDB файл именно для
этого исполняемого файла и добавляет все имена.
И вот оно, только один вызов rand() в этой функции:
.text:01003940 ; __stdcall Rnd(x)
.text:01003940 _Rnd@4
proc near
StartGame()+53
.text:01003940
.text:01003940
; CODE XREF:
; StartGame()+61
4 Автор этой книги однажды сделал это как шутку для его сотрудников, в надежде что они
перестанут играть. Надежды не оправдались.
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
Так её назвала IDA и это было имя данное ей разработчиками Сапёра.
Функция очень простая:
int Rnd(int limit)
{
return rand() % limit;
};
(В PDB-файле не было имени «limit»; это мы назвали этот аргумент так, вручную.)
Так что она возвращает случайное число в пределах от нуля до заданного предела.
Rnd() вызывается только из одного места, это функция с названием StartGame(),
и как видно, это именно тот код, что расставляет мины:
.text:010036C7
.text:010036CD
.text:010036D2
.text:010036D8
.text:010036DA
.text:010036DB
.text:010036E0
.text:010036E1
.text:010036E3
.text:010036E6
.text:010036EE
.text:010036F0
.text:010036F3
.text:010036FA
.text:010036FD
.text:01003703
push
call
push
mov
inc
call
inc
mov
shl
test
jnz
shl
lea
or
dec
jnz
Сапёр позволяет задать размеры доски, так что X (xBoxMac) и Y (yBoxMac) это
глобальные переменные.
Они передаются в Rnd() и генерируются случайные координаты. Мина устанавливается инструкцией OR на 0x010036FA. И если она уже была установлена до
этого (это возможно, если пара функций Rnd() сгенерирует пару, которая уже
была сгенерирована), тогда TEST и JNZ на 0x010036E6 перейдет на повторную
генерацию пары.
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1047
cBombStart это глобальная переменная, содержащая количество мин. Так что
это цикл.
Ширина двухмерного массива это 32 (мы можем это вывести, глядя на инструкцию SHL, которая умножает одну из координат на 32).
Размер глобального массива rgBlk можно легко узнать по разнице между меткой rgBlk в сегменте данных и следующей известной меткой. Это 0x360 (864):
.data:01005340 _rgBlk
MainWndProc(x,x,x,x)+574
.data:01005340
.data:010056A0 _Preferences
FixMenus()+2
...
db 360h dup(?)
; DATA XREF:
dd ?
; DisplayBlk(x,x)+23
; DATA XREF:
864/32 = 27.
Так что размер массива 27∗32? Это близко к тому что мы знаем: если попытаемся установить размер доски в установках Сапёра на 100 ∗ 100, то он установит
размер 24∗30. Так что это максимальный размер доски здесь. И размер массива
фиксирован для доски любого размера.
Посмотрим на всё это в OllyDbg. Запустим Сапёр, присоединим (attach) OllyDbg
к нему и увидим содержимое памяти по адресу где массив rgBlk (0x01005340)
5
.
Так что у нас выходит такой дамп памяти массива:
Address
01005340
01005350
01005360
01005370
01005380
01005390
010053A0
010053B0
010053C0
010053D0
010053E0
010053F0
01005400
01005410
01005420
01005430
01005440
01005450
01005460
01005470
01005480
01005490
OllyDbg, как и любой другой шестнадцатеричный редактор, показывает 16
байт на строку. Так что каждая 32-байтная строка массива занимает ровно
2 строки.
Это уровень для начинающих (доска 9*9).
Тут еще какая-то квадратная структура, заметная визуально (байты 0x10).
Нажмем «Run» в OllyDbg чтобы разморозить процесс Сапёра, потом нажмем в
случайное место окна Сапёра, попадаемся на мине, но теперь видны все мины:
Рис. 8.7: Мины
Сравнивая места с минами и дамп, мы можем обнаружить что 0x10 это граница,
0x0F — пустой блок, 0x8F — мина. Вероятно 0x10 это т.н., sentinel value.
Теперь добавим комментариев и также заключим все байты 0x8F в квадратные
скобки:
border:
01005340
01005350
line #1:
01005360
01005370
line #2:
01005380
01005390
line #3:
010053A0
010053B0
line #4:
Теперь уберем все байты связанные с границами (0x10) и всё что за ними:
0F 0F 0F 0F 0F 0F 0F 0F 0F
0F 0F 0F 0F 0F 0F 0F 0F 0F
0F 0F 0F 0F 0F 0F 0F[8F]0F
0F 0F 0F 0F 0F 0F 0F 0F 0F
0F 0F 0F 0F 0F 0F 0F 0F 0F
0F 0F[8F]0F 0F[8F]0F 0F 0F
[8F]0F 0F[8F]0F 0F 0F 0F 0F
[8F]0F 0F 0F 0F[8F]0F 0F[8F]
0F 0F 0F 0F[8F]0F 0F 0F[8F]
Да, это всё мины, теперь это очень хорошо видно, в сравнении со скриншотом.
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1050
Вот что интересно, это то что мы можем модифицировать массив прямо в
OllyDbg.
Уберем все мины заменив все байты 0x8F на 0x0F, и вот что получится в Сапёре:
Рис. 8.8: Все мины убраны в отладчике
Также уберем их все и добавим их в первом ряду:
Рис. 8.9: Мины, установленные в отладчике
Отладчик не очень удобен для подсматривания (а это была наша изначальная
цель), так что напишем маленькую утилиту для показа содержимого доски:
// Windows XP MineSweeper cheater
// written by dennis(a)yurichev.com for http://beginners.re/ book
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
Теперь всё ясно: координаты были предвычислены, как если бы циферблат
был размером 2 ⋅ 8000, а затем он масштабируется до радиуса текущего циферблата используя ф-цию MulDiv().
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1063
Структура POINT10 это структура из двух 32-битных значений, первое это x,
второе это y.
8.6. Донглы
Автор этих строк иногда делал замену донглам или «эмуляторы донглов» и
здесь немного примеров, как это происходит.
Об одном неописанном здесь случае с Rockey и Z3 вы также можете прочитать
здесь : http://yurichev.com/tmp/SAT_SMT_DRAFT.pdf.
8.6.1. Пример #1: MacOS Classic и PowerPC
Вот пример программы для MacOS Classic 11 , для PowerPC. Компания, разработавшая этот продукт, давно исчезла, так что (легальный) пользователь боялся
того что донгла может сломаться.
Если запустить программу без подключенной донглы, можно увидеть окно с
надписью
”Invalid Security Device”. Мне повезло потому что этот текст можно было легко
найти внутри исполняемого файла.
Представим, что мы не знакомы ни с Mac OS Classic, ни с PowerPC, но всё-таки
попробуем.
IDA открывает исполняемый файл легко, показывая его тип как
”PEF (Mac OS or Be OS executable)” (действительно, это стандартный тип файлов в Mac OS Classic).
В поисках текстовой строки с сообщением об ошибке, мы попадаем на этот
фрагмент кода:
...
seg000:000C87FC
seg000:000C8800
seg000:000C8804
seg000:000C8808
seg000:000C880C
seg000:000C8810
38
48
60
54
40
80
60
03
00
60
82
62
00
93
00
06
00
9F
01
41
00
3F
40
D8
li
bl
nop
clrlwi.
bne
lwz
%r3, 1
check1
%r0, %r3, 24
OK
%r3, TC_aInvalidSecurityDevice
...
Да, это код PowerPC. Это очень типичный процессор для RISC 1990-х. Каждая
инструкция занимает 4 байта (как и в MIPS и ARM) и их имена немного похожи
на имена инструкций MIPS.
10 https://msdn.microsoft.com/en-us/library/windows/desktop/dd162805(v=vs.85).aspx
11 MacOS
перед тем как перейти на UNIX
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1064
check1() это имя которое мы дадим этой функции немного позже. BL это инструкция Branch Link т.е. предназначенная для вызова подпрограмм. Самое
важное место — это инструкция BNE, срабатывающая, если проверка наличия
донглы прошла успешно, либо не срабатывающая в случае ошибки: и тогда
адрес текстовой строки с сообщением об ошибке будет загружен в регистр r3
для последующей передачи в функцию отображения диалогового окна.
Из [Steve Zucker, SunSoft and Kari Karhi, IBM, SYSTEM V APPLICATION BINARY INTERFACE:
PowerPC Processor Supplement, (1995)]12 мы узнаем, что регистр r3 используется
для возврата значений (и еще r4 если значение 64-битное).
Еще одна, пока что неизвестная инструкция CLRLWI. Из [PowerPC(tm) Microprocessor
Family: The Programming Environments for 32-Bit Microprocessors, (2000)]13 мы
узнаем, что эта инструкция одновременно и очищает и загружает. В нашем
случае, она очищает 24 старших бита из значения в r3 и записывает всё это в
r0, так что это аналог MOVZX в x86 (1.23.1 (стр. 263)), но также устанавливает
флаги, так что BNE может проверить их потом.
Посмотрим внутрь check1():
seg000:00101B40
seg000:00101B40
seg000:00101B40
seg000:00101B40
seg000:00101B40
seg000:00101B40
seg000:00101B44
seg000:00101B48
seg000:00101B4C
seg000:00101B50
seg000:00101B54
seg000:00101B58
seg000:00101B5C
seg000:00101B60
seg000:00101B60
mflr
%r0
stw
%r0, arg_8(%sp)
stwu
%sp, −0x40(%sp)
bl
check2
nop
lwz
%r0, 0x40+arg_8(%sp)
addi
%sp, %sp, 0x40
mtlr
%r0
blr
# End of function check1
Как можно увидеть в IDA, эта функция вызывается из многих мест в программе,
но только значение в регистре r3 проверяется сразу после каждого вызова.
Всё что эта функция делает это только вызывает другую функцию, так что
это thunk function: здесь присутствует и пролог функции и эпилог, но регистр
r3 не трогается, так что checkl() возвращает то, что возвращает check2().
BLR14 это похоже возврат из функции, но так как IDA делает всю разметку
функций автоматически, наверное, мы можем пока не интересоваться этим.
Так как это типичный RISC, похоже, подпрограммы вызываются, используя link
register, точно как в ARM.
Функция check2() более сложная:
seg000:00118684
check2: # CODE XREF: check1+Cp
12 Также
доступно здесь: http://yurichev.com/mirrors/PowerPC/elfspec_ppc.pdf
доступно здесь: http://yurichev.com/mirrors/PowerPC/6xx_pem.pdf
14 (PowerPC) Branch to Link Register
13 Также
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
lwz
addi
lwz
mtlr
lwz
lwz
blr
# End of function check2
Снова повезло: имена некоторых функций оставлены в исполняемом файле (в
символах в отладочной секции? Трудно сказать до тех пор, пока мы не знакомы
с этим форматом файлов, может быть это что-то вроде PE-экспортов (6.5.2))?
Как например .RBEFINDNEXT() and .RBEFINDFIRST().
В итоге, эти функции вызывают другие функции с именами вроде .GetNextDeviceViaUSB(),
.USBSendPKT(), так что они явно работают с каким-то USB-устройством.
Тут даже есть функция с названием .GetNextEve3Device() — звучит знакомо,
в 1990-х годах была донгла Sentinel Eve3 для ADB-порта (присутствующих на
Макинтошах).
В начале посмотрим на то как устанавливается регистр r3 одновременно игнорируя всё остальное. Мы знаем, что «хорошее» значение в r3 должно быть не
нулевым, а нулевой r3 приведет к выводу диалогового окна с сообщением об
ошибке.
В функции имеются две инструкции li %r3, 1 и одна li %r3, 0 (Load Immediate,
т.е. загрузить значение в регистр). Самая первая инструкция находится на
0x001186B0 — и честно говоря, трудно заранее понять, что это означает.
А вот то что мы видим дальше понять проще: вызывается .RBEFINDFIRST() и
в случае ошибки, 0 будет записан в r3 и мы перейдем на exit, а иначе будет
вызвана функция check3() — если и она будет выполнена с ошибкой, будет
вызвана .RBEFINDNEXT() вероятно, для поиска другого USB-устройства.
N.B.: clrlwi. %r0, %r3, 16 это аналог того что мы уже видели, но она очищает 16 старших бит, т.е.,
.RBEFINDFIRST() вероятно возвращает 16-битное значение.
B (означает branch) — безусловный переход.
BEQ это обратная инструкция от BNE.
Посмотрим на check3():
seg000:0011873C
seg000:0011873C
seg000:0011873C
lwz
addi
lwz
mtlr
lwz
lwz
blr
# End of function check3
Здесь много вызовов .RBEREAD(). Эта функция, должно быть, читает какие-то
значения из донглы, которые потом сравниваются здесь при помощи CMPLWI.
Мы также видим в регистр r3 записывается перед каждым вызовом .RBEREAD()
одно из этих значений: 0, 1, 8, 0xA, 0xB, 0xC, 0xD, 4, 5. Вероятно адрес в памяти
или что-то в этом роде?
Да, действительно, если погуглить имена этих функций, можно легко найти
документацию к Sentinel Eve3!
Hаверное, уже и не нужно изучать остальные инструкции PowerPC: всё что делает эта функция это просто вызывает .RBEREAD(), сравнивает его результаты
с константами и возвращает 1 если результат сравнения положительный или
0 в другом случае.
Всё ясно: check1() должна всегда возвращать 1 или иное ненулевое значение. Но так как мы не очень уверены в своих знаниях инструкций PowerPC,
будем осторожны и пропатчим переходы в check2 на адресах 0x001186FC и
0x00118718.
На 0x001186FC мы записываем байты 0x48 и 0 таким образом превращая инструкцию BEQ в инструкцию B (безусловный переход): мы можем заметить этот
опкод прямо в коде даже без обращения к [PowerPC(tm) Microprocessor Family:
The Programming Environments for 32-Bit Microprocessors, (2000)]15 .
На 0x00118718 мы записываем байт 0x60 и еще 3 нулевых байта, таким образом
превращая её в инструкцию NOP: Этот опкод мы также можем подсмотреть
прямо в коде.
15 Также
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1073
И всё заработало без подключенной донглы.
Резюмируя, такие простые модификации можно делать в IDA даже с минимальными знаниями ассемблера.
8.6.2. Пример #2: SCO OpenServer
Древняя программа для SCO OpenServer от 1997 разработанная давно исчезнувшей компанией.
Специальный драйвер донглы инсталлируется в системе, он содержит такие
текстовые строки: «Copyright 1989, Rainbow Technologies, Inc., Irvine, CA» и «Sentinel
Integrated Driver Ver. 3.0 ».
После инсталляции драйвера, в /dev появляются такие устройства:
/dev/rbsl8
/dev/rbsl9
/dev/rbsl10
Без подключенной донглы, программа сообщает об ошибке, но сообщение об
ошибке не удается найти в исполняемых файлах.
Еще раз спасибо IDA, она легко загружает исполняемые файлы формата COFF
использующиеся в SCO OpenServer.
Попробуем также поискать строку «rbsl», и действительно, её можно найти в
таком фрагменте кода:
.text:00022AB8
.text:00022AB8
.text:00022AB8
.text:00022AB8
.text:00022AB8
.text:00022AB8
.text:00022AB8
.text:00022AB8
.text:00022AB9
.text:00022ABB
.text:00022ABE
.text:00022ABF
.text:00022AC4
.text:00022AC5
.text:00022AC8
.text:00022AC9
.text:00022ACA
.text:00022ACF
.text:00022AD2
.text:00022AD7
.text:00022ADD
.text:00022ADE
.text:00022AE1
.text:00022AE4
.text:00022AE9
ebp
ebp, esp
esp, 44h
edi
edi, offset unk_4035D0
esi
esi, [ebp+arg_0]
ebx
esi
strlen
esp, 4
eax, 2
loc_22BA4
esi
al, [esi−1]
eax, al
eax, '3'
loc_22B84
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
call
strlen
push
eax
; unsigned int
lea
eax, [ebp+var_44]
push
eax
; void *
mov
eax, [edi]
push
eax
; int
call
_write
add
esp, 10h
pop
ebx
pop
esi
pop
edi
mov
esp, ebp
pop
ebp
retn
db 0Eh dup(90h)
endp
Действительно, должна же как-то программа обмениваться информацией с
драйвером.
Единственное место где вызывается функция SSQC() это thunk function:
.text:0000DBE8
.text:0000DBE8 SSQ
.text:0000DBE8
.text:0000DBE8
.text:0000DBE8 arg_0
.text:0000DBE8
.text:0000DBE8
.text:0000DBE9
.text:0000DBEB
.text:0000DBEE
.text:0000DBEF
.text:0000DBF4
.text:0000DBF7
.text:0000DBF9
.text:0000DBFA
.text:0000DBFB SSQ
public SSQ
proc near ; CODE XREF: sys_info+A9p
; sys_info+CBp ...
= dword ptr
push
mov
mov
push
call
add
mov
pop
retn
endp
ebp
ebp,
edx,
edx
SSQC
esp,
esp,
ebp
8
esp
[ebp+arg_0]
4
ebp
А вот SSQ() может вызываться по крайней мере из двух разных функций.
Одна из них:
.data:0040169C _51_52_53
DATA XREF: init_sys+392r
.data:0040169C
.data:0040169C
CONTINUE: "
.data:004016A0
.data:004016A4
.data:004016A8
dd offset aPressAnyKeyT_0 ;
; sys_info+A1r
; "PRESS ANY KEY TO
dd offset a51
dd offset a52
dd offset a53
; "51"
; "52"
; "53"
...
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
; эти имена мы сами дали этим меткам:
.data:004016C0 answers1
dd 6B05h
sys_info+E7r
.data:004016C4
dd 3D87h
.data:004016C8 answers2
dd 3Ch
sys_info+F2r
.data:004016CC
dd 832h
.data:004016D0 _C_and_B
db 0Ch
sys_info+BAr
.data:004016D0
.data:004016D1 byte_4016D1
db 0Bh
DATA XREF: sys_info+FDr
.data:004016D2
db
0
; DATA XREF:
; DATA XREF:
; DATA XREF:
; sys_info:OKr
;
loc_D6C0: ; CODE XREF: sys_info+C1j
inc
ds:ctl_port
xor
eax, eax
mov
al, ds:ctl_port
cmp
eax, edi
jle
short loc_D652
loc_D6D1: ; CODE XREF: sys_info+98j
; sys_info+B6j
mov
edx, [ebp+var_8]
inc
edx
mov
[ebp+var_8], edx
cmp
edx, 3
jle
loc_D641
loc_D6E1: ; CODE XREF: sys_info+16j
; sys_info+51j ...
pop
ebx
pop
edi
mov
esp, ebp
pop
ebp
retn
OK:
; CODE XREF: sys_info+F0j
; sys_info+FBj
mov
al, _C_and_B[ebx]
pop
ebx
pop
edi
mov
ds:ctl_model, al
mov
esp, ebp
pop
ebp
retn
sys_info
endp
«3C» и «3E» — это звучит знакомо: когда-то была донгла Sentinel Pro от Rainbow
без памяти, предоставляющая только одну секретную крипто-хеширующую
функцию.
О том, что такое хэш-функция, было описано здесь: 2.11 (стр. 602).
Но вернемся к нашей программе. Так что программа может только проверить
подключена ли донгла или нет. Никакой больше информации в такую донглу
без памяти записать нельзя. Двухсимвольные коды — это команды (можно увидеть, как они обрабатываются в функции SSQC()) а все остальные строки хешируются внутри донглы превращаясь в 16-битное число. Алгоритм был секретный, так что нельзя было написать замену драйверу или сделать электронную
копию донглы идеально эмулирующую алгоритм. С другой стороны, всегда
можно было перехватить все обращения к ней и найти те константы, с которыми сравнивается результат хеширования. Но надо сказать, вполне возможно
создать устойчивую защиту от копирования базирующуюся на секретной хешфункции: пусть она шифрует все файлы с которыми ваша программа работает.
Но вернемся к нашему коду.
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1079
Коды 51/52/53 используются для выбора номера принтеровского LPT-порта.
3x/4x используются для выбора «family» так донглы Sentinel Pro можно отличать друг от друга: ведь более одной донглы может быть подключено к LPTпорту.
Единственная строка, передающаяся в хеш-функцию это
”0123456789”. Затем результат сравнивается с несколькими правильными значениями.
Если результат правилен, 0xC или 0xB будет записано в глобальную переменную ctl_model.
Еще одна строка для хеширования: ”PRESS ANY KEY TO CONTINUE: ”, но результат не проверяется. Трудно сказать, зачем это, может быть по ошибке 16 .
Давайте посмотрим, где проверяется значение глобальной переменной ctl_model.
Одно из таких мест:
.text:0000D708 prep_sys proc near ; CODE XREF: init_sys+46Ap
.text:0000D708
.text:0000D708 var_14
= dword ptr −14h
.text:0000D708 var_10
= byte ptr −10h
.text:0000D708 var_8
= dword ptr −8
.text:0000D708 var_2
= word ptr −2
.text:0000D708
.text:0000D708
push
ebp
.text:0000D709
mov
eax, ds:net_env
.text:0000D70E
mov
ebp, esp
.text:0000D710
sub
esp, 1Ch
.text:0000D713
test
eax, eax
.text:0000D715
jnz
short loc_D734
.text:0000D717
mov
al, ds:ctl_model
.text:0000D71C
test
al, al
.text:0000D71E
jnz
short loc_D77E
.text:0000D720
mov
[ebp+var_8], offset aIeCvulnvvOkgT_ ;
"Ie-cvulnvV\\\bOKG]T_"
.text:0000D727
mov
edx, 7
.text:0000D72C
jmp
loc_D7E7
...
.text:0000D7E7 loc_D7E7: ; CODE XREF: prep_sys+24j
.text:0000D7E7
; prep_sys+33j
.text:0000D7E7
push
edx
.text:0000D7E8
mov
edx, [ebp+var_8]
.text:0000D7EB
push
20h
.text:0000D7ED
push
edx
.text:0000D7EE
push
16h
.text:0000D7F0
call
err_warn
.text:0000D7F5
push
offset station_sem
.text:0000D7FA
call
ClosSem
.text:0000D7FF
call
startup_err
16 Это
очень странное чувство: находить ошибки в столь древнем ПО.
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1080
Если оно 0, шифрованное сообщение об ошибке будет передано в функцию
дешифрования, и оно будет показано.
Функция дешифровки сообщений об ошибке похоже применяет простой xoring:
.text:0000A43C err_warn
prep_sys+E8p
.text:0000A43C
.text:0000A43C
.text:0000A43C var_55
.text:0000A43C var_54
.text:0000A43C arg_0
.text:0000A43C arg_4
.text:0000A43C arg_8
.text:0000A43C arg_C
.text:0000A43C
.text:0000A43C
.text:0000A43D
.text:0000A43F
.text:0000A442
.text:0000A443
.text:0000A446
.text:0000A448
.text:0000A44A
.text:0000A44B
.text:0000A44D
.text:0000A450
.text:0000A453
.text:0000A453 loc_A453:
err_warn+28j
.text:0000A453
.text:0000A455
.text:0000A458
.text:0000A45A
.text:0000A45D
.text:0000A45E
.text:0000A460
.text:0000A464
.text:0000A466
.text:0000A466 loc_A466:
err_warn+Fj
.text:0000A466
.text:0000A46B
.text:0000A46E
.text:0000A473
.text:0000A475
.text:0000A478
.text:0000A479
.text:0000A47E
.text:0000A481
.text:0000A481 loc_A481:
err_warn+72j
.text:0000A481
.text:0000A483
.text:0000A485
Вот почему не получилось найти сообщение об ошибке в исполняемых файлах,
потому что оно было зашифровано, это очень популярная практика.
Еще один вызов хеширующей функции передает строку «offln» и сравнивает
результат с константами 0xFE81 и 0x12A9. Если результат не сходится, происходит работа с какой-то функцией timer() (может быть для ожидания плохо
подключенной донглы и нового запроса?), затем дешифрует еще одно сообщение об ошибке и выводит его.
.text:0000DA55 loc_DA55:
sync_sys+24Cj
.text:0000DA55
.text:0000DA5A
.text:0000DA5F
.text:0000DA62
.text:0000DA64
.text:0000DA66
.text:0000DA69
.text:0000DA6B
.text:0000DA71
.text:0000DA77
.text:0000DA7D
.text:0000DA83
.text:0000DA83 loc_DA83:
sync_sys+201j
.text:0000DA83
.text:0000DA85
.text:0000DA88
.text:0000DA8A
.text:0000DA90
.text:0000DA96
.text:0000DA99
...
; это имя мы сами дали этой метке:
.text:0000D9B6 decrypt_end_print_message:
; CODE XREF:
sync_sys+29Dj
.text:0000D9B6
; sync_sys+2ABj
.text:0000D9B6
mov
eax, [ebp+var_18]
.text:0000D9B9
test
eax, eax
.text:0000D9BB
jnz
short loc_D9FB
.text:0000D9BD
mov
edx, [ebp+var_C] ; key
.text:0000D9C0
mov
ecx, [ebp+var_8] ; string
.text:0000D9C3
push
edx
.text:0000D9C4
push
20h
.text:0000D9C6
push
ecx
.text:0000D9C7
push
18h
.text:0000D9C9
call
err_warn
.text:0000D9CE
push
0Fh
.text:0000D9D0
push
190h
.text:0000D9D5
call
sound
.text:0000D9DA
mov
[ebp+var_18], 1
.text:0000D9E1
add
esp, 18h
.text:0000D9E4
call
pcv_kbhit
.text:0000D9E9
test
eax, eax
.text:0000D9EB
jz
short loc_D9FB
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1083
...
; это имя мы сами дали этой метке:
.data:00401736 encrypted_error_message2 db 74h, 72h, 78h, 43h, 48h, 6, 5Ah,⤦
Ç 49h, 4Ch, 2 dup(47h)
.data:00401736
db 51h, 4Fh, 47h, 61h, 20h, 22h, 3Ch, 24h, ⤦
Ç 33h, 36h, 76h
.data:00401736
db 3Ah, 33h, 31h, 0Ch, 0, 0Bh, 1Fh, 7, 1Eh, ⤦
Ç 1Ah
Заставить работать программу без донглы довольно просто: просто пропатчить все места после инструкций CMP где происходят соответствующие сравнения.
Еще одна возможность — это написать свой драйвер для SCO OpenServer, содержащий таблицу возможных вопросов и ответов, все те что имеются в программе.
Дешифровка сообщений об ошибке
Кстати, мы также можем дешифровать все сообщения об ошибке. Алгоритм,
находящийся в функции err_warn() действительно, крайне прост:
Листинг 8.5: Функция дешифровки
.text:0000A44D
.text:0000A450
.text:0000A453 loc_A453:
.text:0000A453
.text:0000A455
дешифровки
.text:0000A458
.text:0000A45A
следующего байта
.text:0000A45D
.text:0000A45E
.text:0000A460
.text:0000A464
mov
mov
esi, [ebp+arg_C] ; key
edx, [ebp+arg_4] ; string
xor
mov
eax, eax
al, [edx+edi] ; загружаем байт для
xor
add
eax, esi
esi, 3
inc
cmp
mov
jl
edi
edi, ecx
[ebp+edi+var_55], al
short loc_A453
; дешифруем его
; изменяем ключ для
Как видно, не только сама строка поступает на вход, но также и ключ для
дешифровки:
.text:0000DAF7 error:
sync_sys+255j
.text:0000DAF7
.text:0000DAF7
mov
Ç encrypted_error_message2
.text:0000DAFE
mov
.text:0000DB05
jmp
Алгоритм это очень простой xoring: каждый байт XOR-ится с ключом, но ключ
увеличивается на 3 после обработки каждого байта.
Напишем небольшой скрипт на Python для проверки наших догадок:
Листинг 8.6: Python 3.x
#!/usr/bin/python
import sys
msg=[0x74, 0x72, 0x78, 0x43, 0x48, 0x6, 0x5A, 0x49, 0x4C, 0x47, 0x47,
0x51, 0x4F, 0x47, 0x61, 0x20, 0x22, 0x3C, 0x24, 0x33, 0x36, 0x76,
0x3A, 0x33, 0x31, 0x0C, 0x0, 0x0B, 0x1F, 0x7, 0x1E, 0x1A]
key=0x17
tmp=key
for i in msg:
sys.stdout.write ("%c" % (i^tmp))
tmp=tmp+3
sys.stdout.flush()
И он выводит: «check security device connection». Так что да, это дешифрованное сообщение.
Здесь есть также и другие сообщения, с соответствующими ключами. Но надо
сказать, их можно дешифровать и без ключей. В начале, мы можем заметить,
что ключ — это просто байт. Это потому что самая важная часть функции дешифровки (XOR) оперирует байтами. Ключ находится в регистре ESI, но только
младшие 8 бит (т.е. байт) регистра используются. Следовательно, ключ может
быть больше 255, но его значение будет округляться.
И как следствие, мы можем попробовать обычный перебор всех ключей в диапазоне 0..255. Мы также можем пропускать сообщения содержащие непечатные символы.
Листинг 8.7: Python 3.x
#!/usr/bin/python
import sys, curses.ascii
msgs=[
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
def is_string_printable(s):
return all(list(map(lambda x: curses.ascii.isprint(x), s)))
cnt=1
for msg in msgs:
print ("message #%d" % cnt)
for key in range(0,256):
result=[]
tmp=key
for i in msg:
result.append (i^tmp)
tmp=tmp+3
if is_string_printable (result):
print ("key=", key, "value=", "".join(list(map(chr,⤦
Ç result))))
cnt=cnt+1
И мы получим:
Листинг 8.8: Results
message #1
key= 20 value= `eb^h%|``hudw|_af{n~f%ljmSbnwlpk
key= 21 value= ajc]i"}cawtgv{^bgto}g"millcmvkqh
key= 22 value= bkd\j#rbbvsfuz!cduh|d#bhomdlujni
key= 23 value= check security device connection
key= 24 value= lifbl!pd|tqhsx#ejwjbb!`nQofbshlo
message #2
key= 7 value= No security device found
key= 8 value= An#rbbvsVuz!cduhld#ghtme?!#!'!#!
message #3
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1086
key= 7 value= BkugasLkvw&fgpgag^uvcrwml.`mwhj
key= 10 value= Ol!td`tMhwx'efwfbf!tubuvnm!anvok
key= 11 value= No security device station found
key= 12 value= In#rjbvsnuz!{duhdd#r{`whho#gPtme
message #4
key= 14 value= Number of authorized users exceeded
key= 15 value= Ovlmdq!hg#`juknuhydk!vrbsp!Zy`dbefe
message #5
key= 17 value= check security device station
key= 18 value= `ijbh!td`tmhwx'efwfbf!tubuVnm!'!
Тут есть какой-то мусор, но мы можем быстро отыскать сообщения на английском языке!
Кстати, так как алгоритм использует простой XOR, та же функция может использоваться и для шифрования сообщения. Если нужно, мы можем зашифровать наши собственные сообщения, и пропатчить программу вставив их.
8.6.3. Пример #3: MS-DOS
Еще одна очень старая программа для MS-DOS от 1995 также разработанная
давно исчезнувшей компанией.
Во времена перед DOS-экстендерами, всё ПО для MS-DOS рассчитывалось на
процессоры 8086 или 80286, так что в своей массе весь код был 16-битным.
16-битный код в основном такой же, какой вы уже видели в этой книге, но все
регистры 16-битные, и доступно меньше инструкций.
Среда MS-DOS не могла иметь никаких драйверов, и ПО работало с «голым»
железом через порты, так что здесь вы можете увидеть инструкции OUT/IN,
которые в наше время присутствуют в основном только в драйверах (в современных OS нельзя обращаться на прямую к портам из user mode).
Учитывая это, ПО для MS-DOS должно работать с донглой обращаясь к принтерному LPT-порту напрямую. Так что мы можем просто поискать эти инструкции.
И да, вот они:
seg030:0034
seg030:0034
seg030:0034
seg030:0034
seg030:0034
seg030:0034
seg030:0035
seg030:0037
seg030:003B
seg030:003E
seg030:003F
seg030:0040
seg030:0040
out_port proc far ; CODE XREF: sent_pro+22p
; sent_pro+2Ap ...
arg_0
55
8B EC
8B 16 7E E7
8A 46 06
EE
5D
CB
= byte ptr
push
mov
mov
mov
out
pop
retf
out_port endp
bp
bp,
dx,
al,
dx,
bp
6
sp
_out_port ; 0x378
[bp+arg_0]
al
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1087
(Все имена меток в этом примере даны мною).
Функция out_port() вызывается только из одной функции:
seg030:0041
seg030:0041
seg030:0041
seg030:0041
seg030:0041
seg030:0041
seg030:0041
seg030:0045
seg030:0046
seg030:0047
seg030:004B
seg030:004C
seg030:004E
seg030:0051
seg030:0054
seg030:0056
seg030:0059
seg030:005C
seg030:005E
seg030:005F
seg030:0062
seg030:0063
seg030:0066
seg030:0067
seg030:006A
seg030:006B
seg030:006E
seg030:006F
seg030:0071
seg030:0073
seg030:0073
seg030:0073
seg030:0074
seg030:0074
seg030:0074
seg030:0078
seg030:007A
seg030:007D
seg030:007E
seg030:0081
seg030:0082
seg030:0085
seg030:0086
seg030:0089
seg030:008A
seg030:008D
seg030:008E
seg030:0091
seg030:0092
seg030:0095
enter
push
push
mov
in
mov
and
or
mov
mov
and
mov
out
push
push
call
pop
push
push
call
pop
xor
jmp
4, 0
si
di
dx, _in_port_1 ; 0x37A
al, dx
bl, al
bl, 0FEh
bl, 4
al, bl
[bp+var_3], al
bl, 1Fh
al, bl
dx, al
0FFh
cs
near ptr out_port
cx
0D3h
cs
near ptr out_port
cx
si, si
short loc_359D4
loc_359D3: ; CODE XREF: sent_pro+37j
inc
si
46
81
7C
68
0E
E8
59
68
0E
E8
59
68
0E
E8
59
68
0E
= byte ptr −3
= word ptr −2
= dword ptr 6
FE 96 00
F9
C3 00
B3 FF
C7 00
AB FF
D3 00
A3 FF
C3 00
loc_359D4: ; CODE XREF: sent_pro+30j
cmp
si, 96h
jl
short loc_359D3
push
0C3h
push
cs
call
near ptr out_port
pop
cx
push
0C7h
push
cs
call
near ptr out_port
pop
cx
push
0D3h
push
cs
call
near ptr out_port
pop
cx
push
0C3h
push
cs
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
loc_35A88: ; CODE XREF: sent_pro+E2j
test
cl, 80h
jz
short loc_35A90
and
bl, 7Fh
8B 16 82 E7
8A C3
EE
8B C7
5F
5E
C9
CB
loc_35A90: ; CODE XREF: sent_pro+EAj
mov
dx, _in_port_1 ; 0x37A
mov
al, bl
out
dx, al
mov
ax, di
pop
di
pop
si
leave
retf
sent_pro endp
Это также «хеширующая» донгла Sentinel Pro как и в предыдущем примере.
Это заметно по тому что текстовые строки передаются и здесь, 16-битные значения также возвращаются и сравниваются с другими.
Так вот как происходит работа с Sentinel Pro через порты. Адрес выходного
порта обычно 0x378, т.е. принтерного порта, данные для него во времена перед USB отправлялись прямо сюда. Порт однонаправленный, потому что когда
его разрабатывали, никто не мог предположить, что кому-то понадобится по-
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1090
лучать информацию из принтера 17 . Единственный способ получить информацию из принтера это регистр статуса на порту 0x379, он содержит такие биты
как «paper out», «ack», «busy» — так принтер может сигнализировать о том,
что он готов или нет, и о том, есть ли в нем бумага. Так что донгла возвращает
информацию через какой-то из этих бит, по одному биту на каждой итерации.
_in_port_2 содержит адрес статуса (0x379) и _in_port_1 содержит адрес управляющего регистра (0x37A).
Судя по всему, донгла возвращает информацию только через флаг «busy» на
seg030:00B9: каждый бит записывается в регистре DI позже возвращаемый в
самом конце функции.
Что означают все эти отсылаемые в выходной порт байты? Трудно сказать. Возможно, команды донглы. Но честно говоря, нам и не обязательно знать: нашу
задачу можно легко решить и без этих знаний.
Вот функция проверки донглы:
00000000
00000000
00000019
0000001B
А так как эта функция может вызываться слишком часто, например, перед
выполнением каждой важной возможности ПО, а обращение к донгле вообщето медленное (и из-за медленного принтерного порта, и из-за медленного MCU
в донгле), так что они, наверное, добавили возможность пропускать проверку
донглы слишком часто, используя текущее время в функции biostime().
Функция get_rand() использует стандартную функцию Си:
seg030:01BF
seg030:01BF
seg030:01BF
seg030:01BF
get_rand proc far ; CODE XREF: check_dongle+25p
arg_0
= word ptr
6
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
bp
bp, sp
_rand
eax, ax
edx, [bp+arg_0]
eax, edx
ebx, 8000h
ebx
bp
Так что текстовая строка выбирается случайно, отправляется в донглу и результат хеширования сверяется с корректным значением.
Текстовые строки, похоже, составлялись так же случайно, во время разработки ПО.
И вот как вызывается главная процедура проверки донглы:
seg033:087B 9A 45 01 96+
call
check_dongle
seg033:0880 0B C0
or
ax, ax
seg033:0882 74 62
jz
short OK
seg033:0884 83 3E 60 42+
cmp
word_620E0, 0
seg033:0889 75 5B
jnz
short OK
seg033:088B FF 06 60 42
inc
word_620E0
seg033:088F 1E
push
ds
seg033:0890 68 22 44
push
offset aTrupcRequiresA ;
"This Software Requires a Software Lock\n"
seg033:0893 1E
push
ds
seg033:0894 68 60 E9
push
offset byte_6C7E0 ; dest
seg033:0897 9A 79 65 00+
call
_strcpy
seg033:089C 83 C4 08
add
sp, 8
seg033:089F 1E
push
ds
seg033:08A0 68 42 44
push
offset aPleaseContactA ; "Please Contact
..."
seg033:08A3 1E
push
ds
seg033:08A4 68 60 E9
push
offset byte_6C7E0 ; dest
seg033:08A7 9A CD 64 00+
call
_strcat
Заставить работать программу без донглы очень просто: просто заставить
функцию check_dongle() возвращать всегда 0.
Например, вставив такой код в самом её начале:
mov ax,0
retf
Наблюдательный читатель может заметить, что функция Си strcpy() имеет 2
аргумента, но здесь мы видим, что передается 4:
seg033:088F 1E
push
seg033:0890 68 22 44
push
"This Software Requires a Software Lock\n"
ds
offset aTrupcRequiresA ;
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
Это связано с моделью памяти в MS-DOS. Об этом больше читайте здесь: 10.6
(стр. 1274).
Так что, strcpy(), как и любая другая функция принимающая указатель (-и) в
аргументах, работает с 16-битными парами.
Вернемся к нашему примеру. DS сейчас указывает на сегмент данных размещенный в исполняемом файле, там, где хранится текстовая строка.
В функции sent_pro() каждый байт строки загружается на
seg030:00EF: инструкция LES загружает из переданного аргумента пару ES:BX
одновременно. MOV на seg030:00F5 загружает байт из памяти, на который указывает пара ES:BX.
8.7. Случай с зашифрованной БД #1
(Эта часть впервые появилась в моем блоге 26-Aug-2015. Обсуждение: https:
//news.ycombinator.com/item?id=10128684.)
8.7.1. Base64 и энтропия
Мне достался XML-файл, содержащий некоторые зашифрованные данные. Вероятно, что-то связанное с заказми и/или с информацией о клиентах.
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1094
...
Файл доступен здесь.
Это явно данные закодированные в base64, потому что все строки состоят из
латинских символов, цифр, и символов плюс (+) и слэш (/). Могут быть еще
два выравнивающих символа (=), но они никогда не встречаются в середине
строки. Зная эти свойства base64, такие строки легко распозновать.
Попробуем декодировать эти блоки и вычислить их энтропии (9.2 (стр. 1215))
при помощи Wolfram Mathematica:
In[]:= ListOfBase64Strings =
Map[First[#[[3]]] &, Cases[Import["encrypted.xml"], XMLElement["Data", _,⤦
Ç _], Infinity]];
In[]:= BinaryStrings =
Map[ImportString[#, {"Base64", "String"}] &, ListOfBase64Strings];
In[]:= Entropies = Map[N[Entropy[2, #]] &, BinaryStrings];
In[]:= Variance[Entropies]
Out[]= 0.0238614
Разброс (variance) низкий. Это означает, что значения энтропии не очень отличаются друг от друга. Это видно на графике:
In[]:= ListPlot[Entropies]
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1095
Большинство значений между 5.0 и 5.4. Это свидетельство того что данные
сжаты и/или зашифрованы.
Чтобы понять разброс (variance), подсчитаем энтропии всех строк в книге Конана Дойля The Hound of the Baskervilles:
In[]:= BaskervillesLines = Import["http://www.gutenberg.org/cache/epub⤦
Ç /2852/pg2852.txt", "List"];
In[]:= EntropiesT = Map[N[Entropy[2, #]] &, BaskervillesLines];
In[]:= Variance[EntropiesT]
Out[]= 2.73883
In[]:= ListPlot[EntropiesT]
Большинство значений находится вокруг 4, но есть также ме́ ньшие значения,
и они повлияли на конечное значение разброса.
Вероятно, самые короткие строки имеют ме́ ньшую энтропию, попробуем короткую строку из книги Конан Дойля:
In[]:= Entropy[2, "Yes, sir."] // N
Out[]= 2.9477
Попробуем еще ме́ ньшую:
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1096
In[]:= Entropy[2, "Yes"] // N
Out[]= 1.58496
In[]:= Entropy[2, "No"] // N
Out[]= 1.
8.7.2. Данные сжаты?
ОК, наши данные сжаты и/или зашифрованы. Сжаты ли? Почти все компрессоры данных помещают некоторый заголовок в начале, сигнатуру или что-то
вроде этого. Как видим, здесь ничего такого нет в начале каждого блока. Все
еще возможно что это какой-то самодельный компрессор, но они очень редки. С другой стороны, самодельные криптоалгоритмы попадаются часто, потому что их куда легче заставить работать. Даже примитивные криптосистемы
без ключей, как memfrob()18 и ROT13 нормально работают без ошибок. А чтобы написать свой компрессор с нуля, используя только фантазию и воображение, так что он будет работать без ошибок, это серьезная задача. Некоторые
программисты реализуют ф-ции сжатия данных по учебникам, но это также
редкость. Наиболее популярные способы это: 1) просто взять опен-сорсную
библиотеку вроде zlib; 2) скопипастить что-то откуда-то. Опен-сорсные алгоритмы сжатия данных обычно добавляют какой-то заголовок, и точно так же
делают алгоритмы с сайтов вроде http://www.codeproject.com/.
8.7.3. Данные зашифрованы?
Основные алгоритмы шифрования обрабатывают данные блоками. DES — по 8
байт, AES — по 16 байт. Если входной буфер не делится без остатка на длину
блока, он дополняется нулями (или еще чем-то), так что зашифрованные данные будут выровнены по размеру блока этого алгоритма шифрования. Это не
наш случай.
Используя Wolfram Mathematica, я проанализировал длины блоков:
In[]:=
Out[]=
44 −>
30 −>
1858 блоков имеют длину 42 байта, 1235 блоков имеют длину 38 байт, итд.
Я сделал график:
ListPlot[Counts[Map[StringLength[#] &, BinaryStrings]]]
18 http://linux.die.net/man/3/memfrob
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1097
Так что большинство блоков имеют размер между ~36 и ~48. Вот еще что стоит
отметить: длины всех блоков четные. Нет ни одного блока с нечетной длиной.
Хотя, существуют потоковые шифры, которые работают на уровне байт, или
даже на уровне бит.
8.7.4. CryptoPP
Программа, при помощи которой можно листать зашифрованную базу написана на C# и код на .NET сильно обфусцирован. Тем не менее, имеется DLL
с кодом для x86, который, после краткого рассмотрения, имеет части из популярной опен-сорсной библиотеки CryptoPP! (Я просто нашел внутри строки
«CryptoPP».) Теперь легко найти все ф-ции внутри DLL, потому что библиотека
CryptoPP опен-сорсная.
Библиотека CryptoPP имеет множество ф-ций шифрования, включая AES (AKA
Rijndael). Современные x86-процессоры имеют AES-инструкции вроде AESENC,
AESDEC и AESKEYGENASSIST 19 . Они не производят полного шифрования/дешифрования, но они делают бо́ льшую часть работы. И новые версии CryptoPP используют их. Например, здесь: 1, 2. К моему удивлению, во время дешифрования, инструкция, AESENC исполняется, а AESDEC — нет (я это проверил при помощи моей утилиты tracer, но можно использовать любой отладчик). Я проверил,
поддерживает ли мой процессор AES-инструкции. Некоторые процессоры Intel
i3 не поддерживают. И если нет, библиотека CryptoPP применяет ф-ции AES ре19 https://en.wikipedia.org/wiki/AES_instruction_set
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1098
ализованные старым способом 20 . Но мой процессор поддерживает их. Почему
AESDEC не исполняется? Почему программа использует шифрование AES чтобы
дешифровать БД?
ОК, найти ф-цию шифрования блока это не проблема. Она называется
CryptoPP::Rijndael::Enc::ProcessAndXorBlock: src, и она может вызывать другую
ф-цию:
Rijndael::Enc::AdvancedProcessBlocks() src, которая, в свою очередь, может вызывать две ф-ции ( AESNI_Enc_Block and AESNI_Enc_4_Blocks ) которые имеют
инструкции AESENC.
Так что, судя по внутренностям CryptoPP,
CryptoPP::Rijndael::Enc::ProcessAndXorBlock() шифрует один 16-байтный блок. Попробуем установить брякпоинт на ней и посмотрим, что происходит во время
дешифрования. Я снова использую мою простую утилиту tracer. Сейчас программа должна дешифровать первый блок. О, и кстати, вот первый блок сконвертированный из кодировки base64 в шестнадцатеричный вид, будем держать его под рукой:
00000000: CA 39 B1 85 75 1B 84 1F
Ç ..]
00000010: 95 80 27 02 21 D5 2D 1A
Ç .
00000020: B1 27 7F 84 FE 41 37 86
F9 31 5E 39 72 13 EC 5D
.9..u....1^9r⤦
0F D9 45 9F 75 EE 24 C4
..'.!.−...E.u.$⤦
C9 C0
.'...A7...
А еще вот аргументы ф-ции из исходных файлов CryptoPP:
size_t Rijndael::Enc::AdvancedProcessBlocks(const byte ∗inBlocks, const ⤦
Ç byte ∗xorBlocks, byte ∗outBlocks, size_t length, word32 flags);
Так что у него 5 аргументов. Возможные флаги это:
enum {BT_InBlockIsCounter=1, BT_DontIncrementInOutPointers=2, BT_XorInput⤦
Ç =4, BT_ReverseDirection=8, BT_AllowParallel=16} ⤦
Ç FlagsForAdvancedProcessBlocks;
ОК, запускаем tracer на ф-ции ProcessAndXorBlock():
... tracer.exe −l:filename.exe bpf=filename.exe!0x4339a0,args:5,dump_args:0⤦
Ç x10
Warning: no tracer.cfg file.
PID=1984|New process software.exe
no module registered with image base 0x77320000
no module registered with image base 0x76e20000
no module registered with image base 0x77320000
no module registered with image base 0x77220000
Warning: unknown (to us) INT3 breakpoint at ntdll.dll!⤦
Ç LdrVerifyImageMatchesChecksum+0x96c (0x776c103b)
(0) software.exe!0x4339a0(0x38b920, 0x0, 0x38b978, 0x10, 0x0) (called from ⤦
Ç software.exe!.text+0x33c0d (0x13e4c0d))
20 https://github.com/mmoss/cryptopp/blob/2772f7b57182b31a41659b48d5f35a7b6cedd34d/src/
rijndael.cpp#L355
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1099
Argument 1/5
0038B920: 01 00 00 00 FF FF FF FF−79 C1 69 0B 67 C1 04 7D "........y.i.g⤦
Ç ..}"
Argument 3/5
0038B978: CD CD CD CD CD CD CD CD−CD CD CD CD CD CD CD CD ⤦
Ç "................"
(0) software.exe!0x4339a0() −> 0x0
Argument 3/5 difference
00000000: C7 39 4E 7B 33 1B D6 1F−B8 31 10 39 39 13 A5 5D ".9N⤦
Ç {3....1.99..]"
(0) software.exe!0x4339a0(0x38a828, 0x38a838, 0x38bb40, 0x0, 0x8) (called ⤦
Ç from software.exe!.text+0x3a407 (0x13eb407))
Argument 1/5
0038A828: 95 80 27 02 21 D5 2D 1A−0F D9 45 9F 75 EE 24 C4 "..'.!.−...E.u.$⤦
Ç ."
Argument 2/5
0038A838: B1 27 7F 84 FE 41 37 86−C9 C0 00 CD CD CD CD CD ".'...A7⤦
Ç ........."
Argument 3/5
0038BB40: CD CD CD CD CD CD CD CD−CD CD CD CD CD CD CD CD ⤦
Ç "................"
(0) software.exe!0x4339a0() −> 0x0
(0) software.exe!0x4339a0(0x38b920, 0x38a828, 0x38bb30, 0x10, 0x0) (called ⤦
Ç from software.exe!.text+0x33c0d (0x13e4c0d))
Argument 1/5
0038B920: CA 39 B1 85 75 1B 84 1F−F9 31 5E 39 72 13 EC 5D ".9..u....1^9r⤦
Ç ..]"
Argument 2/5
0038A828: 95 80 27 02 21 D5 2D 1A−0F D9 45 9F 75 EE 24 C4 "..'.!.−...E.u.$⤦
Ç ."
Argument 3/5
0038BB30: CD CD CD CD CD CD CD CD−CD CD CD CD CD CD CD CD ⤦
Ç "................"
(0) software.exe!0x4339a0() −> 0x0
Argument 3/5 difference
00000000: 45 00 20 00 4A 00 4F 00−48 00 4E 00 53 00 00 00 "E. .J.O.H.N.S⤦
Ç ..."
(0) software.exe!0x4339a0(0x38b920, 0x0, 0x38b978, 0x10, 0x0) (called from ⤦
Ç software.exe!.text+0x33c0d (0x13e4c0d))
Argument 1/5
0038B920: 95 80 27 02 21 D5 2D 1A−0F D9 45 9F 75 EE 24 C4 "..'.!.−...E.u.$⤦
Ç ."
Argument 3/5
0038B978: 95 80 27 02 21 D5 2D 1A−0F D9 45 9F 75 EE 24 C4 "..'.!.−...E.u.$⤦
Ç ."
(0) software.exe!0x4339a0() −> 0x0
Argument 3/5 difference
00000000: B1 27 7F E4 9F 01 E3 81−CF C6 12 FB B9 7C F1 BC ⤦
Ç ".'...........|.."
PID=1984|Process software.exe exited. ExitCode=0 (0x0)
Тут мы можем увидеть входы в ф-цию ProcessAndXorBlock(), и выходы из нее.
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1100
Это вывод из ф-ции во время первого вызова:
00000000: C7 39 4E 7B 33 1B D6 1F−B8 31 10 39 39 13 A5 5D ".9N⤦
Ç {3....1.99..]"
Затем ф-ция ProcessAndXorBlock() вызывается с блоком нулевого размера,
но с флагом 8 (BT_ReverseDirection).
Второй вызов:
00000000: 45 00 20 00 4A 00 4F 00−48 00 4E 00 53 00 00 00 "E. .J.O.H.N.S⤦
Ç ..."
Ох, тут есть знакомая нам строка!
Третий вызов:
00000000: B1 27 7F E4 9F 01 E3 81−CF C6 12 FB B9 7C F1 BC ⤦
Ç ".'...........|.."
Первый вывод очень похож на первые 16 байт зашифрованного буфера.
Вывод первого вызова ProcessAndXorBlock():
00000000: C7 39 4E 7B 33 1B D6 1F−B8 31 10 39 39 13 A5 5D ".9N⤦
Ç {3....1.99..]"
Первые 16 байт зашифрованного буфера:
00000000: CA 39 B1 85 75 1B 84 1F F9 31 5E 39 72 13 EC 5D
.9..u....1^9r..]
Тут слишком много одинаковых байт! Как так получается, что результат шифрования AES может быть очень похож на шифрованный буфер в то время как
это не шифрование, а скорее дешифрование?
8.7.5. Режим обратной связи по шифротексту
Ответ это CFB21 : в этом режиме, алгоритм AES используются не как алгоритм
шифрования, а как устройство для генерации случайных данных с криптографической стойкостью. Само шифрование производится используя простую
операцию XOR.
Вот алгоритм шифрования (иллюстрации взяты из Wikipedia):
21 Режим
обратной связи по шифротексту (Cipher Feedback)
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1101
И дешифрования:
Посмотрим: операция шифрования в AES генерирует 16 байт (или 128 бит) случайных данных, которые можно использовать во время применения операции
XOR, но кто заставляет нас использовать все 16 байт? Если на последней итерации у нас 1 байт данных, давайте про-XOR-им 1 байт данных с 1 байтом
сгенерированных случайных данных? Это приводит к важному свойству режима CFB: данные не нужно выравнивать, данные произвольного размера могут
быть зашифрованы и дешифрованы.
О, и вот почему все шифрованные блоки не выровнены. И вот почему инструкция AESDEC никогда не вызывается.
Давайте попробуем дешифровать первый блок вручную, используя Питон. Режим CFB также использует IV, как seed для CSPRNG22 . В нашем случае, IV это
блок, который шифруется на первой итерации:
22 Криптографически стойкий генератор псевдослучайных чисел (cryptographically secure
pseudorandom number generator)
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
О, и нам нужно также восстановить ключ шифрования. В DLL есть AESKEYGENASSIST,
и она вызывается, и используется в ф-ции
Rijndael::Base::UncheckedSetKey(): src. Её легко найти в IDA и установить брякпойнт. Посмотрим:
... tracer.exe −l:filename.exe bpf=filename.exe!0x435c30,args:3,dump_args:0⤦
Ç x10
Warning: no tracer.cfg file.
PID=2068|New process software.exe
no module registered with image base 0x77320000
no module registered with image base 0x76e20000
no module registered with image base 0x77320000
no module registered with image base 0x77220000
Warning: unknown (to us) INT3 breakpoint at ntdll.dll!⤦
Ç LdrVerifyImageMatchesChecksum+0x96c (0x776c103b)
(0) software.exe!0x435c30(0x15e8000, 0x10, 0x14f808) (called from software.⤦
Ç exe!.text+0x22fa1 (0x13d3fa1))
Argument 1/3
015E8000: CD C5 7E AD 28 5F 6D E1−CE 8F CC 29 B1 21 88 8E "..~.(_m....)⤦
Ç .!.."
Argument 3/3
0014F808: 38 82 58 01 C8 B9 46 00−01 D1 3C 01 00 F8 14 00 "8.X...F⤦
Ç ... removed».
Немного поэкспериментировав с этой функцией, мы быстро понимаем, что проблема именно в ней. Она вызывается из функции chckpass() — одна из функций
проверяющих пароль.
В начале, давайте убедимся, что мы на верном пути:
Запускаем tracer:
tracer64.exe −a:disp+work.exe bpf=disp+work.exe!chckpass,args:3,unicode
PID=2236|TID=2248|(0) disp+work.exe!chckpass (0x202c770, L"Brewered1
⤦
Ç
", 0x41) (called from 0x1402f1060 (disp+work.⤦
Ç exe!usrexist+0x3c0))
PID=2236|TID=2248|(0) disp+work.exe!chckpass −> 0x35
Функции вызываются так: syssigni() -> DyISigni() -> dychkusr() -> usrexist() ->
chckpass().
Число 0x35 возвращается из chckpass() в этом месте:
.text:00000001402ED567 loc_1402ED567:
chckpass+B4
.text:00000001402ED567
.text:00000001402ED56A
.text:00000001402ED56F
.text:00000001402ED572
.text:00000001402ED578
.text:00000001402ED57B
.text:00000001402ED581
usr02_readonly
.text:00000001402ED583
.text:00000001402ED586
Ç password_attempt_limit_exceeded
.text:00000001402ED58B
.text:00000001402ED58D
.text:00000001402ED58F
.text:00000001402ED594
.text:00000001402ED598
Отлично, давайте проверим:
tracer64.exe −a:disp+work.exe bpf=disp+work.exe!⤦
Ç password_attempt_limit_exceeded,args:4,unicode,rt:0
PID=2744|TID=360|(0) disp+work.exe!password_attempt_limit_exceeded (0⤦
Ç x202c770, 0, 0x257758, 0) (called from 0x1402ed58b (disp+work.exe!⤦
Ç chckpass+0xeb))
PID=2744|TID=360|(0) disp+work.exe!password_attempt_limit_exceeded −> 1
PID=2744|TID=360|We modify return value (EAX/RAX) of this function to 0
PID=2744|TID=360|(0) disp+work.exe!password_attempt_limit_exceeded (0⤦
Ç x202c770, 0, 0, 0) (called from 0x1402e9794 (disp+work.exe!chngpass+0⤦
Ç xe4))
PID=2744|TID=360|(0) disp+work.exe!password_attempt_limit_exceeded −> 1
PID=2744|TID=360|We modify return value (EAX/RAX) of this function to 0
Великолепно! Теперь мы можем успешно залогиниться.
Кстати, мы можем сделать вид что вообще забыли пароль, заставляя chckpass()
всегда возвращать ноль, и этого достаточно для отключения проверки пароля:
tracer64.exe −a:disp+work.exe bpf=disp+work.exe!chckpass,args:3,unicode,rt⤦
Ç :0
PID=2744|TID=360|(0) disp+work.exe!chckpass (0x202c770, L"bogus
⤦
Ç
", 0x41) (called from 0x1402f1060 (disp+work.⤦
Ç exe!usrexist+0x3c0))
PID=2744|TID=360|(0) disp+work.exe!chckpass −> 0x35
PID=2744|TID=360|We modify return value (EAX/RAX) of this function to 0
Что еще можно сказать, бегло анализируя функцию
password_attempt_limit_exceeded(), это то, что в начале можно увидеть следующий вызов:
lea
call
test
jz
movzx
cmp
jz
cmp
jz
cmp
jnz
rcx, aLoginFailed_us ; "login/failed_user_auto_unlock"
sapgparam
rax, rax
short loc_1402E19DE
eax, word ptr [rax]
ax, 'N'
short loc_1402E19D4
ax, 'n'
short loc_1402E19D4
ax, '0'
short loc_1402E19DE
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1141
Очевидно, функция sapgparam() используется чтобы узнать значение какойлибо переменной конфигурации. Эта функция может вызываться из 1768 разных мест.
Вероятно, при помощи этой информации, мы можем легко находить те места
кода, на которые влияют определенные переменные конфигурации.
Замечательно! Имена функций очень понятны, куда понятнее чем в Oracle
RDBMS.
По всей видимости, процесс disp+work весь написан на Си++. Должно быть,
он был переписан не так давно?
8.11. Oracle RDBMS
8.11.1. Таблица V$VERSION в Oracle RDBMS
Oracle RDBMS 11.2 это очень большая программа, основной модуль oracle.exe
содержит около 124 тысячи функций. Для сравнения, ядро Windows 7 x64 (ntoskrnl.exe)
— около 11 тысяч функций, а ядро Linux 3.9.8 (с драйверами по умолчанию) —
31 тысяч функций.
Начнем с одного простого вопроса. Откуда Oracle RDBMS берет информацию,
когда мы в SQL*Plus пишем вот такой вот простой запрос:
SQL> select ∗ from V$VERSION;
И получаем:
BANNER
−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−
Oracle Database 11g Enterprise Edition Release 11.2.0.1.0 − Production
PL/SQL Release 11.2.0.1.0 − Production
CORE
11.2.0.1.0
Production
TNS for 32−bit Windows: Version 11.2.0.1.0 − Production
NLSRTL Version 11.2.0.1.0 − Production
Начнем. Где в самом Oracle RDBMS мы можем найти строку V$VERSION?
Для win32-версии, эта строка имеется в файле oracle.exe, это легко увидеть.
Но мы также можем использовать объектные (.o) файлы от версии Oracle RDBMS
для Linux, потому что в них сохраняются имена функций и глобальных переменных, а в oracle.exe для win32 этого нет.
Итак, строка V$VERSION имеется в файле kqf.o, в самой главной Oracle-библиотеке
libserver11.a.
Ссылка на эту текстовую строку имеется в таблице kqfviw, размещенной в
этом же файле kqf.o:
Листинг 8.10: kqf.o
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
Кстати, нередко, при изучении внутренностей Oracle RDBMS, появляется вопрос, почему имена функций и глобальных переменных такие странные. Вероятно, дело в том, что Oracle RDBMS очень старый продукт сам по себе и писался
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1143
на Си еще в 1980-х.
А в те времена стандарт Си гарантировал поддержку имен переменных длиной только до шести символов включительно: «6 significant initial characters in
an external identifier»36
Вероятно, таблица kqfviw содержащая в себе многие (а может даже и все)
view с префиксом V$, это служебные view (fixed views), присутствующие всегда. Бегло оценив цикличность данных, мы легко видим, что в каждом элементе таблицы kqfviw 12 32-битных полей. В IDA легко создать структуру из
12-и элементов и применить её ко всем элементам таблицы. Для версии Oracle
RDBMS 11.2, здесь 1023 элемента в таблице, то есть, здесь описываются 1023
всех возможных fixed view. Позже, мы еще вернемся к этому числу.
Как видно, мы не очень много можем узнать чисел в этих полях. Самое первое
поле всегда равно длине строки-названия view (без терминирующего ноля).
Это справедливо для всех элементов. Но эта информация не очень полезна.
Мы также знаем, что информацию обо всех fixed views можно получить из fixed
view под названием V$FIXED_VIEW_DEFINITION (кстати, информация для этого
view также берется из таблиц kqfviw и kqfvip). Между прочим, там тоже 1023
элемента. Совпадение? Нет.
SQL> select ∗ from V$FIXED_VIEW_DEFINITION where view_name='V$VERSION';
VIEW_NAME
−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−
VIEW_DEFINITION
−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−
V$VERSION
select BANNER from GV$VERSION where inst_id = USERENV('Instance')
Итак, V$VERSION это как бы thunk view для другого, с названием GV$VERSION,
который, в свою очередь:
SQL> select ∗ from V$FIXED_VIEW_DEFINITION where view_name='GV$VERSION';
VIEW_NAME
−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−
VIEW_DEFINITION
−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−
GV$VERSION
select inst_id, banner from x$version
Таблицы с префиксом X$ в Oracle RDBMS— это также служебные таблицы, они
не документированы, не могут изменятся пользователем, и обновляются динамически.
Попробуем поискать текст
36 Draft
ANSI C Standard (ANSI X3J11/88-090) (May 13, 1988) (yurichev.com)
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1144
select BANNER from GV$VERSION where inst_id =
USERENV('Instance')
Таблица, по всей видимости, имеет 4 поля в каждом элементе. Кстати, здесь
так же 1023 элемента, уже знакомое нам число.
Второе поле указывает на другую таблицу, содержащую поля этого fixed view.
Для V$VERSION, эта таблица только из двух элементов, первый это 6 и второй
это строка BANNER (число 6 это длина строки) и далее терминирующий элемент
содержащий 0 и нулевую Си-строку:
Листинг 8.12: kqf.o
.rodata:080BBAC4 kqfv133_c_0 dd 6
; DATA XREF: .rodata:08019574
.rodata:080BBAC8
dd offset _2__STRING_5017_0 ; "BANNER"
.rodata:080BBACC
dd 0
.rodata:080BBAD0
dd offset _2__STRING_0_0
Объединив данные из таблиц kqfviw и kqfvip, мы получим SQL-запросы, которые исполняются, когда пользователь хочет получить информацию из какоголибо fixed view.
Напишем программу oracle tables37 , которая собирает всю эту информацию из
объектных файлов от Oracle RDBMS под Linux.
37 yurichev.com
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1145
Для V$VERSION, мы можем найти следующее:
Листинг 8.13: Результат работы oracle tables
kqfviw_element.viewname: [V$VERSION] ?: 0x3 0x43 0x1 0xffffc085 0x4
kqfvip_element.statement: [select BANNER from GV$VERSION where inst_id = ⤦
Ç USERENV('Instance')]
kqfvip_element.params:
[BANNER]
И:
Листинг 8.14: Результат работы oracle tables
kqfviw_element.viewname: [GV$VERSION] ?: 0x3 0x26 0x2 0xffffc192 0x1
kqfvip_element.statement: [select inst_id, banner from x$version]
kqfvip_element.params:
[INST_ID] [BANNER]
Fixed view GV$VERSION отличается от V$VERSION тем, что содержит еще и поле
отражающее идентификатор instance.
Но так или иначе, мы теперь упираемся в таблицу X$VERSION. Как и прочие
X$-таблицы, она не документирована, однако, мы можем оттуда что-то прочитать:
SQL> select ∗ from x$version;
ADDR
INDX
INST_ID
−−−−−−−− −−−−−−−−−− −−−−−−−−−−
BANNER
−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−
0DBAF574
0
1
Oracle Database 11g Enterprise Edition Release 11.2.0.1.0 − Production
...
Эта таблица содержит дополнительные поля вроде ADDR и INDX.
Бегло листая содержимое файла kqf.o в IDA мы можем увидеть еще одну таблицу где есть ссылка на строку X$VERSION, это kqftab:
Листинг 8.15: kqf.o
.rodata:0803CAC0
.rodata:0803CAC4
.rodata:0803CAC8
.rodata:0803CACC
.rodata:0803CAD0
.rodata:0803CAD4
.rodata:0803CAD8
.rodata:0803CADC
.rodata:0803CAE0
.rodata:0803CAE4
dd
dd
dd
dd
dd
dd
dd
dd
dd
dd
9
; element number 0x1f6
offset _2__STRING_13113_0 ; "X$VERSION"
4
offset _2__STRING_13114_0 ; "kqvt"
4
4
0
4
0Ch
0FFFFC075h
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
Здесь очень много ссылок на названия X$-таблиц, вероятно, на все те что имеются в Oracle RDBMS этой версии.
Но мы снова упираемся в то что не имеем достаточно информации. Не ясно,
что означает строка kqvt.
Вообще, префикс kq может означать kernel и query.
v, может быть, version, а t — type?
Сказать трудно.
Таблицу с очень похожим названием мы можем найти в kqf.o:
Листинг 8.16: kqf.o
.rodata:0808C360 kqvt_c_0
Ç 0, 0, 4, 0, 0>
.rodata:0808C360
.rodata:08042680
.rodata:0808C360
.rodata:0808C384
Ç 0, 0, 0, 4, 0, 0> ;
"INDX"
.rodata:0808C3A8
Ç 0, 0, 0, 4, 0, 0> ;
"INST_ID"
.rodata:0808C3CC
Ç 0, 0, 0, 50h, 0, 0>
"BANNER"
.rodata:0808C3F0
Ç 0, 0, 0, 0>
kqftap_param select ∗ from V$TIMER;
HSECS
−−−−−−−−−−
27295167
Вуаля! Вот почему в win32-версии и версии Linux x86 разные результаты, потому что они получаются разными системными функциями ОС.
Drain по-английски дренаж, отток, водосток. Таким образом, возможно имеется ввиду подключение определенного столбца системной таблице к функции.
Добавим поддержку таблицы kqfd_tab_registry_0 в oracle tables42 , теперь мы
можем видеть, при помощи каких функций, столбцы в системных таблицах
подключаются к значениям, например:
[X$KSUTM] [kqfd_OPN_ksutm_c] [kqfd_tabl_fetch] [NULL] [NULL] [⤦
Ç kqfd_DRN_ksutm_c]
[X$KSUSGIF] [kqfd_OPN_ksusg_c] [kqfd_tabl_fetch] [NULL] [NULL] [⤦
Ç kqfd_DRN_ksusg_c]
OPN, возможно, open, а DRN, вероятно, означает drain.
8.12. Вручную написанный на ассемблере код
8.12.1. Тестовый файл EICAR
Этот .COM-файл предназначен для тестирования антивирусов, его можно запустить в MS-DOS и он выведет такую строку: «EICAR-STANDARD-ANTIVIRUS-TESTFILE!».
Он примечателен тем, что он полностью состоит только из печатных ASCIIсимволов, следовательно, его можно набрать в любом текстовом редакторе:
X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR−STANDARD−ANTIVIRUS−TEST−FILE!$H+H∗
41 MSDN
42 yurichev.com
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1159
Попробуем его разобрать:
; изначальное состояние: SP=0FFFEh, SS:[SP]=0
0100 58
pop
ax
; AX=0, SP=0
0101 35 4F 21
xor
ax, 214Fh
; AX = 214Fh and SP = 0
0104 50
push
ax
; AX = 214Fh, SP = FFFEh and SS:[FFFE] = 214Fh
0105 25 40 41
and
ax, 4140h
; AX = 140h, SP = FFFEh and SS:[FFFE] = 214Fh
0108 50
push
ax
; AX = 140h, SP = FFFCh, SS:[FFFC] = 140h and SS:[FFFE] = 214Fh
0109 5B
pop
bx
; AX = 140h, BX = 140h, SP = FFFEh and SS:[FFFE] = 214Fh
010A 34 5C
xor
al, 5Ch
; AX = 11Ch, BX = 140h, SP = FFFEh and SS:[FFFE] = 214Fh
010C 50
push
ax
010D 5A
pop
dx
; AX = 11Ch, BX = 140h, DX = 11Ch, SP = FFFEh and SS:[FFFE] = 214Fh
010E 58
pop
ax
; AX = 214Fh, BX = 140h, DX = 11Ch and SP = 0
010F 35 34 28
xor
ax, 2834h
; AX = 97Bh, BX = 140h, DX = 11Ch and SP = 0
0112 50
push
ax
0113 5E
pop
si
; AX = 97Bh, BX = 140h, DX = 11Ch, SI = 97Bh and SP = 0
0114 29 37
sub
[bx], si
0116 43
inc
bx
0117 43
inc
bx
0118 29 37
sub
[bx], si
011A 7D 24
jge
short near ptr word_10140
011C 45 49 43 ... db 'EICAR−STANDARD−ANTIVIRUS−TEST−FILE!$'
0140 48 2B
word_10140 dw 2B48h ; CD 21 (INT 21) будет здесь
0142 48 2A
dw 2A48h ; CD 20 (INT 20) будет здесь
0144 0D
db 0Dh
0145 0A
db 0Ah
Добавим везде комментарии, показывающие состояние регистров и стека после каждой инструкции.
Собственно, все эти инструкции нужны только для того чтобы исполнить следующий код:
B4
BA
CD
CD
09
1C 01
21
20
MOV
MOV
INT
INT
AH, 9
DX, 11Ch
21h
20h
INT 21h с функцией 9 (переданной в AH) просто выводит строку, адрес которой
передан в DS:DX. Кстати, строка должна быть завершена символом ’$’. Надо
полагать, это наследие CP/M и эта функция в DOS осталась для совместимости.
INT 20h возвращает управление в DOS.
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1160
Но, как видно, далеко не все опкоды этих инструкций печатные. Так что основная часть EICAR-файла это:
• подготовка нужных значений регистров (AH и DX);
• подготовка в памяти опкодов для INT 21 и INT 20;
• исполнение INT 21 и INT 20.
Кстати, подобная техника широко используется для создания шеллкодов, где
нужно создать x86-код, который будет нужно передать в виде текстовой строки.
Здесь также список всех x86-инструкций с печатаемыми опкодами: .1.6 (стр. 1321).
8.13. Демо
Демо (или демомейкинг) были великолепным упражнением в математике, программировании компьютерной графики и очень плотному программированию
на ассемблере вручную.
8.13.1. 10 PRINT CHR$(205.5+RND(1)); : GOTO 10
Все примеры здесь для .COM-файлов под MS-DOS.
В [Nick Montfort et al, 10 PRINT CHR$(205.5+RND(1)); : GOTO 10, (The MIT Press:2012)]
43
можно прочитать об одном из простейших генераторов случайных лабиринтов. Он просто бесконечно и случайно печатает символ слэша или обратный
слэш, выдавая в итоге что-то вроде:
43 Также
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1161
Здесь несколько известных реализаций для 16-битного x86.
Версия 42-х байт от Trixter
Листинг взят с его сайта44 , но комментарии — автора.
00000000: B001
mov
al,1
; установить видеорежим 40x25
00000002: CD10
int
010
00000004: 30FF
xor
bh,bh
; установить видеостраницу для
вызова int 10h
00000006: B9D007
mov
cx,007D0
; вывод 2000 символов
00000009: 31C0
xor
ax,ax
0000000B: 9C
pushf
; сохранить флаги
; узнать случайное число из чипа таймера
0000000C: FA
cli
; запретить прерывания
0000000D: E643
out
043,al
; записать 0 в порт 43h
; прочитать 16-битное значение из порта 40h
0000000F: E440
in
al,040
00000011: 88C4
mov
ah,al
00000013: E440
in
al,040
00000015: 9D
popf
; разрешить прерывания
возвращая значение флага IF
00000016: 86C4
xchg
ah,al
; здесь мы имеем псевдослучайное 16-битное значение
00000018: D1E8
shr
ax,1
0000001A: D1E8
shr
ax,1
; в CF сейчас находится второй бит из значения
0000001C: B05C
mov
al,05C ;'́
; если CF=1, пропускаем следующую инструкцию
0000001E: 7202
jc
000000022
44 http://trixter.oldskool.org/2012/12/17/maze-generation-in-thirteen-bytes/
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
в регистр AL другой символ
mov
al,02F ;'/'
mov
int
loop
int
ah,00E
010
000000009 ; цикл в 2000 раз
020
; возврат в DOS
Псевдослучайное число на самом деле это время, прошедшее со старта системы, получаемое из чипа таймера 8253, это значение увеличивается на единицу
18.2 раза в секунду.
Записывая ноль в порт 43h, мы имеем ввиду что команда это «выбрать счетчик
0», ”counter latch”, ”двоичный счетчик” (а не значение BCD).
Прерывания снова разрешаются при помощи инструкции POPF, которая также
возвращает флаг IF.
Инструкцию IN нельзя использовать с другими регистрами кроме AL, поэтому
здесь перетасовка.
Моя попытка укоротить версию Trixter: 27 байт
Мы можем сказать, что мы используем таймер не для того чтобы получить
точное время, но псевдослучайное число, так что мы можем не тратить время
(и код) на запрещение прерываний. Еще можно сказать, что так как мы берем
бит из младшей 8-битной части, то мы можем считывать только её.
Немного укоротим код и выходит 27 байт:
00000000:
00000003:
00000005:
00000007:
00000009:
0000000B:
0000000D:
0000000F:
00000011:
; вывести
00000013:
00000015:
00000017:
; выход в
00000019:
B9D007
mov
31C0
xor
E643
out
E440
in
D1E8
shr
D1E8
shr
B05C
mov
7202
jc
B02F
mov
символ на экран
B40E
mov
CD10
int
E2EA
loop
DOS
CD20
int
вывести только 2000 символов
команда чипу таймера
читать 8 бит из таймера
переместить второй бит в флаг CF
подготовить '\'
подготовить '/'
ah,00E
010
000000003
020
Использование случайного мусора в памяти как источника случайных
чисел
Так как это MS-DOS, защиты памяти здесь нет вовсе, так что мы можем читать
с какого угодно адреса. И даже более того: простая инструкция LODSB будет
читать байт по адресу DS:SI, но это не проблема если правильные значения
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1163
не установлены в регистры, пусть она читает 1) случайные байты; 2) из случайного места в памяти!
Так что на странице Trixter-а45 можно найти предложение использовать LODSB
без всякой инициализации.
Есть также предложение использовать инструкцию SCASB вместо, потому что
она выставляет флаги в соответствии с прочитанным значением.
Еще одна идея насчет минимизации кода — это использовать прерывание DOS
INT 29h которое просто печатает символ на экране из регистра AL.
Это то что сделал Peter Ferrie
46
:
Листинг 8.24: Peter Ferrie: 10 байт
; AL в этом месте имеет случайное значение
00000000: AE
scasb
; CF устанавливается по результату вычитания случайного байта памяти из AL.
; так что он здесь случаен, в каком-то смысле
00000001: D6
setalc
; AL выставляется в 0xFF если CF=1 или в 0 если наоборот
00000002: 242D
and
al,02D ;'-'
; AL здесь 0x2D либо 0
00000004: 042F
add
al,02F ;'/'
; AL здесь 0x5C либо 0x2F
00000006: CD29
int
029
; вывести AL на экране
00000008: EBF6
jmps
000000000 ; бесконечный цикл
Так что можно избавиться и от условных переходов. ASCII-код обратного слэша
(«\») это 0x5C и 0x2F для слэша («/»).
Так что нам нужно конвертировать один (псевдослучайный) бит из флага CF в
значение 0x5C или 0x2F.
Это делается легко: применяя операцию «И» ко всем битам в AL (где все 8 бит
либо выставлены, либо сброшены) с 0x2D мы имеем просто 0 или 0x2D.
Прибавляя значение 0x2F к этому значению, мы получаем 0x5C или 0x2F. И
просто выводим это на экран.
Вывод
Также стоит отметить, что результат может быть разным в эмуляторе DOSBox,
Windows NT и даже MS-DOS, из-за разных условий: чип таймера может эмулироваться по-разному, изначальные значения регистров также могут быть разными.
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1164
8.13.2. Множество Мандельброта
You know, if you magnify the coastline, it still
looks like a coastline, and a lot of other
things have this property. Nature has
recursive algorithms that it uses to generate
clouds and Swiss cheese and things like that.
Дональд Кнут, интервью (1993)
Множество Мандельброта это фрактал, характерное свойство которого это самоподобие.
При увеличении картинки, вы видите, что этот характерный узор повторяется
бесконечно.
Вот демо47 написанное автором по имени «Sir_Lagsalot» в 2009, рисующее множество Мандельброта, и это программа для x86 с размером файла всего 64
байта. Там только 30 16-битных x86-инструкций.
Вот что она рисует:
Попробуем разобраться, как она работает.
47 Можно
скачать здесь,
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1165
Теория
Немного о комплексных числах
Комплексное число состоит из двух чисел (вещественная (Re) и мнимая (Im).
Комплексная плоскость — это двухмерная плоскость, где любое комплексное
число может быть расположено: вещественная часть — это одна координата
и мнимая — вторая.
Некоторые базовые правила, которые нам понадобятся:
• Сложение: (a + bi) + (c + di) = (a + c) + (b + d)i
Другими словами:
Re(sum) = Re(a) + Re(b)
Im(sum) = Im(a) + Im(b)
• Умножение: (a + bi)(c + di) = (ac − bd) + (bc + ad)i
Другими словами:
Re(product) = Re(a) ⋅ Re(c) − Re(b) ⋅ Re(d)
Im(product) = Im(b) ⋅ Im(c) + Im(a) ⋅ Im(d)
• Возведение в квадрат: (a + bi)2 = (a + bi)(a + bi) = (a2 − b2 ) + (2ab)i
Другими словами:
Re(square) = Re(a)2 − Im(a)2
Im(square) = 2 ⋅ Re(a) ⋅ Im(a)
Как нарисовать множество Мандельброта
Множество Мандельброта — это набор точек, для которых рекурсивное соотношение zn+1 = zn 2 +c (где z и c это комплексные числа и c это начальное значение)
не стремится к бесконечности.
Простым русским языком:
• Перечисляем все точки на экране.
• Проверяем, является ли эта точка в множестве Мандельброта.
• Вот как проверить:
– Представим точку как комплексное число.
– Возведем в квадрат.
– Прибавим значение точки в самом начале.
– Вышло за пределы? Прерываемся, если да.
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1166
– Передвигаем точку в новое место, координаты которого только что
вычислили.
– Повторять всё это некое разумное количество итераций.
• Двигающаяся точка в итоге не вышла за пределы? Тогда рисуем точку.
• Двигающаяся точка в итоге вышла за пределы?
– (Для черно-белого изображения) ничего не рисуем.
– (Для цветного изображения) преобразуем количество итераций в какойнибудь цвет. Так что цвет будет показывать, с какой скоростью точка
вышла за пределы.
Вот алгоритмы для комплексных и обычных целочисленных чисел (на языке,
отдаленно напоминающем Python):
Листинг 8.25: Для комплексных чисел
def check_if_is_in_set(P):
P_start=P
iterations=0
while True:
if (P>bounds):
break
P=P^2+P_start
if iterations > max_iterations:
break
iterations++
return iterations
# черно-белое
for each point on screen P:
if check_if_is_in_set (P) < max_iterations:
нарисовать точку
# цветное
for each point on screen P:
iterations = if check_if_is_in_set (P)
преобразовать количество итераций в цвет
нарисовать цветную точку
Целочисленная версия, это версия где все операции над комплексными числами заменены на операции с целочисленными, в соответствии с изложенными
ранее правилами.
Листинг 8.26: Для целочисленных чисел
def check_if_is_in_set(X, Y):
X_start=X
Y_start=Y
iterations=0
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1167
while True:
if (X^2 + Y^2 > bounds):
break
new_X=X^2 − Y^2 + X_start
new_Y=2∗X∗Y + Y_start
if iterations > max_iterations:
break
iterations++
return iterations
# черно-белое
for X = min_X to max_X:
for Y = min_Y to max_Y:
if check_if_is_in_set (X,Y) < max_iterations:
нарисовать точку на X, Y
# цветное
for X = min_X to max_X:
for Y = min_Y to max_Y:
iterations = if check_if_is_in_set (X,Y)
преобразовать количество итераций в цвет
нарисовать цветную точку на X,Y
Вот также исходный текст на C#, который есть в статье в Wikipedia48 , но мы
немного изменим его, чтобы он выдавал количество итераций, вместо некоторого символа 49:
using
using
using
using
namespace Mnoj
{
class Program
{
static void Main(string[] args)
{
double realCoord, imagCoord;
double realTemp, imagTemp, realTemp2, arg;
int iterations;
for (imagCoord = 1.2; imagCoord >= −1.2; imagCoord −= 0.05)
{
for (realCoord = −0.6; realCoord
|
|
|
|
|
|
v
X=0, Y=199 X=319, Y=199
; переключиться в графический видеорежим VGA 320*200*256
mov al,13h
int 10h
; в самом начале BX равен 0
; в самом начале DI равен 0xFFFE
; DS:BX (или DS:0) указывает на Program Segment Prefix в этот момент
; ... первые 4 байта которого этого CD 20 FF 9F
les ax,[bx]
; ES:AX=9FFF:20CD
FillLoop:
; установить DX в 0. CWD работает так: DX:AX = sign_extend(AX).
; AX здесь 0x20CD (в начале) или меньше 320 (когда вернемся после цикла),
; так что DX всегда будет 0.
cwd
mov ax,di
; AX это текущий указатель внутри VGA-буфера
; разделить текущий указатель на 320
mov cx,320
div cx
; DX (start_X) - остаток (столбец: 0..319); AX - результат (строка: 0..199)
sub ax,100
; AX=AX-100, так что AX (start_Y) сейчас в пределах -100..99
; DX в пределах 0..319 или 0x0000..0x013F
dec dh
; DX сейчас в пределах 0xFF00..0x003F (-256..63)
xor bx,bx
xor si,si
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
; BX (temp_X)=0; SI (temp_Y)=0
; получить максимальное количество итераций
; CX всё еще 320 здесь, так что это будет максимальным количеством итераций
MandelLoop:
mov bp,si
; BP = temp_Y
imul si,bx
; SI = temp_X*temp_Y
add si,si
; SI = SI*2 = (temp_X*temp_Y)*2
imul bx,bx
; BX = BX^2 = temp_X^2
jo MandelBreak ; переполнение?
imul bp,bp
; BP = BP^2 = temp_Y^2
jo MandelBreak ; переполнение?
add bx,bp
; BX = BX+BP = temp_X^2 + temp_Y^2
jo MandelBreak ; переполнение?
sub bx,bp
; BX = BX-BP = temp_X^2 + temp_Y^2 - temp_Y^2 = temp_X^2
sub bx,bp
; BX = BX-BP = temp_X^2 - temp_Y^2
; скорректировать масштаб:
sar bx,6
; BX=BX/64
add bx,dx
; BX=BX+start_X
; здесь temp_X = temp_X^2 - temp_Y^2 + start_X
sar si,6
; SI=SI/64
add si,ax
; SI=SI+start_Y
; здесь temp_Y = (temp_X*temp_Y)*2 + start_Y
loop MandelLoop
MandelBreak:
; CX=итерации
xchg ax,cx
; AX=итерации. записать AL в VGA-буфер на ES:[DI]
stosb
; stosb также инкрементирует DI, так что DI теперь указывает на следующую
точку в VGA-буфере
; всегда переходим, так что это вечный цикл
jmp FillLoop
Алгоритм:
• Переключаемся в режим VGA 320*200 256 цветов. 320 ∗ 200 = 64000 (0xFA00).
Каждый пиксель кодируется одним байтом, так что размер буфера 0xFA00
байт.
Он адресуется здесь при помощи пары регистров ES:DI.
ES должен быть здесь 0xA000, потому что это сегментный адрес видеобуфера, но запись числа 0xA000 в ES потребует по крайней мере 4 байта
(PUSH 0A000h / POP ES). О 16-битной модели памяти в MS-DOS, читайте
больше тут: 10.6 (стр. 1274).
Учитывая, что BX здесь 0, и Program Segment Prefix находится по нулевому
адресу, 2-байтная инструкция LES AX,[BX] запишет 0x20CD в AX и 0x9FFF
в ES.
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1173
Так что программа начнет рисовать на 16 пикселей (или байт) перед видеобуфером.
Но это MS-DOS, здесь нет защиты памяти, так что запись происходит в
самый конец обычной памяти, а там, как правило, ничего важного нет.
Вот почему вы видите красную полосу шириной 16 пикселей справа. Вся
картинка сдвинута налево на 16 пикселей. Это цена экономии 2-х байт.
• Вечный цикл, обрабатывающий каждый пиксель. Наверное, самый общий
метод обойти все точки на экране это два цикла: один для X-координаты,
второй для Y-координаты.
Но тогда вам придется перемножать координаты для поиска байта в видеобуфере VGA. Автор этого демо решил сделать наоборот: перебирать
все байты в видеобуфере при помощи одного цикла вместо двух и затем
получать координаты текущей точки при помощи деления.
В итоге координаты такие: X в пределах −256..63 и Y в пределах −100..99.
Вы можете увидеть на скриншоте что картинка как бы сдвинута в правую
часть экрана. Это потому что самая большая черная дыра в форме сердца
обычно появляется на координатах 0,0 и они здесь сдвинуты вправо.
Мог ли автор просто отнять 160 от X, чтобы получилось значение в пределах −160..159? Да, но инструкция SUB DX, 160 занимает 4 байта, тогда как
DEC DH — 2 байта (которая отнимает 0x100 (256) от DX). Так что картинка
сдвинута ценой экономии еще 2-х байт.
– Проверить, является ли текущая точка внутри множества Мандельброта. Алгоритм такой же, как и описанный здесь.
– Цикл организуется инструкцией LOOP, которая использует регистр CX
как счетчик. Автор мог бы установить число итераций на какое-то
число, но не сделал этого: потому что 320 уже находится в CX (было установлено на строке 35), и это итак подходящее число как число
максимальных итераций.
Мы здесь экономим немного места, не загружая другое значение в
регистр CX.
– Здесь используется IMUL вместо MUL, потому что мы работаем со знаковыми значениями: помните, что координаты 0,0 должны быть гдето рядом с центром экрана.
Тоже самое и с SAR (арифметический сдвиг для знаковых значений):
она используется вместо SHR.
– Еще одна идея — это упростить проверку пределов. Нам бы пришлось
проверять пару координат, т.е. две переменных. Что делает автор
это трижды проверяет на переполнение: две операции возведения в
квадрат и одно прибавление. Действительно, мы ведь используем 16битные регистры, содержащие знаковые значения в пределах -32768..32767,
так что если любая из координат больше чем 32767 в процессе умножения, точка однозначно вышла за пределы, и мы переходим на метку MandelBreak.
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1174
– Здесь также имеется деление на 64 (при помощи инструкции SAR). 64
задает масштаб.
Попробуйте увеличить значение и вы получите более увеличенную
картинку, или уменьшить для меньшей.
• Мы находимся на метке MandelBreak, есть только две возможности попасть сюда: цикл закончился с CX=0 (точка внутри множества Мандельброта ); или потому что произошло переполнение (CX все еще содержит
какое-то значение). Записываем 8-битную часть CX (CL) в видеобуфер. Палитра по умолчанию грубая, тем не менее, 0 это черный: поэтому видим
черные дыры в местах где точки внутри множества Мандельброта.
Палитру можно инициализировать в начале программы, но не забывайте,
это всего лишь программа на 64 байта!
• Программа работает в вечном цикле, потому что дополнительная проверка, где остановится, или пользовательский интерфейс, это дополнительные инструкции.
Еще оптимизационные трюки:
• 1-байтная CWD используется здесь для обнуления DX вместо двухбайтной
XOR DX, DX или даже трехбайтной MOV DX, 0.
• 1-байтная XCHG AX, CX используется вместо двухбайтной MOV AX,CX. Текущее значение в AX все равно уже не нужно.
• DI (позиция в видеобуфере) не инициализирована, и будет 0xFFFE в начале 50 . Это нормально, потому что программа работает бесконечно для всех
DI в пределах 0..0xFFFF, и пользователь не может увидеть, что работала
началась за экраном (последний пиксель видеобуфера 320*200 имеет адрес 0xF9FF).
Так что некоторая часть работы на самом деле происходит за экраном.
А иначе понадобятся дополнительные инструкции для установки DI в 0;
добавить проверку на конец буфера.
Моя «исправленная» версия
Листинг 8.28: Моя «исправленная» версия
1
2
3
4
5
6
7
8
9
10
org 100h
mov al,13h
int 10h
; установить палитру
mov dx, 3c8h
mov al, 0
out dx, al
mov cx, 100h
inc dx
50 Больше
о состояниях регистров на старте: https://code.google.com/p/corkami/wiki/
InitialValues#DOS
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
l00:
mov al, cl
shl ax, 2
out dx, al ; красный
out dx, al ; зеленый
out dx, al ; синий
loop l00
push 0a000h
pop es
xor di, di
FillLoop:
cwd
mov ax,di
mov cx,320
div cx
sub ax,100
sub dx,160
xor bx,bx
xor si,si
MandelLoop:
mov bp,si
imul si,bx
add si,si
imul bx,bx
jo MandelBreak
imul bp,bp
jo MandelBreak
add bx,bp
jo MandelBreak
sub bx,bp
sub bx,bp
sar
add
sar
add
bx,6
bx,dx
si,6
si,ax
loop MandelLoop
MandelBreak:
xchg ax,cx
stosb
cmp di, 0FA00h
jb FillLoop
; дождаться нажатия любой клавиши
xor ax,ax
int 16h
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1176
64
65
66
67
68
; установить текстовый видеорежим
mov ax, 3
int 10h
; выход
int 20h
Автор сих строк попытался исправить все эти странности: теперь палитра плавная черно-белая, видеобуфер на правильном месте (строки 19..20), картинка рисуется в центре экрана (строка 30), программа в итоге заканчивается
и ждет, пока пользователь нажмет какую-нибудь клавишу (строки 58..68).
Но теперь она намного больше: 105 байт (или 54 инструкции)
51
.
Рис. 8.20: Моя «исправленная» версия
Смотрите также: маленькая программа на Си печатающая множество Мандельброта в ASCII: https://people.sc.fsu.edu/~jburkardt/c_src/mandelbrot_
ascii/mandelbrot_ascii.html
https://miyuki.github.io/2017/10/04/gcc-archaeology-1.html.
51 Можете
поэкспериментировать и сами: скачайте DosBox и NASM и компилируйте так:
nasm file.asm -fbin -o file.com
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1177
8.14. Как я переписывал 100 килобайт x86-кода
на чистый Си
То была DLL-ка с секцией кода 100 килобайт, она брала на вход многоканальный сигнал и выдавала другой многоканальный сигнал. Там много всего было
связано с обработкой сигналов. Внутри было очень много FPU-кода. Написано
по-олдскульному, так, как писали в то время, когда передача параметров через аргументы ф-ций была дорогой, и потому использовалось много глобальных переменных и массивов, почти всё хранилось в них, а ф-ции, напротив,
имели сравнительно мало аргументов, если вообще. Функции большие, их было около ста.
Тесты были, много.
Проблема была в том, что функции слишком большие и Hex-Rays неизменно
выдавал немного неверный код. Нужно было очень внимательно всё чистить
вручную. В процессе работы, я нашел в нем каких-то ошибок: 10.8.
Все 100 ф-ций декомпилировать сразу нельзя — где-то будут ошибки, тесты
не пройдут, и где вы будете искать эти ошибки? Приходится переписывать по
чуть-чуть.
В DLL-ке есть некая корневая ф-ция, скажем, ProcessMain(). Я переписываю её
на Си при помощи Hex-Rays, она запускается из обычного .exe-процесса. Все фции из DLL-ки, которые вызываются далее, у меня вызывались через указатели
на ф-ции. DLL-ка загружена, и пока они все там.
ASLR отключил, и DLL-ка каждый раз грузится по одному и тому же адресу,
потому и адреса всех ф-ций одни и те же. Важно, что и адреса глобальных
массивов тоже одни и те же
Затем переписываю ф-ции, вызывающиеся непосредственно из ProcessMain(),
затем еще ниже, итд. Таким образом, ф-ции я постепенно перетаскивал из DLL
в свою .exe. Каждый раз тестируя.
Много раз бывало и так — ф-ция слишком большая, например, несколько килобайт x86-кода, и после декомпиляции в Си, там что-то косячит, и неизвестно
где. Из IDA я экспортировал её листинг в текст на ассемблере и компилировал
при помощи обычного ассемблера (ML в MSVC). Она компилируется в .obj-файл
и прикомпилируется к главной .exe, и пока всё ОК. Затем я делил эту ф-цию
на более мелкие, здорово пригодился (когда бы еще?) опыт написания программ на чистом ассемблере в середине 90-х (руки до сих пор помнят). Если
всё работает, более мелкие ф-ции постепенно переписывал на Си при помощи
Hex-Rays, в то время как ”головная” ф-ция более высокого уровня всё еще на
ассемблере.
Интересно, что было много глобальных массивов, но границы между ними были сильно размыты. Но я вижу что есть какой-то большой кусок в секции .data,
где лежит всё подряд. Дошел до стадии, когда на Си переписано уже всё, а
все обращения к массивам происходят по адресам внутри секции .data в подгружаемой DLL-ке, впрочем, там почти не было констант. Затем, чтобы совсем
отказаться от DLL-ки, я сделал большой глобальный ”кусок” уже у себя на Си,
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1178
и вся работа с массивами шла через мой ”кусок”, при том, что все массивы всё
еще не были отделены друг от друга.
Вот реальный фрагмент оттуда, как было в начале. Значение — это адрес в
.data-секции в DLL-ке:
int
int
int
int
...
DLL-ку теперь можно было наконец-то отцепить и разбираться с границами
массивов. Этот процесс я хотел немного автоматизировать и использовал для
этого Pin. Я написал утилиту, которая показывала, по каким адресам в глобальном ”куске” были обращения из каждого адреса. Точнее, в каких пределах?
Так стало проще видеть границы массивов.
”На войне все средства хороши”, так что я доходил и до того, что использовал
Mathematica и Z3 для сокращения слишком длинных выражений (Hex-Rays не
всё может оптимизировать):
https://github.com/DennisYurichev/SAT_SMT_by_example/blob/master/proofs/
simplify_EN.tex.
Очень хорошим тестом было пересобрать всё под Linux при помощи GCC и заставить работать — как всегда, это было нелегко. Плюс, чтобы работало корректно и под x86 и под x64.
8.15. ”Прикуп” в игре ”Марьяж”
Знал бы прикуп — жил бы в Сочи.
Поговорка.
”Марьяж” — старая и довольно популярная версия игры в ”Преферанс” под
DOS.
Играют три игрока, каждому раздается по 10 карт, остальные 2 остаются в т.н.
”прикупе”. Начинаются торги, во время которых ”прикуп” скрыт. Он открывается после того, как один из игроков сделает ”заказ”.
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1179
Знание карт в ”прикупе” обычно имеет решающее преимущество.
Вот так в игре выглядит состояние ”торгов”, и ”прикуп” посредине, скрытый:
Рис. 8.21: ”Торги”
Попробуем ”подсмотреть” карты в ”прикупе” в этой игре.
Для начала — что мы знаем? Игра под DOS, датируется 1997-м годом. IDA показывает имена стандартных функций вроде @GetImage$q7Integert1t1t1m3Any
— это ”манглинг” типичный для Borland Pascal, что позволяет сделать вывод,
что сама игра написана на Паскале и скомпилирована Borland Pascal-ем.
Файлов около 10-и и некоторые имеют текстовую строку в заголовке ”Marriage
Image Library” — должно быть, это библиотеки спрайтов.
В IDA можно увидеть что используется функция @PutImage$q7Integert1m3Any4Word,
которая, собственно, рисует некий спрайт на экране. Она вызывается по крайней мере из 8-и мест. Чтобы узнать что происходит в каждом из этих 8-и мест,
мы можем блокировать работу каждой функции и смотреть, что будет происходить. Например, первая ф-ция имеет адрес seg002:062E, и она заканчивается
инструкцией retf 0Eh на seg002:102A. Это означает, что метод вызовов ф-ций
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1180
в Borland Pascal под DOS схож с stdcall — вызываемая ф-ция должна сама возвращать стек в состояние до того как началась передача аргументов. В самом
начале этой ф-ции вписываем инструкцию ”retf 0eh”, либо 3 байта: CA 0E 00.
Запускаем ”Марьяж” и внешне вроде бы ничего не изменилось.
Переходим ко второй ф-ции, которая активно использует @PutImage$q7Integert1m3Any4Word.
Она находится по адресу seg008:0AB5 и заканчивается инструкцией retf 0Ah.
Вписываем эту инструкцию в самом начале и запускаем:
Рис. 8.22: Карт нет
Карт не видно вообще. И видимо, эта функция их отображает, мы её заблокировали, и теперь карт не видно. Назовем эту ф-цию в IDA draw_card(). Помимо @PutImage$q7Integert1m3Any4Word, в этой ф-ции вызываются также ф-ции
@SetColor$q4Word, @SetFillStyle$q4Wordt1,
@Bar$q7Integert1t1t1, @OutTextXY$q7Integert16String.
Сама ф-ция draw_cards() (её название мы дали ей сами только что) вызывается из 4-х мест. Попробуем точно также ”блокировать” каждую ф-цию.
Когда я ”блокирую” вторую, по адресу seg008:0DF3 и запускаю программу, вижу такое:
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1181
Рис. 8.23: Все карты кроме карт игрока
Видны все карты, кроме карт игрока. Видимо, эта функция рисует карты игрока.
Я переименовываю её в IDA в draw_players_cards().
Четвертая ф-ция, вызывающая draw_cards(), находится по адресу seg008:16B3,
и когда я её ”блокирую”, я вижу в игре такое:
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1182
Рис. 8.24: ”Прикупа” нет
Все карты есть, кроме ”прикупа”. Более того, эта ф-ция вызывает только draw_cards(),
и только 2 раза. Видимо эта ф-ция и отображает карты ”прикупа”. Будем рассматривать её внимательнее.
seg008:16B3
seg008:16B3
seg008:16B3
seg008:16B3
seg008:16B3
seg008:16B3
seg008:16B3
seg008:16B3
seg008:16B7
seg008:16BA
seg008:16BC
seg008:16BF
seg008:16C2
seg008:16C5
seg008:16C7
seg008:16CA
draw_prikup
proc far
var_E
var_C
arg_0
= word ptr −0Eh
= word ptr −0Ch
= byte ptr 6
enter
mov
xor
imul
mov
mov
xor
imul
mov
Интересно посмотреть, как именно вызывается draw_prikup(). У нее только
один аргумент.
Иногда она вызывается с аргументом 1:
...
seg010:084C
seg010:084E
...
push
call
1
draw_prikup
А иногда с аргументом 0, причем вот в таком контексте, где уже есть другая
знакомая функция:
seg010:0067
seg010:0069
seg010:006C
seg010:006D
push
mov
push
call
1
al, byte_31F41
ax
sub_12FDC
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
Так что единственный аргумент у draw_prikup() может быть или 0 или 1, т.е.,
это, возможно, булевый тип. На что он влияет внутри самой ф-ции? При ближайшем рассмотрении видно, что входящий 0 или 1 передается в draw_card(),
т.е., у последней тоже есть булевый аргумент. Помимо всего прочего, если передается 1, то по адресам seg008:16DA и seg008:172E копируются несколько
байт из одной группы глобальных переменных в другую.
Эксперимент: здесь 4 раза сравнивается единственный аргумент с 0 и далее
следует JNZ. Что если сравнение будет происходить с 1, и, таким образом, работа функции draw_prikup() будет обратной? Патчим и запускаем:
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1186
Рис. 8.25: ”Прикуп” открыт
”Прикуп” открыт, но когда я делаю ”заказ”, и, по логике вещей, ”прикуп” теперь должен стать открытым, он наоборот становится закрытым:
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1187
Рис. 8.26: ”Прикуп” закрыт
Всё ясно: если аргумент draw_prikup() нулевой, то карты рисуются рубашкой
вверх, если 1, то открытые. Этот же аргумент передается в draw_card() — эта
ф-ция может рисовать и открытые и закрытые карты.
Пропатчить ”Марьяж” теперь легко, достаточно исправить все условные переходы так, как будто бы в ф-цию всегда приходит 1 в аргументе и тогда ”прикуп” всегда будет открыт.
Но что за байты копируются в seg008:16DA и seg008:172E? Я попробовал забить инструкции копирования MOV NOP-ами — ”прикуп” вообще перестал отображаться.
Тогда я сделал так, чтобы всегда записывалась 1:
...
00004B5A:
00004B5C:
00004B5D:
00004B60:
00004B62:
00004B63:
B001
90
A26D08
B001
90
A26C08
mov
nop
mov
mov
nop
mov
al,1
[0086D],al
al,1
[0086C],al
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1188
...
Тогда ”прикуп” отображается как два пиковых туза. А если первый байт — 2, а
второй — 1, получается трефовый туз. Видимо так и кодируется масть карты,
а затем и сама карта. А draw_card() затем считывает эту информацию из пары
глобальных переменных. А копируется она тоже из глобальных переменных,
где собственно и находится состояние карт у игроков и в прикупе после случайной тасовки. Но нельзя забывать, что если мы сделаем так, что в ”прикупе”
всегда будет 2 пиковых туза, это будет только на экране так отображаться, а
в памяти состояние карт останется таким же, как и после тасовки.
Всё понятно: автор решил сделать одну ф-цию для отрисовки и закрытого и
открытого прикупа, поэтому нам, можно сказать, повезло. Могло быть труднее:
в самом начале рисовались бы просто две рубашки карт, а открытый прикуп
только потом.
Я также пробовал сделать шутку-пранк: во время торгов одна карта ”прикупа” открыта, а вторая закрыта, а после ”заказа”, наоборот, первая закрыта, а
вторая открывается. В качестве упражнения, вы можете попробовать сделать
так.
Еще кое-что: чтобы сделать прикуп открытым, ведь можно же найти место где
вызывается draw_prikup() и поменять 0 на 1. Можно, только это место не в
головой marriage.exe, а в marriage.000, а это DOS-овский оверлей (начинается
с сигнатуры ”FBOV”).
В качестве упражнения, можно попробовать подсматривать состояние всех
карт, и у обоих игроков. Для этого нужно отладчиком смотреть состояние глобальной памяти рядом с тем, откуда считываются обе карты прикупа.
Файлы:
оригинальная версия: http://beginners.re/examples/marriage/original.zip,
пропатченная мною версия: http://beginners.re/examples/marriage/patched.
zip (все 4 условных перехода после cmp [bp+arg_0], 0 заменены на JMP).
8.15.1. Упражнение
Бытовали слухи, что сама программа жульничает, “подглядывая” в карты соперниковлюдей. Это возможно, если алгоритмы, определяющие лучший ход, будут использовать информацию из карт соперника. Тогда будет видно, что происходят обращения к этим глобальным переменным из этих мест. Либо же этого
не будет видно, если эти обращения происходят только из ф-ции генерации
случайных карт и ф-ций их отрисовки.
8.16. Другие примеры
Здесь также был пример с Z3 и ручной декомпиляцией. Он перемещен сюда:
https://sat-smt.codes.
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
Глава 9
Примеры разбора закрытых
(проприетарных) форматов
файлов
9.1. Примитивное XOR-шифрование
В русскоязычной литературе также используется термин гаммирование.
9.1.1. Простейшее XOR-шифрование
Однажды я видел ПО, где все отладочные сообщения были зашифрованы используя XOR со значением 3. Иными словами, 2 младших бита каждого символа
были переключены.
“Hello, world” становилось “Kfool/#tlqog”:
Листинг 9.1: Python
#!/usr/bin/python
msg="Hello, world!"
print "".join(map(lambda x: chr(ord(x)^3), msg))
Это интересное шифрование (или даже обфускация), потому что оно имеет
два важных свойства: 1) одна ф-ция для шифрования/дешифрования, просто
вызовите её еще раз; 2) символы на выходе печатаемые, так что вся строка
может быть использована в исходном коде без специальных (escaping) символов.
Второе свойство использует тот факт что все печатаемые символы расположены в рядах: 0x2x-0x7x, и когда вы меняете два младших бита, символ переме-
1189
1190
щается на 1 или 3 символа влево или вправо, но никогда не перемещается в
другой (может быть, непечатаемый) ряд:
Рис. 9.1: 7-битная ASCII-таблица в Emacs
…с единственным исключением символа 0x7F.
Например, давайте зашифруем символы в пределах A-Z:
#!/usr/bin/python
msg="@ABCDEFGHIJKLMNO"
print "".join(map(lambda x: chr(ord(x)^3), msg))
Результат: CBA@GFEDKJIHONML.
Это как если символы “@” и “C” были поменены местами, и так же и “B” и “a”.
Так или иначе, это интересный пример использующий свойства XOR, нежели
шифрование: тот самый эффект сохранения печатаемости может быть достигнут переключая любой из младших 4-х бит, в любой последовательности.
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1191
9.1.2. Norton Guide: простейшее однобайтное XOR-шифрование
Norton Guide был популярен во времена MS-DOS, это была резидентная программа, работающая как гипертекстовый справочник.
Базы данных Norton Guide это файлы с расширением .ng, содержимое которых
выглядит как зашифрованное:
Рис. 9.2: Очень типичный вид
Почему мы думаем, что зашифрованное а не сжатое?
Мы видим, как слишком часто попадается байт 0x1A (который выглядит как
«→»), в сжатом файле такого не было бы никогда.
Во-вторых, мы видим длинные части состоящие только из латинских букв, они
выглядят как строки на незнакомом языке.
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1192
Из-за того, что байт 0x1A слишком часто встречается, мы можем попробовать
расшифровать файл, полагая что он зашифрован простейшим XOR-шифрованием.
Применяем XOR с константой 0x1A к каждому байту в Hiew и мы можем видеть
знакомые текстовые строки на английском:
Рис. 9.3: Hiew применение XOR с 0x1A
XOR-шифрование с одним константным байтом это самый простой способ шифрования, который, тем не менее, иногда встречается.
Теперь понятно почему байт 0x1A так часто встречался: потому что в файле
очень много нулевых байт и в зашифрованном виде они везде были заменены
на 0x1A.
Но эта константа могла быть другой.
В таком случае, можно было бы попробовать перебрать все 256 комбинаций, и
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1193
посмотреть содержимое «на глаз», а 256 — это совсем немного.
Больше о формате файлов Norton Guide: http://www.davep.org/norton-guides/
file-format/.
Энтропия
Очень важное свойство подобного примитивного шифрования в том, что информационная энтропия зашифрованного/дешифрованного блока точно такая
же. Вот мой анализ в Wolfram Mathematica 10.
Листинг 9.2: Wolfram Mathematica 10
In[1]:= input = BinaryReadList["X86.NG"];
In[2]:= Entropy[2, input] // N
Out[2]= 5.62724
In[3]:= decrypted = Map[BitXor[#, 16^^1A] &, input];
In[4]:= Export["X86_decrypted.NG", decrypted, "Binary"];
In[5]:= Entropy[2, decrypted] // N
Out[5]= 5.62724
In[6]:= Entropy[2, ExampleData[{"Text", "ShakespearesSonnets"}]] // N
Out[6]= 4.42366
Что мы здесь делаем это загружаем файл, вычисляем его энтропию, дешифруем его, сохраняем, снова вычисляем энтропию (точно такая же!).
Mathematica дает возможность анализировать некоторые хорошо известные
англоязычные тексты.
Так что мы вычисляем энтропию сонетов Шейкспира, и она близка к энтропии
анализируемого нами файла.
Анализируемый нами файл состоит из предложений на английском языке, которые близки к языку Шейкспира.
И применение побайтового XOR к тексту на английском языке не меняет энтропию.
Хотя, это не будет справедливо когда файл зашифрован при помощи XOR шаблоном длиннее одного байта.
Файл, который мы анализировали, можно скачать здесь: http://beginners.
re/examples/norton_guide/X86.NG.
Еще кое-что о базе энтропии
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1194
Wolfram Mathematica вычисляет энтропию с базой e (основание натурального
логарифма), а утилита UNIX ent 1 использует базу 2.
Так что мы явно указываем базу 2 в команде Entropy, чтобы Mathematica давала те же результаты, что и утилита ent.
1 http://www.fourmilab.ch/random/
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1195
9.1.3. Простейшее четырехбайтное XOR-шифрование
Если при XOR-шифровании применялся шаблон длиннее байта, например, 4байтный, то его также легко увидеть.
Например, вот начало файла kernel32.dll (32-битная версия из Windows Server
2008):
Рис. 9.4: Оригинальный файл
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1196
Вот он же, но «зашифрованный» 4-байтным ключом:
Рис. 9.5: «Зашифрованный» файл
Очень легко увидеть повторяющиеся 4 символа.
Ведь в заголовке PE-файла много длинных нулевых областей, из-за которых
ключ становится видным.
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1197
Вот начало PE-заголовка в 16-ричном виде:
Рис. 9.6: PE-заголовок
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1198
И вот он же, «зашифрованный»:
Рис. 9.7: «Зашифрованный» PE-заголовок
Легко увидеть визуально, что ключ это следующие 4 байта: 8C 61 D2 63. Используя эту информацию, довольно легко расшифровать весь файл.
Таким образом, важно помнить эти свойства PE-файлов: 1) в PE-заголовке много нулевых областей; 2) все PE-секции дополняются нулями до границы страницы (4096 байт), так что после всех секций обычно имеются длинные нулевые
области.
Некоторые другие форматы файлов могут также иметь длинные нулевые области.
Это очень типично для файлов, используемых научным и инженерным ПО.
Для тех, кто самостоятельно хочет изучить эти файлы, то их можно скачать
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
9.1.4. Простое шифрование используя XOR-маску
Я нашел одну старую игру в стиле interactive fiction в архиве if-archive2 :
The New Castle v3.5 − Text/Adventure Game
in the style of the original Infocom (tm)
type games, Zork, Collosal Cave (Adventure),
etc. Can you solve the mystery of the
abandoned castle?
Shareware from Software Customization.
Software Customization [ASP] Version 3.5 Feb. 2000
Можно скачать здесь: https://beginners.re/current-tree/ff/XOR/mask_1/files/
newcastle.tgz.
Там внутри есть файл (с названием castle.dbf ), который явно зашифрован, но
не настоящим криптоалгоритмом, и он не сжат, это что-то куда проще. Я бы
даже не стал измерять уровень энтропии (9.2 (стр. 1215)) этого файла, потому
что я итак уверен, что он низкий. Вот как он выглядит в Midnight Commander:
Рис. 9.8: Зашифрованный файл в Midnight Commander
2 http://www.ifarchive.org/
Если вы заметили опечатку, ошибку или имеете какие-то либо соображения,
пожелания, пожалуйста, напишите мне: . Спасибо!
1200
Зашифрованный файл можно скачать здесь: https://beginners.re/current-tree/
ff/XOR/mask_1/files/castle.dbf.bz2.
Можно ли расшифровать его без доступа к программе, используя просто этот
файл?
Тут явно просматривается повторяющаяся строка. Если использовалось простое шифрование с XOR-маской, такие повторяющиеся строки это явное свидетельство, потому что, вероятно, тут были длинные лакуны с нулевыми байтами, которые, в свою очередь, присутствуют во мноигих исполняемых файлах,
и в остальных бинарных файлах.
Вот дамп начала этого файла используя утилиту xxd из UNIX:
...
0000030:
0000040:
0000050:
0000060:
0000070:
Последние комментарии
5 часов 46 минут назад
6 часов 6 минут назад
6 часов 32 минут назад
6 часов 36 минут назад
16 часов 6 минут назад
16 часов 10 минут назад