Wherein I delve into the internals of my no-name brand "Tecknet Hypertrak Gaming Mouse" in an attempt to write a cross-platform tool that can manipulate its configuration without having to use the awful Windows-only tool it's shipped with. In this first part we tear apart said tool to figure out how it communicates with the mouse.

Official product image for this piece of garbage

Introduction

After a couple of years of exclusively using a laptop, I built a desktop computer in early 2018. I picked up a cheap gaming mouse upon a recommendation from a friend -- the TeckNet HyperTrak Gaming Mouse (or the TeckNet M009-V2, as the drivers call it).

For the princely sum of £23, you get what appears to be a decent little peripheral: it's got a braided USB cable, customisable lights, adjustable weights, DPI switching, 12 programmable buttons (left, right, wheel click, wheel tilt left/right, DPI up/down, five side buttons), macros... Not bad for the price.

The software's a bit shit, but once again I suppose you can't complain too much for the price. It's a Windows-only tool that doesn't really inspire much confidence. Here's a screenshot and a quick overview of the options:

A screenshot of the mouse configuration tool's interface. It's a horrendous custom UI.

  • Button Assignment: choose actions for buttons; you get regular mouse buttons, DPI switching, multimedia keys, some Windows actions, keyboard shortcuts, and macro invocations
  • Macro Manager: create macros consisting of keypresses, mouse clicks and configurable delays
  • X/Y Sensitivity Setting: tune X/Y sensitivity from 1 to 10
  • DPI Setting: enable/disable the four DPI settings, set a colour, and set what DPI value each setting maps to
  • Lighting: choose between some nonsense settings: Standard, Respiration, Neon (I've never figured out how these work!)
  • USB Report Rate: choose between 125Hz, 250Hz, 500Hz, 1000Hz
  • Angle Snap: changes the way diagonal movement is handled
  • Mouse Speed, Double Click Speed, Scroll Speed: shortcuts to adjust settings in the Windows mouse control panel

Selecting the Config button at the bottom left gets you a list of configurations, which you can save to and load from files. The currently-applied configuration is saved onto the mouse itself, so you don't need to have the software running for these features to work - you only need it to make changes.

Anyway, come October, I finally got tired of Windows and decided to install macOS High Sierra* on my desktop. To my dismay, I realised that this mouse doesn't actually expose its extra buttons as standard HID buttons, so they're practically useless without the configuration tool. I tried several tools like BetterTouchTool and USB Overdrive which promised to offer support for input devices with non-standard buttons, but none of them could detect anything when I pressed one of the extra buttons.

What's the obvious solution? To do it myself, of course...

* this machine has a GTX 1060 in it, and there's no reliable way to get graphics acceleration on 10.14 Mojave with one of those just yet - Apple's GeForce drivers don't support Pascal cards, and Nvidia's drivers don't support Mojave yet

Examining the official TeckNet mouse drivers

I thought a good start would be to look at what sort of things the TeckNet drivers are doing, so I rebooted into my Windows installation again and looked into the files they contain.

A screenshot of Windows Explorer, showing the driver installation folder

res contains some icons. Skins contains images for the UI. record.ini contains information about macros. The other INI files all seem to contain versions of the displayed configuration strings; I'm not sure why there's so many of them. configX.bin contains the settings in a binary format. The X64 and X86 directories seem like they might have been intended for Windows drivers, but there's nothing inside them.

What's of most interest to us here are the configuration tool itself (TeckNet M009-V2 setup.exe) and the Update directory, which contains 32-bit and 64-bit executables for firmware updaters.

A screenshot of the firmware update tool saying

But, we'll look at that later... For now, let's throw the configuration tool into a disassembler! It's only 1.25MB, so it can't be that bad, right?

My usual tactic when I get something without symbols is to look at the executable's strings and imports for some leads as to where interesting things happen. It's very obviously a MFC application -- it's been compiled using Visual C++ with RTTI, so you get lots of helpful class names like CWnd and CDialog and COleControlSite. That's a dead giveaway.

Exploring the HID Interfaces

Many of the strings are generic UI fluff, but there's a few interesting things; the first that caught my eye was this set which is obviously related to device communication, as it references the mouse's USB vendor/product ID, 04D9:A118.

Debug strings in the executable related to the mouse's USB IDs and descriptor details

Following references to those format strings takes us to a promising function that's calling a bunch of device enumeration and HID functions. Definitely relevant to our interests! The decompiled output is a bit hard to follow because of all the MFC string class usage, but with a bit of work we can clean up into something that's mostly readable, and make some sense out of it.

signed __int64 init_classes()
{
  HDEVINFO deviceInfoSet; // rbp
  signed __int64 result; // rax
  DWORD interfaceIndex; // er13
  BOOL v3; // er12
  struct _SP_DEVICE_INTERFACE_DETAIL_DATA_W *interfaceDetailData; // rsi
  int v5; // ecx
  signed __int64 v6; // rcx
  WCHAR *v7; // rdi
  bool v8; // zf
  HANDLE v9; // rax
  struct ATL::CStringData *(__fastcall ***v10)(CAfxStringMgr *__hidden, int, int); // rax
  struct ATL::CStringData *(__fastcall ***v11)(CAfxStringMgr *__hidden, int, int); // rax
  __int64 v12; // rax
  signed __int64 v13; // rdi
  __int16 v14; // ax
  __int64 v15; // rdx
  __int64 v16; // rdx
  __int64 v17; // rdx
  __int64 v18; // rdx
  __int64 v19; // rdx
  __int64 v20; // rdx
  __int64 v21; // rdx
  __int64 v22; // rdx
  __int64 v23; // rdx
  __int64 v24; // rdx
  __int64 DeviceInterfaceData; // [rsp+20h] [rbp-F8h]
  __int64 usagePage; // [rsp+20h] [rbp-F8h]
  PSP_DEVINFO_DATA featureReportLength; // [rsp+28h] [rbp-F0h]
  HANDLE inputReportLength; // [rsp+30h] [rbp-E8h]
  __int64 outputReportLength; // [rsp+38h] [rbp-E0h]
  __int64 v30; // [rsp+40h] [rbp-D8h]
  DWORD RequiredSize; // [rsp+48h] [rbp-D0h]
  __int64 v32; // [rsp+50h] [rbp-C8h]
  HIDD_ATTRIBUTES hidAttrs; // [rsp+58h] [rbp-C0h]
  __int64 preparsedDataPtr; // [rsp+68h] [rbp-B0h]
  __int64 v35; // [rsp+70h] [rbp-A8h]
  HIDP_CAPS hidCaps; // [rsp+80h] [rbp-98h]
  struct _SP_DEVICE_INTERFACE_DATA interfaceData; // [rsp+C0h] [rbp-58h]

  v35 = -2i64;
  // find all devices that support HID collections
  deviceInfoSet = SetupDiGetClassDevsW(&GUID_DEVINTERFACE_HID, 0i64, 0i64, 0x12u);// DIGCF_PRESENT | DIGCF_DEVICEINTERFACE
  if ( deviceInfoSet == (HDEVINFO)-1i64 )
  {
    SetupDiDestroyDeviceInfoList((HDEVINFO)0xFFFFFFFFFFFFFFFFi64);
    return 0i64;
  }
  interfaceData.cbSize = 32;
  interfaceIndex = 0;
  // loop through every interface in the set
  while ( 1 )
  {
    v3 = SetupDiEnumDeviceInterfaces(deviceInfoSet, 0i64, &GUID_DEVINTERFACE_HID, interfaceIndex, &interfaceData);
    if ( !v3 && GetLastError() == 259 )
    {
      SetupDiDestroyDeviceInfoList(deviceInfoSet);
      return 0i64;
    }
    RequiredSize = 0;
    SetupDiGetDeviceInterfaceDetailW(deviceInfoSet, &interfaceData, 0i64, 0, &RequiredSize, 0i64);
    interfaceDetailData = (struct _SP_DEVICE_INTERFACE_DETAIL_DATA_W *)malloc(RequiredSize);
    interfaceDetailData->cbSize = 8;
    if ( !SetupDiGetDeviceInterfaceDetailW(deviceInfoSet, &interfaceData, interfaceDetailData, RequiredSize, 0i64, 0i64) )
    {
      free(interfaceDetailData);
      SetupDiDestroyDeviceInfoList(deviceInfoSet);
      return 0i64;
    }
    if ( interfaceDetailData == (struct _SP_DEVICE_INTERFACE_DETAIL_DATA_W *)-4i64 )
    {
      v5 = 0;
    }
    else
    {
      v6 = -1i64;
      v7 = interfaceDetailData->DevicePath;
      do
      {
        if ( !v6 )
          break;
        v8 = *v7 == 0;
        ++v7;
        --v6;
      }
      while ( !v8 );
      v5 = ~(_DWORD)v6 - 1;
    }
    sub_1400049E0((void **)&theDevicePath, interfaceDetailData->DevicePath, v5);
    free(interfaceDetailData);
    // open a handle to this device
    // no reading or writing, just check metadata
    v9 = CreateFileW(theDevicePath, 0, 3u, 0i64, 3u, 0, 0i64);
    g_FF00_FF00_handle = v9;
    if ( v9 == (HANDLE)-1i64 )
    {
      SetupDiDestroyDeviceInfoList(deviceInfoSet);
      return 0i64;
    }
    HidD_GetAttributes(v9, &hidAttrs);
    v10 = getAfxStringMgr();
    if ( v10 == 0i64 )
      throwAfxException(-2147467259);
    v30 = ((__int64 (__fastcall *)(struct ATL::CStringData *(__fastcall ***)(CAfxStringMgr *__hidden, int, int)))(*v10)[3])(v10)
        + 24;
    LODWORD(DeviceInterfaceData) = (unsigned __int16)hidAttrs.VersionNumber;
    formatAfxString(
      (__int64)&v30,
      (__int64)L"PID:%04x\n,VID:%04x\n,VersionNumber:%04x\n",
      (unsigned __int16)hidAttrs.ProductID,
      (unsigned __int16)hidAttrs.VendorID,
      DeviceInterfaceData);
    // check for the Tecknet mouse VID/PID
    if ( hidAttrs.VendorID == 0x4D9 && 0xA118u == hidAttrs.ProductID )
      break;
tryNextInterface:
    ++interfaceIndex;
    v16 = v30 - 24;
    if ( _InterlockedDecrement((volatile signed __int32 *)(v30 - 24 + 16)) <= 0 )
      (*(void (**)(void))(**(_QWORD **)v16 + 8i64))();
    if ( !v3 )
    {
      SetupDiDestroyDeviceInfoList(deviceInfoSet);
      return 0i64;
    }
  }
  if ( (unsigned __int8)HidD_GetPreparsedData(g_FF00_FF00_handle, &preparsedDataPtr) )
  {
    CloseHandle(g_FF00_FF00_handle);
    v11 = getAfxStringMgr();
    if ( v11 == 0i64 )
      throwAfxException(-2147467259);
    v12 = ((__int64 (__fastcall *)(struct ATL::CStringData *(__fastcall ***)(CAfxStringMgr *__hidden, int, int)))(*v11)[3])(v11);
    v13 = v12 + 24;
    v32 = v12 + 24;
    if ( (unsigned int)HidP_GetCaps(preparsedDataPtr, &hidCaps) == 0xC0110001 )
    {
      SetupDiDestroyDeviceInfoList(deviceInfoSet);
      if ( _InterlockedDecrement((volatile signed __int32 *)(v13 - 24 + 16)) <= 0 )
        (*(void (**)(void))(**(_QWORD **)(v13 - 24) + 8i64))();
      v23 = v30 - 24;
      if ( _InterlockedDecrement((volatile signed __int32 *)(v30 - 24 + 16)) <= 0 )
        (*(void (**)(void))(**(_QWORD **)v23 + 8i64))();
      return 0i64;
    }
    LODWORD(outputReportLength) = (unsigned __int16)hidCaps.OutputReportByteLength;
    LODWORD(inputReportLength) = (unsigned __int16)hidCaps.InputReportByteLength;
    LODWORD(featureReportLength) = (unsigned __int16)hidCaps.FeatureReportByteLength;
    LODWORD(usagePage) = (unsigned __int16)hidCaps.UsagePage;
    formatAfxString(
      (__int64)&v32,
      (__int64)L"DevicePath:%s,\n"
                "Usage:0x%04x\n"
                ", UsagePage:0x%04x\n"
                ",FeatureReportByteLength:0x%04x\n"
                ",InputReportByteLength:0x%x\n"
                ",OutputReportByteLength:0x%x\n",
      theDevicePath,
      (unsigned __int16)hidCaps.Usage,
      usagePage,
      featureReportLength,
      inputReportLength,
      outputReportLength);
    v14 = hidCaps.Usage;
    if ( hidCaps.Usage == 1 )
    {
      if ( hidCaps.UsagePage != 0xFF01u || 0 == hidCaps.InputReportByteLength )
      {
failedUsageCheck:
        v15 = v32 - 24;
        if ( _InterlockedDecrement((volatile signed __int32 *)(v32 - 24 + 16)) <= 0 )
          (*(void (**)(void))(**(_QWORD **)v15 + 8i64))();
        goto tryNextInterface;
      }
      // GENERIC_READ, FILE_SHARE_READ|FILE_SHARE_WRITE, no security attribs, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL|FILE_FLAG_OVERLAPPED, no template
      g_readFrom1_FF01 = CreateFileW(theDevicePath, GENERIC_READ, 3u, 0i64, 3u, 0x40000080u, 0i64);
      if ( g_readFrom1_FF01 == (HANDLE)-1i64 )
      {
        SetupDiDestroyDeviceInfoList(deviceInfoSet);
        v17 = v32 - 24;
        if ( _InterlockedDecrement((volatile signed __int32 *)(v32 - 24 + 16)) <= 0 )
          (*(void (**)(void))(**(_QWORD **)v17 + 8i64))();
        v18 = v30 - 24;
        if ( _InterlockedDecrement((volatile signed __int32 *)(v30 - 24 + 16)) <= 0 )
          (*(void (**)(void))(**(_QWORD **)v18 + 8i64))();
        return 0i64;
      }
      SetEvent(Overlapped.hEvent);
      g_inputReportLength = hidCaps.InputReportByteLength;
      v14 = hidCaps.Usage;
    }
    if ( 0xFF00u == v14 && 0xFF00u == hidCaps.UsagePage && hidCaps.FeatureReportByteLength == 9 )
    {
      g_FF00_FF00_handle = CreateFileW(theDevicePath, 0xC0000000, 3u, 0i64, 3u, 0, 0i64);
      if ( g_FF00_FF00_handle == (HANDLE)-1i64 )
      {
        SetupDiDestroyDeviceInfoList(deviceInfoSet);
        v19 = v32 - 24;
        if ( _InterlockedDecrement((volatile signed __int32 *)(v32 - 24 + 16)) <= 0 )
          (*(void (**)(void))(**(_QWORD **)v19 + 8i64))();
        v20 = v30 - 24;
        if ( _InterlockedDecrement((volatile signed __int32 *)(v30 - 24 + 16)) <= 0 )
          (*(void (**)(void))(**(_QWORD **)v20 + 8i64))();
        result = 0i64;
      }
      else
      {
        g_have_FF00_FF00 = 1;
        g_FF00_FF00_outputLength = hidCaps.OutputReportByteLength;
        g_FF00_FF00_inputLength = hidCaps.InputReportByteLength;
        g_FF00_FF00_featureLength = hidCaps.FeatureReportByteLength;
        SetupDiDestroyDeviceInfoList(deviceInfoSet);
        v21 = v32 - 24;
        if ( _InterlockedDecrement((volatile signed __int32 *)(v32 - 24 + 16)) <= 0 )
          (*(void (**)(void))(**(_QWORD **)v21 + 8i64))();
        v22 = v30 - 24;
        if ( _InterlockedDecrement((volatile signed __int32 *)(v30 - 24 + 16)) <= 0 )
          (*(void (**)(void))(**(_QWORD **)v22 + 8i64))();
        result = 1i64;
      }
      return result;
    }
    goto failedUsageCheck;
  }
  // if we got here then HidD_GetPreparsedData failed, so we fucked it
  SetupDiDestroyDeviceInfoList(deviceInfoSet);
  CloseHandle(g_FF00_FF00_handle);
  v24 = v30 - 24;
  if ( _InterlockedDecrement((volatile signed __int32 *)(v30 - 24 + 16)) <= 0 )
    (*(void (**)(void))(**(_QWORD **)v24 + 8i64))();
  return 0i64;
}

Here's a quick overview of what's going on here:

  • Ask Windows for a list of all HID devices on the system, and iterate through it
  • If a device is found matching the mouse's ID (04D9:A118), ask Windows for its HID parameters and then check them
  • If the device has Usage FF01:0001 and a non-zero input report size, then open that device for reading, and store the handle and input report size into global variables
  • If the device has Usage FF00:FF00 and a feature report size of 9, then open that device for reading/writing, and store the handle and report sizes into global variables

These devices allow the tool to communicate with the mouse. There's a pretty good description of how HID works here on MSDN, but in a nutshell: HID stands for Human Interface Device, and it's a standard, extensible way for things like mice and keyboards to communicate with OSes. HIDs can have input reports (data sent by the device in a pre-defined format), output reports (data sent to the device in a pre-defined format) and feature reports (arbitrary data sent by the host to the device, or requested by the host from the device).

Each device has a "Usage" which identifies what sort of device it is (mouse, keyboard, gamepad, joystick, golf club...) against a set of pre-defined device types and properties. These two usages in particular are both within the vendor-specific range, which you can essentially read as "device designers can do whatever the fuck they want"... which is precisely why we're in this mess -- all of this mouse's fancy features are configured through non-standard, vendor-specific feature reports!

So, we now have a rough idea of how the config tool communicates with the mouse. We know how it sets up that communication; that's a good starting point. If we look at the references to the device handles, then that'll probably shed some more light on precisely what it's doing!

Getting Data from the Mouse

We have device FF01:0001, which we only read from - we never write to it. Pretty simple, let's have a look. It's only read from one particular function, which runs inside a thread, and said thread is created right after the communication is initialised. Straightforward? Yes.

void __fastcall __noreturn readFromFF01Device_Thread(void *a1)
{
  DWORD amountToRead; // ebx
  struct ATL::CStringData *(__fastcall ***v2)(CAfxStringMgr *__hidden, int, int); // rax
  DWORD NumberOfBytesTransferred; // [rsp+30h] [rbp-48h]
  DWORD amountThatWasRead; // [rsp+34h] [rbp-44h]
  char Buffer[40]; // [rsp+38h] [rbp-40h]

  amountToRead = (unsigned __int16)g_inputReportLength;
  *(_QWORD *)Buffer = 0i64;
  *(_QWORD *)&Buffer[8] = 0i64;
  *(_QWORD *)&Buffer[16] = 0i64;
  *(_QWORD *)&Buffer[24] = 0i64;
  *(_QWORD *)&Buffer[32] = 0i64;
  amountThatWasRead = 0;
  v2 = getAfxStringMgr();
  if ( !v2 )
    throwAfxException(-2147467259);
  ((void (__fastcall *)(struct ATL::CStringData *(__fastcall ***)(CAfxStringMgr *__hidden, int, int)))(*v2)[3])(v2);
  while ( 1 )
  {
    ResetEvent(Overlapped.hEvent);
    if ( g_have_FF00_FF00 != 1 || g_readFrom1_FF01 == (HANDLE)-1i64 )
    {
      // device isn't connected right now, so wait?
      WaitForSingleObject(Overlapped.hEvent, 0xFFFFFFFF);
    }
    else
    {
      // request some data
      ReadFile(g_readFrom1_FF01, Buffer, amountToRead, &amountThatWasRead, &Overlapped);
      WaitForSingleObject(Overlapped.hEvent, 0xFFFFFFFF);
      if ( g_have_FF00_FF00 )
      {
        // we've got it (hopefully?)
        GetOverlappedResult(g_readFrom1_FF01, &Overlapped, &NumberOfBytesTransferred, 1);
        if ( NumberOfBytesTransferred )
        {
          // send message WM_USER+2 to the dialog if the input report starts with 06 07
          if ( Buffer[0] == 6 && Buffer[1] == 7 )
            SendMessageW(g_dialogHwnd, 0x402u, (unsigned __int8)Buffer[2], (unsigned __int8)Buffer[3]);
        }
      }
    }
  }
}

Essentially it just keeps on reading input reports, over and over again. If it receives one beginning in 06 07, then the two bytes following that are sent to the main dialog box for processing using a non-standard Windows message WM_USER + 2.

Can we find the processing for this? Usually we'd just look for a WndProc or a DlgProc, but this is a MFC application, so naturally it's a bit more complicated than that. A quick search brings me to documentation for ON_MESSAGE on MSDN, which is apparently The One True Way™ to handle user-defined messages in MFC. Judging by the code sample, there should be a message map table somewhere containing these. This is probably an array of structures somewhere.

Searching the executable for 02 04 (0x0402 in little endian) brings us to a possible candidate; there's a bunch of function pointers and zeroes and things that look like message IDs, in fixed intervals, which is a pretty good sign that you are looking at an array of structures. A bit of massaging in IDA and suddenly it looks pretty reasonable:

Yep, that's a message map alright. The handler we want is sub_1400145B0. It takes three parameters: the implicit this pointer (since it's a method), followed by wParam and lParam. We know these correspond to buffer[2] and buffer[3] from the data sent by the mouse, as those were passed into the SendMessage call.

That method is a pain to read as it's accessing a lot of fields inside the dialog object and also doing some MFC string manipulation, but we can still get a general idea of what's going on. The exact behaviour performed depends on what the two parameters are. There's one certainty: the function will always either do nothing, or it will do some stuff and then call Shell_NotifyIconW to spawn a Windows notification.

I know the mouse can change between the different pre-configured DPI levels through button presses, and whenever this happens, the config tool pops up a Windows notification. With that in mind, I'm just going to assume that this code is related to this - it might even pop up different kinds of notifications I haven't seen before. We can always come back to it later after all.

So we now have an informed guess about the FF01:0001 device: it's used by the mouse to notify the software about certain actions including DPI setting changes and possibly other stuff. Next, we want to figure out the FF00:FF00 device - it uses both reading and writing, so that's likely to be where all the interesting configuration stuff happens.

Next Time...

I think this post has dragged on long enough for now, so I'm gonna split it up here. In part 2 I'll look into the origins of this cursed hardware, I'll investigate the protocol used to communicate with the mouse, and tear open the firmware updater.

If you've really enjoyed this series of posts and want to help fund my hot chocolate habit, feel free to send me a couple of pounds: Ko-fi | Monzo.me (UK) | PayPal.me