> For the complete documentation index, see [llms.txt](https://ret2basic.gitbook.io/ctfwriteup/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://ret2basic.gitbook.io/ctfwriteup/game-hacking/pwn-adventure-3-pwnie-island/chall-7-pirates-treasure.md).

# Chall 7: Pirate's Treasure

## Pirate's Treasure

Pirate's Treasure is a DLC keygen challenge on the Pirate Bay ship.

It looks like an online code-entry problem, but invalid keys are rejected by the client before the network layer. That is why bad-key logs do not show a `ky` submission packet.

Source note:

* `../12-pirates-treasure-dlc-keygen.md`

### Core Idea

```
reverse KeyVerifier::VerifyKey
-> implement a local key generator
-> generate a key that passes the same verifier
-> enter it in the real DLC chest UI
-> client submits ky
-> server accepts and issues FlagOfThePirate
```

This is not a brute force challenge, and it is not solved by simply bypassing the UI. The server also validates the submitted key.

### Negative Evidence

The invalid-key run is important:

```
open DLC chest
enter bad key
observe client-side rejection
LiveLogger sees no ky packet
```

That absence is not a capture failure. It tells us the bad key died before `ClientHandler::SubmitDLCKey` was reached.

IDA explains the path:

```
Player::SubmitDLCKey
    if (!KeyVerifier::VerifyKey(key)):
        show "Invalid Unlock Code"
    else:
        GameWorld->SubmitDLCKey(player, key)
```

The server-side path uses the same verifier shape:

```
Player::PerformSubmitDLCKey
    if !player.HasPickedUp("DLC") and KeyVerifier::VerifyKey(key):
        AddItem("CowboyCoder", 1)
        AddItem("ROPChainGun", 1)
        AddItem("FlagOfThePirate", 1)
        AddItem("RifleAmmo", 120)
        AddItem("RevolverAmmo", 100)
        AddItem("Coin", 17500)
        MarkAsPickedUp("DLC")
```

That is why patching the client to skip the local check would not be enough for the online challenge. The submitted key still needs to pass the server's verifier.

### Keygen Shape

Once the verifier is understood, key generation becomes a constructive problem: create a string that satisfies the verifier's checks instead of guessing online.

Public source:

* [challenges/07-pirates-treasure/pirate\_dlc\_keygen.py](https://github.com/ret2basic/Pwn-Adventure-3-Pwnie-Island/blob/main/challenges/07-pirates-treasure/pirate_dlc_keygen.py)

It has a self-test mode and can generate reproducible keys from a seed for article screenshots.

### Tool Implementation

The key generator is [challenges/07-pirates-treasure/pirate\_dlc\_keygen.py](https://github.com/ret2basic/Pwn-Adventure-3-Pwnie-Island/blob/main/challenges/07-pirates-treasure/pirate_dlc_keygen.py).

It mirrors `KeyVerifier::VerifyKey` from the local `GameLogic.dll`. The script is intentionally written as a verifier plus constructive generator, not as a random online brute forcer.

The local verifier has three major layers:

```
1. normalize the key
   remove spaces and hyphens
   accept only the custom 32-character alphabet
   require exactly 25 symbols
   require checksum: sum(first 24 values) & 0x1f == value[24]

2. decode the first 24 symbols
   custom little-endian base32 packing
   24 * 5 bits -> 15 decoded bytes

3. verify the crypto-shaped relation
   use a 12-byte RSA-like block
   compare decrypted bytes against expected_low || "PWNADV3\\0"
```

In IDA, the alphabet is visible as:

```
0123456789ABCDEFHJKLMNPQRTUVWXYZ
```

The omitted characters are the ambiguous ones: `G`, `I`, `O`, and `S`.

The first 24 symbols pack 120 bits:

```
24 * 5 bits = 120 bits = 15 bytes
```

The 15 decoded bytes contain an overlap:

```
decoded[0:12]   RSA ciphertext buffer
decoded[11:15]  extra dword
```

Before RSA, the verifier masks the high six bits of the last cipher byte:

```
cipher[11] &= 0x03
```

The expected plaintext is derived from the overlapping dword:

```
expected_low = (uint32_le(decoded[11:15]) ^ 0xAEB7037B) >> 2
expected     = expected_low_le32 || "PWNADV3\\0"
```

Then the verifier performs the 96-bit modular exponent check:

```
pow(uint96_le(cipher), 65537, n) == expected
```

The constants are embedded in the script because they were recovered from the local IDA view:

```
ALPHABET  = "0123456789ABCDEFHJKLMNPQRTUVWXYZ"
XOR_CONST = 0xAEB7037B
MAGIC     = b"PWNADV3\\x00"
N         = 0x03C9921AC0185B3AAAE37E1B
P         = 33759901540733
Q         = 34719860683127
E         = 0x10001
D         = inverse(E, lcm(P - 1, Q - 1))
```

The modulus appeared in the binary as three little-endian dwords:

```
1b 7e e3 aa  3a 5b 18 c0  1a 92 c9 03
```

Interpreted as a 96-bit little-endian integer, that is:

```
0x03c9921ac0185b3aaae37e1b
```

Generation works backward from a chosen 30-bit value. `build_key_from_high30()` computes the expected low dword, constructs the plaintext `expected_low || MAGIC`, encrypts it with the private exponent `D` to obtain a cipher block, packs the overlapping high bits into the decoded payload, base32 encodes it, and appends the checksum character.

The script validates itself in two ways:

```
--verify KEY       run the local VerifyKey mirror on an existing key
--self-test        generate fixed vectors and 100 seeded random keys, then re-verify them
```

This is why the article can claim a generated key is structurally valid before it is typed into the real chest UI.

### PoC Video

{% embed url="<https://youtu.be/eMOwplyyYr8?si=CMQyF_eY0WUKiJCa>" %}
