Учим telnet.exe правильной игре в MUD'ы

image

Как-то раз, решив сыграть во что-нибудь необычное, я обратил свой взгляд на MUD’ы — текстовые компьютерные многопользовательские игры с чатом. Играть в них можно как при помощи специализированных клиентов, написанных под конкретные сервера, так и по telnet’у.

Выбрав один из существующих на данный момент серверов (https://www.bat.org/), я вооружился дефолтным telnet-клиентом под Windows и… Почувствовал разочарование. Нет, дело вовсе не в игре, а в том, как с этой игрой взаимодействует telnet.exe. Печально осознавать, но ни один из вводимых мной символов (имя персонажа, разнообразные действия etc) не отображался на экране консоли. Да, команды отправлялись по нажатию клавиши Enter, но отсутствие даже минимального интерактива делало такую игру практически невозможной (особенно неудобно было удалять введённые ранее символы, ведь приходилось подсчитывать в уме, сколько символов ты уже удалил и на каком находишься в текущий момент).

Недолго думая, я решил попробовать приконнектиться к тому же самому серверу при помощи putty и… Вот это да! Я вижу вводимые мной символы!

Почему же echo не работает в telnet.exe? Можно ли это каким-то образом исправить? Давайте разберёмся.

Как протекал процесс, и что из этого вышло, читайте под катом. Перед прочтением данной статьи также настоятельно рекомендую ознакомиться с предыдущими, т.к. в них уже объяснены многие из опущенных здесь моментов.
Первый шаг — заполучить подопытного. Устанавливаем telnet-клиент (Win-R → appwiz.cpl → Turn Windows features on or off → ставим галочку рядом с надписью «Telnet Client» и нажимаем на кнопку «OK») и копируем исполняемый файл telnet.exe из »%WINDIR%\System32» в любую другую директорию.

Следующий шаг — вооружиться необходимым инструментарием. Скачиваем PE Tools и OllyDbg, о которых я уже не раз упоминал в предыдущих статьях, и распаковываем их в любую удобную для нас директорию.

Далее необходимо понять, включена ли технология ASLR для бинарника, который мы собираемся исследовать. Запускаем PE Tools, жмём Alt-1, выбираем telnet.exe и нажимаем на кнопку «Optional Header»:

image

Да, ASLR включена. Давайте отключим её — заменяем 0×8140 на 0×8100 (почему именно так уже объяснялось ранее — смотрите, например, тут) и нажимаем на кнопку «Ok».

Итак, какие мысли? Первое, что пришло мне на ум — то, что приложение может явно «отключать» echo при помощи WinAPI-функции SetConsoleMode. Запускаем наш бинарник в OllyDbg, открываем окно со списком межмодульных вызовов и видим, что вызовы этой функции действительно присутствуют в приложении:

image

Ставим на них бряки, жмём F9 и останавливаемся на одной из точек останова:

image

Давайте посмотрим на аргументы в окне стека:

image

Читаем документацию:

ENABLE_ECHO_INPUT
0×0004

Characters read by the ReadFile or ReadConsole function are written to the active screen buffer as they are read. This mode can be used only if the ENABLE_LINE_INPUT mode is also enabled


То, что нужно! Однако есть способ даже проще — просто не вызывать эту функцию:

When a console is created, all input modes except ENABLE_WINDOW_INPUT are enabled by default


Давайте перезапустим отладку, занопим её вызов

image

и проверим, работает ли echo теперь. Нет, результат тот же, что и раньше — вводимые символы не отображаются на экране консоли.

Ладно, давайте дождёмся момента, когда игра попросит ввести имя

image

, и нажмём F12 (Pause) в OllyDbg.

Предлагаю осмотреться, чтобы понять, где мы находимся в текущий момент. Для начала открываем Call Stack по нажатию Alt-K:

image

Итак, мы висим где-то в недрах user32.dll. Прыгаем на ближайший «пользовательский» код (т.е. код, принадлежащий модулю telnet), находящийся по адресу 0×0100D0D0, откуда мы и попали в user32.dll:

image

Опытному разработчику под Windows уже должно быть понятно, адрес какой функции лежит, вероятнее всего, в регистре EDI на момент выполнения выделенной инструкции — GetMessage. Но давайте убедимся в этом лично. Ставим бряк по данному адресу, перезапускаем отладку и жмём F9 до попадания на требуемое место:

image

Как видите, это действительно GetMessage. Проблема в нашем случае заключается в том, что эта функция не вернёт управление вызвавшему её коду до нажатия клавиши Enter, что означает то, что она не имеет к echo ровным счётом никакого отношения.

Тогда давайте взглянем, чем в этот момент занимаются остальные потоки (если они, конечно, вообще есть). Снова запускаем программу на дальнейшее выполнение при помощи F9, нажимаем F12 и открываем окно «Threads» (View → Threads):

image

Открываем каждый из них в окне CPU (right-click по соответствующей строчке в окне Threads → Open in CPU), за исключением выделенного красным цветом (это текущий поток, который мы только что рассмотрели), и смотрим на их Call Stack’и. Ваше внимание должен был привлечь поток со следующим стеком вызовов:

image

ReadConsoleInput — это уже более интересная функция в нашем случае. Ставим бряк на её вызов, перезапускаем отладку и… Останавливаемся на нём каждый раз, как передаём фокус окну telnet’а:

image

Обратите внимание, что рядом находится switch, в котором, вероятнее всего, выполняется прыжок на обработчик соответствующего события. Побегав в отладчике, можно узнать, что в случае смены фокуса управление передаётся default case’у:

image

Судя по анализу кода, проведённому OllyDbg’ом, вариантов тут не так уж и много — помимо default case’а, у нас также имеются case’ы 10 и 1, первый из которых после выполнения нескольких инструкций осуществляет прыжок на только что рассмотренный default case. Давайте попробуем убрать бряк с вызова функции ReadConsoleInput и поставить бряк на case 1:

image

Перезапускаем отладку, дожидаемся сообщения с просьбой ввести имя, нажимаем '1' и останавливемся на этом самом case-блоке:

image

Что мы можем сделать теперь? А теперь можно сверить работу telnet.exe в случае коннекта к bat.org и, допустим, smtp.gmail.com, где, как я помню, echo работало корректно. Открываем окно «Run trace» (View → Run trace), жмём на него правой кнопкой мыши, выбираем пункт меню под названием «Log to file», выбираем любое имя файла и нажимаем Ctrl-F11 (Trace into). После выполнения трассировки закрываем файл (right-click по окну «Run trace» → Close log file) и проделываем то же самое в случае smtp.gmail.com:25 (в случае явного указания порта telnet’у IP-адрес необходимо отделять от него символом пробела, т.е. команда должна выглядеть следующим образом — «telnet.exe smtp.gmail.com 25»).

Заметная разница в поведении начинается с адреса 0×0100A2F9:

В случае bat.org

Address  Thread   Command                                   ; Registers and comments
0100AB9F 00002EA0 JNZ telnet.0100AED2
0100ABA5 00002EA0 TEST BYTE PTR SS:[EBP-24],3
0100ABA9 00002EA0 JE telnet.0100AED2
[...]
0100A2F7 00002EA0 TEST EAX,EAX
0100A2F9 00002EA0 JNZ SHORT telnet.0100A304
0100A2FB 00002EA0 TEST BYTE PTR DS:[1010740],10
[...]


В случае smtp.gmail.com

Address  Thread   Command                                   ; Registers and comments
0100AB9F 00002EA0 JNZ telnet.0100AED2
0100ABA5 00002EA0 TEST BYTE PTR SS:[EBP-24],3
0100ABA9 00002EA0 JE telnet.0100AED2
[...]
0100A2F7 000031D4 TEST EAX,EAX
0100A2F9 000031D4 JNZ SHORT telnet.0100A304
0100A304 000031D4 PUSH EDI                                  ; Arg4 = 01024CA0
[...]

В том случае, когда telnet.exe общается с bat.org, прыжок по адресу 0×0100A304 не осуществляется. Давайте сделаем из инструкции по адресу 0×0100A2F9 безусловный переход. Перезапускаем отладку, переходим в модуль «telnet», нажимаем Ctrl-G, вводим в появившееся окно адрес 0×0100A2F9 и нажимаем Enter. Теперь нажимаем пробел и заменяем инструкцию JNZ на JMP:

image

Нажимаем F9, вводим '1' в окно telnet’а на просьбу выбрать один из предложенных вариантов или ввести имя и… Видим введённый нами символ:

image

Если побегать в отладчике, можно увидеть, что теперь мы попадаем в ветку кода, где выполняются вызовы таких WinAPI-функций, как SetConsoleCursorPosition и WriteConsoleOutputCharacter:

image

Почему же тогда мы не попадали сюда раньше? Давайте посмотрим, от чего зависило принятие решения об осуществлении прыжка:

image

Зависило оно от результата выполнения операции TEST EAX, EAX, а в регистр EAX значение попадало из адреса 0×01010754, как видно на предыдущем скриншоте. Что ж, давайте попытаемся понять, почему оно было равно нулю в случае bat.org.

Для того, чтобы выяснить это, предлагаю поставить хардварный бряк на запись по адресу 0×01010754. Чтобы прыгнуть на него, нажимаем правой кнопкой мыши на инструкцию, находящуюся по адресу 0×0100A2BD → Follow in Dump → Memory address:

image

Жмём правой кнопкой мыши на первый байт по указанному адресу → Breakpoint → Hardware, on write → Dword. Перезапускаем отладку и находим последнее обращение к адресу 0×01010754, когда в него попадает ноль. Такое обращение находится тут:

image

Если мы посмотрим на Call Stack и прыгнем на процедуру, откуда нас сюда позвали, то мы увидим вызов функции recv с последующим разбором пришедших данных:

image

Обратите внимание на константу 0xFF. Согласно спецификации telnet’а, после этого байта следуют команды, использующиеся в данном протоколе:

   The following are the defined TELNET commands.  Note that these codes
   and code sequences have the indicated meaning only when immediately
   preceded by an IAC.

      NAME               CODE              MEANING

      SE                  240    End of subnegotiation parameters.
      NOP                 241    No operation.
      Data Mark           242    The data stream portion of a Synch.
                                 This should always be accompanied
                                 by a TCP Urgent notification.
      Break               243    NVT character BRK.
      Interrupt Process   244    The function IP.
      Abort output        245    The function AO.
      Are You There       246    The function AYT.
      Erase character     247    The function EC.
      Erase Line          248    The function EL.
      Go ahead            249    The GA signal.
      SB                  250    Indicates that what follows is
                                 subnegotiation of the indicated
                                 option.
      WILL (option code)  251    Indicates the desire to begin
                                 performing, or confirmation that
                                 you are now performing, the
                                 indicated option.
      WON'T (option code) 252    Indicates the refusal to perform,
                                 or continue performing, the
                                 indicated option.
      DO (option code)    253    Indicates the request that the
                                 other party perform, or
                                 confirmation that you are expecting
                                 the other party to perform, the
                                 indicated option.
      DON'T (option code) 254    Indicates the demand that the
                                 other party stop performing,
                                 or confirmation that you are no
                                 longer expecting the other party
                                 to perform, the indicated option.
      IAC                 255    Data Byte 255.

Посмотрев на стек, можно увидеть, что мы солкнулись с последовательностью байт 0xFF 0xF9, которая обозначает команду под названием «Go ahead». По поводу неё на сайте Microsoft сообщается следующее:

The original Telnet implementation defaulted to half duplex operation. This means that data traffic could only go in one direction at a time and specific action is required to indicate the end of traffic in one direction and that traffic may now start in the other direction. [This similar to the use of «roger» and «over» by amateur and CB radio operators.] The specific action is the inclusion of a GA character in the data stream


По какой-то причине в реализации telnet-клиента от Microsoft эта команда влияет на echo, так и не возвращая содержимое по адресу 0×01010754 в значение, отличное от нуля.

В этом можно убедиться, написав небольшой сервер на Python:

import socket, threading
 
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(('', 1900))
s.listen(1)
 
class daemon(threading.Thread):

    def __init__(self, (socket, address)):
        threading.Thread.__init__(self)
        self.socket = socket
        self.address = address
 
    def run(self):
        self.socket.send('Greetings!')
 
        while True:
 
            data = self.socket.recv(1024)
 
            if data[0] == '1':
                data = 'Response'
            elif data[0] == '2':
                data = bytearray()
                data.append(0xFF)
                data.append(0xF9)
 
            self.socket.send(data);
 
        self.socket.close()
 
while True:
    daemon(s.accept()).start()


Если запустить данный сервер и подключиться к нему при помощи команды «telnet.exe 127.0.0.1 1900», то мы сможем наблюдать, что echo будет работать ровно до тех пор, пока мы не получим ответ на нашу команду '2':

Greetings!1Response1Response1Response1Response2ResponseResponseResponseResponseResponseResponse


Но это ещё не всё! На самом деле, другие команды также обладают подобным поведением. Например, последовательность байт 0xFF 0xF1, обозначающая «No operation», абсолютно так же «отключает» echo в telnet-клиенте.

Баг? Фича? Кто его знает. Главное, что теперь мы научили наш telnet.exe правильной игре в MUD’ы!

Послесловие


Конечно, решение ещё не идеально. Например, при нажатии на клавишу Backspace символ, находящийся перед курсором, не удаляется (однако изменяется «внутреннее» представление введённой пользователем команды, как это и ожидается). Да, это всего лишь косметический момент и с ним можно смириться, но ведь именно с косметических неудобств мы и начали эту статью, верно?

Спасибо за внимание, и снова надеюсь, что статья оказалась кому-нибудь полезной.

© Habrahabr.ru