Hallo an alle,

Ich weiß nichts über dich, aber ich wollte immer die alten Konsolenspiele umkehren und hatte auch einen Dekompiler auf Lager. Und jetzt ist dieser freudige Moment in meinem Leben gekommen - GHIDRA ist herausgekommen. Ich werde nicht darüber schreiben, was es ist. Sie können es ganz einfach googeln. Und die Bewertungen sind so unterschiedlich (insbesondere rückläufig), dass es für einen Neuling schwierig sein wird, sich überhaupt für dieses Wunder zu entscheiden ... Hier ein Beispiel für Sie: " Ich habe 20 Jahre lang für Ideen gearbeitet und sehe Ihre Hydra mit großem Misstrauen an, weil die NSA. Aber wann ... Ich werde es ausführen und in der Praxis überprüfen . "
Kurz gesagt - Hydra zu laufen ist nicht beängstigend. Und was wir nach dem Start erhalten, wird all Ihre Angst vor Lesezeichen und Hintertüren vor der allgegenwärtigen NSA blockieren.
Also, wovon spreche ich? Es gibt ein solches Präfix: Sony Playstation 1 ( PS1 , PSX , Lockenstab ). Es wurden viele coole Spiele dafür entwickelt, eine Reihe von Franchise-Unternehmen erschienen, die immer noch beliebt sind. Und eines Tages wollte ich herausfinden, wie sie funktionieren: Welche Datenformate gibt es, ob Ressourcenkomprimierung verwendet wird, versuchen Sie, etwas ins Russische zu übersetzen (ich werde sofort sagen, dass ich noch keine Spiele übersetzt habe).
Ich begann damit, ein cooles Dienstprogramm für die Arbeit mit dem TIM
Format mit einem Freund auf Delphi
schreiben (dies ist so etwas wie BMP
aus der Playstation-Welt): Tim2View . Zu einer Zeit genossen Erfolg (und vielleicht jetzt genießt). Dann wollte ich tiefer in die Komprimierung einsteigen.

Und dann begannen die Probleme. Ich war damals nicht mit dem MIPS
vertraut. Nahm zu studieren. Ich war auch nicht mit IDA Pro
vertraut (ich kam später als Playstation
, um Spiele auf Sega Mega Drive
umzukehren). Dank des Internets fand ich jedoch heraus, dass IDA Pro
das Herunterladen und Analysieren von ausführbaren PS1
: PS-X EXE-Dateien unterstützt . Ich habe versucht, eine SLUS_123.45
(es scheint, dass es sich um Lemminge handelt ) mit einem seltsamen Namen und einer seltsamen Erweiterung SLUS_123.45
, wie z. B. SLUS_123.45
in Ida. Ich habe eine Reihe von Zeilen Assembler-Code erhalten (zum Glück hatte ich dank der Windows-Exe-Treiber unter bereits eine Vorstellung davon, was es war x86) und begann zu verstehen.

Der erste schwer zu verstehende Ort war das Fließband. Sie sehen beispielsweise einen Aufruf einer Funktion und unmittelbar nach dem Laden des Parameters, der in dieser Funktion verwendet werden soll, in das Register. Kurz gesagt, vor Sprüngen und Funktionsaufrufen wird zuerst die Anweisung ausgeführt, die auf den Sprung / Aufruf folgt, und erst dann der Aufruf oder Sprung selbst.
Nach all den Schwierigkeiten, die ich durchgemacht habe, habe ich es geschafft, mehrere Packer / Unpacker von Spielressourcen zu schreiben. Aber ich habe Code nie wirklich studiert. Warum? Nun, alles ist alltäglich: Es gab viel Code, Zugriff auf das BIOS und Funktionen, die praktisch nicht zu verstehen waren (es waren Bibliotheksfunktionen, aber ich hatte damals kein SDK zum Einrollen), Anweisungen, die mit drei Registern gleichzeitig arbeiteten, kein Dekompiler.
Und so kommt GHIDRA
nach vielen, vielen Jahren GHIDRA
. Zu den vom Dekompiler unterstützten Plattformen gehört MIPS
. Oh Freude! Versuchen wir bald etwas zu dekompilieren! Aber ... ich habe auf einen Mist gewartet. PS-X EXE
von Hydra nicht unterstützt. Es ist kein Problem, wir schreiben Ihre eigenen!
Eigentlich Code
Genug lyrischer Exkurs, schreiben wir Code. Wie ich meine eigenen Downloader für Ghidra
erstellen konnte, hatte ich bereits eine Vorstellung davon, was ich zuvor geschrieben habe . Daher müssen nur noch die Speicherkarte des ersten Lockenstabs, die Adressen der Register und Binärdateien gesammelt und geladen werden. Kaum gesagt als getan.
Der Code war fertig, Register und Regionen wurden hinzugefügt und erkannt, aber an den Stellen, an denen die Bibliotheksfunktionen und BIOS-Funktionen aufgerufen wurden, gab es immer noch eine große leere Stelle. Und leider hatte Hydra keine FLIRT
Unterstützung. Wenn nicht, fügen wir hinzu.
Das Format der FLIRT
Signaturen ist bekannt und in der Datei pat.txt
, die sich im Ida SDK befindet. Ida hat auch ein Dienstprogramm zum Erstellen dieser Signaturen speziell aus den Playstation
Bibliotheksdateien und heißt: ppsx
. Ich habe das SDK für den PsyQ Playstation Development Kit
namens PsyQ Playstation Development Kit
heruntergeladen, dort lib
Dateien gefunden und versucht, zumindest einige Signaturen daraus zu erstellen - erfolgreich. Es stellt sich ein kleiner Text heraus, in dem jede Zeile ein bestimmtes Format hat. Es bleibt noch Code zu schreiben, der diese Zeilen analysiert und auf den Code anwendet.

Patparser
Da jede Zeile ein bestimmtes Format hat, wäre es logisch, einen regulären Ausdruck zu schreiben. Es stellte sich so heraus:
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})+)?$");
Um dann in der Liste der Module den Versatz, den Typ und den Funktionsnamen separat hervorzuheben, schreiben wir einen separaten regulären Ausdruck:
private static final Pattern modulePat = Pattern.compile("([:\\^][0-9A-F]{4}@?) ([\\.\\w]+) ");
Lassen Sie uns nun die Komponenten jeder Signatur separat durchgehen:
- Zuerst kommt die hexadezimale Folge von Bytes ( 0-9A-F ), von denen einige beliebig sein können (Punktzeichen "."). Daher erstellen wir eine Klasse, die eine solche Sequenz speichert. Ich nannte es
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); } }
- Die Länge des Blocks, aus dem
CRC16
berechnet wird. CRC16
, der ein eigenes Polynom verwendet ( 0x8408
):
Zählcode 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; }
- Die Gesamtlänge des "Moduls" in Bytes.
- Liste der globalen Namen (was wir brauchen).
- Liste der Links zu anderen Namen (ebenfalls erforderlich).
- Schwanzbytes.
Jeder Name im Modul hat einen bestimmten Typ und Versatz relativ zum Anfang. Ein Typ kann je nach Typ durch eines der Zeichen ::, ^, @ angegeben werden:
- " : NAME ": globaler Name. Wegen solcher Namen habe ich alles angefangen;
- " : NAME @ ": lokaler Name / Bezeichnung. Es kann nicht angezeigt werden, aber lassen Sie es sein;
- " ^ NAME ": Link zum Namen.
Einerseits ist alles einfach, aber ein Link kann leicht kein Verweis auf eine Funktion sein (und dementsprechend wird der Sprung relativ sein), sondern auf eine globale Variable. Was ist das Problem? Und es ist so, dass Sie in PSX nicht ein ganzes DWORD
mit einer Anweisung in das Register schieben können. Laden Sie es dazu in Form von Hälften herunter. Tatsache ist, dass in MIPS
Befehlsgröße auf vier Bytes begrenzt ist. Und anscheinend müssen Sie nur eine Hälfte aus einer Anweisung herausholen und dann die nächste zerlegen - und eine zweite Hälfte erhalten. Aber nicht so einfach. Die erste Hälfte kann Anweisungen 5 zurück geladen werden, und der Link im Modul wird erst nach dem Laden der zweiten Hälfte gegeben. Ich musste einen ausgeklügelten Parser schreiben (wahrscheinlich kann er geändert werden).
Als Ergebnis erstellen wir eine enum
für drei Arten von Namen:
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"; } } }
Schreiben wir einen Code, der hexadezimale Textsequenzen und Punkte in 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; }
Sie können sich bereits eine Klasse vorstellen, die Informationen zu jeder einzelnen Funktion speichert: den Namen der Funktion, den Offset im Modul und den Typ:
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; } }
Und zum Schluss: eine Klasse, die alles speichert, was in jeder Zeile der pat
Datei angegeben ist, dh: Bytes, crc, eine Liste von Namen mit Offsets:
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; } }
Nun die Hauptsache: Wir schreiben Code, um all diese Klassen zu erstellen:
Analysieren einer Zeile einer Pat-Datei 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; }
Analysieren aller Pat-Dateizeilen 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); } }
Der Code zum Erstellen der Funktion, bei der eine der Signaturen erkannt wurde:
Funktionserstellung 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); } }
Wie bereits erwähnt, ist es am schwierigsten, einen Link zu einem anderen Namen / einer anderen Variablen zu zählen (möglicherweise muss der Code verbessert werden):
Link Count 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); } }
Und der letzte Schliff - wenden Sie die Unterschriften an:
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); } } }
Hier können Sie über eine interessante Funktion sprechen: findBytes()
. Damit können Sie nach bestimmten Bytesequenzen mit den angegebenen Bitmasken für jedes Byte suchen. Die Methode heißt folgendermaßen:
Address addr = program.getMemory().findBytes(startAddr, endAddr, bytes, masks, forward, TaskMonitor.DUMMY);
Infolgedessen wird die Adresse zurückgegeben, von der aus die Bytes beginnen, oder null
.
Einen Analysator schreiben
Lassen Sie es uns wunderbar machen und wir werden keine Signaturen verwenden, wenn wir nicht möchten, aber den Benutzer diesen Schritt auswählen lassen. Dazu müssen Sie Ihren eigenen Code-Analysator schreiben (Sie können die in dieser Liste sehen - das ist alles, ja):

Um in diese Liste einzusteigen, müssen Sie von der AbstractAnalyzer
Klasse erben und einige Methoden überschreiben:
- Konstruktor. Es muss den Konstruktor der Basisklasse mit dem Namen, der Beschreibung des Analysators und seinem Typ aufrufen (dazu später mehr). Für mich sieht es ungefähr so aus:
public PsxAnalyzer() { super("PSYQ Signatures", "PSX signatures applier", AnalyzerType.INSTRUCTION_ANALYZER); }
getDefaultEnablement()
. Legt fest, ob unser Analysegerät immer verfügbar ist oder nur, wenn bestimmte Bedingungen erfüllt sind (z. B. wenn unser Lader verwendet wird).canAnalyze()
. Ist es möglich, diesen Analysator überhaupt für eine herunterladbare Binärdatei zu verwenden?
Die Absätze 2 und 3 können grundsätzlich durch eine einzige Funktion überprüft werden:
public static boolean isPsxLoader(Program program) { return program.getExecutableFormat().equalsIgnoreCase(PsxLoader.PSX_LOADER); }
Wobei PsxLoader.PSX_LOADER
den Namen des Bootloaders speichert und zuvor darin definiert wurde.
Insgesamt haben wir:
@Override public boolean getDefaultEnablement(Program program) { return isPsxLoader(program); } @Override public boolean canAnalyze(Program program) { return isPsxLoader(program); }
registerOptions()
. Es ist überhaupt nicht notwendig, diese Methode neu zu definieren. Wenn wir den Benutzer jedoch vor der Analyse nach etwas fragen müssen, z. B. nach dem Pfad zur Pat-Datei, ist es am besten, dies in dieser Methode zu tun. Wir bekommen:
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"); }
Hier ist es notwendig zu klären. Die statische Methode getModuleDataFile()
der Application
Klasse gibt den vollständigen Pfad zur Datei im data
, das sich im Baum unseres Moduls befindet, und kann alle erforderlichen Dateien speichern, auf die wir später verweisen möchten.
Nun, die registerOption()
-Methode registerOption()
eine Option mit dem in OPTION_NAME
angegebenen OPTION_NAME
, dem Typ File
( OPTION_NAME
der Benutzer kann die Datei über ein reguläres Dialogfeld auswählen), dem Standardwert und der Beschreibung.
Weiter. Weil Wir haben später keine normale Möglichkeit, auf die registrierte Option zu verweisen. Wir müssen die optionsChanged()
-Methode neu definieren:
@Override public void optionsChanged(Options options, Program program) { super.optionsChanged(options, program); file = options.getFile(OPTION_NAME, file); }
Hier aktualisieren wir einfach die globale Variable entsprechend dem neuen Wert.
Die added()
Methode. Nun die Hauptsache: die Methode, die beim Start des Analysators aufgerufen wird. Darin erhalten wir eine Liste der Adressen, die für die Analyse verfügbar sind, aber wir benötigen nur diejenigen, die Code enthalten. Daher müssen Sie filtern. Endgültiger Code:
Added () Methode @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; }
Hier gehen wir die Liste der ausführbaren Adressen durch und versuchen, dort Signaturen anzuwenden.

Schlussfolgerungen und das Ende
Wie alles. Tatsächlich gibt es hier nichts Super Kompliziertes. Es gibt Beispiele, die Community ist lebhaft, Sie können sicher fragen, was beim Schreiben des Codes nicht klar ist. Fazit: Ein funktionierender Bootloader und Analysator für ausführbare Playstation 1
Dateien.

Alle Quellcodes finden Sie hier: ghidra_psx_ldr
Veröffentlichungen hier: Veröffentlichungen