I thought it would be fun to wrangle together a device that acts similarly to the HAK5 USB Rubber Ducky (i.e. presents itself as a USB keyboard device and interacts with the target by sending rapid keystrokes), but is flexible w.r.t. abilities+implementation (and maybe cheaper!). In addition to just scripting keystroke commands, my bigger goal was to be able to transfer files (payloads? ¯\_(ツ)_/¯) to targets, even if restrictions are in place (like USB mass storage devices being prohibited by a policy applied in the target OS). Using a Raspberry Pi Zero (NOTE: I will include archive.org "wayback machine" snapshot links as wbm alongside all links here in an attempt to preserve-references-over-time for this post as much as possible) that I had lying around (a $15-or-less device to purchase online), an old microSD card, a leftover button, a tiny bit of solder, and a number of helpful guides/programs I found online, I cobbled something together:
Voila, end result...!
And when you connect it to a target computer/device's USB and press the button, it sends your data as keystrokes on a keyboard:
Animation from a macOS terminal...
Android phone Termux app screenshot of it working...
In this post, I'll cover all the steps I took to get this working and share some things I learned along the way, but I'll caveat here that this isn't the most polished-of-guides. 😅
Enjoy!
Poking Around...
As mentioned above, I started with a RPi Zero (Raspberry Pi Zero) that I had handy and an 8GB microSD card that was unused at that moment. I located this
handy guide online (
wbm) that helped carry me much of the way that I wanted to go with this, helping break down the following order of steps:
- setup the microSD card with a bootable Linux image
- install the microSD card into the RPi Zero, boot it, login, and update password and hostname
- setup the RPi for keystroke goodness
- setup for payload delivery
- add a button (optional!)
- use!
I should note that, alongside my RPi Zero, I used a MacBook Pro running macOS 10.15.7, which all my steps below are written to, but nothing should prevent anyone from achieving success using Linux or Windows.
MicroSD Setup
Before we can use the RPi Zero, we need to provide an operating system that it can boot and use. You'll need a SD-to-USB card reader (or a built-in SD card reader) to accomplish this. I followed
this guide (
wbm) to download and use
Raspberry Pi Imager to install Linux-based
Raspberry Pi OS Lite (used to be called Raspbian, for y'all fellow old timers...) on the microSD card, which went something like this under macOS:
Writing Pi OS to the microSD card...
Once the microSD had the OS saved and verified, close the app and do the following:
- unplug the SD-to-USB card reader (or remove the microSD card slot from the built-in reader)
- replug the SD-to-USB card reader back in (or reinsert the microSD card into the built-in slot)
- note that a new /Volumes/boot drive is now mounted (this is the SD card!)
Then, via a terminal window, follow step numbers 6 and 7 from
this site (
wbm) to enable both SSH and a working connection to your WPA2-secured local WiFi network when the RPi Zero boots up. Once you've done those, cleanly unmount the drive with
diskutil eject /Volumes/boot
and remove the microSD. The microSD now ready for the RPi Zero!
RPi Boot and Setup
Follow steps 9 through 11 from from
this site (
wbm) to get the RPi booted up, log in via ssh, and change the account password and hostname (which you'll find under the Network Settings option). Then reboot, and you should be able to access your RPi from a terminal prompt with
using the updated password you set. Neat!
Create the Illusion
At this point, you have a nifty RPi Zero that you can ssh to over the wireless network. Slick! Now let's add the special sauce that will make the RPi appear as a USB keyboard HID (human interface device) to an unsuspecting target.
Follow steps 1 through 3 from this
guide (
wbm), which utilize gadget and "libcomposite" functionality of the Linux kernel to have the RPi appear as a USB keyboard device. Now you have the base pieces in place for your RPi to appear as a keyboard. Getting closer!
At this point, gracefully power your RPi down with:
pi@fakekeyboard:~ $ sudo poweroff
Then connect the RPi to your laptop/desktop via the RPi micro USB port labeled
USB (see the
diagram in section 4 of the
guide you were just using). This will allow your RPi to have the full USB connection (data lines and all) to your system. Once the RPi boots up (~35 seconds), if you get a macOS (or other OS) dialog pop up saying "hey, this is a keyboard, I need you to press some keys so I can identify it", just "cancel" to close that dialog out (worked for me under macOS, and I never got that dialog again).
Someone wrote a
nice little C-based util (
wbm) called
scan for taking stdin and creating "keystrokes" out of it. We'll pull the code down to the RPi and compile it (helpfully, our necessary build tools are already installed with Pi OS). In a terminal window, ssh into your RPi do the following:
pi@fakekeyboard:~ $ wget https://github.com/girst/hardpass-passwordmanager-mirror-of-git.gir.st/archive/master.zip
pi@fakekeyboard:~ $ unzip master.zip
pi@fakekeyboard:~ $ cd hardpass-passwordmanager-mirror-of-git.gir.st-master/send_hid/
pi@fakekeyboard:~/hardpass-passwordmanager-mirror-of-git.gir.st-master/send_hid $ make
At this point, you'll have an executable scan program. Do the following to verify if your RPi can now send keystrokes:
pi@fakekeyboard:~ $ echo "whoami" | sudo ./scan /dev/hidg0 1 0
This command will send the string "whoami" (which happens to be a Linux command for identifying which user you are) to the scan utility, which will translate that into keystrokes. If all goes according to plan, you'll see something like this after you run that one line above:
pi@fakekeyboard:~/hardpass-passwordmanager-mirror-of-git.gir.st-master/send_hid $ echo "whoami" | sudo ./scan /dev/hidg0 1 0
pi@fakekeyboard:~/hardpass-passwordmanager-mirror-of-git.gir.st-master/send_hid $ whoami
So our one line command successfully sent scan the string "whoami" to send as keystrokes, which it did in the middle line above, which resulted in the pi output at the end (telling us we are Linux user "pi" on the system). Neato, we're a keyboard!! :)
Completing the Buildout
As mentioned in the intro, my goal is to copy a file (possibly a binary file) to the target. In order to support binary files, we need to ensure we're able to properly convey the data via our "keyboard" keystrokes. I went with
base64 encoding/decoding (
wbm) to achieve this, as it encodes data into a limited number of
keyboard-supported characters (
wbm), specifically 64 total characters (plus '=' for padding, so really 65!). So the idea is this:
- base64 encode the payload you want to deliver
- setup the RPi to send that encoded payload to the scan utility (just like we did with the 'echo' command in the previous section)
- try it out!
Encoding the Payload
Many *nix-like OSes have a utility present called base64 which is handy for encoding and decoding base64. The OS running on your RPi has it, so we'll just decide which file we'd like as a payload and copy it over there:
scp <payload filename> pi@<hostname>.local:
Once copied over, ssh into your RPi and base64 encode your payload with the following command:
pi@fakekeyboard:~ $ base64 -w0 <payload filename> > payload.base64
This will give you a base64-encoded version of your file (named payload.base64), which also takes on an additional ~33% more bytes in size compared to the file size, just FWIW.
Setup to Send
Let's now setup the RPi to, on boot, send our payload as keystrokes and then power itself off. We'll use the following short script to do accomplish this:
#!/usr/bin/env bash
SCAN_CMD='/home/pi/hardpass-passwordmanager-mirror-of-git.gir.st-master/send_hid/scan /dev/hidg0 1 0'
echo "cat << EOF > /tmp/payload.base64" | sudo ${SCAN_CMD}
cat /home/pi/payload.base64 | sudo ${SCAN_CMD}
echo | sudo ${SCAN_CMD}
echo "EOF" | sudo ${SCAN_CMD}
exit $?
You can copy-and-paste the above and save it to your RPi as filename
gogokeystrokes.sh, or you can ssh into your RPi and run the following command to pull it directly from my
GitHub gists (
wbm):
pi@fakekeyboard:~ $ wget https://gist.githubusercontent.com/pbarry25/50b5c409cbb14791e239790cff30b0c4/raw/2d6b6c884849daf7a88a4e5c8f408bce51e34ad5/gogokeystrokes.sh
Also, don't forget to make it executable!
pi@fakekeyboard:~ $ chmod +x gogokeystrokes.sh
Now let's set things up so that our payload keystrokes happen automatically once the RPi is plugged into the target, and let's also nicely shutdown the RPi to avoid potential corruption to the filesystem. Edit the /etc/rc.local file on your RPi to add two lines before the final 'exit 0' line in the file:
sleep 15
if [ "X`who | /bin/sed -n '/^pi/p'`" = "X" ]; then /home/pi/gogokeystrokes.sh; poweroff; fi
This should leave you with an /etc/rc.local file that has the last handful of lines looking like:
/usr/bin/isticktoit_usb # libcomposite configuration
sleep 15
if [ "X`who | /bin/sed -n '/^pi/p'`" = "X" ]; then /home/pi/gogokeystrokes.sh; poweroff; fi
exit 0
These two lines we added will, at the final part of the RPi booting up, wait 15 seconds to see if anyone has logged into the RPi as the 'pi' user via ssh and, if no one has, then run our gogokeystrokes.sh script to send the payload via keystrokes and then power down the RPi. This allows the RPi to automatically deliver our payload when inserted to a target, while still allowing us to quickly ssh into the RPi on bootup if we want to update the payload or make other changes.
At this point, we should be able to power off the RPi, unplug, and reinsert it to see it "do the keystrokes" that will create a new file called /tmp/payload.base64 of our payload, exactly as we'll want it to do when we insert it into our target device. But there's a small problem...
A Small Fly in the Ointment
Well, two small flies, actually... First, the code in scan doesn't handle more than 256 characters being read in, meaning base64 encoded payloads larger than 256 bytes are truncated down to 256 bytes. Welp, not helpful. Second, scan will send its keystrokes as fast as it can, which is 1) very well above what humans are capable of and 2) because of #1, some OSes will become unresponsive at those high keystroke rates.
We can mitigate both of these issues, respectively, in simple fashion by 1) increasing the read buffer size to something "large enough" for our payloads to fit in and 2) add a delay between keystrokes. If you ssh into your RPi, you can pull a "patch" of these two changes I made via
GitHub (
wbm) to bump the input buffer size to 25,600 bytes and add a delay-between-keystrokes equivalent to typing 200 characters-per-second, which yields about 1Kb of encoded payload data "typed" in 5 seconds:
pi@fakekeyboard:~ $ wget https://gist.githubusercontent.com/pbarry25/cbae67450bb5ecd16a2f5f5f99e410e5/raw/ad7b86140c5e18dbe766801bb325fe270967b0eb/main.c.diff
You can then apply that patch and rebuild scan:
pi@fakekeyboard:~ $ patch -p1 < main.c.diff
patching file hardpass-passwordmanager-mirror-of-git.gir.st-master/send_hid/main.c
pi@fakekeyboard:~ $ cd hardpass-passwordmanager-mirror-of-git.gir.st-master/send_hid/
pi@fakekeyboard:~/hardpass-passwordmanager-mirror-of-git.gir.st-master/send_hid $ make
gcc -std=c99 -Wall -Werror main.c scancodes.c -o scan
OK! Now we're ready to do a quick local test that it's working. Run the following:
pi@fakekeyboard:~/hardpass-passwordmanager-mirror-of-git.gir.st-master/send_hid $ ~/gogokeystrokes.sh
If it's working, you should see a bunch of phantom typing in your terminal window. And once your encoded payload has been fully "typed" out, you should get your prompt back and the newly-delivered encoded payload should be at /tmp/payload.base64. You can decode that base64 encoded file back to the original contents with:
pi@fakekeyboard:~ $ base64 -d /tmp/payload.base64 > payload
And payload should be identical to the file you encoded earlier!
Take it for a Spin!
Time to shine! You can now power off your RPi:
pi@fakekeyboard:~ $ sudo poweroff
And go insert it into a target system (or into the system you've been working with it, that'll work fine, too... :) ). The RPi should boot up, wait the 15 seconds and, when no ssh session for the 'pi' user appears, dump the payload keystrokes out, and power itself off.
NOTE: the full section above was written for targeting *nix-like terminals, particular the lines in gogokeystrokes.sh which use the cat command to write the encoded payload keystrokes to a file in /tmp, but you could easily target other OS terminals/shells/command-lines, OS "hot key" shortcuts, or other applications (anything that accepts keystrokes!).
For the Button Mashers Out There...
As an alternative to the above "delay-based delivery" of keystrokes, the RPi Zero has a number of general purpose input/output (GPIO) pins, making it handy to add something like a momentary switch (i.e. a button)! You can find the pinout
here (
wbm), where you'll note that GPIO 3 (pin 5) hits kind of a sweet spot in that 1) it has a built-in pull-up resistor and 2) it is right next to a ground pin (pin 6). Perfect!
I soldered a couple of header pins into those two pin location locations (of course you can skip this step if your RPi Zero came with this header populated):
Pins literally next to each other...!
I then attached (plugged in) a button salvaged from some other item/device (see pic at the top of this post). Woo! Now to connect it via software!
There's a handy python library for RPi GPIO support called
GPIO Zero (
wbm), and you can find some good examples
here (
wbm) on using it.
You can install GPIO Zero by ssh-ing into your RPi and running the following command (and answering 'Y' to allow it to install the packages, assuming everything looks correct):
pi@fakekeyboard:~ $ sudo apt install python3-gpiozero
Once installed, you can grab a little script from my GitHub gists (wbm) that we'll use to read the value of our button:
pi@fakekeyboard:~ $ wget https://gist.githubusercontent.com/pbarry25/9b2e316e2c9e0818e85bc5b24e5e1579/raw/c34b22275fbe902cb88de09a72d6ad050c4a0695/button-check.py
pi@fakekeyboard:~ $ chmod +x button-check.py
This script will allowing us to initiate the payload keystroke delivery on button press! Let's update our /etc/rc.local to use it instead of the delay-and-dump mechanism. Edit the /etc/rc.local file on your RPi to remove the two lines we added in the section above, and add the following one line in their place, just before the final 'exit 0' line in the file:
/home/pi/button-check.py &
This should leave you with an /etc/rc.local file that has the last handful of lines looking like:
/usr/bin/isticktoit_usb # libcomposite configuration
/home/pi/button-check.py &
exit 0
So, as the RPi is finishing up the boot process, it will run our button-check.py script as a background process. That script will wait in a loop until the button is pressed, at which point it will playback the encoded payload as keystrokes followed by a shutdown of the RPi. You can test it here by running the script directly and then pressing the button!
pi@fakekeyboard:~ $ ./button-check.py
If everything is working as expected, the script will detect your button press (NOTE: the script is only checking the button once per second, so you may need to press it for up to 1 second to be "detected"), send the keystrokes, and then powers off the RPi (NOTE: if this doesn't work, you might try rebooting your RPi and pressing the button once it has booted back up, as I can't recall if the GPIO Zero package/deps need a reboot).
At this point you should be good-to-go for "target practice"!
Parting Thoughts...
I found this project to be pretty fun, pretty quick (a few of evenings of poking around), inexpensive, and with the potential to grow it into something cooler. A few things I thought of while working on this project that might be nice to improve/add/try:
- reduce boot-up time (disabling unnecessary services from starting up, reducing/removing timeouts, etc.)
- harden for abrupt poweroff (unplug) to remove need for clean poweroff
- try other Vendor ID (VID) and Product ID (PID) combinations to try and avoid potential target OS "configure keyboard" pop-ups on device connect
- update payloads to self-decode (and self-execute... ¯\_(ツ)_/¯ ), potentially using "hot key" shortcuts and/or control characters to open/navigate apps and/or the OS itself
- improvements to the scan tool to better/more-flexibly support large amounts of incoming stdin data and adjustable delays between keystrokes
- NOTE: on my MacBook, I see the 'hidd' process (responsible for handling keyboard and mouse events) leap to ~50% CPU with the RPi's scan cranking out keystrokes at 200 per second. If I bump the RPi rate to 400 keystrokes per second, 'hidd' jumps to ~90% CPU and my whole UI starts lagging in responsiveness.
- make the RPi enumerate as having multiple USB capabilities (composite!)
If you'd like more info/examples on Linux USB gadget and libcomposite capabilities, check out
this post (
wbm) which is a good (and short) read! Cheers!