Home / Lab / Porting LTESniffer to macOS on Apple Silicon
Lab

Porting LTESniffer to macOS on Apple Silicon

October 19, 2026 Andrei Gosman 7 min read

LTESniffer builds and runs on macOS ARM64 with a manageable patch set, and the main reason is it links against a fork of srsRAN2 21.10, which is the same srsRAN code line I had already ported for srsRAN 4G. The port is proven end to end on a ZMQ loopback, for both downlink and uplink decode, with no radio attached. The kit is public: https://github.com/AndreiGosman/ltesniffer-macos-arm64

What LTESniffer is ?

LTESniffer is a passive LTE sniffer from KAIST (WiSec 2023), AGPL-3.0. It decodes the downlink control channel, recovers per-UE identities, and in uplink-plus-downlink mode decodes uplink grants and PUSCH. It links srsran_phy, the srsRAN ASN.1 decoders, FALCON’s analysis code (which subsumes the older OWL), FFTW3F and Boost. No daemons, no SCTP stack of its own.

I assumed LTESniffer was built on srsLTE 18.x, the old pre-fork line, but actually it is not. It links a fork of srsRAN2 21.10 (ShaoPaoLao/srsRAN2, commit 0acc79d), which is the same upstream SRS code line as the srsRAN 4G 25.10 I had already ported to Darwin. The two trees have diverged by about four years, so patches do not git-apply cleanly, but the classes of breakage are the same ones I had already solved. That single correction moved the feasibility from hard to reasonable.

PHY first, because that was the real risk

The DSP layer decided feasibility. If srsran_phy would not build with working NEON on arm64, the rest did not matter, so I did that first.

As before, problems sat in the CMake. The arch check matched the exact string “aarch64”, and macOS reports “arm64”, so the match failed and the architecture flags fell back to native. A second guard set HAVE_NEON on a substring but gated HAVE_NEONv8 on the exact “aarch64” again, so the NEONv8 path never turned on. The fix normalizes the arch after the project() call, sets the armv8-a flags and HAVE_NEONv8, and makes arch_select return “neon” on __APPLE__ instead of reading Linux hwcap. Apple clang rejects -march=armv8-a, but srsRAN probes a flag before using it, so that one is skipped without a patch. NEON comes in through defines, not through -march.

I ran the PHY self-tests: the SIMD-versus-scalar vector test, the modem test across BPSK, QPSK and QAM16, and the soft-demodulation test. All passed. That removed the largest risk from the whole effort before I spent time on anything else.

The common was more complicated than expected

An early read of the source suggested the ASN.1 decoders were pure codec but they link srsran_common, so the whole common layer has to build. That is where the usual Darwin work lives: a timerfd shim over kqueue, pthread_setname_np reduced to its one-argument BSD form, endian helpers via OSByteOrder, CPU-affinity calls guarded on __linux__, and a pthread_self cast through uintptr_t.

mbedtls was the one surprise. Homebrew now ships mbedtls 4.2.0, which dropped the legacy headers and split out the crypto core, and srsRAN 21.10 is written against the 2.x API. I did not port the calls forward. The passive sniffer never exercises that code at runtime, it only has to compile and link, so I installed mbedtls@2 2.28.10 and pointed the build at it with PKG_CONFIG_PATH plus an include-order fix so the 4.x headers in the default prefix do not shadow it. mbedtls@2 stays a build dependency.

Integration: subproject, not ExternalProject

srsRAN2 does not export a CMake package config, and LTESniffer has no FindSRSRAN module, so find_package always fails and LTESniffer falls back to an ExternalProject that downloads the upstream master branch. That master is unpatched, which defeats the point. I wired LTESniffer to add_subdirectory against the local patched srsRAN2 tree instead, with the daemons turned off and the C++ standard forced to 17 for UHD 4.x. Nothing is downloaded at build time. For the public kit, the one hardcoded path became a CMake cache variable that the install script fills.

The result links: a Mach-O arm64 binary that prints its usage and finds its RF plugins for uhd, soapy, blade and zmq.

Proving it decodes, without a radio

There was no LTE IQ file on disk, so kind of a simulation was neede. srsenb from the already-ported srsRAN 4G transmits a real downlink over ZMQ. srsenb will not advance over ZMQ without an uplink source answering on its RX port each TTI, so I wrote a 50-line responder that returns zeros. At 25 PRB the sample rate is 5.76 Msps. LTESniffer found the cell, decoded the MIB, logged DCI locations and wrote a MAC pcap. But dci.csv was empty: with no UE attached there is no C-RNTI to report.

A real C-RNTI needs the same downlink delivered to srsue and to LTESniffer at once, and srsRAN’s ZMQ transport is a 1:1 request and reply. So I wrote a downlink fan-out: pull each TTI once from srsenb, serve it to both consumers, and hold the next block until both have read. That gave a transient C-RNTI, but the attach was rejected because there was no core network.

The last step needed srsepc, the 4G EPC, which on macOS has to run under sudo because of its S11 UNIX sockets and the SGi tun device. With the EPC in front, the test UE completed a full attach. LTESniffer, listening passively on the fan-out, held the C-RNTI across the session, populated dci.csv per UE, and mapped the RNTI to the TMSI. That mapping comes from the RRC Reconfiguration, where the GUTI attach exposes the S-TMSI in clear, not from the ciphered NAS. One upstream call that writes DCI to file was commented out, so I re-enabled it. That closed the downlink path end to end: build, decode, control channel, sustained C-RNTI, per-UE records, identity, all cross-checked against the stack that generated the traffic.

Uplink was already there

I expected uplink to be the next porting job but the uplink decode is already compiled into the downlink binary. The PUSCH decoder, the uplink scheduler, format-0 grant handling and uplink RRC/NAS. None of it touches the module I had assumed I needed. That module, behind the ENABLE_MEAS flag, is a separate thing: GPSDO synchronization for multiple USRPs. It is not the lever for uplink decode, and its payoff can only be checked on multi-radio hardware, so I left it alone.

So the uplink task was validation, not porting. I extended the loopback with a second fan-out on the uplink: the test UE’s transmit stream goes to srsenb’s receive port and to a second LTESniffer channel. LTESniffer ran with two channels, one on the downlink and one tuned to the uplink frequency, and the four-way flow barrier held without deadlock. It decoded format-0 uplink grants on the C-RNTI, recovered PUSCH, read the uplink identity, and wrote uplink MAC PDUs to the pcap. All of it matched the ground truth from srsue and srsenb.

Where the loopback stands

The loopback proves the decode code, downlink and uplink, against known inputs. It does not prove GPSDO synchronization, over-the-air timing, or real uplink power from a handset. A single AD9361 radio cannot receive uplink and downlink at once on the same FDD carrier, because the two receive chains share one local oscillator. Concurrent capture needs two independent oscillators, an X310 or two B210s with a shared GPSDO. On-air capture needs that hardware and the right regulatory authorization. The multi-USRP sync module stays unported until there is hardware to validate it against. Porting code whose only job is to sync two radios I do not own would be work with nothing to test it.

The kit

It ships the way my other macOS ports do: a patch kit, not a copy of the upstream source. The install script clones LTESniffer and the srsRAN2 fork at pinned commits, applies the two patch series, and builds the CLI. Licensing is AGPL-3.0, inherited from both upstreams. The loopback scripts that produced the results above are in examples/, with the paths parameterized. Repository: https://github.com/AndreiGosman/ltesniffer-macos-arm64