DEV Community

Cover image for Exploiting "Trust-by-Default": How I transformed an ESP32-S3 into a BadUSB
Donato Maglie
Donato Maglie

Posted on

Exploiting "Trust-by-Default": How I transformed an ESP32-S3 into a BadUSB

The first tool I decided to arm my Swiss army knife with was a BadUSB emulator. First and foremost, this extremely powerful tool (provided you have physical access to the target machine) relies on an inherent vulnerability of the USB protocol (born in 1996), originally designed to make connecting peripherals as simple and invisible as possible.

Practically, when we plug a USB device into a computer, a rigorous process called Enumeration begins. This is divided into several phases: the device is physically connected, its communication speed is identified, and immediately after, the device sends its Descriptors. These descriptors are a set of vital information that defines how the host machine will communicate with the device; subsequently, the device is reset, an address is assigned to it, the interfaces are loaded, and finally, the drivers.

(If you want to delve deeper into the enumeration phase, I recommend this article by TotalPhase).

Having defined this, it's easy to guess that if we alter the descriptors via software, we can disguise an ordinary flash drive (or in our case, an M5Stick) making it appear as a keyboard or a mouse.

And this is where the "Trust-by-Default" vulnerability comes into play: all operating systems blindly trust an HID (Human Interface Device) like a keyboard, as it is an essential input tool. Generic drivers are already pre-installed in the kernel (zero incompatibilities or warning popups) and, moreover, antiviruses have no say in the matter, since they are not designed to scan hardware inputs sent via electrical impulses.

The architectural choice: Dynamic Parser vs Hardcoding

Initially, for "simplicity", I thought about hardcoding the payloads directly in the C source code. But thinking about it carefully, the use cases in pentesting are infinite: the end-user must have the ability to modify scripts on the fly, without having to recompile or flash the ESP32 firmware every time.

For this reason, I decided to implement a dynamic Parser for DuckyScript. This allows the user to upload their own plain text scripts (.txt) onto the device's LittleFS memory and have the microcontroller interpret them on the fly.

For those unfamiliar with it, DuckyScript is a macro language created by Hak5 that abstracts interaction with the victim machine. It's simple and easily readable.
To better explain what I mean by "abstract": if I were to write a payload directly in low-level C or Bash, I would have to carefully calculate every single delay between one command and the next to give the PC time to open windows, and I would have to manually map the hexadecimal codes (HID Keycodes) of every single key to "press". DuckyScript, combined with my parser, automates all of this.

Originally, to use Rubber Ducky scripts, they had to be transformed from simple text files into binary files; this conversion was performed by a PC tool called Ducky Encoder, which converted them into inject.bin. This tool was used because many older devices lacked the computational power to analyze text during runtime, a problem that the ESP32-S3 has now overcome.

So I decided to integrate a native parser into my code: this way, a user can simply upload a file from their phone (via the dedicated internal HTML page) or from a computer and execute it immediately, without having to use an external tool for the conversion. Practically, this is done using standard C functions to separate the actual command from the argument at runtime:

char *cmd = strtok(line, " ");
char *arg = strtok(NULL, "");

if (strcmp(cmd, "DELAY") == 0 && arg) {
    int delay_ms = atoi(arg); 
    if (delay_ms > 0) { 
        vTaskDelay(pdMS_TO_TICKS(delay_ms)); 
    }
}
Enter fullscreen mode Exit fullscreen mode

RAM Security: O(1) Space Complexity

But we all know that C doesn't joke around when it comes to memory allocation, so I tried to make the script reading phase as secure as possible. The underlying problem is that if a user decided to use a very long script (for example, uploading an entire binary file encoded in text), the M5Stick S3 would risk running out of the memory designated for the SRAM, resulting in a Kernel Panic followed by a crash and a device reboot.

To bypass this hardware limit, I thought of allocating a buffer that reads the uploaded file in small, controlled blocks with the help of the fgets() function, which is memory-safe by nature. This mathematically prevents a Buffer Overflow from occurring: even if the user uploads a malicious or malformed file with a single line 10,000 characters long, fgets will still only read the first 255 characters per cycle, safeguarding the microcontroller's stack.

If we look at the reading process from a space-complexity perspective, you can see that the way I decided to implement this mechanic has a space complexity of O(1); whereas the classic implementation would have had a complexity of O(N), as it would have loaded the entire file into RAM simultaneously.

// RAM consumption locked at only 256 bytes
char line[256];

// The file is strictly read and processed line by line
while (fgets(line, sizeof(line), f)) {
    trim_trailing(line); 
    if (strlen(line) == 0) continue;

    // ... start parsing for this specific line ...
}
Enter fullscreen mode Exit fullscreen mode

Beyond abstraction: USB Protocol and Polling Rate

Despite all the numerous advantages that come from using the ESP32-S3, one "limit" remains: not being able to use libraries that allow a high level of abstraction, such as Arduino's Keyboard.h. This is a problem because, in the USB standard, a keyboard must regularly build and send 8-byte raw packets (divided into modifier mask, protocol byte, and key array) known as HID Reports.

Sending these reports falls under the polling operation, which is a mechanism by which the Host (the PC) cyclically queries the USB device to check if there have been any state changes. Specifically, I handle these cyclic requests with tud_hid_ready(), which checks if the USB Endpoint is free and if the PC has confirmed the reception of the previous packet. Through the while (!tud_hid_ready()) vTaskDelay(...) block, I prevent the ESP32 from overwriting its own buffer before the packets have actually traveled across the cable. Without this blocking check, the keystrokes would be "dropped" (lost), and a DuckyScript command like STRING powershell would be read by the victim PC as an incomprehensible pwsel.

State-Based Logic and the Auto-Repeat Trap

Added to this strict temporal "dance" of polling is one last huge engineering pitfall related to the nature of 8-byte reports. The most common cognitive error people run into is thinking that USB sends "print commands" (e.g., "type the letter R"). In reality, the USB architecture imposes a State-Based logic: the keyboard only communicates its electromechanical state to the PC at that precise moment.

When we send a report with a specific key, we are not telling it to print it, but we are communicating: "Right now, the user is physically pressing this key".

The operating system's driver receives this state. If the microcontroller were to immediately move to the next command without doing anything else, the PC would think the user's finger was glued to the key, triggering the operating system's Auto-Repeat (which would endlessly open dozens of windows or print RRRRRRRR...). To solve this problem, my firmware is forced to close the cycle by always sending a subsequent "zeroed" HID Report (passing the NULL array to TinyUSB). This tricks the PC, communicating that the key's physical contacts have finally been released (Key Release), allowing us to safely move on to the next letter.

Conclusion

Developing a low-level BadUSB emulator using esp_tinyusb is an immensely more educational challenge compared to using "pre-built" libraries. It forces you to study the anatomy of USB packets, understand how operating systems handle polling, and deal with the physical limits of a microcontroller's memory.

Now our digital "Swiss Army Knife" has its first real weapon, capable of uploading and launching payloads on the fly in total safety.

But a multi-tool isn't just a keyboard. What if we used the ESP32's antenna to intercept the invisible data floating in the air? In the next article, I'll show you how I configured the network interface in promiscuous mode to turn the ESP32 into a Wi-Fi Sniffer, capable of capturing raw packets and saving them directly to the file system in .pcap format, ready to be analyzed on Wireshark.

Top comments (0)