Skip to content

macOS: Doorstop cannot hook Unity 6000.3 Mono games — chained-fixups header parses as all zeros #108

Description

@Ramanathan0310

Summary

On macOS/Apple Silicon, Doorstop loads successfully into a Unity 6000.3.8f1 Mono game but never installs its dlsym hook, so mono_jit_init_version is never intercepted and the target assembly never runs. BepInEx produces no LogOutput.log and no config/ — it fails completely silently.

I traced this to read_chained_fixups() in plthook_osx.c returning an all-zero dyld_chained_fixups_header for UnityPlayer.dylib. I also tested Doorstop 4.3.0, which fails at a different point for a different reason — details below, since the pair of failures narrows things down.

Environment

OS macOS 27.0 (build 26A5388g), Darwin 27.0.0
CPU Apple M4 (arm64)
Game Ages of Conflict: World War Simulator (Steam appid 2186320)
Unity 6000.3.8f1, Mono backend (libmonobdwgc-2.0.dylib)
Doorstop 4.5.0, via BepInEx 6.0.0-be.785+6abdba4 (Unity.Mono macos-x64)
Also tested Doorstop 4.3.0, via BepInEx 5.4.23.2 (macos_x64)

Both the game executable and UnityPlayer.dylib are universal (x86_64 arm64). libdoorstop.dylib from the be.785 build is also universal, so it loads natively on arm64.

Symptom

Doorstop injects fine — its PLTHOOK_DEBUG_ADDR output is emitted from inside the game process, and the game itself starts normally (Metal device created, PhysX initialised, Steam API connected). But no hook is ever installed, and no BepInEx artifacts are created:

BepInEx/
├── core/
├── patchers/
└── plugins/          <- config/ and LogOutput.log never appear

Diagnosis — Doorstop 4.5.0

plthook_osx.c takes the LC_DYLD_CHAINED_FIXUPS path for this binary (it has no LC_DYLD_INFO/LC_DYLD_INFO_ONLY). The PLTHOOK_DEBUG_FIXUPS dump shows the header parsing as effectively all zeros:

MEMORY MAP(.../Ages of Conflict.app/Contents/Frameworks/UnityPlayer.dylib)
dyld_chained_fixups_header
  fixups_version  0
  starts_offset   0
  imports_offset  0
  symbols_offset  0
  imports_count   0     <- UnityPlayer.dylib has ~5000 symbols
  imports_format  0
  symbols_format  0

With imports_count == 0 no imports are enumerated, so dlsym is never located in the GOT and no hook is installed. Notably __got itself is found — the "__got section is not found in %s" path is never hit, so this is specifically the fixups parse, not section lookup.

For reference, the same dump for libdoorstop.dylib in the same run reports imports_count 1, also wrong.

Relevant binary layout (arm64 slice of UnityPlayer.dylib)

LC_DYLD_CHAINED_FIXUPS
  dataoff  28934144
  datasize 32304

segname __LINKEDIT
  vmaddr   0x1d44000
  vmsize   0xc4000
  fileoff  28934144
  filesize 799840

sectname __got
  segname __DATA_CONST
  addr    0x1ac0000
  size    0x1cf0
  offset  28049408

Note LC_DYLD_CHAINED_FIXUPS.dataoff is exactly equal to __LINKEDIT.fileoff — the header sits at the very start of __LINKEDIT. That makes this look like a linkedit_base computation problem: reading at the intended address lands on zeroed memory. Two candidates I could not distinguish from outside:

  1. The slide + vmaddr - fileoff style base calculation misbehaving when dataoff == linkedit.fileoff.
  2. The header being read before __LINKEDIT is mapped/valid for this image.

I'm reporting the observation rather than asserting the cause — the exact offsets above should make it reproducible.

Second data point — Doorstop 4.3.0 fails differently

Testing BepInEx 5.4.23.2 (Doorstop 4.3.0, x86_64-only dylib, run under Rosetta) is useful because it fails at an earlier stage:

Image name: .../UnityPlayer.dylib
symbol count:     138e        <- parses the symbol table correctly
...
__DATA section __objc_const
__DATA section __data
__DATA section __bss
__DATA section __common       <- only __DATA sections enumerated

At the v4.3.0 tag, plthook_osx.c only walks __LINKEDIT and __DATA, and only matches sections flagged S_LAZY_SYMBOL_POINTERS. It has no LC_DYLD_CHAINED_FIXUPS handling at all. Since this binary keeps __got in __DATA_CONST and uses chained fixups (so there are no lazy-pointer sections), 4.3.0 can never find a hook target either.

So 4.5.0's __DATA_CONST/__got/chained-fixups support is clearly the right direction — it just doesn't produce a usable header on this binary.

Reproduction

  1. On Apple Silicon macOS, install BepInEx 6.0.0-be.785 (Unity.Mono macos-x64) into a Unity 6000.3.x Mono game.
  2. Set executable_name and launch via run_bepinex.sh.
  3. Observe: Doorstop debug output appears, game starts normally, no BepInEx/config or LogOutput.log is ever created.

To launch outside Steam without the game restarting itself, set SteamAppId=<appid> so SteamAPI_RestartAppIfNecessary returns false.

Ruled out

  • Architecture — identical failure natively on arm64 and forced to x86_64 under Rosetta. All of the game's dylibs and libdoorstop.dylib are universal.
  • Install layout — files at game root alongside the .app, not inside the bundle; .app code signature verifies clean.
  • Quarantinecom.apple.quarantine stripped from all extracted files.
  • DOORSTOP_MONO_LIB_PATH — present in the 4.5.0 binary's strings; pointing it directly at Contents/Frameworks/libmonobdwgc-2.0.dylib changes nothing (appears to be Windows-only in effect).
  • Steam relaunch interference — suppressed via SteamAppId; game reaches full init either way.

Why this matters

Unity 6.3 builds ship UnityPlayer.dylib as chained-fixups-only with __got in __DATA_CONST. As more titles move to Unity 6.3, this will affect every macOS Mono game, on both Intel and Apple Silicon.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions