ElgatoLegacy - Fixing The Plugins Window

10 minute read

ElgatoLegacy is a project of mine you can find more about here: Click Me

The main issue and reason for this project is that Elgato does not want to support older operating systems, even ones still in service like Windows 8.1. Because of this, the software for their StreamDeck completely fails to install with an OS error. Using my project, you can extract the installer and bypass the OS issues allowing usage of the StreamDeck on Windows 7, 8, and 8.1. However, this did come with one known issue; the plugins window did not work.

I haven’t had much time to look into this issue or figure out the cause until today. My first approach to this was to look into how the plugins were being downloaded/loaded on a working Windows 10 machine. This gave me the first bit of information as to what to look at and to ensure the Windows 8.1 machine I was testing with could follow too. The first thing that is done is the Elgato software will download a catalog of plugins from their server with a timestamp for the current version information like this:


For this, I dug into the file with IDA to see if this is easily referenced anywhere, which it is.

    v17 = v33;
    if ( v34 - v33 < 0x10 )
      sub_14000B630(&Src, 0x10ui64, 0i64, "/catalog.json?v=", 0x10ui64);
      v33 += 16i64;
      v18 = (char *)&Src;
      if ( v34 >= 0x10 )
        v18 = (char *)Src;
      v19 = &v18[v17];
      memmove(v19, "/catalog.json?v=", 0x10ui64);
      v19[16] = 0;

Here the url is being built and later on it is fully created into a Qt web request:

    *(_BYTE *)(v5 + 194) = 1;
    sub_1401E7840(v5, v13, v5, a3, a4);
    v27 = (const char *)&Src;
    if ( v34 >= 0x10 )
      v27 = (const char *)Src;
    QString::QString((QString *)&Memory, v27);
    QUrl::QUrl(&v30, &Memory, 0i64);
    QString::~QString((QString *)&Memory);
    QNetworkRequest::QNetworkRequest((QNetworkRequest *)&v29, (const struct QUrl *)&v30);
    QVariant::QVariant((QVariant *)&Memory, 1);
    QNetworkRequest::setAttribute(&v29, 21i64, &Memory);
    QVariant::~QVariant((QVariant *)&Memory);
    QNetworkAccessManager::get((QNetworkAccessManager *)(v5 + 128), (const struct QNetworkRequest *)&v29);
    QNetworkRequest::~QNetworkRequest((QNetworkRequest *)&v29);
    QUrl::~QUrl((QUrl *)&v30);

Getting to this point will construct a full url from a known path, but also seems to allow for an override URL.

From this function, I traced back to find where the app is loading the plugin window and invoking the URL request. That leads us to two functions, one is the main parent, the other is what begins to populate the vector of plugins when the request is finished. Since this app is heavily using Qt, everything is wrapped up in their objects making quite a mess to debug/trace through. First, the function that prepares the plugin ‘store’ object and vectors looks like this:

__int64 __usercall sub_1401E8110@<rax>(__int64 a1@<rsi>, __int64 a2@<r14>, __int128 *a3@<xmm6>, __int128 *a4@<xmm7>)
  __int64 result; // rax
  QObject *v5; // rax
  QObject *v6; // rax
  const struct QObject **v7; // rdi
  QTimer *v8; // rax
  QTimer *v9; // rbx
  QObject *v10; // [rsp+60h] [rbp+8h]

  result = qword_140A228B0;
  if ( !qword_140A228B0 )
    v5 = (QObject *)sub_1402E72C8(0x120ui64);
    v10 = v5;
    v6 = sub_1401E73B0(v5);
    v7 = (const struct QObject **)v6;
    qword_140A228B0 = (__int64)v6;
    sub_1401E88C0((__int64)v6, a3, a4, a1, a2);

A global is used to store a pointer to this object once its created the first time and just reused to save memory/allocations. On first creation though, this will begin preparing the manager (ESDAppStoreManager) object. sub_1401E73B0 here is the ctor of this object while sub_1401E88C0 is handling the catalog related steps to begin preparing for downloading the data. This is also using QTimer and slots/signals (Thanks Heals for pointing this out on Discord!) which is used for callbacks and such. Dealing with this was probably where most of my time was wasted trying to track down where things were being written/constructed.

Now that we know where the main parent object was being built, it was time to try and find where this pointer that held the plugin list was being written. Tracing back to the parent function I mentioned before, we land up here:

void __usercall sub_1401E4400(__int64 a1@<rcx>, __int128 *a2@<xmm6>, __int128 *a3@<xmm7>)

This function is the main handler that does everything to get the window populated. This invokes the web request, prepares the UI slot/signals, handles the timer and callbacks, etc. The first part we are interested in is the preparations done here:

  v11 = sub_1401E8110(v6, (__int64)v8, a2, a3);
  sub_14017BE40((unsigned __int64 *)&v42, (const void **)(v11 + 0x58));

Here we have the sub_1401E8110 call handling preparing the manager object. Following that, we have a copy helper sub_14017BE40 which populates the needed list of information from the loaded data when its ready. The (const void **)(v11 + 0x58)) here is what we are mostly interested in, this holds the pointer to the list of loaded/found plugin entries.

On Windows 8.1, this pointer is never set and always 0. So something is blocking it from being fully read/populated. On Windows 10 however, we see the pointer populated and loaded. To help track down where that was happening, on Windows 10, we can load up StreamDeck.exe in a debugger (I used x64dbg for this) and go to this address before the program runs. v11 we know is a global pointer taken from the sub_1401E8110 call which is: qword_140A228B0 and we know the offset being +0x58 to the pointer we are interested in. On this, we can do a hardware breakpoint on write access to find what is writing to this pointer.

  • Click Memory Dump Window 0
  • CTRL+G -> StreamDeck.0 + 0A228B0
  • Select the first 8 bytes.
  • Right-click > Breakpoint > Hardware > On Write (QWORD)

Resume execution and a break will eventually happen. Not surprisingly, it’s in the same function we got the pointer from, sub_1401E8110. We knew this was setting the main pointer but we wanted to break here instantly when it is written to so we can now break on the inner offset of this pointer (+0x58) to get the pointer of interest. So now we can follow this pointer and add +0x58 to it to get the new address we are interested in. Again, setting another hardware breakpoint the same way. Resume again and now nothing. This is not written until we open the plugin window.

Click the button to open the plugin window and a break should happen shortly afterward. On this break, we’ll land up inside of sub_14009E8E0 which is another copy helper to set a pointer. Specifically we land up in:

  *v5 = v13;
  v5[1] = &v13[8 * v9]; // <== here
  v5[2] = &v13[8 * v12];
  return &(*v5)[8 * v7];

This is basically a Qt style list/vector setup with a beginning and end pointer. If you follow the pointers of the new written one, you’ll see entries for each plugin. So as a best-guess, this looks like an object on the lines of:

std::vector<QObject*> plugins;

On Windows 8.1 this is never invoked by default. So sometihng is killing things before we get here. Looking at the call stack where we last broke at, we can trace back a bit to see where the vector setup is happening and what’s causing it. For me, this looks like:

000000ACB9B3D5F0  0000000000000000  
000000ACB9B3D5F8  000002003901F638  
000000ACB9B3D600  0000020038D4F6C0  
000000ACB9B3D608  00007FF6C819BDCF  return to streamdeck.00007FF6C819BDCF from ???
000000ACB9B3D618  0000020038D4F6F0  "1.6"
000000ACB9B3D620  0000020038F36C00  
000000ACB9B3D628  000002003901F638  
000000ACB9B3D630  0000000000000000  
000000ACB9B3D638  000002003901F638  
000000ACB9B3D640  0000020038D4F6C0  
000000ACB9B3D648  000002003901F610  
000000ACB9B3D650  0000020038D4F6C0  
000000ACB9B3D658  00007FF6C820C27D  return to streamdeck.00007FF6C820C27D from streamdeck.00007FF6C819A8D0
000000ACB9B3D660  0000020038F36C58  

Here we can see two return addresses we can look into.

00007FF6C819BDCF points back to a helper function that looks at version numbers. So nothing too interesting, but 00007FF6C820C27D is very interesting as it points back to a large function that is parsing the individual plugin json files loaded from the catalog file.

Inside of this function, the most interesting entry to these sub-json files is:

        v83 = sub_140038E20(&v342);
        sub_140174310(v83, &v365, "Platform");
        v84 = sub_140038E20(&v342);
        v85 = sub_140056880(v84, &v432);
        if ( (unsigned __int8)sub_14011D4D0(&v365, v85) )
          v86 = sub_140038E20(&v365);
          sub_14011CB60(v86, &v635);
          if ( v636 )
            sub_140076F30(&v671, &v635);
        v87 = sub_140038E20(&v342);
        sub_140174310(v87, &v364, "MinimumVersion");
        v88 = sub_140038E20(&v342);
        v89 = sub_140056880(v88, &v382);

Seeing this, I decided to peek into one of the jsons being loaded. These are cached on disk at:


The main catalog.json here holds all the available in-store plugins by their reference name and some basic info per. Then each plugin has its own sub-json downloaded from its reference name. So looking at one, we find info like this:

{"name":"CPU","identifier":"com.elgato.cpu","website":"https://developer.elgato.com","catalog_version":54,"published_versions":[{"number":"1.2","subtitle":"Displays the current CPU usage.","description":"Displays the current CPU usage.","whats_new":"Version 1.2","uploaded_at":"2018-12-17T17:34:20.398553Z","download_link":"https://cloud.elgato.com/store/download/streamDeckPlugin/com.elgato.cpu/1.2/","review_link":"https://cloud.elgato.com/store/review/streamDeckPlugin/com.elgato.cpu/1.2/","thumbnail_link":"https://appstore.elgato.com/streamDeckPlugin/com.elgato.cpu/1.2/com.elgato.cpu.png?v=54","thumbnail_hash":"L626jbj]Vnfkj^fQf5fQVnf6kEf5","minimum_stream_deck_version":{"version_number":"4.0"},"languages":["de","es","fr","ja","ko","zh_CN"],"minimum_os":[{"platform":"mac","version_number":"10.11"},{"platform":"windows","version_number":"10"}],"supported_devices":[{"identifier":"com.elgato.stream-deck.2017"},{"identifier":"com.elgato.stream-deck.mini.2018"}],"localizations":[{"language":"es","key":"name","value":"Procesador"},{"language":"es","key":"description","value":"Muestra el uso actual del procesador."},{"language":"ko","key":"description","value":"현재 CPU 사용량을 표시합니다."},{"language":"ja","key":"description","value":"現在のCPUの使用状況を表示します。"},{"language":"de","key":"description","value":"Zeigt die aktuelle CPU-Auslastung an."},{"language":"zh_CN","key":"description","value":"显示当前的 CPU使用率。"},{"language":"fr","key":"description","value":"Affiche la consommation actuelle de ressources processeur."},{"language":"fr","key":"name","value":"Processeur"}],"previews":[]}],"blacklisted_versions":[],"developer":{"name":"Elgato","trusted":true},"staff_ranking":null,"category":{"identifier":"com.elgato.monitoring"},"preinstall":false}

The most important/interesting bit is the platform data:


Bingo, plugins can tell the plugin loader what their required OS versions are. This means the plugin window can hide this data from being seen if its not matching.

So now, my thought process has shifted to bypassing the version data checks. For this I thought of user-level API that lets you check/get product information of the OS. A quick list of things would be:

  • GetVersion
  • GetVersionExA / GetVersionExW
  • VerifyVersionInfoA / VerifyVersionInfoW
  • GetProductInfo

Hooking all of these and faking the return yielded nothing. GetProductInfo also didn’t need to be hooked once we faked the info of the others.

Hmm.. what could be happening then? Next step, look at the imports of StreamDeck.exe and see if there are other things being used I didn’t think of. I initially started off filtering these with things matching ‘vers’ for version, and immediately found the answer:


Qt offers its own versioning functions, which don’t use direct user-mode API calls that are intended for user level access. Instead, QSysInfo::kernelVersion directly invokes the ntdll function RtlGetVersion which directly returns the real system platform information. This is why the hooks I made failed before, as they are skipping over the usermode calls altogether and the data I was using was weirdly bad on a Win10 test.

For some reason on Windows 10, GetVersionExA/GetVersionExW return differently than RtlGetVersion even though they are just wrappers to this NT function. On Windows 10, these returned:

  • Major: 6
  • Minor: 2
  • Build: 9200

While RtlGetVersion returns:

  • Major: 10
  • Minor: 0
  • Build: 18363

So to bypass this issue and to fix the plugin window, we can hook RtlGetVersion and return fake Windows 10 platform data like so:

 * ntdll!RtlGetVersion detour callback.
 * @param {PRTL_OSVERSIONINFOW} lpVersionInformation - Pointer to either a RTL_OSVERSIONINFOW structure or a RTL_OSVERSIONINFOEXW structure that contains the version information about the currently running operating system.
 * @return {NTSTATUS} STATUS_SUCCESS on success.
NTSTATUS NTAPI Mine_RtlGetVersion(PRTL_OSVERSIONINFOW lpVersionInformation)
    const auto res = Real_RtlGetVersion(lpVersionInformation);
    if (res == STATUS_SUCCESS)
        // Fake the return as Windows 10 (1909 - 18363.592)
        lpVersionInformation->dwMajorVersion = 0x0A;
        lpVersionInformation->dwMinorVersion = 0x00;
        lpVersionInformation->dwBuildNumber  = 0x47BB;
        lpVersionInformation->dwPlatformId   = 0x02;

    return res;


Plugins window working on Windows 8.1

An updated version of ElgatoLegacy is available on GitHub here: https://github.com/atom0s/ElgatoLegacy