CVE-2018-1000079: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal') (CWE-22)
value: RubyGems version Ruby 2.2 series: 2.2.9 and earlier, Ruby 2.3 series: 2.3.6 and earlier, Ruby 2.4 series: 2.4.3 and earlier, Ruby 2.5 series: 2.5.0 and earlier, prior to trunk revision 62422 contains a Directory Traversal vulnerability in gem installation that can result in the gem could write to arbitrary filesystem locations during installation. This attack appear to be exploitable via the victim must install a malicious gem. This vulnerability appears to have been fixed in 2.7.6.
CWE 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.
CVSS v3 base score: 5.5
Published: 2018-03-13T15:29Z
Repository: https://github.com/rubygems/rubygems
Language: Ruby
Files changed: lib/rubygems/package.rb, test/rubygems/test_gem_package.rb
Commit date: 2018-02-15 22:19:04 -0800
Commit message: Implement a safe mkdir for package that verifies were inside the destination dir for all new directories ccreated

Diff: diff --git a/lib/rubygems/package.rb b/lib/rubygems/package.rb
index cb9c74a0fc..b924122827 100644
--- a/lib/rubygems/package.rb
+++ b/lib/rubygems/package.rb
@@ -376,11 +376,11 @@ def extract_tar_gz io, destination_dir, pattern = "*" # :nodoc:
             destination
           else
             File.dirname destination
           end
 
-        FileUtils.mkdir_p mkdir, mkdir_options
+        mkdir_p_safe mkdir, mkdir_options, destination_dir, entry.full_name
 
         File.open destination, 'wb' do |out|
           out.write entry.read
           FileUtils.chmod entry.header.mode, destination
         end if entry.file?
@@ -414,26 +414,39 @@ def gzip_to io # :yields: gz_io
 
   def install_location filename, destination_dir # :nodoc:
     raise Gem::Package::PathError.new(filename, destination_dir) if
       filename.start_with? '/'
 
-    destination_dir = File.realpath destination_dir if
-      File.respond_to? :realpath
+    destination_dir = realpath destination_dir
     destination_dir = File.expand_path destination_dir
 
     destination = File.join destination_dir, filename
-    destination = File.realpath destination if
-      File.respond_to? :realpath
     destination = File.expand_path destination
 
     raise Gem::Package::PathError.new(destination, destination_dir) unless
       destination.start_with? destination_dir + '/'
 
     destination.untaint
     destination
   end
 
+  def mkdir_p_safe mkdir, mkdir_options, destination_dir, file_name
+    destination_dir = realpath File.expand_path(destination_dir)
+    parts = mkdir.split(File::SEPARATOR)
+    parts.reduce do |path, basename|
+      path = realpath path  unless path == ""
+      path = File.expand_path(path + File::SEPARATOR + basename)
+      lstat = File.lstat path rescue nil
+      if !lstat || !lstat.directory?
+        unless path.start_with? destination_dir and (FileUtils.mkdir path, mkdir_options rescue false)
+          raise Gem::Package::PathError.new(file_name, destination_dir)
+        end
+      end
+      path
+    end
+  end
+
   ##
   # Loads a Gem::Specification from the TarEntry +entry+
 
   def load_spec entry # :nodoc:
     case entry.full_name
@@ -620,10 +633,20 @@ def verify_gz entry # :nodoc:
     end
   rescue Zlib::GzipFile::Error => e
     raise Gem::Package::FormatError.new(e.message, entry.full_name)
   end
 
+  if File.respond_to? :realpath
+    def realpath file
+      File.realpath file
+    end
+  else
+    def realpath file
+      file
+    end
+  end
+
 end
 
 require 'rubygems/package/digest_io'
 require 'rubygems/package/source'
 require 'rubygems/package/file_source'
diff --git a/test/rubygems/test_gem_package.rb b/test/rubygems/test_gem_package.rb
index e9bd9e2755..d1664cf285 100644
--- a/test/rubygems/test_gem_package.rb
+++ b/test/rubygems/test_gem_package.rb
@@ -474,11 +474,11 @@ def test_extract_symlink_parent
 
    e = assert_raises Gem::Package::PathError do
      package.extract_tar_gz tgz_io, destination_subdir
    end
 
-   assert_equal("installing into parent path ../outside.txt of " +
+   assert_equal("installing into parent path lib/link/outside.txt of " +
                  "#{destination_subdir} is not allowed", e.message)
   end
 
   def test_extract_tar_gz_directory
     package = Gem::Package.new @gem

Vulnerable code:         FileUtils.mkdir_p mkdir, mkdir_options
    destination_dir = File.realpath destination_dir if
      File.respond_to? :realpath
    destination = File.realpath destination if
      File.respond_to? :realpath
   assert_equal("installing into parent path ../outside.txt of " +
Fixed code:         mkdir_p_safe mkdir, mkdir_options, destination_dir, entry.full_name
    destination_dir = realpath destination_dir
  def mkdir_p_safe mkdir, mkdir_options, destination_dir, file_name
    destination_dir = realpath File.expand_path(destination_dir)
    parts = mkdir.split(File::SEPARATOR)
    parts.reduce do |path, basename|
      path = realpath path  unless path == ""
      path = File.expand_path(path + File::SEPARATOR + basename)
      lstat = File.lstat path rescue nil
      if !lstat || !lstat.directory?
        unless path.start_with? destination_dir and (FileUtils.mkdir path, mkdir_options rescue false)
          raise Gem::Package::PathError.new(file_name, destination_dir)
        end
      end
      path
    end
  end

  if File.respond_to? :realpath
    def realpath file
      File.realpath file
    end
  else
    def realpath file
      file
    end
  end

   assert_equal("installing into parent path lib/link/outside.txt of " +