easy-jail

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:

The Intended Way

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.

The mapping shift

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.

A concise brute-force recipe

What you need to do:

  1. Probe the server with “a” many times and record the mapped characters.
  2. Convert consecutive mapped characters to a sequence of -1/+1 steps (did the letter go down or up?).
  3. Brute-force seeds 0 .. 2^20-1. For each seed, generate the same number of rng.choice([-1,1]) values; the seed that reproduces your observed sequence is the server seed.
  4. Initialize a local random.Random(seed) and advance it the same number of steps you already consumed, so your local RNG matches the server’s current state.
  5. Request a snapshot by sending abcdefghijklmnopqrstuvwxyz once; that gives a full 26-char image of the mapping. Use it to build a local wrapper that converts allowed user characters (a–z and ~><*+) into the encoded string the server expects.
  6. Now you can craft flag[<encoded-index>] expressions (encode indices using only allowed characters) and send them through your wrapper; because you can predict future shifts, you’ll keep the wrapper synchronized and read the flag byte by byte.

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

Encoding numbers with the allowed characters

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.

The tiny building blocks

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 *.

How to make 2 (worked example)

The trick used in the encoder for 2 is compact but a little brain-bendy:

Two = "(~(~([]<[[]])+(~([]>[[]]))))"

Eval step-by-step:

  1. [] < [[]] -> True -> numeric 1
  2. [] > [] -> False -> numeric 0
  3. ~([]<[[]]) -> ~1 -> -2
  4. ~([]>[[]]) -> ~0 -> -1
  5. ~1 + ~0 -> -2 + -1 -> -3
  6. ~(-3) -> 2 (because ~x == -x - 1, so ~(-3) == 2)

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.

The length / complement trick (~(LENGTH - n - 1))

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.

Different approaches

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.

The Unintended solution

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.