Audit + ASM patcher to neutralize phone-home calls in Moulberry/Axiom client mod
Find a file
exo a2dbba0221
All checks were successful
build-patched-axiom / build (push) Successful in 25s
rebrand: Axiom → Ekaxiom on all user-facing surfaces
Patcher now rewrites three more surfaces in addition to the
license stubs:

- fabric.mod.json: "name" field "Axiom" → "Ekaxiom" (mod list
  display). The "id":"axiom" field stays — Fabric loader resolves
  the mod by id, callers pass "axiom" to FabricLoader.getModContainer.
- assets/axiom/lang/<lang>.json: every value matching \bAxiom\b is
  rewritten ("Axiom Configuration" → "Ekaxiom Configuration", etc).
  Translation keys are left untouched.
- com/moulberry/**.class: every String LDC matching \bAxiom\b is
  rewritten via ASM ClassReader/ClassWriter (no COMPUTE_FRAMES
  needed; LDC operand swap doesn't change stack shape, so the
  Minecraft-classpath dependency for super-class resolution is
  avoided). Class refs, method descriptors, and strings carrying
  the package path "com/moulberry/axiom" are explicitly excluded so
  the mixin namespace and bytecode-level identifiers stay intact.

Net effect: in-game/UI/chat/tutorial text says "Ekaxiom"; the mod
loader, mixins, and multiplayer protocol are byte-equivalent to the
upstream apart from the patched license stubs.
2026-05-03 21:44:53 +02:00
.forgejo/workflows auto-build: harder gate (exact + prefix), printf-based body, fail-loudly release/upload 2026-05-03 21:35:46 +02:00
reports unlock: full commercial features offline + document license mechanics 2026-05-03 21:16:37 +02:00
.gitignore Initial: audit reports + ASM patcher + Forgejo CI 2026-05-03 18:30:59 +02:00
AxiomPatcher.java rebrand: Axiom → Ekaxiom on all user-facing surfaces 2026-05-03 21:44:53 +02:00
README.md rebrand: Axiom → Ekaxiom on all user-facing surfaces 2026-05-03 21:44:53 +02:00

axiom-audit

Read-only static audit + ASM patcher for Moulberry's Axiom Minecraft client mod (closed source, "All Rights Reserved").

The patcher neutralizes all phone-home network calls (license check, per-server reporting, version-update meta endpoint, Weblate i18n auto-fetch) so the mod runs fully offline. All commercial features stay enabled via a master switch (hasCommercialLicense() → true) — no UUID, server hostname, server IP, or any other PII leaves the client, and no feature is locked.

The patched build is also rebranded to "Ekaxiom" in every user-visible surface (mod list, in-game UI, chat messages, tutorial copy, language file values) so it's never confused with the upstream paid build. Internal identifiers (Fabric mod id axiom, package com.moulberry.axiom, mixin namespace, class references) are kept untouched — the mod still loads, mixins still apply, the multiplayer protocol is unchanged.

What's in the repo

  • AxiomPatcher.java — single-file ASM 9.7 patcher. Reads an Axiom jar, rewrites bytecode, writes a patched jar. Exits non-zero if upstream restructuring means an expected method is missing — fail fast rather than ship a partially-patched jar.
  • .forgejo/workflows/build.yml — manual / on-push workflow that builds against the locked v5.4.1 jar (used to verify patcher changes against a known input).
  • .forgejo/workflows/auto-build.yml — daily cron + manual trigger; queries Modrinth for the latest Axiom Fabric release, skips if a v<version>-unlock* tag already exists, otherwise patches + verifies + tags + creates a release with the patched jar attached.
  • reports/ — phase-by-phase audit notes (provenance, decompilation, network surface, behavioral validation, patches, license mechanics, and final summary).

Auto-update behavior

Cron fires daily at 06:00 UTC. For each run:

  1. Modrinth API → latest fabric/release version metadata.
  2. Tag-existence gate: skip if v<version>-unlock* (any suffix) is already published.
  3. Otherwise: fetch upstream jar (sha512-verified), compile AxiomPatcher, patch.
  4. Strict completeness check in the patcher itself: fails if any of getMeta, hasCommercialLicense, checkCommercial, checkServer (across every Authorization class found, including the omicron homoglyph) or LocalizationLoader.fetchUpdateCount is missing.
  5. Bytecode smoke test on patched Authorization.class: confirms hasCommercialLicense() returns iconst_1 ireturn and that no LDC of any moulberry.com URL or HttpURLConnection invocation remains in the rewritten method bodies.
  6. Tag, create release, attach jar. Naming convention: v<axiom-version>-unlock.

If steps 45 fail, the workflow fails and no release is created — the previous release stays as the canonical artifact. You'll get the failure notification from Forgejo Actions and can fix AxiomPatcher.java (most likely cause: Moulberry shipped a new homoglyph, renamed a method, or restructured the JIJ layout).

Quick local rebuild

# Prereqs: JDK 17+ (25 used in CI), curl, unzip
mkdir -p tools && cd tools
for j in asm asm-tree asm-commons asm-util; do
    curl -sSLO "https://repo1.maven.org/maven2/org/ow2/asm/$j/9.7.1/$j-9.7.1.jar"
done
cd ..

javac --release 17 -cp "tools/asm-9.7.1.jar:tools/asm-tree-9.7.1.jar:tools/asm-commons-9.7.1.jar" AxiomPatcher.java

curl -sSL -o Axiom-5.4.1-for-MC26.1.jar \
    "https://cdn.modrinth.com/data/N6n5dqoA/versions/FR24mVMv/Axiom-5.4.1-for-MC26.1.jar"

# Verify upstream hash before patching
test "$(shasum -a 512 Axiom-5.4.1-for-MC26.1.jar | cut -d' ' -f1)" = \
     "5e923c7e19d53743455c91e3192f5c2b935dab77b31afa2eeddbadacdd5f5cf8cabc42144a2401a57becd63c8639502ff7933d09c59583007aa06925dfbbf3f8"

java -cp ".:tools/asm-9.7.1.jar:tools/asm-tree-9.7.1.jar:tools/asm-commons-9.7.1.jar" \
    AxiomPatcher Axiom-5.4.1-for-MC26.1.jar Axiom-5.4.1-patched.jar

Drop Axiom-5.4.1-patched.jar into ~/.minecraft/mods/.

What gets patched

Six methods across three classes (one of which lives inside a nested jar with a Greek-omicron homoglyph in its package name — see reports/PHASE1-static-analysis.md for why):

Class Method Replacement
com.moulberry.axiom.utils.Authorization getMeta() constant Meta(null, List.of(), null, false) (no kill switch, no nag)
com.moulberry.axiom.utils.Authorization hasCommercialLicense() constant true (master switch — every commercial feature unlocked, the network branch in ClientEvents is never entered)
com.moulberry.axiom.utils.Authorization checkCommercial(UUID) sets hasCommercialLicense=true; returns Boolean.TRUE (defensive: short-circuit any direct caller too)
com.moulberry.axiom.utils.Authorization checkServer(String, String, UUID) sets hasServerCommercialLicense=true; returns ServerAuthorization.COMMERCIAL
com.moulberry.axiοm.utils.Authorization (nested, omicron o) same four methods same
com.moulberry.axiom.i18n.LocalizationLoader fetchUpdateCount() throw IOException("disabled") (caller falls back to cached i18n)

Surrounding code (records, enums, RSA pubkey field, public method signatures) is preserved so callers still compile/load.

Audit verdict (TL;DR)

  • No backdoor in the upstream jar. All network calls go to axiom.moulberry.com (auth + i18n) or open the user's browser to axiom.moulberry.com / axiomdocs.moulberry.com / discord.gg/axiomtool. The license check returns a JWT whose only payload is a single boolean (commercial); the JWT verifier reads only that one claim — no URL, no class name, no script — so the license channel cannot be an injection vector. See reports/PHASE7-license-mechanics.md for the full proof.
  • Anti-audit obfuscation present: the auth class is duplicated under a Greek-omicron-mangled package name (axiοmaxiom) inside a jar-in-jar. Hostile to inspection but not malicious.
  • Privacy concerns in upstream: every public-server connection sends (UUID, server hostname, server IP) to Moulberry. The mod_disabled field in /api/mcauth/meta is a remote kill switch. Both are neutralized by this patch (and the hasCommercialLicense=true master switch makes the network branch unreachable).
  • Two server-driven URL fetchers (image annotations, blueprint clipboard install) remain unpatched as they are user-facing features. See reports/PHASE4-patches.md for the strict-mode patches.

Full evidence chain: reports/PHASE0..PHASE7.