Nâng cấp Laravel từ PHP 7.x lên PHP 8.4.27 vì PHP2Shell — runbook đã chạy thật

Ngày 21/10/2026 PHP phát hành bản vá khẩn cho PHP2Shell — hai lỗ hổng CVE-2026-109618 và CVE-2026-109619, RCE (remote code execution — chạy lệnh từ xa) không cần đăng nhập, CVSS v4 9.2, dính mọi SAPI phục vụ web (PHP-FPM, mod_php, php-cgi, FrankenPHP, LiteSpeed). Bản sửa chỉ có ở 8.5.12, 8.4.27, 8.3.36, 8.2.35. Ai còn chạy Laravel 8 trên PHP 7.4 — rất nhiều hệ thống nội bộ, admin, CMS cũ — đứng trước câu hỏi: "vá tạm cho qua đêm nay, hay nâng cấp hẳn?"

Bài này là runbook đầy đủ cho stack Laravel + Ubuntu + nginx + PHP-FPM, đích là PHP 8.4.27. Mọi lệnh và output trong bài đều chạy thật trong lab Docker (một "server cũ" Ubuntu 22.04 + nginx + PHP 7.4-FPM + Laravel 8, rồi nâng cấp ngay trên đó). Chỗ nào không chạy được thì ghi rõ chưa verify.

Nguồn kiểm chứng và môi trường. Thông tin lỗ hổng lấy từ thông báo của php.net và hai advisory GHSA-w8hw-w9fx-j79w, GHSA-6wmf-4rv5-p3rp. Bảng hỗ trợ Laravel lấy qua Context7 (laravel/docs 13.x) và laravel.com/docs/12.x/releases; upgrade guide 9→13 lấy qua Context7 + laravel.com; Rector lấy qua Context7 (rectorphp/rector, driftingly/rector-laravel); PHPCompatibility qua Context7 (phpcompatibility/phpcompatibility). Lab: Docker 29.3.1; image ubuntu:20.04/22.04/24.04; PPA ondrej/php (PHP 7.4.33-31, 8.4.27-1); nginx 1.18.0; Composer 2.10.3; Laravel 8.83.29 → 13.38.0; official image php:8.4.26-fpm / php:8.4.27-fpm để so sánh. Script + log nằm ở labs/security/php84-upgrade/ (phụ lục cuối bài). Giới hạn lab: container không có systemd nên systemctl reload được thay bằng kill -USR2 <master> — đúng lệnh mà unit file của Ubuntu chạy (mục 3.3). Lab không chạy exploit thật; không test Apache mod_php.

Tóm tắt cho người đang cháy nhà

  1. Trong 1 giờ đầu: kiểm tra gói PHP đang chạy đã có bản vá chưa — đọc changelog gói, không đọc số version (mục 4.2). Nếu là Ubuntu 22.04/24.04 dùng PPA ondrej/php: php7.4 bản 7.4.33-31 đã backport fix ngay ngày 21/10 — apt upgrade + restart FPM là xong bước chặn máu (lưu ý: trên bản backport này, request vượt memory_limit giết luôn worker và trả 502, Laravel không log được — mục 5.10). Ubuntu 20.04 dùng PHP gốc của Ubuntu: archive miễn phí chưa có fix (lab mục 1.4).
  2. Chưa vá được: bật đồng thời php_admin_value[memory_limit] = -1 và php_admin_flag[session.upload_progress.enabled] = off trong pool FPM, hạ post_max_size, chặn multipart/form-data ở nginx trừ route upload (mục 6.1). Đây là giảm rủi ro, không phải vá.
  3. Trong 1–2 sprint: nâng cấp thật lên PHP 8.4.27. Với Laravel 8, PHP 8.4 kéo theo nâng framework 8 → 9 → 10 → 11 → 12 → 13, vì Laravel 8 chỉ hỗ trợ tới PHP 8.1 và mọi bản 8/10/11 giờ đã dính security advisory trên Packagist (mục 5.5).
  4. Cutover: chạy song song pool 7.4 và 8.4, mỗi pool gắn với một thư mục release riêng (code cũ ↔ PHP cũ, code mới ↔ PHP mới), đổi nginx bằng symlink + nginx -s reload. Lab đo: 3 949 request trong lúc cutover, 0 lỗi; rollback 3 766 request, 0 lỗi (mục 6.4).
  5. Verify: hỏi chính FPM đang phục vụ ("via nginx+FPM"), không hỏi CLI; dò process còn chạy binary cũ (deleted) sau khi apt upgrade (mục 6.5).

1. Bài toán gốc và bối cảnh

1.1 PHP2Shell là gì — giải thích bằng ngôn ngữ của người vận hành

1. CVE-2026-109618 — "RCE via OOM corruption". PHP xử lý lỗi hết bộ nhớ (OOM — out-of-memory, vượt memory_limit) bằng longjmp(): nhảy thẳng ra khỏi đoạn code đang chạy để huỷ request. Advisory chỉ ra có những thời điểm vùng nhớ của request đã "nhìn thấy được" nhưng chưa khởi tạo xong; nếu đúng lúc đó một lần cấp phát bất kỳ thất bại, PHP nhảy ra và để lại dữ liệu ở trạng thái hỏng, rồi bước dọn dẹp cuối request (shutdown) đọc chính dữ liệu hỏng đó. Kẻ tấn công không cần lỗi logic gì của app: advisory liệt kê nhiều cách ép OOM chỉ bằng request multipart — request không có Content-Length dùng được để lách kiểm tra post_max_size, một part multipart chứa vô số header (biểu diễn trong PHP tốn RAM hơn nhiều so với trên dây), hay hàng loạt ô file rỗng không bị đếm vào max_file_uploads.

2. CVE-2026-109619 — "RCE via stale global state". Worker PHP (một process FPM sống qua nhiều request) giữ một số biến toàn cục — trạng thái lỗi, session, extension — trỏ vào bộ nhớ của request trước. Nếu request trước bị ngắt giữa chừng (fatal error, timeout, OOM) trước khi kịp reset, bộ nhớ đó bị giải phóng nhưng con trỏ vẫn còn. Request kế tiếp trên cùng worker có những thao tác chạy trước khi khởi tạo bình thường — advisory xác nhận hai cái: cảnh báo INI áp dụng theo từng request, và session upload progress (bật mặc định, chạy cả khi app không gọi session_start()) — và chúng đọc lại con trỏ chết đó: use-after-free (dùng vùng nhớ đã giải phóng). Advisory gọi đây là cặp producer (request để lại rác) và consumer (request sau đọc rác).

3. Tại sao lần này nghiêm trọng hơn thường lệ. Lỗi nằm trong engine, không phải framework, nên code Laravel sạch cũng không cứu được; code dễ tổn thương có từ PHP 5.4 (2012); writeup và tool khai thác đã public, Cloudflare báo bị tấn công từ 07:43 UTC ngày 21/10 — trước cả giờ phát hành bản vá 14:00 UTC (theo Sansec). NCSC Anh khuyên cân nhắc đưa offline hệ thống chưa vá được (Help Net Security).

1.2 mod_php nguy hiểm hơn FPM — nhưng FPM không miễn nhiễm

Đây là chỗ hay bị hiểu sai thành "dùng FPM thì yên tâm". Đọc kỹ advisory GHSA-w8hw:

SAPIKhi request startup thất bại (OOM lúc parse body)Kết luận của advisory
Apache mod_phpVẫn gọi shutdown vô điều kiện → đọc trạng thái hỏng, trước khi một dòng code app nào chạyExploit RCE hoàn chỉnh đã chứng minh, cấu hình mặc định. Rủi ro cao nhất
PHP-FPMGiết luôn child khi startup fail → không tiêu thụ trạng thái hỏngVẫn "susceptible": OOM sau khi startup xong (trong lúc app chạy) vẫn đi được đường lỗi. Với CVE-2026-109619, đường session upload progress đã validate được RCE trên FPM với pool dynamic bình thường
FrankenPHPGọi shutdown vô điều kiện, giống mod_phpPhân tích source cho thấy nhiều khả năng dính kiểu "không phụ thuộc app"
LiteSpeed (LSAPI)Mô phỏng FPMKhả năng dính qua đường phụ thuộc app, chưa validate

Nghĩa là: FPM làm kẻ tấn công cần thêm điều kiện (một thao tác của app để lại state, rồi request sau rơi đúng vào worker đó), nhưng không có cấu hình nào thay được bản vá — câu này advisory viết in đậm.

1.3 Vì sao "đang chạy 7.4" biến một bản vá thành một dự án

Với ai đang ở 8.4.x, việc cần làm là apt upgrade + restart. Với PHP 7.4 thì khác, vì ba lý do độc lập:

  1. Upstream không vá 7.4/8.0/8.1. PHP chỉ còn hỗ trợ 8.2–8.5. Riêng lần này PHP security team có đẩy minimal source backport cho 7.4, 8.0, 8.1 (nhánh PHP-7.4-CVE-2026-109618-109619) — "để giúp người tự build", không phải release chính thức.
  2. Laravel 8 không chạy chính thức trên bất kỳ PHP nào được vá chính thức. Laravel 8 hỗ trợ PHP 7.3–8.1 (bảng ở mục 4.3). Bản vá upstream thấp nhất là 8.2.35. Không có tổ hợp "Laravel 8 + PHP có bản vá chính thức" nào nằm trong support matrix.
  3. 8.2 chỉ là chỗ dừng chân: 8.2 hết security support ngày 31/12/2026 — hai tháng nữa (php.net/supported-versions). 8.4 được vá tới 31/12/2028.

1.4 Lab: thực tế mỗi bản Ubuntu lấy được PHP nào, đã vá chưa

Câu "PPA có 8.4.27 chưa?" phải trả lời bằng apt-cache policy, không bằng trí nhớ. Chạy trong container sạch của từng bản Ubuntu (00-probe-repos.sh, sáng 22/10/2026):

################ ubuntu:20.04 ################
--- stock archive (no PPA) ---
php7.4-fpm   candidate: 7.4.3-4ubuntu2.29
--- with ppa:ondrej/php ---
php7.4-fpm   candidate: 7.4.3-4ubuntu2.29
php8.4-fpm   candidate:                       <- PPA không còn build cho focal
################ ubuntu:22.04 ################
--- stock archive (no PPA) ---
php8.1-fpm   candidate: 8.1.2-1ubuntu2.28
--- with ppa:ondrej/php ---
php7.4-fpm   candidate: 1:7.4.33-31+ubuntu22.04.1+deb.sury.org+1
php8.2-fpm   candidate: 8.2.35-1+ubuntu22.04.1+deb.sury.org+1
php8.3-fpm   candidate: 8.3.36-1+ubuntu22.04.1+deb.sury.org+1
php8.4-fpm   candidate: 8.4.27-1+ubuntu22.04.1+deb.sury.org+1
php8.5-fpm   candidate: 8.5.12-1+ubuntu22.04.1+deb.sury.org+1
################ ubuntu:24.04 ################
--- stock archive (no PPA) ---
php8.3-fpm   candidate: 8.3.6-0ubuntu0.24.04.13
--- with ppa:ondrej/php ---
php7.4-fpm   candidate: 1:7.4.33-31+ubuntu24.04.1+deb.sury.org+1
php8.4-fpm   candidate: 8.4.27-1+ubuntu24.04.1+deb.sury.org+1

Rồi cài từng gói và grep changelog tìm đúng mã CVE (01-probe-changelogs.sh):

== php7.4-cli 7.4.3-4ubuntu2.29            (ubuntu 20.04, archive miễn phí)
     (no match)
== php8.1-cli 8.1.2-1ubuntu2.28            (ubuntu 22.04, archive)
     4:    - debian/patches/CVE-2026-109618_109619.patch: Avoid state corruption via
     15:    - CVE-2026-109618
== php8.3-cli 8.3.6-0ubuntu0.24.04.13      (ubuntu 24.04, archive)
     4:    - debian/patches/CVE-2026-109618_109619.patch: Avoid state corruption via
     293:    - CVE-2026-109618
== php7.4-cli 1:7.4.33-31+ubuntu22.04.1+deb.sury.org+1   (PPA)
     33:   + [CVE-2026-109618, CVE-2026-109619]: Avoid state corruption via
     34:     bailout.
== php8.4-cli 8.4.27-1+ubuntu22.04.1+deb.sury.org+1      (PPA)
     10:   + (CVE-2026-109618, CVE-2026-109619): Avoid state corruption via

Bốn kết luận rút thẳng từ output, mỗi cái đổi cách ra quyết định:

  1. Ubuntu 22.04 và 24.04 dùng PHP gốc của Ubuntu đã được vá, dù php -v vẫn in 8.1.2 / 8.3.6 — con số nhỏ hơn 8.2.35/8.3.36 rất nhiều. Ai lấy "version < 8.3.36" làm tiêu chí quét lỗ hổng sẽ báo động giả trên các máy này.
  2. PPA ondrej/php backport fix vào cả 7.4 (7.4.33-31, build 21/10/2026 14:22, theo php -v trong lab). php -v vẫn in 7.4.33 như trước. Đây là đường chặn máu nhanh nhất cho Laravel 8 + PHP 7.4 trên 22.04/24.04 — nhưng là backport best-effort cho một nhánh đã EOL 4 năm.
  3. Ubuntu 20.04 dùng PHP gốc (7.4.3-4ubuntu2.29) thì archive miễn phí chưa có fix, và PPA không còn build cho focal. Bản vá cho focal (nếu có) nằm ở Ubuntu Pro/ESM — chưa verify (lab không có subscription). Máy 20.04 vì thế chỉ có ba lối: Ubuntu Pro, tự build từ nhánh backport (mục 7.1, lab đã build thử), hoặc chuyển OS.
  4. Bài học vận hành: số version không phải bằng chứng. Bằng chứng là changelog của gói (hoặc commit/nhánh nếu tự build) có mã CVE.

2. Mô hình tư duy cốt lõi

Gói gọn trong hai ý. Mọi bước trong runbook là hệ quả của hai ý này.

Ý 1 — "nâng cấp PHP" thực ra là ba trục độc lập.

  1. Trục binary (bản vá): binary đang phục vụ request có chứa fix không. Giải quyết bằng gói/tự build + restart. Đo bằng changelog và hỏi chính FPM.
  2. Trục hợp đồng ngôn ngữ: cùng một dòng code cho kết quả khác giữa 7.4 và 8.x (so sánh chuỗi–số, TypeError, deprecation). Giải quyết bằng test + quét tĩnh + log deprecation.
  3. Trục cửa sổ hỗ trợ của framework/thư viện: Laravel/Composer package có cho phép phiên bản PHP đó không, và bản đó còn được vá không. Giải quyết bằng chuỗi upgrade framework.

Vá khẩn chỉ xử lý trục 1. Nâng cấp lên 8.4 phải xử lý cả ba. Nhầm hai việc này là nguồn gốc của hai kiểu thất bại kinh điển: "đã lên 8.4 nhưng quên restart FPM" (trục 1) và "đã restart nhưng giá tiền 'abc' thành không miễn phí" (trục 2).

Ý 2 — khi nhảy major, phiên bản PHP và bản release code là một cặp không tách rời. Laravel 13 cần PHP ≥ 8.3; Laravel 8 dựng trên PHP 7.4 thì lock file còn không cài nổi trên 8.4 (mục 5.4). Nên đơn vị để cutover và rollback không phải "pool PHP" hay "commit", mà là cặp (thư mục release, pool FPM): blue = (Laravel 8, PHP 7.4), green = (Laravel 13, PHP 8.4). nginx chỉ việc trỏ vào một cặp.

flowchart TD
  A["PHP đang phục vụ web là gì?<br/>(hỏi FPM, không hỏi CLI)"] --> B{"Changelog gói có<br/>CVE-2026-109618/109619?"}
  B -->|"Có"| C["Trục 1 xong.<br/>Restart mọi worker, verify"]
  B -->|"Không"| D{"Có gói vá cho đúng<br/>nhánh/OS này?"}
  D -->|"Có (vd PPA 7.4.33-31,<br/>Ubuntu 8.1.2-...28)"| E["apt upgrade + restart<br/>(giờ đầu tiên)"]
  D -->|"Không (vd 20.04 gốc)"| F["Giảm rủi ro ngay (mục 6.1)<br/>+ Ubuntu Pro / tự build / offline"]
  C --> G{"Nhánh còn được upstream<br/>hỗ trợ tới khi nào?"}
  E --> G
  F --> G
  G -->|"7.4/8.0/8.1 EOL,<br/>8.2 hết 31/12/2026"| H["Dự án nâng cấp lên 8.4.27<br/>(trục 2 + trục 3)"]
  G -->|"8.3/8.4/8.5"| I["Giữ nhịp vá định kỳ"]
Cách nói khi phỏng vấn: "Tôi tách vá khẩn và nâng cấp thành hai việc. Việc đầu là đảm bảo cái binary đang phục vụ request có bản vá — kiểm bằng changelog gói chứ không bằng số version, rồi restart toàn bộ worker. Việc sau là nâng cấp thật lên một nhánh còn được hỗ trợ, và việc đó đụng tới hành vi ngôn ngữ lẫn phiên bản framework, nên phải có test, quét tĩnh và một đường rollback. Khi nhảy major thì tôi coi phiên bản PHP và bản code là một cặp, cutover và rollback theo cặp."

3. Internals và quyết định thiết kế

3.1 Cài 8.4 song song 7.4 trên cùng máy — Ubuntu tách những gì, chung những gì

Gói Debian/Ubuntu của PHP tách theo phiên bản: /etc/php/7.4/ và /etc/php/8.4/, binary php-fpm7.4 / php-fpm8.4, service riêng, log riêng. Hai pool chạy song song được — đó là nền của cutover không downtime. Lab (04-install-84.sh):

### 4.1 what 8.4 version does the repo offer right now?
php8.4-fpm:
  Installed: (none)
  Candidate: 8.4.27-1+ubuntu22.04.1+deb.sury.org+1
### 4.3 install (policy-rc.d blocks auto-start so the new pool never starts with a half-migrated config)
PHP 8.4.27 (cli) (built: Oct 21 2026 14:34:07) (NTS)
PHP 8.4.27 (fpm-fcgi) (built: Oct 21 2026 14:34:07) (NTS)
php8.4-fpm 8.4.27-1+ubuntu22.04.1+deb.sury.org+1
update-alternatives (which php is 'php' on the CLI now?):
Best: /usr/bin/php8.4
Value: /usr/bin/php8.4
PHP 8.4.27 (cli) (built: Oct 21 2026 14:34:07) (NTS)

Có ba thứ không tách, và cả ba đều là bẫy:

  1. Lệnh php trên CLI tự đổi sang 8.4 ngay khi cài gói (alternatives ở chế độ auto, chọn bản cao nhất — output trên). Cron php artisan schedule:run, Supervisor chạy php artisan queue:work, script deploy gọi php trần — tất cả lặng lẽ chạy code Laravel 8 bằng PHP 8.4 từ lần chạy kế tiếp, trong khi web vẫn ở 7.4. Cách làm đúng: mọi cron/supervisor/ deploy script gọi đường dẫn tường minh (/usr/bin/php7.4, /usr/bin/php8.4), hoặc ghim lại ngay sau khi cài: update-alternatives --set php /usr/bin/php7.4.
  2. Socket "chung" /run/php/php-fpm.sock — xem 3.2.
  3. Extension: phải cài lại đủ bộ cho 8.4 — xem 3.4.

3.2 Bẫy socket chung: nginx tự chuyển sang 8.4 khi chưa ai bảo

Unit systemd của gói FPM có dòng:

ExecStartPost=-/usr/lib/php/php-fpm-socket-helper install /run/php/php-fpm.sock /etc/php/8.4/fpm/pool.d/www.conf 84
ExecReload=/bin/kill -USR2 $MAINPID

Helper này đăng ký socket của www.conf vào update-alternatives với priority = số phiên bản (74, 84). Ở chế độ auto, bản cao nhất thắng. Nếu nginx đang fastcgi_pass unix:/run/php/php-fpm.sock (rất phổ biến vì tutorial nào cũng ghi vậy), thì lúc php8.4-fpm start, traffic production chuyển sang 8.4 mà không ai sửa nginx, không ai reload. Lab tái hiện (container không có systemd nên gọi helper đúng như ExecStartPost — 07-generic-socket-trap.sh):

before: /run/php/php7.4-fpm.sock -> curl: 7.4.33
after php8.4-fpm start: /run/php/php8.4-fpm.sock -> curl: 8.4.27   (nginx NOT reloaded, config NOT edited)
php-fpm.sock - auto mode
  link best version is /run/php/php8.4-fpm.sock
  link currently points to /run/php/php8.4-fpm.sock
/run/php/php7.4-fpm.sock - priority 74
/run/php/php8.4-fpm.sock - priority 84
--- pin it back explicitly:
update-alternatives: using /run/php/php7.4-fpm.sock to provide /run/php/php-fpm.sock (php-fpm.sock) in manual mode
pinned: /run/php/php7.4-fpm.sock -> curl: 7.4.33

Quyết định thiết kế cho runbook: socket mang tên phiên bản và tên pool (/run/php/php7.4-fpm-app.sock, /run/php/php8.4-fpm-app.sock), xoá www.conf mặc định, nginx trỏ thẳng socket có tên. Đổi phiên bản = đổi một dòng nginx, có chủ đích, có nginx -t.

3.3 Reload FPM thực sự làm gì — và khi nào nó không làm gì

systemctl reload php8.4-fpm = kill -USR2 $MAINPID. Master re-exec chính nó với binary trên đĩa hiện tại, giữ socket đang lắng nghe (không rớt kết nối), worker cũ làm nốt request rồi thoát. Log thật trong lab:

NOTICE: using inherited socket fd=8, "/run/php/php8.4-fpm-app.sock"
NOTICE: fpm is running, pid 14868
NOTICE: ready to handle connections

Hai hệ quả quan trọng, đều đo được trong lab:

  1. USR2 xoá OPcache. Với opcache.validate_timestamps=0 (cấu hình production phổ biến), đổi bootstrap/cache/config.php mà không reload thì FPM vẫn dùng bản đã compile cũ. Lab: file trên đĩa có 'deprecations' => 'single', nhưng FPM đọc ra giá trị cũ cho tới khi master được USR2.
  2. Đừng tin exit code của lệnh reload. Trong container không systemd, service php8.4-fpm reload in ...done., trả rc=0 nhưng không gửi tín hiệu nào: script init.d tìm PID file mà php-fpm.conf của gói (bản cho systemd Type=notify) đã comment dòng pid. Thời điểm start của master không đổi, OPcache không đổi. Bài học tổng quát: sau mọi reload/restart trong runbook, kiểm hiệu ứng (PID/start time của master, log "ready to handle connections", hoặc gọi một endpoint in ra version) chứ không kiểm exit code.

3.4 Extension parity — so php -m, đừng so tên gói

Ánh xạ máy móc 49 gói php7.4-* sang php8.4-* thì 27 tên "không tồn tại" (php8.4-json, php8.4-pdo, php8.4-tokenizer, php8.4-mysqli...). Nhìn apt-cache showpkg mới thấy chúng là gói ảo (virtual package) do php8.4-common, php8.4-xml, php8.4-mysql cung cấp, còn json đã nằm trong core từ 8.0. So danh sách module thật mới là phép đo đúng:

### 4.4 extension parity: php -m 7.4 vs 8.4
count 7.4=59  8.4=60
only in 7.4:
only in 8.4:
   random

Khớp hoàn toàn (thêm random — extension mới của 8.2). Lưu ý: imap và xmlrpc đã bị tách khỏi core (xmlrpc từ 8.0, imap từ 8.4 sang PECL), PPA vẫn đóng gói bản PECL nên lab cài được; máy dùng nguồn khác (image Docker chính thức, tự build) phải tự cài từ PECL hoặc thay thư viện.

3.5 Migrate cấu hình: port phần override, không copy cả php.ini

Diff php.ini production của chính gói 7.4 và 8.4 (bỏ comment):

< disable_functions = pcntl_alarm,pcntl_fork,...,pcntl_unshare,
> disable_functions =
> zend.exception_string_param_max_len = 0
< error_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT
> error_reporting = E_ALL & ~E_DEPRECATED
< log_errors_max_len = 1024
> mail.mixed_lf_and_crlf = Off
< mysqli.reconnect = Off
< session.sid_length = 26
< session.sid_bits_per_character = 5

Nếu copy nguyên php.ini của 7.4 sang 8.4 (lối tắt rất hay gặp), 8.4 nhận hai directive đã deprecated — và vì error_reporting mặc định loại E_DEPRECATED, không ai thấy gì. Bật hiển thị lên mới lộ ra (06b-deprecation-log.sh):

$ php8.4 -c /etc/php/7.4/fpm/php.ini -d error_reporting=-1 -d display_startup_errors=1 -r 'echo "ok\n";'
PHP Deprecated:  PHP Startup: session.sid_length INI setting is deprecated in Unknown on line 0
PHP Deprecated:  PHP Startup: session.sid_bits_per_character INI setting is deprecated in Unknown on line 0
ok

Liên hệ trực tiếp với PHP2Shell: advisory CVE-2026-109619 nêu "PHP 8.4+ legacy session settings" là ví dụ của chẩn đoán INI có thể đóng vai consumer — và nhấn mạnh rằng ẩn chẩn đoán bằng error_reporting/@/display_errors không bảo vệ gì, vì state toàn cục bị chạm trước khi PHP quyết định có báo hay không. Đường nguy hiểm nhất là INI áp dụng theo từng request (nginx fastcgi_param PHP_VALUE); php.ini và pool chỉ áp dụng lúc khởi động worker. Dù vậy, dọn directive lỗi thời là việc rẻ, nên làm luôn.

Quy trình đã chạy:

# pool: copy, đổi đường dẫn có số phiên bản, bỏ www.conf mặc định
sed 's/7\.4/8.4/g' /etc/php/7.4/fpm/pool.d/app.conf > /etc/php/8.4/fpm/pool.d/app.conf
rm -f /etc/php/8.4/fpm/pool.d/www.conf
diff /etc/php/7.4/fpm/pool.d/app.conf /etc/php/8.4/fpm/pool.d/app.conf
5c5
< listen = /run/php/php7.4-fpm-app.sock
> listen = /run/php/php8.4-fpm-app.sock
16c16
< slowlog = /var/log/php7.4-fpm-app.slow.log
> slowlog = /var/log/php8.4-fpm-app.slow.log
20c20
< php_admin_value[error_log] = /var/log/php7.4-fpm-app.error.log
> php_admin_value[error_log] = /var/log/php8.4-fpm-app.error.log

# override riêng của app nằm ở conf.d/99-app.ini -> copy rồi bỏ directive đã chết
cp /etc/php/7.4/fpm/conf.d/99-app.ini /etc/php/8.4/fpm/conf.d/99-app.ini
#   session.sid_length, session.sid_bits_per_character  (deprecated 8.4)
#   track_errors, mbstring.func_overload                (removed 8.0, bị bỏ qua im lặng)
php-fpm8.4 -t
NOTICE: configuration file /etc/php/8.4/fpm/php-fpm.conf test is successful
Chú ý php-fpm8.4 -t báo successful cả khi còn directive deprecated — nó chỉ kiểm cú pháp. Muốn bắt directive lỗi thời phải chạy kèm -d error_reporting=-1 -d display_startup_errors=1 như trên.

4. Đảm bảo và giới hạn

4.1 Mỗi lựa chọn đảm bảo được gì

Lựa chọnĐảm bảoKhông đảm bảo
Gói đã vá (8.4.27, hoặc backport của vendor) + restart mọi workerĐóng cả hai CVE theo nghĩa của advisory ("only complete mitigation")Không đóng lỗ hổng tương lai trong cùng vùng code; không sửa được nếu còn process chạy binary cũ (mục 6.5)
memory_limit=-1 (bắt buộc bằng php_admin_value)Bỏ đường OOM "kiểm soát được" mà exploit đã xác nhận dùng (CVE-…618)Advisory: mmap() hay cấp phát bên dưới thất bại vẫn đi vào longjmp(). Đổi lại: một request ngốn RAM không còn bị PHP chặn (mục 5.10)
session.upload_progress.enabled=0Chặn đường session upload progress đã xác nhận (CVE-…619)Không chặn các cặp producer/consumer khác; advisory nói rõ "no complete generic configuration mitigation"
Chặn multipart ở nginx, hạ post_max_sizeCắt nguồn OOM rẻ nhất cho route không cần uploadRoute upload vẫn mở; OOM do logic app vẫn có thể xảy ra
WAF (Cloudflare đã đẩy rule mặc định)Lọc mẫu tấn công đã biết ở biênKhông thấy request đi vòng (IP gốc lộ, mạng nội bộ); nội dung rule chưa verify
Đưa offline / chỉ đọc (GET/HEAD)Gần như loại bỏ bề mặt tấn công qua bodyMất doanh thu/dịch vụ; GET vẫn đi qua PHP

Khuyến nghị của php.net: hai biện pháp memory_limit=-1 và session.upload_progress.enabled=0 phải bật cùng nhau vì mỗi cái che một CVE — bật một cái là bỏ ngỏ CVE kia.

4.2 Cách duy nhất để chứng minh "đã vá": changelog và câu trả lời của chính FPM

Bảng ở mục 1.4 đã cho thấy ba kiểu "số version nói dối": Ubuntu 8.3.6 đã vá, PPA 7.4.33 đã vá, Ubuntu 20.04 7.4.3 chưa vá. Lệnh kiểm trên một máy Ubuntu:

dpkg-query -W -f='${Package} ${Version}\n' 'php*-fpm'
zcat /usr/share/doc/php8.4-fpm/changelog.Debian.gz | grep -m1 -E 'CVE-2026-109618'
# lab:
php8.4-fpm 8.4.27-1+ubuntu22.04.1+deb.sury.org+1
+ (CVE-2026-109618, CVE-2026-109619): Avoid state corruption via

Rồi hỏi chính process đang phục vụ — mục 6.5 có đủ bốn câu trả lời khác nhau cho câu "đang chạy PHP nào?" và vì sao chỉ câu trả lời qua nginx+FPM là đáng tin.

4.3 Cửa sổ hỗ trợ — vì sao đích là PHP 8.4 + Laravel 13, không phải 8.2 + Laravel 10

LaravelPHP hỗ trợSecurity fix tớiGhi chú với bài này
87.3 – 8.124/01/2023Đã EOL. Không có PHP nào vừa trong range vừa có bản vá upstream
98.0 – 8.206/02/2024EOL
108.1 – 8.304/02/2025EOL. Về lý thuyết chạy được 8.3.36 đã vá, nhưng framework hết vá
118.2 – 8.412/03/2026EOL
128.2 – 8.524/02/2027Còn đúng 4 tháng
138.3 – 8.517/03/2028Đích

Nguồn: Context7 laravel/docs 13.x (Laravel 12, 13) và bảng support policy ở 12.x, 9.x.

PHPSecurity support tớiBản có fix PHP2Shell
7.4 / 8.0 / 8.1EOLChỉ minimal source backport của PHP security team, hoặc backport của vendor
8.231/12/20268.2.35
8.331/12/20278.3.36
8.431/12/20288.4.27
8.531/12/20298.5.12

Lý luận chọn đích: nâng cấp từ Laravel 8 là việc tốn công cố định (gần như bằng nhau dù dừng ở 12 hay 13), nên dừng ở 12 là trả cùng chi phí để mua 4 tháng. PHP 8.4 thay vì 8.5: hệ sinh thái đã ổn định với 8.4 hơn một năm, mà vẫn được vá tới cuối 2028. Chọn 8.5 cũng hợp lý nếu team tự tin, nhưng đây là bài toán an ninh khẩn — không phải lúc nhận thêm rủi ro của một nhánh mới hơn.

4.4 Lab nhỏ: OOM trước và sau bản vá trông thế nào

Bản vá bỏ longjmp() khi OOM, thay bằng "soft OOM" ở safe point kế tiếp của VM và "hard OOM" bằng _exit() nếu vượt một ngưỡng cao hơn. Muốn biết nó có làm đổi hành vi app không, chạy cùng một script trên image chính thức php:8.4.26-fpm (chưa vá) và php:8.4.27-fpm (đã vá), gửi thẳng FastCGI bằng cgi-fcgi (10b-oom-behaviour.sh):

################ php:8.4.26-fpm  (PHP 8.4.26 (fpm-fcgi) (built: Oct 21 2026 00:31:28) (NTS))
--- GET /mem.php?mb=300
   Fatal error:  Allowed memory size of 134217728 bytes exhausted (tried to allocate 314572832 bytes) ...
--- GET /memloop.php?
   Fatal error:  Allowed memory size of 134217728 bytes exhausted (tried to allocate 1052672 bytes) ...
--- FPM master log:            (không có dòng "exited")
################ php:8.4.27-fpm  (PHP 8.4.27 (fpm-fcgi) (built: Oct 21 2026 15:53:31) (NTS))
--- GET /mem.php?mb=300
   Fatal error:  Allowed memory size of 134217728 bytes exhausted (tried to allocate 314576896 bytes) ...
--- GET /memloop.php?
   Fatal error:  Allowed memory size of 134217728 bytes exhausted (tried to allocate 2097152 bytes) ...
--- FPM master log:            (không có dòng "exited")

Với hai kiểu OOM này, phía người dùng không đổi: vẫn là fatal error "Allowed memory size exhausted", worker không chết. Khác biệt nhỏ là số byte "tried to allocate" (cách tính cấp phát đổi). Điều này quan trọng để không đổ lỗi nhầm cho bản vá khi thấy lỗi OOM sau khi nâng cấp lên 8.4.27. Bản backport cho 7.4 thì khác hẳn: OOM giết worker (mục 5.10).

5. Failure modes — tái hiện trong lab

Mỗi mục dưới đây là một cách nâng cấp hỏng, đã tái hiện thật. Phần 5.1–5.3 chạy cùng một file trên php7.4 và php8.4 cùng máy (05-repro.sh, -d error_reporting=-1 -d display_errors=1).

5.1 Hỏng im lặng: so sánh chuỗi với số (PHP 8.0)

                      php7.4   php8.4
'0' == 'abc'          false    false
0 == 'abc'            true     false
0 == ''               true     false
'1' == '01'           true     true
'10' == '1e1'         true     true
100 == '1e2'          true     true
0 == NULL             true     true
'abc' == 0            true     false
in_array('abc',[0])   true     false
switch('abc') case 0  case 0   default

Từ 8.0, khi so số với chuỗi không phải số, PHP đổi số thành chuỗi rồi so chuỗi (trước đây làm ngược lại: ép chuỗi thành số, 'abc' thành 0). Không có warning, không có deprecation, không có exception — chỉ là kết quả khác. Trong app lab, hàm nghiệp vụ viết thời 7.x:

// "price" lấy thẳng từ query string / file CSV import, tức là chuỗi
public function isFree($price)
{
    return $price == 0;
}

Cùng một URL, cùng một bản code Laravel 8, chỉ khác pool FPM (06-migrate-config.sh):

8.4 canary /legacy/free/abc   -> 200 {"price":"abc","isFree":false}
7.4 prod   /legacy/free/abc   -> 200 {"price":"abc","isFree":true}

Và test suite (viết theo hành vi 7.4) bắt được đúng một ca — ô giá trống trong file CSV:

  ⨯ empty price from csv is free
  Failed asserting that false is true.
  ➜  26▕         $this->assertTrue((new PriceRules)->isFree(''));
  Tests:  1 failed, 6 passed

Ca 'abc' không có test nên không ai biết. Đây là loại thay đổi nguy hiểm nhất trong cả cuộc nâng cấp: không công cụ quét tĩnh nào trong lab báo nó (mục 5.6), log không có gì. Chỉ có test theo nghiệp vụ hoặc so sánh output hai phiên bản (canary, mục 6.4) mới lộ ra. Câu hỏi "'abc' có miễn phí không" là câu hỏi cho PO, không phải cho dev: lab chọn giữ nguyên nghĩa "ô trống = mẫu miễn phí" nhưng coi 'abc' = không miễn phí là sửa bug, và viết lại cho tường minh (mục 6.3 bước 5).

Cách nói khi phỏng vấn: "Thay đổi tôi sợ nhất khi lên PHP 8 không phải những thứ báo lỗi, mà là phép so sánh lỏng giữa số và chuỗi. Trước 8.0, chuỗi chữ bị ép thành số không nên 'abc' bằng 0; từ 8.0 thì ngược lại, số bị đổi thành chuỗi nên 'abc' khác 0. Không có cảnh báo nào cả, kết quả chỉ lặng lẽ đổi. Nên tôi tìm mọi chỗ so sánh lỏng với dữ liệu đầu vào dạng chuỗi, viết test theo nghiệp vụ cho chúng, và đổi sang so sánh chặt hoặc kiểm tra is_numeric một cách tường minh."

5.2 Từ "chạy được" thành deprecation: null vào hàm nội bộ (8.1), dynamic property (8.2), implicit nullable (8.4)

---- php7.4 ----                           ---- php8.4 ----
int(0)                                     Deprecated: strlen(): Passing null to parameter #1 ($string) of type string is deprecated
string(0) ""                               Deprecated: trim(): Passing null to parameter #1 ($string) of type string is deprecated
string(0) ""                               Deprecated: str_replace(): Passing null to parameter #3 ($subject) of type array|string is deprecated
string(0) ""                               Deprecated: htmlspecialchars(): Passing null to parameter #1 ($string) of type string is deprecated
Warning: A non-numeric value encountered   TypeError: Unsupported operand types: string * int
  int(0)
Error: Unsupported operand types           TypeError: Unsupported operand types: array + int
Warning: strlen() expects parameter 1 to   TypeError: strlen(): Argument #1 ($string) must be of type string, array given
  be string, array given  -> NULL
---- php7.4 ----        ---- php8.4 ----
coupon=SALE10           Deprecated: Slugger::slug(): Implicitly marking parameter $text as nullable is deprecated,
bag x=1                   the explicit nullable type must be used instead in r3-dynprop-nullable.php on line 14
slug=''                 Deprecated: Creation of dynamic property OrderDto::$couponCode is deprecated in ... line 6
                        coupon=SALE10
                        bag x=1        <- class có #[\AllowDynamicProperties]: không cảnh báo
                        slug=''

Ba điều rút ra:

  1. Deprecation hôm nay là lỗi ngày mai: "Passing null..." được lên kế hoạch thành TypeError ở PHP 9. Không sửa bây giờ thì lần nâng cấp sau sẽ đúng kiểu "hỏng hàng loạt".
  2. Những chỗ 7.4 chỉ warning rồi trả NULL/0 thì 8.x ném TypeError — tức request trả 500 thay vì chạy tiếp với dữ liệu sai. Hành vi mới đúng hơn, nhưng là thay đổi lộ ra người dùng.
  3. Implicit nullable (string $text = null) là deprecation lúc compile — báo một lần khi file được biên dịch, không phải mỗi lần gọi. Hệ quả ở mục 5.3.

5.3 Laravel 8 trên PHP 8.4: chạy được, test vẫn xanh gần hết, log thì im — vì sao

Laravel 8.83 có cơ chế tách riêng deprecation: HandleExceptions gọi error_reporting(-1), nhưng deprecation không thành exception mà được chuyển sang log channel deprecations — mặc định là null (bỏ đi). Code trong vendor/laravel/framework/src/Illuminate/Foundation/Bootstrap/HandleExceptions.php của lab:

45:        error_reporting(-1);
76:        if ($this->isDeprecation($level)) {
77:            return $this->handleDeprecation($message, $file, $line);
config/logging.php:
33:    'deprecations' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),

Đặt LOG_DEPRECATIONS_CHANNEL=single cho pool 8.4 rồi bắn vài request qua cổng canary nội bộ:

--- distinct deprecations logged:
      1 WARNING: trim(): Passing null to parameter #1 ($string) of type string is deprecated in app/Legacy/PriceRules.php on line 16
      1 WARNING: Creation of dynamic property App\Legacy\OrderDto::$couponCode is deprecated in routes/web.php on line 61
--- same traffic on 7.4 prod:
total deprecation lines on 7.4: 0

Implicit nullable trong PriceRules::slug() và hàng loạt implicit nullable trong vendor/facade/ignition, vendor/nunomaduro/collision không có trong log Laravel. Chúng nằm ở log của FPM, vì phát ra lúc compile — trước khi Laravel kịp đăng ký error handler — và chỉ phát một lần, lúc OPcache biên dịch file lần đầu:

WARNING: [pool app] child 14549 said into stderr: "NOTICE: PHP message: PHP Deprecated:
  Facade\FlareClient\Flare::__construct(): Implicitly marking parameter $container as nullable is deprecated ...
WARNING: [pool app] child 14550 said into stderr: "NOTICE: PHP message: PHP Deprecated:
  App\Legacy\PriceRules::slug(): Implicitly marking parameter $text as nullable is deprecated ...

Kết luận vận hành: log deprecation lúc chạy chỉ thấy deprecation runtime; deprecation compile-time phải bắt bằng quét tĩnh (mục 5.6) hoặc đọc log FPM ngay sau khi worker mới khởi động. Dựa vào một nguồn duy nhất là bỏ sót.

5.4 Lock file dựng trên 7.4 không cài được trên 8.4

Lần đầu cho Laravel 8 chạy trên 8.4 ở mục 5.3, lab dùng nguyên thư mục vendor/ build bằng 7.4 — và nó chạy, vì platform_check.php của Composer chỉ kiểm cận dưới ("php": "^7.3|^8.0"). Nhưng khi build lại sạch trên máy 8.4 (đúng như CI/deploy sẽ làm):

$ php8.4 composer install
Your lock file does not contain a compatible set of packages. Please run composer update.
  Problem 1
    - nette/schema is locked to version v1.2.5 and an update of this package was not requested.
    - nette/schema v1.2.5 requires php 7.1 - 8.3 -> your php version (8.4.27) does not satisfy that requirement.
  Problem 2
    - nette/utils is locked to version v3.2.10 and an update of this package was not requested.
    - nette/utils v3.2.10 requires php >=7.2 <8.4 -> your php version (8.4.27) does not satisfy that requirement.

Hai hệ quả: (1) "copy vendor/ từ máy cũ sang" là chạy thư viện ngoài phạm vi chúng tự khai báo hỗ trợ — chạy được không có nghĩa là đúng; (2) mọi quyết định về dependency phải được giải trên đúng phiên bản PHP production. Ghim bằng composer config platform.php 8.4.27 để laptop dev chạy 8.5 cũng không giải ra gói chỉ hợp 8.5.

5.5 Composer 2.10 chặn cài các bản Laravel trung gian vì security advisory

Composer giờ mặc định không nạp các phiên bản bị security advisory. Hậu quả rất thực tế khi đi từng bậc 8 → 9 → 10 → 11:

$ composer require "laravel/framework:8.83.29" --dry-run
  found laravel/framework[v8.83.29] but these were not loaded, because they are affected by security
  advisories ("PKSA-d5tc-s1qs-h781", "PKSA-m5cs-t1y6-qpcs", "PKSA-3r5d-mb8f-1qw9", "PKSA-8qx3-n5y5-vvnd").

# bước 10 và 11:
Root composer.json requires laravel/framework ^10.0, found laravel/framework[v10.0.0, ..., v10.50.3] but these
  were not loaded, because they are affected by security advisories (...)
Root composer.json requires laravel/framework ^11.0, found laravel/framework[v11.0.0, ..., v11.57.0] but these
  were not loaded, because they are affected by security advisories (...)

Toàn bộ dải 10.x và 11.x đều dính (đã EOL nên các advisory 2026 không có bản sửa). Ngay cả composer create-project laravel/laravel:^8.0 ban đầu cũng âm thầm cài 8.x-dev thay vì tag ổn định vì lý do này. Cách lab xử lý: các bậc trung gian chạy với COMPOSER_NO_BLOCKING=1 chỉ trong nhánh nâng cấp, không bao giờ deploy; bậc cuối (Laravel 13) chạy lại với chặn bật, và composer audit sạch:

hop 0  Laravel 8.83.29   audit: Found 5 security vulnerability advisories affecting 2 packages.
hop 4  Laravel 12.70.1   audit: No security vulnerability advisories found.
hop 5  Laravel 13.38.0   audit: No security vulnerability advisories found.

Nhân tiện, composer audit trên Laravel 8 gốc còn liệt kê fruitcake/laravel-cors và swiftmailer/swiftmailer là gói đã bỏ (abandoned) — lý do thứ hai, ngoài PHP2Shell, để không dừng ở "vá PHP xong là đủ".

5.6 Công cụ quét tĩnh thấy gì, không thấy gì

Cài công cụ ngoài app (/opt/tools) để không đụng composer.json của app. PHPCompatibility bản ổn định 9.3.5 không biết PHP 8.4; dòng 10 mới có 10.0.0-alpha2, nên lab cài ^10.0.0@dev (kéo về dev-develop) cùng PHP_CodeSniffer 4.0.4, Rector 2.7.0, rector-laravel 2.7.0 (08-static-scan.sh).

$ phpcs -p --standard=PHPCompatibility --runtime-set testVersion 8.4 app routes config database tests
FILE: /var/www/releases/blue/app/Legacy/PriceRules.php
 14 | WARNING | Implicitly marking a parameter as nullable is deprecated since
    |         | PHP 8.4. Update the type to be explicitly nullable instead.
    |         | Found implicitly nullable parameter: $text.
FILE: /var/www/releases/blue/routes/console.php
 18 | WARNING | Closures / anonymous functions only have access to $this if
    |         | used within a class or when bound to an object using bindTo().

# vendor (laravel/framework 8, facade, nunomaduro):
A TOTAL OF 8 ERRORS AND 79 WARNINGS WERE FOUND IN 62 FILES

Rector với withPhpSets(php84: true) — lần đầu chạy từ thư mục gốc dự án báo [OK] Rector is done!, không đổi gì. Chạy cùng config từ /tmp thì ra 5 file. Lý do nằm trong source: ExplicitNullableParamTypeRector implements MinPhpVersionInterface, và Rector lấy phiên bản PHP của dự án từ composer.json ("php": "^7.3|^8.0" → 7.3). Thêm một dòng là đúng:

return RectorConfig::configure()
    ->withPaths([... 'app', 'routes', 'tests'])
    ->withPhpSets(php84: true)
    ->withPhpVersion(Rector\ValueObject\PhpVersion::PHP_84);

5 files with changes
1) app/Legacy/PriceRules.php:11
-    public function slug(string $text = null)
+    public function slug(?string $text = null)
 * ExplicitNullableParamTypeRector
2) app/Providers/RouteServiceProvider.php:56      * ClosureToArrowFunctionRector
...
Cách nói khi phỏng vấn: "Rector đọc phiên bản PHP của dự án từ composer.json, và các rule của PHP 8.4 tự tắt nếu dự án khai báo thấp hơn. Nên một lần chạy báo 'không có gì để sửa' chưa chắc là code sạch — có thể là công cụ đang nghĩ dự án còn ở 7.3. Tôi luôn khai báo phiên bản đích tường minh, hoặc nâng ràng buộc php trong composer.json trước rồi mới chạy."

Bảng tổng hợp — cái gì bị bắt bởi công cụ nào, đo trên chính app lab:

Vấn đềPHPCompatibilityRector php84Test suiteLog deprecation runtime
$price == 0 đổi nghĩakhôngkhôngchỉ ca có test ('')không (không phải deprecation)
string $text = nullcócó, tự sửaPHPUnit 10+ báo "deprecated"không (compile-time)
trim(null)khôngkhôngPHPUnit 10+ báo "deprecated"có
dynamic property OrderDto::$couponCodekhôngkhôngPHPUnit 10+ báo "deprecated"có
each(), create_function(), $s{0}có (chuyên của nó)có một phầncó nếu đi qua— (lỗi cứng)

Không có một lớp phòng thủ nào đủ. Đúng tinh thần "defense in depth" (phòng thủ nhiều lớp): quét tĩnh cho cú pháp/API đã bỏ, Rector cho thay đổi máy móc, test nghiệp vụ cho đổi nghĩa, log runtime + canary cho phần còn lại.

5.7 Hàm bị xoá, cú pháp bị bỏ — lỗi cứng, dễ thấy, vẫn phải tìm

                          php7.4                                   php8.4
each()                    Deprecated ... => array(...)             Error: Call to undefined function each()
create_function()         Deprecated ... => '\0lambda_1'           Error: Call to undefined function create_function()
money_format()            Deprecated ... => '1234.50'              Error: Call to undefined function money_format()
get_magic_quotes_gpc()    Deprecated ... => false                  Error: Call to undefined function get_magic_quotes_gpc()
implode(array, glue)      Deprecated ... => 'a,b'                  TypeError: implode(): Argument #1 ($separator) must be of type string, array given
array_key_exists(obj)     Deprecated ... => true                   TypeError: array_key_exists(): Argument #2 ($array) must be of type array, stdClass given
utf8_encode()             'café'                                   Deprecated: Function utf8_encode() is deprecated since 8.2 ... => 'café'
E_STRICT constant         2048                                     Deprecated: Constant E_STRICT is deprecated => 2048
$s{0}                     Deprecated: ... curly braces ... => a    Parse error: syntax error, unexpected token "{" (exit=255)

Đáng chú ý nhất là dòng cuối: cú pháp $s{0} làm cả file không biên dịch được, kể cả dòng echo "start" ở đầu file cũng không chạy. Một file helper cũ bị autoload là đủ làm sập toàn bộ request. Mặt tốt: php -l trên toàn bộ code với binary 8.4 bắt được hết loại này trong vài giây.

5.8 Cutover sai thứ tự: chuyển nginx khi pool 8.4 chưa chạy

Kịch bản: quên start pool 8.4 (hoặc nó fail config test) mà vẫn đổi nginx. Lab đánh tải 20 kết nối song song, chuyển sang green ở giây thứ 4, phát hiện và chuyển lại blue ở giây thứ 8 (11-cutover.sh mục 11.3):

   [t=25.25s] switched to green + nginx reload
   [t=29.32s] switched to blue + nginx reload
   ab: Complete requests:      25509
   ab: Non-2xx responses:      23150
     2373  200 php7.4-fpm-app.sock
    23150  502 php8.4-fpm-app.sock
[crit] connect() to unix:/run/php/php8.4-fpm-app.sock failed (2: No such file or directory) while connecting to upstream

Chỉ 4 giây sai đã ra 23 150 lỗi 502 — 502 trả về rất nhanh nên client bắn được nhiều request hơn bình thường (2 124 req/s so với ~320 req/s khi có PHP thật xử lý). Điều này giải thích vì sao tỷ lệ lỗi lúc cutover hỏng trông khủng khiếp hơn cảm giác "chỉ vài giây". Phòng bằng hai bước bắt buộc trước khi đổi nginx: test -S /run/php/php8.4-fpm-app.sock và gọi thử qua cổng canary nội bộ (mục 6.3 bước 7).

5.9 Chặn multipart ở nginx: biến $uri đánh lừa, chặn luôn route upload

Phiên bản đầu của snippet chặn multipart trong lab dùng $uri để chừa route /upload:

if ($uri ~ ^/upload$) { set $mp_block 0; }   # SAI

   multipart -> /echo-post          : 415
   multipart -> /upload             : 415     <- route upload cũng bị chặn

Lý do: Laravel đi qua try_files ... /index.php?$query_string — một internal redirect. Sau internal redirect, nginx chạy lại phase rewrite của server, lúc này $uri đã là /index.php, điều kiện chừa không còn khớp. Dùng $request_uri (URI gốc client gửi, không đổi qua redirect) thì đúng (10d-guard-fixed.sh):

   multipart -> /echo-post          : 415
   multipart -> /upload             : {"files":1} 200
   multipart -> /upload?x=1         : 200
   multipart -> /uploadX            : 415
   multipart -> /index.php/echo-post: 415
   urlencoded -> /echo-post         : {"fields":1,"files":0,"CONTENT_LENGTH":"3"} 200
   multipart CHUNKED -> /echo-post  : 415

Bài học tổng quát cho mọi biện pháp khẩn: viết vội trong đêm, phải test cả ca phải chặn lẫn ca không được chặn. Một rule chặn nhầm route upload thanh toán cũng là sự cố.

5.10 Sau khi bật biện pháp tạm: ba điều bất ngờ

Lab bật mitigation trên pool 7.4 (10-mitigations.sh), đo qua FPM thật:

### BEFORE (pool có php_value[memory_limit]=256M)
   memory_limit                       '256M'
   ini_set(memory_limit,128M)         '\'256M\''        <- app tự đổi được
   memory_limit_after_ini_set         '128M'
   session.upload_progress.enabled    '1'
   post_max_size                      '25M'
GET /mem.php?mb=300 -> HTTP 502

### AFTER (php_admin_value + php_admin_flag, và một .user.ini cố đặt lại memory_limit=128M)
   memory_limit                       '-1'
   ini_set(memory_limit,128M)         'false'           <- php_admin_* khoá cứng, .user.ini cũng thua
   memory_limit_after_ini_set         '-1'
   session.upload_progress.enabled    '0'
   post_max_size                      '8M'
allocated 300MB, peak=302MB, memory_limit=-1    -> HTTP 200
   CLI sees (different SAPI, different ini!): memory_limit=-1 upload_progress=1
  1. php_value vs php_admin_value là khác biệt sống còn. Với php_value, chỉ cần một dòng ini_set('memory_limit', ...) trong app (Laravel command, package import Excel hay làm vậy) là mitigation mất tác dụng. php_admin_value khiến ini_set trả false, và .user.ini cũng không đè được.
  2. memory_limit=-1 có giá: request xin 300MB trước đây bị chặn, giờ chạy được. Mười request như vậy cùng lúc trên pool pm.max_children = 8 là 2,4GB — đủ gọi OOM killer của kernel trên máy nhỏ. Đi kèm phải có giới hạn ở tầng khác: cgroup MemoryMax cho service FPM (advisory khuyến nghị chính sách giết từng worker thay vì cả master), request_terminate_timeout, và theo dõi RSS của worker. Advisory cấm dùng RLIMIT_AS/RLIMIT_DATA vì chúng làm mmap() thất bại — đúng đường lỗi dễ tổn thương.
  3. Trên bản backport 7.4.33-31, mọi lần vượt memory_limit đều giết worker → 502. Request mem.php?mb=300 ở phần BEFORE trả 502, không phải 500. Log FPM:
    WARNING: [pool app] child 14965 said into stderr: "Fatal error: Allowed memory size of 268435456 bytes exhausted (tried to allocate 314572832 bytes) ..."
    WARNING: [pool app] child 14965 exited with code 1 after 381.101344 seconds from start
    nginx: recv() failed (104: Unknown error) while reading response header from upstream
    Để tách nguyên nhân, lab dựng container sạch với cả php7.4-fpm và php8.4-fpm từ PPA, cùng php.ini, cùng pool memory_limit=128M, gửi FastCGI thẳng vào socket; thêm image chính thức php:7.4-fpm (7.4.33 build 11/2022, chưa vá) làm mốc (15-oom-worker-exit.sh):
    ---- php-fpm7.4 1:7.4.33-31 (PPA, backport)   display_errors=Off
       mem.php?mb=300   -> body_bytes=0
       memloop.php?x=1  -> body_bytes=0
         child 17068 said into stderr: "Fatal error: Allowed memory size of 134217728 bytes exhausted (tried to allocate 314572832 bytes) ...
         child 17068 exited with code 1 after 1.284299 seconds from start
         child 17067 exited with code 1 after 1.387555 seconds from start
    ---- php-fpm8.4 8.4.27-1 (PPA)   display_errors=Off
         child 17107 said into stderr: "NOTICE: PHP message: PHP Fatal error:  Allowed memory size ... exhausted ...
         (no worker exited)
    ---- php-fpm7.4 1:7.4.33-31   display_errors=On
       mem.php?mb=300   -> body_bytes=0          <- bật display_errors cũng không có gì trả về
         child 17147 exited with code 1 ...
    ---- php-fpm8.4 8.4.27-1      display_errors=On
       mem.php?mb=300   -> body_bytes=163        <- trang lỗi được trả về, worker sống
         (no worker exited)
    ### PPA no longer carries 7.4.33-30 -> cannot compare against the unpatched 7.4 build
    ---- official php:7.4-fpm (PHP 7.4.33 built: Nov 15 2022), unpatched baseline
       mem.php?mb=300   -> response_bytes=236
       memloop.php?x=1  -> response_bytes=238
         (no worker exited)
    Không phụ thuộc display_errors, không phụ thuộc kiểu OOM (một lần cấp phát lớn hay nhiều lần 1MB): 7.4 chưa vá và 8.4.27 giữ worker sống, chỉ bản backport 7.4 là thoát. Đọc commit backport (712e60f9, tự mô tả "Minimal mitigation for EOL version") thấy đúng lý do — zend_mm_safe_error() không còn zend_bailout() (tức longjmp) mà gọi hàm mới in thẳng ra stderr rồi thoát process:
    +ZEND_API ZEND_COLD ZEND_NORETURN void zend_mm_safe_error_ex(const char *format, ...)
    +	fprintf(stderr, "\nFatal error: %s in %s on line %d\n", buf, ...);
    +	_exit(1);
     ...
    -	heap->overflow = 0;
    -	zend_bailout();
    -	exit(1);
    +	zend_mm_safe_error_ex(format, limit, ..., size);
    Nhánh chính (8.4.27) làm cách tinh vi hơn — "soft OOM" ở safe point kế tiếp, chỉ _exit() khi vượt ngưỡng cao hơn (mục 4.4) — còn bản cho nhánh EOL chọn cách đơn giản và an toàn nhất: OOM là chết process. Hệ quả vận hành khi chặn máu bằng gói 7.4 backport:
    • Endpoint nào từng chạm memory_limit (export lớn, import Excel) giờ trả 502 thay vì 500, và FPM phải fork lại worker mỗi lần.
    • Laravel không biết lỗi đó: không qua exception handler, không vào storage/logs/laravel.log, không lên Sentry, register_shutdown_function không chạy. Dấu vết duy nhất là stderr của worker — tức log FPM, và chỉ khi pool có catch_workers_output = yes (dòng log không có tiền tố PHP message: vì in thẳng ra stderr, không qua error_log).
    • Một kẻ tấn công ép OOM liên tục làm worker chết liên tục — đó là cái giá chấp nhận được để đổi lấy việc không còn RCE, nhưng nên có alert trên số dòng "exited with code" của FPM.
    • Đây cũng là một lý do nữa để không dừng ở bản backport: trên 8.4.27 lỗi OOM đi lại đường bình thường.

5.11 Cài gói mới nhưng process vẫn chạy binary cũ

apt upgrade thay file /usr/sbin/php-fpm8.4 trên đĩa, nhưng process đang chạy vẫn giữ inode cũ trong RAM. Nếu postinst không restart được (container, policy-rc.d, needrestart cấu hình "list only", hoặc deploy tool khoá service), máy "đã cài bản vá" nhưng vẫn phục vụ bằng binary chưa vá. Lab giả lập bằng reinstall với restart bị chặn (13-verify.sh):

### 13.4 simulate 'apt upgraded the package but nobody restarted FPM'
         1 php-fpm: master process (/etc/php/8.4/fpm/php-fpm.conf) /usr/sbin/php-fpm8.4 (deleted)
   detector: processes whose executable was replaced on disk:
     pid 18003 /usr/sbin/php-fpm8.4 (deleted)
### 13.5 reload (USR2) is enough? master re-execs itself:
         1 php-fpm: master process (/etc/php/8.4/fpm/php-fpm.conf) /usr/sbin/php-fpm8.4
   still deleted after USR2: 0

Dấu hiệu (deleted) sau readlink /proc/<pid>/exe là cách kiểm rẻ và chắc chắn. Với FPM, reload (USR2) là đủ vì master re-exec binary mới; với mod_php thì phải restart Apache.

5.12 Trạng thái dùng chung giữa hai bản release: lab không thấy lỗi, nhưng phải kiểm

Trong lúc cutover và rollback, hai bản release cùng đọc ghi một DB, một cache, một hàng đợi. Lab cho Laravel 8 (PHP 7.4) ghi cache + đẩy job, Laravel 13 (PHP 8.4) đọc và chạy job — rồi ngược lại, đúng chiều rollback (12-shared-state.sh):

### 12.1 FORWARD: blue (7.4/L8) writes, green (8.4/L13) reads + works the queue
[PHP 8.4.27 / Laravel 13.38.0] read compat:fromL8:carbon -> Illuminate\Support\Carbon 2026-10-22T05:17:42+00:00
  PingJob handled: from=fromL8 at=Illuminate\Support\Carbon 2026-10-22T05:17:42+00:00 | worker PHP 8.4.27 Laravel 13.38.0
  closure job handled (written by fromL8) | worker PHP 8.4.27
### 12.2 ROLLBACK direction: green (8.4/L13) writes, blue (7.4/L8) reads + works the queue
[PHP 7.4.33 / Laravel 8.83.29] read compat:fromL13:carbon -> Illuminate\Support\Carbon 2026-10-22T05:17:44+00:00
  PingJob handled: from=fromL13 at=Illuminate\Support\Carbon 2026-10-22T05:17:44+00:00 | worker PHP 7.4.33 Laravel 8.83.29
  closure job handled (written by fromL13) | worker PHP 7.4.33
   failed_jobs (newest 2):            (rỗng)

Carbon 2 ↔ Carbon 3 serialize/unserialize qua lại được, job class và closure job chạy được cả hai chiều — trong lab này. Hai lưu ý để không tổng quát hoá quá đà:

  1. Lab giữ nguyên các file config/*.php của Laravel 8. Nếu nhân dịp nâng cấp mà lấy config/cache.php mặc định của Laravel 13, prefix cache đổi từ laravel_cache sang Str::slug(APP_NAME).'-cache-' (đọc trong vendor/laravel/framework/config/cache.php của lab) — cache "mất sạch" ngay lúc cutover và rollback, tải dồn xuống DB. Giữ prefix cũ bằng CACHE_PREFIX.
  2. Payload của job/cache có model hay object của package bên thứ ba thì phải test riêng; lab chỉ phủ Carbon, mảng, job class đơn giản và closure. Phần đó chưa verify.

6. Vận hành trong production: từ giờ đầu tiên tới sau cutover

6.1 Giờ đầu tiên: chặn máu khi chưa nâng cấp được

flowchart TD
  S["Phát hiện máy chạy PHP chưa vá"] --> A{"Có gói vá cho<br/>đúng nhánh + OS?"}
  A -->|"Có"| P["apt install --only-upgrade 'phpX.Y-*'<br/>+ reload FPM, verify (6.5)"]
  A -->|"Không"| M["Pool FPM: php_admin_value[memory_limit]=-1<br/>+ php_admin_flag[session.upload_progress.enabled]=off<br/>+ post_max_size 8M/32M"]
  M --> N["nginx: chặn multipart trừ route upload,<br/>client_max_body_size, xoá PHP_VALUE per-request"]
  N --> R{"Rủi ro còn lại<br/>chấp nhận được?"}
  R -->|"Không (mod_php, dữ liệu nhạy cảm)"| O["Read-only (chỉ GET/HEAD) hoặc offline<br/>(khuyến nghị NCSC)"]
  R -->|"Có, kèm WAF + theo dõi"| W["Giữ chạy, ưu tiên nâng cấp"]
  P --> Q["Dò dấu hiệu bị khai thác + xoay vòng secret"]
  O --> Q
  W --> Q

1. Kiểm kê trước khi làm gì. Tìm mọi SAPI PHP trên máy — máy "chỉ có FPM" đôi khi còn Apache + mod_php của một tool admin cũ:

dpkg -l 'php*' | awk '/^ii/ && /fpm|cgi|libapache2-mod-php/ {print $2, $3}'
ps -eo pid,cmd | grep -E 'php-fpm|php-cgi|apache2|httpd|frankenphp|lsphp' | grep -v grep
grep -rnE 'PHP_(ADMIN_)?VALUE' /etc/nginx/ /etc/apache2/ 2>/dev/null   # INI per-request = consumer của CVE-2026-109619

2. Có gói vá thì cài ngay, kể cả khi kế hoạch là nâng cấp lên 8.4 sau. Trên Ubuntu 22.04/24.04 + PPA, Laravel 8 + PHP 7.4 nhận được 7.4.33-31 có backport. Đây là việc 10 phút, không đổi hành vi ngôn ngữ, không đổi framework.

3. Không có gói vá: mitigation ở pool FPM (đã test ở mục 5.10 — nhớ dùng php_admin_*, không phải php_value):

; /etc/php/7.4/fpm/pool.d/app.conf — PHP2Shell emergency mitigation (gỡ sau khi đã lên bản vá)
php_admin_value[memory_limit] = -1
php_admin_flag[session.upload_progress.enabled] = off
php_admin_value[post_max_size] = 8M          ; 32M nếu app cần upload (khuyến nghị của php.net)
php_admin_value[upload_max_filesize] = 6M    ; luôn < post_max_size

Rồi reload và kiểm qua FPM (CLI có php.ini riêng — lab: CLI vẫn báo upload_progress=1 trong khi FPM đã là 0).

4. nginx: chặn multipart trừ route cần upload (bản đã sửa ở mục 5.9). Lab cũng kiểm hai điểm advisory nhắc về request không có Content-Length: nginx (mặc định fastcgi_request_buffering on) đọc hết body chunked rồi mới chuyển cho PHP, PHP nhận CONTENT_LENGTH thật; và client_max_body_size áp cả cho body chunked:

   chunked urlencoded body -> {"fields":1,"files":0,"CONTENT_LENGTH":"3"}
   30MB chunked -> 413
   30MB with Content-Length -> 413
[error] client intended to send too large chunked body: 26209600+65524 bytes ...
[error] client intended to send too large body: 31457280 bytes ...

Nghĩa là trên stack nginx + FPM (có buffering), lối "multipart không có Content-Length lách post_max_size" bị nginx chặn bớt ở tầng trên. Đây là quan sát với nginx 1.18 cấu hình mặc định; ai tắt fastcgi_request_buffering (để stream upload lớn) thì kết luận này không còn đúng — chưa verify với cấu hình đó.

5. Quyết định offline/read-only theo rủi ro: mod_php, dữ liệu thẻ/sức khoẻ, hệ thống có quyền vào mạng nội bộ → nghiêng về read-only (if ($request_method !~ ^(GET|HEAD)$) { return 503; } — mẫu của Sansec) hoặc offline. Đây là quyết định của business, tech lead mang tới với số liệu, không tự quyết một mình.

6. Sau khi vá: giả định đã bị dò. Theo Aikido/advisory: tìm request multipart bất thường đi kèm lỗi memory limit hoặc 500 lặp lại, cookie PHPSESSID quá dài/méo (CVE-…618 có thể rò bộ nhớ worker qua cookie này), worker FPM restart không rõ lý do, file PHP lạ/bị sửa, user admin mới, kết nối ra ngoài bất thường. Vì bộ nhớ worker có thể đã bị đọc, xoay vòng APP_KEY (cẩn thận: làm mất session/cookie mã hoá), mật khẩu DB, API key, token của hệ thống public.

6.2 Checklist trước khi nâng cấp

#Hạng mụcCách kiểm / lệnhVì sao
1OS có gói PHP 8.4.27 khôngapt-cache policy php8.4-fpmUbuntu 20.04: không có (mục 1.4) → phải lên OS mới / VM mới
2Danh sách extension đang dùngphp7.4 -m từ FPM (phpinfo) và CLIĐối chiếu sau khi cài 8.4 (mục 3.4)
3Override INI và poolls /etc/php/7.4/*/conf.d/99-*, pool.d/*.confPort phần override, không copy cả php.ini (mục 3.5)
4Mọi chỗ gọi php trầncrontab -l, /etc/cron.d, Supervisor, systemd unit, script deployAlternatives tự chuyển CLI sang 8.4 khi cài (mục 3.1)
5nginx trỏ socket nàogrep -rn fastcgi_pass /etc/nginxSocket chung php-fpm.sock tự đổi (mục 3.2)
6Phiên bản framework/packagecomposer outdated -D, composer audit, composer why-not php 8.4Lộ trình nâng Laravel (mục 6.3 bước 4)
7Độ phủ test ở chỗ so sánh lỏnggrep -rnE '[^=!]==[^=]' app/ | grep -v '===' rồi đọc tayThay đổi im lặng (mục 5.1)
8Payload đang nằm trong queue/cacheĐếm job chờ, loại object được cacheHai bản phải đọc được của nhau khi cutover/rollback (mục 5.12)
9Migration DB của bản mới có tương thích ngược khôngĐọc từng migration mớiRollback code không rollback được schema
10RAM cho hai pool cùng chạypm.max_children × RSS/worker × 2Side-by-side là gấp đôi worker trong lúc chuyển (xem bài Scale nginx + PHP-FPM cho cách tính)
11Ai trực, kênh báo, tiêu chí rollbackViết ra trước: "502 > 1% trong 2 phút hoặc lỗi nghiệp vụ X → rollback"Không quyết rollback bằng cảm giác lúc 2h sáng

6.3 Runbook từng bước (đã chạy trong lab)

Mô hình triển khai: hai thư mục release cạnh nhau, mỗi cái đi với một pool FPM, nginx chọn cặp nào.

Hai cặp release + pool FPM, nginx chọn một cặp nginx đọc symlink sites-enabled/app.conf. Symlink trỏ tới app-blue.conf (root releases/blue, socket php7.4-fpm-app.sock) hoặc app-green.conf (root releases/green, socket php8.4-fpm-app.sock). Hai pool FPM cùng chạy. Cả hai release dùng chung DB, cache, queue và .env/storage chung. Cổng 8084 nội bộ luôn trỏ vào green để test trước khi cutover. Một máy, hai cặp (release, pool) — nginx chỉ đổi symlink nginx :80 sites-enabled/app.conf → BLUE (đang chạy) releases/blue Laravel 8.83 php7.4-fpm-app.sock 7.4.33-31 rollback = trỏ symlink về đây GREEN (bản mới) releases/green Laravel 13.38 php8.4-fpm-app.sock 8.4.27 canary nội bộ 127.0.0.1:8084 app-blue.conf app-green.conf chung: DB · cache · queue · storage/ · .env
Hình 1 — Đơn vị cutover là cặp (thư mục release, pool FPM). Hai pool chạy song song nên đổi qua lại chỉ là đổi symlink + nginx -s reload; phần dùng chung ở dưới là chỗ duy nhất hai phiên bản "chạm" nhau, và là chỗ phải test tương thích hai chiều (mục 5.12).

Bước 1 — Cài 8.4 song song, chưa cho chạy.

printf '#!/bin/sh\nexit 101\n' > /usr/sbin/policy-rc.d && chmod +x /usr/sbin/policy-rc.d   # chặn auto-start
apt-get install -y php8.4-fpm php8.4-cli php8.4-{bcmath,curl,gd,igbinary,imagick,imap,intl,mbstring,mysql,opcache,pgsql,readline,redis,soap,sqlite3,xml,xmlrpc,xsl,zip}
rm -f /usr/sbin/policy-rc.d
update-alternatives --set php /usr/bin/php7.4        # cron/worker vẫn ở 7.4 cho tới cutover
update-alternatives --set php-fpm.sock /run/php/php7.4-fpm.sock 2>/dev/null || true
flowchart LR
  A["apt install php8.4-*<br/>(policy-rc.d chặn start)"] --> B["Ghim CLI và php-fpm.sock<br/>về 7.4"]
  B --> C["php -m 7.4 vs 8.4<br/>(chỉ khác 'random')"]

Bước 2 — Port cấu hình. Pool mới với socket có tên phiên bản, port 99-app.ini, bỏ directive chết, kiểm có bật chẩn đoán startup (mục 3.5). Start pool 8.4, xác nhận hai socket cùng tồn tại:

srw-rw---- 1 www-data www-data 0 Oct 22 04:44 php7.4-fpm-app.sock
srw-rw---- 1 www-data www-data 0 Oct 22 04:57 php8.4-fpm-app.sock
      1 root php-fpm: master process (/etc/php/7.4/fpm/php-fpm.conf)
      1 root php-fpm: master process (/etc/php/8.4/fpm/php-fpm.conf)
     16 www-data php-fpm: pool app

Bước 3 — Canary nội bộ: code cũ trên PHP mới. Một vhost chỉ nghe 127.0.0.1:8084, root vào release hiện tại, fastcgi_pass vào socket 8.4. Bật LOG_DEPRECATIONS_CHANNEL, chạy smoke test, so output hai cổng (mục 5.1, 5.3). Bước này trả lời "có cần sửa code/framework không" bằng dữ liệu, trước khi viết dòng nào.

Bước 4 — Nâng framework trong nhánh riêng, từng bậc, chạy test sau mỗi bậc. Tất cả chạy bằng php8.4 + Composer 2.10.3 trên bản copy (09-laravel-chain.sh). Thay đổi mỗi bậc lấy từ upgrade guide chính thức (Context7 + laravel.com):

BậcThay đổi composer.json + code (theo upgrade guide)Kết quả lab
0Giữ Laravel 8, giải lại dependency trên 8.4 (composer update)nette/schema 1.2.5→1.4.0, nette/utils 3.2.10→4.1.6; Laravel 8.83.29; tests: 1 failed, 6 passed; audit: 5 advisories / 2 packages
8→9framework ^9, collision ^6.1, facade/ignition → spatie/laravel-ignition ^1, bỏ fruitcake/laravel-cors, Kernel dùng \Illuminate\Http\Middleware\HandleCors9.52.22; tests: 1 failed, 6 passed
9→10framework ^10, sanctum ^3.2, collision ^7, phpunit ^10, laravel-ignition ^2, minimum-stability stable, bỏ processUncoveredFiles, phpunit --migrate-configuration10.50.3; tests: 3 deprecated, 1 failed, 3 passed — PHPUnit 10 bắt đầu báo deprecation
10→11php ^8.2, framework ^11, collision ^8.1, sanctum ^4; giữ cấu trúc app cũ (guide khuyên không migrate structure)11.57.0; Carbon 2.73.0 → 3.15.0
11→12framework ^12, phpunit ^11 (Carbon 3 bắt buộc)12.70.1; audit: sạch
12→13php ^8.4, framework ^13, tinker ^3, phpunit ^12, config.platform.php = 8.4.27; bật lại chặn advisory13.38.0; tests: 3 deprecated, 1 failed, 3 passed; audit: sạch
--- final composer.json (lab)
"require": { "php": "^8.4", "guzzlehttp/guzzle": "^7.2", "laravel/framework": "^13.0",
             "laravel/sanctum": "^4.0", "laravel/tinker": "^3.0" },
"require-dev": { "fakerphp/faker": "^1.9.1", "laravel/sail": "^1.0.1", "mockery/mockery": "^1.4.4",
                 "nunomaduro/collision": "^8.1", "phpunit/phpunit": "^12.0", "spatie/laravel-ignition": "^2.0" },
"config.platform": { "php": "8.4.27" }, "minimum-stability": "stable"

Lab là app skeleton + vài file legacy, nên mỗi bậc chỉ tốn một lần composer update. App thật sẽ vướng package bên thứ ba ở từng bậc — đó là lý do đi từng bậc thay vì nhảy thẳng 8 → 13: khi test đỏ, biết ngay bậc nào gây ra. Rector-laravel có thể tự sửa phần code theo từng bậc (withComposerBased(laravel: true) đọc phiên bản framework đã cài; Context7 driftingly/rector-laravel) — lab chưa chạy bộ rule này trên app thật; Laravel Shift (dịch vụ trả phí) không dùng trong lab.

flowchart LR
  L8["L8 + deps<br/>giải lại trên 8.4"] --> L9["L9<br/>ignition, cors"] --> L10["L10<br/>PHPUnit 10"] --> L11["L11<br/>Carbon 3, Sanctum 4"] --> L12["L12<br/>PHPUnit 11"] --> L13["L13<br/>php ^8.4, platform pin"]
  L13 --> T["test xanh + audit sạch<br/>+ phpcs/rector sạch"]

Bước 5 — Sửa code theo hành vi 8.4. Rector cho phần máy móc, tay cho phần nghĩa (09b-fix-code.sh):

// 7.4: return $price == 0;   ('' và 'abc' đều "miễn phí")
public function isFree(string|int|float|null $price): bool
{
    if ($price === null || $price === '') {
        return true;                        // ô trống trong CSV = mẫu miễn phí (giữ nghĩa cũ, có test)
    }
    return is_numeric($price) && (float) $price === 0.0;   // 'abc' không còn miễn phí (PO đã chốt)
}

public function slug(?string $text = null): string   // Rector: ExplicitNullableParamTypeRector
{
    return strtolower(trim($text ?? ''));            // không còn truyền null vào trim()
}

class OrderDto
{
    public ?string $couponCode = null;               // khai báo, thay vì #[\AllowDynamicProperties]
}
   PASS  Tests\Unit\LegacyBehaviourTest
  ✓ zero price is free
  ✓ paid price is not free
  ✓ empty price from csv is free
  ✓ slug handles missing input
  ✓ dto accepts extra fields
  Tests:    7 passed (8 assertions)
### PHPCompatibility on green app/ (testVersion 8.4)
   app/: clean
Cách nói khi phỏng vấn: "Hàm cũ dựa vào việc PHP 7 coi mọi chuỗi không phải số là số không. Khi sửa, tôi không cố tái tạo hành vi cũ một cách mù quáng, mà viết ra đúng quy tắc nghiệp vụ: ô trống là mẫu miễn phí, số không là miễn phí, còn chuỗi rác thì không. Phần nào là quyết định nghiệp vụ thì hỏi PO và khoá lại bằng test, để lần nâng cấp sau không phải đoán lại."

Bước 6 — Chuẩn bị release green để phục vụ. Build cache bằng đúng binary sẽ phục vụ nó (php8.4 artisan config:cache), quyền storage/, kiểm migrate:status, reload pool 8.4 (USR2) để OPcache sạch:

  Laravel Version .................................................... 13.38.0
  PHP Version ......................................................... 8.4.27
  2019_12_14_000001_create_personal_access_tokens_table .............. [1] Ran

Bước 7 — Preflight trên canary nội bộ, đúng các URL nghiệp vụ, cộng trang status của pool:

   canary /health          {"php":"8.4.27","sapi":"fpm-fcgi","laravel":"13.38.0","pid":17761}
   canary /legacy/free/0   {"price":"0","isFree":true}
   canary /legacy/free/abc {"price":"abc","isFree":false}
   canary /legacy/slug     {"slug":""}
   canary /legacy/dto      {"coupon":"SALE10"}

Bước 8 — Cutover (mục 6.4). Bước 9 — Verify (mục 6.5). Bước 10 — Chuyển cron/queue worker sang 8.4 (đổi đường dẫn trong cron/Supervisor sang release green + /usr/bin/php8.4, php artisan queue:restart), rồi update-alternatives --set php /usr/bin/php8.4. Bước 11 — Dọn: sau thời gian quan sát (đề xuất ≥ 1 tuần), tắt pool 7.4, gỡ gói 7.4, gỡ mitigation tạm (riêng session.upload_progress.enabled=0 nên giữ nếu app không dùng — advisory khuyên vậy kể cả sau khi vá).

6.4 Cutover, rollback và canary — đo dưới tải

sequenceDiagram
  participant U as Người dùng
  participant N as nginx (master + workers)
  participant B as FPM 7.4 (blue)
  participant G as FPM 8.4 (green)
  participant O as Người vận hành
  Note over B,G: Cả hai pool đang chạy, green đã qua canary :8084
  U->>N: request (đang tới liên tục)
  N->>B: fastcgi_pass php7.4-fpm-app.sock
  O->>N: ln -sfn app-green.conf + nginx -t + nginx -s reload
  Note over N: worker mới đọc config mới,<br/>worker cũ làm nốt request đang dở rồi thoát
  U->>N: request kế tiếp
  N->>G: fastcgi_pass php8.4-fpm-app.sock
  O->>N: theo dõi 5xx theo upstream_addr
  alt lỗi vượt ngưỡng
    O->>N: ln -sfn app-blue.conf + reload (rollback)
    N->>B: quay về 7.4
  end

Lab: ab -c 20 bắn liên tục /health 12 giây, chuyển ở giây thứ 5; access log ghi $upstream_addr nên đếm được request nào do pool nào phục vụ (11-cutover.sh):

### 11.1 cutover blue -> green while ab hammers /health (20 concurrent, 12 s)
   ab: Complete requests:      3949
   ab: Failed requests:        0
     2002  200 php7.4-fpm-app.sock
     1947  200 php8.4-fpm-app.sock
       20  499 php8.4-fpm-app.sock
### 11.2 rollback green -> blue under the same load
   ab: Complete requests:      3766
   ab: Failed requests:        0
     1945  200 php7.4-fpm-app.sock
     1821  200 php8.4-fpm-app.sock
       19  499 php7.4-fpm-app.sock

Đọc số cho đúng: 0 request lỗi trong cả hai chiều. 20 (và 19) dòng 499 là request ab đang dở dang lúc hết 12 giây — 499 là mã nginx ghi khi client tự đóng kết nối, đúng bằng số kết nối song song, và nằm ở pool đang phục vụ cuối cửa sổ, không phải lúc chuyển. Cutover kiểu "đổi symlink + reload" không rớt request vì nginx reload là graceful: worker cũ làm nốt request đang giữ.

Canary theo user (10%, dính theo user). Khi cả hai release cùng sống, có thể chuyển dần thay vì một lần. split_clients băm một khoá ổn định (ở đây header X-User-Id; thực tế dùng cookie/ID người dùng), map chọn socket, root theo biến:

split_clients "${http_x_user_id}" $app_color { 10% green; * blue; }
map $app_color $php_sock {
    green   unix:/run/php/php8.4-fpm-app.sock;
    default unix:/run/php/php7.4-fpm-app.sock;
}
server {
    root /var/www/releases/$app_color/public;
    location ~ \.php$ { ...; fastcgi_pass $php_sock; }
}

   1000 users ->     903 X-App-Color: blue
   1000 users ->      94 X-App-Color: green
   user 8 x20 ->      20 "php":"8.4.27"
   user 4242 x20 ->   20 "php":"7.4.33"
   status over 1000 users:    1000 200

(903 + 94 = 997: 3 request đầu rơi đúng lúc reload và không có header — status cả 1000 request ở lần chạy lại đều là 200.) Phải dính theo user, không random từng request: một người dùng nhảy qua lại giữa Laravel 8 và 13 trong cùng phiên sẽ gặp mọi khác biệt về session/cookie/CSRF giữa hai bản. Trade-off: canary theo user cần hai release dùng chung trạng thái an toàn trong nhiều giờ (mục 5.12), còn cutover một lần thì chỉ cần an toàn trong vài phút quanh lúc chuyển.

flowchart LR
  R["request + X-User-Id"] --> S{"split_clients<br/>hash(user)"}
  S -->|"10%"| G["root releases/green<br/>php8.4-fpm-app.sock"]
  S -->|"90%"| B["root releases/blue<br/>php7.4-fpm-app.sock"]
  G --> M["so 5xx, latency, lỗi nghiệp vụ<br/>giữa hai nhóm"]
  B --> M
  M -->|"ổn"| U["tăng %: 10 → 50 → 100"]
  M -->|"xấu"| D["về 0% (rollback)"]

6.5 Verify: bản vá có thật sự đang phục vụ request không

### 13.1 four different answers to 'which PHP?'
   php -v        : PHP 8.4.27 (cli) (built: Oct 21 2026 14:34:07) (NTS)
   php8.4 -v     : PHP 8.4.27 (cli) (built: Oct 21 2026 14:34:07) (NTS)
   php-fpm8.4 -v : PHP 8.4.27 (fpm-fcgi) (built: Oct 21 2026 14:34:07) (NTS)
   via nginx+FPM : {"php":"8.4.27","sapi":"fpm-fcgi","laravel":"13.38.0","pid":18011}
   package       : php8.4-fpm 8.4.27-1+ubuntu22.04.1+deb.sury.org+1
   changelog     : + (CVE-2026-109618, CVE-2026-109619): Avoid state corruption via
### 13.2 FPM status page straight from the socket (no nginx)
   pool:                 app
   process manager:      static
   start time:           22/Oct/2026:05:18:33 +0000
   accepted conn:        96
   listen queue:         0
   max children reached: 0

Mỗi dòng trả lời một câu khác nhau, và chỉ dòng "via nginx+FPM" trả lời câu hỏi thật:

  1. php -v là CLI, theo alternatives — có thể khác hẳn FPM (mục 3.1).
  2. php-fpm8.4 -v đọc binary trên đĩa, không phải process đang chạy (mục 5.11).
  3. Gói + changelog chứng minh binary trên đĩa có bản vá.
  4. Endpoint trả PHP_VERSION + php_sapi_name() qua nginx chứng minh process đang phục vụ là FPM 8.4.27. Kết hợp thêm kiểm (deleted) ở mục 5.11 thì chắc chắn binary đang chạy là binary đã vá. Endpoint này nên được bảo vệ (chỉ nội bộ), đừng public phpinfo().
# một lệnh để đưa vào script verify / Ansible / cron giám sát
for p in $(pgrep -f 'php-fpm: master'); do echo "$p $(readlink /proc/$p/exe)"; done | grep deleted \
  && echo "FPM master đang chạy binary đã bị thay — cần reload/restart"
Cách nói khi phỏng vấn: "Tôi không tin php -v trên terminal, vì đó là CLI và đọc binary trên đĩa. Tôi chứng minh bằng ba thứ: changelog gói có mã CVE, một endpoint nội bộ đi qua đúng nginx và FPM trả về phiên bản đang phục vụ, và không còn process nào chạy file thực thi đã bị gỡ khỏi đĩa — dấu hiệu nâng gói mà quên restart."

6.6 Kế hoạch rollback

Tình huốngRollbackThời gian (lab)Điều kiện để rollback an toàn
Lỗi sau cutover (5xx, lỗi nghiệp vụ)ln -sfn ../sites-available/app-blue.conf /etc/nginx/sites-enabled/app.conf && nginx -t && nginx -s reload< 1 s, 0 request lỗi (11.2)Pool 7.4 còn chạy; DB không có migration phá tương thích; cache/queue đọc được hai chiều (5.12)
Queue worker 8.4 lỗiTrỏ Supervisor về release blue + /usr/bin/php7.4, queue:restart— (chưa đo)Job do green đẩy vào phải chạy được trên blue (lab: được, với job đơn giản)
Gói 8.4.27 có lỗi mới (regression của bản vá)Không hạ về 8.4.26 (mở lại lỗ hổng). Về blue (7.4.33-31 cũng đã vá) trong lúc chờnhư trênBlue phải là bản 7.4 đã vá — đây là lý do vẫn vá 7.4 ở giờ đầu dù sắp nâng cấp
Đã tắt pool 7.4 / gỡ gói 7.4Không còn rollback nhanh; chỉ còn roll-forward (sửa trên green)—Vì vậy giữ pool 7.4 ít nhất 1 tuần
flowchart TD
  A["Cảnh báo: 5xx theo upstream php8.4 tăng<br/>hoặc lỗi nghiệp vụ"] --> B{"Pool 7.4 còn chạy?"}
  B -->|"Có"| C["Đổi symlink về app-blue.conf<br/>nginx -t && nginx -s reload"]
  B -->|"Không"| F["Roll-forward: hotfix trên green"]
  C --> D["Supervisor/cron về blue + php7.4<br/>queue:restart"]
  D --> E["Kiểm failed_jobs, cache prefix,<br/>so log 2 bên; post-mortem"]

6.7 Theo dõi sau nâng cấp

Tín hiệuLấy từ đâuÝ nghĩa
Tỷ lệ 5xx tách theo $upstream_addraccess log nginx (log_format có $upstream_addr)Phân biệt lỗi do pool mới hay do nginx/mạng
502 + "exited with code"/"exited on signal"log FPM (cần catch_workers_output = yes)Worker chết: OOM trên bản backport 7.4 (mục 5.10), segfault extension
max children reached, listen queuepm.status_pathPHP 8.x có JIT/opcache khác, RSS/worker thay đổi → tính lại pm.max_children
RSS từng worker, tổng RAMnode exporter / psĐặc biệt khi còn memory_limit=-1
Số dòng log channel deprecationsLaravel logNợ kỹ thuật cho lần nâng cấp sau (PHP 9 biến nhiều cái thành TypeError)
Lỗi nghiệp vụ so với trướcMetric business (đơn, thanh toán, import)Bắt loại "đổi nghĩa im lặng" ở mục 5.1
Request multipart bất thường, cookie PHPSESSID dài bất thườngaccess log / WAFDấu hiệu dò PHP2Shell — tiếp tục theo dõi cả sau khi vá
failed_jobsDBPayload không tương thích giữa hai bản

7. So sánh và ma trận quyết định

7.1 Năm lối đi cho một app Laravel 8 + PHP 7.4

Lối điThời gianĐóng PHP2Shell?Rủi ro hành viNợ còn lạiHợp khi
A. apt upgrade lên gói 7.4 có backport (PPA 7.4.33-31, hoặc vendor LTS)PhútCó, theo changelog vendorGần như 0Nguyên vẹn: 7.4 EOL, Laravel 8 EOL + advisory, phụ thuộc thiện chí vendor cho lần sauGiờ đầu tiên, luôn làm nếu có gói
B. Tự build 7.4 từ nhánh PHP-7.4-CVE-2026-109618-109619Giờ (build + đóng gói + test)Có, theo commitThấp, nhưng build tay dễ lệch extension/cấu hình so với gói đang chạyNhư A, cộng gánh tự duy trì binaryUbuntu 20.04 gốc, không Ubuntu Pro, không thể offline
C. Lên 8.2.35/8.3.36 giữ Laravel 8—CóCaoLaravel 8 không hỗ trợ 8.2/8.3; dependency không cài nổi (mục 5.4)Không nên. Tổ hợp không được hỗ trợ, vừa tốn công vừa không được gì lâu dài
D. PHP 8.4.27 + Laravel 13 (bài này)Ngày → sprint, tuỳ độ lớn appCóTrung bình, kiểm soát bằng test + canaryThấp: PHP vá tới 2028, Laravel tới 3/2028Đích của mọi hệ thống còn sống trên 1 năm
E. Chỉ mitigation / offline / read-onlyPhútKhông (giảm rủi ro)Thấp (offline: mất dịch vụ)Toàn bộCầu nối vài giờ tới vài ngày; hệ thống sắp khai tử

Lab đã thử B trên ubuntu:20.04, cấu hình tối thiểu (--disable-all --enable-fpm --enable-session) — đủ chứng minh khả thi và cho thấy thứ cần biết khi verify (14-build-74-backport.sh):

### branch head:
712e60f9 Avoid state corruption via bailout
004cb827 Bumb versions
### files touched by the top commit:
 main/rfc1867.c          | 39 ++++++++++++++++----     (parser multipart)
 ext/session/session.c   |  2 +-
 Zend/zend_vm_execute.h  | 94 ++++++++++++++++++++++++++++++++++++++++++++-----
 17 files changed, 240 insertions(+), 59 deletions(-)
### build took 121s
### version strings:
PHP 7.4.34-dev (cli) (built: Oct 22 2026 06:44:04) ( NTS )
PHP 7.4.34-dev (fpm-fcgi) (built: Oct 22 2026 06:44:08)

Build tối thiểu mất 121 giây trên 4 core; build đủ extension như gói thật (OpenSSL, intl, gd...) tốn hơn nhiều và trên 22.04/24.04 còn vướng OpenSSL 3 (PHP 7.4 cần OpenSSL 1.1) — chưa verify build đầy đủ. Version in ra là 7.4.34-dev: một chuỗi không tồn tại ở release chính thức nào, nên công cụ quét lỗ hổng theo version sẽ không hiểu; ghi lại commit 712e60f9 vào tài liệu vận hành làm bằng chứng.

7.2 Ba cách triển khai nâng cấp

CáchDowntimeRollbackChi phíDùng khi / tránh khi
In-place (gỡ 7.4, cài 8.4, đổi code cùng lúc trên một thư mục)Có — từ lúc dừng 7.4 tới khi 8.4 + code mới sẵn sàngCài lại 7.4 + checkout code cũ: chậm, dễ saiThấp nhấtMôi trường dev/staging. Tránh cho production
Side-by-side trên cùng máy (bài này: hai pool, hai release, symlink)0 (lab: 0 lỗi dưới tải)< 1 sRAM cho hai pool trong lúc chuyểnMáy đơn/ít máy, OS đã có gói 8.4 (22.04/24.04). Tránh khi RAM không đủ gấp đôi worker
Blue-green khác máy/VM (dựng máy mới 24.04 + 8.4, đổi LB/DNS)0 nếu LB đổi được từng phầnĐổi LB về máy cũGấp đôi hạ tầng trong thời gian chuyểnBắt buộc với Ubuntu 20.04 (không có gói 8.4); cũng là lúc trả nợ OS. Tránh khi state nằm trên đĩa local (upload, session file) chưa tách ra

Dùng khi nào, tránh khi nào: làm A ngay hôm nay nếu có gói; E chỉ là cầu nối; D là đích; chọn side-by-side nếu OS đã đủ mới, blue-green khác máy nếu OS quá cũ. Tránh C hoàn toàn. Tránh in-place cho production.

8. Góc nhìn tech lead: tác động tổ chức

Đoạn dưới viết sẵn để chép vào ADR (Architecture Decision Record — tài liệu ghi lại quyết định kiến trúc) hoặc RFC nội bộ.

Bối cảnh. PHP2Shell (CVE-2026-109618/109619) cho phép RCE không cần đăng nhập trên mọi SAPI web. App X chạy Laravel 8.83 + PHP 7.4 trên Ubuntu 22.04 (nginx + FPM). PHP 7.4 và Laravel 8 đều đã EOL; composer audit báo 5 advisory chưa có bản sửa cho Laravel 8.

Quyết định. (1) Trong ngày: nâng gói lên php7.4 7.4.33-31 (PPA, có backport), bật mitigation pool + nginx, rà dấu hiệu khai thác, xoay vòng secret. (2) Trong 2 sprint: nâng lên PHP 8.4.27 + Laravel 13, triển khai side-by-side, canary 10% theo user, giữ pool 7.4 một tuần để rollback.

Lý do không chọn PHP 8.2 + Laravel 10: cả hai hết vá trong vòng vài tháng hoặc đã hết; chi phí nâng cấp gần bằng nhau. Không chọn Laravel 12: hết security fix 24/02/2027.

Hệ quả. 1–2 dev × 1–2 sprint (ước lượng minh hoạ, phụ thuộc số package bên thứ ba và độ phủ test); RAM gấp đôi pool trong tuần chuyển đổi; cần PO chốt các thay đổi nghĩa (như 'abc' == 0).

Exit strategy. Pool 7.4 đã vá được giữ ít nhất 1 tuần; mọi migration DB trong đợt này phải tương thích ngược; cache prefix giữ nguyên.

Những điểm tác động tổ chức đáng nói thêm:

  1. Đây là nợ được "thu" bằng sự cố. Laravel 8 hết vá từ 1/2023, PHP 7.4 từ 11/2022. Gần 4 năm không ai ưu tiên nâng cấp vì "đang chạy ổn". Chi phí nâng cấp hôm nay gồm cả chi phí của 4 năm đó, cộng áp lực thời gian. Bài học cho roadmap: đưa "nhánh PHP còn security support ≥ 12 tháng" thành một tiêu chí kỹ thuật có người chịu trách nhiệm, kiểm hàng quý.
  2. Tách hai luồng làm việc, hai người chịu trách nhiệm. Luồng "chặn máu" (ops, tính bằng giờ, ít rủi ro) và luồng "nâng cấp" (dev, tính bằng sprint, rủi ro hành vi). Gộp làm một dẫn tới hoặc vội nâng cấp trong đêm, hoặc chờ nâng cấp xong mới vá.
  3. Kỹ năng cần có trong team: đọc advisory (không chỉ headline), packaging Debian (alternatives, changelog, policy-rc.d), FPM internals (USR2, OPcache), Composer resolution. Người trực 3h sáng cần runbook mục 6 in sẵn và một lệnh rollback duy nhất.
  4. Vendor lock-in kiểu mới: dựa vào PPA của một cá nhân (ondrej) hay Remi (lần này không có bản vá trong ngày, theo php.net) là phụ thuộc vào thiện chí. Với hệ thống quan trọng, cân nhắc nguồn có cam kết (gói của distro trong vòng đời hỗ trợ, Ubuntu Pro, image có SLA) và ghi rõ trong ADR.
  5. Đuôi dài. Mỗi tool admin nội bộ, cron server cũ, image Docker build từ năm 2021 là một bề mặt tấn công. Tìm hết chúng (mục 6.1 bước 1) quan trọng ngang việc nâng cấp app chính.

9. Anti-patterns và bài học thực tế

  1. Kết luận "đã vá" từ php -v. CLI ≠ FPM, binary trên đĩa ≠ process đang chạy, số version của distro ≠ trạng thái vá (mục 1.4, 5.11, 6.5).
  2. Kết luận "chưa vá" từ số version. Ngược lại cũng sai: Ubuntu 8.3.6 đã vá. Quét lỗ hổng theo version gây báo động giả và mất thời gian lúc gấp.
  3. nginx trỏ /run/php/php-fpm.sock. Cài một gói là traffic production đổi phiên bản (mục 3.2).
  4. Copy nguyên php.ini của bản cũ. Kéo theo directive deprecated, mà php-fpm -t vẫn báo successful (mục 3.5).
  5. Mitigation bằng php_value. Một dòng ini_set trong package là hết tác dụng (mục 5.10).
  6. Bật memory_limit=-1 mà không có trần ở tầng khác. Đổi một lỗ hổng lấy rủi ro OOM killer giết cả máy (mục 5.10).
  7. Tin exit code của service ... reload. Lab: in "done", không làm gì (mục 3.3).
  8. Copy vendor/ từ máy cũ. Chạy được không có nghĩa là thư viện hỗ trợ phiên bản đó (mục 5.4).
  9. Nhảy thẳng 8 → 13 trong một commit. Khi đỏ không biết do bậc nào. Đi từng bậc trong nhánh riêng, giữ commit theo bậc.
  10. Tin một công cụ quét. Không công cụ nào trong lab bắt được thay đổi nghĩa của == (mục 5.6).
  11. Tắt chặn advisory của Composer "cho nhanh" rồi quên bật lại. Chỉ tắt cho bậc trung gian trong nhánh, bậc cuối bật lại và composer audit phải sạch (mục 5.5).
  12. Gỡ pool cũ ngay sau cutover. Mất đường rollback nhanh duy nhất.
  13. Quên cron và queue worker. Web lên 8.4 nhưng schedule:run vẫn chạy release cũ, hoặc ngược lại chạy code cũ bằng PHP mới (mục 3.1).

Điều tài liệu chính thức không nói nhưng người vận hành lâu năm đều biết: bản vá khẩn là lúc tốt nhất để trả nợ nâng cấp — có ngân sách, có sự chú ý của lãnh đạo, có lý do không ai cãi. Tận dụng nó cho nâng cấp thật, đừng dừng ở "vá xong rồi".

10. Câu hỏi phụ interviewer hay hỏi tiếp

Hỏi: Dùng PHP-FPM thì có an toàn hơn mod_php với PHP2Shell không?
Đáp: An toàn hơn nhưng không an toàn. Với mod_php, OOM lúc parse body dẫn tới shutdown đọc state hỏng trước cả khi code app chạy — đã có exploit hoàn chỉnh. FPM giết worker khi startup fail nên đường đó bị chặn, nhưng advisory vẫn validate RCE trên FPM qua session upload progress khi app có thao tác để lại state. Chỉ bản vá mới đủ.

Hỏi: Ubuntu báo PHP 8.3.6, scanner nói phải ≥ 8.3.36. Anh xử lý thế nào?
Đáp: Distro backport bản vá mà giữ số upstream. Tôi đọc changelog gói tìm mã CVE; lab thấy 8.3.6-0ubuntu0.24.04.13 có CVE-2026-109618_109619.patch. Rồi kiểm process đang chạy dùng đúng binary đó. Scanner cần được cấu hình theo OVAL/USN của distro thay vì so số version upstream.

Hỏi: Tại sao không giữ Laravel 8 và chỉ nâng PHP?
Đáp: Laravel 8 chỉ hỗ trợ tới PHP 8.1, bản thấp nhất có fix chính thức là 8.2.35. Lab cho thấy lock file của Laravel 8 còn không cài được trên 8.4 (nette/utils khai báo < 8.4), và Laravel 8 tự nó có 5 advisory không bao giờ được sửa.

Hỏi: Cutover thế nào để không rớt request?
Đáp: Hai pool chạy song song với socket có tên phiên bản, mỗi pool đi với thư mục release của nó. Đổi nginx bằng symlink rồi nginx -t và reload — reload là graceful, worker cũ làm nốt request. Lab: gần 4 000 request trong lúc chuyển, 0 lỗi. Trước khi đổi, kiểm socket tồn tại và smoke test qua cổng nội bộ — lab cho thấy đổi nhầm sang pool chưa chạy là 23 000 lỗi 502 trong 4 giây.

Hỏi: Rollback có thật sự dễ như đổi symlink?
Đáp: Phần nginx thì có. Khó là phần dùng chung: migration DB phải tương thích ngược, cache và job do bản mới ghi phải đọc được ở bản cũ, cache prefix không được đổi. Lab test hai chiều cho Carbon 2/3, job class, closure job — đều chạy; object của package khác phải tự test.

Hỏi: memory_limit=-1 nghe như tắt một lớp bảo vệ, sao advisory lại khuyên?
Đáp: Vì exploit cần một OOM có thể đoán trước do memory_limit tạo ra. Bỏ giới hạn là bỏ cái cò súng đó, đổi lại rủi ro một request ngốn hết RAM. Nên phải dùng php_admin_value để app không đè được, kèm giới hạn cgroup/timeout, và chỉ để tạm tới khi vá.

Hỏi: Làm sao biết code có chỗ nào đổi hành vi lặng lẽ?
Đáp: Không có công cụ nào bắt hết. Tôi xếp lớp: php -l và PHPCompatibility cho cú pháp/hàm bị xoá, Rector cho thay đổi máy móc (nhớ khai phiên bản đích), PHPUnit 10+ để thấy deprecation trong test, log channel deprecations trên canary cho runtime, và test nghiệp vụ + so output hai pool cho các phép so sánh lỏng.

Phụ lục: lab đã chạy

Môi trường: host Linux 6.8, Docker 29.3.1, 4 vCPU / 7 GB RAM. Container "server cũ" tmp-phpup-web từ ubuntu:22.04 + nginx 1.18.0 + PPA ondrej/php (PHP 7.4.33-31, 8.4.27-1) + Composer 2.10.3. App: laravel/laravel v8.6.12 + 3 file legacy + 5 test. Không có systemd: reload FPM bằng kill -USR2 (tương đương ExecReload của unit file). Toàn bộ container đã xoá sau khi chạy.

Script (thư mục labs/security/php84-upgrade/, output thật trong out/):

ScriptLàm gìMục
00-probe-repos.sh, 01-probe-changelogs.shMỗi Ubuntu lấy được PHP nào, changelog có CVE không1.4
Dockerfile.base, 03-setup-old.shDựng server cũ: 7.4-FPM + nginx + Laravel 8—
04-install-84.shCài 8.4 song song, parity extension3.1, 3.4
05-repro.sh + files/repro/r*.phpBreaking change 7.4 vs 8.45.1, 5.2, 5.7
06-migrate-config.sh, 06b-deprecation-log.shPort ini/pool, chạy Laravel 8 trên 8.4, log deprecation3.5, 5.1, 5.3
07-generic-socket-trap.shBẫy php-fpm.sock3.2
08-static-scan.shPHPCompatibility + Rector5.6
09-laravel-chain.sh, 09b-fix-code.shLaravel 8 → 13 từng bậc, sửa code5.4, 5.5, 6.3
10-mitigations.sh, 10b-oom-behaviour.sh, 10d-guard-fixed.shMitigation, OOM 8.4.26 vs 8.4.27, guard nginx4.4, 5.9, 5.10, 6.1
11-cutover.shCutover/rollback dưới tải, cutover hỏng, canary5.8, 6.4
12-shared-state.shCache/queue hai chiều L8 ↔ L135.12
13-verify.shVerify bản vá, process (deleted)5.11, 6.5
14-build-74-backport.shBuild 7.4 từ nhánh backport trên 20.047.1
15-oom-worker-exit.shOOM: 7.4 backport vs 8.4.27 vs 7.4 chưa vá, worker có sống không5.10
cd labs/security/php84-upgrade
docker build -t tmp-phpup-base -f Dockerfile.base .
./03-setup-old.sh > out/03-setup-old.log 2>&1
./04-install-84.sh > out/04-install-84.log 2>&1      # ...lần lượt tới 13
docker rm -f tmp-phpup-web                              # dọn

Chưa verify (ghi rõ để người đọc không hiểu nhầm là đã test):

Nguồn