Ускоренный курс по x86, чтобы сделать его менее страшным.

Итак, вы хотите изучить x86? Ну, вы пришли в нужное место!

PS: Пожалуйста, извините за плохие рисунки. Я старался изо всех сил, имея только трекпад для рисования.

Это ни в коем случае не исчерпывающее руководство по x86, но, надеюсь, это будет всесторонний обзор наиболее важных частей языка.

  1. Почему?: Моя причина для написания этого в первую очередь.
  2. Что?: Честно говоря, что это?
  3. Компоненты программы: Каковы части типичной программы?
  4. Инструкции: типы инструкций, которые существуют.
  5. Разыменование: получение значений из заданного адреса памяти.
  6. Математика: математические операции, которые можно выполнять.
  7. Операции, основанные на стеке: операции, которые выполняют вставку/выталкивание в стек.
  8. Загрузить эффективный адрес: Загрузка значения по заданному адресу памяти.
  9. Сравните: операторы сравнения для условных выражений.
  10. Перейти: переход к другим частям программы по заданному адресу памяти.
  11. Вызов: вызов других функций.
  12. Leave/Ret: Выход из вызова функции.
  13. Ссылки: Ссылки, которые я использовал.

Почему?

Вам может быть интересно, почему я подвергаю себя пыткам, пытаясь выучить ассемблер, ведь это «либо слишком сложно», либо «чертовски скучно». Что ж, вы правы, это точно не самое интересное в мире. Поскольку я прохожу курс наступательной безопасности, мне вроде нужно хотя бы понять ее основы — в основном, чтобы понять дизассемблирование программ для 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 бита.

  1. Умножение 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. Дивизор равен 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].

Проще говоря, вот основные моменты, почему это невозможно:

  1. Инструкция MOV предназначена не только для вычисления адреса данной ячейки памяти. Его работа заключается в доступе к любому значению, хранящемуся по этому адресу, а это означает, что фактически требуется дополнительное время для чтения содержимого этой области памяти. LEA просто вычисляет заданные значения.
  2. Недопустимо выполнять какую-либо форму вычислений без скобок или круглых скобок.
  3. Если предположить, что существует специальная функция для MOV, которая разрешает строку кода выше, это сделает синтаксис менее чистым. Субъективно, но отличить загрузку из памяти и вычисление адреса намного проще, если у тебя 2 разные инструкции.

Возможно, есть и другие причины, но в целом наличие специальной инструкции для вычисления адресов намного чище и оптимальнее, чем перегрузка существующей инструкции, которая увеличивает сложность и ухудшает читабельность.

Сравнивать

Инструкция cmp фактически аналогична инструкции sub, за исключением того, что вместо сохранения результата в первом аргументе она устанавливает флаг в процессоре. Инструкция может быть выполнена как cmp arg1, arg2.

Этот флаг будет иметь следующие возможные значения:

  1. < 0arg1 < arg2
  2. 0arg1 == arg2
  3. > 0arg1 > 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 выполняет очистку кадра стека. Это:

  1. Смещает esp туда, где расположен ebp, который является основанием кадра стека для текущей функции (вызываемой).
  2. Извлекается из вершины стека в ebp, который содержит адрес старого кадра стека (вызывающего).

В x86 это leave эквивалентно этому:

Инструкция return берет значение в верхней части стека после вызова leave, которое является обратным адресом для вызывающего, и помещает его в esp. В конце концов, программа продолжает выполнение с того места, где остановилась в вызывающей функции.

Мне пора уйти и отдохнуть

Понимание x86 имеет решающее значение для понимания и разработки собственных будущих эксплойтов. Я действительно надеюсь, что это помогло сделать x86 немного менее страшным.

Конечно, еще многое предстоит узнать, но это должно заложить основу для будущих исследований. Развлекайся!

Первоначально опубликовано наhttps://spiderpig86.github.io/blog/x86-101.