Card-Crack: Cardputer ESP32-S3 home-lab recon toolkit

Manual-trigger firmware toolkit for pentesting owned devices:
- Pin Scan: UART/JTAG/SWD detection (baud est + IDCODE reads)
- V-Sense: target voltage probe w/ logic-family guess
- UART Sniff: passive capture + manual-only frame replay
- USB Enum: ESP32-S3 host, read-only descriptor fingerprinting
- DefCred: single-host factory-default login check, rate-limited

Modular shell (core/ + modules/), PlatformIO build, hardware/safety docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AAhMHMRAQLQ9hSbBECKNfn
This commit is contained in:
Indiana Holmes
2026-09-10 18:12:42 +00:00
commit 79612197a1
16 changed files with 967 additions and 0 deletions

114
src/modules/defcred.cpp Normal file
View File

@@ -0,0 +1,114 @@
#include "../core/module.h"
#include "../core/ui.h"
#include <WiFi.h>
#include <HTTPClient.h>
#include "mbedtls/base64.h"
// Default-credential checker for devices on YOUR network. Point it at a
// host (default = the gateway) and it walks a short list of well-known
// vendor default logins, trying HTTP Basic Auth against the admin path.
// It reports which pair the device ACCEPTS so you can go change it.
//
// Scope guards, on purpose:
// * one host at a time, entered by you — no subnet sweeping;
// * a tiny curated default list — this is a "did you forget to change
// the factory password" check, not a brute-force/dictionary attack;
// * a delay between attempts so it can't hammer a device;
// * stops on the first success.
//
// Join your own AP first (creds compiled below, or reflash to change).
#ifndef CC_WIFI_SSID
#define CC_WIFI_SSID "set-me"
#endif
#ifndef CC_WIFI_PASS
#define CC_WIFI_PASS "set-me"
#endif
class DefCred : public Module {
struct Cred { const char* u; const char* p; };
// Common factory defaults across consumer routers/appliances.
static constexpr Cred LIST[] = {
{"admin", "admin"}, {"admin", "password"}, {"admin", ""},
{"admin", "1234"}, {"root", "root"}, {"root", "admin"},
{"user", "user"}, {"admin", "12345678"}, {"support", "support"},
};
static constexpr int N = sizeof(LIST) / sizeof(LIST[0]);
String host = "192.168.1.1";
String path = "/";
char status[3][40] = {{0},{0},{0}};
bool running = false;
int ix = 0;
uint32_t lastTry = 0;
public:
const char* name() const override { return "DefCred"; }
const char* blurb() const override { return "default login check"; }
void onEnter() override {
running = false; ix = 0;
if (WiFi.status() != WL_CONNECTED) {
WiFi.mode(WIFI_STA);
WiFi.begin(CC_WIFI_SSID, CC_WIFI_PASS);
say("joining %s...", CC_WIFI_SSID);
}
if (WiFi.status() == WL_CONNECTED) host = WiFi.gatewayIP().toString();
}
bool onKey(char c) override {
if (c == ' ' || c == '\r') {
if (WiFi.status() != WL_CONNECTED) { say("wifi not up yet"); return true; }
running = !running; if (running) { ix = 0; say("target %s", host.c_str()); }
return true;
}
return false;
}
void tick() override {
if (WiFi.status() == WL_CONNECTED && host == "192.168.1.1")
host = WiFi.gatewayIP().toString();
if (!running) return;
if (millis() - lastTry < 600) return; // rate-limit
lastTry = millis();
if (ix >= N) { running = false; say("-- no default creds worked --"); return; }
tryOne(LIST[ix]);
ix++;
}
void draw() override {
bool up = WiFi.status() == WL_CONNECTED;
ui::lineC(0, up ? ui::accent() : ui::warn(), "wifi:%s host:%s",
up ? "up" : "down", host.c_str());
ui::line(1, "trying %d/%d %s", ix, N, running ? "RUN" : "idle");
for (int i = 0; i < 3; i++) ui::line(3 + i, "%s", status[i]);
ui::hintBar("[space]start/stop [`]back");
}
private:
void say(const char* fmt, ...) {
for (int i = 2; i > 0; i--) strncpy(status[i], status[i-1], 39);
va_list ap; va_start(ap, fmt);
vsnprintf(status[0], 40, fmt, ap); va_end(ap);
}
void tryOne(const Cred& c) {
HTTPClient http;
String url = "http://" + host + path;
if (!http.begin(url)) { say("begin fail"); return; }
String token = String(c.u) + ":" + c.p;
unsigned char enc[128]; size_t olen = 0;
mbedtls_base64_encode(enc, sizeof(enc), &olen,
(const unsigned char*)token.c_str(), token.length());
http.addHeader("Authorization", "Basic " + String((char*)enc).substring(0, olen));
http.setConnectTimeout(1500);
int code = http.GET();
if (code == 200) { say("OK! %s:%s <-- CHANGE IT", c.u, c.p[0] ? c.p : "(blank)"); running = false; }
else if (code == 401) say("401 %s:%s", c.u, c.p[0] ? c.p : "-");
else say("%d %s:%s", code, c.u, c.p[0] ? c.p : "-");
http.end();
}
};
constexpr DefCred::Cred DefCred::LIST[];
Module* makeDefCred() { return new DefCred(); }

192
src/modules/pinscan.cpp Normal file
View File

@@ -0,0 +1,192 @@
#include "../core/module.h"
#include "../core/ui.h"
#include "../core/pins.h"
// Pin-detection scanner. Three passive/semi-active discovery modes on
// the PROBE channels:
//
// UART : hold each candidate as input, watch for line activity, then
// bit-time the shortest low pulse to estimate baud. Optional
// TX-poke ("\r\n") on a paired pin to elicit a boot banner.
// JTAG : IDCODE scan — drive TCK/TMS on candidate pairs, shift TDO,
// look for a valid 32-bit IDCODE (bit0==1, != all-ones).
// SWD : line-reset + read IDCODE via SWD-DP (ARM ADIv5). Two wires
// (SWCLK/SWDIO), so the permutation space is small.
//
// All modes are manual-trigger and chunked across ticks so the UI stays
// live and you can abort. This is discovery only: it identifies pins and
// reads public ID registers. It does not halt cores or dump memory.
class PinScan : public Module {
enum Mode { UART, JTAG, SWD } mode = UART;
bool running = false;
int step = 0; // permutation cursor
int found = 0;
char last[3][40] = {{0},{0},{0}};
public:
const char* name() const override { return "Pin Scan"; }
const char* blurb() const override { return "UART/JTAG/SWD detect"; }
void onEnter() override {
for (int i = 0; i < pins::PROBE_COUNT; i++)
pinMode(pins::PROBE[i], INPUT); // hi-Z until we act
running = false; step = 0; found = 0;
}
void onExit() override {
for (int i = 0; i < pins::PROBE_COUNT; i++)
pinMode(pins::PROBE[i], INPUT);
}
bool onKey(char c) override {
if (c == 'm') { mode = (Mode)((mode + 1) % 3); running = false; step = 0; found = 0; return true; }
if (c == ' ' || c == '\r') { running = !running; if (running) { step = 0; found = 0; } return true; }
return false;
}
void tick() override {
if (!running) return;
// Do one permutation per tick (bounded work -> responsive UI).
switch (mode) {
case UART: scanUartStep(); break;
case JTAG: scanJtagStep(); break;
case SWD: scanSwdStep(); break;
}
}
void draw() override {
const char* mn = mode == UART ? "UART" : mode == JTAG ? "JTAG" : "SWD";
ui::lineC(0, ui::accent(), "mode:%-4s %s", mn, running ? "SCANNING" : "idle");
ui::line(1, "chans:%d perm:%d hits:%d", pins::PROBE_COUNT, step, found);
for (int i = 0; i < 3; i++)
ui::line(3 + i, "%s", last[i]);
ui::hintBar("[m]ode [space]start/stop [`]back");
}
private:
void record(const char* s) {
for (int i = 2; i > 0; i--) strncpy(last[i], last[i-1], 39);
strncpy(last[0], s, 39); last[0][39] = 0;
}
// --- UART ---------------------------------------------------------
// Sample a candidate RX pin, measure the narrowest pulse, map to baud.
void scanUartStep() {
int ch = step % pins::PROBE_COUNT;
int pin = pins::PROBE[ch];
pinMode(pin, INPUT_PULLUP);
uint32_t minLow = measureMinPulse(pin, 3000 /*us window*/);
if (minLow) {
long baud = pulseToBaud(minLow);
char b[40]; snprintf(b, sizeof(b), "UART? G%d ~%ld 8N1", pin, baud);
record(b); found++;
}
if (++step >= pins::PROBE_COUNT) { running = false; record("-- uart scan done --"); }
}
uint32_t measureMinPulse(int pin, uint32_t windowUs) {
uint32_t t0 = micros(), minLow = 0;
int last = digitalRead(pin);
uint32_t edge = micros();
while (micros() - t0 < windowUs) {
int now = digitalRead(pin);
if (now != last) {
uint32_t w = micros() - edge;
if (last == LOW && (minLow == 0 || w < minLow)) minLow = w;
edge = micros(); last = now;
}
}
return minLow;
}
long pulseToBaud(uint32_t us) {
static const long std[] = {9600, 19200, 38400, 57600, 115200, 230400, 460800};
long est = 1000000L / (long)us; // one bit-time
long best = std[0]; long bd = 1L<<30;
for (long s : std) { long d = labs(s - est); if (d < bd) { bd = d; best = s; } }
return best;
}
// --- JTAG (IDCODE) ------------------------------------------------
// Try ordered (TCK,TMS,TDO) triples; TDI is optional for IDCODE.
void scanJtagStep() {
int n = pins::PROBE_COUNT;
int tck = step / (n * n) % n;
int tms = step / n % n;
int tdo = step % n;
if (tck != tms && tms != tdo && tck != tdo) {
uint32_t id = readJtagIdcode(pins::PROBE[tck], pins::PROBE[tms], pins::PROBE[tdo]);
if (id && id != 0xFFFFFFFF && (id & 1)) {
char b[40]; snprintf(b, sizeof(b), "JTAG id=%08lX T%d/%d/%d",
(unsigned long)id, pins::PROBE[tck], pins::PROBE[tms], pins::PROBE[tdo]);
record(b); found++;
}
}
if (++step >= n*n*n) { running = false; record("-- jtag scan done --"); }
}
uint32_t readJtagIdcode(int tck, int tms, int tdo) {
pinMode(tck, OUTPUT); pinMode(tms, OUTPUT); pinMode(tdo, INPUT_PULLUP);
auto clk = [&](int tmsv){ digitalWrite(tms, tmsv); digitalWrite(tck, LOW);
delayMicroseconds(2); digitalWrite(tck, HIGH); delayMicroseconds(2); };
for (int i = 0; i < 5; i++) clk(1); // -> Test-Logic-Reset (loads IDCODE)
clk(0); clk(0); // -> Run-Test/Idle -> Select-DR
clk(1); clk(0); clk(0); // -> Shift-DR
uint32_t id = 0;
for (int i = 0; i < 32; i++) {
digitalWrite(tck, LOW); delayMicroseconds(2);
if (digitalRead(tdo)) id |= (1UL << i);
digitalWrite(tck, HIGH); delayMicroseconds(2);
}
return id;
}
// --- SWD (ARM ADIv5 DP IDCODE) ------------------------------------
void scanSwdStep() {
int n = pins::PROBE_COUNT;
int clk = step / n % n, io = step % n;
if (clk != io) {
uint32_t id = readSwdIdcode(pins::PROBE[clk], pins::PROBE[io]);
if (id && id != 0xFFFFFFFF) {
char b[40]; snprintf(b, sizeof(b), "SWD id=%08lX CLK%d IO%d",
(unsigned long)id, pins::PROBE[clk], pins::PROBE[io]);
record(b); found++;
}
}
if (++step >= n*n) { running = false; record("-- swd scan done --"); }
}
uint32_t readSwdIdcode(int swclk, int swdio) {
pinMode(swclk, OUTPUT); pinMode(swdio, OUTPUT);
auto wbit = [&](int b){ digitalWrite(swdio, b); digitalWrite(swclk, LOW);
delayMicroseconds(2); digitalWrite(swclk, HIGH); delayMicroseconds(2); };
// Line reset: >=50 clocks with SWDIO high, then JTAG->SWD magic 0xE79E.
for (int i = 0; i < 56; i++) wbit(1);
uint16_t magic = 0xE79E;
for (int i = 0; i < 16; i++) wbit((magic >> i) & 1);
for (int i = 0; i < 56; i++) wbit(1);
for (int i = 0; i < 4; i++) wbit(0);
// Request: read DP reg 0 (IDCODE). Start=1 APnDP=0 RnW=1 A[2:3]=00, parity, stop=0, park=1.
uint8_t req = 0xA5;
for (int i = 0; i < 8; i++) wbit((req >> i) & 1);
// Turnaround, read 3-bit ACK.
pinMode(swdio, INPUT_PULLUP);
digitalWrite(swclk, LOW); delayMicroseconds(2); digitalWrite(swclk, HIGH); delayMicroseconds(2);
uint8_t ack = 0;
for (int i = 0; i < 3; i++) {
digitalWrite(swclk, LOW); delayMicroseconds(2);
if (digitalRead(swdio)) ack |= (1 << i);
digitalWrite(swclk, HIGH); delayMicroseconds(2);
}
if (ack != 0x1) return 0; // 0b001 = OK
uint32_t id = 0;
for (int i = 0; i < 32; i++) {
digitalWrite(swclk, LOW); delayMicroseconds(2);
if (digitalRead(swdio)) id |= (1UL << i);
digitalWrite(swclk, HIGH); delayMicroseconds(2);
}
return id;
}
};
Module* makePinScan() { return new PinScan(); }

96
src/modules/uartsniff.cpp Normal file
View File

@@ -0,0 +1,96 @@
#include "../core/module.h"
#include "../core/ui.h"
#include "../core/pins.h"
// UART protocol sniff + replay. Passive by default:
//
// SNIFF : attach HardwareSerial to the Grove pins, dump a rolling
// hex/ascii view, and keep a small ring buffer of the last
// N bytes. Cycle common bauds with [b] until it reads clean.
// CAPTURE : freeze the current ring buffer as the "replay frame".
// REPLAY : re-transmit the captured frame ON A KEYPRESS ONLY. This is
// the one active operation in the module and it never fires
// on its own — you press [t] each time.
//
// Replay is for your own bench: confirming a command you already saw the
// device send/accept (e.g. a UART-controlled relay, an OBD ELM327 AT
// string). It is not a fuzzer and does not brute-force.
class UartSniff : public Module {
static constexpr int RING = 256;
uint8_t ring[RING]; int head = 0, count = 0;
uint8_t frame[RING]; int frameLen = 0;
const long BAUDS[6] = {9600, 19200, 38400, 57600, 115200, 230400};
int baudIx = 4;
bool live = false;
HardwareSerial& port = Serial1;
public:
const char* name() const override { return "UART Sniff"; }
const char* blurb() const override { return "sniff / capture / replay"; }
void onEnter() override { count = head = 0; frameLen = 0; startPort(); }
void onExit() override { port.end(); live = false; }
bool onKey(char c) override {
if (c == 'b') { baudIx = (baudIx + 1) % 6; startPort(); return true; }
if (c == ' ') { live = !live; return true; }
if (c == 'c') { capture(); return true; }
if (c == 't') { replayOnce(); return true; }
return false;
}
void tick() override {
if (!live) return;
while (port.available() && count < RING * 2) { // bound per tick
uint8_t b = port.read();
ring[head] = b; head = (head + 1) % RING;
if (count < RING) count++;
}
}
void draw() override {
ui::lineC(0, ui::accent(), "%ld 8N1 %s buf:%d frame:%d",
BAUDS[baudIx], live ? "LIVE" : "paused", count, frameLen);
// Last 3 rows = 8 bytes each of the tail of the ring, hex+ascii.
for (int r = 0; r < 3; r++) drawRow(2 + r, r);
ui::hintBar("[b]aud [space]live [c]apture [t]x-replay");
}
private:
void startPort() {
port.end();
port.begin(BAUDS[baudIx], SERIAL_8N1, pins::GROVE_A /*RX*/, pins::GROVE_B /*TX*/);
live = true;
}
void drawRow(int uiRow, int chunk) {
char line[40]; int p = 0;
int start = (head - count + (count - (chunk + 1) * 8) + RING * 4) % RING;
for (int i = 0; i < 8; i++) {
uint8_t b = ring[(start + i) % RING];
p += snprintf(line + p, sizeof(line) - p, "%02X ", b);
}
p += snprintf(line + p, sizeof(line) - p, " ");
for (int i = 0; i < 8; i++) {
uint8_t b = ring[(start + i) % RING];
line[p++] = (b >= 32 && b < 127) ? b : '.';
}
line[p] = 0;
ui::line(uiRow, "%s", line);
}
void capture() {
frameLen = count < RING ? count : RING;
int start = (head - frameLen + RING) % RING;
for (int i = 0; i < frameLen; i++) frame[i] = ring[(start + i) % RING];
}
void replayOnce() { // manual-only TX
if (frameLen == 0) return;
port.write(frame, frameLen);
port.flush();
}
};
Module* makeUartSniff() { return new UartSniff(); }

126
src/modules/usbenum.cpp Normal file
View File

@@ -0,0 +1,126 @@
#include "../core/module.h"
#include "../core/ui.h"
// USB descriptor enumeration. The ESP32-S3 has a USB-OTG core, so it can
// act as a host and read the standard descriptors a device advertises:
// VID/PID, device class, manufacturer/product strings, and the interface
// classes of each configuration. That's exactly what you want for
// fingerprinting an unknown dongle, a car's USB port, or a peripheral
// before you decide what it actually is.
//
// This is read-only enumeration. We open the device, pull descriptors,
// and close. We do NOT claim interfaces, send class requests, or drive
// endpoints — no HID injection, no mass-storage access.
//
// Requires the board's USB pins wired to the target as HOST (VBUS out).
// Build with ARDUINO_USB_MODE so the console stays on the CDC, and use a
// separate OTG connection for the target. See docs/HARDWARE.md.
#if __has_include("usb/usb_host.h")
#include "usb/usb_host.h"
#define HAVE_USB_HOST 1
#endif
class UsbEnum : public Module {
char lines[6][40];
int nlines = 0;
bool installed = false;
#ifdef HAVE_USB_HOST
usb_host_client_handle_t client = nullptr;
usb_device_handle_t dev = nullptr;
uint8_t devAddr = 0;
#endif
public:
const char* name() const override { return "USB Enum"; }
const char* blurb() const override { return "descriptor readout"; }
void onEnter() override {
nlines = 0;
say("host idle. plug target, press [space]");
#ifndef HAVE_USB_HOST
say("build w/ ESP-IDF usb_host to enable");
#endif
}
void onExit() override { teardown(); }
bool onKey(char c) override {
if (c == ' ') { enumerate(); return true; }
if (c == 'x') { teardown(); nlines = 0; say("host released"); return true; }
return false;
}
void draw() override {
ui::lineC(0, ui::accent(), "USB host %s", installed ? "up" : "down");
for (int i = 0; i < nlines; i++) ui::line(1 + i, "%s", lines[i]);
ui::hintBar("[space]enumerate [x]release [`]back");
}
private:
void say(const char* fmt, ...) {
if (nlines >= 6) { for (int i = 1; i < 6; i++) strncpy(lines[i-1], lines[i], 39); nlines = 5; }
va_list ap; va_start(ap, fmt);
vsnprintf(lines[nlines], 40, fmt, ap); va_end(ap);
nlines++;
}
#ifdef HAVE_USB_HOST
bool ensureHost() {
if (installed) return true;
usb_host_config_t hc = {}; hc.intr_flags = ESP_INTR_FLAG_LEVEL1;
if (usb_host_install(&hc) != ESP_OK) { say("usb_host_install fail"); return false; }
usb_host_client_config_t cc = {};
cc.max_num_event_msg = 5;
if (usb_host_client_register(&cc, &client) != ESP_OK) { say("client reg fail"); return false; }
installed = true; return true;
}
void enumerate() {
if (!ensureHost()) return;
// Pump the host lib so a freshly-plugged device gets an address.
for (int i = 0; i < 200; i++) {
usb_host_lib_handle_events(pdMS_TO_TICKS(5), nullptr);
usb_host_client_handle_events(client, pdMS_TO_TICKS(5));
}
int num = 0; uint8_t list[8];
if (usb_host_device_addr_list_fill(sizeof(list), list, &num) != ESP_OK || num == 0) {
say("no device detected"); return;
}
devAddr = list[0];
if (usb_host_device_open(client, devAddr, &dev) != ESP_OK) { say("open @%d fail", devAddr); return; }
const usb_device_desc_t* dd = nullptr;
if (usb_host_get_device_descriptor(dev, &dd) == ESP_OK && dd) {
say("VID:PID %04X:%04X", dd->idVendor, dd->idProduct);
say("class %02X sub %02X proto %02X", dd->bDeviceClass, dd->bDeviceSubClass, dd->bDeviceProtocol);
say("bcdUSB %04X cfgs %d", dd->bcdUSB, dd->bNumConfigurations);
readString(dd->iManufacturer, "mfr");
readString(dd->iProduct, "prod");
} else say("get dev desc fail");
const usb_config_desc_t* cd = nullptr;
if (usb_host_get_active_config_descriptor(dev, &cd) == ESP_OK && cd)
say("cfg len %d ifaces %d", cd->wTotalLength, cd->bNumInterfaces);
}
void readString(uint8_t idx, const char* label) {
if (!idx) return;
usb_transfer_t* t = nullptr;
// Minimal control-in for a string descriptor; best-effort.
char out[24] = {0};
if (usb_host_get_string_descriptor(dev, idx, 0x0409, (uint8_t*)out, sizeof(out)) == ESP_OK)
say("%s: %s", label, out);
}
void teardown() {
if (dev) { usb_host_device_close(client, dev); dev = nullptr; }
if (client) { usb_host_client_deregister(client); client = nullptr; }
if (installed) { usb_host_uninstall(); installed = false; }
}
#else
void enumerate() { say("USB host stack not compiled in"); }
void teardown() {}
#endif
};
Module* makeUsbEnum() { return new UsbEnum(); }

62
src/modules/vsense.cpp Normal file
View File

@@ -0,0 +1,62 @@
#include "../core/module.h"
#include "../core/ui.h"
#include "../core/pins.h"
// Voltage sensing. Reads the ADC behind an external divider and reports
// a live voltage plus a guess at the logic family, so you know what
// you're probing BEFORE you drive a pin into it. Min/max are held so a
// brief transient (e.g. a bus idling high) is captured.
//
// The ESP32-S3 ADC is non-linear near the rails; we use the Arduino
// analogReadMilliVolts() calibration path and apply the divider ratio.
class VSense : public Module {
float v = 0, vmin = 99, vmax = 0;
uint32_t lastMs = 0;
public:
const char* name() const override { return "V-Sense"; }
const char* blurb() const override { return "target voltage probe"; }
void onEnter() override {
analogReadResolution(12);
pinMode(pins::VSENSE_ADC, INPUT);
vmin = 99; vmax = 0;
}
bool onKey(char c) override {
if (c == 'r') { vmin = 99; vmax = 0; return true; } // reset hold
return false;
}
void tick() override {
if (millis() - lastMs < 100) return;
lastMs = millis();
uint32_t mv = analogReadMilliVolts(pins::VSENSE_ADC);
v = (mv / 1000.0f) * pins::VSENSE_RATIO;
if (v < vmin) vmin = v;
if (v > vmax) vmax = v;
}
void draw() override {
ui::lineC(0, ui::accent(), "Vin = %5.2f V", v);
ui::line(1, "min %4.2f max %4.2f (ratio %.1f)", vmin, vmax, pins::VSENSE_RATIO);
ui::lineC(3, family(v).color, "logic: %s", family(v).name);
ui::line(5, "wire target -> divider -> G%d", pins::VSENSE_ADC);
ui::hintBar("[r]eset hold [`]back");
}
private:
struct Fam { const char* name; uint16_t color; };
Fam family(float x) {
if (x < 0.3f) return {"floating / GND", ui::fg()};
if (x < 1.5f) return {"1.2V core rail", ui::warn()};
if (x < 2.1f) return {"1.8V logic", ui::warn()};
if (x < 3.0f) return {"2.5V logic", ui::warn()};
if (x < 3.9f) return {"3.3V logic OK", ui::accent()};
if (x < 6.0f) return {"5V ! shift req", ui::bad()};
return {"HIGH >6V DANGER", ui::bad()};
}
};
Module* makeVSense() { return new VSense(); }