CTF

나의 첫 CTF [2024 Layer7 CTF]

ris 2025. 1. 12. 16:37

 

소개

2024 Layer7 CTF는 선린 인터넷 고등학교의 Layer7 동아리에서 개최하는 CTF이다.

신청은 Layer7 CTF에서 하고 디스코드도 가입해야 한다.

 

푼 문제들

솔직히 2 문제 풀었다만 실질적으로 푼 문제는 1 문제이다.

나머지 1 문제는 그냥 답이 나와있기 때문이다.

그렇기 때문에 사실 푼 문제'들'이 아니라 푼 문제라고 하는 게 맞다.

그러나 자존심이 있기 때문에 '들'이라고 했다.

 

거두절미하고 다음은 푼 문제에 대한 간단한 write-up이다.

홈 화면

 

Flag 형식은 Layer7{...} 이다.

이번 문제는 File vulnerability와 Path traversal으로 푸는 문제였다.

파일을 올려 다른 경로의 파일을 조회하는 것이 풀이방식이다.

 

전체 코드

from flask import Flask, render_template_string, request, redirect, url_for, flash, send_from_directory
import os
import markdown
import datetime

app = Flask(__name__, static_folder=None)  # 정적 폴더 비활성화
app.secret_key = "helo"
UPLOAD_FOLDER = "uploads"
ALLOWED_EXTENSIONS = {"md"}

if not os.path.exists(UPLOAD_FOLDER):
    os.makedirs(UPLOAD_FOLDER)

app.config["UPLOAD_FOLDER"] = UPLOAD_FOLDER

def allowed_file(filename):
    return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS

@app.route("/", methods=["GET", "POST"])
def index():
    if request.method == "POST":
        if "file" not in request.files:
            flash("No file part")
            return redirect(request.url)
        
        file = request.files["file"]
        if file.filename == "":
            flash("No selected file")
            return redirect(request.url)

        if file and allowed_file(file.filename):
            filepath = os.path.join(app.config["UPLOAD_FOLDER"], file.filename)
            file.save(filepath)
            return redirect(url_for("view_file", filename=file.filename))
        else:
            flash("Only markdown (.md) files are allowed")
            return redirect(request.url)

    return '''
    <!doctype html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
        <title>Layer Blog</title>
        <style>
            body {
                background-color: #f8f9fa;
                font-family: 'Arial', sans-serif;
            }
            .container {
                margin-top: 50px;
                max-width: 600px;
            }
            .btn {
                width: 100%;
            }
        </style>
    </head>
    <body>
        <div class="container text-center">
            <h1 class="mb-4">Welcome to Layer Blog</h1>
            <p class="mb-4">Upload a Markdown file to create a new blog post!</p>
            <form method="post" enctype="multipart/form-data">
                <div class="mb-3">
                    <input type="file" name="file" class="form-control">
                </div>
                <button type="submit" class="btn btn-primary">Upload</button>
            </form>
        </div>
    </body>
    </html>
    '''

@app.route("/view/<filename>")
def view_file(filename):
    filepath = os.path.join(app.config["UPLOAD_FOLDER"], filename)
    if not os.path.exists(filepath):
        return "File not found!", 404

    with open(filepath, "r", encoding="utf-8") as f:
        content = f.read()

    html_content = markdown.markdown(content)
    post_date = datetime.datetime.now().strftime("%B %d, %Y")

    return render_template_string(f"""
    <!doctype html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>{filename} - Layer Blog</title>
        <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
        <style>
            body {{
                background-color: #f8f9fa;
                font-family: 'Georgia', serif;
            }}
            .navbar {{
                background-color: #343a40;
            }}
            .navbar-brand {{
                color: #ffffff !important;
                font-weight: bold;
            }}
            .container {{
                margin-top: 2rem;
                max-width: 800px;
            }}
            .post-title {{
                font-size: 2.5rem;
                font-weight: bold;
                margin-bottom: 1rem;
                color: #343a40;
            }}
            .post-meta {{
                font-size: 0.9rem;
                color: #6c757d;
                margin-bottom: 2rem;
            }}
            .markdown-content {{
                font-size: 1.2rem;
                line-height: 1.8;
                color: #333;
            }}
            footer {{
                margin-top: 3rem;
                padding: 1rem 0;
                background-color: #343a40;
                color: #ffffff;
                text-align: center;
            }}
        </style>
    </head>
    <body>
        <nav class="navbar navbar-dark">
            <div class="container">
                <a class="navbar-brand" href="/">Layer Blog</a>
            </div>
        </nav>
        <div class="container">
            <h1 class="post-title">{filename}</h1>
            <p class="post-meta">Posted on {post_date}</p>
            <div class="markdown-content">
                {html_content}
            </div>
            <a href="/" class="btn btn-primary mt-4">Upload Another File</a>
        </div>
        <footer>
            <p>&copy; 2024 Layer Blog. All rights reserved.</p>
        </footer>
    </body>
    </html>
    """)

@app.route("/image/<filename>")
def serve_image(filename):
    return send_from_directory("static", filename)

if __name__ == "__main__":
    app.run(debug=True, host="0.0.0.0", port=9292)

 

전에 Flask로 게시판을 만들어 본 경험이 도움이 되었다.

 

def allowed_file(filename):
    return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS

@app.route("/", methods=["GET", "POST"])
def index():
    if request.method == "POST":
        if "file" not in request.files:
            flash("No file part")
            return redirect(request.url)
        
        file = request.files["file"]
        if file.filename == "":
            flash("No selected file")
            return redirect(request.url)

        if file and allowed_file(file.filename):
            filepath = os.path.join(app.config["UPLOAD_FOLDER"], file.filename)
            file.save(filepath)
            return redirect(url_for("view_file", filename=file.filename))
        else:
            flash("Only markdown (.md) files are allowed")
            return redirect(request.url)

 

파일을 올리는 창의 코드이다.

순서를 말하자면

 

1. 파일을 POST 방식으로 전달받는다.

2. allowed_file에서 .md가 들어간 파일만을 받는다.

3. 설정한 UPLOAD 폴더에 파일을 넣는다.

4. view_file 창으로 넘어가 upload한 파일을 본다.

 

이제 view_file 코드를 보자면

@app.route("/view/<filename>")
def view_file(filename):
    filepath = os.path.join(app.config["UPLOAD_FOLDER"], filename)
    if not os.path.exists(filepath):
        return "File not found!", 404

    with open(filepath, "r", encoding="utf-8") as f:
        content = f.read()

    html_content = markdown.markdown(content)
    post_date = datetime.datetime.now().strftime("%B %d, %Y")

 

받은 파일을 기반으로 UPLOAD 폴더에서 파일을 꺼내온다.

마크다운 형식의 내용을 html 형식으로 바꿔서 사용자에게 제공한다.

 

그러면 이제 풀이를 작성해 보자.

풀이

이 문제를 풀기 전에 알아야 할 정보가 2가지 있다.

 

.md 파일은 외부 이미지 혹은 내부 이미지를 불러올 수 있다.

static_folder=None 의 의미는 flask 내에서 정적 파일 접근을 막아 커스텀 정적 파일 접근 로직을 만들 수 있어 효율적인 구조를 설정한다는 의미이며, 즉 flask에서 제공하는 /static 경로를 막는다는 뜻이다.

 

고로 /static 경로를 직접적으로 쓰면 안 된다는 말이다.

 

.md에서는 이미지를 불러오기 위해 

![대체할 텍스트](이미지 경로)

 

이런 문법을 가진다.

경로는 정확해야 하며 상대 경로 혹은 절대 경로로 할 수 있다. (내부 이미지 불러올 경우)

 

그리고 문제에서는 

이리 말했기 때문에 

아무 .md 파일을 만들어 안에

 

![아무 텍스트](/image/flag.png)

 

라고 하면 된다.

내가 헷갈렸던 것은 경로 설정이었는데

단순히 /static/flag.png라고 하면 안 되기 때문에 뭐라 해야 하는지 몰라서 한참을 헤맸었다.

 

@app.route("/image/<filename>")
def serve_image(filename):
    return send_from_directory("static", filename)

 

그냥 대놓고 알려준다.

아래 return 부분에는 static 파일에 filename의 이름을 가진 파일을 보여준다는 의미다.

그래서 그냥 url에 /image/flag.png라고 써도 된다.

그리고 .md 파일에 위에 나온 것처럼 써도 된다.

진짜 코드 좀 잘 봐야겠다.

 

그리고 왜 image라고 써야 하냐. 다른 asset이나 아무 텍스트는 왜 안 되냐

이렇게 물어볼 수도 있다.

나도 이랬다.

근데 코드를 조금만 더 살펴보면

소감

어제 처음으로 CTF를 해보며 내가 정말 해킹의 겉핥기를 했구나를 알게 되었다.

솔직히 개념을 숙지하고서 내가 그래도 몇 문제는 풀 수 있겠지라고 생각했지만 전혀 그렇지 않았다.

단순히 개념은 개념일 뿐 응용은 사용자의 역량이었고 나는 그걸 간과한 새내기였었다.

그래도 이제야 깨달았고 겨우겨우 한 문제를 풀 때 그 성취감은 성격이 무던한 나도 가슴이 뛰도록 해주었다.

더욱 더 열심히 실력을 갈고닦아서 다음 CTF에서는 그래도 3문제는 풀고 싶다.