Communicating with root child process launched with osascript "with administrator privileges"

Question:

How can I pass messages between a parent process that launches a child process as root using Apple Script and stdin/stdout?

I’m writing an anti-forensics GUI application that needs to be able to do things that require root permissions on MacOS. For example, shutting down the computer.

For security reasons, I do not want the user to have to launch the entire GUI application as root. Rather, I want to just spawn a child process with root permission and a very minimal set of functions.

Also for security reasons, I do not want the user to send my application its user password. That authentication should be handled by the OS, so only the OS has visibility into the user’s credentials. I read that the best way to do this with Python on MacOS is to leverage osascript.

Unfortunately, for some reason, communication between the parent and child process breaks when I launch the child process using osascript. Why?

Example with sudo

First, let’s look at how it should work.

Here I’m just using sudo to launch the child process. Note I can’t use sudo for my use-case because I’m using a GUI app. I’m merely showing it here to demonstrate how communication between the processes should work.

Parent (spawn_root.py)

The parent python script launches the child script root_child.py as root using sudo.

Then it sends it a command soft-shutdownn and waits for the response

#!/usr/bin/env python3
import subprocess, sys

proc = subprocess.Popen(
 [ 'sudo', sys.executable, 'root_child.py' ],
 stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True
)

print( "sending soft-shutdown command now" )
proc.stdin.write( "soft-shutdownn" )
proc.stdin.flush()
print( proc.stdout.readline() )

proc.stdin.close()

Child (root_child.py)

The child process enters an infinite loop listening for commands (in our actual application, the child process will wait in the background for the command from the parent; it won’t usually just get the soft-shutdown command immediately).

Once it does get a command, it does some sanity checks. If it matches soft-shutdown, then it executes shutdown -h now with subprocess.Popen().

#!/usr/bin/env python3
import os, sys, re, subprocess

if __name__ == "__main__":

    # loop and listen for commands from the parent process
    while True:

        command = sys.stdin.readline().strip()

        # check sanity of recieved command. Be very suspicious
        if not re.match( "^[A-Za-z_-]+$", command ):
            sys.stdout.write( "ERROR: Bad Command Ignoredn" )
            sys.stdout.flush()
            continue

        if command == "soft-shutdown":
            try:
                proc = subprocess.Popen(
                 [ 'sudo', 'shutdown', '-h', 'now' ],
                 stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True
                )
                sys.stdout.write( "SUCCESS: I am root!n" )
                sys.stdout.flush()
            except Exception as e:
                sys.stdout.write( "ERROR: I am not root :'(n" )
                sys.stdout.flush()
                sys.exit(0)

            continue
        else:
            sys.stdout.write( "WARNING: Unknown Command Ignoredn" )
            sys.stdout.flush()
            continue

Example execution

This works great. You can see in this example execution that the shutdown command runs without any exceptions thrown, and then the machine turns off.

user@host ~ % ./spawn_root.py  
sending soft-shutdown command now
SUCCESS: I am root!
...
user@host ~ % Connection to REDACTED closed by remote host.
Connection to REDACTED closed.
user@buskill:~$

Example with osascript

Unfortunately, this does not work when you use osascript to get the user to authenticate in the GUI.

For example, if I change one line in the subprocess call in spawn_root.py from using sudo to using osascript as follows

Parent (spawn_root.py)

#!/usr/bin/env python3
import subprocess, sys

proc = subprocess.Popen(
 ['/usr/bin/osascript', '-e', 'do shell script "' +sys.executable+ ' root_child.py" with administrator privileges' ],
 stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True
)

print( "sending soft-shutdown command now" )
proc.stdin.write( "soft-shutdownn" )
proc.stdin.flush()
print( proc.stdout.readline() )

proc.stdin.close()

Child (root_child.py)

(no changes in this script, just use 'root_child.py' from above)

Example Execution

This time, after I type my user password into the prompt provided by MacOS, the parent gets stuck indefinitely when trying to communicate with the child.

user@host spawn_root_sudo_communication_test % diff simple/spawn_root.py simple_gui/spawn_root.py 
sending soft-shutdown command now

Why is it that I cannot communicate with a child process that was launched with osascript?

Asked By: Michael Altfield

||

Answers:

I ended-up solving this by abandoning osascript and instead calling the AuthorizationExecuteWithPrivileges() function with ctypes, which is actually just what osascript does indirectly.

Parent (spawn_root.py)

#!/usr/bin/env python3
################################################################################
# File:    spawn_root.py
# Version: 0.1
# Purpose: Launch a child process with root permissions on MacOS via
#          AuthorizationExecuteWithPrivileges(). For more info, see:
#           * https://stackoverflow.com/a/74001980
#           * https://stackoverflow.com/q/73999365
#           * https://github.com/BusKill/buskill-app/issues/14
# Authors: Michael Altfield <[email protected]>
# Created: 2022-10-15
# Updated: 2022-10-15
################################################################################

################################################################################
#                                   IMPORTS                                    #
################################################################################

import sys, ctypes, struct
import ctypes.util
from ctypes import byref

# import some C libraries for interacting via ctypes with the MacOS API
libc = ctypes.cdll.LoadLibrary(ctypes.util.find_library("c"))

# https://developer.apple.com/documentation/security
sec = ctypes.cdll.LoadLibrary(ctypes.util.find_library("Security"))

################################################################################
#                                  SETTINGS                                    #
################################################################################

kAuthorizationFlagDefaults = 0

################################################################################
#                                  FUNCTIONS                                   #
################################################################################

# this basically just re-implmenets python's readline().strip() but in C
def read_from_child(io):

    # get the output from the child process character-by-character until we hit a new line
    buf = ctypes.create_string_buffer(1)
    result = ''
    for x in range(1,100):

        # read one byte from the child process' communication PIPE and store it to the buffer
        libc.fread(byref(buf),1,1,io)
    
        # decode the byte stored to the buffer as ascii
        char = buf.raw[:1].decode('ascii')

        # is the character a newline?
        if char == "n":
            # the character is a newline; stop reading
            break
        else:
            # the character is not a newline; append it to the string and continue reading
            result += char

    return result

################################################################################
#                                  MAIN BODY                                   #
################################################################################

################################
# EXECUTE CHILD SCRIPT AS ROOT #
################################

auth = ctypes.c_void_p()
r_auth = byref(auth)
sec.AuthorizationCreate(None,None,kAuthorizationFlagDefaults,r_auth)

exe = [sys.executable,"root_child.py"]
args = (ctypes.c_char_p * len(exe))()
for i,arg in enumerate(exe[1:]):
    args[i] = arg.encode('utf8')

io = ctypes.c_void_p()

print( "running root_child.py")
err = sec.AuthorizationExecuteWithPrivileges(auth,exe[0].encode('utf8'),0,args,byref(io))
print( "err:|" +str(err)+ "|" )
print( "root_child.py executed!")

##################################
# SEND CHILD "MALICIOUS" COMMAND #
##################################

print( "sending malicious command now" )

# we have to explicitly set the encoding to ascii, else python will inject a bunch of null characters (x00) between each character, and the command will be truncated on the receiving end
#  * https://github.com/BusKill/buskill-app/issues/14#issuecomment-1279643513
command = "Robert'); DROP TABLE Students;n".encode(encoding="ascii")
libc.fwrite(command,1,len(command),io)
libc.fflush(io)
print( "result:|" +str(read_from_child(io))+ "|" )

################################
# SEND CHILD "INVALID" COMMAND #
################################

print( "sending invalid command now" )

command = "make-me-a-sandwichn".encode(encoding="ascii")
libc.fwrite(command,1,len(command),io)
libc.fflush(io)
print( "result:|" +str(read_from_child(io))+ "|" )

######################################
# SEND CHILD "soft-shutdown" COMMAND #
######################################

print( "sending soft-shutdown command now" )

command = "soft-shutdownn".encode(encoding="ascii")
libc.fwrite(command,1,len(command),io)
libc.fflush(io)
print( "result:|" +str(read_from_child(io))+ "|" )

# clean exit
libc.close(io)
sys.exit(0)

Child (root_child.py)

#!/usr/bin/env python3
import os, time, re, sys, subprocess

def soft_shutdown():
        try:
                proc = subprocess.Popen(
                 [ 'sudo', 'shutdown', '-h', 'now' ],
                 stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True
                )
        except Exception as e:
                print( "I am not root :'(" )

if __name__ == "__main__":

        # loop and listen for commands from the parent process
        while True:

                command = sys.stdin.buffer.readline().strip().decode('ascii')

                # check sanity of recieved command. Be very suspicious
                if not re.match( "^[A-Za-z_-]+$", command ):
                        msg = "ERROR: Bad Command Ignoredn"

                        sys.stdout.buffer.write( msg.encode(encoding='ascii') )
                        sys.stdout.flush()
                        continue

                if command == "soft-shutdown":
                        try:
                                soft_shutdown()
                                msg = "SUCCESS: I am root!n"

                        except Exception as e:
                                msg = "ERROR: I am not root :'(n"

                else:   
                        msg = "WARNING: Unknown Command Ignoredn"

                sys.stdout.buffer.write( msg.encode(encoding='ascii') )
                sys.stdout.flush()
                continue

Example Execution

maltfield@host communicate % ./spawn_root.py
running root_child.py
err:|0|
root_child.py executed!
sending malicious command now
result:|ERROR: Bad Command Ignored|
sending invalid command now
result:|WARNING: Unknown Command Ignored|
sending soft-shutdown command now
result:|SUCCESS: I am root!|
Traceback (most recent call last):
  File "root_child.py", line 26, in <module>
    sys.stdout.flush()
BrokenPipeError: [Errno 32] Broken pipe
Exception ignored in: <_io.TextIOWrapper name='<stdout>' mode='w' encoding='UTF-8'>
BrokenPipeError: [Errno 32] Broken pipe
maltfield@host communicate % 

*** FINAL System shutdown message from [email protected] ***              
System going down IMMEDIATELY                                                  
                                                                               
                                                                               
Connection to REDACTED closed by remote host.
Connection to REDACTED closed.
maltfield@buskill:~$

Additional Information

Security

Note that AuthorizationExecuteWithPrivileges() has been deprecated by apple in-favor of an alternatve that requires you to pay them money. Unfortunately, there’s some misinformation out there that AuthorizationExecuteWithPrivileges() is a huge security hole. While it’s true that using AuthorizationExecuteWithPrivileges() incorrectly can cause security issues, it is not inherently insecure to use it.

Obviously, any time you run something as root, you need to be very careful!

AuthorizationExecuteWithPrivileges() is deprecated, but it can be used safely. But it can also be used unsafely!

It basically boils down to: do you actually know what you’re running as root? If the script you’re running as root is located in a Temp dir that has world-writeable permissions (as a lot of MacOS App installers have done historically), then any malicious process could gain root access.

To execute a process as root safely:

  1. Make sure that the permissions on the process-to-be-launched are root:root 0400 (or writeable only by root)
  2. Specify the absolute path to the process-to-be-launched, and don’t allow any malicious modification of that path

Further Reading

  1. AuthorizationExecuteWithPrivileges() Reference Documentation
  2. Get root dialog in Python on Mac OS X, Windows?
  3. https://github.com/BusKill/buskill-app/issues/14
  4. https://www.jamf.com/blog/detecting-insecure-application-updates-on-macos/
Answered By: Michael Altfield