這週的資安新聞裡,最讓我停下來多看兩眼的是 PHP2Shell:PHP 核心引擎同時被揭露兩個可以遠端執行程式碼(RCE)的記憶體安全漏洞(CVE-2026-109618 與 CVE-2026-109619),CVSS v4.0 評分為 9.2,並於 2026 年 10 月 26 日正式被列入 CISA KEV(已知遭利用漏洞)清單。
我自己早就不寫 PHP 了,但這個事件有兩件事跟我們每個人都有關:一是漏洞躲在「應用程式碼寫得再乾淨也沒用」的執行引擎層;二是 AI 輔助的漏洞研究與防禦在此次事件中展現了關鍵影響。這篇文章就來拆解它的技術細節,最後整理成人類工程師與 AI agent 各自適用的防禦清單。
本文依據 PHP Foundation、PHP 官方公告、CISA KEV、加拿大資安中心(AL26-026) 以及 Calif、Wiz、Akamai、AWS 等單位的技術揭露整理。文中的攻擊鏈屬於概括性描述,實際利用細節會依 SAPI 架構、建置編譯選項與記憶體佈局而異。
事件概觀
| 項目 | CVE-2026-109618 | CVE-2026-109619 |
|---|---|---|
| 類型 | 記憶體損壞(OOM 導致未完整初始化結構殘留) | use-after-free(跨請求殘留的全域狀態) |
| CVSS v4.0 | 9.2 | 9.2 |
| 觸發條件 | 不需驗證,透過 HTTP 請求觸發 | 不需驗證,需同一 worker 處理後續請求 |
| SAPI / 觸發路徑 | mod_php 可在請求啟動失敗時觸發;FPM 需應用程式層執行路徑 |
需要同一 worker 存在殘留狀態的 producer 與存取該狀態的 consumer |
註:據 Wiz 等單位分析,早至 PHP 5.4 起的多個版本受其中至少一項問題影響。
受影響的執行環境包含 Apache mod_php(風險最高)、PHP-FPM、php-cgi、FrankenPHP 與 LiteSpeed。
官方支援分支的修補版本為 8.5.12、8.4.27、8.3.36 與 8.2.35。針對已結束支援的 PHP 7.4、8.0 與 8.1,PHP 開發團隊本次提供了最小原始碼 backport 修補檔;各大 Linux 發行版與 LTS 維護商亦陸續發布對應的安全套件。
技術細節
問題的根源:zend_bailout 與 longjmp
PHP 對每個請求都有 memory_limit 這道資源配額限制。在受影響的舊版實作中,配額耗盡本身並不代表 heap 元資料已損壞,但 Zend Engine 會呼叫 zend_bailout() 中斷目前執行流程。
在實作上,Zend Engine 使用 zend_try 巨集保存當前的跳躍環境 EG(bailout),並在發生致命錯誤時由 _zend_bailout() 呼叫 C 語言的 longjmp,跳回目前生效的 zend_try 邊界。
longjmp 的特性在於它直接恢復 CPU 暫存器與 stack pointer,完全不執行沿途 C 函式的收尾程式碼:沒有物件解構、沒有 refcount 遞減,也不會把進行到一半的資料結構復原。更關鍵的是,跳回 bailout 邊界後,依 SAPI 架構與失敗發生的階段,後續仍可能進入請求收尾與釋放(request shutdown)流程。此時的清理程式碼仍然假設內部所有結構都是完整且合法的狀態,因而埋下記憶體損壞的隱患。
以下示意未修補版本中一種典型的 OOM 失敗路徑;後續是否執行 request shutdown 或保留 worker 行程,會依不同 SAPI 架構而異:
官方在修補時重構了錯誤處理架構:將一般 OOM 轉為在虛擬機安全點(VM safe point)處理的 soft OOM;若記憶體用量嚴重超出安全門檻,則直接以 _exit() 終止行程(hard OOM),避免在不一致狀態下繼續運作。
CVE-2026-109618:OOM 時的未完整初始化狀態
第一個漏洞發生在內部資料結構的建立過程中。例如在解析 multipart 請求與註冊欄位時,HashTable 的 bucket 計數可能已經遞增,或是結構已被標記為使用中,隨後的鍵值或緩衝區記憶體配置卻遭遇 OOM。
此時 zend_bailout() 的跳躍略過了錯誤恢復邏輯。隨後在 request shutdown 階段,引擎巡訪該資料結構時,看到標記為使用中或依賴計數走訪 bucket,便將未經正確初始化的殘留記憶體視為有效物件嘗試釋放或存取,進而引發記憶體損壞。
CVE-2026-109619:跨請求殘留的全域狀態
第二個漏洞涉及跨請求的生命週期管理。在 mod_php 或 PHP-FPM 這類長壽命 worker 架構中,單一行程會依序處理大量請求。
若前一個請求因 zend_bailout 提前中斷,或是 shutdown 階段在清理完全域狀態後又由非致命診斷邏輯寫入狀態,指向該請求內部記憶體的指標(如 session 內部結構或錯誤回溯指標)就可能殘留在全域變數中。
當請求結束、per-request 記憶體池被釋放後,該全域指標便成為懸空指標(dangling pointer)。一旦下一個由同一個 worker 處理的請求在重新初始化該狀態前讀取它,便會觸發典型的 use-after-free。若攻擊者能在下一個請求中控制記憶體佈局並佔用該釋放區塊,就有機會影響後續的間接呼叫。
攻擊面:session upload progress 與請求前解析
為什麼這組漏洞能不需應用程式漏洞就觸發?公開分析指出,關鍵在於 session.upload_progress.enabled 在許多預設設定中為開啟狀態。當請求帶有相符的表單欄位(如 PHP_SESSION_UPLOAD_PROGRESS)時,PHP 核心便會在解析 multipart/form-data 時啟動進度追蹤。
這項解析工作發生在應用程式的 PHP 程式碼開始執行之前。
然而,不同 SAPI 的利用條件存在關鍵差異:
- 在
mod_php架構下,請求啟動階段(request startup)若因 OOM 失敗,同行程仍會繼續執行 request shutdown,直接使用半初始化的結構,因此即使請求目標是空白 PHP 檔案或完全不處理上傳的端點,依然可能受到波及。 - 在 PHP-FPM 架構下,若在 startup 階段發生錯誤,child worker 通常會直接結束;已驗證的 FPM 攻擊路徑通常需要請求已成功啟動、並在應用程式層的執行過程中耗盡記憶體。
此外,除了 session upload progress 之外,一般的多重欄位上傳在 HashTable 配置時若遭遇 OOM,亦存在類似的不完整狀態問題。
概念性的利用流程
依照公開的技術分析,一種概念性的利用流程大致如下(不同 SAPI 與目標環境的步驟有所差異):
- 透過特定記憶體洩漏或堆積排布探測行程位址,繞過 ASLR。
- 對目標伺服器發送精心構造的 multipart 請求(觸發 upload progress 或多欄位註冊)。
- 刻意使請求在特定資料結構尚未完成配置時超過
memory_limit,觸發zend_bailout。 - 針對後續請求進行堆積噴灑(heap spraying),將受控資料填入剛被釋放的懸空結構。
- 影響內部指標存取或函式指標(例如
HashTable的pDestructor),達成控制流程劫持並執行任意指令。
要穩定完成整條攻擊鏈需要高度精準的記憶體對齊與 offset 計算。但亞馬遜威脅情報團隊在報告中指出,在公開揭露前便已在 MadPot 蜜罐系統中觀察到針對該漏洞的利用嘗試。
為什麼應用程式碼難以阻擋
傳統 web 安全著重於應用程式層面的「輸入驗證、參數化查詢、避免危險函式」。然而這兩個漏洞深植於 C 語言編寫的執行引擎內部。部分攻擊路徑可在應用程式碼執行之前觸發;其他路徑則依賴應用程式執行期間的狀態累積與錯誤觸發,但兩者都發生在 runtime 內部,無法單靠撰寫乾淨的業務邏輯來規避。
WAF 規則雖能過濾已知的特徵 payload,但難以完全覆蓋各種變形編碼與記憶體壓力觸發手法。最根本的防禦手段,只有更新 PHP 執行期本身。
用程式碼看懂 setjmp 與 longjmp
文字描述「跳過清理」很抽象,不如直接看最小的 C 語言範例。以下為解說用之核心邏輯節錄,旨在呈現與 PHP2Shell 根因相同的控制流問題。完整的可編譯原始碼(包含 basic.c、volatile.c、leak.c、uaf.c 與 fixed.c)已直接附於本文目錄中,讀者可透過 GCC 與 AddressSanitizer 直接編譯、重現與檢驗。
運作原理
setjmp 與 longjmp 是 C 標準函式庫提供的非區域跳躍(non-local jump)機制,能在函式呼叫鏈中直接跨層跳回先前的儲存點。
setjmp(env):將當前 CPU 暫存器(以 x86-64 為例,包含 callee-saved 暫存器、stack pointer 與 instruction pointer)保存至jmp_buf。初次呼叫時回傳 0。longjmp(env, val):還原這些暫存器,使控制流程直接回到setjmp的位置。此時setjmp再次回傳,回傳值為val(若傳入 0 則回傳 1)。- 沿途跳過的 stack frames 不會執行任何清理邏輯。此外,它不會備份或還原 stack 與 heap 上的記憶體內容,亦不會撤銷任何已產生的副作用。
#include <setjmp.h>
#include <stdio.h>
static jmp_buf bailout_env;
static void deep(int depth) {
if (depth == 3) {
puts(" deep: fatal condition, jumping out");
longjmp(bailout_env, 1); /* never returns */
}
printf(" deep: depth=%d, entering\n", depth);
deep(depth + 1);
printf(" deep: depth=%d, cleanup runs\n", depth); /* skipped! */
}
int main(void) {
if (setjmp(bailout_env) == 0) { /* first return: 0 */
puts("run request");
deep(0);
puts("request finished normally");
} else { /* returns again: 1 */
puts("bailout: back at the top");
}
return 0;
}執行結果:
run request
deep: depth=0, entering
deep: depth=1, entering
deep: depth=2, entering
deep: fatal condition, jumping out
bailout: back at the top可以看到 cleanup runs 從未執行。這正對應了 zend_bailout() 的行為:遇到致命狀況跳回 bailout 邊界時,中間各層函式的收尾程式碼均被略過。
使用上的幾個陷阱
這組 API 存在數個與語言標準相關的未定義行為與平台細節:
區域變數的值不保證保留
依據 C11 標準(§7.13.2.1),在呼叫 setjmp 的函式內,若具有 automatic storage duration 的區域變數在 setjmp 與 longjmp 之間被修改,且未宣告為 volatile,則跳躍後其值為不確定值(indeterminate)。
此程式刻意讀取 indeterminate 的 automatic 變數,依 C11 標準(§7.13.2.1 與附錄 J.2)屬於未定義行為;以下在 GCC -O2 下的執行結果僅為特定編譯器與最佳化選項下的一次觀察,即使在同一工具鏈下亦不能依賴其一致性:
#include <setjmp.h>
#include <stdio.h>
int main(void) {
jmp_buf env;
int plain = 0;
volatile int safe = 0;
if (setjmp(env) == 0) {
plain = 1; safe = 1;
longjmp(env, 1);
}
printf("plain=%d safe=%d\n", plain, safe);
return 0;
}在 GCC -O2 下執行結果:
plain=0 safe=1呼叫 setjmp 的函式不能已經返回
若呼叫 setjmp 的函式已經 return,該 stack frame 便已消滅。此時對其 jmp_buf 呼叫 longjmp 為嚴重的未定義行為,極易導致 stack 損壞與崩潰。
C++ 非平凡 destructor 與未定義行為
依據 C++ 標準規範,若以 longjmp 跳過具有非平凡 destructor(non-trivial destructor)的 automatic 物件,而該物件原本在例外堆疊回溯(stack unwinding)時會被解構,則該跳躍具有未定義行為。RAII 機制在此完全無法提供安全保證。
signal mask 的保存
標準的 setjmp / longjmp 不保證保存與還原 signal mask。在 POSIX 環境中,若需在跳躍時保留信號遮罩,應改用 sigsetjmp(env, savemask)(savemask != 0)與 siglongjmp。
問題如何被引入:標記為使用中,卻還沒配置完
第一個典型錯誤對應 CVE-2026-109618 的「先標記、後配置」。此處示範的是狀態殘留(state leak),而非單純的記憶體洩漏:
typedef struct { int active; char *buf; } session_t;
static session_t *g_session; /* process-wide, survives requests */
static void handle_request(const char *name) {
g_session = tracked_alloc(sizeof *g_session);
g_session->active = 1; /* (1) marked active FIRST */
g_session->buf = NULL;
g_session->buf = tracked_alloc(64); /* (2) may longjmp away here */
strncpy(g_session->buf, name, 63);
g_session->buf[63] = '\0';
}若 tracked_alloc 在配額用盡時觸發 longjmp,且恰好在第 (2) 行失敗:
[req-A] start
bailout: request aborted
after request 0: g_session->active=1 buf=(nil)active 旗標已被設為 1,但 buf 仍為空指標。後續若有任何清理或存取邏輯看到 active == 1 便認定 buf 有效,就會觸發解參考異常。
問題如何被引入:跨請求殘留的懸空指標
第二個錯誤對應 CVE-2026-109619。下方示範使用 per-request arena 的模型,請求結束時整塊釋放,但全域指標 g_upload 參照了 arena 內的物件:
typedef struct { char *mem; size_t used, cap; } arena_t;
static arena_t arena; /* per-request memory pool */
static jmp_buf bailout_env;
typedef struct { void (*on_close)(const char *); char tag[24]; } upload_t;
static upload_t *g_upload; /* process-wide, lives across requests */
static void *arena_alloc(size_t n) {
/* Invariant: arena.used <= arena.cap */
if (n > arena.cap - arena.used) longjmp(bailout_env, 1); /* quota exceeded */
void *p = arena.mem + arena.used;
arena.used += n;
return p;
}
static void begin_request(size_t limit) {
if (g_upload) /* "flush the previous upload state" */
g_upload->on_close(g_upload->tag); /* (3) trusts g_upload blindly */
arena.mem = calloc(1, limit);
if (!arena.mem) longjmp(bailout_env, 1);
arena.cap = limit;
arena.used = 0;
}
static void parse_upload(size_t payload) {
g_upload = arena_alloc(sizeof *g_upload); /* (1) allocated from the arena */
g_upload->on_close = legit_close;
strncpy(g_upload->tag, "progress", sizeof(g_upload->tag) - 1);
g_upload->tag[sizeof(g_upload->tag) - 1] = '\0';
arena_alloc(payload); /* OOM here => longjmp */
}
static void finish_request(void) { /* normal path only */
g_upload->on_close(g_upload->tag);
g_upload = NULL; /* (2) reset ... only on this path */
}
static void run(const char *name, size_t limit, size_t payload) {
if (setjmp(bailout_env) == 0) {
begin_request(limit);
parse_upload(payload);
finish_request();
} else {
puts(" bailout: finish_request() skipped");
}
if (arena.mem) free(arena.mem); /* request shutdown frees the arena */
memset(&arena, 0, sizeof arena);
}正常情況下,finish_request() 會將 g_upload 重設為 NULL。然而在 OOM 時,longjmp 跳過了 finish_request()。以三個連續請求模擬(正常請求、觸發 OOM 的請求、後續正常請求),使用同目錄下完整提供的 uaf.c 並以 AddressSanitizer 編譯執行:
gcc -g -O0 -fsanitize=address -o uaf uaf.c && ./uaf輸出結果:
[request 1: normal]
close(progress)
[request 2: attacker exceeds the limit]
bailout: finish_request() skipped
[request 3: innocent victim]
=================================================================
ERROR: AddressSanitizer: heap-use-after-free on address 0x7546f95e0080
READ of size 8 at 0x7546f95e0080 thread T0
#0 in begin_request uaf.c:25
#1 in run uaf.c:48
#2 in main uaf.c:65其因果鏈展示了生命週期管理的失效:
- 請求 2 因
longjmp提前中斷,g_upload未被清空。 - 請求 2 的收尾釋放了 arena,
g_upload成為懸空指標。 - 請求 3 由同一個 worker 處理,
begin_request未經重新驗證即存取g_upload,觸發 heap-use-after-free。
在真實攻擊中,若攻擊者能在釋放後、存取前透過受控配置填入惡意位址,該函式指標呼叫便可能演變為控制流程劫持。
修正:單一出口與最後才發布
修正的核心原則在於:改變架構,讓所有路徑(無論正常或異常)共用單一收尾邏輯,並將全域狀態的發布延後至所有易失敗操作完成之後:
static void end_request(void) { /* the single exit, ALL paths */
g_upload = NULL; /* request-scoped globals reset here */
if (arena.mem) free(arena.mem);
memset(&arena, 0, sizeof arena);
}
static void parse_upload(size_t payload) {
upload_t *u = arena_alloc(sizeof *u); /* build a local first */
u->on_close = legit_close;
strncpy(u->tag, "progress", sizeof(u->tag) - 1);
u->tag[sizeof(u->tag) - 1] = '\0';
arena_alloc(payload); /* may bail out: g_upload untouched */
g_upload = u; /* publish LAST */
}
static void run(const char *name, size_t limit, size_t payload) {
if (setjmp(bailout_env) == 0) {
begin_request(limit);
parse_upload(payload);
g_upload->on_close(g_upload->tag);
} else {
puts(" bailout");
}
end_request(); /* runs whether we bailed out or not */
}使用同目錄下完整提供的 fixed.c,再次透過 AddressSanitizer 編譯驗證:
[request 1: normal]
close(progress)
[request 2: attacker exceeds the limit]
bailout
[request 3: innocent victim]
close(progress)測試乾淨通過無誤。這項範例具體落實了兩項原則:
- 最後才發布:在區域指標
u完成所有配置與初始化後,才賦值給全域變數g_upload。若中途失敗,全域狀態不受污染。 - 單一出口:無論正常執行或發生 bailout,
end_request均必定執行,確保跨請求狀態獲得一致清理。
這些原則在後面「如何避免在自己的程式裡重蹈覆轍」一節中亦會進一步推廣至通用架構。
發現的過程:AI 輔助研究與協調修補
此事件的發現歷程依據 PHP Foundation 與相關研究單位的時間線記錄如下:
- 2026 年 8 月 11 日,Calif 透過 AI 輔助分析通報了相關 OOM 錯誤路徑問題。
- 2026 年 8 月 24 日,Wiz 亦獨立發現了延伸的利用向量並通報 PHP 安全團隊。
- 2024 年其實已有靜態稽核指出 PHP 在 OOM 處理時存在非典型邏輯,但當時並未證明其危害。現代 AI 工具的長處,正在於能串接分散的狀態錯誤並輔助推導出端到端的驗證鏈。
- 10 月 20 日,相關漏洞資訊在公開管道提前流出,促使原訂 10 月 22 日發布的協調修補提前於 10 月 21 日正式公開。
- 在公開發布前,AWS 的 MadPot 蜜罐系統即已監測到早期的利用嘗試。
值得注意的是,現有公開研究足以證明 AI 能有效協助資安研究員分析程式碼與建構測試 PoC,但目前尚無公開證據顯示野外攻擊者已普遍採用 AI 自動化發動入侵。不過可以預見的是,這類工具的普及可能進一步壓縮漏洞修補的時間窗口。
給人類工程師的防禦實務
第一優先:更新 PHP
最有效的防禦是直接升級 PHP 執行環境至修補版本:
- PHP 8.5 請升至 8.5.12 以上
- PHP 8.4 請升至 8.4.27 以上
- PHP 8.3 請升至 8.3.36 以上
- PHP 8.2 請升至 8.2.35 以上
對於使用容器環境的團隊,僅更新應用程式碼不會改變底層映像。必須重新拉取基礎映像(base image)、重新建置並重新部署。
此外,在主機或容器內執行 php -v 僅能確認 CLI 執行檔版本;必須針對實際提供對外服務的 SAPI(例如 PHP-FPM pool、Apache mod_php)確認其二進位版本、啟用之擴充套件與載入的 php.ini 設定,並重啟對應的服務行程。
針對使用 PHP 7.4、8.0 與 8.1 等已 EOL 版本的系統,若短期內無法升級主版本,應優先套用官方提供的 backport 原始碼修補檔,或升級由 Linux 發行版(如 Ubuntu Pro、Debian LTS)維護的回補套件。
暫時緩解措施
若無法立即完成版本更新,可透過設定縮小曝險面:
; 關閉 session upload progress(除非應用程式明確需要)
session.upload_progress.enabled = 0
; 關閉致命錯誤回溯(僅適用於 PHP 8.5 以上版本)
fatal_error_backtraces = 0針對這兩項設定需注意以下要點:
- 必須在
php.ini或 SAPI pool 設定檔中配置並重啟 worker,確保在任何請求處理前即已生效;無法依賴應用程式執行期的ini_set()。 fatal_error_backtraces僅存在於 PHP 8.5 及更新版本,舊版無此設定項。session.upload_progress.enabled = 0僅關閉了其中一個常見入口,若應用程式接受其他大型 multipart 上傳,底層HashTable配置仍可能因記憶體耗盡而觸發類似問題。- 官方針對 CVE-2026-109618 的另一項緩解建議是將 PHP
memory_limit設為-1。這能移除 PHP 配額耗盡這個可被精確計算的觸發點,但底層系統配置(如mmap)若失敗仍可能觸發 bailout。此外,應以管理層設定強制套用並重啟 worker,確認沒有個別設定恢復有限額度;若轉由作業系統層級管控記憶體,應使用 cgroup 記憶體上限,避免使用LimitAS=或LimitDATA=(對應RLIMIT_AS/RLIMIT_DATA),因為後者會導致系統呼叫配置直接失敗,反而再次落入脆弱路徑。但需注意 cgroup 記憶體限制可能導致 worker,甚至同群組中的其他行程被系統強制終止,需評估請求中斷與服務可用性;這仍不能取代版本修補。
縱深防禦
除了執行環境修補,建議在基礎架構層落實以下防禦:
- 縮短 worker 壽命:在 PHP-FPM 中將
pm.max_requests設為 1,可切斷同一 child worker 接續處理下一個請求的利用路徑(但無法防禦單一請求內的狀態損壞,且會帶來顯著的行程建立開銷);一般情況下設定較小的數值,能有效縮短殘留狀態的存活窗口。 - 淘汰
mod_php:避免使用與 Apache 共用行程空間的mod_php,改採獨立行程架構的 PHP-FPM,並按站台配置隔離的 pool 與執行帳號。 - 系統層級權限限縮:透過 seccomp 過濾系統呼叫、以 AppArmor 或 SELinux 限制存取路徑,並透過容器隔離命名空間,同時於網路層配置嚴格的 egress 規則。
- 請求大小與結構限制:即使請求未宣告
Content-Length(如 chunked 傳輸),反向代理與伺服器端仍須限制實際接收與解碼後的請求總量,並針對 multipart 中的 part 數量、內部 header 長度、欄位總數與巢狀深度設定上限(例如 Nginx 的client_max_body_size僅能限制整體 body 大小,仍需搭配內部欄位數與解析限制)。 - 異常指標監控:監控
Allowed memory size exhausted錯誤頻率突增、worker 異常崩潰(segfault)次數,以及 PHP 行程是否有衍生非預期子行程(如/bin/sh、curl)的行為。 - 全面清點資產:清查內部所有正在運行的 PHP 服務,包含邊緣系統與內部管理工具,避免因遺漏舊版環境而產生防禦缺口。
給 AI agent 的防禦實務
在本次事件中,AI agent 展現了不同面向的應用,以下針對三種角色分別探討防禦要點:
維運與開發助手
當 agent 負責協助伺服器管理、相依性更新或部署作業時:
- 要求驗證實際服務環境:指示 agent 檢視實際運作中的 SAPI 與設定輸出(如檢驗當前 FPM pool 或檢查套件修訂號),而非單純讀取靜態的
Dockerfile或composer.json即判定修補完成。 - 先提出評估再請求核准:要求 agent 於執行更新前列出所有受影響主機、容器名稱及當前版本,並在重大變更前維持人類確認機制。
- 查證設定項版本相容性:避免 agent 盲目將新版設定(如
fatal_error_backtraces)套用至不支援的舊版本,應要求其以實際運作中的 SAPI 與 pool 設定確認有效性,CLI 輸出僅作為輔助參考。 - 最小權限憑證原則:限制 agent 取得的 API token 與 SSH 金鑰權限範圍與存續時間,降低憑證外洩衝擊。
- 將外部資安資訊視為不可信輸入:資安分析、PoC 腳本或技術討論可能隱含針對 agent 的 prompt injection。指派任務時應明訂「只分析、不執行」原則,禁止 agent 直接在非受控環境嘗試執行 PoC 程式碼。
程式碼審查者
當運用 AI 進行安全性原始碼審查時:
- 聚焦錯誤處理與跨請求狀態:指示 agent 特別審視記憶體配置失敗路徑、bailout 或例外發生時的清理邏輯,以及全域或靜態變數在請求結束時是否被徹底重設。
- 要求產出可重現的檢驗資產:要求 agent 針對疑點提供最小可重現測試、fault injection 腳本或 fuzzing harness,以客觀驗證取代純文字推論。
- 隔離驗證環境:所有涉及潛在脆弱性驗證的動作,皆應於拋棄式沙箱環境內執行。
- 人類專業複核:保持審查紀律,agent 的報告作為假設參考,由資深工程師確認問題真實性與修補方案。
防禦保護目標
若 AI agent 系統本身之後台、排程或外圍服務採用 PHP 建置,該服務若遭入侵亦可能波及整體 agent 平台。應落實網路分區與微隔離架構,將 agent 核心模型服務、憑證儲存庫與 web 前端徹底切隔,降低橫向移動的機會與影響範圍。
如何避免在自己的程式裡重蹈覆轍
從 PHP2Shell 的根本成因出發,若在開發自研 runtime、框架或長壽命服務,可借鏡以下工程原則。
設計層:不要讓提早離開破壞不變量
- 非區域跳躍前維持狀態一致:若架構中使用
longjmp、例外或panic,務必遵守「兩階段提交」原則。先在區域變數完成所有記憶體配置與欄位初始化,確認無失敗可能後,最後才發布至全域狀態或標記為有效。 - 善用結構化資源管理:優先採用具語言層面保障的機制(如 C++ RAII 在作用域結束時解構、Rust 的
Drop、Go 在函式返回時執行的defer)。若使用 Rust 需留意panic=abort不會進行 stack unwinding;使用 Go 則需注意os.Exit會略過defer。 - 區域配置器(arena)隔離:為單一請求配置專屬 arena,請求結束時整塊釋放。但必須嚴格禁止 request-owned 指標逸出至長壽命全域結構中;若確有暫存需要,必須在釋放 arena 前主動撤銷所有外部參照。
- 明確界定全域狀態生命週期:嚴格列管所有全域與靜態變數,明確規範在請求異常終止時的重設機制,並納入常態審查清單。
驗證層:專門測試錯誤路徑
- 針對配置器進行 fault injection:在測試套件中模擬記憶體配置失敗,遍歷各配置點,檢驗在提早中斷時系統狀態是否依然一致。
- 針對協定解析器進行 fuzzing:對所有在身分驗證與核心業務邏輯前執行的協定解析器、上傳剖析器進行專項 fuzzing,並於嚴格的記憶體配額限制下測試邊界行為。
- 整合 sanitizer 工具鏈:於 CI 環境常態啟用 AddressSanitizer(偵測 use-after-free、buffer overflow 與 double free)、UndefinedBehaviorSanitizer(偵測各類未定義行為)與 MemorySanitizer(追蹤未初始化記憶體讀取)。需注意 ASan 與 MSan 記憶體模型互斥,應拆分至不同的 CI 建置任務中執行。
- 請求邊界狀態對比:在整合測試中,於每個請求結束後自動檢查全域狀態是否復歸初始值,及早攔截狀態殘留問題。
語言與架構層:縮小爆炸半徑
- 高風險解析模組優先採用記憶體安全語言:處理不可信輸入的模組(如協定與上傳剖析)優先以 Rust 或 Go 開發,降低記憶體管理失誤機率。
- 啟用二進位強化編譯旗標:全面開啟 PIE、full RELRO、stack canary、
_FORTIFY_SOURCE與控制流完整性(CFI)防護,提高漏洞利用門檻。 - 權限分離架構:負責解析外部複雜輸入的處理程序應獨立為低權限行程,避免與敏感憑證或核心業務資料庫共用行程權限。
流程層:提升修補反應速度
| 面向 | 實務做法 |
|---|---|
| 資產清點 | 維護完整的軟體物料清單(SBOM),精準掌握各服務使用的 runtime 與其詳細修訂版本 |
| 部署演練 | 建立標準化的緊急升級作業流程,包含映像檔重建、逐步部署與快速回復舊版本機制 |
| 生命週期追蹤 | 定期檢視所有相依元件的 EOL 期限,及早規畫主版本升級路線 |
| 情資追蹤 | 訂閱 CISA KEV 與相關上游安全性公告,將已遭野外利用之威脅列為最高修補等級 |
把 AI 放進稽核流程,但維持驗證紀律
AI agent 可用來補足傳統靜態分析的盲點:
- 專責審核例外路徑與狀態清理:讓 agent 聚焦於極端錯誤處理與資源回收流程,並要求提供可重現之測試程式碼。
- 沙箱安全邊界:所有涉及安全性驗證之動作一律於隔離沙箱執行。
- 提高稽核頻率:將 AI 輔助分析整合至重要變更與版本發布流程中,常態性掃描潛在狀態管理問題。
小結
PHP2Shell 這組漏洞給我們幾個實際的借鏡:
longjmp這一類非區域跳躍機制本身很常見,但如果在跳躍時沒有把內部狀態復原或安全隔離,在長壽命 worker 運行環境中就容易破壞資料一致性,累積成高危漏洞。- 預設啟用的功能特性(如 session upload progress)往往會在不知不覺中擴大攻擊面,不用的功能主動關閉,始終是穩健的維運習慣。
- 執行引擎層級的弱點無法透過應用程式碼完全防堵,保持執行環境與底層函式庫即時更新,永遠是資安防禦的第一線。
- AI 工具普及加速了漏洞挖掘與利用鏈合成的節奏,防禦方也該建立自動化測試與嚴謹的驗證流程,讓安全修補能跑在潛在威脅之前。
若目前維運之環境中仍有運行 PHP 服務,建議立即核對實際提供服務之 runtime 版本與設定狀態,確認已落實修補或相應的緩解措施。