Cyber Apocalypse CTF 2025: Tales from Eldoria | Eldoria Panel (Web)
Difficulty: Medium
This challenge gives you a blog like website where you have some exposed information like the API key, the first thought would be to perform an XSS attack but it's not the right way and we will see why later.
This challenge is made using the slim framework. Analyzing the code we can see that the application settings like the templates path are stored in the database :
// /src/bootstrap.php
$stmt = $pdo->prepare("SELECT value FROM app_settings WHERE key = ?");
$stmt->execute(['template_path']);
$templatePathFromDB = $stmt->fetchColumn();
if ($templatePathFromDB) {
$GLOBALS['settings']['templatesPath'] = $templatePathFromDB;
}
and then the PHP templates are rendered from the current templatesPath
, for example the dashboard is rendered as such :
// /src/routes.php
$app->get('/dashboard', function (Request $request, Response $response, $args) {
$html = render($GLOBALS['settings']['templatesPath'] . '/dashboard.php');
$response->getBody()->write($html);
return $response;
})->add($authMiddleware);
Analyzing the code further we can see a path that could modify the current templates path,
// /src/routes.php
// POST /api/admin/appSettings
$app->post('/api/admin/appSettings', function (Request $request, Response $response, $args) {
$data = json_decode($request->getBody()->getContents(), true);
if (empty($data) || !is_array($data)) {
$result = ['status' => 'error', 'message' => 'No settings provided'];
} else {
$pdo = $this->get('db');
$stmt = $pdo->prepare("INSERT INTO app_settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value");
foreach ($data as $key => $value) {
$stmt->execute([$key, $value]);
}
if (isset($data['template_path'])) {
$GLOBALS['settings']['templatesPath'] = $data['template_path'];
}
$result = ['status' => 'success', 'message' => 'Settings updated'];
}
$response->getBody()->write(json_encode($result));
return $response->withHeader('Content-Type', 'application/json');
})->add($adminApiKeyMiddleware);
this endpoint only checks for the API Key using the adminApiKeyMiddleware
:
// /src/routes.php
$adminApiKeyMiddleware = function (Request $request, $handler) use ($app) {
if (!isset($_SESSION['user'])) {
$apiKey = $request->getHeaderLine('X-API-Key');
if ($apiKey) {
$pdo = $app->getContainer()->get('db');
$stmt = $pdo->prepare("SELECT * FROM users WHERE api_key = ?");
$stmt->execute([$apiKey]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if ($user && $user['is_admin'] === 1) {
$_SESSION['user'] = [
'id' => $user['id'],
'username' => $user['username'],
'is_admin' => $user['is_admin'],
'api_key' => $user['api_key'],
'level' => 1,
'rank' => 'NOVICE',
'magicPower' => 50,
'questsCompleted' => 0,
'artifacts' => ["Ancient Scroll of Wisdom", "Dragon's Heart Shard"]
];
}
}
}
return $handler->handle($request);
};
This fucntion does not provide proper authentication and the admin authentication can be bypassed by not giving it an API-Key. In coclusion the endpoint is unauthenticated and the templates path can be modified at will.
We can test it :
as we can see it works.
Now how can we upload our web shell as a template. One could just host an ftp server and it would pull the templates from there, but I currently don't have a VPS, also that way is not intended.
Every time we claim a quest there is a bot that is triggered.
// /src/routes.php
// POST /api/claimQuest
$app->post('/api/claimQuest', function (Request $request, Response $response, $args) {
$data = json_decode($request->getBody()->getContents(), true);
if (empty($data['questId'])) {
$result = ['status' => 'error', 'message' => 'No quest id provided'];
} else {
$pdo = $this->get('db');
$stmt = $pdo->prepare("UPDATE quests SET status = 'Claimed' WHERE id = ?");
$stmt->execute([$data['questId']]);
$result = ['status' => 'success', 'message' => 'Quest claimed'];
}
$response->getBody()->write(json_encode($result));
$response = $response->withHeader('Content-Type', 'application/json');
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request();
} else {
ignore_user_abort(true);
if (ob_get_level() > 0) {
ob_end_flush();
}
flush();
}
if (!empty($data['questUrl'])) {
$validatedUrl = filter_var($data['questUrl'], FILTER_VALIDATE_URL);
if ($validatedUrl === false) {
error_log('Invalid questUrl provided: ' . $data['questUrl']);
} else {
$safeQuestUrl = escapeshellarg($validatedUrl);
$cmd = "nohup python3 " . escapeshellarg(__DIR__ . "/bot/run_bot.py") . " " . $safeQuestUrl . " > /dev/null 2>&1 &";
exec($cmd);
}
}
return $response;
})->add($apiKeyMiddleware);
The code for the bot is :
# /src/bot/run_bot.py
import sys
import time
import sqlite3
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.chrome.options import Options
def main():
if len(sys.argv) < 2:
print("No quest URL provided.", file=sys.stderr)
sys.exit(1)
quest_url = sys.argv[1]
DB_PATH = "/app/data/database.sqlite"
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='users'")
if not c.fetchone():
print("The 'users' table doesn't exist. Run seed script or create it here.")
sys.exit(1)
c.execute("SELECT username, password FROM users WHERE is_admin = 1 LIMIT 1")
admin = c.fetchone()
if not admin:
print("Admin not found in the database.", file=sys.stderr)
sys.exit(1)
admin_username, admin_password = admin
chrome_options = Options()
chrome_options.add_argument("headless")
chrome_options.add_argument("no-sandbox")
chrome_options.add_argument("ignore-certificate-errors")
chrome_options.add_argument("disable-dev-shm-usage")
chrome_options.add_argument("disable-infobars")
chrome_options.add_argument("disable-background-networking")
chrome_options.add_argument("disable-default-apps")
chrome_options.add_argument("disable-extensions")
chrome_options.add_argument("disable-gpu")
chrome_options.add_argument("disable-sync")
chrome_options.add_argument("disable-translate")
chrome_options.add_argument("hide-scrollbars")
chrome_options.add_argument("metrics-recording-only")
chrome_options.add_argument("no-first-run")
chrome_options.add_argument("safebrowsing-disable-auto-update")
chrome_options.add_argument("media-cache-size=1")
chrome_options.add_argument("disk-cache-size=1")
driver = webdriver.Chrome(options=chrome_options)
try:
driver.get("http://127.0.0.1:9000")
username_field = driver.find_element(By.ID, "username")
password_field = driver.find_element(By.ID, "password")
username_field.send_keys(admin_username)
password_field.send_keys(admin_password)
submit_button = driver.find_element(By.ID, "submitBtn")
submit_button.click()
driver.get(quest_url)
time.sleep(5)
except Exception as e:
print(f"Error during automated login and navigation: {e}", file=sys.stderr)
sys.exit(1)
finally:
driver.quit()
if __name__ == "__main__":
main()
the bot would just open the guild link.
After some searching, I found on this thread that the get method can download files from certain websites, the only thing is that it needs is a direct download link.
I tested the code in a docker container to guess the download path and I found that it would create a folder $User_Home_Path/Downloads
and download to it.
Now we put all together.
I first got a php webshell from github, uploaded it to a host that provides direct links for websites with the name of one of the templtes (here we chose Dashboard.php), claimed a quest and put the link as the guild url and changed the templates path to /var/www/Downloads/
.
The bot can sometimes not work so try multiple times and if you exhausted all the quests just go to POST /api/admin/cleanDatabase
to clean the database.
After doing that we just open the path we need (in our case /dashboard) and get the flag.
Eid Moubarak everyone.
Last updated