Step 131. 웹 서버 직접 만들기 — 로그인과 세션

Step 131. 웹 서버 직접 만들기 — 로그인과 세션

Level 2 — 보안 입문과 공격 스킬 기초 | 난이도 ★★★☆☆ | 예상 소요 시간 3시간

전제: Step 94(Flask 맛보기), Step 92(sqlite3), Step 73(HTTP)을 마쳤다.

  • 준비물: 파이썬과 Flask, curl. 이 챕터의 실측은 Flask 3.1.3, 파이썬 3.12, 127.0.0.1(localhost)에서 수행했습니다.
  • 주의: 오늘 만드는 서버는 이 책의 웹 공격 실습 전체에서 표적으로 계속 쓰입니다. 코드를 깃허브 등 외부에 공개하지 말고, 내 컴퓨터 안에서만 띄우세요.
  • ⚠️ 이 챕터의 실습은 내 랩·합법 플랫폼 전용입니다. 허가 없는 시스템에 적용하면 범죄입니다.

HTTP는 상태가 없습니다 — 서버는 매 요청을 처음 만난 사람처럼 대합니다. 그런데 로그인은 왜 유지될까요? 서버가 로그인 순간에 세션 ID를 쿠키로 쥐여 주고, 이후 요청마다 그것을 검사하기 때문입니다. 공격하려면 먼저 만들 줄 알아야 합니다. 오늘 여러분은 로그인·세션·로그아웃이 작동하는 웹앱을 직접 짜고, 세션 쿠키가 생기고 검증되는 전 과정을 안쪽에서 봅니다.


1. 학습 목표

이 챕터를 끝내면 다음을 할 수 있습니다:

  • sqlite3 DB에 계정을 만들고 로그인 검증을 구현할 수 있다
  • Flask sessionsecret_key로 로그인 상태를 유지할 수 있다
  • 보호가 필요한 페이지에 "로그인 검사"를 붙일 수 있다
  • 세션 쿠키의 구조(base64 내용 + 서명)를 직접 해독하고 설명할 수 있다
  • 쿠키 변조가 서명 덕분에 거절되는 것을 시연할 수 있다

2. 배경 지식 — 오늘의 도구와 개념

오늘의 도구 한눈에 보기

구분 내용
언어·환경 파이썬 3 + Flask 3.x + sqlite3, localhost 개발 서버, curl
오늘의 코드 session["user"] = uid, app.secret_key, redirect, "user" not in session, session.clear()
필요한 개념 무상태(stateless) HTTP, 쿠키, 세션, 서명(signature), 302 리다이렉트
오늘의 산출물 DB 연동 로그인 웹앱 1개 + 세션 쿠키 관찰 기록

2-1. 무상태 HTTP와 쿠키 — "또 누구세요?"의 해결

Step 73에서 배운 대로 HTTP 요청은 각각 독립입니다. 로그인에 성공해도, 다음 요청에서 서버는 여러분을 모릅니다. 이 무상태(stateless) 성질을 보완하는 장치가 쿠키(cookie)입니다.

흐름은 이렇습니다. ① 로그인 성공 → ② 서버가 응답에 Set-Cookie 헤더로 표를 찍어 줌 → ③ 브라우저는 이후 요청마다 그 표를 Cookie 헤더에 자동 첨부 → ④ 서버는 표를 보고 "아, 아까 그 사람"이라고 압니다. 오늘 만드는 것이 정확히 이 네 박자입니다.

2-2. 세션 — 서버가 기억하는 로그인 상태

세션(session)은 "로그인된 상태"를 서버가 관리하는 방식입니다. Flask의 session은 특이한 구현을 씁니다 — 세션 내용을 서버 디스크가 아니라 쿠키 안에 담아 브라우저에 맡깁니다.

그럼 브라우저가 내용을 바꿔치기하면 어떻게 될까요? 그래서 서명이 있습니다. 서버는 secret_key로 쿠키 내용에 도장을 찍고, 돌아온 쿠키의 도장을 검사합니다. 내용은 보이지만 고치면 도장이 안 맞아 폐기됩니다. 이 사실 — "세션 쿠키는 읽을 수 있지만 위조는 안 된다" — 이 세션 공격 전체의 출발점입니다.

2-3. 302 리다이렉트 — "저리 가, 로그인부터"

보호된 페이지를 로그인 없이 열면 서버는 내용 대신 302 Found + Location: /login 헤더를 돌려줍니다. "여기 말고 저리로 가라"는 지시이고, 브라우저는 그 주소로 자동 이동합니다.

상태 코드 3개를 오늘 구분해 봅니다 — 200(정상 응답), 302(다른 주소로 이동 지시), 401(인증 실패). 웹 공격을 배우면 이 숫자들이 서버의 대답 어조로 들리기 시작합니다.


3. 따라 하기

3-1. 계정 DB 만들기

Step 92의 sqlite3로 계정 표를 만듭니다. 같은 폴더에 init_db.py:

import sqlite3

conn = sqlite3.connect("users.db")
conn.execute("CREATE TABLE IF NOT EXISTS users (username TEXT, password TEXT)")
conn.execute("DELETE FROM users")
conn.executemany("INSERT INTO users VALUES (?, ?)",
                 [("nadia", "blue-fox-31"), ("guest", "guest")])
conn.commit()
conn.close()
print("users.db 준비 완료")

출력 (2026-09-09 실측, python init_db.py):

users.db 준비 완료

읽는 법: 물음표 바인딩(?)은 Step 93에서 배운 SQL 인젝션 방어입니다. 지금은 평문 비밀번호를 저장하지만 — 실제 서비스라면 해시로 저장해야 합니다. 이 허점은 뒤에서 공격 재료가 되니, 지금은 "일부러 단순하게"라는 점만 기억하세요.

3-2. 로그인 + 세션 서버 전체 코드

app.py를 만듭니다. 오늘의 모든 실습이 이 파일 하나에서 일어납니다.

from flask import Flask, request, redirect, session
import sqlite3

app = Flask(__name__)
app.secret_key = "lab-secret-key-1234"   # 세션 서명용 — 실습용 값. 실제로는 길고 무작위로

def check_user(uid, pw):
    conn = sqlite3.connect("users.db")
    cur = conn.execute("SELECT * FROM users WHERE username=? AND password=?", (uid, pw))
    row = cur.fetchone()
    conn.close()
    return row is not None

LOGIN_FORM = '''
<h1>로그인</h1>
<form method="post" action="/login">
  <input name="uid" placeholder="아이디">
  <input name="pw" type="password" placeholder="비밀번호">
  <button>로그인</button>
</form>
'''

@app.route("/")
def home():
    return '<h1>Step 131 랩</h1><a href="/login">로그인</a> | <a href="/dashboard">대시보드</a>'

@app.route("/login", methods=["GET", "POST"])
def login():
    if request.method == "POST":
        uid = request.form.get("uid", "")
        pw = request.form.get("pw", "")
        if check_user(uid, pw):
            session["user"] = uid
            return redirect("/dashboard")
        return "로그인 실패: 아이디 또는 비밀번호가 다릅니다.", 401
    return LOGIN_FORM

@app.route("/dashboard")
def dashboard():
    if "user" not in session:
        return redirect("/login")
    return f'<h1>{session["user"]}님의 대시보드</h1>비밀 파일 목록... <a href="/logout">로그아웃</a>'

@app.route("/logout")
def logout():
    session.clear()
    return '로그아웃했습니다. <a href="/">처음으로</a>'

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

뼈대는 Step 94와 같고 새 것은 네 개뿐입니다 — session(로그인 상태 주머니), secret_key(서명 도장), redirect(302 보내기), 그리고 DB 확인 함수. 실행해 둡니다 — 출력 (2026-09-09 실측):

 * Serving Flask app 'app'
 * Debug mode: off
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
 * Running on http://127.0.0.1:5000
Press CTRL+C to quit

3-3. 로그인 없이 대시보드로 — 문지방에서 막히는가

서버가 떠 있는 상태에서, 새 터미널을 열고 curl로 확인합니다 (브라우저로 해도 됩니다 — 주소창에 /dashboard를 치면 로그인으로 튕길 겁니다).

입력

curl -i http://127.0.0.1:5000/dashboard

출력 (2026-09-09 실측):

HTTP/1.1 302 FOUND
Server: Werkzeug/3.1.8 Python/3.12.14
Content-Type: text/html; charset=utf-8
Content-Length: 199
Location: /login
Vary: Cookie

읽는 법: 로그인 안 했으니 302와 Location: /login — "로그인부터 하고 오라"는 리다이렉트입니다. "user" not in session 한 줄이 문지방 역할을 했습니다.

3-4. 틀린 로그인과 올바른 로그인 — 서버의 두 얼굴

입력 ① 틀린 비밀번호

curl -i -X POST -d "uid=nadia&pw=wrongpass" http://127.0.0.1:5000/login

출력 (2026-09-09 실측): HTTP/1.1 401 UNAUTHORIZED — 본문은 "로그인 실패: 아이디 또는 비밀번호가 다릅니다." (64바이트).

입력 ② 올바른 계정 — 쿠키를 파일에 저장

curl -i -c jar.txt -X POST -d "uid=nadia&pw=blue-fox-31" http://127.0.0.1:5000/login

출력 (2026-09-09 실측):

HTTP/1.1 302 FOUND
Location: /dashboard
Set-Cookie: session=eyJ1c2VyIjoibmFkaWEifQ.aqEDqQ.P-l1eqkS217ha8ndH5jXuIcezv8; HttpOnly; Path=/

읽는 법: 오늘의 주인공은 Set-Cookie 줄입니다. 로그인 성공의 대가로 서버가 세션 쿠키를 발급했습니다. 값을 점(.)으로 나누면 세 조각 — 앞(내용, base64) / 중간(발급 시각) / 뒤(서명). -c jar.txt는 받은 쿠키를 파일에 저장한 것이고, -b jar.txt는 요청에 쿠키를 실어 보냅니다.

3-5. 쿠키로 대시보드 들어가기 — 표를 보여 주면 열린다

입력

curl -i -b jar.txt http://127.0.0.1:5000/dashboard

출력 (2026-09-09 실측):

HTTP/1.1 200 OK
Vary: Cookie

<h1>nadia님의 대시보드</h1>비밀 파일 목록... <a href="/logout">로그아웃</a>

읽는 법: 3-3에서는 302로 쫓겨났던 주소가, 쿠키 하나 붙이니 200과 개인화된 본문("nadia님의")을 돌려줍니다. 쿠키가 곧 신분증 — 이 한 문장이 쿠키 탈취 공격의 동기 전부입니다.

3-6. 세션 쿠키 해부 — 내용은 보인다

3-4에서 받은 쿠키의 첫 조각을 해독해 봅니다.

입력

echo "eyJ1c2VyIjoibmFkaWEifQ" | python -c "import sys,base64; s=sys.stdin.read().strip(); s+='='*(-len(s)%4); print(base64.urlsafe_b64decode(s).decode())"

출력 (2026-09-09 실측):

{"user":"nadia"}

읽는 법: 충격적인 사실 — 세션 내용이 쿠키에 그대로 들어 있습니다. base64는 암호화가 아니라 글자 옮겨적기라 누구나 되돌릴 수 있습니다. Flask 세션은 "서버가 기억"하는 것이 아니라 "브라우저가 들고 다니는" 방식인 것입니다.

3-7. 그럼 위조는? — 서명이 막는다

내용이 보이면 고쳐도 되나요? nadiaadmin으로 바꾼 가짜 쿠키를 만들어 봅시다 — 뒤의 서명 조각은 그대로 둡니다.

입력

FORGED=$(python -c "import base64; print(base64.urlsafe_b64encode(b'{\"user\":\"admin\"}').decode().rstrip('='))")
curl -i -H "Cookie: session=$FORGED.aqEDqQ.P-l1eqkS217ha8ndH5jXuIcezv8" http://127.0.0.1:5000/dashboard

출력 (2026-09-09 실측):

HTTP/1.1 302 FOUND
Location: /login

읽는 법: 로그인 안 한 사람 취급 — 로그인 페이지로 쫓겨났습니다. 내용을 고쳤는데 서명(뒷조각)이 옛 내용 기준이라 도장 검사에서 탈락한 것입니다. 새 서명을 만들려면 secret_key가 필요하고, 그 키는 서버 안에만 있습니다. "보인다"와 "고칠 수 있다"는 다릅니다 — 서명이 그 사이를 지킵니다.

3-8. 로그아웃과 서버 로그 — 흔적 읽기

입력

curl -i -b jar.txt -c jar.txt http://127.0.0.1:5000/logout

출력 (2026-09-09 실측):

HTTP/1.1 200 OK
Set-Cookie: session=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0; HttpOnly; Path=/

읽는 법: 로그아웃의 구현은 "빈 값 + 과거 만료일로 쿠키를 덮어쓰기"입니다. 이제 이 쿠키로 /dashboard에 가면 다시 302로 쫓겨납니다 (2026-09-09 실측).

마지막으로, 이 모든 요청 동안 서버 콘솔에 남은 로그 (2026-09-09 실측):

127.0.0.1 - - [09/Sep/2026 15:59:22] "POST /login HTTP/1.1" 302 -
127.0.0.1 - - [09/Sep/2026 15:59:22] "GET /dashboard HTTP/1.1" 200 -
127.0.0.1 - - [09/Sep/2026 15:59:22] "GET /dashboard HTTP/1.1" 302 -

시간 순으로 읽으면 이야기가 보입니다 — 로그인 성공(302), 대시보드 입장(200), 그리고 위조 쿠키의 추방(302). 웹 공격 챕터에서 여러분이 만들 요청들은 전부 이 로그에 이렇게 남습니다. 공격자의 행동이 서버에는 기록으로 쌓인다는 감각, 꼭 챙기세요.


4. 미션과 연습문제

미션 — 내 로그인 웹앱 완성하기

  1. 오늘의 app.py를 직접 타이핑해 완성하고, 계정 2개를 넣은 users.db를 만듭니다.
  2. 올바른 로그인 → 대시보드 진입, 틀린 로그인 → 401, 로그아웃 → 대시보드 차단의 세 장면을 각각 curl 출력으로 확보합니다.
  3. 발급된 세션 쿠키를 base64 디코딩해 내용을 확인한 출력을 남깁니다.
  4. 쿠키 한 글자를 바꾼 위조 시도가 302로 거절되는 출력을 남깁니다.
  5. 위키에 세션과쿠키.md를 작성합니다 — ① 무상태 HTTP 문제 ② 쿠키 발급~검증 흐름 ③ 서명이 막는 것과 못 막는 것, 각 3줄.
  6. (도전) /whoami 라우트를 추가해 "로그인 중이면 아이디, 아니면 ‘손님’"을 돌려주게 해 보세요.

연습문제

문제 1. HTTP가 무상태(stateless)라는 뜻과, 쿠키가 이 문제를 어떻게 해결하는지 설명해 보세요.

문제 2. Flask 세션 쿠키의 내용을 누구나 디코딩해 볼 수 있는데도 안전한 이유는 무엇인가요? "안전하다"의 범위(무엇은 막고 무엇은 못 막는지)까지 답해 보세요.

문제 3. 보호된 페이지를 로그인 없이 열 때 서버가 401이 아니라 302+Location: /login을 돌려주는 설계의 장점은 무엇인가요?

문제 4. 3-8의 서버 로그 세 줄을 읽고, 각 줄이 사용자의 어떤 행동에 대응하는지 설명해 보세요.


5. 모범 답안과 완료 기준

미션 모범 답안

핵심 검증 포인트:

  1. 로그인 성공 경로 (2026-09-09 실측과 같은 형태여야 함): POST /login → 302 + Set-Cookie: session=...; HttpOnly; Path=/ → 쿠키 첨부 GET /dashboard → 200.
  2. 실패 경로: 틀린 비밀번호에 401과 실패 메시지. 여기서 "아이디가 틀렸습니다"와 "비밀번호가 틀렸습니다"를 구분해 알려 주면 안 됩니다 — 공격자에게 계정 존재 여부를 알려 주는 것이 되니까요. 모범 답안의 "아이디 또는 비밀번호가 다릅니다"처럼 하나로 뭉뚱그리세요.
  3. 위조 거절: 변조 쿠키는 조용히 로그인 페이지로 리다이렉트됩니다 — 오류를 띄우지 않는 것이 Flask의 정상 동작입니다.
  4. 도전 과제 답안:
@app.route("/whoami")
def whoami():
    return f"현재 사용자: {session.get('user', '손님')}"

연습문제 해답

문제 1 해답. 무상태란 서버가 각 요청을 독립적으로 처리해 이전 요청(로그인 성공)을 기억하지 못한다는 뜻입니다. 쿠키는 로그인 성공 시 서버가 발급한 표(Set-Cookie)를 브라우저가 이후 요청마다 자동 첨부(Cookie)하게 해, "매 요청이 신분증 제시를 동반"하게 만듦으로써 상태를 흉내 냅니다.

문제 2 해답. 내용은 보이지만 위조가 불가능하기 때문입니다. 서버는 secret_key로 쿠키 내용에 서명하고, 돌아온 쿠키의 서명을 검증해 내용이 바뀌었으면 폐기합니다 (3-7 실측: admin으로 바꾼 쿠키는 로그인 페이지로 추방). 단, 이 방식은 변조만 막습니다 — 쿠키를 통째로 훔쳐 그대로 쓰는 탈취는 서명 검증을 통과하므로 못 막습니다. 그 방어(만료, HttpOnly 등)는 뒤의 쿠키 공격 챕터 주제입니다.

문제 3 해답. 302 리다이렉트는 사용자를 로그인 화면으로 안내합니다 — 브라우저가 자동 이동하므로 이용자는 "로그인이 필요한 페이지"로 자연스럽게 흘러갑니다. 401은 "거절"의 코드라 사용자에게 다음 행동을 알려 주지 않습니다. 실무에서 보호 페이지의 미인증 접근은 보통 302(로그인으로)를 쓰고, 401은 API 등 "인증 자체가 실패"를 알릴 때 씁니다 — 우리의 틀린 로그인이 401인 이유입니다.

문제 4 해답. 첫 줄(POST /login 302)은 로그인 성공 — 302로 대시보드에 보냈다는 뜻입니다. 둘째 줄(GET /dashboard 200)은 발급된 쿠키로 대시보드에 정상 입장. 셋째 줄(GET /dashboard 302)은 쿠키 없이(또는 위조 쿠키로) 접근해 로그인으로 쫓겨난 요청입니다. 같은 주소라도 상태 코드가 곧 판정 결과입니다.

완료 기준 체크리스트

  • [ ] sqlite3 계정 DB를 만들고 로그인 검증에 연동할 수 있다
  • [ ] session["user"]로 로그인 상태를 기록하고 검사할 수 있다
  • [ ] secret_key와 서명의 역할을 설명할 수 있다
  • [ ] 보호 페이지의 302 리다이렉트를 구현하고 curl로 확인할 수 있다
  • [ ] 세션 쿠키를 base64 디코딩해 내용을 확인했다
  • [ ] 변조 쿠키가 거절되는 것을 시연했다
  • [ ] 미션: 세 장면의 출력과 세션과쿠키.md를 완성했다

6. 흔한 실수와 해결

벽 1. session을 쓰자마자 RuntimeError가 나요

증상 (실제 오류 형태):

RuntimeError: The session is unavailable because no secret key was set.

원인: app.secret_key를 설정하지 않았습니다. 세션은 서명이 필요하고, 서명에는 키가 필요합니다.

해결: 라우트 정의 전에 app.secret_key = "아무 문자열" 한 줄을 넣으세요. 실습용은 아무 값이나 되지만, 실제 서비스라면 길고 무작위인 값을 환경 변수로 관리합니다.

벽 2. 로그인에 성공해도 대시보드가 계속 로그인으로 튕겨요

증상: POST /login은 302인데 /dashboard가 다시 /login으로 보냅니다.

원인: curl이라면 쿠키를 저장(-c)만 하고 요청에 붙이지(-b) 않았거나, 그 반대입니다. 브라우저라면 쿠키 차단 설정을 의심합니다.

해결: curl은 -c jar.txt(받기)와 -b jar.txt(보내기)가 세트입니다 — 3-4와 3-5를 다시 보세요.

벽 3. 코드를 고쳤는데 쿠키 값이 예전 그대로예요

증상: 서버 코드를 바꿨는데 발급되는 쿠키가 변하지 않습니다.

원인: 브라우저/curl이 예전 쿠키를 계속 들고 있습니다. 서버가 새로 발급한 게 아니라 클라이언트가 옛것을 제시하는 중입니다.

해결: jar.txt를 지우고 로그인부터 다시 하세요. 브라우저는 개발자 도구 → Application → Cookies에서 삭제하면 됩니다.

벽 4. sqlite3.OperationalError: no such table: users가 나요

증상 (실제 오류 메시지 그대로):

sqlite3.OperationalError: no such table: users

원인: init_db.py를 안 실행했거나, 다른 폴더에서 서버를 띄워 빈 users.db가 새로 생겼습니다. sqlite3는 파일이 없으면 조용히 새로 만듭니다.

해결: app.py와 같은 폴더에서 python init_db.py 후 서버를 띄우세요. 폴더에 users.db 두 개가 생기지 않았는지 확인 — sqlite 실습의 고전입니다.

벽 5. 로그인 폼 전송 시 405 Method Not Allowed가 나와요

증상 (Step 94와 동일 실측): 폼 전송 시 405 METHOD NOT ALLOWED.

원인: 라우트에 methods=["GET", "POST"]를 빠뜨렸거나, 폼 태그에 method="post"가 없습니다.

해결: 데코레이터와 폼 태그 둘 다 확인하세요 — 이 둘은 세트입니다 (Step 94 벽 3 복습).


7. 정리

오늘의 개념

개념 한 줄 설명
무상태(stateless) HTTP 요청은 서로를 기억하지 못한다
쿠키 서버가 찍어 주고 브라우저가 들고 다니는 표
세션 로그인 상태 — Flask는 쿠키에 내용을 담는 방식
서명(signature) secret_key로 찍는 도장 — 변조를 감지
302 리다이렉트 "저 주소로 가라" — 보호 페이지의 문지방
HttpOnly JS가 쿠키를 못 읽게 하는 속성 (오늘 발급 쿠키에 자동 부착)

오늘의 코드·명령

코드/명령 하는 일
app.secret_key = "..." 세션 서명용 비밀키 설정
session["user"] = uid 로그인 상태 기록 (쿠키 발급으로 이어짐)
"user" not in session 로그인 검사 (문지방)
session.clear() 로그아웃 (세션 비우기)
redirect("/login") 302 + Location 응답
curl -c jar.txt / curl -b jar.txt 쿠키 저장 / 쿠키 첨부
base64.urlsafe_b64decode(...) 세션 쿠키 내용 해독

명령어보다 중요한 감각

오늘 여러분은 "로그인이 유지된다"는 일상의 뒷면을 봤습니다 — 매 요청마다 쿠키라는 신분증이 오가고, 서버는 도장을 검사합니다. 쿠키가 곧 신분증이라는 이 감각 하나로, 쿠키를 훔치고(XSS) 변조하고(서명 깨기) 심어 주는(세션 고정) 뒤 챕터들의 공격이 전부 "왜 그게 먹히는가"로 읽히기 시작합니다.

그리고 잊지 마세요 — 여러분이 오늘 만든 이 서버는 지금부터 이 책의 표적입니다. 공격을 배울수록 이 코드가 얼마나 순진했는지 보일 것이고, 그때 고치는 법도 배웁니다. 만들어 본 사람만이 무너지는 순간을 정확히 압니다.


전부 체크되면 Step 131 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.