// Il Quiz
Sezione 01. L'esperimentoHo postato una foto su X con questa didascalia:
C'e' un messaggio nascosto in questa foto. Scarica l'immagine, trova il messaggio, scrivilo nei commenti. Questa sera la soluzione se non la trovate prima.
Nessuno l'ha trovato. Qualcuno ha provato con LSB. Qualcuno ha guardato i metadati EXIF. Qualcuno ha alzato il contrasto. Niente. Il messaggio era un audio. Un segnale morse. Due parole. Sepolte nei pixel con una tecnica che sopravvive alla compressione di X.
Questo articolo smonta tutto. Come ho nascosto l'audio, perche' 3 tecniche su 4 muoiono, e perche' una no. La matematica dietro. Il codice. E lo script per risolvere il quiz, se vuoi provare prima di leggere la risposta.
| 1 | pip install numpy pillow |
| 2 | python reveal.py immagine.jpg |
| 3 | |
| 4 | # Output: |
| 5 | # +-===============-+ |
| 6 | # | SIGNAL PIRATE | |
| 7 | # +-===============-+ |
// Il Messaggio
Sezione 02. Da testo a beep a byteIl messaggio e' "SIGNAL PIRATE". Due parole. Per nasconderle in una foto potevo codificarle come testo. Troppo facile. Troppo noioso. Le ho trasformate in un audio morse.
Testo -> Morse -> Tono sinusoidale -> WAV. Ogni lettera diventa una sequenza di dot (brevi) e dash (lunghi). Ogni dot e' un beep a 700 Hz per 100ms. Ogni dash e' lo stesso beep per 300ms. Silenzio tra simboli, silenzio piu' lungo tra lettere, silenzio ancora piu' lungo tra parole.
| 1 | S = ... (3 dot) |
| 2 | I = .. (2 dot) |
| 3 | G = --. (2 dash + 1 dot) |
| 4 | N = -. (1 dash + 1 dot) |
| 5 | A = .- (1 dot + 1 dash) |
| 6 | L = .-.. (dot dash dot dot) |
| 7 | |
| 8 | P = .--. (dot dash dash dot) |
| 9 | I = .. (2 dot) |
| 10 | R = .-. (dot dash dot) |
| 11 | A = .- (dot dash) |
| 12 | T = - (1 dash) |
| 13 | E = . (1 dot) |
Il risultato e' un WAV di 6.4 secondi. 8000 Hz, mono, 8 bit. Grezzo come un segnale radio degli anni '40. Pesa 50.924 byte. Ma compresso con zlib diventa 248 byte. Perche' il morse e' quasi tutto silenzio, e il silenzio si comprime bene.
SIGNAL PIRATE in morse — l'audio nascosto nella foto
248 byte. 2.016 bit. In una foto da 2316x3088 pixel (7.153.088 pixel, 21.459.264 valori di canale). Nascondere 2.016 bit in 21 milioni di numeri e' come nascondere un granello di sabbia in una spiaggia. Il problema non e' lo spazio. Il problema e' che X prende la tua spiaggia e la passa al bulldozer.
// Il Bulldozer
Sezione 03. Cosa fa X alla tua fotoPosti una PNG. X la prende e fa quello che vuole. Il pipeline e' brutale:
Il passo 3 e' il killer. La compressione JPEG non e' un ridimensionamento. E' una trasformazione matematica distruttiva. Prende ogni blocco 8x8 pixel, lo trasforma nel dominio delle frequenze con la DCT (Discrete Cosine Transform), e butta via le frequenze alte. I dettagli fini spariscono. I bordi diventano sfumati. E qualsiasi informazione nascosta nei bit meno significativi viene polverizzata.
Per la steganografia, la compressione JPEG e' un terremoto. Ogni pixel puo' cambiare di valore dopo la ricompressione. Non di tanto, magari di 2-3 unita'. Ma se il tuo messaggio e' nascosto in variazioni di 1 unita', sei morto.
Il punto chiave: nascondere dati in una foto e' facile. Nascondere dati in una foto che sopravvivono a quello che i social network fanno alla foto e' un altro sport.
// 4 Tecniche, 3 Cadaveri
Sezione 04. L'arenaHo testato 4 tecniche di steganografia sulla stessa foto, con lo stesso audio morse. Poi ho salvato in PNG, riaperto, verificato. Poi ho simulato la compressione JPEG quality 85 (simile a quella di X) e riverificato.
| Tecnica | PNG | JPEG q85 | X |
|---|---|---|---|
| LSB (Least Significant Bit) | FAIL | DISTRUTTO | DISTRUTTO |
| DCT (mid-frequency) | OK | SOPRAVVIVE | DISTRUTTO |
| Spread Spectrum (differenziale) | OK | SOPRAVVIVE | SOPRAVVIVE |
| QIM (Quantization Index Modulation) | OK | DISTRUTTO | DISTRUTTO |
LSB morto subito, QIM morto al JPEG, DCT sopravvive al JPEG locale ma muore nel pipeline reale di X. Solo lo Spread Spectrum passa tutto. Per il quiz ho usato quello. Vediamo perche' ognuno vive o muore.
// LSB: il Primo a Morire
Sezione 05. L'ultimo bit e' il primo a saltareLSB e' la tecnica che tutti conoscono. Prendi ogni pixel, cambi l'ultimo bit. Il pixel vale 142? Ora vale 143. O 142. L'occhio non vede la differenza. Il messaggio e' nei bit meno significativi.
| 1 | # Encoding LSB: cambia l'ultimo bit di ogni pixel |
| 2 | for i, bit in enumerate(bits): |
| 3 | flat[i] = (flat[i] & 0xFE) | bit |
| 4 | # pixel 142 (10001110) + bit 1 = 143 (10001111) |
| 5 | # pixel 142 (10001110) + bit 0 = 142 (10001110) |
Elegante. Minimale. E completamente inutile se qualcuno tocca l'immagine. La compressione JPEG cambia i pixel di 2-3 unita'. Se il tuo messaggio sta in una differenza di 1, il JPEG lo cancella come una gomma su una matita. Non e' un attacco. E' un effetto collaterale. X non sa che c'e' un messaggio. Semplicemente lo schiaccia come un camion che passa su una formica senza vederla.
Perche' LSB muore gia' nella nostra pipeline: PNG e' lossless, il formato non perde nulla. Ma il nostro encoder converte l'immagine in YCbCr per lavorare sul canale di luminanza, poi riconverte in RGB per salvare. Quel round-trip RGB → YCbCr → RGB introduce errori di arrotondamento che bastano a flippar bit. LSB non tollera nemmeno quello, figuriamoci la compressione JPEG.
// QIM: Quasi
Sezione 06. La quantizzazione che non bastaQIM e' piu' furbo di LSB. Invece di cambiare un singolo bit, quantizza il pixel su due griglie diverse. Se il bit e' 0, il pixel viene arrotondato alla griglia pari (0, 40, 80, 120...). Se e' 1, alla griglia dispari (20, 60, 100, 140...).
| 1 | QIM_DELTA = 40 |
| 2 | |
| 3 | # Bit 0: quantizza sulla griglia 0, 40, 80, 120... |
| 4 | q0 = round(val / QIM_DELTA) * QIM_DELTA |
| 5 | |
| 6 | # Bit 1: quantizza sulla griglia 20, 60, 100, 140... |
| 7 | q1 = round((val - QIM_DELTA/2) / QIM_DELTA) * QIM_DELTA + QIM_DELTA/2 |
| 8 | |
| 9 | # Decodifica: chi e' piu' vicino? |
| 10 | bit = 1 if abs(val - q1) < abs(val - q0) else 0 |
Con un delta di 40, il pixel puo' essere spostato fino a 20 unita'. Molto piu' robusto di LSB. In PNG funziona perfettamente. Ma JPEG fa anche lui una quantizzazione: nel dominio frequenza applica round(coeff / Q) * Q con passi Q tipicamente tra 5 e 20 a quality 85. Quando due quantizzazioni si sovrappongono, non e' detto che quella originale vinca. Tradotto nello spazio pixel, alcuni valori vengono spostati abbastanza da saltare dalla griglia 0 alla griglia 1. Game over.
// DCT: Quasi ce la Fa
Sezione 07. Nascondersi dove JPEG guardaQui la cosa diventa interessante. La DCT steganography nasconde i dati esattamente dove JPEG opera: nel dominio delle frequenze. Invece di modificare i pixel, modifica i coefficienti DCT di blocchi 8x8.
JPEG funziona cosi': divide l'immagine in blocchi 8x8, applica la Discrete Cosine Transform, ottiene 64 coefficienti per blocco. Il coefficiente in alto a sinistra (DC) e' la luminosita' media. Quelli in basso a destra sono i dettagli fini. JPEG butta via i dettagli fini (frequenze alte) e tiene le frequenze medie e basse.
Il trucco: nascondere i bit nelle frequenze medie. Non troppo alte (JPEG le cancella), non troppo basse (troppo visibili). Le posizioni (2,3), (3,2), (4,1) nella matrice 8x8.
| 1 | DCT_POSITIONS = [(2,3), (3,2), (4,1), (1,4), (3,3)] |
| 2 | DCT_STRENGTH = 50 |
| 3 | |
| 4 | # Per ogni blocco 8x8: |
| 5 | dct_block = dctn(block, type=2, norm='ortho') |
| 6 | pos = DCT_POSITIONS[bit_idx % 5] |
| 7 | |
| 8 | if bit == 1: |
| 9 | dct_block[pos] = DCT_STRENGTH # forza a 50 |
| 10 | else: |
| 11 | dct_block[pos] = 0 # forza a 0 |
Bit 1 = coefficiente forzato a 50. Bit 0 = coefficiente forzato a 0. L'approccio e' brutale (normalmente si farebbe += strength per rispettare il contenuto originale), ma per un payload cosi' piccolo funziona senza artefatti visibili. Per decodificare, guardi se il coefficiente e' sopra o sotto 25 (meta' di 50). La quantizzazione JPEG quality 85 in locale sposta quei coefficienti di 3-8 unita': non abbastanza per rompere il messaggio. Ma il pipeline reale di X non fa solo JPEG. Resize, conversione colore, ricompressione a quality variabile: l'accumulo di errori basta a far saltare abbastanza bit da corrompere il payload.
Perche' regge il JPEG ma non X: la DCT steganography parla la stessa lingua di JPEG. Con strength 50 e soglia 25, il margine e' enorme per una singola ricompressione (JPEG sposta i coefficienti mid-frequency di 3-8 unita'). Ma X fa anche resize e conversione colore prima del JPEG. L'accumulo supera il margine. In laboratorio sopravvive, in produzione muore.
// Spread Spectrum: la Matematica che Vince
Sezione 08. Correlazione, chip pseudorandom, e il trucco differenzialeQuesta e' la tecnica che ho usato per il quiz. E qui la matematica diventa bella.
L'idea viene dalle telecomunicazioni militari. Per trasmettere un bit, non lo metti in un singolo punto. Lo spalmi su centinaia di punti usando una sequenza pseudorandom (il "chip"). Per leggerlo, moltiplichi il segnale ricevuto per la stessa sequenza e sommi. Se il chip e' lo stesso, la correlazione e' alta. Se no, e' rumore.
Ma c'e' un problema. Il segnale originale dell'immagine e' molto piu' forte del messaggio nascosto. Come sentire un sussurro in uno stadio. La soluzione e' il metodo differenziale.
Il trucco: due blocchi, un segreto
Per ogni bit, prendo due blocchi di pixel adiacenti: A e B. Genero un chip pseudorandom (128 valori, ciascuno +1 o -1). Poi:
| 1 | SS_STRENGTH = 20 |
| 2 | SS_HALF = 128 |
| 3 | |
| 4 | chip = rng.choice([-1.0, 1.0], size=SS_HALF) |
| 5 | signal = 1.0 if bit == 1 else -1.0 |
| 6 | |
| 7 | # Encoding: aggiungi al blocco A, sottrai dal blocco B |
| 8 | y[A:A+128] += signal * chip * SS_STRENGTH |
| 9 | y[B:B+128] -= signal * chip * SS_STRENGTH |
Blocco A riceve +signal * chip * 20. Blocco B riceve -signal * chip * 20. I due blocchi dell'immagine originale non sono uguali, ma non importa: la differenza tra i contenuti originali e' incorrelata col chip, quindi si cancella nella correlazione. Il messaggio invece e' opposto nei due blocchi, e si accumula.
La decodifica: il rumore si cancella, il segnale no
| 1 | # Decoding: differenza tra blocco A e blocco B |
| 2 | diff = y[A:A+128] - y[B:B+128] |
| 3 | |
| 4 | # diff = (origA + chip*20) - (origB - chip*20) |
| 5 | # = (origA - origB) + 2*chip*20 |
| 6 | # (origA - origB) non e' correlato col chip: si cancella nella somma |
| 7 | |
| 8 | correlation = np.dot(diff, chip) |
| 9 | bit = 1 if correlation > 0 else 0 |
Ecco il punto. Quando fai A - B, ottieni (origA - origB) + 2 * signal * chip. Il contenuto originale non sparisce. Ma non e' correlato col chip pseudorandom. Nella correlazione, la somma di (origA - origB) * chip tende a zero per la legge dei grandi numeri, mentre il segnale correlato si accumula: 128 * 2 * 20 = 5.120 per un bit 1, -5.120 per un bit 0.
S = strength (20), N = chip size (128). Il termine (origA - origB) si cancella nella somma.
Perche' JPEG non lo uccide
JPEG aggiunge rumore. Ogni pixel puo' cambiare di 2-3 unita'. Ma il rumore e' incorrelato col chip. Quando sommi 128 valori di rumore incorrelato moltiplicati per +1 o -1, la somma tende a zero. Il rumore si cancella.
Rumore JPEG incorrelato col chip: la somma tende a zero per la legge dei grandi numeri
Il segnale utile vale 5.120. Il rumore JPEG per singolo pixel e' circa 3 unita'. Su 128 campioni il rumore aggregato cresce come $\sqrt{N}$, quindi $\sqrt{128} \cdot 3 \approx 34$. Il rapporto segnale/rumore e' quindi ~150:1. JPEG dovrebbe aggiungere 40 unita' di errore per pixel per rompere il messaggio. A quality 85 ne aggiunge 3. Non c'e' partita.
Il principio: il rumore non correlato si cancella quando lo sommi su molti campioni. Il segnale correlato si accumula. Piu' grande il chip (128 campioni), piu' il messaggio emerge dal rumore. Questo e' esattamente lo stesso principio usato dal GPS: il segnale dai satelliti arriva 20 dB sotto il rumore di fondo, ma il ricevitore lo estrae correlando con il codice pseudorandom noto (Gold code). Funziona perche' sa cosa cercare. Senza il codice, e' rumore.
Il seed e' la chiave. La sequenza pseudorandom e' generata da numpy.random.RandomState(42). Chi ha il seed puo' ricostruire i chip e decodificare. Chi non ce l'ha vede solo rumore. Per il quiz il seed e' hardcoded nello script: chiunque scarica reveal.py puo' risolvere. In un sistema reale il seed sarebbe il segreto condiviso tra chi nasconde e chi estrae.
// L'Embedding Completo
Sezione 09. Da WAV a pixel, passo per passoIl pipeline completo per nascondere l'audio:
La compressione zlib e' fondamentale. Il WAV grezzo ha 50.924 byte (407.392 bit). Troppi. Servirebbero 407.392 coppie di blocchi da 256 pixel, cioe' 104 milioni di pixel. La foto ne ha 7 milioni. Non ci sta.
Ma il morse e' quasi tutto silenzio. Byte a 128 (il centro per audio unsigned 8-bit) ripetuti migliaia di volte. zlib li comprime a 248 byte. 2.016 bit. Servono 2.016 coppie da 256 pixel = 516.096 pixel. La foto ne ha 7 milioni. Ci sta con margine enorme.
Il vero trucco e' qui. La compressione del payload prima dell'embedding e' la ragione per cui l'esperimento funziona cosi' bene. Il parametro chiave nella steganografia robusta non e' lo spazio totale dell'immagine ma la payload density: bit nascosti per pixel.
Un bit ogni 3.548 pixel. Estremamente basso.
Molti esperimenti di steganografia fanno l'errore opposto: cercano di massimizzare la capacita'. Noi abbiamo fatto il contrario. Minimizzare il payload significa che il segnale spread spectrum e' molto diluito, non altera la statistica dell'immagine, sopravvive meglio alla compressione, e diventa molto difficile da rilevare con analisi steganalitiche standard. La robustezza e' inversamente proporzionale alla densita'.
Meno nascondi, meglio sopravvive.
// Il Decoder Morse
Sezione 10. Dall'audio al testoUna volta estratto il WAV, bisogna decodificare il morse. Non e' banale come sembra. L'audio ha rumore, i tempi non sono perfetti, e non sai in anticipo la velocita' del morse.
L'approccio: calcola l'energia del segnale in finestre da 20ms. Se l'energia supera il 15% del picco massimo, c'e' un tono. Se no, silenzio. Poi segmenta in periodi on/off e classifica:
| 1 | # Calcola energia in finestre da 20ms |
| 2 | window = 160 # 8000 Hz * 0.02s |
| 3 | energy = [np.mean(signal[i:i+window] ** 2) |
| 4 | for i in range(0, len(signal), window)] |
| 5 | |
| 6 | # Soglia: 15% del picco |
| 7 | threshold = max(energy) * 0.15 |
| 8 | |
| 9 | # Segmenta e classifica |
| 10 | dot_duration = min(on_durations) |
| 11 | for seg_type, duration in segments: |
| 12 | if seg_type == 'on': |
| 13 | if duration <= dot_duration * 2: # dot |
| 14 | morse.append('.') |
| 15 | else: # dash |
| 16 | morse.append('-') |
Il trucco e' stimare la durata del dot dal segmento on piu' corto. Tutto il resto scala da li': un dash e' 3x un dot, una pausa tra lettere e' 3x un dot, una pausa tra parole e' 7x un dot. Il codice morse e' un protocollo autosincronizzante. Basta trovare l'unita' base e il resto viene da se'.
// Il Test Reale
Sezione 11. X, JPEG, e la prova del fuocoPostare la PNG su X. Scaricarla. Lanciare il decoder. Il momento della verita'.
| 1 | $ python reveal.py HCtsu3baUAELF_z.jpg |
| 2 | |
| 3 | Immagine: HCtsu3baUAELF_z.jpg (2316x3088) |
| 4 | |
| 5 | Estrazione dati nascosti... |
| 6 | Audio estratto: hidden_message.wav (6.4s) |
| 7 | |
| 8 | Decodifica morse... |
| 9 | |
| 10 | +-===============-+ |
| 11 | | SIGNAL PIRATE | |
| 12 | +-===============-+ |
Funziona. X ha preso la mia PNG da 8MB, l'ha ricompressa in un JPEG da 1.4MB, ha buttato via i metadati, e il messaggio e' ancora li'. 2.016 bit sopravvissuti al bulldozer.
Se ci pensi, e' assurdo. X ha modificato milioni di valori nell'immagine. Ma le 2.016 correlazioni tra coppie di blocchi sono rimaste tutte dalla parte giusta. Tutte. Nessun errore nel test. Perche' il rumore della ricompressione non e' correlato col chip pseudorandom, e si cancella nella somma. E il segnale utile e' ~150 volte piu' forte del rumore.
// Perche' Funziona Davvero
Sezione 12. Il puntoLa steganografia e' un gioco tra chi nasconde e chi cerca. La maggior parte delle tecniche perde perche' si affida ai dettagli dell'immagine, quelli che la compressione elimina per prima. LSB nasconde nei bit insignificanti. QIM nelle posizioni esatte dei pixel. DCT nelle frequenze medie, ma senza ridondanza. Tutte e tre dipendono dal valore preciso di un singolo coefficiente o pixel.
Lo spread spectrum fa il contrario. Non gli importa del valore di un singolo pixel. Gli importa della correlazione statistica tra centinaia di pixel e una sequenza pseudorandom nota. La compressione puo' spostare ogni singolo pixel, ma non puo' rompere la correlazione aggregata. Come il rumore di una piazza non puo' cancellare il ritmo di un tamburo.
"Un singolo pixel mente. Centinaia di pixel correlati no."
Steganografia spread spectrum: il messaggio e' nella statistica, non nel dettaglio.
Tutto il codice e' su GitHub. Lo script reveal.py ha bisogno solo di numpy e pillow. Se hai ancora l'immagine del quiz, provalo.