Ищем коды уровней в Prehistorik-2

в 11:50, , рубрики: game development, old school, reverse engineering, История ИТ, Обратный инжиниринг, старые игры, метки: , , ,

Ищем коды уровней в Prehistorik 2
В игре Prehistorik 2 не предусмотрены сейвы, но на каждом уровне есть (болтается в воздухе в некотором месте уровня) код уровня. Есть два режима прохождения, Beginner и Expert, код также определяет режим. При старте игры можно начать с первого уровня, а можно ввести код и попасть сразу на соответствующий уровень. На одном и том же компьютере с неизменным окружением коды не меняются, но на разных компьютерах коды, вообще говоря, разные, так что коды, найденные при прохождении и тщательно выписанные на бумажку, станут совершенно бесполезны в другом окружении. Поэтому вместо бумажки лучше иметь программу, которая пишет коды для конкретного окружения. Готовый результат: genpass.com, представляет из себя DOS-программу, которая должна запускаться в том же окружении, что и игра. Альтернативный вариант попасть на нужный уровень из экрана ввода кода: ввести три кода ADDE C0DE F00D либо DEAD C0DE F00D, каждый из трёх кодов сам по себе неверен, но при вводе их в таком порядке четвёртый код — номер уровня от 1 до 10, плюс 10 для режима Expert, приводит сразу на запрошенный уровень.

Под катом — процесс исследования. Требуется знание ассемблера x86 хотя бы на уровне «читаю со словарём».

Инструменты

Главный инструмент — дизассемблер IDA Pro. Для исследования DOS-программ вполне хватит бесплатной версии 5.0.

Много разных DOS-утилит есть здесь: http://www.exetools.com/, некоторые из них будут упомянуты далее в тексте.

Необязательный инструмент — отладчик. Можно использовать какой-нибудь из DOSовских отладчиков (выбор есть по ссылке выше), их общий недостаток — разделение ресурсов (как минимум, экрана) с отлаживаемой программой. Отдать все ресурсы программе, но при этом отлаживать её позволяет эмулятор Bochs со встроенным отладчиком. Кроме того, можно обойтись и без отладчика.

Также не помешает hex-редактор. Из открытых, видимо, лучший — beye (бывший biew). Но можно обойтись и hexdump для просмотра и echo -ne 'x..x..' | dd of=<filename> seek=<offset> bs=1 conv=notrunc для изменения (без conv=notrunc для уменьшения размера).

Распаковка

Будем анализировать версию, которую можно скачать с old-games.ru: pre2orig.exe размером 63728 байт. Над ней потрудилась (сняв защиту от копирования оригинальной версии) группа Hybrid, о чём игра настойчиво сообщает при старте.

В exe'шнике не обнаруживается связных текстовых строк, что наводит на мысли об упакованности файла: exe'шник на диске сжат с добавлением кода распаковщика, при загрузке сначала выполняется распаковщик, который распаковывает данные в памяти и передаёт управление на распакованный код. Естественно, перед исследованием кода придётся снять этот слой. File Scanner отсюда успешно идентифицирует упаковщик как LZEXE, для которого есть автоматический распаковщик unlzexe. Запустив unlzexe, получаем pre2_1.exe размером 63728 байт.

В pre2_1.exe осмысленные текстовые строки есть, первая из них «INTRO CODED BY CYBER». Смотрим внутрь:

seg002:5E48 start           proc near
seg002:5E48                 mov     cs:pspSeg, ds
seg002:5E4D                 mov     ax, cs
seg002:5E4F                 mov     es, ax
seg002:5E51                 assume es:seg002
seg002:5E51                 mov     cx, 20h
seg002:5E54                 mov     si, 80h
seg002:5E57                 mov     di, si
seg002:5E59                 rep movsb
seg002:5E5B                 mov     ds, ax
seg002:5E5D                 assume ds:seg002
seg002:5E5D                 call    loc_19960
seg002:5E60                 mov     ax, pspSeg
seg002:5E63                 add     ax, 10h
seg002:5E66                 add     nextCS, ax
seg002:5E6A                 add     nextSS, ax
seg002:5E6E                 sub     ax, 10h
seg002:5E71                 mov     ds, ax
seg002:5E73                 assume ds:nothing
seg002:5E73                 mov     es, ax
seg002:5E75                 assume es:nothing
seg002:5E75                 mov     ax, cs:nextSS
seg002:5E79                 mov     ss, ax
seg002:5E7B                 assume ss:nothing
seg002:5E7B                 mov     sp, cs:nextSP
seg002:5E80                 mov     bx, offset nextIP
seg002:5E83                 jmp     dword ptr cs:[bx]
seg002:5E83 start           endp
seg002:5E83
seg002:5E83 ; ---------------------------------------------------------------------------
seg002:5E86 nextIP          dw 3                    ; DATA XREF: start+38o
seg002:5E88 nextCS          dw 0                    ; DATA XREF: start+1Ew
seg002:5E8A pspSeg          dw 0                    ; DATA XREF: startw
seg002:5E8A                                         ; start+18r
seg002:5E8C nextSS          dw 1681h                ; DATA XREF: start+22w
seg002:5E8C                                         ; start+2Dr
seg002:5E8E nextSP          dw 80h                  ; DATA XREF: start+33r

DOS создаёт в памяти перед программой область PSP размером 100h байт, самая интересная часть которой — командная строка по смещению 80h (в Pascal-стиле: первый байт — длина). Регистры ds и es при запуске программы указывают на сегмент PSP, приложение начинается с адреса ds:0+100h = (ds+10h):0. Первая команда сохраняет ds, следующие 7 (не считая assume — это не команды, а директивы ассемблера) копируют командную строку и настраивают ds, es, затем следует вызов какой-то процедуры loc_19960. После завершения процедуры следует последовательность действий, типичная для последней части кода, «навешенного» поверх готового бинарника, эмулирующая последние действия DOS перед началом выполнения программы: ds и es устанавливаются в сегмент PSP, ss:sp устанавливаются в значение, находящееся по фиксированному смещению относительно начала программы (сегмент pspSeg+10h), происходит прыжок на адрес, находящийся по фиксированному смещению относительно начала программы. Процедура loc_19960 начинает с трёх проверок, при нарушении которых немедленно выходит; о смысле проверок лучше всего говорят текстовые сообщения «This program requires an 80286 or higher», «This program requires a VGA card», «Intro skipped». Фрагмент кода последней проверки:

seg002:082F                 cmp     al, '/'
seg002:0831                 jnz     short loc_1A08A
seg002:0833                 lodsb
seg002:0834                 and     al, not 20h
seg002:0836                 cmp     al, 'N'
seg002:0838                 jnz     short loc_1A08B
seg002:083A                 lodsb
seg002:083B                 and     al, not 20h
seg002:083D                 cmp     al, 'I'
seg002:083F                 jnz     short loc_1A08B

То есть, остаток процедуры loc_19960 не вызывается, если передать в командной строке параметр /ni. Пробуем, видим, что с этим параметром игра пропускает напоминание о доблестной команде Hybrid и сразу переходит к собственно игре. Делаем вывод, что весь seg002 — ненужная для игры заставка, навешенная на рабочий exe'шник. Переписываем в заголовке cs:ip на 0000:0003, ss:sp на 1681:0080, обрезаем последние 5E90h байт, соответственно скорректировав поля размера и дополнительной памяти в заголовке.

Получаем файл pre2_2.exe размером 39520 байт. В нём снова нет связных текстовых строк (все строки из pre2_1.exe оказались связаны с заставкой Hybrid), что снова наводит на мысли об упакованности. Тот же File Scanner показывает «Diet 1.00,1.20» в качестве упаковщика. Этот факт примечателен тем, что diet может работать в двух режимах: упаковка бинарников и упаковка данных. Попробовав распаковать файлы уровней (как самые интересные данные из всех) с помощью diet, убеждаемся, что, действительно, файлы уровней тоже упакованы diet, и, если нас заинтересует структура файлов уровней, один слой (упаковку) мы уже знаем.

Для снятия diet прибегнем к универсальному распаковщику cup386. diet не содержит антиотладочных приёмов, запуск cup386 pre2_2.exe pre2unp.exe /1 приводит к распакованному файлу pre2unp.exe размером 91344 байт. Поиск текстовых строк показывает, что pre2unp.exe — то, что нам нужно.

Поиск кодов

Ищем коды уровней в Prehistorik 2
Прежде всего нужно найти место проверки и генерации кодов. Вариантов несколько. Можно прийти дизассемблером в точку входа программы и начать изучать всё подряд (если вы никуда не торопитесь; самый медленный способ, зато по дороге можно узнать много интересного). Можно зайти отладчиком, пройтись по коду без захода внутрь вызываемых процедур, отмечая, какие визуальные эффекты происходят во время выполнения процедур. Можно запустить под отладчиком, дойти до экрана выбора кода и прервать выполнение, посмотрев, где мы находимся (и заглянув в стек, чтобы выяснить, кто вызвал процедуру, где мы находимся). В данном случае самый простой способ — привязаться к текстовой строке «ENTER CODE», наличествующей на экране ввода кода. Поиск находит такую строку по адресу seg002:B171 (IDA любезно определила сегменты ещё при загрузке exe'шника), поиск значения 0B171h выводит на такую процедуру:

seg001:99C3 sub_19BC3       proc near               ; DATA XREF: sub_1906F+A1o
seg001:99C3                 call    sub_19D1E
seg001:99C6                 mov     byte_251D7, 3
seg001:99CB                 mov     bx, offset aEnterCode ; "ENTER CODE"
seg001:99CE                 mov     word_251D2, 0AF2h
seg001:99D4                 call    sub_19AC4
seg001:99D7                 mov     byte_251D7, 4
seg001:99DC                 mov     bx, offset levelCode ; "[[[["
seg001:99DF                 mov     word_251D2, 12C9h
seg001:99E5                 call    sub_19AC4

Первую команду вызова sub_19D1E проигнорируем. Дальше следует два вызова одной и той же процедуры sub_19AC4, первый раз для строки «ENTER CODE», второй раз для строки из 4 символов, изменяемой далее в ходе процедуры; логично считать, что это вывод строки на экран, а вторая строка — вводимый код.

seg001:99E8                 cmp     byte_251D6, 1
seg001:99ED                 jnz     short loc_19BF2
seg001:99EF                 jmp     loc_19CF2
seg001:99F2 ; ---------------------------------------------------------------------------
seg001:99F2
seg001:99F2 loc_19BF2:                              ; CODE XREF: sub_19BC3+2Aj
seg001:99F2                 cmp     byte_251D6, 2
seg001:99F7                 jnz     short loc_19BFC
seg001:99F9                 jmp     loc_19D02
seg001:99FC ; ---------------------------------------------------------------------------
...
seg001:9AF2 ; ---------------------------------------------------------------------------
seg001:9AF2
seg001:9AF2 loc_19CF2:                              ; CODE XREF: sub_19BC3+2Cj
seg001:9AF2                 inc     word_251DC
seg001:9AF6                 mov     bx, word_251DC
seg001:9AFA                 sub     bx, 8Ch
seg001:9AFE                 jb      short locret_19D1D
seg001:9B00                 jmp     short loc_19D06
seg001:9B02 ; ---------------------------------------------------------------------------
seg001:9B02
seg001:9B02 loc_19D02:                              ; CODE XREF: sub_19BC3+36j
seg001:9B02                                         ; sub_19BC3+4Ej
seg001:9B02                 inc     byte_251DE
seg001:9B06
seg001:9B06 loc_19D06:                              ; CODE XREF: sub_19BC3+13Dj
seg001:9B06                 mov     bx, offset levelCode ; "[[[["
seg001:9B09                 mov     word ptr [bx], '[['
seg001:9B0D                 mov     word ptr [bx+2], '[['
seg001:9B12                 mov     byte_251D6, 0
seg001:9B17                 mov     word_251D4, 0
seg001:9B1D
seg001:9B1D locret_19D1D:                           ; CODE XREF: sub_19BC3+42j
seg001:9B1D                                         ; sub_19BC3+60j ...
seg001:9B1D                 retn
seg001:9B1D sub_19BC3       endp

В режиме byte_251D6 == 1 увеличивается счётчик word_251DC, пока не достигнет значения 8Ch, после чего byte_251D6 сбрасывается в 0, а введённый код очищается. Это как раз подходит под то, что после введения неправильного кода в игре он некоторое время остаётся на экране, после чего сбрасывается сам собой. В режиме byte_251D6 == 2 происходит то же самое, но без паузы; это нам неинтересно.

seg001:99FC
seg001:99FC loc_19BFC:                              ; CODE XREF: sub_19BC3+34j
seg001:99FC                 mov     bl, ScanCode
seg001:9A00                 test    bl, 80h
seg001:9A03                 jz      short loc_19C08
seg001:9A05                 jmp     locret_19D1D
seg001:9A08 ; ---------------------------------------------------------------------------
seg001:9A08
seg001:9A08 loc_19C08:                              ; CODE XREF: sub_19BC3+40j
seg001:9A08                 mov     al, KeysPressed+39h
seg001:9A0B                 or      al, KeysPressed+1Ch
seg001:9A0F                 jz      short loc_19C14
seg001:9A11                 jmp     loc_19D02
seg001:9A14 ; ---------------------------------------------------------------------------
seg001:9A14

Дальше читается переменная по адресу seg002:2870. Изучение ссылок на неё (найденных IDA) показывает, что ненулевое значение записывается только в одном месте, где оно берётся чтением из порта клавиатуры 60h. Делаем вывод, что эта переменная — последний обработанный сканкод клавиатуры, а записывающая процедура — обработчик прерывания клавиатуры; переименовываем переменную в ScanCode. Прокрутив обработчик прерывания до конца, понимаем значение ещё двух используемых переменных: массив KeyPressed (обновляемый в обработчике клавиатуры) содержит статусы кнопок (0 = кнопка не нажата, 0FFh = кнопка нажата). Возвращаемся в исследуемую процедуру: KeyPressed[39h] и KeyPressed[1Ch] — статусы пробела и Enter соответственно. При нажатии пробела или Enter (как показывает тест) игра возвращается из экрана ввода кода в экран с меню, это не то, что нам надо.

seg001:9A14 loc_19C14:                              ; CODE XREF: sub_19BC3+4Cj
seg001:9A14                 mov     ScanCode, 0
seg001:9A19                 xor     bh, bh
seg001:9A1B                 mov     al, byte ptr a1234567890AEADfCB[bx] ; "--1234567890----A-E-----------A-DF-----"...
seg001:9A1F                 cmp     al, '-'
seg001:9A21                 jnz     short loc_19C26
seg001:9A23                 jmp     locret_19D1D
seg001:9A26 ; ---------------------------------------------------------------------------
seg001:9A26
seg001:9A26 loc_19C26:                              ; CODE XREF: sub_19BC3+5Ej
seg001:9A26                 mov     ah, al
seg001:9A28                 sub     ah, '0'
seg001:9A2B                 cmp     ah, 9
seg001:9A2E                 jbe     short loc_19C33
seg001:9A30                 sub     ah, 7
seg001:9A33
seg001:9A33 loc_19C33:                              ; CODE XREF: sub_19BC3+6Bj

Здесь начинаются интересные действия. Сканкод подставляется как индекс в строку "--1234567890----A-E-----------A-DF------------C-B-...-", это преобразование в ASCII-код (заглянув в список сканкодов, обнаруживаем, что ASCII-код 'A' получается не только от клавиши A, но и от клавиши Q — надо полагать, чтобы игра работала также с раскладкой AZERTY). Если ASCII-код не является hex-цифрой, процедура игнорирует клавишу и выходит. Если является, то происходит преобразование в число от 0 до 0Fh.

seg001:9A33                 mov     dx, word_251E5
seg001:9A37                 mov     cl, 4
seg001:9A39                 shl     ah, cl
seg001:9A3B                 shl     ah, 1
seg001:9A3D                 rcl     dx, 1
seg001:9A3F                 shl     ah, 1
seg001:9A41                 rcl     dx, 1
seg001:9A43                 shl     ah, 1
seg001:9A45                 rcl     dx, 1
seg001:9A47                 shl     ah, 1
seg001:9A49                 rcl     dx, 1
seg001:9A4B                 mov     word_251E5, dx
seg001:9A4F                 mov     bx, word_251D4
seg001:9A53                 mov     byte ptr levelCode[bx], al ; "[[[["
seg001:9A57                 inc     word_251D4
seg001:9A5B                 inc     bl
seg001:9A5D                 cmp     bl, 4
seg001:9A60                 jnb     short loc_19C65
seg001:9A62                 jmp     locret_19D1D
seg001:9A65 ; ---------------------------------------------------------------------------

Переменная word_251E5 сдвигается на 4 бита влево, младшие биты заполняются числом, соответствующим введённому символу. Сам символ дописывается в строку со введённым кодом, число введённых символов word_251D4 увеличивается на единицу. Если ещё нет 4 символов, на этом обработка заканчивается. После 4 символов word_251E5 содержит 16-битное число, хранящее введённый код («1234» -> 1234h), дальше следует ожидать проверок этого числа.

seg001:9A65
seg001:9A65 loc_19C65:                              ; CODE XREF: sub_19BC3+9Dj
seg001:9A65                 push    bp
seg001:9A66                 mov     word_251DC, 0
seg001:9A6C                 mov     di, word_251DF
seg001:9A70                 mov     si, word_251E1
seg001:9A74                 mov     bp, word_251E3
seg001:9A78                 mov     ax, di
seg001:9A7A                 rol     ax, 1
seg001:9A7C                 rol     ax, 1
seg001:9A7E                 mul     ah
seg001:9A80                 xor     ax, si
seg001:9A82                 mul     bp
seg001:9A84                 mov     bx, ax
seg001:9A86                 add     bx, si
seg001:9A88                 ror     bx, 1
seg001:9A8A                 rol     bl, 1
seg001:9A8C                 xor     bl, bh
seg001:9A8E                 xor     bx, bp
seg001:9A90                 pop     bp
seg001:9A91                 cmp     ax, 36C8h
seg001:9A94                 jnz     short loc_19CAC
seg001:9A96                 cmp     bx, 8BD1h
seg001:9A9A                 jnz     short loc_19CAC
seg001:9A9C                 cmp     dx, 8E71h
seg001:9AA0                 jnz     short loc_19CAC
seg001:9AA2                 mov     dx, word_251E5
seg001:9AA6                 dec     dx
seg001:9AA7                 cmp     dx, 14h
seg001:9AAA                 jbe     short loc_19CD9
seg001:9AAC
seg001:9AAC loc_19CAC:                              ; CODE XREF: sub_19BC3+D1j
seg001:9AAC                                         ; sub_19BC3+D7j ...

Обнуление счётчика word_251DC и первая проверка. Если некоторые преобразования над тремя переменными, лежащими в памяти непосредственно перед word_251E5, приводят к некоторым фиксированным значениям и введённый код находится в диапазоне от 1 до 15h включительно, происходит переход на метку loc_19CD9 с dx, равным введённому коду минус единица. Отложим пока выяснение, что же это за переменные.

seg001:9AAC                 xor     dx, dx
seg001:9AAE
seg001:9AAE loc_19CAE:                              ; CODE XREF: sub_19BC3+FAj
seg001:9AAE                 mov     ax, dx
seg001:9AB0                 call    sub_19559
seg001:9AB3                 cmp     word_251E5, ax
seg001:9AB7                 jz      short loc_19CD9
seg001:9AB9                 inc     dx
seg001:9ABA                 cmp     dx, 14h
seg001:9ABD                 jbe     short loc_19CAE
seg001:9ABF                 mov     si, offset word_251E1
seg001:9AC2                 mov     ax, [si]
seg001:9AC4                 mov     [si-2], ax
seg001:9AC7                 mov     ax, [si+2]
seg001:9ACA                 mov     [si], ax
seg001:9ACC                 mov     ax, [si+4]
seg001:9ACF                 mov     [si+2], ax
seg001:9AD2                 mov     byte_251D6, 1
seg001:9AD7                 jmp     short locret_19D1D
seg001:9AD9 ; ---------------------------------------------------------------------------

Основная проверка. word_251E5 последовательно сравнивается с результатом работы функции sub_19559, вызываемой последовательно с dx от 0 до 14h. При равенстве происходит переход на метку loc_19CD9 с dx, при котором sub_19559 возвращает введённый код. Ясно, что sub_19559 возвращает код заданного уровня.

Если совпадения нет, то byte_251D6 устанавливается в 1 (как мы уже видели, это соответствует неверному коду), введённый код и две предшествующих ему переменных перемещаются на одну переменную назад. После трёх неправильных попыток три переменные, предшествующие введённому коду, будут хранить предыдущие варианты; теперь понятно, откуда берутся исходные данные для первой проверки.

seg001:9AD9
seg001:9AD9 loc_19CD9:                              ; CODE XREF: sub_19BC3+E7j
seg001:9AD9                                         ; sub_19BC3+F4j
seg001:9AD9                 mov     al, 0
seg001:9ADB                 cmp     dl, 0Ah
seg001:9ADE                 jb      short loc_19CE4
seg001:9AE0                 sub     dl, 0Ah
seg001:9AE3                 inc     ax
seg001:9AE4
seg001:9AE4 loc_19CE4:                              ; CODE XREF: sub_19BC3+11Bj
seg001:9AE4                 mov     byte_1CDB6, dl
seg001:9AE8                 mov     byte_251C3, al
seg001:9AEB                 mov     byte_251D6, 2
seg001:9AF0                 jmp     short locret_19D1D

Завершение процедуры при правильном коде: если dx меньше 10, оно записывается как есть в byte_1CDB6, а byte_251C3 становится нулём; иначе в byte_1CDB6 записывается dx-10, а byte_251C3 становится единицей. В любом случае byte_251D6 становится равным 2, что означает конец процедуры.

Остаётся сделать генератор кодов, действующий так же, как sub_19599, и/или выяснить, как пройти первую проверку. Кратко о вычислении кодов: сначала вычисляется переменная word_2435F на основе последних 10h байт BIOS F000:FFF0 (включающих дату BIOS) и первых 80h байт всех расширений BIOS в интервале от C000:0000 до F000:0000. Собственно код уровня вычисляется следующим образом:

; ax = zero-based level
seg001:93C6                 xor     ax, 55A3h
seg001:93C9                 mul     word_2435F
seg001:93CD                 mov     cl, cs:ProcessorType ; always 3 for 386+
seg001:93D2                 rol     ax, cl

Процедура sub_19599 достаточно независима от окружения (ей требуется только тип процессора — для 386+ он всегда равен 3), её можно просто скопировать из дизассемблированного кода, получив примерно такой генератор кодов: genpass.asm, genpass.com.

Немного сложнее выяснить «магические» значения для первой проверки: три кода, после введения которых можно просто ввести номер уровня (в hex и 1-based). Проверяются три значения, вычисляемые по трём кодам: пара dx:ax должна быть равна 8E7136C8h, значение bx должно быть 8BD1h. Анализ кода, приведённого выше, показывает, что dx:ax получается на выходе команды mul bp и 8E7136C8h — произведение двух 16-битных чисел: третьего кода и некоторого выражения с первыми двумя кодами. bx — некоторое выражение со вторым и третьим кодом. Разлагая 8E7136C8h на множители, получаем 23*7*4861*8779; чтобы получить два 16-битных сомножителя, 4861 и 8779 должны входить в разные сомножители, а оставшиеся множители 23*7 должны быть как-то разбиты на две части. Получается довольно мало вариантов (16 до отсеивания из-за 16-битности). Переберём каждый из вариантов; второй сомножитель — третий код, зная его, находим второй код из bx, после чего первый сомножитель даёт произведение двух 8-битных множителей. Не всякое 16-битное число можно представить в виде произведения двух 8-битных, здесь отсеиваются все варианты для третьего кода, кроме одного, который зато даёт два варианта для первого кода (отличающихся порядком 8-битных множителей). Код перебора: getmagic.c, результаты: ADDE C0DE F00D и DEAD C0DE F00D.

Автор: grechnik

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js