안드로이드 게임 해킹 - 메모리 치팅

게임 해킹에 관심이 있으신 분이라면 한 번쯤 '치트엔진' 이라는 툴을 사용해 본 경험이 있을 것이다. 해당 툴에서 제공하는 기능은 아주 많지만, 그 중에서 가장 널리 사용되는 기능은 게임 내의 특정 값(게임 머니, 체력, 마나 등등..)이 저장된 주소를 알아내어 그 값을 변조시키는 기능이다. 이 장에서 다루고자 하는 것이 바로 이것, '메모리 치팅' 이다.


메모리 치팅은 '사용자가 원하는 대로 게임 데이터를 바꾸는 기술' 이라고 정의할 수도 있지만, 좀 더 컴퓨터공학적인 관점에서 본다면 '타 프로세스의 메모리 값을 보고, 바꾸는 기술' 이라고 정의할 수도 있다. 그럼 일단 첫 번째 문제, 어떻게 타 프로세스의 메모리 값을 보고(Read), 바꿀 수(Write) 있을까?


타 프로세스의 메모리 값을 보고(Read), 바꾸는(Write) 방법


방법 1. 게임 프로세스에 Attach 하라


2장에서도 잠깐 언급했듯이, 디버거는 디버기의 레지스터 뿐만 아니라 메모리까지도 보고, 바꿀 수 있다. 즉, 게임 프로세스에 Attach 만 할 수 있다면 타 프로세스의 메모리 값을 보고, 바꾸는 문제는 해결된다. 하지만 내가 하고자 하는 것은 '일반 어플리케이션'에 대한 해킹이 아닌, '보안 솔루션이 적용된 어플리케이션'에 대한 해킹이고, 해당 보안 솔루션은 게임 프로세스에 직접적으로 Attach 하는 것을 방어하고 있었기 때문에 첫 번째 방법인 '게임 프로세스에 Attach 하라' 는 사용할 수 없었다.


방법 2. 공격 코드를 게임 프로세스 내에 삽입하라


타 프로세스의 메모리 값을 보고, 바꾸는 것은 어렵지만, 내 프로세스의 메모리 값을 보고, 바꾸는 것은 아주 쉽다. 즉, 공격 코드가 게임 프로세스가 아닌 타 프로세스에 있다면 우리의 목표는 '타 프로세스의 메모리 값을 보고, 바꾸는 것'이 되기에 험난한 길을 걸어야만 하지만, 공격 코드가 게임 프로세스에서 동작하고 있다면 우리의 목표는 '내 프로세스의 메모리 값을 보고, 바꾸는 것'이 되기에 아주 쉽게 메모리 치팅을 할 수 있다. 그렇다면, 공격 코드가 게임 프로세스에서 동작하게 하려면 어떻게 해야 할까? 우리는 이미 한번 이것을 다뤘었다. 스피드핵을 만들 때 몇몇 함수들을 후킹했던 것을 기억하는가? printf 함수를 내가 만든 함수인 fake_printf 함수로 후킹하면, 해당 프로세스에서 printf 함수를 호출할 때 마다 fake_printf 함수가 호출된다. 즉, 후킹을 통해 내가 만든 함수, 내가 만든 공격 코드를 게임 프로세스 내에서 동작하게 할 수 있다.


// 게임 머니가 0x1000 번지에 저장되어 있다고 가정, 게임 머니를 2배로 만드는 공격 코드

fake_printf

{

    int money = *((int *) 0x1000);

    *((int *) 0x1000) = money * 2;

}


방법 3. /proc/pid/mem 파일을 보고(Read), 바꿔라(Write) (루트 권한 필요)


안드로이드에서는 각 프로세스의 가상 메모리를 /proc/pid/mem 파일을 통해 관리하고 있다. 또한 /proc/pid/mem 파일은 해당 프로세스의 가상 메모리와 동일한 구조를 가지기 때문에, 예를 들어 해당 프로세스 가상 메모리의 0x4000 번지에 저장된 값을 보고 싶다면 /proc/pid/mem 파일의 0x4000 오프셋에 저장된 값을 보면 되는 식으로, 아주 쉽게 타 프로세스의 메모리 값을 보고, 바꿀 수 있다.


타 프로세스의 메모리 값을 보고(Read), 바꾸는(Write) 것은 위에서 언급한 세 가지 방법을 사용하면 된다. 그렇다면 사용자가 바꾸고자 하는 게임 데이터, 예를 들어 게임 머니라 한다면, 해당 게임 머니가 저장된 메모리 주소는 어떻게 알아낼 수 있을까?


게임 데이터가 저장된 주소를 찾는 방법


일반적으로 메모리 치팅에서 원하는 주소를 찾는 메커니즘은 아래 코드와 같다. 게임 머니가 저장된 주소를 찾고자 한다면, ① 우선 현재 게임 머니 값과 동일한 값을 가지는 모든 주소들을 찾아낸다(first_search). ② 그리고 게임을 진행하여 게임 머니 값을 바꾼 후, 방금 찾아낸 주소들에서 바뀐 게임 머니 값을 가지는 주소들을 추려낸다(next_search). ③ 하나의 주소가 남을 때까지 과정 ②를 반복한다. 


다만 메모리 전체 영역(32 bit 운영체제의 경우 4GB)을 전부 스캔하기에는 너무 오랜 시간이 소요되므로, 스캔할 메모리 영역을 최소화할 필요가 있다.


// 게임 머니가 int 타입으로 저장되어 있다고 가정

first_search(int value)

{

    for(n=0; n<4GB; n+=4)

    {

        if(*n == value)

    new_list.add(n);

    }


    return new_list;

}


next_search(int value, List old_list)

{

    for(n in old_list)

    {

        if(*n == value)

    new_list.add(n);

    }


    return new_list;

}


일반적으로 유니티로 만든 게임의 경우, 게임 머니, 체력, 마나와 같은 값들은 객체 내 필드로써 저장된다. 그리고 유니티 문서(Understanding Automatic Memory Management)에 따르면 객체들은 힙(Heap) 공간에 저장되기 때문에 힙 영역을 스캔할 메모리 영역으로 설정하여도 찾고자 하는 값을 충분히 찾을 수 있을 것이다. 또한 게임 머니, 체력, 마나와 같은 값들은 게임이 실행되면서 지속적으로 읽고, 쓰여지는 값이기 때문에 스캔할 메모리 영역을 Read, Write 권한이 있는 영역으로 제한할 수도 있다. 

(이러한 메모리 매핑 정보(힙 영역 여부, Read, Write 권한 여부 등)는 /proc/pid/maps 파일을 통해 얻어올 수 있다(루트 권한 필요))


메모리 치팅을 막을 수 있을까?


개인적으로, '게임 프로세스의 메모리 값을 보고(Read), 바꾸는(Write) 것' 자체를 막을 수는 없다고 생각한다. 하지만 여러 기법을 통해 메모리 치팅을 '방해'할 수는 있다.


방법 1. 데이터를 저장할 때 암호화하라


// 일반 버전

class Character

{

    int HP;


    setHP(int HP)

    {

        this.HP = HP;

    }

    

    int getHP()

    {

        return this.HP;

    }

}


// 암호화 버전

class Character

{

    int HP;

    int key = 0x10101010;


    setHP(int HP)

    {

        this.HP = HP XOR this.key;

    }

    

    int getHP()

    {

        return this.HP XOR this.key;

    }

}


위 코드와 같이 저장할 때 암호화를, 가져올 때 복호화를 하는 식으로 데이터를 보관하면 게임 사용자의 화면에는 HP가 1,000으로 표기되어도, 실제로 메모리 내에 저장되어 있는 값은 1,000이 아닌 1,000 XOR key 가 되므로, 데이터가 저장된 주소를 쉽게 찾지 못하게 할 수 있다.


방법 2. 데이터를 조각 형태로 저장하라


// 조각 버전

class Character

{

    int fullHP;

    int damaged;


    setHP(int HP)

    {

        this.damaged = this.fullHP - HP;

    }

    

    int getHP()

    {

        return this.fullHP - this.damaged;

    }

}


사실 방법 1과 어떻게 보면 같은 방법이라고 볼 수 있는데, 데이터를 저장할 때 화면에 보이는 값 자체를 저장하지 말고 '화면에 보이는 값을 구성하는 값'들을 저장해놓으면 방법 1과 마찬가지로 데이터가 저장된 주소를 쉽게 찾지 못하게 할 수 있다.


물론 게임 코드가 노출되어 메모리에 저장된 데이터의 형태가 유출된다면 위와 같은 방법들은 무용지물이 된다. 나의 경우, 연구를 진행하면서 '보안 솔루션이 적용된 어플리케이션'에 메모리 치팅을 시도해보았는데 찾고자 하는 데이터가 저장된 주소를 찾을 수 없었다. 하지만 향후 암호화 되어있던 게임 코드를 복호화하여 확인해 본 결과, 게임 스코어를 int 형태가 아닌 string 형태로 저장하고 있음을 알 수 있었고, 그에 맞추어 치팅 툴을 개발하여 메모리 치팅을 성공적으로 끝낼 수 있었다.