Dies ist bereits der dritte Teil meiner Artikel-Reihe über AMPLEC, mein jüngstes Projekt. Die vorherigen Folgen und weitere Details findest du gesammelt hier.
In diesem Beitrag dreht sich alles um das Herzstück des Projekts: den Weg, wie Malware-Analyse-Daten so aufbereitet werden, dass ein LLM sie zuverlässig abfragen kann.
Diesen Vorgang nenne ich „Naturalisation“, weil wir streng strukturierte JSON-Daten in etwas überführen, das sich möglichst wie natürliche Sprache liest – ohne die Reproduzierbarkeit der Struktur zu verlieren.
Das Problem #
Schon in den ersten Experimenten lief ich in meine erste echte Sackgasse.
Kleine Modelle, große Kopfschmerzen #
Für erste Machbarkeits-Checks nutzte ich vor allem ChatGPT (GPT-4o). Dem macht es bekanntermaßen wenig aus, riesige JSON-Blöcke fehlerfrei zu durchsuchen. Als ich dann auf unterschiedliche Open-Source-Modelle umgeschwenkt bin, wurde mir meine eigene Unerfahrenheit schmerzlich bewusst: Gerade die kleineren Varianten – etwa Llama 3.2-3b oder Llama 3.1-8b – halluzinierten munter vor sich hin, selbst bei simplen Extraction-Aufgaben aus einem winzigen 10-zeiligen JSON.
Einer der harten Projekt-Requirements lautet allerdings: Datensicherheit und komplett lokaler Betrieb. Sprich, es musste mit Modellen funktionieren, die man im Zweifel auf einem Laptop ohne dedizierte GPU laufen lassen kann.
Warum LLMs sich mit JSON schwertun #
Vorweg: Es gibt durchaus LLMs, die strukturierte Daten elegant handhaben – nur sind die meist deutlich größer oder speziell dafür optimiert und sprengen damit mein Hardware-Budget.
Tokenisierung #
Der Knackpunkt liegt in der Funktionsweise von LLMs beim Einlesen von Text. In der sogenannten Tokenisierung zerlegt das Modell Worte in Tokens (siehe Abbildung unten).
Das läuft so ab, dass das Modell ein festes Wörterbuch an Tokens bzw. Subwörtern besitzt – im Grunde eine reguläre Sprache –, die dann in Zahlenwerte übersetzt werden, mit denen das Netz rechnen kann. Ein verbreitetes Verfahren dafür ist Byte Pair Encoding (BPE): Es startet mit einem Grundalphabet von Bytes und führt dann häufig auftretende Zeichenfolgen zusammen – erst „i“ + „n“ zu „in“, dann z. B. „g“ dazu für „ing“. Dieser Prozess läuft iterativ, bis die definierte Wörterbuchgröße erreicht ist. Moderne LLMs haben obendrein etliche ganze englische Wörter fix im Vokabular, wie du im Screenshot erkennst. Komplexere Wörter können dagegen aus vielen Subtokens bestehen, wie das zweite Beispiel zeigt.
Achte auf dich selbst! #
Kommt nun JSON ins Spiel, erzeugen Einrückungen, Anführungszeichen, geschweifte Klammern usw. unverhältnismäßig viele Tokens für relativ wenig inhaltliche Information.
Jedem Token ordnet das Modell einen mehrdimensionalen Vektor zu – in dieser „Sprache“ denkt das LLM tatsächlich.
LLMs fußen im Kern auf dem Konzept der Self-Attention: Für jedes Token wird bewertet, wie wichtig es für alle anderen Tokens ist. So erkennt das Netz Kontext und Beziehungen zwischen Worten und Fakten.
Kontext-Erhalt in JSON #
Nachdem wir nun wissen, wie Tokens gebildet werden, wie der Verarbeitungsprozess läuft und warum JSON pro Token weniger reine Information trägt, schauen wir uns einmal den Optimalfall beim Einlesen strukturierter Daten an:
{
"sha256": "de34da69219e4da77015469778509fc15cb412a8f3c808124eed7a7725c519a0",
"family": "LummaStealer"
}
In dieser Mini-Struktur liegen der sha256-Hash und das family-Tag direkt nebeneinander. Das Modell kann die Verbindung fast schon „im Vorbeigehen“ herstellen.
Jetzt kommt allerdings ein deutlich komplexeres Beispiel:
{
"sha256":"de34da69219e4da77015469778509fc15cb412a8f3c808124eed7a7725c519a0",
"urls": [
"fishy-business.com",
"absolutely-not-malware.com"
],
"ips": [
"192.168.0.1",
"127.0.0.1"
],
"signatures":[
{
"name": "Commercial obfuscation software .NET Reactor by Eziriz",
"score": 9,
"indicators": []
},
{
"label": "fw_startup_file",
"name": "Drops startup file",
"score": 7,
"indicators": [
{
"ioc": "C:\\Users\\Admin\\AppData\\Roaming\\Microsoft\\Windows\\Start Menu\\Programs\\Startup\\ilsucsfth.vbs",
"description": "File created",
"procid": 30
}
]
},
{
"name": "Suspicious use of WriteProcessMemory",
"indicators": [
{
"description": "PID 2424 wrote to memory of 5060",
"pid": 2424,
"procid": 30,
"procid_target": 31
},
{
"description": "PID 2424 wrote to memory of 5060",
"pid": 2424,
"procid": 30,
"procid_target": 31
},
{
"description": "PID 2424 wrote to memory of 5060",
"pid": 2424,
"procid": 30,
"procid_target": 31
},
{
"description": "PID 2424 wrote to memory of 5060",
"pid": 2424,
"procid": 30,
"procid_target": 31
}
]
}
],
"family": "LummaStealer"
}
Hier wird das Grundproblem sofort sichtbar: Die Beziehung zwischen sha256 und family verwässert, weil dazwischen Unmengen an Tokens liegen. Gerade die kleinen, nicht spezialisierten LLMs, die ich testen wollte, stolperten darüber heftig.
Die Idee #
Mein Dilemma hatte zwei Seiten:
- Das Modell beziehungsweise dessen Kontextfenster durfte nicht mit irrelevanten Details überflutet werden.
- Die Eingabe musste in einer Form vorliegen, die das LLM intuitiver versteht als reines JSON. Man braucht kein Einstein zu sein, um zu ahnen, dass alles, was näher an natürlicher Sprache liegt, ein Sprachmodell leichter verdauen kann – immerhin wurde es überwiegend mit natürlichem Text trainiert. So entstand die Idee, die Daten sprachlich zu „verkleiden“.
Die Lösung #
Das Ergebnis ist ein Prozess, den ich „Naturalisation“ getauft habe. Bevor wir in die Details springen, erst mal meine Designziele:
- Reproduzierbarkeit – Die gleiche JSON-Eingabe liefert immer dieselbe Ausgabe.
- Sichere Defaults – Man kann einzelne Schlüssel oder Strukturen speziell behandeln, aber jeder beliebige JSON-Block muss wenigstens ein brauchbares Ergebnis erzeugen.
- Leicht erweiterbar – Der Workflow soll sich iterativ verbessern lassen, ohne dass man alles neu schreiben muss.
So funktioniert der Algorithmus #
- Rekursiver Abstieg Die Routine läuft jedes Dictionary – inklusive aller Unter-Dictionaries – nacheinander ab. Auf jeder Ebene sucht sie nach einem Primär-Identifier, z. B. nach einem SHA-256-Hash, und baut daraus den Satzanfang.
- Überschrift / Headline bauen
- Taucht ein bekannter Identifier wie
"sha256"auf, startet der Satz etwa so:The sample with SHA-256 <hash> - Bei unbekannten Schlüsseln wird stattdessen der Platzhalter
has <key_name>genutzt.
- Taucht ein bekannter Identifier wie
- Rekursive Erweiterung
Alle restlichen Schlüssel werden Stück für Stück angehängt – oder, falls nötig, unterhalb der aktuellen Überschrift verschachtelt. Beim einfachen Beispiel
passiert Folgendes:
{ "sha256": "de34da69219e4da77015469778509fc15cb412a8f3c808124eed7a7725c519a0", "family": "LummaStealer" }- Der Hash wird als Identifier erkannt und liefert den eröffnenden Satzteil.
- Danach stößt der Algorithmus auf
"family"und verwendet die Standardregelhas family. - Am Ende werden beide Fragmente mitsamt Wert zusammengeführt, sodass dieser Satz entsteht:
The sample with SHA-256 de34da69219e4da77015469778509fc15cb412a8f3c808124eed7a7725c519a0 has family LummaStealer.
Fazit #
Jetzt weißt du, warum ich die Naturalisation überhaupt erfunden habe, in welchen Fällen dir dieselben Stolpersteine begegnen können – und vor allem, wie du sie umgehen kannst.