diff --git a/lactf-2025/web/chessbased/app.js b/lactf-2025/web/chessbased/app.js new file mode 100644 index 00000000..baed6e55 --- /dev/null +++ b/lactf-2025/web/chessbased/app.js @@ -0,0 +1,49 @@ +const express = require('express'); +const cookieParser = require('cookie-parser'); +const path = require('path'); +const { openings } = require('./openings.js'); + +const port = process.env.PORT ?? 3000; +const flag = process.env.FLAG ?? 'lactf{owo_uwu}'; +const adminpw = process.env.ADMINPW ?? 'adminpw'; +const challdomain = process.env.CHALLDOMAIN ?? 'http://localhost:3000/'; + +openings.forEach((op) => (op.premium = false)); +openings.push({ premium: true, name: 'flag', moves: flag }); + +const lookup = new Map(openings.map((op) => [op.name, op])); + +app = express(); + +app.use(cookieParser()); +app.use('/', express.static(path.join(__dirname, '../frontend/dist'))); +app.use(express.json()); + +app.get('/render', (req, res) => { + const id = req.query.id; + const op = lookup.get(id); + res.send(` +

${op?.name}

+

${op?.moves}

+ `); +}); + +app.post('/search', (req, res) => { + if (req.headers.referer !== challdomain) { + res.send('only challenge is allowed to make search requests'); + return; + } + const q = req.body.q ?? 'n/a'; + const hasPremium = req.cookies.adminpw === adminpw; + for (const op of openings) { + if (op.premium && !hasPremium) continue; + if (op.moves.includes(q) || op.name.includes(q)) { + return res.redirect(`/render?id=${encodeURIComponent(op.name)}`); + } + } + return res.send('lmao nothing'); +}); + +app.listen(port, () => { + console.log(`Listening on http://localhost:${port}`); +}); diff --git a/lactf-2025/web/chessbased/chessbased-writeup.md b/lactf-2025/web/chessbased/chessbased-writeup.md new file mode 100644 index 00000000..3f9e58eb --- /dev/null +++ b/lactf-2025/web/chessbased/chessbased-writeup.md @@ -0,0 +1,38 @@ +# LA CTF 2025: web/chessbased + +## Context +Chessbased is a search engine of a database of chess strategies based on if the search criteria is in the name or moves of a certain strategy. The code loops through the data in order and returns the first match. + +In app.js, we see that flag is added to the data at the end with the flag name, but the programmer also added a 'premium' field to all of the data with only the flag's premium boolean set to true. Then, when the code loops through the data to serach for the search criteria, the flag is skipped unless the user has the adminpw in the cookie. + +Since we are given an admin bot and through the code, we know that the admin bot can access the flag because it has the cookie. + +## Vulnerability +In app.js, the home page, `/render` and `/search` are the main pages we can access. We realize that `/render` can be accessed, allowing us to possibly put any argument (id) we'd like. + +It seems the author [got too lost in the sauce](https://hackmd.io/@r2dev2/S1P0RYHYke#ChessbasedGigachessbased) and forgot to add authentification on render... + +## Exploitation +Since there is no authentification on `/render`, we can add `/render?id=flag` to the url and have it print the flag out. + +## Remediation +To remediate this vulnerability, add authentification on the `/render` page to check that this user has the adminpw cookie. + +An example of this is the authentification author r2uwu2 adds on `/render` for the gigachessbased challenge. + +``` +app.get('/render', (req, res) => { + const hasPremium = req.cookies.adminpw === adminpw; + const id = req.query.id; + const op = lookup.get(id); + + if (op.premium && !hasPremium) { + return res.send('nice try buddy pay up'); + } + + res.send(` +

${op?.name}

+

${op?.moves}

+ `); +}); +``` \ No newline at end of file diff --git a/lactf-2026/web/blogler/app.py b/lactf-2026/web/blogler/app.py new file mode 100644 index 00000000..3759bfed --- /dev/null +++ b/lactf-2026/web/blogler/app.py @@ -0,0 +1,166 @@ +from flask import Flask, session, request, redirect, render_template +import os +import yaml +import re +import string +from pathlib import Path +import mistune + +app = Flask(__name__) +app.secret_key = os.urandom(16).hex() + +EMAIL_RE = re.compile( + r"^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@" + r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?" + r"(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+$" +) + +users = dict() + +blog_path = Path(__file__).parent / "blogs" +blog_path.mkdir(exist_ok=True) + +def is_valid_email(email: str) -> bool: + return bool(EMAIL_RE.match(email)) + +def display_name(username: str) -> str: + return "".join(p.capitalize() for p in username.split("_")) + +def validate_conf(old_cfg: dict, uploaded_conf: str) -> dict | str: + try: + conf = yaml.safe_load(uploaded_conf) + + # validate all blog entries + for i, blog in enumerate(conf["blogs"]): + if not isinstance(blog.get("title"), str): + return f"please provide a 'title' to the {i+1}th blog" + + # no lfi + file_name = blog["name"] + assert isinstance(file_name, str) + file_path = (blog_path / file_name).resolve() + if "../" in file_name or file_name.startswith("/") or not file_path.is_relative_to(blog_path): + return f"file path {file_name!r} is a hacking attempt. this incident will be reported" + + # recover from missing display name/passwords with sane default of old one + if not isinstance(conf.get("user"), dict): + conf["user"] = dict() + + conf["user"]["name"] = display_name(conf["user"].get("name", old_cfg["user"]["name"])) + conf["user"]["password"] = conf["user"].get("password", old_cfg["user"]["password"]) + if not isinstance(conf["user"]["password"], str): + return "provide a valid password bro" + return conf + except Exception as e: + return f"exception - {e}" + + +@app.get("/") +def index(): + if "username" not in session: + return redirect("/login") + return render_template("index.html") + + +@app.get("/login") +def serve_login(): + return render_template("login.html") + + +@app.get("/config") +def get_config_yaml(): + if "username" not in session: + return redirect("/login") + return yaml.dump(users[session["username"]]), 200 + + +@app.post("/config") +def update_config(): + config = request.form.get("config") + if config is None: + return "give a config..." + if "username" not in session: + return redirect("/login") + + validated_config = validate_conf(users[session["username"]], config) + + # this means there was an error in validation - return err string + if isinstance(validated_config, str): + return validated_config, 400 + + # update the user conf if it is valid + users[session["username"]] = validated_config + + return redirect("/") + + +@app.get("/blog/") +def serve_user_personal_blog(): + if "username" not in session: + return redirect("/login") + return redirect("/blog/" + session["username"]) + +@app.get("/blog/") +def serve_blog(username): + if username not in users: + return "username does not exist", 404 + blogs = [ + {"title": blog["title"], "content": mistune.html((blog_path / blog["name"]).read_text())} + for blog in users[username]["blogs"] + ] + return render_template("blog.html", blogs=blogs, name=users[username]["user"]["name"]) + +@app.post("/blog") +def upload_blog(): + if "username" not in session: + return redirect("/login") + + title = request.form.get("title") + blog_content = request.form.get("blog") + filename = session["username"] + "_blog_" + os.urandom(8).hex() + ".md" + filepath = blog_path / filename + + # TODO - do validation on title / blog content + + filepath.write_text(blog_content) + users[session["username"]]["blogs"].append({"title": title, "name": filename}) + + return redirect("/") + +@app.post("/register") +def register(): + username = request.form.get("username") + password = request.form.get("password") + + # TODO - add inp validation + + initial_conf = {"user": {"name": display_name(username), "password": password}, "blogs": []} + + users[username] = initial_conf + + session["username"] = username + + return redirect("/") + + +@app.post("/login") +def login(): + username = request.form.get("username") + password = request.form.get("password") + + # TODO - add inp validation + + user = users.get(username) + if user is None: + return "user does not exist", 400 + + if password != user["user"]["password"]: + return "invalid password", 400 + + session["username"] = username + + return redirect("/home") + + +if __name__ == "__main__": + app.run("0.0.0.0", 3000, debug=True) diff --git a/lactf-2026/web/blogler/blogler-writeup.md b/lactf-2026/web/blogler/blogler-writeup.md new file mode 100644 index 00000000..0e993035 --- /dev/null +++ b/lactf-2026/web/blogler/blogler-writeup.md @@ -0,0 +1,80 @@ +# LA CTF 2026: web/blogler + +## Context +Blogler is a blogging platform built in Flask where users register an account and write blog posts in Markdown. Users are also able to modify YAML configuration. + +The code has built-in checks for `../` and `/`(root) directory traversal in the YAML configuration. + +The **goal of the challenge** is to find the flag in a flag file on the server. + +### Background information on YAML +YAML ("Yet Another Markup Language" or "YAML Ain't Markup Language") is a data serialization language used for file configuration and data exchange between different systems. It is similar to JSON or XML but does not need to use brackets and braces, rather relying on indentations like Python. + +## Vulnerability +The website is built with Flask, which defines routes that map URLs to specific view functions. + +In app.py, each GET request is defined using decorators. For example, `@app.get("/blog/")` defines a dynamic route that retrieves the blog posts for a specific username. + +By manipulating the username of the user, you are able to traverse different routes. + +For example, changing the username to `../login` causes clicking the blog to redirect to the login page, and `../config` causes clicking the blog to redirect to the config page. + +Since this allows us to traverse, we can use this to find `/flag` in the server. + +## Exploitation +Although such traversal can result in different routes, there is no `/flag` decorator defined so we cannot directly reach the flag. + +There are a couple of other things to notice in order to make this work. First, the display_name function used to remove underscores and make usernames more readable or user friendly. Second, YAML supports anchors (&) and aliases (*), which creates references to the same object specified. + + +Using these points, we can craft a method to dump the flag file to the screen. + +Originally, I thought there needed to be a specific username and password defined, but that is not necessary because the username and password will be changed later anyways. + +Next, in the config editor, we want to alias and anchor `user` and a blog under `blogs` together like +``` +blogs: + - &ref + name: "hi_blog_4b7f6c44a31806e7.md" + title: "hi" +user: *ref +``` + +Now, since `user` and the blog under `blogs` reference the same object, the name for that blog will become the new user name because both user and blog have a `name` field which allows this to work. We can change the `name` to be a directory traversal that takes advantage of the display_name function's underscore removal. For example: `._._/._._/flag` or `_.._/_.._/flag`. + +``` +blogs: + - &ref + name: "._._/._._/flag" + title: "flag" +user: *ref +``` + +Update the config, which changes the config to display: +``` +blogs: +- &id001 + name: ../../flag + password: hi + title: Blog Title +user: *id001 +``` +The flag path is now ready. Now, clicking blogs will bring you to the blogs page of the user with your original username, but that one specific blog will print the flag because of `"content": mistune.html((blog_path / blog["name"]).read_text())` where `blog_path/../../flag` brings you to the flag file and `.read_text()` would allow the flag to be printed. + + + +## Remediation +This exploitation takes advantage of different features in the functionality of the program including the display_name function for the username, YAML's alias and anchors feature, and the way blogs are retrieved for users. + +To prevent such exploitations, the programmer can add more validations against local file inclusion attacks, particularly checking for path traversals (`../` and `/`), and using `is_relative_to()` after all modifications and before passing to a functionality. + +Although we didn't particularly take advantage of the username and password customization in this exploitation, the programmer should also add input validation for the blog and registration (which they wrote `TODO` comments on). This will also prevent the directory traversals using custom usernames through the `/blogs` page. + + +## Other + +### Other things I noticed +1. In `app.run()`, debug is set to True. My initial thought process was to have that dump users if there may possibly be an admin user. + +2. `/login` doesn't actually work. It gets redirected to a `/home` page that doesn't exist. + diff --git a/picoctf/web/javacodeanalysis/SecretGenerator.java b/picoctf/web/javacodeanalysis/SecretGenerator.java new file mode 100644 index 00000000..219aa554 --- /dev/null +++ b/picoctf/web/javacodeanalysis/SecretGenerator.java @@ -0,0 +1,43 @@ +package io.github.nandandesai.pico.security; + +import io.github.nandandesai.pico.configs.UserDataPaths; +import io.github.nandandesai.pico.utils.FileOperation; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.nio.charset.Charset; + +@Service +class SecretGenerator { + private Logger logger = LoggerFactory.getLogger(SecretGenerator.class); + private static final String SERVER_SECRET_FILENAME = "server_secret.txt"; + + @Autowired + private UserDataPaths userDataPaths; + + private String generateRandomString(int len) { + // not so random + return "1234"; + } + + String getServerSecret() { + try { + String secret = new String(FileOperation.readFile(userDataPaths.getCurrentJarPath(), SERVER_SECRET_FILENAME), Charset.defaultCharset()); + logger.info("Server secret successfully read from the filesystem. Using the same for this runtime."); + return secret; + }catch (IOException e){ + logger.info(SERVER_SECRET_FILENAME+" file doesn't exists or something went wrong in reading that file. Generating a new secret for the server."); + String newSecret = generateRandomString(32); + try { + FileOperation.writeFile(userDataPaths.getCurrentJarPath(), SERVER_SECRET_FILENAME, newSecret.getBytes()); + } catch (IOException ex) { + ex.printStackTrace(); + } + logger.info("Newly generated secret is now written to the filesystem for persistence."); + return newSecret; + } + } +} diff --git a/picoctf/web/javacodeanalysis/images/jwtio_decode.png b/picoctf/web/javacodeanalysis/images/jwtio_decode.png new file mode 100644 index 00000000..0c0c6c06 Binary files /dev/null and b/picoctf/web/javacodeanalysis/images/jwtio_decode.png differ diff --git a/picoctf/web/javacodeanalysis/images/jwtio_encode.png b/picoctf/web/javacodeanalysis/images/jwtio_encode.png new file mode 100644 index 00000000..fc258f09 Binary files /dev/null and b/picoctf/web/javacodeanalysis/images/jwtio_encode.png differ diff --git a/picoctf/web/javacodeanalysis/javacodeanalysis-writeup.md b/picoctf/web/javacodeanalysis/javacodeanalysis-writeup.md new file mode 100644 index 00000000..57524ede --- /dev/null +++ b/picoctf/web/javacodeanalysis/javacodeanalysis-writeup.md @@ -0,0 +1,44 @@ +# picoCTF: Java Code Analysis!?! + +## Context & Vulnerability +This code creates an extensive reading application where a user can read pdfs/books that they are given the jurisdiction to access. According to the challenge description, we need to read the 'Flag' book which is only accessible by a user with admin authority whereas the user account provided to us only has the free tier authority. + +In the security folder of the challenge, specifically SecretGenerator.java, we have following code: +``` +private String generateRandomString(int len) { + // not so random + return "1234"; +} +``` +This is clearly a security concern. The application uses JWT (JSON Web Tokens) to authenticate logins and the JWT code depends on this generateRandomString function for its secret key. Since the secret key that is used for authenticating a user is not secure, it is possible that a malicious user can gain access to another user's account. + +## Background: JSON Web Tokens (JWT) +A JSON Web Token (JWT) is a method for securely transmitting information between two parties through a compact, URL-safe JSON object. It is primarily used for authentication and authorization. +A common example of JWT usage is authentication. In authentication, the user sends credentials to the server. The server verifies the credentials and creates a signed JWT that it returns to the client. In subsequent requests to protected resources that the client creates, this JWT is added to the header. The server can verify this JWT information to authorize the request is from a user with the sufficient privileges. + +## Exploitation +JWT's auth-token and token-payload can be found in Applications > Local Storage through the Inspect tool. Using the website jwt.io (JWT Debugger), one can decode the string: + +![jwtio_decode](images/jwtio_decode.png) + +We can see that these are parameters for the user, including ones we're interested in like "role": "Free" and "userId": 1. + +When we read through the code of the BookShelfConfig.java in the configs folder, we find that the code initializes the user and admin users with user's id being 1 and admin being 2. On the website, we also notice that there are three roles: Free, Premium, and Admin. Our goal is to get to Admin. + +Since the secret of the JWT was hard-coded as '1234', we can use that in the JWT Signature Verification section of jwt.io and encode the payload again, changing role to Admin and userId to 2. + +![jwtio_encode](images/jwtio_encode.png) + + +Using this payload and the new encoded JWT auth-token, we can replace the previous auth-token and token-payload. + +Refreshing the page, we receive the flag: + +picoCTF{w34k_jwt_n0t_g00d_d7c2e335} + + +## Remediation +The remediation is to actually generate a random string and complete the generateRandomString function rather than hard code the random string to 1234. In doing such, JWT can properly function and authenticate users. + +## Resources +What is JWT? https://www.jwt.io/introduction \ No newline at end of file diff --git a/umass-2026/web/order66/app.js b/umass-2026/web/order66/app.js new file mode 100644 index 00000000..8bc89e9a --- /dev/null +++ b/umass-2026/web/order66/app.js @@ -0,0 +1,54 @@ +const puppeteer = require('puppeteer'); +require('dotenv').config(); + +const FLAG = process.env.FLAG; + +async function checkUrl(targetUrl, vulnIndex) { + const browser = await puppeteer.launch({ + headless: "new", + executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || 'chromium', + args: [ + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-gpu', + '--disable-dev-shm-usage' + ] + }); + + try { + const page = await browser.newPage(); + + page.on('console', msg => console.log(msg.text())); + + const parsedUrl = new URL(targetUrl); + + await page.setCookie({ + name: 'flag', + value: FLAG, + domain: parsedUrl.hostname, + path: '/', + httpOnly: false, + secure: false, + sameSite: 'Lax' + }); + + + await page.goto(targetUrl, { + waitUntil: 'domcontentloaded', + timeout: 15000 + }); + + await new Promise(r => setTimeout(r, 5000)); + + } catch (e) { + } finally { + await browser.close(); + } +} + +if (require.main === module) { + const args = process.argv.slice(2); + if (args.length >= 2) { + checkUrl(args[0], args[1]); + } +} \ No newline at end of file diff --git a/umass-2026/web/order66/app.py b/umass-2026/web/order66/app.py new file mode 100644 index 00000000..cd232709 --- /dev/null +++ b/umass-2026/web/order66/app.py @@ -0,0 +1,115 @@ +from flask import Flask, render_template, session, request +import random +import os +import redis +import uuid +import subprocess +from dotenv import load_dotenv + +load_dotenv() + +app = Flask(__name__) +app.secret_key = os.getenv("SECRET_KEY") +PORT = int(os.getenv('PORT')) +REDIS_HOST = os.getenv('REDIS_HOST', 'redis') +host = os.getenv('Host') + +db = redis.Redis(host=REDIS_HOST, port=6379, decode_responses=True) +db.flushdb() + +def get_grid_context(uid, seed): + random.seed(seed) + v_index = random.randint(1, 66) + data = {i: (db.get(f"{uid}:box_{i}") or "") for i in range(1, 67)} + return data, v_index + +@app.route("/", methods=['GET', 'POST']) +def hello_world(): + if 'user_id' not in session: + session['user_id'] = str(uuid.uuid4()) + session['seed'] = random.randint(1000, 9999) + + uid = session['user_id'] + + current_seed = session.get('seed', random.randint(1000, 9999)) + _, current_vuln_index = get_grid_context(uid, current_seed) + + current_content = db.get(f"{uid}:box_{current_vuln_index}") or "" + + is_payload_present = " 1: + return "ERROR: Only ONE box allowed.", 400 + + for i in range(1, 67): + content = request.form.get(f'box_{i}') + if content and i in submitted: + db.set(f"{uid}:box_{i}", content) + if i == current_vuln_index and ("/") +def view_grid(uid, seed): + grid_data, vuln_index = get_grid_context(uid, seed) + return render_template('index.html', vuln_index=vuln_index, grid_data=grid_data, user_id=uid, seed=seed,host=host) + +@app.route("/admin") +def admin_page(): + return render_template('admin.html') + +from urllib.parse import urlparse + +@app.route("/admin/visit", methods=['POST']) +def admin_visit(): + target_url = request.form.get('target_url') + if not target_url or not target_url.startswith("http://"): + return "ERROR: Invalid Domain." + + # --- ADD THIS TRANSLATION LOGIC --- + try: + parsed_url = urlparse(target_url) + # In Docker, 'web' is the service name, and PORT is your env var + # This turns http://localhost:8080 into http://web:80 + internal_target = target_url.replace(parsed_url.netloc, f"web:{PORT}") + + parts = target_url.rstrip('/').split('/') + target_seed = int(parts[-1]) + target_uid = parts[-2] + _, vuln_index = get_grid_context(target_uid, target_seed) + except Exception as e: + return f"ERROR: Parsing failed: {str(e)}" + + bot_path = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'app.js') + + try: + process = subprocess.run( + ['node', bot_path, internal_target, str(vuln_index)], # Use internal_target! + capture_output=True, + text=True, + timeout=25, + shell=False, + env=os.environ + ) + + # If process.stdout is empty, it usually means Node crashed + return process.stdout if process.stdout else f"Bot Error: {process.stderr}" + except Exception as e: + return f"System Error: {str(e)}" + +if __name__ == "__main__": + app.run(host='0.0.0.0', port=PORT, debug=False) diff --git a/umass-2026/web/order66/order66-writeup.md b/umass-2026/web/order66/order66-writeup.md new file mode 100644 index 00000000..9ff22ad8 --- /dev/null +++ b/umass-2026/web/order66/order66-writeup.md @@ -0,0 +1,50 @@ +# UMass 2026: web/order66 + +## Context & Vulnerability +This is an application with 66 order boxes and a link to the chancellor (admin) as well as a url 'logs for chancellor'. While testing out the site, you notice that you can only input into one of the orders. Additionally, regardless of the input, the link in the url box does not change, whereas inserting this link into the chancellor/admin does not work. The chancellor/admin also does not take https, only http connections. + +In the code, you notice that is_payload_present variable hints at the XSS solve. + +`is_payload_present = "alert(1)` + +The box that allows the alert to go through is the vulnerable box which we can inject with a payload we want the chancellor/admin to execute. We can use `` in order to dump the cookie into the output of the chancellor/admin. + +app.py also contains the endpoint `/view//` which is what we want to add to the end of the ctf url to input to the chancellor/admin. Doing so dumps the flag. + +``` +◇ injecting env (0) from .env // tip: ⌘ override existing { override: true } +flag=UMASS{m@7_t53_f0rce_b$_w!th_y8u} +Failed to load resource: the server responded with a status of 404 (NOT FOUND) +``` + +## Remediation +The easiest remediation for this scenario in order for the user to be unable to access the cookie is setting secure equal to true since the chancellor/admin only takes http connections. + +However, in order to prevent XSS, it is imperative to check other inputs as well for javascript injections that the admin will execute when visiting the site inputted. Checks include sanitizing and encoding in order to make sure code is not executed. + +Additionally, not including a vulnerable index or box would be an optimal remediation for this challenge. +