2007 г.
Win32 в машинных кодах
Рустэм Галеев aka Roustem
Назад Содержание Вперёд
5. Консольное приложение
Существует разновидность приложений Windows, которые называются консольными. По своим "внешним" проявлениям они напоминают приложения DOS, запущенные в Windows. Тем не менее, это настоящие Win32-приложения, которые под DOS работать не будут; для них также доступен Win32 API, а кроме того, они могут использовать консоль - окно, предоставляемое системой, которое работает в текстовом режиме и в которое можно вводить данные с клавиатуры.
Особенность консольных приложений в том, что они работают не в графическом, а в текстовом режиме. Для этого используются унаследованые от DOS так называемые стандартный ввод и стандартный вывод. Все, что пользователь вводит с клавиатуры (когда консольное окно имеет фокус), попадает в буфер стандартного ввода, откуда данные можно читать, как из файла. Выходные же данные можно записать, как в файл, в буфер стандартного вывода, и они будут отображены в консольном окне.
Еще одной особенностью стандартных ввода и вывода является возможность их перенаправления в файл. Этим мы уже пользовались при создании наших приложений, используя в командной строке знаки '<' для перенаправления ввода и '>' для перенаправления вывода. Debug, будучи приложением DOS, использует для приема команд стандартный ввод, а для отображения данных - стандартный вывод. Когда мы писали: 'debug < code.txt', содержащиеся в файле code.txt данные поступали в debug так, как будто их набирают на клавиатуре. При команде же 'debug > result.lst' вся информация, которая выводилась бы на экран, попадала в файл 'result.lst'.
Из приложений, подобных debug, т.е. использующих для ввода данных стандартный ввод, а для вывода - соответственно стандартный вывод, можно строить даже своеобразные "конвейеры". Для этого данные из стандартного вывода одного приложения подают на стандартный ввод другого посредством специального знака командной строки '|' (вертикальной черты). В команде
приложение1 | приложение 2 | приложение3
данные проходят последовательную обработку этими тремя приложениями. Например, приложение1 могло бы составлять список из данных источника, приложение2 - сортировать его, а приложение3 - форматировать нужным образом. Таким образом, у консольных приложений есть свои преимущества; их удобно использовать в качестве "строительных блоков" для автоматизации многих рутинных задач, не требующих интерактивного взаимодействия с пользователем.
Работа со стандартными вводом и выводом "изнутри" подобна работе с файлами. Стандартный ввод выглядит как файл с разрешением "только для чтения", а стандартный вывод - как файл с разрешением "только для записи". Для работы с ними используют соответствующие функции API - ReadFile и WriteFile из модуля Kernel32.dll. Рассмотрим их подробнее.
При вызове функции ReadFile в стек помещаются 5 параметров в следующем порядке:
- адрес структуры, использующейся при асинхронном вводе-выводе. При обычном (синхронном) вводе значение этого параметра равно нулю;
- адрес переменной (4 байта), по которому будет записано количество действительно прочитанных функцией байтов (это значение может быть меньше заявленного - например, если кончились данные);
- число байтов, которые нужно прочесть ("заявка");
- адрес, по которому нужно разместить прочитанные данные (буфер);
- описатель файла, из которого производится чтение.
Внимания заслуживает последний аргумент. Для работы с файлом используется так называемый описатель (handle) - это некий идентификатор, который система присваивает файлу после его открытия. На самом деле, при открытии файла создается внутренняя системная структура, в которой хранятся различные вспомогательные данные, такие как текущая позиция, с которой нужно читать или записывать данные, и т.п. Все обращения к файлам возможны только после их открытия и только по их описателям.
Функция WriteFile также принимает 5 схожих параметров:
- адрес структуры для асинхронного вывода;
- адрес переменной (4 байта), в которую будет помещено количество действительно записанных байтов;
- число байтов, которые нужно записать ("заявка");
- адрес начала буфера, где находятся предназначенные для записи данные;
- описатель файла, в который производится запись.
Но как получить нужные описатели? В случае файлов существуют специальные функции API (наподобие CreateFile) для их открытия. Для стандартного ввода-вывода тоже существует своя функция - GetStdHandle, тоже из модуля Kernel32.dll. Она принимает лишь один аргумент - число, указывающее на тип нужного описателя (для стандартного ввода или вывода). На самом деле, существует еще и третий тип - стандартная ошибка, он, как и стандартный вывод, служит для отображения сообщений на экране. Его можно использовать в тех случаях, когда нужно как-то разделить обычные сообщения и сообщения об ошибках (например, можно использовать перенаправление только для стандартной ошибки - тогда эти сообщения будут записаны в файл и не попадут на экран). В качестве параметра функции GetStdHandle используются 0FFFFFFF6h для стандартного ввода, 0FFFFFFF5h для стандартного вывода и 0FFFFFFF4h для стандартной ошибки.
Настало время обсудить один важный вопрос. Функции не только принимают параметры, часто они еще возвращают значения. Результат работы функции по ее возвращении (т.е. перед выполнением следующей после вызова функции инструкции) оказывается в регистре EAX. Это общее соглашение: когда мы начнем создавать свои функции, мы тоже будем должны записывать в регистр EAX значение, которое должно быть возвращено как результат функции. В случае функции GetStdHandle таким результатом как раз и является нужный нам описатель. Его можно либо сохранить где-то в памяти (переписав туда значение из регистра), либо использовать прямо в регистре, если вызов нужной функции непосредственно следует после получения описателя.
Здесь нужно отметить еще один момент. Мы уже знаем, что из 8 общих регистров один (ESP) используется в качестве указателя стека, и его трогать нельзя. На самом деле, при работе со стеком используется еще и второй регистр - EBP, поэтому число доступных для манипуляций регистров сокращается до 6. Теперь задумайтесь над вопросом: а что случается с данными, которые находились в регистрах, после вызова функции? Особенно, если это "чужие" функции, являющиеся для нас "черными ящиками" (как в случае с функциями API). Значения в регистрах могут быть перезаписаны (ведь надо с чем-то работать!), а могут остаться без изменения. Чтобы внести ясность в этот вопрос, для работы с функциями Win32 API было принято следующее соглашение: при вызовах любых функций значения регистров EBX, ESI и EDI остаются без изменений - какие были перед вызовом функции, такие будут и после; значения же регистров EAX, ECX и EDX могут быть изменены произвольным образом. В регистре EAX, как мы уже знаем, будет находиться результат работы функции (если функция возвращает результат). Если функция не имеет возвращаемого результата, значение в EAX не определено.
Практический же вывод такой. Если нам нужно, чтобы значение в "изменяемых" регистрах (EAX, ECX или EDX) сохранилось после вызова функции, перед ее вызовом необходимо поместить значение соответствующего регистра в стек, а после вызова функции - извлечь его оттуда (в тот же регистр). И наоборот: если мы создаем свою функцию, которую может вызвать система (например, забегая вперед, это относится к главной функции окна), и если в работе этой функции нам приходится использовать регистры, которые не должны изменяться (EBX, ESI или EDI), мы должны в самом начале функции сохранить значение этого используемого регистра в стеке, а перед возвратом из функции - восстановить его. В случае "изменяемых" регистров этого делать не нужно.
Что ж, необходимый теоретический минимум мы прошли; теперь можно применить его на практике. Попробуем создать простое консольное приложение, выводящее сообщение. "Макет" нашего приложения будет следующий: сначала идет PE-заголовок, затем секции кода (.code), данных (.data) и вспомогательных данных для импорта (.rdata). По сравнению с прошлым разом для разнообразия переставлены местами секции .data и .rdata. Как и раньше, секции располагаются в памяти по смещениям 1000h, 2000h и 3000h соответственно, а в файле - 200h, 400h и 600h.
Начнем с секции данных. Их немного - в начале секции (по смещению 2000h, который после загрузки превратится в виртуальный адрес 402000h) разместим переменную в 4 байта для вывода количества записанных байтов. Сразу за ней (по адресу 402004h) будет выводимая текстовая строка. Набираем файл data.txt:
n data.bin
r cx
200
f 0 l 200 0
a 0
; 4 байта для числа выведенных байтов
db 0 0 0 0
; выводимая строка
db "Greetings from console window" 0a 0d 0
m 0 l 200 100
w
q
Числа 0Ah и 0Dh после строки являются ASCII-символами перехода на новую строку; сама строка должна завершаться нулем.
Теперь надо заняться секцией импорта. Нам нужно импортировать три функции, и все из модуля Kernel32.dll: GetStdHandle, WriteFile и ExitProcess. В начале секции, как обычно, таблица импортируемых адресов (IAT); на этот раз она имеет, по числу функций, 3 поля и четвертое нулевое. Сразу вслед за IAT расположим таблицу поиска, тем более, что они при загрузке должны быть идентичны. Затем будет таблица импорта, содержащая одну запись для единственного импортируемого модуля и одну завершающую нулевую запись (общий размер 28h байт). Затем последуют строки с именами модуля и функций. Здесь удобно использовать два прохода в режиме ассемблирования - при "черновом" содержимое структур можно просто заполнять нулями (сохраняя лишь размер полей), а для второго "чистового" прохода подставить из полученного файла отчета нужные значения. Для этой же цели лучше выбрать для "сборки" образа в debug то же смещение, что и у загруженной в память секции (в данном случае - 3000h; файл rdata.txt):
n rdata.bin
r cx
200
f 3000 l 200 0
a 3000
; Таблица импортируемых адресов: до загрузки
; идентична таблице поиска
; будущий адрес GetStdHandle
db 55 30 0 0
; будущий адрес WriteFile
db 64 30 0 0
; будущий адрес ExitProcess
db 70 30 0 0
; завершение таблицы нулями
db 0 0 0 0
; Таблица поиска
; смещение строки с именем GetStdHandle
db 55 30 0 0
; смещение строки с именем WriteFile
db 64 30 0 0
; смещение строки с именем ExitProcess
db 70 30 0 0
; завершающие нули
db 0 0 0 0
; Таблица импорта
; строка для импорта из Kernel32.dll:
; смещение таблицы поиска
db 10 30 0 0
; 2 пустых поля
db 0 0 0 0 0 0 0 0
; смещение имени модуля
db 48 30 0 0
; смещение таблицы импортируемых адресов
db 0 30 0 0
; завершение таблицы - пустая строка (20 нулевых байтов)
db 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
; Имя импортируемого модуля
db "Kernel32.dll" 0
; Имена импортируемых функций; вместо "подсказок" нули
db 0 0 "GetStdHandle" 0
db 0 0 "WriteFile" 0
db 0 0 "ExitProcess" 0
m 3000 l 200 100
w
q
Переходим к секции кода. Освежим знания по инструкциям: "короткая" команда помещения в стек 6Ah + 1 байт, "длинная" - 68h + 4 байта; команда вызова функции - опкод 0FFh, байт ModR/M 15h, указывающий, что операнд находится в памяти по 4-байтному адресу, который включен в инструкцию. Обратите внимание, что для помещения в стек числа 0FFFFFFF5h мы можем использовать "короткий" вариант инструкции со знаковым расширением, т.к. это на самом деле отрицательное число, представимое в виде 1 байта (-11 = 0F5h. Функцию WriteFile мы вызываем непосредственно после функции GetStdHandle, значит, нужный нам описатель файла будет находиться в регистре EAX; поэтому в этот раз придется использовать также инструкцию помещения в стек значения регистра EAX (если помните, это 50h). Указатели на нужные нам функции будут находиться после загрузки в соответствующих полях IAT, по адресам 403000h, 403004h и 403008h. Итак, файл code.txt:
n code.bin
r cx
200
f 0 l 200 0
a 0
; параметр для стандартного вывода
db 6a f5
; вызов GetStdHandle
db ff 15 0 30 40 0
; параметры для WriteFile:
; не используется - 0
db 6a 0
; адрес переменной для числа выведенных символов
db 68 0 20 40 0
; длина строки = 30 (1Eh)
db 6a 1e
; адрес выводимой строки
db 68 4 20 40 0
; содержимое регистра EAX
db 50
; вызов WriteFile
db ff 15 4 30 40 0
; параметр кода завершения (0)
db 6a 0
; вызов ExitProcess
db ff 15 8 30 40 0
m 0 l 200 100
w
q
Осталось лишь добавить PE-заголовок. Сначала скопируем его шаблон (header.txt) в рабочий каталог, а затем слегка его подправим. Потребуются изменения всего в трех местах. Самое главное - нужно изменить подсистему: вместо графической (2) поставить консольную (3). Собственно, это единственное, чем консольные приложения отличаются от графических! Находим в шаблоне строки:
a 9C
; Подсистема: 2 - графическая, 03 - консольная (2 байта)
Сразу после нее должно быть:
db 03 00
Теперь надо указать расположение таблицы импорта. Находим строку:
; Здесь начинается первый элемент каталога:
За ней должен следовать текст:
; смещение таблицы экспорта (4 байта)
db 0 0 0 0
; размер таблицы экспорта (4 байта)
db 0 0 0 0
; Второй элемент каталога:
; смещение таблицы импорта (4 байта)
db 20 30 0 0
; размер таблицы импорта (4 байта)
db 28 0 0 0
Наконец, мы поменяли местами секции .data и .rdata (хотя в принципе этого можно было и не делать). Находим начало второй секции:
; вторая секция
И заменяем оставшийся текст на следующий:
db '.data' 0 0 0
db 0 2 0 0
db 0 20 0 0
db 0 2 0 0
db 0 4 0 0
db 0 0 0 0 0 0 0 0 0 0 0 0
db 40 0 0 c0
;
; третья секция
db '.rdata' 0 0
db 0 2 0 0
db 0 30 0 0
db 0 2 0 0
db 0 6 0 0
db 0 0 0 0 0 0 0 0 0 0 0 0
db 40 0 0 40
m 0 l 200 100
w
q
Вот и все. В файле сборки (make.bat) секции также должны идти в соответствующем порядке:
@echo off
debug < header.txt > report.lst
debug < code.txt >> report.lst
debug < data.txt >> report.lst
debug < rdata.txt >> report.lst
copy /b header.bin+code.bin+data.bin+rdata.bin cnsl.exe
Проверив файл отчета report.lst, можно запускать cnsl.exe. Если вы запускаете его не из консоли, создаваемое окно будет мелькать - закрываться сразу после завершения программы. Поэтому можно запустить сначала консоль командной строки DOS и уже из него - наше приложение, набрав его имя (и путь, если требуется).
Еще одно примечание - в консольных приложениях используется кодировка DOS. Поэтому если вы набрали текст для вывода в Блокноте и на русском, то в консольном окне прочесть его не сможете - в Windows используется другая кодировка (ANSI).
На самом деле, возможности текстового вывода шире, чем можно было бы подумать. Попробуйте в качестве примера использовать такой файл data.txt:
n data1.bin
r cx
200
f 0 l 200 0
a 0
db 0 0 0 0
db c9 cd cd cb cd cd bb 0a 0d
db ba 20 20 ba 20 20 ba 0a 0d
db c7 c4 c4 d7 c4 c4 b6 0a 0d
db ba 20 20 ba 20 20 ba 0a 0d
db c8 cd cd ca cd cd bc 0a 0d 0
m 0 l 200 100
w
q
Чтобы пример работал правильно, надо еще подправить кодовую секцию - там, где строка:
; длина строки = 30 (1Eh)
надо заменить
db 6a 1e
на
db 6a 2e
Впрочем, это лишь подсказка - экспериментируйте сами!
Назад Содержание Вперёд