-
Notifications
You must be signed in to change notification settings - Fork 14
Report 4 - Flora - LACTF 2024 /web/la-housing-portal #72
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
florayq
wants to merge
4
commits into
CUCTF:main
Choose a base branch
from
florayq:lahousingportal
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| # LA CTF 2024: web/la-housing-portal | ||
|
|
||
| ## Context & Vulnerability | ||
| This is a simple housing portal that matches the user with people in the database that have the same preferences for guests, neatness, sleeptime, and wake-up time. | ||
|
|
||
| When clicking submit, the application creates a POST request with the information of the request, does a basic check for commenting in SQL `--` and Python `/*` before passing the arguments into a SQL instruction to search the database. The arguments are added as additional parameters for the search which returns only the ones that satisfy the conditions. | ||
|
|
||
| The application is not secure, as the prompt gives the warning: | ||
| Please note, we do not condone any actual attacking of websites without permission, even if they explicitly state on their website that their systems are vulnerable. | ||
|
|
||
| Our goal is the flag which is in a separate table called flag under the variable name flag. | ||
|
|
||
| The use of SQL, the flag location, and the vulnerable website indicate that we will likely have to use SQL injection to the POST request to try to have the flag printed from that table. | ||
|
|
||
| ## Background Information: SQL Injection (SQLi) | ||
| SQL injection is the practice of putting SQL language into inputs in order to change the backend SQL command that will be executed. This can allow the attacker to access sensitive information in the database. Common SQL injections include using `--` which is SQL language for commenting out the rest of the line, and `'1'='1'` which resolves to a true statement in SQL. | ||
|
|
||
| ## Exploitation | ||
| The goal is to print the flag from the flag table, but we are unable to use comments because it is being checked. We can try adding SQL injection statements to the last parameter in BurpSuite, altering the request. | ||
| A good tool for trying these requests is BurpSuite's repeater. | ||
|
|
||
| When trying basic SQL injection statements, we find that `'1'='1` works, and realize that there is an apostrophe at the end we must account for. | ||
|
|
||
| Knowing this, now we can try to use UNION to select also from the flag table. An attempt could be `' union select * from flag where '1'='1`. Note that this has to be url encoded because of the whitespaces in BurpSuite. This would not work and return a 500 error code meaning that there was a problem with the SQL injection we gave that could not be executed. This is because unioning the tables must have the same number of columns. | ||
|
|
||
| We may try something similar to `' union select flag, NULL, NULL, NULL, NULL from flag where '1'='1` to see how many columns and if we can get the flag, but the server returns "invalid form data" due to the injection being too long. | ||
|
|
||
| Using `'union select 1,2,3,4,5,6 from flag where '1` works and we see prints 2,3,4,5,6 in the columns. Then, we can add the flag to item 2 to print it out. | ||
|
|
||
| `'union select 1,flag,3,4,5,6 from flag where '1` | ||
|
|
||
| lactf{us3_s4n1t1z3d_1npu7!!!} | ||
|
|
||
| ## Remediation | ||
| This exploitation takes advantage of the fact that we can inject SQL code into the POST request and the input is not thoroughly checked for attacks. Some ways to prevent these attacks is encoding the information transported by the request to the server or checking the received information on the server request to verify that it is a valid option from the dropdown list before passing it into the SQL command. | ||
|
|
||
| ## Other Things to Note | ||
| The usage of `where '1'` actually only works in sqlite which is used for this local database because of sqlite's flexibility. This would not work in general non-sqlite cases. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(` | ||
| <p>${op?.name}</p> | ||
| <p>${op?.moves}</p> | ||
| `); | ||
| }); | ||
|
|
||
| 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}`); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(` | ||
| <p>${op?.name}</p> | ||
| <p>${op?.moves}</p> | ||
| `); | ||
| }); | ||
| ``` |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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/<string:username>") | ||
| 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) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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/<string:username>")` 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. | ||
|
|
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you go into a shade more detail on this?