正確處理 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.Run或async執行到一半,不知不覺就變回原本的使用者 - 明明還在偽裝狀態,卻連日誌輸出或後續處理都一起執行,權限邊界變得模糊
- 把主要權杖和偽裝權杖搞混,導致啟動處理程序失敗
- 卡在「使用者明明屬於 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 |
| 偽裝權杖 | 用於讓執行緒以另一個安全性內容運作 | ImpersonateLoggedOnUser、SetThreadToken、Named Pipe 的用戶端偽裝 |
特別重要的一點是:如果目的是啟動處理程序,原則上就需要主要權杖。
即使手上有偽裝權杖,也不代表可以直接拿來啟動另一個使用者的處理程序。
一般的流程如下:
偽裝用戶端
↓
用 OpenThreadToken 取得偽裝權杖
↓
用 DuplicateTokenEx 建立主要權杖
↓
傳給 CreateProcessAsUser 等函式
反過來,如果只是「想讓這個執行緒以別的使用者身分做檔案存取」,那就不是啟動處理程序的問題,而是使用偽裝權杖的問題。
把這兩者混在一起,就會出現 API 參數看起來沒錯,卻還是碰到 Access denied 或 The parameter is incorrect 之類錯誤而卡住的情況。
7. 理解偽裝層級
偽裝權杖有 偽裝層級(impersonation level)。
代表性的等級共有 4 個:
| 偽裝層級 | 大致含意 |
|---|---|
| Anonymous | 伺服器無法取得用戶端的識別資訊 |
| Identification | 伺服器可以識別用戶端,但不能用該權限去存取物件 |
| Impersonation | 伺服器可以在本機系統上以用戶端的權限運作 |
| Delegation | 伺服器甚至可以將用戶端的權限委派到遠端系統 |
實務上常卡住的地方,是 Identification 和 Impersonation 的差異。
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 | 在本機端接近目前的認證,有時用於在遠端連線時使用指定的認證資訊 |
這裡想表達的重點,不是要背下每個登入類型。
重要的是以下兩點:
- 登入類型不同,本機存取、網路存取、啟動處理程序時的行為也會不同
- 需要確認拿回來的權杖是主要權杖還是偽裝權杖
例如,用 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 來確認 TokenType、TokenImpersonationLevel、TokenElevationType、TokenIntegrityLevel 等資訊。
不過,就實務設計而言,比起「偽裝系統管理員什麼都能做」,把必要的操作封閉在專用服務或管理 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)、桌面、環境變數、工作階段、權限等其他議題。
使用 CreateProcessAsUser 或 CreateProcessWithTokenW 時,應該把它當成與偽裝不同的設計問題來處理。
22. 測試的角度
如果只在習慣的系統管理員環境中確認偽裝處理,很容易有所疏漏。
至少要準備以下這些測試案例。
| 案例 | 要確認的內容 |
|---|---|
| 有權限的使用者 | 能否讀取 / 寫入目標檔案 |
| 沒有權限的使用者 | 是否正確以 Access denied 失敗 |
| 不存在的使用者 | 是否能正確處理為驗證失敗 |
| 密碼錯誤 | 是否在不留下機密資訊的情況下失敗 |
| 網路共用 | 確認本機與 UNC 之間的行為差異 |
| 非同步處理 | await 之後是否仍在預期範圍內執行 |
| 發生例外 | 偽裝是否一定會被解除 |
| 並行請求 | 不同使用者的偽裝是否不會混在一起 |
| 服務執行 | 是否以真正的服務帳戶運作,而不是開發者的互動式登入環境 |
尤其以下這兩點絕對不能省略:
成功時要成功
應該失敗時要正確失敗
在偽裝相關的處理中,不只要測試成功案例,也要測試沒有權限的使用者是否確實被拒絕。
如果應該被拒絕的操作卻成功了,就代表偽裝或授權的設計可能有問題。
23. 實作檢查清單
在實作前後,可以用這份檢查清單確認一次。
| 角度 | 檢查內容 |
|---|---|
| 目的 | 能否說明為什麼需要偽裝 |
| 替代方案 | 有沒有檢討過用服務帳戶或應用程式授權是否可行 |
| 範圍 | 偽裝範圍是否已經最小化 |
| 還原 | 發生例外時是否也能確實還原 |
| 非同步 | 是否在 RunImpersonatedAsync 內部 await 到完成 |
| 權杖 | 是否有把主要權杖和偽裝權杖搞混 |
| 偽裝層級 | 是否有確認 Identification 和 Impersonation / 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、服務帳戶、既有檔案伺服器、企業內部網域資產的實務工具。
參考資料
- 本文的範例程式碼一套(函式庫、示範程式、單元測試)
https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/windows-impersonation-token - Microsoft Learn: Access Tokens
https://learn.microsoft.com/en-us/windows/win32/secauthz/access-tokens - Microsoft Learn: Impersonation Tokens
https://learn.microsoft.com/en-us/windows/win32/secauthz/impersonation-tokens - Microsoft Learn: Impersonation Levels
https://learn.microsoft.com/en-us/windows/win32/secauthz/impersonation-levels - Microsoft Learn:
SECURITY_IMPERSONATION_LEVELenumeration
https://learn.microsoft.com/en-us/windows/win32/api/winnt/ne-winnt-security_impersonation_level - Microsoft Learn:
ImpersonateLoggedOnUserfunction
https://learn.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-impersonateloggedonuser - Microsoft Learn:
RevertToSelffunction
https://learn.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-reverttoself - Microsoft Learn:
LogonUserfunction
https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-logonusera - Microsoft Learn:
DuplicateTokenExfunction
https://learn.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-duplicatetokenex - Microsoft Learn:
TOKEN_INFORMATION_CLASSenumeration
https://learn.microsoft.com/en-us/windows/win32/api/winnt/ne-winnt-token_information_class - Microsoft Learn:
WindowsIdentity.RunImpersonated
https://learn.microsoft.com/en-us/dotnet/api/system.security.principal.windowsidentity.runimpersonated - Microsoft Learn:
WindowsIdentity.RunImpersonatedAsync
https://learn.microsoft.com/en-us/dotnet/api/system.security.principal.windowsidentity.runimpersonatedasync - Microsoft Learn: Configure Windows Authentication in ASP.NET Core
https://learn.microsoft.com/en-us/aspnet/core/security/authentication/windowsauth
相關文章
共用相同標籤的最新文章。能以相近的主題延伸理解。
Windows為何演變成現在的樣子:從開發者角度看歷代Windows的演進
本文從相容性、穩定性、權限管理、驅動程式、Win32、.NET、資安等Windows應用程式開發者的角度,整理Windows 95到Windows 11的變化,而不是單純以外觀年表的方式呈現。
在 WinForms/WPF 應用程式中導入 Entra ID 驗證 ── MSAL.NET 與 WAM 代理的實務架構
從實務角度整理在 WinForms/WPF 桌面應用程式中導入 Entra ID(原 Azure AD)驗證的做法。內容涵蓋公用客戶端的概念、ROPC 廢除現況、應用程式註冊、MSAL.NET 的 AcquireTokenSilent 模式、WAM 代理、權杖快取持久化,以...
業務應用程式的日期時間與時區 ── 從 DateTime 的陷阱到 UTC 儲存原則、測試設計
伺服器搬遷後時間整整差了 9 個小時、只有海外據點的日期會變成前一天──本文從 DateTime 的 Kind 與隱式轉換整理日期時間事故的根本原因。內容涵蓋與 DateTimeOffset 的取捨、UTC 儲存與 ISO 8601 原則、TimeZoneInfo 與夏令時...
在 C# 中將 SQLite 用於業務應用程式 ── WAL 模式・排他控制・損毀對策・與 EF Core 的取捨
整理用 Microsoft.Data.Sqlite 把 SQLite 整合進業務用 Windows 應用程式的實務知識:連線字串與連線池、WAL 模式的原理、SQLITE_BUSY 與寫入路徑單一化、用交易加速、型別對應的陷阱、用 VACUUM INTO 備份,一直到該在什...
Windows 服務的建立與維運 ── 從工作排程器的取捨到 BackgroundService 服務化
常駐處理該做成 Windows 服務,還是工作排程器就足夠?本文整理判斷表,以及用 .NET Worker Service(UseWindowsService)建立服務的方法、執行帳戶、復原選項、事件記錄、安全停止處理,從實務角度梳理上線維運所需的設計。
相關主題
與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。
Windows 技術主題
彙整 KomuraSoft LLC 關於 Windows 開發、故障調查與既有資產活用文章的主題中心。
常見問題
整理諮詢這個主題時常見的問題。
- 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 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。