Skip to content

Instantly share code, notes, and snippets.

@karol-broda
Last active January 17, 2026 08:19
Show Gist options
  • Select an option

  • Save karol-broda/3491df132865ffb3a3cddcde1848fdd9 to your computer and use it in GitHub Desktop.

Select an option

Save karol-broda/3491df132865ffb3a3cddcde1848fdd9 to your computer and use it in GitHub Desktop.
hytale analyzing

hytale launcher

wanted to see if theres a way to disable the self-update since it fails on nixos anyway.

binary lives at ~/.local/share/Hytale/install/release/package/launcher/*/hytale-launcher

finding env vars

nix-shell -p binutils

bin=~/.local/share/Hytale/install/release/package/launcher/2026.01.14-cabac20/hytale-launcher

strings $bin | grep -oE "HYTALE_LAUNCHER_[A-Z_]+" | sort -u

found:

  • HYTALE_LAUNCHER_DEBUG_LOGGING
  • HYTALE_LAUNCHER_OFFLINE_MODE
  • HYTALE_LAUNCHER_ENABLE_KEYRING
  • HYTALE_LAUNCHER_NO_TEST_RUN_BINARIES
  • HYTALE_LAUNCHER_OS
  • HYTALE_LAUNCHER_ARCH

looking for update disable

strings $bin | grep -iE "auto.?update|disable.?update|skip.?update|no.?update"

nothing useful.

strings $bin | grep -iE "self.?update"

found references to selfupdate package and CanSelfUpdate function but no toggle.

debug logging

running with HYTALE_LAUNCHER_DEBUG_LOGGING=1 shows whats happening:

  • downloads update to ~/.local/share/Hytale/downloads/
  • extracts to ~/.local/share/Hytale/install/release/package/launcher//
  • new binary tries to overwrite the original at the nix store path
  • fails because read-only, but keeps running fine

directory structure

ls -la ~/.local/share/Hytale/
account.dat - encrypted blob, probably auth tokens
downloads/ - temp downloads
eula.txt - legal
hytale-launcher.log - main log
install/ - game/launcher/jre installs
third-party-licenses.txt
UserData/ - saves, settings, logs

the install dir has:

install/release/package/
├── game/latest/Client/ - actual game binary + libs
├── jre/latest - bundled temurin 25.0.1+8-LTS
├── launcher/ - launcher versions (keeps old ones)
└── sig/ - signature files for validation

account.dat

nix-shell -p file xxd

file ~/.local/share/Hytale/account.dat  # -> "data"
xxd ~/.local/share/Hytale/account.dat | head

just random bytes, encrypted. cant read it without knowing the key/format.

game client

file ~/.local/share/Hytale/install/release/package/game/latest/Client/HytaleClient

ELF 64-bit, stripped, dynamically linked. native binary, not java.

the client loads java for the actual game server/modding stuff:

--java-exec /home/.../jre/latest/bin/java

internal code structure

go binaries leak their source paths. found these:

strings $bin | grep -E "/hytale-launcher/internal" | grep "\.go$" | sort -u

interesting files:

/home/hypixel/actions-runner/_work/hytale-launcher/hytale-launcher/internal/selfupdate/update.go
/home/hypixel/actions-runner/_work/hytale-launcher/hytale-launcher/internal/selfupdate/cleanup.go
/home/hypixel/actions-runner/_work/hytale-launcher/hytale-launcher/internal/build/env.go
/home/hypixel/actions-runner/_work/hytale-launcher/hytale-launcher/internal/keyring/keyring_linux.go
/home/hypixel/actions-runner/_work/hytale-launcher/hytale-launcher/internal/pkg/launcher.go
/home/hypixel/actions-runner/_work/hytale-launcher/hytale-launcher/internal/pkg/java.go

theyre building it on github actions (actions-runner), hypixel org.

selfupdate mechanism

found the function names:

strings $bin | grep -E "selfupdate\." | sort -u
selfupdate.Do
selfupdate.HasUpdateFlags
selfupdate.CleanupOldLauncher
selfupdate.replaceBin
selfupdate.updateBin
selfupdate.waitForProcessExit

also found this string:

skipping launcher update check since branch cannot self-update

so theres a branch check. maybe dev/internal branches cant self-update but release can.

when updating, the launcher spawns itself with these flags:

-start-pid <pid>
-source-exe <new binary path>
-dest-exe <original binary path>
-launcher-patchline <branch>
-launcher-version <version>

the new binary then waits for parent to exit, tries to overwrite dest-exe, fails on nix, continues anyway.

tech stack

strings $bin | grep -E "github\.com/[^/]+/[^/]+" | sort -u | head -20

found:

  • github.com/wailsapp/wails/v2
  • github.com/getsentry/sentry-go
  • github.com/itchio/wharf (delta patching)
  • github.com/godbus/dbus/v5
  • github.com/pkg/browser

build info in binary:

build -ldflags="-X 'hytale-launcher/internal/build.Version=2026.01.14-cabac20' -X 'hytale-launcher/internal/build.Branch=release' -w -s"

so version and branch are baked in at compile time.

bundled jre

cat ~/.local/share/Hytale/install/release/package/jre/latest/release

Temurin-25.0.1+8-LTS, confirms official recommendation is temurin 25.

network endpoints

launcher update manifest is public:

curl -s "https://launcher.hytale.com/version/release/launcher.json"
{
  "version": "2026.01.14-cabac20",
  "download_url": {
    "linux": {
      "amd64": {
        "url": "https://launcher.hytale.com/builds/release/linux/amd64/hytale-launcher-2026.01.14-cabac20.zip",
        "sha256": "6b4bfe603f707555ba7916eae381b8bb5e059d2032f7745b99cef9b3544592b3"
      }
    },
    ...
  }
}

game and java manifests 404, probably need auth or different path.

connectivity check urls from strings:

strace analysis

wanted to see how it finds its own path:

nix-shell -p strace

strace -f -s 200 -e trace=readlinkat hytale-launcher 2>&1 | grep "/proc/self/exe"
# ctrl+c once it starts
[pid 35227] readlinkat(AT_FDCWD, "/proc/self/exe", "/nix/store/...-hytale-launcher-unwrapped-.../opt/hytale-launcher/hytale-launcher", 128) = 124
[pid 35306] readlinkat(AT_FDCWD, "/proc/self/exe", "/home/karolbroda/.local/share/Hytale/install/release/package/launcher/2026.01.14-cabac20/hytale-launcher", 128) = 104

main process gets the nix store path, update helper (second pid) gets the downloaded version path. helper tries to overwrite the nix store path, fails.

LD_PRELOAD attempt

idea: intercept readlinkat to return a fake writable path instead of the nix store path.

wrote /tmp/fake_exe.c:

#define _GNU_SOURCE
#include <dlfcn.h>
#include <string.h>
#include <unistd.h>
#include <stdio.h>

ssize_t readlinkat(int dirfd, const char *pathname, char *buf, size_t bufsiz) {
    static ssize_t (*real_readlinkat)(int, const char*, char*, size_t) = NULL;
    if (!real_readlinkat) {
        real_readlinkat = dlsym(RTLD_NEXT, "readlinkat");
    }
    
    ssize_t result = real_readlinkat(dirfd, pathname, buf, bufsiz);
    
    if (pathname && strcmp(pathname, "/proc/self/exe") == 0 && result > 0) {
        if (strstr(buf, "/nix/store/") && strstr(buf, "hytale-launcher")) {
            const char *fake = "/tmp/fake-hytale-launcher";
            size_t len = strlen(fake);
            if (len < bufsiz) {
                strcpy(buf, fake);
                return len;
            }
        }
    }
    return result;
}
nix-shell -p gcc

gcc -shared -fPIC -o /tmp/fake_exe.so /tmp/fake_exe.c -ldl

touch /tmp/fake-hytale-launcher
chmod +x /tmp/fake-hytale-launcher

LD_PRELOAD=/tmp/fake_exe.so hytale-launcher

didnt work. the FHS environment (buildFHSEnv) uses bwrap which creates a new mount namespace, LD_PRELOAD doesnt propagate through. would need to modify the package.nix to pass it, and even then it would just make the launcher write to the fake path instead of actually stopping the update check.

ghidra decompilation

created project in ~/personal/hytale-launcher.gpr, imported the binary, ran auto-analysis + golang analyzer

finding update-related functions

found these in the selfupdate package:

00a4b720 : selfupdate.init
00a4bbe0 : selfupdate.(*cleanupNote).WriteFile
00a4bc80 : selfupdate.consumeCleanupNote
00a4be60 : selfupdate.CleanupOldLauncher
00a4bfe0 : selfupdate.processExists
00a4c040 : selfupdate.replaceBin
00a4c4e0 : selfupdate.updateBin
00a4c6e0 : selfupdate.waitForProcessExit
00a4c940 : selfupdate.Do

decompiled selfupdate.Do (update.go:119)

the main update function:

void hytale_launcher_internal_selfupdate_Do(void) {
  // line 120: check if source and dest paths are set
  if (DAT_01a2f880 == NULL || DAT_01a2f880[1] == 0 ||
      DAT_01a2f888 == NULL || DAT_01a2f888[1] == 0) {
    return;  // no update pending
  }
  
  // line 124: log "performing update" with source/target
  log_slog___Logger__log(..., "performing update", ...);
  
  // line 127: verify code signature of new binary
  hytale_launcher_internal_codesign_Verify(*DAT_01a2f880, ...);
  if (error) {
    // line 128-130: log to sentry, "code signature verification failed"
    return;
  }
  
  // line 133-134: wait for old process to exit
  if (DAT_01a2f878 != NULL && *DAT_01a2f878 > 0) {
    hytale_launcher_internal_selfupdate_waitForProcessExit(*DAT_01a2f878);
  }
  
  // line 138: THIS IS WHERE IT FAILS ON NIXOS
  hytale_launcher_internal_selfupdate_updateBin();
  if (error) {
    // line 140: log "failed to update"
    return;
  }
  
  // line 145: make new binary executable
  hytale_launcher_internal_ioutil_MakeExecutable(*DAT_01a2f888, ...);
  
  // line 152-156: write cleanup note for next launch
  // line 164+: spawn new binary
}

decompiled updateBin (update.go:60)

void hytale_launcher_internal_selfupdate_updateBin(void) {
  // line 79: log "updating binary" from/to
  log_slog___Logger__log(..., "updating binary", ...);
  
  // line 80: TRY TO DELETE OLD BINARY
  os_Remove(*DAT_01a2f888, ...);  // target = nix store path
  
  // line 81-83: if remove fails and its not "file not found"
  if (error && !os_underlyingErrorIs(error, os.ErrNotExist)) {
    // log "failed to remove existing executable"
    return;  // <-- NIXOS FAILS HERE with EROFS
  }
  
  // line 85: would copy new binary over old
  hytale_launcher_internal_selfupdate_replaceBin(...);
}

decompiled replaceBin (update.go:29)

void hytale_launcher_internal_selfupdate_replaceBin(src, dst) {
  // line 30: log "replacing binary" from/to
  
  // line 34: read new binary
  os_ReadFile(src);
  if (error) {
    return fmt_Errorf("error reading source binary: %w", error);
  }
  
  // line 38: write to destination (nix store)
  os_WriteFile(dst, data, 0x1a4);  // 0x1a4 = 0644 perms
  if (error) {
    return fmt_Errorf("error writing destination binary: %w", error);
  }
  
  // line 44-50: stat both to verify
}

the branch check (launcher.go:50)

found in Launcher.CheckForUpdate:

// checking if branch == "release" (7 chars) or "stage" (5 chars)
if (DAT_018b5748 != 7 || memcmp(goss_release_18b5740, "release", 7) != 0) {
  if (DAT_018b5748 != 5 || memcmp(goss_release_18b5740, "stage", 5) != 0) {
    // not release or stage branch
    log("skipping launcher update check since branch cannot self-update");
    return;
  }
}
// only release/stage branches proceed with update check

so the update check only runs for "release" or "stage" branches. dev builds skip it

launcherUpdate.selfUpdate (launcher.go:103)

this spawns the new binary:

void launcherUpdate_selfUpdate(...) {
  // line 104: get current exe path
  os_executable();
  
  // line 110: get current pid
  syscall_rawSyscallNoError(0x27, ...);  // getpid
  
  // line 108-115: build args array
  args = [
    "-start-pid", strconv.FormatInt(pid),
    "-source-exe", new_binary_path,
    "-dest-exe", current_exe_path,  // this becomes nix store path
    "-launcher-patchline", patchline,
    "-launcher-version", version
  ];
  
  // spawns new binary with these args
}

what actually happens on nixos

  1. CheckForUpdate runs (branch is "release" so it proceeds)
  2. downloads new version to ~/.local/share/Hytale/downloads/
  3. extracts to ~/.local/share/Hytale/install/release/package/launcher/<version>/
  4. calls selfUpdate() which spawns new binary with -dest-exe /nix/store/.../hytale-launcher
  5. new binary runs selfupdate.Do()
  6. updateBin() tries os.Remove("/nix/store/...")
  7. fails with EROFS (read-only filesystem)
  8. logs error, returns, launcher keeps running

go binary analysis with redress

redress is made for Go binaries:

nix-shell -p go

go install github.com/goretk/redress@latest

~/go/bin/redress info $bin
OS            EM_X86_64
Arch          amd64
Compiler      1.25.5 (2025-12-02)
Build ID      bE7GUOflrWGdy8NrL537/...
GoRoot        /home/hypixel/actions-runner/_work/_tool/go/1.25.5/x64
Main root     /home/hypixel/actions-runner/_work/hytale-launcher/hytale-launcher
-ldflags      -X 'hytale-launcher/internal/build.Version=2026.01.14-cabac20' -X 'hytale-launcher/internal/build.Branch=release' -w -s
-tags         desktop,webkit2_41,wv2runtime.download,production

the ldflags show Version and Branch are set at compile time

also checked the build package functions:

strings $bin | grep -E "^hytale-launcher/internal/build\.[A-Z]" | sort -u
build.Arch
build.CanSelfUpdate
build.ChannelVersion
build.DebugLogging
build.FakeOfflineMode
build.GetPlatform
build.IsDev
build.OS
build.TestRunBinaries

CanSelfUpdate is the gate. IsDev probably checks if Branch != "release". the message "skipping launcher update check since branch cannot self-update" confirms its a branch check

Branch is hardcoded at compile time, no way to change it at runtime

conclusion

no way to disable self-updates from outside. the binary checks CanSelfUpdate based on branch, but release branch always can. it resolves /proc/self/exe to find its real path so cant trick it with symlinks. fails gracefully though, just logs an error and keeps running

the failure point

from ghidra decompilation: os.Remove() in update.go:80 tries to delete the binary at the nix store path, gets EROFS, logs "failed to remove existing executable", returns. never even gets to the write

currently

image working with ghidra to figure out more
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment