ある日ふと、こんな思いに駆られました。
ピングにチャンクをぶち込みたいっ!!
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チャンクを埋め込んでいきます。その画像は↓の赤点なんですが、

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

後述するコード中にベタ書きで出てきますので、同じ事をやってみようと思う方は、コードを丸コピーしてみてください。
とりあえずバイナリ(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(){
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 buffer = new ArrayBuffer(array.length);
let dv = new DataView(buffer);
for (var i=0;i<array.length;++i){
dv.setUint8(i, array[i]);
}
let el = document.createElement("a");
document.body.appendChild(el);
el.style = "display: none";
let blob = new Blob([buffer], {type: "octet/stream"}),
url = window.URL.createObjectURL(blob);
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(){
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;
let buffer = new ArrayBuffer(array.length + AddSize);
let dv = new DataView(buffer);
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);
let TypeKey16 = hex(Type + Kword);
for (var i=0;i<TypeSize+KwordSize;++i) {
AddList.push(parseInt(TypeKey16.substr(i*2,2),16));
}
AddList.push(0);
console.log("Length+Type+Keyword+Separator:",AddList);
let Data16 = hex(Data);
for (var i=0;i<DataSize;++i) {
AddList.push(parseInt(Data16.substr(i*2,2),16));
}
console.log("Length+Type+Keyword+Separator+Data:",AddList);
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);
Array.prototype.splice.apply(array,[AddPosition,0].concat(AddList));
for (var i=0;i<array.length;++i) {
dv.setUint8(i, array[i]);
}
let el = document.createElement("a");
document.body.appendChild(el);
el.style = "display: none";
let blob = new Blob([buffer], {type: "octet/stream"}),
url = window.URL.createObjectURL(blob);
el.href = url;
el.download = "sample1.png";
el.click();
window.URL.revokeObjectURL(url);
}
function hex(s) {
let result="";
for(var i=0;i<s.length;++i){
let h = ("0"+s.charCodeAt(i).toString(16)).substr(-2);
result += h;
}
return result;
}
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
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;
let buffer = new ArrayBuffer(OriginFileSize + AddSize);
let dv = new DataView(buffer);
for (var i=0;i<AddPosition;++i){
dv.setUint8(i, array[i]);
}
Offset += AddPosition;
console.log("IHDRまで挿入した時のOffset値",Offset)
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)
let TypeKey16 = hex(Type + Kword);
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)
dv.setUint8(Offset, 0);
Offset += SeprSize;
console.log("Separator挿入後のoffset",Offset)
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)
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) {
dv.setUint8(i+Offset, parseInt(CrcAdj.substr(i*2,2),16));
}
Offset += CrCSize;
console.log("CRC挿入後のoffset",Offset)
for (var i=0;i<OriginFileSize-AddPosition;++i){
dv.setUint8(i+Offset, array[i+AddPosition]);
}
Offset += OriginFileSize - AddPosition;
console.log("IENDまで挿入した時のOffset値",Offset)
let el = document.createElement("a");
document.body.appendChild(el);
el.style = "display: none";
let blob = new Blob([buffer], {type: "octet/stream"}),
url = window.URL.createObjectURL(blob);
el.href = url;
el.download = "sample1.png";
el.click();
window.URL.revokeObjectURL(url);
}
function hex(s) {
let result="";
for(var i=0;i<s.length;++i){
let h = ("0"+s.charCodeAt(i).toString(16)).substr(-2);
result += h;
}
return result;
}
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)
var num = 15;
num.toString(2);
num.toString(8);
num.toString(10);
num.toString(16);
var num = 0xff;
num.toString(2);
num.toString(8);
num.toString(10);
num.toString(16);
parseInt(str,int)
var str = "10";
parseInt(str,2);
parseInt(str,8);
parseInt(str,10);
parseInt(str,16);
String.fromCharCode(num)
String.fromCharCode(97,0x61,45,122,0x7A);
ASCIIコード表:
https://www.k-cube.co.jp/wakaba/server/ascii_code.html
str.charCodeAt(index)
console.log("abc".charCodeAt(0));
console.log("abc".charCodeAt(1));
console.log("abc".charCodeAt(2)+10000);
hex(str)
上のstr.charCodeAt(index) の桁数を調整して出力してくれる関数です。
こちらのコードを使用させていただきました。
https://gist.github.com/shigemk2/add259a622ff88c998eb
function hex(s) {
let result="";
for(var i=0;i<s.length;++i){
let h = ("0"+s.charCodeAt(i).toString(16)).substr(-2);
result += h;
}
return result;
}
hex("a")
hex("ab")
hex("abc")+"aiueo"
charCodeAtとhex(N)との違いを整理。
var N = String.fromCharCode(0);
hex(N);
N.charCodeAt(0);
hex(N)+100;
N.charCodeAt(0)+100;
おわり