- Published on
the "authoritative" game coordinator that wasn't
- Authors
- Name
- alula
- @__alula
- reverse engineer
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
Connectwith 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
GameClassdefinitions. 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
GameInstancesandEventCalls, 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. the key the game actually ships and uses in the handshake is not generated by that code. it’s a static, hardcoded 509-bit modulus, and 509 bits is much bigger than anything the toy generator can produce. it sits right there in the binary as a constant called Crypto::RSA_KEY_N, written out with thousands separators (1,531,265,...) just like the prime table, with the commas stripped before parsing.
here it is in the client’s connect handshake, encrypting the freshly generated AES key with that hardcoded modulus:
unsigned __int64 __fastcall AGC::GameClient::internalConnect(
AGC::GameClient *this,
std::string *a2,
const AGC::ConnectArgs *a3)
{
// ...
if ( this->runNetThread == 1 )
{
std::allocator<char>::allocator((__int64)&v25);
std::string::basic_string(&v36, "Connecting to AGC server");
AGC::EventConnectionStatus::EventConnectionStatus(v39, 1, &v36);
// ...
port = (__int16)a3->port;
v6 = std::string::c_str(a2);
v13 = v4(this->socket, v6, port);
if ( v13 != 1 )
{
SocketError = CSimpleSocket::GetSocketError((CSimpleSocket *)this->socket);
AGC::GameClient::reportError(this, SocketError, 0);
this->runNetThread = 0;
}
else
{
NetFrame::NetFrame(&v24);
AES_NetInterface::SetEncryption((AES_NetInterface *)this, 0);
OutputBitStream::OutputBitStream(&v17, 0x20u);
BitStream::writenetworkMessage(&v17, 0);
BitStream::writenetworkRole(&v17, a3->role);
BitStream::writeuint64(&v17, a3->steamid);
if ( a3->role == NETROLE_CLIENT )
{
Crypto::RSA::RSA((Crypto::RSA *)&v26, 0);
AES_NetInterface::GenKey((AES_NetInterface *)this, (Crypto::AES *)v39);
AES_NetInterface::SetKey(this, v39);
std::allocator<char>::allocator((__int64)&v21);
std::string::basic_string(v30, Crypto::RSA_KEY_E);
Crypto::UnboundInt::UnboundInt(&v18, v30);
Crypto::UnboundInt::operator=(&v29, &v18);
Crypto::UnboundInt::~UnboundInt((Crypto::UnboundInt *)&v18);
// ... destructors
std::string::basic_string(v31, Crypto::RSA_KEY_N);
Crypto::UnboundInt::UnboundInt(&v21, v31);
Crypto::UnboundInt::operator=(&v28, &v21);
// ... destructors
v15 = Crypto::RSA::Encrypt((Crypto::RSA *)&v26, v39, 0x100u, (unsigned __int64 *)v14);
if ( a3->authTicketLength > 0x400u )
{
notice the whole key exchange sits behind if ( a3->role == NETROLE_CLIENT ). that’s the role asymmetry from earlier: connect as a server instead and none of it runs, so the handshake goes out unencrypted. and the key itself, two strings baked into the binary:
std::string Crypto::RSA_KEY_E = "65,537";
std::string Crypto::RSA_KEY_N = "1,531,265,262,646,994,674,547,685,201,799,770,701,129,655,795,791,765,592,119,860,136,262,723,946,624,623,377,808,897,771,089,403,142,932,514,192,913,807,115,890,516,781,670,418,314,877,312,885,578,799,429";
i confirmed this the boring way: i pulled the 99 primes out of the binary, tried every pairwise product against the real modulus, and tried brute-forcing the rng seed too. nothing matched, which makes sense, because the real key is nearly twice the bit length the generator can even reach.
so the toy generator is probably leftover scaffolding. i didn’t chase down why it’s there. confirming whether the shipped key was ever 256-bit and got bumped, or whether the table is just an abandoned early experiment, would mean pulling old steam depot manifests and diffing historical binaries, and that’s a lot of effort for a footnote. the point stands either way: one path makes laughably weak keys, the other ships a single static 509-bit key. both are breakable.
the 509-bit one just needs a little more compute.
problem three: the decrypt routine hands you uninitialized memory
while reversing the RSA code i noticed the decrypt output was non-deterministic at the tail. the plaintext was correct up to a point and then turned into garbage that changed between runs.
here’s Crypto::RSA::Decrypt, decompiled:
uint8_t *__fastcall Crypto::RSA::Decrypt(Crypto::RSA *this, char *a2, unsigned __int64 a3, unsigned __int64 *a4)
{
// ...
v10 = Crypto::UnboundInt::NumBits(&this->publicKeyDecrypt.n);
*a4 = 0LL;
if ( v10 <= 8 )
return 0LL;
v11 = (v10 >> 3) + (v10 % 0x10 != 0);
v12 = a3 / v11 + (a3 % v11 != 0);
v8 = 0LL;
Crypto::Encryptor::GrowBuff(this, (v10 >> 3) * v12 + a3 % v11 / (v10 >> 3));
buffer = this->buffer;
Crypto::UnboundInt::UnboundInt(&v14);
Crypto::UnboundInt::UnboundInt(&v15);
for ( i = 0LL; i < v12; ++i )
{
Crypto::UnboundInt::FromBits(&v15, &a2[v11 * i], 8 * v11);
Crypto::RSA::DecryptCRT(this, &v15, &v14);
Crypto::UnboundInt::GetBytes(&v14, (char *)&buffer[v8], v10 >> 3);
v8 += (unsigned __int64)Crypto::UnboundInt::NumBytes(&v14) >> 1;
*a4 += Crypto::UnboundInt::NumBytes(&v14);
}
v4 = buffer;
Crypto::UnboundInt::~UnboundInt(&v15);
Crypto::UnboundInt::~UnboundInt(&v14);
return v4;
}
now let’s focus on the problematic parts:
buffer = GrowBuff(this, ...); // operator new[], never zeroed, reused across calls
for (i = 0; i < num_blocks; i++) {
FromBits(&block, &input[chunk_size * i], 8 * chunk_size);
DecryptCRT(this, &block, &plaintext);
GetBytes(&plaintext, &buffer[pos], n_bits >> 3); // always writes 63 bytes
pos += NumBytes(&plaintext) >> 1; // advances by half that
out_size += NumBytes(&plaintext); // reports the full amount
}
return buffer;
the output buffer comes from operator new[], which gives you uninitialized heap. GrowBuff only ever grows it and never clears it, so it carries leftovers from previous crypto operations. then the bookkeeping just doesn’t add up: it writes a fixed 63 bytes per block, advances the write cursor by half the byte count, and reports the full byte count as the output size. the returned “plaintext” runs off the end of what was actually written and into whatever was lying in that buffer.
so AGC’s decrypt routine leaks uninitialized heap into its output. in one capture i grabbed, that tail contained pointer-shaped bytes (the telltale 7f00 high bytes of a linux pointer) and bytes that looked like key material. it’s not a reliable, point-it-here-and-get-the-key oracle, the contents are whatever happened to be in the buffer, which is exactly why it’s non-deterministic. but a hand-rolled crypto routine that returns uninitialized memory is its own special kind of bad, independent of how big the key is.
this is the part i’d underline for anyone tempted to write their own crypto in C++. it’s two failures in one. the math is wrong, and the memory handling is wrong, and the memory one is a completely ordinary C++ footgun (new[] gives you uninitialized bytes, a reused buffer keeps secrets around) that happens to be sitting in the most security-sensitive function in the codebase. funnily enough my rust reimplementation can’t reproduce that bug class by accident, which is sort of the whole pitch for memory-safe languages.
and that’s only the stuff that’s actively broken. there’s a whole second category: the things modern crypto treats as table stakes that this code simply doesn’t do.
it’s textbook RSA, no padding. no OAEP, no PKCS#1, just the raw key blob run through modular exponentiation, which is its own bag of problems. the exponentiation isn’t constant-time, so it leaks timing. there’s no blinding on the private-key operation, which is the classic setup for a timing or cache side-channel on RSA. nothing is compared in constant time. the RNG is a timestamp-seeded LCG instead of a real CSPRNG.
i won’t dwell on these, because next to a 509-bit key and a decrypt routine that leaks heap, they’re paint on a house with no walls. nobody is going to mount a timing attack on a key you can just factor.
the real point is how long that list is. getting crypto right means getting all of it right, and it’s far too much to hold in your head. that’s the whole reason to reach for a library instead of your own modular exponentiation: there are permissively-licensed, exhaustively-audited ones you can ship inside a closed-source game for free. you don’t have to be a hero here.
bonus round: the AES key generation (a smell, not a bug)
unsigned __int64 __fastcall Crypto::AES::GenerateKey(Crypto::AES *this, AES_ctx *a2)
{
// ...
v8 = __readfsqword(0x28u);
for ( i = 0; i <= 59; ++i )
*((_DWORD *)v7 + i) = std::random_device::operator()(&`anonymous namespace'::rando, a2);
for ( j = 0; j <= 3; ++j )
*((_DWORD *)v6 + j) = std::random_device::operator()(&`anonymous namespace'::rando, a2);
this->_vftable = (void *)v7[0];
*(_QWORD *)&this->_aes_ctx.RoundKey[208] = v7[29];
qmemcpy(
(void *)((unsigned __int64)&this->buffer & 0xFFFFFFFFFFFFFFF8LL),
(const void *)((char *)v7 - ((char *)this - ((unsigned __int64)&this->buffer & 0xFFFFFFFFFFFFFFF8LL))),
8LL * ((((_DWORD)this - (((_DWORD)this + 8) & 0xFFFFFFF8) + 240) & 0xFFFFFFF8) >> 3));
v2 = v6[1];
*(_QWORD *)&this->_aes_ctx.RoundKey[216] = v6[0];
*(_QWORD *)&this->_aes_ctx.RoundKey[224] = v2;
return __readfsqword(0x28u) ^ v8;
}
this is the routine that generates the AES session key. instead of generating a 256-bit key and running the AES key schedule, it fills the entire 240-byte expanded round-key buffer, plus the IV, straight from std::random_device.
that’s worth a second look, because std::random_device is a known footgun. the C++ standard doesn’t require it to be cryptographically secure, and there’s a degenerate libstdc++ configuration (plus an old MinGW bug) where it quietly degrades to an mt19937 seeded by time(): on the order of 2^32 possible keystreams instead of 2^256. if that were the case here, you could brute-force the AES key directly, no factoring required.
so i checked, and it holds up. the windows client links MSVC’s OS-backed _Random_device. the linux dedicated server pulls random_device from the system libstdc++, and on a modern one the “default” token routes to the kernel’s getrandom(). i confirmed it on a current toolchain: entropy() reports 32, the output is non-deterministic, and it issues a getrandom syscall. the keys are properly random on every build that actually ships.
so it isn’t a vulnerability. i think i clocked it back when i first went through this code, decided it was probably unexploitable, and promptly forgot about it. it only resurfaced when i was digging through everything again for this writeup, at which point i flagged it to the vendor as exactly that, a smell worth knowing about rather than a bug to fix. the right move is to leave it alone, and they have. but it’s still a tell. generating a “256-bit” key by splatting std::random_device into a round-key buffer and trusting the platform to make that secure works right up until the day a build does something unusual, and it’s the one piece of the crypto the later rewrite never touched.
okay so let’s factor it
the security of RSA rests entirely on it being hard to factor the modulus N back into its two primes. get the primes and you can reconstruct the private key. 509 bits used to be safe. the first 512-bit RSA key fell to a research effort in 1999. it has only gotten easier.
the tool for this is cado-nfs, an implementation of the general number field sieve, which is the best known classical algorithm for factoring big numbers. the rough shape of GNFS is:
- polynomial selection: find a good pair of polynomials sharing a root mod N.
- sieving: collect a huge number of “relations” (smooth numbers) over a number field.
- linear algebra: solve a giant sparse matrix to find a combination that gives you a congruence of squares.
- square root: turn that into the actual factors.
our number was 154 digits, which puts it roughly at RSA-155 in size. cado-nfs’s own benchmark table clocks RSA-155 at about 5.3 days of wall-clock on a dual 8-core xeon. i wanted it faster, and i had friends with fast computers.
hot-plugging gaming pcs into a factorization
here’s the part i actually want to talk about.
cado-nfs runs a coordinator that hands out work units over an HTTP server, and clients that poll it for work. i started the job alone on one machine. then i went poking around the cado-nfs code and realized something nice about how the client and server talk to each other.
a client needs exactly two things to join: the server’s URL, and the SHA1 fingerprint of its self-signed TLS cert. there’s no account, no login, no per-job token. the server keeps no per-client state. a worker shows up, asks for a work unit, computes, posts the result, and if it vanishes mid-unit the coordinator just times that unit out and hands it to somebody else.
which means you can add machines to a factorization that’s already running, with zero reconfiguration. so i did.
the only wrinkle was that i was behind CGNAT, so nobody could reach my coordinator directly. i stood up a dumb tcp proxy on a public VPS (some netcat-shaped thing, the exact incantation is long gone) to republish the work-unit port at the VPS’s IP. the cert was issued for my original hostname, not the VPS, so the hostname check would normally reject it. that’s what --nocncheck is for: it drops the hostname and chain validation and leaves only the cert fingerprint pin, which still protects against tampering. with that in place, contributing was one command:
docker run -d --rm registry.gitlab.inria.fr/cado-nfs/cado-nfs/factoring-full \
cado-nfs-client.py --server https://<vps-ip>:8001 --certsha1=<fingerprint> --nocncheck
my friends Espi and Angie pasted that into their machines (a 7950X3D, an i9-13900k, and a couple dozen client instances between them) and suddenly i had a small supercomputer. only the parallel stages distribute this way, polynomial selection and sieving, so all those donated cores went straight at the long sieving stage. the central stages (filtering, the linear algebra, the square root) stayed on my machine.
every real cado-nfs run takes a bit of hand-holding and tuning, and this one was no exception. i also, at one memorable point, discovered that one of the central stages had been running on 2 threads instead of 32, which cut the remaining estimate from “12 hours” to “10 minutes” the moment i fixed it. distributed scientific computing, live, with a thread-count typo.
the result
back over a weekend in august 2024, after roughly a day and a half of messy wall-clock time, sieving 164 million relations down into a 3.9 million by 3.9 million matrix that ate about 45 GB of RAM, the square root stage spat out two primes. multiply them back together and you get the modulus from the binary. i’ll put the full polynomial and the factors in an appendix, since that’s the part the factoring crowd actually cares about.
n = 1531265262646994674547685201799770701129655795791765592119860136262723946624623377808897771089403142932514192913807115890516781670418314877312885578799429
p = 25087581112890208701981219367313035629681768505640667755013287023865861088559
q = 61036783728034178384604538205108617411389889823493033808159873510047400489931
254 and 256 bits respectively. from there d = modinv(65537, lcm(p-1, q-1)) and you hold the private key.
one fun fact while we’re here. cado-nfs comes from inria, france’s national research institute for computing. the people currently out front on building efficient quantum circuits to break elliptic curves? also inria. the same institution that handed me the tool to kill this 512-bit key is doing the work that will eventually kill the generation that replaced it. give it enough years and “just use ECC” might read the way “just use 512-bit RSA” reads today.
proving it works against an unmodified client
a factored key is a fun number. the part that makes it real is decrypting live traffic from a game you haven’t touched.
i wrote a proxy in rust that sits between the game and the real AGC server, decrypts each frame with the recovered key, and logs it. the clip below shows it against a stock client: the on-screen SHA-256 of agc_lib.dll never changes, the recovered AES key drops straight out of the Connect handshake, and the rest of the session scrolls past in plaintext.
the whole point of showing the hash is that the crypto library never changes. i’m not patching agc_lib.dll, hooking it, or editing its memory. the traffic is decrypted off the wire against the stock crypto, because the key encapsulation was breakable on its own. that’s the difference between a cheat and a broken protocol.
i’ll be straight about the setup, though. i did use the game’s dev features for convenience here: a hidden setting that aims the client at a local AGC endpoint. i could have redirected the client with an unreal engine .ini setting instead, which edits nothing in the game at all, or rebound the AGC server’s IP at the network layer, or skipped the live proxy entirely and just captured the packets with tcpdump and decrypted them offline. the only thing the attack actually needs is the private key. everything else is just how i chose to plug into it on the day.
the part where “authoritative” is doing a lot of work
while i was in there, the protocol showed me something worth pointing at.
AGC is “authoritative” in the sense that it holds the authoritative database of what you own. it is the source of truth for your balance. it is not authoritative about the transactions that change that balance. a lot of events carry a role that says the client is allowed to call them, and the server applies what the client says.
remember incrementexpinternal from that schema slice earlier, the one tagged role: Client? here’s the client actually calling it in a captured session:
C -> AGC: EventCalls { instance_id: 2002,
calls: [ incrementexpinternal, args: [ 11, 642, 0 ] ] }
the args are gameid, exp, actionidentifier. the client picked 642 for exp. the server credited 642 XP. requestexp, grantitem, setitemcount, getfunds are all in the same boat. you can, more or less literally, ask the backend for money.
the reason it’s built this way is that minigame lobbies are peer-hosted, so progression gets reported from the client side instead of being derived server-side. it’s a design tradeoff, and it predates anything i did to the crypto. this is also why i’m not handing out a working client, which i’ll come back to.
the fix (which they actually shipped)
i reported the crypto issues on 2025-07-16 with a 90-day disclosure window, alongside a recommendation: stop hand-rolling primitives, move to audited libraries and modern elliptic-curve crypto.
ten days later it was done.
the 2025-07-26 beta hot fix carried “significant backend changes,” and the dev asked testers to log in and confirm they still connected and still had their units, which is exactly what you’d ask after swapping out the key exchange. i pulled the new agc_lib.dll (the post-fix build) and the strings tell the story:
secp256k1_ec_pubkey_create
secp256k1_ecdh
libsecp256k1-5.dll
sodium_init
libsodium.dll
bitcoin-core’s secp256k1 for the ECDH, libsodium for the MAC and the RNG. instead of getting clever and rolling another key exchange, they reached for two libraries that already get this right. the only real cost was shipping two extra dlls, and that is a trade i would take every single time over maintaining your own modular exponentiation. these are not obscure dependencies either: bitcoin-core’s secp256k1 secures a trillion-dollar economy, so it can certainly handle a key exchange for a game about gambling and minigolf. the new scheme uses ephemeral keypairs on both ends, so capturing a handshake no longer lets you decrypt anything. there’s forward secrecy and there’s no static key left to factor. the entire attack i just described is dead against the new build.
by my count this was the protocol’s third version. v1 ran for most of the game’s life, the one with the factorable RSA. v2 arrived with the dedicated condo beta and added real auth for community-hosted servers, but it kept the same RSA handshake, so the break still worked. v3 is this one, the forward-secrecy rewrite, and it exists because i reported v1.
i want to be clear about this because it’s rare. pixeltail is a small studio. they acknowledged a reported critical, agreed a window, and then actually did the hard architectural work in about ten days while also shipping a dedicated-condo beta. plenty of much larger studios would have sat on this for months. Sabrina and the team stayed responsive and technical the whole way through and didn’t get defensive about any of it. credit where it’s due.
one thing the rewrite didn’t touch: the AES session key still comes from std::random_device, while the new elliptic-curve secret is correctly generated with libsodium’s CSPRNG. it’s the same smell from earlier, frozen into the new code: a real CSPRNG on one side of the handshake and std::random_device on the other. harmless in practice, but still a tell.
the gap that stayed open
here’s the fun part. libsodium exists precisely so you can’t screw this up. the whole design philosophy is a handful of high-level recipes that you call and basically cannot hold wrong. they used it, and still left a hole, because no library can make you verify who you’re talking to if you never ask it to.
the new crypto made the traffic confidential. it did nothing about authentication. the client generates an ephemeral keypair, the server generates one too, and they do ECDH. but the client trusts whatever public key the server hands it in ConnectAck. there’s no notion of “is this actually pixeltail’s server.” a man-in-the-middle who sits in the path can present their own key to the client, run a separate handshake with the real server, and re-encrypt in the middle.
this is a narrower problem than before. an attacker can’t passively decrypt anything anymore, they have to actively interpose. but it’s the same lesson SSH and TLS exist to teach: encryption keeps the conversation private, authentication tells you who you’re talking to, and you need both.
it’s also the point where rolling your own protocol stops paying for itself. every annoying thing about TLS and SSH, the certificate warnings, the “host key has changed” wall of text, the chain validation nobody enjoys, exists precisely because this is the step everyone gets wrong. they are annoying on purpose. doing your own key exchange means rebuilding all of that yourself and getting it right, and you’ve seen how the first attempt went. at some point the move is to stop being clever and let TLS handle it for free. the lighter fix, if you want to keep the custom protocol, is to pin a server identity: ship a known public key with the client and check it.
to be fair, i get why they didn’t just switch to TLS. openssl already ships inside the game, since unreal depends on it, so the library is right there for the taking. but slotting TLS underneath a custom bit-packed protocol means gutting your own AES transport layer and threading TLS through networking code that was never built for it, and that’s genuinely fiddly work. rebuilding the key exchange in place was the much smaller job, so that’s the one they did. it works and it’s a real improvement. it also leaves exactly this gap.
i did start adapting my tooling to the v3 protocol to build a proper man-in-the-middle proof of concept, the kind that would demonstrate the re-encryption end to end. then i got busy and never finished it. so to be clear about what i’m claiming: the MITM here is something i’m confident is possible from reading the handshake, not something i’ve stood up and run. that’s also part of why there’s no working MITM tool sitting in a repo.
i raised this on 2025-07-29 and the team discussed shipping a public key in the client config to validate against. one of them summed the whole thing up perfectly: TLS would do this for free. whether that fix landed, i genuinely don’t know. that’s the last day i checked. this was unpaid volunteer work, and at some point free labor reaches its expiry date and i stopped re-reversing their binaries to check. if certificate pinning is in there now, good, and consider this paragraph out of date.
timeline
- 2023: first poked at AGC, noticed the key generation looked weak.
- 2024-08-11: started seriously attacking it. promptly lost four days to travel and covid.
- 2024-08-17 to 18: ran the factorization with help from friends’ machines. got the primes.
- 2025-07-12: dedicated condos became publicly hostable. confirmed the condo-admin bug worked on them the same day.
- 2025-07-16: reported the condo-admin bug, the RSA break, and the decrypt leak to pixeltail. 90-day window agreed.
- 2025-07-18: condo-admin fix in a release-candidate build.
- 2025-07-26: the crypto fix lands. secp256k1 and libsodium replace the homemade RSA.
- 2025-07-29: flagged the remaining man-in-the-middle gap. last day i verified it.
- 2026: this post.
what to take away from this
I’m going to break character for this part, because it’s the bit that’s actually worth copy-pasting into a team channel.
Don’t roll your own crypto. It is the oldest advice in the field, and this codebase earns it several times over: a 509-bit key, a key generator that tops out around 256 bits, a prime table stored with thousands separators, ciphertext that grows on every operation, raw textbook RSA with no padding, and a decrypt routine that returns uninitialized heap. Every one of those is a well-understood failure mode that an audited library would never have shipped.
Memory safety is part of cryptography. The most damaging bug here had nothing to do with key size. That uninitialized-heap leak is an ordinary C++ mistake, and it was sitting in the single most sensitive function in the product. Cryptographic code written in a memory-unsafe language inherits every footgun the language has, in the worst possible place. Prefer audited libraries, and prefer memory-safe languages for anything that touches key material.
Use the right tool for the job. AGC’s task was key encapsulation: getting a symmetric session key to the server. That problem has dedicated solutions, the key-agreement and key-encapsulation mechanisms like ECDH, ECIES, or ML-KEM. Encrypting a raw key blob with your own RSA implementation is the wrong shape for it. Choosing the primitive that matches the problem is half of not getting this wrong, and moving to ECDH is exactly what they did in the end.
512-bit RSA is dead, and the clock is running on the rest. This one fell over a weekend on borrowed gaming PCs, for free, and that was the slow way. People have done RSA-512 in days on a few rented cloud servers. 1024-bit has no public break yet, but that reflects the cost rather than the security. Treat 2048-bit as the floor if you must use RSA, prefer modern elliptic curves, and keep one eye on the horizon: quantum computers break RSA and classical ECC alike, the circuits to do it are being actively optimized (ecdsa.fail is a live competition to shrink them), and whatever you encrypt today can be harvested now and decrypted later. If you are choosing a key exchange now, look at a post-quantum KEM like ML-KEM (Kyber), hybridized with a classical curve.
Encryption is not authentication. They are separate properties and you need both. A channel can be perfectly confidential and still let an attacker impersonate the server, which is exactly where this protocol sits even after the fix. The moment you find yourself reimplementing certificate or host-key verification by hand, stop and use TLS. It solves this problem once and correctly, and every friction point it imposes is there because authenticating the other end is exactly the step hand-rolled protocols skip.
“Authoritative” has to mean validated, not just stored. Holding the database of record is not the same as being authoritative over the writes to it. If clients can self-report the values that change their own balances, the server is just expensive storage. Validate state transitions server-side.
why we’re posting this, and what isn’t in it
this is a ud2.rip post, and for this one i played it straight. i had a full break of this protocol and never deployed it, never touched the economy despite it being trivial, and reported everything instead. i’m telling you because the evidence backs it up, not because you should take my word for it.
so a few things are deliberately missing from this post. the rust protocol parser isn’t here, the reverse-engineered headers that let you link against agc_lib aren’t here, and i’m not shipping a turnkey decryptor. the math, the bugs, the technique, and the lessons are the educational part and they’re all above. a plug-and-play client is the part that would let someone mint units and grief a live game, and that’s the line.
i’m comfortable describing the economy design flaw because it changes nothing on the ground: people already abuse that with ordinary unreal engine injection, and pixeltail has decent logging and bans for it. describing how it works hands nobody a new capability. shipping the tooling would.
if you hunt bugs in indie games, do it for the writeup and the learning. there was no bounty here and i didn’t expect one. that’s fine. this was for the fun of it, and breaking a cryptosystem on hardware you have at home is a lot of fun.
hack the planet~
appendix: the polynomial and the factors
for anyone who wants the factoring details. this is the cado-nfs polynomial pair and parameters that cracked the modulus.
n: 1531265262646994674547685201799770701129655795791765592119860136262723946624623377808897771089403142932514192913807115890516781670418314877312885578799429
# degree 5 polynomial selection
skew: 3278447.765
c5: 113400
c4: 1387547813058
c3: -2111162385673810741
c2: -32234588333982897736692168
c1: 23134404867673498237315787312281
c0: 41181328789222565014395235559625831210
Y1: 2523506688787726609727
Y0: -598936110376483919922171646856
# MurphyE (Bf=2.147e+09,Bg=2.147e+09,area=6.979e+14) = 3.625e-07
# key sieve params
lim0=33000000 lim1=40000000 lpb0=31 lpb1=31 I=14 qmin=5200000 qrange=20000
rels_wanted=164000000 target_density=148
# verified factors, p * q == n
p = 25087581112890208701981219367313035629681768505640667755013287023865861088559
q = 61036783728034178384604538205108617411389889823493033808159873510047400489931
e = 65537