2 points par GN⁺ 2025-02-13 | 1 commentaires | Partager sur WhatsApp
  • 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

 
GN⁺ 2025-02-13
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

    • Le challenge "encrypted runner" du Google CTF quals 2024 reposait sur cette idée
    • Oui. Le texte Zalgo est un cas de test courant pour les champs de saisie des sites web, mais il ne se passe généralement rien de très intéressant. Au pire, on déclenche parfois une exception sur une limite de longueur en base de données, et en général le processus ne meurt même pas ; l’exception reste cantonnée au thread courant
      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 \n et \r\n dans les chaînes de texte : JavaScript compte généralement le retour chariot comme 1 octet, alors que la spécification HTTP en exige 2
      unescape(encodeURIComponent("ç")).length est une méthode rapide et approximative pour vérifier la longueur en octets en JavaScript, et le problème de \r\n se règle en normalisant la chaîne avant de compter sa longueur
    • Je débute, mais tu pourrais expliquer davantage comment ça marche ou comment tu as fait ? Ça ressemble à une faille à tester
  • C’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

    • Je viens de tester moi-même : les caractères private use s’affichent chez moi sous forme de carrés (󰀀). 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ère
    • La différence, c’est que les caractères PUA se rendent généralement de façon assez visible d’une manière ou d’une autre, alors que les variation selectors, non
    • Il manque un peu de contexte : cette idée vient d’une discussion autour d’une soumission au Open Heart Protocol
      https://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
    • Est-ce que ce n’est pas plus proche d’un usage des noncharacters désignés que du private-use area ? La PUA sert aussi à des encodages non officiels de systèmes d’écriture qui ne sont pas encore dans Unicode, ou à des choses comme le logo Apple ; l’utiliser ainsi risque donc de créer des collisions
      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’usage
    • Honnêtement, j’ai collé ce commentaire dans le décodeur fourni. Je me disais qu’il ne pouvait pas à ce point passer à côté de l’essentiel et qu’il devait y avoir un message caché dedans, mais soit je l’ai vraiment raté, soit ce site web les supprime
      On 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îte
  • Il 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.exe apparaissait comme funnypictureexe.png
    Avec en plus une icône personnalisée qui ressemblait à un aperçu photo, c’était assez crédible

    • J’ai travaillé dans la détection du phishing, et c’était un schéma fréquemment utilisé par les attaquants. Les .exe sont 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 redirection window.location obfusquée
      Les 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 phishing
    • La version dans le code source de ce procédé, c’est CVE-2021-42574, et il y a aussi un site web
      https://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é
    • Je ne connaissais pas cette astuce précise, mais je suis heureux de voir que mon habitude paranoïaque d’ouvrir depuis toujours les fichiers multimédias potentiellement suspects via « clic droit → Ouvrir avec » était justifiée
    • J’ai déjà créé un fichier .bat nommé guitar_tab.txt
  • Comme 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

    • Je ne comprends pas pourquoi tout le monde pense que le watermarking de l’IA va fonctionner. Quel que soit le watermarking, il pourra être retiré immédiatement et facilement, donc cela ne marchera jamais vraiment
      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
    • Vu la quantité de prétraitement appliquée avant l’intégration dans un dataset, je serais surpris que ce genre de bidouille fonctionne réellement
    • Dans la plupart des terminaux Linux, ce qui est envoyé passe tel quel comme une simple séquence d’octets. Cette technique est compatible UTF-8 et n’utilise pas de glyphes supplémentaires, donc dans un terminal conforme à Unicode, c’est invisible à l’œil nu. Je l’ai testé sur plusieurs
      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’inverse
      Aprè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 dernier e a é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 echo de la phrase dans un fichier de test, puis en l’ouvrant dans le navigateur pour copier le texte
    • Il existe aussi d’autres approches bien plus robustes et plus difficiles à détecter pour le watermarking des LLM. Elles exploitent le fait qu’un LLM produit une distribution de probabilités sur les prochains tokens possibles, puis génère sa sortie en échantillonnant aléatoirement dedans
      Si 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é

    • Comme le texte en ligne est largement pollué par des caractères invisibles mais pénibles à l’écoute, j’utilise un script sur mon lecteur d’écran qui supprime tous les caractères non ASCII
  • 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

    • Il me semble qu’il existe un plugin Better Discord qui utilise ça ou quelque chose de similaire. On peut envoyer des messages entièrement chiffrés qui n’ont l’air de rien pour les autres
      En revanche, il faut partager un mot de passe secret pour que l’autre personne puisse décoder
    • J’ai voulu essayer dans un enregistrement TXT DNS de Cloudflare, mais Cloudflare a eu la bonne idée de décoder le contenu quand on le colle dans le champ TXT
  • 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

    • Ce n’est probablement pas gênant à ce point. On peut créer un détecteur assez facilement. Il suffit de signaler les variantes attachées à des caractères qui n’ont pas réellement de variante. Cela pourrait même servir à la signature
  • 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.cpp fournissent 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 mikupad0
    C’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...