Ускоренный курс по x86, чтобы сделать его менее страшным.
Итак, вы хотите изучить x86? Ну, вы пришли в нужное место!
PS: Пожалуйста, извините за плохие рисунки. Я старался изо всех сил, имея только трекпад для рисования.
Это ни в коем случае не исчерпывающее руководство по x86, но, надеюсь, это будет всесторонний обзор наиболее важных частей языка.
- Почему?: Моя причина для написания этого в первую очередь.
- Что?: Честно говоря, что это?
- Компоненты программы: Каковы части типичной программы?
- Инструкции: типы инструкций, которые существуют.
- Разыменование: получение значений из заданного адреса памяти.
- Математика: математические операции, которые можно выполнять.
- Операции, основанные на стеке: операции, которые выполняют вставку/выталкивание в стек.
- Загрузить эффективный адрес: Загрузка значения по заданному адресу памяти.
- Сравните: операторы сравнения для условных выражений.
- Перейти: переход к другим частям программы по заданному адресу памяти.
- Вызов: вызов других функций.
- Leave/Ret: Выход из вызова функции.
- Ссылки: Ссылки, которые я использовал.
Почему?
Вам может быть интересно, почему я подвергаю себя пыткам, пытаясь выучить ассемблер, ведь это «либо слишком сложно», либо «чертовски скучно». Что ж, вы правы, это точно не самое интересное в мире. Поскольку я прохожу курс наступательной безопасности, мне вроде нужно хотя бы понять ее основы — в основном, чтобы понять дизассемблирование программ для CTF.
Пока не так много простых руководств по изучению x86 онлайн по сравнению с другими языками. Так что, надеюсь, это будет быстрое и простое руководство, которому вы сможете следовать, чтобы изучить самые основы x86. 😄
Что такое х86?
Хуже, чем MIPS. Просто шучу. x86 — это вариант Assembly, который обычно пишется для процессоров x86–64, которые являются процессорами CISC (в настоящее время распространены в большинстве настольных компьютеров). x86 сложнее, чем, скажем, MIPS в том смысле, что он предназначен для выполнения задач с помощью всего нескольких инструкций, а не с использованием более простых инструкций [1].
Например, архитектуры CISC поставляются со встроенной инструкцией, которая выполняет умножение, не разбивая его на серию более мелких инструкций. В RISC-подходе умножение фактически разбивается на серию из нескольких инструкций [2].
В подходе RISC он разбит на ряд других шагов.
Компоненты программы
Прежде чем мы начнем с языка x86, сначала мы должны понять основные разделы программы, написанной на C. Это будет очень краткое руководство, так как в Интернете есть много информации. Обратите внимание, что я буду говорить о 32-битном варианте архитектуры x86.
куча
куча — это раздел неуправляемой памяти (в Java есть сборщик мусора, поэтому технически он там управляется), который выделяется операционной системой. ОС использует системный вызов sysbrk
для выделения памяти из запрашивающей функции. В отличие от стека, память не становится недействительной при возврате функции, что приводит к утечкам памяти. Чтобы избежать этого, в C вы должны освобождать память с помощью free()
после вызова malloc()
или одного из его вариантов. Куча растет вверх по направлению к более высоким адресам памяти.
Куча
Это относится к области памяти, выделенной процессу главным образом для реализации функций. Это управляется в том смысле, что созданные переменные остаются локальными для функции, в которой они находятся, и становятся недействительными после выхода из функции (когда указатель стека перемещается вверх). Значения все еще там, но они будут перезаписаны содержимым фрейма стека следующего вызова функции. Два регистра x86, которые имеют дело с этим:
esp
(расширенный указатель стека) — указывает на вершину стека, гдеpush
иpop
соответственно уменьшают и увеличивают указатель стека.push
добавляет элемент на вершину стека, аpop
удаляет элемент с вершины стека.ebp
(расширенный базовый указатель) — указывает на начало текущего кадра стека, то есть на место, выделенное для вызова функции. Инструкцияcall
помещает текущий указатель инструкции в стек.
Обратите внимание, что стек растет вниз в сторону более низких адресов памяти. Здесь также объявляются глобальные и статические переменные.
Регистры
Это небольшие запоминающие устройства, расположенные непосредственно в процессоре, для быстрого доступа к промежуточным значениям при вызовах функций. Он известен как кэш L0 и расположен на самом высоком уровне иерархии памяти.
В архитектуре x86 имеется шесть 32-битных регистров общего назначения [3]:
eax
(расширенный регистр-аккумулятор) — используется для хранения возвращаемых значений функций и специального регистра для определенных вычислений.ebx
(расширенный базовый регистр) — часто устанавливается на часто используемые значения для ускорения вычислений.ecx
(расширенный регистр счетчика) — используется как параметр функции или счетчик цикла. Обычно используется с циклами for в качестве счетчика.edx
(регистр расширенных данных) — также используется в качестве регистра параметров функции, напримерecx
, и для хранения краткосрочных переменных внутри функции.esi
(регистр расширенного индекса источника) — регистр, указывающий на то, где в памяти находится «источник».edi
(регистр индекса расширенных данных) — аналогиченesi
, но указывает на место назначения.
ebp
, esp
и eip
известны как зарезервированные регистры.
Формат инструкции
По сути, существуют две концепции формата инструкций: синтаксис AT&T и синтаксис Intel.
Короче говоря, разница заключается в том, что для синтаксиса AT&T исходный регистр находится слева, а регистр назначения — справа, в то время как для синтаксиса Intel все наоборот. сильный>.
Инструкции обычно имеют 2 формы:
op arg
- есть операция только с одним аргументом.op arg1, arg2
- есть операция с двумя указанными аргументами, разделенными запятой.
Например, с инструкцией mov
она копирует значение, сохраненное вторым аргументом, в первый аргумент.
Хотя он называется mov
, на самом деле вместо этого он копирует, а это означает, что значение, хранящееся в arg2
, на самом деле все еще существует. Однако есть некоторые предостережения относительно этой конкретной инструкции, которые в целом обсуждаются в следующем разделе.
Разыменование
Предупреждение возникает, когда вы пытаетесь получить значения из стека.
Когда вы пытаетесь получить значения из стека, скажем, перемещая локальную переменную в ebp - 12 (0xC)
со значением 42, вы ожидаете, что вы получите значение, хранящееся в этом месте, в eax
. Это означает, что eax
должно иметь значение 42, верно?
НЕПРАВИЛЬНО
ebp - 0xC
— это адрес, указывающий, где в памяти хранится число 42. Простой вызов mov
переместит этот адрес в eax
, но не в 42. Если вы знаете C, вы знаете, что нам потребуется разыменовать указатель, чтобы получить значение в этом месте. В x86 также существует аналогичная идея, где мы можем думать об использовании нотации квадратных скобок в качестве оператора разыменования.
Итак, чтобы получить 42, мы запустим это:
Математика
Конечно, x86 поддерживает основные математические операторы, необходимые для выполнения более сложных вычислений.
Дополнение
Инструкция add
представляет собой бинарную операцию, которая складывает два заданных регистра в формате add arg1, arg2
, который совпадает с arg1 = arg1 + arg2
. Первый и второй аргументы суммируются и сохраняются в первом аргументе.
Приведенная выше инструкция добавляет значение в %eax
к 5 и сохраняет его обратно в %eax
.
Вычитание
Это бинарная операция, которая вычитает второй аргумент из первого: sub arg1, arg2
совпадает с arg1 = arg1 - arg2
.
Приведенная выше инструкция перемещает указатель стека вниз на 4 байта. Это также можно сделать, добавив отрицательное значение с меньшими накладными расходами.
Умножение
Умножение немного сложнее, так как оно сопровождается несколькими различными инструкциями. Кроме того, его регистр назначения различается в зависимости от размера памяти значения, на которое вы умножаете.
Примечание. Используемая здесь терминология отличается от терминологии для других архитектур. слово имеет размер 16 бит (размер регистра), а двойное слово – 32 бита.
- Умножение 2 байтов
- Это обычный случай, когда мы перемещаем один из операндов в регистр
%AX
. Затем мы вызываемmul <arg2>
, который будет выполнять%AX = %AX
, гдеarg2
— это какой-то регистр, а **не** непосредственный.
2.Умножение 2 слов (16 бит)
- Эта операция выполняет умножение двух 16-битных значений.
- Множимое или умножаемое число хранится в регистре
%AX
, а множитель — это слово, хранящееся в памяти или другом регистре. - Синтаксис аналогичен предыдущему, где мы вызываем
mul <arg2>
, который выполняет%(DX:AX) = %AX & arg2
. - Поскольку это позволяет получить гораздо большие результаты, произведение двух слов может привести к двойному слову, а это означает, что для хранения всего результата требуется два регистра.
3.Умножение 2 двойных слов (32 бита)
- Идея аналогична здесь, но мы используем расширенные версии регистров, такие как
%EAX
и%EDX
. - Это также называется
mul <arg2>
. - результатом этого может быть
qword
или четверное слово, поэтому нам нужно будет использовать 2 расширенных регистра для хранения результата. - И
%EDX
, и%EAX
хранят 32 бита. - Как и раньше,
%EDX
хранит старшие 32 бита, а%EAX
хранит младшие 32 бита.
Конечно, есть imul
, который обрабатывает **умножение со знаком**.
Отдел
Как и в случае с умножением, в делении действуют те же правила для работы с делителями разного размера.
- Дивизор равен 1 байту
- Во-первых, мы помещаем наш дивиденд (число, на которое мы делим) в
%AX
. - Тогда делитель является другим регистром или ячейкой памяти, которую мы указываем, которая имеет делитель, поэтому операция работает следующим образом:
%AX / arg2
. - Результат будет сохранен в двух регистрах:
%AL
и%AH
, которые хранят частное и остаток соответственно. - Для этого позвоните по номеру
div <arg2>
2. Дивизор равен 1 слову
- Здесь основное отличие состоит в том, что делимое (32 бита) помещается в регистры
%(DX:AX)
, где старшие 16 бит находятся в%DX
, а младшие 16 бит — в%AX
. - После деления 16-битное частное переходит в
%AX
, а 16-битный остаток — в%DX
.
3. Дивизор — это двойное слово
- Делимое здесь предполагается равным 64 битам, хранящимся в регистрах
%(EDX:EAX)
. - После деления частное переходит в
%EAX
, а остаток — в%EDX
.
Операции на основе стека
Операторы на основе стека предназначены для управления регистрами, соответствующими указателю стека, например %esp
и %ebp
. Имейте в виду, что указатель стека увеличивается в памяти вниз, поэтому «верхняя часть» стека на самом деле находится внизу.
Отправить
- Инструкция
push
предназначена для размещения данных на вершине стека. - Это достигается путем уменьшения `%esp` на 4 байта (чтобы выделить место для адреса в 32-битных системах), а затем помещать значение в эти 4 байта.
push <arg>
Поп
- Инструкция
pop
используется для извлечения или удаления данных из вершины стека, на которую указывает%esp
. - Это работает путем увеличения
%esp
на 4 байта (в 32-битных системах) и копирования значения в соответствующий регистр. - Фактические данные не очищаются сами по себе в стеке, но, поскольку указатель стека находится над ним, данные будут перезаписаны в следующий раз, когда указатель стека пройдет мимо него.
pop <register>
Леа
Эта инструкция означает загрузить эффективный адрес.
Он помещает адрес соответствующего значения, указанного во втором аргументе, и помещает его в регистр, указанный в первом аргументе.
Инструкция была в основном включена для добавления поддержки языков более высокого уровня, таких как C, где требуется адрес некоторого значения, будь то массивы или структуры. Это было бы эквивалентно оператору &
в C. Допустим, у нас есть структура, определенная в C следующим образом:
struct Point { int x; int y; int z; }
Если бы мы получили доступ к координате z массива Points
внутри некоторого цикла for, мы бы выполнили что-то вроде этого:
В x86 эквивалентным оператором будет:
- Напомним, что инструкция
MOV
используется для перемещения значений из второго аргумента в первый. - Скобки используются как оператор разыменования для получения связанного значения по этому адресу.
- Мы предполагаем, что
%EBX
содержит базовый адрес массива, а%EAX
содержит переменную-счетчик. - Предполагая 4-байтовое выравнивание, мы умножаем счетчик
%EAX
на12
, поскольку структураPoint
имеет размер12
байт (из 3 32-битных целых чисел). - Смещение для получения переменной
z
составляет8
байт, так как есть 2 предшествующих целых числа,x
иy
. Теперь это отлично подходит для получения нужного значения, но что, если нам просто нужен фактический адрес в памяти. В C оператор&
пригодится, и мы можем написать что-то вроде этого:
Но Стэн, подожди!
Разве мы не можем сделать то же самое, написав MOV ESI, EBX + 12 * EAX + 8
?
Ну нет, мы не можем. Объяснение станет ясным после прочтения захватывающего обсуждения Stackoverflow за период с 2011 по 2018 год [4].
Проще говоря, вот основные моменты, почему это невозможно:
- Инструкция
MOV
предназначена не только для вычисления адреса данной ячейки памяти. Его работа заключается в доступе к любому значению, хранящемуся по этому адресу, а это означает, что фактически требуется дополнительное время для чтения содержимого этой области памяти.LEA
просто вычисляет заданные значения. - Недопустимо выполнять какую-либо форму вычислений без скобок или круглых скобок.
- Если предположить, что существует специальная функция для
MOV
, которая разрешает строку кода выше, это сделает синтаксис менее чистым. Субъективно, но отличить загрузку из памяти и вычисление адреса намного проще, если у тебя 2 разные инструкции.
Возможно, есть и другие причины, но в целом наличие специальной инструкции для вычисления адресов намного чище и оптимальнее, чем перегрузка существующей инструкции, которая увеличивает сложность и ухудшает читабельность.
Сравнивать
Инструкция cmp
фактически аналогична инструкции sub
, за исключением того, что вместо сохранения результата в первом аргументе она устанавливает флаг в процессоре. Инструкция может быть выполнена как cmp arg1, arg2
.
Этот флаг будет иметь следующие возможные значения:
< 0
—arg1 < arg2
0
—arg1 == arg2
> 0
—arg1 > arg2
Сохраняемый флаг на самом деле является значением arg1 - arg2
. Например, вот как это будет работать:
- Эта инструкция выполнит
1 - 3
, установив-2
внутри флага, который говорит нам, чтоarg1 < arg2
. - Имейте в виду, что это не совсем правильный способ, поскольку мы не можем напрямую сравнивать 2 немедленных объекта.
- Первый аргумент обычно является регистром, а второй может быть либо немедленным, либо регистром.
Теперь есть много других примеров использования инструкции cmp
вместе со многими другими приемлемыми аргументами, которые можно найти здесь [5].
Прыжок
Обычно при использовании приведенной выше инструкции cmp
за ней следует инструкция jmp
или переход. Эта инструкция принимает в качестве параметра один аргумент для адреса перехода.
Переход будет выполнен на основе значения флага в процессоре. При переходе в регистр eip
устанавливается этот аргумент. Конечно, существует множество вариантов инструкции перехода, которые изменяют ход программы в зависимости от значения флага. Показанная выше инструкция jmp
является безусловным переходом.
Ниже приведен неполный список различных существующих инструкций перехода. В основном это обычные.
je
(jump equal) - прыгает при равенствеjz
(jump zero) - перейти если нольjl
(прыгать меньше) - прыгать, если меньшеjg
(прыгать больше) - прыгать, если больше
Еще много чего можно найти здесь [6].
Ниже приведен пример программы, написанной на псевдокоде, о том, как она может работать. Адреса слева, а инструкции справа, предположим, что eip
указывает на addr3
:
addr1 instr1 addr2 cmp 1, 3 addr3 jl addr10 addr4 instr4 ... ... addr10 instr10
- Глядя на приведенный выше пример, переход будет выполнен к
addr10
, поскольку1 < 3
и мы используемjl
, что означает переход, если первый аргумент меньше второго. - Если бы мы использовали
jg
,eip
переместилось бы вaddr4
.
Вы, наверное, заметили, что я часто использовал слово флаг в последних двух разделах, но что это, черт возьми, такое?
Идея регистра FLAGS состоит в том, чтобы служить регистром состояния, содержащим текущее состояние процессора. По сути, это означает, что этот регистр шириной 16 bit
содержит много информации о том, что происходит в процессоре.
Если все состояние ЦП хранится в одном регистре, как мы можем отслеживать несколько независимых состояний?
Идея использования единого регистра заключается в экономии места. К счастью, с помощью битовой маски мы можем указать определенный набор битов для представления состояния определенной части информации. Короче говоря, каждый флаг или информация о состоянии отображается в определенный набор битов. Сбивает с толку, верно?
Например, допустим, если мы выполняем какое-то сложение и значение переноса было сгенерировано ALU, флаг переноса или CF
устанавливается на 1
, чтобы сообщить нам, что это произошло. Это 1
— всего лишь 1 бит в 16-битном регистре, представляющем состояние. Чтобы получить конкретное значение CF
, мы используем значение маски 0x0001
, что означает, что младший значащий бит соответствует флагу переноса.
Наиболее распространенными флагами являются CF
, ZF
, OF
, PF
и SF
. Поскольку это лишь краткий обзор, гораздо более подробную разбивку флагов x86 вы можете увидеть здесь [7].
Регистр FLAGS не ограничен только 16
битами, так как некоторые процессоры имеют 32-битные и 64-битные размеры регистров. 32-битный вариант обозначается как EFLAGS, а 64-битный вариант обозначается как RFLAGS.
Вызов
Возможно, это самая важная инструкция из всех. Это простая на первый взгляд инструкция, которая принимает один аргумент.
Это предназначено для «вызова» функции в программе или библиотеке. Когда вы дизассемблируете свою программу на C, вы заметите, что вызовы стандартных функций библиотеки C, таких как printf
, все указывают на некоторый адрес памяти.
Принцип работы call
состоит в том, что он помещает адрес возврата или текущее значение eip
в стек и переходит к аргументу.
Другими словами, это означает, что call <func_addr>
эквивалентно:
Это создает фрейм стека, который, по сути, размещает все параметры, адрес возврата и саму функцию в правильном порядке в стеке, чтобы она могла выполняться и вернуться к вызывающему абоненту. Каждая вызываемая функция будет иметь свой собственный фрейм стека, где базовый указатель ebp
содержит начало фрейма стека, а esp
указывает на вершину фрейма стека.
Чтобы вызвать функцию с параметрами, это будет выглядеть примерно так. Вот как foo(a, b, c)
будет выглядеть в x86:
Обратите внимание, что мы всегда помещаем данные в обратном порядке, так как стек — это LIFO. Напомним, что все параметры хранятся в стеке. Чтобы получить к ним доступ, мы можем написать что-то вроде этого:
На самом деле нам не хватает регистра базового указателя стека, который помогает нам отслеживать, где находится начало кадра стека, поскольку без него обработка функций может быть довольно запутанной.
Эти две инструкции всегда используются в конце данной функции. За инструкцией leave
всегда следует инструкция return
, как это видно при дизассемблировании программ на C.
Уехать
Эти две инструкции составляют то, что мы знаем сегодня как один оператор return
в языках более высокого уровня, таких как Java или C. Они должны использоваться вместе, так как они очищают фрейм стека и возвращают к правильная часть программы.
Инструкция leave
выполняет очистку кадра стека. Это:
- Смещает
esp
туда, где расположенebp
, который является основанием кадра стека для текущей функции (вызываемой). - Извлекается из вершины стека в
ebp
, который содержит адрес старого кадра стека (вызывающего).
В x86 это leave
эквивалентно этому:
Инструкция return
берет значение в верхней части стека после вызова leave
, которое является обратным адресом для вызывающего, и помещает его в esp
. В конце концов, программа продолжает выполнение с того места, где остановилась в вызывающей функции.
Мне пора уйти и отдохнуть
Понимание x86 имеет решающее значение для понимания и разработки собственных будущих эксплойтов. Я действительно надеюсь, что это помогло сделать x86 немного менее страшным.
Конечно, еще многое предстоит узнать, но это должно заложить основу для будущих исследований. Развлекайся!
Первоначально опубликовано наhttps://spiderpig86.github.io/blog/x86-101.