U盤禁用守護進程:插拔檢測、強制禁用與自我?;? alt=)
干這行的人多少都遇到過這種需求公司內(nèi)網(wǎng)機器要封U盤、機房設(shè)備只允許指定U盤讀寫、或者就是單純不想讓人隨便往服務(wù)器上插東西。市面上現(xiàn)成的管控軟件不是價格離譜就是策略太死板弄來弄去還不如自己用C#寫一個。今天這篇就聊一個完整的C# U盤禁用守護進程覆蓋U盤插入、拔出、卸載這三個關(guān)鍵動作的處理順帶把服務(wù)化、自我?;钸@類生產(chǎn)環(huán)境必須考慮的問題也講透。適合有WinForm、Windows服務(wù)基礎(chǔ)的C#開發(fā)者參考也適合被臨時部署一套U盤管控折磨過的運維、桌面管理人員。1. 為什么要自己寫一個U盤管控守護服務(wù)1.1 現(xiàn)成方案能做到什么程度系統(tǒng)自帶的策略確實能禁U盤但操作路徑深、效果有限。打開組策略編輯器定位到計算機配置-管理模板-系統(tǒng)-可移動存儲訪問里面有一堆可移動磁盤拒絕讀取權(quán)限之類的選項啟用后普通U盤確實被擋住了。問題是這個策略對已經(jīng)安裝過驅(qū)動、或者通過USB讀卡器轉(zhuǎn)接的設(shè)備經(jīng)常漏網(wǎng)而且策略刷新有延遲用戶插上U盤到策略生效之間有幾秒鐘的空窗期。更麻煩的是管理員自己要用U盤的時候來回改策略太折騰沒法做到按設(shè)備白名單放行。注冊表禁用USBSTORSYSTEM\CurrentControlSet\Services\USBSTOR的 Start 值改為 4是另一種常見手段但只對還沒裝驅(qū)動的U盤有效已緩存驅(qū)動的設(shè)備照樣能識別。而且改注冊表需要管理員權(quán)限被安全軟件攔截的概率也不低。所以我自己折騰這套東西的目的很明確把插拔檢測、設(shè)備禁用、白名單放行、后臺?;钸@些東西集中到一個C#守護進程里既能實時響應(yīng)插拔動作又能按設(shè)備實例ID精確管控。1.2 守護進程要管的三件事所謂守護進程核心就是三件事盯著、攔截、活著。盯著隨時知道U盤的插入、拔出、彈出請求。攔截在U盤剛插入還沒生效時禁用或者在用戶點安全刪除硬件時阻止卸載?;钪M程不能被普通用戶隨手kill服務(wù)崩潰了能自動拉起開機自啟不依賴用戶登錄。用C#寫這套東西好處是托管代碼寫起來快WMI、注冊表、Windows服務(wù)這些基礎(chǔ)設(shè)施調(diào)用方便對很多做上位機和桌面軟件的人來說門檻也低。壞處是容易被殺軟盯上后面會專門聊排坑。2. 插拔檢測WMI事件監(jiān)聽和DevicChange消息要怎么選2.1 WMI的Win32_VolumeChangeEvent監(jiān)聽要實時感知U盤插入和拔出最省事的方法是WMI事件監(jiān)聽。用ManagementEventWatcher訂閱Win32_VolumeChangeEvent當系統(tǒng)發(fā)生卷變更事件時回調(diào)會觸發(fā)。示例代碼using System.Management; var watcher new ManagementEventWatcher( new WqlEventQuery(SELECT * FROM Win32_VolumeChangeEvent)); watcher.EventArrived (sender, e) { var eventType Convert.ToInt32(e.NewEvent.Properties[EventType].Value); var driveName e.NewEvent.Properties[DriveName].Value?.ToString(); // EventType: 1配置變更, 2設(shè)備到達(插入), 3設(shè)備拔出, 4彈出請求 switch (eventType) { case 2: Console.WriteLine($[插入] {driveName}); break; case 3: Console.WriteLine($[拔出] {driveName}); break; case 4: Console.WriteLine($[彈出請求] {driveName}); break; } }; watcher.Start(); // 程序退出時記得 watcher.Stop() 和 watcher.Dispose()這段代碼有一個必須注意的坑WMI事件的回調(diào)線程和你的主線程不是同一個如果需要操作UI必須通過Invoke或SynchronizationContext切回UI線程。對服務(wù)程序來說沒這個問題但如果你的守護進程是WinForm托盤程序不改的話會在回調(diào)里直接拋跨線程異常。還有一點Win32_VolumeChangeEvent只對分配了盤符的卷觸發(fā)。有些U盤量產(chǎn)工具會把U盤識別成無盤符的存儲設(shè)備這種情況WMI事件不觸發(fā)需要配合下面的設(shè)備通知。2.2 WM_DEVICECHANGE與設(shè)備級別的通知如果需要更底層的設(shè)備插拔事件比如U盤還沒分配盤符就立刻感知應(yīng)該在窗口過程中處理WM_DEVICECHANGE消息。WinForm里重寫WndProcprotected override void WndProc(ref Message m) { const int WM_DEVICECHANGE 0x0219; const int DBT_DEVICEARRIVAL 0x8000; // 設(shè)備已插入 const int DBT_DEVICEQUERYREMOVE 0x8001; // 系統(tǒng)詢問能否移除 const int DBT_DEVICEREMOVEPENDING 0x8003; // 移除即將發(fā)生 const int DBT_DEVICEREMOVECOMPLETE 0x8004; // 移除完成 if (m.Msg WM_DEVICECHANGE) { var wParam m.WParam.ToInt32(); switch (wParam) { case DBT_DEVICEARRIVAL: // 設(shè)備剛插入此時卷可能還未就緒 break; case DBT_DEVICEQUERYREMOVE: // 用戶點了安全刪除硬件或系統(tǒng)準備彈出設(shè)備 // 這里可以設(shè)置 m.Result (IntPtr)1; 阻止移除 break; case DBT_DEVICEREMOVEPENDING: // 移除已經(jīng)無法阻止只能記錄 break; case DBT_DEVICEREMOVECOMPLETE: break; } } base.WndProc(ref m); }WM_DEVICECHANGE是發(fā)送到窗口消息隊列的所以必須有窗口句柄。如果你把守護進程寫成Windows服務(wù)Session 0里沒有可見窗口就得靠隱藏窗口或者直接用WMI方案。我實際項目里的做法是WMI負責卷級別插拔感知WM_DEVICECHANGE負責攔截安全刪除硬件的彈出請求兩者配合才算完整。2.3 為什么不能只靠定時掃描有人會想何必搞事件監(jiān)聽直接寫個Timer每秒鐘掃一遍盤符列表對比上次狀態(tài)不就知道插拔了這種做法在低要求環(huán)境能跑但有兩個硬傷。第一是響應(yīng)延遲。定時掃描最快也得幾百毫秒一輪U盤插上去到被禁用之間的時間窗口可能被利用——有人就是趁著這零點幾秒把文件拷走的。事件驅(qū)動是系統(tǒng)主動通知延遲在毫秒級。第二是攔不住彈出請求。安全刪除硬件這個動作不會有盤符變化只是系統(tǒng)發(fā)一個DBT_DEVICEQUERYREMOVE詢問。這種操作只有處理設(shè)備消息才能攔截輪詢盤符列表根本感知不到。如果你的需求里有禁止用戶隨意卸載U盤這一條定時掃描方案可以直接放棄了。3. 禁用的幾種手段和組合拳3.1 注冊表USBSTOR開關(guān)最經(jīng)典的禁用方式是修改USBSTOR服務(wù)的啟動類型using Microsoft.Win32; private static void SetUsbStorageDisabled(bool disabled) { const string path SYSTEM\CurrentControlSet\Services\USBSTOR; using (var key Registry.LocalMachine.OpenSubKey(path, writable: true)) { if (key null) return; // 4 禁用, 3 手動(默認) key.SetValue(Start, disabled ? 4 : 3, RegistryValueKind.DWord); } }注意這個操作需要管理員權(quán)限而且修改后不會立刻對已插入的設(shè)備生效。注冊表里的 Start 值只影響下一次插入時是否加載驅(qū)動。也就是說U盤已經(jīng)插在上面時改這個值不會把已經(jīng)加載的設(shè)備頂?shù)?。想要立即生效得配合設(shè)備管理器的禁用設(shè)備操作見下一節(jié)。還有個特殊情況USBSTOR鍵可能不存在通常是因為系統(tǒng)里從沒插過U盤。鍵不存在時不能直接創(chuàng)建并寫 Start 值正確做法是先插一次U盤讓系統(tǒng)生成鍵或者用pnputil加載默認驅(qū)動否則注冊表寫入會失敗。我遇到過部署腳本在全新系統(tǒng)上報找不到USBSTOR的坑后來統(tǒng)一改成先判斷鍵是否存在不存在就在日志里提示先去設(shè)備管理器刷新一次。3.2 設(shè)備實例級別的啟用與禁用如果要在U盤已經(jīng)插入的情況下強制禁用可以通過pnputil命令行工具按設(shè)備實例ID來操作:: 禁用指定設(shè)備 pnputil /disable-device USBSTOR\DISKVEN_GENERICPROD_USB_MS_STORAGEREV_0000\1234567890 :: 啟用指定設(shè)備 pnputil /enable-device USBSTOR\DISKVEN_GENERICPROD_USB_MS_STORAGEREV_0000\1234567890 :: 枚舉當前所有U盤設(shè)備 pnputil /enum-devices /class USBSTORC#里可以這樣調(diào)用using System.Diagnostics; private static void RunPnputil(string arguments) { var psi new ProcessStartInfo(pnputil.exe, arguments) { UseShellExecute false, CreateNoWindow true, WindowStyle ProcessWindowStyle.Hidden }; using (var proc Process.Start(psi)) { proc?.WaitForExit(10000); } }判斷設(shè)備是不是U盤可以用WMI查詢Win32_DiskDrive的InterfaceType是否為USB或者Win32_PnPEntity的DeviceID是否以USBSTOR\開頭。以DeviceID做白名單匹配更可靠因為同一個U盤的DeviceID里的序列號是固定的量產(chǎn)過的除外。組合拳的思路是注冊表USBSTOR Start4 做全局默認禁用白名單設(shè)備在插入瞬間用 pnputil /enable-device 放行。這樣既保證默認安全又允許指定U盤使用。3.3 彈出請求的攔截前面提到用戶點安全刪除硬件時系統(tǒng)發(fā)DBT_DEVICEQUERYREMOVE。要禁止卸載在WndProc里把消息的Result設(shè)為(IntPtr)1case DBT_DEVICEQUERYREMOVE: // 判斷設(shè)備是否在白名單中 if (!IsDeviceAllowed(GetDeviceIdFromMessage(m))) { m.Result (IntPtr)1; // 拒絕移除 return; } break;DBT_DEVICEQUERYREMOVE的lParam里包含一個DEV_BROADCAST_HDR結(jié)構(gòu)要拿到設(shè)備實例ID還得解析DEV_BROADCAST_DEVICEINTERFACE或DEV_BROADCAST_HANDLE。用Marshal.PtrToStructure可以解析但代碼量不小。如果想省事可以給受保護的U盤先打開一個句柄不給共享刪除權(quán)限這樣系統(tǒng)會說設(shè)備正在使用中一樣攔得住。這個辦法比較粗暴但實現(xiàn)成本極低// 打開設(shè)備句柄并保持不釋放 FileStream fs new FileStream(\\.\E:, FileMode.Open, FileAccess.Read, FileShare.Read);不過這種保留句柄的方式副作用也明顯U盤無法安全彈出且極少數(shù)U盤會出現(xiàn)掉盤后句柄失效、程序崩潰的情況。按量化的方式用SetupAPI解析設(shè)備路徑更優(yōu)雅但對很多開發(fā)者來說Marshal解析結(jié)構(gòu)體這件事本身就是個坎。我的建議是如果項目周期緊先用手動解析DBT消息的方案哪怕解析不到DevicePath也能通過盤符反查設(shè)備實例ID足夠應(yīng)付大多數(shù)場景。4. 守護進程的服務(wù)化與自我?;?.1 Windows服務(wù)承載和普通進程怎么選寫U盤守護進程第一版通常是個WinForm小程序雙擊運行、圖標在托盤。在開發(fā)機上自測沒問題部署到用戶機器就出幺蛾子——用戶一注銷程序就退了程序崩了沒人點重啟。所以我后來統(tǒng)一改成Windows服務(wù)原因有四個服務(wù)由services.exe拉起用戶注銷不影響。服務(wù)默認運行在Session 0普通用戶的任務(wù)管理器里能看到進程但沒有權(quán)限結(jié)束。服務(wù)可以設(shè)置Automatic啟動類型開機自動運行。服務(wù)失敗時可以通過sc failure設(shè)置重啟動作這是系統(tǒng)級的恢復(fù)機制。用C#寫Windows服務(wù)的骨架很簡單public partial class UsbGuardService : ServiceBase { private ManagementEventWatcher _watcher; public UsbGuardService() { InitializeComponent(); ServiceName UsbGuardDaemon; } protected override void OnStart(string[] args) { StartWmiWatcher(); } protected override void OnStop() { _watcher?.Stop(); _watcher?.Dispose(); } }配一個安裝程序類[RunInstaller(true)] public class ProjectInstaller : Installer { public ProjectInstaller() { var serviceProcessInstaller new ServiceProcessInstaller { Account ServiceAccount.LocalSystem }; var serviceInstaller new ServiceInstaller { ServiceName UsbGuardDaemon, DisplayName USB Guard Daemon, Description USB device control and monitoring service, StartType ServiceStartMode.Automatic }; Installers.Add(serviceProcessInstaller); Installers.Add(serviceInstaller); } }安裝方式sc create UsbGuardDaemon binPath C:\Program Files\UsbGuard\UsbGuardDaemon.exe start auto sc failure UsbGuardDaemon reset 86400 actions restart/5000/restart/10000/restart/30000sc failure這一段特別關(guān)鍵它讓服務(wù)在異常退出后自動重啟。但注意被任務(wù)管理器結(jié)束的服務(wù)恢復(fù)機制會觸發(fā)如果是被安全軟件直接kill了進程這個機制通常不生效所以還需要第二層防護。4.2 雙進程互監(jiān)控與看門狗Windows服務(wù)的恢復(fù)機制只能覆蓋服務(wù)管理器認為服務(wù)掛了的情況攔不住進程被強制結(jié)束。所以要再加一道保障一個獨立的看門狗進程定時檢查服務(wù)進程是否存在不存在就重新拉起。看門狗本身是另一個普通Windows服務(wù)或計劃任務(wù)每隔10秒檢查主服務(wù)進程private static bool IsServiceProcessRunning(string processName) { return Process.GetProcessesByName(processName).Length 0; } static void WatchLoop() { while (true) { if (!IsServiceProcessRunning(UsbGuardDaemon)) { // 重新拉起服務(wù) Process.Start(sc.exe, start UsbGuardDaemon); } Thread.Sleep(TimeSpan.FromSeconds(10)); } }但雙進程也有它自己的問題看門狗被結(jié)束怎么辦所以更完整的架構(gòu)是三個進程互相盯——主服務(wù)、看門狗、輔助工具進程。三者的關(guān)系是主服務(wù)崩潰看門狗拉起??撮T狗崩潰主服務(wù)里的自檢邏輯發(fā)現(xiàn)心跳丟失重新啟動看門狗。輔助工具進程被結(jié)束主服務(wù)重新拉起。實現(xiàn)方式不一定要三進程拉滿小環(huán)境下做主服務(wù)看門狗就夠了但看門狗建議用計劃任務(wù)跑因為計劃任務(wù)獨立于登錄會話比輔助進程存活率更高。我實際部署時發(fā)現(xiàn)光靠進程互相拉不太夠還得在主服務(wù)里加一個Timer自檢線程定期檢查關(guān)鍵功能是否正常比如WMI watcher是否還活著、注冊表策略是否被改了。有一次用戶裝了某優(yōu)化軟件把USBSTOR的Start值給改回3了我的進程還活著但策略已經(jīng)失效——這種狀態(tài)靠進程保活是發(fā)現(xiàn)不了的必須每隔幾分鐘主動復(fù)核一遍注冊表策略。4.3 Session 0隔離和權(quán)限的坑服務(wù)運行在Session 0這個設(shè)計天然隔離了普通用戶的窗口交互。但要注意如果服務(wù)里寫了MessageBox.Show或者試圖彈出托盤圖標、窗口用戶是看不到的。所以U盤被禁用時的用戶提示不能靠服務(wù)直接彈窗得另想辦法用WTSRegisterSessionNotification監(jiān)聽用戶會話然后跨Session彈窗?;蛘叻?wù)把事件寫入日志/發(fā)UDP廣播由用戶Session里的托盤程序負責展示通知。最省事只寫日志事件不管提示反正用戶發(fā)現(xiàn)U盤用不了自然會上報。權(quán)限方面服務(wù)用LocalSystem賬戶運行操作注冊表、調(diào)用pnputil都沒問題。但有幾點要注意64位系統(tǒng)上注冊表有32位/64位視圖之分用Registry.LocalMachine默認操作64位視圖沒問題如果用了RegistryView.Registry32去讀USBSTOR可能讀不到。pnputil在某些精簡版系統(tǒng)上可能沒有部署前要檢查C:\Windows\System32\pnputil.exe是否存在。如果想用SetupAPI的CM_Disable_DevNode禁用設(shè)備需要管理員令牌服務(wù)模式下默認有普通進程跑就需要提權(quán)。5. 實際部署中踩過的坑和排查記錄5.1 注冊表改了Start4但U盤還能用這個坑我印象太深了?,F(xiàn)象是USBSTOR的Start值明明改成了4插上U盤照樣識別、照樣分配盤符。排查鏈路如下先看事件日志發(fā)現(xiàn)每次插入時驅(qū)動加載記錄是USBSTOR成功。這就說明Start4沒生效。再翻注冊表發(fā)現(xiàn)USBSTOR鍵下有多個子鍵包括Enum和Parameters。重新讀文檔后意識到Start是USBSTOR服務(wù)的啟動類型U盤設(shè)備已經(jīng)被系統(tǒng)緩存過之后驅(qū)動文件已經(jīng)加載到內(nèi)存光改Start不夠必須配合pnputil /restart-device或重啟系統(tǒng)。另外有些機器插的是USB 3.0口走的驅(qū)動可能不是USBSTOR而是USBXHCI棧下面的UASP驅(qū)動UASPStor.sys需要把UASPStor也一起處理。解決方案是把這兩個服務(wù)都改掉var services new[] { SYSTEM\CurrentControlSet\Services\USBSTOR, SYSTEM\CurrentControlSet\Services\UASPStor };5.2 WMI事件偶發(fā)丟失Win32_VolumeChangeEvent在實際運行中不是100%可靠壓力測試時發(fā)現(xiàn)拔出事件偶爾不觸發(fā)。原因多半是WMI倉庫性能問題或者事件訂閱被回收。解決思路在ManagementEventWatcher的Stopped事件里重新訂閱。定期檢查watcher狀態(tài)。用WM_DEVICECHANGE兜底兩邊的事件都收到后做去重。我在代碼里加了一個簡單的去重邏輯以盤符設(shè)備實例ID為key記錄最近一次事件時間10秒內(nèi)同一設(shè)備的事件只處理一次。這既避免重復(fù)處理又不影響對快速插拔的響應(yīng)。5.3 殺軟和繞過問題C#寫的服務(wù)編譯出來自帶.NET運行時很多殺軟會掃描并提示可疑程序——尤其是當你調(diào)用pnputil禁用設(shè)備、寫注冊表自啟動這類行為時。我處理的辦法是給程序加上強名稱簽名必要時做一下EV代碼簽名能顯著降低誤報率。避免使用容易被標記的混淆器。部署時先加白名單再放量。不要用Process.Start(cmd.exe, /c ...)這種明顯被監(jiān)控的調(diào)用鏈直接調(diào)用ProcessStartInfo指定pnputil.exe相對更安全。繞過的問題也提一嘴。聰明的用戶知道禁用U盤靠的是USBSTOR注冊表他會自己把它改回來。所以守護進程要定時檢查Start值是否被篡改發(fā)現(xiàn)異常立即改回去并記日志。這屬于防君子不防小人但對絕大多數(shù)辦公環(huán)境來說已經(jīng)夠了。真要防有管理員權(quán)限的惡意用戶那就得上驅(qū)動過濾或EDR級別的方案不是純靠C#用戶態(tài)程序能完全解決的。5.4 部署與回滾策略上生產(chǎn)之前一定要做好回滾方案。我們的做法是守護進程不做永久的卸載阻斷而是帶一個維護模式開關(guān)。在配置文件里加一個MaintenanceMode選項管理員把U盤插入并按下組合鍵后守護進程進入臨時放行狀態(tài)放行窗口默認5分鐘時間到自動恢復(fù)管控。維護模式的開啟方式不能寫在日志里不然誰都知道了?;貪L就是簡單地停服務(wù)、刪注冊表改過的值、把pnputil /disable-device的設(shè)備重新enable。我已經(jīng)把部署腳本和回滾腳本都寫成PowerShell部署時自動化執(zhí)行回滾也一鍵完成。對桌面環(huán)境的批量管控來說回滾能力比部署能力更重要——出了問題收不住比一開始沒管控更麻煩。寫在最后的幾個經(jīng)驗這套守護進程我從第一版WinForm托盤程序踩到現(xiàn)在服務(wù)看門狗架構(gòu)最大的體會是U盤管控的價值在策略的完整性而不在技術(shù)的高深。檢測插拔的方式再多策略本身有漏洞照樣白搭。比如只禁了USBSTOR卻沒禁讀卡器、只攔了彈出卻放過網(wǎng)絡(luò)共享都是實際項目中容易漏掉的口子。另一個經(jīng)驗是做好日志審計。每次插入、拔出、禁用、放行都往Windows事件日志里寫一條結(jié)構(gòu)化記錄字段里帶上設(shè)備實例ID和盤符。出了任何問題看日志就能復(fù)盤整個鏈路省去大量扯皮時間。最后別貪多。U盤管控只是安全體系里的一小環(huán)如果你已經(jīng)在用終端管理軟件EDR、準入客戶端先看看它有沒有現(xiàn)成的設(shè)備控制模塊能復(fù)用就別重復(fù)造輪子。只有現(xiàn)成方案滿足不了特殊需求時才值得自己寫守護進程。真寫起來保持事件驅(qū)動檢測 組合策略禁用 雙進程保活 審計日志這個骨架整個系統(tǒng)的穩(wěn)定性和可維護性都會有保障。