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?
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.
The parent python script launches the child script root_child.py
as root using sudo
.
Then it sends it a command soft-shutdown\n
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-shutdown\n" )
proc.stdin.flush()
print( proc.stdout.readline() )
proc.stdin.close()
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 Ignored\n" )
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 Ignored\n" )
sys.stdout.flush()
continue
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:~$
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
#!/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-shutdown\n" )
proc.stdin.flush()
print( proc.stdout.readline() )
proc.stdin.close()
(no changes in this script, just use 'root_child.py' from above)
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
?
I ended-up solving this by abandoning osascript
and instead calling the AuthorizationExecuteWithPrivileges()
function with ctypes, which is actually just what osascript
does indirectly.
#!/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 <michael@michaelaltfield.net>
# 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-sandwich\n".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-shutdown\n".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)
#!/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 Ignored\n"
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 Ignored\n"
sys.stdout.buffer.write( msg.encode(encoding='ascii') )
sys.stdout.flush()
continue
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 maltfield@host.local ***
System going down IMMEDIATELY
Connection to REDACTED closed by remote host.
Connection to REDACTED closed.
maltfield@buskill:~$
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: