2 pontos por GN⁺ 2025-02-13 | 1 comentários | Compartilhar no WhatsApp
  • Ao concatenar variation selectors do Unicode, é possível esconder uma sequência de bytes atrás de um único caractere; ela não aparece na tela, mas acompanha o texto ao copiar e colar
  • Existem 256 variation selectors, do VS-1 ao VS-256, o que permite criar um mapeamento exato para o intervalo de 1 byte
  • Mesmo anexando os bytes de hello[0x68, 0x65, 0x6c, 0x6c, 0x6f] — após 😊, visualmente ainda parece apenas um emoji comum
  • A decodificação procura os intervalos U+FE00..U+FE0F e U+E0100..U+E01EF e os converte de volta em bytes; o caractere base nem precisa ser um emoji
  • Esse método é um abuso do Unicode e pode ser usado indevidamente para contornar filtros de conteúdo humanos ou embutir marca d’água em texto

Como dados invisíveis ficam anexados a um caractere

  • Texto Unicode é representado como uma sequência de codepoints e normalmente é escrito no formato U+XXXX
  • Em caracteres latinos simples, costuma haver uma correspondência 1:1 entre codepoint e o caractere visível na tela
    • Ex.: U+0067 representa o caractere g
  • Em outros sistemas de escrita, um único caractere visual pode ser composto por vários codepoints
    • Ex.: em devanágari, um caractere lido como é representado pelo par sequencial U+0915 e U+0940

Usando variation selectors como depósito de dados

  • O Unicode define 256 codepoints de variation selector, chamados de VS-1 até VS-256
  • O variation selector em si não é exibido na tela; ele serve para alterar a forma de exibição do caractere anterior
  • A maioria dos caracteres Unicode não possui variações associadas, mas o Unicode busca compatibilidade futura, então até código que não entende o significado deve preservar os variation selectors
    • Mesmo colocando U+FE01 (VS-2) após U+0067 (g), a tela continua mostrando apenas a letra minúscula g
    • Ao copiar e colar, o variation selector também vai junto
  • Como 256 variation selectors correspondem exatamente à quantidade necessária para representar 1 byte, é possível esconder 1 byte de dados após qualquer codepoint Unicode arbitrário
  • A especificação do Unicode não trata de forma específica sequências com vários variation selectors em série e dá a entender que elas devem ser ignoradas durante a renderização
  • Ao concatenar vários variation selectors, é possível representar uma sequência arbitrária de bytes atrás de um único caractere

Codificando bytes em variation selectors

  • Os variation selectors são divididos em dois intervalos de codepoints
    • U+FE00 .. U+FE0F: os primeiros 16
    • U+E0100 .. U+E01EF: os 240 restantes
  • A regra para converter bytes em variation selectors é simples
    • Se o byte for menor que 16, use 0xFE00 + byte
    • Caso contrário, use 0xE0100 + (byte - 16)
  • A codificação primeiro insere um caractere base e, em seguida, converte cada byte em variation selector e o concatena
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
}
  • Ao anexar os bytes de hello[0x68, 0x65, 0x6c, 0x6c, 0x6f] — após 😊, o resultado visual é uma string que parece apenas um emoji comum
  • Em saída normal, os caracteres ocultos não aparecem, mas no formato de depuração do Rust surgem codepoints escondidos como \u{e0158}
"😊\u{e0158}\u{e0155}\u{e015c}\u{e015c}\u{e015f}"

Como ler os bytes ocultos novamente

  • A decodificação percorre os caracteres e converte de volta em bytes os codepoints que estiverem nos intervalos de variation selector
  • O intervalo U+FE00..U+FE0F é restaurado com variation_selector - 0xFE00
  • O intervalo U+E0100..U+E01EF é restaurado com variation_selector - 0xE0100 + 16
  • Caracteres comuns antes do primeiro variation selector são tratados como o caractere base e ignorados
  • Se aparecer um caractere que não seja variation selector depois que já houver resultado, a decodificação termina
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
    }
}
  • Ao decodificar o mesmo resultado da codificação e interpretá-lo como UTF-8, obtém-se "hello"
  • O caractere base não precisa ser um emoji; o tratamento de variation selector é o mesmo em caracteres comuns
  • O motivo para usar emoji é apenas porque fica mais divertido

Possibilidades de uso indevido

  • Esse método é um abuso do Unicode e não deve ser usado
  • Como os dados não aparecem no resultado renderizado, moderadores ou revisores humanos dificilmente percebem que existe informação oculta
  • Isso pode ser explorado para esconder dados e passar por filtros de conteúdo humanos
  • Também pode ser usado para marca d’água em texto
    • Após enviar a mesma mensagem para várias pessoas, um vazamento permitiria rastrear o destinatário original
    • A sequência de variation selectors sobrevive à maioria das operações de copiar e colar
    • A técnica permite densidade arbitrária de dados e, se quiser, dá para colocar marca d’água em todos os caracteres

LLMs conseguem processar os dados ocultos?

  • Depois que o tema apareceu no Hacker News, surgiram perguntas sobre como LLMs lidam com esse tipo de dado escondido
  • Em geral, tokenizers parecem preservar os variation selectors como tokens, então, em teoria, o modelo pode acessá-los
  • O tokenizer da OpenAI pode ser usado como ferramenta de verificação disso
  • No geral, os modelos não parecem tentar decodificar esses dados diretamente por conta própria
  • Com um interpretador de código, alguns modelos conseguem extrair os dados ocultos

1 comentários

 
GN⁺ 2025-02-13
Comentários do Hacker News
  • Em termos de abuso de Unicode, isso é só a ponta do iceberg. Com técnicas parecidas, dá para estourar buffers em vários sistemas que recebem strings Unicode e, normalmente, isso termina em erro ou crash, mas com sorte pode gerar uns comportamentos bem interessantes
    Fazendo testes de intrusão na época anterior ao Python 3, já consegui estourar o buffer de um servidor web backend expandindo um único caractere em vários bytes só com diacríticos. Naquele caso, o resultado foi só um crash com reinicialização automática, mas, se cavar o suficiente, parece algo que poderia ser usado em exploits de sistemas ou softwares específicos

    • O desafio "encrypted runner" do Google CTF quals 2024 foi baseado nessa ideia
    • Sim. Texto Zalgo é um caso de teste comum em campos de entrada de sites, mas na maioria das vezes nada de muito interessante acontece. Ocasionalmente, ele aciona alguma exceção de limite de comprimento no banco de dados, e normalmente o processo nem morre, com a exceção ficando contida na thread atual
      Mesmo em formulários modernos, dá para provocar algo parecido só desativando o JavaScript, e, no melhor caso, o modo de depuração está ligado e exibe stack trace ou queries, vazando um pouco de informação. Outro erro comum é contar errado o comprimento de \n e \r\n em strings de texto: o JavaScript normalmente conta o carriage return como 1 byte, mas a especificação HTTP exige 2 bytes
      unescape(encodeURIComponent("ç")).length é uma forma rápida e aproximada de verificar o comprimento em bytes no JavaScript, e o problema de \r\n pode ser resolvido normalizando a string antes de contar o tamanho
    • Sou iniciante, mas você poderia explicar melhor como isso funciona ou como você fez? Parece uma brecha interessante para testar
  • Isso é bonitinho, mas não é realmente necessário. O Unicode tem uma grande faixa chamada PUA(private use area). Os códigos dessa faixa não são mapeados para caractere nenhum, nem serão no futuro, então são usados para fins internos ou definidos pelo usuário
    Por exemplo, no fish-shell, ao fazer o parsing seguro de tokens como string, caracteres especiais não escapados são trocados por outros code points Unicode dentro da string, mas colocados na área PUA, e depois interceptados adiante no pipeline. Eles não deveriam vazar para fora do limite da API, mas, quando aparecem, a recomendação é apenas deixá-los passar, e a maioria dos sistemas e bibliotecas faz isso. Isso pode virar um caminho óbvio de vazamento, mas muitos desenvolvedores perfeitamente normais não sabem sobre Unicode muito além de “use sempre Unicode para evitar problemas de internacionalização”, então muitas vezes isso fica totalmente exposto

    • Testei aqui e os caracteres de uso privado são renderizados como quadradinhos no meu ambiente (󰀀). O ponto aqui é codificar de forma que, ao copiar e colar, isso fique escondido e seja tratado como “parte” de outro caractere
    • A diferença é que caracteres PUA geralmente são renderizados de forma bem perceptível de algum jeito, enquanto variation selectors não são
    • Falta um contexto importante: essa ideia surgiu numa discussão em torno de uma submissão do Open Heart Protocol
      https://news.ycombinator.com/item?id=42791378
      Como havia a restrição de que a API só aceitava emoji, logo se discutiu o potencial de uso criminoso. Para esse caso, PUA não serve; tem que ser codificado dentro do emoji
    • Isso não estaria mais próximo do uso de noncharacter designado do que de private-use area? PUA também é usado para codificações informais de sistemas de escrita que ainda não entraram no Unicode, ou para coisas como o logotipo da Apple, então usar dessa forma parece sujeito a conflitos
      Os noncharacters designados incluem 0xFFFF, 0xFFFE e os dois últimos code points de cada plano, além de uma área no meio de Arabic Presentation Forms. Pelo que sei, eles foram adicionados depois à lista justamente para garantir mais noncharacters que as pessoas pudessem usar desse jeito
    • Para ser sincero, eu colei este comentário no decodificador fornecido. Achei que não dava para perder o ponto a esse nível e que devia haver uma mensagem escondida dentro, mas ou eu realmente deixei passar, ou este site está removendo essas coisas
      Não dá para fazer watermark invisível de caracteres arbitrários com caracteres PUA não reconhecidos. Eles não são tratados como caracteres combinantes. Em vez disso, aparece uma caixa de placeholder renderizada separadamente. Ex.: — claro, se você estiver usando private use area de forma realmente privada, pode ser que não apareça como caixa
  • Uns 10 anos atrás, eu costumava assustar colegas colocando U+202D LEFT-TO-RIGHT OVERRIDE no meio de nomes de arquivos no Windows. funnypicturegnp.exe passava a parecer funnypictureexe.png
    Com um ícone personalizado que parecesse pré-visualização de foto, ficava bem convincente

    • Trabalhei com detecção de phishing, e isso era um padrão comum entre atacantes. .exe costuma ser bloqueado automaticamente, mas hoje em dia extensões maliciosas muitas vezes são .html, que acabam abrindo páginas falsas de login com redirecionamentos window.location ofuscados
      Abusos de RTL como cute-cat-lmth.png eram relativamente comuns, mas também muito fáceis de detectar, então esses e-mails eram marcados imediatamente como phishing
    • A versão disso em código-fonte é a CVE-2021-42574, e há até um site sobre isso
      https://trojansource.codes/
      Basicamente, dá para esconder código que parece comentário, mas que ao compilar é tratado como código executável. Ainda assim, lembro que o status de CVE foi controverso, porque muitos editores de texto já passaram a destacar esse tipo de comentário suspeito
    • Eu não conhecia esse truque específico, mas fico feliz que meu hábito paranoico de sempre abrir arquivos de mídia potencialmente suspeitos por “clique com o botão direito → abrir com” há décadas tenha sido justificado
    • Já criei um arquivo bat com o nome guitar_tab.txt
  • Como caso de uso real, a Sanity usou esse truque para codificar Content Source Maps dentro do próprio texto real entregue à página web no “modo de pré-visualização”0. Assim, os editores podem rastrear facilmente a localização original dentro de uma estrutura profunda de conteúdo apenas clicando naquele texto ou conteúdo
    Também há desvantagens e limitações. Por exemplo, é preciso impedir que isso seja adicionado a valores que precisam ser analisados ou usados literalmente, como datas e timestamps, URLs e IDs. Ainda assim, é um truque bem divertido
    0 https://www.sanity.io/docs/stega
    [1] https://github.com/sanity-io/content-source-maps

  • Gosto da ideia de usar isso para watermarking de saída de LLM. É um ponto excelente. Afinal, 99% dos geradores de baixa qualidade que só vivem de copiar e colar inevitavelmente seriam pegos, e quase não haveria impacto em outros casos de uso importantes
    Também fico curioso sobre quanto seria inserido em cada caractere ou token de saída. Seriam coisas como ID do usuário, referência ao prompt, data e número do token? Também queria saber como isso seria interpretado no terminal; é muito legal

    • Não sei por que todo mundo acha que watermarking de IA vai funcionar. Qualquer watermark pode ser removido imediatamente e com facilidade, então isso nunca vai funcionar de verdade
      A única defesa real contra IA seria exigir assinaturas de chave verificadas por identidade real em toda interação humana, e mesmo isso A: nunca vai acontecer, e B: pode ser abusado em países com governos corruptos, ou governos corruptos fortemente influenciados pela iniciativa privada, como os EUA
    • Há tanto pré-processamento antes de algo entrar em um dataset que eu me surpreenderia se esse tipo de brincadeira realmente funcionasse
    • Na maioria dos terminais Linux, o que você envia simplesmente passa como uma sequência de bytes. Esta técnica é compatível com UTF-8 e não usa glifos adicionais, então em terminais compatíveis com Unicode ela fica invisível aos olhos humanos. Testei em alguns
      Claro, se você passar a frase para xxd, ela aparece. A sugestão de PUA no comentário principal atual é diferente, porque fica visível imediatamente
      Em testes adicionais, depois de colar no terminal, no xxd a mensagem passa completamente sem modificação, mas se eu seleciono no terminal e colo de novo, na seleção X do mate terminal e do konsole só algumas palavras permanecem e o resto é cortado. Não sei se isso é culpa do terminal ou do X. No xterm, o último e foi alterado e o conteúdo selecionado foi truncado ainda mais
      Em arquivos, a frase é gravada sem alteração. Então parece mais que alguns dados se perdem ao copiar para fora do terminal. Testei dando echo da frase para um arquivo de teste e abrindo no navegador para copiar o texto
    • Há outras abordagens bem mais robustas e difíceis de detectar para watermarking de LLM. Elas exploram o fato de que o LLM cria uma distribuição de probabilidade para cada próximo token possível e então produz a saída por amostragem aleatória entre eles
      Se você mexer na forma de amostragem durante a geração, depois pode rodar o LLM novamente e observar o padrão da saída para detectar a impressão digital. Por exemplo, alternando entre tokens de alta probabilidade e de baixa probabilidade. A implementação real obviamente seria muito mais sofisticada, mas a ideia vai nessa direção
  • Como observação interessante, leitores de tela conseguem detectar esses variation selectors quando se navega caractere por caractere. Se você usar as setas no exemplo acima, ele lê algo como “Smiling face with smiling eyes”, “Symbol e zero one five five”, “Symbol e zero one five c”
    No entanto, isso varia conforme o sintetizador de voz usado e, se o documento estiver apenas sendo lido normalmente, não dá para saber que esses caracteres estão ali, então no geral não é uma grande vantagem

    • Como o texto online em geral está poluído por caracteres invisíveis, mas irritantes de ouvir, uso um script no meu leitor de tela para remover todos os caracteres não ASCII
  • O StegCloak0 é da mesma família e leva essa ideia um passo adiante ao criptografar a carga oculta com AES-256-CTR. É um truque pequeno, mas bem interessante
    0 https://github.com/KuroLabs/stegcloak

    • Parece que existe um plugin do Better Discord que usa isso ou algo parecido. Você pode enviar mensagens totalmente criptografadas que para outras pessoas parecem não ser nada
      Só que, para a outra pessoa decodificar, vocês precisam compartilhar um segredo de senha
    • Tentei testar isso em um registro TXT do DNS da Cloudflare, mas a Cloudflare foi esperta e decodifica ao colar no campo TXT
  • O título é um pouco enganoso. A ideia é que “o caractere base não precisa ser emoji, e o tratamento de variation selectors é o mesmo em caracteres comuns. Com emoji só fica mais divertido”
    Se você usar esse método com caracteres não emoji, ele fica mais discreto e mais inconveniente

    • Não parece tão inconveniente assim. Não seria difícil criar um detector. Bastaria marcar variantes anexadas a caracteres que na prática não têm variação. Na verdade, até parece algo que poderia ser usado para assinatura
  • Mais do que um simples watermarking de saída de LLM, isso parece poder ser uma forma elegante de empacotar também os dados de logprobs
    Basicamente, incluiria as informações de probabilidade de todos os tokens gerados, dando um pouco de transparência ao processo de geração. Isso também está incluído na especificação da API da OpenAI, e vários motores como llama.cpp também fornecem essa informação. Normalmente ela vem em um campo separado, mas também existem formas de visualização como o mikupad0
    Provavelmente é uma má ideia, mas ainda assim é um conceito que instiga

  • Técnica muito interessante. Ela reflete ASCII e também existem caracteres Unicode Tag em elementos de UI, especialmente em apps web, que muitas vezes não ficam visíveis
    O ponto peculiar dos caracteres Tag é que alguns LLMs interpretam texto oculto como ASCII e seguem as instruções, e até conseguem escrevê-lo diretamente
    https://embracethered.com/blog/posts/2024/hiding-and-finding...
    Também há uma prova de conceito de exploit real que a Microsoft corrigiu no Copilot
    https://embracethered.com/blog/posts/2024/m365-copilot-promp...