Windows 위장 토큰을 올바르게 다루기 - 스레드 단위 권한 대여와 안전한 복원 방법

· · Windows, 보안, AccessToken, Impersonation, Win32, .NET, CSharp, 운영, 기존 자산 활용

1. 가장 먼저 짚어둘 것

Windows 애플리케이션이나 Windows 서비스를 만들다 보면 “이 처리만 다른 사용자로 실행하고 싶다”는 상황을 만나게 됩니다.

예를 들면 다음과 같은 상황입니다.

  • Windows 서비스에서 이용자 본인의 권한으로 파일 서버에 액세스하고 싶다
  • 관리용 애플리케이션에서 특정 사용자에게 보이는 범위만 확인하고 싶다
  • Named Pipe, RPC, COM, IIS, ASP.NET Core 등에서 호출한 사용자의 권한으로 일부 처리를 하고 싶다
  • 기존 자산의 사정으로 처리 단위마다 사용할 Windows 계정을 전환하고 싶다

이때 등장하는 것이 위장(impersonation), 액세스 토큰, 위장 토큰입니다.

다만, 여기서 먼저 강조해두고 싶은 것이 있습니다.

Windows의 위장은 “관리자가 되는 마법”이 아닙니다. 액세스 검사에 사용되는 보안 컨텍스트를 주로 스레드 단위로 전환하는 메커니즘입니다.

이 차이를 짚지 않은 채 구현하면 다음과 같은 문제가 생깁니다.

  • 위장했다고 생각했는데 파일 액세스가 Access denied가 된다
  • 로컬 파일은 읽을 수 있는데 네트워크 공유만 실패한다
  • Task.Run이나 async 도중에 어느새 원래 사용자로 돌아가 있다
  • 위장한 채로 로그 출력이나 후속 처리까지 동작해버려서 권한 경계가 모호해진다
  • 기본 토큰과 위장 토큰을 혼동해서 프로세스 시작에서 실패한다
  • “사용자가 Administrators에 속해 있는데 왜 쓸 수 없는가”에서 막힌다

이 글에서는 Windows의 위장 토큰을 실무에서 안전하게 다루기 위한 사고방식을 정리합니다.

공격 기법이나 권한 탈취에 관한 이야기가 아닙니다. Windows 애플리케이션, Windows 서비스, .NET 애플리케이션에서 권한 경계를 올바르게 다루기 위한 이야기입니다.

또한 이 글에 등장하는 코드는 빌드할 수 있는 샘플 일체(라이브러리, Windows에서 실행하는 데모, 인자 검증 및 가드 동작에 대한 단위 테스트)로 GitHub에 공개하고 있습니다.

windows-impersonation-token - komurasoft-blog-samples (GitHub)

2. 액세스 토큰이란 무엇인가

Windows에서는 사용자나 프로세스의 보안 컨텍스트를 나타내기 위해 액세스 토큰이 사용됩니다.

액세스 토큰에는 대략 다음과 같은 정보가 담겨 있습니다.

  • 사용자의 SID
  • 소속 그룹
  • 권한, Privilege
  • 기본 소유자
  • 기본 DACL
  • 제한된 SID
  • 무결성 수준
  • 상승 상태
  • 위장 레벨
  • 토큰 종류

파일, 레지스트리, 서비스, Named Pipe, 프로세스, 스레드, 이벤트, 뮤텍스 등 Windows의 많은 객체는 보안 기술자(security descriptor)를 갖습니다.

어떤 스레드가 보호 대상 객체를 열려고 할 때, Windows는 토큰 정보와 대상 객체의 ACL을 대조합니다.

동작하는 스레드
  ↓
어떤 보안 컨텍스트로 액세스하는가
  ↓
토큰의 사용자, 그룹, 권한을 본다
  ↓
대상 객체의 ACL과 대조한다
  ↓
허용 / 거부를 결정한다

이 “어떤 보안 컨텍스트로 액세스하는가”를 이해하는 것이 위장 토큰을 이해하는 입구입니다.

3. 프로세스에는 기본 토큰이 있다

Windows의 각 프로세스에는 보통 기본 액세스 토큰(primary token)이 있습니다.

예를 들어 어떤 사용자가 데스크톱에서 애플리케이션을 시작하면, 그 프로세스에는 해당 사용자의 보안 컨텍스트를 나타내는 기본 토큰이 부여됩니다.

Windows 서비스라면 서비스의 실행 계정에 대한 기본 토큰이 부여됩니다.

예를 들면 다음과 같은 이미지입니다.

MyService.exe
  Primary Token: DOMAIN\svc-app

이 서비스 안의 스레드가 특별히 위장하고 있지 않다면, 파일이나 레지스트리에 액세스할 때는 프로세스의 기본 토큰이 사용됩니다.

즉, 기본값으로는 다음과 같습니다.

Thread A
  Impersonation Token: 없음
  ↓
액세스 검사에서는 Process의 Primary Token을 사용한다

이 상태에서 C:\Data\foo.txt를 열면 DOMAIN\svc-app에 대한 액세스 권한이 있는지가 확인됩니다.

4. 위장 토큰은 스레드에 부여된다

위장이 시작되면 스레드에는 위장 토큰이 부여됩니다. 여기가 중요한 지점인데, 위장은 기본적으로 “프로세스 전체가 다른 사용자가 된다”기보다 그 스레드가 다른 보안 컨텍스트에서 액세스 검사를 받는다고 생각하는 것이 정확합니다.

MyService.exe
  Primary Token: DOMAIN\svc-app

Thread A
  Impersonation Token: DOMAIN\alice

Thread B
  Impersonation Token: 없음

이때 Thread A가 파일을 열면 DOMAIN\alice의 권한으로 액세스 검사를 받습니다.

반면 Thread B는 위장하고 있지 않으므로 DOMAIN\svc-app의 권한으로 액세스 검사를 받습니다.

이 차이를 이해하지 못하면 다음과 같은 혼란이 생깁니다.

// Thread A에서 위장했다고 생각했지만
StartImpersonation(token);

// 실제로는 다른 스레드로 처리를 넘기고 있음
Task.Run(() =>
{
    File.ReadAllText(path);
});

// 곧바로 되돌려버림
RevertToSelf();

이 경우 실제로 파일을 읽는 스레드가 기대한 위장 상태로 동작한다고 보장할 수 없습니다.

위장은 스코프, 스레드, 비동기 처리와의 관계를 명확히 해서 다뤄야 합니다.

5. ‘위장’은 권한 상승이 아니다

위장이라는 단어는 조금 강하게 들리지만, 실무에서 중요한 것은 이를 “권한 상승”과 혼동하지 않는 것입니다.

위장으로 할 수 있는 것은 기본적으로 다음과 같은 일입니다.

서버 프로세스의 권한으로 처리한다
  ↓
일부 처리만 클라이언트 사용자의 권한으로 액세스 검사한다

예를 들어 파일 서버의 ACL을 그대로 권한 제어로 사용하고 싶은 경우, 서버 애플리케이션이 항상 서비스 계정으로 파일을 읽으면 이용자별 ACL을 반영할 수 없습니다.

그래서 요청 처리 중 일부만 호출한 사용자로 위장하여 파일 액세스를 수행합니다.

HTTP / RPC / Named Pipe 요청
  User: DOMAIN\alice
      ↓
서버 애플리케이션
  Process: DOMAIN\svc-app
      ↓
파일 액세스 부분만 DOMAIN\alice로 위장
      ↓
파일 서버의 ACL로 허용 / 거부된다

이는 애플리케이션 고유의 권한 판정이 아니라 Windows의 기존 ACL을 활용하고 싶을 때 도움이 됩니다.

다만 위장은 편리한 한편, 설계를 잘못하면 권한 경계가 알기 어려워집니다.

  • 어떤 처리를 누구로 실행하고 있는지
  • 어디서 위장을 시작했는지
  • 어디서 확실히 되돌리고 있는지
  • 어떤 로그가 어떤 사용자 권한으로 나오고 있는지
  • 예외가 발생해도 되돌아가는지
  • 비동기 처리의 끝까지 위장이 유효한지

이런 부분을 코드로 명확히 하는 것이 중요합니다.

6. 기본 토큰과 위장 토큰을 구분해서 생각한다

Windows 토큰에서 특히 혼동하기 쉬운 것이 기본 토큰위장 토큰입니다.

대략 다음과 같이 생각합니다.

토큰 주요 용도 대표 예
기본 토큰 프로세스의 보안 컨텍스트를 나타냄 프로세스 시작, CreateProcessAsUser
위장 토큰 스레드가 다른 보안 컨텍스트에서 동작하기 위해 사용 ImpersonateLoggedOnUser, SetThreadToken, Named Pipe의 클라이언트 위장

특히 중요한 것은 프로세스를 시작하고 싶은 경우 원칙적으로 기본 토큰이 필요하다는 점입니다.

위장 토큰을 가지고 있다고 해서 그대로 다른 사용자의 프로세스 시작에 사용할 수 있다고는 할 수 없습니다.

일반적으로 다음과 같은 흐름이 됩니다.

클라이언트를 위장한다
  ↓
OpenThreadToken으로 위장 토큰을 얻는다
  ↓
DuplicateTokenEx로 기본 토큰을 만든다
  ↓
CreateProcessAsUser 등에 전달한다

반대로 “이 스레드에서 파일 액세스만 다른 사용자로 하고 싶다”는 경우라면, 프로세스 시작이 아니라 위장 토큰을 사용하는 이야기가 됩니다.

이 둘을 섞으면 API의 인자는 맞는데도 Access deniedThe parameter is incorrect 같은 오류로 헤매게 됩니다.

7. 위장 레벨을 이해한다

위장 토큰에는 위장 레벨이 있습니다.

대표적으로 4단계가 있습니다.

위장 레벨 의미의 기준
Anonymous 서버는 클라이언트의 식별 정보를 얻을 수 없다
Identification 서버는 클라이언트를 식별할 수 있지만, 그 권한으로 객체에 액세스하는 용도로는 사용할 수 없다
Impersonation 서버는 로컬 시스템에서 클라이언트의 권한으로 동작할 수 있다
Delegation 서버는 원격 시스템에 대해서도 클라이언트의 권한을 위임받을 수 있다

실무에서 자주 막히는 것은 IdentificationImpersonation의 차이입니다.

Identification은 이름 그대로 상대가 누구인지 아는 정도의 레벨입니다. 해당 사용자의 권한으로 파일을 여는 용도로는 충분하지 않습니다.

그래서 다음과 같은 일이 생깁니다.

WindowsIdentity.GetCurrent().Name은 기대한 사용자 이름으로 보인다
  ↓
그런데 파일 액세스는 Access denied가 된다

이 경우 이름만 보지 말고 위장 레벨도 확인해야 합니다.

.NET이라면 WindowsIdentity.ImpersonationLevel을 확인하면 힌트가 됩니다.

using System.Security.Principal;

WindowsIdentity identity = WindowsIdentity.GetCurrent();

Console.WriteLine(identity.Name);
Console.WriteLine(identity.ImpersonationLevel);

네트워크를 통한 액세스에서는 더욱 주의가 필요합니다.

“웹 서버에서 사용자를 위장해서 그 사용자로 다른 파일 서버나 DB 서버에 액세스한다”는 구성에서는 이른바 더블 홉(double hop) 문제에 부딪힐 수 있습니다.

이 경우 단순히 애플리케이션 코드에서 위장하면 해결된다고는 할 수 없습니다. Kerberos, SPN, 위임, 제약된 위임, 서비스 계정, 접속 대상의 인증 방식까지 포함해서 설계해야 합니다.

8. 위장의 기본 형태

Win32 API로 위장을 다룰 때의 개념적인 형태는 다음과 같습니다.

1. 위장에 사용할 토큰을 얻는다
2. 그 토큰으로 현재 스레드를 위장시킨다
3. 필요한 처리만 실행한다
4. 반드시 원래 보안 컨텍스트로 되돌린다
5. 토큰 핸들을 닫는다

코드의 형태로는 반드시 try / finally로 만듭니다.

if (!ImpersonateLoggedOnUser(tokenHandle))
{
    throw new Win32Exception(Marshal.GetLastWin32Error());
}

try
{
    // 이 부분만 위장 사용자로 실행한다
    DoWorkAsImpersonatedUser();
}
finally
{
    if (!RevertToSelf())
    {
        // 되돌릴 수 없는 상태는 위험하므로 최소한 처리를 계속해서는 안 된다
        throw new Win32Exception(Marshal.GetLastWin32Error());
    }
}

중요한 것은 위장을 시작하는 것보다 확실히 되돌리는 것입니다. 되돌리는 것을 잊으면 그 스레드에서 후속 처리가 위장된 사용자 상태로 동작합니다.

특히 스레드 풀을 사용하는 애플리케이션에서는 하나의 처리라고 생각했던 것이 다른 요청이나 다른 처리에 영향을 줄 수 있습니다.

그러므로 위장은 “시작했으면 되돌린다”가 아니라 작은 스코프에 가두어야 합니다.

9. .NET에서는 WindowsIdentity.RunImpersonated를 사용한다

.NET에서는 가능하다면 WindowsIdentity.RunImpersonated를 사용하면 위장 스코프를 코드상으로 표현하기 쉬워집니다.

SafeAccessTokenHandle을 가지고 있다면 다음과 같이 작성할 수 있습니다.

using Microsoft.Win32.SafeHandles;
using System.Security.Principal;

static string ReadFileAsUser(SafeAccessTokenHandle token, string path)
{
    return WindowsIdentity.RunImpersonated(token, () =>
    {
        return File.ReadAllText(path);
    });
}

이 형태의 좋은 점은 위장하고 있는 범위가 람다식 안에 갇혀 있다는 것입니다.

RunImpersonated(token, () =>
{
    // 이 부분만 위장
});

// 여기서부터는 원래 컨텍스트

파일 액세스, 레지스트리 액세스, 기존 라이브러리 호출 등 특정 처리만 위장하고 싶은 경우, 이 형태가 읽기 쉽고 안전합니다.

비동기 처리에서는 RunImpersonatedAsync를 사용합니다.

using Microsoft.Win32.SafeHandles;
using System.Security.Principal;

static Task WriteFileAsUserAsync(
    SafeAccessTokenHandle token,
    string path,
    string text,
    CancellationToken cancellationToken)
{
    return WindowsIdentity.RunImpersonatedAsync(token, async () =>
    {
        await File.WriteAllTextAsync(path, text, cancellationToken);
    });
}

피해야 할 것은 위장 스코프 안에서 fire-and-forget 방식의 태스크를 던지는 형태입니다.

// 좋지 않은 예
WindowsIdentity.RunImpersonated(token, () =>
{
    _ = Task.Run(() =>
    {
        File.WriteAllText(path, text);
    });
});

이 코드는 “실제로 파일을 쓰는 처리”가 언제, 어떤 실행 컨텍스트에서 동작하는지 알기 어려워집니다.

위장한 상태로 하고 싶은 비동기 처리는 RunImpersonatedAsync 안에서 await하고, 처리가 완료된 후에 스코프를 나가는 형태로 만듭니다.

10. LogonUser로 토큰을 얻는 경우

다른 사용자의 자격 증명으로부터 토큰을 얻는 대표적인 API로 LogonUser가 있지만, 이는 신중하게 다뤄야 할 API입니다.

LogonUser에는 사용자 이름, 도메인, 비밀번호를 전달합니다. 즉, 애플리케이션 쪽이 자격 증명을 다루게 됩니다.

실무에서는 다음 사항에 주의합니다.

  • 비밀번호를 코드나 설정 파일에 평문으로 두지 않는다
  • 가능하면 OS의 인증, 서비스 계정, 위임, 기존 Windows 인증을 사용한다
  • 비밀 정보는 적절한 Secret Store나 운영 기반에서 관리한다
  • 토큰 핸들은 반드시 닫는다
  • 로그에 사용자 이름 이외의 비밀 정보를 남기지 않는다
  • 위장하는 범위를 최소화한다

최소 구성의 예를 실어둡니다.

using Microsoft.Win32.SafeHandles;
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Security.Principal;

internal static class NativeMethods
{
    private const int LOGON32_LOGON_INTERACTIVE = 2;
    private const int LOGON32_PROVIDER_DEFAULT = 0;

    [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
    internal static extern bool LogonUser(
        string lpszUsername,
        string? lpszDomain,
        string lpszPassword,
        int dwLogonType,
        int dwLogonProvider,
        out SafeAccessTokenHandle phToken);

    public static SafeAccessTokenHandle Logon(
        string userName,
        string? domain,
        string password)
    {
        bool ok = LogonUser(
            userName,
            domain,
            password,
            LOGON32_LOGON_INTERACTIVE,
            LOGON32_PROVIDER_DEFAULT,
            out SafeAccessTokenHandle token);

        if (!ok)
        {
            throw new Win32Exception(Marshal.GetLastWin32Error());
        }

        return token;
    }
}

public static string ReadFileWithExplicitCredential(
    string userName,
    string? domain,
    string password,
    string path)
{
    using SafeAccessTokenHandle token = NativeMethods.Logon(userName, domain, password);

    return WindowsIdentity.RunImpersonated(token, () =>
    {
        return File.ReadAllText(path);
    });
}

이 예시는 어디까지나 API의 형태를 보여주기 위한 것입니다.

실무에서는 “애초에 애플리케이션이 비밀번호를 받는 설계가 맞는가”를 먼저 검토합니다.

많은 경우 다음과 같은 대안을 고려하는 것이 더 안전합니다.

하고 싶은 일 대안
서비스 전체가 특정 리소스에 액세스하고 싶다 전용 서비스 계정에 필요 최소한의 ACL을 부여한다
이용자 본인의 권한으로 파일에 액세스하고 싶다 Windows 인증과 위임 설계를 사용한다
관리자 권한이 필요한 작업을 하고 싶다 서비스 측에 명확한 관리 API를 두고 앱 측의 인가로 제어한다
일부 처리만 다른 계정으로 하고 싶다 위장 범위를 메서드 단위로 가두고 감사 로그를 남긴다

11. LogonUser의 로그온 유형에 주의한다

LogonUser로 얻는 토큰의 성질은 로그온 유형에 따라 달라집니다.

특히 이 차이를 이해하지 못한 채 복붙하면 기대한 대로 동작하지 않습니다.

로그온 유형 주의점
Interactive 대화형 로그온에 가까운 형태. 로컬 작업에 쓰기 쉽지만 실행 환경이나 권한에 의존한다
Network 네트워크 로그온용. 반환되는 토큰이 프로세스 시작에 그대로 사용할 수 없는 경우가 있다
NewCredentials 로컬에서는 현재 자격 증명에 가깝고, 원격 접속 시에 지정한 자격 증명을 사용하는 용도로 쓰이는 경우가 있다

여기서 말하고자 하는 것은 특정 로그온 유형을 외우는 것이 아닙니다.

중요한 것은 두 가지입니다.

  1. 로그온 유형에 따라 로컬 액세스, 네트워크 액세스, 프로세스 시작에서의 동작이 달라진다
  2. 돌려받은 토큰이 기본 토큰인지 위장 토큰인지 확인해야 한다

예를 들어 LOGON32_LOGON_NETWORK로 얻은 토큰을 그대로 CreateProcessAsUser에 넘겨서 실패하는 것은 전형적인 혼란입니다.

프로세스 시작이 목적이라면 기본 토큰이 필요합니다. 필요에 따라 DuplicateTokenEx로 기본 토큰을 만드는 설계가 됩니다.

12. RevertToSelf를 가볍게 여기지 않는다

Win32 API로 위장을 시작한 경우 RevertToSelf로 위장을 종료합니다. 이 복원 처리는 단순한 뒷정리가 아니라 보안 경계를 원래대로 되돌리기 위한 중요한 처리입니다.

나쁜 예입니다.

ImpersonateLoggedOnUser(token);
DoWork();
RevertToSelf();

겉보기에는 문제없어 보이지만, DoWork()에서 예외가 발생하면 RevertToSelf()가 호출되지 않습니다.

반드시 finally로 만듭니다.

if (!ImpersonateLoggedOnUser(token))
{
    throw new Win32Exception(Marshal.GetLastWin32Error());
}

try
{
    DoWork();
}
finally
{
    if (!RevertToSelf())
    {
        throw new Win32Exception(Marshal.GetLastWin32Error());
    }
}

RevertToSelf가 실패했을 때 그대로 처리를 계속하는 것도 위험합니다. 원래 권한으로 되돌리지 못했을 가능성이 있는 상태에서 후속 처리를 계속하면 의도하지 않은 사용자의 권한으로 처리가 이어집니다. 최소한 그 처리 단위는 실패로 취급하고 안전한 쪽으로 처리합니다.

.NET의 RunImpersonated / RunImpersonatedAsync는 이러한 복원 누락을 피하기 위한 스코프 표현으로 유용합니다.

13. 위장 범위는 최대한 작게 만든다

위장에서 가장 중요한 설계 원칙은 필요한 처리만 위장하는 것입니다.

나쁜 예입니다.

WindowsIdentity.RunImpersonated(token, () =>
{
    ValidateRequest();
    LoadConfiguration();
    WriteDebugLog();
    ReadUserFile();
    UpdateDatabase();
    SendNotification();
});

이렇게 넓은 범위를 위장하면 어떤 작업이 어떤 권한으로 이루어지는지 알기 어려워집니다.

예를 들어 로그 출력 대상에 대한 액세스가 위장 사용자의 권한으로 이루어져 로그 기록에 실패할 수 있습니다. DB 연결이 서비스 계정이 아니라 위장 사용자의 자격 증명으로 시도될 수 있습니다. 알림 처리나 임시 파일 생성까지 불필요한 권한 컨텍스트의 영향을 받을 수 있습니다.

좋은 예는 위장이 필요한 작업만 분리하는 형태입니다.

ValidateRequest();
LoadConfiguration();

string content = WindowsIdentity.RunImpersonated(token, () =>
{
    return File.ReadAllText(userFilePath);
});

UpdateDatabase(content);
WriteAuditLog(userName, userFilePath, success: true);

이 형태라면 위장이 필요한 것은 File.ReadAllText 부분뿐임을 알 수 있습니다.

위장은 편리하지만 범위를 넓힐수록 읽기 어려워지고 사고가 나기 쉬워집니다.

14. 비동기 처리에서는 ‘끝날 때까지 위장되어 있는지’를 본다

현대의 .NET 애플리케이션에서는 파일, HTTP, DB, 큐, 스토리지 등 많은 처리가 비동기입니다.

그래서 위장과 async / await의 조합은 주의가 필요합니다.

기본 방침은 이것뿐입니다.

위장이 필요한 비동기 처리는 RunImpersonatedAsync 안에서 await한다

좋은 예입니다.

await WindowsIdentity.RunImpersonatedAsync(token, async () =>
{
    await using FileStream stream = File.OpenRead(path);
    using var reader = new StreamReader(stream);
    string text = await reader.ReadToEndAsync();

    await ProcessTextAsync(text);
});

다만 이 형태에서도 생각해야 할 것이 있습니다.

ProcessTextAsync까지 위장 사용자로 동작할 필요가 있는가 하는 점입니다.

파일을 읽는 부분만 위장하면 충분하다면 다음과 같이 나누는 것이 더 안전합니다.

string text = await WindowsIdentity.RunImpersonatedAsync(token, async () =>
{
    return await File.ReadAllTextAsync(path);
});

await ProcessTextAsync(text);

위장 스코프 안에서 await할 수 있다고 해서 무엇이든 넣어도 되는 것은 아닙니다.

비동기 처리에서도 위장 범위는 최소화합니다.

15. ASP.NET Core와 위장

ASP.NET Core에서 Windows 인증을 사용하는 경우에도 위장의 취급에는 주의가 필요합니다.

“Windows 인증으로 로그인했으니 요청 처리 전체가 그 사용자로 동작한다”고 생각하면 위험합니다.

일반적으로 애플리케이션의 프로세스 자체는 애플리케이션 풀 ID나 서비스의 실행 계정으로 동작합니다. 이용자의 Windows ID는 인증 정보로 얻을 수 있지만, 그대로 모든 처리가 이용자 권한이 되는 것은 아닙니다.

이용자의 권한으로 특정 작업을 해야 한다면 명시적으로 RunImpersonated / RunImpersonatedAsync로 스코프를 만듭니다.

코드의 이미지는 다음과 같습니다.

app.MapGet("/download", async (HttpContext context) =>
{
    if (context.User.Identity is not WindowsIdentity user)
    {
        return Results.Unauthorized();
    }

    string path = GetPathFromRequest(context);

    byte[] bytes = await WindowsIdentity.RunImpersonatedAsync(
        user.AccessToken,
        async () => await File.ReadAllBytesAsync(path));

    return Results.File(bytes, "application/octet-stream");
});

이 예시에서도 위장하고 있는 것은 파일 읽기 부분뿐입니다.

응답 생성, 로그, 애플리케이션 측의 인가 판정까지 모두 위장 스코프에 넣어야 하는지는 신중하게 생각해야 합니다.

16. Access denied를 보는 방법

위장을 사용한 구현에서 자주 나오는 오류가 Access denied입니다.

이 오류가 나왔을 때 “위장에 실패했다”라고만 생각하면 오히려 돌아가는 길이 됩니다.

확인할 관점을 나눠봅니다.

관점 확인 내용
실제로 위장이 되고 있는가 위장 스코프 안에서 WindowsIdentity.GetCurrent().Name을 확인한다
위장 레벨이 충분한가 Identification이 아니라 필요한 레벨로 되어 있는가
대상 리소스의 ACL이 올바른가 위장 사용자에게 읽기 / 쓰기 권한이 있는가
로컬인가 원격인가 로컬 파일에서는 성공하고 UNC만 실패하고 있지 않은가
더블 홉이 아닌가 웹 서버에서 파일 서버로 이용자 권한으로 가려고 하고 있지 않은가
위장 스코프를 벗어나지 않았는가 실제 I/O가 스코프 밖이나 다른 작업에서 동작하고 있지 않은가
토큰 종류가 맞는가 프로세스 시작에 위장 토큰을 넘기고 있지 않은가
UAC / 무결성 수준의 영향은 없는가 Administrators 소속이어도 비상승 토큰은 아닌가

특히 이름만 확인하고 안심하지 않는 것이 중요합니다.

WindowsIdentity identity = WindowsIdentity.GetCurrent();
Console.WriteLine(identity.Name);

이 로그는 유용하지만 충분하지 않습니다.

최소한 다음 항목도 확인합니다.

Console.WriteLine(identity.ImpersonationLevel);
Console.WriteLine(identity.IsAuthenticated);

또한 대상이 네트워크 공유라면 애플리케이션 코드뿐 아니라 인증 방식, 위임 설정, SPN, 서비스 계정, 파일 서버 측의 ACL도 확인합니다.

17. UAC와 ‘관리자인데도 실패하는’ 문제

Windows에서는 사용자가 Administrators 그룹에 속해 있는 것과 현재 토큰이 상승된 상태인 것은 같지 않습니다.

UAC가 활성화된 환경에서는 관리자 사용자라도 보통은 제한된 토큰으로 프로세스가 동작하며, 관리자 권한이 필요한 작업에는 상승이 필요합니다.

그래서 다음과 같은 일이 생깁니다.

사용자는 Administrators에 속해 있다
  ↓
그런데 현재 토큰은 비상승 상태
  ↓
Program Files나 HKLM에 쓰기 시도 시 Access denied

위장에서도 마찬가지입니다.

“위장 대상 사용자가 관리자니까 쓸 수 있을 것”이라고 생각하는 것이 아니라, 실제로 전달된 토큰이 어떤 상태인지 확인해야 합니다.

디버깅에서는 다음과 같은 관점을 봅니다.

  • 소속 그룹
  • Privilege의 활성 / 비활성
  • 무결성 수준
  • 상승 상태
  • 제한된 토큰인지 여부
  • 연결된 상승 토큰이 있는지

Win32 API에서는 GetTokenInformation을 사용하여 TokenType, TokenImpersonationLevel, TokenElevationType, TokenIntegrityLevel 등을 확인할 수 있습니다.

다만 실무상의 설계로는 “관리자 사용자를 위장해서 무엇이든 한다”보다 전용 서비스나 관리 API에 필요 최소한의 작업을 가두는 쪽이 안전합니다.

18. 네트워크 공유와 더블 홉

위장에서 매우 많은 상담이 네트워크 공유에 대한 액세스입니다.

클라이언트 PC
  ↓ Windows 인증
웹 서버 / API 서버
  ↓ 위장해서 액세스하고 싶다
파일 서버

이 구성에서는 “웹 서버에서는 사용자 이름을 얻을 수 있는데 파일 서버로 가면 실패한다”는 일이 있을 수 있습니다.

이는 이용자의 자격 증명을 다른 서버로 재위임할 수 있는가 하는 문제입니다.

로컬 서버에서의 위장과 다른 서버로의 위임은 같지 않습니다.

Impersonation 레벨은 로컬 작업에는 사용할 수 있어도 원격 서버에 대해 클라이언트로서 동작하기에는 부족한 경우가 있습니다.

네트워크를 넘어 이용자 본인의 권한을 사용하고 싶다면 Kerberos 위임, 제약된 위임, SPN, 서비스 계정, 인증 방식의 설계가 필요합니다.

한편, 업무 요건에 따라서는 이용자 본인의 Windows 권한으로 파일 서버에 갈 필요가 없는 경우도 있습니다.

이 경우에는 다음과 같은 설계가 더 단순합니다.

이용자의 인증・인가는 애플리케이션에서 수행한다
  ↓
파일 서버 액세스는 전용 서비스 계정으로 수행한다
  ↓
작업 로그에 이용자 ID와 대상 파일을 기록한다

이는 “OS의 ACL을 최종 판단으로 삼는” 것이 아니라 “애플리케이션의 인가를 최종 판단으로 삼는” 설계입니다.

어느 쪽이 옳은지는 업무 요건에 따라 다릅니다.

다만 어느 방식을 선택하고 있는지를 명확히 하지 않으면 위장, 위임, ACL, 앱 인가가 뒤섞여 알기 어려워집니다.

19. 토큰 핸들의 수명

토큰은 커널 객체에 대한 핸들이므로, 얻은 후 불필요해진 시점에 닫아야 합니다.

.NET에서는 SafeAccessTokenHandle을 사용하고 using으로 스코프를 관리하는 것이 기본입니다.

using SafeAccessTokenHandle token = NativeMethods.Logon(userName, domain, password);

string result = WindowsIdentity.RunImpersonated(token, () =>
{
    return File.ReadAllText(path);
});

좋지 않은 예입니다.

// 좋지 않은 예: 토큰을 전역으로 계속 유지한다
private static SafeAccessTokenHandle? _cachedToken;

토큰을 장기간 유지하면 다음과 같은 문제로 이어집니다.

  • 핸들 누수
  • 어떤 처리가 어떤 토큰을 사용하고 있는지 알 수 없게 된다
  • 계정 비활성화나 권한 변경과의 일관성을 알기 어려워진다
  • 자격 증명을 오래 유지하는 설계가 되기 쉽다
  • 감사 시 설명하기 어렵다

원칙은 다음과 같습니다.

필요할 때 얻는다
  ↓
최소 범위로 사용한다
  ↓
반드시 닫는다

물론 인증 비용이나 운영 요건에 따라 캐시를 검토하는 경우도 있습니다. 다만 그 경우에도 유효 기간, 폐기, 계정 변경, 감사 로그, 권한 변경 시의 처리를 설계에 포함해야 합니다.

20. 감사 로그에 남겨야 할 것

위장을 사용하는 처리에서는 로그 설계도 중요합니다.

최소한 이 표의 정보를 남길 수 있도록 해두면 나중에 조사하기 쉬워집니다.

항목
요청한 이용자 DOMAIN\alice
실행 프로세스의 계정 DOMAIN\svc-app
위장한 계정 DOMAIN\alice 또는 전용 계정
대상 리소스 파일 경로, 공유 이름, 레지스트리 키 등
작업 Read, Write, Delete, CreateProcess 등
결과 Success, AccessDenied, Timeout, UnexpectedError
오류 코드 Win32 오류 코드, HRESULT, 예외 종류
위장 범위 어떤 메서드, 어떤 작업 단위로 위장했는가

다만 로그에 남겨서는 안 되는 것도 있습니다.

  • 비밀번호
  • 액세스 토큰 값
  • 인증 헤더
  • Kerberos 티켓이나 자격 증명 자체
  • 개인정보를 포함한 파일 내용

로그의 목적은 “누구의 요청으로 어떤 계정으로 무엇을 시도했고 어떻게 실패 또는 성공했는지”를 나중에 추적할 수 있게 하는 것입니다.

자격 증명 자체를 기록할 필요는 없습니다.

21. 자주 보이는 안티패턴

위장 관련해서 자주 보이는 위험한 구현을 정리합니다.

21.1 앱 전체를 위장한다

WindowsIdentity.RunImpersonated(token, () =>
{
    RunEntireApplication();
});

앱 전체를 위장하면 어떤 작업이 어떤 권한으로 실행되는지 알 수 없게 됩니다.

위장은 필요한 I/O나 특정 API 호출로 한정합니다.

21.2 finally 없이 되돌린다

ImpersonateLoggedOnUser(token);
DoWork();
RevertToSelf();

예외 발생 시 되돌아가지 않으므로 위험합니다.

반드시 try / finallyRunImpersonated를 사용합니다.

21.3 위장 중에 fire-and-forget한다

WindowsIdentity.RunImpersonated(token, () =>
{
    _ = Task.Run(DoWorkAsync);
});

처리가 완료되기 전에 위장 스코프를 벗어나므로 기대와 다른 권한으로 동작할 수 있습니다.

위장이 필요하다면 RunImpersonatedAsync 안에서 await합니다.

21.4 사용자 이름만으로 성공 여부를 판단한다

Console.WriteLine(WindowsIdentity.GetCurrent().Name);

사용자 이름이 기대한 대로여도 위장 레벨이나 권한이 부족한 경우가 있습니다.

ImpersonationLevel, 대상 ACL, 로그온 유형, 네트워크 위임도 확인합니다.

21.5 관리자 계정을 편의용 계정으로 위장한다

“이 처리는 실패하면 안 되니까 관리자 계정으로 위장한다”는 설계는 위험합니다.

필요 최소한의 권한을 가진 전용 계정을 준비하고 작업 단위를 좁히는 것이 더 안전합니다.

21.6 비밀번호를 설정 파일에 두는 것

{
  "UserName": "DOMAIN\\admin",
  "Password": "P@ssw0rd!"
}

이는 피해야 합니다.

자격 증명을 다뤄야 한다면 Secret Store, Windows Credential Manager, DPAPI, 클라우드의 Key Vault, 운영 기반의 시크릿 관리 등 환경에 맞는 방식을 사용합니다.

21.7 프로세스 시작과 파일 액세스를 같은 문제로 취급한다

파일 액세스만이라면 위장 토큰으로 충분한 경우가 있습니다.

한편 다른 사용자로 프로세스를 시작하고 싶다면 기본 토큰, 프로필, 데스크톱, 환경 변수, 세션, 권한 등 다른 논점이 등장합니다.

CreateProcessAsUserCreateProcessWithTokenW를 사용하는 경우에는 위장과는 별도의 설계로 다뤄야 합니다.

22. 테스트 관점

위장 처리는 손에 익은 관리자 환경에서만 확인하면 놓치는 부분이 생깁니다.

최소한 이 정도의 테스트 케이스를 준비합니다.

케이스 확인할 것
권한이 있는 사용자 대상 파일을 읽을 수 있는지 / 쓸 수 있는지
권한이 없는 사용자 Access denied로 올바르게 실패하는지
존재하지 않는 사용자 인증 실패로 처리되는지
비밀번호 오류 로그에 비밀 정보를 남기지 않고 실패하는지
네트워크 공유 로컬과 UNC의 동작 차이를 확인한다
비동기 처리 await 이후에도 기대한 범위에서 동작하는지
예외 발생 위장이 반드시 해제되는지
동시 요청 다른 사용자의 위장이 섞이지 않는지
서비스 실행 개발자의 대화형 로그온 환경이 아니라 실제 서비스 계정으로 동작하는지

특히 이 두 가지는 빠뜨릴 수 없습니다.

성공하는 것
실패해야 할 때 실패하는 것

위장 처리에서는 성공 케이스뿐 아니라 권한이 없는 사용자에서 확실히 거부되는지도 테스트합니다.

거부되어야 할 작업이 성공해버린다면 위장이나 인가 설계가 잘못되었을 가능성이 있습니다.

23. 구현 체크리스트

구현 전후에 이 체크리스트를 확인합니다.

관점 확인 내용
목적 왜 위장이 필요한지 설명할 수 있는가
대안 서비스 계정이나 앱 인가로는 안 되는지 검토했는가
범위 위장 스코프가 최소인가
복원 예외 발생 시에도 확실히 되돌아가는가
비동기 RunImpersonatedAsync 안에서 완료까지 await하고 있는가
토큰 기본 토큰과 위장 토큰을 혼동하지 않았는가
위장 레벨 IdentificationImpersonation / Delegation의 차이를 보고 있는가
네트워크 UNC, 더블 홉, Kerberos 위임의 필요성을 확인했는가
UAC 관리자 소속과 상승된 상태를 혼동하지 않았는가
비밀 정보 비밀번호를 평문으로 저장하지 않았는가
핸들 SafeAccessTokenHandleusing으로 닫고 있는가
로그 이용자, 위장 대상, 대상, 결과를 추적할 수 있는가
테스트 권한 있음 / 없음 / 예외 / 동시 처리를 확인했는가

이 체크리스트에서 걸리는 항목이 많다면 코드를 작성하기 전에 설계를 다시 검토하는 것이 좋습니다.

24. 사용할 곳을 판단한다

위장 토큰은 강력하지만 항상 가장 먼저 선택해야 할 수단은 아닙니다.

설계 시에는 용도별로 나누어 생각하면 헤매기 어렵습니다.

24.1 OS의 ACL을 그대로 사용하고 싶은 경우

파일 서버나 공유 폴더의 ACL이 업무 규칙의 중심이고 애플리케이션도 그 판단을 따라야 한다면, 이용자 본인으로서의 위장에 의미가 있습니다.

이용자 본인으로서 액세스
  ↓
Windows의 ACL이 최종 판단

이 경우에는 Windows 인증, 위장 레벨, 위임, 네트워크 구성까지 포함해서 설계합니다.

24.2 애플리케이션 측에서 인가하고 싶은 경우

업무 규칙이 애플리케이션 쪽에 있고 파일 서버나 DB가 애플리케이션의 관리 하에 있다면, 서비스 계정으로 액세스하고 애플리케이션 쪽에서 인가하는 것이 더 알기 쉬운 경우가 있습니다.

이용자를 인증
  ↓
애플리케이션에서 인가
  ↓
서비스 계정으로 리소스에 액세스
  ↓
감사 로그에 이용자 ID를 남긴다

이 방식에서는 위장을 사용하지 않는 대신 애플리케이션의 인가 로직과 감사 로그가 중요해집니다.

24.3 관리 작업을 하고 싶은 경우

관리 작업을 이용자의 토큰으로 직접 실행하는 것보다, 관리 작업 전용 서비스나 API를 마련하고 거기서 인가, 입력 검증, 감사, 롤백을 수행하는 쪽이 더 안전한 경우가 많습니다.

클라이언트
  ↓
관리 API에 요청
  ↓
관리 API가 인가
  ↓
필요 최소한의 권한으로 작업
  ↓
감사 로그

“일단 관리자를 위장한다”는 짧게 보면 편해 보입니다.

그러나 장기적으로는 감사, 장애 조사, 권한 변경, 보안 리뷰에서 힘들어집니다.

25. 정리

Windows의 위장 토큰은 Windows의 권한 관리를 올바르게 사용하기 위한 중요한 메커니즘입니다.

다만 이해하지 못한 채 사용하면 성공한 것처럼 보이면서도 위험한 코드가 되기 쉬운 분야이기도 합니다.

짚어야 할 포인트를 나열해둡니다.

  • 액세스 토큰은 사용자, 그룹, 권한 등의 보안 컨텍스트를 나타낸다
  • 프로세스에는 기본 토큰이 있다
  • 위장 토큰은 주로 스레드에 부여되며 액세스 검사에서 사용된다
  • 위장은 권한 상승이 아니다
  • 기본 토큰과 위장 토큰은 용도가 다르다
  • 위장 레벨에 따라 식별만 가능한지, 실제로 액세스할 수 있는지, 원격으로 위임할 수 있는지가 달라진다
  • Win32 API로 위장한다면 반드시 try / finallyRevertToSelf한다
  • .NET에서는 WindowsIdentity.RunImpersonated / RunImpersonatedAsync로 스코프를 작게 표현한다
  • LogonUser를 사용하는 경우 자격 증명 관리와 로그온 유형에 주의한다
  • 네트워크 공유에서는 위장뿐 아니라 Kerberos 위임이나 서비스 계정 설계도 관련된다
  • 토큰 핸들은 SafeAccessTokenHandleusing으로 관리한다
  • 성공 케이스뿐 아니라 거부되어야 할 케이스도 테스트한다

위장 토큰 구현에서 중요한 것은 “다른 사용자로 동작했다”는 한 가지 사실이 아니라, 다음과 같은 것을 설명할 수 있는 상태로 만들어두는 것입니다.

어떤 처리를
누구의 요청으로
어떤 계정으로서
어느 범위만 실행하고
어디서 원래대로 되돌리고
성공과 실패를 어떻게 기록하고 있는가

여기까지 정리할 수 있다면 위장은 무서운 메커니즘이 아닙니다.

Windows의 ACL, 서비스 계정, 기존 파일 서버, 사내 도메인 자산을 활용하기 위한 실무적인 도구로 사용할 수 있게 됩니다.

참고

같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.

이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.

이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.

자주 묻는 질문

이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.

Windows의 위장 토큰이란 무엇인가요?
위장 토큰은 스레드가 다른 보안 컨텍스트에서 액세스 검사를 받도록 스레드에 부여되는 토큰입니다. 프로세스 전체가 다른 사용자가 되는 것이 아니라, 위장한 스레드만 해당 사용자의 권한으로 파일이나 레지스트리 등에 대한 액세스 검사를 받습니다. '관리자가 되는 마법', 즉 권한 상승이 아니라 서버 프로세스의 일부 처리만 클라이언트 사용자의 권한으로 액세스 검사를 받게 하는 메커니즘입니다.
기본 토큰과 위장 토큰은 어떻게 다른가요?
기본 토큰은 프로세스의 보안 컨텍스트를 나타내며 CreateProcessAsUser 등 프로세스 시작에 사용됩니다. 위장 토큰은 스레드가 다른 보안 컨텍스트에서 동작하기 위해 사용되며 ImpersonateLoggedOnUser나 Named Pipe의 클라이언트 위장에서 등장합니다. 프로세스를 시작하고 싶다면 원칙적으로 기본 토큰이 필요하며, 위장 토큰만 있는 경우에는 DuplicateTokenEx로 기본 토큰을 만드는 흐름이 됩니다. 이를 혼동하면 Access denied 같은 오류로 헤매게 됩니다.
위장하고 있는데도 Access denied가 나는 이유는 무엇인가요?
확인해야 할 관점이 여러 가지 있습니다. 위장 범위 안에서 WindowsIdentity.GetCurrent()의 Name뿐 아니라 ImpersonationLevel도 확인하여 Identification 레벨이 아니라 Impersonation 이상인지를 봐야 합니다. 대상 리소스의 ACL, 실제 I/O가 위장 범위 밖이나 다른 작업에서 실행되고 있지 않은지, 로컬은 성공하는데 UNC만 실패하는 더블 홉 문제는 아닌지, UAC로 인한 비상승 토큰의 영향도 확인합니다. 이름이 예상대로 나와도 권한이 부족한 경우가 있습니다.
.NET에서 위장을 안전하게 구현하려면 어떻게 해야 하나요?
WindowsIdentity.RunImpersonated / RunImpersonatedAsync를 사용하여 위장 범위를 람다식 안에 가두는 것이 기본입니다. 위장이 필요한 비동기 처리는 RunImpersonatedAsync 안에서 await하고, 위장 범위 안에서 fire-and-forget 방식의 태스크를 던지지 않도록 합니다. Win32 API로 위장할 경우에는 반드시 try/finally로 RevertToSelf를 호출합니다. 위장 범위는 필요한 I/O에만 최소화하고, 토큰 핸들은 SafeAccessTokenHandle과 using으로 관리합니다.

저자 프로필

기사 저자의 프로필 페이지입니다.

Go Komura

합동회사 코무라소프트 대표

Windows 소프트웨어 개발, 기술 상담, 장애 조사를 중심으로 재현이 어려운 장애 조사와 기존 자산이 남아 있는 프로젝트에 강점이 있습니다.

블로그 목록으로 돌아가기