Skip to content

Denuvo auth fails on titles missed by ProtectionScan (e.g. Sniper Elite 4 / appid 312660) — root cause + fix #120

Description

@xingcheng1423-cmyk

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions