CTF · 워게임
Step 176. CTF 맛보기 1: Web 문제 5개 — 실전 경기장에 입장하다
Level 3 · 난이도 ★★★☆☆ · 학습 계획 약 5시간, 개인별 차이 있음
전제: Level 2의 HTTP·쿠키·정보 노출·접근 제어. 준비물: Python3, Flask3.1.3, curl과 브라우저. 이 수업은 자신의 컴퓨터에 띄운 미니 CTF로 완주할 수 있습니다.
다섯 문제를 관찰하고 가설을 세운 뒤 플래그를 제출합니다. 원리를 이해했는지는 취약한 결과와 수정한 결과를 비교해 확인합니다. 다운로드한 앱은 의도적으로 취약한 개인 실습용이며 127.0.0.1에만 열립니다. 공개 서버나 실제 개인정보를 넣은 서비스로 사용하지 마세요.
1. 학습 목표와 Level 3 경로
- HTML 본문·주석·응답 헤더·쿠키·쿼리 매개변수를 구분한다.
- 가설→요청→관찰→플래그 제출을 다섯 문제에 적용한다.
- 플래그 형식과 정답 검증을 구별한다.
- 서명된 쿠키·서버 측 세션·객체별 인가를 구별한다.
- 독립 해결·힌트 사용·해설 후 재현을 기록하고 다음 학습 행동을 정한다.
Level 3는 Step176~303의 128차시입니다. 176~180에서 Web·Pwn·Reversing·Crypto·Forensics/Misc를 맛보고, 181에서 주력 분야를 고릅니다. 182~189는 어셈블리·첫 메모리 공격, 190은 팀과 대회 일정으로 이어집니다. 아래 표는 안내용 묶음이며 실제 순서는 차시 번호를 따릅니다.
| 범위 | 주제 |
|---|---|
| 176~181, 190 | 분야 입문·주력 선정·팀 구성 |
| 182~189 | 어셈블리·gdb·첫 메모리 공격 |
| 191~202 | 웹 해킹 고급 |
| 203~214 | Pwn 심화 |
| 215~226 | 리버싱 심화 |
| 227~238 | Crypto |
| 239~250 | Forensics |
| 251~272 | 실전 머신·플레이북 |
| 273~278 | Hard 도전·속도 훈련 |
| 279~303 | 대회 참가·복기·Level 3 평가 |
2. CTF와 관찰 방법
제퍼디 방식 CTF는 분야별 문제를 골라 정답 문자열을 제출하는 형식입니다. 규칙·힌트·소스 제공 여부는 대회마다 다릅니다. ‘CTF에는 힌트가 없다’고 일반화하지 않습니다. 이 실습의 플래그 형식은 FLAG{...}이며, 같은 형식만 맞춘 문자열은 오답입니다. 문제 번호와 전체 값이 일치해야 정답 처리됩니다.
먼저 서비스에서 보이는 기능을 목록으로 만드세요. 각 기능에서 ‘어떤 입력을 믿고 어떤 자료를 돌려줄까’를 가설로 적고 요청을 하나씩 바꿉니다. 한 응답이 가설과 다르다고 곧바로 취약점이라고 확정하지 말고, 기대한 접근 정책과 실제 결과를 비교합니다. 여기서는 주석→공개 경로→헤더→쿠키→프로필 순서로 다섯 관찰을 연습합니다.
| 명령 | 정확한 의미 |
|---|---|
curl -sS URL |
GET 본문. 진행 표시를 숨기되 오류 메시지는 표시 |
curl -sS -i URL |
GET 응답의 헤더와 본문 |
curl -sS -I URL |
HEAD 요청. GET 결과에서 본문만 잘라내는 명령과 다름 |
curl -sS --cookie "admin=true" URL |
지정한 쿠키를 요청에 넣음 |
Windows PowerShell에서는 아래 curl을 curl.exe로 실행하면 Invoke-WebRequest 별칭과 혼동하지 않습니다. 이 예제의 Flask는 GET 경로에 HEAD도 제공하지만, 다른 서버가 두 메서드에서 동일하게 응답한다고 가정하지 마세요.
3. 로컬 대회장 열기
3-1. 파일 준비와 실행
실습 ZIP을 풀고 해당 폴더로 이동하세요. 서버·자동 재현기·세션 설명 실험·의존성 목록·두 언어 기록표와 안내·검증 결과가 있습니다. 한국어 안내 · 풀이 기록표. 처음에는 아래 해설과 소스의 플래그를 보지 않고 시도해 보세요.
# Linux/macOS
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt
.venv/bin/python step176_ctf.py --lang ko --port 8176
# Windows PowerShell
py -m venv .venv
.venv\Scripts\python.exe -m pip install -r requirements.txt
.venv\Scripts\python.exe step176_ctf.py --lang ko --port 8176
브라우저에서 http://127.0.0.1:8176/을 엽니다. 영어 실습 화면은 서버를 정상 종료한 뒤 --lang en으로 실행합니다. 8176이 사용 중이면 소유 프로세스를 먼저 확인하고 이 실습을 --port 8178 등 빈 포트로 옮기세요. 기존 다른 프로세스를 강제 종료하지 않습니다. 모든 후속 URL의 포트도 함께 바꿉니다.
이 앱은 Flask3.1.3으로 검증했습니다. 개발 서버·디버거 꺼짐·자동 재시작 꺼짐입니다. 관리자 권한은 필요하지 않습니다. 점수는 서버 프로세스 메모리에만 있으며 재시작하면 0/5로 돌아옵니다. 풀이 기록표는 별도 파일로 저장하세요.
3-2. 먼저 시도하고 제출하기
대회장의 robots.txt·공지사항·입장·내 프로필을 확인하고 다섯 가설을 적습니다. 플래그를 얻으면 페이지의 문제 번호와 플래그 입력란에 제출하세요. 틀린 값은 점수를 올리지 않고, 같은 정답을 다시 제출해도 중복 계산하지 않습니다. JSON 점수는 /score에서 볼 수 있습니다.
curl -sS --data-urlencode "challenge=1" --data-urlencode "flag=여기에_찾은_전체_플래그" http://127.0.0.1:8176/submit
curl -sS http://127.0.0.1:8176/score
서버에는 학습용 고정 플래그가 들어 있어 소스를 읽으면 정답을 볼 수 있습니다. 이것은 개인 재현 실습이며 경쟁용 채점 시스템이 아닙니다. 제출 성공과 독립 해결을 같은 것으로 기록하지 마세요.
해설과 완료 기준 · 펼치기/접기
4. 다섯 문제 해설
4-1. HTML 주석
curl -sS http://127.0.0.1:8176/
브라우저에 보이는 문장 외에 HTML 주석에 첫 플래그가 있습니다. FLAG{v1ew_s0urce_1s_th3_f1rst_t00l}을 문제1에 제출합니다. 화면에서 숨긴 정보도 클라이언트에 전달했다면 읽을 수 있다는 관찰입니다. 모든 웹 문제가 소스 검색만으로 풀린다는 뜻은 아닙니다.
4-2. robots.txt와 접근 통제
curl -sS http://127.0.0.1:8176/robots.txt
curl -sS -i http://127.0.0.1:8176/secret_backup
robots.txt에 Disallow: /secret_backup이 있고, 실제 요청은200과 FLAG{r0b0ts_txt_1s_n0t_a_d00r}를 반환합니다. robots.txt는 크롤러에 대한 규칙이며 요청 권한을 검사하는 장치가 아닙니다. 경로가 적혀 있다는 사실과 그 경로가 인증 없이 열린다는 사실을 구분해 기록합니다.
4-3. 헤더의 플래그
curl -sS -I http://127.0.0.1:8176/notice
curl -sS -i http://127.0.0.1:8176/notice
이 서버의 HEAD와 GET 모두 X-Flag: FLAG{r34d_th3_h34d3rs_c4r3fully}를 반환합니다. HEAD 본문은 비어 있고 GET 본문에는 플래그가 없습니다. 날짜·Server·Content-Length는 환경에 따라 달라지므로 외우지 않습니다. 여기서 X-Flag는 이 실습이 만든 헤더 이름이며, X-로 시작한다는 이유만으로 보안상 안전하거나 위험하다고 판정하지 않습니다.
4-4. 신뢰할 수 없는 관리자 쿠키
curl -sS -i http://127.0.0.1:8176/enter
curl -sS --cookie "admin=true" http://127.0.0.1:8176/enter
첫 응답은 Set-Cookie: admin=false를 포함합니다. 두 번째 요청의 조작한 쿠키만으로 FLAG{c00k13s_c4n_b3_c00k3d}가 나옵니다. 서버가 검증되지 않은 클라이언트 값을 권한 근거로 사용한 것이 원인입니다. 쿠키가 클라이언트에 있다는 사실 자체가 취약점은 아닙니다.
4-5. 객체별 권한 확인 누락
curl -sS "http://127.0.0.1:8176/profile?uid=2"
curl -sS "http://127.0.0.1:8176/profile?uid=1"
일반 프로필 대신 다른 사용자 번호를 요청하면 FLAG{1d0r_ch4ng3_th3_numb3r}가 노출됩니다. 이 앱의 취약 모드에는 실제 로그인도 없습니다. IDOR의 핵심은 번호가 쉬워서가 아니라, 요청자가 그 객체를 볼 권한이 있는지 검사하지 않는다는 점입니다. 번호를 긴 무작위 값으로 바꾸는 것만으로 인가 검사를 대신할 수 없습니다.
5. 수정 전후와 세션 원리
5-1. 같은 요청으로 차이 확인
실습 서버는 그대로 두고 두 번째 터미널에서 수정 비교 모드를8177에 엽니다. Windows는 Python 실행 경로만 앞 절처럼 바꿉니다.
.venv/bin/python step176_ctf.py --mode fixed --role user --port 8177
curl -sS -i http://127.0.0.1:8177/secret_backup
curl -sS -i --cookie "admin=true" http://127.0.0.1:8177/enter
curl -sS -i "http://127.0.0.1:8177/profile?uid=1"
curl -sS -i "http://127.0.0.1:8177/profile?uid=2"
실측에서 백업·조작 쿠키·다른 프로필은403, 본인 프로필2는200이었습니다. 주석의 플래그와 X-Flag도 제거됐지만 robots.txt 경로는 남겼습니다. 경로를 숨기지 않아도 접근 검사가 동작함을 볼 수 있습니다.
--role user/admin은 서버 시작 시 정하는 가상의 신원입니다. 로그인 기능이 아니며 브라우저 입력이나 쿠키가 그 값을 정하지 않습니다. 비교 서버를 종료하고 --role admin으로 다시 실행하면 관리자용 백업·입장·프로필1은200입니다. 실제 서비스에서는 인증으로 신원을 확인하고, 그 신원으로 매 요청의 역할·객체 권한을 검사해야 합니다. 이 비교 코드는 운영용 보안 완성품이 아닙니다.
5-2. 서명된 쿠키와 서버 측 세션
서버 측 세션은 보통 쿠키의 불투명한 식별자로 서버 저장소를 찾습니다. Flask의 기본 세션은 데이터가 담긴 서명 쿠키이며 같은 방식이 아닙니다. 서명은 변조 검증에 사용되고 암호화와 다릅니다. 민감한 내용을 쿠키에 넣고 서명이 있으니 숨겨졌다고 생각하면 안 됩니다. 서명 검증에 성공해도 객체별 권한 검사는 따로 필요합니다.
.venv/bin/python session_demo.py
이 실험은 네트워크를 열지 않는 Flask 테스트 클라이언트입니다. 서명 키 없이 쿠키의 데이터 부분을 해석하면 role=user와 note=readable을 볼 수 있습니다. 기존 서명을 그대로 두고 role만 admin으로 바꾸면 세션 내용이 거부돼 빈 세션이 됩니다. 이는 기본 세션의 저장 위치·가독성·변조 검증을 확인하는 실험이며 실제 로그인 보안 전체를 검증한 것은 아닙니다.
5-3. 전체 소스와 자동 재현
실습 서버 전체 코드 · HTTP 재현기 · 세션 실험. 참고 소스에는 정답이 있으므로 독립 시도 후 펼쳐 보세요.
실습 서버 전체 소스 펼치기 — 플래그 포함
"""Five intentionally weak local CTF challenges and a separate fixed comparison.
Never deploy this teaching app as a public service. The fixed identity is a
server-side lab fixture, NOT a production login implementation.
"""
import argparse
import threading
from flask import Flask, jsonify, make_response, request
from markupsafe import escape
FLAGS = {
1: "FLAG{v1ew_s0urce_1s_th3_f1rst_t00l}",
2: "FLAG{r0b0ts_txt_1s_n0t_a_d00r}",
3: "FLAG{r34d_th3_h34d3rs_c4r3fully}",
4: "FLAG{c00k13s_c4n_b3_c00k3d}",
5: "FLAG{1d0r_ch4ng3_th3_numb3r}",
}
def create_app(mode="challenge", role="user", language="ko"):
if mode not in {"challenge", "fixed"} or role not in {"user", "admin"} or language not in {"ko", "en"}:
raise ValueError("Unknown lab configuration")
app = Flask(__name__)
app.config.update(MAX_CONTENT_LENGTH=4096, TRUSTED_HOSTS=["127.0.0.1", "localhost"])
solved = set()
lock = threading.Lock()
korean = language == "ko"
def text(ko, en):
return ko if korean else en
def page(title, body):
return f'''<!doctype html><html lang="{language}"><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"><title>{escape(title)}</title>
<style>body{{font-family:system-ui,sans-serif;max-width:760px;margin:32px auto;padding:0 20px;line-height:1.7}}input,select,button{{font:inherit;padding:6px}}input{{max-width:90%}}code{{overflow-wrap:anywhere}}</style>
<h1>{escape(title)}</h1><p>{text('로컬 학습 실습','Local learning exercise')} · {text('취약 모드','Challenge mode') if mode=='challenge' else text('수정 비교 모드','Fixed comparison')}</p>{body}
<p><a href="/">{text('대회장으로','Back to arena')}</a></p></html>'''
@app.after_request
def no_cache(response):
response.headers['Cache-Control'] = 'no-store'
return response
@app.get('/')
def index():
with lock:
count = len(solved)
comment = f'<!-- First flag / 첫 플래그: {FLAGS[1]} -->' if mode == 'challenge' else ''
form = f'''<h2>{text('플래그 제출','Submit a flag')}</h2>
<form action="/submit" method="post"><label>{text('문제 번호','Challenge number')} <select name="challenge">{''.join(f'<option>{n}</option>' for n in FLAGS)}</select></label><br>
<label>{text('찾은 플래그','Found flag')} <input name="flag" required maxlength="150" autocomplete="off"></label><br>
<button type="submit">{text('제출','Submit')}</button></form>''' if mode == 'challenge' else ''
body = f'''<p>{text('다섯 플래그를 찾고 관찰 근거를 기록하세요.','Find five flags and record the evidence for each.')}</p>
<ul><li><a href="/robots.txt">robots.txt</a></li><li><a href="/notice">{text('공지사항','Notice')}</a></li>
<li><a href="/enter">{text('입장','Entrance')}</a></li><li><a href="/profile?uid=2">{text('내 프로필','My profile')}</a></li></ul>
<p>{text('정답 처리','Accepted')}: {count}/5 · <a href="/score">{text('점수 JSON','Score JSON')}</a></p>{form}{comment}'''
return page(text('미니 CTF 대회장','Mini CTF Arena'), body)
@app.get('/robots.txt')
def robots():
return 'User-agent: *\nDisallow: /secret_backup\n', 200, {'Content-Type':'text/plain; charset=utf-8'}
@app.get('/secret_backup')
def backup():
if mode == 'fixed' and role != 'admin':
return page(text('접근 거부','Access denied'), '<p>403</p>'), 403
return page(text('백업','Backup'), f'<p>{FLAGS[2]}</p>')
@app.get('/notice')
def notice():
response = make_response(page(text('공지사항','Notice'), '<p>'+text('이 응답을 관찰하세요.','Inspect this response.')+'</p>'))
if mode == 'challenge':
response.headers['X-Flag'] = FLAGS[3]
return response
@app.get('/enter')
def enter():
allowed = request.cookies.get('admin') == 'true' if mode == 'challenge' else role == 'admin'
if allowed:
return page(text('관리자 입장','Admin entrance'), f'<p>{FLAGS[4]}</p>')
response = make_response(page(text('일반 사용자','Regular user'), '<p>'+text('관리자만 이 플래그를 볼 수 있습니다.','Only admins can see this flag.')+'</p>'), 200 if mode=='challenge' else 403)
if mode == 'challenge':
response.set_cookie('admin', 'false', samesite='Lax')
return response
@app.get('/profile')
def profile():
uid = request.args.get('uid', '2')
if uid not in {'1','2'}:
return page(text('잘못된 사용자 번호','Invalid user ID'), '<p>'+text('사용자 번호 1 또는 2를 입력하세요.','Use user ID 1 or 2.')+'</p>'), 400
if mode == 'fixed' and role != 'admin' and uid != '2':
return page(text('접근 거부','Access denied'), '<p>403</p>'), 403
content = FLAGS[5] if uid=='1' else 'user2'
return page(text('프로필','Profile'), f'<p>uid={escape(uid)} · {content}</p>')
@app.get('/score')
def score():
with lock:
return jsonify(solved=sorted(solved), accepted=len(solved), total=5, persistent=False)
@app.post('/submit')
def submit():
if mode != 'challenge':
return page(text('비교 모드','Comparison mode'), '<p>'+text('수정 비교 모드에서는 제출하지 않습니다.','Submission is disabled in comparison mode.')+'</p>'), 403
number = request.form.get('challenge', '')
flag = request.form.get('flag', '')
if number not in {'1','2','3','4','5'}:
return page(text('잘못된 문제 번호','Invalid challenge number'), ''), 400
accepted = flag == FLAGS[int(number)]
with lock:
duplicate = int(number) in solved
if accepted:
solved.add(int(number))
count = len(solved)
result = text('정답','Correct') if accepted else text('오답: 문제 번호와 전체 문자열을 확인하세요.','Incorrect: check the challenge number and complete string.')
if accepted and duplicate:
result += text(' · 이미 정답 처리됨',' · Already accepted')
return page(text('제출 결과','Submission result'), f'<p>{result}</p><p>{count}/5</p>')
return app
def port_number(value):
port = int(value)
if not 1024 <= port <= 65535:
raise argparse.ArgumentTypeError('Choose a port from 1024 to 65535')
return port
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--mode',choices=['challenge','fixed'],default='challenge')
parser.add_argument('--role',choices=['user','admin'],default='user',help='Server-side fixture for fixed mode; not a login')
parser.add_argument('--lang',choices=['ko','en'],default='ko')
parser.add_argument('--port',type=port_number,default=8176)
args = parser.parse_args()
create_app(args.mode,args.role,args.lang).run(host='127.0.0.1',port=args.port,debug=False,use_reloader=False)
if __name__=='__main__':
main()
.venv/bin/python reproduce.py
재현기는 빈 루프백 포트에 자기 서버를 띄워 두 언어의 다섯 플래그를 HTTP로 얻고 제출합니다. 종료 시 자기 서버를 닫습니다. 수동 서버나 다른 서비스는 종료하지 않습니다. 2026-09-27에 Windows Python3.12.10와 외부 네트워크 없는 Linux Python3.14.7, Flask3.1.3에서 언어별13검사 범주가 통과했습니다. Windows에서는 실제 curl.exe의 HEAD·쿠키 명령도 확인했습니다. Linux 검증 컨테이너에는 curl이 없어 같은 HTTP 흐름을 Python으로 확인했으며 Linux curl 실행으로 기록하지 않습니다.
Windows 결과 · Linux 결과. 구성요소 버전과 소스 해시가 포함됩니다. 수정 비교의403뿐 아니라 본인 프로필·관리자 허용200, 오답·중복·잘못된 번호·새 서버의 점수 초기화도 검사했습니다. 외부 CTF 문제나 참가자의 실력을 검증한 결과는 아닙니다.
6. 기록표·미션·연습문제
다섯 행에 문제 번호, 최초 가설, 요청과 관찰, 취약점 원인, 독립/힌트/해설 후 재현, 실제 소요 시간, 수정 후 결과를 적습니다. 시간을 예시 숫자로 채우지 마세요. 해설을 본 문제는 다음날 해설 없이 재현해 기록을 추가합니다. 자가 평가에는 다음에 바꿀 행동 하나를 적습니다. 예: ‘본문만 확인했으므로 다음에는 GET 헤더도 저장한다.’
- FLAG{…}처럼 보이는 문자열을 찾으면 왜 제출까지 확인해야 하는가?
- robots.txt는 그대로인데 수정 모드의 백업이403인 이유는 무엇인가?
curl -I와curl -i의 요청 차이는 무엇인가?- 서명된 Flask 세션이 서버 측 세션이나 암호화된 데이터와 다른 점은 무엇인가?
- 쿠키 조작 문제와 프로필 문제에서 각각 빠진 신뢰·권한 검사는 무엇인가?
- ‘권한 없는 요청403’만 시험하면 어떤 결함을 놓칠 수 있는가?
해답
- 모양은 탐색 단서일 뿐입니다. 문제 번호와 전체 값이 맞아야 정답이며, 미끼나 다른 문제의 문자열일 수 있습니다.
- 서버가 요청자의 권한을 검사하기 때문입니다. 크롤러 규칙과 인가는 서로 다른 기능입니다.
- -I는 HEAD, -i는 GET 응답에 헤더도 출력합니다. 이 앱은 둘 다 지원하지만 다른 서버에서는 상태·헤더가 다를 수 있습니다.
- Flask 기본값은 클라이언트에 데이터가 있는 서명 쿠키입니다. 내용을 읽을 수 있지만 정상 키 없이 변경하면 검증에 실패합니다. 서버 저장소를 찾는 식별자 방식과 다르고 암호화도 아닙니다.
- 쿠키 문제는 검증되지 않은 admin 값을 믿습니다. 프로필 문제는 대상 객체를 볼 자격을 검사하지 않습니다. 인증된 사용자라는 사실만으로 모든 객체 접근이 허용되지는 않습니다.
- 모든 요청을 막아 버린 구현도 통과할 수 있습니다. 본인 객체와 관리자처럼 허용돼야 할 요청의200도 확인해야 합니다.
7. 오류 대응과 완료 기준
- Flask 모듈 없음: requirements를 설치한 가상환경의 Python인지 확인합니다.
- 연결 거부: 서버 터미널의 시작 오류·주소·포트부터 확인합니다. 점유된 포트라면 실습 포트를 바꾸고 다른 서비스를 강제 종료하지 않습니다.
- 한글 깨짐:
curl.exe -sS -o response.html URL로 저장해 UTF-8로 읽거나 브라우저에서 확인합니다. 인코딩을 모른 채 변환을 반복하지 않습니다. - 플래그 오답: 문제 번호·대소문자·중괄호·불필요한 공백을 확인합니다. 복사한 해설의 따옴표를 함께 넣지 않습니다.
- 쿠키 변경이 안 먹음: 요청의 –cookie 인자, challenge/fixed 모드, 대상 포트를 확인합니다. 수정 모드의403은 의도된 비교 결과입니다.
- 외부 문제가 어려움: 이 미니 CTF는 다섯 단순 사례입니다. 플랫폼 난이도와 동일하다고 가정하지 말고 개념·도구·가설 중 막힌 부분을 기록합니다.
완료 조건은 다섯 문제의 요청·응답·제출을 자신의 환경에서 재현하고, 수정 전후를 설명하며, 기록표5행과 자가 평가를 남기는 것입니다. 독립 해결하지 못한 항목은 그렇게 표시하고 다시 시도할 작업을 남깁니다. 끝나면 본인이 연 서버를 Ctrl+C로 종료합니다.
공식 근거: Flask 세션 · curl 옵션 · robots 규약 · OWASP 객체별 접근 통제.
한 단계 더 가까이
수업을 마쳤나요?
본문의 완료 기준을 확인한 뒤 직접 표시하세요.