secure command execution with Python: subprocess.Popen

Security is important for me while developing the picockpit-client.

The following applies to Linux systems (but probably is applicable to all Unix like systems, including macOS)

Python allows to run external commands using the subprocess module.

import subprocess

In the upcoming version of PiCockpit, users will be able to create their own buttons (simply editing a JSON file on the Pi) in order to run commands.

PiCockpit will ship with three buttons by default:

image

These commands all require root privileges. picockpit-client, per default, runs with root privileges on your system.

But other commands you want to execute, should run as less privileged users (we default to the user “pi”). In fact, some commands, like the Chromium Browser will refuse to run as root.

In short, we need a solution to run these processes using a different user.

This is possible, thanks to the preexec_fn parameter for popen.

Here is my code:

def thread_run(self):
     def sanitize(input_string):
         #https://maxharp3r.wordpress.com/2008/05/15/pythons-minidom-xml-and-illegal-unicode-characters/
         RE_XML_ILLEGAL = u'([\u0000-\u0008\u000b-\u000c\u000e-\u001f\ufffe-\uffff])’ + \
                          u’|’ + \
                          u'([%s-%s][^%s-%s])|([^%s-%s][%s-%s])|([%s-%s]$)|(^[%s-%s])’ % \
                          (chr(0xd800), chr(0xdbff), chr(0xdc00), chr(0xdfff),
                           chr(0xd800), chr(0xdbff), chr(0xdc00), chr(0xdfff),
                           chr(0xd800), chr(0xdbff), chr(0xdc00), chr(0xdfff))
         return re.sub(RE_XML_ILLEGAL, “”, input_string)

    def demote(user, uid, gid):
         def demote_function():
             print(“starting”)
             print (‘uid, gid = %d, %d’ % (os.getuid(), os.getgid()))
             print (os.getgroups())
             # initgroups must be run before we lose the privilege to set it!
             os.initgroups(user, gid)
             os.setgid(gid)
             # this must be run last
             os.setuid(uid)
             print(“finished demotion”)
             print(‘uid, gid = %d, %d’ % (os.getuid(), os.getgid()))
             print (os.getgroups())
         return demote_function

    def run_cmd(cmd, cmd_def):
         return_code = 0
         return_error = “”
         return_result = “”
         timeout = None
         user = “pi”
         if “timeout” in cmd_def:
             if isinstance(cmd_def[“timeout”], int):
                 if cmd_def[“timeout”] > 0:
                     timeout = cmd_def[“timeout”]
         if “user” in cmd_def:
             if isinstance(cmd_def[“user”], str):
                 user = cmd_def[“user”]
         try:
             print(“picontrol :: setup of user rights”)
             uid = pwd.getpwnam(user).pw_uid
             gid = pwd.getpwnam(user).pw_gid
             print(“picontrol :: requested user ” + user + ” uid ” + str(uid) + ” gid ” + str(gid))
             print(“picontrol :: starting command … “)
             print(cmd)
             proc = subprocess.Popen(
                 cmd,
                 stdout=subprocess.PIPE,
                 stderr=subprocess.PIPE,
                 preexec_fn=demote(user, uid, gid)
             )
             # TODO: handle timeout differently
                 # timeout=timeout)               
             print(“picontrol :: AFTER subprocess.Popen”)
         except FileNotFoundError:
             return_code = errno.ENOENT
             return_error = “Command not found on your system ” + ” “.join(cmd)
         # TODO: will this actually ever be called?
         except subprocess.TimeoutExpired:
             return_code = errno.ETIME
             return_error = “Command timeout expired (” + str(cmd_def[“timeout”]) + ” sec)”
         except Exception as e:
             if hasattr(e, ‘errno’):
                 return_code = e.errno
             else:
                 return_code = 1
             if hasattr(e, ‘message’):
                 return_error = str(e.message)
             else:
                 return_error = str(e)
         else:
             # process can execute normally, no exceptions at startup
             process_running = True
             while process_running:
                 if proc.poll() is not None:
                     process_running = False
                 if self.stop_flag.is_set():
                     print(“stop flag received in process_running, setting process_running to false”)
                     process_running = False
                     # and also do steps for active termination after that.
                 # half a second of resolution
                 time.sleep(0.5)
             return_code = proc.returncode
             return_error = sanitize(proc.stderr.read().decode(‘UTF-8’).rstrip(“\r\n”))
             return_result = sanitize(proc.stdout.read().decode(‘UTF-8’).rstrip(“\r\n”))

        return (return_code, return_error, return_result)

This code is a work in progress, so parts of it may not work. The parts I want to discuss in this blog post, however DO work.

User under Linux

A user name, under Linux, will be assigned a user id, a user group, and the user will be a member of other groups.

/etc/passwd contains the users defined on the system:

image

The entries can be decoded as follows:

user : password : user id (uid) : group id (gid) : full name of the user (GECOS) : user home directory : login shell

Interesting sidenote: storing the password in /etc/passwd (in an encrypted form) is optional (in case the password is not stored, an x is stored here); Since this file is world-readable, many systems opt to use the root-readable /etc/shadow instead:

image

which indeed contains the password.

For picockpit, we get the user name from the JSON settings file, and want to set up everything else accordingly. So we start out with:

uid = pwd.getpwnam(user).pw_uid
gid = pwd.getpwnam(user).pw_gid

(Be sure to import pwd).

The pwd module allows us to access the /etc/passwd file using Python. getpwnam looks up the corresponding line by name of the user, in our case “pi”, or “root” (or whatever you want, whatever is set up in the JSON file).

This will help us obtain the user’s user id (uid) and their group id (gid).

Next we call subprocess.Popen:

proc = subprocess.Popen(
     cmd,
     stdout=subprocess.PIPE,
     stderr=subprocess.PIPE,
     preexec_fn=demote(user, uid, gid)
)

The stdout and stderr are PIPEd, so we can capture them later.

the preexec_fn is set to demote(user, uid, gid).

Important!

This is an important bit: preexec_fn expects a function handle, which it will execute in the context of the new process it is starting (so the process will demote itself!). This function will run before the actual code, thus we can be sure that the code will execute in the context we want it to execute.

The way we pass demote here, it looks as if the function is being called.

But look at the definition of demote:

def demote(user, uid, gid):
     def demote_function():
        print(“starting”)
         print (‘uid, gid = %d, %d’ % (os.getuid(), os.getgid()))
         print (os.getgroups())
         # initgroups must be run before we lose the privilege to set it!
         os.initgroups(user, gid)
         os.setgid(gid)
         # this must be run last
         os.setuid(uid)
         print(“finished demotion”)
         print(‘uid, gid = %d, %d’ % (os.getuid(), os.getgid()))
         print (os.getgroups())
     return demote_function

This function wraps another function inside itself, which it returns. Using the syntax which is specified above we actually return a function but are able to pass parameters to it, too.

Kind of like having your cake, and eating it. Nice Smile

Here is a screenshot, in case WordPress messes with the syntax above:

image

OK, now I’ll explain the code in this demote function:

os.initgroups:

we set up the groups which the process should be a member of (yes, we have to do this – otherwise the process will only be a member of the default pi group) – this command expects the user name

os.setgid:

here we set up the group id for the new process.

There is also a function os.setegid() which is described as “Set the current process’s effective group id.” 

What is the difference?

setegid sets the gid in a temporary way, so that the process can return to the original gid later. This is not what we want, since the process we are calling could upgrade it’s rights back up again.

os.setuid(uid):

similarly, there is a os.seteuid(euid) function, which would allow the executed process to “upgrade back”. So we’re not using that.

The order of these commands is important! If we would set the user id first, we would not have sufficient privileges to set up the groups, for example. So set the user id last.

Sidenote: preexec_fn could be used to set up environment variables and other things, too, to allow for more flexibility. Possibly in a future picockpit-client upgrade, let’s see.

Sidenote 2: the preexec_fn writes to the new process output stream (remember, it is pre-executed in it’s context):

That is why the debug output is sent to the picockpit frontend:

image

Ref

Additional links of interest:

Bonus: Chromium

chromium-browser –display=:0 https://www.picockpit.com

image

will allow you to run the Chromium browser from the command line (in the shell). –display=:0 sets the X display to use for Chromium to output to, thus you do not need to set up the appropriate environment variable.

Peter.sh gives tons of command line switches, which you can use to configure Chromium to your liking (e.g. kiosk mode, etc).

Note that you NEED to run an X server, if you want to display Chromium. On the Raspberry Pi, the easiest way to do is is to run the Raspberry Pi Desktop.

Looking for professional consulting & development for the Raspberry Pi platform?

We offer consulting and development services in case you are looking for someone to set up a secure Chromium based Kiosk application for you. Get in touch today.