어셈블리 경로 주입

신뢰할 수 없는 경로를 통한 C# 어셈블리 로드

설명

외부 입력이 실행할 어셈블리 파일의 경로를 선택하면 애플리케이션이 공격자가 배치하거나 선택한 관리 코드를 자신의 권한으로 로드할 수 있습니다. Assembly.LoadFrom, Assembly.LoadFile, Assembly.UnsafeLoadFrom뿐 아니라 최신 .NET의 AssemblyLoadContext.LoadFromAssemblyPath도 전달받은 파일을 신뢰할 수 있는 코드로 바꾸어 주지 않습니다.

Path.GetFullPath, Path.GetFileName, .dll 확장자 검사와 같은 경로 변환은 파일의 출처나 게시자를 인증하지 않습니다. 또한 AssemblyLoadContext는 어셈블리 버전과 종속성 충돌을 분리하는 도구이지 보안 샌드박스가 아닙니다. Microsoft도 신뢰할 수 없는 코드를 신뢰된 .NET 프로세스 안에 안전하게 로드할 수 없다고 안내합니다.

이 취약점은 일반적인 경로 탐색과 겹칠 수 있지만 초점이 다릅니다. 경로 탐색은 의도한 디렉터리 밖의 파일 접근을 다루고, 어셈블리 경로 주입은 외부 입력이 로드·활성화·실행할 어셈블리 파일을 선택하여 실행 코드의 신뢰 경계를 넘는 상황을 다룹니다.

잠재적 영향

  • 애플리케이션 계정 권한으로 임의의 관리 코드가 실행될 수 있습니다.
  • 파일, 자격 증명, 데이터베이스, 내부 네트워크 등 애플리케이션이 접근할 수 있는 자원이 노출될 수 있습니다.
  • 악성 플러그인과 그 종속성이 프로세스에 로드되어 지속성 확보나 추가 공격에 사용될 수 있습니다.

해결 방법

  1. 요청에서 파일 경로나 어셈블리 이름을 받지 말고, 작은 플러그인 식별자만 받습니다.
  2. 식별자를 검토된 고정 파일명 또는 절대 경로에 서버 측에서 매핑합니다. 플러그인 디렉터리와 매니페스트는 신뢰할 수 없는 사용자가 수정할 수 없도록 배포 권한을 제한합니다.
  3. 최신 .NET에서는 승인된 경로를 AssemblyLoadContext로 로드합니다. 플러그인별 종속성 분리가 필요하면 사용자 입력이 아닌 승인된 플러그인 경로로 AssemblyDependencyResolver를 구성합니다.
  4. 제3자 플러그인을 허용해야 한다면 조직의 명시적인 신뢰 정책에 따라 게시자 서명이나 서명된 매니페스트의 콘텐츠 해시를 검증하고 종속성도 함께 검증합니다. 공격자가 파일과 함께 제공한 해시를 비교하는 것만으로는 신뢰를 증명할 수 없습니다.
  5. strong name은 어셈블리 식별자일 뿐 코드 신뢰 검증 수단이 아닙니다. .NET Core와 .NET 5 이상에서는 런타임이 strong-name 서명을 검증하지 않습니다. 게시자 인증이 필요하면 플랫폼에 맞는 Authenticode 또는 동등한 코드 서명 정책을 사용합니다.
  6. 실제로 신뢰할 수 없는 플러그인을 실행해야 한다면 동일 프로세스에 로드하지 말고 OS 프로세스, 컨테이너 또는 가상화 경계에서 최소 권한으로 격리합니다.

새 코드에서는 Assembly.UnsafeLoadFrom과 레거시 경로 기반 AppDomain/Activator API를 사용하지 않습니다. 이러한 API를 최신 API로 바꾸는 것만으로는 안전해지지 않으며, 로드 대상의 선택과 신뢰를 먼저 제한해야 합니다.

예시

변경 전

csharp
using Microsoft.AspNetCore.Mvc;
using System.IO;
using System.Reflection;
using System.Runtime.Loader;

[ApiController]
[Route("plugins")]
public sealed class PluginController : ControllerBase
{
    [HttpPost("load")]
    public Assembly Load([FromQuery] string assemblyPath)
    {
        // 절대 경로로 정규화해도 파일의 신뢰성은 검증되지 않습니다.
        string normalized = Path.GetFullPath(assemblyPath);
        return AssemblyLoadContext.Default.LoadFromAssemblyPath(normalized);
    }
}

변경 후

csharp
using Microsoft.AspNetCore.Mvc;
using System;
using System.IO;
using System.Reflection;
using System.Runtime.Loader;

static class ApprovedPlugins
{
    // 이 디렉터리는 배포 계정만 쓸 수 있어야 합니다.
    private const string PluginRoot = "/opt/example/plugins";

    public static Assembly Load(string pluginId)
    {
        string fileName = pluginId switch
        {
            "reporting" => "ReportingPlugin.dll",
            "billing" => "BillingPlugin.dll",
            _ => throw new ArgumentOutOfRangeException(nameof(pluginId))
        };

        string trustedPath = Path.GetFullPath(Path.Combine(PluginRoot, fileName));
        return AssemblyLoadContext.Default.LoadFromAssemblyPath(trustedPath);
    }
}

[ApiController]
[Route("plugins")]
public sealed class PluginController : ControllerBase
{
    [HttpPost("load")]
    public IActionResult Load([FromQuery] string pluginId)
    {
        Assembly plugin = ApprovedPlugins.Load(pluginId);
        return Ok(plugin.GetName().Name);
    }
}

설명:

  • 변경 전: 요청 값이 실제 로드 경로를 결정합니다. 경로 정규화는 경로 형식만 바꾸며 어셈블리의 출처나 권한을 검증하지 않습니다.
  • 변경 후: 요청 값은 제한된 식별자로만 사용되고, 서버가 각 식별자를 고정 파일명에 매핑합니다. 예시의 안전성은 플러그인 디렉터리와 배포 산출물이 신뢰할 수 있는 주체만 수정할 수 있다는 조건에 의존합니다.

게시자나 콘텐츠 검증에 실패하면 로드를 중단하세요. 검증 정책과 신뢰 키 저장소, 플러그인 사용 권한도 별도로 보호해야 합니다.

참조