hyunsu blog

홈강좌Rust 실무 16편 중 15편째

Rust 와 보안 1 — 막아 주는 것과 막지 못하는 것

목차 · 4
  1. 01막아 주는 것 — 메모리 오류
  2. 02막지 못하는 것 1 — release 의 정수 넘침
  3. 03막지 못하는 것 2 — 로직 실수
  4. 04실습 — 막지 못하는 것 3·4 와 확인 목록

Rust 는 메모리 오류라는 큰 보안 사고의 갈래를 컴파일러로 막는다. 하지만 release 빌드의 정수 넘침, 권한 판정 같은 로직 실수, unsafe 블록, 의존성은 막지 못한다. 배열 밖 읽기가 패닉이 되는 것, release 에서 70000 × 70000 이 조용히 틀리는 것, 권한 버그가 테스트에만 걸리는 것을 실행해 본다.

01 막아 주는 것 — 메모리 오류

마이크로소프트 보안 대응 센터(MSRC)는 2019년 글에서 해마다 CVE 를 매기는 취약점의 약 70% 가 메모리 안전 문제라고 밝혔다. 미국 CISA 는 2023년 12월 여러 나라 보안 기관과 함께 낸 권고에서, 소프트웨어 회사들에 메모리 안전 언어로 옮겨 가는 로드맵을 세우라고 했다(Rust 는 그 언어들 가운데 하나다).

Rust 에서는 해제한 메모리를 다시 쓰기·같은 값을 동시에 고치기(데이터 경쟁) 같은 코드가 컴파일되지 않는다(입문 15~19·45편). 컴파일러가 증명하지 못하는 배열 번호는 실행 중에 검사한다. 사용자가 준 번호로 배열을 읽어 보자.

rust
use std::env;

fn main() {
    let files = ["report.txt", "photo.png", "notes.md"];
    let index: usize = env::args().nth(1).and_then(|s| s.parse().ok()).unwrap_or(0);
    println!("opening {}", files[index]);
}
text
   Compiling files v0.1.0 (~/projects/files)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.18s
     Running `target/debug/files 7`

thread 'main' (213422661) panicked at src/main.rs:6:28:
index out of bounds: the len is 3 but the index is 7
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

길이 3인 배열의 7번을 달라는 요청에, 그 너머의 메모리를 읽지 않고 멈췄다. C 라면 옆에 있던 다른 데이터(비밀번호일 수도 있다)를 읽어 돌려줬을 자리다. 멈추는 것도 서비스 입장에서는 장애이니, 실무에서는 입문 28편의 get 으로 None 을 처리한다. 그래도 새는 것이 아니라 멈춘다는 것이 핵심이다.

02 막지 못하는 것 1 — release 의 정수 넘침

단가 70,000원짜리를 70,000개 주문하면 49억 원이다. u32 는 약 42억까지만 담는다.

rust
use std::env;

fn total_price(unit_price: u32, quantity: u32) -> u32 {
    unit_price * quantity
}

fn main() {
    let quantity: u32 = env::args().nth(1).and_then(|s| s.parse().ok()).unwrap_or(1);
    let total = total_price(70_000, quantity);
    println!("quantity {quantity} -> total {total} won");
}

debug 빌드에서는 넘치는 순간 멈춘다.

text
   Compiling cart v0.1.0 (~/projects/cart)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.18s
     Running `target/debug/cart 70000`

thread 'main' (213423115) panicked at src/main.rs:4:5:
attempt to multiply with overflow
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

같은 코드를 release 로 돌리면 멈추지 않는다.

text
   Compiling cart v0.1.0 (~/projects/cart)
    Finished `release` profile [optimized] target(s) in 0.21s
     Running `target/release/cart 70000`
quantity 70000 -> total 605032704 won

6억 원이 나왔다. release 빌드는 성능을 위해 넘침 검사를 빼고, 넘친 값을 감싸서(2의 보수로 돌아서) 계산을 이어 간다. 결제 금액·재고·크기 계산에서 이것은 그대로 보안 사고다(큰 수량으로 싼 값을 만드는 공격). 금액처럼 넘치면 안 되는 계산은 checked_* 로 넘침을 직접 다룬다.

rust
use std::env;

fn total_price(unit_price: u32, quantity: u32) -> Option<u32> {
    unit_price.checked_mul(quantity)
}

fn main() {
    let quantity: u32 = env::args().nth(1).and_then(|s| s.parse().ok()).unwrap_or(1);
    match total_price(70_000, quantity) {
        Some(total) => println!("quantity {quantity} -> total {total} won"),
        None => println!("quantity {quantity} is too large, order rejected"),
    }
}
text
   Compiling cart v0.1.0 (~/projects/cart)
    Finished `release` profile [optimized] target(s) in 0.24s
     Running `target/release/cart 70000`
quantity 70000 is too large, order rejected

checked_mul 은 넘치면 None 을 준다(입문 26편). release 에서도 주문을 거절했다. Cargo.toml 의 [profile.release] 에 overflow-checks = true 를 넣어 release 에서도 패닉하게 할 수 있지만, 멈추는 것보다 거절하는 편이 서비스에는 낫다.

03 막지 못하는 것 2 — 로직 실수

"관리자는 아무 글이나, 다른 사람은 자기 글만 지울 수 있다" 를 || 대신 && 로 잘못 썼다.

rust
struct User {
    is_admin: bool,
}

struct Post {
    owner_is_me: bool,
}

/// Admins can delete any post; others only their own.
fn can_delete(user: &User, post: &Post) -> bool {
    user.is_admin && post.owner_is_me
}

fn main() {
    let admin = User { is_admin: true };
    println!("{}", can_delete(&admin, &Post { owner_is_me: false }));
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn owner_can_delete_own_post() {
        assert!(can_delete(&User { is_admin: false }, &Post { owner_is_me: true }));
    }

    #[test]
    fn admin_can_delete_any_post() {
        assert!(can_delete(&User { is_admin: true }, &Post { owner_is_me: false }));
    }

    #[test]
    fn others_cannot_delete() {
        assert!(!can_delete(&User { is_admin: false }, &Post { owner_is_me: false }));
    }
}
text
running 3 tests
test tests::others_cannot_delete ... ok
test tests::owner_can_delete_own_post ... FAILED
test tests::admin_can_delete_any_post ... FAILED

failures:

---- tests::owner_can_delete_own_post stdout ----

thread 'tests::owner_can_delete_own_post' (213422840) panicked at src/main.rs:25:9:
assertion failed: can_delete(&User { is_admin: false }, &Post { owner_is_me: true })

---- tests::admin_can_delete_any_post stdout ----

thread 'tests::admin_can_delete_any_post' (213422838) panicked at src/main.rs:30:9:
assertion failed: can_delete(&User { is_admin: true }, &Post { owner_is_me: false })
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::admin_can_delete_any_post
    tests::owner_can_delete_own_post

test result: FAILED. 1 passed; 2 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

이 코드는 경고 하나 없이 컴파일된다. 타입도 소유권도 맞기 때문이다. 권한·인증·가격 규칙 같은 뜻의 실수는 입문 42편의 테스트만 잡는다. 테스트 이름을 규칙 문장 그대로 지은 것이 실패 출력을 바로 읽히게 했다.

04 실습 — 막지 못하는 것 3·4 와 확인 목록

  • unsafe — unsafe { } 블록 안에서는 컴파일러가 일부 검사를 사람에게 맡긴다. 다른 언어와 C 라이브러리를 부를 때 꼭 필요하지만, 그 안에서는 메모리 오류가 다시 가능하다. 새 unsafe 는 리뷰에서 반드시 읽는다(12편).
  • 의존성 — 4편에서 본 25개 크레이트 가운데 하나만 악성이어도 컴파일러는 모른다(12편의 2025년 사례).
Rust 가무엇을대신 할 일
막는다해제 후 사용·이중 해제·데이터 경쟁—
멈추게 한다배열 밖 접근, debug 의 정수 넘침get·checked_* 로 미리 처리
막지 못한다release 의 정수 넘침금액·크기는 checked_*
막지 못한다권한·규칙 같은 로직 실수규칙마다 테스트
막지 못한다unsafe 안의 실수·악성 의존성리뷰·버전 고정·RustSec

자기 코드에서 금액·수량·크기를 곱하는 곳을 찾아 checked_mul 로 바꿔 보자. 다음 편은 웹 API 를 지키는 실무 설정이다.