컴퓨터 내의 운영체제(OS)에서 실행되는 프로세스에는 개별 프로세스의 데이터를 일시적으로 저장할 수 있는 가상 메모리(Virtual Memory) 또는 개인 메모리(Private Memory)라고 부르는 영역이 있습니다. 이 영역은 프로세스가 실행될 동안에만 유효하는데, 이 영역에 저장된 데이터는 다른 프로세스에서도 읽거나, 쓸 수가 있습니다. 그렇기 때문에 이 영역에 저장된 값들을 바꾸는 대표적인 프로그램으로는 치트엔진(CheatEngine)이라는 프로그램이 있습니다. 값을 수정해서 게임에 반영하죠.


치트엔진의 주 원리는 다음과 같습니다.

  1. 대상 프로세스를 선택 후 해당 프로세스를 연다. (이 때 사용하는 권한은 VM_READ | VM_WRITE | VM_OPERATION 이다)
  2. 해당 프로세스의 핸들을 가지고 가상 메모리(Virtual Memory) 영역을 읽거나 쓰고, 필요에 따라서는 보호 상태를 바꾼다. (보호 상태를 바꾸면 쓰기가 금지된 상태를 쓰기 상태로 바꿀 수 있음)

물론, 메모리 영역은 프로세스의 가상 메모리만 존재하는 것은 아닙니다. 운영체제가 사용하는 메모리 영역도 있지만, 이 부분은 커널모드(Kernel-Mode)에서 조작이 가능합니다만, 조작 자체도 잘못해버리면 바로 윈도우가 죽어버리는 현상이 발생합니다. -_-...


우선, 프로세스를 열고 메모리의 값을 바꾸는 데에는 아래의 API들이 사용됩니다.

OpenProcess, ReadProcessMemory, WriteProcessMemory, VirtualProtectEx

(추가로 VirtualQueryEx 등의 API는 메모리 상태를 읽어오기 위해 사용될 수도 있습니다)


API를 포함해서 치트엔진이 메모리를 바꾸는 원리를 설명하자면

  1. OpenProcess API로 프로세스를 연다.
  2. ReadProcessMemory API로 기존의 값을 가져온다. -> 여기서 API가 실패할 경우에는 VirtualProtectEx API로 메모리 영역의 보호 상태를 바꿔버린다.
  3. WriteProcessMemory API로 기존의 값을 덮어쓴다. -> 마찬가지로 실패할 경우 VirtualProtectEx API로 보호 상태를 바꾼다.

VirtualProtectEx API는 메모리의 보호 상태만 바꾸기 때문에, 값을 보호하는 것에는 제약이 발생합니다.

이 때!
값이 엄청 중요하거나, 절대 사용자에 의해 강제로 수정되어야 하는 값들은 어떻게 보호할까요?
대표적인 예로 비밀번호 같은 것들 말이죠.

.NET 프레임워크에서는 참 친절하게도 SecureString (MSDN) 이라는 클래스를 지원합니다.
아쉽게도 SecureString 클래스는 문자열만 지원하지만 비밀번호 같은 보안을 필요로 하는 값을 보호하는 데에는 딱입니다!

그럼 이제, SecureString 클래스의 작동 원리를 설명해보도록 하겠습니다.

  1. SecureString 개체 생성
  2. 보호하려는 문자열 추가
  3. 끝!

쉽죠?
아쉽게도 SecureString 클래스에서는 "문자열"을 추가하는 것을 지원하지 않습니다.
하지만, 문자를 추가하는 AppendChar(System.Char) 메서드가 있으니 문자열을 추가할 때에는 이 메서드를 응용해서 사용하면 됩니다.

AppendChar 확장 Append 및 AppendString 메서드
using System.Security;
                    
public class Program
{
    public static void Main()
    {
        SecureString secure = new SecureString();
        
        Extensions.AppendString(secure, "Hello world");
        // Or secure.Append("Hello world");
    }
}

public static class Extensions {
    public static void Append(this SecureString s, string str) {
        foreach ( char ch in str ) 
            s.AppendChar(ch);
    }
    public static void AppendString(SecureString s, string str) {
        Append(s, str);
    }
}
예제 코드 (.NET Fiddle)


이렇게 보시면 이해가 잘 안가시죠?

정말 간단합니다. SecureString 클래스에 정의되어 있는 AppendChar() 메서드를 반복적으로 호출해서, 문자열의 문자 하나 하나를 SecureString 클래스에 추가해 주는 메서드입니다. 이렇게 해놓으면 문자 하나 하나 추가해줄 필요 없이, 문자열을 입력하면 뙇!! 바로 SecureString 클래스에 추가가 됩니다.


그리고.. SecureString 클래스는 이상한 점이 하나 있습니다.

어디를 봐도 문자열을 가져오는 방법이 없습니다. 어떤 문자가 포함되어 있는지도 확인할 방법이 없어요!!!

그렇다면, 이 클래스에서는 도대체 어떻게 문자열을 관리하는 걸까요?


SecureString 클래스 내부에는 SafeBSTRHandle 형식을 가진 필드가 있는데요! SafeBSTRHandle 형식이 문자열을 관리해줍니다.

그럼 겨우 문자열을 포인터 식으로 저장하는게 값을 보호하는 것일까요?

그건 아닙니다. 물론, 문자열 자체를 저장하는 것 보다는 포인터 형태로 저장하는 것이 값을 보호하는 데에는 도움이 됩니다.

하지만, 다른 프로세스에서 메모리 영역을 파고들어 오는 것에는 속수무책일 뿐입니다. 이제! SecureString 클래스의 내부를 파헤쳐 보겠습니다.



우선, SecureString 클래스 내부에는 ProtectMemory(), UnProtectMemory() 라는 메서드가 있습니다.

위의 두 메서드는 SafeBSTRHandle 형식이 문자열을 저장하고 있는 버퍼를 암호화/복호화하는 메서드입니다.

내부에서는 SystemFunction040, SystemFunction041 이라고 명명된 API를 호출하는데 이 API들이 바로 메모리의 값을 암호화/복호화하는 API입니다.



SecureString.ProtectMemory() 메서드에서 암호화가 이루어지는 부분
int status = Win32Native.SystemFunction040(m_buffer, (uint)m_buffer.Length * 2, ProtectionScope);
if (status < 0)  { // non-negative numbers indicate success
#if FEATURE_CORECLR
    throw new CryptographicException(Win32Native.RtlNtStatusToDosError(status));
#else
    throw new CryptographicException(Win32Native.LsaNtStatusToWinError(status));
#endif
}
m_encrypted = true;
예제 코드 (Microsoft Reference Source)


SecureString.UnProtectMemory() 메서드에서 복호화가 이루어지는 부분
if (m_encrypted) {
    int status = Win32Native.SystemFunction041(m_buffer, (uint)m_buffer.Length * 2, ProtectionScope);
    if (status < 0)
    { // non-negative numbers indicate success
#if FEATURE_CORECLR
        throw new CryptographicException(Win32Native.RtlNtStatusToDosError(status));
#else
        throw new CryptographicException(Win32Native.LsaNtStatusToWinError(status));
#endif
    }
    m_encrypted = false;
}
예제 코드 (Microsoft Reference Source)


암호화에서는 SystemFunction040, 복호화에서는 SystemFunction041 API를 호출하는 것을 확인할 수 있습니다.

이렇게 API 호출을 통해서 메모리의 값을 보호하고 있습니다.

SecureString 클래스에서는 문자를 추가할 때 SafeBSTRHandle 형식의 buffer 필드(IntPtr 형식)를 복호화한 후 문자를 추가하고, 다시 암호화를 하는 방식으로 SecureString 클래스에 문자를 추가합니다.

그러면, 어떻게 SecureString 클래스에 "안전하게 보관되고 있는" 문자열을 가져올까요?

일단, SecureString 클래스 내의 SafeBSTRHandle 형식 필드는 한정자가 public 이 아니기 때문에 외부에서는 접근이 불가능합니다.

여기서부터는 주목해야할 클래스가 바뀝니다.


System.Runtime.InteropServices.Marshal 클래스 (MSDN)


위 링크로 가시면 메서드 목록 중 

SecureStringToGlobalAllocAnsi

SecureStringToGlobalAllocUnicode


두 개의 메서드가 보이실겁니다.

위의 메서드들은 SecureString 클래스 개체를 매개변수로 받고, 클래스 내부의 SafeBSTRHandle 형식의 buffer 를 반환합니다.

물론, 이 때는 값을 복호화한 후에 반환하므로 값이 이상하게 나오지는 않습니다.


SecureStringToGlobalAllocXXX 메서드를 호출 후 반환된 값은 PtrToStringXXX 메서드를 호출하여 문자열로 변환할 수 있습니다.

문자열의 사용이 종료되었다면 반드시 IntPtr 값은 ZeroFreeGlobalAllocUnicode 메서드를 사용하여 메모리에서 해제되어야 합니다.


SecureString 개체를 문자열로 변환하는 과정
using System;
using System.Security;
using System.Runtime.InteropServices;            

public class Program
{
    public static void Main()
    {
        SecureString secure = new SecureString();
        
        Extensions.AppendString(secure, "Hello world");
        // Or secure.Append("Hello world");
        
        IntPtr pSecureString = Marshal.SecureStringToGlobalAllocUnicode(secure);
        Console.WriteLine("Address of SecureString: 0x{0:X16}", pSecureString.ToInt64());
        
        string strSecureString = Marshal.PtrToStringUni(pSecureString);
        Console.WriteLine("Value of SecureString  : {0}", strSecureString);
        
        Marshal.ZeroFreeGlobalAllocUnicode(pSecureString);
    }
}

public static class Extensions {
    public static void Append(this SecureString s, string str) {
        foreach ( char ch in str ) 
            s.AppendChar(ch);
    }
    public static void AppendString(SecureString s, string str) {
        Append(s, str);
    }
}
예제 코드 (.NET Fiddle)


여기까지입니다. SecureString 클래스를 이용하면 외부에서 저장된 문자열에 접근하는 것을 막을 수 있으며 추가로 메모리 암호화를 지원하여, 치트엔진 같은 프로그램이 메모리에 강제로 접근해도 암호화된 값이므로 데이터를 보호할 수 있습니다.


글을 대충 한번 읽어보니 정말 두서없게 쓴 듯 합니다....

요약:

1. SecureString 클래스는 문자열을 암호화하고 필요할 때에만 복호화하여 사용하므로 값 변조를 방지할 수 있다.

2. SecureString 클래스에 문자열을 추가하려면 AppendChar(char) 메서드를 반복해서 호출해야 하므로 AppendString(SecureString, string) 이런 메서드를 만들어서 쓰는 것이 좋다.

3. Marshal 클래스와 같이 사용해야 SecureString 클래스에 저장된 문자열을 가져올 수 있다.



마지막으로 이 코드는 SecureString 클래스의 내부 버퍼에 접근하고 SafeBSTRHandle의 부모 클래스인 SafeHandle 의 handle 값을 가져오고 해당 값을 문자열로 변환하는 코드입니다. 이 코드는 정상적으로 작동하지 않습니다. 그 이유는 메모리에 저장된 값이 암호화 되었기 때문입니다.

+ 추가로 이 코드는 .NET Fiddle 에서는 컴파일이 되지 않습니다. (권한 문제) 코드를 복사해서 컴퓨터에서 실행시켜 보세요.


SecureString 내부 버퍼 접근 및 암호화된 상태의 문자열 변환
using System;
using System.Security;
using System.Runtime.InteropServices;
using System.Reflection;

public class Program
{
    public static void Main()
    {
        SecureString secure = new SecureString();
        
        Extensions.AppendString(secure, "Hello world");
        // Or secure.Append("Hello world");
        
        IntPtr pSecuredString = GetInternalBuffer(secure);
        Console.WriteLine("Address of Secured SecureString: 0x{0:X16}", pSecuredString.ToInt64());
        
        string strSecuredString = Marshal.PtrToStringUni(pSecuredString);
        Console.WriteLine("Value of Secured SecureString  : {0}", strSecuredString);
        
        IntPtr pSecureString = Marshal.SecureStringToGlobalAllocUnicode(secure);
        Console.WriteLine("Address of SecureString: 0x{0:X16}", pSecureString.ToInt64());
        
        string strSecureString = Marshal.PtrToStringUni(pSecureString);
        Console.WriteLine("Value of SecureString  : {0}", strSecureString);
        
        Marshal.ZeroFreeGlobalAllocUnicode(pSecuredString);
        Marshal.ZeroFreeGlobalAllocUnicode(pSecureString);
    }
    
    static readonly Type SecureStringType = typeof(SecureString);
    public static IntPtr GetInternalBuffer(SecureString s) {
        FieldInfo     field             = SecureStringType.GetField("m_buffer", BindingFlags.NonPublic | BindingFlags.Instance);
        SafeHandle    handle            = (SafeHandle) field.GetValue(s);
        IntPtr        internalBuffer    = handle.DangerousGetHandle();
        return internalBuffer;
    }
}

public static class Extensions {
    public static void Append(this SecureString s, string str) {
        foreach ( char ch in str ) 
            s.AppendChar(ch);
    }
    public static void AppendString(SecureString s, string str) {
        Append(s, str);
    }
}
예제 코드 (.NET Fiddle)


긴 글 읽어주시느라 고생 많으셨습니다.

궁금한 점은 댓글 남겨주시기 바랍니다.

+ Recent posts