| 항목 | 내용 |
|---|---|
| CVE ID | CVE-2026-35355 |
| 영향 소프트웨어 | uutils/coreutils (Rust로 재구현된 GNU coreutils) install 유틸리티 |
| 영향 버전 | < 0.6.0 (GitHub Security Advisory 기준) |
| 수정 버전 | 0.6.0 |
| CVSS v3.1 | 6.3 (Medium) — AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:H/A:H (Ubuntu Security Team) |
| 공개일 | 2026-04-22 (NVD) |
| 취약점 유형 | CWE-367계열 TOCTOU(Time-Of-Check to Time-Of-Use) 레이스 컨디션 — unlink 후 경로 기반 재생성 |
| 수정 커밋 | b5bbabc18a1121908848d836f869a4e98eb63886 (PR #10067) |
| 수정 파일 | src/uu/install/src/install.rs의 copy_file() |
install은 파일을 복사하면서 소유자·권한·SELinux 컨텍스트까지 함께 설정해주는
명령으로, 배포판 패키지 설치 스크립트나 빌드 시스템에서 흔히 root 권한으로 실행된다.
uutils 구현의 copy_file()은 대상 경로에 이미 파일이 있으면 fs::remove_file()로
지우고, 그 다음 다시 같은 경로 이름으로 파일을 새로 만들어 내용을 써넣는 방식으로
동작했다.
문제는 "지우기"와 "이름으로 다시 만들기" 사이에 시간 간격이 있고, 그 사이 대상 경로에
무엇이 존재하는지를 재생성 시점에 검증하지 않았다는 점이다. 대상 디렉터리에 쓰기 권한이
있는 공격자는 이 틈에 같은 경로를 심볼릭 링크로 다시 만들어 둘 수 있다. install이
사용하던 File::create()류 API는 경로가 심볼릭 링크면 그 링크를 그대로 따라가 실제
타깃 파일을 열어버리므로, root 권한으로 실행 중이던 install이 공격자가 지정한
임의의 시스템 파일(/etc/shadow 등)을 덮어쓰게 만들 수 있다.
파일 시스템을 다루는 코드가 "이 경로가 어떤 상태인지 확인한다(Check)"와 "그 경로에 대해 실제 작업을 수행한다(Use)"를 서로 다른 시스템 콜로 나눠서 처리하면, 그 사이에 다른 프로세스가 경로가 가리키는 대상을 바꿔치기할 수 있다. 파일 시스템 경로는 프로세스 간에 공유되는 전역 자원이기 때문에, "확인"과 "사용" 사이에 원자성이 없으면 확인 당시 봤던 대상과 실제로 작업이 적용되는 대상이 달라질 수 있다. 이런 종류의 버그를 TOCTOU 레이스라고 부른다.
유닉스 계열 파일 시스템에서 경로 이름과 그 이름이 가리키는 실제 파일(inode)은
분리되어 있다. 심볼릭 링크는 "다른 경로를 가리키는 특수 파일"이며, 대부분의 파일
열기 API는 기본적으로 심볼릭 링크를 투명하게 따라간다 — 즉 open("/tmp/target")을
호출했는데 /tmp/target이 /etc/shadow를 가리키는 심볼릭 링크라면, 커널은 실제로
/etc/shadow를 연다. 이 자체는 정상 동작이지만, 어떤 프로그램이 "내가 이전에 확인한
그 파일"이라고 믿고 경로 이름만으로 다시 접근한다면, 그 사이 경로가 심볼릭 링크로
바뀌었을 경우 전혀 다른 파일을 조작하게 된다.
이를 막는 표준적인 방법은 파일을 열 때 O_EXCL 플래그를 O_CREAT와 함께 쓰는
것이다. O_CREAT | O_EXCL로 여는 호출은 그 경로에 무엇이든(일반 파일이든 심볼릭
링크든) 이미 존재하면 무조건 실패한다. 존재 여부 확인과 생성이 커널 안에서 원자적으로
처리되기 때문에, "확인 시점엔 없었는데 생성 시점엔 생겼다"는 레이스 자체가 성립하지
않는다. Rust 표준 라이브러리에서는 std::fs::OpenOptions::create_new(true)가 이
O_EXCL 의미론을 제공한다.
install src dst는 파일을 복사하되, 배포 관점에서 필요한 소유자/권한/SELinux 라벨을
함께 설정해주는 도구다. 패키지 매니저나 make install류 빌드 스크립트가 관리자
권한으로 반복 실행하는 경우가 많다는 점이 이 취약점의 위험도를 키운다 — 공격자
입장에서는 "언젠가 root가 install을 실행해 줄 대상 디렉터리에 쓰기 권한만 있으면"
레이스를 시도할 수 있고, install이 반복 실행되는 CI/배포 파이프라인에서는 레이스
성공 확률이 실질적으로 높아진다.
fn copy_file(from: &Path, to: &Path) -> UResult<()> {
...
// fs::copy fails if destination is a invalid symlink.
// so lets just remove all existing files at destination before copy.
if let Err(e) = fs::remove_file(to) {
if e.kind() != std::io::ErrorKind::NotFound {
show_error!(...);
}
}
let ft = match metadata(from) { ... };
#[cfg(unix)]
if ft.is_char_device() || ft.is_block_device() || ft.is_fifo() {
let mut handle = File::open(from)?;
let mut dest = File::create(to)?;
copy_stream(&mut handle, &mut dest)?;
return Ok(());
}
copy_normal_file(from, to)?; // 내부적으로 fs::copy(from, to) 호출
Ok(())
}일반 파일 경로는 copy_normal_file()을 거쳐 std::fs::copy(from, to)를 호출한다.
fs::copy는 Unix에서 대상을 OpenOptions::new().write(true).create(true).truncate(true)로
연다 — O_CREAT | O_TRUNC이지만 O_EXCL은 없다. 특수 파일 경로(문자/블록
디바이스, FIFO)는 File::create(to)를 직접 쓰는데, 이 역시 내부적으로 O_CREAT만
쓰고 O_EXCL은 쓰지 않는 동일한 문제를 가진다.
즉 두 경로 모두 "①remove_file로 지운다 → ②경로 이름으로 다시 연다"는 2단계 구조이고,
②에서 경로가 이미 존재하면(공격자가 심볼릭 링크를 심어놨다면) 실패하지 않고 그 대상을
그대로 열어 덮어쓴다.
프로젝트가 공개한 재현 절차(GitHub 이슈 #10023)를 정리하면 다음과 같다.
┌─────────────────────────────┬─────────────────────────────────────┐
│ 터미널 1 (반복 실행되는 │ 터미널 2 (쓰기 권한이 있는 공격자) │
│ 특권 install) │ │
├─────────────────────────────┼─────────────────────────────────────┤
│ loop: │ loop: │
│ sudo install source.txt \ │ rm -f /tmp/attacker-writable/target│
│ /tmp/attacker-writable│ ln -s /etc/shadow \ │
│ /target │ /tmp/attacker-writable/target │
└─────────────────────────────┴─────────────────────────────────────┘
시간 ──────────────────────────────────────────────────────────────▶
install: remove_file(target) ─┐
│ ← 이 틈에 공격자가
│ ln -s /etc/shadow target 실행
install: fs::copy(source, target)
= OpenOptions{create,write,truncate}.open(target)
(O_EXCL 없음)
│
target이 지금 /etc/shadow를 가리키는 심볼릭 링크
→ open()이 심볼릭 링크를 따라가 실제로 /etc/shadow를 오픈
→ source.txt 내용으로 /etc/shadow가 덮어써짐
install이 충분히 자주(예: 배포 스크립트, CI, 패키지 재설치) 실행되는 환경이라면,
공격자는 rm -f + ln -s를 tight loop로 반복하는 것만으로 이 좁은 윈도우를 통계적으로
맞출 수 있다. 성공하면 루트 권한 프로세스가 공격자가 지정한 임의의 시스템 파일을
source.txt 내용으로 통째로 덮어쓰므로, 결과는 무결성 파괴(예: /etc/shadow 손상으로
인한 로그인 불가)부터 상황에 따라 권한 상승으로 이어질 수 있는 임의 파일 쓰기까지
다양하다.
- "존재 확인/제거"(
remove_file)와 "생성"(fs::copy/File::create)이 서로 다른 두 시스템 콜로 나뉘어 있고, 그 사이 경로 상태에 대한 원자적 보장이 전혀 없었다. - 생성 단계에서
O_EXCL의미론을 쓰지 않아, 그 틈에 무엇이 다시 생기든(특히 심볼릭 링크) 그대로 따라가 버렸다. copy_file()의 주석("fs::copy는 유효하지 않은 심볼릭 링크가 대상이면 실패하므로 미리 지운다")은 "깨진 심볼릭 링크를 정리한다"는 기능적 필요에서 나온 것이었지만, 그 지우기와 재생성 사이의 시간 간격이 보안 경계라는 점은 고려되지 않았다.
패치(PR #10067)는 재생성 단계에서 create_new(true)(= O_CREAT | O_EXCL)를 사용해
"지우기"와 "생성" 사이에 무엇이 다시 나타나더라도 안전하게 실패하도록 바꿨다.
- // fs::copy fails if destination is a invalid symlink.
- // so lets just remove all existing files at destination before copy.
+ // Remove existing file at destination to allow overwriting
+ // Note: create_new() below provides TOCTOU protection; if something
+ // appears at this path between the remove and create, it will fail safely
if let Err(e) = fs::remove_file(to) {
if e.kind() != std::io::ErrorKind::NotFound {
show_error!(...);
}
}
- let ft = match metadata(from) { ... };
-
- // Stream-based copying to get around the limitations of std::fs::copy
- #[cfg(unix)]
- if ft.is_char_device() || ft.is_block_device() || ft.is_fifo() {
- let mut handle = File::open(from)?;
- let mut dest = File::create(to)?;
- copy_stream(&mut handle, &mut dest)?;
- return Ok(());
- }
-
- copy_normal_file(from, to)?;
+ let mut handle = File::open(from)?;
+ // create_new provides TOCTOU protection
+ let mut dest = OpenOptions::new().write(true).create_new(true).open(to)?;
+
+ copy_stream(&mut handle, &mut dest).map_err(|err| {
+ InstallError::InstallFailed(from.to_path_buf(), to.to_path_buf(), err.to_string())
+ })?;
Ok(())핵심 변경은 fs::copy/File::create 대신 항상
OpenOptions::new().write(true).create_new(true).open(to)로 대상을 여는 것이다.
create_new(true)는 Unix에서 open(path, O_CREAT | O_EXCL | O_WRONLY, ...)으로
번역되므로, to 경로에 파일이든 심볼릭 링크든 뭔가 이미 존재하면 그 대상을 따라가지
않고 EEXIST 에러로 즉시 실패한다. 특수 파일(문자/블록 디바이스, FIFO) 처리 분기와
일반 파일 처리 분기가 하나의 경로로 합쳐진 것도 이 변경의 결과다 — 스트림 복사
(copy_stream)로 통일하면서 안전한 오픈 방식 하나만 유지하면 되도록 단순화됐다.
┌──────────────────────────────────────────────────────────────────┐
│ 패치 후 타임라인 (같은 공격 시도) │
├──────────────────────────────────────────────────────────────────┤
│ install: remove_file(target) │
│ │ │
│ │ ← 공격자가 다시 ln -s /etc/shadow target 실행 │
│ ▼ │
│ install: OpenOptions{write,create_new}.open(target) │
│ = open(target, O_CREAT|O_EXCL|O_WRONLY) │
│ │ │
│ └─ target 경로에 이미 무언가(심볼릭 링크) 존재 │
│ → EEXIST로 즉시 실패, 심볼릭 링크를 따라가지 않음 │
│ → /etc/shadow는 열리지도 않음 │
└──────────────────────────────────────────────────────────────────┘
레이스에서 공격자가 이겨 심볼릭 링크를 먼저 심어두더라도, install은 그 링크를
따라가는 대신 안전하게 에러를 내고 종료한다. 프로젝트는 회귀 테스트
(test_install_normal_file_replaces_symlink)로 "대상이 심볼릭 링크여도 그 링크가
가리키는 파일이 아니라 심볼릭 링크 자체가 교체되어야 한다"는 동작을 고정했다.
사용자는 uutils-coreutils 0.6.0 이상으로 업데이트하면 된다. 배포판 패키지로 설치한
경우 해당 배포판의 백포트 여부를 확인해야 한다.
- 이 보고서는 공개된 GitHub 이슈(#10023)의 재현 절차를 정적으로 인용했을 뿐, 실제
레이스를 실행해 검증하지는 않았다. 레이스 성공률은 파일 시스템 종류, 디스크 I/O
지연,
install호출 빈도에 따라 달라질 수 있다. - Windows 등 심볼릭 링크 의미론이 다른 플랫폼에서 동일한 공격이 성립하는지는 이 문서의 분석 범위를 벗어난다. 재현 절차와 패치 모두 Unix 심볼릭 링크를 전제로 한다.
- CVE-2026-35355 — NVD — CVSS, 참조 링크
- GHSA-239g-2685-54x3 — uutils/coreutils Security Advisory — 영향 버전(
< 0.6.0)과 패치 버전(0.6.0) - install TOCTOU symlink race — Issue #10023 — 원 취약점 보고와 재현 절차
- install: prevent TOCTOU race attack — PR #10067 — 실제 수정 diff와 회귀 테스트