Building a WebAuthn Click Farm — Are CAPTCHAs Obsolete?

By Luke Young

Luke Young

How I built a click farm to “bypass” Cloudflare’s CAPTCHA killer with some cheap USB security keys, an Arduino, and a bit of python.

Any opinions stated here are my own, not necessarily those of any past, present, or future employer.

6 powered HyperFIDO keys connected to a USB hub and attached to a Arduino

Cloudflare recently published a blog post about a potential replacement for CAPTCHAs by utilizing signatures from hardware security keys and WebAuthn they are calling “Attestation of Personhood”. The post triggered a good bit of discussion online, particularly around the threat of automation mentioned near the end of the post:

We also have to consider the possibility of facing automated button-pressing systems. A drinking bird able to press the capacitive touch sensor could pass the Cryptographic Attestation of Personhood. At best, the bird solving rate matches the time it takes for the hardware to generate an attestation. With our current set of trusted manufacturers, this would be slower than the solving rate of professional CAPTCHA-solving services, while allowing legitimate users to pass through with certainty.

After a bit of brainstorming and discussion on Slack, I decided it would be a fun weekend project to test this out with actual hardware and see just how difficult it would be.

Acquiring compatible hardware

6 HyperFIDO hardware FIDO tokens still in packaging

Because WebAuthn is an open standard it’s of course trivial to build a software token and use it to sign requests. However, Cloudflare mitigates this by requiring a “hardware attestation” signature from specific manufacturers:

our initial rollout is limited to a few devices: YubiKeys, which we had the chance to use and test; HyperFIDO keys; and Thetis FIDO U2F keys.

This attestation is essentially a unique key per batch/manufacturer (minimum of 100k devices) baked into the device which (in theory) would be as difficult to extract as any other private keys from a device. I love my Yubikeys but there’s one thing you can’t deny, they’re expensive at $50+ apiece. Thankfully, HyperFIDO keys are much cheaper at $16 each, so I bought $100 worth of keys which were helpfully available with Amazon’s one-day shipping.

Prototyping with a SoloKey

With limited confidence in my soldering abilities, I wanted to prototype the software side of the equation while waiting for the keys to be delivered. Coincidentally, I recently acquired a SoloKey which is an open-source FIDO2 security key that allows the user to recompile and flash replacement user firmware. Predictably this key isn’t trusted by Cloudflare, but it will allow me to soft-disable the user presence test (physically pressing the key) required by the WebAuthn standard while still developing software that interacts with a physical USB-based key.

Thankfully this is a pretty trivial task, replacing the ctap_user_presence_test function with one that always succeeds. In fact, this is already implemented for use during testing via the “SKIP_BUTTON_CHECK_FAST” ifdef. Enabling it and compiling a new firmware release gets us a hardware token that signs any incoming WebAuthn request instantly (or as fast as the hardware will support).

Developing an actual server to expose the USB key and its operations to the internet is quite simple thanks to Yubico’s python-fido2 library, which abstracts the OS-specific USB interactions to a common interface. A basic HTTP server with no threading/locking/error handling can be implemented as follows:

Building an electronic version of the drinking bird

While experimenting with the SoloKey, the actual HyperFIDO keys arrived. Using some YouTube videos it was easy to get the keys apart and the internal circuit board exposed. Thankfully, going cheap on hardware actually paid off: instead of a capacitance-based presence detection like those seen on a Yubikey/SoloKey, they have a physical button that can be “easily” soldered for debugging.

A bare circuit board with a small wire being soldered to the button on the edge

Interestingly the button seems to be normally open at 3.3 volts and is brought down to 0 volts (ground) when pressed. The “proper” way to automate this would be to use a relay connected to either side of the button circuit allowing each button to be independently triggered without interference.

However, it took a solid hour to get all the keys disassembled and “reliably” soldered on one side, and I had no desire to do it twice. My hacky solution was to connect the wire to a GPIO pin on a Raspberry Pi (eventually replaced by an Arduino). By switching the pin between INPUT mode (floating) and OUTPUT_LOW (pull-down) it can cause each USB key to believe a button press has occurred. This works surprisingly well for a prototype but resulted in a few (in retrospect obvious) learnings:

  • If the output is ever accidentally set to OUTPUT_HIGH it may damage/destroy the USB key and if the normal/input voltage is higher than expected (3.3v) it may damage the Raspberry Pi.
  • It is critical that the ground between the USB key and Raspberry Pi/Arduino is tied together. The easiest way to do this is to power all devices from the same USB hub.
6 lite HyperFIDO keys connected to a USB hub and attached to a Raspberry Pi

Pressing buttons is tricky

My initial button press script was quite primitive, triggering each button iteratively as fast as possible. However, as I decreased the interval between button presses I quickly ran into a problem: when a user presence test wasn’t pending, each press triggered the “slot 1” behavior on the USB key generating a 6 digit numeric TOTP token (‘888888’ by default). This meant each key was outputting useless key presses to the connected host at a rate that actually started to result in software problems, particularly on macOS where I was doing local development.

To resolve this issue, I replaced the Raspberry Pi with an unused Arduino Uno I had. This allowed me to simplify the setup (no systemd/python just to toggle GPIO pins) and allowed me to move from a loop to an event-driven architecture. By reading which key to “press” via the (USB) serial interface on the Arduino, I could trigger a button press only when necessary. Perhaps a better solution would have been to disable the “slot 1” TOTP behavior altogether (which is possible on a Yubikey), but I don’t believe it’s possible to reprogram this on the HyperFIDO keys.

Putting it all together

At this point, I have all the ingredients necessary to build an internet-facing web service with the ability to answer WebAuthn signing requests that “automatically” bypass the user presence test on-demand. With a few more modifications the server became multi-threaded and supported exclusive locking per device: github.com/bored-engineer/fido-farm. Benchmarking the server I got some surprising (at least to me) results:

The server is capable of reliably handling just under 28 signatures/sec, or roughly 4.6 requests per key/sec which is much better than I expected. However, it is roughly on par with the benchmarking of an individual key multiplied by 6 so it makes sense.

If an attacker has automated attacks (e.g. DDOS, mass goods purchasing, etc.) that need to bypass the attestation of personhood this is a reliable way to do it with relatively low cost and effort. For a few hours of work and a hundred dollars of hardware keys, an attacker could make a reusable system that could support theoretically limitless automated requests that could successfully bypass the challenge.

Does this mean “Attestation of Personhood” broken?

In my opinion, no. Starting with the obvious, Cloudflare has clearly considered this attack vector as they mentioned it in the post and decided it still raises the cost of an attack over the current CAPTCHA model:

Another issue that we keep a close eye on is security. The security of this challenge depends on the underlying hardware provided by trusted manufacturers. We have confidence they are secured. If any breach were to occur, we would be able to quickly deauthorize manufacturers’ public keys at various levels of granularity.

CAPTCHA solving services charge a variety of prices differentiating their services based on price-per-image, response time, and success rate. Of course, our solution is 100% reliable (until the batch of keys is banned) and fast (~430ms/request) but comes at a significant up-front cost. Assuming a similar price point as CAPTCHA solving services of 50 cents per 1000 challenge images, our service would need to answer over 24,000 challenges per key before getting banned/detected just to cover the cost of the key let alone the additional hardware/bandwidth. And given the probability that all of the hardware keys are from the same batch, this wouldn’t be an event that can be easily recovered by just swapping out an individual key.

Another aspect to consider is that challenge flows are often only one of many factors used when determining if a request should be allowed. Factors like the reputation of the source IP/network, capabilities of the browser (ex: is JavaScript enabled), user behavior on the site such as how quickly was a captcha solved or did the user actually click the element or trigger the function automatically can be used before a decision is made to allow/deny the request.

How could you detect this type of abuse?

Perhaps one of the reasons Cloudflare has confidence in this solution is brute-force hardware automation like the kind developed here is likely much easier to detect than you’d think, (and if not, here’s a free suggestion):

While an attestation certificate is shared amongst at least 100k devices, an additional attribute is provided as part of the attestation called a “signature counter”, this is a unique counter that is incremented each time an operation is performed:

Authenticators SHOULD implement a signature counter feature. These counters are conceptually stored for each credential by the authenticator, or globally for the authenticator as a whole. The initial value of a credential’s signature counter is specified in the signCount value of the authenticator data returned by authenticatorMakeCredential.

In the case of the HyperFIDO tokens, this counter is global which means it increments each time the crypto module is used. Unless the attestation key is extracted from the device allowing complete control over the signed authenticator data, this global counter will increment with every credential operation performed:

Counter: (uint32) 366

This type of unnatural/consistent growth within a batch of keys could be very easy for Cloudflare to detect if they chose to track the values across a batch of keys. Even a basic privacy-preserving detection blocking all requests (or sending to an alternate challenge) that denies requests when the signature counter is greater than a reasonable human threshold (ex: 20k) could be effective.

A graph showing a sharp upwards increment in signature counter values for multiple keys

It will be interesting to see if over time Cloudflare finds it necessary to implement additional detections for this type of physical automation, as well as how quickly and how broadly they disable entire batches of keys when inevitably an attestation private key is successfully extracted in the future.

Takeaways

It is easy to build software that intercepts WebAuthn requests and sends them to a remote FIDO hardware key to be solved. With a bit of soldering, hardware FIDO keys can be modified so the user presence test (physically touching the key) is bypassed on-demand.

By combining these components, it is possible to automate Cloudflare’s Attestation of Personhood challenge. However, this comes at a significantly higher cost to attackers than CAPTCHAs and can be relatively easily detected (at least with the keys I tested).

Try it out yourself

For a final bit of fun, I’ve exposed this entire setup to the internet (via Cloudflare of course) and you can test it out yourself. Simply visit cloudflarechallenge.com and run the following in your browser’s developer console, then attempt to complete the challenge, it should be automatically solved by the hardware farm I set up:

Contributions and Thanks

A special thanks to those who helped peer review and make this post as useful as it is: Luke Matarazzo and James Chiappetta