OWASP Top 10:2021 10개 항목을 취약 코드와 안전 코드로 나란히 구현하고, pytest 30개로 공격 통과와 차단을 검증한 로컬 실습 저장소를 공개함.

GitHub: kh20134/owasp-top10-defense (MIT 라이선스)

owasp-check 한국어 보고서: 취약 프로필 실패 7건, 안전 프로필 통과

(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을 지켜라" 요구사항은 빈번하나, 실제 코드로 차이를 보여 주는 자료는 드묾
  • 항목별 취약·안전 엔드포인트를 나란히 두고 동일 공격 결과를 비교하는 실습을 직접 구현함
  • 설명보다 테스트 한 줄이 개발자 설득에 더 효과적임을 현장에서 다수 경험함

구조와 안전장치

OWASP Top 10 로컬 방어 실습 아키텍처: 로컬호스트 가드, 취약·안전 엔드포인트, 인메모리 SQLite, CI

(아키텍처, 출처: 본인 깃허브)

  • 브라우저, 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.flags
  • severity: high, medium, low
  • message: 예) "Content-Security-Policy 헤더가 없습니다."

취약 프로필 실행 시 finding_count는 7임. (지면상 값은 축약함)

실행 결과

  • 상단 이미지가 owasp-check 화면임. 취약 프로필은 7건으로 실패, 안전 프로필은 "발견된 설정 문제 없음"으로 통과
  • 하단은 pytest 전체 실행 결과임
pytest 실행 결과: 30 passed

(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 이슈로 접수

GitHub: https://github.com/kh20134/owasp-top10-defense