WhatsApp Web nicht entschlüsseln (und trotzdem gewinnen)
Auf dieser Seite
Oder: Die Geschichte von 6.000 F-Bomben und einem Sieg aus Versehen
Ich habe heute Nacht ungefähr 6.000 Mal “fuck” gesagt.
Plus minus. Eins davon war wahrscheinlich ein Freudenschrei, der Rest kam vom Reverse Engineering der Datenspeicherung von WhatsApp Web. Als ich aufgewacht bin, hatte ich das nicht vor. Aber Neugier ist eine verdammt harte Droge.
Heute Nacht habe ich zum ersten Mal erfahren, dass WhatsApp das Signal-Protokoll benutzt.
Super! Und: fuck.
Spulen wir zurück.
Das ursprüngliche Ziel
Ich habe an einer neuen Integration für Jamie gearbeitet, meinen persönlichen KI-Assistenten. Das Ziel war simpel: WhatsApp-Nachrichten einlesen, zeitliche Hinweise erkennen (“lass uns Montag um 3 Uhr nachmittags machen”) und sie automatisch in deinen Kalender eintragen.
Einfach, oder?
Hahahahaha.
Teil 1: Unwissenheit ist ein Segen (IndexedDB)
Ich habe im Browser angefangen. WhatsApp Web speichert Daten in IndexedDB, und ich dachte, ich werfe da einfach mal einen Blick rein.
async function exploreWhatsAppDatabases() {
const databases = await indexedDB.databases();
console.log('Found databases:', databases);
// ['model-storage', 'signal-storage', 'wawc']
}
Bis hierhin lief es gut.
- model-storage → enthält die Nachrichten
- signal-storage → enthält die Krypto-Schlüssel (oh nein)
- wawc → Metadaten
Ich habe ein paar Beispieldaten aus model-storage gezogen:
{
id: "false_AAAAHHHHHHHHHHHHHHH@g.us_OBFUSCATINGFOROBVIOUSREASONS_00000000000@c.us",
msgRowOpaqueData: {
_data: ArrayBuffer(160),
iv: Uint8Array(16),
_keyId: 1,
_scheme: 1
}
}
Alles ist verschlüsselt.
Jede. Einzelne. Nachricht.
Und nicht nur verschlüsselt, sondern ordentlich verschlüsselt. Hohe Entropie, dynamische Größe, einheitliches Schema.
Hinweis: IndexedDB spielt in der fertigen Lösung trotzdem eine Rolle. Ich nutze es, um den Sync-Status zu verfolgen, zu bestätigen, wann das Laden der Nachrichten wirklich abgeschlossen ist, und nie mit halben Daten zu arbeiten. Der Trick ist herauszufinden, wann sich der Client vollständig beruhigt hat.
Teil 2: Versuchen wir es trotzdem mit Brute Force
Wie jeder vernünftige Mensch ohne Respekt vor der eigenen Zeit habe ich versucht, die Nachrichten direkt zu entschlüsseln.
Schritt 1: Den Master Key holen
const masterKey = localStorage.getItem('WebEncKeySalt');
// A 172-byte base64 string
Schritt 2: Ihn in AES-GCM verwenden
async function attemptDirectDecryption(message, masterKey) {
try {
const keyBytes = atob(masterKey);
const keyArray = new Uint8Array(keyBytes.length);
for (let i = 0; i < keyBytes.length; i++) {
keyArray[i] = keyBytes.charCodeAt(i);
}
const aesKey = await crypto.subtle.importKey(
'raw',
keyArray.slice(0, 32),
{ name: 'AES-GCM' },
false,
['decrypt']
);
const decrypted = await crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv: message.msgRowOpaqueData.iv,
tagLength: 128,
},
aesKey,
message.msgRowOpaqueData._data
);
return new TextDecoder().decode(decrypted);
} catch (err) {
console.error('fuck:', err);
}
}
Ergebnis:
fuck: OperationError: AES-GCM decryption failed
Teil 3: Das Signal-Protokoll in 2 Stunden von Grund auf lernen (kein Ding)
Nach etwas Recherche habe ich verstanden, dass WhatsApp das Signal-Protokoll benutzt, komplett mit Double Ratchet, Chain Keys, Identity Keys, Prekeys, dem ganzen Programm.
Die Session Keys liegen in signal-storage, verteilt auf:
session-storeidentity-storeprekey-storesignal-meta-store
Ich habe die Session-Strukturen per Reverse Engineering nachgebaut:
{
address: "192432234885263@lid.0",
session: {
rootKey: Uint8Array(32),
sendChain: { chainKey: ... },
recvChains: [...]
}
}
Theoretisch ist das machbar.
Praktisch heißt einen funktionierenden Signal-Protocol-Client zu bauen und die Schlüsselableitung von WhatsApp nachzubilden, die Ende-zu-Ende-Verschlüsselung von Grund auf neu zu implementieren.
Natürlich habe ich also eine Stunde damit verschwendet, es zu versuchen.
Teil 4: Moment mal… Das DOM weiß Bescheid
Das war der Wendepunkt.
Wenn ich entschlüsselte Nachrichten im Browser sehen kann, dann hat der Browser sie entschlüsselt.
Auftritt DOM-Observer:
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
const el = node.querySelector('.copyable-text[data-pre-plain-text]');
const content = node.querySelector(
'._ao3e.selectable-text.copyable-text span'
);
if (el && content) {
console.log({
timestamp: el.getAttribute('data-pre-plain-text'),
message: content.textContent,
});
}
});
});
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
Und es hat funktioniert. Heilige Scheiße.
Entschlüsselte Nachrichten, einfach so im DOM.
<div
class="copyable-text"
data-pre-plain-text="[23:41, 06/08/2025] Will Hackett: "
>
<span class="_ao3e selectable-text copyable-text">
<span>Hey are you still good for tomorrow?</span>
</span>
</div>
Jetzt kommt der knifflige Teil: WhatsApp randomisiert seine Klassennamen. Typisch Facebook. Dazu kommen Unterschiede in der UI-Struktur je nach Sprache, und ein stabiles Targeting wird zur Riesenplage. Das zu umgehen hat sorgfältige Abstraktion, Fuzzy-Matching von Elementen und mehr als ein paar Experimente gebraucht.
Den genauen Ansatz verrate ich nicht. Nur so viel: Ich habe es so weit gelöst, dass ein kleines LLaMA-Modell auf demselben Server die relevanten strukturierten Daten extrahieren kann, ohne dass irgendetwas woanders hin übertragen wird. Und das ist der Gewinn.
Teil 5: Die Integrationsarchitektur
Als der DOM-Ansatz lief, habe ich den ganzen Ablauf gebaut:
- WhatsApp Web in Puppeteer starten
- Per QR-Code authentifizieren
- Warten, bis sich der erste Sync beruhigt hat
- Einen DOM-Observer einhängen, der Nachrichten extrahiert
- Eingehende Nachrichten filtern
- An den Parser für Kalenderabsichten weiterreichen
Hier ein Beispiel für die Sync-Erkennung:
await page.evaluate(() => {
window.__lastCryptoOperation = Date.now();
const origDecrypt = window.crypto?.subtle?.decrypt;
if (origDecrypt) {
window.crypto.subtle.decrypt = function (...args) {
window.__lastCryptoOperation = Date.now();
return origDecrypt.apply(this, args);
};
}
});
Der Code beobachtet Krypto-Operationen, um zu erkennen, wann die Entschlüsselung zur Ruhe gekommen ist. Eine clevere Methode, um das Ende des Syncs zu bestimmen.
Teil 6: Die finale Extraktionsfunktion
private async extractMessagesFromDOM(page: Page): Promise<WhatsAppMessage[]> {
const domMessages = await page.evaluate(() => {
const messageElements = document.querySelectorAll('.copyable-text[data-pre-plain-text]');
const messages = [];
messageElements.forEach(element => {
const prePlainText = element.getAttribute('data-pre-plain-text');
const content = element.querySelector('._ao3e.selectable-text.copyable-text span')?.textContent;
const isOutgoing = element.closest('.message-out') !== null;
if (content && prePlainText && !isOutgoing) {
messages.push({ prePlainText, content });
}
});
return messages;
});
return parseMessages(domMessages);
}
Noch ein paar Anmerkungen
Das ist keine leichtgewichtige Operation.
Puppeteer einen Browser emulieren zu lassen und pro Nutzer eine aktive WhatsApp-Session zu halten, ist… mutig. Es ist auch wahrscheinlich die schlechteste Architekturentscheidung für etwas, das sich schnell, reaktionsfreudig und latenzarm anfühlen soll. Eine komplette Chrome-Instanz am Laufen zu halten ist schwer. Den Browser-Zustand zwischen den Läufen zu erhalten ist kompliziert. Und alles synchron, isoliert und trotzdem performant zu halten war wohl der schwierigste Teil des Ganzen.
Aber es funktioniert.
Inzwischen steckt etwas Geheimzutat drin, mit der ein kleines lokales LLaMA-Modell die Nachrichten parsen und Absichten erkennen kann, direkt auf dem Host, auf dem die Browser-Instanz läuft. Keine externen API-Aufrufe, keine Nachrichtendaten, die die Maschine verlassen, und alles dauert nur ein paar Mal 10 Sekunden. (Ich wünschte, ich könnte “Echtzeit” sagen.)
Nutzer profitieren bald davon. Und was für ein Albtraum es war, bis hierher zu kommen.
Aber ehrlich, es lohnt sich. In deinen Nachrichten steckt so viel persönlicher Kontext, und wir können jetzt etwas Sinnvolles damit anfangen. Zum Beispiel: sich daran erinnern, was dein Partner dich vor 300 Nachrichten gebeten hat, zum Abendessen mitzubringen.
(Spoiler: Genau das habe ich damit gemacht. Lebensretter.)
Schlussgedanken
Ich habe angefangen und gedacht, ich entschlüssele WhatsApp-Nachrichten, habe aufgegeben, stattdessen einen DOM-Scraper gebaut… und dann beim Schreiben dieses Beitrags die Entschlüsselung tatsächlich geknackt.
Plot Twist.
Die Sache ist die: WhatsApp tut VIEL dafür, DOM-Scraping extrem schwer zu machen. Sie randomisieren Klassennamen, ändern Elementstrukturen je nach Sprache und machen dir generell das Leben schwer, wenn du Daten zuverlässig aus der UI ziehen willst. Meine DOM-Lösung hat funktioniert, aber es wäre immer ein Katz-und-Maus-Spiel geblieben.
Mit echter Entschlüsselung dagegen? Wir lesen direkt an der Datenquelle. Strukturierte Ausgabe, verlässliches Parsing, keine Jagd mehr nach zufällig benannten CSS-Klassen und keine Probleme mit UI-Änderungen. Für die langfristige Zuverlässigkeit ist das eine ernsthafte Verbesserung.
Der Entschlüsselungsansatz bedeutet:
- Keine fragilen CSS-Selektoren mehr, die bei jedem WhatsApp-Update brechen
- Strukturierte Daten statt HTML-Parsing
- Bessere Performance ohne ständige DOM-Beobachtung
- Die komplette Nachrichtenhistorie, nicht nur das, was auf dem Bildschirm gerendert ist
Die Moral von der Geschicht’?
Manchmal lohnt sich die “unmögliche” Lösung doch. Der DOM-Ansatz hat mich zu 80% ans Ziel gebracht und mir genug über die Architektur von WhatsApp beigebracht, um die restlichen 20% zu knacken.
Und ja, Fluchen hilft immer noch.
Die komplette Entschlüsselungs-Implementierung ist eine Geschichte für einen anderen Beitrag, aber vorerst: Es funktioniert! Nur so viel: HKDF und wawc_db_enc können sich gerne irgendwo still und leise zum Teufel scheren.