<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[ReversingRealms]]></title><description><![CDATA[Technical notes on software reverse engineering and the inner workings of MMORPGs — from network protocol to server emulation. Binary analysis, packet dissectio]]></description><link>https://octrys.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6aa300c96fa3735eed79847b/18b72dd0-e561-4842-b796-3116cf7a83eb.png</url><title>ReversingRealms</title><link>https://octrys.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Sun, 20 Sep 2026 17:36:41 GMT</lastBuildDate><atom:link href="https://octrys.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[ROM: Golden Age # Part 4 — The wire: framing, the game server, and the cipher hunt]]></title><description><![CDATA[Series: Reverse-engineering an MMORPG for preservation.
Auth (Part 3) gets the client a sessionKey and a game-server host. Now the central question: what does that server speak, and how does it travel]]></description><link>https://octrys.hashnode.dev/rom-golden-age-part-4-the-wire-framing-the-game-server-and-the-cipher-hunt</link><guid isPermaLink="true">https://octrys.hashnode.dev/rom-golden-age-part-4-the-wire-framing-the-game-server-and-the-cipher-hunt</guid><category><![CDATA[AI]]></category><category><![CDATA[MMORPG]]></category><category><![CDATA[reverse engineering]]></category><dc:creator><![CDATA[Alan Gregory]]></dc:creator><pubDate>Sun, 20 Sep 2026 02:48:13 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aa300c96fa3735eed79847b/d360b27d-a560-44c7-bcaa-118eb0456109.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><em>Series: Reverse-engineering an MMORPG for preservation.</em></p>
<p>Auth (<a href="part-3-auth.md">Part 3</a>) gets the client a <code>sessionKey</code> and a game-server host. Now the central question: <strong>what does that server speak, and how does it travel on the wire?</strong> This part leans on the <strong>message catalog</strong> (985 messages with opcodes, names, and types) as a given — reconstructing it on next part. Here it's the transport: framing first, then the cipher, which looked like a ten-minute job and became the hardest part of the project.</p>
<h2>The framing and the server</h2>
<p>Port <strong>17701</strong> is the game server — <strong>raw TCP</strong>, the framing came from an <strong>offline self-test</strong>: serialize a known <code>C2S_Login</code> under the client's own serializer and hexdump it —</p>
<pre><code class="language-plaintext">42 00 | 6e 30 19 2d | 0d 00 00 00 | 55 00 53 00 45 00 ...   ("USER_...", UTF-16LE)
 len       opcode        strlen

Frame  = [u16 length] [u32 opcode] [fields...]      little-endian
String = [u32 charCount] [UTF-16LE]
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/6aa300c96fa3735eed79847b/1959c69c-f8e7-4684-8250-82dd132d6959.png" alt="" style="display:block;margin:0 auto" />

<p>The leading <code>u16</code> is the <strong>total</strong> length (its own 2 bytes included), sent <strong>in the clear</strong>. A pcap of a real session confirms it: the <code>length</code> matches each packet, but the body (opcode + fields) comes out <strong>encrypted</strong>.</p>
<h2>The gift: the key travels in the clear</h2>
<p>One packet isn't encrypted — the server's <strong>first</strong>, <code>S2C_CheckConnection</code>. Its six <code>Int64</code> fields are a giveaway:</p>
<pre><code class="language-plaintext">S2C_CheckConnection { socketUID, connectionKey,
                      clientSendIV, serverSendIV,      ← per-direction IVs
                      encryptionKeyLow, encryptionKeyHigh }   ← the key
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/6aa300c96fa3735eed79847b/d05bbe5d-572f-4d09-a78b-6678a1f9b739.png" alt="" style="display:block;margin:0 auto" />

<p>The server <strong>hands over key and IVs in plaintext</strong> in the first frame (48 bytes = its whole body). Everything after is encrypted with a cipher derived from them. Great for an emulator — we'll be the server, so we pick the key. We just needed to know <em>which cipher</em>.</p>
<h2>The cipher hunt (the long part)</h2>
<ul>
<li><p><strong>Act 1 — "It's AES" (wrong).</strong> 128-bit key, non-block-aligned bodies → a stream mode. Brute-forced 2000+ AES combos (CTR/CFB/OFB, both key orderings, every IV variation). Zero hits.</p>
</li>
<li><p><strong>Act 2 — "Not AES" (wrong).</strong> RC4, Salsa20, ChaCha20 (all shipped in the client). Nothing. Brute-force exhausted — time to read the code.</p>
</li>
<li><p><strong>Act 3 — Disassembly at runtime.</strong> Themida unpacks in memory, so <code>frida-il2cpp-bridge</code></p>
<ul>
<li>an <code>address → name</code> map made the disassembly readable. The handler shows the key is <strong>transformed</strong> before use (<em>that's</em> why raw-key AES failed) and that <strong>two</strong> cipher objects are created, one per direction.</li>
</ul>
</li>
<li><p><strong>Act 4 — The key derivation.</strong> The transform is <code>RijndaelManaged</code> (CBC, zero IV, one block ⇒ ECB) with an embedded ASCII key. Hooking <code>set_Key</code> caught it — <code>c5i0u+e(1EHvE[l7</code> — and it matched on <strong>decrypt</strong>: <code>derivedKey = AES-128-ECB-decrypt("c5i0u+e(1EHvE[l7", rawKey16)</code>.</p>
</li>
<li><p><strong>Act 5 — The cipher.</strong> The class signature (128-bit key, 64-bit IV, state in 32-bit words, counter with carry) is <strong>Rabbit</strong> (RFC 4503). Confirmed by matching its keystream against the pcap.</p>
</li>
<li><p><strong>Act 6 — Block alignment.</strong> The keystream is consumed in <strong>16-byte-aligned blocks per frame</strong> (<code>ceil(len/16)*16</code>, discarding the remainder), so each frame starts on a 16-byte boundary. The first S2C frame (<code>CheckConnection</code>) is plaintext and consumes no keystream.</p>
</li>
</ul>
<h2>The recipe</h2>
<pre><code class="language-plaintext">Handshake: S2C_CheckConnection (PLAINTEXT) → socketUID, connectionKey,
           clientSendIV, serverSendIV, encryptionKeyLow, encryptionKeyHigh

rawKey16   = LE(encryptionKeyLow) || LE(encryptionKeyHigh)
derivedKey = AES-128-ECB-decrypt("c5i0u+e(1EHvE[l7", rawKey16)

Frame = [u16 length LE, plaintext] [body]
Body: Rabbit(key=derivedKey, iv=LE(sendIV)), 16-byte block-aligned per frame
  C2S iv = LE(clientSendIV)  — all frames encrypted
  S2C iv = LE(serverSendIV)  — 1st frame (CheckConnection) plaintext
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/6aa300c96fa3735eed79847b/22440fbd-5fb0-481d-8bbc-8c2bdf6af168.png" alt="" style="display:block;margin:0 auto" />

<p><strong>Result:</strong> the pcap decrypts 100% (C2S 48/48, S2C 122/122) — the whole <code>VersionCheck → SessionAuthLogin → EnterWorld → walk → Logout</code> session readable, and reproducible in any language.</p>
<p><strong>Lessons:</strong> brute-force has a ceiling; the indirection was in the <em>key</em>, not the algorithm; runtime tools beat static protections (Themida); validate every hypothesis against real data.</p>
<p><strong>Next part:</strong> how that message catalog was built — 985 messages straight from the IL2CPP metadata, without unpacking the binary.</p>
]]></content:encoded></item><item><title><![CDATA[ROM: Golden Age # Part 3 — Auth: the front door, and rebuilding it]]></title><description><![CDATA[Series: Reverse-engineering an MMORPG for preservation.
The screen lies about your options
Launch the client and the login screen offers Google and Apple. That's it. But the identity layer — redlabgam]]></description><link>https://octrys.hashnode.dev/rom-golden-age-part-3-auth-the-front-door-and-rebuilding-it</link><guid isPermaLink="true">https://octrys.hashnode.dev/rom-golden-age-part-3-auth-the-front-door-and-rebuilding-it</guid><category><![CDATA[AI]]></category><category><![CDATA[MMORPG]]></category><category><![CDATA[reverse engineering]]></category><dc:creator><![CDATA[Alan Gregory]]></dc:creator><pubDate>Fri, 18 Sep 2026 19:17:08 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aa300c96fa3735eed79847b/a15ac0fe-b318-4a80-8b5c-4b8df2066ee2.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><em>Series: Reverse-engineering an MMORPG for preservation.</em></p>
<h2>The screen lies about your options</h2>
<p>Launch the client and the login screen offers <strong>Google</strong> and <strong>Apple</strong>. That's it. But the identity layer — redlabgames' own SDK, <code>RedlabSDK</code> — knows more than the screen shows. Its identity-provider enum spells out the real menu:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6aa300c96fa3735eed79847b/20022952-58dd-4440-a27c-fd65bdb8760c.png" alt="" style="display:block;margin:0 auto" />

<pre><code class="language-plaintext">ERLIDPCode:
  NONE = 0,
  EMAIL = 1,
  GOOGLE = 2,
  APPLE = 4,
  GUEST = 99
</code></pre>
<p>Guest and email exist in the code; the build just never renders their buttons (a region/build gate). We confirmed this at runtime with <strong>frida-il2cpp-bridge</strong>: the "Choose a login method" popup (<code>CUIIDPLoginPopup</code>) has an <code>m_btnGuest</code> field sitting right there in the prefab, deactivated.</p>
<p>Let's observe the traffic captured on data-proxy to check what a login actually does.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6aa300c96fa3735eed79847b/4b333ec0-b9da-4494-8423-b508a4628d92.png" alt="" style="display:block;margin:0 auto" />

<p>The identity step, captured live:</p>
<pre><code class="language-plaintext">POST /v1/auth/pc/google/start        -&gt; { result, state, authUrl }
POST /v1/auth/guest/authorize        -&gt; { result, state, userCode }   (guest: authToken="")
POST /v1/auth/pc/&lt;provider&gt;/auth     -&gt; { result, state, userCode }   (carries the IDP token)
</code></pre>
<p>The <code>userCode</code> is a 28-character handle (Firebase-UID shape) — the stable account identity. But a <code>userCode</code> is <strong>not</strong> what the game socket wants. That comes from the regional API:</p>
<pre><code class="language-plaintext">POST /api/worldList        -&gt; the world/server list (points the game socket somewhere)
POST /api/authLogin        -&gt; { userCode, sessionKey (Int32), accountId, worldListData }
POST /api/worldList:cross  -&gt; a light cross-region list
</code></pre>
<p>And <em>that</em> closes the loop back to the protocol.</p>
<p>So the chain is: <strong>identity issues who you are (</strong><code>userCode</code><strong>); the region issues a short-lived ticket (</strong><code>sessionKey</code><strong>); the game socket checks the ticket.</strong> Two auth APIs, not one — a general identity service and a per-region game service — plus the CDN that bootstraps which region host to even talk to.</p>
<h2>The Google button never needed Google</h2>
<p>The most useful discovery was how the OAuth webview finishes. The <code>authUrl</code> from <code>/start</code> opens a webview; when the provider sign-in completes, the page calls back into a <strong>local HTTP listener the client is running</strong> — <code>CodeListenerPC</code> on <code>localhost:7077</code> — handing over the auth code:</p>
<pre><code class="language-plaintext">http://localhost:7077/auth?isSuccess=true&amp;state=&lt;state&gt;&amp;authToken=&lt;token&gt;&amp;error=0
</code></pre>
<p>Read that again: the webview's only hard requirement is to reach <code>localhost:7077</code>. It does <strong>not</strong> have to be <code>accounts.google.com</code>. If our <code>/start</code> returns an <code>authUrl</code> that points at <em>our own</em> <code>/authorize</code> page, and that page just does the <code>localhost:7077</code> callback itself, the whole Google round-trip disappears. Google login becomes <strong>offline-capable</strong> — no real Google, no secrets, no network beyond our own server.</p>
<h2>Rebuilding it as a server</h2>
<p>A single path-routing script proved the flow, but the real backend wants the three concerns split. Because the client talks to <strong>fixed hostnames on :443</strong>, the split is by hostname (a reverse proxy up front, later), not by port:</p>
<ul>
<li><p><strong>CDN</strong> — static: <code>domaindata.json</code> + patch files. Stays a plain file server.</p>
</li>
<li><p><strong>Identity (auth)</strong> — <code>/v1/auth/...</code>, owns accounts, issues <code>userCode</code>.</p>
</li>
<li><p><strong>Regional (region)</strong> — <code>/api/...</code>, issues <code>sessionKey</code>/<code>accountId</code>, owns the world list.</p>
</li>
</ul>
<p>The identity and regional services became two small Node/TypeScript (Express + SQLite) apps. The interesting decisions:</p>
<p><strong>Accounts and registration.</strong> Guest and social logins auto-provision on first sight (keyed on device/identity). We also un-hid the email path the client's enum promised: email + password, hashed with <strong>scrypt</strong> (Node built-in — no dependency), stored in SQLite. The login/register UI is the very webview page the client already opens; it just shows a real form now instead of auto-completing.</p>
<p><strong>A single-use token in the middle.</strong> The form doesn't hand the client a <code>userCode</code> directly. On success it mints a short-lived (5-minute), <strong>single-use</strong> <code>authToken</code>; the client's existing <code>/v1/auth/&lt;provider&gt;/auth</code> call then trades that token for the account's <code>userCode</code>. Register does <em>not</em> sign you in — it creates the account and sends you back to the sign-in form, so account creation and authentication stay distinct.</p>
<p><strong>The region validates against auth.</strong> <code>/api/authLogin</code> doesn't trust a <code>userCode</code> blindly — it calls the auth service to confirm the account exists and <strong>fails closed</strong> if it doesn't. Then it mints a <code>sessionKey</code>, stored with a <strong>1-day TTL</strong>.</p>
<p><strong>The game server validates against the region.</strong> This is the last link, and it's the one the raw socket needs. The region exposes an internal endpoint the game server calls on connect, in one of two flavours:</p>
<pre><code class="language-plaintext">GET  /internal/sessions/&lt;sessionKey&gt;          -&gt; reusable until it expires
POST /internal/sessions/&lt;sessionKey&gt;/consume  -&gt; single-use (second call fails)
</code></pre>
<p>Either way it returns <code>{ accountId, userCode, worldId, expiresAt }</code>, and the game server must check the returned <code>userCode</code> matches the <code>accountCode</code> the client sent on the socket — otherwise a valid ticket could be paired with someone else's account. <code>consume</code> makes the ticket one-time, so a replayed <code>sessionKey</code> is rejected atomically.</p>
<p>That is the full trust chain, each link checking the one before it:</p>
<pre><code class="language-plaintext">form login -&gt; userCode      (auth owns identity)
userCode   -&gt; sessionKey    (region validates userCode with auth, fails closed)
sessionKey -&gt; game entry     (game server validates sessionKey with region)
</code></pre>
<h2>The Api</h2>
<p>Now with this info we can create a simple api to simulate those endpoints, it's hosted on <a href="https://github.com/octrys/rom-api/">rom-api</a>.</p>
<p>It's <strong>two independent services</strong>, deliberately split by responsibility:</p>
<ul>
<li><p><strong>auth</strong> (identity) — owns the accounts. Handles <strong>email + password</strong> login/registration (scrypt-hashed), plus <strong>guest</strong> and the social providers. Issues the <code>userCode</code>, the stable account identifier. The login/register form is served inside the very OAuth webview the client already opens.</p>
</li>
<li><p><strong>region</strong> (regional game API) — issues the <strong>game session</strong> (<code>sessionKey</code>/<code>accountId</code>) and the world list. Before issuing, it validates the <code>userCode</code> against auth and <strong>fails closed</strong> if the account doesn't exist.</p>
</li>
</ul>
<p>Outside rom-api sit the <strong>CDN</strong> (static files and <code>domaindata.json</code>) and the <strong>game socket</strong> (raw TCP with its own cipher) — the latter validates the issued session by calling an internal region endpoint.</p>
<p><strong>The trust chain</strong> is the heart of the design: each link checks the one before it.</p>
<pre><code class="language-plaintext">login form  -&gt; userCode      (auth: identity)
userCode    -&gt; sessionKey    (region validates against auth, fails closed)
sessionKey  -&gt; world entry   (game server validates against region)
</code></pre>
<p>A session is valid for <strong>1 day</strong> and can be checked in <strong>single-use</strong> mode (replay protection for the same ticket). A short-lived (5-minute), single-use intermediate <code>authToken</code> bridges the form to the <code>userCode</code> exchange.</p>
<p><strong>Stack:</strong> Node.js + TypeScript (Express 5), one SQLite database per service, no heavy dependencies — hashing and IDs use only Node's built-in <code>crypto</code>. Each service runs standalone; TLS and hostname routing are handled by a reverse proxy in front (a later step).</p>
<p><strong>Status:</strong> both ends (auth and region) are implemented, tested, and committed. What remains is the game-server integration (validating the session at socket login) and hardening the <code>/internal/*</code> endpoints.</p>
<p>Now when the client clicks on google login, it shows a simple login/register form.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6aa300c96fa3735eed79847b/a5ad14e3-65d3-4946-806b-a87d79de71c1.png" alt="" style="display:block;margin:0 auto" />

<p>And after a successful login we can see our server list on the client.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6aa300c96fa3735eed79847b/49701750-2d18-4658-82be-400dff6434c4.png" alt="" style="display:block;margin:0 auto" />]]></content:encoded></item><item><title><![CDATA[ROM: Golden Age # Part 2 — The client: Unity, IL2CPP, the Themida wall, and what it fetches]]></title><description><![CDATA[Series: Reverse-engineering an MMORPG for preservation.
In Part 1 the launcher handed us the CDN hosts, the patch manifest, and the exact -env=Real command to start the game. Now we turn to the game c]]></description><link>https://octrys.hashnode.dev/rom-golden-age-part-2-the-client-unity-il2cpp-the-themida-wall-and-what-it-fetches</link><guid isPermaLink="true">https://octrys.hashnode.dev/rom-golden-age-part-2-the-client-unity-il2cpp-the-themida-wall-and-what-it-fetches</guid><category><![CDATA[AI]]></category><category><![CDATA[MMORPG]]></category><category><![CDATA[reverse engineering]]></category><dc:creator><![CDATA[Alan Gregory]]></dc:creator><pubDate>Wed, 16 Sep 2026 17:15:35 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aa300c96fa3735eed79847b/79517a13-0e0c-48d6-9dc7-015e29d38e9b.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><em>Series: Reverse-engineering an MMORPG for preservation.</em></p>
<p>In <a href="https://octrys.hashnode.dev/rom-golden-age-part-1-the-target-and-the-launcher-where-to-get-in">Part 1</a> the launcher handed us the CDN hosts, the patch manifest, and the exact <code>-env=Real</code> command to start the game. Now we turn to the game client itself — the Unity binary that ultimately speaks the network protocol we need to reconstruct. Before anything else, figure out what it's made of.</p>
<h2>First reconnaissance</h2>
<p>You need to know <strong>what you're dealing with</strong>. A glance at the client folder already tells a lot of the story:</p>
<ul>
<li><p><code>GameAssembly.dll</code> (~45 MB) and <code>UnityPlayer.dll</code> → it's <strong>Unity</strong>, and the game code is compiled with <strong>IL2CPP</strong> (C# → C++ → native), not Mono. That matters: with IL2CPP the original C# isn't there "for free," but there is a file that reconstructs almost all of it.</p>
</li>
<li><p><code>ROMGoldenAge_Data/il2cpp_data/Metadata/global-metadata.dat</code> (~34 MB) → <strong>the most important file in the project</strong>. It's where IL2CPP keeps type names, fields, methods, strings, and constants. If it's clean, you can recover the game's entire type structure — including the network protocol.</p>
</li>
<li><p><code>client/GameGuard/</code> + <code>NPGameDLL64.dll</code> → <strong>nProtect GameGuard</strong>, an anti-cheat with a kernel driver. It'll be an obstacle down the road. Worth flagging early: at startup GameGuard also makes its <strong>own</strong> network calls, to <code>*.gameguard.co.kr</code> (nProtect's anti-cheat update/live-check). Those aren't part of the game's bootstrap and don't come from anything we can patch — the packed GameGuard modules hold the host, so it never shows up in the metadata or assets. Our stance for now: <strong>ignore it and let it reach its real destination</strong> — we neither redirect nor block <code>gameguard.co.kr</code>. That matches how the whole project treats GameGuard: tolerated, not touched (the server never sends the one anti-cheat protocol message, so the client never runs the GG network handshake). It stays a live external dependency to revisit for full offline preservation.</p>
</li>
<li><p><code>lua5.3.dll</code> → part of the game logic is Lua script.</p>
</li>
<li><p>The Unity version, extractable from the metadata: <strong>2022.3.62f2</strong>.</p>
</li>
</ul>
<p>We confirmed that <code>global-metadata.dat</code> is <strong>unencrypted</strong> from the header: dumping the first bytes with a hex editor (<code>xxd</code> on the command line; HxD on Windows) shows <code>AF 1B B1 FA 1F 00 00 00</code> — the magic <code>0xFAB11BAF</code> and version <code>31</code>. A clean metadata = half the battle already won.</p>
<blockquote>
<p><strong>Tools in this section:</strong> the OS file explorer / <code>ls</code> to survey the folder, and a hex editor (<code>xxd</code>, HxD) to read the metadata header.</p>
</blockquote>
<h2>Watching the client talk: a MITM proxy</h2>
<p>The static survey tells you what's <em>in</em> the folder; it doesn't tell you what the client <em>fetches</em>. <a href="https://octrys.hashnode.dev/rom-golden-age-part-1-the-target-and-the-launcher-where-to-get-in">Part 1</a> pulled the CDN base and the patch <code>manifest.json</code> out of the launcher config — but the game client asks for more than that manifest lists, and some of the endpoints it uses aren't hardcoded anywhere on disk: they arrive at runtime in a <code>domaindata.json</code> the client downloads on boot. To see the whole picture you have to watch the wire.</p>
<p>So we stood up a small <strong>man-in-the-middle proxy</strong> — <code>data-proxy</code>, a Node/TypeScript tool — and pointed the retail client through it:</p>
<ul>
<li><p>A <strong>TLS-terminating HTTPS proxy on</strong> <code>:443</code><strong>.</strong> For every incoming SNI name it mints a leaf certificate on the fly, signed by a local CA (via <code>node-forge</code>) that we install as a trusted root on the Windows box. The client's HTTPS calls (patch/CDN, auth, launching) <strong>don't pin certificates</strong>, so once the CA is trusted the proxy reads every request and response in the clear.</p>
</li>
<li><p>Its <strong>own upstream resolver.</strong> The proxy resolves the <em>real</em> server IPs through public DNS (<code>8.8.8.8</code>) to forward traffic on — even though the client's own DNS is being redirected (next point).</p>
</li>
<li><p>An <strong>observe-only handler.</strong> For ROM it logs every request/response (and decodes WebSocket text frames) and rewrites nothing. Its only filter is an <code>ignoreRequest</code> pattern that drops the noise irrelevant to host discovery: <code>UnCheater</code> and <code>gameguard</code> (the anti-cheat's own traffic — flagged above) and the bulk <code>.dat</code>/<code>.mp4</code> asset and movie downloads.</p>
</li>
</ul>
<p>To get the client's traffic <em>into</em> the proxy without touching the client, we redirect DNS. The machine points at <a href="https://www.npmjs.com/package/dns-proxy"><code>dns-proxy</code></a> as its resolver, which answers the game's domains with the proxy's address and forwards everything else upstream. No hosts-file edits, no client patching — just "resolve <code>patch.romgoldenage.com</code> (and friends) to us." Here's a sample config</p>
<pre><code class="language-json">{
    "host": "192.168.10.2",
    "nameservers": [
        "8.8.8.8"
    ],
    "domains": {
        "romgoldenage.com":"192.168.10.2"
    }
}
</code></pre>
<blockquote>
<p><strong>Tools in this section:</strong> <code>data-proxy</code> (our Node/TypeScript MITM proxy — per-SNI leaf certs from a local CA, TLS termination, request/response + WebSocket logging) and <a href="https://www.npmjs.com/package/dns-proxy"><code>dns-proxy</code></a> to redirect the client's DNS at the proxy.</p>
</blockquote>
<h3>What the proxy revealed</h3>
<p>With the client booting through the proxy, the log <em>is</em> the client's shopping list. Beyond the launcher/patch manifests from Part 1, it pulls a set of <strong>standalone files the manifest never mentions</strong>: <code>domaindata.json</code> (the runtime endpoint table), <code>maintenances.json</code>, <code>ROMGoldenAge_WemixPay_Crypto.json</code>, and a batch of packed game-data blobs under <code>patch/Windows/</code> (<code>table.dat</code>, <code>AssetBundlesVersion.txt</code>, <code>bundlegamedata.dat</code>, <code>lobby.dat</code>, <code>tablecrypto.dat</code>, <code>defult.dat</code>) — plus two indices that fan out into hundreds more downloads: <code>MovieFile.json</code> (the cutscene list) and <code>BundleInfo.dat</code> (the asset-bundle catalog).</p>
<p>That runtime-fetched <code>domaindata.json</code> is the payoff for bothering with a proxy at all: the <strong>regional auth/billing/translate hosts and the game-server endpoint aren't strings in the binary</strong> — they exist only in that JSON, so pure static string-scanning would miss them. Cross-referenced with the hosts we <em>did</em> find hardcoded (scraped from three places in the shipped client — the IL2CPP dump, <code>global-metadata.dat</code>'s string literals, and <code>resources.assets</code>), it gives the complete infrastructure map, catalogued in <code>rom-tools</code>' <code>client/DOMAINS.md</code>. Check the <code>domaindata.json</code> content.</p>
<pre><code class="language-json">{
  "result": 0,
  "domainData": [
    {
      "region": "ASIA",
      "domainData": [
        {
          "domain_name": "AUTH",
          "domain": "live-auth-region-ap.romgoldenage.com"
        },
        {
          "domain_name": "BILLING",
          "domain": "billing-ap.romgoldenage.com"
        },
        {
          "domain_name": "TRANSLATE",
          "domain": "translation-ap.romgoldenage.com"
        }
      ]
    },
    {
      "region": "NA/SA/EU",
      "domainData": [
        {
          "domain_name": "AUTH",
          "domain": "live-auth-region-sa.romgoldenage.com"
        },
        {
          "domain_name": "BILLING",
          "domain": "billing-sa.romgoldenage.com"
        },
        {
          "domain_name": "TRANSLATE",
          "domain": "translation-sa.romgoldenage.com"
        }
      ]
    }
  ]
}
</code></pre>
<h3>Mirroring it before it's gone</h3>
<p>Discovery only matters if we keep the bytes. Everything the proxy showed the client fetching, we wired into <code>rom-tools</code>' patch mirror, <code>patcher/patch_downloader.py</code>. It walks the <code>launcher</code> and <code>patch</code> component manifests and downloads every per-file zip (verified against <code>ArchiveSize</code>, kept compressed), then mirrors the standalone files, the movie index and every movie, and the bundle index and every bundle — each verified against the size in its index and skipped if already present, so the mirror is idempotent and resumable across the ~3.7 GB. Standard library only; point it at a release and let it run. The whole reason it exists: when the servers go dark on <strong>2026-10-15</strong>, the patch CDN goes with them.</p>
<h2>The wall: Themida</h2>
<p>The obvious plan is to run a ready-made tool like <strong>Il2CppDumper</strong> or <strong>Il2CppInspector</strong>: you feed it <code>GameAssembly.dll</code> + <code>global-metadata.dat</code> and it spits out the reconstructed C# project. Except here that <strong>fails</strong>.</p>
<p>The reason: <code>GameAssembly.dll</code> is packed with <strong>Themida/WinLicense</strong> (Oreans), a commercial protector. <strong>Detect It Easy (DIE)</strong> flags the packer on sight, and a look at the section table (with DIE, CFF Explorer, or the <code>pefile</code> Python module) confirms it: the telltale sections (<code>.winlice</code>, <code>.vm_sec</code>, <code>.boot</code>) have a huge virtual size and <strong>zero bytes on disk</strong> — the real code (including the <code>Il2CppCodeRegistration</code>/<code>MetadataRegistration</code> tables the tools need) is only unpacked <strong>in memory, at runtime</strong>. On disk, the targets of the il2cpp exports point to zeroed bytes.</p>
<p>In other words: <strong>static analysis of the binary is a dead end.</strong> That sets the strategy for the rest of the series:</p>
<ol>
<li><p>You can still pull a LOT out of the <strong>clean metadata</strong> alone, without touching the packed binary (Part 3).</p>
</li>
<li><p>For what only exists in the binary (the field <strong>types</strong>), we'll need a <strong>runtime</strong> approach.</p>
</li>
</ol>
<blockquote>
<p><strong>Tools in this section:</strong> <strong>Il2CppDumper</strong> / <strong>Il2CppInspector</strong> (the ready-made dumpers that failed), <strong>Detect It Easy (DIE)</strong> to identify the packer, and a PE inspector (DIE, CFF Explorer, or <code>pefile</code>) to read the section table.</p>
</blockquote>
<h2>Rewriting the client's URLs</h2>
<p>Knowing which hosts the client calls has an obvious sequel: being able to <em>change</em> them. Repoint the client's CDN host at a server of our own and the retail client boots against our infrastructure with <strong>no proxy and no hosts-file hack</strong> — the redirect lives in the client's own files. Standing up the server that answers on the other end is a later thread; what belongs here is the client-side half: where those URLs live, and how to patch them without breaking the file.</p>
<p>The client resolves its CDN host from <strong>two</strong> baked-in strings, and every bootstrap URL is built as <code>String.Format("{0}/{1}", base, path)</code> from one of them:</p>
<table>
<thead>
<tr>
<th>Base</th>
<th>Lives in</th>
<th>String</th>
<th>Builds</th>
</tr>
</thead>
<tbody><tr>
<td><strong>A</strong></td>
<td>IL2CPP string literal in <code>global-metadata.dat</code></td>
<td><code>https://patch.romgoldenage.com/NewPCwemix/</code></td>
<td><code>/NewPCwemix/Real/patch/manifest.json</code></td>
</tr>
<tr>
<td><strong>B</strong></td>
<td><code>ProjectSettingData_Crypto_Win_Live</code> in <code>resources.assets</code></td>
<td><code>https://patch.romgoldenage.com/real/</code></td>
<td><code>domaindata.json</code>, <code>maintenances.json</code>, WemixPay, <code>/real/patch/Windows/*</code> (and the <code>auth.romgoldenage.com</code> host alongside)</td>
</tr>
</tbody></table>
<p>Both point at the same host, in different files and formats — so redirecting the client means editing <strong>both</strong>. And because everything derives from these two bases, one host swap cascades through the whole bootstrap: base B's <code>domaindata.json</code> is where the regional auth and the game-server endpoint come from — the runtime-only hosts the proxy surfaced above — so patching the host pulls patch, auth, and game along with it.</p>
<p>Two small <code>rom-tools</code> scripts do the edits, and the interesting part is that each file format forces a different in-place trick:</p>
<ul>
<li><p><code>client/metadata_host.py</code> <strong>(base A).</strong> An IL2CPP string literal is <code>{ u32 length; i32 dataIndex }</code> plus a raw data blob, each carrying its own explicit length. So the edit is simply: overwrite the bytes and rewrite the length field — <strong>no offset rebuild</strong> — as long as the new string is <code>&lt;=</code> the original (a host→IP swap shrinks it; <code>https</code>→<code>http</code> shrinks it further). The <code>/NewPCwemix/</code> path suffix is preserved.</p>
</li>
<li><p><code>client/resources_host.py</code> <strong>(base B).</strong> <code>resources.assets</code> is a Unity <code>SerializedFile</code>, where a <code>[u32 length][UTF-8]</code> string's length feeds every <em>later</em> byte offset — change the length and the whole file corrupts downstream. So this edit keeps the <strong>exact</strong> byte count: no padding, and the tool <strong>refuses</strong> if the chosen host yields a different length (over <code>https</code> that means a 22-character host for the <code>/real/</code> patch string, 21 for auth) rather than silently corrupt the file. A redirect webserver is then expected to strip everything before <code>/real/</code>.</p>
</li>
</ul>
<p>Both run <strong>analyse-only</strong> by default — locate the string, confirm it's unique, change nothing — and neither ever overwrites the source: they emit a patched <strong>copy</strong> you swap in, keeping the original as a backup.</p>
<blockquote>
<p><strong>Tools in this section:</strong> <code>rom-tools</code>' <code>client/metadata_host.py</code> (base A — <code>global-metadata.dat</code> literal) and <code>client/resources_host.py</code> (base B — <code>resources.assets</code> host strings), both standard-library Python that edit in place and emit a patched copy.</p>
</blockquote>
<h2>Where we stand</h2>
<p>We have the lay of the land: a Unity/IL2CPP client with open metadata, a binary armored by Themida, and a kernel anti-cheat to deal with later. We also watched the client on the wire — mapping every host and file it fetches (including the runtime-only endpoints no static scan would find) and mirroring the whole ~3.7 GB before the CDN disappears. None of it is a show-stopper — it just decides where to get in. And it points straight at the richest target that <em>doesn't</em> require defeating Themida: the clean metadata. Now we are able to modify <code>domaindata.json</code> and point to custom domains.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6aa300c96fa3735eed79847b/57972734-cdde-4138-a459-1155e155a79f.png" alt="" style="display:block;margin:0 auto" />

<p><strong>Next part:</strong> verifiy the auth options to replicate.</p>
<hr />
<p><em>Tools used in this part:</em> <code>xxd</code><em>/HxD (hex inspection),</em> <code>data-proxy</code> <em>(our Node/TypeScript MITM proxy) +</em> <a href="https://www.npmjs.com/package/dns-proxy"><code>dns-proxy</code></a> <em>(DNS redirect) to observe the client's traffic,</em> <code>rom-tools</code><em>'</em> <code>patch_downloader.py</code> <em>(CDN mirror), Il2CppDumper / Il2CppInspector (the dumpers that failed), Detect It Easy +</em> <code>pefile</code><em>/CFF Explorer (packer + PE sections).</em></p>
]]></content:encoded></item><item><title><![CDATA[ROM: Golden Age # Part 1 — The target and the launcher: where to get in]]></title><description><![CDATA[Series: Reverse-engineering an MMORPG for preservation.
ROM: Golden Age https://romgoldenage.com/ (a version of ROM: Remember Of Majesty, developed by redlabgames) is going to EOS(End of Service) on O]]></description><link>https://octrys.hashnode.dev/rom-golden-age-part-1-the-target-and-the-launcher-where-to-get-in</link><guid isPermaLink="true">https://octrys.hashnode.dev/rom-golden-age-part-1-the-target-and-the-launcher-where-to-get-in</guid><category><![CDATA[AI]]></category><category><![CDATA[MMORPG]]></category><category><![CDATA[reverse engineering]]></category><dc:creator><![CDATA[Alan Gregory]]></dc:creator><pubDate>Tue, 15 Sep 2026 04:02:21 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aa300c96fa3735eed79847b/d39f50b5-3ef7-48ab-a299-224c79d1ed45.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><em>Series: Reverse-engineering an MMORPG for preservation.</em></p>
<p>ROM: Golden Age <a href="https://romgoldenage.com/">https://romgoldenage.com/</a> (a version of ROM: Remember Of Majesty, developed by redlabgames) is going to EOS(End of Service) on October 15, 2026 and the goal of this series is to preserve it: understand the client well enough to reconstruct a private server, in the same tradition as other mmorpgs.</p>
<blockquote>
<p><strong>Legal / ethics note.</strong> All of this work is done on a client I installed legally, with the goal of <strong>preserving</strong> a game that is being discontinued. Nothing here is for cheating on live servers, pirating content, or commercial repackaging.</p>
</blockquote>
<p>One more thing about <em>how</em> this gets done: I lean on <strong>AI</strong> throughout to speed things up. To be clear about what that means — the AI isn't doing anything a skilled human reverse-engineer couldn't do; it's doing the same work <strong>faster</strong>. Parsing a binary format, sifting 30,000 string literals, writing a throwaway decryptor, cross-checking a hypothesis against a capture — all of it is well-trodden ground for a human, just slow and tedious by hand. And time is exactly what this project doesn't have: the servers are on a clock. So AI is the accelerator, not a shortcut past understanding — every result in this series is one I checked and can explain.</p>
<h2>The launcher: the easy way in</h2>
<p>The game binary is armored; the launcher, on the other hand, is the weak link: a <strong>.NET</strong> executable. And .NET decompiles <em>very</em> well. This is the base folder after the setup installation.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6aa300c96fa3735eed79847b/b8da7917-87e7-41e8-a9df-d6f3bcb39289.png" alt="" style="display:block;margin:0 auto" />

<p><code>ROMGoldenAge_Launcher.exe</code> is a <strong>.NET 8 single-file bundle</strong> (WPF). "Single file" means the assemblies are embedded and compressed inside the exe. We unpacked the bundle into its DLLs with <code>single-file-extractor</code> (the <code>sinbad</code>/<code>SingleFileExtractor</code> tool for the .NET bundle format), then ran <code>ilspycmd</code> (ILSpy on the command line) to reconstruct the C#:</p>
<pre><code class="language-plaintext">Launcher.dll (26.8 MB), Launcher.Core.dll, Launcher.Shared.dll  →  readable C#
</code></pre>
<blockquote>
<p><strong>Tools in this section:</strong> <code>single-file-extractor</code> to split the .NET single-file bundle, and <strong>ILSpy</strong> (<code>ilspycmd</code>) to decompile the DLLs back to C#.</p>
</blockquote>
<h3>The encrypted <code>appsettings.json</code></h3>
<p>The launcher config ships with values in the form <code>ENC: &lt;base64&gt;</code>. Searching through the decompiled code, the crypto routine shows up in full in <code>Settings.cs</code>. It's <strong>AES-256-CBC</strong> with a homegrown key derivation from four embedded phrases (<code>p1</code>..<code>p4</code>):</p>
<pre><code class="language-csharp">private static (byte[] Key, byte[] Iv) GetCryptoKeys()
{
    string s = p1 + p4 + p2 + p3;                       // specific concat order
    using SHA256 sha = SHA256.Create();
    byte[] key = sha.ComputeHash(Encoding.UTF8.GetBytes(s)); // 32 bytes -&gt; AES-256
    byte[] iv  = new byte[16];
    Array.Copy(key, 0, iv, 0, 16);                      // iv = first 16 bytes of the hash
    return (key, iv);
}

public static string Decrypt(string cipherText)
{
    if (string.IsNullOrEmpty(cipherText) || !cipherText.StartsWith("ENC: "))
        return cipherText;                              // plaintext passthrough

    var (key, iv) = GetCryptoKeys();
    string b64 = cipherText.Substring(5).Trim();        // strip "ENC: "

    using Aes aes = Aes.Create();                       // default: CBC + PKCS7
    aes.Key = key; aes.IV = iv;
    using var ms = new MemoryStream(Convert.FromBase64String(b64));
    using var cs = new CryptoStream(ms, aes.CreateDecryptor(), CryptoStreamMode.Read);
    using var sr = new StreamReader(cs);
    return sr.ReadToEnd();
}
</code></pre>
<pre><code class="language-plaintext">s   = p1 + p4 + p2 + p3          # concatenation in a specific order
key = SHA256(s)                  # 32 bytes → AES-256
iv  = key[:16]                   # the first 16 bytes of the hash
# value = "ENC: " + base64(AES-CBC(plaintext)), PKCS7
</code></pre>
<p>With the four phrases (which are in the code) we reproduce the decryption in a short <strong>Python</strong> script (using <strong>pycryptodome</strong> for the AES) and read the config in the clear. What it reveals:</p>
<ul>
<li><p><strong>Production CDN (AWS S3/CloudFront):</strong> <code>patch.romgoldenage.com</code>, with the path <code>NewPCwemix/Real/{launcher|patch|web}</code>. ("wemix" = the edition with the WEMIX blockchain SDK; "Real" = production.)</p>
</li>
<li><p>The game's <strong>patch manifest</strong>: <code>.../Real/patch/manifest.json</code> — version <strong>1.4.0</strong>, 123 files, ~3.74 GB uncompressed. Each file downloads from <code>&lt;base&gt;/&lt;file&gt;.zip</code>.</p>
</li>
</ul>
<pre><code class="language-json">{
  "Launcher": {
    "Version": "ver 1.4.0.5 (Crypto)",
    "HomepageUrl": "https://patch.romgoldenage.com/NewPCwemix/Real/web/{0}/index.html"
  },
  "SelfUpdate": {
    "Enabled": true,
    "RequireSignature": false,
    "ManifestUrl": "https://patch.romgoldenage.com/NewPCwemix/Real/launcher/manifest.json",
    "BaseUrl": "https://patch.romgoldenage.com/NewPCwemix/Real/launcher",
    "PublicKeyPem": "manifest_pub.pem",
    "UpdaterExe": "Updater.exe",
    "LocalManifestName": "launcher_local_manifest.json"
  },
  "GameUpdate": {
    "ManifestUrl": "https://patch.romgoldenage.com/NewPCwemix/Real/patch/manifest.json",
    "BaseUrl": "https://patch.romgoldenage.com/NewPCwemix/Real/patch",
    "InstallRoot": "client",
    "GameName": "ROMGoldenAge.exe",
    "TempRoot": "tmp"
  }
}
</code></pre>
<blockquote>
<p>Preservation note: these manifests and binaries should be <strong>mirrored before the shutdown</strong>.</p>
</blockquote>
<h3>"NewPCwemix" and the Cross build</h3>
<p>The <code>NewPCwemix</code> in the path is literally the internal build name (New PC, WEMIX edition). There's a second build, <strong>Cross</strong> (<code>NewPCcross/...</code>), with its own web3 stack and cross-server plumbing. We downloaded its metadata too and compared the protocol: <strong>identical</strong> — same 985 messages, same IDs, same fields. A single emulator serves both. The builds only differ in the account/wallet/store shell.</p>
<h3>How to start the game without the launcher</h3>
<p>Practical question: to instrument the game (and later point it at our server), I need to be able to run it directly. What does the launcher do when you click "play"? In the decompiled <code>GameUpdateClient.LaunchGame()</code>, the answer is disappointingly simple: it runs the client passing <strong>only</strong> one argument:</p>
<pre><code class="language-plaintext">client\ROMGoldenAge.exe -env=Real
</code></pre>
<p>No token, no environment variable, no parent-process check. The game only has a single-instance mutex. In other words, <code>-env=Real</code> is a complete launch. That'll be gold in the future, when we instrument the process with Frida.</p>
<h2>Where we stand</h2>
<p>From the launcher alone we've already pulled: how the game updates, from where (CDN + versions), and the exact command to start the game without the launcher — all without touching the protected game binary.</p>
<p><strong>Next part:</strong> the game client itself — Unity/IL2CPP, its clean metadata, and the Themida wall that makes the main binary a hard target. It's where the reverse engineering really begins.</p>
<h2>Postscript: our own tooling — <code>rom-tools</code></h2>
<p>Everything the launcher gave us — the CDN hosts, the patch manifest, the <code>-env=Real</code> launch — is also what we need to eventually point the client at <em>our</em> server instead of production. We collected that work into a companion repository, <code>rom-tools</code>, with the tooling we built to <strong>redirect the launcher and the patcher</strong> away from the official CDN stand up a local server that answers the bootstrap/patch requests, so the retail client boots against our infrastructure with no proxy or hosts-file hacks. It's hosted on <a href="https://github.com/octrys/rom-tools/">https://github.com/octrys/rom-tools/</a></p>
<hr />
<p><em>Tools used in this part:</em> <code>xxd</code><em>/HxD (hex inspection), Il2CppDumper / Il2CppInspector (the dumpers that failed), Detect It Easy +</em> <code>pefile</code><em>/CFF Explorer (packer + PE sections),</em> <code>single-file-extractor</code> <em>(.NET bundle), ILSpy/</em><code>ilspycmd</code> <em>(decompilation), Python + pycryptodome (config decryption).</em></p>
]]></content:encoded></item></channel></rss>