跳至主要內容
Skip to content

二進位外科手術 — 直接操作 ArrayBuffer

現在我們要當一次「二進位外科醫生」,直接切開 Blob,修改裡面的數字。

操作流程是:

Blob (封存的包裹) → ArrayBuffer (打開包裹放在桌上) → Uint8Array (戴上手套的手,直接修改)

一、 手術工具介紹

工具比喻用途
ArrayBuffer手術台一塊固定大小的記憶體空間
Uint8Array手術刀用 0-255 的視角讀寫這塊記憶體
DataView精密儀器可以指定 endianness 讀取多 byte 數值

二、 實驗一:把文字當數字改 (字串變身術)

假設我們有一段文字 "ABC"。在 ASCII 編碼中:

  • A = 65
  • B = 66
  • C = 67

如果我們把第一個數字 65 加 1,它就會變成 66,也就是 'B'

我們完全不使用字串取代 (replace),而是直接改記憶體裡的數字:

javascript
async function modifyBinary() {
  // 1. 準備原始 Blob
  const originalBlob = new Blob(["ABC"], { type: "text/plain" });

  // 2. 轉成 ArrayBuffer (取得記憶體控制權)
  const buffer = await originalBlob.arrayBuffer();

  // 3. 建立 "編輯視圖" (Uint8Array)
  // 這讓我們可以像操作陣列一樣操作記憶體
  const bytes = new Uint8Array(buffer);

  console.log("修改前:", bytes); // Uint8Array(3) [65, 66, 67]

  // --- 手術開始 ---
  bytes[0] = bytes[0] + 1; // 把第一個 byte (A/65) 加 1
  // 現在變成 66 (也就是 'B')
  // --- 手術結束 ---

  console.log("修改後:", bytes); // Uint8Array(3) [66, 66, 67]

  // 4. 打包回 Blob
  const newBlob = new Blob([buffer], { type: "text/plain" });

  // 驗證結果
  const newText = await newBlob.text();
  console.log("最終文字:", newText); // "BBC"
}

modifyBinary();

結論:電腦眼裡沒有文字,只有數字運算。我們剛才透過數學運算修改了文字。


三、 實驗二:讀取「檔案指紋」(Magic Numbers)

這是前端開發(尤其是做檔案上傳功能時)很實用的技巧,但要記住:前端檢查是 UX 與初步防呆,不是安全邊界。

問題情境

很多時候使用者會「騙」你:他把 virus.exe 改名成 photo.png 然後上傳。如果你只檢查副檔名 (.png),你就被騙了。

但是,檔案的頭幾個 Bytes (Magic Numbers) 通常比副檔名可靠。它不能保證檔案絕對安全,因為檔頭也可能被刻意偽造,但足以攔下許多「只改副檔名」的假檔案。

Magic Numbers 是什麼?

每種檔案格式在開頭都會放置固定的「簽名」,這就是 Magic Numbers。例如:

  • PNG:開頭 8 個 bytes 是 89 50 4E 47 0D 0A 1A 0A
  • JPEG:開頭 2 個 bytes 是 FF D8
  • PDF:開頭 4 個 bytes 是 25 50 44 46 (就是 %PDF 的 ASCII)

驗證程式碼

javascript
async function checkIsRealPng(file) {
  // 1. 我們只需要讀取前 8 個 bytes,不用讀整張圖 (節省效能)
  const buffer = await file.slice(0, 8).arrayBuffer();

  // 2. 轉成 Uint8Array
  const bytes = new Uint8Array(buffer);

  console.log("檔案的前 8 個數字:", bytes);

  // PNG 的標準簽名 (Magic Numbers)
  // 137, 80, 78, 71, 13, 10, 26, 10 (16進位轉成10進位)
  const pngSignature = [137, 80, 78, 71, 13, 10, 26, 10];

  // 3. 比對數字
  const isPng = pngSignature.every((byte, index) => byte === bytes[index]);

  if (isPng) {
    console.log("初步驗證通過:檔頭符合 PNG");
  } else {
    console.log("警告:檔頭不符合 PNG,可能是假冒的檔案");
  }

  return isPng;
}

// 測試:建立一個假的 PNG
const fakeFile = new Blob(["這不是圖片"], { type: "image/png" });
checkIsRealPng(fakeFile); // ❌ 警告

四、 常見檔案類型的 Magic Numbers 對照表

檔案類型Magic Numbers (Hex)Magic Numbers (Decimal)位置
PNG89 50 4E 47 0D 0A 1A 0A[137, 80, 78, 71, 13, 10, 26, 10]前 8 bytes
JPEGFF D8 FF[255, 216, 255]前 3 bytes
GIF47 49 46 38[71, 73, 70, 56]前 4 bytes (GIF8)
PDF25 50 44 46[37, 80, 68, 70]前 4 bytes (%PDF)
ZIP50 4B 03 04[80, 75, 3, 4]前 4 bytes (PK)
WebP52 49 46 46 + 57 45 42 50[82, 73, 70, 70] + [87, 69, 66, 80]前 4 bytes (RIFF) 與第 8-11 bytes (WEBP)

NOTE

Magic Numbers 是「格式特徵」,不是安全保證。有些格式可能有多種合法簽名,有些檔案也可能被刻意偽造檔頭。前端可以先擋掉明顯錯誤,後端仍要重新驗證。


五、 封裝通用驗證器

以下是一個可以直接用在專案中的檔案驗證器:

javascript
const FILE_SIGNATURES = {
  "image/png": {
    signatures: [
      { bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], offset: 0 },
    ],
  },
  "image/jpeg": {
    signatures: [{ bytes: [0xff, 0xd8, 0xff], offset: 0 }],
  },
  "image/gif": {
    signatures: [{ bytes: [0x47, 0x49, 0x46, 0x38], offset: 0 }], // GIF8
  },
  "application/pdf": {
    signatures: [{ bytes: [0x25, 0x50, 0x44, 0x46], offset: 0 }], // %PDF
  },
  "image/webp": {
    signatures: [
      { bytes: [0x52, 0x49, 0x46, 0x46], offset: 0 }, // RIFF
      { bytes: [0x57, 0x45, 0x42, 0x50], offset: 8 }, // WEBP
    ],
  },
};

/**
 * 驗證檔案是否符合其聲稱的 MIME Type
 * @param {File} file - 要驗證的檔案
 * @param {string} expectedType - 期望的 MIME Type
 * @returns {Promise<boolean>}
 */
async function validateFileType(file, expectedType) {
  const config = FILE_SIGNATURES[expectedType];

  if (!config) {
    console.warn(`未知的檔案類型: ${expectedType}`);
    return false;
  }

  const maxLength = Math.max(
    ...config.signatures.map(({ bytes, offset }) => offset + bytes.length)
  );

  // 只讀取需要的部分
  const buffer = await file.slice(0, maxLength).arrayBuffer();
  const bytes = new Uint8Array(buffer);

  if (bytes.length < maxLength) {
    return false;
  }

  return config.signatures.every(({ bytes: signature, offset }) =>
    signature.every((byte, index) => byte === bytes[offset + index])
  );
}

// 使用範例
async function handleUpload(file) {
  const isValid = await validateFileType(file, file.type);

  if (!isValid) {
    alert("檔案類型不符,請上傳正確的檔案格式");
    return;
  }

  // 繼續上傳流程...
}

六、 為什麼這對 Web 開發很重要?

  1. 初步安全檢查 (Security):比副檔名更可靠地篩掉假冒檔案;真正的安全驗證仍需要在後端做完整格式解析、權限控管與掃描
  2. 修復損壞的檔案:有些下載下來的 PDF 或圖片打不開,可能是因為伺服器傳輸時多塞了一些空白字元在檔頭。你可以用這個方法把多餘的 Byte 切掉
  3. 解析自定義格式:讀取二進位檔案的結構,例如解析音訊檔案的 metadata

前端與後端該各自負責什麼?

位置適合做的事不該承擔的事
前端即時提示、減少錯誤上傳、節省頻寬判定檔案絕對安全
後端重新驗證 MIME / Magic Numbers、限制大小、掃描檔案、隔離儲存完全相信前端傳來的結果

前端驗證可以讓使用者早點知道「你選錯檔了」,但任何安全決策都要在後端再做一次。


七、 進階:使用 DataView 讀取多 byte 數值

當你需要讀取一個跨越多個 bytes 的數值(例如一個 32-bit 整數),就需要考慮 位元組序 (Endianness)

  • Big Endian:高位 byte 在前(網路傳輸常用)
  • Little Endian:低位 byte 在前(x86 處理器常用)
javascript
async function readFileHeader(file) {
  const buffer = await file.slice(0, 16).arrayBuffer();
  const view = new DataView(buffer);

  // 讀取一個 32-bit 無符號整數 (4 bytes)
  // 第二個參數 true = little endian, false = big endian
  const value = view.getUint32(0, false); // Big Endian

  console.log("檔頭數值:", value.toString(16));
}

總結

概念說明
ArrayBuffer固定大小的記憶體空間(手術台)
Uint8Array用 0-255 視角操作記憶體(手術刀)
Magic Numbers檔案開頭的格式特徵,用來初步辨識檔案類型
DataView可指定 endianness 讀取多 byte 數值

透過直接操作這些數字,你就跳脫了 HTML/JS 的高階限制,進入了更底層的「資料處理」領域。

常見誤解

誤解正確理解
Magic Numbers 可以保證檔案安全它只能初步判斷格式特徵,不能取代後端安全檢查
file.type 一定可信它來自瀏覽器或系統判斷,仍可能不準
讀檔頭一定要讀整個檔案file.slice() 讀需要的 bytes 即可
Uint8Array 適合讀所有多 byte 數字需要處理 endian 時用 DataView 更合適

練習:自己驗證檔案指紋

練習 1:假 PNG 能通過嗎?

先猜 isPng 會是 true 還是 false

javascript
const fakePng = new Blob(["not really png"], { type: "image/png" });
const bytes = new Uint8Array(await fakePng.slice(0, 8).arrayBuffer());
const pngSignature = [137, 80, 78, 71, 13, 10, 26, 10];

const isPng = pngSignature.every((byte, index) => byte === bytes[index]);
console.log([...bytes]);
console.log(isPng);
看答案

結果會是 falsefakePng.type 宣稱自己是 image/png,但內容開頭不是 PNG 的 Magic Numbers。這能驗證 file.type 和副檔名都不能單獨信任。

練習 2:觀察 endian 差異

javascript
const buffer = new ArrayBuffer(4);
const bytes = new Uint8Array(buffer);
bytes.set([0x00, 0x00, 0x01, 0x00]);

const view = new DataView(buffer);
console.log(view.getUint32(0, false)); // Big Endian
console.log(view.getUint32(0, true));  // Little Endian

同一組 bytes,用不同位元組序讀取,會得到不同數值。這就是處理二進位格式時不能忽略 endianness 的原因。

自我檢查

  • Magic Numbers 能保證檔案安全嗎?
  • 為什麼 WebP 不能只檢查 RIFF
  • 前端驗證通過後,後端還要再驗證嗎?
  • 什麼時候該用 DataView 而不是 Uint8Array

← 上一章:互轉實戰 | 返回專題首頁 | 下一章:FormData 混合上傳 →