Add openvixdisklib as an open NBD replacement for VMware VDDK.

VDDK is no longer publicly distributed, so this library reverse-engineers
the vSphere NFC path and exposes ConnectEx, Open, Read, and Write without
the proprietary SDK.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Lucian Petrut
2026-09-07 11:41:01 +00:00
co-authored by Cursor
commit 4ddf0c015d
31 changed files with 4089 additions and 0 deletions
+281
View File
@@ -0,0 +1,281 @@
# Reverse-engineering procedure
This is the working method used to replace VDDK’s NBD path with Python.
Protocol details live in `docs/nfc_auth.md`, `docs/nfc_open.md`,
`docs/nfc_read.md`, and `docs/nfc_write.md`. The capture tool is
described in `docs/ssl_hook.md`.
This file is the **sequence of steps**, including dead ends, so later
NFC work can follow the same loop instead of rediscovering it.
Scope so far: `VixDiskLib_ConnectEx` + `VixDiskLib_Open` +
`VixDiskLib_Read` + `VixDiskLib_Write` against lab vCenter 8.0.1 /
ESXi 8, transport `nbd`. Driver: `tests/integration/` (`TestBase`
creates a temporary empty VM with a 10 GiB disk in `setUpClass` and
destroys it in `tearDownClass`).
Rule from `AGENTS.md`: reuse pyVmomi for every public VIM operation.
Only reimplement what pyVmomi does not expose.
## Loop
Each unknown stage (ticket SOAP, authd, NFC binary) went through:
1. **Name it** from VDDK logs and `strings` on the bundled libraries.
2. **See it** on the wire (or prove that tcpdump cannot).
3. **Replay** the smallest working subset in Python against the lab.
4. **Write** findings into a protocol doc and keep the hook out of the
library path.
Do not skip (2). Log lines such as `SESSIONID` or `useSSL=0` named the
wrong wire command until the intercept existed.
## Lab and artifacts
| Item | Where / value |
| ----------------------- | -------------------------------------------------------------------- |
| VDDK 8.0.2 | `.vddk/` (`libvixDiskLib`, `libvddkVimAccess`, `libvim-types`) |
| pyVmomi | `.venv` |
| Known-good VDDK client | `tests/integration/test_vddk.py` / `tests/integration/vixdisklib.py` |
| Verbose NFC logs | `vixDiskLib.nfc.LogLevel=4` in a temp VDDK config |
| ctypes Open+Read driver | `/tmp/vddk_open_trace.py` (not in the library) |
| SSL / `write` hook | `/tmp/sslhook.c` → `/tmp/sslhook.so` |
Always set `LD_LIBRARY_PATH` to `.vddk/` so VDDK uses its own
`libssl.so.3`. Unset `LD_PRELOAD` before running the Python replacement;
a leftover `write` hook will crash pyVmomi’s TLS.
## Step 1 — Map the public VDDK calls
`tests/integration/test_vddk.py` is the specification of what
“success” looks like: login, open the temporary VM’s VMDK, write a
known pattern, read it back.
Turn on VDDK verbose logging around `InitEx` / `ConnectEx` / `Open`. The
logs split the work that the Python API hides:
- `ConnectEx` → VIM login only.
- `Open` → NFC ticket, authd, NFC handshake, AIO open, then I/O.
- `transport_modes=nbd` → URL form `vpxa-nfc://[ds] path.vmdk@esxi:902`.
- `snapshot_ref` does not appear on the ticket SOAP call.
That mapping is the table at the top of `docs/nfc_auth.md`. It tells you
which stage to reverse next and which arguments belong there (VM moref
on the ticket, VMDK path on NFC `OPEN_FILE`).
## Step 2 — Strings and pyVmomi before any capture
`strings -a` on `.vddk/*.so` produced candidate tokens before a single
packet was decoded:
- SOAP: `NfcService`, `NfcGetVmFiles`, `nfcService`, `ha-nfc`,
`HostServiceTicket`.
- authd: `SESSION`, `BANNER`, `THUMBPRINT_SHA2`, `PROXY`, `USER`,
`PASS`, `SSL Required`.
- NFC: `NFC_HANDSHAKE`, `NFC_CONNECTION_DATA`, `NFC_AIO_MSG_*`,
`NFC_DISK`.
Then check whether pyVmomi already has the type:
```python
from pyVmomi import vim
hasattr(vim, "NfcService") # False
hasattr(vim, "HostServiceTicket") # True
```
`ServiceManager.QueryServiceList` on the live vCenter does **not** list
NFC. `GET /sdk/nfcServiceVersions.xml` does (`urn:nfc` 7.0.3.2). The
moref is hardcoded in VDDK (`nfcService` on vCenter, `ha-nfc` on ESXi).
Anything public (`SmartConnect`, `vim.VirtualMachine`,
`HostServiceTicket`) stays in pyVmomi. Missing managed types are
registered with `CreateManagedType` on the same SOAP stub so cookies
and serialization are not reimplemented.
## Step 3 — Confirm tcpdump is the wrong tool for TLS stages
tcpdump on 443 and 902 shows TLS records only. That is enough to prove
“something talks to vCenter then to ESXi:902”, and not enough for SOAP
bodies, authd lines, or NFC headers.
VDDK logs name functions and AIO `opId` / `type` / `size`. They do not
give magic numbers, path placement, or command spacing (`BANNER \r\n`).
An ESXi-impersonating service was considered (`AGENTS.md`) and not
needed: the lab answers VDDK, so capturing the real client is simpler
than simulating the server.
## Step 4 — Interpose OpenSSL (authd and SOAP)
VDDK 8.0.2 still calls `SSL_write` / `SSL_read`. A small `LD_PRELOAD`
library logs those buffers as hex, tagged with the `SSL *` pointer.
Run a minimal ctypes program (`InitEx`, `ConnectEx`, `Open`, `Read`)
under:
```bash
export LD_LIBRARY_PATH=…/.vddk
export LD_PRELOAD=/tmp/sslhook.so
export SSLHOOK_LOG=/tmp/sslhook-open.log
python /tmp/vddk_open_trace.py
```
Parse offline:
1. Concatenate adjacent same-direction records (authd `SSL_read` is
often one byte).
2. Split by `SSL *`. vCenter HTTPS contains `POST /sdk` and SOAP.
ESXi:902 contains `SESSION` / `PROXY`.
3. An early hook without the pointer mixed both streams; always tag.
The vCenter stream identified the ticket as `NfcGetVmFiles` with moref
`nfcService` and `xmlns="urn:vim25"` (not a guess from strings alone).
The ESXi stream gave the authd command order, including the trailing
space on `BANNER` and `THUMBPRINT_SHA2 PlainText`.
Hook implementation notes: `docs/ssl_hook.md`.
## Step 5 — Probe SOAP, then replay only what VDDK sends
With a SmartConnect session, raw SOAP posts were used to learn
parameter names and which moref vCenter accepts:
- `ha-nfc` on vCenter → `ManagedObjectNotFound`.
- `NfcGetServerNfcLibVersion` without `hostForAccess` → invalid
argument; with a host moref → `11`.
- `NfcGetVmFiles(vm)` → `HostServiceTicket` (VDDK read-only Open).
- `NfcRandomAccessOpenReadonly(vm, diskDeviceKey, host)` → same ticket
type, disk-scoped read.
- `NfcRandomAccessOpenDisk(vm, diskDeviceKey, host)` → writable
ticket. `GetVmFiles` plus `OPEN_FILE` flags `0x1a` fails with
`VIX_E_FILE_READ_ONLY` (`0x0b`).
The replacement registers those methods and calls them through pyVmomi.
It does not ship a hand-rolled SOAP client for login or tickets.
## Step 6 — Probe authd; record dead ends
Plaintext banner (`220 … SSL Required`), then `ssl.wrap_socket`.
Commands before TLS drop the connection.
vCenter UID/password are **not** sent to port 902. Attempts that failed
and must not be retried for this ticket type:
| Attempt | Result |
| ------------------------------------- | ------------------------------------- |
| `USER` / `PASS` (vCenter account) | `530 Login incorrect` |
| `USER` / `PASS` with `sessionId` | `530 Login incorrect` |
| `SESSIONID <sessionId>` | `530 Please login with USER and PASS` |
| `CONNECT_VPXA` after TLS | `530 Please login with USER and PASS` |
| Wait for a reply after `SESSION` | Hang until `BANNER` / `PROXY` follow |
| `THUMBPRINT_SHA2` with SHA-1 digest | `501 Invalid arguments` |
The working sequence is in `docs/nfc_auth.md`. `useSSL=0` in the VDDK
log means skip a **second** NFCSSL wrap, not skip TLS on 902.
Replay: `openvixdisklib/nfc_auth.py` /
`tests/integration/test_nfc_auth.py`. Stop at `200 Connect`.
## Step 7 — NFC binary: extend the hook to `write` / `read`
After `PROXY`, `SSL_write` on the ESXi `SSL *` goes silent. VDDK logs
`useSSL=0` / “plain-text connection is deprecated” and then NFC
function names. The bytes are `write(SSL_get_fd(ssl), …)` / `read` on
peer port 902.
The hook was extended to those syscalls, filtered with `getpeername`
port 902, and mutex-locked (VDDK is multi-threaded). Skip TLS records
(`16 03` / `17 03`) left over from the authd phase.
Correlate each frame with the verbose log line that has the same
`type` and `size` (`NfcAioSendMessage: opId = … type = … size = …`).
Name the types from the consecutive `NFC_AIO_MSG_*` string table in
`libvixDiskLib.so`. Lengths 4 and 7 on the connection-data message are
the ASCII strings `vddk` and `nbdmode` sent in the next two writes.
Classic NFC uses a 264-byte padded struct; AIO uses a 16-byte header
(`magic 0xA100DA7A`) plus payload; path / DDB key / sector data are
extra writes not included in `size`.
## Step 8 — Replay the smallest subset, then compare to VDDK
Python must **dup the authd fd** and send NFC as raw TCP.
`SSLSocket.send` would encrypt; `unwrap()` would `SSL_shutdown`. VDDK
does neither.
`openvixdisklib/nfc_open.py` replays handshake + AIO `OPEN_SESSION` /
sockopts / resource pool / `OPEN_FILE`. VDDK’s extra `DDB_GET` keys
were omitted once a file handle was enough to read. Proof of open:
`tests/integration/test_nfc_open.py`.
Do not copy every VDDK message. Copy what the server requires for the
Python API you are replacing.
## Step 9 — Vary `VixDiskLib_Read` until IO fields stop moving
A single-sector read is not enough to decode `NFC_AIO_MSG_IO`. Drive
VDDK with several `(startSector, numSectors)` pairs in one Open
(including `n=128` = 64 KiB and `n=129`) under the `write`/`read` hook.
What that comparison showed:
- Wire units are bytes (`offset = start * 512`, `length = n * 512`).
- Request size stays 44; data is extra after the payload.
- VDDK sends **one** request even when `length > 65536`. The server
replies with several type-7 messages that share `opId`, each with a
chunk length at payload offset 32 (max 65536).
- Treating offset 36 as `NFC_DISK` (`2`) was a 1-sector coincidence;
VDDK repeats the byte length there.
- Zeros on the wire are real transferred zeros, not a sparse skip.
Replay: `NfcDisk.read` loops on fragments until `length` bytes arrive.
Proof: `tests/integration/test_nfc_read_write.py` writes a known pattern
(including a 129-sector read that must assemble two fragments) and
checks the bytes that came back.
## Step 10 — Writes from the same IO message
`VixDiskLib_Write` uses the same 44-byte `NFC_AIO_MSG_IO` layout as
read. The direction field at offset 8 is `0` instead of `1`, and the
sector bytes are sent after the payload (like the path on
`OPEN_FILE`). Writable `OPEN_FILE` flags are `0x1a` (the captured
read-only flags `0x1e` with bit `0x04` cleared, matching
`VIXDISKLIB_FLAG_OPEN_READ_ONLY` in `.vddk/vixDiskLib.h`).
Writable `OPEN_FILE` still failed with `VIX_E_FILE_READ_ONLY` until
the ticket switched from `NfcGetVmFiles` to `NfcRandomAccessOpenDisk`
(`libvim-types.so`: vmodl `randomAccessOpen` ↔ WSDL
`NfcRandomAccessOpenDisk`). Integration tests create a temporary empty
10 GiB VM for the run so writes cannot land on other lab disks.
The Python client splits writes larger than 64 KiB; it does not send a
single oversized write the way VDDK sends an oversized read. Details:
`docs/nfc_write.md`. Proof: write then read in
`tests/integration/test_nfc_read_write.py` and the VDDK cross-check in
`tests/integration/test_crosscheck.py`.
## What to write down
After a stage works:
| Document | Contents |
| --------------------------------------- | --------------------------------------------- |
| `docs/nfc_auth.md` | Ticket SOAP + authd wire format |
| `docs/nfc_open.md` | Classic NFC + AIO open |
| `docs/nfc_read.md` | AIO IO / `VixDiskLib_Read` |
| `docs/nfc_write.md` | AIO IO / `VixDiskLib_Write` |
| `docs/ssl_hook.md` | Capture tool only |
| `docs/reverse_engineering_procedure.md` | This procedure (update when the method changes) |
Keep the hook and ctypes driver under `/tmp`. They are not part of the
replacement library.
## Next stages (same procedure)
Not yet reversed, same loop as above:
- `DDB_GET` / disk geometry, compression, encrypted disks
- `NFC_DELTA_DISK`, CBT / `QueryAllocatedBlocks`
- `VixDiskLib_GetInfo` capacity
- Host-switch AIO messages
- `useSSL=1` (second NFCSSL wrap)
- Direct ESXi `ha-nfc` without vCenter `vpxa-nfc`