Продовжуємо розбиратися з «історичними причинами» cmd.exe

image

У попередній статті ми поговорили про можливий варіант вирішення ситуації з необхідністю зазначення ключа "/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. Якби ми обрали перший шлях, то ніякої статті і не було, так що Ви вже повинні були здогадатися, до чого все йде.

Як протікав процес, і що з цього вийшло, читайте під катом (обережно, багато скріншотів).

Для початку нам треба подивитися, з чого це раптом cmd.exe вирішив шукати об'єкти у зазначеній користувачем директорії, а в %HOMEPATH%.

Итерирование по об'єктах в директорії за допомогою WinAPI зазвичай здійснюється за допомогою функцій FindFirstFile і FindNextFile, а також їх варіацій у вигляді FindFirstFileEx, FindFirstFileTransacted і т. д. Запускаємо OllyDbg, завантажуємо в нього cmd.exe (зрозуміло, заздалегідь скопійований в будь-яку відмінну від "%WINDIR%\system32" директорію), відкриваємо вікно зі списком межмодульних викликів (right-click по вікну CPU -> Search for -> All intermodular calls), пишемо «FindFirstFile» і ставимо бряки на всі виклики за допомогою клавіші F2:

image

Вводимо досліджувану нами команду «CD C:/», натискаємо Tab і бачимо перед собою таку картину:

image

Зверніть увагу на перший переданих функції 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'а й бачимо:

image

Так, тепер цей аргумент вказує на рядок: «C:\*», як і передбачалося. Отже, у разі використання прямого слеша як path separator'а cmd.exe пробігається по об'єктах, підходящим для автодоповнення, в поточній директорії.

Бігаємо по викликам всіх процедур з call stack'а, що відкривається по натисканню клавіш Alt-K, і бачимо біля одного з них щось схоже на парсинг прийшла від користувача команди:

image

Ставимо бряк на початок даної процедури (в моєму випадку це 0x4ACE1877), натискаємо F9, вводимо нашу команду із зворотним слешем і Tab'ом ще раз і починаємо покрокову налагодження. Незабаром після посилених натискань клавіші F7 ми розуміємо, що опинилися в циклі, який пробігається за всіма наявними у введеної користувачем команді символів:

image

EBP+8 вказує на юникодовую рядок з командою, в EBP+10 міститься довжина команди, а EDI є лічильником циклу.

Практично відразу після цього циклу знаходиться виклик функції std::memcpy, в результаті якого у разі використання backslash'а в dest потрапить «C:\"

image

, а в разі forward slash'а — порожній рядок:

image

Що ж, спробуємо розібратися, що відбувається в цьому циклі, перевівши алгоритм його роботи на якій-небудь високорівнева мова програмування. 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)
{
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-кодів:

image

Поекспериментувавши з вхідними даними, можна побачити наступне:

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:

image

По дефолту ReadConsole повертає управління викликав його коду після натискання клавіші Enter, але, мабуть, це не наш випадок, т. к. завершувати свою роботу вона повинна, наприклад, після натискання Tab.

Ставимо софтварный бряк на її виклик і домагаємося його спрацьовування:

image

Зверніть увагу на останній параметр, який тут именутся pReserved. Насправді, він називається pInputControl і відповідає за наступне:

pInputControl [in, optional]
A pointer to a CONSOLE_READCONSOLE_CONTROL structure that specifies a control character signal to 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:

image

Ставимо бряк, зупиняємося на ньому, натискаємо 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;

В результаті отримуємо наступне:

image

Вказівка символів для зупинки вводу відбувається за допомогою поля dwCtrlWakeupMask:

dwCtrlWakeupMask
A user-defined control character used to signal that the read is complete
Як бачите, у нашому випадку він містить значення 0x200, яке вийшло в результаті виконання операції бітового зсуву 1 << 0x9, де 0x9 — це ASCII-код Tab'а.

Що ж, ми переконалися, що повернення з функції ReadConsole здійснюється після введення користувачем enter ' або а, або Tab'а. Тепер давайте повернемося до покрокової налагодження.

Побігавши трохи, ми опинимося на ще одному циклі, який итерируется по всім символам у введеної користувачем команді:

image

Тут EDI вказує на юникодовую рядок з командою, а EAX є лічильником циклу.

Як бачите, кожен символ порівнюється спочатку з 0x0D, потім зі значенням за адресою 0x4A1640A0, а потім з вмістом за адресою 0x4A1640A4. Якщо поглянути на таблицю ASCII-кодів, то ми побачимо, що 0x0D — це ні що інше, як carriage return. За вказаними раніше адресами зберігається одне і те ж значення 0x9, яке, як вже згадувалося раніше, є ASCII-кодом Tab'а:

image

А недалеко від адреси, за якою буде здійснюватися перехід в разі рівності поточного символу з 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:

image

Я думаю, Ви вже помітили, що він затер наступну інструкцію. Нічого страшного, оскільки, по суті, нею була аналогічна перевірка на рівність поточного символу на все той же Tab, а іншими способами потрапити на неї було не можна. Щоб переконатися в цьому, можна виділити зміни, повернути все, як було, за допомогою Alt-Backspace, виділити рядок з даною інструкцією та натиснути Ctrl-R, де буде одна-єдина рядок з цією ж адресою:

image

Перевіряємо работоспобность, і… По натисненню Tab'а forward slash'і дійсно замінюються на backslash'і, у результаті чого автодоповнення виконується по вказаній користувачем директорії, незалежно від того, які саме слеші він використовував спочатку.

Післямова
Хтось може сказати, що це все дрібниці. Комусь може не сподобатися те, що цю задачу вирішуємо ми, а не розробники з Microsoft. Кому-то може взагалі нічого не сподобатися. Але факт залишається фактом — свою проблему ми вирішили, і тепер cmd.exe працює так, як ми того хотіли на самому початку статті. А займатися подібним чи ні, вирішувати вже Вам.

Справедливості заради варто відзначити, що в PowerShell цю «проблему», так само як і ситуацію з ключем "/D" для команди CD, все ж виправили.

Спасибі за увагу, і знову сподіваюся, що стаття була комусь корисною.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.