Hacking The Hacker’s Tool — Patching Flipper Zero’s Levels For Fun

Noy Pearl
10 min readJun 3, 2023

TL;DR

I researched how the levels functionality in Flipper Zero works and tampered with it by changing the backup files so I can rank up [very] fast.

There’s a demo video at the end.

Why

So recently I started learning C and at a perfect timing I also got my hands on a Flipper Zero.

It has 3 levels, and since I’m familiar with game hacking — I really wanted to reach the next levels by walking the “unintended” way — AKA cheating ;)

Current stats:

Flipper zero low XP — a long way to go

DISCLAIMER: I know that Flipper Zero’s firmware was meant to be patched, rewritten and flashed, but this time I challenged myself to dig into the C source code of a [real] firmware for the first time and to tamper with something that is kinda like a game (Tamagochi), so I think that’s a cool experience that worth some flex and was really interesting!

What

Basically, there’s a desktop application called qFlipper that enables you to do a lot of stuff w/ your flipper (flash firmware, backup settings, factory reset) and more.

Specifically I wanted to test the backup/restore functionality, since I saw that you can backup your “Dolphin”.

“Dolphin” is your avatar in flipper and it has levels and moods. Its levels are increased every time that you use your flipper, e.g read RF signals, scan NFC cards and more.

You can backup your flipper settings and restore them. After I did a backup, factory reset and restored from backup I returned to my original level — which means that the level is also saved in the backup files.

Let’s see how they look like.

We can get the backup files by pressing the “Backup” button in qflipper:

qflipper GUI

The backup files are saved in your PC as a gzip compressed data file like the following:

extracting backup

Specifically, the .dolphin.state is the one that is most likely to store your level/XP:

Now, let’s dig into the source code of the firmware.

dolphin_state_filename.h defines the name of the saved file

define DOLPHIN_STATE_FILE_NAME ".dolphin.state"

And in dolphin_state.c we can see that the actual path is defined, alongside other parameters such as DOLPHIN_STATE_HEADER_MAGIC ,DOLPHIN_STATE_HEADER_VERSION and LEVEL2_THRESHOLD:

#define DOLPHIN_STATE_PATH INT_PATH(DOLPHIN_STATE_FILE_NAME)
#define DOLPHIN_STATE_HEADER_MAGIC 0xD0
#define DOLPHIN_STATE_HEADER_VERSION 0x01
#define LEVEL2_THRESHOLD 300
#define LEVEL3_THRESHOLD 1800
#define BUTTHURT_MAX 14
#define BUTTHURT_MIN 0

Following the references to DOLPHIN_STATE_PATH we see a function that might be responsible to save the struct that we saw previously - dolphin_state_save which calls saved_struct_save and seem to use the size of a struct, the path, the data and all other good stuff:

bool dolphin_state_save(DolphinState* dolphin_state) {
if(!dolphin_state->dirty) {
return true;
}
bool result = saved_struct_save(
DOLPHIN_STATE_PATH,
&dolphin_state->data,
sizeof(DolphinStoreData),
DOLPHIN_STATE_HEADER_MAGIC,
DOLPHIN_STATE_HEADER_VERSION);
if(result) {
FURI_LOG_I(TAG, "State saved");
dolphin_state->dirty = false;
} else {
FURI_LOG_E(TAG, "Failed to save state");
}
return result;
}

Jumping to the declaration of the saved_struct_save function to see what the types are:

bool saved_struct_save(const char* path, void* data, size_t size, uint8_t magic, uint8_t version) {

So, it suits the parameters that we saw before.

Jumping into the definition of the saved_struct_save function - we see an initialization of a boolean result:

bool result = true;

And while trying to load the state in saved_struct_load - the result is set to false in case the header (savedStructHeader) isn’t sized correctly:

if(bytes_count != (sizeof(SavedStructHeader) + size)) {
FURI_LOG_E(TAG, "Size mismatch of file \\"%s\\"", path);
result = false;
}

And there are more tests here to verify the magic & version, for example:

if(result && (header.magic != magic || header.version != version)) {
FURI_LOG_E(
TAG,
"Magic(%d != %d) or Version(%d != %d) mismatch of file \\"%s\\"",
header.magic,
magic,
header.version,
version,
path);
result = false;
}

And a calculation of a checksum. size = size_t argument in function (see definition above), and eventually is sizeof(DolphinStoreData).

Here’s how the checksum is calculated:

if(result) {
uint8_t checksum = 0;
const uint8_t* source = (const uint8_t*)data_read;
for(size_t i = 0; i < size; i++) {
checksum += source[i];
}

The checksum goes byte after byte of the data_read and adds each bytes to itself (initial value is 0).

So the final checksum should be the sum of all the data of the state.

In a case of a checksum mismatch — we are doomed:

if(header.checksum != checksum) {
FURI_LOG_E(
TAG, "Checksum(%d != %d) mismatch of file \\"%s\\"", header.checksum, checksum, path);
result = false;
}

And the restore from backup won’t work:

if(result) {
memcpy(data, data_read, size);
}

Now, we know the following:

  • How the checksum is calculated
  • How the state is verified, saved and loaded

We need to understand:

  • Where the checksum is stored in the binary .dolphin.state file
  • Where the level/XP are in this whole state, how they behave and where they are saved in the binary .dolphin.state file, so we could tamper with them.

Let’s go.

In saved_struct.c we have the state’s header - see the checksum’s location in the following struct:

typedef struct {
uint8_t magic;
uint8_t version;
uint8_t checksum;
uint8_t flags;
uint32_t timestamp;
} SavedStructHeader;

The checksum is 2 bytes (16 bits) after the 1st member:

.dolphin.state file hexdump

So when we’ll change the values of the state itself — we need to keep in mind to re-calculate a valid checksum — otherwise our patch won’t work.

So the header struct can be represented like this:

+----------+   +------------+   +-------------+   +-----------+   +-------------+
| magic | | version | | checksum | | flags | | timestamp |
| (1 byte) | | (1 byte) | | (1 byte) | | (1 byte) | | (4 bytes) |
+----------+ +------------+ +-------------+ +-----------+ +-------------+

(8 bytes in total)

Now, let’s see how the levels/XP are calculated & stored.

By searching for already-known value DOLPHIN_STATE_FILE_NAME from before - we return to the file dolphin_state.c and notice the following:

#define LEVEL2_THRESHOLD 300
#define LEVEL3_THRESHOLD 1800

Ok, so there are 3 levels and there’s some threshold for each of them. Maybe that’s the XP threshold? Let’s see.

At the bottom of the page we notice the following:

bool dolphin_state_is_levelup(uint32_t icounter) {
return (icounter == LEVEL2_THRESHOLD) || (icounter == LEVEL3_THRESHOLD);
}

uint8_t dolphin_get_level(uint32_t icounter) {
if(icounter <= LEVEL2_THRESHOLD) {
return 1;
} else if(icounter <= LEVEL3_THRESHOLD) {
return 2;
} else {
return 3;
}
}

Ok, so there’s an icounter variable that decides the actual level of the Dolphin.

In another function in the same file we see this:

uint32_t dolphin_state_xp_to_levelup(uint32_t icounter) {
uint32_t threshold = 0;
if(icounter <= LEVEL2_THRESHOLD) {
threshold = LEVEL2_THRESHOLD;
} else if(icounter <= LEVEL3_THRESHOLD) {
threshold = LEVEL3_THRESHOLD;
} else {
threshold = (uint32_t)-1;
}
return threshold - icounter;
}

Great. The icounter is surely the XP that is used.

Now that we know how the XP (icounter) behaves, let’s see where it’s saved in the struct and change it (and the checksum) accordingly!

Let’s go to the header file of the same file, called dolphin_state.h

typedef struct DolphinState DolphinState;
typedef struct {
uint8_t icounter_daily_limit[DolphinAppMAX];
uint8_t butthurt_daily_limit;
uint32_t flags;
uint32_t icounter;
int32_t butthurt;
uint64_t timestamp;
} DolphinStoreData;

Ok, here’s the struct that we need. It has 8-bit array of DolphinAppMAX

(we don’t know what it is yet), a few other members and icounter is right there - uint32_t.

Now, here’s the binary presentation of our state data struct in memory:

+---------------------------------------+
| DolphinStoreData |
+---------------------------------------+
| icounter_daily_limit[DolphinAppMAX] |
| (DolphinAppMAX bytes) |
+---------------------------------------+
| butthurt_daily_limit |
| (1 byte) |
+---------------------------------------+
| flags |
| (4 bytes) |
+---------------------------------------+
| icounter |
| (4 bytes) |
+---------------------------------------+
| butthurt |
| (4 bytes) |
+---------------------------------------+
| timestamp |
| (8 bytes) |
+---------------------------------------+

Let’s understand what’s the size of the icounter_daily_limit array so we could know how to find the icounter bytes in the .dolphin.state binary!

We see thatDolphinAppMAX is an enum in dolphin_deed.c :

typedef enum {
DolphinAppSubGhz,
DolphinAppRfid,
DolphinAppNfc,
DolphinAppIr,
DolphinAppIbutton,
DolphinAppBadusb,
DolphinAppPlugin,
DolphinAppMAX,
} DolphinApp;

Since the value of the DolphinAppMax isn’t declared - it gets the default one according to the index, which is 7 (the indexes of the values in the enum are starting from 0), like that:

typedef enum {
DolphinAppSubGhz, (val is 0)
DolphinAppRfid, ( val is 1)
DolphinAppNfc, ( val is 2)
DolphinAppIr, (val is 3)
DolphinAppIbutton, (val is 4)
DolphinAppBadusb, (val is 5)
DolphinAppPlugin, (val is 6)
DolphinAppMAX, (val is 7) <-------- this!
} DolphinApp;

So icounter_daily_limit is basically an array sized 7.

Here’s how the body(data) of the state looks like now:

+---------------------------------------+
| DolphinStoreData |
+---------------------------------------+
| icounter_daily_limit[7] |
| (7 bytes) |
+---------------------------------------+
| butthurt_daily_limit |
| (1 byte) |
+---------------------------------------+
| flags |
| (4 bytes) |
+---------------------------------------+
| icounter |
| (4 bytes) |
+---------------------------------------+
| butthurt |
| (4 bytes) |
+---------------------------------------+
| timestamp |
| (8 bytes) |
+---------------------------------------+

(28 bytes in total)

This DolphinStoreData struct is actually a member of another struct called DolphinState in dolphin_state.h:

struct DolphinState {
DolphinStoreData data;
bool dirty;
};

Which is actually the struct that is used when allocating, freeing and writing to memory for the save/load functionality of the binary .dolphin.state


void dolphin_state_free(DolphinState* dolphin_state);
bool dolphin_state_save(DolphinState* dolphin_state);
bool dolphin_state_load(DolphinState* dolphin_state);
DolphinState* dolphin_state_alloc();

Therefore, our new DolphinState struct is saved in memory like this:

+---------------------------------------+
| DolphinState |
| |
+---------------------------------------+
| DolphinStoreData data |
| (28 bytes) |
+---------------------------------------+
| bool dirty |
| (1 byte) |
+---------------------------------------+

So the size of the body of the dolphin is actually 29 bytes.

Now, SavedStructHeader is the header (with the magic and checksum) and DolphinState is the body (with the icounter and butthurt members):

+---------------------------------------+
| SavedStructHeader |
| (8 bytes) |
+---------------------------------------+
+---------------------------------------+
| DolphinState |
| (29 bytes) |
+---------------------------------------+

Calculating the offset of our bytes in file:

checksum offset: magic (1) + version (1) = 1+1 = 2

icounter offset: magic(1) + version(1) + checksum(1) + flags(1) + timestamp(4) + icounter_daily_limit (7) + butthurt_daily_limit (1) + flags(4) = 1+1+1+1+4+7+1+4= 20

Let’s return to the actual .dolphin.state file:

00000000: d001 cf00 0000 0000 000f 1414 0100 022e  ................
00000010: 0000 0000 3a00 0000 0000 0000 0000 0000 ....:...........
00000020: 113e 7a64 0000 0000 0a .>zd.....

Now, we know how to calculate the offsets of each one of the members in the two structs (header and body):

too-colorful values of the members in the header and data structs

So the values are:

checksum : 0xcf (207 in decimal)

icounter: 0x3a (58 in decimal)

Now, we want to increase the icounter, let’s increase both icounter and checksum by 200.

tempchecksum: 0x197 (407 in decimal)

new icounter: 0x102 (258 in decimal)

Notice I called the new value of checksum temp checksum. Here’s why:

The chucksum is 1 byte long (8-bit) -

uint8_t checksum;

And the largest value that 1 byte can hold is 0xff (255 in decimal).

BUT — have here 0x197- which is 407 in decimal that should be represented by 1 byte.

The new value 0x197 (407 in decimal) is bigger than the possible largest 1-byte value, which is 255 in decimal, 0xff in hex.

Therefore, we should use modulo on the new value of 256 and add the remainder from the devision by 256 (if there’s any) to the total checksum value.

In other words, the new value should be 407 % 256 + remainder (remainder from dividing 407 by 256).

calculating the new value as the following:

temp checksum = 407 in decimal

remainder = tempchecksum / 256 = 407 / 256 = 1

result = temp checksum % 256 + remainder = 407 % 256 + 1 = 151

new checksum: 0x98 (151 in decimal)

new icounter: 0x102 (258 in decimal)

We don’t have the same thing with icounter since it’s uint32_t , therefore the largest value that icounter can hold is 0xffffffff - 2^32, (4294967295 in decimal)

POV: My brain trying to calculate the offsets

Now, we can edit the .dolphin.state file manually.

Since I’m lazy I’m using a trick to automatically edit the bytes:

  • Open the file in vim
  • run :%!xxd to convert to hexdump
  • Edit the hexdump
  • run :%!xxd -r to convent the hexdump to binary
  • Save

So our new .dolphin.state file looks like this:

new patched .dolphin.state file

*Notice the new values for the checksum and icounter

*To easily convert hex or decimal 4-byte values I wrote this little script, you might find it useful:

import argparse
import struct
import os

parser = argparse.ArgumentParser(description="Packer for bytes, enter byte and get the little endian representation for binary relace")
parser.add_argument("byte", type=str, help="unter byte value, such as 300, 0x50")
args = parser.parse_args()
byte = args.byte
print(f"Packing {byte}")
packed = struct.pack("I", int(byte, 0))
print(f"here's your binary stuff: \\n{packed}")

Running it to get the bytes representation:

bytes.py helper output

We can upload the new .dolphin.statefile in the flash in Flipper using qflipper program:

And viola! 258 XP out of 300!

Flipper Zero with 258 XP

HURRAY!

Let’s show off and set the icounter to 858, to benefit from all the hard work we’ve done.

Using the new values:

icounter - 0x35a (858 in decimal)

checksum -0xf2 (242 in decimal)

The hexdump of .dolphin.state looks like this:

hexdump of level 2

Level UP!

Level 2 with 858 XP

Here’s a script that does that automatically in python:

https://github.com/noypearl/FlipFlop/blob/main/levelup.py

Aaaand — see it in action:

Let me know if you have any comments!

--

--