🖼️

ファイルシグネチャついてのメモ

に公開

背景

拡張子を無理やり変更してファイルアップロードをされた際の処理に困り、防ぐ方法を考えていた。その中でファイルシグネチャを知ったので自分のためにメモを残しておく。JavaScriptで判定する方法をChatGPTに教えてもらったのでその裏取りも行う。

ファイルシグネチャとは

ファイルの中身を特定したり判別するもの。マジックナンバーとも言われる。各ファイル形式に存在していてhexadecimal(16進数)で表現されることもある。

※一次情報を読もうとすると「hex〇〇」という文字が結構出てくる。自分は最初何のことか分からず苦戦した(これは自分の勉強不足)。コードを書く際も16進数で表現しておくとあとで見返す時に困らなかったり、可読性の向上に寄与できる。

いくつかファイルシグネチャを見てみる

リストはここにある。
https://en.wikipedia.org/wiki/List_of_file_signatures

Wikipediaをどこまで信じて良いのかわからなかったため一次情報を探してみる。RFCやW3C、有名企業の記事など信頼できそうな情報源へありつくことができた。

PNG

https://datatracker.ietf.org/doc/html/rfc2083

12.11. PNG file signature
The first eight bytes of a PNG file always contain the following
values:
(decimal) 137 80 78 71 13 10 26 10
(hexadecimal) 89 50 4e 47 0d 0a 1a 0a
(ASCII C notation) \211 P N G \r \n \032 \n

JPEG

https://datatracker.ietf.org/doc/html/rfc2435
https://docs.oracle.com/javase/jp/7/api/javax/imageio/metadata/doc-files/jpeg_metadata.html

JPEG メタデータは、JPEG ストリーム内のマーカーセグメントに含まれるデータで構成されます。読み込みで返されるイメージメタデータオブジェクトには、そのイメージの SOI マーカーと EOI マーカー間にあるマーカーセグメントの内容が記述されます。

int MakeHeaders(u_char *p, int type, int q, int w, int h)
{
        u_char *start = p;
        u_char lqt[64];
        u_char cqt[64];

        /* convert from blocks to pixels */
        w <<= 3;
        h <<= 3;

        MakeTables(q, lqt, cqt);

        *p++ = 0xff;
        *p++ = 0xd8;            /* SOI */

はっきりと書いてあったわけではないがいろんな先人の記事や信頼できそうな記事からSOI(JPEGを表すマーカーの存在とそのマーカーを作ってそうなコードを発見)

※間違っていたらご指摘ください。

GIF

https://www.w3.org/Graphics/GIF/spec-gif87.txt

Specification
GIF SIGNATURE
The following GIF Signature identifies the data following as a
valid GIF image stream. It consists of the following six characters:
G I F 8 7 a

GIF89aの場合は 7 が 9 に変わります。

WebP

https://developers.google.com/speed/webp/docs/riff_container?hl=ja

0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 'R' | 'I' | 'F' | 'F' |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| File Size |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 'W' | 'E' | 'B' | 'P' |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

最初が ASCII 文字'RIFF' で最後が ASCII 文字'WEBP' で終わる。

コードで理解してみる

アップロードされたファイルを読み取って判別しています。アップロードされたファイルはReactであれば ChangeEvent<HTMLInputElement> で取得する。

// 一部抜粋(簡易的に書いてます)
const Image = async (e: ChangeEvent<HTMLInputElement>) => {
    setError(null)
    const imageFile = e.target.files?.[0]
    const fileHeader = await readFileHeader(imageFile)
    const validateFileSignatureError = validateFileSignature(fileHeader)
    // 判定する
    if (validateFileSignatureError) {
        // 判定後処理1
    }
    // 判定後処理2
    ・
    ・
    ・
    ・
}
export const checkFileSignature = (uint8Array: Uint8Array): string | undefined => {
    /**
     * シグネチャは16進数表記されることが多いので比較は16進数で実施
     * 0x〇〇 ← 16進数表記
     */
    const isPNG =
        uint8Array.length >= 8 &&
        uint8Array[0] === 0x89 &&
        uint8Array[1] === 0x50 &&
        uint8Array[2] === 0x4e &&
        uint8Array[3] === 0x47 &&
        uint8Array[4] === 0x0d &&
        uint8Array[5] === 0x0a &&
        uint8Array[6] === 0x1a &&
        uint8Array[7] === 0x0a // PNG

    const isJPEG = uint8Array.length >= 3 && uint8Array[0] === 0xff && uint8Array[1] === 0xd8 && uint8Array[2] === 0xff // JPEG/JPG

    const isGIF =
        uint8Array.length >= 6 &&
        uint8Array[0] === 0x47 &&
        uint8Array[1] === 0x49 &&
        uint8Array[2] === 0x46 &&
        uint8Array[3] === 0x38 &&
        (uint8Array[4] === 0x37 || uint8Array[4] === 0x39) &&
        uint8Array[5] === 0x61 // GIF(87a or 89a)

    const isValidFormat = isPNG || isJPEG || isGIF
    return isValidFormat ? undefined : 'PNG, JPEG, GIF形式ではない。'
}

/**
 * ひとまずファイルの先頭8バイトを読み取る
 */
export const readFileHeader = async (file: File): Promise<Uint8Array> => {
    const byteLength = 8
    const reader = new FileReader()
    const fileHeader = await new Promise<ArrayBuffer>((resolve, reject) => {
        reader.onload = (e) => {
            if (e.target?.result) {
                resolve(e.target.result as ArrayBuffer)
            } else {
                reject(new Error('ファイルの読み取りに失敗'))
            }
        }
        reader.onerror = reject
        reader.readAsArrayBuffer(file.slice(0, byteLength))
    })
    return new Uint8Array(fileHeader)
}

コード書いてて調べたオブジェクトや関数

MDNに全部載ってました。
https://developer.mozilla.org/ja/docs/Web/API/FileReader

https://developer.mozilla.org/ja/docs/Web/API/Blob

https://developer.mozilla.org/ja/docs/Web/API/FileReader/readAsArrayBuffer

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array

1.ファイルをBlobで読み取りバイナリバッファー → 2.配列変換 → 3.配列の中身(ファイルのマジックナンバー)を検証する

こんな流れ。

まとめ

ファイル処理、普通は仕様まで意識することなんてないと思います。自分はそうでした。
今回を機に「RFCをきちんと読むことの大事さ」、「すべて仕様があり最終的にはコードへ行きつく」ということを学べた。

文字コードも気になってます。時間作って読んでみよう。
https://gihyo.jp/book/2019/978-4-297-10291-3

Discussion