在深夜的伺服器機房裡,工程師面前立著一面光之盾,擋下紅色攻擊,伺服器則以綠燈持續運作

2026 年 10 月 21 日 21 點 55 分,高市首相在 X 上發了這則貼文。

若難以迅速套用修補程式,例如也可暫時停掉網站等,作為降低風險的有效措施之一。
(高市早苗 on X)

這是兩個可任意執行程式碼的 PHP 漏洞,通稱 php2shell(CVE-2026-109618 / CVE-2026-109619,CVSS v4.0 9.2)的那一夜。

那天晚上,我負責維運的某家線上服務業者 A 公司旗下多個網站,都是跑在 PHP 7.4 上。這是沒有官方修正版釋出的版本。我們正是那種「難以迅速套用修補程式」的一方。

即便如此,我們沒有停站,而是靠 WAF 與 PHP 設定把漏洞補起來。寫成這樣很像很厲害,但實際上做的只有兩件事:「在入口擋掉 multipart」以及「在 ini 裡加一行」。補完之後,真實攻擊就真的打到日誌裡來了。這篇文章按時間順序記錄那一夜的判斷、處置方式,以及途中踩到的坑。

我會寫這篇文章,是因為那一夜其實有點好玩。也許這聽起來不太莊重,但 X 和聊天群裡,不斷有人分享:目前已經確認到哪裡、該在哪裡停手、哪些暫時性對策有效。邊看邊動手,會有一種和不認識的人一起撐過同一個夜晚的感覺。既然當時收到了很多資訊,我也想把我們那一夜發生的事情分享出來。

受保密義務限制,文中的公司名、服務名、網域、IP 位址、規模等都已隱去。文中的網域已替換為 example.com。架構與設定皆以當晚當下為準,之後的處置狀況不在本文範圍內。

首先,放著不管會發生什麼事

在講那一夜之前,先說明這個漏洞到底允許了什麼。不了解這點,後面的應對看起來可能會覺得太大費周章。以下是根據公開分析整理的內容。

攻擊者直接能拿到的有兩項:

可得內容 內容 出處
任意程式碼執行 不需登入,就能以執行 PHP 的使用者權限執行命令 php.net、Sansec
記憶體內容 類似 Heartbleed,其他使用者請求中的密碼、Session 權杖、API 金鑰有可能外洩 Sansec

第二項尤其讓人不寒而慄。伺服器上可能什麼都沒被寫入,入侵痕跡也不明顯,但使用者的登入資訊卻可能已經被偷走了。

再往下,就是一般在 Web 伺服器被執行程式碼時會發生的事。以下並不是這次實際觀測到的攻擊行為,而是通常會出現的風險。

  • 資料外洩:應用程式為了連接資料庫,會持有連線資訊。由於 PHP 權限可讀,所以可把使用者資料整批帶走
  • 雲端橫向擴張:如果在 EC2 上,攻擊者可能從 instance metadata 取得綁定在執行個體上的 role 憑證。那個 role 能做的事,攻擊者也能做
  • 長期駐留:若被塞入 Web Shell 或 cron,即使之後更新 PHP,攻擊者也不會自己消失。修補漏洞,和清理入侵痕跡,是兩件不同的工作
  • 對使用者造成傷害:如果頁面被竄改,受害範圍就不只是自家伺服器,而會擴散到使用者端

一個 POST 就能打開這些入口。實際上,當晚我的負責站點也真的收到了那個 POST(後半段會提)。

那一夜停止的服務

做出停站決定的大型服務也不少。以下是報導中可確認的例子(時間以報導當下為準,之後可能有變更)。

服務 停止服務時間帶(報導時點) 出處
BASE 10/21 15:50 ~ 10/22 7:00 GIGAZINE
Melonbooks 10/21 17:30 ~ 10/22 10:00 同上
NicoNico KADOKAWA 10/21 19:00 ~ 未定 同上
BANDAI CHANNEL BANDAI 10/21 20:00 ~ 未定 同上
pixiv pixiv ~ 10/22 早晨 同上
BOOK☆WALKER BOOK☆WALKER ~ 10/22 早晨 同上
Uma Musume Pretty Derby Cygames 10/21 22:00 左右 ~(提前自 23 點) 同上
Granblue Fantasy Cygames 10/21 22:00 左右 ~(提前自 23 點) 同上
Shadowverse: Worlds Beyond Cygames 10/21 23:00 左右 ~ 未定 Fami通
Pokémon Sleep 任天堂 10/21 23:00 ~ 未定 GIGAZINE
Heaven Burns Red WFS 10/21 22:30 之後 Game Spark(Yahoo!新聞)
makeshop / カラーミーショップ 各公司 10/21 晚間 ~ 10/22 日本網路經濟新聞(Yahoo!新聞)

遊戲、電商、影音、電子書,完全不分業種,全都停了。從上面那麼寬的攻擊入口來看,為了避免損失營收而決定停站,可說是相當沉重的判斷。像 BASE 這樣,甚至沒有等到 23 點修正版釋出,就在傍晚先停下來了。

停站當然是一種正確選擇。不過一旦停站,營收與使用者信任都會受損。A 公司的網站選擇的是「不關站也要守住」。

整體流程

夜晚開始:要守住的是什麼環境

項目 內容
網站 多個服務(使用相同樣板建立的架構)
架構 AWS ALB → EC2(nginx + php-fpm),各網站各自以 ASG 管理多台主機
PHP 7.4 系列。透過第三方 repository 安裝,官方支援已於 2022 年結束
管理畫面 以同一台伺服器上的另一個主機名稱運作。已在 nginx 以來源 IP 限制
WAF 沒有

最麻煩的是 PHP 7.4。根據 php.net 的公告,可直接取得修正版的只有 8.2~8.5。7.4 / 8.0 / 8.1 只有最小限度的原始碼修補(GitHub 分支)。也就是說,透過套件安裝的 PHP,無法直接靠「更新套件就結束」。

等待公告時:不要只看版本號

一開始,我們先把影響判定軸定成 3 個:

  1. 是否屬於受影響版本
  2. 是否存在可成立的攻擊條件(設定 / 路徑)
  3. 是否能從網際網路進來

調查時使用 AWS Systems Manager(SSM)的 Run Command,並且只在全機台執行唯讀指令。由於有些非 Web 用途的伺服器也可能裝了 PHP,所以我們不以伺服器名稱排除,而是全部檢查。

# 在所有主機上執行的部分確認命令(唯讀)
php -v | head -1
php -i | grep -E '^session\.upload_progress\.enabled'
ss -ltnp | grep php-fpm   # php-fpm 實際在哪裡聽候連線

結果如下。

對象 判定 理由
Web 伺服器上的 PHP 有影響・高風險 全部都是 7.4。且與攻擊有關的 session.upload_progress.enabled 預設為 On
同一天被報導的 nginx 漏洞(PoolSlip) 低風險 沒有會觸發的 rewrite 寫法
非 Web 伺服器上的 php-fpm 外部不可達 只在 127.0.0.1 上聽候連線,也沒有 nginx 轉送的路徑

設計 WAF:在擋掉之前,先確認會壞掉什麼

根據報導,攻擊使用的是 multipart/form-data(檔案上傳格式)的請求。因此我們考慮做一個「在入口擋掉 multipart」的 WAF。

不過如果擋得太粗暴,正常功能也會壞掉。在提出方案前,我們從兩個方向確認影響範圍。

  • 原始碼:盤點送出端(前端 FormData、原生 App 的表單送出等)與接收端($_FILES 等)
    • 使用 multipart 的只有管理畫面的圖片與 CSV 匯入(十幾條流程)
    • 找到了共用的通訊處理,因此可很快確認判斷方式
    • 原生 App 傳的是原始位元組,Web 的圖示註冊則是以 base64 字串搭配一般表單格式送出
  • Access Log:按子網域彙總 POST 的送往路徑,與原始碼調查結果交叉比對

結論是:「只要封鎖送到非管理畫面主機的 multipart,對使用者沒有影響」。

除外條件,要以「會被繞過」為前提來做

「Host 是管理畫面就排除」這種條件,只要偽造 Host 標頭就能繞過。因此我們進一步用兩層檢查,確認如果真的偽造,會發生什麼事。

  • 管理畫面中送到 PHP 的 API 路徑位於 nginx 的 IP 限制(allow + deny all)內側。我們也確認了巢狀 location
  • 來源 IP 是根據 ALB 加上的 X-Forwarded-For 值來判斷(不相信客戶端自己寫入的值),因此 IP 偽造也無效

另外,我們把 Web ACL 依各個 ALB 分開。如果用同一份 ACL 來排除所有站點的管理畫面,只要對別的網站 ALB 送出「其他站點管理畫面的 Host」,就能繞過排除。繞過後打到的,就是該 ALB 的 default server(PHP)。

把規則 1 的概念寫成 JSON,大致如下(網域為示意):

{
  "Name": "block-multipart-except-admin",
  "Priority": 0,
  "Action": { "Block": {} },
  "Statement": {
    "AndStatement": {
      "Statements": [
        {
          "ByteMatchStatement": {
            "SearchString": "multipart",
            "FieldToMatch": { "SingleHeader": { "Name": "content-type" } },
            "TextTransformations": [{ "Priority": 0, "Type": "LOWERCASE" }],
            "PositionalConstraint": "CONTAINS"
          }
        },
        {
          "NotStatement": {
            "Statement": {
              "ByteMatchStatement": {
                "SearchString": "admin.site-a.example.com",
                "FieldToMatch": { "SingleHeader": { "Name": "host" } },
                "TextTransformations": [{ "Priority": 0, "Type": "LOWERCASE" }],
                "PositionalConstraint": "EXACTLY"
              }
            }
          }
        }
      ]
    }
  },
  "VisibilityConfig": {
    "SampledRequestsEnabled": true,
    "CloudWatchMetricsEnabled": true,
    "MetricName": "block-multipart-except-admin"
  }
}

此外,我們也先把 AWS 托管規則(CommonRuleSet / KnownBadInputs)以 Count(僅記錄) 模式加入。日誌送到 CloudWatch Logs,只保留 Count 或 Block 的項目 30 天。

一開始先把所有規則都設為 Count,確認沒有誤判後,再切換成 Block。

23:00 官方公告:7.4 沒有「完整的暫時解法」

23 點時,PHP 官方公告發布。確定可用的暫時對策是 session.upload_progress.enabled=0。不過公告也明確寫著:「沒有完整的暫時解法,更新是唯一安全的選擇。」對於不在修正版範圍內的 7.4 來說,只能疊加暫時對策。

我們沒有直接動 php.ini 本體,而是額外加了一個檔案。如此一來,回復也很簡單:刪掉這個檔案後 reload 即可。

; /etc/php.d/99-php2shell-mitigation.ini
; CVE-2026-109618 / CVE-2026-109619 的暫時對策
session.upload_progress.enabled = 0

reload 時不要產生 502

套用設定需要 reload php-fpm。但這個環境的 process_control_timeout 仍是預設值 0。這代表 reload 的瞬間,正在處理中的請求會變成 502。

因此我們採用一次從 ALB 上移除一台、套用、再加回去的方式。

單台的操作流程如下:

TG_ARN="arn:aws:elasticloadbalancing:...:targetgroup/site-a/..."
ID="i-0123456789abcdef0"

aws elbv2 deregister-targets --target-group-arn "$TG_ARN" --targets Id="$ID"
aws elbv2 wait target-deregistered --target-group-arn "$TG_ARN" --targets Id="$ID"

# 在該主機上(透過 SSM Run Command)
#   cp 99-php2shell-mitigation.ini /etc/php.d/
#   systemctl reload php-fpm
#   php-fpm -i | grep session.upload_progress.enabled   # => Off

aws elbv2 register-targets --target-group-arn "$TG_ARN" --targets Id="$ID"
aws elbv2 wait target-in-service --target-group-arn "$TG_ARN" --targets Id="$ID"

處理期間,我們每秒對網站送出一次請求,結果全部都維持 200。本番與測試環境加起來十幾台,約 10 分鐘內完成。

23:37 第一波攻擊:擋下來的不是 WAF

WAF 以 Count 模式啟用後,日誌立刻開始出現以下紀錄。

時間 來源 內容 結果
23:37 英國的 VPS 對 https://example.com/(apex 網域)送出 multipart 的 POST。大小分別為 8,828 B 與 25,212 B WAF 尚在 Count 中。被 ALB 的「apex → www」301 重新導向擋住
23:37 同上 跟隨 301 後對 https://www.example.com/ 發出 GET(沒有本文) 因為是 GET,所以攻擊資料不會被帶過去

第一波攻擊被擋住的,其實是偶然啟用的 ALB 重新導向規則。攻擊工具盯的是 apex 網域,而且在跟隨 301 時把 POST 改成了 GET,所以攻擊資料沒有到 PHP。

抱著紅色包裹的機器人,沿著箭頭指示繞路,結果把包裹掉在路上

原本不是為了防禦而加上的那條 301,反而成了當晚的第一道防線。當然,這只是運氣好;如果對方直接打 www,Count 模式的 WAF 就會被直接穿過去。所以我們立刻把它改成 Block。

切換成 Block 後,反而什麼都沒擋住

從 Count 切到 Block 時,我們只把 get-web-acl 取出的 Rules 裡的 Action 改掉,再原封不動地送回 update-web-acl。雖然沒有報錯,但測試請求卻沒有變成 403。

測試請求是送一個只有 Content-Type 是 multipart、但本文為空的 GET 到靜態檔案。PHP 不會把 GET 的本文當成 multipart 解析,因此即使真的打到正式環境也無害。

curl -s -o /dev/null -w '%{http_code}\n' \
  -H 'Content-Type: multipart/form-data; boundary=x' \
  https://www.example.com/robots.txt
# 期望值: 403  實際: 200

原因在於字串處理方式:

  • get-web-acl 回傳的 SearchString 是 base64
  • 如果搭配 --cli-binary-format raw-in-base64-out 再送回去,實際上會把base64 字串本身當成搜尋字串註冊
  • 結果規則不是在找 multipart,而是在找 bXVsdGlwYXJ0

    確認已註冊的搜尋字串,並做解碼

    aws wafv2 get-web-acl --scope REGIONAL --name site-a-acl --id "$ACL_ID" \
    | jq -r '.WebACL.Rules[0].Statement.AndStatement.Statements[0]
    .ByteMatchStatement.SearchString' \
    | base64 -d

    正確時會是 multipart;如果又包了一層,就會變成 bXVsdGlwYXJ0

那段時間,WAF 還很認真地等著 bXVsdGlwYXJ0 這個 Content-Type 出現,但這種請求當然不會來。

後來我直接用手上原始的明文定義重建,並且同時確認解碼結果與 curl 的 403。那一刻真的體會到:沒有出錯卻沒有生效,才是最可怕的。

23:51 起,5 個 IP 從不同地方送來同一種攻擊

切到 Block 之後,同樣兩種大小的 POST 接連出現,而且全部都被 Block 了。

時間 來源 內容 結果
23:51~ 法國、美國、德國、波蘭的 VPS(5 個 IP) 8,828 B 與 25,212 B 的 multipart POST WAF Block
  • User-Agent 全部都是 Python aiohttp。攻擊端看起來也在用非同步 I/O 提升效率
  • 同一組工具以相同大小的資料,從多個國家 IP 依序送來。看起來像是從多個據點同步掃描
  • 其中一個來源,在數小時前只送過一筆 GET /。應該是先探路

WAF 日誌不會留下送出的資料內容,所以這裡只能寫成「疑似是針對本次漏洞的流量」。

確認是否已經被入侵

接著,我們用唯讀方式檢查伺服器端有沒有受害。

確認項目 為什麼要看 結果
php-fpm worker 異常結束(例如 signal 11) 記憶體破壞型攻擊常見 10 月只有月初 1 筆
記憶體上限、執行逾時錯誤 另一個 CVE 的發動路徑 10 月為 0 筆
php-fpm 底下的子程序是否有 shell 被啟動 是否有落地 沒有
最近變更的 PHP 檔案與執行檔 是否有 Web Shell 只有我們自己的發版變更
cron 變更、對外連線 是否有駐留與外連 沒有

在檢查範圍內,沒有看到入侵或竄改痕跡。

前面提到的「一個 POST 就能打開的入口」,第一個入口就是被 WAF 擋住了。如果沒有 WAF,這 5 個 IP 的 POST 就會直接送進 php-fpm。

這裡又踩到一個坑。要按日統計 php-fpm log 時,如果把 21-Oct-2026 這種日期字串直接排序,月份順序會亂掉。用 tail 截取時,甚至可能把 10 月的內容切掉。

# NG:日期會以字串順序排序,tail 之後可能把 10 月的列丟掉
grep 'signal 11' /var/log/php-fpm/error.log | awk '{print $1}' | tr -d '[' \
  | sort | uniq -c | tail

# OK:先限定想看的月份,再統計
grep 'signal 11' /var/log/php-fpm/error.log | grep 'Oct-2026' \
  | awk '{print $1}' | tr -d '[' | sort | uniq -c

在提交報告前,我們有即時發現並重新統計。

日子轉換時:Remi 已經釋出 7.4 更新

許多用 RPM 安裝 PHP 7.4 的環境,都是使用 Remi 的 repository(rpms.remirepo.net)。php.net 的公告曾這樣寫:

If you rely on the volunteer-maintained rpms.remirepo.net, we urge you to switch to distribution packages or build from source, so you can immediately apply updates. The repo will not have updates today.
(PHP 2026 News Archive)

原本應該是「今天不會有更新」。但在日子轉換後直接查看 repository 時,發現 7.4.33-31 已於 10/21 上架。看來志工維護者那邊的「今天」,比公告寫得還長了一些。RPM 的變更紀錄中也明確寫到了這次 CVE。

2026-10-21 Remi Collet <[email protected]> - 7.4.33-31
- Mitigation fix Avoid state corruption via bailout
  CVE-2026-109618, CVE-2026-109619
位置 套件 版本
EL8 / EL9 / EL10 的 php74 模組 php-* 7.4.33-31
EL8 / EL9 的 safe(SCL 形式) php74-php-* 7.4.33-31

當時 Remi 的部落格還沒發公告文。若只等公告,很可能就沒注意到。若你也是用 Remi,比起看部落格,直接看 repository 和變更紀錄會更快。

# 更新(模組形式;若是 SCL 則用 'php74-php*')
sudo dnf update 'php*'

# 用變更紀錄確認是否已套用
rpm -q --changelog php-common | head -5
# 如果出現 CVE-2026-109618, CVE-2026-109619,就代表已更新

要注意兩點:

  • 變更紀錄寫的是「Mitigation fix(緩解修正)」。僅憑這句話,無法判斷是否與原本 8.x 的正式修正完全相同
  • 若使用 ASG 管理,手動更新只會套用到當下這台,下一台新起的主機不會自動跟上。連起始映像(AMI)也要一起修,夜裡加的 ini 也是如此

再兩個坑

一開始就把托管規則設為 Block,應用程式會停掉

CommonRuleSet 的 NoUserAgent_Header,和原生 App 以及 WebSocket 的正常通訊大量重疊。因為它們都不會帶 User-Agent。SizeRestrictions_Body 則與管理畫面的 CSV 匯入相符。

如果當初是「直接把托管規則設成 Block」,整個應用程式很可能都會停掉。幸好我們先用 Count 觀察日誌,才避開這個坑。

CloudWatch 指標的 Rule 維度不是規則名稱

在用 WAF 的 BlockedRequests 搭配 Rule 維度查詢時,指定的不是規則名稱,而是 VisibilityConfig.MetricName。如果傳規則名稱,查出來會是 0 件。這會讓人誤以為「根本沒有 Block」。

翌日之後:把監視做成 Skill

為了避免「今夜做完就算了」,我把 WAF 日誌的定期檢查做成 Claude Code 的 Skill。採唯讀方式檢查以下項目:

  • 設定是否仍然正確:規則是否照預期運作、把搜尋字串解碼後是否仍是 multipart(用來偵測又發生二重 base64)
  • 規則 1 的 Block 數量
  • 被 Block 的流量來源(IP、國家、User-Agent、大小)
  • 托管規則的偵測結果(排除正常應用流量的誤判)

第一次執行,就找到了上面提到的「5 個 IP 的 Block」。

總結:如果遇到同樣的夜晚,請用這份檢查清單

  • 影響不要只看版本號,要從「版本」「成立條件」「能不能從外部打進來」三個面向判斷
  • 調查時只用唯讀指令,而且不要因為伺服器名稱就排除,所有主機都要看
  • 在入口擋下來之前,先從原始碼和 access log 兩邊確認會壞掉什麼
  • 除外條件要以 Host 偽造、跨 ALB 偽造也能成立的情境來設計(ACL 要按 ALB 分開)
  • WAF 先用 Count,觀察日誌後再切換成 Block
  • 切換後要用 curl 確認真的回 403,並檢查搜尋字串解碼結果
  • reload php-fpm 時,從 ALB 一台一台移出來處理,並持續送驗證請求
  • 確認是否被入侵時,要看異常結束、子程序、變更檔案、cron、外連
  • 如果你用的是 Remi,不要只看部落格,要直接看 repository 和 rpm -q --changelog
  • 在 ASG 環境中,記得把變更一路反映到 AMI / 啟動範本,才算完成

如果你暫時無法立刻升版,kenimoto.dev 已整理出 php.net 官方提供的 7 種暫時對策,以及各自無法防住的範圍。本文使用的 ini 與 WAF 之外的方式(memory_limit、post_max_size、cgroup 上限等)也有收錄。

如首相貼文所說,停站也是有效措施之一。不過,就算不停站,也有方法可以守住。WAF 和 ini 都只是緩解措施,根本對策仍是升到 8.x(雖然這件事從 2022 年支援結束後就一直被反覆提起)。如果你也是用 7.4 撐過這次,下一個夜晚來臨前,還能推進到什麼程度呢?

用投影片回顧

我把這一夜的流程整理成 15 張投影片了,歡迎拿去和團隊分享。

php2shell 的那一夜,不停機守住 PHP 7.4 正式環境 —— WAF、1 行 ini 與真實攻擊 by 井本賢

最後讓我順便宣傳一下。要在入口擋下什麼、要如何確認沒有被入侵。當 AI agent 被放進正式環境時,同樣的問題也會出現。MCP 伺服器的驗證設計,以及基於 OWASP MCP Top 10 的攻擊與防禦,我已整理在書籍《MCP 實戰資安 AI 代理時代的 Model Context Protocol 安全營運指南》(インプレス)中。
Kindle 版 / 平裝版

參考資料


原文出處:https://qiita.com/kenimo49/items/30f290bf0f4fff021fb3


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝9   💬1  
312
🥈
mike
1
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
📢 贊助商廣告 · 我要刊登