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

Khoảng 11 phút đọc

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 PointMục đíchĐếm ký tựChiều rộng hiển thị
Zero Width Space (ZWSP)U+200BChỉ định vị trí có thể ngắt dòngTính là 1 ký tự0
Zero Width Joiner (ZWJ)U+200DNối ký tự (tổng hợp emoji)Tính là 1 ký tự0
Zero Width Non-Joiner (ZWNJ)U+200CNgă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ảnTính là 1 ký tự0
Right-to-Left Mark (RLM)U+200FĐiều khiển hướng văn bảnTính là 1 ký tự0
Byte Order Mark (BOM)U+FEFFNhận dạng encodingTí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+00ADChỉ định vị trí gạch nốiTính là 1 ký tựThường là 0 (chỉ hiển thị khi ngắt dòng)
Word Joiner (WJ)U+2060Chỉ định vị trí không ngắt dòngTí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ư:

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ầnSố code pointĐếm ký tự (JavaScript)
👨‍👩‍👧‍👦 (Gia đình)👨 + ZWJ + 👩 + ZWJ + 👧 + ZWJ + 👦711 (bao gồm surrogate pair)
👩‍💻 (Nữ kỹ thuật viên)👩 + ZWJ + 💻35
🏳️‍🌈 (Cờ cầu vồng)🏳️ + ZWJ + 🌈46
👨‍🍳 (Đầu bếp nam)👨 + ZWJ + 🍳35

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:

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 đíchNội dung được nhúngThao tác cần có để đọc ra
Truyền một tin nhắn ẩnMộ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épChuỗi bit thể hiện nguồn gốcKiể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ượngVí dụ code
JavaScript regexCác ký tự zero-width chínhstr.replace(/[\u200B-\u200F\u2028-\u202F\uFEFF]/g, '')
Python regexTương tựre.sub(r'[\u200b-\u200f\u2028-\u202f\ufeff]', '', text)
Trình soạn thảoKý tự vô hình và ký tự dễ gây nhầm lẫnVS Code: editor.unicodeHighlight.invisibleCharacters
Dòng lệnhKý tự vô hình trong filecat -v filename hoặc xxd filename
PHPCác ký tự zero-width chínhpreg_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 danhZWSP 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ỗiESLint no-irregular-whitespace
PythonSyntaxError (bị từ chối vì là ký tự không in được)Giữ lại như một phần của chuỗiChính trình thông dịch từ chối chạy file
RustLỗi biên dịch (không thể phân tách token)Giữ lại như một phần của chuỗiCảnh báo uncommon_codepoints với ZWNJ và ZWJ
C / C++ (clang)Được nhận như định danh, chỉ cảnh báoGiữ 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.

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ôngKý tự vô hình sử dụngTác độngBiện pháp đối phó
Trojan SourceKý 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 reviewBật cảnh báo trình biên dịch
Tấn công homographKý tự khác nhau trông giống nhau (U+0430 vs U+0061)Giả mạo URL phishingKiểm tra hiển thị Punycode
ZWSP injectionU+200BVượt qua validation đầu vàoLoại bỏ ký tự vô hình phía server
BOM injectionU+FEFFLỗi file parserXử 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.

Chia sẻ bài viết này