Tech
앱 보안을 강화하는 유니티 코드 난독화 - 1부
2023. 11. 15


안녕하세요, 공통 모듈 개발과 기술 R&D를 통해 쿡앱스의 기술적 성장을 위한 업무를 맡는 기술지원팀 클라이언트 프로그래머 이진호입니다. 이번 아티클에서는 코드 난독화를 적용해야 하는 이유와 다양한 수준의 보호 방법, 난독화를 적용할 때 주의할 부분을 사례와 함께 소개하겠습니다.

 


 

코드 난독화란?

코드 난독화란 배포된 바이너리를 통해 코드를 분석하여 해석 및 조작을 어렵게 하기 위한 방법입니다. 난독화된 코드는 제작자의 원래 의도를 벗어나지 않게 동작하고, 제삼자가 배포된 코드를 수정할 수 없도록 해서 오직 실행만 가능하게 하는 것을 목표로 합니다.

 


 

코드 난독화가 필요한 이유

코드 난독화는 개인 또는 기업의 지적재산을 해커가 개인의 이익을 위해 역공학으로 무단 사용 및 배포하는 행위를 막기 위해 필요합니다. 난독화 방법에 따라 다양한 수준으로 코드를 보호할 수 있는데, 이를 통해 해커가 더욱 복잡한 분석, 시간, 등 비용적인 부담을 갖도록 하여 더 이상 코드 분석을 의미 없게 하거나 포기하도록 만들 수 있습니다.

 


 

어떻게 유니티 게임을 분석하고 조작하나요?

인터넷 검색을 해보면 유독 유니티 엔진으로 만든 게임이 Mod APK가 많이 보입니다. 유니티로 제작한 모바일 게임이 많아서일 수도 있지만 유니티로 제작한 게임이 Mod APK를 만들기 쉽다는 것도 중요한 원인입니다. 유니티가 Mono를 기반에 두고 만들어진 엔진이기 때문입니다.

Mono는 C# 컴파일러와 공통 언어 런타임을 포함해, Ecma 표준을 준수하는 닷넷 프레임워크의 오픈 소스 버전입니다. Mono는 JIT 컴파일러를 사용하여 중간 언어(IL, Intermediate Language)로 DLL 파일을 생성합니다. 이때 생성된 DLL은 간단한 디컴파일러를 이용해 원본 코드에 가까운 복원이 가능해서 보안에 치명적인 단점이 있습니다. 그리고 이는 IL2CPP를 사용하여 DLL 파일을 C++ 코드로 변환한 후 빌드 한 결과물에도 어느 정도 유효합니다.

// 유니티에서 작성한 코드
public class GoldManager
{
    private int _gold = 100;
    public bool UseGold(int gold) 
    {
        if (gold <= _gold)
        {
            _gold -= gold;
            return true;
        }
        return false;
    }
}

// IL2CPP 빌드 결과물을 디컴파일러를 이용해 복원한 코드
[Token(Token = "0x2000002")]
public class GoldManager
{
  [Token(Token = "0x4000001")]
  [FieldOffset(Offset = "0x8")]
  private int _gold;
  [Token(Token = "0x6000001")]
  [Address(Offset = "0x1E2C14", RVA = "0x1E2C14", VA = "0x1E2C14")]
  public bool UseGold(int gold) => new bool();
  [Token(Token = "0x6000002")]
  [Address(Offset = "0x1E2C34", RVA = "0x1E2C34", VA = "0x1E2C34")]
  public GoldManager() {}
}
원본 코드와 IL2CPP 빌드 후 얻은 APK 파일을 디컴파일링한 복원 코드



빌드에서 복원된 결과를 보면 구현 내용은 보이지 않지만 클래스, 메서드, 맴버 변수 등이 쉽게 복원이 됩니다. 여기서 누구나 GoldManager 가 골드에 관련된 클래스이고, UseGold 메서드가 골드를 사용한 후 성공 여부를 bool형으로 반환한다는 것을 유추할 수 있습니다. UseGold  메소드부분을 libil2cpp.so 에서 찾아 항상 true 값으로 반환하도록 수정한다면 무한 골드 사용이 가능합니다.

만일 GoldManager UseGold 등의 이름이 전혀 알 수 없는 문자가 된다면 앱의 분석 난이도가 대폭 상승할 것입니다. 이것이 코드 난독화의 핵심입니다.

 


 

난독화 단계

기존 C#의 코드의 빌드 과정은 아래 이미지와 같습니다. C#의 코드가 컴파일되고 il2cpp를 통해 C++로 변환됩니다. 이때 난독화는 컴파일러에서 IL assembly 단계와 il2cpp를 통해 C++로 변환되는 단계 사이에서 이뤄집니다. 이때 Mono Cecil을 사용하면 Mono로 컴파일된 .dll 수정이 가능합니다.

기존 C#의 코드의 빌드에서 난독화가 추가된 과정

 

난독화 과정은 아래 3단계로 적용됩니다.

  • 1. 컴파일러에서 Assembly-CSharp.dll, UnityEngine.dll 등이 생성

  • 2. 생성된 .dll을 Mono Cecil을 이용하여 수정

  • 3. 수정된 .dll을 il2cpp를 통해 C++로 변환되어 빌드

 


 

난독화 기본 예시

아래는 decompile 이후에 난독화 전, 후를 비교한 것입니다. 누구나 쉽게 유추 가능한 GoldManager 이름이 의미 없는 CCOOFEENFCA 로 바뀌고 메서드 이름도 무작위로 바뀌었습니다. 이렇게 의미 없는 이름으로 난독화가 적용되어, 앱 분석을 방해할 수 있습니다.

// 난독화 전
[Token(Token = "0x2000002")]
public class GoldManager
{
  [Token(Token = "0x4000001")]
  [FieldOffset(Offset = "0x8")]
  private int _gold;
  [Token(Token = "0x6000001")]
  [Address(Offset = "0x1E2C14", RVA = "0x1E2C14", VA = "0x1E2C14")]
  public bool UseGold(int gold) => new bool();
  [Token(Token = "0x6000002")]
  [Address(Offset = "0x1E2C34", RVA = "0x1E2C34", VA = "0x1E2C34")]
  public GoldManager() {}
}

// 난독화 후
[Token(Token = "0x2000002")]
public class CCOOFEENFCA
{
  [Token(Token = "0x4000001")]
  [FieldOffset(Offset = "0x8")]
  private int JODFLMBMOHC;
  [Token(Token = "0x6000001")]
  [Address(Offset = "0x1E2C64", RVA = "0x1E2C64", VA = "0x1E2C64")]
  public bool JMPNPBAGNFF(int BEMMJODKCFC) => new bool();
  [Token(Token = "0x6000002")]
  [Address(Offset = "0x1E2C84", RVA = "0x1E2C84", VA = "0x1E2C84")]
  public CCOOFEENFCA() {}
}
난독화 전(위)과 후(아래) decompile 비교

 


 

Namespace 난독화

만일 클래스, 메서드 이름이 난독화 적용되었더라도 네임스페이스가 빠지면 아래와 같은 상황이 발생합니다. 코드 난독화 시 네임스페이스 제거를 권장합니다.

// class, method가 난독화 되었지만
// namespace의 존재로 'Shop'에 관련된 것이라는 중요한 힌트가 제공됩니다.
namespace Shop
{
    [Token(Token = "0x2000002")]
    public class CCOOFEENFCA
    {
      [Token(Token = "0x6000002")]
      [Address(Offset = "0x1E2C84", RVA = "0x1E2C84", VA = "0x1E2C84")]
      public CCOOFEENFCA() {}
    }   
}
네임스페이스가 제거된 경우

 


 

String 난독화

코드를 작성할 때 네트워크 전송, 로컬 저장에 암호화를 사용하는 경우가 많습니다. 이때 아래의 경우처럼 코드에 직접 중요 키값을 string타입으로 작성 시 보안 위험이 발생합니다.

public static class AesCrypt
{
    public static string GetIK()
    {
        return "ik123456";  // <---- 직접 암호화키를 코드에 사용
    }
    public static string GetIV()
    {
        return "iv123456";  // <---- 직접 암호화키를 코드에 사용
    }
}
중요한 키값을 string타입으로 작성한 경우

 

위와 같이 작성 이후, 빌드에서 복원한 결과는 아래와 같습니다.

[Token(Token = "0x2000002")]
public static class AesCrypt
{
  [Token(Token = "0x6000001")]
  [Address(Offset = "0x1DBE64", RVA = "0x1DBE64", VA = "0x1DBE64")]
  public static string GetIK() => (string) null;
  [Token(Token = "0x6000002")]
  [Address(Offset = "0x1DBEAC", RVA = "0x1DBEAC", VA = "0x1DBEAC")]
  public static string GetIV() => (string) null;
}
난독화 복원 결과

 

복원 결과, 클래스와 메서드는 확인되지만 중요한 키값은 노출이 안됐습니다. 하지만 몇 가지 툴을 이용하여 바이너리 파일의 string을 스캔하여 키값을 찾아낼 수 있습니다. 

string 스캔을 통한 키 값 확인 결과

 

다만, 위의 결과처럼 코드에 문자열 키값을 직접 추가하는 것은 보안에 치명적입니다. 중요한 키값을 코드에 사용 시 이와 같이 복잡한 로직을 사용하는 것을 권장합니다.

public static class AesCrypt
{
    // 난독화 X
    public static string GetIV() => "iv123456";
    // 난독화 O
    public static string GetIK()
    {
        // key = "ik123456"
        string key = "\uD38C\uF38D\u1387\uD388\u938A\uD38A\u138B\uD38C";
        for (int pxVvD = 0, cIsSP = 0; pxVvD < 8; pxVvD++)
        {
            cIsSP = key[pxVvD];
            cIsSP += 0xBAF2;
            cIsSP = ~cIsSP;
            cIsSP--;
            cIsSP += pxVvD;
            cIsSP = ((cIsSP << 3) | ((cIsSP & 0xFFFF) >> 13)) & 0xFFFF;
            cIsSP += 0x7393;
            cIsSP = ~cIsSP;
            cIsSP ^= pxVvD;
            key = key.Substring(0, pxVvD) + (char)(cIsSP & 0xFFFF) + key.Substring(pxVvD + 1);
        }
        return key;
    }
}
중요한 키값 사용 시 로직 사례

 


 

가짜 코드 추가

난독화 시 특정 클래스, 메서드에서 항상 비슷한 형식으로 난독화가 진행이 된다면 몇 개의 빌드를 분석하여 난독화 패턴을 유추할 수 있습니다. 이때 만일 가짜 메서드가 매번 바뀌어서 추가된다면 보안에 도움이 됩니다. 이처럼 가짜 메서드를 추가하여 난독화하면 어떤 것이 원본 메서드 BuyItem 인지 파악이 어려워집니다. 가짜 코드는 런타임 시 실행 속도에 영향이 없습니다. 추가된 가짜 코드만큼 앱의 용량이 증가합니다.

public class ShopManager
{
    public void BuyItem(int id)
    {
        // buy item
    }
}
난독화 전 원본 코드

 

public class ShopManager
{
  [Token(Token = "0x6000011")]
  [Address(Offset = "0x25524C", RVA = "0x25524C", VA = "0x25524C")]
  public ShopManager() {}  
  
  [Token(Token = "0x600000F")]
  [Address(Offset = "0x255244", RVA = "0x255244", VA = "0x255244")]
  public void GOEJALHHONB(int MLLCPANPAGA) {}
  
  [Token(Token = "0x6000010")]
  [Address(Offset = "0x255248", RVA = "0x255248", VA = "0x255248")]
  public void IJLGBLMKGJO(int MLLCPANPAGA) {}
  [Token(Token = "0x6000012")]
  [Address(Offset = "0x255254", RVA = "0x255254", VA = "0x255254")]
  public void MMJFCKPBKEL(int MLLCPANPAGA)  {}
  [Token(Token = "0x6000013")]
  [Address(Offset = "0x255258", RVA = "0x255258", VA = "0x255258")]
  public void DDFHLDEEMPN(int MLLCPANPAGA) {}
  [Token(Token = "0x6000014")]
  [Address(Offset = "0x25525C", RVA = "0x25525C", VA = "0x25525C")]
  public void PNFOEBIJHGD(int MLLCPANPAGA) {}
  
  [Token(Token = "0x6000015")]
  [Address(Offset = "0x255260", RVA = "0x255260", VA = "0x255260")]
  public void OIIGMBCOAHP(int MLLCPANPAGA) {}
}
가짜 코드가 적용된 난독화 사례

 


 

난독화 이름 규칙

지금까지는 난독화 시 이름을 영문 알파벳으로만 진행하였습니다. 알파벳은 우리에게 익숙한 개발 문자이지만 국제적으로도 익숙한 문자입니다. 따라서 난독화 시 생소한 문자로 바꾸면 분석의 난이도가 올라갈 것입니다. 

class Program
{
    static void Main()
    {
        // C# Language Specification
        // 유니코드의 사용이 가능합니다.
        var Δ = 1; 
        Δ++;
        System.Console.WriteLine(Δ);        
    }
}
C#의 식별자 스펙상 식별자에 유니코드 사용 가능한 경우

 

아래는 같은 코드를 몇 가지 언어로 난독화한 표본입니다. 키릴 문자와 같이 마치 문자가 깨진 것 같고 눈이 어지럽지만, 정상적으로 실행되는 코드입니다.

public class ⴆⴊⴌⴍⴒⴇⴏⴓⴊⴌⴈ
{
  public ⴆⴊⴌⴍⴒⴇⴏⴓⴊⴌⴈ()
  {
  }
  private int ⴆⴅⴑⴆⴅⴊⴅⴐⴇⴍⴎ;
  public bool ⴄⴌⴅⴄⴉⴎⴌⴏⴄⴆⴉ(int ⴊⴓⴏⴈⴈⴆⴏⴎⴑⴍⴒ) => new bool();
  public bool ⴉⴊⴄⴌⴄⴑⴄⴍⴆⴍⴌ(int ⴊⴓⴏⴈⴈⴆⴏⴎⴑⴍⴒ) => new bool();
  public bool ⴄⴏⴇⴅⴐⴊⴋⴓⴎⴒⴋ(int ⴊⴓⴏⴈⴈⴆⴏⴎⴑⴍⴒ) => new bool();
  public bool ⴑⴈⴌⴏⴐⴎⴏⴎⴋⴎⴎ(int ⴊⴓⴏⴈⴈⴆⴏⴎⴑⴍⴒ) => new bool();
  public bool ⴊⴍⴐⴐⴏⴎⴌⴅⴊⴌⴅ(int ⴊⴓⴏⴈⴈⴆⴏⴎⴑⴍⴒ) => new bool();
  public bool ⴏⴆⴉⴆⴄⴆⴅⴋⴐⴎⴄ(int ⴊⴓⴏⴈⴈⴆⴏⴎⴑⴍⴒ) => new bool();
}

public class ӦӨөӧӧӧөӨӧӨӨөӨӦӧөӨөөөӧӧӨ
{
  public ӦӨөӧӧӧөӨӧӨӨөӨӦӧөӨөөөӧӧӨ()
  {
  }
  private int ӦөӦөӧөӨӨӧӦӧөӨӨӨӨӧӨӦөӦӦө;
  public bool ӦӦөӧӨӧӧӦӨөөөөӦӨӦӦөөӧӧӦӦ(int ӨөӦөөӦөӧӧөӨӨӦӦөөӨөөӦӧӨӨ) => new bool();
  public bool ӨӨӧөөӦөөӨӨӦӧӧӨӧӦӨөӦӨӧӨӦ(int ӨөӦөөӦөӧӧөӨӨӦӦөөӨөөӦӧӨӨ) => new bool();
  public bool ӧӧӨӨөӧӨӨӦӦӨӧӦӧөӧӦӦӧӧӨӦӧ(int ӨөӦөөӦөӧӧөӨӨӦӦөөӨөөӦӧӨӨ) => new bool();
  public bool ӨӦӨӧӨөӦөӧӨӨӨӧӦӧӧӦӦӧӦөөө(int ӨөӦөөӦөӧӧөӨӨӦӦөөӨөөӦӧӨӨ) => new bool();
  public bool ӨөӨӧӦӧөӨӨөөӨөӧӧӧӦөөӧӨӨө(int ӨөӦөөӦөӧӧөӨӨӦӦөөӨөөӦӧӨӨ) => new bool();
  public bool ӦӧӧӦӧӨӨӦӨӨөӦӧӧӦөӦӨөӨӧөӨ(int ӨөӦөөӦөӧӧөӨӨӦӦөөӨөөӦӧӨӨ) => new bool();
}
view raw
다양한 언어로 난독화한 표본

 

어려운 문자를 사용하면 분석이 어렵지만, 디버깅도 어려워집니다. 그래서 우리에게 익숙한 한글을 사용하면 외국인들에게는 외계어 같지만, 한국인은 읽는 게 가능합니다. 

// 한글 난독화
public class 괆괆괁괆괇괄괋관괄괆괅
{
  public 괆괆괁괆괇괄괋관괄괆괅()
  {
  }
  private int 괎괉관괂괇괊괌괂괇괃괉;
  public bool 관괏괋괉괂괄괍관괍괌괏(int 괆괉괈괍괅괈괏괍괍괅괈) => new bool();
  public bool 괄괉괅괆괉괊괄괍괉괍괁(int 괆괉괈괍괅괈괏괍괍괅괈) => new bool();
  public bool 괆괋괈괄괅괋괉괄괉괆괍(int 괆괉괈괍괅괈괏괍괍괅괈) => new bool(); 
  public bool 괌괈괆괇괍괎관괈괌괈괎(int 괆괉괈괍괅괈괏괍괍괅괈) => new bool();
  public bool 괌괅괃괆괈괏괇괊괋괍관(int 괆괉괈괍괅괈괏괍괍괅괈) => new bool();
  public bool 괋괄괃관괊괎괃괍괂괆괈(int 괆괉괈괍괅괈괏괍괍괅괈) => new bool();
}
view raw
한글 난독화 표본

 


 

난독화 무작위 이름 생성

같은 코드에서 난독화를 하더라도 빌드 시 항상 랜덤 seed, hash값을 기반으로 무작위 이름을 생성하면 신규 버전 출시마다 처음부터 분석을 해야 해서 난이도가 크게 상승합니다.

무작위 이름 생성 예시

 


 

복호화

난독화된 빌드 배포 이후 오류 리포트를 받거나 디버깅을 해야 하는 경우가 있습니다. 이때 어떤 코드에서 난독화가 진행되었는지 확인이 가능한 복호화 기능을 꼭 생성해야 합니다.

서비스 중인 게임의 오류 리포트 화면

 

핵심 코드들이 난독화되어서, 어디서 오류가 발생하였는지 추적할 수 없는 경우도 있습니다. 이런 경우를 대비하여 복호화가 가능한 파일을 꼭 생성하고 백업해야 합니다. 복호화를 할 경우, 난독화된 코드와 원본 코드가 1:1 매칭되는 것이 확인 가능합니다.

복호화 파일의 예시

 


 

마치며

코드 난독화는 최대한 해커에게 혼란을 주고 분석에 많은 시간을 사용하게 하도록 하여 해킹을 어렵게 만들 수 있지만, 해커의 공격으로부터 코드를 100% 방어하는 것은 불가능합니다. 루팅 감지, 메모리 보호, 암호화 등 다양한 보안 조치를 함께 적용하여 해커의 다양한 공격에 대비해야 합니다. 다음 아티클에서는 코드 난독화 시 놓치지 말아야 할 5가지 체크리스트를 소개하겠습니다.