‹ Back to blog

A Horse with No Names

Can you make it through the desert on a horse with no names?

The service ran at nc horse.chal.uiuc.tf 1337.

This Python jail gives us an eval(), then piles two regular expressions and a bytecode modification on top of it. I liked this one a lot; we ended up getting third blood.

The Dockerfile tells us the flag is stored at /flag.txt. The complete jail is short:

#!/usr/bin/python3
import re
import random

horse = input("Begin your journey: ")

if re.match(r"[a-zA-Z]{4}", horse):
    print("It has begun raining, so you return home.")
elif len(set(re.findall(r"[\W]", horse))) > 4:
    print("A dead horse cannot bear the weight of all those special characters.")
else:
    discovery = list(
        eval(compile(horse, "<horse>", "eval").replace(co_names=()))
    )
    random.shuffle(discovery)
    print("This is all you can remember:", discovery)

If we can cross all three roadblocks, arbitrary Python such as open('/flag.txt').read() is waiting on the other side.

No four-letter ASCII sequence

The first expression uses re.match() with [a-zA-Z]{4}. It is meant to reject four letters in a row. Two bypasses are useful here: encoded strings and Unicode identifiers.

Python normalizes many Unicode characters when they are used in identifiers. For example, the italic letters in 𝘦𝘹𝘦𝘤 are normalized to the ordinary name exec by Python, while the regular expression only sees non-ASCII characters and does not match them.

𝘦𝘹𝘦𝘤  # accepted by the ASCII regex, normalized to exec by Python

Inside a quoted string we can also represent ASCII characters as hexadecimal escapes. '\x65\x78\x65\x63' becomes 'exec' at runtime, although the encoded form never contains four consecutive letters. The limitation is that escapes only help inside strings; they cannot replace function names in Python syntax.

There is also a small bug in this first version of the challenge: re.match() checks only at the start, so some longer words can avoid the exact four-letter match. A later version used search() and blocked words of four or more letters more consistently. The Unicode technique works independently of that mistake.

Only four different symbols

The second filter collects every non-word character with \W, converts the result to a set and rejects more than four unique symbols. Reusing the same symbol is free, but introducing (, ), quotes, dots, slashes, colons or spaces quickly spends the entire budget.

Spaces count too, so the final expression has to be compact. This restriction is why a normal payload with a quoted path and chained method calls will not fit.

Cleared co_names

The final roadblock is the interesting one. The service compiles our expression and replaces its co_names tuple with an empty tuple before evaluating it. Bytecode instructions such as LOAD_NAME use indexes into that tuple, so a direct call like print('a') or an attribute lookup like ().__class__ breaks after the replacement.

While experimenting, I noticed that names inside a function body are not loaded in the same way. A function uses instructions such as LOAD_GLOBAL and LOAD_ATTRIBUTE. We cannot define a normal multi-line function because the service calls eval(), but a lambda is both a function and a valid expression.

(lambda:print('a'))()

The expression above creates the lambda and calls it immediately. It establishes the basic shape of the final payload and uses only parentheses and a colon around the function body.

Building the payload

A first attempt such as (lambda:open('/flag.txt'))() still needs too many different symbols: parentheses, colon, quotes, a slash and a dot. Hex-encoding the path removes the slash but still needs a quoted string.

The builtin chr() gives us a cleaner route. It converts an integer to one character, so an entire Python program can be represented as calls to chr() joined only with plus signs. We can generate that long expression locally.

command = "print(open('/flag.txt').read())"
encoded = "+".join(
    f"chr({ord(character)})"
    for character in command
)

payload = f"(lambda:𝘦𝘹𝘦𝘤({encoded}))()"
print(payload)

The generated payload uses a lambda to escape the cleared outer co_names, the Unicode spelling of exec to bypass the ASCII word filter, and chr()+chr()+... to reconstruct the command without quotes, slashes or dots in the outer expression.

$ nc horse.chal.uiuc.tf 1337
== proof-of-work: disabled ==
Begin your journey: (lambda:𝘦𝘹𝘦𝘤(chr(112)+chr(114)+...+chr(41)))()

uiuctf{my_challenges_have_abandoned_any_pretense_of_practical_applicability_and_im_okay_with_that}

The flag is almost a review of the challenge, and honestly a fair one. The exploit only works because the regex, compiler, and runtime disagree about what the same input means.