block amplitude

This commit is contained in:
Devin Zuczek
2026-07-27 11:14:31 -04:00
parent 1127727270
commit c086e3d3d4
3 changed files with 127 additions and 4 deletions
+4 -3
View File
@@ -113,9 +113,10 @@ check (forces verify-true); it does not swap in a replacement key.
Two things to remember:
- **Blast radius:** forcing verify-true affects *all* mscorlib RSA verification, not just images.
BestHTTP's TLS uses its own bundled BouncyCastle, so cert validation appears unaffected — inferred
from assembly layout, not proven. Keep it behind the config knob.
- **Blast radius is the point, not a wart.** Forcing verify-true affects *all* mscorlib RSA
verification, not just images. That breadth is load-bearing — see the warning below. BestHTTP's TLS
uses its own bundled BouncyCastle, so cert validation appears unaffected — inferred from assembly
layout, not proven. Keep it behind the config knob.
- **If it ever stops working:** images failing to load with the knob on means verification moved off
mscorlib RSA onto `BestHTTP.SecureProtocol.Org.BouncyCastle`; the equivalent hooks there are the
**concrete** `RsaDigestSigner`/`PssSigner.VerifySignature` (not the abstract `ISigner` — gotcha 3).
+119
View File
@@ -0,0 +1,119 @@
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using HarmonyLib;
namespace RecNetPlugin.Patches;
// Amplitude analytics: the client ships telemetry to Amplitude, which a self-hosted setup has no use
// for (and which leaks play data off-box). Prefix every event-logging entrypoint and swallow the call
// so nothing is ever queued, batched or sent.
//
// One knob, see [Analytics] in the .cfg:
// Disable Amplitude Analytics -> default true; skip every AmplitudeAnalyticsClient.Log* method.
//
// Target resolution: AmplitudeAnalytics.AmplitudeAnalyticsClient in RecRoom.Analytics.Runtime.dll,
// concrete (it derives from SingletonMonoBehaviour<T>, so there is no abstract-interface dispatch
// trap here — see gotcha 3 in CLAUDE.md). All five Log* names are UNobfuscated in the 20230414 build.
// They are still strings, so a rename shows up only as a HarmonyX "Could not find method" in
// LogOutput.log, not as a build error — the per-method [AMPLITUDE] blocked log line below is the real
// proof a hook is live.
//
// Why all five: blocking LogEventAsync alone was not enough — a session's room_stats/perf_stats
// events still went out, and the LogEventAsync prefix never logged at all, so that entrypoint was
// never even called. Those events are the pre-serialized batch the client parks in the
// `pending_room_stats` PlayerPref (visible in the DUID-PROBE log), which points at
// LogSerializedEventAsync rather than LogEventAsync.
[HarmonyPatch]
public static class AmplitudePatch
{
private static readonly HashSet<string> _loggedBlocked = new();
// Returns false to skip the original. Logs once per entrypoint so LogOutput.log shows which door
// the client actually used.
private static bool Block(string entrypoint)
{
if (_loggedBlocked.Add(entrypoint))
Plugin.Log.LogInfo($"[AMPLITUDE] analytics disabled — blocked {entrypoint}");
return false;
}
[HarmonyPrefix]
[HarmonyPatch(typeof(AmplitudeAnalytics.AmplitudeAnalyticsClient), "LogEventAsync")]
private static bool LogEventAsyncPrefix() =>
Plugin.DisableAmplitudeAnalytics.Value && Block("LogEventAsync");
[HarmonyPrefix]
[HarmonyPatch(typeof(AmplitudeAnalytics.AmplitudeAnalyticsClient), "LogPrevSessionEventAsync")]
private static bool LogPrevSessionEventAsyncPrefix() =>
Plugin.DisableAmplitudeAnalytics.Value && Block("LogPrevSessionEventAsync");
// The likely culprit for the room_stats/perf_stats batch — takes the already-serialized
// Dictionary<string, object> that gets parked in `pending_room_stats`.
[HarmonyPrefix]
[HarmonyPatch(typeof(AmplitudeAnalytics.AmplitudeAnalyticsClient), "LogSerializedEventAsync")]
private static bool LogSerializedEventAsyncPrefix() =>
Plugin.DisableAmplitudeAnalytics.Value && Block("LogSerializedEventAsync");
[HarmonyPrefix]
[HarmonyPatch(typeof(AmplitudeAnalytics.AmplitudeAnalyticsClient), "LogIdentifyAsync")]
private static bool LogIdentifyAsyncPrefix() =>
Plugin.DisableAmplitudeAnalytics.Value && Block("LogIdentifyAsync");
// The odd one out: static, and it returns a promise instead of void. Skipping it with a null
// __result would hand the caller something it will chain .Then() on, so we substitute an
// already-resolved promise — the call looks like it succeeded instantly. If we cannot build one,
// we let the original run rather than risk a null-deref at quit time.
[HarmonyPrefix]
[HarmonyPatch(typeof(AmplitudeAnalytics.AmplitudeAnalyticsClient), "LogOutOfSessionEvent")]
private static bool LogOutOfSessionEventPrefix(ref LAHBDKNMNHN __result)
{
if (!Plugin.DisableAmplitudeAnalytics.Value)
return true;
var resolved = ResolvedPromise();
if (resolved == null)
return true;
__result = resolved;
return Block("LogOutOfSessionEvent");
}
private static bool _promiseResolved;
private static MethodInfo _resolvedPromiseGetter;
// Finds the concrete Promise class's static `Resolved` property getter without hardcoding its
// obfuscated name. Among the static, 0-param property getters in RecRoom.Promises.Runtime that
// return the promise interface there are exactly two: the real (obfuscated) property getter and
// the compiler-generated `get_<Name>_k__BackingField`. The backing field may be null if the
// property initialises lazily, so we drop it by its compiler-generated name — which the
// obfuscator leaves alone — and keep the other one.
private static LAHBDKNMNHN ResolvedPromise()
{
if (!_promiseResolved)
{
_promiseResolved = true;
var candidates = typeof(LAHBDKNMNHN).Assembly.GetTypes()
.SelectMany(t => t.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static))
.Where(m => m.ReturnType == typeof(LAHBDKNMNHN)
&& m.GetParameters().Length == 0
&& m.IsSpecialName
&& !m.Name.EndsWith("_k__BackingField"))
.ToList();
if (candidates.Count == 1)
_resolvedPromiseGetter = candidates[0];
else
Plugin.Log.LogWarning(
$"[AMPLITUDE] expected exactly one resolved-promise getter, found {candidates.Count} " +
"— LogOutOfSessionEvent will NOT be blocked");
}
if (_resolvedPromiseGetter == null)
return null;
return _resolvedPromiseGetter.Invoke(null, null) as LAHBDKNMNHN;
}
}
+4 -1
View File
@@ -29,6 +29,7 @@ public class Plugin : BasePlugin
public static ConfigEntry<string> DeviceIdResponseOverride { get; private set; }
public static ConfigEntry<int> DeviceIdResponseStatus { get; private set; }
public static ConfigEntry<bool> DisableSignatureVerification { get; private set; }
public static ConfigEntry<bool> DisableAmplitudeAnalytics { get; private set; }
private static bool _corruptDone;
@@ -51,7 +52,9 @@ public class Plugin : BasePlugin
DeviceIdResponseOverride = Config.Bind("Advanced", "DeviceId Response Override", "", "Replace the body of the PlayerReporting/v1/deviceId response with this text, to test what shape the client will accept. Empty = leave the server's response alone.");
DeviceIdResponseStatus = Config.Bind("Advanced", "DeviceId Response Status", 200, "HTTP status to force on the PlayerReporting/v1/deviceId response. Only applies when the override body is set.");
DisableSignatureVerification = Config.Bind("Signing", "Disable Signature Verification", true, "Force RSA signature verification to succeed (ON by default), so the client stops checking that images are signed with Rec Room's private key. This is what lets a self-hosted server serve its own images without the baked-in modulus matching. NOTE: this forces ALL mscorlib RSA verification to pass, not just image signatures.");
DisableSignatureVerification = Config.Bind("Signing", "Disable Signature Verification", true, "Force RSA signature verification to succeed (ON by default), so the client stops checking that images are signed with Rec Room's private key. This is what lets a self-hosted server serve its own images without the baked-in modulus matching. NOTE: this forces ALL mscorlib RSA verification to pass, not just image signatures — that breadth is deliberate, see CLAUDE.md.");
DisableAmplitudeAnalytics = Config.Bind("Analytics", "Disable Amplitude Analytics", true, "Drop every AmplitudeAnalyticsClient.Log* call (ON by default) — LogEventAsync, LogPrevSessionEventAsync, LogSerializedEventAsync, LogIdentifyAsync and LogOutOfSessionEvent — so the client never ships telemetry events to Amplitude. A self-hosted server has no use for them. Set false to let analytics through.");
Harmony.CreateAndPatchAll(typeof(Plugin).Assembly);