치트 발견 - “정당한 획득 직후 값 변조”
Flutter 기반 텍스트 게임을 운영하면서, 일부 유저의 스탯 데이터에서 수상한 패턴을 발견했다.
예를 들어 공부 경험치 스탯을 조작한 유저의 로그를 보면, 게임 내 정당한 방식으로 경험치를 획득한 직후 값이 비정상적으로 변하는 흐름이 반복되고 있었다.
[12:03:01] 공부 경험치 +5 → 105
[12:03:03] 공부 경험치 ??? → 99999 ← 비정상
이 패턴이 의미하는 바는 명확했다. 유저가 정당하게 값을 획득하는 순간을 이용해 메모리에서 해당 값의 주소를 특정한 뒤, 직접 값을 덮어쓰고 있다는 것이다. 전형적인 Cheat Engine 사용 정황이었다.
이런 분석이 가능했던 건, 게임 데이터 파일에 유저의 행동과 스탯 변화를 세세하게 로깅해두었기 때문이다. 만약 로그가 부실했다면 단순히 “값이 이상하다” 정도로만 파악하고 원인을 추적하지 못했을 것이다.
Cheat Engine은 어떻게 동작하는가

Cheat Engine은 가장 널리 알려진 메모리 스캐너/에디터 프로그램이다. 동작 원리는 단순하다.
- 게임 프로세스의 메모리를 스캔하여 특정 값(예: 현재 체력
100)을 가진 주소들을 찾는다 - 게임 내에서 값을 변화시킨다 (체력이
95로 바뀌도록 피격) - 변화된 값으로 다시 스캔하여 후보 주소를 좁힌다
- 최종적으로 특정된 메모리 주소의 값을 원하는 값으로 덮어쓴다
핵심은 메모리에 저장된 실제 값과 게임이 표시하는 값이 동일하다는 전제에 기반한다는 점이다. int health = 100이라면 메모리 어딘가에 100이라는 정수가 그대로 들어있고, Cheat Engine은 이 값을 검색해서 찾아낸다.
그렇다면 방어 전략은 간단하다 - 메모리에 저장되는 값과 실제 사용하는 값을 다르게 만들면 된다.
XOR 난독화 - 단순하지만 효과적인 방어
이 문제를 조사하던 중, 외국 게임 커뮤니티에서 흥미로운 글을 발견했다. 한 개발자가 간단한 비트 연산(XOR)을 통한 난독화만으로 시중 치트 엔진의 거의 대부분을 방어했다는 내용이었다.
아이디어는 간단하다.
- 객체 생성 시 랜덤한
mask값을 하나 생성한다 - 값을 저장할 때
value XOR mask로 변환하여 저장한다 - 값을 읽을 때 다시
stored XOR mask로 복원한다
XOR 연산의 성질상 A XOR B XOR B = A이므로, 같은 mask로 두 번 XOR하면 원래 값이 복원된다.
이렇게 하면 메모리에 저장되는 값은 실제 게임 내 값과 완전히 다른 숫자가 된다. 예를 들어 체력이 100이고 mask가 742라면, 메모리에는 100 XOR 742 = 642가 저장된다. Cheat Engine으로 100을 검색해도 절대 찾을 수 없다.
실제 값: 100
mask: 742
저장 (XOR): 100 ^ 742 = 642 ← 메모리에 저장되는 값
복원 (XOR): 642 ^ 742 = 100 ← 읽을 때 원래 값 복원
게다가 각 인스턴스마다 mask가 랜덤으로 생성되므로, 같은 값이라도 메모리에서의 표현이 매번 달라진다.
Dart 구현 - ObfuscatedInt
이 아이디어를 Dart에 그대로 반영한 것이 ObfuscatedInt 클래스다.
import 'dart:math';
class ObfuscatedInt {
late final int _mask;
late int _value;
ObfuscatedInt(int value) {
_mask = Random().nextInt(1000);
_value = value ^ _mask;
}
int get value => _value ^ _mask;
set value(int value) {
_value = value ^ _mask;
}
}구현은 놀라울 정도로 단순하다.
_mask: 생성 시 랜덤으로 결정되는 XOR 키.late final이므로 이후 변경 불가_value: 난독화된 상태로 저장되는 실제 데이터- getter/setter를 통해 외부에서는 일반
int처럼 사용 가능
실제 게임 코드에서의 적용도 깔끔하다. getter/setter 패턴으로 감싸면 기존 코드를 거의 수정하지 않고 적용할 수 있다.
class User extends Person {
late final ObfuscatedInt _money;
late final ObfuscatedInt _studyExp;
late final ObfuscatedInt _hp;
int get money => _money.value;
set money(int value) => _money.value = value;
int get studyExp => _studyExp.value;
set studyExp(int value) => _studyExp.value = value;
// 사용하는 쪽에서는 차이를 느끼지 못한다
// user.money += 500; ← 그대로 동작
}돌아보며
이 경험에서 두 가지를 느꼈다.
간단한 연산만으로도 충분한 방어가 가능하다. XOR 한 번이면 끝나는 수준의 경량 연산이 시중 치트 엔진 대부분을 무력화할 수 있다. 물론 리버스 엔지니어링으로 mask를 추출하면 뚫을 수 있지만, 대다수 일반 유저가 사용하는 치트 엔진은 단순 메모리 스캔 기반이라 이 정도면 실질적으로 충분하다.
세세한 로깅이 문제의 출발점이었다. 치트를 탐지할 수 있었던 것도, Cheat Engine 사용이라고 추측할 수 있었던 것도, 전부 유저의 행동과 데이터 변화를 상세하게 기록해두었기 때문이다. “정당한 획득 직후 값 변조”라는 패턴은 로그 없이는 절대 보이지 않는다. 무엇이든 기록해두면 언젠가 쓸모가 있다.