(utf-8) Немного о reverse engineering. Я разделил этот текст условно на три части: о процессорах, об операционных системах и о компиляторах. 1. Процессоры. Собственно, процессор исполняет (машинный) код. Можно думать о процессоре как он работает, но можно также и думать, какие сервисы он предоставляет разработчику. У процессора есть пачка регистров, пачка инструкций, итд. Инструкции делятся примерно на две категории: те, которые можно использовать в user mode и некоторые системные, о которых нужно знать только если пишешь ядро ОС. В современном x86, с некоторым натягом, внутри не один проц а три - обычный, FPU и SIMD. Для всех этих трех подпроцессоров есть свои наборы команд и свои наборы регистров. Еще одна важная часть процессора - MMU, девайс который отвечает за связь с памятью. Он также отвечает за защиту. Когда один процесс попытается записать что-то в чужую область, сработает защита и процесс свалится. Защита также работает в процессе свопинга. Если процесс обращается к куску памяти, который ОС свалила в своп, срабатывает защита, запускается обработчик исключений в ОС, и он достает этот кусок памяти из файла-своппинга. Этим девайсом в свое время отличались более взрослые процессоры (начиная с 80286) от менее взрослых (вроде 8086). Также благодаря этому обеспечивается маппинг файлов в память. Вы когда-нибудь запускали очень большой инсталлятор под виндой? Скажем, от 100 метров. Он запускается очень быстро. Потому что винда на самом деле не загружает его в память полностью. И часть его тоже не загружает (винда просто не знает, что нужно загружать, а что нет). Винда мапит исполняемый файл в память. Затем загрузчик обращается к заголовку PE-файла. Но его в памяти нет, срабатывает исключение и этот кусок подтягивается в память из файла. И так подтягивается постепенно только то, к чему обращается проц в процессе исполнения проги. x86-процы традиционно считаются CISC (Complex instruction set computer). Разница между CISC и RISC следующая. Когда-то компьютеры были большими и дорогими и весь софт для них писался на ассемблере. Ассемблер фактически это и есть машинный код. Конечно, те девелоперы хотели разнообразия и ассемблер тех процов немного эволюциониовал и усложнялся. Потом как-то так получилось, что начали развиваться языки высокого уровня типа Кобола или Фортрана. Ассемблер процов остановился в развитии. А позже получилось так, что лучше этот самый асм еще и упростить. RISC - это процессорный минимализм, идеология предлагающая самый минимум инструкций. Например, инструкция x86 PUSH - всунуть значение в стек на самом деле состоит из двух: MOV (положить собственно значение в стек) и SUB ESP, 4 (сместить текущий указатель стека). Помимо минимализма RISC имеет один очень мощный бонус, а именно: современные процы умеют затаскивать пачку команд в очередь и исполнять их одновременно (Out-of-order execution): и в случае с RISC это намного удобнее: отслеживать, какие инструкции не влияют на какие и как их можно исполнять одновременно. Существуют куда более изяшные процессоры, например Itanium. Одна из интересных его фич заключается в том что все подаваемые ему инструкции состоят из пачек из трех команд и проц исполняет каждую пачку одновременно. То есть, сам компилятор заботится о том, чтобы инструкции внутри пачки не мешали друг другу, а проц не глядя исполняет их одновременно. Это называется EPIC (Explicitly parallel instruction computing) - т.е., "явный параллелизм". Но к сожалению, Итаниум ничем не эффективнее по скорости или цене чем x86, поэтому не может конкурировать. А жаль. Насчет эволюции x86. Первым процом в этой линии был 8086 - это была 16-битная версия 8080, или "расширенная". От 8080 x86 унаследовал названия регистров. 8086 был в общем-то обычным 16-битным процом без MMU. Один из уродливых костылей который там был - это адресация памяти. 16-битные регистры не могут никак адресовать более 64 килобайт. Поэтому там все указатели состоял и из двух регистров, они еще складывались по-уродски. Я думаю, что на каждом выпускаемом ныне компе еще есть 16-битный код работающий в "реальном" режиме, то есть, режиме проца без защиты памяти. Это то что защито в BIOS и загрузчик ОС - будь то NTLDR или GRUB или еще что. 80386 - это расширение до 32-битного проца. Проц снова унаследовал имена регистров. Добавились кое-какие команды. В принципе, с этого момента, набор инструкций и основные фичи остановились в развитии. В 80486 есть кеши и всякие такие приятные штуки, но ничего нового программеру он не предлагает. То есть, с некоторым натягом, ассемблер для 32-битных интеловских процов можно учить по учебнику для 80386. FPU - это всегда стоявший особняком сопроцессор. Он выглядит как программируемый калькулятор в некотором смысле. У него есть пачка регистров для хранения чисел в виде знак-мантисса-экспонента. Ну и пачка инструкций для работы со всей это байдой. А вот что примечательно, так это то что в свое время FPU вообще был отдельным чипом, вплоть до 80386 включительно. Это потому что он был дорогой, и его покупали не все. А если не покупали, то для него оставалось пустое гнездо на матери. Для нас это означает следующее. Когда исполнялась команда сопроцессора, основной проц по шине обращался к сопроцу и передавал ему команду. Когда исполнялась инструкция записи из регистра сопроца в память, то снова. Довольно уродливо. В 80486 процы объеденились "под одной крышей", то есть, это стал один чип. А раннее, если исполнялась инструкция FPU, но сопроца физически не было, срабатывало исключение и вызывался обработчкик команд сопроца, это называлось софтверный эмулятор FPU. Худо-бедно, но он эмулировал команды FPU используя обычный проц. Это было медленнее, но это считалось лучше чем ничего. Следующая веха в развитии x86 это введение SIMD. В начале это называлось Pentium MMX. Это я вообще называю третьим процессором наряду с основным и FPU. SIMD дословно означает single instruction multiple data. То есть, это идеология обработки пачки данных за один присест. Простейший пример: загружаем в фотошоп картинку и немного меняем яркость. Что делает фотошоп это берет буфер где лежит картинка и от каждого пикселя отнимает еденицу или прибавляет. В этом смысле SIMD очень выгоден так как позволяет отнимать еденицу не от каждого байта в памяти, а когда возьмет сразу пачку байт. SIMD-регистры в современных x86 имеют 128-битную разрядность. То есть, каждый регистр способен вместить в себя 16 байт. Если мы упростим немного наш фотошоп и примем за факт что у нас монохромное изображение, и каждый пиксель определяется одним 8-битным байтом, то при помощи SIMD, мы можем вгрузить в регистр сразу 16 пикселей, отнять еденицу от каждого пикселя и вгрузить эти 16 байт назад в память. SIMD еще очень активно используется в графических процах и вообще получил там дальнейшее развитие, но это уже другая история. Следущая веха - это поддержка 64-битных инструкций, регистров итд. Ну здесь ничего суперинтересного не произошло, ширину регистров просто удвоили. Основная причина была в том что память подешевела и купить себе более 4-х гиг не представляется проблемой. Но для того чтобы адресовать более 4-х гиг, нужны указатели шире 32-битных. Также, по стандарту Си, тип int всегда отражает слово процессора, таким образом, компилируя код на Си, int теперь будет 64-битным. И в памяти он тоже будет занимать в два раза больше места. Что не очень приятно. Ведь мало кому нужны большие числа. А если кому и нужны, то он использует FPU, где они представляются в виде знака-мантиссы-экспоненты. Вы можете вспомнить, когда вы использовали числа более 4-х миллиардов в типе int? То-то же. Еще указатели в памяти автоматически начинают занимать в два раза больше места. Таким образом, это, можно сказать, цена апгрейда. 2. Операционная система. Операционная система, вещь сама по себе очень непростая, тем не менее, с точки зрения девелопера, предоставляет ему просто набор сервисов и API. Ну то есть, открыть файл, записать в файл, нарисовать точку на экране. С точки зрения реверсера, знать об ОС нужно как минимум две вещи - это предоставляемое API, а еще формат исполняемых файлов. 2.1. API Работа под большинством ОС делится на две части: kernel-mode и user-mode. Почему так все. Потому что MMU процессора x86 изначально предоставляло 4 кольца защиты (ring). Предпологалось в самом нижнем кольце защиты распологать микроядро ОС. Которое имело бы права делать все что вздумается. На уровень выше распологать драйвера. Для того что если какой-то драйвер заглючит, то микроядро могло бы отстрелить его. В последнем кольце, наименее привилегированном, распологать юзерский софт. Но в винде решили использовать только два кольца защиты, в одном имеется ядро системы и драйвера (kernel mode), во втором - юзерский софт (user mode). Это означает, что когда происходит исключение на уровне user-mode, то процесс закрывается и винда говорит вам, мол, процесс свалился. В юниксах вам будет сказано о "core dumped". Если произойдет исключение в ядре системы, или в драйвере, то винда покажет вам синий экран где будет указано, где произошло исключение и в каком драйвере. Вот почему писать драйвера нужно как можно более аккуратно: любая ошибка валит систему и вообще ошибки искать труднее. Предоставляемое API для user-mode и для драйверов, в винде, немного разное. Вообще, API для драйверов более жесткое. Точка соприкосновения между kernel mode и user mode - это syscalls. Вообще, в процессоре x86, код в наименее привилегированном кольце не имеет права передать управление в код из другого кольца. А чтобы это было возмжоным, там есть специальные шлюзы. На практике, в линухе, сисколл это команда int 80h, в винде тоже был какой-то int, потом в процах ввели специальную инструкцию sysenter. Еще интересно, как API имплементируется в системе. Ну например в линуксе. Есть библиотечная функция printf. Чтобы ядро линуха поддерживала эту функцию на уровне сисколла - это было бы очень жирно. Вообще, количество сисколлов всегда и везде экономится и это поле стараются всеми силами не захламлять. Можно сказать, что набор сисколлов - это самое главное API ОС со стороны user mode. Так что, printf вынесен в glibc, вместе с остальным подобным. Вы пишете прогу, которая вызывает printf, функция в glibc по одному символу выводит строку на экран используя уже сисколл вроде "записать в файл". stdout, понятно, это тоже файл. Линуховые сисколлы: http://www.unusedino.de/linuxassembly/syscall.html Виндовые сисколлы: http://www.metasploit.com/users/opcode/syscalls.html По сисколлам можно быстро оценить, что предлагает ОС девелоперу, и что появилось в следующей винде, сервиспаке, итд. Как хорошо нужно знать API операционной системы? Думаю, нужно знать в общих чертах, к остальному относиться как к справочной инфе. Например, можно вспомнить такую жирную софту как Oracle RDBMS. Он есть под разные юнихи и под винду в том числе. Вот представьте, перед вами стоит задача написать очень большую софту и при этом, она должна работать под разными ОС. Первое что приходит в голову - это вынести системозависимый код в отдельную область. Что и было сделано в oracle. То есть, тут есть некоторая часть, которой он "соприкасается" с системой, и эту часть пытались сделать как можно более небольшой. Например, в юниксах есть fork(), а в винде - нету. Это надо учитывать с самого начала. В общем, ковыряя такую огромную софту как Oracle RDBMS, я бы сказал, что знать системное API нужно, но не нужно его зубрить. Когда все итак есть в MSDN или еще где. Все самое трудное для понимания происходит вовсе не здесь. 2.2. Формат исполняемых файлов. В винде - PE, в линухах - elf. Вообще, с точки зрения начинающего реверсера, можно сказать что типичный исполняемый файл состоит из сегмента кода, сегмента данных и таблицы импортов (то есть, что исполняемый файл подтягивает из загружаемых либ .so или .DLL). Можно сказать с некоторым натягом, что сегмент данных обычно содержит глобальные переменные а также константы. Кстати, бывали времена! Когда трава была зеленее, и так далее... что когда вы объявляете в Си константу через const, эта переменная сдвигается в сегмент для констант. Это почти такой же сегмент как и сегмент данных, но на нем висит защита от записи. Бонус этой истории в том, что если программер по ошибке будет писать в константу, то в ОС сразу сработает защита. Также, по обыкновению, сегмент кода защищен от записи. А сегмент данных защищен от исполнения кода. Чтобы нельзя было сделать jmp на область в сегменте данных. Но от эксплоитов это не особо спасает, честно говоря. Еще один важный тип сегмента, BSS, неопределенные данные. Если вы объявляете большой массив чего-нибудь, но не указываете, что там будет лежать, то компилятор объявит его в сегменте BSS. После загрузки, ОС не выделяет памяти для BSS. Но она будет это делать тогда, когда какой либо код попытается писать туда или читать. 3. Компилятор. Компилятор в этой все истории - очень важная вещь, потому что именно он собственно генерит код, который реверсеру разгребать затем. Компилер традиционно состоит из двух частей - front-end и back-end. Например возьмем GCC, который способен компилить из Си, Ады, Фортрана, еще чего-то. И еще он способен генерить код под прорву процов. Его front-end компилит из этих всех языков в промежуточный код, который back-end затем транслирует под конкретный проц. Оптимизация происходит обычно на стадии промежуточного кода, хотя и в кодегенераторе также. У каждого компилера есть какие-то особенности, по которым, глядя на код, можно примерно прикинуть, какой это мог бы быть компилер. Вообще, когда я учил Си, а затем Си++, я застрял где-то на виртуальных методах, и тогда я просто писал куски кода на Си++, компилил и смотрел что получилось на асме, так мне понять было намного проще. Я делал это такое количество раз, что связь между кодом на Си и тем что генерит компилер вбилась мне в подсознание достаточно глубоко, поэтому я могу глядя на код на асме сразу понимать, в общих чертах, что там было написано на Си. Возможно, это может быть неплохой метод для обучения реверсингу. Самые часто применяемые x86 инструкции в коде генерируемом компиляторами это MOV/PUSH/CALL. MOV просто перекладывает байты, PUSH активно используется для передачи параметров через стек и CALL вызывает другую функцию. С некоторым натягом, можно сказать, что основное количество времени процессор просто перекладывает байты с места на место. Можно сказать, что это наверное цена за разделение кода на разные уровни абстракций. Основные компилеры в наше время это GCC для линуха и Microsoft-овский для винды. Вопрос: стоит ли напрягаться и пытаться писать на асме для оптимизации кода? Наверное нет. И вот почему. Да, когда-то бывали такие времена, когда процессоры исполняли по одной команде за раз. Существует также компилер от Интела, он наиболее злобный. Получилось так что Интел добавлял в свои процы разные фичи, но их никто не юзал, потому как не было компиляторов, которые могли бы генерить такой код, который будет юзать их на всю катушку. Видимо, поэтому Интел свой компилер и сделал. Он использует векторизацию циклов, SIMD и прочие злобные вещи. Векторизация циклов это когда вы исполняете некий кусок кода скажем 10 раз. В это время проц исполняет само тело цикла плюс инструкции которые собственно обеспечивают цикл: это прибавить счетчик на еденицу, сравнить его с чем-то там, перейти если, итд. Если тело цикла расположить просто 10 раз кряду, то это выглядит конечно слегка абсурдно, зато это экономия на этих инструкциях. Интеловский компилер умеет разворачивать циклы не спрашивая девелопера. Помимо всего прочего, этот компилер имеет свои стандартные либы, вроде memcpy(). Что может быть сложного в функции, которая копирует просто блок из одного места памяти в другое? Очень много чего. Нормальный программер так и напишет: переложить побайтово отсюда сюда. Проц грузит в регистр один байт из памяти и грузит его назад в другое место, в цикле. Но! У современных процов широкая шина с памятью! Мы можем загружать сразу несколько байт. Хорошо, мы пишем, грузить сразу 32-битное значение из одного места и загружать его в другое. Уже лучше, хотя мы теперь должны красиво обрабатывать копирование блоков с размером не кратным 4-м байтам. Мы можем использовать SIMD - грузить сразу по 16 байт в регистр. Пока все хорошо. Рафинированное зло начинается когда нужно обрабатывать копирование блоков не кратным 16-и байтам. В интеловской функции memcpy() используется злобный switch/case для обработки подобного. И еще: условный переход в switch() должен указывать на адреса в памяти которые выровнены по 16-и байтам: так лучше для проца, его кеша, и так далее. Подобные вещи обрабатываются компиляторами. Поэтому не удивляйтесь если вы видите в неожиданных местах много бессмысленных инструкций вроде nop или его заменителей. Интеловская функция memcpy() на асме занимает примерно 2000 строк. Вот это она: http://conus.info/RE-articles/1/intel_new_memcpy.asm Из простых примеров: в старых учебниках по ассемблеру вы наверное увидите примеры с инструкцией LOOP. Это для обеспечения циклов. Команда проверяет текущее значение CX/ECX/RCX и если оно не ноль, то делает переход. Все хорошо, но только в интеловских мануалах где-то было сказано, что эта инструкция оставлена для совместимости и более не будет оптимизироваться. Вместо этого, для циклов, все компилеры определяют цикл при помощи инструкций CMP/DEC/INC/JZ/JNZ. Что конечно выглядит хуже. Зато все это втягивается в процессор и исполняется одновременно. Вот допустим код на Си, который мы сейчас скомпилим при помощи интеловского компилера со включенными оптимизациями. int f(int a, int b) { int rt; for (int i=0; i