Знание - сила, но добро или зло зависит исключительно от намерения × _ ×

В отличие от предыдущей серии статей Разработка вредоносного ПО (где мы сосредоточились на стратегиях заражения дисков на платформах Linux), с этого момента мы планируем свои намерения, рассматривая память как игровую площадку. Если все сделано правильно, это может обеспечить гораздо лучшую скрытность, чем заражение дисков. Это вводная статья из этой серии, которая не фокусируется на каком-либо конкретном методе, используемом вредоносными программами, а служит фундаментальной базой, с которой вы сможете самостоятельно начать исследование вредоносных программ. Эта серия статей разделена на следующие части, начиная с предварительных требований и заканчивая описанием методов заражения памяти для систем Linux:

  • Часть 0x1 - Введение в двоичный мир
  • Часть 0x2 - ориентирована на создание руткитов пользовательского уровня с помощью техники LD_PRELOAD.
  • Часть 0x3 - основное внимание уделяется созданию простого инжектора памяти, чтобы прояснить идею внедрения процесса в Linux.

ПРИМЕЧАНИЕ. Темы, затронутые в этой статье, ни в коем случае не являются исчерпывающим списком предпосылок для данной области исследования.

Предпосылки

  • Бесконечное любопытство и никогда не отказываться от отношения больше всего на свете.
  • Что ж, некоторые части этой статьи предполагают базовое понимание формата файла ELF. Для более подробной информации вы можете прочитать Спецификации ELF.

[Исходный код]: ЦП, ты меня когда-нибудь чувствовал?

Для большинства людей, имеющих дело с кодом, работа заканчивается написанием исходного кода, получением правильного вывода, вечеринкой как монстр и сном, как младенцем. Но теперь, когда мы вступаем в мир вредоносных программ, важно отметить, что исходный код - это не что иное, как символы в кодировке ASCII, хранящиеся вместе в файле. Это абсолютно несущественно для машины, пока не будет преобразовано во что-то, что может понять сам ЦП (двоичный код). У каждого процессора есть свой собственный уникальный язык, известный как ассемблер (который состоит из набора инструкций), который можно научить взаимодействовать напрямую с ЦП. Ниже приведен пример исходного кода вместе с инструкциями по сборке (синий выделен в синтаксисе INTEL) и необработанными байтами кода операции ( зеленый подсвечивает, что имеет смысл для ЦП)

Например: такой простой оператор, как int a = 5;, как показано выше (выделен оранжевым), не имеет никакого смысла для ЦП, пока не будет преобразованы в c7 45 fc 05 00 00 00 (необработанные байты выделены розовым цветом), которые при передаче в ЦП Intel x86–64 будут интерпретироваться как mov DWORD PTR [rbp-0x4],0x5 инструкция (имеющая эффект сохранения 5 в ячейке памяти[rbp-0x4], т.е. в ячейке переменной a).

Мы планируем каким-то образом внедрить наши намерения (выделены зеленым) в память и заставить их выполнять ЦП. Позже в этой серии мы увидим, как написать код, подходящий для внедрения в память.

Почему мне это нужно знать?
Представьте, что индуистский бог / богиня появляется прямо перед вами (после многих лет медитации в Гималаях), и вы забыли выучить санскрит.

От исходного кода к процессу и не только!

Что ж, заставить ЦП что-то чувствовать - довольно сложная задача. Теперь, по крайней мере, мы знаем, что исходный код необходимо преобразовать в двоичную программу для выполнения. Но как на самом деле происходит этот процесс преобразования?

Компиляция

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

  • Предварительная обработка - на этом этапе обрабатываются все директивы включения, инструкции условной компиляции и определения макросов, после чего создается предварительно обработанный вывод. . Используйте флаг -E с GCC для генерации предварительно обработанного вывода.
  • Компиляция - этот этап (со странным названием) принимает предварительно обработанный исходный код в качестве входных данных и генерирует исходный файл на языке ассемблера (зависящий от архитектуры процессора, для которого он компилируется) в качестве выходных данных (* .s ). Здесь константа и переменные сохранят свои имена, т. Е. их символические имена сохранены. Используйте флаг-S -masm=intel с GCC, чтобы сгенерировать улучшенный (синтаксис Intel) вывод исходного кода сборки.
  • Сборка. На этом этапе ассемблер принимает исходный код сборки (из предыдущего этапа) и преобразует его в объектный код (машинный код), то есть файл, состоящий из необработанных байтов ( зеленым светом, показанным выше). Созданный объектный файл известен как перемещаемый двоичный файл (* .o). Вывод будет содержать всю информацию о перемещении (более подробно объяснено ниже. ) и без символов / ссылок еще не разрешены. Используйте флаг -c с GCC для создания перемещаемого объектного кода в качестве вывода.
  • Редактирование ссылок. На самом деле, перемещаемые двоичные файлы не имеют адреса точки входа, поскольку объектный код необходимо связать с некоторым дополнительным кодом (библиотеками) перед ним. может быть выполнен. Этот этап отвечает за связывание во время компиляции (/usr/bin/ld - GNU Linker), которое включает связывание одного или нескольких объектных файлов (* .o) в одну двоичную программу (исполняемый файл или общий объект). При связывании во время компиляции имена функций (символы кода ), имена переменных и констант (символы данных). Однако не все символы разрешены, разрешение внешних символов (динамических символов, таких как имена функций общей библиотеки) остается на уровне выполнения, также известном как динамический компоновщик или программный интерпретатор (о котором мы поговорим в ближайшее время). Используйте флаг -o с GCC, чтобы связать перемещаемые объекты в единую (готовую к выполнению) двоичную программу.

Давайте создадим файл с именем backbencher.c, содержащий приведенное ниже содержимое.

#include <stdio.h>
  
int main(void)
{
    printf("hello hell x_x");
    return 0;
}

Теперь, чтобы скомпилировать этот файл в двоичный исполняемый файл, введите следующую команду в командной строке - gcc backbencher.c -o backbencher. Здесь gcc (компилятор GNU C) выполнит все 4 этапа, описанные выше, и сгенерирует окончательный двоичный файл с именем backbencher.

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

Любимая программа Backbencher

Завершая кропотливую работу, проделанную компилятором, мы получаем готовую к выполнению ELF двоичную программу, состоящую из инструкций, данных и метаданные (для организации инструкций и данных) в кратчайшие сроки. За исключением первых 16 байтов (поле e_ident [] заголовка ELF), вся информация представлена ​​в виде байтов в кодировке, зависящей от процессора. Теперь за выполнение этой программы ЦП отвечает операционная система. Тем не менее, существует критерий приемлемости, а именно: для того, чтобы любая программа выполнялась центральным процессором, она должна находиться в основной памяти (ОЗУ). Поэтому программа должна быть перенесена с диска в основную память, чтобы иметь право на выполнение.

Предполагая, что мы находимся на платформе Linux и используем / bin / bash в качестве нашей оболочки для выполнения команд. Используя /usr/bin/strace, мы можем отследить все системные вызовы, выданные backbencher, который является двоичным ELF-файлом общего объекта (см. Выделение оранжевого цвета ниже) .

Загрузка, подождите… x_x

  • Когда мы вводим команду ./backbencher для запуска программы, оболочка выполняет системный вызов execve () (известный как загрузчик, который является частью ядра). Ядро находит сегмент PT_INTERP вызванной программы и создает начальный образ процесса из сегмента файла интерпретатора программы. В этом случае интерпретатор программы должен отобразить вызываемый двоичный файл - ./backbencher с диска в память путем анализа таблицы заголовков программы вызываемого двоичного файла. Этот процесс загрузки программы с диска в память известен как загрузка. (См. Выделение синим выше)
  • ПРИМЕЧАНИЕ. Расположение адресного пространства процесса для ./backbencher программы определяется ее таблицей заголовков программы (поскольку PHT является компонентом, который отвечает за предоставление представления выполнения программы ELF двоичный)
  • После того, как программа (код и данные) загружена в память, ядро ​​сопоставляет среду выполнения / динамический компоновщик, также известный как программный интерпретатор (представленный как общий объект / .so) и передает ему управление, передавая достаточную информацию (argc, argv, envp и auxv, помещенные в сегмент стека), чтобы продолжить дальнейшее выполнение двоичного файла приложения.

Перехват выполнения динамическим компоновщиком

Бинарные спецификации ELF позволяют нам указать программный интерпретатор (также известный как динамический компоновщик), который должен знать двоичный формат ELF, а также выполнить поток выполнения после того, как загрузчик завершит свою работу. Путь к интерпретатору программы хранится в разделе .interp (двоичного файла ELF на диске), который отображается под первым загружаемым сегментом, имеющим запись PHT типа PT_INTERP. Обычно динамический компоновщик присутствует по адресу - /lib64/ld-linux-x86–64.so.2, и его не следует путать с /usr/bin/ld (это программа, используемая для компоновки во время компиляции ) оба имеют дело с управлением символами. Глядя на вывод strace, после execve() системного вызова динамический компоновщик выполняет следующие действия:

  • Сначала он выполняет собственное перемещение (не забывайте, что сам динамический компоновщик является общим объектом).
  • Затем он выполняет access(“/etc/ld.so.preload”, R_OK) системный вызов, проверяя его способность читать (R_OK) файл «/etc/ld.so.preload». Этот файл содержит список общих объектов ELF, которые должны быть загружены перед любой другой зависимостью / разделяемой библиотекой. (См. Выделение красным).
  • Теперь динамический компоновщик рекурсивно выполняет разрешение зависимостей. Он ищет динамический сегмент программы, чтобы найти и построить список всех записей DT_NEEDED (зависимости общих объектов), которые он рекурсивно загружает в адресное пространство процесса, после чего выполняет перемещение. на загруженных зависимостях.
    Начиная с системного вызова openat () - см. выделение серого цвета выше, где libc .so.6 (стандартная библиотека C .so) открывается и в последующих системных вызовах отображается динамическим компоновщиком в адресное пространство процесса backbencher.

Информация о зависимостях представлена ​​в . динамическом разделе двоичного файла и помечена как DT_NEEDED (значение d_tag ​​ поле в struct ElfN_Dyn - см. $ man 5 elf). В качестве альтернативы /usr/bin/ldd можно использовать для выяснения всех зависимостей программы (с дополнительным преимуществом устранения дубликатов).

Примечание: динамический компоновщик сверится с файлом /etc/ld.so.preload и загрузит указанные в нем общие объекты, прежде чем разрешить любую другую зависимость SO.

  • Затем он выполняет перемещения в памяти вызванного процесса с помощью записей перемещения.
  • Выполняет перемещение в последнюю минуту - отложенная загрузка, ленивая загрузка или ленивая привязка. Как обсуждалось на 4-м этапе процесса компиляции (компоновщик), некоторые функциональные символы остались неразрешенными на этапе компоновки во время компиляции, которые включают символы, внешние по отношению к двоичному файлу (например, символы из общей библиотеки / .so). Эти external function symbol resolution откладываются до первого вызова функции.
    Например
    : любая ссылка на printf () не будет разрешена / исправлена ​​до первого когда она вызывается программой, поскольку это имя функции (предоставленное libc.so) является символом внешним по отношению к двоичному файлу. Такие символы также известны как динамические символы ( присутствующие в разделе .dynsym). Динамический раздел присутствует только в динамически связанных двоичных файлах ELF.

Вы можете использовать readelf для просмотра динамических символов любого двоичного файла ELF $ readelf — dyn-syms /program. printf, являющийся динамическим символом, выделен ниже зеленым.

ПРИМЕЧАНИЕ. Часть Задержка загрузки не отображается в выводе strace и представлена ​​в этом документе, потому что она используется как вирусом, так и эксплойты повреждения памяти (позже мы увидим, как) для управления поведением программы во время выполнения. С такой любовью и вниманием, которые он получает от нападающих, к нему следует относиться с уважением.

  • Внизу вывода strace write(1, “hello hell x_x”, 14) = 14 (пурпурный выделение в выводе strace) - это фактическая функциональность программы, а в исходном коде мы используется printf(“hello hell x_x”);, который внутренне выполняет write() системный вызов, записывающий 14 символьную строку в файловый дескриптор 1 (STDOUT).
  • Наконец, программа, соответствующая оператору return 0; в исходном коде C и процесс умирает изящно.

Подведение итогов!

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

ПОСЛЕСЛОВИЕ

На этом мы завершаем некоторые подробности жизненного цикла двоичного кода. Знание формата двоичного файла (ELF для Linux или PE для Windows) - это ключ к пониманию существующих и обнаружению новых атак векторы. Понимать методы и приемы, используемые вредоносными программами, относительно легче тому, у кого есть прочная основа. В следующей статье мы сделаем первый шаг в мир руткитов пользовательского пространства, обсудив самый тривиальный, но достаточно эффективный метод, используемый вредоносным ПО для Linux :)

Ура,
Абхинав Тхакур
(он же compilepeace)

Подключитесь к Linkedin