2 điểm bởi GN⁺ 2025-02-13 | 1 bình luận | Chia sẻ qua WhatsApp
  • Bằng cách nối các variation selector của Unicode, có thể giấu một chuỗi byte phía sau một ký tự; chúng không hiển thị trên màn hình nhưng vẫn đi theo khi sao chép/dán
  • Có 256 variation selector, từ VS-1 đến VS-256, nên có thể tạo ánh xạ khớp chính xác với phạm vi 1 byte
  • Ngay cả khi gắn các byte của hello[0x68, 0x65, 0x6c, 0x6c, 0x6f] sau 😊, bề ngoài nó vẫn trông như một emoji bình thường
  • Việc giải mã tìm các phạm vi U+FE00..U+FE0FU+E0100..U+E01EF rồi chuyển lại thành byte; ký tự cơ sở không nhất thiết phải là emoji
  • Cách này là sự lạm dụng Unicode và có thể bị lợi dụng để vượt qua bộ lọc nội dung của con người hoặc cài watermark vào văn bản

Cách dữ liệu vô hình được gắn vào một ký tự

  • Văn bản Unicode được biểu diễn bằng một chuỗi codepoint và thường được ghi theo dạng U+XXXX
  • Với các chữ cái Latin đơn giản, codepoint và ký tự hiển thị trên màn hình tương ứng 1:1
    • Ví dụ: U+0067 biểu diễn ký tự g
  • Trong các hệ chữ khác, một ký tự hiển thị trên màn hình có thể được cấu thành từ nhiều codepoint
    • Ví dụ: trong Devanagari, chữ đọc là ki được biểu diễn bằng cặp liên tiếp U+0915U+0940

Dùng variation selector như kho lưu trữ dữ liệu

  • Unicode định nghĩa 256 codepoint variation selector, được đặt tên từ VS-1 đến VS-256
  • Bản thân variation selector không được hiển thị trên màn hình, mà được dùng để thay đổi cách hiển thị của ký tự đứng trước
  • Phần lớn ký tự Unicode không có biến thể liên kết, nhưng vì Unicode hướng đến khả năng tương thích trong tương lai, mã xử lý không hiểu ý nghĩa cũng phải bảo toàn variation selector
    • Ngay cả khi gắn U+FE01 (VS-2) sau U+0067 (g), trên màn hình nó vẫn trông như chữ g thường
    • Khi sao chép/dán, variation selector cũng đi kèm
  • Vì 256 variation selector đúng bằng số lượng có thể biểu diễn 1 byte, nên có thể giấu dữ liệu 1 byte sau một codepoint Unicode bất kỳ
  • Đặc tả Unicode không xử lý cụ thể các chuỗi có nhiều variation selector liên tiếp, và ngụ ý rằng chúng nên bị bỏ qua trong quá trình render
  • Nếu nối nhiều variation selector, có thể biểu diễn một chuỗi byte tùy ý phía sau một ký tự

Mã hóa byte thành variation selector

  • Variation selector được chia thành hai phạm vi codepoint
    • U+FE00 .. U+FE0F: 16 cái đầu tiên
    • U+E0100 .. U+E01EF: 240 cái còn lại
  • Quy tắc chuyển byte thành variation selector rất đơn giản
    • Nếu byte nhỏ hơn 16 thì 0xFE00 + byte
    • Ngược lại thì 0xE0100 + (byte - 16)
  • Khi mã hóa, trước tiên đặt một ký tự cơ sở (base character), rồi chuyển từng byte thành variation selector và nối phía sau
fn byte_to_variation_selector(byte: u8) -> char {
    if byte < 16 {
        char::from_u32(0xFE00 + byte as u32).unwrap()
    } else {
        char::from_u32(0xE0100 + (byte - 16) as u32).unwrap()
    }
}
fn encode(base: char, bytes: &[u8]) -> String {
    let mut result = String::new();
    result.push(base);
    for byte in bytes {
        result.push(byte_to_variation_selector(*byte));
    }
    result
}
  • Nếu gắn các byte biểu diễn hello[0x68, 0x65, 0x6c, 0x6c, 0x6f] sau 😊, chuỗi tạo ra bề ngoài sẽ trông như một emoji bình thường
  • Trong kết quả xuất thông thường, các ký tự ẩn không thấy được, nhưng nếu in bằng định dạng debug của Rust thì các codepoint ẩn như \u{e0158} sẽ lộ ra
"😊\u{e0158}\u{e0155}\u{e015c}\u{e015c}\u{e015f}"

Cách đọc lại các byte ẩn

  • Việc giải mã duyệt qua các ký tự và chuyển các codepoint nằm trong phạm vi variation selector trở lại thành byte
  • Phạm vi U+FE00..U+FE0F được khôi phục bằng variation_selector - 0xFE00
  • Phạm vi U+E0100..U+E01EF được khôi phục bằng variation_selector - 0xE0100 + 16
  • Các ký tự thường trước khi gặp variation selector đầu tiên được xem là ký tự cơ sở và bị bỏ qua
  • Nếu gặp ký tự không phải variation selector và đã có kết quả, quá trình giải mã sẽ kết thúc
fn variation_selector_to_byte(variation_selector: char) -> Option<u8> {
    let variation_selector = variation_selector as u32;
    if (0xFE00..=0xFE0F).contains(&variation_selector) {
        Some((variation_selector - 0xFE00) as u8)
    } else if (0xE0100..=0xE01EF).contains(&variation_selector) {
        Some((variation_selector - 0xE0100 + 16) as u8)
    } else {
        None
    }
}
  • Sau khi giải mã cùng kết quả mã hóa đó và diễn giải theo UTF-8, sẽ thu được "hello"
  • Ký tự cơ sở không nhất thiết phải là emoji; việc xử lý variation selector cũng giống nhau với ký tự thông thường
  • Lý do dùng emoji là vì vui hơn

Khả năng bị lạm dụng

  • Cách này là lạm dụng Unicode và không nên sử dụng
  • Trong kết quả đã render, dữ liệu không hiển thị, nên các moderator hoặc reviewer là con người khó biết rằng có dữ liệu ẩn tồn tại
  • Nó có thể bị lạm dụng như một cách giấu dữ liệu để vượt qua bộ lọc nội dung của con người
  • Cũng có thể dùng cho watermarking văn bản
    • Sau khi gửi thông điệp cho nhiều người, nếu bị rò rỉ thì có thể truy vết người nhận ban đầu
    • Chuỗi variation selector sống sót qua hầu hết thao tác sao chép/dán
    • Cho phép mật độ dữ liệu tùy ý, và nếu muốn có thể chèn watermark vào từng ký tự

LLM có thể xử lý dữ liệu ẩn không

  • Sau khi bài được đăng lên Hacker News, đã có câu hỏi về việc LLM xử lý loại dữ liệu ẩn này như thế nào
  • Nhìn chung, tokenizer có vẻ bảo toàn variation selector dưới dạng token, nên về lý thuyết mô hình có thể truy cập được
  • OpenAI tokenizer là công cụ kiểm tra có thể dùng để xác nhận điều này
  • Nhìn chung, các mô hình dường như không tự cố gắng giải mã trực tiếp ở bên trong
  • Khi dùng cùng code interpreter, một số mô hình có thể giải dữ liệu ẩn
    • Gemini 2 Flash đã dùng Codename GooseforeverVM để giải một ví dụ trong 7 giây
    • Cũng có một video dài cho thấy Claude giải một ví dụ

1 bình luận

 
GN⁺ 2025-02-13
Các ý kiến trên Hacker News
  • Về việc lạm dụng Unicode thì đây chỉ là phần nổi của tảng băng. Với các kỹ thuật tương tự, có thể làm tràn bộ đệm trong nhiều hệ thống nhận chuỗi Unicode; thường thì chỉ kết thúc bằng lỗi hoặc crash, nhưng nếu may mắn cũng có thể tạo ra vài hành vi khá thú vị
    Thời trước Python 3, khi làm pentest, tôi từng dùng riêng các dấu phụ để kéo một ký tự thành nhiều byte và làm tràn bộ đệm của web server backend. Khi đó chỉ là crash và tự động khởi động lại, nhưng nếu đào đủ sâu thì có vẻ có thể dùng để khai thác một hệ thống hay phần mềm cụ thể

    • Bài "encrypted runner" ở vòng loại Google CTF 2024 dựa trên ý tưởng này
    • Đúng vậy. Văn bản Zalgo là một ca kiểm thử phổ biến cho các trường nhập liệu trên website, nhưng phần lớn không có gì thú vị xảy ra. Đôi khi chỉ chạm tới ngoại lệ giới hạn độ dài của cơ sở dữ liệu; thường thì tiến trình cũng không chết và ngoại lệ kết thúc ngay trong luồng hiện tại
      Ngay cả với các form hiện đại, chỉ cần tắt JavaScript cũng có thể gây ra chuyện tương tự; trường hợp tốt nhất là debug đang bật nên stack trace hoặc truy vấn được in ra, rò rỉ một chút thông tin. Một lỗi phổ biến khác là đếm sai độ dài \n\r\n trong chuỗi văn bản: JavaScript thường tính carriage return là 1 byte, nhưng đặc tả HTTP yêu cầu 2 byte
      unescape(encodeURIComponent("ç")).length là một cách đại khái để kiểm tra nhanh độ dài theo byte trong JavaScript, còn vấn đề \r\n thì chỉ cần chuẩn hóa chuỗi trước khi đếm độ dài
    • Tôi là người mới, có thể giải thích thêm chuyện này xảy ra như thế nào hoặc bạn đã làm ra sao không? Nghe như một lỗ hổng đáng để thử nghiệm
  • Cái này dễ thương, nhưng không thật sự cần thiết. Unicode có một vùng lớn gọi là PUA (private use area); các mã trong vùng này không được ánh xạ tới ký tự nào và cũng sẽ không được ánh xạ trong tương lai, nên được dùng cho mục đích nội bộ/tùy biến của người dùng
    Ví dụ trong fish-shell, khi phân tích token thành chuỗi một cách an toàn, các ký tự đặc biệt chưa được escape sẽ được đổi thành các code point Unicode khác trong chuỗi nhưng đặt vào vùng PUA, rồi bị chặn lại ở giai đoạn sau trong pipeline. Không nên để chúng lộ ra ngoài ranh giới API, nhưng khi gặp thì khuyến nghị là cứ truyền nguyên trạng, và phần lớn hệ thống cùng thư viện cũng làm vậy. Đây có thể là một kênh rò rỉ rõ ràng, nhưng nhiều lập trình viên bình thường không biết nhiều về Unicode ngoài mức “để tránh vấn đề quốc tế hóa thì luôn dùng Unicode”, nên thường để hở như thế

    • Tôi tự thử thì ký tự private use được render thành ô vuông trong môi trường của tôi (󰀀). Điểm chính ở đây là mã hóa sao cho khi copy-paste nó bị ẩn đi và được coi như “một phần” của ký tự khác
    • Khác biệt là ký tự PUA thường được render khá dễ thấy theo cách nào đó, còn variation selector thì không
    • Có một bối cảnh bị bỏ sót: đây là ý tưởng nảy sinh từ cuộc thảo luận quanh bài gửi về Open Heart Protocol
      https://news.ycombinator.com/item?id=42791378
      Vì API bị ràng buộc chỉ nhận emoji, khả năng dùng vào mục đích phạm pháp đã lập tức được bàn tới. Trong trường hợp đó không thể dùng PUA mà phải mã hóa bên trong emoji
    • Cái này chẳng phải gần với mục đích của noncharacter được chỉ định hơn là private-use area sao? PUA cũng được dùng cho các mã hóa không chính thức của những hệ chữ chưa có trong Unicode, hoặc những thứ như logo Apple, nên dùng theo cách này sẽ lo bị xung đột
      Các noncharacter được chỉ định bao gồm 0xFFFF, 0xFFFE và hai code point cuối của mỗi mặt phẳng, cũng như một vùng ở giữa Arabic Presentation Forms. Tôi hiểu là danh sách này về sau đã được bổ sung để người ta có thêm noncharacter dùng theo kiểu này
    • Nói thật là tôi đã dán bình luận này vào bộ giải mã được cung cấp. Tôi nghĩ không thể nào lại bỏ lỡ trọng tâm đến mức này, chắc phải có thông điệp ẩn bên trong, nhưng có vẻ là họ thật sự bỏ lỡ hoặc website này đang loại bỏ chúng
      Không thể watermark vô hình các ký tự tùy ý bằng các ký tự PUA không được nhận dạng. Chúng không được xử lý như ký tự kết hợp. Thay vào đó sẽ xuất hiện một ô placeholder được render riêng. Ví dụ: — tất nhiên nếu bạn đang tự dùng private use area theo cách riêng thì có thể nó không phải là ô vuông
  • Khoảng 10 năm trước, tôi từng làm đồng nghiệp giật mình bằng cách chèn U+202D LEFT-TO-RIGHT OVERRIDE vào giữa tên tệp trên Windows. funnypicturegnp.exe trông như funnypictureexe.png
    Nếu thêm cả icon tùy chỉnh trông như ảnh preview thì khá thuyết phục

    • Tôi từng làm mảng phát hiện phishing, và đây là mẫu mà kẻ tấn công hay dùng. .exe hầu hết bị chặn tự động, nhưng phần mở rộng độc hại ngày nay thường là .html, rồi dùng window.location redirect đã bị làm rối để mở trang đăng nhập giả
      Lạm dụng RTL kiểu cute-cat-lmth.png tương đối phổ biến, nhưng cũng rất dễ phát hiện, và những email như vậy bị đánh dấu phishing ngay lập tức
    • Phiên bản mã nguồn của trò này là CVE-2021-42574, và còn có website nữa
      https://trojansource.codes/
      Về cơ bản có thể giấu mã trông như comment nhưng khi biên dịch lại hoạt động như code. Tuy nhiên tôi nhớ tình trạng CVE của nó từng gây tranh cãi vì nhiều trình soạn thảo văn bản đã hiển thị các comment đáng ngờ kiểu này rồi
    • Tôi không biết riêng chiêu này, nhưng cũng mừng vì thói quen hoang tưởng suốt mấy chục năm qua của tôi — luôn mở các file media có khả năng đáng ngờ bằng “nhấp phải → Open with” — đã được biện minh
    • Tôi từng tạo một file bat tên là guitar_tab.txt
  • Trong một trường hợp sử dụng thực tế, Sanity đã dùng mẹo này để mã hóa Content Source Maps vào trong chính văn bản thật được cung cấp cho trang web ở “chế độ xem trước”0. Biên tập viên có thể dễ dàng truy ngược đến vị trí gốc sâu bên trong cấu trúc nội dung chỉ bằng cách nhấp vào văn bản hoặc nội dung đó
    Cũng có nhược điểm và giới hạn. Ví dụ, cần ngăn không cho nó được thêm vào các giá trị phải được phân tích cú pháp hoặc sử dụng nguyên trạng như ngày tháng/dấu thời gian, URL, ID. Dù vậy, đây vẫn là một mẹo khá thú vị
    0 https://www.sanity.io/docs/stega
    [1] https://github.com/sanity-io/content-source-maps

  • Tôi thích ý tưởng dùng cái này cho watermark đầu ra LLM. Nó chạm đúng điểm. Dù sao thì 99% các trình tạo chất lượng thấp chỉ biết sao chép/dán cũng sẽ bị phát hiện, còn các trường hợp sử dụng cốt lõi khác hầu như không bị ảnh hưởng
    Cũng tò mò là sẽ chèn bao nhiêu vào mỗi ký tự hoặc mỗi token đầu ra. Có thể là ID người dùng, tham chiếu prompt, ngày tháng, số thứ tự token? Tôi cũng tò mò terminal sẽ diễn giải nó thế nào; thật sự rất hay

    • Tôi không hiểu vì sao mọi người nghĩ watermark AI sẽ hoạt động. Bất kỳ watermark nào cũng có thể bị gỡ bỏ ngay lập tức và dễ dàng, nên nó sẽ không bao giờ hoạt động đúng nghĩa
      Biện pháp phòng vệ AI thực sự duy nhất là yêu cầu mọi tương tác của con người phải có chữ ký khóa đã được xác minh bằng danh tính thật, nhưng điều đó A: sẽ không bao giờ xảy ra, và B: có thể bị lạm dụng ở các nước có chính phủ tham nhũng, hoặc ở những nước có chính phủ tham nhũng chịu ảnh hưởng mạnh của ngành tư nhân, chẳng hạn như Mỹ
    • Có quá nhiều bước tiền xử lý trước khi đưa vào dataset, nên tôi sẽ khá ngạc nhiên nếu trò này thật sự hiệu quả trong thực tế
    • Trên hầu hết terminal Linux, thứ bạn truyền vào cứ thế đi qua như một chuỗi byte. Kỹ thuật này hợp lệ với UTF-8 và không dùng glyph bổ sung, nên trong các terminal tuân thủ Unicode thì mắt người sẽ không nhìn thấy. Tôi đã thử trên vài cái
      Tất nhiên nếu đưa câu vào xxd thì nó sẽ hiện ra. Đề xuất PUA trong bình luận cấp cao nhất hiện tại thì khác ở chỗ nó sẽ lộ ngay
      Thử thêm thì thấy sau khi dán vào terminal, qua xxd thông điệp đi qua hoàn toàn không biến đổi, nhưng nếu chọn lại trong terminal rồi dán lại, ở X selection của mate terminal và konsole thì nó bị cắt, chỉ còn vài từ. Tôi không biết việc cắt này là do terminal hay do X. Trong xterm, chữ e cuối cùng bị biến đổi và nội dung được chọn còn bị cắt nhiều hơn
      Khi ghi vào file thì câu được ghi lại không biến đổi. Vì vậy có vẻ giống trường hợp một phần dữ liệu bị rơi mất khi sao chép ra khỏi terminal hơn. Tôi đã echo câu vào một file thử nghiệm, mở bằng trình duyệt rồi sao chép văn bản để kiểm tra
    • Với watermark LLM, còn có những cách tiếp cận khác bền vững hơn nhiều và khó phát hiện hơn. Chúng tận dụng việc LLM tạo ra một phân phối xác suất cho từng token tiếp theo có thể có, rồi lấy mẫu ngẫu nhiên từ đó để tạo đầu ra
      Nếu can thiệp vào cách lấy mẫu khi sinh văn bản, sau này có thể chạy lại LLM để quan sát dạng đầu ra và phát hiện dấu vân tay. Ví dụ như luân phiên chọn token có xác suất cao và token có xác suất thấp. Tất nhiên triển khai thực tế sẽ tinh vi hơn nhiều, nhưng ý tưởng là theo hướng đó
  • Một điểm thú vị là trình đọc màn hình có thể phát hiện các variation selector này khi di chuyển theo từng ký tự. Nếu dùng phím mũi tên di chuyển trên ví dụ, nó sẽ đọc kiểu “Smiling face with smiling eyes”, “Symbol e zero one five five”, “Symbol e zero one five c”
    Tuy nhiên điều này còn tùy vào bộ tổng hợp giọng nói đang dùng, và nếu chỉ đọc tài liệu bình thường thì không thể biết có những ký tự đó hay không, nên nhìn chung cũng không phải lợi ích lớn

    • Vì văn bản trực tuyến nói chung bị ô nhiễm bởi những ký tự không nhìn thấy nhưng nghe thì khó chịu, tôi đang dùng một script trong trình đọc màn hình của mình để loại bỏ toàn bộ ký tự không phải ASCII
  • StegCloak0 cũng thuộc nhóm tương tự, và đẩy ý tưởng này xa hơn một bước bằng cách mã hóa payload ẩn bằng AES-256-CTR. Một mẹo nhỏ khá hay
    0 https://github.com/KuroLabs/stegcloak

    • Có vẻ trong số các plugin Better Discord có cái dùng cách này hoặc cách tương tự. Nó cho phép gửi thông điệp được mã hóa hoàn toàn trông như không có gì với người khác
      Tuy nhiên để phía kia giải mã thì phải chia sẻ giá trị bí mật mật khẩu
    • Tôi đã định thử trong bản ghi TXT của Cloudflare DNS, nhưng Cloudflare thông minh đến mức giải mã luôn khi dán vào trường TXT
  • Tiêu đề hơi dễ gây hiểu lầm. Nội dung nói rằng “ký tự cơ sở không nhất thiết phải là emoji, và cách xử lý variation selector cũng giống với ký tự thông thường. Dùng emoji chỉ khiến nó thú vị hơn thôi”
    Nếu dùng cách này với ký tự không phải emoji thì nó sẽ kín đáo hơn và phiền phức hơn

    • Tôi không nghĩ nó phiền đến mức đó. Có thể tạo detector không khó. Nếu một ký tự không có biến thể thực tế mà lại được gắn variant thì cứ hiển thị nó ra. Ngược lại, có vẻ còn có thể dùng cho mục đích ký tên
  • Hơn cả watermark đầu ra LLM đơn thuần, đây có vẻ có thể là một cách gọn gàng để đóng gói kèm dữ liệu logprobs
    Về cơ bản là bao gồm thông tin xác suất của mọi token đã được sinh ra, nhằm đem lại một chút minh bạch cho quá trình tạo. Nó cũng có trong đặc tả OpenAI API, và nhiều engine như llama.cpp cũng cung cấp thông tin này. Thường thì nó được gắn dưới dạng một trường riêng, nhưng cũng có các cách trực quan hóa như mikupad0
    Có lẽ đây là một ý tưởng tồi, nhưng vẫn là một ý tưởng khiến người ta thấy ngứa ngáy trong đầu

  • Đây là một kỹ thuật rất hay. Nó phản ánh ASCII và còn có ký tự Unicode Tag, một thành phần UI, đặc biệt là trong ứng dụng web, thường không thấy xuất hiện
    Điểm độc đáo của ký tự Tag là một số LLM diễn giải văn bản ẩn thành ASCII và làm theo chỉ thị, thậm chí còn có thể viết trực tiếp chúng
    https://embracethered.com/blog/posts/2024/hiding-and-finding...
    Cũng có một proof-of-concept exploit thực tế mà Microsoft đã sửa trong Copilot
    https://embracethered.com/blog/posts/2024/m365-copilot-promp...)