Gọi hàm C mà không cần gọi tên: Khi sinh viên qua mặt hệ thống chấm điểm tự động
Một sinh viên đã tìm ra cách gọi hàm execve mà không cần gọi tên nó, vượt qua hàng rào kiểm tra của hệ thống chấm điểm lập trình tự động. Kỹ thuật này kết hợp việc rò rỉ offset giữa các hàm trong libc, lợi dụng ASan và mmap để thực thi shellcode tùy ý.
Gọi hàm C mà không cần gọi tên: Khi sinh viên qua mặt hệ thống chấm điểm tự động
Một câu chuyện thú vị từ cộng đồng lập trình: một sinh viên và người bạn của mình đã tìm ra cách gọi hàm execve trong C mà không cần gọi tên nó, qua đó vượt qua hệ thống kiểm tra của trường. Kỹ thuật này không chỉ là một trò đùa nghịch ngợm mà còn là bài học thực tế về ASLR, ASan và cách trình biên dịch xử lý các ký hiệu (symbol).
Bối cảnh: Hệ thống chấm điểm tự động
Trường của tác giả có một môi trường thực thi mã từ xa (remote code execution) để tự động chấm điểm các bài nộp. Hệ thống này kiểm tra việc thực thi, định dạng và nhiều yếu tố khác — trong đó có một công cụ phát hiện đạo văn mã nguồn. Sinh viên chỉ thấy thông báo lỗi biên dịch hoặc kết quả không khớp.
Mục tiêu của tác giả và bạn mình là tìm ra một lời giải duy nhất có thể giải quyết mọi bài tập. Bước đầu tiên là có được khả năng thực thi shell code tùy ý. Họ giả định trên máy ảo chấm điểm có sẵn sh, nên chỉ cần gọi execve.
Rào cản đầu tiên: Danh sách hàm được phép
Hệ thống kiểm tra không chỉ đơn thuần là grep mã nguồn. Tác giả phỏng đoán nó dùng clang để tiền xử lý và phân tích mã, sau đó kiểm tra trên cây cú pháp trừu tượng (AST). Vì execve không bao giờ nằm trong danh sách hàm được phép của bài tập, bài nộp sẽ bị từ chối trước khi mã kịp chạy.
Vậy làm sao để gọi execve mà không được phép gọi tên nó?
Ý tưởng: Lấy địa chỉ hàm trong bộ nhớ
Về bản chất, mã C được biên dịch thành một tệp nhị phân, và gọi hàm chỉ là nhảy đến một địa chỉ trong bộ nhớ. Liệu có thể lấy địa chỉ của execve trong bộ nhớ, hardcode nó vào chương trình rồi gọi trực tiếp?
Vấn đề là ASLR (Address Space Layout Randomization) — cơ chế ngẫu nhiên hóa địa chỉ của nhị phân, thư viện, heap và stack mỗi lần chạy. Hơn nữa, cờ -pie thường được bật mặc định, và execve không nằm trong tệp thực thi mà được nạp động từ libc, nên địa chỉ của nó luôn thay đổi.
Tuy nhiên, ASLR chỉ ngẫu nhiên hóa vị trí các phân đoạn (segment), còn nội dung bên trong phân đoạn thì giữ nguyên thứ tự. Nếu biết offset tĩnh giữa hai hàm fA và fB, ta có thể suy ra địa chỉ của hàm này từ hàm kia.
Bước đi then chốt: Rò rỉ offset
Có nhiều cách để lấy offset cố định giữa hai hàm trên máy đích. Một cách đơn giản là lợi dụng một bài tập khác cho phép gọi tên cả hai hàm, rồi in ra hiệu số địa chỉ.
Mục tiêu là execve (thuộc libc). Giả sử ta luôn được phép dùng printf. Thử nghiệm cục bộ ban đầu cho ra kết quả khác nhau mỗi lần chạy — trái với kỳ vọng. Dùng objdump --dynamic-syms, họ phát hiện printf không đến từ glibc mà từ một vùng khác: các ký hiệu __interceptor_*.
Nguyên nhân là ASan (AddressSanitizer). Hệ thống chấm điểm cũng biên dịch với ASan để kiểm tra lỗi bộ nhớ. ASan thay thế nhiều hàm thư viện chuẩn như malloc hay printf bằng phiên bản của riêng nó, nên chúng không còn nằm trong glibc nữa — khác với execve.
Chuyển hướng: Dùng mmap thay vì execve
Vì execve nằm ở phân đoạn khác, họ tìm một ký hiệu hữu ích trong cùng phân đoạn ASan — và để mắt đến mmap.
mmap đặc biệt vì chính sách W^X (write-xor-execute): không có vùng nhớ nào vừa ghi vừa thực thi được. Ta không thể ghi mã máy x86 thẳng vào buffer rồi nhảy tới, cũng không thể ghi đè hàm có sẵn. Nhưng mmap giải quyết điều này: nó cấp một trang nhớ có quyền ghi và thực thi.
Họ rò rỉ offset giữa printf và mmap qua một bài tập khác, rồi dùng union để lách luật -pedantic (cấm ép kiểu giữa các loại con trỏ hàm):
union notmmap_build {
int (*basefn)(const char *);
size_t addr;
void *(*fn)(void *addr, size_t length, int prot, int flags, int fd, long offset);
};
union notmmap_build notmmap = {.basefn = printf};
notmmap.addr += OFFSET;
Thực thi shellcode tùy ý
Sau đó họ viết một đoạn assembly x86 nhỏ để thực hiện syscall Linux như một hàm C:
mov %rdi,%rax
mov %rsi,%rdi
mov %rdx,%rsi
mov %rcx,%rdx
syscall
ret
Đoạn mã này cho phép gọi bất kỳ syscall nào với tối đa 3 tham số. Họ copy các byte này vào trang nhớ vừa được mmap cấp quyền ghi/thực thi, rồi gọi nó như một hàm bình thường. Kết quả: in ra Hello, World! thành công trên máy ảo chấm điểm.
Kết luận và bài học bảo mật
Tác giả nhận định không có cách vá lỗ hổng này một cách đơn giản. ASLR không giúp ích gì ở đây. Một ý tưởng là liên kết lại thư viện chuẩn C với thứ tự hàm khác nhau mỗi lần chạy (như OpenBSD làm lúc khởi động), nhưng ASan vẫn thay thế hàm bằng phiên bản riêng nên không giải quyết được.
Điểm mấu chốt của khai thác này nằm ở chỗ hệ thống vốn đã cho phép thực thi mã từ xa khá tự do như một tính năng. Dù vậy, nó vẫn đòi hỏi phải vượt qua ASLR và hiểu khá sâu về những gì đang diễn ra bên dưới.
Câu chuyện này là lời nhắc nhở thú vị cho các nhà phát triển và cả những ai quan tâm đến bảo mật: ranh giới giữa "kiểm tra hợp lệ" và "thực thi mã" đôi khi mong manh hơn ta tưởng. Với độc giả Việt Nam đang học lập trình hệ thống hoặc tham gia các nền tảng chấm bài tự động, đây là ví dụ tuyệt vời để hiểu sâu hơn về cách trình biên dịch, thư viện động và bộ nhớ tương tác với nhau.


