Фундаментальные основы хакерства
Пятнадцать лет назад эпический труд Криса Касперски «Фундаментальные основы хакерства» был настольной книгой каждого начинающего исследователя в области компьютерной безопасности. Однако время идет, и знания, опубликованные Крисом, теряют актуальность. Редакторы «Хакера» попытались обновить этот объемный труд и перенести его из времен Windows 2000 и Visual Studio 6.0 во времена Windows 10 и Visual Studio 2019.
Ссылки на другие статьи из этого цикла ищи на странице автора.
Казалось бы, что может быть сложного в идентификации строк? Если то, на что ссылается указатель, выглядит как строка, это и есть строка! Более того, в подавляющем большинстве случаев строки обнаруживаются и идентифицируются тривиальным просмотром дампа программы (при условии, конечно, что они не зашифрованы, но шифрование — тема отдельного разговора). Так‑то оно так, да не все столь просто!
Задача номер один — автоматизированное выявление строк в программе: не пролистывать же мегабайтовые дампы вручную? Существует множество алгоритмов идентификации строк. Самый простой (но не самый надежный) основан на двух тезисах:
- строка состоит из ограниченного ассортимента символов. В грубом приближении — это цифры, буквы алфавита (включая пробелы), знаки препинания и служебные символы наподобие табуляции или возврата каретки;
- строка должна состоять по крайней мере из нескольких символов.
Условимся считать минимальную длину строки равной N байтам. Тогда для автоматического выявления всех строк достаточно отыскать все последовательности из N и более «строковых» символов. Весь вопрос в том, чему должна быть равна N и какие символы считать «строковыми».
Если N имеет малое значение, порядка трех‑четырех байтов, то мы получим очень большое количество ложных срабатываний. Напротив, когда N велико, порядка шести‑восьми байтов, число ложных срабатываний близко к нулю и ими можно пренебречь, но все короткие строки, например OK
, YES
, NO
, окажутся не распознаны! Другая проблема: помимо знако‑цифровых символов, в строках встречаются и элементы псевдографики (особенно часты они в консольных приложениях) и всякие там «мордашки», «стрелки», «карапузики» — словом, почти вся таблица ASCII. Чем же тогда строка отличается от случайной последовательности байтов? Частотный анализ здесь бессилен: ему для нормальной работы требуется как минимум сотня байтов текста, а мы говорим о строках из двух‑трех символов!
Зайдем с другого конца. Если в программе есть строка, значит, на нее кто‑нибудь да ссылается. А раз так, можно поискать среди непосредственных значений указатель на распознанную строку. И если он будет найден, шансы на то, что это действительно именно строка, а не случайная последовательность байтов, резко возрастают. Все просто, не так ли?
Просто, да не совсем! Рассмотрим следующий пример (writeln_d
):
program writeln_d;begin Writeln('Hello, Sailor!');end.
Откомпилируем этот пример. Хотелось бы сказать, любым Pascal-компилятором, только любой нам не подойдет, поскольку нам нужен бинарный код под архитектуру x86-64. Это автоматически сужает круг подходящих компиляторов. Даже популярный Free Pascal все еще не умеет билдить программы для Windows x64. Но не убирай его далеко, он нам еще пригодится.
В таком случае нам придется воспользоваться Embarcadero Delphi 10.4. Настрой компилятор для построения 64-битных приложений и загрузи откомпилированный файл в дизассемблер:
IDA определила, что перед вызовом функции _ZN6System14_Write0UStringERNS_8TTextRecENS_13UnicodeStringE
в регистр RDX
загружается указатель на смещение aHelloSailor
. Посмотрим, куда оно указывает (дважды щелкнем по нему):
.text:000000000040E6DC aHelloSailor: ; DATA XREF: _ZN9Writeln_d14initializationEv+26↑o.text:000000000040E6DC text "UTF-16LE", 'Hello, Sailor!',0
Ага! Текст в кодировке UTF-16LE. Что такое UTF-16, думаю, всем понятно. Два конечных символа обозначают порядок байтов. В данном случае, поскольку приложение скомпилировано для архитектуры x86-64, в которой используется порядок байтов «от младшего к старшему» — little endian, упомянутые символы говорят именно об этом. В противоположном случае, например на компьютере с процессором SPARC, кодировка имела бы название UTF-16BE от big endian.
Из этого следует, что Delphi кодирует каждый символ переменным количеством байтов: 2 или 4. Посмотрим, как себя поведет Visual C++ 2019 с аналогичным кодом:
#include <stdio.h>int main() { printf("%s", "Hello, Sailor!");}
Результат дизассемблирования:
main proc near sub rsp, 28h lea rdx, aHelloSailor ; "Hello, Sailor!" lea rcx, _Format ; "%s" call printf xor eax, eax add rsp, 28h retnmain endp
Поинтересуемся, что находится в сегменте данных только для чтения (rdata
) по смещению aHelloSailor
:
.rdata:0000000140002240 aHelloSailor db 'Hello, Sailor!',0 ; DATA XREF: main+4↑o
Никаких дополнительных сведений о размере символов. Из этого можно сделать вывод, что используется стандартная 8-битная кодировка ASCII, в которой под каждый символ отводится только 1 байт.
Ядро Windows NT изначально использовало для работы со строковыми символами кодировку UTF-16, однако до Windows 10 в пользовательском режиме применялись две кодировки: UTF-16 и ASCII (нижние 128 символов для английского языка, верхняя половина — для русского). Начиная с Windows 10, в user mode используется только UTF-16. Между тем символы могут храниться и в ASCII, что мы видели в примере выше.
В C/C++ char является исходным типом символа и позволяет хранить любой символ нижней и верхней частей кодировки ASCII размером 8 бит. Хотя реализация типа wchar_t полностью лежит на совести разработчика компилятора, в Visual C++ он представляет собой полный аналог символа кодировки UTF-16LE, то есть позволяет хранить любой символ Юникода.
Для демонстрации двух основных символьных типов в Visual C++ напишем элементарный пример:
#include <iostream>int main() { std::cout << "size of 'char': " << sizeof(char) << "\n"; std::cout << "size of 'wchar': " << sizeof(wchar_t) << "\n"; char line1[] = "Hello, Sailor!"; wchar_t line2[] = L"Hello, Sailor!"; std::cout << "size of 'array of chars': " << sizeof(line1) << "\n"; std::cout << "size of 'array of wchars': " << sizeof(line2) << "\n";}
Результат его выполнения представлен ниже.
Думаю, все понятно без подробных пояснений: char
— 1 байт, wchar_t
— 2 байта. Строка "Hello, Sailor!" состоит из 14 символов, плюс конечный 0. Это также отражено в выводе программы.
info
В стандарте C++ есть типы символов: char8_t
, char16_t
, char32_t
. Первый из них был добавлен с введением стандарта C++20, два других добавлены в C++11. Их размер отражается в их названиях: char16_t
используется для символов кодировки UTF-16, char32_t
— для UTF-32. При этом char8_t
не то же самое, что «унаследованный» char
, хотя позволяет работать с символами последнего, главным образом он предназначен для литералов кодировки UTF-8.
Размеры символов важны для обнаружения границ строк при анализе дизассемблерных листингов программ.
Можно сделать вывод, что современные Visual C++ и Delphi оперируют одинаковыми типами строк, неважно какого размера, но оканчивающиеся символом 0. Но так было не всегда. В качестве исторического экскурса откомпилируем пример writeln_d
компилятором Free Pascal.
Загрузим результат в IDA.
_main proc nearargc = dword ptr 8argv = dword ptr 0Chenvp = dword ptr 10h push ebp mov ebp, esp push ebx call FPC_INITIALIZEUNITS call fpc_get_output mov ebx, eax mov ecx, offset _$WRITELN_FP$_Ld1 mov edx, ebx mov eax, 0 call FPC_WRITE_TEXT_SHORTSTR call FPC_IOCHECK mov eax, ebx call fpc_writeln_end call FPC_IOCHECK call FPC_DO_EXIT_main endp
Так‑так‑так… какой интересный код для нас приготовил Free Pascal! Сразу же бросается в глаза смещение
_$WRITELN_FP$_Ld1
адрес которого помещается в регистр ECX
перед вызовом процедуры FPC_WRITE_TEXT_SHORTSTR
, своим названием намекающей на вывод текста. Постой, ведь это же 32-разрядная программа, где передача параметров в регистрах скорее исключение, чем правило, и используется только при соглашении fastcall, в остальных же случаях параметры передаются через стек!
Заглянем‑ка в документацию по компилятору… Есть контакт! По умолчанию в коде механизма вызова процедур для процессоров i386 используется соглашение register. У нормальных людей оно называется fastcall. И, поскольку для платформы x86 оно не стандартизировано, в отличие от x64, для передачи параметров используются все свободные регистры! Поэтому в том, что используется регистр ECX
, нет ничего сверхъестественного.
Чтобы окончательно убедиться в нашей догадке, посмотрим, как распоряжается переданным параметром вызываемая функция FPC_WRITE_TEXT_SHORTSTR
:
FPC_WRITE_TEXT_SHORTSTR proc near ; CODE XREF: _main+1C↑p ; sub_403320+31↑p ... push ebx push esi push edi mov ebx, eax mov esi, edx mov edi, ecx ; Копирование параметра в регистр EDI
Но тут много чего копируется, поэтому эта инструкция не доказательство. Смотрим дальше.
mov edx, ds:FPC_THREADVAR_RELOCATE test edx, edx jz short loc_40661C mov eax, ds:U_$SYSTEM_$$_INOUTRES call edx ; FPC_THREADVAR_RELOCATE jmp short loc_406621; ---------------------------------------------------------------------------loc_40661C: ; CODE XREF: FPC_WRITE_TEXT_SHORTSTR+11↑j mov eax, offset unk_40B154loc_406621: ; CODE XREF: FPC_WRITE_TEXT_SHORTSTR+1A↑j cmp word ptr [eax], 0 jnz loc_4066AC mov eax, [esi+4] cmp eax, 0D7B1h jl short loc_40668C sub eax, 0D7B1h jz short loc_40666C sub eax, 1 jnz short loc_40668C mov esi, esi
Ага! Следующая инструкция копирует указатель, преобразуя его в 32-разрядное значение без учета знака (указатель не может быть отрицательным). Затем с помощью команды cmp
сравниваются значения двух регистров: EAX
и EBX
. И если EAX
больше или равен EBX
, выполняется переход на метку loc_40665C
...
movzx eax, byte ptr [edi] cmp eax, ebx jge short loc_40665C movzx eax, byte ptr [edi] mov edx, ebx sub edx, eax mov eax, esi call sub_4064F0 lea esi, [esi+0]loc_40665C: ; CODE XREF: FPC_WRITE_TEXT_SHORTSTR+49↑j
...где происходит похожая на манипуляцию со строкой деятельность.
movzx ecx, byte ptr [edi] lea edx, [edi+1] mov eax, esi call sub_406460 ...
Теперь мы смогли убедиться в правильности нашего предположения! Вернемся к основному исследованию и посмотрим, что же скрывается под подозрительным смещением:
.rdata:00409004 _$WRITELN_FP$_Ld1 db 0Eh ; DATA XREF: _main+10↑o.rdata:00409005 db 48h ; H.rdata:00409006 db 65h ; e.rdata:00409007 db 6Ch ; l.rdata:00409008 db 6Ch ; l.rdata:00409009 db 6Fh ; o.rdata:0040900A db 2Ch ; ,.rdata:0040900B db 20h.rdata:0040900C db 53h ; S.rdata:0040900D db 61h ; a.rdata:0040900E db 69h ; i.rdata:0040900F db 6Ch ; l.rdata:00409010 db 6Fh ; o.rdata:00409011 db 72h ; r.rdata:00409012 db 21h ; !.rdata:00409013 db 0
Согласись, не это мы ожидали увидеть. Однако последовательное расположение символов строки «в столбик» дела не меняет. Интересен другой момент: в начале строки стоит число, показывающее количество символов в строке, — 0xE
(14 в десятичной системе).
Оказывается, мало идентифицировать строку, требуется еще как минимум определить ее границы.
Типы строк
Наиболее популярны следующие типы строк: С‑строки, которые завершаются нулем; DOS-строки, завершаются символом $
(такие строки используются не только в MS-DOS); Pascal-строки, которые предваряет одно-, двух- или четырехбайтовое поле, содержащее длину строки. Рассмотрим каждый из этих типов подробнее.
С-строки
С‑строки, также именуемые ASCIIZ-строками (от Zero — ноль на конце) или нуль‑терминированными, — весьма распространенный тип строк, широко использующийся в операционных системах семейств Windows и UNIX. Символ \
(не путать с 0) имеет специальное предназначение и трактуется по‑особому, как признак завершения строки. Длина ASCIIZ-строк практически ничем не ограничена, ну разве что размером адресного пространства, выделенного процессу. Поэтому теоретически в Windows NT х64 максимальный размер ASCIIZ-строки лишь немногим менее 16 Тбайт.
Фактическая длина ASCIIZ-строк лишь на байт длиннее исходной ASCII-строки. Несмотря на перечисленные выше достоинства, С‑строкам присущи и некоторые недостатки. Во‑первых, ASCIIZ-строка не может содержать нулевых байтов, поэтому она непригодна для обработки бинарных данных. Во‑вторых, операции копирования, сравнения и конкатенации С‑строк сопряжены со значительными накладными расходами — современным процессорам невыгодно работать с отдельными байтами, им желательно иметь дело с четверными словами.
Но, увы, длина ASCIIZ-строк наперед неизвестна, и ее приходится вычислять на лету, проверяя каждый байт на символ завершения. Правда, разработчики некоторых компиляторов идут на хитрость: они завершают строку семью нулями, что позволяет работать с двойными словами, а это на порядок быстрее. Почему семью, а не четырьмя, ведь в двойном слове байтов четыре? Да, верно, четыре, но подумай, что произойдет, если последний значимый символ строки придется на первый байт двойного слова? Верно, его конец заполнят три нулевых байта, но двойное слово из‑за вмешательства первого символа уже не будет равно нулю! Вот поэтому следующему двойному слову надо предоставить еще четыре нулевых байта, тогда оно гарантированно будет равно нулю. Впрочем, семь служебных байтов на каждую строку — это уже перебор!
DOS-строки
В MS-DOS (и не только в ней) функция вывода строки воспринимает знак $
как символ завершения, поэтому в программистских кулуарах такие строки называют DOS-строками. Термин не совсем корректен: все остальные функции MS-DOS работают исключительно с ASCIIZ-строками!
Продолжение доступно только участникам
Вариант 1. Присоединись к сообществу «Xakep.ru», чтобы читать все материалы на сайте
Членство в сообществе в течение указанного срока откроет тебе доступ ко ВСЕМ материалам «Хакера», позволит скачивать выпуски в PDF, отключит рекламу на сайте и увеличит личную накопительную скидку! Подробнее
Вариант 2. Открой один материал
Заинтересовала статья, но нет возможности стать членом клуба «Xakep.ru»? Тогда этот вариант для тебя! Обрати внимание: этот способ подходит только для статей, опубликованных более двух месяцев назад.
Я уже участник «Xakep.ru»