1P by GN⁺ 22시간전 | ★ favorite | 댓글 1개
  • 메모리 안전성은 크게 개선되지만, 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이나 ? 같은 엄격 변환은 입력을 거부하거나 프로세스를 종료시킬 수 있음
  • commCVE-2026-35346은 손실 변환으로 출력이 망가진 경우였음
    • src/uu/comm/src/comm.rs에서 입력 바이트 ra, rbString::from_utf8_lossy로 바꿔 print!했음
    • GNU comm은 바이너리 파일에서도 바이트를 그대로 옮기지만, uutils는 유효하지 않은 UTF-8을 U+FFFD로 바꿔 출력을 손상시켰음
    • 수정은 BufWriterwrite_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-fromCVE-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 -Rchown -R는 전체 작업 중 마지막 파일의 종료 코드만 반환했음
    • 앞선 다수 파일 처리에 실패해도 마지막 파일이 성공하면 0으로 끝날 수 있음
    • 스크립트는 전체 작업이 문제없이 끝난 것으로 오판하게 됨
  • dd/dev/null에서 GNU 동작을 흉내 내기 위해 set_len() 결과에 Result::ok()를 호출했음
    • 의도는 제한된 상황에서 오류를 버리는 것이었지만 같은 코드가 일반 파일에도 적용됐음
    • 디스크가 가득 찬 경우에도 반쯤만 써진 목적 파일이 조용히 남을 수 있었음
  • .ok(), .unwrap_or_default(), let _ =Result를 버리면 중요한 실패 원인이 사라짐
  • 첫 실패에서 바로 중단하지 않더라도 가장 심한 오류 코드를 기억해 종료해야 함
  • Result를 버려야 하면 그 실패를 왜 안전하게 무시할 수 있는지 이유를 코드에 남겨야 함

원본 도구와의 정확한 호환성도 안전 기능임

  • 여러 CVE는 코드가 위험한 연산을 해서가 아니라 GNU와 다르게 동작해 생겼음
    • 실제 셸 스크립트는 원본 GNU 동작에 의존하고 있어, 의미 차이가 보안 문제로 이어짐
  • kill -1CVE-2026-35369가 대표적임
    • GNU는 -1signal 1로 읽고 PID를 요구함
    • uutils는 이를 PID -1에 기본 시그널 전송으로 해석했음
    • Linux에서 PID -1은 볼 수 있는 모든 프로세스를 뜻하므로 단순한 오타가 시스템 전체 kill로 이어질 수 있음
  • 재구현 도구에서는 bug-for-bug 호환성이 출구 코드, 오류 메시지, edge case, 옵션 의미까지 포함한 안전 장치가 됨
  • GNU와 다른 동작이 있는 지점마다 셸 스크립트가 잘못된 판단을 내릴 가능성이 커짐
  • uutils는 이제 CI에서 upstream GNU coreutils 테스트 스위트를 함께 돌림
    • 이런 종류의 차이를 막기 위한 방어 규모로 적절해 보임

신뢰 경계를 넘기 전에 먼저 해석해야 함

  • CVE-2026-35368chrootlocal 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는 코드의 타입, 이름, 제어 흐름이 실행 환경의 진실을 드러내야 함
    • 보기 좋은 화이트보드 코드보다 덜 예쁘더라도 더 정직한 형태가 필요함

참고 자료

Hacker News 의견들
  • GNU Coreutils 유지보수자로서 글은 흥미롭게 읽었지만, 내가 조금 써본 Rust에서는 std::fsTOCTOU race를 만들기가 너무 쉬웠음
    openat 비슷한 API가 표준 라이브러리에 결국 들어가길 바람

    그리고 경로를 비교하기 전에 resolve하라는 규칙에는 동의하지 않음
    일반적으로는 fstat를 호출해서 st_devst_ino를 비교하는 편이 더 낫고, 글에도 그 점은 일부 들어가 있었음

    덜 자주 고려되는 부작용은 성능 비용
    실제 예시로 아주 깊은 디렉터리 경로에서 cp는 0.010초인데 uu_cp는 12.857초가 걸렸음

    현실에서 이런 경로를 일부러 만드는 일은 드물겠지만, GNU 소프트웨어는 임의의 한계를 피하려고 매우 강하게 노력함
    https://www.gnu.org/prep/standards/standards.html#Semantics

    그리고 글에서 Rust 재작성은 비슷한 기간 동안 메모리 안전성 버그가 0개라고 했는데, 그건 사실이 아님 :)
    https://github.com/advisories/GHSA-w9vv-q986-vj7x

    • 맞음, std::fslowest common denominator 문제를 안고 있음
      Rust 1.0에 뭔가는 넣어야 했고, 안타깝게도 그 상태가 오래 굳어졌음

      uutils는 더 실수하기 어려운 std::fs 대체 API를 설계해 보기 좋은 장소라고 봄

    • 반대편 입장에서 이 관점을 이렇게 간결하게 설명해줘서 고마움

      여기서 뭘 배워야 하는지 묻고 싶음
      인터넷 글치고는 일부러 아주 공격적으로 묻는 편인데, 대비가 있어야 차이와 실수를 더 또렷하게 볼 수 있어서 그럼
      물론 시간이나 정신적 에너지를 써줄 의무는 전혀 없음

      왜 자꾸 속도, 성능, race condition, st_ino가 같이 따라오는지 궁금함
      지연시간, 실제 저장장치에 쓰는 일, 원자성, ACID, 유한한 정보 전달 속도 같은 것들이 결국 비슷한 본질로 수렴하는 듯 보임
      회계 같은 신뢰성 높은 시스템은 결국 ACID로 가야 하는 것 같고, 신뢰성이 낮은 시스템은 너무 빨리 잊혀져서 컴퓨터의 차이가 크지 않은 것처럼 느껴지기도 함

      또 일상적인 애플리케이션에서는 throughput이 정말 latency보다 더 중요한지도 궁금함

      그리고 C, Unix 계열 OS, GNU coreutils의 역사 때문에 inode 번호에 초점을 맞추는 건 이해가 가지만,
      아주 기본적인 예로 USB 메모리를 파일 저장용으로 그냥 제대로 동작하게 만드는 문제를 보면 어떨지 궁금함
      libc I/O 버퍼링, fflush, 커널 버퍼링, 멀티코어, 시분할, 여러 애플리케이션 동시 실행 같은 복잡성을 피하지 않고도 말임

    • 완전 초보라서 그런데, 왜 그냥 $(yes a/ | head -n $((32 * 1024)) | tr -d '\n')로 바로 cd하지 않고 while 루프가 필요했는지 궁금했음

      수정: 이해했음. -bash: cd: a/a/a/....../a/a/: File name too long 때문이었음

    • 혹시 봤는지 모르겠지만, wget 같은 GNU 유틸리티를 메모리 안전한 C++ subset으로 자동 변환하는 데모가 있음
      https://duneroadrunner.github.io/scpp_articles/PoC_autotranslation_of_wget

      위험한 C 요소를 동작이 대응되는 안전한 C++ 요소로 거의 1:1 치환하는 방식이라, 재작성처럼 새 버그와 새 동작 차이를 들여올 가능성이 더 낮아 보임

      원본 코드를 조금만 정리하면 변환은 완전 자동화될 수 있어서, 빌드 단계에서 원래 C 소스로부터 약간 느리지만 메모리 안전한 실행 파일을 만들 수 있음

    • 좀 멍청한 질문일 수 있지만, GNU Coreutils 쪽에서 자체적인 Rust 재작성을 검토하거나 계획 중인지 궁금함

  • Rust는 쓸 줄 알았겠지만, Unix API와 그 의미론, 함정에 충분히 익숙했던 건 아님
    저 실수들 대부분은 오래된 GNU coreutils나 BSD, Solaris 기반 개발자 관점에선 꽤 초보적인 축에 들어감
    그런 이슈는 수십 년 전에 이미 상당수 드러나고 정리됐고, 기존 코드베이스에는 지금도 긴 꼬리의 수정이 남아 있지만 이제는 대체로 소량만 계속 들어오는 수준임

    • Canonical 스레드를 읽고 정말 기가 막혔음
      요지는 대충 “Rust가 더 안전하고, 보안이 최우선이니, coreutils 전체 재작성 배포는 긴급하다. 뭔가 깨져도 괜찮고 나중에 고치면 된다”는 식이었음

      나는 그런 식으로 생각하는 사람들이 만든 코드를 내 머신에서 돌리고 싶지 않음
      나도 Rust에는 찬성하지만, Rust가 더 안전하다는 건 다른 조건이 같을 때만 성립함
      여기서는 다른 조건이 전혀 같지 않음

      재작성은 필연적으로 수십 년간 관리된 코드보다 훨씬 더 많은 버그와 취약점을 가질 수밖에 없어서, 보안 논리는 장기 전환 전략에서는 의미가 있어도 성급한 롤아웃의 근거는 못 됨

      배포 후에 사용자 영향은 별것 아니라고 축소하거나, “이렇게 해야 버그가 드러난다”, “기존 coreutils에도 제대로 된 테스트가 없었다”고 말하는 태도는 너무 무책임함
      사용자는 실험용 쥐가 아님
      유지보수자는 사용자 시스템의 신뢰성을 해치지 않을 도덕적 책임이 있다고 봄

    • 그보다 더 근본적으로, Rust 표준 라이브러리가 개발자를 잘못된 추상화 수준의 깔끔한 API로 유도하는 것처럼 보임
      예를 들면 핸들 기반 파일 작업 대신 경로 기반 작업 쪽으로 말임
      내가 틀렸길 바람

    • Rust의 요점은 가장 크고, 가장 쉽게 빠지는 함정은 굳이 신경 쓰지 않아도 되게 만드는 데 있다고 봄

      이 글의 핵심도 결국 파일 시스템 API가 그런 역할을 해야 한다는 데 있는 듯함

    • 누군가 이와 비슷한 표현으로 disassembler rage라는 말을 만든 적이 있음
      충분히 가까이서 들여다보면 모든 실수는 아마추어처럼 보인다는 뜻임

      디스어셈블러만 들여다보며, 콜스택 100프레임 아래 함수 안에서 왜 switch 대신 if를 썼냐고 고수준 프로그래머를 욕하는 태도에서 나온 말이기도 함

      지금 우리는 그들이 틀린 몇 가지만 보고 있고, 그 주변의 수천 줄 맞게 작성된 코드는 거의 보지 않음

    • 이런 유틸리티에서 panic이 나는 건 Rust 기준으로 봐도 꽤 아마추어한 실수
      처리 불가능한 alloc 에러 같은 거라면 모르겠지만, expectunwrap은 그 코드 경로가 절대 실행되지 않게 하는 불변식을 정말 엄격히 보장하는 경우가 아니면 변명하기 어려움

  • 코드를 재작성할 때 어려운 점 중 하나는, 원래 코드는 실제 운영 환경에서만 드러난 문제들에 대응하면서 점진적으로 변형돼 왔다는 데 있음

    그 과정에서 얻은 교훈이 코드 안에 조용히 스며들고, 문서화돼 있지 않으면 동등한 수준에 도달하기 전에 해야 할 숨은 작업이 엄청 많아짐

    원문은 바로 그런 종류의 리스트를 잘 보여줌

    그렇다고 곧바로 아마추어라고 부르기 전에, 이게 소프트웨어에서 가장 소프트웨어다운 현상 중 하나라는 점도 봐야 함
    coreutils에 정말 좋은 기술 문서와 해당 케이스를 담은 테스트가 있었는데도 무시한 게 아니라면, 이런 일은 거의 필연이었음

    • 글에 나온 좋은 예가 chroot + NSS CVE
      NSS가 동적이고, chroot 내부에서 라이브러리를 dlopen한다는 규칙은 어디에도 눈에 띄게 적혀 있지 않음

      그건 25년 넘게 시스템 관리자들이 몸으로 겪으며 알아낸 사실에 가깝고, 클린룸 재작성은 그걸 대개 새 CVE로 다시 배움
      같은 코드를 LLM으로 포팅해도 사정은 비슷함
      함수 시그니처는 읽을 수 있지만, 정말 필요한 건 그 코드에 남은 상처와 흉터들임

    • GPL을 피하려고 원본 소스도 읽지 않은 채 이 작업을 한다면 더 어려워짐

      내 생각엔 uutils가 GPL이었고, coreutils 원본 소스에서 직접 영감을 받아도 됐다면 훨씬 나았을 것임

    • 이런 교훈이나 최소한 피하려 했던 버그와 취약점을 문서화하지 않는 것도 나쁜 관행이라는 점은 분명히 해야 함

      물론 애초에 코드를 잘 짜서 암묵적으로 피한 모든 버그를 전부 문서로 남기긴 어렵지만,
      미래의 독자에게는 “여기서 bar 대신 foo를 쓰는 이유는 ABC 조건에서 bar를 쓰면 XYZ 때문에 위험한 baz가 생기기 때문” 같은 설명을 남기는 편이 중요함
      시간과 문서 공간이 좀 낭비돼 보이더라도 그게 낫다고 봄

  • 이 글에서 지적한 것들 중 상당수는, 특히 GNU coreutils 소스와 비교해 보면, 웬만한 unit test나 수동 리뷰에서 걸러졌어야 한다고 느낌
    coreutils 재작성은 끔찍한 아이디어처럼 보이고
    https://www.joelonsoftware.com/2000/04/06/things-you-should-never-do-part-i/
    이전 소프트웨어가 쌓아온 지식을 충분히 가져오지 못한 채 잘못된 방식으로 진행된 듯함

    재작성을 하려면 전작을 완전히 이해하고 배워야 함
    그렇지 않으면 같은 실수를 반복하게 되고, 솔직히 꽤 민망한 일임

    분명히 하자면 Rust는 좋아하고 여러 프로젝트에 쓰고 있으며 훌륭함
    다만 Rust가 나쁜 엔지니어링까지 구해주진 못함

    • 흥미롭게도 uutilsGNU coreutils 테스트 스위트를 사용함

      덧붙이면 GPL 소스를 읽고 작성한 기여는 받지 않겠다는 입장도 명시해 둔 상태임

    • unity, upstart, snap을 만든 쪽이라면 딱 이런 일도 예상 범위 안임

    • 새 시스템 프로그래머들에게는 이런 환영 인사를 해야 할 듯함
      Unix는 망가져 있고, 결국 못생기고 교육적이지도 않은 우회책을 직접 써야 하며, 경험적 테스트도 해야 함
      신뢰할 수 있는 소프트웨어와 좋은 소프트웨어 엔지니어링은 원래 그런 식으로 굴러감

  • differential fuzzing이 이런 버그를 못 잡았는지 궁금함

    https://github.com/uutils/coreutils/tree/main/fuzz/uufuzz

  • 경로에 대해 한 번 syscall로 확인하고, 같은 경로에 다시 syscall을 날려 작업하는 패턴은 늘 같은 문제를 부름
    부모 디렉터리에 쓰기 권한이 있는 공격자는 그 사이에 경로 구성 요소를 심볼릭 링크로 바꿔치기할 수 있고, 커널은 두 번째 호출에서 경로를 처음부터 다시 resolve해서 권한 있는 작업이 공격자가 고른 대상으로 가게 됨

    • 사실은 이보다 더 나쁨
      부모 디렉터리에 쓰기 권한이 있는 공격자는 하드링크로도 장난칠 수 있음
      정규 파일 쪽만 건드릴 수 있다고 해도 사실상 마땅한 완화책이 거의 없음
      예시는 https://michael.orlitzky.com/articles/posix_hardlink_heartache.xhtml 참고
    • 음… 디렉터리에 write lock을 거는 방법이 있을지도 모르겠지만, timeout 같은 문제까지 얽히면 금방 더 복잡해질 듯함
  • 몇몇 버그의 근본 원인은 Unix API가 너무 불투명하다는 데 있는 듯함

    예를 들어 get_user_by_name이 새 루트 파일시스템 안에서 공유 라이브러리를 로드해 사용자 이름을 해석하고, 그래서 chroot 안에 파일을 심을 수 있는 공격자가 uid 0으로 코드를 실행하게 된다는 건 거의 부비트랩처럼 느껴짐

    사용자 데이터를 얻는 함수가 갑자기 공유 라이브러리까지 로드하는 건 관심사가 섞인 설계로 보임
    사용자 데이터 조회와 라이브러리 로딩을 함수 차원에서 분리하거나, 적어도 이름만 봐도 그런 동작이 드러나게 해야 한다고 봄

    • 일부는 그럴 수 있어도, coreutils를 바닥부터 다시 쓰기로 했다면 POSIX API를 이해하는 것 자체가 문자 그대로 핵심 업무임

      게다가 경로가 파일시스템 루트를 가리키는지 검사하는 코드가 file == Path::new("/")였다면, 그건 API 문제가 아님
      그렇게 쓴 사람은 이 프로젝트에 참여할 자격이 거의 없어 보임

    • 오히려 함수형 안전 언어를 쓰면 다루는 데이터도 무상태라고 착각하게 만들 수 있다고 봄
      하지만 운영체제에서는 정말 많은 것이 늘 바뀜

      스냅샷을 제공하는 파일시스템이 나오기 전까지는 모든 걸 계속 다시 확인해야 함

      결국 필요한 건 입력이 들어오면 성공한 결과 아니면 실패만 주는 API임
      성공, 실패, 에러 셋 중 하나를 주는 API가 아님

    • 맞음, musl libc는 바로 그런 부분 하나를 제거함

    • 근본 원인은 Unix API의 불투명함보다, root가 자신이 통제하지 못하는 디렉터리로 chroot하는 상황을 제대로 생각하지 않은 데 있다고 봄

      무엇이든 chroot한 대상은 그 chroot를 만든 쪽의 통제 아래 있고, 그걸 이해하지 못하면 chroot()를 쓸 자격이 없음

      get_user_by_name이 함정처럼 느껴질 수는 있지만, 사실 newroot/etc/passwd를 쓰는 것과 newroot/usr/lib/x86_64-linux-gnu/libnss_compat.so, newroot/bin/sh 같은 걸 쓰는 것 사이에는 실질적 차이가 거의 없음

      그래서 /usr/sbin/chroot가 애초에 사용자 ID를 조회할 이유가 없다고 봄
      toybox chroot는 그러지도 않음
      결국 버그는 뭔가를 잘못한 방식이 아니라, 애초에 그 작업을 한 것 자체였음

    • Unix와 POSIX는 프랙탈처럼 어디를 잘라도 함정투성이임

  • Rust 쪽 사람들이 Linux 경험 없이 coreutils를 다시 썼다 치더라도, Ubuntu가 그걸 어떻게 mainline에 받아들였는지가 더 이해되지 않음

    • Ubuntu는 거의 매 릴리스마다 시스템의 기반 요소 하나쯤을 엉성하고 미완성인 실험으로 바꾸는 정책이라도 있는 듯함

      여기서 핵심은 “세상에, Rust 코드에 버그가 있었네”가 아니라 바로 그 점이라고 봄

    • 원본은 GPL 라이선스고, 재작성판은 MIT 라이선스

  • “이 버그들이 실제 배포된 Rust 코드에서 나왔고, 작성자들도 자기들이 뭘 하는지 아는 사람들이었다”는 말이 사실이라면,
    원래 유틸리티에도 테스트 하니스가 없었고, 재작성도 그걸 먼저 만드는 데서 출발하지 않았다는 뜻인지 궁금함

    엣지 케이스가 많다 해도, OS와 FS를 어느 정도 추상화해서 rm .//가 정말 기대한 대로 현재 디렉터리를 지우지 않는지 같은 건 검증할 수 있지 않나 싶음

    이건 지저분한 코딩 문제나 언어 비판이라기보다, 또다시 시스템 프로그래밍에서는 테스트 안 한다는 오래된 태도처럼 보이기도 함

    반대로 원래 유틸리티에 테스트가 있었는데도 이렇게 빈틈이 많았다면, 원본 테스트 스위트 자체가 크게 부족한 것일 수도 있음

    • 그렇다고 봄

      하지만 OS와 FS를 충분히 추상화해서 검증할 수 있다는 데는 그만큼 확신하지 못하겠음
      사람들이 내가 태어나기 전부터 그런 시도를 해왔지만 아직 성공하지 못한 듯하기 때문임

      예를 들어 /를 몇 개까지 붙여서 시험할지 어떻게 정할 건지부터 애매함

      더 나아가 rm이 파일의 처음 9바이트가 important면 삭제를 거부한다고 가정하면,
      그 문자열을 미리 모르는 상태에서 그런 동작을 잡아내는 테스트를 어떻게 떠올릴지 난감함
      심지어 그 마법 단어가 사전에 없는 문자열이라면 더 어려움

      나는 “시스템 프로그래밍은 테스트 안 한다”는 말을 진지하게 하는 사람은 거의 못 봤음
      대신 테스트가 사람들이 기대하는 역할을 항상 해주지는 않는다는 말은 자주 들었음

    • 내가 이해한 바로는 uutils 개발 과정에는 원본 유틸리티와의 광범위한 동작 비교 테스트가 있었고, 심지어 버그까지 보존하려 했음

    • 이런 이유 중 하나 때문에 Windows는 기본적으로 symlink를 비활성화
      추상화로 푸는 게 아니라 기능 자체를 사실상 제거하는 방식임

      Unix 계열은 수십 년간 symlink에 의존한 소프트웨어가 너무 많아서 그렇게 할 수 없음

      MacOS도 비슷한 식의 대응이 있음
      예를 들어 chroot() 버그는 기본 설정에선 실질적 문제가 잘 안 되는데, MacOS가 chroot()를 기본적으로 막기 때문임
      쓰려면 system integrity protection을 꺼야 함

      근본 문제는 POSIX API의 날카로운 모서리에 있고, 해결책은 그걸 추상화하는 게 아니라 아예 없애는 것에 가까움

  • 사람들이 실험하고 서툴게 시도하는 건 괜찮다고 봄
    원래 그렇게 배우고 성장하니까

    진짜 궁금한 건 Ubuntu의 의사결정 체인이 어떻게 망가졌길래 이런 게 프로덕션까지 들어갔는지임

    • 가끔 성장이란 건 키만 커지는 일이기도 함