| 항목 | 내용 |
|---|---|
| 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는 역사적으로 setuid/setgid 루트 헬퍼로 설치되는 경우를 고려해 설계되어 있다.
실행 파일의 실제 UID(real UID)와 유효 UID(effective UID)가 다르면 — 즉 setuid 비트가
걸린 바이너리를 소유자가 아닌 사용자가 실행하면 — bash는 이를 "특권 상태로 시작되었다"고
판단하고 몇 가지 방어 로직을 적용한다. shell.c의 uidget()은 이 판단을 다음과 같이
내린다(실제 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()) 자체가 커널 레벨에서 불완전했다는 훨씬 더 근본적인 지점을
겨냥한다 — 함수 임포트 필터를 통과하지 못해도 상관없다, 권한을 내려놓았다는 착각 자체가
깨져 있기 때문이다.
리눅스를 포함한 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로 그대로 남음 ← 여기가 문제
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.uid는 uidget()이 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다.
- setuid 바이너리(예: 소유자가 어떤 서비스 계정인 bash)를 일반 사용자가
-p없이 실행한다.running_setuid가 참이 되어disable_priv_mode()가 호출되고, 유효 UID/GID는 실제 UID/GID로 낮아진다 — 여기까지는 bash 스스로도, 겉으로 보기에도 "권한이 없는 셸"이다. - 그러나 saved UID는 여전히 setuid 바이너리의 소유자 값으로 남아 있다. 이 값은
ps/id같은 일반적인 확인 수단으로는 잘 드러나지 않는다. - 공격자는 이 낮아진 권한 상태의 bash 안에서
enable -f ./malicious.so name을 실행한다.enable -f는 bash의 동적 로딩형 내장 명령 기능으로, 지정한 공유 오브젝트를 현재 프로세스에dlopen()으로 적재하고 그 안의 심볼을 새 내장 명령으로 등록한다 — 임의의 attacker-controlled 네이티브 코드가 bash 프로세스 자체의 컨텍스트 안에서 실행되는 지점이다. - 적재된 공유 오브젝트가 생성자 함수(또는 새로 등록된 내장 명령 자체)에서
setuid(saved_uid)(= 원래 setuid 바이너리 소유자의 UID)를 호출한다. 이번에는 호출 시점의 프로세스가 여전히 "실제 UID == 유효 UID == 자신의 UID, saved UID == owner" 상태이므로, POSIX 규칙("유효 UID를 saved UID 값으로는 특권 없이도 바꿀 수 있다")에 따라 이 호출이 성공하고, 유효 UID가 다시 owner로 올라간다. - 결과적으로
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 이후의 임의 코드 실행이 권한을 되찾을 수 있는지
여부를 가른다.
- CVE-2019-18276 — NVD
- 수정 커밋 951bdaad7a18cc0dc1036bba86b18b90874d39ff — bminor/bash (GitHub 미러)
- 동일 커밋 — GNU Savannah 공식 저장소 (cgit)
- 원 패치 제안 — Ian Eldred Pudney, savannah.gnu.org/patch/?9822
- GNU Bash 5.0 공식 패치 목록 — ftp.gnu.org (해당 수정이 번호 붙은 5.0 패치에는 없음을 직접 확인한 소스)
- Debian Security Tracker — CVE-2019-18276 (수정 버전 5.1-2+deb11u1부터로 기록)
- Ubuntu Security — CVE-2019-18276 (자체 배포본 영향 평가: Low, "Ubuntu does not ship with bash setuid")
- Red Hat Bugzilla 1778309 — CVE-2019-18276 bash: when effective UID is not equal to its real UID the saved UID is not dropped
- setuid(2) man page — man7.org (real/effective/saved UID와 특권 없는 프로세스의 setuid() 동작 규정)
- Bash 5.0 Patch 11 - SUID Priv Drop — Packet Storm 공개 writeup
공개 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 트래커에서 확인 가능한 버전 정보만 인용했다.