В предыдущей статье мы поговорили о возможном варианте решения ситуации с необходимостью указания ключа "/D" для команды CD, входящей в поставку стандартного для операционных систем семейства Windows интерпретатора командной строки cmd.exe. Пришла пора поговорить о ещё одном поведении, которое тянется с незапамятных времён без особой на то причины.
На этот раз речь пойдёт об автодополнении путей, которое в большинстве сред и программных продуктов (и cmd.exe не является в данном случае исключением) осуществляется при помощи нажатия клавиш Tab / Shift-Tab. Думаю, никто не станет спорить с тем, что фича это довольно полезная и зачастую экономит до нескольких секунд времени, которое было бы потрачено на ручной ввод полного пути до интересущего пользователя файла или директории. Здорово, что она присутствует и в cmd.exe, однако…
Давайте поэкспериментируем. Запустим cmd.exe (Win-R -> cmd), начнём вводить команду «CD C:/», нажмём Tab, и… Вместо ожидаемых директорий наподобие «Program Files» и «Windows» получим первый по алфавиту объект из %HOMEPATH%, «слепленный» воедино с «C:/» (в моём случае это дало результат в виде «C:/.vim»). Почему? Думаю, те, кому по своему роду деятельности приходилось часто сталкиваться с cmd.exe, уже поняли, в чём тут дело — вместо forward slash'а для корректного автодополнения следовало использовать backslash (кстати, есть и другие исключения в этом плане). Особенно это непривычно для тех, кто большую часть своего времени проводит в других системах (например, *nix-like), где в качестве path separator'а используется как раз прямой слеш, а не обратный. Почему Microsoft решили использовать именно этот символ вместо уже ставшего на тот момент привычным для многих пользователей forward slash'а, объясняется, например, тут. Ну, а нам остаётся либо смириться с этим, либо взять в руки
Как протекал процесс, и что из этого вышло, читайте под катом (осторожно, много скриншотов).
Для начала нам надо посмотреть, с чего это вдруг cmd.exe решил искать объекты не в указанной пользователем директории, а в %HOMEPATH%.
Итерирование по объектам в директории при помощи WinAPI обычно осуществляется посредством функций FindFirstFile и FindNextFile, а также их вариаций в виде FindFirstFileEx, FindFirstFileTransacted и т.д. Запускаем OllyDbg, загружаем в него cmd.exe (разумеется, заранее скопированный в любую отличную от "%WINDIR%\system32" директорию), открываем окно со списком межмодульных вызовов (right-click по окну CPU -> Search for -> All intermodular calls), пишем «FindFirstFile» и ставим бряки на все вызовы при помощи клавиши F2:
Вводим исследуемую нами команду «CD C:/», нажимаем Tab и видим перед собой следующую картину:
Обратите внимание на первый из переданных функции FindFirstFileEx аргумент — именно он, согласно документации, задаёт критерий, по которому будет осуществляться поиск:
lpFileName [in]
The directory or path, and the file name, which can include wildcard characters, for example, an asterisk (*) or a question mark (?)
В моём случае он указывал на адрес 0x0030F660, где хранилась юникодовая строка «C:\Program Files\*». Почему именно она? Да потому что именно там я находился в момент ввода команды CD.
Давайте проделаем то же самое, используя backslash вместо forward slash'а. Нажимаем F9, вводим команду «CD C:\» с последующим нажатием Tab'а и видим:
Да, теперь этот аргумент указывает на строку «C:\*», как и предполагалось. Следовательно, в случае использования прямого слеша в качестве path separator'а cmd.exe пробегается по объектам, подходящим для автодополнения, в текущей директории.
Бегаем по вызовам всех процедур из call stack'а, открывающегося по нажатию клавиш Alt-K, и видим возле одного из них нечто похожее на парсинг пришедшей от пользователя команды:
Ставим бряк на начало данной процедуры (в моём случае это 0x4ACE1877), нажимаем F9, вводим нашу команду с обратным слешем и Tab'ом ещё раз и начинаем пошаговую отладку. Вскоре после усиленных нажатий клавиши F7 мы понимаем, что оказались в цикле, который пробегается по всем имеющимся во введённой пользователем команде символам:
EBP+8 указывает на юникодовую строку с командой, в EBP+10 содержится длина команды, а EDI является счётчиком цикла.
Практически сразу после этого цикла находится вызов функции std::memcpy, в результате которого в случае использования backslash'а в dest попадёт «C:\»
, а в случае forward slash'а — пустая строка:
Что ж, попробуем разобраться, что происходит в этом цикле, переведя алгоритм его работы на какой-нибудь высокоуровневый язык программирования. IDA Pro может декомпилировать код за вас, но, к сожалению, за это она просит довольно много денег, так что попробуем перевести его самостоятельно на C++:
#include <cstddef>
#include <cstring>
#include <cwchar>
#include <iostream>
#include <string>
int main()
{
std::wstring command;
std::getline(std::wcin, command);
auto command_size = command.size();
int ebx = -1;
int esi = 0;
int edx = 0;
const int ebp_24 = 0; // Always 0 in our case cause it changes in the '"' branch
// Not actually used in our case
int ebp_1c = 0;
int ebp_28 = 0;
int ebp_2c = 0;
/**
* 4ACE18C7 | > / 897D D0 / MOV DWORD PTR SS : [EBP - 30], EDI
* 4ACE18CA | . | 8B45 10 | MOV EAX, DWORD PTR SS : [EBP + 10]
* 4ACE18CD | . | 3BF8 | CMP EDI, EAX
* 4ACE18CF | . | 0F8D 90000000 | JGE cmd.4ACE1965
*/
for (std::wstring::size_type i = 0; i < command_size; ++i)
{
/**
* 4ACE18D5 | . 8B45 08 | MOV EAX, DWORD PTR SS : [EBP + 8]
* 4ACE18D8 | . 0FB70478 | MOVZX EAX, WORD PTR DS : [EAX + EDI * 2]
*/
const wchar_t cur_symbol = command[i];
// 4ACE18DC | . 66:83F8 2F | CMP AX, 2F
if (cur_symbol == L'/')
{
/**
* 4ACE18E2 | . 8D77 01 | LEA ESI, DWORD PTR DS : [EDI + 1]
* 4ACE18E5 | . 8975 D8 | MOV DWORD PTR SS : [EBP - 28], ESI
*/
esi = i + 1;
ebp_28 = esi;
}
else if (cur_symbol == L'"')
{
// ...
}
// 4ACE18F0 | . 3955 DC | CMP DWORD PTR SS : [EBP - 24], EDX
if (ebp_24 == edx)
{
/**
* 4ACE190C | . 50 | PUSH EAX; / w
* 4ACE190D | . 68 E008D04A | PUSH cmd.4AD008E0; | wstr = " &()[]{}^=;!%'+,`~"
* 4ACE1912 | .FF15 F010CC4A | CALL DWORD PTR DS : [<&msvcrt.wcschr>]; \wcschr
* 4ACE1918 | . 59 | POP ECX
* 4ACE1919 | . 59 | POP ECX
* 4ACE191A | . 85C0 | TEST EAX, EAX
*/
if (std::wcschr(L" &()[]{}^=;!%'+,`~", cur_symbol) != NULL)
{
/**
* 4ACE191E |. 8D77 01 |LEA ESI,DWORD PTR DS:[EDI+1]
* 4ACE1921 |. 8975 D8 |MOV DWORD PTR SS:[EBP-28],ESI
* 4ACE1924 |. 8365 E4 00 |AND DWORD PTR SS:[EBP-1C],0
* 4ACE1928 |. 33D2 |XOR EDX,EDX
*/
esi = i + 1;
ebp_28 = esi;
ebp_1c = 0;
edx = 0;
}
else
{
// 4ACE192C | > \33D2 | XOR EDX, EDX
edx = 0;
/**
* 4ACE1935 | . 66:83F8 3A | CMP AX, 3A
* 4ACE1939 | . 74 1B | JE SHORT cmd.4ACE1956
* 4ACE193B | . 66 : 83F8 5C | CMP AX, 5C
* 4ACE193F | . 74 15 | JE SHORT cmd.4ACE1956
*/
if (cur_symbol == L':' || cur_symbol == L'\\')
{
/**
* 4ACE1956 | > \8D5F 01 | LEA EBX, DWORD PTR DS : [EDI + 1]
* 4ACE1959 | . 895D D4 | MOV DWORD PTR SS : [EBP - 2C], EBX
* 4ACE195C | > 8955 E4 | MOV DWORD PTR SS : [EBP - 1C], EDX
*/
ebx = i + 1;
ebp_2c = ebx;
ebp_1c = edx;
}
else if (cur_symbol == L'*' || cur_symbol == L'?')
{
// ...
}
}
}
}
/**
* 4ACE1965 |> \83FB FF CMP EBX,-1
* 4ACE1968 |. 74 04 JE SHORT cmd.4ACE196E
* 4ACE196A |. 3BDE CMP EBX,ESI
* 4ACE196C |. 7D 05 JGE SHORT cmd.4ACE1973
*/
if (ebx == -1 || ebx < esi)
{
/**
* 4ACE196E | > \8BDE MOV EBX, ESI
* 4ACE1970 | . 895D D4 MOV DWORD PTR SS : [EBP - 2C], EBX
*/
ebx = esi;
ebp_2c = ebx;
}
/**
* 4ACE1973 | > \2BC6 SUB EAX, ESI
* 4ACE1975 | . 03C0 ADD EAX, EAX
* 4ACE1977 | . 8BF8 MOV EDI, EAX
* 4ACE1979 | . 57 PUSH EDI; / n
* 4ACE197A | . 8B45 08 MOV EAX, DWORD PTR SS : [EBP + 8]; |
* 4ACE197D | . 8D0470 LEA EAX, DWORD PTR DS : [EAX + ESI * 2]; |
* 4ACE1980 | . 50 PUSH EAX; | src
* 4ACE1981 | .FF75 E0 PUSH DWORD PTR SS : [EBP - 20]; | dest
* 4ACE1984 | .E8 52FAFDFF CALL <JMP.&msvcrt.memcpy>; \memcpy
*/
const std::size_t count = (command_size - esi) * 2;
wchar_t dest[1024] = { 0 };
std::memcpy(dest, command.substr(esi).c_str(), count);
std::wcout << "Result: " << dest << std::endl;
}
Места, помеченные комментарием "// ...", в рассматриваемых нами случаях не затрагиваются.
Символы наподобие '*' и '\' были определены по таблице ASCII-кодов:
Поэкспериментировав со входными данными, можно увидеть следующее:
CD C:\
Result: C:\
CD C:/
Result:
CD C:\Windows\
Result: C:\Windows\
CD C:/Windows\
Result: Windows\
Несложно заметить, что forward slash вызывает проблемы независимо от того, в каком именно месте введённого пользователем пути он стоит — хоть в конце, хоть в середине.
Решением может являться замена всех forward slash'ей на backslash'и сразу после того, как cmd.exe понял, что необходимо выполнять автодополнение. Для этого предлагаю подойти с другой стороны — провести пошаговую отладку сразу после ввода пользователем данных из стандартного потока ввода.
Однако считывание данных из stdin может осуществляться самыми разными способами. Как же понять, что именно используется в cmd.exe? Довольно просто — нажимаем F9, затем F12 (Pause), смотрим на call stack и видим среди вызовов WinAPI-функцию под названием ReadConsole:
По дефолту ReadConsole возвращает управление вызвавшему его коду после нажатия клавиши Enter, но, видимо, это не наш случай, т.к. завершать свою работу она должна, например, после нажатия Tab.
Ставим софтварный бряк на её вызов и добиваемся его срабатывания:
Обратите внимание на последний параметр, который здесь именутся pReserved. На самом деле, он называется pInputControl и отвечает за следующее:
pInputControl [in, optional]
A pointer to a CONSOLE_READCONSOLE_CONTROL structure that specifies a control character to signal the end of the read operation. This parameter can be NULL
В нашем случае он вовсе не NULL, так что давайте посмотрим, как выглядит структура CONSOLE_READCONSOLE_CONTROL:
typedef struct _CONSOLE_READCONSOLE_CONTROL {
ULONG nLength;
ULONG nInitialChars;
ULONG dwCtrlWakeupMask;
ULONG dwControlKeyState;
} CONSOLE_READCONSOLE_CONTROL, *PCONSOLE_READCONSOLE_CONTROL;
Смотреть на «сырые» байты не очень удобно, так что давайте воспользуемся специальным плагином для OllyDbg под названием StollyStructs, который как раз и предназначен для визуализации структур. Качаем, разархивируем .dll и .ini в директорию, где лежит исполняемый файл OllyDbg (разумеется, если он указан в качестве пути для плагинов, что и сделано по дефолту) и перезапускаем отладчик. После перезапуска cmd.exe адреса могут смениться, но, вероятнее всего, «окончание» адресов останется прежним. Например, если раньше интересующий нас вызов ReadConsole находился по адресу 0x4ACD3589, то теперь он, возможно, будет находиться на адресе вида 0xXXXXX589:
Ставим бряк, останавливаемся на нём, нажимаем Plugins -> StollyStruct -> Select structure, вписываем адрес, передаваемый в качестве аргумента pInputControl, в поле «Address», и… Не находим структуру CONSOLE_READCONSOLE_CONTROL в выпадающем списке. Что ж, автор и не обещал, что заранее будут заданы все структуры из WinAPI. Варианта два — либо добавить описание данной структуры в конфигурационный файл плагина, либо воспользоваться другой структурой, которая будет аналогична интересующей нас. Первое, что мне пришло на ум — это структура RECT, которая так же содержит 4 поля с той лишь разницей, что в ней используются LONG'и вместо ULONG'ов, что нас, в принципе, в данном случае вряд ли будет беспокоить:
typedef struct _RECT {
LONG left;
LONG top;
LONG right;
LONG bottom;
} RECT, *PRECT;
В результате получаем следующее:
Указание символов для остановки ввода происходит при помощи поля dwCtrlWakeupMask:
dwCtrlWakeupMask
A user-defined control character used to signal that the read is complete
Как видите, в нашем случае он содержит значение 0x200, которое получилось в результате выполнения операции битового сдвига 1 << 0x9, где 0x9 — это ASCII-код Tab'а.
Что ж, мы убедились, что возврат из функции ReadConsole осуществляется после ввода пользователем либо Enter'а, либо Tab'а. Теперь давайте вернёмся к пошаговой отладке.
Побегав немного, мы окажемся на ещё одном цикле, который итерируется по всем символам во введённой пользователем команде:
Здесь EDI указывает на юникодовую строку с командой, а EAX является счётчиком цикла.
Как видите, каждый символ сравнивается сначала с 0x0D, потом со значением по адресу 0x4A1640A0, а затем с содержимым по адресу 0x4A1640A4. Если взглянуть на таблицу ASCII-кодов, то мы увидим, что 0x0D — это ни что иное, как carriage return. По указанным же ранее адресам хранится одно и то же значение 0x9, которое, как уже упоминалось ранее, является ASCII-кодом Tab'а:
А недалеко от адреса, по которому будет осуществляться переход в случае равенства текущего символа с Tab'ом, находится код парсинга переданной команды, который мы уже видели ранее. Что ж, по-моему, это то самое место, где лучше всего разместить переход на наш code cave.
Что мы будем в нём делать? Предлагаю поступить следующим образом — пробегаться с конца строки до её начала, проверяя каждый символ до первого встреченного символа пробела на равенство с forward slash'ем и заменяя его на backslash. Выглядеть это будет примерно следующим образом:
PUSHFD
PUSHAD
; Если строка пустая, то ничего не делаем
TEST EAX,EAX
JZ l1
; Декрементируем счётчик цикла (необходимо выполнять даже на
; первой итерации, чтобы пропустить символ Tab'а)
l4:
DEC EAX
; Помещаем в ECX текущий символ
MOVZX ECX,WORD PTR DS:[EDI+EAX*2]
CMP CX,2F ; Если это forward slash
JE l2
CMP CX,20 ; Если это пробел
JE l1
JMP l3
l2:
; Заменяем forward slash на backslash
MOV WORD PTR DS:[EDI+EAX*2],5C
l3:
; Если счётчик цикла равен нулю, то выходим из цикла
TEST EAX,EAX
; В противном случае прыгаем на начало цикла
JNZ l4
l1:
POPAD
POPFD
; Осуществляем прыжок на адрес, по которому
; должны были перейти изначально
JMP 4ACD42CD
Находим место для нашего code cave'а (можно сделать это при помощи Ctrl-B -> много нулей в поле «HEX +0C») и пишем туда следующий код (адреса, разумеется, могут отличаться):
4A163CC5 9C PUSHFD
4A163CC6 60 PUSHAD
4A163CC7 85C0 TEST EAX,EAX
4A163CC9 74 1D JE SHORT cmd.4A163CE8
4A163CCB 48 DEC EAX
4A163CCC 0FB70C47 MOVZX ECX,WORD PTR DS:[EDI+EAX*2]
4A163CD0 66:83F9 2F CMP CX,2F
4A163CD4 74 08 JE SHORT cmd.4A163CDE
4A163CD6 66:83F9 20 CMP CX,20
4A163CDA 74 0C JE SHORT cmd.4A163CE8
4A163CDC EB 06 JMP SHORT cmd.4A163CE4
4A163CDE 66:C70447 5C0>MOV WORD PTR DS:[EDI+EAX*2],5C
4A163CE4 85C0 TEST EAX,EAX
4A163CE6 ^ 75 E3 JNZ SHORT cmd.4A163CCB
4A163CE8 61 POPAD
4A163CE9 9D POPFD
4A163CEA ^ E9 DE05FFFF JMP cmd.4A1542CD
, где 0x4A1542CD — это адрес, на который мы должны были перейти в результате условного перехода, находящегося по адресу 0x4A154299 и осуществляющего проверку на равенство текущего символа в команде на Tab. Тот переход, соответственно, заменяем на прыжок на наш code cave:
Я думаю, Вы уже заметили, что он затёр следующую инструкцию. Ничего страшного, т.к., по сути, ею являлась аналогичная проверка на равенство текущего символа на всё тот же Tab, а другими способами попасть на неё было нельзя. Чтобы убедиться в этом, можно выделить наши изменения, вернуть всё, как было, при помощи Alt-Backspace, выделить строчку с данной инструкцией и нажать Ctrl-R, где будет одна-единственная строчка с этим же адресом:
Проверяем работоспобность, и… По нажатию Tab'а forward slash'и действительно заменяются на backslash'и, в результате чего автодополнение выполняется по указанной пользователем директории, независимо от того, какие именно слеши он использовал изначально.
Послесловие
Кто-то может сказать, что это всё мелочи. Кому-то может не понравиться то, что эту задачу решаем мы, а не разработчики из Microsoft.
Справедливости ради стоит отметить, что в PowerShell эту «проблему», равно как и ситуацию с ключом "/D" для команды CD, всё же исправили.
Спасибо за внимание, и снова надеюсь, что статья оказалась кому-нибудь полезной.