1. Why build another multi-tool?
How many of you have often found yourselves wanting to buy a Flipper Zero? I thought about it many times, but there were always problems holding me back: stock is often limited, the price tag is quite high, and above all, you miss out on the thrill of building such a powerful tool literally from scratch.
From these observations, my project was born: designing and developing a low-level "Swiss Army Knife".
It all started a few months ago. I was thinking about buying an M5Stick S3 after watching some videos online where people spoke very highly of it, especially for one major detail: unlike the Flipper, it has Wi-Fi and Bluetooth modules already built-in.
Digging deeper, I quickly realized the advantages of the ESP32-S3 over the classic Arduino. The key features that convinced me were:
- Dual-core processor: It opens the door to serious features, like managing firmware tasks separately.
- More RAM: It allows integrating very complex external libraries (like heavy graphical interfaces) without killing performance.
- Native USB HID: It allows emulating peripherals like keyboards or mice natively and quickly.
So, the hardware was decided. But why build a multi-tool?
The main reason is to explore and understand the technical background of as many tools as possible. Lately, I feel there is a tendency to overlook the ingenuity of the mechanisms operating right in front of our eyes. We prefer having a ready-made tool, usable perhaps without even knowing the basics of computer science. I wanted to go in the opposite direction and understand exactly how these things work at the code level.
2. Fluid Graphics and Multitasking: How not to blow up an ESP32
A major problem when rendering a graphical interface on a microcontroller is that the CPU has to calculate and send every single pixel. Since this is a time-consuming operation, the entire device gets blocked until the whole interface is completely redrawn. In a multi-tool, if the ESP32 is stuck drawing buttons, it cannot sniff packets or execute attacks.
To solve this problem and make the device truly responsive, I decided to use DMA (Direct Memory Access). DMA is a dedicated hardware component that allows peripherals (in this case the SPI display) to read data directly from RAM without involving the CPU, generating a single interrupt only when the transfer is complete.
I initialized the SPI bus asking ESP-IDF to automatically assign a DMA channel:
spi_bus_initialize(SPI2_HOST, &buscfg, SPI_DMA_CH_AUTO);
The RAM problem and Chunk Rendering
However, implementing DMA without the right precautions wouldn't have been enough. The device's screen resolution is 135x240 at 16 bits, which requires a total of 64.8 KB to be rendered. This leads to two major problems:
- Wasting so many resources just for the interface is not sustainable for a microcontroller.
- It is virtually impossible to find 64.8 KB of contiguous memory inside the RAM once the system is running.
To bypass this issue, I decided to divide the screen into horizontal "slices" of 20 lines each (about 5.4 KB total). Using the advanced ESP-IDF APIs, I allocated this buffer in a memory area compatible with DMA:
lv_color_t *buf1 = heap_caps_malloc(LCD_H_RES * 20 * sizeof(lv_color_t), MALLOC_CAP_DMA);
This way, we optimize RAM usage while still maintaining super fluid rendering.
At this point, a question arises: how does LVGL (the library I use to manage the interface) know when the DMA has finished sending the bits for those 20 lines? Simply, with a Hardware Interrupt system that defines this flow:
- LVGL fills the buffer and calls the driver.
- The driver passes the buffer to the DMA controller.
- When the transmission process ends, the interrupt is triggered.
- The interrupt immediately calls a callback (
notify_lvgl_flush_ready) which orders LVGL to calculate the next slice of the interface.
static bool notify_lvgl_flush_ready(esp_lcd_panel_io_handle_t panel_io, esp_lcd_panel_io_event_data_t *edata, void *user_ctx) {
lv_disp_drv_t *disp_driver = (lv_disp_drv_t *)user_ctx;
lv_disp_flush_ready(disp_driver);
return false;
}
Semaphores for Safety (Thread-Safety)
Since FreeRTOS is a system based on multi-threading, it is important to implement proper task management. A fundamental detail to know is that LVGL is not thread-safe by design. This means that the library has no internal mechanisms to protect itself: if two different tasks try to access or modify the graphical interface at the exact same moment, the system will crash.
To prevent these race-conditions, which can lead to data inconsistency and deadlocks, I implemented a Semaphore (Mutex) system: every time an external task (other than the graphical one) wants to modify a GUI element, it must first "take" the semaphore.
lvgl_mux = xSemaphoreCreateMutex();
If the graphical task is already working, the network task (for example, the WiFi sniffer) waits in queue for a few milliseconds until the Mutex is released. This guarantees that LVGL's memory is read or written by only one thread at a time, making the firmware solid and crash-proof.
3. Data Security and Crash Prevention
Having a responsive interface and fast loading times is not enough: we also must try to make the firmware as robust as possible against hardware failures.
This justifies the choice of the file system: LittleFS. Unlike the classic FAT32 or SPIFFS (which would corrupt the disk leading to the loss of all data in case of sudden shutdown), LittleFS was specifically designed for battery-powered microcontrollers and can maintain its integrity even if the power is suddenly cut off.
esp_vfs_littlefs_conf_t conf = {
.base_path="/memoria",
.partition_label="storage",
.format_if_mount_failed = true,
.dont_mount = false,
};
esp_err_t ret = esp_vfs_littlefs_register(&conf);
A great engineering practice is using the .format_if_mount_failed = true parameter, which allows handling the very first boot of the firmware on an empty memory by simply formatting it entirely on its own, without the user having to do anything.
How to read Giant Payloads without running out of RAM
The second major risk for stability is reading files. Many microcontrollers have very limited RAM that fills up in an instant if you try to open a file of several Megabytes all at once.
To prevent the BadUSB tool from going into Out of Memory when executing complex payloads, I decided to implement a "line-by-line" reading. Think of it a bit like an e-reader: it doesn't load the whole book into memory, but only shows the small part of text you are reading, allowing you to scroll through the pages interacting with the interface.
My parser dynamically loads only 256 bytes at a time:
bool esegui_duckyscript(const char *path) {
FILE *f = fopen(path, "r");
if (!f) return false;
char line[256];
// RAM consumption is capped at 256 bytes, regardless
// of the size of the file loaded by the user!
while (fgets(line, sizeof(line), f)) {
trim_trailing(line);
if (strlen(line) == 0) continue;
char *cmd = strtok(line, " ");
// parsing and execution of the single command...
}
fclose(f);
return true;
}
Conclusion
This is how I structured my project to guarantee its stability, responsiveness, and scalability, especially considering the large number of tools I am going to implement.
In the next episode, we will get to the heart of the action: I will show you how I used the USB stack to make the computer believe that the ESP32 is a keyboard, in order to execute DuckyScript scripts (BadUSB).
In the meantime, which pentesting tool would you like to see added to the project? Let me know in the comments!
Top comments (0)