> For the complete documentation index, see [llms.txt](https://tazarkour.gitbook.io/blog/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://tazarkour.gitbook.io/blog/writeups/cyber-apocalypse-ctf-2025-tales-from-eldoria-or-eldoria-panel-web.md).

# 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.

<figure><img src="https://2030182716-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FHaXlWvvSeitk8U02TmA9%2Fuploads%2FLTEFZWvHQrnismkYuKfW%2F1.PNG?alt=media&amp;token=0346db2d-15d5-471f-b68f-3ae7cdc98256" alt=""><figcaption></figcaption></figure>

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 :&#x20;

```php
// /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 :&#x20;

```php
// /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,&#x20;

```php
// /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` :&#x20;

```php
// /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 :&#x20;

<figure><img src="https://2030182716-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FHaXlWvvSeitk8U02TmA9%2Fuploads%2FnDsmJlcVpzduhVlhcQSC%2F2.PNG?alt=media&amp;token=3e7c344f-8b67-4e29-b432-5da7762788b6" alt=""><figcaption></figcaption></figure>

<figure><img src="https://2030182716-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FHaXlWvvSeitk8U02TmA9%2Fuploads%2FW8O9GsulBzFZ3e4EpgqL%2F3.PNG?alt=media&amp;token=4a92d683-d4ca-4bca-98c9-c53c30d21300" alt=""><figcaption></figcaption></figure>

<figure><img src="https://2030182716-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FHaXlWvvSeitk8U02TmA9%2Fuploads%2FOPjpPJKdJ5CNm6vB0Eb3%2F4.PNG?alt=media&amp;token=7cdb6475-b2ff-4a05-a9ac-9188c65810b9" alt=""><figcaption></figcaption></figure>

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.

```php
// /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 :&#x20;

```python
# /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](https://stackoverflow.com/a/65927137) 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.

<figure><img src="https://2030182716-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FHaXlWvvSeitk8U02TmA9%2Fuploads%2FCuum6mvFUIiekjxoZtH3%2F8.PNG?alt=media&amp;token=d4eeaab6-ebc6-4c51-bd62-7ecde537d4f2" alt=""><figcaption></figcaption></figure>

Now we put all together.

I first got a php webshell from [github](https://gist.github.com/joswr1ght/22f40787de19d80d110b37fb79ac3985), 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/` .

<figure><img src="https://2030182716-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FHaXlWvvSeitk8U02TmA9%2Fuploads%2F3NIarapinCYOLwntNibr%2F5.PNG?alt=media&amp;token=d0e5c5aa-0bd0-4240-9e18-e0be68292f54" alt=""><figcaption></figcaption></figure>

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.

<figure><img src="https://2030182716-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FHaXlWvvSeitk8U02TmA9%2Fuploads%2FC4qEu8H7pagLyxGnWCIO%2F6.PNG?alt=media&amp;token=6154c058-69f7-4ee1-b615-9e7397148d5a" alt=""><figcaption></figcaption></figure>

<figure><img src="https://2030182716-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FHaXlWvvSeitk8U02TmA9%2Fuploads%2F4N6wd7c7qNKSea0o5qwo%2F7.PNG?alt=media&amp;token=bfa50b2d-aa0c-46d8-b41c-872979ed1016" alt=""><figcaption></figcaption></figure>

Eid Moubarak everyone.
