Rust가 잡지 못하는 버그들

1 week ago 11
  • 메모리 안전성은 크게 개선되지만, 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 계열 시스템 코드에서는 상황에 맞는 타입을 써야 함
    • 파일 경로에는 Path, PathBuf
    • 환경 변수에는 OsString
    • 스트림 내용에는 Vec<u8> 또는 &[u8]
  • 포매팅 편의를 위해 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는 코드의 타입, 이름, 제어 흐름이 실행 환경의 진실을 드러내야 함
    • 보기 좋은 화이트보드 코드보다 덜 예쁘더라도 더 정직한 형태가 필요함

참고 자료

Read Entire Article