0

Tóm tắt kiến thức về AddressSanitizer trong quá trình tôi tìm lỗ hổng 0-day với hệ thống viết bằng C++

AddressSanitizer trong C++

Khi tôi bắt đầu mày mò tìm lỗ hổng bảo mật (vulnerability) trong các phần mềm viết bằng C++, tôi nhanh chóng nhận ra một sự thật: phần lớn các lỗ hổng nghiêm trọng nhất đều xuất phát từ lỗi quản lý bộ nhớ. Một con trỏ trỏ sai chỗ, một mảng bị ghi tràn ra ngoài, một vùng nhớ đã giải phóng nhưng vẫn bị dùng lại — những lỗi tưởng nhỏ này chính là cánh cửa để kẻ tấn công chiếm quyền điều khiển chương trình.

Vấn đề là những lỗi đó rất khó tìm bằng mắt thường. Chương trình có thể chạy đúng hàng nghìn lần rồi mới crash một lần, và khi crash thì thường ở một chỗ chẳng liên quan gì đến lỗi thật. Trong quá trình đó, công cụ giúp tôi nhiều nhất chính là AddressSanitizer (gọi tắt là ASAN). Bài viết này là phần tóm tắt kiến thức tôi tích lũy được về nó.

Một chút khái niệm trước khi bắt đầu

Nếu bạn mới học, có vài thuật ngữ sẽ lặp lại nhiều lần trong bài, nên tôi giải thích nhanh ở đây:

  • 0-day: lỗ hổng bảo mật mà người làm ra phần mềm chưa biết tớichưa có bản vá. "Zero day" nghĩa là họ có 0 ngày để chuẩn bị. Đây là loại lỗ hổng được giới bảo mật săn lùng nhiều nhất.
  • Bộ nhớ (memory): nơi chương trình lưu dữ liệu khi đang chạy. Trong C++, lập trình viên phải tự tay xin cấp phát và trả lại bộ nhớ — và đây chính là nguồn cơn của vô số lỗi.
  • Heap và Stack: hai vùng bộ nhớ chính. Stack dùng cho biến cục bộ trong hàm (tự động dọn dẹp), còn heap là vùng bạn tự xin cấp phát bằng malloc/new và phải tự trả lại bằng free/delete.
  • Build / biên dịch (compile): quá trình biến code C++ (mà con người đọc được) thành file thực thi (mà máy tính chạy được). Công cụ làm việc này gọi là trình biên dịch (compiler), ví dụ gcc hay clang.

Giữ mấy khái niệm này trong đầu, phần còn lại sẽ rất dễ theo.

ASAN là gì và nó giúp tôi điều gì?

Nói đơn giản, AddressSanitizer là một "máy dò lỗi bộ nhớ" cho chương trình C/C++. Bạn bật nó lên khi build, rồi chạy chương trình như bình thường. Mỗi khi chương trình chạm vào bộ nhớ một cách sai trái, ASan lập tức dừng lại và in ra một bản báo cáo chi tiết: lỗi loại gì, xảy ra ở dòng nào, vùng nhớ đó được tạo ra ở đâu.

Với người đi tìm lỗ hổng như tôi, đây gần như là một "trợ lý" không biết mệt. Thay vì ngồi đoán xem bug nằm ở đâu, tôi để ASan tự chỉ tận tay.

Cụ thể, ASan bắt được các loại lỗi bộ nhớ phổ biến nhất — và tình cờ đây cũng đúng là những loại hay bị khai thác thành lỗ hổng bảo mật:

  • Use after free: dùng lại vùng nhớ sau khi đã trả về (giải phóng). Đây là một trong những "mỏ vàng" của giới khai thác lỗ hổng.
  • Heap buffer overflow: ghi/đọc vượt ra ngoài vùng nhớ đã xin trên heap.
  • Stack buffer overflow: tràn vùng nhớ trên stack — kinh điển trong các bài về bảo mật.
  • Global buffer overflow: tràn vùng nhớ của biến toàn cục.
  • Use after return / use after scope: dùng biến cục bộ sau khi nó đã "hết hạn".
  • Memory leak (rò rỉ bộ nhớ): xin bộ nhớ mà quên trả, khiến chương trình ngày càng phình to.

Một điểm khiến tôi rất thích: ASan nhanh. Chương trình bật ASan chỉ chạy chậm hơn bản gốc khoảng 2 lần. Nghe có vẻ chậm, nhưng nếu so với các công cụ tương tự (có cái chậm tới 10–50 lần) thì đây là khác biệt một trời một vực. Đủ nhanh để tôi chạy cả bộ test, thậm chí chạy gần giống môi trường thật.

Lấy ASan ở đâu? Tin vui là bạn đã có sẵn

Bạn gần như không cần cài thêm gì. ASan được tích hợp sẵn vào hai trình biên dịch phổ biến nhất:

  • LLVM/Clang từ phiên bản 3.1 trở đi.
  • GCC từ phiên bản 4.8 trở đi.

Nếu máy bạn đã có gcc hoặc clang đời mới (gần như chắc chắn rồi nếu bạn lập trình C++), thì bạn đã có ASan. Nó cũng chạy trên nhiều nền tảng: Linux, macOS, FreeBSD, Android, và nhiều loại CPU khác nhau.

Cách build chương trình với ASan

Đây là phần quan trọng nhất, mà thật ra lại đơn giản đến bất ngờ. Bạn chỉ cần thêm một flag (tham số dòng lệnh) khi biên dịch: -fsanitize=address.

Hãy xem một ví dụ kinh điển về lỗi use-after-free — đoạn code mà tôi từng gặp vô số lần khi soi các phần mềm C++:

// use-after-free.c
#include <stdlib.h>
int main() {
  char *x = (char*)malloc(10 * sizeof(char*)); // xin một vùng nhớ
  free(x);        // trả lại vùng nhớ đó
  return x[5];    // LỖI: vẫn dùng x sau khi đã trả!
}

Biên dịch nó với ASan như sau:

clang -fsanitize=address -O1 -fno-omit-frame-pointer -g use-after-free.c

Nhìn dài nhưng mỗi tham số đều có lý do, tôi giải thích từng cái:

  • -fsanitize=addressbật ASan. Đây là tham số duy nhất bắt buộc.
  • -O1 — bật tối ưu hóa mức nhẹ, để chương trình bật ASan không chạy quá chậm.
  • -fno-omit-frame-pointer — giúp báo cáo lỗi hiển thị "đường đi" của lỗi (stack trace) đẹp và chính xác hơn.
  • -g — thêm thông tin gỡ lỗi, để báo cáo chỉ ra được tên file và số dòng thay vì một mớ địa chỉ khó hiểu.

Với C++ thì y hệt, chỉ thay clang bằng clang++ (hoặc dùng g++):

clang++ -fsanitize=address -O1 -fno-omit-frame-pointer -g main.cpp -o app

Lưu ý quan trọng cho người mới: quá trình build C++ thường có hai bước — compile (dịch code) và link (ghép các phần lại thành file chạy). Bạn phải dùng -fsanitize=addresscả hai bước. Nếu chỉ dùng ở bước compile mà quên ở bước link, bạn sẽ gặp lỗi khó hiểu kiểu "undefined symbol". Tôi đã mất kha khá thời gian vì cái bẫy này hồi mới bắt đầu.

Đọc báo cáo lỗi của ASan

Khi chạy file vừa build, nếu chương trình chạm bộ nhớ sai, ASan sẽ in ra một báo cáo. Đây là output (đã rút gọn) cho ví dụ trên:

==9901==ERROR: AddressSanitizer: heap-use-after-free on address 0x60700000dfb5 ...
READ of size 1 at 0x60700000dfb5 thread T0
    #0 0x45917a in main use-after-free.c:5
    ...
0x60700000dfb5 is located 5 bytes inside of 80-byte region [...]
freed by thread T0 here:        <-- nơi vùng nhớ bị trả lại
    #1 0x45914a in main use-after-free.c:4
previously allocated by thread T0 here:   <-- nơi vùng nhớ được tạo ra
    #1 0x45913f in main use-after-free.c:3
SUMMARY: AddressSanitizer: heap-use-after-free use-after-free.c:5 main

Với người mới, báo cáo này có thể trông đáng sợ, nhưng thực ra nó kể cho bạn một câu chuyện rất rõ ràng theo 3 mốc thời gian:

  1. Lỗi gì, ở đâu: heap-use-after-free, xảy ra ở dòng 5 trong hàm main.
  2. Vùng nhớ bị trả lại ở đâu (freed ... here): dòng 4.
  3. Vùng nhớ được tạo ra ban đầu ở đâu (previously allocated ... here): dòng 3.

Chỉ trong một báo cáo, tôi biết được cả ba điều: bộ nhớ được sinh ra ở đâu, bị xóa ở đâu, và bị dùng sai ở đâu. Với cách debug truyền thống (rải printf khắp nơi), tìm ra được ngần ấy thông tin có khi mất cả buổi.

Một vài "công tắc" nâng cao khi cần đào sâu

Ngoài flag lúc build, bạn còn có thể điều chỉnh ASan lúc chạy thông qua một biến môi trường tên là ASAN_OPTIONS. Đây là nơi tôi bật các chế độ kiểm tra mạnh hơn khi muốn soi kỹ một mục tiêu:

export ASAN_OPTIONS=detect_stack_use_after_return=1:check_initialization_order=1

Hai tùy chọn tôi thấy hữu ích nhất khi đi tìm lỗ hổng:

  • Continue-after-error: mặc định ASan dừng ngay khi gặp lỗi đầu tiên. Nếu muốn nó tiếp tục chạy để gom nhiều lỗi một lúc, hãy build thêm với -fsanitize-recover=address rồi chạy với ASAN_OPTIONS=halt_on_error=0. (Lưu ý: các lỗi sau lỗi đầu tiên đôi khi không chính xác, cần xem xét cẩn thận.)
  • Nếu "đường đi của lỗi" hiển thị quá ngắn hoặc vô nghĩa, thử thêm ASAN_OPTIONS=fast_unwind_on_malloc=0 — đổi lại chương trình sẽ chạy chậm hơn.

Bạn không cần nhớ hết các tùy chọn này từ đầu; chỉ cần biết là chúng tồn tại, và khi gặp tình huống cụ thể thì tra cứu thêm.

Tích hợp vào dự án thật

Trong thực tế, không ai gõ tay lệnh biên dịch cho từng file. Các dự án dùng công cụ quản lý build. Dưới đây là cách bật ASan trong hai công cụ phổ biến.

Với CMake (rất hay gặp trong các dự án C++ lớn), bạn chỉ cần thêm flag vào cả phần compile và link:

add_executable(app main.cpp)
target_compile_options(app PRIVATE -fsanitize=address -fno-omit-frame-pointer)
target_link_options(app    PRIVATE -fsanitize=address)

Với Makefile đơn giản, gom flag vào cả CXXFLAGS (compile) lẫn LDFLAGS (link):

SANITIZE = -fsanitize=address -fno-omit-frame-pointer -g
CXXFLAGS += $(SANITIZE)
LDFLAGS  += $(SANITIZE)

Một cách dùng tôi đánh giá rất cao là chạy ASan trong CI/CD — tức là hệ thống tự động build và test mỗi khi có thay đổi code. Vì ASan chỉ làm chậm khoảng 2 lần, bạn hoàn toàn có thể chạy cả bộ test với ASan bật sẵn, và bắt được lỗi bộ nhớ ngay trong lúc phát triển, trước khi nó kịp lọt ra ngoài thành lỗ hổng thật.

Những cạm bẫy tôi từng vấp

ASan rất mạnh, nhưng có vài điểm gây bối rối mà tôi ước gì có người nói trước cho mình:

  • Không dùng được với link tĩnh (-static). Nếu cố ép, trình biên dịch sẽ báo lỗi luôn. Bạn phải tắt link tĩnh để dùng ASan.
  • Không trộn được Clang và GCC. Hai trình biên dịch này có cách triển khai ASan hoàn toàn không tương thích. Đã chọn cái nào thì dùng nhất quán cái đó cho toàn bộ chương trình.
  • Coi chừng _FORTIFY_SOURCE. Nhiều bản Linux đời mới bật sẵn một cơ chế bảo vệ tên là _FORTIFY_SOURCE, và nó có thể khiến ASan báo lỗi giả hoặc bỏ sót lỗi. Nếu thấy ASan "cư xử lạ", hãy thử tắt nó đi.
  • Đừng hoảng vì con số 20 terabyte. Chương trình bật ASan trông như đang chiếm tới ~20 TB bộ nhớ. Nhưng đó là bộ nhớ ảo — chỉ là không gian được "đặt chỗ trước", không phải RAM thật bị dùng hết. Đây là chuyện bình thường, đừng cố giới hạn nó bằng ulimit -v.
  • Build lại toàn bộ để bắt hết lỗi. ASan vẫn chạy nếu bạn chỉ bật cho một phần chương trình, nhưng những lỗi nằm trong phần không được bật sẽ bị bỏ sót. Muốn soi kỹ, hãy build lại tất cả với ASan.

Một điều cần nhớ về phạm vi của ASan

ASan chuyên về lỗi địa chỉ bộ nhớ — đúng vùng tôi quan tâm nhất khi đi tìm 0-day. Nhưng nó không bắt mọi loại bug. Ví dụ, lỗi đọc biến chưa khởi tạo là việc của một công cụ anh em tên MemorySanitizer, còn lỗi nhiều luồng chạy đua nhau (data race) là việc của ThreadSanitizer. Hãy xem ASan như một lớp phòng thủ chuyên trách cực kỳ hiệu quả, chứ không phải cây đũa thần giải quyết tất cả.

Đôi khi ASan cũng "im lặng" dù code có vẻ sai rõ ràng — thường vì trình biên dịch đã tối ưu mất đoạn lỗi đó trước khi ASan kịp kiểm tra. Đây là chuyện bình thường, không phải ASan hỏng.

Kết lại

Trong hành trình tìm lỗ hổng 0-day trên các hệ thống C++, AddressSanitizer là một trong những công cụ cho tôi nhiều giá trị nhất so với công sức bỏ ra. Chi phí để bắt đầu gần như bằng không: chỉ cần thêm -fsanitize=address -fno-omit-frame-pointer -g vào lệnh build, nhớ để flag ở cả bước compile lẫn link, rồi chạy chương trình như bình thường.

Nếu bạn mới vào ngành và đang muốn tìm hiểu về bảo mật phần mềm hay đơn giản là viết C++ chắc tay hơn, tôi khuyên bạn thử ngay: lấy một đoạn code C++ bất kỳ của mình, build lại với ASan, rồi chạy thử. Rất có thể bạn sẽ bất ngờ vì những con bug đã âm thầm nằm đó từ lâu. Và bắt được chúng sớm bao giờ cũng tốt hơn để chúng trở thành lỗ hổng mà người khác khai thác.


Dù tôi mới tìm được một bug với serverity medium nhưng đây có thể coi là khởi đầu khá suôn sẻ khi làm một điều mới mẻ, hy vọng sẽ tìm được bug nghiêm trọng hơn trong tương lai 💪


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí