1. 簡介
Redis 是一套開放原始碼的記憶體內資料結構儲存系統,長期以來透過整合的 Lua 腳本環境提供擴充性。雖然這項功能藉由允許原子操作來提升效能,但也帶來了記憶體管理與狀態同步方面顯著的攻擊面。這份報告分析了 CVE-2026-23631 ,也稱為 DarkReplica ,這是在 Redis 複製子系統中發現的一個嚴重權限驗證後 Use-After-Free (UAF) 漏洞。該漏洞源於 Redis 在 Lua 函數執行期間處理同步事件時的一個邏輯缺陷,導致 Lua 解譯器狀態被過早摧毀。
2. 背景:Redis Lua 引擎與執行模型
現代 Redis 版本維護兩個不同的 Lua 執行環境:傳統的腳本引擎 (EVAL/SCRIPT LOAD) 以及 7.0 版引入的新函數引擎 (FUNCTION LOAD/FCALL)。函數引擎的設計目的在提供更好的持久性與叢集同步。然而,由於 Redis 運行在單執行緒的事件迴圈上,任何長時間執行的 Lua 腳本都可能阻塞整個伺服器。為了解決此問題,Redis 實作了一套 「Slow Script」檢測機制 。
當腳本執行時間超過設定的閾值 (預設為 5 秒) 時,Redis 會觸發一個逾時旗標。為了讓伺服器保持足夠的反應能力來處理
FUNCTION KILL
或
SHUTDOWN
指令,Redis 利用 Lua hooks (
lua_sethook
) 透過
processEventsWhileBlocked()
函數定期將控制權交還給伺服器的事件迴圈
[1]
。這樣的設計讓伺服器即使在腳本技術上「blocking」主執行緒時,仍能處理 I/O 事件。
3. DarkReplica 邏輯缺陷的技術分析
DarkReplica 漏洞的核心在於
processEventsWhileBlocked()
機制與 Redis 複製協定之間的互動。在正常情況下,當伺服器「busy」執行腳本時,
processCommand()
函數會拒絕來自一般用戶端的大多數指令,以防止狀態損壞。然而,對於複製架構中來自主要伺服器 (Master) 的指令,此保護機制則會被繞過。
當 Redis 執行個體使用
SLAVEOF
指令設定為複本 (Slave) 時,它會與主要伺服器建立一個持久連線。如果主要伺服器發起
FULLRESYNC
,複本就必須清除其現有資料並載入一個新的 RDB (Redis Database) 檔案。關鍵在於,如果這個
FULLRESYNC
發生在一個 Lua 函數於
processEventsWhileBlocked()
中被暫停的時候,伺服器會繼續清除全域函數環境,包括
lua_State
物件本身,而被暫停的函數仍然持有一個指向它的 Pointer。
這種行為與其他 Lua 相關的漏洞 (例如
CVE-2025-49844
) 形成對比,CVE-2025-49844 涉及 Lua 解析器內因垃圾回收對
TString
物件保護不足所導致的 UAF
[2]
。CVE-2025-49844 是解析過程中低階的記憶體管理錯誤,而 CVE-2026-23631 則是更高階的架構缺陷,其中整個解譯器的生命週期並未與複製狀態機同步。
4. 系統架構與漏洞流程
下方的循序圖說明了 DarkReplica 攻擊的生命週期,突顯了腳本執行與複製同步之間的 Race Condition。
5. 程式碼分析
以下來自 Redis 原始碼的片段展示了有漏洞的路徑。
5.1. 中斷機制
Redis 使用一個 hook 從 Lua 引擎取回控制權。
scriptInterrupt
函數決定腳本是否應該被終止,或者伺服器是否應該處理事件。
- /*
- * Snippet 1: The Lua hook mechanism
- * This function is called every 100,000 Lua instructions.
- */
- int scriptInterrupt(scriptRunCtx *run_ctx) {
- if (run_ctx->flags & SCRIPT_TIMEDOUT) {
- /*
- * If the script has timed out, we enter this block.
- * processEventsWhileBlocked() is the danger zone where
- * replication events are handled.
- */
- processEventsWhileBlocked();
- /* Returns whether to kill the script or continue */
- return (run_ctx->flags & SCRIPT_KILLED) ? SCRIPT_KILL : SCRIPT_CONTINUE;
- }
- return SCRIPT_CONTINUE;
- }
5.2. 指令處理漏洞
processCommand
函數通常在腳本執行期間會阻擋指令,但複製流量會繞過這些檢查。
- /*
- * Snippet 2: Logic in processCommand
- * Regular clients are blocked, but master-replica traffic is not.
- */
- int processCommand(client *c) {
- // ...
- /* Check if the server is busy with a script */
- if (isInsideYieldingLongCommand() && !(c->cmd->flags & CMD_ALLOW_BUSY)) {
- /*
- * Rejects commands from normal clients.
- * However, commands from the Master do not always
- * trigger this rejection, especially during RDB loading.
- */
- rejectCommand(c, shared.slowscripterr);
- return C_OK;
- }
- // ...
- }
5.3. 複製環境清除
當收到
FULLRESYNC
時,從屬伺服器會呼叫
functionsLibCtxClearCurrent
來為新資料做準備。
- /*
- * Snippet 3: Clearing the function context
- * This is the root of the UAF.
- */
- void functionsLibCtxClearCurrent(int async) {
- if (async) {
- /* Handle asynchronous clearing */
- functionsLibCtx *old_l_ctx = curr_functions_lib_ctx;
- dict *old_engines = engines;
- freeFunctionsAsync(old_l_ctx, old_engines);
- } else {
- /*
- * Synchronous clearing: Immediately frees the engines dict.
- * This includes the lua_State currently executing the script.
- */
- functionsLibCtxFree(curr_functions_lib_ctx);
- dictRelease(engines); // <--- CRITICAL: lua_State is destroyed here
- }
- functionsInit(); // Re-initializes a fresh context
- }
5.4. 深入了解環境銷毀
functionsLibCtxFree
函數會遞迴清理所有相關資源。
- /*
- * Snippet 4: Freeing the function library context
- * Detailed cleanup of libraries and engines.
- */
- void functionsLibCtxFree(functionsLibCtx *functions_lib_ctx) {
- /* Clear all registered functions in the library */
- functionsLibCtxClear(functions_lib_ctx);
- /* Release the dictionaries holding function and library metadata */
- dictRelease(functions_lib_ctx->functions);
- dictRelease(functions_lib_ctx->libraries);
- /* Release engine statistics */
- dictRelease(functions_lib_ctx->engines_stats);
- /* Finally, free the context structure itself */
- zfree(functions_lib_ctx);
- }
5.5. 從 RDB 載入新函數
在
FULLRESYNC
的 RDB 載入階段,新函數會被註冊,但在載入完成前它們會使用一個不同的環境。
- /*
- * Snippet 5: RDB Function Loading
- * Part of the rdbLoad process.
- */
- if (type == RDB_OPCODE_FUNCTION2) {
- sds err = NULL;
- /*
- * Loads the function library from the RDB file.
- * If this happens while a script is yielding, the old
- * engine state has already been marked for destruction.
- */
- if (rdbFunctionLoad(rdb, rdbver, rdb_loading_ctx->functions_lib_ctx, rdbflags, &err) != C_OK) {
- serverLog(LL_WARNING,"Failed loading library, %s", err);
- sdsfree(err);
- goto eoferr;
- }
- continue;
- }
6. 攻擊情境
要觸發此漏洞,攻擊者必須擁有 Redis 執行個體的驗證存取權限。攻擊遵循以下步驟:
- 準備: 攻擊者註冊一個包含無限迴圈或非常長執行路徑的 Lua 函數。
-
設定:
攻擊者使用
CONFIG SET slave-read-only no確保即使伺服器進入複本狀態,FCALL仍然可以被執行。 -
執行:
攻擊者透過
FCALL執行這個慢速函數。 -
觸發複製:
當函數正在執行並進入逾時狀態 (觸發
processEventsWhileBlocked) 時,攻擊者使用SLAVEOF指令將受害者指向一個由攻擊者控制的主要伺服器。 -
Payload 傳遞:
攻擊者的主要伺服器發送一個
FULLRESYNC回應,其中包含一個特製的 RDB 檔案。受害者的伺服器在處於「running」Lua 腳本的暫停狀態時,會處理該複製事件並銷毀使用中的 Lua 引擎。 -
UAF 執行:
一旦複製事件處理完畢,
processEventsWhileBlocked函數就會回傳,而 Lua 引擎會嘗試使用一個已被釋放的lua_StatePointer 來恢復原始腳本的執行,導致任意程式碼執行。
7. 與先前 Lua 漏洞的比較
將 CVE-2026-23631 與較早的漏洞如
CVE-2025-46817
(
unpack()
中的整數溢位)
[2]
進行比較是很有啟發性的。在
unpack()
漏洞的情況中,缺陷是缺乏對索引的邊界檢查,導致堆疊損壞。然而,DarkReplica 代表了一個更複雜的
「State Machine」漏洞
。它不是由單一有問題的程式碼行所造成,而是兩個複雜功能(讓出 Lua 執行權與完整複製重新同步)之間未能預見的互動所導致的。
8. 結論與修復建議
CVE-2026-23631 突顯了在單執行緒架構中進行多狀態管理的風險。一個「blocking」的腳本能有效地停止所有狀態變更操作的假設,已被複製子系統的優先權所推翻。Redis 已透過確保在腳本讓出執行權時無法清除函數環境來解決這個問題。
強烈建議使用者升級到以下已修復的版本:Redis 7.2.14、7.4.9、8.2.6、8.4.3 或 8.6.3。此外,一個安全最佳實務是透過 Redis ACL 限制
SLAVEOF
與
CONFIG
指令的使用,以防止已驗證但非特權的使用者重新設定伺服器的複製狀態。