From a2a2cf5bc47e8ec8e91302e992fbea8122598d9a Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Tue, 09 Jun 2026 14:02:28 +0000 Subject: [PATCH] feat: git init --- .gitignore | 12 ++++++++++++ firmware_analysis/COMMAND_IDS.md | 101 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ firmware_analysis/PROCESS_LOG.md | 274 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ firmware_analysis/README.md | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ firmware_analysis/WIRE_PROTOCOL.md | 107 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ firmware_analysis/frida_scripts/extract_protocol.js | 251 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ firmware_analysis/frida_scripts/fake_device_v2.js | 90 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ firmware_analysis/frida_scripts/fake_device_v3.js | 78 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ firmware_analysis/frida_scripts/hook_drive_request.js | 111 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ firmware_analysis/frida_scripts/hook_send.js | 100 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ firmware_analysis/frida_scripts/hook_serialization.js | 132 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ firmware_analysis/frida_scripts/memory_scan.js | 213 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ firmware_analysis/frida_scripts/quick_extract.js | 104 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 13 file(s) changed, 1639 insertion(s)(+), 0 deletion(s)(-) diff --git a/.gitignore b/.gitignore new file mode 100644 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# Extracted firmware binaries (re-extractable from Logic 2 app bundle) +*.bin + +# Ghidra project files +*.gpr +*.rep + +# Temporary decompilation output +/tmp/decomp* + +# macOS +.DS_Store diff --git a/firmware_analysis/COMMAND_IDS.md b/firmware_analysis/COMMAND_IDS.md new file mode 100644 --- /dev/null +++ b/firmware_analysis/COMMAND_IDS.md @@ -0,0 +1,101 @@ +# Command ID Map — Final Consolidated + +Extracted via r2ghidra decompilation of `libgraph_server_shared.dylib`. +Command IDs are passed as the 3rd argument to `sym.func.010d0cf4(buf, data, CMD_ID, ...)`. + +## Confirmed Command IDs + +These were verified by decompiling individual Serialize* functions and observing +the constant passed to the send function. + +| ID | Hex | Command | Verification | +|----|-----|---------|-------------| +| 2 | 0x02 | GetScState | SerializeScState decompilation | +| 3 | 0x03 | ScPdProcessingEnable | SerializeScPdProcessingEnable decompilation | +| 5 | 0x05 | HeartbeatDetectionTimer | SerializeHeartbeatDetectionTimer decompilation | +| 15 | 0x0F | ConfigureAfe | Log string "Configuring AFE" + w6=0x0F | +| 17 | 0x11 | GetTemperatureState | SerializeTemperatureState decompilation | +| 33 | 0x21 | EnableFanControl | SerializeFanControlState decompilation | +| 41 | 0x29 | EnableHardwareMonitoring | SerializeSystemMonitorState decompilation | +| 42 | 0x2A | ConfigureIoExpander | Log string "Configuring IO Expander" | +| 193 | 0xC1 | GpioConfigure | SerializeGpioConfigure decompilation | +| 213 | 0xD5 | PdUvdmTransfer | SerializePdUvdmTransfer decompilation | +| 217 | 0xD9 | I2cTransfer | SerializeI2cTransfer decompilation | +| 218 | 0xDA | EventEnable (alt path) | SerializeEventEnable in shared caller | +| 226 | 0xE2 | VSysEnable | SerializeVSysEnable decompilation | +| 227 | 0xE3 | GetPdState | SerializePdState decompilation | +| 228 | 0xE4 | EventEnable | SerializeEventEnable decompilation | +| 231 | 0xE7 | GetWakeReason | SerializeWakeReason decompilation | +| 232 | 0xE8 | GetPmicShutdownSource | SerializePmicShutdownSource decompilation | +| 233 | 0xE9 | LpmEnable | SerializeLpmEnable decompilation | + +## Likely Command IDs (from log string correlation) + +These appeared as w6 values in log calls near device-command-related strings. +The w6 field serves dual purpose (log source ID + command ID), so these need +verification via USB capture or deeper decompilation. + +| ID | Hex | Probable Command | Evidence | +|----|-----|-----------------|---------| +| 7 | 0x07 | SetLed | "set led" | +| 10 | 0x0A | WaitForAdcLock | "ADC Locked" | +| 11 | 0x0B | CheckSuperSpeed | "SUPER SPEED" | +| 12 | 0x0C | StopCapture | "stop capture" | +| 13 | 0x0D | StartCapture | "start capture" | +| 14 | 0x0E | SetRegularSpeed | "regular speed" | +| 16 | 0x10 | InitializeMso / WriteMetadata | "Initializing MSO", "Writing metadata" | +| 18 | 0x12 | EnableVcco33 / WriteFlashPage | "Enabling VCCO 3.3V" | +| 20 | 0x14 | DownloadFirmware / EnableSmartPorts | "Downloading firmware" | +| 21 | 0x15 | SendStartCommand / SpiFlashChipErase | "Sending start command" | +| 22 | 0x16 | StartJesdBerTest | "Starting JESD BER test" | +| 24 | 0x18 | ResetFpgaTimestamp | "Resetting FPGA timestamp" | +| 25 | 0x19 | GetHardwareRevision | "Getting hardware revision" | +| 27 | 0x1B | SkipMsoInit | "SKIPPING MSO INITIALIZATION" | +| 29 | 0x1D | ResetDebugWords / SetDigitalThreshold | "Resetting all mso debug words" | +| 37 | 0x25 | IgnoreWallPowerInterrupt | "Ignoring wall power interrupt" | +| 44 | 0x2C | HardResetDevice | "hard resetting device" | +| 45 | 0x2D | SleepPermission / WakeResume | "System waking up" | +| 65 | 0x41 | MsoBootloaderDetected | "Found MSO device running Cypress bootloader" | +| 222 | 0xDE | GetFirmwareVersion | GetFirmwareVersion passes 0xDE to send func | + +## Architecture Notes + +### Send Function +```c +// sym.func.010d0cf4 - Primary USB command send +void send_command(ByteBuffer *response_buf, void *request_data, uint32_t cmd_id, ...); +``` + +### Packet Construction Flow +1. Caller creates a ByteBuffer and sets mode (1=write, 0=read) +2. Calls appropriate Serialize* function which writes command-specific data into buffer +3. Calls `send_command(buffer, data, CMD_ID, ...)` to send over USB +4. The Serialize* function also writes the magic token (16 bytes) and CRC32 + +### Dual-Purpose w6 Field +The log function at `0x118e258` takes w6 as its 7th parameter. This field serves as BOTH: +- A log source/component identifier (for debugging) +- A command identifier (when the log call is part of a command send path) + +This makes static extraction ambiguous. USB capture is needed to disambiguate. + +### Remaining Unknowns +Commands not yet mapped to IDs: +- FpgaConfigBegin / FpgaConfigEnd / FpgaConfigWaitForCompletion +- GpifStartStop / GpifFlush +- InRequest (sample data request) +- Batch +- SpiTransfer +- ResetDevice / SoftResetFx3 +- ForceFirmwareFault / GetFirmwareFault +- VendorRequest +- DebugDestinationUart +- I2cSwitchReset +- GetUsbEventLog / GetUsbStats +- FanControlSettings / SetOvertemperatureThresholds +- PeekDebugWord / PokeDebugWord / ResetDebugWords +- GetApplicationContext / SetApplicationContext +- GetHardwareVersion / GetAfeHardwareVersion + +These likely use the same send function but with cmd_ids that couldn't be extracted +because the compiler passed them via registers rather than inline immediates. diff --git a/firmware_analysis/PROCESS_LOG.md b/firmware_analysis/PROCESS_LOG.md new file mode 100644 --- /dev/null +++ b/firmware_analysis/PROCESS_LOG.md @@ -0,0 +1,274 @@ +# Reverse Engineering Process Log + +Documenting the methodology, tools, and lessons learned while reverse engineering the Saleae Logic Pro MSO wire protocol without physical hardware. + +## Timeline + +### Phase 1: Firmware Extraction (Static Analysis) +**Tools:** `xxd`, `strings`, `python3`, `npx asar` + +Extracted two FX3 firmware images embedded in `libgraph_server_shared.dylib`: +- V1 (117 KB, Oct 2022): Original Logic Pro (digital-only) +- V2 (260 KB, May 2024, git `550b4c5`): Logic Pro MSO (mixed-signal) + +Found via `CYWBFX3B` magic bytes at known offsets. Parsed USB descriptors directly from firmware binary. + +**Lesson:** Electron apps bundle native code in predictable locations. The dylib contained both host C++ code AND embedded firmware as data blobs. Distinguishing between them required checking address ranges. + +### Phase 2: Host Library Symbol Analysis +**Tools:** `nm`, `otool`, `strings`, `c++filt` + +Discovered the architecture: +- `libgraph_server_shared.dylib` — main protocol library (stripped exports) +- `graph-interface.node` — thin Node.js bridge +- Exported API: `CreateGraphServer`, `DriveRequest`, `PopResponse`, `FreeResponseBuffer` +- No libusb linkage — uses macOS IOKit for USB (statically linked or direct IOKit calls) +- Protobuf (`proto.saleae.automation.*`) is only for the automation/gRPC API, NOT the device wire protocol + +**Lesson:** The wire protocol uses a custom binary format, not protobuf. Don't assume modern apps use standard serialization. The presence of protobuf in the bundle was a red herring. + +### Phase 3: ARM64 Static Disassembly +**Tools:** `python3` (custom disassembler), `xcrun llvm-objdump` + +Built a minimal ARM64 instruction decoder to trace ADRP+ADD string references. Found: +- `SerializeMagicToken(ByteBuffer*, const char*)` function signature at `0xf1cedc` +- Three metadata tags exactly 16 bytes each: ``, ``, `` +- Each tag has exactly one code reference, all calling the same function with the tag as x1 +- CRC32 reflected polynomial (0xEDB88320) found at 3 locations + +**Lesson:** Custom ARM64 disassembly in Python is viable for targeted analysis when Ghidra/r2 are too slow or unavailable. ADRP+ADD is the dominant string-loading pattern on ARM64 Mach-O. + +### Phase 4: Frida Dynamic Analysis (Without Hardware) +**Tools:** `frida` 17.10.1 + +#### Challenges Encountered +1. **Wrong process**: The dylib loads in the Electron renderer child process, not the main `Logic` process. Must attach to PID from `pgrep -f "Saleae Logic Helper \(Renderer\)"`. +2. **`--no-pause` removed**: Frida 17.x removed this flag; default behavior is already non-pausing. +3. **Interactive REPL**: Must use `-q` flag for non-interactive/scripted execution. +4. **Module name resolution**: `Process.getModuleByName()` fails because the loaded module name differs from the filename. Use `Process.enumerateModules().find()` with partial match. +5. **API version differences**: `Module.findExportByName()`, `Module.enumerateImports()`, `Module.enumerateExports()` have inconsistent availability across Frida versions. Fall back to memory scanning. +6. **No protocol activity without hardware**: DriveRequest/SendRequest never fire without a connected device. Memory scanning finds constants but not runtime values. + +#### What Frida Successfully Extracted +- Confirmed CRC32 reflected (3 hits in loaded memory) +- Found all protocol string locations in ASLR-adjusted memory +- Confirmed the three 16-byte magic tokens are present in writable memory +- Mapped the dylib's loaded base address and segment layout +- Scanned RW memory for runtime-constructed tokens (none found — token is static) + +**Lesson:** Frida without hardware is useful for confirming static analysis findings and resolving ASLR, but cannot capture actual protocol traffic. The renderer process isolation in Electron adds complexity. + +### Phase 5: Ghidra Headless Decompilation +**Tools:** Ghidra 12.1.2, `analyzeHeadless` + +#### Challenges Encountered +1. **Java script compilation failure**: Ghidra 12 changed the scripting classloader. Java scripts must match exact class naming conventions. +2. **Python requires PyGhidra**: Ghidra 12 disabled legacy Jython. Python scripts need `pyghidra` launcher, not `analyzeHeadless`. +3. **Project directory must exist**: `analyzeHeadless` won't create the project directory automatically. +4. **Analysis time**: 37 MB ARM64 binary takes ~4 minutes for initial analysis, plus decompilation time per function. +5. **Address space errors**: Large binaries hit "out of address spaces" warnings during decompilation. Non-fatal but may affect some functions. + +#### What Ghidra Successfully Provided +- Full C decompilation of command builder functions +- Revealed the `SendRequest(buf, data, length)` call pattern +- Exposed the source file path: `mso_device_interface.cpp` +- Showed buffer construction: `buffer[0] = direction_flag`, then serializer writes command-specific data +- Identified the log/serialize function signature with command ID as last parameter + +**Lesson:** Ghidra headless is powerful but fragile. Always have a fallback (r2ghidra). The decompiled C code is invaluable for understanding data flow that static disassembly alone cannot reveal. + +### Phase 6: r2ghidra Decompilation (Breakthrough) +**Tools:** `radare2` 6.1.6, `r2ghidra` plugin + +Installed via `r2pm -U && r2pm -ci r2ghidra`. Used as fallback when Ghidra headless scripting failed. + +#### Key Advantages Over Ghidra Headless +- No scripting API compatibility issues +- Fast incremental decompilation (decompile one function at a time) +- Can combine with r2's analysis (`aaa`) for cross-references +- Direct command-line usage without project management overhead + +#### Results +Successfully decompiled all major command builder functions and extracted: +- Complete command ID map (16 commands with numeric IDs) +- Buffer construction patterns +- Serializer function names mapped to command IDs +- SendRequest call signatures with buffer lengths + +**Lesson:** r2ghidra is the most practical tool for targeted decompilation of specific functions. Reserve full Ghidra GUI/headless for broad analysis; use r2ghidra for surgical extraction. + +## Key Findings Summary + +### Protocol Architecture +``` +Host App (Electron/React) + ↓ IPC +libgraph_server_shared.dylib (C++) + ├── MsoDevice / MsoDeviceInterface classes + ├── ByteBuffer serialization with magic tokens + ├── CRC32 (reflected) + length framing + └── SendRequest() → USB bulk transfer + ↓ + Cypress FX3 (ARM926EJ-S, ThreadX RTOS) + ↓ GPIF + FPGA → Sample Data +``` + +### Wire Format (Partially Confirmed) +``` +┌──────────────┬──────────────┬────────────────┬─────────┐ +│ Magic Token │ CRC32 │ Length │ Payload │ +│ (16 bytes) │ (4 bytes) │ (4 bytes) │ (var) │ +└──────────────┴──────────────┴────────────────┴─────────┘ +``` + +Payload byte 0 = direction flag (1=read, 2=write), followed by command-specific serialized data. + +### Magic Tokens +| Token | Hex | Purpose | +|-------|-----|---------| +| `` | `3c53414c4541455f4d534f4d41494e3e` | Main board metadata | +| `` | `3c53414c4541455f4d534f4146455f3e` | AFE board metadata | +| `` | `3c53414c4541455f4d534f4c4153433e` | Smart cable metadata | + +### Command IDs (Extracted) +| ID | Hex | Command | +|----|-----|---------| +| 2 | 0x02 | GetScState | +| 3 | 0x03 | ScPdProcessingEnable | +| 5 | 0x05 | HeartbeatDetectionTimer | +| 15 | 0x0F | ConfigureAfe | +| 17 | 0x11 | GetTemperatureState | +| 33 | 0x21 | EnableFanControl | +| 41 | 0x29 | EnableHardwareMonitoring | +| 193 | 0xC1 | GpioConfigure | +| 213 | 0xD5 | PdUvdmTransfer | +| 217 | 0xD9 | I2cTransfer | +| 226 | 0xE2 | VSysEnable | +| 227 | 0xE3 | GetPdState | +| 228 | 0xE4 | EventEnable | +| 231 | 0xE7 | GetWakeReason | +| 232 | 0xE8 | GetPmicShutdownSource | +| 233 | 0xE9 | LpmEnable | + +### Remaining Unknowns +- Command IDs for: GetFirmwareVersion, GetHardwareVersion, FpgaConfigBegin/End, GpifStartStop, InRequest, Batch, ResetDevice, SpiTransfer, Test, PeekDebugWord, PokeDebugWord, etc. +- Exact ByteBuffer internal format (how Serialize* functions encode command ID into the buffer) +- Sample data frame format (MsoDataHeader structure) +- Interrupt endpoint async message format +- EEPROM metadata binary layout +- FPGA bitstream format + +### Recommended Next Steps +1. **With hardware**: Plug in Saleae device, run `fake_device.js` to capture the full handshake. The `>>> OUTGOING` lines will show exact packet bytes including command IDs we haven't extracted yet (GetFirmwareVersion, FpgaConfigBegin, GpifStartStop, InRequest, Batch, ResetDevice, SpiTransfer). +2. **Without hardware**: Continue r2ghidra decompilation of remaining command functions: + - GetFirmwareVersion @ `0xf23a88` + - ProgramFpgaBitstream @ `0xf28374` + - GpifStartStop (find via string ref) + - InRequest (find via string ref) + - Batch_CommandHandler (find via string ref) + - ResetDevice (find via string ref) +3. **Sample format**: Decompile `ProcessStreamData` / `HandleWaveformData` in host code to understand the EP2 IN data framing +4. **Full emulation**: To build a complete USB emulator without hardware, need to hook IOKit async read callbacks (`ReadPipeAsyncTO` completion handler) to inject responses. This requires finding the callback function pointer setup in `OsxUsbDevice::StartReadStream`. +5. **Alternative hardware path**: A Raspberry Pi Zero 2 W with USB gadget configfs can present as VID 21A9:PID 1007 with the correct endpoint configuration. This would allow full bidirectional testing without modifying Logic 2. + +### Phase 7: Fake USB Device (Frida Injection) +**Tools:** `frida` 17.10.1, `r2ghidra`, custom JavaScript + +Built a frida-based fake device that hooks into Logic 2's USB layer at the application level, avoiding IOKit complexity entirely. + +#### Architecture Decision +Rather than emulating a USB device at the kernel/IOKit level (which would require DriverKit or kext on macOS), we hook at the **application protocol layer**: +- Hook `SendRequest` to capture all outgoing command packets +- Hook `PopResponse` to observe response handling +- Monitor `HandleUsbDeviceConnectionForMsoDevice` for connection attempts +- Built CRC32 (reflected) implementation in JavaScript for response construction +- Response builder with magic token + CRC + length + payload framing + +#### Key Discovery: OsxUsbDevice Class +Decompilation revealed the USB abstraction layer: +- Source: `OsxUsbDevice.cpp` in Saleae's monorepo +- Uses IOKit `IOUSBDevice` interface +- Async reads via `ReadPipeAsyncTO` / `ReadPipeTO` +- Device type detection returns status code `0x0C` for MSO bootloader mode +- Connection handler: `HandleUsbDeviceConnectionForMsoDevice` + +#### What the Fake Device Captures +Without hardware: confirms hooks work, monitors enumeration attempts. +With hardware: captures complete handshake including: +- Exact packet bytes sent by host +- Command IDs in context +- Timing between requests +- Response format validation + +#### Critical Discovery: JSON RPC Layer +Decompilation of `DriveRequest` revealed it takes **JSON**, not raw USB binary: +```c +void DriveRequest(void* server, uint8_t* json_data, size_t json_length); +``` + +It parses JSON with `/type` and `/contents` fields. When `type == "request"`, it dispatches to `ProcessRequest`. Error strings confirm: `"json_missing_contents"`, `"contents_is_not_an_object"`, `"json_missing_type"`, `"type_is_not_a_string"`, `"invalid type"`. + +This means the architecture has THREE layers: +``` +Electron UI → JSON RPC → Graph Server → Binary USB Protocol → Device +``` + +We were trying to fake the USB layer when we should be faking the **JSON RPC layer**. This is dramatically simpler — no IOKit hooks, no binary protocol emulation, just JSON request/response. + +#### Fake Device v2: JSON RPC Interception +Rebuilt the fake device to hook `DriveRequest` and `PopResponse` at their known addresses (`0x5e7b8` and `0x5f078`). Successfully captures all JSON traffic between the Electron UI and graph server. + +To build a complete fake device without hardware: +1. Hook `DriveRequest` to intercept incoming JSON commands +2. Parse the command type from `contents.type` +3. Generate appropriate JSON responses +4. Inject responses by calling the internal response queue push function (or by replacing `PopResponse` return data) + +#### Limitations +- `Module.findExportByName()` and `Module.enumerateExports()` are broken for this module in Frida 17.10.1 — must use hardcoded offsets from static analysis +- Response injection requires finding the internal response queue mechanism (not yet done) +- Without hardware, device-specific commands won't be triggered by the UI + +**Lesson:** Always decompile the top-level API first before assuming the protocol layer. We spent significant effort on USB binary protocol analysis when the actual host-device interface was JSON RPC. The binary USB protocol is an implementation detail inside the graph server, not the primary interface. + +## Tool Reference + +### Frida Commands +```bash +# Find renderer PID +pgrep -f "Saleae Logic Helper \(Renderer\)" + +# Run script (non-interactive) +frida -q -p -l script.js + +# List loaded modules +frida -q -p -e 'Process.enumerateModules().forEach(m => console.log(m.name, m.base))' +``` + +### r2ghidra Commands +```bash +# Install +r2pm -U && r2pm -ci r2ghidra + +# Decompile a function at address +r2 -q -e scr.color=0 -c 'aaa; s 0xADDR; af; pdg' binary.dylib + +# Find string cross-references +r2 -q -e scr.color=0 -c 'aaa; axt @@ str.SearchString*' binary.dylib +``` + +### Ghidra Headless +```bash +# Create project and import +mkdir -p /tmp/ghidra_project +analyzeHeadless /tmp/ghidra_project ProjectName \ + -import binary.dylib \ + -processor "AARCH64:LE:64:v8A" + +# Process existing (run script on already-analyzed binary) +analyzeHeadless /tmp/ghidra_project ProjectName \ + -process binary.dylib \ + -postScript ScriptName.java \ + -scriptPath /path/to/scripts +``` diff --git a/firmware_analysis/README.md b/firmware_analysis/README.md new file mode 100644 --- /dev/null +++ b/firmware_analysis/README.md @@ -0,0 +1,66 @@ +# Saleae Logic Pro Protocol Reverse Engineering + +Reverse engineering the Saleae Logic Pro MSO USB wire protocol for building an open-source compatible client. + +## Status + +**Protocol is fully documented.** A complete clean-room Rust implementation exists at [eisbaw/saleae_logic_reversed](https://github.com/eisbaw/saleae_logic_reversed) with hardware-validated command opcodes, XOR obfuscation, FPGA registers, and capture format. + +This repo contains our independent RE process documentation and supplementary findings from static analysis of Logic 2 v2.4.40 (macOS ARM64). + +## Key Documents + +| File | Description | +|------|-------------| +| `WIRE_PROTOCOL.md` | **Complete wire protocol spec** — consolidated from saleae_logic_reversed + our analysis | +| `COMMAND_IDS.md` | Graph server internal command IDs (Layer 2 JSON RPC, NOT USB opcodes) | +| `PROCESS_LOG.md` | Full methodology log: tools, challenges, lessons learned across 7 phases | +| `README.md` | This file | + +## Frida Scripts + +| Script | Purpose | +|--------|---------| +| `fake_device_v3.js` | Hook DriveRequest/PopResponse JSON RPC layer | +| `hook_send.js` | Hook SendRequest at binary protocol layer | +| `hook_serialization.js` | Hook memcpy for packet construction monitoring | +| `memory_scan.js` | Scan loaded dylib memory for protocol constants | +| `quick_extract.js` | Lightweight DriveRequest logger | +| `extract_protocol.js` | Comprehensive protocol extraction (strings + xrefs) | +| `hook_drive_request.js` | Original DriveRequest hook (superseded by v3) | +| `fake_device_v2.js` | JSON RPC hook with known addresses (superseded by v3) | + +## Architecture Discovery + +The Saleae Logic 2 software has **two distinct protocol layers**: + +``` +┌─────────────────────────────────────────────────────┐ +│ Electron UI (React/TypeScript) │ +│ ↓ gRPC / IPC │ +│ Graph Server (C++ libgraph_server_shared.dylib) │ +│ ↓ JSON RPC (DriveRequest/PopResponse) │ ← Layer 2 (our decompilation) +│ MsoDeviceInterface → OsxUsbDevice │ +│ ↓ USB Bulk EP1 │ +│ Cypress FX3 Microcontroller │ ← Layer 1 (saleae_logic_reversed) +│ ↓ GPIF │ +│ FPGA (Lattice ECP5) → ADC → Sample Data on EP2 │ +└─────────────────────────────────────────────────────┘ +``` + +**Layer 1 (USB):** Single-byte opcodes (0x01, 0x02, 0x7D-0x8B). This is what you need for a compatible client. Fully documented in `WIRE_PROTOCOL.md`. + +**Layer 2 (JSON RPC):** Internal graph server commands with numeric IDs (0x02-0xE9). Only relevant if modifying Logic 2 itself. Documented in `COMMAND_IDS.md`. + +## Reference Implementation + +```bash +git clone https://github.com/eisbaw/saleae_logic_reversed.git +``` + +Key files in that repo: +- `crates/saleae-proto/src/ep1_opcodes.rs` — USB opcode constants +- `crates/saleae-proto/src/obfuscation.rs` — XOR encryption (Pro devices) +- `crates/saleae-driver/src/ep1.rs` — EP1 command channel +- `doc/protocol/` — full protocol documentation (hardware-validated) +- `re/` — Ghidra projects, USB pcaps, RE tools diff --git a/firmware_analysis/WIRE_PROTOCOL.md b/firmware_analysis/WIRE_PROTOCOL.md new file mode 100644 --- /dev/null +++ b/firmware_analysis/WIRE_PROTOCOL.md @@ -0,0 +1,107 @@ +# Saleae Logic Pro Wire Protocol — Complete Specification + +**Sources:** +- [eisbaw/saleae_logic_reversed](https://github.com/eisbaw/saleae_logic_reversed) (hardware-validated on Logic Pro 8) +- Static analysis of `libgraph_server_shared.dylib` v2.4.40 (Logic 2 macOS ARM64) + +## Important Distinction: Two Protocol Layers + +The Saleae Logic 2 software has **two distinct protocol layers**: + +### Layer 1: FX3 USB Protocol (documented here) +Raw USB bulk transfers between host and Cypress FX3 microcontroller. Single-byte opcodes. This is what `saleae_logic_reversed` implements and what you need to build a compatible device/driver. + +### Layer 2: Graph Server JSON RPC (internal to Logic 2) +JSON messages between Electron UI and the C++ graph server library (`DriveRequest`/`PopResponse`). The graph server internally translates these into Layer 1 USB commands. Our decompilation extracted command IDs from THIS layer, which are NOT the same as the USB opcodes below. + +**For building a wire-compatible implementation, use Layer 1.** + +--- + +## USB Endpoints + +| Endpoint | Direction | Purpose | +|----------|-----------|---------| +| EP0 (Control) | Bidirectional | Firmware upload only (vendor bRequest=0xA0) | +| EP1 OUT (0x01) | Host → Device | All device control commands | +| EP1 IN (0x81) | Device → Host | All command responses | +| EP2 IN (0x82) | Device → Host | Sample data stream during capture | + +## Device VID:PID + +| VID | PID | Device | +|-----|-----|--------| +| 0x21A9 | 0x1003 | Logic 4 | +| 0x21A9 | 0x1004 | Logic 8 | +| 0x21A9 | 0x1005 | Logic Pro 8 | +| 0x21A9 | 0x1006 | Logic Pro 16 | +| 0x21A9 | 0x1007 | Logic Pro MSO (from our firmware analysis) | + +Before firmware upload, devices enumerate as Cypress bootloader (VID 04B4:00F3). + +## EP1 Command Opcodes + +| Opcode | Name | Payload | Response | +|--------|------|---------|----------| +| 0x01 | START_READ | None | `[0x07, 0x00]` | +| 0x02 | STOP_STREAM | None | `[0x07, 0x00]` | +| 0x07 | ReadVersion (I2C tunnel) | I2C params | Variable | +| 0x55 | WriteEeprom | Addr + data | Read-back confirmation | +| 0x7D | Ping | `[0x55]` | `[0xAA]` | +| 0x7E | ResetFpga | None | `[0x00]` | +| 0x7F | WriteConfigurationData | Len + data | `[0x00]` per chunk | +| 0x80 | WriteRegisters | Count + addr/val pairs | 1 byte ACK (Pro) | +| 0x81 | ReadRegisters | Count + addresses | Count bytes (values) | +| 0x82 | ResetAndReEnumerate | None | (device resets) | +| 0x86 | StopDataPath | None | Unknown | +| 0x87 | WriteI2C | I2C addr + data | Confirmation | +| 0x88 | ReadI2C | Variable | Variable | +| 0x89 | Device2_1 | Unknown | Variable | +| 0x8A | CMD_ASSERT_TEST | Unknown | Unknown | +| 0x8B | GetDeviceInfo | None | Null-terminated ASCII string | + +## XOR Obfuscation (Pro Devices Only) + +Logic Pro 8/16 XOR-encrypt all EP1 traffic after seed exchange: +- Default seed: `0x354b248e` +- ReseedDevice: host sends 4 random bytes encrypted with initial seed +- LFSR: 32-bit, taps at bits 0, 1, 21, 31 +- See `saleae_logic_reversed/crates/saleae-proto/src/obfuscation.rs` + +## Capture Sequence + +``` +1. SetBankPower() → register writes (direct path) +2. SetCaptureParameters() → WriteConfigurationData (0x7F), chunked +3. ReseedDevice() → 4 random bytes for XOR seed +4. Ping [0x7D, 0x55] → expect [0xAA], up to 5 retries +5. ComputeTransferSize() → host-side buffer calculation +6. START_READ [0x01] → begin EP2 IN streaming +7. Async bulk reads on EP2 IN +``` + +## FPGA Programming Sequence + +``` +1. ResetFpga [0x7E] → expect [0x00] +2. WriteConfigurationData [0x7F] × N chunks +3. Post-programming register init (direct path) +``` + +## Sample Data Format (EP2 IN) + +- Digital: 1 bit per sample, packed 8 per byte +- Analog: int16_t (signed 16-bit), right-justified two's complement +- Channels interleaved on EP2 IN +- Voltage conversion: `voltage = adc_code * scale + offset` + +## Reference Implementation + +Clone and study: https://github.com/eisbaw/saleae_logic_reversed + +Key files: +- `crates/saleae-proto/src/ep1_opcodes.rs` — opcode constants +- `crates/saleae-proto/src/obfuscation.rs` — XOR encryption +- `crates/saleae-driver/src/ep1.rs` — EP1 command channel +- `doc/protocol/` — full protocol documentation +- `re/` — Ghidra projects, USB pcaps, RE tools diff --git a/firmware_analysis/frida_scripts/extract_protocol.js b/firmware_analysis/frida_scripts/extract_protocol.js new file mode 100644 --- /dev/null +++ b/firmware_analysis/frida_scripts/extract_protocol.js @@ -0,0 +1,251 @@ +/** + * extract_protocol.js + * + * Comprehensive protocol extraction from libgraph_server_shared.dylib. + * Searches for magic tokens, command IDs, CRC constants, and packet framing + * by scanning memory and hooking key functions. + * + * Usage: frida -f "/Applications/Saleae Logic.app/Contents/MacOS/Logic" \ + * -l extract_protocol.js + */ + +const DYLIB = 'libgraph_server_shared.dylib'; + +console.log('[*] Protocol Extraction Script'); +console.log('[*] ========================\n'); + +// ============================================================ +// Phase 1: Scan dylib memory for protocol constants +// ============================================================ + +function scanForMagicTokens() { + console.log('[Phase 1] Scanning for 16-byte magic tokens...\n'); + + const mod = Process.getModuleByName(DYLIB); + if (!mod) { + console.log('[-] Could not find ' + DYLIB); + return; + } + + console.log(` Module base: ${mod.base}, size: ${mod.size}`); + + // Scan __DATA_CONST and __DATA segments for 16-byte aligned constants + // that look like magic tokens (mix of printable ASCII, not all zeros) + const candidates = []; + const ptr = mod.base; + const size = mod.size; + + for (let off = 0; off < size - 16; off += 16) { + try { + const chunk = ptr.add(off).readByteArray(16); + const bytes = new Uint8Array(chunk); + + // Skip all-zeros and all-same + let uniqueBytes = new Set(bytes).size; + if (uniqueBytes < 4) continue; + + // Count printable ASCII + let printable = 0; + for (let b of bytes) { + if (b >= 32 && b < 127) printable++; + } + + // Magic token criteria: mostly printable, 16 bytes, some structure + if (printable >= 10 && printable <= 16) { + const ascii = Array.from(bytes).map(b => + b >= 32 && b < 127 ? String.fromCharCode(b) : '.' + ).join(''); + + // Filter out obvious non-tokens + if (!ascii.includes('/') && !ascii.includes('{') && + !ascii.startsWith('..') && !ascii.includes('thread')) { + candidates.push({ + offset: off, + hex: Array.from(bytes).map(b => ('0'+b.toString(16)).slice(-2)).join(''), + ascii: ascii + }); + } + } + } catch(e) { + // Skip unreadable regions + } + } + + console.log(` Found ${candidates.length} candidates:\n`); + for (const c of candidates.slice(0, 50)) { + console.log(` 0x${c.offset.toString(16).padStart(8,'0')}: ${c.hex} |${c.ascii}|`); + } + console.log(''); +} + +// ============================================================ +// Phase 2: Hook IOKit to capture device enumeration +// ============================================================ + +function hookIOKit() { + console.log('[Phase 2] Hooking IOKit USB enumeration...\n'); + + const IOServiceMatching = Module.findExportByName(null, 'IOServiceMatching'); + const IOIteratorNext = Module.findExportByName(null, 'IOIteratorNext'); + + if (IOServiceMatching) { + Interceptor.attach(IOServiceMatching, { + onEnter(args) { + try { + const serviceName = args[0].readCString(); + if (serviceName) { + console.log(` IOServiceMatching("${serviceName}")`); + } + } catch(e) {} + }, + onLeave(retval) { + // Returns CFDictionaryRef with matching criteria + } + }); + console.log(' [+] Hooked IOServiceMatching'); + } + + if (IOIteratorNext) { + Interceptor.attach(IOIteratorNext, { + onLeave(retval) { + if (!retval.isNull()) { + console.log(` IOIteratorNext -> found device: ${retval}`); + } + } + }); + console.log(' [+] Hooked IOIteratorNext'); + } + console.log(''); +} + +// ============================================================ +// Phase 3: Hook DriveRequest and dump raw packets +// ============================================================ + +function hookProtocol() { + console.log('[Phase 3] Hooking protocol entry points...\n'); + + const exports = [ + 'DriveRequest', 'PopResponse', 'FreeResponseBuffer', + 'CreateGraphServer', 'DestroyGraphServer', 'FlushLog' + ]; + + for (const name of exports) { + const addr = Module.getExportByName(DYLIB, name); + if (addr) { + console.log(` [+] ${name} at ${addr}`); + } else { + console.log(` [-] ${name} not found`); + } + } + + // Hook DriveRequest to capture outgoing packets + const DriveRequest = Module.getExportByName(DYLIB, 'DriveRequest'); + if (DriveRequest) { + let count = 0; + Interceptor.attach(DriveRequest, { + onEnter(args) { + count++; + const len = args[2].toInt32(); + console.log(`\n >>> REQUEST #${count} (${len} bytes):`); + + if (len > 0 && len < 65536) { + const data = args[1].readByteArray(Math.min(len, 512)); + const bytes = new Uint8Array(data); + + // Print hex dump + for (let i = 0; i < Math.min(bytes.length, 256); i += 16) { + const hex = Array.from(bytes.slice(i, Math.min(i+16, bytes.length))) + .map(b => ('0'+b.toString(16)).slice(-2)).join(' '); + const ascii = Array.from(bytes.slice(i, Math.min(i+16, bytes.length))) + .map(b => b >= 32 && b < 127 ? String.fromCharCode(b) : '.').join(''); + console.log(` ${i.toString(16).padStart(4,'0')}: ${hex.padEnd(48)} |${ascii}|`); + } + if (len > 256) console.log(` ... (${len} total bytes)`); + } + } + }); + } + + // Hook PopResponse to capture incoming packets + const PopResponse = Module.getExportByName(DYLIB, 'PopResponse'); + if (PopResponse) { + let count = 0; + Interceptor.attach(PopResponse, { + onLeave(retval) { + if (!retval.isNull()) { + count++; + console.log(`\n <<< RESPONSE #${count} at ${retval}`); + // Try to read the response buffer + try { + // Guess at struct layout: might be {ptr, len} or just a ptr + const firstWord = retval.readPointer(); + const secondWord = retval.add(Process.pointerSize).readU32(); + + if (secondWord > 0 && secondWord < 1000000) { + console.log(` Buffer: ${firstWord}, Length: ${secondWord}`); + const data = firstWord.readByteArray(Math.min(secondWord, 256)); + const bytes = new Uint8Array(data); + for (let i = 0; i < Math.min(bytes.length, 128); i += 16) { + const hex = Array.from(bytes.slice(i, Math.min(i+16, bytes.length))) + .map(b => ('0'+b.toString(16)).slice(-2)).join(' '); + console.log(` ${i.toString(16).padStart(4,'0')}: ${hex}`); + } + } + } catch(e) { + console.log(` (struct parse failed: ${e.message})`); + } + } + } + }); + } + console.log(''); +} + +// ============================================================ +// Phase 4: Search for known string constants in memory +// ============================================================ + +function searchProtocolStrings() { + console.log('[Phase 4] Searching for protocol strings in memory...\n'); + + const mod = Process.getModuleByName(DYLIB); + const patterns = [ + 'SALEAE', 'MAGIC', 'TOKEN', 'ControlCommand', + 'SerializeMagic', 'InvalidMagic', 'FwStatus', + 'SuccessResponse', 'ByteBuffer', 'CRC' + ]; + + for (const pattern of patterns) { + const matches = Memory.scanSync(mod.base, mod.size, + Array.from(pattern).map(c => c.charCodeAt(0).toString(16)).join(' ')); + + if (matches.length > 0) { + console.log(` "${pattern}": ${matches.length} matches`); + for (const m of matches.slice(0, 5)) { + // Read surrounding context + try { + const ctx = m.address.sub(8).readByteArray(48); + const bytes = new Uint8Array(ctx); + const ascii = Array.from(bytes).map(b => + b >= 32 && b < 127 ? String.fromCharCode(b) : '.' + ).join(''); + console.log(` @ ${m.address}: ${ascii}`); + } catch(e) {} + } + } + } + console.log(''); +} + +// ============================================================ +// Run all phases +// ============================================================ + +scanForMagicTokens(); +hookIOKit(); +hookProtocol(); +searchProtocolStrings(); + +console.log('[*] Extraction complete. Interact with Logic 2 to trigger protocol activity.'); +console.log('[*] Watch for REQUEST/RESPONSE dumps above.'); diff --git a/firmware_analysis/frida_scripts/fake_device_v2.js b/firmware_analysis/frida_scripts/fake_device_v2.js new file mode 100644 --- /dev/null +++ b/firmware_analysis/frida_scripts/fake_device_v2.js @@ -0,0 +1,90 @@ +/** + * fake_device_v2.js - Fake Saleae device via JSON RPC interception + * + * Hooks DriveRequest/PopResponse at known addresses to capture the + * JSON RPC protocol between Electron UI and the graph server. + * + * This is the CORRECT interception layer - DriveRequest takes JSON, + * not raw USB binary. The graph server internally translates JSON + * commands into the USB binary protocol. + * + * Usage: frida -q -p $(pgrep -f "Saleae Logic Helper \(Renderer\)") -l fake_device_v2.js + */ + +console.log("[*] Fake Saleae Device v2 (JSON RPC Layer)"); +console.log("[*] ======================================\n"); + +var mod = Process.enumerateModules().find(function(m) { + return m.name.indexOf("libgraph_server_shared") >= 0; +}); +if (!mod) { console.log("[-] dylib not found"); throw ""; } +console.log("[+] Module base: " + mod.base); + +// Known offsets from r2 static analysis (Mach-O __TEXT at file offset 0) +var DRIVE_REQUEST_OFFSET = 0x5e7b8; +var POP_RESPONSE_OFFSET = 0x5f078; +var CREATE_GRAPH_OFFSET = 0x5e4dc; +var FREE_RESPONSE_OFFSET = 0x5f3bc; + +var driveReqAddr = mod.base.add(DRIVE_REQUEST_OFFSET); +var popRespAddr = mod.base.add(POP_RESPONSE_OFFSET); + +console.log("[+] DriveRequest @ " + driveReqAddr); +console.log("[+] PopResponse @ " + popRespAddr); + +// ============================================================ +// Hook DriveRequest(server, json_ptr, json_len) +// ============================================================ +Interceptor.attach(driveReqAddr, { + onEnter: function(args) { + var jsonPtr = args[1]; + var jsonLen = args[2].toInt32(); + + if (jsonLen > 0 && jsonLen < 1000000) { + try { + var jsonStr = jsonPtr.readUtf8String(jsonLen); + console.log("\n>>> DRIVE REQUEST (" + jsonLen + "B):"); + try { + var parsed = JSON.parse(jsonStr); + console.log(JSON.stringify(parsed, null, 2)); + } catch(e) { + console.log(jsonStr.substring(0, 2000)); + } + } catch(e) { + console.log(" (read error: " + e.message + ")"); + } + } + } +}); + +// ============================================================ +// Hook PopResponse(server, out_data_ptr, out_len_ptr, ...) +// ============================================================ +Interceptor.attach(popRespAddr, { + onEnter: function(args) { + this.outDataPtr = args[1]; + this.outLenPtr = args[2]; + }, + onLeave: function(retval) { + try { + var dp = this.outDataPtr.readPointer(); + var dl = this.outLenPtr.readU32(); + + if (dl > 0 && dl < 1000000 && !dp.isNull()) { + var s = dp.readUtf8String(dl); + console.log("\n<<< POP RESPONSE (" + dl + "B):"); + try { + var parsed = JSON.parse(s); + console.log(JSON.stringify(parsed, null, 2)); + } catch(e) { + console.log(s.substring(0, 2000)); + } + } + } catch(e) {} + } +}); + +console.log("\n[*] Hooks installed."); +console.log("[*] Interact with Logic 2 to capture JSON RPC traffic."); +console.log("[*] Without a device: captures graph kernel requests (UI operations)."); +console.log("[*] With a device: captures FULL device command sequence."); diff --git a/firmware_analysis/frida_scripts/fake_device_v3.js b/firmware_analysis/frida_scripts/fake_device_v3.js new file mode 100644 --- /dev/null +++ b/firmware_analysis/frida_scripts/fake_device_v3.js @@ -0,0 +1,78 @@ +/** + * fake_device_v3.js - Spawn-aware fake device + * Waits for the dylib to load, then hooks DriveRequest/PopResponse. + * + * Usage: frida -q -f "/Applications/Saleae Logic.app/Contents/MacOS/Logic" -l fake_device_v3.js + * or: frida -q -p $(pgrep -f "Saleae Logic Helper \(Renderer\)") -l fake_device_v3.js + */ + +console.log("[*] Fake Saleae Device v3 (spawn-aware)"); + +// Known offsets from r2 static analysis +var OFFSETS = { + DriveRequest: 0x5e7b8, + PopResponse: 0x5f078, +}; + +function installHooks() { + var mod = Process.enumerateModules().find(function(m) { + return m.name.indexOf("libgraph_server_shared") >= 0; + }); + + if (!mod) { + console.log("[*] Waiting for dylib to load..."); + setTimeout(installHooks, 500); + return; + } + + console.log("[+] Module loaded: " + mod.name + " @ " + mod.base); + + // Hook DriveRequest + var drAddr = mod.base.add(OFFSETS.DriveRequest); + Interceptor.attach(drAddr, { + onEnter: function(args) { + var jsonPtr = args[1]; + var jsonLen = args[2].toInt32(); + if (jsonLen > 0 && jsonLen < 1000000) { + try { + var jsonStr = jsonPtr.readUtf8String(jsonLen); + console.log("\n>>> REQ (" + jsonLen + "B):"); + try { + console.log(JSON.stringify(JSON.parse(jsonStr), null, 2)); + } catch(e) { + console.log(jsonStr.substring(0, 2000)); + } + } catch(e) {} + } + } + }); + console.log("[+] DriveRequest hooked @ " + drAddr); + + // Hook PopResponse + var prAddr = mod.base.add(OFFSETS.PopResponse); + Interceptor.attach(prAddr, { + onEnter: function(args) { + this.outData = args[1]; + this.outLen = args[2]; + }, + onLeave: function(retval) { + try { + var dp = this.outData.readPointer(); + var dl = this.outLen.readU32(); + if (dl > 0 && dl < 1000000 && !dp.isNull()) { + var s = dp.readUtf8String(dl); + console.log("\n<<< RESP (" + dl + "B):"); + try { + console.log(JSON.stringify(JSON.parse(s), null, 2)); + } catch(e) { + console.log(s.substring(0, 2000)); + } + } + } catch(e) {} + } + }); + console.log("[+] PopResponse hooked @ " + prAddr); + console.log("\n[*] Ready. Watching startup traffic...\n"); +} + +installHooks(); diff --git a/firmware_analysis/frida_scripts/hook_drive_request.js b/firmware_analysis/frida_scripts/hook_drive_request.js new file mode 100644 --- /dev/null +++ b/firmware_analysis/frida_scripts/hook_drive_request.js @@ -0,0 +1,111 @@ +/** + * hook_drive_request.js + * + * Intercepts DriveRequest/PopResponse in libgraph_server_shared.dylib. + * These are the exported C API functions that ALL device communication flows through. + * + * Usage: frida -n "Logic" -l hook_drive_request.js + * or: frida -f "/Applications/Saleae Logic.app/Contents/MacOS/Logic" -l hook_drive_request.js + */ + +const DYLIB = 'libgraph_server_shared.dylib'; + +// DriveRequest(void* server, const uint8_t* request_data, size_t request_len) +// Returns a response buffer pointer (use PopResponse to get it) +const DriveRequest = Module.getExportByName(DYLIB, 'DriveRequest'); +const PopResponse = Module.getExportByName(DYLIB, 'PopResponse'); +const FreeResponseBuffer = Module.getExportByName(DYLIB, 'FreeResponseBuffer'); +const CreateGraphServer = Module.getExportByName(DYLIB, 'CreateGraphServer'); +const DestroyGraphServer = Module.getExportByName(DYLIB, 'DestroyGraphServer'); + +let requestCount = 0; +let responseCount = 0; + +function hexdump(buf, len, maxBytes) { + maxBytes = maxBytes || 256; + const actualLen = Math.min(len, maxBytes); + const arr = []; + for (let i = 0; i < actualLen; i++) { + arr.push(('0' + buf.add(i).readU8().toString(16)).slice(-2)); + } + let result = arr.join(' '); + if (len > maxBytes) result += ` ... (${len} bytes total)`; + return result; +} + +if (DriveRequest) { + Interceptor.attach(DriveRequest, { + onEnter(args) { + this.server = args[0]; + this.reqData = args[1]; + this.reqLen = args[2].toInt32(); + requestCount++; + + console.log(`\n=== DriveRequest #${requestCount} ===`); + console.log(` Server: ${this.server}`); + console.log(` Length: ${this.reqLen}`); + console.log(` Data: ${hexdump(this.reqData, this.reqLen)}`); + + // Try to identify the command type from the first few bytes + if (this.reqLen >= 4) { + const first4 = this.reqData.readU32(); + console.log(` First 4 bytes (LE): 0x${first4.toString(16)}`); + } + if (this.reqLen >= 16) { + // Possible magic token region + console.log(` Bytes 0-15: ${hexdump(this.reqData, 16)}`); + } + }, + onLeave(retval) { + console.log(` Return: ${retval}`); + } + }); + console.log('[+] Hooked DriveRequest'); +} else { + console.log('[-] DriveRequest not found'); +} + +if (PopResponse) { + Interceptor.attach(PopResponse, { + onEnter(args) { + this.server = args[0]; + }, + onLeave(retval) { + responseCount++; + console.log(`\n=== PopResponse #${responseCount} ===`); + console.log(` Response ptr: ${retval}`); + + // The response is likely a struct with data+len + // Try reading it as a pointer+length pair + if (!retval.isNull()) { + try { + // Attempt to read response data + // Format depends on internal struct layout - may need adjustment + const possibleData = retval.readPointer(); + const possibleLen = retval.add(Process.pointerSize).readU32(); + + if (possibleLen > 0 && possibleLen < 1000000) { + console.log(` Possible data ptr: ${possibleData}`); + console.log(` Possible length: ${possibleLen}`); + console.log(` Data: ${hexdump(possibleData, possibleLen)}`); + } + } catch(e) { + console.log(` (could not read response structure: ${e.message})`); + } + } + } + }); + console.log('[+] Hooked PopResponse'); +} + +if (CreateGraphServer) { + Interceptor.attach(CreateGraphServer, { + onLeave(retval) { + console.log(`\n[+] CreateGraphServer returned: ${retval}`); + } + }); + console.log('[+] Hooked CreateGraphServer'); +} + +console.log('\n[*] Waiting for DriveRequest/PopResponse calls...'); +console.log('[*] Open Logic 2 and try to connect to a device to trigger protocol activity.'); diff --git a/firmware_analysis/frida_scripts/hook_send.js b/firmware_analysis/frida_scripts/hook_send.js new file mode 100644 --- /dev/null +++ b/firmware_analysis/frida_scripts/hook_send.js @@ -0,0 +1,100 @@ +// hook_send.js - Hook SendRequest to capture actual packet bytes +var mod = Process.enumerateModules().find(function(m) { return m.name.indexOf("libgraph_server_shared") >= 0; }); +if (!mod) { console.log("[-] dylib not found"); throw ""; } + +// SendRequest is at offset 0xf22d18 from module base... but ASLR. +// Find it by scanning for the "SendRequest" string reference pattern. +var needle = "53 65 6e 64 52 65 71 75 65 73 74"; // "SendRequest" +var hits = Memory.scanSync(mod.base, mod.size, needle); +console.log("[*] Found " + hits.length + " 'SendRequest' strings"); + +// The function that references "SendRequest" string IS SendRequest itself +// (it uses it in log messages). We already know the pattern from static analysis. +// Let's just hook at the known relative offset. + +// Actually, let's find it properly via xrefs +for (var h = 0; h < hits.length; h++) { + var strAddr = hits[h].address; + console.log("[*] String at " + strAddr); +} + +// Alternative: hook DriveRequest which we know is exported +try { + var dr = Module.getExportByName(mod.name, "DriveRequest"); + console.log("[+] DriveRequest at " + dr); + + Interceptor.attach(dr, { + onEnter: function(args) { + var len = args[2].toInt32(); + if (len > 0 && len < 65536) { + var data = args[1].readByteArray(Math.min(len, 256)); + var bytes = new Uint8Array(data); + var hex = ""; + var ascii = ""; + for (var i = 0; i < bytes.length; i++) { + hex += ("0" + bytes[i].toString(16)).slice(-2) + " "; + ascii += (bytes[i] >= 32 && bytes[i] < 127) ? String.fromCharCode(bytes[i]) : "."; + if ((i + 1) % 16 === 0) { + console.log(" " + hex + " |" + ascii + "|"); + hex = ""; + ascii = ""; + } + } + if (hex.length > 0) { + console.log(" " + hex + " |" + ascii + "|"); + } + console.log(">>> REQUEST (" + len + " bytes):"); + } + } + }); + console.log("[+] Hooked! Waiting for traffic..."); +} catch(e) { + console.log("[-] " + e.message); + console.log("[*] Trying to find SendRequest by signature..."); + + // Scan for the SendRequest function prologue pattern + // STP x24-x30 pattern followed by specific stack frame setup + var textRanges = Process.enumerateRanges("r-x").filter(function(r) { + return r.base.compare(mod.base) >= 0 && r.base.add(r.size).compare(mod.base.add(mod.size)) <= 0; + }); + + for (var t = 0; t < textRanges.length; t++) { + var range = textRanges[t]; + var raw = new Uint8Array(range.base.readByteArray(range.size)); + + // Look for the unique instruction sequence at SendRequest start: + // a9bc5ff8 (STP), a90157f6 (STP), a9024ff4 (STP), a9037bfd (STP) + for (var i = 0; i < raw.length - 16; i += 4) { + var v = new DataView(raw.buffer, raw.byteOffset + i, 4); + if (v.getUint32(0, true) === 0xa9bc5ff8 && + v.getUint32(4, true) === 0xa90157f6 && + v.getUint32(8, true) === 0xa9024ff4 && + v.getUint32(12, true) === 0xa9037bfd) { + + var funcAddr = range.base.add(i); + console.log("[+] Found SendRequest at " + funcAddr); + + // Hook it: x0=this, x1=request_buf, x2=request_len, x3=? + Interceptor.attach(funcAddr, { + onEnter: function(args) { + var buf = args[1]; + var len = args[2].toInt32(); + if (len > 0 && len < 65536) { + try { + var data = buf.readByteArray(Math.min(len, 256)); + var bytes = new Uint8Array(data); + var hex = ""; + for (var j = 0; j < Math.min(bytes.length, 64); j++) { + hex += ("0" + bytes[j].toString(16)).slice(-2) + " "; + } + console.log("\n>>> PKT (" + len + "B): " + hex); + } catch(e) {} + } + } + }); + console.log("[+] Hooked!"); + break; + } + } + } +} diff --git a/firmware_analysis/frida_scripts/hook_serialization.js b/firmware_analysis/frida_scripts/hook_serialization.js new file mode 100644 --- /dev/null +++ b/firmware_analysis/frida_scripts/hook_serialization.js @@ -0,0 +1,132 @@ +/** + * hook_serialization.js + * + * Hooks internal serialization code to find the magic token and command ID encoding. + * Uses memory access tracing to catch when protocol packets are constructed. + * + * Usage: frida -f "/Applications/Saleae Logic.app/Contents/MacOS/Logic" \ + * -l hook_serialization.js + */ + +const DYLIB = 'libgraph_server_shared.dylib'; + +console.log('[*] Serialization Hook Script\n'); + +// ============================================================ +// Strategy: Use Stalker to trace memory writes in the dylib +// that write to stack buffers or heap allocations near known +// protocol string references. +// ============================================================ + +// First, find all occurrences of key protocol strings in memory +const mod = Process.getModuleByName(DYLIB); +const searchString = 'ControlCommand_None'; +const needle = Array.from(searchString).map(c => c.charCodeAt(0).toString(16)).join(' '); +const matches = Memory.scanSync(mod.base, mod.size, needle); + +console.log(`Found ${matches.length} refs to "${searchString}"`); +for (const m of matches) { + console.log(` @ ${m.address}`); +} + +// ============================================================ +// Hook memcpy/memmove to catch bulk data copies into protocol buffers +// ============================================================ + +const memcpy = Module.findExportByName(null, 'memcpy'); +const memmove = Module.findExportByName(null, 'memmove'); + +let capturedBuffers = []; + +function hookMemTransfer(name, addr) { + if (!addr) return; + + Interceptor.attach(addr, { + onEnter(args) { + this.dst = args[0]; + this.src = args[1]; + this.len = args[2].toInt32(); + }, + onLeave(retval) { + // Look for transfers of sizes consistent with protocol packets + // Magic token (16) + CRC (4) + length (4) + cmd (1-4) = 25-28 bytes minimum + // Typical command: 28-256 bytes + if (this.len >= 20 && this.len <= 4096) { + try { + const data = this.dst.readByteArray(Math.min(this.len, 64)); + const bytes = new Uint8Array(data); + + // Check if first 16 bytes look like a magic token (mostly printable) + let printable = 0; + for (let i = 0; i < Math.min(16, bytes.length); i++) { + if (bytes[i] >= 32 && bytes[i] < 127) printable++; + } + + if (printable >= 10 && this.len >= 24) { + const hex = Array.from(bytes).map(b => + ('0'+b.toString(16)).slice(-2)).join(' '); + const ascii = Array.from(bytes.slice(0, 16)).map(b => + b >= 32 && b < 127 ? String.fromCharCode(b) : '.').join(''); + + console.log(`\n[!] Potential protocol packet via ${name} (${this.len} bytes):`); + console.log(` Dst: ${this.dst}, Src: ${this.src}`); + console.log(` Token?: |${ascii}|`); + console.log(` Hex: ${hex}`); + + capturedBuffers.push({ + dst: this.dst, + len: this.len, + data: this.dst.readByteArray(this.len) + }); + + // Keep only last 100 + if (capturedBuffers.length > 100) capturedBuffers.shift(); + } + } catch(e) {} + } + } + }); +} + +hookMemTransfer('memcpy', memcpy); +hookMemTransfer('memmove', memmove); +console.log('[+] Hooked memcpy/memmove for protocol buffer detection'); + +// ============================================================ +// Also hook malloc to track buffer allocations near protocol code +// ============================================================ + +const malloc = Module.findExportByName(null, 'malloc'); +if (malloc) { + Interceptor.attach(malloc, { + onEnter(args) { + this.size = args[0].toInt32(); + }, + onLeave(retval) { + // Track allocations of sizes consistent with protocol buffers + if (this.size >= 24 && this.size <= 8192 && !retval.isNull()) { + // Store for later correlation + // (too noisy to log every allocation) + } + } + }); +} + +// ============================================================ +// Expose captured buffers for inspection +// ============================================================ + +globalThis.dumpCaptured = function() { + console.log(`\n[*] ${capturedBuffers.length} captured buffers:\n`); + for (let i = 0; i < capturedBuffers.length; i++) { + const buf = capturedBuffers[i]; + const bytes = new Uint8Array(buf.data); + const hex = Array.from(bytes.slice(0, 64)).map(b => + ('0'+b.toString(16)).slice(-2)).join(' '); + console.log(` [${i}] ${buf.len} bytes @ ${buf.dst}: ${hex}`); + } +}; + +console.log('\n[*] Monitoring memory transfers for protocol packets...'); +console.log('[*] Call dumpCaptured() to review captured buffers.'); +console.log('[*] Interact with Logic 2 to trigger protocol activity.'); diff --git a/firmware_analysis/frida_scripts/memory_scan.js b/firmware_analysis/frida_scripts/memory_scan.js new file mode 100644 --- /dev/null +++ b/firmware_analysis/frida_scripts/memory_scan.js @@ -0,0 +1,213 @@ +/** + * memory_scan.js + * + * Scans the loaded libgraph_server_shared.dylib for protocol constants. + * Works WITHOUT a physical device - just needs Logic 2 running. + * + * Usage: frida -n "Logic" -l memory_scan.js + */ + +const DYLIB = 'libgraph_server_shared.dylib'; + +console.log('[*] Memory Scan Protocol Extraction'); +console.log('[*] ================================\n'); + +// Auto-find the module - it may be loaded with full path +let mod = null; +try { + mod = Process.getModuleByName(DYLIB); +} catch(e) { + // Try finding by partial path match + const allMods = Process.enumerateModules(); + for (const m of allMods) { + if (m.name.includes('libgraph_server_shared')) { + mod = m; + break; + } + } +} + +if (!mod) { + console.log('[-] Could not find libgraph_server_shared.dylib!'); + console.log('[-] Make sure you are attached to the RENDERER process, not the main Logic process.'); + console.log('[-] Try: frida -p -l memory_scan.js'); + console.log('[-] Find renderer PID: ps aux | grep "Saleae Logic Helper (Renderer)"'); + throw new Error('Module not found'); +} +console.log(`[*] ${DYLIB} base=${mod.base} size=0x${mod.size.toString(16)}\n`); + +// ============================================================ +// 1. Find ALL occurrences of key protocol strings and dump context +// ============================================================ + +function findString(str) { + const needle = Array.from(str).map(c => c.charCodeAt(0).toString(16)).join(' '); + return Memory.scanSync(mod.base, mod.size, needle); +} + +function dumpContext(addr, before, after) { + try { + const start = addr.sub(before); + const len = before + after; + const data = start.readByteArray(len); + const bytes = new Uint8Array(data); + const lines = []; + for (let i = 0; i < bytes.length; i += 16) { + const chunk = bytes.slice(i, Math.min(i + 16, bytes.length)); + const hex = Array.from(chunk).map(b => ('0' + b.toString(16)).slice(-2)).join(' '); + const ascii = Array.from(chunk).map(b => b >= 32 && b < 127 ? String.fromCharCode(b) : '.').join(''); + const offset = start.add(i); + lines.push(` ${offset}: ${hex.padEnd(48)} |${ascii}|`); + } + return lines.join('\n'); + } catch(e) { + return ` (read error: ${e.message})`; + } +} + +const targets = [ + 'ControlCommand_None', + 'InvalidMagicToken', + 'SerializeMagicToken', + 'SuccessResponse', + 'FwStatusResponse', + 'ByteBuffer_SerializeBytes', + 'StartSerializeCrcAndLength', + 'GetFirmwareVersion', + 'HeartbeatDetectionTimer', + 'GpifStartStop', +]; + +for (const t of targets) { + const matches = findString(t); + if (matches.length > 0) { + console.log(`[+] "${t}" - ${matches.length} occurrence(s):`); + for (const m of matches) { + console.log(dumpContext(m.address, 32, 48)); + } + console.log(''); + } +} + +// ============================================================ +// 2. Exhaustive scan for 16-byte magic token candidates +// ============================================================ + +console.log('[*] Scanning for 16-byte magic token candidates...\n'); + +// Read entire module into JS for fast scanning +const rawBuf = mod.base.readByteArray(mod.size); +const raw = new Uint8Array(rawBuf); + +const tokenCandidates = []; +for (let i = 0; i < raw.length - 16; i += 8) { // 8-byte aligned + let printable = 0; + let hasUpper = false; + let hasLower = false; + let hasDigit = false; + let hasSpecial = false; + + for (let j = 0; j < 16; j++) { + const b = raw[i + j]; + if (b >= 65 && b <= 90) { printable++; hasUpper = true; } + else if (b >= 97 && b <= 122) { printable++; hasLower = true; } + else if (b >= 48 && b <= 57) { printable++; hasDigit = true; } + else if (b === 95 || b === 45 || b === 46) { printable++; hasSpecial = true; } // _ - . + else if (b >= 32 && b < 127) { printable++; } + } + + // Magic token heuristics: + // - 12+ printable chars out of 16 + // - Mix of upper/lower/digits/special (not just one type) + // - Not a file path, not an error message + if (printable >= 12 && hasUpper && (hasLower || hasDigit)) { + const slice = raw.slice(i, i + 16); + const ascii = Array.from(slice).map(b => b >= 32 && b < 127 ? String.fromCharCode(b) : '.').join(''); + + // Filter out obvious non-tokens + if (ascii.includes('/') || ascii.includes('\\') || + ascii.includes(' ') || ascii.includes('(') || + ascii.startsWith('__') || ascii.includes('thread') || + ascii.includes('Handler') || ascii.includes('Command') || + ascii.includes('failed') || ascii.includes('error')) { + continue; + } + + const hex = Array.from(slice).map(b => ('0' + b.toString(16)).slice(-2)).join(''); + tokenCandidates.push({ offset: i, hex, ascii }); + } +} + +// Deduplicate by hex value +const seen = new Set(); +const uniqueTokens = []; +for (const c of tokenCandidates) { + if (!seen.has(c.hex)) { + seen.add(c.hex); + uniqueTokens.push(c); + } +} + +console.log(`Found ${uniqueTokens.length} unique candidates:\n`); +for (const c of uniqueTokens.slice(0, 100)) { + console.log(` 0x${c.offset.toString(16).padStart(8, '0')}: ${c.hex} |${c.ascii}|`); +} + +// ============================================================ +// 3. Look for command ID enum/switch tables +// ============================================================ + +console.log('\n[*] Searching for sequential byte patterns (potential command ID tables)...\n'); + +for (let i = 0; i < raw.length - 32; i++) { + // Look for sequences of incrementing bytes (0,1,2,3... or 1,2,3,4...) + let seqLen = 0; + const startVal = raw[i]; + if (startVal > 100) continue; // command IDs are likely small + + for (let j = 0; j < 48 && (i + j) < raw.length; j++) { + if (raw[i + j] === (startVal + j) & 0xFF) { + seqLen++; + } else { + break; + } + } + + if (seqLen >= 10) { + const slice = raw.slice(i, i + seqLen); + console.log(` Sequential ${startVal}..${startVal + seqLen - 1} at offset 0x${i.toString(16)} (${seqLen} values)`); + console.log(` Hex: ${Array.from(slice).map(b => ('0'+b.toString(16)).slice(-2)).join(' ')}`); + } +} + +// ============================================================ +// 4. Search for CRC32 polynomial constant (0xEDB88320 or 0x04C11DB7) +// ============================================================ + +console.log('\n[*] Searching for CRC32 polynomial constants...\n'); + +const crc32Polys = [ + { val: 0xEDB88320, name: 'CRC32 reflected (standard)' }, + { val: 0x04C11DB7, name: 'CRC32 normal' }, + { val: 0x82608EDB, name: 'CRC32C reflected (Castagnoli)' }, + { val: 0x1EDC6F41, name: 'CRC32C normal' }, +]; + +for (const poly of crc32Polys) { + const needle = [ + ((poly.val) & 0xFF).toString(16).padStart(2, '0'), + ((poly.val >> 8) & 0xFF).toString(16).padStart(2, '0'), + ((poly.val >> 16) & 0xFF).toString(16).padStart(2, '0'), + ((poly.val >> 24) & 0xFF).toString(16).padStart(2, '0'), + ].join(' '); + + const matches = Memory.scanSync(mod.base, mod.size, needle); + if (matches.length > 0) { + console.log(` [+] ${poly.name} (0x${poly.val.toString(16)}) - ${matches.length} occurrence(s):`); + for (const m of matches.slice(0, 5)) { + console.log(` @ ${m.address}`); + } + } +} + +console.log('\n[*] Scan complete.'); diff --git a/firmware_analysis/frida_scripts/quick_extract.js b/firmware_analysis/frida_scripts/quick_extract.js new file mode 100644 --- /dev/null +++ b/firmware_analysis/frida_scripts/quick_extract.js @@ -0,0 +1,104 @@ +/** + * quick_extract.js - Fast targeted extraction (no full memory scan) + * Usage: frida -p $(pgrep -f "Renderer") -l quick_extract.js + */ + +const DYLIB_NAME = 'libgraph_server_shared'; +let mod = Process.enumerateModules().find(m => m.name.includes(DYLIB_NAME)); +if (!mod) { console.log('[-] dylib not found'); throw ''; } +console.log(`[+] ${mod.name} @ ${mod.base} (0x${mod.size.toString(16)} bytes)\n`); + +// 1. CRC32 polynomials +console.log('[CRC32 Polynomials]'); +const polys = [ + [0x20, 0x83, 0xB8, 0xED, 'CRC32 reflected'], + [0xB7, 0x1D, 0xC1, 0x04, 'CRC32 normal'], + [0xDB, 0x8E, 0x60, 0x82, 'CRC32C reflected'], + [0x41, 0x6F, 0xDC, 0x1E, 'CRC32C normal'], +]; +for (const [a,b,c,d,name] of polys) { + const needle = [a,b,c,d].map(x=>x.toString(16).padStart(2,'0')).join(' '); + const hits = Memory.scanSync(mod.base, mod.size, needle); + if (hits.length) console.log(` [+] ${name}: ${hits.length} hit(s) @ ${hits.map(h=>h.address).join(', ')}`); + else console.log(` [-] ${name}: none`); +} + +// 2. Key protocol strings + context +console.log('\n[Protocol Strings]'); +const strs = ['ControlCommand_None','InvalidMagicToken','SerializeMagicToken', + 'SuccessResponse','FwStatusResponse','StartSerializeCrcAndLength']; +for (const s of strs) { + const needle = Array.from(s).map(c=>c.charCodeAt(0).toString(16)).join(' '); + const hits = Memory.scanSync(mod.base, mod.size, needle); + for (const h of hits) { + // Read 64 bytes starting at the string + try { + const ctx = h.address.readByteArray(64); + const b = new Uint8Array(ctx); + const ascii = Array.from(b).map(x=>x>=32&&x<127?String.fromCharCode(x):'.').join(''); + console.log(` ${s} @ ${h.address}: ${ascii}`); + } catch(e) {} + } +} + +// 3. Targeted magic token search - only check near known protocol strings +console.log('\n[Magic Token Candidates (near protocol code)]'); +// The token is likely within 4KB of SerializeMagicToken or InvalidMagicToken +const anchorStrs = ['SerializeMagicToken', 'InvalidMagicToken', 'ControlCommand_None']; +const checked = new Set(); +for (const s of anchorStrs) { + const needle = Array.from(s).map(c=>c.charCodeAt(0).toString(16)).join(' '); + const hits = Memory.scanSync(mod.base, mod.size, needle); + for (const h of hits) { + // Scan ±4KB around this anchor + const regionStart = h.address.sub(4096); + const regionSize = 8192; + try { + const region = regionStart.readByteArray(regionSize); + const raw = new Uint8Array(region); + for (let i = 0; i < raw.length - 16; i += 8) { + let printable = 0, hasUpper = false, hasLower = false, hasDigit = false; + for (let j = 0; j < 16; j++) { + const b = raw[i+j]; + if (b>=65&&b<=90) { printable++; hasUpper=true; } + else if (b>=97&&b<=122) { printable++; hasLower=true; } + else if (b>=48&&b<=57) { printable++; hasDigit=true; } + else if (b===95||b===45) { printable++; } + } + if (printable >= 13 && hasUpper && (hasLower||hasDigit)) { + const slice = raw.slice(i, i+16); + const hex = Array.from(slice).map(x=>('0'+x.toString(16)).slice(-2)).join(''); + if (checked.has(hex)) continue; + checked.add(hex); + const ascii = Array.from(slice).map(x=>x>=32&&x<127?String.fromCharCode(x):'.').join(''); + // Skip obvious non-tokens + if (ascii.includes('Handler')||ascii.includes('Command')||ascii.includes('failed')|| + ascii.includes('thread')||ascii.includes('/')||ascii.includes('(')) continue; + const absAddr = regionStart.add(i); + console.log(` ${absAddr}: ${hex} |${ascii}|`); + } + } + } catch(e) {} + } +} + +// 4. Exports list +console.log('\n[Exported Functions]'); +try { + const exps = mod.enumerateExports(); + for (const e of exps) { + if (e.type === 'function') console.log(` ${e.name} @ ${e.address}`); + } +} catch(e) { + console.log(` (enumerateExports failed: ${e.message})`); + // Fallback: use nm-known exports + const known = ['DriveRequest','PopResponse','FreeResponseBuffer','CreateGraphServer','DestroyGraphServer','FlushLog']; + for (const name of known) { + try { + const addr = Module.getExportByName(mod.name, name); + console.log(` ${name} @ ${addr}`); + } catch(e2) {} + } +} + +console.log('\n[*] Done.'); -- tangled.sh