Files
ts3java/ts3-client/docs/myteamspeak/CRYPTO_RE_SALT.md
ericek111 a7aa122728 Sign in to myTeamSpeak and import what the account synchronises
The myTeamSpeak client protocol and its end-to-end encryption are
reverse-engineered (docs/myteamspeak): an HTTP POST of a framed protobuf,
a PBKDF2-HMAC-SHA512 login token and account key, an AES-GCM wrapped
item key and AES-CTR items with a SHA-512 trailer.

Options on desktop and Settings on Android gain a myTeamSpeak page: sign
in and out, and the account's bookmarks and identities, each picked for
import and marked when already here. The client stays signed in the way
the official one does, keeping the derived token and key rather than the
password; each read signs in afresh, and a sign-in the server no longer
takes signs out. Nothing is written to the account.

Items are the same Item_Data as the local TeamSpeak store, so they go
through the existing decoder and importer; a bookmark chosen alone brings
the identity it connects with, found by UUID or, as some name it, by
the identity's name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 01:07:17 +00:00

22 KiB
Raw Blame History

myTeamSpeak crypto — reverse-engineering results and reproducible vectors

This document is standalone. It contains every measured byte, the constructions recovered from the official client, and the remaining uncertainty needed to finish a Java read-only cloud-sync client.

0. Results recovered on 2026-09-25/26

Login token: solved and verified

The LoginData.password value is standard-base64 of:

PBKDF2-HMAC-SHA512(
    password = UTF8(password),
    salt = UTF8(asciiLower(email) + "ts3Login" + password),
    iterations = 10000,
    dkLen = 48
)

Only the email is lowercased. Both independent vectors in section 1a reproduce byte-for-byte. The official implementation lowercases bytes; email addresses used by the protocol are effectively ASCII.

Static x86-64 Android evidence:

  • Account_Manager_Impl RTTI string at 0x24a0a0, typeinfo at 0x11c1088, vtable address point at 0x11c0dc0; setup slot +0x18 resolves to 0xa03e40.
  • KDF construction helper 0xab74a0 loads the literal ts3Login at 0x223384.
  • PBKDF helper 0xab94a0 fixes dkLen=0x30, iterations=0x2710, and SHA-512.

Account/item key unwrap: solved and verified

The account wrapping key uses the parallel construction:

PBKDF2-HMAC-SHA512(
    password = UTF8(password),
    salt = UTF8(asciiLower(email) + "ts3Encryption" + password),
    iterations = 10000,
    dkLen = 32
)

For the captured account it yielded the key that opened that account's LoginSession.key below.

LoginSession.key is a versioned AES-256-GCM package:

byte 0       version = 0x02
bytes 1..16  authentication tag (16 bytes)
bytes 17..28 IV (12 bytes)
bytes 29..   ciphertext (32 bytes in the captured login)
AAD          none

JCA expects ciphertext || tag, so the tag must be moved from the package prefix before calling AES/GCM/NoPadding. Decrypting the captured account's package produced its 32-byte item/data key: 22efa5d631ddaa11ec97ccb82846b4d24598a9376319f444b5065cd47b391b2d.

The ts3Encryption literal is at x86-64 Android address 0x22aeca. Encryption_Impl_V2 has RTTI at 0x259f5c, typeinfo at 0x11ca088, vtable address point at 0x11c9ff0, and constructor 0xab9b20. Its fields establish key/tag/IV sizes of 32/16/12. Direct GCM decrypt at 0xaba140 slices the package as tag, IV, ciphertext.

Item framing and CTR transform: solved

The complete item frame is:

first 16 bytes  initial 128-bit counter/IV
next N bytes    AES-256-CTR ciphertext
last 64 bytes   SHA-512 of the plaintext (not encrypted)
counter update increment the full 128-bit value in LITTLE-endian byte order

JCA's AES/CTR/NoPadding increments big-endian and therefore cannot be used directly. Generate each keystream block using AES-ECB and increment counter byte 0 first. Applying this construction with the unwrapped item key decrypts the captured blob's first 231 payload bytes into a valid Item_Data bookmark matching the local database, except that the captured older revision has manipulated=0 instead of 3. This proves the key and CTR construction independently of the local copy.

The supposed 27 mystery bytes were a truncated integrity suffix. They are exactly the first 27 bytes of SHA512(plaintext): 4ec510126130dd962e54778518ae0a44947e6dd19975eef8b33ce8. The complete digest is: 4ec510126130dd962e54778518ae0a44947e6dd19975eef8b33ce8d1563dc7f316043eddfeba1c77710975b0cc3d4090b82943fdcb161d2ed953527073311fc5. Thus the recorded 274-byte fixture is the first 274 bytes of a 311-byte frame and is missing the final 37 digest bytes; it was the extraction/capture that was incomplete, not the wire format.

Static proof in high-level decrypt 0xab7c90: it rejects inputs of 64 bytes or fewer, copies the final 64 bytes aside, passes the prefix to vtable slot +0x58 (CTR decrypt), hashes the plaintext through slot +0x18, and compares the two 64-byte strings. Encrypt 0xab77d0 performs the inverse: SHA-512 the plaintext, CTR-encrypt through +0x48, then append the digest.

Relevant V2 vtable entries: SHA-512 +0x18 (0xab9c60), AES-GCM encrypt/decrypt +0x38/+0x40, CTR encrypt/decrypt +0x48/+0x58, and the little-endian CTR helper at 0xabad00.

Java implementation

The verified constructions are implemented without an additional crypto dependency in core/src/main/java/com/ts3client/myts/MyTsCrypto.java (decryption only, as the client only imports). Captured-vector tests live in core/src/test/java/com/ts3client/myts/MyTsCryptoTest.java.

The original goals were:

  1. loginToken(email, password) -> 48-byte value (base64'd into LoginData.password), and
  2. decryptItem(item_blob, ...) -> Item_Data protobuf plaintext.

Both goals, including authenticated item framing, are complete.

All primitives are standard (AES-256-GCM, SHA-256, SHA-512, CTR-DRBG — TeamSpeak's static "teamcrypto" lib has nothing else). The unknown is the construction / salt, not the primitives.

The captured account's email, password, login token, keys and session are deliberately left out: the login token and account key sign in and decrypt just as the password does. Sample B below is a login the client computed for an address without an account.


1. Ground-truth samples (all hex unless noted)

1a. Login token (deterministic — verified identical across two separate logins for the same creds)

Wire framing of the request body to POST https://clientapi.myteamspeak.com/authentication: 0x05 "login" <LoginData protobuf>, where LoginData { email=1:string, password=2:string }, and the password field is base64 text of exactly 48 bytes.

Sample A (server ACCEPTED this login): a real account; omitted. It reproduces byte-for-byte too.

Sample B (server REJECTED — wrong password — but the client still computed & sent the token, so it is a valid input→output pair of the SAME client-side KDF):

  • email = probe.nobody@example.com
  • password = wrongpassword1
  • token (base64) = kJLau9cQuYPQi/hi0ey4dPBChwvTMhz2lfAYHWQnNNtaVffCzHHac8mIFVb2FpN4
  • token (48 bytes) = 9092dabbd710b983d08bf862d1ecb874f042870bd3321cf695f0181d642734db5a55f7c2cc71da73c9881556f6169378

Determinism ⇒ no random salt/nonce in the token; any salt is fixed or derived from email/password.

1b. LoginSession reply for Sample A (values omitted)

LoginSession { key=1 bytes, session=2 string, limits=3, uuid=4 string, error=5 varint, purge=6 varint, username=8 string, myts_id_data=9 MyTeamSpeakIdData, ... alternative_login_info=16 }

  • error (f5) = 200 (ErrorCommon.ERROR_LOGIN_OK)
  • session (f2) = a UUID, the bearer token for later calls
  • key (f1) = 61 bytes: 02 || tag[16] || iv[12] || ciphertext[32] (section 0)
  • alternative_login_info.renewal_token was empty

MyTeamSpeakIdData (f9): user_public_key 32 bytes, encrypted_user_private_key 112 bytes, account_creation_time, my_teamspeak_id 33 bytes = 01 20 <32 bytes> (version 1, length 32, id), public_signature.

1c. Recovery / backup key

32 bytes, shown base64 in the account UI. Its use (unwrapping the data key without the password) is not recovered.

1d. One encrypted sync item (a bookmark) with its KNOWN plaintext

From POST /synchronization requestServerItems reply, Sync_ItemClasses_Data_Detail { item_uuid=1 string, item_version=2 string, item_blob=3 bytes }:

  • item_uuid = 43c5d058-4803-3cd8-b844-15fdcc92e730
  • item_version = 5d831e3c-d5aa-0e66-7af2-a0feb13af048
  • item_blob (274 bytes, note leading 0x11) = 1114428e2059c038f339b1c1a6bc4eb0eac1f4479ec2764ea53c0b4dd0fc46264642cab8908d295933fac43152154f58990909e933d899b86d4776ae8c52f61575b33932d4667fc3fb50e78a2a056dc6c8cd74e3e2446275e0d351c7139bc686a3555b4dd3dd6fdfae1c7176e84f99b30df52dbe9b95e28d9545e2dd3188fec7e34ac233fb7de6d21db8795a45186ff91a01d23189d072d6afb60cad9b1c834e35acf04006daa1d6e439750afaa00a4c63e53f10cf54d2820108c1b859b394d0ba7d80b38de402b631da9ba4daedbff4d9de85cdeae7466b516ac5abe9af4d43e9df1c9bf8951d12e9f5c42ce7cf802443d4bc051902164ec510126130dd962e54778518ae0a44947e6dd19975eef8b33ce8

Known plaintext — after the official client logged in, it wrote this same bookmark DECRYPTED into its local settings.db (ProtobufItems row 5). That decrypted Item_Data protobuf (231 bytes) is:

  • hex = 122434336335643035382d343830332d336364382d623834342d3135666463633932653733301a2464373663663736312d316163332d373434662d366234312d363636333863626431653033200330003a00420048f7f5dbd506820189010a19457269c48d516f76205465616d537065616b20736572766572120f7365727665722e6c69786b6f2e657518834e22056e696765722a00320744656661756c743a0042004a0050005a0744656661756c74620744656661756c746a0070007a1c2b547967324a7478453876524e5a702b4a6955426e6d4268304d593d8a0100900100980100b00101
  • decoded: Item_Data{ item_uuid(2)="43c5d058-4803-3cd8-b844-15fdcc92e730", version(3)="d76cf761-1ac3-744f-6b41-66638cbd1e03", manipulated(4)=3, item_type(6)=0 BOOKMARK, timestamp(9)=…, bookmark(16)=Bookmark_Data{ name="EriÄQov TeamSpeak server", address="server.lixko.eu", port=9987, nickname="niger", capture/playback/hotkey="Default", server_uid="+Tyg2JtxE8vRNZp+JiUBnmBh0MY=", send_mytsid=1 } }

Caveat resolved: the local copy's sync_version_uuid is d76cf761-… while the captured wire item_version is 5d831e3c-… because the local row is a later revision. Decrypting the captured ciphertext nevertheless yields a valid 231-byte Item_Data for the same bookmark; its older revision has manipulated=ITEM_ADDED (0), whereas the committed local copy has ITEM_NOT_MANIPULATED (3). The apparent 43-byte overhead was an artifact of a truncated capture: the 274 captured bytes contain IV + ciphertext

  • only 27 of the 64 SHA-512 bytes. The complete frame is 311 bytes.

2. What the binary tells us about the construction

Strings/asserts from the client binaries (teamcrypto + cloud_sync_client):

  • teamcrypto exposes ONLY: aes::Gcm, aes::Ctr, sha2::Sha2<mbedtls_sha256>, sha2::Sha2<mbedtls_sha512>, drbg::DRBG_Block_Ctr. (So HKDF/PBKDF2/scrypt/Argon2 strings elsewhere are from statically-linked OpenSSL and are NOT used by the sync path.)
  • cloud_sync_client/src/lib/Encryption.cpp asserts: plain_hash.size() == teamcrypto::sha2::sha512_size (== 64). ⇒ a SHA-512 digest ("plain_hash") is central to the item encryption/derivation.
  • cloud_sync_client/src/lib/Item_Manager.cpp asserts: !salt.empty(). ⇒ items use a salt.
  • Account_Serializing.proto has Auth_Token_Package { auth_token, encrypted_encryption_password, encryption_tag, iv } and Account_Data carries key, backup_ed_key, per-class version UUIDs. ⇒ classic E2E model: a random encryption_password (data key) is AES-GCM-wrapped by a password-derived key AND (separately) recoverable via the 32-byte recovery key.
  • The account keypair (user_public_key 32B, encrypted_user_private_key 112B) suggests X25519/Ed25519; 112 bytes wrapped for a 32-byte key ⇒ ~ (12 nonce + 32 + 16 tag = 60?) no — 112 is large, so the wrapped plaintext is likely more than the bare 32-byte key (maybe key+metadata, or salt-prefixed).

3. Historical hypotheses ruled out before the construction was recovered

Login token (had to reproduce both samples):

  • Plain digests: SHA-256/384/512 (and truncations to 48) of pw, email+pw, pw+email, email:pw, with email in raw and lowercased forms. No.
  • HMAC-SHA256/384/512 (key=email or pw; msg=the other), truncated to 48. No.
  • PBKDF2-HMAC-SHA1/256/384/512, iters ∈ {1,100,1000,2048,4096,5000,10000,20000,50000,100000}, dklen 48, salt ∈ {email, lower(email), sha256(email), sha1(email), md5(email), "", "TeamSpeak", "myTeamSpeak", "teamspeak", "teamspeak.com", "clientapi.myteamspeak.com", …, and each pepper ± email}. No.
  • scrypt (N ∈ {1k…64k}, r=8, p=1, dklen 48) over the same pw/salt matrix. No.
  • Argon2 i/d/id (t ∈ 1..4, m ∈ 8M..256M, dklen 48) over the same matrix. No. (and Argon2 can't be it anyway: the desktop client links OpenSSL 1.1.1 which lacks Argon2, yet must produce the same token.)
  • ~35 SHA-512-composite forms tested against BOTH samples: sha512(sha512(pw)+e), sha512(e+sha512(pw)), sha512(sha512(e)+sha512(pw)), sha256(pw+e)+sha256(e+pw)[:16], xor forms, etc. No.

The missing salt components turned out to be the purpose strings ts3Login/ts3Encryption followed by the password itself; the primitive is PBKDF2-HMAC-SHA512.

Item decryption:

  • AES-256-GCM with key ∈ {recovery_key, sha256(rk), sha512(rk)[:32], sha512(rk)[32:]}, nonce at offset 0 or 1 (skipping the 0x11 byte), nonce len 12 or 16, tag = last 16 bytes, AAD ∈ {none, item_uuid (ascii), item_version (ascii), uuid+version, 16-byte binary uuids, 0x11}. No verify.
  • Non-AEAD (AES-CBC / AES-CTR / ChaCha20) with the same keys, checked against the known plaintext prefix 1224 + "43c5d058-…". No. The recovery key is not the item key. For password login, the item key is obtained by unwrapping LoginSession.key as described in section 0.

4. Where the code is (for tool-driven RE with the binaries)

Binaries in this repo: TeamSpeak3-Client-linux_amd64/ts3client_linux_amd64 (x86-64, links libcrypto.so.1.1) and re-android/resources/lib/arm64-v8a/libteamspeak_client.so (arm64, NDK r28, stripped; file offset == vaddr in .rodata/.text). Both are stripped; addresses below are from the arm64 lib unless noted.

  • Encryption.cpp SHA-512 assert string @ vaddr 0x1baed6; referenced by code @ 0xa8c280; enclosing function starts @ 0xa8c198 (0x1e0 stack frame). It reaches SHA-512/AES-GCM through a vtable (interface object in x0): ldr x8,[x0]; ldr x9,[x8,#0x18]; blr x9 (compute something → checks size==0x40) then ldr x9,[x8,#0x48]; blr x9. So the crypto is behind Encryption's dependency interface; resolving it needs the vtable in .data.rel.ro or dynamic tracing.
  • SHA-512 K-table @ vaddr 0x29f4d8 (SHA-256 K @ 0x29f318, SHA-512 IV0 @ 0x241580). SHA-512 processing code references the K page from ~0xcbfe00–0xcc5990 (mbedtls sha512). Its callers (2 hops up) include the login-token KDF and Encryption.
  • Login entry: Java_..._AccountManager_setupSyncAccount @ 0x8931a8 takes (email, password, device), converts the 3 Java Strings, then calls Account_Manager_Impl vtable slot [vtable+0x18] (0x8932d0: ldr x8,[x26]; ldr x8,[x8,#0x18]; blr x8). Follow that to the LoginData builder → the KDF.
  • Desktop x86-64: same assert string @ vaddr 0x2dcac1 — use a lea rXX,[rip+…] xref to find the Encryption fn there, for gdb breakpoints on the running client.

Disassembly excerpt — encryption fn prologue + the SHA-512-size check (arm64)

0xa8c198  sub  sp, sp, #0x1e0
...
0xa8c1e4  ldr  x0, [x0]            ; deref interface obj
0xa8c1ec  ldr  x8, [x0]            ; vtable
0xa8c1f4  ldr  x9, [x8, #0x18]     ; vfn @ +0x18  -> produces a digest into [sp,#0x48]
0xa8c1fc  blr  x9
0xa8c200  ldrb w8, [sp, #0x48]     ; read std::string/vector size (SSO-tagged)
...
0xa8c218  cmp  x9, #0x40           ; == 64  (teamcrypto::sha2::sha512_size)  -> plain_hash
0xa8c224  b.ne #0xa8c270           ;   else assert-fail (Encryption.cpp)
0xa8c228  ldr  x0, [x21]
0xa8c234  ldr  x9, [x8, #0x48]     ; vfn @ +0x48  -> next crypto step (AES-GCM?)
0xa8c23c  blr  x9

5. Synchronization and upload state machine

This is a two-phase comparison/upload protocol. requestServerItems does not blindly upload every local blob. Static tracing of the official Android x86-64 client shows that its initial request iterates the local item snapshot and creates details containing only the local Item_Data.item_uuid and Item_Data.sync_version_uuid. The latter is copied directly to wire field item_version; no blob is set by that builder.

5a. Phase 1: advertise local state and receive the difference

Send POST /synchronization, framed with method requestServerItems, and a Sync_Request_ItemClasses:

session = current LoginSession.session
globalversion = last global cursor returned by the server
sync_version = V1_1 (1) for the recovered current protocol
classes[] {
    itemclass = BOOKMARK / IDENTITY / ...
    version = last server cursor for this class
    detail[] { item_uuid, item_version } // item_version = local sync_version_uuid
}

For an initial/full comparison the official flow can use the all-zero global UUID. Thereafter, globalversion and each class version are opaque server cursors: retain the values in the reply and send them back; do not invent UUIDs for either one.

The reply is the server's comparison result. Its class/details can contain remote item_blob values, deletion instructions, and not_in_db. Interpret status as follows:

Status Meaning for the client
300 IN_SYNC No second-phase work for this comparison. Commit the returned cursors.
301 NOT_IN_SYNC Apply/download remote differences and/or submit the requested local changes in phase 2.
302 NOT_IN_DB The advertised class/item is absent server-side; this is normally an upload candidate, subject to deletion state.
303 COLLISION A cursor/base is stale or revisions conflict. Pull current state, resolve/merge, then retry with the newly returned cursors.
304 OVERLIMIT Do not retry as a conflict. Surface the account/storage limit.

The precise mixture of remote changes and requested uploads in a NOT_IN_SYNC reply should be treated as server-directed reconciliation, not inferred solely from the top-level status. Match details by (itemclass, item_uuid).

5b. Phase 2: send the selected changes

Send the same protobuf type to the same endpoint, but frame it with method synchronizeItems. Echo the server globalversion and per-class version from phase 1. For an add/change, a detail carries:

item_uuid       = Item_Data.item_uuid
item_version    = Item_Data.sync_version_uuid
item_blob       = IV16 || AES-CTR-LE128(Item_Data bytes) || SHA512(Item_Data bytes)
delete_on_server = false / omitted

The encrypted plaintext is the complete serialized Item_Data, including its current manipulated value. The older captured server blob decrypts with ITEM_ADDED, which is direct evidence that this flag is not scrubbed before encryption.

For a deletion, the high-confidence wire mapping is a tombstone detail with the stable item_uuid, its revision in item_version, item_delete_on_server=true (the recovered .proto misspells this item_delte_on_server), and no meaningful item_blob. The schema and mutation paths agree on this mapping, but the stripped second-stage detail builder has not yet been isolated instruction-for-instruction; keep this one assertion behind a fixture/integration test before enabling destructive live sync.

Only mark submitted local items clean after a successful reply. Paths that accept server/committed state set Item_Data.manipulated=ITEM_NOT_MANIPULATED (3). Persist the new global and class cursors atomically with those clean-state transitions so a crash cannot acknowledge local changes without saving the cursor.

5c. Local mutation semantics recovered from Item_Manager

Local operation item_uuid sync_version_uuid / wire item_version manipulated
Add Generate a new UUID Generate a new UUID ITEM_ADDED (0)
Edit an unsynced add Preserve Generation point not yet isolated Remains ITEM_ADDED (0)
Edit a committed item Preserve Generation point not yet isolated ITEM_CHANGED (1)
Delete Preserve A fresh UUID is generated in the observed tombstone path ITEM_DELETED (2)
Accept remote/committed state Preserve server identity Use accepted revision ITEM_NOT_MANIPULATED (3)

The update transaction deliberately avoids changing an item already marked ITEM_ADDED or ITEM_DELETED; this is why an edit before first upload is still an add, not a change. Do not assume that last_known_version is interchangeable with sync_version_uuid. Field 5 is very likely the conflict/base revision used during collision handling, but its exact wire lifecycle is not yet proven.

5d. Version ownership and retry rules

Value Owner / rule
globalversion Server-owned global cursor; echo the latest reply.
class version Server-owned per-class cursor; echo the latest reply.
item_uuid Stable logical item identity; client-generated for a new item.
wire item_version Exact projection of plaintext field 3, sync_version_uuid.
last_known_version Probable conflict base; purpose is not proven enough to synthesize it.

Never loop synchronizeItems with the same stale cursors after COLLISION. Re-run phase 1, decrypt and compare the current remote item, apply the chosen merge policy, and construct a new mutation. The official binary exposes a solveCollision path, but application-level winner/merge policy remains to be recovered.

5e. Static evidence (Android x86-64 build)

  • Initial request builder 0xa39350 gets the local item list via 0xa47ab0, reads item_uuid through 0xa4cdc0, reads sync_version_uuid through 0xa4ce50, and copies them into detail fields 1 and 2.
  • New-item initialization calls 0xa4cfe0 (new item UUID), 0xa4d0b0 (new sync-version UUID), then marks the item ITEM_ADDED.
  • Update transaction near 0xa45220 changes clean/changed items to ITEM_CHANGED, but preserves ITEM_ADDED and ITEM_DELETED.
  • The observed tombstone path marks ITEM_DELETED and calls 0xa4d0b0 for a fresh revision.
  • Remote-accept/import paths near 0xa48896 and 0xa494xx set ITEM_NOT_MANIPULATED.

These addresses are for re-android/resources/lib/x86_64/libteamspeak_client.so, not the ARM64 or desktop library.

The transport, protobuf codec, login, download, encryption, and low-level synchronizeItems call are recovered. The client implements the read-only pull (com.ts3client.myts, see IMPLEMENTATION.md); two-way sync would still need the high-level reconciliation state machine, collision policy, and a safe deletion fixture.