UPNG.jsとJS-Interpreterでエモい画像カードを作る
はじめに
みなさんは、画像に任意のデータを埋め込んだことがありますか?
例えば CTF ではステガノグラフィ[1]といった画像へのデータ埋め込み技術を用いた問題が出題されていたりと、こういった分野には一定の人気と需要があります。
こうした技術には電子透かしなどの建設的(かつ複雑)な活用方法がいろいろと考えられる一方、私の中ではプリミティブな感情として「そもそも『データ埋め込み』はそれ単体で面白いのだから、建設的ではないが楽しいものを作ってみたい」というものが燻っていました。
せっかくの年末ですし、今回は「エモい画像カード」をテーマにして、遊び心のある玩具のようなデータ埋め込みスクリプトを作ってみようと思います。
具体的に実装するものは以下の二種です:
- 既存の画像に対し、動的に実行可能なスクリプト(今回はJavaScript)を埋め込む
maker
を作成する - スクリプトが埋め込まれた画像を入力として、サンドボックス内で逐次スクリプトを実行するライブラリ
loader
を作成する
また、今回はメタデータや画像以外のチャンクを使わず、あくまで「画像」部分の身を取り扱うものとします。
データ埋め込みの基本アイデアと流れ
「エモい画像カード」を作るということで、まずはスクリプトの埋め込み手法について考えます。ステガノグラフィのように埋め込むデータを綺麗に隠しても良いのですが、二番煎じにしても面白くないですし、今回の題材からしても「データの隠し方」自体は本質でないように思えます。
そこで今回はシンプルに考えて、画像のピクセルらから直接データを取り出すこととしました。とはいえ全ピクセルを対象にすると埋め込みデータだけになってしまいかねないので、今回は「既存の正方形の画像[2]に対し、その下部にピクセルを追加してデータを埋め込む」ような仕組みにします。
埋め込み先画像ファイルのフォーマットにはPNGを採用します。PNGは無劣化かつ圧縮した状態でデータを保持できるため埋め込みに適していますし、透明度も保持できるため、追加したピクセル部分を全て透明にすれば表面上の内容を隠すことは可能です。が、今回は実装を簡易にするため、RGBAのピクセル(各32bit)をそのまま埋め込み領域として利用することにします。
まとめると、今回実装する埋め込みの要件は以下の通りです:
- 既存の正方形の画像に対し、その下部(具体的には
width * width px
後)にJavaScriptコードを埋め込む- 抽出は
width * width px
の後ろのピクセルを読み出すことで実現する
- 抽出は
- 埋め込みはピクセル情報をそのまま利用する
UPNG.jsを用いたJavaScriptの埋め込み
埋め込みの流れが定まったので、具体的に実装を進めていきます。今回は JavaScript でこれらの実装を行うことにしました。埋め込むスクリプトも JavaScript コードとしたので、実装するものと揃えたかったのが一番の理由です。また、後述しますが、便利な JavaScript インタプリタのライブラリ[3]が利用できるのも理由としてあります。
データの埋め込みと抽出を行うにあたり、まずはPNGファイルの操作(読み込みと書き込み)を行う必要があります。今回は UPNG.js[4] を利用しました。
UPNG.js は非常に優秀で、以下のようにメソッドを一つ呼び出すだけでPNGのエンコード・デコードが可能です。
import UPNG from 'upng-js';
// PNG画像のデコード
const fileBuffer = fs.readFileSync(PNGファイルパス);
const image = UPNG.decode(fileBuffer);
const [cWidth, cHeight] = [image.width, image.width];
const cArray = new Uint8Array(cWidth * cHeight * ピクセルあたりバイト数);
// (処理略)
// PNG画像へのエンコード
// UPNGではAPNGにも対応しているので、第一引数はArrayBufferの配列(各フレームが要素に対応)
const pngArrayBuffer = UPNG.encode([cArray.buffer], cWidth, cHeight, 0);
上記でいうところの cArray
の部分にスクリプトのバイト列を含めれば、無事にスクリプト入りの画像が生成されます。
埋め込みコードはそのままファイルから読み出したものを使っても良いのですが、データ量削減のために minify したいのと、後述するインタプリタがES5相当の記法にしか対応していないこともあり、以下のように Babel でトランスパイルしてから画像化します。
const sBuffer = fs.readFileSync(埋め込みコードのファイルパス);
const rawSource = (new TextDecoder()).decode(sBuffer);
const newSource = transformSync(rawSource, {presets: ['@babel/preset-env', 'minify'], comments: false}).code;
const sArray = (new TextEncoder()).encode(newSource); // これを cArray にコピーする
大雑把には以上の通りですが、実際のコードでは、判定用のマジックバイトと埋め込んだスクリプトのサイズ(パディング分を抜いたもの)を埋め込み領域の先頭に格納しています。
完成した maker
は こちら です。
では、早速画像を生成してみましょう。以下の画像に Hello, World!
のスクリプトを埋め込んでみます。
アイコンとして使っているバラの画像
// log() は console.log() と同様の文字列表示メソッド(定義は後述)
log('Hello, World!');
結果、以下のような画像が得られました。
埋め込んだデータが小さいため差が僅かですが、よく見ると画像下部にピクセルが付加されていることがわかります。
JS-Interpreterを用いたJavaScriptの(安全な)実行
これで画像にスクリプトを含めることができるようになりました。が、現状だと埋め込みと抽出はできても実行ができず、全くエモくありません。
というわけで、続いてスクリプトの実行環境を用意します。抽出したスクリプトを Node.js やブラウザでそのまま実行してしまうとセキュリティリスクになってしまうので、「ピュアな JavaScript で記述された JavaScript のインタープリタ」である JS-Interpreter[3:1] を利用して、サンドボックス内でスクリプトを実行するようにします。
基本的には公式リポジトリのドキュメントを見つつ組み込んでいけば良いのですが、 JS-Interpreter をESモジュールとして読み込むために、以下のように Rollup[5] を使って前処理を行っておきます。
const acorn = require('../JS-Interpreter/acorn');
globalThis.acorn = acorn;
require('../JS-Interpreter/interpreter');
export default Interpreter;
import commonjs from '@rollup/plugin-commonjs';
const config = {
input: 'wrapper.js',
output: {
file: './interpreter.js',
format: 'esm'
},
plugins: [commonjs({ transformMixedEsModules: true }),]
};
export default config;
あとは Rollup で生成されたコードを読み込んで、インタープリタを組み込んでいきます。
サンドボックス環境なので、入出力については組み込み側が実装する必要があります。今回は、先ほどのサンプルスクリプトでも利用している log()
を含めた4種類のメソッドを以下の通り実装しました。
最後二つについては必ずしも実装する必要はないものの、使いやすさの面から追加で実装してあります。
const initFunc = (interpreter, globalObject) => {
interpreter.setProperty(globalObject, 'log', interpreter.createNativeFunction((text, rawProps) => {
const props = rawProps ? interpreter.pseudoToNative(rawProps) : undefined;
logger(text, props); // 出力に渡す(Node.jsの場合は console.log() )
}));
interpreter.setProperty(globalObject, 'input', interpreter.createAsyncFunction((fn) => {
// callbackFn(userInput: string)
callbackFn = fn;
status.set(StatusValue.WAITING_INPUT); // ステータス処理用
}));
interpreter.setProperty(globalObject, 'wait', interpreter.createAsyncFunction((ms, cb) => {
setTimeout(cb, ms);
}));
interpreter.setProperty(globalObject, 'randInt', interpreter.createNativeFunction((min, max) => {
return Math.floor(Math.random() * (max - min + 1)) + min;
}));
};
const myInterpreter = new Interpreter(code, initFunc);
インタープリタの組み込みを終えて完成したコードは こちら にあります。
また、画像からスクリプトを抽出するコードは こちら です。
これで実行環境が整ったので、以下のように簡易的な loader
を作成しサンプル画像を実行してみます。
import fs from 'fs';
import { load } from './libs/loader.js';
import { createInterpreter } from './libs/interpreter.js';
const cardPath = './output/helloworld.png';
const cBuffer = fs.readFileSync(cardPath);
const sBuffer = load(cBuffer);
const code = (new TextDecoder()).decode(sBuffer);
const logger = (text) => {
console.log(text);
};
const interpreter = createInterpreter(code, logger, 10);
interpreter.run();
実行すると、お馴染みの Hello, World!
が出力されるはずです。
インターフェースを整える
あとは、これをベースにユーザー入力などのインターフェースを設ければ、対話型のスクリプトを実行することができるようになります。
Node.js で動作させることはもちろん、HTMLで画面を作ればブラウザでも動作します。
サンプルコードリポジトリのexamples にて Node.js 版、ブラウザ版の対話型対応コードを公開しています。また、ブラウザ版については著者のサンプルページ[6]でも動作を試すことができます。
ブラウザでの動作デモ
上記の実装に対し、例えば以下の画像を与えると対話型のチャット気分を味わえてエモさを感じることができますので、試してみると面白いかもしれません。画像下部の髭っぽいピクセルたちが、インタープリタで実行されるのをお待ちしています。
まとめ
「スクリプトが埋め込まれており、それを実行することができるエモい画像カード」の作成を簡単にご紹介しました。
今回はあくまでスクリプト実行という言わば玩具部分が主眼であったため、画像の下に直接ピクセルを配置する愚直な方針を取りましたが、綺麗に隠すならステガノグラフィのような技術を使ってもいいですし、PNGであれば画像情報以外のチャンクに格納しても良いでしょう。
また、現状だとプラットフォームへのファイルアップロードなどでPNGが最適化されてしまった場合、うまく再現できなくなってしまいます。こうした最適化に耐性のある方式を考えるのも良さそうです。
いずれにせよ、どんなデータを埋め込み、それをどう活用するかが面白いポイントだと感じています。ぜひ、みなさんも画像に何かを埋め込んで、エモい画像を生み出していただければ幸いです。
明日の紹介
明日はYoshitomo Yasunoさんの記事が公開予定です。「CredoのSpecに関するwarningの自動修正」ということで、ワクワクしますね。明日が楽しみです!
ライセンス
本記事の内容は、特記なき限りCreative Commons Attribution 4.0 International Public License[7]のもとで自由に利用することができます。ただし、別のライセンスが示されている部分についてはそちらに従ってください。
なお、著者ウェブサイトにて同一の内容[8]を掲載しています。
また、完成したデータ埋め込み・抽出コード(Card.js)については、Apache License 2.0のもとで利用することができます。詳しくはリポジトリ[9]をご覧ください。
-
https://eset-info.canon-its.jp/malware_info/special/detail/220517.html ↩︎
-
埋め込みデータを後置する都合上、埋め込みデータの開始位置までを簡単に算出できるようにしています。今回はやりませんでしたが、オフセットを記録しておけばどんなサイズにも対応可能なはずです。 ↩︎
Discussion