본문 바로가기
개발/Unity

[Unity] 클라이언트에서 사용할 수 있는 안티치트 기법 (1)

by 김뜬뜬 2025. 11. 3.

안녕하세요 주인장입니다.

 

게임을 좋아하신다면 한 번쯤은 치트엔진이나 치트오매틱 같은 프로그램을 다뤄보신 적 있으실 겁니다.

이런 툴들은 게임 메모리를 직접 수정해서 체력, 골드, 경험치 등을 마음껏 조작할 수 있게 해주죠.

 

그래서 오늘은 클라이언트 측에서 치트엔진 같은 툴을 통한 데이터 변조를 방지하는 방법에 대해 다뤄보겠습니다.

 

물론, 안티치트의 핵심은 서버 검증 입니다.

서버와의 데이터 동기화 및 무결성 검증이 가장 확살한 방법이지만,

이번 글에서는 클라이언트 단에서 할 수 있는 최소한의 방어 기법을 중심으로 살펴보겠습니다.

 

핵심 기능 코드

아래는 제가 주로 사용하는 메모리 변조 방지 스크립트 입니다.

using System;
using System.Security.Cryptography;
using System.Text;
using UnityEngine;

public class ProtectedValue
{
    private byte[] SecretValue; // 원본 데이터
    private byte[] encryptedSecret; // 암호화된 데이터
    private byte[] key; // AES 암호화에 사용할 키
    private byte[] iv; // 초기화 벡터(IV)

    public ProtectedValue(int value)
    {
        key = GenerateKey(); // 암호화 키 생성
        iv = GenerateIV(); // 초기화 벡터 생성
        SecretValue = BitConverter.GetBytes(value);
        encryptedSecret = Encrypt(SecretValue, key, iv); // 값 암호화
    }

    public ProtectedValue(bool value)
    {
        key = GenerateKey();
        iv = GenerateIV();
        SecretValue = Encoding.ASCII.GetBytes(value.ToString());
        encryptedSecret = Encrypt(SecretValue, key, iv);
    }

    public ProtectedValue(string value)
    {
        key = GenerateKey();
        iv = GenerateIV();
        SecretValue = Encoding.ASCII.GetBytes(value);
        encryptedSecret = Encrypt(SecretValue, key, iv);
    }

    public void ApplyNewValue(bool value)
    {
        SecretValue = Encoding.ASCII.GetBytes(value.ToString());
        encryptedSecret = Encrypt(SecretValue, key, iv);
    }

    public void ApplyNewValue(int value)
    {
        SecretValue = BitConverter.GetBytes(value);
        encryptedSecret = Encrypt(SecretValue, key, iv);
    }

    public void ApplyNewValue(string value)
    {
        SecretValue = Encoding.ASCII.GetBytes(value);
        encryptedSecret = Encrypt(SecretValue, key, iv);
    }

    public bool CompareValue(bool value)
    {
        return value == GetBool();
    }

    public bool CompareValue(int value)
    {
        return value == GetInt();
    }

    public bool CompareValue(string value)
    {
        return value == GetString();
    }

    public bool GetBool()
    {
        byte[] bytes = Decrypt(encryptedSecret, key, iv);
        return bool.Parse(Encoding.ASCII.GetString(bytes));
    }

    public int GetInt()
    {
        byte[] bytes = Decrypt(encryptedSecret, key, iv);
        return BitConverter.ToInt32(bytes, 0);
    }

    public string GetString()
    {
        byte[] bytes = Decrypt(encryptedSecret, key, iv);
        return Encoding.ASCII.GetString(bytes);
    }

    // AES 암호화 함수
    private byte[] Encrypt(byte[] data, byte[] key, byte[] iv)
    {
        using (Aes aesAlg = Aes.Create())
        {
            aesAlg.Key = key;
            aesAlg.IV = iv;

            ICryptoTransform encryptor = aesAlg.CreateEncryptor(aesAlg.Key, aesAlg.IV);

            using (System.IO.MemoryStream msEncrypt = new System.IO.MemoryStream())
            {
                using (CryptoStream csEncrypt = new CryptoStream(msEncrypt, encryptor, CryptoStreamMode.Write))
                {
                    csEncrypt.Write(data, 0, data.Length);
                    csEncrypt.FlushFinalBlock();
                    return msEncrypt.ToArray();
                }
            }
        }
    }

    // AES 복호화 함수
    private byte[] Decrypt(byte[] cipherText, byte[] key, byte[] iv)
    {
        using (Aes aesAlg = Aes.Create())
        {
            aesAlg.Key = key;
            aesAlg.IV = iv;

            ICryptoTransform decryptor = aesAlg.CreateDecryptor(aesAlg.Key, aesAlg.IV);

            using (System.IO.MemoryStream msDecrypt = new System.IO.MemoryStream(cipherText))
            {
                using (CryptoStream csDecrypt = new CryptoStream(msDecrypt, decryptor, CryptoStreamMode.Read))
                {
                    byte[] decrypted = new byte[cipherText.Length];
                    int decryptedCount = csDecrypt.Read(decrypted, 0, decrypted.Length);
                    Array.Resize(ref decrypted, decryptedCount);
                    return decrypted;
                }
            }
        }
    }

    // 키와 초기화 벡터(IV) 생성 함수
    private byte[] GenerateKey()
    {
        using (RNGCryptoServiceProvider rng = new RNGCryptoServiceProvider())
        {
            byte[] key = new byte[32]; // 256비트 키
            rng.GetBytes(key);
            return key;
        }
    }

    private byte[] GenerateIV()
    {
        using (RNGCryptoServiceProvider rng = new RNGCryptoServiceProvider())
        {
            byte[] iv = new byte[16]; // 128비트 IV
            rng.GetBytes(iv);
            return iv;
        }
    }
}

 

이 클래스는 게임 내에서 중요한 값을 암호화해서 메모리에 저장하는 역할을 합니다.

즉, 외부에서 치트엔진으로 스캔해도 값이 암호화된 상태로 보이기 때문에 직접 수정이 어렵습니다.

 

핵심아이디어

  1. 실제 값(int, bool, string)을 바이트 배열로 변환
  2. 그 값을 AES 알고리즘으로 암호화
  3. 메모리에 저장될 때는 암호화된 데이터만 존재
  4. 값을 읽을 때만 복호화해서 원래 데이터로 복원

 

주요 동작 원리

  1. 값 저장시
    SecretValue = BitConverter.GetBytes(value);
    encryptedSecret = Encrypt(SecretValue, key, iv);

    평문 값을 AES로 암호화하여 encryptedSecret 에 저장합니다

  2. 값 읽을 때
    byte[] bytes = Decrypt(encryptedSecret, key, iv);
    return BitConverter.ToInt32(bytes, 0);

    메모리에 저장된 암호문을 복호화해서 원래 값을 반환합니다.

  3. 매번 다른 키와 IV
key = GenerateKey();
iv = GenerateIV();


각 인스턴스마다 다른 AES 키와 초기화 벡터를 사용합니다

 

 

실제 사용 예시

예시를 위해 간단한 스크립트를 만들어 보았습니다

using UnityEngine;
using UnityEngine.UI;

public class NewMonoBehaviourScript : MonoBehaviour
{
    public GameObject cheaterPopup; // 치트 탐지 시 표시할 팝업
    public Text hpText;             // 체력 UI 표시용 텍스트
    public int Health = 100;        // 로컬 체력 값
    private ProtectedValue protectedhealth; // 암호화된 체력 저장소

    void Awake()
    {
        // 1) ProtectedValue 초기화 및 정상 체력 저장
        protectedhealth = new ProtectedValue(Health);

        // 2) 치트 팝업 초기 비활성화
        if (cheaterPopup != null) cheaterPopup.SetActive(false);
    }

    void Update()
    {
        // 3) 무결성 체크: 외부 변조 여부 확인
        if (!protectedhealth.CompareValue(Health))
        {
            // 4) 변조 발견 시 즉시 복원
            Health = protectedhealth.GetInt();
            hpText.text = $"HP : {Health}";

            // 5) 탐지 알림: 팝업 표시
            if (cheaterPopup != null) cheaterPopup.SetActive(true);

            // TODO: 서버 로그 전송, 강제 로그아웃 등 추가 조치 가능
        }
    }

    // 6) 정상 체력 감소 처리
    public void TakeDamage(int damage)
    {
        int currentHP = Health;
        int newHP = currentHP - damage;

        // 7) ProtectedValue와 로컬 값 동기화
        Health = newHP;
        protectedhealth.ApplyNewValue(newHP);

        // 8) UI 갱신
        hpText.text = $"HP : {newHP}";
    }
}

 

 

사용 영상:

https://youtu.be/QNappXCaZ-U?si=wur5HYYDFCchwuMC

 

 

TakeDamage 함수를 통해 정상적으로 체력이 감소할 때는 아무 문제가 없지만,
치트 엔진을 사용해 값을 변조하면 치트 탐지 팝업이 활성화되는 것을 확인할 수 있습니다.

이번 예제에서는 탐지 로직을 Update()에서 처리했지만,
실제 프로젝트에서는 이벤트 기반 방식으로 처리하거나,
탐지 후 서버 로그 전송, 세션 차단 등 추후 조치를 어떤 방식으로 할지는
프로젝트의 성격과 요구사항에 맞게 자유롭게 응용하면 됩니다.

 

 

오늘의 기록은 여기까지. 주인장은 이만 로그아웃 합니다, 모두 평안한 밤 보내세요!