If you’ve played the challenge and want to keep reading this writeup, a quick heads-up: I’ll start by explaining the intended solution first. I know there’s an unintended one out there (and yeah, I’ll talk about that too later on). My goal here is to walk through how it was supposed to be solved, why it works, and then look at how people managed to break it in unexpected ways.
Hopefully you’ll find this writeup both useful and maybe a little entertaining.
Files:
This challenge gives you a Python service over an nc connection and its incomplete implementation, a classic pyjail. The goal: interact with a restricted Python environment and pull a protected flag out of a ProtectedFlag wrapper.
seed = random.randint(0, 2**20)
shift_rng = random.Random(seed)
At startup the server picks a 20-bit seed and builds a random.Random instance from it. That RNG is the hidden driver for how the character mapping evolves between rounds.
class ProtectedFlag:
def __init__(self, value):
self._value = value
def __str__(self):
return "variable protected, sryy"
def __repr__(self):
return "variable protected, sryy"
def __getitem__(self, index):
try:
return self._value[index]
except Exception:
return "variable protected, sryy"
ProtectedFlag is the gatekeeper. str()/repr() are actively hostile, they return fake messages. The only safe read is getitem, i.e. flag[i]. That pushes us toward building expressions that evaluate to integer indices and doing flag[<encoded index>] to extract bytes one at a time.
def make_initial_mapping():
letters = list(string.ascii_lowercase)
shuffled = letters[:]
random.shuffle(shuffled)
return dict(zip(letters, shuffled))
The server starts with a random substitution mapping of a..z -> a..z. That mapping is used to rewrite your input before eval.
encoded = "".join(mapping[c] if c in mapping else c for c in user_in)
mapping = shift_mapping(mapping)
result = eval(encoded, {"__builtins__": None}, {"flag": flag})
The server maps your characters into the encoded string, then it calls eval in a stripped sandbox (only flag provided), and after eval it mutates mapping with shift_mapping for the next prompt. So each prompt you send is encoded with the current mapping; the mapping changes only after your input runs.
We couldn’t read shift_mapping directly, but we could watch its effects. If you repeatedly send a single letter (say “a”) and log the server’s reply, the mapped character for “a” walks up and down the alphabet:
> a
a
s
> a
a
r
> a
a
s
> a
a
r
> a
a
s
> a
a
t
> a
Each round the mapped letter for a moves by exactly one position forward or backward. That strongly suggests shift_mapping applies a ±1 cyclic shift to all mapped letters each round, where the sign is chosen by shift_rng.choice([-1, 1]).
Because the RNG seed is the range of 2^20, we can recover it. Observe several consecutive ± steps, convert them to a ±1 sequence, and brute-force the seed: for each candidate seed simulate random.Random(seed).choice([-1,1]) repeatedly and match the observed signs. The matching seed reproduces the server’s future shift decisions exactly.
What you need to do:
Here’s the core matching logic:
def shifted(c, old):
"""Return -1 if c < old else +1."""
return -1 if c < old else 1
def gen_seq(seed, n):
"""Generate a sequence of -1/+1 based on a seed."""
rng = random.Random(seed)
return [rng.choice([-1, 1]) for _ in range(n)]
def find_seed(shifts):
"""Brute-force the seed from observed shifts."""
for candidate in range(MAX_SEED):
if gen_seq(candidate, len(shifts)) == shifts:
return candidate
return None
We can’t type numeric literals into the sandbox, only the characters a–z and the symbols ~><*+ are allowed. So we have to build integers from legal Python expressions made only from lists, comparisons, ~ (bitwise NOT), +, and *. It sounds awkward, but it’s neat and completely deterministic once you see the primitives.
Start with the absolute smallest things you can express:
In Python True == 1 and False == 0 when used in arithmetic. Also ~x is bitwise-NOT, and ~x == -x - 1. That lets us turn small booleans into other integers via arithmetic.
So the basic constants are:
Zero = "([]>[])" # [] > [] -> False -> 0
One = "([]<[[]])" # [] < [[]] -> True -> 1
With 0 and 1 we can build everything else by combining ~, +, and *.
The trick used in the encoder for 2 is compact but a little brain-bendy:
Two = "(~(~([]<[[]])+(~([]>[[]]))))"
Eval step-by-step:
So that long-looking expression evaluates to 2. Once you have 1 and 2, making 3, 5, etc. is straightforward:
Three = "(" + One + ")+(" + Two + ")" # 1 + 2 = 3
Five = "(" + Two + ")+(" + Three + ")" # 2 + 3 = 5
Why the encoder defines primes
The encoder stores compact expressions for a handful of small primes and builds arbitrary integers by multiplying those prime encodings together:
factors = prime_factors(n)
result_parts = [prime_map.get(p, One) for p in factors]
result = ")*(".join(result_parts)
result = '(' + result + ')'
Two reasons:
So prime_map contains primes (and 0,1) rather than every integer, prime factorization gives you every number while keeping the templates short.
There’s a per-input length limit in the service, so the encoder uses a trick for big numbers:
if len(result) >= 150:
result = "~(" + encode_number(LENGTH - n - 1) + ")"
Why that works:
This is a simple length-saver: encode the complement if the direct encoding inside the input-size limit would be too long.
We assume LENGTH is known or guessed; the complement trick depends on it. If you don’t know the flag length, pick a safe upper bound or probe for it.
Please note, I know this mapping + number representation isn’t the only way to do it, and honestly it’s probably not the most optimal either. I saw a bunch of your solutions and I was genuinely impressed. As the author, learning new tricks from solvers is the best part of doing these things.
Here’s a nice alternative someone posted that I liked because it’s clean, recursive, and builds numbers compactly (so you rarely hit the “too long -> use negative index” shortcut):
# $ cat symbol_number.py
class SymbolNumber:
def __init__(self, value: str):
self.value = value
def __add__(self, other: "SymbolNumber") -> "SymbolNumber":
return SymbolNumber(f"({self.value})+({other.value})")
def __mul__(self, other: "SymbolNumber") -> "SymbolNumber":
return SymbolNumber(f"({self.value})*({other.value})")
def __str__(self) -> str:
return self.value
@staticmethod
def from_number(n: int) -> "SymbolNumber":
if n == 0:
return SymbolNumber("[]>[]")
elif n == 1:
return SymbolNumber("[[]]>[]")
elif n == 2:
return SymbolNumber.from_number(1) + SymbolNumber.from_number(1)
elif n % 2 == 1:
return SymbolNumber.from_number(n - 1) + SymbolNumber.from_number(1)
else:
return SymbolNumber.from_number(n // 2) * SymbolNumber.from_number(2)
def from_number(n: int) -> str:
return str(SymbolNumber.from_number(n))
# $ python -i symbol_number.py
>>> from_number(1)
'[[]]>[]'
>>> from_number(2)
'([[]]>[])+([[]]>[])'
>>> from_number(5)
'((([[]]>[])+([[]]>[]))*(([[]]>[])+([[]]>[])))+([[]]>[])'
>>> from_number(30)
'((((((([[]]>[])+([[]]>[]))+([[]]>[]))*(([[]]>[])+([[]]>[])))+([[]]>[]))*(([[]]>[])+([[]]>[])))+([[]]>[]))*(([[]]>[])+([[]]>[]))'
Why I like this one:
All in all: neat, practical, and elegant, exactly the kind of alternative I like seeing from solvers. Keep the clever stuff coming.
Okay, real talk: a lot of you solved this chal by brute-probing instead of cracking the seed/shift RNG. I noticed the solve count jump and asked myself: “Wait, did I accidentally make this trivial?” Turns out, yeah, a lot of people used an unintended (but perfectly valid) path. Honestly, I can’t blame you, it’s clever in its own lazy way.
Here’s the thing: you didn’t need to recover shift_mapping or its seed. All you needed was the initial mapping and some stubbornness. Why? Because the server shifts every letter by either +1 or −1 each round. That means there’s always a nonzero chance the mapping will eventually come back to exactly the same state it started in. So if you keep sending the exact same payload over and over, sooner or later the mapping will land back on the initial mapping and your payload will work perfectly. It’s just probability + patience.
def shift_mapping(mapping):
"""Shift the VALUES of the mapping randomly by +1 or -1 in the alphabet."""
shifted = {}
shift = shift_rng.choice([-2, -1, 1, 2])
for k, v in mapping.items():
shifted[k] = chr(((ord(v) - 97 + shift) % 26) + 97)
return shifted
Let me make that a bit more concrete: think of the cumulative shift after n rounds. Each round you add either +1 or −1, so after n rounds your net shift is some integer S_n. We only care about S_n modulo 26 (because there are 26 letters). That’s a random walk on a circle of 26 positions. Random walks on a finite state space like that will eventually visit every position, so starting at zero, the walk will almost surely return to zero. In plain English: if you keep trying, you will eventually hit the round where the mapping equals the initial mapping again. On average that happens pretty quickly, which explains why this brute approach felt so fast in practice.
Yeah, I could’ve tried to made shift_mapping more complicated, pick bigger step sizes, use a weird permutation, whatever, but that’s basically bad design for a CTF. If the shift rule is guessy, solvers are forced to blindly guess the logic instead of demonstrating real techniques, which makes the challenge brittle and annoying.
More importantly, complexity alone doesn’t fix the fundamental issue: you’re still working on a finite state space. As long as the server’s state evolves deterministically (or from a small seed) and you can query it many times, the state will eventually cycle or repeat, which means the initial mapping is reachable again. Put another way:

So yeah, I learned something here. I tried to design a seed-recovery challenge, but the simple ±1 random walk gave an easy escape hatch. Still, big props to everybody who solved it: whether you wrote the RNG-bruteforce, reverse-engineered the mapping, or just spammed the same payload until the right round, you still had to:
All of that is solid. If I had to design it over again, I might pick a different shift model, increase the alphabet, or limit queries, but then I’d probably learn something new too. So fair play.