Có một loại vật thể trong truyện trinh thám khiến người ta rùng mình: một lá thư mà đọc xuôi là lời chúc mừng vô hại, đọc theo một quy tắc khác lại là lệnh ám sát. Người gác cổng kiểm tra "đây có phải lá thư thật không?" — đúng, thư thật, chữ thật, con dấu thật — rồi cho qua, vì câu hỏi anh ta hỏi không phải câu hỏi cần hỏi. Tệp polyglot trong bài này đúng là lá thư đó: nó vừa là một ảnh PNG thật một trăm phần trăm, vừa là mã chạy được, và mọi phép kiểm "có phải ảnh không" đều trả lời "phải" một cách trung thực.
Cho phép người dùng tải tệp lên — ảnh đại diện, tài liệu, đính kèm — là một trong những tính năng nguy hiểm nhất một ứng dụng có thể có. Người dùng đưa dữ liệu tuỳ ý vào máy chủ của bạn, và nếu bạn xử lý sai, dữ liệu đó trở thành mã chạy được. Tôi dựng lại bốn cách khai thác upload, và chứng minh cách nguy hiểm nhất bằng một cuộc thực thi mã thật.
Toàn bộ chạy trong container tôi tự dựng, tự dọn. Tôi chỉ tấn công máy chủ do chính mình tạo, không nhắm vào hệ thống của ai.
Bốn cách khai thác
- Đuôi tệp giả: đặt tên tệp php là
avatar.png. Nếu máy chủ tin đuôi tên, nó lưu một tệp thực thi vào thư mục web. - Polyglot: một tệp vừa là ảnh PNG hợp lệ vừa chứa mã. Nó qua được cả phép kiểm "có phải ảnh không".
- Path traversal: đặt tên tệp là
../../../etc/cron.d/xđể ghi ra ngoài thư mục upload. - DoS bằng dung lượng: tải một tệp khổng lồ để làm đầy đĩa hoặc hết RAM.
Chứng minh: polyglot thực thi mã thật
Đây là phần đáng sợ nhất, và tôi đo nó bằng một máy chủ PHP+Apache thật. Tôi tạo một tệp 118 byte: một PNG 1×1 hợp lệ, rồi nối mã PHP vào cuối. Kết quả đo, ba phần:
| Kiểm tra | Kết quả |
|---|---|
| Pillow (thư viện ảnh) mở tệp | đọc được — PNG 1×1 hợp lệ |
Phục vụ với đuôi .png |
Apache trả nội dung thô, không thực thi |
Phục vụ với đuôi .php |
RCE-THANH-CONG:uid=33(www-data) |
Dòng cuối là một cuộc thực thi mã từ xa (RCE) thật: mã PHP nhúng trong "ảnh" chạy lệnh id trên máy chủ và trả về danh tính tiến trình web (www-data). Từ đó kẻ tấn công chạy được lệnh tuỳ ý. Một tệp 118 byte, một cửa mở toang.
Chỗ phản trực giác nhất
"Kiểm xem có phải ảnh không" là không đủ — vì tệp này thật sự là ảnh. Đây là điều làm tôi thấy cần gằn giọng nhất. Rất nhiều hướng dẫn khuyên "kiểm magic bytes" hoặc "thử mở bằng thư viện ảnh". Polyglot của tôi qua cả hai: Pillow mở nó ra thành một ảnh PNG 1×1 hoàn chỉnh. Nó không phải ảnh giả — nó là ảnh thật và đồng thời là mã PHP thật. Không có phép kiểm nội dung nào phân biệt được, đơn giản vì không có gì để phân biệt: cả hai cùng đúng.
Cái cứu bạn không phải kiểm nội dung, mà là không bao giờ thực thi thứ được tải lên. Nhìn hai dòng cuối bảng: cùng một tệp, đuôi .png thì vô hại, đuôi .php thì RCE. Khác biệt duy nhất là máy chủ có thực thi nó hay không. Cách chặn thật: lưu tệp ngoài thư mục web (không phục vụ trực tiếp), đặt tên ngẫu nhiên với đuôi cố định do bạn quyết định (không dùng tên hay đuôi client gửi), và cấu hình thư mục upload không bao giờ chạy mã. Làm đúng ba điều đó thì một polyglot dù tinh vi tới đâu cũng chỉ nằm im như một tệp dữ liệu chết.
basename chặn path traversal — nhưng chỉ theo hệ điều hành đang chạy. Đây là cái bẫy tôi đo được và thấy đáng nhớ: os.path.basename('../../../etc/passwd') trên Linux trả về passwd — chặn đúng. Nhưng os.path.basename('..\\..\\windows\\system32\\x') trên Linux trả về nguyên chuỗi ..\..\windows\system32\x, vì Linux không coi \ là dấu phân tách thư mục. Một tên tệp dùng dấu \ lọt qua basename trên máy chủ Linux, và nếu đường dẫn đó được một thành phần khác (hay một hệ Windows) diễn giải, traversal thành công. Bài học: đừng chỉ dựa vào basename; sinh tên tệp hoàn toàn mới thay vì làm sạch tên client.
Đuôi giả thậm chí không cần polyglot
Trước khi bàn polyglot, đáng nhớ rằng cách đơn giản nhất vẫn hiệu quả nếu máy chủ ngây thơ. Trong phép đo, một tệp chứa <?php system($_GET['cmd']); ?> đặt tên avatar.png: nhận dạng theo tên cho ra .png (tưởng là ảnh, cho lưu vào thư mục web), còn nhận dạng theo byte đầu cho ra .php — mã thực thi. Nếu máy chủ chỉ tin đuôi tên client gửi, nó lưu một web shell vào đúng chỗ nó sẽ chạy.
Đây là lý do nhận dạng phải theo nội dung chứ không theo tên. Polyglot là cấp độ khó hơn — khi kẻ tấn công làm cho cả nhận-dạng-theo-nội-dung cũng bị qua — nhưng phần lớn upload bị khai thác vẫn chỉ vì tin đuôi tên. Con số hai cách nhận dạng cho ra hai kết quả khác nhau trên cùng một tệp là toàn bộ vấn đề, cô đọng lại.
Vì sao bốn cách này cùng một gốc
Bốn cách khai thác chia làm hai nhóm, theo hậu quả:
- Đuôi giả và polyglot dẫn tới thực thi mã — nghiêm trọng nhất. Gốc chung: máy chủ đối xử với tệp người dùng như mã thay vì dữ liệu. Cách chặn cũng chung: đảm bảo tệp tải lên không bao giờ chạy.
- Path traversal và DoS dẫn tới ghi sai chỗ và cạn tài nguyên. Gốc chung: tin dữ liệu client (tên tệp, dung lượng) mà không kiểm. Cách chặn: sinh tên mới, giới hạn dung lượng trước khi đọc vào bộ nhớ.
Sợi chỉ xuyên suốt giống mọi bài bảo mật khác trong sê-ri: dữ liệu người dùng là dữ liệu, không phải lệnh — và không phải tên đường dẫn, không phải đuôi tệp, không phải kích thước tin được. Mọi thuộc tính của tệp mà client kiểm soát đều phải bị nghi ngờ và thay bằng giá trị do máy chủ quyết định.
Nghĩa là gì trong công việc hằng ngày
- Sinh tên tệp mới hoàn toàn phía máy chủ (UUID + đuôi cố định), đừng dùng hay "làm sạch" tên client. Điều này chặn cả path traversal lẫn đuôi giả trong một bước.
- Lưu tệp tải lên ngoài web root, phục vụ chúng qua một handler kiểm quyền, không để web server chạy trực tiếp. Thư mục upload phải được cấu hình không thực thi.
- Giới hạn dung lượng trước khi đọc. Kiểm
Content-Lengthvà dừng đọc khi vượt ngưỡng, đừng nạp cả tệp vào bộ nhớ rồi mới kiểm. - Nhận dạng kiểu theo nội dung để hiển thị đúng, nhưng đừng coi đó là phép bảo mật. Magic bytes hữu ích để đặt
Content-Type, nhưng như polyglot cho thấy, chúng không đảm bảo tệp chỉ là ảnh.
Cùng một cái bẫy "dữ liệu hoá ra là mã", đổi ngăn xếp
Chuyện "tệp người dùng bị đối xử như mã" không riêng gì PHP+Apache — mỗi nền tảng có phiên bản RCE-qua-upload của nó.
Trong thế giới Java, cái bẫy kinh điển là upload một tệp .jsp (hoặc một .war) vào đúng thư mục mà servlet container phục vụ — Tomcat gặp .jsp là biên dịch và chạy nó, y hệt Apache với .php. Và Java còn một cửa nguy hiểm hơn nhiều: nếu ứng dụng deserialize một tệp người dùng gửi (Java serialization), một payload dựng khéo có thể thành RCE mà chẳng cần đuôi tệp nào — đó là cả một họ CVE. Cách chặn vẫn quy về đúng nguyên tắc: đừng để đầu vào người dùng chạm tới một cơ chế biến dữ liệu thành mã.
Trong Go, một binary tĩnh không có "trình thông dịch" để vô tình chạy tệp upload như PHP/JSP, nên RCE trực tiếp khó hơn. Nhưng cái bẫy dời sang chỗ khác: http.ServeFile và filepath.Join với tên tệp client là đường path traversal kinh điển, và image.Decode trên ảnh người dùng vẫn dính các lỗ giải mã (decompression bomb, lỗ trong thư viện ảnh). Go buộc bạn tường minh, nhưng không miễn nhiễm với "tin tên tệp client".
Còn ở cơ sở dữ liệu, họ hàng gần nhất là các tính năng nạp tệp: LOAD DATA INFILE của MySQL hay COPY FROM PROGRAM của PostgreSQL — nếu một lỗ SQL injection với tới chúng, kẻ tấn công đọc tệp máy chủ hoặc thậm chí chạy lệnh. Cùng một cấu trúc: một đầu vào tưởng là "dữ liệu" chạm tới một cơ chế coi nó như "đường dẫn" hay "lệnh".
Mẫu số chung: một tệp — hay bất cứ blob dữ liệu nào người dùng gửi — chỉ nguy hiểm khi hệ thống của bạn có một chỗ diễn giải nó thay vì chỉ lưu nó; và phòng thủ luôn là cắt đứt con đường từ dữ liệu người dùng tới bộ diễn giải đó. Đừng để web server thực thi thư mục upload, đừng deserialize đầu vào không tin cậy, đừng ghép tên client vào đường dẫn hệ thống. Nên câu hỏi đúng khi làm tính năng upload không phải "tệp này có phải ảnh không" — polyglot đã cho thấy câu đó vô nghĩa — mà là "sau khi lưu, có chỗ nào trong hệ thống của tôi diễn giải tệp này không, và tôi đã bịt chỗ đó chưa".
Muốn tự soát, tìm hai điều trong mã nếu ứng dụng của bạn cho tải tệp lên. Thứ nhất: tên tệp lưu xuống đến từ đâu — từ request (client) hay do bạn sinh mới? Nếu từ client, bạn có thể có path traversal. Thứ hai: tệp được lưu ở đâu, và web server có phục vụ trực tiếp thư mục đó không? Nếu thư mục upload nằm trong web root và server có thể chạy mã ở đó, một polyglot như trong bài này — một "ảnh" thực thi được — là một đường thẳng tới RCE.