Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
88 changes: 76 additions & 12 deletions src/Hook/Hooks_IPC_ISteamUser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,43 @@
#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 <mutex>
#include <unordered_map>
#include <vector>

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<AppId_t, std::vector<uint8_t>> g_freshEticket;

// [Post-Handler]: IClientUser::GetSteamID
void HandlerPost_IClientUser_GetSteamID(CPipeClient* pipe,CUtlBuffer* pRead, CUtlBuffer* pWrite)
{
AppId_t appId = Hooks_Misc::ResolveAppId();
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;
}

Expand All @@ -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;

Expand Down Expand Up @@ -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<const uint8_t> 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<std::mutex> lock(g_freshEticketMutex);
g_freshEticket[appId] = std::move(*fresh);
}
}
}

bool haveFresh;
{
std::lock_guard<std::mutex> lock(g_freshEticketMutex);
haveFresh = g_freshEticket.find(appId) != g_freshEticket.end();
}

std::vector<uint8_t> 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<uint8_t> 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<uint8_t> ticket;
{
std::lock_guard<std::mutex> 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<uint32>(ticket.size());
uint32 newCapacity = pWrite->Capacity() + ticketSize;
Expand Down
72 changes: 72 additions & 0 deletions src/Hook/Hooks_NetPacket.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 <chrono>
#include <cstdio>
#include <cstring>
#include <deque>
#include <string>
#include <future>
#include <mutex>
#include <unordered_map>
Expand Down Expand Up @@ -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<uint32>(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
// ════════════════════════════════════════════════════════════════
Expand Down Expand Up @@ -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;
}
Expand Down
1 change: 1 addition & 0 deletions src/OSTPlatform/Windows/PE.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@ Image::Image(const std::filesystem::path& path) : path_(path) {
section->Misc.VirtualSize,
section->PointerToRawData,
section->SizeOfRawData,
section->Characteristics,
});
}

Expand Down
6 changes: 6 additions & 0 deletions src/OSTPlatform/include/PE.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
11 changes: 8 additions & 3 deletions src/Pipe/Features/DenuvoAuth/DenuvoAuth.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -151,15 +151,20 @@ 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);
return;
}

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;
}

Expand All @@ -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);
}

Expand Down
Loading