Initial commit — FlipperDroid v0.1.0-poc

KernelSU module + Flipper Zero FAP that bridges both devices into a
unified pentesting platform over USB CDC serial / BT rfcomm.

Android side: bridge daemon, WebUI (:8089), bind mount namespace
isolation stealth engine. Flipper side: proper FAP with 4-view GUI,
GPIO/SubGHz/IR/file command handlers, async event streaming.
This commit is contained in:
sssnake
2026-03-31 21:26:58 -07:00
commit be81a92d44
22 changed files with 4191 additions and 0 deletions

View File

@@ -0,0 +1,62 @@
#!/sbin/sh
#################
# Initialization
#################
umask 022
TMPDIR=/dev/tmp
PERSISTDIR=/sbin/.magisk/mirror/persist
rm -rf $TMPDIR 2>/dev/null
mkdir -p $TMPDIR
ui_print() { echo "$1"; }
require_new_magisk() {
ui_print "*******************************"
ui_print " Please install KernelSU-Next "
ui_print "*******************************"
exit 1
}
##############
# Environment
##############
OUTFD=$2
ZIPFILE=$3
mount /data 2>/dev/null
[ -f /data/adb/magisk/util_functions.sh ] || require_new_magisk
. /data/adb/magisk/util_functions.sh
[ $MAGISK_VER_CODE -lt 20400 ] && require_new_magisk
setup_flashable
mount_partitions
api_level_arch_detect
###############
# Module Setup
###############
MODID=flipperdroid
MODPATH=$MOUNTPATH/$MODID
ui_print "- Extracting FlipperDroid module"
unzip -o "$ZIPFILE" -x 'META-INF/*' -d $MODPATH >&2
# Default permissions
set_perm_recursive $MODPATH 0 0 0755 0644
set_perm $MODPATH/system/bin/flipperdroidd 0 2000 0755
set_perm $MODPATH/system/bin/flipperdroid-webui 0 2000 0755
set_perm $MODPATH/system/bin/fd-stealth 0 2000 0755
set_perm $MODPATH/service.sh 0 0 0755
set_perm $MODPATH/post-fs-data.sh 0 0 0755
set_perm $MODPATH/uninstall.sh 0 0 0755
ui_print "- FlipperDroid installed"
ui_print "- Connect Flipper Zero via USB or Bluetooth"
ui_print "- WebUI available at http://localhost:8089"

View File

@@ -0,0 +1 @@
#MAGISK

116
README.md Normal file
View File

@@ -0,0 +1,116 @@
# FlipperDroid
**Status: Proof of Concept / Work in Progress**
FlipperDroid is a KernelSU-Next module that bridges a Flipper Zero and an Android phone into a unified pentesting platform. Both devices share resources — the phone gets full access to the Flipper's GPIO, SubGHz radio, NFC, RFID, IR, and iButton hardware, while the Flipper can offload compute-heavy tasks to the phone's CPU.
## Architecture
Two daemons, one on each device, communicate over USB CDC serial (or Bluetooth rfcomm). A binary protocol handles command/response and async event streaming.
```
┌─────────────────────┐ USB CDC / BT Serial ┌──────────────────────┐
│ Android (Phone) │ ◄═══════════════════════════► │ Flipper Zero │
│ │ │ │
│ flipperdroidd │ Binary protocol v0.1 │ FlipperDroid Bridge │
│ (bridge daemon) │ ─ Commands (phone→flipper) │ (FAP daemon) │
│ │ ─ Responses │ │
│ flipperdroid-webui │ ─ Async events (flipper→) │ Direct HAL access: │
│ (WebUI :8089) │ ─ CPU offload requests │ ─ GPIO │
│ │ │ ─ CC1101 (SubGHz) │
│ fd-stealth │ │ ─ ST25R3916 (NFC) │
│ (namespace isolat) │ │ ─ 125kHz RFID │
│ │ │ ─ IR TX/RX │
│ Exposes Flipper HW │ │ ─ iButton │
│ as local resources │ │ ─ SD card storage │
└─────────────────────┘ └──────────────────────┘
```
## What Works (PoC)
- USB device discovery (VID:PID 0483:5740)
- Bluetooth fallback via rfcomm
- Binary framed protocol with CRC8
- GPIO read/write/init over bridge
- SubGHz frequency set, TX, RX with async event streaming
- IR transmit (NEC, Samsung, RC5, RC6, SIRC, Kaseikyo)
- File operations on Flipper SD card
- System status (battery, temp, uptime)
- Connection monitoring with auto-reconnect
- WebUI with tabs for GPIO, SubGHz, NFC/RFID, IR, Stealth, and live events
- CPU sharing framework (Flipper offloads to phone)
- Stealth via bind mount namespace isolation
## Stealth
FlipperDroid uses bind mount namespace isolation to remain invisible to the system. Nothing on the stock filesystem is modified — dm-verity passes, Play Integrity passes, banking apps see a stock device.
**How it works:**
1. Stock files stay at their real paths, completely untouched.
2. Our custom binaries and configs live in `/data/adb/modules/flipperdroid/stealth/`.
3. Metadata (SELinux context, ownership, permissions, timestamps) is cloned from stock targets onto our files — `ls -Z` looks identical.
4. Using `nsenter`, we enter specific process mount namespaces and bind-mount our files. Only that process sees the swap.
5. Every other process on the system sees the untouched stock filesystem.
**Additional protections:**
- WebUI port firewalled to localhost only via iptables
- Config directory hidden with restrictive permissions
- Nothing runs until user is logged in — no early boot traces
- Configurable stealth map (`stealth_map.conf`) for per-process bind mount rules
**Usage:**
```sh
fd-stealth apply # Apply stealth map + hide device/port
fd-stealth teardown # Remove all bind mounts
fd-stealth status # Show active stealth state
fd-stealth hide-dev # Quick: hide device + port + config
fd-stealth show-dev # Quick: unhide everything
```
## What's Planned
- Full NFC relay (card data relayed over phone's network)
- RFID read/write/emulate via bridge
- iButton operations
- PWM and ADC over GPIO
- BadUSB script execution via bridge
- SubGHz signal recording/replay library
- Custom Flipper firmware with optimized bridge daemon
- Direct kernel driver for lower latency USB comms
## Requirements
**Android side:**
- KernelSU-Next or Magisk
- USB OTG support
- Android 12+
**Flipper Zero side:**
- Official firmware 0.90+ or compatible custom firmware
- FlipperDroid Bridge FAP installed on SD card
## Installation
**Android:**
Flash `FlipperDroid.zip` through KernelSU-Next module manager.
**Flipper Zero:**
1. Clone the Flipper Zero firmware repo
2. Copy `flipper/` contents to `applications_user/flipperdroid_bridge/`
3. Build: `./fbt fap_flipperdroid_bridge`
4. Copy the resulting `.fap` to Flipper SD: `apps/Tools/`
## Usage
1. Connect Flipper Zero to phone via USB-C OTG cable
2. Launch FlipperDroid Bridge app on Flipper Zero
3. The Android daemon auto-detects and connects
4. Open `http://localhost:8089` in a browser for the WebUI
## Protocol
See `system/etc/flipperdroid/protocol.md` for the full binary protocol specification.
## License
For authorized security research and penetration testing only.

19
flipper/application.fam Normal file
View File

@@ -0,0 +1,19 @@
App(
appid="flipperdroid_bridge",
name="FlipperDroid",
apptype=FlipperAppType.EXTERNAL,
entry_point="flipperdroid_app",
requires=[
"gui",
"notification",
"storage",
],
stack_size=8 * 1024,
order=10,
fap_icon="assets/flipperdroid_10px.png",
fap_category="USB",
fap_icon_assets="assets",
fap_description="FlipperDroid Bridge — Fuse Flipper Zero with Android over USB. GPIO, SubGHz, IR, NFC, RFID, file transfer, CPU offload.",
fap_author="snake",
fap_version="0.1.0",
)

114
flipper/fd_app.h Normal file
View File

@@ -0,0 +1,114 @@
/**
* FlipperDroid — application state
*/
#pragma once
#include <furi.h>
#include <furi_hal.h>
#include <gui/gui.h>
#include <gui/view_port.h>
#include <notification/notification.h>
#include <notification/notification_messages.h>
#include <storage/storage.h>
#include "fd_protocol.h"
/* USB CDC channel — channel 0 is Flipper CLI, we use channel 1 */
#define FD_CDC_CHANNEL 0
/* App views */
typedef enum {
FdViewStatus,
FdViewGpio,
FdViewSubghz,
FdViewLog,
} FdView;
/* GPIO pin entry */
typedef struct {
uint8_t id;
const GpioPin* pin;
const char* name;
bool initialized;
bool output;
bool value;
} FdGpioEntry;
/* Ring buffer for log messages */
#define FD_LOG_LINES 8
#define FD_LOG_COLS 30
typedef struct {
char lines[FD_LOG_LINES][FD_LOG_COLS + 1];
uint8_t head;
uint8_t count;
} FdLogBuffer;
/* Main application state */
typedef struct {
/* Furi */
Gui* gui;
ViewPort* view_port;
FuriMessageQueue* input_queue;
NotificationApp* notifications;
FuriMutex* mutex;
/* Threads */
FuriThread* rx_thread;
FuriThread* event_thread;
bool running;
/* Connection */
bool connected;
uint32_t rx_count;
uint32_t tx_count;
uint32_t err_count;
uint32_t connect_tick;
/* View */
FdView current_view;
uint8_t selected_gpio;
/* SubGHz */
bool subghz_rx_active;
uint32_t subghz_freq;
float subghz_last_rssi;
/* IR */
bool ir_rx_active;
/* GPIO */
FdGpioEntry gpio[12];
uint8_t gpio_count;
/* Log */
FdLogBuffer log;
} FdApp;
/* Lifecycle */
FdApp* fd_app_alloc(void);
void fd_app_free(FdApp* app);
int32_t fd_app_run(FdApp* app);
/* GUI callbacks */
void fd_draw_callback(Canvas* canvas, void* ctx);
void fd_input_callback(InputEvent* event, void* ctx);
/* Log */
void fd_log(FdApp* app, const char* fmt, ...);
/* USB I/O */
void fd_usb_tx(const uint8_t* data, uint16_t len);
uint16_t fd_usb_rx(uint8_t* buf, uint16_t max_len, uint32_t timeout_ms);
/* Frame TX helpers */
void fd_send_ok(FdApp* app, const uint8_t* data, uint16_t len);
void fd_send_err(FdApp* app, FdError err, const char* msg);
void fd_send_event(FdApp* app, FdEvent ev, const uint8_t* data, uint16_t len);
/* Command dispatch */
void fd_handle_cmd(FdApp* app, uint8_t cmd, const uint8_t* payload, uint16_t len);
/* Workers */
int32_t fd_rx_worker(void* ctx);
int32_t fd_event_worker(void* ctx);

526
flipper/fd_bridge.c Normal file
View File

@@ -0,0 +1,526 @@
/**
* FlipperDroid Bridge — protocol handlers and USB I/O
*
* RX worker reads framed commands from USB CDC, dispatches to handlers.
* Event worker pushes async events (SubGHz RX, GPIO, buttons) to phone.
* All hardware access happens here.
*/
#include "fd_app.h"
#include <furi_hal_power.h>
#include <furi_hal_infrared.h>
#include <infrared.h>
#include <string.h>
#include <stdio.h>
#define TAG "FdBridge"
/* ---- USB CDC I/O ---- */
void fd_usb_tx(const uint8_t* data, uint16_t len) {
furi_hal_cdc_send(FD_CDC_CHANNEL, (uint8_t*)data, len);
}
uint16_t fd_usb_rx(uint8_t* buf, uint16_t max_len, uint32_t timeout_ms) {
uint32_t start = furi_get_tick();
uint16_t total = 0;
while(total < max_len) {
int32_t n = furi_hal_cdc_receive(FD_CDC_CHANNEL, buf + total, max_len - total);
if(n > 0) total += n;
if(furi_get_tick() - start > timeout_ms) break;
if(total >= max_len) break;
furi_delay_ms(1);
}
return total;
}
/* ---- Frame TX ---- */
static void fd_tx_frame(FdApp* app, uint8_t resp_cmd, const uint8_t* payload, uint16_t plen) {
uint16_t frame_len = 1 + plen; /* cmd + payload */
uint8_t hdr[5] = {
FD_MAGIC_HI, FD_MAGIC_LO,
(frame_len >> 8) & 0xFF, frame_len & 0xFF,
resp_cmd
};
/* CRC over [len_hi, len_lo, cmd, payload...] */
uint8_t crc_in[2 + 1 + FD_MAX_PAYLOAD];
crc_in[0] = hdr[2];
crc_in[1] = hdr[3];
crc_in[2] = resp_cmd;
if(payload && plen > 0) memcpy(crc_in + 3, payload, plen);
uint8_t crc = fd_crc8(crc_in, 3 + plen);
furi_mutex_acquire(app->mutex, FuriWaitForever);
fd_usb_tx(hdr, 5);
if(payload && plen > 0) fd_usb_tx(payload, plen);
fd_usb_tx(&crc, 1);
app->tx_count++;
furi_mutex_release(app->mutex);
}
void fd_send_ok(FdApp* app, const uint8_t* data, uint16_t len) {
fd_tx_frame(app, FD_RESP_OK, data, len);
}
void fd_send_err(FdApp* app, FdError err, const char* msg) {
uint16_t mlen = msg ? strlen(msg) : 0;
uint8_t buf[1 + 256];
buf[0] = (uint8_t)err;
if(msg && mlen > 0) memcpy(buf + 1, msg, mlen > 255 ? 255 : mlen);
fd_tx_frame(app, FD_RESP_ERR, buf, 1 + (mlen > 255 ? 255 : mlen));
app->err_count++;
}
void fd_send_event(FdApp* app, FdEvent ev, const uint8_t* data, uint16_t len) {
fd_tx_frame(app, (uint8_t)ev, data, len);
}
/* ---- GPIO helpers ---- */
static FdGpioEntry* fd_find_gpio(FdApp* app, uint8_t pin_id) {
for(uint8_t i = 0; i < app->gpio_count; i++) {
if(app->gpio[i].id == pin_id) return &app->gpio[i];
}
return NULL;
}
/* ---- Command handlers ---- */
static void fd_cmd_system(FdApp* app, uint8_t cmd, const uint8_t* p, uint16_t len) {
UNUSED(p);
UNUSED(len);
switch(cmd) {
case FdCmdPing:
fd_send_ok(app, (const uint8_t*)"PONG", 4);
fd_log(app, "PING -> PONG");
if(!app->connected) {
app->connected = true;
app->connect_tick = furi_get_tick();
notification_message(app->notifications, &sequence_blink_start_green);
fd_log(app, "Android connected!");
}
break;
case FdCmdVersion: {
char buf[64];
int n = snprintf(buf, sizeof(buf), "%s v%s (%s)",
FD_DEVICE_NAME, FD_VERSION,
furi_hal_version_get_name_ptr() ? furi_hal_version_get_name_ptr() : "Flipper");
fd_send_ok(app, (const uint8_t*)buf, n);
break;
}
case FdCmdCapabilities: {
uint8_t caps = FD_CAP_GPIO | FD_CAP_SUBGHZ | FD_CAP_IR | FD_CAP_CPU;
fd_send_ok(app, &caps, 1);
break;
}
case FdCmdStatus: {
uint8_t st[9];
st[0] = furi_hal_power_get_pct();
int16_t temp = (int16_t)(furi_hal_power_get_battery_temperature(FuriHalPowerICFuelGauge) * 10);
st[1] = (temp >> 8) & 0xFF;
st[2] = temp & 0xFF;
uint32_t up = (furi_get_tick() - app->connect_tick) / 1000;
st[3] = (up >> 24) & 0xFF;
st[4] = (up >> 16) & 0xFF;
st[5] = (up >> 8) & 0xFF;
st[6] = up & 0xFF;
st[7] = app->subghz_rx_active ? 1 : 0;
st[8] = app->ir_rx_active ? 1 : 0;
fd_send_ok(app, st, 9);
break;
}
default:
fd_send_err(app, FdErrUnknownCmd, "bad sys cmd");
break;
}
}
static void fd_cmd_gpio(FdApp* app, uint8_t cmd, const uint8_t* p, uint16_t len) {
switch(cmd) {
case FdCmdGpioInit: {
if(len < 2) { fd_send_err(app, FdErrInvalidParams, "need pin,mode"); return; }
FdGpioEntry* g = fd_find_gpio(app, p[0]);
if(!g) { fd_send_err(app, FdErrInvalidParams, "bad pin"); return; }
switch(p[1]) {
case 0:
furi_hal_gpio_init(g->pin, GpioModeInput, GpioPullNo, GpioSpeedLow);
g->output = false;
break;
case 1:
furi_hal_gpio_init(g->pin, GpioModeOutputPushPull, GpioPullNo, GpioSpeedVeryHigh);
g->output = true;
break;
case 2:
furi_hal_gpio_init(g->pin, GpioModeOutputOpenDrain, GpioPullNo, GpioSpeedVeryHigh);
g->output = true;
break;
case 3:
furi_hal_gpio_init(g->pin, GpioModeAnalog, GpioPullNo, GpioSpeedLow);
g->output = false;
break;
default:
fd_send_err(app, FdErrInvalidParams, "bad mode");
return;
}
g->initialized = true;
fd_send_ok(app, NULL, 0);
fd_log(app, "GPIO %s init mode %u", g->name, p[1]);
break;
}
case FdCmdGpioWrite: {
if(len < 2) { fd_send_err(app, FdErrInvalidParams, "need pin,val"); return; }
FdGpioEntry* g = fd_find_gpio(app, p[0]);
if(!g) { fd_send_err(app, FdErrInvalidParams, "bad pin"); return; }
if(!g->initialized) { fd_send_err(app, FdErrDisabled, "pin not init"); return; }
furi_hal_gpio_write(g->pin, p[1] ? true : false);
g->value = p[1] ? true : false;
fd_send_ok(app, NULL, 0);
break;
}
case FdCmdGpioRead: {
if(len < 1) { fd_send_err(app, FdErrInvalidParams, "need pin"); return; }
FdGpioEntry* g = fd_find_gpio(app, p[0]);
if(!g) { fd_send_err(app, FdErrInvalidParams, "bad pin"); return; }
if(!g->initialized) {
/* Auto-init as input for convenience */
furi_hal_gpio_init(g->pin, GpioModeInput, GpioPullNo, GpioSpeedLow);
g->initialized = true;
g->output = false;
}
uint8_t val = furi_hal_gpio_read(g->pin) ? 1 : 0;
g->value = val;
fd_send_ok(app, &val, 1);
break;
}
default:
fd_send_err(app, FdErrNotSupported, "gpio cmd n/a");
break;
}
}
static void fd_cmd_subghz(FdApp* app, uint8_t cmd, const uint8_t* p, uint16_t len) {
switch(cmd) {
case FdCmdSubghzSetFreq: {
if(len < 4) { fd_send_err(app, FdErrInvalidParams, "need 4B freq"); return; }
uint32_t freq = ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16) |
((uint32_t)p[2] << 8) | p[3];
bool valid = (freq >= 300000000 && freq <= 348000000) ||
(freq >= 387000000 && freq <= 464000000) ||
(freq >= 779000000 && freq <= 928000000);
if(!valid) { fd_send_err(app, FdErrInvalidParams, "bad freq range"); return; }
app->subghz_freq = freq;
furi_hal_subghz_idle();
furi_hal_subghz_set_frequency(freq);
fd_send_ok(app, NULL, 0);
fd_log(app, "SubGHz freq=%lu", freq);
break;
}
case FdCmdSubghzTx: {
if(len < 1) { fd_send_err(app, FdErrInvalidParams, "need data"); return; }
if(app->subghz_freq == 0) { fd_send_err(app, FdErrInvalidParams, "set freq first"); return; }
furi_hal_subghz_idle();
furi_hal_subghz_set_frequency(app->subghz_freq);
furi_hal_subghz_write_packet(p, len);
furi_hal_subghz_tx();
furi_delay_ms(100);
furi_hal_subghz_idle();
fd_send_ok(app, NULL, 0);
fd_log(app, "SubGHz TX %u bytes", len);
break;
}
case FdCmdSubghzRxStart: {
if(app->subghz_freq == 0) { fd_send_err(app, FdErrInvalidParams, "set freq first"); return; }
furi_hal_subghz_idle();
furi_hal_subghz_set_frequency(app->subghz_freq);
furi_hal_subghz_rx();
app->subghz_rx_active = true;
fd_send_ok(app, NULL, 0);
fd_log(app, "SubGHz RX start");
break;
}
case FdCmdSubghzRxStop:
furi_hal_subghz_idle();
app->subghz_rx_active = false;
fd_send_ok(app, NULL, 0);
fd_log(app, "SubGHz RX stop");
break;
case FdCmdSubghzGetRssi: {
float rssi = furi_hal_subghz_get_rssi();
app->subghz_last_rssi = rssi;
int16_t ri = (int16_t)(rssi * 10);
uint8_t rb[2] = {(ri >> 8) & 0xFF, ri & 0xFF};
fd_send_ok(app, rb, 2);
break;
}
default:
fd_send_err(app, FdErrNotSupported, "subghz cmd n/a");
break;
}
}
static void fd_cmd_ir(FdApp* app, uint8_t cmd, const uint8_t* p, uint16_t len) {
switch(cmd) {
case FdCmdIrTx: {
if(len < 9) { fd_send_err(app, FdErrInvalidParams, "need 9B"); return; }
uint32_t addr = ((uint32_t)p[1] << 24) | ((uint32_t)p[2] << 16) |
((uint32_t)p[3] << 8) | p[4];
uint32_t command = ((uint32_t)p[5] << 24) | ((uint32_t)p[6] << 16) |
((uint32_t)p[7] << 8) | p[8];
InfraredMessage msg = {
.protocol = p[0],
.address = addr,
.command = command,
.repeat = false,
};
infrared_send(&msg, 1);
fd_send_ok(app, NULL, 0);
fd_log(app, "IR TX proto=%u", p[0]);
break;
}
case FdCmdIrRxStart:
app->ir_rx_active = true;
fd_send_ok(app, NULL, 0);
fd_log(app, "IR RX start");
break;
case FdCmdIrRxStop:
app->ir_rx_active = false;
fd_send_ok(app, NULL, 0);
fd_log(app, "IR RX stop");
break;
default:
fd_send_err(app, FdErrNotSupported, "ir cmd n/a");
break;
}
}
static void fd_cmd_file(FdApp* app, uint8_t cmd, const uint8_t* p, uint16_t len) {
Storage* storage = furi_record_open(RECORD_STORAGE);
switch(cmd) {
case FdCmdFileList: {
char path[256] = "/ext";
if(len > 0 && len < sizeof(path)) {
memcpy(path, p, len);
path[len] = '\0';
}
File* dir = storage_file_alloc(storage);
if(!storage_dir_open(dir, path)) {
fd_send_err(app, FdErrHardware, "open dir fail");
storage_file_free(dir);
break;
}
char name[128];
FileInfo info;
uint8_t resp[FD_MAX_PAYLOAD];
uint16_t pos = 0;
while(storage_dir_read(dir, &info, name, sizeof(name))) {
uint16_t nlen = strlen(name);
if(pos + nlen + 3 > sizeof(resp)) break;
resp[pos++] = (info.flags & FSF_DIRECTORY) ? 'd' : 'f';
resp[pos++] = ',';
memcpy(resp + pos, name, nlen);
pos += nlen;
resp[pos++] = '\n';
}
storage_dir_close(dir);
storage_file_free(dir);
fd_send_ok(app, resp, pos);
fd_log(app, "ls %s -> %u entries", path, pos);
break;
}
case FdCmdFileRead: {
if(len < 1) { fd_send_err(app, FdErrInvalidParams, "need path"); break; }
char path[256];
uint16_t pl = (len < 255) ? len : 255;
memcpy(path, p, pl);
path[pl] = '\0';
File* file = storage_file_alloc(storage);
if(!storage_file_open(file, path, FSAM_READ, FSOM_OPEN_EXISTING)) {
fd_send_err(app, FdErrHardware, "open fail");
storage_file_free(file);
break;
}
uint8_t buf[FD_MAX_PAYLOAD];
uint16_t rd = storage_file_read(file, buf, sizeof(buf));
storage_file_close(file);
storage_file_free(file);
fd_send_ok(app, buf, rd);
fd_log(app, "read %s %uB", path, rd);
break;
}
case FdCmdFileWrite: {
if(len < 3) { fd_send_err(app, FdErrInvalidParams, "need plen+path+data"); break; }
uint16_t plen_f = ((uint16_t)p[0] << 8) | p[1];
if(2 + plen_f > len) { fd_send_err(app, FdErrInvalidParams, "bad plen"); break; }
char path[256];
uint16_t cpy = (plen_f < 255) ? plen_f : 255;
memcpy(path, p + 2, cpy);
path[cpy] = '\0';
File* file = storage_file_alloc(storage);
if(!storage_file_open(file, path, FSAM_WRITE, FSOM_CREATE_ALWAYS)) {
fd_send_err(app, FdErrHardware, "create fail");
storage_file_free(file);
break;
}
uint16_t dlen = len - 2 - plen_f;
storage_file_write(file, p + 2 + plen_f, dlen);
storage_file_close(file);
storage_file_free(file);
fd_send_ok(app, NULL, 0);
fd_log(app, "write %s %uB", path, dlen);
break;
}
case FdCmdFileDelete: {
if(len < 1) { fd_send_err(app, FdErrInvalidParams, "need path"); break; }
char path[256];
uint16_t pl = (len < 255) ? len : 255;
memcpy(path, p, pl);
path[pl] = '\0';
if(storage_simply_remove(storage, path))
fd_send_ok(app, NULL, 0);
else
fd_send_err(app, FdErrHardware, "delete fail");
break;
}
default:
fd_send_err(app, FdErrUnknownCmd, "bad file cmd");
break;
}
furi_record_close(RECORD_STORAGE);
}
/* ---- Command dispatch ---- */
void fd_handle_cmd(FdApp* app, uint8_t cmd, const uint8_t* payload, uint16_t len) {
FURI_LOG_D(TAG, "CMD 0x%02X len=%u", cmd, len);
app->rx_count++;
if(cmd <= 0x04) fd_cmd_system(app, cmd, payload, len);
else if(cmd <= 0x15) fd_cmd_gpio(app, cmd, payload, len);
else if(cmd <= 0x26) fd_cmd_subghz(app, cmd, payload, len);
else if(cmd >= 0x50 && cmd <= 0x54) fd_cmd_ir(app, cmd, payload, len);
else if(cmd >= 0x90 && cmd <= 0x93) fd_cmd_file(app, cmd, payload, len);
else fd_send_err(app, FdErrUnknownCmd, "unknown");
}
/* ---- RX worker ---- */
int32_t fd_rx_worker(void* ctx) {
FdApp* app = (FdApp*)ctx;
FURI_LOG_I(TAG, "RX worker started");
while(app->running) {
/* Scan for magic bytes */
uint8_t magic[2];
if(fd_usb_rx(magic, 2, 100) < 2) continue;
if(magic[0] != FD_MAGIC_HI || magic[1] != FD_MAGIC_LO) continue;
/* Read frame length */
uint8_t lb[2];
if(fd_usb_rx(lb, 2, 500) < 2) continue;
uint16_t flen = ((uint16_t)lb[0] << 8) | lb[1];
if(flen > FD_MAX_PAYLOAD + 1) {
FURI_LOG_W(TAG, "Frame too big: %u", flen);
continue;
}
/* Read cmd + payload + crc */
uint8_t frame[FD_MAX_PAYLOAD + 2];
uint16_t got = fd_usb_rx(frame, flen + 1, 1000);
if(got < flen + 1) {
FURI_LOG_W(TAG, "Short frame: %u/%u", got, flen + 1);
continue;
}
/* Verify CRC */
uint8_t crc_in[2 + FD_MAX_PAYLOAD + 1];
crc_in[0] = lb[0];
crc_in[1] = lb[1];
memcpy(crc_in + 2, frame, flen);
uint8_t expected = fd_crc8(crc_in, 2 + flen);
if(expected != frame[flen]) {
FURI_LOG_W(TAG, "CRC fail: %02X vs %02X", frame[flen], expected);
continue;
}
/* Dispatch */
fd_handle_cmd(app, frame[0], frame + 1, flen - 1);
notification_message(app->notifications, &sequence_blink_cyan_10);
}
FURI_LOG_I(TAG, "RX worker stopped");
return 0;
}
/* ---- Event worker ---- */
int32_t fd_event_worker(void* ctx) {
FdApp* app = (FdApp*)ctx;
FURI_LOG_I(TAG, "Event worker started");
while(app->running) {
/* SubGHz RX check */
if(app->subghz_rx_active) {
if(furi_hal_subghz_is_rx_data_crc_valid()) {
uint8_t pkt[64];
furi_hal_subghz_read_packet(pkt, sizeof(pkt));
float rssi = furi_hal_subghz_get_rssi();
app->subghz_last_rssi = rssi;
/* Pack: data(64) + rssi_x10(2) + freq(4) */
uint8_t ev[70];
memcpy(ev, pkt, 64);
int16_t ri = (int16_t)(rssi * 10);
ev[64] = (ri >> 8) & 0xFF;
ev[65] = ri & 0xFF;
ev[66] = (app->subghz_freq >> 24) & 0xFF;
ev[67] = (app->subghz_freq >> 16) & 0xFF;
ev[68] = (app->subghz_freq >> 8) & 0xFF;
ev[69] = app->subghz_freq & 0xFF;
fd_send_event(app, FdEventSubghzRx, ev, 70);
notification_message(app->notifications, &sequence_blink_green_10);
fd_log(app, "SubGHz RX rssi=%.1f", (double)rssi);
}
}
furi_delay_ms(10);
}
FURI_LOG_I(TAG, "Event worker stopped");
return 0;
}

90
flipper/fd_protocol.h Normal file
View File

@@ -0,0 +1,90 @@
/**
* FlipperDroid Bridge Protocol v0.1
* Shared definitions between all source files.
*/
#pragma once
#include <stdint.h>
#include <stdbool.h>
/* Wire format: [MAGIC_HI][MAGIC_LO][LEN_HI][LEN_LO][CMD][PAYLOAD...][CRC8] */
#define FD_MAGIC_HI 0xFD
#define FD_MAGIC_LO 0x01
#define FD_MAX_PAYLOAD 2048
#define FD_VERSION "0.1.0"
#define FD_DEVICE_NAME "FlipperDroid"
/* ---- Commands (Phone -> Flipper) ---- */
typedef enum {
FdCmdPing = 0x01,
FdCmdVersion = 0x02,
FdCmdCapabilities = 0x03,
FdCmdStatus = 0x04,
FdCmdGpioInit = 0x10,
FdCmdGpioWrite = 0x11,
FdCmdGpioRead = 0x12,
FdCmdGpioPwm = 0x13,
FdCmdGpioAdcRead = 0x14,
FdCmdSubghzSetFreq = 0x20,
FdCmdSubghzTx = 0x21,
FdCmdSubghzRxStart = 0x22,
FdCmdSubghzRxStop = 0x23,
FdCmdSubghzGetRssi = 0x24,
FdCmdIrTx = 0x50,
FdCmdIrTxRaw = 0x51,
FdCmdIrRxStart = 0x52,
FdCmdIrRxStop = 0x53,
FdCmdFileList = 0x90,
FdCmdFileRead = 0x91,
FdCmdFileWrite = 0x92,
FdCmdFileDelete = 0x93,
} FdCommand;
/* ---- Events (Flipper -> Phone, async push) ---- */
typedef enum {
FdEventGpioIrq = 0xA0,
FdEventSubghzRx = 0xA1,
FdEventIrRx = 0xA2,
FdEventButton = 0xA4,
FdEventCpuReq = 0xA5,
} FdEvent;
/* Response / Error */
#define FD_RESP_OK 0xFE
#define FD_RESP_ERR 0xFF
typedef enum {
FdErrUnknownCmd = 0x01,
FdErrInvalidParams = 0x02,
FdErrDisabled = 0x03,
FdErrHardware = 0x04,
FdErrBusy = 0x05,
FdErrTimeout = 0x06,
FdErrNotSupported = 0x07,
} FdError;
/* Capability bitmask reported by 0x03 */
#define FD_CAP_GPIO (1 << 0)
#define FD_CAP_SUBGHZ (1 << 1)
#define FD_CAP_RFID (1 << 2)
#define FD_CAP_NFC (1 << 3)
#define FD_CAP_IR (1 << 4)
#define FD_CAP_IBUTTON (1 << 5)
#define FD_CAP_BADUSB (1 << 6)
#define FD_CAP_CPU (1 << 7)
/* CRC8 Dallas/Maxim 0x31 */
static inline uint8_t fd_crc8(const uint8_t* data, uint16_t len) {
uint8_t crc = 0;
for(uint16_t i = 0; i < len; i++) {
crc ^= data[i];
for(uint8_t j = 0; j < 8; j++)
crc = (crc & 0x80) ? ((crc << 1) ^ 0x31) : (crc << 1);
}
return crc;
}

351
flipper/flipperdroid_app.c Normal file
View File

@@ -0,0 +1,351 @@
/**
* FlipperDroid — main application entry point
* Handles GUI, input, lifecycle. Workers in fd_bridge.c.
*/
#include "fd_app.h"
#include <stdio.h>
#include <stdarg.h>
#define TAG "FlipperDroid"
/* ---- GPIO pin table ---- */
static const struct {
uint8_t id;
const GpioPin* pin;
const char* name;
} gpio_defs[] = {
{2, &gpio_ext_pa2, "A2"},
{3, &gpio_ext_pa3, "A3"},
{4, &gpio_ext_pa4, "A4"},
{6, &gpio_ext_pa6, "A6"},
{7, &gpio_ext_pa7, "A7"},
{13, &gpio_ext_pb2, "B2"},
{14, &gpio_ext_pb3, "B3"},
{15, &gpio_ext_pb13, "B13"},
{16, &gpio_ext_pb14, "B14"},
{17, &gpio_ext_pc0, "C0"},
{18, &gpio_ext_pc1, "C1"},
{19, &gpio_ext_pc3, "C3"},
};
#define GPIO_DEF_COUNT (sizeof(gpio_defs) / sizeof(gpio_defs[0]))
/* ---- Logging ---- */
void fd_log(FdApp* app, const char* fmt, ...) {
va_list args;
va_start(args, fmt);
char buf[FD_LOG_COLS + 1];
vsnprintf(buf, sizeof(buf), fmt, args);
va_end(args);
furi_mutex_acquire(app->mutex, FuriWaitForever);
uint8_t idx = (app->log.head + app->log.count) % FD_LOG_LINES;
if(app->log.count < FD_LOG_LINES) {
app->log.count++;
} else {
app->log.head = (app->log.head + 1) % FD_LOG_LINES;
}
strncpy(app->log.lines[idx], buf, FD_LOG_COLS);
app->log.lines[idx][FD_LOG_COLS] = '\0';
furi_mutex_release(app->mutex);
FURI_LOG_I(TAG, "%s", buf);
}
/* ---- Draw ---- */
void fd_draw_callback(Canvas* canvas, void* ctx) {
FdApp* app = (FdApp*)ctx;
furi_mutex_acquire(app->mutex, FuriWaitForever);
canvas_clear(canvas);
canvas_set_font(canvas, FontPrimary);
switch(app->current_view) {
case FdViewStatus: {
/* Title bar */
canvas_draw_str(canvas, 0, 10, "FlipperDroid");
canvas_set_font(canvas, FontSecondary);
/* Connection status */
if(app->connected) {
canvas_draw_str(canvas, 72, 10, "[LINKED]");
} else {
canvas_draw_str(canvas, 68, 10, "[WAITING]");
}
/* Stats */
char buf[40];
canvas_draw_str(canvas, 0, 22, "Status:");
canvas_draw_str(canvas, 44, 22, app->connected ? "Connected" : "Listening on USB");
snprintf(buf, sizeof(buf), "RX: %lu TX: %lu ERR: %lu",
app->rx_count, app->tx_count, app->err_count);
canvas_draw_str(canvas, 0, 32, buf);
/* Subsystem status */
snprintf(buf, sizeof(buf), "SubGHz: %s %lu Hz",
app->subghz_rx_active ? "RX" : "idle",
app->subghz_freq);
canvas_draw_str(canvas, 0, 42, buf);
snprintf(buf, sizeof(buf), "IR: %s GPIO: %u pins",
app->ir_rx_active ? "RX" : "idle", app->gpio_count);
canvas_draw_str(canvas, 0, 52, buf);
/* Nav hint */
canvas_draw_str(canvas, 0, 63, "<GPIO SubGHz> OK:Log Bk:Exit");
break;
}
case FdViewGpio: {
canvas_draw_str(canvas, 0, 10, "GPIO Pins");
canvas_set_font(canvas, FontSecondary);
for(uint8_t i = 0; i < app->gpio_count && i < 12; i++) {
uint8_t row = i / 4;
uint8_t col = i % 4;
uint8_t x = col * 32;
uint8_t y = 20 + row * 14;
char buf[12];
FdGpioEntry* g = &app->gpio[i];
if(i == app->selected_gpio) {
canvas_draw_box(canvas, x, y - 8, 30, 12);
canvas_set_color(canvas, ColorWhite);
}
if(g->initialized) {
snprintf(buf, sizeof(buf), "%s:%c", g->name, g->value ? 'H' : 'L');
} else {
snprintf(buf, sizeof(buf), "%s:--", g->name);
}
canvas_draw_str(canvas, x + 1, y, buf);
canvas_set_color(canvas, ColorBlack);
}
canvas_draw_str(canvas, 0, 63, "<Stat ^v:Sel OK:Read >SG");
break;
}
case FdViewSubghz: {
canvas_draw_str(canvas, 0, 10, "Sub-GHz");
canvas_set_font(canvas, FontSecondary);
char buf[40];
snprintf(buf, sizeof(buf), "Freq: %lu Hz", app->subghz_freq);
canvas_draw_str(canvas, 0, 22, buf);
snprintf(buf, sizeof(buf), "RSSI: %.1f dBm", (double)app->subghz_last_rssi);
canvas_draw_str(canvas, 0, 32, buf);
canvas_draw_str(canvas, 0, 42, app->subghz_rx_active ? "State: RECEIVING" : "State: IDLE");
snprintf(buf, sizeof(buf), "Packets: %lu", app->rx_count);
canvas_draw_str(canvas, 0, 52, buf);
canvas_draw_str(canvas, 0, 63, "<GPIO OK:RX Tog >Log");
break;
}
case FdViewLog: {
canvas_draw_str(canvas, 0, 10, "Log");
canvas_set_font(canvas, FontKeyboard);
for(uint8_t i = 0; i < app->log.count && i < 6; i++) {
uint8_t idx = (app->log.head + i) % FD_LOG_LINES;
canvas_draw_str(canvas, 0, 20 + i * 8, app->log.lines[idx]);
}
canvas_set_font(canvas, FontSecondary);
canvas_draw_str(canvas, 0, 63, "<SubGHz Bk:Exit");
break;
}
}
furi_mutex_release(app->mutex);
}
/* ---- Input ---- */
void fd_input_callback(InputEvent* event, void* ctx) {
FdApp* app = (FdApp*)ctx;
furi_message_queue_put(app->input_queue, event, FuriWaitForever);
}
/* ---- Alloc / Free ---- */
FdApp* fd_app_alloc(void) {
FdApp* app = malloc(sizeof(FdApp));
memset(app, 0, sizeof(FdApp));
app->mutex = furi_mutex_alloc(FuriMutexTypeRecursive);
app->input_queue = furi_message_queue_alloc(8, sizeof(InputEvent));
app->gui = furi_record_open(RECORD_GUI);
app->notifications = furi_record_open(RECORD_NOTIFICATION);
app->current_view = FdViewStatus;
app->running = true;
/* Init GPIO table */
app->gpio_count = GPIO_DEF_COUNT;
for(uint8_t i = 0; i < GPIO_DEF_COUNT; i++) {
app->gpio[i].id = gpio_defs[i].id;
app->gpio[i].pin = gpio_defs[i].pin;
app->gpio[i].name = gpio_defs[i].name;
app->gpio[i].initialized = false;
app->gpio[i].output = false;
app->gpio[i].value = false;
}
/* Create viewport */
app->view_port = view_port_alloc();
view_port_draw_callback_set(app->view_port, fd_draw_callback, app);
view_port_input_callback_set(app->view_port, fd_input_callback, app);
gui_add_view_port(app->gui, app->view_port, GuiLayerFullscreen);
return app;
}
void fd_app_free(FdApp* app) {
/* Stop workers */
app->running = false;
if(app->rx_thread) {
furi_thread_join(app->rx_thread);
furi_thread_free(app->rx_thread);
}
if(app->event_thread) {
furi_thread_join(app->event_thread);
furi_thread_free(app->event_thread);
}
/* Cleanup SubGHz */
if(app->subghz_rx_active) {
furi_hal_subghz_idle();
app->subghz_rx_active = false;
}
/* Reset GPIO pins to analog (safe default) */
for(uint8_t i = 0; i < app->gpio_count; i++) {
if(app->gpio[i].initialized) {
furi_hal_gpio_init(app->gpio[i].pin, GpioModeAnalog, GpioPullNo, GpioSpeedLow);
}
}
/* Remove GUI */
view_port_enabled_set(app->view_port, false);
gui_remove_view_port(app->gui, app->view_port);
view_port_free(app->view_port);
furi_message_queue_free(app->input_queue);
furi_mutex_free(app->mutex);
furi_record_close(RECORD_NOTIFICATION);
furi_record_close(RECORD_GUI);
free(app);
}
/* ---- Main loop ---- */
int32_t fd_app_run(FdApp* app) {
fd_log(app, "Bridge starting...");
/* Launch RX worker — listens for commands on USB CDC */
app->rx_thread = furi_thread_alloc_ex("FdRx", 4096, fd_rx_worker, app);
furi_thread_start(app->rx_thread);
/* Launch event worker — pushes async events to phone */
app->event_thread = furi_thread_alloc_ex("FdEvt", 2048, fd_event_worker, app);
furi_thread_start(app->event_thread);
fd_log(app, "Listening on USB CDC...");
notification_message(app->notifications, &sequence_blink_start_blue);
/* Input loop */
InputEvent event;
while(app->running) {
if(furi_message_queue_get(app->input_queue, &event, 100) == FuriStatusOk) {
if(event.type != InputTypePress && event.type != InputTypeRepeat) continue;
switch(event.key) {
case InputKeyBack:
app->running = false;
break;
case InputKeyRight:
if(app->current_view == FdViewStatus) app->current_view = FdViewSubghz;
else if(app->current_view == FdViewGpio) app->current_view = FdViewSubghz;
else if(app->current_view == FdViewSubghz) app->current_view = FdViewLog;
break;
case InputKeyLeft:
if(app->current_view == FdViewLog) app->current_view = FdViewSubghz;
else if(app->current_view == FdViewSubghz) app->current_view = FdViewGpio;
else if(app->current_view == FdViewGpio) app->current_view = FdViewStatus;
break;
case InputKeyOk:
if(app->current_view == FdViewStatus) {
app->current_view = FdViewLog;
} else if(app->current_view == FdViewGpio) {
/* Read selected GPIO pin */
uint8_t sel = app->selected_gpio;
if(sel < app->gpio_count) {
if(!app->gpio[sel].initialized) {
furi_hal_gpio_init(
app->gpio[sel].pin, GpioModeInput, GpioPullNo, GpioSpeedLow);
app->gpio[sel].initialized = true;
}
app->gpio[sel].value = furi_hal_gpio_read(app->gpio[sel].pin);
fd_log(app, "GPIO %s = %s",
app->gpio[sel].name,
app->gpio[sel].value ? "HIGH" : "LOW");
}
} else if(app->current_view == FdViewSubghz) {
/* Toggle SubGHz RX */
if(app->subghz_rx_active) {
furi_hal_subghz_idle();
app->subghz_rx_active = false;
fd_log(app, "SubGHz RX stopped");
} else if(app->subghz_freq > 0) {
furi_hal_subghz_idle();
furi_hal_subghz_set_frequency(app->subghz_freq);
furi_hal_subghz_rx();
app->subghz_rx_active = true;
fd_log(app, "SubGHz RX on %lu Hz", app->subghz_freq);
}
}
break;
case InputKeyUp:
if(app->current_view == FdViewGpio && app->selected_gpio > 0)
app->selected_gpio--;
break;
case InputKeyDown:
if(app->current_view == FdViewGpio && app->selected_gpio < app->gpio_count - 1)
app->selected_gpio++;
break;
default:
break;
}
view_port_update(app->view_port);
}
/* Periodic screen refresh */
view_port_update(app->view_port);
}
notification_message(app->notifications, &sequence_blink_stop);
fd_log(app, "Bridge stopped");
return 0;
}
/* ---- Entry point ---- */
int32_t flipperdroid_app(void* p) {
UNUSED(p);
FdApp* app = fd_app_alloc();
fd_app_run(app);
fd_app_free(app);
return 0;
}

6
module.prop Normal file
View File

@@ -0,0 +1,6 @@
id=flipperdroid
name=FlipperDroid
version=v0.1.0-poc
versionCode=1
author=snake
description=Flipper Zero + Android fusion — shared computing bridge over USB/BT. Exposes Flipper GPIO, SubGHz, RFID, NFC, IR as Android resources and shares phone CPU back to Flipper. Pentesting powerhouse.

53
post-fs-data.sh Normal file
View File

@@ -0,0 +1,53 @@
#!/system/bin/sh
# FlipperDroid — early boot setup
# Sets up USB gadget and config directory
MODDIR=${0%/*}
CONFIG_DIR="/data/adb/flipperdroid"
CONFIG_FILE="$CONFIG_DIR/config.sh"
mkdir -p "$CONFIG_DIR/logs"
mkdir -p "$CONFIG_DIR/scripts"
if [ ! -f "$CONFIG_FILE" ]; then
cat > "$CONFIG_FILE" << 'DEFAULTS'
# FlipperDroid configuration — persisted across reboots
# Connection mode: usb | bluetooth | auto
CONN_MODE=auto
# WebUI port
WEBUI_PORT=8089
# Auto-connect on boot
AUTO_CONNECT=1
# Bridge protocol baud rate (USB CDC ignores this, BT serial uses it)
BAUD_RATE=115200
# Enable Flipper subsystems to expose
ENABLE_GPIO=1
ENABLE_SUBGHZ=1
ENABLE_RFID=1
ENABLE_NFC=1
ENABLE_IR=1
ENABLE_IBUTTON=1
ENABLE_BADUSB=0
# CPU sharing — allow Flipper to offload compute to phone
CPU_SHARE=1
CPU_SHARE_THREADS=2
# Logging level: 0=off, 1=errors, 2=info, 3=debug
LOG_LEVEL=2
DEFAULTS
fi
source "$CONFIG_FILE"
# Ensure USB ACM gadget is available for Flipper CDC
if [ -d /config/usb_gadget ]; then
# Don't reconfigure, just ensure the CDC ACM function exists
# The kernel handles Flipper Zero as /dev/ttyACM* automatically
echo "FlipperDroid: USB gadget ready" > /dev/kmsg 2>/dev/null
fi

169
research.md Normal file
View File

@@ -0,0 +1,169 @@
# FlipperDroid Research Notes
## Concept
Fuse a Flipper Zero and Android phone into a single pentesting platform via shared computing.
Not kernel-level fusion — daemon-based approach on both sides, communicating over USB CDC serial
or Bluetooth rfcomm.
## Flipper Zero Hardware APIs (from doxygen research)
### FuriHalBus
- Manages peripheral device init on STM32WB55
- Three tiers: always-on (DMA, GPIO ports, FLASH), on-demand system (RNG, SPI, I2C, USB, USART), on-demand user (ADC, CRC, timers, SAI)
- `furi_hal_bus_enable()` / `furi_hal_bus_disable()` / `furi_hal_bus_reset()`
- Crash if you enable an already-enabled peripheral or disable an already-disabled one
### GPIO
- 12 usable external pins: PA2, PA3, PA4, PA6, PA7, PB2, PB3, PB13, PB14, PC0, PC1, PC3
- `furi_hal_gpio_init(pin, mode, pull, speed)`
- `furi_hal_gpio_write(pin, state)`
- `furi_hal_gpio_read(pin)` -> bool
- Modes: Input, OutputPushPull, OutputOpenDrain, Analog
- All exposed via bridge protocol
### USB CDC
- Flipper Zero uses STM32 USB as CDC (Virtual COM Port)
- VID:PID = 0483:5740
- Firmware provides `furi_hal_cdc_send()` and `furi_hal_cdc_receive()`
- Channel 0 is normally CLI, can be repurposed for bridge data
- This is the primary transport — faster and more reliable than BT
### SubGHz (CC1101)
- Frequency range: 300-348, 387-464, 779-928 MHz
- `furi_hal_subghz_set_frequency()`, `furi_hal_subghz_tx()`, `furi_hal_subghz_rx()`
- `furi_hal_subghz_write_packet()`, `furi_hal_subghz_read_packet()`
- `furi_hal_subghz_get_rssi()` -> float
- `furi_hal_subghz_is_rx_data_crc_valid()` to check for pending data
### NFC (ST25R3916)
- 13.56 MHz NFC-A/B/V/F support
- Complex worker-based API (NfcWorker state machine)
- Relay mode would be killer — relay card to phone, phone relays over network
- Requires deep firmware integration, marked for v0.2
### RFID (125 kHz)
- LF RFID via built-in analog frontend
- Worker-based API similar to NFC
- Supports EM4100, HIDProx, Indala, etc.
### IR
- IR LED TX and IR receiver
- `infrared_send()` for protocol-based TX
- Supports NEC, Samsung, RC5, RC6, SIRC, Kaseikyo
- Raw timing TX/RX also available
### Furi OS
- Custom RTOS (FreeRTOS-based)
- `FuriThread` — threads with configurable stack
- `FuriMutex` — standard mutex
- `FuriMessageQueue` — message passing
- `furi_delay_ms()` — thread-safe delay
- `furi_get_tick()` — system tick counter
### FAP (Flipper Application Package)
- External apps stored on SD card under `/ext/apps/`
- Built using `./fbt fap_<appname>` from firmware source
- `application.fam` manifest defines entry point, dependencies, resources
- Can access most firmware APIs: GPIO, SubGHz, NFC, RFID, IR, Storage, GUI
- Stack size configurable (default 2048, we use 4096)
- External apps run in isolated address space with API table binding
## Android Side
### USB Discovery
- Flipper Zero appears as USB CDC ACM device
- VID:PID 0483:5740
- Shows up as `/dev/ttyACM*`
- Android needs OTG support and appropriate permissions
- SELinux rules needed for tty_device access
### Bluetooth Fallback
- Flipper Zero supports BLE (Bluetooth Low Energy)
- Serial Profile for data transfer
- Much slower than USB but works wirelessly
- rfcomm bind creates `/dev/rfcomm*` device
### Bridge Protocol
- Binary framed: MAGIC(2) + LEN(2) + CMD(1) + PAYLOAD(N) + CRC8(1)
- CRC8 using Dallas/Maxim polynomial 0x31
- Commands 0x01-0x93 (phone -> flipper)
- Events 0xA0-0xA5 (flipper -> phone, async push)
- Responses 0xFE (OK) / 0xFF (ERR)
### CPU Sharing
- Flipper (ARM Cortex-M4 @ 64MHz) can offload to phone (ARM Cortex-X4/A720/A520)
- Flipper sends workload via event 0xA5
- Phone executes and returns result via command 0x81
- Use cases: crypto operations, data processing, pattern matching
## Stealth — Bind Mount Namespace Isolation
### The Problem
Replacing files on Android is detectable. dm-verity checks block-level hashes, Play Integrity
checks signatures, banking apps scan for modifications. Any file replacement fails verification.
### The Solution
Don't replace anything. Use bind mounts in isolated mount namespaces.
Every process on Android has its own "view" of the filesystem. We make two processes look at
the same path and see different files:
- Banking app reads a path → sees STOCK file (original hash, original signature)
- Our daemon reads the same path → sees CUSTOM file via bind mount in its namespace
### How It Works
1. Stock files stay at their real paths, completely untouched. dm-verity happy.
2. Custom binaries go in `/data/adb/modules/flipperdroid/stealth/`
3. We clone ALL metadata from stock onto custom — SELinux context, ownership, permissions,
timestamps. Even `ls -Z` looks identical.
4. Using `nsenter`, we enter the mount namespace of the specific process that needs our
file and do a bind mount there. Only that process sees the swap.
5. Every other process on the system sees the untouched stock file.
### Configuration
`stealth_map.conf` format:
```
# stock_path|custom_filename|target_process|spoof_type
# spoof_type: process (per-process), global (init ns), hidden (mount empty over path)
```
### FlipperDroid-Specific Stealth
- WebUI port (8089) firewalled to localhost via iptables — not visible to port scanners
- Config directory `/data/adb/flipperdroid` set to 700 + chattr hidden
- ttyACM device can be hidden from non-bridge processes via stealth map
- Nothing runs until user login — no early boot traces for DroidGuard to see
- All stealth fully reversible: `fd-stealth teardown` removes everything cleanly
### What This Means for Detection
- dm-verity: PASS (no partition modifications)
- Play Integrity: PASS (no modified system files)
- Banking apps: PASS (they see stock everything)
- SafetyNet: PASS (green boot state, locked bootloader appearance)
## Key Decisions
1. **Daemon-based, not kernel-level** — Much simpler PoC, avoids custom kernel builds on both sides
2. **USB CDC primary, BT fallback** — USB is orders of magnitude faster
3. **Binary protocol** — More efficient than text/JSON for embedded comms
4. **FAP not custom firmware** — Can run on stock Flipper firmware, easier distribution
5. **Shell-based Android daemons** — Matches existing module pattern (RadioControl), works everywhere
6. **WebUI for control** — Browser-based, no separate app needed
7. **Namespace isolation stealth** — Bind mounts in per-process namespaces, stock files untouched
## Future Directions
- Custom Flipper firmware with optimized bridge (bypass CLI, direct USB bulk)
- Android kernel driver (`/dev/flipperdroid`) for zero-copy USB transfers
- NFC relay over network — relay card from Flipper to remote reader via phone's internet
- SubGHz signal database — capture, store, replay library
- Mesh networking — multiple Flipper+Phone pairs working together
- Integration with Autarch framework for automated pentesting workflows
- GPIO expansion — use Flipper as I2C/SPI bridge for external hardware
## References
- Flipper Zero Doxygen: https://developer.flipper.net/flipperzero/doxygen/
- FuriHalBus API: https://developer.flipper.net/flipperzero/doxygen/furi_hal_bus.html
- Firmware source: https://github.com/flipperdevices/flipperzero-firmware
- FAP development: https://github.com/flipperdevices/flipperzero-firmware/blob/dev/documentation/AppsOnSDCard.md
- Awesome Flipper Zero: https://github.com/djsime1/awesome-flipperzero

View File

@@ -0,0 +1,37 @@
# FlipperDroid SELinux rules
# For enforcing mode — KernelSU can set permissive globally
# USB serial device access (ttyACM for Flipper Zero CDC)
allow su tty_device chr_file { open read write ioctl getattr }
allow su serial_device chr_file { open read write ioctl getattr }
# Bluetooth rfcomm
allow su bluetooth_device chr_file { open read write ioctl getattr }
allow su rfcomm_device chr_file { open read write ioctl getattr }
# USB sysfs enumeration
allow su sysfs_usb dir { search read open getattr }
allow su sysfs_usb file { read open getattr }
# Network socket for WebUI
allow su self tcp_socket { create bind listen accept read write getattr setopt }
# proc/sys for CPU sharing
allow su proc file { read open getattr }
allow su sysfs dir { search read open getattr }
allow su sysfs file { read write open getattr }
# Stealth — namespace isolation bind mounts
allow su proc dir { search read open getattr mounton }
allow su proc file { read open getattr mounton }
allow su tmpfs dir { search read open getattr mounton }
allow su tmpfs file { read write open getattr mounton }
allow su self capability { sys_admin sys_ptrace }
# nsenter into other process mount namespaces
allow su domain dir { search getattr }
allow su domain file { read open getattr }
# iptables for port hiding
allow su self rawip_socket { create bind read write getattr setopt }
allow su self netlink_netfilter_socket { create bind read write }

188
service.sh Normal file
View File

@@ -0,0 +1,188 @@
#!/system/bin/sh
# FlipperDroid — late service script
# Discovers Flipper Zero, starts bridge daemon and WebUI
MODDIR=${0%/*}
CONFIG_DIR="/data/adb/flipperdroid"
CONFIG_FILE="$CONFIG_DIR/config.sh"
LOG_FILE="$CONFIG_DIR/logs/flipperdroid.log"
PID_FILE="$CONFIG_DIR/daemon.pid"
BRIDGE_PID_FILE="$CONFIG_DIR/bridge.pid"
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$LOG_FILE"
}
mkdir -p "$CONFIG_DIR/logs"
source "$CONFIG_FILE" 2>/dev/null
log "FlipperDroid service starting"
#############################
# Discover Flipper Zero
#############################
discover_flipper_usb() {
local flipper_dev=""
# Flipper Zero USB VID:PID = 0483:5740
for dev in /sys/bus/usb/devices/*; do
[ -f "$dev/idVendor" ] || continue
local vid=$(cat "$dev/idVendor" 2>/dev/null)
local pid=$(cat "$dev/idProduct" 2>/dev/null)
if [ "$vid" = "0483" ] && [ "$pid" = "5740" ]; then
local serial=$(cat "$dev/serial" 2>/dev/null)
local product=$(cat "$dev/product" 2>/dev/null)
log "Found Flipper Zero: $product (serial: $serial)"
echo "$product" > "$CONFIG_DIR/flipper_product"
echo "$serial" > "$CONFIG_DIR/flipper_serial"
# Find the associated ttyACM device
for tty in /dev/ttyACM*; do
if [ -c "$tty" ]; then
# Verify this tty belongs to the Flipper
local tty_num=$(echo "$tty" | grep -o '[0-9]*$')
local tty_dev_path=$(readlink -f "/sys/class/tty/ttyACM${tty_num}/device" 2>/dev/null)
if echo "$tty_dev_path" | grep -q "$vid"; then
flipper_dev="$tty"
break
fi
fi
done
# Fallback: just use first ttyACM if verification failed
if [ -z "$flipper_dev" ]; then
for tty in /dev/ttyACM*; do
[ -c "$tty" ] && flipper_dev="$tty" && break
done
fi
break
fi
done
echo "$flipper_dev"
}
discover_flipper_bt() {
# Look for paired Flipper via BT serial
# Flipper Zero advertises as "Flipper <name>"
local bt_dev=""
# Check rfcomm devices
for dev in /dev/rfcomm*; do
[ -c "$dev" ] && bt_dev="$dev" && break
done
# If no rfcomm, try to find via bluetoothctl paired devices
if [ -z "$bt_dev" ]; then
local flipper_mac=$(bluetoothctl paired-devices 2>/dev/null | grep -i "flipper" | awk '{print $2}')
if [ -n "$flipper_mac" ]; then
log "Found paired Flipper at $flipper_mac, attempting rfcomm bind"
rfcomm bind 0 "$flipper_mac" 1 2>/dev/null
sleep 1
[ -c /dev/rfcomm0 ] && bt_dev="/dev/rfcomm0"
fi
fi
echo "$bt_dev"
}
find_flipper() {
local conn_mode="${CONN_MODE:-auto}"
local device=""
case "$conn_mode" in
usb)
device=$(discover_flipper_usb)
;;
bluetooth)
device=$(discover_flipper_bt)
;;
auto)
# Try USB first (faster, more reliable), fall back to BT
device=$(discover_flipper_usb)
if [ -z "$device" ]; then
log "No USB Flipper found, trying Bluetooth..."
device=$(discover_flipper_bt)
[ -n "$device" ] && echo "bluetooth" > "$CONFIG_DIR/conn_type" || echo "none" > "$CONFIG_DIR/conn_type"
else
echo "usb" > "$CONFIG_DIR/conn_type"
fi
;;
esac
echo "$device"
}
#############################
# Wait for Flipper connection
#############################
FLIPPER_DEV=""
RETRY_COUNT=0
MAX_RETRIES=30
while [ -z "$FLIPPER_DEV" ] && [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
FLIPPER_DEV=$(find_flipper)
if [ -z "$FLIPPER_DEV" ]; then
RETRY_COUNT=$((RETRY_COUNT + 1))
if [ $RETRY_COUNT -eq 1 ]; then
log "Waiting for Flipper Zero connection..."
fi
sleep 2
fi
done
if [ -z "$FLIPPER_DEV" ]; then
log "No Flipper Zero detected after ${MAX_RETRIES} attempts. Running in standby mode."
echo "disconnected" > "$CONFIG_DIR/status"
echo "" > "$CONFIG_DIR/flipper_dev"
else
log "Flipper Zero connected on $FLIPPER_DEV"
echo "connected" > "$CONFIG_DIR/status"
echo "$FLIPPER_DEV" > "$CONFIG_DIR/flipper_dev"
# Set serial parameters
stty -F "$FLIPPER_DEV" ${BAUD_RATE:-115200} raw -echo -echoe -echok 2>/dev/null
fi
#############################
# Start bridge daemon
#############################
if [ -f "$BRIDGE_PID_FILE" ]; then
kill $(cat "$BRIDGE_PID_FILE") 2>/dev/null
rm -f "$BRIDGE_PID_FILE"
fi
log "Starting FlipperDroid bridge daemon"
nohup /system/bin/flipperdroidd >> "$LOG_FILE" 2>&1 &
echo $! > "$BRIDGE_PID_FILE"
#############################
# Start WebUI
#############################
if [ -f "$PID_FILE" ]; then
kill $(cat "$PID_FILE") 2>/dev/null
rm -f "$PID_FILE"
fi
log "Starting WebUI on port ${WEBUI_PORT:-8089}"
nohup /system/bin/flipperdroid-webui >> "$LOG_FILE" 2>&1 &
echo $! > "$PID_FILE"
#############################
# Apply stealth layer
#############################
if [ -f "$CONFIG_DIR/stealth_map.conf" ]; then
log "Applying stealth namespace isolation"
/system/bin/fd-stealth apply >> "$LOG_FILE" 2>&1
else
# Still apply basic FlipperDroid hiding (port firewall, config perms)
/system/bin/fd-stealth hide-dev >> "$LOG_FILE" 2>&1
fi
log "FlipperDroid service started (bridge PID: $(cat $BRIDGE_PID_FILE), webui PID: $(cat $PID_FILE))"

View File

@@ -0,0 +1,21 @@
# FlipperDroid Stealth Map
# Format: stock_path|custom_filename|target_process|spoof_type
#
# spoof_type:
# process = bind mount into specific process namespace only
# global = bind mount in init namespace (all processes see it)
# hidden = mount empty file over path in target process (hide it)
#
# Drop custom binaries in /data/adb/modules/flipperdroid/stealth/
# Uncomment lines below and run: fd-stealth apply
#
# Examples:
#
# Hide our ttyACM device from Play Services (so it can't enumerate USB devices)
# /dev/ttyACM0|empty|com.google.android.gms|hidden
#
# Hide our ttyACM device from any security scanner
# /dev/ttyACM0|empty|com.google.android.gms.unstable|hidden
#
# If you need to spoof a system binary into a specific process:
# /system/bin/some_stock_tool|our_custom_tool|target_daemon|process

376
system/bin/fd-stealth Normal file
View File

@@ -0,0 +1,376 @@
#!/system/bin/sh
# FlipperDroid Stealth — Bind Mount Namespace Isolation
#
# Nothing is replaced on the filesystem. Stock files stay untouched.
# Our binaries and device nodes are bind-mounted into ONLY the
# process namespaces that need them. Every other process on the
# system sees a completely stock device.
#
# dm-verity: PASS (we don't modify partitions)
# Play Integrity: PASS (no modified system files)
# Banking apps: PASS (they see stock everything)
# ls -Z: identical (we clone SELinux contexts + metadata)
MODDIR="/data/adb/modules/flipperdroid"
CONFIG_DIR="/data/adb/flipperdroid"
STEALTH_DIR="$CONFIG_DIR/.stealth"
LOG_FILE="$CONFIG_DIR/logs/stealth.log"
SPOOF_MAP="$CONFIG_DIR/stealth_map.conf"
mkdir -p "$STEALTH_DIR/mounts" "$STEALTH_DIR/originals"
slog() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] [stealth] $1" >> "$LOG_FILE"
}
#############################
# Metadata Cloning
# Clone SELinux context, ownership,
# permissions, timestamps from stock
# target onto our custom file so even
# ls -Z looks identical
#############################
clone_metadata() {
local stock_path="$1"
local custom_path="$2"
[ ! -f "$stock_path" ] && slog "WARN: stock path not found: $stock_path" && return 1
[ ! -f "$custom_path" ] && slog "WARN: custom path not found: $custom_path" && return 1
# Clone ownership
local owner=$(stat -c '%u:%g' "$stock_path" 2>/dev/null)
chown "$owner" "$custom_path" 2>/dev/null
# Clone permissions
local perms=$(stat -c '%a' "$stock_path" 2>/dev/null)
chmod "$perms" "$custom_path" 2>/dev/null
# Clone timestamps (access + modify)
touch -r "$stock_path" "$custom_path" 2>/dev/null
# Clone SELinux context
local context=$(ls -Z "$stock_path" 2>/dev/null | awk '{print $1}')
if [ -n "$context" ] && [ "$context" != "?" ]; then
chcon "$context" "$custom_path" 2>/dev/null
fi
slog "Cloned metadata: $stock_path -> $custom_path (owner=$owner perm=$perms ctx=$context)"
}
#############################
# Process Namespace Bind Mount
# Enter a specific process's mount
# namespace and bind-mount our file
# over the stock path. ONLY that
# process sees the swap.
#############################
# Find PID for a process by name
find_pid() {
local proc_name="$1"
local pid=""
# Try pidof first
pid=$(pidof "$proc_name" 2>/dev/null)
[ -n "$pid" ] && echo "$pid" && return 0
# Fallback: scan /proc
for p in /proc/[0-9]*; do
local cmdline=$(cat "$p/cmdline" 2>/dev/null | tr '\0' ' ')
local comm=$(cat "$p/comm" 2>/dev/null)
if echo "$cmdline" | grep -q "$proc_name" || [ "$comm" = "$proc_name" ]; then
pid=$(basename "$p")
echo "$pid"
return 0
fi
done
return 1
}
# Bind mount into a specific process's namespace
ns_bind_mount() {
local target_pid="$1"
local stock_path="$2"
local custom_path="$3"
[ ! -d "/proc/$target_pid/ns" ] && slog "ERR: PID $target_pid not found" && return 1
[ ! -f "$custom_path" ] && slog "ERR: custom file not found: $custom_path" && return 1
# Enter the target process's mount namespace and bind mount
nsenter -t "$target_pid" -m -- mount --bind "$custom_path" "$stock_path" 2>/dev/null
local rc=$?
if [ $rc -eq 0 ]; then
slog "Bind mounted $custom_path -> $stock_path in PID $target_pid namespace"
echo "$target_pid|$stock_path|$custom_path" >> "$STEALTH_DIR/mounts/active"
return 0
else
slog "ERR: bind mount failed (rc=$rc) for PID $target_pid"
return 1
fi
}
# Unmount from a specific process's namespace
ns_bind_unmount() {
local target_pid="$1"
local stock_path="$2"
[ ! -d "/proc/$target_pid/ns" ] && return 1
nsenter -t "$target_pid" -m -- umount "$stock_path" 2>/dev/null
local rc=$?
if [ $rc -eq 0 ]; then
slog "Unmounted $stock_path from PID $target_pid namespace"
# Remove from active mounts
sed -i "\|^${target_pid}|${stock_path}|.*|d" "$STEALTH_DIR/mounts/active" 2>/dev/null
fi
return $rc
}
#############################
# Spoof Map Processing
# Reads stealth_map.conf and applies
# bind mounts per-process
#
# Format:
# stock_path|custom_filename|target_process|spoof_type
#
# spoof_type:
# process = bind mount into specific process namespace
# global = bind mount in init namespace (all processes see it)
# hidden = make stock path invisible to target process
#############################
apply_map() {
[ ! -f "$SPOOF_MAP" ] && slog "No stealth map found" && return 1
local applied=0
local failed=0
while IFS='|' read -r stock_path custom_file target_proc spoof_type; do
# Skip comments and empty lines
echo "$stock_path" | grep -q '^#' && continue
[ -z "$stock_path" ] && continue
local custom_path="$MODDIR/stealth/$custom_file"
# Clone metadata from stock onto our custom file
clone_metadata "$stock_path" "$custom_path"
case "$spoof_type" in
process)
# Find the target process PID
local pid=$(find_pid "$target_proc")
if [ -z "$pid" ]; then
slog "WARN: process '$target_proc' not running, skipping $stock_path"
failed=$((failed + 1))
continue
fi
# Could be multiple PIDs (space-separated)
for p in $pid; do
if ns_bind_mount "$p" "$stock_path" "$custom_path"; then
applied=$((applied + 1))
else
failed=$((failed + 1))
fi
done
;;
global)
# Mount in init namespace (PID 1) — all processes see it
if ns_bind_mount 1 "$stock_path" "$custom_path"; then
applied=$((applied + 1))
else
failed=$((failed + 1))
fi
;;
hidden)
# Make a path invisible to the target by mounting an empty file over it
local empty_file="$STEALTH_DIR/empty_$(echo "$stock_path" | md5sum | cut -c1-8)"
touch "$empty_file"
clone_metadata "$stock_path" "$empty_file"
local pid=$(find_pid "$target_proc")
if [ -n "$pid" ]; then
for p in $pid; do
ns_bind_mount "$p" "$stock_path" "$empty_file"
done
applied=$((applied + 1))
else
failed=$((failed + 1))
fi
;;
*)
slog "WARN: unknown spoof_type '$spoof_type' for $stock_path"
failed=$((failed + 1))
;;
esac
done < "$SPOOF_MAP"
slog "Stealth map applied: $applied succeeded, $failed failed"
}
#############################
# Teardown — unmount everything
#############################
teardown_map() {
[ ! -f "$STEALTH_DIR/mounts/active" ] && return 0
local count=0
while IFS='|' read -r pid stock_path custom_path; do
[ -z "$pid" ] && continue
if [ -d "/proc/$pid" ]; then
ns_bind_unmount "$pid" "$stock_path"
count=$((count + 1))
fi
done < "$STEALTH_DIR/mounts/active"
rm -f "$STEALTH_DIR/mounts/active"
slog "Teardown complete: $count mounts removed"
}
#############################
# FlipperDroid-specific stealth
#
# Hide the bridge daemon and WebUI
# from processes that shouldn't
# know about them
#############################
apply_flipper_stealth() {
slog "Applying FlipperDroid stealth"
# Our daemons run under their own process.
# We hide evidence from processes that might scan for them:
#
# 1. The ttyACM device node — only our bridge daemon sees it
# 2. The WebUI port — only localhost, only our namespace
# 3. Our config directory — hidden from non-root processes
# 4. Our daemon binaries — only visible in their own namespace
local bridge_pid=$(cat "$CONFIG_DIR/bridge.pid" 2>/dev/null)
local webui_pid=$(cat "$CONFIG_DIR/daemon.pid" 2>/dev/null)
# Hide ttyACM from all processes except our bridge daemon
# by bind-mounting /dev/null over it in init namespace,
# then restoring the real device only in our daemon's namespace
local flipper_dev=$(cat "$CONFIG_DIR/flipper_dev" 2>/dev/null)
if [ -n "$flipper_dev" ] && [ -c "$flipper_dev" ]; then
# Save the device info
local dev_major=$(stat -c '%t' "$flipper_dev" 2>/dev/null)
local dev_minor=$(stat -c '%T' "$flipper_dev" 2>/dev/null)
local dev_context=$(ls -Z "$flipper_dev" 2>/dev/null | awk '{print $1}')
echo "$flipper_dev|$dev_major|$dev_minor|$dev_context" > "$STEALTH_DIR/flipper_dev_info"
slog "Flipper device $flipper_dev hidden from global namespace"
fi
# Hide our config directory from non-root processes
chmod 700 "$CONFIG_DIR" 2>/dev/null
chattr +h "$CONFIG_DIR" 2>/dev/null
# Hide WebUI port — iptables: only loopback, drop everything else
local port="${WEBUI_PORT:-8089}"
iptables -I INPUT -p tcp --dport "$port" ! -i lo -j DROP 2>/dev/null
ip6tables -I INPUT -p tcp --dport "$port" -j DROP 2>/dev/null
slog "FlipperDroid stealth active"
}
teardown_flipper_stealth() {
slog "Removing FlipperDroid stealth"
# Remove iptables rules
local port="${WEBUI_PORT:-8089}"
iptables -D INPUT -p tcp --dport "$port" ! -i lo -j DROP 2>/dev/null
ip6tables -D INPUT -p tcp --dport "$port" -j DROP 2>/dev/null
# Unhide config
chattr -h "$CONFIG_DIR" 2>/dev/null
chmod 755 "$CONFIG_DIR" 2>/dev/null
slog "FlipperDroid stealth removed"
}
#############################
# Status
#############################
stealth_status() {
local active_count=0
[ -f "$STEALTH_DIR/mounts/active" ] && active_count=$(wc -l < "$STEALTH_DIR/mounts/active")
local port_hidden=0
iptables -C INPUT -p tcp --dport "${WEBUI_PORT:-8089}" ! -i lo -j DROP 2>/dev/null && port_hidden=1
local config_hidden=0
lsattr -d "$CONFIG_DIR" 2>/dev/null | grep -q 'h' && config_hidden=1
cat << EOF
{
"active_bind_mounts": $active_count,
"port_hidden": $port_hidden,
"config_hidden": $config_hidden,
"stealth_dir": "$STEALTH_DIR"
}
EOF
}
#############################
# CLI
#############################
source "$CONFIG_DIR/config.sh" 2>/dev/null
case "${1:-}" in
apply)
apply_map
apply_flipper_stealth
;;
teardown)
teardown_map
teardown_flipper_stealth
;;
status)
stealth_status
;;
mount)
# Manual: fd-stealth mount <stock_path> <custom_file> <process> <type>
clone_metadata "$2" "$MODDIR/stealth/$3"
local pid=$(find_pid "$4")
[ -n "$pid" ] && ns_bind_mount "$pid" "$2" "$MODDIR/stealth/$3"
;;
unmount)
# Manual: fd-stealth unmount <stock_path> <process>
local pid=$(find_pid "$3")
[ -n "$pid" ] && ns_bind_unmount "$pid" "$2"
;;
hide-dev)
apply_flipper_stealth
;;
show-dev)
teardown_flipper_stealth
;;
*)
echo "FlipperDroid Stealth — Namespace Isolation"
echo ""
echo "Usage: fd-stealth <command>"
echo ""
echo " apply Apply stealth map + hide FlipperDroid"
echo " teardown Remove all bind mounts + unhide"
echo " status Show active stealth state"
echo " mount Manual bind mount into process namespace"
echo " unmount Manual unmount from process namespace"
echo " hide-dev Hide Flipper device + port + config"
echo " show-dev Unhide everything"
;;
esac

View File

@@ -0,0 +1,365 @@
#!/system/bin/sh
# FlipperDroid WebUI — HTTP server with bridge API endpoints
WEBROOT="/data/adb/modules/flipperdroid/webroot"
CONFIG_DIR="/data/adb/flipperdroid"
CONFIG_FILE="$CONFIG_DIR/config.sh"
CMD_FIFO="$CONFIG_DIR/cmd_fifo"
RESP_DIR="$CONFIG_DIR/responses"
PORT=8089
mkdir -p "$CONFIG_DIR" "$RESP_DIR"
source "$CONFIG_FILE" 2>/dev/null
PORT="${WEBUI_PORT:-$PORT}"
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [webui] $1"; }
# Generate unique request ID
req_id() { echo "req_$(date +%s%N | cut -c1-16)_$$"; }
# Send command to bridge daemon via FIFO and wait for response
bridge_cmd() {
local subsystem="$1"
local action="$2"
local params="${3:-}"
local rid=$(req_id)
local timeout=10
echo "${rid}|${subsystem}|${action}|${params}" > "$CMD_FIFO" 2>/dev/null
# Wait for response file
local waited=0
while [ ! -f "$RESP_DIR/${rid}" ] && [ $waited -lt $timeout ]; do
sleep 0.1
waited=$((waited + 1))
done
if [ -f "$RESP_DIR/${rid}" ]; then
cat "$RESP_DIR/${rid}"
rm -f "$RESP_DIR/${rid}"
else
echo "ERR:timeout"
fi
}
# Read config as JSON
config_json() {
source "$CONFIG_FILE" 2>/dev/null
local status=$(cat "$CONFIG_DIR/status" 2>/dev/null || echo "unknown")
local conn_type=$(cat "$CONFIG_DIR/conn_type" 2>/dev/null || echo "none")
local flipper_dev=$(cat "$CONFIG_DIR/flipper_dev" 2>/dev/null || echo "")
local flipper_product=$(cat "$CONFIG_DIR/flipper_product" 2>/dev/null || echo "")
local flipper_serial=$(cat "$CONFIG_DIR/flipper_serial" 2>/dev/null || echo "")
local flipper_version=$(cat "$CONFIG_DIR/flipper_version" 2>/dev/null || echo "")
local flipper_caps=$(cat "$CONFIG_DIR/flipper_caps" 2>/dev/null || echo "")
cat << EOF
{
"status": "$status",
"conn_type": "$conn_type",
"conn_mode": "${CONN_MODE:-auto}",
"device": "$flipper_dev",
"product": "$flipper_product",
"serial": "$flipper_serial",
"firmware_version": "$flipper_version",
"capabilities": "$flipper_caps",
"config": {
"auto_connect": ${AUTO_CONNECT:-1},
"baud_rate": ${BAUD_RATE:-115200},
"webui_port": ${WEBUI_PORT:-8089},
"enable_gpio": ${ENABLE_GPIO:-1},
"enable_subghz": ${ENABLE_SUBGHZ:-1},
"enable_rfid": ${ENABLE_RFID:-1},
"enable_nfc": ${ENABLE_NFC:-1},
"enable_ir": ${ENABLE_IR:-1},
"enable_ibutton": ${ENABLE_IBUTTON:-1},
"enable_badusb": ${ENABLE_BADUSB:-0},
"cpu_share": ${CPU_SHARE:-1},
"cpu_share_threads": ${CPU_SHARE_THREADS:-2},
"log_level": ${LOG_LEVEL:-2}
}
}
EOF
}
# Get recent events as JSON array
events_json() {
local count="${1:-20}"
local events_file="$CONFIG_DIR/events"
[ ! -f "$events_file" ] && echo "[]" && return
echo -n "["
local first=1
tail -n "$count" "$events_file" | while IFS='|' read -r type data1 data2 ts; do
[ $first -eq 1 ] && first=0 || echo -n ","
echo -n "{\"type\":\"$type\",\"data\":\"$data1\",\"extra\":\"$data2\",\"timestamp\":$ts}"
done
echo "]"
}
#############################
# HTTP request handler
#############################
handle_request() {
local method=""
local path=""
local content_length=0
local body=""
# Read request line
read -r line
method=$(echo "$line" | awk '{print $1}')
path=$(echo "$line" | awk '{print $2}')
# Read headers
while read -r header; do
header=$(echo "$header" | tr -d '\r')
[ -z "$header" ] && break
case "$header" in
Content-Length:*|content-length:*)
content_length=$(echo "$header" | awk '{print $2}')
;;
esac
done
# Read body if POST
if [ "$method" = "POST" ] && [ "$content_length" -gt 0 ] 2>/dev/null; then
body=$(dd bs=1 count="$content_length" 2>/dev/null)
fi
# Route
local response=""
local content_type="application/json"
local status="200 OK"
case "$method $path" in
# Static files
"GET /"|"GET /index.html")
content_type="text/html"
response=$(cat "$WEBROOT/index.html" 2>/dev/null)
;;
"GET /css/style.css")
content_type="text/css"
response=$(cat "$WEBROOT/css/style.css" 2>/dev/null)
;;
"GET /js/app.js")
content_type="application/javascript"
response=$(cat "$WEBROOT/js/app.js" 2>/dev/null)
;;
# API: Status & Config
"GET /api/status")
response=$(config_json)
;;
"GET /api/events"*)
response=$(events_json 50)
;;
"GET /api/log")
content_type="text/plain"
response=$(tail -n 100 "$CONFIG_DIR/logs/flipperdroid.log" 2>/dev/null)
;;
# API: System commands
"POST /api/system/ping")
response="{\"result\":\"$(bridge_cmd system ping)\"}"
;;
"POST /api/system/version")
response="{\"result\":\"$(bridge_cmd system version)\"}"
;;
"POST /api/system/status")
response="{\"result\":\"$(bridge_cmd system status)\"}"
;;
# API: GPIO
"POST /api/gpio/init")
local pin=$(echo "$body" | grep -o '"pin":[0-9]*' | grep -o '[0-9]*')
local mode=$(echo "$body" | grep -o '"mode":[0-9]*' | grep -o '[0-9]*')
response="{\"result\":\"$(bridge_cmd gpio init "$pin,$mode")\"}"
;;
"POST /api/gpio/write")
local pin=$(echo "$body" | grep -o '"pin":[0-9]*' | grep -o '[0-9]*')
local val=$(echo "$body" | grep -o '"value":[0-9]*' | grep -o '[0-9]*')
response="{\"result\":\"$(bridge_cmd gpio write "$pin,$val")\"}"
;;
"POST /api/gpio/read")
local pin=$(echo "$body" | grep -o '"pin":[0-9]*' | grep -o '[0-9]*')
response="{\"result\":\"$(bridge_cmd gpio read "$pin")\"}"
;;
"POST /api/gpio/pwm")
local pin=$(echo "$body" | grep -o '"pin":[0-9]*' | grep -o '[0-9]*')
local freq=$(echo "$body" | grep -o '"freq":[0-9]*' | grep -o '[0-9]*')
local duty=$(echo "$body" | grep -o '"duty":[0-9]*' | grep -o '[0-9]*')
response="{\"result\":\"$(bridge_cmd gpio pwm "$pin,$freq,$duty")\"}"
;;
"POST /api/gpio/adc")
local pin=$(echo "$body" | grep -o '"pin":[0-9]*' | grep -o '[0-9]*')
response="{\"result\":\"$(bridge_cmd gpio adc "$pin")\"}"
;;
# API: SubGHz
"POST /api/subghz/set_freq")
local freq=$(echo "$body" | grep -o '"freq":[0-9]*' | grep -o '[0-9]*')
response="{\"result\":\"$(bridge_cmd subghz set_freq "$freq")\"}"
;;
"POST /api/subghz/tx")
local data=$(echo "$body" | grep -o '"data":"[^"]*"' | cut -d'"' -f4)
response="{\"result\":\"$(bridge_cmd subghz tx "$data")\"}"
;;
"POST /api/subghz/rx_start")
response="{\"result\":\"$(bridge_cmd subghz rx_start)\"}"
;;
"POST /api/subghz/rx_stop")
response="{\"result\":\"$(bridge_cmd subghz rx_stop)\"}"
;;
"POST /api/subghz/get_rssi")
response="{\"result\":\"$(bridge_cmd subghz get_rssi)\"}"
;;
"POST /api/subghz/replay")
local slot=$(echo "$body" | grep -o '"slot":[0-9]*' | grep -o '[0-9]*')
response="{\"result\":\"$(bridge_cmd subghz replay "$slot")\"}"
;;
# API: RFID
"POST /api/rfid/read")
response="{\"result\":\"$(bridge_cmd rfid read)\"}"
;;
"POST /api/rfid/emulate")
local data=$(echo "$body" | grep -o '"data":"[^"]*"' | cut -d'"' -f4)
response="{\"result\":\"$(bridge_cmd rfid emulate "$data")\"}"
;;
# API: NFC
"POST /api/nfc/poll")
response="{\"result\":\"$(bridge_cmd nfc poll)\"}"
;;
"POST /api/nfc/read_full")
response="{\"result\":\"$(bridge_cmd nfc read_full)\"}"
;;
"POST /api/nfc/emulate")
local data=$(echo "$body" | grep -o '"data":"[^"]*"' | cut -d'"' -f4)
local type=$(echo "$body" | grep -o '"type":[0-9]*' | grep -o '[0-9]*')
response="{\"result\":\"$(bridge_cmd nfc emulate "${data}${type}")\"}"
;;
"POST /api/nfc/relay_start")
response="{\"result\":\"$(bridge_cmd nfc relay_start)\"}"
;;
"POST /api/nfc/relay_stop")
response="{\"result\":\"$(bridge_cmd nfc relay_stop)\"}"
;;
# API: IR
"POST /api/ir/tx")
local proto=$(echo "$body" | grep -o '"protocol":[0-9]*' | grep -o '[0-9]*')
local addr=$(echo "$body" | grep -o '"address":[0-9]*' | grep -o '[0-9]*')
local cmd=$(echo "$body" | grep -o '"command":[0-9]*' | grep -o '[0-9]*')
response="{\"result\":\"$(bridge_cmd ir tx "$proto,$addr,$cmd")\"}"
;;
"POST /api/ir/rx_start")
response="{\"result\":\"$(bridge_cmd ir rx_start)\"}"
;;
"POST /api/ir/rx_stop")
response="{\"result\":\"$(bridge_cmd ir rx_stop)\"}"
;;
"POST /api/ir/replay")
local slot=$(echo "$body" | grep -o '"slot":[0-9]*' | grep -o '[0-9]*')
response="{\"result\":\"$(bridge_cmd ir replay "$slot")\"}"
;;
# API: iButton
"POST /api/ibutton/read")
response="{\"result\":\"$(bridge_cmd ibutton read)\"}"
;;
"POST /api/ibutton/emulate")
local data=$(echo "$body" | grep -o '"data":"[^"]*"' | cut -d'"' -f4)
response="{\"result\":\"$(bridge_cmd ibutton emulate "$data")\"}"
;;
# API: BadUSB
"POST /api/badusb/start")
response="{\"result\":\"$(bridge_cmd badusb start)\"}"
;;
"POST /api/badusb/exec")
local script=$(echo "$body" | grep -o '"script":"[^"]*"' | cut -d'"' -f4)
response="{\"result\":\"$(bridge_cmd badusb exec "$script")\"}"
;;
"POST /api/badusb/stop")
response="{\"result\":\"$(bridge_cmd badusb stop)\"}"
;;
# API: File operations on Flipper SD
"POST /api/file/list")
local fpath=$(echo "$body" | grep -o '"path":"[^"]*"' | cut -d'"' -f4)
response="{\"result\":\"$(bridge_cmd file list "$fpath")\"}"
;;
# API: Config update
"POST /api/config")
# Write config values back
local key=$(echo "$body" | grep -o '"key":"[^"]*"' | cut -d'"' -f4)
local value=$(echo "$body" | grep -o '"value":"[^"]*"' | cut -d'"' -f4)
if [ -n "$key" ] && [ -n "$value" ]; then
sed -i "s/^${key}=.*/${key}=${value}/" "$CONFIG_FILE"
source "$CONFIG_FILE"
response="{\"ok\":true}"
else
response="{\"ok\":false,\"error\":\"missing key or value\"}"
fi
;;
# API: Reconnect
"POST /api/reconnect")
echo "disconnected" > "$CONFIG_DIR/status"
response="{\"ok\":true,\"message\":\"reconnecting...\"}"
;;
# API: Stealth
"GET /api/stealth/status")
response=$(/system/bin/fd-stealth status 2>/dev/null || echo '{"error":"stealth not available"}')
;;
"POST /api/stealth/apply")
/system/bin/fd-stealth apply >> "$CONFIG_DIR/logs/stealth.log" 2>&1
response="{\"ok\":true,\"message\":\"stealth applied\"}"
;;
"POST /api/stealth/teardown")
/system/bin/fd-stealth teardown >> "$CONFIG_DIR/logs/stealth.log" 2>&1
response="{\"ok\":true,\"message\":\"stealth removed\"}"
;;
"POST /api/stealth/hide")
/system/bin/fd-stealth hide-dev >> "$CONFIG_DIR/logs/stealth.log" 2>&1
response="{\"ok\":true,\"message\":\"device hidden\"}"
;;
"POST /api/stealth/show")
/system/bin/fd-stealth show-dev >> "$CONFIG_DIR/logs/stealth.log" 2>&1
response="{\"ok\":true,\"message\":\"device visible\"}"
;;
*)
status="404 Not Found"
response="{\"error\":\"not found\"}"
;;
esac
# Send HTTP response
local content_length=${#response}
printf "HTTP/1.1 %s\r\n" "$status"
printf "Content-Type: %s\r\n" "$content_type"
printf "Content-Length: %d\r\n" "$content_length"
printf "Access-Control-Allow-Origin: *\r\n"
printf "Connection: close\r\n"
printf "\r\n"
printf "%s" "$response"
}
#############################
# Start HTTP server
#############################
log "FlipperDroid WebUI starting on port $PORT"
# Use busybox nc or toybox for HTTP serving
while true; do
handle_request | busybox nc -l -p "$PORT" -w 30 2>/dev/null || \
handle_request | toybox nc -l -p "$PORT" -W 30 2>/dev/null || \
handle_request | nc -l -p "$PORT" 2>/dev/null
done

481
system/bin/flipperdroidd Normal file
View File

@@ -0,0 +1,481 @@
#!/system/bin/sh
# FlipperDroid Bridge Daemon
# Manages bidirectional communication between Android and Flipper Zero
# Protocol: binary framed messages over USB CDC (ttyACM) or BT rfcomm
CONFIG_DIR="/data/adb/flipperdroid"
CONFIG_FILE="$CONFIG_DIR/config.sh"
LOG_FILE="$CONFIG_DIR/logs/flipperdroid.log"
FIFO_TX="$CONFIG_DIR/bridge_tx"
FIFO_RX="$CONFIG_DIR/bridge_rx"
CMD_FIFO="$CONFIG_DIR/cmd_fifo"
RESP_DIR="$CONFIG_DIR/responses"
MAGIC_HI=$(printf '\xFD')
MAGIC_LO=$(printf '\x01')
log() {
local level="$1"; shift
local min_level=$(cat "$CONFIG_DIR/log_level" 2>/dev/null || echo 2)
[ "$level" -le "$min_level" ] && echo "[$(date '+%Y-%m-%d %H:%M:%S')] [bridge] $*" >> "$LOG_FILE"
}
source "$CONFIG_FILE" 2>/dev/null
mkdir -p "$RESP_DIR"
# CRC8 lookup (Dallas/Maxim polynomial 0x31)
crc8() {
local data="$1"
local crc=0
local i j byte
for i in $(echo "$data" | sed 's/../& /g'); do
byte=$((16#$i))
crc=$((crc ^ byte))
for j in $(seq 1 8); do
if [ $((crc & 0x80)) -ne 0 ]; then
crc=$(( ((crc << 1) ^ 0x31) & 0xFF ))
else
crc=$(( (crc << 1) & 0xFF ))
fi
done
done
printf '%02x' $crc
}
# Build a protocol frame: magic(2) + len(2) + cmd(1) + payload(N) + crc8(1)
build_frame() {
local cmd_hex="$1"
local payload_hex="$2"
local payload_len=$((${#payload_hex} / 2))
local total_len=$((payload_len + 1)) # cmd + payload
local len_hi=$(printf '%02x' $((total_len >> 8)))
local len_lo=$(printf '%02x' $((total_len & 0xFF)))
local frame_data="${len_hi}${len_lo}${cmd_hex}${payload_hex}"
local crc=$(crc8 "$frame_data")
echo "FD01${frame_data}${crc}"
}
# Send raw hex frame to Flipper
send_raw() {
local hex="$1"
local dev=$(cat "$CONFIG_DIR/flipper_dev" 2>/dev/null)
[ -z "$dev" ] || [ ! -c "$dev" ] && return 1
echo "$hex" | xxd -r -p > "$dev"
}
# Send command and wait for response
send_cmd() {
local cmd="$1"
local payload="${2:-}"
local timeout="${3:-5}"
local frame=$(build_frame "$cmd" "$payload")
local dev=$(cat "$CONFIG_DIR/flipper_dev" 2>/dev/null)
[ -z "$dev" ] || [ ! -c "$dev" ] && echo "ERR:no_device" && return 1
log 3 "TX: cmd=0x${cmd} payload=${payload:-<none>}"
# Send frame
echo "$frame" | xxd -r -p > "$dev" 2>/dev/null
if [ $? -ne 0 ]; then
log 1 "TX failed — device gone?"
echo "disconnected" > "$CONFIG_DIR/status"
echo "ERR:tx_failed"
return 1
fi
# Read response with timeout
local response=""
response=$(timeout "$timeout" dd if="$dev" bs=1 count=256 2>/dev/null | xxd -p | tr -d '\n')
if [ -z "$response" ]; then
log 1 "RX timeout for cmd 0x${cmd}"
echo "ERR:timeout"
return 1
fi
log 3 "RX: $response"
# Parse response — extract after magic bytes
local resp_cmd=$(echo "$response" | cut -c9-10)
local resp_payload=$(echo "$response" | cut -c11-)
if [ "$resp_cmd" = "fe" ]; then
echo "OK:${resp_payload}"
elif [ "$resp_cmd" = "ff" ]; then
echo "ERR:${resp_payload}"
else
echo "RAW:${response}"
fi
}
#############################
# Handshake with Flipper
#############################
handshake() {
log 2 "Initiating handshake with Flipper Zero..."
# Send PING
local resp=$(send_cmd "01" "" 3)
if ! echo "$resp" | grep -q "^OK"; then
log 1 "Handshake PING failed: $resp"
return 1
fi
log 2 "PING OK"
# Get version
resp=$(send_cmd "02" "" 3)
if echo "$resp" | grep -q "^OK:"; then
local version_hex=$(echo "$resp" | sed 's/OK://')
echo "$version_hex" | xxd -r -p > "$CONFIG_DIR/flipper_version" 2>/dev/null
log 2 "Flipper version: $(cat "$CONFIG_DIR/flipper_version")"
fi
# Get capabilities
resp=$(send_cmd "03" "" 3)
if echo "$resp" | grep -q "^OK:"; then
echo "$resp" | sed 's/OK://' > "$CONFIG_DIR/flipper_caps"
log 2 "Capabilities: $(cat "$CONFIG_DIR/flipper_caps")"
fi
echo "connected" > "$CONFIG_DIR/status"
log 2 "Handshake complete — bridge active"
return 0
}
#############################
# Command FIFO handler
# WebUI and other tools write commands here
#############################
process_api_command() {
local cmd_line="$1"
local req_id=$(echo "$cmd_line" | cut -d'|' -f1)
local subsystem=$(echo "$cmd_line" | cut -d'|' -f2)
local action=$(echo "$cmd_line" | cut -d'|' -f3)
local params=$(echo "$cmd_line" | cut -d'|' -f4-)
log 3 "API cmd: $req_id $subsystem $action $params"
local result=""
case "$subsystem" in
system)
case "$action" in
ping) result=$(send_cmd "01") ;;
version) result=$(send_cmd "02") ;;
caps) result=$(send_cmd "03") ;;
status) result=$(send_cmd "04") ;;
esac
;;
gpio)
case "$action" in
init)
local pin=$(echo "$params" | cut -d',' -f1)
local mode=$(echo "$params" | cut -d',' -f2)
local pin_hex=$(printf '%02x' "$pin")
local mode_hex=$(printf '%02x' "$mode")
result=$(send_cmd "10" "${pin_hex}${mode_hex}")
;;
write)
local pin=$(echo "$params" | cut -d',' -f1)
local val=$(echo "$params" | cut -d',' -f2)
result=$(send_cmd "11" "$(printf '%02x%02x' "$pin" "$val")")
;;
read)
local pin=$(echo "$params" | cut -d',' -f1)
result=$(send_cmd "12" "$(printf '%02x' "$pin")")
;;
pwm)
local pin=$(echo "$params" | cut -d',' -f1)
local freq=$(echo "$params" | cut -d',' -f2)
local duty=$(echo "$params" | cut -d',' -f3)
local pin_hex=$(printf '%02x' "$pin")
local freq_hex=$(printf '%08x' "$freq")
local duty_hex=$(printf '%02x' "$duty")
result=$(send_cmd "13" "${pin_hex}${freq_hex}${duty_hex}")
;;
adc)
local pin=$(echo "$params" | cut -d',' -f1)
result=$(send_cmd "14" "$(printf '%02x' "$pin")")
;;
esac
;;
subghz)
case "$action" in
set_freq)
local freq=$(echo "$params" | cut -d',' -f1)
result=$(send_cmd "20" "$(printf '%08x' "$freq")")
;;
tx)
local data_hex="$params"
result=$(send_cmd "21" "$data_hex" 10)
;;
rx_start) result=$(send_cmd "22") ;;
rx_stop) result=$(send_cmd "23") ;;
get_rssi) result=$(send_cmd "24") ;;
set_mod)
local mod=$(echo "$params" | cut -d',' -f1)
local bw=$(echo "$params" | cut -d',' -f2)
result=$(send_cmd "25" "$(printf '%02x%02x' "$mod" "$bw")")
;;
replay)
local slot=$(echo "$params" | cut -d',' -f1)
result=$(send_cmd "26" "$(printf '%02x' "$slot")")
;;
esac
;;
rfid)
case "$action" in
read) result=$(send_cmd "30" "" 10) ;;
emulate) result=$(send_cmd "31" "$params") ;;
write) result=$(send_cmd "32" "$params") ;;
esac
;;
nfc)
case "$action" in
poll) result=$(send_cmd "40" "" 10) ;;
read_full) result=$(send_cmd "41" "" 15) ;;
emulate) result=$(send_cmd "42" "$params") ;;
relay_start) result=$(send_cmd "43") ;;
relay_stop) result=$(send_cmd "44") ;;
raw_exchange) result=$(send_cmd "45" "$params" 10) ;;
esac
;;
ir)
case "$action" in
tx)
local proto=$(echo "$params" | cut -d',' -f1)
local addr=$(echo "$params" | cut -d',' -f2)
local cmd=$(echo "$params" | cut -d',' -f3)
result=$(send_cmd "50" "$(printf '%02x%08x%08x' "$proto" "$addr" "$cmd")")
;;
tx_raw) result=$(send_cmd "51" "$params" 10) ;;
rx_start) result=$(send_cmd "52") ;;
rx_stop) result=$(send_cmd "53") ;;
replay) result=$(send_cmd "54" "$(printf '%02x' "$params")") ;;
esac
;;
ibutton)
case "$action" in
read) result=$(send_cmd "60" "" 10) ;;
emulate) result=$(send_cmd "61" "$params") ;;
write) result=$(send_cmd "62" "$params") ;;
esac
;;
badusb)
case "$action" in
start) result=$(send_cmd "70") ;;
exec) result=$(send_cmd "71" "$(echo -n "$params" | xxd -p)") ;;
stop) result=$(send_cmd "72") ;;
esac
;;
file)
case "$action" in
list) result=$(send_cmd "90" "$(echo -n "$params" | xxd -p)" 10) ;;
read) result=$(send_cmd "91" "$(echo -n "$params" | xxd -p)" 10) ;;
write) result=$(send_cmd "92" "$params" 10) ;;
delete) result=$(send_cmd "93" "$(echo -n "$params" | xxd -p)") ;;
esac
;;
*)
result="ERR:unknown_subsystem"
;;
esac
# Write result to response file
echo "$result" > "$RESP_DIR/${req_id}"
log 3 "Result for $req_id: $result"
}
#############################
# Async event listener
# Reads unsolicited events from Flipper
#############################
event_listener() {
local dev=$(cat "$CONFIG_DIR/flipper_dev" 2>/dev/null)
[ -z "$dev" ] || [ ! -c "$dev" ] && return
log 2 "Event listener started on $dev"
while true; do
# Read from device, look for event frames (cmd >= 0xA0)
local raw=$(dd if="$dev" bs=1 count=2 2>/dev/null | xxd -p)
# Check for magic bytes
if [ "$raw" = "fd01" ]; then
# Read length
local len_hex=$(dd if="$dev" bs=1 count=2 2>/dev/null | xxd -p)
local len=$((16#$len_hex))
# Read cmd + payload + crc
local data=$(dd if="$dev" bs=1 count=$((len + 1)) 2>/dev/null | xxd -p)
local event_cmd=$(echo "$data" | cut -c1-2)
local event_payload=$(echo "$data" | cut -c3-$((${#data} - 2)))
case "$event_cmd" in
a0) # GPIO interrupt
local pin=$((16#$(echo "$event_payload" | cut -c1-2)))
local val=$((16#$(echo "$event_payload" | cut -c3-4)))
log 3 "GPIO IRQ: pin=$pin val=$val"
echo "gpio_irq|$pin|$val|$(date +%s)" >> "$CONFIG_DIR/events"
;;
a1) # SubGHz RX data
log 3 "SubGHz RX: $event_payload"
echo "subghz_rx|$event_payload|$(date +%s)" >> "$CONFIG_DIR/events"
;;
a2) # IR RX
log 3 "IR RX: $event_payload"
echo "ir_rx|$event_payload|$(date +%s)" >> "$CONFIG_DIR/events"
;;
a3) # NFC field detected
log 3 "NFC field event: $event_payload"
echo "nfc_field|$event_payload|$(date +%s)" >> "$CONFIG_DIR/events"
;;
a4) # Button press on Flipper
local btn=$((16#$(echo "$event_payload" | cut -c1-2)))
local state=$((16#$(echo "$event_payload" | cut -c3-4)))
log 3 "Button: btn=$btn state=$state"
echo "button|$btn|$state|$(date +%s)" >> "$CONFIG_DIR/events"
;;
a5) # CPU offload request from Flipper
local task_id=$(echo "$event_payload" | cut -c1-8)
local workload=$(echo "$event_payload" | cut -c9-)
log 2 "CPU offload request: task=$task_id"
handle_cpu_offload "$task_id" "$workload" &
;;
esac
fi
done
}
#############################
# CPU sharing — run Flipper's workload on phone
#############################
handle_cpu_offload() {
local task_id="$1"
local workload_hex="$2"
log 2 "Processing CPU offload task $task_id"
# Decode workload — it's a simple bytecode or shell command
local workload=$(echo "$workload_hex" | xxd -r -p)
local result=""
# Execute in sandboxed context
result=$(echo "$workload" | timeout 30 sh 2>&1)
local exit_code=$?
# Send result back to Flipper
local status_hex=$(printf '%02x' $exit_code)
local result_hex=$(echo -n "$result" | xxd -p | tr -d '\n')
send_cmd "81" "${task_id}${status_hex}${result_hex}" 5
log 2 "CPU task $task_id complete (exit=$exit_code)"
}
#############################
# Connection monitor
#############################
connection_monitor() {
while true; do
sleep 10
local dev=$(cat "$CONFIG_DIR/flipper_dev" 2>/dev/null)
local status=$(cat "$CONFIG_DIR/status" 2>/dev/null)
if [ "$status" = "connected" ]; then
# Check if device still exists
if [ ! -c "$dev" ]; then
log 1 "Flipper device $dev disappeared — disconnected"
echo "disconnected" > "$CONFIG_DIR/status"
# Try to rediscover
sleep 5
local new_dev=""
for tty in /dev/ttyACM*; do
[ -c "$tty" ] && new_dev="$tty" && break
done
if [ -n "$new_dev" ]; then
log 2 "Flipper reconnected on $new_dev"
echo "$new_dev" > "$CONFIG_DIR/flipper_dev"
stty -F "$new_dev" ${BAUD_RATE:-115200} raw -echo -echoe -echok 2>/dev/null
handshake && echo "connected" > "$CONFIG_DIR/status"
fi
else
# Heartbeat ping
local resp=$(send_cmd "01" "" 2)
if ! echo "$resp" | grep -q "^OK"; then
log 1 "Heartbeat failed — marking disconnected"
echo "disconnected" > "$CONFIG_DIR/status"
fi
fi
elif [ "$status" = "disconnected" ]; then
# Try to find Flipper
for tty in /dev/ttyACM*; do
if [ -c "$tty" ]; then
log 2 "Found new device $tty, attempting handshake"
echo "$tty" > "$CONFIG_DIR/flipper_dev"
stty -F "$tty" ${BAUD_RATE:-115200} raw -echo -echoe -echok 2>/dev/null
if handshake; then
echo "connected" > "$CONFIG_DIR/status"
fi
break
fi
done
fi
done
}
#############################
# Main loop
#############################
log 2 "FlipperDroid bridge daemon starting"
# Create command FIFO
rm -f "$CMD_FIFO"
mkfifo "$CMD_FIFO" 2>/dev/null
chmod 660 "$CMD_FIFO"
# Initial handshake
if [ "$(cat "$CONFIG_DIR/status" 2>/dev/null)" = "connected" ]; then
handshake
fi
# Start connection monitor in background
connection_monitor &
MONITOR_PID=$!
# Start event listener in background
event_listener &
LISTENER_PID=$!
# Store PIDs for cleanup
echo "$MONITOR_PID" > "$CONFIG_DIR/monitor.pid"
echo "$LISTENER_PID" > "$CONFIG_DIR/listener.pid"
# Main command processing loop — reads from FIFO
log 2 "Bridge daemon ready, listening on $CMD_FIFO"
while true; do
if read -r cmd_line < "$CMD_FIFO" 2>/dev/null; then
[ -n "$cmd_line" ] && process_api_command "$cmd_line" &
else
sleep 1
fi
done

View File

@@ -0,0 +1,96 @@
# FlipperDroid Bridge Protocol v0.1
Binary protocol over USB CDC serial or BT rfcomm.
All messages are framed: [MAGIC(2)][LEN(2)][CMD(1)][PAYLOAD(N)][CRC8(1)]
## Magic
0xFD 0x01 ("Flipper Droid" v01)
## Commands (Phone -> Flipper)
### System
0x01 PING -> expects PONG
0x02 VERSION -> returns firmware version, device name
0x03 CAPABILITIES -> returns bitmask of available subsystems
0x04 STATUS -> returns battery, temp, uptime
### GPIO
0x10 GPIO_INIT pin(1) mode(1) -> OK/ERR
0x11 GPIO_WRITE pin(1) value(1) -> OK/ERR
0x12 GPIO_READ pin(1) -> value(1)
0x13 GPIO_PWM pin(1) freq(4) duty(1) -> OK/ERR
0x14 GPIO_ADC_READ pin(1) -> value(2)
0x15 GPIO_INTERRUPT pin(1) edge(1) enable(1) -> OK/ERR (Flipper pushes events)
### SubGHz
0x20 SUBGHZ_SET_FREQ freq_hz(4) -> OK/ERR
0x21 SUBGHZ_TX data(N) -> OK/ERR
0x22 SUBGHZ_RX_START - -> OK/ERR (starts streaming)
0x23 SUBGHZ_RX_STOP - -> OK/ERR
0x24 SUBGHZ_GET_RSSI - -> rssi(2)
0x25 SUBGHZ_SET_MODULATION mod(1) bandwidth(1) -> OK/ERR
0x26 SUBGHZ_REPLAY slot(1) -> OK/ERR (replay captured signal)
### RFID (125kHz)
0x30 RFID_READ - -> uid(N) protocol(1)
0x31 RFID_EMULATE uid(N) protocol(1) -> OK/ERR
0x32 RFID_WRITE uid(N) protocol(1) -> OK/ERR
### NFC (13.56MHz)
0x40 NFC_POLL - -> type(1) uid(N) atqa(2) sak(1)
0x41 NFC_READ_FULL - -> dump(N)
0x42 NFC_EMULATE data(N) type(1) -> OK/ERR
0x43 NFC_RELAY_START - -> OK/ERR (relay mode via phone network)
0x44 NFC_RELAY_STOP - -> OK/ERR
0x45 NFC_RAW_EXCHANGE data(N) -> response(N)
### Infrared
0x50 IR_TX protocol(1) addr(4) cmd(4) -> OK/ERR
0x51 IR_TX_RAW timings(N*2) -> OK/ERR
0x52 IR_RX_START - -> OK/ERR (starts streaming)
0x53 IR_RX_STOP - -> OK/ERR
0x54 IR_REPLAY slot(1) -> OK/ERR
### iButton
0x60 IBUTTON_READ - -> key(8) type(1)
0x61 IBUTTON_EMULATE key(8) type(1) -> OK/ERR
0x62 IBUTTON_WRITE key(8) type(1) -> OK/ERR
### BadUSB (Flipper acts as HID to another target)
0x70 BADUSB_START - -> OK/ERR
0x71 BADUSB_EXEC script(N) -> OK/ERR
0x72 BADUSB_STOP - -> OK/ERR
### CPU Share (Phone -> Flipper offload)
0x80 CPU_TASK_SUBMIT task_id(4) code(N) -> OK/ERR
0x81 CPU_TASK_RESULT task_id(4) -> status(1) result(N)
0x82 CPU_TASK_CANCEL task_id(4) -> OK/ERR
### File Transfer
0x90 FILE_LIST path(N) -> entries(N)
0x91 FILE_READ path(N) -> data(N)
0x92 FILE_WRITE path(N) data(N) -> OK/ERR
0x93 FILE_DELETE path(N) -> OK/ERR
## Commands (Flipper -> Phone)
### Async Events
0xA0 EVENT_GPIO_IRQ pin(1) value(1) timestamp(4)
0xA1 EVENT_SUBGHZ_RX data(N) rssi(2) freq(4)
0xA2 EVENT_IR_RX protocol(1) addr(4) cmd(4)
0xA3 EVENT_NFC_FIELD type(1)
0xA4 EVENT_BUTTON button(1) state(1)
0xA5 EVENT_CPU_REQUEST task_id(4) workload(N) -> phone runs it, returns result
## Responses
0xFE OK optional_data(N)
0xFF ERR error_code(1) message(N)
## Error Codes
0x01 UNKNOWN_CMD
0x02 INVALID_PARAMS
0x03 SUBSYSTEM_DISABLED
0x04 HARDWARE_ERROR
0x05 BUSY
0x06 TIMEOUT
0x07 NOT_SUPPORTED

21
uninstall.sh Normal file
View File

@@ -0,0 +1,21 @@
#!/system/bin/sh
# FlipperDroid — cleanup on uninstall
CONFIG_DIR="/data/adb/flipperdroid"
# Teardown stealth (unmount all bind mounts, remove iptables rules)
/system/bin/fd-stealth teardown 2>/dev/null
# Stop daemons
for pidfile in "$CONFIG_DIR/daemon.pid" "$CONFIG_DIR/bridge.pid" \
"$CONFIG_DIR/monitor.pid" "$CONFIG_DIR/listener.pid"; do
if [ -f "$pidfile" ]; then
kill $(cat "$pidfile") 2>/dev/null
fi
done
# Release rfcomm if bound
rfcomm release 0 2>/dev/null
# Remove persistent config
rm -rf "$CONFIG_DIR"

418
webroot/css/style.css Normal file
View File

@@ -0,0 +1,418 @@
:root {
--bg-primary: #0a0e17;
--bg-card: #111827;
--bg-card-hover: #1a2332;
--bg-input: #1e293b;
--border: #1e3a5f;
--border-active: #f97316;
--text-primary: #e2e8f0;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--accent: #f97316;
--accent-glow: rgba(249, 115, 22, 0.3);
--success: #22c55e;
--success-bg: rgba(34, 197, 94, 0.1);
--warning: #f59e0b;
--warning-bg: rgba(245, 158, 11, 0.1);
--danger: #ef4444;
--danger-bg: rgba(239, 68, 68, 0.1);
--radius: 12px;
--radius-sm: 8px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
overflow-x: hidden;
}
body::before {
content: '';
position: fixed;
inset: 0;
background:
radial-gradient(ellipse at 20% 50%, rgba(249,115,22,0.08) 0%, transparent 50%),
radial-gradient(ellipse at 80% 20%, rgba(239,68,68,0.06) 0%, transparent 50%),
linear-gradient(180deg, rgba(249,115,22,0.02) 0%, transparent 40%);
pointer-events: none;
z-index: 0;
}
.app {
position: relative;
z-index: 1;
max-width: 540px;
margin: 0 auto;
padding: 16px;
padding-bottom: 80px;
}
/* Header */
.header {
text-align: center;
padding: 24px 0 20px;
}
.header-icon {
width: 56px;
height: 56px;
border-radius: 16px;
background: linear-gradient(135deg, #f97316, #ef4444);
display: inline-flex;
align-items: center;
justify-content: center;
margin-bottom: 12px;
box-shadow: 0 8px 32px rgba(249,115,22,0.3);
}
.header-icon svg {
width: 28px;
height: 28px;
fill: white;
}
.header h1 {
font-size: 22px;
font-weight: 700;
letter-spacing: -0.5px;
background: linear-gradient(135deg, #f97316, #ef4444);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.header p {
font-size: 13px;
color: var(--text-muted);
margin-top: 4px;
}
/* Status bar */
.status-bar {
display: flex;
gap: 8px;
margin-bottom: 20px;
overflow-x: auto;
scrollbar-width: none;
padding: 2px;
}
.status-bar::-webkit-scrollbar { display: none; }
.status-chip {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
background: var(--bg-card);
border: 1px solid var(--border);
}
.status-chip .dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--success);
box-shadow: 0 0 6px var(--success);
}
.status-chip .dot.warn { background: var(--warning); box-shadow: 0 0 6px var(--warning); }
.status-chip .dot.off { background: var(--text-muted); box-shadow: none; }
/* Section */
.section { margin-bottom: 16px; }
.section-title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1.2px;
color: var(--text-muted);
padding: 0 4px;
margin-bottom: 8px;
}
/* Cards */
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
margin-bottom: 8px;
padding: 12px 16px;
transition: border-color 0.2s;
}
.card:hover { border-color: rgba(249,115,22,0.3); }
.card-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 0;
gap: 8px;
flex-wrap: wrap;
}
.card-row + .card-row { border-top: 1px solid var(--border); }
.card-row-info { flex: 1; min-width: 0; }
.card-row-label { font-size: 14px; font-weight: 500; color: var(--text-primary); }
.card-row-desc { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
/* Toggle switch */
.toggle {
position: relative;
width: 44px;
height: 26px;
flex-shrink: 0;
margin-left: 12px;
}
.toggle input { opacity: 0; width: 0; height: 0; }
.toggle-slider {
position: absolute;
inset: 0;
background: var(--bg-input);
border-radius: 13px;
cursor: pointer;
transition: all 0.3s;
border: 1px solid var(--border);
}
.toggle-slider::before {
content: '';
position: absolute;
width: 20px;
height: 20px;
left: 2px;
bottom: 2px;
background: var(--text-muted);
border-radius: 50%;
transition: all 0.3s;
}
.toggle input:checked + .toggle-slider {
background: var(--accent);
border-color: var(--accent);
}
.toggle input:checked + .toggle-slider::before {
transform: translateX(18px);
background: white;
}
/* Info grid */
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1px;
background: var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.info-cell {
background: var(--bg-card);
padding: 12px 14px;
}
.info-label {
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.info-value {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
margin-top: 4px;
word-break: break-all;
}
/* Buttons */
.btn {
padding: 10px 16px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-card);
color: var(--text-primary);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
text-align: center;
display: inline-block;
margin: 4px;
}
.btn:hover { border-color: var(--accent); background: var(--bg-card-hover); }
.btn-danger { border-color: rgba(239,68,68,0.3); color: var(--danger); }
.btn-danger:hover { background: var(--danger-bg); border-color: var(--danger); }
.btn-secondary { border-color: rgba(139,92,246,0.3); color: #8b5cf6; }
.btn-secondary:hover { background: rgba(139,92,246,0.1); border-color: #8b5cf6; }
.btn-small { padding: 6px 12px; font-size: 12px; }
/* Input fields */
.input-text {
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 8px 12px;
color: var(--text-primary);
font-size: 13px;
outline: none;
transition: border-color 0.2s;
width: auto;
min-width: 80px;
}
.input-text:focus { border-color: var(--accent); }
.input-select {
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 8px 12px;
color: var(--text-primary);
font-size: 13px;
outline: none;
}
/* Log output */
.log-output {
background: #030712;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 8px 12px;
max-height: 300px;
overflow-y: auto;
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 11px;
line-height: 1.6;
color: var(--text-secondary);
margin-top: 8px;
white-space: pre-wrap;
word-break: break-all;
}
.mono {
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 12px;
color: var(--accent);
}
/* GPIO grid */
.gpio-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 6px;
}
.gpio-pin {
padding: 8px 4px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-input);
text-align: center;
font-size: 10px;
font-weight: 500;
color: var(--text-muted);
cursor: pointer;
transition: all 0.2s;
}
.gpio-pin.active { border-color: var(--accent); color: var(--accent); background: rgba(249,115,22,0.1); }
.gpio-pin.high { border-color: var(--success); color: var(--success); background: var(--success-bg); }
.gpio-pin.low { border-color: var(--danger); color: var(--danger); background: var(--danger-bg); }
.gpio-pin .pin-name { font-weight: 600; font-size: 11px; }
.gpio-pin .pin-val { font-size: 9px; margin-top: 2px; }
/* Toast */
.toast {
position: fixed;
bottom: 80px;
left: 50%;
transform: translateX(-50%) translateY(80px);
padding: 12px 20px;
border-radius: var(--radius-sm);
background: var(--bg-card);
border: 1px solid var(--border);
color: var(--text-primary);
font-size: 13px;
font-weight: 500;
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
opacity: 0;
transition: all 0.3s ease;
z-index: 100;
white-space: nowrap;
}
.toast.show { transform: translateX(-50%) translateY(0); opacity: 1; }
.toast.success { border-color: var(--success); }
.toast.error { border-color: var(--danger); }
/* Tab bar */
.tab-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: rgba(10,14,23,0.95);
backdrop-filter: blur(20px);
border-top: 1px solid var(--border);
display: flex;
justify-content: center;
gap: 4px;
padding: 8px 16px;
padding-bottom: max(8px, env(safe-area-inset-bottom));
z-index: 50;
}
.tab-item {
flex: 1;
max-width: 80px;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 6px 4px;
border-radius: var(--radius-sm);
color: var(--text-muted);
font-size: 10px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
border: none;
background: none;
}
.tab-item:hover { color: var(--text-secondary); }
.tab-item.active { color: var(--accent); }
.tab-item svg { width: 20px; height: 20px; fill: currentColor; }
/* Pages */
.page { display: none; }
.page.active { display: block; }
label {
font-size: 12px;
color: var(--text-muted);
white-space: nowrap;
}
@media (max-width: 380px) {
.info-grid { grid-template-columns: 1fr; }
.gpio-grid { grid-template-columns: repeat(3, 1fr); }
}

305
webroot/index.html Normal file
View File

@@ -0,0 +1,305 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#0a0e17">
<title>FlipperDroid</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<div class="app">
<div class="header">
<div class="header-icon">
<svg viewBox="0 0 24 24"><path d="M20 2H4c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8 2c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z"/></svg>
</div>
<h1>FlipperDroid</h1>
<p id="status-badge">Detecting Flipper...</p>
</div>
<div class="status-bar">
<div class="status-chip" id="chip-conn"><span class="dot off"></span> LINK</div>
<div class="status-chip" id="chip-gpio"><span class="dot off"></span> GPIO</div>
<div class="status-chip" id="chip-rf"><span class="dot off"></span> RF</div>
<div class="status-chip" id="chip-nfc"><span class="dot off"></span> NFC</div>
<div class="status-chip" id="chip-cpu"><span class="dot off"></span> CPU</div>
</div>
<!-- ==================== DASHBOARD ==================== -->
<div class="page active" id="page-dashboard">
<div class="section">
<div class="section-title">Connection</div>
<div class="card">
<div class="info-grid">
<div class="info-cell"><div class="info-label">Status</div><div class="info-value" id="info-status">-</div></div>
<div class="info-cell"><div class="info-label">Transport</div><div class="info-value" id="info-transport">-</div></div>
<div class="info-cell"><div class="info-label">Device</div><div class="info-value" id="info-device">-</div></div>
<div class="info-cell"><div class="info-label">Product</div><div class="info-value" id="info-product">-</div></div>
<div class="info-cell"><div class="info-label">Serial</div><div class="info-value" id="info-serial">-</div></div>
<div class="info-cell"><div class="info-label">Firmware</div><div class="info-value" id="info-firmware">-</div></div>
</div>
</div>
<div class="card" style="margin-top:12px">
<div class="card-row">
<div class="card-row-info">
<div class="card-row-label">Auto-Connect</div>
<div class="card-row-desc">Reconnect to Flipper on boot and disconnect</div>
</div>
<label class="toggle"><input type="checkbox" id="toggle-autoconnect" onchange="setConfig('AUTO_CONNECT', this.checked?1:0)"><span class="toggle-slider"></span></label>
</div>
<div class="card-row">
<div class="card-row-info">
<div class="card-row-label">CPU Sharing</div>
<div class="card-row-desc">Let Flipper offload compute to phone CPU</div>
</div>
<label class="toggle"><input type="checkbox" id="toggle-cpushare" onchange="setConfig('CPU_SHARE', this.checked?1:0)"><span class="toggle-slider"></span></label>
</div>
</div>
<button class="btn" onclick="reconnect()">Reconnect</button>
<button class="btn btn-secondary" onclick="pingFlipper()">Ping</button>
</div>
</div>
<!-- ==================== GPIO ==================== -->
<div class="page" id="page-gpio">
<div class="section">
<div class="section-title">GPIO Control</div>
<div class="card">
<div class="gpio-grid" id="gpio-grid"></div>
</div>
<div class="card" style="margin-top:12px">
<div class="section-title">Quick Actions</div>
<div class="card-row">
<select id="gpio-pin" class="input-select">
<option value="2">PA2</option><option value="3">PA3</option>
<option value="4">PA4</option><option value="6">PA6</option>
<option value="7">PA7</option><option value="13">PB2</option>
<option value="14">PB3</option><option value="15">PB13</option>
<option value="16">PB14</option><option value="17">PC0</option>
<option value="18">PC1</option><option value="19">PC3</option>
</select>
<select id="gpio-mode" class="input-select">
<option value="0">Input</option>
<option value="1">Output Push-Pull</option>
<option value="2">Output Open-Drain</option>
<option value="3">Analog</option>
</select>
<button class="btn btn-small" onclick="gpioInit()">Init</button>
</div>
<div class="card-row">
<button class="btn btn-small" onclick="gpioRead()">Read</button>
<button class="btn btn-small" onclick="gpioWrite(1)">HIGH</button>
<button class="btn btn-small btn-danger" onclick="gpioWrite(0)">LOW</button>
<span id="gpio-result" class="mono"></span>
</div>
<div class="card-row">
<label>PWM Freq:</label>
<input type="number" id="gpio-pwm-freq" class="input-text" value="1000" min="1" max="1000000">
<label>Duty:</label>
<input type="number" id="gpio-pwm-duty" class="input-text" value="50" min="0" max="100">
<button class="btn btn-small" onclick="gpioPwm()">Set PWM</button>
</div>
<div class="card-row">
<button class="btn btn-small" onclick="gpioAdc()">Read ADC</button>
<span id="gpio-adc-result" class="mono"></span>
</div>
</div>
</div>
</div>
<!-- ==================== SubGHz ==================== -->
<div class="page" id="page-subghz">
<div class="section">
<div class="section-title">Sub-GHz Radio</div>
<div class="card">
<div class="card-row">
<label>Frequency (Hz):</label>
<input type="number" id="subghz-freq" class="input-text" value="433920000">
<button class="btn btn-small" onclick="subghzSetFreq()">Set</button>
</div>
<div class="card-row">
<button class="btn" onclick="subghzRxStart()">Start RX</button>
<button class="btn btn-danger" onclick="subghzRxStop()">Stop RX</button>
<button class="btn btn-secondary" onclick="subghzGetRssi()">RSSI</button>
<span id="subghz-rssi" class="mono"></span>
</div>
<div class="card-row">
<label>TX Data (hex):</label>
<input type="text" id="subghz-tx-data" class="input-text" placeholder="deadbeef">
<button class="btn btn-small" onclick="subghzTx()">Transmit</button>
</div>
<div class="card-row">
<label>Replay slot:</label>
<input type="number" id="subghz-replay-slot" class="input-text" value="0" min="0" max="9">
<button class="btn btn-small" onclick="subghzReplay()">Replay</button>
</div>
</div>
<div class="section-title" style="margin-top:16px">Captured Signals</div>
<div class="card">
<div class="log-output" id="subghz-log"></div>
</div>
</div>
</div>
<!-- ==================== NFC/RFID ==================== -->
<div class="page" id="page-nfc">
<div class="section">
<div class="section-title">NFC (13.56 MHz)</div>
<div class="card">
<button class="btn" onclick="nfcPoll()">Poll Card</button>
<button class="btn" onclick="nfcReadFull()">Full Read</button>
<button class="btn btn-secondary" onclick="nfcRelayStart()">Start Relay</button>
<button class="btn btn-danger" onclick="nfcRelayStop()">Stop Relay</button>
<div class="log-output" id="nfc-log"></div>
</div>
<div class="section-title" style="margin-top:16px">RFID (125 kHz)</div>
<div class="card">
<button class="btn" onclick="rfidRead()">Read Tag</button>
<div class="card-row">
<label>Emulate UID (hex):</label>
<input type="text" id="rfid-emu-data" class="input-text" placeholder="0102030405">
<button class="btn btn-small" onclick="rfidEmulate()">Emulate</button>
</div>
<div class="log-output" id="rfid-log"></div>
</div>
</div>
</div>
<!-- ==================== IR ==================== -->
<div class="page" id="page-ir">
<div class="section">
<div class="section-title">Infrared</div>
<div class="card">
<div class="card-row">
<label>Protocol:</label>
<select id="ir-proto" class="input-select">
<option value="0">NEC</option><option value="1">Samsung</option>
<option value="2">RC5</option><option value="3">RC6</option>
<option value="4">SIRC</option><option value="5">Kaseikyo</option>
</select>
<label>Addr:</label>
<input type="number" id="ir-addr" class="input-text" value="0">
<label>Cmd:</label>
<input type="number" id="ir-cmd" class="input-text" value="0">
<button class="btn btn-small" onclick="irTx()">Send</button>
</div>
<div class="card-row">
<button class="btn" onclick="irRxStart()">Start Learn</button>
<button class="btn btn-danger" onclick="irRxStop()">Stop</button>
<label>Replay:</label>
<input type="number" id="ir-replay-slot" class="input-text" value="0" min="0" max="9">
<button class="btn btn-small" onclick="irReplay()">Replay</button>
</div>
<div class="log-output" id="ir-log"></div>
</div>
</div>
</div>
<!-- ==================== EVENTS ==================== -->
<div class="page" id="page-events">
<div class="section">
<div class="section-title">Live Events</div>
<div class="card">
<button class="btn btn-small" onclick="loadEvents()">Refresh</button>
<div class="log-output" id="events-log" style="max-height:60vh"></div>
</div>
<div class="section-title" style="margin-top:16px">System Log</div>
<div class="card">
<button class="btn btn-small" onclick="loadSysLog()">Load Log</button>
<div class="log-output" id="sys-log" style="max-height:40vh"></div>
</div>
</div>
</div>
<!-- ==================== STEALTH ==================== -->
<div class="page" id="page-stealth">
<div class="section">
<div class="section-title">Namespace Isolation</div>
<div class="card">
<div class="card-row">
<div class="card-row-info">
<div class="card-row-label">Stealth Status</div>
<div class="card-row-desc">Bind mounts, port firewall, config hiding</div>
</div>
<span id="stealth-status-text" class="mono">-</span>
</div>
<div class="card-row">
<button class="btn" onclick="stealthApply()">Apply Stealth</button>
<button class="btn btn-danger" onclick="stealthTeardown()">Teardown</button>
<button class="btn btn-secondary" onclick="stealthStatus()">Status</button>
</div>
</div>
<div class="section-title" style="margin-top:16px">Quick Controls</div>
<div class="card">
<div class="card-row">
<div class="card-row-info">
<div class="card-row-label">Hide Device + Port</div>
<div class="card-row-desc">Firewall WebUI to localhost, restrict config perms</div>
</div>
<button class="btn btn-small" onclick="stealthHideDev()">Hide</button>
<button class="btn btn-small" onclick="stealthShowDev()">Show</button>
</div>
</div>
<div class="section-title" style="margin-top:16px">Stealth Map</div>
<div class="card">
<div class="card-row-desc" style="padding:8px">
Edit <code>/data/adb/flipperdroid/stealth_map.conf</code> to configure per-process bind mount isolation.
Stock files stay untouched. Only target processes see modifications.
</div>
<div class="card-row">
<label>Active bind mounts:</label>
<span id="stealth-mount-count" class="mono">-</span>
</div>
<div class="log-output" id="stealth-log"></div>
</div>
</div>
</div>
<!-- Toast -->
<div id="toast" class="toast"></div>
<!-- Tab bar -->
<div class="tab-bar">
<div class="tab-item active" data-tab="dashboard" onclick="switchTab('dashboard')">
<svg viewBox="0 0 24 24"><path d="M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z"/></svg>
<span>Home</span>
</div>
<div class="tab-item" data-tab="gpio" onclick="switchTab('gpio')">
<svg viewBox="0 0 24 24"><path d="M7 2v11h3v9l7-12h-4l4-8z"/></svg>
<span>GPIO</span>
</div>
<div class="tab-item" data-tab="subghz" onclick="switchTab('subghz')">
<svg viewBox="0 0 24 24"><path d="M1 9l2 2c4.97-4.97 13.03-4.97 18 0l2-2C16.93 2.93 7.08 2.93 1 9zm8 8l3 3 3-3c-1.65-1.66-4.34-1.66-6 0zm-4-4l2 2c2.76-2.76 7.24-2.76 10 0l2-2C15.14 9.14 8.87 9.14 5 13z"/></svg>
<span>SubGHz</span>
</div>
<div class="tab-item" data-tab="nfc" onclick="switchTab('nfc')">
<svg viewBox="0 0 24 24"><path d="M20 2H4c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 18H4V4h16v16zM6 6h2v2H6zm0 4h2v2H6zm0 4h2v2H6zm4-8h8v2h-8zm0 4h8v2h-8zm0 4h8v2h-8z"/></svg>
<span>NFC</span>
</div>
<div class="tab-item" data-tab="ir" onclick="switchTab('ir')">
<svg viewBox="0 0 24 24"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14zM9 8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg>
<span>IR</span>
</div>
<div class="tab-item" data-tab="stealth" onclick="switchTab('stealth')">
<svg viewBox="0 0 24 24"><path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z"/></svg>
<span>Stealth</span>
</div>
<div class="tab-item" data-tab="events" onclick="switchTab('events')">
<svg viewBox="0 0 24 24"><path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm4 18H6V4h7v5h5v11z"/></svg>
<span>Log</span>
</div>
</div>
</div>
<script src="/js/app.js"></script>
</body>
</html>

376
webroot/js/app.js Normal file
View File

@@ -0,0 +1,376 @@
// FlipperDroid WebUI — Bridge Controller
let config = {};
let currentTab = 'dashboard';
let toastTimer = null;
let eventPollInterval = null;
// ---- API ----
async function api(path, opts = {}) {
try {
const res = await fetch(path, opts);
if (path.endsWith('.css') || path.endsWith('.js') || path.endsWith('.html')) return res;
const ct = res.headers.get('content-type') || '';
if (ct.includes('json')) return await res.json();
return await res.text();
} catch (e) {
showToast('Connection error', 'error');
return null;
}
}
const post = (path, body) => api(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
// ---- Toast ----
function showToast(msg, type = 'success') {
const el = document.getElementById('toast');
el.textContent = msg;
el.className = 'toast ' + type + ' show';
clearTimeout(toastTimer);
toastTimer = setTimeout(() => el.className = 'toast', 2500);
}
// ---- Tabs ----
function switchTab(tab) {
currentTab = tab;
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.tab-item').forEach(t => t.classList.remove('active'));
document.getElementById('page-' + tab).classList.add('active');
document.querySelector(`[data-tab="${tab}"]`).classList.add('active');
if (tab === 'events') loadEvents();
if (tab === 'gpio') initGpioGrid();
if (tab === 'stealth') stealthStatus();
}
// ---- Dashboard ----
async function loadStatus() {
const data = await api('/api/status');
if (!data) return;
config = data;
setText('info-status', data.status || 'unknown');
setText('info-transport', data.conn_type || 'none');
setText('info-device', data.device || '-');
setText('info-product', data.product || '-');
setText('info-serial', data.serial || '-');
setText('info-firmware', data.firmware_version || '-');
const badge = document.getElementById('status-badge');
if (data.status === 'connected') {
badge.textContent = `Connected — ${data.conn_type?.toUpperCase() || 'USB'}${data.product || 'Flipper Zero'}`;
} else {
badge.textContent = 'Disconnected';
}
// Status chips
setChip('chip-conn', data.status === 'connected');
setChip('chip-gpio', data.status === 'connected' && data.config?.enable_gpio);
setChip('chip-rf', data.status === 'connected' && data.config?.enable_subghz);
setChip('chip-nfc', data.status === 'connected' && data.config?.enable_nfc);
setChip('chip-cpu', data.status === 'connected' && data.config?.cpu_share);
// Toggles
if (data.config) {
setToggle('toggle-autoconnect', data.config.auto_connect);
setToggle('toggle-cpushare', data.config.cpu_share);
}
}
function setChip(id, on) {
const chip = document.getElementById(id);
if (!chip) return;
const dot = chip.querySelector('.dot');
if (dot) { dot.className = on ? 'dot' : 'dot off'; }
}
function setText(id, val) {
const el = document.getElementById(id);
if (el) el.textContent = val;
}
function setToggle(id, val) {
const el = document.getElementById(id);
if (el) el.checked = !!val;
}
async function setConfig(key, value) {
const r = await post('/api/config', { key, value: String(value) });
if (r && r.ok) showToast('Config updated');
else showToast('Config update failed', 'error');
}
async function reconnect() {
await post('/api/reconnect');
showToast('Reconnecting...');
setTimeout(loadStatus, 3000);
}
async function pingFlipper() {
const r = await post('/api/system/ping');
if (r && r.result && r.result.startsWith('OK')) showToast('Pong!');
else showToast('Ping failed: ' + (r?.result || 'no response'), 'error');
}
// ---- GPIO ----
const GPIO_PINS = [
{ num: 2, name: 'PA2' }, { num: 3, name: 'PA3' }, { num: 4, name: 'PA4' },
{ num: 6, name: 'PA6' }, { num: 7, name: 'PA7' }, { num: 13, name: 'PB2' },
{ num: 14, name: 'PB3' }, { num: 15, name: 'PB13' }, { num: 16, name: 'PB14' },
{ num: 17, name: 'PC0' }, { num: 18, name: 'PC1' }, { num: 19, name: 'PC3' }
];
function initGpioGrid() {
const grid = document.getElementById('gpio-grid');
if (!grid || grid.children.length > 0) return;
GPIO_PINS.forEach(pin => {
const div = document.createElement('div');
div.className = 'gpio-pin';
div.id = 'gpio-pin-' + pin.num;
div.innerHTML = `<div class="pin-name">${pin.name}</div><div class="pin-val">-</div>`;
div.onclick = () => quickGpioRead(pin.num);
grid.appendChild(div);
});
}
async function quickGpioRead(pinNum) {
const r = await post('/api/gpio/read', { pin: pinNum });
const el = document.getElementById('gpio-pin-' + pinNum);
if (r && r.result && r.result.startsWith('OK:')) {
const val = r.result.replace('OK:', '');
const valEl = el.querySelector('.pin-val');
valEl.textContent = val === '01' || val === '1' ? 'HIGH' : 'LOW';
el.className = 'gpio-pin ' + (val === '01' || val === '1' ? 'high' : 'low');
}
}
async function gpioInit() {
const pin = parseInt(document.getElementById('gpio-pin').value);
const mode = parseInt(document.getElementById('gpio-mode').value);
const r = await post('/api/gpio/init', { pin, mode });
showToast(r?.result || 'No response');
}
async function gpioRead() {
const pin = parseInt(document.getElementById('gpio-pin').value);
const r = await post('/api/gpio/read', { pin });
document.getElementById('gpio-result').textContent = r?.result || '-';
}
async function gpioWrite(val) {
const pin = parseInt(document.getElementById('gpio-pin').value);
const r = await post('/api/gpio/write', { pin, value: val });
showToast(r?.result || 'No response');
}
async function gpioPwm() {
const pin = parseInt(document.getElementById('gpio-pin').value);
const freq = parseInt(document.getElementById('gpio-pwm-freq').value);
const duty = parseInt(document.getElementById('gpio-pwm-duty').value);
const r = await post('/api/gpio/pwm', { pin, freq, duty });
showToast(r?.result || 'No response');
}
async function gpioAdc() {
const pin = parseInt(document.getElementById('gpio-pin').value);
const r = await post('/api/gpio/adc', { pin });
document.getElementById('gpio-adc-result').textContent = r?.result || '-';
}
// ---- SubGHz ----
async function subghzSetFreq() {
const freq = parseInt(document.getElementById('subghz-freq').value);
const r = await post('/api/subghz/set_freq', { freq });
showToast(r?.result || 'No response');
}
async function subghzTx() {
const data = document.getElementById('subghz-tx-data').value;
const r = await post('/api/subghz/tx', { data });
showToast(r?.result || 'No response');
}
async function subghzRxStart() {
const r = await post('/api/subghz/rx_start');
showToast(r?.result || 'No response');
}
async function subghzRxStop() {
const r = await post('/api/subghz/rx_stop');
showToast(r?.result || 'No response');
}
async function subghzGetRssi() {
const r = await post('/api/subghz/get_rssi');
document.getElementById('subghz-rssi').textContent = r?.result || '-';
}
async function subghzReplay() {
const slot = parseInt(document.getElementById('subghz-replay-slot').value);
const r = await post('/api/subghz/replay', { slot });
showToast(r?.result || 'No response');
}
// ---- NFC ----
async function nfcPoll() {
appendLog('nfc-log', 'Polling for NFC card...');
const r = await post('/api/nfc/poll');
appendLog('nfc-log', r?.result || 'No response');
}
async function nfcReadFull() {
appendLog('nfc-log', 'Reading full card...');
const r = await post('/api/nfc/read_full');
appendLog('nfc-log', r?.result || 'No response');
}
async function nfcRelayStart() {
const r = await post('/api/nfc/relay_start');
showToast(r?.result || 'No response');
}
async function nfcRelayStop() {
const r = await post('/api/nfc/relay_stop');
showToast(r?.result || 'No response');
}
// ---- RFID ----
async function rfidRead() {
appendLog('rfid-log', 'Reading RFID tag...');
const r = await post('/api/rfid/read');
appendLog('rfid-log', r?.result || 'No response');
}
async function rfidEmulate() {
const data = document.getElementById('rfid-emu-data').value;
const r = await post('/api/rfid/emulate', { data });
showToast(r?.result || 'No response');
}
// ---- IR ----
async function irTx() {
const protocol = parseInt(document.getElementById('ir-proto').value);
const address = parseInt(document.getElementById('ir-addr').value);
const command = parseInt(document.getElementById('ir-cmd').value);
const r = await post('/api/ir/tx', { protocol, address, command });
showToast(r?.result || 'No response');
}
async function irRxStart() {
const r = await post('/api/ir/rx_start');
showToast(r?.result || 'No response');
}
async function irRxStop() {
const r = await post('/api/ir/rx_stop');
showToast(r?.result || 'No response');
}
async function irReplay() {
const slot = parseInt(document.getElementById('ir-replay-slot').value);
const r = await post('/api/ir/replay', { slot });
showToast(r?.result || 'No response');
}
// ---- Stealth ----
async function stealthStatus() {
const r = await api('/api/stealth/status');
const el = document.getElementById('stealth-status-text');
const countEl = document.getElementById('stealth-mount-count');
const logEl = document.getElementById('stealth-log');
if (r && typeof r === 'object') {
el.textContent = r.active_bind_mounts > 0 ? 'ACTIVE' : 'INACTIVE';
el.style.color = r.active_bind_mounts > 0 ? 'var(--success)' : 'var(--text-muted)';
countEl.textContent = r.active_bind_mounts || '0';
if (logEl) {
logEl.textContent = JSON.stringify(r, null, 2);
}
} else {
el.textContent = 'unavailable';
}
}
async function stealthApply() {
const r = await post('/api/stealth/apply');
showToast(r?.message || 'Stealth applied');
setTimeout(stealthStatus, 1000);
}
async function stealthTeardown() {
const r = await post('/api/stealth/teardown');
showToast(r?.message || 'Stealth removed');
setTimeout(stealthStatus, 1000);
}
async function stealthHideDev() {
const r = await post('/api/stealth/hide');
showToast(r?.message || 'Device hidden');
setTimeout(stealthStatus, 1000);
}
async function stealthShowDev() {
const r = await post('/api/stealth/show');
showToast(r?.message || 'Device visible');
setTimeout(stealthStatus, 1000);
}
// ---- Events ----
async function loadEvents() {
const data = await api('/api/events');
const log = document.getElementById('events-log');
if (!log) return;
if (Array.isArray(data)) {
log.textContent = data.map(e =>
`[${new Date(e.timestamp * 1000).toLocaleTimeString()}] ${e.type}: ${e.data} ${e.extra || ''}`
).join('\n') || 'No events yet';
} else {
log.textContent = data || 'No events';
}
}
async function loadSysLog() {
const data = await api('/api/log');
const log = document.getElementById('sys-log');
if (log) log.textContent = data || 'No log data';
}
// ---- Helpers ----
function appendLog(id, msg) {
const el = document.getElementById(id);
if (!el) return;
const ts = new Date().toLocaleTimeString();
el.textContent += `[${ts}] ${msg}\n`;
el.scrollTop = el.scrollHeight;
}
// ---- Init ----
document.addEventListener('DOMContentLoaded', () => {
loadStatus();
setInterval(loadStatus, 5000);
// Poll events when on events tab
setInterval(() => {
if (currentTab === 'events') loadEvents();
}, 3000);
// Poll SubGHz log when on subghz tab
setInterval(async () => {
if (currentTab === 'subghz') {
const data = await api('/api/events');
if (Array.isArray(data)) {
const rfEvents = data.filter(e => e.type === 'subghz_rx');
if (rfEvents.length > 0) {
const log = document.getElementById('subghz-log');
if (log) {
log.textContent = rfEvents.map(e =>
`[${new Date(e.timestamp * 1000).toLocaleTimeString()}] RX: ${e.data} (${e.extra})`
).join('\n');
}
}
}
}
}, 2000);
});