컴퓨터공학 300 주제 시리즈의 254번째 글이다. 전체 지도는 여기.

한 줄 요약

시큐어 코딩은 취약점 목록을 외우는 것이 아니라 신뢰 경계에서 입력을 검증하고, 출력 문맥에 맞게 인코딩하고, 위험한 API 를 피하고, 실패하면 닫히게 짜는 몇 가지 원칙을 습관으로 만드는 일이다.

왜 필요한가

앞의 글들에서 본 SQL 인젝션, XSS, IDOR 는 서로 다른 버그 같지만 뿌리가 겹친다. 신뢰할 수 없는 데이터가 검증 없이 해석기에 들어가거나, 권한 확인이 빠지거나, 기본값이 열려 있다. 취약점마다 따로 대응하면 끝이 없다. 원칙으로 막으면 아직 이름이 붙지 않은 변종까지 막는다.

비용 면에서도 그렇다. 배포 후 발견된 취약점은 핫픽스, 사고 대응, 고객 공지까지 이어진다. 미국 NIST 의 SSDF(SP 800-218)가 보안 활동을 개발 생명주기 전체에 넣으라고 권하는 이유다.

핵심 개념

오래된 원칙, 여전히 유효하다

1975년 Saltzer 와 Schroeder 의 논문 “The Protection of Information in Computer Systems” 가 정리한 설계 원칙 중 실무에서 가장 자주 쓰이는 것들이다.

원칙 의미 코드에서의 모습
실패 시 안전한 기본값(Fail-safe defaults) 기본은 거부, 명시적 허용만 통과 인가 미들웨어 기본 거부, 허용 목록 검증
완전한 중재(Complete mediation) 모든 접근을 매번 확인 캐시된 권한 판단 재사용 금지, 객체마다 검사
최소 권한(Least privilege) 필요한 만큼만 DB 계정 분리, 컨테이너 non-root
메커니즘의 경제성 단순하게 직접 만든 암호·파서 금지
공개 설계(Open design) 비밀은 키에만, 설계 숨기기에 기대지 않음 난독화를 보안으로 착각하지 않기
권한 분리 둘 이상의 조건 배포 승인 2인, MFA

입력 검증: 허용 목록으로, 경계에서

  • 어디서: 신뢰 경계를 넘어오는 순간. HTTP 요청, 파일 업로드, 메시지 큐, 외부 API 응답, 환경 변수까지 포함한다.
  • 어떻게: 무엇이 허용되는지를 정의한다(형식·타입·길이·범위·열거). 거부 목록(<script> 금지 등)은 변형으로 우회된다.
  • 한계: 입력 검증은 인젝션의 1차 방어가 아니다. 이름 필드에 O'Brien 은 정당한 값이다. 인젝션은 출력 쪽에서 파라미터화·인코딩으로 막고, 입력 검증은 업무 규칙과 심층 방어를 맡는다.

위험한 API 목록

범주 피할 것 대신
OS 명령 셸을 거치는 실행(shell=True, system()) 인자 배열로 실행, 가능하면 라이브러리 API
역직렬화 신뢰할 수 없는 데이터에 pickle, Java 네이티브 직렬화, yaml.load JSON 등 데이터 전용 형식, yaml.safe_load
동적 실행 eval, exec, 템플릿에 사용자 문자열 컴파일 파서·허용 목록
경로 사용자 입력을 경로에 그대로 결합 정규화 후 기준 디렉터리 포함 여부 확인, ID→경로 매핑
난수 random 모듈로 토큰 생성 secrets 모듈, OS CSPRNG
비교 비밀값을 == 로 비교 상수 시간 비교

파이썬 공식 문서도 pickle 모듈은 안전하지 않으며 신뢰할 수 있는 데이터만 역직렬화하라고 경고한다. 역직렬화 과정에서 임의 코드가 실행될 수 있기 때문이다.

오류 처리와 비밀 관리

  • 오류는 닫히는 쪽으로 처리한다. 권한 서버 호출이 실패하면 허용이 아니라 거부한다.
  • 사용자에겐 일반 메시지와 추적 ID, 로그에는 상세 정보. 단 로그에도 비밀번호·토큰·주민번호를 남기지 않는다.
  • 비밀은 코드·이미지·깃 저장소에 넣지 않는다. 시크릿 관리 도구나 런타임 주입을 쓴다.

프로세스: SSDF

NIST SP 800-218 은 실천 항목을 네 묶음으로 나눈다.

  • PO 조직 준비: 역할, 정책, 도구 체인
  • PS 소프트웨어 보호: 코드·빌드 산출물의 무단 변경 방지
  • PW 안전한 소프트웨어 생산: 설계 검토, 위협 모델, 코드 리뷰, 테스트, 안전한 기본 설정
  • RV 취약점 대응: 접수, 분석, 근본 원인 제거

직접 해 보기

세 가지 흔한 실수를 취약/개선 쌍으로 확인한다. (1) 셸을 거친 명령 실행 (2) 경로 조작 (3) 거부 목록 대신 허용 목록 검증. 명령은 무해한 echo 만 쓴다.

import subprocess, os, tempfile, json, pathlib

# 1) OS 명령 인젝션: 셸을 거치면 ; 뒤가 새 명령이 된다
user_input = "hello; echo INJECTED"
vuln = subprocess.run(f"echo {user_input}", shell=True, capture_output=True, text=True)
safe = subprocess.run(["echo", user_input], capture_output=True, text=True)   # 셸 없음, 인자 하나
print("shell=True :", vuln.stdout.splitlines())
print("인자 목록   :", safe.stdout.splitlines())

# 2) 경로 조작: 기준 디렉터리를 벗어나는지 정규화 후 확인
base = pathlib.Path(tempfile.mkdtemp()).resolve()
(base / "report.txt").write_text("ok")
def read_vulnerable(name):
    return (base / name).as_posix()                    # 그냥 이어 붙임
def read_safe(name):
    p = (base / name).resolve()
    if not p.is_relative_to(base):                    # Python 3.9+
        raise PermissionError(f"기준 디렉터리 밖: {name}")
    return p.read_text()
print("취약 경로 :", read_vulnerable("../../etc/passwd"))
print("정상      :", read_safe("report.txt"))
try:
    read_safe("../../etc/passwd")
except PermissionError as e:
    print("거부      :", e)

# 3) 입력 검증: 허용 목록 + 타입·범위 (거부 목록 아님)
def parse_order(raw: str):
    d = json.loads(raw)                               # pickle 대신 데이터 전용 형식
    if set(d) != {"sku", "qty"}:
        raise ValueError("필드 불일치")
    if not (type(d["qty"]) is int and 1 <= d["qty"] <= 100):
        raise ValueError("qty 범위 1~100")
    if not (isinstance(d["sku"], str) and d["sku"].isascii() and d["sku"].isalnum() and len(d["sku"]) <= 12):
        raise ValueError("sku 형식")
    return d
for raw in ['{"sku":"AB12","qty":3}', '{"sku":"AB12","qty":-5}', '{"sku":"AB12","qty":3,"price":0}']:
    try:
        print("통과 :", parse_order(raw))
    except ValueError as e:
        print("거부 :", raw, "->", e)

실행 결과(Python 3.12, 리눅스. 임시 경로는 실행마다 다르다):

shell=True : ['hello', 'INJECTED']
인자 목록   : ['hello; echo INJECTED']
취약 경로 : /tmp/tmpXXXXXXXX/../../etc/passwd
정상      : ok
거부      : 기준 디렉터리 밖: ../../etc/passwd
통과 : {'sku': 'AB12', 'qty': 3}
거부 : {"sku":"AB12","qty":-5} -> qty 범위 1~100
거부 : {"sku":"AB12","qty":3,"price":0} -> 필드 불일치

1번에서 셸을 거치면 ; 뒤가 별도 명령으로 실행됐다. 인자 배열로 넘기면 같은 문자열이 그냥 echo 의 인자 하나다. SQL 인젝션과 똑같은 구조다. 2번은 문자열을 다루는 대신 경로를 정규화한 결과로 판단한다. ../, 심볼릭 링크, 중복 슬래시 같은 변형을 하나하나 막으려 하지 말고 최종 위치를 본다. 3번은 price 같은 예상 밖 필드를 거부해 대량 할당까지 막는다. qty 검사에 isinstance 대신 type(...) is int 를 쓴 이유는 파이썬에서 True 도 int 의 인스턴스이기 때문이다.

현업에서는

  • 보안 코드 리뷰 체크리스트: 새 엔드포인트마다 “인증? 객체 인가? 입력 스키마? 출력 인코딩? 오류 응답? 로그에 민감 정보?” 여섯 줄이면 대부분을 잡는다. OWASP ASVS 를 팀 기준으로 줄여 쓰기도 한다.
  • 정적 분석(SAST)과 시크릿 스캔을 CI 에: 위험한 API 호출과 하드코딩된 키는 기계가 잘 찾는다. 사람은 인가·업무 로직에 집중한다.
  • 안전한 기본값을 가진 라이브러리 선택: 자동 이스케이프 템플릿, 파라미터화가 기본인 쿼리 빌더, safe_load 가 기본인 YAML 로더. 개발자가 실수하기 어렵게 만드는 쪽이 교육보다 효과가 크다.
  • 컨테이너에서도 같은 원칙: 홈랩 쿠버네티스에 올리는 작은 서비스도 non-root 실행, 읽기 전용 루트 파일시스템, 필요한 시크릿만 마운트하면 앱 코드의 실수가 노드 장악으로 번지지 않는다. 최소 권한이 코드 밖에서도 작동하는 예다.

확인 문제

  1. 입력 검증이 SQL 인젝션의 1차 방어가 될 수 없는 이유는?
  2. 거부 목록 대신 허용 목록을 쓰는 이유는?
  3. 경로 조작을 막을 때 ../ 문자열을 제거하는 방식이 위험한 이유는?
  4. “실패 시 안전한 기본값” 원칙을 권한 확인 코드에 적용하면?
  5. random.random() 으로 비밀번호 재설정 토큰을 만들면 안 되는 이유는?

풀이

  1. 정당한 데이터에도 따옴표 같은 특수문자가 들어갈 수 있어 다 거를 수 없다. 데이터와 코드를 분리하는 파라미터화가 1차 방어다.
  2. 위험한 입력의 변형은 무한하지만 정당한 입력의 형식은 정의할 수 있다. 정의 밖은 모두 거부되므로 새 변종에도 안전하다.
  3. ....// 처럼 제거 후 다시 ../ 가 되는 변형, 인코딩, 심볼릭 링크로 우회된다. 정규화한 최종 경로가 기준 디렉터리 안인지 확인해야 한다.
  4. 권한 정보를 못 가져오거나 예외가 나면 거부한다. 정의되지 않은 동작은 기본 거부한다.
  5. random 은 암호학적으로 안전하지 않은 의사난수라 출력에서 내부 상태를 추정할 수 있다. secrets 모듈을 써야 한다.

더 읽을거리 (References)