내가 하는 모 게임은 게임을 구동하기 위해 런처에서 로그인 후 게임을 실행해야 한다.
그리고 그 과정은 대단히 귀찮다.
심지어 난 비밀번호가 크롬에서 자동생성 해주는 복잡한 그거라 일일이 크롬에서 그걸 가져왔다.
그게 귀찮다고 다른 데 메모해 두면 보안의 의미가 없으니까...
그래서 이 번거로운 과정을 건너뛰고 한방에 게임을 켜고 싶었다.
계정의 세션ID는 변경되지 않고 고유하며, 게임 실행 시에 그 인자로 들어간다.
이를 포함한 정보를 저장했다가 다시 불러와 사용할 수 있게 하고 싶었던 것.
이전에 내가 의도하는 기능을 하는 어떤 개인 개발 프로그램이 있었는데,
유감스럽게도 소스코드나 .pdb
파일이 없었기 때문에 이를 리버싱 할 필요가 있었다.
프로그램을 디컴파일하고, 그 코드를 보기 좋게 고친 다음, 몇 가지를 수정했다.
1. GUI
버튼은 딱 2개.
한 개로 만들 수도 있지만 계속 유지보수 할 물건은 아니라 최소한의 기능만 두는 걸로.
버튼에 물론 텍스트도 있지만 사이즈를 달리해 구분감이 생겼다.
2. 위쪽 버튼
이 버튼은 실행 중인 게임 프로세스의 명령줄(CommandLine)을 읽어와,
이를 암호화하고 별도 파일로 저장한다.
private void button1_Click(object sender, EventArgs e)
{
Process process = null;
try
{
process = Process.GetProcessesByName("게임실행파일").FirstOrDefault();
if (process == null) throw new Exception();
}
catch
{
MessageBox.Show("게임실행파일.exe가 실행중이 아닙니다.");
return;
}
// 쿼리로 명령줄을 가져온다
var commandLine = "";
using (var searcher = new ManagementObjectSearcher($"SELECT CommandLine FROM Win32_Process WHERE ProcessId = " + process.Id))
{
foreach (ManagementObject obj in searcher.Get())
{
commandLine = obj["CommandLine"].ToString();
break;
}
}
try
{
var encryptedData = Encrypt(commandLine);
File.WriteAllBytes(Path.ChangeExtension(Application.ExecutablePath, ".dat"), encryptedData);
MessageBox.Show("저장완료!");
}
catch (Exception ex)
{
MessageBox.Show("암호화 실패!!!");
throw ex;
}
}
WMI를 통해 정보를 가져오기 위해 아래의 NuGet 패키지를 활용했다.
프로세스의 ID만을 가져오기 위해 사용하지 않는다.
이후의 암호화 과정에도 이를 활용할 것이다.
2-1. 암호화
암호화는 간단히 아래의 스텝을 거친다.
- 명령줄을 가져온다
- AES로 암호화를 한다
- DPAPI로 암호화된 데이터를 거듭 암호화한다
- 이 정보를 파일로 저장
private byte[] Encrypt(string data)
{
return EncryptWithAesThenDpapi(data);
}
public byte[] EncryptWithAesThenDpapi(string data)
{
byte[] encryptedDataWithAes = EncryptWithAes(data);
byte[] encryptedDataWithDpapi = ProtectedData.Protect(encryptedDataWithAes, null, DataProtectionScope.LocalMachine);
return encryptedDataWithDpapi;
}
여기까진 심플하다.
이전 C# 글에서도 DPAPI를 활용하여 암호화를 진행했다.
이제 AES 암호화는 어떻게 진행되는지 보자.
private void AddHardwareInfo(StringBuilder sb, string wmiClass, string propertyName)
{
using (var searcher = new ManagementObjectSearcher($"SELECT {propertyName} FROM {wmiClass}"))
{
foreach (ManagementObject obj in searcher.Get())
{
sb.AppendFormat("{0}: {1}\n", propertyName, obj[propertyName]);
}
}
}
private byte[] GetKey()
{
var sb = new StringBuilder();
using (var searcher = new ManagementObjectSearcher("root\\CIMV2", "SELECT * FROM Win32_Processor"))
{
foreach (ManagementObject obj in searcher.Get())
{
sb.AppendFormat("Architecture : {0}\n", obj["Architecture"]);
sb.AppendFormat("Caption : {0}\n", obj["Caption"]);
sb.AppendFormat("Family : {0}\n", obj["Family"]);
sb.AppendFormat("ProcessorId : {0}\n", obj["ProcessorId"]);
}
}
AddHardwareInfo(sb, "Win32_BaseBoard", "SerialNumber");
AddHardwareInfo(sb, "Win32_BIOS", "SerialNumber");
return Encoding.ASCII.GetBytes(Convert.ToBase64String(Encoding.UTF8.GetBytes(sb.ToString())));
}
먼저 AES 암호화에 사용될 Key
를 어떻게 생성하는지 보자.
Key는 고유해야 하며 다른 사람이 흉내내기 대단히 어려워야 한다.
난 이 프로그램의 보안 스코프를 사용자의 컴퓨터(LocalMachine)로 결정했기 때문에 그에 맞춰 Key를 고유하게 생성할 방법이 필요했다.
따라서 컴퓨터의 고유한 데이터를 활용하기로 했다.
프로세서나 마더보드 등의 시리얼 넘버는 고유하기 때문에 좋은 요소가 되어줄 것이라 생각했다.
여기서 System.Management
를 활용해 이러한 데이터들을 갖고 와 하나의 스트링으로 만들었다.
그리고 Base64
인코딩을 통해 Key
로 사용하기 좋게 했다.
private byte[] EncryptWithAes(string data)
{
byte[] key = GetKey();
byte[] iv;
byte[] encryptedData;
using (Aes aesAlg = Aes.Create())
{
aesAlg.Mode = CipherMode.CBC;
aesAlg.KeySize = 256;
aesAlg.BlockSize = 128;
// key의 역순에서 8바이트를 Salt로 사용
using (var rfc2898DeriveBytes = new Rfc2898DeriveBytes(key, key.Reverse().Take(8).ToArray(), 1000))
aesAlg.Key = rfc2898DeriveBytes.GetBytes(aesAlg.KeySize / 8);
// IV는 매 암호화 과정마다 변경됨
aesAlg.GenerateIV();
iv = aesAlg.IV;
using (ICryptoTransform encryptor = aesAlg.CreateEncryptor(aesAlg.Key, aesAlg.IV))
using (MemoryStream msEncrypt = new MemoryStream())
{
using (CryptoStream csEncrypt = new CryptoStream(msEncrypt, encryptor, CryptoStreamMode.Write))
using (StreamWriter swEncrypt = new StreamWriter(csEncrypt))
{
swEncrypt.Write(data);
// StreamWriter를 닫지 않고, CryptoStream을 닫으면 데이터가 올바르게 Flush 되지 않을 수 있음
// SW를 이용해 CS에 데이터를 쓰면,
// 데이터는 암호화 되어 msEncrypt에 저장됨
}
// IV 다음에 암호화된 데이터 저장
// 16Byte(IV) + EncryptedData
encryptedData = iv.Concat(msEncrypt.ToArray()).ToArray();
}
}
return encryptedData;
}
암호화 모드는 CBC
로 정했다.
암호화할 데이터는 특정 패턴이 반복적으로 등장하지 않기도 하고,
강한 보안이 필요하기 때문에 CBC
가 적절하다고 생각했다.
그리고 암호화에 사용될 진짜 Key를 만들기 위해 Rfc2898DeriveBytes
를 활용한다.
Salt로는 GetKey()로 만든 바이트 배열의 역순에서 8바이트를 가져왔다.
반복 횟수는 1000회이다.
초기화 벡터(IV)는 매 암호화 과정 수행 시 새로 만들어 사용한다.
이제 실제 암호화를 진행한다.
1. 암호화 변환기 생성
using (ICryptoTransform encryptor = aesAlg.CreateEncryptor(aesAlg.Key, aesAlg.IV))
aesAlg.CreateEncryptor
는 AES 알고리즘을 사용하여 데이터를 암호화할 때 필요한 암호화 변환기(ICryptoTransform
)를 생성한다.
이 변환기는 암호화 키(aesAlg.Key
)와 초기화 벡터(aesAlg.IV
)를 사용하여 데이터를 암호화하는 데 사용된다.
2. 메모리 스트림 초기화
using (MemoryStream msEncrypt = new MemoryStream())
MemoryStream
객체는 암호화된 데이터를 메모리에서 직접 다룰 수 있게 해 준다.
암호화 과정에서 생성되는 데이터는 이 스트림에 저장된다고 보면 된다.
3. 암호화 스트림 설정
using (CryptoStream csEncrypt = new CryptoStream(msEncrypt, encryptor, CryptoStreamMode.Write))
CryptoStream
객체는 데이터 스트림을 암호화 변환기를 통해 암호화하거나 복호화하는 일을 한다.
이 경우, Write
모드로 설정되어 있어, 데이터를 쓰기 위해 csEncrypt
스트림을 사용하게 된다.
위에서 설명한 대로 msEncrypt
메모리 스트림에 암호화된 데이터가 저장된다.
4. 데이터 쓰기
using (StreamWriter swEncrypt = new StreamWriter(csEncrypt))
{
swEncrypt.Write(data);
}
StreamWriter
를 사용하여 data
를 csEncrypt
스트림에 쓴다.
이때 csEncrypt
는 내부적으로 encryptor
를 사용해 데이터를 암호화하고, 결과를 msEncrypt
에 저장한다.
StreamWriter
와 CryptoStream
을 닫는 것(Dispose
호출)은 내부 버퍼의 데이터를 강제로 Flush 하여 msEncrypt
에 완전히 저장되게 한다.
만약 여기서 제대로 닫지 않는다면 msEncrypt
에 제대로 저장되지 않아 null
을 반환하게 되어 Exception
을 일으키는 원인이 될 수도 있다.
5. IV와 암호화된 데이터 결합
encryptedData = iv.Concat(msEncrypt.ToArray()).ToArray();
모든 암호화 과정이 완료되면 초기화 벡터를 가장 앞에 두고, 그 뒤에 암호화된 데이터를 Concat
을 통해 합친다.
encryptedData
의 최초 16바이트는 IV이고 그 나머지는 암호화된 데이터로 구성된다.
이렇게 암호화된 데이터를 리턴하면 완료된다.
3. 아래쪽 버튼
이 버튼은 저장된 데이터를 불러와 복호화하고 게임을 실행한다.
private void button2_Click(object sender, EventArgs e)
{
try
{
var decryptedCommand = Decrypt(File.ReadAllBytes(Path.ChangeExtension(Application.ExecutablePath, ".dat")));
NativeMethods.WinExec(decryptedCommand, 5U);
Application.Exit();
}
catch
{
// Handle exceptions or ignore
}
}
private static class NativeMethods
{
[DllImport("kernel32.dll")]
public static extern uint WinExec(string lpCmdLine, uint uCmdShow);
}
WinExec()라는 별로 권장되지 않는 함수를 사용했는데, 이는 개선의 여지가 있다.
복호화의 암호화의 역순. 이를 잘 따라 CommandLine
을 얻고 게임을 실행하게 된다.
두 번째 인자로 준 5U
는 UnsignedInt32
인 5를 나타낸다.
하지만 C#에선 이런 리터럴을 사용하지 않기 때문에 컴파일러가 알아서 5U를 5로 인식해 컴파일한다.
이 외에 어떤 값을 가지는 지는 다음의 링크를 참조하라.
여하튼 CPU나 메인보드를 바꾸지 않고 윈도우를 새로이 설치하지 않는 이상 저장된 데이터는 언제든지 복호화할 수 있을 것이다.
이건 매우 단순하게 동작하지만 AES 암호화 과정에 대해 조금이나마 공부할 수 있는 좋은 시간이 되었다.
'Study > C++ & C#' 카테고리의 다른 글
[C++] Boost.Asio 에코 서버 (0) | 2024.03.29 |
---|---|
[C#] ###Clicker 개선판 (0) | 2024.03.18 |
[C#] ###Clicker (0) | 2024.02.29 |
[C#] 자동화 플러그인 수정 (0) | 2023.10.17 |
[C++ / Python / DB] ProcedureGenerator (0) | 2023.09.01 |