"Vá lỗi thì cứ nâng phiên bản là xong" — nghe đơn giản tới mức người ta hay bỏ qua ba câu hỏi thật: vá thực ra sửa cái gì, lỗ hổng có biến mất hẳn sau khi vá không, và bạn còn phơi nhiễm bao lâu giữa lúc bản vá ra và lúc bạn thật sự dùng nó? Bài này trả lời cả ba trên một lỗ hổng có thật và nổi tiếng: lỗi thực thi mã tùy ý (RCE) qua yaml.load của PyYAML.
Tôi tái dựng nó trong container dùng một lần với một payload YAML độc hại, đo trên nhiều phiên bản, 3 lần mỗi phiên bản, tự dọn. Payload chỉ tạo một file đánh dấu /tmp/PWNED để đo "mã có chạy không" mà không làm gì hại; toàn bộ chạy trên máy của mình, không nhắm vào hệ thống của ai khác. Và có một chỗ tôi tự đo sai một cách rất đáng kể — kể ở giữa bài.
Lỗ hổng thật: yaml.load từng chạy mã tùy ý
YAML không chỉ mô tả dữ liệu; cú pháp của nó có thẻ đặc biệt !!python/object/apply cho phép gọi một hàm Python bất kỳ. Payload của tôi lợi dụng đúng thẻ đó:
!!python/object/apply:os.system ["touch /tmp/PWNED"]
Nếu trình phân tích YAML tin thẻ này, nó sẽ gọi os.system("touch /tmp/PWNED") — tức chạy lệnh shell của kẻ tấn công. Trên bản PyYAML cũ (trước 2019), yaml.load mặc định làm đúng như vậy:
pyyaml 3.13 yaml.load() -> ma doc hai CHAY? CO_BI_RCE
Giống hệt qua 3 lần. Chỉ cần đưa một chuỗi YAML từ người dùng vào yaml.load, kẻ tấn công đã chạy được lệnh trên máy chủ. Đây là loại lỗ hổng nghiêm trọng nhất — thực thi mã từ xa — và nó nằm trong một dòng code trông vô hại mà hàng vạn dự án từng viết.
Vá là gì: đổi mặc định, không chỉ đổi số phiên bản
Đội PyYAML sửa lỗi này từ bản 5.1 (năm 2019) theo một cách đáng chú ý: họ đổi hành vi mặc định. Từ 5.1, yaml.load không còn tự dùng trình nạp nguy hiểm; nó cảnh báo rồi (ở các bản sau) đòi bạn chỉ định Loader một cách tường minh. Đo trên bản mới:
pyyaml 6.0.3 yaml.load() -> khong (TypeError: doi hoi Loader)
pyyaml 6.0.3 yaml.safe_load() -> khong (ConstructorError: chan thẻ python)
Trên bản 6.0.3, gọi yaml.load(payload) như xưa thì thất bại ngay với TypeError — nó không chịu chạy nếu bạn không nói rõ dùng trình nạp nào. Và safe_load thì chặn thẳng thẻ python/object với ConstructorError. Mã độc không chạy. Đây là điểm tinh tế: "vá" ở đây không phải sửa một dòng tính toán sai, mà là đổi mặc định từ nguy hiểm sang an toàn — để lập trình viên bình thường, viết dòng đơn giản nhất, tự động rơi vào đường an toàn thay vì đường nguy hiểm.
Chỗ tôi tự đo sai — và vì sao nó là bài học
Lần chạy đầu tiên, tôi viết một đoạn "khéo": nếu yaml.load(payload) ném TypeError trên bản mới, tôi bắt lỗi rồi gọi lại với Loader=yaml.Loader cho "chạy được". Kết quả làm tôi giật mình: bản 6.0.3 mới nhất báo CO_BI_RCE — nghĩa là bản đã vá vẫn dính? Suýt nữa tôi kết luận "nâng lên bản mới không cứu được".
Sai. Bản mới không dính. Cái dính là chính tay tôi: khi bắt TypeError rồi thêm Loader=yaml.Loader, tôi đã cố ý chọn lại trình nạp nguy hiểm — đúng cái mặc định an toàn vừa gạt đi. Tôi tự mở lại lỗ hổng rồi đổ cho phiên bản. Đây không phải lỗi vô hại để bỏ qua, vì nó phản chiếu chính xác cách lỗ hổng này sống sót qua bản vá trong thực tế: lập trình viên nâng thư viện, gặp TypeError, tra Stack Overflow, và "sửa" bằng cách thêm Loader=yaml.Loader để hết lỗi — vô tình dán lại đúng lỗ hổng mà bản vá vừa bịt. Đo lại cho đúng khẳng định điều đó:
pyyaml 6.0.3 Loader=yaml.Loader -> CO_BI_RCE
pyyaml 6.0.3 yaml.unsafe_load() -> CO_BI_RCE
Bản mới nhất vẫn cho bạn cửa thoát để chạy lại hành vi cũ nếu bạn khăng khăng đòi. Vá đổi mặc định, nhưng không thể cấm bạn tự chọn lại đường nguy hiểm. Nên "đã nâng phiên bản" chưa chắc là "đã an toàn" — nếu code của bạn còn gọi yaml.Loader hay unsafe_load, cửa vẫn mở.
Nghịch lý: bản vá công khai là tấm bản đồ cho kẻ tấn công
Có một điều làm cửa sổ phơi nhiễm nguy hiểm hơn vẻ ngoài. Khi một bản vá được công bố, nó thường đi kèm mô tả lỗ hổng và, với mã nguồn mở, cả đúng dòng code đã sửa. Kẻ tấn công đọc changelog và bản vá để làm điều ngược lại: từ chỗ được sửa, họ suy ra chỗ trước đó bị hở, và viết exploit cho những ai chưa vá. Đây gọi là lỗ hổng "n-day": không phải bí mật (0-day) nữa, mà đã công khai — và chính sự công khai đó biến mọi hệ thống chưa vá thành mục tiêu dễ. Nói cách khác, thời điểm bản vá ra đời cũng là thời điểm đồng hồ tấn công bắt đầu chạy nhanh hơn, chứ không phải chậm lại. Càng nấn ná ở bản cũ sau khi vá đã ra, bạn càng đứng giữa một tấm bia mà ai cũng có bản đồ.
Vì không phải lúc nào cũng vá được tức thì — có lịch phát hành, có kiểm thử hồi quy — nên thực tế cần cả giảm thiểu tạm thời trong lúc chờ: tắt tính năng dính lỗ hổng nếu không dùng, thêm một lớp kiểm đầu vào chặn payload đã biết, hoặc một luật ở tường lửa ứng dụng. Đó không phải bản vá thật, chỉ là thu hẹp cửa sổ trong lúc bản vá thật đi qua quy trình. Với ca PyYAML, "giảm thiểu tạm thời" gần như trùng với bản vá: đổi mọi yaml.load thành yaml.safe_load là việc một dòng, làm được ngay mà không cần chờ nâng phiên bản.
Thời gian phơi nhiễm: bất đối xứng chết người
Phần cuối là về thời gian. Đặt hai đồng hồ cạnh nhau:
- Khai thác chạy trong vài mili giây, tự động, lặp vô hạn. Một kẻ tấn công quét cả Internet tìm endpoint nhận YAML, bắn payload, thu máy chủ — không tốn công người.
- Vá tính bằng ngày tới tuần: biết có lỗ hổng, kiểm bản vá không làm hỏng gì, qua quy trình duyệt, triển khai.
"Thời gian phơi nhiễm" (exposure window) là khoảng bạn còn chạy bản cũ — hoặc còn gọi loader cũ — sau khi bản vá đã có. Bất đối xứng nằm ở chỗ: kẻ tấn công chỉ cần cửa mở một lần để vào; bạn phải giữ cửa đóng ở mọi khoảnh khắc. Một cửa sổ vài ngày nghe ngắn, nhưng với một exploit tự động thì vài phút cũng đủ. Đó là lý do các đội nghiêm túc coi việc rút ngắn cửa sổ này — quét phụ thuộc tự động (như bài "Chuỗi cung ứng" đã đo), cập nhật nhanh, chặn build khi có CVE nghiêm trọng — quan trọng ngang với việc vá đúng.
Thử ba mươi giây
Tìm trong dự án Python của chính bạn xem còn đường nạp YAML nguy hiểm nào không:
grep -rnE 'yaml\.load\(|yaml\.unsafe_load\(|Loader=yaml\.Loader|Loader=yaml\.UnsafeLoader' .
Mỗi dòng hiện ra là một cửa cần đóng. Cách sửa gần như luôn là đổi sang yaml.safe_load(...). Nếu không có dòng nào, và bản PyYAML của bạn từ 5.1 trở lên, bạn đang ở đường mặc định an toàn — nhưng hãy nhớ bài học ở giữa bài này: đừng "sửa" một TypeError tương lai bằng cách dán Loader=yaml.Loader vào, vì đó đúng là tự tay mở lại cánh cửa mà bản vá đã đóng giúp bạn.