Привет всем,
Не знаю как вам, а мне всегда хотелось пореверсить старые приставочные игры, имея в запасе ещё и декомпилятор. И вот, этот радостный момент в моей жизни настал — вышла GHIDRA. О том, что это такое, писать не буду, можно легко загуглить. И, отзывы настолько разные (особенно от ретроградов), что новичку будет сложно даже решиться на запуск этого чуда… Вот вам пример: «20 лет работал в иде, и смотрю я на вашу Гидру с большим недоверием, потому что АНБ. Но когда-нибудь запущу и проверю её в деле«.
Если в двух словах — запускать Гидру не страшно. И то, что мы получаем после запуска перекроет весь ваш страх перед закладками-и-бэкдорами от вездесущих АНБ.
Так вот, о чём это я… Есть такая приставка: Sony Playstation 1 (PS1, PSX, Плойка). Под неё было создано множество крутых игр, появилась куча франшиз, которые популярны до сих пор. И мне однажды захотелось узнать, как они устроены: какие есть форматы данных, используется ли сжатие ресурсов, попробовать что-то перевести на русский язык (сразу скажу, пока не перевёл ни одной игры).
Начал с того, что написал с товарищем на Delphi
крутую утилиту для работы с форматом TIM
(это что-то типа BMP
от мира Playstation): Tim2View. В своё время пользовалась успехом (а может и сейчас пользуется). Потом захотелось углубиться в сжатие.
И тут начались проблемы. С процессором MIPS
я тогда ещё не был знаком. Взялся изучать. С IDA Pro
я тоже тогда не был знаком (я пришёл к реверсу игр на Sega Mega Drive
позже Playstation
). Но, благодаря интернету я узнал, что как раз таки IDA Pro
поддерживает загрузку и анализ исполняемых файлов PS1
: PS-X EXE. Попробовал закинуть файл игры (кажется, это были Lemmings) со странным именем и расширением, типа SLUS_123.45
в Иду, получил кучу строк ассемблерного кода (к счастью, я уже имел представление о том, что это такое, благодаря exe-шникам Винды под x86), и начал разбираться.
Первым трудным для понимания местом был конвейер инструкций. Например, вы видите вызов какой-то функции, а сразу за ним идёт загрузка в регистр параметра, который должен использоваться в этой функции. Если вкратце, то перед всякими прыжками и вызовами функций сначала выполняется следующая за прыжком/вызовом инструкция, а уже потом — сам вызов или прыжок.
После всех пройденных трудностей мне удалось написать несколько упаковщиков/распаковщиков ресурсов игр. Но именно изучением кода я никогда не занимался. Почему? Ну, всё банально: кода было очень много, обращения к BIOS и функциям, которые понять было фактически невозможно (они были библиотечными, а SDK под плойку у меня тогда не было), инструкции, работающие с тремя регистрами одновременно, отсутствие декомпилятора.
И вот, спустя много-много лет, выходит GHIDRA
. Среди поддерживаемых декомпилятором платформ есть MIPS
. О, радость! Давайте же скорее попробуем что-то декомпилировать! Но… меня ждал облом. Исполняемые файлы PS-X EXE
не поддерживаются Гидрой. Не беда, напишем свой!
Собственно код
Хватит лирических отступлений, давайте писать код. Как создавать свои загрузчики для Ghidra
, я уже представление имел, о чём писал ранее. Поэтому, осталось только найти Memory Map первой плойки, адреса регистров и, можно собирать и грузить бинари. Сказано — сделано.
Код был готов, регистры и регионы добавлялись и распознавались, но на местах вызовов библиотечных функций и функций BIOS по-прежнему было большое белое пятно. И, к сожалению, поддержки FLIRT
у Гидры не было. Если нету, давай добавим.
Формат FLIRT
-сигнатур известен и описан в pat.txt
файлике, который можно найти в SDK Иды. Также, у Иды есть утилита для создания этих сигнатур специально из библиотечных файлов Playstation
, и называется: ppsx
. Скачал SDK для плойки, который называется PsyQ Playstation Development Kit
, нашёл там lib
-файлы и попробовал создать из них хоть какие-то сигнатуры — успешно. Получается текстовичок, в котором каждая строка имеет определённый формат. Остаётся написать код, который будет парсить эти строки, и применять их на код.
PatParser
Так как каждая строка имеет определённый формат, логично будет написать регулярное выражение. Оно получилось таким:
private static final Pattern linePat = Pattern.compile("^((?:[0-9A-F\.]{2})+) ([0-9A-F]{2}) ([0-9A-F]{4}) ([0-9A-F]{4}) ((?:[:\^][0-9A-F]{4}@? [\.\w]+ )+)((?:[0-9A-F\.]{2})+)?$");
Ну и для выделения потом в списке модулей отдельно смещения, типа, и имени функции, пишем отдельный регэксп:
private static final Pattern modulePat = Pattern.compile("([:\^][0-9A-F]{4}@?) ([\.\w]+) ");
Теперь пройдёмся по составляющим каждой сигнатуры отдельно:
- Сначала идёт hex-последовательность из байт (0-9A-F), где некоторые из них могут быть любыми (символ точки «.»). Поэтому создаём класс, который будет хранить такую последовательность. Я назвал её
MaskedBytes
:
package pat; public class MaskedBytes { private final byte[] bytes, masks; public final byte[] getBytes() { return bytes; } public final byte[] getMasks() { return masks; } public final int getLength() { return bytes.length; } public MaskedBytes(byte[] bytes, byte[] masks) { this.bytes = bytes; this.masks = masks; } public static MaskedBytes extend(MaskedBytes src, MaskedBytes add) { return extend(src, add.getBytes(), add.getMasks()); } public static MaskedBytes extend(MaskedBytes src, byte[] addBytes, byte[] addMasks) { int length = src.getBytes().length; byte[] tmpBytes = new byte[length + addBytes.length]; byte[] tmpMasks = new byte[length + addMasks.length]; System.arraycopy(src.getBytes(), 0, tmpBytes, 0, length); System.arraycopy(addBytes, 0, tmpBytes, length, addBytes.length); System.arraycopy(src.getMasks(), 0, tmpMasks, 0, length); System.arraycopy(addMasks, 0, tmpMasks, length, addMasks.length); return new MaskedBytes(tmpBytes, tmpMasks); } }
- Длина блока, от которого посчитана
CRC16
. CRC16
, в которой используется свой полином (0x8408
):
public static boolean checkCrc16(byte[] bytes, short resCrc) { if ( bytes.length == 0 ) return true; int crc = 0xFFFF; for (int i = 0; i < bytes.length; ++i) { int a = bytes[i]; for (int x = 0; x < 8; ++x) { if (((crc ^ a) & 1) != 0) { crc = (crc >> 1) ^ 0x8408; } else { crc >>= 1; } a >>= 1; } } crc = ~crc; int x = crc; crc = (crc << 8) | ((x >> 8) & 0xFF); crc &= 0xFFFF; return (short)crc == resCrc; }
- Полная длина «модуля» в байтах.
- Список глобальных имён (то, что нам нужно).
- Список ссылок на другие имена (тоже нужно).
- Хвостовые байты.
У каждого имени в модуле есть определённый тип и смещение относительно начала. Тип может быть обозначен одним из символов: :, ^, @, в зависимости от типа:
- «:NAME«: глобальное имя. Именно ради таких имён я всё и затеял;
- «:NAME@«: локальное имя/метка. Можно и не обозначать, но пусть будет;
- «^NAME«: ссылка на имя.
С одной стороны всё просто, но, ссылка запросто может быть ссылкой на функцию (и, соответственно, прыжок будет относительным), а на глобальную переменную. В чём, скажете вы, проблема? А она в том, что в PSX нельзя одной инструкцией затолкать целый DWORD
в регистр. Для этого необходимо загрузить его в виде половинок. Дело в том, в MIPS
размер инструкции ограничен четырьмя байтами. И, казалось бы, нужно всего лишь сначала получить одну половинку из одной инструкции, а затем дизассемблировать следующую — и получить вторую половинку. Но не так всё просто. Первая половинка может быть загружена инструкций 5 назад, а ссылка в модуле будет дана только после загрузки её второй половины. Пришлось писать изощрённый парсер (наверное его можно доработать).
В итоге, создаём enum
для трёх типов имён:
package pat; public enum ModuleType { GLOBAL_NAME, LOCAL_NAME, REF_NAME; public boolean isGlobal() { return this == GLOBAL_NAME; } public boolean isLocal() { return this == LOCAL_NAME; } public boolean isReference() { return this == REF_NAME; } @Override public String toString() { if (isGlobal()) { return "Global"; } else if (isLocal()) { return "Local"; } else { return "Reference"; } } }
Давайте напишем код, который преобразовывает текстовые шестнадцатеричные последовательности и точки в тип MaskedBytes
:
private MaskedBytes hexStringToMaskedBytesArray(String s) { MaskedBytes res = null; if (s != null) { int len = s.length(); byte[] bytes = new byte[len / 2]; byte[] masks = new byte[len / 2]; for (int i = 0; i < len; i += 2) { char c1 = s.charAt(i); char c2 = s.charAt(i + 1); masks[i / 2] = (byte) ( (((c1 == '.') ? 0x0 : 0xF) << 4) | (((c2 == '.') ? 0x0 : 0xF) << 0) ); bytes[i / 2] = (byte) ( (((c1 == '.') ? 0x0 : Character.digit(c1, 16)) << 4) | (((c2 == '.') ? 0x0 : Character.digit(c2, 16)) << 0) ); } res = new MaskedBytes(bytes, masks); } return res; }
Уже можно подумать и о классе, который будет хранить информацию о каждой отдельной функции: имя функции, смещение в модуле, и тип:
package pat; public class ModuleData { private final long offset; private final String name; private final ModuleType type; public ModuleData(long offset, String name, ModuleType type) { this.offset = offset; this.name = name; this.type = type; } public final long getOffset() { return offset; } public final String getName() { return name; } public final ModuleType getType() { return type; } }
И, последнее: класс, который будет хранить всё, что указано в каждой строке pat
-файла, то бишь: байты, crc, список имён со смещениями:
package pat; import java.util.Arrays; import java.util.List; public class SignatureData { private final MaskedBytes templateBytes, tailBytes; private MaskedBytes fullBytes; private final int crc16Length; private final short crc16; private final int moduleLength; private final List modules; public SignatureData(MaskedBytes templateBytes, int crc16Length, short crc16, int moduleLength, List modules, MaskedBytes tailBytes) { this.templateBytes = this.fullBytes = templateBytes; this.crc16Length = crc16Length; this.crc16 = crc16; this.moduleLength = moduleLength; this.modules = modules; this.tailBytes = tailBytes; if (this.tailBytes != null) { int addLength = moduleLength - templateBytes.getLength() - tailBytes.getLength(); byte[] addBytes = new byte[addLength]; byte[] addMasks = new byte[addLength]; Arrays.fill(addBytes, (byte)0x00); Arrays.fill(addMasks, (byte)0x00); this.fullBytes = MaskedBytes.extend(this.templateBytes, addBytes, addMasks); this.fullBytes = MaskedBytes.extend(this.fullBytes, tailBytes); } } public MaskedBytes getTemplateBytes() { return templateBytes; } public MaskedBytes getTailBytes() { return tailBytes; } public MaskedBytes getFullBytes() { return fullBytes; } public int getCrc16Length() { return crc16Length; } public short getCrc16() { return crc16; } public int getModuleLength() { return moduleLength; } public List getModules() { return modules; } }
Теперь основное: пишем код для создания всех эти классов:
private List parseModuleData(String s) { List res = new ArrayList(); if (s != null) { Matcher m = modulePat.matcher(s); while (m.find()) { String __offset = m.group(1); ModuleType type = __offset.startsWith(":") ? ModuleType.GLOBAL_NAME : ModuleType.REF_NAME; type = (type == ModuleType.GLOBAL_NAME && __offset.endsWith("@")) ? ModuleType.LOCAL_NAME : type; String _offset = __offset.replaceAll("[:^@]", ""); long offset = Integer.parseInt(_offset, 16); String name = m.group(2); res.add(new ModuleData(offset, name, type)); } } return res; }
private void parse(List lines) { modulesCount = 0L; signatures = new ArrayList(); int linesCount = lines.size(); monitor.initialize(linesCount); monitor.setMessage("Reading signatures..."); for (int i = 0; i < linesCount; ++i) { String line = lines.get(i); Matcher m = linePat.matcher(line); if (m.matches()) { MaskedBytes pp = hexStringToMaskedBytesArray(m.group(1)); int ll = Integer.parseInt(m.group(2), 16); short ssss = (short)Integer.parseInt(m.group(3), 16); int llll = Integer.parseInt(m.group(4), 16); List modules = parseModuleData(m.group(5)); MaskedBytes tail = null; if (m.group(6) != null) { tail = hexStringToMaskedBytesArray(m.group(6)); } signatures.add(new SignatureData(pp, ll, ssss, llll, modules, tail)); modulesCount += modules.size(); } monitor.incrementProgress(1); } }
Код создания функции там, где распозналась одна из сигнатур:
private static void disasmInstruction(Program program, Address address) { DisassembleCommand cmd = new DisassembleCommand(address, null, true); cmd.applyTo(program, TaskMonitor.DUMMY); } public static void setFunction(Program program, FlatProgramAPI fpa, Address address, String name, boolean isFunction, boolean isEntryPoint, MessageLog log) { try { if (fpa.getInstructionAt(address) == null) disasmInstruction(program, address); if (isFunction) { fpa.createFunction(address, name); } if (isEntryPoint) { fpa.addEntryPoint(address); } if (isFunction && program.getSymbolTable().hasSymbol(address)) { return; } program.getSymbolTable().createLabel(address, name, SourceType.IMPORTED); } catch (InvalidInputException e) { log.appendException(e); } }
Самое сложное место, как говорилось ранее — подсчёт ссылки на другое имя/переменную (возможно, код нуждается в доработке):
public static void setInstrRefName(Program program, FlatProgramAPI fpa, PseudoDisassembler ps, Address address, String name, MessageLog log) { ReferenceManager refsMgr = program.getReferenceManager(); Reference[] refs = refsMgr.getReferencesFrom(address); if (refs.length == 0) { disasmInstruction(program, address); refs = refsMgr.getReferencesFrom(address); if (refs.length == 0) { refs = refsMgr.getReferencesFrom(address.add(4)); if (refs.length == 0) { refs = refsMgr.getFlowReferencesFrom(address.add(4)); Instruction instr = program.getListing().getInstructionAt(address.add(4)); if (instr == null) { disasmInstruction(program, address.add(4)); instr = program.getListing().getInstructionAt(address.add(4)); if (instr == null) { return; } } FlowType flowType = instr.getFlowType(); if (refs.length == 0 && !(flowType.isJump() || flowType.isCall() || flowType.isTerminal())) { return; } refs = refsMgr.getReferencesFrom(address.add(8)); if (refs.length == 0) { return; } } } } try { program.getSymbolTable().createLabel(refs[0].getToAddress(), name, SourceType.IMPORTED); } catch (InvalidInputException e) { log.appendException(e); } }
И, финальный штрих — применяем сигнатуры:
public void applySignatures(ByteProvider provider, Program program, Address imageBase, Address startAddr, Address endAddr, MessageLog log) throws IOException { BinaryReader reader = new BinaryReader(provider, false); PseudoDisassembler ps = new PseudoDisassembler(program); FlatProgramAPI fpa = new FlatProgramAPI(program); monitor.initialize(getAllModulesCount()); monitor.setMessage("Applying signatures..."); for (SignatureData sig : signatures) { MaskedBytes fullBytes = sig.getFullBytes(); MaskedBytes tmpl = sig.getTemplateBytes(); Address addr = program.getMemory().findBytes(startAddr, endAddr, fullBytes.getBytes(), fullBytes.getMasks(), true, TaskMonitor.DUMMY); if (addr == null) { monitor.incrementProgress(sig.getModules().size()); continue; } addr = addr.subtract(imageBase.getOffset()); byte[] nextBytes = reader.readByteArray(addr.getOffset() + tmpl.getLength(), sig.getCrc16Length()); if (!PatParser.checkCrc16(nextBytes, sig.getCrc16())) { monitor.incrementProgress(sig.getModules().size()); continue; } addr = addr.add(imageBase.getOffset()); List modules = sig.getModules(); for (ModuleData data : modules) { Address _addr = addr.add(data.getOffset()); if (data.getType().isGlobal()) { setFunction(program, fpa, _addr, data.getName(), data.getType().isGlobal(), false, log); } monitor.setMessage(String.format("%s function %s at 0x%08X", data.getType(), data.getName(), _addr.getOffset())); monitor.incrementProgress(1); } for (ModuleData data : modules) { Address _addr = addr.add(data.getOffset()); if (data.getType().isReference()) { setInstrRefName(program, fpa, ps, _addr, data.getName(), log); } monitor.setMessage(String.format("%s function %s at 0x%08X", data.getType(), data.getName(), _addr.getOffset())); monitor.incrementProgress(1); } } }
Тут можно рассказать об одной интересной функции: findBytes()
. С её помощью можно искать определённые последовательности байт, с указанными битовыми масками для каждого байта. Метод вызывается так:
Address addr = program.getMemory().findBytes(startAddr, endAddr, bytes, masks, forward, TaskMonitor.DUMMY);
В итоге возвращается адрес, с которого начинаются байты, либо null
.
Пишем анализатор
Давайте сделаем красиво, и не будем применять сигнатуры, если мы не хотим, а позволим выбирать этот шаг пользователю. Для этого необходимо будет написать свой собственный анализатор кода (вы могли видеть подобные в этом списке — это всё они, да):
Итак, чтобы вклиниться в этот список, нужно будет унаследоваться от класса AbstractAnalyzer
и переопределить некоторые методы:
- Конструктор. Должен будет вызвать конструктор базового класса с указанием имени, описания анализатора, и его типа (об этом позже). У меня выглядит как-то так:
public PsxAnalyzer() { super("PSYQ Signatures", "PSX signatures applier", AnalyzerType.INSTRUCTION_ANALYZER); }
getDefaultEnablement()
. Определяет, доступен ли наш анализатор всегда, или же только при выполнении каких-то условий (например, если используется наш же загрузчик).canAnalyze()
. Можно ли вообще использовать данный анализатор на загружаемом бинарном файле.
Пункты 2 и 3 можно в принципе проверять одной единственной функцией:
public static boolean isPsxLoader(Program program) { return program.getExecutableFormat().equalsIgnoreCase(PsxLoader.PSX_LOADER); }
Где PsxLoader.PSX_LOADER
хранит имя загрузчика, и определено ранее в нём же.
Итого, имеем:
@Override public boolean getDefaultEnablement(Program program) { return isPsxLoader(program); } @Override public boolean canAnalyze(Program program) { return isPsxLoader(program); }
registerOptions()
. Вовсе не обязательно переопределять этот метод, но, если нам нужно что-то до анализа спросить у пользователя, например, путь к pat-файлу, то лучше всего это делать в данном методе. Получаем:
private static final String OPTION_NAME = "PSYQ PAT-File Path"; private File file = null; @Override public void registerOptions(Options options, Program program) { try { file = Application.getModuleDataFile("psyq4_7.pat").getFile(false); } catch (FileNotFoundException e) { } options.registerOption(OPTION_NAME, OptionType.FILE_TYPE, file, null, "PAT-File (FLAIR) created from PSYQ library files"); }
Тут необходимо пояснить. Статический метод getModuleDataFile()
класса Application
возвращает полный путь к файлу в каталоге data
, который имеется в дереве нашего модуля, и может хранить любые необходимые файлы, на которые мы захотим позже сослаться.
Ну а метод registerOption()
регистрирует опцию с именем указанным в OPTION_NAME
, типом File
(т.е. у пользователя будет возможность выбирать файл через обычное диалоговое окно), дефолтным значением и описанием.
Далее. Т.к. нормальной возможности потом сослаться на зарегистрированную опцию у нас не будет, потребуется переопределить метод optionsChanged()
:
@Override public void optionsChanged(Options options, Program program) { super.optionsChanged(options, program); file = options.getFile(OPTION_NAME, file); }
Здесь мы просто обновляем глобальную переменную согласно новому значению.
Метод added()
. Теперь основное: метод, который будет вызываться при запуске анализатора. В него нам будет приходить список адресов доступных для анализа, но, нам нужны только те, что содержат код. Поэтому нужно отфильтровать. Итоговый код:
@Override public boolean added(Program program, AddressSetView set, TaskMonitor monitor, MessageLog log) throws CancelledException { if (file == null) { return true; } Memory memory = program.getMemory(); AddressRangeIterator it = memory.getLoadedAndInitializedAddressSet().getAddressRanges(); while (!monitor.isCancelled() && it.hasNext()) { AddressRange range = it.next(); try { MemoryBlock block = program.getMemory().getBlock(range.getMinAddress()); if (block.isInitialized() && block.isExecute() && block.isLoaded()) { PatParser pat = new PatParser(file, monitor); RandomAccessByteProvider provider = new RandomAccessByteProvider(new File(program.getExecutablePath())); pat.applySignatures(provider, program, block.getStart(), block.getStart(), block.getEnd(), log); } } catch (IOException e) { log.appendException(e); return false; } } return true; }
Тут мы проходимся по списку адресов, которые являются исполняемыми, и пытаемся применить там сигнатуры.
Выводы и финалочка
Вроде всё. На самом деле, супер сложного здесь ничего нет. Примеры есть, сообщество живое, можно спокойно спрашивать о том, что не понятно, пока пишешь код. Итог: рабочий загрузчик и анализатор исполняемых файлов Playstation 1
.
Все исходные коды доступны здесь: ghidra_psx_ldr
Релизы тут: Releases
Источник