Commit a17811f35 for clamav.net
commit a17811f357487f136c91ed6f5da38827c0b7a70c
Author: Valerie Snyder <valsnyde@cisco.com>
Date: Wed Aug 5 15:22:14 2026 -0400
Support safe quarantine removal on FreeBSD
The CLAM-2959 quarantine hardening requires an atomic way to prove that a
pathname still identifies the source retained during scanning before removing
it. FreeBSD lacks the no-replace rename primitive used by the generic POSIX
capture path on supported releases, causing --move and --remove to fail with
ENOTSUP.
Detect funlinkat() and use it with the retained scan descriptor after securely
traversing to the source parent. FreeBSD atomically checks that the basename
still names the retained descriptor before unlinking it. Normalize EDEADLK to
EAGAIN so replacement races follow the existing action-failure behavior.
Resolve descriptor paths through F_KINFO so FreeBSD symlink submissions retain
the real scanned source path. Other platform implementations and the
conservative unsupported-POSIX fallback remain unchanged.
Reported-by: Hiroki Imai from Ricerca Security, Inc.
CLAM-2959
diff --git a/CMakeLists.txt b/CMakeLists.txt
index eddbd5de8..ff60833fa 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -864,6 +864,7 @@ if(WIN32)
set(HAVE_MKSTEMP 1)
set(HAVE_POLL 1)
else()
+ check_symbol_exists(funlinkat "unistd.h" HAVE_FUNLINKAT)
check_symbol_exists(fseeko "stdio.h" HAVE_FSEEKO)
check_symbol_exists(getaddrinfo "netdb.h" HAVE_GETADDRINFO)
check_symbol_exists(getpagesize "unistd.h" HAVE_GETPAGESIZE)
diff --git a/clamav-config.h.cmake.in b/clamav-config.h.cmake.in
index 1c75629ac..ee44a74a2 100644
--- a/clamav-config.h.cmake.in
+++ b/clamav-config.h.cmake.in
@@ -101,6 +101,9 @@
/* use "Cache-Control: no-cache" in freshclam */
#cmakedefine FRESHCLAM_NO_CACHE 1
+/* Have funlinkat */
+#cmakedefine HAVE_FUNLINKAT 1
+
/* attrib aligned */
#cmakedefine HAVE_ATTRIB_ALIGNED 1
diff --git a/common/actions.c b/common/actions.c
index da70b32df..fd5921314 100644
--- a/common/actions.c
+++ b/common/actions.c
@@ -437,6 +437,19 @@ static int action_unlinkat_nointr(int dirfd, const char *path, int flags)
return rc;
}
+#if defined(__FreeBSD__) && defined(HAVE_FUNLINKAT)
+static int action_funlinkat_nointr(int dirfd, const char *path, int fd, int flags)
+{
+ int rc;
+
+ do {
+ rc = funlinkat(dirfd, path, fd, flags);
+ } while ((rc < 0) && (EINTR == errno));
+
+ return rc;
+}
+#endif
+
#ifndef _WIN32
static int action_fstatat_nointr(int dirfd, const char *path, STATBUF *st, int flags)
{
@@ -3313,12 +3326,16 @@ static int action_restore_captured_unlink_target(
* This approach mitigates the possibility that one of the directories
* in the path has been replaced with a malicious symlink.
*
- * @param target A file to be deleted.
- * @return 0 Unlink succeeded.
- * @return -1 Unlink failed.
+ * @param target A file to be deleted.
+ * @param source_fd POSIX descriptor for the scanned source.
+ * @param expected_stat POSIX metadata for the scanned source.
+ * @param target_file_handle Windows handle for the scanned source.
+ * @param target_file_handle_can_delete Whether the Windows handle has delete access.
+ * @return 0 Unlink succeeded.
+ * @return -1 Unlink failed.
*/
#ifndef _WIN32
-static int traverse_unlink(const char *target, const STATBUF *expected_stat)
+static int traverse_unlink(const char *target, int source_fd, const STATBUF *expected_stat)
#else
static int traverse_unlink(
const char *target,
@@ -3347,6 +3364,10 @@ static int traverse_unlink(
goto done;
}
+#if !defined(_WIN32) && (!defined(__FreeBSD__) || !defined(HAVE_FUNLINKAT))
+ UNUSEDPARAM(source_fd);
+#endif
+
#ifndef _WIN32
/* On posix, we want a file descriptor for the directory */
if (0 != traverse_to(target, true, &target_directory_fd)) {
@@ -3382,6 +3403,30 @@ static int traverse_unlink(
goto done;
}
} else {
+#if defined(__FreeBSD__) && defined(HAVE_FUNLINKAT)
+ if (source_fd >= 0) {
+ /*
+ * FreeBSD verifies atomically that target_basename still names
+ * source_fd before unlinking it. This avoids the private capture
+ * and no-replace restore required on other POSIX platforms.
+ */
+ if (0 != action_funlinkat_nointr(target_directory_fd, target_basename, source_fd, 0)) {
+ int unlink_errno = errno;
+
+ if (EDEADLK == unlink_errno) {
+ logg(LOGG_INFO, "traverse_unlink: Refusing to unlink '%s' because the source changed after validation.\n", target);
+ errno = EAGAIN;
+ } else {
+ logg(LOGG_INFO, "traverse_unlink: Failed to unlink '%s' through its opened descriptor: %s\n", target, strerror(unlink_errno));
+ errno = unlink_errno;
+ }
+ goto done;
+ }
+
+ status = 0;
+ goto done;
+ }
+#endif
if (0 != action_create_private_unlink_dir(
target_directory_fd,
private_directory_name,
@@ -3541,7 +3586,7 @@ static void action_move(const action_source_t *source)
notmoved++;
goto done;
}
- if (0 != traverse_unlink(action_filename, &source_stat)) {
+ if (0 != traverse_unlink(action_filename, source->scan_fd, &source_stat)) {
int unlink_errno = errno;
if (show_action_path) {
logg(LOGG_ERROR, "Can't unlink '%s' (real path: '%s') after linking into quarantine: %s\n", filename, action_filename, strerror(unlink_errno));
@@ -3613,7 +3658,7 @@ static void action_move(const action_source_t *source)
goto done;
}
#ifndef _WIN32
- if (0 != traverse_unlink(action_filename, &source_stat)) {
+ if (0 != traverse_unlink(action_filename, source->scan_fd, &source_stat)) {
if (show_action_path) {
logg(LOGG_ERROR, "Can't unlink '%s' (real path: '%s') after copy: %s\n", filename, action_filename, strerror(errno));
} else {
@@ -3742,7 +3787,7 @@ static void action_remove(const action_source_t *source)
#ifndef _WIN32
if ((false == source->has_stat) ||
!S_ISREG(source->statbuf.st_mode) ||
- (0 != traverse_unlink(action_filename, &source->statbuf))) {
+ (0 != traverse_unlink(action_filename, source->scan_fd, &source->statbuf))) {
#else
if (0 != traverse_unlink(
action_filename,
diff --git a/libclamav/others.h b/libclamav/others.h
index c02667945..cefcc1a19 100644
--- a/libclamav/others.h
+++ b/libclamav/others.h
@@ -1231,7 +1231,7 @@ cl_error_t cli_get_filepath_from_handle(HANDLE hFile, char **filepath);
* @brief Attempt to get a filename from an open file descriptor.
*
* Caller is responsible for free'ing the filename.
- * Should work on Linux, macOS, Windows.
+ * Should work on Linux, FreeBSD, macOS, Windows.
*
* @param desc File descriptor
* @param[out] filepath Will be set to file path if found, or NULL.
diff --git a/libclamav/others_common.c b/libclamav/others_common.c
index 7d231772d..8f4cbeef8 100644
--- a/libclamav/others_common.c
+++ b/libclamav/others_common.c
@@ -41,6 +41,9 @@
#endif
#include <time.h>
#include <fcntl.h>
+#ifdef __FreeBSD__
+#include <sys/user.h>
+#endif
#ifdef HAVE_PWD_H
#include <pwd.h>
#endif
@@ -1493,6 +1496,31 @@ cl_error_t cli_get_filepath_from_filedesc(int desc, char **filepath)
goto done;
}
+#elif defined(__FreeBSD__)
+
+ struct kinfo_file file_info;
+
+ if (NULL == filepath) {
+ cli_errmsg("cli_get_filepath_from_filedesc: Invalid args.\n");
+ goto done;
+ }
+
+ memset(&file_info, 0, sizeof(file_info));
+ file_info.kf_structsize = sizeof(file_info);
+
+ if ((fcntl(desc, F_KINFO, &file_info) < 0) || ('\0' == file_info.kf_path[0])) {
+ cli_dbgmsg("cli_get_filepath_from_filedesc: Failed to resolve filename for descriptor %d\n", desc);
+ status = CL_EOPEN;
+ goto done;
+ }
+
+ evaluated_filepath = CLI_STRNDUP(file_info.kf_path, CLI_STRNLEN(file_info.kf_path, sizeof(file_info.kf_path)));
+ if (NULL == evaluated_filepath) {
+ cli_errmsg("cli_get_filepath_from_filedesc: Failed to allocate memory to store filename\n");
+ status = CL_EMEM;
+ goto done;
+ }
+
#elif C_DARWIN
char fname[PATH_MAX];