Back
PQCHub

影像資料隱藏 (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 並思考以下問題:

  1. 為什麼檔案大小完全沒變? 不論你寫入的是 "Hello" 還是整本小說,只要沒超過圖片容量上限,BMP 的檔案大小(Bytes)都會保持不變。這是因為我們沒有「增加」數據,而是「替換」了現有像素中的雜訊位元。

  2. 視覺驗證: 將 input.bmpoutput_lsb.bmp 並排顯示。即使是資料科學家的眼睛,也無法分辨 RGB 值 (255, 0, 0)(254, 0, 0) 的差異。這證明了 LSB 作為一種隱寫手段的隱蔽性。

  3. 如果圖片被旋轉了怎麼辦? 我們目前的寫入順序是 (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 解碼器就像一個傳送帶,它會源源不絕地吐出 01。我們的程式邏輯必須像一個「封裝工人」:

  1. 每收集到 8 個 bits,就將它們打包。
  2. 將這 8 bits 轉換回 ASCII 字元 (例如 01000001 $\rightarrow$ A)。
  3. 終止條件:如果我們讀到了 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。這是一次非常重要的視覺衝擊體驗。

預期結果

你應該會看到一張全黑的圖片,但在圖片的最上方(也就是資料寫入的起始點),會有一小塊區域充滿了**「雜訊與雪花」**。

  1. 雪花區:這就是我們寫入的文字數據。因為 ASCII 編碼後的 0 與 1 分佈看起來是隨機的,所以呈現出雜訊的樣子。
  2. 全黑區:這是原本圖片中未被我們修改的區域。
    • 等等,原本圖片的 LSB 難道都是 0 嗎?
    • 思考:通常不是。自然照片的 LSB 本來就是隨機雜訊。所以如果你的 input.bmp 是一張真正的風景照,你提取出來的會是整張圖的雜訊。
    • 實驗建議:為了讓效果最明顯,建議同學先用小畫家做一張純黑色純白色的 BMP 當作 Input。這樣背景雜訊就會消失,你將能清晰地看到你寫入的資料在圖片記憶體中佔據了多少空間(物理上的空間)。

資料科學的啟示

這張 Noise Map 揭示了 LSB 隱寫術最大的弱點:統計特徵異常。 如果我們在一張原本應該很平滑的圖(例如卡通圖)中,發現 LSB 層充滿了高頻雜訊,這就是圖片被動過手腳的鐵證。這也是資安專家進行「隱寫分析 (Steganalysis)」的第一步。