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..c6d925e 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,73 @@ 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)); + // 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); + } + } + } + + 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/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/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) // ============================================================