Внедрение кода в сторонний процесс.
Теоретическая и практическая реализация инъекции кода.
Одним из основных принципов работы ОС Windows является максимальная изоляция процессов друг от друга. С одной стороны это позитивно отражается на стабильности системы, так как вероятность того, что сбой в одном процессе отразится на остальных процессах, сводится к минимуму. С другой стороны, некоторые данные и функции Windows API становятся (выражаясь языком ООП) “приватными”, т.е. могут быть доступны только из “своего” процесса. Время от времени необходимо преодолеть границы процесса, и получить (или изменить) “приватные” данные в другом процессе. Для решения этой задачи и используется внедрение кода.
В этой статье будет детально рассмотрен метод внедрения кода основанный на прямом копировании исполняемого кода в сторонний процесс.
Существует несколько предвзятых мнений в отношении внедрения кода. Во-первых, считается, что он используется только хакерских атак и взлома. На самом деле хакеры им пользуются не так уж часто, а вот причин его использования в мирных целях гораздо больше, чем может показаться на первый взгляд. Вот лишь некоторые из них:
- создание подкласса окна, порожденного другим процессом;
- получение информации для отладки;
- установка ловушек в других процессах;
- ошибки на этапе проектирования, иногда их проще “залатать”, чем исправить.
Второе заблуждение заключается в том, что инъекция сильно влияет на стабильность “пациента”. Влияние, конечно, есть, но оно всецело определяется двумя факторами: расположением рук программиста и радиусом их кривизны. Если внедрять что попало и куда попало, ничего хорошего из этого не выйдет. Внедренный код исполняется в отдельном потоке и (при грамотном подходе) никак не мешает выполнению процесса.
Справедливости ради следует отметить, что внедрение кода достаточно экстремальный метод, поэтому в хорошо спроектированной программе не должно возникать необходимости его использования. Естественно, за исключением тех случаев, где без него не обойтись.
Теперь немного теории. Дж. Рихтер в своей книге “Windows via C/C++” описывает способ внедрения кода с помощью DLL. Способ имеет много полюсов, но минусы тоже имеются. Во-первых, DLL знает, куда ее внедряют, но не знает, кто ее внедряет. Что бы узнать инициатора внедрения придется писать дополнительный код. Во-вторых, DLL понятия не имеет, зачем ее внедряют, она просто загружается и инициализируется вызовом DllMain. А если необходимо реализовать несколько сценариев поведения? Тогда придется для каждого сценария писать свою DLL, либо реализовывать возможность передачи в DLL параметров. И, наконец, если внедряемый код занимает всего несколько строк, делать для него свою динамическую библиотеку не очень рационально.
Представленный способ, по сути, очень похож на вышеописанный. Отличие состоит в том, что код внедряется непосредственно в адресное пространство стороннего процесса. В таком случае существенно повышается управляемость внедрением, так как появляется возможность изменять код динамически. Но есть и большой минус в виде большого количества подводных камней, о которых сейчас и пойдет речь.
Что бы понять, как делать можно, сначала рассмотрим, как делать нельзя. Рассмотрим следующую процедуру (процедура приведена исключительно для наглядности, любые совпадения с реальными процедурами являются случайными).
procedure Proc();
var
H: Integer;
begin
H := 0;
SetWindowText(H, 'TEXT');
end;
Попытка внедрения этой процедуры в сторонний процесс завершится для последнего очень плачевно. Причины такого поведения становятся очевидны, если рассмотреть уже откомпилированный код процедуры. Для наглядности, код компилировался при отключенной оптимизации и включенных проверках времени исполнения.
10 004085E4 push ebp занести в стек вершину кадра стека
20 004085E5 mov ebp,esp выровнять вершину кадра стека по вершине стека
30 004085E7 push ecx занести в стек значение регистра ECX
H := 0;
40 004085E8 xor eax,eax обнулить регистр EAХ
50 004085EA mov [ebp-$04],eax занести значение EAX в переменную H
SetWindowText(H, 'TEXT');
60 004085ED push $00408608 занести в стек адрес строки 'TEXT'
70 004085F2 mov eax,[ebp-$04] загрузить переменную H в регистр EAX
80 004085F5 test eax,eax |
90 004085F7 jns $004085fe |обработка переполнения
100 004085F9 call @BoundErr |
110 004085FE push eax занести в стек переменную H
120 004085FF call SetWindowText вызвать подпрограмму SetWindowText
end;
130 00408604 pop ecx восстановить регистр ECX
140 00408605 pop ebp восстановить вершину кадра стека
150 00408606 ret выйти из подпрограммы
До строки (60) все достаточно просто. В строках (60-110) в стек заносятся параметры для вызова функции SetWindowText() (120), тут и начинаются проблемы. В строке (60) в стек заносится указатель на строку ‘TEXT’. Естественно, после внедрения кода, этот указатель будет указывать на случайный набор байт. Поскольку переменная H объявлена как знаковое целое, а функция SetWindowText() в качестве первого параметра принимает беззнаковое целое, компилятор добавляет проверку диапазона (80-100). Если выяснится, что значение H отрицательное, будет вызвана подпрограмма @BoundErr и очень маловероятно, что она окажется в стороннем процессе, да еще и по тому же адресу. Если в первом случае процесс еще имеет шанс выжить, то в этом управление будет передано по случайному адресу, что в 99.999999% приведет к краху. Кстати, строка (90) на первый взгляд выглядит подозрительно, т.к. содержит абсолютный адрес, но исходя из опкода, реально она совершит относительный переход на 5 байт, абсолютный адрес любезно вычислил дизассемблер.
Код операции, операционный код, опкод - часть машинного языка, называемой инструкцией, определяющей операцию, которая должна быть выполнена.
Регистр EBP – указатель базы кадра стека, отрицательное смещение относительно этого указателя адресует локальные переменные, а положительное – параметры функции или процедуры.
Регистр ESP - указатель вершины стека.
Особый интерес представляет строка (120). По идее она должна передать управление подпрограмме SetWindowText. Но вместо этого переход будет выполнен на следующую команду, которая и перенаправить вызов “реальной” SetWindowText().
SetWindowText:
00405128 jmp dword ptr [$00410254]
По сути, это просто “заглушка” которую компилятор Delphi создает для функции SetWindowText() (да и для других функций из DLL при связывании во время загрузки). Она необходима, поскольку на этапе компиляции и сборки невозможно разрешить ссылки на из DLL, подключаемой во время загрузки. Дальше все просто. (130-140) – восстановление регистров из стека. (150)- выход из подпрограммы.
Таким образом, для внедрения кода должны соблюдаться следующие условия:
1. Код не должен содержать указателей, не имеющих смысла в стороннем процессе.
2. Код не должен содержать вызовов подпрограмм, не имеющих смысла в стороннем процессе.
3. Все проверки времени исполнения должны быть отключены.
Первое условие означает, что все указатели, которые будут использоваться внедряемым кодом, должны адресовать корректные данные в адресном пространстве стороннего процесса. Второе условие практически аналогичное, код должен содержать реальные адреса подпрограмм, имеющие смысл в стороннем процессе. Эти два условия означают, что данные и адреса подпрограмм необходимо скопировать в адресное пространство стороннего процесса, при этом внедряемый код должен знать, где эти данные искать. Третье условие самое простое, снимаем галочки – готово!
На первый взгляд кажется, что выполнить первые два условия достаточно сложно. Но этот процесс можно сильно упростить. Ниже приведен пример функции injCode(), вполне готовой для внедрения. Функция очень простая, она всего лишь выводит диалоговое окно с заранее заданным текстом.
type
TMessageBox = function (hWnd: HWND; lpText, lpCaption: PChar; uType: UINT): Integer; stdcall;
PInjectRec = ^TInjectRec;
TInjectRec = record
fMessageBox : TMessageBox;
Text : array [0..255] of Char;
Caption : array [0..255] of Char;
end;
function injCode(injRec: PInjectRec): DWORD;stdcall;
var
uType: Cardinal;
begin
uType := MB_ICONASTERISK;
injRec.fMessageBox(0, PChar(@injRec^.Text[0]), PChar(@injRec^.Caption[0]), uType);
Result := 0;
end;
Суть метода заключается в том, что компилятор реализует доступ к полям записи (records) через смещение относительно указателя на эту запись. Поэтому, для работоспособности кода, оперирующего полями записи, будет достаточно корректности указателя на эту структуру. Идея, надеюсь, ясна. Вся необходимая информация (данные и адреса вызовов) заносится в запись, которая копируется в сторонний процесс. Теперь, что бы внедренный код получил доступ ко всей необходимой информации, ему достаточно передать только указатель на эту структуру.
Так, в приведенном примере создается структура TInjectRec, в полях которой содержится информация о точке входа в функцию MessageBox(), и два массива символов, которые будут содержать текст для диалогового окна. Указатель на эту структуру передается в функциию injCode(). Что бы компилятор понял, что fMessageBox – это действительно функция, придется по прототипу функции создать тип TMessageBox.
Тут необходимо обратить внимание, что данные должны объявляться именно как статические массивы. Можно, конечно, поле Text, объявить как PChar, но тогда необходимо вручную выделить память в стороннем процессе, скопировать в нее необходимые данные, и сохранить в этом поле указатель на эту память.
Теперь осталось только этот код внедрить. Сделать это можно процедурой, представленной ниже. Метод внедрения очень похож на метод внедрения DLL, детально описанный Дж. Рихтером, если с пониманием возникнут проблемы, советую обратиться к его книге.
procedure Inject(PID: Cardinal);
var
hRemoteProcess : Cardinal;
dwRemoteThreadId: DWORD;
lpData, lpCode : Pointer;
InjRec : TInjectRec;
hUser32 : Cardinal;
Dummy : Cardinal;
begin
hUser32 := GetModuleHandle('user32.dll');
InjRec.fMessageBox := GetProcAddress(hUser32, 'MessageBoxA');
InjRec.Text := 'Code successfully injected!';
InjRec.Caption := 'Code Injector';
hRemoteProcess := OpenProcess(PROCESS_ALL_ACCESS, False, PID);
lpData := VirtualAllocEx( hRemoteProcess, nil, SizeOf(TInjectRec)+128, MEM_COMMIT or MEM_RESERVE, PAGE_EXECUTE_READWRITE );
lpCode := Pointer(DWORD(lpData) + SizeOf(TInjectRec));
WriteProcessMemory(hRemoteProcess, lpData, @injRec, SizeOf(TInjectRec), Dummy);
WriteProcessMemory(hRemoteProcess, lpCode, @injCode, 128, Dummy);
CloseHandle(
CreateRemoteThread(hRemoteProcess, nil, 0, lpCode, lpData, 0, dwRemoteThreadId));
CloseHandle(hRemoteProcess);
end;
Процедура Inject() принимает идентификатор процесса, в который нужно внедрить функцию injCode(). Для простоты все проверки опущены. Последовательность действий, которые необходимо выполнить для внедрения следующая:
1. Необходимо заполнить поля записи InjRec. В поле fMessageBox заноситься адрес функции MessageBoxA, а в поля Text и Caption соответствующие строки. Для этого примера, это все данные, которые необходимо передать в заданный процесс. Перед внедрением кода следует убедиться, что все необходимые DLL загружены в адресное пространство стороннего процесса.
При вычислении адресов функций предполагается, что DLL загружаются во все процессы по одним базовым адресам, поэтому и адреса функций во все процессах одинаковы. Для системных DLL это, как правило, верно. Но базовые адреса DLL сторонних производителей могут для разных процессов отличаться. В этом случае вычисление адреса необходимой функции усложняется.
2. Функцией OpenProcess() открывается дескриптор заданного процесса.
3. Функцией VirtualAllocEx() в этом процессе выделяется память для данных и кода. Для выделения памяти необходимо указать размер выделяемой памяти, т.е. размер данных + размер кода. Размер данных определить просто, а вот с размером кода сложнее. К счастью, задавать точный размер кода не обязательно, достаточно, что бы заданный размер был не меньше реального. В примере размер кода принят в 128 байт.
4. VirtualAllocEx() возвращает указатель lpData на выделенную память в заданном процессе. Указатель на внедренный код можно легко вычислить, прибавив в lpData размер данных.
5. Функцией WriteProcessMemory() данные и код копируются в адресное пространство заданного процесса.
6. Все подготовительные операции завершены. Данные и код скопированы. Теперь функцией CreateRemoteThread() можем создать новый поток в заданном процессе и запустить его. Точка входа в функцию потока задается четвертым параметром CreateRemoteThread(). Пятый параметр задает указатель, который будет передан функции потока, т.е. в injCode() будет передан указатель на структуру TInjectRec. CreateRemoteThread() ожидает, что функция потока объявлена в соотвествии с соглашением о вызовах принятых в WindowsAPI, поэтому injCode() необходимо объявить как stdcall.
Определить точный размер процедуры (или функции) можно используя средство отладчика View CPU. Достаточно установить току останова на точку входа в процедуру, затем найти точку возврата (команда RET), и вычесть из адреса точки возврата адрес точки входа.
Многие функции WindowsAPI, принимающие в качестве параметров нуль-терминированные строки, существуют в двух версиях. Первая имеет суффикс “A” (ANSI) и принимает MultiByte-строки, вторая обозначается суффиксом “W” (WideChar) и принимает UNICODE строки (на самом деле ANSI вариант является оберткой на
UNICODE). Так как в разных версиях Delphi тип Char может быть как AnsiChar так и WideChar, следует убедится, что вызываются соответствующие версии функций.
В начале статьи говорилось, что метод внедрения кода, основанный на подключении DLL имеет два недостатка. Как с помощью представленного метода избавиться от второго (передача параметров), уже показано. А избавится от первого (DLL не знает, кто ее внедряет) можно добавив к TInjectRec еще два поля: дескриптор основного процесса hHost и его идентификатор PidHost.
TInjectRec = record
…
hHost: Cardinal;
PIDHost: Cardinal;
…
end;
…
InjRec.PIDHost := GetCurrentProcessId();
DuplicateHandle(GetCurrentProcess(), GetCurrentProcess(), hRemoteProcess, @InjRec.hHost, 0, false, DUPLICATE_SAME_ACCESS);
И последнее. На 64-х битных операционных внедрение будет работать, только если оба процесса имеют одинаковую разрядность. Нельзя внедрить код из 32-х битного процесса в 64-битный (и наоборот), так как размеры указателей в этих процессах разные.