從檔案內容判斷是不是圖片檔

GolangNode.js程式語言計算機概論雜記

最近在工作上需要寫一隻圖片上傳的API,好讓使用者上傳證明用的圖片供後續人工審核用。身為一隻稱職的圖片上傳API,當收到使用者發過來的請求後第一步肯定要做參數檢查,驗證使用者上傳的檔案是不是真的是圖片檔,並將亂上傳的請求拒於門外。

但是今天使用者傳來的是檔案其實對API來說都是binary的形式,要怎麼檢驗它真的是圖片檔呢?如果用最直覺判斷副檔名的方式的話有可能會遇到使用者故意改副檔名,然後惡意上傳其它非影像的檔案,所以用副檔名擋肯定是不夠的。在survey了一下後,發現其實還有一招是可以從檔案內容中的magic number下手來判斷。

Magic Number

不知道大家有沒有試過故意把圖片的副檔名亂改改掉,然後再使用圖片預覽器打開圖片,圖片預覽器依然可以正確顯示圖片的這個現象感到好奇。

其實圖片預覽器在打開圖片時,也是用magic number來判斷這個檔案是哪個類型的圖片(例如是jpg、gif、還是png),之後再根據對應類型的圖片格式解析圖片。所以magic number具體來說是什麼呢?就是每種類型的檔案即便檔案內容不一樣,但是都會故意在特定位置留下相同的標記,而這個相同的標記就是俗稱的magic number。換句話說,相同檔案類型的檔案會有相同的magic number。

舉例來說截至2023年3月此刻,所有的jpg檔案內容的前3個byte都會是0xFF 0xD8 0xFF,這個0xFF 0xD8 0xFF就是jpg檔案類型的magic number。

真的嗎這們神奇嗎,我們來實際驗證一下。隨便找一張電腦裡面的jpg圖片,然後在terminal下:

xxd ./pic01.jpg | head

xxd指令會把檔案以binary的形式dump出,再透過head指令查看前幾行我們要的資訊。

00000000: ffd8 ffe1 141f 4578 6966 0000 4d4d 002a  ......Exif..MM.*
00000010: 0000 0008 000c 0100 0003 0000 0001 0500  ................
00000020: 0000 0101 0003 0000 0001 02d0 0000 0102  ................
00000030: 0003 0000 0003 0000 009e 0106 0003 0000  ................
00000040: 0001 0002 0000 0112 0003 0000 0001 0001  ................
00000050: 0000 0115 0003 0000 0001 0003 0000 011a  ................
00000060: 0005 0000 0001 0000 00a4 011b 0005 0000  ................
00000070: 0001 0000 00ac 0128 0003 0000 0001 0002  .......(........
00000080: 0000 0131 0002 0000 001f 0000 00b4 0132  ...1...........2
00000090: 0002 0000 0014 0000 00d3 8769 0004 0000  ...........i....

真的耶~可以看到pic01.jpg檔案的第1、2、3個byte還真的是0xFF 0xD8 0xFF。除了jpg外,其它常見的圖片格式例如gif是0x47 0x49 0x46,png的話magic number就比較長些,分別是在檔案內容中的第1至第8個byte0x89 0x50 0x4E 0x47 0x0D 0x0A 0x1A 0x0A。還有其它各式各樣檔案類型的magic number可以到Wiki – List of file signatures這邊逛逛。

有了這項法寶,我們的後端API也可以仿照圖片預覽器判別圖片檔案類型的方式透過magic number識別出真正的類型,再把亂上傳檔案的請求擋掉,藉此減少server被濫上傳無效檔案消耗硬碟空間的機會。

實作

工作上Node.js及Golang是併行使用的,所以下面我會使用這兩種語言來實作判斷。只要呼叫isImageContent(theUploadedFile)就可以見真章上傳的檔案到底是不是圖片檔。

Node.js

在Node.js中,通常會以Buffer的形式拿到上傳的檔案的物件。如果有去翻一下Node.js有關Buffer的文件,可以發現Buffer骨子裡就是Uint8Array。因此,我們可以直接以index的形式取得檔案內容中特定位置的值出來比較。

/**
 * 判斷內容是否為圖片
 *
 * @param {Buffer} arrayLike 任何可以使用.length及index操作的物件
 * @returns {boolean} 是否為圖片
 */
function isImageContent(file) {
    const checkIsImageContent = [isJPG, isGIF, isPNG];
    if (checkIsImageContent.some((isImage) => isImage(file))) {
        return true;
    }

    return false;
}

/**
 * 判斷內容是否為JPG
 *
 * @param {Buffer} arrayLike 任何可以使用.length及index操作的物件
 * @returns {boolean} 是否是JPG的內容
 */
function isJPG(arrayLike) {
    if (!arrayLike || arrayLike.length < 3) {
        return false;
    }

    return arrayLike[0] === 0xFF
        && arrayLike[1] === 0xD8
        && arrayLike[2] === 0xFF;
}

/**
 * 判斷內容是否為GIF
 *
 * @param {Buffer} arrayLike 任何可以使用.length及index操作的物件
 * @returns {boolean} 是否是GIF的內容
 */
function isGIF(arrayLike) {
    if (!arrayLike || arrayLike.length < 3) {
        return false;
    }
 
    return arrayLike[0] === 0x47
        && arrayLike[1] === 0x49
        && arrayLike[2] === 0x46;
}

/**
 * 判斷內容是否為PNG
 *
 * @param {Buffer} arrayLike 任何可以使用.length及index操作的物件
 * @returns {boolean} 是否是PNG的內容
 */
function isPNG(arrayLike) {
    if (!arrayLike || arrayLike.length < 8) {
        return false;
    }

    return arrayLike[0] === 0x89
        && arrayLike[1] === 0x50
        && arrayLike[2] === 0x4E
        && arrayLike[3] === 0x47
        && arrayLike[4] === 0x0D
        && arrayLike[5] === 0x0A
        && arrayLike[6] === 0x1A
        && arrayLike[7] === 0x0A;
}

Golang

Golang的話拿到的檔案物件型態會是[]byte,加上邏輯也是大同小異所以就直接看code唄。

// 判斷內容是否為圖片
func isImageContent(img []byte) bool {
    checkIsImageContent := []func(img []byte) bool{
        isJPG, isGIF, isPNG,
    }
    for _, isImage := range checkIsImageContent {
        if isImage(img) {
            return true
        }
    }

    return false
}

// 判斷內容是否為JPG
func isJPG(img []byte) bool {
    if len(img) < 3 {
        return false
    }

    return img[0] == 0xFF &&
        img[1] == 0xD8 &&
        img[2] == 0xFF
}

// 判斷內容是否為GIF
func isGIF(img []byte) bool {
    if len(img) < 3 {
        return false
    }

    return img[0] == 0x47 &&
        img[1] == 0x49 &&
        img[2] == 0x46
}

// 判斷內容是否為PNG
func isPNG(img []byte) bool {
    if len(img) < 8 {
        return false
    }

    return img[0] == 0x89 &&
        img[1] == 0x50 &&
        img[2] == 0x4E &&
        img[3] == 0x47 &&
        img[4] == 0x0D &&
        img[5] == 0x0A &&
        img[6] == 0x1A &&
        img[7] == 0x0A
}

總結

用magic number來判斷上傳的檔案是哪個類型只能算是第二層防護(第一層是判斷副檔名),阻擋內容根本不是影像的檔案被保存至server上,造成硬碟空間浪費(空間就是錢呀)。實際上如果有有心人士上傳損毀的圖片檔,那server還是會照單全收。至於如果真的不幸收到了損毀的圖片檔,以目前的業務邏輯來說就會交由下一關人工審核給reject掉,畢盡這隻API是讓使用者上傳類似KYC圖檔供後續人工審核用的。

One Comment

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *