Repository: opencontainers/runc (go)

Issue:
(u|g)idMappings should not exist when joining an existing user ns
### Description

When reviewing #3985, we found an error when joining an existing user namespace.
Ref: https://github.com/opencontainers/runc/pull/3985#discussion_r1398300077

### Steps to reproduce the issue

1. start a container test with user mapping, for example:
.linux.namespaces += [{"type": "user"}]
.linux.uidMappings = [{"hostID": 100000, "containerID": 0, "size": 65536}]
.linux.gidMappings = [{"hostID": 100000, "containerID": 0, "size": 65536}]

2. get the container init process's pid
runc ps test
for example the pid is 14821

3. start an new container test1 with pid 14821's user namespace, for example:
.linux.namespaces += [{"type": "user", "path": "/proc/14821/ns/user"}]


### Describe the results you received and expected

Received:
ERRO[0000] runc run failed: User namespaces enabled, but no uid mappings found.

Expected:
The container should be started successfully.

### What version of runc are you using?

all

### Host OS information

NAME="Ubuntu"
VERSION="20.04.6 LTS (Focal Fossa)"
ID=ubuntu
ID_LIKE=debian
PRETTY_NAME="Ubuntu 20.04.6 LTS"
VERSION_ID="20.04"
HOME_URL="https://www.ubuntu.com/"
SUPPORT_URL="https://help.ubuntu.com/"
BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
VERSION_CODENAME=focal
UBUNTU_CODENAME=focal

### Host kernel information

Linux codespaces-21ad96 6.2.0-1016-azure #16~22.04.1-Ubuntu SMP Tue Oct 10 17:11:51 UTC 2023 x86_64 x86_64 x86_64 GNU/Linux

Pull request:
*: fix several issues with namespace path handling
This fixes #4122 along with several other issues I found while writing integration tests for joining namespace paths. In particular, this fixes the following issues:

 * Starting a container in a target user namespace now works properly.
   - ~~**NOTE** This requires calling the `nsenter` binary. I've taken care to ensure that we don't do this in any context other than the host, but I'm not super happy with this. The other option would be to self-exec but that would require changing up how `runc init` works as well so we could have two self-exec entrypoints. I'm not sure it's worth it.~~
   - I've reimplemented this using CGo, by forking in C code and doing the `setns(2)` there. Technically it's a little dodgy to be doing work after a `fork(2)`, and while we don't do anything too complicated (like heap allocations), I'm not sure I'm comfortable saying that it won't ever have any issues.
 * Time namespace configurations now work with user namespaces (they worked with rootless containers, but not "standard" ones).
 * Disallow configurations where the timens and userns namespaces are configured to join an existing namespace and there are configuration entries for the timens and userns. This is needed because you cannot modify the configuration of these namespace has been configured. Previously we would not use the configuration to configure the container but would use the mapping to figure out how uids and gids map (which was broken).
 * Remap the rootfs when using user namespaces in our integration tests, to get rid of the `mkdir -p rootfs/{proc,sys,dev}` hack.

There is a test I want to add to #3985 which requires these changes (to verify that `idmap` uses the correct user namespace when joining a userns instead of creating a new one).

Signed-off-by: Aleksa Sarai <cyphar@cyphar.com>

Patch:
diff --git a/.gitignore b/.gitignore
index 52dac0bf..1fcbcfbd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,6 +7,7 @@ vendor/pkg
 /contrib/cmd/fs-idmap/fs-idmap
 /contrib/cmd/memfd-bind/memfd-bind
 /contrib/cmd/pidfd-kill/pidfd-kill
+/contrib/cmd/remap-rootfs/remap-rootfs
 man/man8
 release
 Vagrantfile
diff --git a/Makefile b/Makefile
index df935e41..caa44417 100644
--- a/Makefile
+++ b/Makefile
@@ -71,10 +71,10 @@ runc-bin: runc-dmz
 	$(GO_BUILD) -o runc .
 
 .PHONY: all
-all: runc recvtty sd-helper seccompagent fs-idmap memfd-bind pidfd-kill
+all: runc recvtty sd-helper seccompagent fs-idmap memfd-bind pidfd-kill remap-rootfs
 
-.PHONY: recvtty sd-helper seccompagent fs-idmap memfd-bind pidfd-kill
-recvtty sd-helper seccompagent fs-idmap memfd-bind pidfd-kill:
+.PHONY: recvtty sd-helper seccompagent fs-idmap memfd-bind pidfd-kill remap-rootfs
+recvtty sd-helper seccompagent fs-idmap memfd-bind pidfd-kill remap-rootfs:
 	$(GO_BUILD) -o contrib/cmd/$@/$@ ./contrib/cmd/$@
 
 .PHONY: static
diff --git a/contrib/cmd/remap-rootfs/remap-rootfs.go b/contrib/cmd/remap-rootfs/remap-rootfs.go
new file mode 100644
index 00000000..b9739ba8
--- /dev/null
+++ b/contrib/cmd/remap-rootfs/remap-rootfs.go
@@ -0,0 +1,143 @@
+package main
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"os"
+	"path/filepath"
+	"syscall"
+
+	"github.com/urfave/cli"
+
+	"github.com/opencontainers/runtime-spec/specs-go"
+)
+
+const usage = `contrib/cmd/remap-rootfs
+
+remap-rootfs is a helper tool to remap the root filesystem of a Open Container
+Initiative bundle using user namespaces such that the file owners are remapped
+from "host" mappings to the user namespace's mappings.
+
+Effectively, this is a slightly more complicated 'chown -R', and is primarily
+used within runc's integration tests to remap the test filesystem to match the
+test user namespace. Note that calling remap-rootfs multiple times, or changing
+the mapping and then calling remap-rootfs will likely produce incorrect results
+because we do not "un-map" any pre-applied mappings from previous remap-rootfs
+calls.
+
+Note that the bundle is assumed to be produced by a trusted source, and thus
+malicious configuration files will likely not be handled safely.
+
+To use remap-rootfs, simply pass it the path to an OCI bundle (a directory
+containing a config.json):
+
+    $ sudo remap-rootfs ./bundle
+`
+
+func toHostID(mappings []specs.LinuxIDMapping, id uint32) (int, bool) {
+	for _, m := range mappings {
+		if m.ContainerID <= id && id < m.ContainerID+m.Size {
+			return int(m.HostID + id), true
+		}
+	}
+	return -1, false
+}
+
+type inodeID struct {
+	Dev, Ino uint64
+}
+
+func toInodeID(st *syscall.Stat_t) inodeID {
+	return inodeID{Dev: st.Dev, Ino: st.Ino}
+}
+
+func remapRootfs(root string, uidMap, gidMap []specs.LinuxIDMapping) error {
+	seenInodes := make(map[inodeID]struct{})
+	return filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
+		if err != nil {
+			return err
+		}
+
+		mode := info.Mode()
+		st := info.Sys().(*syscall.Stat_t)
+
+		// Skip symlinks.
+		if mode.Type() == os.ModeSymlink {
+			return nil
+		}
+		// Skip hard-links to files we've already remapped.
+		id := toInodeID(st)
+		if _, seen := seenInodes[id]; seen {
+			return nil
+		}
+		seenInodes[id] = struct{}{}
+
+		// Calculate the new uid:gid.
+		uid := st.Uid
+		newUID, ok1 := toHostID(uidMap, uid)
+		gid := st.Gid
+		newGID, ok2 := toHostID(gidMap, gid)
+
+		// Skip files that cannot be mapped.
+		if !ok1 || !ok2 {
+			niceName := path
+			if relName, err := filepath.Rel(root, path); err == nil {
+				niceName = "/" + relName
+			}
+			fmt.Printf("skipping file %s: cannot remap user %d:%d -> %d:%d\n", niceName, uid, gid, newUID, newGID)
+			return nil
+		}
+		if err := os.Lchown(path, newUID, newGID); err != nil {
+			return err
+		}
+		// Re-apply any setid bits that would be cleared due to chown(2).
+		return os.Chmod(path, mode)
+	})
+}
+
+func main() {
+	app := cli.NewApp()
+	app.Name = "remap-rootfs"
+	app.Usage = usage
+
+	app.Action = func(ctx *cli.Context) error {
+		args := ctx.Args()
+		if len(args) != 1 {
+			return errors.New("exactly one bundle argument must be provided")
+		}
+		bundle := args[0]
+
+		configFile, err := os.Open(filepath.Join(bundle, "config.json"))
+		if err != nil {
+			return err
+		}
+		defer configFile.Close()
+
+		var spec specs.Spec
+		if err := json.NewDecoder(configFile).Decode(&spec); err != nil {
+			return fmt.Errorf("parsing config.json: %w", err)
+		}
+
+		if spec.Root == nil {
+			return errors.New("invalid config.json: root section is null")
+		}
+		rootfs := filepath.Join(bundle, spec.Root.Path)
+
+		if spec.Linux == nil {
+			return errors.New("invalid config.json: linux section is null")
+		}
+		uidMap := spec.Linux.UIDMappings
+		gidMap := spec.Linux.GIDMappings
+		if len(uidMap) == 0 && len(gidMap) == 0 {
+			fmt.Println("skipping remapping -- no userns mappings specified")
+			return nil
+		}
+
+		return remapRootfs(rootfs, uidMap, gidMap)
+	}
+	if err := app.Run(os.Args); err != nil {
+		fmt.Fprintln(os.Stderr, "error:", err)
+		os.Exit(1)
+	}
+}
diff --git a/libcontainer/configs/config_linux.go b/libcontainer/configs/config_linux.go
index 1900fc97..3c0e71e4 100644
--- a/libcontainer/configs/config_linux.go
+++ b/libcontainer/configs/config_linux.go
@@ -1,12 +1,13 @@
 package configs
 
-import "errors"
+import (
+	"errors"
+	"fmt"
+)
 
 var (
-	errNoUIDMap   = errors.New("User namespaces enabled, but no uid mappings found.")
-	errNoUserMap  = errors.New("User namespaces enabled, but no user mapping found.")
-	errNoGIDMap   = errors.New("User namespaces enabled, but no gid mappings found.")
-	errNoGroupMap = errors.New("User namespaces enabled, but no group mapping found.")
+	errNoUIDMap = errors.New("user namespaces enabled, but no uid mappings found")
+	errNoGIDMap = errors.New("user namespaces enabled, but no gid mappings found")
 )
 
 // Please check https://man7.org/linux/man-pages/man2/personality.2.html for const details.
@@ -31,7 +32,7 @@ func (c Config) HostUID(containerId int) (int, error) {
 		}
 		id, found := c.hostIDFromMapping(containerId, c.UIDMappings)
 		if !found {
-			return -1, errNoUserMap
+			return -1, fmt.Errorf("user namespaces enabled, but no mapping found for uid %d", containerId)
 		}
 		return id, nil
 	}
@@ -54,7 +55,7 @@ func (c Config) HostGID(containerId int) (int, error) {
 		}
 		id, found := c.hostIDFromMapping(containerId, c.GIDMappings)
 		if !found {
-			return -1, errNoGroupMap
+			return -1, fmt.Errorf("user namespaces enabled, but no mapping found for gid %d", containerId)
 		}
 		return id, nil
 	}
diff --git a/libcontainer/configs/validate/rootless.go b/libcontainer/configs/validate/rootless.go
index 6d32704a..faceea04 100644
--- a/libcontainer/configs/validate/rootless.go
+++ b/libcontainer/configs/validate/rootless.go
@@ -2,6 +2,7 @@ package validate
 
 import (
 	"errors"
+	"fmt"
 	"strconv"
 	"strings"
 
@@ -28,25 +29,18 @@ func rootlessEUIDCheck(config *configs.Config) error {
 	return nil
 }
 
-func hasIDMapping(id int, mappings []configs.IDMap) bool {
-	for _, m := range mappings {
-		if id >= m.ContainerID && id < m.ContainerID+m.Size {
-			return true
-		}
-	}
-	return false
-}
-
 func rootlessEUIDMappings(config *configs.Config) error {
 	if !config.Namespaces.Contains(configs.NEWUSER) {
 		return errors.New("rootless container requires user namespaces")
 	}
-
-	if len(config.UIDMappings) == 0 {
-		return errors.New("rootless containers requires at least one UID mapping")
-	}
-	if len(config.GIDMappings) == 0 {
-		return errors.New("rootless containers requires at least one GID mapping")
+	// We only require mappings if we are not joining another userns.
+	if path := config.Namespaces.PathOf(configs.NEWUSER); path == "" {
+		if len(config.UIDMappings) == 0 {
+			return errors.New("rootless containers requires at least one UID mapping")
+		}
+		if len(config.GIDMappings) == 0 {
+			return errors.New("rootless containers requires at least one GID mapping")
+		}
 	}
 	return nil
 }
@@ -68,8 +62,8 @@ func rootlessEUIDMount(config *configs.Config) error {
 					// Ignore unknown mount options.
 					continue
 				}
-				if !hasIDMapping(uid, config.UIDMappings) {
-					return errors.New("cannot specify uid= mount options for unmapped uid in rootless containers")
+				if _, err := config.HostUID(uid); err != nil {
+					return fmt.Errorf("cannot specify uid=%d mount option for rootless container: %w", uid, err)
 				}
 			}
 
@@ -79,8 +73,8 @@ func rootlessEUIDMount(config *configs.Config) error {
 					// Ignore unknown mount options.
 					continue
 				}
-				if !hasIDMapping(gid, config.GIDMappings) {
-					return errors.New("cannot specify gid= mount options for unmapped gid in rootless containers")
+				if _, err := config.HostGID(gid); err != nil {
+					return fmt.Errorf("cannot specify gid=%d mount option for rootless container: %w", gid, err)
 				}
 			}
 		}
diff --git a/libcontainer/configs/validate/validator.go b/libcontainer/configs/validate/validator.go
index 0df7242b..2594d05c 100644
--- a/libcontainer/configs/validate/validator.go
+++ b/libcontainer/configs/validate/validator.go
@@ -104,11 +104,19 @@ func security(config *configs.Config) error {
 func namespaces(config *configs.Config) error {
 	if config.Namespaces.Contains(configs.NEWUSER) {
 		if _, err := os.Stat("/proc/self/ns/user"); os.IsNotExist(err) {
-			return errors.New("USER namespaces aren't enabled in the kernel")
+			return errors.New("user namespaces aren't enabled in the kernel")
 		}
+		hasPath := config.Namespaces.PathOf(configs.NEWUSER) != ""
+		hasMappings := config.UIDMappings != nil || config.GIDMappings != nil
+		if !hasPath && !hasMappings {
+			return errors.New("user namespaces enabled, but no namespace path to join nor mappings to apply specified")
+		}
+		// The hasPath && hasMappings validation case is handled in specconv --
+		// we cache the mappings in Config during specconv in the hasPath case,
+		// so we cannot do that validation here.
 	} else {
 		if config.UIDMappings != nil || config.GIDMappings != nil {
-			return errors.New("User namespace mappings specified, but USER namespace isn't enabled in the config")
+			return errors.New("user namespace mappings specified, but user namespace isn't enabled in the config")
 		}
 	}
 
@@ -122,6 +130,11 @@ func namespaces(config *configs.Config) error {
 		if _, err := os.Stat("/proc/self/timens_offsets"); os.IsNotExist(err) {
 			return errors.New("time namespaces aren't enabled in the kernel")
 		}
+		hasPath := config.Namespaces.PathOf(configs.NEWTIME) != ""
+		hasOffsets := config.TimeOffsets != nil
+		if hasPath && hasOffsets {
+			return errors.New("time namespace enabled, but both namespace path and time offsets specified -- you may only provide one")
+		}
 	} else {
 		if config.TimeOffsets != nil {
 			return errors.New("time namespace offsets specified, but time namespace isn't enabled in the config")
diff --git a/libcontainer/init_linux.go b/libcontainer/init_linux.go
index 6ab0698e..c02538d6 100644
--- a/libcontainer/init_linux.go
+++ b/libcontainer/init_linux.go
@@ -494,15 +494,6 @@ func setupUser(config *initConfig) error {
 		}
 	}
 
-	// Rather than just erroring out later in setuid(2) and setgid(2), check
-	// that the user is mapped here.
-	if _, err := config.Config.HostUID(execUser.Uid); err != nil {
-		return errors.New("cannot set uid to unmapped user in user namespace")
-	}
-	if _, err := config.Config.HostGID(execUser.Gid); err != nil {
-		return errors.New("cannot set gid to unmapped user in user namespace")
-	}
-
 	if config.RootlessEUID {
 		// We cannot set any additional groups in a rootless container and thus
 		// we bail if the user asked us to do so. TODO: We currently can't do
@@ -538,9 +529,15 @@ func setupUser(config *initConfig) error {
 	}
 
 	if err := unix.Setgid(execUser.Gid); err != nil {
+		if err == unix.EINVAL {
+			return fmt.Errorf("cannot setgid to unmapped gid %d in user namespace", execUser.Gid)
+		}
 		return err
 	}
 	if err := unix.Setuid(execUser.Uid); err != nil {
+		if err == unix.EINVAL {
+			return fmt.Errorf("cannot setuid to unmapped uid %d in user namespace", execUser.Uid)
+		}
 		return err
 	}
 
diff --git a/libcontainer/nsenter/nsexec.c b/libcontainer/nsenter/nsexec.c
index 9b10b232..f64cc41e 100644
--- a/libcontainer/nsenter/nsexec.c
+++ b/libcontainer/nsenter/nsexec.c
@@ -48,6 +48,8 @@ enum sync_t {
 	SYNC_MOUNTSOURCES_ACK = 0x47,	/* All mount sources have been sent. */
 	SYNC_MOUNT_IDMAP_PLS = 0x48,	/* Tell parent to mount idmap sources. */
 	SYNC_MOUNT_IDMAP_ACK = 0x49,	/* All idmap mounts have been done. */
+	SYNC_TIMEOFFSETS_PLS = 0x50,	/* Request parent to write timens offsets. */
+	SYNC_TIMEOFFSETS_ACK = 0x51,	/* Timens offsets were written. */
 };
 
 #define STAGE_SETUP  -1
@@ -755,13 +757,13 @@ void receive_idmapsources(int sockfd)
 	receive_fd_sources(sockfd, "_LIBCONTAINER_IDMAP_FDS");
 }
 
-static void update_timens_offsets(char *map, size_t map_len)
+static void update_timens_offsets(pid_t pid, char *map, size_t map_len)
 {
 	if (map == NULL || map_len == 0)
 		return;
-	write_log(DEBUG, "update /proc/self/timens_offsets to '%s'", map);
-	if (write_file(map, map_len, "/proc/self/timens_offsets") < 0)
-		bail("failed to update /proc/self/timens_offsets");
+	write_log(DEBUG, "update /proc/%d/timens_offsets to '%s'", pid, map);
+	if (write_file(map, map_len, "/proc/%d/timens_offsets", pid) < 0)
+		bail("failed to update /proc/%d/timens_offsets", pid);
 }
 
 void nsexec(void)
@@ -1008,6 +1010,15 @@ void nsexec(void)
 						bail("failed to sync with child: write(SYNC_MOUNT_IDMAP_ACK)");
 					}
 
+					break;
+				case SYNC_TIMEOFFSETS_PLS:
+					write_log(DEBUG, "stage-1 requested timens offsets to be configured");
+					update_timens_offsets(stage1_pid, config.timensoffset, config.timensoffset_len);
+					s = SYNC_TIMEOFFSETS_ACK;
+					if (write(syncfd, &s, sizeof(s)) != sizeof(s)) {
+						sane_kill(stage1_pid, SIGKILL);
+						bail("failed to sync with child: write(SYNC_TIMEOFFSETS_ACK)");
+					}
 					break;
 				case SYNC_CHILD_FINISH:
 					write_log(DEBUG, "stage-1 complete");
@@ -1161,7 +1172,19 @@ void nsexec(void)
 			 * was broken, so we'll just do it the long way anyway.
 			 */
 			try_unshare(config.cloneflags, "remaining namespaces");
-			update_timens_offsets(config.timensoffset, config.timensoffset_len);
+
+			if (config.timensoffset) {
+				write_log(DEBUG, "request stage-0 to write timens offsets");
+
+				s = SYNC_TIMEOFFSETS_PLS;
+				if (write(syncfd, &s, sizeof(s)) != sizeof(s))
+					bail("failed to sync with parent: write(SYNC_TIMEOFFSETS_PLS)");
+
+				if (read(syncfd, &s, sizeof(s)) != sizeof(s))
+					bail("failed to sync with parent: read(SYNC_TIMEOFFSETS_ACK)");
+				if (s != SYNC_TIMEOFFSETS_ACK)
+					bail("failed to sync with parent: SYNC_TIMEOFFSETS_ACK: got %u", s);
+			}
 
 			/* Ask our parent to send the mount sources fds. */
 			if (config.mountsources) {
diff --git a/libcontainer/specconv/spec_linux.go b/libcontainer/specconv/spec_linux.go
index c1632ac1..90275043 100644
--- a/libcontainer/specconv/spec_linux.go
+++ b/libcontainer/specconv/spec_linux.go
@@ -18,6 +18,7 @@ import (
 	"github.com/opencontainers/runc/libcontainer/configs"
 	"github.com/opencontainers/runc/libcontainer/devices"
 	"github.com/opencontainers/runc/libcontainer/seccomp"
+	"github.com/opencontainers/runc/libcontainer/userns"
 	libcontainerUtils "github.com/opencontainers/runc/libcontainer/utils"
 	"github.com/opencontainers/runtime-spec/specs-go"
 	"github.com/sirupsen/logrus"
@@ -515,6 +516,9 @@ func CreateLibcontainerConfig(opts *CreateOpts) (*configs.Config, error) {
 }
 
 func toConfigIDMap(specMaps []specs.LinuxIDMapping) []configs.IDMap {
+	if specMaps == nil {
+		return nil
+	}
 	idmaps := make([]configs.IDMap, len(specMaps))
 	for i, id := range specMaps {
 		idmaps[i] = configs.IDMap{
@@ -968,6 +972,26 @@ func setupUserNamespace(spec *specs.Spec, config *configs.Config) error {
 		config.UIDMappings = toConfigIDMap(spec.Linux.UIDMappings)
 		config.GIDMappings = toConfigIDMap(spec.Linux.GIDMappings)
 	}
+	if path := config.Namespaces.PathOf(configs.NEWUSER); path != "" {
+		// We cannot allow uid or gid mappings to be set if we are also asked
+		// to join a userns.
+		if config.UIDMappings != nil || config.GIDMappings != nil {
+			return errors.New("user namespaces enabled, but both namespace path and mapping specified -- you may only provide one")
+		}
+		// Cache the current userns mappings in our configuration, so that we
+		// can calculate uid and gid mappings within runc. These mappings are
+		// never used for configuring the container if the path is set.
+		uidMap, gidMap, err := userns.GetUserNamespaceMappings(path)
+		if err != nil {
+			return fmt.Errorf("failed to cache mappings for userns: %w", err)
+		}
+		config.UIDMappings = uidMap
+		config.GIDMappings = gidMap
+		logrus.WithFields(logrus.Fields{
+			"uid_map": uidMap,
+			"gid_map": gidMap,
+		}).Debugf("config uses path-based userns configuration -- current uid and gid mappings cached")
+	}
 	rootUID, err := config.HostRootUID()
 	if err != nil {
 		return err
diff --git a/libcontainer/userns/userns_maps.c b/libcontainer/userns/userns_maps.c
new file mode 100644
index 00000000..84f2c618
--- /dev/null
+++ b/libcontainer/userns/userns_maps.c
@@ -0,0 +1,79 @@
+#define _GNU_SOURCE
+#include <fcntl.h>
+#include <sched.h>
+#include <stdio.h>
+#include <unistd.h>
+#include <stdarg.h>
+#include <stdlib.h>
+
+/*
+ * All of the code here is run inside an aync-signal-safe context, so we need
+ * to be careful to not call any functions that could cause issues. In theory,
+ * since we are a Go program, there are fewer restrictions in practice, it's
+ * better to be safe than sorry.
+ *
+ * The only exception is exit, which we need to call to make sure we don't
+ * return into runc.
+ */
+
+void bail(int pipefd, const char *fmt, ...)
+{
+	va_list args;
+
+	va_start(args, fmt);
+	vdprintf(pipefd, fmt, args);
+	va_end(args);
+
+	exit(1);
+}
+
+int spawn_userns_cat(char *userns_path, char *path, int outfd, int errfd)
+{
+	char buffer[4096] = { 0 };
+
+	pid_t child = fork();
+	if (child != 0)
+		return child;
+	/* in child */
+
+	/* Join the target userns. */
+	int nsfd = open(userns_path, O_RDONLY);
+	if (nsfd < 0)
+		bail(errfd, "open userns path %s failed: %m", userns_path);
+
+	int err = setns(nsfd, CLONE_NEWUSER);
+	if (err < 0)
+		bail(errfd, "setns %s failed: %m", userns_path);
+
+	close(nsfd);
+
+	/* Pipe the requested file contents. */
+	int fd = open(path, O_RDONLY);
+	if (fd < 0)
+		bail(errfd, "open %s in userns %s failed: %m", path, userns_path);
+
+	int nread, ntotal = 0;
+	while ((nread = read(fd, buffer, sizeof(buffer))) != 0) {
+		if (nread < 0)
+			bail(errfd, "read bytes from %s failed (after %d total bytes read): %m", path, ntotal);
+		ntotal += nread;
+
+		int nwritten = 0;
+		while (nwritten < nread) {
+			int n = write(outfd, buffer, nread - nwritten);
+			if (n < 0)
+				bail(errfd, "write %d bytes from %s failed (after %d bytes written): %m",
+				     nread - nwritten, path, nwritten);
+			nwritten += n;
+		}
+		if (nread != nwritten)
+			bail(errfd, "mismatch for bytes read and written: %d read != %d written", nread, nwritten);
+	}
+
+	close(fd);
+	close(outfd);
+	close(errfd);
+
+	/* We must exit here, otherwise we would return into a forked runc. */
+	exit(0);
+}
diff --git a/libcontainer/userns/userns_maps_linux.go b/libcontainer/userns/userns_maps_linux.go
new file mode 100644
index 00000000..d81290de
--- /dev/null
+++ b/libcontainer/userns/userns_maps_linux.go
@@ -0,0 +1,171 @@
+//go:build linux
+
+package userns
+
+import (
+	"bufio"
+	"bytes"
+	"fmt"
+	"io"
+	"os"
+	"unsafe"
+
+	"github.com/opencontainers/runc/libcontainer/configs"
+	"github.com/sirupsen/logrus"
+)
+
+/*
+#include <stdlib.h>
+extern int spawn_userns_cat(char *userns_path, char *path, int outfd, int errfd);
+*/
+import "C"
+
+func parseIdmapData(data []byte) (ms []configs.IDMap, err error) {
+	scanner := bufio.NewScanner(bytes.NewReader(data))
+	for scanner.Scan() {
+		var m configs.IDMap
+		line := scanner.Text()
+		if _, err := fmt.Sscanf(line, "%d %d %d", &m.ContainerID, &m.HostID, &m.Size); err != nil {
+			return nil, fmt.Errorf("parsing id map failed: invalid format in line %q: %w", line, err)
+		}
+		ms = append(ms, m)
+	}
+	if err := scanner.Err(); err != nil {
+		return nil, fmt.Errorf("parsing id map failed: %w", err)
+	}
+	return ms, nil
+}
+
+// Do something equivalent to nsenter --user=<nsPath> cat <path>, but more
+// efficiently. Returns the contents of the requested file from within the user
+// namespace.
+func spawnUserNamespaceCat(nsPath string, path string) ([]byte, error) {
+	rdr, wtr, err := os.Pipe()
+	if err != nil {
+		return nil, fmt.Errorf("create pipe for userns spawn failed: %w", err)
+	}
+	defer rdr.Close()
+	defer wtr.Close()
+
+	errRdr, errWtr, err := os.Pipe()
+	if err != nil {
+		return nil, fmt.Errorf("create error pipe for userns spawn failed: %w", err)
+	}
+	defer errRdr.Close()
+	defer errWtr.Close()
+
+	cNsPath := C.CString(nsPath)
+	defer C.free(unsafe.Pointer(cNsPath))
+	cPath := C.CString(path)
+	defer C.free(unsafe.Pointer(cPath))
+
+	childPid := C.spawn_userns_cat(cNsPath, cPath, C.int(wtr.Fd()), C.int(errWtr.Fd()))
+
+	if childPid < 0 {
+		return nil, fmt.Errorf("failed to spawn fork for userns")
+	} else if childPid == 0 {
+		// this should never happen
+		panic("runc executing inside fork child -- unsafe state!")
+	}
+
+	// We are in the parent -- close the write end of the pipe before reading.
+	wtr.Close()
+	output, err := io.ReadAll(rdr)
+	rdr.Close()
+	if err != nil {
+		return nil, fmt.Errorf("reading from userns spawn failed: %w", err)
+	}
+
+	// Ditto for the error pipe.
+	errWtr.Close()
+	errOutput, err := io.ReadAll(errRdr)
+	errRdr.Close()
+	if err != nil {
+		return nil, fmt.Errorf("reading from userns spawn error pipe failed: %w", err)
+	}
+	errOutput = bytes.TrimSpace(errOutput)
+
+	// Clean up the child.
+	child, err := os.FindProcess(int(childPid))
+	if err != nil {
+		return nil, fmt.Errorf("could not find userns spawn process: %w", err)
+	}
+	state, err := child.Wait()
+	if err != nil {
+		return nil, fmt.Errorf("failed to wait for userns spawn process: %w", err)
+	}
+	if !state.Success() {
+		errStr := string(errOutput)
+		if errStr == "" {
+			errStr = fmt.Sprintf("unknown error (status code %d)", state.ExitCode())
+		}
+		return nil, fmt.Errorf("userns spawn: %s", errStr)
+	} else if len(errOutput) > 0 {
+		// We can just ignore weird output in the error pipe if the process
+		// didn't bail(), but for completeness output for debugging.
+		logrus.Debugf("userns spawn succeeded but unexpected error message found: %s", string(errOutput))
+	}
+	// The subprocess succeeded, return whatever it wrote to the pipe.
+	return output, nil
+}
+
+func GetUserNamespaceMappings(nsPath string) (uidMap, gidMap []configs.IDMap, err error) {
+	var (
+		pid         int
+		extra       rune
+		tryFastPath bool
+	)
+
+	// nsPath is usually of the form /proc/<pid>/ns/user, which means that we
+	// already have a pid that is part of the user namespace and thus we can
+	// just use the pid to read from /proc/<pid>/*id_map.
+	//
+	// Note that Sscanf doesn't consume the whole input, so we check for any
+	// trailing data with %c. That way, we can be sure the pattern matched
+	// /proc/$pid/ns/user _exactly_ iff n === 1.
+	if n, _ := fmt.Sscanf(nsPath, "/proc/%d/ns/user%c", &pid, &extra); n == 1 {
+		tryFastPath = pid > 0
+	}
+
+	for _, mapType := range []struct {
+		name  string
+		idMap *[]configs.IDMap
+	}{
+		{"uid_map", &uidMap},
+		{"gid_map", &gidMap},
+	} {
+		var mapData []byte
+
+		if tryFastPath {
+			path := fmt.Sprintf("/proc/%d/%s", pid, mapType.name)
+			data, err := os.ReadFile(path)
+			if err != nil {
+				// Do not error out here -- we need to try the slow path if the
+				// fast path failed.
+				logrus.Debugf("failed to use fast path to read %s from userns %s (error: %s), falling back to slow userns-join path", mapType.name, nsPath, err)
+			} else {
+				mapData = data
+			}
+		} else {
+			logrus.Debugf("cannot use fast path to read %s from userns %s, falling back to slow userns-join path", mapType.name, nsPath)
+		}
+
+		if mapData == nil {
+			// We have to actually join the namespace if we cannot take the
+			// fast path. The path is resolved with respect to the child
+			// process, so just use /proc/self.
+			data, err := spawnUserNamespaceCat(nsPath, "/proc/self/"+mapType.name)
+			if err != nil {
+				return nil, nil, err
+			}
+			mapData = data
+		}
+		idMap, err := parseIdmapData(mapData)
+		if err != nil {
+			return nil, nil, fmt.Errorf("failed to parse %s of userns %s: %w", mapType.name, nsPath, err)
+		}
+		*mapType.idMap = idMap
+	}
+
+	return uidMap, gidMap, nil
+}


Test patch:
diff --git a/libcontainer/configs/validate/validator_test.go b/libcontainer/configs/validate/validator_test.go
index a882fa64..755d2a07 100644
--- a/libcontainer/configs/validate/validator_test.go
+++ b/libcontainer/configs/validate/validator_test.go
@@ -170,7 +170,7 @@ func TestValidateSecurityWithoutNEWNS(t *testing.T) {
 	}
 }
 
-func TestValidateUsernamespace(t *testing.T) {
+func TestValidateUserNamespace(t *testing.T) {
 	if _, err := os.Stat("/proc/self/ns/user"); os.IsNotExist(err) {
 		t.Skip("Test requires userns.")
 	}
@@ -181,6 +181,8 @@ func TestValidateUsernamespace(t *testing.T) {
 				{Type: configs.NEWUSER},
 			},
 		),
+		UIDMappings: []configs.IDMap{{HostID: 0, ContainerID: 123, Size: 100}},
+		GIDMappings: []configs.IDMap{{HostID: 0, ContainerID: 123, Size: 100}},
 	}
 
 	err := Validate(config)
@@ -189,11 +191,11 @@ func TestValidateUsernamespace(t *testing.T) {
 	}
 }
 
-func TestValidateUsernamespaceWithoutUserNS(t *testing.T) {
-	uidMap := configs.IDMap{ContainerID: 123}
+func TestValidateUsernsMappingWithoutNamespace(t *testing.T) {
 	config := &configs.Config{
 		Rootfs:      "/var",
-		UIDMappings: []configs.IDMap{uidMap},
+		UIDMappings: []configs.IDMap{{HostID: 0, ContainerID: 123, Size: 100}},
+		GIDMappings: []configs.IDMap{{HostID: 0, ContainerID: 123, Size: 100}},
 	}
 
 	err := Validate(config)
@@ -221,6 +223,29 @@ func TestValidateTimeNamespace(t *testing.T) {
 	}
 }
 
+func TestValidateTimeNamespaceWithBothPathAndTimeOffset(t *testing.T) {
+	if _, err := os.Stat("/proc/self/ns/time"); os.IsNotExist(err) {
+		t.Skip("Test requires timens.")
+	}
+	config := &configs.Config{
+		Rootfs: "/var",
+		Namespaces: configs.Namespaces(
+			[]configs.Namespace{
+				{Type: configs.NEWTIME, Path: "/proc/1/ns/time"},
+			},
+		),
+		TimeOffsets: map[string]specs.LinuxTimeOffset{
+			"boottime":  {Secs: 150, Nanosecs: 314159},
+			"monotonic": {Secs: 512, Nanosecs: 271818},
+		},
+	}
+
+	err := Validate(config)
+	if err == nil {
+		t.Error("Expected error to occur but it was nil")
+	}
+}
+
 func TestValidateTimeOffsetsWithoutTimeNamespace(t *testing.T) {
 	config := &configs.Config{
 		Rootfs: "/var",
diff --git a/libcontainer/integration/exec_test.go b/libcontainer/integration/exec_test.go
index 9795964c..1bc84011 100644
--- a/libcontainer/integration/exec_test.go
+++ b/libcontainer/integration/exec_test.go
@@ -18,6 +18,7 @@ import (
 	"github.com/opencontainers/runc/libcontainer/cgroups"
 	"github.com/opencontainers/runc/libcontainer/cgroups/systemd"
 	"github.com/opencontainers/runc/libcontainer/configs"
+	"github.com/opencontainers/runc/libcontainer/userns"
 	"github.com/opencontainers/runtime-spec/specs-go"
 
 	"golang.org/x/sys/unix"
@@ -1550,6 +1551,11 @@ func TestInitJoinNetworkAndUser(t *testing.T) {
 	config2 := newTemplateConfig(t, &tParam{userns: true})
 	config2.Namespaces.Add(configs.NEWNET, netns1)
 	config2.Namespaces.Add(configs.NEWUSER, userns1)
+	// Emulate specconv.setupUserNamespace().
+	uidMap, gidMap, err := userns.GetUserNamespaceMappings(userns1)
+	ok(t, err)
+	config2.UIDMappings = uidMap
+	config2.GIDMappings = gidMap
 	config2.Cgroups.Path = "integration/test2"
 	container2, err := newContainer(t, config2)
 	ok(t, err)
diff --git a/libcontainer/specconv/spec_linux_test.go b/libcontainer/specconv/spec_linux_test.go
index d84853bc..9fd64de7 100644
--- a/libcontainer/specconv/spec_linux_test.go
+++ b/libcontainer/specconv/spec_linux_test.go
@@ -608,6 +608,40 @@ func TestDupNamespaces(t *testing.T) {
 	}
 }
 
+func TestUserNamespaceMappingAndPath(t *testing.T) {
+	if _, err := os.Stat("/proc/self/ns/user"); os.IsNotExist(err) {
+		t.Skip("Test requires userns.")
+	}
+
+	spec := &specs.Spec{
+		Root: &specs.Root{
+			Path: "rootfs",
+		},
+		Linux: &specs.Linux{
+			UIDMappings: []specs.LinuxIDMapping{
+				{ContainerID: 0, HostID: 1000, Size: 1000},
+			},
+			GIDMappings: []specs.LinuxIDMapping{
+				{ContainerID: 0, HostID: 2000, Size: 1000},
+			},
+			Namespaces: []specs.LinuxNamespace{
+				{
+					Type: "user",
+					Path: "/proc/1/ns/user",
+				},
+			},
+		},
+	}
+
+	_, err := CreateLibcontainerConfig(&CreateOpts{
+		Spec: spec,
+	})
+
+	if !strings.Contains(err.Error(), "user namespaces enabled, but both namespace path and mapping specified") {
+		t.Errorf("user namespace with mapping and namespace path should be forbidden")
+	}
+}
+
 func TestNonZeroEUIDCompatibleSpecconvValidate(t *testing.T) {
 	if _, err := os.Stat("/proc/self/ns/user"); os.IsNotExist(err) {
 		t.Skip("Test requires userns.")
diff --git a/tests/integration/delete.bats b/tests/integration/delete.bats
index a1a21748..a7ade108 100644
--- a/tests/integration/delete.bats
+++ b/tests/integration/delete.bats
@@ -10,70 +10,10 @@ function teardown() {
 	teardown_bundle
 }
 
-@test "runc delete" {
-	# Need a permission to create a cgroup.
-	# XXX(@kolyshkin): currently this test does not handle rootless when
-	# fs cgroup driver is used, because in this case cgroup (with a
-	# predefined name) is created by tests/rootless.sh, not by runc.
-	[ $EUID -ne 0 ] && requires systemd
-	set_resources_limit
-
-	runc run -d --console-socket "$CONSOLE_SOCKET" testbusyboxdelete
-	[ "$status" -eq 0 ]
-
-	testcontainer testbusyboxdelete running
-	# Ensure the find statement used later is correct.
-	output=$(find /sys/fs/cgroup -name testbusyboxdelete -o -name \*-testbusyboxdelete.scope 2>/dev/null || true)
-	if [ -z "$output" ]; then
-		fail "expected cgroup not found"
-	fi
-
-	runc kill testbusyboxdelete KILL
-	[ "$status" -eq 0 ]
-	wait_for_container 10 1 testbusyboxdelete stopped
-
-	runc delete testbusyboxdelete
-	[ "$status" -eq 0 ]
-
-	runc state testbusyboxdelete
-	[ "$status" -ne 0 ]
-
-	output=$(find /sys/fs/cgroup -name testbusyboxdelete -o -name \*-testbusyboxdelete.scope 2>/dev/null || true)
-	[ "$output" = "" ] || fail "cgroup not cleaned up correctly: $output"
-}
-
-@test "runc delete --force" {
-	# run busybox detached
-	runc run -d --console-socket "$CONSOLE_SOCKET" test_busybox
-	[ "$status" -eq 0 ]
-
-	# check state
-	testcontainer test_busybox running
-
-	# force delete test_busybox
-	runc delete --force test_busybox
-
-	runc state test_busybox
-	[ "$status" -ne 0 ]
-}
-
-@test "runc delete --force ignore not exist" {
-	runc delete --force notexists
-	[ "$status" -eq 0 ]
-}
-
-# Issue 4047, case "runc delete".
-@test "runc delete [host pidns + init gone]" {
-	test_runc_delete_host_pidns
-}
-
-# Issue 4047, case "runc delete --force" (different code path).
-# shellcheck disable=SC2030
-@test "runc delete --force [host pidns + init gone]" {
-	test_runc_delete_host_pidns --force
-}
-
 # See also: "kill KILL [host pidns + init gone]" test in kill.bats.
+#
+# This needs to be placed at the top of the bats file to work around
+# a shellcheck bug. See <https://github.com/koalaman/shellcheck/issues/2873>.
 function test_runc_delete_host_pidns() {
 	requires cgroups_freezer
 
@@ -101,7 +41,6 @@ function test_runc_delete_host_pidns() {
 	fi
 
 	runc run -d --console-socket "$CONSOLE_SOCKET" test_busybox
-	# shellcheck disable=SC2031
 	[ "$status" -eq 0 ]
 	cgpath=$(get_cgroup_path "pids")
 	init_pid=$(cat "$cgpath"/cgroup.procs)
@@ -125,13 +64,10 @@ function test_runc_delete_host_pidns() {
 	done
 
 	# Must kill those processes and remove container.
-	# shellcheck disable=SC2031
 	runc delete "$@" test_busybox
-	# shellcheck disable=SC2031
 	[ "$status" -eq 0 ]
 
 	runc state test_busybox
-	# shellcheck disable=SC2031
 	[ "$status" -ne 0 ] # "Container does not exist"
 
 	# Make sure all processes are gone.
@@ -140,6 +76,68 @@ function test_runc_delete_host_pidns() {
 	[ -z "$pids" ]
 }
 
+@test "runc delete" {
+	# Need a permission to create a cgroup.
+	# XXX(@kolyshkin): currently this test does not handle rootless when
+	# fs cgroup driver is used, because in this case cgroup (with a
+	# predefined name) is created by tests/rootless.sh, not by runc.
+	[ $EUID -ne 0 ] && requires systemd
+	set_resources_limit
+
+	runc run -d --console-socket "$CONSOLE_SOCKET" testbusyboxdelete
+	[ "$status" -eq 0 ]
+
+	testcontainer testbusyboxdelete running
+	# Ensure the find statement used later is correct.
+	output=$(find /sys/fs/cgroup -name testbusyboxdelete -o -name \*-testbusyboxdelete.scope 2>/dev/null || true)
+	if [ -z "$output" ]; then
+		fail "expected cgroup not found"
+	fi
+
+	runc kill testbusyboxdelete KILL
+	[ "$status" -eq 0 ]
+	wait_for_container 10 1 testbusyboxdelete stopped
+
+	runc delete testbusyboxdelete
+	[ "$status" -eq 0 ]
+
+	runc state testbusyboxdelete
+	[ "$status" -ne 0 ]
+
+	output=$(find /sys/fs/cgroup -name testbusyboxdelete -o -name \*-testbusyboxdelete.scope 2>/dev/null || true)
+	[ "$output" = "" ] || fail "cgroup not cleaned up correctly: $output"
+}
+
+@test "runc delete --force" {
+	# run busybox detached
+	runc run -d --console-socket "$CONSOLE_SOCKET" test_busybox
+	[ "$status" -eq 0 ]
+
+	# check state
+	testcontainer test_busybox running
+
+	# force delete test_busybox
+	runc delete --force test_busybox
+
+	runc state test_busybox
+	[ "$status" -ne 0 ]
+}
+
+@test "runc delete --force ignore not exist" {
+	runc delete --force notexists
+	[ "$status" -eq 0 ]
+}
+
+# Issue 4047, case "runc delete".
+@test "runc delete [host pidns + init gone]" {
+	test_runc_delete_host_pidns
+}
+
+# Issue 4047, case "runc delete --force" (different code path).
+@test "runc delete --force [host pidns + init gone]" {
+	test_runc_delete_host_pidns --force
+}
+
 @test "runc delete --force [paused container]" {
 	runc run -d --console-socket "$CONSOLE_SOCKET" ct1
 	[ "$status" -eq 0 ]
@@ -251,7 +249,6 @@ EOF
 	requires systemd_v244 # Older systemd lacks RuntimeMaxSec support.
 
 	set_cgroups_path
-	# shellcheck disable=SC2016
 	update_config '	  .annotations += {
 				"org.systemd.property.RuntimeMaxSec": "2",
 				"org.systemd.property.TimeoutStopSec": "1"
diff --git a/tests/integration/events.bats b/tests/integration/events.bats
index 707fe4e7..cf42eaf5 100644
--- a/tests/integration/events.bats
+++ b/tests/integration/events.bats
@@ -10,7 +10,40 @@ function teardown() {
 	teardown_bundle
 }
 
-# shellcheck disable=SC2030
+# This needs to be placed at the top of the bats file to work around
+# a shellcheck bug. See <https://github.com/koalaman/shellcheck/issues/2873>.
+function test_events() {
+	# XXX: currently cgroups require root containers.
+	requires root
+	init_cgroup_paths
+
+	local status interval retry_every=1
+	if [ $# -eq 2 ]; then
+		interval="$1"
+		retry_every="$2"
+	fi
+
+	runc run -d --console-socket "$CONSOLE_SOCKET" test_busybox
+	[ "$status" -eq 0 ]
+
+	# Spawn two subshels:
+	# 1. Event logger that sends stats events to events.log.
+	(__runc events ${interval:+ --interval "$interval"} test_busybox >events.log) &
+	# 2. Waits for an event that includes test_busybox then kills the
+	#    test_busybox container which causes the event logger to exit.
+	(
+		retry 10 "$retry_every" grep -q test_busybox events.log
+		__runc delete -f test_busybox
+	) &
+	wait # for both subshells to finish
+
+	[ -e events.log ]
+
+	output=$(head -1 events.log)
+	[[ "$output" == [\{]"\"type\""[:]"\"stats\""[,]"\"id\""[:]"\"test_busybox\""[,]* ]]
+	[[ "$output" == *"data"* ]]
+}
+
 @test "events --stats" {
 	# XXX: currently cgroups require root containers.
 	requires root
@@ -27,7 +60,6 @@ function teardown() {
 	[[ "${lines[0]}" == *"data"* ]]
 }
 
-# shellcheck disable=SC2030
 @test "events --stats with psi data" {
 	requires root cgroups_v2 psi
 	init_cgroup_paths
@@ -56,39 +88,6 @@ function teardown() {
 	done
 }
 
-function test_events() {
-	# XXX: currently cgroups require root containers.
-	requires root
-	init_cgroup_paths
-
-	local status interval retry_every=1
-	if [ $# -eq 2 ]; then
-		interval="$1"
-		retry_every="$2"
-	fi
-
-	runc run -d --console-socket "$CONSOLE_SOCKET" test_busybox
-	# shellcheck disable=SC2031
-	[ "$status" -eq 0 ]
-
-	# Spawn two subshels:
-	# 1. Event logger that sends stats events to events.log.
-	(__runc events ${interval:+ --interval "$interval"} test_busybox >events.log) &
-	# 2. Waits for an event that includes test_busybox then kills the
-	#    test_busybox container which causes the event logger to exit.
-	(
-		retry 10 "$retry_every" grep -q test_busybox events.log
-		__runc delete -f test_busybox
-	) &
-	wait # for both subshells to finish
-
-	[ -e events.log ]
-
-	output=$(head -1 events.log)
-	[[ "$output" == [\{]"\"type\""[:]"\"stats\""[,]"\"id\""[:]"\"test_busybox\""[,]* ]]
-	[[ "$output" == *"data"* ]]
-}
-
 @test "events --interval default" {
 	test_events
 }
diff --git a/tests/integration/helpers.bash b/tests/integration/helpers.bash
index 44af05d9..42f86251 100755
--- a/tests/integration/helpers.bash
+++ b/tests/integration/helpers.bash
@@ -18,6 +18,7 @@ SD_HELPER="${INTEGRATION_ROOT}/../../contrib/cmd/sd-helper/sd-helper"
 SECCOMP_AGENT="${INTEGRATION_ROOT}/../../contrib/cmd/seccompagent/seccompagent"
 FS_IDMAP="${INTEGRATION_ROOT}/../../contrib/cmd/fs-idmap/fs-idmap"
 PIDFD_KILL="${INTEGRATION_ROOT}/../../contrib/cmd/pidfd-kill/pidfd-kill"
+REMAP_ROOTFS="${INTEGRATION_ROOT}/../../contrib/cmd/remap-rootfs/remap-rootfs"
 
 # Some variables may not always be set. Set those to empty value,
 # if unset, to avoid "unbound variable" error.
@@ -657,6 +658,12 @@ function teardown_bundle() {
 	remove_parent
 }
 
+function remap_rootfs() {
+	[ ! -v ROOT ] && return 0 # nothing to remap
+
+	"$REMAP_ROOTFS" "$ROOT/bundle"
+}
+
 function is_kernel_gte() {
 	local major_required minor_required
 	major_required=$(echo "$1" | cut -d. -f1)
diff --git a/tests/integration/idmap.bats b/tests/integration/idmap.bats
index 5bd554ca..53784b55 100644
--- a/tests/integration/idmap.bats
+++ b/tests/integration/idmap.bats
@@ -16,7 +16,6 @@ function setup() {
 	# Use other owner for source-2
 	chown 1:1 source-2/foo.txt
 
-	mkdir -p rootfs/{proc,sys,tmp}
 	mkdir -p rootfs/tmp/mount-{1,2}
 	mkdir -p rootfs/mnt/bind-mount-{1,2}
 
@@ -43,6 +42,8 @@ function setup() {
 						]
 					}
 				] '
+
+	remap_rootfs
 }
 
 function teardown() {
diff --git a/tests/integration/kill.bats b/tests/integration/kill.bats
index 3124bb0e..d1e0de19 100644
--- a/tests/integration/kill.bats
+++ b/tests/integration/kill.bats
@@ -10,32 +10,8 @@ function teardown() {
 	teardown_bundle
 }
 
-# shellcheck disable=SC2030
-@test "kill detached busybox" {
-	# run busybox detached
-	runc run -d --console-socket "$CONSOLE_SOCKET" test_busybox
-	[ "$status" -eq 0 ]
-
-	# check state
-	testcontainer test_busybox running
-
-	runc kill test_busybox KILL
-	[ "$status" -eq 0 ]
-	wait_for_container 10 1 test_busybox stopped
-
-	# Check that kill errors on a stopped container.
-	runc kill test_busybox 0
-	[ "$status" -ne 0 ]
-	[[ "$output" == *"container not running"* ]]
-
-	# Check that -a (now obsoleted) makes kill return no error for a stopped container.
-	runc kill -a test_busybox 0
-	[ "$status" -eq 0 ]
-
-	runc delete test_busybox
-	[ "$status" -eq 0 ]
-}
-
+# This needs to be placed at the top of the bats file to work around
+# a shellcheck bug. See <https://github.com/koalaman/shellcheck/issues/2873>.
 test_host_pidns_kill() {
 	requires cgroups_freezer
 
@@ -53,7 +29,6 @@ test_host_pidns_kill() {
 	fi
 
 	runc run -d --console-socket "$CONSOLE_SOCKET" test_busybox
-	# shellcheck disable=SC2031
 	[ "$status" -eq 0 ]
 	cgpath=$(get_cgroup_path "pids")
 	init_pid=$(cat "$cgpath"/cgroup.procs)
@@ -82,7 +57,6 @@ test_host_pidns_kill() {
 	done
 
 	runc kill test_busybox KILL
-	# shellcheck disable=SC2031
 	[ "$status" -eq 0 ]
 	wait_for_container 10 1 test_busybox stopped
 
@@ -92,6 +66,31 @@ test_host_pidns_kill() {
 	[ -z "$pids" ]
 }
 
+@test "kill detached busybox" {
+	# run busybox detached
+	runc run -d --console-socket "$CONSOLE_SOCKET" test_busybox
+	[ "$status" -eq 0 ]
+
+	# check state
+	testcontainer test_busybox running
+
+	runc kill test_busybox KILL
+	[ "$status" -eq 0 ]
+	wait_for_container 10 1 test_busybox stopped
+
+	# Check that kill errors on a stopped container.
+	runc kill test_busybox 0
+	[ "$status" -ne 0 ]
+	[[ "$output" == *"container not running"* ]]
+
+	# Check that -a (now obsoleted) makes kill return no error for a stopped container.
+	runc kill -a test_busybox 0
+	[ "$status" -eq 0 ]
+
+	runc delete test_busybox
+	[ "$status" -eq 0 ]
+}
+
 # This is roughly the same as TestPIDHostInitProcessWait in libcontainer/integration.
 # The differences are:
 #
diff --git a/tests/integration/mounts.bats b/tests/integration/mounts.bats
index 3cd01da8..a4d997ca 100644
--- a/tests/integration/mounts.bats
+++ b/tests/integration/mounts.bats
@@ -10,6 +10,20 @@ function teardown() {
 	teardown_bundle
 }
 
+# https://github.com/opencontainers/runc/security/advisories/GHSA-m8cg-xc2p-r3fc
+#
+# This needs to be placed at the top of the bats file to work around
+# a shellcheck bug. See <https://github.com/koalaman/shellcheck/issues/2873>.
+function test_ro_cgroup_mount() {
+	local lines status
+	# shellcheck disable=SC2016
+	update_config '.process.args |= ["sh", "-euc", "for f in `grep /sys/fs/cgroup /proc/mounts | awk \"{print \\\\$2}\"| uniq`; do test -e $f && grep -w $f /proc/mounts | tail -n1; done"]'
+	runc run test_busybox
+	[ "$status" -eq 0 ]
+	[ "${#lines[@]}" -ne 0 ]
+	for line in "${lines[@]}"; do [[ "${line}" == *'ro,'* ]]; done
+}
+
 # https://github.com/opencontainers/runc/issues/3991
 @test "runc run [tmpcopyup]" {
 	mkdir -p rootfs/dir1/dir2
@@ -88,22 +102,9 @@ function teardown() {
 	test_ro_cgroup_mount
 }
 
-# shellcheck disable=SC2030
 @test "runc run [ro /sys/fs/cgroup mounts + cgroupns]" {
 	requires cgroupns
 	# With cgroup namespace.
 	update_config '.linux.namespaces |= if index({"type": "cgroup"}) then . else . + [{"type": "cgroup"}] end'
 	test_ro_cgroup_mount
 }
-
-# https://github.com/opencontainers/runc/security/advisories/GHSA-m8cg-xc2p-r3fc
-# shellcheck disable=SC2031
-function test_ro_cgroup_mount() {
-	local lines status
-	# shellcheck disable=SC2016
-	update_config '.process.args |= ["sh", "-euc", "for f in `grep /sys/fs/cgroup /proc/mounts | awk \"{print \\\\$2}\"| uniq`; do test -e $f && grep -w $f /proc/mounts | tail -n1; done"]'
-	runc run test_busybox
-	[ "$status" -eq 0 ]
-	[ "${#lines[@]}" -ne 0 ]
-	for line in "${lines[@]}"; do [[ "${line}" == *'ro,'* ]]; done
-}
diff --git a/tests/integration/run.bats b/tests/integration/run.bats
index e2eda270..f6bd3c86 100644
--- a/tests/integration/run.bats
+++ b/tests/integration/run.bats
@@ -160,3 +160,73 @@ function teardown() {
 	[[ "$output" = *"Hello World"* ]]
 	[[ "$output" = *"runc-dmz: using /proc/self/exe clone"* ]]
 }
+
+@test "runc run [joining existing container namespaces]" {
+	requires timens
+
+	# Create a detached container with the namespaces we want. We notably want
+	# to include both userns and timens, which require config-related
+	# configuration.
+	if [ $EUID -eq 0 ]; then
+		update_config '.linux.namespaces += [{"type": "user"}]
+			| .linux.uidMappings += [{"containerID": 0, "hostID": 100000, "size": 100}]
+			| .linux.gidMappings += [{"containerID": 0, "hostID": 200000, "size": 200}]'
+		remap_rootfs
+	fi
+	update_config '.linux.namespaces += [{"type": "time"}]
+		| .linux.timeOffsets = {
+			"monotonic": { "secs": 7881, "nanosecs": 2718281 },
+			"boottime": { "secs": 1337, "nanosecs": 3141519 }
+		}'
+	update_config '.process.args = ["sleep", "infinity"]'
+
+	runc run -d --console-socket "$CONSOLE_SOCKET" target_ctr
+	[ "$status" -eq 0 ]
+
+	# Modify our container's configuration such that it is just going to
+	# inherit all of the namespaces of the target container.
+	#
+	# NOTE: We cannot join the mount namespace of another container because of
+	# some quirks of the runtime-spec. In particular, we MUST pivot_root into
+	# root.path and root.path MUST be set in the config, so runc cannot just
+	# ignore root.path when joining namespaces (and root.path doesn't exist
+	# inside root.path, for obvious reasons).
+	#
+	# We could hack around this (create a copy of the rootfs inside the rootfs,
+	# or use a simpler mount namespace target), but those wouldn't be similar
+	# tests to the other namespace joining tests.
+	target_pid="$(__runc state target_ctr | jq .pid)"
+	update_config '.linux.namespaces |= map_values(.path = if .type == "mount" then "" else "/proc/'"$target_pid"'/ns/" + ({"network": "net", "mount": "mnt"}[.type] // .type) end)'
+	# Remove the userns and timens configuration (they cannot be changed).
+	update_config '.linux |= (del(.uidMappings) | del(.gidMappings) | del(.timeOffsets))'
+
+	runc run -d --console-socket "$CONSOLE_SOCKET" attached_ctr
+	[ "$status" -eq 0 ]
+
+	# Make sure there are two sleep processes in our container.
+	runc exec attached_ctr ps aux
+	[ "$status" -eq 0 ]
+	run -0 grep "sleep infinity" <<<"$output"
+	[ "${#lines[@]}" -eq 2 ]
+
+	# ... that the userns mappings are the same...
+	runc exec attached_ctr cat /proc/self/uid_map
+	[ "$status" -eq 0 ]
+	if [ $EUID -eq 0 ]; then
+		grep -E '^\s+0\s+100000\s+100$' <<<"$output"
+	else
+		grep -E '^\s+0\s+'$EUID'\s+1$' <<<"$output"
+	fi
+	runc exec attached_ctr cat /proc/self/gid_map
+	[ "$status" -eq 0 ]
+	if [ $EUID -eq 0 ]; then
+		grep -E '^\s+0\s+200000\s+200$' <<<"$output"
+	else
+		grep -E '^\s+0\s+'$EUID'\s+1$' <<<"$output"
+	fi
+
+	# ... as well as the timens offsets.
+	runc exec attached_ctr cat /proc/self/timens_offsets
+	grep -E '^monotonic\s+7881\s+2718281$' <<<"$output"
+	grep -E '^boottime\s+1337\s+3141519$' <<<"$output"
+}
diff --git a/tests/integration/timens.bats b/tests/integration/timens.bats
index e0d21d55..fb33e2b2 100644
--- a/tests/integration/timens.bats
+++ b/tests/integration/timens.bats
@@ -53,3 +53,25 @@ function teardown() {
 	grep -E '^monotonic\s+7881\s+2718281$' <<<"$output"
 	grep -E '^boottime\s+1337\s+3141519$' <<<"$output"
 }
+
+@test "runc run [simple timens + userns]" {
+	requires root
+	requires timens
+
+	update_config ' .linux.namespaces += [{"type": "user"}]
+		| .linux.uidMappings += [{"hostID": 100000, "containerID": 0, "size": 65534}]
+		| .linux.gidMappings += [{"hostID": 200000, "containerID": 0, "size": 65534}] '
+	remap_rootfs
+
+	update_config '.process.args = ["cat", "/proc/self/timens_offsets"]'
+	update_config '.linux.namespaces += [{"type": "time"}]
+		| .linux.timeOffsets = {
+			"monotonic": { "secs": 7881, "nanosecs": 2718281 },
+			"boottime": { "secs": 1337, "nanosecs": 3141519 }
+		}'
+
+	runc run test_busybox
+	[ "$status" -eq 0 ]
+	grep -E '^monotonic\s+7881\s+2718281$' <<<"$output"
+	grep -E '^boottime\s+1337\s+3141519$' <<<"$output"
+}
diff --git a/tests/integration/userns.bats b/tests/integration/userns.bats
index d52444ca..2094cbf1 100644
--- a/tests/integration/userns.bats
+++ b/tests/integration/userns.bats
@@ -12,18 +12,28 @@ function setup() {
 	# Permissions only to the owner, it is inaccessible to group/others
 	chmod 700 source-inaccessible-{1,2}
 
-	mkdir -p rootfs/{proc,sys,tmp}
 	mkdir -p rootfs/tmp/mount-{1,2}
 
+	to_umount_list="$(mktemp "$BATS_RUN_TMPDIR/userns-mounts.XXXXXX")"
 	if [ $EUID -eq 0 ]; then
 		update_config ' .linux.namespaces += [{"type": "user"}]
 			| .linux.uidMappings += [{"hostID": 100000, "containerID": 0, "size": 65534}]
-			| .linux.gidMappings += [{"hostID": 100000, "containerID": 0, "size": 65534}] '
+			| .linux.gidMappings += [{"hostID": 200000, "containerID": 0, "size": 65534}] '
+		remap_rootfs
 	fi
 }
 
 function teardown() {
 	teardown_bundle
+
+	if [ -v to_umount_list ]; then
+		while read -r mount_path; do
+			umount -l "$mount_path" || :
+			rm -f "$mount_path"
+		done <"$to_umount_list"
+		rm -f "$to_umount_list"
+		unset to_umount_list
+	fi
 }
 
 @test "userns with simple mount" {
@@ -83,3 +93,74 @@ function teardown() {
 	runc exec test_busybox cat /sys/fs/cgroup/{pids,memory}/tasks
 	[ "$status" -eq 0 ]
 }
+
+@test "userns join other container userns" {
+	# Create a detached container with the id-mapping we want.
+	update_config '.process.args = ["sleep", "infinity"]'
+	runc run -d --console-socket "$CONSOLE_SOCKET" target_userns
+	[ "$status" -eq 0 ]
+
+	# Configure our container to attach to the first container's userns.
+	target_pid="$(__runc state target_userns | jq .pid)"
+	update_config '.linux.namespaces |= map(if .type == "user" then (.path = "/proc/'"$target_pid"'/ns/" + .type) else . end)
+		| del(.linux.uidMappings)
+		| del(.linux.gidMappings)'
+	runc run -d --console-socket "$CONSOLE_SOCKET" in_userns
+	[ "$status" -eq 0 ]
+
+	runc exec in_userns cat /proc/self/uid_map
+	[ "$status" -eq 0 ]
+	if [ $EUID -eq 0 ]; then
+		grep -E '^\s+0\s+100000\s+65534$' <<<"$output"
+	else
+		grep -E '^\s+0\s+'$EUID'\s+1$' <<<"$output"
+	fi
+
+	runc exec in_userns cat /proc/self/gid_map
+	[ "$status" -eq 0 ]
+	if [ $EUID -eq 0 ]; then
+		grep -E '^\s+0\s+200000\s+65534$' <<<"$output"
+	else
+		grep -E '^\s+0\s+'$EUID'\s+1$' <<<"$output"
+	fi
+}
+
+@test "userns join other container userns [bind-mounted nsfd]" {
+	requires root
+
+	# Create a detached container with the id-mapping we want.
+	update_config '.process.args = ["sleep", "infinity"]'
+	runc run -d --console-socket "$CONSOLE_SOCKET" target_userns
+	[ "$status" -eq 0 ]
+
+	# Bind-mount the first containers userns nsfd to a different path, to
+	# exercise the non-fast-path (where runc has to join the userns to get the
+	# mappings).
+	target_pid="$(__runc state target_userns | jq .pid)"
+	userns_path=$(mktemp "$BATS_RUN_TMPDIR/userns.XXXXXX")
+	mount --bind "/proc/$target_pid/ns/user" "$userns_path"
+	echo "$userns_path" >>"$to_umount_list"
+
+	# Configure our container to attach to the first container's userns.
+	update_config '.linux.namespaces |= map(if .type == "user" then (.path = "'"$userns_path"'") else . end)
+		| del(.linux.uidMappings)
+		| del(.linux.gidMappings)'
+	runc run -d --console-socket "$CONSOLE_SOCKET" in_userns
+	[ "$status" -eq 0 ]
+
+	runc exec in_userns cat /proc/self/uid_map
+	[ "$status" -eq 0 ]
+	if [ $EUID -eq 0 ]; then
+		grep -E '^\s+0\s+100000\s+65534$' <<<"$output"
+	else
+		grep -E '^\s+0\s+'$EUID'\s+1$' <<<"$output"
+	fi
+
+	runc exec in_userns cat /proc/self/gid_map
+	[ "$status" -eq 0 ]
+	if [ $EUID -eq 0 ]; then
+		grep -E '^\s+0\s+200000\s+65534$' <<<"$output"
+	else
+		grep -E '^\s+0\s+'$EUID'\s+1$' <<<"$output"
+	fi
+}