logic pro compatible fpga based logic analyzer
picologic firmware_analysis PROCESS_LOG.md
14 kB · 274 lines
Markdown
at main

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: <SALEAE_MSOMAIN>, <SALEAE_MSOAFE_>, <SALEAE_MSOLASC>
  • 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
<SALEAE_MSOMAIN> 3c53414c4541455f4d534f4d41494e3e Main board metadata
<SALEAE_MSOAFE_> 3c53414c4541455f4d534f4146455f3e AFE board metadata
<SALEAE_MSOLASC> 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
  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:

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 #

# Find renderer PID
pgrep -f "Saleae Logic Helper \(Renderer\)"

# Run script (non-interactive)
frida -q -p <PID> -l script.js

# List loaded modules
frida -q -p <PID> -e 'Process.enumerateModules().forEach(m => console.log(m.name, m.base))'

r2ghidra Commands #

# 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 #

# 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