---
title: "Rust가 못 잡는 버그들: uutils 44개 CVE에서 나온 시스템 코드 체크리스트"
published: 2026-04-29T02:19:11.000Z
canonical: https://jeff.news/article/1925
---
# Rust가 못 잡는 버그들: uutils 44개 CVE에서 나온 시스템 코드 체크리스트

Canonical이 Ubuntu 25.10부터 기본 포함한 Rust 기반 GNU coreutils 재구현체 uutils에서 44개 CVE를 공개했다. 이 버그들은 버퍼 오버플로 같은 메모리 안전 문제가 아니라, 경로 TOCTOU, Unix 바이트 처리, panic, GNU 호환성 차이처럼 Rust 컴파일러가 잡아주지 않는 시스템 경계의 문제였다.

## Rust 컴파일러가 막아준 것과 못 막아준 것

- Canonical이 2026년 4월 uutils에서 44개 CVE를 공개함
  - uutils는 GNU coreutils를 Rust로 다시 구현한 프로젝트고, Ubuntu 25.10부터 기본 탑재됐음
  - 26.04 LTS를 앞두고 외부 감사를 진행하면서 다수 문제가 나온 것으로 보임
  - `cp`, `mv`, `rm`이 Ubuntu 26.04 LTS에서 아직 GNU 버전으로 남는 이유도 이 문제 묶음과 관련 있음

- 중요한 포인트는 “Rust라서 안전하다”가 틀렸다는 게 아니라, Rust가 책임지는 안전의 범위가 다르다는 것임
  - 이번 버그들은 borrow checker, Clippy, `cargo audit`이 잡아줄 종류가 아니었음
  - 대신 파일 경로, syscall 순서, Unix byte 처리, panic, exit code, GNU 호환성 같은 시스템 경계에서 터짐
  - 반대로 버퍼 오버플로, use-after-free, double-free, 데이터 레이스, 널 포인터 역참조, 미초기화 메모리 읽기는 없었음

> [!IMPORTANT]
> Rust는 C에서 흔한 메모리 폭발을 많이 없애줬지만, “두 syscall 사이에 공격자가 경로를 바꿀 수 있다” 같은 현실 세계의 시간 문제는 컴파일러가 대신 해결해주지 않음.

## 경로를 두 번 믿으면 TOCTOU가 된다

- 가장 큰 버그 묶음은 경로를 한 번 확인하고, 다시 같은 경로에 행동하는 패턴이었음
  - 첫 syscall에서 경로 상태를 확인한 뒤, 두 번째 syscall에서 같은 문자열 경로를 다시 해석함
  - 그 사이 부모 디렉터리에 쓰기 권한이 있는 공격자가 경로 컴포넌트를 심볼릭 링크로 바꿀 수 있음
  - 커널은 두 번째 호출에서 경로를 새로 해석하므로, 권한 있는 작업이 공격자가 고른 대상에 꽂힘

- CVE-2026-35355 사례가 딱 이 패턴임
  - `install` 유틸리티가 먼저 목적지 파일을 지우고, 이후 `File::create(to)`로 다시 생성함
  - 삭제와 생성 사이에 공격자가 `to`를 `/etc/shadow`를 가리키는 심볼릭 링크로 바꾸면, 권한 있는 프로세스가 `/etc/shadow`를 덮어쓸 수 있음
  - 수정은 `OpenOptions::create_new(true)`를 써서 대상 위치에 파일이나 dangling symlink가 있으면 실패하게 만드는 방식이었음

- 일반 규칙은 `&Path`를 값처럼 믿지 말라는 것임
  - Rust 코드에서 `Path`는 안정적인 값처럼 보이지만, 커널 입장에선 매번 다시 해석하는 이름일 뿐임
  - 새 파일 생성은 `create_new()`가 도움이 되지만, 그 외 작업은 부모 디렉터리를 열고 file descriptor 기준으로 상대 작업을 해야 함
  - 같은 경로에 두 번 행동하면 TOCTOU라고 의심하고 증명해야 함

## 권한과 루트 체크도 문자열 감각으로 하면 터진다

- 권한은 만든 뒤 고치는 게 아니라, 만들 때부터 맞게 만들어야 함
  - `create_dir()`로 기본 권한 디렉터리를 만든 다음 `set_permissions(0o700)`을 호출하면 짧은 시간이지만 넓은 권한 상태가 존재함
  - 다른 사용자가 그 틈에 `open()`으로 file descriptor를 잡으면, 나중에 `chmod`해도 이미 열린 핸들은 사라지지 않음
  - `OpenOptions::mode()`나 `DirBuilderExt::mode()`로 생성 시점 권한을 지정해야 함

- `--preserve-root` 같은 보호 로직을 문자열 비교로 구현하면 우회가 쉬움
  - 기존 `chmod` 체크는 `file == Path::new("/")` 형태였음
  - `/../`, `/./`, `/usr/..`, `/`를 가리키는 심볼릭 링크는 문자열은 다르지만 실제 대상은 루트임
  - 수정은 `canonicalize`로 실제 경로를 해석한 뒤 `/`인지 비교하는 쪽으로 갔음

- 파일 정체성은 문자열이 아니라 `(dev, inode)`로 봐야 할 때가 많음
  - 루트 디렉터리는 부모가 없어 `canonicalize` 비교가 비교적 안전하지만, 일반 경로 비교는 여전히 공격자 교체 문제가 있음
  - GNU coreutils처럼 양쪽을 열고 device와 inode를 비교해야 “같은 파일인가”에 가까워짐
  - CVE-2026-35363처럼 `rm .`과 `rm ..`은 거부하면서 `rm ./`, `rm .///`은 받아 현재 디렉터리를 지우는 웃픈 버그도 나옴

## Unix 경계에서는 String이 아니라 byte로 살아야 한다

- Rust의 `String`과 `&str`은 항상 UTF-8이지만, Unix의 경로와 입력 스트림은 그렇지 않음
  - 경로, 환경변수, 인자, `cut`, `comm`, `tr` 같은 도구의 입력은 raw byte 세계에 있음
  - `from_utf8_lossy`는 invalid byte를 U+FFFD로 바꿔서 조용히 데이터를 망가뜨림
  - `unwrap`이나 `?`로 엄격 변환하면 유효하지 않은 UTF-8에서 크래시하거나 작업을 거부함

- CVE-2026-35346의 `comm` 버그가 대표적임
  - GNU `comm`은 바이너리 파일도 byte 그대로 처리함
  - uutils 버전은 `String::from_utf8_lossy`로 출력하면서 유효하지 않은 byte를 치환해버렸음
  - 수정은 `BufWriter`와 `write_all`로 raw byte를 stdout에 직접 쓰는 방식이었음

- 시스템 코드에서는 타입 선택이 곧 보안 선택임
  - 파일시스템 경로는 `Path`와 `PathBuf`, 환경변수는 `OsString`, 스트림 내용은 `Vec<u8>`나 `&[u8]`가 맞는 경우가 많음
  - 보기 편하다고 `String`으로 왕복시키는 순간 손상, 거부, panic 중 하나가 끼어들 수 있음
  - UTF-8은 앱 문자열 기본값으로는 좋지만 Unix 도구의 raw byte 기본값으로는 틀릴 수 있음

## panic과 버려진 에러는 운영 장애가 된다

- CLI에서 `unwrap`, `expect`, 인덱싱, unchecked arithmetic은 전부 잠재적 서비스 거부임
  - 공격자가 입력을 만들 수 있다면 panic은 프로세스를 죽임
  - cron job, CI 파이프라인, 셸 스크립트 안에서 죽으면 전체 작업 흐름이 멈춤
  - `sort --files0-from`은 NUL로 구분된 파일명 목록을 읽으면서 UTF-8 변환에 `expect()`를 써서 non-UTF-8 파일명에서 panic이 났음

- bad input은 panic이 아니라 에러로 바꿔야 함
  - `?`, `get`, `checked_*`, `try_from` 같은 패턴을 써서 호출자가 처리할 수 있는 실패로 올려야 함
  - Clippy에서 `unwrap_used`, `expect_used`, `panic`, `indexing_slicing`, `arithmetic_side_effects`를 경고로 두는 베이스라인도 제안됨
  - 테스트 코드에서는 panic이 자연스러우니 범위를 잘 나눠야 함

- 에러를 버리는 것도 실제 CVE로 이어짐
  - `chmod -R`, `chown -R`이 마지막 파일의 exit code만 반환해 중간 실패를 숨기는 문제가 있었음
  - `dd`는 `set_len()` 결과를 `.ok()`로 버렸다가 디스크가 꽉 찬 상황에서 반쪽짜리 출력 파일을 조용히 만들 수 있었음
  - `.ok()`, `.unwrap_or_default()`, `let _ =`를 쓸 때는 왜 그 실패를 무시해도 안전한지 설명이 필요함

> [!TIP]
> 시스템 Rust 코드 리뷰를 한다면 `from_utf8_lossy`, `unwrap()`, `expect()`, `File::create`, 버려진 `Result`, `"/"` 문자열 비교부터 grep해도 꽤 많은 냄새를 잡을 수 있음.

## 재구현에서는 예쁜 의미보다 호환성이 안전이다

- GNU coreutils와 다르게 동작하는 것 자체가 취약점이 될 수 있음
  - `kill -1`에서 GNU는 `-1`을 “signal 1”로 읽고 PID를 요구함
  - uutils는 이를 “기본 signal을 PID -1에 보내기”로 해석했고, Linux에서 PID -1은 볼 수 있는 모든 프로세스를 의미함
  - 오타 하나가 시스템 전체 kill switch가 되는 셈임

- battle-tested 도구를 다시 만들 땐 bug-for-bug 호환성도 보안 기능임
  - 셸 스크립트는 exit code, 에러 메시지, 옵션 의미, edge case에 의존함
  - “이쪽이 더 깔끔한 의미론인데?”라고 바꾼 부분에서 누군가의 운영 스크립트가 잘못된 결정을 할 수 있음
  - uutils가 GNU coreutils upstream test suite를 CI에서 돌리기 시작한 건 이 문제에 맞는 방어 규모임

- chroot의 CVE-2026-35368은 단일 버그 중 가장 심각한 사례로 소개됨
  - 코드 흐름은 `chroot(new_root)` 이후 `get_user_by_name(name)`을 호출하고, 그 다음 `setgid`, `setuid`, `exec`를 하는 구조였음
  - 문제는 `get_user_by_name`이 NSS를 통해 새 루트 파일시스템의 동적 라이브러리를 로드할 수 있다는 점임
  - 공격자가 chroot 안에 파일을 심어둘 수 있으면 uid 0 상태에서 코드 실행이 가능해짐
  - GNU chroot처럼 사용자 정보를 chroot 전에 해석하는 게 수정 방향임

- 결론은 Rust 시스템 코드의 안전 경계가 더 선명해졌다는 쪽에 가까움
  - Rust 덕분에 C coreutils에서 반복되던 메모리 취약점 종류는 상당히 사라짐
  - 대신 남은 버그는 경로, byte, syscall, 권한, 호환성처럼 OS와 만나는 경계에 몰림
  - 좋은 Rust는 borrow checker를 통과하는 코드가 아니라, 실행 환경의 지저분함을 타입과 흐름에 정직하게 반영하는 코드라는 얘기임

---

## 기술 맥락

- uutils의 선택은 GNU coreutils를 Rust로 재구현해 메모리 안전성을 크게 끌어올리는 거였어요. 왜냐면 C로 된 오래된 시스템 도구들은 버퍼 오버플로, use-after-free 같은 취약점 이력이 워낙 많았고, Rust는 이 범주를 구조적으로 줄여주거든요.

- 하지만 Unix 도구는 메모리 안에서만 사는 프로그램이 아니에요. 파일시스템 이름은 syscall마다 다시 해석되고, 경로는 공격자가 바꿀 수 있고, 입력은 UTF-8이 아닐 수 있어요. Rust 타입이 안전해도 외부 세계의 의미는 사람이 모델링해야 해요.

- `Path` 대신 file descriptor에 고정하거나, `String` 대신 byte와 `OsStr`을 쓰는 건 덜 예뻐 보여도 더 정직한 설계예요. 왜냐면 커널이 실제로 다루는 단위가 문자열 값이 아니라 열린 핸들, inode, raw byte인 경우가 많기 때문이에요.

- GNU 호환성을 테스트하는 것도 단순 품질 관리가 아니에요. coreutils는 수많은 자동화 스크립트의 암묵적 API라서 exit code 하나가 달라져도 보안 판단이나 배포 흐름이 틀어질 수 있거든요.

- 그래서 이 글의 체크리스트는 Rust 팀에도 바로 쓸 수 있어요. TOCTOU, UTF-8 변환, panic, 버려진 Result, chroot 전후 해석 순서처럼 컴파일러가 안 잡는 영역을 리뷰 룰과 CI lint로 따로 관리해야 해요.

## 핵심 포인트

- Rust는 메모리 안전 버그를 크게 줄였지만 파일시스템, 경로, 권한, 입력 바이트 같은 외부 세계의 의미까지 보장하진 않음
- `Path`를 두 번 syscall에 넘기면 심볼릭 링크 교체로 TOCTOU 취약점이 생길 수 있음
- Unix 도구는 UTF-8 문자열이 아니라 raw byte를 다뤄야 하며 `from_utf8_lossy`는 데이터 손상을 만들 수 있음
- GNU coreutils 재구현은 보기 좋은 의미론보다 bug-for-bug 호환성이 보안 기능이 될 수 있음

## 인사이트

Rust를 쓰면 C식 메모리 취약점은 크게 줄지만, ‘운영체제와 만나는 경계’는 여전히 사람이 설계해야 한다. 이 글은 Rust 비판이라기보다, 프로덕션 시스템 Rust에서 진짜 리뷰해야 할 체크리스트에 가깝다.
