Skip to content

Latest commit

 

History

History
339 lines (289 loc) · 18.5 KB

File metadata and controls

339 lines (289 loc) · 18.5 KB

setuid Bash의 불완전한 권한 포기 — disable_priv_mode() saved-UID 미제거 (CVE-2019-18276)

메타데이터

항목 내용
CVE ID CVE-2019-18276
영향 버전 GNU Bash 5.0 patch 11 이하 (bash 5.1에서 수정, 배포판별 4.x 백포트도 존재)
CVSS v3.1 7.8 (High) — AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H
공개일 2019-11-27 (NVD 공개). 실제 수정 커밋은 2019-07-01 (bash devel 스냅샷)
수정 커밋 951bdaad7a18cc0dc1036bba86b18b90874d39ff ("commit bash-20190628 snapshot", 작성자 Chet Ramey)
CWE CWE-273 (Improper Check for Dropped Privileges)

개요

GNU Bash는 -p(privileged) 옵션 없이 setuid/setgid 상태로 실행되면, 시작 직후 disable_priv_mode()를 호출해 유효 UID/GID를 실제 UID/GID로 되돌려 "권한을 스스로 포기"한다. 그런데 bash 5.0 patch 11까지의 disable_priv_mode()는 이 포기 작업을 setuid()/setgid() 시스템 콜만으로 수행했다. 문제는 setuid 계열 함수가 이미 root가 아닌 프로세스에서 호출되면 유효 UID만 바꿀 뿐, 커널이 별도로 관리하는 saved-UID (saved-set-user-ID)는 건드리지 않는다는 POSIX/Linux의 표준 동작이다. 그 결과 disable_priv_mode()를 통과한 뒤에도 원래의 setuid 소유자 권한이 saved-UID 자리에 그대로 남아 있고, 이 상태에서 enable -f로 임의의 공유 오브젝트를 새 내장 명령으로 적재해 setuid(saved_uid)를 호출하면 그 잔존 saved-UID를 근거로 원래의 상승된 권한을 다시 얻을 수 있다. 즉 "권한을 껐다"는 shell의 판단과 커널이 실제로 기억하고 있는 자격 증명 상태가 어긋나는, 애플리케이션 레벨의 불완전한 권한 포기(incomplete privilege drop) 취약점이다.

사전 지식

bash의 "privileged mode"와 setuid 설치

bash는 역사적으로 setuid/setgid 루트 헬퍼로 설치되는 경우를 고려해 설계되어 있다. 실행 파일의 실제 UID(real UID)와 유효 UID(effective UID)가 다르면 — 즉 setuid 비트가 걸린 바이너리를 소유자가 아닌 사용자가 실행하면 — bash는 이를 "특권 상태로 시작되었다"고 판단하고 몇 가지 방어 로직을 적용한다. shell.cuidget()은 이 판단을 다음과 같이 내린다(실제 5.0 소스).

/* See whether or not we are running setuid or setgid. */
return (current_user.uid != current_user.euid) ||
	   (current_user.gid != current_user.egid);

이 결과가 전역 변수 running_setuid에 저장된다. 사용자가 명시적으로 -p 옵션을 주면 privileged_mode 플래그가 켜지고, 이 경우 bash는 상승된 권한을 계속 유지한 채로 동작한다. 반대로 -p 없이 setuid로 실행된 경우(running_setuid && privileged_mode == 0), bash는 "의도치 않게 특권으로 떠 있는 상태"로 보고 즉시 권한을 스스로 내려놓는다. 이 판단은 main()에서 다음 한 줄로 이어진다(shell.c).

if (running_setuid && privileged_mode == 0)
    disable_priv_mode ();

특권 모드(-p)로 남아 있는 동안 bash는 여러 추가 방어도 적용한다. 예를 들어 initialize_shell_variables()privileged_mode || running_setuid가 참이면 환경 변수로부터 셸 함수를 불러오지 않는다(variables.c). 여기서 참조하는 것이 바로 Shellshock 계열 취약점(CVE-2014-6271/7169, 이 저장소의 별도 리포트 참고)이 악용했던 BASH_FUNC_<이름>%% 형태의 환경변수 함수 임포트 메커니즘이다.

#define BASHFUNC_PREFIX		"BASH_FUNC_"
#define BASHFUNC_PREFLEN	10
#define BASHFUNC_SUFFIX		"%%"
#define BASHFUNC_SUFFLEN	2

/* If exported function, define it now.  Don't import functions from
   the environment in privileged mode. */
if (privmode == 0 && read_but_dont_execute == 0 &&
    STREQN (BASHFUNC_PREFIX, name, BASHFUNC_PREFLEN) &&
    STREQ (BASHFUNC_SUFFIX, name + char_index - BASHFUNC_SUFFLEN) &&
    STREQN ("() {", string, 4))
  { ... }

즉 bash는 "특권 모드에서는 환경변수를 통한 함수 주입을 차단한다"는 원칙을 이미 세워 두었다. 이 CVE는 그 원칙을 우회하는 게 아니라, 애초에 특권 모드를 끄는 절차 (disable_priv_mode()) 자체가 커널 레벨에서 불완전했다는 훨씬 더 근본적인 지점을 겨냥한다 — 함수 임포트 필터를 통과하지 못해도 상관없다, 권한을 내려놓았다는 착각 자체가 깨져 있기 때문이다.

UID 삼중 구조와 setuid()의 함정

리눅스를 포함한 POSIX 시스템은 프로세스마다 세 가지 UID를 관리한다: 실제 UID(real UID, "누가 이 프로세스를 실행했는가"), 유효 UID(effective UID, "지금 이 순간의 권한 검사 기준"), 그리고 saved UID(saved-set-user-ID, "예전에 이 프로세스가 가졌던, 나중에 다시 꺼내 쓸 수 있는 권한의 백업 저장소"). setuid 바이너리를 실행하면 커널은 유효 UID와 saved UID를 파일 소유자로 설정하고, 실제 UID는 실행자 본인으로 남긴다.

setuid(2) 매뉴얼 페이지는 setuid 호출자가 특권이 없는 경우(Linux 기준 CAP_SETUID capability가 없는 경우)의 동작을 다음과 같이 규정한다.

If the calling process is privileged (...has the CAP_SETUID capability...), the real UID and saved set-user-ID are also set. ... An unprivileged process may only set the effective user ID to the real user ID, the effective user ID, or the saved set-user-ID.

바꿔 말하면, root가 아닌 프로세스setuid(real_uid)를 호출하면 유효 UID만 real_uid로 바뀌고, saved UID는 원래 값(= setuid 바이너리 소유자의 UID) 그대로 남는다. 이는 버그가 아니라 POSIX가 의도적으로 설계한 "일시적으로 권한을 내렸다가, 나중에 다시 seteuid()로 되돌릴 수 있게 하기 위한" 표준 동작이다. root 프로세스에서 호출된 setuid()만 세 UID를 모두 동일하게 만들어 "돌이킬 수 없는 완전한 포기"가 된다.

문제는 bash의 disable_priv_mode()가 이 구분을 고려하지 않고, "root가 아닌 setuid 바이너리"라는 가장 흔한 케이스에서도 setuid() 한 번으로 "완전히 권한을 포기했다"고 가정했다는 점이다.

[일반적인 setuid 실행 흐름 — 개념도]

exec("bash-suid")
    │
    ▼
커널: real=user, effective=owner, saved=owner   (파일 소유자 = owner, 예: 특정 서비스 계정)
    │
    ▼
bash 시작 (running_setuid = true, -p 없음 → privileged_mode = false)
    │
    ▼
disable_priv_mode() 호출 — "이제 권한을 내려놓겠다"
    │
    ▼
비-root 프로세스가 setuid(real_uid) 호출
    │
    ├─ effective UID → real_uid로 변경          (겉보기엔 성공)
    └─ saved UID     → owner로 그대로 남음        ← 여기가 문제

취약점 분석

실제 소스: 패치 전 disable_priv_mode() (bash 5.0, shell.c)

void
disable_priv_mode ()
{
  int e;

  if (setuid (current_user.uid) < 0)
    {
      e = errno;
      sys_error (_("cannot set uid to %d: effective uid %d"), current_user.uid, current_user.euid);
#if defined (EXIT_ON_SETUID_FAILURE)
      if (e == EAGAIN)
	exit (e);
#endif
    }
  if (setgid (current_user.gid) < 0)
    sys_error (_("cannot set gid to %d: effective gid %d"), current_user.gid, current_user.egid);

  current_user.euid = current_user.uid;
  current_user.egid = current_user.gid;
}

current_user.uiduidget()getuid()로 읽어 둔 실제 UID다. 이 함수는 "유효 UID를 실제 UID로 되돌리면 권한을 포기한 것"이라는 전제 위에서 setuid() / setgid()만 호출한다. 그러나 앞서 설명한 것처러, setuid 바이너리를 소유자가 아닌 일반 사용자가 실행한 시점의 프로세스는 (유효 UID가 아직 owner라 해도) CAP_SETUID를 갖지 않는 일반 프로세스로 취급되므로, 이 setuid() 호출은 유효 UID만 current_user.uid로 낮추고 saved UID는 owner(파일 소유자) 값 그대로 남긴다. 함수 종료 후 bash 자신은 current_user.euid = current_user.uid로 갱신해 "이제 나는 평범한 사용자다"라고 믿지만, 커널이 프로세스 자격 증명 테이블에 들고 있는 saved UID는 여전히 owner다.

공격이 성립하는 경로

  1. setuid 바이너리(예: 소유자가 어떤 서비스 계정인 bash)를 일반 사용자가 -p 없이 실행한다. running_setuid가 참이 되어 disable_priv_mode()가 호출되고, 유효 UID/GID는 실제 UID/GID로 낮아진다 — 여기까지는 bash 스스로도, 겉으로 보기에도 "권한이 없는 셸"이다.
  2. 그러나 saved UID는 여전히 setuid 바이너리의 소유자 값으로 남아 있다. 이 값은 ps/id 같은 일반적인 확인 수단으로는 잘 드러나지 않는다.
  3. 공격자는 이 낮아진 권한 상태의 bash 안에서 enable -f ./malicious.so name 을 실행한다. enable -f는 bash의 동적 로딩형 내장 명령 기능으로, 지정한 공유 오브젝트를 현재 프로세스에 dlopen()으로 적재하고 그 안의 심볼을 새 내장 명령으로 등록한다 — 임의의 attacker-controlled 네이티브 코드가 bash 프로세스 자체의 컨텍스트 안에서 실행되는 지점이다.
  4. 적재된 공유 오브젝트가 생성자 함수(또는 새로 등록된 내장 명령 자체)에서 setuid(saved_uid)(= 원래 setuid 바이너리 소유자의 UID)를 호출한다. 이번에는 호출 시점의 프로세스가 여전히 "실제 UID == 유효 UID == 자신의 UID, saved UID == owner" 상태이므로, POSIX 규칙("유효 UID를 saved UID 값으로는 특권 없이도 바꿀 수 있다")에 따라 이 호출이 성공하고, 유효 UID가 다시 owner로 올라간다.
  5. 결과적으로 disable_priv_mode()가 "포기했다"고 믿었던 권한이 그대로 복구되어, 공격자는 원래 setuid 바이너리 소유자의 권한으로 이후 명령을 실행할 수 있다.
[패치 전 시퀀스]

exec(setuid-bash, owner=svcacct)
    │
    ▼
running_setuid=true, privileged_mode=false
    │
    ▼
disable_priv_mode()
    │  setuid(real_uid)  ← 비-root 프로세스가 호출
    │      effective UID → real_uid   (성공, 확인 가능)
    │      saved UID     → svcacct    (변경 안 됨!)              ← 취약점 지점
    ▼
겉보기엔 "권한 없는 셸" (euid == real_uid)
    │
    ▼
공격자: enable -f ./libpwn.so pwn
    │  dlopen()으로 공격자 제어 네이티브 코드가 bash 프로세스에 적재·실행
    ▼
공격자 코드: setuid(svcacct_uid) 호출
    │  saved UID(svcacct)와 일치 → 특권 없이도 성공
    ▼
effective UID → svcacct 로 복귀      ← 포기했던 권한이 되살아남

제약 조건

  • bash 바이너리가 실제로 setuid로 설치되어 있어야 한다(running_setuid 조건). 대부분의 주요 배포판은 기본적으로 /bin/bash를 setuid로 배포하지 않으므로, 이 취약점의 실질적 영향은 "직접 setuid bash를 깔아 쓰는" 드문 구성으로 제한된다 — Ubuntu 보안팀도 정확히 이 점을 근거로 자체 배포본에 대한 영향을 낮게(Low priority) 평가했다.
  • setuid 바이너리의 유효 UID가 0(root)인 경우는 영향을 받지 않는다. setuid()가 root 프로세스에서 호출되면 커널이 real/effective/saved UID를 모두 동일하게 만들기 때문에, "포기 후에도 saved UID가 남는" 이 문제 자체가 발생하지 않는다.
  • enable -f로 임의 공유 오브젝트를 적재할 수 있어야 한다(기본적으로 컴파일 옵션에 따라 활성화 여부가 다를 수 있음 — 이 세부 조건은 아래 "미해결/불확실 지점" 참고).

수정 방법

수정은 커밋 951bdaad7a18cc0dc1036bba86b18b90874d39ff ("commit bash-20190628 snapshot", Chet Ramey, 2019-07-01)의 shell.c 변경분이다. 커밋 로그에는 다음과 같이 기록되어 있다.

shell.c - disable_priv_mode: if we have setres[ug]id, use them over set[ug]id, which only set the save user-id and group-id if the process is running as root. From Ian Eldred Pudney in https://savannah.gnu.org/patch/?9822

실제 diff는 다음과 같다.

 disable_priv_mode ()
 {
   int e;

+#if HAVE_DECL_SETRESUID
+  if (setresuid (current_user.uid, current_user.uid, current_user.uid) < 0)
+#else
   if (setuid (current_user.uid) < 0)
+#endif
     {
       e = errno;
       sys_error (_("cannot set uid to %d: effective uid %d"), current_user.uid, current_user.euid);
@@
 	exit (e);
 #endif
     }
+#if HAVE_DECL_SETRESGID
+  if (setresgid (current_user.gid, current_user.gid, current_user.gid) < 0)
+#else
   if (setgid (current_user.gid) < 0)
+#endif
     sys_error (_("cannot set gid to %d: effective gid %d"), current_user.gid, current_user.egid);

configure.ac/config.h.in에도 setresuid/setresgid의 존재 여부를 검사하는 HAVE_DECL_SETRESUID/HAVE_DECL_SETRESGID 매크로가 함께 추가되었다. setresuid(ruid, euid, suid)는 real/effective/saved UID 세 값을 명시적으로, 한 번에 지정할 수 있는 Linux 확장 시스템 콜이다. 패치는 이 세 인자에 모두 current_user.uid(실제 UID)를 넘겨, "유효 UID만 낮추고 saved UID는 못 건드리는" setuid()의 구멍을 원천적으로 없앤다 — setresuid()를 쓸 수 있는 플랫폼에서는 saved UID까지 확실히 실제 UID로 맞춰지므로, 이후 setuid(saved_uid)로 권한을 복구할 근거 자체가 사라진다. (setresuid가 없는 플랫폼에서는 여전히 기존 setuid()로 폴백한다 — 이 경우 이론적 위험은 남아 있으나, Linux를 포함한 대다수 현대 유닉스 계열은 setresuid를 지원한다.)

이 수정은 bash 5.0의 번호가 매겨진 공식 패치(bash50-001 ~ bash50-018)에는 포함되지 않았다 — GNU 배포 서버(ftp.gnu.org/gnu/bash/bash-5.0-patches/)의 패치 파일을 모두 직접 확인했으나 disable_priv_mode/setresuid 관련 변경은 없었다. 대신 이 수정은 2019-07-01 개발 스냅샷 커밋으로 devel 브랜치에 들어갔고, 정식으로는 bash 5.1 릴리스(2020년 12월)에 처음 포함되어 배포되었다. 실제로 bash 5.1 소스의 shell.c를 직접 대조한 결과 HAVE_SETRESUID/setresuid() 분기가 반영되어 있음을 확인했다. Debian 보안 추적기 역시 이 CVE의 수정 버전을 5.1-2+deb11u1(즉 bash 5.1 계열)부터로 기록하고 있어, "5.0 계열에는 번호 붙은 공식 패치가 없고 5.1에서야 고쳐졌다"는 결론과 일치한다.

[패치 후 시퀀스]

exec(setuid-bash, owner=svcacct)
    │
    ▼
running_setuid=true, privileged_mode=false
    │
    ▼
disable_priv_mode()
    │  setresuid(real_uid, real_uid, real_uid)
    │      real UID      → real_uid
    │      effective UID → real_uid
    │      saved UID     → real_uid      ← 이제 owner 값이 완전히 사라짐
    ▼
겉보기에도, 커널 자격 증명 테이블상으로도 "권한 없는 셸"
    │
    ▼
공격자: enable -f ./libpwn.so pwn 시도
    │  적재된 코드가 setuid(svcacct_uid) 호출
    ▼
svcacct_uid는 real/effective/saved UID 어디에도 없음 → EPERM
    권한 복구 실패 — 상승된 권한이 되살아나지 않음

패치 전후의 차이는 "유효 UID만 낮추느냐, saved UID까지 포함한 세 UID 모두를 낮추느냐" 단 한 곳이지만, 그 차이가 enable -f 이후의 임의 코드 실행이 권한을 되찾을 수 있는지 여부를 가른다.

참고 자료

공개 PoC로는 Exploit-DB에 등록된 Bash 5.0 Patch 11 - SUID Priv Drop Exploit (EDB-ID 47726)가 검색으로 확인되었다(미검증, 실행하지 않음). 별도의 GitHub 전용 PoC 저장소는 검색 범위 내에서 발견하지 못했다.

미해결/불확실 지점

  • enable -f를 통한 동적 로딩 내장 명령 기능은 bash 컴파일 옵션(-DDYNAMIC_LOADING 등 빌드 타임 설정)에 따라 비활성화될 수 있다고 알려져 있으나, 이 문서에서는 bash 5.0 소스의 정확한 조건부 컴파일 지점(builtins/enable.def 등)까지 직접 대조하지는 못했다. 배포판에 따라 enable -f가 기본적으로 막혀 있어 이 공격 경로 자체가 성립하지 않을 수 있다.
  • "번호 붙은 bash 5.0 공식 패치에는 이 수정이 없다"는 점은 GNU 배포 서버의 bash50-001 ~ bash50-018 패치 파일 전체를 직접 내려받아 disable_priv_mode/ setresuid 문자열로 대조해 확인했지만, 혹시 이후 추가된 미확인 패치 번호(019 이후)가 존재할 가능성까지 완전히 배제하지는 못했다.
  • 각 리눅스 배포판(RHEL, SUSE, Gentoo 등)이 자사 4.x/5.0 패키지에 이 수정을 언제·어떤 커밋으로 백포트했는지는 배포판별 changelog를 하나하나 대조하지 않았고, Debian/Ubuntu 트래커에서 확인 가능한 버전 정보만 인용했다.