- En enchaînant les variation selectors d’Unicode, on peut cacher une suite d’octets derrière un seul caractère, invisible à l’écran mais conservée lors du copier-coller
- Il existe 256 variation selectors, de VS-1 à VS-256, ce qui permet une correspondance exacte avec une plage de 1 octet
- En ajoutant les octets de
hello [0x68, 0x65, 0x6c, 0x6c, 0x6f] après 😊, l’ensemble ressemble visuellement à un simple émoji ordinaire
- Le décodage consiste à repérer les plages
U+FE00..U+FE0F et U+E0100..U+E01EF, puis à les reconvertir en octets, et le caractère de base n’a pas besoin d’être un émoji
- Cette méthode est un détournement d’Unicode et peut servir à contourner des filtres de contenu humains ou à insérer un filigrane dans un texte
Comment des données invisibles s’attachent à un seul caractère
- Le texte Unicode est représenté comme une séquence de code points, généralement notés au format
U+XXXX
- Avec les simples caractères latins, il existe en général une correspondance 1:1 entre code point et caractère affiché
- Exemple :
U+0067 représente le caractère g
- Dans d’autres systèmes d’écriture, un seul caractère affiché peut être composé de plusieurs code points
- Exemple : en devanagari, le caractère lu comme
키 est représenté par la paire successive U+0915 et U+0940
Utiliser les variation selectors comme espace de stockage
- Unicode définit 256 code points de type variation selector, nommés de VS-1 à VS-256
- Un variation selector ne s’affiche pas à l’écran ; il sert à modifier la manière dont le caractère précédent est rendu
- La plupart des caractères Unicode n’ont pas de variante associée, mais Unicode vise la compatibilité future, donc même un code qui n’en comprend pas le sens doit préserver les variation selectors
- Ajouter
U+FE01 (VS-2) après U+0067 (g) laisse l’affichage identique à un g minuscule
- Lors d’un copier-coller, le variation selector est aussi conservé
- Comme il y a exactement 256 variation selectors, cela correspond parfaitement à 1 octet, ce qui permet de cacher 1 octet de données derrière n’importe quel code point Unicode
- La spécification Unicode ne traite pas précisément des séquences de plusieurs variation selectors à la suite, mais laisse entendre qu’ils doivent être ignorés au rendu
- En chaînant plusieurs variation selectors, on peut représenter une suite arbitraire d’octets derrière un seul caractère
Encoder des octets en variation selectors
- Les variation selectors sont répartis sur deux plages de code points
U+FE00 .. U+FE0F : les 16 premiers
U+E0100 .. U+E01EF : les 240 restants
- La règle pour convertir un octet en variation selector est simple
- si l’octet est inférieur à 16, alors
0xFE00 + byte
- sinon,
0xE0100 + (byte - 16)
- L’encodage commence par un caractère de base, puis ajoute derrière lui chaque octet converti en 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
}
- Si l’on ajoute après
😊 les octets [0x68, 0x65, 0x6c, 0x6c, 0x6f] représentant hello, on obtient une chaîne qui ressemble extérieurement à un émoji tout à fait normal
- Dans un affichage ordinaire, les caractères cachés restent invisibles, mais le format de débogage de Rust révèle des code points masqués comme
\u{e0158}
"😊\u{e0158}\u{e0155}\u{e015c}\u{e015c}\u{e015f}"
Comment relire les octets cachés
- Le décodage parcourt les caractères et reconvertit en octets les code points appartenant aux plages des variation selectors
- La plage
U+FE00..U+FE0F est restaurée via variation_selector - 0xFE00
- La plage
U+E0100..U+E01EF est restaurée via variation_selector - 0xE0100 + 16
- Les caractères ordinaires rencontrés avant le premier variation selector sont considérés comme le caractère de base et ignorés
- Si l’on rencontre un caractère qui n’est pas un variation selector alors que des données ont déjà été lues, le décodage s’arrête
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
}
}
- Après décodage du même résultat puis interprétation en UTF-8, on obtient
"hello"
- Le caractère de base n’a pas besoin d’être un émoji, et le traitement des variation selectors est identique avec un caractère ordinaire
- Si l’on utilise un émoji, c’est simplement parce que c’est plus amusant
Possibilités d’abus
- Cette méthode est un abus d’Unicode et ne devrait pas être utilisée
- Comme les données n’apparaissent pas dans le rendu, il est difficile pour un modérateur ou un relecteur humain de savoir qu’elles existent
- Elle peut être exploitée pour cacher des données et passer au travers de filtres de contenu humains
- Elle peut aussi servir au filigranage de texte
- après avoir envoyé un message à plusieurs personnes, une fuite permettrait de retrouver le destinataire initial
- les séquences de variation selectors survivent à la plupart des copier-coller
- la densité de données arbitraires est flexible, au point de pouvoir insérer un filigrane sur chaque caractère si on le souhaite
Les LLM peuvent-ils traiter ces données cachées ?
- Après la publication sur Hacker News, des questions ont émergé sur la manière dont les LLM gèrent ce type de données cachées
- En général, les tokenizers semblent préserver ces variation selectors comme tokens, ce qui les rend théoriquement accessibles au modèle
- Le tokenizer d’OpenAI peut servir d’outil de vérification
- Globalement, les modèles ne semblent pas tenter eux-mêmes un décodage direct en interne
- Utilisés avec un interpréteur de code, certains modèles peuvent toutefois extraire les données cachées
1 commentaires
Commentaires sur Hacker News
En matière d’abus d’Unicode, ce n’est que la partie émergée de l’iceberg. Avec des techniques similaires, on peut faire déborder les buffers de nombreux systèmes qui acceptent des chaînes Unicode ; en général, ça se termine par une erreur ou un crash, mais avec un peu de chance on peut obtenir des comportements assez amusants
À l’époque d’avant Python 3, en faisant du pentest, j’ai déjà fait déborder le buffer d’un serveur web backend en allongeant un caractère en plusieurs octets uniquement avec des signes diacritiques. À l’époque, ça se limitait à un crash suivi d’un redémarrage automatique, mais en creusant suffisamment, ça semble exploitable contre certains systèmes ou logiciels
On peut aussi provoquer quelque chose de similaire sur des formulaires modernes simplement en désactivant JavaScript ; dans le meilleur des cas, le mode debug est activé et affiche une stack trace ou des requêtes, ce qui laisse fuiter un peu d’information. Une autre erreur fréquente consiste à mal compter la longueur de
\net\r\ndans les chaînes de texte : JavaScript compte généralement le retour chariot comme 1 octet, alors que la spécification HTTP en exige 2unescape(encodeURIComponent("ç")).lengthest une méthode rapide et approximative pour vérifier la longueur en octets en JavaScript, et le problème de\r\nse règle en normalisant la chaîne avant de compter sa longueurC’est mignon, mais pas vraiment nécessaire. Unicode dispose d’une grande plage appelée PUA (private use area). Les codes de cette plage ne sont mappés à aucun caractère, et ne le seront jamais ; ils servent à des usages internes ou définis par l’utilisateur
Par exemple, dans fish-shell, quand il faut parser des tokens en toute sécurité dans une chaîne, les caractères spéciaux non échappés sont remplacés par d’autres points de code Unicode à l’intérieur de la chaîne, mais placés dans la zone PUA, puis interceptés plus tard dans le pipeline. Il ne faut pas les exposer au-delà des frontières de l’API, mais lorsqu’on les rencontre, il est recommandé de les laisser passer tels quels, et la plupart des systèmes et bibliothèques font d’ailleurs cela. Ça peut clairement devenir une voie de fuite, car beaucoup de développeurs tout à fait légitimes ne savent guère plus d’Unicode que « utilisez toujours Unicode pour éviter les problèmes d’internationalisation », donc ce genre de chose reste souvent ouvert
). L’idée clé ici, c’est de les encoder de manière à ce qu’ils soient invisibles au copier-coller et traités comme une « partie » d’un autre caractèrehttps://news.ycombinator.com/item?id=42791378
Comme l’API n’acceptait que des emojis, la possibilité d’un usage criminel a été évoquée très vite. Pour cet usage, on ne peut pas utiliser la PUA : il faut encoder dans l’emoji lui-même
Les noncharacters désignés incluent
0xFFFF,0xFFFE, ainsi que les deux derniers points de code de chaque plan, mais aussi une zone au milieu des Arabic Presentation Forms. Si je me souviens bien, la liste a été complétée plus tard pour réserver davantage de noncharacters à ce type d’usageOn ne peut pas filigraner invisiblement des caractères arbitraires avec des caractères PUA non reconnus, parce qu’ils ne sont pas traités comme des caractères combinants. À la place, on voit apparaître des boîtes d’espace réservé rendues séparément. Exemple :
— sauf, bien sûr, si vous utilisez directement la private use area de façon privée, auquel cas ce ne sera peut-être pas une boîteIl y a une dizaine d’années, j’ai déjà surpris des collègues en mettant U+202D LEFT-TO-RIGHT OVERRIDE au milieu d’un nom de fichier Windows.
funnypicturegnp.exeapparaissait commefunnypictureexe.pngAvec en plus une icône personnalisée qui ressemblait à un aperçu photo, c’était assez crédible
.exesont pour la plupart bloqués automatiquement, mais de nos jours les extensions malveillantes sont souvent des .html, qui ouvrent une fausse page de connexion via une redirectionwindow.locationobfusquéeLes abus RTL du type
cute-cat-lmth.pngétaient relativement courants, mais aussi très faciles à détecter, donc on marquait immédiatement ces e-mails comme phishinghttps://trojansource.codes/
En gros, on peut cacher du code qui a l’air d’un commentaire mais qui, une fois compilé, s’exécute comme du code. Cela dit, beaucoup d’éditeurs de texte montrent déjà ce type de commentaires suspects, donc il me semble que son statut de CVE a été contesté
guitar_tab.txtComme cas d’usage concret, Sanity a utilisé cette astuce pour encoder des Content Source Maps directement dans le texte réel servi sur les pages web en « mode aperçu »0. Les éditeurs peuvent ainsi remonter facilement jusqu’à l’emplacement d’origine dans une structure de contenu profonde, simplement en cliquant sur ce texte ou ce contenu
Il y a aussi des inconvénients et des limites. Par exemple, il faut empêcher son ajout sur des valeurs qui doivent être analysées ou utilisées telles quelles, comme des dates·horodatages, des URL ou des ID. Cela reste malgré tout une astuce assez amusante
0 https://www.sanity.io/docs/stega
[1] https://github.com/sanity-io/content-source-maps
J’aime bien l’idée de l’utiliser pour le watermarking des sorties de LLM. C’est exactement le bon niveau. De toute façon, 99 % des générateurs bas de gamme qui se contentent de copier-coller se feront forcément attraper, et cela n’a presque aucun impact sur les autres cas d’usage importants
Je me demande aussi quelle quantité on mettrait par caractère ou par token de sortie. Des choses comme un ID utilisateur, une référence au prompt, une date, un numéro de token ? Je me demande aussi comment cela serait interprété dans un terminal, c’est vraiment génial
La seule vraie défense contre l’IA serait d’exiger, pour toute interaction humaine, des signatures de clés vérifiées par une identité réelle, mais cela aussi A : n’arrivera jamais, et B : pourrait être détourné dans des pays avec des gouvernements corrompus, ou des gouvernements corrompus fortement influencés par l’industrie privée, comme les États-Unis
Bien sûr, si on envoie la phrase dans
xxd, cela apparaît. La proposition PUA du commentaire actuellement en tête se voit immédiatement, à l’inverseAprès quelques tests supplémentaires, dans
xxd, après collage dans le terminal, le message passe entièrement sans altération, mais si on le sélectionne dans le terminal puis qu’on le recolle, la sélection X de mate terminal et de konsole ne garde que quelques mots avant de couper. Je ne sais pas si la coupure vient du terminal ou de X. Dans xterm, le dernierea été altéré et le contenu sélectionné était encore plus tronquéDans un fichier, la phrase est enregistrée sans altération. Cela semble donc plutôt se produire lors de la copie hors du terminal. J’ai testé en faisant un
echode la phrase dans un fichier de test, puis en l’ouvrant dans le navigateur pour copier le texteSi on introduit une petite manipulation dans la méthode d’échantillonnage au moment de la génération, on peut ensuite relancer le LLM et observer le motif de sortie pour détecter l’empreinte. Par exemple, en alternant entre des tokens à forte probabilité et à faible probabilité. L’implémentation réelle serait évidemment bien plus sophistiquée, mais l’idée va dans ce sens
Point intéressant : les lecteurs d’écran peuvent détecter ces variation selectors lorsqu’on se déplace caractère par caractère. Si on se déplace avec les flèches dans l’exemple ci-dessus, cela lit « Smiling face with smiling eyes », « Symbol e zero one five five », « Symbol e zero one five c »
Cela dit, cela dépend du moteur de synthèse vocale utilisé, et si le document est simplement lu à la suite, on ne peut pas savoir que ces caractères sont présents, donc l’avantage global est limité
StegCloak0 est aussi dans la même famille, et pousse l’idée un cran plus loin en chiffrant la charge cachée en AES-256-CTR. C’est un petit tour plutôt sympa
0 https://github.com/KuroLabs/stegcloak
En revanche, il faut partager un mot de passe secret pour que l’autre personne puisse décoder
Le titre est un peu trompeur. Il dit en substance : « Le caractère de base n’a pas besoin d’être un emoji, et le traitement des variation selectors fonctionne de la même manière avec des caractères ordinaires. C’est juste plus amusant avec des emoji »
Utiliser cette méthode avec des caractères non emoji la rend plus discrète et plus gênante
Plus qu’un simple watermarking de sortie de LLM, cela pourrait être une manière élégante d’embarquer aussi les données de logprobs
L’idée serait d’inclure, pour chaque token généré, son information de probabilité, afin d’apporter un peu de transparence au processus de génération. C’est inclus dans la spécification de l’API OpenAI, et plusieurs moteurs comme
llama.cppfournissent aussi cette information. En général, elle est ajoutée dans un champ séparé, mais il existe aussi des méthodes de visualisation comme mikupad0C’est probablement une mauvaise idée, mais c’est quand même une idée qui chatouille l’esprit
Technique impressionnante. Elle s’appuie sur l’ASCII et sur des éléments d’interface, en particulier les caractères Unicode Tag, qui sont souvent invisibles dans les applications web
Ce qui est particulier avec les caractères Tag, c’est que certains LLM interprètent le texte caché comme de l’ASCII et suivent les instructions, et qu’il est même possible de les écrire directement
https://embracethered.com/blog/posts/2024/hiding-and-finding...
Il existe aussi une preuve de concept d’un exploit réel que Microsoft a corrigé dans Copilot
https://embracethered.com/blog/posts/2024/m365-copilot-promp...