二進位外科手術 — 直接操作 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),而是直接改記憶體裡的數字:
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)
驗證程式碼
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) | 位置 |
|---|---|---|---|
| PNG | 89 50 4E 47 0D 0A 1A 0A | [137, 80, 78, 71, 13, 10, 26, 10] | 前 8 bytes |
| JPEG | FF D8 FF | [255, 216, 255] | 前 3 bytes |
| GIF | 47 49 46 38 | [71, 73, 70, 56] | 前 4 bytes (GIF8) |
25 50 44 46 | [37, 80, 68, 70] | 前 4 bytes (%PDF) | |
| ZIP | 50 4B 03 04 | [80, 75, 3, 4] | 前 4 bytes (PK) |
| WebP | 52 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 是「格式特徵」,不是安全保證。有些格式可能有多種合法簽名,有些檔案也可能被刻意偽造檔頭。前端可以先擋掉明顯錯誤,後端仍要重新驗證。
五、 封裝通用驗證器
以下是一個可以直接用在專案中的檔案驗證器:
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 開發很重要?
- 初步安全檢查 (Security):比副檔名更可靠地篩掉假冒檔案;真正的安全驗證仍需要在後端做完整格式解析、權限控管與掃描
- 修復損壞的檔案:有些下載下來的 PDF 或圖片打不開,可能是因為伺服器傳輸時多塞了一些空白字元在檔頭。你可以用這個方法把多餘的 Byte 切掉
- 解析自定義格式:讀取二進位檔案的結構,例如解析音訊檔案的 metadata
前端與後端該各自負責什麼?
| 位置 | 適合做的事 | 不該承擔的事 |
|---|---|---|
| 前端 | 即時提示、減少錯誤上傳、節省頻寬 | 判定檔案絕對安全 |
| 後端 | 重新驗證 MIME / Magic Numbers、限制大小、掃描檔案、隔離儲存 | 完全相信前端傳來的結果 |
前端驗證可以讓使用者早點知道「你選錯檔了」,但任何安全決策都要在後端再做一次。
七、 進階:使用 DataView 讀取多 byte 數值
當你需要讀取一個跨越多個 bytes 的數值(例如一個 32-bit 整數),就需要考慮 位元組序 (Endianness):
- Big Endian:高位 byte 在前(網路傳輸常用)
- Little Endian:低位 byte 在前(x86 處理器常用)
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:
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);看答案
結果會是 false。fakePng.type 宣稱自己是 image/png,但內容開頭不是 PNG 的 Magic Numbers。這能驗證 file.type 和副檔名都不能單獨信任。
練習 2:觀察 endian 差異
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?