Pangram verdict · v3.3
We believe that this document is a mix of AI-generated, AI-assisted, and human-written content
AI likelihood · overall
MixedArticle text · 1,705 words · 7 segments analyzed
tl;dr i factored a real 509-bit RSA key by turning a few friends’ gaming PCs into a weekend supercomputer, then used it to decrypt an entire backend protocol off the wire against a completely unmodified client. the game it belongs to is tower unite, but the gaming is incidental. this is a story about breaking hand-rolled crypto with actual number theory, and it’s worth your time even if you’ve never touched the game. the short version: tower unite’s backend wraps its AES-256 session key in its own homemade RSA. the modulus was sitting in the binary at 509 bits, small enough to factor at home with cado-nfs and the general number field sieve. about a day and a half of compute later i had the private key, and from there the session key from any handshake, and from there the entire decrypted conversation. the crypto was broken three separate ways: a key generator that’s a toy, a static key it actually shipped, and a decrypt routine that leaks uninitialized heap. each is its own little lesson in why you don’t roll your own. i reported it, and pixeltail, a small indie studio, ripped the whole homemade layer out and replaced it with secp256k1 and libsodium inside two weeks, which is genuinely impressive. the one gap they left is server authentication, so an active man-in-the-middle is still on the table. this is a ud2.rip post. the math, the bugs, and the lessons are all here. the working tooling is not, for reasons i’ll get to. how i got here i didn’t set out to break any crypto.
i set out to run a server. back in 2023 i’d been poking at AGC, the thing tower unite uses to talk to its backend, mostly to see what was editable and what was cheatable. that’s when i first noticed the key generation looked suspicious. i filed it under “funny, revisit later” and moved on. the “later” arrived in august 2024. pixeltail had been talking about community-hosted dedicated condos in their dev updates, and they had their own dedicated condo sitting on the server list. i wanted one running locally, which meant getting the AGC side of things to cooperate. it did not cooperate. my dedicated server’s logs filled up with AGC connection failures, over and over. so i started looking at how a server is even supposed to connect to AGC, which is the thread that pulled me into the rest of this. the role system is where it got interesting. AGC tags every connection with a role: the player client, an official pixeltail “tower server” (the plazas and game hubs), a community-hosted dedicated condo server, or AGC itself. the authentication is completely different depending on which one you claim to be. a client or a community condo server has to present a steam auth ticket. an official tower server presents a role number and a steam id, and that’s the whole story. no ticket, no password, no encryption. the only thing that could plausibly be gating those connections is an IP allowlist on pixeltail’s side. so now i had two questions: could i convince AGC i was a server, and what could i reach once i was talking to it. while staring at the handshake trying to answer the first one, i realized i could man-in-the-middle it.
that was the moment the project stopped being “run a server” and became “okay, how broken is this.” what even is AGC AGC stands for Authoritative Game Coordinator. it’s pixeltail’s own name for it, there’s a wiki page and everything. it is not the realtime game server. the actual gameplay runs on unreal engine 4 dedicated servers, and another chunk of the backend is plain php sitting at backend.towerunite.com. AGC is the out-of-band piece. clients stay connected to it the entire time they’re playing, the game servers connect to it too, and it’s how everyone gets notified about backend events. it owns persistence and economy: your XP, your units, your inventory, achievements, the store, fishing, server listings, all of that. internally it’s called something else. the binary leaks its own source paths in .rodata: /home/robert/Desktop/Git/CasinoBackend/src/agc_lib/clientmessages.cpp /home/robert/Desktop/Git/CasinoBackend/src/agc_shared/bitstream.cpp /home/robert/Desktop/Git/CasinoBackend/src/agc_shared/netmessages.cpp so it’s “CasinoBackend”, it’s C++, and it’s split into a client library (agc_lib, the thing shipped inside the game) and a shared protocol layer (agc_shared). on the wire it’s a custom, bit-packed, very stateful RPC protocol. a connection roughly goes: the client sends Connect with its steam id, a steam auth ticket, and the AES session key (this is the bit RSA is supposed to protect). the server replies ConnectAck. the server sends a NameTable, which is a dictionary of every identifier it’s about to use (achievement, getfunds, createcondo, …), packed six bits per character.
the server sends GameClass definitions. each one is a little RPC interface: a list of events (methods) and net vars (replicated state), where every argument has an explicit bit width and a role saying who’s allowed to call it. then GameInstances and EventCalls, which are the actual method calls. the whole thing is self-describing. it hands you the entire schema on every single connection, which is lovely engineering and also means a parser can learn the interface straight off the wire. here’s a trimmed slice of one class as my proxy decoded it: GameClass "achievement" (Game Achievement/EXP) event getexpresult role: Tower Server args: gameid u8, exp u32 event incrementachievement role: Client args: achid u32, amount u32 event incrementexpinternal role: Client args: gameid u7, exp i32, actionidentifier u32 event requestexpall role: Client args: (none) ... and a few more note the role on each event. it says who is allowed to call it, and hold that thought, because it comes back later in the worst way. the catch with all this is that you can’t decode packet N without all the state from the packets before it: name indices are read with a variable bit width that depends on how big the name table currently is, field widths come from the class definitions, and a single frame packs several messages back to back. it is annoying in the specific way that bit-packed stateful protocols are always annoying. the schema is also filtered by role. AGC only hands you the slice relevant to whatever you connected as. in a capture taken from a normal client you see events tagged for the client, for tower servers, and for community condo servers, but never anything tagged for AGC itself. so even a complete client log doesn’t show you the whole protocol surface, only the part a client is allowed to know about. warm-up: becoming condo admin with one flipped bit before the crypto, a quick one, because it’s the bug that actually mattered to pixeltail’s release schedule.
condos have an admin flag. the client keeps a copy of it on the local player state object. if you flip bIsAdmin to true on your own client, using bog-standard unreal engine reflection (a UE4SS lua mod, in my case), the server cheerfully let you spawn items, edit surfaces, scale things, and edit item properties in condos you had no business touching. the validation just wasn’t there on the server side. on a normal player condo that’s a shrug. on a 24/7 dedicated condo, which had just become publicly hostable, it’s a real problem: anyone who joins can rearrange a server that nobody’s actively watching. that’s the reason the timing of all this is what it is. dedicated condo hosting opened to beta testers on 2025-07-12, i confirmed the admin flag still worked on dedicated condos that same day, and the bug got reported four days later. pixeltail had server-side validation in a release-candidate build two days after that. okay. crypto. the main event: they rolled their own crypto there’s a saying that you shouldn’t roll your own crypto unless you know how to break crypto. AGC’s handshake is a good advertisement for it. the design is reasonable on paper: generate a random AES-256 key for the session, encrypt the rest of the traffic with it, and protect that key in transit with RSA. the problem is entirely in the execution, and there are three separate problems stacked on top of each other. problem one: the key generator is a toy here’s the runtime key generation, decompiled out of the linux build of agc_lib: unsigned __int64 __fastcall Crypto::RSA::RSA(Crypto::RSA *this, bool generate) { // ... v26 = __readfsqword(0x28u); Crypto::Encryptor::Encryptor(this); this->_vftable = Crypto::RSA_vtable; Crypto::RSA::PublicKey::PublicKey(&this->publicKeyDecrypt); Crypto::RSA::PublicKey::PublicKey(&this->publicKeyEncrypt);
p_privateKey = &this->privateKey; Crypto::RSA::PrivateKey::PrivateKey(p_privateKey); if ( generate ) { *(_QWORD *)&v25.gcd.isNegative = std::chrono::_V2::system_clock::now((std::chrono::_V2::system_clock *)p_privateKey); *(_QWORD *)&v24.isNegative = std::chrono::time_point<std::chrono::_V2::system_clock,std::chrono::duration<long,std::ratio<1l,1000000000l>>>::time_since_epoch(&v25); *(_QWORD *)&v23.isNegative = std::chrono::duration_cast<std::chrono::duration<long,std::ratio<1l,1000l>>,long,std::ratio<1l,1000000000l>>(&v24); v10 = std::chrono::duration<long,std::ratio<1l,1000l>>::count((__int64)&v23); srand((_DWORD)this + v10); v3 = rand(); v4 = (0x4AFD6A052BF5A815LL * (unsigned __int128)(unsigned __int64)v3) >> 64; Crypto::UnboundInt::UnboundInt( &v11, (char *)&`anonymous namespace'::PRIMES + 32 * (v3 - 99 * ((v4 + ((unsigned __int64)(v3 - v4) >> 1)) >> 6))); v5 =
rand(); v6 = (0x4AFD6A052BF5A815LL * (unsigned __int128)(unsigned __int64)v5) >> 64; Crypto::UnboundInt::UnboundInt( &v12, (char *)&`anonymous namespace'::PRIMES + 32 * (v5 - 99 * ((v6 + ((unsigned __int64)(v5 - v6) >> 1)) >> 6))); and the crucial part, cleaned up: srand((unsigned int)this + millisecond_timestamp); p = PRIMES[rand() % 99]; q = PRIMES[rand() % 99]; // n = p * q, e = 65537, d = modinv(e, lcm(p-1, q-1)) two things are wrong here and they compound. the seed is a millisecond timestamp (plus an object pointer). that’s a tiny search space for rand(), which is a weak, fully predictable standard-library PRNG to begin with. worse, the primes don’t come from anywhere random. they come from PRIMES, a hardcoded table of 99 entries baked into the binary. each entry looks like this: 55, 111, 313, 381, 385, 439, 481, 663, 679, 865 which is not a list. it’s a single number written with thousands separators, 55111313381385439481663679865, about 96 bits. the table tops out around 129 bits per prime. so a runtime-generated modulus is two of those multiplied together, which lands at 258 bits at the absolute most. 258-bit RSA is a formality. and since both primes come from a 99-entry table, the entire universe of possible keys is roughly 99 squared, about 9800 pairs. you could break this by trial-multiplying the table against itself in a fraction of a second. problem two: the real key isn’t even that here’s the twist.