From f4021e388b392958781817bd8088a66ffc831f39 Mon Sep 17 00:00:00 2001 From: 0x3bb <0x3bb@3bb.io> Date: Thu, 26 Feb 2026 19:26:32 +0000 Subject: [PATCH] wos --- ...2-26-take-me-to-goblin-catapults-keygen.md | 379 ++++++++++++++++++ 1 file changed, 379 insertions(+) create mode 100644 2026-02-26-take-me-to-goblin-catapults-keygen.md diff --git a/2026-02-26-take-me-to-goblin-catapults-keygen.md b/2026-02-26-take-me-to-goblin-catapults-keygen.md new file mode 100644 index 0000000..bdc0411 --- /dev/null +++ b/2026-02-26-take-me-to-goblin-catapults-keygen.md @@ -0,0 +1,379 @@ +--- +title: "Take me to the goblin catapults" +author: "0x3bb" +date: M02-26-2026 +--- + +I used to play a game called [Well of Souls](http://www.synthetic-reality.com/) back in the early 2000s. + +It looks incredibly dated now, but in its time, you'd find close to a hundred players online killing slimes, goblins and trying their luck at challenging other player killers. + +A [Golden Soul](http://www.synthetic-reality.com/wosGolden.htm) is a token of appreciation to the single developer that made this game. There were some minor QoL advantages, but really, if you had one -- it was mostly for bragging rights and to show your support. + +![](/b/images/wos_golden.png) + +Since I started reversing games with a [friend](https://uauth.io), I thought this was be a good entry level challenge for me and a fun throwback to one of the first online games I ever experienced. + +Dan, if you're reading this (probably busy enjoying retirement to be fair), thanks for the memories in game -- and coming back to Well of Souls as an adult for RE efforts has been a ton of fun. Please do mail us if you ever read this! + +# license hashing function + +The format of a Well of Souls license key is 16 characters in length and looks something like `SOUL00123456479E` + +![Golden Soul activation screen on Well of Souls](/b/images/wos_activation.png) + + +**Prefix** `SOUL` `serial[0:3]` + +**Serial** `00123456` `serial[4:12]` + +**Checksum** `479E` `serial[13:16]` + +Below is the full pseudo code from BN -- `sub_40a19c`, the hashing function for the serial key. + + +## BN Psuedo C + +``` +0040a19c uint32_t sub_40a19c(char* arg1, uint32_t* arg2) +0040a19c { +0040a19c int32_t esi = 0xd1e7; +0040a1b7 char var_18; +0040a1b7 strncpy(&var_18, &arg1[4], 8); +0040a1c3 char var_10 = 0; +0040a1c9 void* ebx = &data_4dd448; +0040a1d1 uint32_t eax_2 = strtoul(&var_18, nullptr, 0xa); +0040a1d1 +0040a1e2 if (eax_2 >= 0x708) +0040a1e4 ebx = &data_4dd488; +0040a1e4 +0040a1ef if (eax_2 >= 0xce4) +0040a1f1 ebx = &data_4dd4c8; +0040a1f1 +0040a22b for (int32_t i = 0; i < 0xc; ) +0040a22b { +0040a204 int32_t eax_3; +0040a204 (uint8_t)eax_3 = arg1[i]; +0040a207 int32_t eax_4 = eax_3 & 0x7f; +0040a213 int32_t edx_4 = *(uint32_t*)((char*)ebx + (i << 2)); +0040a216 int32_t esi_2 = (esi ^ (eax_4 * esi)) + (eax_4 << (uint8_t)i); +0040a218 i += 1; +0040a225 esi = esi_2 - (edx_4 + (int64_t)esi_2 % 0x64); +0040a22b } +0040a22b +0040a22d uint32_t result = esi & 0x7fffffff; +0040a22d +0040a239 if (eax_2 < 0xce4) +0040a247 result = (uint32_t)(uint16_t)((int64_t)result % 0xff2f); +0040a247 +0040a252 if (arg2) +0040a254 *(uint32_t*)arg2 = eax_2; +0040a254 +0040a25e return result; +0040a19c } + +``` + +## Reversed + +``` +0040a19c uint32_t hashSerial(char* serial, uint32_t* pKind) +0040a19c { +0040a19c // hash is a transformation of serial characters which begins as an +0040a19c // initial seed for the first pass of the hashing function, +0040a19c // hardcoded within the binary +0040a19c int32_t hash = 53735; +0040a1b7 // Identifier holds the first 8 characters of a serial, e.g. +0040a1b7 // 00123456 to determine if a different SUB_TABLE should be used +0040a1b7 char identifier; +0040a1b7 strncpy(&identifier, &serial[4], 8); +0040a1c3 char var_10 = 0; +0040a1c9 // Set the substitution table to the default +0040a1c9 void* SUB_TABLE = &data_4dd448; +0040a1d1 // ul of identifier, e.g. 00123456 -> 123456 +0040a1d1 uint32_t kind = strtoul(&identifier, nullptr, 0xa); +0040a1d1 +0040a1e2 if (kind >= 1800) +0040a1e4 SUB_TABLE = &data_4dd488; +0040a1e4 +0040a1ef if (kind >= 3300) +0040a1f1 SUB_TABLE = &data_4dd4c8; +0040a1f1 +0040a22b for (int32_t i = 0; i < 12; ) +0040a22b { +0040a204 int32_t current; +0040a204 (uint8_t)current = serial[i]; +0040a207 int32_t currentToAscii = current & 127; +0040a213 int32_t chunk = *(uint32_t*)((char*)SUB_TABLE + (i << 2)); +0040a216 int32_t transformed = +0040a216 (hash ^ (currentToAscii * hash)) + (currentToAscii << (uint8_t)i); +0040a218 i += 1; +0040a225 hash = transformed - (chunk + (int64_t)transformed % 0x64); +0040a22b } +0040a22b +0040a22d // Set highest bit to 0 to ensure hash is positive +0040a22d uint32_t sanitized = hash & 0x7fffffff; +0040a22d +0040a239 if (kind < 3300) // Force 16 bit hash integer +0040a247 sanitized = (uint32_t)(uint16_t)((int64_t)sanitized % 0xff2f); +0040a247 +0040a252 if (pKind) // Set kind from calling function on the stack +0040a254 *(uint32_t*)pKind = kind; +0040a254 +0040a25e return sanitized; +0040a19c } +``` + +There are three different sub tables in the games code, and the one used is based on the identifier which are the 8 characters after `SOUL` in a serial key. + +Each element in the table is used to transform the serial characters, in order, one by one. + +``` +004dd448 int32_t SUB_TABLE_A[0x10] = +004dd448 { +004dd448 [0x0] = 0x0000000b +004dd44c [0x1] = 0x00000011 +004dd450 [0x2] = 0x00000003 +004dd454 [0x3] = 0x00000065 +004dd458 [0x4] = 0x00000007 +004dd45c [0x5] = 0x00000138 +004dd460 [0x6] = 0x0000000c +004dd464 [0x7] = 0x0000000d +004dd468 [0x8] = 0x00000013 +004dd46c [0x9] = 0x00000017 +004dd470 [0xa] = 0x0000000f +004dd474 [0xb] = 0x0000001b +004dd478 [0xc] = 0x00000012 +004dd47c [0xd] = 0x0000001f +004dd480 [0xe] = 0x00000004 +004dd484 [0xf] = 0x0000001d +004dd488 } +004dd488 int32_t SUB_TABLE_B[0x10] = +004dd488 { +004dd488 [0x0] = 0x0000000b +004dd48c [0x1] = 0x00000011 +004dd490 [0x2] = 0x00000003 +004dd494 [0x3] = 0x00000065 +004dd498 [0x4] = 0x00000034 +004dd49c [0x5] = 0x0000000b +004dd4a0 [0x6] = 0x00000061 +004dd4a4 [0x7] = 0x00000021 +004dd4a8 [0x8] = 0x00000011 +004dd4ac [0x9] = 0x00000067 +004dd4b0 [0xa] = 0x00000029 +004dd4b4 [0xb] = 0x0000004d +004dd4b8 [0xc] = 0x00000012 +004dd4bc [0xd] = 0x0000001f +004dd4c0 [0xe] = 0x00000004 +004dd4c4 [0xf] = 0x0000001d +004dd4c8 } +004dd4c8 int32_t SUB_TABLE_C[0x10] = +004dd4c8 { +004dd4c8 [0x0] = 0x00000057 +004dd4cc [0x1] = 0x0000001f +004dd4d0 [0x2] = 0x00000006 +004dd4d4 [0x3] = 0x0000002c +004dd4d8 [0x4] = 0x00000034 +004dd4dc [0x5] = 0x000000b +004dd4e0 [0x6] = 0x00000058 +004dd4e4 [0x7] = 0x0000002f +004dd4e8 [0x8] = 0x00000011 +004dd4ec [0x9] = 0x0000000b +004dd4f0 [0xa] = 0x00000029 +004dd4f4 [0xb] = 0x0000005e +004dd4f8 [0xc] = 0x00000005 +004dd4fc [0xd] = 0x000000ff +004dd500 [0xe] = 0x00000004 +004dd504 [0xf] = 0x0000001d +004dd508 } +``` + +Interesting to note from above, is that there are 16 constants in these tables, but the hashing algorithm only iterates over 12 characters, so 4 in each are useless -- perhaps they were just leftovers from initial obfuscation efforts. + +![Sub table instruction on each character of the Well of Souls serial key](/b/images/wos_0040a213.png) + +For each character in the serial, it gets the first byte in the relevant `SUB_TABLE` and stores it in `chunk` + +![Sub table memory dump in Well of Souls activation logic](/b/images/wos_0040a213_dump.png) + +To visualize how it works, given the first 2 characters in `SOUL00123456`: + - on `S` the first byte in the table is 87 in decimal + - on `O` the first byte in the table 31 in decimal + ...and so forth. + +![](/b/images/wos_0040a1f8.png) + +`ESI` holds the current hash value, in this case it's pretty easy to see because it's the initial seed of `0xD1E7` (53735). Note due to compiler optimizations it gets harder to follow the linear flow of the pseudo code in the debugger. + +Finally, the `transformed` hash is subtracted from the remainder of the sum of the `chunk` and `transformed` hash divided by 100. Besides the additional obfuscation, maybe to prevent integer overflows from subsequent transformations in the loop. + +You might observe that we only hashed the first 12 characters (`SOUL00123456`), instead of the full 16 characters (`SOUL00123456479E`) -- this is because the final 4 characters, `479E`, serve as the checksum within the serial. + + +# checksum validation + +Now we have the hash from the serial, we can look at the calling function: + +## BN Pseudo C + +``` +0040a262 int32_t sub_40a262(char* arg1, char* arg2, uint32_t* arg3) +0040a262 { +0040a262 uint32_t var_8 = 0; +0040a27b uint32_t eax = sub_40a19c(arg1, &var_8); +0040a29e uint32_t ebx_2 = (uint32_t)strtoul(&arg1[0xc], nullptr, 0x10); +0040a29e +0040a2a0 if (var_8 >= 0xce4) +0040a2b4 ebx_2 |= strtoul(arg2, nullptr, 0x10) << 0x10; +0040a2b4 +0040a2c2 int32_t result = -((0 - 0)); +0040a2c2 +0040a2c6 if (result) +0040a2cb *(uint32_t*)arg3 = eax; +0040a2cb +0040a2d5 return result; +0040a262 } +``` + +## Reversed + +``` +0040a262 int32_t validateSerial(char* serial, char* activationCode, uint32_t* pHash) +0040a262 { +0040a262 uint32_t kind = 0; +0040a27b // Get the hash of a serial code +0040a27b uint32_t hash = hashSerial(serial, &kind); +0040a29e // Checksum is the last 4 characters of a serial +0040a29e uint32_t checksum = (uint32_t)strtoul(&serial[12], nullptr, 16); +0040a29e +0040a2a0 // If kind is >= 3300, then we enforce a 32-bit(8-char) +0040a2a0 // activation code by appending 4 extra characters from the +0040a2a0 // 16-bit checksum at the end of serial. This is where the +0040a2a0 // activation code textbox in the UI serves a purpose +0040a2a0 if (kind >= 3300) +0040a2b4 checksum |= strtoul(activationCode, nullptr, 0x10) << 0x10; +0040a2b4 +0040a2c2 // Not sure how to fix this up in BN, but, +0040a2c2 // bool valid = (checksum == hash); +0040a2c2 bool valid = (char)-((0 - 0)); +0040a2c2 +0040a2c6 if (valid) +0040a2cb *(uint32_t*)pHash = hash; +0040a2cb +0040a2d5 return valid; +0040a262 } +``` + +This is simpler to understand: the function takes a `serial` and an `activationCode`. + +It calls the above function we reversed: `hashSerial`. + +It then takes the last 4 characters of the serial key as the `checksum` and compares if the checksum matches the hash. + +![](/b/images/wos_activation_subcode.png) + +If we know the last 4 unhashed characters of the serial are the checksum, then why the additional subcode field on the right? + +I would think (and I may be entirely wrong) what happened was, in earlier builds of WoS, the serial was a single field with a 16-bit checksum (4 characters). The keys may have been easily bruteforced, and so, in later builds, the developer chose to make it more difficult to reverse by adding a 32-bit checksum (kind -> `SUB_TABLE_C`). + +However, the developer still needed the older keys to work, so he extended the checksum by adding an optional field. If `kind >= 3300`, the `activationCode` is appended to the `checksum`. + + +## keygen + +Basically, all that is needed is to just generate a random number for the serial, reference the obfuscation tables in the headers, and since we're doing the hashing the same as in the game, we can append the sanitized checksum to the serial by masking the most significant bit of the final hash. + +We've used `SOUL00123456` in the discussions, so here's what the full pass looks like: + +``` + +| i | char | hex | transformed | chunk | hash | +|----|------|------|-------------|-------|-------------| +| 0 | 'S' | 0x53 | 0x0044dc55 | 87 | 0x0044dbc9 | +| 1 | 'O' | 0x4f | 0x157b096c | 31 | 0x157b0901 | +| 2 | 'U' | 0x55 | 0x34a2f5a8 | 6 | 0x34a2f56e | +| 3 | 'L' | 0x4c | -0x6b3dd3da | 44 | 0x94c22c24 | +| 4 | '0' | 0x30 | 0x70aa6de4 | 52 | 0x70aa6d84 | +| 5 | '0' | 0x30 | 0x6f5eeb44 | 11 | 0x6f5eeae1 | +| 6 | '1' | 0x31 | 0x3e742c30 | 88 | 0x3e742b88 | +| 7 | '2' | 0x32 | 0x0cc4c418 | 47 | 0x0cc4c3b9 | +| 8 | '3' | 0x33 | -0x78098e9e | 17 | 0x87f67187 | +| 9 | '4' | 0x34 | 0x19f9e6eb | 11 | 0x19f9e691 | +| 10 | '5' | 0x35 | 0x79462e94 | 41 | 0x79462e1b | +| 11 | '6' | 0x36 | -0x1272b857 | 94 | 0xed8d479e | +``` + +The final hash is `0xed8d479e` + +Since we masked the signed bit in the hash to make sure it's positive: + +`0xe` in binary is `1110` -> `0110` -> `0x6`, the checksum for `SOUL00123456` is `0x6d8d479e` + +Our key is therefore `SOUL00123456479E` with a subcode of `6D8D` + +![](/b/images/wos_soul00123456_activate.png) + +![](/b/images/wos_soul123456.png) + +```cpp +#include "keygen.h" +#include +#include +#include +#include +#include + +std::string keygen() { + srand(time(0)); + std::string serial = ""; + + // First generate random serial digits + for (int i = 0; i < 8; i++) { + serial += std::to_string(rand() % 10); + } + + // Identifier means we use a different sub table + // in the hashing loop at i + uint32_t identifier = std::stoul(serial); + const int32_t* sub_table = nullptr; + + if (identifier >= 3300) { + sub_table = SUB_TABLE_C; // will require subcode + } + else if (identifier >= 1800) { + sub_table = SUB_TABLE_B; + } + else { + sub_table = SUB_TABLE_A; + } + + std::string key = "SOUL" + serial; + + // Hardcoded in binary as initial hash seed + uint32_t hash = LICENSE_KEY_SEED; + + // 0040a19c inline hashing logic + for (int i = 0; i < 12; i++) { + int32_t to_ascii = key[i] & 0x7F; + int32_t transformed = (hash ^ (to_ascii * hash)) + (to_ascii << i); + int32_t chunk = sub_table[i]; + + hash = transformed - (chunk + (transformed % 100)); + } + + uint32_t checksum = hash & 0x7FFFFFFF; + if (identifier < 3300) { + checksum = (uint32_t)(uint16_t)(checksum % 65327); + } + // 32-bit checksum, needs 8 chars (subcode) + if (identifier >= 3300) { + uint16_t low = checksum & 0xFFFF; + uint16_t high = (checksum >> 16) & 0xFFFF; + return std::format("{}{:04X} || SUBCODE {:04X}", key, low, high); + } + else { + // Checksum follows the serial, no subcode needed + return std::format("{}{:04X}", key, checksum); + } +} +```