Files
blog/2026-02-26-take-me-to-goblin-catapults-keygen.md
2026-02-27 00:33:59 +00:00

380 lines
14 KiB
Markdown

---
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 <cstdint>
#include <cstdlib>
#include <ctime>
#include <format>
#include <string>
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);
}
}
```