- หากนำ variation selector ของ Unicode มาเรียงต่อกัน จะสามารถซ่อนลำดับไบต์ไว้หลังอักขระหนึ่งตัวได้ แม้จะไม่แสดงบนหน้าจอ แต่ยังติดไปกับการคัดลอก·วาง
- มี variation selector ทั้งหมด 256 ตัว ตั้งแต่ VS-1 ถึง VS-256 จึงสร้างการแมปที่ตรงกับช่วง 1 ไบต์ได้พอดี
- แม้จะต่อท้าย
😊 ด้วยไบต์ของ hello คือ [0x68, 0x65, 0x6c, 0x6c, 0x6f] ภายนอกก็ยังดูเหมือนอีโมติคอนธรรมดาหนึ่งตัว
- การถอดรหัสทำได้โดยค้นหาช่วง
U+FE00..U+FE0F และ U+E0100..U+E01EF แล้วแปลงกลับเป็นไบต์ โดย อักขระฐาน ไม่จำเป็นต้องเป็นอีโมติคอนก็ได้
- วิธีนี้เป็นการใช้ Unicode ในทางที่ผิด และอาจถูกนำไปใช้หลบเลี่ยงตัวกรองคอนเทนต์ของมนุษย์ หรือฝัง ลายน้ำ ลงในข้อความได้
วิธีที่ข้อมูลที่มองไม่เห็นถูกแนบกับอักขระหนึ่งตัว
- ข้อความ Unicode แสดงเป็นลำดับของ โค้ดพอยต์ (codepoint) และโดยทั่วไปเขียนในรูปแบบ
U+XXXX
- ในอักขระละตินแบบง่าย โค้ดพอยต์กับอักขระที่มองเห็นบนหน้าจอจะสอดคล้องกันแบบ 1:1
- ตัวอย่าง:
U+0067 แทนอักขระ g
- ในระบบตัวเขียนอื่น อักขระหนึ่งตัวบนหน้าจออาจประกอบด้วยหลายโค้ดพอยต์
- ตัวอย่าง: ในเทวนาครี อักขระที่อ่านว่า
คี แสดงด้วยคู่ลำดับของ U+0915 และ U+0940
ใช้ variation selector เหมือนที่เก็บข้อมูล
- Unicode กำหนดโค้ดพอยต์ variation selector ไว้ 256 ตัว มีชื่อตั้งแต่ VS-1 ถึง VS-256
- ตัว variation selector เองจะไม่แสดงบนหน้าจอ และใช้เพื่อเปลี่ยนรูปแบบการแสดงผลของอักขระก่อนหน้า
- อักขระ Unicode ส่วนใหญ่ไม่มีรูปแบบแปรผันที่เชื่อมโยงอยู่ แต่เนื่องจาก Unicode มุ่งหวังความเข้ากันได้ในอนาคต โค้ดที่ประมวลผลแต่ไม่เข้าใจความหมายก็ควรเก็บ variation selector ไว้
- แม้ใส่
U+FE01 (VS-2) ต่อท้าย U+0067 (g) บนหน้าจอก็ยังดูเหมือนตัวพิมพ์เล็ก g
- เมื่อคัดลอก·วาง variation selector ก็จะติดไปด้วย
- variation selector จำนวน 256 ตัวเป็นจำนวนที่แทน 1 ไบต์ ได้พอดี จึงซ่อนข้อมูล 1 ไบต์ไว้หลังโค้ดพอยต์ Unicode ใด ๆ ได้
- ข้อกำหนด Unicode ไม่ได้ระบุรายละเอียดของลำดับที่มี variation selector หลายตัวต่อกัน และบอกเป็นนัยว่าควรถูกละเว้นระหว่างการเรนเดอร์
- หากนำ variation selector หลายตัวมาเรียงต่อกัน ก็สามารถแทน ลำดับไบต์ใด ๆ ไว้หลังอักขระหนึ่งตัวได้
การเข้ารหัสไบต์เป็น variation selector
- variation selector แบ่งเป็นสองช่วงโค้ดพอยต์
U+FE00 .. U+FE0F: 16 ตัวแรก
U+E0100 .. U+E01EF: อีก 240 ตัวที่เหลือ
- กฎในการแปลงไบต์เป็น variation selector นั้นเรียบง่าย
- ถ้าไบต์น้อยกว่า 16 ให้ใช้
0xFE00 + byte
- นอกนั้นให้ใช้
0xE0100 + (byte - 16)
- การเข้ารหัสจะใส่ อักขระฐาน (base character) หนึ่งตัวก่อน แล้วแปลงแต่ละไบต์เป็น variation selector และต่อท้ายเข้าไป
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
}
- เมื่อนำไบต์ที่แทน
hello คือ [0x68, 0x65, 0x6c, 0x6c, 0x6f] ไปต่อท้าย 😊 ก็จะได้สตริงที่ภายนอกดูเหมือนอีโมติคอนธรรมดา
- ในเอาต์พุตทั่วไปจะมองไม่เห็นอักขระที่ซ่อนอยู่ แต่ถ้าพิมพ์ด้วยฟอร์แมตดีบักของ Rust จะเห็นโค้ดพอยต์ที่ซ่อนอยู่ เช่น
\u{e0158}
"😊\u{e0158}\u{e0155}\u{e015c}\u{e015c}\u{e015f}"
วิธีอ่านไบต์ที่ซ่อนอยู่กลับมา
- การถอดรหัสทำโดยวนผ่านอักขระ แล้วแปลงโค้ดพอยต์ที่อยู่ในช่วง variation selector กลับเป็นไบต์
- ช่วง
U+FE00..U+FE0F กู้คืนด้วย variation_selector - 0xFE00
- ช่วง
U+E0100..U+E01EF กู้คืนด้วย variation_selector - 0xE0100 + 16
- อักขระทั่วไปก่อนพบ variation selector ตัวแรกจะถือเป็น อักขระฐาน และถูกละเว้น
- หากพบอักขระที่ไม่ใช่ variation selector และมีผลลัพธ์อยู่แล้ว ให้ยุติการถอดรหัส
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
}
}
- เมื่อนำผลลัพธ์การเข้ารหัสเดียวกันมาถอดรหัสแล้วตีความเป็น UTF-8 จะได้
"hello"
- อักขระฐานไม่จำเป็นต้องเป็นอีโมติคอน และการประมวลผล variation selector ก็เหมือนกันในอักขระทั่วไป
- เหตุผลที่ใช้อีโมติคอนคือมันสนุกกว่า
ความเป็นไปได้ในการนำไปใช้ในทางที่ผิด
- วิธีนี้เป็น การใช้ Unicode ในทางที่ผิด และไม่ควรใช้
- ในผลลัพธ์ที่เรนเดอร์แล้ว ข้อมูลจะมองไม่เห็น ทำให้ผู้ดูแลหรือผู้รีวิวที่เป็นมนุษย์รู้ได้ยากว่ามีข้อมูลซ่อนอยู่
- อาจถูกนำไปใช้ในทางที่ผิดเพื่อซ่อนข้อมูลให้ผ่านตัวกรองคอนเทนต์ของมนุษย์
- ยังใช้กับ การทำลายน้ำ ในข้อความได้
- หลังส่งข้อความให้หลายคน หากมีการรั่วไหล ก็สามารถติดตามผู้รับเดิมได้
- ลำดับ variation selector จะรอดผ่านการคัดลอก·วางส่วนใหญ่
- รองรับความหนาแน่นของข้อมูลตามต้องการ และถ้าต้องการก็ใส่ลายน้ำในทุกอักขระได้
LLM ประมวลผลข้อมูลที่ซ่อนอยู่ได้หรือไม่
- หลังจากถูกโพสต์บน Hacker News ก็มีคำถามว่า LLM จัดการข้อมูลที่ซ่อนแบบนี้อย่างไร
- โดยทั่วไป tokenizer ดูเหมือนจะ เก็บรักษา variation selector ไว้เป็นโทเค็น ดังนั้นในทางทฤษฎีโมเดลจึงเข้าถึงได้
- OpenAI tokenizer เป็นเครื่องมือตรวจสอบที่ใช้ยืนยันเรื่องนี้ได้
- โดยภาพรวม โมเดลดูเหมือนจะไม่ได้พยายามถอดรหัสโดยตรงจากภายใน
- เมื่อใช้ร่วมกับ code interpreter บางโมเดลสามารถแกะข้อมูลที่ซ่อนอยู่ได้
1 ความคิดเห็น
ความคิดเห็นจาก Hacker News
ในแง่การใช้ Unicode ในทางที่ผิด นี่เป็นแค่ ยอดภูเขาน้ำแข็ง เท่านั้น เทคนิคคล้าย ๆ กันสามารถทำให้บัฟเฟอร์ล้นในหลายระบบที่รับสตริง Unicode ได้ และโดยทั่วไปมักจบลงที่ข้อผิดพลาดหรือแครช แต่ถ้าโชคดีอาจได้พฤติกรรมที่น่าสนใจทีเดียว
สมัยก่อน Python 3 ตอนทำ penetration testing ผมเคยใช้แค่เครื่องหมายกำกับเสียงทำให้อักขระตัวเดียวขยายเป็นหลายไบต์จนทำให้บัฟเฟอร์ของเว็บเซิร์ฟเวอร์ฝั่งแบ็กเอนด์ล้น ตอนนั้นก็แค่แครชกับรีสตาร์ตอัตโนมัติ แต่ถ้าขุดลึกพอ ดูเหมือนจะใช้เป็น exploit กับระบบหรือซอฟต์แวร์บางอย่างได้
แม้แต่ในฟอร์มสมัยใหม่ ก็ทำให้เกิดอาการคล้ายกันได้เพียงแค่ปิด JavaScript และกรณีดีที่สุดคือเปิด debug ไว้ ทำให้มี stack trace หรือ query ถูกพิมพ์ออกมา และมีข้อมูลรั่วเล็กน้อย อีกความผิดพลาดที่พบบ่อยคือการนับความยาว
\nกับ\r\nในสตริงข้อความผิด โดย JavaScript มักนับ carriage return เป็น 1 ไบต์ แต่สเปก HTTP ต้องการ 2 ไบต์unescape(encodeURIComponent("ç")).lengthเป็นวิธีแบบคร่าว ๆ ในการเช็กความยาวเป็นไบต์อย่างรวดเร็วใน JavaScript และปัญหา\r\nก็แก้ได้ด้วยการจัดระเบียบสตริงก่อนนับความยาวอันนี้น่ารักดี แต่ก็ไม่ได้จำเป็นขนาดนั้น Unicode มีช่วงใหญ่ที่เรียกว่า PUA(private use area) ซึ่งโค้ดในช่วงนี้ไม่ได้แมปกับอักขระใด ๆ และจะไม่ถูกแมปในอนาคต จึงใช้สำหรับงานภายในหรือกำหนดเองโดยผู้ใช้
ตัวอย่างเช่น ใน fish-shell เวลาพาร์สโทเคนเป็นสตริงอย่างปลอดภัย จะเปลี่ยนอักขระพิเศษที่ไม่ได้ escape ให้เหมือนเป็น code point Unicode อื่นในสตริง แต่ใส่ไว้ในพื้นที่ PUA แล้วค่อยดักจับใน pipeline ภายหลัง ไม่ควรปล่อยให้หลุดออกนอกขอบเขต API แต่เมื่อเจอแล้ว แนวทางที่แนะนำคือส่งผ่านไปตามเดิม และระบบกับไลบรารีส่วนใหญ่ก็ทำแบบนั้น นี่อาจเป็นช่องทางรั่วไหลที่ชัดเจนได้ เพราะนักพัฒนาปกติจำนวนมากไม่ค่อยรู้อะไรเกี่ยวกับ Unicode มากไปกว่า “ถ้าอยากหลีกเลี่ยงปัญหา internationalization ให้ใช้ Unicode เสมอ” จึงมักเปิดค้างไว้แบบนั้น
) ประเด็นสำคัญตรงนี้คือการเข้ารหัสให้มันถูกซ่อนตอนคัดลอก-วาง และถูกถือว่าเป็น “ส่วนหนึ่ง” ของอักขระอื่นhttps://news.ycombinator.com/item?id=42791378
เพราะ API มีข้อจำกัดว่ารับเฉพาะอีโมจิ ความเป็นไปได้ในการนำไปใช้ในทางอาชญากรรมจึงถูกพูดถึงทันที สำหรับการใช้งานนั้นใช้ PUA ไม่ได้ ต้องเข้ารหัสไว้ในอีโมจิ
noncharacter ที่กำหนดไว้มีทั้ง
0xFFFF,0xFFFEหรือ code point สองตัวสุดท้ายของแต่ละ plane รวมถึงพื้นที่หนึ่งตรงกลาง Arabic Presentation Forms ด้วย ผมเข้าใจว่ามีการเพิ่มลงในรายการภายหลังเพื่อให้มี noncharacter มากขึ้นสำหรับให้ผู้คนใช้ในลักษณะนี้คุณไม่สามารถ watermark อักขระใด ๆ แบบมองไม่เห็นด้วยอักขระ PUA ที่ไม่ถูกรู้จักได้ เพราะมันไม่ได้ถูกถือว่าเป็น combining character แต่จะมีกรอบ placeholder ที่เรนเดอร์แยกออกมาแทน ตัวอย่าง:
— แน่นอนว่าถ้าคุณใช้ private use area แบบส่วนตัวจริง ๆ มันก็อาจไม่ใช่กล่องก็ได้ประมาณ 10 ปีก่อน ผมเคยใส่ U+202D LEFT-TO-RIGHT OVERRIDE ไว้กลางชื่อไฟล์บน Windows เพื่อแกล้งให้เพื่อนร่วมงานตกใจ
funnypicturegnp.exeดูเหมือนfunnypictureexe.pngถ้าใส่ไอคอนกำหนดเองที่ดูเหมือนพรีวิวรูปภาพเข้าไปด้วย ก็ดูสมจริงทีเดียว
.exeส่วนใหญ่จะถูกบล็อกอัตโนมัติ แต่ส่วนขยายอันตรายในยุคนี้มักเป็น .html และมักเปิดหน้าเข้าสู่ระบบปลอมด้วยwindow.locationredirect ที่ถูกทำให้สับสนการใช้ RTL ในทางที่ผิดอย่าง
cute-cat-lmth.pngพบได้ค่อนข้างบ่อย แต่ตรวจจับง่ายมาก และอีเมลแบบนั้นจะถูกทำเครื่องหมายเป็นฟิชชิงทันทีhttps://trojansource.codes/
โดยพื้นฐานแล้วสามารถซ่อนโค้ดที่ดูเหมือนคอมเมนต์ แต่เมื่อคอมไพล์แล้วทำงานเป็นโค้ดได้ อย่างไรก็ตาม ผมจำได้ว่าสถานะ CVE ของมันเป็นที่ถกเถียง เพราะ text editor จำนวนมากทำให้คอมเมนต์น่าสงสัยแบบนี้มองเห็นได้แล้ว
guitar_tab.txtในกรณีใช้งานจริง Sanity ใช้ทริกนี้เพื่อเข้ารหัส Content Source Maps ไว้ในข้อความจริงที่ถูกส่งให้เว็บเพจใน “โหมดพรีวิว”0 ผู้แก้ไขสามารถคลิกข้อความหรือคอนเทนต์นั้น ๆ เพื่อไล่ย้อนกลับไปยังตำแหน่งต้นฉบับภายในโครงสร้างคอนเทนต์ที่ซับซ้อนได้อย่างง่ายดาย
แต่ก็มีข้อเสียและข้อจำกัดอยู่ เช่น ต้องป้องกันไม่ให้มันถูกเติมเข้าไปในค่าที่ต้องถูก parse หรือใช้งานตามตัวอักษร เช่น วันที่·timestamp, URL, ID ถึงอย่างนั้นก็ยังเป็นทริกที่น่าสนใจมาก
0 https://www.sanity.io/docs/stega
[1] https://github.com/sanity-io/content-source-maps
ชอบไอเดียที่เอาสิ่งนี้ไปใช้กับ การทำ watermark ให้เอาต์พุตของ LLM มันลงตัวพอดี เพราะยังไง generator คุณภาพต่ำ 99% ที่แค่คัดลอก·วางก็หนีไม่พ้นโดนจับได้ และแทบไม่กระทบ use case หลักอื่น ๆ
ก็สงสัยเหมือนกันว่าจะใส่ข้อมูลมากแค่ไหนต่อแต่ละตัวอักษรหรือ output token จะเป็น user ID, การอ้างอิง prompt, วันที่, หมายเลข token อะไรพวกนี้ไหม? และก็สงสัยด้วยว่าในเทอร์มินัลจะตีความอย่างไร เท่มากจริง ๆ
มาตรการป้องกัน AI ที่แท้จริงมีแค่การบังคับให้ทุกปฏิสัมพันธ์ของมนุษย์ต้องมีลายเซ็นคีย์ที่ยืนยันด้วยตัวตนจริงเท่านั้น ซึ่ง A: ไม่มีทางเกิดขึ้นเด็ดขาด และ B: อาจถูกนำไปใช้ในทางที่ผิดในประเทศที่มีรัฐบาลคอร์รัปชัน หรือประเทศที่มีรัฐบาลคอร์รัปชันซึ่งได้รับอิทธิพลจากอุตสาหกรรมเอกชนอย่างรุนแรง เช่น สหรัฐฯ
แน่นอนว่าถ้าส่งประโยคเข้า
xxdก็จะเห็นมัน ข้อเสนอ PUA ในคอมเมนต์บนสุดตอนนี้ต่างจากอันนี้ตรงที่มองเห็นได้ทันทีหลังจากทดสอบเพิ่ม พบว่าหลังจากวางลงในเทอร์มินัลแล้ว ใน
xxdข้อความผ่านไปได้โดยไม่เปลี่ยนรูปเลย แต่ถ้าเลือกในเทอร์มินัลแล้ววางกลับมาอีกครั้ง ใน X selection ของ mate terminal และ konsole จะเหลือเพียงไม่กี่คำแล้วถูกตัดไป ไม่แน่ใจว่าการถูกตัดเกิดจากเทอร์มินัลหรือ X กันแน่ ใน xterm ตัวeสุดท้ายถูกเปลี่ยนรูป และข้อความที่เลือกก็ถูกตัดมากกว่าเดิมในไฟล์ ประโยคถูกบันทึกโดยไม่เปลี่ยนรูป ดังนั้นดูเหมือนว่าจะเป็นกรณีที่ข้อมูลบางส่วนหลุดหายตอนคัดลอกออกจากเทอร์มินัลมากกว่า ผมทดสอบโดย
echoประโยคลงไฟล์ทดสอบ แล้วเปิดในเบราว์เซอร์เพื่อคัดลอกข้อความมาตรวจสอบถ้าใส่ลูกเล่นลงไปในวิธี sampling ตอนสร้างข้อความ ภายหลังก็สามารถรัน LLM อีกครั้งแล้วสังเกตรูปแบบของเอาต์พุตเพื่อตรวจจับ fingerprint ได้ เช่น เลือก token ที่มีความน่าจะเป็นสูงและต่ำสลับกัน แน่นอนว่าการใช้งานจริงคงซับซ้อนกว่านี้มาก แต่ไอเดียไปในทิศทางนั้น
จุดที่น่าสนใจคือ screen reader สามารถตรวจพบ variation selector เหล่านี้ได้เมื่อเลื่อนทีละตัวอักษร ถ้าเลื่อนด้วยลูกศรบนตัวอย่าง มันจะอ่านว่า “Smiling face with smiling eyes”, “Symbol e zero one five five”, “Symbol e zero one five c”
อย่างไรก็ตามขึ้นอยู่กับ speech synthesizer ที่ใช้ และถ้ากำลังอ่านข้อความในเอกสารตามปกติ ก็จะไม่รู้ว่ามีอักขระเหล่านั้นอยู่ จึงไม่ได้เป็นประโยชน์ใหญ่นักโดยรวม
StegCloak0 ก็อยู่ในกลุ่มเดียวกัน และผลักไอเดียนี้ไปอีกขั้นด้วยการเข้ารหัส payload ที่ซ่อนไว้ด้วย AES-256-CTR เป็นทริกเล็ก ๆ ที่ใช้ได้ดีทีเดียว
0 https://github.com/KuroLabs/stegcloak
แต่ฝ่ายตรงข้ามต้องแชร์ secret รหัสผ่านกันก่อนถึงจะถอดรหัสได้
ชื่อเรื่องชวนเข้าใจผิดเล็กน้อย มีข้อความว่า “อักขระฐานไม่จำเป็นต้องเป็น emoji และการจัดการ variation selector ก็เหมือนกันกับอักขระทั่วไป แค่ใช้ emoji แล้วสนุกกว่าเท่านั้น”
ถ้าใช้วิธีนี้กับ อักขระที่ไม่ใช่ emoji จะยิ่งแนบเนียนและยิ่งน่ารำคาญขึ้น
มากกว่าการทำ watermark เอาต์พุต LLM แบบง่าย ๆ สิ่งนี้ดูเหมือนอาจเป็นวิธีที่เรียบร้อยในการแพ็ก ข้อมูล logprobs ไปด้วย
โดยพื้นฐานคือรวมข้อมูลความน่าจะเป็นของ token ทั้งหมดที่ถูกสร้างขึ้น เพื่อเพิ่มความโปร่งใสเล็กน้อยให้กับกระบวนการสร้าง ข้อมูลนี้มีอยู่ในสเปก OpenAI API ด้วย และเอนจินหลายตัวอย่าง
llama.cppก็ให้ข้อมูลนี้เช่นกัน โดยปกติจะถูกแนบเป็นฟิลด์แยกต่างหาก แต่ก็มีวิธี visualize อย่าง mikupad0 ด้วยอาจจะเป็นไอเดียที่ไม่ดี แต่ก็เป็นแนวคิดที่คาใจอยู่ดี
เป็นเทคนิคที่เจ๋งมาก มีการสะท้อน ASCII และยังมี อักขระ Unicode Tag ซึ่งเป็นองค์ประกอบ UI ที่ไม่ค่อยพบเห็นบ่อยนัก โดยเฉพาะในเว็บแอป
จุดเด่นของอักขระ Tag คือ LLM บางตัวจะตีความข้อความที่ซ่อนอยู่เป็น ASCII แล้วทำตามคำสั่ง และถึงขั้นสามารถเขียนออกมาได้โดยตรงด้วย
https://embracethered.com/blog/posts/2024/hiding-and-finding...
ยังมี proof of concept ของช่องโหว่จริงที่ Microsoft แก้ไขแล้วใน Copilot ด้วย
https://embracethered.com/blog/posts/2024/m365-copilot-promp...