É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, ...], ...]}
modelvautgpt2.inputs_embedsest 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]
wteest la table d’embedding des tokens (50257 × 768).wpeest 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
-
Récupérer les poids. On télécharge
vocab.jsondeopenai-community/gpt2sur Hugging Face, puis seulement les tenseurswte.weightetwpe.weightdemodel.safetensors, avec des requêtes HTTPRange. Pour trouver les octets à demander :- les 8 premiers octets du fichier donnent la taille du header JSON (14283) ;
- ce header indique les
data_offsetsde chaque tenseur ; - l’offset réel dans le fichier vaut
8 + 14283 + offset.
Pas besoin de torch ni de transformers.
-
Retirer la position. Pour chaque position, on calcule
X = inputs_embeds - wpe[0:24]. -
Chercher le plus proche voisin. Pour chaque ligne de
X, on prendargminde la distance L2 àwte. La distance minimale est exactement 0.0 pour les 24 tokens, donc la correspondance est parfaite. -
Décoder. On décode les 24 tokens avec le BPE byte-level de GPT-2 (table
bytes_to_unicodeinverse) et on obtientBA26{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)))