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:
[1.1] *: fix several issues with userns path handling
This a partial backport of the key parts of #4124 as well as the needed fixes in #4134.

~~*Marking as a draft until #4134 is merged.*~~

Fixes #4122

Patch:
diff --git a/libcontainer/configs/config.go b/libcontainer/configs/config.go
index c1b4a004..6ebf5ec7 100644
--- a/libcontainer/configs/config.go
+++ b/libcontainer/configs/config.go
@@ -21,9 +21,9 @@ type Rlimit struct {
 
 // IDMap represents UID/GID Mappings for User Namespaces.
 type IDMap struct {
-	ContainerID int `json:"container_id"`
-	HostID      int `json:"host_id"`
-	Size        int `json:"size"`
+	ContainerID int64 `json:"container_id"`
+	HostID      int64 `json:"host_id"`
+	Size        int64 `json:"size"`
 }
 
 // Seccomp represents syscall restrictions
diff --git a/libcontainer/configs/config_linux.go b/libcontainer/configs/config_linux.go
index 8c02848b..51fe9407 100644
--- a/libcontainer/configs/config_linux.go
+++ b/libcontainer/configs/config_linux.go
@@ -1,6 +1,10 @@
 package configs
 
-import "errors"
+import (
+	"errors"
+	"fmt"
+	"math"
+)
 
 var (
 	errNoUIDMap   = errors.New("User namespaces enabled, but no uid mappings found.")
@@ -16,11 +20,18 @@ func (c Config) HostUID(containerId int) (int, error) {
 		if c.UidMappings == nil {
 			return -1, errNoUIDMap
 		}
-		id, found := c.hostIDFromMapping(containerId, c.UidMappings)
+		id, found := c.hostIDFromMapping(int64(containerId), c.UidMappings)
 		if !found {
 			return -1, errNoUserMap
 		}
-		return id, nil
+		// If we are a 32-bit binary running on a 64-bit system, it's possible
+		// the mapped user is too large to store in an int, which means we
+		// cannot do the mapping. We can't just return an int64, because
+		// os.Setuid() takes an int.
+		if id > math.MaxInt {
+			return -1, fmt.Errorf("mapping for uid %d (host id %d) is larger than native integer size (%d)", containerId, id, math.MaxInt)
+		}
+		return int(id), nil
 	}
 	// Return unchanged id.
 	return containerId, nil
@@ -39,11 +50,18 @@ func (c Config) HostGID(containerId int) (int, error) {
 		if c.GidMappings == nil {
 			return -1, errNoGIDMap
 		}
-		id, found := c.hostIDFromMapping(containerId, c.GidMappings)
+		id, found := c.hostIDFromMapping(int64(containerId), c.GidMappings)
 		if !found {
 			return -1, errNoGroupMap
 		}
-		return id, nil
+		// If we are a 32-bit binary running on a 64-bit system, it's possible
+		// the mapped user is too large to store in an int, which means we
+		// cannot do the mapping. We can't just return an int64, because
+		// os.Setgid() takes an int.
+		if id > math.MaxInt {
+			return -1, fmt.Errorf("mapping for gid %d (host id %d) is larger than native integer size (%d)", containerId, id, math.MaxInt)
+		}
+		return int(id), nil
 	}
 	// Return unchanged id.
 	return containerId, nil
@@ -57,7 +75,7 @@ func (c Config) HostRootGID() (int, error) {
 
 // Utility function that gets a host ID for a container ID from user namespace map
 // if that ID is present in the map.
-func (c Config) hostIDFromMapping(containerID int, uMap []IDMap) (int, bool) {
+func (c Config) hostIDFromMapping(containerID int64, uMap []IDMap) (int64, bool) {
 	for _, m := range uMap {
 		if (containerID >= m.ContainerID) && (containerID <= (m.ContainerID + m.Size - 1)) {
 			hostID := m.HostID + (containerID - m.ContainerID)
diff --git a/libcontainer/configs/validate/rootless.go b/libcontainer/configs/validate/rootless.go
index 9a6e5eb3..37c38336 100644
--- a/libcontainer/configs/validate/rootless.go
+++ b/libcontainer/configs/validate/rootless.go
@@ -28,25 +28,18 @@ func (v *ConfigValidator) rootlessEUID(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
 }
@@ -70,8 +63,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)
 				}
 			}
 
@@ -82,8 +75,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 4fbd308d..ece70a45 100644
--- a/libcontainer/configs/validate/validator.go
+++ b/libcontainer/configs/validate/validator.go
@@ -109,11 +109,19 @@ func (v *ConfigValidator) security(config *configs.Config) error {
 func (v *ConfigValidator) usernamespace(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")
 		}
 	}
 	return nil
diff --git a/libcontainer/container_linux.go b/libcontainer/container_linux.go
index 2f17e37d..59aa0338 100644
--- a/libcontainer/container_linux.go
+++ b/libcontainer/container_linux.go
@@ -2268,7 +2268,7 @@ func ignoreTerminateErrors(err error) error {
 
 func requiresRootOrMappingTool(c *configs.Config) bool {
 	gidMap := []configs.IDMap{
-		{ContainerID: 0, HostID: os.Getegid(), Size: 1},
+		{ContainerID: 0, HostID: int64(os.Getegid()), Size: 1},
 	}
 	return !reflect.DeepEqual(c.GidMappings, gidMap)
 }
diff --git a/libcontainer/specconv/spec_linux.go b/libcontainer/specconv/spec_linux.go
index 1b358b24..7dbfb869 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"
@@ -926,9 +927,9 @@ next:
 func setupUserNamespace(spec *specs.Spec, config *configs.Config) error {
 	create := func(m specs.LinuxIDMapping) configs.IDMap {
 		return configs.IDMap{
-			HostID:      int(m.HostID),
-			ContainerID: int(m.ContainerID),
-			Size:        int(m.Size),
+			HostID:      int64(m.HostID),
+			ContainerID: int64(m.ContainerID),
+			Size:        int64(m.Size),
 		}
 	}
 	if spec.Linux != nil {
@@ -939,6 +940,40 @@ func setupUserNamespace(spec *specs.Spec, config *configs.Config) error {
 			config.GidMappings = append(config.GidMappings, create(m))
 		}
 	}
+	if path := config.Namespaces.PathOf(configs.NEWUSER); path != "" {
+		// 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)
+		}
+		// 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 {
+			// FIXME: It turns out that containerd and CRIO pass both a userns
+			// path and the mappings of the namespace in the same config.json.
+			// Such a configuration is technically not valid, but we used to
+			// require mappings be specified, and thus users worked around our
+			// bug -- so we can't regress it at the moment. But we also don't
+			// want to produce broken behaviour if the mapping doesn't match
+			// the userns. So (for now) we output a warning if the actual
+			// userns mappings match the configuration, otherwise we return an
+			// error.
+			if !userns.IsSameMapping(uidMap, config.UidMappings) ||
+				!userns.IsSameMapping(gidMap, config.GidMappings) {
+				return errors.New("user namespaces enabled, but both namespace path and non-matching mapping specified -- you may only provide one")
+			}
+			logrus.Warnf("config.json has both a userns path to join and a matching userns mapping specified -- you may only provide one. Future versions of runc may return an error with this configuration, please report a bug on <https://github.com/opencontainers/runc> if you see this warning and cannot update your configuration.")
+		}
+
+		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..7a8c2b02
--- /dev/null
+++ b/libcontainer/userns/userns_maps_linux.go
@@ -0,0 +1,186 @@
+//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
+}
+
+// IsSameMapping returns whether or not the two id mappings are the same. Note
+// that if the order of the mappings is different, or a mapping has been split,
+// the mappings will be considered different.
+func IsSameMapping(a, b []configs.IDMap) bool {
+	if len(a) != len(b) {
+		return false
+	}
+	for idx := range a {
+		if a[idx] != b[idx] {
+			return false
+		}
+	}
+	return true
+}


Test patch:
diff --git a/libcontainer/configs/validate/validator_test.go b/libcontainer/configs/validate/validator_test.go
index 5181333f..e89a60b0 100644
--- a/libcontainer/configs/validate/validator_test.go
+++ b/libcontainer/configs/validate/validator_test.go
@@ -150,7 +150,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.")
 	}
@@ -161,6 +161,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}},
 	}
 
 	validator := New()
@@ -170,11 +172,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}},
 	}
 
 	validator := New()
diff --git a/libcontainer/integration/exec_test.go b/libcontainer/integration/exec_test.go
index 5c6272e5..670b33fc 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"
@@ -1588,6 +1589,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 79768e31..f79ea0e3 100644
--- a/libcontainer/specconv/spec_linux_test.go
+++ b/libcontainer/specconv/spec_linux_test.go
@@ -610,6 +610,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(), "both namespace path and non-matching mapping specified") {
+		t.Errorf("user namespace with path and non-matching mapping should be forbidden, got error %v", err)
+	}
+}
+
 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/run.bats b/tests/integration/run.bats
index e59ccb4a..88540c76 100644
--- a/tests/integration/run.bats
+++ b/tests/integration/run.bats
@@ -108,3 +108,60 @@ function teardown() {
 	[ "$status" -eq 0 ]
 	[ "$output" = "410" ]
 }
+
+@test "runc run [joining existing container namespaces]" {
+	# Create a detached container with the namespaces we want. We notably want
+	# to include userns, which requires 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}]'
+		mkdir -p rootfs/{proc,sys,tmp}
+	fi
+	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 configuration (it cannot be changed).
+	update_config '.linux |= (del(.uidMappings) | del(.gidMappings))'
+
+	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
+}
diff --git a/tests/integration/userns.bats b/tests/integration/userns.bats
index eca1e0ce..2329119a 100644
--- a/tests/integration/userns.bats
+++ b/tests/integration/userns.bats
@@ -15,15 +15,25 @@ function setup() {
 	mkdir -p rootfs/{proc,sys,tmp}
 	mkdir -p rootfs/tmp/mount-{1,2}
 
+	to_umount_list="$(mktemp "$BATS_RUN_TMPDIR/userns-mounts.XXXXXX")"
 	if [ "$ROOTLESS" -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}] '
 	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
+}