Tất cả bài viết
22 tháng 10, 2026·11 phút đọc·Long Nguyen · CTO, ERPFit
securityphphostingdockerdevops

Vá lỗ hổng RCE lõi PHP tháng 10/2026: shared hosting panel vs container, và những cái bẫy của phiên bản cũ

CVE-2026-109618/109619 là lỗi RCE ở lõi PHP. Cách chúng tôi kiểm kê, giảm thiểu và vá trên cả hosting panel lẫn container — cùng 12 cái bẫy khiến báo cáo 'đã vá' sai sự thật.

Mục lục

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:

  1. Lọc danh sách về những package apt thực sự có bản cài (Candidate khác (none)).
  2. 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 panelContainer
Tốc độ váNhanh cho nhánh được hỗ trợ: apt upgrade một lần cho mọi site cùng nhánhPhải build lại image và tạo lại container cho từng ứng dụng
Bán kính ảnh hưởngLớn: một tiến trình FPM chung nhánh phục vụ nhiều khách hàngNhỏ: mỗi ứng dụng một image, một phiên bản
Nhánh hết hạnPanel giữ 5.6–8.1 "cho tiện" — bề mặt không vá được, luôn chạyChỉ 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 ẩnphp.ini bị ghi đè khi nâng cấp; cấu hình theo panelDockerfile lệch với image thật; image upstream (wordpress:latest) mất drop-in khi recreate
RollbackPhụ 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

  1. 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).
  2. Disable — không chỉ stop — FPM của nhánh không phục vụ ai.
  3. Giảm thiểu bằng drop-in conf.d, cho cả nhánh đã disable; kiểm tra bằng binary FPM.
  4. Vá với cổng kiểm tra là phiên bản binary, không phải exit code hay "đã restart".
  5. Canary trước; không có bằng chứng ⇒ dừng.
  6. 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.
  7. Lên kế hoạch nâng nhánh cho mọi thứ dưới 8.2 — có diễn tập.
LN
Long Nguyen
CTO, ERPFit

CTO của ERPFit. Trực tiếp xây dựng và vận hành hạ tầng kỹ thuật, bộ công cụ Craft và nền tảng dịch vụ cho doanh nghiệp Việt Nam.

Chia sẻ:𝕏FBin