├── docker ├── fs-stage │ ├── tmp │ │ ├── test-file │ │ ├── space file │ │ ├── test-dir │ │ │ └── test-file │ │ └── space dir │ │ │ └── space file │ ├── etc │ │ └── fixuid │ │ │ └── config.yml │ └── usr │ │ └── local │ │ └── bin │ │ ├── fixuid-mount-test.sh │ │ └── fixuid-test.sh ├── .gitignore ├── fedora │ └── Dockerfile ├── alpine │ └── Dockerfile └── debian │ └── Dockerfile ├── test-no-escalate ├── .gitignore ├── build.sh └── test-no-escalate.go ├── .gitignore ├── docker-compose.yml ├── go.mod ├── install.sh ├── pack.sh ├── LICENSE ├── go.sum ├── .github └── workflows │ └── main.yaml ├── CHANGELOG.md ├── README.md ├── test.sh └── fixuid.go /docker/fs-stage/tmp/test-file: -------------------------------------------------------------------------------- 1 | test-file -------------------------------------------------------------------------------- /docker/fs-stage/tmp/space file: -------------------------------------------------------------------------------- 1 | space file -------------------------------------------------------------------------------- /docker/fs-stage/tmp/test-dir/test-file: -------------------------------------------------------------------------------- 1 | test-file -------------------------------------------------------------------------------- /docker/fs-stage/tmp/space dir/space file: -------------------------------------------------------------------------------- 1 | space file -------------------------------------------------------------------------------- /test-no-escalate/.gitignore: -------------------------------------------------------------------------------- 1 | /test-no-escalate 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /fixuid 2 | /fixuid-*.tar.gz 3 | 4 | /.idea 5 | -------------------------------------------------------------------------------- /docker/fs-stage/etc/fixuid/config.yml: -------------------------------------------------------------------------------- 1 | user: docker 2 | group: docker 3 | -------------------------------------------------------------------------------- /docker/.gitignore: -------------------------------------------------------------------------------- 1 | /fs-stage/usr/local/bin/fixuid 2 | /fs-stage/usr/local/bin/test-no-escalate 3 | stage -------------------------------------------------------------------------------- /test-no-escalate/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | cd "$(dirname "$0")" 3 | 4 | rm -f ./test-no-escalate 5 | CGO_ENABLED=0 go build 6 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.1" 2 | 3 | services: 4 | alpine: 5 | build: ./docker/alpine 6 | image: "fixuid-alpine" 7 | 8 | fedora: 9 | build: ./docker/fedora 10 | image: "fixuid-fedora" 11 | 12 | debian: 13 | build: ./docker/debian 14 | image: "fixuid-debian" 15 | -------------------------------------------------------------------------------- /docker/fedora/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM fedora:latest 2 | 3 | RUN groupadd -g 1000 docker && \ 4 | useradd -u 1000 -g docker -d /home/docker -s /bin/sh docker 5 | 6 | COPY stage / 7 | RUN chmod u+s /usr/local/bin/fixuid && \ 8 | chown -R docker:docker /tmp/* 9 | 10 | USER docker:docker 11 | 12 | RUN touch /home/docker/aaa && \ 13 | touch /home/docker/zzz 14 | -------------------------------------------------------------------------------- /docker/alpine/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:latest 2 | 3 | RUN addgroup -g 1000 docker && \ 4 | adduser -u 1000 -G docker -h /home/docker -s /bin/sh -D docker 5 | 6 | COPY stage / 7 | RUN chmod u+s /usr/local/bin/fixuid && \ 8 | chown -R docker:docker /tmp/* 9 | 10 | USER docker:docker 11 | 12 | RUN touch /home/docker/aaa && \ 13 | touch /home/docker/zzz 14 | -------------------------------------------------------------------------------- /docker/debian/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:latest 2 | 3 | RUN addgroup --gid 1000 docker && \ 4 | adduser --uid 1000 --ingroup docker --home /home/docker --shell /bin/sh --disabled-password --gecos "" docker 5 | 6 | COPY stage / 7 | RUN chmod u+s /usr/local/bin/fixuid && \ 8 | chown -R docker:docker /tmp/* 9 | 10 | USER docker:docker 11 | 12 | RUN touch /home/docker/aaa && \ 13 | touch /home/docker/zzz 14 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/boxboat/fixuid 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/go-ozzo/ozzo-config v0.0.0-20160627170238-0ff174cf5aa6 7 | golang.org/x/exp v0.0.0-20230811145659-89c5cff77bcb 8 | ) 9 | 10 | require ( 11 | github.com/BurntSushi/toml v1.3.2 // indirect 12 | github.com/hnakamur/jsonpreprocess v0.0.0-20171017030034-a4e954386171 // indirect 13 | gopkg.in/yaml.v2 v2.4.0 // indirect 14 | ) 15 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | cd $(dirname $0) 3 | 4 | display_usage() { 5 | echo "Usage:\n$0 [version]" 6 | } 7 | 8 | # check whether user had supplied -h or --help . If yes display usage 9 | if [ $1 = "--help" ] || [ $1 = "-h" ] 10 | then 11 | display_usage 12 | exit 0 13 | fi 14 | 15 | # check number of arguments 16 | if [ $# -ne 1 ] 17 | then 18 | display_usage 19 | exit 1 20 | fi 21 | 22 | sudo rm -f /usr/local/bin/fixuid 23 | sudo tar -C /usr/local/bin -xvzf fixuid-$1-linux-amd64.tar.gz 24 | ls -lh /usr/local/bin/fixuid 25 | -------------------------------------------------------------------------------- /docker/fs-stage/usr/local/bin/fixuid-mount-test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | expected_uid=$1 4 | expected_gid=$2 5 | 6 | rc=0 7 | 8 | files="/home/docker/mnt-dir/test-dir /home/docker/mnt-dir/test-dir/test-file /home/docker/mnt-dir/test-file /home/docker/mnt-file" 9 | for file in $files 10 | do 11 | file_uid=$(stat -c "%u" $file) 12 | if [ "$file_uid" != "$expected_uid" ] 13 | then 14 | >&2 echo "$file expected owning uid: $expected_uid, actual owning uid: $file_uid" 15 | rc=1 16 | fi 17 | 18 | file_gid=$(stat -c "%g" $file) 19 | if [ "$file_gid" != "$expected_gid" ] 20 | then 21 | >&2 echo "$file expected owning gid: $expected_gid, actual owning gid: $file_gid" 22 | rc=1 23 | fi 24 | 25 | done 26 | 27 | >&2 echo "mount test complete, RC=$rc" 28 | exit $rc 29 | -------------------------------------------------------------------------------- /pack.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | cd "$(dirname "$0")" 3 | 4 | display_usage() { 5 | echo "Usage:\n$0 [version]" 6 | } 7 | 8 | # check whether user had supplied -h or --help . If yes display usage 9 | if [ $# = "--help" ] || [ $# = "-h" ] 10 | then 11 | display_usage 12 | exit 0 13 | fi 14 | 15 | # check number of arguments 16 | if [ $# -ne 1 ] 17 | then 18 | display_usage 19 | exit 1 20 | fi 21 | 22 | for GOOS in linux; do 23 | for GOARCH in amd64 arm64 mips64 mips64le ppc64 ppc64le riscv64; do 24 | echo "packing $GOOS/$GOARCH" >&2 25 | export GOOS="$GOOS" 26 | export GOARCH="$GOARCH" 27 | ./build.sh 28 | rm -f fixuid-*"-$GOOS-$GOARCH.tar.gz" 29 | perm="$(id -u):$(id -g)" 30 | sudo chown root:root fixuid 31 | sudo chmod u+s fixuid 32 | tar -cvzf "fixuid-$1-$GOOS-$GOARCH.tar.gz" fixuid 33 | sudo chmod u-s fixuid 34 | sudo chown "$perm" fixuid 35 | done 36 | done 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 BoxBoat Technologies, LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test-no-escalate/test-no-escalate.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "syscall" 7 | ) 8 | 9 | var logger = log.New(os.Stderr, "", 0) 10 | 11 | func main() { 12 | logger.SetPrefix("test-no-escalate: ") 13 | 14 | logger.Printf("Current UID: %d, GID: %d", os.Getuid(), os.Getgid()) 15 | logger.Printf("Current EUID: %d, EGID: %d", os.Geteuid(), os.Getegid()) 16 | 17 | // Test that both seteuid(0) and setegid(0) fail as expected 18 | euidError := syscall.Seteuid(0) 19 | egidError := syscall.Setegid(0) 20 | 21 | if euidError != nil && egidError != nil { 22 | logger.Printf("Got expected error when setting EUID to 0: %v", euidError) 23 | logger.Printf("Got expected error when setting EGID to 0: %v", egidError) 24 | // This is the expected behavior - exit with success 25 | os.Exit(0) 26 | } else { 27 | // At least one of them succeeded, which is a security vulnerability 28 | if euidError == nil { 29 | logger.Printf("ERROR: Successfully set EUID to 0. New EUID: %d", os.Geteuid()) 30 | } 31 | if egidError == nil { 32 | logger.Printf("ERROR: Successfully set EGID to 0. New EGID: %d", os.Getegid()) 33 | } 34 | // Exit with failure 35 | os.Exit(1) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= 2 | github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= 3 | github.com/go-ozzo/ozzo-config v0.0.0-20160627170238-0ff174cf5aa6 h1:T2JpXPk0mDD6uTT6vAwmd6pmaPqiHsBvP9Ggjr3UpE4= 4 | github.com/go-ozzo/ozzo-config v0.0.0-20160627170238-0ff174cf5aa6/go.mod h1:2RI3/USV7S8KzKNwmZtofbkg/BsCIAmeqJ5sJBWQ6T4= 5 | github.com/hnakamur/jsonpreprocess v0.0.0-20171017030034-a4e954386171 h1:G9nrYr376hLdDulCFOSmRiEa6X5vV6E/ANh+lQWmN4I= 6 | github.com/hnakamur/jsonpreprocess v0.0.0-20171017030034-a4e954386171/go.mod h1:ZSbf3Rg8HEW2bz6oeZBK8FbwS+g/s/KSrpZOx7CQSmw= 7 | golang.org/x/exp v0.0.0-20230811145659-89c5cff77bcb h1:mIKbk8weKhSeLH2GmUTrvx8CjkyJmnU1wFmg59CUjFA= 8 | golang.org/x/exp v0.0.0-20230811145659-89c5cff77bcb/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= 9 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 10 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 11 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 12 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 13 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: Main 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | name: Build 6 | runs-on: ubuntu-latest 7 | steps: 8 | 9 | - name: Checkout 10 | uses: actions/checkout@v3 11 | 12 | - name: Setup Go 13 | uses: actions/setup-go@v4 14 | 15 | - name: Print Go Version 16 | run: go version 17 | 18 | - name: Build 19 | run: ./build.sh 20 | 21 | - name: Test 22 | run: ./test.sh 23 | 24 | - name: Compute Tag 25 | if: | 26 | github.event_name == 'push' 27 | && startsWith(github.event.ref, 'refs/tags/v') 28 | id: compute_tag 29 | run: | 30 | tag=${GITHUB_REF#refs/tags/v} 31 | if [ "$tag" != "$GITHUB_REF" ]; then 32 | tag=$(echo "$tag" | sed -e 's/[^a-zA-Z0-9\-\.]/-/g') 33 | echo ::set-output name=TAG::${tag} 34 | else 35 | echo "unable to determine tag" >&2 36 | exit 1 37 | fi 38 | 39 | - name: Pack 40 | if: | 41 | github.event_name == 'push' 42 | && startsWith(github.event.ref, 'refs/tags/v') 43 | run: ./pack.sh "${{ steps.compute_tag.outputs.TAG }}" 44 | 45 | - name: Create Release 46 | if: | 47 | github.event_name == 'push' 48 | && startsWith(github.event.ref, 'refs/tags/v') 49 | env: 50 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 51 | run: | 52 | assets=() 53 | for asset in fixuid-*-*-*.tar.gz; do 54 | assets+=("-a" "$asset") 55 | done 56 | hub release create "${assets[@]}" \ 57 | -m "v${{ steps.compute_tag.outputs.TAG }}" \ 58 | "v${{ steps.compute_tag.outputs.TAG }}" 59 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [0.6.0](https://github.com/boxboat/fixuid/releases/tag/v0.6.0) - 2023-08-17 4 | 5 | ### Features 6 | 7 | - Call `syscall.Setgroups` on groups from `/etc/passwd` and `/etc/group`: [#37](https://github.com/boxboat/fixuid/pull/37) 8 | 9 | ## [0.5.1](https://github.com/boxboat/fixuid/releases/tag/v0.5.1) - 2021-07-19 10 | 11 | ### Features 12 | 13 | - Add linux architectures `mips64` `mips64le` `ppc64` `ppc64le` and `riscv64`: [#33](https://github.com/boxboat/fixuid/pull/33), [#34](https://github.com/boxboat/fixuid/pull/34) 14 | 15 | ## [0.5](https://github.com/boxboat/fixuid/releases/tag/v0.5) - 2020-06-12 16 | 17 | ### Fixes 18 | 19 | - Use Lchown so that symbolic links are not followed: [#27](https://github.com/boxboat/fixuid/pull/27) 20 | 21 | ## [0.4.1](https://github.com/boxboat/fixuid/releases/tag/v0.4.1) - 2020-04-28 22 | 23 | ### Features 24 | 25 | - Add linux arm64 release: [#23](https://github.com/boxboat/fixuid/pull/23) 26 | 27 | ## [0.4](https://github.com/boxboat/fixuid/releases/tag/v0.4) - 2018-05-24 28 | 29 | ### Features 30 | 31 | - Add quiet mode command-line flag `-q`: [#11](https://github.com/boxboat/fixuid/issues/11) 32 | 33 | ## [0.3](https://github.com/boxboat/fixuid/releases/tag/v0.3) - 2018-01-15 34 | 35 | ### Features 36 | 37 | - Allow specifying paths to search: [#5](https://github.com/boxboat/fixuid/issues/5) 38 | 39 | ### Fixes 40 | 41 | - Change Mount Detection to read /proc/mounts: [#7](https://github.com/boxboat/fixuid/issues/7) 42 | - Handle errors from `lstat` and `filepath.readDirNames`: [#4](https://github.com/boxboat/fixuid/issues/4) 43 | 44 | ## [0.2](https://github.com/boxboat/fixuid/releases/tag/v0.2) - 2017-11-08 45 | 46 | ### Fixes 47 | 48 | - Properly skip mounted files: [#3](https://github.com/boxboat/fixuid/pull/3) 49 | 50 | ## [0.1](https://github.com/boxboat/fixuid/releases/tag/v0.1) - 2017-07-18 51 | 52 | - Initial Release 53 | -------------------------------------------------------------------------------- /docker/fs-stage/usr/local/bin/fixuid-test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | expected_user="$1" 4 | expected_group="$2" 5 | expected_groups="$3" 6 | if [ -z "$expected_groups" ]; then 7 | expected_groups="$expected_group" 8 | fi 9 | 10 | if [ ! -f /var/run/fixuid.ran ] 11 | then 12 | set -e 13 | set_home=$( fixuid $FIXUID_FLAGS ) 14 | set +e 15 | eval $set_home 16 | fi 17 | 18 | rc=0 19 | 20 | user=$(id -u -n) 21 | if [ "$user" != "$expected_user" ] 22 | then 23 | >&2 echo "expected user: $expected_user, actual user: $user" 24 | rc=1 25 | fi 26 | 27 | group=$(id -g -n) 28 | if [ "$group" != "$expected_group" ] 29 | then 30 | >&2 echo "expected group: $expected_group, actual group: $group" 31 | rc=1 32 | fi 33 | 34 | groups=$(groups) 35 | if [ "$groups" != "$expected_groups" ] 36 | then 37 | >&2 echo "expected groups: [$expected_groups], actual groups: [$groups]" 38 | rc=1 39 | fi 40 | 41 | OLD_IFS="$IFS" 42 | IFS="|" 43 | files="/tmp/test-dir|/tmp/test-dir/test-file|/tmp/test-file|/home/docker|/home/docker/aaa|/home/docker/zzz|/tmp/space dir|/tmp/space dir/space file|/tmp/space file" 44 | for file in $files 45 | do 46 | file_user=$(stat -c "%U" $file) 47 | if [ "$file_user" != "$expected_user" ] 48 | then 49 | >&2 echo "$file expected owning user: $expected_user, actual owning user: $file_user" 50 | rc=1 51 | fi 52 | 53 | file_group=$(stat -c "%G" $file) 54 | if [ "$file_group" != "$expected_group" ] 55 | then 56 | >&2 echo "$file expected owning group: $expected_group, actual owning group: $file_group" 57 | rc=1 58 | fi 59 | done 60 | IFS="$OLD_IFS" 61 | 62 | if [ "$user" = "root" ] 63 | then 64 | if [ "$HOME" != "/root" ] 65 | then 66 | >&2 echo "expected home directory: /root, actual home directory: $HOME" 67 | rc=1 68 | fi 69 | elif [ "$HOME" != "/home/$user" ] 70 | then 71 | >&2 echo "expected home directory: /home/$user, actual home directory: $HOME" 72 | rc=1 73 | fi 74 | 75 | >&2 echo "test complete, RC=$rc" 76 | exit $rc 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fixuid 2 | 3 | ![Build Status](https://github.com/boxboat/fixuid/workflows/Main/badge.svg) 4 | 5 | `fixuid` is a Go binary that changes a Docker container's user/group and file permissions that were set at build time to the UID/GID that the container was started with at runtime. Primary use case is in development Docker containers when working with host mounted volumes. 6 | 7 | `fixuid` was born because there is currently no way to remap host volume UIDs/GIDs from the Docker Engine, [see moby issue 7198](https://github.com/moby/moby/issues/7198) for more details. 8 | 9 | Check out [BoxBoat's blog post](https://web.archive.org/web/20230530104308/https://boxboat.com/2017/07/25/fixuid-change-docker-container-uid-gid/) for a practical explanation of how `fixuid` benefits development teams consisting of multiple developers. 10 | 11 | **fixuid should only be used in development Docker containers. DO NOT INCLUDE in a production container image** 12 | 13 | # Overview 14 | 15 | - build a Dockerfile with user/group `dockeruser:dockergroup` that has UID/GID `1000:1000` 16 | - host is running as UID/GID 1001:1002, host mounted volume has permissions 1001:1002 17 | - run the docker container with argument `-u 1001:1002` so that container is now running with same UID/GID as host 18 | - `fixuid` can run as an entrypoint or in a startup script and performs the following: 19 | - changes `dockeruser` UID to 1001 20 | - changes `dockergroup` GID to 1002 21 | - changes all file permissions for old `dockeruser:dockergroup` to 1001:1002 22 | - updates $HOME inside container to `dockeruser` $HOME 23 | - now container UID/GID matches host UID/GID and files created in the container on the host mount will be correct 24 | 25 | ## Motivation 26 | 27 | Common Docker development workflows involve mounting source code into a container via a host volume. Build tools such as `gradle`, `yarn`, `webpack`, etc. download dependencies and create files in the host mount. 28 | 29 | Many times the UID/GID of the build tools running in the Docker container do not match the UID/GID of the mounted host volume, and files generated in the container do not match files in the host volume. This can lead to problems, such as an IDE running on the host not able to modify a file that was created by the container due to a file ownership mismatch. 30 | 31 | In large development teams, it is possible to have many developers running as different UIDs/GIDs on their host systems. With `fixuid`, individual developers can run the same container using the appropriate UID/GID for their host environment. 32 | 33 | ## Install fixuid in Dockerfile 34 | 35 | 1. Create a non-root user and group inside of your docker container. Use any UID/GID, 1000:1000 is a good choice. 36 | 37 | Note: some images already create UID/GID 1000:1000 for you, e.g. `nodejs` creates user/group `node:node` as UID/GID 1000:1000. In this case you can skip this step and use the `node:node` user/group. 38 | 39 | ``` 40 | # sample command to create user/group on different base images 41 | # creates user "docker" with UID 1000, home directory /home/docker, and shell /bin/sh 42 | # creates group "docker" with GID 1000 43 | 44 | # alpine 45 | RUN addgroup -g 1000 docker && \ 46 | adduser -u 1000 -G docker -h /home/docker -s /bin/sh -D docker 47 | 48 | # debian / ubuntu 49 | RUN addgroup --gid 1000 docker && \ 50 | adduser --uid 1000 --ingroup docker --home /home/docker --shell /bin/sh --disabled-password --gecos "" docker 51 | 52 | # fedora 53 | RUN groupadd -g 1000 docker && \ 54 | useradd -u 1000 -g docker -d /home/docker -s /bin/sh docker 55 | ``` 56 | 57 | 2. Install `fixuid` in the container, ensure that root owns the file, make it execuatble, and enable the [setuid bit](https://en.wikipedia.org/wiki/Setuid). Create the file `/etc/fixuid/config.yml` with two lines, `user: ` and `group: ` using the user and group from step 1. 58 | 59 | Note: this command must be run as root and requires that `curl` is installed in the container 60 | 61 | ``` 62 | RUN USER=docker && \ 63 | GROUP=docker && \ 64 | curl -SsL https://github.com/boxboat/fixuid/releases/download/v0.6.0/fixuid-0.6.0-linux-amd64.tar.gz | tar -C /usr/local/bin -xzf - && \ 65 | chown root:root /usr/local/bin/fixuid && \ 66 | chmod 4755 /usr/local/bin/fixuid && \ 67 | mkdir -p /etc/fixuid && \ 68 | printf "user: $USER\ngroup: $GROUP\n" > /etc/fixuid/config.yml 69 | ``` 70 | 71 | 3. Set the default user/group to `user:group` and set the entrypoint to `fixuid`. 72 | 73 | ``` 74 | USER docker:docker 75 | ENTRYPOINT ["fixuid"] 76 | ``` 77 | 78 | 4. Run the container using UID/GID of your host. Replace `1000:1000` with your host's `UID/GID`: 79 | 80 | ``` 81 | docker run --rm -it -u 1000:1000 sh 82 | ``` 83 | 84 | ## Set Default Values inside of Docker Compose 85 | 86 | Set a default UID and GID for the container to run as inside of the `docker-compose.yml` file. Developers who are running as a different UID/GID on their host can override the defaults using environment variables or a [.env file](https://docs.docker.com/compose/env-file/) 87 | 88 | ``` 89 | nginx: 90 | image: my-nginx 91 | user: ${FIXUID:-1000}:${FIXGID:-1000} 92 | volumes: 93 | - ./nginx:/etc/nginx 94 | - ./www:/var/www 95 | ``` 96 | 97 | ## Specify Paths and Behavior across Devices 98 | 99 | The default behavior of `fixuid` is to start at the root path `/` and recursively scan each file and directory on the same devices as `/`. In the configuration file `/etc/fixuid/config.yml`, you can specify specify the directories that should be recursively scanned: 100 | 101 | ```yaml 102 | user: docker 103 | group: docker 104 | paths: 105 | - /home/docker 106 | - /tmp 107 | ``` 108 | 109 | `fixuid` will only recurse into a directory as long as it is on the same initial device specified in `paths` and will not recurse into directories mounted on other devices. This includes Docker volumes. If you want `fixuid` to run on the root Docker filesystem and a Docker volume at `/home/docker/.cache`, your configuration should include: 110 | 111 | ```yaml 112 | user: docker 113 | group: docker 114 | paths: 115 | - / 116 | - /home/docker/.cache 117 | ``` 118 | 119 | ## Run in Startup Script instead of Entrypoint 120 | 121 | You can run `fixuid` as part of your container's startup script. `fixuid` will `export HOME=/path/to/home` if $HOME is the default value of `/`, so be sure to evaluate the output of `fixuid` when running as a script. Supplementary groups will not be set in this mode. 122 | 123 | ``` 124 | #!/bin/sh 125 | 126 | # UID/GID map to unknown user/group, $HOME=/ (the default when no home directory is defined) 127 | 128 | eval $( fixuid ) 129 | 130 | # UID/GID now match user/group, $HOME has been set to user's home directory 131 | ``` 132 | 133 | ## Command-Line Flags 134 | 135 | `fixuid` has the following command-line flags: 136 | 137 | ``` 138 | Usage of ./fixuid: 139 | -q quiet mode 140 | ``` 141 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | cd $(dirname $0) 3 | set -e 4 | 5 | # build fixuid 6 | ./build.sh 7 | mv fixuid docker/fs-stage/usr/local/bin 8 | 9 | # build test-no-escalate 10 | ./test-no-escalate/build.sh 11 | mv test-no-escalate/test-no-escalate docker/fs-stage/usr/local/bin 12 | 13 | rm -rf docker/alpine/stage 14 | cp -r docker/fs-stage docker/alpine/stage 15 | rm -rf docker/fedora/stage 16 | cp -r docker/fs-stage docker/fedora/stage 17 | rm -rf docker/debian/stage 18 | cp -r docker/fs-stage docker/debian/stage 19 | 20 | docker compose build 21 | 22 | echo "\nalpine default user/group cmd" 23 | docker run --rm fixuid-alpine fixuid-test.sh docker docker 24 | echo "\nfedora default user/group cmd" 25 | docker run --rm fixuid-fedora fixuid-test.sh docker docker 26 | echo "\ndebian default user/group cmd" 27 | docker run --rm fixuid-debian fixuid-test.sh docker docker 28 | echo "\nalpine default user/group entrypoint" 29 | docker run --rm --entrypoint fixuid fixuid-alpine fixuid-test.sh docker docker 30 | echo "\nfedora default user/group entrypoint" 31 | docker run --rm --entrypoint fixuid fixuid-fedora fixuid-test.sh docker docker 32 | echo "\ndebian default user/group entrypoint" 33 | docker run --rm --entrypoint fixuid fixuid-debian fixuid-test.sh docker docker "docker users" 34 | 35 | echo "\nalpine 1001:1001 cmd" 36 | docker run --rm -u 1001:1001 fixuid-alpine fixuid-test.sh docker docker 37 | echo "\nfedora 1001:1001 cmd" 38 | docker run --rm -u 1001:1001 fixuid-fedora fixuid-test.sh docker docker 39 | echo "\ndebian 1001:1001 cmd" 40 | docker run --rm -u 1001:1001 fixuid-debian fixuid-test.sh docker docker 41 | echo "\nalpine 1001:1001 entrypoint" 42 | docker run --rm -u 1001:1001 --entrypoint fixuid fixuid-alpine fixuid-test.sh docker docker 43 | echo "\nfedora 1001:1001 entrypoint" 44 | docker run --rm -u 1001:1001 --entrypoint fixuid fixuid-fedora fixuid-test.sh docker docker 45 | echo "\ndebian 1001:1001 entrypoint" 46 | docker run --rm -u 1001:1001 --entrypoint fixuid fixuid-debian fixuid-test.sh docker docker "docker users" 47 | 48 | echo "\nalpine 0:0 cmd" 49 | docker run --rm -u 0:0 fixuid-alpine fixuid-test.sh root root 50 | echo "\nfedora 0:0 cmd" 51 | docker run --rm -u 0:0 fixuid-fedora fixuid-test.sh root root 52 | echo "\ndebian 0:0 cmd" 53 | docker run --rm -u 0:0 fixuid-debian fixuid-test.sh root root 54 | echo "\nalpine 0:0 entrypoint" 55 | docker run --rm -u 0:0 --entrypoint fixuid fixuid-alpine fixuid-test.sh root root "root bin daemon sys adm disk wheel floppy dialout tape video" 56 | echo "\nfedora 0:0 entrypoint" 57 | docker run --rm -u 0:0 --entrypoint fixuid fixuid-fedora fixuid-test.sh root root 58 | echo "\ndebian 0:0 entrypoint" 59 | docker run --rm -u 0:0 --entrypoint fixuid fixuid-debian fixuid-test.sh root root 60 | 61 | echo "\nalpine 0:1001 cmd" 62 | docker run --rm -u 0:1001 fixuid-alpine fixuid-test.sh root docker 63 | echo "\nfedora 0:1001 cmd" 64 | docker run --rm -u 0:1001 fixuid-fedora fixuid-test.sh root docker 65 | echo "\ndebian 0:1001 cmd" 66 | docker run --rm -u 0:1001 fixuid-debian fixuid-test.sh root docker 67 | echo "\nalpine 0:1001 entrypoint" 68 | docker run --rm -u 0:1001 --entrypoint fixuid fixuid-alpine fixuid-test.sh root docker "docker root bin daemon sys adm disk wheel floppy dialout tape video" 69 | echo "\nfedora 0:1001 entrypoint" 70 | docker run --rm -u 0:1001 --entrypoint fixuid fixuid-fedora fixuid-test.sh root docker "docker root" 71 | echo "\ndebian 0:1001 entrypoint" 72 | docker run --rm -u 0:1001 --entrypoint fixuid fixuid-debian fixuid-test.sh root docker "docker root" 73 | 74 | echo "\nalpine 1001:0 cmd" 75 | docker run --rm -u 1001:0 fixuid-alpine fixuid-test.sh docker root 76 | echo "\nfedora 1001:0 cmd" 77 | docker run --rm -u 1001:0 fixuid-fedora fixuid-test.sh docker root 78 | echo "\ndebian 1001:0 cmd" 79 | docker run --rm -u 1001:0 fixuid-debian fixuid-test.sh docker root 80 | echo "\nalpine 1001:0 entrypoint" 81 | docker run --rm -u 1001:0 --entrypoint fixuid fixuid-alpine fixuid-test.sh docker root "root docker" 82 | echo "\nfedora 1001:0 entrypoint" 83 | docker run --rm -u 1001:0 --entrypoint fixuid fixuid-fedora fixuid-test.sh docker root "root docker" 84 | echo "\ndebian 1001:0 entrypoint" 85 | docker run --rm -u 1001:0 --entrypoint fixuid fixuid-debian fixuid-test.sh docker root "root users docker" 86 | 87 | echo "\nalpine run twice cmd" 88 | docker run --rm fixuid-alpine sh -c "fixuid-test.sh docker docker && fixuid fixuid-test.sh docker docker" 89 | echo "\nfedora run twice cmd" 90 | docker run --rm fixuid-fedora sh -c "fixuid-test.sh docker docker && fixuid fixuid-test.sh docker docker" 91 | echo "\ndebian run twice cmd" 92 | docker run --rm fixuid-debian sh -c "fixuid-test.sh docker docker && fixuid fixuid-test.sh docker docker 'docker users'" 93 | echo "\nalpine run twice entrypoint" 94 | docker run --rm --entrypoint fixuid fixuid-alpine sh -c "fixuid-test.sh docker docker && fixuid fixuid-test.sh docker docker" 95 | echo "\nfedora run twice entrypoint" 96 | docker run --rm --entrypoint fixuid fixuid-fedora sh -c "fixuid-test.sh docker docker && fixuid fixuid-test.sh docker docker" 97 | echo "\ndebian run twice entrypoint" 98 | docker run --rm --entrypoint fixuid fixuid-debian sh -c "fixuid-test.sh docker docker 'docker users' && fixuid fixuid-test.sh docker docker 'docker users'" 99 | 100 | echo "\nalpine should not chown mount" 101 | docker run --rm -v $(pwd)/docker/fs-stage/tmp:/home/docker/mnt-dir -v $(pwd)/docker/fs-stage/tmp/test-file:/home/docker/mnt-file -u 1234:1234 fixuid-alpine sh -c "fixuid-test.sh docker docker && fixuid-mount-test.sh $(id -u) $(id -g)" 102 | echo "\nfedora should not chown mount" 103 | docker run --rm -v $(pwd)/docker/fs-stage/tmp:/home/docker/mnt-dir -v $(pwd)/docker/fs-stage/tmp/test-file:/home/docker/mnt-file -u 1234:1234 fixuid-fedora sh -c "fixuid-test.sh docker docker && fixuid-mount-test.sh $(id -u) $(id -g)" 104 | echo "\ndebian should not chown mount" 105 | docker run --rm -v $(pwd)/docker/fs-stage/tmp:/home/docker/mnt-dir -v $(pwd)/docker/fs-stage/tmp/test-file:/home/docker/mnt-file -u 1234:1234 fixuid-debian sh -c "fixuid-test.sh docker docker && fixuid-mount-test.sh $(id -u) $(id -g)" 106 | 107 | echo "\nalpine quiet cmd" 108 | docker run --rm -e "FIXUID_FLAGS=-q" fixuid-alpine fixuid-test.sh docker docker 109 | echo "\nfedora quiet cmd" 110 | docker run --rm -e "FIXUID_FLAGS=-q" fixuid-fedora fixuid-test.sh docker docker 111 | echo "\ndebian quiet cmd" 112 | docker run --rm -e "FIXUID_FLAGS=-q" fixuid-debian fixuid-test.sh docker docker 113 | echo "\nalpine quiet entrypoint" 114 | docker run --rm --entrypoint fixuid fixuid-alpine -q fixuid-test.sh docker docker 115 | echo "\nfedora quiet entrypoint" 116 | docker run --rm --entrypoint fixuid fixuid-fedora -q fixuid-test.sh docker docker 117 | echo "\ndebian quiet entrypoint" 118 | docker run --rm --entrypoint fixuid fixuid-debian -q fixuid-test.sh docker docker 'docker users' 119 | 120 | echo "\nalpine test no escalate" 121 | docker run --rm --entrypoint fixuid fixuid-alpine test-no-escalate 122 | echo "\nfedora test no escalate" 123 | docker run --rm --entrypoint fixuid fixuid-fedora test-no-escalate 124 | echo "\ndebian test no escalate" 125 | docker run --rm --entrypoint fixuid fixuid-debian test-no-escalate 126 | 127 | printf "\npaths:\n - /\n - /home/docker\n - /tmp/space dir\n - /does/not/exist" >> docker/alpine/stage/etc/fixuid/config.yml 128 | printf "\npaths:\n - /\n - /home/docker\n - /tmp/space dir\n - /does/not/exist" >> docker/fedora/stage/etc/fixuid/config.yml 129 | printf "\npaths:\n - /\n - /home/docker\n - /tmp/space dir\n - /does/not/exist" >> docker/debian/stage/etc/fixuid/config.yml 130 | docker compose build 131 | 132 | echo "\nalpine 1001:1001 cmd" 133 | docker run --rm -u 1001:1001 -v /home/docker -v "/tmp/space dir" fixuid-alpine fixuid-test.sh docker docker 134 | echo "\nfedora 1001:1001 cmd" 135 | docker run --rm -u 1001:1001 -v /home/docker -v "/tmp/space dir" fixuid-fedora fixuid-test.sh docker docker 136 | echo "\ndebian 1001:1001 cmd" 137 | docker run --rm -u 1001:1001 -v /home/docker -v "/tmp/space dir" fixuid-debian fixuid-test.sh docker docker 138 | echo "\nalpine 1001:1001 entrypoint" 139 | docker run --rm -u 1001:1001 -v /home/docker -v "/tmp/space dir" --entrypoint fixuid fixuid-alpine fixuid-test.sh docker docker 140 | echo "\nfedora 1001:1001 entrypoint" 141 | docker run --rm -u 1001:1001 -v /home/docker -v "/tmp/space dir" --entrypoint fixuid fixuid-fedora fixuid-test.sh docker docker 142 | echo "\ndebian 1001:1001 entrypoint" 143 | docker run --rm -u 1001:1001 -v /home/docker -v "/tmp/space dir" --entrypoint fixuid fixuid-debian fixuid-test.sh docker docker "docker users" 144 | -------------------------------------------------------------------------------- /fixuid.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "errors" 6 | "flag" 7 | "fmt" 8 | "golang.org/x/exp/slices" 9 | "log" 10 | "os" 11 | "os/exec" 12 | "path" 13 | "path/filepath" 14 | "runtime" 15 | "strconv" 16 | "strings" 17 | "syscall" 18 | 19 | config "github.com/go-ozzo/ozzo-config" 20 | ) 21 | 22 | const ranFile = "/var/run/fixuid.ran" 23 | 24 | var logger = log.New(os.Stderr, "", 0) 25 | var quietFlag = flag.Bool("q", false, "quiet mode") 26 | 27 | func main() { 28 | runtime.GOMAXPROCS(1) 29 | logger.SetPrefix("fixuid: ") 30 | flag.Parse() 31 | 32 | // development warning 33 | logInfo("fixuid should only ever be used on development systems. DO NOT USE IN PRODUCTION") 34 | 35 | argsWithoutProg := flag.Args() 36 | // detect what user we are running as 37 | runtimeUIDInt := os.Getuid() 38 | runtimeUID := strconv.Itoa(runtimeUIDInt) 39 | runtimeGIDInt := os.Getgid() 40 | runtimeGID := strconv.Itoa(runtimeGIDInt) 41 | 42 | // only run once on the system 43 | if _, err := os.Stat(ranFile); !os.IsNotExist(err) { 44 | logInfo("already ran on this system; will not attempt to change UID/GID") 45 | exitOrExec(runtimeUID, runtimeUIDInt, runtimeGIDInt, -1, argsWithoutProg) 46 | } 47 | 48 | // check that script is running as root 49 | if os.Geteuid() != 0 { 50 | logger.Fatalln(`fixuid is not running as root, ensure that the following criteria are met: 51 | - fixuid binary is owned by root: 'chown root:root /path/to/fixuid' 52 | - fixuid binary has the setuid bit: 'chmod u+s /path/to/fixuid' 53 | - NoNewPrivileges is disabled in container security profile 54 | - volume containing fixuid binary does not have the 'nosuid' mount option`) 55 | } 56 | 57 | // load config from /etc/fixuid/config.[json|toml|yaml|yml] 58 | rootConfig := config.New() 59 | configError := errors.New("could not find config at /etc/fixuid/config.[json|toml|yaml|yml]") 60 | var filePath string 61 | for _, fileName := range [...]string{"config.json", "config.toml", "config.yaml", "config.yml"} { 62 | filePath = path.Join("/etc/fixuid", fileName) 63 | if _, err := os.Stat(filePath); !os.IsNotExist(err) { 64 | configError = rootConfig.Load(filePath) 65 | if configError != nil { 66 | logInfo("error when loading configuration file " + filePath) 67 | } else { 68 | break 69 | } 70 | } 71 | } 72 | if configError != nil { 73 | logger.Fatalln(configError) 74 | } 75 | 76 | // validate the container user from the config 77 | containerUser := rootConfig.GetString("user") 78 | if containerUser == "" { 79 | logger.Fatalln("cannot find key 'user' in configuration file " + filePath) 80 | } 81 | 82 | containerUID, containerUIDError := findUID(containerUser) 83 | if containerUIDError != nil { 84 | logger.Fatalln(containerUIDError) 85 | } 86 | if containerUID == "" { 87 | logger.Fatalln("user '" + containerUser + "' does not exist") 88 | } 89 | containerUIDInt, err := strconv.Atoi(containerUID) 90 | if err != nil { 91 | logger.Fatal(err) 92 | } 93 | containerUIDUint32 := uint32(containerUIDInt) 94 | 95 | // validate the container group from the config 96 | containerGroup := rootConfig.GetString("group") 97 | if containerGroup == "" { 98 | logger.Fatalln("cannot find key 'group' in configuration file " + filePath) 99 | } 100 | containerGID, containerGIDError := findGID(containerGroup) 101 | if containerGIDError != nil { 102 | logger.Fatalln(containerGIDError) 103 | } 104 | if containerGID == "" { 105 | logger.Fatalln("group '" + containerGroup + "' does not exist") 106 | } 107 | containerGIDInt, err := strconv.Atoi(containerGID) 108 | if err != nil { 109 | logger.Fatal(err) 110 | } 111 | containerGIDUint32 := uint32(containerGIDInt) 112 | 113 | // validate the paths from the config 114 | var paths []string 115 | err = rootConfig.Configure(&paths, "paths") 116 | if err != nil { 117 | switch err.(type) { 118 | case *config.ConfigPathError: 119 | paths = append(paths, "/") 120 | default: 121 | logger.Fatalln("key 'paths' is malformed; should be an array of strings in configuration file " + filePath) 122 | } 123 | } 124 | 125 | // declare uid/gid vars and 126 | var oldUID, newUID, oldGID, newGID string 127 | needChown := false 128 | 129 | // decide if need to change UIDs 130 | existingUser, existingUserError := findUser(runtimeUID) 131 | if existingUserError != nil { 132 | logger.Fatalln(existingUserError) 133 | } 134 | if existingUser == "" { 135 | logInfo("updating user '" + containerUser + "' to UID '" + runtimeUID + "'") 136 | needChown = true 137 | oldUID = containerUID 138 | newUID = runtimeUID 139 | } else { 140 | oldUID = "" 141 | newUID = "" 142 | if existingUser == containerUser { 143 | logInfo("runtime UID '" + runtimeUID + "' already matches container user '" + containerUser + "' UID") 144 | } else { 145 | logInfo("runtime UID '" + runtimeUID + "' matches existing user '" + existingUser + "'; not changing UID") 146 | needChown = true 147 | } 148 | } 149 | 150 | // decide if need to change GIDs 151 | existingGroup, existingGroupError := findGroup(runtimeGID) 152 | if existingGroupError != nil { 153 | logger.Fatalln(existingGroupError) 154 | } 155 | if existingGroup == "" { 156 | logInfo("updating group '" + containerGroup + "' to GID '" + runtimeGID + "'") 157 | needChown = true 158 | oldGID = containerGID 159 | newGID = runtimeGID 160 | } else { 161 | oldGID = "" 162 | newGID = "" 163 | if existingGroup == containerGroup { 164 | logInfo("runtime GID '" + runtimeGID + "' already matches container group '" + containerGroup + "' GID") 165 | } else { 166 | logInfo("runtime GID '" + runtimeGID + "' matches existing group '" + existingGroup + "'; not changing GID") 167 | needChown = true 168 | } 169 | } 170 | 171 | // update /etc/passwd if necessary 172 | if oldUID != newUID || oldGID != newGID { 173 | err := updateEtcPasswd(containerUser, oldUID, newUID, oldGID, newGID) 174 | if err != nil { 175 | logger.Fatalln(err) 176 | } 177 | } 178 | 179 | // update /etc/group if necessary 180 | if oldGID != newGID { 181 | err := updateEtcGroup(containerGroup, oldGID, newGID) 182 | if err != nil { 183 | logger.Fatalln(err) 184 | } 185 | } 186 | 187 | // search entire filesystem and chown containerUID:containerGID to runtimeUID:runtimeGID 188 | if needChown { 189 | 190 | // process /proc/mounts 191 | mounts, err := parseProcMounts() 192 | if err != nil { 193 | logger.Fatalln(err) 194 | } 195 | 196 | // store the current mountpoint 197 | var mountpoint string 198 | 199 | // this function is called for every file visited 200 | visit := func(filePath string, fileInfo os.FileInfo, err error) error { 201 | 202 | // an error to lstat or filepath.readDirNames 203 | // see https://github.com/boxboat/fixuid/issues/4 204 | if err != nil { 205 | logInfo("error when visiting " + filePath) 206 | logInfo(err) 207 | return nil 208 | } 209 | 210 | // stat file to determine UID and GID 211 | sys, ok := fileInfo.Sys().(*syscall.Stat_t) 212 | if !ok { 213 | logInfo("cannot stat " + filePath) 214 | return filepath.SkipDir 215 | } 216 | 217 | // prevent recursing into mounts 218 | if findMountpoint(filePath, mounts) != mountpoint { 219 | if sys.Uid == containerUIDUint32 && sys.Gid == containerGIDUint32 { 220 | logInfo("skipping mounted path " + filePath) 221 | } 222 | if fileInfo.IsDir() { 223 | return filepath.SkipDir 224 | } 225 | return nil 226 | } 227 | 228 | // only chown if file is containerUID:containerGID 229 | if sys.Uid == containerUIDUint32 && sys.Gid == containerGIDUint32 { 230 | logInfo("chown " + filePath) 231 | err := syscall.Lchown(filePath, runtimeUIDInt, runtimeGIDInt) 232 | if err != nil { 233 | logInfo("error changing owner of " + filePath) 234 | logInfo(err) 235 | } 236 | return nil 237 | } 238 | return nil 239 | } 240 | 241 | for _, path := range paths { 242 | // stat the path to ensure it exists 243 | _, err := os.Stat(path) 244 | if err != nil { 245 | logInfo("error accessing path: " + path) 246 | logInfo(err) 247 | continue 248 | } 249 | mountpoint = findMountpoint(path, mounts) 250 | 251 | logInfo("recursively searching path " + path) 252 | filepath.Walk(path, visit) 253 | } 254 | 255 | } 256 | 257 | // mark the script as ran 258 | if err := os.WriteFile(ranFile, []byte{}, 0644); err != nil { 259 | logger.Fatalln(err) 260 | } 261 | 262 | // if the existing HOME directory is "/", change it to the user's home directory 263 | existingHomeDir := os.Getenv("HOME") 264 | if existingHomeDir == "/" { 265 | homeDir, homeDirErr := findHomeDir(runtimeUID) 266 | if homeDirErr == nil && homeDir != "" && homeDir != "/" { 267 | if len(argsWithoutProg) > 0 { 268 | os.Setenv("HOME", homeDir) 269 | } else { 270 | fmt.Println(`export HOME="` + strings.Replace(homeDir, `"`, `\"`, -1) + `"`) 271 | } 272 | } 273 | } 274 | 275 | oldGIDInt := -1 276 | if oldGID != "" && oldGID != newGID { 277 | if gid, err := strconv.Atoi(oldGID); err != nil { 278 | oldGIDInt = gid 279 | } 280 | } 281 | 282 | // all done 283 | exitOrExec(runtimeUID, runtimeUIDInt, runtimeGIDInt, oldGIDInt, argsWithoutProg) 284 | } 285 | 286 | func logInfo(v ...interface{}) { 287 | if !*quietFlag { 288 | logger.Println(v...) 289 | } 290 | } 291 | 292 | // oldGIDInt should be -1 if the GID was not changed 293 | func exitOrExec(runtimeUID string, runtimeUIDInt, runtimeGIDInt, oldGIDInt int, argsWithoutProg []string) { 294 | if len(argsWithoutProg) > 0 { 295 | // exec mode - de-escalate privileges and exec new process 296 | binary, err := exec.LookPath(argsWithoutProg[0]) 297 | if err != nil { 298 | logger.Fatalln(err) 299 | } 300 | 301 | // get real user 302 | user, err := findUser(runtimeUID) 303 | if err != nil { 304 | logger.Fatalln(err) 305 | } 306 | 307 | // set groups 308 | if user != "" { 309 | // get all existing group IDs 310 | existingGIDs, err := syscall.Getgroups() 311 | if err != nil { 312 | logger.Fatalln(err) 313 | } 314 | 315 | // get primary GID from /etc/passwd 316 | primaryGID, err := findPrimaryGID(runtimeUID) 317 | if err != nil { 318 | logger.Fatalln(err) 319 | } 320 | 321 | // get supplementary GIDs from /etc/group 322 | supplementaryGIDs, err := findUserSupplementaryGIDs(user) 323 | if err != nil { 324 | logger.Fatalln(err) 325 | } 326 | 327 | // add all GIDs to a map 328 | allGIDs := append(existingGIDs, primaryGID) 329 | allGIDs = append(allGIDs, supplementaryGIDs...) 330 | gidMap := make(map[int]struct{}) 331 | for _, gid := range allGIDs { 332 | gidMap[gid] = struct{}{} 333 | } 334 | 335 | // remove the old GID if it was changed 336 | if oldGIDInt >= 0 { 337 | delete(gidMap, oldGIDInt) 338 | } 339 | 340 | groups := make([]int, 0, len(gidMap)) 341 | for gid := range gidMap { 342 | groups = append(groups, gid) 343 | } 344 | 345 | // set groups 346 | err = syscall.Setgroups(groups) 347 | if err != nil { 348 | logger.Fatalln(err) 349 | } 350 | } 351 | 352 | // de-escalate the group back to the original 353 | if err := syscall.Setegid(runtimeGIDInt); err != nil { 354 | logger.Fatalln(err) 355 | } 356 | 357 | // de-escalate the user back to the original 358 | if err := syscall.Seteuid(runtimeUIDInt); err != nil { 359 | logger.Fatalln(err) 360 | } 361 | 362 | // exec new process 363 | env := os.Environ() 364 | if err := syscall.Exec(binary, argsWithoutProg, env); err != nil { 365 | logger.Fatalln(err) 366 | } 367 | } 368 | 369 | // nothing to exec; exit the program 370 | os.Exit(0) 371 | } 372 | 373 | func searchColonDelimitedFile(filePath string, search string, searchOffset int, returnOffset int) (string, error) { 374 | file, err := os.Open(filePath) 375 | if err != nil { 376 | return "", err 377 | } 378 | defer file.Close() 379 | 380 | scanner := bufio.NewScanner(file) 381 | for scanner.Scan() { 382 | cols := strings.Split(scanner.Text(), ":") 383 | if len(cols) < (searchOffset+1) || len(cols) < (returnOffset+1) { 384 | continue 385 | } 386 | if cols[searchOffset] == search { 387 | return cols[returnOffset], nil 388 | } 389 | } 390 | return "", nil 391 | } 392 | 393 | func findUID(user string) (string, error) { 394 | return searchColonDelimitedFile("/etc/passwd", user, 0, 2) 395 | } 396 | 397 | func findUser(uid string) (string, error) { 398 | return searchColonDelimitedFile("/etc/passwd", uid, 2, 0) 399 | } 400 | 401 | // returns -1 if not found 402 | func findPrimaryGID(uid string) (int, error) { 403 | gid, err := searchColonDelimitedFile("/etc/passwd", uid, 2, 3) 404 | if err != nil { 405 | return -1, err 406 | } 407 | if gid == "" { 408 | return -1, nil 409 | } 410 | return strconv.Atoi(gid) 411 | } 412 | 413 | func findHomeDir(uid string) (string, error) { 414 | return searchColonDelimitedFile("/etc/passwd", uid, 2, 5) 415 | } 416 | 417 | func findGID(group string) (string, error) { 418 | return searchColonDelimitedFile("/etc/group", group, 0, 2) 419 | } 420 | 421 | func findGroup(gid string) (string, error) { 422 | return searchColonDelimitedFile("/etc/group", gid, 2, 0) 423 | } 424 | 425 | func findUserSupplementaryGIDs(user string) ([]int, error) { 426 | // group:pass:gid:users 427 | file, err := os.Open("/etc/group") 428 | if err != nil { 429 | return nil, err 430 | } 431 | 432 | var gids []int 433 | scanner := bufio.NewScanner(file) 434 | for scanner.Scan() { 435 | cols := strings.Split(scanner.Text(), ":") 436 | if len(cols) < 4 { 437 | continue 438 | } 439 | users := strings.Split(cols[3], ",") 440 | if !slices.Contains(users, user) { 441 | continue 442 | } 443 | gid, err := strconv.Atoi(cols[2]) 444 | if err != nil { 445 | continue 446 | } 447 | gids = append(gids, gid) 448 | } 449 | file.Close() 450 | 451 | if err := scanner.Err(); err != nil { 452 | return nil, err 453 | } 454 | 455 | return gids, nil 456 | } 457 | 458 | func updateEtcPasswd(user string, oldUID string, newUID string, oldGID string, newGID string) error { 459 | // user:pass:uid:gid:comment:home dir:shell 460 | file, err := os.Open("/etc/passwd") 461 | if err != nil { 462 | return err 463 | } 464 | 465 | newLines := "" 466 | scanner := bufio.NewScanner(file) 467 | for scanner.Scan() { 468 | cols := strings.Split(scanner.Text(), ":") 469 | if len(cols) < 4 { 470 | continue 471 | } 472 | if oldUID != "" && newUID != "" && cols[0] == user && cols[2] == oldUID { 473 | cols[2] = newUID 474 | } 475 | if oldGID != "" && newGID != "" && cols[3] == oldGID { 476 | cols[3] = newGID 477 | } 478 | newLines += strings.Join(cols, ":") + "\n" 479 | } 480 | file.Close() 481 | 482 | if err := scanner.Err(); err != nil { 483 | return err 484 | } 485 | 486 | if err := os.WriteFile("/etc/passwd", []byte(newLines), 0644); err != nil { 487 | return err 488 | } 489 | 490 | return nil 491 | } 492 | 493 | func updateEtcGroup(group string, oldGID string, newGID string) error { 494 | // group:pass:gid:users 495 | file, err := os.Open("/etc/group") 496 | if err != nil { 497 | return err 498 | } 499 | 500 | newLines := "" 501 | scanner := bufio.NewScanner(file) 502 | for scanner.Scan() { 503 | cols := strings.Split(scanner.Text(), ":") 504 | if len(cols) < 3 { 505 | continue 506 | } 507 | if oldGID != "" && newGID != "" && cols[0] == group && cols[2] == oldGID { 508 | cols[2] = newGID 509 | } 510 | newLines += strings.Join(cols, ":") + "\n" 511 | } 512 | file.Close() 513 | 514 | if err := scanner.Err(); err != nil { 515 | return err 516 | } 517 | 518 | if err := os.WriteFile("/etc/group", []byte(newLines), 0644); err != nil { 519 | return err 520 | } 521 | 522 | return nil 523 | } 524 | 525 | func parseProcMounts() (map[string]bool, error) { 526 | // device mountpoint type options dump fsck 527 | // spaces appear as \040 528 | file, err := os.Open("/proc/mounts") 529 | if err != nil { 530 | return nil, err 531 | } 532 | 533 | mounts := make(map[string]bool) 534 | scanner := bufio.NewScanner(file) 535 | for scanner.Scan() { 536 | cols := strings.Fields(scanner.Text()) 537 | if len(cols) >= 2 { 538 | mounts[filepath.Clean(strings.Replace(cols[1], "\\040", " ", -1))] = true 539 | } 540 | } 541 | file.Close() 542 | 543 | return mounts, nil 544 | } 545 | 546 | func findMountpoint(path string, mounts map[string]bool) string { 547 | path = filepath.Clean(path) 548 | var lastPath string 549 | for path != lastPath { 550 | if _, ok := mounts[path]; ok { 551 | return path 552 | } 553 | lastPath = path 554 | path = filepath.Dir(path) 555 | } 556 | return "/" 557 | } 558 | --------------------------------------------------------------------------------