Binding to privileged ports without root on macOS
Since macOS Mojave it has been possible to bind to privileged ports without root on macOS. For example running the following shows nc
binding to port 81 without issue.
$ nc -l 0.0.0.0 81
^C
However binding to a port on a specific interface still requires root.
$ nc -l 127.0.0.1 81
nc: Permission denied
This makes binding a process to a single interface like a WireGuard network or localhost impossible without root permissions.
It’s possible to work around the above restriction by using launchd
socket activation instead. Instead of having a process bind to ports itself, launchd
will bind to those ports and pass those file descriptors to the process. For example I have the following plist
to run Python’s HTTPServer
module listening on localhost only.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>KeepAlive</key>
<true/>
<key>Label</key>
<string>zmanji.startpage</string>
<key>EnvironmentVariables</key>
<dict>
<key>PYTHONUNBUFFERED</key>
<string>1</string>
</dict>
<key>ProgramArguments</key>
<array>
<string>/Users/zmanji/bin/startpage.py</string>
</array>
<key>WorkingDirectory</key>
<string>/Users/zmanji/.config/startpage</string>
<key>RunAtLoad</key>
<true/>
<key>Sockets</key>
<dict>
<key>Listeners</key>
<dict>
<key>SockNodeName</key>
<string>127.0.0.1</string>
<key>SockServiceName</key>
<string>80</string>
</dict>
</dict>
<key>StandardOutPath</key>
<string>/tmp/zmanji.startpage.stdout.log</string>
<key>StandardErrorPath</key>
<string>/tmp/zmanji.startpage.stderr.log</string>
</dict>
</plist>
Example launchd plist
that has a Sockets
key
The key part of this plist
is the Sockets
key which specifies the interface and port for launchd
to bind to.
Then in the program being run it needs to receive the socket from launchd
by calling the launch_activate_socket
API. Once the sockets are obtained they can be used like any other socket. For example my startpage.py
script looks like this.
#!/usr/bin/python3
import os
from socket import socket
from http.server import HTTPServer, SimpleHTTPRequestHandler
def launch_activate_socket(name: str):
from ctypes import byref, CDLL, c_int, c_size_t, POINTER
libc = CDLL('/usr/lib/libc.dylib')
fds = POINTER(c_int)()
count = c_size_t()
res = libc.launch_activate_socket(name.encode('utf-8'), byref(fds), byref(count))
if res:
raise Exception(os.strerror(res))
sockets = [socket(fileno=fds[s]) for s in range(count.value)]
libc.free(fds)
return sockets
def main():
# Name must match key in Sockets dict of plist
sockets = launch_activate_socket('Listeners')
if len(sockets) != 1:
raise Exception(f"Too many sockets: {len(sockets)}")
socket = sockets[0]
# Pass the socket to the HTTP server
HTTPServer.address_family = socket.family
with HTTPServer(socket.getsockname(), SimpleHTTPRequestHandler, bind_and_activate=False) as httpd:
httpd.socket = socket
httpd.server_activate()
host, port = httpd.socket.getsockname()[:2]
url_host = f'[{host}]' if ':' in host else host
print(
f"Serving HTTP on {host} port {port} "
f"(http://{url_host}:{port}/) ..."
)
httpd.serve_forever()
Example Python program that obtains sockets from launchd
Once the plist
above is placed into ~/Library/LaunchAgents
it works like any other launch agent. When activated the process will be listening on the specific interface and port but no root permissions were required.