2015년 12월 22일

Memory corruption: 찾기 어려운 크래시 수정

저자: Gil Gribb

코드가 잘못되는 방법은 수도 없이 많겠지만, 랜덤 메모리 오버라이트 문제는 변함없이 다이나믹 메모리 할당에 관련되어 있어 왔습니다. 어떤 시스템은 메모리를 약간 할당하고 한동안 사용한 다음 메모리를 할당자에 반환하고 나서는, 계속 사용하여 치명적인 문제의 원인이 됩니다. 그러는 중에, 메모리 할당자는 메모리 블럭을 재활용하고 다른 시스템에 할당시켜 줍니다. 메모리는 공유되는 자원이기 때문에 메모리는 다른 서브시스템이나 다른 스레드, 또는 콘솔이나 심지어 GPU에 할당되기도 합니다.

할당자로 반환된 메모리 블럭으로의 포인터는 스테일이라고 부릅니다. 이러한 스테일 포인터의 읽기와 쓰기 모두 버그 또는 크래시를 일으키게 됩니다. 스테일 포인터를 읽는 것은 결과물이 잘 드러나는데, 왜냐하면 동일한 서브시스템 내에서 나쁜 코드로 인해 크래시가 일어나기 때문입니다. 하지만 스테일 포인터로의 쓰기를 하면 상관이 없어 보이는 다양하고 당황스러운 버그와 크래시가 생기게 됩니다.

스테일 포인터 사용에 따른 메모리 이슈를 찾아내는 평범한 방법은 다양한 방법으로 이런 이슈들을 찾는 특별한 할당자를 사용하는 것입니다. 할당자가 이런 버그를 찾는 방법의 일부는 아래에 나와 있습니다.

  • 메모리 블럭이 반환되면 스테일 포인터를 읽은 경우에 문제가 일어나도록 하는 특별한 비트 패턴을 넣어 줍니다.
  • 메모리 블럭이 반환되고 특별한 비트 패턴으로 채워지고 나면, 잠시 놔둡니다. 조금 뒤에 재사용이 되겠지만 그 전에, 비트 패턴을 한 번 확인해 줍니다. 비트 패턴이 바뀌었다면, 어떤 다른 프로그램에서 스테일 포인터에 쓰기를 했다는 뜻이 됩니다.
  • 도입부와 말단부에 특별한 비트 패턴을 채운 새로운 블럭을 만들어 줍니다. 이 블럭은 카나리아 라고 불립니다. "탄광 속 카나리아" 에서 유래한 것입니다. (역주- 과거 탄광 속에 유해 가스가 들어있는지를 탐지하기 위해 카나리아를 새장에 넣어서 가지고 다녔습니다.) 블럭이 비워졌을 때(또는 다른 상황에서), 카나리아를 체크하고 기대했던 값과 다르면, 저희는 다른 프로그램이 도입부 혹은 말단부에 값을 입력했다는 것을 추측해 낼 수 있습니다. 이것은 그 자체로는 스테일 포인터가 아니지만, 비슷한 종류의 미스테리한 결과를 가져오게 됩니다.
  • 현대의 CPU는 읽기/쓰기를 방지하기 위해 페이지라고 불리는 메모리의 범위를 갖는 하드웨어 특성이 있습니다. 여기에 대해 아래에서 집중적으로 다루도록 하겠습니다.

메모리 프로텍션을 이용해서 버그를 찾아내기

현대 CPU의 버추얼 메모리 시스템은 이 포스팅에서 다루지 않는 수 많은 기능을 포함하고 있습니다. 메모리 버그를 찾아내기 위해서 저희는 두 가지의 기능을 사용합니다.

  • 방대한 양의 메모리 주소를 할당하는 기능입니다. 이 것은 어떤 면에서 동일한 주소를 두 번 사용할 필요가 없다는 것을 의미합니다. 이렇게 하면 버그로부터 올바른 메모리 사용이었는지 쉽게 구분해 낼 수 있습니다.
  • 특정 범위의 메모리 주소를 읽을 수 없거나 쓸 수 없도록 설정하는 기능입니다. 이 기능으로 CPU가 스테일 포인터를 읽거나 쓰려고 할 때 크래시가 나도록 할 수 있습니다.

CPU들은 메모리 프로텍션에 있어 고정된 최소 크기를 가지고 있습니다. 메모리가 페이지에서 할당, 매핑, 반환, 프로텍션될 때 말이죠. 대부분의 CPU들은 다양한 페이지 사이즈를 사용할 수 있도록 하지만, 가장 작은 크기는 4096바이트 입니다. 그러므로, 가상 메모리 기능을 버그를 찾기 위해 CPU에 사용하게 되면, 저희는 4k 청크를 할당해야 합니다. 이렇게 되면 16바이트만 필요할 때에도 4096바이트를 할당해야 하는데, 엄청난 낭비가 됩니다. 그러므로 메모리 프로텍션을 사용하기 위해 페이지에 할당시키는 할당자는 아주 많은 메모리를 사용하게 됩니다. 보통 메모리 2GB를 사용하는 게임은 이런 할당자 아래에서는 30GB를 사용하게 될 것입니다. 또한 모든 할당과 반환에 있어 이런 방식을 운영체제에서 콜을 하게 된다면 퍼포먼스가 나오지 않을 것입니다.

저희는 또한 CPU하드웨어로 페이지의 말단부와 다음 페이지에 프로텍션을 걸어서 메모리 블럭 말단부의 읽기/쓰기를 방지할 수 있습니다. 또는 메모리 블럭을 페이지 도입부에 놓아서 메모리 블럭 앞에서 읽기/쓰기를 방지해서 앞 페이지가 프로텍션 되는 것을 확실히 할 수도 있습니다. 기타 크기이거나 작은 크기의 블럭은 페이지의 전/후에 프로텍션을 걸 방법이 없는데, 왜냐하면 페이지의 최소 단위에 걸리기 때문입니다.

최근에 Paragon은 랜덤 메모리 오버라이트 문제 때문에 골머리를 앓았습니다. 운 좋게도 오픈소스 커뮤니티에서 활발하게 활동하고 있는 유저가 할당자에 대한 굉장한 임플멘테이션 소스를 업로드 요청하였고 굉장한 블로그 포스트에서 여기에 관해 다루고 있습니다.

저희는 이 소스를 받아들여서 거의 바로 다섯개의 치명적인 버그를 찾아냈습니다. 어떤 경우에는 실제 버그가 아주 복잡하지만, 아래와 같이 간단한 이슈도 존재합니다.

GameplayCueDataMap.Add(ThisGameplayCueTag) = GameplayCueDataMap.FindChecked(Parent);

여기에서 보시면, GameplayCueDataMap은 연관 배열의 한 종류로 언리얼 엔진 4의 평범한 TMap입니다. 먼저, FindChecked가 부모 아이템을 찾고 레퍼런스를(포인터라고도 합니다.) 반환합니다. 그리고 나서 Add가 호출되고 포인터가 지정하는 메모리를 반환하게 되는 데이터 내부 구조에서 TMap으로의 리사이징이 일어납니다. 그리고 최종적으로는 스테일 포인터가 디레퍼런스된 상태가 됩니다. 특별한 디버그 할당자가 없으면 버그가 쉽게 찾아지지 않습니다. 심지어는 이상한 동작으로 이어지기까지 하는데, 가히 최악의 상황까지 기다렸다가 튀어나온다는 것을 굳게 믿고 계셔도 좋습니다.

해결 방법은 간단합니다. 컨테이너를 확장할 가능성이 있다면 그 전에 강제로 디레퍼런스를 합니다. 이렇게 하면 스테일 포인터가 생기지 않습니다.

int32 ParentValue = GameplayCueDataMap.FindChecked(Parent);
GameplayCueDataMap.Add(ThisGameplayCueTag, ParentValue);

운 좋게도, 이러한 종류의 버그는 별로 흔하지 않습니다. 버그가 나타나게 되면 올바른 도구를 사용하는 것이 버그를 찾는 데 있어 핵심적으로 작용합니다.