Prelude's adversary engineers recently enjoyed a week of Flipper Zero security research. The goal was to discover an exploitable vulnerability in the Flipper Zero firmware via manual code review and report our findings.
Our results include two file loader bugs in two different Flipper Zero applications. In each case, improper parsing of the file leads to heap corruption and a crash.
Let’s talk about input
The first step of our research was to understand how we could control input data to the Flipper Zero.
As expected for a radio device, some applications can receive input from the radio transceivers, but we decided to focus on file inputs because they are relatively quick to assess and require no additional hardware. There are several applications within Flipper Zero that load and store file data. Documenting each application's unique file format helped us understand how we could interact with the functions within each application.
Once the file formats and functions had been mapped out, we could start to explore the file format requirements of each application. This includes understanding which file headers must be present, or which file fields must contain a particular value, in order for the application to utilize the file. Having done this, we've reduced our file attack surface to a small list of mutable file values and corresponding functions that might be exploitable.
After some trial and error, we landed on a few crashes. Let's look at some examples!
Infrared file format
Flipper Zero's infrared application is used to transmit and receive IR signals, like those of a TV remote.
When the infrared app receives an infrared signal on the Flipper Zero's built-in infrared transceiver, it can be saved to the Flipper Zero. Loading the IR file, a user is able to replay the IR signal back through the IR transceiver. The IR file format used in the Flipper Zero is unique to it. This file describes the type of signal, the IR protocol, and the data to be transmitted. An example of an IR file, used to power on my Samsung TV, is shown below.
Filetype: IR signals file
Version: 1
#
name: Power
type: parsed
protocol: NECext
address: 86 05 00 00
command: 0F F0 00 00
The IR application, started via the infrared_app()
function is responsible for verifying the path of an IR file and calling the infrared_remote_load()
function to load the IR file into memory.
Within infrared_remote_load()
are checks to ensure the IR file itself is valid. For example, flipper_format_read_header()
parses the IR file header Filetype:
value (shown in the example file above) and checks that this value matches the string IR signals file
. It also checks if Version
is equal to 1
. If either of these are false, infrared_remote_load()
quits and no further file parsing occurs.
Next, infrared_signal_read()
is responsible for parsing the name:
value in the file. This function searches the file for the string name:
and reads all bytes up to a line break. Since there's no size check, if we pass in a very long name
value this might result in an overflow.
The type
value is parsed by the infrared_signal_read_body()
function that reads each line in the file and checks if it starts with type:
and reads all chars up to the line break as the field's value. As with the name
value we're able to pass in very large values, but unfortunately the function checks whether the value equals raw
or parsed
and otherwise quits. As with the header, this value doesn't look exploitable.
The protocol
value is parsed in infrared_signal_read_message()
where the value is loaded similarly to name
and type
values, but the value is then checked against a list of values in infrared_get_protocol_by_name()
. Unfortunately, we won't be able to mutate protocol
values either without failing this check.
Finally, the address
and command
values are parsed together in infrared_signal_read_message()
by the flipper_format_read_hex()
function. This function reads a hex encoded string, such as 86 05 00 00
and converts it to a 4 byte array. Mutating the value so that it's not valid hex, or sending more than 4 bytes in a string, causes the check to fail and the IR application function to quit.
So after all that testing, it seems that only the name
value might be useful.
Crashing the Infrared app
What happens if we load an IR file with a very long name
value?
In the simplest case, our file will look something like this.
Filetype: IR signals file
Version: 1
#
name: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA ... AAAAAAAAAAAAAAAA
type: parsed
protocol: NECext
address: 86 05 00 00
command: 0F F0 00 00
When we load this file, the Flipper Zero crashes with this a furi_check
error. Crashes are good, but not necessarily useful!
The furi_check
is used to test a condition and gracefully crash the device if the test fails. Shown below.
/** Check condition and crash if check failed */
#define furi_check(__e) ((__e) ? (void)0 : furi_crash("furi_check failed\r\n"))
For our purpose, we encounter this check every time a block of data is read from the file stream buffer and written to the heap. As we begin to overflow the heap, corrupting the data used in this check, the check fails. This means we're not able to overflow the heap to an arbitrary address, such as the location of a callback pointer, because we'll always fail this check first.
If we want to proceed past this check, the name
value needs to include structures and addresses on the heap that we're overflowing. The problem, and reason this vulnerability is a dead end, is because that heap data includes null bytes that would terminate the name
value string if one were to include them.
Unfortunately for us, it seems there isn't a way to get past this check.
BadUSB file format
Flipper Zero's BadUSB application implements DuckyScript to perform USB keystroke injection.
DuckyScripts are plain text files that contain a simple macro language to inject keystrokes, and may also simulate USB storage or HID devices. Through the BadUSB application, the Flipper Zero can imitate a USB keyboard and send keystrokes to a connected device. Some of the more advanced features of DuckyScript, such as control flow and keystroke reflection, are not yet available in BadUSB.
The BadUSB file format doesn't contain any of the Flipper Zero file metadata found in the other application files, like the IR files. Instead, each BadUSB file contains only the DuckyScript macro functions that tell it how to interact with the connected device. An example of a BadUSB script that simply enters the string hello world
on a keyboard is shown below.
ID 1234:5678 Apple:1234
STRING hello world
ENTER
The application logic works by loading a DuckyScript file and parsing it one line at a time. It matches the first word which designates the macro function and dispatches the corresponding function or keystroke using the remainder of the line as an argument. For example, hello world
is dispatched to the STRING
function that is responsible for injecting the individual keystrokes of that string.
All of the DuckyScript macros implemented in the FlipperZero are shown below.
static const char ducky_cmd_comment[] = {"REM"};
static const char ducky_cmd_id[] = {"ID"};
static const char ducky_cmd_delay[] = {"DELAY "};
static const char ducky_cmd_string[] = {"STRING "};
static const char ducky_cmd_defdelay_1[] = {"DEFAULT_DELAY "};
static const char ducky_cmd_defdelay_2[] = {"DEFAULTDELAY "};
static const char ducky_cmd_repeat[] = {"REPEAT "};
static const char ducky_cmd_altchar[] = {"ALTCHAR "};
static const char ducky_cmd_altstr_1[] = {"ALTSTRING "};
static const char ducky_cmd_altstr_2[] = {"ALTCODE "};
As with the IR example, we need to understand the valid inputs into each function.
It would take too much time to describe each of these, so we'll just look at the most interesting one - which is the ID
function. This DuckyScript macro is used to set the device ID of the simulated keyboard, such as an Apple keyboard.
There's a bug in the ducky_set_usb_id()
function that parses ID
from a DuckyScript file.
static bool ducky_set_usb_id(BadUsbScript* bad_usb, const char* line) {
if(sscanf(line, "%lX:%lX", &bad_usb->hid_cfg.vid, &bad_usb->hid_cfg.pid) == 2) {
bad_usb->hid_cfg.manuf[0] = '\0';
bad_usb->hid_cfg.product[0] = '\0';
uint8_t id_len = ducky_get_command_len(line);
if(!ducky_is_line_end(line[id_len + 1])) {
sscanf(
&line[id_len + 1],
"%31[^\r\n:]:%31[^\r\n]",
bad_usb->hid_cfg.manuf,
bad_usb->hid_cfg.product);
}
FURI_LOG_D(
WORKER_TAG,
"set id: %04X:%04X mfr:%s product:%s",
bad_usb->hid_cfg.vid,
bad_usb->hid_cfg.pid,
bad_usb->hid_cfg.manuf,
bad_usb->hid_cfg.product);
return true;
}
return false;
}
The problem is the second call to sscanf
that delimits the hid_cfg.manuf
and hid_cfg.product
values by a colon, matching up to a carriage return. So we might be able to use this field to overflow values in the bad_usb
struct on the heap.
The malformed DuckyScript files looks something like this.
ID 1234:5678 Apple:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA ... AAAAAAAAAAAA
STRING hello world
ENTER
If we load that file, using a very long ID
string, we get another furi_check
crash.
Unlike the IR file, if we're careful with the length of the hid_cfg.product
, we can mutate some data on the heap without triggering a furi_crash
.
As a DuckyScript executes on the Flipper Zero it shows the completion percent of the DuckyScript. Something looks a little off here…
We get this result because it’s overflowing a heap value used to store the completion value of the DuckyScript progress as it executes the script line by line.
The heap object we’re overflowing is the BadUsbScript
struct shown below.
struct BadUsbScript {
FuriHalUsbHidConfig hid_cfg;
BadUsbState st;
string_t file_path;
uint32_t defdelay;
FuriThread* thread;
uint8_t file_buf[FILE_BUFFER_LEN + 1];
uint8_t buf_start;
uint8_t buf_len;
bool file_end;
string_t line;
string_t line_prev;
uint32_t repeat_cnt;
};
Specifically, we’re overflowing the product
field of the hid_cfg
object. The hid_cfg struct shown below.
typedef struct {
uint32_t vid;
uint32_t pid;
char manuf[32];
char product[32];
} FuriHalUsbHidConfig;
The issue for us is that there are calls to furi_check
on some of the values located past this heap object which would require null bytes, and no values within this struct that can be mutated for further exploitation (such as a callback pointer).
Unfortunately, while it’s fun to turn the Flipper Zero screen into glitch art, there isn’t much we can do from here.
Wrap up
While we weren’t able to achieve code execution, digging into the Flipper Zero firmware resulted in a few interesting discoveries for our team.
We’d like to thank the Flipper Zero team for their hard work bringing this RF multi-tool to life, and for providing an interesting target for exploit development. <3<3<3
See you all next time!