Container Next.js Bị Chiếm Quyền Đào Coin: Bài Học Xương Máu Về Root, Docker và UFW
Container thường được xem như một lớp isolation đủ mạnh để giảm đáng kể rủi ro cho ứng dụng. Process nằm trong namespace riêng, filesystem riêng, network stack riêng; khi có sự cố, container cũng có thể bị xóa và dựng lại từ image.
Nhưng isolation không đồng nghĩa với security.
Một container vẫn có thể bị khai thác thông qua ứng dụng đang chạy bên trong. Và khi ứng dụng đó chạy với quyền root, một lỗ hổng ở tầng application có thể nhanh chóng trở thành quyền kiểm soát hoàn toàn runtime.
Sự việc dưới đây là một ví dụ khá điển hình: một container Next.js trên Production đột nhiên chiếm gần toàn bộ CPU, bên trong xuất hiện cryptominer, process được ngụy trang dưới tên next-server, có watchdog tự hồi sinh sau khi bị kill và để lại cơ chế persistence.
Điều đáng nói là source code của ứng dụng hoàn toàn sạch.
Vấn đề nằm ở runtime và cách container được triển khai.
1. Khi CPU 400% không còn là một lỗi ứng dụng thông thường
Ban đầu, dấu hiệu chỉ là một container web có mức sử dụng tài nguyên bất thường:
- CPU tăng lên khoảng 380–400%, gần như chiếm trọn 4 core.
- RAM tăng lên khoảng 2.4 GB.
- Restart container khiến CPU trở về bình thường.
- Nhưng khoảng 5 phút sau, CPU lại tăng vọt.
Nếu đây chỉ là memory leak, resource usage thường sẽ tăng theo thời gian. Nếu nguyên nhân là traffic, access log và request rate cũng phải có dấu hiệu tương ứng.
Nhưng một process biến mất rồi quay trở lại sau một khoảng thời gian gần như cố định lại là một câu chuyện khác.
Nó cho thấy có khả năng tồn tại một cơ chế đang chủ động duy trì sự sống cho process đó.
Và khi kiểm tra trực tiếp bên trong container bằng docker top, docker diff cùng các process và file runtime, dấu vết bắt đầu khớp với một kịch bản khác: container đã bị inject mã độc đào tiền ảo ngay sau khi khởi chạy.
Không có malicious code nào trong repository.
Thứ được thêm vào nằm hoàn toàn ở runtime.
2. Bên trong container thực sự đang chạy thứ gì?
Mẫu hành vi quan sát được thuộc kiểu tấn công thường thấy trong các chiến dịch cryptojacking nhắm vào Linux và container environment.
Điểm nguy hiểm của loại malware này không nằm ở việc nó chỉ chạy một miner.
Nó thường đi cùng cả một chuỗi:
[ Hacker / Botnet ]
│
│ Khai thác RCE / exposed service
▼
[ Container chạy ROOT ]
│
├──► Download malicious binary
│
├──► Process spoofing
│ └── "next-server"
│
├──► Watchdog
│ └── tự khởi động lại miner
│
└──► Persistence
├── cron
├── startup scripts
└── các cơ chế tự kích hoạt khác
Nói cách khác, đây không phải một process chạy nhầm CPU.
Đây là một payload được thiết kế để sống sót sau khi admin phát hiện và xử lý sự cố.
2.1. Crypto miner: thứ thực sự đốt CPU
Binary độc hại được tìm thấy tại:
/app/jxz
Đây là một ELF 64-bit executable được đóng gói bằng UPX.
Khi chạy, process sử dụng gần như toàn bộ CPU được cấp cho container để thực hiện mining.
Đây là lý do biểu đồ monitoring nhìn giống một ứng dụng đang bị overload hơn là một server đang bị compromise: CPU đơn giản là bị chiếm hết bởi một workload mà ứng dụng không hề tạo ra.
2.2. Process spoofing: biến malware thành một process "có vẻ hợp lệ"
Một kỹ thuật đáng chú ý là malware không nhất thiết để nguyên tên process của mình.
Nó có thể thay đổi argv[0] để process xuất hiện dưới những cái tên quen thuộc như:
next-server
sh
Trong một container Next.js, việc nhìn thấy next-server sử dụng CPU cao chưa chắc ngay lập tức khiến admin nghi ngờ.
Đây chính là giá trị của process spoofing: không cần ẩn process hoàn toàn, chỉ cần khiến nó trông đủ bình thường.
Khi đó, người vận hành có thể nhìn thẳng vào process độc hại mà vẫn nghĩ mình đang nhìn process của ứng dụng.
2.3. Watchdog: lý do "restart xong 5 phút lại chết"
Trong container còn tồn tại một process ẩn có tên:
.xwlpzeskuqhz --guard
Tên file bắt đầu bằng dấu . khiến nó không xuất hiện khi sử dụng ls thông thường.
Vai trò của process này không phải phục vụ application.
Nó đóng vai trò watchdog: theo dõi miner và khởi động lại nó khi process chính bị kill hoặc biến mất.
Đây là mảnh ghép giải thích hiện tượng:
docker restart
↓
CPU bình thường
↓
watchdog hoạt động
↓
miner được khởi động lại
↓
CPU lại tăng
Vì vậy, docker restart trong trường hợp này không thực sự xử lý được nguyên nhân.
Nó chỉ làm process độc hại biến mất trong một khoảng thời gian ngắn.
2.4. Persistence: làm thế nào malware sống sót?
Payload không chỉ dựa vào một watchdog.
Nó còn có thể để lại các cơ chế persistence thông qua những vị trí như:
/etc/crontabs/root
/etc/cron.d/
/etc/profile.d/
Mục tiêu của persistence rất đơn giản: đừng để một lần kill process biến thành một lần mất quyền kiểm soát.
Đây cũng là lý do việc chỉ xóa binary miner là chưa đủ.
3. Root Cause: Tại sao container có thể bị chiếm quyền?
Điểm quan trọng nhất của sự cố không phải là miner.
Miner chỉ là payload cuối cùng.
Câu hỏi cần trả lời là: tại sao kẻ tấn công có thể đưa payload vào container và có đủ quyền để thiết lập persistence?
Trong trường hợp này, vấn đề nằm ở nhiều lớp cấu hình cộng dồn.
3.1. Container chạy dưới quyền root
Docker mặc định chạy process trong container với user root nếu image không chỉ định user khác.
Ví dụ:
FROM node:20-alpine
WORKDIR /app
COPY . .
CMD ["npm", "start"]
Dockerfile này hoàn toàn có thể build và chạy bình thường.
Nhưng vì không có:
USER ...
process của ứng dụng sẽ chạy với UID 0.
Điều này biến một RCE từ:
Application compromise
↓
Root inside container
↓
Write files
↓
Download binary
↓
chmod +x
↓
Create persistence
thành một chuỗi tấn công hoàn chỉnh.
Khi attacker chỉ có quyền của một user không đặc quyền, nhiều hành động trong chuỗi trên sẽ thất bại.
Khi attacker đã là root, phần còn lại trở nên đơn giản hơn rất nhiều.
3.2. Cái bẫy "Docker tự động bypass UFW"
Một misconception phổ biến là:
Đã bật UFW và chỉ allow 80/443 thì các port khác đã bị chặn.
Điều này không đúng khi host đang sử dụng Docker và container được publish port theo cách thông thường.
Docker tự tạo các firewall/NAT rules để phục vụ networking và port publishing. Vì vậy, traffic tới published container ports có thể được xử lý trước các rule INPUT/OUTPUT mà UFW quản lý.
Ví dụ:
services:
mysql:
image: mysql:8.0
ports:
- "3306:3306"
redis:
image: redis:alpine
ports:
- "6379:6379"
Nếu không chỉ định địa chỉ bind, Docker có thể publish port trên toàn bộ interface của host:
0.0.0.0:3306
0.0.0.0:6379
Khi đó, service vốn chỉ cần phục vụ nội bộ lại có thể trở thành mục tiêu quét trực tiếp từ Internet.
Vì vậy, chỉ nhìn:
ufw status
không đủ để kết luận rằng:
3306 = closed from Internet
6379 = closed from Internet
Phải kiểm tra từ góc nhìn của một máy bên ngoài.
3.3. Writable layer: tại sao docker restart không xóa được malware?
Đây là một điểm rất dễ hiểu nhầm.
Khi container đang chạy, mọi file được tạo hoặc thay đổi bên trong container sẽ nằm trên writable layer.
Read-only image layers
+
Writable container layer
=
Container filesystem
docker restart chỉ dừng và khởi động lại container.
Nó không xóa writable layer.
Vì vậy, những file như:
/app/jxz
.xwlpzeskuqhz
/etc/crontabs/root
vẫn còn nguyên sau:
docker restart <container>
Đó là lý do malware có thể tiếp tục hồi sinh.
Muốn loại bỏ writable layer của container bị nhiễm, cần destroy chính container đó.
4. Khắc phục: Đừng cố "dọn" một container đã bị compromise
Khi container đã bị attacker ghi file, sửa cron hoặc thiết lập persistence, việc chỉ xóa binary miner rồi restart container không phải một phương án đáng tin cậy.
Không thể biết chắc attacker đã để lại bao nhiêu thay đổi khác.
Đối với một container production đã bị compromise, cách an toàn hơn là loại bỏ runtime bị nhiễm và dựng lại từ source/image sạch.
4.1. Destroy container và image bị nhiễm
docker rm -f <container_name>
docker rmi <image_name>
Sau đó build image lại từ source đã được kiểm tra.
Không nên restore nguyên filesystem của container cũ sang container mới nếu mục tiêu là loại bỏ hoàn toàn dấu vết runtime.
4.2. Chạy application bằng non-root user
Docker hỗ trợ chỉ định user runtime thông qua USER.
Ví dụ:
FROM node:20-alpine AS runner
WORKDIR /app
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 appuser
COPY . .
USER appuser
EXPOSE 3000
CMD ["node", "server.js"]
Nếu application bị RCE, attacker giờ chỉ có quyền của appuser.
Điều đó không biến container thành "an toàn tuyệt đối", nhưng nó làm giảm đáng kể blast radius.
Thay vì:
RCE
↓
root
↓
write system files
↓
persistence
ta có:
RCE
↓
non-root user
↓
limited filesystem permissions
↓
fewer persistence options
Đây là một trong những lớp hardening có chi phí thấp nhưng hiệu quả rất cao.
4.3. Không publish service nội bộ ra Internet
Service chỉ phục vụ Nginx hoặc các container khác không nên bind trực tiếp lên toàn bộ network interface của host.
Ví dụ:
services:
app:
ports:
- "127.0.0.1:3000:3000"
mysql:
image: mysql:8.0
ports:
- "127.0.0.1:3306:3306"
Docker cho phép chỉ định IP cụ thể khi publish port.
Tốt hơn nữa, với service chỉ được truy cập từ container khác, không cần publish port ra host ngay từ đầu.
Ví dụ:
services:
app:
depends_on:
- mysql
mysql:
image: mysql:8.0
Application có thể giao tiếp với MySQL thông qua Docker network:
mysql:3306
thay vì:
host:3306
Khi đó database không cần xuất hiện trên public interface.
4.4. Giảm khả năng ghi filesystem bằng read-only root filesystem
Một lớp hardening khác là:
services:
app:
read_only: true
tmpfs:
- /tmp
Ý tưởng rất đơn giản:
Application
↓
Không thể tùy ý ghi filesystem
↓
Khó drop binary
Khó sửa file hệ thống
Khó tạo persistence
read_only không phải security boundary tuyệt đối và không thể thay thế non-root hoặc network isolation.
Nhưng nó làm giảm đáng kể khả năng attacker biến một RCE thành runtime persistence.
Ứng dụng vẫn có thể được cấp quyền ghi có chọn lọc thông qua volume hoặc tmpfs cho những thư mục thực sự cần thiết.
5. Bài học quan trọng hơn cả con miner
Sự cố này nhìn bề ngoài giống một vụ cryptojacking đơn giản.
Nhưng nếu chỉ dừng ở việc "xóa miner", chúng ta sẽ bỏ qua nguyên nhân thực sự.
Vấn đề nằm ở chuỗi đặc quyền:
Exposed service / application vulnerability
↓
RCE
↓
Container chạy root
↓
Ghi filesystem tùy ý
↓
Persistence + watchdog
↓
Cryptominer
Mỗi mắt xích đều có thể làm giảm mức độ nghiêm trọng của sự cố.
Không thể đảm bảo application không bao giờ có RCE.
Nhưng có thể đảm bảo rằng một RCE không mặc nhiên biến attacker thành root.
Không thể giả định UFW đã bảo vệ mọi container port.
Nhưng có thể đảm bảo database và Redis không được publish ra Internet ngay từ đầu.
Không thể ngăn mọi malware từng xuất hiện.
Nhưng có thể khiến container khó ghi filesystem, khó persistence và dễ bị thay thế bằng một instance sạch.
Đó mới là tư duy đúng khi hardening Docker production:
Đừng cố đảm bảo rằng attacker sẽ không bao giờ vào được. Hãy thiết kế để khi attacker vào được, quyền hạn của họ vẫn bị giới hạn và đường lui của họ càng ít càng tốt.
Và ba nguyên tắc đáng nhớ nhất từ sự cố này là:
Non-root by default
Application production không có lý do chính đáng để chạy dưới UID 0 thì không nên chạy dưới UID 0.
Never assume UFW covers Docker-published ports
Hãy xác minh exposed ports từ một máy bên ngoài và thiết kế network sao cho service nội bộ không cần public port ngay từ đầu.
Investigate before restart
Khi CPU đột nhiên lên 400%, đừng chỉ docker restart và kết luận rằng sự cố đã biến mất.
Kiểm tra:
docker top <container>
docker diff <container>
docker inspect <container>
trước khi thay đổi trạng thái container.
Bởi vì đôi khi thứ bạn đang restart không phải là một ứng dụng bị lỗi.
Mà là một malware vừa bị đánh thức để chạy lại.
All Rights Reserved