Учим telnet.exe правильной игре в MUD'ы
Как-то раз, решив сыграть во что-нибудь необычное, я обратил свой взгляд на 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»:
Да, ASLR включена. Давайте отключим её — заменяем 0×8140 на 0×8100 (почему именно так уже объяснялось ранее — смотрите, например, тут) и нажимаем на кнопку «Ok».
Итак, какие мысли? Первое, что пришло мне на ум — то, что приложение может явно «отключать» echo при помощи WinAPI-функции SetConsoleMode. Запускаем наш бинарник в OllyDbg, открываем окно со списком межмодульных вызовов и видим, что вызовы этой функции действительно присутствуют в приложении:
Ставим на них бряки, жмём F9 и останавливаемся на одной из точек останова:
Давайте посмотрим на аргументы в окне стека:
Читаем документацию:
ENABLE_ECHO_INPUT
0×0004Characters 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
Давайте перезапустим отладку, занопим её вызов
и проверим, работает ли echo теперь. Нет, результат тот же, что и раньше — вводимые символы не отображаются на экране консоли.
Ладно, давайте дождёмся момента, когда игра попросит ввести имя
, и нажмём F12 (Pause) в OllyDbg.
Предлагаю осмотреться, чтобы понять, где мы находимся в текущий момент. Для начала открываем Call Stack по нажатию Alt-K:
Итак, мы висим где-то в недрах user32.dll. Прыгаем на ближайший «пользовательский» код (т.е. код, принадлежащий модулю telnet), находящийся по адресу 0×0100D0D0, откуда мы и попали в user32.dll:
Опытному разработчику под Windows уже должно быть понятно, адрес какой функции лежит, вероятнее всего, в регистре EDI на момент выполнения выделенной инструкции — GetMessage. Но давайте убедимся в этом лично. Ставим бряк по данному адресу, перезапускаем отладку и жмём F9 до попадания на требуемое место:
Как видите, это действительно GetMessage. Проблема в нашем случае заключается в том, что эта функция не вернёт управление вызвавшему её коду до нажатия клавиши Enter, что означает то, что она не имеет к echo ровным счётом никакого отношения.
Тогда давайте взглянем, чем в этот момент занимаются остальные потоки (если они, конечно, вообще есть). Снова запускаем программу на дальнейшее выполнение при помощи F9, нажимаем F12 и открываем окно «Threads» (View → Threads):
Открываем каждый из них в окне CPU (right-click по соответствующей строчке в окне Threads → Open in CPU), за исключением выделенного красным цветом (это текущий поток, который мы только что рассмотрели), и смотрим на их Call Stack’и. Ваше внимание должен был привлечь поток со следующим стеком вызовов:
ReadConsoleInput — это уже более интересная функция в нашем случае. Ставим бряк на её вызов, перезапускаем отладку и… Останавливаемся на нём каждый раз, как передаём фокус окну telnet’а:
Обратите внимание, что рядом находится switch, в котором, вероятнее всего, выполняется прыжок на обработчик соответствующего события. Побегав в отладчике, можно узнать, что в случае смены фокуса управление передаётся default case’у:
Судя по анализу кода, проведённому OllyDbg’ом, вариантов тут не так уж и много — помимо default case’а, у нас также имеются case’ы 10 и 1, первый из которых после выполнения нескольких инструкций осуществляет прыжок на только что рассмотренный default case. Давайте попробуем убрать бряк с вызова функции ReadConsoleInput и поставить бряк на case 1:
Перезапускаем отладку, дожидаемся сообщения с просьбой ввести имя, нажимаем '1' и останавливемся на этом самом case-блоке:
Что мы можем сделать теперь? А теперь можно сверить работу 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:
Нажимаем F9, вводим '1' в окно telnet’а на просьбу выбрать один из предложенных вариантов или ввести имя и… Видим введённый нами символ:
Если побегать в отладчике, можно увидеть, что теперь мы попадаем в ветку кода, где выполняются вызовы таких WinAPI-функций, как SetConsoleCursorPosition и WriteConsoleOutputCharacter:
Почему же тогда мы не попадали сюда раньше? Давайте посмотрим, от чего зависило принятие решения об осуществлении прыжка:
Зависило оно от результата выполнения операции TEST EAX, EAX, а в регистр EAX значение попадало из адреса 0×01010754, как видно на предыдущем скриншоте. Что ж, давайте попытаемся понять, почему оно было равно нулю в случае bat.org.
Для того, чтобы выяснить это, предлагаю поставить хардварный бряк на запись по адресу 0×01010754. Чтобы прыгнуть на него, нажимаем правой кнопкой мыши на инструкцию, находящуюся по адресу 0×0100A2BD → Follow in Dump → Memory address:
Жмём правой кнопкой мыши на первый байт по указанному адресу → Breakpoint → Hardware, on write → Dword. Перезапускаем отладку и находим последнее обращение к адресу 0×01010754, когда в него попадает ноль. Такое обращение находится тут:
Если мы посмотрим на Call Stack и прыгнем на процедуру, откуда нас сюда позвали, то мы увидим вызов функции recv с последующим разбором пришедших данных:
Обратите внимание на константу 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 символ, находящийся перед курсором, не удаляется (однако изменяется «внутреннее» представление введённой пользователем команды, как это и ожидается). Да, это всего лишь косметический момент и с ним можно смириться, но ведь именно с косметических неудобств мы и начали эту статью, верно?
Спасибо за внимание, и снова надеюсь, что статья оказалась кому-нибудь полезной.