From 975753deac082371c77de9c7277effbe2b752ebf Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:29:32 +0300 Subject: [PATCH] feat: DorkNet.Quest, a sideload patcher for the 2023 Quest client Patches the Quest build of Rec Room 2023.03.21 (20230317 / 2128210, the same appVersion as the PC client) to connect to a DorkNet server, using one native hook library loaded via DT_NEEDED on libmain.so. - host rewrite onto . or a {service}-dev.example.com template - Photon app ids and region injection (PhotonRegion must match PC) - TLS / pinning / image-signature bypass, integrity callbacks neutralised - no-Meta-account build; the server treats platform id 0/1/blank as a placeholder and keys those accounts by device id - build.ps1 -Obb packages a DorkNet-.xapk with one-command installers for Windows and macOS/Linux; app is named via AppName - README, docs/quest.md and NOTICE updated; credits RecFlare and And64InlineHook (MIT) Tested on a Quest 2: all hooks arm, requests reach the dev server. --- DorkNet.Quest/.gitattributes | 2 + DorkNet.Quest/.gitignore | 9 + DorkNet.Quest/README.md | 215 ++++++ DorkNet.Quest/dorknet-quest.cfg.example | 74 +++ DorkNet.Quest/scripts/build.ps1 | 248 +++++++ DorkNet.Quest/scripts/gen_config_header.py | 78 +++ DorkNet.Quest/scripts/gen_targets_header.py | 56 ++ DorkNet.Quest/scripts/inject_needed.py | 43 ++ DorkNet.Quest/scripts/installer/install.cmd | 5 + DorkNet.Quest/scripts/installer/install.ps1 | 145 +++++ DorkNet.Quest/scripts/installer/install.sh | 96 +++ DorkNet.Quest/scripts/package.ps1 | 170 +++++ DorkNet.Quest/scripts/resolve_targets.py | 174 +++++ DorkNet.Quest/src/config.cpp | Bin 0 -> 4059 bytes DorkNet.Quest/src/config.h | 88 +++ DorkNet.Quest/src/dorknet_quest.cpp | 613 ++++++++++++++++++ .../And64InlineHook/And64InlineHook.cpp | 596 +++++++++++++++++ .../And64InlineHook/And64InlineHook.hpp | 42 ++ .../src/third_party/And64InlineHook/LICENSE | 21 + DorkNet.Quest/targets/20230317-2128210.json | 116 ++++ DorkNet.Quest/targets/spec.json | 119 ++++ .../QuestPlaceholderPlatformIdTests.cs | 133 ++++ DorkNet.Server/Services/PlayerService.cs | 52 +- NOTICE | 18 + README.md | 48 +- docs/architecture.md | 8 + docs/deploy.md | 12 + docs/quest.md | 76 +++ 28 files changed, 3237 insertions(+), 20 deletions(-) create mode 100644 DorkNet.Quest/.gitattributes create mode 100644 DorkNet.Quest/.gitignore create mode 100644 DorkNet.Quest/README.md create mode 100644 DorkNet.Quest/dorknet-quest.cfg.example create mode 100644 DorkNet.Quest/scripts/build.ps1 create mode 100644 DorkNet.Quest/scripts/gen_config_header.py create mode 100644 DorkNet.Quest/scripts/gen_targets_header.py create mode 100644 DorkNet.Quest/scripts/inject_needed.py create mode 100644 DorkNet.Quest/scripts/installer/install.cmd create mode 100644 DorkNet.Quest/scripts/installer/install.ps1 create mode 100644 DorkNet.Quest/scripts/installer/install.sh create mode 100644 DorkNet.Quest/scripts/package.ps1 create mode 100644 DorkNet.Quest/scripts/resolve_targets.py create mode 100644 DorkNet.Quest/src/config.cpp create mode 100644 DorkNet.Quest/src/config.h create mode 100644 DorkNet.Quest/src/dorknet_quest.cpp create mode 100644 DorkNet.Quest/src/third_party/And64InlineHook/And64InlineHook.cpp create mode 100644 DorkNet.Quest/src/third_party/And64InlineHook/And64InlineHook.hpp create mode 100644 DorkNet.Quest/src/third_party/And64InlineHook/LICENSE create mode 100644 DorkNet.Quest/targets/20230317-2128210.json create mode 100644 DorkNet.Quest/targets/spec.json create mode 100644 DorkNet.Server.Tests/QuestPlaceholderPlatformIdTests.cs create mode 100644 docs/quest.md diff --git a/DorkNet.Quest/.gitattributes b/DorkNet.Quest/.gitattributes new file mode 100644 index 0000000..1a9fbcc --- /dev/null +++ b/DorkNet.Quest/.gitattributes @@ -0,0 +1,2 @@ +*.sh text eol=lf +*.cmd text eol=crlf diff --git a/DorkNet.Quest/.gitignore b/DorkNet.Quest/.gitignore new file mode 100644 index 0000000..6a47cbe --- /dev/null +++ b/DorkNet.Quest/.gitignore @@ -0,0 +1,9 @@ +# Operator secrets and build outputs — never commit. +dorknet-quest.cfg +*.keystore +*.so +build/ +*_generated.h + +# The resolved per-build target tables ARE committed (targets/-.json); +# only the generated C header is ignored. diff --git a/DorkNet.Quest/README.md b/DorkNet.Quest/README.md new file mode 100644 index 0000000..86f7dd4 --- /dev/null +++ b/DorkNet.Quest/README.md @@ -0,0 +1,215 @@ +# DorkNet.Quest + +Sideload-patches the **Meta Quest** build of Rec Room **2023.03.21** +(versionName `20230317`, versionCode `2128210`) to connect to a DorkNet server. + +It is the Quest counterpart of `DorkNet.ClientMod` (the PC MelonLoader mod) plus +`tools/patch-client.ps1` (the PC Photon byte-patch). Android/IL2CPP has no +MelonLoader, so everything is done in **one injected native library** +(`libdorknet.so`): it is added as a `DT_NEEDED` entry on `libmain.so` so the +loader maps it before Unity, then it installs arm64 inline hooks at addresses +resolved from the build's own `dump.cs`. + +This build reports appVersion **`20230317`** — the *same* version the PC client +reports — so a Quest player patched with this tool shares rooms, presence and +Photon shards with PC DorkNet players with no server-side version change. + +> This ships **no** Rec Room code or assets. You supply your own extracted store +> APK and OBB. It disables the client's certificate pinning and image-signature +> checks so a self-hosted server works; do not redistribute game binaries. + +## What it does + +| Hook | Effect | PC equivalent | +| --- | --- | --- | +| `HTTPManager.SendRequest` | rewrite `.rec.net` → `.`, or into a `{service}` template such as `{service}-dev.example.com` for hyphen-style hosts (the name server supplies every other URL) | `ClientMod` UriPatches | +| Photon `AppSettings` factory | inject the operator's Realtime/Voice/Chat app ids and fixed region (`PhotonRegion`, must match the PC clients) | `tools/patch-client.ps1` | +| `PlatformSettings.get_AppID` / `get_MobileAppID` | return the operator's Meta app id (only when set) | — | +| `UnityTlsContext.VerifyCallback` + BouncyCastle `NotifyServerCertificate` | accept the self-hosted TLS chain | `ClientMod` TlsPatches | +| three certificate-pin verifiers | accept the chain (pins never match a self-hosted host) | `ClientMod` DisableTLSPinning | +| image RSA/SHA1 verify | accept Content-Signatures the server signs with its own key | PC ImageSigning | +| hile-warning / unknown-DLL / file-hash / ToxMod callbacks | neutralised so a re-signed, hook-loaded client is not warned/quit | `ClientMod` AntiTamper/ToxMod/FileHash | +| integrity report + quit funnels | **log only** — names any new rejection in logcat | — | +| Oculus `ovr_*` platform calls | (no-Meta build only) steer the entitlement/login path to the client's own credential screen | — | + +### Two builds, selected by `OculusAppId` + +- **Set** — the *Meta build*: Meta's real entitlement + login run against the + app you registered; the platform hooks stay disarmed. Players need a Meta + account, and your package + signing cert must be registered with that app. +- **Blank** — the *no-Meta-account build*: the platform path is steered past + Meta's checks to Rec Room's own username/password screen. It authenticates + nothing — every headset sends the placeholder platform user id `1`. DorkNet's + server treats platform id `1` (and `0`/blank) as a non-identity and keys + accounts by `deviceId` instead (`PlayerService.IsPlaceholderPlatformId`), so + players are **not** merged into one account and are never offered each other's + "remembered logins". **Never** add a server rule that accepts platform id `1` + as an identity. + +## Prerequisites + +- **Android NDK** r27c-class (`ANDROID_NDK`, or `ndk/` under + `ANDROID_SDK_ROOT`) — compiles the hook library against bionic. +- **Android SDK build-tools** (`zipalign`, `apksigner`) — `ANDROID_SDK_ROOT`. +- **apktool** `apktool.jar` (`APKTOOL_JAR`, or next to `ANDROID_SDK_ROOT`). +- **JDK** (`keytool` for the debug keystore; apksigner needs a JRE). +- **Python 3** with **LIEF** (`pip install lief`) — the `DT_NEEDED` injection. +- **[Il2CppDumper](https://github.com/Perfare/Il2CppDumper)** — to produce + `dump.cs` for the target APK (see below). +- Your own **store APK** (`com.AgainstGravity.RecRoom`, versionCode `2128210`) + and its matching **OBB**. + +> On Windows with Controlled Folder Access on, the NDK linker and apksigner +> can't write under `Documents`. `build.ps1` builds in `%TEMP%` by default; +> keep the work dir outside protected folders. + +## Build + +```powershell +# 1. Dump the target build (Il2CppDumper), once, into a scratch dir: +dotnet Il2CppDumper.dll ` + \lib\arm64-v8a\libil2cpp.so ` + \assets\bin\Data\Managed\Metadata\global-metadata.dat ` + \dump + +# 2. Configure: +copy DorkNet.Quest\dorknet-quest.cfg.example DorkNet.Quest\dorknet-quest.cfg +# edit ServerHost, Photon app ids, (optional) OculusAppId, PackageName + +# 3. Build the signed APK: +$env:ANDROID_SDK_ROOT = 'C:\path\to\android-sdk' +$env:ANDROID_NDK = 'C:\path\to\android-sdk\ndk\27.2.12479018' +$env:APKTOOL_JAR = 'C:\path\to\apktool.jar' +DorkNet.Quest\scripts\build.ps1 ` + -Apk C:\path\to\RecRoom-store-2128210.apk ` + -DumpCs \dump\dump.cs ` + -Obb C:\path o\main.2128210.com.AgainstGravity.RecRoom.obb +# -> %TEMP%\dorknet-quest-build\dorknet-recroom-20230317.apk +# -> %TEMP%\dorknet-quest-build\DorkNet-Quest-20230317\ (with -Obb) +``` + +With `-Obb`, the build also runs `scripts/package.ps1`, which writes a release +folder ready to hand to players: + +| File | Purpose | +| --- | --- | +| `DorkNet-20230317.xapk` | standard XAPK: `manifest.json`, the APK, the OBB at its install path, `icon.png` | +| `install.cmd` / `install.ps1` | one-command install on Windows (double-click `install.cmd`) | +| `install.sh` | one-command install on macOS / Linux | +| `README.txt` | player instructions | + +To package an APK you already built, run `scripts/package.ps1 -Apk -Obb `. +The package contains game binaries from your store copy, so only share it with +people who own the game. + +`build.ps1` chains: resolve targets → generate headers → compile +`libdorknet.so` → apktool decode → package rename + LAUNCHER + manifest → +app name (`AppName`) → drop the library in + `DT_NEEDED` it onto `libmain.so` → apktool build → +zipalign → apksigner (v2+v3). It generates a fresh debug keystore in the work +dir on first run. **Keep that keystore** — Meta ties tokens to app id + package ++ signing cert, so re-signing with a different key breaks any Meta registration. +It is never committed. + +### Rebasing onto a different build + +Obfuscated Assembly-CSharp names are re-randomised each release but shared with +the PC build of the same release, so the PC decompile is the oracle. For a new +build: + +1. Dump it with Il2CppDumper. +2. Re-run `scripts/resolve_targets.py --dump dump.cs --il2cpp libil2cpp.so + --version-name --version-code --objdump /llvm-objdump` and + confirm every target resolves, is ≥ 20 bytes, and has a real frame-setup + prologue. Adjust `targets/spec.json` signatures if a name/shape changed + (cross-check against the PC decompile first). +3. Commit the new `targets/-.json`. + +The library refuses to arm any target whose 20-byte fingerprint does not match +the running `libil2cpp.so`, so a table can never be applied to the wrong build. + +## Install + +From a release folder, one command does everything (finds adb and the headset, +installs the APK, copies the OBB to the right path, skips the OBB if it is +already there, and launches the app): + +```powershell +.\install.cmd # Windows (or double-click it) +./install.sh # macOS / Linux +``` + +Both take an optional package path, `-Serial`/`--serial` for a specific headset +and `-NoLaunch`/`--no-launch`. SideQuest and other XAPK installers can open the +`.xapk` directly. The app shows up as `AppName` (default **DorkNet**) under +Library > Unknown Sources. + +By hand: + +```sh +PKG=net.dorknet.RecRoom # your PackageName +VC=2128210 +adb uninstall $PKG 2>/dev/null +adb install -r dorknet-recroom-20230317.apk +adb shell mkdir -p /sdcard/Android/obb/$PKG +adb push main.2128210.com.AgainstGravity.RecRoom.obb \ + /sdcard/Android/obb/$PKG/main.$VC.$PKG.obb +``` + +The on-device OBB filename **must** be `main...obb` — +Unity rebuilds that exact path at runtime, so the source file's own name is +irrelevant, only the destination is. + +## Check it on the headset + +**Wear the headset** — off-head it sleeps within seconds and never finishes +init, which looks like a crash. + +```sh +adb logcat -c +adb shell am start -n net.dorknet.RecRoom/com.unity3d.player.UnityPlayerActivity +adb logcat -d -s DORKNET +``` + +A healthy boot logs: `DorkNet.Quest hooks loaded`, `init: build 20230317/...`, +every `armed `, then `intercepted https://ns.rec.net/ -> https://ns./`, +`unitytls verify: forcing 0x8 -> SUCCESS`, `[http<-] 200 ...`, and +`photon appsettings: RT=... Voice=... Chat=...`. + +| Symptom | Likely cause | +| --- | --- | +| `REFUSING : ... fingerprint mismatch` | wrong APK for this target table — rebuild from the matching store APK, or rebase | +| install fails `-124` / `NO_CERTIFICATES` | signing/align step — rebuild with `build.ps1`, don't hand-zip | +| boots to an empty world | OBB missing or named for the wrong versionCode | +| `[integrity] report reason=... NetworkCertificateMismatch` then a quit | TLS bypass off, or a pin hook didn't arm | +| `[http<-]` shows a Cloudflare challenge page | edge WAF blocked the request before your origin | +| multiplayer never connects | Photon app ids not set in the cfg | +| Quest and PC players never see each other's rooms | `PhotonRegion` differs from the PC ClientMod `PhotonCloudRegion` | + +## Layout + +| Path | Purpose | +| --- | --- | +| `src/dorknet_quest.cpp` | all hooks + init | +| `src/config.h` / `config.cpp` | compile-time defaults + runtime cfg loader | +| `src/third_party/And64InlineHook/` | vendored arm64 inline hooker (MIT, © Rprop) | +| `targets/spec.json` | target signatures (class header + method), build-independent | +| `targets/-.json` | resolved RVAs + fingerprints for one build | +| `scripts/resolve_targets.py` | dump.cs + libil2cpp.so → target table (+ prologue check) | +| `scripts/gen_targets_header.py` | target table → `targets_generated.h` | +| `scripts/gen_config_header.py` | `dorknet-quest.cfg` → `config_generated.h` | +| `scripts/inject_needed.py` | LIEF `DT_NEEDED` injection | +| `scripts/build.ps1` | one-shot: store APK → signed patched APK (+ release package with `-Obb`) | +| `scripts/package.ps1` | patched APK + OBB → `.xapk` + installers | +| `scripts/installer/` | `install.ps1`, `install.cmd`, `install.sh` copied into each package | +| `dorknet-quest.cfg.example` | config template; copy to `dorknet-quest.cfg` (git-ignored) | + +## Credits + +- [RecFlare](https://github.com/recflare) by djdevin. Their Quest client + showed the way this patcher is built: one native hook library loaded + through a `DT_NEEDED` entry on `libmain.so`, and which client checks need + hooking. They target a newer build (20230414), so every hook target here + was re-derived from the 20230317 build; no RecFlare code is included. +- [And64InlineHook](https://github.com/Rprop/And64InlineHook) © Rprop (MIT) — + vendored under `src/third_party/`. +- [Il2CppDumper](https://github.com/Perfare/Il2CppDumper), [LIEF](https://lief.re). diff --git a/DorkNet.Quest/dorknet-quest.cfg.example b/DorkNet.Quest/dorknet-quest.cfg.example new file mode 100644 index 0000000..683e91d --- /dev/null +++ b/DorkNet.Quest/dorknet-quest.cfg.example @@ -0,0 +1,74 @@ +# DorkNet.Quest build configuration. +# +# Copy to dorknet-quest.cfg (git-ignored) and edit. scripts/build.ps1 bakes +# these into libdorknet.so at compile time. Omit a key to keep the built-in +# default. This same file can be pushed to +# /sdcard/Android/data//files/dorknet-quest.cfg +# to override on-device without a rebuild. + +# ---- deployment ---- + +# The apex your DorkNet server is reachable at. The client's service hosts are +# rewritten onto it: api.rec.net -> api., auth.rec.net -> +# auth., ns.rec.net -> ns., and so on. The ns response +# then supplies every other service URL. Your DNS must resolve each subdomain +# (ns, api, auth, match, chat, cdn, config, ...) and the TLS cert must cover +# them. No default -- required. +# +# For a server on DorkNet's hyphen host style (Domain:HostStyle = hyphen, e.g. +# ns-dev.example.com), give a template instead: ServerHost = {service}-dev.example.com +ServerHost = example.com + +# Android package name for the patched build, so it coexists with the store +# app and uses its own OBB path. Keep this stable: Meta ties platform tokens to +# package + signing cert, and the runtime-config path derives from it. +PackageName = net.dorknet.RecRoom + +# Name shown in the headset library (Unknown Sources). Build-time only. +AppName = DorkNet + +# ---- Meta (Oculus) platform ---- + +# Your registered Meta app id. Set it for the Meta build: real entitlement and +# Meta login run against an app your players own. Leave BLANK for the +# no-Meta-account build -- the platform entitlement/login path is steered to the +# client's own username/password screen. Blank sends platform id 1 from every +# headset; DorkNet's server treats that as a placeholder and keys accounts by +# device id (see PlayerService.IsPlaceholderPlatformId), so players are NOT +# merged. Never add a server rule that accepts platform id 1 as an identity. +OculusAppId = + +# ---- Photon ---- + +# Your own Photon app ids (dashboard.photonengine.com). Rec Room's originals +# reject external clients. Leave an id blank to keep the client's own (dead) +# value -- multiplayer will not connect until these are set. No defaults. +PhotonAppId = +PhotonVoiceAppId = +PhotonChatAppId = + +# Optional self-hosted Photon name server (blank / 0 = Photon Cloud). +PhotonNameServer = +PhotonNameServerPort = 0 + +# Photon Cloud region. Must match the PC clients' PhotonCloudRegion (ClientMod +# config), or Quest and PC players connect to different masters and never meet. +PhotonRegion = us + +# ---- client behaviour ---- + +# Accept the self-hosted TLS chain and skip Rec Room's certificate pinning. +# Required for a self-hosted server; turn off only if you front it with a cert +# chain the client's bundled CA set trusts AND you reproduce Rec Room's pins. +EnableTlsTrustBypass = true + +# Accept image Content-Signatures the server signs with its own key. +AcceptUnsignedImages = true + +# ---- diagnostics (adb logcat -s DORKNET) ---- + +LogHttp = true +# WARNING: Debug logs request/response BODIES, including auth tokens and +# passwords. Leave off except when debugging, and don't share the logs. +Debug = false +LogHttpBodyMax = 10000 diff --git a/DorkNet.Quest/scripts/build.ps1 b/DorkNet.Quest/scripts/build.ps1 new file mode 100644 index 0000000..671b10f --- /dev/null +++ b/DorkNet.Quest/scripts/build.ps1 @@ -0,0 +1,248 @@ +<# +.SYNOPSIS + Build a DorkNet-patched Rec Room Quest APK from a pristine store APK. + +.DESCRIPTION + End to end: + 1. resolve_targets.py -> per-build target table (RVA + fingerprint) + 2. gen_targets_header.py -> targets_generated.h + 3. gen_config_header.py -> config_generated.h (from your cfg) + 4. NDK clang++ -> libdorknet.so (arm64) + 5. apktool decode -> working project + 6. package rename + manifest/version edits + 7. drop libdorknet.so in, DT_NEEDED it into libmain.so (LIEF) + 8. apktool build -> zipalign -> apksigner -> the signed output APK + + Nothing game-derived is written into the repo. The work directory defaults to + a temp path because Windows Controlled Folder Access blocks the NDK linker and + apksigner from writing under Documents; override with -WorkDir if needed. + + You supply the store APK and (for install) the matching OBB. No game binaries + ship with this tool. + +.PARAMETER Apk + Path to the pristine store APK (com.AgainstGravity.RecRoom, versionCode + 2128210 / version 20230317). + +.PARAMETER Config + dorknet-quest.cfg with your ServerHost / Photon ids / package name. Defaults + to dorknet-quest.cfg next to the repo folder; falls back to the .example. + +.PARAMETER WorkDir + Scratch build directory (must be outside Controlled-Folder-Access-protected + locations). Defaults to $env:TEMP\dorknet-quest-build. + +.PARAMETER OutApk + Where to write the finished APK. Default: \dorknet-recroom-.apk. + +.PARAMETER NdkRoot + Android NDK root. Default: $env:ANDROID_NDK, else the NDK under $env:ANDROID_SDK_ROOT\ndk. + +.PARAMETER BuildToolsDir + Android SDK build-tools dir holding zipalign + apksigner. Default: resolved + from $env:ANDROID_SDK_ROOT. + +.PARAMETER Obb + The store OBB matching the APK. When given, package.ps1 also bundles the + result into an installable .xapk with one-command installers. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$Apk, + [string]$Config, + [string]$WorkDir = (Join-Path $env:TEMP 'dorknet-quest-build'), + [string]$OutApk, + [string]$NdkRoot, + [string]$BuildToolsDir, + [string]$ApktoolJar, + [string]$DumpCs, + [string]$ApiLevel = '24', + [string]$Obb +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$RepoDir = Split-Path -Parent $PSScriptRoot +$Scripts = $PSScriptRoot +$OldPkg = 'com.AgainstGravity.RecRoom' +$SoName = 'libdorknet.so' + +function Step($m) { Write-Host "==> $m" -ForegroundColor Cyan } +function Need($path, $what) { if (-not (Test-Path -LiteralPath $path)) { throw "$what not found: $path" } } + +# ---- resolve tools ------------------------------------------------------ +if (-not $NdkRoot) { + $NdkRoot = $env:ANDROID_NDK + if (-not $NdkRoot -and $env:ANDROID_SDK_ROOT) { + $cand = Join-Path $env:ANDROID_SDK_ROOT 'ndk' + if (Test-Path $cand) { $NdkRoot = (Get-ChildItem $cand -Directory | Sort-Object Name -Descending | Select-Object -First 1).FullName } + } +} +if (-not $NdkRoot) { throw "Android NDK not found. Pass -NdkRoot or set ANDROID_NDK / ANDROID_SDK_ROOT." } +$Cxx = Join-Path $NdkRoot "toolchains\llvm\prebuilt\windows-x86_64\bin\aarch64-linux-android$ApiLevel-clang++.cmd" +$ObjDump = Join-Path $NdkRoot 'toolchains\llvm\prebuilt\windows-x86_64\bin\llvm-objdump.exe' +Need $Cxx 'NDK aarch64 clang++' + +if (-not $BuildToolsDir) { + if (-not $env:ANDROID_SDK_ROOT) { throw "Pass -BuildToolsDir or set ANDROID_SDK_ROOT." } + $bt = Join-Path $env:ANDROID_SDK_ROOT 'build-tools' + $BuildToolsDir = (Get-ChildItem $bt -Directory | Sort-Object Name -Descending | Select-Object -First 1).FullName +} +$ZipAlign = Join-Path $BuildToolsDir 'zipalign.exe' +$ApkSigner = Join-Path $BuildToolsDir 'apksigner.bat' +Need $ZipAlign 'zipalign' +Need $ApkSigner 'apksigner' + +if (-not $ApktoolJar) { + $ApktoolJar = if ($env:APKTOOL_JAR) { $env:APKTOOL_JAR } elseif ($env:ANDROID_SDK_ROOT) { Join-Path $env:ANDROID_SDK_ROOT 'apktool.jar' } else { '' } +} +Need $ApktoolJar 'apktool.jar' +Need $Apk 'store APK' + +if (-not $Config) { + $c = Join-Path $RepoDir 'dorknet-quest.cfg' + $Config = if (Test-Path $c) { $c } else { Join-Path $RepoDir 'dorknet-quest.cfg.example' } +} +Need $Config 'config' +Write-Host "Using config: $Config" + +$PkgName = ((Get-Content $Config) | Where-Object { $_ -match '^\s*PackageName\s*=' } | Select-Object -First 1) +$PkgName = if ($PkgName) { ($PkgName -split '=', 2)[1].Trim() } else { 'net.dorknet.RecRoom' } +$AppName = ((Get-Content $Config) | Where-Object { $_ -match '^\s*AppName\s*=' } | Select-Object -First 1) +$AppName = if ($AppName) { ($AppName -split '=', 2)[1].Split('#')[0].Trim() } else { '' } +if (-not $AppName) { $AppName = 'DorkNet' } + +# Resolve the launcher to its FULL exe path -- "& py ..." would otherwise match +# the (case-insensitive) function name and recurse. Prefer py -3.14, else python. +$PyCmd = (Get-Command py.exe -ErrorAction SilentlyContinue) +if ($PyCmd) { $PyExe = $PyCmd.Source; $PyPre = @('-3.14', '-I') } +else { $PyExe = (Get-Command python.exe -ErrorAction Stop).Source; $PyPre = @('-I') } +function Invoke-Py { param([Parameter(ValueFromRemainingArguments = $true)][string[]]$a) + $pa = $PyPre + $a + & $PyExe @pa + if ($LASTEXITCODE) { throw "python failed: $($a -join ' ')" } } + +# ---- work dirs ---------------------------------------------------------- +New-Item -ItemType Directory -Force -Path $WorkDir | Out-Null +$Gen = Join-Path $WorkDir 'gen' +$Project = Join-Path $WorkDir 'project' +$Extract = Join-Path $WorkDir 'extract' +New-Item -ItemType Directory -Force -Path $Gen, $Extract | Out-Null + +# ---- 1. extract + dump + resolve targets -------------------------------- +Step 'extracting libil2cpp.so + global-metadata.dat' +Add-Type -AssemblyName System.IO.Compression.FileSystem +$zip = [System.IO.Compression.ZipFile]::OpenRead((Resolve-Path $Apk)) +try { + foreach ($e in @('lib/arm64-v8a/libil2cpp.so', 'assets/bin/Data/Managed/Metadata/global-metadata.dat', 'lib/arm64-v8a/libmain.so')) { + $entry = $zip.GetEntry($e); if (-not $entry) { throw "APK missing $e" } + $dest = Join-Path $Extract (Split-Path $e -Leaf) + [System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $dest, $true) + } +} finally { $zip.Dispose() } +$Il2Cpp = Join-Path $Extract 'libil2cpp.so' +$LibMain = Join-Path $Extract 'libmain.so' + +if (-not $DumpCs) { + # Prefer a dump.cs the operator points at; otherwise require one. Dumping + # is left to Il2CppDumper (see README) to keep this script toolchain-light. + throw "Pass -DumpCs from Il2CppDumper for this APK (see DorkNet.Quest/README.md)." +} +Need $DumpCs 'dump.cs' + +Step 'resolving hook targets' +$verName = '20230317'; $verCode = '2128210' +# Write the resolved table into the work dir (Controlled Folder Access can +# block writes under Documents). The repo keeps a committed copy under targets/. +$table = Join-Path $WorkDir "$verName-$verCode.json" +Invoke-Py @((Join-Path $Scripts 'resolve_targets.py'), '--dump', $DumpCs, '--il2cpp', $Il2Cpp, + '--version-name', $verName, '--version-code', $verCode, '--objdump', $ObjDump, '--out', $table) + +Step 'generating headers' +Invoke-Py @((Join-Path $Scripts 'gen_targets_header.py'), $table, (Join-Path $Gen 'targets_generated.h')) +Invoke-Py @((Join-Path $Scripts 'gen_config_header.py'), $Config, $PkgName, (Join-Path $Gen 'config_generated.h')) + +# ---- 4. compile libdorknet.so ------------------------------------------- +Step 'compiling libdorknet.so (arm64)' +$src = @( + (Join-Path $RepoDir 'src\dorknet_quest.cpp'), + (Join-Path $RepoDir 'src\config.cpp'), + (Join-Path $RepoDir 'src\third_party\And64InlineHook\And64InlineHook.cpp') +) +$So = Join-Path $WorkDir $SoName +& $Cxx '-std=c++17' '-fPIC' '-shared' '-O2' '-fvisibility=hidden' '-static-libstdc++' ` + '-ffunction-sections' '-fdata-sections' '-Wl,--gc-sections' ` + "-I$Gen" "-I$(Join-Path $RepoDir 'src')" $src '-llog' '-o' $So +if ($LASTEXITCODE) { throw 'compile failed' } +Need $So 'libdorknet.so' + +# ---- 5. apktool decode -------------------------------------------------- +Step 'apktool decode' +if (Test-Path $Project) { Remove-Item -Recurse -Force $Project } +& java -jar $ApktoolJar decode -f -o $Project (Resolve-Path $Apk) +if ($LASTEXITCODE) { throw 'apktool decode failed' } + +# ---- 6. package rename + manifest ------------------------------------- +Step "renaming package $OldPkg -> $PkgName" +$manifest = Join-Path $Project 'AndroidManifest.xml' +$m = Get-Content -Raw $manifest +$m = $m.Replace($OldPkg, $PkgName) +if ($m -notmatch 'android.intent.category.LAUNCHER') { + $m = $m.Replace('', + '') +} +Set-Content -NoNewline -Path $manifest -Value $m + +# The launcher label comes from @string/app_name; set it in every locale. +Step "naming the app '$AppName'" +$labelXml = [System.Security.SecurityElement]::Escape($AppName).Replace("'", "\'") +foreach ($sx in Get-ChildItem (Join-Path $Project 'res') -Filter strings.xml -Recurse) { + $s = Get-Content -Raw -Encoding utf8 $sx.FullName + $n = [regex]::Replace($s, '[^<]*', "$labelXml") + if ($n -ne $s) { Set-Content -NoNewline -Encoding utf8 -Path $sx.FullName -Value $n } +} + +# ---- 7. inject the hook library ---------------------------------------- +Step 'injecting libdorknet.so' +Copy-Item $So (Join-Path $Project 'lib\arm64-v8a' $SoName) -Force +$projMain = Join-Path $Project 'lib\arm64-v8a\libmain.so' +Invoke-Py @((Join-Path $Scripts 'inject_needed.py'), $projMain, $SoName) + +# ---- 8. rebuild + align + sign ----------------------------------------- +Step 'apktool build' +$unsigned = Join-Path $WorkDir 'unsigned.apk' +& java -jar $ApktoolJar build -f -o $unsigned $Project +if ($LASTEXITCODE) { throw 'apktool build failed' } + +$Keystore = Join-Path $WorkDir 'dorknet-debug.keystore' +if (-not (Test-Path $Keystore)) { + Step 'generating a fresh debug keystore (kept in the work dir, never committed)' + & keytool -genkeypair -v -keystore $Keystore -alias dorknet -keyalg RSA -keysize 2048 ` + -validity 10000 -storepass dorknet -keypass dorknet -dname 'CN=DorkNet Quest, O=DorkNet' + if ($LASTEXITCODE) { throw 'keytool failed' } +} + +Step 'zipalign' +$aligned = Join-Path $WorkDir 'aligned.apk' +& $ZipAlign -f -p 4 $unsigned $aligned +if ($LASTEXITCODE) { throw 'zipalign failed' } + +if (-not $OutApk) { $OutApk = Join-Path $WorkDir "dorknet-recroom-$verName.apk" } +Step "apksigner -> $OutApk" +& $ApkSigner sign --ks $Keystore --ks-key-alias dorknet --ks-pass pass:dorknet --key-pass pass:dorknet ` + --v2-signing-enabled true --v3-signing-enabled true --out $OutApk $aligned +if ($LASTEXITCODE) { throw 'apksigner failed' } +& $ApkSigner verify --print-certs $OutApk | Out-Null + +Write-Host "" +Write-Host "DONE. Patched APK: $OutApk" -ForegroundColor Green +Write-Host "Package: $PkgName versionCode: $verCode" -ForegroundColor Green +if ($Obb) { + Write-Host "" + & (Join-Path $Scripts 'package.ps1') -Apk $OutApk -Obb $Obb -BuildToolsDir $BuildToolsDir +} else { + Write-Host "Install it, then push the matching OBB to:" -ForegroundColor Green + Write-Host " /sdcard/Android/obb/$PkgName/main.$verCode.$PkgName.obb" -ForegroundColor Green + Write-Host "(or pass -Obb to bundle both into an installable .xapk)" -ForegroundColor Green +} diff --git a/DorkNet.Quest/scripts/gen_config_header.py b/DorkNet.Quest/scripts/gen_config_header.py new file mode 100644 index 0000000..c121e80 --- /dev/null +++ b/DorkNet.Quest/scripts/gen_config_header.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Generate config_generated.h from dorknet-quest.cfg. + +Bakes the operator's config into the library at compile time. Keys absent from +the cfg are simply not emitted, so the built-in defaults in config.h apply. +Secrets (Photon ids, Meta app id) live only in the git-ignored cfg, never in a +committed file. + +Usage: gen_config_header.py +""" +import sys +from pathlib import Path + +# cfg key -> (macro, kind) where kind is "str", "bool" or "int". +KEYS = { + "ServerHost": ("DN_SERVER_HOST", "str"), + "OculusAppId": ("DN_OCULUS_APPID", "str"), + "PhotonAppId": ("DN_PHOTON_REALTIME", "str"), + "PhotonVoiceAppId": ("DN_PHOTON_VOICE", "str"), + "PhotonChatAppId": ("DN_PHOTON_CHAT", "str"), + "PhotonNameServer": ("DN_PHOTON_SERVER", "str"), + "PhotonNameServerPort": ("DN_PHOTON_PORT", "int"), + "PhotonRegion": ("DN_PHOTON_REGION", "str"), + "EnableTlsTrustBypass": ("DN_TLS_TRUST_BYPASS", "bool"), + "AcceptUnsignedImages": ("DN_ACCEPT_UNSIGNED_IMAGES", "bool"), + "LogHttp": ("DN_LOG_HTTP", "bool"), + "Debug": ("DN_DEBUG_HTTP", "bool"), + "LogHttpBodyMax": ("DN_LOG_BODY_MAX", "int"), +} +TRUE = {"true", "1", "yes", "on"} +FALSE = {"false", "0", "no", "off"} + + +def c_string(v): + return '"' + v.replace("\\", "\\\\").replace('"', '\\"') + '"' + + +def main(): + if len(sys.argv) != 4: + raise SystemExit("usage: gen_config_header.py ") + cfg, package, out = Path(sys.argv[1]), sys.argv[2], Path(sys.argv[3]) + lines = ["// Generated by scripts/gen_config_header.py -- do not edit, do not commit.", + "#ifndef DORKNET_QUEST_CONFIG_GENERATED_H", + "#define DORKNET_QUEST_CONFIG_GENERATED_H", + f"#define DN_PACKAGE {c_string(package)}", ""] + + if cfg.exists(): + for raw in cfg.read_text(encoding="utf-8").splitlines(): + line = raw.split("#", 1)[0].strip() + if not line or "=" not in line or line.startswith("["): + continue + key, _, val = line.partition("=") + key, val = key.strip(), val.strip() + if key not in KEYS or val == "": + continue + macro, kind = KEYS[key] + if kind == "str": + lines.append(f"#define {macro} {c_string(val)}") + elif kind == "int": + lines.append(f"#define {macro} {int(val)}") + elif kind == "bool": + low = val.lower() + if low in TRUE: + lines.append(f"#define {macro} true") + elif low in FALSE: + lines.append(f"#define {macro} false") + else: + raise SystemExit(f"{key}: not a boolean: {val}") + else: + print(f"note: {cfg} not found; baking package only, all values default", file=sys.stderr) + + lines += ["", "#endif"] + out.write_text("\n".join(lines) + "\n", encoding="utf-8") + print(f"wrote {out}") + + +if __name__ == "__main__": + main() diff --git a/DorkNet.Quest/scripts/gen_targets_header.py b/DorkNet.Quest/scripts/gen_targets_header.py new file mode 100644 index 0000000..c0ca923 --- /dev/null +++ b/DorkNet.Quest/scripts/gen_targets_header.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Generate targets_generated.h from a resolved target table. + +The header holds each target's RVA, length and 20-byte FNV-1a fingerprint as a +named struct field, plus the build's version. The hook library checks the +fingerprint against the running libil2cpp.so before arming, so a table can only +ever be applied to the build it was resolved from. + +Usage: gen_targets_header.py +""" +import json +import sys +from pathlib import Path + + +def main(): + if len(sys.argv) != 3: + raise SystemExit("usage: gen_targets_header.py ") + table = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) + ids = list(table["targets"].keys()) + + lines = [ + "// Generated by scripts/gen_targets_header.py -- do not edit.", + f"// Build {table['versionName']} / versionCode {table['versionCode']}", + f"// libil2cpp.so sha256 {table['il2cppSha256']}", + "#ifndef DORKNET_QUEST_TARGETS_GENERATED_H", + "#define DORKNET_QUEST_TARGETS_GENERATED_H", + "#include ", + "", + f'#define DN_TARGET_VERSION_NAME "{table["versionName"]}"', + f'#define DN_TARGET_VERSION_CODE "{table["versionCode"]}"', + f"#define DN_HOOK_BYTES {table['hookBytes']}", + "", + "typedef struct { const char *id; uint32_t rva; uint64_t fingerprint; uint32_t length; } DnTarget;", + "", + "typedef struct {", + ] + for tid in ids: + lines.append(f" DnTarget {tid};") + lines.append("} DnTargets;") + lines.append("") + lines.append("static const DnTargets dn_targets = {") + for tid in ids: + e = table["targets"][tid] + length = e["length"] if e["length"] is not None else 0 + lines.append( + f' .{tid} = {{ "{tid}", {e["rva"]:#x}u, {e["fingerprint"]}ull, {length}u }},') + lines.append("};") + lines.append("") + lines.append("#endif") + Path(sys.argv[2]).write_text("\n".join(lines) + "\n", encoding="utf-8") + print(f"wrote {sys.argv[2]} ({len(ids)} targets)") + + +if __name__ == "__main__": + main() diff --git a/DorkNet.Quest/scripts/inject_needed.py b/DorkNet.Quest/scripts/inject_needed.py new file mode 100644 index 0000000..abddfb4 --- /dev/null +++ b/DorkNet.Quest/scripts/inject_needed.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""Add a DT_NEEDED entry to libmain.so so the Android loader pulls in +libdorknet.so before Unity starts. + +libmain.so is tiny (the Unity bootstrap shim) and lists libil2cpp etc. as +DT_NEEDED; adding one more makes the loader map our hook library first. This is +the same mechanism RecFlare uses on Quest. Idempotent: a second run is a no-op. + +Usage: inject_needed.py (e.g. libdorknet.so) +""" +import sys + +import lief + + +def main(): + if len(sys.argv) != 3: + raise SystemExit("usage: inject_needed.py ") + path, soname = sys.argv[1], sys.argv[2] + binary = lief.ELF.parse(path) + if binary is None: + raise SystemExit(f"could not parse {path}") + + existing = [e.name for e in binary.dynamic_entries + if e.tag == lief.ELF.DynamicEntry.TAG.NEEDED] + if soname in existing: + print(f"{soname} already a DT_NEEDED of {path}; nothing to do") + return + + binary.add_library(soname) + binary.write(path) + print(f"added DT_NEEDED {soname} to {path}") + + check = lief.ELF.parse(path) + names = [e.name for e in check.dynamic_entries + if e.tag == lief.ELF.DynamicEntry.TAG.NEEDED] + if soname not in names: + raise SystemExit("injection did not stick") + print("verified:", ", ".join(names)) + + +if __name__ == "__main__": + main() diff --git a/DorkNet.Quest/scripts/installer/install.cmd b/DorkNet.Quest/scripts/installer/install.cmd new file mode 100644 index 0000000..5e39b67 --- /dev/null +++ b/DorkNet.Quest/scripts/installer/install.cmd @@ -0,0 +1,5 @@ +@echo off +rem One-click installer: runs install.ps1 next to this file. +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0install.ps1" %* +echo. +pause diff --git a/DorkNet.Quest/scripts/installer/install.ps1 b/DorkNet.Quest/scripts/installer/install.ps1 new file mode 100644 index 0000000..75b50d0 --- /dev/null +++ b/DorkNet.Quest/scripts/installer/install.ps1 @@ -0,0 +1,145 @@ +<# +.SYNOPSIS + Install a DorkNet Rec Room .xapk onto a Meta Quest over adb. + +.DESCRIPTION + Installs the APK, copies the OBB to the path the game expects, then launches + the app. Works in Windows PowerShell 5.1 and PowerShell 7. Run it with no + arguments from the package folder, or double-click install.cmd. + +.PARAMETER Xapk + The package to install. Default: the only .xapk next to this script. + +.PARAMETER Serial + adb serial of the headset when more than one device is connected. + +.PARAMETER NoLaunch + Install only; don't start the app afterwards. +#> +[CmdletBinding()] +param( + [string]$Xapk, + [string]$Serial, + [switch]$NoLaunch +) + +# adb writes progress to stderr; under 'Stop' Windows PowerShell would treat +# that as a failure, so exit codes are checked by hand instead. +$ErrorActionPreference = 'Continue' +Set-StrictMode -Version 2 +Add-Type -AssemblyName System.IO.Compression, System.IO.Compression.FileSystem + +function Say($m) { Write-Host "==> $m" -ForegroundColor Cyan } +function Fail($m) { Write-Host "ERROR: $m" -ForegroundColor Red; exit 1 } + +# ---- find the package ---------------------------------------------------- +if (-not $Xapk) { + $found = @(Get-ChildItem -Path $PSScriptRoot -Filter *.xapk -File) + if ($found.Count -eq 0) { Fail "no .xapk found next to $PSCommandPath; pass -Xapk ." } + if ($found.Count -gt 1) { Fail "more than one .xapk here; pass -Xapk ." } + $Xapk = $found[0].FullName +} +if (-not (Test-Path -LiteralPath $Xapk)) { Fail "package not found: $Xapk" } +$Xapk = (Resolve-Path -LiteralPath $Xapk).Path + +# ---- find adb ----------------------------------------------------------- +$adb = $null +$cmd = Get-Command adb -ErrorAction SilentlyContinue +if ($cmd) { $adb = $cmd.Source } +if (-not $adb) { + $roots = @($env:ANDROID_SDK_ROOT, $env:ANDROID_HOME, (Join-Path $env:LOCALAPPDATA 'Android\Sdk'), $PSScriptRoot) | Where-Object { $_ } + foreach ($r in $roots) { + foreach ($c in @((Join-Path $r 'platform-tools\adb.exe'), (Join-Path $r 'adb.exe'))) { + if (Test-Path $c) { $adb = $c; break } + } + if ($adb) { break } + } +} +if (-not $adb) { + Fail ("adb not found. Install Android platform-tools from " + + "https://developer.android.com/tools/releases/platform-tools and put adb on PATH " + + "(or unzip platform-tools next to this script).") +} + +# ---- pick the headset ---------------------------------------------------- +$lines = & $adb devices | Select-Object -Skip 1 | Where-Object { $_ -match '\S' } +$ready = @($lines | Where-Object { $_ -match "^(\S+)\s+device$" } | ForEach-Object { ($_ -split '\s+')[0] }) +$unauth = @($lines | Where-Object { $_ -match '\sunauthorized$' }) +if ($Serial) { + if ($ready -notcontains $Serial) { Fail "device $Serial is not connected and authorised (adb devices)." } +} elseif ($ready.Count -eq 1) { + $Serial = $ready[0] +} elseif ($ready.Count -gt 1) { + Fail ("more than one device connected: " + ($ready -join ', ') + ". Pass -Serial .") +} elseif ($unauth.Count -gt 0) { + Fail 'the headset is connected but not authorised: put it on and accept "Allow USB debugging".' +} else { + Fail 'no headset found. Enable developer mode, connect it over USB, and accept "Allow USB debugging" in the headset.' +} +function Adb { & $adb -s $Serial @args } +$model = (Adb shell getprop ro.product.model | Out-String).Trim() +Say "headset: $model ($Serial)" + +# ---- read the package ---------------------------------------------------- +$zip = [System.IO.Compression.ZipFile]::OpenRead($Xapk) +$tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("dorknet-install-" + [guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $tmp | Out-Null +try { + $me = $zip.GetEntry('manifest.json') + if (-not $me) { Fail 'not an XAPK: manifest.json missing.' } + $sr = New-Object System.IO.StreamReader($me.Open()) + $manifest = $sr.ReadToEnd() | ConvertFrom-Json + $sr.Dispose() + $pkg = $manifest.package_name + Say "package: $($manifest.name) $pkg $($manifest.version_name) ($($manifest.version_code))" + + function Extract($entry) { + $out = Join-Path $tmp ([System.IO.Path]::GetFileName($entry.FullName)) + [System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $out, $true) + return $out + } + + # ---- APK ------------------------------------------------------------- + $apkEntry = $zip.GetEntry("$pkg.apk") + if (-not $apkEntry) { $apkEntry = @($zip.Entries | Where-Object { $_.FullName -like '*.apk' })[0] } + if (-not $apkEntry) { Fail 'no APK inside the package.' } + Say 'installing the app' + $apkFile = Extract $apkEntry + $res = (Adb install -r $apkFile 2>&1 | Out-String) + if ($res -match 'INSTALL_FAILED_UPDATE_INCOMPATIBLE|signatures do not match') { + Write-Host "An older copy of $pkg is installed with a different signing key." -ForegroundColor Yellow + Write-Host "It has to be removed first. Your account lives on the server, but local settings on the headset are lost." -ForegroundColor Yellow + $ans = Read-Host 'Uninstall it and continue? [y/N]' + if ($ans -notmatch '^(y|yes)$') { Fail 'cancelled.' } + Adb uninstall $pkg | Out-Null + $res = (Adb install -r $apkFile 2>&1 | Out-String) + } + if ($res -notmatch 'Success') { Fail ("app install failed:`n" + $res.Trim()) } + Remove-Item -Force $apkFile + + # ---- OBB (expansion files) ------------------------------------------ + foreach ($x in @($manifest.expansions)) { + if (-not $x) { continue } + $entry = $zip.GetEntry($x.file) + if (-not $entry) { Fail "package is missing $($x.file)." } + $dest = '/sdcard/' + $x.install_path + $have = (Adb shell "stat -c %s '$dest' 2>/dev/null" | Out-String).Trim() + if ($have -eq [string]$entry.Length) { Say "game data already on the headset, skipping ($([System.IO.Path]::GetFileName($dest)))"; continue } + Say ("copying game data ({0:N2} GB) - this takes a few minutes" -f ($entry.Length / 1GB)) + $f = Extract $entry + Adb shell "mkdir -p '$([System.IO.Path]::GetDirectoryName($dest).Replace('\','/'))'" | Out-Null + Adb push $f $dest + if ($LASTEXITCODE) { Fail 'copying the game data failed (is there enough free space on the headset?).' } + Remove-Item -Force $f + } +} finally { + $zip.Dispose() + Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue +} + +Write-Host '' +Write-Host "Installed. In the headset: Library > Unknown Sources > $($manifest.name)." -ForegroundColor Green +if (-not $NoLaunch) { + Say 'launching (put the headset on)' + Adb shell monkey -p $pkg -c android.intent.category.LAUNCHER 1 2>&1 | Out-Null +} diff --git a/DorkNet.Quest/scripts/installer/install.sh b/DorkNet.Quest/scripts/installer/install.sh new file mode 100644 index 0000000..a708bca --- /dev/null +++ b/DorkNet.Quest/scripts/installer/install.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# Install a DorkNet Rec Room .xapk onto a Meta Quest over adb (macOS / Linux). +# +# ./install.sh [package.xapk] [--serial ] [--no-launch] +# +# Needs adb (Android platform-tools) and unzip. +set -euo pipefail + +here="$(cd "$(dirname "$0")" && pwd)" +xapk="" serial="" launch=1 +while [ $# -gt 0 ]; do + case "$1" in + --serial) serial="$2"; shift 2 ;; + --no-launch) launch=0; shift ;; + *) xapk="$1"; shift ;; + esac +done + +say() { printf '\033[36m==> %s\033[0m\n' "$*"; } +fail() { printf '\033[31mERROR: %s\033[0m\n' "$*" >&2; exit 1; } + +if [ -z "$xapk" ]; then + set -- "$here"/*.xapk + [ -e "$1" ] || fail "no .xapk found next to this script; pass it as an argument." + [ $# -eq 1 ] || fail "more than one .xapk here; pass the one to install." + xapk="$1" +fi +[ -f "$xapk" ] || fail "package not found: $xapk" +command -v unzip >/dev/null || fail "unzip is required." + +adb="$(command -v adb || true)" +for c in "${ANDROID_SDK_ROOT:-}/platform-tools/adb" "${ANDROID_HOME:-}/platform-tools/adb" \ + "$HOME/Library/Android/sdk/platform-tools/adb" "$HOME/Android/Sdk/platform-tools/adb" \ + "$here/platform-tools/adb"; do + [ -n "$adb" ] && break + [ -x "$c" ] && adb="$c" +done +[ -n "$adb" ] || fail "adb not found. Install Android platform-tools: https://developer.android.com/tools/releases/platform-tools" + +if [ -z "$serial" ]; then + ready=$("$adb" devices | awk 'NR>1 && $2=="device"{print $1}') + count=$(printf '%s' "$ready" | grep -c . || true) + if [ "$count" -eq 0 ]; then + if "$adb" devices | grep -q unauthorized; then + fail 'the headset is connected but not authorised: put it on and accept "Allow USB debugging".' + fi + fail 'no headset found. Enable developer mode, connect over USB, and accept "Allow USB debugging".' + fi + [ "$count" -eq 1 ] || fail "more than one device connected: $(echo $ready). Pass --serial ." + serial="$ready" +fi +a() { "$adb" -s "$serial" "$@"; } +say "headset: $(a shell getprop ro.product.model | tr -d '\r') ($serial)" + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +manifest="$(unzip -p "$xapk" manifest.json)" || fail "not an XAPK: manifest.json missing." +field() { printf '%s' "$manifest" | grep -o "\"$1\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" | head -1 | sed 's/.*:[[:space:]]*"\(.*\)"/\1/'; } +pkg="$(field package_name)" +[ -n "$pkg" ] || fail "manifest.json has no package_name." +say "package: $(field name) $pkg $(field version_name) ($(field version_code))" + +say "installing the app" +unzip -q -o "$xapk" "$pkg.apk" -d "$tmp" +out="$(a install -r "$tmp/$pkg.apk" 2>&1 || true)" +if printf '%s' "$out" | grep -qE 'INSTALL_FAILED_UPDATE_INCOMPATIBLE|signatures do not match'; then + echo "An older copy of $pkg is installed with a different signing key; it has to be removed first." + echo "Your account lives on the server, but local settings on the headset are lost." + read -r -p "Uninstall it and continue? [y/N] " ans + case "$ans" in y|Y|yes) ;; *) fail "cancelled." ;; esac + a uninstall "$pkg" >/dev/null || true + out="$(a install -r "$tmp/$pkg.apk" 2>&1 || true)" +fi +printf '%s' "$out" | grep -q Success || fail "app install failed: $out" +rm -f "$tmp/$pkg.apk" + +# Expansion files: every "install_path" in the manifest. +printf '%s' "$manifest" | grep -o '"install_path"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*:[[:space:]]*"\(.*\)"/\1/' | +while read -r path; do + dest="/sdcard/$path" + size="$(unzip -l "$xapk" "$path" | awk 'NR==4{print $1}')" + have="$(a shell "stat -c %s '$dest' 2>/dev/null" | tr -d '\r' || true)" + if [ -n "$size" ] && [ "$have" = "$size" ]; then say "game data already on the headset, skipping"; continue; fi + say "copying game data - this takes a few minutes" + unzip -q -o "$xapk" "$path" -d "$tmp" + a shell "mkdir -p '$(dirname "$dest")'" + a push "$tmp/$path" "$dest" || fail "copying the game data failed (is there enough free space on the headset?)." + rm -f "$tmp/$path" +done + +echo +printf '\033[32mInstalled. In the headset: Library > Unknown Sources > %s.\033[0m\n' "$(field name)" +if [ "$launch" -eq 1 ]; then + say "launching (put the headset on)" + a shell monkey -p "$pkg" -c android.intent.category.LAUNCHER 1 >/dev/null 2>&1 || true +fi diff --git a/DorkNet.Quest/scripts/package.ps1 b/DorkNet.Quest/scripts/package.ps1 new file mode 100644 index 0000000..4871ae3 --- /dev/null +++ b/DorkNet.Quest/scripts/package.ps1 @@ -0,0 +1,170 @@ +<# +.SYNOPSIS + Bundle a patched Quest APK and its OBB into one installable package. + +.DESCRIPTION + Writes a release folder holding: + DorkNet-.xapk standard XAPK (manifest.json + APK + OBB + icon) + install.cmd / install.ps1 one-command installer for Windows + install.sh one-command installer for macOS / Linux + README.txt what to do with it + + The XAPK is the usual APKPure layout, so XAPK-aware installers (SideQuest's + file drop, SAI, APKPure) can also open it. The OBB is stored uncompressed: + it is already compressed, and storing it keeps packaging and install fast. + + The output contains game binaries from your own store copy. Share it only + with people who own the game. + +.PARAMETER Apk + The signed, patched APK produced by build.ps1. + +.PARAMETER Obb + The store OBB matching the APK's versionCode (main..com.AgainstGravity.RecRoom.obb). + +.PARAMETER OutDir + Release folder to create. Default: \DorkNet-Quest-. + +.PARAMETER BuildToolsDir + Android SDK build-tools dir holding aapt2. Default: resolved from $env:ANDROID_SDK_ROOT. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$Apk, + [Parameter(Mandatory = $true)][string]$Obb, + [string]$OutDir, + [string]$BuildToolsDir +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +Add-Type -AssemblyName System.IO.Compression, System.IO.Compression.FileSystem + +function Step($m) { Write-Host "==> $m" -ForegroundColor Cyan } +function Need($path, $what) { if (-not (Test-Path -LiteralPath $path)) { throw "$what not found: $path" } } + +Need $Apk 'patched APK' +Need $Obb 'OBB' +$Apk = (Resolve-Path -LiteralPath $Apk).Path +$Obb = (Resolve-Path -LiteralPath $Obb).Path + +if (-not $BuildToolsDir) { + if (-not $env:ANDROID_SDK_ROOT) { throw "Pass -BuildToolsDir or set ANDROID_SDK_ROOT." } + $bt = Join-Path $env:ANDROID_SDK_ROOT 'build-tools' + $BuildToolsDir = (Get-ChildItem $bt -Directory | Sort-Object Name -Descending | Select-Object -First 1).FullName +} +$Aapt2 = Join-Path $BuildToolsDir 'aapt2.exe' +Need $Aapt2 'aapt2' + +# ---- read the APK's identity -------------------------------------------- +Step 'reading APK metadata' +$badging = & $Aapt2 dump badging $Apk 2>$null +if ($LASTEXITCODE) { throw 'aapt2 dump badging failed' } +function Badge($pattern) { foreach ($l in $badging) { if ($l -match $pattern) { return $Matches[1] } } return $null } +$pkg = Badge "^package: name='([^']+)'" +$verCode = Badge "versionCode='([^']+)'" +$verName = Badge "versionName='([^']+)'" +$minSdk = Badge "^(?:minSdkVersion|sdkVersion):'([^']+)'" +$tgtSdk = Badge "^targetSdkVersion:'([^']+)'" +$label = Badge "^application-label:'([^']*)'" +if (-not $pkg -or -not $verCode) { throw 'could not read package name / versionCode from the APK' } +$perms = @($badging | Where-Object { $_ -match "^uses-permission: name='([^']+)'" } | ForEach-Object { $_ -replace "^uses-permission: name='([^']+)'.*", '$1' }) +$iconRef = @($badging | Where-Object { $_ -match "^application-icon-\d+:'([^']+)'" } | + ForEach-Object { [pscustomobject]@{ Density = [int]($_ -replace "^application-icon-(\d+):.*", '$1'); Path = ($_ -replace "^application-icon-\d+:'([^']+)'.*", '$1') } } | + Sort-Object Density -Descending | Select-Object -First 1 | ForEach-Object { $_.Path }) + +# The launcher icon may be an adaptive-icon XML; XAPK installers want a PNG, so +# fall back to the same-named PNG from the densest mipmap/drawable folder. +$iconPath = $null +if ($iconRef) { + $src = [System.IO.Compression.ZipFile]::OpenRead($Apk) + try { + if ($iconRef -like '*.png' -and $src.GetEntry($iconRef)) { $iconPath = $iconRef } + else { + $base = [System.IO.Path]::GetFileNameWithoutExtension($iconRef) + foreach ($d in 'xxxhdpi', 'xxhdpi', 'xhdpi', 'hdpi', 'mdpi', 'ldpi') { + foreach ($kind in 'mipmap', 'drawable') { + if (-not $iconPath -and $src.GetEntry("res/$kind-$d/$base.png")) { $iconPath = "res/$kind-$d/$base.png" } + } + } + } + } finally { $src.Dispose() } +} +Write-Host " $pkg version $verName ($verCode)" + +$obbName = "main.$verCode.$pkg.obb" +$obbEntry = "Android/obb/$pkg/$obbName" +$apkEntry = "$pkg.apk" + +if (-not $OutDir) { $OutDir = Join-Path (Split-Path -Parent $Apk) "DorkNet-Quest-$verName" } +New-Item -ItemType Directory -Force -Path $OutDir | Out-Null +$xapk = Join-Path $OutDir "DorkNet-$verName.xapk" +if (Test-Path $xapk) { Remove-Item -Force $xapk } + +# ---- manifest.json ------------------------------------------------------- +$manifest = [ordered]@{ + xapk_version = 2 + package_name = $pkg + name = if ($label) { $label } else { 'DorkNet' } + version_code = $verCode + version_name = $verName + min_sdk_version = $minSdk + target_sdk_version = $tgtSdk + permissions = $perms + total_size = (Get-Item $Apk).Length + (Get-Item $Obb).Length + expansions = @([ordered]@{ file = $obbEntry; install_location = 'EXTERNAL_STORAGE'; install_path = $obbEntry }) +} +if ($iconPath) { $manifest.icon = 'icon.png' } + +# ---- write the XAPK ------------------------------------------------------ +Step "writing $xapk (the OBB is large; this takes a minute)" +$zip = [System.IO.Compression.ZipFile]::Open($xapk, [System.IO.Compression.ZipArchiveMode]::Create) +try { + $e = $zip.CreateEntry('manifest.json', [System.IO.Compression.CompressionLevel]::Optimal) + $w = New-Object System.IO.StreamWriter($e.Open(), (New-Object System.Text.UTF8Encoding($false))) + $w.Write(($manifest | ConvertTo-Json -Depth 5)); $w.Dispose() + + if ($iconPath) { + $src = [System.IO.Compression.ZipFile]::OpenRead($Apk) + try { + $ie = $src.GetEntry($iconPath) + if ($ie) { + $dst = $zip.CreateEntry('icon.png', [System.IO.Compression.CompressionLevel]::NoCompression) + $in = $ie.Open(); $out = $dst.Open(); $in.CopyTo($out); $out.Dispose(); $in.Dispose() + } + } finally { $src.Dispose() } + } + + [System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip, $Apk, $apkEntry, [System.IO.Compression.CompressionLevel]::NoCompression) | Out-Null + [System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip, $Obb, $obbEntry, [System.IO.Compression.CompressionLevel]::NoCompression) | Out-Null +} finally { $zip.Dispose() } + +# ---- installers + readme ------------------------------------------------- +Step 'adding installers' +foreach ($f in 'install.ps1', 'install.cmd', 'install.sh') { + Copy-Item (Join-Path $PSScriptRoot "installer\$f") (Join-Path $OutDir $f) -Force +} +$readme = @" +$($manifest.name) $verName - Rec Room for DorkNet (Meta Quest) +============================================================ + +1. Turn on developer mode for your headset (Meta Horizon phone app), plug it + in over USB and accept "Allow USB debugging" inside the headset. +2. Install: + Windows: double-click install.cmd + macOS / Linux: ./install.sh + The installer needs adb (Android platform-tools) on PATH or in + ANDROID_SDK_ROOT. It installs the app, copies the game data (~1.4 GB) and + offers to launch it. +3. In the headset: Library > Unknown Sources > $($manifest.name). + +The app installs as $pkg alongside any store copy of Rec Room. +SideQuest users can also drop the .xapk onto SideQuest instead. +"@ +Set-Content -Path (Join-Path $OutDir 'README.txt') -Value $readme -Encoding utf8 + +$sizeGb = '{0:N2}' -f ((Get-Item $xapk).Length / 1GB) +Write-Host "" +Write-Host "DONE. Package folder: $OutDir" -ForegroundColor Green +Write-Host " $(Split-Path -Leaf $xapk) ($sizeGb GB)" -ForegroundColor Green +Write-Host "Install with: $(Join-Path $OutDir 'install.cmd')" -ForegroundColor Green diff --git a/DorkNet.Quest/scripts/resolve_targets.py b/DorkNet.Quest/scripts/resolve_targets.py new file mode 100644 index 0000000..b85e5fa --- /dev/null +++ b/DorkNet.Quest/scripts/resolve_targets.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +"""Resolve DorkNet.Quest hook targets for one Rec Room Quest build. + +Reads targets/spec.json, finds every target in an Il2CppDumper dump.cs by its +class header and method signature, and verifies each against the build's +libil2cpp.so: + + * exactly one method matches the signature, + * the method body is at least 20 bytes (the inline hook rewrites the first + five instructions), + * the first instructions are disassembled (llvm-objdump, when provided) so + the prologue can be eyeballed before a build. + +Output is a per-build table (targets/-.json) holding +each target's RVA, body length and an FNV-1a fingerprint of the first 20 code +bytes. The hook library refuses to arm a target whose fingerprint does not +match the running libil2cpp.so, so a table can never be applied to the wrong +build. Only RVAs, lengths and hashes are written -- no game code or metadata. + +Usage: + python resolve_targets.py --dump dump.cs --il2cpp libil2cpp.so \ + --version-name 20230317 --version-code 2128210 \ + [--objdump path/to/llvm-objdump] [--out targets/20230317-2128210.json] +""" +import argparse +import hashlib +import json +import re +import subprocess +from pathlib import Path + +HOOK_BYTES = 20 +RVA_RE = re.compile(r"^\s*// RVA: 0x([0-9A-Fa-f]+) Offset: 0x([0-9A-Fa-f]+)") +TYPE_RE = re.compile(r"^(public|internal|private|protected)\b.*\b(class|struct|interface|enum)\b ") + + +def fnv1a64(data): + h = 0xCBF29CE484222325 + for b in data: + h ^= b + h = (h * 0x100000001B3) & 0xFFFFFFFFFFFFFFFF + return h + + +def sha256(path): + d = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1 << 20), b""): + d.update(chunk) + return d.hexdigest() + + +def scan_dump(dump, spec): + """One pass over dump.cs collecting (rva, offset) for each spec match and + for the NEXT method after it (to bound the body length).""" + wanted = {t["id"]: (t["class"], t["method"]) for t in spec["targets"]} + hits = {} # id -> {rva, offset, ...} + all_rvas = set() # every method RVA, for next-greater length bounding + rva = off = None + cur_class = None + lines = dump.read_text(encoding="utf-8", errors="replace").splitlines() + for ln in lines: + m = RVA_RE.match(ln) + if m: + rva, off = int(m.group(1), 16), int(m.group(2), 16) + if rva: + all_rvas.add(rva) + continue + if TYPE_RE.match(ln): + # dump.cs class headers end with "// TypeDefIndex: N"; drop it. + cur_class = ln.split("// TypeDefIndex")[0].rstrip() + continue + # dump.cs method lines end with " { }" (or "; " for abstract); drop it. + body = ln.strip() + body = body.split("// RVA")[0].rstrip() + if body.endswith("{ }"): + body = body[:-3].rstrip() + elif body.endswith(";"): + body = body[:-1].rstrip() + for tid, (cls, meth) in wanted.items(): + if tid in hits: + continue + # Class header may carry a base/interface list ("X : Base, IFoo"). + # Match the exact class, or that class followed by " : ", + # but never a nested "X.Y" (which continues with "." not " "). + spec_class = cls.rstrip() + class_ok = cur_class is not None and ( + cur_class == spec_class or cur_class.startswith(spec_class + " ")) + if class_ok and body == meth.rstrip(): + if rva is None: + raise SystemExit(f"{tid}: matched signature with no preceding RVA line") + hits[tid] = {"rva": rva, "offset": off, "signature": meth, + "class": cls.rstrip()} + # Bound each body by the next-greater RVA anywhere in the dump: dump.cs + # lists methods in declaration order, so the following *line* is not the + # following *function* in memory. + ordered = sorted(all_rvas) + import bisect + for h in hits.values(): + i = bisect.bisect_right(ordered, h["rva"]) + h["end_rva"] = ordered[i] if i < len(ordered) else None + return hits + + +def objdump_prologue(objdump, il2cpp, rva, count=6): + try: + out = subprocess.run( + [objdump, "-d", f"--start-address={rva:#x}", + f"--stop-address={rva + count * 4:#x}", str(il2cpp)], + capture_output=True, text=True, check=True).stdout + except (OSError, subprocess.CalledProcessError) as e: + return f"(objdump failed: {e})" + return "\n".join(l for l in out.splitlines() if ":\t" in l) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--dump", required=True, type=Path) + ap.add_argument("--il2cpp", required=True, type=Path) + ap.add_argument("--version-name", required=True) + ap.add_argument("--version-code", required=True) + ap.add_argument("--objdump") + ap.add_argument("--spec", type=Path, + default=Path(__file__).resolve().parent.parent / "targets" / "spec.json") + ap.add_argument("--out", type=Path) + args = ap.parse_args() + + spec = json.loads(args.spec.read_text(encoding="utf-8")) + hits = scan_dump(args.dump, spec) + + code = args.il2cpp.read_bytes() + missing = [t["id"] for t in spec["targets"] if t["id"] not in hits] + if missing: + raise SystemExit("targets not found in dump: " + ", ".join(missing)) + + entries = {} + ok = True + for t in spec["targets"]: + tid = t["id"] + h = hits[tid] + rva = h["rva"] + # In this ELF Offset == VA == RVA, so the file offset is the RVA. + length = (h["end_rva"] - rva) if h["end_rva"] else None + fp = fnv1a64(code[rva:rva + HOOK_BYTES]) + short = length is not None and length < HOOK_BYTES + if short: + ok = False + entries[tid] = { + "rva": rva, + "length": length, + "fingerprint": f"{fp:#018x}", + "why": t["why"], + } + flag = " TOO SHORT" if short else "" + print(f"{tid:28s} rva={rva:#010x} len={length}{flag}") + if args.objdump: + print(objdump_prologue(args.objdump, args.il2cpp, rva)) + + table = { + "versionName": args.version_name, + "versionCode": args.version_code, + "il2cppSha256": sha256(args.il2cpp), + "hookBytes": HOOK_BYTES, + "targets": entries, + } + out = args.out or (args.spec.parent / f"{args.version_name}-{args.version_code}.json") + out.write_text(json.dumps(table, indent=2) + "\n", encoding="utf-8") + print(f"\nwrote {out}") + if not ok: + raise SystemExit("one or more targets are shorter than the hook patch; refuse to build") + + +if __name__ == "__main__": + main() diff --git a/DorkNet.Quest/src/config.cpp b/DorkNet.Quest/src/config.cpp new file mode 100644 index 0000000000000000000000000000000000000000..db60b39d604d488fec5c8a05a866f92644d1084e GIT binary patch literal 4059 zcmbVPZFAE`5bo#9 z*TX4>E%d2OV4*O6Dd2qC2PJ_S#PeyLX@!UiaVrj5%Hj6b&$)<;MZ%$DYcb3^+gqOb zh0;kbhuQCKTa}C1J!4YjH^#WV#lL7?3Fu5akP8h_gvnJhPohH3q7vWM0on1J>{Dz? zL!7Y+dYaAJhIFp`FdPoUF-Cxe#=61uR{qRGY4 zyC@1NHKm!(V_0G|psRcySjQ@auiIOIANyS5p(*r9mcO#R;1KlCwhvwW_w5_FhmX4T zRJEQ&H-vI`*P-Mo1SRYsYzT8cpJOitst+X|l&%Ucf-;0%ctVMiQ|1Erv5j!aDzm_~?yeJkbLOsco2FN#Vj8tY)OA#S&7o=EtWQHc4 z88eFS+Hiy3C`gB4^J}7PSxuOR!F$eL%Ib&ED?URhyNhnE8x+B?NlzEZHMb>GNSD^a z%y2~coXxm;AY1ze2~VaqHobjL_|2lWkQDW9WO5tL*_YK2P^C+zv#Vczdv;wnt}yDA z2DD()lJ+6QsTipwW>qqpShX7^OtaA=HA+!G6n;TDIyj5YC(}2RqeJuk;{Dm>@#$eS zeRUL_O->K%nR<16atOT?8#|RHX@hasshXiPteRr5P7OOkg6rv8?C}EfNR9DIfMVTT zSenZU#zLUs*fH`Lka?(?O^4C_#LwGFPCDaS1@`hnGmN>@1LmHFP2n1lUdB{rnIq0B z_Zr@L>Lh6+2-5Uz>R*`#)kWSn>i9st?(7>iFbxSUL%*GKp=;amk=6e_UHxM=?>Qr zWBMLAs*uZ|b`Yg-;)!d#?68S+~pfFNQAS@;2}{_bCLtwt9p{ zV@o#kI z;eIgsGrZc{hM_}jnxsX_IJZ_5T(=k{-@H-mA*>voBcqcupW$%|n3g$d{SOuY!mz#H z=U?k!E#9K}7u#*ksav5iXVS8*`mJWD2iojVu$zKk7V5IXyU|M{945n+j!=T` z{%Rb060>2#_ImbSDEx88lVc-9#z{+>?)ho0Zs_Vn&R(N(8T4Al?g7^e8)6=i{29cR z5Z=RFEp3SEB7KQcddj}|ebC4&Il0qXjy%%1kctOs?aThk>)aJJ^BX;Zw32g_w?@Ux HFO+`)uA;a( literal 0 HcmV?d00001 diff --git a/DorkNet.Quest/src/config.h b/DorkNet.Quest/src/config.h new file mode 100644 index 0000000..1cfff01 --- /dev/null +++ b/DorkNet.Quest/src/config.h @@ -0,0 +1,88 @@ +// Compile-time configuration for libdorknet.so. +// +// scripts/gen_config_header.py turns dorknet-quest.cfg into config_generated.h +// (put on the include path by the build), baking the operator's values into the +// APK. Any key left out of the cfg falls back to the built-in default below. +// The same cfg can also be pushed to +// /sdcard/Android/data//files/dorknet-quest.cfg +// to override at runtime without a rebuild (dn_load_runtime_config). +#ifndef DORKNET_QUEST_CONFIG_H +#define DORKNET_QUEST_CONFIG_H + +#include +#include + +#if defined(__has_include) +# if __has_include("config_generated.h") +# include "config_generated.h" +# endif +#endif + +// The apex the client's *.rec.net hosts are rewritten onto. "api.rec.net" +// becomes "api.", "auth.rec.net" -> "auth.", etc. No default: a +// build with no server configured would do nothing useful. +#ifndef DN_SERVER_HOST +# define DN_SERVER_HOST "" +#endif +// Empty = keep the client's own (Rec Room's) Meta app id, which selects the +// no-Meta-account build (platform entitlement/login are steered past). +#ifndef DN_OCULUS_APPID +# define DN_OCULUS_APPID "" +#endif +// Photon app ids: per-deployment, no default. Empty leaves the client's own +// (dead) value in place, so multiplayer will not connect until set. +#ifndef DN_PHOTON_REALTIME +# define DN_PHOTON_REALTIME "" +#endif +#ifndef DN_PHOTON_VOICE +# define DN_PHOTON_VOICE "" +#endif +#ifndef DN_PHOTON_CHAT +# define DN_PHOTON_CHAT "" +#endif +#ifndef DN_PHOTON_SERVER +# define DN_PHOTON_SERVER "" // "" = Photon Cloud +#endif +#ifndef DN_PHOTON_PORT +# define DN_PHOTON_PORT 0 +#endif +// Photon Cloud fixed region. Must match the PC clients' region (ClientMod +// PhotonCloudRegion), or Quest and PC players land on different masters. +#ifndef DN_PHOTON_REGION +# define DN_PHOTON_REGION "us" +#endif +#ifndef DN_TLS_TRUST_BYPASS +# define DN_TLS_TRUST_BYPASS true // accept a self-hosted TLS chain + skip pinning +#endif +#ifndef DN_ACCEPT_UNSIGNED_IMAGES +# define DN_ACCEPT_UNSIGNED_IMAGES true +#endif +#ifndef DN_LOG_HTTP +# define DN_LOG_HTTP true +#endif +#ifndef DN_DEBUG_HTTP +# define DN_DEBUG_HTTP false // also log request/response bodies (incl. tokens!) +#endif +#ifndef DN_LOG_BODY_MAX +# define DN_LOG_BODY_MAX 10000 +#endif + +#ifndef DN_RUNTIME_CFG_NAME +# define DN_RUNTIME_CFG_NAME "dorknet-quest.cfg" +#endif +// Package the runtime cfg is read from; the build's gen step overrides this. +#ifndef DN_PACKAGE +# define DN_PACKAGE "net.dorknet.RecRoom" +#endif + +// Apply /sdcard/Android/data//files/dorknet-quest.cfg over the +// baked defaults. Each out pointer already holds the compiled-in value. +void dn_load_runtime_config(char *server_host, size_t server_host_cap, + char *oculus_appid, size_t oculus_appid_cap, + char *photon_realtime, char *photon_voice, char *photon_chat, + char *photon_server, size_t photon_server_cap, int *photon_port, + char *photon_region, size_t photon_region_cap, + bool *tls_bypass, bool *accept_images, + bool *log_http, bool *debug_http, int *body_max); + +#endif // DORKNET_QUEST_CONFIG_H diff --git a/DorkNet.Quest/src/dorknet_quest.cpp b/DorkNet.Quest/src/dorknet_quest.cpp new file mode 100644 index 0000000..d56101b --- /dev/null +++ b/DorkNet.Quest/src/dorknet_quest.cpp @@ -0,0 +1,613 @@ +// DorkNet.Quest native hook library (libdorknet.so). +// +// Loaded via a DT_NEEDED entry added to libmain.so, so it runs before Unity. +// It waits for libil2cpp.so, then installs arm64 inline hooks (And64InlineHook) +// at RVAs resolved from the build's dump.cs by scripts/resolve_targets.py and +// baked into targets_generated.h at compile time. It is the Quest counterpart +// of DorkNet.ClientMod (the PC MelonLoader mod) and does the same jobs: +// +// * rewrite .rec.net hosts to . (the ns +// response then supplies every other service URL) -- like ClientMod's +// UriPatches, +// * inject the operator's Photon app ids -- like the PC byte patch +// tools/patch-client.ps1, +// * optionally set the operator's Meta (Oculus) app id, +// * accept the self-hosted TLS chain and the image signature the operator's +// server produces -- like ClientMod's TlsPatches / the PC ImageSigning, +// * neutralise the integrity callbacks that would reject a re-signed, +// hook-loaded client -- like ClientMod's AntiTamper/ToxMod/FileHash patches, +// * when no Meta app id is set, steer the Oculus platform path past its +// entitlement/login checks to the client's own credential screen. +// +// Every RVA is checked against a 20-byte FNV-1a fingerprint from the target +// table before it is hooked, so the wrong build is refused rather than +// miswritten. Obfuscated Assembly-CSharp names are re-randomised per release +// but shared with the PC build of the same release; this build (20230317 / +// versionCode 2128210) shares its names with the PC 2023-03-21 decompile. +// +// No Rec Room code or assets are included. Third-party inline hooker in +// src/third_party/And64InlineHook (MIT, (c) Rprop). + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "third_party/And64InlineHook/And64InlineHook.hpp" +#include "config.h" +#include "targets_generated.h" + +extern "C" int __android_log_print(int prio, const char *tag, const char *fmt, ...); +#define LOGI(...) __android_log_print(4, "DORKNET", __VA_ARGS__) +#define LOGE(...) __android_log_print(6, "DORKNET", __VA_ARGS__) + +// --------------------------------------------------------------- config ----- + +static const char *kOfficialSuffix = ".rec.net"; + +static char g_server_host[160] = DN_SERVER_HOST; // .rec.net -> . +static char g_oculus_appid[64] = DN_OCULUS_APPID; // "" = keep the client's own +static char g_photon_realtime[64] = DN_PHOTON_REALTIME; +static char g_photon_voice[64] = DN_PHOTON_VOICE; +static char g_photon_chat[64] = DN_PHOTON_CHAT; +static char g_photon_server[128] = DN_PHOTON_SERVER; // "" = Photon Cloud +static int g_photon_port = DN_PHOTON_PORT; +static char g_photon_region[16] = DN_PHOTON_REGION; +static bool g_tls_bypass = DN_TLS_TRUST_BYPASS; +static bool g_accept_images = DN_ACCEPT_UNSIGNED_IMAGES; +static bool g_log_http = DN_LOG_HTTP; +static bool g_debug_http = DN_DEBUG_HTTP; +static int g_body_max = DN_LOG_BODY_MAX; + +// AppSettings field offsets (dump.cs; unchanged across the 2023 builds). +static const size_t kAppIdRealtimeOff = 0x10; +static const size_t kAppIdChatOff = 0x18; +static const size_t kAppIdVoiceOff = 0x20; +static const size_t kUseNameServerOff = 0x30; // bool +static const size_t kFixedRegionOff = 0x38; +static const size_t kServerOff = 0x48; +static const size_t kPortOff = 0x50; // int +static const size_t kProtocolOff = 0x60; // int (Udp = 0) + +// HTTPRequest / HTTPResponse field offsets (dump.cs). +static const size_t kUriFieldOffset = 0x10; // HTTPRequest. +static const size_t kReqMethodOffset = 0x18; // HTTPRequest. (byte) +static const size_t kReqResponseOffset = 0x70; // HTTPRequest. +static const size_t kReqExceptionOffset = 0x80; // HTTPRequest. +static const size_t kReqStateOffset = 0xB4; // HTTPRequest. (int) +static const size_t kRespStatusOffset = 0x18; // HTTPResponse. (int) +static const size_t kRespDataOffset = 0x48; // HTTPResponse. (byte[]) + +// Il2CppArray data layout on 64-bit. +static const size_t kArrayLengthOff = 0x18; +static const size_t kArrayDataOff = 0x20; + +// Oculus platform message types (dump.cs ONGCNIAMKCG.DJCEHCFKHME). +static const unsigned kMsgEntitlementGetIsViewerEntitled = 409688241u; +static const unsigned kMsgUserGetUserProof = 578880643u; +static const char *kPlaceholderNonce = "dorknet-no-platform-account"; +static const unsigned long long kNoPlatformUserId = 1ULL; + +static const size_t kLogChunk = 1500; +static const uint32_t kUnityTlsSuccess = 0; // UNITYTLS_X509VERIFY_SUCCESS / UNITYTLS_SUCCESS +static const size_t kErrorStateCodeOff = 0x4; + +// --------------------------------------------------------------- il2cpp ------ + +typedef void *(*il2cpp_object_get_class_t)(void *obj); +typedef void *(*il2cpp_class_get_method_from_name_t)(void *klass, const char *name, int argc); +typedef void *(*il2cpp_object_new_t)(void *klass); +typedef void *(*il2cpp_string_new_t)(const char *str); +typedef void *(*il2cpp_runtime_invoke_t)(void *method, void *obj, void **params, void **exc); + +static il2cpp_object_get_class_t il2cpp_object_get_class; +static il2cpp_class_get_method_from_name_t il2cpp_class_get_method_from_name; +static il2cpp_object_new_t il2cpp_object_new; +static il2cpp_string_new_t il2cpp_string_new; +static il2cpp_runtime_invoke_t il2cpp_runtime_invoke; + +static uintptr_t g_il2cpp_base; + +// Il2CppString: [klass][monitor][int32 length][char16 chars...] +static void str_to_utf8(void *s, char *out, size_t cap) { + out[0] = '\0'; + if (!s || cap == 0) return; + int32_t len = *(int32_t *)((char *)s + 0x10); + if (len < 0) return; + const uint16_t *chars = (const uint16_t *)((char *)s + 0x14); + size_t i = 0; + for (; i < (size_t)len && i + 1 < cap; i++) + out[i] = chars[i] < 0x80 ? (char)chars[i] : '?'; + out[i] = '\0'; +} + +static const unsigned char *invoke_bytes(void *obj, const char *method, size_t *len) { + *len = 0; + if (!obj) return nullptr; + void *cls = il2cpp_object_get_class(obj); + if (!cls) return nullptr; + void *m = il2cpp_class_get_method_from_name(cls, method, 0); + if (!m) return nullptr; + void *exc = nullptr; + void *arr = il2cpp_runtime_invoke(m, obj, nullptr, &exc); + if (!arr || exc) return nullptr; + *len = (size_t) * (uint64_t *)((char *)arr + kArrayLengthOff); + return (const unsigned char *)((char *)arr + kArrayDataOff); +} + +static void invoke_str(void *obj, const char *method, char *out, size_t cap) { + out[0] = '\0'; + if (!obj) return; + void *cls = il2cpp_object_get_class(obj); + if (!cls) return; + void *m = il2cpp_class_get_method_from_name(cls, method, 0); + if (!m) return; + void *exc = nullptr; + void *s = il2cpp_runtime_invoke(m, obj, nullptr, &exc); + if (s && !exc) str_to_utf8(s, out, cap); +} + +// --------------------------------------------------------------- logging ---- + +static void log_chunked(const char *prefix, const char *text) { + size_t n = strlen(text); + if (n <= kLogChunk) { LOGI("%s%s", prefix, text); return; } + char buf[kLogChunk + 1]; + for (size_t off = 0, part = 1; off < n; off += kLogChunk, part++) { + size_t take = n - off < kLogChunk ? n - off : kLogChunk; + memcpy(buf, text + off, take); + buf[take] = '\0'; + LOGI("%s[%zu] %s", prefix, part, buf); + } +} + +static bool looks_binary(const unsigned char *d, size_t n) { + if (n == 0) return false; + size_t sample = n < 4096 ? n : 4096, nontext = 0; + for (size_t i = 0; i < sample; i++) { + unsigned char b = d[i]; + if (b == 0) return true; + if (b < 0x20 && b != 0x09 && b != 0x0A && b != 0x0D) nontext++; + } + return nontext * 100 / sample > 10; +} + +static void format_body(const unsigned char *d, size_t n, char *out, size_t cap) { + if (!d || n == 0) { snprintf(out, cap, ""); return; } + size_t limit = (size_t)g_body_max; + if (limit > cap - 64) limit = cap - 64; + if (!looks_binary(d, n)) { + size_t take = n < limit ? n : limit; + memcpy(out, d, take); + out[take] = '\0'; + if (take < n) snprintf(out + take, cap - take, "... ", n - take); + return; + } + int w = snprintf(out, cap, "", n); + out[w < 0 ? 0 : (size_t)w] = '\0'; +} + +// --------------------------------------------------------------- hooks ------- + +static void *(*orig_SendRequest)(void *request); + +static void *hk_SendRequest(void *request) { + if (!request) return orig_SendRequest(request); + void *uri = *(void **)((char *)request + kUriFieldOffset); + if (!uri) return orig_SendRequest(request); + void *uri_class = il2cpp_object_get_class(uri); + if (!uri_class) return orig_SendRequest(request); + + void *m_abs = il2cpp_class_get_method_from_name(uri_class, "get_AbsoluteUri", 0); + if (!m_abs) return orig_SendRequest(request); + void *exc = nullptr; + void *s = il2cpp_runtime_invoke(m_abs, uri, nullptr, &exc); + if (!s || exc) return orig_SendRequest(request); + + char url[2048]; + str_to_utf8(s, url, sizeof(url)); + + if (g_log_http) { + int method = *(unsigned char *)((char *)request + kReqMethodOffset); + static const char *methods[] = {"GET", "HEAD", "POST", "PUT", "DELETE", + "PATCH", "MERGE", "OPTIONS", "CONNECT"}; + const char *mname = (method >= 0 && method < 9) ? methods[method] : "?"; + LOGI("[http] %s %s", mname, url); + if (g_debug_http) { + size_t blen = 0; + const unsigned char *body = invoke_bytes(request, "GetEntityBody", &blen); + if (body && blen) { + size_t cap = (size_t)g_body_max + 128; + char *text = (char *)malloc(cap); + if (text) { format_body(body, blen, text, cap); log_chunked("[http] body=", text); free(text); } + } + } + } + + // Rewrite any ".rec.net" host to ".". + const char *scheme_end = strstr(url, "://"); + if (!scheme_end) return orig_SendRequest(request); + const char *host = scheme_end + 3; + const char *host_end = host; + while (*host_end && *host_end != '/' && *host_end != ':' && *host_end != '?') host_end++; + size_t host_len = (size_t)(host_end - host); + size_t suf = strlen(kOfficialSuffix); + if (host_len <= suf || strncasecmp(host_end - suf, kOfficialSuffix, suf) != 0) + return orig_SendRequest(request); + + char newurl[1024]; + size_t head = (size_t)(host - url); // "https://" + size_t service_len = host_len - suf; // "api", "auth", ... + // ServerHost is either a plain apex ("example.com" -> "api.example.com") or a + // template with "{service}" for servers on DorkNet's hyphen host style + // ("{service}-dev.example.com" -> "ns-dev.example.com"). + static const char kServiceToken[] = "{service}"; + const char *token = strstr(g_server_host, kServiceToken); + size_t new_host_len = token ? strlen(g_server_host) - (sizeof(kServiceToken) - 1) + service_len + : service_len + 1 + strlen(g_server_host); + if (head + new_host_len + strlen(host_end) + 1 >= sizeof(newurl)) + return orig_SendRequest(request); + memcpy(newurl, url, head); + size_t p = head; + if (token) { + size_t before = (size_t)(token - g_server_host); + memcpy(newurl + p, g_server_host, before); + p += before; + memcpy(newurl + p, host, service_len); + p += service_len; + strcpy(newurl + p, token + sizeof(kServiceToken) - 1); + } else { + memcpy(newurl + p, host, service_len); + p += service_len; + newurl[p++] = '.'; + strcpy(newurl + p, g_server_host); + } + strcat(newurl + head, host_end); + + void *new_str = il2cpp_string_new(newurl); + void *new_uri = il2cpp_object_new(uri_class); + void *ctor = il2cpp_class_get_method_from_name(uri_class, ".ctor", 1); + if (!new_str || !new_uri || !ctor) return orig_SendRequest(request); + void *args[1] = {new_str}; + exc = nullptr; + il2cpp_runtime_invoke(ctor, new_uri, args, &exc); + if (exc) return orig_SendRequest(request); + *(void **)((char *)request + kUriFieldOffset) = new_uri; + LOGI("intercepted %s -> %s", url, newurl); + return orig_SendRequest(request); +} + +static void (*orig_CallCallback)(void *thiz, void *method); +static void hk_CallCallback(void *thiz, void *method) { + if (!thiz || !g_log_http) { orig_CallCallback(thiz, method); return; } + char url[2048]; + invoke_str(*(void **)((char *)thiz + kUriFieldOffset), "get_AbsoluteUri", url, sizeof(url)); + int state = *(int *)((char *)thiz + kReqStateOffset); + void *resp = *(void **)((char *)thiz + kReqResponseOffset); + if (!resp) { + void *ex = *(void **)((char *)thiz + kReqExceptionOffset); + if (ex) { + char msg[900]; + invoke_str(ex, "ToString", msg, sizeof(msg)); + LOGI("[http<-] NO-RESPONSE state=%d %s | %s", state, url, msg); + } else { + LOGI("[http<-] NO-RESPONSE state=%d %s | (no exception)", state, url); + } + } else { + int status = *(int *)((char *)resp + kRespStatusOffset); + LOGI("[http<-] %d %s", status, url); + if (g_debug_http || status < 200 || status >= 300) { + size_t blen = 0; + const unsigned char *data = invoke_bytes(resp, "get_Data", &blen); + size_t cap = (size_t)g_body_max + 128; + char *text = (char *)malloc(cap); + if (text) { format_body(data, blen, text, cap); log_chunked("[http<-] body=", text); free(text); } + } + } + orig_CallCallback(thiz, method); +} + +static void *hk_get_AppID(void *method) { + (void)method; + return il2cpp_string_new(g_oculus_appid); +} + +static void *(*orig_Photon)(void *thiz, void *method); +static void *hk_Photon(void *thiz, void *method) { + void *s = orig_Photon(thiz, method); + if (s) { + if (g_photon_realtime[0]) *(void **)((char *)s + kAppIdRealtimeOff) = il2cpp_string_new(g_photon_realtime); + if (g_photon_chat[0]) *(void **)((char *)s + kAppIdChatOff) = il2cpp_string_new(g_photon_chat); + if (g_photon_voice[0]) *(void **)((char *)s + kAppIdVoiceOff) = il2cpp_string_new(g_photon_voice); + *(unsigned char *)((char *)s + kUseNameServerOff) = 1; + *(void **)((char *)s + kFixedRegionOff) = il2cpp_string_new(g_photon_region); + *(int *)((char *)s + kProtocolOff) = 0; + if (g_photon_server[0]) { + *(void **)((char *)s + kServerOff) = il2cpp_string_new(g_photon_server); + *(int *)((char *)s + kPortOff) = g_photon_port ? g_photon_port : 4533; + } + static int logged = 0; + if (logged < 2) { + logged++; + LOGI("photon appsettings: RT=%s Voice=%s Chat=%s region=%s server=%s port=%d", + g_photon_realtime[0] ? g_photon_realtime : "(unset)", + g_photon_voice[0] ? g_photon_voice : "(unset)", + g_photon_chat[0] ? g_photon_chat : "(unset)", g_photon_region, + g_photon_server[0] ? g_photon_server : "(cloud)", g_photon_port); + if (!g_photon_realtime[0]) + LOGE("photon app ids are not configured; multiplayer will try Rec Room's dead apps"); + } + } + return s; +} + +static uint32_t (*orig_UnityTls)(void *thiz, uint64_t chain, void *es, void *method); +static uint32_t hk_UnityTls(void *thiz, uint64_t chain, void *es, void *method) { + uint32_t r = orig_UnityTls(thiz, chain, es, method); + if (r != kUnityTlsSuccess) { + static int logged = 0; + if (logged < 3) { logged++; LOGI("unitytls verify: forcing 0x%x -> SUCCESS", r); } + if (es) *(uint32_t *)((char *)es + kErrorStateCodeOff) = kUnityTlsSuccess; + } + return kUnityTlsSuccess; +} + +// void NotifyServerCertificate(Certificate): a no-op accepts the chain. +static void hk_NotifyServerCertificate(void *thiz, void *cert, void *method) { + (void)thiz; (void)cert; (void)method; +} + +// bool pin verifiers: accept unconditionally (replace-only, so no mismatch +// report is ever built and the client never quits). +static bool hk_pin_true4(void *a, void *b, void *c, void *d) { (void)a;(void)b;(void)c;(void)d; return true; } +static bool hk_pin_true4b(void *a, void *b, void *c, void *d) { (void)a;(void)b;(void)c;(void)d; return true; } + +static bool hk_image_true(void *data, void *sig, void *method) { + (void)data; (void)sig; (void)method; + return true; +} + +// void callbacks the client runs on an integrity rejection -- no-op them so a +// re-signed, hook-loaded sideload is not warned/quit (as DorkNet.ClientMod does +// on PC). The quit funnels and the report are left to run but are logged. +static void hk_void0(void *thiz, void *method) { (void)thiz; (void)method; } +static void hk_void1(void *thiz, void *arg, void *method) { (void)thiz; (void)arg; (void)method; } + +static bool hk_toxmod_false(void *method) { (void)method; return false; } + +static const char *cheat_reason(int r) { + switch (r) { + case 0: return "Obscured"; case 1: return "Time"; case 2: return "Inject"; + case 3: return "GiftCount"; case 4: return "Engine"; case 5: return "UnknownDll"; + case 6: return "ImageSignature"; case 7: return "AvatarHack"; + case 100: return "NetworkCertificatePublicKey"; case 101: return "NetworkCertificateIssuer"; + case 102: return "NetworkCertificateMissing"; case 103: return "NetworkCertificateMismatch"; + case 150: return "AutosaveChecksumMismatch"; case 151: return "AutosaveSubRoomIdMismatch"; + case 152: return "AutosaveChecksumException"; case 200: return "Photon_MissingHash"; + case 201: return "Photon_CorruptHash"; case 203: return "Photon_DifferentHash"; + case 300: return "AppData_Runtime_LengthMismatch"; case 301: return "AppData_Runtime_LastWriteTimeMismatch"; + case 302: return "AppData_Runtime_FileModified"; case 310: return "AppData_Boot_InvalidSignature"; + case 311: return "AppData_Boot_UnableToVerifySignatures"; case 320: return "Config_MissingHash"; + case 321: return "Config_DifferentHash"; case 400: return "Photon_InstantiateTool"; + case 500: return "Memory_Hash_Mismatch"; case 600: return "Driver_Invalid_Signature"; + default: return "?"; + } +} + +static void log_caller(const char *what, void *ret) { + uintptr_t a = (uintptr_t)ret; + if (g_il2cpp_base && a > g_il2cpp_base) + LOGI("[caller] %s <- libil2cpp rva 0x%lx", what, (unsigned long)(a - g_il2cpp_base)); + else + LOGI("[caller] %s <- %p", what, ret); +} + +static void *(*orig_CheatReport)(int reason, void *msg, uint64_t code, void *method); +static void *hk_CheatReport(int reason, void *msg, uint64_t code, void *method) { + char text[512]; + str_to_utf8(msg, text, sizeof(text)); + LOGI("[integrity] report reason=%d (%s) msg=\"%s\"", reason, cheat_reason(reason), text); + log_caller("report", __builtin_return_address(0)); + return orig_CheatReport(reason, msg, code, method); +} + +static void (*orig_FatalQuit)(int code, void *reason, void *method); +static void hk_FatalQuit(int code, void *reason, void *method) { + char msg[512]; + str_to_utf8(reason, msg, sizeof(msg)); + LOGI("[exit] FatalApplicationQuit(%d, \"%s\")", code, msg); + log_caller("fatal quit", __builtin_return_address(0)); + orig_FatalQuit(code, reason, method); +} + +static void (*orig_TryQuit)(int code, void *method); +static void hk_TryQuit(int code, void *method) { + LOGI("[exit] TryApplicationQuit(%d)", code); + log_caller("try quit", __builtin_return_address(0)); + orig_TryQuit(code, method); +} + +// ------------------------------------------------------- oculus platform ----- + +typedef bool (*ovr_IsError_t)(void *m); +typedef unsigned (*ovr_GetType_t)(void *m); +static ovr_IsError_t orig_ovr_IsError; +static ovr_GetType_t ovr_GetType; + +static bool hk_ovr_IsError(void *m) { + bool real = orig_ovr_IsError(m); + if (!real || !ovr_GetType) return real; + unsigned t = ovr_GetType(m); + if (t == kMsgEntitlementGetIsViewerEntitled || t == kMsgUserGetUserProof) return false; + return real; +} + +static void *(*orig_ovr_GetUserProof)(void *m); +static void *hk_ovr_GetUserProof(void *m) { + void *p = orig_ovr_GetUserProof ? orig_ovr_GetUserProof(m) : nullptr; + return p ? p : (void *)0x1; +} + +static const char *(*orig_ovr_GetNonce)(void *proof); +static const char *hk_ovr_GetNonce(void *proof) { + if (proof && proof != (void *)0x1 && orig_ovr_GetNonce) { + const char *n = orig_ovr_GetNonce(proof); + if (n && n[0]) return n; + } + return kPlaceholderNonce; +} + +static unsigned long long (*orig_ovr_GetLoggedInUserID)(); +static unsigned long long hk_ovr_GetLoggedInUserID() { + unsigned long long real = orig_ovr_GetLoggedInUserID ? orig_ovr_GetLoggedInUserID() : 0; + return real ? real : kNoPlatformUserId; +} + +static unsigned long long (*orig_ovr_User_GetID)(void *u); +static unsigned long long hk_ovr_User_GetID(void *u) { + unsigned long long real = orig_ovr_User_GetID ? orig_ovr_User_GetID(u) : 0; + return real ? real : kNoPlatformUserId; +} + +// ----------------------------------------------------------------- init ------ + +static uintptr_t module_base(const char *name) { + FILE *f = fopen("/proc/self/maps", "r"); + if (!f) return 0; + char line[512]; + uintptr_t base = 0; + while (fgets(line, sizeof(line), f)) + if (strstr(line, name)) { base = (uintptr_t)strtoull(line, nullptr, 16); break; } + fclose(f); + return base; +} + +static uint64_t fnv1a(const unsigned char *d, size_t n) { + uint64_t h = 0xCBF29CE484222325ULL; + for (size_t i = 0; i < n; i++) { h ^= d[i]; h *= 0x100000001B3ULL; } + return h; +} + +// Arm a target by id: verify the 20-byte fingerprint, then install the hook. +static bool arm(const DnTarget *t, void *hook, void **orig) { + void *addr = (void *)(g_il2cpp_base + t->rva); + uint64_t fp = fnv1a((const unsigned char *)addr, DN_HOOK_BYTES); + if (fp != t->fingerprint) { + LOGE("REFUSING %s: rva 0x%lx fingerprint 0x%llx != expected 0x%llx (wrong build?)", + t->id, (unsigned long)t->rva, (unsigned long long)fp, + (unsigned long long)t->fingerprint); + return false; + } + void *dummy = nullptr; + A64HookFunction(addr, hook, orig ? orig : &dummy); + LOGI("armed %s @ rva 0x%lx", t->id, (unsigned long)t->rva); + return true; +} + +static bool resolve_il2cpp(void *h) { +#define SYM(n) n = (n##_t)dlsym(h, #n); if (!n) { LOGE("dlsym " #n " failed"); return false; } + SYM(il2cpp_object_get_class) + SYM(il2cpp_class_get_method_from_name) + SYM(il2cpp_object_new) + SYM(il2cpp_string_new) + SYM(il2cpp_runtime_invoke) +#undef SYM + return true; +} + +static void arm_oculus_platform_bypass() { + void *ovr = nullptr; + for (int i = 0; i < 600; i++) { + ovr = dlopen("libovrplatformloader.so", RTLD_NOLOAD | RTLD_NOW); + if (ovr) break; + usleep(100 * 1000); + } + if (!ovr) { LOGE("libovrplatformloader.so never appeared; platform bypass not armed"); return; } + ovr_GetType = (ovr_GetType_t)dlsym(ovr, "ovr_Message_GetType"); + void *isErr = dlsym(ovr, "ovr_Message_IsError"); + if (ovr_GetType && isErr) { + A64HookFunction(isErr, (void *)hk_ovr_IsError, (void **)&orig_ovr_IsError); + LOGI("platform entitlement/proof bypass armed (no Meta app id configured)"); + } else { + LOGE("ovr symbols missing; platform bypass not armed"); + return; + } + void *a; + if ((a = dlsym(ovr, "ovr_GetLoggedInUserID"))) + A64HookFunction(a, (void *)hk_ovr_GetLoggedInUserID, (void **)&orig_ovr_GetLoggedInUserID); + if ((a = dlsym(ovr, "ovr_User_GetID"))) + A64HookFunction(a, (void *)hk_ovr_User_GetID, (void **)&orig_ovr_User_GetID); + if ((a = dlsym(ovr, "ovr_Message_GetUserProof"))) + A64HookFunction(a, (void *)hk_ovr_GetUserProof, (void **)&orig_ovr_GetUserProof); + if ((a = dlsym(ovr, "ovr_UserProof_GetNonce"))) + A64HookFunction(a, (void *)hk_ovr_GetNonce, (void **)&orig_ovr_GetNonce); +} + +static void *init_thread(void *) { + void *h = nullptr; + for (int i = 0; i < 600; i++) { + h = dlopen("libil2cpp.so", RTLD_NOLOAD | RTLD_NOW); + if (h) break; + usleep(100 * 1000); + } + if (!h) { LOGE("libil2cpp.so never appeared; aborting"); return nullptr; } + g_il2cpp_base = module_base("libil2cpp.so"); + if (!g_il2cpp_base) { LOGE("could not find libil2cpp.so base"); return nullptr; } + if (!resolve_il2cpp(h)) return nullptr; + dn_load_runtime_config(g_server_host, sizeof(g_server_host), g_oculus_appid, sizeof(g_oculus_appid), + g_photon_realtime, g_photon_voice, g_photon_chat, + g_photon_server, sizeof(g_photon_server), &g_photon_port, + g_photon_region, sizeof(g_photon_region), + &g_tls_bypass, &g_accept_images, &g_log_http, &g_debug_http, &g_body_max); + + LOGI("DorkNet.Quest init: build %s/%s il2cpp @ 0x%lx; host-rewrite -> *.%s", + DN_TARGET_VERSION_NAME, DN_TARGET_VERSION_CODE, (unsigned long)g_il2cpp_base, g_server_host); + + arm(&dn_targets.http_send_request, (void *)hk_SendRequest, (void **)&orig_SendRequest); + if (g_log_http) + arm(&dn_targets.http_call_callback, (void *)hk_CallCallback, (void **)&orig_CallCallback); + arm(&dn_targets.photon_app_settings, (void *)hk_Photon, (void **)&orig_Photon); + + if (g_oculus_appid[0]) { + arm(&dn_targets.oculus_get_app_id, (void *)hk_get_AppID, nullptr); + arm(&dn_targets.oculus_get_mobile_app_id, (void *)hk_get_AppID, nullptr); + LOGI("oculus app id override: %s", g_oculus_appid); + } + + if (g_tls_bypass) { + arm(&dn_targets.tls_unity_verify, (void *)hk_UnityTls, (void **)&orig_UnityTls); + arm(&dn_targets.tls_bc_notify_server_certificate, (void *)hk_NotifyServerCertificate, nullptr); + arm(&dn_targets.pin_nameserver, (void *)hk_pin_true4, nullptr); + arm(&dn_targets.pin_https, (void *)hk_pin_true4b, nullptr); + arm(&dn_targets.pin_bc_verifier, (void *)hk_pin_true4, nullptr); + } + + if (g_accept_images) + arm(&dn_targets.image_signature_verify, (void *)hk_image_true, nullptr); + + // Integrity callbacks that would reject a re-signed, hook-loaded client. + arm(&dn_targets.hile_warning_callback, (void *)hk_void1, nullptr); + arm(&dn_targets.unknown_dll_callback, (void *)hk_void0, nullptr); + arm(&dn_targets.file_hash_callback, (void *)hk_void1, nullptr); + arm(&dn_targets.toxmod_can_initialize, (void *)hk_toxmod_false, nullptr); + + // Log-only diagnostics. + arm(&dn_targets.cheat_report, (void *)hk_CheatReport, (void **)&orig_CheatReport); + arm(&dn_targets.fatal_application_quit, (void *)hk_FatalQuit, (void **)&orig_FatalQuit); + arm(&dn_targets.try_application_quit, (void *)hk_TryQuit, (void **)&orig_TryQuit); + + if (!g_oculus_appid[0]) arm_oculus_platform_bypass(); + LOGI("DorkNet.Quest init complete"); + return nullptr; +} + +__attribute__((constructor)) static void on_load() { + LOGI("DorkNet.Quest hooks loaded (build " __DATE__ " " __TIME__ ")"); + pthread_t t; + pthread_create(&t, nullptr, init_thread, nullptr); + pthread_detach(t); +} diff --git a/DorkNet.Quest/src/third_party/And64InlineHook/And64InlineHook.cpp b/DorkNet.Quest/src/third_party/And64InlineHook/And64InlineHook.cpp new file mode 100644 index 0000000..53617b7 --- /dev/null +++ b/DorkNet.Quest/src/third_party/And64InlineHook/And64InlineHook.cpp @@ -0,0 +1,596 @@ +/* + * @date : 2018/04/18 + * @author : Rprop (r_prop@outlook.com) + * https://github.com/Rprop/And64InlineHook + */ +/* + MIT License + + Copyright (c) 2018 Rprop (r_prop@outlook.com) + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + */ +#define __STDC_FORMAT_MACROS +#include +#include +#include +#include +#include +#include + +#if defined(__aarch64__) + +#include "And64InlineHook.hpp" +#define A64_MAX_INSTRUCTIONS 5 +#define A64_MAX_REFERENCES (A64_MAX_INSTRUCTIONS * 2) +#define A64_NOP 0xd503201fu +#define A64_JNIEXPORT __attribute__((visibility("default"))) +#define A64_LOGE(...) ((void)__android_log_print(ANDROID_LOG_ERROR, "A64_HOOK", __VA_ARGS__)) +#ifndef NDEBUG +# define A64_LOGI(...) ((void)__android_log_print(ANDROID_LOG_INFO, "A64_HOOK", __VA_ARGS__)) +#else +# define A64_LOGI(...) ((void)0) +#endif // NDEBUG +typedef uint32_t *__restrict *__restrict instruction; +struct context +{ + struct fix_info + { + uint32_t *bp; + uint32_t ls; // left-shift counts + uint32_t ad; // & operand + }; + struct insns_info + { + union + { + uint64_t insu; + int64_t ins; + void *insp; + }; + fix_info fmap[A64_MAX_REFERENCES]; + }; + int64_t basep; + int64_t endp; + insns_info dat[A64_MAX_INSTRUCTIONS]; + +public: + inline bool is_in_fixing_range(const int64_t absolute_addr) { + return absolute_addr >= this->basep && absolute_addr < this->endp; + } + inline intptr_t get_ref_ins_index(const int64_t absolute_addr) { + return static_cast((absolute_addr - this->basep) / sizeof(uint32_t)); + } + inline intptr_t get_and_set_current_index(uint32_t *__restrict inp, uint32_t *__restrict outp) { + intptr_t current_idx = this->get_ref_ins_index(reinterpret_cast(inp)); + this->dat[current_idx].insp = outp; + return current_idx; + } + inline void reset_current_ins(const intptr_t idx, uint32_t *__restrict outp) { + this->dat[idx].insp = outp; + } + void insert_fix_map(const intptr_t idx, uint32_t *bp, uint32_t ls = 0u, uint32_t ad = 0xffffffffu) { + for (auto &f : this->dat[idx].fmap) { + if (f.bp == NULL) { + f.bp = bp; + f.ls = ls; + f.ad = ad; + return; + } //if + } + // What? GGing.. + } + void process_fix_map(const intptr_t idx) { + for (auto &f : this->dat[idx].fmap) { + if (f.bp == NULL) break; + *(f.bp) = *(f.bp) | (((int32_t(this->dat[idx].ins - reinterpret_cast(f.bp)) >> 2) << f.ls) & f.ad); + f.bp = NULL; + } + } +}; + +//------------------------------------------------------------------------- + +#define __intval(p) reinterpret_cast(p) +#define __uintval(p) reinterpret_cast(p) +#define __ptr(p) reinterpret_cast(p) +#define __page_size 4096 +#define __page_align(n) __align_up(static_cast(n), __page_size) +#define __ptr_align(x) __ptr(__align_down(reinterpret_cast(x), __page_size)) +#define __align_up(x, n) (((x) + ((n) - 1)) & ~((n) - 1)) +#define __align_down(x, n) ((x) & -(n)) +#define __countof(x) static_cast(sizeof(x) / sizeof((x)[0])) // must be signed +#define __atomic_increase(p) __sync_add_and_fetch(p, 1) +#define __sync_cmpswap(p, v, n) __sync_bool_compare_and_swap(p, v, n) +#define __predict_true(exp) __builtin_expect((exp) != 0, 1) +#define __flush_cache(c, n) __builtin___clear_cache(reinterpret_cast(c), reinterpret_cast(c) + n) +#define __make_rwx(p, n) ::mprotect(__ptr_align(p), \ + __page_align(__uintval(p) + n) != __page_align(__uintval(p)) ? __page_align(n) + __page_size : __page_align(n), \ + PROT_READ | PROT_WRITE | PROT_EXEC) + +//------------------------------------------------------------------------- + +static bool __fix_branch_imm(instruction inpp, instruction outpp, context *ctxp) +{ + static constexpr uint32_t mbits = 6u; + static constexpr uint32_t mask = 0xfc000000u; // 0b11111100000000000000000000000000 + static constexpr uint32_t rmask = 0x03ffffffu; // 0b00000011111111111111111111111111 + static constexpr uint32_t op_b = 0x14000000u; // "b" ADDR_PCREL26 + static constexpr uint32_t op_bl = 0x94000000u; // "bl" ADDR_PCREL26 + + const uint32_t ins = *(*inpp); + const uint32_t opc = ins & mask; + switch (opc) { + case op_b: + case op_bl: + { + intptr_t current_idx = ctxp->get_and_set_current_index(*inpp, *outpp); + int64_t absolute_addr = reinterpret_cast(*inpp) + (static_cast(ins << mbits) >> (mbits - 2u)); // sign-extended + int64_t new_pc_offset = static_cast(absolute_addr - reinterpret_cast(*outpp)) >> 2; // shifted + bool special_fix_type = ctxp->is_in_fixing_range(absolute_addr); + // whether the branch should be converted to absolute jump + if (!special_fix_type && llabs(new_pc_offset) >= (rmask >> 1)) { + bool b_aligned = (reinterpret_cast(*outpp + 2) & 7u) == 0u; + if (opc == op_b) { + if (b_aligned != true) { + (*outpp)[0] = A64_NOP; + ctxp->reset_current_ins(current_idx, ++(*outpp)); + } //if + (*outpp)[0] = 0x58000051u; // LDR X17, #0x8 + (*outpp)[1] = 0xd61f0220u; // BR X17 + memcpy(*outpp + 2, &absolute_addr, sizeof(absolute_addr)); + *outpp += 4; + } else { + if (b_aligned == true) { + (*outpp)[0] = A64_NOP; + ctxp->reset_current_ins(current_idx, ++(*outpp)); + } //if + (*outpp)[0] = 0x58000071u; // LDR X17, #12 + (*outpp)[1] = 0x1000009eu; // ADR X30, #16 + (*outpp)[2] = 0xd61f0220u; // BR X17 + memcpy(*outpp + 3, &absolute_addr, sizeof(absolute_addr)); + *outpp += 5; + } //if + } else { + if (special_fix_type) { + intptr_t ref_idx = ctxp->get_ref_ins_index(absolute_addr); + if (ref_idx <= current_idx) { + new_pc_offset = static_cast(ctxp->dat[ref_idx].ins - reinterpret_cast(*outpp)) >> 2; + } else { + ctxp->insert_fix_map(ref_idx, *outpp, 0u, rmask); + new_pc_offset = 0; + } //if + } //if + + (*outpp)[0] = opc | (new_pc_offset & ~mask); + ++(*outpp); + } //if + + ++(*inpp); + return ctxp->process_fix_map(current_idx), true; + } + } + return false; +} + +//------------------------------------------------------------------------- + +static bool __fix_cond_comp_test_branch(instruction inpp, instruction outpp, context *ctxp) +{ + static constexpr uint32_t lsb = 5u; + static constexpr uint32_t lmask01 = 0xff00001fu; // 0b11111111000000000000000000011111 + static constexpr uint32_t mask0 = 0xff000010u; // 0b11111111000000000000000000010000 + static constexpr uint32_t op_bc = 0x54000000u; // "b.c" ADDR_PCREL19 + static constexpr uint32_t mask1 = 0x7f000000u; // 0b01111111000000000000000000000000 + static constexpr uint32_t op_cbz = 0x34000000u; // "cbz" Rt, ADDR_PCREL19 + static constexpr uint32_t op_cbnz = 0x35000000u; // "cbnz" Rt, ADDR_PCREL19 + static constexpr uint32_t lmask2 = 0xfff8001fu; // 0b11111111111110000000000000011111 + static constexpr uint32_t mask2 = 0x7f000000u; // 0b01111111000000000000000000000000 + static constexpr uint32_t op_tbz = 0x36000000u; // 0b00110110000000000000000000000000 "tbz" Rt, BIT_NUM, ADDR_PCREL14 + static constexpr uint32_t op_tbnz = 0x37000000u; // 0b00110111000000000000000000000000 "tbnz" Rt, BIT_NUM, ADDR_PCREL14 + + const uint32_t ins = *(*inpp); + uint32_t lmask = lmask01; + if ((ins & mask0) != op_bc) { + uint32_t opc = ins & mask1; + if (opc != op_cbz && opc != op_cbnz) { + opc = ins & mask2; + if (opc != op_tbz && opc != op_tbnz) { + return false; + } //if + lmask = lmask2; + } //if + } //if + + const uint32_t msb = __builtin_clz(~lmask); + intptr_t current_idx = ctxp->get_and_set_current_index(*inpp, *outpp); + int64_t absolute_addr = reinterpret_cast(*inpp) + (static_cast((ins & ~lmask) << msb) >> (lsb - 2u + msb)); + int64_t new_pc_offset = static_cast(absolute_addr - reinterpret_cast(*outpp)) >> 2; // shifted + bool special_fix_type = ctxp->is_in_fixing_range(absolute_addr); + if (!special_fix_type && llabs(new_pc_offset) >= (~lmask >> (lsb + 1))) { + if ((reinterpret_cast(*outpp + 4) & 7u) != 0u) { + (*outpp)[0] = A64_NOP; + ctxp->reset_current_ins(current_idx, ++(*outpp)); + } //if + (*outpp)[0] = (((8u >> 2u) << lsb) & ~lmask) | (ins & lmask); // B.C #0x8 + (*outpp)[1] = 0x14000005u; // B #0x14 + (*outpp)[2] = 0x58000051u; // LDR X17, #0x8 + (*outpp)[3] = 0xd61f0220u; // BR X17 + memcpy(*outpp + 4, &absolute_addr, sizeof(absolute_addr)); + *outpp += 6; + } else { + if (special_fix_type) { + intptr_t ref_idx = ctxp->get_ref_ins_index(absolute_addr); + if (ref_idx <= current_idx) { + new_pc_offset = static_cast(ctxp->dat[ref_idx].ins - reinterpret_cast(*outpp)) >> 2; + } else { + ctxp->insert_fix_map(ref_idx, *outpp, lsb, ~lmask); + new_pc_offset = 0; + } //if + } //if + + (*outpp)[0] = (static_cast(new_pc_offset << lsb) & ~lmask) | (ins & lmask); + ++(*outpp); + } //if + + ++(*inpp); + return ctxp->process_fix_map(current_idx), true; +} + +//------------------------------------------------------------------------- + +static bool __fix_loadlit(instruction inpp, instruction outpp, context *ctxp) +{ + const uint32_t ins = *(*inpp); + + // memory prefetch("prfm"), just skip it + // http://infocenter.arm.com/help/topic/com.arm.doc.100069_0608_00_en/pge1427897420050.html + if ((ins & 0xff000000u) == 0xd8000000u) { + ctxp->process_fix_map(ctxp->get_and_set_current_index(*inpp, *outpp)); + ++(*inpp); + return true; + } //if + + static constexpr uint32_t msb = 8u; + static constexpr uint32_t lsb = 5u; + static constexpr uint32_t mask_30 = 0x40000000u; // 0b01000000000000000000000000000000 + static constexpr uint32_t mask_31 = 0x80000000u; // 0b10000000000000000000000000000000 + static constexpr uint32_t lmask = 0xff00001fu; // 0b11111111000000000000000000011111 + static constexpr uint32_t mask_ldr = 0xbf000000u; // 0b10111111000000000000000000000000 + static constexpr uint32_t op_ldr = 0x18000000u; // 0b00011000000000000000000000000000 "LDR Wt/Xt, label" | ADDR_PCREL19 + static constexpr uint32_t mask_ldrv = 0x3f000000u; // 0b00111111000000000000000000000000 + static constexpr uint32_t op_ldrv = 0x1c000000u; // 0b00011100000000000000000000000000 "LDR St/Dt/Qt, label" | ADDR_PCREL19 + static constexpr uint32_t mask_ldrsw = 0xff000000u; // 0b11111111000000000000000000000000 + static constexpr uint32_t op_ldrsw = 0x98000000u; // "LDRSW Xt, label" | ADDR_PCREL19 | load register signed word + // LDR S0, #0 | 0b00011100000000000000000000000000 | 32-bit + // LDR D0, #0 | 0b01011100000000000000000000000000 | 64-bit + // LDR Q0, #0 | 0b10011100000000000000000000000000 | 128-bit + // INVALID | 0b11011100000000000000000000000000 | may be 256-bit + + uint32_t mask = mask_ldr; + uintptr_t faligned = (ins & mask_30) ? 7u : 3u; + if ((ins & mask_ldr) != op_ldr) { + mask = mask_ldrv; + if (faligned != 7u) + faligned = (ins & mask_31) ? 15u : 3u; + if ((ins & mask_ldrv) != op_ldrv) { + if ((ins & mask_ldrsw) != op_ldrsw) { + return false; + } //if + mask = mask_ldrsw; + faligned = 7u; + } //if + } //if + + intptr_t current_idx = ctxp->get_and_set_current_index(*inpp, *outpp); + int64_t absolute_addr = reinterpret_cast(*inpp) + ((static_cast(ins << msb) >> (msb + lsb - 2u)) & ~3u); + int64_t new_pc_offset = static_cast(absolute_addr - reinterpret_cast(*outpp)) >> 2; // shifted + bool special_fix_type = ctxp->is_in_fixing_range(absolute_addr); + // special_fix_type may encounter issue when there are mixed data and code + if (special_fix_type || (llabs(new_pc_offset) + (faligned + 1u - 4u) / 4u) >= (~lmask >> (lsb + 1))) { // inaccurate, but it works + while ((reinterpret_cast(*outpp + 2) & faligned) != 0u) { + *(*outpp)++ = A64_NOP; + } + ctxp->reset_current_ins(current_idx, *outpp); + + // Note that if memory at absolute_addr is writeable (non-const), we will fail to fetch it. + // And what's worse, we may unexpectedly overwrite something if special_fix_type is true... + uint32_t ns = static_cast((faligned + 1) / sizeof(uint32_t)); + (*outpp)[0] = (((8u >> 2u) << lsb) & ~mask) | (ins & lmask); // LDR #0x8 + (*outpp)[1] = 0x14000001u + ns; // B #0xc + memcpy(*outpp + 2, reinterpret_cast(absolute_addr), faligned + 1); + *outpp += 2 + ns; + } else { + faligned >>= 2; // new_pc_offset is shifted and 4-byte aligned + while ((new_pc_offset & faligned) != 0) { + *(*outpp)++ = A64_NOP; + new_pc_offset = static_cast(absolute_addr - reinterpret_cast(*outpp)) >> 2; + } + ctxp->reset_current_ins(current_idx, *outpp); + + (*outpp)[0] = (static_cast(new_pc_offset << lsb) & ~mask) | (ins & lmask); + ++(*outpp); + } //if + + ++(*inpp); + return ctxp->process_fix_map(current_idx), true; +} + +//------------------------------------------------------------------------- + +static bool __fix_pcreladdr(instruction inpp, instruction outpp, context *ctxp) +{ + // Load a PC-relative address into a register + // http://infocenter.arm.com/help/topic/com.arm.doc.100069_0608_00_en/pge1427897645644.html + static constexpr uint32_t msb = 8u; + static constexpr uint32_t lsb = 5u; + static constexpr uint32_t mask = 0x9f000000u; // 0b10011111000000000000000000000000 + static constexpr uint32_t rmask = 0x0000001fu; // 0b00000000000000000000000000011111 + static constexpr uint32_t lmask = 0xff00001fu; // 0b11111111000000000000000000011111 + static constexpr uint32_t fmask = 0x00ffffffu; // 0b00000000111111111111111111111111 + static constexpr uint32_t max_val = 0x001fffffu; // 0b00000000000111111111111111111111 + static constexpr uint32_t op_adr = 0x10000000u; // "adr" Rd, ADDR_PCREL21 + static constexpr uint32_t op_adrp = 0x90000000u; // "adrp" Rd, ADDR_ADRP + + const uint32_t ins = *(*inpp); + intptr_t current_idx; + switch (ins & mask) { + case op_adr: + { + current_idx = ctxp->get_and_set_current_index(*inpp, *outpp); + int64_t lsb_bytes = static_cast(ins << 1u) >> 30u; + int64_t absolute_addr = reinterpret_cast(*inpp) + (((static_cast(ins << msb) >> (msb + lsb - 2u)) & ~3u) | lsb_bytes); + int64_t new_pc_offset = static_cast(absolute_addr - reinterpret_cast(*outpp)); + bool special_fix_type = ctxp->is_in_fixing_range(absolute_addr); + if (!special_fix_type && llabs(new_pc_offset) >= (max_val >> 1)) { + if ((reinterpret_cast(*outpp + 2) & 7u) != 0u) { + (*outpp)[0] = A64_NOP; + ctxp->reset_current_ins(current_idx, ++(*outpp)); + } //if + + (*outpp)[0] = 0x58000000u | (((8u >> 2u) << lsb) & ~mask) | (ins & rmask); // LDR #0x8 + (*outpp)[1] = 0x14000003u; // B #0xc + memcpy(*outpp + 2, &absolute_addr, sizeof(absolute_addr)); + *outpp += 4; + } else { + if (special_fix_type) { + intptr_t ref_idx = ctxp->get_ref_ins_index(absolute_addr & ~3ull); + if (ref_idx <= current_idx) { + new_pc_offset = static_cast(ctxp->dat[ref_idx].ins - reinterpret_cast(*outpp)); + } else { + ctxp->insert_fix_map(ref_idx, *outpp, lsb, fmask); + new_pc_offset = 0; + } //if + } //if + + // the lsb_bytes will never be changed, so we can use lmask to keep it + (*outpp)[0] = (static_cast(new_pc_offset << (lsb - 2u)) & fmask) | (ins & lmask); + ++(*outpp); + } //if + } + break; + case op_adrp: + { + current_idx = ctxp->get_and_set_current_index(*inpp, *outpp); + int32_t lsb_bytes = static_cast(ins << 1u) >> 30u; + int64_t absolute_addr = (reinterpret_cast(*inpp) & ~0xfffll) + ((((static_cast(ins << msb) >> (msb + lsb - 2u)) & ~3u) | lsb_bytes) << 12); + A64_LOGI("ins = 0x%.8X, pc = %p, abs_addr = %p", + ins, *inpp, reinterpret_cast(absolute_addr)); + if (ctxp->is_in_fixing_range(absolute_addr)) { + intptr_t ref_idx = ctxp->get_ref_ins_index(absolute_addr/* & ~3ull*/); + if (ref_idx > current_idx) { + // the bottom 12 bits of absolute_addr are masked out, + // so ref_idx must be less than or equal to current_idx! + A64_LOGE("ref_idx must be less than or equal to current_idx!"); + } //if + + // *absolute_addr may be changed due to relocation fixing + A64_LOGI("What is the correct way to fix this?"); + *(*outpp)++ = ins; // 0x90000000u; + } else { + if ((reinterpret_cast(*outpp + 2) & 7u) != 0u) { + (*outpp)[0] = A64_NOP; + ctxp->reset_current_ins(current_idx, ++(*outpp)); + } //if + + (*outpp)[0] = 0x58000000u | (((8u >> 2u) << lsb) & ~mask) | (ins & rmask); // LDR #0x8 + (*outpp)[1] = 0x14000003u; // B #0xc + memcpy(*outpp + 2, &absolute_addr, sizeof(absolute_addr)); // potential overflow? + *outpp += 4; + } //if + } + break; + default: + return false; + } + + ctxp->process_fix_map(current_idx); + ++(*inpp); + return true; +} + +//------------------------------------------------------------------------- + +static void __fix_instructions(uint32_t *__restrict inp, int32_t count, uint32_t *__restrict outp) +{ + context ctx; + ctx.basep = reinterpret_cast(inp); + ctx.endp = reinterpret_cast(inp + count); + memset(ctx.dat, 0, sizeof(ctx.dat)); + static_assert(sizeof(ctx.dat) / sizeof(ctx.dat[0]) == A64_MAX_INSTRUCTIONS, + "please use A64_MAX_INSTRUCTIONS!"); +#ifndef NDEBUG + if (count > A64_MAX_INSTRUCTIONS) { + A64_LOGE("too many fixing instructions!"); + } //if +#endif // NDEBUG + + uint32_t *const outp_base = outp; + + while (--count >= 0) { + if (__fix_branch_imm(&inp, &outp, &ctx)) continue; + if (__fix_cond_comp_test_branch(&inp, &outp, &ctx)) continue; + if (__fix_loadlit(&inp, &outp, &ctx)) continue; + if (__fix_pcreladdr(&inp, &outp, &ctx)) continue; + + // without PC-relative offset + ctx.process_fix_map(ctx.get_and_set_current_index(inp, outp)); + *(outp++) = *(inp++); + } + + static constexpr uint_fast64_t mask = 0x03ffffffu; // 0b00000011111111111111111111111111 + auto callback = reinterpret_cast(inp); + auto pc_offset = static_cast(callback - reinterpret_cast(outp)) >> 2; + if (llabs(pc_offset) >= (mask >> 1)) { + if ((reinterpret_cast(outp + 2) & 7u) != 0u) { + outp[0] = A64_NOP; + ++outp; + } //if + outp[0] = 0x58000051u; // LDR X17, #0x8 + outp[1] = 0xd61f0220u; // BR X17 + *reinterpret_cast(outp + 2) = callback; + outp += 4; + } else { + outp[0] = 0x14000000u | (pc_offset & mask); // "B" ADDR_PCREL26 + ++outp; + } //if + + const uintptr_t total = (outp - outp_base) * sizeof(uint32_t); + __flush_cache(outp_base, total); // necessary +} + +//------------------------------------------------------------------------- + +extern "C" { + static __attribute__((__aligned__(__page_size))) uint32_t __insns_pool[A64_MAX_BACKUPS][A64_MAX_INSTRUCTIONS * 10]; + + //------------------------------------------------------------------------- + + class A64HookInit + { + public: + A64HookInit() + { + __make_rwx(__insns_pool, sizeof(__insns_pool)); + A64_LOGI("insns pool initialized."); + } + }; + static A64HookInit __init; + + //------------------------------------------------------------------------- + + static uint32_t *FastAllocateTrampoline() + { + static_assert((A64_MAX_INSTRUCTIONS * 10 * sizeof(uint32_t)) % 8 == 0, "8-byte align"); + static volatile int32_t __index = -1; + + int32_t i = __atomic_increase(&__index); + if (__predict_true(i >= 0 && i < __countof(__insns_pool))) { + return __insns_pool[i]; + } //if + + A64_LOGE("failed to allocate trampoline!"); + return NULL; + } + + //------------------------------------------------------------------------- + + A64_JNIEXPORT void *A64HookFunctionV(void *const symbol, void *const replace, + void *const rwx, const uintptr_t rwx_size) + { + static constexpr uint_fast64_t mask = 0x03ffffffu; // 0b00000011111111111111111111111111 + + uint32_t *trampoline = static_cast(rwx), *original = static_cast(symbol); + + static_assert(A64_MAX_INSTRUCTIONS >= 5, "please fix A64_MAX_INSTRUCTIONS!"); + auto pc_offset = static_cast(__intval(replace) - __intval(symbol)) >> 2; + if (llabs(pc_offset) >= (mask >>1)) { + int32_t count = (reinterpret_cast(original + 2) & 7u) != 0u ? 5 : 4; + if (trampoline) { + if (rwx_size < count * 10u) { + A64_LOGE("rwx size is too small to hold %u bytes backup instructions!", count * 10u); + return NULL; + } //if + __fix_instructions(original, count, trampoline); + } //if + + if (__make_rwx(original, 5 * sizeof(uint32_t)) == 0) { + if (count == 5) { + original[0] = A64_NOP; + ++original; + } //if + original[0] = 0x58000051u; // LDR X17, #0x8 + original[1] = 0xd61f0220u; // BR X17 + *reinterpret_cast(original + 2) = __intval(replace); + __flush_cache(symbol, 5 * sizeof(uint32_t)); + + A64_LOGI("inline hook %p->%p successfully! %zu bytes overwritten", + symbol, replace, 5 * sizeof(uint32_t)); + } else { + A64_LOGE("mprotect failed with errno = %d, p = %p, size = %zu", + errno, original, 5 * sizeof(uint32_t)); + trampoline = NULL; + } //if + } else { + if (trampoline) { + if (rwx_size < 1u * 10u) { + A64_LOGE("rwx size is too small to hold %u bytes backup instructions!", 1u * 10u); + return NULL; + } //if + __fix_instructions(original, 1, trampoline); + } //if + + if (__make_rwx(original, 1 * sizeof(uint32_t)) == 0) { + __sync_cmpswap(original, *original, 0x14000000u | (pc_offset & mask)); // "B" ADDR_PCREL26 + __flush_cache(symbol, 1 * sizeof(uint32_t)); + + A64_LOGI("inline hook %p->%p successfully! %zu bytes overwritten", + symbol, replace, 1 * sizeof(uint32_t)); + } else { + A64_LOGE("mprotect failed with errno = %d, p = %p, size = %zu", + errno, original, 1 * sizeof(uint32_t)); + trampoline = NULL; + } //if + } //if + + return trampoline; + } + + //------------------------------------------------------------------------- + + A64_JNIEXPORT void A64HookFunction(void *const symbol, void *const replace, void **result) + { + void *trampoline = NULL; + if (result != NULL) { + trampoline = FastAllocateTrampoline(); + *result = trampoline; + if (trampoline == NULL) return; + } //if + + // fix Android 10 .text segment is read-only by default + __make_rwx(symbol, 5 * sizeof(size_t)); + + trampoline = A64HookFunctionV(symbol, replace, trampoline, A64_MAX_INSTRUCTIONS * 10u); + if (trampoline == NULL && result != NULL) { + *result = NULL; + } //if + } +} + +#endif // defined(__aarch64__) diff --git a/DorkNet.Quest/src/third_party/And64InlineHook/And64InlineHook.hpp b/DorkNet.Quest/src/third_party/And64InlineHook/And64InlineHook.hpp new file mode 100644 index 0000000..7af5090 --- /dev/null +++ b/DorkNet.Quest/src/third_party/And64InlineHook/And64InlineHook.hpp @@ -0,0 +1,42 @@ +/* + * @date : 2018/04/18 + * @author : Rprop (r_prop@outlook.com) + * https://github.com/Rprop/And64InlineHook + */ +/* + MIT License + + Copyright (c) 2018 Rprop (r_prop@outlook.com) + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + */ +#pragma once +#define A64_MAX_BACKUPS 256 + +#ifdef __cplusplus +extern "C" { +#endif + + void A64HookFunction(void *const symbol, void *const replace, void **result); + void *A64HookFunctionV(void *const symbol, void *const replace, + void *const rwx, const uintptr_t rwx_size); + +#ifdef __cplusplus +} +#endif \ No newline at end of file diff --git a/DorkNet.Quest/src/third_party/And64InlineHook/LICENSE b/DorkNet.Quest/src/third_party/And64InlineHook/LICENSE new file mode 100644 index 0000000..75a6020 --- /dev/null +++ b/DorkNet.Quest/src/third_party/And64InlineHook/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 RLib + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/DorkNet.Quest/targets/20230317-2128210.json b/DorkNet.Quest/targets/20230317-2128210.json new file mode 100644 index 0000000..4616bfa --- /dev/null +++ b/DorkNet.Quest/targets/20230317-2128210.json @@ -0,0 +1,116 @@ +{ + "versionName": "20230317", + "versionCode": "2128210", + "il2cppSha256": "842a496cc59948af03fe435e0abe0a173cbf2a6bf439be48096ad2b2c2dccbb9", + "hookBytes": 20, + "targets": { + "http_send_request": { + "rva": 108025384, + "length": 456, + "fingerprint": "0x8cd03afe41eecdef", + "why": "Rewrites .rec.net hosts to . before a request is queued." + }, + "http_call_callback": { + "rva": 108061608, + "length": 424, + "fingerprint": "0x98ed61ac83527269", + "why": "Logs each finished request's status (and the body of non-2xx responses)." + }, + "oculus_get_app_id": { + "rva": 166405008, + "length": 32, + "fingerprint": "0xaa5a50a8d4246c46", + "why": "Returns the operator's Meta app id when OculusAppId is set." + }, + "oculus_get_mobile_app_id": { + "rva": 166380836, + "length": 32, + "fingerprint": "0x5f5d3bc64dedb8de", + "why": "Same as get_AppID; the Quest build reads this getter." + }, + "photon_app_settings": { + "rva": 69268008, + "length": 196, + "fingerprint": "0xc8050d166579e43b", + "why": "Builds Photon.Realtime.AppSettings with Rec Room's hardcoded Realtime/Voice app ids (PC: same class+method). Postfixed to set the operator's ids." + }, + "tls_unity_verify": { + "rva": 159789160, + "length": 884, + "fingerprint": "0xf8d4a696f15e6f0e", + "why": "Platform TLS trust decision (Mono's bundled CA set). Forced to success when TlsTrustBypass is on." + }, + "tls_bc_notify_server_certificate": { + "rva": 103301824, + "length": 284, + "fingerprint": "0x7f061bfc843e881f", + "why": "BestHTTP BouncyCastle trust hook (PC ClientMod skips the same method). No-op when TlsTrustBypass is on." + }, + "pin_nameserver": { + "rva": 96989180, + "length": 332, + "fingerprint": "0x659c928fe96e1008", + "why": "Name-server client's certificate callback: chain status + SPKI pin compare. Always accepted (pins never match a self-hosted server)." + }, + "pin_https": { + "rva": 103249928, + "length": 1032, + "fingerprint": "0x943a238f04d54362", + "why": "SPKI pinning for every RecNet request; a miss reports NetworkCertificateMismatch through FPIBGPIAOBI. Always accepted." + }, + "pin_bc_verifier": { + "rva": 82659376, + "length": 1128, + "fingerprint": "0x60415d1d29a7315d", + "why": "The same pin check as an ICertificateVerifyer for the BouncyCastle path. Always accepted." + }, + "image_signature_verify": { + "rva": 97302848, + "length": 732, + "fingerprint": "0xe1549ef6f4691a33", + "why": "RSA-SHA1 check of image Content-Signature against Rec Room's baked public key. Accepted when AcceptUnsignedImages is on." + }, + "hile_warning_callback": { + "rva": 75750004, + "length": 352, + "fingerprint": "0xccf510feeada40d9", + "why": "Cheat-warning dialog/quit callback. Skipped, as on PC." + }, + "unknown_dll_callback": { + "rva": 57998052, + "length": 112, + "fingerprint": "0x84e1fed3a70e7560", + "why": "Unknown-library detection callback (the injected hook library is one). Skipped, as on PC (PC closure class is PNHDGEKCJCH)." + }, + "file_hash_callback": { + "rva": 52872472, + "length": 1052, + "fingerprint": "0x03a8b6f1b373e600", + "why": "File-hash checker result callback (the APK is re-signed). Skipped, as on PC." + }, + "toxmod_can_initialize": { + "rva": 55928080, + "length": 1040, + "fingerprint": "0xf72dd6f5dbb16216", + "why": "Third-party voice moderation service; forced false, as on PC." + }, + "cheat_report": { + "rva": 106127656, + "length": 2248, + "fingerprint": "0x9c1e0ef53e5a3ec8", + "why": "Log-only: names any integrity report so a new one is visible in logcat." + }, + "fatal_application_quit": { + "rva": 55748824, + "length": 348, + "fingerprint": "0x8cd03afe41eecdef", + "why": "Log-only: prints the quit reason and caller." + }, + "try_application_quit": { + "rva": 55748412, + "length": 412, + "fingerprint": "0xb12b0044ac4f844f", + "why": "Log-only: prints the quit code and caller." + } + } +} diff --git a/DorkNet.Quest/targets/spec.json b/DorkNet.Quest/targets/spec.json new file mode 100644 index 0000000..be4d1be --- /dev/null +++ b/DorkNet.Quest/targets/spec.json @@ -0,0 +1,119 @@ +{ + "_comment": [ + "Hook targets, identified by the class header and method signature exactly as", + "Il2CppDumper prints them in dump.cs. scripts/resolve_targets.py turns this into a", + "per-build table (RVA, length, code fingerprint). Obfuscated names are fixed per", + "release and shared with the PC build of the same release; when rebasing, re-check", + "each one against the PC decompile and update the signature here." + ], + "targets": [ + { + "id": "http_send_request", + "class": "public static class HTTPManager ", + "method": "public static HTTPRequest SendRequest(HTTPRequest request)", + "why": "Rewrites .rec.net hosts to . before a request is queued." + }, + { + "id": "http_call_callback", + "class": "public sealed class HTTPRequest ", + "method": "internal void CallCallback()", + "why": "Logs each finished request's status (and the body of non-2xx responses)." + }, + { + "id": "oculus_get_app_id", + "class": "public sealed class PlatformSettings ", + "method": "public static string get_AppID()", + "why": "Returns the operator's Meta app id when OculusAppId is set." + }, + { + "id": "oculus_get_mobile_app_id", + "class": "public sealed class PlatformSettings ", + "method": "public static string get_MobileAppID()", + "why": "Same as get_AppID; the Quest build reads this getter." + }, + { + "id": "photon_app_settings", + "class": "internal class NFOMFIAOKFO ", + "method": "public AppSettings LBKJOHGFHOO()", + "why": "Builds Photon.Realtime.AppSettings with Rec Room's hardcoded Realtime/Voice app ids (PC: same class+method). Postfixed to set the operator's ids." + }, + { + "id": "tls_unity_verify", + "class": "internal class UnityTlsContext ", + "method": "private UnityTls.unitytls_x509verify_result VerifyCallback(UnityTls.unitytls_x509list_ref chain, UnityTls.unitytls_errorstate* errorState)", + "why": "Platform TLS trust decision (Mono's bundled CA set). Forced to success when TlsTrustBypass is on." + }, + { + "id": "tls_bc_notify_server_certificate", + "class": "public class LegacyTlsAuthentication ", + "method": "public virtual void NotifyServerCertificate(Certificate serverCertificate)", + "why": "BestHTTP BouncyCastle trust hook (PC ClientMod skips the same method). No-op when TlsTrustBypass is on." + }, + { + "id": "pin_nameserver", + "class": "public class PAPLNIPKAMG ", + "method": "private static bool GKHFNFDOKDK(HTTPRequest ODNHKCNDAKN, X509Certificate PGKJCEFFIPB, X509Chain AJJPNBICGLG)", + "why": "Name-server client's certificate callback: chain status + SPKI pin compare. Always accepted (pins never match a self-hosted server)." + }, + { + "id": "pin_https", + "class": "public static class LJEHIHEHMCN ", + "method": "public static bool JFIODJJKMBF(HTTPRequest ODNHKCNDAKN, X509Certificate PGKJCEFFIPB, X509Chain AJJPNBICGLG)", + "why": "SPKI pinning for every RecNet request; a miss reports NetworkCertificateMismatch through FPIBGPIAOBI. Always accepted." + }, + { + "id": "pin_bc_verifier", + "class": "public class LJEHIHEHMCN.NALNEIDKBOC ", + "method": "public bool IsValid(Uri HEEAJLIFCHN, X509CertificateStructure[] MFLGMAEAFON)", + "why": "The same pin check as an ICertificateVerifyer for the BouncyCastle path. Always accepted." + }, + { + "id": "image_signature_verify", + "class": "public class KLJOGJHBONK ", + "method": "private static bool PBLLFOKJAMK(byte[] HBMMCJPOJGP, byte[] JJGOGMKDLGI)", + "why": "RSA-SHA1 check of image Content-Signature against Rec Room's baked public key. Accepted when AcceptUnsignedImages is on." + }, + { + "id": "hile_warning_callback", + "class": "private sealed class FPIBGPIAOBI.KHIIHOIFDMP ", + "method": "internal void b__0(bool shouldQuit)", + "why": "Cheat-warning dialog/quit callback. Skipped, as on PC." + }, + { + "id": "unknown_dll_callback", + "class": "private sealed class CheatManager.NOBCKPOGHEB ", + "method": "internal void b__0()", + "why": "Unknown-library detection callback (the injected hook library is one). Skipped, as on PC (PC closure class is PNHDGEKCJCH)." + }, + { + "id": "file_hash_callback", + "class": "private sealed class BEPEAHOOLDB.PBENKLNHBHL ", + "method": "internal void b__0(JFDPIJFDHAD.ACCABIOJGNE result)", + "why": "File-hash checker result callback (the APK is re-signed). Skipped, as on PC." + }, + { + "id": "toxmod_can_initialize", + "class": "public class ToxModVoiceComponent ", + "method": "public static bool CanInitializeToxMod()", + "why": "Third-party voice moderation service; forced false, as on PC." + }, + { + "id": "cheat_report", + "class": "public class FPIBGPIAOBI ", + "method": "public static LDGADANDBIO IEIHCFLCHBE(FPIBGPIAOBI.HHKINDNBLEE EHPFPOFFCEJ, string EPAIMCEIMPA, Nullable BNCEPENKLDF)", + "why": "Log-only: names any integrity report so a new one is visible in logcat." + }, + { + "id": "fatal_application_quit", + "class": "public class SessionManager ", + "method": "public static void FatalApplicationQuit(int DKOGJICFAEP, string EPAIMCEIMPA = \"\")", + "why": "Log-only: prints the quit reason and caller." + }, + { + "id": "try_application_quit", + "class": "public class SessionManager ", + "method": "public static void TryApplicationQuit(int DKOGJICFAEP)", + "why": "Log-only: prints the quit code and caller." + } + ] +} diff --git a/DorkNet.Server.Tests/QuestPlaceholderPlatformIdTests.cs b/DorkNet.Server.Tests/QuestPlaceholderPlatformIdTests.cs new file mode 100644 index 0000000..393f2bb --- /dev/null +++ b/DorkNet.Server.Tests/QuestPlaceholderPlatformIdTests.cs @@ -0,0 +1,133 @@ +using System.Net.Http.Headers; +using System.Text.Json; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Tests; + +/// +/// A Quest client patched without its own Meta app has no platform session, +/// so every headset reports the same placeholder platform user id ("1"). +/// Accounts must stay keyed by device id: two headsets get two accounts, a +/// headset gets its own account back on the next boot, and the placeholder is +/// never stored or matched by the remembered-logins lookup — otherwise every +/// Quest player would be offered every other Quest player's account. +/// +public sealed class QuestPlaceholderPlatformIdTests : IClassFixture +{ + // RecNet platform value the 2023 Quest client sends on /connect/token. + // The fix does not depend on it; any platform with a placeholder id is + // handled the same way. + private const string QuestPlatform = "1"; + private const string PlaceholderPlatformId = "1"; + + private readonly DorkNetServerFactory _factory; + + public QuestPlaceholderPlatformIdTests(DorkNetServerFactory factory) => _factory = factory; + + [Theory] + [InlineData(null, true)] + [InlineData("", true)] + [InlineData(" ", true)] + [InlineData("0", true)] + [InlineData("1", true)] + [InlineData(" 1 ", true)] + [InlineData("10", false)] + [InlineData("2173678582678296", false)] + public void Placeholder_platform_ids_are_recognised(string? platformId, bool expected) + => Assert.Equal(expected, PlayerService.IsPlaceholderPlatformId(platformId)); + + [Fact] + public async Task Two_headsets_with_the_placeholder_platform_id_get_separate_accounts() + { + using var client = Client("auth"); + var headsetA = $"quest-headset-a-{Guid.NewGuid():N}"; + var headsetB = $"quest-headset-b-{Guid.NewGuid():N}"; + + var a = await CreateAccountAsync(client, headsetA); + var b = await CreateAccountAsync(client, headsetB); + Assert.NotEqual(a, b); + + // Same headset, next boot: its own account comes back. + var aAgain = await CreateAccountAsync(client, headsetA); + Assert.Equal(a, aAgain); + } + + [Fact] + public async Task Placeholder_platform_id_never_lists_remembered_logins() + { + using var client = Client("auth"); + await CreateAccountAsync(client, $"quest-headset-{Guid.NewGuid():N}"); + await CreateAccountAsync(client, $"quest-headset-{Guid.NewGuid():N}"); + + using var response = await client.GetAsync( + $"/cachedlogin/forplatformid/{QuestPlatform}/{PlaceholderPlatformId}"); + var body = await response.Content.ReadAsStringAsync(); + Assert.True(response.IsSuccessStatusCode, + $"GET cachedlogin/forplatformid -> {(int)response.StatusCode}: {body}"); + + using var json = JsonDocument.Parse(body); + Assert.Equal(JsonValueKind.Array, json.RootElement.ValueKind); + Assert.Equal(0, json.RootElement.GetArrayLength()); + } + + [Fact] + public async Task A_real_platform_id_still_lists_its_remembered_login() + { + using var client = Client("auth"); + var realPlatformId = $"{Random.Shared.NextInt64(10_000_000_000, 99_999_999_999)}"; + var id = await CreateAccountAsync(client, $"quest-meta-{Guid.NewGuid():N}", realPlatformId); + + using var response = await client.GetAsync( + $"/cachedlogin/forplatformid/{QuestPlatform}/{realPlatformId}"); + var body = await response.Content.ReadAsStringAsync(); + Assert.True(response.IsSuccessStatusCode, + $"GET cachedlogin/forplatformid -> {(int)response.StatusCode}: {body}"); + Assert.Contains(id.ToString(), body); + } + + // The 2023 client's account-creation request (LBNJFPOLCDL: grant_type, + // client_id, platform, platform_id, device_id, ...). No username/password, + // so the server takes the device-id path. + private async Task CreateAccountAsync( + HttpClient client, string deviceId, string platformId = PlaceholderPlatformId) + { + var form = new Dictionary + { + ["grant_type"] = "create_account", + ["client_id"] = "recroom", + ["platform"] = QuestPlatform, + ["platform_id"] = platformId, + ["device_id"] = deviceId, + }; + + using var request = new HttpRequestMessage(HttpMethod.Post, "/connect/token") + { + Content = new FormUrlEncodedContent(form), + }; + request.Headers.Accept.ParseAdd("application/json"); + request.Headers.UserAgent.ParseAdd("RecRoom/2023.03.17"); + request.Headers.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21"); + + using var response = await client.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); + Assert.True(response.IsSuccessStatusCode, + $"POST /connect/token -> {(int)response.StatusCode}: {body}"); + + using var json = JsonDocument.Parse(body); + var accessToken = json.RootElement.GetProperty("access_token").GetString()!; + + using var userInfo = new HttpRequestMessage(HttpMethod.Get, "/connect/userinfo"); + userInfo.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + using var userInfoResponse = await client.SendAsync(userInfo); + userInfoResponse.EnsureSuccessStatusCode(); + using var userJson = JsonDocument.Parse(await userInfoResponse.Content.ReadAsStringAsync()); + return long.Parse(userJson.RootElement.GetProperty("sub").GetString()!); + } + + private HttpClient Client(string host) + { + var client = _factory.CreateClient(new() { AllowAutoRedirect = false }); + client.BaseAddress = new Uri($"http://{host}.{_factory.ApexDomain}"); + return client; + } +} diff --git a/DorkNet.Server/Services/PlayerService.cs b/DorkNet.Server/Services/PlayerService.cs index 8813e93..9bda070 100644 --- a/DorkNet.Server/Services/PlayerService.cs +++ b/DorkNet.Server/Services/PlayerService.cs @@ -423,6 +423,38 @@ public class PlayerService(DorkNetDbContext db, RoomService rooms, ILogger + /// True for a platform id that identifies nobody: blank, "0" (no platform + /// session) or "1". A Quest client patched without its own Meta app + /// (DorkNet.Quest, no-Meta mode) has no platform session, so it reports + /// the same placeholder user id "1" from every headset. Treating that as a + /// real id would list every Quest player's account in every other Quest + /// player's remembered-logins prompt and, with no device id, merge them + /// into one synthetic account. Placeholders are never stored on an + /// account, never matched by the cached-login lookup and never used to + /// synthesise a device id — those players are keyed by deviceId alone. + /// + public static bool IsPlaceholderPlatformId(string? platformId) + { + if (string.IsNullOrWhiteSpace(platformId)) return true; + var trimmed = platformId.Trim(); + return trimmed == "0" || trimmed == "1"; + } + + /// Lookup-only counterpart to + /// . Returns the existing player + /// row for the given deviceId, or null if none exists — never creates + /// a new account. Callers that want to enforce the admin "signups + /// disabled" toggle use this to gate the device-id login fallback + /// without accidentally minting an account. + public async Task GetByDeviceAsync(string? deviceId) + { + if (string.IsNullOrWhiteSpace(deviceId)) return null; + return await db.Players + .Include(p => p.Avatar) + .FirstOrDefaultAsync(p => p.DeviceId == deviceId); + } + /// /// Look up an existing account by deviceId, or create a new one if no /// account is bound to that device yet. This is the canonical @@ -440,26 +472,13 @@ public class PlayerService(DorkNetDbContext db, RoomService rooms, ILogger - /// Lookup-only counterpart to - /// . Returns the existing player - /// row for the given deviceId, or null if none exists — never creates - /// a new account. Callers that want to enforce the admin "signups - /// disabled" toggle use this to gate the device-id login fallback - /// without accidentally minting an account. - public async Task GetByDeviceAsync(string? deviceId) - { - if (string.IsNullOrWhiteSpace(deviceId)) return null; - return await db.Players - .Include(p => p.Avatar) - .FirstOrDefaultAsync(p => p.DeviceId == deviceId); - } - public async Task GetOrCreateByDeviceAsync( string? deviceId, int platform = 0, string? platformId = null, string? displayName = null) { + if (IsPlaceholderPlatformId(platformId)) platformId = null; if (string.IsNullOrWhiteSpace(deviceId)) { // Synthesize a stable id from the platformId or generate one so @@ -559,6 +578,7 @@ public class PlayerService(DorkNetDbContext db, RoomService rooms, ILogger public async Task> GetCachedLoginsAsync(int platform, string? platformId, int take = 10) { - if (string.IsNullOrWhiteSpace(platformId)) + if (IsPlaceholderPlatformId(platformId)) return []; return await db.Players .Where(p => p.LastPlatform == platform && p.LastPlatformId == platformId) diff --git a/NOTICE b/NOTICE index 2239a76..f035e39 100644 --- a/NOTICE +++ b/NOTICE @@ -37,3 +37,21 @@ covered by AGPL §13 — you must: changes you have made relative to the upstream version. The full text of the AGPL-3.0 license is in LICENSE. + +-------------------------------------------------------------------------- + +Third-party components + + DorkNet.Quest vendors And64InlineHook (src/third_party/And64InlineHook/), + an arm64 inline-hook helper by Rprop, under the MIT License. Its license + text is kept alongside the sources at + DorkNet.Quest/src/third_party/And64InlineHook/LICENSE. + +-------------------------------------------------------------------------- + +Acknowledgements + + RecFlare (https://github.com/recflare, by djdevin) is an independent + open-source 2023 Rec Room server and Quest client. DorkNet.Quest follows + its native-hook loading approach, and its implementation served as a + cross-check for several 2023 wire contracts. No RecFlare code is included. diff --git a/README.md b/README.md index c67f6ad..21ec6a4 100644 --- a/README.md +++ b/README.md @@ -153,10 +153,25 @@ calls so self-signed certs work. ### Quest -A Quest client for this branch is in progress and not usable yet. The -archive holds a Quest build of the same release (version `20230317`), so -Quest and PC players will be on matching versions once it lands. Until -then, this branch supports the PC client only. +`DorkNet.Quest` patches the Rec Room 2023.03.21 Quest client (versionCode +`2128210`) for sideloading onto a standalone Meta Quest. That build reports +`appVersion` `20230317`, the same version as the PC client, so Quest and PC +players can share rooms. + +The patcher injects a single native hook library into the APK. The library +redirects the Rec Room name server to your DorkNet host, supplies your +Photon AppIds, and turns off the client's certificate pinning and +image-signature checks so a self-hosted server works. A build without a +Meta app ID lets players sign in with DorkNet credentials and no Meta +account. Each hook checks a fingerprint of its target first, so the patch +refuses to apply to any other build. + +You supply your own extracted APK and OBB; no game binaries are +redistributed. Build, install and troubleshooting steps are in +[DorkNet.Quest/README.md](DorkNet.Quest/README.md), and the operator view +is in [docs/quest.md](docs/quest.md). + +> The Quest build is new and has not yet been tested on a headset. ### Debugging (opt-in — `DorkNet.DebugMod`) @@ -478,6 +493,31 @@ it across. Don't rebase across branches. --- +## Acknowledgements + +Thanks to [RecFlare](https://github.com/recflare), an independent +open-source 2023 Rec Room server by djdevin, for sharing their work in the +open. Their server and Quest client were a valuable second opinion while +building this branch: + +- **Quest:** `DorkNet.Quest` follows their approach of a single native hook + library loaded through a `DT_NEEDED` entry on `libmain.so`. Their notes + also showed which client checks a self-hosted server has to get past. +- **Wire contracts:** their implementation pointed us at several things we + then confirmed in our own client disassembly. These include the profile + edit form fields, the saved-outfit keys, the `{Success, Error}` envelope + behind `isCreationAllowedForAccount`, and the gameconfig catalogue, + including the `PlayMenuCarouselOrdering_*` format. +- **Accounts:** their warning that a Quest client without a Meta account + sends the same platform id from every headset led to the placeholder + platform-id handling in `PlayerService`. + +No RecFlare code is included in DorkNet. RecFlare targets a newer client +(20230414), so every finding was re-verified against the 2023.03.21 build +before use. + +--- + ## License [AGPL-3.0](LICENSE). Hosted forks must publish their source. diff --git a/docs/architecture.md b/docs/architecture.md index de1e13a..d57ff27 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -50,6 +50,7 @@ DorkNet.sln | `DorkNet.Services.Moderation` | Bug reporting, player reporting, sanitize, path-routed admin API, testcase routes | .NET 9 | | `DorkNet.Services.Web` | Public site, admin static host, same-origin admin browser API, feed static host, site API | .NET 9 | | `DorkNet.ClientMod` | MelonLoader 0.6.x IL2CPP mod — the client patcher, JSON config | .NET 6 | +| `DorkNet.Quest` | native hook library + build scripts for the Meta Quest client | C++/arm64 | `ClientMod` applies the client-side patches needed to point the 2020 watch at a DorkNet server: URI rewrite (`.rec.net` → configured apex), @@ -59,6 +60,13 @@ MelonLoader 0.6.x and reads its settings from a JSON config under same config: Desktop Screen-Share FPS, RRO quest party size, and Rec Royale night mode (see the README and `recroom-2023-scene-moods.md`). +`DorkNet.Quest` does the same jobs for the Meta Quest (Android/IL2CPP) +2023.03.21 client, where there is no MelonLoader: one native library +(`libdorknet.so`) is injected via a `DT_NEEDED` entry on `libmain.so` +and installs arm64 inline hooks (host rewrite, Photon ids, TLS/pin and +image-signature bypass, integrity-callback neutralisation). See +[`quest.md`](quest.md). + --- ## Server folder layout diff --git a/docs/deploy.md b/docs/deploy.md index c2c14ad..2810005 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -400,6 +400,18 @@ means losing post-cutover writes unless you migrate them back. --- +## Meta Quest client + +DorkNet also serves a sideload-patched Rec Room 2023.03.21 **Quest** client +(versionCode 2128210, appVersion `20230317` — the same version the PC client +reports, so Quest and PC players share rooms). The same DNS/TLS/Photon/image- +signing setup here serves it; the only Quest-specific server behaviour is the +placeholder-platform-id handling in `PlayerService`. See [`quest.md`](quest.md) +for the operator view and [`../DorkNet.Quest/README.md`](../DorkNet.Quest/README.md) +for building and installing the patched APK. + +--- + ## Photon Cloud Use your own Photon AppIds. Rec Room's original Photon AppIds reject diff --git a/docs/quest.md b/docs/quest.md new file mode 100644 index 0000000..5b7472c --- /dev/null +++ b/docs/quest.md @@ -0,0 +1,76 @@ +# Meta Quest client + +DorkNet can be played on a standalone Meta Quest headset using a +sideload-patched Rec Room **2023.03.21** client. The patcher and the full build +and install guide live in [`../DorkNet.Quest/`](../DorkNet.Quest/README.md); +this page is the server operator's view. + +## Which build + +| | | +| --- | --- | +| Rec Room version | `2023.03.21` (versionName `20230317`, versionCode `2128210`) | +| Package (store) | `com.AgainstGravity.RecRoom`, arm64-v8a, IL2CPP | +| Ships | single APK + a ~1.4 GB OBB | + +This build reports appVersion **`20230317`** — identical to the PC +`march-2023-03-21` client. The server hardcodes `"20230317"` in presence / +match / notification responses, so **Quest and PC players interoperate with no +server change**: the version gate (`GET api/versioncheck/v4`), the presence +`appVersion` string, and Photon room versioning all line up. Do **not** patch a +different Quest build (e.g. `20230414`) against this branch — a different +appVersion reads as "version mismatch, can't join". + +## How it connects + +There is no MelonLoader on Android, so `DorkNet.Quest` does the ClientMod + +Photon-byte-patch jobs with one native library (`libdorknet.so`) injected via a +`DT_NEEDED` entry on `libmain.so`. At runtime it: + +- rewrites `.rec.net` → `.` (for a server on + `Domain:HostStyle = hyphen`, set `ServerHost = {service}-.`) (the name server then + hands out every other service URL — same bootstrap as the PC client); +- injects the operator's Photon app ids and fixed region (`PhotonRegion`; it + must match the PC clients' `PhotonCloudRegion` or the two never meet); +- accepts the self-hosted TLS chain and image signatures; +- neutralises the integrity callbacks that would otherwise reject a re-signed, + hook-loaded sideload. + +So the **same DNS, TLS, Photon and image-signing setup from +[`deploy.md`](deploy.md) serves Quest** — every `*.` subdomain the PC +client uses must resolve and be covered by the cert. + +## Accounts without a Meta account + +If the build is made with a blank `OculusAppId` (the no-Meta-account build), the +client can't produce a real Oculus platform identity, so **every headset sends +the placeholder platform user id `1`**. The server must not treat that as an +identity, or all those players collapse into one shared account and are offered +each other's "remembered logins". + +DorkNet handles this server-side in `PlayerService` +(`IsPlaceholderPlatformId`): platform ids `0`, blank and `1` are never stored on +an account, never matched by the cached-login lookup +(`cachedlogin/forplatformid`), and never used to synthesise a device id — those +players are keyed by `deviceId` alone, exactly like the PC Steam-emulator path. +A real Meta platform id (the Meta build) still tags and lists normally. + +Thanks to [RecFlare](https://github.com/recflare) for documenting this +pitfall in their Quest client notes. + +> Covered by `DorkNet.Server.Tests/QuestPlaceholderPlatformIdTests.cs`. **Never +> add a rule that accepts platform id `1` as a real identity** — the whole +> no-Meta fleet sends the same value. + +## Operator checklist + +1. DNS + TLS for every `*.` service subdomain (see `deploy.md`). +2. Your own Photon Realtime/Voice app ids (see `deploy.md` → Photon Cloud). +3. Image signing key configured (`ImageSignatureService`), as for PC. +4. Build with `ServerHost=`, the Photon ids and region baked in, passing + `-Obb` so you get a release folder (`DorkNet-.xapk` plus + `install.cmd`/`install.sh`). Players run the installer with the headset + plugged in; the app appears as **DorkNet** under Unknown Sources. + +No server component is Quest-specific beyond the placeholder-platform-id +handling above.