Лечим родной драйверПо этой причине я начал с драйвера. К тому же, чтобы заняться драйвером, не нужно вообще вставать из-за стола — все инструменты под рукой.
Подход был простым: пытаться печатать (хоть из Блокнота) и отладчиком смотреть, что вызывает падение. Драйвер-то не ядерный, а юзермодный, так что всё вообще легко. Да и было понимание, что с высокой степенью вероятности всё лечение будет супер-тривиальным: что скорее всего я наткнусь на вызов
VirtualAlloc/
HeapAlloc, при котором память выделяется без X-атрибута. Стоило бы чуть-чуть подправить вызов выделяющей функции и всё бы заработало.
Такой фикс заключался бы в правке одного единственного байта в файле! Очень заманчивая перспектива!
Итак, я погрузился в отладку. Стоило отправить абракадабру из Блокнота на печать, как Блокнот вылетел, и я подключился к нему отладчиком (OllyDbg).
Вот исключение и место, где оно возникло:
Как и следовало ожидать, был осуществлён переход на адрес
0x0006D4A0, который принадлежит странице, имеющей
RW- или
R-- атрибуты, то есть без X-флага. Это и вызывает срабатывание DEP и исключение как итог.
В заголовке отладчика написано только «Main thread» и не содержится упоминание модуля, что говорит о том, что текущий фрагмент кода не относится к какому-либо загруженному модулю (EXE или DLL). Нажимаем Ctrl+A (команда Analyze) и видим, что ничего не меняется, что только подтверждает, что мы видим код, размещённый в памяти «на правах» данных.
Наша конечная цель — найти место, где выделяется фрагмент памяти, в который затем копируется код, частью которого является инструкция по адресу
0x0006D4A0, на которую потом впоследствии происходит переход, который всё рушит. Добраться до этого места проще всего двигаясь в обратном порядке.
То есть в первую очередь необходимо выяснить,
откуда происходит переход на адрес
0x0006D4A0. Переход мог быть осуществлён инструкцией
JMP (или условным джампом), и в этом случае найти место, откуда был сделан переход, можно только пошаговой трассировкой, либо инструкцией
CALL, и тогда место, откуда был сделан переход, можно вычислить по адресу возврата, который в данный момент лежит на верхушке стека.
Выяснить, какой вариант из двух — правильный, можно только проверив второй. Если он окажется неверным, значит верный — первый.
Не верхушке стека лежит DWORD
0x50909DC4 с пометкой от отладчика «RETURN to 50909DC4». Судя по отсутствию в этой пометке названия модуля, адрес
0x50909DC4 вероятно тоже не принадлежит какому-либо модулю. При попытке кликнуть по этому DWORD-у правой кнопкой и посмотреть код по этому адресу мы видим, что в меню нет пункта «Follow in Disassembler». Есть только «Follow in Dump». Значит отладчик не предполагает, что по адресу
0x50909DC4 может быть какой-то код.
На самом деле, это ещё ничего не означает. Я всё равно перехожу на этот адрес используя команду
at dword[esp] в командной строке отладчика, и вот что там за код:
Выглядит как вполне нормальный код, вполне возможно, что инструкция
call dword[ebp-8a] как раз и передала управление на адрес
0x0006D4A0 (по которому происходит исключение). Ставим брекпоинт на этой инструкции, чтобы убедиться, что именно она передаёт управление на адрес
0x0006D4A0.
Ставим и перезапускаем процесс.
Но после повторного прогона отладчик не останавливается на инструкции CALL, а останавливается опять на инструкции по адресу
0x0006D4A0, с которой мы начали наш поиск.
Значит ли это, что переход на
0006D4A0 делается не инструкцией CALL по адресу
50909DBE, а какой-то другой инструкцией, а число на верхушке стека — это чистая случайность? Ведь перед попаданием на
0006D4A0 мы не стопнулись на
50909DBE?
На самом деле — конечно нет! Для успешной отладки надо не только знать фичи и инструменты отладчика, но и понимать, как они устроены и работают. Обычные брекпоинты (точки останова) в OllyDbg (и большинстве других отладчиков) устроены так: при установке брекпоинта на инструкцию отладчик перезаписывает первый байт инструкции, меняя его на
0xCC, что соответствует инструкции
INT3.
INT3 это специальная короткая (однобайтовая) форма инструкции
INT xx (кодируемой как
CD xx — в случае int 3 это будет пара байтов
CD 03), которая возбуждает отладочное прерывание, которое ядром Windows трансформируется в отладочное исключение, которое останавливает выполнение отлаживаемого процесса и передаёт управление отладчику. Отладчик в этот момент восстанавливает первый байт модифицированной инструкции и оставляет за пользователем возможность действовать. Если пользователь решает продолжить выполнении, первый байт опять заменяется на
CC. Для пользователя отладчика механизм брекпоинтов кажется прозрачным, но на самом деле (под капотом) каждый брекпоинт — это внесённая отладчиком модификация в отлаживаемый код (которая отменяется на врем пауз в отладчике и делается вновь при продолжении выполнения отлаживаемого процесса).
Из-за такого принципа устройства брекпоинтов надо понимать, что брекпоинт не сработает, если отлаживаемый процесс сам перезаписывает код в своём АП, после того, как брекпоинт установлен. Точно так же брекпоинт не сработает, если он установлен на коде, который был выгружен, а потом вновь загружен по тому же адресу (и отладчик об этом не в курсе).
OllyDbg при завершении отладки сохраняет список всех установленных брекпоинтов и в следующий раз при подключении к отлаживаемому процессу устанавливает эти брекпоинты вновь. Однако, если в момент подключения к отлаживаемому процессу или запуску процесса с нуля под отладчиком, если по нужному адресу в памяти в принципе ничего нет, OllyDbg в принципе не может установить там брекпоинты (потому что установка его есть запись байта
CC, а нельзя записывать что-то в
невыделенную память).
В нашем случае случае после перезапуска Блокнота под отладкой по адресу
50909DBE нет абсолютно ничего, поэтому OllyDbg и не может установить там брекпоинт. А момент, когда по этому адресу появляется какой-то код (в котором нужно поправить 1 байт, чтобы на нём стопалось выполнение), отладчик отловить не может (хотя и мог бы при должном старании автора OllyDbg).
Дело в том, что как обычное оконное приложение ожидает в цикле прихода новых оконных сообщений, и обычно не делает абсолютно ничего (пока новых сообщений нет), а тупо спит и не выполняется, точно так же отладчик в цикле
ждёт отладочных событий (debug events), и пока отлаживаемый процесс выполняется без исключений и пауз, отладчик не получает шанса, чтобы его код выполнился. На самом деле, при желании автора, отладчик может выполнять какие-то действия в фоне и без всяких отладочных событий (как минимум GUI-отладчики обрабатывают оконные сообщения), но автор OllyDbg не стал делать механизм, который бы в фоне проверял появление в памяти новых фрагментов, на которые бы приходились ранее установленные запомненные брекпоинты, и их переустановку путём подмены первого байта инструкций на
CC (хотя, безусловно, мог).
Поэтому наш брекпоинт по адресу
50909DBE не срабатал в первую очередь из-за того, что OllyDbg тупо прозевал момент, когда по этому адресу перестала быть абсолютная пустота, а появился какой-то код. Прозевал момент, когда ему нужно было подменить первый байт инструкции и ничего не подменил, поэтому и стоп не произошёл.
Чтобы брекпоинт по этому адресу был жизнеспособным, нужно чтобы с момента начала отладки процесса произошёл хотя бы одна остановка после того, как по адресу
50909DBE появляется код, но до того, как этот код получит шанс выполниться.
Поэтому нам нужно понять, каким образом некий код оказывается по адресу
50909DBE. Вообще-то этот адрес (само число) не похож на типичный адрес, выделяемый функциями HeapAlloc и им подобным — это больше похоже на загруженный модуль (DLL и EXE). Это и есть загруженный модуль, и это видно, если перейти в окно отладчика «Modules» (кнопка [E] в тулбаре).
Модули, которые появились в АП процесса с тех пор, как отладчик в последний раз «проверил» список загруженных модулей, подсвечены красным:
Среди них видно и
CAP3RDN.DLL с базовым адрес
50900000, на секцию кода которого и приходится та call-инструкция по адресу
50909DBE, на которую мы безуспешно ставили брекпоинт.
Кстати, команда контекстного меню «Actualize» (в окне списка исполняемых модулей) сделает все строки чёрными и позволит отследить ещё новые модули, загруженные уже после выполнения команды «Actualize».
Если вернуться к окну «CPU» отладчика, то уже видно, что после того, как отладчик осознал, что в АП отлаживаемого процесса за время его сна был подгружен модуль «CAP3RDN» (помимо прочих), он теперь отображает и название модуля в заголовке, и в стеке показывает не просто «RETURN to 50909DC4», а «RETURN to CAP3RDN.50909DC4»:
Но это только потому, что мы заглянули в список модулей, заставив OllyDbg заново просканировать этот список и расставить брекпоинты ранее установленные брекпоинты там, где он не мог установить раньше.
Если отлаживаемый процесс перезапустить, брекпоинт по адресу
50909DBE опять не будет установлен вплоть до того момента, пока процесс не столкнётся с исключением уже по адресу
0x0006D4A0 — но тогда будет поздно.
Чтобы дать OllyDbg шанс вовремя пропатчить инструкцию по адресу
50909DBE, нужно сделать так, чтобы отлаживаемый процесс стопнулся сразу же после того, как
CAP3RDN.DLL будет подгружен в АП отлаживаемого процесса. Для этого нужно найти место в коде, которое подгружает
CAP3RDN.DLL. Это делается легко: можно установить брекпоинты на LoadLibraryA/LoadLibraryW/LoadLibraryExA/LoadLibraryExW в kernel32, а можно в опциях OllyDbg временно включить опцию «Break on new modules».
В итоге это место легко находится:
Модуль
CAP3K.DLL запрашивает загрузку
CAP3RDN.DLLНужно поставить брекпоинт
после вызова
LoadLibraryW, потому что если отладчик стопнется до загрузки библиотеки, толку от этого не будет никакого, а вот если после — то он увидит новый код по по адресам 50900000—5090E000 и применит брекпоинт (то есть пропатчит первый байт инструкции по адресу
50909DBE) и тогда мы сможем на ней остановиться.
Теперь перезапускаем отладку процесса с нуля. Выводим произвольный текст на печать. Сначала мы стопаемся после момента подгрузки
CAP3RDN.DLL. В этот момент нужно глянуть список модулей в OllyDbg и продолжить выполнение отлаживаемого процесса. А вот следующая остановка происходит как раз на интересующей нас call-инструкции:
В итоге, мы оказались правы с изначальным предположением, что именно эта call-инструкция передаёт управление на фрагмент кода, лежащей в странице, не имеющей права на выполнение. Теперь достаточно найти место, где этот фрагмент памяти выделяется и поменять способ, которым он выделяется, на DEP-friendly.
Очевидно, что драйвер принтера выделяет в памяти кусочек, в который записывает код, а далее по ходу работы передаёт управление этому коду. Нужно найти место, где выделяется память под этот динамически формируемый код, и если это будет
VirtualAlloc, то модифицировать комбинацию флагов, передаваемую в 4-й параметр, добавив бит
PAGE_EXECUTE.
Если это будет
HeapAlloc, то нужно найти момент создания кучи (вызов
HeapCreate) и опять-таки модифицировать значение, передаваемое в 1-й параметр (добавив бит
HEAP_CREATE_ENABLE_EXECUTE).
Я решил выяснить, какой функцией модуль
CAP3RDN.DLL выделяет память под динамически формируемый кусочек кода, а потом на все вызовы этой функции поставить брекпоинты и выяснить, где именно (откуда) выделяется память под фрагмент и пропатчить этот код.
Однако, посмотрев импорты
CAP3RDN.DLL я в первый раз разочаровался: она не использует ни
VirtualAlloc, ни
HeapAlloc.
Все выделения памяти делаются с помощью GlobalAlloc, что портит дело, потому что фиксом в 1 байт уже не обойдётся: придётся модифицировать таблицу импорта и добавлять больше кода в секцию данных.
Я поставил брекпоинты на все вызовы
GlobalAlloc, сделал прогон, но среди выделяемых фрагментов не оказалось фрагмента, по которому впоследствии осуществляется переход. Как же так? Ведь больше библиотека никак память выделять не может, она её выделяет, но не вызовом
GlobalAlloc. А чем же ещё?
Оставался только один вариант, и я подошёл к выяснению этого момента с другой стороны.
Адрес, по которому нужно перейти, инструкция
CALL берёт по адресу
dword[EBP-8A]. А как этот адрес попадает попадает в
DWORD[EBP-8A]?
Я просто просмотрел код снизу вверх и нашёл место, где адрес, по которому произойдёт вызов, помещается в ячейку по адресу
EBP-8A:
Вот оно что! Место под динамически формируемый кусок кода выделяется из стека! Поэтому ни один вызов GlobalAlloc и не вернул интересующий меня адрес. Вот тут хорошо видно, что из стека выделяется 400h байтов памяти, полученный адрес записывается в регистр EDI, а затем это значение записывается в локальную переменную по адресу
EBP-8A. Далее вызывается процедура
cblt, которая, как оказалось, на месте выделенного из стека участка и формирует код, который позднее будет выполнен. Чуть позже адрес кода извлекается из
DWORD[EBP-8A] и делается вызов.
Сперва я подумал, что код этой процедуры (не
cblt, а той, которая выделяет память на стеке, она называется
RP_BITMAP1T01) написан на Си и использует
alloca. Но вскоре я понял, что код написан не на Си, а скорее всего на ассемблере, по двум причинам:
- При вызове cblt адрес буфера, в котором cblt сформирует кусок кода, передаётся через регистр EDI, что не соответствует ни одному из стандартных соглашений о вызове (это не fastcall!).
- При том, что используется EBP-относительная адресация стека, после злосчастного вызова выделенные из стека 400h байтов «освобождаются» инструкцией add esp, 400h, что не соответствует тому, какой код генерируется сишными компиляторами при использовании alloca.
Кому интересно, кусок из
RP_BITMAP1T01 полностью: выделение, генерация кода, вызов сгенерированного кода, освобождение.
Что можно сказать? Авторы драйвера — те ещё затейники. Мало того, что они использует динамически генерируемый код (не имею ничего против), так они место под него выделяют прямо из стека! Хотя на первый взгляд это может показаться кривым подходом, в этом есть разумное зерно: видимо подход с динамической генерацией кода применён в целях увеличения производительности. При этом каждый раз генерируется новый код (раз формирование кода не вынесли «за скобки»). А раз так, выделять память нужно каждый раз. А выделение памяти с помощью WinAPI-функций убило бы весь выигрыш в скорости от использования динамически формируемого кода. В то время как выделение кусочка из стека — чрезвычайно быстрая вещь по сравнению с системным вызовом (если только при росте стека вверх не произойдёт расширение набора закоммиченных страниц стека (но оно происходит 1 раз)).
С другой стороны я посмотрел: во всей библиотеки есть только 1 обращение к процедуре
cblt — вот это, которое мы видим. Может быть помимо
cblt есть и другие процедуры, выполняющие генерацию кода, но если нет, то значит это единственное место с кодогенерацией, а раз так, то выделение буфера под генерируемый код можно было бы вынести «за скобки», то есть делать 1 раз — в таком случае нет ничего зазорного использовать системные вызовы.
Что ж, по крайней мере теперь у нас есть все знания, необходимые, чтобы пофиксить этот момент. Может быть, конечно, после того, как мы сделаем это место DEP-friendly, мы вывалимся следом где-нибудь ещё — узнаем только после того, как попробуем.
Есть два способа вылечить этот код: вместо выделения памяти со стека выделять из кучи, либо оставить выделение из стека, но менять атрибуты доступа страницы стека (исключительно на время выделения 1024-байтового фрагмента стека под код). Второй вариант не очень хорош, ведь он делает 1 или даже 2 страницы стека потенциально опасными с точки зрения уязвимиости.
Первый же осложнён тем, что либо память придётся выделять с помощью
VirtualAlloc — а это влечёт за собой необходимость ещё и резервировать память. Резервируется память блоками по 64кб, а выделяется страницами по 4кб, а ведь нам нужно-то 1024 байта всего! Да и резервирование+выделение — долгая операция. Либо придётся выделять с помощью HeapAlloc, а это влечёт за собой необходимость создавать кучу с правом выполнения для её станиц. Создание кучи — чертовски тяжелая операция (в рассматриваемых масштабах!), к тому же, нужно где-то хранить хендл кучи, что представляет собой особую проблему, ведь код драйвера наверняка должен быть многопоточным, а значит «взять» себе место из секции данных под переменную не получится.
Модифицировать DllEntryPoint-процедуру и выделять память в ней тоже не получится: у этой DLL точки входа попросту нет! (Хотя можно сделать).
Только дальнейшее исследование может показать, какой вариант выбрать и как быть.
Для проверки я выбрал вариант с VirtualAlloc, причём на каждый вызов
RP_BITMAP1T01 будет приходиться резервирование+выделение, а затем разрезервирование.
Я накидал прямо в отладчике вот такие кусочки кода, которые выделяют и освобождают память:
А затем в оригинальном коде вместо выделения/освобождения участка на стеке сделал вызовы моих пробных кусочков:
Запускаем на печать — и всё срабатывает на ура. Задание уходит на принтер, принтер начинает завывать, пытаясь раскрутить зеркало, а после правильных
точечных ударов — распечатывает то, что нужно.
Важные выводы:
- Мы в принципе победили кривость драйвера, пусть и черновым путём пока.
- В драйвере только 1 место, где применена кодогенерация и где в качестве буфера для кода использовался стек.
- Размер этого буфера всегда 0x400 байт.
- Может быть выводы 2 и 3 ошибочны — ведь я тестировал пока только на Блокноте, а попытка печати сложной графики может дать опять крэш.
«Чёрность» этого чернового способа состоит из двух компонентов:
- Абсолютная: мы не импортируем функции VirtualAlloc и VirtualFree, как это следовало бы делать, а пока что жестко вшили их адреса в свой кода. Это значит, что попади драйвер на чуть другую версию ОС с другой kernel32.dll или обновись сама kernel32.dll — и ничего работать не будет.
- Относительная: мы выделяем и освобождаем блок по каждому чиху, то есть ухудшаем производительность.
Чтобы понять, насколько критичен такой наплевательский подход к производительности, а также развеять сомнения из пункта №4, я хочу погонять этот фикс в других приложениях, выводящих сложную растровую и векторную графику. Поскольку потенциальных приложений-кандидатов для тестирования много, и подключаться отладчиком к каждому и всякий раз вносить одни и те же правки в код — утомительно, я пропатченный образ DLL материализую в виде DLL-файла.
Но раз мы собираемся гонять пропатченный драйвер без отладчика, нужно же как-то получать от него информацию. Поэтому добавляем в начало кусочка
hacked_alloc400 пару инструкций вызова
OutputDebugString, ну и саму строчку чуть позже прямо в секцию кода:
Естественно, поскольку начало кусочка
hacked_alloc400 переместилось назад на 10 байт, нужно подкорректирвоать инструкцию
call, которая вызывает
hacked_alloc400.
Помимо этого, есть ещё один момент. Модуль
CAP3K.DLL взаимодействует с
CAP3RDN.DLL через несколько экспортируемых последней функций.
Вот что экспортирует
CAP3RDN.DLL:
И вот как сразу после загрузки (LoadLibrary, где мы ставили брекпоинт в начале)
CAP3K.DLL отложенно импортирует эти функции:
Так вот, я хочу понаблюдать, как коррелирует содержимое выводимого на печать документа с вызовами этих функций, и как вызовы этих функций соотносятся с вызовами процедуры
RP_BITMAP1T01 (в которой мы теперь выделяем память). И как следствие узнать, сколько циклов выделения/освобождения приходится на обработку разных документов.
Для этого я «перехвачу», вернее модифицирую прологи всех этих экспортируемых функций, сделав вывод отладочного сообщения в начале каждой из них. Перехват покажу на примере
hHREOpen:
Длинный джамп (инструкция) занимает 5 байтов (1 байт на опкод и 4 байта относительное смещение), значит первые 5 байтов функции нужно перенести в другое место.
Ставим в оригинальной функции метку (
contOf_hHREOpen), куда мы собираемся вернуться из перенесённого кусочка:
В конце секции кода набиваем кусочек кода из 5 оригинальных push-инструкций (которые будут заменены на джамп), пару инструкций вызова
OutputDebugString и возврат к оригинальному коду:
И в начале оригинальной функции ставим переход на собственный кусочек:
Обратите внимание на маленькую хитрость: строчку «hHREOpen» (адрес
5090B070) я взял в директории экспорта DLL-модуля — зачем дублировать то, что уже есть.
Аналогичным образом перехватываем все остальные экспортируемые функции и делаем так, что каждая выводит отладочную строку в момент вызова. Вот все кусочки для всех перехваченных фукнций:
В принципе можно было (и это было бы даже красивее!) перехватить начала функций иначе: не трогать и не модифицировать прологи, а просто модифицировать RVA-указатели в таблице экспорта, перевешав их на собственные переходнички, которые только выводят отладочные строки и сразу прыгают на оригинальные нетронутые прологи.
И сохраняем пропатченный DLL файл:
Я сохранил два файла:
- CAP3RDN_patched.DLL — с исправленным багом падения но без вывода отладочных сообщений.
- CAP3RDN_patched_verbose.DLL — исправленный + вывод отл. сообщений с помощью OutputDebugString.
Теперь подменяем оригинальный драйвер в system32: благо он не охраняется SFP и не имеет цифровой подписи (которая бы нарушилась от наших модификаций).
Теперь мы можем
из любого приложения выводить что-либо на печать и при помощи программы
DebugView (или любого аналога) смотреть, какие функции из драйвера печати дёргаются, сколько раз, и сколько там циклов выделения/высвобождения памяти.
Вот несколько тестов (слева — что печатаем, справа — лог вызовов экспортируемых ф-ций и циклов выделения памяти):
Как можно увидеть на этих тестовых примерах, не так уж и много циклов выделения памяти (в вызовах
RP_BITMAP1T01) происходит при печати чего-то примитивного. Например можно сделать вывод, что на каждую выводимую букву приходится один такой цикл (но некоторый софт печатает некоторые группы букв дважды?).
Ладно, это всё баловство. Напечатаем что-нибудь тяжеловесное:
Вывод этого чертежа выдал аж 153 тысячи отладочных строк в
DebugView! И обработка задания заняла драйвером 20 секунд! Для желающих самостоятельно покопаться в этом логе, вот он:
Но рано думать, что такое падение производительности вызвано нашим фиксом (многократным вызовом VirtualAlloc/VirtualFree) — скорее всего bottleneck в данном случае кроется в самом выводе отладочной информации.
Сделаем ещё одну пропатченную версию драйвера, убрав вывод отладочной информации отовсюду, кроме
hHREOpen и
uiHREClose (которые вызываются в самом начале и в самом конце обработки задания).
Печатаем тот же самый чертёж и получаем уже совсем не такую страшную картину:
Чуть меньше, чем половина секунды — вполне себе приемлемый результат.
Ладно, можно считать лечение успешным. После того, как остальные проблемы с принтером будут устранены, можно будет вернуться и улучшить фикс, вынеся выделение памяти «за скобки», применив TLS для хранения указателя на 400h-байтный буфер.
А пока нужно довести вылеченный драйвер до кондиции, сделав нормальный импорт функций
VirtualAlloc и
VirtualFree.
Сейчас у нас 5 импортов из
kernel32.dll, а будет 7. Для этого размер таблицы IAT и таблицы лукапов нужно увеличить на 2 ячейки (каждую). Тут нас ждёт первая трудность: таблица IAT, в которую загрузчик расставит адреса импортируемых функций, расположенная (в данном случае) в самом начале секции кода, не может расти вниз, потому что там лежат какие-то непонятные данные, и таблица лукапов тоже не может расти вниз, потому что следом за ней идёт цепочка структур
IMAGE_IMPORT_BY_NAME.
Цепочку структур
IMAGE_IMPORT_BY_NAME мы легко можем подвинуть (подправив их RVA в IAT и таблице лукапов), а вот что за данные идут следом за IAT — совершенно не ясно. Причём там идёт большой блок данных, в котором есть даже строчка
Win32 Render for CAPT. Причём я заставил OllyDbg поискать все ссылки к интересующему блоку и отладчик не нашёл
ни одной! Что выглядит подозрительно и наводит на мысль, что для доступа к этим данным используется непрямой доступ с использованием адресной арифметики, которую отладчик, ясное дело, своим довольно-таки простым анализом обнаружить не может.
Вполне возможно, что это в самом деле неиспользуемое мусорное пространство, и можно было бы рискнуть и захватить 8 байт из этой области под свои нужды. Но мы не будем рисковать и пойдём другим путём.
В PE-заголовке находится RVA-указатель на начало таблицы десктрипторов импорта. Каждой импортируемой DLL-библиотеке соответствует отдельный дескриптор (элемент) в таблице дескрипторов импорта. Но, хоть это обычно не принято, ничего не мешает для одной и той же библиотеки иметь более одного дескриптора импорта. Обычно этим грешит линкер Borland'овских продуктов (Delphi и C++ Builder).
Раз мы не можем увеличить размер IAT, мы сделаем вторую IAT и вторую таблицу лукапов и разместим их там, где нам удобно и ничего не мешает (а именно — в конце секции). Но расширить саму таблицу дескрипторов мешает следующая прямо за таблицей дескрипторов импорта таблица лукапов для первого дескриптора.
Пустой (терминирующий) дескриптор придётся сдвинуть вниз — он займёт место, которое сейчас занимает ILT для первого дескриптора. Она (ILT — таблица лукапов) имеет размер 6 DWORD-ов, что достаточно для помещения на её место сдвигаемого дескриптора, размер которого — 5 DWORD-ов. ILT мы перенесём вниз, а цепочку из структур
IMAGE_IMPORT_BY_NAME для первого дескриптора можно оставить там, где она сейчас.
Сам дескриптор импорта имеет такую структуру:
- Код: Выделить всё
typedef struct _IMAGE_IMPORT_DESCRIPTOR {
DWORD OriginalFirstThunk; // RVA таблицы лукапов (ILT)
DWORD TimeDateStamp; // Timestamp для bound-импорта
DWORD ForwarderChain;
DWORD Name; // RVA строки с именем модуля
DWORD FirstThunk; // RVA таблицы адресов (IAT)
} IMAGE_IMPORT_DESCRIPTOR;
Копируем оригинальную Import Lookup Table вниз (начало будет по адресу
50909E70). Первое поле первого дескриптора (RVA на ILT) меняем с
9E00 на
9E70):
Следом за перенесённой вниз оригинальной ILT формируем вторую ILT, вторую IAT и пару структур
IMAGE_IMPORT_BY_NAME для функций
VirtualFree и
VirtualAlloc:
В этих структурах помимо имён функций содержится 16-битный хинт — это просто порядковый номер функции в таблице экспорта соответствующей библиотеки. Этот номер позволяет загрузчику быстрее при импорте найти соответствующую запись в таблице экспорта.
Хинты можно посмотреть любой утилитой — я, например, посмотрел в
Dependency Walker:
Дальше на том месте, где раньше был нулевой (теминирующий) дескриптор импорта формируем второй дескриптор импорта для
kernel32.dll. А на том месте, где раньше была оригинальная ILT, формируем новый пустой (терминирующий) дескриптор импорта. В новом (втором) дескрипторе импорта проставляет RVA на новую ILT, новую IAT и старую строчку
KERNEL32.dll:
После этого копируем внесённые изменения в представление файла-образа (команда
Copy to executable → All modifications контекстного меню), а затем представление файла-образа в файл (команда
Save file контекстного меню).
Теперь можно увидеть, что у полученного файла появился второй импорт из библиотеки
kernel32.dll, отвечающий за пару
VirtualAlloc+VirtualFree:
После повторной загрузки драйвера под отладчиком на месте новой IAT должны быть уже не RVA, а самые настоящие адреса функций, проставленные сюда загрузчиком Windows.
Как бы не так! При попытке повторной отладки наш модифицированный драйвер вообще не может загрузиться — где-то в недрах NTDLL возникает исключение (access violation) при попытке записи! Это исключение возникает как раз в момент проставления реального адреса функции в ячейку IAT загрузчиком: дело в том, что страница секции кода, на которую приходится IAT, имеет атрибуты защиты, запрещающие запись (ибо вся секция имеет атрибуты защиты READ+EXECUTE, а не READ+WRITE+EXECUTE). При попытке записи адреса в страницу только-для-чтения и возникает исключение.
Но как же тогда загрузчик проставлял адреса в оригинальную (родную) IAT, которая была в драйвере всегда, ведь эта IAT тоже лежит в начале той же секции кода, недоступной для записи? Разгадка проста: на время заполнения таблицы IAT загрузчик изменяет атрибуты защиты соответствующих страниц на
PAGE_READWRITE. Когда же IAT заполнена актуальными адресами, страницам, на которые приходилась IAT, возвращаются прежние (оригинальные) атрибуты защиты.
На обработку каждого дескриптора импорта вызывается функция
LdrpSnapIAT из NTDLL. Она меняет атрибуты защиты страниц на
PAGE_READWRITE (для обозначения сего действия разработки Windows применяют термин «unprotect»), затем в цикле вызывает
LdrpSnapThunk для каждой ячейки IAT (т.е. для каждой импортируемой функции/сущности), после чего восстанавливает страницам прежние атрибуты защиты.
Если загрузчик так поступает, почему же в
LdrpSnapThunk при проставлении значения (адреса) в ячейку IAT возникает исключение? Всё дело в том, как
LdrpSnapIAT определяет диапазон страниц, которым нужно поменять атрибуты защиты. Для этого она смотрит отдельную директорию в таблице директорий PE-файла, которая называется «IAT Directory» (не путать с Import Directory!). Если в IAT-директории есть база и размер IAT-зоны, то именно этот диапазон страниц подвергается unprotect'ингу. Обычно модуль импортирует из множества других модулей, и IAT-ы разных модулей лежат компактно — вплотную друг к другу. На такое плотное компактное скопление IAT-таблиц и указывает IAT-директория. В случае же, если IAT-директория не заполнена, загрузчик пытается самостоятельно вычислить диапазон страниц, которые нужно unprotect'ить. При этом он действует довольно примитивно: ищет секцию, в которой лежит таблица дескрипторов импорта и unprotect-ит
всю секцию целиком! Да, размер IAT нигде не хранится, но можно было бы пройтись IAT и вычислить её длину, а дальше менять атрибуты защиты только необходимым страницам, а не вообще всем, но так не сделали — оставим это на совести разработчиков Windows (кстати, может быть в последних версиях это поведение поменяли).
Посмотрим на IAT Directory в PE-заголовке нашего драйвера:
Как можно заметить, поля «RVA» и «Size» IAT-директории заполнены, и значит загрузчик (
LdrpSnapIAT) использует именно этот диапазон для определения набора страниц, атрибуты защиты которых нужно поменять. В оригинале IAT из 5 ячеек попадала в этот диапазон, но сейчас у нас две IAT: одна в начале секции кода, другая в самом конце.
Выхода у нас два: либо занулить поля IAT-директории в PE-заголовке, либо дотянуть поле Size (которое сейчас равно 0x18) так, чтобы оно захватывало и нашу новую (вторую) IAT.
После исправления этой проблемы, как я и говорил, при загрузке драйвера в отладчике на месте новой IAT уже не RVA, а реальные адреса импортируемых функций:
Остаётся только заново сделать другие варианты
hacked_alloc400 и
hacked_free400 — вызывающие
VirtualAlloc и
VirtualFree уже через IAT, а не через жестко вшитые адреса:
После выполнения такой модификации и сброса модифицированного образа в файл драйвер успешно загружается в АП, печать работает на ура, и казалось бы всё сделано, но на самом деле нет!
На самом деле осталось ещё две вещи, о которых мы забыли, которые, пока не сделаны, оставляют наш пропатченный драйвер неполноценным уродцем.
Во-первых, наш проблемный драйвер печати — это DLL, а DLL, впрочем как и любой PE-модуль, может быть загружена не потому адресу, по какому планировал её создатель, а по совершенно другому, если изначально желаемый диапазон адресного пространства занят чем-то другим, либо если задействована технология
ASLR.
При загрузке по неродному базовому адресу все абсолютные адреса, вшитые в код и данные образа, становятся некорректными и недействительными. Для исправления этой ситуации все они должны быть скорректированы на величину, равную разнице между фактическим базовым адресом загрузки и желаемым базовым адресом загрузки. При этом список мест, которые должны подвергнуться коррекции, содержится в самом PE-файле: это так называемые релоки (или релокации), которые обычно находятся в отдельной секции (как правило с именем
.reloc и флагом
IMAGE_SCN_MEM_DISCARDABLE, позволяющим не проецировать секцию на АП процесса после завершения загрузки PE-модуля).
Релоки могут отсутствовать в образе PE-модуля (и обычно всегда отсутствуют у EXE, и всегда присутствуют у DLL, OCX, SYS и т.п.) — в этом случае сам PE-модуль будет иметь установленным флаг
IMAGE_FILE_RELOCS_STRIPPED в поле характеристик PE-заголовка, а при попытке загрузить такой модуль в АП, если соответствующий диапазон страниц АП окажется занятым хотя бы частично, загрузка
вообще не состоится.
Наш проблемный драйвер имеет секцию релоков (и не имеет флага
IMAGE_FILE_RELOCS_STRIPPED, соответственно), а значит является перемещаемым, то есть поддерживается загрузку по неродному базовому адресу. Проблема, однако же, состоит в том, что внедрённые нами в конец секции кода фрагменты
hacked_alloc400 и
hacked_free400 обращаются к ячейкам IAT, используя
абсолютные адреса. Эти абсолютные адреса, вшитые в код, не подвергнутся коррекции при загрузке по неродному базовому адресу, потому что мы их сами добавили в образ, не добавив ничего в релоки, и в итоге окажутся недействительными — при попытке что-то печатать, как только дело дойдёт до выделения буфера под динамически генерируемый код, драйвер обрушит всю программу, пытающуюся что-то напечатать.
Из этой ситуации есть два выхода:
- Своими руками расширить секцию релоков, добавив туда упоминание ещё двух новых мест, которые должны подвергаться коррекции.
- Выставить флаг IMAGE_FILE_RELOCS_STRIPPED, запретив таким образом загружать DLL по неродной базе и устранив проблему на корню.
Второй способ, при своей небывалой простоте, является невообразимо глупым решением: будучи драйвером печати, эта DLL должна подгружаться в самые невообразимые и фантастические окружения (имеются в виду АП процессов) и быть способной втискиваться в самые фрагментированные АП, лишь бы только в нём нашёлся свободный кусочек памяти (АП), достаточный для загрузки её туда. И загружается она в АП процесса не в начале его работы, когда АП ещё более пустое и менее фрагментированное, а как правило в конце (долго набирали/рисовали документ, теперь пришла пора напечатать), причём подгружаться ей приходится в самые разнообразные процессы (любые, которые потенциально что-то могут выводить на принтер). Совершенно невозможно предсказать или быть уверенным, что диапазон адресов
0x50900000—
0x5090E000 в целевом процессе окажется свободным — запросто он может оказаться чем-то занятым к моменту подгрузки драйвера печати, и было бы совершенной глупостью делать драйвер, исход успешной загрузки которого будет зависеть от такого случайного фактора.
Остаётся только первый вариант.
Есть на самом деле ещё и третий вариант: при формировании внедряемых кусочков можно воспользоваться классическим «хакерским» трюком по определению EIP с помощью фиктивной call-инструкции:
- Код: Выделить всё
call label
label: pop eax ; EIP теперь содержится в EAX
А дальше вычислить адрес нужной IAT-ячейки относительно полученного EIP. Абсолютные адреса в таком случае вообще бы не использовались и проблемы с загрузкой по неродному базовому адресу (неродной базе) не было бы изначально. Но! Этот трюк не очень «чистый» и может действовать на антивирусы как красная тряпка на быка: проблема базонезависимости (точнее базозависимости) — это классическая проблема, возникающая при внедрении нового кода в уже сформированный PE-модуль, а внедрениями занимаются в 99% случаев именно вирусы.
Поэтому мы займёмся модификацией таблицы релоков. Кстати, в OllyDbg можно очень легко увидеть места, подвергаемые модификации при релокации (при загрузке по неродной базе) — при отображении дампа секции такие места подчёркиваются серой линией.
На такие подчёркивания нужно внимательно смотреть, если вы модифицируете оригинальный код в прямо по месту: там, где до модификации кода были абсолютные адреса, нуждающиеся в коррекции при перемещении, после модификации могут оказаться опкоды или аргументы совершенно других инструкций, которые будут неизбежно испорчены с непредсказуемыми последствиями, если вдруг образ при загрузке будет загружен по неродному базовому адресу.
Хотя таблица релокаций обычно лежит в отдельной секции с именем
.reloc, ни имя секции, ни сам факт её наличия — не играют никакой роли. Загрузчик PE-модулей в Windows-находит таблицу релокаций глядя в таблицу директорий данных (в соответствующей ячейке там хранится RVA и размер таблицы релокаций), существование при этом отдельной секции с каким-то определённым именем совершенно не обязательно — таблица релокаций может запросто лежать и в секции кода или любой другой.
Посмотрим на соответствующую ячейку таблицы директорий данных в составе PE-заголовка нашего драйвера. В нашем случае начало таблицы релокаций совпадает с началом секции
.reloc:
Если дважды щёлкнуть по секции
.reloc в окне
Memory Map, отладчик покажет нам содержимое этой секции, и к нерадости неискушённых товарищей содержимое будет отображено вот так:
Иными словами, отладчик OllyDbg
не умеет нормально отображать таблицу релокаций, показывая её элементы, структуры, поля, их смысл и назначение. Точно так же он не позволяет легко вносить в неё правки пользователю. Максимум — это отображение hex-дампа секции и возможность править произвольные байты произвольным образом. OllyDbg версии 2, который у меня тоже есть, но которым я реже пользуюсь (из-за меньшей стабильности и отсутствия поддержки плагинов), по прежнему не умеет красиво отображать таблицу релоков.
Но не стоит опускать руки раньше времени: таблица релокаций имеет сравнительно несложный формат, и поняв её устройство, будет не так уж сложно дополнить её голыми руками.
Вся таблица релокаций (таблицей она называется весьма условно) представляет собой последовательность
блоков. Размер блоков не фиксированный. Каждый блок состоит из 8-байтного заголовка (два DWORD-а), с которого начинается блок, и некоторого числа 2-байтных элементов, которые идут следом за заголовком.
Заголовок состоит из двух DWORD-полей — в одном из них хранится размер блока в байтах (включая сам заголовок, все 2-байтные элементы в составе этого блока и выравнивающие нули в конце блока (если они есть)). Следом за заголовком идут элементы: каждый элемент (16 бит) описывает отельное место в образе, которое подлежит коррекции в случае загрузки по неродной базе. Поскольку число элементов в блоке может быть нечётным, итоговый размер блока может оказаться не кратным 4 байтам. По этой причине начала блоков выравниваются по 4-байтной границе, а концы предшествующих блоков добиваются до этой границы нулями.
Структура 16-битного элемента в составе блока следующая: младшие 12 бит хранят смещение корректируемого места, а старшие 4 бита — тип релока (а их существует множество разных видов — об этом ниже). Поскольку смещение относительно базы (иными словами — RVA) не способно уместиться в 12-битное поле, общий (для всех элементов одного блока) RVA хранится в заголовке блока (это второе из полей заголовка, кроме поля с размером блока), а в каждом элементе хранится смещение корректируемого места относительно общего для всего блока RVA.
Иными словами, каждый такой блок таблицы релокаций сопоставляется с участком образа размером 4 Кб (по чистому совпадению размер региона, описываемого одним блоком таблицы релокаций, совпадает с размером страницы x86). В заголовке блока лежит RVA четырёхкилобайтного окна, на которое приходится некоторое количество релокаций, а каждый элемент в составе блока описывает описывает одну отдельно взятую релокацию из этого окна и хранит в себе смещение этой релокации относительно начала четырёхкилобайтного окна и тип релокации. Причём 4 кб регион, описываемый отдельным блоком, не обязан быть выровнен по границе страницы, блоки в принципе могут иметь начала в произвольных местах образа и вообще пересекаться между собой.
Примечательно, что в составе 16-битного элемента поля разделены по ширине именно как 4 и 12 бит: при таком разделении в hex-записи 12-битному полю (хранящему смещение) соответствует 3 младших hex-цифры, а 4-битному полю (хранящему тип) соответствует старшая hex-цифра.
Теперь что касается типов релокаций: их несколько. Как минимум, исходя из того факта, что под хранение типа релокации выделено 4 бита, количество возможных значений этого поля равно 16. На самом же деле, загрузчик Windows различает 12 типов релокаций:
- Код: Выделить всё
0 ABSOLUTE
1 HIGH
2 LOW
3 HIGHLOW
4 HIGHADJ
5 MIPS_JMPADDR
6 SECTION
7 REL32
9 IA64_IMM64
10 DIR64
11 HIGH3ADJ
Тип релокации определяет, сколько байтов и по какому принципу корректируется. Поскольку релокации как правило корректируют абсолютные адреса, коррекции может подвергаться старшая часть адреса, младшая часть, старшая и младшая части и т.п.
Типы
MIPS_JMPADDR и
IA64_IMM64 не относятся к архитектуре x86. Тип
ABSOLUTE означает, что коррекция адреса не требуется (вопреки названию, потому что абсолютным адресам как раз таки коррекция и нужна — видимо имеется в виду абсолютный адрес, адресующий что-то, находящееся вне пределов перемещаемого образа. Типам
SECTION и
REL32 в загрузчике Windows пока не придумано никакой обработки — они попросту игнорируются. Самым широкоиспользуемым является тип
HIGHLOW с кодом 3, означающий, что просто берётся DWORD и корректируется на нужную величину.
Даже если сейчас посмотреть на скриншот дампа секции релокаций (выше), то видно, что большинство DWORD-ов имееют тройку в позиции 8-ой и 4-ой цифр. Поскольку тип
ABSOLUTE на практике никогда не используется, можно визуально легко находить границы блоков: ведь блоки начинаются с заголовков, а заголовок хранит два значения (RVA окна и размер блока), которые на практике никогда не бывают настолько большими, чтобы иметь старшие 4 бита отличные от нуля.
Если вновь взглянуть на содержимое секции релоков, имея все вышеизложенные знания, можно легко увидеть границы блоков (их тут 6), заголовки блоков и элементы (они все сплошь содержат тройки колонками):
Можно на примере первого блока рассмотреть структуру элементов: если в составе каждого элемента раскрасить 12-битное поле, хранящее смещение корректируемого места относительно начала окна блока, то выглядеть это будет вот так:
Тройки, которые остались незакрашенными — это 4-битное (старшие 4 бита) поле, хранящее тип элемента.
Как видно, все элементы здесь имеют тип
HIGHLOW.
Теперь, глядя на цифры, которые на первый взгляд казались хаотичными, и видя за ними чёткую понятную структуру, задача внесения в таблицу релокаций пары новых элементов кажется совсем уж простой.
У нас есть два места, которые должны корректироваться при загрузке по нештатному базовому адресу:
Их адреса:
- VA при загрузке по штатной базе: 0x50909ECE+2=0x50909ED0 / RVA: 0x9ED0
- VA при загрузке по штатной базе: 0x50909EE4+2=0x50909EE6 / RVA: 0x9EE6
Оба места попадают в окно предпоследнего блока (RVA начала окна =
0x9000), но к блоке нет свободного места, которое можно было бы использовать.
Выхода два:
- Добавить в конец ещё один новый блок: с RVA окна = 0x9000, размером блока = 12 байт и двумя 16-битными элементами.
- Расширить предпоследний блок, подвинув последний блок на 4 байта вперёд, в конец предпоследнего блока вставить 2 новых элемента
Поскольку OllyDbg имеет команды
Binary Copy и
Binary Paste, нет никакой проблемы подвинуть последний блок на 4 байта вперёд, и я выбираю именно этот путь.
- Сдвигаем последний блок на 4 байта вперёд, в предпоследнем блоке меняем размер 0x44 на 0x48.
- Окно этого блока начинается по RVA=0x9000, тогда 12-битное смещение корректируемых мест относительно начала окна будет 0x0ED0 и 0x0EE6.
- Старшие 4 бита у элементов должны быть равны значению 3 (тип HIGHLOW), поэтому в качестве элементов дописываем в конец предпоследнего блока слова 3ED0 и 3EE6
После этих модификаций концовка секции релоков выглядит так:
Вот иллюстрация, показывающая, как изменилась концовка секции при модификации (слева — было, справа — стало):
Поскольку мы расширили таблицу релокаций (она увеличилась в размере на 4 байта), необходимо отразить это в двух местах:
- В элементе таблицы директорий данных указать новый размер таблицы релокаций:
- В таблице секций увеличить виртуальный размер секции и размер инициализированной части секции (подгружаемой из файла). Однако оказалось, что для секции .reloc эти величины изначально стоят с запасом, и наше изменение размера с 0x3F0 до 0x3F4 не требует какой-либо правки в таблице секций:
Теперь экспортируем модифицированный образ из OllyDbg в файл, перезагружаем DLL-файл под отладкой и видим столь желанные подчёркивания под вшитыми в инструкции абсолютными адресами IAT-ячеек, свидетельствующие о том, что релокации работают и абсолютные адреса будут подкорректированы в случае загрузки по неродной базе:
Я выше писал(а):На самом деле осталось ещё две вещи, о которых мы забыли, которые, пока не сделаны, оставляют наш пропатченный драйвер неполноценным уродцем.
Это была первая и главная вещь. Вторая вещь не настолько критична и без всё работает, но лучше конечно не забывать об этом.
Мы внедрили кое-что (кусочки
hacked_alloc400,
hacked_free400, новую IAT и новый дескриптор импорта) в конец секции кода, а значит, нам нужно исправить виртуальный размер секции
.text в таблице секций. Новая граница секции кода лежит по адресу
0x50909EEB, что соответствует RVA
0x9EEB, что с учётом RVA начала секции
0x1000 даёт новый виртуальный размер секции, равный
0x8EEB.
Исправляем:
Кроме того, мы в общем и целом внесли множество изменений в PE-файл, что неизбежно привело к потере соответствия контрольной суммы, хранящейся в PE-заголовке, и реальной контрольной суммы образа. Нужно вычислить новую контрольную сумму и записать её в PE-заголовок. OllyDbg не умеет вычислять чексум (по крайней мере без сторонних плагинов) — это можно сделать множеством других утилит. Я же попросту написал такую утилиту из пары строк: всего-то вызвать
MapFileAndCheckSum.
Обновляем чексум и сохраняем изменения в файл уже в последний раз:
Всё! Теперь на руках имеется исправленный драйвер, который дружит с DEP, который не зависит от версии kernel32.dll, который импортирует функции как положено и при этом может загружаться по любому базовому адресу. Печать из любых приложений работает и ничего не падает!
Я принялся искать темы на разных форумах, где поднималась бы проблема кривого оригинального драйвера, и уже представлял себе,
что я приду в эти топики и как мессия выложу вылеченный драйвер и принесу людям избавление от этой проблемы.
Однако же в этот раз практически сразу я наткнулся на информацию о том, что Canon всё-таки выпустил новую версию драйвера, где исправил эту проблему! То есть на сайте Кэнона на странице, посвящённой принтеру, по прежнему под видом «самых последних, самых свежих» драйверов скачиваются файлы версии
1.0.0.7 с проблемой DEP-несовместимости. Но существует отдельный «архив», где лежит именно файл
CAP3RDN.DLL версии
1.0.0.8, в котором этот баг исправлен силами самой фирмы
Canon!
И если 5 лет назад, когда я усиленно искал готовое решение проблемы, ни одного упоминания исправленной версии не находилось, то сейчас о ней знает, кажется, половина интернета. И все старания по вылечиванию версии
1.0.0.7 были напрасными, ведь есть уже готовая хорошая версия
1.0.0.8.
Надо сказать, что я не сильно расстроился: на все описанные манипуляции по вылечиванию драйвера у меня ушло не больше 40—60 минут. Зато на то, чтобы написать весь этот текст, описать процесс лечения, сделать все эти скриншоты, нарисовать на них стрелочки, подписи и выделения цветом ушла просто
уйма времени. Без преувеличений.
Ну, зато по мотивам проделанной работы получилась неплохая статья о том, как лечить подобные проблемы, которая раскрывает некоторые хитрости и тонкости процесса модификации исполняемых файлов, и может быть кому-то окажется полезной для лечения пусть не этого драйвера принтера, но какого-то другого. Или хотя бы придаст уверенности, чтобы не бояться идти на пролом и решать проблему.
Ну и житейская мудрость: семь раз погугли — один раз хакни.Если кому-то интересно по итогам прочитанного посмотреть и поковырять драйвер в отладчике или дизассемблере, выкладываю: