1P by GN⁺ | ★ favorite | 댓글 1개
  • Windows 11 24H2에서 Skimmer 수상비행기가 사라지거나 스폰 직후 플레이어가 비정상적으로 높은 하늘로 튀어 오르는 문제가 재현됐고, 원인은 OS가 아니라 게임 내부의 오래된 데이터 처리 버그였음
  • Skimmer의 vehicles.ide 행에는 비행기에 필요한 휠 스케일 2개 값이 빠져 있었지만, CFileLoader::LoadVehicleObjectsscanf 반환값을 확인하지 않아 초기화되지 않은 지역 변수를 그대로 사용함
  • 예전 Windows 환경에서는 직전 차량 TopFun의 0.7 휠 스케일 값이 스택에 우연히 남아 Skimmer가 정상처럼 보였지만, Windows 11 24H2에서 LeaveCriticalSection스택 사용량이 달라지며 그 우연이 깨짐
  • 잘못된 휠 스케일은 서스펜션 계산과 충돌 박스 Z 좌표를 오염시켰고, 생성 높이와 블레이드 속도 계산까지 전파되어 카메라 위치 이상, burn-in 효과, SilentPatch 환경의 루프 정지로 이어짐
  • 해결은 vehicles.ide의 Skimmer 행에 -1, 0.7, 0.7, -1을 추가하거나 다음 SilentPatch 핫픽스를 적용하는 것이며, 입력 데이터 검증과 컴파일 경고 관리가 장기 호환성에 직접 영향을 줌

Windows 11 24H2에서 드러난 Skimmer 증상

  • SilentPatch 이슈 트래커에 Windows 11 24H2 업데이트 이후 Skimmer 비행기가 게임에서 완전히 사라졌다는 보고가 올라옴
    • 트레이너로도 스폰되지 않고, 원래 스폰 위치에서도 찾을 수 없었음
    • 모드가 포함된 게임과 SilentPatch만 적용한 바닐라 복사본 모두에서 재현됨
  • GTAForums에서도 2024년 11월부터 같은 문제가 보고됐고, 일부 사용자는 SilentPatch를 의심했지만 완전한 무모드 게임에서도 같은 현상이 나타남
  • Windows 10 22H2와 Windows 11 23H2에서는 Skimmer가 정상 스폰됐고, Windows 11 24H2 사용자들은 같은 버그를 겪음
  • 24H2 가상 머신에서 원격 디버깅한 결과, 다른 비행기와 보트는 정상이고 Skimmer만 사라지는 상태였음

비정상 고도와 끝나지 않는 블레이드 루프

  • 스크립트로 Skimmer를 강제로 생성하고 CJ를 태우면 플레이어가 1.0287648030984853e+0031m, 약 10.3 nonillion 미터 높이로 튀어 오름
  • SilentPatch가 설치된 경우 게임은 플레이어를 위로 날린 직후 루프에 빠져 멈춤
  • SilentPatch가 없으면 게임은 멈추지 않지만, 카메라가 무한대에 가까운 위치로 이동할 때 생기는 유명한 burn-in 효과가 나타남
  • 멈춘 위치는 CPlane::PreRender의 로터 블레이드 각도 정규화 루프였음
    • m_fBladeSpeed 값이 3.73340132e+29까지 커짐
    • 6.2831855를 반복해서 빼도 부동소수점 표현상 값이 변하지 않아 루프가 끝나지 않음
  • 블레이드 속도는 비행기 고도에 비례하는 값에서 파생되므로, Skimmer가 처음부터 비정상적으로 높은 위치에 생성됐다는 단서가 됨

충돌 박스를 오염시킨 서스펜션 계산

  • 스크립트 생성 함수 CCarCtrl::CreateCarForScript는 전달받은 Z 좌표에 GetDistanceFromCentreOfMassToBaseOfModel 결과를 더함
  • Skimmer의 충돌 박스를 확인하자 bbox.sup.z-4.30747210e+33처럼 말이 안 되는 값으로 오염돼 있었음
  • 데이터 브레이크포인트로 추적한 결과, 초기 로딩 시점의 충돌 박스 값은 정상임
    • 초기 bbox.sup.z-2.21952772였음
    • 이후 차량이 처음 스폰될 때 SetupSuspensionLines가 서스펜션 높이를 반영하며 충돌 박스 Z 좌표를 갱신함
  • 문제는 서스펜션 라인 계산에 들어가는 입력값 중 하나였음
    • 계산에는 handling.cfg의 서스펜션 상한·하한과 vehicles.ide의 휠 스케일이 사용됨
    • Skimmer의 handling.cfg 값은 다른 비행기와 크게 다르지 않았음

Skimmer의 짧은 vehicles.ide

  • Skimmer의 vehicles.ide 정의는 다른 비행기보다 짧고, 마지막 4개 매개변수가 빠져 있음
  • 누락된 값 중 2개가 앞·뒤 휠 스케일
  • 보트에는 이 값이 없어도 문제가 없지만, Skimmer는 비행기 중 유일하게 해당 매개변수를 생략함
  • Skimmer는 Vice City에서 보트로 정의됐다가 San Andreas에서 비행기로 바뀌는 과정에서 새로 필요한 매개변수가 추가되지 않은 것으로 보임
  • 누락된 매개변수를 다시 넣으면 Skimmer가 정상 동작함

sscanf 반환값을 확인하지 않은 로더

  • CFileLoader::LoadVehicleObjectvehicles.ide 한 줄을 sscanf로 파싱하면서 모든 매개변수가 항상 존재한다고 가정함
  • 이 함수는 sscanf의 반환값을 확인하지 않고, 마지막 매개변수 대부분에 기본값도 넣지 않음
    • wheelModelID는 초기화되지 않음
    • frontWheelScale, rearWheelScale도 초기화되지 않음
    • wheelUpgradeClass-1로 초기화돼 있음
  • Skimmer처럼 값이 빠진 행에서는 휠 스케일 변수가 초기화되지 않은 상태로 남고, 그 값이 차량 데이터로 전파됨
  • SilentPatch 수정은 sscanf 호출을 감싸 마지막 4개 값에 기본값을 제공하는 방식임
    • wheelModelID = -1
    • frontWheelSize = 0.7f
    • rearWheelSize = 0.7f
    • wheelUpgradeClass = -1
  • 수정 커밋은 SilentPatch 저장소에 반영됨

20년 동안 숨어 있던 이유

  • San Andreas는 정적으로 컴파일된 CRT를 사용하므로, Windows의 CRT 수준 핫픽스가 sscanf 동작을 바꾼 것은 아님
  • Windows 10에서는 Skimmer 파싱 직전 지역 변수 위치에 0.7 값이 남아 있었음
    • 이 값은 Skimmer 바로 앞에 정의된 TopFun의 휠 스케일과 일치함
    • TopFun 행에는 -1, 0.7, 0.7, -1이 들어 있음
  • vehicles.ide는 순서대로 읽히며, 각 행마다 LoadVehicleObject가 호출됨
  • Windows 10에서는 LoadVehicleObject 호출 사이에 해당 스택 위치가 덮어써지지 않아 Skimmer가 우연히 TopFun의 휠 스케일을 물려받음
  • Windows 11 24H2에서는 다음 행을 읽는 과정에서 fgets 내부의 LeaveCriticalSection이 더 많은 스택 공간을 사용했고, 그 결과 남아 있던 값이 덮어써짐

Windows 11 24H2는 방아쇠였을 뿐

  • 내부 WinAPI 함수가 스택을 사용하는 방식은 계약된 동작이 아니며, 사전 공지 없이 바뀔 수 있음
  • Windows 11 24H2는 게임이 의존하던 우연한 스택 잔존 값을 없앴을 뿐, 실제 원인은 게임의 정의되지 않은 동작
  • Windows 10에서도 휠 스케일 바로 다음 지역 변수는 이미 LeaveCriticalSection에 의해 덮어써졌고, 게임은 몇 년 전에도 이 버그를 만날 수 있는 상태였음
  • San Andreas는 Windows 98도 지원했기 때문에, 이 버그는 적어도 10여 개 Windows 버전과 여러 Wine 릴리스에서 우연히 드러나지 않았음
  • 공식 1.01 PC 패치에서는 이 버그가 수정되지 않았지만, 원래 Xbox 릴리스에는 기본값 1.0을 넣는 수정이 들어가 있었음
    • Steam 3.0, newsteam, RGL은 Xbox 코드 브랜치 기반이라 이 수정을 물려받음
    • War Drum Studios의 Android, X360, PS3 릴리스와 Definitive Edition도 영향을 받음

SilentPatch가 0.7을 기본값으로 고른 이유

  • SilentPatch는 Rockstar의 Xbox 수정처럼 1.0이 아니라 0.7을 기본 휠 스케일로 사용함
  • 선택 근거는 세 가지임
    • PC판에서 Skimmer는 지금까지 사실상 TopFun의 휠 스케일인 0.7로 동작해 왔음
    • 물 위에 뜨는 다른 비보트 차량인 Sea Sparrow와 Vortex도 휠 스케일이 0.7
    • 게임 내 많은 자동차의 휠 스케일도 0.7

직접 수정하는 방법

  • 코드 수정은 다음 SilentPatch 핫픽스에 포함될 예정임
  • 즉시 고치려면 San Andreas 디렉터리의 data\vehicles.ide를 메모장으로 열고 460, skimmer로 시작하는 행을 교체하면 됨
  • 교체할 행은 다음과 같음
460, 	skimmer,	skimmer, 	plane,		SEAPLANE,	SKIMMER,	null,	ignore,		5,	0,	0,		-1, 0.7, 0.7,		-1

오래된 게임 호환성이 남긴 교훈

  • 이 문제는 San Andreas의 단순한 버그였고, 해당 함수는 원래부터 올바르게 동작할 수 없는 코드였음
  • 내부 구현의 스택 레이아웃 변화도, 버그가 있는 애플리케이션이 특정 동작에 우연히 의존하면 호환성 문제로 이어질 수 있음
  • 비슷한 사례로 Windows 10에서 깨졌던 Bully: Scholarship Edition도 잘못된 가정에 의존하다가 OS 변화로 문제가 드러남
  • San Andreas의 근본 문제는 불완전한 설정 행을 걸러내지 못한 입력 데이터 검증 부재였음
  • 이 코드는 원래 컴파일 경고를 냈을 가능성이 높으며, 경고를 무시하거나 비활성화하면 장기간 숨어 있던 버그가 실제 사용자 문제로 나타날 수 있음

댓글과 토론

Hacker News 의견들
  • 이런 글은 Raymond Chen에게서나 기대할 법한 수준이고, 그건 엄청난 칭찬임
    정확히 왜 그런지까지 더 파고들어 밝혀낸 점이 반가움

  • 개인적으로는 계약에 포함되지 않은 동작이라면 무작위화해야 한다고 봄
    예를 들어 언어가 맵 순회 순서를 보장하지 않는다면, 의도적으로 순서를 무작위화해야 함
    그렇지 않으면 “될 때까지는 잘 되다가 어느 날 깨지는” 취약한 코드가 생김

    • -ftrivial-auto-var-init처럼 초기화되지 않은 변수를 특정 값이나 무작위 값으로 초기화하는 컴파일러 옵션은 여러 가지가 있음
      하지만 함수 호출마다 스택 전체 내용을 무작위화하거나 0으로 채우면 성능 저하가 끔찍해서 보통 그렇게 하지 않음
    • 이 수준의 무작위화는 비용이 너무 큼
      디버그 목적으로 이런 일을 하는 도구는 있지만, 그 모드에서는 프로그램이 훨씬 느리게 돌아감
    • 계약 관점에서는 원문에 나온 이 교훈도 있음: “호환성에서 흥미로운 교훈이다. 애플리케이션에 버그가 있고 특정 동작에 의도치 않게 의존한다면, 내부 구현의 스택 배치 변경조차 호환성 영향을 만들 수 있다”
      아마 Linux 커널 유지보수자들이 사용자 공간을 절대 깨지 말라고 고집하는 이유도 이 때문일 것 같음
    • 아님. https://www.hyrumslaw.com/를 기억해야 함
      API 사용자가 충분히 많으면, 계약에서 무엇을 약속했는지는 중요하지 않고 시스템의 관찰 가능한 모든 동작에 누군가는 의존하게 됨
      무작위화를 약속하면, 누군가는 그 무작위화에도 의존할 것임
      그러면 그것도 영원히 제거할 수 없게 됨
    • C 같은 언어의 장점 중 하나는 선택한 기능에 대해서만 비용을 낸다는 점이라고 볼 수도 있음
      쓰지 않는 변수 초기화 같은 불필요한 오버헤드를 강제로 지불하지 않음
  • “컴파일 경고를 무시하지 말라”는 부분에서, 여기서 어떤 컴파일러 오류를 기대할 수 있는지 모르겠음
    scanf의 반환값이 인자 개수와 맞는지 확인하지 않은 것 정도일까? 그 외에는 컴파일러가 알 수 없는 데이터 파일 오류처럼 보임

    • g++ 11.4로 해보면 sscanf 반환값을 확인하지 않아도 기본 경고가 없음
      작은 예제에서는 g++ -Wall -Wextra -Wunused-result를 줘도 경고가 나오지 않음
    • 초기화되지 않은 메모리에 접근하는 건 정의되지 않은 동작이라서, 새니타이저가 잡아냈을 것임
    • 좋은 지점임. 읽을 때는 막연히 “초기화되지 않은 메모리 사용” 경고가 이걸 잡을 거라고 생각했음
      하지만 한 줄 전체를 단일 sscanf 호출로 파싱하므로, 컴파일러의 정적 분석은 값들이 이제 초기화됐다고 가정할 수밖에 없음
      이 버그를 잡을 일반적인 정적 분석 방법은 없어 보임
      다만 scanf 전용 경고를 만들어서, 미리 초기화된 값을 넘기거나 반환값을 확인하도록 강제할 수는 있을 듯함
  • 이런 깊은 기술 분석 글을 읽는 건 언제나 즐거움
    AI 시대에 이런 글이 더 드물어질지 아닐지가 궁금함

    • 더 드물어질 것 같지는 않음. 항상 깊게 파고드는 상위권 엔지니어들은 있을 것임
      AI가 그들을 대체하지도 않을 것이고, 지난 50년 넘는 소프트웨어 개발 혁신도 그러지 못했음
      고수준 프로그래밍 언어 개발자 수백만 명, 어쩌면 수천만 명은 스택과 힙의 차이를 학교에서 어렴풋이 배운 이론 정도로만 알고, 일상 업무에서는 신경 쓸 필요가 없어서 관심도 없음
    • 일반적인 소프트웨어 엔지니어는 장인에서 기술직에 가까운 쪽으로 이동할 수 있지만, 이런 글은 그 자체로 장인적 스타일에서 나오는 것 같음
  • 이 Windows 버전에서 임계 구역 잠금/해제 구현이 무엇이 바뀌었는지가 더 궁금함

    • 사용된 스택 크기나 스택 보호 영역이 늘어난 것처럼 보임
  • 이 코드가 거슬리는 건 나뿐인가?
    while (this->m_fBladeAngle > 6.2831855) { this->m_fBladeAngle = this->m_fBladeAngle - 6.2831855; }
    나눗셈 하기 귀찮아서 무한 루프가 될 수도 있는 while 루프를 쓴 느낌임

    • GTA 개발자들이 PlayStation 2 같은 환경에서 부동소수점 나눗셈보다 빠르니까 이런 해킹을 했다고 믿고 싶음
      하지만 sscanf로 JSON을 파싱해서 GTA5 로딩을 5분이나 늘릴 수 있었던 걸 보면 기대가 크지는 않음
    • 성능 때문에 그랬을 가능성이 높다고 봄. 뺄셈은 부동소수점 나눗셈보다 저렴함
      컴파일러도 이를 더 최적화하는 기법이 있을 수 있음
      이게 무한 루프가 될 방법은 사실상 없음. 언더플로는 가능하지만, 그러려면 각도가 이미 2*pi보다 작아야 하므로 루프를 빠져나감
    • 가능성은 낮지만, 값이 작다면 이 루프가 나눗셈보다 빠를 수도 있음
    • 진짜로 그렇다. 작성자가 fmod를 전혀 몰랐던 듯함
  • 접근에 문제가 있는 사람은 이 링크를 쓰면 됨
    https://web.archive.org/web/20250423144746/https://cookieplm...

  • C/C++를 알다 보니 블로그 초반부터 대략 무슨 일이 벌어지는지, 즉 초기화되지 않은 변수 문제라고 짐작했음
    변수를 초기화하지 않은 채 둘 수 있게 허용하는 언어라는 게 놀라움. 이로 인해 직접 본 운영 버그를 포함해 셀 수 없이 많은 버그가 생겼고, 잡으려면 추가 컴파일러 플래그나 정적 분석 도구, Valgrind 등에 의존해야 하는 경우가 많음
    더 최신 언어들은 기본 0값을 쓰거나 사용 전 초기화를 강제하는 식으로 다른 해법을 택하는데도, 사람들은 계속 C/C++로 돌아감

  • “이 모든 발견은 버그가 Windows 11 24H2 문제가 아님을 증명한다. 내부 WinAPI 함수의 스택 사용 방식 같은 것은 계약이 아니며, 사전 공지 없이 언제든 바뀔 수 있다”는 부분이 예전에 읽은 훌륭한 글을 떠올리게 함
    요지는 충분히 성공한 API에는 비공개 API라는 것이 존재하지 않는다는 내용이었음

    • 그 글을 찾아서 링크해주면 좋겠음. 논리가 궁금함
    • 이와 관련된 XKCD 만화가 있는 걸로 알고 있음