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
session과secret_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. 그럼 위조는? — 서명이 막는다
내용이 보이면 고쳐도 되나요? nadia를 admin으로 바꾼 가짜 쿠키를 만들어 봅시다 — 뒤의 서명 조각은 그대로 둡니다.
입력
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. 미션과 연습문제
미션 — 내 로그인 웹앱 완성하기
- 오늘의
app.py를 직접 타이핑해 완성하고, 계정 2개를 넣은users.db를 만듭니다. - 올바른 로그인 → 대시보드 진입, 틀린 로그인 → 401, 로그아웃 → 대시보드 차단의 세 장면을 각각 curl 출력으로 확보합니다.
- 발급된 세션 쿠키를 base64 디코딩해 내용을 확인한 출력을 남깁니다.
- 쿠키 한 글자를 바꾼 위조 시도가 302로 거절되는 출력을 남깁니다.
- 위키에
세션과쿠키.md를 작성합니다 — ① 무상태 HTTP 문제 ② 쿠키 발급~검증 흐름 ③ 서명이 막는 것과 못 막는 것, 각 3줄. - (도전)
/whoami라우트를 추가해 "로그인 중이면 아이디, 아니면 ‘손님’"을 돌려주게 해 보세요.
연습문제
문제 1. HTTP가 무상태(stateless)라는 뜻과, 쿠키가 이 문제를 어떻게 해결하는지 설명해 보세요.
문제 2. Flask 세션 쿠키의 내용을 누구나 디코딩해 볼 수 있는데도 안전한 이유는 무엇인가요? "안전하다"의 범위(무엇은 막고 무엇은 못 막는지)까지 답해 보세요.
문제 3. 보호된 페이지를 로그인 없이 열 때 서버가 401이 아니라 302+Location: /login을 돌려주는 설계의 장점은 무엇인가요?
문제 4. 3-8의 서버 로그 세 줄을 읽고, 각 줄이 사용자의 어떤 행동에 대응하는지 설명해 보세요.
5. 모범 답안과 완료 기준
미션 모범 답안
핵심 검증 포인트:
- 로그인 성공 경로 (2026-09-09 실측과 같은 형태여야 함):
POST /login→ 302 +Set-Cookie: session=...; HttpOnly; Path=/→ 쿠키 첨부GET /dashboard→ 200. - 실패 경로: 틀린 비밀번호에 401과 실패 메시지. 여기서 "아이디가 틀렸습니다"와 "비밀번호가 틀렸습니다"를 구분해 알려 주면 안 됩니다 — 공격자에게 계정 존재 여부를 알려 주는 것이 되니까요. 모범 답안의 "아이디 또는 비밀번호가 다릅니다"처럼 하나로 뭉뚱그리세요.
- 위조 거절: 변조 쿠키는 조용히 로그인 페이지로 리다이렉트됩니다 — 오류를 띄우지 않는 것이 Flask의 정상 동작입니다.
- 도전 과제 답안:
@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 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.