正確處理 Windows 偽裝權杖 - 執行緒層級的權限借用與安全還原方式

· · Windows, 資安, AccessToken, Impersonation, Win32, .NET, CSharp, 維運, 既有資產運用

1. 首先要掌握的事

在開發 Windows 應用程式或 Windows 服務時,經常會遇到「只想讓這一段處理以別的使用者身分執行」的情境。

例如以下這些場景:

  • 想從 Windows 服務以使用者本人的權限存取檔案伺服器
  • 在管理用應用程式中,想確認特定使用者所能看到的範圍
  • 想在 Named Pipe、RPC、COM、IIS、ASP.NET Core 等場合,讓一部分處理以呼叫端使用者的權限執行
  • 因既有資產的限制,需要依處理單位切換所使用的 Windows 帳號

這時就會出現 偽裝(impersonation)存取權杖(access token)偽裝權杖(impersonation token) 這些概念。

不過,這裡要先強調一件事。

Windows 的偽裝並不是「變成系統管理員的魔法」。它是一種主要以執行緒為單位切換「存取檢查時使用的安全性內容」的機制。

如果沒掌握這個差異就直接動手實作,會遇到以下問題:

  • 自以為已經偽裝了,但檔案存取卻變成 Access denied
  • 本機檔案讀得到,但網路共用卻失敗
  • Task.Runasync 執行到一半,不知不覺就變回原本的使用者
  • 明明還在偽裝狀態,卻連日誌輸出或後續處理都一起執行,權限邊界變得模糊
  • 把主要權杖和偽裝權杖搞混,導致啟動處理程序失敗
  • 卡在「使用者明明屬於 Administrators,為什麼還是寫不進去」的問題上

本文將整理在實務中安全處理 Windows 偽裝權杖的思考方式。

這不是關於攻擊手法或權限奪取的話題。 而是關於 Windows 應用程式、Windows 服務、.NET 應用程式如何正確處理權限邊界的內容。

此外,本文中出現的程式碼,已整理成一套可建置的範例(函式庫、在 Windows 上執行的示範程式,以及涵蓋參數驗證與防護行為的單元測試),並公開在 GitHub 上。

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

2. 什麼是存取權杖

在 Windows 中,會使用 存取權杖(access token) 來表示使用者或處理程序的安全性內容。

存取權杖大致上包含以下資訊:

  • 使用者的 SID
  • 所屬群組
  • 權限(Privilege)
  • 預設擁有者
  • 預設 DACL
  • 限制 SID
  • 完整性等級
  • 提升狀態
  • 偽裝層級
  • 權杖類型

檔案、登錄檔、服務、Named Pipe、處理程序、執行緒、事件、Mutex 等等,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. 偽裝權杖附加在執行緒上

一旦開始偽裝,執行緒就會附加上 偽裝權杖(impersonation token)。這裡是重點所在:與其把偽裝理解成「整個處理程序變成另一個使用者」,更準確的理解方式是 那個執行緒改以另一個安全性內容來接受存取檢查

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
偽裝權杖 用於讓執行緒以另一個安全性內容運作 ImpersonateLoggedOnUserSetThreadToken、Named Pipe 的用戶端偽裝

特別重要的一點是:如果目的是啟動處理程序,原則上就需要主要權杖

即使手上有偽裝權杖,也不代表可以直接拿來啟動另一個使用者的處理程序。

一般的流程如下:

偽裝用戶端
  ↓
用 OpenThreadToken 取得偽裝權杖
  ↓
用 DuplicateTokenEx 建立主要權杖
  ↓
傳給 CreateProcessAsUser 等函式

反過來,如果只是「想讓這個執行緒以別的使用者身分做檔案存取」,那就不是啟動處理程序的問題,而是使用偽裝權杖的問題。

把這兩者混在一起,就會出現 API 參數看起來沒錯,卻還是碰到 Access deniedThe parameter is incorrect 之類錯誤而卡住的情況。

7. 理解偽裝層級

偽裝權杖有 偽裝層級(impersonation level)

代表性的等級共有 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);

透過網路存取時,還需要更加留意。

在「Web 伺服器偽裝使用者,再以該使用者身分存取另一台檔案伺服器或資料庫伺服器」這類架構中,可能會遇到所謂的雙躍點(double hop)問題。

在這種情況下,單靠應用程式程式碼進行偽裝,未必能解決問題。 必須連同 Kerberos、SPN、委派、約束委派、服務帳戶、連線對象的驗證方式一起納入設計考量。

8. 偽裝的基本形式

用 Win32 API 處理偽裝時,概念上的形式如下:

1. 取得用於偽裝的權杖
2. 用該權杖偽裝目前的執行緒
3. 只執行必要的處理
4. 一定要還原成原本的安全性內容
5. 關閉權杖 handle

程式碼的形式上,一定要寫成 try / finally

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

try
{
    // 只有這裡以偽裝使用者身分執行
    DoWorkAsImpersonatedUser();
}
finally
{
    if (!RevertToSelf())
    {
        // 無法還原的狀態很危險,至少不能讓處理繼續下去
        throw new Win32Exception(Marshal.GetLastWin32Error());
    }
}

重要的不是開始偽裝,而是 確實地還原。忘記還原的話,那個執行緒接下來的處理,就會一直以偽裝使用者的身分繼續執行。

尤其是在使用執行緒集區(thread pool)的應用程式中,原本以為只是一次性的處理,可能會影響到其他請求或其他處理。

因此,偽裝不該想成「開始了就要還原」,而應該想成 把它封閉在一個小範圍裡

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);
    });
}

這種形式的優點是,偽裝的範圍完全封閉在 lambda 表達式之內。

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 需要傳入使用者名稱、網域、密碼。 也就是說,應用程式這一端要負責處理認證資訊。

實務上要留意以下幾點:

  • 不要把密碼以純文字形式放在程式碼或設定檔中
  • 盡可能使用作業系統的驗證、服務帳戶、委派、既有的 Windows 驗證機制
  • 機密資訊要用適當的 Secret Store 或維運平台來管理
  • 權杖 handle 一定要關閉
  • 日誌中除了使用者名稱之外,不要留下其他機密資訊
  • 把偽裝的範圍降到最低

以下是最小組態的範例。

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 取得的權杖性質,會依登入類型(logon type)而不同。

特別是如果不了解這個差異就直接複製貼上,就無法照預期運作。

登入類型 注意事項
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();
});

像這樣偽裝一個很廣的範圍,會讓人搞不清楚哪個操作是以哪個權限執行的。

舉例來說,日誌輸出目的地的存取,可能會變成以偽裝使用者的權限進行,導致日誌寫入失敗。 資料庫連線可能不是用服務帳戶,而是用偽裝使用者的認證資訊來嘗試連線。 甚至連通知處理、暫存檔案建立,都可能受到不必要的權限內容影響。

好的範例,是只把需要偽裝的操作獨立出來。

ValidateRequest();
LoadConfiguration();

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

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

這樣一來就能清楚知道,需要偽裝的只有 File.ReadAllText 這一段。

偽裝雖然方便,但範圍越大就越難看懂,也越容易出事故。

14. 在非同步處理中要留意「是否偽裝到結束」

現代的 .NET 應用程式中,檔案、HTTP、資料庫、佇列、儲存體等很多處理都是非同步的。

因此,偽裝和 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 驗證登入了,整個請求處理都會以該使用者身分執行」,這樣想是很危險的。

一般來說,應用程式的處理程序本身,是以應用程式集區識別(Application Pool Identity)或服務的執行帳戶來運作。 使用者的 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 失敗
是否是雙躍點問題 是不是想從 Web 伺服器以使用者權限存取檔案伺服器
是否已經離開偽裝範圍 實際的 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 來確認 TokenTypeTokenImpersonationLevelTokenElevationTypeTokenIntegrityLevel 等資訊。

不過,就實務設計而言,比起「偽裝系統管理員什麼都能做」,把必要的操作封閉在專用服務或管理 API 之中,通常會更安全。

18. 網路共用與雙躍點問題

在偽裝相關的諮詢中,非常常見的是存取網路共用的問題。

用戶端電腦
  ↓ Windows 驗證
Web 伺服器 / API 伺服器
  ↓ 想偽裝後存取
檔案伺服器

在這種架構下,有時會發生「在 Web 伺服器上明明能取得使用者名稱,但存取檔案伺服器就失敗」的情況。

這其實是「能否把使用者的認證資訊重新委派給另一台伺服器」的問題。

在本機伺服器上的偽裝,和委派到另一台伺服器,並不是同一件事。

Impersonation 層級即使可以用於本機操作,有時仍不足以讓伺服器對遠端伺服器表現得像個用戶端。

如果想在跨網路的情況下使用使用者本人的權限,就需要設計 Kerberos 委派、約束委派、SPN、服務帳戶,以及驗證方式。

另一方面,依業務需求而定,有時其實不需要以使用者本人的 Windows 權限去存取檔案伺服器。

在這種情況下,以下設計會更單純:

使用者的驗證與授權由應用程式負責
  ↓
存取檔案伺服器改用專用服務帳戶
  ↓
在操作日誌中記錄使用者 ID 與目標檔案

這並不是把「OS 的 ACL」當作最終判斷依據,而是把「應用程式的授權」當作最終判斷依據的設計。

哪一種方式才對,取決於業務需求。

不過,如果沒有明確選定其中一種方式,偽裝、委派、ACL、應用程式授權就會混在一起,變得難以理解。

19. 權杖 Handle 的生命週期

權杖是核心物件的 handle,取得之後,一旦不再需要就必須關閉。

在 .NET 中,基本做法是使用 SafeAccessTokenHandle,並用 using 來管理範圍。

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

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

以下是不好的範例。

// 不好的範例:把權杖以全域方式持續保留
private static SafeAccessTokenHandle? _cachedToken;

長期持有權杖,會導致以下問題:

  • Handle 洩漏
  • 搞不清楚哪個處理正在使用哪個權杖
  • 難以掌握與帳號停用或權限變更之間的一致性
  • 容易演變成長期保存認證資訊的設計
  • 在稽核時難以說明

原則如下:

需要的時候才取得
  ↓
用最小範圍使用
  ↓
一定要關閉

當然,依驗證成本或維運需求,有時也會考慮快取。 但即使如此,也必須把有效期限、廢棄、帳號變更、稽核日誌、權限變更時的處理方式,一併納入設計。

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 / finally,或者使用 RunImpersonated

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 把啟動處理程序和檔案存取當成同一件事

如果只是檔案存取,有時偽裝權杖就足夠了。

但如果想以另一個使用者的身分啟動處理程序,就會牽涉到主要權杖、設定檔(profile)、桌面、環境變數、工作階段、權限等其他議題。

使用 CreateProcessAsUserCreateProcessWithTokenW 時,應該把它當成與偽裝不同的設計問題來處理。

22. 測試的角度

如果只在習慣的系統管理員環境中確認偽裝處理,很容易有所疏漏。

至少要準備以下這些測試案例。

案例 要確認的內容
有權限的使用者 能否讀取 / 寫入目標檔案
沒有權限的使用者 是否正確以 Access denied 失敗
不存在的使用者 是否能正確處理為驗證失敗
密碼錯誤 是否在不留下機密資訊的情況下失敗
網路共用 確認本機與 UNC 之間的行為差異
非同步處理 await 之後是否仍在預期範圍內執行
發生例外 偽裝是否一定會被解除
並行請求 不同使用者的偽裝是否不會混在一起
服務執行 是否以真正的服務帳戶運作,而不是開發者的互動式登入環境

尤其以下這兩點絕對不能省略:

成功時要成功
應該失敗時要正確失敗

在偽裝相關的處理中,不只要測試成功案例,也要測試沒有權限的使用者是否確實被拒絕。

如果應該被拒絕的操作卻成功了,就代表偽裝或授權的設計可能有問題。

23. 實作檢查清單

在實作前後,可以用這份檢查清單確認一次。

角度 檢查內容
目的 能否說明為什麼需要偽裝
替代方案 有沒有檢討過用服務帳戶或應用程式授權是否可行
範圍 偽裝範圍是否已經最小化
還原 發生例外時是否也能確實還原
非同步 是否在 RunImpersonatedAsync 內部 await 到完成
權杖 是否有把主要權杖和偽裝權杖搞混
偽裝層級 是否有確認 IdentificationImpersonation / Delegation 的差異
網路 是否確認過 UNC、雙躍點、Kerberos 委派的必要性
UAC 是否把「屬於系統管理員群組」和「已提升」搞混
機密資訊 是否有把密碼以純文字方式儲存
Handle 是否用 using 關閉了 SafeAccessTokenHandle
日誌 是否能追蹤使用者、偽裝對象、目標、結果
測試 是否確認過有權限 / 無權限 / 例外 / 並行處理的情況

如果這份檢查清單中有很多項目卡住,建議在寫程式碼之前,先重新檢視設計。

24. 判斷該用在哪裡

偽裝權杖雖然強大,但不代表永遠應該是第一個選擇的手段。

在設計時,依用途分開來思考,會比較不容易迷失方向。

24.1 想直接使用 OS 的 ACL 時

如果檔案伺服器或共用資料夾的 ACL 是業務規則的核心,應用程式也應該遵循這個判斷,那麼以使用者本人身分進行偽裝就有意義。

以使用者本人身分存取
  ↓
Windows 的 ACL 是最終判斷依據

在這種情況下,需要連同 Windows 驗證、偽裝層級、委派、網路架構一起設計。

24.2 想在應用程式端進行授權時

如果業務規則在應用程式端,而檔案伺服器或資料庫都在應用程式的管理之下,那麼以服務帳戶存取、並在應用程式端進行授權,有時會更容易理解。

驗證使用者
  ↓
由應用程式進行授權
  ↓
以服務帳戶存取資源
  ↓
在稽核日誌中留下使用者 ID

在這種方式下,不使用偽裝,但應用程式的授權邏輯與稽核日誌就變得很重要。

24.3 想執行管理操作時

比起直接用使用者的權杖執行管理操作,準備專用的管理服務或 API,在那裡進行授權、輸入驗證、稽核、還原機制,往往會更安全。

用戶端
  ↓
向管理 API 提出請求
  ↓
管理 API 進行授權
  ↓
以最小必要權限執行操作
  ↓
稽核日誌

「先偽裝系統管理員再說」,短期來看似乎很輕鬆。

但長期而言,在稽核、故障排查、權限變更、資安審查上都會變得很痛苦。

25. 結語

Windows 的偽裝權杖,是正確運用 Windows 權限管理機制中很重要的一環。

不過,如果不理解就直接使用,也很容易寫出看起來成功、實際上卻很危險的程式碼,這也是這個領域的特徵之一。

以下整理需要掌握的重點。

  • 存取權杖代表使用者、群組、權限等安全性內容
  • 處理程序擁有主要權杖
  • 偽裝權杖主要附加在執行緒上,用於存取檢查
  • 偽裝不是權限提升
  • 主要權杖和偽裝權杖的用途不同
  • 偽裝層級會決定只能識別身分,還是能實際存取,甚至能委派到遠端
  • 若用 Win32 API 進行偽裝,一定要用 try / finally 呼叫 RevertToSelf
  • 在 .NET 中,用 WindowsIdentity.RunImpersonated / RunImpersonatedAsync 把範圍表達得更小
  • 使用 LogonUser 時,要留意認證資訊管理與登入類型
  • 在網路共用中,除了偽裝之外,Kerberos 委派或服務帳戶設計也有關係
  • 權杖 handle 要用 SafeAccessTokenHandle 搭配 using 來管理
  • 不只測試成功案例,也要測試應該被拒絕的案例

在偽裝權杖的實作中,重要的不是「以另一個使用者身分執行成功」這一個結果,而是能不能說明清楚以下這些事:

執行了什麼處理
是根據誰的請求
以什麼帳戶身分
只在多大的範圍內執行
在哪裡還原回去
成功與失敗是如何記錄的

只要能整理到這個程度,偽裝就不是一件可怕的機制。

它可以成為活用 Windows 的 ACL、服務帳戶、既有檔案伺服器、企業內部網域資產的實務工具。

參考資料

共用相同標籤的最新文章。能以相近的主題延伸理解。

與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。

本文連結到以下服務頁面,歡迎從最接近的入口查看。

常見問題

整理諮詢這個主題時常見的問題。

Windows 的偽裝權杖是什麼?
偽裝權杖是附加在執行緒上的權杖,用來讓該執行緒以另一個安全性內容接受存取檢查。並不是整個處理程序變成別的使用者,而是只有進行偽裝的那個執行緒,以該使用者的權限對檔案或登錄檔等資源接受存取檢查。這不是「變成系統管理員的魔法」,也就是說不是權限提升,而是讓伺服器處理程序中的一部分作業,以呼叫端使用者的權限來接受存取檢查的機制。
主要權杖和偽裝權杖有什麼差別?
主要權杖代表處理程序的安全性內容,用於 CreateProcessAsUser 等啟動新處理程序的場合。偽裝權杖則用於讓執行緒以另一個安全性內容運作,會出現在 ImpersonateLoggedOnUser 或 Named Pipe 的用戶端偽裝中。如果目的是啟動處理程序,原則上需要主要權杖;只有偽裝權杖的話,就要透過 DuplicateTokenEx 建立主要權杖。把兩者搞混,就會在 Access denied 之類的錯誤中卡住。
明明已經偽裝了,為什麼還是出現 Access denied?
需要確認的角度不只一個。除了在偽裝範圍內確認 WindowsIdentity.GetCurrent() 的 Name,也要確認 ImpersonationLevel,看是不是已經到達 Impersonation 以上的等級而不是 Identification。此外也要檢查目標資源的 ACL、實際的 I/O 是否在偽裝範圍外或其他工作中執行、是否是本機成功但 UNC 失敗的雙躍點(double hop)問題,以及 UAC 造成的未提升權杖的影響。名稱看起來符合預期,權限卻不足的情況並不少見。
在 .NET 中要怎麼安全地實作偽裝?
基本做法是使用 WindowsIdentity.RunImpersonated / RunImpersonatedAsync,把偽裝範圍封閉在 lambda 表達式之內。需要偽裝的非同步處理,要在 RunImpersonatedAsync 內部 await,避免在偽裝範圍內丟出 fire-and-forget 的工作。若用 Win32 API 進行偽裝,一定要用 try/finally 呼叫 RevertToSelf。偽裝範圍應縮小到必要的 I/O,權杖 handle 則以 SafeAccessTokenHandle 搭配 using 來管理。

作者檔案

本文作者的個人檔案頁面。

Go Komura

小村軟體有限公司 代表

以 Windows 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。

回到部落格一覽