rust dangling pointer ub

wangki·2025년 2월 26일

Rust

목록 보기
24/57

개요

rust를 개발하며 dangling pointer로 난감한 경험을 했다.
분명 동작이 안되야하는 코드인데 정상적으로 동작하는 것처럼 보여서 무언가 잘못된 기분이었다. 우연한 프로그램을 처음으로 만났다.

내용

fn get_pcwstr_data(str: &str) -> PCWSTR {
    let vec: Vec<u16> = str.encode_utf16().chain(once(0)).collect();
    let p = PCWSTR(vec.as_ptr());

    p
}

이 코드의 잘못된 점이 보이시나요? 저는 이 코드를 보자마자 dangling pointer다 라고 생각했다. 왜냐하면 vec의 로우 포인터를 PCWSTR 튜플 구조체가 필드 값으로 가지고 있기 때문에 함수가 종료 시 vec의 소유권은 사라지기에 dangling pointer를 가진다고 생각했다. 그러나 아래 코드를 실행하고 당황했다.

fn main() {
    let res = test_func("테스트입니다."); 
    let text = unsafe { res.to_string().unwrap() };
    println!("{}", text);
}

위 코드를 실행했더니 정상적으로 출력이 되었다.

분명 메모리가 해제된다면 violation exception이 발생해야 정상아닌가라는 생각을 했다. 그러나 출력은 정상적으로 되고 있었다. 잘못되었음을 보이기 위해 아래와 같이 작성했다

    println!("{:?}", res);

    let mut vec = unsafe {slice::from_raw_parts(res.0, res.len() + 1).to_vec()};

    println!("{:?}", vec); 

    println!("{:p}", res.0);
    vec.pop();

    println!("{:?}", vec); 

출력값은 아래와 같다.

어처구니없게도 정상적으로 출력이 되었다.
찾아보니,,

러스트에서 드롭되면 "메모리가 해제된다"는 건, 그 메모리가 더 이상 프로그램에의해 소유되지 않고 메모리 할당자에게 반환된다는 뜻이다. 메모리 내용을 즉시 지우거나 물리적으로 삭제하는게 아니라, 할당자가 다시 사용할 수 있게 예약 풀에 넣는 것 뿐이다. 즉, 실제 메모리 데이터는 새 할당이 그 공간을 덮어쓸 때까지 그대로 남아있을 수 있다.

즉, 지금의 코드는 우연에 맡기는 코드이고, Undefined Behavior을 일으킬 수 있다. 증명하는 코드를 작성해 보겠다.

fn main() {
    let normal_vec: Vec<u16> = "test입니다".encode_utf16().chain(once(0)).collect();
    let res = PCWSTR(normal_vec.as_ptr());

    // 동일한 크기의 Vec을 여러 번 할당/해제해서 메모리 재사용 유도
    for _ in 0..100 {
        let _overwrite = vec![666u16; 10]; // 원래 vec과 비슷한 크기(10)로 반복 할당
    }
    
    // 더 큰 메모리 할당으로 추가 압박
    let _big_vec = vec![42u16; 100000]; // 100,000개로 늘려서 메모리 풋프린트 확대

    let text = unsafe { res.to_string().unwrap() };
    println!("{}", text);

}

일부러 100번 정도 메모리를 할당 해제를 반복하였다. 그리고 큰 메모리로 추가적으로 메모리를 할당하였다. normal_vec의 경우 힙에 할당받은 메모리 공간을 소유하고 있다. 추가로 힙에 메모리를 할당하더라도 현재 자신이 소유한 메모리 공간을 침범하지 않는다. 출력값을 보겠다.

정상적으로 원래의 데이터가 나오는 것을 볼 수 있다.

다음은 문제가 되는 코드를 보겠다.

fn main() {
    let res = test_func("테스트입니다.");

    // 동일한 크기의 Vec을 여러 번 할당/해제해서 메모리 재사용 유도
    for _ in 0..100 {
        let _overwrite = vec![666u16; 10]; // 원래 vec과 비슷한 크기(10)로 반복 할당
    }
    
    // 더 큰 메모리 할당으로 추가 압박
    let _big_vec = vec![42u16; 100000]; // 100,000개로 늘려서 메모리 풋프린트 확대

    let text = unsafe { res.to_string().unwrap() };
    println!("{}", text);

}

fn test_func(str: &str) -> PCWSTR {
    let vec: Vec<u16> = str.encode_utf16().chain(once(0)).collect();
    PCWSTR(vec.as_ptr());
}

위와 같이 쓰레기 값이 나오느걸 확인할 수 있다. 즉, 메모리 어딘가를 가르키고는 있지만 소유하고 있지 않기 때문에 메모리 할당자가 해당 공간에 새로운 값을 넣어준 것이다. 따라서 unsafe 키워드를 통해 rust는 개발자에게 메모리에 대한 책임은 너에게 있다고 말해주었고 개발자는 잘못된 메모리 접근으로 위와 같은 결과를 얻은 것이다. 위에서 정상적으로 동작했던 코드는 결국 운이 좋았던 것뿐이다.

이제 마지막으로 어떻게 dangling pointer의 문제를 피할 지 고민해보자.

fn test_func(str: &str) -> (PCWSTR, Vec<u16>) {
    let vec: Vec<u16> = str.encode_utf16().chain(once(0)).collect();
    let p = PCWSTR(vec.as_ptr());

    (p, vec)
}

위와 같이 vec을 튜플로 묶어서 반환하여 메모리의 소유를 살리는 방법이 있다.
다른 방식으로 zeroize 크레이트를 써서 메모리를 0으로 채워버리는 방법이 있다고 한다. 이 방식은 drop시 안전하게 메모리의 잔재가 남을 가능성을 줄이는 것 같다.

결론

살짝 억지로 메모리를 증명했는데 아직은 실력이 좀 부족한 것 같다.ㅎㅎ
unsafe 코드를 사용하지 않았더라면 위와 같은 일은 나타나지 않았을 것이다. rust를 개발하면서 unsafe로 래핑 하라고 하면 별생각 없이 래핑을 했다. 그러나 앞으로 unsafe를 쓸 일이 있다면 메모리에 대해서 더 깊이 있게 생각하고 주의하면서 사용해야겠다.

0개의 댓글