CVE-2022-39367: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal') (CWE-22)
value: QTIWorks is a software suite for standards-based assessment delivery. Prior to version 1.0-beta15, the QTIWorks Engine allows users to upload QTI content packages as ZIP files. The ZIP handling code does not sufficiently check the paths of files contained within ZIP files, so can insert files into other locations in the filesystem if they are writable by the process running the QTIWorks Engine. In extreme cases, this could allow anonymous users to change files in arbitrary locations in the filesystem. In normal QTIWorks Engine deployments, the impact is somewhat reduced because the default QTIWorks configuration does not enable the public demo functionality, so ZIP files can only be uploaded by users with "instructor" privileges. This vulnerability is fixed in version 1.0-beta15. There are no database configuration changes required when upgrading to this version. No known workarounds for this issue exist.
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: 6.5
Published: 2022-10-28T16:15Z
Repository: https://github.com/davemckain/qtiworks
Language: Java
Files changed: qtiworks-engine/src/main/java/uk/ac/ed/ph/qtiworks/services/AssessmentPackageFileImporter.java
Commit date: 2022-09-23 03:48:36 +0000
Commit message: vuln-fix: Zip Slip Vulnerability

This fixes a Zip-Slip vulnerability.

This change does one of two things. This change either

1. Inserts a guard to protect against Zip Slip.
OR
2. Replaces `dir.getCanonicalPath().startsWith(parent.getCanonicalPath())`, which is vulnerable to partial path traversal attacks, with the more secure `dir.getCanonicalFile().toPath().startsWith(parent.getCanonicalFile().toPath())`.

For number 2, consider `"/usr/outnot".startsWith("/usr/out")`.
The check is bypassed although `/outnot` is not under the `/out` directory.
It's important to understand that the terminating slash may be removed when using various `String` representations of the `File` object.
For example, on Linux, `println(new File("/var"))` will print `/var`, but `println(new File("/var", "/")` will print `/var/`;
however, `println(new File("/var", "/").getCanonicalPath())` will print `/var`.

Weakness: CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
Severity: High
CVSSS: 7.4
Detection: CodeQL (https://codeql.github.com/codeql-query-help/java/java-zipslip/) & OpenRewrite (https://public.moderne.io/recipes/org.openrewrite.java.security.ZipSlip)

Reported-by: Jonathan Leitschuh <Jonathan.Leitschuh@gmail.com>
Signed-off-by: Jonathan Leitschuh <Jonathan.Leitschuh@gmail.com>

Bug-tracker: https://github.com/JLLeitschuh/security-research/issues/16

Co-authored-by: Moderne <team@moderne.io>
Diff: diff --git a/qtiworks-engine/src/main/java/uk/ac/ed/ph/qtiworks/services/AssessmentPackageFileImporter.java b/qtiworks-engine/src/main/java/uk/ac/ed/ph/qtiworks/services/AssessmentPackageFileImporter.java
index 09cf3e24..8bc2da7c 100644
--- a/qtiworks-engine/src/main/java/uk/ac/ed/ph/qtiworks/services/AssessmentPackageFileImporter.java
+++ b/qtiworks-engine/src/main/java/uk/ac/ed/ph/qtiworks/services/AssessmentPackageFileImporter.java
@@ -158,10 +158,13 @@ public class AssessmentPackageFileImporter {
             inputStream = ServiceUtilities.ensureInputSream(multipartFile);
             zipInputStream = new ZipInputStream(inputStream);
             while ((zipEntry = zipInputStream.getNextEntry()) != null) {
                 foundEntry = true;
                 final File destFile = new File(importSandboxDirectory, zipEntry.getName());
+                if (!destFile.toPath().normalize().startsWith(importSandboxDirectory.toPath().normalize())) {
+                    throw new RuntimeException("Bad zip entry");
+                }
                 if (!zipEntry.isDirectory()) {
                     ServiceUtilities.ensureFileCreated(destFile);
                     final FileOutputStream destOutputStream = new FileOutputStream(destFile);
                     try {
                         ByteStreams.copy(zipInputStream, destOutputStream);

Fixed code:                 if (!destFile.toPath().normalize().startsWith(importSandboxDirectory.toPath().normalize())) {
                    throw new RuntimeException("Bad zip entry");
                }