diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 8a979d9..adae20d 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 @@ -168,6 +169,22 @@ target_compile_definitions(OpenSteamTool PRIVATE $<$:OPENSTEAMTOOL_LOGGING_ENABLED> ) +# Backend endpoint for on-demand eticket minting (strict Denuvo titles that bind +# their encrypted app ticket to a launch nonce). Empty by default: the feature is +# off and the DLL never makes a network request. Point your own build at your own +# backend and the resulting DLL is self-contained, no Lua config needed: +# +# cmake -B build -DOST_ETICKET_URL="https://your-host/eticket" +# +# Left out of the source deliberately so no single deployment's backend ships +# baked into a public tree. seteticketurl() in the Lua config overrides it. +set(OST_ETICKET_URL "" CACHE STRING + "Backend URL for on-demand eticket minting (empty disables the feature)") +if(OST_ETICKET_URL) + target_compile_definitions(OpenSteamTool PRIVATE + OST_ETICKET_URL="${OST_ETICKET_URL}") +endif() + # --------------------------------------------------------------------------- # dwmapi.dll hijack — small loader DLL placed alongside Steam. # --------------------------------------------------------------------------- diff --git a/src/Hook/Hooks_IPC_ISteamUser.cpp b/src/Hook/Hooks_IPC_ISteamUser.cpp index f0ac67e..ceb5b0f 100644 --- a/src/Hook/Hooks_IPC_ISteamUser.cpp +++ b/src/Hook/Hooks_IPC_ISteamUser.cpp @@ -2,14 +2,27 @@ #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 "Utils/Config/LuaConfig.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) { @@ -17,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; } @@ -49,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; @@ -83,27 +101,75 @@ 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(); + // 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)); + // Mint a fresh eticket whenever the credential store already has a ticket + // for this app (existingSteamId != 0). The minted eticket is pinned to + // the same pool account via existingSteamId, which matches GetSteamID's + // spoof (also sourced from the credential store via CredentialStoreThenForge) + // — no error-54 risk. This fixes error 05 for games launched more than + // 30 min after activation (stored ticket expired, fresh mint is current). + if (existingSteamId != 0) { + if (auto fresh = EticketClient::FetchFreshEticket(appId, nonce, existingSteamId)) { + 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 5fe24be..17ad2a3 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 @@ -460,6 +463,78 @@ 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(); + + // Prefer the credential-store ticket when it is already valid: that + // ensures GetAppOwnershipTicketExtendedData and the 858 response hand + // Denuvo the identical bytes. Serving a different (backend-minted) ticket + // here caused a cross-check mismatch → 012 even when the SteamID was the + // same account. Only mint from the backend when the credential store has + // no valid ticket (existingSteamId == 0). + auto stored = AppTicket::GetAppOwnershipTicketFromCredentialStore(resp.app_id()); + const uint64_t existingSteamId = AppTicket::ExtractSteamIdFromTicketBytes(stored); + + std::vector ticketBytes; + if (existingSteamId != 0) { + ticketBytes = std::move(stored); + } else { + auto minted = EticketClient::FetchOwnershipTicket(resp.app_id(), {}, 0); + if (!minted) { + LOG_NETPACKET_WARN("OwnershipTicketResponse[858]: appid={} eresult={} but no owner ticket available", + resp.app_id(), origEresult); + return; + } + ticketBytes = std::move(*minted); + } + + resp.set_ticket(ticketBytes.data(), ticketBytes.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(), ticketBytes.size(), origEresult); + } + +} // namespace Hooks_NetPacket_OwnershipTicket + + // ════════════════════════════════════════════════════════════════ // Hooks_NetPacket_FamilySharing // ════════════════════════════════════════════════════════════════ @@ -1270,6 +1345,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/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/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 bb46a6e..5d1cc89 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,45 @@ 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). 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 = 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) { + 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 +267,45 @@ 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; + 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, confidence); + return match; + } + return std::nullopt; + } + std::optional DetectModule( const ModuleCandidate& module, const OSTPlatform::PE::Image& image) { @@ -236,7 +315,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 +441,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); 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/AppTicket.cpp b/src/Utils/Tickets/AppTicket.cpp index 69431f3..4a60793 100644 --- a/src/Utils/Tickets/AppTicket.cpp +++ b/src/Utils/Tickets/AppTicket.cpp @@ -146,6 +146,12 @@ namespace AppTicket { return true; } + uint64_t ExtractSteamIdFromTicketBytes(const std::vector& ticket) { + // Layout: ticket bytes start with [uint32 Size][uint32 Version][uint64 SteamID][...]. + if (ticket.size() < kSteamIdTicketMinimumSize) return 0; + return reinterpret_cast(ticket.data())[1]; + } + uint64_t GetSpoofSteamID(AppId_t appId) { // exclude those appids that are not in addappid if (!LuaConfig::HasDepot(appId)) { @@ -160,13 +166,10 @@ namespace AppTicket { // The SteamID baked into the cached AppOwnershipTicket is the same // one Steam itself uses for this app — pull it straight out of the // ticket so spoofed responses match what the DRM layer expects. - // Layout: ticket bytes start with [uint32 Size][uint32 Version][uint64 SteamID][...]. - std::vector ticket = GetAppOwnershipTicketFromCredentialStore(appId); - if (ticket.size() >= kSteamIdTicketMinimumSize) { - const uint64_t steamID = reinterpret_cast(ticket.data())[1]; + const uint64_t steamID = ExtractSteamIdFromTicketBytes(GetAppOwnershipTicketFromCredentialStore(appId)); + if (steamID) { LOG_DEBUG("GetSpoofSteamID for AppId {}: -> 0x{:X}({})", appId, steamID, steamID); - return steamID; } - return 0; + return steamID; } } diff --git a/src/Utils/Tickets/AppTicket.h b/src/Utils/Tickets/AppTicket.h index e36afe8..142ca68 100644 --- a/src/Utils/Tickets/AppTicket.h +++ b/src/Utils/Tickets/AppTicket.h @@ -38,6 +38,12 @@ namespace AppTicket { //Get spoof steamID From the cached AppOwnershipTicket for the given AppId. uint64_t GetSpoofSteamID(AppId_t appId); + // Parses the SteamID baked into app-ownership-ticket bytes (offset + // kAppTicketSteamIdOffset). Returns 0 if the ticket is too short to + // contain one. Lets callers identify which account a ticket belongs to + // without duplicating the layout knowledge. + uint64_t ExtractSteamIdFromTicketBytes(const std::vector& ticket); + // Write AppTicket binary data to Steam's local credential store. bool WriteAppOwnershipTicket(AppId_t appId, const std::vector& data); diff --git a/src/Utils/Tickets/EticketClient.cpp b/src/Utils/Tickets/EticketClient.cpp new file mode 100644 index 0000000..72c76c4 --- /dev/null +++ b/src/Utils/Tickets/EticketClient.cpp @@ -0,0 +1,243 @@ +#include "EticketClient.h" + +#include "OSTPlatform/include/Http.h" +#include "Utils/Config/LuaConfig.h" +#include "Utils/Logging/Log.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace EticketClient { +namespace { + + // On-demand mint endpoint. The backend is POSTed + // {app_id, nonce(hex), existing_steam_id} and returns + // {eticket, appticket, steam_id}. Any failure falls back to the static + // credential-store ticket. + // + // Resolved in two steps so a build can be self-contained without putting + // any one deployment's backend into public source: + // 1. seteticketurl() in the Lua config, if called (runtime override). + // 2. OST_ETICKET_URL, baked in at compile time via + // cmake -DOST_ETICKET_URL="https://your-host/eticket" + // + // Empty when neither is set, which disables the feature outright: the DLL + // never makes a network request and behaves exactly like stock OST. +#ifndef OST_ETICKET_URL +#define OST_ETICKET_URL "" +#endif + + std::string EticketUrl() { + const std::string& configured = LuaConfig::GetEticketUrl(); + if (!configured.empty()) return configured; + return std::string(OST_ETICKET_URL); + } + + // 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; + // The pool account these tickets were minted under. If the registry's + // current account later differs (user re-activated onto a different pool + // account mid-session), the cache is evicted and re-minted so the served + // ticket never disagrees with the account Denuvo now sees. + uint64_t steamId = 0; + }; + + std::mutex g_mutex; + std::unordered_map g_cache; // only successful fetches are cached + // Apps the backend has told us it has no owning pool account for. There's no + // point hammering the backend (or logging) on every retry within a launch, so + // we skip on-demand for the rest of the session once we learn this. + std::unordered_set g_noOwnerApps; + + 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 + // — except a "no owning account" verdict, which is sticky for the session. + // nonce and existingSteamId are only used on the first fetch for an app; once + // an entry is cached, subsequent calls (ownership vs eticket, any order) share + // it so both layers always align to the same account. + bool EnsureFetched(AppId_t appId, std::span nonce, uint64_t existingSteamId, CachedTickets& out) { + // No seteticketurl() in the config: feature off, never touch the network. + // Callers fall back to the static credential-store ticket, i.e. stock OST. + if (EticketUrl().empty()) return false; + + { + std::lock_guard lock(g_mutex); + if (g_noOwnerApps.count(appId)) return false; // already known: no pool owner + auto it = g_cache.find(appId); + if (it != g_cache.end()) { + // Evict if the registry's current account differs from the one we + // cached — a re-activation onto a different pool account must not + // be served the previous account's ticket. + const uint64_t cachedId = it->second.steamId; + if (existingSteamId != 0 && cachedId != 0 && cachedId != existingSteamId) { + LOG_IPC_DEBUG("EticketClient: appid={} registry SteamID={} differs from cached SteamID={} — evicting stale cache entry and re-minting", + appId, existingSteamId, cachedId); + g_cache.erase(it); + } else { + out = it->second; + return true; + } + } + } + + const std::string nonceHex = ToHex(nonce); + std::string reqBody = + "{\"app_id\":\"" + std::to_string(appId) + "\",\"nonce\":\"" + nonceHex + "\""; + // Only send existing_steam_id when we actually have one — an empty/zero + // value would make the backend refuse (it's read as "a ticket exists but + // for account 0", i.e. foreign) instead of picking an owner itself. + if (existingSteamId != 0) { + reqBody += ",\"existing_steam_id\":\"" + std::to_string(existingSteamId) + "\""; + } + reqBody += "}"; + + auto r = OSTPlatform::Http::Execute( + L"POST", EticketUrl().c_str(), + reqBody.data(), static_cast(reqBody.size()), + L"Content-Type: application/json\r\n", + kResolveMs, kConnectMs, kSendMs, kRecvMs); + + if (!r.ok) { + LOG_IPC_WARN("EticketClient: on-demand fetch failed appid={} status={} ok={} (fallback to credential store)", + appId, r.status, r.ok); + return false; + } + + // 409 = the backend deliberately refused. Two distinct reasons: + // - no owning account: nothing in the pool owns this app → skip for the + // rest of the session (sticky) so we stop retrying/logging. + // - foreign_account: the static ticket already in the registry belongs + // to an account the backend doesn't operate → it (correctly) won't + // mint a DIFFERENT account's ticket. Fall back to the static ticket, + // but DON'T make it sticky — a later re-activation could change it. + if (r.status == 409) { + if (r.body.find("\"foreign_account\":true") != std::string::npos) { + LOG_IPC_DEBUG("EticketClient: appid={} existing ticket belongs to an account outside our pool — skipping on-demand override for this launch", + appId); + } else { + { + std::lock_guard lock(g_mutex); + g_noOwnerApps.insert(appId); + } + LOG_IPC_DEBUG("EticketClient: appid={} no owning account in pool — skipping on-demand for this session", + appId); + } + return false; + } + + if (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; + } + + // Remember which pool account the backend minted under, so a later call + // whose registry account differs triggers the eviction above. + if (ExtractStringField(r.body, "steam_id", hex)) { + fetched.steamId = std::strtoull(hex.c_str(), nullptr, 10); + } + + { + 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, uint64_t existingSteamId) { + CachedTickets t; + if (!EnsureFetched(appId, nonce, existingSteamId, t) || t.eticket.empty()) return std::nullopt; + return t.eticket; +} + +std::optional> FetchOwnershipTicket(AppId_t appId, std::span nonce, uint64_t existingSteamId) { + CachedTickets t; + if (!EnsureFetched(appId, nonce, existingSteamId, 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..083eb14 --- /dev/null +++ b/src/Utils/Tickets/EticketClient.h @@ -0,0 +1,50 @@ +#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 the Tokeer backend, which mints a FRESH ticket from an + // owning pool account with userdata=nonce — matching the exact challenge the + // running game validates. + // + // existingSteamId is the SteamID already baked into whatever static + // AppTicket is sitting in the credential store for this app (0 if none). + // It lets the backend pin the mint to that SAME account when it's one of + // its own pool accounts — so a refreshed/nonce-bound ticket never + // disagrees with a ticket that's already in the registry. When the + // existing ticket belongs to an account the backend doesn't control (a + // real owner's own ticket, or one shared peer-to-peer from someone + // else), the backend refuses outright rather than minting a DIFFERENT + // account's ticket — that would otherwise leave half of Denuvo's + // identity checks (GetSteamID, the IPC ownership ticket) pointing at the + // original account while the eticket/network ownership ticket point at + // an unrelated pool account, guaranteeing a mismatch (88500012). + // + // Returns the fresh ticket bytes, or nullopt on any failure (disabled, + // backend down, bad response, or the existing ticket is a foreign + // account). 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, uint64_t existingSteamId = 0); + + // 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 and existingSteamId are only used on the first fetch for an app. + std::optional> FetchOwnershipTicket(AppId_t appId, std::span nonce, uint64_t existingSteamId = 0); + +} // 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) // ============================================================