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:
Walkthrough:
Original Unpack
Take a look what we have here:
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:
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:
NarUnpackerPurpose: unpack a
.narfile (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:
narfile, 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 ifentryNamehas../?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.
ZipFilegives 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