OPC UA .NET Standard Stack 資源耗盡漏洞分析-05-26
漏洞概述
OPC UA .NET Standard Stack是OPC Foundation(OPC基金會)官方維護的OPC UA協議棧的參考實現。該協議棧以.NET語言開發,包含了可移植的OPC UA協議棧和核心庫(包含客戶端、服務端、配置、復雜類型支持庫等),服務端和客戶端的參考實現以及客戶端和服務端的X.509證書認證等實現。
OPC UA協議是工業控制領域中的一種十分流行的通訊協議。啟明星辰ADLab研究員在工控漏洞情報跟蹤中發現了OPC UA .NET Standard Reference Server中存在內存資源耗盡漏洞(編號為CVE-2023-27321),并對該漏洞進行了深入分析和驗證。
漏洞分析
該漏洞存在于OPC UA .NET Standard Server Stack代碼庫中。根據官方漏洞公告,遠程攻擊者可通過發送惡意的請求來耗盡服務器所有可用內存。

圖 1、OPC Foundation Security Bulletin關于CVE-2023-27321概述【1】
由官方漏洞公告的描述可以看出,該漏洞存在于OPC UA .NET Standard Reference Server對OPC UA Client請求的處理代碼中。OPC UA .NET Standard處理客戶端請求的關鍵代碼類位于協議棧代碼Stack\Opc.Ua.Core\Stack\Tcp目錄下。如下圖所示:

圖 2、OPC UA .NET Standard處理TCP連接核心代碼文件
其中創建OPC UA Server Service的核心入口位于TCPServiceHost類函數CreateServiceHost中。
該函數首先通過Create函數創建一個TCPTransportListener,隨后調用ServerBase類函數CreateServiceHostEndpoint啟動該監聽器:

圖 3、TcpServiceHost類成員函數CreateServiceHost調用Create方法創建Tcp監聽器

圖 4、ServerBase類函數CreateServiceHostEndpoint調用Open方法啟動Tcp監聽器
CreateServiceHostEndpoint函數調用TCPTransportListener的Open方法啟動監聽器。在Open函數中,首先通過ChannelQuotas類對OPC UA連接的通道參數進行了一些列的配置,例如MaxBufferSize、MaxMessageSize等。并且實例化了一個用于管理Socket buffer的BufferManager類。然后調用Start函數運行Listener。

圖 5、TcpTransportListener類Open函數主要代碼
在Start函數中,完成Server Socket的創建工作,同時指定了OnAccept函數來處理OPC UA Client連接:

圖 6、TcpServerListener類函數Start創建Server socket
OnAccept函數則建立了一個TcpServerChannel來管理客戶端連接。同時設置該channel的各種消息處理回調函數。并調用Attach函數將該TcpServerChannel與Client socket進行關聯。

圖 7、TcpTransportListener類OnAccept函數創建TcpServerChannel關聯Client socket
隨后在Attach函數中創建了TcpMessageSocket實例來處理客戶端請求數據,使用TcpMessageSocket類的ReadNextMessage方法來處理客戶端請求數據。

圖 8、TcpServerChannel類函數Attach創建TcpMessageSocket實例處理客戶端請求數據
以上便是OPC UA .NET Standard創建Server,接受Client連接和處理Client請求的過程。下面我們著重分析ReadNextMessage()函數,該函數負責處理客戶端的請求數據。該函數的實現代碼如下所示:

圖 9、ReadNextMessage函數代碼
該函數是一個雙重循環,第一重循環首先通過BufferManager申請一塊內存,然后設置m_bytesToReceive為TcpMessageLimits.MessageTypeAndSize(值為8)大小,然后調用ReadNextBlock讀取Message數據。
在ReadNextBlock函數中,使用ReceiveAsync函數異步方式接受客戶端的請求數據,在ReceiveAsync的接收回調函數OnReadComplete中,通過調用DoReadComplete函數,完整讀取一個Message消息的所有內容。然后通過ReadNext函數重新進入ReadNextMessage循環,不斷的處理客戶端的請求消息。

圖 10、ReadNextBlock調用ReceiveAsync異步接收客戶端數據

圖 11、OnReadComplete通過readState狀態確定客戶端一個Message是否讀取完畢
DoReadComplete函數中首先確保讀取到了8字節的Message頭部,其中包含了4字節的MessageSize,然后通過該MessageSize大小讀取剩余Message消息數據。如果完成一個Message的讀取,再通過觸發OnMessageReceived函數來處理消息的內容。

圖 12、DoReadComplete讀取Messsage消息過程
OnMessageReceive函數最終是通過HandleInComingMessage來具體處客戶端請求消息內容。在HandleInComingMessage函數中通過判斷MessageType來確定不同的Message處理函數(包括ProcessRequestMessage、ProcessHelloMessage、ProcessOpenSecureChannelRequest等)。

圖 13、HandleInComingMessage函數處理流程
在消息的具體處理函數ProcessXXX中解析和處理完消息數據后,便會釋放存儲消息的內存。如下是ProcessRequestMessage函數中釋放Message內存的代碼:

圖 14、ProcessRequestMessage函數釋放內存代碼
在Server端收到客戶端消息時,會根據創建TcpServerChannel時設置的回調函數(參見圖7),對客戶端進行回復。例如處理客戶端request的回調函數是TcpTransportListener類中的OnRequestReceived函數。該函數設置了消息處理完畢的回調函數OnProcessRequestComplete函數,并在此函數中調用SendReponse對客戶端消息進行回復。

圖 15、客戶端Request消息處理的回調函數設置
SendResponse函數則通過WriteSymmetricMessage函數生成Response消息并調用BeginWriteMessage發送給OPC UA Client。

圖 16、SendResponse函數關鍵代碼
這里WriteSymmetricMessage函數中依然會通過BufferManager來申請一塊內存來存儲回復消息數據。申請的內存大小為SendBufferSize。

圖 17、WriteSymmetricMessage內存分配關鍵代碼
從上述OPC UA .NET Standard Server處理Client請求的過程可以看出,無論是在接收Client Message消息階段還是發送Response消息階段,Server都會申請一塊內存來臨時存儲數據。對于接收階段,在ReadNextMessage函數中為每個Message申請的內存的大小為m_receiveBufferSize,該變量在TcpServerChannel類中初始化TcpMessageSocket時指定為Quotas.MaxBufferSize(參見圖8)。而Quotas.MaxBufferSize是在TcpTransportListener類的Open函數中賦值的(參見圖5),其最終來源為ServerBase類函數CreateServiceHostEndpoint中的參數ApplicationConfiguration。對于OPC UA Reference Server應用來說,就是其配置文件(Quickstarts.ReferenceServer.Config.xml)的MaxBufferSize參數,默認值為65535。

圖 18、Reference Server配置文件中關于TransportQuotas的參數配置
同樣回溯WriteSymmetricMessage函數中SendBufferSize的賦值過程發現,其初始大小也是MaxBufferSize。

圖 19、UaSCBinaryChannel類構造函數中對sendBufferSize的初始化
也就是說,對于客戶端發送的每個TCP請求,OPC UA Reference Server都會通過BufferManager申請64K的內存來存放Request數據,然后處理完畢后再申請64KB的內存來存放Response數據。
根據BufferManager的實現可知,該類是通過ArrayPool來進行動態內存管理的。

圖 20、BufferManager構造函數代碼
ArrayPool是.NET框架中的一個類,用于管理和重用數組內存緩沖區。它旨在幫助減少在高性能應用程序中頻繁分配和釋放大量相同大小的數組時產生的垃圾回收壓力。在傳統的.NET內存管理中,每次使用new關鍵字創建數組時,都會在堆上分配一塊內存。當這些數組不再使用時,垃圾回收器會負責回收這些內存。這種頻繁的內存分配和垃圾回收操作可能會對性能產生負面影響。ArrayPool通過維護一個內部的緩沖區池來解決這個問題。當需要分配一個數組時,可以從池中獲取一個可用的數組,而不是每次都分配新的內存。使用完畢后,可以將數組返回到池中以供重用,而不是立即釋放內存。
ArrayPool的使用雖然提高了系統進行內存分配和釋放的性能,但是對ArrayPool不加限制的不當使用,會導致系統資源被耗盡。
漏洞復現
復現環境
lOPC UA Vulnerable Server
OPC UA .NET Standard Reference Server(Version: UA-.NETStandard-1.4.371.60)
復現過程
首先按照默認配置編譯OPC UA .NET Standard Reference Server。然后啟動該OPC UA Server。

圖 21、OPC UA .NET Standard Reference Server啟動界面
運行PoC腳本,腳本中OPC Client連接Reference Server之后將發送大量請求,最終可消耗Reference Server所在主機的所有可用內存。如下圖所示:

圖 22、OPC UA .NET Standard Reference Server消耗大量內存
下圖展示了在調試環境中通過插樁內存分析代碼得到的ArrayPool內存占用情況和程序實際內存占用的情況。
補丁分析
根據OPC UA的官方漏洞公告,該漏洞在OPC UA .NET Standard 1.4.371.86版本【2】中修復。通過對該版本代碼的分析,我們發現實際上該漏洞在Github庫UA-.NET Standard的Commit 67fd91ca993c01c38712a61f0342dfdf3c02f4c5中【3】已被修復。
主要修復的方式如下:
1.限制了服務端RequestQueue隊列的大小。

圖 23、漏洞補丁(部分)-限制Server端RequestQueue大小
2.增加了判斷Client和Server通信的通道是否已滿的函數ChannelFull,該函數限制了在一個Channel會話中能保持活躍的最大WriteRequest數量為100。當客戶端不再從Server讀取數據后,關閉當前Client連接的channel。

圖 24、漏洞補丁(部分)-判斷Server端Channel WriteRequest數量
上述修復方式的核心是:限制OPC UA Server所能占用的托管內存大小,避免在ArrayPool中分配過多的內存資源。
安全建議
鑒于該漏洞無需認證便可從網絡側發動針對OPC UA服務器的拒絕服務攻擊,我們建議使用了OPC UA .NET Standard代碼的相關OPC UA產品及時更新OPC UA .NET Standard代碼到版本1.4.371.86, 或者將引用的代碼版本更新到修復了該漏洞的commit版本。