Copy & Run

작은 Flask 서비스를 직접 공격하고 한 줄씩 고치기

빈 폴더에서 Flask 게시판을 직접 띄우고 로그인·다운로드·게시판 기능을 차례로 뚫어 봅니다. 뚫은 지점을 그대로 한 줄씩 고치면서 왜 그 한 줄이 공격을 막는지 눈으로 확인합니다.

실습 노트 · 2026-10-15 발행 · 14분 · 2026-10 확인

로그인·게시판·파일 다운로드만 있는 작은 Flask 서비스를 처음부터 직접 띄웁니다. 그 다음 같은 서비스를 세 방향으로 직접 공격해 보고, 공격이 통하는 자리를 코드에서 찾아 한 줄씩 고칩니다. 고친 뒤에는 방금 썼던 공격을 똑같이 다시 넣어 막히는 것까지 확인합니다.

여기 나오는 공격은 자신이 직접 띄운 로컬 서비스에만 실행합니다. 다른 사람의 서버나 서비스에 같은 입력을 넣는 것은 별개의 문제이고, 허락 없이 하면 안 됩니다.

준비물

도구 확인한 버전
Python 3.12.10
Flask 3.1.3
curl Windows 기본 포함 curl (curl.exe)

명령은 Git Bash 기준입니다. PowerShell에서는 curl이 다른 명령의 별칭이므로 curl.exe로 바꿔 쓰고, grep·head·/dev/null은 쓸 수 없습니다. 전체 과정은 20분 안팎이면 충분합니다.

1. 빈 폴더에서 서비스를 직접 띄우기

새 폴더에 가상환경을 만들고 Flask를 설치합니다.

mkdir flask-demo && cd flask-demo
python -m venv .venv
source .venv/Scripts/activate
pip install flask

macOS나 Linux에서는 source .venv/bin/activate를 사용합니다. 이어서 같은 폴더에 파일 두 개를 만듭니다. init_db.py는 계정 두 개와 글 세 개가 든 app.db를 만들고, app.py는 세 가지 경로를 가진 서비스입니다. /login은 아이디·비밀번호를 문자열로 그대로 이어 붙여 질의를 만들고, /download는 사용자가 준 파일명을 검증 없이 경로에 붙이고, 글 상세 화면은 글 본문을 이스케이프 없이 HTML에 끼워 넣습니다. 세 지점 모두 입력을 검증하지 않고 그대로 쓴다는 같은 문제를 안고 있습니다.

init_db.py 전체 코드
import os
import sqlite3

BASE = os.path.dirname(os.path.abspath(__file__))
DB = os.path.join(BASE, "app.db")

if os.path.exists(DB):
    os.remove(DB)
conn = sqlite3.connect(DB)
conn.executescript("""
CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE, password TEXT);
CREATE TABLE posts (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, body TEXT, author TEXT);
""")
conn.executemany("INSERT INTO users (username, password) VALUES (?, ?)",
                 [("admin", "s3cr3t-admin"), ("user", "user1234")])
conn.executemany("INSERT INTO posts (title, body, author) VALUES (?, ?, ?)",
                 [("공지사항", "실습용 게시판입니다.", "admin"),
                  ("환영합니다", "자유롭게 글을 남겨 보세요.", "user"),
                  ("점검 안내", "정기 점검이 예정되어 있습니다.", "admin")])
conn.commit()
conn.close()
print("created", DB)
app.py 전체 코드
import os
import sqlite3
from flask import Flask, request, session, redirect, send_file, render_template_string

app = Flask(__name__)
app.secret_key = "dev-insecure-key"
BASE = os.path.dirname(os.path.abspath(__file__))
DB = os.path.join(BASE, "app.db")
UPLOAD_DIR = os.path.join(BASE, "uploads")
os.makedirs(UPLOAD_DIR, exist_ok=True)

LIST_HTML = """<h1>게시판</h1>
<ul>{% for p in posts %}<li>{{ p.id }}. <a href="/board/{{ p.id }}">{{ p.title }}</a> ({{ p.author }})</li>{% endfor %}</ul>"""


def db():
    conn = sqlite3.connect(DB)
    conn.row_factory = sqlite3.Row
    return conn


@app.route("/login", methods=["GET", "POST"])
def login():
    if request.method == "POST":
        username = request.form.get("username", "")
        password = request.form.get("password", "")
        query = ("SELECT * FROM users WHERE username = '%s' AND password = '%s'"
                 % (username, password))
        conn = db()
        try:
            row = conn.execute(query).fetchone()
        except sqlite3.Error as e:
            return "오류: %s" % e, 500
        finally:
            conn.close()
        if row:
            session["user"] = row["username"]
            return redirect("/board")
        return "로그인 실패", 200
    return '<form method="post"><input name="username"><input name="password" type="password"><button>로그인</button></form>'


@app.route("/board", methods=["GET", "POST"])
def board():
    conn = db()
    if request.method == "POST":
        if "user" not in session:
            conn.close()
            return redirect("/login")
        conn.execute("INSERT INTO posts (title, body, author) VALUES (?, ?, ?)",
                     (request.form.get("title", ""), request.form.get("body", ""), session["user"]))
        conn.commit()
    posts = conn.execute("SELECT * FROM posts ORDER BY id DESC").fetchall()
    conn.close()
    return render_template_string(LIST_HTML, posts=posts)


@app.route("/board/<int:post_id>")
def post_detail(post_id):
    conn = db()
    post = conn.execute("SELECT * FROM posts WHERE id = ?", (post_id,)).fetchone()
    conn.close()
    if not post:
        return "글을 찾을 수 없습니다.", 404
    html = ("<h2>%s</h2><p>작성자: %s</p><hr><div>%s</div>"
            % (post["title"], post["author"], post["body"]))
    return render_template_string(html)


@app.route("/download")
def download():
    filename = request.args.get("file", "")
    path = os.path.join(UPLOAD_DIR, filename)
    if not os.path.exists(path):
        return "파일이 없습니다.", 404
    return send_file(path)


if __name__ == "__main__":
    app.run(host="127.0.0.1", port=5000)

데이터베이스를 만들고 서비스를 띄웁니다.

python init_db.py
python app.py
# 기대 출력
created ...\flask-demo\app.db
 * Serving Flask app 'app'
 * Debug mode: off
 * Running on http://127.0.0.1:5000

서비스는 이 터미널에서 계속 실행되므로, 이후 명령은 새 터미널을 열어 같은 폴더에서 실행합니다. 새 터미널에서는 source .venv/Scripts/activate를 한 번 더 실행합니다. 브라우저로 http://127.0.0.1:5000/board에 들어가면 게시글 목록이 보입니다. 계정은 admin / s3cr3t-admin, user / user1234입니다.

2. 로그인 화면을 SQL 인젝션으로 뚫어 보기

로그인 처리 코드를 보면 비밀번호 검사를 이렇게 합니다.

query = ("SELECT * FROM users WHERE username = '%s' AND password = '%s'"
         % (username, password))
row = conn.execute(query).fetchone()

아이디 칸에 admin' -- 을 넣으면 질의 뒤쪽이 주석으로 처리되어 비밀번호 검사 자체가 없어집니다.

curl -s -X POST http://127.0.0.1:5000/login \
  --data-urlencode "username=admin' -- " --data-urlencode "password=x" -i | grep -E "^(HTTP|Location)"
# 기대 출력
HTTP/1.1 302 FOUND
Location: /board

비밀번호를 전혀 모르는 상태에서 로그인 성공 뒤의 이동 응답인 302가 나옵니다. 컬럼 수를 모를 때는 UNION SELECT로 하나씩 늘려 가며 오류가 사라지는 지점을 찾습니다. 이 서비스는 users 테이블이 3컬럼이라 z' UNION SELECT 1,2 -- 에서는 500 오류가 나고 z' UNION SELECT 1,2,3 -- 에서 오류 없이 로그인이 됩니다.

3. 경로 조작으로 데이터베이스 파일을 훔쳐보기

다운로드 코드는 받은 파일명을 그대로 폴더 경로에 붙입니다.

filename = request.args.get("file", "")
path = os.path.join(UPLOAD_DIR, filename)
if not os.path.exists(path):
    return "파일이 없습니다.", 404
return send_file(path)

../로 업로드 폴더를 벗어나면 서비스 폴더 안의 다른 파일을 그대로 내려받을 수 있습니다. 내려받은 파일을 열어 보는 check_stolen.py를 같은 폴더에 만듭니다. 따옴표가 섞인 한 줄 명령을 셸마다 다르게 써야 하는 문제를 피하려고 파일로 둡니다.

import sqlite3
print(sqlite3.connect('stolen.db').execute('select username,password from users').fetchall())
curl -s "http://127.0.0.1:5000/download?file=../app.db" -o stolen.db
python check_stolen.py
# 기대 출력
[('admin', 's3cr3t-admin'), ('user', 'user1234')]

데이터베이스 파일을 폴더 밖에서 그대로 꺼내 왔고, 비밀번호가 평문으로 저장되어 있어 바로 읽힙니다.

4. 게시판에 스크립트를 심어 보기

글 상세 화면은 본문을 문자열로 조립해 그대로 돌려줍니다.

html = ("<h2>%s</h2><p>작성자: %s</p><hr><div>%s</div>"
        % (post["title"], post["author"], post["body"]))
return render_template_string(html)

로그인한 상태로 글 본문에 <script>alert(1)</script>를 넣고 등록한 뒤 그 글을 다시 엽니다.

curl -s -c cookie.txt -X POST http://127.0.0.1:5000/login \
  --data-urlencode "username=admin' -- " --data-urlencode "password=x" -o /dev/null
curl -s -b cookie.txt -X POST http://127.0.0.1:5000/board \
  --data-urlencode "title=test" --data-urlencode "body=<script>alert(1)</script>" -o /dev/null
curl -s http://127.0.0.1:5000/board/4
# 기대 출력
<h2>test</h2><p>작성자: admin</p><hr><div><script>alert(1)</script></div>

저장한 스크립트 태그가 이스케이프 없이 응답에 그대로 들어 있습니다. 이 글에서는 응답 내용까지만 확인했습니다. 브라우저는 이 응답을 HTML로 해석하므로 http://127.0.0.1:5000/board/4를 열어 경고창이 뜨는지 직접 확인해 볼 수 있습니다. 코드가 글을 올린 사람이 아니라 읽는 사람의 브라우저에서 실행되는 구조라서, 게시판과 댓글처럼 여러 사람이 보는 화면에서 저장형 XSS가 특히 위험합니다.

5. 한 줄씩 고치고 다시 공격해 보기

app.py의 세 지점을 각각 고칩니다. 로그인은 문자열 조립 대신 물음표 자리표시자로 값과 질의를 분리합니다. query = ... 줄은 지우고 conn.execute(query) 부분을 아래처럼 바꿉니다.

conn = db()
try:
    row = conn.execute(
        "SELECT * FROM users WHERE username = ? AND password = ?",
        (username, password)).fetchone()
except sqlite3.Error as e:
    return "오류: %s" % e, 500
finally:
    conn.close()

다운로드는 실제 경로를 구한 뒤 그 경로가 업로드 폴더 안에 있는지 확인합니다.

real = os.path.realpath(os.path.join(UPLOAD_DIR, filename))
if not real.startswith(os.path.realpath(UPLOAD_DIR) + os.sep):
    return "허용되지 않는 경로입니다.", 403
if not os.path.exists(real):
    return "파일이 없습니다.", 404
return send_file(real)

글 상세 화면은 문자열로 HTML을 만들지 않고, 템플릿에 값을 데이터로 넘겨 자동 이스케이프에 맡깁니다. 파일 위쪽에 템플릿 문자열을 하나 추가하고, 상세 함수의 html = ...과 return 두 문장을 한 줄로 바꿉니다.

DETAIL_HTML = """<h2>{{ post.title }}</h2><p>작성자: {{ post.author }}</p><hr><div>{{ post.body }}</div>"""

return render_template_string(DETAIL_HTML, post=post)

서비스를 Ctrl+C로 끄고 python init_db.py로 데이터베이스를 다시 만든 뒤 python app.py로 다시 띄웁니다. 2~4단계의 공격을 그대로 다시 실행합니다. 로그인 우회가 막혔으므로 4단계의 글 등록은 정상 계정으로 로그인해서 진행합니다.

curl -s -X POST http://127.0.0.1:5000/login \
  --data-urlencode "username=admin' -- " --data-urlencode "password=x" -i | grep -E "^HTTP"
curl -s "http://127.0.0.1:5000/download?file=../app.db" -i | head -1
curl -s -c cookie.txt -X POST http://127.0.0.1:5000/login \
  --data-urlencode "username=admin" --data-urlencode "password=s3cr3t-admin" -o /dev/null
curl -s -b cookie.txt -X POST http://127.0.0.1:5000/board \
  --data-urlencode "title=test" --data-urlencode "body=<script>alert(1)</script>" -o /dev/null
curl -s http://127.0.0.1:5000/board/4 | grep script
# 기대 출력
HTTP/1.1 200 OK
HTTP/1.1 403 FORBIDDEN
<h2>test</h2><p>작성자: admin</p><hr><div>&lt;script&gt;alert(1)&lt;/script&gt;</div>

로그인 우회는 200(로그인 실패 화면)으로 돌아가고, 경로 조작은 403으로 막히고, 스크립트 태그는 글자 그대로 화면에 찍힙니다. 정상 아이디·비밀번호로 로그인하면 여전히 302로 넘어갑니다. 질의는 값과 분리하고, 경로는 실제 위치를 확인하고, 화면에 보여 줄 글자는 글자로만 다루는 것이 세 수정의 공통점입니다.

비밀번호는 여전히 평문으로 저장되어 있습니다. 수정 뒤에도 app.db가 다른 경로로 유출되면 3단계의 check_stolen.py로 비밀번호가 그대로 읽힙니다. 솔트와 해시 저장은 이 글에서 고치지 않았으며 다음 과제입니다.

자주 막히는 곳

  • python app.py가 Address already in use로 끝나는 경우: 5000번 포트를 다른 프로그램이 쓰고 있습니다. app.py 마지막 줄의 port=5000과 명령의 주소를 같은 다른 번호로 바꿉니다.
  • 로그인 우회 명령을 넣었는데 302가 아니라 200(로그인 실패 화면)이 나오는 경우: -- 뒤에 공백이 없는 경우가 가장 흔합니다. SQL 주석은 -- 처럼 대시 두 개 다음에 공백이 있어야 뒤쪽 조건이 통째로 주석 처리됩니다.
  • 데이터베이스 파일을 내려받았는데 sqlite3.OperationalError: unable to open database file이 뜨는 경우: curl -o stolen.db로 받은 파일 경로와 check_stolen.py가 여는 경로가 다른 폴더를 가리키고 있는 경우가 많습니다. 같은 폴더에서 두 명령을 실행합니다.

이어서 해 볼 것

같은 서비스에 명령어를 셸에 그대로 넘기는 지점(예: ping 점검 도구)이나 파일 업로드 지점을 하나 더 추가해 보면, 이번 글에서 고친 것과 다른 종류의 검증(허용 확장자 목록, 셸을 거치지 않고 인자를 배열로 넘기기)이 필요하다는 것을 바로 체감할 수 있습니다. 로그인에 세션 재발급(session.clear() 후 session["user"] = ...)을 추가해, 로그인 성공 시점에 세션 값이 실제로 바뀌는지 쿠키 값을 비교해 확인해 볼 수도 있습니다. 비밀번호 저장에 솔트와 해시를 적용하면, 같은 stolen.db를 다시 꺼내도 비밀번호 원문이 보이지 않는 것까지 확인할 수 있습니다.

시큐어코딩 (Python, Java) 과정의 일부입니다

이 실습은 교육에서 참가자마다 독립된 환경으로 진행합니다. 팀 교육으로 구성이 필요하면 문의해 주세요.

과정 보기교육 문의