| 항목 | 내용 |
|---|---|
| CVE ID | CVE-2015-5602 |
| 영향 소프트웨어 | Sudo 1.8.15 미만 (sudoedit 기능) |
| CWE | CWE-264 (Permissions, Privileges, and Access Control) |
| CVSS v2 | 7.2 (High, AV:L/AC:L/Au:N/C:C/I:C/A:C) — NVD는 v3 점수를 채점하지 않음 |
| 공개일 | 2015-11-17 (NVD 등록), 수정은 Sudo 1.8.15 (2015-10-31 릴리스)에 포함 |
| 관련 이슈 | Sudo Bugzilla Bug #707 |
| 1차 수정 커밋 | c50cead8 "Add directory writability checks for sudoedit." (Sudo 1.8.15) |
| 보완 수정 커밋 | 68c1073f "Rewritten sudoedit_checkdir support that checks all the dirs in the path" (Sudo 1.8.16) |
sudoedit(= sudo -e)는 관리자가 visudo로 특정 사용자에게 "이 파일들만 관리자 권한으로
편집하게 허용"하도록 정교하게 제한할 수 있는 sudo의 기능이다. 그런데 이 제한은 파일의
경로 문자열만 검사할 뿐, 그 경로가 실제로 어떤 파일을 가리키는지는 파일시스템이
결정한다. 만약 허용된 경로의 중간 디렉터리 중 하나를 공격자가 자유롭게 쓸 수 있는 곳으로
치환할 수 있다면(심볼릭 링크로), 사용자는 겉보기엔 허용된 경로를 그대로 요청했지만 실제로는
전혀 다른(임의의) 파일을 root 권한으로 덮어쓸 수 있다. Sudo 1.8.15는 이런 심볼릭 링크
공격을 막기 위해 "파일을 열기 직전, 그 파일이 있는 디렉터리가 요청한 사용자에게 쓰기
가능한지" 검사하는 코드를 추가했다. 문제는 이 검사가 파일 바로 위의 디렉터리 한
단계만 확인했다는 것이다. /etc/sudoers에 /home/*/*/file.txt처럼 와일드카드가
경로의 여러 단계에 걸쳐 있으면, 공격자는 검사 대상이 아닌 더 상위 단계(자신이 소유한
디렉터리)에 심볼릭 링크를 심어 최종적으로 열리는 디렉터리를 원하는 곳으로 유도할 수
있었고, 이때 마지막 한 단계만 보는 검사는 "지금 열리는 디렉터리가 쓰기 가능한가?"라는
질문에는 정직하게 답했지만 "그 디렉터리에 도달하기까지 심볼릭 링크를 거쳤는가?"는 아예
묻지 않았다. 결과적으로 이 첫 수정은 불완전했고, 넉 달 뒤 Sudo 1.8.16에서 경로의 모든
디렉터리 단계를 하나하나 순회하며 검사하는 방식으로 다시 작성됐다.
가장 단순한 "관리자만 이 설정 파일을 편집하게 하고 싶다"는 요구를 구현하는 가장 나쁜
방법은 sudo vim /etc/foo.conf를 허용하는 것이다. 대부분의 텍스트 에디터(vim, emacs 등)는
그 안에서 셸 명령을 실행하거나 다른 파일을 여는 기능(:!sh, :e /etc/shadow)을 제공하기
때문에, 에디터 자체가 root 권한으로 뜨는 순간 사실상 그 사용자에게 무제한 root 셸을 준
것과 다름없다. sudoedit은 이 문제를 다음과 같은 3단계로 우회한다.
- root 권한으로 원본 파일을 읽어 사용자의 홈 디렉터리 등 임시 위치에 사용자 권한으로 쓸 수 있는 임시 복사본을 만든다.
- 그 임시 복사본을 사용자 자신의 권한으로 평소 쓰던 에디터(
$EDITOR/$VISUAL)로 연다 — 즉 에디터 프로세스 자체는 root가 아니라 일반 사용자로 실행된다. - 편집이 끝나면 다시 root 권한으로 임시 복사본의 내용을 원본 파일 경로에 덮어쓴다.
이 구조 덕분에 에디터 프로세스는 한 번도 root 권한을 갖지 않는다. 대신 root 권한으로 여는 지점이 딱 두 곳(1단계의 원본 읽기, 3단계의 원본 쓰기)으로 좁혀지는데, 이 CVE는 바로 그 좁혀진 지점 중 "root 권한으로 어떤 경로를 여는" 그 순간을 노린다.
유닉스 파일시스템에서 경로(path)와 그 경로가 실제로 가리키는 대상(inode)은 별개다.
/home/alice/note.txt라는 경로가 있을 때, alice가 자신의 홈 디렉터리 안에서 note.txt
자리에 심볼릭 링크(ln -s /etc/shadow note.txt)를 만들면, 그 경로를 여는 프로그램은
커널의 경로 탐색(path resolution) 과정에서 자동으로 심볼릭 링크를 따라가 실제로는
/etc/shadow를 열게 된다. root 권한으로 동작하는 프로그램이 사용자가 지정한(혹은 사용자가
쓸 수 있는 위치에 있는) 경로를 열 때 이런 치환을 검사하지 않으면, "이 경로만 허용했다"는
접근 제어가 사실상 무의미해진다. 이런 유형의 취약점을 흔히 "확인 시점(check)"과 "사용
시점(use)"이 분리되어 그 사이에 상태가 바뀔 수 있다는 뜻에서 TOCTOU(Time-Of-Check to
Time-Of-Use) 또는 단순히 "심볼릭 링크 공격"이라 부른다. sudoedit이 막아야 하는 것은
정확히 이것이다 — sudoers에 /home/*/*/file.txt를 허용했다면, 그 경로가 정말로 그
자리에 있는 평범한 파일을 가리켜야지, 공격자가 심볼릭 링크로 갈아치운 다른 곳을
가리켜서는 안 된다.
sudoers 파일의 명령/경로 지정에는 셸 글롭과 비슷한 와일드카드(*, ?, [...])를 쓸 수
있다. 예를 들어 관리자가 "각 사용자가 자신의 홈 디렉터리 아래 아무 하위 디렉터리에 있는
file.txt는 sudoedit으로 편집해도 된다"는 규칙을 /home/*/*/file.txt 형태로 작성했다고
하자. 여기서 첫 번째 *는 사용자 이름(예: alice)에 해당하고, 이 자체는 공격 대상이
아니다. 문제는 두 번째 * 다 — 이 자리는 사용자가 자신의 홈 디렉터리 안에 자유롭게
만들 수 있는 임의의 하위 디렉터리 이름과 매치된다. 즉 공격자는 /home/alice/<임의 이름>
자리에 평범한 디렉터리 대신 심볼릭 링크를 놓을 수 있고, sudoers 규칙의 문자열 매칭
자체는 이를 구분하지 않는다.
1.8.15 이전 sudo_edit_create_tfiles()(src/sudo_edit.c)는 사용자가 지정한 경로를
그냥 open(files[i], O_RDONLY, 0644)으로 열었다. 심볼릭 링크 여부나 그 파일이 있는
디렉터리의 소유권/쓰기 권한은 전혀 확인하지 않았다. sudoers가 /home/*/*/file.txt를
허용했다면, alice는 /home/alice/x 자리에 ln -s /etc/shadow x를 심고
sudoedit /home/alice/x/file.txt... (정확히는 /home/alice/x가 심볼릭 링크이므로
경로 탐색이 곧바로 링크 대상으로 넘어간다) 를 실행해, root 권한으로 열리는 파일을 원하는
곳으로 유도할 수 있었다.
Sudo 1.8.15(커밋 c50cead8)는 sudoedit_follow(파일 자체가 심볼릭 링크면 거부,
O_NOFOLLOW)와 sudoedit_checkdir(파일이 있는 디렉터리가 호출자에게 쓰기 가능하면 거부)
라는 두 가지 기본값 참(true) 옵션을 도입했다. 후자를 구현한 함수가
sudo_edit_open_nonwritable()이다.
/* src/sudo_edit.c (Sudo 1.8.15, 커밋 c50cead8) */
static int
sudo_edit_open_nonwritable(char *path, int oflags, mode_t mode)
{
char *base, *dir;
struct stat sb;
int dfd, fd;
base = strrchr(path, '/'); /* path를 "마지막 '/' " 딱 한 곳에서만 자른다 */
if (base != NULL) {
*base++ = '\0';
dir = path; /* dir = 파일 바로 위 디렉터리 */
} else {
base = path;
dir = ".";
}
if ((dfd = open(dir, O_PATH)) != -1) { /* dir을 연다 (커널이 심볼릭 링크를 알아서 따라감) */
if (fstat(dfd, &sb) == -1) { ... }
}
...
if (dir_is_writable(&sb, user_details.uid, user_details.gid,
user_details.ngroups, user_details.groups)) {
close(dfd);
errno = ENOTDIR;
debug_return_int(-1); /* dir이 호출자에게 쓰기 가능하면 거부 */
}
...
fd = openat(dfd, base, oflags, mode); /* 검사를 통과했으면 dir을 거쳐 파일을 연다 */
...
}strrchr(path, '/')는 경로 문자열에서 마지막 /를 기준으로 딱 한 번만 자른다. 즉 이
함수는 /home/alice/x/file.txt가 주어지면 dir = "/home/alice/x", base = "file.txt"로
쪼갠 뒤, dir이라는 이름의 디렉터리를 열어서(open(dir, O_PATH)) 그 결과(dfd)가
쓰기 가능한지만 검사한다. 그런데 여기서 핵심 허점이 생긴다 — open()은 경로 탐색 도중
만나는 심볼릭 링크를 커널이 알아서 투명하게 따라간다. dir 문자열 자체("/home/alice/x")
가 심볼릭 링크인지 아닌지는 이 함수의 관심사가 아니고, 오직 "그 경로를 열어서 도달한
최종 디렉터리"의 소유권/권한만 dir_is_writable()로 검사한다.
공격자가 /home/alice/x를 자신이 원하는 위치(예: 세계 쓰기 불가능해 보이는 어떤
디렉터리, 혹은 반대로 검사를 통과하도록 계산된 어떤 디렉터리)로 향하는 심볼릭 링크로
만들었다고 하자. sudo_edit_open_nonwritable()이 dir = "/home/alice/x"를 열면, 커널은
심볼릭 링크를 따라가 실제로는 링크가 가리키는 대상 디렉터리를 연다. 이 함수는 그렇게
최종적으로 도달한 디렉터리 하나의 소유권만 검사하므로, 그 최종 디렉터리 자체가
공격자에게 쓰기 불가능하기만 하면(예: /etc처럼) — 정작 거기 도달하기 위해 공격자
소유의 쓰기 가능한 디렉터리(/home/alice) 안에 심볼릭 링크가 있었다는 사실은 완전히
지나쳐 버린다. sudoers 규칙이 /home/*/*/file.txt처럼 두 단계의 와일드카드를 갖고
있어야만 이 우회가 성립하는 이유가 여기 있다 — 검사되는 것은 항상 파일 바로 위 "한 단계"
뿐이므로, 그 앞 단계(첫 번째 *가 매치하는 /home/alice 자체, 또는 그 사이의 임의
단계)에 심볼릭 링크를 심어 최종 목적지를 원하는 곳으로 유도하면 검사망을 그대로
피해간다. 반대로 와일드카드가 한 단계뿐인 규칙(/home/*/file.txt)이었다면 검사 대상인
"파일 바로 위 디렉터리"가 곧 와일드카드가 매치하는 그 디렉터리라서 우회의 여지가 훨씬
좁았다.
sudoers 규칙: /home/*/*/file.txt (두 번째 *는 공격자가 자유롭게 이름 붙이는 하위 디렉터리)
실제 경로: /home/alice/x/file.txt
│
▼
+-----------------------+
| /home/alice (attacker | <- 공격자가 완전히 소유/쓰기 가능
| 가 소유, 쓰기 가능) |
+-----------------------+
│
│ "x" 자리에 심볼릭 링크를 심음
▼
+-----------------------+
| x --(symlink)--> | <- 검사 대상이 아님! (마지막 '/' 이전은 안 봄)
| /some/other/dir |
+-----------------------+
│ open("/home/alice/x", O_PATH) 가 커널 레벨에서
│ 심볼릭 링크를 그대로 따라감
▼
+-----------------------+
| /some/other/dir | <- sudo_edit_open_nonwritable()이
| (attacker 소유 아님) | 검사하는 것은 오직 "여기"뿐
+-----------------------+ dir_is_writable() → false (쓰기 불가)
│ => 검사 통과! 계속 진행
▼
openat(dfd, "file.txt", ...) → 실제로는 /some/other/dir/file.txt 가 열림
(root 권한으로 읽고 쓰는 대상이 sudoers가 의도한 곳이 아님)
Sudo 1.8.16(커밋 68c1073f, 커밋 메시지 그대로 "This is a better fix for
CVE-2015-5602")는 sudo_edit_open_nonwritable()을 완전히 다시 작성해, 루트(또는
현재 디렉터리)부터 시작해 파일에 도달할 때까지 경로의 모든 구성 요소를 한 단계씩
openat()으로 순회하도록 바꿨다. 핵심 아이디어는 각 단계에서 "지금까지 지나온
디렉터리가 쓰기 가능했다면, 그다음 단계를 열 때는 반드시 O_NOFOLLOW를 강제해 심볼릭
링크를 만나면 그 자리에서 실패시킨다"는 것이다.
/* src/sudo_edit.c (Sudo 1.8.16, 커밋 68c1073f) — 핵심 순회 로직 발췌 */
for (;;) {
char *slash;
int subdfd;
if (fstat(dfd, &sb) == -1) { ... }
is_writable = dir_is_writable(&sb, user_details.uid, user_details.gid,
user_details.ngroups, user_details.groups);
while (path[0] == '/')
path++;
slash = strchr(path, '/');
if (slash == NULL)
break; /* 마지막 구성 요소(파일명) 도달 */
*slash = '\0';
if (is_writable)
/* 지금까지의 디렉터리가 쓰기 가능했다면 다음 단계는 심볼릭 링크 금지 */
subdfd = sudo_edit_openat_nofollow(dfd, path, dflags, 0);
else
subdfd = openat(dfd, path, dflags, 0);
*slash = '/';
close(dfd);
if (subdfd == -1)
debug_return_int(-1); /* 심볼릭 링크였거나 열기 실패 → 즉시 거부 */
path = slash + 1;
dfd = subdfd;
}기존 코드가 strrchr()로 마지막 / 하나만 잘라 "파일 바로 위 한 단계"만 봤다면, 이
루프는 strchr()로 경로를 앞에서부터 한 구성 요소씩 잘라가며 dfd(현재까지
내려온 디렉터리의 파일 디스크립터)를 매 단계 갱신한다. 그리고 매 단계마다 "지금 열려는
dfd가 사용자에게 쓰기 가능했는가"를 기억해 두었다가, 그다음 하위 구성 요소를 열 때
sudo_edit_openat_nofollow()(openat(dfd, path, oflags|O_NOFOLLOW, mode))로 강제해
그 자리에 심볼릭 링크가 있으면 곧바로 실패하게 만든다. 즉 "공격자가 쓸 수 있는 디렉터리
바로 다음에 심볼릭 링크를 놓는" 모든 지점을, 그것이 경로의 몇 번째 단계이든 상관없이
빠짐없이 검사한다. 이 변경과 함께 sudoedit_checkdir의 의미도 "직계 부모 디렉터리만
확인"에서 "경로상의 모든 디렉터리를 확인"으로 문서에 명시적으로 갱신됐고(doc/UPGRADE),
1.8.16부터는 이 옵션이 기본값으로 켜지도록 바뀌었다.
실제 경로: /home/alice/x/file.txt (여전히 "x" 자리는 공격자가 만든 심볼릭 링크)
dfd = open("/", ...)
│ 1단계: "home" 열기 (dfd가 쓰기 불가 디렉터리였으므로 openat 그대로)
▼
+-------------------+
| /home | dir_is_writable() → false
+-------------------+
│ 2단계: "alice" 열기
▼
+-------------------+
| /home/alice | dir_is_writable() → TRUE (attacker 소유)
+-------------------+
│ 3단계: "x" 열기 — 직전 단계가 쓰기 가능했으므로
│ O_NOFOLLOW 강제 적용!
▼
+-------------------+
| x --(symlink)--> | openat(dfd, "x", O_NOFOLLOW, ...) → ELOOP로 즉시 실패
| /some/other/dir | <- 겹치는 지점: 여기서 우회 시도가 막힘
+-------------------+
│
▼
edit 거부, "editing files in a writable directory is not permitted"
(심볼릭 링크였다면 "editing symbolic links is not permitted")
- NVD CVE-2015-5602 — 공식 CVE 설명("multiple wildcards",
/home/*/*/file.txt예시), CVSS v2 7.2. (검증됨) - Sudo Bugzilla Bug #707 — 최초 보고 이슈 트래커(사이트 접근 제한으로 본문은 직접 확인하지 못했으나, 아래 두 커밋 메시지가 모두 이 버그 번호를 명시적으로 인용함). (미검증 — 접근 제한)
- 수정 커밋
c50cead8— "Add directory writability checks for sudoedit.", sudo-project/sudo — Sudo 1.8.15에 포함된 1차 수정의 실제 diff(sudo_edit_open_nonwritable(),dir_is_writable()). (검증됨, 1차 자료) - 보완 수정 커밋
68c1073f— "Rewritten sudoedit_checkdir support...This is a better fix for CVE-2015-5602. Bug #707", sudo-project/sudo — Sudo 1.8.16의 전체 경로 순회 재작성 diff. (검증됨, 1차 자료) - sudo NEWS — Sudo 1.8.15 릴리스 노트(커밋
c50cead8의 NEWS 파일 변경분) — "Added a sudoers option to prevent sudoedit from editing files located in a directory that is writable by the invoking user." (검증됨, 1차 자료) - sudo doc/UPGRADE — 1.8.16 변경 안내(커밋
68c1073f) — "Previously, it would only check the parent directory of the file to be edited. In 1.8.16 and higher all directories in the path to be edited are checked..." (검증됨, 1차 자료) - sudo-project/sudo 태그
SUDO_1_8_15,SUDO_1_8_16— 각 릴리스 커밋의 실제 날짜(2015-10-31, 2016-03-17) 확인. (검증됨, 1차 자료)
- Bugzilla Bug #707 원문(최초 보고자의 정확한 재현 절차와 문구)은 사이트가 자동화된 접근을 차단(Cloudflare 챌린지)해 직접 확인하지 못했다. 이 리포트의 기술적 분석은 Bugzilla 본문이 아니라, 두 수정 커밋의 실제 diff와 코드 주석·NEWS·UPGRADE 문서 변경분만을 근거로 재구성한 것이다.
- 1.8.15 수정이 "왜 마지막 한 단계만 검사하도록 설계됐는지"에 대한 원 개발자의 설계 의도(의도적 단순화였는지, 단순 누락이었는지)는 커밋 메시지에 명시되어 있지 않아 확인하지 못했다.