cracking conquest of elysium 5
intro

when i first saw it, i thought the algorithm was incredibly basic and foolish.
WEIRD_SUM_OF_G1_TO_G4 = (int)(3 * (G4 & 0x7F) + G1 + 52 * v9 + 19 * v50 + 15 * v45 + 11 * (G3 & 0x7F) + 7 * (G4 / 128)) % 10000;
sub_5555556E37A0(a1: G3);
if ( G5 == WEIRD_SUM_OF_G1_TO_G4 )
// your key is validit checks the weighted sum of the first 4 sections of
the key G1-G2-G3-G4, then ensures it equals
to G5.
i got a little cocky, stating "the algorithm is so
braindead that 0000-0000-0000-0000-0000
works". in my defense, that was true, as well as the key
000000000. both those keys let you get past
the menu.
but what i didn't realize at the time was that the
CoE5 devs were sneaky gremlins that added various key
checks in unassuming parts of the game, causing glitchy
behavior — intended to make you think it was a bug and
drive you crazy if you used a fake key.
it was kind of cute, and it also made for a really fun
crack. i would totally do the same (just to be
diabolical) if i were to make an offline key checker for
my own games.
i would need to put on my investigative glasses and lock in to crack this in a weekend. so join me as we dig through decompiled code and obfuscated algorithms in the name of research!
the first wall i hit with the key
000000000 was actually playing a game. when
i start a single-player game, before the first turn, it
would unexpectedly end immediately with the message
"Team 0 has won".
let me introduce you to my classification system
for CoE5 keys. you may not know it (i didn't at the
time), but there are actually 5 levels of CoE5 keys. the
key 000000000 is a level 1 key — meaning it
only passes the level 1 check.
| Level | Function | Check | Effect if failed |
|---|---|---|---|
| 1 | validate_key |
G5 == weighted_sum(G1,G2,G3,G4) % 10000 |
Can't enter menu |
| 2 | ??? | ??? | ??? |
| 3 | ??? | ??? | ??? |
| 4 | ??? | ??? | ??? |
| 5 | ??? | ??? | ??? |
we will fill this table out as we discover more!
level 2
at first i thought this "Team 0 has won" was a bug.
so, i enabled the logs; which is a variable set to 0 at
the top of main. you can just patch it to
1:
int __fastcall __noreturn main(int argc, const char **argv, const char **envp)
{
// [COLLAPSED LOCAL DECLARATIONS. PRESS NUMPAD "+" TO EXPAND]
while ( &v266 != (__int64 *)&v249 )
;
v3 = 0;
PRINT_DEBUG_LOGS = 1; // HERE
memset(v261, 0, sizeof(v261));
v6 = (char *)&STACK[0x6058];
v262 = 0;
debug_log(format: "CoE %s starting\n", "5.39");
...
}but the logs didn't reveal any errors. when i entered a legit key, i was able to start the game with no problems on the same machine, so that led me to reason that there must be a second key check.
i dug around main() where i last found
the level 1 check, and even uncovered a spelling
mistake!
if ( (unsigned int)file_exists(a1: (const char *)&STACK[0x5378]) == 0 )
debug_log(format: "keyfile '%s' dosen't exist\n", (const char *)&STACK[0x5378]); // dosen't
if ( (unsigned int)file_exists(a1: (const char *)&STACK[0x5378]) != 0 )
{
v211 = fopen(filename: (const char *)&STACK[0x5378], modes: "rb");
v213 = v211;
if ( v211 != nullptr )
{
v295 = v212;
fscanf(stream: v211, format: "%d-%d-%d-%d-%d", &G1, &G2, &G3, &G4, name); // should be called G5; my bad
fclose(stream: v213);
}
}
if ( G1 == 0 ) ...
if ( (unsigned int)validate_key(G1, G2, G3, G4, G5: *(int *)name) == 0 )
{
showing_some_ui(a1: (unsigned int)"Invalid CD key", a2: v168, a3: G1, a4: v167, a5: v169, a6: v170);
remove_coe5key_files();
goto LABEL_254;
}as you can see, it reads the keyfile then validates
it right here in main:
validate_key(G1, G2, G3, G4, G5: *(int *)name)but i had already accounted for this check (the level 1 check).
with no further success in main, i
looked around the binary for strings. i suspected the
other key check to be unrelated to this one in
main, and my conjecture was confirmed when
specifically "coe5key" yielded results. i
found another function,
find_and_validate_key_2, that opened
%s/../coe5key and did a chain of SHIFT,
AND, ADD.
v4 = fopen(filename: s, modes: "rb");
if ( v4 != nullptr )
{
fclose(stream: v4);
v5 = fopen(filename: s, modes: "rb");
v6 = v5;
if ( v5 != nullptr )
{
fscanf(stream: v5, format: "%d-%d-%d-%d-%d", &G1, &G2, &G3, &G4, &G5);
fclose(stream: v6);
}
}
v7 = (G1 ^ 0x27282) >> 1;
if ( (((unsigned __int8)G1 ^ 0x82) & 0x10) != 0 )
v7 = ((G1 ^ 0x27282) >> 1) + (((G1 ^ 0x27282) >> 1) & 0x10) + (((G1 ^ 0x27282) >> 1) & 4) + 1000;
v8 = v7 >> 1;
if ( (v7 & 0x10) != 0 )
v8 = ((v7 >> 1) & 0x14) + (v7 >> 1) + 1000;
v9 = v8 + v7 + (G1 ^ 0x82);
v10 = v8 >> 1;
if ( (v8 & 0x10) != 0 )
v10 = (v8 >> 1) + ((v8 >> 1) & 0x10) + ((v8 >> 1) & 4) + 1000;
v11 = v10 + v9;
v12 = v10 >> 1;
if ( (v10 & 0x10) != 0 )
v12 = (v10 >> 1) + ((v10 >> 1) & 0x10) + ((v10 >> 1) & 4) + 1000;
v13 = v12 + v11;
v14 = v12 >> 1;
...
v33 = v31 + v32;
v34 = v32 >> 1;
if ( (v32 & 0x10) != 0 )
LOBYTE(v34) = v34 + (v34 & 0x10) + (v34 & 4) - 24;
result = (void *)((v33 + (_BYTE)v34) & 0x7F);
if ( (G2 & 0x7F) != (_DWORD)result )
{
// you lose! the return value of this function is irrelevant,
// the important thing is we memset the fact that the key is invalid
debug_log(format: "barb qqq 32\n");
return memset(s: &g_key_corruption_memblock, c: 0, n: 0xF1080u);
}yep. this find_and_validate_key_2
function is randomly called in one place: on line 4724
of the main game processing function,
after_game_turn.
it loads your key file, performs a chain of math on G1,
and validates its low byte against
G2 & 0x7F.
in order words, we've discovered the LEVEL 2
CHECK! it forces a new relationship between the
G1 and G2 sections of the
key.
the log line "barb qqq 32" is completely
meaningless to most. it is designed to blend in with the
other logs, looking totally unassuming.
but it actually signifies to anyone reading the logs
that:
- your key is invalid,
- and 987264 bytes of critical game data have just
been
memsetto 0.
after asking "WHAT DOES 'barb qqq 32' MEAN!?!", i have concluded it's just the devs trying to cause confusion, having a laugh at anyone reverse-engineering the function.
let's add this new discovery to the table, shall we?
| Level | Function | Check | Effect if failed |
|---|---|---|---|
| 1 | validate_key |
G5 == weighted_sum(G1,G2,G3,G4) % 10000 |
Can't enter menu |
| 2 | find_and_validate_key_2 |
G2 & 0x7F == hash(G1) |
Immediate "Team 0 wins" upon starting game |
| 3 | ??? | ??? | ??? |
| 4 | ??? | ??? | ??? |
| 5 | ??? | ??? | ??? |
level 3
with that, i thought i was in the clear. i started a
game with my keygen-generated Level 2 key, and i played
a few turns.
patting myself on the back for being such a great
reverse engineer, i was ready to wrap up this
project.
but of course,
that's not what happened.
after playing a few turns, i hit "next turn", and

huh. that's weird. i didn't leave my citadel undefended. what happened?
following that, one-by-one, every single player on the map was "eliminated due to no citadel", and i knew something was up.
checking the logs, nothing too suspicious, just a bunch of map updates.
cas♥ /home/cas/apps/ConquestOfElysium5/coe5
...
mist: spread from 353,6
mist: spread from 358,7
mist: spread from 358,12
mist: spread from 351,13
mist: spread from 352,14
mist: spread from 357,14
mist: spread from 360,15
mist: spread from 493,16
mist: spread from 356,17
mist: spread from 357,17
mist: spread from 502,20
mist: spread from 359,21
mist: spread from 373,21
mist: spread from 389,21
mist: spread from 388,24
mist: spread from 505,24
mist: spread from 496,26
mist: spread from 154,30
mist: spread from 151,32
a 67
AGT fluff
updatevoid
bt3: Giant Jellyfish dies [unr -1][vic 63]
hit: dead unr63 (Giant Jellyfish)
msg to 24: '1 unit drowned in a Plain.'
bt3: Asp Turtle dies [unr -1][vic 88]
hit: dead unr88 (Asp Turtle)
msg to 24: '1 unit drowned in a Plain.'
bt3: Hippocampus dies [unr -1][vic 118]
...
so... this part of the crack was the largest struggle, and it all comes back to the first assumption i'd made.
getting nowhere searching the binary for other
strings, i took a closer look inside
validate_key, the level 1 function. i was —
like you are being now — assaulted with a vast amount of
confusing math operations.
// LEVEL 1 CHECK
__int64 __fastcall validate_key(int G1, int G2, int G3, signed int G4, int G5)
{
// [COLLAPSED LOCAL DECLARATIONS. PRESS NUMPAD "+" TO EXPAND]
useless_var_4 += obfuscated_hash_function(a1: dword_5555599C9628, a2: G2);
useless_var_1 += obfuscated_hash_function(a1: G1, a2: useless_var_4);
useless_var_5 += obfuscated_prng_function(a1: G1 + G2, a2: dword_555563DF3934);
v7 = useless_var_5;
if ( (useless_var_1 & 1) != 0 )
useless_var_2 += obfuscated_hash_function(a1: G1, a2: G2);
useless_var_3 += obfuscated_prng_function(a1: G1, a2: G2);
dword_5555627501E8 = G1;
useless_var_4 += obfuscated_hash_function(a1: dword_5555599C9628, a2: G3);
useless_var_1 += obfuscated_hash_function(a1: G1, a2: useless_var_4);
v8 = obfuscated_prng_function(a1: G1 + G3, a2: dword_555563DF3934) + v7;
useless_var_5 = v8;
if ( (useless_var_1 & 1) != 0 )
useless_var_2 += obfuscated_hash_function(a1: G1, a2: G3);
useless_var_3 += obfuscated_prng_function(a1: G1, a2: G3);
v9 = G1 & 0x7F;
v10 = G2 / 128;
useless_var_4 += obfuscated_hash_function(a1: dword_5555599C9628, a2: G2 / 128);
useless_var_1 += obfuscated_hash_function(a1: v9, a2: useless_var_4);
v11 = obfuscated_prng_function(a1: v9 + G2 / 128, a2: dword_555563DF3934) + v8;
useless_var_5 = v11;
if ( (useless_var_1 & 1) != 0 )
useless_var_2 += obfuscated_hash_function(a1: v9, a2: v10);
v12 = obfuscated_prng_function(a1: v9, a2: v10);
dword_555563DE9C88 = G2 / 128;
useless_var_3 += v12;
v13 = _mm_shuffle_epi32(_mm_cvtsi32_si128(G1), 0);
v14 = 0;
si128 = _mm_load_si128((const __m128i *)&xmmword_55555586AC00);
v16 = _mm_load_si128((const __m128i *)&xmmword_55555586AC10);
v17 = 0;
do
{
v18 = si128;
++v14;
si128 = _mm_add_epi32(si128, v16);
v17 = _mm_add_epi32(v17, _mm_xor_si128(v18, v13));
}
while ( v14 != 250 );
... // tons and tons more useless assignments and function calls
// the actual validate line
WEIRD_SUM_OF_G1_TO_G4 = (3 * (G4 & 0x7F) + G1 + 52 * v10 + 19 * G2_AND_0x7F + 15 * v48 + 11 * (G3 & 0x7F) + 7 * (G4 / 128)) % 10000;
obfuscated_hash_function(a1: G3, a2: G4);
if ( G5 == WEIRD_SUM_OF_G1_TO_G4 )
dword_555563DE9C80 = 1;
obfuscated_hash_function(a1: G2, a2: G4);
v43 = G3 * G2;
useless_var_4 += obfuscated_hash_function(a1: dword_5555599C9628, a2: G1);
useless_var_1 += obfuscated_hash_function(a1: G3 * G2, a2: useless_var_4);
useless_var_5 = obfuscated_prng_function(a1: G3 * G2 + G1, a2: dword_555563DF3934) + v41;
if ( (useless_var_1 & 1) != 0 )
useless_var_2 += obfuscated_hash_function(a1: v43, a2: G1);
useless_var_3 += obfuscated_prng_function(a1: v43, a2: G1);
result = 1;
if ( G5 != WEIRD_SUM_OF_G1_TO_G4 )
{
obfuscated_hash_function(a1: G3, a2: G3);
return 0;
}
return result;
}i'd inspected this function once before, so i knew my
way around. but i had made one critical
assumption:
i believed it was complete nonsense designed to
distract you (as you can see by my naming:
useless_var_1 etc.)
the actual validate was a single line of code near the
bottom, and i'd insisted the function returned either a
0 or 1, and that's all that it
did.
i more closely investigated in the function, and made a critical observation.
did you notice it?
take a closer look at the "junk" in the above function.
hidden within all the junk, useless assignments, obfuscated functions that do nothing — were multiple GLOBAL STORES.
let's look at that function again, with all the nonsense stripped.
// LEVEL 1 CHECK
__int64 __fastcall validate_key(int G1, int G2, int G3, signed int G4, int G5)
{
// [COLLAPSED LOCAL DECLARATIONS. PRESS NUMPAD "+" TO EXPAND]
...
g_key_G1 = G1; // GLOBAL STORE #1: g_key_G1 = G1 (read by process_drowning_and_check_g1_g2 = LEVEL 3)
...
g_key_G2_div128_stored = G2 / 128; // GLOBAL STORE #2
...
i = 0;
v29 = G2 ^ G3 ^ G5 ^ G4;
obfuscated_hash_function(a1: G1, a2: v29);
ptr = &GAME_DATA_ARRAY_PLUS_0x30; // GAME_DATA_ARRAY LOOP 1 START: base = unk_5555609803D0 (= unk_5555609803A0 "GAME_DATA_ARRAY" + 0x30). Writes 1000 records of 212 bytes each. Per record: offset 0x30(DWORD -1), offset 0x40(WORD idx^G1), offset 0x34(DWORD idx^G2div128), offset 0xD0(WORD idx^(G3&0x7F)) = field_104
do
{
ptr += 53;
obfuscated_hash_function(a1: G1, a2: v29);
*(ptr - 53) = -1;
*((_WORD *)ptr - 98) = i ^ G1;
*(ptr - 52) = i ^ v10;
v31 = i++ ^ G3 & 0x7F;
*((_WORD *)ptr - 26) = v31; // Writes field_104 (struct offset 0xD0): idx ^ (G3 & 0x7F). Read by after_game_turn
obfuscated_hash_function(a1: G1, a2: v29);
}
while ( i != 1000 );
ptr_2 = &GAME_DATA_ARRAY_PLUS_0x30; // GAME_DATA_ARRAY LOOP 2 START: Same base. Writes per record: offset 0x30(DWORD -1), offset 0x40(WORD idx^G1), offset 0x34(DWORD idx^G2div128), offset 0xCE(WORD idx^G1) = field_103. Does NOT write field_104.
...
j = 0;
do
{
ptr_2 += 53;
obfuscated_hash_function(a1: G1, a2: v29);
v35 = j ^ G1;
v36 = j++ ^ v10;
*(ptr_2 - 53) = -1;
*((_WORD *)ptr_2 - 98) = v35;
*(ptr_2 - 52) = v36;
*((_WORD *)ptr_2 - 27) = v35; // Writes field_103 (struct offset 0xCE): idx ^ G1.
obfuscated_hash_function(a1: G1, a2: v29);
}
while ( j != 1000 );
...
g_key_G1_stored = G1; // GLOBAL STORE #3
...
g_key_G3_shifted_stored = v48; // GLOBAL STORE #4
...
g_key_G4_div128_stored = G4 / 128; // GLOBAL STORE #5
// the actual validate line
WEIRD_SUM_OF_G1_TO_G4 = (3 * (G4 & 0x7F) + G1 + 52 * v10 + 19 * G2_AND_0x7F + 15 * v48 + 11 * (G3 & 0x7F) + 7 * (G4 / 128)) % 10000;
obfuscated_hash_function(a1: G3, a2: G4);
if ( G5 == WEIRD_SUM_OF_G1_TO_G4 )
g_key_valid_flag = 1; // GLOBAL STORE #6
obfuscated_hash_function(a1: G2, a2: G4);
...
result = 1;
if ( G5 != WEIRD_SUM_OF_G1_TO_G4 )
{
obfuscated_hash_function(a1: G3, a2: G3);
return 0;
}
return result;
}this function not only validates your key with a boolean return value (0 = invalid, 1 = valid)
but — hidden among the nonsense useless assignments
and useless function calls — the function also sneakily
manipulates global state.
not just any global state, though. it stores
G1, G2, G3, and
G4!
after noting all the global stores, i figured tackling the next level would be easy! just take a look at the cross-references to those global variables, and boom, find the check that destroys all the citadels.
it was not that easy.
the first thing i found was in
process_drowning_and_check_g1_g2— a
function called once on line 5071 of
after_game_turn.
within the function was this diffident snippet of
code:
if ( g_turn_process_counter > 45 )
{
v14 = (g_key_G1 ^ 0x10) + 3 * ((g_key_G1 ^ 0x10) & 0x1F);
// LEVEL 3 CHECK
if ( g_key_G2_div128_stored != ((((unsigned __int8)(v14 + 20 * ((v14 - 20) & 7)) ^ 0x10) + (unsigned __int8)(g_key_G1 / 8)) & 0x3F) )
g_red_screen_corruption_flag = 1;
}without the variable names, it just looks like... operations. it blends in well.
now, you may be wondering how i got the name
g_red_screen_corruption_flag. well, take a
look at the one place it's referenced:
render_game_frame.
if ( g_red_screen_corruption_flag == 1 )
{
glClear(a1: 16640);
draw_colored_rect(left: 0.0, top: 0.0, width: (float)dword_555557E8CB88, height: (float)dword_555557E8CB84, r: 1.0, g: 0.0, b: 0.0, a: 1.0, a9: v283, a10: v284, a11: v281, a12: v282, a13: v285, a14: v286);
}when i saw this, i was quite confused. prior to this
point, even though i knew deep inside that all these
checks were deliberate, i hadn't consciously registered
the utter sneakiness of the CoE5 devs.
i now knew they've added these devious checks on
purpose, trying to troll reverse engineers.
i mean, look at the zaniness of the situation. if i am
understanding it correctly, here's what's happening:
- key checked on game initialization, stored sneakily
into
g_key_G1andg_key_G2_div128_stored - you start a game with your fake key
- you play 45 turns in single-player with no issues, thinking everything is perfect
- something on the map falls in water and drowns, triggering the key check
- the check fails: framebuffer is cleared and a red rectangle is permanently drawn onto your screen
i, personally, find this quite hysterical.
though this was not the check i was looking for, let's note it down:
| Level | Function | Check | Effect if failed |
|---|---|---|---|
| 1 | validate_key |
G5 == weighted_sum(G1,G2,G3,G4) % 10000 |
Can't enter menu |
| 2 | find_and_validate_key_2 |
G2 & 0x7F == hash(G1) |
"Team 0 wins" upon starting game |
| 3 | process_drowning_and_check_g1_g2 |
G2/128 == f(G1) |
Red screen corruption after 45 turns |
| 4 | ??? | ??? | ??? |
| 5 | ??? | ??? | ??? |
level 4
after that, i couldn't easily find anything.
and that's unsurprising, as the level 4 check was the
stealthiest of all.
lucky you~ you don't have to dig through anything;
we'll just skip to the answer.
let's trace through what happens.
when you open the game, validate_key is
called, and it stores your G1 and
G3 into a global array, as we saw
above:
(simplified code)
// LEVEL 1 CHECK
__int64 __fastcall validate_key(int G1, int G2, int G3, signed int G4, int G5) {
...
_DWORD* ptr = &GAME_DATA_ARRAY + 0x30;
for(int i = 0; i < 1000; i++)
{
ptr += 53; // moves right by 212 bytes (1x GameDataRecord struct)
*((_WORD *)ptr - 26) = i ^ (G3 & 0x7F); // Writes field_104 (struct offset 53*4-26*2+0x30 = 0xD0): i ^ (G3 & 0x7F)
}
_DWORD* ptr_2 = &GAME_DATA_ARRAY + 0x30;
for(int j = 0; j < 1000; j++)
{
ptr_2 += 53;
*((_WORD *)ptr_2 - 27) = j ^ G1; // Writes field_103 (struct offset 53*4-27*2+0x30 = 0xCE): j ^ G1
}
... // the other validation stuff
}it writes 1000 records of index-XORed G1
and G3 keys into field_103 and
field_104 for every 212-byte
struct GameDataRecord in the
GAME_DATA_ARRAY. but it does so in an odd
fashion: manual pointer-arithmetic. this blended in with
the other heaps of junk code, making it relatively
difficult to find.
then, on line 2921 of after_game_turn —
squashed between routine game logic about world events —
this code sits buried, undiscovered:
// NORMAL GAME LOGIC
debug_log(format: "eviltimes: new brigand lair at %s\n", v473);
...
if ( dword_555558E148F0 > 25 && (int)random(max: 700) <= 3 && word_555558E14804 == 1 && dword_555560FD9AA8 <= 0 )
{
sub_55555577E150(a1: (char)"Evil Times. There is a strong sense of evil all over the world of Elysium. Murderers, bandits and brigands are thriving.");
dword_555560FD9AA8 = sub_5555558528A0(a1: 3) + 3;
}
...
if ( (int)random(max: 100) <= 39 ) // 39% chance to trigger
{
arr_idx = random(max: 100);
debug_log(format: (char *)"a %d\n", i);
// LEVEL 4 CHECK
if ( GAME_DATA_ARRAY[i].field_104 ^ i != (((unsigned __int8)i ^ (unsigned __int8)GAME_DATA_ARRAY[i].field_103) * ((unsigned __int8)i ^ (unsigned __int8)GAME_DATA_ARRAY[i].field_103) + 124) & 0x7F )
{
debug_log(format: "AGT fluff\n");
// Zero out the entire map, causing all citadels to be destroyed
memset(s: GAME_MAP_TERRAIN_DATA, c: 0, n: 0x3037C00u);
}
}
// NORMAL GAME LOGIC
if ( dword_555558E148F0 > 36 )
{
if ( (int)random(max: 100) <= 1 ) {
...
sub_5555555FDFA0(-1, -2, v738, v737, 4, 0, (__int64)"World News", format: "A %s has found and opened an old gateway to Inferno. Elysium risks getting overrun by devils and demons if this gateway isn't closed again.", v604);
...
}
}every turn, there is a 39% chance that the game reads
field_103 and field_104 from
those structs, XORs them with the index again to reveal
the raw G1 and G3 & 0x7F
key segments, and checks it.
as you can see, when it checks the key, if it's not equal...
debug_log(format: "AGT fluff\n");
// Zero out the entire map, causing all citadels to be destroyed
memset(s: GAME_MAP_TERRAIN_DATA, c: 0, n: 0x3037C00u);yep, bye-bye map!
"oops, looks like i've deleted 50MB of map data" – this
careless function
and the devs once again disguised it, calling it "AGT fluff" (i assume, "after_game_turn fluff").
remember that log from before — the one where i said i couldn't find any errors?
cas♥ /home/cas/apps/ConquestOfElysium5/coe5
...
mist: spread from 373,21
mist: spread from 389,21
mist: spread from 388,24
mist: spread from 505,24
mist: spread from 496,26
mist: spread from 154,30
mist: spread from 151,32
a 67
AGT fluff
updatevoid
bt3: Giant Jellyfish dies [unr -1][vic 63]
hit: dead unr63 (Giant Jellyfish)
msg to 24: '1 unit drowned in a Plain.'
bt3: Asp Turtle dies [unr -1][vic 88]
hit: dead unr88 (Asp Turtle)
msg to 24: '1 unit drowned in a Plain.'
bt3: Hippocampus dies [unr -1][vic 118]
...
yep, that exact line "AGT fluff" was in the log, and i just scrolled past it. how inconspicuous!
| Level | Function | Check | Effect if failed |
|---|---|---|---|
| 1 | validate_key |
G5 == weighted_sum(G1,G2,G3,G4) % 10000 |
Can't enter menu |
| 2 | find_and_validate_key_2 |
G2 & 0x7F == hash(G1) |
"Team 0 wins" upon starting game |
| 3 | process_drowning_and_check_g1_g2 |
G2/128 == f(G1) |
Red screen corruption after 45 turns |
| 4 | after_game_turn |
G3 & 0x7F == ((G1 & 0xFF)² + 124) & 0x7F |
50MB map zeroed → all citadels destroyed |
| 5 | ??? | ??? | ??? |
level 5
so now we have a key that works perfectly — for
singleplayer, that is.
if i even tried to connect to my localhost server, it
would fail with the blasted UI telling me "Key
authentication failed (%d). ...".
to wrap this project up, i would have to crack this
check as well.
looking at the xrefs, some lines within the function
connect_to_lobby_server referenced
g_key_G1, G3, and
G4.
after examination, this code was the client sending
packet #90 ("LOBBY HELLO"):
debug_log(format: "connect %d errno %d\n", v81, v83);
if ( dword_55556090AFE0 != 0 || (_DWORD)(result = render_game_frame(a1: 0, a2: 0, a3: v99)) != 999 )
{
debug_log(format: "send hello to gamelobby\n");
// LOBBY HELLO PACKET: sends protocol version 5599, game version 539, then G1, G3_shifted, G4_div128 to server.
// Server validates these and responds with packet #91, containing an i16 value: -1=too old, -3=msg, -4=KEY BANNED, positive=success
tcp_beginpacket();
tcp_put_u8(a1: 90); // packet type 90 - "hello"
tcp_put_i16(a1: 5599);
tcp_put_i16(a1: 539);
tcp_put_i32(a1: g_key_G1);
tcp_put_i32(a1: g_key_G3_shifted_stored);
tcp_put_i32(a1: g_key_G4_div128_stored);
tcp_put_i16(a1: 0);
sendpacket_wrapper(a1: 0xFFFFFFFFLL);
netlobbyscreen();
}but this packet wasn't actually that relevant. i
think it's mostly just to not let people join when their
game client is outdated. as you can see, it sends
539 aka 5.39, the current
version of CoE5.
the real discovery was when i searched for that string "Key authentication failed (%d)":
tcp_beginpacket();
tcp_put_u8(a1: 1);
sendpacket(a1: 0xFFFFFFFFLL, a2: 0);
debug_log(format: "wait for pack 2\n");
sub_5555557006A0(a1: 2, a2: "Waiting for game status");
dword_5555599C9710 = (char)read_1_byte();
tcp_beginpacket();
tcp_put_u8(a1: 29); // GAME SERVER AUTH PACKET (type 29): sends G1 and G3_shifted to game server. Server responds with packet 30 containing 2-byte value: <=0=AUTH FAILED, 2=key in use (old user kicked), 1=success
tcp_put_i32(a1: g_key_G1);
tcp_put_i32(a1: g_key_G3_shifted_stored);
sendpacket(a1: 0xFFFFFFFFLL, a2: 0);
debug_log(format: "wait for pack 30\n");
sub_5555557006A0(a1: 30, a2: &byte_5555559607CE);
value = (__int16)read_2_bytes();
if ( value <= 0 )
{
// THE KEYGEN FAILS THIS CHECK! (the local server has more key-validation math — Level 5)
showing_some_ui(
a1: (unsigned int)"Key authentication failed (%d). Make sure no one else is using the same serial key. If you recently disconnected from a server wait a minute before reconnecting.",
a2: value,
a3: v157,
a4: v158,
a5: v160,
a6: v161);
kill_the_game(s: (char *)&byte_5555559607CE);
}this client code begins by sending packet #1, some
kind of empty ping. it then waits for the server to
respond with packet #2 "pong", and reads it with
read_1_byte.
BORING!
but right after that, we send packet #29
("KEY AUTH") to the server, and we await
the packet #30 response from the server, reading it with
value = (__int16)read_2_bytes();
if value is negative, the authentication
failed, and boom, that pesky failure UI pops up.
so the most pragmatic approach was to jump straight
to the server-side
game_server_packet_handler for packet
#29.
and here it is, the whole algorithm laid out:
if ( received_pkt_type == 29 )
{
// LEVEL 5 CHECK: Packet type 29 - Key validation. Reads G1 and G3/128.
// Response: 0=fail, 1=success, 2=duplicate serial (kick old user). Sends packet #30 with i16 result.
value = 0;
G1 = tcp_get_i32();
G3_shifted = tcp_get_i32();
if ( G1 > 0 )
{
v342 = G1 + 300;
if ( (G1 & 1) == 0 )
v342 = G1;
if ( (v342 & 4) != 0 )
v342 += 30;
v343 = v342 >> 1;
...
if ( (v365 & 1) != 0 )
v365 += 300;
if ( (v365 & 4) != 0 )
v365 += 30;
v366 = v365 ^ v364;
v367 = v365 >> 1;
if ( (v367 & 1) != 0 )
LOBYTE(v367) = v367 + 44;
if ( (v367 & 4) != 0 )
LOBYTE(v367) = v367 + 30;
if ( G3_shifted == ((v366 ^ (unsigned __int8)v367) & 0x3F) )
{
v368 = dword_555558E148EC;
dword_55556193B5C0[v20] = G1;
if ( v368 < 0 )
{
value = 1;
}
else
{
v369 = 0;
value = 1;
do
{
if ( v14 != (_DWORD)v369 && G1 == dword_55556193B5C0[v369] )
{
value = 2;
debug_log(format: "con %d has same serial %d", v369, G1);
((void (__fastcall *)(_QWORD))sub_5555556FE7A0)(a1: (unsigned int)v369);
v368 = dword_555558E148EC;
}
++v369;
}
while ( v368 >= (int)v369 );
}
}
}
tcp_beginpacket();
tcp_put_u8(a1: 30); // Respond with pkt#30
tcp_put_i16(a1: value);
sendpacket_wrapper(a1: v14);
goto LABEL_58;
}the server does some sort of XOR-shift-add300-add30
chain on the client's G1 then checks it
against their G3_shifted.
and with that, our table of checks is completed!!! (yay!)
| Level | Function | Check | Effect if failed |
|---|---|---|---|
| 1 | validate_key |
G5 == weighted_sum(G1,G2,G3,G4) % 10000 |
Can't enter menu |
| 2 | find_and_validate_key_2 |
G2 & 0x7F == hash(G1) |
Key not saved, asked each time |
| 3 | process_drowning_and_check_g1_g2 |
G2/128 == f(G1) |
Red screen corruption after 45 turns |
| 4 | after_game_turn |
G3 & 0x7F == ((G1&0xFF)² + 124) & 0x7F |
50MB map zeroed → all citadels destroyed |
| 5 | game_server_packet_handler |
G3/128 == level5_hash(G1) & 0x3F |
Can't join multiplayer |
if you want your
XXXXXX-XXXX-XXXX-XXXX-XXXX key to unlock
all features of CoE5, you need to pass every one of
these checks.
it actually works out nicely: G2,
G3, and G5 are the dependent
variables to your G1 and
G4.
what do i mean? well, with all those algorithms
combined, you can select ANY G1 and
G4, and the math relationships work out so
there is exactly ONE valid pair of G2,
G3, G5 that satisfies all
those checks (per my testing of the finished
keygen).
G2 and G3 can be calculated
purely from G1:
for (int g2 = 0; g2 < 10000; g2++)
{
// level 2 & level 3
if (find_and_validate_key_2(G1, g2) && drowning_check(G1, g2))
{
printf("Found valid G2=%04d to G1=%06d\n", g2, G1);
}
}
for (int g3 = 0; g3 < 10000; g3++)
{
// level 4 & level 5
if (AGT_fluff(G1, g3) && server_packet_29_handler(G1, g3))
{
printf("Found valid G3=%04d to G1=%06d\n", g3, G1);
}
}and G5 is calculated at the very end
from the weighted sum of the other four:
for (int g5 = 0; g5 < 10000; g5++)
{
// level 1
if (validate_key(G1, G2, G3, G4, g5))
{
printf("Found valid G5=%04d\n", g5);
}
}outro
whew, that was a lot of decompiled code and algorithms to go over. i hope it was interesting — i tried to make it entertaining, but probably failed miserably.
this was a fun and enlightening 3-day weekend project, i started around 2026-09-05 22:46, and cracked level 5 at 2026-09-07 15:09. one thing i learned was that the CoE5 devs have a great sense of humor.
disclaimer: i would love to share the keygen
source code, but it's unfortunately probably
illegal.
for that reason, i may also have messed with the
constant numbers for the checks in this article, meaning
you would have to reverse engineer the game yourself to
make a working keygen. hey, that's not a bad excuse to
pick up a new skill ;)
tip: if you want to test other keys on your own coe5
binary, move your coe5key out of the game
directory, and you'll get the CD Key prompt on
startup.
hope you have an amazing day.