CVE-2018-10897: Improper Link Resolution Before File Access ('Link Following') (CWE-59)
value: A directory traversal issue was found in reposync, a part of yum-utils, where reposync fails to sanitize paths in remote repository configuration files. If an attacker controls a repository, they may be able to copy files outside of the destination directory on the targeted system via path traversal. If reposync is running with heightened privileges on a targeted system, this flaw could potentially result in system compromise via the overwriting of critical system files. Version 1.1.31 and older are believed to be affected.
CWE description: The product attempts to access a file based on the filename, but it does not properly prevent that filename from identifying a link or shortcut that resolves to an unintended resource.
CVSS v3 base score: 8.1
Published: 2018-08-01T17:29Z
Repository: https://github.com/rpm-software-management/yum-utils
Language: Other
Files changed: docs/reposync.1, reposync.py
Commit date: 2018-07-20 11:00:40 +0200
Commit message: reposync: prevent path traversal. BZ 1552328

Diff: diff --git a/docs/reposync.1 b/docs/reposync.1
index 5a5b092..580847e 100644
--- a/docs/reposync.1
+++ b/docs/reposync.1
@@ -45,10 +45,20 @@ Also download comps.xml.
 Download all the non-default metadata
 .IP "\fB\-n, \-\-newest\-only\fP"
 Download only newest packages per-repo.
 .IP "\fB\-q, \-\-quiet\fP"
 Output as little information as possible.
+.IP "\fB\-\-allow-path-traversal\fP"
+Allow packages stored outside their repo directory to be synced.
+These are packages that are referenced in metadata by using absolute paths or
+up-level ".." symbols, and are normally skipped by \fBreposync\fR for security
+reasons.
+
+\fBCAUTION:\fR Using this option has potential security implications since, by
+providing malicious repodata, an attacker could make \fBreposync\fR write to
+arbitrary locations on the file system that are accessible by the user running
+it.
 .SH "EXAMPLES"
 .IP "Sync all packages from the 'updates' repo to the current directory:"
 \fB reposync \-\-repoid=updates\fP
 .IP "Sync only the newest packages from the 'updates' repo to the current directory:"
 \fB reposync \-n \-\-repoid=updates\fP
diff --git a/reposync.py b/reposync.py
index 6b65116..b3d091d 100755
--- a/reposync.py
+++ b/reposync.py
@@ -72,10 +72,16 @@ def localpkgs(directory):
                 cache[pkg] = subcache[pkg]
         elif stat.S_ISREG(st.st_mode) and name.endswith(".rpm"):
             cache[name] = {'path': fn, 'size': st.st_size, 'device': st.st_dev}
     return cache
 
+def is_subpath(path, root):
+    root = os.path.realpath(root)
+    path = os.path.realpath(os.path.join(root, path))
+    # join() is used below to ensure root ends with a slash
+    return path.startswith(os.path.join(root, ''))
+
 def parseArgs():
     usage = _("""
     Reposync is used to synchronize a remote yum repository to a local 
     directory using yum to retrieve the packages.
     
@@ -114,10 +120,14 @@ def parseArgs():
     parser.add_option("-m", "--downloadcomps", default=False, action="store_true",
         help=_("also download comps.xml"))
     parser.add_option("", "--download-metadata", dest="downloadmd",
         default=False, action="store_true",
         help=_("download all the non-default metadata"))
+    parser.add_option("", "--allow-path-traversal", default=False,
+        action="store_true",
+        help=_("Allow packages stored outside their repo directory to be synced "
+               "(UNSAFE, USE WITH CAUTION!)"))
     (opts, args) = parser.parse_args()
     return (opts, args)
 
 
 def main():
@@ -214,10 +224,34 @@ def main():
         if opts.norepopath:
             local_repo_path = opts.destdir
         else:
             local_repo_path = opts.destdir + '/' + repo.id
 
+        # Ensure we don't traverse out of local_repo_path by dropping any
+        # packages whose remote_path is absolute or contains up-level
+        # references (unless explicitly allowed).
+        # See RHBZ#1600221 for details.
+        if not opts.allow_path_traversal:
+            newlist = []
+            skipped = False
+            for pkg in download_list:
+                if is_subpath(pkg.remote_path, local_repo_path):
+                    newlist.append(pkg)
+                    continue
+                my.logger.warning(
+                    _('WARNING: skipping package %s: remote path "%s" not '
+                      'within repodir, unsafe to mirror locally')
+                    % (pkg, pkg.remote_path)
+                )
+                skipped = True
+            if skipped:
+                my.logger.info(
+                    _('You can enable unsafe remote paths by using '
+                      '--allow-path-traversal (see reposync(1) for details)')
+                )
+            download_list = newlist
+
         if opts.delete and os.path.exists(local_repo_path):
             current_pkgs = localpkgs(local_repo_path)
 
             download_set = {}
             for pkg in download_list:

Fixed code: .IP "\fB\-\-allow-path-traversal\fP"
Allow packages stored outside their repo directory to be synced.
These are packages that are referenced in metadata by using absolute paths or
up-level ".." symbols, and are normally skipped by \fBreposync\fR for security
reasons.

\fBCAUTION:\fR Using this option has potential security implications since, by
providing malicious repodata, an attacker could make \fBreposync\fR write to
arbitrary locations on the file system that are accessible by the user running
it.
def is_subpath(path, root):
    root = os.path.realpath(root)
    path = os.path.realpath(os.path.join(root, path))
    # join() is used below to ensure root ends with a slash
    return path.startswith(os.path.join(root, ''))

    parser.add_option("", "--allow-path-traversal", default=False,
        action="store_true",
        help=_("Allow packages stored outside their repo directory to be synced "
               "(UNSAFE, USE WITH CAUTION!)"))
        # Ensure we don't traverse out of local_repo_path by dropping any
        # packages whose remote_path is absolute or contains up-level
        # references (unless explicitly allowed).
        # See RHBZ#1600221 for details.
        if not opts.allow_path_traversal:
            newlist = []
            skipped = False
            for pkg in download_list:
                if is_subpath(pkg.remote_path, local_repo_path):
                    newlist.append(pkg)
                    continue
                my.logger.warning(
                    _('WARNING: skipping package %s: remote path "%s" not '
                      'within repodir, unsafe to mirror locally')
                    % (pkg, pkg.remote_path)
                )
                skipped = True
            if skipped:
                my.logger.info(
                    _('You can enable unsafe remote paths by using '
                      '--allow-path-traversal (see reposync(1) for details)')
                )
            download_list = newlist