Kinh nghiệm · Lessons Learned
Phát hiện sai không phải là sai
Báo cáo này đã được ẩn danh. Nó mô tả một sự cố thật mà không nêu tên máy chủ, địa chỉ hay dữ liệu khách hàng. Trọng tâm không phải lỗ hổng, mà là chuỗi chứng cứ: cần kiểm tra gì khi phải quyết định trong vài giờ liệu một hệ thống đã bị xâm nhập hay chưa — và việc đọc sai kết quả dễ đến mức nào?
Xuất phát điểm
Trong vòng một ngày, dự án PHP công bố hai lỗ hổng nghiêm trọng có thể khai thác mà không cần xác thực (CVE-2026-109618 và CVE-2026-109619, mỗi lỗi CVSS 9,2, được gọi chung là “PHP2Shell”). Đối tượng ảnh hưởng là các cài đặt PHP phục vụ yêu cầu web. Máy chủ liên quan chạy một reverse proxy, một phiên bản Nextcloud trên PHP-FPM và một số trang tĩnh. Câu hỏi của người vận hành rất đơn giản:
Chúng ta có bị ảnh hưởng không? Bản vá có đang hoạt động không? Và log có cho thấy ai đó đã khai thác lỗ hổng không?
Ba câu hỏi, ba bài toán chứng cứ rất khác nhau. Khó khăn nằm ở đó.
Bước 1 — chứng minh phạm vi ảnh hưởng thay vì phỏng đoán
Phản xạ đầu tiên (“chúng ta dùng PHP nên bị ảnh hưởng”) là quá thô. Điều quyết định là SAPI: chỉ những cài đặt xử lý yêu cầu web mới bị phơi bày. Việc chứng minh vì thế rất cụ thể:
- Kiểm kê môi trường PHP: chỉ cài
fpmvàcli— không cómod_php, không cóphp-cgi, không có Apache. - Điểm phơi bày: đúng một ứng dụng PHP sau proxy (Nextcloud) và một cổng quản trị chỉ trong LAN; bên cạnh là một trang Adminer bị bỏ rơi mà socket PHP đã không còn từ nhiều tuần.
- Không có “PHP ở nơi khác”: các trang tĩnh không có khối FastCGI không phải bề mặt tấn công PHP.
Bước 2 — chứng minh bản vá: gói và thời gian chạy
Một bản cập nhật bảo mật đã cài chưa phải là bản vá đang hoạt động. Giữa “gói nằm trên đĩa” và “tiến trình đang chạy dùng nó” là một lần khởi động lại FPM master. Vì vậy đã kiểm tra hai lần:
| Kiểm tra | Kết quả | Điều được chứng minh |
|---|---|---|
| Phiên bản gói | 8.5.4-0ubuntu1.5 (bản vá Ubuntu, USN-8948-1/-2) | Bản vá đã được cài |
| Thời điểm cài | 2026-10-22 06:12 (cập nhật tự động) | Khi nào bản vá đến |
| Khởi động lại FPM | 2026-10-22 06:13, master hoạt động từ đó | Bản vá đang chạy |
| Toàn vẹn tệp | kiểm tra gói không sai lệch | Không có binary bị sửa |
Bốn dòng này là chứng cứ thực sự của bản vá. Mọi thứ khác là diễn giải log.
Bước 3 — bốn lớp log, bốn câu hỏi khác nhau
Một tệp log không thể chứng minh một sự cố. Mỗi lớp trả lời một câu hỏi khác — và có điểm mù riêng:
| Lớp | Trả lời | Điểm mù |
|---|---|---|
| Log truy cập proxy | Yêu cầu nào đã đến, với mã trạng thái và kích thước thân yêu cầu nào? | Không có nội dung; vòng quay log giới hạn khả năng nhìn lại |
| Log ứng dụng (Nextcloud) | Ứng dụng đã làm gì bên trong — lỗi, cảnh báo, đăng nhập? | Ở mức info chỉ có sự kiện, không có truy cập |
| Log FPM | Worker sụp, cạn kiệt bộ nhớ, lỗi nghiêm trọng | Chỉ mức PHP, không có ngữ cảnh HTTP |
| Journald / kernel | OOM kill, segfault, core dump | Không có ngữ nghĩa ứng dụng |
Bổ sung: Fail2ban (dò mật khẩu SSH) và trình quản lý gói (thời điểm vá). Chỉ sự kết hợp mới mang lại một khẳng định — và chỉ khi các điểm mù được nêu rõ.
Phát hiện sai — “ứng dụng không ghi log”
Việc tìm log ứng dụng đã quét các thư mục thông thường (/srv, /var, /home, độ sâu giới hạn) và không thấy gì. Kết luận hiển nhiên: không có log. Kết luận đó sai.
Hai điều đã bị bỏ sót:
- Thư mục dữ liệu nằm ngoài phạm vi tìm kiếm. Phiên bản không lưu dữ liệu trong thư mục chương trình mà trên một volume riêng. Việc tìm kiếm rất kỹ — chỉ là không tìm ở đó.
- Logger có cơ chế dự phòng. Nếu tệp log đã cấu hình không tồn tại và thư mục của nó không ghi được, ứng dụng ghi vào tệp thay thế trong thư mục dữ liệu. Đó là hành vi đúng — nhưng nó che giấu một cấu hình sai.
Lỗi thực sự nằm ở tầng sâu hơn: tác vụ quay log đọc đường dẫn log từ cấu hình. Vì đường dẫn đó không tồn tại, nó so sánh kích thước của một tệp không có — và không bao giờ quay log. Kết quả: một tệp duy nhất 438 MB thay vì quay theo giờ.
logfile = thư-mục-chương-trình/data"] --> L{"Tệp có tồn tại?"} L -->|không| F["Logger dùng dự phòng:
thư-mục-dữ-liệu/nextcloud.log"] L -->|có| W["Logger ghi tệp đã cấu hình"] C --> R["Tác vụ quay log đọc
cùng đường dẫn đã cấu hình"] R --> N{"Tệp có ở đó?"} N -->|không| X["Không quay log
(tệp tăng không giới hạn)"] F --> X
Bài học này không dễ chịu vì nó liên quan đến chính công việc của chúng ta: “không tìm thấy” không phải là một phát hiện chừng nào phạm vi tìm kiếm chưa được ghi lại. Một phát hiện phủ định cần sự cẩn trọng như một phát hiện khẳng định — bao gồm việc nêu rõ nơi không tìm kiếm.
Phát hiện sai thứ hai — tường lửa
Một cổng quản trị được cho là “chỉ truy cập được trong LAN”. Việc kiểm tra không thấy quy tắc cho phép riêng cho cổng đó và ghi nhận: không có quy tắc cho phép, vậy là đóng từ bên ngoài. Điều đó cũng quá vội.
Thực tế tồn tại một quy tắc chung cho toàn bộ subnet LAN, cho phép mọi cổng. Vậy cổng đó mở trong LAN — sự bảo vệ đến từ cấu trúc mạng, không từ quy tắc cổng. Kết quả (“không truy cập được từ internet”) đúng, nhưng lý do thì không. Và một lý do sai là khởi đầu của quyết định sai tiếp theo.
Nguyên tắc: đọc tường lửa như một tổng thể — chính sách mặc định, các quy tắc cho phép chung, thứ tự. Chỉ nhìn “thiếu quy tắc cho cổng này?” là không đủ.
Log thực sự cho thấy gì
Sau khi sửa đường dẫn log, việc đánh giá mới có thể thực hiện — và bức tranh khá bình thường:
- 18.244 yêu cầu HTTP trong mười ngày; ứng dụng PHP không trả lời bất kỳ yêu cầu nào bằng 5xx.
- Thân yêu cầu lớn nhất: 25 KB. Không có thân yêu cầu ≥ 1 MB — con đường điển hình của cạn kiệt bộ nhớ không xuất hiện trong lưu lượng quan sát được.
- Log ứng dụng: 14.518 mục, 0 lỗi nghiêm trọng, chỉ một lỗi (không kết nối được app store), không có đăng nhập thất bại, không cài ứng dụng nào.
- Tìm dấu hiệu trên 789.528 dòng (các hàm webshell điển hình, đường dẫn
/tmp/): mỗi mẫu đều bằng không. Các kết quả trùng tên shell quen thuộc hoá ra chỉ là chuỗi ngẫu nhiên trong mã yêu cầu. - FPM và kernel: không lỗi nghiêm trọng, không OOM kill, không segfault.
- Thư mục web: không có tệp PHP mới từ đầu kỳ quan sát, không thay đổi bất thường.
Kết quả “nóng” duy nhất là một máy quét gửi bốn yêu cầu khoảng 1 MB tới trang Adminer bị bỏ rơi — đúng mẫu mà một nỗ lực khai thác tạo ra. Cả bốn đều kết thúc bằng 502 vì socket PHP không tồn tại. Cuộc tấn công chạy vào một backend đã chết. Đó cũng là một kết quả: phát hiện nguy hiểm nhất lại chứng minh hiệu quả của một biện pháp gia cố (ngoài ý muốn).
Giới hạn của chứng cứ — và vì sao phải bảo toàn bằng chứng
Hai quy tắc lưu trữ giới hạn khẳng định:
- Log proxy: quay 5 × 10 MB — khoảng mười ngày nhìn lại. Kỳ quan sát được phủ đầy đủ, không hơn.
- Log ứng dụng: vòng quay chỉ giữ đúng một tệp trước đó. Ở lần quay kế tiếp, kỳ đã đánh giá sẽ bị ghi đè.
Vì vậy kỳ đánh giá đã được bảo toàn trước lần quay kế tiếp dưới dạng một kho lưu riêng: nén, chủ sở hữu root, quyền 640 (tệp chứa tên người dùng và địa chỉ IP), kèm tổng kiểm tra được ghi lại. Một chứng cứ không thể tái tạo có rất ít giá trị trong kiểm toán — tổng kiểm tra biến một tệp thành một khẳng định có thể kiểm chứng.
(một tệp đã quay)"] --> B["Trích xuất kỳ
2026-10-15 … 2026-10-25"] B --> C["Kho nén gzip
root:root 640"] C --> D["SHA-256 trong báo cáo"] D --> E["Tái tạo:
gunzip + wc -l"]
Điều đã làm tốt
- Chứng cứ bản vá giữ hai lần (phiên bản gói và thời gian chạy) — khẳng định quyết định không phụ thuộc vào log.
- Chứng cứ phủ định thay vì phỏng đoán: không tệp PHP mới, không 5xx, không OOM kill, không lỗi nghiêm trọng.
- Nhận ra cuộc tấn công vào backend đã chết — phát hiện 502 ngăn một đánh giá bị thổi phồng.
- Quyết định có lý do, không chỉ tuyên bố: nghĩa vụ báo cáo đã được xem xét và bác bỏ kèm lý do, không bị bỏ qua âm thầm.
Lần sau chúng ta sẽ làm khác
- Ghi lại phạm vi tìm kiếm trước khi tìm. Chỉ viết “không tìm thấy” khi đã nêu đường dẫn và độ sâu đã quét.
- Mỗi khẳng định về cấu hình cần một phép đối chứng. Đường dẫn log: không chỉ đọc, mà kiểm tra tệp có tồn tại, có tăng và có quay hay không.
- Kiểm tra vòng quay log một cách chủ động (vượt ngưỡng kích thước có chủ đích, chứng minh trong log) — không giả định là nó chạy.
- Đọc tường lửa như một tổng thể (chính sách mặc định, quy tắc subnet chung, thứ tự).
- Dỡ bỏ ngay các trang bị bỏ rơi. Một trang PHP chết không phải rủi ro, nhưng là nguồn nhiễu vĩnh viễn trong log và là điểm mù trong đánh giá.
- Bảo toàn bằng chứng trước khi quay log — kèm chủ sở hữu, quyền và tổng kiểm tra.
- Sửa các phát hiện sai, không làm mượt chúng. Báo cáo chứa phần đính chính kèm ngày và nguyên nhân. Một phát hiện sai được sửa âm thầm phá hoại niềm tin nhiều hơn chính lỗi đó.
Khả năng chuyển giao
Bản thân sự cố không kịch tính: đã vá, không bị khai thác, không cần thông báo. Điều có giá trị là bộ máy chứng cứ: phạm vi ảnh hưởng qua kiểm kê, bản vá qua gói và thời gian chạy, khai thác qua nhiều lớp log, đánh giá với giới hạn chứng cứ nêu rõ, bằng chứng kèm tổng kiểm tra — và một báo cáo có lịch sử thay đổi, trong đó có cả sai sót của chính chúng ta. Cấu trúc này có thể được quản lý phiên bản trong Git và vẫn kiểm chứng được sau nhiều năm.
Nguồn
- Dự án PHP: thông báo bảo mật cho
CVE-2026-109618vàCVE-2026-109619. - CERT-EU, Security Advisory 2026-018 (biện pháp giảm thiểu và tình hình khai thác).
- Ubuntu Security Notices USN-8948-1/-2 (bản vá gói cho PHP 8.5).
- Công bố của nhà sản xuất thiết bị vành đai: không sử dụng PHP.