OWASP Top 10:2021 10개 항목을 취약 코드와 안전 코드로 나란히 구현하고, pytest 30개로 공격 통과와 차단을 검증한 로컬 실습 저장소를 공개함.
GitHub: kh20134/owasp-top10-defense (MIT 라이선스)
(owasp-check 실행 화면, 출처: 본인 깃허브)
개요
- 목표: A01~A10별로 동일 입력에 한쪽은 뚫리고 한쪽은 막히는 결과를 코드·테스트로 제시
- 기간: 2026년 10월
- 역할: 기획, 설계, 구현, 테스트
- 기술 스택: Python 3.11/3.12, Flask 3.1.3, argon2-cffi, 인메모리 SQLite, pytest, bandit, pip-audit, GitHub Actions
개발 배경
- 현장에서 "OWASP Top 10을 지켜라" 요구사항은 빈번하나, 실제 코드로 차이를 보여 주는 자료는 드묾
- 항목별 취약·안전 엔드포인트를 나란히 두고 동일 공격 결과를 비교하는 실습을 직접 구현함
- 설명보다 테스트 한 줄이 개발자 설득에 더 효과적임을 현장에서 다수 경험함
구조와 안전장치
(아키텍처, 출처: 본인 깃허브)
- 브라우저, pytest, owasp-check가 동일 Flask 프로세스에 요청 전송
- 요청은 로컬호스트 가드 통과 후 취약 구현 또는 안전 구현으로 분기, 양쪽 모두 동일 인메모리 SQLite 참조
- 서버는
127.0.0.1에만 바인딩 - 루프백이 아닌 클라이언트는 403 응답
- 취약 경로는
/lab/aNN/vuln/..., 안전 경로는/lab/aNN/fixed/...
의도적으로 취약하게 만든 교육용 서버이므로 외부 공개 절대 금지.
상세: 항목별 공격기법 및 대응
A01 접근 제어 실패
- 위험성: 로그인 사용자라도 타인 객체 열람 불가해야 함. 본 실습의 실패 형태는 IDOR로, 문서 번호만 알면 소유자와 무관하게 본문 노출
- 공격 예시: 로그인 없이
GET /lab/a01/vuln/documents/2호출 시 bob의 메모bob-only-memo반환 - 대응: 세션 사용자와 문서 소유자 일치 시에만 반환. 미인증은 401, 타인 문서는 404로 존재 여부까지 은닉
Before (취약)
row = document_by_id(
conn, doc_id)
if row is None:
return None
return {...}
After (안전)
if user_id is None:
raise AuthRequired
row = document_by_id(
conn, doc_id)
if (row is None or
row["owner_id"]
!= user_id):
return None
return {...}
A02 암호화 실패
- 위험성: 솔트 없는 MD5는 동일 비밀번호가 항상 동일 값이므로 사전 계산으로 즉시 복원됨. 해시가 응답에 포함되면 DB 유출 전에도 오프라인 추측 가능
- 공격 예시:
GET /lab/a02/vuln/stored/alice가md5("alice-lab-pass")와 동일한 32자리 값 반환. 솔트가 없어 사전 계산 표 대조로 원래 비밀번호 확인 가능 - 대응: 비밀번호는 Argon2id(
PasswordHasher)로만 검증, 응답에는 해시 대신 알고리즘 이름만 제공
Before (취약)
hashlib.md5(
value.encode("utf-8")
).hexdigest()
After (안전)
hasher = PasswordHasher()
try:
return hasher.verify(
stored_hash, candidate)
except (VerifyMismatchError,
VerificationError,
InvalidHashError):
return False
A03 인젝션
- 위험성: SQL 인젝션은 타 행의 비밀 메모 열람, XSS는 입력이 브라우저에서 스크립트로 실행 가능. 본 항목에 두 유형 모두 포함함
- 공격 예시:
?id=1 OR 1=1입력 시 메모 2건 전부 반환, 그중bob-secret-note포함 <script>alert(1)</script>를 echo에 전송 시 태그가 HTML에 그대로 잔존, CSP도 없음- 대응: 숫자가 아니면 빈 결과 반환, 조회는
?자리표시자로만 수행. HTML은html.escape로 변환 후 CSP 적용
Before (취약)
query = (
"SELECT id, title, body "
"FROM notes WHERE id = "
f"{raw_id}")
conn.execute(query)
After (안전)
if not raw_id.isdigit():
return []
conn.execute(
"SELECT id, title, body "
"FROM notes WHERE id = ?",
(int(raw_id),))
XSS는 출력 직전 이스케이프 처리.
safe = html.escape(
text, quote=True)
A04 안전하지 않은 설계
- 위험성: 비밀번호 재설정 토큰이 사용자 이름임. 암호 기술 문제가 아닌, 위협 모델링 없이 설계한 절차의 문제임
- 공격 예시:
{"username": "alice"}로 재설정 요청 시 토큰이alice로 발급되고, 해당 값으로 비밀번호 변경 가능 - 대응:
secrets.token_urlsafe(32)로 발급, SHA-256만 저장. 만료(실습 기본 15분)와 1회 사용 확인, 발급 응답에 토큰 미포함
Before (취약)
def issue_token(username):
return username
After (안전)
def issue_token():
return secrets \
.token_urlsafe(32)
# 저장은 sha256(token)만
# 비교는 compare_digest
# 사용 후 used = 1
A05 보안 설정 오류
- 위험성: 보안 헤더 부재 시 클릭재킹, MIME 혼동, 리퍼러 유출에 노출. 쿠키 플래그 누락이나 오류 화면 스택 노출 시 설정 자체가 취약점이 됨
- 공격 예시: 취약 프로필 응답에 CSP, X-Frame-Options 등 헤더 없음, 쿠키는
prefs=open뿐임. 취약 debug는 500 본문에ZeroDivisionError스택 그대로 노출 - 대응: 보안 헤더 전부 적용, 쿠키에 세 플래그 부착. 예외 시 "요청을 처리할 수 없습니다."만 반환
Before (취약)
headers["Set-Cookie"] = \
"prefs=open"
return traceback \
.format_exc(), 500
After (안전)
headers["Set-Cookie"] = (
"prefs=open; HttpOnly; "
"Secure; SameSite=Strict")
apply_security_headers(resp)
return {"error": "요청을 "
"처리할 수 없습니다."}, 500
A06 취약하고 오래된 구성요소
- 위험성: 직접 작성한 코드가 아니어도 오래된 라이브러리의 공개 결함이 곧 서버의 결함이 됨. 빌드가 취약 버전을 차단하지 않으면 수정본이 있어도 배포에 잔존
- 공격 예시: 감사 표본에
pyyaml==5.3.1(CVE-2020-14343)과django==1.11.29(CVE-2021-33203) 포함. 취약 경로는 이를 발견하고도 200으로 통과 - 대응: 안전 경로는 동일 핀 존재 시 409와
blocked: true로 거부. 표본은 미설치, 실제 런타임 의존성은 CI의 pip-audit가 재감사
Before (취약)
return {
"enforced": False,
"findings":
find_advisories(text)}
After (안전)
findings = \
find_advisories(text)
return {
"enforced": True,
"findings": findings,
"blocked": bool(findings)}
# blocked면 HTTP 409
A07 식별 및 인증 실패
- 위험성: 실패 문구가 다르면 계정 존재 여부 노출, 잠금이 없으면 무한 추측 가능. 세션 값이 사용자 번호면 쿠키 변경만으로 타인 사칭 가능
- 공격 예시: 없는 계정과 틀린 비밀번호에 서로 다른 문구 출력, 6회 실패해도 계속 401. 쿠키
session_id=2로 접속 시 bob으로 인증됨 - 대응: 실패 문구 단일화, 5회 실패 시 5분간 429로 잠금. 세션은 무작위 값으로 생성, 쿠키에 세 플래그 부착
Before (취약)
if user_exists:
return BAD_PASSWORD
return UNKNOWN
...
return str(user_id)
After (안전)
return ("아이디 또는 "
"비밀번호가 올바르지 "
"않습니다.")
...
return secrets \
.token_urlsafe(32)
A08 소프트웨어 및 데이터 무결성 실패
- 위험성: 클라이언트가 보낸 역할(role)을 그대로 신뢰하면 권한 경계 소멸. 검증 없는 데이터 신뢰 자체가 무결성 실패임
- 공격 예시:
{"username": "alice", "role": "admin"}전송 시 역할이 admin으로 저장됨 - 대응: 서버가 역할을 user로 고정해 HMAC-SHA256 서명 부착 후 발급, 서명 일치 시에만 수용. 역할만 admin으로 변조 시 403
Before (취약)
return {
"username":
payload.get("username"),
"role":
payload.get("role"),
"verified": False}
After (안전)
expected = sign(
secret, username, role)
if not hmac.compare_digest(
expected, signature):
return None
return {..., "verified": True}
A09 보안 로깅 및 모니터링 실패
- 위험성: 실패한 비밀번호를 로그에 기록하면 로그 저장소가 두 번째 비밀번호 저장소가 됨. 실패 횟수 미집계 시 반복 공격을 경보로 전환할 근거 없음
- 공격 예시: 틀린 비밀번호로 로그인 후 이벤트 확인 시
password필드에 해당 문자열 존재,auth_failure는 0 - 대응: 이벤트에는 결과, 사용자 이름,
request_id만 기록, 실패 시마다 카운터 증가
Before (취약)
log.append({
"event": "login",
"username": username,
"password": password,
"success": success})
After (안전)
log.append({
"event": "auth_success"
if success
else "auth_failure",
"username": username,
"request_id":
uuid.uuid4().hex[:12]})
if not success:
metrics["auth_failure"] += 1
A10 서버 측 요청 위조(SSRF)
- 위험성: 서버가 접근 가능한 주소는 사용자 브라우저와 다름. 서버가 대신 URL을 가져오면 루프백, 클라우드 메타데이터 등 내부 응답 유출
- 공격 예시: 테스트에서 루프백에 표식 서버를 띄우고 해당 URL을 취약 fetch에 전달 시, 본문의
INTERNAL-LAB-MARKER그대로 반환 - 대응: https, 허용 호스트(
books.example), 443 포트일 때만 진행. DNS 결과가 공인 주소가 아니면 연결 차단, 리다이렉트 미추적
Before (취약)
with open_url(
url, timeout=2) as r:
payload = r.read(4096)
After (안전)
if (parsed.scheme != "https"
or host not in
ALLOWED_HOSTS):
raise FetchBlocked(...)
for raw in addresses:
ip = ipaddress \
.ip_address(raw)
if not ip.is_global:
raise FetchBlocked(...)
루프백 URL과 http://169.254.169.254/latest는 연결 전 403으로 차단됨.
상세: owasp-check 사용법
owasp-check는 대상 URL에 GET 요청 1회 전송 후 응답 헤더와Set-Cookie만 확인- 공격 문자열 미전송, 리다이렉트 미추적
python -m venv .venv
source .venv/bin/activate
pip install \
-r requirements.txt \
-r requirements-dev.txt
pytest
python -m app
서버는 http://127.0.0.1:5000에서만 수신. 별도 터미널에서 점검 수행.
BASE=http://127.0.0.1:5000
owasp-check --url \
$BASE/lab/a05/vuln/profile
owasp-check --url \
$BASE/lab/a05/fixed/profile
owasp-check --json \
--url $BASE/
패키지 미설치 시 python -m owasp_check로 동일 명령 실행 가능.
점검 항목 및 심각도.
- 높음: CSP 없음, X-Content-Type-Options가 nosniff 아님, X-Frame-Options가 DENY/SAMEORIGIN 아님, 쿠키에 HttpOnly·Secure·SameSite 중 하나라도 없음
- 중간: Referrer-Policy 없음, HSTS에 max-age 없음
- 낮음: Permissions-Policy 없음
- 종료 코드: 통과 0, 발견 1, 연결 실패 2. CI 단계에 그대로 적용 용이
--json지정 시 한국어 메시지 포함 JSON 출력
{
"target": "...",
"ok": false,
"finding_count": 7,
"findings": [{
"check_id": "...",
"severity": "high",
"message": "..."
}, ...]
}
check_id: 예)header.content-security-policy,cookie.flagsseverity: high, medium, lowmessage: 예) "Content-Security-Policy 헤더가 없습니다."
취약 프로필 실행 시 finding_count는 7임. (지면상 값은 축약함)
실행 결과
- 상단 이미지가 owasp-check 화면임. 취약 프로필은 7건으로 실패, 안전 프로필은 "발견된 설정 문제 없음"으로 통과
- 하단은 pytest 전체 실행 결과임
(pytest 실행 화면, 출처: 본인 깃허브)
- 항목별로 "취약 경로에서 공격이 통과한다"와 "안전 경로에서 막힌다"를 쌍으로 확인
- 로컬호스트 가드와 owasp-check 테스트 포함 30개 전부 통과
- GitHub Actions는 Python 3.11·3.12에서 pytest, bandit, pip-audit 순으로 실행, 머지 커밋 기준 모두 성공
- bandit은 안전 구현, 앱 골격, owasp_check만 검사. 의도적 취약 코드인
app/vulnerable은 비교용이므로bandit.yaml에서 제외함
대응방안: 현장 적용
- 교육 자료: 개발자 보안 교육에서 취약·안전 코드를 나란히 띄우고 테스트 실행 시 수정 필요성 즉시 전달 가능
- 코드 리뷰 체크포인트: "소유자 확인", "자리표시자", "실패 문구 통일", "로그에 비밀 없음" 등 항목별 한 줄 기준을 리뷰에 그대로 적용 가능
- 헤더 점검: owasp-check는 GET 1회로 헤더만 확인하므로, 본인 운영 또는 허가받은 서비스의 기본 설정 확인에 활용 가능
- CI 게이트: 종료 코드가 0/1/2로 구분되어 배포 전 단계 적용 용이. 의존성은 pip-audit 병행 권장
- 회귀 방지: 수정한 취약점마다 "공격이 막힌다" 테스트를 남기면 향후 원복 시 CI에서 검출됨
한계
- 교육용 로컬 실습임. 운영 서비스용 프레임워크·보안 라이브러리 아님
- 번호는 OWASP Top 10:2021 기준. 2025년 판의 순위·명칭 변경은 README에만 별도 정리함
- owasp-check는 보안 헤더와 쿠키 플래그만 확인. 인젝션, 접근 제어 등 취약점은 미탐지
- HSTS는 HTTPS 서비스 시에만 유효. 로컬 HTTP 실습에서는 헤더 존재 여부만 확인
- A06 감사는 표본 핀 2개만 담은 고정 스냅샷임. 실시간 감사는 CI의 pip-audit 담당
- DB, 로그, 잠금 상태는 메모리 저장으로 서버 재시작 시 초기화됨
- A10 허용 호스트
books.example은 실습용 예시 이름임
Q&A
Q. 외부 서버 배포 가능 여부
불가함. 의도적 취약 코드가 포함되어 127.0.0.1에만 바인딩하며, 루프백이 아닌 요청은 403으로 차단함.
Q. owasp-check의 공격 수행 여부
수행하지 않음. GET 요청 1회로 헤더와 쿠키만 확인하며 공격 페이로드 미전송.
Q. 2025년 판 기준 여부
실습 번호는 2021 기준 유지. 2025 변경 사항은 README의 「2025에서 확인된 변화」에 별도 기재함.
결론
- 보안 대책은 "같은 입력을 넣었을 때 막히는가"로 증명 필요
- 본 저장소는 해당 증명을 10개 항목 전부 테스트로 남긴 실습임
- 교육·리뷰 활용 자유, 의견은 GitHub 이슈로 접수