Temporarily dropping root privileges in Go

Devrim Şahin
Picus Security Engineering
13 min readApr 26, 2023

Introduction

In this blog post, we will discuss a way of spawning root child processes from an effectively non-root parent process.

Now, before you grab your pitchforks, we would like to direct your attention to the ‘effectively’ part here. The parent process will start its lifecycle as a privileged process, but drop these privileges immediately. From there on, we want the parent process to act like a non-root process; until it’s time to spawn child processes.

(Notice that this is different from permanently dropping root privileges. Web servers used to do connect to lower ports as root and drop their privileges this way. Nowadays, users are recommended to set CAP_NET_BIND_SERVICE on the server binary and avoid root altogether.)

Before we get into the details, we should discuss why we want to do this.

Background

As you probably know, in Picus Security we specialize in security control validation. In order to assess the prevention capabilities of security controls, we continuously expand our library with the latest threats. These threats are categorized into modules by how these attacks are simulated; different modules verifying different kinds of security tools.

One of these modules is the Endpoint Scenario Attacks module, which simulates an attacker trying to run a malicious command or executable file in the victim’s computer (the endpoint); validating the effectiveness of endpoint security tools present in the endpoint. If the simulated process can successfully evade security tools; then the endpoint is deemed vulnerable to that kind of threat.

To run a simulation, Picus platform users download and run a simulation agent application. When the user defines a simulation run on the Picus platform and starts it, the agent is given a recipe of each threat in the simulation run. The agent then performs the actions requested; and returns results for each action. In the case of endpoint scenario attacks, the agent is asked to sequentially execute a list of commands, each in its own OS process.

In short; simulation agents should be able to execute a list of arbitrary commands.

Scenario actions can have different privilege requirements: While some commands may require that the agent acts like root, some may not need root, or may explicitly require a non-root user. For example, testing a privilege escalation threat does not make sense if the process is already root. This means that we should be able to ‘impersonate’ both root and non-root child processes.

(Quick intervention: This discussion concerns our Linux and macOS agents. The described ‘impersonation’ flow acts differently in our Windows agents, which has a different approach for user accounts.)

This is easier to achieve if the parent process is root: Child process can simply drop its privileges for non-root commands after being forked.

However, we can imagine a case where the parent process has other responsibilities; for example it may be creating log files, or access some critical resources. If we want the parent process to act as root only when spawning child processes; we have some work to do.

The method we are about to describe is no longer used in the Picus simulation agent, due to changing requirements and other use cases.

Dropping privileges permanently

Let’s first discuss how to drop root privileges permanently.

This method used to be rather common for web servers that needed to open a socket to lower ports (which requires either CAP_NET_BIND_SERVICE capability on the process, or root). Since the “binding to server port” part is done once at the beginning; the process is started as root, binds to the port, then it drops its privileges.

In Unix, access control is based on a set of user and group IDs that are associated with the process, limiting the system resources a process can access. Specifically, user ID 0 is reserved for root, who has unlimited access.

Unix also differentiates between multiple types of user IDs (uids): Real uid (ruid or simply uid), and effective uid (euid). There is also saved uid (suid), which the kernel uses internally to memorize changes in between the former two, and in Linux filesystem uid (fsuid), which controls file resource accesses. However suid is mostly an implementation detail, and fsuid is equal to euid unless explicitly set; so we can skip these. SetUID Demystified is a great resource for detailed explanation of these concepts, but we will try to summarize them.

(Group IDs have similar semantics: Real group ID is gid or rgid, effective group ID is egid, and so on.)

A process can set its uid, euid, gid or egid using the system calls setuid, seteuid, setgid or setegid respectively. There are other syscalls such as setreuid, setresuid, setfsuid etc. but these are not readily available in Go, nor needed in our case.

While setuid assigns both uid and euid values; seteuid doesn’t affect uid. setgid and setegid work similarly. Whether the process has the permission to invoke these syscalls or not is determined by the uid value.

Let’s say that we have a system where there are two users: root {uid=0 gid=0} and user {uid=501 gid=20}. (Linux systems often start at uid=gid=1000, but let’s use 501 and 20 so that we can tell them apart).

A process started as root will have {uid=0 euid=0}. Calling setuid(501) on this process will yield {uid=501 euid=501}. Therefore, using setuid also sets euid=uid. The uid value is permanently dropped to 501 and cannot be re-escalated to 0.

package main

import (
"log"
"os"
"syscall"
)

func main() {
// program must be run as root
if os.Getuid() != 0 {
log.Fatalln("program was not run as root")
}

// do something as root
log.Println("Doing some tasks as root...")
log.Printf(" {uid=%d euid=%d gid=%d egid=%d}\n",
os.Getuid(), os.Geteuid(), os.Getgid(), os.Getegid())
_ = os.WriteFile("./root_owned", []byte("hi"), 0o666)

// drop privileges
log.Println("Dropping privileges...")
log.Printf(" - setgid(20): %v\n", syscall.Setgid(20))
log.Printf(" - setuid(501): %v\n", syscall.Setuid(501))

// we are no longer root
log.Println("Root privileges permanently dropped.")
log.Printf(" {uid=%d euid=%d gid=%d egid=%d}\n",
os.Getuid(), os.Geteuid(), os.Getgid(), os.Getegid())
_ = os.WriteFile("./user_owned", []byte("hi"), 0o666)

// from here on; we cannot be root again: these calls will fail
log.Println("Trying to re-escalate:")
log.Printf(" - setuid(0): %v\n", syscall.Setuid(0))
log.Printf(" - setgid(0): %v\n", syscall.Setgid(0))
}

Several things to note here:

  • We start by asserting that the program is run as root. We do this by verifying that os.Getuid() returns 0. Unless this is true, the set*id syscalls will not work (To keep things brief, we are blatantly ignoring the CAP_SETUID file capability here. There is also a setuid file mode bit, which is a can of worms that we will gleefully omit here).
  • We print the uid, euid, gid and egid values for the process using corresponding functions under the os package (All four functions also exist under the syscall package, if that’s what you prefer). For root, we expect all these values to be 0. At this stage, we also write a file to observe that the file is root-owned (Which will show us that fsuid=0 as well; equal to euid).
  • To drop privileges, we use syscall.Setgid(20) and syscall.Setuid(501). Note that these functions are under the syscall package only, and not under os. Also, the order of these two calls matter: If we were to call Setuid first; we would not have the permission to call Setgid anymore, so the latter would fail with an “operation not permitted” error.
  • Once root privileges are dropped; we print all four IDs again; and write another file to check its ownership.
  • Finally, to prove that the privilege drop was permanent; we attempt to re-acquire root IDs. These calls should fail.

We can visualize what is happening as follows. Once setuid(501) is called, we can’t regain privileges:

Permanent permission drop via setuid

Output of the program when run as root:

Doing some tasks as root...
{uid=0 euid=0 gid=0 egid=0}
Dropping privileges...
- setgid(20): <nil>
- setuid(501): <nil>
Root privileges permanently dropped.
{uid=501 euid=501 gid=20 egid=20}
Trying to re-escalate:
- setuid(0): operation not permitted
- setgid(0): operation not permitted

Generated files have the following owners:

  • ./root_owned belongs to root (implying fsuid=0)
  • ./user_owned belongs to user (implying fsuid=501)

(On macOS, default group behavior for files may differ slightly.)

There is one annoying thing before we move on: We had to hard-code user’s IDs! Can we get them dynamically?

As we start the program as root; somehow we should obtain the uid/gid of the non-root user that we want to impersonate.

Well, if we don’t run the process from a root user’s session, but from a non-root user’s terminal instead; we can drop to the session owner user’s IDs. There is a way for (some) non-root users to start root processes: sudo.

Conveniently, sudo sets the environment variables SUDO_UID and SUDO_GID to the original user’s IDs (that is, the user in the terminal session: user {uid=501 gid=20} in our case). We can try to parse these environment variables:

// getEnvInt is a helper function to get an int value from the environment
func getEnvInt(key string) (parsed int, err error) {
if value := os.Getenv(key); value == "" {
err = fmt.Errorf("missing env key %s", key)
} else if parsed, err = strconv.Atoi(value); err != nil {
err = fmt.Errorf("cannot parse env key %s=%q: %v", key, value, err)
}
return
}

func main() {
if os.Getuid() != 0 {
log.Fatalln("program was not run as root")
}

// get original user uid/gid from environment variables
uid, err := getEnvInt("SUDO_UID")
if err != nil {
log.Fatalf("program was not run as sudo: %v\n", err)
}
gid, err := getEnvInt("SUDO_GID")
if err != nil {
log.Fatalf("program was not run as sudo: %v\n", err)
}

// -- snip --

log.Println("Dropping privileges...")
log.Printf(" - setgid(%d): %v\n", gid, syscall.Setgid(gid)) // gid instead of 20
log.Printf(" - setuid(%d): %v\n", uid, syscall.Setuid(uid)) // uid instead of 501

// -- snip --
}

This program works exactly the same as the one above; except we don’t hardcode 501 and 20 anymore. However, if we try to run it directly as root (i.e. from the root’s terminal session), we are presented with this output, because there isn’t an ‘original’ user to return to:

program was not run as sudo: missing env key SUDO_UID

Obviously, user should be defined as a “sudoer” for this trick to work. Also, the code should actually verify that SUDO_UID and SUDO_GID are non-zero, but that part is left as an exercise. There are more edge cases that we didn’t handle here, but let’s move on: We have more ground to cover.

Dropping privileges temporarily

In the code above; once we call setuid(501), its effect is permanent: We cannot re-escalate privileges to root. Recall however, that there is a real and an effective uid: setuid assigns both, but seteuid does not assign uid.

But how are these two user IDs different?

Effective user ID is what actually determines the current, apparent access level of the process. When a process attempts to access a system resource (for example, a TCP socket), the kernel checks its euid to see if the process has that kind of permission.

By default, euid=uid; but we can assign a different value to euid using the seteuid syscall. For example, if we set euid=501 while uid=0, it can no longer access system resources as root! Since fsuid=euid unless explicitly changed; all our file accesses will belong to user as well (and not to root).

However, setuid capabilities are determined using uid, not euid: This means that at a later time, we can still call seteuid(0), and restore our root privileges! So, if the process has uid=0 and euid != 0, for all intents and purposes it will act like a non-root process; while secretly retaining setuid permissions!

(Again, before you grab your pitchforks; we are simplifying this a lot so that we can cover a specific scenario.)

Let’s try it!

func main() {
// -- snip --
// get original user uid/gid from environment variables
// removed err checks to keep the code short
uid, _ := getEnvInt("SUDO_UID")
gid, _ := getEnvInt("SUDO_GID")

// do something as root
log.Println("Doing some tasks as root...")
log.Printf(" {uid=%d euid=%d gid=%d egid=%d}\n",
os.Getuid(), os.Geteuid(), os.Getgid(), os.Getegid())
_ = os.WriteFile("./root_owned_1", []byte("hi"), 0o666)

// drop privileges
log.Println("Dropping privileges...")
log.Printf(" - setegid(%d): %v\n", gid, syscall.Setegid(gid)) // note the use of Setegid
log.Printf(" - seteuid(%d): %v\n", uid, syscall.Seteuid(uid)) // note the use of Seteuid

// we are no longer root
log.Println("Root privileges temporarily dropped.")
log.Printf(" {uid=%d euid=%d gid=%d egid=%d}\n",
os.Getuid(), os.Geteuid(), os.Getgid(), os.Getegid())
_ = os.WriteFile("./user_owned", []byte("hi"), 0o666)

// from here on; we CAN be root again
log.Println("Trying to re-escalate:")
log.Printf(" - seteuid(0): %v\n", syscall.Seteuid(0))
log.Printf(" - setegid(0): %v\n", syscall.Setegid(0))

// do something as root
log.Println("Doing some tasks as root again...")
log.Printf(" {uid=%d euid=%d gid=%d egid=%d}\n",
os.Getuid(), os.Geteuid(), os.Getgid(), os.Getegid())
_ = os.WriteFile("./root_owned_2", []byte("hi"), 0o666)
}

Again, we can visualize it as follows. It is the seteuid call that allows us to regain privileges later:

Temporary permission drop via seteuid

The output:

Doing some tasks as root...
{uid=0 euid=0 gid=0 egid=0}
Dropping privileges...
- setegid(20): <nil>
- seteuid(501): <nil>
Root privileges temporarily dropped.
{uid=0 euid=501 gid=0 egid=20}
Trying to re-escalate:
- seteuid(0): <nil>
- setegid(0): <nil>
Doing some tasks as root again...
{uid=0 euid=0 gid=0 egid=0}

Now we can go back to being root again! If we check the ownership of the created files, we also see that the fsuid=euid invariant is still maintained in each.

Even though we demonstrated how to re-establish root privileges; we still have things to do:

  • We had ultimately wanted to spawn root and non-root child processes, which we haven’t done yet.
  • The non-root state no longer satisfies euid=uid; which is uncommon for most processes. There are some cases where a child program (such as bash without “privileged mode”) may try to rectify this and cause unexpected behavior.
  • We keep invoking set*id syscalls during the lifetime of the program. This is undesirable, because these attributes belong to the entire program, not one goroutine. Imagine that the parent process has multiple goroutines that each access some system resources concurrently. If we were to call setuid in the main goroutine just as one of these worker goroutines was creating a file, some of these files would arbitrarily belong to root. Ideally, we want to make all necessary set*id calls in the beginning of each (parent and child) process only; before any goroutines were spawned.

With these requirements, we implement a final version. To keep the code together; parent and child processes are implemented as one program, differentiated by a CHILD_PRIV environment variable: If the key is not set, we behave as the parent process, otherwise it is the child. The assigned value determines if the child process should be root or non-root:

func main() {
if priv := os.Getenv("CHILD_PRIV"); priv != "" {
childMain(priv == "true")
} else {
parentMain()
}
}

func spawnChildProcess(isPrivileged bool) {
cmd := exec.Command(os.Args[0]) // self path

// pass the privilege info in an environment variable
cmd.Env = append(cmd.Env, fmt.Sprintf("CHILD_PRIV=%v", isPrivileged))

log.Printf("Spawning child process (isPrivileged=%v)...\n", isPrivileged)

// this waits until the child is terminated
out, err := cmd.CombinedOutput()
if err != nil {
log.Fatalf("cannot run child command: %v\n", err)
}
log.Printf("Child process output: \n| %s\n",
strings.ReplaceAll(strings.TrimRight(string(out), "\n"), "\n", "\n| "))
}

func parentMain() {
if os.Getuid() != 0 {
log.Fatalln("program was not run as root")
}
// removed err checks to keep the code short
uid, _ := getEnvInt("SUDO_UID")
gid, _ := getEnvInt("SUDO_GID")

log.Println("Dropping privileges...")
log.Printf(" - setegid(%d): %v\n", gid, syscall.Setegid(gid))
log.Printf(" - seteuid(%d): %v\n", uid, syscall.Seteuid(uid))
log.Printf(" {uid=%d euid=%d gid=%d egid=%d}\n",
os.Getuid(), os.Geteuid(), os.Getgid(), os.Getegid())

// [POINT_1]
log.Println("Spawning non-root child...")
spawnChildProcess(false)

log.Println("Spawning root child...")
spawnChildProcess(true)
}

func childMain(isPrivileged bool) {
var uid, gid int
if isPrivileged {
uid, gid = os.Getuid(), os.Getgid() // 0, 0
} else {
uid, gid = os.Geteuid(), os.Getegid() // 501, 20
}
_ = syscall.Setuid(os.Getuid()) // [POINT_2]

log.Printf("setgid(%d): %v\n", gid, syscall.Setgid(gid))
log.Printf("setuid(%d): %v\n", uid, syscall.Setuid(uid))

// [POINT_3]
log.Printf("{uid=%d euid=%d gid=%d egid=%d}",
os.Getuid(), os.Geteuid(), os.Getgid(), os.Getegid())
}

Visualizing this (purple boxes are child processes):

Spawning non-root and root child processes from an effectively non-root parent

Output of the code:

Dropping privileges...
- setegid(20): <nil>
- seteuid(501): <nil>
{uid=0 euid=501 gid=0 egid=20}
Spawning non-root child...
Spawning child process (isPrivileged=false)...
Child process output:
| setgid(20): <nil>
| setuid(501): <nil>
| {uid=501 euid=501 gid=20 egid=20}
Spawning root child...
Spawning child process (isPrivileged=true)...
Child process output:
| setgid(0): <nil>
| setuid(0): <nil>
| {uid=0 euid=0 gid=0 egid=0}

The parent process first retrieves the sudoer user’s uid and gid as before; which are 501 and 20 in our example. Then we invoke seteuid(501) and setegid(20) in the parent process. From this point on (POINT_1), parent process will never call set*id syscalls again. Since parent process IDs are {uid=0 euid=501 gid=0 egid=20}, it will behave like non-root user while keep accessing system resources; but its child processes will have set*id privileges as well.

spawnChildProcess passes the desired privilege level to the child process via the environment key’s value (We could have also used a program argument for this). The child process should attempt to restore the euid=uid and egid=gid invariants. We can use setuid for both cases, since it also assigns euid. For the privileged case we call setuid(getuid()) (i.e. setuid(0)), and for the unprivileged case, setuid(geteuid()) (i.e. setuid(501)). The same logic applies for gid/egid.

Keen-eyed readers will have noticed that we didn’t describe the line of code at POINT_2.

Due to a detail regarding how suid behaves, the child process does not actually have the privilege to call setuid(501) yet. Therefore, we first -restore the child node to {uid=0 euid=0} by calling setuid(getuid()) (Since we didn’t discuss suid at all; we will leave it at that). Then we can restore the invariants like described above.

We are finally at POINT_3 where the child process is done with syscalls as well. At this point, the child process also satisfies euid=uid and egid=gid; and we can move on to doing other things like open file descriptors, spawn goroutines etc.

Final words

In this article, we tried to present a case that can be solved using Unix access control mechanics. Keeping the size of this discussion to a blog post forced us to make many sacrifices: We tried to write the shortest code possible in each case as one-file programs; never discussed how group IDs and user IDs interact with one another, completely skipped the SysProcAttr field of exec.Cmd, and made many implicit assumptions that we didn’t check in the code (For example, what would happen if the parent process was spawned by another process that called setfsuid first?). We didn’t discuss the setuid file mode bit, nor the CAP_SETUID file capability. We didn’t spawn child processes from child processes, and we didn’t get into how sh, bash and bash -p behave differently when euid!=uid

Also, what do you think happens to $HOME, (or $PATH, or any other environment variable) when we change users like this?

In our case, with all the quirks and pitfalls of setuid, we eventually applied a different, more robust approach in our simulation agent. Instead, we decided to share some of our findings in a blog post. All in all, we hope that the result was brief, educational and enjoyable, and not a rambling, incoherent mess.

In Picus Security, we regularly tackle problems that challenge us to combine the most OS-specific minutiae with the highest-level system design concepts. We meticulously innovate at a junction where many concepts like concurrency, telemetry, network protocols, OS fingerprinting, scalability, graph theory, CI/CD, cryptography and binary obfuscation can casually come together. By bringing hard work and the joy of discovery together, we have been pushing boundaries on Breach and Attack Simulation for 10 years. We invite you to witness it yourself: Get started with our Complete Security Validation Platform today!

--

--