# TryHackMe Signed Messages—Writeup

Welcome back to another part of the Valentine's special CTF series of TryHackMe, Signed Messages.  Let's start.

<figure><img src="https://271954773-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FYsivTjPn2jLXI0ZgVqeF%2Fuploads%2FShXHZdoKRYyU231Xcuqf%2F46f187145c330d4621d515c07cbc38e2.gif?alt=media&#x26;token=a2a5b5a6-003d-4bc9-a5cc-abb9e483d882" alt=""><figcaption></figcaption></figure>

Let's visit the website for a while:

<figure><img src="https://271954773-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FYsivTjPn2jLXI0ZgVqeF%2Fuploads%2FsDBsG5D9Npl4lhT3uE0N%2FScreenshot%20(1339).png?alt=media&#x26;token=47456453-e832-431f-bebf-5a56ef92cd71" alt=""><figcaption></figcaption></figure>

Let's register

<figure><img src="https://271954773-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FYsivTjPn2jLXI0ZgVqeF%2Fuploads%2F6AcC27yxuJl3ZfDWTUGD%2FScreenshot%20(1342).png?alt=media&#x26;token=c31b2251-ab31-474c-b38c-47643f11fa93" alt=""><figcaption></figcaption></figure>

There's a note at the end of the registration form

<figure><img src="https://271954773-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FYsivTjPn2jLXI0ZgVqeF%2Fuploads%2FIfiSF7fWRomMQICMitDM%2FScreenshot%20(1343).png?alt=media&#x26;token=2a5d0428-8228-4087-aabf-06046b039b54" alt=""><figcaption></figcaption></figure>

This appears to be the authentication mechanism in use. After registration, the application issues a private key for signing and a public key for verification, indicating that the authentication flow relies on a signature‑based model.

There are several endpoints available, so I decided to enumerate them using Gobuster.&#x20;

`gobuster dir -u http://10.67.189.172:5000/ -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt -t 2`

<figure><img src="https://271954773-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FYsivTjPn2jLXI0ZgVqeF%2Fuploads%2FHECcVDg209lrRdFeZikj%2FScreenshot%20(1341).png?alt=media&#x26;token=5502b78e-fa60-4bd5-85ea-9481f28f074e" alt=""><figcaption></figcaption></figure>

As you can see there's a lot, but one stands out, which is the `/debug` , this endpoint is hidden in the application so let's visit it.

<figure><img src="https://271954773-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FYsivTjPn2jLXI0ZgVqeF%2Fuploads%2FDgPqISb9nMu8P6QGZeMe%2FScreenshot%20(1348).png?alt=media&#x26;token=62e491ae-de43-44c3-8e1a-ed7ae3fd6818" alt=""><figcaption></figcaption></figure>

The logs show that the system is running in **development mode**, and more importantly:

```
Using deterministic key generation
Seed pattern: {username}_lovenote_2026_valentine
```

This means RSA keys are **not randomly generated**. Instead, they are deterministically derived from a predictable seed based on the username.

The seed follows a fixed format:

```
{username}_lovenote_2026_valentine
```

Once constructed:

* The seed is converted into bytes
* Hashed using **SHA-256**
* The resulting hash is interpreted as a large integer

This integer becomes the foundation for prime generation.

The first prime (`p`) is generated as follows:

1. Convert `SHA256(seed)` into a large integer
2. Start checking consecutive integers
3. Stop once a valid prime is found

This means `p` is simply the **first prime number greater than or equal to the hash value**.

To avoid reusing the same prime, the system slightly modifies the seed:

```
seed + b"pki"
```

Then:

1. Hash the modified seed using SHA-256
2. Convert the hash into an integer
3. Increment until a valid prime is found

This produces the second prime (`q`).

Once both primes are derived:

* The RSA modulus is computed as `n = p × q`
* A standard RSA-2048 key pair is constructed
* Both public and private keys are saved to disk

At this point, the cryptographic process is complete.

The problem is not RSA itself, it’s **how the primes are generated**.

Because:

* The seed format is fixed and predictable
* The username is known or easily guessable
* The hashing and prime-search logic is fully exposed

Anyone can **reproduce the exact same RSA key pair** offline.

In other words:

> Knowing the username is enough to regenerate the private key.

This completely breaks the security guarantees of RSA.

Now if you noticed, there's a `/verify`  endpoint:

<figure><img src="https://271954773-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FYsivTjPn2jLXI0ZgVqeF%2Fuploads%2FEPXlQ9mjmAz3cTH0kJmx%2FScreenshot%20(1361).png?alt=media&#x26;token=83c6be34-88af-4b7e-9638-650e16fab89a" alt=""><figcaption></figcaption></figure>

And there's a message below

<figure><img src="https://271954773-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FYsivTjPn2jLXI0ZgVqeF%2Fuploads%2Flmvsma9r0SgqmCqW99Op%2FScreenshot%20(1362).png?alt=media&#x26;token=af675861-7d79-401a-b857-eb9e77bae8e4" alt=""><figcaption></figcaption></figure>

Using the public key to verify a message via its hexadecimal digital signature is standard procedure.

With the ability to forge a valid PSS signature, the trust model of the application effectively collapses. At this point, the verification process no longer serves its intended purpose, any data we sign is accepted as legitimate.

Now let's make a Python script that will break the security of this challenge:

```python
from Crypto.PublicKey import RSA
from Crypto.Signature import pss
from Crypto.Hash import SHA256
import hashlib
from sympy import nextprime

USER = "admin"
MESSAGE = "Can you help me?"

def make_key(u):
    """
    Deterministically reconstructs the RSA private key used by the system.

    The key generation is based entirely on a predictable seed derived
    from the username and a fixed string. The seed is hashed using SHA256,
    then converted into a large integer. Consecutive integers are checked
    until valid primes are found, producing p and q.

    Because this process is deterministic, anyone who knows the username
    and seed format can regenerate the exact same RSA key pair.
    """
    seed = (u + "_lovenote_2026_valentine").encode()
    
    p_val = nextprime(int(hashlib.sha256(seed).hexdigest(), 16))
    q_val = nextprime(int(hashlib.sha256(seed + b"pki").hexdigest(), 16))
    
    n = p_val * q_val
    e = 65537
    phi = (p_val - 1) * (q_val - 1)
    d = pow(e, -1, phi)
    
    return RSA.construct((n, e, d))

def make_signature(k, msg):
    """
    Generates a valid RSA-PSS digital signature for a given message.

    Since the private key is fully reconstructed, this function can
    produce signatures that are indistinguishable from those generated
    by the original server. The signature will successfully verify
    using the server's public key.

    This demonstrates that the cryptographic system is broken not
    through mathematics, but through predictable key generation.
    """
    h = SHA256.new(msg.encode())
    bits = k.size_in_bits()
    emlen = (bits - 1 + 7) // 8
    salt = emlen - h.digest_size - 2
    
    if salt < 0:
        raise Exception("key too small")
    
    sig = pss.new(k, salt_bytes=salt).sign(h)
    return sig.hex()

try:
    print("Generating key...")
    key = make_key(USER)
    
    print("Signing message...")
    sig = make_signature(key, MESSAGE)
    
    print("Signature generated!")
    print(sig)

except Exception as e:
    print("Oops:", e)
```

Executing the exploit produces a valid forged signature, allowing us to retrieve the flag.

<figure><img src="https://271954773-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FYsivTjPn2jLXI0ZgVqeF%2Fuploads%2FyBjrnHJUvYNlfn4EW2lN%2FScreenshot%20(1360).png?alt=media&#x26;token=073e75a8-ebfe-45c5-a05b-6eb4361c964e" alt=""><figcaption></figcaption></figure>

Now let's use it as signature for user admin!

<figure><img src="https://271954773-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FYsivTjPn2jLXI0ZgVqeF%2Fuploads%2FawlBudhLG0qyd1pbPUxh%2FScreenshot%20(1356).png?alt=media&#x26;token=3120acea-89da-4bd1-bb6f-90d9ffdb0261" alt=""><figcaption></figcaption></figure>

And there's the flag!

This challenge doesn’t end with breaking a system, it ends with understanding why it broke. What failed here wasn’t encryption, nor mathematics, nor complexity. It was the assumption that a cryptographic primitive, once implemented, is automatically secure. Signatures were verified, checks were performed, and yet the boundary between trusted and untrusted quietly dissolved. When verification logic can be satisfied without authority, security becomes theater. The system continues to function, unaware that its guarantees no longer mean anything. The flag was never hidden behind computation, it was hidden behind misplaced confidence. And once that confidence cracked, everything else followed.
