A macOS driver for USB tethering from an Android phone, built as a DriverKit system extension.
Plug the phone in, turn on USB tethering, and it appears as an Ethernet interface. No kernel extension, no disabling SIP, no lowering the boot security policy on Apple Silicon.
Important
This driver has never been run against a phone, and it cannot be until Apple grants three restricted entitlements. What exists today compiles, links, signs, and has its protocol layer under test. That is not the same as working. See Status.
Android phones present USB tethering as RNDIS — a Microsoft protocol that carries Ethernet frames over two USB bulk endpoints, with a control channel on endpoint 0. Windows has had a driver since forever, Linux has had one for twenty years, and macOS has never shipped one.
This project is an independent implementation of the host side, written from the published specification:
- [MS-RNDIS], Microsoft's Remote Network Driver Interface Specification, for the message layouts, OIDs and status codes.
- Apple's NetworkingDriverKit and USBDriverKit documentation, and the Connecting a network driver sample, for the driver lifecycle.
- USB-IF class codes for the interface triples it matches on.
No third-party driver source is included or adapted. See NOTICE.
| RNDIS protocol layer | written, 33 tests / 86 checks, sanitizers clean |
| System extension | compiles, links, signs |
| Installer app | compiles, links, signs |
| Loading on a Mac | blocked on Apple — the entitlements are restricted |
| Tested against a phone | no |
A DriverKit extension that touches the network stack and USB needs three entitlements, and none of them can be self-assigned:
com.apple.developer.driverkitcom.apple.developer.driverkit.family.networkingcom.apple.developer.driverkit.transport.usb
They are requested together, as one group, through the DriverKit entitlement request form against an App ID. Until Apple grants them, a signed build is refused at load time — not at build time, which is why CI can be green while nothing can actually be installed.
One awkward detail, recorded here rather than discovered later: the request form asks which hardware vendor ID the driver is for. This driver does not have one. It matches on the USB interface class that every Android RNDIS gadget presents, across every manufacturer, which is the shape of request the form is least set up for.
| macOS | 13 Ventura or later |
| Mac | Apple Silicon or Intel |
| Phone | Any Android device with USB tethering |
macOS 13 is the floor because the extension targets DriverKit 22. Going lower is possible on paper -- NetworkingDriverKit dates to macOS 11 -- but nothing below 13 has been built or tested, so the number here is the one that is actually true.
There is no upper bound: a DriverKit extension is not tied to the macOS version it was built against.
Releases ship one disk image per kind of Mac:
| Your Mac | Download | Extension | App |
|---|---|---|---|
| Apple Silicon | ...-AppleSilicon-arm64e.dmg |
arm64e |
arm64 |
| Intel | ...-Intel-x86_64.dmg |
x86_64 |
x86_64 |
Building locally gives you a universal binary covering both, which is the default and is what CI checks; the split is only in what gets published.
The extension's Apple Silicon slice must be arm64e, not plain
arm64. A plain arm64 dext stages and activates and then fails to
launch, with a message that does not explain why. The app has no such
requirement -- arm64e is a DriverKit rule, not an application one.
The build asks the installed SDK which architectures it can still
produce rather than assuming both, so when Apple eventually drops
x86_64 from the DriverKit SDK this builds an Apple-Silicon-only
extension instead of failing.
Once there are signed releases, installation is: open the disk image for your Mac, drag AndroidTether.app onto the Applications folder beside it, open it from there, click Install Driver, and approve the extension in System Settings › Privacy & Security.
The app has to stay in /Applications. macOS deactivates a system
extension whose host app is gone, so deleting the app is also how you
uninstall the driver.
Needs the full Xcode, not just the Command Line Tools: iig and the
DriverKit SDK ship only with Xcode.
make testmakemake test builds and runs the protocol tests and needs nothing but
clang — it runs on Linux, which is where CI runs it. make additionally
builds the extension and the app into driver/build/.
To build signed, drop a DriverKit provisioning profile next to
driver/Makefile (the Team ID is read out of it, never committed) and
pass your identity:
make -C driver SIGN_ID="Developer ID Application: NAME (TEAMID)"An ad-hoc build is signed without the app's entitlements, on purpose. Those entitlements are restricted ones that only a provisioning profile can authorise, and signing a binary with entitlements it is not authorised for does not merely fail to grant them: AMFI refuses to launch it at all, before any code runs, with
Launch failed ... Launchd job spawn failed (POSIX error 163)
Left off, the app starts and shows its window. That is the whole of what an unsigned build can do; clicking Install Driver reports
Missing entitlement com.apple.developer.system-extension.install
because installing a system extension is exactly the thing that entitlement authorises.
That one is not the hard part. It is an ordinary capability any Apple Developer Program member can switch on for an App ID, so a $99 membership gets you past this message. What waits immediately behind it is the real wall: the extension itself still needs the three restricted DriverKit entitlements, and those Apple grants case by case. Getting past this error means reaching the next one, not reaching a working driver.
A build downloaded from Releases will not even get that far: macOS quarantines downloaded files, and Gatekeeper refuses an app that is not notarized ("Apple could not verify ... is free of malware"). Build locally if you want to see the app run; nothing you can do to the download makes the driver load.
An Apple Developer Program membership buys the ability to ship this to other people. Proving the driver works on your own Mac does not need one -- but it does need you to turn off the protections that are refusing it, which is a real cost and worth understanding before you start.
With AMFI disabled, entitlements are not checked at all, so the unauthorised ones stop mattering. The build keeps them on request:
make -C driver ADHOC_ENTITLEMENTS=1That app will not launch on a normal Mac -- it is for a machine prepared like this:
- Apple Silicon only: shut down, hold the power button until "Loading startup options", then Options → Utilities → Startup Security Utility → pick your disk → Security Policy → Permissive Security.
- In recoveryOS, Utilities → Terminal:
csrutil disable - Boot normally, then
sudo nvram boot-args="amfi_get_out_of_my_way=0x1"and reboot. systemextensionsctl developer on
To undo it afterwards: sudo nvram -d boot-args, csrutil enable from
recoveryOS, and set the security policy back to Full Security.
Warning
This disables System Integrity Protection and code-signing enforcement for the whole machine, not just for this driver. Every program you run loses those protections. It is reasonable on a machine you develop on and a bad idea on one you keep your life in.
Two honest caveats. First, this has not been verified -- nobody has run this driver on a prepared machine yet, so these steps are the known procedure, not a tested recipe. Second, it gets you to a working driver at best; it does nothing for anyone else, since they would have to lower their own machine the same way. Shipping still needs Apple.
rndis/ the protocol, in plain C: no IOKit, no DriverKit, no kernel
tests/ host-side tests for all of the above
driver/ the system extension
app/ the installer that carries it
The split is the point. Everything a phone can influence — message
lengths, buffer offsets, record counts — is decided in rndis/, which
has no platform in it and can therefore be fed the shapes real hardware
will not produce on request. The driver moves bytes between USB pipes and
the network stack and makes no protocol decisions of its own.
Every length and offset in a received transfer is chosen by the device on the other end of the cable. A phone — or something pretending to be one — can claim a frame that starts past the end of the buffer it arrived in, or an offset and a length that sum to less than either because 32-bit arithmetic wrapped. The tests are mostly those cases:
truncated header, msg_len past the transfer, data_offset past the record,
offset+length overflow, a frame overlapping its own header, zero-length
frames, frames larger than the receive buffer, control messages arriving
on the data pipe
All bounds arithmetic widens to 64 bits before comparing, and the parser stops at the first record that does not make sense while still delivering the good frames that came before it.
One dispatch queue handles everything: both pipe completions, the transmit doorbell, and the retry timer. That is what makes the absence of locking correct rather than lucky.
Receive uses four buffers in flight so the device always has somewhere to put the next transfer. Transmit copies each frame into its own buffer behind the RNDIS header — the header has to go in front of the frame, and the packet handed over by the network stack has no room there — which also means the stack's packet can be returned immediately instead of being held until USB completes.
The RNDIS packet filter is set in SetInterfaceEnable rather than at
startup, so a phone that is plugged in but whose interface nobody brought
up is not asked to forward traffic.
Three decisions in the driver are reasoned rather than copied from a worked example, and they are the first places to look if it misbehaves:
- Link header length on receive is set to 14, the Ethernet header. Apple's sample passes 0, but its packet is a synthetic IP datagram with no link header at all; ours is a real Ethernet frame.
- Queue order in
RegisterEthernetInterfaceis RX submission, RX completion, TX submission, TX completion. The documentation does not state an order. - No keep-alive. Android's gadget does not require one, and a timer issuing synchronous control transfers from the datapath queue is a hazard for no benefit. If a device turns out to halt an idle session, this is what to add.
Bug reports are most useful with the system log (log show --predicate 'eventMessage CONTAINS "AndroidTether"' --last 5m) and the output of
system_profiler SPUSBDataType for the phone.
Changes to rndis/ should come with tests; that is the whole reason it
is a separate, platform-free directory.