Follow-up to the symptom in #117 (Denuvo error 8850000A when a non-owner account plays with an owner's ticket injected). I traced it end-to-end with a Debug build + trace logs and found two concrete bugs in ProtectionScan, plus a fragile spot in the auth window. A one-line ProtectionScan fix is enough to make detection engage; PR incoming for that part.
Repro
- Game: Sniper Elite 4 (appid 312660), Denuvo-protected.
- Non-owner account logged in; owner's AppOwnershipTicket + EncryptedAppTicket injected via lua
setAppTicket / setETicket.
- Result: game refuses to launch with Denuvo
8850000A.
Finding 1 — the size floor skips the game executable
kMinPackedModuleBytes = 80 MB in ProtectionScan.cpp is applied to every module including the main .exe. SniperElite4_DX11.exe is 70.97 MB, so it is skipped before any scan:
DenuvoAuth: module skipped below packed size floor path=...\SniperElite4_DX11.exe size=74420224 (70.97 MB) min=83886080 (80.00 MB)
DenuvoAuth: pid=... enumerated 0 candidate module(s)
DenuvoAuth: pid=... no Denuvo match scanned=0
→ denuvo=false → the authorization window never opens → GetSteamID returns the real (non-owner) SteamID while the owner ETicket is still served (it's not gated) → identity mismatch → 8850000A.
Fix (exempt the main executable from the floor; keep it for DLLs):
// EnumerateModules() in ProtectionScan.cpp
- if (module.size < kMinPackedModuleBytes) {
+ // Denuvo packs the .exe itself; some titles (e.g. Sniper Elite 4, 71MB) fall
+ // under the 80MB floor and get skipped. Always scan the main executable.
+ if (!executable && module.size < kMinPackedModuleBytes) {
Finding 2 — after lifting the floor, neither signature matches this build
With the .exe scanned, it is still not detected:
DenuvoAuth: OEP section timing ... section=.edata raw_start=0x30CE00 scan_size=512 (0.00 MB) matched=false
DenuvoAuth: OEP section scanned ... section=.edata oep=0xF4C151 raw_start=0x30CE00 scan_size=512 matched=false
DenuvoAuth: legacy section present but DENUVO string not found ... section=.srdata
DenuvoAuth: no Denuvo match scanned=1
Two issues:
- The OEP (RVA
0xF4C151) resolved to section .edata (the export directory, 512 bytes). The OEP almost certainly shouldn't land in .edata; this looks like a SectionContainingRva resolution problem on this binary's (Denuvo-rewritten) section table, so the DODENUVO scan reads the wrong 512 bytes. Worth checking SectionContainingRva / EntryPointRva for PEs with this layout.
- The legacy path does find a known Denuvo section (
.srdata) but bails because the literal DENUVO ASCII string isn't present in this build (newer Denuvo obfuscates it). So requiring section and literal string misses this title.
Most Denuvo titles trigger the OEP pattern or the legacy string and detect fine — this is specifically about titles that slip both heuristics.
Additional: the authorization window is timing-fragile (separate from detection)
Even once denuvo=true, DenuvoAuth.cpp closes the window after a fixed count of pipe handshakes:
constexpr uint32 kEndDenuvoVerificationHandshake = 2;
...
if (stage == Stage::Authorizing && handshakeCount >= kEndDenuvoVerificationHandshake) {
stage = Stage::EndAuthorization; // window closed permanently; bound to the first pipe only
}
bool CanUseAuthorizedIdentity(const PipeKey& key) const {
return denuvo && stage == Stage::Authorizing && authorizationPipe == key;
}
handshakeCount counts pipe handshakes for the process and increments on the selecting handshake itself, so the window effectively closes on the process's 2nd pipe handshake — which has no relationship to when Denuvo actually performs its ownership/SteamID verification (often later and/or on a different pipe). When that happens, GetSteamID is no longer spoofed and GetAppOwnershipTicket falls back to ForgeOnly, so Denuvo sees the owner ETicket paired with the real SteamID → mismatch.
For the "playing account ≠ owning account" case there is no real identity to restore, so we widened it downstream:
bool CanUseAuthorizedIdentity(const PipeKey& key) const {
if (!denuvo) return false;
if (!LuaConfig::IsOwned(authorizedAppId)) return true; // non-owner: serve owner identity for the whole session, any pipe
return stage == Stage::Authorizing && authorizationPipe == key; // owned: keep your narrow window
}
This is more of a design question for you than a clean fix, so I'm not putting it in the PR — just flagging it.
What worked
With Finding 1 fixed (or detection otherwise forced on for this app) plus persistent owner-identity spoofing, Sniper Elite 4 launches on a non-owner account. So the pipeline itself is sound; the blocker was purely that ProtectionScan didn't recognize this Denuvo build.
I'll open a PR for the Finding 1 one-liner (it's unambiguous). Happy to share full trace logs, the test binary, or help investigate the SectionContainingRva issue in Finding 2.
Follow-up to the symptom in #117 (Denuvo error
8850000Awhen a non-owner account plays with an owner's ticket injected). I traced it end-to-end with a Debug build + trace logs and found two concrete bugs inProtectionScan, plus a fragile spot in the auth window. A one-lineProtectionScanfix is enough to make detection engage; PR incoming for that part.Repro
setAppTicket/setETicket.8850000A.Finding 1 — the size floor skips the game executable
kMinPackedModuleBytes = 80 MBinProtectionScan.cppis applied to every module including the main.exe.SniperElite4_DX11.exeis 70.97 MB, so it is skipped before any scan:→
denuvo=false→ the authorization window never opens →GetSteamIDreturns the real (non-owner) SteamID while the owner ETicket is still served (it's not gated) → identity mismatch →8850000A.Fix (exempt the main executable from the floor; keep it for DLLs):
Finding 2 — after lifting the floor, neither signature matches this build
With the .exe scanned, it is still not detected:
Two issues:
0xF4C151) resolved to section.edata(the export directory, 512 bytes). The OEP almost certainly shouldn't land in.edata; this looks like aSectionContainingRvaresolution problem on this binary's (Denuvo-rewritten) section table, so theDODENUVOscan reads the wrong 512 bytes. Worth checkingSectionContainingRva/EntryPointRvafor PEs with this layout..srdata) but bails because the literalDENUVOASCII string isn't present in this build (newer Denuvo obfuscates it). So requiring section and literal string misses this title.Most Denuvo titles trigger the OEP pattern or the legacy string and detect fine — this is specifically about titles that slip both heuristics.
Additional: the authorization window is timing-fragile (separate from detection)
Even once
denuvo=true,DenuvoAuth.cppcloses the window after a fixed count of pipe handshakes:handshakeCountcounts pipe handshakes for the process and increments on the selecting handshake itself, so the window effectively closes on the process's 2nd pipe handshake — which has no relationship to when Denuvo actually performs its ownership/SteamID verification (often later and/or on a different pipe). When that happens,GetSteamIDis no longer spoofed andGetAppOwnershipTicketfalls back toForgeOnly, so Denuvo sees the owner ETicket paired with the real SteamID → mismatch.For the "playing account ≠ owning account" case there is no real identity to restore, so we widened it downstream:
This is more of a design question for you than a clean fix, so I'm not putting it in the PR — just flagging it.
What worked
With Finding 1 fixed (or detection otherwise forced on for this app) plus persistent owner-identity spoofing, Sniper Elite 4 launches on a non-owner account. So the pipeline itself is sound; the blocker was purely that
ProtectionScandidn't recognize this Denuvo build.I'll open a PR for the Finding 1 one-liner (it's unambiguous). Happy to share full trace logs, the test binary, or help investigate the
SectionContainingRvaissue in Finding 2.