39728d
From 5c79b9faae0f1dd67cc8288964c72c12e03884f8 Mon Sep 17 00:00:00 2001
39728d
From: =?UTF-8?q?Petr=20P=C3=ADsa=C5=99?= <ppisar@redhat.com>
39728d
Date: Fri, 15 Jun 2018 14:49:47 +0200
39728d
Subject: [PATCH] Prevent from traversing symlinks and parent directories when
39728d
 extracting
39728d
MIME-Version: 1.0
39728d
Content-Type: text/plain; charset=UTF-8
39728d
Content-Transfer-Encoding: 8bit
39728d
39728d
If an attacker-supplied archive contains symbolic links and files that
39728d
referes to the symbolic links in their path components, the user can
39728d
be tricked into overwriting any arbitrary file.
39728d
39728d
The same issue is with archives whose members refer to a parent
39728d
directory (..) in their path components.
39728d
39728d
This patch fixes it by aborting an extraction (extractTree(),
39728d
extractMember(), extractMemberWithoutPaths()) in those cases by not
39728d
traversing the dangerous paths and returning AZ_ERORR instead.
39728d
39728d
However, if a user supplies a local file name, the security checks are
39728d
not performed. This is based on the assumption that a user knows
39728d
what's on his local file system.
39728d
39728d
CVE-2018-10860
39728d
https://bugzilla.redhat.com/show_bug.cgi?id=1591449
39728d
39728d
Signed-off-by: Petr Písař <ppisar@redhat.com>
39728d
---
39728d
 MANIFEST                               |   3 +
39728d
 lib/Archive/Zip.pm                     |   8 ++
39728d
 lib/Archive/Zip/Archive.pm             |  37 +++++++
39728d
 t/25_traversal.t                       | 189 +++++++++++++++++++++++++++++++++
39728d
 t/data/dotdot-from-unexistant-path.zip | Bin 0 -> 245 bytes
39728d
 t/data/link-dir.zip                    | Bin 0 -> 260 bytes
39728d
 t/data/link-samename.zip               | Bin 0 -> 257 bytes
39728d
 7 files changed, 237 insertions(+)
39728d
 create mode 100644 t/25_traversal.t
39728d
 create mode 100644 t/data/dotdot-from-unexistant-path.zip
39728d
 create mode 100644 t/data/link-dir.zip
39728d
 create mode 100644 t/data/link-samename.zip
39728d
39728d
diff --git a/MANIFEST b/MANIFEST
39728d
index 2e9655d..a1bd7d6 100644
39728d
--- a/MANIFEST
39728d
+++ b/MANIFEST
39728d
@@ -59,6 +59,7 @@ t/21_zip64.t
39728d
 t/22_deflated_dir.t
39728d
 t/23_closed_handle.t
39728d
 t/24_unicode_win32.t
39728d
+t/25_traversal.t
39728d
 t/badjpeg/expected.jpg
39728d
 t/badjpeg/source.zip
39728d
 t/common.pm
39728d
@@ -68,6 +69,7 @@ t/data/crypcomp.zip
39728d
 t/data/crypt.zip
39728d
 t/data/def.zip
39728d
 t/data/defstr.zip
39728d
+t/data/dotdot-from-unexistant-path.zip
39728d
 t/data/empty.zip
39728d
 t/data/emptydef.zip
39728d
 t/data/emptydefstr.zip
39728d
@@ -75,6 +77,7 @@ t/data/emptystore.zip
39728d
 t/data/emptystorestr.zip
39728d
 t/data/good_github11.zip
39728d
 t/data/jar.zip
39728d
+t/data/link-dir.zip
39728d
 t/data/linux.zip
39728d
 t/data/mkzip.pl
39728d
 t/data/perl.zip
39728d
diff --git a/lib/Archive/Zip.pm b/lib/Archive/Zip.pm
39728d
index ca82e31..907808b 100644
39728d
--- a/lib/Archive/Zip.pm
39728d
+++ b/lib/Archive/Zip.pm
39728d
@@ -1145,6 +1145,9 @@ member is used as the name of the extracted file or
39728d
 directory.
39728d
 If you pass C<$extractedName>, it should be in the local file
39728d
 system's format.
39728d
+If you do not pass C<$extractedName> and the internal filename traverses
39728d
+a parent directory or a symbolic link, the extraction will be aborted with
39728d
+C<AC_ERROR> for security reason.
39728d
 All necessary directories will be created. Returns C<AZ_OK>
39728d
 on success.
39728d
 
39728d
@@ -1162,6 +1165,9 @@ extracted member (its paths will be deleted too). Otherwise,
39728d
 the internal filename of the member (minus paths) is used as
39728d
 the name of the extracted file or directory. Returns C<AZ_OK>
39728d
 on success.
39728d
+If you do not pass C<$extractedName> and the internal filename is equalled
39728d
+to a local symbolic link, the extraction will be aborted with C<AC_ERROR> for
39728d
+security reason.
39728d
 
39728d
 =item addMember( $member )
39728d
 
39728d
@@ -1609,6 +1615,8 @@ a/x to f:\d\e\x
39728d
 
39728d
 a/b/c to f:\d\e\b\c and ignore ax/d/e and d/e
39728d
 
39728d
+If the path to the extracted file traverses a parent directory or a symbolic
39728d
+link, the extraction will be aborted with C<AC_ERROR> for security reason.
39728d
 Returns an error code or AZ_OK if everything worked OK.
39728d
 
39728d
 =back
39728d
diff --git a/lib/Archive/Zip/Archive.pm b/lib/Archive/Zip/Archive.pm
39728d
index 48f0d1a..b0d3e46 100644
39728d
--- a/lib/Archive/Zip/Archive.pm
39728d
+++ b/lib/Archive/Zip/Archive.pm
39728d
@@ -185,6 +185,8 @@ sub extractMember {
39728d
         $dirName = File::Spec->catpath($volumeName, $dirName, '');
39728d
     } else {
39728d
         $name = $member->fileName();
39728d
+        if ((my $ret = _extractionNameIsSafe($name))
39728d
+            != AZ_OK) { return $ret; }
39728d
         ($dirName = $name) =~ s{[^/]*$}{};
39728d
         $dirName = Archive::Zip::_asLocalName($dirName);
39728d
         $name    = Archive::Zip::_asLocalName($name);
39728d
@@ -218,6 +220,8 @@ sub extractMemberWithoutPaths {
39728d
     unless ($name) {
39728d
         $name = $member->fileName();
39728d
         $name =~ s{.*/}{};    # strip off directories, if any
39728d
+        if ((my $ret = _extractionNameIsSafe($name))
39728d
+            != AZ_OK) { return $ret; }
39728d
         $name = Archive::Zip::_asLocalName($name);
39728d
     }
39728d
     my $rc = $member->extractToFileNamed($name, @_);
39728d
@@ -827,6 +831,37 @@ sub addTreeMatching {
39728d
     return $self->addTree($root, $dest, $matcher, $compressionLevel);
39728d
 }
39728d
 
39728d
+# Check if one of the components of a path to the file or the file name
39728d
+# itself is an already existing symbolic link. If yes then return an
39728d
+# error. Continuing and writing to a file traversing a link posseses
39728d
+# a security threat, especially if the link was extracted from an
39728d
+# attacker-supplied archive. This would allow writing to an arbitrary
39728d
+# file. The same applies when using ".." to escape from a working
39728d
+# directory. <https://bugzilla.redhat.com/show_bug.cgi?id=1591449>
39728d
+sub _extractionNameIsSafe {
39728d
+    my $name = shift;
39728d
+    my ($volume, $directories) = File::Spec->splitpath($name, 1);
39728d
+    my @directories = File::Spec->splitdir($directories);
39728d
+    if (grep '..' eq $_, @directories) {
39728d
+        return _error(
39728d
+            "Could not extract $name safely: a parent directory is used");
39728d
+    }
39728d
+    my @path;
39728d
+    my $path;
39728d
+    for my $directory (@directories) {
39728d
+        push @path, $directory;
39728d
+        $path = File::Spec->catpath($volume, File::Spec->catdir(@path), '');
39728d
+        if (-l $path) {
39728d
+            return _error(
39728d
+                "Could not extract $name safely: $path is an existing symbolic link");
39728d
+        }
39728d
+        if (!-e $path) {
39728d
+            last;
39728d
+        }
39728d
+    }
39728d
+    return AZ_OK;
39728d
+}
39728d
+
39728d
 # $zip->extractTree( $root, $dest [, $volume] );
39728d
 #
39728d
 # $root and $dest are Unix-style.
39728d
@@ -861,6 +896,8 @@ sub extractTree {
39728d
         $fileName =~ s{$pattern}{$dest};       # in Unix format
39728d
                                                # convert to platform format:
39728d
         $fileName = Archive::Zip::_asLocalName($fileName, $volume);
39728d
+        if ((my $ret = _extractionNameIsSafe($fileName))
39728d
+            != AZ_OK) { return $ret; }
39728d
         my $status = $member->extractToFileNamed($fileName);
39728d
         return $status if $status != AZ_OK;
39728d
     }
39728d
diff --git a/t/25_traversal.t b/t/25_traversal.t
39728d
new file mode 100644
39728d
index 0000000..d03dede
39728d
--- /dev/null
39728d
+++ b/t/25_traversal.t
39728d
@@ -0,0 +1,189 @@
39728d
+use strict;
39728d
+use warnings;
39728d
+
39728d
+use Archive::Zip qw( :ERROR_CODES );
39728d
+use File::Spec;
39728d
+use File::Path;
39728d
+use lib 't';
39728d
+use common;
39728d
+
39728d
+use Test::More tests => 41;
39728d
+
39728d
+# These tests check for CVE-2018-10860 vulnerabilities.
39728d
+# If an archive contains a symlink and then a file that traverses that symlink,
39728d
+# extracting the archive tree could write into an abitrary file selected by
39728d
+# the symlink value.
39728d
+# Another issue is if an archive contains a file whose path component refers
39728d
+# to a parent direcotory. Then extracting that file could write into a file
39728d
+# out of current working directory subtree.
39728d
+# These tests check extracting of these files is refuses and that they are
39728d
+# indeed not created.
39728d
+
39728d
+# Suppress croaking errors, the tests produce some.
39728d
+Archive::Zip::setErrorHandler(sub {});
39728d
+my ($existed, $ret, $zip, $allowed_file, $forbidden_file);
39728d
+
39728d
+# Change working directory to a temporary directory because some tested
39728d
+# functions operarates there and we need prepared symlinks there.
39728d
+my @data_path = (File::Spec->splitdir(File::Spec->rel2abs('.')), 't', 'data');
39728d
+ok(chdir TESTDIR, "Working directory changed");
39728d
+
39728d
+# Case 1:
39728d
+#   link-dir -> /tmp
39728d
+#   link-dir/gotcha-linkdir
39728d
+# writes into /tmp/gotcha-linkdir file.
39728d
+SKIP: {
39728d
+    # Symlink tests make sense only if a file system supports them.
39728d
+    my $link = 'trylink';
39728d
+    $ret = eval { symlink('.', $link)};
39728d
+    skip 'Symbolic links are not supported', 12 if $@;
39728d
+    unlink $link;
39728d
+
39728d
+    # Extracting an archive tree must fail
39728d
+    $zip = Archive::Zip->new();
39728d
+    isa_ok($zip, 'Archive::Zip');
39728d
+    is($zip->read(File::Spec->catfile(@data_path, 'link-dir.zip')), AZ_OK,
39728d
+        'Archive read');
39728d
+    $existed = -e File::Spec->catfile('', 'tmp', 'gotcha-linkdir');
39728d
+    $ret = eval { $zip->extractTree() };
39728d
+    is($ret, AZ_ERROR, 'Tree extraction aborted');
39728d
+    SKIP: {
39728d
+        skip 'A canary file existed before the test', 1 if $existed;
39728d
+        ok(! -e File::Spec->catfile('link-dir', 'gotcha-linkdir'),
39728d
+            'A file was not created in a symlinked directory');
39728d
+    }
39728d
+    ok(unlink(File::Spec->catfile('link-dir')), 'link-dir removed');
39728d
+
39728d
+    # The same applies to extracting an archive member without an explicit
39728d
+    # local file name. It must abort.
39728d
+    $link = 'link-dir';
39728d
+    ok(symlink('.', $link), 'A symlink to a directory created');
39728d
+    $forbidden_file = File::Spec->catfile($link, 'gotcha-linkdir');
39728d
+    $existed = -e $forbidden_file;
39728d
+    $ret = eval { $zip->extractMember('link-dir/gotcha-linkdir') };
39728d
+    is($ret, AZ_ERROR, 'Member extraction without a local name aborted');
39728d
+    SKIP: {
39728d
+        skip 'A canary file existed before the test', 1 if $existed;
39728d
+        ok(! -e $forbidden_file,
39728d
+            'A file was not created in a symlinked directory');
39728d
+    }
39728d
+
39728d
+    # But allow extracting an archive member into a supplied file name
39728d
+    $allowed_file = File::Spec->catfile($link, 'file');
39728d
+    $ret = eval { $zip->extractMember('link-dir/gotcha-linkdir', $allowed_file) };
39728d
+    is($ret, AZ_OK, 'Member extraction passed');
39728d
+    ok(-e $allowed_file, 'File created');
39728d
+    ok(unlink($allowed_file), 'File removed');
39728d
+    ok(unlink($link), 'A symlink to a directory removed');
39728d
+}
39728d
+
39728d
+# Case 2:
39728d
+#   unexisting/../../../../../tmp/gotcha-dotdot-unexistingpath
39728d
+# writes into ../../../../tmp/gotcha-dotdot-unexistingpath, that is
39728d
+# /tmp/gotcha-dotdot-unexistingpath file if CWD is not deeper than
39728d
+# 4 directories.
39728d
+$zip = Archive::Zip->new();
39728d
+isa_ok($zip, 'Archive::Zip');
39728d
+is($zip->read(File::Spec->catfile(@data_path,
39728d
+            'dotdot-from-unexistant-path.zip')), AZ_OK, 'Archive read');
39728d
+$forbidden_file = File::Spec->catfile('..', '..', '..', '..', 'tmp',
39728d
+    'gotcha-dotdot-unexistingpath');
39728d
+$existed = -e $forbidden_file;
39728d
+$ret = eval { $zip->extractTree() };
39728d
+is($ret, AZ_ERROR, 'Tree extraction aborted');
39728d
+SKIP: {
39728d
+    skip 'A canary file existed before the test', 1 if $existed;
39728d
+    ok(! -e $forbidden_file, 'A file was not created in a parent directory');
39728d
+}
39728d
+
39728d
+# The same applies to extracting an archive member without an explicit local
39728d
+# file name. It must abort.
39728d
+$existed = -e $forbidden_file;
39728d
+$ret = eval { $zip->extractMember(
39728d
+        'unexisting/../../../../../tmp/gotcha-dotdot-unexistingpath',
39728d
+    ) };
39728d
+is($ret, AZ_ERROR, 'Member extraction without a local name aborted');
39728d
+SKIP: {
39728d
+    skip 'A canary file existed before the test', 1 if $existed;
39728d
+    ok(! -e $forbidden_file, 'A file was not created in a parent directory');
39728d
+}
39728d
+
39728d
+# But allow extracting an archive member into a supplied file name
39728d
+ok(mkdir('directory'), 'Directory created');
39728d
+$allowed_file = File::Spec->catfile('directory', '..', 'file');
39728d
+$ret = eval { $zip->extractMember(
39728d
+        'unexisting/../../../../../tmp/gotcha-dotdot-unexistingpath',
39728d
+        $allowed_file
39728d
+    ) };
39728d
+is($ret, AZ_OK, 'Member extraction passed');
39728d
+ok(-e $allowed_file, 'File created');
39728d
+ok(unlink($allowed_file), 'File removed');
39728d
+
39728d
+# Case 3:
39728d
+#   link-file -> /tmp/gotcha-samename
39728d
+#   link-file
39728d
+# writes into /tmp/gotcha-samename. It must abort. (Or replace the symlink in
39728d
+# more relaxed mode in the future.)
39728d
+$zip = Archive::Zip->new();
39728d
+isa_ok($zip, 'Archive::Zip');
39728d
+is($zip->read(File::Spec->catfile(@data_path, 'link-samename.zip')), AZ_OK,
39728d
+    'Archive read');
39728d
+$existed = -e File::Spec->catfile('', 'tmp', 'gotcha-samename');
39728d
+$ret = eval { $zip->extractTree() };
39728d
+is($ret, AZ_ERROR, 'Tree extraction aborted');
39728d
+SKIP: {
39728d
+    skip 'A canary file existed before the test', 1 if $existed;
39728d
+    ok(! -e File::Spec->catfile('', 'tmp', 'gotcha-samename'),
39728d
+        'A file was not created through a symlinked file');
39728d
+}
39728d
+ok(unlink(File::Spec->catfile('link-file')), 'link-file removed');
39728d
+
39728d
+# The same applies to extracting an archive member using extractMember()
39728d
+# without an explicit local file name. It must abort.
39728d
+my $link = 'link-file';
39728d
+my $target = 'target';
39728d
+ok(symlink($target, $link), 'A symlink to a file created');
39728d
+$forbidden_file = File::Spec->catfile($target);
39728d
+$existed = -e $forbidden_file;
39728d
+# Select a member by order due to same file names.
39728d
+my $member = ${[$zip->members]}[1];
39728d
+ok($member, 'A member to extract selected');
39728d
+$ret = eval { $zip->extractMember($member) };
39728d
+is($ret, AZ_ERROR,
39728d
+    'Member extraction using extractMember() without a local name aborted');
39728d
+SKIP: {
39728d
+    skip 'A canary file existed before the test', 1 if $existed;
39728d
+    ok(! -e $forbidden_file,
39728d
+        'A symlinked target file was not created');
39728d
+}
39728d
+
39728d
+# But allow extracting an archive member using extractMember() into a supplied
39728d
+# file name.
39728d
+$allowed_file = $target;
39728d
+$ret = eval { $zip->extractMember($member, $allowed_file) };
39728d
+is($ret, AZ_OK, 'Member extraction using extractMember() passed');
39728d
+ok(-e $allowed_file, 'File created');
39728d
+ok(unlink($allowed_file), 'File removed');
39728d
+
39728d
+# The same applies to extracting an archive member using
39728d
+# extractMemberWithoutPaths() without an explicit local file name.
39728d
+# It must abort.
39728d
+$existed = -e $forbidden_file;
39728d
+# Select a member by order due to same file names.
39728d
+$ret = eval { $zip->extractMemberWithoutPaths($member) };
39728d
+is($ret, AZ_ERROR,
39728d
+    'Member extraction using extractMemberWithoutPaths() without a local name aborted');
39728d
+SKIP: {
39728d
+    skip 'A canary file existed before the test', 1 if $existed;
39728d
+    ok(! -e $forbidden_file,
39728d
+        'A symlinked target file was not created');
39728d
+}
39728d
+
39728d
+# But allow extracting an archive member using extractMemberWithoutPaths()
39728d
+# into a supplied file name.
39728d
+$allowed_file = $target;
39728d
+$ret = eval { $zip->extractMemberWithoutPaths($member, $allowed_file) };
39728d
+is($ret, AZ_OK, 'Member extraction using extractMemberWithoutPaths() passed');
39728d
+ok(-e $allowed_file, 'File created');
39728d
+ok(unlink($allowed_file), 'File removed');
39728d
+ok(unlink($link), 'A symlink to a file removed');
39728d
diff --git a/t/data/dotdot-from-unexistant-path.zip b/t/data/dotdot-from-unexistant-path.zip
39728d
new file mode 100644
39728d
index 0000000000000000000000000000000000000000..faaa5bb95c4310ad3dfa8ea7bbad6850da3f2095
39728d
GIT binary patch
39728d
literal 245
39728d
zcmWIWW@Zs#0D%jBS9~Vyb&-_^vO(Aih)eTQD>92qGV{{)_4H6sNp69DdVWcAMxt&?
39728d
zehCoiBGeWnmSjNWtQ7S06v{J8G87Q93LxnKZ$>5&X51D7?FNHwjUWo48O04iClPW+
39728d
TfHx}}$OJ|p%mC8mAPxfn{*XXp
39728d
39728d
literal 0
39728d
HcmV?d00001
39728d
39728d
diff --git a/t/data/link-dir.zip b/t/data/link-dir.zip
39728d
new file mode 100644
39728d
index 0000000000000000000000000000000000000000..99fbb437ec0bd694b8122cdb1ce8221a3da2e453
39728d
GIT binary patch
39728d
literal 260
39728d
zcmWIWW@Zs#0D
39728d
z`6bC2iMk-2K#dTdLRn^_0+6Qw66Ff;W@Hj!#%(FkG%)zT5JbV8fUXPO0T4Y54BHyD
39728d
ZkaX#zIw!!Jl?|kj2?(o!bTNp-004oiIM)CG
39728d
39728d
literal 0
39728d
HcmV?d00001
39728d
39728d
diff --git a/t/data/link-samename.zip b/t/data/link-samename.zip
39728d
new file mode 100644
39728d
index 0000000000000000000000000000000000000000..e9036c0348f5fb9536cb7ee0f2c09b9ef595a12c
39728d
GIT binary patch
39728d
literal 257
39728d
zcmWIWW@Zs#00HsHOFm_vR
39728d
zfTHz8cF##^pcW8D(F)O}P?njf0Me-o(wd?GGMOvDn~_O`8MpO7qrl+*LJ$Ra47xUS
39728d
bt09^g7`8Q9qiSPi14%IfVIGjK1#uVvx^_1y
39728d
39728d
literal 0
39728d
HcmV?d00001
39728d
39728d
-- 
39728d
2.14.4
39728d