テクノロジー

【CTF】SECCON Beginners CTF 2023 新卒チームWriteup

はじめに

始めまして、23年新卒入社のG・Sです。

マイナビ2023年新卒入社で参加者を募って、6/3(土)4(日)開催のSECCON Beginners CTF 2023に参加してきました。
本記事は、我々新卒チームが解いた問題のwritupになっています。

今回参加したCTFについて

SECCON Beginners CTF 2023
日本のCTF初心者〜中級者を対象としたCTFのコンテストで毎年6月の上旬に開催されています。今回は2018年の初開催から数えて6回目の開催となります。

出題範囲は、crypto、pwnable、misc、web、reversingでした。

Writeup

我々のチームはBeginnerを全て解くことができました!!!
ひとつずつ解説していこうと思います。

CoughingFox2(crypto)

CougningFox2.tar.gzを展開するとcipher.txtとmain.pyが入っていました。
cipher.txtとmain.pyの内容はそれぞれ以下の通りです。

cipher = [4396, 22819, 47998, 47995, 40007, 9235, 21625, 25006, 4397, 51534, 46680, 44129, 38055, 18513, 24368, 38451, 46240, 20758, 37257, 40830, 25293, 38845, 22503, 44535, 22210, 39632, 38046, 43687, 48413, 47525, 23718, 51567, 23115, 42461, 26272, 28933, 23726, 48845, 21924, 46225, 20488, 27579, 21636]
# coding: utf-8
import random
import os

flag = b"ctf4b{xxx___censored___xxx}"

# Please remove here if you wanna test this code in your environment :)
flag = os.getenv("FLAG").encode()

cipher = []

for i in range(len(flag)-1):
    c = ((flag[i] + flag[i+1]) ** 2 + i)
    cipher.append(c)

random.shuffle(cipher)

print(f"cipher = {cipher}")

プログラムを読むと暗号化を行い最終的にcipherというリストを出力していることが分かります。このことからcipher.txtに書かれていたものは暗号化がおこなわれた後のものでと考えられます。また暗号化の部分はi文字目とi+1文字目を足して2乗し、iを足したものであることから、一つの文字が分かれば次の文字を総当たりで探し(flag[i] + flag[i+1]) ** 2 + iを計算しcipher.txt内の配列に存在するか確認すれば芋づる式に解きFlagを得ました。

Flag : ctf4b{hi_b3g1nner!g00d_1uck_4nd_h4ve_fun!!!}

学び

暗号は一見難しそうなものでもプログラムを書いたりすることで容易に解けてしまうものも多く存在しています。
よって、暗号の選定をする際にはきちんと脆弱性がないものを選ぶことが重要であると感じました。

poem(pwnable)


poem.tar.gzを展開するとpoemとsrc.cが入っていました。

poemは実行ファイルであり、src.cの内容は以下の通りです。

#include <stdio.h>
#include <unistd.h>

char *flag = "ctf4b{***CENSORED***}";
char *poem[] = {
    "In the depths of silence, the universe speaks.",
    "Raindrops dance on windows, nature's lullaby.",
    "Time weaves stories with the threads of existence.",
    "Hearts entwined, two souls become one symphony.",
    "A single candle's glow can conquer the darkest room.",
};

int main() {
  int n;
  printf("Number[0-4]: ");
  scanf("%d", &n);
  if (n < 5) {
    printf("%s\n", poem[n]);
  }
  return 0;
}

__attribute__((constructor)) void init() {
  setvbuf(stdin, NULL, _IONBF, 0);
  setvbuf(stdout, NULL, _IONBF, 0);
  alarm(60);
}

このプログラムを読むと数値nを読み取り、配列に格納されているn番目のポエムを出力するプログラムであることが分かります。読み込んだnが5より大きいときはポエムを出力しないようにしてありますが、0より小さい場合はそのまま配列にアクセスすることになっています。

よってnにマイナスの値を入力することでほかの領域のアドレスにアクセスすることがで、-4を入力することでFlagを得ました。

Flag : ctf4b{y0u_sh0uld_v3rify_the_int3g3r_v4lu3}

学び

Pythonなどでは、リストなどの範囲外のindexを指定した場合IndexErrorが出ますが、C言語ではエラーが出ずに別メモリ領域にアクセス可能になります。
これを防ぐためには、境界値を正しく設定し条件分岐することが大事たと感じました。

YARO(misc)


YARO.tar.gzを展開するとrule_example.yarとserver.pyが入っていました。
それぞれ内容は以下の通りです。

rule shebang {
    strings:
        $shebang = /^#!(\/[^\/ ]*)+\/?/
    condition:
        $shebang
}
rule maybe_python_executable {
    strings:
        $ident = /python(2|3)\r*\n/
    condition:
        shebang and $ident
}
#!/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")

コードを見るとruleを入力させ、yaraというマルウェア解析・検知ツールを用いて検索を行っています。
下記のようなruleを書くことでflagの一文字目がAかどうか確認できます。

rule flag {
    strings:
        $shebang = /ctf4b{A.*}/
    condition:
        $shebang
}

これを繰り返して探索していくことでFlagを得ました。

Flag : ctf4b{Y3t_An0th3r_R34d_Opp0rtun1ty}

学び

ユーザーに任意の正規表現を書かせるようなプログラムを書くと秘匿情報を探索される可能性があるため注意が必要だと学びました。

polyglot4b(misc)

polyglot4b.tar.gzを展開すると画像ファイルsample/sushi.jpgとpolyglot4b.pyが入っていました。

import os
import sys
import uuid
import shutil
import subprocess

print(
    f"""\033[36m\
 ____       _             _       _     _____    _ _ _
|  _ \ ___ | |_   _  __ _| | ___ | |_  | ____|__| (_) |_ ___  _ __
| |_) / _ \| | | | |/ _` | |/ _ \| __| |  _| / _` | | __/ _ \| '__|
|  __/ (_) | | |_| | (_| | | (_) | |_  | |__| (_| | | || (_) | |
|_|   \___/|_|\__, |\__, |_|\___/ \__| |_____\__,_|_|\__\___/|_|
              |___/ |___/
{"-" * 68}
>> """,
    end="",
)

file = b""
for _ in range(10):
    text = sys.stdin.buffer.readline()
    if b"QUIT" in text:
        break
    file += text

print(f"{'-' * 68}\033[0m")

if len(file) >= 50000:
    print("ERROR: File size too large. (len < 50000)")
    sys.exit(0)

f_id = uuid.uuid4()
os.makedirs(f"tmp/{f_id}", exist_ok=True)
with open(f"tmp/{f_id}/{f_id}", mode="wb") as f:
    f.write(file)
try:
    f_type = subprocess.run(
        ["file", "-bkr", f"tmp/{f_id}/{f_id}"], capture_output=True
    ).stdout.decode()
except:
    print("ERROR: Failed to execute command.")
finally:
    shutil.rmtree(f"tmp/{f_id}")

types = {"JPG": False, "PNG": False, "GIF": False, "TXT": False}
if "JPEG" in f_type:
    types["JPG"] = True
if "PNG" in f_type:
    types["PNG"] = True
if "GIF" in f_type:
    types["GIF"] = True
if "ASCII" in f_type:
    types["TXT"] = True

for k, v in types.items():
    v = "🟩" if v else "🟥"
    print(f"| {k}: {v} ", end="")
print("|")

if all(types.values()):
    print("FLAG: ctf4b{****REDACTED****}")
else:
    print("FLAG: No! File mashimashi!!")

コードを読むと10回入力を受け取り入力を結合してfileコマンドを実行しその出力にPNG、JPEG、GIF、ASCIIの文字列があるかを確認して全てあればフラグを出力しています。
まずsample/sushi.jpgをfile -bkr sample/sushi.jpgを行うとDescriptionの欄にCTF4Bという文字を見つけられます。このことからDescription欄には、任意の文字を入れることができると考えられます。よってCTF4Bの後ろにPNGJPEGGIFASCIIを追加して、nc polyglot4b.beginners.seccon.games 31416 < sushi.jpgとやることで、Flagを得ました。

Flag : ctf4b{y0u_h4v3_fully_und3r5700d_7h15_p0ly6l07}

学び

fileコマンドを使ってファイルを識別する際に正しく処理を書かなければ、ユーザーの悪意によっていくらでも偽装できることが可能であるとわかりました。今回の問題のミスをなくすためには、fileコマンドの解析をファイルタイプを示している部分だけにするなどの工夫が必要であると感じました。

Forbidden(web)


Forbidden.tar.gzを展開するとnginxで作られたWebアプリケーションが入っていました。
app/index.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);
});

コードを読むとblockで/flagに遷移したとき403を返していることが分かります。
nginxは、URLの大文字小文字の区別がない。すなわち"/flag"と"/Flag"は、同じページであると判断されます。
しかし、JavaScriptでは、if (req.path.includes('/flag'))で分岐を行いブロック処理を記述しているため、https://forbidden.beginners.seccon.games/flagは403が帰ってくるがhttps://forbidden.beginners.seccon.games/Flagとすることでフラグを得ることができます。

Flag : ctf4b{403_forbidden_403_forbidden_403}

学び

フレームワークの仕様をしっかり理解していることが大切であると感じました。
今回の例では、nginxとJSで文字列の大小の扱いが違っていてその違いを理解していないことで意図するように動いていないので、仕様理解をおろそかにしてはいけないと学びました。

aiwaf(web)

aiwaf.tar.gzを展開するとnginxで作られたWebアプリケーションが入っていました。
app/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)

コードを読むと

以下の{puuid}に囲まれた部分のURLクエリはパストラバーサル攻撃でしょうか?
そうである場合Yesを、違う場合Noを返してください。
../やflagという文字列が含まれていた場合もYesを返してください。

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

というプロンプトをAIに投げて帰ってきた値がYesだった場合、ブロックする仕様になっています。

プロンプト部分を見るとurllib.parse.unquote(request.query_string)[:50]となっており与えられたURLの50文字目までしかAIによる検知を行っていないことがわかります。
これにより、パラメータに適当なものを追加しfile=../flagが50文字目以降となるように設定することでAIによる検知を抜けてフラグを得ることができました。

Flag : ctf4b{pr0mp7_1nj3c710n_c4n_br34k_41_w4f}

学び

OpenAIは文字単位でお金がかかります。そのため、コストを下げようと途中で文字列を切ってからプロンプトを生成してしまうなどすると切り取った以降の部分に攻撃が含まれている可能性があり、攻撃が成立してしまう可能性があります。文字列を切る場合は、以降の処理も切ってから行うなどしないといけないと感じました。

Half(reversing)


Half.tar.gzを展開するとhalfという実行ファイルが入っていました。
このプログラムを実行すると入力待ちになり、入力されたフラグの正誤判定をしてくれます。

この実行ファイルをstringsコマンドを使って可読部分を表示して得られる結果が以下の通りです。

$ strings half
/lib64/ld-linux-x86-64.so.2
libc.so.6
strncmp
__isoc99_scanf
puts
printf
strlen
__cxa_finalize
strcmp
__libc_start_main
GLIBC_2.7
GLIBC_2.2.5
_ITM_deregisterTMCloneTable
__gmon_start__
_ITM_registerTMCloneTable
u+UH
[]A\A]A^A_
Enter the FLAG: 
%99s%*[^
Invalid FLAG
ctf4b{ge4_t0_kn0w_the
_bin4ry_fi1e_with_s4ring3}
Correct!
:*3$"
GCC: (Ubuntu 9.4.0-1ubuntu1~20.04.1) 9.4.0
crtstuff.c
deregister_tm_clones
__do_global_dtors_aux
completed.8061
__do_global_dtors_aux_fini_array_entry
frame_dummy
__frame_dummy_init_array_entry
main.c
__FRAME_END__
__init_array_end
_DYNAMIC
__init_array_start
__GNU_EH_FRAME_HDR
_GLOBAL_OFFSET_TABLE_
__libc_csu_fini
strncmp@@GLIBC_2.2.5
_ITM_deregisterTMCloneTable
puts@@GLIBC_2.2.5
_edata
strlen@@GLIBC_2.2.5
printf@@GLIBC_2.2.5
__libc_start_main@@GLIBC_2.2.5
__data_start
strcmp@@GLIBC_2.2.5
__gmon_start__
__dso_handle
_IO_stdin_used
__libc_csu_init
__bss_start
main
__isoc99_scanf@@GLIBC_2.7
__TMC_END__
_ITM_registerTMCloneTable
__cxa_finalize@@GLIBC_2.2.5
.symtab
.strtab
.shstrtab
.interp
.note.gnu.property
.note.gnu.build-id
.note.ABI-tag
.gnu.hash
.dynsym
.dynstr
.gnu.version
.gnu.version_r
.rela.dyn
.rela.plt
.init
.plt.got
.plt.sec
.text
.fini
.rodata
.eh_frame_hdr
.eh_frame
.init_array
.fini_array
.dynamic
.data
.bss
.comment

この出力を見てみると20行目にFlagがあります。

Flag : ctf4b{ge4_t0_kn0w_the_bin4ry_fi1e_with_s4ring3}

学び

文字列は、実行ファイルにそのまま保存されるため、秘匿情報を文字列にしたまま実行ファイルに入れてしまうと容易に見れてしまうので、秘匿情報を載せないことを徹底しなければいけないと感じました。

終わりに

今回は、私は初参加ながら全てのbeginner問題を解くことができたので大変満足しています。
しかし、medium・herdあたりが全く解けなかったのでもう少し精進していきたいと感じました。

CTFに参加することで今まで知らなかった脆弱性などを知る機会になり、
セキュリティへの意識が高まるので是非皆さんも参加してみてください。

※サムネ画像ロゴは『SECCON Beginners CTF』より引用

※本記事は2023年09月時点の内容です。

テクノロジーの記事一覧
タグ一覧
TOPへ戻る