GHIDRA, ejecutables de Playstation 1, firmas FLIRT y PsyQ

Hola a todos



No sé sobre ti, pero siempre quise revertir los viejos juegos de consola, teniendo también un descompilador en stock. Y ahora, este momento alegre en mi vida ha llegado: GHIDRA ha salido. No escribiré sobre qué es, puedes buscarlo fácilmente en Google. Y, las revisiones son tan diferentes (especialmente retrógradas) que será difícil para un recién llegado incluso decidir lanzar este milagro ... Aquí hay un ejemplo para usted: " Trabajé por ideas durante 20 años, y miro a su Hydra con gran desconfianza, porque la NSA. Pero cuando- Lo ejecutaré y lo comprobaré en la práctica ".


En pocas palabras, correr Hydra no da miedo. Y lo que obtengamos después del lanzamiento bloqueará todo su miedo a los marcadores y puertas traseras de la ubicua NSA.


Entonces, ¿de qué estoy hablando? Hay un prefijo: Sony Playstation 1 ( PS1 , PSX , Curling iron ). Se crearon muchos juegos geniales para él, aparecieron un montón de franquicias que todavía son populares. Y un día quería saber cómo funcionan: qué formatos de datos hay, si se utiliza la compresión de recursos, intentar traducir algo al ruso (diré de inmediato que todavía no he traducido ningún juego).


Comencé escribiendo una utilidad genial para trabajar con el formato TIM con un amigo en Delphi (esto es algo así como BMP del mundo de Playstation): Tim2View . Hubo un tiempo en que disfrutaba el éxito (y tal vez ahora lo disfruta). Entonces quise profundizar en la compresión.


imagen


Y entonces comenzaron los problemas. Entonces no estaba familiarizado con el MIPS . Tomó para estudiar. Tampoco estaba familiarizado con IDA Pro (vine a revertir juegos en Sega Mega Drive más tarde que Playstation ). Pero, gracias a Internet, descubrí que IDA Pro admite la descarga y el análisis de archivos ejecutables PS1 : PS-X EXE . Traté de cargar un archivo de juego (parece que eran Lemmings ) con un nombre y una extensión extraños, como SLUS_123.45 en Ida, obtuve un montón de líneas de código de ensamblador (afortunadamente, ya tenía una idea de lo que era, gracias a los controladores exe de Windows en x86), y comenzó a entender.



El primer lugar difícil de entender fue la línea de montaje. Por ejemplo, ve una llamada a alguna función e inmediatamente después de que se está cargando en el registro el parámetro que se debe usar en esta función. En resumen, antes de los saltos y las llamadas a funciones, primero se ejecuta la instrucción que sigue al salto / llamada, y solo luego la llamada o el salto en sí.


Después de todas las dificultades por las que pasé, logré escribir varios empacadores / desempacadores de recursos del juego. Pero nunca he estudiado realmente el código. Por qué Bueno, todo es banal: había mucho código, acceso al BIOS y funciones que eran prácticamente imposibles de entender (eran una biblioteca, y no tenía un SDK para rizar), instrucciones que funcionaban con tres registros al mismo tiempo, falta de un descompilador.


Y así, después de muchos, muchos años, sale GHIDRA . Entre las plataformas compatibles con el descompilador se encuentra MIPS . Oh alegría ¡Intentemos descompilar algo pronto! Pero ... estaba esperando un fastidio. PS-X EXE no PS-X EXE compatibles con Hydra. No hay problema, escribe el tuyo!


En realidad codificar


Suficiente digresión lírica, escribamos un código. Cómo crear mis propios descargadores para Ghidra , ya tenía una idea de lo que escribí antes . Por lo tanto, solo queda encontrar el Mapa de memoria del primer rizador, las direcciones de los registros y, puede recopilar y cargar binarios. Apenas dicho que hecho.


El código estaba listo, se agregaron y reconocieron registros y regiones, pero todavía había un gran espacio en blanco en los lugares donde se llamaban las funciones de la biblioteca y las funciones del BIOS. Y, desafortunadamente, Hydra no tenía soporte FLIRT . Si no, agreguemos.


El formato de las firmas FLIRT se conoce y se describe en el archivo pat.txt , que se puede encontrar en el SDK de Ida. Ida también tiene una utilidad para crear estas firmas específicamente a partir de los archivos de la biblioteca de Playstation , y se llama: ppsx . Descargué el SDK para el PsyQ Playstation Development Kit llamado PsyQ Playstation Development Kit , encontré archivos lib allí e intenté crear al menos algunas firmas a partir de ellos, con éxito. Resulta un pequeño texto en el que cada línea tiene un formato específico. Queda por escribir el código que analizará estas líneas y las aplicará al código.



Patparser


Como cada línea tiene un formato específico, sería lógico escribir una expresión regular. Resultó así:


 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})+)?$"); 

Bueno, para resaltar en la lista de módulos por separado el desplazamiento, el tipo y el nombre de la función, escribimos una expresión regular separada:


 private static final Pattern modulePat = Pattern.compile("([:\\^][0-9A-F]{4}@?) ([\\.\\w]+) "); 

Ahora veamos los componentes de cada firma por separado:


  1. Primero viene la secuencia hexadecimal de bytes ( 0-9A-F ), donde algunos de ellos pueden ser cualquier (carácter de punto "."). Por lo tanto, creamos una clase que almacenará dicha secuencia. Lo llamé MaskedBytes :

MaskedBytes.java
 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); } } 

  1. La longitud del bloque a partir del cual se calcula CRC16 .
  2. CRC16 , que usa su propio polinomio ( 0x8408 ):

Código de conteo CRC16
 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; } 

  1. La longitud total del "módulo" en bytes.
  2. Lista de nombres globales (lo que necesitamos).
  3. Lista de enlaces a otros nombres (también necesarios).
  4. Bytes de cola.

Cada nombre en el módulo tiene un tipo específico y un desplazamiento relativo al comienzo. Un tipo puede ser indicado por uno de los caracteres ::, ^, @, dependiendo del tipo:


  • " : NOMBRE ": nombre global. Fue por el bien de tales nombres que comencé todo;
  • " : NAME @ ": nombre / etiqueta local. Puede no estar indicado, pero déjalo estar;
  • " ^ NOMBRE ": enlace al nombre.

Por un lado, todo es simple, pero un enlace puede no ser fácilmente una referencia a una función (y, en consecuencia, el salto será relativo), sino a una variable global. ¿Cuál, dices, es el problema? Y es que en PSX no puede insertar un DWORD completo en el registro con una sola instrucción. Para hacer esto, descárguelo en forma de mitades. El hecho es que, en MIPS tamaño de la instrucción está limitado a cuatro bytes. Y, al parecer, solo necesita obtener primero la mitad de una instrucción, y luego desmontar la siguiente, y obtener una segunda mitad. Pero no tan simple. La primera mitad puede cargar las instrucciones 5 de regreso, y el enlace en el módulo se dará solo después de cargar su segunda mitad. Tuve que escribir un analizador sofisticado (probablemente puede modificarse).


Como resultado, creamos enum para tres tipos de nombres:


ModuleType.java
 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"; } } } 

Escribamos un código que convierta secuencias de texto hexadecimales y puntos para escribir MaskedBytes :


hexStringToMaskedBytesArray ()
 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; } 

Ya puede pensar en una clase que almacenará información sobre cada función individual: el nombre de la función, el desplazamiento en el módulo y escriba:


ModuleData.java
 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; } } 

Y, por último: una clase que almacenará todo lo que se indica en cada línea del archivo pat , es decir: bytes, crc, una lista de nombres con desplazamientos:


SignatureData.java
 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<ModuleData> modules; public SignatureData(MaskedBytes templateBytes, int crc16Length, short crc16, int moduleLength, List<ModuleData> 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<ModuleData> getModules() { return modules; } } 

Ahora lo principal: escribimos código para crear todas estas clases:


Analizando una línea tomada de un archivo pat
 private List<ModuleData> parseModuleData(String s) { List<ModuleData> res = new ArrayList<ModuleData>(); 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; } 

Analizando todas las líneas de archivo pat
 private void parse(List<String> lines) { modulesCount = 0L; signatures = new ArrayList<SignatureData>(); 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<ModuleData> 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); } } 

El código para crear la función donde se reconoció una de las firmas:


Creación de funciones
 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); } } 

El lugar más difícil, como se mencionó anteriormente, es contar un enlace a otro nombre / variable (quizás el código debe mejorarse):


Cuenta de enlaces
 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); } } 

Y, el toque final: aplique las firmas:


applySignatures ()
 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<ModuleData> 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); } } } 

Aquí puedes hablar sobre una función interesante: findBytes() . Con él, puede buscar secuencias específicas de bytes, con las máscaras de bits especificadas para cada byte. El método se llama así:


 Address addr = program.getMemory().findBytes(startAddr, endAddr, bytes, masks, forward, TaskMonitor.DUMMY); 

Como resultado, la dirección desde la que comienzan los bytes se devuelve o es null .


Escribir un analizador


Hagámoslo maravillosamente, y no usaremos firmas si no queremos, pero dejemos que el usuario elija este paso. Para hacer esto, necesitará escribir su propio analizador de código (puede ver los que están en esta lista, eso es todo, sí):



Por lo tanto, para ingresar a esta lista, deberá heredar de la clase AbstractAnalyzer y anular algunos métodos:


  1. Constructor. Tendrá que llamar al constructor de la clase base con el nombre, la descripción del analizador y su tipo (más sobre eso más adelante). A mí me parece algo así:

 public PsxAnalyzer() { super("PSYQ Signatures", "PSX signatures applier", AnalyzerType.INSTRUCTION_ANALYZER); } 

  1. getDefaultEnablement() . Determina si nuestro analizador siempre está disponible o solo si se cumplen ciertas condiciones (por ejemplo, si se utiliza nuestro cargador).
  2. canAnalyze() . ¿Es posible usar este analizador en un archivo binario descargable?
    Los párrafos 2 y 3 pueden, en principio, ser verificados por una sola función:

 public static boolean isPsxLoader(Program program) { return program.getExecutableFormat().equalsIgnoreCase(PsxLoader.PSX_LOADER); } 

Donde PsxLoader.PSX_LOADER almacena el nombre del gestor de arranque y se define anteriormente en él.


Total, tenemos:


 @Override public boolean getDefaultEnablement(Program program) { return isPsxLoader(program); } @Override public boolean canAnalyze(Program program) { return isPsxLoader(program); } 

  1. registerOptions() . No es necesario redefinir este método, pero si necesitamos preguntarle al usuario algo, por ejemplo, la ruta al archivo pat antes del análisis, entonces es mejor hacerlo en este método. Obtenemos:

 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"); } 

Aquí hay que aclarar. El método estático getModuleDataFile() de la clase Application devuelve la ruta completa al archivo en el directorio de data , que se encuentra en el árbol de nuestro módulo, y puede almacenar los archivos necesarios a los que queremos hacer referencia más adelante.


Bueno, el método registerOption() una opción con el nombre especificado en OPTION_NAME , el tipo File (es decir, el usuario podrá seleccionar el archivo a través de un cuadro de diálogo normal), el valor predeterminado y la descripción.


Siguiente Porque no tendremos una oportunidad normal de referirnos a la opción registrada más tarde, tendremos que redefinir el método optionsChanged() :


 @Override public void optionsChanged(Options options, Program program) { super.optionsChanged(options, program); file = options.getFile(OPTION_NAME, file); } 

Aquí simplemente actualizamos la variable global de acuerdo con el nuevo valor.


El método added() . Ahora lo principal: el método que se llamará cuando se inicie el analizador. En él recibiremos una lista de direcciones disponibles para el análisis, pero solo necesitamos aquellas que contengan código. Por lo tanto, necesita filtrar. Código final:


Método agregado ()
 @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; } 

Aquí revisamos la lista de direcciones que son ejecutables e intentamos aplicar firmas allí.



Conclusiones y el final


Como todo De hecho, no hay nada súper complicado aquí. Hay ejemplos, la comunidad es animada, puede preguntar con seguridad sobre lo que no está claro al escribir el código. En pocas palabras: un gestor de arranque y analizador de trabajo para archivos ejecutables de Playstation 1 .



Todos los códigos fuente están disponibles aquí: ghidra_psx_ldr
Lanzamientos aquí: Lanzamientos

Source: https://habr.com/ru/post/448098/


All Articles