유니티 빌드 파이프라인 4 - 기획 데이터 암호화와 키 관리

이번 글에서는 아래 내용을 정리한다.

  • IL2CPP가 지키는 것과 지키지 못하는 것, 그래서 무엇을 암호화하나
  • 확장자가 아니라 폴더로 대상을 정한 이유
  • 파일 형식(MGENC1)과 PowerShell 도구, C# 로더의 교차 검증
  • 키를 어디에 두고, 게임 안에는 어떤 형태로 들어가나
  • 키 교체 절차
  • 이 방식의 한계

IL2CPP와 암호화는 막는 것이 다르다

IL2CPP는 코드를 지킨다. Mono 빌드의 Assembly-CSharp.dll은 디컴파일러로 원본에 가깝게 복원되지만, IL2CPP는 네이티브 코드로 바꿔 로직 복원을 어렵게 한다. 다만 global-metadata.dat에 클래스, 메서드, 문자열 이름이 남고, 데이터 파일과 에셋은 IL2CPP와 무관하게 그대로 노출된다. 데이터는 따로 암호화해야 한다.

대상 암호화 이유
기획 테이블: 확률, 드롭율, 가격, 성장 곡선 한다 유저가 메모장으로 고치거나 밸런스를 통째로 읽어가는 것을 막는 핵심
세이브 파일 한다 + HMAC (게임 쪽 후속 작업) 타이쿤 장르는 세이브 숫자 고치기가 가장 흔한 치트
서버 주소, API 키 난독화 수준 클라이언트 안의 값은 결국 뽑힌다
텍스처, 모델, 사운드, 씬 안 한다 추출 도구로 어차피 나오고, 막으려면 에셋 로딩 구조를 통째로 바꿔야 한다

대상은 폴더로 정한다

유니티 빌드 결과에서 실행 중에 파일 경로로 읽히는 곳은 Data 폴더 아래 StreamingAssets뿐이다. 그래서 기획 데이터는 프로젝트의 Assets/StreamingAssets/Data/에 두고, 암호화 대상은 그 폴더 아래의 파일로 정했다. 확장자는 안전장치로 목록만 둔다(json, csv, tsv, txt, xml, yaml, yml, bytes, lua, ndt). 목록에 없는 확장자는 경고만 내고 건너뛴다.

폴더 기준인 이유는 둘이다. StreamingAssets 루트에는 Addressables 카탈로그나 플러그인이 자기 파일을 두기도 해서 거기까지 암호화하면 엔진이 못 읽는다. 그리고 기획자가 새 테이블을 추가할 때 어디에 넣을지만 알면 된다.

확장자는 바꾸지 않는다. items.csv는 암호화된 뒤에도 items.csv다. 파일 앞의 식별자로 구분하므로 로딩 코드는 개발 중(평문)과 배포판(암호화)에서 같은 경로를 같은 함수로 읽는다. 파일 형식 자체(CSV, JSON, 탭 구분 텍스트)는 아직 정하지 않았다. 예전 회사의 .ndt(엑셀 탭을 유니코드 텍스트로 저장하고 data와 end 표식 사이를 파싱)도 후보인데, UTF-16이라 git diff가 안 보이는 문제는 .gitattributes의 working-tree-encoding=UTF-16LE-BOM으로 풀 수 있다.

형식과 도구

"MGENC1" (6바이트)  +  IV (16)  +  HMAC-SHA256 (32, IV+암호문)  +  AES-256-CBC/PKCS7 암호문
encKey = SHA256(key + "enc"),  macKey = SHA256(key + "mac"),  key = 32바이트

HMAC을 넣은 이유는 변조 감지다. 값을 한 바이트만 고쳐도 로딩이 실패한다. AES-GCM을 쓰면 한 번에 되지만 유니티의 .NET 프로필에는 AesGcm이 없어서 CBC와 HMAC(Encrypt-then-MAC)으로 갔다.

도구는 PowerShell 5.1로 만들었다. Windows 어디서나 추가 설치 없이 돌고, 배포 Job이 쓰는 것과 사람이 쓰는 것이 같은 프로그램이다.

DataCrypt.cmd selftest -DevKey
DataCrypt.cmd encrypt <파일|폴더> -KeyFromServer -Out .\enc
DataCrypt.cmd decrypt <파일|폴더> -KeyFromServer -Out .\plain
DataCrypt.cmd info    <파일|폴더> -KeyFromServer          암호화 여부와 키 일치(HMAC) 확인

게임 쪽 로더는 C# 하나다. DataCrypto.ReadAllText(path)가 식별자를 보고 암호화면 검증하고 해독하며, 평문이면 에디터와 개발 빌드에서만 그대로 돌려준다. 릴리즈 빌드는 평문을 거부한다. 파일이 없거나 비어 있거나 변조되면 예외를 던진다. 조용히 넘어가지 않는다.

두 구현이 같은 형식인지는 실제로 교차 검증했다. PowerShell 도구로 암호화한 파일을 열려 있는 유니티 에디터 안에서 C# 로더로 읽어 원문과 일치하는 것을 확인했고, 반대로 배포된 파일을 서버에서 Python과 openssl로 HMAC 검증 후 해독해 보았다. 도구 자체도 매 배포마다 자체 테스트(라운드트립, 변조 감지, 잘못된 키 감지)를 먼저 돈다. 암호화 도구와 로더는 서로 다른 언어로 두 번 구현되는 것이라 이 교차 검증을 빼면 배포판에서만 터진다.

한 번은 확장자 목록을 도구에 넘길 때 배열이 하나의 문자열로 전달되어 모든 파일이 목록에 없음으로 건너뛰어졌다. 배포는 성공했고 데이터는 평문이었다. 성공 로그의 ENCRYPTED=0을 보고서야 알았다. 도구가 쉼표 문자열도 배열로 정규화하게 고쳤다.

키는 어디에

위치 역할
Jenkins 자격증명 data-crypto-key 원본. 빌드가 게임에 넣고, release 빌드와 배포가 암호화할 때 쓴다
컨트롤러 VM의 root 전용 백업 파일 원본과 같은 값. 도구의 -KeyFromServer가 SSH로 읽어 메모리에서만 쓴다
게임 클라이언트 빌드 때 컴파일되어 들어간다. 유저 PC에 있으니 작정하면 추출 가능

git 어디에도 키가 없다. 프로젝트에 커밋된 DataCryptoKey.cs는 0으로 채운 개발용이고, 빌드가 실제 키로 덮어쓴 뒤 끝나면 되돌린다.

처음 구현은 키를 const string으로 넣었다. 그러자 IL2CPP 빌드의 global-metadata.dat에 64자리 16진수 문자열이 그대로 남았다. 문자열 검색 한 번이면 나오는 상태였다. 지금은 빌드마다 무작위 마스크를 만들어 키와 XOR한 조각 8개를 byte 배열로 넣고 실행 시 조합한다. 문자열 형태로는 어디에도 없다. 그래도 클라이언트 안에 있는 키는 마스크와 조각을 찾아 XOR하면 복원된다. 목표는 쉽게 못 열게 하는 것, 즉 메모장으로 고치는 사람과 치트 배포자를 막는 것이고, 예전 회사의 .ndt도 같은 전제였다.

static readonly byte[] q2 = { 0x.., ... };   // 마스크 조각
static readonly byte[] p0 = { 0x.., ... };   // 키 XOR 마스크 조각
...
public static byte[] Bytes()
{
    var k = new byte[32];
    Mix(k, 0, p0, q0); Mix(k, 8, p1, q1); Mix(k, 16, p2, q2); Mix(k, 24, p3, q3);
    return k;
}

키 교체

컨트롤러 VM에서 스크립트 한 줄로 새 키를 만들어 자격증명과 백업 파일을 함께 갱신하고, 이전 키는 날짜 붙은 파일로 남긴다. 그다음 release 빌드와 배포를 한 번씩 돌리면 게임과 데이터가 같은 새 키를 갖는다. 새 키 이전의 배포판은 새 데이터를 읽지 못한다. 그게 교체의 목적이니 정상이다. 이번에 키 문자열이 노출된 것을 발견한 뒤 실제로 한 번 교체했고, 옛 키로는 배포 파일이 열리지 않는 것까지 확인했다.

클라이언트 안의 키는 숨기는 것이지 지키는 것이 아니다. 실행 중에는 데이터가 메모리에 풀려 있어 메모리 편집 도구도 막지 못한다. 싱글 게임은 여기까지가 현실적인 선이고, 경쟁 요소가 붙으면 판정을 서버가 가져가야 한다.

마치며

암호화 자체보다 어디까지 지키려는 것인지를 먼저 정하는 게 중요했다. 기획 테이블과 세이브만 지키고 에셋은 포기한다, 키는 클라이언트에 있지만 문자열로는 없다, 형식은 도구와 로더가 교차 검증한다. 이 세 줄이 결론이다. 마지막 글은 하루를 마감하면서 한 보안 점검과 거기서 나온 문제들이다.