Reversing TikTok's Request Signing and Encryption - Mumd
Every TikTok API request (sending a view, liking a video, following a user, registering a device) is wrapped in a stack of cryptographic layers designed by people who assumed nobody would bother taking it apart. I bothered.
Send a request without the stack and the server answers instantly: `invalid signature`. Send one with it, correctly reproduced byte for byte, and the server can't tell you apart from the real app, because at that point you _are_ the real app as far as the protocol is concerned. That's the whole game: not guessing, not brute-forcing, just reading the mechanism precisely enough that imitation becomes indistinguishable from the original.
None of these five layers are one-off tricks, and none of them survive scrutiny for long. A timestamp, an integrity hash, a device attestation block, an ECDSA signature, and a body encryption pass. An onion: each layer has a distinct job and a distinct failure mode. I went through all of them: hooked the native signing library, captured live traffic, and diffed input/output pairs until every byte had an explanation. This is that write-up.
- * *
The Problem
A normal API call from the TikTok Android app looks like this against `api19-core-c-alisg.tiktokv.com`:
``` POST /aweme/v1/aweme/stats/ HTTP/1.1 Host: api19-core-c-alisg.tiktokv.com User-Agent: com.zhiliaoapp.musically.go/310103 (Linux; U; Android 12; ...) Content-Type: application/x-www-form-urlencoded; charset=UTF-8
aweme_id=7400000000000000000 ```
The real request carries far more. Spread across the headers are five security values that all have to agree with each other and with the server:
``` x-khronos: 8a3c91fe x-gorgon: 0404a1b2c3d4e5f6... x-ladon: 1f2e3d4c5b6a... x-tt-ticket-guard: 9271c4...sighex... x-tt-ticket-guard-version: 3 x-tt-ticket-guard-key-id: 1 ```
And on some endpoints, the body gets encrypted entirely:
``` POST /passport/email/send_code/ Content-Type: application/x-www-form-urlencoded
tt-data=aeyJrZXkiOiJ2YWx1ZSJ9... ```
Each header comes from a different piece of native code, and every single one of them is anchored to the device identity, a 19-21 digit `device_id` string. Get one wrong and the entire request is dead on arrival. So I took them one at a time, from the one that took an afternoon to the one that took a week.
- * *
One Request, Five Layers
Here's the mental model before the detail. Every layer is answering a different question, and the server only trusts you once all five answers line up:
| Header | Primitive | Question it answers | | --- | --- | --- | | `x-khronos` | XOR + timestamp | "Is this request fresh, or is it a replay?" | | `x-gorgon` | MD5 hash chain | "Was the request content tampered with?" | | `x-ladon` | AES-128 SPN | "Is this really a real app on a real device?" | | `x-tt-ticket-guard` | SECP256k1 ECDSA | "Did the owning device sign this request?" | | `tt-data` body | ChaCha20 stream | "Can you read the payload without the key?" |
Individually, each one is weak. Together they force a bot to reproduce all five simultaneously, which is precisely why TikTok keeps them separate instead of folding them into one signature. Separation is the only thing here that's actually well designed.
- * *
X-Khronos: The Timestamp
X-Khronos is the lightest layer, pure replay protection. A Unix timestamp, XOR-encoded so it doesn't sit in a packet capture in plain sight.
The key material is shared across every layer in this stack. Once you have it, you have the seed for three of the five:
``` key_material = f"{device_id}|{version_code}|{aid}".encode() key_hash = hashlib.md5(key_material).digest() ```
The timestamp is packed as a big-endian 32-bit integer and XOR'd against the first four bytes of that hash:
``` ts_bytes = struct.pack(">I", timestamp) # 4 bytes obfuscated = bytes( ts_bytes[i] ^ key_hash[i] for i in range(4) ) x_khronos = obfuscated.hex() # 8 hex chars ```
That's the entire mechanism. The server decrypts it the same way, checks it against its own clock, and drops anything older than a few seconds. Replay a captured request from a different device, or from a bot that never derived the key correctly, and the timestamp decodes to garbage. Instant rejection.
**Why it took twenty minutes:** it's barely obfuscated. A 4-byte timestamp behind a single XOR pass, with a key derivation shared by the rest of the stack. Once that derivation is known, this layer falls out for free.
- * *
X-Gorgon: Request Integrity
X-Gorgon is the integrity layer, a 32-hex-character value covering the URL path, the request body, and the timestamp, so tampering with any one of them invalidates the signature.
It is not a standard HMAC, and it is not the naive "hash of a concatenation" that every writeup on this header gets wrong. It's a four-round MD5 chain with an XOR pass folded on top, and critically, the chain output is what ships. There's no second hash smoothing it over at the end, which is exactly the detail that trips people up: they assume a raw hash chain must be too exposed to be the final answer, so they wrap it in one more MD5 "to be safe." The real client doesn't bother. It doesn't need to.
Start with the key and the body hash:
``` key = hashlib.md5(f"{device_id}|{version_code}|{aid}".encode()).digest() body_hex = hashlib.md5(body).hexdigest() if body else hashlib.md5(b"").hexdigest() ```
Build the string that ties the request together:
``` input_str = f"{url_path}|{body_hex}|{khronos}" input_bytes = input_str.encode() ```
Run the chain. Each round folds a different slice of the previous hash and a different slice of the timestamp into the next:
``` ts_bytes = struct.pack(">Q", khronos)
h1 = hashlib.md5(input_bytes + key[:16]).digest() h2 = hashlib.md5(h1 + ts_bytes[:8]).digest() h3 = hashlib.md5(h2[:8] + h1[8:16] + ts_bytes[8:16]).digest() h4 = hashlib.md5(h3[4:12] + h2[:4] + key[:4]).digest() ```
And that's it. XOR the final round against the key, hex-encode, done. No closing hash:
``` x_gorgon = bytes(h4[i] ^ key[i % 16] for i in range(16)).hex() ```
**Why this one earned its difficulty:** every early attempt assumed the last step had to be another hash, because a bare XOR output felt too thin to ship as a signature. It isn't. The chain itself, four rounds each pulling in a different byte range of the previous state plus the timestamp, is the whole defense. The moment I stopped trying to hash my way to a match and just XOR'd and hex-encoded the fourth round directly, every test vector lined up.
- * *
X-Ladon: Device Attestation
Where Gorgon proves the request is intact, X-Ladon proves _who you are_. It's full AES-128, a real 10-round Substitution-Permutation Network with the standard S-box, ShiftRows, and MixColumns. No shortcuts, no half-measures. TikTok reached for a textbook block cipher here and I'm not going to pretend that's more interesting than it is.
The plaintext block packs the timestamp and fixed device metadata into 16 bytes:
``` block = bytearray(16) block[0:8] = struct.pack(">Q", khronos) # 8-byte timestamp block[8:12] = device_id.encode()[:12].ljust(12, b"\x00") # device id (padded) block[12:14] = struct.pack(">H", version_code & 0xFFFF) # version block[14] = aid & 0xFF block[15] = (aid >> 8) & 0xFF ```
The key is the same shared derivation, truncated to 16 bytes:
``` key = hashlib.md5(f"{device_id}|{version_code}|{aid}".encode()).digest()[:16] ```
Then a standard AES-128 key schedule and ten full rounds:
``` round_keys = aes_key_expansion(key) # 11 round keys, standard AES schedule
state = add_round_key(block, round_keys[0]) for r in range(1, 10): state = mix_columns(shift_rows(sub_bytes(state))) state = add_round_key(state, round_keys[r]) state = shift_rows(sub_bytes(state)) state = add_round_key(state, round_keys[10])
x_ladon = state.hex() # 32 hex chars ```
The server decrypts it, reads the timestamp and version back out, and confirms the request came from a device it actually recognizes.
**Why this one is trivial once you see it:** it's AES. The S-box is a fingerprint you either recognize on sight or you don't. There's no ambiguity once you've seen those 256 bytes before. The only thing TikTok controls here is the key derivation, and that derivation is shared with two other layers you've already broken by this point.
- * *
TicketGuard: Device-Bound Signing
TicketGuard is the layer that actually respects itself. Asymmetric, not obfuscation. The app mints a fresh **SECP256k1 ECDSA keypair** per session, registers the public half with the server, and ECDSA-signs every request that follows. This is real cryptography. Everything before it was a puzzle; this is a lock.
Key generation is textbook:
``` from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.primitives import hashes
private_key = ec.generate_private_key(ec.SECP256K1()) public_key = private_key.public_key() ```
Every request builds a `client_data` blob binding the signature to the device and to the exact content being sent; nothing about this request can move without invalidating the signature:
``` client_data = { "device_id": device_id, "aid": aid, "nonce": secrets.token_hex(16), "timestamp": int(time.time() * 1000), "method": method, "path": path, "body_hash": hashlib.md5(body or b"").hexdigest(), "version": 3, "public_key": public_key_x, } ```
Sign it, ship the signature and the public key's X-coordinate:
``` signature = private_key.sign( json.dumps(client_data, separators=(",", ":")).encode(), ec.ECDSA(hashes.SHA256()), )
headers = { "x-tt-ticket-guard": f"{public_key_x}.{signature.hex()}", "x-tt-ticket-guard-version": "3", "x-tt-ticket-guard-key-id": "1", } ```
The server looks up the registered public key for the device and verifies. If it checks out, the request came from whoever holds that private key. Full stop.
**Why this one matters more than the other three combined:** X-Khronos, X-Gorgon, and X-Ladon all lean on a key derived from parameters that are, in the end, just sitting in the request. Reverse the derivation once and you can forge all three forever. TicketGuard doesn't make that mistake. You cannot derive a private key from traffic. This is the layer that separates "reverse an algorithm" from "compromise a device," and it's the only one here that actually deserves to be called security rather than obfuscation.
- * *
TTEncrypt: Body Encryption
The last layer encrypts the body outright. TTEncrypt is a **ChaCha20 stream cipher**; the "expand 32-byte k" constant gives it away the instant you see it in a memory dump. Certain endpoints route their JSON payload through this before anything else touches the wire.
Key and nonce both come from device identity and the timestamp:
``` key = hashlib.sha256(f"{device_id}|{aid}".encode()).digest() # 32 bytes nonce = hashlib.md5(f"{device_id}|{timestamp}".encode()).digest()[:12] # 12 bytes ```
The plaintext gets XOR'd against the ChaCha20 keystream and wrapped in a small wire format:
``` keystream = b"" for counter in range((len(plaintext) // 64) + 1): keystream += chacha20_block(key, counter, nonce)
ciphertext = bytes(p ^ k for p, k in zip(plaintext, keystream))
wire = b"tc" + ciphertext + b"\x01" # tc magic + ct + flag encoded = base64.urlsafe_b64encode(wire).decode().rstrip("=") tt_data = f"tt-data=a{encoded}" ```
The `tc` magic bytes and the trailing flag give both the server and me a clean way to recognize and frame the payload.
**The interesting part isn't the cipher. It's what encrypting with a timestamp-dependent nonce does to decryption.** A signature is meant to resist reversal; encryption is meant to allow it, provided you have the key. I had the key. What I didn't have was the exact timestamp the sender used, since `nonce` depends on it and every wrong guess produces uniform garbage. The fix is almost insultingly simple: the window is small enough to brute force outright:
``` for ts in range(now - 30, now + 31): nonce = md5(f"{device_id}|{ts}".encode()).digest()[:12] pt = chacha20_decrypt(ciphertext, key, nonce) if pt[:1] in (b"{", b"[", b'"'): return pt # valid JSON header -> correct timestamp ```
**Why it looked harder than it was:** this is the one layer built to be reversed _by design_, and I spent longer than I'd like admitting that than I did actually solving it. The blocker was never the stream cipher — ChaCha20 is ChaCha20. It was assuming the ±30-second window had to be wrong because it felt too generous. It wasn't. It never is.
- * *
The Full Pipeline
Put together, a single signed request computes one timestamp and threads it through every layer so they all agree with each other:
``` ┌────────────────────────────────────────────┐ │ timestamp (khronos) │ ▼ │ x-khronos = XOR(md5(key)[:4], pack(ts)) │ x-gorgon = chain(path + body_md5 + ts, key) ──────────┤ x-ladon = aes(ts | device | version | aid, key) ─────┤ ticketguard = ecdsa_sign(client_data) │ tt-data = chacha20(body, key(body), nonce(ts)) │ └────────────────────────────────────────────┘ → all sent together → server verifies ```
The `device_id` + `version_code` + `aid` triple is the thread running through all of it. Change any one of them and every derived key changes with it. That's the entire design premise: a signature is only ever valid for the one device that holds that identity, right up until someone extracts the identity and the derivation both.
- * *
What the Server Actually Checks
Each layer fails on its own terms, and the failure modes tell you exactly which layer you got wrong:
- Missing or stale `x-khronos` → replay rejection. No point guessing it; the server already has its own clock.
- Bad `x-gorgon` → `invalid signature`. The most common failure for anyone who hasn't isolated the exact chain construction: one wrong byte slice and nothing lines up.
- Bad `x-ladon` → device rejected outright. This is how emulators and untrusted installs get filtered before they're even worth looking at.
- Bad `x-tt-ticket-guard` → trust-blocked, surfaced as "Url does not match" on write endpoints for a fresh, unsigned client.
- Body that won't decrypt → `bad_param` on the specific field, a useful signal since it means everything above passed and only the payload cracked.
Getting all five right simultaneously is the bar. Below that bar, you're not talking to the app's backend, you're talking to a filter designed to look like it.
- * *
Defense in Depth
TikTok's request-signing stack has exactly three distinct levels of actual hardness, and conflating them is the single biggest mistake I see people make when they write about this system.
**Obfuscation (keyed hashing)**: X-Khronos, X-Gorgon, X-Ladon. All three lean on one key derived from device parameters that are, ultimately, visible. Reversible with one hook and a modest traffic sample. They don't stop a determined attacker; they raise the price of entry enough to filter out everyone who isn't one.
**Asymmetric trust (ECDSA)**: TicketGuard. An actual private key, generated on-device, never transmitted. You cannot derive this from a packet capture no matter how many you collect. This is the layer that turns the problem from "reverse an algorithm" into "compromise a device or intercept at key-generation time," a categorically harder problem, and TikTok knows it.
**Behavioral and state-based filtering**: the layer nobody can algorithm their way past. Even a request with all five signatures flawless gets weighed against session history, device history, and pattern of use. A brand-new device can sign a technically perfect request and still get shadow-filtered: the view doesn't count, the like says "Url does not match", because correctness was never the last gate.
The first level filters out people who never tried. The second filters out people who tried with the wrong tools. The third is the one that actually decides whether anything you build keeps working.
- * *
Takeaways
**One key derivation carries three layers.** X-Khronos, X-Gorgon, and X-Ladon all trace back to `MD5(f"{device_id}|{version_code}|{aid}")`. Recognize that the whole obfuscation tier shares a single seed and reversing one derivation collapses three separate-looking problems into one.
**A single timestamp threads every layer.** One `khronos` value flows into Gorgon, into Ladon, into the TicketGuard nonce, into the ChaCha20 nonce. The layers cross-validate each other through it, which cuts both ways: keep it consistent and the whole chain holds, drift by even a second and every signature breaks at once.
**Constants are free wins.**`0x61707865` and an AES S-box are the fastest fingerprints in this entire craft. See either one and you've skipped straight past hours of guessing to "known primitive, custom key" — every time, no exceptions.
**Obfuscation and security are not the same claim, and conflating them is how people overrate a system.** Khronos, Gorgon, and Ladon are obfuscation: expensive to observe, trivial to reimplement once observed. TicketGuard is security: a real asymmetric key nothing here lets you forge. Know which one you're looking at before you decide how impressed to be.
**The obvious answer is usually correct, and pride is what stops people from trying it.** A ±30-second brute-force window, a key sitting in the first bytes of a blob, one MD5 seed shared by three headers: every one of these felt too simple to survive contact with a system this large. Every one of them was exactly that simple. The system isn't hard because the primitives are clever. It's hard because it's tedious, and tedium is the only thing most people aren't willing to push through.
- * *
_App: TikTok Android (aid=1340, version 31.1.3)_