diff --git a/fsdev/virtfs-proxy-helper.c b/fsdev/virtfs-proxy-helper.c
index 5cafcd7703..f311519fa3 100644
--- a/fsdev/virtfs-proxy-helper.c
+++ b/fsdev/virtfs-proxy-helper.c
@@ -26,6 +26,7 @@
 #include "qemu/xattr.h"
 #include "9p-iov-marshal.h"
 #include "hw/9pfs/9p-proxy.h"
+#include "hw/9pfs/9p-util.h"
 #include "fsdev/9p-iov-marshal.h"
 
 #define PROGNAME "virtfs-proxy-helper"
@@ -338,6 +339,49 @@ static void resetugid(int suid, int sgid)
     }
 }
 
+/*
+ * Open regular file or directory. Attempts to open any special file are
+ * rejected.
+ *
+ * returns file descriptor or -1 on error
+ */
+static int open_regular(const char *pathname, int flags, mode_t mode) {
+    int fd;
+    struct stat stbuf;
+
+    fd = open(pathname, flags, mode);
+    if (fd < 0) {
+        return fd;
+    }
+
+    /* CVE-2023-2861: Prohibit opening any special file directly on host
+     * (especially device files), as a compromised client could potentially
+     * gain access outside exported tree under certain, unsafe setups. We
+     * expect client to handle I/O on special files exclusively on guest side.
+     */
+    if (qemu_fstat(fd, &stbuf) < 0) {
+        close_preserve_errno(fd);
+        return -1;
+    }
+    if (!S_ISREG(stbuf.st_mode) && !S_ISDIR(stbuf.st_mode)) {
+        /* Tcreate and Tlcreate 9p messages mandate to immediately open the
+         * created file for I/O. So this is not (necessarily) due to a broken
+         * client, and hence no error message is to be reported in this case.
+         */
+        if (!(flags & O_CREAT)) {
+            error_report_once(
+                "9p: broken or compromised client detected; attempt to open "
+                "special file (i.e. neither regular file, nor directory)"
+            );
+        }
+        close(fd);
+        errno = ENXIO;
+        return -1;
+    }
+
+    return fd;
+}
+
 /*
  * send response in two parts
  * 1) ProxyHeader
@@ -682,7 +726,7 @@ static int do_create(struct iovec *iovec)
     if (ret < 0) {
         goto unmarshal_err_out;
     }
-    ret = open(path.data, flags, mode);
+    ret = open_regular(path.data, flags, mode);
     if (ret < 0) {
         ret = -errno;
     }
@@ -707,7 +751,7 @@ static int do_open(struct iovec *iovec)
     if (ret < 0) {
         goto err_out;
     }
-    ret = open(path.data, flags);
+    ret = open_regular(path.data, flags, 0);
     if (ret < 0) {
         ret = -errno;
     }
diff --git a/hw/9pfs/9p-util.h b/hw/9pfs/9p-util.h
index c314cf381d..9da1a0538d 100644
--- a/hw/9pfs/9p-util.h
+++ b/hw/9pfs/9p-util.h
@@ -13,6 +13,8 @@
 #ifndef QEMU_9P_UTIL_H
 #define QEMU_9P_UTIL_H
 
+#include "qemu/error-report.h"
+
 #ifdef O_PATH
 #define O_PATH_9P_UTIL O_PATH
 #else
@@ -95,6 +97,7 @@ static inline int errno_to_dotl(int err) {
 #endif
 
 #define qemu_openat     openat
+#define qemu_fstat      fstat
 #define qemu_fstatat    fstatat
 #define qemu_mkdirat    mkdirat
 #define qemu_renameat   renameat
@@ -118,6 +121,7 @@ static inline int openat_file(int dirfd, const char *name, int flags,
                               mode_t mode)
 {
     int fd, serrno, ret;
+    struct stat stbuf;
 
 #ifndef CONFIG_DARWIN
 again:
@@ -142,6 +146,31 @@ again:
         return -1;
     }
 
+    /* CVE-2023-2861: Prohibit opening any special file directly on host
+     * (especially device files), as a compromised client could potentially
+     * gain access outside exported tree under certain, unsafe setups. We
+     * expect client to handle I/O on special files exclusively on guest side.
+     */
+    if (qemu_fstat(fd, &stbuf) < 0) {
+        close_preserve_errno(fd);
+        return -1;
+    }
+    if (!S_ISREG(stbuf.st_mode) && !S_ISDIR(stbuf.st_mode)) {
+        /* Tcreate and Tlcreate 9p messages mandate to immediately open the
+         * created file for I/O. So this is not (necessarily) due to a broken
+         * client, and hence no error message is to be reported in this case.
+         */
+        if (!(flags & O_CREAT)) {
+            error_report_once(
+                "9p: broken or compromised client detected; attempt to open "
+                "special file (i.e. neither regular file, nor directory)"
+            );
+        }
+        close(fd);
+        errno = ENXIO;
+        return -1;
+    }
+
     serrno = errno;
     /* O_NONBLOCK was only needed to open the file. Let's drop it. We don't
      * do that with O_PATH since fcntl(F_SETFL) isn't supported, and openat()
