Cập nhật lần cuối:
Thế giới ký tự vô hình - Sự cố do ký tự zero-width và ký tự không nhìn thấy gây ra
Chuỗi ký tự bạn nhập lẽ ra phải là 10 ký tự, nhưng hệ thống khăng khăng nói là 12. Dù nhìn kỹ đến đâu cũng không thấy ký tự thừa nào. Thủ phạm là "ký tự zero-width" - những ký tự hoàn toàn không hiển thị trên màn hình nhưng chắc chắn tồn tại dưới dạng dữ liệu. Bài viết này giải thích các loại và mục đích của ký tự vô hình được định nghĩa trong Unicode, tác động đến việc đếm ký tự, cùng các mẫu sự cố điển hình xảy ra khi chúng lẻn vào và cách xử lý.
Danh mục ký tự vô hình - Những ký tự tồn tại mà không được nhìn thấy
Unicode định nghĩa nhiều ký tự không hiển thị trên màn hình (hoặc có chiều rộng bằng không). Đây không phải "bug" - chúng tồn tại vì những lý do chính đáng trong xử lý văn bản.
| Tên ký tự | Code Point | Mục đích | Đếm ký tự | Chiều rộng hiển thị |
|---|---|---|---|---|
| Zero Width Space (ZWSP) | U+200B | Chỉ định vị trí có thể ngắt dòng | Tính là 1 ký tự | 0 |
| Zero Width Joiner (ZWJ) | U+200D | Nối ký tự (tổng hợp emoji) | Tính là 1 ký tự | 0 |
| Zero Width Non-Joiner (ZWNJ) | U+200C | Ngăn nối ký tự | Tính là 1 ký tự | 0 |
| Left-to-Right Mark (LRM) | U+200E | Điều khiển hướng văn bản | Tính là 1 ký tự | 0 |
| Right-to-Left Mark (RLM) | U+200F | Điều khiển hướng văn bản | Tính là 1 ký tự | 0 |
| Byte Order Mark (BOM) | U+FEFF | Nhận dạng encoding | Tính là 1 ký tự (biến mất trong các môi trường loại bỏ nó khi đọc file) | 0 |
| Soft Hyphen (SHY) | U+00AD | Chỉ định vị trí gạch nối | Tính là 1 ký tự | Thường là 0 (chỉ hiển thị khi ngắt dòng) |
| Word Joiner (WJ) | U+2060 | Chỉ định vị trí không ngắt dòng | Tính là 1 ký tự | 0 |
Tất cả các ký tự này đều có vai trò chính đáng trong xử lý văn bản. Vấn đề là khi chúng vô tình xâm nhập vào văn bản, chúng âm thầm làm sai lệch số đếm ký tự.
Zero Width Space (U+200B) - Ký tự vô hình phiền toái nhất
Zero Width Space (ZWSP) là ký tự nhúng thông tin "có thể ngắt dòng tại đây" vào văn bản. Nó được sử dụng trong các ngôn ngữ như tiếng Thái và tiếng Khmer không dùng khoảng trắng giữa các từ, cho phép trình duyệt ngắt dòng tại vị trí thích hợp.
Tuy nhiên, ZWSP dễ dàng xâm nhập vào văn bản khi sao chép và dán từ trang web, gây ra các sự cố như:
- Dữ liệu nhập form bị đánh giá "vượt giới hạn ký tự" (nhìn bằng mắt thì trong giới hạn)
- Sao chép dán mật khẩu thất bại (ZWSP xâm nhập biến thành chuỗi khác)
- Tìm kiếm không khớp (chuỗi trông giống nhau nhưng không tìm thấy)
- Dữ liệu file CSV không phân tích đúng
- Xâm nhập mã nguồn chương trình gây lỗi biên dịch
Xâm nhập mật khẩu đặc biệt nghiêm trọng. Khi ZWSP lẻn vào mật khẩu được sao chép từ trang web, bạn gặp tình huống mật khẩu trông đúng nhưng không thể đăng nhập. Khi xem xét độ dài mật khẩu và bảo mật, sự tồn tại của ký tự vô hình không thể bỏ qua.
Zero Width Joiner (U+200D) - Ký tự ma thuật tổng hợp emoji
Zero Width Joiner (ZWJ) đóng vai trò tích cực nhất trong số các ký tự vô hình. Như đã giải thích chi tiết trong đếm ký tự emoji, ZWJ kết hợp nhiều emoji để tạo ra emoji mới.
| Emoji hiển thị | Thành phần | Số code point | Đếm ký tự (JavaScript) |
|---|---|---|---|
| 👨👩👧👦 (Gia đình) | 👨 + ZWJ + 👩 + ZWJ + 👧 + ZWJ + 👦 | 7 | 11 (bao gồm surrogate pair) |
| 👩💻 (Nữ kỹ thuật viên) | 👩 + ZWJ + 💻 | 3 | 5 |
| 🏳️🌈 (Cờ cầu vồng) | 🏳️ + ZWJ + 🌈 | 4 | 6 |
| 👨🍳 (Đầu bếp nam) | 👨 + ZWJ + 🍳 | 3 | 5 |
Emoji gia đình 👨👩👧👦 trông như một emoji duy nhất, nhưng bên trong gồm 4 emoji và 3 ZWJ. Thuộc tính .length của JavaScript trả về 11. Trên mạng xã hội có giới hạn ký tự, một emoji như thế này có thể tiêu tốn rất nhiều ký tự.
Ký tự điều khiển hướng - Cơ chế cho ngôn ngữ viết từ phải sang trái
Tiếng Ả Rập và tiếng Hebrew là ngôn ngữ viết từ phải sang trái (RTL). Trong văn bản mà các ngôn ngữ này cùng tồn tại với tiếng Anh (từ trái sang phải, LTR), cần có ký tự vô hình để điều khiển hướng văn bản.
U+200E (Left-to-Right Mark) và U+200F (Right-to-Left Mark) là các ký tự để chỉ định rõ ràng hướng văn bản. Khi chúng vô tình xâm nhập, có thể làm rối loạn thứ tự hiển thị hoặc sai lệch số đếm ký tự.
Năm 2021, lỗ hổng bảo mật "Trojan Source" lợi dụng ký tự điều khiển hướng đã được báo cáo. Bằng cách nhúng ký tự điều khiển hướng vào mã nguồn, code trông bình thường với mắt người nhưng được trình biên dịch hiểu là logic khác. Lỗ hổng này cho thấy ký tự vô hình cũng có thể gây ra rủi ro bảo mật.
BOM (U+FEFF) - Ký tự vô hình ẩn nấp ở đầu file
Byte Order Mark (BOM) là ký tự được thêm vào đầu file văn bản để nhận dạng encoding. BOM của UTF-8 là 3 byte (EF BB BF) và đôi khi được Windows Notepad thêm vào khi lưu file.
BOM bị nhiều chương trình bỏ qua, nhưng gây ra vấn đề trong các trường hợp sau:
- BOM ở đầu file PHP khiến hàm
header()không hoạt động (bị đánh giá là đầu ra đã bắt đầu) - BOM ở đầu file CSV khiến tên cột đầu tiên không được nhận dạng đúng
- BOM trong file JSON có thể khiến parser trả về lỗi
- BOM ở đầu shell script khiến shebang (
#!/bin/bash) không được nhận dạng
Kỹ thuật giấu tin bằng ký tự zero-width (Công nghệ watermark)
Steganography (watermark kỹ thuật số) là công nghệ tận dụng ngược đặc tính "không nhìn thấy" của ký tự vô hình. Bằng cách nhúng các mẫu ký tự zero-width vào văn bản, có thể cài thông tin ẩn mà không thay đổi giao diện.
Thực chất ở đây chỉ có một kỹ thuật duy nhất, và các ký tự mà nó dùng cũng chỉ giới hạn trong những ký tự zero-width như U+200B, U+200C, U+200D và U+FEFF. Điều duy nhất khác nhau giữa các cách dùng là chuỗi bit được nhúng sẽ được đọc thành cái gì.
| Mục đích | Nội dung được nhúng | Thao tác cần có để đọc ra |
|---|---|---|
| Truyền một tin nhắn ẩn | Một chuỗi bit tùy ý | Người nhận giải mã bằng cùng bảng ánh xạ |
| Xác định nguồn rò rỉ (watermark riêng cho từng người nhận) | Chuỗi bit định danh người nhận | Đối chiếu với mẫu đã ghi lại lúc phân phát |
| Phát hiện sao chép trái phép | Chuỗi bit thể hiện nguồn gốc | Kiểm tra văn bản đã sao chép theo từng code point |
Ví dụ, bằng cách coi 4 loại ký tự zero-width là thông tin 2-bit (U+200B = 00, U+200C = 01, U+200D = 10, U+FEFF = 11) và chèn ký tự zero-width giữa mỗi từ trong văn bản, có thể giấu dữ liệu nhị phân bên trong.
Công nghệ này đôi khi được doanh nghiệp sử dụng để xác định nguồn rò rỉ tài liệu mật. Bằng cách nhúng mẫu ký tự zero-width khác nhau cho mỗi người nhận, khi tài liệu bị rò rỉ ra ngoài, có thể xác định nguồn rò rỉ.
Phát hiện và loại bỏ ký tự vô hình
Để xử lý đúng văn bản bị ký tự vô hình xâm nhập, bạn cần biết phương pháp phát hiện và loại bỏ.
| Phương pháp | Đối tượng | Ví dụ code |
|---|---|---|
| JavaScript regex | Các ký tự zero-width chính | str.replace(/[\u200B-\u200F\u2028-\u202F\uFEFF]/g, '') |
| Python regex | Tương tự | re.sub(r'[\u200b-\u200f\u2028-\u202f\ufeff]', '', text) |
| Trình soạn thảo | Ký tự vô hình và ký tự dễ gây nhầm lẫn | VS Code: editor.unicodeHighlight.invisibleCharacters |
| Dòng lệnh | Ký tự vô hình trong file | cat -v filename hoặc xxd filename |
| PHP | Các ký tự zero-width chính | preg_replace('/[\x{200B}-\x{200F}\x{FEFF}]/u', '', $str) |
Phạm vi mà regex JavaScript /[\u200B-\u200F\u2028-\u202F\uFEFF]/g bao phủ chỉ giới hạn ở "các ký tự zero-width phổ biến nhất". Cho văn bản chạy qua nó thì ZWSP (U+200B) và BOM (U+FEFF) thực sự biến mất, nhưng word joiner (U+2060) và soft hyphen (U+00AD) trong bảng ở đầu bài viết này, cùng các ký tự điều khiển hướng dạng phân tách được nói đến ở phần sau (U+2066 đến U+2069), đều nằm ngoài phạm vi của lớp ký tự và vẫn còn nguyên ở đó. Làm sạch giá trị nhập form ở phía server là biện pháp đúng đắn, nhưng bạn phải tự đếm ra những ký tự thực sự muốn loại bỏ và viết chúng vào lớp ký tự.
Tuy nhiên, loại bỏ vô điều kiện tất cả ký tự vô hình là nguy hiểm. ZWJ cần thiết cho việc tổng hợp emoji, loại bỏ nó sẽ phân rã emoji. ZWNJ không thể thiếu cho hiển thị đúng tiếng Ba Tư và tiếng Hindi. Việc loại bỏ ký tự vô hình phải được thực hiện cẩn thận với sự hiểu biết về mục đích và ngữ cảnh.
Xử lý ký tự vô hình theo ngôn ngữ lập trình
Chuyện gì xảy ra khi một ZWSP lẻn vào mã nguồn phụ thuộc vào ngôn ngữ, và vào phiên bản của toolchain. Một số ngôn ngữ dừng lại với lỗi; số khác âm thầm nhận ký tự đó như một phần của định danh, và nhóm thứ hai này phiền toái hơn nhiều. Bảng dưới đây cho thấy hành vi tiêu biểu tại thời điểm tháng 8 năm 2026. Vì nó có thể thay đổi giữa các trình biên dịch và phiên bản, cách kiểm tra chắc chắn duy nhất là tự thử trên toolchain của bạn.
| Ngôn ngữ | ZWSP bên trong định danh | ZWSP trong chuỗi ký tự | Dấu hiệu phát hiện |
|---|---|---|---|
| JavaScript (Node.js) | Lỗi cú pháp (không phải ký tự được phép trong định danh) | Giữ lại như một phần của chuỗi | ESLint no-irregular-whitespace |
| Python | SyntaxError (bị từ chối vì là ký tự không in được) | Giữ lại như một phần của chuỗi | Chính trình thông dịch từ chối chạy file |
| Rust | Lỗi biên dịch (không thể phân tách token) | Giữ lại như một phần của chuỗi | Cảnh báo uncommon_codepoints với ZWNJ và ZWJ |
| C / C++ (clang) | Được nhận như định danh, chỉ cảnh báo | Giữ lại như một phần của chuỗi | -Wunicode-zero-width của clang |
Điều dễ hiểu ngược nhất ở đây là khác biệt giữa ZWSP và ZWNJ hay ZWJ. Tập ký tự mà JavaScript cho phép bên trong một định danh có chứa ZWNJ (U+200C) và ZWJ (U+200D), và không chứa ZWSP (U+200B). Vì vậy var he\u200Bllo dừng lại với lỗi cú pháp, còn var he\u200Cllo thì đi qua mà không báo gì và khai báo một biến khác với hello trông y hệt nó.
Trên thực tế, hai trường hợp này mang ý nghĩa trái ngược nhau. ZWSP vô hình trong editor nhưng làm chương trình gãy ngay khi chạy, nên bạn luôn biết là nó có ở đó. Trường hợp nguy hiểm là cái đi qua được: cả khai báo lẫn các chỗ tham chiếu đều thành công, rồi một hello gõ tay về sau lại trở thành biến chưa được định nghĩa. Ký tự vẫn vô hình, và tất cả những gì còn lại để nhìn thấy chỉ là một cái tên không khớp.
Khi xem xét hướng dẫn độ dài tên biến và hàm, rủi ro xâm nhập ký tự vô hình cũng nên được lưu ý. Vì không thể phát hiện bằng mắt trong code review, việc thiết lập cơ chế phát hiện tự động qua linter và cài đặt editor là rất quan trọng.
Các mẫu thất bại điển hình do ký tự vô hình gây ra
Cách những ký tự này lẻn vào, và cách mọi thứ gãy sau đó, đều đi theo một số ít mẫu. Điểm chung của chúng là nguyên nhân không nhìn thấy được, nên việc khoanh vùng mất nhiều thời gian.
- So sánh chuỗi thất bại âm thầm: Hai chuỗi trông giống nhau lại không bằng nhau. Nó thường lộ ra dưới dạng dữ liệu test gõ tay thì pass, còn chỉ dữ liệu thật được tạo bằng sao chép mới không khớp
- Tìm kiếm không ra kết quả: Khi tên sản phẩm hay tiêu đề bài viết chứa ký tự zero-width, gõ đúng những gì hiện trên màn hình cũng không còn khớp
- Ràng buộc duy nhất và kiểm tra trùng lặp bị vượt qua: Với một ZWSP nằm giữa địa chỉ email hay một ID, hệ thống coi đó là giá trị khác, nên các bản ghi trông y hệt nhau lại được đăng ký cạnh nhau
- Sao chép dán từ PDF và trang web mang chúng theo: Soft hyphen (U+00AD) và zero-width space được nhúng cho mục đích trình bày bị dán vào nguyên trạng, khiến validation nhập form đánh giá vượt giới hạn ký tự
- Diff không hiển thị chúng: Màn hình review và kết quả diff không vẽ ký tự zero-width, nên chúng lọt qua cả code review lẫn soát bản thảo
Công cụ đếm ký tự và ký tự vô hình
Cách các công cụ đếm ký tự xử lý ký tự vô hình khác nhau tùy công cụ. Một số bỏ qua ký tự vô hình khi đếm, số khác đếm nguyên trạng. Nếu không hiểu kiến thức cơ bản về Unicode, bạn không thể xác định tại sao số đếm ký tự khác nhau giữa các công cụ.
Nếu muốn đếm chính xác số ký tự văn bản, chúng tôi khuyên bạn nên kiểm tra sự tồn tại của ký tự vô hình trước, loại bỏ nếu cần rồi mới đếm. Chỉ cần biết rằng "ký tự vô hình" tồn tại đã có thể ngăn ngừa nhiều sự cố liên quan đến đếm ký tự.
Ký tự vô hình và bảo mật - Mối đe dọa không nhìn thấy
Trojan Source đã nói ở trên lợi dụng chín ký tự vô hình dùng để điều khiển văn bản hai chiều (U+202A đến U+202E cho nhúng và ghi đè, U+2066 đến U+2069 cho phân tách) để tạo ra khoảng cách giữa giao diện mã nguồn và logic thực thi thực tế. Ngoài ra còn có những con đường khác biến ký tự vô hình thành vấn đề bảo mật.
| Phương pháp tấn công | Ký tự vô hình sử dụng | Tác động | Biện pháp đối phó |
|---|---|---|---|
| Trojan Source | Ký tự điều khiển hướng (U+202A-U+202E, U+2066-U+2069) | Logic độc hại không phát hiện được trong code review | Bật cảnh báo trình biên dịch |
| Tấn công homograph | Ký tự khác nhau trông giống nhau (U+0430 vs U+0061) | Giả mạo URL phishing | Kiểm tra hiển thị Punycode |
| ZWSP injection | U+200B | Vượt qua validation đầu vào | Loại bỏ ký tự vô hình phía server |
| BOM injection | U+FEFF | Lỗi file parser | Xử lý tự động loại bỏ BOM |
Cơ chế làm cuộc tấn công này hoạt động rất đơn giản. Một khi ký tự điều khiển hướng được gài vào bên trong một comment hay một chuỗi ký tự, editor và màn hình diff trên web sẽ sắp xếp lại thứ tự ký tự để hiển thị đúng như được chỉ thị, còn trình biên dịch thì đọc dãy byte theo thứ tự gốc. Chỉ thế là đủ tạo ra khoảng cách khiến đoạn code đọc trên màn hình là "kiểm tra quyền rồi mới thực thi xử lý" thực tế lại thực thi xử lý mà không kiểm tra. Cái bị lợi dụng không phải là một ký tự không hợp lệ, mà là cơ chế chính đáng tồn tại để hiển thị đúng văn bản hai chiều.
Cuộc tấn công này đặc biệt nguy hiểm vì nó vô hiệu hóa code review - quy trình xác minh bằng mắt người. Biện pháp đối phó bao gồm bật cài đặt cảnh báo về việc sử dụng ký tự điều khiển hướng trong compiler và linter, và tích hợp bước phát hiện ký tự vô hình vào pipeline CI/CD.
Như đã đề cập trong bài viết cách viết Git commit message, việc sử dụng linter là không thể thiếu cho quản lý chất lượng code. Phát hiện ký tự vô hình cũng là một trong những vai trò quan trọng của linter.