From e12381f6da75fc482028b91155606b72f2b376ab Mon Sep 17 00:00:00 2001 From: andy Date: Fri, 11 Sep 2026 14:08:30 +0100 Subject: [PATCH] Add BLE GATT Fuzzer app Connects, walks every service/characteristic, reads all readable values, and (on OK) writes boundary payloads to writable characteristics, logging every response. For authorised testing. Reuses libble. --- ble_gattfuzz/application.fam | 15 ++ ble_gattfuzz/ble_gattfuzz.c | 434 +++++++++++++++++++++++++++++++++++ ble_gattfuzz/icon.png | Bin 0 -> 95 bytes build.sh | 2 +- 4 files changed, 450 insertions(+), 1 deletion(-) create mode 100644 ble_gattfuzz/application.fam create mode 100644 ble_gattfuzz/ble_gattfuzz.c create mode 100644 ble_gattfuzz/icon.png diff --git a/ble_gattfuzz/application.fam b/ble_gattfuzz/application.fam new file mode 100644 index 0000000..10fdf43 --- /dev/null +++ b/ble_gattfuzz/application.fam @@ -0,0 +1,15 @@ +# Needs firmware from ../build.sh (full BLE stack). Staged with libble/. +App( + appid="ble_gattfuzz", + name="BLE GATT Fuzzer", + apptype=FlipperAppType.EXTERNAL, + entry_point="ble_gattfuzz_app", + requires=["gui", "storage"], + stack_size=4 * 1024, + fap_category="Bluetooth", + fap_icon="icon.png", + fap_version="1.0", + fap_description="Enumerate, read and fuzz a peripheral's GATT database, logging responses", + fap_author="andydixon", + fap_weburl="https://github.com/andydixon/flipper-bt-inspector", +) diff --git a/ble_gattfuzz/ble_gattfuzz.c b/ble_gattfuzz/ble_gattfuzz.c new file mode 100644 index 0000000..1fc64f4 --- /dev/null +++ b/ble_gattfuzz/ble_gattfuzz.c @@ -0,0 +1,434 @@ +// BLE GATT Fuzzer / logger: connect to a selected peripheral, walk every +// service and characteristic, read all readable values, and (optionally) write +// a set of boundary payloads to writable characteristics, logging every +// response and error. For authorised testing of devices you own. Reuses the +// libble central/GATT layer. +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "ble_central.h" +#include "ble_names.h" + +#define TAG "BleGattFuzz" +#define LOG_DIR EXT_PATH("apps_data/ble_gattfuzz") +#define ROWS 3 +#define FEED_MAX 64 +#define FEED_LINE 40 + +typedef enum { ViewScan, ViewFeed } ViewId; +typedef enum { EvtTick, EvtSelect, EvtFeed, EvtJobDone } AppEvent; +typedef enum { JobRun, JobFuzz, JobDisconnect, JobQuit } JobType; + +typedef struct { + BcDevice devs[BC_MAX_DEVICES]; + size_t count, sel, top; +} ScanModel; + +typedef struct { + char lines[FEED_MAX][FEED_LINE]; + int count; + size_t scroll; + char header[40]; + char status[28]; +} FeedModel; + +typedef struct { + Gui* gui; + ViewDispatcher* vd; + View* scan_view; + View* feed_view; + FuriTimer* timer; + Storage* storage; + File* log; + BleCentral* bc; + FuriThread* worker; + FuriMessageQueue* jobs; + ViewId cur; + BcDevice dev; + BcService services[BC_MAX_SERVICES]; + BcChar chars[BC_MAX_CHARS]; + bool fuzz_enabled; +} App; + +static const char* dev_label(const BcDevice* d) { + return d->name[0] ? d->name : "(no name)"; +} + +static void feed(App* app, const char* fmt, ...) { + char line[FEED_LINE]; + va_list va; + va_start(va, fmt); + vsnprintf(line, sizeof(line), fmt, va); + va_end(va); + if(app->log) { + storage_file_write(app->log, line, strlen(line)); + storage_file_write(app->log, "\n", 1); + storage_file_sync(app->log); + } + with_view_model( + app->feed_view, + FeedModel * m, + { + strncpy(m->lines[m->count % FEED_MAX], line, FEED_LINE - 1); + m->lines[m->count % FEED_MAX][FEED_LINE - 1] = 0; + m->count++; + m->scroll = 0; + }, + app->cur == ViewFeed); +} + +static void feed_status(App* app, const char* status) { + with_view_model( + app->feed_view, FeedModel * m, { strncpy(m->status, status, sizeof(m->status) - 1); }, + app->cur == ViewFeed); +} + +static uint16_t char_end(App* app, int svc, int idx, int nch) { + if(idx + 1 < nch) return app->chars[idx + 1].decl_handle - 1; + return app->services[svc].end; +} + +// ---- worker: enumerate + read (+ optional fuzz) ----------------------------- + +static void run_enum(App* app, bool fuzz) { + if(!bc_is_connected(app->bc)) { + if(!bc_connect(app->bc, &app->dev, 8000)) { + feed(app, "connect failed"); + feed_status(app, "connect failed"); + return; + } + feed(app, "connected, mtu %u", bc_mtu(app->bc)); + } + int nsvc = bc_discover_services(app->bc, app->services, BC_MAX_SERVICES); + if(nsvc < 0) { + feed(app, "service discovery failed"); + return; + } + feed_status(app, fuzz ? "fuzzing..." : "reading..."); + // Boundary payloads for the fuzz pass. + static const uint8_t p_zero[1] = {0x00}; + static const uint8_t p_ff[1] = {0xFF}; + static const uint8_t p_long[20] = + {0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, + 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41}; + struct { + const uint8_t* d; + uint8_t len; + const char* tag; + } pats[] = {{NULL, 0, "empty"}, {p_zero, 1, "0x00"}, {p_ff, 1, "0xFF"}, {p_long, 20, "20xA"}}; + + uint8_t val[BC_VALUE_MAX]; + FuriString* s = furi_string_alloc(); + for(int si = 0; si < nsvc; si++) { + furi_string_reset(s); + ble_uuid_str(s, app->services[si].uuid, app->services[si].uuid_len); + const char* sn = ble_uuid_name(app->services[si].uuid, app->services[si].uuid_len); + feed(app, "SVC %s %s", furi_string_get_cstr(s), sn ? sn : ""); + int nch = bc_discover_chars(app->bc, &app->services[si], app->chars, BC_MAX_CHARS); + for(int ci = 0; ci < nch; ci++) { + BcChar* c = &app->chars[ci]; + furi_string_reset(s); + ble_uuid_str(s, c->uuid, c->uuid_len); + if(c->props & CHAR_PROP_READ) { + int n = bc_read(app->bc, c->value_handle, val, BC_VALUE_MAX); + if(n >= 0) { + FuriString* h = furi_string_alloc(); + ble_hex(h, val, n < 8 ? n : 8); + feed(app, " R %04X=%s", c->value_handle, furi_string_get_cstr(h)); + furi_string_free(h); + } else { + feed(app, " R %04X err:%s", c->value_handle, + ble_att_error_name(bc_last_error(app->bc)) ? ble_att_error_name(bc_last_error(app->bc)) : "?"); + } + } + bool writable = c->props & (CHAR_PROP_WRITE | CHAR_PROP_WRITE_WITHOUT_RESP); + if(fuzz && writable) { + bool wr = c->props & CHAR_PROP_WRITE; + for(size_t pi = 0; pi < COUNT_OF(pats); pi++) { + bool ok = bc_write(app->bc, c->value_handle, pats[pi].d, pats[pi].len, wr); + feed(app, " W %04X %s:%s", c->value_handle, pats[pi].tag, ok ? "ok" : "rej"); + } + } + UNUSED(char_end); + } + } + furi_string_free(s); + feed(app, fuzz ? "fuzz pass done" : "read pass done"); + feed_status(app, "done (OK=fuzz)"); +} + +static int32_t worker_thread(void* ctx) { + App* app = ctx; + JobType job; + while(furi_message_queue_get(app->jobs, &job, FuriWaitForever) == FuriStatusOk) { + if(job == JobQuit) break; + if(job == JobRun) + run_enum(app, false); + else if(job == JobFuzz) + run_enum(app, true); + else if(job == JobDisconnect) { + bc_disconnect(app->bc); + bc_scan_start(app->bc); + } + view_dispatcher_send_custom_event(app->vd, EvtJobDone); + } + return 0; +} + +// ---- scan view -------------------------------------------------------------- + +static void scan_draw(Canvas* c, void* m_) { + ScanModel* m = m_; + FuriString* s = furi_string_alloc(); + canvas_clear(c); + canvas_set_font(c, FontPrimary); + canvas_draw_str(c, 2, 9, "GATT Fuzzer"); + canvas_set_font(c, FontSecondary); + furi_string_printf(s, "%u", (unsigned)m->count); + canvas_draw_str_aligned(c, 126, 9, AlignRight, AlignBottom, furi_string_get_cstr(s)); + canvas_draw_line(c, 0, 11, 127, 11); + if(!m->count) canvas_draw_str_aligned(c, 64, 40, AlignCenter, AlignCenter, "Scanning..."); + for(size_t r = 0; r < ROWS && m->top + r < m->count; r++) { + const BcDevice* d = &m->devs[m->top + r]; + int y = 13 + r * 17; + bool sel = (m->top + r == m->sel); + if(sel) { + canvas_draw_rbox(c, 0, y, 122, 17, 2); + canvas_set_color(c, ColorWhite); + } + canvas_set_font(c, FontPrimary); + furi_string_set_str(s, dev_label(d)); + elements_string_fit_width(c, s, 90); + canvas_draw_str(c, 3, y + 8, furi_string_get_cstr(s)); + canvas_set_font(c, FontSecondary); + furi_string_printf(s, "%d", d->rssi); + canvas_draw_str_aligned(c, 119, y + 8, AlignRight, AlignBottom, furi_string_get_cstr(s)); + furi_string_reset(s); + ble_addr_str(s, d->addr); + elements_string_fit_width(c, s, 116); + canvas_draw_str(c, 3, y + 16, furi_string_get_cstr(s)); + if(sel) canvas_set_color(c, ColorBlack); + } + if(m->count > ROWS) elements_scrollbar_pos(c, 125, 13, 51, m->sel, m->count); + furi_string_free(s); +} + +static bool scan_input(InputEvent* e, void* ctx) { + App* app = ctx; + if(e->type != InputTypeShort && e->type != InputTypeRepeat) return false; + if(e->key == InputKeyOk && e->type == InputTypeShort) { + view_dispatcher_send_custom_event(app->vd, EvtSelect); + return true; + } + if(e->key != InputKeyUp && e->key != InputKeyDown) return false; + with_view_model( + app->scan_view, + ScanModel * m, + { + if(e->key == InputKeyUp && m->sel > 0) m->sel--; + if(e->key == InputKeyDown && m->sel + 1 < m->count) m->sel++; + if(m->sel < m->top) m->top = m->sel; + if(m->sel >= m->top + ROWS) m->top = m->sel - ROWS + 1; + }, + true); + return true; +} + +static void scan_enter(void* ctx) { + App* app = ctx; + app->cur = ViewScan; + bc_scan_start(app->bc); +} + +// ---- feed view -------------------------------------------------------------- + +static void feed_draw(Canvas* c, void* m_) { + FeedModel* m = m_; + canvas_clear(c); + canvas_set_font(c, FontPrimary); + canvas_draw_str(c, 2, 9, m->header[0] ? m->header : "GATT"); + canvas_set_font(c, FontSecondary); + canvas_draw_str_aligned(c, 126, 9, AlignRight, AlignBottom, m->status); + canvas_draw_line(c, 0, 11, 127, 11); + int visible = 5; + int total = m->count; + int shown = total < visible ? total : visible; + for(int r = 0; r < shown; r++) { + int idx = total - 1 - (int)m->scroll - (shown - 1 - r); + if(idx < 0 || idx < total - FEED_MAX) continue; + canvas_draw_str(c, 2, 22 + r * 9, m->lines[idx % FEED_MAX]); + } + if(total > visible) elements_scrollbar_pos(c, 126, 12, 52, m->scroll, total); +} + +static bool feed_input(InputEvent* e, void* ctx) { + App* app = ctx; + if(e->type != InputTypeShort && e->type != InputTypeRepeat) return false; + if(e->key == InputKeyOk && e->type == InputTypeShort) { + furi_message_queue_put(app->jobs, &(JobType){JobFuzz}, 0); + return true; + } + if(e->key != InputKeyUp && e->key != InputKeyDown) return false; + with_view_model( + app->feed_view, + FeedModel * m, + { + int maxs = m->count > 5 ? m->count - 5 : 0; + if(e->key == InputKeyDown && (int)m->scroll < maxs) m->scroll++; + if(e->key == InputKeyUp && m->scroll > 0) m->scroll--; + }, + true); + return true; +} + +static uint32_t feed_back(void* ctx) { + App* app = ctx; + furi_message_queue_put(app->jobs, &(JobType){JobDisconnect}, 0); + app->cur = ViewScan; + return ViewScan; +} +static uint32_t scan_exit(void* ctx) { + UNUSED(ctx); + return VIEW_NONE; +} + +static bool custom_cb(void* ctx, uint32_t event) { + App* app = ctx; + switch(event) { + case EvtTick: + if(app->cur == ViewScan) + with_view_model( + app->scan_view, + ScanModel * m, + { + m->count = bc_snapshot(app->bc, m->devs, BC_MAX_DEVICES); + if(m->sel >= m->count) m->sel = m->count ? m->count - 1 : 0; + if(m->sel < m->top) m->top = m->sel; + if(m->sel >= m->top + ROWS) m->top = m->sel - ROWS + 1; + }, + true); + break; + case EvtSelect: { + bool have = false; + with_view_model( + app->scan_view, ScanModel * m, + { if(m->count) { app->dev = m->devs[m->sel]; have = true; } }, false); + if(have) { + with_view_model( + app->feed_view, + FeedModel * m, + { + m->count = 0; + m->scroll = 0; + snprintf(m->header, sizeof(m->header), "%s", dev_label(&app->dev)); + strncpy(m->status, "working", sizeof(m->status) - 1); + }, + false); + app->cur = ViewFeed; + view_dispatcher_switch_to_view(app->vd, ViewFeed); + furi_message_queue_put(app->jobs, &(JobType){JobRun}, 0); + } + } break; + case EvtJobDone: break; + default: return false; + } + return true; +} + +static void timer_cb(void* ctx) { + App* app = ctx; + view_dispatcher_send_custom_event(app->vd, EvtTick); +} + +int32_t ble_gattfuzz_app(void* p) { + UNUSED(p); + App* app = malloc(sizeof(App)); + memset(app, 0, sizeof(App)); + app->gui = furi_record_open(RECORD_GUI); + app->storage = furi_record_open(RECORD_STORAGE); + app->jobs = furi_message_queue_alloc(8, sizeof(JobType)); + + app->vd = view_dispatcher_alloc(); + view_dispatcher_set_event_callback_context(app->vd, app); + view_dispatcher_set_custom_event_callback(app->vd, custom_cb); + view_dispatcher_attach_to_gui(app->vd, app->gui, ViewDispatcherTypeFullscreen); + + app->scan_view = view_alloc(); + view_allocate_model(app->scan_view, ViewModelTypeLocking, sizeof(ScanModel)); + view_set_context(app->scan_view, app); + view_set_draw_callback(app->scan_view, scan_draw); + view_set_input_callback(app->scan_view, scan_input); + view_set_enter_callback(app->scan_view, scan_enter); + view_set_previous_callback(app->scan_view, scan_exit); + view_dispatcher_add_view(app->vd, ViewScan, app->scan_view); + + app->feed_view = view_alloc(); + view_allocate_model(app->feed_view, ViewModelTypeLocking, sizeof(FeedModel)); + view_set_context(app->feed_view, app); + view_set_draw_callback(app->feed_view, feed_draw); + view_set_input_callback(app->feed_view, feed_input); + view_set_previous_callback(app->feed_view, feed_back); + view_dispatcher_add_view(app->vd, ViewFeed, app->feed_view); + + app->timer = furi_timer_alloc(timer_cb, FuriTimerTypePeriodic, app); + + if(!bc_supported()) { + view_dispatcher_switch_to_view(app->vd, ViewScan); + view_dispatcher_run(app->vd); + goto teardown; + } + + storage_simply_mkdir(app->storage, LOG_DIR); + DateTime dt; + furi_hal_rtc_get_datetime(&dt); + FuriString* path = furi_string_alloc_printf( + LOG_DIR "/fuzz_%02u%02u%02u.log", dt.hour, dt.minute, dt.second); + app->log = storage_file_alloc(app->storage); + if(!storage_file_open(app->log, furi_string_get_cstr(path), FSAM_WRITE, FSOM_CREATE_ALWAYS)) { + storage_file_free(app->log); + app->log = NULL; + } + furi_string_free(path); + + bool was_adv = furi_hal_bt_is_active(); + furi_hal_bt_stop_advertising(); + furi_delay_ms(100); + app->bc = bc_alloc(NULL, NULL); + app->worker = furi_thread_alloc_ex("BleFuzzW", 3072, worker_thread, app); + furi_thread_start(app->worker); + furi_timer_start(app->timer, furi_ms_to_ticks(300)); + + view_dispatcher_switch_to_view(app->vd, ViewScan); + view_dispatcher_run(app->vd); + + furi_timer_stop(app->timer); + furi_message_queue_put(app->jobs, &(JobType){JobQuit}, 0); + furi_thread_join(app->worker); + furi_thread_free(app->worker); + bc_free(app->bc); + if(was_adv) furi_hal_bt_start_advertising(); + if(app->log) { + storage_file_close(app->log); + storage_file_free(app->log); + } + +teardown: + furi_timer_free(app->timer); + view_dispatcher_remove_view(app->vd, ViewScan); + view_dispatcher_remove_view(app->vd, ViewFeed); + view_free(app->scan_view); + view_free(app->feed_view); + view_dispatcher_free(app->vd); + furi_message_queue_free(app->jobs); + furi_record_close(RECORD_STORAGE); + furi_record_close(RECORD_GUI); + free(app); + return 0; +} diff --git a/ble_gattfuzz/icon.png b/ble_gattfuzz/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..2d6f2fbc8f428fe0b6c579297e80bee719714064 GIT binary patch literal 95 zcmeAS@N?(olHy`uVBq!ia0vp^AT}2VkYHF5IUx~9sd>6MhE&{2{=v_}^Z%g=GjkK? rvI8nYO`T0s4qY)y2>mVZse2`cq5H7+*7zpl8jwy;S3j3^P6