검사의 강도는 검사가 실행되는 위치가 정합니다
대부분의 유니티 안티치트 코드는 같은 모습으로 시작합니다. 무언가를 확인하고 게임을 계속 실행할지 정하는 C# 메서드입니다. 시작하기에 가장 자연스러운 곳이지만, 공격자가 가장 먼저 들여다보는 곳이기도 합니다.
이 글은 OZeroSecurity를 만들면서 그 로직의 중요한 부분을 관리 코드(C#)에서 네이티브 C++ 계층으로 옮긴 이유와, 그렇게 해서 실제로 달라지는 점, 그리고 마찬가지로 중요한 달라지지 않는 점을 정리합니다.
공격자가 C# 검사를 다루는 방법
검사가 무엇을 하든 공격은 대개 세 단계입니다. 읽고, 패치하고, 그 패치를 재사용합니다.
읽기. Mono 빌드에는 IL이 들어 있어서 .NET 디컴파일러로 메서드 이름까지 거의 원래 C#에 가깝게 되돌릴 수 있습니다. IL2CPP 빌드는 코드를 네이티브 기계어로 컴파일하지만 global-metadata.dat가 함께 배포되고, 이 파일로 클래스·메서드 이름과 바이너리 안의 위치를 복원할 수 있습니다. 이 과정은 IL2CPP는 정말 안전한가에서 다뤘습니다. 어느 쪽이든 IsTampered나 CheckSpeed 같은 메서드는 쉽게 찾힙니다.
패치. 검사는 거의 언제나 분기로 끝납니다.
if (Integrity.IsTampered())
{
Application.Quit();
}
IsTampered가 항상 false를 반환하게 만들면 검사는 사라집니다. Mono 빌드에서는 작은 IL 수정이고, IL2CPP 빌드에서는 메타데이터가 알려 준 위치의 몇 바이트를 바꾸는 일입니다.
재사용. 패치는 한 번 만들면 같은 빌드의 모든 사본에 그대로 통합니다. 개조 APK가 퍼지는 방식이 이렇습니다. 한 사람이 작업하고 나머지는 결과물을 내려받습니다.
여기에 특별한 기술이 필요하지는 않습니다. 도구는 공개돼 있고 대부분 사용법 글도 있습니다.
관리 힙에 숨긴 값의 문제
두 번째 약점은 보호하려는 값입니다. 흔한 방법은 게임 값을 C# 필드에 XOR로 마스킹해 두고 읽을 때 풀어 쓰는 것입니다.
문제는 모든 것이 놓인 위치입니다. 키, 마스킹된 값, 실제 값이 모두 관리 힙에 있고, 서로 가까이 있는 경우가 많습니다. 적절한 시점의 메모리 덤프에서는 셋의 관계가 드러나고, 값이 바뀌는 것을 몇 번 지켜보는 메모리 스캐너로 대개 짝을 찾을 수 있습니다. 또 값을 풀 때마다 할당이 생기기 쉬워서, 자주 실행되는 게임 코드에 GC 부담이 더해집니다.
네이티브 C++로 옮기면 달라지는 것
C#을 읽기 어렵게 만드는 데 힘을 쓰지 않았습니다. 중요한 부분을 C# 밖으로 옮겼습니다.
보안 값은 네이티브 계층에 둡니다. OZeroSV_Int, OZeroSV_Float 같은 타입은 값을 네이티브 메모리에 둡니다. C# 쪽에는 숫자가 아니라 토큰만 있어서, 값을 그대로 검색해서는 찾을 수 없습니다. 읽기와 쓰기에서 관리 힙 할당이 생기지 않도록 설계했습니다.
핵심 검사는 네이티브 코드에서 실행합니다. 빌드 무결성과 서명 검증, 속도·시간 검사, 메모리 변조 검사, 인젝션·후킹 신호를 네이티브 계층에서 판단합니다. C# 코드는 설정을 넘기고 결과를 받을 뿐, 탐지 로직 자체는 디컴파일러로 보이는 C# 안에 있지 않습니다.
네이티브 코드는 더 어려운 대상입니다. 네이티브 코드도 디스어셈블할 수 있습니다. 다만 모든 함수 이름을 알려 주는 메타데이터 파일이 없습니다. 그 안에서 검사를 찾고, 이해하고, 패치하는 데는 C# 메서드를 고치는 것보다 훨씬 많은 시간이 들고, 저희가 높이려는 것이 바로 그 비용입니다.
로컬에서 동작합니다. 서버, 계정, 라이선스 키가 필요하지 않습니다. 검사는 플레이어 기기의 게임 안에서 실행됩니다.
네이티브로 옮겨도 달라지지 않는 것
네이티브 계층은 공격 비용을 높입니다. 게임을 뚫리지 않게 만들지는 않으며, 그 점을 분명히 말해 두는 편이 낫다고 생각합니다.
- 네이티브 코드도 분석할 수 있습니다. 시간과 의지가 있는 공격자는 네이티브 코드도 역분석합니다. 목표는 시도마다 더 느리고 비싸게 만드는 것이지, 일어나지 않는다고 약속하는 것이 아닙니다.
- Mono 빌드는 더 많이 드러납니다. 네이티브 계층은 Mono에서도 동작하지만 게임의 나머지 코드는 여전히 읽기 쉽습니다. 유니티가 지원하는 대상이라면 출시 빌드는 IL2CPP로 만드세요.
- WebGL에는 네이티브 계층이 없습니다. 브라우저에서는 제한적인 보호만 적용됩니다.
- 클라이언트는 최종 판단자가 아닙니다. 온라인 게임에서 재화, 결제, 랭킹은 직접 운영하는 서버에서 확인해야 합니다. 클라이언트 보호는 치트를 어렵게 하고, 무엇을 인정할지는 서버 검증이 정합니다. (그 부분이 필요한 팀을 위해 OZero Pro가 서버 검증과 텔레메트리를 제공합니다.)
실무 체크리스트
- 출시 빌드는 IL2CPP와 관리 코드 스트리핑으로 만듭니다.
- 무결성 정보에 서명합니다. 개발자 시크릿을 만들고 manifest 서명을 켭니다(Auto Setup이 둘 다 적용할 수 있습니다).
- 재화, 점수처럼 고칠 가치가 있는 값은 일반 필드가 아니라 보안 값 타입에 둡니다.
- 돈이나 랭킹에 걸린 판단은 서버가 최종 결정하게 합니다.
- 출시 전에 변조 경로를 직접 시험합니다. 빌드를 리패키징하고, 세이브 파일을 고쳐 보고, 설정한 대응이 실제로 일어나는지 확인합니다.
검사를 네이티브로 옮기는 것은 한 번의 기교가 아닙니다. 공격자가 작업해야 하는 장소를 바꾸고, 그곳에서의 단계마다 시간이 더 들게 만드는 일입니다.