調査するPNGファイル
私のアルバイト先のプログラミング教室では、 Microsoft Makecode Arcade という環境を使って生徒がゲーム制作を行っています。
制作したゲームはエクスポートしたり、インポートして続きから制作できるのですが、保存されるファイルが PNG画像1枚だけ だったことが気になりました。
作ったプログラムやキャラクターのデータをどこに保存しているのか調査してみることにしました。
まずは簡単なコイン集めゲームを作ります。
ゲームを保存すると PNGファイルがダウンロードされ、 そのファイルを別のMakecode Arcade環境で開いても内容が変更されていないことを確認します。
基本情報の確認
保存されるPNG画像は512×424サイズで、プレイ画面のキャプチャとプロジェクト名 coin_collecterが表示されています。
メタデータの確認
まず最初に考えたのは、メタデータにプログラム情報が保存されているパターンです。
exiftool でメタデータを確認します。
$ exiftool coin_collecter.png
ExifTool Version Number : 13.50
File Name : coin_collecter.png
File Size : 28 kB
File Type : PNG
File Type Extension : png
MIME Type : image/png
Bit Depth : 8
Color Type : RGB with Alpha
Compression : Deflate/Inflate
Filter : Adaptive
Interlace : Noninterlaced
Image Size : 512x424
Megapixels : 0.217
得られたのは画像サイズや色形式などの一般的な情報だけで、プログラムは保存されてませんでした。
チャンクを見てみる
PNGファイルには チャンク という単位でデータが分割保存されており、中には 補助チャンク という独自にデータを保存できる領域があるようです。
以下の記事が非常にわかりやすかったです。
代表的なチャンクの種類は以下の通りです。
tEXtや独自チャンクにプログラム情報があるかもしれません。
| チャンク名 | 種類 | 役割 |
|---|---|---|
IHDR |
必須 | 画像の基本情報を持つチャンク |
IDAT |
必須 | 圧縮された画像データ本体を持つチャンク |
IEND |
必須 | PNGファイルの終端を示すチャンク |
tEXt |
補助 | テキストデータを持つチャンク |
zTXt |
補助 | 圧縮されたテキストデータを持つチャンク |
eXIf |
補助 | Exif情報を持つチャンク |
pngcheck というツールを用いてチャンクごとの情報を確認していきます。
$ pngcheck -v coin_collecter.png
File: coin_collecter.png (28289 bytes)
chunk IHDR at offset 0x0000c, length 13
512 x 424 image, 32-bit RGB+alpha, non-interlaced
chunk IDAT at offset 0x00025, length 4096
zlib: deflated, 32K window, superfast compression
chunk IDAT at offset 0x01031, length 4096
chunk IDAT at offset 0x0203d, length 4096
chunk IDAT at offset 0x03049, length 4096
chunk IDAT at offset 0x04055, length 4096
chunk IDAT at offset 0x05061, length 4096
chunk IDAT at offset 0x0606d, length 3566
chunk IDAT at offset 0x06e67, length 6
chunk IEND at offset 0x06e79, length 0
No errors detected in coin_collecter.png (10 chunks, 96.7% compression).
IHDR IDAT IENDの必須チャンクしかなく、想定していた補助チャンクがありません…
いつかのCTFで終端チャンク以降にデータが格納されていることがありましたが、今回はそれもなさそうです。
メタデータと補助チャンクしか保存場所を知らなかったので、万策尽きたという感じです…
PXTのリポジトリを調べる
PNGファイル自体の解析は中断し、公式実装を調べてみます。
MakeCode Arcadeは、Microsoftが公開している PXT(Programming eXperience Toolkit) を基盤として作られています。
関連する実装もGitHubで公開されているため、PNG保存を担当する処理をCodexに探してもらいます。
・プロジェクトファイルの圧縮処理
pxtlib/package.tsのcompressToFileAsync()では、ソースファイル群やメタ情報をまとめたプロジェクトデータをJSON文字列へ変換し、LZMAで圧縮しています。
・圧縮したデータを画像へ埋め込む処理
圧縮後のデータを画像へ埋め込む処理は、pxtlib/util.tsのencodeBlobAsync()にあります。
関数の冒頭では、画像に格納できる容量を計算し、1つの色成分へ何ビット書き込むかを表すbppを決定しています。
続くencode()では、Canvasから取得したRGBA形式のピクセルデータに対して、圧縮済みデータをRGB各成分の下位ビットへ順番に書き込んでいます。
メタデータやチャンク外にデータを埋め込むのではなく、 画像データ本体 に細かくデータを埋め込んでいたんですね。
LSB法
今回確認できたデータ埋め込み方法は LSB法 というものです。
色の値を2進数で表したとき、右端にある最下位ビットを LSB(Least Significant Bit) と呼びます。その名の通り、最も影響の小さいビットです。
たとえば、R(赤)の値を254から255へ変更しても、変化するのは最下位の1ビットだけで、画像を見ただけでは変化に気づけません。
254 = 11111110
255 = 11111111
^
LSB
MakeCode Arcadeでは、この性質を利用してプロジェクトデータをピクセルへ埋め込んでいました。
PXTは先にCanvas上のRGB値を書き換えます。その画像をPNGとして保存すると、変更後のピクセルデータがフィルタ処理とDeflate圧縮を経てIDATへ格納されます。
プロジェクト内のファイル
↓ JSON化
プロジェクトデータ
↓ LZMA圧縮
圧縮済みバイナリ
↓ RGBのLSBへ書き込み
Canvasのピクセルデータ
↓ Deflate圧縮処理
IDATチャンク
画像データ本体のIDATチャンクに埋め込まれていたため、exiftoolやpngcheckで調べても見つからなかったんですね。
実際にPNGからデータを取り出してみる
PXTリポジトリでは、埋め込んだデータを読み出す decodeBlobAsync()も確認できました。処理の流れは、書き込み時とほぼ逆です。
この処理を参考に、Codexに画像からプロジェクトデータを抽出するスクリプトを作ってもらいました。(すごい)
PNGのフィルタ処理とDeflate圧縮の展開にはPillowを使います。
Pillowが入っていない場合は、先にインストールします。
$ python3 -m pip install Pillow
抽出スクリプト全文(extract_makecode_png.py)
from pathlib import Path
import json
import lzma
import struct
import sys
from PIL import Image
IMAGE_MAGIC = 0x59347A7D
HEADER_SIZE = 36
def decode(rgba, ptr, bpp, size):
mask = (1 << bpp) - 1
result = bytearray()
shift = 0
value = 0
while len(result) < size:
value |= (rgba[ptr] & mask) << shift
ptr += 1
# RGBAの4番目にあるAlphaを飛ばす
if ptr & 3 == 3:
ptr += 1
shift += bpp
if shift >= 8:
result.append(value & 0xFF)
value >>= 8
shift -= 8
return bytes(result), ptr
png_path = Path(sys.argv[1])
image = Image.open(png_path).convert("RGBA")
rgba = image.tobytes()
# 最初のピクセルのRGBからbppを取得する
bpp = (rgba[0] & 1) | ((rgba[1] & 1) << 1) | ((rgba[2] & 1) << 2)
# 2番目のピクセル以降から36バイトのヘッダーを読む
header, ptr = decode(rgba, 4, bpp, HEADER_SIZE)
magic, data_length, added_lines, *_ = struct.unpack("<9I", header)
if magic != IMAGE_MAGIC:
raise ValueError(f"不正な識別値です: 0x{magic:08x}")
if added_lines != 0:
raise NotImplementedError("このスクリプトは追加行のあるPNGに未対応です")
# ヘッダーに記録された長さだけ圧縮データを読む
compressed, _ = decode(rgba, ptr, bpp, data_length)
# LZMAを展開し、外側とsource内のJSONを解析する
project = json.loads(lzma.decompress(compressed).decode("utf-8"))
source = json.loads(project["source"])
print(f"画像サイズ : {image.width}x{image.height}")
print(f"最初のピクセル : {tuple(rgba[:4])}")
print(f"bpp : {bpp}")
print(f"識別値 : 0x{magic:08x}")
print(f"圧縮データ長 : {data_length} bytes")
print(f"追加行数 : {added_lines}")
print(f"プロジェクト名 : {project['meta']['name']}")
print("ファイル : " + ", ".join(source.keys()))
作成してもらったスクリプトを coin_collecter.pngに対し実行すると、公式ドキュメントで言及されていた4つのファイル( README.md、main.blocks、 main.ts、 pxt.json)を抽出できました!
$ python3 extract_makecode_png.py coin_collecter.png
画像サイズ : 512x424
最初のピクセル : (255, 254, 254, 255)
bpp : 1
識別値 : 0x59347a7d
圧縮データ長 : 3076 bytes
追加行数 : 0
プロジェクト名 : coin_collecter
ファイル : README.md, assets.json, main.blocks, main.ts, pxt.json
| ファイル | 役割 | 今回の内容 |
|---|---|---|
main.ts |
ゲームの処理を記述するTypeScriptコード | コインの生成、スコア、制限時間など |
main.blocks |
main.tsと同じ内容をブロックエディターの状態として保存したXML |
使用したコードブロック、接続関係、配置など(形式は違うが内容は main.tsと同じ) |
pxt.json |
プロジェクト全体の設定ファイル | プロジェクト名、使用ファイル、対象バージョンなど |
assets.json |
アセットエディターで扱う素材 | 今回は空 |
README.md |
プロジェクトの説明 | 今回は空 |
中身もきちんと確認できました。
info.onCountdownEnd(function () {
if (info.score() >= 10) {
game.gameOver(true)
game.setGameOverEffect(true, effects.confetti)
} else {
game.gameOver(false)
game.setGameOverEffect(false, effects.melt)
}
})
LSB法とステガノグラフィ
今回使われていたLSB法は、サイバーセキュリティの分野でよく登場する ステガノグラフィ の代表的な手法の一つです。
ステガノグラフィとは、あるデータを画像や音声など別のデータの中に埋め込み、「情報が存在すること」自体を隠す技術です。
画像の見た目をほとんど変えずにデータを埋め込める性質は、マルウェアの隠蔽にも悪用されています。
Avast Threat Labsが2022年に報告したPNGLoaderでは、PNGのRGBA各成分の最下位ビットにPowerShellスクリプトや.NETコードが埋め込まれていました。
Avast Threat Labs - PNG Steganography Hides Backdoor
なぜPNGファイル?なぜLSB法?
私の考えですが、画像ファイルの 「一目でどのゲームかわかる」 性質を重視したんだろうと思います。
ZIPでもいいだろうに… ユーザーへの思いやりですね!
実際、アルバイト先の教室では多くのゲームデータを管理していますが、PNGであるおかげでスムーズに判別できていると感じます。
また、なぜLSB法で埋め込んでるのかについては、「ブラウザのCanvasだけで読み書きできる」「メタデータと違い削除されることがない」などの理由がありそうです。
感想
なんとなく気になって調べ始めましたが、PNGの構造やLSB法について学べて勉強になりました。
また、マルウェアの隠蔽など闇のイメージしかなかったステガノグラフィに、こんな光の使い方があったことを知れてよかったです![]()


