How to Speak the WPS Protocol Directly
WPS PIN attacks have a reputation for needing a laptop, Kali, and a USB adapter in monitor mode. A phone cannot enter monitor mode, its Wi-Fi chipset is usually closed, and the operating system owns the radio.
What this post is about. WIPWN is my attempt to run those attacks from an Android phone in Termux anyway. It is not a reimplementation of aircrack — it is a state machine, a program whose behaviour is decided by what state it is in, that speaks the WPS protocol directly, through wpa_supplicant, over a control socket the tool opens itself. This post is partly about that, and partly about what happens when you ship an obfuscated binary.
What you need to know first. WPS is Wi-Fi Protected Setup, the button-press pairing protocol that lets a phone join a router without typing a password. It uses an eight-digit PIN — a setup code printed on the router — and it works by exchanging a short handshake, a back-and-forth message sequence that proves you know the code. Monitor mode is a special state a Wi-Fi adapter can be put into where it captures raw radio traffic instead of joining a network. An access point is the router; its BSSID is the router’s hardware address, a six-byte identifier for its radio. Termux is a terminal emulator for Android, and Kali is the penetration-testing Linux distribution that these attacks are usually run from. None of this requires you to have used any of it before; I define each term the first time it matters.
The situation
The constraint that shapes everything is: no monitor mode.
The usual attack flow is: put the adapter in monitor mode, capture the M1/M2 handshake messages, feed them to pixiewps, which computes the M2 from the nonces in M1 — a nonce is a random number the exchange starts with — and with a matching M2 the access point hands you the PIN. Pixie Dust does this in seconds on vulnerable chips, and succeeds maybe 10–20 % of the time. The rest of the time you brute-force the PIN — try every candidate until one works — and the PIN is a 8-digit number with a known checksum, so it splits into a 4-digit half and a 3-digit half plus a check digit.
None of that needs monitor mode if the machine running the attack is also the one talking to the access point through wpa_supplicant. And that is the key realization: on Android, wpa_supplicant is the only handle you have on the radio. It is the daemon — a background program — that owns the Wi-Fi interface and speaks the protocol on the operating system’s behalf. A control socket is the local network endpoint it listens on for commands.
So the tool owns wpa_supplicant.
Why this design
The tool starts and configures wpa_supplicant itself. Not wpa_cli, not the OS Wi-Fi API — the daemon, in WPS mode, with its control socket open, so the tool can drive the M1/M2 exchange directly:
1 | # consts: 'wpa_supplicant -K -d -Dnl80211,wext,hostapd,wired -i{} -c{}' |
The command line above is the daemon’s: -i names the wireless interface, -c points at a configuration file, and -K keeps the control socket for privileged commands. The tool speaks sendOnly/sendAndReceive over that socket, performs the WPS handshake, and rotates the source MAC per attempt — a MAC is the hardware address of a network card, and changing it makes the tool look like a different device, so a locked-out access point sees a new one each time.
Android’s radio has to be taken out of the way. The OS Wi-Fi stack and wpa_supplicant cannot both own the interface, so the tool disables the OS radio and restores the previous state afterwards:
1 | # ('settings', 'get', 'global', 'wifi_scan_always_enabled') |
These are Android shell commands rather than Python calls: the first reads a global setting, the second turns the radio off. The wifi_scan_always_enabled read exists because disabling the radio also kills background scanning, and the tool has to put it back.
Both attack modes are first-class. Pixie Dust is fast but unreliable; brute force is reliable but slow and the access point locks after N consecutive failures. Treating the first as the primary and the second as a fallback would have been the wrong default, because in practice the failure rate is 80–90 %.
The run must survive the phone. A brute-force attempt takes hours, the access point locks, the phone runs out of battery, Termux gets killed. So the tool persists progress — last_pin_tried, attempts, per-BSSID statistics — in JSON under ~/.Wipwn/sessions/, and can resume.
The tool assumes the access point is rate-limiting by design. WPS lockout is a specification feature, not a bug, so the attack has to live with it:
1 | # consts: 'consecutive_failures', 'timeout', 'consecutive_timeouts', |
These are the literal strings the failure handler looks for. The handler distinguishes a timeout from a NACK — the access point’s explicit “no” to a PIN — from a lock, escalates the timeout after repeated timeouts, and waits out a lock instead of hammering.
How it works
The layers, from the inside out:
| Component | Responsibility |
|---|---|
Companion |
The engine: owns wpa_supplicant, performs the WPS M1/M2 exchange, PBC and PIN modes, two-half brute force, per-attempt MAC rotation |
PixiewpsExtendedModule |
Wraps the pixiewps binary: pixie dust, brute force, hybrid, auto-detect, output parsing |
WPSpin |
PIN algebra: ~50 vendor-specific generators (Zyxel, TPLink, Huawei, DLink, ASUS, Netgear, Xiaomi), checksum, suggested-pin computation |
ManagedModeWPSScanner |
iw scan parsing → SSID/BSSID/WPS flags/signal, vendor fingerprinting, session save/load |
OnlineBruteforcer |
Thread-pool PIN testing, driven straight at wpa_supplicant |
RateLimitBypass |
Detects AP lockout, then MAC spoofing / channel hopping / timing |
SessionResumeManager |
JSON session state so a run can continue |
AndroidNetwork |
Termux-specific radio management |
The PIN generator table is worth a sentence on its own. WPS PINs are not random per vendor: each manufacturer encodes information about the device into the pin (often derived from the MAC address) into the first four digits. With ~50 generators and a 7,227-entry pin/OUI table — an OUI being the first half of a hardware address, which names the manufacturer — the “suggested PIN” path turns a 10,000-attempt search into a handful.
Parsing as a contract
pixiewps is a separate binary with its own output format, and the tool has to survive its variations:
1 | # PIN regexes: (\bPIN[:\s]+([0-9]{8}), (\[PIN\]\s+([0-9]{8}), PIN:\s*(\d{8}), Found\s+PIN:\s+(\d{8})) |
A regular expression, or regex, is a small pattern language for “find text that looks like this”; each of these patterns matches a different spelling of a PIN or a password in the output. Treating a subprocess’s stdout as a contract — a set of documented shapes plus a set of known failure strings — is the minimum viable way to integrate an external tool whose error handling you do not control.
The shipped artifact is not source
Here is the part I would not have predicted when I started this project.
The repository’s main.py is a 49-line loader. The real program is a ~470 KB marshalled code object, unpacked through several layers:
1 | hd0OtAwLWb_result = hd0OtAwLWb(d3P3zemw15W) |
Each line calls a function with a scrambled name and a scrambled argument, and each function peels off one layer. The { ... } hides two more of the same shape. Decoding the chain — hex, base64 twice, ROT-23, XOR, zlib, XOR, marshal.loads, exec — is a twenty-minute exercise, and I want to be explicit about that, because it is the whole argument. If you publish a security tool for study, obfuscation buys you nothing that a straight source release does not buy you more cheaply.
For what those names mean: hex and base64 are ways of writing bytes as text; ROT-23 shifts each letter of the alphabet by 23 places; XOR is a bitwise operation that combines two byte strings and is its own inverse, so the same key both scrambles and unscrambles; zlib is a compression library; marshal.loads turns Python’s own serialised form back into a code object; and exec runs it.
What the obfuscation actually costs:
- It is unauditable. A reader cannot check what the tool does to their phone. The upstream community this project depends on —
pixiewps,wpa_supplicant, Termux packaging — cannot audit a dependency they do not maintain. - It breaks reproducibility. The payload only loads under CPython 3.14 — CPython being the standard Python interpreter. On 3.9, 3.11, 3.12 and 3.13 it fails with
bad marshal data (unknown type code). That is an interpreter pin created by an accident of the packing tool, not by a design decision. - Every bug report becomes a black box. “It does not work” is unanswerable when the failure is inside an opaque blob.
- It hides the part that is actually worth reading. The PIN generator table and the WPS state machine are the interesting engineering. They are the parts a competitor most wants to copy, which is presumably the point — but it is also the part a reader most wants to learn from.
Trade-offs
Running the tool’s own wpa_supplicant is a feature and a constraint. Owning the daemon gives complete control of the WPS exchange, but it also means the tool must deal with driver quirks, interface naming, and the platform’s own network manager. Everything shells out: wpa_supplicant, pixiewps, iw, wpscan, ifconfig, macchanger, make, even curl. No native APIs are used, so behaviour varies per platform.
The fixroot helper curls a script from a GitHub Gist and runs it with bash -c. A Gist is a GitHub paste page; curl downloads, bash -c runs what it downloaded. In a tool that already runs as root — the phone’s superuser account, which can change anything on the device — that is a supply-chain footgun: supply chain is the path a piece of software takes to reach you, and here part of that path is a mutable web page, so the trust boundary is a URL, not a hash. A hash is a short fingerprint of a file’s contents; if you can check it, you can tell whether the file you downloaded is the file you were given.
Three session systems coexist. SessionManager, SessionResumeManager and Companion.save_session each persist progress with different schemas. This is what layered accretion looks like from the inside — every layer was reasonable when it was added.
The repository’s surface hygiene is good. Keep-a-Changelog, SemVer from 3.0.1 to 3.3.0, MIT license, --doctor diagnostics, an HTML/CSV/JSON report generator, Telegram community, donation links. Someone reading only the repository would conclude it is a well-run project. The changelog is, in effect, the only readable architecture record — which is an odd thing for a project to have.
What I would do differently
Ship the source. Not “ship the source and obfuscate it” — ship the source, and let the archive be the source. For a research tool, the audience that matters is the one that wants to understand how a WPS handshake is actually performed on a phone, and that audience is exactly the audience an opaque blob excludes. The copy-protection instinct is a product instinct, and this is a research project.
Drop fixroot and vendor what it fetches. A root helper should be a file in the repository whose hash the user can verify, not a curl | bash against a mutable gist.
Unify the session layer. One schema, one writer, one reader. The current arrangement guarantees that a session saved by one code path cannot be resumed by another.
Keep the honest diagnostics. --doctor, --interfaces, the structured failure messages and the per-attack statistics are the best parts of the project, and they are the parts that survive being open source. If anything is going to make the tool’s credibility durable, it is the fact that it tells you exactly what it did and what it refused to do.
Then, and only then, work on the attack engine. The protocol work — the M1/M2 exchange, the two-half brute force, the vendor PIN algebra, the lockout-aware pacing — is genuinely good, and it is the part of this project I would still be proud of in five years. It deserves to be readable.
References
- WIPWN — the project this post is about
- aircrack-ng — the tool suite the usual WPS attack flow is built on
- aircrack-ng wiki — background notes on the suite and its scripts
- w1.fi — the Wi-Fi Alliance, the body behind the WPS specification this post’s protocol work follows
- RFC 4253 — the SSH transport protocol specification, a worked example of a framed request/response protocol