Продолжаем разбираться с «историческими причинами» в 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)
          {
            /**
             * 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-кодов:

    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 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:

    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, всё же исправили.

    Спасибо за внимание, и снова надеюсь, что статья оказалась кому-нибудь полезной.
    • +37
    • 23.1k
    • 7
    Share post
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 7

      +1
      Да вы маньяк! Реализация команды «cd -» присутствует?)
        +2
        Нет, но есть готовые PUSHD и POPD
        +5
        Такое ощущение, что автору дали задание портировать башевский скрипт на cmd, и он, следуя завету «проще портировать шелл, чем шелл-скритп» начал потихоньку портировать.
          0
          Большое спасибо за статью. Хабр торт!
            –2
            Добрый день!

            В ReactOS логика работы CMD аналогичная. Может зашлете патч с кратким объяснением «исторических причин»?
              0
              Для любителей *nix'ов рекомендую clink. У него дофига вкусняшек, но на мой взгляд киллер-фича — это превращение tab-дополнений из виндового стиля в никсовый. (Кстати, прямые слэши при этом автоматически конвертируются в обратные, в самой командной строке.)
                0
                Спасибо за ссылку на clink!

              Only users with full accounts can post comments. Log in, please.