В своё время один из клиентов сообщил нам, что на Itanium его программа завершалась аварийно.
Постойте, не закрывайте статью!
На Itanium клиент выявил проблему, но она свойственна и всем остальным архитектурам, так что продолжайте чтение.
Код выглядел примерно так:
struct REMOTE_THREAD_INFO
{
int data1;
int data2;
int data3;
};
static DWORD CALLBACK RemoteThreadProc(REMOTE_THREAD_INFO* info)
{
try {
... use the info to do something ...
} catch (...) {
... ignore all exceptions ...
}
return 0;
}
static void EndOfRemoteThreadProc()
{
}
// Error checking elided for expository purposes
void DoSomethingCrazy()
{
// Calculate the number of code bytes.
SIZE_T functionSize = (BYTE*)EndOfRemoteThreadProc - (BYTE*)RemoteThreadProc;
// Allocate memory in the remote process
SIZE_T allocSize = sizeof(REMOTE_THREAD_INFO) + functionSize;
REMOTE_THREAD_INFO* buffer = (REMOTE_THREAD_INFO*)
VirtualAllocEx(targetProcess, NULL, allocSize, MEM_COMMIT,
PAGE_EXECUTE_READWRITE);
// Write data to the remote process
REMOTE_THREAD_INFO localInfo = { ... };
WriteProcessMemory(targetProcess, buffer,
&localInfo, sizeof(localInfo));
// Write code to the remote process
WriteProcessMemory(targetProcess, buffer + 1,
(void*)RemoteThreadProc, functionSize);
// Execute it!
CreateRemoteThread(targetProcess, NULL, 0,
(LPTHREAD_START_ROUTINE)(buffer + 1),
buffer);
}
Этот код настолько плох, что я специально добавил в него ошибки, чтобы он даже не компилировался.
Смысл заключался в том, что клиент хотел внедрить некий код в целевой процесс, поэтому использовал
VirtualAlloc
для выделения памяти под этот процесс. Первая часть блока данных содержала какие-то данные, которые нужно было передать. Вторая часть блока данных содержала байты кода, которые нужно было исполнить, и клиент запускал эти байты кода при помощи CreateRemoteThread
.Скажу прямо: сама идея, на которой построен этот код, фундаментально неверна.
Клиент сообщил, что этот код «отлично работал на 32-битных x86 и 64-битных x86», но не работает на Itanium.
На самом деле, я удивлён, что он работал даже на x86!
Структура программы подразумевает, что весь код в
RemoteThreadProc
не зависит от позиции. Требование независимости сгенерированного кода от позиции отсутствует. Например, один из вариантов генерации кода для операторов switch
заключается в использовании таблицы переходов, и эта таблица состоит из абсолютных адресов x86.На самом деле, очевидно, что код не является независимым от позиции, потому что в нём используется обработка исключений C++, а в реализации обработки исключений компилятора Microsoft используется таблица, сопоставляющая точки исполнения с операторами
catch
, чтобы было понятно, какой оператор catch
использовать. И если бы использовался catch
с фильтрацией, то существовали бы дополнительные таблицы для определения того, применяется ли фильтр catch
к выданному исключению.Также структура подразумевает, что код не содержит ссылок ни на что за пределами самого тела функции. Все таблицы переходов и поиска, используемые функцией, должны копироваться в целевой процесс, а код подразумевает, что эти таблицы находятся между метками
EndOfRemoteThreadProc
и RemoteThreadProc
.Но мы знаем, что ссылки на содержимое за пределами тела функции будут присутствовать, потому что блок C++ try/catch вызывает функции в библиотеке C runtime support library.
И x86-64, и Itanium используют для обработки исключений коды раскрутки (unwind codes), а в целевом процессе отсутствуют попытки регистрации этих кодов.
Предполагаю, что клиенту повезло, и исключений не выдавалось, или, по крайней мере, они выдавались достаточно редко, чтобы это осталось незамеченным при тестировании.
Кроме того, нет гарантий того, что
EndOfRemoteThreadProc
будет размещена в памяти непосредственно после RemoteThreadProc
. На самом деле, нет даже гарантий того, что EndOfRemoteThreadProc
будет иметь отдельную сущность. Компоновщик может выполнить свёртывание COMDAT, при котором несколько идентичных функций соединяются в одну. Даже если отключить свёртывание COMDAT, то Profile-Guided Optimization переместит функции по отдельности и маловероятно, что они окажутся в одном месте.На самом деле, не существует даже требования, чтобы байты кода функции
RemoteThreadProc
вообще были смежными! Profile-Guided Optimization изменяет порядок базовых блоков и код одной функции может оказаться разбросанным по разным частям программы (это зависит от паттернов использования).И даже без Profile-Guided Optimization оптимизация этапа компиляции может встроить часть функции или функцию целиком, поэтому одна функция может иметь множество копий в памяти, каждая из которых была оптимизирована под свою конкретную точку вызова.
Также существуют особые правила для Itanium, гарантировано обеспечивающие аварийное завершение на Itanium.
У процессоров Itanium все команды должны быть выровнены по 16-байтным границам, но приведённый выше код не соответствует этому требованию. Кроме того, на Itanium указатели функций указывают не на первый байт кода, а на
структуру дескриптора, содержащую пару указателей: один на gp
функции, второй на первый байт кода. (Тот же паттерн используется в PowerPC.)Я сообщил представителю клиента, что написанное им пытается проделать очень подозрительные действия и походит на вирус. Представитель клиента объяснил, что всё наоборот: клиент является поставщиком популярного антивирусного ПО! В продукте клиента есть важная функциональность, которая реализована на основе этой техники удалённого инъектирования кода, и на данном этапе они не могут от неё отказаться.
Теперь я уже был напуган.
Более безопасным1 способом инъектирования кода в процесс была бы загрузка кода в качестве библиотеки при помощи
LoadLibrary
. Она бы вызвала загрузчик, который бы проделал всю работу по реализации необходимых исправлений, правильно бы распределил память с корректным выравниванием, регистрацией защиты потока управления и таблиц раскрутки исключений, загрузил бы зависимые библиотеки и в целом правильно подготовил среду выполнения для запуска нужного кода.С тех пор от этого клиента не поступало никаких известий.
1 Я не сказал, что это безопасный способ инъектирования кода. Он всего лишь более безопасный.
Комментарии (26)
rstepanov
30.12.2021 13:11+8клиент является поставщиком популярного антивирусного ПО!
Это несколько проясняет ситуацию почему при установке антивирусов часто появляются совершенно необъяснимые проблемы, которых не было раньше. Жаль, что не раскрыто больше подробностей о том, кто, как и для чего использовал такие интересные техники программирования.
zvszvs
30.12.2021 14:07+41 Я не сказал, что это безопасный способ инъектирования кода. Он всего лишь более безопасный.
Можно ли хоть какой-то способ инъектирования кода считать безопасным?
Риторический вопрос...bogolt
30.12.2021 14:13+7современное инъектирование кода:
1. скачать гугл хром
2. выполнить в нем свой жс
grishkaa
31.12.2021 02:36Можно ли хоть какой-то способ инъектирования кода считать безопасным?
В macOS можно подгрузить свою динамическую библиотеку в чужой процесс и подменить реализацию какого-нибудь метода в нём на свою через objc runtime. Выглядит достаточно безопасно, как по мне.zvszvs
31.12.2021 13:44+2Тут скорее филосовский вопрос. "Инъекция кода" подразумевает добавление кода в процесс, который этого не ожидает. Можно ли считать безопасным процесс, который затрагивает двоих, но проинформирован о нем только один.
Аналогия с людьми вообще прекрасная выходит. )m0tral
31.12.2021 18:10+1Да вообще дичь конечно, особенно в виде копирования кода метода, не, ну в embedded такое ещё нормально, ну плюс все очень сильно зависит от настроек компилятора и линкера, но там ты контролируешь распределение памяти, а тут реально стрёмно.
DCNick3
31.12.2021 19:21Ну вообще так вполне можно написать. Просто собрать отдельный испольняемый файл, position-independent, в каком-нибудь flat binary формате, подключить его как ресурс и будет счастье. А так получается да, выстрел в ногу
ifilonov
30.12.2021 14:10Смахивает на код из detours, библиотеки и для перехвата функций внутри процесса и встраивания своего кода в чужие процессы. Кстати, если мне память не изменяет, библиотеки от microsoft.
KanuTaH
30.12.2021 15:08+2Скажем так - в Detours есть возможность копировать в целевой процесс некие данные, и при большом желании наверное можно написать что-нибудь вроде
DetourAttach(..., DetourCopyPayloadToProcessEx(...))
, но это вовсе не означает, что так и надо делать :) По-хорошему штатное использование как раз-таки предусматривает загрузку кода через dll injection (черезDetourCreateProcessWithDlls()
или вручную), а там ужеDllMain
через Detours API сделает все что надо (а именно, немного пропатчит первые несколько байт целевой функции, заменив их на вызов соответствующей функции из загруженной dll'ки).
realimba
30.12.2021 17:49+6Да ладно, вполне себе рабочий и распространенный в узких кругах прием (если делать правильно), например, похожим образом пропихивают код из юзермода в ядро чтобы не переподписывать драйвер. Другой вопрос, насколько конструкция стабильна и безопасна (кого это парит в нашей реальности)?
PS. А в том что все "поставщики популярного антивирусного ПО" говноделы никто не сомневался..
firehacker
30.12.2021 21:19+2PS. А в том что все "поставщики популярного антивирусного ПО" говноделы никто не сомневался..
Внедрять CPP-код — это нечто.
ramzes2
30.12.2021 23:03+4А не подскажите, какие драйвера так делают? Это прям супер дыра в безопасности!
firehacker
30.12.2021 20:57+1Уважаю Рэймонда Чена, но в данном случае он напускает жуть там, где не нужно.
Кроме того, нет гарантий того, что EndOfRemoteThreadProc будет размещена в памяти непосредственно после RemoteThreadProc
Есть такая гарантия: компилятор генерирует сущности машинного кода в строгом соответствии с тем, кем они фигурировали в исходном файле.
На самом деле, нет даже гарантий того, что EndOfRemoteThreadProc будет иметь отдельную сущность. Компоновщик может выполнить свёртывание COMDAT, при котором несколько идентичных функций соединяются в одну.
И действительно, но COMDAT folding выполняется линкером и только тогда, когда указана опция /OPT:ICF. Уже при /OPT:REF линкер может только выкидывать сущности, которые нигде не referenced, но не сливать (merge) их и не переставлять по своему усмотрению. Но у нас к RemoteThreadProc и EndOfRemoteThreadProc точно есть обращения, так что выкинуты они не будут. Казалось бы, я хочу посоветовать отключить COMDAT Folding, чтобы гарантировать работоспособность кода? Но тогда мы потеряем неплохую оптимизацию в пределах всего бинарника.
На самом деле, всё гораздо проще и лучше. Не надо глобально отключать COMDAT folding в процессе линковки. Чтобы COMDAT folding мог выкидывать или медждить ненужные или дублирующиеся куски объектных файлов, сам объектный файл должен быть сгененирован компилятором с использованием так называемого function-level linking (ключ /Gy компилятора), при котором каждая функция, каждая vftable, каждая переменная помещаеются в OBJ-файл упакованными в отдельную COMDAT-секцию.
Но если не указывать ключ /Gy и не использовать function-level linking, а мы можем сделать это для одного отдельно взятого исходного файла, то компилятор всю начинку, например, все процедуры положить в одну монолитную секцию .text. И линкер с такой монолитной секцией (где сразу куча процедур) ничего не сможет и не будет делать: они либо откинет всю секцию целиком, если ни на одну сущность из неё нигде больше нет ссылок, либо всю секцию целиком включит в состав выходного исполняемого файла.
То есть внедряемую процедуру и процедуру конца маркера мы выносим в отдельный .c/.cpp файл и только этот отдельный файл компилируем с /Gy-, а все остальные можем по прежнему компилировать с /Gy и использовать /OPT:ICF при линковке.
Profile-Guided Optimization изменяет порядок базовых блоков и код одной функции может оказаться разбросанным по разным частям программы (это зависит от паттернов использования).
Может. А может и не может: нужно посмотреть, нет ли тонких настроек PGO, позволяющих не перекраивать отдельные фрагменты программы. Но есть кое что, что точно остановит PGO от «расколбашивания» нашей маленькой внедряемой процедуры по всему образу. PGO никогда не переставляет ничего между секциями. Вся хирургия и всё перекраивание происходит в пределах одной секции.
Поэтому волшебная #pragma alloc_text() с указанием секции «.inject» вынесла бы внедряемую и маркерную процедуру в отдельную секцию, и они бы гарантированно остались в пределах секции. Собственно, тогда и маркерная процедура не нужна: внеднять в чужой процесс целесообразно секцию целиком.
Тактика «внедряем секцию целиком» решила бы даже ранее упомянутые проблему перефрагментированности кода с помощью PGO, если бы мы её отдельно не решали.
Более того, она бы решила и проблему, которую Чен описал позже: проблему выравниваний для отдельных архитектур. Секция и вся начинка гарантированно имела бы правильное выравнивание, а выделяя под неё память в АП чужого процесса, мы бы выделяли правильной границы выравнивания (по другому VirtualAlloc и не может работать в принципе).И даже без Profile-Guided Optimization оптимизация этапа компиляции может встроить часть функции или функцию целиком, поэтому одна функция может иметь множество копий в памяти, каждая из которых была оптимизирована под свою конкретную точку вызова.
Может. Но инлайнинг можно отключить ключами при компилировании каждого отдельного исходного файла. И поместив внедряемый код в отдельный исходник, мы можем его скомпилировать так, чтобы инлайнинг был запрещён.
Более безопасным способом инъектирования кода в процесс была бы загрузка кода в качестве библиотеки при помощи LoadLibrary
Очевидно, что LoadLibrary слишком «громкий» способ внедрения в другой процесс для антивирусного ПО. Подозрительный процесс, вирусный или заражённый вирусом, может воспрепятствовать внедрению в себя со стороны подобным способом одним из множества способов.
grechnik
30.12.2021 21:29+6Есть такая гарантия: компилятор генерирует сущности машинного кода в строгом соответствии с тем, кем они фигурировали в исходном файле.
Да ну?
C:\temp>type main.c #include <stdio.h> extern void RemoteThreadProc(void); extern void EndOfRemoteThreadProc(void); int main() { printf("%p %p\n", &RemoteThreadProc, &EndOfRemoteThreadProc); return 0; } C:\temp>type inject.c #include <stdio.h> void RemoteThreadProc(void); void DoSomethingUseful(void); void EndOfRemoteThreadProc(void); void RemoteThreadProc(void) { printf("RemoteThreadProc\n"); DoSomethingUseful(); } void DoSomethingUseful(void) { printf("DoSomethingUseful\n"); } void EndOfRemoteThreadProc(void) { printf("EndOfRemoteThreadProc\n"); } C:\temp>cl /O2 main.c inject.c Оптимизирующий компилятор Microsoft (R) C/C++ версии 19.29.30138 для x86 (C) Корпорация Майкрософт (Microsoft Corporation). Все права защищены. main.c inject.c Создание кода... Microsoft (R) Incremental Linker Version 14.29.30138.0 Copyright (C) Microsoft Corporation. All rights reserved. /out:main.exe main.obj inject.obj C:\temp>main.exe 00901070 00901060
firehacker
30.12.2021 22:11-1Вы obj-файл дампите (dumpbin-ом, например) и смотрите какой там порядок следования сущностей, а не конечный результат, который на свет производится линкером.
grechnik
30.12.2021 22:13+3Как скажете:
C:\temp>dumpbin /disasm inject.obj Microsoft (R) COFF/PE Dumper Version 14.29.30138.0 Copyright (C) Microsoft Corporation. All rights reserved. Dump of file inject.obj File Type: COFF OBJECT _DoSomethingUseful: 00000000: 68 00 00 00 00 push offset ??_C@_0BD@GJCDPNPO@DoSomethingUseful?6@ 00000005: E8 00 00 00 00 call _printf 0000000A: 59 pop ecx 0000000B: C3 ret _EndOfRemoteThreadProc: 00000000: 68 00 00 00 00 push offset ??_C@_0BH@IKBAAHGF@EndOfRemoteThreadProc?6@ 00000005: E8 00 00 00 00 call _printf 0000000A: 59 pop ecx 0000000B: C3 ret _RemoteThreadProc: 00000000: 68 00 00 00 00 push offset ??_C@_0BC@EGMKFOBF@RemoteThreadProc?6@ 00000005: E8 00 00 00 00 call _printf 0000000A: 68 00 00 00 00 push offset ??_C@_0BD@GJCDPNPO@DoSomethingUseful?6@ 0000000F: E8 00 00 00 00 call _printf 00000014: 83 C4 08 add esp,8 00000017: C3 ret ___local_stdio_printf_options: 00000000: B8 00 00 00 00 mov eax,offset ?_OptionsStorage@?1??__local_stdio_printf_options@@9@9 00000005: C3 ret __vfprintf_l: 00000000: FF 74 24 10 push dword ptr [esp+10h] 00000004: FF 74 24 10 push dword ptr [esp+10h] 00000008: FF 74 24 10 push dword ptr [esp+10h] 0000000C: FF 74 24 10 push dword ptr [esp+10h] 00000010: E8 00 00 00 00 call ___local_stdio_printf_options 00000015: FF 70 04 push dword ptr [eax+4] 00000018: FF 30 push dword ptr [eax] 0000001A: E8 00 00 00 00 call ___stdio_common_vfprintf 0000001F: 83 C4 18 add esp,18h 00000022: C3 ret _printf: 00000000: 56 push esi 00000001: 8B 74 24 08 mov esi,dword ptr [esp+8] 00000005: 6A 01 push 1 00000007: E8 00 00 00 00 call ___acrt_iob_func 0000000C: 83 C4 04 add esp,4 0000000F: 8D 4C 24 0C lea ecx,[esp+0Ch] 00000013: 51 push ecx 00000014: 6A 00 push 0 00000016: 56 push esi 00000017: 50 push eax 00000018: E8 00 00 00 00 call ___local_stdio_printf_options 0000001D: FF 70 04 push dword ptr [eax+4] 00000020: FF 30 push dword ptr [eax] 00000022: E8 00 00 00 00 call ___stdio_common_vfprintf 00000027: 83 C4 18 add esp,18h 0000002A: 5E pop esi 0000002B: C3 ret Summary 90 .chks64 60 .debug$F 64 .debug$S 2F .drectve 3C .rdata 85 .text$mn
firehacker
30.12.2021 22:25С ключом /Gy- компилируйте, пожалуйста, чтобы говорить о порядке следования функций в пределах секции, а не о порядке вывода секций dumpbin-ом.
grechnik
30.12.2021 23:55+1С ключом /Gy- это ещё и от версии компилятора зависит. Вот, например, компилятор из Visual Studio 2010:
C:\temp\vs2010>cl /c /O2 /Gy- /I"E:\programs\compilers\Microsoft Visual Studio 10.0\VC\include" C:\temp\inject.c Microsoft (R) 32-bit C/C++ Optimizing Compiler Version 16.00.30319.01 for 80x86 Copyright (C) Microsoft Corporation. All rights reserved. inject.c C:\temp\vs2010>dumpbin /disasm inject.obj Microsoft (R) COFF/PE Dumper Version 10.00.30319.01 Copyright (C) Microsoft Corporation. All rights reserved. Dump of file inject.obj File Type: COFF OBJECT _DoSomethingUseful: 00000000: 68 00 00 00 00 push offset ??_C@_0BD@GJCDPNPO@DoSomethingUseful?6?$AA@ 00000005: E8 00 00 00 00 call _printf 0000000A: 59 pop ecx 0000000B: C3 ret 0000000C: CC int 3 0000000D: CC int 3 0000000E: CC int 3 0000000F: CC int 3 _EndOfRemoteThreadProc: 00000010: 68 00 00 00 00 push offset ??_C@_0BH@IKBAAHGF@EndOfRemoteThreadProc?6?$AA@ 00000015: E8 00 00 00 00 call _printf 0000001A: 59 pop ecx 0000001B: C3 ret 0000001C: CC int 3 0000001D: CC int 3 0000001E: CC int 3 0000001F: CC int 3 _RemoteThreadProc: 00000020: 68 00 00 00 00 push offset ??_C@_0BC@EGMKFOBF@RemoteThreadProc?6?$AA@ 00000025: E8 00 00 00 00 call _printf 0000002A: 68 00 00 00 00 push offset ??_C@_0BD@GJCDPNPO@DoSomethingUseful?6?$AA@ 0000002F: E8 00 00 00 00 call _printf 00000034: 83 C4 08 add esp,8 00000037: C3 ret Summary 30 .debug$F 6C .debug$S 2F .drectve 3C .rdata 38 .text
(тут, кстати, видно, что printf ещё была обычной внешней функцией). В VS2019 для конкретно этого примера ключ /Gy- дополнительно переупорядочивает функции в более ожидаемом порядке. Для более сложных примеров уже детально ковыряться надо (и перспектива получить ответ "ну конечно же никто не будет в столь системном коде столь творчески инстанцировать плюсовые шаблоны" не особенно вдохновляет), мне лень.
grechnik
31.12.2021 02:03UPD: с VS2019 забавнее, для конкретно этого примера /Gy- переупорядочивает в порядке первого объявления, а не реализации. Соответственно, если мы, как добропорядочные сишники, вынесли внешние объявления в отдельный inject.h (чтобы не нарваться на ситуацию "реализацию поменяли, объявления в других файлах .c поменять забыли")
void RemoteThreadProc(void); void EndOfRemoteThreadProc(void);
в inject.c поместили реализации всех вспомогательных функций между этими двумя
#include <stdio.h> #include "inject.h" void DoSomethingUsefulButNotInlinable(int n); void RemoteThreadProc(void) { printf("RemoteThreadProc\n"); DoSomethingUsefulButNotInlinable(42); } void DoSomethingUsefulButNotInlinable(int n) { if (n) DoSomethingUsefulButNotInlinable(n - 1); printf("DoSomethingUsefulButNotInlinable %d\n", n); } void EndOfRemoteThreadProc(void) { printf("EndOfRemoteThreadProc\n"); }
и рассчитываем, что в бинарнике они будут идти в таком же порядке и можно смело memcpy-ить всё это куда угодно... нас может спасти только инлайнинг всего подряд, потому что с /Gy- порядок этих функций будет RemoteThreadProc, EndOfRemoteThreadProc, DoSomethingUsefulButNotInlinable, а без него DoSomethingUsefulButNotInlinable, EndOfRemoteThreadProc, RemoteThreadProc (вряд ли стоит копипастить сюда третий ассемблерный листинг подряд только чтобы проиллюстрировать порядок; я бы дал ссылку на godbolt.org, но там управляющая система, судя по всему, не дизассемблирует бинарник, а просит листинг у компилятора через /Fa и не исключено, что ещё и переупорядочивает его потом).
grechnik
31.12.2021 02:42+2Очевидно, что LoadLibrary слишком «громкий» способ внедрения в другой процесс для антивирусного ПО. Подозрительный процесс, вирусный или заражённый вирусом, может воспрепятствовать внедрению в себя со стороны подобным способом одним из множества способов.
Вирусный процесс может запросто и от CreateRemoteThread защищаться (каждый новый поток, в том числе созданный удалённо, вызывает TLS callbacks и DllMain всех dll-ек, пока адрес грядущей передачи управления лежит себе на стеке), и каждые пять секунд сканировать всю свою память на предмет executable-страниц вне dll-ек (палевно, системные библиотеки себе такого не позволяют), и каждую секунду перечислять список потоков в своём процессе... Единственный выигрышный ход — не играть в эту игру и вообще не запускать свой код в контексте чужого процесса, всё остальное — противостояние щита и меча.
lorc
31.12.2021 17:04+3Есть такая гарантия: компилятор генерирует сущности машинного кода в строгом соответствии с тем, кем они фигурировали в исходном файле.
Неа. Единственная гарантия — это что видимое поведение кода будет соответствовать тому что написал программист. И то, если программист не допустил UB. Все остальные ваши размышления — они о поведении конкретной версии конкретного компилятора.
Тот же gcc даже с -O2 такую кашу из кода делает, что часто точных границ функций тупо не найти, особенно если в пределах одного файла есть несколько функций с похожими кусками (включая прологи и эпилоги).
v1000
Как раньше шутили про антивирус Касперского, что, учитывая как сильно он замедляет систему, он не особо отличается от вирусов, от который должен защищать. Еще и денег за лицензию требует.
mayorovp
Про антивирус Касперского шутили по-другому: замедление системы — это и есть основной способ защиты от вирусов. Им просто не хватает для работы ресурсов!