Грязные решения в реверс-инжиниринге
Перед разработчиками довольно часто встаёт выбор — сделать всё правильно, потратив на решение задачи уйму времени, или сделать так, чтобы работало, не особо вдаваясь в детали того, как именно это получилось. Со стороны заказчика, разумеется, наиболее привлекательной является некая золотая середина, которая в данном случае заключается одновременно и в хорошем понимании программистом выполненного таска, и в как можно меньшем количестве затраченных на него человеко-часов. С разработчиками тоже не всё так однозначно — с одной стороны, понимать, что происходит в своём собственном коде, это вполне естесственное желание (особенно если поддержка данного продукта также будет лежать на его плечах), а с другой стороны, если результаты работы приложения представлены в наглядном виде (графики / звуковые или видео-фрагменты etc), разработка разовая, и отдел тестирования говорит, что всё хорошо, то почему бы не проскроллить оставшуюся часть рабочего времени Хабр, посвятив время себе любимому?
Ближе к делу. В одной из предыдущих статей я уже упоминал о программе под названием «Говорилка». Несмотря на название, сама по себе она ничего не озвучивает, а лишь является связующим звеном между пользователем и речевыми движками, предоставляя более удобный интерфейс и возможность конфигурации. Одним из наиболее популярных в узких кругах движков является «Digalo 2000 text-to-speech engine» (далее — Digalo), ссылку на который можно найти как раз на сайте «Говорилки». Как вы уже, наверное, догадались из тематик моих предыдущих статей, не всё с ним так хорошо, и без багов тут также не обошлось. На этот раз проблема проявилась при озвучивании текста «ааааааааааааааааааааааааааааааааааааааааааааааааааааааааааааааааааааааа». Немного поэкспериментировав, я обнаружил, что при достижении определённого количества «неразрывных» символов Digalo начинает крашиться, предлагая отладить свой процесс. Ну, а что, почему бы и нет?
Как протекал процесс, и что из этого вышло, читайте под катом (перед прочтением данной статьи я настоятельно рекомендую ознакомиться с предыдущими, которые можно найти, например, тут).
Я думаю, излишне говорить, что исходников вместе с Digalo не поставляется (более того, скачать с официального сайта даже бинарники интересующей меня версии уже нельзя), так что нашими лучшими друзьями снова станут дизассемблер, отладчик и hex-редактор, которые, по сути, можно свести к одной лишь OllyDbg. Но прежде чем браться за изучение дизассемблировааного листинга, да-да, надо скачать приложение и проверить, не накрыто ли оно протектором и не запаковано ли каким-нибудь паковщиком.
Скачиваем и устанавливаем Digalo, залезаем в директорию, куда он установился, и исследуем исполняемый файл в DiE и PEiD:
Несложно заметить, что оба анализатора решили, что DIGALO_RUS.exe запакован PECompact’ом, и, в принципе, у нас нет особых причин, чтобы им не верить.
Несмотря на то, что PECompact и ASPack (о котором уже шла речь в одной из предыдущих статей) — совершенно разные паковщики, принцип распаковки для них один и тот же. Загружаем DIGALO_RUS.exe в OllyDbg, добегаем до инструкции PUSHFD, которая выполняется сразу же после первого JMP’а, открываем Command Line при помощи Alt-F1, ставим хардварный бряк на ESP-4 при помощи команды hr esp-4, нажимаем F9 до тех пор, пока не окажемся на месте после выполнения инструкции POPFD, добегаем до ближайшего RETN’а, нажимаем F8 и оказываемся по адресу 0×0045B97B, который в данном случае и является OEP:
Снимаем дамп при помощи плагина OllyDump, оставляя галочку на checkbox’е «Rebuild Import», проверяем работоспособность исследуемого приложения после распаковки и… Видим, что оно работает (разумеется, на тех строках, которые оно корректно обрабатывало и раньше).
Теперь перед нами встаёт важный вопрос — как же можно отладить этот речевой движок? Проблема заключается в том, что падает он практически сразу после старта, отрезая возможность аттача к уже запущенному процессу. Что ж, тут есть небольшая хитрость — мы можем поменять первый байт, находящийся на OEP, на инструкцию INT3, которая в данном случае (из-за отсутствия подключенного к процессу отладчика) заставит ОС показать стандартное диалоговое окно с предложением отладить процесс в текущем JIT-отладчике. Делаем OllyDbg таковым (Options → Just-in-time debugging → Make OllyDbg just-in-time-debugger) и заменяем первый байт на OEP с 0×55 (PUSH EBP) на 0xCC (INT3):
Сохраняем изменения (right-click по окну CPU → Copy to executable → All modifications → Copy all → right-click по открывшемуся окну → Save file), заменяем оригинальный исполняемый файл и запускаем консольную версию «Говорилки» с аргументом «ааааааааааааааааааааааааааааааааааааааааааааааааааааааааааааааааааааааа»:
Нажимаем кнопку «Debug the program», заменяем INT3 обратно на PUSH EBP, нажимаем F9 и видим, что мы имеем дело с Access Violation:
Запускаем приложение ещё раз, ставим бряк по адресу, где происходит Access Violation (в моём случае это 0×00428B9D), и пытаемся выяснить, как часто вызывается это место перед падением. Оказывается, что бряк срабатывает два раза перед тем, как упасть (после первого всё нормально, а вот в момент срабатывания второго значение регистра ECX как раз содержит адрес, обращение к которому и вызывает данное исключение). Давайте запустим трассировку с этого места после первого срабатывания брейкпоинта и посмотрим, что окажется в окне «Run trace» в случае успешной работы приложения (например, при запуске говорилки с аргументом «Hello») и в случае падения:
В случае падения
Address Thread Command ; Registers and comments
Flushing gathered information
00428B9D 00002410 MOV EDX,DWORD PTR DS:[ECX+64] ; EDX=0051A820
00428BA0 00002410 LEA EAX,DWORD PTR DS:[EAX+EAX*2] ; EAX=00000003
00428BA3 00002410 MOV CL,BYTE PTR DS:[ESI] ; ECX=03007BA0
[...]
004303FB 00002410 CMP EDX,ECX
004303FD 00002410 JL digalo_r.00430282
00430282 00002410 MOV EDI,1 ; EDI=00000001
00430287 00002410 MOV EAX,DWORD PTR SS:[ESP+1C] ; EAX=00000028
0043028B 00002410 MOV EDX,DWORD PTR DS:[4A8BDC] ; EDX=004A8DC8
00430291 00002410 MOVSX ECX,BYTE PTR SS:[ESP+EAX+113] ; ECX=00000074
В случае корректной работы
Address Thread Command ; Registers and comments
Flushing gathered information
00428B9D 000024D8 MOV EDX,DWORD PTR DS:[ECX+64] ; EDX=0059A880
00428BA0 000024D8 LEA EAX,DWORD PTR DS:[EAX+EAX*2] ; EAX=00000003
00428BA3 000024D8 MOV CL,BYTE PTR DS:[ESI] ; ECX=02F37BE5
[...]
004303FB 000024D8 CMP EDX,ECX
004303FD 000024D8 JL digalo_r.00430282
00430403 000024D8 MOV EDI,DWORD PTR SS:[ESP+28] ; EDI=00000001
00430407 000024D8 MOV ESI,DWORD PTR SS:[ESP+244] ; ESI=02F37B20
0043040E 000024D8 LEA EDX,DWORD PTR SS:[ESP+114] ; EDX=024CF348
Если посмотреть на вывод с конца, то мы увидим, что различие заключается как минимум в том, что в случае падения срабатывает условный переход по адресу 0×00430282, чего не происходит в случае корректной работы.
Что ж, давайте попробуем занопить инструкцию условного перехода по этому адресу и посмотреть, что получится. Да, Digalo теперь действительно произносит это самое протяжное «а»! Но появилась другая проблема — после зачитывания текста движок снова падает с Access Violation, но уже в совершенно другом месте:
Уже по адресам вам должно быть заметно, что на этот раз речь идёт о недрах системных библиотек. Взглянем на call stack при помощи Alt-K и узнаем, что падение произошло внутри WinAPI-функции HeapFree:
Разумеется, с 99% вероятностью никакого бага в kernel32.dll мы не обнаружили, а всего лишь передали неверные параметры. Если поставить бряки на вызовах HeapFree, то мы увидим, что во всех остальных случаях аргумент, передаваемый в качестве параметра pMemory, содержит адрес, значительно отличающийся от того, который был передан в момент падения приложения:
Подозрительно, не правда ли? Но что мы можем сделать? Варианта два — либо долго и нудно изучать причину попадания сюда данного адреса, либо просто забить на освобождение памяти. Большинство из вас, наверное, уже начинают покрывать меня нецензурными выражениями, но, если задуматься, в этом может не быть практически ничего ужасного. Да-да, вы не ослышались. Разумеется, я согласен с тем, что убирать все вызовы HeapFree из кода — это, мягко говоря, неправильно, ведь в процессе работы приложение может выделить безумное количество памяти (например, при чтении длинного текста или чего-нибудь подобного), неосвобождение которой может привести уже к новым проблемам. Однако что плохого в том, что мы уберём освобождение памяти при завершении работы приложения? Т.к. речь идёт только о Windows, ОС всё равно освободит ресурсы (для каких-то платформ и систем это могло бы оказаться критическим, согласен).
Давайте посмотрим по call stack’у, как мы сюда добрались. Что ж, запустим приложение ещё раз и поставим бряки по адресам 0×0045A2B3 и 0×0041136C. Бряк по первому адресу срабатывает много раз, что говорит нам о том, что эта функция, вероятнее всего, является враппером над HeapFree и служит для общего освобождения указанной памяти, а вот бряк по второму адресу срабатывает только после прочтения речевым движком переданного ему текста, что, вероятнее всего, означает, что данная процедура вызывается только при завершении работы приложения:
Занопим вызов процедуры 0×0045A273, находящийся по адресу 0×0041136C, и проверим, исправило ли это нашу проблему. Да, проблема исправлена — движок произносит указанную фразу и корректно завершается:
Т.к. моей целью являлось получение возможности произнесения конкретного протяжного звука «а» при помощи речевого движка Digalo, то, можно сказать, на этом задача была завершена. Да, мы не углубились в выяснение причин падения приложения при вызове функции HeapFree, а также не до конца поняли, можно ли просто занопить условный переход для того, чтобы избежать изначальной проблемы, но, в конце-концов, зачем тратить на решение подобной задачи слишком много времени? Звук произнесли? Произнесли. Для остальных фраз и звуков можно продолжать пользоваться оригинальной версией исполняемого файла Digalo, чтобы не переживать, что мы своими действиями добавили каких-то непредвиденных последствий.
Послесловие
Своей статьёй я хотел показать, что добиться своего прямо здесь и сейчас для вас может быть не так сложно, как вы об этом, возможно, думаете. Если какая-то программа отказывается сохранять результаты вашей работы или, например, не делает в определённых ситуациях то, чего от неё ожидают, решить этот вопрос вы вполне можете и самостоятельно, не дожидаясь ответа от тех. поддержки используемого приложения (которой, кстати, может и вообще не быть). Хорошо это или плохо, решайте сами. В конце-концов, это вообще довольно странный вопрос для реверс-инжиниринга, верно?
Спасибо за внимание, и снова надеюсь, что статья оказалась кому-нибудь полезной.