Blog02 — Con Trỏ & Bộ Nhớ
Bố cục bài viết
Trang này
Ngày tạo
05/08/2026
Cập nhật
Lượt xem
30
Đọc
12m
02 — Con Trỏ & Bộ Nhớ
Mục tiêu: Đây là module quan trọng nhất của cả lộ trình. Con trỏ và mô hình bộ nhớ là bản chất của C, và cũng là bản chất của mọi lỗ hổng memory trong telecom stack. Nắm chắc chương này = bạn đã có 50% tư duy của một chuyên gia khai thác/audit.
1. Bộ nhớ là gì — mô hình tinh thần đúng
Hãy hình dung RAM là một dãy ô liên tiếp, mỗi ô 1 byte, mỗi ô có một địa chỉ (số nguyên):
Địa chỉ: 0x7000 0x7001 0x7002 0x7003 0x7004 ...
┌──────┬──────┬──────┬──────┬──────┐
Giá trị: │ 0x45 │ 0x00 │ 0x00 │ 0x3c │ 0x1a │ ...
└──────┴──────┴──────┴──────┴──────┘
- Biến = một cái tên gợi nhớ cho một (hoặc vài) ô nhớ.
- Con trỏ (pointer) = một biến mà giá trị của nó là một địa chỉ.
Đó là tất cả. Mọi thứ phức tạp về sau chỉ là hệ quả của ý tưởng đơn giản này.
2. Con trỏ cơ bản: & và *
int x = 65;
int *p = &x; // p giữ ĐỊA CHỈ của x. '&' = "địa chỉ của"
// int * = "con trỏ tới int"
printf("%d\n", x); // 65 — giá trị của x
printf("%p\n", (void*)p); // 0x7ffe... — địa chỉ (giá trị của p)
printf("%d\n", *p); // 65 — '*p' = "giá trị TẠI địa chỉ p" (dereference)
*p = 66; // ghi 66 vào ô nhớ mà p trỏ tới → x trở thành 66
printf("%d\n", x); // 66
| Ký hiệu | Đọc là | Ý nghĩa |
|---|---|---|
&x | “địa chỉ của x” | Lấy địa chỉ của biến x |
*p | “giá trị tại p” / “dereference p” | Truy cập ô nhớ mà p trỏ tới |
int *p | “p là con trỏ tới int” | Khai báo con trỏ |
Mẹo đọc khai báo: đọc từ tên ra ngoài.
int *p→*plàint→plà con trỏ tới int.
3. Tại sao con trỏ là TẤT CẢ trong C
3.1. Sửa biến gốc từ trong hàm (output parameter)
void set_teid(uint32_t *out) { // nhận địa chỉ
*out = 0xDEADBEEF; // ghi vào ô gốc
}
uint32_t teid = 0;
set_teid(&teid); // truyền địa chỉ của teid
// teid bây giờ = 0xDEADBEEF
Đây là lý do parser C thường có chữ ký int parse(const uint8_t *in, size_t len, struct result *out).
3.2. Tránh sao chép dữ liệu lớn
Truyền một buffer 1500 byte theo giá trị = copy 1500 byte. Truyền con trỏ = copy 8 byte (địa chỉ). Toàn bộ packet processing dựa vào điều này.
3.3. Cấu trúc dữ liệu động (linked list, cây AVP lồng nhau) — bất khả thi nếu không có con trỏ.
4. Con trỏ void*, NULL, và const
void *raw; // con trỏ "vô kiểu" — trỏ tới bất cứ gì, KHÔNG dereference trực tiếp được
int *p = NULL; // NULL = "không trỏ tới đâu cả" (địa chỉ 0)
if (p != NULL) *p = 5; // LUÔN kiểm tra NULL trước khi dereference
const uint8_t *r; // CON TRỎ tới dữ liệu KHÔNG ĐƯỢC SỬA (đọc-only buffer)
uint8_t *const c = &b;// con trỏ CỐ ĐỊNH (không đổi địa chỉ) tới dữ liệu sửa được
| Khai báo | Sửa được dữ liệu? | Đổi được địa chỉ con trỏ? |
|---|---|---|
uint8_t *p | ✅ | ✅ |
const uint8_t *p | ❌ | ✅ |
uint8_t *const p | ✅ | ❌ |
const uint8_t *const p | ❌ | ❌ |
Quy ước: parser nhận
const uint8_t *bufđể cam kết “tôi không sửa gói tin đầu vào”.
Bẫy NULL deref: dereference
NULL→ crash (SIGSEGV). Trong code mạng, nếumallocthất bại trảNULLmà bạn không kiểm tra → DoS.
5. STACK vs HEAP — phải phân biệt rạch ròi
Đây là kiến thức nền cho toàn bộ phần khai thác bộ nhớ sau này.
Bố cục bộ nhớ một tiến trình (đơn giản hóa):
Địa chỉ cao
┌─────────────────────┐
│ STACK │ ← biến cục bộ, tham số hàm, địa chỉ trả về
│ ↓ lớn xuống │ (mọi lần gọi hàm tạo 1 "stack frame")
│ │
│ (khoảng trống) │
│ │
│ ↑ lớn lên │
│ HEAP │ ← vùng malloc/free (động)
├─────────────────────┤
│ BSS / DATA │ ← biến toàn cục & static
├─────────────────────┤
│ TEXT │ ← mã máy (chỉ đọc)
└─────────────────────┘
Địa chỉ thấp
| Đặc điểm | STACK | HEAP |
|---|---|---|
| Cấp phát | Tự động (vào/ra hàm) | Thủ công (malloc/free) |
| Tốc độ | Rất nhanh | Chậm hơn |
| Kích thước | Nhỏ (vài MB), cố định | Lớn |
| Lifetime | Theo scope hàm | Đến khi free |
| Lỗi điển hình | Stack buffer overflow → ghi đè địa chỉ trả về | Heap overflow, use-after-free, double-free |
Liên hệ khai thác (đào sâu ở
09):
- Tràn buffer trên stack có thể ghi đè return address → chiếm luồng điều khiển (ret2libc, ROP).
- Tràn/UAF trên heap thao túng metadata của allocator (tcache, fastbin) → ghi tùy ý. Cả hai đều bắt đầu từ việc bạn hiểu hai vùng này khác nhau thế nào.
6. Cấp phát động trên heap: malloc, calloc, realloc, free
#include <stdlib.h>
#include <string.h>
// Cấp phát buffer để chứa 1 AVP có độ dài lấy từ gói
uint8_t *buf = malloc(len); // cấp len byte (nội dung RÁC, chưa khởi tạo)
if (buf == NULL) return -1; // LUÔN kiểm tra — malloc có thể thất bại
memset(buf, 0, len); // hoặc dùng calloc để vừa cấp vừa zero
// ... dùng buf ...
free(buf); // trả lại bộ nhớ
buf = NULL; // tốt: tránh dùng nhầm con trỏ đã free
| Hàm | Công dụng |
|---|---|
malloc(n) | Cấp n byte, không khởi tạo (rác) |
calloc(k, n) | Cấp k×n byte, zero hóa; còn kiểm tra tràn nhân |
realloc(p, n) | Đổi kích thước vùng đã cấp (có thể di chuyển!) |
free(p) | Giải phóng. free(NULL) an toàn (không làm gì) |
6.1. Quy tắc vàng của quản lý bộ nhớ thủ công
- Mỗi
malloccó đúng mộtfree. Không free → memory leak (DoS dài hạn cho probe chạy lâu). Free hai lần → double-free (lỗ hổng). - Sau
free, đặt con trỏ =NULL. Tránh use-after-free. - Không bao giờ dùng con trỏ sau khi
free. Đó là use-after-free (UAF) — lớp lỗ hổng nguy hiểm nhất hiện nay. realloccó thể đổi địa chỉ. Luôn gán lại:p = realloc(p, n);(nhưng dùng biến tạm để không mất con trỏ cũ nếu thất bại).
6.2. Năm tội lỗi bộ nhớ kinh điển (CWE) — bạn sẽ săn chúng cả đời
// (A) Memory leak — quên free
uint8_t *p = malloc(100);
return; // p mất, 100 byte rò rỉ vĩnh viễn [CWE-401]
// (B) Use-after-free
free(p);
p[0] = 1; // dùng sau khi free [CWE-416]
// (C) Double-free
free(p);
free(p); // free hai lần [CWE-415]
// (D) Heap buffer overflow
uint8_t *p = malloc(8);
memcpy(p, src, 16); // ghi 16 byte vào vùng 8 byte [CWE-122]
// (E) Đọc bộ nhớ chưa khởi tạo
uint8_t *p = malloc(8);
printf("%d", p[0]); // p[0] là rác — rò rỉ thông tin? [CWE-457]
Cách phát hiện: chạy chương trình với
valgrind ./proghoặc biên dịch-fsanitize=address. ASan/Valgrind sẽ chỉ chính xác dòng và loại lỗi. Đây là kỹ năng audit bắt buộc — ta luyện ở10.
7. Mảng và quan hệ mật thiết với con trỏ
uint8_t packet[16] = {0x45, 0x00, 0x00, 0x3c};
packet[0]; // phần tử đầu = 0x45
*packet; // GIỐNG packet[0] — tên mảng "phân rã" thành con trỏ tới phần tử đầu
*(packet + 3); // GIỐNG packet[3] — số học con trỏ
&packet[3]; // địa chỉ phần tử thứ 3
7.1. Số học con trỏ (pointer arithmetic) — đơn vị là phần tử, không phải byte!
uint32_t arr[4];
uint32_t *p = arr;
p + 1; // tiến 4 BYTE (vì sizeof(uint32_t)=4), trỏ tới arr[1]
uint8_t *b = (uint8_t*)arr;
b + 1; // tiến 1 BYTE
Khi parse packet, ta thường dùng
uint8_t*để cộng theo byte chính xác:
const uint8_t *cur = buf;
uint8_t type = cur[0];
uint16_t length = (cur[2] << 8) | cur[3]; // big-endian
cur += length; // nhảy tới field kế tiếp
7.2. CẠM BẪY LỚN NHẤT: C không kiểm tra biên mảng
uint8_t a[4];
a[10] = 0xFF; // KHÔNG LỖI BIÊN DỊCH. Ghi ra ngoài mảng → UB, corruption.
a[-1] = 0xFF; // cũng "hợp lệ" về cú pháp — ghi đè ô nhớ trước mảng
Đây chính là buffer overflow. C tin tưởng tuyệt đối vào lập trình viên. Trong viễn thông, nếu chỉ số đến từ length field trong gói của kẻ tấn công → thảm họa.
Quy tắc thép #5: Mọi truy cập mảng/buffer bằng chỉ số có nguồn gốc từ dữ liệu mạng phải được kiểm tra
idx < lentrước. Không có ngoại lệ.
8. Chuỗi C — chỉ là mảng char kết thúc bằng \0
char s[] = "INVITE"; // thực chất: {'I','N','V','I','T','E','\0'} → 7 byte
Chuỗi C không lưu độ dài; nó dựa vào byte \0 (NUL terminator) để biết điểm kết thúc. Đây là nguồn gốc vô số lỗ hổng.
8.1. Hàm chuỗi NGUY HIỂM vs AN TOÀN
#include <string.h>
char dst[8];
strcpy(dst, src); // NGUY HIỂM — không giới hạn, tràn nếu src dài [tránh]
strncpy(dst, src, sizeof(dst)); // giới hạn, NHƯNG có thể không kết thúc \0 [cẩn thận]
snprintf(dst, sizeof(dst), "%s", src); // AN TOÀN — luôn kết thúc \0 [ưu tiên]
size_t n = strlen(s); // đếm tới \0 — nếu thiếu \0 → đọc tràn (over-read)
| Hàm nguy hiểm | Vấn đề | Thay bằng |
|---|---|---|
gets() | Không giới hạn (đã bị xóa khỏi chuẩn) | fgets |
strcpy | Không giới hạn | snprintf / strlcpy |
strcat | Không giới hạn | snprintf |
sprintf | Không giới hạn | snprintf |
scanf("%s") | Không giới hạn | fgets |
Bài học SIP: SIP là protocol text (chuỗi). Vô số CVE trong Kamailio/Asterisk đến từ xử lý header SIP bằng hàm chuỗi không giới hạn. Khi parse SIP bằng C, dữ liệu nhị phân (binary) và chuỗi NUL-terminated là hai thế giới khác nhau — gói mạng có thể chứa byte
\0ở giữa, làmstrlen/strcpyhiểu sai. Với protocol nhị phân, luôn dùng cặp (ptr, len), không dùng hàm chuỗi.
9. Con trỏ tới con trỏ (**) — dùng khi nào?
// Hàm tự cấp phát buffer và "trả" con trỏ ra ngoài
int alloc_buf(uint8_t **out, size_t n) {
*out = malloc(n);
return (*out != NULL) ? 0 : -1;
}
uint8_t *buf = NULL;
alloc_buf(&buf, 128); // truyền địa chỉ của con trỏ → hàm sửa được chính con trỏ
free(buf);
Dùng cho: hàm cấp phát giúp caller, mảng động của con trỏ (vd. mảng các AVP), char *argv[].
10. Soi bộ nhớ thật bằng gdb (kỹ năng cốt lõi của security)
gcc -g -O0 prog.c -o prog
gdb ./prog
Trong gdb:
(gdb) break main
(gdb) run
(gdb) next # chạy từng dòng
(gdb) print x # in giá trị biến
(gdb) print &x # in địa chỉ
(gdb) print *p # dereference
(gdb) x/16xb buf # xem 16 byte (hex) tại buf ← SOI PACKET TRONG RAM
(gdb) x/4xw arr # xem 4 word (4 byte) hex
(gdb) info locals # các biến cục bộ
(gdb) bt # backtrace — chuỗi gọi hàm (xem được sau crash)
Thói quen: với mỗi struct/buffer bạn tạo, dùng
x/Nxbđể nhìn tận mắt layout byte. Khi bạn có thể đối chiếu byte trong gdb ↔ hex trong Wireshark ↔ định nghĩa trong RFC, bạn đã tư duy như một chuyên gia.
11. Bài tập / Lab module 02
- Swap bằng con trỏ: viết
void swap(uint32_t *a, uint32_t *b)tráo giá trị hai biến. Chứng minh nó hoạt động (khác với swap pass-by-value không hoạt động). - Dynamic buffer: viết hàm đọc toàn bộ một file vào buffer cấp phát động (dùng
malloc/realloc), trả về(con trỏ, độ dài). Nhớfree. Chạy quavalgrindđể xác nhận không leak. - Tự cố tình tạo lỗi rồi bắt nó: viết 4 chương trình nhỏ tái hiện (B) UAF, (C) double-free, (D) heap overflow, (E) đọc chưa khởi tạo ở mục 6.2. Biên dịch
-fsanitize=addressvà đọc kỹ báo cáo của ASan. Đây là bài tập quan trọng nhất module — học cách lỗi “trông như thế nào”. - Pointer walker: cho một buffer mô phỏng chuỗi TLV (
type[1] len[1] value[len] ...), viết vòng lặp dùng con trỏcurduyệt và in từng record. Phải kiểm tra biên để không đọc tràn khilenxấu (vd.lenlớn hơn phần còn lại của buffer). Thử cho một buffer độc hại (len = 0xFFnhưng chỉ còn 3 byte) và chứng minh code của bạn từ chối an toàn thay vì crash. - gdb hexdump: lấy chương trình bài 4, đặt breakpoint, dùng
x/16xb bufxem byte, đối chiếu với những gì in ra.
Cột mốc module 02: Bạn (a) hiểu stack vs heap, (b) quản lý
malloc/freekhông leak/UAF, (c) duyệt buffer bằng con trỏ có kiểm tra biên, (d) nhận diện được 5 lỗi bộ nhớ kinh điển qua báo cáo ASan/Valgrind. Đây là năng lực phân biệt một coder C bình thường với một người sắp trở thành chuyên gia security.
➡️ Tiếp theo: 03-C-Struct-Union-Bitfield.md — mô tả header protocol bằng struct.
Lộ trình học tập
Lập Trình C/C++
- 1 01 — C Cơ Bản
- 2 02 — Con Trỏ & Bộ Nhớ
- 3 03 — Struct, Union, Bitfield & Bố Cục Bộ Nhớ
- 4 04 — C Nâng Cao
- 5 05 — C++ Cốt Lõi
- 6 06 — C++ Hiện Đại
- 7 07 — Lập Trình Hệ Thống & Mạng
- 8 08 — Phân Tích Giao Thức Telecom Bằng C/C++
- 9 09 — An Ninh Bộ Nhớ & Khai Thác Cơ Bản
- 10 11 — Lập Trình Nhúng & Real-time (SDR-first)
- 11 Bài tập & Pet Project — Lộ trình C/C++ chuyên sâu
- 12 Tổng hợp các dạng printf để in giá trị trong C
- 13 Bitwise & Mask, Octet, Endian: Hướng dẫn parse protocol byte cho người mới
- 14 Tổng hợp: Con trỏ, kích thước kiểu dữ liệu và byte trong bộ nhớ