원본 항목: Part 1-1. 삽입 계열 — 4. 운영체제 명령어 삽입CWE-78repo 폴더: sd04_oscmd/ (빌드·실행은 §3-1)
목표 시간: 1회차 15분 / 2회차 9분 / 3회차 6분system() 을 실제로 호출하면 위험하므로, “몇 개의 명령이 실행될 것인가”만 세는 mock_shell 로 재현한다.
이 mock은 미리 제공된다.
이 드릴의 문서 구성 (SD01~SD58 공통)
① 요구사항서 — 어떤 코드가 뚫려 있고, 무엇을 만드나.
② pseudo code 템플릿 — Bad 와 Good 의 뼈대이고, 회차마다 가린다.
③ 테스트 코드 — 공격은 막히고 정상은 통과해야 끝이다.
④ 자주 하는 실수
⑤ 모범답안 — 맨 뒤에 접어 둔다.
변형 A/B/C 는 같은 치환 규칙을 다른 소재로 다시 치는 것이다.
0. 이 드릴로 체화할 것
- snprintf(cmd, sizeof(cmd), "cat %s", userArg); /* 검증 없이 조립 */+ if (strpbrk(userArg, ";|&`$\\\"'") != NULL) return -1; /* 위험 문자 있으면 거부 */+ snprintf(cmd, sizeof(cmd), "cat %s", userArg);
PDF 원문과 다른 점
원문 가이드는 위험 문자로 ; \ " ' . 을 검사하라고 하지만, . 은 파일 확장자(Story.txt)에 정상적으로 쓰이는 문자라 그대로 적용하면 정상 케이스까지 막힌다.
이 드릴에서는 셸 메타문자만(; | & `` “ $ \ ” ‘) 검사한다 — 근본적으로는 system()자체를 없애고execve()` 계열로 인자를 배열 분리하는 것이 정답이다.
회차별 진행
회차
변형
보는 것
순서
1회차
A 파일 출력 명령 조립
① 요구사항서 ② pseudo code 전체
1. Bad 를 그대로 쳐서 취약점을 관찰한다. 2. Good pseudo 를 한 줄씩 코드로 바꾼다. 3. ③ 테스트를 실행한다.
2회차
B 압축 명령 조립
① 요구사항서 ② pseudo code 의 번호와 제목만 (Level 2)
1. Bad 를 치고 취약점을 확인한다. 2. 번호만 보고 Good 을 쓴다. 3. ③ 테스트를 실행한다.
3회차
C 허용 명령 화이트리스트로 전환
① 요구사항서만
1. ③ 테스트를 먼저 읽는다. 2. 백지에서 Bad 부터 Good 까지 재현한다.
1회차에 막히면 같은 변형을 한 번 더 친다.
3회차를 통과하면 A 를 백지로 다시 친다.
1. 요구사항서
1-0. 환경 코드 — mock_shell (미리 제공, 타이핑하지 않음)
sd04_oscmd/src/mock_shell.h / mock_shell.c — repo에 이미 있다
/* mock_shell.h */#ifndef MOCK_SHELL_H#define MOCK_SHELL_H/* 실제로 실행하지 않고 ';' 로 구분되는 "명령 개수"만 센다. 진짜 셸이라면 이 개수만큼 프로세스가 실행된다. */int mock_shell_command_count(const char *cmdline);#endif
/* mock_shell.c */#include "mock_shell.h"int mock_shell_command_count(const char *cmdline){ int count = 1; const char *p; for (p = cmdline; *p; p++) if (*p == ';') count++; return count;}
1-A. 변형 A — 파일 출력 명령 조립
사용자가 지정한 파일명(userArg)으로 cat <파일명> 명령을 조립해 실행한다.
조립된 명령이 실제로 몇 개의 명령으로 나뉘어 실행될지(mock_shell_command_count)를 반환한다.
정상 파일명은 항상 명령 1개로 끝나야 한다.
함수
int build_and_count_bad(const char *userArg);int build_and_count_good(const char *userArg);
신뢰 경계
값
출처
검증 없이 흘러가는 곳
userArg
외부(표준입력)
system() 에 전달될 명령 문자열
공격 입력표
입력
Bad 결과(명령 개수)
이유
Story.txt (정상)
1
정상 동작
Story.txt; ls
2
세미콜론 뒤 ls 가 별도 명령으로 실행된다
`Story.txt \
rm -rf /`
(파이프, mock에서는 명령 1개로 세지만 실제 셸에서는 위험)
1-B. 변형 B — 압축 명령 조립
gzip <로그파일명> 형태.
위험 문자에 >(리다이렉트)도 추가해야 하는 이유를 생각해본다
1-C. 변형 C — 허용 명령 화이트리스트로 전환
위험 문자 블랙리스트 대신, 미리 정의한 명령 후보 목록({"cat","gzip","ls"})에서만 선택하도록 바꾼다 — 가이드 원문이 제시하는 더 근본적인 해법
2. pseudo code 템플릿 — 보고 쳐서 익힌다
사용법
변형별로 아래 pseudo code 를 소스 파일에 주석만 복사한다.
코드는 주석을 보고 내가 쓴다.
다 쓰면 [ ] 를 x 로 바꾼다.
막히면 §5 모범답안(A)의 해당 단계만 본다.
2-A. build_and_count_good — Level 1 (1회차, 전부 본다)
먼저 Bad 를 그대로 쳐서 §3 의 test_bad_is_vulnerable 이 통과하는지(= 실제로 뚫리는지) 확인한다.
그다음 Good 을 쓴다.
int build_and_count_good(const char *userArg){ /* [ ] 1. 지역변수 */ /* [ ] 2. 위험 문자 검사 (조립 전) */ /* [ ] 3. snprintf 로 "cat %s" 조립 */ /* [ ] 4. return mock_shell_command_count(cmd) */}
2-C. build_and_count_good — Level 3 (3회차, 백지)
int build_and_count_good(const char *userArg){ /* 아무것도 안 본다. * §1 요구사항서와 §0 의 치환 규칙만 보고 친다. * * 막히면 아래만 찾아본다. * - `strpbrk` 의 헤더 (`<string.h>`) * - C 문자열 안에서 `\` 와 `"` 이스케이프 */}
막혔을 때 찾는 순서
컴파일 에러가 나면 에러 문구를 본다.
unknown type name / implicit declaration 이면 include 누락이다.
man 3 <함수> 를 연다.
SYNOPSIS 맨 위의 #include 를 확인한다.
로직이 막히면 §3 테스트의 기대값을 먼저 읽는다.
공격 입력표(§1)와 테스트가 답이다.
눈여겨볼 점: build_and_count_good 도 여전히 system()(mock)에 문자열을 넘긴다 — 블랙리스트는 차선책이다.
최종적으로는 execve("cat", (char *[]){"cat", userArg, NULL}, ...) 처럼 셸을 아예 거치지 않는 것이 정답이라는 걸 잊지 않는다(변형 C에서 화이트리스트로 한 걸음 더 나아간다).