DEV Community

engnzdl
engnzdl

Posted on

Reversing TikTok's Request Signing: From Obfuscated Native Code to Pure Python

Reversing TikTok's Request Signing: From Obfuscated Native Code to Pure Python

TikTok's mobile app signs every API request with a stack of tokens: x-argus, x-gorgon, x-ladon, x-khronos. The server checks them before it returns a single byte. Get one wrong and you don't get a clean 403. You get an empty 200, or an error that looks like a network hiccup. The rejection is silent, and that silence is most of why this thing is hard to reverse.

I reverse-engineered the full signature set for TikTok 47.0.3 and rebuilt it in pure Python. No emulator, no runtime instrumentation, no JVM, no loading native modules. A few Python dependencies and a signing facade.

I'm leaving out the exact byte layouts, constant tables, and key-derivation steps. This post is about how to approach this class of problem, not instructions for reproducing the signatures.

1. What you're up against

Four moving parts, and they compose rather than stack independently:

Signature What it covers Character
x-argus request body + query the strict one, enforced on high-value endpoints
x-gorgon a header-level signature table-driven bit mixing
x-ladon device / license attestation ties the request to an app identity
x-khronos timestamp tightly coupled to the others; same-timestamp consistency matters

Enforcement is risk-based and per-endpoint. Not every endpoint checks every signature. I probed live endpoints with deliberately broken signatures and found the anonymous pre-login surface is where the server is strictest. Once you hold a valid session, the checks relax a lot. That looks like a deliberate anti-bot posture, and it doubles as a development shortcut: test against the strictest endpoints first, because if those pass, the rest will too.

2. The approach

The specific tools matter less than the order of thinking. Three principles carried the whole effort.

Observe at the point of least resistance. The native signing code is control-flow-obfuscated, and it generates constants in registers at runtime. They are never written to disk, so a disassembler finds nothing to grep for. The breakthrough came from watching the code at the one place where its secrets are briefly in the clear: a full emulation environment where timing side effects vanish and short-lived intermediate state can be captured. In a real process that state lives for microseconds. In an emulator you can freeze it and read it back.

Treat negative results as scope reduction. Instrumenting the higher-level runtime produced nothing but silence. The signature path was entirely native; the managed layer only handled string decryption and device queries. That silence was the finding. It told me precisely where not to look and saved weeks. A hook that returns nothing is still an answer, if you read it as a boundary.

Prove the generator before you trust it. The turning point was building the inverse of the signing pipeline and requiring it to fully reverse a genuine token before I accepted the forward implementation. If your decoder can't explain a real signature byte for byte, your generator is a guess.

3. The findings that mattered

These are behavioral findings, not the algorithm:

  • One byte in the argus header is a version/flag field that must be small. A random large value gets rejected before the body is even read. A whole class of plausible-looking forgeries dies on that single byte.

  • The trailing padding is not real PKCS7. It's length-prefixed random fill. Treating it as standard block padding produces a structurally plausible signature that gets silently rejected.

  • Not every field that looks like device attestation is actually checked. I found fingerprint-looking values that are client-generated and never verified by the server. The lesson isn't the specific field. It's to test every assumption: "the server checks this" is a hypothesis until you prove it with a live request.

  • Two error codes that look like siblings mean opposite things. One means "your signature is invalid." The other means "your signature is fine, but I want a challenge step first." I confirmed this by reproducing the app's own login flow: the challenge appears after the signature has already been accepted. Confusing the two sends you debugging the wrong layer for days.

  • Silent rejection is the real enemy. HTTP 200 with an empty body looks like success, but it's risk-control quietly dropping your action. A 403 at least tells you something. Treat an empty 200 as the red flag.

4. The pure Python port

The runtime is deliberately boring, and that's the point. A TLS layer that matches the app's own fingerprint, a pure Python implementation of the core hash primitive, and a standard library for the symmetric layer. No native modules, no subprocess, no emulator, no runtime instrumentation. It runs anywhere Python runs.

A single signing facade produces the entire header set from one timestamp, which guarantees the x-khronos consistency the server checks. Around it sits a thin client: device registration, session management, and a typed API layer for feed, search across 7 tabs, profile, video download, and DM read. The session store is sqlite-backed with restricted permissions.

5. Verification

I validated against the endpoints that actually enforce the signatures, not the lenient ones:

  • The anonymous feed endpoint, one of the strictest, returned a real feed.
  • The like endpoint returned is_digg: 1, meaning the server accepted the signature and applied the action.
  • The login endpoint returned the same challenge response the real app gets. That's the strongest signal: the server read the signature as valid and moved on to the next auth step.
  • A 38 MB video pulled clean through the signing path.

The read path holds. What's left is write actions, which hit risk-control silent rejections rather than signature failures.

6. What I'm not showing

This covers methodology and behavioral findings. The exact byte layouts, constant tables, and key-derivation chain stay private. If you want to take on a system like this yourself, the reusable part is the order of operations: observe where the secrets are briefly exposed, treat dead ends as scope reduction, and prove your generator with a reverse-verifier before you trust it.

The signature rotates with every app release anyway. The methodology is what lasts.

Top comments (0)