13 april 2026
Manipulating WhatsApp Web Traffic (for fun)
I've always wanted to read messages without triggering the blue ticks while still being able to see if my own messages got read. Petty? Yes. But it made me curious about what's actually going over the network when you use WhatsApp Web.
So I opened DevTools and went looking.
Step 1 — figuring out what I'm even looking at
Network tab, WebSocket frames. Immediately things looked wrong. Binary blobs, no obvious structure, nothing readable. So I did a basic sanity check: sent "a", then sent "a".repeat(1000), and watched the frame sizes.
They scaled with message length. That told me enough: messages are serialized client-side and then encrypted before hitting the wire. The browser encodes, encrypts, sends. On the other end it decrypts, decodes. Which means somewhere in the browser, there's a moment where the plaintext exists in memory.
Step 2 — hooking the crypto
Instead of immediately diving into obfuscated minified JS, I took a shortcut. Hook the crypto layer.
WhatsApp Web uses AES-GCM for transport, which goes through crypto.subtle. So I monkey-patched it:
const realEncrypt = crypto.subtle.encrypt.bind(crypto.subtle);
crypto.subtle.encrypt = async function(algo, key, data) {
console.log("Outgoing plaintext:", new Uint8Array(data));
return realEncrypt(algo, key, data);
};
Same for decrypt. Now instead of seeing encrypted blobs, I'm seeing whatever the app was about to encrypt.
Step 3 — the weird binary format
The decrypted frames weren't JSON or protobuf. They were something custom: compact trees with tokenized strings, lookup tables for common field names, its own encoding for JIDs and lists. After cross-referencing some open-source work (mostly Baileys), I figured out it's WhatsApp's own binary tree format, usually called WABinary.
Once decoded it looks almost like XML:
<message to="..." type="text">
<enc>...</enc>
</message>
And suddenly the traffic made sense. <message>, <presence>, <receipt>, <chatstate>. Messages, online status, read receipts, typing indicators. Clean one-to-one mapping once you can read it.
Step 4 — actually modifying things
Once I could decode frames, the next question was obvious: what happens if I change them before they go out?
App → plaintext → (my hook) → modify → encrypt → send
Read receipts look like this:
<receipt type="read" to="..." id="..." />
So I tried stripping the type="read" attribute before it got encrypted and sent:
function stripType(node) {
const newAttrs = {};
for (const [k, v] of Object.entries(node.attrs)) {
if (k !== 'type') newAttrs[k] = v;
}
return { tag: node.tag, attrs: newAttrs, content: node.content };
}
Message delivered. Blue tick never appeared. It worked.
Step 5 — making it a proper tool
Once you know the pattern, the rest is just mapping features to protocol nodes:
| Feature | Node |
|---|---|
| Read receipts | <receipt type="read"> |
| Voice note played | <receipt type="played"> |
| Typing indicator | <chatstate><composing /></chatstate> |
| Online presence | <presence type="available" /> |
I built a small classifier layer on top:
function isTypingIndicator(node) {
return node.tag === 'chatstate' &&
node.content?.some(c => c.tag === 'composing');
}
if (flags.blockTyping && isTypingIndicator(node)) {
return 'DROP';
}
Ended up with a script that hooks encrypt/decrypt, decodes WABinary, classifies each frame, and either passes it through, modifies it, or drops it. Ghost mode (no presence), hidden read receipts, suppressed typing indicators, all working through the same pipeline.
Closing thoughts
This was a reverse-engineering exercise, not an attempt to break anything. The encryption itself is untouched. All I'm doing is sitting between the app and the crypto layer and reading what the app was going to send anyway.
But that's the part I find genuinely interesting: you don't need to break the encryption to understand what a client is doing. The plaintext has to exist somewhere, and if you can get there before it disappears into a cipher, the whole protocol opens up.