O2Jam Encrypted Arguments
Follow-up to O2JamO2 Launch Arguments.
Credit to Kishi for taking a look at this back in 2012 and Arrowgene.O2Jam for publicly sharing the encryption logic far before I did all of this.There is a nasty quirk with this encryption however that has been solved in publicly available code, keep reading!
Starting from O2JamO2 Final and O2Jam Classic, the launcher stopped passing arguments as plain text and switched to passing an encrypted blob that appears to be a long hexadecimal string.
They look something like this:
OTwo.exe 00C70200E85000DF8E00E85000CA9300F1A60096B800043E00811000717E0096B80099FC0013FD00CB8300A40C006E4200B96E00B705003F1600BF7500ADFE001B2000C4C100C5F30099F4005BBD00384F002C4700EEB500B9AF00C19100D50B00EEB50086CF00D83B00EBCF00639B00E83600DF6F005FB300192000583200637E001ABB00512000717E001A6800774100A50700A40C0000D800ED6E00C534006F880078BF006C3300ED0200AF02008D8C006EF6003C0900191000ED6E002C47005DC200192000FB5800A40C00CB8300DD38002C4700316A00F70800774100B11800A876006F8800280E00854E00F27600C2AA00AE6F00781200170E006351005FB3001B20001E1500078600F4920028B600280E00854E00F27600C2AA00AE6F00781200170E0063510073DB00670600CE5700845300B0BE00E25400D883004EF400B4D900C2AA0087F400B662000124000B82007463004E1700FBA0001B2000C4C1006C63009A91 |test|??|127.0.0.1|15010|test|??|127.0.0.1|15010|test|??|127.0.0.1|15010|test|??|127.0.0.1|15010|test|??|127.0.0.1|15010
Note that in windows, I had to escape the
|with^so they are not treated as a pipe.
While the above command line is a valid way to run the game, it replaces the opaque token mechanism we had in the e-Games client, and we can’t just pass anything to replace this encrypted string. This mean if we can’t figure this out, we are stuck using one credential account. For local solo play, this is fine, but it is not something we can have for building a multiplayer server.
This is also break the presumptions that O2Jam will not employ complex encryption logic: Most of the encryption around the client has always involve simple yet tricky XOR operations. This is clearly no longer the case.
Where do we even start?
I’ll say it straight, its not like I had to start from 0. The encryption was discovered first by Kishi, followed by Vittee. These 2 people are well-known in O2Jam modding community since the e-Games era.
I will not going to expose these people private social media account, but here is roughly what Kishi shared in his social media back in 2012:
Alright, o2kr arguments’ encryption keys:
p = 251
q = 269
n = 67519
phi(n) = 67000
e = 54391
d = 68711Those are the RSA keys.
That being said, I still wanted to see how the client handle the decryption. I could probably write the code and call it a day, but where is the fun in it? Well, actually I did it for O2JamO2, but now since I am exploring O2Jam Classic, I think this is the time to explore it once and for all.
Rest assured that this will worth both yours and my time because we will see something “interesting” along the way. It only cost me more than half of my sanity, cheap price to pay (no, its not).
Finding the logic
I imagine hunting the logic is quite simple, the command line parser was always somewhere in one of the early branch of WinMain so we can definitely start from there. Also, unlike previous build, O2JamO2 and O2Jam Classic client are full with RTTI, figuring out the original class name is possible, and certainly helps figuring out the composition of the original classes and its structures.
Anyway, back to WinMain, it hands the encrypted argv to a small wrapper function at sub_405d30, which mallocs a buffer, copies the input in, and delegates to sub_585910. The wrapper carries RTTI: its vtable points at a .?AVCCCommandLineParser@@ descriptor, so I went ahead to rename it to CCommandLineParser::Decrypt.
sub_585910 is also just prep work. It mallocs another buffer, zeroes it, and delegates to sub_585ed0. The latter is where the actual work lives, and three things inside it told me what I was looking at:
- First check:
if (strlen(input) % 6 != 0) return -1. Input is a multiple of six characters. - The main loop peels six chars off at a time, runs them through a helper (
sub_5859a0, which turned out to be a tiny hex-to-int converter), pushes the resulting integer through a second helper (sub_585a80), thensprintfs the answer back to hex and pulls two raw bytes out. So six hex chars in, two bytes out, per iteration. A block cipher of some kind. sub_585a80takes threedoublearguments, runs a loop callingpowandfmod, and returns adouble. That’s modular exponentiation, written on floats.
Modular exponentiation with a hardcoded exponent and modulus, fed 16-bit blocks, is small-key RSA. The three doubles sub_585a80 references decode to integers, and those integers pin down the scheme.
sub_585a80, which I renamed Rsa::ModExp after recognising the algorithm. The pow and fmod calls are immediately suspicious.| Constant | Raw bytes | Decoded value |
|---|---|---|
dbl_64E1A0 (?) | 40D466C000000000 | 20891.0 |
g_RsaD | 40F0C67000000000 | 68711.0 |
g_RsaN | 40F07BF000000000 | 67519.0 |
So N = 67519 = 251 × 269, D = 68711. A valid RSA key pair, just a toy-sized one and match with Kishi finding. Every block is 16 bits of plaintext, which fits in [0, 2^16] while N fits in 17 bits.
From here on I’ll use the renamed versions: sub_585ed0 is Rsa::DecryptImpl, sub_585910 is Rsa::Decrypt, sub_585a80 is Rsa::ModExp, sub_5859a0 is Rsa::HexToInt.
To my suprise: Rsa::Encrypt plus Rsa::EncryptBlock is compiled into the binary too, even though nothing on the login path actually calls it. That encrypt side is what spared me the brute-force plan. So the dbl_64E1A0 turns out g_RsaE, it is a different value from what Kishi came up though.
Block Layout
The encoding is stupid simple once you squint at it.
- Take two plaintext bytes
aandb. Form the 16-bit valuev = (a << 8) | b. - Encrypt:
c = pow(v, E) mod N.clives in[0, N]so it fits in a 17-bit integer. - Emit
cas a six-character lowercase hex string viasprintf("%06x", c). - Odd-length plaintext gets padded with one trailing space (
0x20).
Decryption inverts each six-hex block:
int v = Rsa::HexToInt(block); // the ciphertext integer
int p = Rsa::ModExp(v, D, N); // the plaintext 16-bit value
sprintf(Buffer, "%x", p); // <- important: "%x", not "%04x"
out[i] = Rsa::HexToInt(Buffer[0..1]);
out[i+1] = Rsa::HexToInt(Buffer[2..4]);
Buffer is memset once at the top and reused for every sprintf.We are done. Right?
My first implementation was ~15 lines. BigInteger.ModPow for both directions, a bit of hex formatting, done. It round-tripped cleanly against itself, so I shipped it into the IdentityP2.Encore. But soon when I plugged the ciphertext into the O2Jam Classic client, and watched the login field render as:
Username: 5816B250795C411083A375975E2539ýˆ
That should have been ...E25394A (a 32-character GUID string). The last two characters got replaced with garbage. Other fields garbled too, at specific positions, reproducibly.
This can’t be the decryption fault since any incorrect keys would result in completely random string. So, correct RSA encryption in, garbled decrypted text out. Cool, just what I needed.
Bug #1: 32-bit Multiplication in a Float Function
ModExp is written as if the dev knew roughly what modular exponentiation looked like, started implementing it in double because C’s long long wasn’t ergonomic enough, and then drifted halfway back to integers when the arithmetic got annoying.
Bear with me for the nonsense what I am going to spout: The loop is a base-k exponent decomposition where k = LogCeil(v, N) (smallest k such that v^k > N). For most iterations k = 2, so it’s effectively binary squaring. That part works. The part that doesn’t work is the accumulator:
585c16 mov eax, dword ptr [ebp+var_94] ; eax = low 32 bits of (int64)pow(v, r)
585c1c imul eax, [ebp+var_10] ; eax *= acc -- 32-bit imul, keeps low 32 only
585c20 mov [ebp+var_10], eax ; acc = low 32 bits of product
585c23 ...
585c4b call _fmod ; fmod((double)(unsigned)acc, N)
The intermediate multiply is done in a 32-bit register (imul eax, reg). Then the result is zero-extended to int64 and fed into fmod as a double. If the product exceeds 2^32, the top bits are silently discarded before fmod ever sees the number.
acc and its multiplicand both live in [0, N]. N-1 squared is 67518^2 = 4,558,680,324, which is obviously larger than 2^32 = 4,294,967,296. So any iteration where both factors happen to land in roughly [63612, 67518] wraps around, and fmod ends up reducing the wrong value.
imul overflow in Rsa::ModExp.The same pattern shows up again in the final return at 0x585d43 for v * acc % N. Two overflow sites, not one.
The effect is that ModExp is wrong for a specific subset of input/exponent pairs. And it’s wrong in both directions. Encrypt and decrypt use the same buggy function, so even the client’s own round-trip is broken for those pairs.
Yes, I tried to code-cave the CheckCrashDump to call original encrypt function, the client itself can’t round-trip the encrypt/decrypt some plaintext properly!
A “correct” BigInteger ciphertext from my server will fail client decryption on any pair whose inverse image is empty under the buggy math.
I dumped the image of Dec_buggy(c) = ModExp(c, D, N) over all c ∈ [0, N] and found 112 plaintext pair values with no preimage. Some that show up often in real auth payloads:
| Pair | Value |
|---|---|
"4A" | 0x3441 |
"2F" | 0x3246 |
"E2" | 0x4532 |
".d" | 0x2E64 |
".x" | 0x2E78 |
"R " | 0x5220 |
"4A" in particular is the reason the screenshot above garbles: the GUID I was testing ends in "4A", which is literally impossible to encode cleanly.
Bug #2: sprintf("%x") With a Shared Buffer
Buried in DecryptImpl there’s a second, subtler bug that only bites for a narrow input range. The decrypt loop writes every block’s intermediate value into a single shared buffer:
sprintf(Buffer, "%x", (int)v10); // no width / no padding
v7[0] = Buffer[0]; v7[1] = Buffer[1];
// ... extract first byte ...
v7[0] = Buffer[2]; v7[1] = Buffer[3];
// ... extract second byte ...
Buffer is memset to zero exactly once, before the loop. The loop never clears it between iterations. And the format string is "%x", not "%04x", so when ModExp returns a short value, sprintf writes fewer characters than the code assumes.
- Return
0x3939→ writes"3939\0"→ fine,Buffer[0..3]are fresh. - Return
0x00AB→ writes"ab\0"→Buffer[2]is the new null terminator,Buffer[3]is leftover data from the previous block. - Return
0x0005→ writes"5\0"→ bothBuffer[2]andBuffer[3]are stale.
So when one block’s decrypted value happens to be small, its extracted bytes can depend on what the previous block’s ciphertext was. That’s why, during testing, I kept seeing the Email field garble and un-garble as I changed the Username field. The Username’s final decrypted value was leaking state into Email’s first block via that shared Buffer.
Replicating the Bugs in C#
Porting buggy code faithfully is its own little exercise. The 32-bit imul maps to an (unchecked) uint * uint, the FP math to Math.Pow plus fmod (or % on doubles), and the stale-buffer behaviour falls out naturally if you write the extraction against value.ToString("x") with the exact same byte slicing.
private static uint ModExp(uint value, uint exp, uint mod)
{
bool completed = false;
uint acc = 1;
while (!completed && value != 1)
{
uint k = LogCeil(value, mod);
if (k == 0)
return 0;
uint r = exp % k;
exp = (exp - r) / k;
uint br = (uint)Math.Pow(value, r);
acc = (acc * br) % mod; // <- 32-bit wrap on purpose
if (exp == 0)
return acc;
completed = exp == 1;
value = (uint)(Math.Pow(value, k) % mod);
}
return (value * acc) % mod; // <- and here
}
The unchecked keyword is implicit in the default compile settings, but I explicitly keep it in mind here because flipping overflow checks on globally would turn these lines into exceptions, and the whole point is that the overflow is the behaviour we’re emulating.
Getting Clean Client Decrypts
For every plaintext pair p the server might want to send, I need a ciphertext c such that Dec_buggy(c) = p. There’s no closed form because the buggy function isn’t a proper inverse of buggy-encrypt. But I can enumerate:
private static readonly uint[] Encryptable = BuildEncryptLookup();
private static uint[] BuildEncryptLookup()
{
var table = new uint[N];
Array.Fill(table, uint.MaxValue);
for (uint c = 0; c < N; c++)
{
uint p = ModExp(c, D, N); // what the client would decrypt c into
if (table[p] == uint.MaxValue)
table[p] = c;
}
return table;
}
67,519 modexp calls, runs in a handful of milliseconds at startup. Now Encrypt(p) is just table[p], and by construction, running that cipher through Dec_buggy returns exactly p. No more garbles on any pair that has a preimage.
The 112 gaps from earlier still exist. Those pairs just can’t be encoded; the client fundamentally cannot display them after decryption. The only way to avoid them is to make sure they never appear as an aligned 2-byte block in the composed plaintext.
So here what clean data looks like roughly:
29121.125.76.135|121.125.76.136022105O2Jam05O2Jam048.02081374159810crioncross1012345678900219011081374159816user@mail.domain15203.238.136.182051501102-116o2jam.nopp.co.kr01057http://o2jam.nopp.co.kr/client/bbs_patch_notice_nopp.html05O2JAM
Each fields prefixed with length expressed in 2 chars string. Some of these values are mock data that comes from the official launcher. And based on the CCommandLineParser, one of these are suppose to be a password. Luckily, they never brave enough to put the actual password here now that we can decrypt the data.
If you are interested in the meaning of these fields, check out AuthParameters in IdentityP2.Encore
Numbers Are Always Safe
No pair where both bytes are ASCII digits 0x30 through 0x39 is in the unreachable set. User id, rank, whatever. This was a relief, because most of the interesting fields in the launch payload are numeric. The fragile ones are free-form strings like email, and URLs amd they are never actually consumed by the client for most of the time. For username, I can substitute it with a random token that made of numbers.
Crisis averted!