From 0e50ded8e423949a14d73f99fc359f2eca2e3277 Mon Sep 17 00:00:00 2001 From: sanasol Date: Thu, 5 Mar 2026 18:43:33 +0100 Subject: [PATCH] MAESTRO: harden patcher replacements for multi-binary installs --- backend/utils/__tests__/clientPatcher.test.js | 36 ++++ backend/utils/clientPatcher.js | 168 +++++++++++++++--- package.json | 1 + 3 files changed, 178 insertions(+), 27 deletions(-) create mode 100644 backend/utils/__tests__/clientPatcher.test.js diff --git a/backend/utils/__tests__/clientPatcher.test.js b/backend/utils/__tests__/clientPatcher.test.js new file mode 100644 index 0000000..ca756f2 --- /dev/null +++ b/backend/utils/__tests__/clientPatcher.test.js @@ -0,0 +1,36 @@ +const assert = require('assert'); +const clientPatcher = require('../clientPatcher'); + +function bytesAreZero(buffer) { + for (const byte of buffer) { + if (byte !== 0x00) { + return false; + } + } + return true; +} + +const directStrategy = clientPatcher.getDomainStrategy('authws.net'); +assert.strictEqual(directStrategy.mode, 'direct'); +assert.strictEqual(directStrategy.mainDomain, 'authws.net'); +assert.strictEqual(directStrategy.subdomainPrefix, ''); + +const splitStrategy = clientPatcher.getDomainStrategy('auth.sanasol.ws'); +assert.strictEqual(splitStrategy.mode, 'split'); +assert.strictEqual(splitStrategy.subdomainPrefix, 'auth.s'); +assert.strictEqual(splitStrategy.mainDomain, 'anasol.ws'); + +const oldSubdomain = clientPatcher.stringToLengthPrefixed('https://tools.'); +const newSubdomain = clientPatcher.stringToLengthPrefixed('https://'); +const replaceResult = clientPatcher.replaceBytes(oldSubdomain, oldSubdomain, newSubdomain); +const padding = replaceResult.buffer.slice(newSubdomain.length, oldSubdomain.length); +assert.ok(bytesAreZero(padding), 'Expected null padding after shorter replacement'); + +const oldDomainUtf16 = clientPatcher.stringToUtf16LE('hytale.com'); +const smartResult = clientPatcher.findAndReplaceDomainSmart(oldDomainUtf16, 'hytale.com', 'auth.ws'); +const newDomainUtf16 = clientPatcher.stringToUtf16LE('auth.ws'); +assert.ok(smartResult.buffer.slice(0, newDomainUtf16.length).equals(newDomainUtf16)); +const smartPadding = smartResult.buffer.slice(newDomainUtf16.length, oldDomainUtf16.length); +assert.ok(bytesAreZero(smartPadding), 'Expected null padding in smart replacement'); + +console.log('clientPatcher tests passed'); diff --git a/backend/utils/clientPatcher.js b/backend/utils/clientPatcher.js index 4656f3d..081d3c8 100644 --- a/backend/utils/clientPatcher.js +++ b/backend/utils/clientPatcher.js @@ -132,9 +132,10 @@ class ClientPatcher { /** * Replace bytes in buffer - only overwrites the length of new bytes */ - replaceBytes(buffer, oldBytes, newBytes) { + replaceBytes(buffer, oldBytes, newBytes, options = {}) { let count = 0; const result = Buffer.from(buffer); + const padNulls = options.padNulls !== false; if (newBytes.length > oldBytes.length) { console.warn(` Warning: New pattern (${newBytes.length}) longer than old (${oldBytes.length}), skipping`); @@ -144,6 +145,9 @@ class ClientPatcher { const positions = this.findAllOccurrences(result, oldBytes); for (const pos of positions) { newBytes.copy(result, pos); + if (padNulls && newBytes.length < oldBytes.length) { + result.fill(0x00, pos + newBytes.length, pos + oldBytes.length); + } count++; } @@ -173,14 +177,21 @@ class ClientPatcher { if (lastCharFirstByte === oldLastCharByte) { newUtf16NoLast.copy(result, pos); - result[lastCharPos] = newLastCharByte; + const newLastCharPos = pos + newUtf16NoLast.length; + const secondByte = lastCharPos + 1 < result.length ? result[lastCharPos + 1] : 0x00; + const isUtf16 = secondByte === 0x00; + if (newUtf16NoLast.length < oldUtf16NoLast.length) { + const padEnd = isUtf16 ? lastCharPos + 2 : pos + oldUtf16NoLast.length; + result.fill(0x00, newLastCharPos + 1, padEnd); + } + result[newLastCharPos] = newLastCharByte; - if (lastCharPos + 1 < result.length) { - const secondByte = result[lastCharPos + 1]; - if (secondByte === 0x00) { + if (newLastCharPos + 1 < result.length) { + const nextByte = result[newLastCharPos + 1]; + if (nextByte === 0x00) { console.log(` Patched UTF-16LE occurrence at offset 0x${pos.toString(16)}`); } else { - console.log(` Patched length-prefixed occurrence at offset 0x${pos.toString(16)} (metadata: 0x${secondByte.toString(16)})`); + console.log(` Patched length-prefixed occurrence at offset 0x${pos.toString(16)} (metadata: 0x${nextByte.toString(16)})`); } } count++; @@ -190,6 +201,51 @@ class ClientPatcher { return { buffer: result, count }; } + /** + * Apply all domain patches using legacy UTF-16LE format + */ + applyLegacyPatches(data, domain, protocol = 'https://') { + let result = Buffer.from(data); + let totalCount = 0; + const strategy = this.getDomainStrategy(domain); + + console.log(` Legacy strategy: ${strategy.description}`); + + const oldSentry = 'https://ca900df42fcf57d4dd8401a86ddd7da2@sentry.hytale.com/2'; + const newSentry = `${protocol}t@${domain}/2`; + const sentryResult = this.replaceBytes( + result, + this.stringToUtf16LE(oldSentry), + this.stringToUtf16LE(newSentry) + ); + result = sentryResult.buffer; + if (sentryResult.count > 0) { + console.log(` Replaced ${sentryResult.count} legacy sentry occurrence(s)`); + totalCount += sentryResult.count; + } + + const domainResult = this.findAndReplaceDomainSmart(result, ORIGINAL_DOMAIN, strategy.mainDomain); + result = domainResult.buffer; + if (domainResult.count > 0) { + console.log(` Replaced ${domainResult.count} legacy domain occurrence(s)`); + totalCount += domainResult.count; + } + + const subdomains = ['https://tools.', 'https://sessions.', 'https://account-data.', 'https://telemetry.']; + const newSubdomainPrefix = protocol + strategy.subdomainPrefix; + + for (const sub of subdomains) { + const subResult = this.findAndReplaceDomainSmart(result, sub, newSubdomainPrefix); + result = subResult.buffer; + if (subResult.count > 0) { + console.log(` Replaced ${subResult.count} legacy subdomain occurrence(s)`); + totalCount += subResult.count; + } + } + + return { buffer: result, count: totalCount }; + } + /** * Apply all domain patches using length-prefixed format */ @@ -472,7 +528,7 @@ class ClientPatcher { if (count === 0 && discordCount === 0) { console.log('No occurrences found - trying legacy UTF-16LE format...'); - const legacyResult = this.findAndReplaceDomainSmart(data, ORIGINAL_DOMAIN, strategy.mainDomain); + const legacyResult = this.applyLegacyPatches(data, newDomain); if (legacyResult.count > 0) { console.log(`Found ${legacyResult.count} occurrences with legacy format`); fs.writeFileSync(clientPath, legacyResult.buffer); @@ -645,23 +701,64 @@ class ClientPatcher { * Find client binary path based on platform */ findClientPath(gameDir) { + const candidates = this.findClientBinaries(gameDir); + return candidates.length > 0 ? candidates[0] : null; + } + + /** + * Find all client binaries that may contain patchable strings + */ + findClientBinaries(gameDir) { const candidates = []; + const seen = new Set(); + const clientDir = path.join(gameDir, 'Client'); + + const addCandidate = (candidate) => { + if (!candidate || seen.has(candidate)) { + return; + } + if (fs.existsSync(candidate)) { + candidates.push(candidate); + seen.add(candidate); + } + }; if (process.platform === 'darwin') { - candidates.push(path.join(gameDir, 'Client', 'Hytale.app', 'Contents', 'MacOS', 'HytaleClient')); - candidates.push(path.join(gameDir, 'Client', 'HytaleClient')); + const appBundle = path.join(clientDir, 'Hytale.app'); + const appMacosDir = path.join(appBundle, 'Contents', 'MacOS'); + + addCandidate(path.join(appMacosDir, 'HytaleClient')); + addCandidate(path.join(clientDir, 'HytaleClient')); + + if (fs.existsSync(appMacosDir)) { + const entries = fs.readdirSync(appMacosDir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isFile()) { + addCandidate(path.join(appMacosDir, entry.name)); + } + } + } } else if (process.platform === 'win32') { - candidates.push(path.join(gameDir, 'Client', 'HytaleClient.exe')); + addCandidate(path.join(clientDir, 'HytaleClient.exe')); + addCandidate(path.join(clientDir, 'HytaleClient.dll')); + + if (fs.existsSync(clientDir)) { + const entries = fs.readdirSync(clientDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isFile()) { + continue; + } + const lowerName = entry.name.toLowerCase(); + if (lowerName.endsWith('.dll')) { + addCandidate(path.join(clientDir, entry.name)); + } + } + } } else { - candidates.push(path.join(gameDir, 'Client', 'HytaleClient')); + addCandidate(path.join(clientDir, 'HytaleClient')); } - for (const candidate of candidates) { - if (fs.existsSync(candidate)) { - return candidate; - } - } - return null; + return candidates; } /** @@ -691,17 +788,34 @@ class ClientPatcher { success: true }; - const clientPath = this.findClientPath(gameDir); - if (clientPath) { - if (progressCallback) progressCallback('Patching client binary...', 10); - results.client = await this.patchClient(clientPath, (msg, pct) => { - if (progressCallback) { - progressCallback(`Client: ${msg}`, pct ? pct / 2 : null); - } - }); + const clientPaths = this.findClientBinaries(gameDir); + if (clientPaths.length > 0) { + if (progressCallback) progressCallback('Patching client binaries...', 10); + const clientResults = []; + let totalPatchCount = 0; + + for (const clientPath of clientPaths) { + const result = await this.patchClient(clientPath, (msg, pct) => { + if (progressCallback) { + progressCallback(`Client: ${path.basename(clientPath)}: ${msg}`, pct ? pct / 2 : null); + } + }); + clientResults.push({ path: clientPath, ...result }); + totalPatchCount += result.patchCount || 0; + } + + const allSuccess = clientResults.every((entry) => entry.success); + const allAlreadyPatched = clientResults.every((entry) => entry.alreadyPatched); + + results.client = { + success: allSuccess, + patchCount: totalPatchCount, + alreadyPatched: allAlreadyPatched, + binaries: clientResults + }; } else { - console.warn('Could not find HytaleClient binary'); - results.client = { success: false, error: 'Client binary not found' }; + console.warn('Could not find HytaleClient binaries'); + results.client = { success: false, error: 'Client binaries not found' }; } // Download DualAuth ByteBuddy Agent (runtime patching, no JAR modification) diff --git a/package.json b/package.json index 4cbf9da..c0ed7db 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "scripts": { "start": "electron .", "dev": "electron . --dev", + "test": "node backend/utils/__tests__/clientPatcher.test.js", "build": "electron-builder", "build:win": "electron-builder --win", "build:linux": "electron-builder --linux",