Задумывались ли вы над тем, как именно используется память, доступная вашей программе, да и вообще, что именно размещается в этих двух-трех гигабайтах виртуальной памяти, с которыми работает ваше ПО?
Спросите, зачем?
Ну как же, для 32-битного приложения 2-3 гигабайта – это ваш лимит за пределы которого без использования AWE вы выбраться не сможете, а контролировать собственные ресурсы все же желательно. Но даже и без этого просто с целью разобраться…
В прошлых статьях я описывал работу отладчика, где производились модификации памяти приложения, находящегося под отладкой. Эта статья является продолжением данного материала. И хотя к отладчику она не будет иметь отношения, но вот к процессу отладки – самое непосредственное…
Давайте посмотрим, как именно программист работает с памятью при отладке (особенно при отладке стороннего приложения, проще говоря, при реверсе):
1. Как правило, самой частой операцией будет поиск значения в памяти приложения и, к сожалению, данный функционал почему-то не предоставлен в отладчике Delphi (собственно, как и в MS VC++).
2. Модификация системных структур (PEB/TEB/SEHChain/Unwind/директорий PE-файлов etc...) будет происходить гораздо проще, когда поля структур размаплены на занимаемые ими адреса и представлены в читабельном виде.
3. Отслеживание изменений в памяти процесса (практически никем не предоставляемый функционал, реализованный в виде плагинов к популярным отладчикам). Действительно, зачем трассировать до посинения, когда достаточно сравнить два снимка карты памяти, чтобы понять, тут ли происходит нужная нам модификация данных или нет?
Да, собственно, вариантов использования много.
Впрочем, если без лирики, утилит отображающих более-менее вменяемую информацию о карте памяти процесса, которую можно применить для отладки, очень мало.
Самая удобная реализация от OllyDebug 2, но, к сожалению, она не отображает данные по 64 битам (все еще ждем).
VMMap от Марка Руссиновича выполняет чисто декоративные свойства, да красиво, да за подписью Microsoft, но практически применить выводимые ей данные тяжеловато.
ProcessHacker – хороший инструмент, но его автор не ставил перед собой задач по работе с выводом данных о памяти, поэтому выводимая им информация можно сказать вообще самая простая.
Ну а к карте памяти от IDA Pro за столько лет работы с ней я так и не привык (мне не удобно) :)
Впрочем, отладка это не все, где может пригодиться валидная карта памяти. В частности, по работе я использую карту памяти при анализах лога ошибок, присылаемых нам пользователями вместе с дампом критических участков, интегрировав информацию о ней в EurekaLog.
В данной статье я попробую по шагам рассказать, как самостоятельно составить карту памяти процесса и разместить в ней информацию о нужных для отладки и анализа данных.
1. Получаем список доступных регионов
Вся виртуальная память процесса представлена в виде страниц.
Страницы бывают маленькие (4096 байт) и большие. (Подробнее можно узнать в MSDN)
В большинстве случаев идущие подряд страницы имеют одинаковые атрибуты.
Что есть регион?
Грубо (если взять за основу MSDN) – это набор всех страниц имеющих одинаковые атрибуты, которые начинающихся с переданного функции VirtualQuery адреса.
В самом простейшем виде получить список регионов нашего процесса можно вот таким кодом:
program Project1;
{$APPTYPE CONSOLE}
{$R *.res}
uses
Windows,
SysUtils;
var
MBI: TMemoryBasicInformation;
dwLength: NativeUInt;
Address: PByte;
begin
Address := nil;
dwLength := SizeOf(TMemoryBasicInformation);
while VirtualQuery(Address, MBI, dwLength) <> 0 do
begin
Writeln(
'AllocationBase: ', IntToHex(NativeUInt(MBI.AllocationBase), 8),
', BaseAddress: ', IntToHex(NativeUInt(MBI.BaseAddress), 8),
', RegionSize: ', MBI.RegionSize);
Inc(Address, MBI.RegionSize);
end;
Readln;
end.
К примеру, изначально мы передали первым параметром адрес nil. После вызова функции переменная MBI примет следующие значения:
- BaseAddress = nil
- AllocationBase = nil
- AllocationProtect = 0
- RegionSize = $10000
- State = $10000
- Protect = 1
- Type_9 = 0
Размер региона равен $10000 (64 кб), это соответствует 16 страницам, идущим подряд, начиная с адреса ноль, состояние которых (State) равно MEM_FREE ($10000) и выставлен атрибут защиты PAGE_NO_ACCESS (1) в параметре Protect.
Если переписать код вот таким образом:
function ExtractAccessString(const Value: DWORD): string;
const
PAGE_WRITECOMBINE = $400;
begin
Result := 'Unknown access';
if (Value and PAGE_EXECUTE) = PAGE_EXECUTE then Result := 'E';
if (Value and PAGE_EXECUTE_READ) = PAGE_EXECUTE_READ then Result := 'RE';
if (Value and PAGE_EXECUTE_READWRITE) = PAGE_EXECUTE_READWRITE then
Result := 'RWE';
if (Value and PAGE_EXECUTE_WRITECOPY) = PAGE_EXECUTE_WRITECOPY then
Result := 'RE, Write copy';
if (Value and PAGE_NOACCESS) = PAGE_NOACCESS then Result := 'No access';
if (Value and PAGE_READONLY) = PAGE_READONLY then Result := 'R';
if (Value and PAGE_READWRITE) = PAGE_READWRITE then Result := 'RW';
if (Value and PAGE_WRITECOPY) = PAGE_WRITECOPY then Result := 'Write copy';
if (Value and PAGE_GUARD) = PAGE_GUARD then
Result := Result + ', Guarded';
if (Value and PAGE_NOCACHE) = PAGE_NOCACHE then
Result := Result + ', No cache';
if (Value and PAGE_WRITECOMBINE) = PAGE_WRITECOMBINE then
Result := Result + ', Write Combine';
end;
function ExtractRegionTypeString(Value: TMemoryBasicInformation): string;
begin
Result := '';
case Value.State of
MEM_FREE: Result := 'Free';
MEM_RESERVE: Result := 'Reserved';
MEM_COMMIT:
case Value.Type_9 of
MEM_IMAGE: Result := 'Image';
MEM_MAPPED: Result := 'Mapped';
MEM_PRIVATE: Result := 'Private';
end;
end;
Result := Result + ', ' + ExtractAccessString(Value.Protect);
end;
var
MBI: TMemoryBasicInformation;
dwLength: NativeUInt;
Address: PByte;
begin
Address := nil;
dwLength := SizeOf(TMemoryBasicInformation);
while VirtualQuery(Address, MBI, dwLength) <> 0 do
begin
Writeln(
'AllocationBase: ', IntToHex(NativeUInt(MBI.AllocationBase), 8),
', BaseAddress: ', IntToHex(NativeUInt(MBI.BaseAddress), 8),
' - ', ExtractRegionTypeString(MBI));
Inc(Address, MBI.RegionSize);
end;
… то можно наглядно увидеть принцип разбиения на регионы функцией VirtualAlloc:
К примеру, у второго и третьего региона атрибуты доступа одинаковые (чтение запись), но разная AllocationBase. AllocationBase назначается страницам при выделении памяти посредством VirtualAlloc, объединяя их таким образом в отдельный регион.
2. Собираем данные о потоках
Пришла пора начать заполнять полученные нами регионы информацией о том, что они хранят, и начнем мы с потоков (нитей – кому как удобнее).
Код получения списка потоков простой – через CreateToolhelp32Snapshot.
const
THREAD_GET_CONTEXT = 8;
THREAD_SUSPEND_RESUME = 2;
THREAD_QUERY_INFORMATION = $40;
ThreadBasicInformation = 0;
ThreadQuerySetWin32StartAddress = 9;
STATUS_SUCCESS = 0;
var
hSnap, hThread: THandle;
ThreadEntry: TThreadEntry32;
TBI: TThreadBasicInformation;
TIB: NT_TIB;
lpNumberOfBytesRead: NativeUInt;
ThreadStartAddress: Pointer;
begin
// Делаем снимок нитей в системе
hSnap := CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, GetCurrentProcessId);
if hSnap <> INVALID_HANDLE_VALUE then
try
ThreadEntry.dwSize := SizeOf(TThreadEntry32);
if Thread32First(hSnap, ThreadEntry) then
repeat
if ThreadEntry.th32OwnerProcessID <> GetCurrentProcessId then Continue;
Writeln('ThreadID: ', ThreadEntry.th32ThreadID);
// Открываем нить
hThread := OpenThread(THREAD_GET_CONTEXT or
THREAD_SUSPEND_RESUME or THREAD_QUERY_INFORMATION,
False, ThreadEntry.th32ThreadID);
if hThread <> 0 then
try
// Получаем адрес ThreadProc()
if NtQueryInformationThread(hThread, ThreadQuerySetWin32StartAddress,
@ThreadStartAddress, SizeOf(ThreadStartAddress), nil) = STATUS_SUCCESS then
Writeln('ThreadProcAddr: ', IntToHex(NativeUInt(ThreadStartAddress), 1));
// Получаем информацию по нити
if NtQueryInformationThread(hThread, ThreadBasicInformation, @TBI,
SizeOf(TThreadBasicInformation), nil) = STATUS_SUCCESS then
begin
Writeln('Thread Environment Block (TEB) Addr: ',
IntToHex(NativeUInt(TBI.TebBaseAddress), 1));
// Читаем из удаленного адресного пространства
// TIB (Thread Information Block) открытой нити
if ReadProcessMemory(GetCurrentProcess,
TBI.TebBaseAddress, @TIB, SizeOf(NT_TIB),
lpNumberOfBytesRead) then
begin
Writeln('Thread StackBase Addr: ',
IntToHex(NativeUInt(TIB.StackBase), 1));
Writeln('Thread StackLimit Addr: ',
IntToHex(NativeUInt(TIB.StackLimit), 1));
end;
end;
finally
CloseHandle(hThread);
end;
until not Thread32Next(hSnap, ThreadEntry);
finally
CloseHandle(hSnap);
end;
Readln;
end.
По шагам:
- При помощи CreateToolhelp32Snapshot/Thread32First/Thread32Next получаем список активных потоков у нашего приложения.
- Для получения более подробной информации потребуется хендл потока, который получаем посредством вызова OpenThread.
- При помощи NtQueryInformationThread получаем адрес процедуры потока, с которой он начал работу, и базовую информацию о потоке в виде структуры TThreadBasicInformation.
- Из этой структуры нас интересует только одно поле – TebBaseAddress, которое содержит адрес блока окружения потока, т.н. TEB (Thread Environment Block).
- Посредством вызова ReadProcessMemory (хотя для своего приложения это и избыточно) зачитываем данные по адресу TEB, а именно самый первый ее параметр, представляющий из себя структуру NT_TIB.
Декларация NT_TIB выглядит так:
PNT_TIB = ^_NT_TIB;
_NT_TIB = record
ExceptionList: Pointer;
StackBase,
StackLimit,
SubSystemTib: Pointer;
case Integer of
0: (
FiberData: Pointer
);
1: (
Version: ULONG;
ArbitraryUserPointer: Pointer;
Self: PNT_TIB;
)
end;
NT_TIB = _NT_TIB;
PPNT_TIB = ^PNT_TIB;
Ну или вот так, если описывать чуть подробнее:
- ExceptionList – в 32-битном процессе указатель на адрес текущего SEH фрейма (структуру EXCEPTION_REGISTRATION). Основываясь на данной информации, мы будем раскручивать всю цепочку SEH фреймов.
- Если же TEB принадлежит 64-битному потоку, работающего в 32-битном приложении, то данное поле будет указывать на поле ExceptionList своего 32-битного аналога.
- В 64-битном процессе данное поле всегда обнилено, т.к. для 64 бит взамен механизма SEH работает немного другой механизм.
- StackBase – база стека. Адрес от которого стек начинает расти в направлении StackLimit.
- StackLimit – текущая верхушка стека.
- ArbitraryUserPointer – что-то наподобие свободного TLS слота. Грубо говоря переменная принадлежащая потоку, значение которой может произвольно изменятся самим программистом для собственных нужд.
- Self — параметр, содержащий адрес TEB (т.е. самого себя)
Остальные поля не нужны.
Ну, впрочем, как – не нужны?
Нужны, конечно, но пока что они для нас избыточны.
Кстати, вот ссылка, где вы сможете увидеть слегка устаревшее описание данной структуры: Thread Environment Block.
Данный код отобразит нам следующую картинку:
А вот так это будет видно в VMMap.
На картинке видно что VMMap не отобразила информацию о TEB.
Кстати, часть функций и структур из приведенного выше кода не задекларированы в стандартных исходниках Delphi, их декларацию вы сможете увидеть в демо-примерах, идущих в составе данной статьи. Но это не означает того, что они недокументированы в MSDN :)
Если мы захотим работать с TEB своего потока, то код очень сильно упростится из-за того что не нужно использовать функции ToolHelp32.dll, а достаточно использовать сегментный регистр FS (или GS для х64).
К примеру, очень часто встречается такая функция для получения адреса TEB:
function GetCurrentTEB: NativeUInt;
asm
{$IFDEF WIN64}
// mov RAX, qword ptr GS:[30h]
// реализованно через машкоды, ввиду неверной генерации кода инструкции 64-битным компилятором
DB $65, $48, $8B, $04, $25, $30, 0, 0, 0
// ну либо вот так, чуть менее оптимально
mov RAX, qword ptr GS:[abs $30]
{$ELSE}
mov EAX, FS:[18h]
{$ENDIF}
end;
В данном случае происходит доступ к параметру NtTIB.Self структуры TEB, который расположен по смещению 0x18 (или 0x30 в случае 64-битного TEB) от ее начала.
Впрочем, продолжим…
Часть данных получили, но это не вся информация доступная нам.
На стеке каждого потока расположены SEH фреймы, которые генерируются автоматом при входе в блок try..finally/except, а также стек вызовов процедур. Было бы хорошо иметь эти данные на руках и выводить их в более наглядном виде – с привязкой к региону.
Раскруткой SEH фреймов у нас будет заниматься вот такая простенькая процедура:
procedure GetThreadSEHFrames(InitialAddr: Pointer);
type
EXCEPTION_REGISTRATION = record
prev, handler: Pointer;
end;
var
ER: EXCEPTION_REGISTRATION;
lpNumberOfBytesRead: NativeUInt;
begin
while ReadProcessMemory(GetCurrentProcess, InitialAddr, @ER,
SizeOf(EXCEPTION_REGISTRATION), lpNumberOfBytesRead) do
begin
Writeln('SEH Frame at Addr: ',
IntToHex(NativeUInt(InitialAddr), 1), ', handler at addr: ',
IntToHex(NativeUInt(ER.handler), 1));
InitialAddr := ER.prev;
if DWORD(InitialAddr) <= 0 then Break;
end;
end;
Получив в качестве параметра значение TEB.TIB.ExceptionList, которое указывает на первую структуру EXCEPTION_REGISTRATION, она бежит по цепочке данных структур, ориентируясь на значение prev данной структуры, которое содержит адрес предыдущей структуры EXCEPTION_REGISTRATION. А параметр handler содержит адрес обработчика исключения, если оно вдруг произойдет.
Выглядит все вот так:
Ну а CallStack будет получать следующая процедура:
procedure GetThreadCallStack(hThread: THandle);
var
StackFrame: TStackFrame;
ThreadContext: PContext;
MachineType: DWORD;
begin
// ThreadContext должен быть выровнен, поэтому используем VirtualAlloc
// которая автоматически выделит память выровненную по началу страницы
// в противном случае получим ERROR_NOACCESS (998)
ThreadContext := VirtualAlloc(nil, SizeOf(TContext), MEM_COMMIT, PAGE_READWRITE);
try
ThreadContext^.ContextFlags := CONTEXT_FULL;
if not GetThreadContext(hThread, ThreadContext^) then
Exit;
ZeroMemory(@StackFrame, SizeOf(TStackFrame));
StackFrame.AddrPC.Mode := AddrModeFlat;
StackFrame.AddrStack.Mode := AddrModeFlat;
StackFrame.AddrFrame.Mode := AddrModeFlat;
StackFrame.AddrPC.Offset := ThreadContext.Eip;
StackFrame.AddrStack.Offset := ThreadContext.Esp;
StackFrame.AddrFrame.Offset := ThreadContext.Ebp;
MachineType := IMAGE_FILE_MACHINE_I386;
while True do
begin
if not StackWalk(MachineType, GetCurrentProcess, hThread, @StackFrame,
ThreadContext, nil, nil, nil, nil) then
Break;
if StackFrame.AddrPC.Offset <= 0 then Break;
Writeln('CallStack Frame Addr: ',
IntToHex(NativeUInt(StackFrame.AddrFrame.Offset), 1));
Writeln('CallStack Handler: ',
IntToHex(NativeUInt(StackFrame.AddrPC.Offset), 1));
Writeln('CallStack Stack: ',
IntToHex(NativeUInt(StackFrame.AddrStack.Offset), 1));
Writeln('CallStack Return: ',
IntToHex(NativeUInt(StackFrame.AddrReturn.Offset), 1));
end;
finally
VirtualFree(ThreadContext, SizeOf(TContext), MEM_FREE);
end;
end;
Правда, в отличие от отладчика Delphi, он будет выводить данные о процедурах, для которых сгенерирован стековый фрейм, остальные он пропустит.
За перечисление информации о стековых фреймах отвечает функция StackWalk (или StackWalk64).
Теперь нюанс: если мы применим данный код к самому себе, то он сможет оттрассировать только один стековый фрейм, после чего произойдет выход (можете проверить на демоприложении).
Произойдет это по следующей причине: для правильной трассировки функции StackWalk необходимо указать параметры текущего кадра стека (EBP и ESP/ RBP и RSP для х64) и, собственно, текущий адрес кода (регистр EIP или RIP для х64). Если мы будем брать эти данные с самого себя, то это произойдет в тот момент, когда бы вызвали функцию GetThreadContext, а раскручивать стек мы начнем уже после выхода из данной функции, где все три параметра станут, мягко говоря, не валидны. По этой причине сделать трассировку самого себя вызовом данной функции не получится.
Этот момент желательно учитывать…
На получении информации о потоках 32-битного процесса под 64-битной ОС включая 32 и 64-битные варианты я остановлюсь несколько позже, а сейчас…
3. Собираем данные о кучах
Само по себе Delphi приложение, как правило, кучи не использует, это больше прерогатива С++ приложений, но все-таки кучи присутствуют и здесь. Обычно их создают и используют различные сторонние библиотеки для своих нужд.
Нюанс при получении данных о кучах в том, что элементов HeapEntry, из которых состоит каждая куча, может быть несколько тысяч, а второй нюанс в том, что функция Heap32Next при каждом вызове заново перестраивает весь список, создавая при этом достаточно чувствительную задержку (вплоть до десятков секунд).
Об этой неприятной особенности я уже писал.
Правда, в той статье код был достаточно примерный, просто чтобы продемонстрировать сам принцип, и нам он не подойдет, но вполне устроит его более причесанный вариант:
const
RTL_HEAP_BUSY = 1;
RTL_HEAP_SEGMENT = 2;
RTL_HEAP_SETTABLE_VALUE = $10;
RTL_HEAP_SETTABLE_FLAG1 = $20;
RTL_HEAP_SETTABLE_FLAG2 = $40;
RTL_HEAP_SETTABLE_FLAG3 = $80;
RTL_HEAP_SETTABLE_FLAGS = $E0;
RTL_HEAP_UNCOMMITTED_RANGE = $100;
RTL_HEAP_PROTECTED_ENTRY = $200;
RTL_HEAP_FIXED = (RTL_HEAP_BUSY or RTL_HEAP_SETTABLE_VALUE or
RTL_HEAP_SETTABLE_FLAG2 or RTL_HEAP_SETTABLE_FLAG3 or
RTL_HEAP_SETTABLE_FLAGS or RTL_HEAP_PROTECTED_ENTRY);
STATUS_SUCCESS = 0;
function CheckSmallBuff(Value: DWORD): Boolean;
const
STATUS_NO_MEMORY = $C0000017;
STATUS_BUFFER_TOO_SMALL = $C0000023;
begin
Result := (Value = STATUS_NO_MEMORY) or (Value = STATUS_BUFFER_TOO_SMALL);
end;
function FlagToStr(Value: DWORD): string;
begin
case Value of
LF32_FIXED: Result := 'LF32_FIXED';
LF32_FREE: Result := 'LF32_FREE';
LF32_MOVEABLE: Result := 'LF32_MOVEABLE';
else
Result := '';
end;
end;
var
I, A: Integer;
pDbgBuffer: PRtlDebugInformation;
pHeapInformation: PRtlHeapInformation;
pHeapEntry: PRtrHeapEntry;
dwAddr, dwLastSize: ULONG_PTR;
hit_seg_count: Integer;
BuffSize: NativeUInt;
begin
// Т.к. связка Heap32ListFirst, Heap32ListNext, Heap32First, Heap32Next
// работает достаточно медленно, из-за постоянного вызова
// RtlQueryProcessDebugInformation на каждой итерации, мы заменим ее вызов
// аналогичным кодом без ненужного дубляжа
// Создаем отладочный буфер
BuffSize := $400000;
pDbgBuffer := RtlCreateQueryDebugBuffer(BuffSize, False);
// Запрашиваем информацию по списку куч процесса
while CheckSmallBuff(RtlQueryProcessDebugInformation(GetCurrentProcessId,
RTL_QUERY_PROCESS_HEAP_SUMMARY or RTL_QUERY_PROCESS_HEAP_ENTRIES,
pDbgBuffer)) do
begin
// если размера буфера не хватает, увеличиваем...
RtlDestroyQueryDebugBuffer(pDbgBuffer);
BuffSize := BuffSize shl 1;
pDbgBuffer := RtlCreateQueryDebugBuffer(BuffSize, False);
end;
if pDbgBuffer <> nil then
try
// Запрашиваем информацию по списку куч процесса
if RtlQueryProcessDebugInformation(GetCurrentProcessId,
RTL_QUERY_PROCESS_HEAP_SUMMARY or RTL_QUERY_PROCESS_HEAP_ENTRIES,
pDbgBuffer) = STATUS_SUCCESS then
begin
// Получаем указатель на кучу по умолчанию
pHeapInformation := @pDbgBuffer^.Heaps^.Heaps[0];
// перечисляем все ее блоки...
for I := 0 to pDbgBuffer^.Heaps^.NumberOfHeaps - 1 do
begin
// начиная с самого первого
pHeapEntry := pHeapInformation^.Entries;
dwAddr := DWORD(pHeapEntry^.u.s2.FirstBlock) +
pHeapInformation^.EntryOverhead;
dwLastSize := 0;
A := 0;
while A < Integer(pHeapInformation^.NumberOfEntries) do
try
hit_seg_count := 0;
while (pHeapEntry^.Flags and RTL_HEAP_SEGMENT) = RTL_HEAP_SEGMENT do
begin
// Если блок отмечен флагом RTL_HEAP_SEGMENT,
// то рассчитываем новый адрес на основе EntryOverhead
dwAddr := DWORD(pHeapEntry^.u.s2.FirstBlock) +
pHeapInformation^.EntryOverhead;
Inc(pHeapEntry);
Inc(A);
Inc(hit_seg_count);
// проверка выхода за границы блоков
if A + hit_seg_count >=
Integer(pHeapInformation^.NumberOfEntries - 1) then
Continue;
end;
// Если блок не самый первый в сегменте, то текущий адрес блока равен,
// адресу предыдущего блока + размер предыдущего блока
if hit_seg_count = 0 then
Inc(dwAddr, dwLastSize);
// Выставляем флаги
if pHeapEntry^.Flags and RTL_HEAP_FIXED <> 0 then
pHeapEntry^.Flags := LF32_FIXED
else
if pHeapEntry^.Flags and RTL_HEAP_SETTABLE_FLAG1 <> 0 then
pHeapEntry^.Flags := LF32_MOVEABLE
else
if pHeapEntry^.Flags and RTL_HEAP_UNCOMMITTED_RANGE <> 0 then
pHeapEntry^.Flags := LF32_FREE;
if pHeapEntry^.Flags = 0 then
pHeapEntry^.Flags := LF32_FIXED;
// Выводим данные
Writeln('HeapID: ', I, ', entry addr: ', IntToHex(dwAddr, 8),
', size: ', IntToHex(pHeapEntry^.Size, 8), ' ', FlagToStr(pHeapEntry^.Flags));
// Запоминаем адрес последнего блока
dwLastSize := pHeapEntry^.Size;
// Переходим к следующему блоку
Inc(pHeapEntry);
finally
Inc(A);
end;
// Переходим к следующей куче
Inc(pHeapInformation);
end;
end;
finally
RtlDestroyQueryDebugBuffer(pDbgBuffer);
end;
Readln;
end.
Вкратце, при помощи вызова функций RtlQueryProcessDebugInformation, RtlCreateQueryDebugBuffer и RtlQueryProcessDebugInformation создается буфер, в котором содержится информация о текущих кучах процесса. После чего, зная структуру данных, хранящихся в нем, получаем эти данные в цикле.
pDbgBuffer^.Heaps — хранит в себе списки куч (аналог THeapList32), а сами записи хранятся в pDbgBuffer^.Heaps^.Heaps[N].Entries (аналог THeapEntry32).
Данный код выведет следующую информацию:
В принципе, кучи я использую при отладке достаточно редко, но иногда и эта информация может пригодиться.
4. Собираем данные о загруженных PE файлах
Теперь пришла пора получить информацию о загруженных в адресное пространство процесса исполняемым файлах и библиотеках. Есть несколько способов сделать это (например, проанализировав PEB.LoaderData), но поступим проще.
Как правило, под PE файл выделяется отдельный регион (ну, по крайней мере, я еще не встречался с таким, чтобы PE образ был загружен без выравнивания по верхушке региона), поэтому, взяв за основу код из первой главы и проверив данные первой страницы региона на соответствие PE файлу, получим список всех загруженных библиотек и исполняемых файлов.
Следующий код детектирует наличие валидного PE файла по указанному адресу:
function CheckPEImage(hProcess: THandle;
ImageBase: Pointer; var IsPEImage64: Boolean): Boolean;
var
ReturnLength: NativeUInt;
IDH: TImageDosHeader;
NT: TImageNtHeaders;
begin
Result := False;
IsPEImage64 := False;
if not ReadProcessMemory(hProcess, ImageBase,
@IDH, SizeOf(TImageDosHeader), ReturnLength) then Exit;
if IDH.e_magic <> IMAGE_DOS_SIGNATURE then Exit;
ImageBase := Pointer(NativeInt(ImageBase) + IDH._lfanew);
if not ReadProcessMemory(hProcess, ImageBase,
@NT, SizeOf(TImageNtHeaders), ReturnLength) then Exit;
Result := NT.Signature = IMAGE_NT_SIGNATURE;
IsPEImage64 :=
(NT.FileHeader.Machine = IMAGE_FILE_MACHINE_IA64) or
(NT.FileHeader.Machine = IMAGE_FILE_MACHINE_ALPHA64) or
(NT.FileHeader.Machine = IMAGE_FILE_MACHINE_AMD64);
end;
Ну точнее как, он просто проверяет наличие ImageDosHeader и ImageNTHeader, ориентируясь на их сигнатуры. В принципе для 99% случаев этого достаточно.
Третий параметр просто информационный, он показывает является ли PE файл 64-битным.
Получить путь к загруженному файлу можно вызовом функции GetMappedFileName:
function GetFileAtAddr(hProcess: THandle; ImageBase: Pointer): string;
begin
SetLength(Result, MAX_PATH);
SetLength(Result,
GetMappedFileName(hProcess, ImageBase, @Result[1], MAX_PATH));
end;
А теперь попробуем посмотреть, что у нас загружается в обычное консольное приложение:
var
MBI: TMemoryBasicInformation;
dwLength: NativeUInt;
Address: PByte;
IsPEImage64: Boolean;
begin
Address := nil;
dwLength := SizeOf(TMemoryBasicInformation);
while VirtualQuery(Address, MBI, dwLength) <> 0 do
begin
if CheckPEImage(GetCurrentProcess, MBI.BaseAddress, IsPEImage64) then
begin
Write(IntToHex(NativeUInt(MBI.BaseAddress), 8), ': ',
GetFileAtAddr(GetCurrentProcess, MBI.BaseAddress));
if IsPEImage64 then
Writeln(' (x64)')
else
Writeln(' (x32)');
end;
Inc(Address, MBI.RegionSize);
end;
Readln;
end.
Получится вот такая картинка:
64-битная библиотека в 32-битном приложении? Да проще простого :)
Приложение у меня 32-битное, операционная система Windows 7 x64. Судя по тому что отображено на картинке, в нашем 32-битном процессе спокойно живут и работают четыре 64-битных библиотеки, впрочем, тут ничего не обычного — это так называемый Wow64 (эмуляция Win32 в 64-разрядной Windows).
Зато сразу становится понятно, откуда появляются 64-битные аналоги 32-битных потоков и куч.
Теперь, по-хорошему, нужно получить адреса секций каждого PE файла, чтобы можно было их показать более наглядно. Все секции выравниваются по адресу начала страницы и не пересекаются друг с другом.
Сделаем это вот таким кодом:
procedure GetInfoFromImage(const FileName: string; ImageBase: Pointer);
var
ImageInfo: TLoadedImage;
ImageSectionHeader: PImageSectionHeader;
I: Integer;
begin
if MapAndLoad(PAnsiChar(AnsiString(FileName)), nil, @ImageInfo, True, True) then
try
ImageSectionHeader := ImageInfo.Sections;
for I := 0 to Integer(ImageInfo.NumberOfSections) - 1 do
begin
Write(
IntToHex((NativeUInt(ImageBase) + ImageSectionHeader^.VirtualAddress), 8), ': ',
string(PAnsiChar(@ImageSectionHeader^.Name[0])));
if IsExecute(ImageSectionHeader^.Characteristics) then
Write(' Execute');
if IsWrite(ImageSectionHeader^.Characteristics) then
Write(' Writable');
Writeln;
Inc(ImageSectionHeader);
end;
finally
UnMapAndLoad(@ImageInfo);
end;
Writeln;
end;
Здесь используется вызов функции MapAndLoad, которая, помимо загрузки файла и проверки его заголовков, производит также выравнивание секций посредством вызова NtMapViewOfSection.
Для своего собственного процесса, конечно, вызов данной функции избыточен, т.к. требуемый PE файл и так уже подгружен в адресное пространство процесса, но т.к. нам потребуется более универсальный код для работы и с другими процессами, то воспользуемся именно этим подходом.
MapAndLoad хороша еще и тем, что позволяет 64-битным процессам подгружать 32-битные PE файлы (правда, это не работает для 32-битных процессов), и в дальнейшем эта возможность нам еще пригодится.
Суть кода такова: после выполнения MapAndLoad у нас будет на руках заполненная структура TLoadedImage, параметр Sections которой указывает на массив из структур TImageSectionHeader. У каждой из этих структур есть поле VirtualAddress, которое является смещением от адреса загрузки библиотеки. Сложив значение этого поля с hInstance библиотеки, мы получим адрес секции.
Функции IsExecute и IsWrite проверяют характеристики секции и возвращают True в том случае, если секция содержит исполняемый код (IsExecute) или данные, доступные для модификации (IsWrite). Выглядят они следующим образом:
function IsExecute(const Value: DWORD): Boolean;
begin
Result := False;
if (Value and IMAGE_SCN_CNT_CODE) =
IMAGE_SCN_CNT_CODE then Result := True;
if (Value and IMAGE_SCN_MEM_EXECUTE) =
IMAGE_SCN_MEM_EXECUTE then Result := True;
end;
function IsWrite(const Value: DWORD): Boolean;
begin
Result := False;
if (Value and IMAGE_SCN_CNT_UNINITIALIZED_DATA) =
IMAGE_SCN_CNT_UNINITIALIZED_DATA then Result := True;
if (Value and IMAGE_SCN_MEM_WRITE) = IMAGE_SCN_MEM_WRITE then
Result := True;
end;
В результате работы данного кода мы увидим следующее:
Правда, с этим кодом есть еще один небольшой нюанс.
Как видно было на предыдущей картинке, функция GetMappedFileName возвращает путь к загруженному файлу в следующем виде: "DeviceHarddiskVolume2WindowsSystem32wow64cpu.dll", а функция MapAndLoad требует нормализированного пути вида «C:WindowsSystem32wow64cpu.dll».
За приведение пути к привычному виду отвечает следующий код:
function NormalizePath(const Value: string): string;
const
OBJ_CASE_INSENSITIVE = $00000040;
STATUS_SUCCESS = 0;
FILE_SYNCHRONOUS_IO_NONALERT = $00000020;
FILE_READ_DATA = 1;
ObjectNameInformation = 1;
DriveNameSize = 4;
VolumeCount = 26;
DriveTotalSize = DriveNameSize * VolumeCount;
var
US: UNICODE_STRING;
OA: OBJECT_ATTRIBUTES;
IO: IO_STATUS_BLOCK;
hFile: THandle;
NTSTAT, dwReturn: DWORD;
ObjectNameInfo: TOBJECT_NAME_INFORMATION;
Buff, Volume: string;
I, Count, dwQueryLength: Integer;
lpQuery: array [0..MAX_PATH - 1] of Char;
AnsiResult: AnsiString;
begin
Result := Value;
// Подготавливаем параметры для вызова ZwOpenFile
RtlInitUnicodeString(@US, StringToOleStr(Value));
// Аналог макроса InitializeObjectAttributes
FillChar(OA, SizeOf(OBJECT_ATTRIBUTES), #0);
OA.Length := SizeOf(OBJECT_ATTRIBUTES);
OA.ObjectName := @US;
OA.Attributes := OBJ_CASE_INSENSITIVE;
// Функция ZwOpenFile спокойно открывает файлы, путь к которым представлен
// с использованием символьных ссылок, например:
// SystemRootSystem32ntdll.dll
// ??C:WindowsSystem32ntdll.dll
// DeviceHarddiskVolume1WINDOWSsystem32ntdll.dll
// Поэтому будем использовать ее для получения хендла
NTSTAT := ZwOpenFile(@hFile, FILE_READ_DATA or SYNCHRONIZE, @OA, @IO,
FILE_SHARE_READ or FILE_SHARE_WRITE or FILE_SHARE_DELETE,
FILE_SYNCHRONOUS_IO_NONALERT);
if NTSTAT = STATUS_SUCCESS then
try
// Файл открыт, теперь смотрим его формализованный путь
NTSTAT := NtQueryObject(hFile, ObjectNameInformation,
@ObjectNameInfo, MAX_PATH * 2, @dwReturn);
if NTSTAT = STATUS_SUCCESS then
begin
SetLength(AnsiResult, MAX_PATH);
WideCharToMultiByte(CP_ACP, 0,
@ObjectNameInfo.Name.Buffer[ObjectNameInfo.Name.MaximumLength -
ObjectNameInfo.Name.Length {$IFDEF WIN64} + 4{$ENDIF}],
ObjectNameInfo.Name.Length, @AnsiResult[1],
MAX_PATH, nil, nil);
Result := string(PAnsiChar(AnsiResult));
// Путь на открытый через ZwOpenFile файл
// возвращается в виде DeviceHarddiskVolumeХбла-бла
// Осталось только его сопоставить с реальным диском
SetLength(Buff, DriveTotalSize);
Count := GetLogicalDriveStrings(DriveTotalSize, @Buff[1]) div DriveNameSize;
for I := 0 to Count - 1 do
begin
Volume := PChar(@Buff[(I * DriveNameSize) + 1]);
Volume[3] := #0;
// Преобразуем имя каждого диска в символьную ссылку и
// сравниваем с формализированным путем
QueryDosDevice(PChar(Volume), @lpQuery[0], MAX_PATH);
dwQueryLength := Length(string(lpQuery));
if Copy(Result, 1, dwQueryLength) = string(lpQuery) then
begin
Volume[3] := '';
if lpQuery[dwQueryLength - 1] <> '' then
Inc(dwQueryLength);
Delete(Result, 1, dwQueryLength);
Result := Volume + Result;
Break;
end;
end;
end;
finally
ZwClose(hFile);
end;
end;
Это уже достаточно старый код, постоянно применяемый мной для приведения к нормальному пути. Суть его в том чтобы из путей следующих видов:
- SystemRootSystem32ntdll.dll
- ??C:WindowsSystem32ntdll.dll
- DeviceHarddiskVolume1WINDOWSsystem32ntdll.dll
… получить фиксированный "DeviceHarddiskVolume1WINDOWSsystem32ntdll.dll".
Это делается посредством вызова ZwOpenFile + NtQueryObject, после чего просто перебираются все диски в системе и для каждого вызывается QueryDosDevice, который возвращает путь в таком же формате. После чего пути сравниваются и (при совпадении) к переданному пути подставляется соответствующая метка диска.
Но это лирика.
Чтобы быть полностью довольными собой, желательно вывести так же директории PE файла, чтобы было понятно сразу, где искать, к примеру, таблицу импорта, где сидит UNWIND и т.п.
Это делается довольно простым кодом:
procedure EnumDirectoryes(ImageBase: Pointer; ImageInfo: TLoadedImage;
AddrStart, AddrEnd: NativeUInt);
const
DirectoryStr: array [0..14] of string =
('export', 'import', 'resource', 'exception',
'security', 'basereloc', 'debug', 'copyright',
'globalptr', 'tls', 'load_config', 'bound_import',
'iat', 'delay_import', 'com');
var
I: Integer;
dwDirSize: DWORD;
DirAddr: Pointer;
ReadlDirAddr: NativeUInt;
begin
for I := 0 to 14 do
begin
DirAddr := ImageDirectoryEntryToData(ImageInfo.MappedAddress,
True, I, dwDirSize);
if DirAddr = nil then Continue;
ReadlDirAddr := NativeUint(ImageBase) +
NativeUint(DirAddr) - NativeUint(ImageInfo.MappedAddress);
if (ReadlDirAddr >= AddrStart) and (ReadlDirAddr < AddrEnd) then
Writeln(
IntToHex(ReadlDirAddr, 8), ': directory "', DirectoryStr[I], '"');
end;
end;
Имея на руках структуру TLoadedImage, мы можем достаточно просто вызовом функции ImageDirectoryEntryToData получить её адрес, правда, он будет привязан к адресу, по которому отображен PE файл. Чтобы перевести его в реальный, нужно из текущего адреса вычесть адрес, по которому отображен образ, получив таким образом смещение от начала файла, и уже его сложить с ImageBase библиотеки.
В итоге получится вот такая картинка:
Сразу видно, что, к примеру, в секции ".text" библиотеки msctf.dll расположены директории импорта/экспорта/отложенного импорта и т.п.
Директория с ресурсами сидит в секции ".rsrc", да и релоки тоже там где положено, однако выпадает из схемы директория «bound_import».
Да, действительно, данная директория не располагается непосредственно ни в одной из секций библиотеки, такова ее особенность. Она обычно идет сразу за PE заголовком (хотя иногда может встречаться и в промежутках между секциями). Данная директория служит для обеспечения механизма «привязанного импорта», который встречается в основном только у программ и библиотек, идущих в составе ОС.
Суть её в том, что все адреса импортируемых функций зашиваются в исполняемый файл еще на этапе компиляции, таким образом не нужно выполнять излишних телодвижений, бегая по таблице обычного импорта в поисках адреса функции.
Но и накладные расходы тоже соответствующие, ибо как только изменится любая из библиотек, заявленная в секции привязанного импорта, приложение должно быть перекомпилировано.
5. Блок окружения процесса (PEB) + KUSER_SHARED_DATA
Имея на руках данные о потоках, кучах и исполняемых файлах, уже прямо сейчас можно сделать небольшую утилиту, которая выведет информацию в удобочитаемом виде, но что можно еще добавить?
Как минимум, крайне желательно получать и выводить информацию из блока окружения процесса.
Доступ к нему можно получить вызовом функции NtQueryInformationProcess с флагом ProcessBasicInformation (константа равная нулю). В этом случае на руках будет структура PROCESS_BASIC_INFORMATION, у которой поле PebBaseAddress и будет содержать адрес PEB.
Но это будет актуально, только если битности процессов (запрашивающего и о котором запрашиваем информацию) совпадут. Если мы вызовем данную функцию из 64-битного приложения применительно к 32-битному, то получим адрес 64-битного PEB, а не родного 32-битного.
Для того, чтобы из 64-битного приложения получить доступ к Wow64PEB (назовем его так), необходимо вызвать функцию NtQueryInformationProcess с параметром ProcessWow64Information (константа равная 26) и размером буфера равным SizeOf(ULONG_PTR). В этом случае вместо структуры PROCESS_BASIC_INFORMATION функция вернет указатель на 32-битный PEB, из которого и будем зачитывать нужную нам информацию посредством ReadProcessMemory.
Что такое PEB?
Грубо говоря, это не сильно документированная структура, в большинстве своем предназначенная для хранения данных, используемых непосредственно системой. Но это не означает, что она не интересна разработчику обычного прикладного приложения. В частности данная структура содержит ряд интересных полей, таких как: флаг BeingDebugged, указывающий подключен ли к процессу отладчик; указатель на PEB_LDR_DATA, в которой содержится информация о загруженных в процесс модулях; и много остальной достаточно полезной для программиста информации, особенно для того, кто знает как ее применить в своих целях :)
Выглядит данная структура примерно вот так (декларация для Windows7 x86/64):
PPEB = ^TPEB;
TPEB = record
InheritedAddressSpace: BOOLEAN;
ReadImageFileExecOptions: BOOLEAN;
BeingDebugged: BOOLEAN;
BitField: BOOLEAN;
{
BOOLEAN ImageUsesLargePages : 1;
BOOLEAN IsProtectedProcess : 1;
BOOLEAN IsLegacyProcess : 1;
BOOLEAN IsImageDynamicallyRelocated : 1;
BOOLEAN SkipPatchingUser32Forwarders : 1;
BOOLEAN IsPackagedProcess : 1;
BOOLEAN IsAppContainer : 1;
BOOLEAN SpareBits : 1;
}
Mutant: HANDLE;
ImageBaseAddress: PVOID;
LoaderData: PVOID;
ProcessParameters: PRTL_USER_PROCESS_PARAMETERS;
SubSystemData: PVOID;
ProcessHeap: PVOID;
FastPebLock: PRTLCriticalSection;
AtlThunkSListPtr: PVOID;
IFEOKey: PVOID;
EnvironmentUpdateCount: ULONG;
UserSharedInfoPtr: PVOID;
SystemReserved: ULONG;
AtlThunkSListPtr32: ULONG;
ApiSetMap: PVOID;
TlsExpansionCounter: ULONG;
TlsBitmap: PVOID;
TlsBitmapBits: array[0..1] of ULONG;
ReadOnlySharedMemoryBase: PVOID;
HotpatchInformation: PVOID;
ReadOnlyStaticServerData: PPVOID;
AnsiCodePageData: PVOID;
OemCodePageData: PVOID;
UnicodeCaseTableData: PVOID;
KeNumberOfProcessors: ULONG;
NtGlobalFlag: ULONG;
CriticalSectionTimeout: LARGE_INTEGER;
HeapSegmentReserve: SIZE_T;
HeapSegmentCommit: SIZE_T;
HeapDeCommitTotalFreeThreshold: SIZE_T;
HeapDeCommitFreeBlockThreshold: SIZE_T;
NumberOfHeaps: ULONG;
MaximumNumberOfHeaps: ULONG;
ProcessHeaps: PPVOID;
GdiSharedHandleTable: PVOID;
ProcessStarterHelper: PVOID;
GdiDCAttributeList: ULONG;
LoaderLock: PRTLCriticalSection;
NtMajorVersion: ULONG;
NtMinorVersion: ULONG;
NtBuildNumber: USHORT;
NtCSDVersion: USHORT;
PlatformId: ULONG;
Subsystem: ULONG;
MajorSubsystemVersion: ULONG;
MinorSubsystemVersion: ULONG;
AffinityMask: ULONG_PTR;
{$IFDEF WIN32}
GdiHandleBuffer: array [0..33] of ULONG;
{$ELSE}
GdiHandleBuffer: array [0..59] of ULONG;
{$ENDIF}
PostProcessInitRoutine: PVOID;
TlsExpansionBitmap: PVOID;
TlsExpansionBitmapBits: array [0..31] of ULONG;
SessionId: ULONG;
AppCompatFlags: ULARGE_INTEGER;
AppCompatFlagsUser: ULARGE_INTEGER;
pShimData: PVOID;
AppCompatInfo: PVOID;
CSDVersion: UNICODE_STRING;
ActivationContextData: PVOID;
ProcessAssemblyStorageMap: PVOID;
SystemDefaultActivationContextData: PVOID;
SystemAssemblyStorageMap: PVOID;
MinimumStackCommit: SIZE_T;
FlsCallback: PPVOID;
FlsListHead: LIST_ENTRY;
FlsBitmap: PVOID;
FlsBitmapBits: array [1..FLS_MAXIMUM_AVAILABLE div SizeOf(ULONG) * 8] of ULONG;
FlsHighIndex: ULONG;
WerRegistrationData: PVOID;
WerShipAssertPtr: PVOID;
pContextData: PVOID;
pImageHeaderHash: PVOID;
TracingFlags: ULONG;
{
ULONG HeapTracingEnabled : 1;
ULONG CritSecTracingEnabled : 1;
ULONG LibLoaderTracingEnabled : 1;
ULONG SpareTracingBits : 29;
}
CsrServerReadOnlySharedMemoryBase: ULONGLONG;
end;
Кстати, сравните эту структуру с той, что официально доступна в MSDN.
Для Window 2000/XP/2003 будут небольшие изменения, но не сильно критичные.
Расписывать каждое поле я не буду, те кто работают с PEB и так знают? что именно им нужно, но на некоторых полях я заострю ваше внимание.
Итак:
- Поле BeingDebugged — в третьей части статьи об отладчике я показывал один из вариантов обхода детектирования оного посредством патча памяти приложения. Суть подхода заключалась как раз в определении адреса PEB и изменения значения параметра BeingDebugged на ноль, после чего функция IsDebuggerPresent, ориентирующаяся на данное поле, начинала возвращать False, говоря о том? что отладчика она не обнаружила.
- Поле ImageBaseAddress — указывает на hInstance приложения (оно может не совпадать с полем ImageBase в PE заголовке).
- LoaderData — указатель на данные о загруженных модулях, в нем хранится достаточно полезная информация для тех, кто строит защиту приложения самостоятельно, но, к сожалению, пока что это выходит за рамки данной статьи. На этом поле я остановлюсь чуть подробнее, когда увидит свет статья о детектировании инжекта в ваше приложение :)
- ProcessParameters — откуда берут информацию ParamStr/GetCurrentDir и т.п. функции? Именно отсюда. Здесь же сидит адрес переменных окружения.
- А еще мы можем узнать сервиспак системы, не дергая реестр, в этом нам поможет поле CSDVersion. Да, впрочем, поля NtMajorVersion/NtMinorVersion/NtBuildNumber говорят сами за себя.
Ну и так далее — продолжать можно долго.
Большинство данных полей занимают свои страницы в адресном пространстве процесса. К примеру, ProcessParameters обычно сидит в одной из куч, созданных загрузчиком, переменные окружения расположены тоже где-то в том районе.
Если мы хотим визуализировать все это (а к этому я и веду), эти данные мы должны иметь на руках, чтобы было что отобразить в финальном приложении.
Согласитесь, гораздо приятней иметь на руках вместо некоего блока бинарных данных что-то в виде такого:
А ведь есть еще и KUSER_SHARED_DATA.
Это тоже структура используемая системой, и вы постоянно встречаетесь с ней, вызывая тот же GetTickCount или IsProcessorFeaturePresent.
К примеру, NtSystemRoot сидит именно в ней, да и, опять же, зачем все перечислять, проще увидеть:
- Хотите узнать, что за процесс активен без вызова GetForegroundWindow – читайте ConsoleSessionForegroundProcessId.
- Вам пытаются подсунуть левую версию Win, чтобы отключилась часть системы защиты, не рассчитанная на предыдущие OS? Читайте актуальные значения из полей NtMajorVersion/NtMinorVersion...
Впрочем, пожалуй, здесь мы пока и остановимся…
6. TRegionData
На этом теоретическая часть закончилась и пришла пора применить это все на практике.
Прежде всего нужно определиться с тем, каким образом хранить информацию о регионах. Готовясь к статье, я написал набор классов, выделенных в общее пространство имен «MemoryMap», их вы сможете найти в составе демо-примеров.
ВАЖНО!!!
Данный набор классов разработан с учетом нововведений, присутствующих в Delphi XE4, под более старыми версиями Delphi его работоспособность не проверялась и не гарантируется.
Информацию по каждому региону будет хранить класс TRegionData, реализованный в модуле «MemoryMap.RegionData.pas».
Выглядит он примерно следующим образом (в процессе развития проекта декларация класса может меняться).
TRegionData = class
private
FParent: TRegionData;
FRegionType: TRegionType;
FMBI: TMemoryBasicInformation;
FDetails: string;
FRegionVisible: Boolean;
FHiddenRegionCount: Integer;
FTotalRegionSize: NativeUInt;
FHeap: THeapData;
FThread: TThreadData;
FPEBData: TSystemData;
FSection: TSection;
FContains: TList;
FDirectories: TList;
FShared: Boolean;
FSharedCount: Integer;
FFiltered: Boolean;
protected
...
public
constructor Create;
destructor Destroy; override;
property RegionType: TRegionType read FRegionType;
property MBI: TMemoryBasicInformation read FMBI;
property Details: string read FDetails;
property RegionVisible: Boolean read FRegionVisible;
property HiddenRegionCount: Integer read FHiddenRegionCount;
property Parent: TRegionData read FParent;
property TotalRegionSize: NativeUInt read FTotalRegionSize;
property Heap: THeapData read FHeap;
property Thread: TThreadData read FThread;
property SystemData: TSystemData read FPEBData;
property Section: TSection read FSection;
property Directory: TList read FDirectories;
property Contains: TList read FContains;
end;
По порядку:
Каждый регион, как правило, хранит в себе данные одного типа.
Т.е. для куч, стеков потоков, PE файлов, выделяется свой собственный регион страниц.
За хранение типа региона отвечает свойство RegionType. Это перечислимый тип, объявленный следующим образом:
// Тип региона
TRegionType = (
rtDefault,
rtHeap, // регион содержит элементы кучи
rtThread, // регион содержит стек потока или TEB
rtSystem, // регион содержит системные данные (PEB/KUSER_SHARED_DATA и т.п.)
rtExecutableImage // регион содержит образ исполняемого PE файла
);
Параметры региона, полученные при помощи вызова VirtualQueryEx хранятся в поле MBI.
Краткое описание региона хранится в Details. В нем можно хранить все что угодно, к примеру путь к отображенному PE файлу, если таковой присутствует, строковое описание ID потока и т.п…
Следующие три параметра используются для организации древовидной структуры.
Один из регионов является корневым узлом (рутом), остальные дочерние.
Флаг RegionVisible указывает на то, является ли регион корневым узлом.
Свойство HiddenRegionCount содержит в себе количество подрегионов (AllocationBase которых равен BaseAddress рута).
Ну а параметр Parent хранит ссылку на рута.
Сделано не совсем оптимально, можно было бы организовать и классическое дерево, но на текущий момент банально нет времени переделывать, может быть, когда-нибудь потом :)
TotalRegionSize содержит в себе общий размер всех подрегионов, включая рутовый.
В случае, если регион содержит кучу, данные о первом ее элементе помещаются в параметр Heap, представляющий из себя следующую структуру:
THeapEntry = record
Address: ULONG_PTR;
Size: SIZE_T;
Flags: ULONG;
end;
THeapData = record
ID: DWORD;
Wow64: Boolean;
Entry: THeapEntry;
end;
Остальные элементы кучи, расположенные в рамках региона, размещаются в поле Contains.
Вообще поле Contains может содержать данные многих типов.
TContainItemType = (itHeapBlock, itThreadData,
itStackFrame, itSEHFrame, itSystem);
TContainItem = record
ItemType: TContainItemType;
function Hash: string;
case Integer of
0: (Heap: THeapData);
1: (ThreadData: TThreadData);
2: (StackFrame: TThreadStackEntry);
3: (SEH: TSEHEntry);
4: (System: TSystemData);
end;
Далее идет поле Thread, в нем хранится информация о потоке, который использует регион для хранения собственных данных.
type
TThreadInfo = (tiNoData, tiExceptionList, tiStackBase,
tiStackLimit, tiTEB, tiThreadProc);
type
TThreadData = record
Flag: TThreadInfo;
ThreadID: Integer;
Address: Pointer;
Wow64: Boolean;
end;
Если данных о потоке в пределах региона много (например список SEH фреймов или CallStack потока), они также помещаются в поле Contains.
Данные из системных структур (поля структур PEB/TEB и т.п.) помещаются в поле SystemData, представляющее из себя запись из адреса данных и их описания.
Также эти данные могут быть помещены в поле Contains.
Если регион принадлежит одной из секций PE файла, данные о секции размещаются в параметре Section. Ну а список директорий файла размещается в поле Directory.
Вот как-то так вкратце. Теперь для представления данных о карте памяти процесса нам необходимо получить список регионов, создать для каждого из них экземпляр класса TRegionData и инициализировать поля созданного объекта требуемой информацией.
За это отвечает класс TMemoryMap…
7. TMemoryMap
Данный класс реализован в модуле «MemoryMap.Core.pas».
Задача его сводится буквально к трем основным этапам:
- Получению списка всех выделенных регионов в памяти указанного приложения, данных по нитям/кучам/загруженным образам и т.п..
- Созданию списка TRegionData и заполнению его полей полученной информацией.
- Сохранение/загрузка данных, фильтрация данных.
На практике все выглядит несколько сложнее.
Основная процедура сбора информации выглядит вот так:
function TMemoryMap.InitFromProcess(PID: Cardinal;
const ProcessName: string): Boolean;
var
ProcessLock: TProcessLockHandleList;
begin
Result := False;
FRegions.Clear;
FModules.Clear;
FFilter := fiNone;
ProcessLock := nil;
// Открываем процесс на чтение
FProcess := OpenProcess(
PROCESS_QUERY_INFORMATION or PROCESS_VM_READ,
False, PID);
if FProcess = 0 then
RaiseLastOSError;
try
FPID := PID;
FProcessName := ProcessName;
// определяем битность процесса
FProcess64 := False;
{$IFDEF WIN64}
if not IsWow64(FProcess) then
FProcess64 := True;
{$ELSE}
// если наше приложение 32 битное, а исследуемый процесс 64-битный
// кидаем исключение
if Is64OS and not IsWow64(FProcess) then
raise Exception.Create('Can''t scan process.');
{$ENDIF}
// проверяем необходимость суспенда процесса
if SuspendProcessBeforeScan then
ProcessLock := SuspendProcess(PID);
try
FSymbols := TSymbols.Create(FProcess);
try
FPEImage := TPEImage.Create;
try
FWorkset := TWorkset.Create(FProcess);;
try
// получаем данные по регионам и отмапленым файлам
GetAllRegions;
finally
FWorkset.Free;
end;
{$IFDEF WIN64}
// если есть возможность получаем данные о 32 битных кучах
AddWow64HeapsData;
{$ENDIF}
// добавляем данные о потоках
AddThreadsData;
// добавляем данные о кучах
AddHeapsData;
// добавляем данные о Process Environment Block
AddPEBData;
// добавляем данные о загруженых PE файлах
AddImagesData;
finally
FPEImage.Free;
end;
finally
FSymbols.Free;
end;
finally
if SuspendProcessBeforeScan then
ResumeProcess(ProcessLock);
end;
// сортируем
SortAllContainsBlocks;
// считаем общую информацию о регионах
CalcTotal;
// применяем текущий фильтр
UpdateRegionFilters;
finally
CloseHandle(FProcess);
end;
end;
Примерный код процедур GetAllRegions/AddThreadsData/AddHeapsData и AddImagesData я приводил в первых четырех главах и на нем я заострять внимание не буду, а вот с остальным желательно разобраться.
Самым первым шагом после открытия процесса происходит определение битности процесса.
Это необходимо по той причине, что в случае, если битности процессов (текущего и по которому мы получаем информацию), не совпадают, то нужно предпринять некоторые дополнительные действия.
Общая схема такая:
- 32-битный процесс может получить данные по 32-битному под 32-битной ОС в полном объеме.
- 64-битный процесс может получить данные по 64-битному в полном объеме.
- 32-битный процесс НЕ МОЖЕТ получить данные по 64-битному.
- 32-битный процесс может получить данные по 32-битному под 64-битной ОС, но частично.
- 64-битный процесс может получить данные по 32-битному, но частично.
Если с первыми двумя пунктами все ясно, то остальные три рассмотрим поподробнее.
Причина того что 32-битный процесс не сможет получить данные по 64-битному простая: не позволит размер указателя, плюс ReadProcessMemory периодически будет выдавать ошибку ERROR_PARTIAL_COPY.
А вот с получением данных из 32-битного процесса в 64-битной ОС все гораздо хитрее.
Как я говорил ранее, в 32-битном приложении загружены четыре 64-битные библиотеки, которые создают свои кучи/потоки.
Если мы будем получать список куч и потоков из 32-битного приложения, то увидим данные только относящиеся к 32 битам, данные по 64-битным аналогам получить не удастся.
То же будет и в случае запроса данных о 32-битном процессе из 64-битного, вернутся только данные, относящиеся к 64 битам. Хотя в этом случае есть вариант получить их частично.
В частности, доступ к 32-битному PEB производится вызовом такой функции:
const
ProcessWow64Information = 26;
...
NtQueryInformationProcess(FProcess, ProcessWow64Information,
@FPebWow64BaseAddress, SizeOf(ULONG_PTR), @ReturnLength)
Доступ к 32-битному TEB можно получить, считав адрес из 64-битного TEB, который хранится в параметре NtTIB.ExceptionList.
// в 64 битном TEB поле TIB.ExceptionList указывает на начало Wow64TEB
if not ReadProcessMemory(hProcess,
TIB.ExceptionList, @WOW64_NT_TIB, SizeOf(TWOW64_NT_TIB),
lpNumberOfBytesRead) then Exit;
Получить контекст 32-битного потока для раскрутки CallStack можно вот таким кодом:
const
ThreadWow64Context = 29;
...
ThreadContext^.ContextFlags := CONTEXT_FULL;
if NtQueryInformationThread(hThread, ThreadWow64Context, ThreadContext,
SizeOf(TWow64Context), nil) <> STATUS_SUCCESS then Exit;
Либо вызовом функции Wow64GetThreadContext.
А вот как получить данные о 32-битных кучах из 64-битного процесса легальным способом, мне неизвестно. Единственный вариант, который я применяю сейчас, это передача команды 32-битному процессу, который собирает данные о 32-битных кучах и отдает их обратно в 64-битный (примерно этим и занимается обработчик в функции AddWow64HeapsData).
Теперь, когда с определением битности процесса и для чего это нужно, разобрались, пойдем дальше, а именно к вызову функции SuspendProcess.
По-хорошему, это нужно только для того, чтобы данные в удаленном процессе не изменились на неактуальные в момент их чтения. Правда, обычно я применяю этот набор классов в двух случаях, для своего собственного приложения или для приложения находящегося под отладчиком. В обоих случаях замораживать потоки не нужно, но если анализируется какое-то стороннее приложение, то почему бы и нет?
После заморозки удаленного процесса создаются три вспомогательных класса.
- TSymbols — о нем я расскажу в следующей главе.
- TPEImage — этот класс содержит в себе методы, позволяющие получить информацию о PE файле, описанные в четвертой главе. Сделан исключительно для удобства.
- TWorkset — еще один вспомогательный класс, задача которого получить информацию об общедоступной памяти.
По сути, TWorkset хранит в себе список структур вида:
TShareInfo = record
Shared: Boolean;
SharedCount: Byte;
end;
Эти структуры хранятся в словаре и каждая ассоциируется с конкретным адресом страницы.
Параметры простые:
- Shared — является ли страница общедоступной
- SharedCount — сколько ссылок есть на страницу
Получаются эти данные следующим способом, в котором все сводится к вызову функции QueryWorkingSet:
procedure TWorkset.InitWorksetData(hProcess: THandle);
const
{$IFDEF WIN64}
AddrMask = $FFFFFFFFFFFFF000;
{$ELSE}
AddrMask = $FFFFF000;
{$ENDIF}
SharedBitMask = $100;
SharedCountMask = $E0;
function GetSharedCount(Value: ULONG_PTR): Byte; inline;
begin
Result := (Value and SharedCountMask) shr 5;
end;
var
WorksetBuff: array of ULONG_PTR;
I: Integer;
ShareInfo: TShareInfo;
begin
SetLength(WorksetBuff, $400000);
while not QueryWorkingSet(hProcess, @WorksetBuff[0],
Length(WorksetBuff) * SizeOf(ULONG_PTR)) do
SetLength(WorksetBuff, WorksetBuff[0] * 2);
for I := 0 to WorksetBuff[0] - 1 do
begin
ShareInfo.Shared := WorksetBuff[I] and SharedBitMask <> 0;
ShareInfo.SharedCount := GetSharedCount(WorksetBuff[I]);
try
FData.Add(Pointer(WorksetBuff[I] and AddrMask), ShareInfo);
except
on E: EListError do ;
else
raise;
end;
end;
end;
Данная функция возвращает массив ULONG_PTR, каждый элемент которого хранит данные следующим образом: первые пять бит хранят в себе атрибуты защиты страницы; следующие три бита – количество процессов, которым доступна данная страница; еще один бит указывает общедоступность страницы; ну и далее идет адрес самой страницы.
Более подробно можно прочитать тут: PSAPI_WORKING_SET_BLOCK.
По сути, это просто информационный класс, ни больше ни меньше.
Впрочем, вернемся к нашему коду.
Следующими шагами идут:
- GetAllRegions — аналог кода из первой главы.
- AddThreadsData — аналог кода из второй главы.
- AddHeapsData — аналог кода из третьей главы.
- AddPEBData — вывод данных о структуре из пятой главы.
- AddImagesData — аналог кода из четвертой главы.
Как видите, все интересное я уже рассказал (ну почти) :)
Оставшиеся шаги не интересны, за исключением вызова UpdateRegionFilters.
Он выполняет утилитарную функцию, а именно исключает из списка ненужные на текущий момент регионы (ну, к примеру, убирает регионы с невыделенной памятью и т.п).
Данная процедура будет вызываться постоянно при изменении фильтра через свойство Filter.
Впрочем, все это вы сможете при желании увидеть из кода самого класса.
Работать с ним достаточно просто:
var
AMemoryMap: TMemoryMap;
M: TMemoryStream;
I: Integer;
begin
try
M := TMemoryStream.Create;
try
// Создаем класс
AMemoryMap := TMemoryMap.Create;
try
// получаем текущую карту памяти
AMemoryMap.InitFromProcess(GetCurrentProcessId, '');
// сохраняем ее,
AMemoryMap.SaveToStream(M);
// тут можно прикрутить дампы регионов и все что душе угодно
finally
AMemoryMap.Free;
end;
// тут якобы передали данные куда-то, теперь загружаем их и работаем
M.Position := 0;
// Создаем класс
AMemoryMap := TMemoryMap.Create;
try
// загружаем данные
AMemoryMap.LoadFromStream(M);
// убираем вообще все фильтры
AMemoryMap.Filter := fiNone; // не обязательно
// говорим отображать регионы с невыделенной памятью
AMemoryMap.ShowEmpty := True;
// выводим список регионов
for I := 0 to AMemoryMap.Count - 1 do
Writeln(NativeUInt(AMemoryMap[I].MBI.BaseAddress));
finally
AMemoryMap.Free;
end;
finally
M.Free;
end;
except
on E: Exception do
Writeln(E.ClassName, ': ', E.Message);
end;
Readln;
end.
Как говорится, писал сам для себя, поэтому и работать с этим классом проще простого :)
8. TSymbols — работа с символами
Суть данного класса заключается в получении более подробной информации об адресе в процессе. Ну, к примеру, во второй главе мы получали CallStack потока (или обработчики SEH фреймов) и это были просто некие адреса. Но гораздо интереснее вместо сухих чисел видеть что-то наподобие этой картинки:
Делается это очень просто — достаточно вызова функции SymGetSymFromAddr, но есть несколько нюансов.
Давайте сначала посмотрим на код:
function TSymbols.GetDescriptionAtAddr(Address, BaseAddress: ULONG_PTR;
const ModuleName: string): string;
const
BuffSize = $7FF;
{$IFDEF WIN64}
SizeOfStruct = SizeOf(TImagehlpSymbol64);
MaxNameLength = BuffSize - SizeOfStruct;
var
Symbol: PImagehlpSymbol64;
Displacement: DWORD64;
{$ELSE}
SizeOfStruct = SizeOf(TImagehlpSymbol);
MaxNameLength = BuffSize - SizeOfStruct;
var
Symbol: PImagehlpSymbol;
Displacement: DWORD;
{$ENDIF}
begin
Result := '';
if not FInited then Exit;
GetMem(Symbol, BuffSize);
try
Symbol^.SizeOfStruct := SizeOfStruct;
Symbol^.MaxNameLength := MaxNameLength;
Symbol^.Size := 0;
SymLoadModule(FProcess, 0, PAnsiChar(AnsiString(ModuleName)),
nil, BaseAddress, 0);
try
if SymGetSymFromAddr(FProcess, Address, @Displacement, Symbol) then
Result := string(PAnsiChar(@(Symbol^).Name[0])) + ' + 0x' + IntToHex(Displacement, 4)
else
begin
// с первой попытки может и не получиться
SymLoadModule(FProcess, 0, PAnsiChar(AnsiString(ModuleName)), nil, BaseAddress, 0);
if SymGetSymFromAddr(FProcess, Address, @Displacement, Symbol) then
Result := string(PAnsiChar(@(Symbol^).Name[0])) + ' + 0x' + IntToHex(Displacement, 4);
end;
finally
SymUnloadModule(FProcess, BaseAddress);
end;
finally
FreeMem(Symbol);
end;
if Result = '' then
Result := ExtractFileName(ModuleName) + ' + 0x' + IntToHex(Address - BaseAddress, 1);
end;
Для правильного получения описания имени функции, которой принадлежит адрес, необходимо знать путь к библиотеке, которой принадлежит функция, либо адрес, по которому данная библиотека подгружена (в коде используются оба параметра). Эти параметры необходимы для функции SymLoadModule.
Второй нюанс заключается в том, что вызов функции SymGetSymFromAddr иногда может завершиться неуспешно. Причина мне не ясна, но в интернете периодически описывают данную ситуацию и как решение её — повторный вызов функции SymLoadModule без вызова SymUnloadModule. В таком странном поведении не разбирался – но действительно помогает.
Последний из нюансов заключается в том, что данная функция вернет валидное описание адреса только тогда, когда эта информация присутствует (загружены символы из внешнего файла или они есть в составе искомого модуля).
Эта информация не является сильно важной при отладке, но немного ее упрощает.
Вот так, к примеру, выглядит стандартный стек потока браузера Chrome (CallStack + SEH фреймы):
Более полезная информация, которую могут предоставить символы, это список экспортируемых функций библиотеки и их текущие адреса.
В классе TSymbols эта информация получается вызовом процедуры GetExportFuncList и выглядит следующим образом:
function SymEnumsymbolsCallback(SymbolName: LPSTR; SymbolAddress: ULONG_PTR;
SymbolSize: ULONG; UserContext: Pointer): Bool; stdcall;
var
List: TStringList;
begin
List := UserContext;
List.AddObject(string(SymbolName), Pointer(SymbolAddress));
Result := True;
end;
procedure TSymbols.GetExportFuncList(const ModuleName: string;
BaseAddress: ULONG_PTR; Value: TStringList);
begin
SymLoadModule(FProcess, 0, PAnsiChar(AnsiString(ModuleName)),
nil, BaseAddress, 0);
try
if not SymEnumerateSymbols(FProcess, BaseAddress,
@SymEnumsymbolsCallback, Value) then
begin
SymLoadModule(FProcess, 0, PAnsiChar(AnsiString(ModuleName)),
nil, BaseAddress, 0);
SymEnumerateSymbols(FProcess, BaseAddress,
@SymEnumsymbolsCallback, Value)
end;
finally
SymUnloadModule(FProcess, BaseAddress);
end;
end;
Все сводится к вызову SymEnumerateSymbols, которой передается адрес функции обратного вызова.
При ее вызове параметр SymbolName будет содержать имя экспортируемой функции, а SymbolAddress ее адрес.
Этого вполне достаточно для того, чтобы отобразить пользователю вот такую табличку:
Более подробно реализацию данного класса, включая опущенные вызовы SymSetOptions и SymInitialize, вы сможете увидеть в модуле «MemoryMap.Symbols.pas».
9. ProcessMemoryMap
Ну вот мы подошли и к заключительной части статьи.
Как я и говорил ранее, я использую набор классов MemoryMap в двух вариантах:
1. Интегрируя его в вывод EurekaLog посредством перекрытия ее обработчика OnAttachedFilesRequest, где добавляю текущую карту процесса актуальную на момент возникновения исключения, и дампы всех Private регионов (страниц, не ассоциированных с определенными данными, имеющих флаг MEM_PRIVATE) и стеков потоков, плюс часть информации из PEB. Обычно этого достаточно для разбора причин возникновения ошибки.
2. Использую как альтернативный инструмент для анализа отлаживаемого приложения.
Для второго варианта была реализована отдельная утилита, которая работает непосредственно с классами MemoryMap, плюс добавляет некий дополнительный функционал.
Описывать ее исходный код я не буду, пройдусь только немного по функционалу.
С интерфейсной части она практически один в один напоминает VMMap. Впрочем, так и планировалось изначально, ибо такой интерфейс наиболее удобен для анализа.
В верхней части расположен список с общей информацией по регионам, сгруппированным по их типам, он же является фильтром.
На текущий момент она представляет следующий функционал:
1. Просмотр содержимого памяти по указанному адресу (Ctrl+Q).
Этот функционал, в принципе, присутствует в отладчике Delphi в окне CPU View, но возможностей у этого режима гораздо больше. К примеру, в случае просмотра поля PEB, будут выводится данные в другом виде:
Вот так будет выглядеть блок параметров процесса:
Ну и так далее. Всего на данный момент утилита может выводить размапленные данные по следующим структурам:
- PEB — Process Environment Block (32/64)
- TEB — Thread Environment Block (32/64)
- KUSER_SHARED_DATA
- PE Header (IMAGE_DOS_HEADER / IMAGE_NT_HEADER / IMAGE_FILE_HEADER / IMAGE_OPTIONAL_HEADER(32/64) / IMAGE_DATA_DIRECTORY / IMAGE_SECTION_HEADERS)
- Process Parameters (32/64)
Этот список не окончательный, периодически в него будут добавляться новые структуры.
2. Поиск данных в памяти процесса (Ctrl+F):
Этот функционал в отладчике Delphi, к сожалению, отсутствует.
Поиск можно производить как по Ansi, так и по Unicode строке, либо просто по абстрактному HEX буферу. При поиске можно указывать адрес начала поиска, а так же флаг, указывающий на необходимость поиска в страницах, доступ к которым возможен только на чтение.
Результат выводится в виде окна с дампом памяти, показанного выше.
3. Компаратор двух карт памяти. Включается в настройках.
Позволяет найти отличия между двумя картами памяти и выводит их в виде текста.
Сравниваются только сами карты, а не данные. Т.е. если по какому-то адресу изменились 4 байта, это изменение не отобразится. Но вот в том случае, если изменился размер региона, удалилась куча, выгрузился/загрузился файл и т.п. — все это будет отображено в результатах сравнения.
Сравнивать можно как текущий снимок карты с сохраненным ранее, так и при обновлении снимка по горячей клавише F5.
4. Дамп памяти.
Также отсутствующий в отладчике Delphi функционал. Позволяет сохранить на диск содержимое памяти указанного региона либо данные с указанного адреса.
5. Вывод всех доступных экспортируемых функций из всех библиотек, подгруженных в анализируемый процесс (Ctrl+E).
А также быстрый поиск функции по ее наименованию или адресу.
Пока что текущего функционала для меня лично достаточно, и новый я не добавлял, но в перспективе данная утилита будет развиваться.
ProcessMemoryMap является OpenSource проектом.
Ее последний стабильный релиз всегда доступен по ссылке: http://rouse.drkb.ru/winapi.php#pmm2
GitHub репозиторий с последними изменениями кода можно обнаружить здесь: https://github.com/AlexanderBagel/ProcessMemoryMap
Прямая ссылка на исходный код: https://github.com/AlexanderBagel/ProcessMemoryMap/archive/master.zip
Прямая ссылка на последнюю сборку: http://rouse.drkb.ru/files/processmm_bin.zip
Для самостоятельной сборки потребуется установленный пакет компонентов Virtual TreeView версии 5 и выше: http://www.soft-gems.net/.
Сборка осуществляется с использованием Delphi XE4 и выше в режиме «Win32/Release», при этом автоматически будет собрана и подключена (в виде ресурса) 64-битная версия данной утилиты.
Под более старыми версиями Delphi работоспособность ProcessMemoryMap не проверялась.
10. В качестве заключения
Ну что ж, надеюсь данный материал будет для вас полезен. Я, конечно, прошел только по самым вершкам, ибо если раскрывать весь материал более подробно, то объем статьи неимоверно увеличится.
Поэтому вот вам несколько ссылок, по которым вы сможете узнать немного больше информации.
Информацию о системных структурах TEB/PEB и т.п. можно найти здесь:
http://processhacker.sourceforge.net/
http://redplait.blogspot.ru/
http://www.reactos.org/ru
Информация о PE файлах:
http://msdn.microsoft.com/en-us/magazine/ms809762.aspx
Информация о SEH:
http://msdn.microsoft.com/en-us/library/ms680657(v=VS.85).aspx
http://www.microsoft.com/msj/0197/exception/exception.aspx
http://qxov.narod.ru/articles/seh/seh.html
Исходный код всех демо-примеров можно забрать по данной ссылке.
Огромное СПАСИБО форуму «Мастера Дельфи» за неоднократную помощь в подготовке статьи.
Персональное спасибо за вычитку материала Дмитрию aka «брат Птибурдукова», Андрею Васильеву aka «Inovet», а также Сергею aka «Картман».
Удачи.
Автор: Rouse