8.5 KiB
Virtualization.framework Keyboard Event Pipeline
Reverse engineering findings for the keyboard event pipeline in Apple's Virtualization.framework (macOS 26.2, version 259.3.3.0.0). Documents how keyboard events flow from the macOS host to the virtual iPhone guest.
Event Flow Architecture
There are two pipelines for sending keyboard events to the VM:
Pipeline 1: _VZKeyEvent -> sendKeyEvents: (Standard Keys)
_VZKeyEvent(type, keyCode)
-> _VZKeyboard.sendKeyEvents:(NSArray<_VZKeyEvent>)
-> table lookup: keyCode -> intermediate index
-> pack: uint64_t = (index << 32) | is_key_down
-> std::vector<uint64_t>
-> [if type==2] sendKeyboardEventsHIDReport:keyboardID: (switch -> IOHIDEvent -> HID reports)
-> [fallback] eventSender.sendKeyboardEvents:keyboardID: (VzCore C++ layer)
Pipeline 2: _processHIDReports (Raw HID Reports)
Raw HID report bytes
-> std::span<const unsigned char>{data_ptr, length}
-> std::vector<std::span<...>>{begin, end, cap}
-> VZVirtualMachine._processHIDReports:forDevice:deviceType:
-> XpcEncoder::encode_data(span) -> xpc_data_create
-> XPC to VMM process
_VZKeyEvent Structure
From IDA + LLDB inspection:
struct _VZKeyEvent { // sizeof = 0x18
uint8_t isa[8]; // offset 0x00 -- ObjC isa pointer
uint16_t _keyCode; // offset 0x08 -- Apple VK code (0x00-0xB2)
uint8_t _pad[6]; // offset 0x0A -- padding
int64_t _type; // offset 0x10 -- 0 = keyDown, 1 = keyUp
};
Initializer: _VZKeyEvent(type: Int64, keyCode: UInt16)
_VZKeyboard Object Layout
From LLDB memory dump:
+0x00: isa
+0x08: _eventSender (weak, id<_VZHIDAdditions, _VZKeyboardEventSender>)
+0x10: _deviceIdentifier (uint32_t) -- value 1 for first keyboard
+0x18: type (int64_t) -- 0 for USB keyboard, 2 for type that tries HIDReport first
Lookup Tables in sendKeyEvents:
Two tables indexed by Apple VK keyCode (0x00-0xB2, 179 entries x 8 bytes each):
Table 1 (validity flags): All valid entries = 0x0000000100000000 (bit 32 set).
Invalid entries = 0.
Table 2 (intermediate indices): Maps Apple VK codes to internal indices (0x00-0x72).
The tables are OR'd: combined = table1[vk] | table2[vk]. Bit 32 check validates
the entry. The lower 32 bits of combined become the intermediate index.
Sample Table 2 Entries
| Apple VK | Key | Table2 (Index) | HID Page | HID Usage |
|---|---|---|---|---|
| 0x00 | A | 0x00 | 7 | 0x04 |
| 0x01 | S | 0x12 | 7 | 0x16 |
| 0x24 | Return | 0x24 | 7 | 0x28 |
| 0x31 | Space | 0x29 | 7 | 0x2C |
| 0x35 | Escape | 0x25 | 7 | 0x29 |
| 0x38 | Shift | 0x51 | 7 | 0xE1 |
| 0x37 | Command | 0x53 | 7 | 0xE3 |
Invalid VK Codes (both tables = 0, silently dropped)
0x48 (Volume Up), 0x49 (Volume Down), 0x4A (Mute), and many others.
Packed Event Format (std::vector<uint64_t>)
Each element in the vector sent to sendKeyboardEvents:keyboardID::
bits 63:32 = intermediate_index (from table2, lower 32 bits of combined)
bits 31:1 = 0
bit 0 = is_key_down (1 = down, 0 = up)
sendKeyboardEventsHIDReport Switch Statement
For type-2 keyboards, the intermediate index is mapped to
IOHIDEventCreateKeyboardEvent(page, usage) via a large switch.
Standard Keyboard Entries (HID Page 7)
| Index | HID Page | HID Usage | Meaning |
|---|---|---|---|
| 0x00-0x19 | 7 | 4-29 | Letters a-z |
| 0x1A-0x23 | 7 | 30-39 | Digits 1-0 |
| 0x24 | 7 | 40 | Return |
| 0x25 | 7 | 41 | Escape |
| 0x29 | 7 | 44 | Space |
| 0x48-0x4B | 7 | 79-82 | Arrow keys |
| 0x50-0x53 | 7 | 224-227 | L-Ctrl/Shift/Alt/Cmd |
Consumer / System Entries (Non-Standard Pages)
| Index | HID Page | HID Usage | Meaning |
|---|---|---|---|
| 0x6E | 12 | 671 | Consumer Volume Down |
| 0x6F | 12 | 674 | Consumer Volume Up |
| 0x70 | 12 | 207 | Consumer Play/Pause |
| 0x71 | 12 | 545 | Consumer Snapshot |
| 0x72 | 1 | 155 | Generic Desktop Wake |
Home/Menu (Consumer page 0x0C, usage 0x40) has NO intermediate index. It cannot be sent through Pipeline 1 at all.
_processHIDReports Parameter Format
From IDA decompilation of
VZVirtualMachine._processHIDReports:forDevice:deviceType: at 0x2301b2310.
The void * parameter is a pointer to std::vector<std::span>:
Level 3 (outermost): std::vector (24 bytes, passed by pointer)
+0x00: __begin_ (pointer to span array)
+0x08: __end_ (pointer past last span)
+0x10: __end_cap_ (capacity pointer)
Level 2: std::span (16 bytes per element in the array)
+0x00: data_ptr (const unsigned char *)
+0x08: length (size_t)
Level 1 (innermost): raw HID report bytes
The function iterates spans in the vector:
begin = *vec; // vec->__begin_
end = *(vec + 1); // vec->__end_
for (span = begin; span != end; span += 16) {
data_ptr = *(uint64_t*)span;
length = *(uint64_t*)(span + 8);
encoder.encode_data(data_ptr, length); // -> xpc_data_create
}
deviceType: 0 = keyboard, 1 = pointing device
device: device identifier (uint32_t, matches _VZKeyboard._deviceIdentifier)
Crash Analysis: Why Raw Bytes Crashed
Passing raw [0x40, 0x00] as the void* parameter:
- Function reads bytes as vector struct: begin = 0x0040 (first 8 bytes), end = garbage
- Dereferences begin as span pointer -> reads from address ~0x0040
- Gets garbage data_ptr (0x700420e) and garbage length (0x300000020 = 12GB)
xpc_data_create(0x700420e, 0x300000020)-> EXC_BAD_ACCESS in memcpy
The three-level indirection (vector -> span -> bytes) must be constructed correctly or the framework will dereference invalid pointers.
Swift Implementation Notes
Accessing _VZKeyboard
// Get keyboards array
let arr = Dynamic(vm)._keyboards.asObject as? NSArray
let keyboard = arr?.object(at: 0) as AnyObject
// _deviceIdentifier is an ivar, not a property -- use KVC
(keyboard as? NSObject)?.value(forKey: "_deviceIdentifier") as? UInt32
Constructing std::vector<uint64_t> for sendKeyboardEvents
let data = UnsafeMutablePointer<UInt64>.allocate(capacity: 1)
data.pointee = (index << 32) | (isKeyDown ? 1 : 0)
var vec = (data, data.advanced(by: 1), data.advanced(by: 1))
withUnsafeMutablePointer(to: &vec) { vecPtr in
Dynamic(vm).sendKeyboardEvents(UnsafeMutableRawPointer(vecPtr), keyboardID: deviceId)
}
Constructing vector<span> for _processHIDReports
let reportPtr = UnsafeMutablePointer<UInt8>.allocate(capacity: N)
// fill report bytes...
let spanPtr = UnsafeMutablePointer<Int>.allocate(capacity: 2)
spanPtr[0] = Int(bitPattern: reportPtr) // data pointer
spanPtr[1] = N // length
let vecPtr = UnsafeMutablePointer<Int>.allocate(capacity: 3)
vecPtr[0] = Int(bitPattern: UnsafeRawPointer(spanPtr)) // begin
vecPtr[1] = Int(bitPattern: UnsafeRawPointer(spanPtr).advanced(by: 16)) // end
vecPtr[2] = vecPtr[1] // cap
Dynamic(vm)._processHIDReports(UnsafeRawPointer(vecPtr), forDevice: deviceId, deviceType: 0)
Source Files
- Class dumps:
/Users/qaq/Documents/GitHub/super-tart-vphone-private/Virtualization_26.2-class-dump/ - IDA database: dyld_shared_cache_arm64e with Virtualization.framework
Key Functions Analyzed
| Function | Address |
|---|---|
-[_VZKeyboard sendKeyEvents:] |
0x2301b2f54 |
-[_VZKeyboard sendKeyboardEventsHIDReport:keyboardID:] |
0x2301b3230 |
-[VZVirtualMachine(_VZHIDAdditions) _processHIDReports:forDevice:deviceType:] |
0x2301b2310 |
-[VZVirtualMachineView _sendKeyEventsToVirtualMachine:] |
-- |
-[_VZHIDEventMonitor getHIDReportsFromHIDEvent:] |
0x2301b2af0 |