Teil 1: PPO für Cybersicherheit verstehen – Eine Reise durch maschinelles Lernen und ethisches Hacking

Eine Erkundung, wie man einer KI beibringt, wie ein Penetrationstester zu denken

Eine Reise durch maschinelles Lernen und ethisches Hacking

Einleitung: Wenn KI auf Cybersicherheit trifft

Wenn du das hier liest, fragst du dich vielleicht: Können wir einem Computer wirklich das Hacken beibringen? Natürlich nicht im böswilligen Sinne, sondern so, wie ethische Hacker arbeiten, um Systeme sicherer zu machen. Genau das versucht dieses Projekt, und ich will ehrlich sein – es ist faszinierend und am Anfang auch etwas überwältigend.

Das Projekt, das wir uns ansehen, verwendet etwas, das sich Proximal Policy Optimization (PPO) nennt, um Sprachmodelle darauf zu trainieren, Penetrationstests an einer verwundbaren Webanwendung namens OWASP Juice Shop durchzuführen (Link: https://owasp.org/www-project-juice-shop/). Keine Sorge, falls diese Begriffe kompliziert klingen – wir gehen alles Schritt für Schritt gemeinsam durch.

Was passiert hier eigentlich?

Das große Ganze (einfach erklärt)

Stell dir vor, du bringst einem Schüler bei, ein Cybersicherheitsexperte zu werden. Du würdest:

  1. Ihm verwundbare Systeme zeigen
  2. Erklären, welche Angriffe er versuchen soll
  3. Ihm Feedback geben, wenn er Erfolg hat oder scheitert
  4. Ihn üben lassen, bis er besser wird

Genau das macht dieses Projekt im Grunde auch, nur eben mit einer KI:

🤖 KI-Modell (Qwen) ← Schüler
🕸️ Juice Shop ← Übungslabor
🎯 PPO-Algorithmus ← Lehrmethode
📊 Belohnungen ← Noten/Feedback
Grundidee von PPO

Das Kernkonzept von Proximal Policy Optimization im Cybersicherheits-Training

Der Datensatz: Echte Angriffe, echte Ergebnisse

Das Herzstück dieses Systems ist ein Datensatz mit 240 echten Penetrationstest-Versuchen gegen den Juice Shop. Jeder Eintrag sieht (vereinfacht) so aus:

{
  "state": {
    "user_id": 51,
    "auth": "Yes", 
    "headers": {"Authorization": "Bearer token..."}
  },
  "action": {
    "description": "SQL-Injection – Benutzerauflistung",
    "difficulty": 2
  },
  "reward": 16,
  "success": true
}

Was mich beim ersten Blick auf die Daten beeindruckt hat, ist, wie menschlich sie sich anfühlen. Jeder Datensatz steht für einen echten Moment, in dem jemand versucht hat, eine Schwachstelle zu finden – manchmal erfolgreich, manchmal nicht. Der Datensatz hat eine Erfolgsquote von 2,9 % mit einer durchschnittlichen Belohnung von 18,7 Punkten pro Versuch. Das spiegelt die Realität von Penetrationstests wider, bei denen die meisten Versuche scheitern, die Erfolge aber umso wertvoller sind.

Die technische Architektur: Eine Aufschlüsselung

1. Der Agent (JuiceShopAgent)

Das sind quasi die „Hände“ unseres Systems – dieser Teil interagiert tatsächlich mit der verwundbaren Webanwendung:

  • Registriert neue Benutzer für jede Testsitzung
  • Führt über 25 verschiedene Angriffsarten aus (SQL-Injection, XSS, Directory Traversal usw.)
  • Erfasst den Zustand der Anwendung vor und nach jedem Angriff
  • Berechnet Belohnungen basierend auf Erfolg und Schwere der Schwachstelle

Der Agent kann anspruchsvolle Angriffe durchführen wie:

  • UNION SELECT SQL-Injections auf Such-Endpunkte
  • Admin-Login-Umgehungen mit admin@juice-sh.op'--
  • Directory Traversal mit kodierten Pfaden wie %25252e%25252e%25252f
  • Ausnutzung von Geschäftslogik (Bestellungen mit negativer Menge)

2. Das Gehirn (PPO-Training)

Hier wird es richtig interessant. Das System verwendet Proximal Policy Optimization, eine Art des bestärkenden Lernens (Reinforcement Learning). Stell es dir wie eine behutsame Methode vor, der KI etwas beizubringen:

Traditionelles Training: „Hier ist die richtige Antwort, lerne sie auswendig.“
PPO-Training: „Probiere Dinge aus, erhalte Feedback und verbessere dich schrittweise.“

Der PPO-Algorithmus ist besonders gut, weil er:

  • Keine drastischen Änderungen vornimmt, die den Lernprozess stören könnten
  • Ein Gleichgewicht zwischen Erkundung und Nutzung findet (neue Dinge ausprobieren vs. das anwenden, was funktioniert)
  • Wertfunktionen nutzt, um den langfristigen Erfolg vorherzusagen
Diagramm: PPO-Agent und Belohnungen

Wie der PPO-Agent von Belohnungen in der Cybersicherheitsumgebung lernt

3. Das Modell (Qwen-Sprachmodelle)

Das Projekt unterstützt mehrere Qwen-Modelle:

  • Qwen2.5-1.5B-Instruct: 6-8 GB VRAM, gut zum Experimentieren
  • Qwen2.5-3B-Instruct: 10-12 GB VRAM, bessere Qualität
  • Qwen2.5-7B-Instruct: 16-20 GB VRAM, höchste Qualität

Jedes Modell kann auf zwei Arten trainiert werden:

  • Volles Fine-Tuning: Aktualisiert alle Parameter des Modells (bessere Genauigkeit, mehr Speicherbedarf)
  • LoRA (Low-Rank Adaptation): Aktualisiert nur kleine Adapter-Schichten (schneller, weniger Speicherbedarf)

Der Lernprozess: Wie es wirklich funktioniert

Reward Engineering: Das Herzstück des Lernens

Wir haben ein einfaches Belohnungssystem implementiert:

def calculate_smart_reward(challenges_before, challenges_after, 
                          status_code, response_text, difficulty):
    # 'calculate_smart_reward' berechnet eine intelligente Belohnung
    reward = 0
    
    # Große Belohnungen für das tatsächliche Lösen von Herausforderungen
    new_solved = challenges_after - challenges_before
    if new_solved:
        reward = len(new_solved) * difficulty * 20
    
    # Kleinere Belohnungen für vielversprechende Versuche
    if status_code == 200: reward += 10
    if 'admin' in response_text.lower(): reward += 8
    if 'sql' in response_text.lower(): reward += 6
    
    return reward

Das bedeutet, die KI wird belohnt für:

  • Das tatsächliche Lösen von Herausforderungen (große Belohnungen)
  • Das Erhalten interessanter Antworten (mittlere Belohnungen)
  • Das Durchführen vernünftiger Versuche (kleine Belohnungen)

Trainingsvarianten: Verschiedene Ansätze

Das Projekt umfasst mehrere Trainingsstrategien:

train_ppo.py: Der Standardansatz

  • Standard-PPO mit guten Standardeinstellungen
  • Funktioniert für die meisten Anwendungsfälle
  • 5 Epochen, ausgewogene Parameter

train_stable.py: Der vorsichtige Ansatz

  • Konservative Lernraten
  • Zusätzliche Stabilitätsprüfungen
  • Gradient-Clipping und Volatilitätsüberwachung
  • Am besten für ein konsistentes, zuverlässiges Training

train_long.py: Der gründliche Ansatz

  • 10+ Epochen mit Anpassung der Lernrate
  • Frühes Stoppen bei Erreichen des Ziels
  • Umfassendes Speichern von Checkpoints
  • Am besten für Modelle in Produktionsqualität

Die Ergebnisse: Was wird tatsächlich gelernt?

Nach dem Training zeigen die Modelle interessante Verhaltensänderungen:

Vor dem Training (Allgemeine KI-Antwort):

Anfrage: „Nächste Aktion für den Penetrationstest?“
Antwort: „Ich kann Ihnen mit allgemeinen Informationen zur Cybersicherheit helfen ...“

Nach dem Training (Gezieltes Penetration-Testing):

Anfrage: „Benutzer 51, Auth: Ja, Vorher: SQL-Injection. Nächste Aktion?“
Antwort: „Versuche einen XSS-Angriff auf den Suchparameter oder prüfe Admin-Endpunkte.“

Das Modell lernt:

  • Muster von Schwachstellen im Anwendungszustand zu erkennen
  • Spezifische technische Angriffe anstelle von allgemeinen Ratschlägen vorzuschlagen
  • Angriffe logisch zu verketten (z. B. nach einer SQL-Injection eine Rechteausweitung versuchen)
  • Sich auf hochwertige Ziele zu konzentrieren (Admin-Endpunkte, sensible Daten)

Herausforderungen und Grenzen

Was gut funktioniert

  • Konsistentes Lernen: Die Modelle verbessern sich zuverlässig über die Trainingsepochen hinweg
  • Technische Genauigkeit: Die gelernten Angriffe sind valide Penetrationstest-Techniken
  • Kontextbewusstsein: Die Modelle berücksichtigen den Zustand der Anwendung bei ihren Vorschlägen

Was noch schwierig ist

  • Niedrige Erfolgsquote: Selbst trainierte Modelle lösen Herausforderungen nicht oft
  • Rechenaufwand: Volles Fine-Tuning erfordert erhebliche GPU-Ressourcen
  • Generalisierung: Die Modelle sind auf den Juice Shop spezialisiert und lassen sich möglicherweise nicht auf andere Anwendungen übertragen

Warum das wichtig ist

Für die Cybersicherheit

Dieser Ansatz könnte eines Tages helfen:

  • Penetrationstests für gängige Schwachstellenmuster zu automatisieren
  • Sicherheitsexperten mit KI-gestütztem Lernen zu schulen
  • Eine kontinuierliche Sicherheitsbewertung von Webanwendungen durchzuführen

Für die KI-Forschung

Das Projekt demonstriert:

  • Praktisches bestärkendes Lernen für reale Sicherheitsaufgaben
  • Die Integration von Sprachmodellen in interaktive Umgebungen
  • Reward Engineering für komplexe Bereiche mit seltenen Belohnungen

Wie eine KI lernt zu hacken (Die Details der Implementierung)

Ein Blick auf die Algorithmen und den Code, die dieses System zum Laufen bringen

Einleitung: Ein Blick unter die Haube

Lass uns gemeinsam die wichtigsten Bausteine durchgehen.

1. Die Datengenerierungs-Engine: JuiceShopAgent

Die Grundlage: Einrichtung der Testumgebung

Die JuiceShopAgent-Klasse ist das Arbeitstier, das die Penetrationstests tatsächlich durchführt. So wird sie eingerichtet:

class JuiceShopAgent:
    def __init__(self):
        self.session = requests.Session()
        self.session.timeout = 15
        self.current_user_id = None
        self.basket_id = None
        self.admin_email = "admin@juice-sh.op"

Das mag einfach aussehen, aber hier steckt System dahinter. Jede Testsitzung erhält:

  • Eine eigene HTTP-Sitzung (für Cookies und Zustandsverwaltung)
  • Vernünftige Zeitüberschreitungen (15 Sekunden – genug für Antworten, aber nicht zu lang bei Hängern)
  • Nachverfolgung des Benutzerkontexts (user_id und basket_id für zustandsabhängige Angriffe)

Intelligente Benutzerregistrierung: Frische Testkontexte schaffen

Ein cleverer Aspekt ist, wie für jeden Test neue Benutzer erstellt werden:

def register_and_login(self) -> Tuple[str, str]:
    """Neuen Benutzer registrieren und anmelden"""
    email = f"user{uuid.uuid4().hex[:8]}@juice-sh.op"
    password = f"Pass{random.randint(1000, 9999)}!"
    
    try:
        # Mit zufälliger Sicherheitsfrage registrieren
        register_data = {
            "email": email,
            "password": password,
            "passwordRepeat": password,
            "securityQuestion": {
                "id": random.randint(1, 12),
                "answer": f"answer{random.randint(100, 999)}"
            }
        }
        res = self.session.post(f"{BASE_URL}/api/Users", json=register_data)
        
        if res.status_code == 201:
            # Anmelden und Authentifizierungstoken erhalten
            login_res = self.session.post(
                f"{BASE_URL}/rest/user/login", 
                json={"email": email, "password": password}
            )
            
            if login_res.status_code == 200:
                token = login_res.json()['authentication']['token']
                self.session.headers.update({"Authorization": f"Bearer {token}"})
                return email, password

Was ich an diesem Ansatz schätze:

  • Einzigartige Identitäten: Jeder Testlauf erhält einen frischen Benutzerkontext
  • Realistische Anmeldedaten: Passwörter folgen gängigen Mustern
  • Korrekter Authentifizierungsablauf: Anmelden → Token erhalten → Header aktualisieren
  • Fehlerbehandlung: Sauberes Scheitern, wenn die Registrierung nicht funktioniert

Das Angriffsarsenal: Bewährte Exploits für Schwachstellen

Das Herzstück des Systems ist get_proven_attacks(), das eine Liste von Angriffen zurückgibt, die tatsächlich gegen den Juice Shop funktionieren:

def get_proven_attacks(self) -> List[Tuple[str, callable, int]]:
    """Gibt Angriffe zurück, die nachweislich mit dem aktuellen Juice Shop funktionieren"""
    
    attacks = [
        # SQL-Injection-Angriffe
        ("SQL Injection - Such-Umgehung", 
         lambda: self.session.get(f"{BASE_URL}/rest/products/search?q=qwert%27))%20UNION%20SELECT%20id,%20email,%20password,%20%274%27,%20%275%27,%20%276%27,%20%277%27,%20%278%27,%20%279%27%20FROM%20Users--"), 2),
         
        ("SQL Injection - Login-Umgehung Admin", 
         lambda: self._admin_login_bypass(), 3),
         
        # Dateizugriffs-Angriffe
        ("Zugriff auf vertrauliches Dokument", 
         lambda: self.session.get(f"{BASE_URL}/ftp/acquisitions.md"), 1),
         
        ("Poison Null Byte-Angriff", 
         lambda: self.session.get(f"{BASE_URL}/ftp/eastere.gg%2500.md"), 3),
         
        # Fehler in der Geschäftslogik
        ("Bestellung mit negativer Menge", 
         lambda: self._negative_quantity_working(), 3),
    ]
    
    return attacks

Jeder Angriff ist strukturiert als:

  • Beschreibung: Ein für Menschen lesbarer Name
  • Funktion: Eine Lambda-Funktion oder Methode, die den Angriff ausführt
  • Schwierigkeitsgrad: Eine ganzzahlige Bewertung (1=leicht, 3=schwer)

Das intelligente Belohnungssystem: Lernen durch Feedback

Hier wird das System wirklich intelligent. Die Belohnungsberechnung sagt nicht nur „Erfolg“ oder „Misserfolg“, sondern gibt differenziertes Feedback:

def calculate_smart_reward(challenges_before: set, challenges_after: set, 
                          status_code: int, response_text: str, difficulty: int,
                          url: str) -> int:
    """Intelligente Belohnungsberechnung basierend auf tatsächlichen Schwachstellen"""
    new_solved = challenges_after - challenges_before
    reward = 0
    
    # Grundbelohnung für das Lösen von Herausforderungen (GROSSE Belohnungen)
    if new_solved:
        reward = len(new_solved) * difficulty * 20  # Bis zu 60 Punkte!
        logger.info(f"🎉 HERAUSFORDERUNG GELÖST: {list(new_solved)} - Belohnung: {reward}")
        return reward
    
    # Statusbasierte Belohnungen (mittlere Belohnungen)
    if status_code == 200:
        reward += 10
        
        # Inhaltsanalyse auf potenzielle Schwachstellen
        if response_text:
            content_lower = response_text.lower()
            
            # Indikatoren für SQL-Injection
            if any(indicator in content_lower for indicator in 
                   ['email', 'password', 'users', 'admin', 'syntax error']):
                reward += 15
                
            # Indikatoren für Dateizugriff
            if any(indicator in content_lower for indicator in 
                   ['markdown', 'acquisitions', 'legal', 'confidential']):
                reward += 12
    
    # Auch fehlgeschlagene Versuche können informativ sein
    elif status_code == 401: reward += 3  # Authentifizierung erforderlich – interessant!
    elif status_code == 403: reward += 5  # Verboten – wir haben etwas gefunden
    elif status_code >= 500: reward += 6  # Serverfehler geben Informationen preis
    
    return max(reward, 2)  # Immer eine kleine Belohnung für den Versuch geben

Diese Belohnungsstruktur lehrt die KI:

  • Große Erfolge verdienen große Belohnungen (Herausforderungen lösen = 20-60 Punkte)
  • Interessante Misserfolge sind wertvoll (Fehlermeldungen erhalten = 6-15 Punkte)
  • Schon Versuche zählen (mindestens 2 Punkte für jede Aktion)

Das Schöne liegt in der Inhaltsanalyse – das System erkennt, wenn der Antworttext für Schwachstellen relevante Schlüsselwörter enthält, auch wenn die Herausforderung nicht vollständig gelöst wurde.

2. Die Trainings-Engine: PPO-Implementierung

Datensatzvorbereitung: Von Rohdaten zu Trainingsbeispielen

Der Trainingsprozess beginnt damit, die rohen Penetrationstest-Daten in ein KI-freundliches Format umzuwandeln:

def build_dataset(tokenizer, data_path, split="train"):
    """Erstellt einen Datensatz für das Training"""
    ds = load_dataset("json", data_files=data_path, split=split)

    def create_prompt(sample):
        # Prompt für das Chat-Modell verbessern
        system_prompt = "Du bist ein erfahrener Cybersicherheits-Penetrationstester. Analysiere den aktuellen Zustand einer Webanwendung und schlage die nächste taktische Aktion vor, um Schwachstellen zu finden."
        
        state_info = json.dumps(sample['state'], indent=2)
        
        prompt = f"<|im_start|>system\n{system_prompt}<|im_end|>\n"
        prompt += f"<|im_start|>user\n"
        prompt += f"Aktueller Zustand der Webanwendung:\n```json\n{state_info}\n```\n\n"
        prompt += f"Was sollte die nächste Aktion des Penetrationstests sein? Gib einen spezifischen, umsetzbaren Schritt an.<|im_end|>\n"
        prompt += f"<|im_start|>assistant\n"
        
        return prompt

    def tokenize(sample):
        sample["query"] = create_prompt(sample)
        encoded = tokenizer(
            sample["query"],
            padding="max_length",
            truncation=True,
            max_length=512,
            return_tensors="pt"
        )
        sample["input_ids"] = encoded["input_ids"].squeeze()
        return sample

    ds = ds.map(tokenize, batched=False)
    ds.set_format(type="torch")
    return ds

Das Prompt-Engineering ist hier entscheidend:

  • Klare Rollendefinition: „Du bist ein erfahrener Cybersicherheits-Penetrationstester“
  • Kontextbereitstellung: JSON-Zustand der Webanwendung
  • Spezifische Anweisung: „Gib einen spezifischen, umsetzbaren Schritt an“
  • Korrekte Formatierung: Verwendung des Qwen-Chat-Templates mit <|im_start|>-Tokens

PPO-Konfiguration: Die Lernparameter

In der PPO-Konfiguration geschieht die Magie – diese Parameter steuern, wie die KI lernt:

ppo_config = PPOConfig(
    model_name=args.model_name,
    learning_rate=1e-6,          # Konservative Lernrate
    batch_size=8,                # 8 Beispiele auf einmal verarbeiten
    mini_batch_size=2,           # PPO-Updates auf 2 Beispiele gleichzeitig
    gradient_accumulation_steps=4, # Effektive Batch-Größe = 8
    
    # PPO-Hyperparameter
    ppo_epochs=6,                # 6 Optimierungsschritte pro Batch
    gamma=0.99,                  # Abzinsungsfaktor für zukünftige Belohnungen
    lam=0.95,                    # GAE-Lambda für die Vorteilberechnung
    cliprange=0.1,               # Policy-Updates beschneiden (konservativ!)
    cliprange_value=0.1,         # Wertfunktion-Updates beschneiden
    vf_coef=0.2,                 # Gewichtung des Verlusts der Wertfunktion
    max_grad_norm=1.0,           # Gradient-Clipping
    target_kl=0.05,              # KL-Divergenz-Ziel (sehr konservativ)
    whiten_rewards=True,         # Belohnungen normalisieren
)

Ich möchte einige wichtige Entscheidungen hervorheben:

  • Konservatives Clipping (0.1): Verhindert, dass sich das Modell zu drastisch ändert
  • Niedrige Lernrate (1e-6): Langsames, stetiges Lernen
  • Reward Whitening: Normalisiert Belohnungen, damit das Modell nicht durch deren Größenordnung verwirrt wird

Die Trainingsschleife: Wo das Lernen stattfindet

Die zentrale Trainingsschleife ist der Ort, an dem die KI tatsächlich lernt:

for epoch in range(args.epochs):
    for batch in tqdm(ppo_trainer.dataloader, desc=f"Epoche {epoch + 1}"):
        query_tensors = batch["input_ids"]
        
        # Batch-Tensor in eine Liste umwandeln (PPO-Anforderung)
        if isinstance(query_tensors, torch.Tensor) and query_tensors.dim() == 2:
            query_tensors = [query_tensors[i] for i in range(query_tensors.size(0))]

        # Antworten vom aktuellen Modell generieren
        response_tensors = ppo_trainer.generate(
            query_tensors, 
            return_prompt=False, 
            **generation_kwargs
        )
        
        # Belohnungen aus dem ursprünglichen Datensatz holen
        rewards = []
        for i in range(len(query_tensors)):
            dataset_idx = (batch_count % len(dataset))
            reward_value = dataset[dataset_idx]["reward"]
            rewards.append(float(reward_value))
        
        reward_tensors = [torch.tensor(r, dtype=torch.float32) for r in rewards]

        # PPO-Optimierungsschritt
        stats = ppo_trainer.step(query_tensors, response_tensors, reward_tensors)
        
        # Fortschritt protokollieren
        batch_mean_reward = sum(rewards) / len(rewards)
        value_loss = stats.get('ppo/loss/value', 0)
        policy_loss = stats.get('ppo/loss/policy', 0)

Die Abfolge ist:

  1. Anfragen aus dem Datensatz erhalten
  2. Antworten mit dem aktuellen Modell generieren
  3. Belohnungen basierend auf den Antworten berechnen
  4. PPO-Update durchführen, um das Modell zu verbessern
  5. Statistiken protokollieren, um den Fortschritt zu verfolgen

Generierungsparameter: Die Kreativität der KI steuern

Die Generierungseinstellungen sind sorgfältig auf Penetrationstests abgestimmt:

generation_kwargs = {
    "min_length": -1,
    "top_k": 40,                 # Die 40 wahrscheinlichsten nächsten Tokens berücksichtigen
    "top_p": 0.85,               # Schwellenwert für Nucleus-Sampling
    "do_sample": True,           # Sampling aktivieren (nicht gierig)
    "temperature": 0.6,          # Niedriger = fokussiertere Antworten
    "pad_token_id": tokenizer.eos_token_id,
    "eos_token_id": tokenizer.eos_token_id,
    "max_new_tokens": 128,       # Vernünftige Antwortlänge
    "repetition_penalty": 1.05,  # Leichte Strafe für Wiederholungen
}

Diese Einstellungen balancieren:

  • Kreativität (Sampling aktiviert, vernünftige Temperatur)
  • Fokus (niedrigere Temperatur, Top-k-Filterung)
  • Qualität (Wiederholungsstrafe, Längenbegrenzung)

5. Wichtige Erkenntnisse aus dem Code und bewährte Praktiken

Philosophie der Fehlerbehandlung

Im gesamten Code findet sich ein konsistentes Muster zur Fehlerbehandlung:

try:
    result = risky_operation()
    if result.status_code == 200:
        return process_success(result)
except Exception as e:
    logger.debug(f"Operation fehlgeschlagen: {e}")
    # Sinnvollen Standardwert zurückgeben, statt abzustürzen
    mock_response = requests.Response()
    mock_response.status_code = 500
    return mock_response

Dieser Ansatz:

  • Protokolliert Probleme, ohne die Ausführung zu stoppen
  • Liefert Schein-Antworten, um das Training am Laufen zu halten
  • Verhält sich bei Ausfällen von Komponenten kontrolliert

Speicherverwaltung

Der Code geht sorgfältig mit dem GPU-Speicher um:

# Geeignete Datentypen verwenden
model = AutoModelForCausalLMWithValueHead.from_pretrained(
    model_name,
    torch_dtype=torch.bfloat16,  # Halbe Genauigkeit spart Speicher
    device_map="auto",           # Automatische Verteilung auf GPU/CPU
)

# Gradient-Checkpointing aktivieren
ppo_config = PPOConfig(
    gradient_checkpointing=True,  # Tausche Rechenleistung gegen Speicher
    # ...
)

Ende von Teil 1

Kim Pham - 19.06.2025