Showing posts with label mprotect. Show all posts
Showing posts with label mprotect. Show all posts

16/09/2021

Маленький взлом системы (2): заставим процессор выполнить переменную

В прошлой статье (Маленький взлом системы (1): наконец-то вы сможете изменять строки типа char* String) мы рассмотрели как можно писать в область памяти, в которую запись запрещена. Сегодня мы разовьём эту тему и попробуем выполнить переменную. Начнём с подготовительных работ. Как и в прошлый раз, нам придётся изменить режим доступа к памяти, так чтобы её можно было исполнять, то есть, чтобы выполнить call или jmp на неё. Для этого объявим переменную следующим образом:

uint8_t lMagicCode [] = { };

Содержание её мы рассмотрим ниже. А сейчас выполним установку необходимых нам режимов доступа к этой переменной. Здесь повторим действия из примера в предыдущей статьи:

void* lPageBoundary = (void*) ((long) lMagicCode & ~(getpagesize () - 1));
mprotect (lPageBoundary, 1, PROT_EXEC | PROT_WRITE);

Мы добавили режим доступа PROT_EXEC, который и позволит нам выполнить переменную. Как вы заметили, я оставил режим PROT_WRITE. Это сделано потому что MMU работает со страницами. На наших машинах её размер, вероятнее всего будет 4kB, что довольно много и помеченная страница скорее всего заденет область, следующую за интересующей нас переменной. А нам нужен режим чтения и записи для обвязки нашего эксперимента. Поэтому чтобы не схватить SIGSEGV после вызова mprotect, мы выставляем совмещённый режим — запись (которая на x86 MMU не бывает без режима чтения) и выполнение.

Далее нам нужно как-то указать машине, как и когда выполнить эту переменную. На всякий случай помечу: эта переменная сама по себе никогда не выполнится, функция mprotect не запустит её, а лишь пометит, как доступную для выполнения. Перейдём к последнему этапу подготовительных работ на высоком уровне (на уровне C), который заключается в том, чтобы объявить функцию, ссылающуюся на адрес этой переменной, что в последствии, позволит нам выполнять эту переменную.

unsigned int (*NewExit)(unsigned long _ExitCode) = (void*)lMagicCode;

Здесь мы объявили указатель на функцию с названием NewExit, принимающую один параметр _ExitCode и возвращающую значение. Теперь мы можем выполнить нашу переменную и получить возвращаемое ею значение простой строчкой:

unsigned int lResult = NewExit (1);

Пока не надо этого делать — пытаясь выполнить пустую переменную, то есть, выполняя call на адрес пустой переменной, вы по сути дела, «провалитесь» дальше (как было в примере предыдущей статьи с выводом строки), а там может быть что угодно и, скорее всего, данные, даже не похожие на последовательность байт, представляющую собой корректную машинную команду с операндами. Что, вероятнее всего, приведёт к ошибке Illegal instruction.

Далее нам нужно заполнить нашу переменную корректным кодом. Начнём, допустим с возврата, то есть исключим «проваливание» потока выполнения при вызове этой функции. По справочникам найдём код операции ret, который очень простой и равен C3h.

uint8_t lMagicCode [] = { 0xC3 };

На самом деле это retn — return near, то есть ближний возврат или внутрисегментный возврат — возврат в пределах одного сегмента кода.

Теперь можно выполнить нашу функцию, не боясь неопределённых последствий, так как теперь произойдёт следующее: команда call сохранит в стэке адрес возврата равный адресу операнда следующему сразу за ним (адрес call + длинна инструкции call и его операнда), затем перейдёт по указанному адресу (адресу нашей переменной), в которой лежит код команды ret, которая, в свою очередь извлечёт адрес возврата из стэка и выполнит переход по нему.

printf ("The result of NewExit is: %d.\n", lResult);

Сейчас мы будем видеть «мусор» на выходе из функции. Для получения какого-то осмысленного результата, вернём значение. Значение из функции по правилам x86_64 ABI возвращается через регистр «семейства» AX. Мы объявили нашу функцию как возвращающую значение unsigned int, то есть 32 бита. Значит нам нужно записать результат в EAX — 32-битный регистр. Найдём код загрузки константы в EAX — это B8h. При заполнении команды нужно учитывать размерность операнда. Здесь мы не можем написать 0xB8, 0x04. Нам нужно указать всё значение полностью.

В языках высокого уровня это за нас делает компилятор. GAS так же подставляет дополненные значения в зависимости от суффикса мнемонического представления команды, например movl $0x04, %eax запишет в реальный код B804000000h, дополнив нашу четвёрку нулями до длинны long. Здесь это не тот long, о котором мы говорили в моделях памяти (LP64), это long с точки зрения x86-ой машины длинной 32 бита. Некоторые трансляторы даже подбирают код конкретной операции из обобщённого мнемонического представления и дополняют размерность операнда в зависимости от указанных размерностей приёмника и источника.

В противном случае, если мы не распишем все байты составляющие 32-битное значение, последующий код, который мы запишем как следующую инструкцию или операнд, в процессе выборки команды процессором будет воспринят как данные для загрузки в EAX. Поэтому соберём код 0xB8, 0x07, 0x00, 0x00, 0x00. И наша переменная приобретёт следующий вид:

uint8_t lMagicCode [] =
{
  0xB8, 0x07, 0x00, 0x00, 0x00, // movl $0x7, %eax
  0xC3,                         // retn
};

Теперь, вызвав эту функцию как

printf ("The result of NewExit is: %d.\n", NewExit (0));

Мы получим:

The result of NewExit is: 7

Функция названа NewExit. Давайте придадим ей этот смысл. Для этого мы воспользуемся системной функцией под номером 60/3Ch и вызовем её при помощи syscall, которая имеет код операции 0F05h. Операционная система, при вызове системных функций от пользовательских процессов, принимает номер функции в регистре EAX. Писать туда мы уже умеем. Наша переменная с машинным кодом приобретёт такой вид (возврат из этой функции нам уже не нужен):

uint8_t lMagicCode [] =
{
   0xB8, 0x3C, 0, 0, 0, // movl $0x3C, %eax, 3Ch/60 - system call exit()
   0x0F, 0x05,          // syscall
};

У нашей функции есть один параметр. Этот параметр попадёт в возвращаемое значение функции main, то есть код нашей переменной аналогичен функции exit. Вы можете это проверить запустив программу на исполнение и проверив код выхода при помощи echo $?. Вы увидите число переданное вами в функцию NewExit.

Хоть мы и передаём int в качестве параметра, возвращаемое из функции main значение всегда снижается системой до одного байта, поэтому не имеет смысла задавать значения более 255.

Здесь может возникнуть вопрос — «А почему так? Мы так много кода писали для простейших действий, а возвращаемое значение попадает в систему без каких либо операций вообще». Дело в том, что по правилам x86_64 ABI первый параметр, указанный в функции, записывается в регистр RDI. А системная функция exit, 60/3Ch возвращает в систему в качестве кода выхода значение RDI. Так совпало — наше значение «провалилось» насквозь и попало в оболочку, в переменную $? и писать нам для этого действительно ничего не пришлось.

P.S.
Интересно отметить тот факт, что отлаживать участки программы, представленные в виде переменных и сформированные в качестве их содержания, будет проблематично. Это связано с тем, что этот код попадает в секцию .data, которая отладчиком не рассматривается как код. Даже если посмотреть ассемблерный вывод после компилятора, то мы увидим просто переменную, в которой будут числовые значения — перечень наших байт в объявленном массиве.

        .size   lMagicCode, 7
lMagicCode:
        .byte   -72
        .byte   60
        .byte   0
        .byte   0
        .byte   0
        .byte   15
        .byte   5

Ниже приведу всю программу:

#include <stdio.h>
#include <stdint.h>
#include <unistd.h>
#include <sys/mman.h>

int main (void)
{
  uint8_t lMagicCode [] =
  {
//     0xB8, 0x3C, 0, 0, 0,          // movl $0x3C, %eax # 3Ch/60 - system call exit()
//     0x0F, 0x05,                   // syscall
    0xB8, 0x07, 0x00, 0x00, 0x00, // movl $0x7, %eax
    0xC3,                         // retn
  };
  
  void* lPageBoundary = (void*) ((long) lMagicCode & ~(getpagesize () - 1));
  
  mprotect (lPageBoundary, 1, PROT_EXEC | PROT_WRITE);
  
  unsigned int (*NewExit)(unsigned long _ExitCode) = (void*)lMagicCode;
  
  unsigned long lResult = NewExit (2);
  
  printf ("The result of NewExit is: %d. Or will never be printed...\n", NewExit (0));
  
  return 0;
}

08/09/2021

Маленький взлом системы (1): наконец-то вы сможете изменять строки типа char* String

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

char* String = "Hello, World!";

Несмотря на то, что эта операция выглядит абсолютно логично и мы ожидаем от неё весьма конкретного и несложного результата, пытаясь сделать что-то вроде

String[1] = 'a';

мы получаем ошибку сегментации (Segmentation fault и сигнал SIGSEGV). И, как правило, на этом вся разработка заканчивается.

Ни в коем случае не пытайтесь это сделать — ваш процессор немедленно сгорит и все несохранённые данные будут потеряны!

Ошибка сегментации возникает в случае, когда процесс «лезет» в недоступную ему область памяти с целью записи (или чтения, см. ниже). А что это за область памяти такая, в которую мы не можем писать? В нашем примере, это область в сегменте данных, помеченная как память только для чтения — RO DATA. Посмотрим что получается в коде программы, в которой объявлена строка char* String. Из программы типа:

int main ()
{
  char* lString = "Hello, World!";
}

мы получим:

    .section   .rodata
.LC0:
    .string "Hello, World!"

Конечно, можно просто заменить .section .rodata на .section .data, но каждый раз вмешиваться в сборку проекта на не самом удобном этапе — трансляции, мы предлагать не будем.

Я решил рассмотреть функцию POSIX — mprotect(). Функция изменяет условия доступа к области памяти.

Но с этой функцией связана одна ошибка. В документации — как в man, так и в интернете, указано, что mprotect() помечает память начиная от адреса страницы до адреса страницы + длинна в байтах за вычетом единицы. В интернете я случайно нашёл упоминание, что длинна здесь указывается в количестве страниц. Что логично, так как MMU работает со страницами, а не с байтами. На нашем примере я подтвердил это — указывая в качестве длинны единицу я пометил достаточное количество памяти для всех наших переменных, то есть, видимо, mprotect() действительно берёт длину в количестве страниц.

Принимает функция на вход адрес, длину и флаг. В нашем примере нас будет интересовать, флаг и, как ни странно, адрес. Флаг нам нужен PROT_WRITE. А с адресом всё чуть более интересно. Так как MMU работает со страницами, начальный адрес, должен быть кратным размеру страницы. С тем, чтобы получить нужный нам адрес, мы вычислим ближайшую (в меньшую сторону) к интересующей нас переменной границу страницы памяти. Сделаем это мы следующим образом. Запросим размер страницы, сбросим у адреса переменной все правые биты, совпадающие с размером страницы. Проще говоря — обнулим адрес переменной справа на величину размера страницы.

Например: адрес lString равен 555555556004h, размер страницы получаем от системы, в моём случае он оказался равен 4096. Из размера страницы уберём единицу, так как фактически она адресуется с 0 по 4095, получим FFFh. Видно, что страницы с таким размером кратны трём полубайтам или полутора байтам. В нашем примере адрес ближайшей к переменной странице равен 555555556000h. Чтобы вычислить этот, адрес нужно сбросить крайние полтора байта адреса переменной. Инвертировав размер страницы получаем маску для логической операции FFFFFFFFFFFFF000h. Приведём это к типу void*, что даст нам полный размер адреса в памяти для любой архитектуры. В результате получим следующие результаты подготовки:

void* lPageBoundary = (void*) ((long) lStr1 & ~(getpagesize () - 1));
Длину, для нашего эксперимента мы поставим равной одной странице. Размер страницы позволит нам поиграть не только с записью в запретные места, но и с границами переменных.
mprotect (lPageBoundary, 1, PROT_WRITE);

Всё. С этого момента начинаются невиданные до сих пор чудеса. Например, следующая программа выводит «World!», а не столь неприятный и уже надоевший «Segmentation fault»:

#include "string.h"
#include "unistd.h"
#include "stdio.h"
#include "sys/mman.h"

int main ()
{
  char *lStr1 = "Hello";
  char *lStr2 = " orld!";
  void* lPageBoundary = (void*) ((long) lStr1 & ~(getpagesize () - 1));

  // Comment next line to turn magic off:
  mprotect (lPageBoundary, 1, PROT_WRITE);
  lStr2[0] = 'W';
  printf ("%s\n", lStr2);
}

Но я предлагаю пройти дальше и ещё чуть-чуть поиграть с адресами. Как вы можете видеть, я объявил две переменные — lStr1 и lStr2. char* — это переменная типа asciz или LPSZ (Long Pointer to Zero Terminated String) или, по-русски — строка оконченная нулём. Как известно, функции, работающие со строками, определяют их длину и окончание по этому самому нулю. Давайте проверим, что будет, если вместо оконечного нуля lStr1 поставить пробел и вывести эту строку при помощи printf(). Здесь же продемонстрируем что мы можем писать (изменять память) в пределах всей памяти модифицированной функцией mprotect(). Эксперимент с удалением последнего нуля из строки lStr1 я предлагаю реализовать путём записи по адресу строки lStr2 - 1:

lStr2[-1] = ' ';
printf ("%s\n", lStr1);

Так как строки расположены в памяти непосредственно одна за второй, получается, что я удалил завершающий нуль lStr1 и поставил не его место пробел. Соответственно, строка lStr1 перестала быть asciz и по логике, printf() должен «провалиться» дальше. Проверим это, получим вывод:

Hello World!

Всё верно, printf() прошёл до первого завершающего нуля, а им оказался завершающий нуль строки lStr2. Мы получили вывод «совмещённой» строки.

На самом деле это выглядит непривычно только на C. На ассемблере подобная работа с данными является повседневной нормой — например длинна строки (string или ascii/asciz) или массива может быть вычислена вычитанием адреса следующей за ней переменной из её собственного адреса.

Но и это ещё не всё. Возможно, к этому моменту, у вас в голове возникла мысль — «Если можно так, то, может, я смогу изменять значения переменных, объявленных как const»? Ответ положителен — эту давнюю мечту можно реализовать. Но с небольшим нюансом. Как мы знаем, компилятор нам не позволит писать в переменные, объявленные с модификатором const. Попытка сделать:

const char* lStr3 = "hello world!";
lStr3[0] = 'H';

приведёт к:

./mprotect.c:18:12: error: assignment of read-only location '*lStr3'
   lStr3[0] = 'H';
            ^

Для разрешения этой ситуации обманем компилятор следующим образом:

*((char*)lStr3 + 0) = 'H';

Мы взяли адрес от переменной, добавили к нему смещение и по нему прописали, что хотели. Теперь всё собирается и работает. За смещение здесь взят нуль, это не имеет технического смысла. Я написал здесь смещение для того чтобы в полной мере раскрыть форму записи доступа к переменным объявленным как const. По этой форме записи вы можете адресоваться на любой символ строки... и не только на него, и не только вперёд, так как мы «взяли» себе целую страницу.

NB
Вроде и очевидно, но считаю что я должен предупредить. Мы сняли запрет записи на область памяти, возможно вы снимете запрет на большую область памяти. Это значит, что вы можете модифицировать память без какого либо вообще контроля со стороны ОС, MMU и компилятора, так как мы полностью исключили этот функционал. Вы можете писать в эти области памяти, но, если вы хотите сохранить адекватную работоспособность своего кода, вам нужно ещё более тщательно следить за границами областей памяти изменяемых вами!

P.S
Возможно вы заметили, что мы использовали флаг доступа PROT_WRITE, без PROT_READ и, при этом мы читали данные из этих областей. Всё это было так потому что у MMU x86 (а этот эксперимент проводился на x86-ой машине) нет режима записи без чтения, поэтому можно поставить PROT_WRITE | PROT_READ, но смысла в этом нет. Если вы хотите поиграть с доступом, вы можете попробовать PROT_NONE. В этом случае вы не будете иметь никакого доступа к странице памяти и даже попытка чтения, например через printf(), приведёт к ошибке сегментации. Эту особенность можно было бы использовать каким-то образом на практике, но это затруднено тем, что мы можем помечать только целую страницу, а они бывают только 4Kb/2Mb/4Mb и 4Gb размером, в зависимости от архитектуры и/или режима работы. 4Kb — довольно много для использования механизмов доступа к памяти в качестве какой-нибудь «ловушки». Хотя, если программа достаточно большая, можно группировать флаги, на изменение которых в какие-то моменты мы хотим реагировать, в блоки по 4Kb и в обработчике сигнала реализовать логику реакции на сработавшее исключение защиты.