[CS300 #254] 시큐어 코딩 — 취약점을 고치는 게 아니라 생기지 않게 짜는 법
컴퓨터공학 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 실행, 읽기 전용 루트 파일시스템, 필요한 시크릿만 마운트하면 앱 코드의 실수가 노드 장악으로 번지지 않는다. 최소 권한이 코드 밖에서도 작동하는 예다.
확인 문제
- 입력 검증이 SQL 인젝션의 1차 방어가 될 수 없는 이유는?
- 거부 목록 대신 허용 목록을 쓰는 이유는?
- 경로 조작을 막을 때
../문자열을 제거하는 방식이 위험한 이유는? - “실패 시 안전한 기본값” 원칙을 권한 확인 코드에 적용하면?
random.random()으로 비밀번호 재설정 토큰을 만들면 안 되는 이유는?
풀이
- 정당한 데이터에도 따옴표 같은 특수문자가 들어갈 수 있어 다 거를 수 없다. 데이터와 코드를 분리하는 파라미터화가 1차 방어다.
- 위험한 입력의 변형은 무한하지만 정당한 입력의 형식은 정의할 수 있다. 정의 밖은 모두 거부되므로 새 변종에도 안전하다.
....//처럼 제거 후 다시../가 되는 변형, 인코딩, 심볼릭 링크로 우회된다. 정규화한 최종 경로가 기준 디렉터리 안인지 확인해야 한다.- 권한 정보를 못 가져오거나 예외가 나면 거부한다. 정의되지 않은 동작은 기본 거부한다.
random은 암호학적으로 안전하지 않은 의사난수라 출력에서 내부 상태를 추정할 수 있다.secrets모듈을 써야 한다.
더 읽을거리 (References)
- NIST, SP 800-218: Secure Software Development Framework (SSDF) Version 1.1
- OWASP, Secure Coding Practices Quick Reference Guide
- OWASP, Input Validation Cheat Sheet
- Python, pickle — Python object serialization (보안 경고)
- 논문: J. H. Saltzer, M. D. Schroeder, “The Protection of Information in Computer Systems”, Proceedings of the IEEE, 1975.