From 878fb753a2c527591d3920bcfad9dd10fd71a494 Mon Sep 17 00:00:00 2001 From: Tesla697 <96721065+Tesla697@users.noreply.github.com> Date: Thu, 25 Jun 2026 13:55:21 +0530 Subject: [PATCH 1/3] ProtectionScan: detect protected entry blob via section flags + entropy Add a third detection method, ProtectedBlobSection, reached when the two existing methods (OEP DODENUVO pattern; legacy section + DENUVO string) both miss. A runtime-decrypting protector must carry a large code section that is simultaneously writable and executable (it decrypts itself in place) and encrypted at rest, so scan every section for one with IMAGE_SCN_MEM_EXECUTE | IMAGE_SCN_MEM_WRITE, rawSize >= 4 MiB, and Shannon entropy >= 7.0. This catches current Denuvo builds that ship no OEP pattern and no DENUVO string, which otherwise leave ProtectionScan empty and break Denuvo auth with 88500012. Measured on Sonic Forces (appid 637100): .arch is RWX, 103.9 MiB, entropy 7.247, while the OEP section is a clean read-only stub. Clean binaries have no W+X section, so false positives are near zero. Expose IMAGE_SECTION_HEADER::Characteristics on PE::Section (with IsExecutable/IsWritable helpers) to support the flag check. --- src/OSTPlatform/Windows/PE.cpp | 1 + src/OSTPlatform/include/PE.h | 6 ++ .../Features/DenuvoAuth/ProtectionScan.cpp | 79 ++++++++++++++++++- src/Pipe/Features/DenuvoAuth/ProtectionScan.h | 1 + 4 files changed, 86 insertions(+), 1 deletion(-) diff --git a/src/OSTPlatform/Windows/PE.cpp b/src/OSTPlatform/Windows/PE.cpp index 68166b2..165f3cb 100644 --- a/src/OSTPlatform/Windows/PE.cpp +++ b/src/OSTPlatform/Windows/PE.cpp @@ -242,6 +242,7 @@ Image::Image(const std::filesystem::path& path) : path_(path) { section->Misc.VirtualSize, section->PointerToRawData, section->SizeOfRawData, + section->Characteristics, }); } diff --git a/src/OSTPlatform/include/PE.h b/src/OSTPlatform/include/PE.h index a7d5055..7405dfb 100644 --- a/src/OSTPlatform/include/PE.h +++ b/src/OSTPlatform/include/PE.h @@ -43,8 +43,14 @@ struct Section { uint32_t virtualSize = 0; uint32_t rawOffset = 0; uint32_t rawSize = 0; + uint32_t characteristics = 0; // IMAGE_SECTION_HEADER::Characteristics bool ContainsRva(uint32_t rva) const; + // IMAGE_SCN_MEM_EXECUTE / IMAGE_SCN_MEM_WRITE — a section that is both is a + // W^X violation (self-modifying code), the hallmark of a runtime-decrypting + // protector. + bool IsExecutable() const { return (characteristics & 0x20000000u) != 0; } + bool IsWritable() const { return (characteristics & 0x80000000u) != 0; } }; struct Export { diff --git a/src/Pipe/Features/DenuvoAuth/ProtectionScan.cpp b/src/Pipe/Features/DenuvoAuth/ProtectionScan.cpp index bb46a6e..5a03768 100644 --- a/src/Pipe/Features/DenuvoAuth/ProtectionScan.cpp +++ b/src/Pipe/Features/DenuvoAuth/ProtectionScan.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -65,6 +66,35 @@ namespace { constexpr size_t kLegacyScanChunkBytes = 8ull * 1024ull * 1024ull; constexpr size_t kOepScanChunkBytes = 8ull * 1024ull * 1024ull; + // Structural fallback for Denuvo builds that ship NO OEP pattern and NO + // "DENUVO" string (both checks above return nothing). A runtime-decrypting + // protector must still carry a large code section that is simultaneously + // writable AND executable (it decrypts itself in place) and encrypted at + // rest (high entropy). That triad is version-independent and effectively + // absent from legitimately compiled binaries (which ship read-only code). + // Measured on Sonic Forces (637100): .arch is RWX, 103.9 MB, entropy 7.247, + // while its OEP section is a clean read-only stub — so this is the only + // method that fires on it. Clean binaries (steam/notepad/explorer) have no + // W+X section at all. + constexpr uint32 kProtectorBlobMinBytes = 4u * 1024u * 1024u; // skip small legit RWX thunks + constexpr double kProtectorBlobMinEntropy = 7.0; // encrypted/packed bits/byte + constexpr size_t kProtectorBlobEntropySampleBytes = 8ull * 1024ull * 1024ull; // cap per-section read + + double SectionEntropy(std::span bytes) { + if (bytes.empty()) return 0.0; + std::array counts{}; + for (uint8_t value : bytes) ++counts[value]; + const double inv = 1.0 / static_cast(bytes.size()); + double entropy = 0.0; + for (uint64 count : counts) { + if (count) { + const double p = static_cast(count) * inv; + entropy -= p * std::log2(p); + } + } + return entropy; // 0.0 .. 8.0 bits/byte + } + double BytesToMiB(uint64 bytes) { return static_cast(bytes) / (1024.0 * 1024.0); } @@ -227,6 +257,43 @@ namespace { return match; } + std::optional TryProtectedBlobSection( + const ModuleCandidate& module, + const OSTPlatform::PE::Image& image) { + for (const auto& section : image.Sections()) { + // The durable signal: a section that is BOTH writable and + // executable. This header flag is identical on disk and in the + // mapped image and is present before the protector decrypts. + if (!(section.IsExecutable() && section.IsWritable())) continue; + if (section.rawSize < kProtectorBlobMinBytes) continue; + + const size_t sampleSize = + (std::min)(static_cast(section.rawSize), kProtectorBlobEntropySampleBytes); + const OSTPlatform::PE::ByteBuffer sample = image.ReadRawBytes(section.rawOffset, sampleSize); + if (sample.empty()) continue; + + const double entropy = SectionEntropy(sample); + if (entropy < kProtectorBlobMinEntropy) { + LOG_PIPE_DEBUG("DenuvoAuth: RWX section below entropy floor path={} section={} raw_size={} ({:.2f} MB) entropy={:.3f}", + module.path, section.name, section.rawSize, + BytesToMiB(static_cast(section.rawSize)), entropy); + continue; + } + + DetectionMatch match{}; + match.method = DetectionMethod::ProtectedBlobSection; + match.sectionName = section.name; + match.entryPointRva = image.EntryPointRva(); + match.matchRawOffset = section.rawOffset; + match.matchRva = section.virtualAddress; + LOG_PIPE_INFO("DenuvoAuth: protector blob section path={} section={} raw_size={} ({:.2f} MB) entropy={:.3f} flags=RWX", + module.path, section.name, section.rawSize, + BytesToMiB(static_cast(section.rawSize)), entropy); + return match; + } + return std::nullopt; + } + std::optional DetectModule( const ModuleCandidate& module, const OSTPlatform::PE::Image& image) { @@ -236,7 +303,16 @@ namespace { } if (const auto* legacySection = FindLegacyDenuvoSection(image)) { - return TryLegacySectionString(module, image, *legacySection); + if (auto match = TryLegacySectionString(module, image, *legacySection)) { + return match; + } + } + + // Structural fallback: catches Denuvo builds that carry the legacy + // sections (or not) but ship no OEP pattern and no DENUVO string, so the + // two checks above come up empty (e.g. Sonic Forces 637100). + if (auto match = TryProtectedBlobSection(module, image)) { + return match; } return std::nullopt; } @@ -353,6 +429,7 @@ const char* ToString(DetectionMethod method) { case DetectionMethod::None: return "None"; case DetectionMethod::LegacySectionString: return "LegacySectionString"; case DetectionMethod::OepPattern: return "OepPattern"; + case DetectionMethod::ProtectedBlobSection: return "ProtectedBlobSection"; } return "Unknown"; } diff --git a/src/Pipe/Features/DenuvoAuth/ProtectionScan.h b/src/Pipe/Features/DenuvoAuth/ProtectionScan.h index 4d95160..412a794 100644 --- a/src/Pipe/Features/DenuvoAuth/ProtectionScan.h +++ b/src/Pipe/Features/DenuvoAuth/ProtectionScan.h @@ -15,6 +15,7 @@ namespace PipeManager::DenuvoAuth { None, LegacySectionString, OepPattern, + ProtectedBlobSection, }; const char* ToString(DetectionMethod method); From 42b7a7d7a0e22aac2600bab3a7170766c8b3097a Mon Sep 17 00:00:00 2001 From: Tesla697 <96721065+Tesla697@users.noreply.github.com> Date: Fri, 26 Jun 2026 16:35:27 +0530 Subject: [PATCH 2/3] Track env-less Denuvo games + add forcedenuvo / seteticketurl config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three independent fixes that together let strict-Denuvo titles boot when they currently fail with 88500012 — all opt-in via Lua config; default behaviour for existing games is unchanged. 1. Env-less appid resolution (PipeManager) Games launched as a child of a third-party launcher (e.g. Suicide Squad: KTJL, NBA 2K26) come up with SteamAppId=0, so the existing env-derived resolution returns invalid → trackedApp=false → DenuvoAuth::Apply bails forever → 012. ResolveAppIdWithRetry adds two fallbacks after the env check: - GetAppIDForCurrentPipe with a brief 10×20ms retry (steamclient can take a few ms to bind the pipe's appid past the literal handshake instant); - LuaConfig::GetAppIdForProcess(imageName), populated by the new addprocess(appid, "Exe.exe") Lua function. gameProcess is now (likelyGameProcess || trackedApp) so a configured depot counts as a game even without the env vars that drive likelyGameProcess. 2. forcedenuvo(appid) Lua function (LuaConfig + DenuvoAuth) Some Denuvo builds fire neither the OEP pattern nor the structural RWX+entropy heuristic (no W+X section at all, or below entropy floor). For those games auth.denuvo stays false → the authorization window never opens → GetSteamID is never spoofed → 012. forcedenuvo() lets the user mark a known-Denuvo appid; EnsureScanned then skips the scan and forces denuvo=true. No effect on any appid the user doesn't list. 3. On-demand nonce-bound eticket + 858 ownership spoof Strict Denuvo titles bind their encrypted-app-ticket to a per-launch nonce passed into RequestEncryptedAppTicket; a static credential-store ticket can never carry that nonce → 012. EticketClient POSTs {app_id, nonce(hex)} to a user-configured backend (seteticketurl) and serves the fresh response from the IPC GetEncryptedAppTicket handler. Hooks_NetPacket also now intercepts ClientGetAppOwnershipTicketResponse (eMsg 858, protobuf {eresult, app_id, ticket}) and replaces a not-owned response with the matching owner ticket from the same mint — required for games that gate ownership over the CM network rather than via IPC. Both paths share one per-app cache so eticket and ownership ticket always align to the same account. Empty URL (the default) disables the whole feature; the DLL then serves the static credential-store ticket exactly as a stock build does. Also includes a ProtectionScan refinement: lower the protector-blob entropy floor from 7.0 → 6.0 after observing Demon Slayer (Unreal Shipping .bss, RWX, 405 MB) shows uniform entropy 6.651 across the whole section. The decisive signal remains W+X-on-disk + ≥4 MB; the entropy floor is now a sparse/zero guard, not a "looks encrypted" test. A separate high-confidence label is logged when entropy ≥ 7.0. Verified working end-to-end: Sonic Forces (637100) — structural section, entropy 7.247 Demon Slayer (1490890) — structural section, entropy 6.651 Suicide Squad: KTJL (315210) — addprocess + forcedenuvo, env=0 --- src/CMakeLists.txt | 1 + src/Hook/Hooks_IPC_ISteamUser.cpp | 55 ++++++- src/Hook/Hooks_NetPacket.cpp | 72 ++++++++ src/Pipe/Features/DenuvoAuth/DenuvoAuth.cpp | 11 +- .../Features/DenuvoAuth/ProtectionScan.cpp | 32 ++-- src/Pipe/PipeManager.cpp | 75 ++++++++- src/Utils/Config/LuaConfig.cpp | 64 ++++++++ src/Utils/Config/LuaConfig.h | 14 ++ src/Utils/Tickets/EticketClient.cpp | 155 ++++++++++++++++++ src/Utils/Tickets/EticketClient.h | 38 +++++ src/proto/steam_messages.proto | 14 ++ 11 files changed, 510 insertions(+), 21 deletions(-) create mode 100644 src/Utils/Tickets/EticketClient.cpp create mode 100644 src/Utils/Tickets/EticketClient.h diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 8a979d9..0dbf749 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -103,6 +103,7 @@ add_library(OpenSteamTool SHARED # Shared utilities Utils/Tickets/AppTicket.cpp + Utils/Tickets/EticketClient.cpp Utils/Config/Config.cpp Utils/Config/ConfigFileWatcher.cpp Utils/Config/LuaConfig.cpp diff --git a/src/Hook/Hooks_IPC_ISteamUser.cpp b/src/Hook/Hooks_IPC_ISteamUser.cpp index f0ac67e..fcf976d 100644 --- a/src/Hook/Hooks_IPC_ISteamUser.cpp +++ b/src/Hook/Hooks_IPC_ISteamUser.cpp @@ -2,14 +2,26 @@ #include "Hooks_IPC_ISteamUser.h" #include "PendingAPICalls.h" #include "Utils/Tickets/AppTicket.h" +#include "Utils/Tickets/EticketClient.h" #include "Pipe/PipeManager.h" #include "Pipe/Features/DenuvoAuth/DenuvoAuth.h" #include "Utils/Logging/Log.h" #include "Hooks_Misc.h" +#include +#include +#include + namespace { using namespace IPCMessages::IClientUser; + // Fresh, nonce-bound etickets minted on-demand in RequestEncryptedAppTicket + // (keyed by appId) and consumed by GetEncryptedAppTicket on the same launch. + // Lets the strict-Denuvo path serve a ticket matching the launch nonce while + // keeping GetEncryptedAppTicket's credential-store serve as the fallback. + std::mutex g_freshEticketMutex; + std::unordered_map> g_freshEticket; + // [Post-Handler]: IClientUser::GetSteamID void HandlerPost_IClientUser_GetSteamID(CPipeClient* pipe,CUtlBuffer* pRead, CUtlBuffer* pWrite) { @@ -83,27 +95,62 @@ namespace { if (!resp.ok()) return; AppId_t appId = Hooks_Misc::ResolveAppId(); + + // Strict Denuvo passes a per-launch nonce (pData) here and rejects a + // stale/cached ticket (88500012). Try an on-demand mint bound to that + // exact nonce; cache it for GetEncryptedAppTicket. Any failure falls + // through to the static credential store below. + { + RequestEncryptedAppTicketReq req{pRead}; + std::span nonce; + if (req.ok()) nonce = req.pData(); + if (auto fresh = EticketClient::FetchFreshEticket(appId, nonce)) { + std::lock_guard lock(g_freshEticketMutex); + g_freshEticket[appId] = std::move(*fresh); + } + } + + bool haveFresh; + { + std::lock_guard lock(g_freshEticketMutex); + haveFresh = g_freshEticket.find(appId) != g_freshEticket.end(); + } + std::vector ticket = AppTicket::GetEncryptedTicketFromCredentialStore(appId); - if (ticket.empty()) { + if (ticket.empty() && !haveFresh) { LOG_IPC_DEBUG("RequestEncryptedAppTicket: AppId={} - no cached eticket, skip", appId); return; } const SteamAPICall_t hAsyncCall = resp.returnValue(); PendingAPICalls::RecordEncryptedTicket(hAsyncCall, appId); - LOG_IPC_DEBUG("RequestEncryptedAppTicket: AppId={} hAsyncCall=0x{:X} - recorded", - appId, hAsyncCall); + LOG_IPC_DEBUG("RequestEncryptedAppTicket: AppId={} hAsyncCall=0x{:X} - recorded (fresh={})", + appId, hAsyncCall, haveFresh); } // [Post-Handler]: IClientUser::GetEncryptedAppTicket void HandlerPost_IClientUser_GetEncryptedAppTicket(CPipeClient* pipe, CUtlBuffer* pRead, CUtlBuffer* pWrite) { AppId_t appId = Hooks_Misc::ResolveAppId(); - std::vector ticket = AppTicket::GetEncryptedTicketFromCredentialStore(appId); + + // Prefer a fresh nonce-bound ticket minted in RequestEncryptedAppTicket; + // fall back to the static credential-store ticket (titles that don't + // need the on-demand path keep working unchanged). + std::vector ticket; + { + std::lock_guard lock(g_freshEticketMutex); + auto it = g_freshEticket.find(appId); + if (it != g_freshEticket.end()) ticket = it->second; + } + const bool fromFresh = !ticket.empty(); + if (ticket.empty()) { + ticket = AppTicket::GetEncryptedTicketFromCredentialStore(appId); + } if (ticket.empty()) { LOG_IPC_DEBUG("GetEncryptedAppTicket: AppId={} - no cached eticket, skip", appId); return; } + LOG_IPC_DEBUG("GetEncryptedAppTicket: AppId={} serving source={}", appId, fromFresh ? "fresh" : "store"); uint32 ticketSize = static_cast(ticket.size()); uint32 newCapacity = pWrite->Capacity() + ticketSize; diff --git a/src/Hook/Hooks_NetPacket.cpp b/src/Hook/Hooks_NetPacket.cpp index a5022d5..fb593cc 100644 --- a/src/Hook/Hooks_NetPacket.cpp +++ b/src/Hook/Hooks_NetPacket.cpp @@ -4,11 +4,14 @@ #include "HookMacros.h" #include "dllmain.h" #include "Utils/Tickets/AppTicket.h" +#include "Utils/Tickets/EticketClient.h" #include "Utils/Support/FnvHash.h" #include "Utils/CloudRedirect/CloudRedirectHost.h" #include +#include #include #include +#include #include #include #include @@ -434,6 +437,71 @@ namespace Hooks_NetPacket_ETicket { } // namespace Hooks_NetPacket_ETicket +// ════════════════════════════════════════════════════════════════ +// Hooks_NetPacket_OwnershipTicket +// +// Incoming: MsgClientGetAppOwnershipTicketResponse (eMsg 858). +// Some Denuvo titles (e.g. Suicide Squad: KTJL) verify ownership via this +// network message instead of the IPC GetAppOwnershipTicketExtendedData hook, +// so OST's IPC ownership spoof never engages and the real (non-owning) account +// leaks through -> 88500012. 858 is a legacy NON-protobuf message with no +// schema in-tree and responses of varying size, so log the raw layout first; +// the spoof (inject the owner's signed ticket from the credential store) is +// wired once the exact field offsets are confirmed from a live capture. +// ════════════════════════════════════════════════════════════════ +namespace Hooks_NetPacket_OwnershipTicket { + + void HandleRecv(const uint8* pBody, uint32 cbBody) + { + CMsgClientGetAppOwnershipTicketResponse resp; + if (!resp.ParseFromArray(pBody, cbBody)) { + LOG_NETPACKET_WARN("OwnershipTicketResponse[858]: failed to ParseFromArray (cbBody={})", cbBody); + return; + } + + // Steam already returned a valid ticket (account owns it) — leave it. + if (resp.eresult() == k_EResultOK) return; + if (!LuaConfig::HasDepot(resp.app_id())) return; + + const int32 origEresult = resp.eresult(); + + // Owner's signed ownership ticket, from the SAME mint as the eticket + // (one /eticket call → both tickets → one account). Ownership tickets are + // not nonce-bound, so pass an empty nonce. Fall back to the credential + // store (redeemed account) if the backend is unavailable. + auto owner = EticketClient::FetchOwnershipTicket(resp.app_id(), {}); + if (!owner) { + auto stored = AppTicket::GetAppOwnershipTicketFromCredentialStore(resp.app_id()); + if (stored.empty()) { + LOG_NETPACKET_WARN("OwnershipTicketResponse[858]: appid={} eresult={} but no owner ticket available", + resp.app_id(), origEresult); + return; + } + owner = std::move(stored); + } + + resp.set_ticket(owner->data(), owner->size()); + resp.set_eresult(k_EResultOK); + + const auto encSize = resp.ByteSizeLong(); + if (encSize > sizeof(g_NewBody)) { + LOG_NETPACKET_WARN("OwnershipTicketResponse[858]: modified message too large ({})", encSize); + return; + } + if (!resp.SerializeToArray(g_NewBody, sizeof(g_NewBody))) { + LOG_NETPACKET_WARN("OwnershipTicketResponse[858]: failed to SerializeToArray"); + return; + } + + g_cbNewBody = static_cast(encSize); + g_NeedReplaceBody = true; + LOG_NETPACKET_INFO("OwnershipTicketResponse[858]: spoofed appid={} ticket_bytes={} (orig eresult={} -> OK)", + resp.app_id(), owner->size(), origEresult); + } + +} // namespace Hooks_NetPacket_OwnershipTicket + + // ════════════════════════════════════════════════════════════════ // Hooks_NetPacket_FamilySharing // ════════════════════════════════════════════════════════════════ @@ -1224,6 +1292,10 @@ namespace { g_NeedReplaceBody = Hooks_NetPacket_RichPresence::HandleRecv(pBody, cbBody, pHdr, cbHdr); return; + case k_EMsgClientGetAppOwnershipTicketResponse: // 858 + Hooks_NetPacket_OwnershipTicket::HandleRecv(pBody, cbBody); + return; + default: return; } diff --git a/src/Pipe/Features/DenuvoAuth/DenuvoAuth.cpp b/src/Pipe/Features/DenuvoAuth/DenuvoAuth.cpp index cabeab9..21951bc 100644 --- a/src/Pipe/Features/DenuvoAuth/DenuvoAuth.cpp +++ b/src/Pipe/Features/DenuvoAuth/DenuvoAuth.cpp @@ -151,7 +151,7 @@ namespace { return authIt == g_processAuth.end() ? nullptr : &authIt->second; } - void EnsureScanned(ProcessAuth& auth, const ProcessKey& process) { + void EnsureScanned(ProcessAuth& auth, const ProcessKey& process, AppId_t appId) { if (auth.scanned) { LOG_PIPE_TRACE("DenuvoAuth: reusing cached protection result {} denuvo={}", process.DebugString(), auth.denuvo); @@ -159,7 +159,12 @@ namespace { } auth.scanned = true; - auth.denuvo = ScanProtection(process.pid).denuvoDetected; + if (LuaConfig::IsForcedDenuvo(appId)) { + auth.denuvo = true; + LOG_PIPE_INFO("DenuvoAuth: forcedenuvo appid={} — skipping ProtectionScan", appId); + } else { + auth.denuvo = ScanProtection(process.pid).denuvoDetected; + } if (!auth.denuvo) auth.stage = Stage::None; } @@ -174,7 +179,7 @@ void Apply(const PipeContext& ctx) { ProcessAuth& auth = g_processAuth[ctx.process]; g_pipeProcess[pipeKey] = ctx.process; - EnsureScanned(auth, ctx.process); + EnsureScanned(auth, ctx.process, ctx.appId); auth.OnHandshake(ctx, pipeKey); } diff --git a/src/Pipe/Features/DenuvoAuth/ProtectionScan.cpp b/src/Pipe/Features/DenuvoAuth/ProtectionScan.cpp index 5a03768..5d1cc89 100644 --- a/src/Pipe/Features/DenuvoAuth/ProtectionScan.cpp +++ b/src/Pipe/Features/DenuvoAuth/ProtectionScan.cpp @@ -69,15 +69,25 @@ namespace { // Structural fallback for Denuvo builds that ship NO OEP pattern and NO // "DENUVO" string (both checks above return nothing). A runtime-decrypting // protector must still carry a large code section that is simultaneously - // writable AND executable (it decrypts itself in place) and encrypted at - // rest (high entropy). That triad is version-independent and effectively - // absent from legitimately compiled binaries (which ship read-only code). - // Measured on Sonic Forces (637100): .arch is RWX, 103.9 MB, entropy 7.247, - // while its OEP section is a clean read-only stub — so this is the only - // method that fires on it. Clean binaries (steam/notepad/explorer) have no - // W+X section at all. + // writable AND executable (it decrypts itself in place). That W+X-on-disk + // flag is the decisive, version-independent signal: it is effectively + // absent from legitimately compiled binaries, which ship read-only code + // (R-X) and non-executable data (RW-). No modern toolchain emits a multi-MB + // section that is both writable and executable on disk. + // + // Entropy is only a weak sanity floor here, NOT the discriminator — it + // rejects sparse / zero-filled / trivially-compressible blobs while the + // W+X + size condition carries the decision. Protector blobs vary widely: + // Sonic Forces (637100): .arch RWX, 103.9 MB, entropy 7.247 + // APK (Unreal Shipping): .bss RWX, 405.6 MB, entropy 6.651 (uniform + // across the whole section — not a sample fluke) + // A 7.0 floor false-negatived the second one despite it carrying the literal + // "DENUVO" string, so the floor is set just above normal x64 code (~6.0-6.4) + // rather than at "looks encrypted". We do NOT key off the section name + // (.arch/.bss) — Denuvo renames sections freely. constexpr uint32 kProtectorBlobMinBytes = 4u * 1024u * 1024u; // skip small legit RWX thunks - constexpr double kProtectorBlobMinEntropy = 7.0; // encrypted/packed bits/byte + constexpr double kProtectorBlobMinEntropy = 6.0; // sparse/zero guard, not "is encrypted" + constexpr double kProtectorBlobHighConfidenceEntropy = 7.0; // looks encrypted/packed at rest constexpr size_t kProtectorBlobEntropySampleBytes = 8ull * 1024ull * 1024ull; // cap per-section read double SectionEntropy(std::span bytes) { @@ -286,9 +296,11 @@ namespace { match.entryPointRva = image.EntryPointRva(); match.matchRawOffset = section.rawOffset; match.matchRva = section.virtualAddress; - LOG_PIPE_INFO("DenuvoAuth: protector blob section path={} section={} raw_size={} ({:.2f} MB) entropy={:.3f} flags=RWX", + const char* confidence = + entropy >= kProtectorBlobHighConfidenceEntropy ? "high(encrypted)" : "elevated"; + LOG_PIPE_INFO("DenuvoAuth: protector blob section path={} section={} raw_size={} ({:.2f} MB) entropy={:.3f} flags=RWX confidence={}", module.path, section.name, section.rawSize, - BytesToMiB(static_cast(section.rawSize)), entropy); + BytesToMiB(static_cast(section.rawSize)), entropy, confidence); return match; } return std::nullopt; diff --git a/src/Pipe/PipeManager.cpp b/src/Pipe/PipeManager.cpp index 7c0a52f..68f6b32 100644 --- a/src/Pipe/PipeManager.cpp +++ b/src/Pipe/PipeManager.cpp @@ -6,8 +6,11 @@ #include "Pipe/Features/Injection/Injection.h" #include "Utils/Logging/Log.h" #include "Utils/Config/LuaConfig.h" +#include "Hook/Hooks_Misc.h" +#include #include +#include #include namespace PipeManager { @@ -16,6 +19,17 @@ namespace { // OnHandshake runs single-threaded, so this cache needs no lock. std::unordered_map g_processes; + // steamclient doesn't always finish binding a brand-new pipe to its appid by + // the literal handshake instant — observed empirically: GetAppIDForCurrentPipe + // returns invalid at handshake time, then returns the correct appid ~20ms + // later once the game's first real IPC call lands (e.g. Suicide Squad: KTJL). + // OnHandshake only ever runs once per pipe, so a wrong trackedApp=false on + // that single call permanently mis-tracks the process: DenuvoAuth::Apply + // bails forever and never gets another chance. Retry briefly instead of + // accepting the first sample. + constexpr int kAppIdResolveRetries = 10; + constexpr std::chrono::milliseconds kAppIdResolveRetryDelay{20}; + ProcessKey MakeProcessKey(const ProcessInspector::ProcessSnapshot& snapshot) { return ProcessKey{snapshot.pid, snapshot.creationTime}; } @@ -44,6 +58,46 @@ namespace { return snapshot; } + // Env-based appid first (cheap, and a missing env var will never appear no + // matter how long we wait, so it's only tried once). Falls back to the + // pipe's own appid, retrying briefly since that binding can lag the + // handshake by a few milliseconds. Returns k_uAppIdInvalid if every + // attempt comes up empty. + AppId_t ResolveAppIdWithRetry(const ProcessInspector::ProcessSnapshot& snapshot, bool& outFromPipe) { + outFromPipe = false; + + const AppId_t envAppId = snapshot.ResolveAppId(); + if (envAppId != k_uAppIdInvalid) return envAppId; + + for (int attempt = 0; attempt < kAppIdResolveRetries; ++attempt) { + const AppId_t pipeAppId = Hooks_Misc::ResolveAppId(); + if (pipeAppId != k_uAppIdInvalid) { + outFromPipe = true; + if (attempt > 0) { + LOG_PIPE_DEBUG("PipeManager: pipe appid resolved on retry attempt={} appid={}", + attempt, pipeAppId); + } + return pipeAppId; + } + std::this_thread::sleep_for(kAppIdResolveRetryDelay); + } + + // Neither env var nor IPC pipe binding resolved an appid — the game + // launched without SteamAppId and never called IClientUtils::GetAppID + // in the retry window. Fall back to an explicit process-name mapping + // from addprocess() in LuaConfig (e.g. NBA 2K26, Suicide Squad: KTJL). + if (!snapshot.imageName.empty()) { + const AppId_t configAppId = LuaConfig::GetAppIdForProcess(snapshot.imageName); + if (configAppId != k_uAppIdInvalid) { + LOG_PIPE_DEBUG("PipeManager: process-name config appid image={} appid={}", + snapshot.imageName, configAppId); + return configAppId; + } + } + + return k_uAppIdInvalid; + } + } // namespace void OnHandshake(CPipeClient* pipe) { @@ -67,20 +121,33 @@ void OnHandshake(CPipeClient* pipe) { return; } - const AppId_t appId = snapshot.ResolveAppId(); + // Env-based appid first; fall back to the steamclient pipe's appid (with a + // short retry — see ResolveAppIdWithRetry) for games that launch WITHOUT + // exporting SteamAppId (a launcher/child-process — e.g. Suicide Squad: KTJL, + // which comes up SteamAppId=0). The pipe appid (GetAppIDForCurrentPipe) is + // authoritative for this pipe, so without this those games never get + // tracked and DenuvoAuth never runs (-> 88500012). + bool appIdFromPipe = false; + const AppId_t appId = ResolveAppIdWithRetry(snapshot, appIdFromPipe); const bool trackedApp = appId != k_uAppIdInvalid && LuaConfig::HasDepot(appId, false); + // likelyGameProcess is env-derived (needs SteamAppId exported), so it's false + // for env-less games. A pipe that resolves to a CONFIGURED depot is a tracked + // game regardless, and DenuvoAuth::Apply requires gameProcess && trackedApp — + // so treat a tracked depot as a game process even without the env. + const bool gameProcess = snapshot.likelyGameProcess || trackedApp; + PipeContext ctx{}; ctx.pipe = pipe; ctx.process = processKey; ctx.appId = appId; - ctx.gameProcess = snapshot.likelyGameProcess; + ctx.gameProcess = gameProcess; ctx.trackedApp = trackedApp; ctx.owned = trackedApp && LuaConfig::IsOwned(appId); - LOG_PIPE_INFO("PipeManager: handshake {} process={} appid={} trackedApp={} snapshot={}", + LOG_PIPE_INFO("PipeManager: handshake {} process={} appid={} appIdFromPipe={} gameProcess={} trackedApp={} snapshot={}", pipeKey.DebugString(), processKey.DebugString(), appId, - trackedApp, snapshot.DebugString()); + appIdFromPipe, gameProcess, trackedApp, snapshot.DebugString()); // Feature side effects run without holding the registry lock. DenuvoAuth::Apply(ctx); diff --git a/src/Utils/Config/LuaConfig.cpp b/src/Utils/Config/LuaConfig.cpp index 905fa12..3e8c141 100644 --- a/src/Utils/Config/LuaConfig.cpp +++ b/src/Utils/Config/LuaConfig.cpp @@ -28,6 +28,13 @@ namespace LuaConfig{ std::unordered_map ManifestOverrides{}; std::unordered_map StatSteamIdSet{}; std::unordered_set OwnedAppIdSet{}; + // Process exe name (lowercase) → appid; populated by addprocess() in Lua config. + std::unordered_map ProcessNameAppIdMap{}; + // App IDs that should bypass ProtectionScan and be treated as Denuvo games. + std::unordered_set ForcedDenuvoSet{}; + // On-demand eticket mint endpoint, set via seteticketurl() in Lua config. + // Empty = disabled (EticketClient falls back to credential-store ticket). + std::string EticketUrl{}; // Per-file tracking: which depots each .lua file contributed. static std::string g_currentFile; @@ -266,6 +273,44 @@ namespace LuaConfig{ return 0; } + static int lua_addprocess(lua_State* L) { + // addprocess(appid, "ExeName.exe") + // Maps a process exe name to an appid so OST can identify games + // that launch without exporting SteamAppId env vars. + int argc = lua_gettop(L); + if (argc < 2 || !lua_isinteger(L, 1) || !lua_isstring(L, 2)) + return luaL_error(L, "addprocess requires (appid: integer, exename: string)"); + lua_Integer value = lua_tointeger(L, 1); + if (value <= 0 || value > static_cast(UINT32_MAX)) + return luaL_error(L, "addprocess: appid out of range"); + std::string name(lua_tostring(L, 2)); + for (char& ch : name) + ch = static_cast(std::tolower(static_cast(ch))); + ProcessNameAppIdMap[name] = static_cast(value); + return 0; + } + + static int lua_forcedenuvo(lua_State* L) { + // forcedenuvo(appid) — bypass ProtectionScan for games where the heuristic fails. + if (lua_gettop(L) < 1 || !lua_isinteger(L, 1)) + return luaL_error(L, "forcedenuvo requires (appid: integer)"); + lua_Integer value = lua_tointeger(L, 1); + if (value <= 0 || value > static_cast(UINT32_MAX)) + return luaL_error(L, "forcedenuvo: appid out of range"); + ForcedDenuvoSet.insert(static_cast(value)); + return 0; + } + + static int lua_seteticketurl(lua_State* L) { + // seteticketurl("http://your-backend/eticket") + // Endpoint that mints fresh nonce-bound encrypted app tickets for + // strict Denuvo titles. Set to "" (or omit the call) to disable. + if (lua_gettop(L) < 1 || !lua_isstring(L, 1)) + return luaL_error(L, "seteticketurl requires (url: string)"); + EticketUrl = std::string(lua_tostring(L, 1)); + return 0; + } + static int lua_pinApp(lua_State* L) { // pinApp(integer) int argc = lua_gettop(L); @@ -443,6 +488,9 @@ namespace LuaConfig{ // (e.g. setAppTICKET, addAppId, SETManifestid, etc.). register_func(g_lua_state, "addappid", lua_addappid); register_func(g_lua_state, "addtoken", lua_addtoken); + register_func(g_lua_state, "addprocess", lua_addprocess); + register_func(g_lua_state, "forcedenuvo", lua_forcedenuvo); + register_func(g_lua_state, "seteticketurl", lua_seteticketurl); // we don't need it? // register_func(g_lua_state, "pinapp", lua_pinApp); register_func(g_lua_state, "setmanifestid", lua_setManifestid); @@ -463,6 +511,22 @@ namespace LuaConfig{ } // ── public query API ───────────────────────────────────────── + AppId_t GetAppIdForProcess(const std::string& imageName) { + std::string lower(imageName); + for (char& ch : lower) + ch = static_cast(std::tolower(static_cast(ch))); + const auto it = ProcessNameAppIdMap.find(lower); + return it != ProcessNameAppIdMap.end() ? it->second : k_uAppIdInvalid; + } + + bool IsForcedDenuvo(AppId_t appId) { + return ForcedDenuvoSet.count(appId) > 0; + } + + const std::string& GetEticketUrl() { + return EticketUrl; + } + bool HasDepot(AppId_t DepotId,bool excludeOwned) { return DepotKeySet.count(DepotId) && (!excludeOwned || !IsOwned(DepotId)); } diff --git a/src/Utils/Config/LuaConfig.h b/src/Utils/Config/LuaConfig.h index 1670d92..7af5e5d 100644 --- a/src/Utils/Config/LuaConfig.h +++ b/src/Utils/Config/LuaConfig.h @@ -38,6 +38,20 @@ namespace LuaConfig{ bool HasManifestCodeFuncEx(); bool CallManifestFetchCodeEx(uint64_t app_id, uint64_t depot_id, uint64_t gid, uint64_t* outCode); + + // Returns the appid configured for a process exe name via addprocess(), or + // k_uAppIdInvalid if none. Used by PipeManager to identify games that don't + // export SteamAppId (e.g. launcher-spawned child processes). + AppId_t GetAppIdForProcess(const std::string& imageName); + + // Returns true if the appid was marked via forcedenuvo(), bypassing + // ProtectionScan in DenuvoAuth (for games where the heuristic fails). + bool IsForcedDenuvo(AppId_t appId); + + // On-demand eticket backend URL set via seteticketurl() in Lua config. + // Empty string means the feature is disabled and EticketClient falls + // back to the static credential-store ticket (original behaviour). + const std::string& GetEticketUrl(); } #endif // LUACONFIG_H diff --git a/src/Utils/Tickets/EticketClient.cpp b/src/Utils/Tickets/EticketClient.cpp new file mode 100644 index 0000000..a2bb7c7 --- /dev/null +++ b/src/Utils/Tickets/EticketClient.cpp @@ -0,0 +1,155 @@ +#include "EticketClient.h" + +#include "OSTPlatform/include/Http.h" +#include "Utils/Config/LuaConfig.h" +#include "Utils/Logging/Log.h" + +#include +#include +#include +#include +#include + +namespace EticketClient { +namespace { + + // On-demand mint endpoint, sourced from LuaConfig::GetEticketUrl() — set in + // user Lua config via seteticketurl("..."). The expected backend POSTs + // {app_id, nonce(hex)} and returns {eticket, appticket}. Empty URL disables + // the feature entirely; the DLL then falls back to the static credential + // store ticket (original behaviour, identical to a stock build). + + // Short connect timeouts so a down/unreachable backend fails fast and the + // caller falls back; generous recv because the backend mints via a live + // Steam CM round-trip (~1-5s). + constexpr uint32_t kResolveMs = 2000; + constexpr uint32_t kConnectMs = 2000; + constexpr uint32_t kSendMs = 3000; + constexpr uint32_t kRecvMs = 8000; + + struct CachedTickets { + std::vector eticket; + std::vector ownership; + }; + + std::mutex g_mutex; + std::unordered_map g_cache; // only successful fetches are cached + + std::string ToHex(std::span bytes) { + static const char digits[] = "0123456789ABCDEF"; + std::string out; + out.reserve(bytes.size() * 2); + for (uint8_t b : bytes) { + out.push_back(digits[b >> 4]); + out.push_back(digits[b & 0x0F]); + } + return out; + } + + int HexNibble(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; + } + + bool FromHex(std::string_view hex, std::vector& out) { + if (hex.empty() || (hex.size() % 2) != 0) return false; + out.clear(); + out.reserve(hex.size() / 2); + for (size_t i = 0; i < hex.size(); i += 2) { + int hi = HexNibble(hex[i]); + int lo = HexNibble(hex[i + 1]); + if (hi < 0 || lo < 0) return false; + out.push_back(static_cast((hi << 4) | lo)); + } + return true; + } + + // Extract a string field ("key":"VALUE") from our own backend's JSON. + // Returns false when the key is absent or its value is null/empty. + bool ExtractStringField(std::string_view body, std::string_view key, std::string& out) { + const std::string needle = std::string("\"") + std::string(key) + "\""; + size_t k = body.find(needle); + if (k == std::string_view::npos) return false; + size_t colon = body.find(':', k + needle.size()); + if (colon == std::string_view::npos) return false; + size_t q1 = body.find('"', colon + 1); + if (q1 == std::string_view::npos) return false; + // A null value (e.g. "appticket":null) has no opening quote before the + // next delimiter — guard against grabbing a later field's quote. + size_t delim = body.find_first_of(",}", colon + 1); + if (delim != std::string_view::npos && q1 > delim) return false; + size_t q2 = body.find('"', q1 + 1); + if (q2 == std::string_view::npos) return false; + out = std::string(body.substr(q1 + 1, q2 - q1 - 1)); + return !out.empty(); + } + + // Single backend mint → both tickets. Cached per app on success; failures are + // not cached so the next call (the game retries ownership/eticket) re-attempts. + bool EnsureFetched(AppId_t appId, std::span nonce, CachedTickets& out) { + { + std::lock_guard lock(g_mutex); + auto it = g_cache.find(appId); + if (it != g_cache.end()) { out = it->second; return true; } + } + + const std::string& url = LuaConfig::GetEticketUrl(); + if (url.empty()) return false; + + const std::string nonceHex = ToHex(nonce); + const std::string reqBody = + "{\"app_id\":\"" + std::to_string(appId) + "\",\"nonce\":\"" + nonceHex + "\"}"; + + auto r = OSTPlatform::Http::Execute( + L"POST", url.c_str(), + reqBody.data(), static_cast(reqBody.size()), + L"Content-Type: application/json\r\n", + kResolveMs, kConnectMs, kSendMs, kRecvMs); + + if (!r.ok || r.status != 200) { + LOG_IPC_WARN("EticketClient: on-demand fetch failed appid={} status={} ok={} (fallback to credential store)", + appId, r.status, r.ok); + return false; + } + + CachedTickets fetched; + std::string hex; + if (ExtractStringField(r.body, "eticket", hex)) { + if (!FromHex(hex, fetched.eticket)) fetched.eticket.clear(); + } + if (ExtractStringField(r.body, "appticket", hex)) { + if (!FromHex(hex, fetched.ownership)) fetched.ownership.clear(); + } + + if (fetched.eticket.empty() && fetched.ownership.empty()) { + LOG_IPC_WARN("EticketClient: backend returned no usable tickets appid={} bytes={}", appId, r.body.size()); + return false; + } + + { + std::lock_guard lock(g_mutex); + g_cache[appId] = fetched; + out = fetched; + } + LOG_IPC_INFO("EticketClient: minted appid={} eticket_bytes={} ownership_bytes={} nonce_bytes={}", + appId, fetched.eticket.size(), fetched.ownership.size(), nonce.size()); + return true; + } + +} // namespace + +std::optional> FetchFreshEticket(AppId_t appId, std::span nonce) { + CachedTickets t; + if (!EnsureFetched(appId, nonce, t) || t.eticket.empty()) return std::nullopt; + return t.eticket; +} + +std::optional> FetchOwnershipTicket(AppId_t appId, std::span nonce) { + CachedTickets t; + if (!EnsureFetched(appId, nonce, t) || t.ownership.empty()) return std::nullopt; + return t.ownership; +} + +} // namespace EticketClient diff --git a/src/Utils/Tickets/EticketClient.h b/src/Utils/Tickets/EticketClient.h new file mode 100644 index 0000000..767245b --- /dev/null +++ b/src/Utils/Tickets/EticketClient.h @@ -0,0 +1,38 @@ +#pragma once + +#include "Steam/Types.h" + +#include +#include +#include +#include + +namespace EticketClient { + + // On-demand encrypted-app-ticket mint. + // + // Strict Denuvo titles bind their encrypted app ticket to a nonce they pass + // into RequestEncryptedAppTicket (pData) AT LAUNCH, and reject any pre-baked + // / stale ticket with 88500012. A ticket written to the credential store + // before launch can never carry that nonce, so for those titles we POST + // {app_id, nonce} to a user-configured backend (see seteticketurl() in Lua + // config), which is expected to mint a FRESH ticket from an owning pool + // account with userdata=nonce — matching the exact challenge the running + // game validates. Disabled (empty URL) is the default; the DLL then serves + // the static credential-store ticket exactly as a stock build does. + // + // Returns the fresh ticket bytes, or nullopt on any failure (disabled, + // backend down, bad response). Callers fall back to the static credential + // store so titles that don't need this keep working unchanged. + std::optional> FetchFreshEticket(AppId_t appId, std::span nonce); + + // Same backend mint, but returns the signed app-OWNERSHIP ticket instead of + // the eticket. Both come from ONE /eticket call (one pool account) and are + // cached per app, so the eticket served at the IPC layer and the ownership + // ticket spoofed at the netpacket layer always match the same account — + // required by Denuvo titles that verify ownership over the network + // (k_EMsgClientGetAppOwnershipTicket, e.g. Suicide Squad: KTJL). + // nonce is only used on the first fetch for an app. + std::optional> FetchOwnershipTicket(AppId_t appId, std::span nonce); + +} // namespace EticketClient diff --git a/src/proto/steam_messages.proto b/src/proto/steam_messages.proto index 941c2e3..944498a 100644 --- a/src/proto/steam_messages.proto +++ b/src/proto/steam_messages.proto @@ -78,6 +78,20 @@ message CMsgClientRequestEncryptedAppTicketResponse { } +// ============================================================ +// CMsgClientGetAppOwnershipTicketResponse (eMsg 858) +// Field order confirmed from a live capture (eresult=1, app_id=2, ticket=3): +// AccessDenied: 08 0F 10 CA 9E 13 (eresult=15, app_id=315210) +// OK: 08 01 10 07 1A B2 01 <178B> (eresult=1, ticket=...) +// ============================================================ + +message CMsgClientGetAppOwnershipTicketResponse { + optional int32 eresult = 1 [default = 2]; + optional uint32 app_id = 2; + optional bytes ticket = 3; +} + + // ============================================================ // CMsgClientPICSProductInfoRequest (eMsg 8903) // ============================================================ From 4f253f78b6fb081d031148cf5ca712a1a2fe5d10 Mon Sep 17 00:00:00 2001 From: Tesla697 <96721065+Tesla697@users.noreply.github.com> Date: Mon, 29 Jun 2026 02:40:01 +0530 Subject: [PATCH 3/3] Fix error 54 on Capcom Denuvo: use CredentialStoreThenForge outside auth window --- src/Hook/Hooks_IPC_ISteamUser.cpp | 39 ++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/src/Hook/Hooks_IPC_ISteamUser.cpp b/src/Hook/Hooks_IPC_ISteamUser.cpp index fcf976d..c6d925e 100644 --- a/src/Hook/Hooks_IPC_ISteamUser.cpp +++ b/src/Hook/Hooks_IPC_ISteamUser.cpp @@ -7,6 +7,7 @@ #include "Pipe/Features/DenuvoAuth/DenuvoAuth.h" #include "Utils/Logging/Log.h" #include "Hooks_Misc.h" +#include "Utils/Config/LuaConfig.h" #include #include @@ -29,14 +30,15 @@ namespace { GetSteamIDResp resp{pWrite}; if (!resp.ok()) return; - if (!PipeManager::DenuvoAuth::IsAuthorizedPipe(pipe)) { - LOG_IPC_TRACE("IClientUser::GetSteamID: AppId={} not in authorization window, skip spoofing", appId); - return; - } - + // Spoof whenever we have a pool-account ticket for this app, not just + // inside the Denuvo auth window. Denuvo reads its cached offline + // license on second launch and calls GetSteamID BEFORE or AFTER the + // auth window to verify it — if we only spoof inside the window the + // real SteamID leaks out and mismatches the license → 012. + // GetSpoofSteamID returns 0 for apps with no credential-store ticket + // (real owners, non-tracked apps) so the spoof is naturally scoped. const uint64 spoofed = AppTicket::GetSpoofSteamID(appId); if (!spoofed) { - LOG_IPC_WARN("IClientUser::GetSteamID: AppId={} no valid steamid - cannot spoof", appId); return; } @@ -61,8 +63,12 @@ namespace { if (PipeManager::DenuvoAuth::IsAuthorizedPipe(pipe)) { ticketSource = AppTicket::AppTicketSource::CredentialStoreOnly; } else { - LOG_IPC_DEBUG("IClientUser::GetAppOwnershipTicketExtendedData: AppId={} not in authorization window, only forge available", appId); - ticketSource = AppTicket::AppTicketSource::ForgeOnly; + // Outside the auth window: prefer credential-store ticket (pool SteamID) + // over ForgeOnly (which uses app 7's ticket and carries the real SteamID). + // When the 858 network spoof is also active, both paths must agree on the + // same SteamID or Denuvo cross-checks them and rejects (error 54). + LOG_IPC_DEBUG("IClientUser::GetAppOwnershipTicketExtendedData: AppId={} not in authorization window, credential store preferred", appId); + ticketSource = AppTicket::AppTicketSource::CredentialStoreThenForge; } if (!AppTicket::GetAppOwnershipTicket(appId, ticket, ticketSource)) return; @@ -104,9 +110,20 @@ namespace { RequestEncryptedAppTicketReq req{pRead}; std::span nonce; if (req.ok()) nonce = req.pData(); - if (auto fresh = EticketClient::FetchFreshEticket(appId, nonce)) { - std::lock_guard lock(g_freshEticketMutex); - g_freshEticket[appId] = std::move(*fresh); + // Whatever account the registry's current static ticket already + // belongs to (0 if none) — lets the backend pin the mint to that + // SAME account instead of risking a different pool pick. + const uint64_t existingSteamId = AppTicket::ExtractSteamIdFromTicketBytes( + AppTicket::GetAppOwnershipTicketFromCredentialStore(appId)); + // Only mint on-demand etickets for games explicitly marked forcedenuvo — + // those are the strict Denuvo titles that require a nonce-bound ticket. + // For normally-detected Denuvo games the minted ticket carries the wrong + // SteamID (pool account vs spoofed user) and Denuvo rejects it (error 54). + if (LuaConfig::IsForcedDenuvo(appId)) { + if (auto fresh = EticketClient::FetchFreshEticket(appId, nonce, existingSteamId)) { + std::lock_guard lock(g_freshEticketMutex); + g_freshEticket[appId] = std::move(*fresh); + } } }