원본 항목: Part 4. 에러 처리 — 2. 오류상황 대응 부재CWE-390repo 폴더: sd41_erract/ (빌드·실행은 §3-1)
목표 시간: 1회차 10분 / 2회차 6분 / 3회차 4분
“검사는 했지만 아무 조치가 없는” 빈 오류 처리 블록을 재현한다 — 실패를 감지하는 것과 실패에 대응하는 것은 완전히 다른 일이라는 걸 상태 변화로 직접 확인한다.
이 드릴의 문서 구성 (SD01~SD58 공통)
① 요구사항서 — 어떤 코드가 뚫려 있고, 무엇을 만드나.
② pseudo code 템플릿 — Bad 와 Good 의 뼈대이고, 회차마다 가린다.
③ 테스트 코드 — 공격은 막히고 정상은 통과해야 끝이다.
④ 자주 하는 실수
⑤ 모범답안 — 맨 뒤에 접어 둔다.
변형 A/B/C 는 같은 치환 규칙을 다른 소재로 다시 치는 것이다.
0. 이 드릴로 체화할 것
if (rc != 0) {- /* do nothing */ /* 검사는 하지만 아무 조치도 없음 */+ return -1; /* 실제 조치: 즉시 중단하고 실패를 알림 */ }
빈 if 블록은 “검사했다”가 아니라 “오류를 은폐했다”는 뜻이다.
검사 뒤에는 반드시 로그·복구·중단 중 하나의 실제 조치가 있어야 한다.
회차별 진행
회차
변형
보는 것
순서
1회차
A 잔액 차감 트랜잭션
① 요구사항서 ② pseudo code 전체
1. Bad 를 그대로 쳐서 취약점을 관찰한다. 2. Good pseudo 를 한 줄씩 코드로 바꾼다. 3. ③ 테스트를 실행한다.
2회차
B 파일 쓰기 실패로 변형
① 요구사항서 ② pseudo code 의 번호와 제목만 (Level 2)
1. Bad 를 치고 취약점을 확인한다. 2. 번호만 보고 Good 을 쓴다. 3. ③ 테스트를 실행한다.
3회차
C 재시도(retry) 로직 추가
① 요구사항서만
1. ③ 테스트를 먼저 읽는다. 2. 백지에서 Bad 부터 Good 까지 재현한다.
1회차에 막히면 같은 변형을 한 번 더 친다.
3회차를 통과하면 A 를 백지로 다시 친다.
1. 요구사항서
1-A. 변형 A — 잔액 차감 트랜잭션
요청을 처리하기 전에 사전 검증 단계를 거친다.
사전 검증이 실패하면 잔액 차감이 절대 일어나서는 안 된다.
실패 시 호출자는 반드시 실패를 알 수 있어야 한다.
함수
int process_request_bad(int step1_will_fail, int *out_balance);int process_request_good(int step1_will_fail, int *out_balance);
신뢰 경계
값
출처
검증 없이 흘러가는 곳
사전 검증 결과(rc)
내부 검증 로직
if (rc != 0) 블록이 비어 있어 검증 결과와 무관하게 다음 로직(잔액 차감)이 그대로 실행된다
공격 입력표
상황
Bad 결과
이유
사전 검증 실패(rc = -1)
잔액이 그대로 차감되고 함수는 성공(0)을 반환
if 블록이 비어 있어 실패를 감지만 하고 아무 것도 막지 않는다 — 심지어 반환값도 거짓으로 성공을 알린다
같은 상황(Good)
잔액은 그대로, 함수는 실패(-1)를 반환
실패를 감지한 즉시 return -1로 이후 로직 실행 자체를 막는다
1-B. 변형 B — 파일 쓰기 실패로 변형
가이드 원문처럼 fputs류 함수의 반환값을 검사하는 형태로 바꿔, “쓰기 실패 감지 후 아무 조치 없음” 대 “실패 로그 남기고 중단”을 비교한다
1-C. 변형 C — 재시도(retry) 로직 추가
Good을 한 단계 발전시켜, 실패 시 즉시 중단하는 대신 최대 3회까지 재시도한 뒤에도 실패하면 그때 중단하는 함수로 확장한다
2. pseudo code 템플릿 — 보고 쳐서 익힌다
사용법
변형별로 아래 pseudo code 를 소스 파일에 주석만 복사한다.
코드는 주석을 보고 내가 쓴다.
다 쓰면 [ ] 를 x 로 바꾼다.
막히면 §5 모범답안(A)의 해당 단계만 본다.
2-A. process_request_good — Level 1 (1회차, 전부 본다)
먼저 Bad 를 그대로 쳐서 §3 의 test_bad_is_vulnerable 이 통과하는지(= 실제로 뚫리는지) 확인한다.
그다음 Good 을 쓴다.
int process_request_bad(int step1_will_fail, int *out_balance){ /* 1. rc = step1_will_fail ? -1 : 0 * 2. rc != 0 이면 { /* do nothing */ } ← 검사만 하고 조치가 없다 * 3. *out_balance -= 100 ← 실패해도 그대로 진행 * 4. return 0 ← 실패를 숨긴다 */}int process_request_good(int step1_will_fail, int *out_balance){ /* [ ] 1. rc = step1_will_fail ? -1 : 0 */ /* [ ] 2. rc != 0 이면 → return -1 * (실제 조치: 즉시 중단하고 실패를 알린다) */ /* [ ] 3. *out_balance -= 100 */ /* [ ] 4. return 0 */}
int process_request_good(int step1_will_fail, int *out_balance){ /* 아무것도 안 본다. * §1 요구사항서와 §0 의 치환 규칙만 보고 친다. * * 막히면 아래만 찾아본다. * - 빈 `if` 블록은 오류를 은폐한 것 * - 실패 시 `*out_balance` 가 **변하지 않는지** 시험으로 확인 */}
막혔을 때 찾는 순서
컴파일 에러가 나면 에러 문구를 본다.
unknown type name / implicit declaration 이면 include 누락이다.
man 3 <함수> 를 연다.
SYNOPSIS 맨 위의 #include 를 확인한다.
로직이 막히면 §3 테스트의 기대값을 먼저 읽는다.
공격 입력표(§1)와 테스트가 답이다.
블록을 지워도 다음 줄(*out_balance -= 100)이 여전히 무조건 실행된다. 핵심은 “블록을 없애는 것”이 아니라 “블록 안에 return/중단을 넣어 이후 코드 실행을 막는 것”이다
로그만 남기고 return을 빼먹음
”감지했다는 흔적”을 남기는 것과 “실행 흐름을 실제로 바꾸는 것”은 다르다. 로그를 찍어도 다음 줄이 계속 실행되면 상태 오염은 똑같이 일어난다
Bad의 반환값이 왜 0(성공)인지 이해 못 함
이 항목의 핵심 위험은 단순히 “차감이 일어난다”가 아니라, 호출자마저 실패를 모른 채 성공으로 착각한다는 것이다. 이중 은폐(상태 오염 + 거짓 성공 보고)라는 점을 놓치기 쉽다
5. 모범답안 (변형 A)
다 치고 나서 열 것 — erract.h / erract.c
#ifndef ERRACT_H#define ERRACT_Hint process_request_bad(int step1_will_fail, int *out_balance);int process_request_good(int step1_will_fail, int *out_balance);#endif
#include "erract.h"int process_request_bad(int step1_will_fail, int *out_balance){ int rc = step1_will_fail ? -1 : 0; if (rc != 0) { /* do nothing */ /* 검사는 하지만 아무 조치도 없음 */ } *out_balance -= 100; /* 실패했든 말든 그대로 진행 */ return 0; /* 실패를 숨기고 항상 성공을 보고 */}int process_request_good(int step1_will_fail, int *out_balance){ int rc = step1_will_fail ? -1 : 0; if (rc != 0) { return -1; /* 실제 조치: 즉시 중단하고 실패를 알림 */ } *out_balance -= 100; return 0;}
눈여겨볼 점: 두 함수의 차이는 if 블록 안의 단 한 줄(/* do nothing */ vs return -1;)뿐이다.
겉보기엔 사소해 보이지만, 그 한 줄의 유무가 “실패가 다음 로직 실행을 막는가”를 완전히 가른다.
코드 리뷰에서 빈 오류 처리 블록을 그냥 지나치기 쉬운 이유가 바로 이 시각적 사소함이다.