Blue Team vs Red Team: Как незаметно запустить зашифрованный бинарный ELF-файл в памяти

Blue Team vs Red Team: Как незаметно запустить зашифрованный бинарный ELF-файл в памяти
image

Автор: Red Timmy Security

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

Как реализовать эту схему

В теории все выглядит гладко, но как реализовать этот сценарий на практике? Мы с командой Red Timmy Security создали проект «golden frieza», представляющий собой коллекцию нескольких техник для шифрования/дешифрования бинарный файлов на лету. Пока мы еще не готовы показать проект полностью, но хотим в деталях рассмотреть один из методов, сопроводив рассуждения исходным кодом.

https://www.redtimmy.com/wp-content/uploads/2020/02/golden_freiza.png

Почему эта тема касается как пентестеров, так и специалистов отдела безопасности и расследования угроз? Давайте представим типичный сценарий, когда пентестер загружает на скомпрометированую машину утилиты навроде «procmon» или «mimikatz», но защитные комплексы не подают никаких сигналов. С другой стороны, рассмотрим эксплоит для расширения привилегий на основе уязвимости нулевого дня, который злоумышленник планирует использовать локально на только что взломанной системе, но не хочет, чтобы эксплоит был обнаружен, и проблема оказалась предана всеобщей огласке.

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

Перед началом повествования небольшая преамбула. Все примеры и код, которые можно найти на Github, предназначены для работы с бинарными файлами в формате ELF, однако нет никаких препятствий, чтобы не реализовать то же самое для формата Windows PE, если внести соответствующие изменения.

Что шифровать

Бинарный ELF-файл состоит из нескольких секций. В контексте шифрования нас больше всего интересует секция .text, где находятся инструкции, выполняемые CPU, когда интерпретатор помещает бинарный файл в память и передает управление процессору. Попросту говоря, секция .text содержит логику нашего приложения, которую мы хотим защитить от реверс-инжиниринга.

Алгоритм шифрования

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

Практическая реализация

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

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

  • Лаунчер, представляющий собой программу, которая принимает на входе динамическую ELF-библиотеку, дешифрует в памяти при помощи ключа и осуществляет запуск.

Переходим к детализации схемы шифрования. Нужно ли шифровать всю секцию .text или только отдельные функции, экспортируемые в ELF-модуле? Исходные код на рисунке ниже экспортирует функцию testalo() без аргументов. После компиляции мы хотим, чтобы дешифровка выполнялась только во время загрузки в память.

https://www.redtimmy.com/wp-content/uploads/2020/02/testalo_mod-1.png

Рисунок 1: Исходный код тестовой функции testalo()

Компилируем код как динамическую библиотеку:

$ gcc testalo_mod.c -o testalo_mod.so -shared -fPIC

Смотрим содержимое скомпилированной библиотеки при помощи утилиты readelf:

https://www.redtimmy.com/wp-content/uploads/2020/02/readelf_output1.png

Рисунок 2: Содержимое библиотеки testalo_mod.so

В нашем случае секция .text начинается по смещению 0x580(1408 байт от начала файла testalo_mod.so). Размер секции - 0x100 (256 байт). Что если мы заполним это пространство нулями и попробуем программно загрузить библиотеку в память? Окажется ли эта секция в памяти нашего процесса или интерпретатор будет выдавать ошибку? Поскольку во время шифрования создаются мусорные инструкции, заполнение секции .text нашего модуля нулями фактически эмулирует эту процедуру. Для заполнения секции нулями выполняем следующую команду:

$ dd if=/dev/zero of=testalo_mod.so seek=1408 bs=1 count=256 conv=notrunc
… а затем при помощи утилиты xxd проверяем, что секция .text полностью обнулена:
$ xxd testalo_mod.so
[...]
00000580: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000590: 0000 0000 0000 0000 0000 0000 0000 0000 ................
[...]
00000670: 0000 0000 0000 0000 0000 0000 0000 0000 ................
[...]
Для проверки получившейся логики нам понадобится код, показанный на рисунке ниже (файл dlopen_test.c), который пытается разместить модуль testalo_mod.so в адресном пространстве (строка 12), а затем, в случае успешного завершения этой операции, проверяет, что адрес символа testalo получен (строка 18) и выполняет саму функцию (строка 23).

Рисунок 3: Код для загрузки модуля и выполнения функции testalo()

Компилируем и запускаем файл dlopen_test.

$ gcc dlopen_test -o dlopen_test –ldl
$ ./dlopen_test
Segmentation fault (core dumped)
Во время выполнения, как только программа дошла до строки 12, произошел сбой. Почему? А потому, что даже если dlopen() в нашем приложении явно не вызывает ничего из testalo_mod.so, в этом модуле есть функции, вызываемые автоматически (например, frame_dummy()) во время инициализации. При выяснении этой проблемы нам поможет отладчик gdb.

https://www.redtimmy.com/wp-content/uploads/2020/02/gdb.jpg

Рисунок 4: Место, где произошел сбой

$ objdump -M intel -d testalo_mod.so

https://www.redtimmy.com/wp-content/uploads/2020/02/objdump1.jpg

Рисунок 5: Дизассемблированная секция .text

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

В этом случае можно попробовать зашифровать содержимое только функции testalo(), где располагается наша логика. Нам нужно перекомпилировать модуль testalo_mod.so и определить размер кода на основе информации о начале и конце функции, которую можно получить при помощи команды «objdump -M intel -d testalo_mod.so»:

https://www.redtimmy.com/wp-content/uploads/2020/02/readelf_output2.jpg

Рисунок 6: Начало и конец функции testalo()

Формула для расчета искомого значения выглядит так 0x680 – 0x65a = 0x26 = 38 байт.

Вновь перезаписываем библиотеку testalo_mod.so 38 байтами нулей от начала функции testalo(). На этот раз смещение 0x65a = 1626 от начала файла:

$ dd if=/dev/zero of=testalo_mod.so seek=1626 bs=1 count=38 conv=notrunc
Запускаем файл dlopen_test еще раз:
$ ./dlopen_test

Segmentation fault (core dumped)
Казалось бы, результат тот же самый. Однако более детальный анализ показывает, что сбой произошел по другой причине:

https://www.redtimmy.com/wp-content/uploads/2020/02/gdb2.jpg

Рисунок 7: Новое место сбоя приложения

В прошлый раз сбой произошел в строке 12 файла dlopen_test.c во время инициализации динамической библиотеки testalo_mod.so. В этот раз сбой произошел в строке 23, когда библиотека testalo_mod.so оказалась в памяти процесса правильным образом. Символ testalo() уже оказался преобразованным (строка 18), и сама функция выполнилась (строка 23), что послужило причиной сбоя. Естественно, инструкции бинарного файла являются некорректными, поскольку мы обнулили тот блок памяти. Хотя если бы мы поместили зашифрованные функции и выполнили бы расшифровку перед выполнением функции testalo(), сбой бы не произошел.

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

Первый прототип проекта

Рассмотрим практический пример, как расшифровать в памяти зашифрованную полезную нагрузку. Ранее упоминалось, что для успешной реализации требуется два компонента:

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

  • Лаунчер, представляющий собой программу, которая принимает на входе динамическую ELF-библиотеку, дешифрует в памяти при помощи ключа и осуществляет запуск.

Касаемо первого пункта мы будем продолжать использование библиотеки testalo_mod.so, но теперь с шифрованием только содержимого функции testalo(). Воспользуемся уже существующими инструментами dd и openssl:

$ dd if=./testalo_mod.so of=./text_section.txt skip=1626 bs=1 count=38

$ openssl rc4 -e -K 41414141414141414141414141414141 -in text_section.txt -out text_section.enc -nopad

$ dd if=./text_section.enc of=testalo_mod.so seek=1626 bs=1 count=38 conv=notrunc
Первая команда извлекает 38 байт бинарных инструкций функции testalo(). Вторая команда шифрует эти инструкции при помощи ключа AAAAAAAAAAAAAAAA (в шестнадцатеричном виде -> 41414141414141414141414141414141) по алгоритму RC4. Третья команда записывает обратно зашифрованный контент туда, где находится функция testalo(). Если сейчас посмотреть содержимое функции при помощи команды «objdump -M intel -d ./testalo_mod.so», логику работы понять уже намного сложнее:

Рисунок 8: Зашифрованная функция testalo()

Во-вторых, нам понадобится лаунчер. Начнем с подробного анализа кода лаунчера, написанного на C, который приведен на рисунке ниже. В начале происходит получение смещения в шестнадцатеричном формате, где размещается зашифрованная функция (информация, которую мы получали ранее при помощи утилиты readelf), и длины в байтах (строка 102). Затем в терминале отключается функция отображения вводимых данных (строки 116-125), чтобы пользователь мог безопасно ввести криптографический ключ (строка 128). В конце терминал возвращается в первоначальное состояние (строки 131-135).

https://www.redtimmy.com/wp-content/uploads/2020/02/launcher_codepiece1.jpg

Рисунок 9: Исходный код лаунчера

Теперь у нас есть смещение зашифрованной функции в памяти, но мы пока не знаем полного адреса. Эта информация находится в файле /proc/PID/maps, который обрабатывается при помощи кода, указанного ниже:

https://www.redtimmy.com/wp-content/uploads/2020/02/launcher_codepiece2.jpg

Рисунок 10: Код для обработки файла /proc/PID/maps

Далее происходит извлечение из памяти зашифрованных бинарных инструкций (строка 199), расшифровка при помощи ранее указанного RC4-ключа и записи обратно в то место, где находится содержимое функции testalo() (строка 213). Однако прежде нужно пометить страницу памяти на запись (строки 206-210), а затем вернуть обратно атрибуты только на чтение/выполнение (строки 218-222) после размещения расшифрованной полезной нагрузки. С целью защиты кода во время выполнения интерпретатор использует область памяти, которая недоступна для записи. Криптографический ключ также удаляется из памяти после использования (строка 214).

https://www.redtimmy.com/wp-content/uploads/2020/02/launcher_codepiece3.jpg

Рисунок 11: Извлечение зашифрованной полезной нагрузки и последующие операции

Теперь адрес дешифрованной функции testalo() может быть получен (строка 228) и соответствующие бинарные инструкции выполнены (строка 234).

https://www.redtimmy.com/wp-content/uploads/2020/02/launcher_codepiece4.jpg

Рисунок 12: Запуск расшифрованной функции testalo()

Первую версию исходного кода лаунчера можно скачать отсюда.

Выполняем компиляцию при помощи следующей команды:

$ gcc golden_frieza_launcher_v1.c -o golden_frieza_launcher_v1 -ldl
После компиляции запускаем и проверяем работоспособность (жирным выделены данные, вводимые пользователем):
$ ./golden_frieza_launcher_v1 ./testalo_mod.so
Enter offset and len in hex (0xXX): 0x65a 0x26
Offset is 1626 bytes
Len is 38 bytes
Enter key: <-- key is inserted here but not echoed back
PID is: 28527
Module name is: testalo_mod.so
7feb51c56000-7feb51c57000 r-xp 00000000 fd:01 7602195 /tmp/testalo_mod.so
Start address is: 0x7feb51c56000
End address is 0x7feb51c57000

Execution of .text
==================
Sucalo Sucalo oh oh!
oh oh Sucalo Sucalo!!

По результатам выполнения команды видим, что содержимое функции testalo(), расшифрованное в памяти, выполнилось успешно.

Однако…

У этого метода есть недостаток. Хотя наша библиотека будет удалена, символы функций, вызываемые testalo() (например, puts() и exit()), которые нуждаются в преобразовании и перемещении во время выполнения, остаются хорошо видимы. Если бинарный файл оказывается в распоряжении системного администратора или специалиста по безопасности, даже в случае шифрования секции .text, используя простейший статический анализ при помощи утилит навроде objdump и readelf можно догадаться о назначении кода. Рассмотрим более конкретный пример. Вместо простейшей библиотеки скомпилируем bindshell как ELF-модуль.

$ gcc testalo_bindshell.c –o testalo_bindshell.so –shared -fPIC

Мы обработали бинарный файл при помощи команды strip и зашифровали часть секции .text, как объяснялось ранее. Если сейчас взглянуть на таблицу символов (readelf –s testalo_bindshell.so) или таблицу перемещений (readelf –r testalo_bindshell.so), станет видно нечто похожее на рисунке ниже:

https://www.redtimmy.com/wp-content/uploads/2020/02/readelf_output3.jpg

Рисунок 13: Секция перемещений

По скриншоту выше сразу же понятно, что используются функции bind(), listen(), accept(), execl() и так далее, которые обычно импортируются при реализации bindshell. На основе этой информации тут же выявляется логика кода, что в нашем случае не очень удобно, и нужно найти обходной путь.

Функции dlopen и dlsyms

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

Например, обычный кусок кода с вызовом функции socket() выглядел бы примерно так:

#include
[...]
if((srv_sockfd = socket(PF_INET, SOCK_STREAM, 0)) < 0)
[...]

Когда бинарный файл скомпилирован и скомпонован, кусок кода выше отвечает за создание записи о функции socket() в таблицах перемещений и динамических символов. Однако, как было сказано выше, мы хотим обойти эту проблему. Соответственно, код должен быть преобразован следующим образом:

https://www.redtimmy.com/wp-content/uploads/2020/02/socket_dynamic_resolution-1.jpg

Рисунок 14: Альтернативный код с функцией socket()

Здесь функция dlopen() вызывается только один раз и функция dlsyms() вызывается для всех внешних функций, которые должны быть преобразованы. На практике эта концепция выглядит так:

  • int (*_socket)(int, int, int); -> определяем переменную с указателем функции с тем же прототипом, что и оригинальная функция socket().

Рисунок 15: Прототип функции socket()

  • handle = dlopen (NULL, RTLD_LAZY); -> 
    как говорится в документации: «если первый параметр – NULL, возвращаемый дескриптор - для основной программы»
  • _socket = dlsym(handle, "socket"); ->
    переменная _socket будет содержать адрес функции socket(), преобразованной во время выполнения dlsym().
  •   (*_socket)(PF_INET, SOCK_STREAM, 0) -> 
    используем эту конструкцию в качестве эквивалента строке «socket(PF_INET, SOCK_STREAM, 0)». Значение, на которое указывает переменная _socket, представляет собой адрес функции socket(), преобразованной при помощи dlsym().

Эта схема должна быть использована для всех внешних функций: bind(), listen(), accept(), execl() и так далее.

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

$ gcc testalo_bindshell_mod.c -shared -o testalo_bindshell_mod.so -fPIC

https://www.redtimmy.com/wp-content/uploads/2020/02/readelf_objdump_combined_cmd.jpg

Рисунок 16: Содержимое скомпилированной библиотеки, созданной в новом стиле

Теперь видны только символы функций dlopen() и dlsyms(), а использование других функций скрыто.

Этого достаточно?

У этого подхода тоже есть недостатки. Рассмотрим секцию данных .rodata с атрибутом «только чтение» в динамической ELF-библиотеке:

https://www.redtimmy.com/wp-content/uploads/2020/02/readelf_output4.jpg

Рисунок 17: Содержимое секции .rodata

Все строки, объявленные в модуле bindshell, отображаются в открытом виде внутри секции .rodata(начиная со смещения 0xaf5 и заканчивая смещением 0xbb5), содержащей все значения констант, объявленных в программе, написанной на C! Почему мы наблюдаем этой явление? Все зависит от способа передачи строковых параметров во внешние функции:

_socket = dlsym(handle, "socket");

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

	 gcc golden_frieza_launcher_v2.c -o golden_frieza_launcher_v2 -ldl

Рассмотрим, как работает новая схема.

Вначале шифруется секция .text модуля bindshell:

$ dd if=./testalo_bindshell_mod.so of=./text_section.txt skip=1738 bs=1 count=1055

$ openssl rc4 -e -K 41414141414141414141414141414141 -in text_section.txt -out text_section.enc –nopad

$ dd if=./text_section.enc of=./testalo_bindshell_mod.so seek=1738 bs=1 count=1055 conv=notrunc

Схожим образом шифруем секцию .rodata:

$ dd if=./testalo_bindshell_mod.so of=./rodata_section.txt skip=2805 bs=1 count=193

$ openssl rc4 -e -K 41414141414141414141414141414141 -in rodata_section.txt -out rodata_section.enc -nopad

$ dd if=./rodata_section.enc of=./testalo_bindshell_mod.so seek=2805 bs=1 count=193 conv=notrunc

Затем запускается лаунчер, который в качестве параметра принимает на входе имя файла модуля с зашифрованными секциями .text и .rodata.

$ ./golden_frieza_launcher_v2 ./testalo_bindshell_mod.so

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

Enter .text offset and len in hex (0xXX): 0x6ca 0x41f
Offset is 1738 bytes
Len is 1055 bytes

В точности так же передается смещение и длина секции .rodata (тоже в виде шестнадцатеричных значений). Как видно на последнем скриншоте, где использовалась команда readelf, в этом случае секция начинается по смещению 0xaf5, а длина вычисляется по формуле 0xbb5 – 0xaf5 + 1 = 0xc1:

Enter .rodata offset and len in hex (0xXX): 0xaf5 0xc1
.rodata offset is 2805 bytes
.rodata len is 193 bytes

Затем лаунчер запрашивает параметр для командной строки. Наш модуль bindshell (в частности, экспортируемая функция testalo()) принимает на входе один параметр, а именно – TCP-порт, на котором осуществляется прослушивание. Выбираем порт с номером 9000:

Enter cmdline: 9000
Cmdline is: 9000

Ключ шифрования (AAAAAAAAAAAAAAAA) вводится без отображения на экране:

Enter key:

Последняя часть выходных данных выглядит так:

PID is: 3915
Module name is: testalo_bindshell_mod.so
7f5d0942f000-7f5d09430000 r-xp 00000000 fd:01 7602214 /tmp/testalo_bindshell_mod.so
Start address is: 0x7f5d0942f000
End address is 0x7f5d09430000

Execution of .text
==================

В этот раз под сообщением «Execution of .text» мы ничего не видим, поскольку в нашем модуле bindshell не предусмотрен вывод какой-либо информации. Однако запуск произошел корректно и модуль работает в фоновом режиме:

 $ netstat -an | grep 9000
tcp 0 0 0.0.0.0:9000 0.0.0.0:* LISTEN

$ telnet localhost 9000
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
python -c 'import pty; pty.spawn("/bin/sh")'
$ id
uid=1000(cippalippa) gid=1000(cippalippa_group)

Последний трюк

Важный момент связан с отображением нашего модуля в списке процессов после запуска:

$ ps -wuax
[...]
./golden_frieza_launcher_v2 ./testalo_bindshell_mod.so
[...]

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

https://www.redtimmy.com/wp-content/uploads/2020/02/launcher_codepiece5.jpg

Рисунок 18: Код, предназначенный для маскировки процесса

Компилируем новую версию лаунчера:

$ gcc golden_frieza_launcher_v3.c -o golden_frieza_launcher_v3 -ldl

В этот раз помимо имени файла зашифрованной динамической библиотеки лаунчер принимает дополнительный параметр, представляющий собой имя, которое мы хотим назначить процессу. В нашем примере используется имя «initd]»:

$ ./golden_frieza_launcher_v3 ./testalo_bindshell_mod.so "[initd]"

Вначале с помощью утилиты netstat находим идентификатор процесса (предполагается, что bindshell работает на TCP порту с номером 9876):

		 $ netstat -tupan | grep 9876

 tcp 0 0 0.0.0.0:9876 0.0.0.0:* LISTEN 19087

А затем проверяем, что у процесса с нужным PID’ом имя, которое мы назначили:

$ ps -wuax | grep init
user 19087 0.0 0.0 8648 112 pts/5 S 19:56 0:00 [initd]

Теперь вы знаете, что никогда не следует доверять утилите ps!

Заключение

Что если некто обнаружит лаунчер и динамическую зашифрованную ELF-библиотеку в файловой системе? Ключ шифрования не известен, и, соответственно, никто не сможет расшифровать и выполнить нашу полезную нагрузку.

Что если смещение и длина зашифрованных секций введены некорректно? Скорее всего, произойдет сбой лаунчера. В это случае код также не будет известен.

Можно ли реализовать эту схему на Windows-машине? Функции LoadLibrary(), LoadModule() и GetProcAddress() делают то же самое, что и dlopen() и dlsyms().

На сегодня все.

Подписывайтесь на каналы "SecurityLab" в TelegramTelegram и TwitterTwitter, чтобы первыми узнавать о новостях и эксклюзивных материалах по информационной безопасности.