Aller au contenu
Tous les challenges
Misc Résolu Facile · 1 800 m

Lost in Embedding

Des embeddings GPT-2 « anonymes » qui s'inversent exactement.

Auteur
xoze
Scripts
1 fichier

Énoncé (résumé)

“We never see your words, only numbers.” We found their logs. Numbers remember more than you think.

Le seul fichier fourni est capture.json, un « log » qui ne contient que des nombres.

Reconnaissance

{"session_id": "a3f9c27e", "model": "gpt2", "inputs_embeds": [[0.114, -0.345, ...], ...]}
  • model vaut gpt2.
  • inputs_embeds est une liste de 24 vecteurs de 768 flottants. 768 est la dimension cachée de GPT-2 small.

Le nom inputs_embeds est celui de l’argument du forward() de Hugging Face Transformers. Le service se croit « privé » parce qu’il n’envoie pas le texte, seulement les embeddings d’entrée.

Point clé

En entrée de GPT-2, l’embedding du i-ème token vaut :

h0[i] = wte[token_i] + wpe[i]
  • wte est la table d’embedding des tokens (50257 × 768).
  • wpe est la table d’embedding des positions (1024 × 768).

Les deux tables sont publiques et la fonction est déterministe, sans aucun secret. L’inversion est donc exacte : on soustrait wpe[i], puis on cherche le vecteur le plus proche dans wte. Ce n’est pas un embedding « contextuel » de sortie : ces nombres ne sont que le texte encodé autrement.

Exploitation

  1. Récupérer les poids. On télécharge vocab.json de openai-community/gpt2 sur Hugging Face, puis seulement les tenseurs wte.weight et wpe.weight de model.safetensors, avec des requêtes HTTP Range. Pour trouver les octets à demander :

    • les 8 premiers octets du fichier donnent la taille du header JSON (14283) ;
    • ce header indique les data_offsets de chaque tenseur ;
    • l’offset réel dans le fichier vaut 8 + 14283 + offset.

    Pas besoin de torch ni de transformers.

  2. Retirer la position. Pour chaque position, on calcule X = inputs_embeds - wpe[0:24].

  3. Chercher le plus proche voisin. Pour chaque ligne de X, on prend argmin de la distance L2 à wte. La distance minimale est exactement 0.0 pour les 24 tokens, donc la correspondance est parfaite.

  4. Décoder. On décode les 24 tokens avec le BPE byte-level de GPT-2 (table bytes_to_unicode inverse) et on obtient BA26{Emb33d1ngS-4r3-N0t-S3cr3T}.

Même sans l’étape 2, le plus proche voisin dans wte donne le bon texte : wpe est petit devant wte.

Script

solve.py ne dépend que de numpy. Il lit files/capture.json ainsi que model/wte.bin, model/wpe.bin et model/vocab.json.

cd .
python3 -m venv .venv && .venv/bin/pip install numpy
# Téléchargement des poids (Range HTTP sur le safetensors) :
U=https://huggingface.co/openai-community/gpt2/resolve/main; B=$((8+14283))
curl -sL -o model/vocab.json $U/vocab.json
curl -sL -r $((B+287209472))-$((B+441598976-1)) -o model/wte.bin $U/model.safetensors
curl -sL -r $((B+267533312))-$((B+270679040-1)) -o model/wpe.bin $U/model.safetensors
.venv/bin/python -I solve.py

Sortie :

raw -> 'BA26{Emb33d1ngS-4r3-N0t-S3cr3T}'
minus_wpe min dist: [0.0, 0.0, ..., 0.0]
minus_wpe -> 'BA26{Emb33d1ngS-4r3-N0t-S3cr3T}'

Leçon

Les embeddings d’entrée d’un modèle public ne protègent pas les données : ce sont des tables de correspondance publiques, et wte[id] + wpe[pos] se renverse exactement. Même les embeddings de sortie (sentence embeddings) peuvent être largement inversés, par exemple avec vec2text.

Code

#!/usr/bin/env python3
"""Lost in Embedding: invert GPT-2 inputs_embeds by nearest-neighbour on wte (optionally minus wpe)."""
import json, sys, os
import numpy as np

D = os.path.dirname(os.path.abspath(__file__))
cap = json.load(open(sys.argv[1] if len(sys.argv) > 1 else f"{D}/files/capture.json"))
E = np.array(cap["inputs_embeds"], dtype=np.float32)
wte = np.fromfile(f"{D}/model/wte.bin", dtype="<f4").reshape(50257, 768)
wpe = np.fromfile(f"{D}/model/wpe.bin", dtype="<f4").reshape(1024, 768)
vocab = {v: k for k, v in json.load(open(f"{D}/model/vocab.json")).items()}

# GPT-2 byte-level BPE: map unicode chars back to bytes
bs = list(range(ord("!"), ord("~") + 1)) + list(range(ord("¡"), ord("¬") + 1)) + list(range(ord("®"), ord("ÿ") + 1))
cs = bs[:]; n = 0
for b in range(256):
    if b not in bs: bs.append(b); cs.append(256 + n); n += 1
byte_dec = {chr(c): b for b, c in zip(bs, cs)}

def decode(ids):
    return bytes(byte_dec[c] for i in ids for c in vocab[i]).decode("utf-8", "replace")

for name, X in [("raw", E), ("minus_wpe", E - wpe[: len(E)])]:
    d = ((X[:, None, :] - wte[None]) ** 2).sum(-1)
    ids = d.argmin(1)
    print(name, "min dist:", np.round(d.min(1), 4).tolist())
    print(name, "->", repr(decode(ids)))