CVE-2014-9767 (published 2016-05-22T01:59Z)

Directory traversal vulnerability in the ZipArchive::extractTo function in ext/zip/php_zip.c in PHP before 5.4.45, 5.5.x before 5.5.29, and 5.6.x before 5.6.13 and ext/zip/ext_zip.cpp in HHVM before 3.12.1 allows remote attackers to create arbitrary empty directories via a crafted ZIP archive.

Severity: MEDIUM
CVSS v2 base score: 4.3 (AV:N/AC:M/Au:N/C:N/I:P/A:N)
CVSS v3 base score: 4.3, MEDIUM (CVSS:3.0/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:L/A:N)

Weakness information:
cwe_id: CWE-22
cwe_name: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
description: The product uses external input to construct a pathname that is intended to identify a file or directory that is located underneath a restricted parent directory, but the product does not properly neutralize special elements within the pathname that can cause the pathname to resolve to a location that is outside of the restricted directory.
extended_description: 
            Many file operations are intended to take place within a restricted directory. By using special elements such as ".." and "/" separators, attackers can escape outside of the restricted location to access files or directories that are elsewhere on the system. One of the most common special elements is the "../" sequence, which in most modern operating systems is interpreted as the parent directory of the current location. This is referred to as relative path traversal. Path traversal also covers the use of absolute pathnames such as "/usr/local/bin" to access unexpected files. This is referred to as absolute path traversal.
         
         

Fix information:
repo_url: https://github.com/facebook/hhvm
commit_details: repo_url: https://github.com/facebook/hhvm
author: Paul Bissonnette
author_date: 2016-02-29 15:59:56-08:00
msg: ZipArchive::extractTo bug 70350

Summary:Don't allow upward directory traversal when extracting zip archive files.

Files in zip files with `..` or starting at main root `/` should be normalized
to something where the file being extracted winds up within the directory or
a subdirectory where the actual extraction is taking place.

http://git.php.net/?p=php-src.git;a=commit;h=f9c2bf73adb2ede0a486b0db466c264f2b27e0bb

Reviewed By: FBNeal

Differential Revision: D2798452

fb-gh-sync-id: 844549c93e011d1e991bb322bf85822246b04e30
shipit-source-id: 844549c93e011d1e991bb322bf85822246b04e30
repository: repo_url: https://github.com/facebook/hhvm
repo_name: facebook/hhvm
description: A virtual machine for executing programs written in Hack.
repo_language: Hack
owner: facebook
file_changes: filename: ext_zip.cpp
change_type: ModificationType.MODIFY
diff: @@ -17,6 +17,7 @@
 #include <zip.h>
 
 #include "hphp/runtime/base/array-init.h"
+#include "hphp/runtime/base/file-util.h"
 #include "hphp/runtime/base/preg.h"
 #include "hphp/runtime/base/stream-wrapper-registry.h"
 #include "hphp/runtime/ext/extension.h"
@@ -650,25 +651,103 @@ static bool HHVM_METHOD(ZipArchive, deleteName, const String& name) {
   return true;
 }
 
+// Make the path relative to "." by flattening.
+// This function is named the same and similar in implementation to that in
+// php-src:php_zip.c
+// One difference is that we canonicalize here whereas php-src is already
+// assumed passed a canonicalized path.
+static std::string make_relative_path(const std::string& path) {
+  if (path.empty()) {
+    return path;
+  }
+
+  // First get the path to a state where we don't have .. in the middle of it
+  // etc. canonicalize handles Windows paths too.
+  std::string canonical(FileUtil::canonicalize(path));
+
+  // If we have a slash at the beginning, then just remove it and we are
+  // relative. This check will hold because we have canonicalized the
+  // path above to remove .. from the path, so we know we can be sure
+  // we are at a good place for this check.
+  if (FileUtil::isDirSeparator(canonical[0])) {
+    return canonical.substr(1);
+  }
+
+  // If we get here, canonical looks something like:
+  //   a/b/c
+
+  // Search through the path and if we find a place where we have a slash
+  // and a "." just before that slash, then cut the path off right there
+  // and just take everything after the slash.
+  std::string relative(canonical);
+  int idx = canonical.length() - 1;
+  while (1) {
+    while (idx > 0 && !(FileUtil::isDirSeparator(canonical[idx]))) {
+      idx--;
+    }
+    // If we ever get to idx == 0, then there were no other slashes to deal with
+    if (idx == 0) {
+      return canonical;
+    }
+    if (idx >= 1 && (canonical[idx - 1] == '.' || canonical[idx - 1] == ':')) {
+      relative = canonical.substr(idx + 1);
+      break;
+    }
+    idx--;
+  }
+  return relative;
+}
+
 static bool extractFileTo(zip* zip, const std::string &file, std::string& to,
                           char* buf, size_t len) {
-  auto sep = file.rfind('/');
+
+  struct zip_stat zipStat;
+  // Verify the file to be extracted is actually in the zip file
+  if (zip_stat(zip, file.c_str(), 0, &zipStat) != 0) {
+    return false;
+  }
+
+  auto clean_file = file;
+  auto sep = std::string::npos;
+  // Normally would just use std::string::rfind here, but if we want to be
+  // consistent between Windows and Linux, even if techincally Linux won't use
+  // backslash for a separator, we are checking for both types.
+  int idx = file.length() - 1;
+  while (idx >= 0) {
+    if (FileUtil::isDirSeparator(file[idx])) {
+      sep = idx;
+      break;
+    }
+    idx--;
+  }
   if (sep != std::string::npos) {
-    auto path = to + file.substr(0, sep);
+    // make_relative_path so we do not try to put files or dirs in bad
+    // places. This securely "cleans" the file.
+    clean_file = make_relative_path(file);
+    std::string path = to + clean_file;
+    bool is_dir_only = true;
+    if (sep < file.length() - 1) { // not just a directory
+      auto clean_file_dir = HHVM_FN(dirname)(clean_file);
+      path = to + clean_file_dir.toCppString();
+      is_dir_only = false;
+    }
+
+    // Make sure the directory path to extract to exists or can be created
     if (!HHVM_FN(is_dir)(path) && !HHVM_FN(mkdir)(path, 0777, true)) {
       return false;
     }
 
-    if (sep == file.length() - 1) {
+    // If we have a good directory to extract to above, we now check whether
+    // the "file" parameter passed in is a directory or actually a file.
+    if (is_dir_only) { // directory, like /usr/bin/
       return true;
     }
+    // otherwise file is actually a file, so we actually extract.
   }
 
-  to.append(file);
-  struct zip_stat zipStat;
-  if (zip_stat(zip, file.c_str(), 0, &zipStat) != 0) {
-    return false;
-  }
+  // We have ensured that clean_file will be added to a relative path by the
+  // time we get here.
+  to.append(clean_file);
 
   auto zipFile = zip_fopen_index(zip, zipStat.index, 0);
   FAIL_IF_INVALID_PTR(zipFile);

programming_language: C++
filename: ziparchive_extractto_directory.php
change_type: ModificationType.ADD
diff: @@ -0,0 +1,57 @@
+<?php
+$dir = tempnam(sys_get_temp_dir(), __FILE__);
+unlink($dir);
+mkdir($dir);
+$archive = new ZipArchive();
+$archive->open("$dir/a.zip",ZipArchive::CREATE);
+$archive->addEmptyDir("../dir1/");
+$archive->addEmptyDir("/var/www/dir2/");
+$archive->addEmptyDir("a/b/../../../dir3");
+$archive->addEmptyDir("a/b/../dir4/");
+$archive->addEmptyDir("a/b/../c/../../d/dir5/");
+$archive->addEmptyDir("./dir6");
+$archive->addEmptyDir("x/y/dir7/..");
+$archive->addEmptyDir("z/dir8/.");
+$archive->addEmptyDir("simple");
+$archive->close();
+$archive2 = new ZipArchive();
+$archive2->open("$dir/a.zip");
+$archive2->extractTo($dir);
+$archive2->close();
+var_dump(file_exists("$dir/dir1/")); // true
+var_dump(file_exists("../dir1/")); // false
+var_dump(file_exists("$dir/var/www/dir2")); // true
+var_dump(file_exists("/var/www/dir2/")); // false
+var_dump(file_exists("$dir/dir3/")); // true
+var_dump(file_exists("a/b/../../../dir3/")); // false
+var_dump(file_exists("$dir/a/dir4/")); // true
+var_dump(file_exists("a/b/../dir4/")); // false
+var_dump(file_exists("$dir/d/dir5/")); // true
+var_dump(file_exists("a/b/../c/../../d/dir5/")); // false
+var_dump(file_exists("$dir/dir6")); // true
+var_dump(file_exists("./dir6")); // false
+var_dump(file_exists("$dir/x/y/")); // true
+var_dump(file_exists("x/y/dir7/..")); // false
+var_dump(file_exists("$dir/z/dir8")); // true
+var_dump(file_exists("z/dir8/.")); // false
+var_dump(file_exists("$dir/simple")); // true
+var_dump(file_exists("simple")); // false
+
+// Cleanup. Also verifies that everything is where it is supposed to be.
+rmdir("$dir/dir1");
+rmdir("$dir/var/www/dir2");
+rmdir("$dir/var/www");
+rmdir("$dir/var");
+rmdir("$dir/dir3");
+rmdir("$dir/a/dir4");
+rmdir("$dir/a");
+rmdir("$dir/d/dir5");
+rmdir("$dir/d");
+rmdir("$dir/dir6");
+rmdir("$dir/x/y");
+rmdir("$dir/x");
+rmdir("$dir/z/dir8");
+rmdir("$dir/z");
+rmdir("$dir/simple");
+unlink("$dir/a.zip");
+rmdir($dir);

programming_language: PHP
filename: ziparchive_extractto_directory.php.expect
change_type: ModificationType.ADD
diff: @@ -0,0 +1,18 @@
+bool(true)
+bool(false)
+bool(true)
+bool(false)
+bool(true)
+bool(false)
+bool(true)
+bool(false)
+bool(true)
+bool(false)
+bool(true)
+bool(false)
+bool(true)
+bool(false)
+bool(true)
+bool(false)
+bool(true)
+bool(false)

programming_language: Python
filename: ziparchive_extractto_file.php
change_type: ModificationType.ADD
diff: @@ -0,0 +1,75 @@
+<?php
+$str = 'temp';
+$dir = tempnam(sys_get_temp_dir(), __FILE__);
+unlink($dir);
+mkdir($dir);
+$archive = new ZipArchive();
+$archive->open("$dir/a.zip",ZipArchive::CREATE);
+$archive->addFromString("../dir1/A.txt", $str);
+$archive->addFromString("/var/www/dir2/B", $str);
+$archive->addFromString("a/b/../../../dir3/C.txt.other", $str);
+$archive->addFromString("a/b/../dir4/D.not.a.file/D.a.file", $str);
+$archive->addFromString("a/b/../c/../../d/dir5/E", $str);
+$archive->addFromString("./dir6/F.exe", $str);
+$archive->addFromString("./G.txt", $str);
+$archive->addFromString("H.txt", $str);
+$archive->addFromString("x/y/dir7/../I.txt", $str);
+$archive->addFromString("z/dir8/./J.txt", $str);
+$archive->addFromString("SIMPLE.txt", $str);
+$archive->close();
+$archive2 = new ZipArchive();
+$archive2->open("$dir/a.zip");
+$archive2->extractTo($dir);
+$archive2->close();
+var_dump(file_exists("$dir/dir1/A.txt")); // true
+var_dump(file_exists("../dir1/A.txt")); // false
+var_dump(file_exists("$dir/var/www/dir2/B")); // true
+var_dump(file_exists("/var/www/dir2/B")); // false
+var_dump(file_exists("$dir/dir3/C.txt.other")); // true
+var_dump(file_exists("a/b/../../../dir3/C.txt.other")); // false
+var_dump(file_exists("$dir/a/dir4/D.not.a.file/D.a.file")); // true
+var_dump(file_exists("a/b/../dir4/D.not.a.file/D.a.file")); // false
+var_dump(file_exists("$dir/d/dir5/E")); // true
+var_dump(file_exists("a/b/../c/../../d/dir5/E")); // false
+var_dump(file_exists("$dir/dir6/F.exe")); // true
+var_dump(file_exists("./dir6/F.exe")); // false
+var_dump(file_exists("$dir/G.txt")); // true
+var_dump(file_exists("./G.txt")); // false
+var_dump(file_exists("$dir/H.txt")); // true
+var_dump(file_exists("H.txt")); // false
+var_dump(file_exists("$dir/x/y/I.txt")); // true
+var_dump(file_exists("x/y/dir7/../I.txt")); // false
+var_dump(file_exists("$dir/z/dir8/J.txt")); // true
+var_dump(file_exists("z/dir8/./J.txt")); // false
+var_dump(file_exists("$dir/SIMPLE.txt")); // true
+var_dump(file_exists("SIMPLE.txt")); // false
+
+// Cleanup. Also verifies that everything is where it is supposed to be.
+unlink("$dir/dir1/A.txt");
+rmdir("$dir/dir1");
+unlink("$dir/var/www/dir2/B");
+rmdir("$dir/var/www/dir2");
+rmdir("$dir/var/www");
+rmdir("$dir/var");
+unlink("$dir/dir3/C.txt.other");
+rmdir("$dir/dir3");
+unlink("$dir/a/dir4/D.not.a.file/D.a.file");
+rmdir("$dir/a/dir4/D.not.a.file");
+rmdir("$dir/a/dir4");
+rmdir("$dir/a");
+unlink("$dir/d/dir5/E");
+rmdir("$dir/d/dir5");
+rmdir("$dir/d");
+unlink("$dir/dir6/F.exe");
+rmdir("$dir/dir6");
+unlink("$dir/x/y/I.txt");
+rmdir("$dir/x/y");
+rmdir("$dir/x");
+unlink("$dir/z/dir8/J.txt");
+rmdir("$dir/z/dir8");
+rmdir("$dir/z");
+unlink("$dir/G.txt");
+unlink("$dir/H.txt");
+unlink("$dir/a.zip");
+unlink("$dir/SIMPLE.txt");
+rmdir($dir);

programming_language: Perl
filename: ziparchive_extractto_file.php.expect
change_type: ModificationType.ADD
diff: @@ -0,0 +1,22 @@
+bool(true)
+bool(false)
+bool(true)
+bool(false)
+bool(true)
+bool(false)
+bool(true)
+bool(false)
+bool(true)
+bool(false)
+bool(true)
+bool(false)
+bool(true)
+bool(false)
+bool(true)
+bool(false)
+bool(true)
+bool(false)
+bool(true)
+bool(false)
+bool(true)
+bool(false)

programming_language: Python