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
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),
+ ×_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 ();
}
```