影像資料隱藏 (Image Data Hiding) - Part 1
深入位元層級:利用 LSB 演算法在像素中寫入秘密
1. 數位影像的微觀世界:一切皆為 0 與 1
在資料科學的領域中,我們習慣將圖片視為矩陣 (Matrix)。但在電腦記憶體的底層視角,影像只是一連串由 Byte (8 bits) 組成的數據流。
8-bit 的權重 (Bit Weight)
在 24-bit 的 BMP 圖片中,每一個顏色通道(R、G、B)都佔用 1 Byte (8 bits)。 這 8 個 bits 並不是生而平等的,它們代表的數值權重由左至右遞減:
| Bit 位置 | 7 (MSB) | 6 | 5 | 4 | 3 | 2 | 1 | 0 (LSB) |
|---|---|---|---|---|---|---|---|---|
| 數值權重 | 128 | 64 | 32 | 16 | 8 | 4 | 2 | 1 |
- MSB (Most Significant Bit):最高有效位。變動它會導致數值 $\pm 128$,顏色劇烈變化(例如黑色瞬間變灰色)。
- LSB (Least Significant Bit):最低有效位。變動它只會導致數值 $\pm 1$。
LSB 隱寫術的核心:
利用人眼的「視覺冗餘」。當像素值從 200 變成 201 時,這種微小的光影差異會被淹沒在感光元件的自然熱雜訊中,肉眼完全無法察覺。
2. 演算法邏輯:位元手術
我們需要對像素的 RGB 數值進行精確操作:切除最後 1 bit,換上我們的秘密 bit。
假設我們有一個像素值 P (例如 10110101) 和一個秘密 bit b (例如 0)。
步驟 A:遮罩 (Masking)
使用 AND 運算符與 1111 1110 (十六進位 0xFE) 進行運算。這會保留前 7 位,強制將最後一位歸零。
\begin{align} P_{masked} = P \ \& \ 0xFE \end{align}
步驟 B:注入 (Injection)
使用 OR 運算符將秘密 bit b 放入最後一位。
\begin{align} P_{new} = P_{masked} \ | \ b \end{align}
3. 實作:使用 ES6 Modules 與 Pixel 操作
我們將使用之前課程完成的 Bmp 類別。請確保你的環境支援 ES6 Modules (專案 package.json 設定 "type": "module" 或使用 .mjs 副檔名)。
檔案結構
Bmp.js(或是Bmp.mjs):你之前寫好的類別,需使用export default class Bmp ...。lsb_hide.js:本次的主程式。
程式碼:lsb_hide.js
import Bmp from './Bmp.js';
/**
* 輔助工具:將字串轉為二進位流
* 輸入 "A" -> 輸出 "01000001"
*/
function stringToBinary(text) {
// 1. 將字串轉為 Buffer (Byte Array)
const buffer = Buffer.from(text, 'utf-8');
let binaryStr = "";
// 2. 遍歷 Buffer 中的每一個 Byte
for (const byte of buffer) {
// byte 是一個 0-255 的數字
// toString(2) 轉成二進位字串
// padStart(8, '0')
binaryStr += byte.toString(2).padStart(8, '0');
}
return binaryStr;
};
// 1. 載入圖片 (請確保目錄下有一張 input.bmp)
const img = Bmp.load('./input.bmp');
console.log(`[載入圖片] 尺寸: ${img.width}x${img.height}`);
// 2. 準備秘密訊息 (加上 \0 作為結束符號)
const secret = "ES6 Modules with LSB steganography is cool!";
const payload = stringToBinary(secret + '\0');
// 3. 檢查容量
// 每個像素有 RGB 3 個通道,每個通道可藏 1 bit
const maxCapacity = img.width * img.height * 3;
if (payload.length > maxCapacity) {
console.error(`容量不足!需要 ${payload.length} bits, 圖片僅提供 ${maxCapacity} bits`);
}
else {
console.log(`[寫入數據] 正在將 ${payload.length} bits 寫入圖片...`);
let bitIndex = 0;
// --- 核心迴圈:遍歷所有像素 ---
// 使用 ES6 區塊變數 let
for (let y = 0; y < img.height; y++) {
for (let x = 0; x < img.width; x++) {
// 若訊息寫完則提早結束
if (bitIndex >= payload.length) break;
// A. 取得目前像素物件 {r, g, b}
let pixel = img.getPixel(x, y);
// B. 依序處理 B, G, R 三個通道 這裡注意,之前教過 BMP 存放記憶體的順序其實是 BGR,當然,這個引岑資料的順序式可以改的,但之後解開時需要用一樣的順序!
const channels = ['b', 'g', 'r'];
for (const channel of channels) {
// 如果資料已經藏完了就不用繼續
if (bitIndex < payload.length) {
const bit = parseInt(payload[bitIndex]);
// LSB 演算法: (原值 & 0xFE) | 秘密Bit
pixel[channel] = (pixel[channel] & 0xFE) | bit;
bitIndex++;
}
}
// C. 將修改後的像素寫回 Buffer
img.setPixel(x, y, pixel.r, pixel.g, pixel.b);
}
if (bitIndex >= payload.length) break;
}
// 4. 存檔
img.save('output_lsb.bmp');
console.log(`[完成] 加密圖片已產出: output_lsb.bmp`);
}4. 課堂思考:驗證與風險
執行程式後,請觀察 output_lsb.bmp 並思考以下問題:
為什麼檔案大小完全沒變? 不論你寫入的是 "Hello" 還是整本小說,只要沒超過圖片容量上限,BMP 的檔案大小(Bytes)都會保持不變。這是因為我們沒有「增加」數據,而是「替換」了現有像素中的雜訊位元。
視覺驗證: 將
input.bmp與output_lsb.bmp並排顯示。即使是資料科學家的眼睛,也無法分辨 RGB 值(255, 0, 0)與(254, 0, 0)的差異。這證明了 LSB 作為一種隱寫手段的隱蔽性。如果圖片被旋轉了怎麼辦? 我們目前的寫入順序是
(0,0) -> (1,0) -> ...。如果這張圖片被順時針旋轉 90 度,所有的像素座標都會改變,解碼器如果依照原本順序讀取,讀出來的位元流將會完全混亂。這說明了 LSB 演算法雖然簡單,但非常脆弱(Fragile),無法抵抗幾何變換攻擊。
1. 解碼邏輯:如何把雜訊變回文字?
在編碼階段,我們做了 (val & 0xFE) | bit 的操作。現在我們要進行逆向工程。
我們要從 RGB 通道中提取最後一個位元,這在數學上非常簡單:只需要對數值進行 AND 1 運算(或是 MOD 2)。
\begin{align} Bit = PixelValue \ \& \ 1 \end{align}
- 如果像素值是
200(11001000) $\rightarrow$200 & 1 = 0 - 如果像素值是
201(11001001) $\rightarrow$201 & 1 = 1
資料重組流程 (Reassembly)
pixel
解碼器就像一個傳送帶,它會源源不絕地吐出 0 和 1。我們的程式邏輯必須像一個「封裝工人」:
- 每收集到 8 個 bits,就將它們打包。
- 將這 8 bits 轉換回 ASCII 字元 (例如
01000001$\rightarrow$A)。 - 終止條件:如果我們讀到了
00000000(NULL Character,\0),代表訊息結束,停止讀取。
2. 實作:LSB 解碼器 (Decoder)
請建立 lsb_reveal.js。我們同樣使用 Bmp 類別與 getPixel 介面。
import Bmp from './Bmp.js';
// 1. 載入我們上一堂課產出的加密圖片
const img = Bmp.load('./output_lsb.bmp');
console.log(`[載入圖片] 正在分析 ${img.width}x${img.height} 的像素矩陣...`);
let collectedBits = ""; // 暫存目前的 8 bits
const byteBuffer = []; // 用來存放解出的 Byte 數值 (0~255),最後再一次轉回文字
let foundTerminator = false; // 是否找到結束符號
// --- 核心迴圈 ---
// 必須與加密時的順序完全一致 (左->右, 上->下, B->G->R)
for (let y = 0; y < img.height; y++) {
for (let x = 0; x < img.width; x++) {
if (foundTerminator) break;
const pixel = img.getPixel(x, y);
// 【重要】解碼順序必須與隱藏時完全一致:['b', 'g', 'r']
const channels = ['b', 'g', 'r'];
for (const channel of channels) {
// A. 提取 LSB (關鍵演算法: 取出最後一位)
const bit = pixel[channel] & 1;
// B. 收集 bit
collectedBits += bit;
// C. 每滿 8 bits 就組裝成一個 Byte
if (collectedBits.length === 8) {
// 將 "01100001" 轉回數值 97
const byteValue = parseInt(collectedBits, 2);
// 檢查是否為結束符號 (NULL Character: 0)
if (byteValue === 0) {
foundTerminator = true;
break;
}
// 【關鍵修改】不直接轉文字,而是存入 Buffer 陣列
// 這樣才能正確處理 UTF-8 的多字節字符 (例如中文)
byteBuffer.push(byteValue);
// 清空暫存,準備下一個 Byte
collectedBits = "";
}
}
if (foundTerminator) break;
}
if (foundTerminator) break;
}
if (foundTerminator) {
// 最後將收集到的所有 Bytes 轉回 UTF-8 字串
const decodedMessage = Buffer.from(byteBuffer).toString('utf-8');
console.log("------------------------------------------------");
console.log("[解密成功] 隱藏的訊息是:");
console.log(`\x1b[32m${decodedMessage}\x1b[0m`); // 用綠色字體顯示
console.log("------------------------------------------------");
}
else {
console.log("[警告] 讀完整張圖片都沒找到結束符號,可能這張圖沒有藏資料,或是格式/順序不對。");
}3. 資料科學視角:位元平面切片 (Bit Plane Slicing)
作為資料科學家,我們不能只滿足於文字解碼。我們需要**「看見」**資料的分佈。 既然 LSB 看起來像是雜訊,那我們就把這層雜訊獨立提取出來,並將其對比度拉到最大。
原理
原本 LSB 為 1 時,顏色值只加 1 (肉眼不可見)。 如果我們將提取出來的 LSB 乘以 255:
- $Bit = 0 \rightarrow Color = 0$ (全黑)
- $Bit = 1 \rightarrow Color = 255$ (全白)
這樣我們就能產出一張**「雜訊地圖 (Noise Map)」**。這張圖上的黑白噪點,就是我們藏入的資訊。
實作:雜訊視覺化工具
建立 lsb_visualize.js。
import Bmp from './Bmp.js';
// 載入加密過的圖片
const img = Bmp.load('./output_lsb.bmp');
console.log(`[視覺化分析] 正在提取 LSB 層...`);
// 建立一張新圖片來存放結果 (這裡我們直接修改原圖物件並另存)
for (let y = 0; y < img.height; y++) {
for (let x = 0; x < img.width; x++) {
const pixel = img.getPixel(x, y);
// 對每個通道進行「二值化放大」
// 如果 LSB 是 1 -> 變成 255 (白)
// 如果 LSB 是 0 -> 變成 0 (黑)
pixel.r = (pixel.r & 1) * 255;
pixel.g = (pixel.g & 1) * 255;
pixel.b = (pixel.b & 1) * 255;
img.setPixel(x, y, pixel.r, pixel.g, pixel.b);
}
}
img.save('analysis_noise_map.bmp');
console.log(`[完成] 雜訊地圖已輸出至: analysis_noise_map.bmp`);
4. 課堂實驗與觀察
請執行 lsb_visualize.js 並打開產生的 analysis_noise_map.bmp。這是一次非常重要的視覺衝擊體驗。
預期結果
你應該會看到一張全黑的圖片,但在圖片的最上方(也就是資料寫入的起始點),會有一小塊區域充滿了**「雜訊與雪花」**。
- 雪花區:這就是我們寫入的文字數據。因為 ASCII 編碼後的 0 與 1 分佈看起來是隨機的,所以呈現出雜訊的樣子。
- 全黑區:這是原本圖片中未被我們修改的區域。
- 等等,原本圖片的 LSB 難道都是 0 嗎?
- 思考:通常不是。自然照片的 LSB 本來就是隨機雜訊。所以如果你的
input.bmp是一張真正的風景照,你提取出來的會是整張圖的雜訊。 - 實驗建議:為了讓效果最明顯,建議同學先用小畫家做一張純黑色或純白色的 BMP 當作 Input。這樣背景雜訊就會消失,你將能清晰地看到你寫入的資料在圖片記憶體中佔據了多少空間(物理上的空間)。
資料科學的啟示
這張 Noise Map 揭示了 LSB 隱寫術最大的弱點:統計特徵異常。 如果我們在一張原本應該很平滑的圖(例如卡通圖)中,發現 LSB 層充滿了高頻雜訊,這就是圖片被動過手腳的鐵證。這也是資安專家進行「隱寫分析 (Steganalysis)」的第一步。