Tóm tắt
Ngày 21/10/2026, PHP phát hành bản vá ngoài lịch cho hai lỗ hổng thực thi mã từ xa (RCE) ở lõi PHP: CVE-2026-109618 và CVE-2026-109619. Điểm CVSS v4.0 theo bên công bố là 9.2 (con số 10.0 lan truyền trên mạng không phải từ nguồn chính thức). Phạm vi được báo cáo: PHP 5.4 trở lên — nghĩa là gần như mọi website PHP còn đang chạy. Chỉ các nhánh 8.2, 8.3, 8.4 và 8.5 nhận bản vá từ upstream; 5.6 → 8.1 đã hết vòng đời và sẽ không bao giờ được vá.
Bài này không nhắc lại thông cáo. Nó ghi lại cách chúng tôi làm, và quan trọng hơn, những chỗ mà một quy trình vá "trông có vẻ xong" thực ra chưa xong.
Điều đã được công bố — và điều chưa
Trong những giờ đầu, thông tin công khai rất mỏng: không nêu thành phần bị lỗi, không nêu điều kiện khai
thác, không có dấu hiệu nhận biết (IoC). Thông báo trên php.net thậm chí không nhắc tên hai CVE — mã
CVE đến từ bên nghiên cứu và các nguồn thứ cấp. Phân tích của bên thứ ba chỉ ra một lỗi use-after-free
trên heap trong đường xử lý tiến trình upload của session (php_session_rfc1867_callback, ext/session),
và cho rằng đây là một trong hai đường khai thác.
Bài học đầu tiên: khi thông tin còn mỏng, đừng chờ có đủ chi tiết mới hành động. Bắt đầu từ thứ bạn kiểm soát được: kiểm kê.
Bước 1 — Kiểm kê trước, vá sau
Câu hỏi đầu tiên không phải "vá thế nào" mà là "PHP đang chạy ở đâu, phiên bản nào, phục vụ site nào". Câu trả lời của chúng tôi bất ngờ ở hai điểm:
- PHP chỉ có mặt trên một phần nhỏ hệ thống. Phần lớn dịch vụ của chúng tôi viết bằng TypeScript/Bun hoặc Rust. Biết điều này sớm giúp khoanh vùng cả cuộc ứng cứu.
- Hosting panel cài sẵn mọi nhánh PHP mà nó hỗ trợ, kể cả các nhánh đã hết vòng đời, và chạy PHP-FPM cho tất cả, kể cả những nhánh không có website nào dùng. Máy chủ shared hosting lúc đó đúng như vậy.
Bẫy 1: hosting panel cài và chạy mọi nhánh PHP
Các control panel phổ biến (HestiaCP, và nhiều panel tương tự) cài sẵn nhiều nhánh PHP để khách hàng tự chọn. Hệ quả là các tiến trình FPM của 5.6, 7.0, 7.1… vẫn chạy dù không phục vụ ai — bề mặt tấn công không thể vá, mà cũng không mang lại giá trị gì.
Cách xử lý: disable (không chỉ stop) các dịch vụ FPM không phục vụ site nào. Một dịch vụ chỉ bị
stop sẽ quay lại ở lần reboot tiếp theo — đó là bề mặt tấn công bị hoãn lại, không phải bị loại bỏ.
Bẫy 2: pool "dummy" làm kiểm kê đếm sai
Panel tạo một pool dummy cho mỗi nhánh đã cài. Nếu script kiểm kê chỉ loại trừ www.conf, mọi nhánh
đều trông như đang phục vụ site. Kết quả đi theo hướng nguy hiểm nhất: nó che mất chính những dịch vụ
có thể tắt ngay. Phải loại trừ cả dummy.conf.
Bẫy 3: thông báo lỗi bị đọc thành số phiên bản
Khi quét container bằng docker exec <c> php -v, những container không có PHP (nginx, redis,
postgres…) trả về OCI runtime exec failed … — và với một số runtime, chuỗi lỗi này nằm trên stdout.
Một bộ lọc kiểu awk '{print $2}' sẽ ghi nhận container nginx là "PHP phiên bản runtime". Báo cáo của
chúng tôi lúc đầu phóng đại số lượng "PHP hết hạn đang phục vụ" lên gần mười lần vì hai bẫy 2 và 3.
Cách sửa: chỉ chấp nhận chuỗi có dạng phiên bản thật N.N.N.
Một phát hiện sai tốn kém hơn không có phát hiện nào: nó hướng cửa sổ bảo trì vào những container không hề có PHP.
Bước 2 — Giảm thiểu trong lúc chờ bản vá
Bẫy 4: dòng bị comment trong php.ini nghĩa là BẬT
Trong mọi file php.ini chúng tôi kiểm tra, dòng
;session.upload_progress.enabled = On đều bị comment. "Bị comment" đọc lên như "đang tắt" — nhưng thực
tế là giá trị mặc định khi biên dịch (On) được áp dụng. Chúng tôi tắt tính năng này bằng một
drop-in trong conf.d/, không sửa trực tiếp php.ini, vì lần nâng cấp package tiếp theo có thể ghi
đè php.ini. Drop-in được đặt cho cả các nhánh đã disable — để nếu ai đó bật lại dịch vụ, nó không
quay về trạng thái On.
Bẫy 5: kiểm tra binary FPM, không phải CLI
php -i đọc cấu hình của CLI. Thứ phục vụ website là FPM, đọc một bộ ini khác. Kiểm tra đúng là
gọi chính binary FPM của từng nhánh với -i. Có ảnh container còn không có file php.ini nào
(Loaded Configuration File: (none)) — chạy hoàn toàn bằng mặc định biên dịch, tức là tính năng đang
bật.
Bẫy 6: "Permission denied" không phải là một chẩn đoán
Với các image hạ quyền người dùng, docker exec không ghi được vào thư mục cấu hình. Công cụ của chúng
tôi từng diễn giải lỗi đó thành "image này không có PHP" — sai, và một container suýt bị bỏ qua. Dùng
docker exec -u root, và luôn in nguyên văn lỗi, đừng suy ra nguyên nhân từ một lần ghi thất bại.
Lưu ý: đây là giảm thiểu, không phải khắc phục — theo phân tích công khai nó chỉ chặn một trong hai đường khai thác. Trước khi tắt, chúng tôi đánh giá ảnh hưởng: hầu hết ứng dụng chỉ mất thanh tiến trình upload, không mất chức năng upload.
Bước 3 — Vá, và chứng minh là đã vá
Bẫy 7: apt hủy cả giao dịch, nhưng script vẫn "xanh"
Lệnh apt-get install --only-upgrade với toàn bộ danh sách package php8.x-* thất bại vì
php8.x-json — một package chuyển tiếp không còn bản cài (json đã vào lõi từ PHP 8.0). Toàn bộ giao dịch
bị hủy, không package nào được nâng cấp — nhưng script vẫn chạy tiếp, restart FPM và in báo cáo gọn
gàng. Chỉ một bước bắt được: đọc phiên bản từ chính binary sau khi vá. Hai quy tắc rút ra:
- Lọc danh sách về những package apt thực sự có bản cài (Candidate khác
(none)). - Không bao giờ coi "đã restart" là "đã vá". Cổng kiểm tra là phiên bản binary.
Bẫy 8: kho Sury chỉ giữ phiên bản hiện tại
Kế hoạch rollback dựa trên việc tải lại các file .deb cũ — nhưng kho PPA/Sury chỉ giữ phiên bản mới
nhất, nên phiên bản trước đó không tải lại được nữa. Một máy có bản .deb cũ trong cache, máy kia thì
không. Nếu biến "có bản sao .deb" thành điều kiện bắt buộc, máy quan trọng hơn sẽ không được vá —
đánh đổi một RCE chắc chắn lấy một rollback giả định. Điều kiện cứng hợp lý là: ghi lại phiên bản và sao
lưu toàn bộ cây /etc/php, thứ luôn lấy được.
Bẫy 9: điều kiện kích hoạt phải so với bản ổn định hôm nay
Chúng tôi tự động hóa việc vá: một timer kiểm tra mỗi giờ, khi bản sửa lỗi xuất hiện thì sao lưu, vá máy canary trước, kiểm tra từng website, rồi mới vá máy còn lại. Điều kiện kích hoạt phải là "bản ứng viên mới hơn bản ổn định của ngày hôm nay", không phải "mới hơn bản đang cài" — nếu không, một máy chỉ đơn giản là chậm cập nhật bản tháng trước sẽ "phát hiện ra bản vá" và vá… chẳng có gì mới.
Bẫy 10: cổng canary đã đi qua nhờ… không có bằng chứng
Ở phiên bản đầu, nếu không thu được kết quả kiểm tra website nào sau khi vá canary, số "site bị hỏng" là 0 — và cổng cho qua. "Tôi không thu được gì" bị đọc thành "không có gì hỏng". Bây giờ: không có kết quả kiểm tra ⇒ chưa xác minh ⇒ dừng.
Container: nhanh hơn, nhưng có bẫy riêng
Bẫy 11: nginx giữ IP cũ của container PHP-FPM
Sau khi build image PHP mới và tạo lại container, website trả về File not found. — thông báo của
chính FPM. Nguyên nhân: nginx phân giải fastcgi_pass php-laravel:9000 một lần lúc nạp cấu hình.
Container mới nhận IP mới, nginx vẫn gửi request tới IP cũ — lúc này lại thuộc về container FPM khác,
nơi không có mã nguồn ứng dụng. DNS nội bộ của Docker trả lời đúng suốt thời gian đó, nên kiểm tra DNS sẽ
không thấy gì. Cách xử lý: restart (hoặc reload) nginx trong cùng thao tác tạo lại container PHP, hoặc
dùng resolver + biến trong fastcgi_pass để nginx phân giải lại.
Bẫy 12: Dockerfile trong repo không còn khớp với image đang chạy
Dockerfile trong repo thiếu hai dòng so với image đang chạy production: dòng cài extension redis.
Nếu build lại từ repo, ta có một image chạy được, nhưng âm thầm mất redis — Laravel sẽ lỗi ở chỗ khó
đoán. Chúng tôi đồng bộ Dockerfile về đúng thực tế trước khi tạo biến thể PHP 8.3, và cổng kiểm tra
là so sánh danh sách extension hai chiều, không phải "build thành công".
Hosting panel vs container — so sánh thực tế
| Tiêu chí | Shared hosting panel | Container |
|---|---|---|
| Tốc độ vá | Nhanh cho nhánh được hỗ trợ: apt upgrade một lần cho mọi site cùng nhánh | Phải build lại image và tạo lại container cho từng ứng dụng |
| Bán kính ảnh hưởng | Lớn: một tiến trình FPM chung nhánh phục vụ nhiều khách hàng | Nhỏ: mỗi ứng dụng một image, một phiên bản |
| Nhánh hết hạn | Panel giữ 5.6–8.1 "cho tiện" — bề mặt không vá được, luôn chạy | Chỉ có đúng nhánh ứng dụng cần; nhưng image cũ có thể nằm im nhiều năm |
| Kiểm kê | Phải hiểu cấu trúc pool của panel (dummy, www) | Phải xử lý container không có PHP, image hạ quyền |
| Rủi ro ẩn | php.ini bị ghi đè khi nâng cấp; cấu hình theo panel | Dockerfile lệch với image thật; image upstream (wordpress:latest) mất drop-in khi recreate |
| Rollback | Phụ thuộc kho package (thường chỉ giữ bản mới nhất) | Giữ tag image cũ — rollback rẻ và chắc chắn |
Kết luận của chúng tôi: không có mô hình nào miễn nhiễm. Panel vá nhanh nhưng dễ tích tụ phiên bản cũ; container cô lập tốt nhưng dễ trôi giữa "mã nguồn" và "thứ đang chạy".
Phiên bản cũ và mã nguồn cũ — rủi ro thật sự
- Nhánh PHP hết hạn không bao giờ được vá. 5.6 → 8.1 không có bản sửa cho lỗi này. Lựa chọn duy nhất là nâng nhánh — và đó là một dự án, không phải một lệnh.
- Framework hết hạn cần diễn tập. Một ứng dụng Laravel 9 (đã EOL) chưa từng chạy trên PHP 8.3. Chúng tôi không suy luận từ changelog: chúng tôi diễn tập trên một bản sao warm standby có cùng bộ container, so sánh trang chủ từng byte với bản cũ, kiểm tra log lỗi và deprecation. Lưu ý giới hạn: diễn tập theo GET không bao phủ thanh toán, trang quản trị hay tác vụ định kỳ.
- Plugin premium không có license không thể cập nhật. Một bộ plugin đa ngôn ngữ từ 2021 không có license đã làm một website lỗi 500 liên tục sau khi WordPress tự cập nhật core. Khi một bản vá PHP đòi nâng nhánh, chính những plugin này sẽ chặn đường. Hãy rà soát và nói chuyện với khách hàng về license trước sự cố, không phải sau.
- Bản sao chạy cùng mã nguồn cũng là mục tiêu. Vá máy chính mà quên máy standby có cùng bộ ứng dụng là để lại một bản sao dễ bị tấn công.
Checklist rút gọn
- Kiểm kê: PHP ở đâu, nhánh nào, phục vụ site nào (loại trừ pool dummy, chỉ chấp nhận
N.N.N). - Disable — không chỉ stop — FPM của nhánh không phục vụ ai.
- Giảm thiểu bằng drop-in
conf.d, cho cả nhánh đã disable; kiểm tra bằng binary FPM. - Vá với cổng kiểm tra là phiên bản binary, không phải exit code hay "đã restart".
- Canary trước; không có bằng chứng ⇒ dừng.
- Container: restart nginx cùng lúc với recreate FPM; so extension hai chiều; giữ Dockerfile khớp với image thật.
- Lên kế hoạch nâng nhánh cho mọi thứ dưới 8.2 — có diễn tập.