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 resolvingfoo/../bar
→bar
).Containment check: Ensures every extracted file stays inside the intended
workingDirectory
.If the attacker tries
../../etc/passwd
, after normalization it won’t start withworkingDirectoryPath
, 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 ajar/zip
) into a working directory.Main entry point:
unpackNar(File nar, File baseWorkingDirectory)
→ callsdoUnpackNar(...)
.
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:
File handling:
new File(workingDirectory, name)
→ 🚨 smells like possible path traversal.Concurrency/locking: Using
ConcurrentHashMap
+FileLock
→ okay, but check for race conditions.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).
Streams: They close resources with
try-with-resources
✅ (good).Error handling: They log and clean up ✅.
So in first pass, the biggest risk you’d highlight: archive entry paths are trusted → Zip 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 ifentryName
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