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];