Writeups/TryHackMe/Signed Messages - TryHackMe Writeup | Deterministic RSA Key Recovery
TryHackMeMediumRoom

Signed Messages - TryHackMe Writeup | Deterministic RSA Key Recovery

Signed Messages TryHackMe writeup โ€” Love At First Breach 2026: LoveNote deterministic RSA, /debug key leak, and PSS signature forgery to capture the flag.

##TryHackMe Room โ€” Signed Messages (Love At First Breach 2026)

Every message is cryptographically signed using RSA-2048. No message can be forged.

Signed Messages is a Valentine-themed cryptography challenge built around LoveNote, a Flask web application that lets users register and sign messages with RSA. The platform claims that every message is signed with RSA-2048 and that no message can be forged. My goal was to obtain the flag, which the challenge implied would require forging a valid admin-signed message that the server would accept. That usually means either breaking the cryptography (e.g. factoring the modulus, which is infeasible for proper RSA) or finding a flaw in how the system is implemented โ€” for example, weak key generation, exposed keys, or a debug endpoint that leaks critical information. I started with reconnaissance: port scan, then directory enumeration to map all routes and spot anything that looked like debug, config, or admin-only functionality. This writeup documents the full chain from that initial recon through understanding the deterministic key generation, reconstructing the admin key, and submitting a forged signature to capture the flag.


##Overview

ItemDetail
GoalForge an admin-signed message and submit it to /verify to get the flag
Attack chainRecon โ†’ /debug leak โ†’ deterministic RSA โ†’ rebuild admin key โ†’ sign message โ†’ verify โ†’ flag
Key conceptsRSA, deterministic key generation, PSS padding, debug endpoint disclosure

##High-level attack flow

mermaid
flowchart LR A[Recon] --> B["/debug leak"] B --> C[Deterministic RSA] C --> D[Rebuild admin key] D --> E[Sign message] E --> F["/verify"] F --> G[Flag]

Each step follows from the previous; once you see the debug page and understand the key derivation, the rest is implementation.


##Reconnaissance

I began with a quick port and service scan to see what was exposed:

bash
nmap -sC -sV <TARGET_IP>

Results:

PortServiceDetails
22SSHOpenSSH 8.9p1
5000HTTPWerkzeug / Python 3.10

So the main target was the web application on port 5000. I opened it in the browser and saw the LoveNote interface: a landing page with information about the platform and links to register, view messages, and (after logging in) compose and verify messages. To map the full attack surface, I ran a directory enumeration (e.g. with gobuster or ffuf) against the base URL:

bash
gobuster dir -u http://<TARGET_IP>:5000/ -w /usr/share/wordlists/dirb/common.txt -x php,txt,html -t 100

What I was looking for: Any path that might expose internal state โ€” for example debug, admin, config, key, or similar. Even if such a path isn't linked from the main site, it might be left enabled in development and reveal how keys are generated or stored.

Notable routes included /about, /messages, /verify, /compose, /dashboard โ€” and, critically, /debug, which returned 200. That was an immediate red flag: a debug endpoint on a crypto application could leak key material, algorithm details, or seeds. I navigated to /debug and read the page content carefully.

Signed Messages web app home
Signed Messages web app home


##The /debug endpoint

Signed Messages debug endpoint
Signed Messages debug endpoint

The /debug page contained internal cryptographic logs โ€” a human-readable description of how the application generated RSA keys. From memory, it stated something along these lines:

  • >Development mode: ENABLED
  • >Using deterministic key generation
  • >Seed pattern: {username}_lovenote_2026_valentine
  • >Prime derivation step 1: Convert SHA256(seed) to a large integer, then find the next prime (that's p).
  • >Prime derivation step 2: SHA256(seed + "pki") โ†’ large integer โ†’ next prime (that's q).

So the application was deterministic: for any given username, the same seed was used every time, and the primes p and q were derived from that seed in a fixed way. That meant anyone who knew the algorithm and a username could recompute that user's RSA key pair offline. In particular, if there was an admin user, we could derive the admin private key without ever seeing it โ€” and then sign any message as admin and submit it to /verify to get the flag. The entire security of "no message can be forged" collapsed because the key generation was not random.


##Key concept: Why deterministic RSA is fatal

In proper RSA, the two primes p and q must be chosen at random (using a cryptographically secure RNG). The security of RSA relies on the fact that an attacker cannot feasibly factor n = pยทq or guess p and q. Here, however, the primes were derived from a deterministic process:

  • >seed = f"{username}_lovenote_2026_valentine" (e.g. for admin: "admin_lovenote_2026_valentine").
  • >p = nextprime(SHA256(seed)) โ€” take the SHA256 hash of the seed, interpret it as an integer, then find the next prime number.
  • >q = nextprime(SHA256(seed + b"pki")) โ€” same idea, but the input to SHA256 is the seed concatenated with the bytes "pki", not a double hash. (I mention this because the debug wording can be read ambiguously; getting this wrong will give you the wrong q and the signature will fail.)

So the private key is uniquely determined by the username. There is no randomness. Once we know the algorithm, we can rebuild the key for admin and sign the message the server expects. No brute force, no advanced cryptanalysis โ€” just reimplementing the same logic the server uses.


##Verifying key size (optional but useful)

Before writing the exploit, I wanted to confirm the exact key size and padding the server used. I registered a normal user account; the application provided a "your public key" download or display. I saved that and inspected it with OpenSSL:

bash
openssl rsa -pubin -in public_key.pem -text -noout

The output showed the modulus length as 512 bits, not 2048 โ€” so despite the "RSA-2048" marketing, the implementation was using a very small key. That would be weak even without the deterministic leak (512-bit RSA has been factored in the past). Combined with deterministic generation, the system was completely broken. I also noted the exponent (typically 65537) and that the server would be expecting a specific message to be signed โ€” likely the welcome or admin message shown when an admin logs in. I would need to sign that exact string with the reconstructed admin key using the same padding the server used (from the debug or from testing, it was PSS with SHA256 and maximum salt length).


##Reconstructing the admin private key

From the /debug information, the algorithm is:

  1. >seed = "admin_lovenote_2026_valentine" (as bytes or string, consistently).
  2. >p = nextprime(SHA256(seed)) โ€” in code: hash the seed, convert hex to integer, then find the next prime. I used Python's sympy for nextprime.
  3. >q = nextprime(SHA256(seed + b"pki")) โ€” important: the argument to SHA256 is the original seed string concatenated with the bytes "pki". It is not SHA256(seed) + "pki" and not a double hash like SHA256(SHA256(seed) + b"pki"). Getting this wrong was a pitfall that cost me time during the challenge.
  4. >Compute n = p * q, then the private exponent d = e^(-1) mod ฯ†(n) where ฯ†(n) = (p-1)(q-1) and e = 65537.
  5. >Build the full RSA private key (including CRT parameters if your library needs them). I used the cryptography library's RSAPrivateNumbers and related helpers.
  6. >Sign the exact admin welcome message (the string the server shows to admin users โ€” e.g. "Welcome to LoveNote! Send encrypted love messages this Valentine's Day. Your communications are secured with industry-standard RSA-2048 digital signatures."). The server uses PSS (RSA-PSS) with SHA256 and maximum salt length; the signing code must use the same so the signature verifies.

I wrote a Python script that did all of the above and printed the signature in hex. That hex string is what the /verify page expects when you submit the message and signature as admin.


##Full exploit script (summary)

The script:

  1. >Defines the seed for admin and derives p and q using the same logic as the server (SHA256 + nextprime).
  2. >Computes n, d, and builds the private key (e.g. via RSAPrivateNumbers in the cryptography library).
  3. >Takes the exact welcome message string (copy it from the application or from the script comments).
  4. >Signs it with PSS/SHA256 and max salt length, then outputs the signature in hex.

Important: The message must match byte-for-byte what the server expects โ€” same punctuation, spaces, and wording. If the server expects a different string (e.g. with a different line break or wording), the signature will fail verification. So double-check the message against what the app displays for an admin user.

You can use the script saved in the repo:

Run it (after pip install sympy cryptography). It will print a long hex string. Then go to the application's /verify page and submit:

  • >Username: admin
  • >Message: the exact admin welcome message (same as in the script)
  • >Signature: the hex output of the script

The server validates the signature and returns the flag.


##Flag

text
THM{*redacted*}

(Replace with the actual flag when you solve the room.)


##Attack chain summary

StepActionOutcome
1Recon + directory enumerationFind /debug
2Read /debugLearn deterministic seed and prime derivation
3Reimplement key generation for adminObtain admin private key
4Sign admin welcome message with PSS/SHA256Get valid signature hex
5Submit to /verifyFlag

##Pitfalls and notes

  • >

    Double-hashing or wrong concatenation for q: The second prime uses SHA256(seed + b"pki") โ€” that's one hash of the concatenated bytes. It is not SHA256(SHA256(seed) + b"pki") or SHA256(seed) + "pki". I initially misread the debug text and spent time debugging wrong primes until I matched the server's behavior.

  • >

    Message mismatch: The signed message must be identical to what the server checks. Copy the exact string from the app or the script; a single character or space difference will cause verification to fail.

  • >

    Padding and hash: The server uses PSS with SHA256 and maximum salt length. Your signing code must use the same (e.g. in Python's cryptography library: padding.PSS(mgf=..., salt_length=padding.PSS.MAX_LENGTH) and hashes.SHA256()). Using PKCS#1 v1.5 or a different salt length will not work.

  • >

    Debug endpoints in production: Exposing /debug (or any path that leaks key-generation logic) in a crypto application is catastrophic. Always disable or remove debug routes and ensure key generation uses a proper CSPRNG.


##References and resources


This writeup is part of my Love At First Breach 2026 event writeups.

$ echo "Open to Red Team Security Research and Security Engineering roles."

> Open to Red Team Security Research and Security Engineering roles.

$ uptime

> Portfolio online since 2024 | Last updated: Mar 2026

"No one is useless in this world who lightens the burdens of another." โ€” Charles Dickens

Considered a small donation if you found any of the walkthrough or blog posts helpful. Much appreciate :)

Buy me a coffee

ยฉ 2026 Shivang Tiwari. Built with Next.js. Hack the planet.