Rust 4분 소요

메모리 안전의 대명사 Rust, 정작 '빌드 버튼' 누르는 순간 털렸습니다

Rust를 소개할 때 가장 먼저 나오는 말은 언제나 메모리 안전입니다. 버퍼 오버플로우도, use-after-free도 컴파일러가 잡아준다는 자랑이죠. 그런데 이번에 crates.io에서 발견된 악성 크레이트는 그 자랑이 닿지 않는 곳을 정확히 찔렀습니다. 개발자는 코드를 실행한 적이 없습니다. 그냥 cargo build를 한 번 눌렀을 뿐입니다.

호출하지 않아도 실행되는 코드

이번에 표적이 된 건 arrayref 같은 초소형 유틸리티 크레이트입니다. 이 크레이트가 하는 일이라곤 슬라이스를 고정 크기 배열 참조로 바꿔주는 매크로 몇 개가 전부입니다. 파일 하나짜리 라이브러리인데요. 문제는 blake2, curve25519 계열 암호 라이브러리가 이걸 기본으로 깔고 있다는 겁니다. 내가 직접 쓴 적이 없어도 의존성 트리 3단계 아래에 조용히 앉아 있습니다.

공격자가 노린 지점이 바로 여기입니다. 이름이 한 글자 다른 크레이트를 올려두고 악성 코드를 라이브러리 본문이 아니라 proc-macro 쪽에 심는 방식입니다. 라이브러리 본문에 심으면 누군가 그 함수를 호출해야 발동합니다. 하지만 proc-macro는 다릅니다. 컴파일러가 코드를 파싱하는 그 순간에 실행됩니다.

참고로 이번 건은 아직 제대로 된 사후 분석 보고서가 나오기 전이라 피해 규모나 정확한 타임라인은 유동적입니다. 다만 공격의 뼈대 자체는 전혀 새롭지 않습니다. 오히려 몇 년째 똑같은 수법이 되풀이된다는 쪽이 더 찜찜합니다.

cargo는 원래 컴파일 타임에 남의 코드를 돌립니다

이게 버그가 아니라 설계라는 게 중요합니다. Rust 프로젝트를 빌드할 때 임의 코드가 실행되는 경로는 크게 두 갈래입니다.

첫째는 build.rs입니다. 빌드 스크립트라고 부르는데, 본체 컴파일에 앞서 먼저 컴파일된 뒤 실행됩니다. C 라이브러리를 링크하거나, 플랫폼별 설정을 감지하거나, 코드를 생성할 때 필요합니다. 그리고 이 스크립트는 권한이 전부 열려 있는 평범한 Rust 프로그램입니다. 네트워크도 열고, 홈 디렉터리도 읽고, 셸도 띄웁니다. 아무 제약이 없습니다.

둘째가 앞서 말한 proc-macro입니다. #[derive(Serialize)] 같은 걸 써보셨다면 이미 쓰고 계신 겁니다. 절차적 매크로는 컴파일러 플러그인처럼 동작하는데 실체는 컴파일러 프로세스 안에서 실행되는 일반 Rust 코드입니다. 문법 트리를 받아서 문법 트리를 뱉는 게 원래 역할이지만 그 사이에 무슨 짓을 하든 막을 방법이 없습니다.

그러니까 Rust 프로젝트를 빌드한다는 건 의존성 트리에 있는 수백 개 크레이트 중 일부의 코드를 내 노트북에서, 혹은 CI 러너에서 실행한다는 뜻입니다. 샌드박스는 없습니다.

여기서 많이들 헷갈립니다. unsafe 없이도 std::process::Command는 얼마든지 쓸 수 있습니다. 메모리 안전은 프로그램이 잘못된 주소를 건드리지 않게 해주는 속성이지, 프로그램이 나쁜 짓을 못 하게 막는 속성이 아닙니다. 메모리 안전과 공급망 안전은 완전히 다른 축인데 “Rust니까 안전하겠지” 하는 마음이 오히려 경계를 풀어놓습니다.

crates.io 신뢰 모델의 구멍

crates.io는 평면 네임스페이스를 씁니다. npm에는 @org/package 형태의 스코프가 있지만 crates.io에는 없습니다. 이름은 선착순입니다. arrayrefarray-refarray_ref가 서로 다른 사람의 크레이트일 수 있습니다. 심지어 cargo는 하이픈과 언더스코어를 어느 정도 호환해서 다루기 때문에 눈으로 훑을 때 더 헷갈립니다. 타이포스쿼팅에 이보다 좋은 환경이 없죠.

게시할 때 심사도 없습니다. 그동안 개선이 없었던 건 아닙니다. 2023년에 게시자 2FA가 의무화됐고 이후 GitHub Actions 등에서 단기 토큰으로 게시하는 Trusted Publishing도 도입됐습니다. 좋은 변화입니다. 다만 이 대책이 막는 건 계정 탈취입니다. 처음부터 작정한 사람이 자기 정상 계정으로 악성 코드를 올리는 시나리오는 그대로 뚫립니다.

한 번 게시된 버전은 삭제할 수 없다는 점도 양날의 검입니다. 재현 가능한 빌드를 하려면 필요한 정책이지만 악성 버전이 올라와도 yank로는 신규 해석만 막힙니다. 이미 Cargo.lock에 박힌 프로젝트는 계속 그 버전을 가져다 씁니다.

다운로드 수를 신뢰 지표로 쓰는 습관도 위험합니다. crates.io 다운로드 카운터의 상당 부분은 사람이 아니라 CI가 만들어냅니다. 숫자가 크다는 건 인기가 있다는 뜻이지, 누군가 코드를 읽어봤다는 뜻이 아닙니다.

이미 여러 번 있었던 일입니다

2022년 5월, rustdecimal이라는 크레이트가 문제가 됐습니다. 널리 쓰이는 rust_decimal에서 언더스코어 하나를 뺀 이름이었죠. 겉보기엔 멀쩡했지만 내부에 숨은 코드가 GITLAB_CI 환경변수가 있는지 확인한 뒤 외부에서 페이로드를 받아왔습니다. 개발자 개인 머신이 아니라 CI 파이프라인을 노린 겁니다. CI에는 배포 키와 클라우드 자격증명이 다 있으니까요.

2025년 9월에는 faster_logasync_println이 발견됐습니다. 로깅 라이브러리인 척하면서 프로젝트 소스 파일을 뒤져 이더리움과 솔라나 개인키 패턴을 찾아내 외부 서버로 보냈습니다. 무서운 건 이 크레이트가 광고한 로깅 기능도 정상적으로 동작했다는 점입니다. 쓰는 사람 입장에서 이상함을 느낄 계기가 없었습니다.

Rust 밖으로 눈을 돌리면 2024년 3월의 xz-utils 백도어가 있습니다. 언어도 생태계도 다르지만 교훈은 똑같습니다. 악성 코드는 사람이 눈으로 읽는 소스 트리가 아니라 빌드 시스템 안에 숨어 있었습니다. m4 스크립트와 테스트용 바이너리 픽스처에 나눠 담겨 있었고 GitHub에서 소스를 봐서는 보이지 않고 배포용 tarball에만 들어 있었습니다.

세 사건은 한 군데서 똑같이 겹칩니다. 공격자는 사람이 리뷰하는 곳을 피하고 기계가 자동으로 실행하는 곳에 심습니다. build.rs와 proc-macro가 딱 그 자리입니다.

지금 당장 할 수 있는 것들

근본 해결책은 빌드 스크립트 샌드박싱입니다. 실제로 cackle 같은 도구가 크레이트별 권한을 선언하고 위반을 잡아내는 방향을 시도하고, cargo 자체에 샌드박스를 넣자는 논의도 오래전부터 있었습니다. 다만 아직 기본값이 아니라는 게 문제죠. 그때까지는 손으로 방어선을 쳐야 합니다.

Cargo.lock을 반드시 커밋하고 CI에서는 --locked를 붙이세요. 어느 날 갑자기 의존성이 조용히 바뀌는 상황만 막아도 대응 시간을 법니다. 그다음 cargo-audit이나 cargo-deny를 파이프라인에 넣어 RustSec 자문 데이터베이스와 대조하면 최소한 이미 공개된 악성 크레이트는 걸러집니다.

한 단계 더 가고 싶다면 Mozilla가 만든 cargo-vet이 있습니다. 의존성 감사 결과를 조직끼리 공유하는 도구인데요. 새 크레이트를 추가할 때 “이거 누가 실제로 들여다본 적 있나"를 확인할 수 있습니다. 모든 걸 혼자 감사하는 건 불가능하니 남의 감사를 빌려 쓰는 접근입니다.

습관 차원에서는 두 가지만 기억하면 됩니다. 의존성을 추가할 때 이름을 손으로 치지 말고 공식 문서에서 복사하거나 cargo add를 쓰세요. 그리고 가끔 cargo tree로 의존성 트리를 훑으면서 build.rs가 딸린 크레이트와 proc-macro 크레이트가 몇 개나 되는지 세어보세요.

마무리

Rust의 강점은 위험한 코드를 컴파일 타임에 잡아준다는 데 있습니다. 그런데 이번 사건에서 드러난 건 그 컴파일 타임 자체가 공격면이라는 겁니다. 컴파일러가 방패인 동시에 실행 환경이기도 한 셈이죠.

지금 열려 있는 프로젝트에서 cargo tree를 한 번 돌려보세요. 의존성이 몇 개인지, 그중 빌드하는 순간 내 머신에서 코드를 실행할 수 있는 크레이트가 몇 개인지 알고 계신가요. 저는 처음 세어보고 조금 당황했습니다.

Rust 공급망 공격 cargo crates.io 오픈소스 보안

댓글

    댓글을 불러오는 중...