October 17, 2026
W1seGuy: The Flag Was Never Encrypted
17 oct. 2026 ยท @Elimane NDOYE

By Elimane NDOYE
6 min read
A short, satisfying TryHackMe crypto challenge that teaches a real, decades-old lesson: read the source before you touch the keyboard.
Some CTF rooms want you to break in. W1seGuy wants you to read.
The room's title quietly tells you everything: the answer is usually as plain as day. That's not flavor text โ it's the solution, hiding in the open, the way the best crypto challenges always do. And unlike a lot of beginner crypto rooms that teach a technique in isolation, this one is built around a mistake that has genuinely appeared โ in exactly this shape โ in production systems.
The challenge
Connect to a TCP service on port 1337, and it hands you a block of hex-encoded gibberish along with a claim: this is an XOR-encrypted flag, and the key used to scramble it is five random alphanumeric characters. Guess the key correctly, and a second, real flag is yours.
Five random alphanumeric characters sounds survivable until you do the arithmetic: 62 possible characters per position, five positions, comes out to roughly 916 million combinations. A naive brute force isn't impossible against a fast local service, but it's wasteful and slow enough that the room clearly isn't betting on you doing it that way. So it doesn't make you guess blind. Task 1 hands you the server's entire Python source code before you've touched the terminal at all, with a one-line instruction: have a look before moving on.
That's the whole challenge, really. Most people skim past a "read the source" prompt on their way to the interesting part. The room is quietly betting you won't take it seriously โ and that the handful of people who do will solve the whole thing in under five minutes.
A short detour: why XOR shows up in CTFs at all
It's worth pausing on why XOR specifically is such a recurring character in beginner cryptography challenges, rather than, say, AES or RSA. XOR (exclusive OR) is a bitwise operation with one property that makes it both useful and dangerous: it's its own inverse. XOR a value with a key once, and you get ciphertext; XOR that ciphertext with the same key again, and you get the original value right back. No separate decryption algorithm needed โ encryption and decryption are literally the same operation.
That elegance is exactly why XOR-based stream ciphers (the one-time pad being the theoretically unbreakable extreme case) are both a foundational building block of real cryptography and an extremely common source of real-world vulnerabilities when implemented carelessly. The one-time pad is provably unbreakable โ but only if the key is truly random, used exactly once, and kept completely secret. Break any one of those three conditions, and the "unbreakable" cipher collapses, often completely. W1seGuy breaks a looser cousin of that third condition: it doesn't encrypt the real flag at all โ it encrypts a plaintext that's already public, by virtue of being printed in the source code you were handed.
The bug, in one line
Somewhere in the source, buried in the function that builds the "encrypted" message sent to the player, sits this:
def setup(server, key):
flag = 'THM{thisisafakeflag}'
xored = ""
for i in range(0, len(flag)):
xored += chr(ord(flag[i]) ^ ord(key[i % len(key)]))
hex_encoded = xored.encode().hex()
return hex_encodeddef setup(server, key):
flag = 'THM{thisisafakeflag}'
xored = ""
for i in range(0, len(flag)):
xored += chr(ord(flag[i]) ^ ord(key[i % len(key)]))
hex_encoded = xored.encode().hex()
return hex_encodedRead that first line again, slowly. The flag that gets encrypted and sent to you is not the real flag. It's a hard-coded placeholder string, identical character-for-character on every single connection, regardless of who connects or when. The real flag, loaded from flag.txt at the very top of the file, never passes through this function at all โ it's held back entirely, used only later, only after the player supplies the correct key.
Which means the ciphertext you're staring at on your screen was built from plaintext you already possess, sitting right there in the source file you downloaded before you ever opened a terminal.
Why that single fact breaks the entire scheme
XOR encryption has exactly one algebraic property that matters here, and it's worth stating with precision: if ciphertext equals plaintext XOR key (C = P โ K), then key equals ciphertext XOR plaintext (K = C โ P). This isn't an approximation or a probabilistic shortcut โ it's exact, deterministic algebra, the same identity that makes XOR its own inverse in the first place. There's no guessing involved whatsoever: if you know any two of the three values in that equation, the third falls out immediately, with zero ambiguity.
You know the ciphertext โ the server just handed it to you, hex-encoded, the moment you connected. You know the plaintext โ you read it minutes earlier in the downloaded source file. XOR the two together, byte by byte, and the key appears in full, every single time, with no trial and error required.
The only wrinkle worth tracking carefully: the key is only five characters long, but the plaintext is longer than that, so the key repeats, cycling back to its first character once it runs out (key[i % len(key)] in the code โ that modulo operator is doing all the work of wrapping the short key around the longer message). Practically, that means the first five bytes of ciphertext, XORed against the first five bytes of known plaintext, hand you the entire key in a single pass. No iteration across the rest of the message is even necessary โ the repetition that was meant to stretch a short key across a longer message is exactly what makes recovering that key from only its first cycle sufficient.
It's less "cracking a code" in the Hollywood sense, and more like reading a password off a sticky note someone left attached to the lock.
Turning it into a script
A handful of lines of Python does the entire job, start to finish:
import socket
import string
HOST = "<lab machine IP>"
PORT = 1337
known_prefix = "THM{"
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((HOST, PORT))
data = s.recv(4096).decode()
hex_part = data.split("flag 1: ")[1].split("\n")[0].strip()
cipher_bytes = bytes.fromhex(hex_part)
# Recover the first 4 key characters from the known "THM{" prefix
partial_key = ""
for i in range(4):
partial_key += chr(cipher_bytes[i] ^ ord(known_prefix[i]))
# Brute-force only the 5th character โ 62 possibilities, trivially fast
charset = string.ascii_letters + string.digits
found_key = None
for c in charset:
key = partial_key + c
decoded = "".join(chr(cipher_bytes[i] ^ ord(key[i % 5])) for i in range(len(cipher_bytes)))
if all(32 <= ord(ch) <= 126 for ch in decoded) and decoded.endswith("}"):
found_key = key
break
prompt = s.recv(4096).decode() # "What is the encryption key? "
s.send((found_key + "\n").encode())
response = s.recv(4096).decode()
print(response)
s.close()import socket
import string
HOST = "<lab machine IP>"
PORT = 1337
known_prefix = "THM{"
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((HOST, PORT))
data = s.recv(4096).decode()
hex_part = data.split("flag 1: ")[1].split("\n")[0].strip()
cipher_bytes = bytes.fromhex(hex_part)
# Recover the first 4 key characters from the known "THM{" prefix
partial_key = ""
for i in range(4):
partial_key += chr(cipher_bytes[i] ^ ord(known_prefix[i]))
# Brute-force only the 5th character โ 62 possibilities, trivially fast
charset = string.ascii_letters + string.digits
found_key = None
for c in charset:
key = partial_key + c
decoded = "".join(chr(cipher_bytes[i] ^ ord(key[i % 5])) for i in range(len(cipher_bytes)))
if all(32 <= ord(ch) <= 126 for ch in decoded) and decoded.endswith("}"):
found_key = key
break
prompt = s.recv(4096).decode() # "What is the encryption key? "
s.send((found_key + "\n").encode())
response = s.recv(4096).decode()
print(response)
s.close()A worthwhile detail here: the script only derives the first four key characters directly from the known THM{ prefix โ not the full twenty-character placeholder text from the downloaded source โ and brute-forces the fifth. That's a deliberate hedge, and it mattered in practice: the placeholder string actually running on the live server turned out to differ slightly from the one shown in the downloadable source file, producing a ciphertext of a different length than expected. Anchoring the attack only on the part that's universally guaranteed โ every single TryHackMe flag begins with THM{ โ made the script robust to that mismatch, where a version hard-coded against the full placeholder text would have silently produced garbage.
Run the script against the target, and the server hands back two things in the same breath: a decoded confirmation message proving the recovered key is correct, and โ because the key was right โ the real second flag, unprompted, no further interaction required.
The lesson under the lesson
It's tempting to file this away as "easy crypto room, nothing to see here," solved and forgotten in under ten minutes. I think that undersells what's actually being taught. W1seGuy is a compact, almost elegant demonstration of one of the oldest rules in applied cryptography: a cipher's real-world strength lives in its key and plaintext management, not in the mathematical elegance of the algorithm underneath it. "Encrypt the same known string every time" is a key-management failure wearing an encryption scheme as a disguise. XOR itself isn't broken here, and never was โ it's doing exactly what XOR is supposed to do. Using it this way is the actual bug.
That distinction matters more broadly than this one room. A meaningful slice of real-world cryptographic vulnerabilities โ in TLS implementations, in disk encryption, in wireless protocols โ get found this way: not by attacking the underlying math, which is usually sound and heavily peer-reviewed, but by noticing that an implementation reused something that was only ever safe to use once. A nonce reused across two messages. An initialization vector that's predictable. A "random" session key that turns out to be derived from a timestamp. The algorithm was never the weak point in any of those cases. The scaffolding built around it was.
Takeaway
Before reaching for brute force, stop and ask what you already know. In this room, the answer was sitting in a file you'd already downloaded, inside a function whose unassuming name โ setup โ undersold exactly how much it was quietly setting up for you to notice. The fastest path through a crypto challenge is rarely more computation. It's usually just reading one function more carefully than everyone else did.
Previous: a much rougher ride through a real WordPress CVE, a cryptominer, and a VPN that wouldn't stay connected โ the exact opposite kind of room from this one, and a useful reminder that not every "hacking" challenge is really about hacking at all.