Binding to privileged ports without root on macOS

Created On:

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.