From be81a92d440b977f54bc347e113e7154d756ce37 Mon Sep 17 00:00:00 2001 From: sssnake Date: Tue, 31 Mar 2026 21:26:58 -0700 Subject: [PATCH] =?UTF-8?q?Initial=20commit=20=E2=80=94=20FlipperDroid=20v?= =?UTF-8?q?0.1.0-poc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- META-INF/com/google/android/update-binary | 62 +++ META-INF/com/google/android/updater-script | 1 + README.md | 116 +++++ flipper/application.fam | 19 + flipper/fd_app.h | 114 +++++ flipper/fd_bridge.c | 526 +++++++++++++++++++++ flipper/fd_protocol.h | 90 ++++ flipper/flipperdroid_app.c | 351 ++++++++++++++ module.prop | 6 + post-fs-data.sh | 53 +++ research.md | 169 +++++++ sepolicy/flipperdroid.rule | 37 ++ service.sh | 188 ++++++++ stealth/stealth_map.conf.example | 21 + system/bin/fd-stealth | 376 +++++++++++++++ system/bin/flipperdroid-webui | 365 ++++++++++++++ system/bin/flipperdroidd | 481 +++++++++++++++++++ system/etc/flipperdroid/protocol.md | 96 ++++ uninstall.sh | 21 + webroot/css/style.css | 418 ++++++++++++++++ webroot/index.html | 305 ++++++++++++ webroot/js/app.js | 376 +++++++++++++++ 22 files changed, 4191 insertions(+) create mode 100644 META-INF/com/google/android/update-binary create mode 100644 META-INF/com/google/android/updater-script create mode 100644 README.md create mode 100644 flipper/application.fam create mode 100644 flipper/fd_app.h create mode 100644 flipper/fd_bridge.c create mode 100644 flipper/fd_protocol.h create mode 100644 flipper/flipperdroid_app.c create mode 100644 module.prop create mode 100644 post-fs-data.sh create mode 100644 research.md create mode 100644 sepolicy/flipperdroid.rule create mode 100644 service.sh create mode 100644 stealth/stealth_map.conf.example create mode 100644 system/bin/fd-stealth create mode 100644 system/bin/flipperdroid-webui create mode 100644 system/bin/flipperdroidd create mode 100644 system/etc/flipperdroid/protocol.md create mode 100644 uninstall.sh create mode 100644 webroot/css/style.css create mode 100644 webroot/index.html create mode 100644 webroot/js/app.js diff --git a/META-INF/com/google/android/update-binary b/META-INF/com/google/android/update-binary new file mode 100644 index 0000000..77222d9 --- /dev/null +++ b/META-INF/com/google/android/update-binary @@ -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" diff --git a/META-INF/com/google/android/updater-script b/META-INF/com/google/android/updater-script new file mode 100644 index 0000000..11d5c96 --- /dev/null +++ b/META-INF/com/google/android/updater-script @@ -0,0 +1 @@ +#MAGISK diff --git a/README.md b/README.md new file mode 100644 index 0000000..4fdc81a --- /dev/null +++ b/README.md @@ -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. diff --git a/flipper/application.fam b/flipper/application.fam new file mode 100644 index 0000000..d3d9966 --- /dev/null +++ b/flipper/application.fam @@ -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", +) diff --git a/flipper/fd_app.h b/flipper/fd_app.h new file mode 100644 index 0000000..be22533 --- /dev/null +++ b/flipper/fd_app.h @@ -0,0 +1,114 @@ +/** + * FlipperDroid — application state + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#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); diff --git a/flipper/fd_bridge.c b/flipper/fd_bridge.c new file mode 100644 index 0000000..60d7be4 --- /dev/null +++ b/flipper/fd_bridge.c @@ -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 +#include +#include +#include +#include + +#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; +} diff --git a/flipper/fd_protocol.h b/flipper/fd_protocol.h new file mode 100644 index 0000000..6f233e2 --- /dev/null +++ b/flipper/fd_protocol.h @@ -0,0 +1,90 @@ +/** + * FlipperDroid Bridge Protocol v0.1 + * Shared definitions between all source files. + */ + +#pragma once + +#include +#include + +/* 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; +} diff --git a/flipper/flipperdroid_app.c b/flipper/flipperdroid_app.c new file mode 100644 index 0000000..e675fba --- /dev/null +++ b/flipper/flipperdroid_app.c @@ -0,0 +1,351 @@ +/** + * FlipperDroid — main application entry point + * Handles GUI, input, lifecycle. Workers in fd_bridge.c. + */ + +#include "fd_app.h" +#include +#include + +#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, " 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, "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, "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, "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; +} diff --git a/module.prop b/module.prop new file mode 100644 index 0000000..d5aec79 --- /dev/null +++ b/module.prop @@ -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. diff --git a/post-fs-data.sh b/post-fs-data.sh new file mode 100644 index 0000000..6440bea --- /dev/null +++ b/post-fs-data.sh @@ -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 diff --git a/research.md b/research.md new file mode 100644 index 0000000..4d0ccb8 --- /dev/null +++ b/research.md @@ -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_` 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 diff --git a/sepolicy/flipperdroid.rule b/sepolicy/flipperdroid.rule new file mode 100644 index 0000000..10c9a95 --- /dev/null +++ b/sepolicy/flipperdroid.rule @@ -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 } diff --git a/service.sh b/service.sh new file mode 100644 index 0000000..5413141 --- /dev/null +++ b/service.sh @@ -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 " + 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))" diff --git a/stealth/stealth_map.conf.example b/stealth/stealth_map.conf.example new file mode 100644 index 0000000..49da417 --- /dev/null +++ b/stealth/stealth_map.conf.example @@ -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 diff --git a/system/bin/fd-stealth b/system/bin/fd-stealth new file mode 100644 index 0000000..b2cd99c --- /dev/null +++ b/system/bin/fd-stealth @@ -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 + 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 + 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 " + 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 diff --git a/system/bin/flipperdroid-webui b/system/bin/flipperdroid-webui new file mode 100644 index 0000000..09437a6 --- /dev/null +++ b/system/bin/flipperdroid-webui @@ -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 diff --git a/system/bin/flipperdroidd b/system/bin/flipperdroidd new file mode 100644 index 0000000..6ddd6fb --- /dev/null +++ b/system/bin/flipperdroidd @@ -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:-}" + + # 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 diff --git a/system/etc/flipperdroid/protocol.md b/system/etc/flipperdroid/protocol.md new file mode 100644 index 0000000..26ee93b --- /dev/null +++ b/system/etc/flipperdroid/protocol.md @@ -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 diff --git a/uninstall.sh b/uninstall.sh new file mode 100644 index 0000000..34be295 --- /dev/null +++ b/uninstall.sh @@ -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" diff --git a/webroot/css/style.css b/webroot/css/style.css new file mode 100644 index 0000000..2dce9b9 --- /dev/null +++ b/webroot/css/style.css @@ -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); } +} diff --git a/webroot/index.html b/webroot/index.html new file mode 100644 index 0000000..ad1e9e9 --- /dev/null +++ b/webroot/index.html @@ -0,0 +1,305 @@ + + + + + + + FlipperDroid + + + +
+ +
+
+ +
+

FlipperDroid

+

Detecting Flipper...

+
+ +
+
LINK
+
GPIO
+
RF
+
NFC
+
CPU
+
+ + +
+
+
Connection
+
+
+
Status
-
+
Transport
-
+
Device
-
+
Product
-
+
Serial
-
+
Firmware
-
+
+
+ +
+
+
+
Auto-Connect
+
Reconnect to Flipper on boot and disconnect
+
+ +
+
+
+
CPU Sharing
+
Let Flipper offload compute to phone CPU
+
+ +
+
+ + + +
+
+ + +
+
+
GPIO Control
+
+
+
+ +
+
Quick Actions
+
+ + + +
+
+ + + + +
+
+ + + + + +
+
+ + +
+
+
+
+ + +
+
+
Sub-GHz Radio
+
+
+ + + +
+
+ + + + +
+
+ + + +
+
+ + + +
+
+ +
Captured Signals
+
+
+
+
+
+ + +
+
+
NFC (13.56 MHz)
+
+ + + + +
+
+ +
RFID (125 kHz)
+
+ +
+ + + +
+
+
+
+
+ + +
+
+
Infrared
+
+
+ + + + + + + +
+
+ + + + + +
+
+
+
+
+ + +
+
+
Live Events
+
+ +
+
+ +
System Log
+
+ +
+
+
+
+ + +
+
+
Namespace Isolation
+
+
+
+
Stealth Status
+
Bind mounts, port firewall, config hiding
+
+ - +
+
+ + + +
+
+ +
Quick Controls
+
+
+
+
Hide Device + Port
+
Firewall WebUI to localhost, restrict config perms
+
+ + +
+
+ +
Stealth Map
+
+
+ Edit /data/adb/flipperdroid/stealth_map.conf to configure per-process bind mount isolation. + Stock files stay untouched. Only target processes see modifications. +
+
+ + - +
+
+
+
+
+ + +
+ + +
+
+ + Home +
+
+ + GPIO +
+
+ + SubGHz +
+
+ + NFC +
+
+ + IR +
+
+ + Stealth +
+
+ + Log +
+
+ +
+ + + diff --git a/webroot/js/app.js b/webroot/js/app.js new file mode 100644 index 0000000..ad9c1e8 --- /dev/null +++ b/webroot/js/app.js @@ -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 = `
${pin.name}
-
`; + 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); +});