logo MSJO.kr

C# AOT 라이브러리 활용

2026-03-22
MsJ
 

C#은 관리형 언어(Managed Language)이다. 개발자가 메모리 할당과 해제를 하는 것을 CLR(런타임 환경)이 이를 대신해 주고 가비지 컬렉션(Garbage Collection)으로 사용하지 않는 메모리를 시스템이 자동으로 회수하여 메모리 누수를 방지한다.

대신에 빌드하면 소스코드가 바로 기계어로 바뀌는 것이 아니라 중간 언어(IL, Intermediate Language)로 컴파일된 실행 시점에 JIT(Just-In-Time)에 의해 기계어로 번역된다. 그래서 파일(exe, dll)을 ILSpy 같은 툴로 보면 소스코드가 훤히 보여 중요한 문자열을 숨기지 못하는 단점이 있다.

C#의 AOT(Ahead-of-Time) 컴파일은 애플리케이션을 실행하기 전(빌드 시점)에 코드를 해당 운영체제와 CPU가 이해할 수 있는 네이티브 기계어로 미리 번역해 두는 기술이다. 이것 때문에 이를 활용하면 ILSpy툴로 내용(소스)을 볼 수 없다. 다만 문자열은 헥사에니터 같은것으로 충분히 볼 수 있기 때문에 이를 보완해야 한다.

C++ constexpr, zig comptime, rust의 매크로함수인 obfstr!함수를 이용하여 컴파일 타임에 문자열을 난독화한다. 다만 여기에서는 AOT만을 이용하여 기본을 학습한 후 이것을 토대로 zig, rust, c++를 라이브러리로 만들어 c#에서 활용해 보려고 한다.

AOT 라이브러리
  • 프로젝트(NativeTestLib) 구성
<Project Sdk="Microsoft.NET.Sdk">  
    <PropertyGroup>  
        <TargetFramework>net10.0</TargetFramework>  
        <ImplicitUsings>disable</ImplicitUsings>  
        <Nullable>enable</Nullable>  
        <PublishAot>true</PublishAot>  
        <IsAotCompatible>true</IsAotCompatible>  
        <AllowUnsafeBlocks>true</AllowUnsafeBlocks>  
    </PropertyGroup>  
</Project>
  • 소스(NativeTestLib.cs)
using System.Runtime.CompilerServices;  
using System.Runtime.InteropServices;  
  
namespace NativeTestLib;  
  
public static class NativeMain  
{  
    private static readonly byte[] _encryptedData =  
    [  
        0xBA, 0xCF, 0xF5, 0xB1, 0xFA, 0xC0, 0xB1, 0xC4, 0xEF, 0x8C, 0xFA, 0xF6, 0x88,  
        0xF7, 0xF9, 0x4B, 0x34, 0x0D, 0x0A, 0x1F, 0x19, 0x09, 0x29, 0x0F, 0x1B, 0x11  
    ];  
  
    private const byte BASE_KEY = 0x57;  
  
    [UnmanagedCallersOnly(EntryPoint = "GetSecureData")]  
    public static unsafe int GetSecureData(byte* buffer, int bufferLen)  
    {  
        try  
        {  
            int dataLen = _encryptedData.Length;  
  
            if (buffer == null)  
            {  
                return dataLen;  
            }  
  
            if (bufferLen < dataLen)  
            {  
                return -1;  
            }  
  
            for (int i = 0; i < dataLen; i++)  
            {  
                buffer[i] = DecryptByte(_encryptedData[i], i);  
            }  
  
            return dataLen;  
        }  
        catch  
        {  
            return -2;  
        }  
    }  
  
    [MethodImpl(MethodImplOptions.AggressiveInlining)]  
    private static byte DecryptByte(byte b, int index)  
    {  
        return (byte)(b ^ (BASE_KEY + index));  
    }  
}

작성 후 NativeTestLib.csproj에서 dotnet publish -r win-x64 -c Release를 실행하면 publish 폴더에 NativeTestLib.dll 이 만들어지는데 이 파일을 메인 프로그램에서 사용한다. 참고로 여기에 소스는 unsafe 블록으로 포인터 개념을 사용한다.

메인 프로그램
  • 프로젝트 구성(NativeTest)
<Project Sdk="Microsoft.NET.Sdk">  
    <PropertyGroup>  
        <OutputType>Exe</OutputType>  
        <TargetFramework>net10.0</TargetFramework>  
        <ImplicitUsings>disable</ImplicitUsings>  
        <Nullable>enable</Nullable>  
        <AllowUnsafeBlocks>true</AllowUnsafeBlocks>  
    </PropertyGroup>  
  
    <PropertyGroup>  
        <RuntimeIdentifier Condition="'$(RuntimeIdentifier)' == ''">win-x64</RuntimeIdentifier>  
    </PropertyGroup>  
  
    <ItemGroup>  
        <None Include="..\NativeTestLib\bin\$(Configuration)\$(TargetFramework)\$(RuntimeIdentifier)\publish\NativeTestLib.dll">  
            <Link>NativeTestLib.dll</Link>  
            <CopyToOutputDirectory>Always</CopyToOutputDirectory>  
        </None>  
    </ItemGroup>  
</Project>

아이템 그룹으로 설정하면 되는데 동작이 안 될 때는 라이브러리(dll) 파일을 실행파일 있는 곳에 복사하여 사용한다.

  • 메인프로그램 소스(Program.cs)
using System;  
using System.Runtime.InteropServices;  
using System.Text;  
using System.Threading.Tasks;  
  
namespace NativeTest;  
  
internal partial class Program  
{  
    [LibraryImport("NativeTestLib.dll", EntryPoint = "GetSecureData", StringMarshalling = StringMarshalling.Utf8)]  
    private static partial int GetSecureData(ref byte buffer, int bufferLen);  
  
    [LibraryImport("NativeTestLib.dll", EntryPoint = "GetSecureData", StringMarshalling = StringMarshalling.Utf8)]  
    private static partial int GetSize(IntPtr buffer, int bufferLen);  
  
    private static async Task<string> FetchSecureStringAsync()  
    {  
        try  
        {  
            return await Task.Run(() =>  
            {  
                int requiredSize = GetSize(IntPtr.Zero, 0);  
  
                if (requiredSize <= 0)  
                {  
                    return string.Empty;  
                }  
  
                Span<byte> buffer = stackalloc byte[requiredSize];  
                int actualLen = GetSecureData(ref MemoryMarshal.GetReference(buffer), buffer.Length);  
  
                if (actualLen <= 0)  
                {  
                    return string.Empty;  
                }  
  
                string result = Encoding.UTF8.GetString(buffer[..actualLen]);  
                buffer.Clear();  
                return result;  
            });  
        }  
        catch (DllNotFoundException ex)  
        {  
            Console.WriteLine($"NotFound: {ex.Message}");  
            return string.Empty;  
        }  
        catch (Exception ex)  
        {  
            Console.WriteLine($"Exception: {ex.Message}");  
            return string.Empty;  
        }  
    }  
  
    private static async Task Main()  
    {  
        const string str = "헬로우월드-SecureData";  
        string temp = Helper.GenerateXorHex(str);  
        Helper.Clipboard(temp);  
        Console.WriteLine(temp);  
  
        string secureKey = await FetchSecureStringAsync();  
        Console.WriteLine(string.IsNullOrWhiteSpace(secureKey) ? "데이터없음" : secureKey);  
    }  
}

위의 소스 중 아래의 소스는 실제 프로젝트에 포함하지 말아야 한다. Helper.cs파일도 마찬가지. 난독화한 바이트 배열을 만들고 이것을 AOT 라이브러리 소스에 복사하여 사용한다.

const string str = "헬로우월드-SecureData";  
string temp = Helper.GenerateXorHex(str);  
Helper.Clipboard(temp);  
Console.WriteLine(temp);  
Helper 클래스
  • 메인에서 사용하는 Helper.cs
using System;  
using System.Diagnostics;  
using System.Text;  
  
namespace NativeTest;  
  
public class Helper  
{  
    public static string GenerateXorHex(string plainText, byte baseKey = 0x57)  
    {  
        byte[] bytes = Encoding.UTF8.GetBytes(plainText);  
        StringBuilder hexBuilder = new();  
  
        hexBuilder.AppendLine($"원본 문자열: {plainText}");  
        hexBuilder.Append("private static readonly byte[] EncryptedData = { ");  
  
        for (int i = 0; i < bytes.Length; i++)  
        {  
            // 가변 XOR 적용: (원본 바이트) ^ (기본키 + 인덱스)  
            byte encoded = (byte)(bytes[i] ^ (baseKey + i));  
  
            hexBuilder.Append($"0x{encoded:X2}");  
  
            if (i < bytes.Length - 1)  
                hexBuilder.Append(", ");  
        }  
  
        hexBuilder.Append(" };");  
  
        return hexBuilder.ToString();  
    }  
  
    public static void Clipboard(string text)  
    {  
        if (string.IsNullOrWhiteSpace(text))  
        {  
            return;  
        }  
  
        string base64Text = Convert.ToBase64String(Encoding.Unicode.GetBytes(text));  
        string script = $"[System.Text.Encoding]::Unicode.GetString([System.Convert]::FromBase64String('{base64Text}')) | Set-Clipboard";  
  
        using Process process = new();  
        process.StartInfo.FileName = "powershell";  
        process.StartInfo.Arguments = $"-NoProfile -ExecutionPolicy Bypass -Command \"{script}\"";  
        process.StartInfo.CreateNoWindow = true;  
        process.StartInfo.UseShellExecute = false;  
  
        process.Start();  
        process.WaitForExit();  
    }  
}

클립보드 함수는 복사 붙여넣기를 바로 하기 위하여 포함하였다 암튼 이 helper 클래스는 실제 프로젝트에는 배포 시 제외해야 한다.

이것으로 AOT를 이용하여 간단한 문자열 숨김을 해보았다. 실무에서는 더 복잡하게 작성해야 하는데 이 부분은 다음 글 작성 시 zig, rust, c++로 라이브러리를 만들어 c#에서 호출해 볼 것이다. 바로 호출하기 보다는 중간에 AOT를 매개로 연결한다. 참고로 AOT로 빌드한 네이티브 파일도 GC의 영향을 받는다.


Prεv(Θld)  
Content
Search     RSS Feed     BY-NC-ND