CVE-2024-27317 Apache Pulsar

The code is below

/**
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */

/**
 * This class was adapted from NiFi NAR Utils
 * https://github.com/apache/nifi/tree/master/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-nar-utils
 */

package org.apache.pulsar.common.nar;

import com.google.common.annotations.VisibleForTesting;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.RandomAccessFile;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
import java.util.Enumeration;
import java.util.concurrent.ConcurrentHashMap;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import lombok.extern.slf4j.Slf4j;

/**
 * Helper class to unpack NARs.
 */
@Slf4j
public class NarUnpacker {
    private static final ConcurrentHashMap<String, Object> CURRENT_JVM_FILE_LOCKS = new ConcurrentHashMap<>();

    /**
     * Unpacks the specified nar into the specified base working directory.
     *
     * @param nar
     *            the nar to unpack
     * @param baseWorkingDirectory
     *            the directory to unpack to
     * @return the directory to the unpacked NAR
     * @throws IOException
     *             if unable to explode nar
     */
    public static File unpackNar(final File nar, final File baseWorkingDirectory) throws IOException {
        return doUnpackNar(nar, baseWorkingDirectory, null);
    }

    @VisibleForTesting
    static File doUnpackNar(final File nar, final File baseWorkingDirectory, Runnable extractCallback)
            throws IOException {
        File parentDirectory = new File(baseWorkingDirectory, nar.getName() + "-unpacked");
        if (!parentDirectory.exists()) {
            if (parentDirectory.mkdirs()) {
                log.info("Created directory {}", parentDirectory);
            } else if (!parentDirectory.exists()) {
                throw new IOException("Cannot create " + parentDirectory);
            }
        }
        String md5Sum = Base64.getUrlEncoder().withoutPadding().encodeToString(calculateMd5sum(nar));
        // ensure that one process can extract the files
        File lockFile = new File(parentDirectory, "." + md5Sum + ".lock");
        // prevent OverlappingFileLockException by ensuring that one thread tries to create a lock in this JVM
        Object localLock = CURRENT_JVM_FILE_LOCKS.computeIfAbsent(lockFile.getAbsolutePath(), key -> new Object());
        synchronized (localLock) {
            // create file lock that ensures that other processes
            // using the same lock file don't execute concurrently
            try (FileChannel channel = new RandomAccessFile(lockFile, "rw").getChannel();
                 FileLock lock = channel.lock()) {
                File narWorkingDirectory = new File(parentDirectory, md5Sum);
                if (narWorkingDirectory.mkdir()) {
                    try {
                        log.info("Extracting {} to {}", nar, narWorkingDirectory);
                        if (extractCallback != null) {
                            extractCallback.run();
                        }
                        unpack(nar, narWorkingDirectory);
                    } catch (IOException e) {
                        log.error("There was a problem extracting the nar file. Deleting {} to clean up state.",
                                narWorkingDirectory, e);
                        FileUtils.deleteFile(narWorkingDirectory, true);
                        throw e;
                    }
                }
                return narWorkingDirectory;
            }
        }
    }

    /**
     * Unpacks the NAR to the specified directory.
     *
     * @param workingDirectory
     *            the root directory to which the NAR should be unpacked.
     * @throws IOException
     *             if the NAR could not be unpacked.
     */
    private static void unpack(final File nar, final File workingDirectory) throws IOException {
        try (JarFile jarFile = new JarFile(nar)) {
            Enumeration<JarEntry> jarEntries = jarFile.entries();
            while (jarEntries.hasMoreElements()) {
                JarEntry jarEntry = jarEntries.nextElement();
                String name = jarEntry.getName();
                File f = new File(workingDirectory, name);
                if (jarEntry.isDirectory()) {
                    FileUtils.ensureDirectoryExistAndCanReadAndWrite(f);
                } else {
                    makeFile(jarFile.getInputStream(jarEntry), f);
                }
            }
        }
    }

    /**
     * Creates the specified file, whose contents will come from the <tt>InputStream</tt>.
     *
     * @param inputStream
     *            the contents of the file to create.
     * @param file
     *            the file to create.
     * @throws IOException
     *             if the file could not be created.
     */
    private static void makeFile(final InputStream inputStream, final File file) throws IOException {
        try (final InputStream in = inputStream; final FileOutputStream fos = new FileOutputStream(file)) {
            byte[] bytes = new byte[65536];
            int numRead;
            while ((numRead = in.read(bytes)) != -1) {
                fos.write(bytes, 0, numRead);
            }
        }
    }

    /**
     * Calculates an md5 sum of the specified file.
     *
     * @param file
     *            to calculate the md5sum of
     * @return the md5sum bytes
     * @throws IOException
     *             if cannot read file
     */
    private static byte[] calculateMd5sum(final File file) throws IOException {
        try (final FileInputStream inputStream = new FileInputStream(file)) {
            final MessageDigest md5 = MessageDigest.getInstance("md5");

            final byte[] buffer = new byte[1024];
            int read = inputStream.read(buffer);

            while (read > -1) {
                md5.update(buffer, 0, read);
                read = inputStream.read(buffer);
            }

            return md5.digest();
        } catch (NoSuchAlgorithmException nsae) {
            throw new IllegalArgumentException(nsae);
        }
    }
}

The Diff:

--- a/pulsar-common/src/main/java/org/apache/pulsar/common/nar/NarUnpacker.java
+++ b/pulsar-common/src/main/java/org/apache/pulsar/common/nar/NarUnpacker.java
@@ -33,13 +33,14 @@ import java.io.InputStream;
 import java.io.RandomAccessFile;
 import java.nio.channels.FileChannel;
 import java.nio.channels.FileLock;
+import java.nio.file.Path;
 import java.security.MessageDigest;
 import java.security.NoSuchAlgorithmException;
 import java.util.Base64;
 import java.util.Enumeration;
 import java.util.concurrent.ConcurrentHashMap;
-import java.util.jar.JarEntry;
-import java.util.jar.JarFile;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipFile;
 import lombok.extern.slf4j.Slf4j;
 
 /**
@@ -114,16 +115,22 @@ public class NarUnpacker {
      *             if the NAR could not be unpacked.
      */
     private static void unpack(final File nar, final File workingDirectory) throws IOException {
-        try (JarFile jarFile = new JarFile(nar)) {
-            Enumeration<JarEntry> jarEntries = jarFile.entries();
-            while (jarEntries.hasMoreElements()) {
-                JarEntry jarEntry = jarEntries.nextElement();
-                String name = jarEntry.getName();
-                File f = new File(workingDirectory, name);
-                if (jarEntry.isDirectory()) {
+        Path workingDirectoryPath = workingDirectory.toPath().normalize();
+        try (ZipFile zipFile = new ZipFile(nar)) {
+            Enumeration<? extends ZipEntry> zipEntries = zipFile.entries();
+            while (zipEntries.hasMoreElements()) {
+                ZipEntry zipEntry = zipEntries.nextElement();
+                String name = zipEntry.getName();
+                Path targetFilePath = workingDirectoryPath.resolve(name).normalize();
+                if (!targetFilePath.startsWith(workingDirectoryPath)) {
+                    log.error("Invalid zip file with entry '{}'", name);
+                    throw new IOException("Invalid zip file. Aborting unpacking.");
+                }
+                File f = targetFilePath.toFile();
+                if (zipEntry.isDirectory()) {
                     FileUtils.ensureDirectoryExistAndCanReadAndWrite(f);
                 } else {
-                    makeFile(jarFile.getInputStream(jarEntry), f);
+                    makeFile(zipFile.getInputStream(zipEntry), f);
                 }
             }
         }

Walkthrough:

Original Unpack

Take a look what we have here:

try (JarFile jarFile = new JarFile(nar)) {
    Enumeration<JarEntry> jarEntries = jarFile.entries();
    while (jarEntries.hasMoreElements()) {
        JarEntry jarEntry = jarEntries.nextElement();
        String name = jarEntry.getName();
        // Iterating Through each element
        File f = new File(workingDirectory, name);
        if (jarEntry.isDirectory()) {
            FileUtils.ensureDirectoryExistAndCanReadAndWrite(f);
        } else {
            makeFile(jarFile.getInputStream(jarEntry), f);
        }
    }
}
  • It iterates through every entry in the .nar (basically a .jar/.zip) file.

  • For each entry, it just did new File(workingDirectory, name).

  • It then created/extracted that file or directory.

⚠️ Vulnerability: Zip Slip (aka directory traversal). If name looked like ../../../../etc/passwd, then new File(workingDirectory, name) would happily point outside of workingDirectory. That means a malicious .nar could overwrite arbitrary files on disk.

Prevention

The fix switched from JarFile to ZipFile (similar API, broader use). But the real defense is here:

Path workingDirectoryPath = workingDirectory.toPath().normalize();
Path targetFilePath = workingDirectoryPath.resolve(name).normalize();

if (!targetFilePath.startsWith(workingDirectoryPath)) {
    log.error("Invalid zip file with entry '{}'", name);
    throw new IOException("Invalid zip file. Aborting unpacking.");
}
  • Normalization: .normalize() removes ../ and ./ from a path (like resolving foo/../barbar).

  • Containment check: Ensures every extracted file stays inside the intended workingDirectory.

    • If the attacker tries ../../etc/passwd, after normalization it won’t start with workingDirectoryPath, so extraction aborts.

How to Spot

Now the question comes how to spot and read such codes


🔹 Step 1: High-level structure (what is this class for?)

  • Class name: NarUnpacker

  • Purpose: unpack a .nar file (Pulsar’s plugin packaging format, basically a jar/zip) into a working directory.

  • Main entry point: unpackNar(File nar, File baseWorkingDirectory) → calls doUnpackNar(...).

So: this class takes an archive file, locks it, extracts it safely into a directory.


🔹 Step 2: Break down into logical chunks

Look for method boundaries and doc comments:

  • unpackNar() → Public API.

  • doUnpackNar() → Real work (locking + extraction).

  • unpack() → Iterate through archive entries.

  • makeFile() → Write extracted file.

  • calculateMd5sum() → Compute checksum for directory naming/locking.

So there are only 5 real methods. That’s manageable.


🔹 Step 3: Identify external interactions

When reviewing for security, you care about inputs and outputs:

  • Inputs: nar file, directory paths, archive entry names.

  • Outputs: extracted files, directories, logs.

  • External APIs:

    • File system (File, RandomAccessFile, FileChannel, FileOutputStream)

    • Cryptography (MessageDigest)

    • Concurrency (ConcurrentHashMap)

    • Archive parsing (JarFile, JarEntry)

Each external boundary is where bugs/security issues creep in.


🔹 Step 4: Do a “first-pass” review (security smells)

Now scan for common dangerous patterns:

  1. File handling: new File(workingDirectory, name) → 🚨 smells like possible path traversal.

  2. Concurrency/locking: Using ConcurrentHashMap + FileLock → okay, but check for race conditions.

  3. Cryptography: Using MD5 → 🚨 insecure if used for auth/crypto, but here it’s just for a directory lock name (not a bug, but worth noting).

  4. Streams: They close resources with try-with-resources ✅ (good).

  5. Error handling: They log and clean up ✅.

So in first pass, the biggest risk you’d highlight: archive entry paths are trustedZip Slip.

Lessons for Java Secure Code Review

When reviewing similar code, here are the red flags and fix patterns you should apply:

Check how file paths are constructed.

  • If you see new File(base, entryName), ask: What if entryName has ../?

  • Ensure Path.resolve().normalize() and boundary checks are used.

Always enforce extraction boundaries.

  • Verify target.startsWith(base) after normalization.

Prefer ZipFile over JarFile when unpacking archives.

  • ZipFile gives better control and is the general-purpose class.

Log and abort on malicious input.

  • Don’t silently skip invalid entries; fail early.

Security smells in the old code:

  • No validation on entry names.

  • Trusting archive contents blindly.

Last updated