ホンモノのエンジニアになりたい

ITやビジネス、テクノロジーの話を中心とした雑記ブログです。

JavascriptでPNGファイルにtEXtチャンクを差し込むサンプルコード

ある日ふと、こんな思いに駆られました。

ピングにチャンクをぶち込みたいっ!!
JSでっ!!

言葉通りの意味です。それ以外の意味はありません。

 

PNGファイルにtEXtチャンクをJavascriptで挿入するという趣旨のエントリです。勘違い無きように宜しくお願いします。

 

 

背景

なんでそんなことをしようと思ったのか。一つはCTFでたまに出題されるPNG問題への理解を深めたかったから。まぁお勉強の一環ですな。

 

もう1つは、これが出来るとブログ上で何か面白いことが出来んじゃないかと前々から考えていたことです。このサイトは、はてなブログで書いているんですが、アップロードできるファイルって精々画像ファイルくらいなものなんですよ。色んな情報を画像に紛れ込ませてアップできたら面白そうだなぁと、そんなことを考えておりました。

  

ちなみに私はサーバ屋さんなので、「その書き方はSexyじゃない」とかそういうご指摘は基本心の中で唱えていただければと思います。コメントは歓迎ですが、私が傷つかないように配慮をお願いします。笑

 

前提

PNGの仕様

PNGはいくつかの必須チャンクと、その他任意で配置できる補助チャンクで構成されるファイルになっています。分かりやすい説明をしてくれているサイトが山ほどあるので、詳細はWebで。ググってみてください。私が参照させていただいたサイトをいくつか列挙します。

Portable Network Graphics - Wikipedia
PNG ファイルフォーマット | www.setsuki.com
MODULE.JP - PNGとGIFとJPEGにコメントを埋め込む

 

このエントリで企てているtEXtチャンクの説明だけ軽~くやっておきます。tEXtチャンクは以下の要素を含むバイト配列となっています。

Byte数 名称 説明
4 Length Dataの長さ
4 Type ASCIIコードでtEXt
N(<80) Data Keyword
1   Separator(0x00の決め打ちです)
N(<)   Data(Lengthが溢れないサイズまで)
4 CRC TypeとDataの情報から計算されるチェック値

 

Keywordはいくつか一般的に使われるものが決まっていて、CopyrightとかCommentとか、こういう文字列を使うことができます。詳細はこちら。PNGのチャンク(tEXT)

自分で名付けたキーワードを使っても大丈夫です。

 

Dataは表中に書いた通り、Lengthが溢れないサイズまで1つのチャンクに書き込むことが可能です。Lengthは4Byteなので、0xFFFFFFFF=4,294,967,295 Byte までいける(はず)。 普通にテキスト情報を載せるだけなら最大値は気にする必要がない感じですね。

 

このエントリで使う画像

このエントリでは、RGB(255,0,0)で塗りつぶした5×5ピクセルの小さい画像にtEXtチャンクを埋め込んでいきます。その画像は↓の赤点なんですが、

f:id:kwnflog:20191222212145p:plain

はてなブログにアップロードすると、ファイルの生成日や変更日といった情報が自動的に付加されてしまうので、アップロード前の16進数表記のバイナリを掲載しておきます。

 

f:id:kwnflog:20191222212855p:plain

 

後述するコード中にベタ書きで出てきますので、同じ事をやってみようと思う方は、コードを丸コピーしてみてください。

 

とりあえずバイナリ(16進数)からファイルを生成するサンプル

とりあえず動作して目に見える結果が得られる小さいサンプルを作る、というのが本エントリの隠された目的です。コピペしてファイル保存、それをブラウザで開いて、ポチッとボタンを押せば、ズドーンとファイルが落ちてきます。 

 

このサンプルでは、バイナリエディタで開いた16進数表記の文字列をコピペしてarray配列に突っ込んでいます。で、array配列のサイズ分(ここでは131Byte)だけ確保したBufferに、DataViewを通して1Byteずつ入れていく。最後にBlobに変換してダウンロードさせています。

バイナリエディタの内容をコード中にベタ書きして、それをJavascriptでファイルに再構成しているだけです。何もやっていないです。

 

このエントリではバイナリエディタからコピペした内容を使っていますので、arrayの中身を0xffの16進数の形で定義しています。これは10進数0-255の表現でもDataViewを通してセット可能です。

 

<body>
  <input type="button" value="Download" onclick="InsText();">
  <script>
    function InsText(){

      // PNG画像の16進数定義 (RGB:255,0,0),(5x5)
      // バイナリエディタからコピペしてちょろっと加工したデータです
      array=[
          0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A,0x00,0x00,0x00,0x0D,0x49,0x48,0x44,0x52,
          0x00,0x00,0x00,0x05,0x00,0x00,0x00,0x05,0x08,0x06,0x00,0x00,0x00,0x8D,0x6F,0x26,
          0xE5,0x00,0x00,0x00,0x01,0x73,0x52,0x47,0x42,0x00,0xAE,0xCE,0x1C,0xE9,0x00,0x00,
          0x00,0x04,0x67,0x41,0x4D,0x41,0x00,0x00,0xB1,0x8F,0x0B,0xFC,0x61,0x05,0x00,0x00,
          0x00,0x09,0x70,0x48,0x59,0x73,0x00,0x00,0x12,0x74,0x00,0x00,0x12,0x74,0x01,0xDE,
          0x66,0x1F,0x78,0x00,0x00,0x00,0x18,0x49,0x44,0x41,0x54,0x18,0x57,0x63,0x7C,0x2B,
          0xA3,0xF2,0x9F,0x01,0x0D,0x30,0x41,0x69,0x14,0x40,0x91,0x20,0x03,0x03,0x00,0xE0,
          0xF5,0x02,0x36,0x3B,0xA7,0x34,0x2A,0x00,0x00,0x00,0x00,0x49,0x45,0x4E,0x44,0xAE,
          0x42,0x60,0x82]


      // bufferをファイルの長さで指定して生成。DataViewにセット。
      // このサンプルではarrayの長さ分で131Byteを確保している。
      let buffer = new ArrayBuffer(array.length);
      let dv = new DataView(buffer);

      // arrayを回して、DataViewにバイト情報を流し込む
      // iは0から始まるオフセット値
      for (var i=0;i<array.length;++i){
        dv.setUint8(i, array[i]);
      }

      //// 作りこんだBufferをファイルにしてダウンロードする
      //まず見えないエレメントを生成
      let el = document.createElement("a");
      document.body.appendChild(el);
      el.style = "display: none";

      //DataViewで弄ったbufferをblobに変換し、URLを生成
      let blob = new Blob([buffer], {type: "octet/stream"}),
      url = window.URL.createObjectURL(blob);

      //URLをエレメントに嵌めてクリックさせることでダウンロードが開始される
el.href = url; el.download = "sample1.png"; //ダウンロードファイルのファイル名 el.click(); window.URL.revokeObjectURL(url); } </script> </body>

 

 

バイナリファイルを構成してダウンロードするところは以下のサイトのコードを参照させていただきました。

JavaScriptでバイナリーデータを作ってファイルを保存する(ArrayBuffer, Blob, DataView – Urusu Lambda Web

 

入力したテキストをtEXtチャンクにブッ込むサンプル

textareaで指定した内容をtEXtチャンクのキーワード:Commentとして書き込むサンプルです。

 

このサンプルコードではtEXtチャンクを差し込む位置を0x21としてベタ書きしています。PNGファイルのマジックナンバーと先頭のIHDRチャンクはこのサイズで固定なので、IHDRの直後にtEXtチャンクを差し込むサンプルになっています。ここを弄れば内部構造を破壊しない限り好きなところに差し込めます。

 

やっていることは、まず元ファイルのバイナリ情報を持つarray配列を定義。tEXtチャンクの情報をAddListとして配列で作りこむ。array配列の0x21番目のところにAddListを挿入しています。そして最後にAddListを差し込んだarrayをBufferに流し込んでファイルダウンロード。

 

<body>
  <textarea id="mytext" rows="4" cols="40"></textarea>
  <input type="button" value="Download" onclick="InsText2();">
  <script>
    function InsText2(){
      // ここまでと同じ5x5の画像データバイナリ
      array=[
        0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A,0x00,0x00,0x00,0x0D,0x49,0x48,0x44,0x52,
        0x00,0x00,0x00,0x05,0x00,0x00,0x00,0x05,0x08,0x06,0x00,0x00,0x00,0x8D,0x6F,0x26,
        0xE5,0x00,0x00,0x00,0x01,0x73,0x52,0x47,0x42,0x00,0xAE,0xCE,0x1C,0xE9,0x00,0x00,
        0x00,0x04,0x67,0x41,0x4D,0x41,0x00,0x00,0xB1,0x8F,0x0B,0xFC,0x61,0x05,0x00,0x00,
        0x00,0x09,0x70,0x48,0x59,0x73,0x00,0x00,0x12,0x74,0x00,0x00,0x12,0x74,0x01,0xDE,
        0x66,0x1F,0x78,0x00,0x00,0x00,0x18,0x49,0x44,0x41,0x54,0x18,0x57,0x63,0x7C,0x2B,
        0xA3,0xF2,0x9F,0x01,0x0D,0x30,0x41,0x69,0x14,0x40,0x91,0x20,0x03,0x03,0x00,0xE0,
        0xF5,0x02,0x36,0x3B,0xA7,0x34,0x2A,0x00,0x00,0x00,0x00,0x49,0x45,0x4E,0x44,0xAE,
        0x42,0x60,0x82]

      //挿入情報の定義
      let Type    = "tEXt";
      let Kword   = "Comment"
      let Data    = document.getElementById('mytext').value;
      let AddPosition  = 0x21;
      let AddList = [];

      //挿入情報のバイト数を定義
      let LenSize   = 4;
      let TypeSize  = Type.length;
      let KwordSize = Kword.length;
      let SeprSize  = 1;
      let DataSize  = Data.length;
      let CrCSize   = 4;

      let AddSize   = LenSize + TypeSize + KwordSize + SeprSize + DataSize + CrCSize;

      //Bufferの定義:元ファイル+挿入分のバイト数でbufferを確保してDataViewにセット
      let buffer = new ArrayBuffer(array.length + AddSize);
      let dv     = new DataView(buffer);


      ////挿入データの作成

      //Length情報を計算
      //桁数調整のために16進数化してパディング。下8桁をとっています
let DataChunkSizeHEX = (KwordSize + SeprSize + DataSize).toString(16); let DataChunkSize = ('00000000'+DataChunkSizeHEX).slice(-8); for (var i=0;i<LenSize;++i) { AddList.push(parseInt(DataChunkSize.substr(i*2,2),16)); } console.log("Length:",AddList); //tEXt,KeywordをAddListに挿入する let TypeKey16 = hex(Type + Kword); //74455874436f6d6d656e74 for (var i=0;i<TypeSize+KwordSize;++i) { AddList.push(parseInt(TypeKey16.substr(i*2,2),16)); } //Separatorを挿入する AddList.push(0); console.log("Length+Type+Keyword+Separator:",AddList); //textareaから取得したDataを挿入する let Data16 = hex(Data); //hex()は下の方に書いています。 for (var i=0;i<DataSize;++i) { AddList.push(parseInt(Data16.substr(i*2,2),16)); } console.log("Length+Type+Keyword+Separator+Data:",AddList); //CRC32を計算する
 //CRC計算領域を16進数で連結(ASCIIだとSeparatorの"00"が表現できなかったため) let CrcData16 = hex(Type+Kword) + "00" + hex(Data); let CrcDataText = ""; for (i=0;i<TypeSize+KwordSize+SeprSize+DataSize;++i){ CrcDataText += String.fromCharCode(parseInt(CrcData16.substr(i*2,2),16)); } let CRC = crc32(CrcDataText).toString(16); let CrcAdj = ('00000000'+CRC).slice(-8) for (var i=0;i<CrCSize;++i) { //> AddList.push(parseInt(CrcAdj.substr(i*2,2),16)); } console.log("AddList完成形:",AddList); //// AddList配列をarray配列のAddPositionバイト目に挿入する Array.prototype.splice.apply(array,[AddPosition,0].concat(AddList)); //// AddListを加えたarray配列をDataViewに流し込む for (var i=0;i<array.length;++i) { //> dv.setUint8(i, array[i]); } //// 作りこんだBufferをファイルにしてダウンロードする //見えないエレメントを生成 let el = document.createElement("a"); document.body.appendChild(el); el.style = "display: none"; //DataViewで弄ったbufferをblobに変換し、URLを生成 let blob = new Blob([buffer], {type: "octet/stream"}), url = window.URL.createObjectURL(blob); //URLをエレメントに嵌めてクリックさせる el.href = url; el.download = "sample1.png"; el.click(); window.URL.revokeObjectURL(url); } //// str→16進数に変換する関数 function hex(s) { let result=""; for(var i=0;i<s.length;++i){ //charCodeAtでAsciiにして、toStringで16進数に、substrで2桁を指定 let h = ("0"+s.charCodeAt(i).toString(16)).substr(-2); result += h; } return result; } //// CRC32の計算用関数 //// 参照元:http://crc32.nichabi.com/javascript-function.php var crc32 = (function () { var table = [] for (var i = 0; i < 256; i++) { var c = i for (var j = 0; j < 8; j++) { c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1) } table.push(c) } return function (str, crc) { str = unescape(encodeURIComponent(str)) if (!crc) crc = 0 crc = crc ^ (-1) for (var i = 0; i < str.length; i++) { var y = (crc ^ str.charCodeAt(i)) & 0xff crc = (crc >>> 8) ^ table[y] } crc = crc ^ (-1) return crc >>> 0 } })() </script> </body>

 

Offset用のカウンタで随時Bufferに情報を差し込むサンプル

Offset用のカウンタを作って随時Bufferにバイト情報を差し込んでいくサンプルです。動作は前項のサンプルと同じで、textareaに書いた情報をIHDRチャンクの直後に(tEXtチャンク:キーワードComment)で挿入します。 

 

前方からファイルを舐めていって、Bufferに挿入したサイズだけOffsetカウンタをずらしていきます。

 

<body>
  <textarea id="mytext2" rows="4" cols="40"></textarea>
  <input type="button" value="Download" onclick="InsText3();">
  <script>
    function InsText3(){
      array=[
        0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A,0x00,0x00,0x00,0x0D,0x49,0x48,0x44,0x52,
        0x00,0x00,0x00,0x05,0x00,0x00,0x00,0x05,0x08,0x06,0x00,0x00,0x00,0x8D,0x6F,0x26,
        0xE5,0x00,0x00,0x00,0x01,0x73,0x52,0x47,0x42,0x00,0xAE,0xCE,0x1C,0xE9,0x00,0x00,
        0x00,0x04,0x67,0x41,0x4D,0x41,0x00,0x00,0xB1,0x8F,0x0B,0xFC,0x61,0x05,0x00,0x00,
        0x00,0x09,0x70,0x48,0x59,0x73,0x00,0x00,0x12,0x74,0x00,0x00,0x12,0x74,0x01,0xDE,
        0x66,0x1F,0x78,0x00,0x00,0x00,0x18,0x49,0x44,0x41,0x54,0x18,0x57,0x63,0x7C,0x2B,
        0xA3,0xF2,0x9F,0x01,0x0D,0x30,0x41,0x69,0x14,0x40,0x91,0x20,0x03,0x03,0x00,0xE0,
        0xF5,0x02,0x36,0x3B,0xA7,0x34,0x2A,0x00,0x00,0x00,0x00,0x49,0x45,0x4E,0x44,0xAE,
        0x42,0x60,0x82]

      //挿入情報の定義
      let Type   = "tEXt";
      let Kword  = "Comment"
      let Data   = document.getElementById('mytext2').value;

let OriginFileSize = array.length;
let AddPosition = 0x21 // = 34; 元ファイルの34Byte目に差し込む let Offset = 0; //挿入情報のバイト数を定義 let LenSize = 4; let TypeSize = Type.length; let KwordSize = Kword.length; let SeprSize = 1; let DataSize = Data.length; let CrCSize = 4 let AddSize = LenSize + TypeSize + KwordSize + SeprSize + DataSize + CrCSize; //Bufferの定義:元ファイル+挿入分のバイト数で領域確保 let buffer = new ArrayBuffer(OriginFileSize + AddSize); let dv = new DataView(buffer); //ファイル先頭~IHDRチャンクまでをBufferに入れる
for (var i=0;i<AddPosition;++i){ dv.setUint8(i, array[i]); }
//Bufferに書き込んだ分だけOffset値を加算
Offset += AddPosition; console.log("IHDRまで挿入した時のOffset値",Offset) //Length情報を計算 let DataChunkSizeHEX = (KwordSize + SeprSize + DataSize).toString(16); let DataChunkSize = ('00000000'+DataChunkSizeHEX).slice(-8) for (var i=0;i<LenSize;++i) { dv.setUint8(i+Offset, parseInt(DataChunkSize.substr(i*2,2),16)); } Offset += LenSize; console.log("Length挿入後のoffset",Offset) //tEXt,KeywordをAddListに挿入する let TypeKey16 = hex(Type + Kword); //74455874436f6d6d656e74 for (var i=0;i<TypeSize+KwordSize;++i) { dv.setUint8(i+Offset, parseInt(TypeKey16.substr(i*2,2),16)); } Offset += TypeSize + KwordSize; console.log("Type&Keyword挿入後のoffset",Offset) //Separatorを挿入する dv.setUint8(Offset, 0); Offset += SeprSize; console.log("Separator挿入後のoffset",Offset) //textareaから取得したDataを挿入する let Data16 = hex(Data); for (var i=0;i<DataSize;++i) { dv.setUint8(i+Offset, parseInt(Data16.substr(i*2,2),16)); } Offset += DataSize; console.log("Data挿入後のoffset",Offset) //CRC32を計算する let CrcData16 = hex(Type+Kword) + "00" + hex(Data); let CrcDataText = ""; //UTF-16に変換して保持する変数 for (i=0;i<TypeSize+KwordSize+SeprSize+DataSize;++i){ CrcDataText += String.fromCharCode(parseInt(CrcData16.substr(i*2,2),16)); } let CRC = crc32(CrcDataText).toString(16); //16進数にして let CrcAdj = ('00000000'+CRC).slice(-8); //桁調整 for (var i=0;i<CrCSize;++i) { dv.setUint8(i+Offset, parseInt(CrcAdj.substr(i*2,2),16)); } Offset += CrCSize; console.log("CRC挿入後のoffset",Offset) //IHDRより後ろの情報を書き込む for (var i=0;i<OriginFileSize-AddPosition;++i){ dv.setUint8(i+Offset, array[i+AddPosition]); }
 Offset += OriginFileSize - AddPosition; console.log("IENDまで挿入した時のOffset値",Offset) //// 作りこんだBufferをファイルにしてダウンロードする //見えないエレメントを生成 let el = document.createElement("a"); document.body.appendChild(el); el.style = "display: none"; //DataViewで弄ったbufferをblobに変換し、URLを生成 let blob = new Blob([buffer], {type: "octet/stream"}), url = window.URL.createObjectURL(blob); //URLをエレメントに嵌めてクリックさせる el.href = url; el.download = "sample1.png"; el.click(); window.URL.revokeObjectURL(url); }
 //strをASCIIの16進数2桁str型に変換する関数
function hex(s) { let result=""; for(var i=0;i<s.length;++i){ //charCodeAtでAsciiにして、toStringで16進数に、substrで2桁を指定 let h = ("0"+s.charCodeAt(i).toString(16)).substr(-2); result += h; } return result; } //// CRC32の計算用関数 //// http://crc32.nichabi.com/javascript-function.php var crc32 = (function () { var table = [] for (var i = 0; i < 256; i++) { var c = i for (var j = 0; j < 8; j++) { c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1) } table.push(c) } return function (str, crc) { str = unescape(encodeURIComponent(str)) if (!crc) crc = 0 crc = crc ^ (-1) for (var i = 0; i < str.length; i++) { var y = (crc ^ str.charCodeAt(i)) & 0xff crc = (crc >>> 8) ^ table[y] } crc = crc ^ (-1) return crc >>> 0 } })() </script> </body>

 

変換系の命令を整理する

前項までのコードで使った変換系の命令を整理します。(自分で書いてて何が何だかわからなくなったので‥‥)

 

num.toString(N)

// num.toString(N)
// numをN進数表記の文字列に変換する(numに数字を入れると10進数扱い。0xffと書けば16進数扱い)
var num = 15;
num.toString(2);    // 1111
num.toString(8);    // 17
num.toString(10);   // 15
num.toString(16);   // f

var num = 0xff;
num.toString(2);    // 11111111
num.toString(8);    // 377
num.toString(10);   // 255
num.toString(16);   // ff

 

parseInt(str,int)

// parseInt(str,int)
// int進数で表現されたstrを10進数(num型)にする
var str = "10";
parseInt(str,2);    // 2
parseInt(str,8);    // 8
parseInt(str,10);   // 10
parseInt(str,16);   // 16

 

String.fromCharCode(num)

// String.fromCharCode(num)
// numに指定した数字に対応するUTF-16文字をstrで返す
// 複数のnumを指定すると文字列として連結して返す
// 普通に数字を渡すと10進数扱い、0xFFの形だと16進数扱い

String.fromCharCode(97,0x61,45,122,0x7A); // "aa-zz"

ASCIIコード表:
https://www.k-cube.co.jp/wakaba/server/ascii_code.html

 

str.charCodeAt(index)

// str.charCodeAt(index)
// strのindex文字目のUTF-16コード(10進数,num型)を返す

console.log("abc".charCodeAt(0));        // 97
console.log("abc".charCodeAt(1));        // 98
console.log("abc".charCodeAt(2)+10000);  // 10099

 

hex(str)

上のstr.charCodeAt(index) の桁数を調整して出力してくれる関数です。

こちらのコードを使用させていただきました。
https://gist.github.com/shigemk2/add259a622ff88c998eb

// hex(str)
// strを渡すとUTF-16の16進数2桁(str)で返してくれる関数
// 複数文字を渡すとstr型で連結した値を返す

function hex(s) {
   let result="";
   for(var i=0;i<s.length;++i){
     //charCodeAtでAsciiにして、toStringで16進数に、substrで2桁を指定(0パディング)
     let h = ("0"+s.charCodeAt(i).toString(16)).substr(-2);
     result += h;
   }
   return result;
}

hex("a")             // "61"
hex("ab")            // "6162"
hex("abc")+"aiueo"   // "616263aiueo"

 

 charCodeAtとhex(N)との違いを整理。 

var N = String.fromCharCode(0); // ASCIIコード:0:Null文字

hex(N);          // "00"
N.charCodeAt(0); // 0

// hexはstrを返す関数で、charCodeAtはUTF-16の10進数値を返すので、加算するとこうなる。 hex(N)+100; // "00100" N.charCodeAt(0)+100; // 100

 

おわり