Skip to content

Latest commit

 

History

History
290 lines (248 loc) · 19 KB

File metadata and controls

290 lines (248 loc) · 19 KB

sudoedit의 심볼릭 링크 보호가 "직계 부모 디렉터리"만 검사해 다중 와일드카드로 우회되는 문제 (CVE-2015-5602)

메타데이터

항목 내용
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에서 경로의 모든 디렉터리 단계를 하나하나 순회하며 검사하는 방식으로 다시 작성됐다.

사전 지식

sudoedit은 왜 존재하는가 — "에디터를 root로 실행"의 위험

가장 단순한 "관리자만 이 설정 파일을 편집하게 하고 싶다"는 요구를 구현하는 가장 나쁜 방법은 sudo vim /etc/foo.conf를 허용하는 것이다. 대부분의 텍스트 에디터(vim, emacs 등)는 그 안에서 셸 명령을 실행하거나 다른 파일을 여는 기능(:!sh, :e /etc/shadow)을 제공하기 때문에, 에디터 자체가 root 권한으로 뜨는 순간 사실상 그 사용자에게 무제한 root 셸을 준 것과 다름없다. sudoedit은 이 문제를 다음과 같은 3단계로 우회한다.

  1. root 권한으로 원본 파일을 읽어 사용자의 홈 디렉터리 등 임시 위치에 사용자 권한으로 쓸 수 있는 임시 복사본을 만든다.
  2. 그 임시 복사본을 사용자 자신의 권한으로 평소 쓰던 에디터($EDITOR/$VISUAL)로 연다 — 즉 에디터 프로세스 자체는 root가 아니라 일반 사용자로 실행된다.
  3. 편집이 끝나면 다시 root 권한으로 임시 복사본의 내용을 원본 파일 경로에 덮어쓴다.

이 구조 덕분에 에디터 프로세스는 한 번도 root 권한을 갖지 않는다. 대신 root 권한으로 여는 지점이 딱 두 곳(1단계의 원본 읽기, 3단계의 원본 쓰기)으로 좁혀지는데, 이 CVE는 바로 그 좁혀진 지점 중 "root 권한으로 어떤 경로를 여는" 그 순간을 노린다.

TOCTOU와 심볼릭 링크 공격의 일반적인 구조

유닉스 파일시스템에서 경로(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 와일드카드와 이 취약점의 무대

sudoers 파일의 명령/경로 지정에는 셸 글롭과 비슷한 와일드카드(*, ?, [...])를 쓸 수 있다. 예를 들어 관리자가 "각 사용자가 자신의 홈 디렉터리 아래 아무 하위 디렉터리에 있는 file.txt는 sudoedit으로 편집해도 된다"는 규칙을 /home/*/*/file.txt 형태로 작성했다고 하자. 여기서 첫 번째 *는 사용자 이름(예: alice)에 해당하고, 이 자체는 공격 대상이 아니다. 문제는 두 번째 * 다 — 이 자리는 사용자가 자신의 홈 디렉터리 안에 자유롭게 만들 수 있는 임의의 하위 디렉터리 이름과 매치된다. 즉 공격자는 /home/alice/<임의 이름> 자리에 평범한 디렉터리 대신 심볼릭 링크를 놓을 수 있고, sudoers 규칙의 문자열 매칭 자체는 이를 구분하지 않는다.

취약점 분석

1.8.15 이전: 아무 검사도 없었다

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 권한으로 열리는 파일을 원하는 곳으로 유도할 수 있었다.

1.8.15의 수정: sudo_edit_open_nonwritable() — "바로 위 디렉터리"만 검사

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)이었다면 검사 대상인 "파일 바로 위 디렉터리"가 곧 와일드카드가 매치하는 그 디렉터리라서 우회의 여지가 훨씬 좁았다.

텍스트로 그린 그림 — 패치 전(1.8.15): 마지막 한 단계만 검사

   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가 의도한 곳이 아님)

수정 방법

1.8.16의 재작성: 경로의 모든 디렉터리를 한 단계씩 검사

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부터는 이 옵션이 기본값으로 켜지도록 바뀌었다.

텍스트로 그린 그림 — 패치 후(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")

참고 자료

미해결/불확실 지점

  • Bugzilla Bug #707 원문(최초 보고자의 정확한 재현 절차와 문구)은 사이트가 자동화된 접근을 차단(Cloudflare 챌린지)해 직접 확인하지 못했다. 이 리포트의 기술적 분석은 Bugzilla 본문이 아니라, 두 수정 커밋의 실제 diff와 코드 주석·NEWS·UPGRADE 문서 변경분만을 근거로 재구성한 것이다.
  • 1.8.15 수정이 "왜 마지막 한 단계만 검사하도록 설계됐는지"에 대한 원 개발자의 설계 의도(의도적 단순화였는지, 단순 누락이었는지)는 커밋 메시지에 명시되어 있지 않아 확인하지 못했다.