GHIDRA, exécutables Playstation 1, signatures FLIRT et PsyQ

Bonjour à tous



Je ne sais pas pour vous, mais j'ai toujours voulu inverser les anciens jeux de console, ayant également un décompilateur en stock. Et maintenant, ce moment joyeux de ma vie est venu - GHIDRA est sorti. Je n'écrirai pas sur ce que c'est, vous pouvez facilement le rechercher sur Google. Et, les critiques sont si différentes (en particulier de rétrogrades) qu'il sera difficile pour un nouveau venu de décider même de lancer ce miracle ... Voici un exemple pour vous: " J'ai travaillé pour des idées pendant 20 ans, et je regarde votre Hydra avec une grande méfiance, parce que la NSA. Mais quand - Je vais l'exécuter et le vérifier dans la pratique . "


En un mot - faire fonctionner Hydra n'est pas effrayant. Et ce que nous obtenons après le lancement bloquera toute votre peur des signets et des portes dérobées de la NSA omniprésente.


Alors, de quoi je parle ... Il y a un tel préfixe: Sony Playstation 1 ( PS1 , PSX , fer à friser ). De nombreux jeux sympas ont été créés pour cela, un tas de franchises sont apparues qui sont toujours populaires. Et un jour, j'ai voulu savoir comment ils fonctionnent: quels sont les formats de données, si la compression des ressources est utilisée, essayez de traduire quelque chose en russe (je dirai tout de suite que je n'ai pas encore traduit de jeux).


J'ai commencé par écrire un utilitaire sympa pour travailler avec le format TIM avec un ami sur Delphi (c'est quelque chose comme BMP du monde Playstation): Tim2View . À un moment donné, a connu le succès (et peut-être jouit maintenant) Ensuite, j'ai voulu approfondir la compression.


image


Et puis les problèmes ont commencé. Je ne connaissais pas alors le MIPS . A pris à étudier. Je ne connaissais pas non plus IDA Pro (je suis venu inverser les jeux sur Sega Mega Drive plus tard que sur Playstation ). Mais grâce à Internet, j'ai découvert IDA Pro prend en charge le téléchargement et l'analyse des fichiers exécutables EXE PS1 : PS-X . J'ai essayé de télécharger un fichier de jeu (il semble qu'il s'agissait de Lemmings ) avec un nom et une extension étranges, comme SLUS_123.45 dans Ida, j'ai eu un tas de lignes de code assembleur (heureusement, j'avais déjà une idée de ce que c'était, grâce aux pilotes d'exe de Windows sous x86), et a commencé à comprendre.



Le premier endroit difficile à comprendre a été la chaîne de montage. Par exemple, vous voyez un appel à une fonction et, immédiatement après le chargement dans le registre, le paramètre à utiliser dans cette fonction. En bref, avant tout saut et appel de fonction, l'instruction qui suit le saut / appel est d'abord exécutée, puis seulement l'appel ou le saut lui-même.


Après toutes les difficultés que j'ai traversées, j'ai réussi à écrire plusieurs packers / unpackers de ressources de jeu. Mais je n'ai jamais vraiment étudié le code. Pourquoi? Eh bien, tout est banal: il y avait beaucoup de code, l'accès au BIOS et des fonctions qui étaient pratiquement impossibles à comprendre (ils étaient bibliothèque, et je n'avais pas de SDK pour le curling à l'époque), des instructions fonctionnant avec trois registres en même temps, le manque de décompilateur.


Et donc, après de très nombreuses années, GHIDRA sort. Parmi les plates-formes prises en charge par le décompilateur se trouve MIPS . Oh joie! Essayons de décompiler quelque chose bientôt! Mais ... j'attendais une déception. PS-X EXE ne PS-X EXE pas pris en charge par Hydra. Ce n'est pas un problème, nous allons écrire le vôtre!


Code réellement


Assez de digression lyrique, écrivons du code. Comment créer mes propres téléchargeurs pour Ghidra , j'avais déjà une idée de ce que j'avais écrit plus tôt . Par conséquent, il ne reste plus qu'à trouver la carte mémoire du premier fer à friser, les adresses des registres et, vous pouvez collecter et charger des binaires. Aussitôt dit, aussitôt fait.


Le code était prêt, des registres et des régions ont été ajoutés et reconnus, mais il y avait encore un grand espace vide aux endroits où les fonctions de bibliothèque et les fonctions du BIOS étaient appelées. Et, malheureusement, Hydra n'a pas pris en charge FLIRT . Sinon, ajoutons.


Le format des signatures FLIRT est connu et décrit dans le fichier pat.txt , qui se trouve dans le SDK Ida. Ida a également un utilitaire pour créer ces signatures spécifiquement à partir des fichiers de la bibliothèque Playstation , et s'appelle: ppsx . J'ai téléchargé le SDK pour le PsyQ Playstation Development Kit appelé PsyQ Playstation Development Kit , y PsyQ Playstation Development Kit trouvé des fichiers lib et essayé de créer au moins quelques signatures à partir d'eux - avec succès. Il se trouve un petit texte dans lequel chaque ligne a un format spécifique. Il reste à écrire du code qui analysera ces lignes et les appliquera au code.



Patparser


Étant donné que chaque ligne a un format spécifique, il serait logique d'écrire une expression régulière. Il s'est avéré comme ceci:


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

Eh bien, pour mettre en évidence dans la liste des modules séparément le décalage, le type et le nom de la fonction, nous écrivons une expression rationnelle distincte:


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

Passons maintenant en revue les composants de chaque signature séparément:


  1. Vient d'abord la séquence hexadécimale d'octets ( 0-9A-F ), où certains d'entre eux peuvent être n'importe lesquels (caractère point "."). Par conséquent, nous créons une classe qui stockera une telle séquence. Je l'ai appelé 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 longueur du bloc à partir duquel CRC16 est calculé.
  2. CRC16 , qui utilise son propre polynôme ( 0x8408 ):

Code de comptage 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 longueur totale du "module" en octets.
  2. Liste des noms globaux (ce dont nous avons besoin).
  3. Liste de liens vers d'autres noms (également requis).
  4. Octets de queue.

Chaque nom du module a un type et un décalage spécifiques par rapport au début. Un type peut être indiqué par l'un des caractères ::, ^, @, selon le type:


  • " : NAME ": nom global. C'est pour ces noms que j'ai tout commencé;
  • " : NAME @ ": nom / libellé local. Ce n'est peut-être pas indiqué, mais que ce soit;
  • " ^ NAME ": lien vers le nom.

D'une part, tout est simple, mais un lien peut facilement ne pas être une référence à une fonction (et, par conséquent, le saut sera relatif), mais à une variable globale. Quel est, vous dites, le problème? Et c'est que dans PSX, vous ne pouvez pas pousser un DWORD entier dans le registre avec une seule instruction. Pour ce faire, téléchargez-le sous forme de moitiés. Le fait est que, dans MIPS taille des instructions est limitée à quatre octets. Et, il semblerait, vous avez juste besoin d'obtenir d'abord la moitié d'une instruction, puis de démonter la suivante - et d'obtenir une seconde moitié. Mais pas si simple. La première moitié peut être chargée des instructions 5 en arrière, et le lien dans le module ne sera donné qu'après avoir chargé sa seconde moitié. J'ai dû écrire un analyseur sophistiqué (il peut probablement être modifié).


Par conséquent, nous créons une enum pour trois types de noms:


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

MaskedBytes un code qui convertit les séquences et les points hexadécimaux en texte pour taper 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; } 

Vous pouvez déjà penser à une classe qui stockera des informations sur chaque fonction individuelle: le nom de la fonction, le décalage dans le module et tapez:


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

Et enfin: une classe qui stockera tout ce qui est indiqué sur chaque ligne du fichier pat , c'est-à-dire: octets, crc, une liste de noms avec décalages:


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

Maintenant l'essentiel: nous écrivons du code pour créer toutes ces classes:


Analyse d'une ligne prise d'un fichier 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; } 

Analyser toutes les lignes de fichiers 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); } } 

Le code pour créer la fonction où l'une des signatures a été reconnue:


Création de fonction
 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); } } 

L'endroit le plus difficile, comme mentionné précédemment, est de compter un lien vers un autre nom / variable (peut-être que le code doit être amélioré):


Nombre de liens
 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); } } 

Et, la touche finale - appliquez les signatures:


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

Ici, vous pouvez parler d'une fonction intéressante: findBytes() . Avec lui, vous pouvez rechercher des séquences spécifiques d'octets, avec les masques de bits spécifiés pour chaque octet. La méthode est appelée comme ceci:


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

Par conséquent, l'adresse à partir de laquelle les octets commencent est renvoyée ou null .


Écrire un analyseur


Faisons-le magnifiquement, et nous n'utiliserons pas de signatures si nous ne le voulons pas, mais laissons l'utilisateur choisir cette étape. Pour ce faire, vous devrez écrire votre propre analyseur de code (vous pouvez voir ceux de cette liste - c'est tout ce qu'ils sont, oui):



Ainsi, pour vous faufiler dans cette liste, vous devrez hériter de la classe AbstractAnalyzer et remplacer certaines méthodes:


  1. Constructeur. Il devra appeler le constructeur de la classe de base avec le nom, la description de l'analyseur et son type (plus de détails plus loin). Cela me ressemble à ceci:

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

  1. getDefaultEnablement() . Détermine si notre analyseur est toujours disponible, ou seulement si certaines conditions sont remplies (par exemple, si notre chargeur est utilisé).
  2. canAnalyze() . Est-il possible d'utiliser cet analyseur sur un fichier binaire téléchargeable?
    Les paragraphes 2 et 3 peuvent, en principe, être vérifiés par une seule fonction:

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

PsxLoader.PSX_LOADER stocke le nom du chargeur de démarrage et est défini précédemment dans celui-ci.


Au total, nous avons:


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

  1. registerOptions() . Il n'est pas du tout nécessaire de redéfinir cette méthode, mais si nous devons demander à l'utilisateur quelque chose, par exemple, le chemin d'accès au fichier pat avant l'analyse, il est préférable de le faire dans cette méthode. Nous obtenons:

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

Ici, il est nécessaire de clarifier. La méthode statique getModuleDataFile() de la classe Application renvoie le chemin d'accès complet au fichier dans le répertoire de data , qui se trouve dans l'arborescence de notre module, et peut stocker tous les fichiers nécessaires auxquels nous voulons nous référer ultérieurement.


Eh bien, la méthode registerOption() une option avec le nom spécifié dans OPTION_NAME , le type File (c'est-à-dire que l'utilisateur pourra sélectionner le fichier via une boîte de dialogue standard), la valeur et la description par défaut.


Ensuite. Parce que nous n'aurons pas la possibilité normale de faire référence à l'option enregistrée plus tard, nous devrons redéfinir la méthode optionsChanged() :


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

Ici, nous mettons simplement à jour la variable globale en fonction de la nouvelle valeur.


La méthode added() . Maintenant l'essentiel: la méthode qui sera appelée au démarrage de l'analyseur. Nous y recevrons une liste d'adresses disponibles pour l'analyse, mais nous n'avons besoin que de celles qui contiennent du code. Par conséquent, vous devez filtrer. Code final:


Added (), méthode
 @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; } 

Ici, nous parcourons la liste des adresses qui sont exécutables et essayons d'y appliquer des signatures.



Conclusions et fin


Comme tout. En fait, il n'y a rien de super compliqué ici. Il y a des exemples, la communauté est animée, vous pouvez en toute sécurité demander ce qui n'est pas clair lors de l'écriture du code. Conclusion: un chargeur de démarrage et un analyseur de travail pour les fichiers exécutables Playstation 1 .



Tous les codes sources sont disponibles ici: ghidra_psx_ldr
Communiqués ici: Communiqués

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


All Articles