Skip to content

portal: Account for source fds in fd_map_remap_fd max_fd tracking - #6785

Merged
swick merged 1 commit into
flatpak:mainfrom
swick:wip/portal-fd-remap-fix
Aug 19, 2026
Merged

swick merged 1 commit into
flatpak:mainfrom
swick:wip/portal-fd-remap-fix

Conversation

@swick

@swick swick commented Aug 18, 2026 •

Copy link
Copy Markdown
Collaborator

fd_map_remap_fd assigns a temporary fd number (++max_fd) that may later be used as a conflict resolution target. If the source fd passed to fd_map_remap_fd is higher than max_fd, conflict resolution can assign a replacement value that collides with that source fd. In child_setup_func, the dup2 for the conflicting entry then overwrites the source fd before it is remapped, delivering the wrong fd to the child process.

In practice this manifests as sandbox-expose-fd-ro passing a random fd (e.g. a socket) instead of the intended O_PATH fd to flatpak run via --ro-bind-fd, producing errors like:

error: /proc/self/fd/21 resolves to non-absolute path socket:[423446]

Similar error have been observed in Epiphany/WebKit:
https://bugs.webkit.org/show_bug.cgi?id=305344

Verified by an LLM, with a test case which is too specific to commit to the repo:

● The collision happens when dest_fd == F+2, where F is the first fd number the portal assigns to the received GUnixFDList. Here's
  the exact sequence:

  Setup: Portal receives 6 fds as F..F+5, plus creates env_fd as F+6 (memfd).

  Building fd_map (handle_spawn):

  1. arg_fds[0]: from=F, to=1 (stdout)
  2. arg_fds[1]: from=F+1, to=F+2 (dest_fd, the collision value)
  3. arg_fds[2]: from=F+2, to=5
  4. max_fd after arg_fds = F+2

  fd_map_remap_fd calls (without fix, from not tracked in max_fd):

  5. fd_map_remap_fd(env_fd=F+6): to = ++max_fd = F+3. max_fd is now F+3, but F+6 was not folded in.
  6. fd_map_remap_fd(expose_fd=F+5): to = ++max_fd = F+4. max_fd is now F+4, but F+5 was not folded in.

  Conflict resolution loop:

  7. Entry 1's to (F+2) == Entry 2's from (F+2) — conflict detected.
  8. Entry 1's to reassigned to ++max_fd = F+5.
  9. F+5 is the expose fd's from. No further conflict check catches this because the loop only checks entry[j].from for j > i, and
  the expose entry's from was already passed.

  child_setup_func (after fork, two-pass dup2):

  Pass 1 — dup2(from → to):

  10. dup2(F, 1) — stdout, fine.
  11. dup2(F+1, F+5) — arg_fd[0] (the pipe) overwrites the expose fd sitting at F+5.
  12. dup2(F+2, 5) — arg_fd[1], fine.
  13. dup2(F+6, F+3) — env_fd, fine.
  14. dup2(F+5, F+4) — supposed to copy the expose fd, but F+5 now holds the pipe from step 11.

  Pass 2 — dup2(to → final):

  15. The expose fd ends up at its final destination, but it's the pipe, not the O_PATH file.

  Result: mock-flatpak receives --ro-bind-fd=N pointing to the pipe. flatpak_get_path_for_fd reads /proc/self/fd/N →
  pipe:[63765418] → rejected as non-absolute path.

  The fix adds if (fd > *max_fd_in_out) *max_fd_in_out = fd at the top of fd_map_remap_fd. At step 5, max_fd jumps to F+6
  (accounting for env_fd's source). At step 6, max_fd becomes F+7 (accounting for expose_fd's source). At step 8, the conflict
  resolution assigns ++max_fd = F+8, which doesn't collide with anything.
diff --git ./tests/test-portal.c ../tests/test-portal.c
index 40df8241..6f9392d5 100644
--- ./tests/test-portal.c
+++ ../tests/test-portal.c
@@ -14,16 +14,17 @@
  * You should have received a copy of the GNU Lesser General Public
  * License along with this library. If not, see <http://www.gnu.org/licenses/>.
  */
 
 #include <errno.h>
 #include <fcntl.h>
 #include <string.h>
 #include <sys/stat.h>
+#include <sys/wait.h>
 #include <unistd.h>
 
 #include "libglnx.h"
 
 #include <glib.h>
 #include <gio/gio.h>
 #include <gio/gunixfdlist.h>
 
@@ -566,16 +567,177 @@ test_replace (Fixture *f,
   g_subprocess_wait_check (gets_replaced, NULL, &error);
   g_assert_no_error (error);
 
   g_subprocess_send_signal (f->portal, SIGTERM);
   g_subprocess_wait (f->portal, NULL, &error);
   g_assert_no_error (error);
 }
 
+static void
+test_expose_fd_remap (Fixture *f,
+                      gconstpointer context G_GNUC_UNUSED)
+{
+  g_autoptr(GError) error = NULL;
+  char expose_path[] = "/tmp/flatpak-portal-expose-XXXXXX";
+  char dummy_path_0[] = "/tmp/flatpak-portal-dummy-XXXXXX";
+  char dummy_path_1[] = "/tmp/flatpak-portal-dummy-XXXXXX";
+  char argfd_path_1[] = "/tmp/flatpak-portal-argfd-XXXXXX";
+  glnx_autofd int expose_tmpfd = g_mkstemp (expose_path);
+  glnx_autofd int expose_opath_fd = -1;
+  glnx_autofd int stdout_fd = open ("/dev/null", O_RDWR | O_CLOEXEC);
+  glnx_autofd int dummy_fd_0 = g_mkstemp (dummy_path_0);
+  glnx_autofd int dummy_fd_1 = g_mkstemp (dummy_path_1);
+  glnx_autofd int argfd_1 = g_mkstemp (argfd_path_1);
+  int pipe_fds[2];
+  glnx_autofd int argfd_0 = -1;
+  size_t times_exited = 0;
+  gulong handler_id;
+
+  g_assert_cmpint (expose_tmpfd, >=, 0);
+  g_assert_cmpint (stdout_fd, >=, 0);
+  g_assert_cmpint (dummy_fd_0, >=, 0);
+  g_assert_cmpint (dummy_fd_1, >=, 0);
+  g_assert_cmpint (argfd_1, >=, 0);
+
+  g_assert_no_errno (pipe (pipe_fds));
+  argfd_0 = pipe_fds[0];
+  close (pipe_fds[1]);
+
+  unlink (dummy_path_0);
+  unlink (dummy_path_1);
+
+  fixture_start_portal (f);
+
+  handler_id = g_signal_connect (f->proxy, "spawn-exited",
+                                 G_CALLBACK (count_successful_exit_cb),
+                                 &times_exited);
+
+  expose_opath_fd = open (expose_path, O_PATH | O_CLOEXEC);
+  g_assert_cmpint (expose_opath_fd, >=, 0);
+
+  /* Iterate dest values for arg_fd[0].  For the right value
+   * (dest == F+2, where F is the portal's base fd for received fds),
+   * the conflict resolution in handle_spawn triggers the bug fixed
+   * by fd_map_remap_fd accounting for source fds in max_fd.
+   *
+   * arg_fd[0] is a pipe (resolves to pipe:[N] in /proc/self/fd),
+   * so when the collision causes it to land at the expose fd's
+   * position, mock-flatpak's --ro-bind-fd validation rejects the
+   * non-absolute path — reproducing the real-world symptom from
+   * Epiphany where a socket fd appeared at an expose fd position.
+   *
+   * GUnixFDList layout (6 fds):
+   *   handle 0: stdout      (dest = 1, keeps portal fd F low in arg_fds)
+   *   handle 1: arg_fd[0]   (pipe, dest = dest_fd, varied)
+   *   handle 2: arg_fd[1]   (dest = 5, fixed low value)
+   *   handle 3: dummy       (padding — not referenced, occupies portal fd)
+   *   handle 4: dummy       (padding — likewise)
+   *   handle 5: expose fd   (sandbox-expose-fd-ro, O_PATH)
+   *
+   * Portal fds F..F+5, env_fd at F+6 (memfd_create).
+   *
+   * When dest_fd == F+2 (= arg_fd[1]'s portal fd) and bug is present:
+   *   max_fd after arg_fds = F+2.
+   *   fd_map_remap_fd(env_fd=F+6): to=F+3  (doesn't account for F+6)
+   *   fd_map_remap_fd(expose=F+5): to=F+4  (doesn't account for F+5)
+   *   Conflict resolution: entry 1's to=F+2 matches entry 2's from=F+2.
+   *     Reassigned to ++max_fd = F+5  ==  expose entry's from.
+   *     COLLISION: child_setup_func overwrites expose fd before use. */
+  for (unsigned int dest_fd = 3; dest_fd < 30; dest_fd++)
+    {
+      g_autoptr(GUnixFDList) fds_in = g_unix_fd_list_new ();
+      g_autoptr(GUnixFDList) fds_out = NULL;
+      g_auto(GVariantBuilder) fd_map_builder = {};
+      g_auto(GVariantBuilder) env_builder = {};
+      g_auto(GVariantBuilder) options_builder = {};
+      unsigned int pid;
+      gboolean ok;
+      const char * const argv[] = { "hello", NULL };
+      int expose_handle;
+
+      if (dest_fd == 5 || dest_fd == 1)
+        continue;
+
+      g_variant_builder_init (&fd_map_builder, G_VARIANT_TYPE ("a{uh}"));
+      g_variant_builder_init (&env_builder, G_VARIANT_TYPE ("a{ss}"));
+      g_variant_builder_init (&options_builder, G_VARIANT_TYPE ("a{sv}"));
+      times_exited = 0;
+
+      /* handle 0: stdout — placed first so its portal fd (F) doesn't
+       * inflate max_fd past the expose fd's portal fd (F+5). */
+      g_variant_builder_add (&fd_map_builder, "{uh}",
+                             (guint32) STDOUT_FILENO,
+                             (gint32) g_unix_fd_list_append (fds_in, stdout_fd, &error));
+      g_assert_no_error (error);
+
+      /* handle 1: arg_fd[0] — dest varied to probe for F+2 */
+      g_variant_builder_add (&fd_map_builder, "{uh}",
+                             dest_fd,
+                             (gint32) g_unix_fd_list_append (fds_in, argfd_0, &error));
+      g_assert_no_error (error);
+
+      /* handle 2: arg_fd[1] — fixed low dest */
+      g_variant_builder_add (&fd_map_builder, "{uh}",
+                             (guint32) 5,
+                             (gint32) g_unix_fd_list_append (fds_in, argfd_1, &error));
+      g_assert_no_error (error);
+
+      /* handles 3, 4: dummy fds (gap between arg_fds and expose) */
+      g_unix_fd_list_append (fds_in, dummy_fd_0, &error);
+      g_assert_no_error (error);
+      g_unix_fd_list_append (fds_in, dummy_fd_1, &error);
+      g_assert_no_error (error);
+
+      /* handle 5: expose fd (O_PATH) — last handle = highest portal fd */
+      expose_handle = g_unix_fd_list_append (fds_in, expose_opath_fd, &error);
+      g_assert_no_error (error);
+      g_assert_cmpint (expose_handle, ==, 5);
+
+      g_variant_builder_add (&env_builder, "{ss}", "TEST_FD", "1");
+
+      {
+        g_auto(GVariantBuilder) expose_builder = {};
+
+        g_variant_builder_init (&expose_builder, G_VARIANT_TYPE ("ah"));
+        g_variant_builder_add (&expose_builder, "h", (gint32) expose_handle);
+        g_variant_builder_add (&options_builder, "{sv}",
+                               "sandbox-expose-fd-ro",
+                               g_variant_builder_end (&expose_builder));
+      }
+
+      ok = portal_flatpak_call_spawn_sync (f->proxy,
+                                           "/",
+                                           argv,
+                                           g_variant_builder_end (&fd_map_builder),
+                                           g_variant_builder_end (&env_builder),
+                                           FLATPAK_SPAWN_FLAGS_NONE,
+                                           g_variant_builder_end (&options_builder),
+                                           fds_in,
+                                           &pid,
+                                           &fds_out,
+                                           NULL,
+                                           &error);
+      g_assert_no_error (error);
+      g_assert_true (ok);
+
+      while (times_exited == 0)
+        g_main_context_iteration (NULL, TRUE);
+    }
+
+  g_signal_handler_disconnect (f->proxy, handler_id);
+
+  unlink (expose_path);
+  unlink (argfd_path_1);
+
+  g_subprocess_send_signal (f->portal, SIGTERM);
+  g_subprocess_wait (f->portal, NULL, &error);
+  g_assert_no_error (error);
+}
+
 static void
 teardown (Fixture *f,
           gconstpointer context G_GNUC_UNUSED)
 {
   tests_dbus_daemon_teardown (&f->dbus_daemon);
   g_clear_object (&f->portal);
   g_free (f->portal_path);
   g_free (f->mock_flatpak);
@@ -587,11 +749,12 @@ main (int argc,
 {
   g_test_init (&argc, &argv, NULL);
 
   g_test_add ("/help", Fixture, NULL, setup, test_help, teardown);
   g_test_add ("/basic", Fixture, NULL, setup, test_basic, teardown);
   g_test_add ("/fd-passing", Fixture, NULL, setup, test_fd_passing, teardown);
   g_test_add ("/spawn-env", Fixture, NULL, setup, test_spawn_env, teardown);
   g_test_add ("/replace", Fixture, NULL, setup, test_replace, teardown);
+  g_test_add ("/expose-fd-remap", Fixture, NULL, setup, test_expose_fd_remap, teardown);
 
   return g_test_run ();
 }

fd_map_remap_fd assigns a temporary fd number (++max_fd) that may later
be used as a conflict resolution target. If the source fd passed to
fd_map_remap_fd is higher than max_fd, conflict resolution can assign
a replacement value that collides with that source fd. In
child_setup_func, the dup2 for the conflicting entry then overwrites
the source fd before it is remapped, delivering the wrong fd to the
child process.

In practice this manifests as sandbox-expose-fd-ro passing a random fd
(e.g. a socket) instead of the intended O_PATH fd to flatpak run via
--ro-bind-fd, producing errors like:

  error: /proc/self/fd/21 resolves to non-absolute path socket:[423446]

Similar error have been observed in Epiphany/WebKit:
https://bugs.webkit.org/show_bug.cgi?id=305344
@swick
swick force-pushed the wip/portal-fd-remap-fix branch from 6126656 to d443535 Compare August 18, 2026 15:54
@swick

swick commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator Author

Another Epiphany crash has been confirmed to be fixed with this.

/cc @smcv

@mcatanzaro

Copy link
Copy Markdown
Collaborator

Another Epiphany crash has been confirmed to be fixed with this.

Specifically, I found this fixes: #6783.

I see https://gitlab.gnome.org/GNOME/libglnx/-/merge_requests/79 has already been proposed to fix that issue, but that did not work for me.

@swick
swick added this pull request to the merge queue Aug 19, 2026
@smcv

smcv commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Specifically, I found this fixes: #6783.

I see https://gitlab.gnome.org/GNOME/libglnx/-/merge_requests/79 has already been proposed to fix that issue, but that did not work for me.

I think perhaps there's more than one root cause for the same symptom. Hopefully it's only two: the one fixed by #6787 (which integrates libglnx!79) and the one fixed here.

Merged via the queue into flatpak:main with commit 22eec07 Aug 19, 2026
15 of 17 checks passed
@swick
swick deleted the wip/portal-fd-remap-fix branch August 19, 2026 12:04
@swick swick mentioned this pull request Aug 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants