Rust가 잡지 못하는 버그들
1 week ago
10
- 메모리 안전성은 크게 개선되지만, Rust 프로덕션 코드에서도 시스템 경계 처리 문제는 그대로 남아 취약점으로 이어질 수 있음
- 같은 경로를 여러 syscall에서 다시 해석하는 흐름, 생성 뒤 권한을 바꾸는 방식, 문자열 기반 경로 비교는 TOCTOU와 권한 노출 같은 문제를 만들기 쉬움
- Unix에서는 경로, 환경 변수, 스트림 데이터가 원시 바이트로 오가므로 String 중심 처리나 from_utf8_lossy, unwrap, expect는 데이터 훼손이나 DoS로 이어질 수 있음
- 오류를 버리면 실패가 성공처럼 보일 수 있고, GNU coreutils와의 동작 차이도 셸 스크립트와 privileged 도구에서 곧바로 보안 문제로 이어질 수 있음
- 이번 감사에서는 buffer overflow, use-after-free, double-free 같은 메모리 안전성 계열 버그는 나오지 않았고, 남은 핵심 위험은 Rust 내부보다 외부 세계와 맞닿는 경계에 집중돼 있었음
감사에서 드러난 Rust의 한계
- Canonical이 공개한 uutils의 44개 CVE는 Rust 프로덕션 코드에서도 borrow checker, clippy, cargo audit가 잡지 못하는 취약점이 남을 수 있음을 보여줌
- 문제의 중심은 메모리 안전성보다 시스템 경계 처리에 있었음
- 경로와 syscall 사이 시간차가 있었음
- Unix 바이트 데이터와 UTF-8 문자열이 어긋났음
- 원본 도구와의 동작 차이가 있었음
- 오류 처리 누락과 panic! 종료가 있었음
- 이 CVE 목록은 Rust 시스템 코드에서 안전성이 끝나는 지점을 압축해 보여줌
경로를 두 번 해석하면 TOCTOU가 생김
- 같은 경로를 한 syscall에서 확인하고 다음 syscall에서 다시 작업하면 TOCTOU 취약점으로 이어지기 쉬움
- 두 호출 사이에 상위 디렉터리에 쓰기 권한이 있는 공격자가 경로 구성 요소를 심볼릭 링크로 바꿀 수 있음
- 두 번째 호출에서 커널이 경로를 처음부터 다시 해석하면서 권한 있는 작업이 공격자가 고른 대상으로 향하게 됨
- Rust의 std::fs API는 &Path 기반 재해석을 기본으로 두어 이런 실수를 만들기 쉬움
- CVE-2026-35355에서는 파일 삭제 뒤 같은 경로에 새 파일을 만드는 흐름이 악용됨
- src/uu/install/src/install.rs에서 fs::remove_file(to)? 뒤 File::create(to)?가 이어졌음
- 삭제와 생성 사이에 to가 /etc/shadow 같은 대상을 가리키는 심볼릭 링크로 바뀌면 권한 있는 프로세스가 그 파일을 덮어쓸 수 있음
- 수정은 OpenOptions::create_new(true)을 써서 새 파일만 생성하도록 바뀜
- 문서상 create_new는 대상 위치에 기존 파일뿐 아니라 dangling symlink도 허용하지 않음
- 같은 경로에 두 번 작업해야 하면 파일 디스크립터에 고정하는 쪽이 안전함
- 새 파일 생성 외의 경우에는 부모 디렉터리를 한 번 열고 그 핸들 기준 상대 경로로 작업하는 편이 맞음
- 같은 경로에 두 번 작업하면 반증되기 전까지 TOCTOU로 봐야 함
권한은 생성 후 수정하지 말고 생성 시점에 정해야 함
- 디렉터리나 파일을 기본 권한으로 만든 뒤 나중에 chmod하는 흐름도 짧은 노출 구간을 만듦
- fs::create_dir(&path)? 후 fs::set_permissions(&path, Permissions::from_mode(0o700))?처럼 작성하면 그 사이 path가 기본 권한으로 존재함
- 다른 사용자는 그 창구간 동안 open()할 수 있고, 이후 chmod를 해도 이미 얻은 파일 디스크립터는 회수되지 않음
- 권한은 생성 시점에 함께 지정해야 함
- OpenOptions::mode()와 DirBuilderExt::mode()를 사용해 원하는 권한으로 태어나게 해야 함
- 커널은 여기에 umask를 추가로 적용하므로, 그 영향까지 중요하면 umask도 명시적으로 다뤄야 함
경로 문자열 비교는 파일시스템 동일성이 아님
- chmod의 초기 --preserve-root 검사는 문자열 비교만 했음
- recursive && preserve_root && file == Path::new("/")
- /../, /./, /usr/.., /를 가리키는 심볼릭 링크처럼 실제로는 루트를 가리키지만 문자열이 /가 아닌 입력은 이 검사를 우회함
- 수정은 fs::canonicalize로 경로를 실제 절대 경로로 해석한 뒤 비교하는 방식으로 바뀜
- 수정 PR
- canonicalize는 .., ., 심볼릭 링크를 해결한 실제 경로를 돌려줌
- --preserve-root의 경우 /는 부모 디렉터리가 없어 이 방식이 통함
- 두 임의 경로가 같은 파일시스템 객체인지 일반적으로 비교하려면 문자열이 아니라 (dev, inode) 를 비교해야 함
- GNU coreutils도 이런 방식으로 처리함
- CVE-2026-35363에서는 rm이 .과 ..는 거부하면서도 ./, .///는 허용해 현재 디렉터리를 지울 수 있었음
- 입력 형태 차이를 문자열 수준에서만 다루면 검사가 쉽게 비껴감
Unix 경계에서는 문자열보다 바이트를 우선해야 함
- Rust의 String과 &str은 항상 UTF-8이지만, Unix의 경로·환경 변수·인자·스트림 데이터는 원시 바이트 세계에 있음
- 이 경계를 넘을 때 잘못된 선택은 두 부류의 버그로 이어짐
- from_utf8_lossy 같은 손실 변환은 잘못된 바이트를 U+FFFD로 바꿔 조용히 데이터를 훼손함
- unwrap이나 ? 같은 엄격 변환은 입력을 거부하거나 프로세스를 종료시킬 수 있음
- comm의 CVE-2026-35346은 손실 변환으로 출력이 망가진 경우였음
- src/uu/comm/src/comm.rs에서 입력 바이트 ra, rb를 String::from_utf8_lossy로 바꿔 print!했음
- GNU comm은 바이너리 파일에서도 바이트를 그대로 옮기지만, uutils는 유효하지 않은 UTF-8을 U+FFFD로 바꿔 출력을 손상시켰음
- 수정은 BufWriter와 write_all로 raw bytes를 그대로 stdout에 쓰는 방식이었음
- print!는 Display를 거치며 UTF-8 왕복을 강제하지만, Write::write_all은 그렇지 않음
- Unix 계열 시스템 코드에서는 상황에 맞는 타입을 써야 함
- 포매팅 편의를 위해 String을 경유하면 데이터 훼손이 스며들기 쉬움
모든 panic은 서비스 거부로 이어질 수 있음
- CLI에서 unwrap, expect, 슬라이스 인덱싱, 검사 없는 산술, from_utf8는 공격자가 입력을 조절할 수 있을 때 DoS 지점이 될 수 있음
- panic!은 스택을 unwind하고 프로세스를 중단시킴
- cron job, CI pipeline, shell script에서 실행 중이면 전체 작업이 멈출 수 있음
- 반복 실행 환경에서는 crash loop로 시스템 전체를 마비시킬 수도 있음
- sort --files0-from의 CVE-2026-35348은 NUL 구분 파일명 목록에서 비 UTF-8 파일명을 만나면 중단됐음
- 파서는 각 이름 바이트에 std::str::from_utf8(bytes).expect(...)를 호출했음
- GNU sort는 파일명을 커널과 마찬가지로 raw bytes로 다루지만, uutils는 UTF-8을 강제하면서 첫 비 UTF-8 경로에서 전체 프로세스를 중단시켰음
- 신뢰할 수 없는 입력을 처리하는 코드에서는 unwrap, expect, 인덱싱, as cast를 잠재 CVE로 봐야 함
- ?, get, checked_*, try_from을 쓰고 실제 오류를 호출자에게 올려야 함
- CI에서 잡기 위한 clippy 기준도 제시됨
- unwrap_used
- expect_used
- panic
- indexing_slicing
- arithmetic_side_effects
- 테스트 코드에서는 이런 경고가 과도할 수 있어 cfg(test) 범위에서 제한하는 방식이 적절함
오류를 버리면 실패가 성공처럼 보일 수 있음
- 일부 CVE는 오류를 무시하거나 오류 정보가 사라지는 흐름에서 나왔음
- chmod -R와 chown -R는 전체 작업 중 마지막 파일의 종료 코드만 반환했음
- 앞선 다수 파일 처리에 실패해도 마지막 파일이 성공하면 0으로 끝날 수 있음
- 스크립트는 전체 작업이 문제없이 끝난 것으로 오판하게 됨
- dd는 /dev/null에서 GNU 동작을 흉내 내기 위해 set_len() 결과에 Result::ok()를 호출했음
- 의도는 제한된 상황에서 오류를 버리는 것이었지만 같은 코드가 일반 파일에도 적용됐음
- 디스크가 가득 찬 경우에도 반쯤만 써진 목적 파일이 조용히 남을 수 있었음
- .ok(), .unwrap_or_default(), let _ =로 Result를 버리면 중요한 실패 원인이 사라짐
- 첫 실패에서 바로 중단하지 않더라도 가장 심한 오류 코드를 기억해 종료해야 함
- 꼭 Result를 버려야 하면 그 실패를 왜 안전하게 무시할 수 있는지 이유를 코드에 남겨야 함
원본 도구와의 정확한 호환성도 안전 기능임
- 여러 CVE는 코드가 위험한 연산을 해서가 아니라 GNU와 다르게 동작해 생겼음
- 실제 셸 스크립트는 원본 GNU 동작에 의존하고 있어, 의미 차이가 보안 문제로 이어짐
- kill -1의 CVE-2026-35369가 대표적임
- GNU는 -1을 signal 1로 읽고 PID를 요구함
- uutils는 이를 PID -1에 기본 시그널 전송으로 해석했음
- Linux에서 PID -1은 볼 수 있는 모든 프로세스를 뜻하므로 단순한 오타가 시스템 전체 kill로 이어질 수 있음
- 재구현 도구에서는 bug-for-bug 호환성이 출구 코드, 오류 메시지, edge case, 옵션 의미까지 포함한 안전 장치가 됨
- GNU와 다른 동작이 있는 지점마다 셸 스크립트가 잘못된 판단을 내릴 가능성이 커짐
- uutils는 이제 CI에서 upstream GNU coreutils 테스트 스위트를 함께 돌림
- 이런 종류의 차이를 막기 위한 방어 규모로 적절해 보임
신뢰 경계를 넘기 전에 먼저 해석해야 함
- CVE-2026-35368은 chroot의 local root code execution이었음
- 문제 패턴은 chroot(new_root)? 후 공격자가 제어하는 새 루트 안에서 사용자 이름을 해석한 데 있었음
- get_user_by_name(name)?가 새 루트 파일시스템의 공유 라이브러리를 읽어 사용자 이름을 해석하게 됨
- 공격자가 chroot 내부에 파일을 심어두면 uid 0 코드 실행으로 이어질 수 있음
- GNU chroot는 사용자 해석을 chroot 이전에 수행함
- 신뢰 경계를 한 번 넘은 뒤에는 라이브러리 호출 하나하나가 공격자 코드를 실행시킬 수 있음
- 정적 링크도 이 문제를 막지 못함
- get_user_by_name은 NSS를 거치며 런타임에 libnss_* 모듈을 dlopen하기 때문임
Rust가 실제로 막아낸 버그들
- 이번 감사에서 발견되지 않은 버그 종류도 분명함
- buffer overflow는 없었음
- use-after-free도 없었음
- double-free도 없었음
- 공유 가변 상태의 data race도 없었음
- null-pointer dereference도 없었음
- uninitialized memory read도 없었음
- 도구에 버그가 있더라도 임의 메모리 읽기로 악용될 수 있는 종류는 감사 결과에 나오지 않았음
- GNU coreutils는 최근 몇 년간 이런 메모리 안전성 계열 CVE를 계속 내왔음
- pwd deep path buffer overflow
- numfmt out-of-bounds read
- unexpand --tabs heap buffer overflow
- od --strings -N heap buffer 바깥 NUL 쓰기
- sort heap buffer 앞 1바이트 read
- split --line-bytes heap overwrite인 CVE-2024-0684
- b2sum --check malformed input에서 unallocated memory read
- tail -f stack buffer overrun
- 같은 기간 비교에서 Rust 재구현은 이런 범주의 버그를 0건으로 유지했음
- 단, 감사가 메모리 안전성 버그 부재를 증명한 것은 아니고 발견하지 못했을 뿐이라는 단서도 붙음
- 남은 문제는 Rust 내부보다 외부 세계와 맞닿는 경계에서 주로 생김
- 경로
- 바이트와 문자열
- syscall
- 시간차와 파일시스템 상태 변화
올바른 Rust는 관용적 Rust이기도 함
- 관용적 Rust는 borrow checker를 통과하고 clippy가 조용한 코드에 그치지 않음
- 정확성도 관용성의 일부여야 함
- 현실에서 살아남는 코드 형태가 커뮤니티 경험을 통해 굳어졌기 때문임
- 견고한 시스템은 현실의 지저분함을 숨기기보다 그대로 반영해야 함
- 경로 대신 파일 디스크립터
- String 대신 OsStr
- unwrap 대신 ?
- 더 깔끔해 보이는 의미보다 원본과의 bug-for-bug 호환성
- 타입 시스템은 많은 것을 표현할 수 있지만 두 syscall 사이 시간 경과처럼 통제 밖 조건까지 담아내지는 못함
- 관용적 Rust는 코드의 타입, 이름, 제어 흐름이 실행 환경의 진실을 드러내야 함
- 보기 좋은 화이트보드 코드보다 덜 예쁘더라도 더 정직한 형태가 필요함
참고 자료
-
Homepage
-
Tech blog
- Rust가 잡지 못하는 버그들