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 at0xf1cedc- 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 #
- Wrong process: The dylib loads in the Electron renderer child process, not the main
Logicprocess. Must attach to PID frompgrep -f "Saleae Logic Helper \(Renderer\)". --no-pauseremoved: Frida 17.x removed this flag; default behavior is already non-pausing.- Interactive REPL: Must use
-qflag for non-interactive/scripted execution. - Module name resolution:
Process.getModuleByName()fails because the loaded module name differs from the filename. UseProcess.enumerateModules().find()with partial match. - API version differences:
Module.findExportByName(),Module.enumerateImports(),Module.enumerateExports()have inconsistent availability across Frida versions. Fall back to memory scanning. - 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 #
- Java script compilation failure: Ghidra 12 changed the scripting classloader. Java scripts must match exact class naming conventions.
- Python requires PyGhidra: Ghidra 12 disabled legacy Jython. Python scripts need
pyghidralauncher, notanalyzeHeadless. - Project directory must exist:
analyzeHeadlesswon't create the project directory automatically. - Analysis time: 37 MB ARM64 binary takes ~4 minutes for initial analysis, plus decompilation time per function.
- 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
Recommended Next Steps #
- With hardware: Plug in Saleae device, run
fake_device.jsto capture the full handshake. The>>> OUTGOINGlines will show exact packet bytes including command IDs we haven't extracted yet (GetFirmwareVersion, FpgaConfigBegin, GpifStartStop, InRequest, Batch, ResetDevice, SpiTransfer). - 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)
- GetFirmwareVersion @
- Sample format: Decompile
ProcessStreamData/HandleWaveformDatain host code to understand the EP2 IN data framing - Full emulation: To build a complete USB emulator without hardware, need to hook IOKit async read callbacks (
ReadPipeAsyncTOcompletion handler) to inject responses. This requires finding the callback function pointer setup inOsxUsbDevice::StartReadStream. - 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
SendRequestto capture all outgoing command packets - Hook
PopResponseto observe response handling - Monitor
HandleUsbDeviceConnectionForMsoDevicefor 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.cppin Saleae's monorepo - Uses IOKit
IOUSBDeviceinterface - Async reads via
ReadPipeAsyncTO/ReadPipeTO - Device type detection returns status code
0x0Cfor 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:
- Hook
DriveRequestto intercept incoming JSON commands - Parse the command type from
contents.type - Generate appropriate JSON responses
- Inject responses by calling the internal response queue push function (or by replacing
PopResponsereturn data)
Limitations #
Module.findExportByName()andModule.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