Rust의 안전성이 어디까지 보호하고 어디서 끝나는지, uutils 감사에서 드러난 실제 CVE 사례를 통해 살펴봅니다.
2026년 4월, Canonical은 uutils에서 44개의 CVE를 공개했습니다. uutils는 GNU coreutils를 Rust로 다시 구현한 프로젝트로, 25.10부터 기본 탑재되고 있습니다. 이들 대부분은 26.04 LTS를 앞두고 의뢰된 외부 감사에서 나왔습니다.
저는 그 목록을 읽어 보면서 여기에서 배울 것이 정말 많다고 느꼈습니다.
눈에 띄는 점은 이 버그들이 모두 실제 운영 Rust 코드베이스에 들어갔다는 것입니다. 작성자들은 무엇을 하고 있는지 아는 사람들이었고, 그럼에도 이 버그들 중 어느 것도 borrow checker, clippy lints, cargo audit에 잡히지 않았습니다.
저는 이것을 uutils 팀을 비판하려고 쓰는 것이 아닙니다. 오히려 정반대입니다. 우리 모두가 배울 수 있도록 감사 결과를 이렇게 자세히 공유해 준 것에 실제로 감사하고 싶습니다.
또 최근에는 Ubuntu의 VP Engineering인 Jon Seager를 저희 ‘Rust in Production’ 팟캐스트에 모셨는데, 많은 청취자들이 Canonical에서의 Rust 현황에 대해 솔직하게 이야기해 준 점을 높이 평가했습니다.
Rust로 시스템 코드를 작성한다면, 지금 시점에서 Rust의 안전성이 어디서 끝나는지를 이보다 더 밀도 높게 보여 주는 자료를 찾기 어려울 것입니다.
이것은 감사에서 가장 큰 비중을 차지한 버그 군집입니다. 그리고 Ubuntu 26.04 LTS에서 cp, mv, rm이 여전히 GNU인 이유이기도 합니다. :(
패턴은 늘 같습니다. 어떤 path에 대해 무언가를 _확인_하기 위해 한 번 syscall을 하고, 같은 path에 대해 _동작_하기 위해 또 한 번 syscall을 합니다. 그 두 호출 사이에서 상위 디렉터리에 대한 쓰기 권한을 가진 공격자는 path 구성 요소를 심볼릭 링크로 바꿔치기할 수 있습니다. 그러면 커널은 두 번째 호출에서 path를 처음부터 다시 해석하고, 권한 있는 동작은 공격자가 고른 대상에 적용됩니다.
Rust 표준 라이브러리는 이 실수를 하기 쉽게 만듭니다. 처음 손이 가는 편리한 API들(fs::metadata, File::create, fs::remove_file, fs::set_permissions)은 모두 path를 받아서 매번 다시 해석합니다. 파일 디스크립터를 받아 그것을 기준으로 동작하지 않습니다. 일반 프로그램이라면 괜찮지만, 로컬 공격자에 대해서도 안전해야 하는 권한 있는 도구를 작성한다면 주의해야 합니다.
다음은 src/uu/install/src/install.rs에서 단순화한 버그입니다.
// 1. Clear the destination
fs::remove_file(to)?;
// ...
// 2. Create the destination. The path is re-resolved here!
let mut dest = File::create(to)?; // follows symlinks, truncates
copy(from, &mut dest)?;
1단계와 2단계 사이에서, 상위 디렉터리에 대한 쓰기 권한이 있는 누구든 to를 예를 들어 /etc/shadow를 가리키는 심볼릭 링크로 심어 둘 수 있습니다. 그러면 File::create는 그 심볼릭 링크를 따라가고, 권한 있는 프로세스는 from에 들어 있던 내용으로 /etc/shadow를 기쁘게 덮어써 버립니다.
수정은 OpenOptions::create_new(true)을 사용합니다.
fs::remove_file(to)?;
let mut dest = OpenOptions::new()
.write(true)
.create_new(true)
.open(to)?;
copy(from, &mut dest)?;
create_new의 문서는 이렇게 말합니다. (강조는 원문)
대상 위치에는 어떤 파일도 존재할 수 없으며, (끊어진) 심볼릭 링크도 안 됩니다. 이렇게 하면 호출이 성공했을 때 반환된 파일이 새 파일임이 보장됩니다.
Rust에서 &Path는 _값_처럼 보이지만, 커널 입장에서 그것은 그저 이름일 뿐이라는 점을 기억하세요. 그 이름은 syscall 사이마다 다른 것을 가리킬 수 있습니다. 대신 파일 디스크립터를 기준점으로 삼아 동작을 고정하세요.
create_new()는 새 파일을 만들 때만 이 문제를 도와줍니다. 그 외의 경우에는 부모 디렉터리를 한 번 열고 그 핸들을 기준으로 상대 경로로 작업하세요.
같은 path에 대해 두 번 동작한다면, 반대가 증명되기 전까지는 TOCTOU(Time Of Check To Time Of Use) 버그라고 가정하세요.
이것은 TOCTOU와 매우 가까운 친척입니다. 제한적인 권한을 가진 디렉터리를 원해서, 다음과 같은 코드를 작성한다고 해 봅시다.
// Create with default permissions
fs::create_dir(&path)?;
// Fix up permissions
fs::set_permissions(&path, Permissions::from_mode(0o700))?;
아주 짧은 순간이지만 path는 기본 권한으로 존재합니다. 그 창구 동안 시스템의 다른 사용자는 누구나 그것에 open()할 수 있습니다. 일단 파일 디스크립터를 얻고 나면, 나중의 chmod는 그 권한을 빼앗지 못합니다.
원하는 권한으로 파일이나 디렉터리가 태어나도록 OpenOptions::mode()와 DirBuilderExt::mode()를 사용하세요. 커널은 그 위에 umask를 적용하므로, 정말 중요하다면 그것도 명시적으로 설정하세요.
chmod의 원래 --preserve-root 검사는 말 그대로 이것이었습니다.
if recursive && preserve_root && file == Path::new("/") {
return Err(PreserveRoot);
}
이 비교는 /로 해석되지만 표기가 /는 아닌 모든 입력으로 우회됩니다. 예를 들어 /../, /./, /usr/.., 또는 /를 가리키는 심볼릭 링크가 그렇습니다. chmod -R 000 /../를 실행해 보면 이 검사를 그대로 통과해서 시스템 전체를 잠가 버릴 것입니다.
다음은 수정 사항입니다.
fn is_root(file: &Path) -> bool {
matches!(fs::canonicalize(file), Ok(p) if p == Path::new("/"))
}
if recursive && preserve_root && is_root(file) {
return Err(PreserveRoot);
}
canonicalize는 .., ., 심볼릭 링크를 실제 절대 경로로 해석합니다. 이것은 문자열 비교보다 훨씬 낫습니다.
아, 그리고 이 줄이 궁금했다면요.
matches!(fs::canonicalize(file), Ok(p) if p == Path::new("/"))
제 생각에는 이것은 그냥 이렇게 조금 멋들어지게 쓴 표현일 뿐입니다.
// First, resolve the path to its canonical form
if let Ok(p) = fs::canonicalize(file) {
// If that succeeded, check if the canonical path is "/"
p == Path::new("/")
} else {
false
}
--preserve-root라는 특정 경우에는 이것으로 충분합니다. /에는 부모 디렉터리가 없기 때문에, 공격자가 밑에서 뭔가를 바꿔치기할 여지가 없습니다. 하지만 파일시스템 동일성 관점에서 임의의 두 path를 비교하는 더 일반적인 경우라면, GNU coreutils가 하는 것처럼 둘 다 열어서 (dev, inode) 쌍을 비교하고 싶을 것입니다. (문자열 동등성이 아니라 동일성을 생각하세요.)
참고로 이 그룹에서 제가 가장 좋아하는 버그는 CVE-2026-35363입니다.
rm . # ❌
rm .. # ❌
rm ./ # ✅
rm ./// # ✅
.와 ..는 거부했지만 ./와 .///는 흔쾌히 받아들였고, 그러고는 Invalid input을 출력하면서 현재 디렉터리를 삭제했습니다. 😅
Rust의 String과 &str는 항상 UTF-8입니다. 이것은 전체 경우의 99%에서는 훌륭한 선택이지만, Unix path, 환경 변수, 인자, 그리고 cut, comm, tr 같은 도구를 통과하는 입력들은 바이트라는 지저분한 세계에 살고 있습니다.
Rust 프로그램이 그 간극을 건널 때마다 선택지는 셋뿐입니다.
from_utf8_lossy를 쓰는 손실 변환은 잘못된 바이트를 조용히 U+FFFD로 바꿔 씁니다. 이것은 그럴듯하게 포장된 데이터 손상일 뿐입니다.unwrap이나 ?를 쓰는 엄격한 변환은 프로그램을 죽이거나 동작을 거부합니다.OsStr나 &[u8]로 바이트 상태를 유지하는 것이 보통은 해야 할 일입니다.감사에서는 앞의 두 범주 모두에서 버그를 찾아냈습니다. 예를 하나 보겠습니다.
comm (CVE-2026-35346)다음은 src/uu/comm/src/comm.rs에서 가져온 원래 코드입니다.
// ra, rb are &[u8], raw bytes from the input files.
print!("{}", String::from_utf8_lossy(ra));
print!("{delim}{}", String::from_utf8_lossy(rb));
GNU comm는 그저 바이트를 이리저리 옮기기만 하기 때문에 바이너리 파일에서도 동작합니다. 하지만 uutils 버전은 유효한 UTF-8이 아닌 모든 것을 U+FFFD로 바꿔서, 출력이 조용히 손상되었습니다.
수정은 간단합니다. 바이트로 머무르세요.
let mut out = BufWriter::new(io::stdout().lock());
out.write_all(ra)?;
out.write_all(delim)?;
out.write_all(rb)?;
print!는 Display를 거치는 UTF-8 왕복 변환을 강제합니다. Write::write_all은 그렇지 않습니다. 원시 바이트를 직접 stdout에 씁니다.
Unix 계열 시스템 코드에서는 파일시스템 path에 Path와 PathBuf를, 환경 변수에는 OsString를, 스트림 내용에는 Vec<u8> 또는 &[u8]를 사용하세요. 포매팅을 쉽게 하려고 이것들을 String으로 왕복시키고 싶어지지만, 바로 그 지점에서 손상이 스며듭니다.
UTF-8은 애플리케이션 문자열에는 훌륭한 기본값이지만, Unix 도구가 다루는 원시 바이트 세계에는 절대로, 정말로 적절한 기본값이 아닙니다.
panic!을 서비스 거부로 취급하세요CLI에서는 모든 unwrap, 모든 expect, 모든 슬라이스 인덱스, 모든 검증되지 않은 산술 연산, 모든 from_utf8이 공격자가 입력을 조작할 수 있다면 잠재적인 서비스 거부가 됩니다. panic!은 스택을 되감고 프로세스를 중단시키기 때문입니다. 도구가 cron 작업, CI 파이프라인, 셸 스크립트 안에서 실행 중이었다면, 전체 작업이 그대로 멈춘다는 뜻입니다. 더 나쁘게는 시스템 전체를 마비시키는 크래시 루프에 빠질 수도 있습니다.
감사에서 대표적인 사례는 sort --files0-from(CVE-2026-35348)이었습니다. 이 플래그는 파일에서 NUL로 구분된 파일명 목록을 읽는데, 파서는 각 이름의 UTF-8 변환에 expect()를 호출하고 있었습니다.
// Inside sort.rs, simplified
let path = std::str::from_utf8(bytes)
.expect("Could not parse string from zero terminated input.");
GNU sort는 커널처럼 파일명을 원시 바이트로 취급합니다. 하지만 uutils 버전은 UTF-8을 요구했고, UTF-8이 아닌 path를 처음 만나는 순간 전체 프로세스를 중단했습니다.
$ python3 -c "open('list0','wb').write(b'weird\xffname\0')"
$ coreutils sort --files0-from=list0
thread 'main' panicked at uu_sort-0.2.2/src/sort.rs:1076:18:
Could not parse string from zero terminated input.: Utf8Error { valid_up_to: 5, error_len: Some(1) }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
(저는 이것을 macOS의 coreutils 0.2.2에서 재현했습니다. Python 원라이너를 넣은 이유는, 요즘 대부분의 셸이 UTF-8이 아닌 파일명을 직접 만들기를 거부하기 때문입니다.)
밤마다 도는 cron 작업은 죽었고, 당신의 주말도 함께 날아갑니다.
신뢰할 수 없는 입력을 처리하는 코드에서는 모든 unwrap, expect, 인덱싱, as 캐스트를 언젠가 CVE가 붙을 대기열이라고 생각하세요. ?, get, checked_*, try_from을 사용해서 실제 에러를 표면으로 올리세요. 애플리케이션 경계에서 밀어내고, 그 여파는 호출자가 처리하게 하세요.
CI에서 이것을 잡기 위한 괜찮은 lint 기준선은 다음과 같습니다.
[lints.clippy]
unwrap_used = "warn"
expect_used = "warn"
panic = "warn"
indexing_slicing = "warn"
arithmetic_side_effects = "warn"
이 lint들은 테스트 코드에서는 꽤 시끄럽습니다. 나쁜 데이터에 대해 panic하는 것이 정확히 원하는 동작이기 때문입니다. 이것들을 비테스트 코드에만 깔끔하게 적용하는 가장 좋은 방법은 각 crate 루트 맨 위에 #![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::indexing_slicing, clippy::arithmetic_side_effects))]를 두거나, 개별 #[cfg(test)] 모듈에서 #[allow(...)]를 조건부로 거는 것입니다.
앞선 논점과 밀접하게 연결되어 있는데, 몇몇 CVE는 에러 정보를 무시하거나 잃어버린 데서 나왔습니다.
chmod -R과 chown -R은 가장 나쁜 결과가 아니라 마지막으로 처리한 파일의 종료 코드를 반환했습니다. 그래서 chmod -R 600 /etc/secrets/*가 절반의 파일에서 실패하고도 종료 코드 0으로 끝날 수 있었습니다. 스크립트는 모든 것이 괜찮다고 믿게 됩니다.
dd는 /dev/null에서의 GNU 동작을 흉내 내기 위해 set_len() 호출 결과에 Result::ok()를 호출했습니다. 의도 자체는 이해할 만했지만, 같은 코드가 일반 파일에도 실행되었고, 그래서 디스크가 가득 차도 반쯤만 기록된 대상 파일이 조용히 남게 되었습니다.
원인은 누군가 Result를 버리고 싶어서 .ok(), .unwrap_or_default(), 또는 let _ =에 손을 뻗었기 때문입니다.
이를 피하기 위한 아주 단순한 패턴은 다음과 같습니다.
// Don't bail on the first error, but remember the worst one.
let mut worst = 0;
for file in files {
if let Err(e) = chmod_one(file) {
worst = worst.max(e.exit_code());
}
}
process::exit(worst);
또한 Result를 버리기 위해 .ok()를 쓴다면, 왜 _이 특정 실패_는 무시해도 안전한지 설명하는 주석을 남기세요.
놀랍게도 이런 CVE들 중 상당수는 “코드가 위험한 일을 한다”가 아니라 “코드가 GNU와 다르게 동작했고, 어딘가의 셸 스크립트가 GNU의 동작에 의존하고 있었다”는 종류입니다.
가장 분명한 예는 kill -1(CVE-2026-35369)입니다. GNU는 -1을 “시그널 1”로 읽고 PID를 요구합니다. 하지만 uutils는 이것을 “기본 시그널을 PID -1에 보내라”로 해석했고, Linux에서 이는 _당신이 볼 수 있는 모든 프로세스_를 뜻합니다. 큰일입니다!
오타 하나가 시스템 전체를 죽이는 스위치가 됩니다.
검증된 도구를 다시 구현한다면, 종료 코드, 에러 메시지, 극단 사례, 옵션 의미에서 버그까지 포함한 호환성은 보안 기능입니다. (Hyrum’s Law를 떠올려 보세요. 그리고 빠질 수 없는 XKCD 1172도요!)
원본과 동작이 달라지는 모든 지점에서, 누군가의 셸 스크립트는 잘못된 결정을 내리게 됩니다.
uutils는 이제 CI에서 업스트림 GNU coreutils 테스트 스위트를 자기 자신에게 돌립니다. 이런 종류의 버그를 막기 위한 방어선으로는 올바른 규모입니다.
CVE-2026-35368은 이 감사에서 단일 버그로는 가장 심각합니다. chroot에서 발생하는 로컬 root 코드 실행입니다. 무엇을 찾아야 하는지 알고 보면 버그가 보입니다. (chroot 다음에 동적 라이브러리를 불러오는 함수 호출이 이어집니다.) 하지만 처음 읽을 때 바로 눈에 들어오는 종류는 아닙니다.
다음은 chroot 유틸리티에서 단순화한 패턴입니다.
chroot(new_root)?;
// Still uid 0, but now inside the attacker-controlled filesystem.
let user = get_user_by_name(name)?;
setgid(user.gid())?;
setuid(user.uid())?;
exec(cmd)?;
음. 겉보기에는 무해합니다.
함정은 get_user_by_name이 사용자 이름을 해석하기 위해 결국 _새 루트 파일시스템_에서 공유 라이브러리를 로드하게 된다는 점입니다. 공격자가 chroot 안에 파일을 심을 수 있다면 uid 0으로 코드를 실행할 수 있게 됩니다.
GNU chroot는 chroot를 호출하기 전에 사용자를 해석합니다. 여기서도 수정은 같습니다.
let user = get_user_by_name(name)?;
chroot(new_root)?;
setgid(user.gid())?;
setuid(user.uid())?;
exec(cmd)?;
일단 경계를 넘고 나면, 모든 라이브러리 호출이 공격자의 코드를 실행할 수 있습니다. 그리고 아니요, 정적 링크는 여기서 도움이 되지 않습니다. get_user_by_name은 NSS를 거치며, 이것은 바이너리가 정적으로 링크되었는지와 무관하게 런타임에 libnss_* 모듈을 dlopen하기 때문입니다.
여기까지 읽고 “와, 버그가 정말 많네! Rust가 생각만큼 안전하지 않은 건가?”라고 느꼈을 수도 있습니다.
그 결론은 틀렸습니다.
다음과 같은 나쁜 일은 하나도 일어나지 않았다는 점을 기억하세요.
즉, 도구들이 버그투성이였을 수는 있어도, 임의 메모리를 읽도록 악용할 수 있는 버그는 하나도 없었다는 뜻입니다.
GNU coreutils는 이 모든 범주에서 CVE를 내보낸 적이 있습니다. GNU NEWS 파일의 최근 몇 년만 훑어 보세요.
2 * PATH_MAX보다 긴 path에서 pwd 버퍼 오버플로우 (9.11, 2026)numfmt out-of-bounds read (9.9, 2025)unexpand --tabs heap buffer overflow (9.9, 2025)od --strings -N이 heap buffer 뒤에 NUL 바이트를 씀 (9.8, 2025)sort가 SIZE_MAX key offset과 함께 heap buffer 앞의 1바이트를 읽음 (9.8, 2025)ls -Z와 ls -l 크래시 (9.7, 2025)split --line-bytes heap overwrite (CVE-2024-0684, 9.5, 2024)b2sum --check가 잘못된 입력에서 할당되지 않은 메모리를 읽음 (9.4, 2023)ulimit -n에서 tail -f stack buffer overrun (9.0, 2021)…목록은 끝이 없습니다. Rust 재작성판은 비슷한 활동 기간 동안 이런 종류의 문제를 하나도 내지 않았습니다.1 이것이 역사적으로 C 코드베이스에서 잘못되곤 하는 것들의 _대부분_입니다.
남는 것은 솔직히 더 흥미로운 종류의 버그입니다. 그것은 우리가 통제하는 Rust 환경과, path, 바이트, 문자열, syscall이 영원한 슬픔의 한 덩어리로 엉켜 있는 지저분하고 혼란스러운 바깥세상의 경계에 존재합니다. 이것이 현대 시스템 코드의 새로운 보안 경계입니다.2
Rust로 시스템 코드를 쓴다면, 이 CVE 목록을 체크리스트처럼 취급하세요. 자신의 코드베이스에서 from_utf8_lossy, 흘러다니는 unwrap() 호출, 버려진 Result, File::create, 그리고 "/"와의 문자열 비교를 grep해 보세요.
저는 Patterns for Defensive Programming in Rust라는 제목의 짝이 되는 글도 썼습니다.
제가 “idiomatic Rust”를 떠올릴 때, 정확성은 가장 먼저 생각나는 것이 아닙니다. 결국 그것은 컴파일러의 일 아닌가요? 대신 저는 우아한 iterator patterns, 쓰기 편한 메서드 시그니처, immutability, 또는 expressions의 영리한 사용을 떠올립니다. 하지만 코드가 올바른 일을 하지 않는다면 그런 것들은 아무 의미가 없고, 컴파일러는 정확성을 강제하는 데 결코 완벽하지 않습니다. 그래서 우리는 더 우아한 코드를 쓰기 위한 관용구만 갖고 있는 것이 아니라, 올바른 코드를 쓰기 위한 관용구도 갖고 있습니다. 그것들은 공동체가 종종 고통스럽게 배워 온 응축된 경험입니다. 어떤 코드 형태가 현실과의 접촉에서 살아남고 어떤 것은 그렇지 않은지를 말입니다.
현실은 우리가 거기에 덧씌우고 싶어 하는 추상화만큼 깔끔한 경우가 드뭅니다. 어떤 언어에서든 견고한 시스템의 표지는 그 지저분함을 덮어 버리기보다 반영하려는 의지입니다. Rust는 그렇게 할 수 있는 대단한 도구를 우리에게 주고, 컴파일러도 많은 부분을 대신 붙들어 줍니다. 하지만 그것이 붙들 수 없는 부분, 즉 우리 프로그램과 그 밖의 모든 것 사이의 경계는 여전히 우리가 올바르게 다뤄야 합니다. 타입 시스템은 많은 것을 표현할 수 있지만, 두 번의 syscall 사이에 시간이 흐르는 것처럼 자기 통제 밖의 조건까지 표현할 수는 없습니다.
그렇다면 관용적인 Rust란 단지 borrow checker가 받아 주거나 clippy가 그냥 넘어가는 코드가 아닙니다. 그것은 그 코드가 실행되는 시스템에 대해 타입, 이름, 제어 흐름이 _진실_을 말하는 코드입니다. 그리고 그 진실은 때로는 못생겼습니다. path 대신 파일 디스크립터를 쓰는 것, String 대신 OsStr를 쓰는 것, unwrap 대신 ?를 쓰는 것, 그리고 깔끔한 의미론보다 버그까지 포함한 호환성을 택하는 것을 뜻할 수 있습니다. 어느 것도 화이트보드 위에 적을 버전만큼 예쁘지는 않습니다. 하지만 훨씬 더 정직합니다.
당신의 팀이 Rust를 실제 운영 환경에 배포하고 있고 같은 함정에 빠지고 있지 않은지 확인하고 싶으신가요? 저는 코드 리뷰와 보안 중심 감사를 포함한 Rust 컨설팅 서비스부터, 컴파일러가 대신 강제해 주지 않는 패턴에 대한 팀 교육까지 제공하고 있습니다. 자세한 내용은 문의해 주세요.
GNU를 위해 공정하게 말하자면, GNU coreutils는 40년 된 프로젝트이고 이런 종류의 버그가 드러나고 수정될 시간을 아주 오래 가졌습니다. 그리고 Rust 재작성판에 메모리 안전성 버그가 _없다는 것을 우리가 아는 것_도 아닙니다. 단지 감사에서 발견하지 못했을 뿐입니다. 그래도 같은 기간의 개발 활동을 비교하면 차이는 분명합니다. ↩
덧붙이자면 Path/PathBuf TOCTOU 종류의 버그는 어떤 면에서는 Rust보다 C에서 오히려 더 피하기 쉬울 수 있습니다. C 코드는 자연스럽게 열린 파일 디스크립터와 *at 계열 syscall(openat, fstatat, unlinkat, mkdirat)을 향하고, 대부분의 생성 syscall은 mode 인자를 직접 받습니다. 반면 Rust의 고수준 std::fs API는 파일 디스크립터를 추상화하고 &Path 값에 대해 동작하므로, path 기반으로 매번 다시 해석하는 호출이 가장 저항이 적은 길이 됩니다. 핸들 기반 API는 모든 Unix 플랫폼에 존재합니다. Rust가 그것들을 전면에 내세우지 않을 뿐입니다. ↩