Reverse-engineering a VNA ECal Interface With Cynthion
Recently I’ve been working on a little reverse-engineering project, hoping to make some of my electronics test equipment more convenient to use.
Often when doing reverse-engineering, a general strategy that I follow is to make (informed) guesses about how something might work and then I go looking for ways to prove that right or wrong. In this project, Cynthion was really useful for that process as I could use it to emulate part of the target system, so that I could quickly and easily test out theories about the protocol.
This write-up goes over some of the progress I’ve made so far and hopefully it will provide some helpful techniques you can use in your own projects!
Background
In my work and hobby projects, I’m often using a Vector Network Analyzer (VNA) to measure RF components. Ideally, on each power-up and whenever the measurement parameters are changed, the VNA should be calibrated by connecting and measuring a set of four standards in turn on each of the two measurement ports. This can get a little tedious if you need to re-calibrate often, so an alternative option is to use an Electronic Calibration module (ECal) which only requires one connection per port and then has internal switches to select between the different standards automatically. ECal modules are available for my VNA (an Agilent E8803A), but they’re rare and expensive, so I’d like to figure out how the VNA communicates with them so that I can implement it myself in an open-source ECal.
Reverse-engineering options
Of course, the easiest way to do this would be to connect an ECal module and capture the USB traffic (with Packetry!), but an actual ECal is too elusive.
The next option is to disassemble and analyze the software running on the VNA, which I spent a bit of time doing, but it was slow going as I’m not too familiar with the APIs on Windows and how it uses them for USB. However, there are some quick things to learn by looking at the software. It’s split into many DLLs, so it’s easy to see the imports and exports of each and get an idea of the functionality we might expect from the USB ECal:
$ winedump -j export ecalusb.dll
Contents of ecalusb.dll: 33280 bytes
[...]
Entry Pt Ordn Name
00001F50 1 ReadModule
00002160 2 SetState
00001F90 3 ReadModule1K
000016D0 4 Reset
00001FD0 5 ReadModuleData
00002010 6 WriteModuleData
00002210 7 EraseSector
Done dumping ecalusb.dll
So, it should have ways to read and write data on the module, which makes sense as it needs to store S-parameter data describing the characteristics of the different standards to be used for calibration. It also has SetState
, which probably sets the switch positions to select different standards on each port.
Device Emulation
I decided to go down the route of emulating the ECal device, then seeing what the VNA tried to request of it and iterate from there. Using Cynthion with the Facedancer library you can easily emulate a USB device by writing a Python script, and quickly make changes to try out different things.
I started with the template.py
example from the Facedancer project. Usually, a USB host will identify a particular device by looking at the vendor ID & product ID and/or the manufacturer/product/serial strings. From searching on forums and other test equipment groups, I found the expected values for the target device and I modified the template with these:
#!/usr/bin/env python3
from facedancer import main
from facedancer import *
@use_inner_classes_automatically
class ECalDevice(USBDevice):
vendor_id : int = 0x0957
product_id : int = 0x0001
manufacturer_string : str = "Agilent Technologies"
product_string : str = "USB ECal Module"
serial_number_string : str = "S/N 12346"
device_speed : DeviceSpeed = DeviceSpeed.FULL
class ECalConfiguration(USBConfiguration):
class ECalInterface(USBInterface):
class ECalInEndpoint(USBEndpoint):
number : int = 1
direction : USBDirection = USBDirection.IN
transfer_type : USBTransferType = USBTransferType.BULK
max_packet_size : int = 64
def handle_data_requested(self):
self.send(b"Hello!")
class ECalOutEndpoint(USBEndpoint):
number : int = 1
direction : USBDirection = USBDirection.OUT
def handle_data_received(self, data):
print(f"Received data: {data}")
main(ECalDevice)
Running this script and then clicking “Detect Connected ECals” on the VNA gives the following output:
$ python ecal-emulate.py --suggest
INFO | __init__ | Starting emulation, press 'Control-C' to disconnect and exit.
INFO | moondancer | Using the Moondancer backend.
INFO | moondancer | Connected FULL speed device '__main__.ECalDevice' to target host.
INFO | device | Host issued a bus reset; resetting our connection.
INFO | moondancer | Target host configuration complete.
WARNING | device | Stalling unhandled OUT VENDOR request 0x04 to DEVICE [value=0x0000, index=0x0000, length=0].
This shows that the script got a vendor-specific request from the VNA, but doesn’t yet have the code to handle it so it returns a STALL
response (which is the terminology for how USB devices respond to unhandled requests).
Since I ran it with the --suggest
argument, stopping the script gives me a suggestion for code that I can add to handle the request!
^CINFO | moondancer | Disconnecting from target host.
Automatic Suggestions
These suggestions are based on simple observed behavior;
not all of these suggestions may be useful / desirable.
Request handler code:
@vendor_request_handler(number=4, direction=USBDirection.OUT)
@to_device
def handle_control_request_4(self, request):
# Most recent request data: bytearray(b'').
# Replace me with your handler.
request.stall()
I add the suggested handler to my script, but change the response from stall
to ack
and also print
out the request:
--- a/ecal-emulate.py
+++ b/ecal-emulate.py
@@ -34,4 +34,11 @@ class ECalDevice(USBDevice):
def handle_data_received(self, data):
print(f"Received data: {data}")
+ @vendor_request_handler(number=4, direction=USBDirection.OUT)
+ @to_device
+ def handle_control_request_4(self, request):
+ print(request)
+ request.ack()
+
+
main(ECalDevice)
I ran the script and got a new message saying that there was also a vendor request number 2 to be handled, so I went through the same process to handle that too. After doing that, I got a lot more output - now it sent many #2 vendor requests with values counting down from 0x400 (1024) by 6 each time:
OUT VENDOR request 0x04 to DEVICE [value=0x0000, index=0x0000, length=0]
OUT VENDOR request 0x02 to DEVICE [value=0x0400, index=0x0000, length=0]
OUT VENDOR request 0x02 to DEVICE [value=0x03fa, index=0x0000, length=0]
...
OUT VENDOR request 0x02 to DEVICE [value=0x0010, index=0x0000, length=0]
OUT VENDOR request 0x02 to DEVICE [value=0x000a, index=0x0000, length=0]
OUT VENDOR request 0x02 to DEVICE [value=0x0004, index=0x0000, length=0]
This looked very promising, as I was expecting it to read out 1 kB of memory from the hints in some of the DLL exports mentioned earlier. However, I was a bit confused at this point because, while it seemed to be addressing the memory, I couldn’t see any way that the device could actually return the data. Due to the way USB control transfers work, there isn’t a way for an OUT request to return any data - it can only ACK or NAK.
I wanted to see if there might be something else happening on the bus that I might be missing. Fortunately, I have a few Cynthions about so I hooked up a second one in-line to do a packet capture and see if I could learn more:
Doh! Of course, the template example I was working from was also setting up a BULK IN endpoint to return “Hello!”.
After each address vendor request, the VNA would request data on the bulk endpoint and receive those 6 bytes, then adjust the address accordingly and repeat.
If I had realised, I could have just added a print
to the handler to see that happening rather than setting up the packet capture.
Now that I had a pretty good idea of how the data was being read out I could go ahead and implement it properly, but I needed some appropriate data to return.
Fortunately I was able to find some memory dumps that another user had shared online for the 8506x series ECal modules.
They had shared them as ASCII hex dumps, so I converted them to binaries with xxd
:
$ head HP85062-60006.txt
=~=~=~=~=~=~=~=~=~=~=~= PuTTY log 2021.09.29 21:47:04 =~=~=~=~=~=~=~=~=~=~=~=
dump 0 040000
000000: 48 50 38 35 30 36 30 43 20 45 43 41 4C 00 E8 D1 | HP85060C ECAL... |
000010: 31 83 C4 02 64 00 4E 6F 76 20 32 38 20 31 39 39 | 1...d.Nov 28 199 |
000020: 34 00 FF FF FF FF FF FF FF FF FF FF FF FF FF FF | 4............... |
000030: FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF | ................ |
000040: FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF | ................ |
000050: FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF | ................ |
000060: FF FF FF FF 30 30 33 36 37 00 31 C0 50 E8 88 08 | ....00367.1.P... |
$ xxd -r HP85062-60006.txt HP85062-60006.bin
$ hexdump -C HP85062-60006.bin | head
00000000 48 50 38 35 30 36 30 43 20 45 43 41 4c 00 e8 d1 |HP85060C ECAL...|
00000010 31 83 c4 02 64 00 4e 6f 76 20 32 38 20 31 39 39 |1...d.Nov 28 199|
00000020 34 00 ff ff ff ff ff ff ff ff ff ff ff ff ff ff |4...............|
00000030 ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff |................|
*
00000060 ff ff ff ff 30 30 33 36 37 00 31 c0 50 e8 88 08 |....00367.1.P...|
00000070 33 35 46 33 35 46 20 4d 57 31 00 c4 04 56 e8 fb |35F35F MW1...V..|
00000080 08 83 c4 02 38 20 41 75 67 20 32 30 30 31 20 00 |....8 Aug 2001 .|
00000090 41 47 49 4c 45 4e 54 2f 4d 54 41 00 b8 f8 6a eb |AGILENT/MTA...j.|
000000a0 93 83 3e 94 06 00 74 1e a1 82 06 83 c0 41 a2 98 |..>...t......A..|
Then I modified my Python script to load that file and return the data from the bulk endpoint, and tried again to detect the device from the VNA:
--- a/ecal-emulate.py
+++ b/ecal-emulate.py
@@ -14,6 +14,9 @@ class ECalDevice(USBDevice):
serial_number_string : str = "S/N 12346"
device_speed : DeviceSpeed = DeviceSpeed.FULL
+ address = 0
+ data = open('EEPROM/HP85062-60006.bin', 'rb').read()
+
class ECalConfiguration(USBConfiguration):
class ECalInterface(USBInterface):
@@ -25,7 +28,10 @@ class ECalDevice(USBDevice):
max_packet_size : int = 64
def handle_data_requested(self):
- self.send(b"Hello!")
+ # Respond with 32 bytes of EEPROM data
+ dev = self.get_device()
+ addr = dev.address
+ self.send(dev.data[addr:addr+32])
class ECalOutEndpoint(USBEndpoint):
number : int = 1
Success! …sort of. It detected something, which is great progress, but the output is a mess. After staring at it for a while, I realised my silly mistake - I’d forgotten to update the address when receiving vendor request 2:
--- a/ecal-emulate.py
+++ b/ecal-emulate.py
@@ -44,6 +44,7 @@ class ECalDevice(USBDevice):
@to_device
def handle_control_request_2(self, request):
print(request)
+ self.address = request.value
request.ack()
I added that, re-ran everything, and… nothing! It didn’t detect anything anymore. Something about that mistake actually made it work better.
Something that’s very common in file formats is to start with a header that includes some magic value and the parser will check that value before doing anything else. With the mistake in place, the script was returning the first 32 bytes of data to every request, so that was probably enough to pass the header check and show a detected device. However, that suggests that upon implementing the addressing, now the script wasn’t returning the header whenever the VNA was expecting it.
I had a look back at the pattern of #2 vendor requests:
OUT VENDOR request 0x02 to DEVICE [value=0x0400, index=0x0000, length=0]
OUT VENDOR request 0x02 to DEVICE [value=0x03e0, index=0x0000, length=0]
...
OUT VENDOR request 0x02 to DEVICE [value=0x0040, index=0x0000, length=0]
OUT VENDOR request 0x02 to DEVICE [value=0x0020, index=0x0000, length=0]
^CINFO | moondancer | Disconnecting from target host.
Two things stood out to me about those:
- The VNA starts by requesting with
value=0x400
and then counts down - that’s a bit odd, I’d expect it to start at address 0 and count up. - The VNA never actually sends a request with
value=0x0
, so the script never sends the header at all with the addressing in place!
This got me thinking that maybe there’s some quirk in the addressing and it should actually be reversed (so that a request with value=0x400
goes to address 0x0
).
I made that change and re-ran the test:
--- a/ecal-emulate.py
+++ b/ecal-emulate.py
@@ -44,7 +44,7 @@ class ECalDevice(USBDevice):
@to_device
def handle_control_request_2(self, request):
print(request)
- self.address = request.value
+ self.address = 0x400 - request.value
request.ack()
Bingo! The device is detected and all the information about it looks correct now.
While there are some other details still to figure out, I’ll wrap it up there as I think it’s demonstrated the process pretty well. Hopefully it provides some inspiration, please let us know if you use these techniques and tools in your own projects!
For anyone interested, the full code and any further research is available here: https://github.com/miek/ecal-reversing