Skip to content

Don't crash while reporting a bad liveness value - #27

Open
Treyo1928 wants to merge 1 commit into
QuestPackageManager:masterfrom
Treyo1928:fix/nonfatal-liveness-diagnostic-master
Open

Treyo1928 wants to merge 1 commit into
QuestPackageManager:masterfrom
Treyo1928:fix/nonfatal-liveness-diagnostic-master

Conversation

@Treyo1928

@Treyo1928 Treyo1928 commented Sep 18, 2026 •

Copy link
Copy Markdown

TraverseGCDescriptor's diagnostic fires when a traversed value looks corrupt, and then dereferences that value's class pointer — first via HasParentUnsafe, which reads klass->typeHierarchyDepth and indexes klass->typeHierarchy, then via logAll. Both sites only null-check the pointer.

When the class pointer really is bad — which is the case the diagnostic exists to report — that turns the report into a SIGSEGV on the AssetGarbageCollector thread. The log ends at Attempting HasParentUnsafe(...) with nothing after it, so the warning that says "THIS WILL CRASH" is itself the crash, and the information it was about to dump is lost.

Evidence

Seen on Beat Saber 1.40.8 with custom-types 0.18.4, while helping a user debug repeated crashes after finishing or quitting a song. 18 of 40 Beat Saber crashes in their tombstones were this signature — the single most common cause, ahead of an unrelated beatsaber-hook issue at 16.

Every one faulted at 0x00fffffffffffff8. That is exactly the address produced by

klass->typeHierarchy[parent->typeHierarchyDepth - 1]

when typeHierarchy is NULL and the depth byte reads 0, i.e. *(NULL - 8).

Worth stressing: the game's own code tolerates these same values. Disassembling the corresponding game path shows it reads a flag byte and skips. So this diagnostic converts a condition the game handles safely into a hard crash.

Why this applies to master

0.20.1 changed where the filter class is read from (state->filter rather than a hardcoded offset), which removed a source of false positives. But the dereference of the suspect class is unguarded either way, so the fatal path is still present on master:

if (filterClass) {                                       // only a null check
    auto ret = HasParentUnsafe(GET_CLASS(val), filterClass);
}
if (GET_CLASS(val)) {                                    // only a null check
    custom_types::logAll(GET_CLASS(val));
}

The change

Add LooksLikeValidClass() — a real Il2CppClass points its klass field back at itself and has a name — and check both class pointers before dereferencing them, logging which one looked wrong when skipping.

Everything that was already safe to dump is unchanged: the warning, the offending instance, its class and gc_desc, the field index, the value pointer, the traverse counters, and the logAll of obj. The diagnostic still reports what it was written to report; it just no longer dies partway through.

Before (stock 0.18.4 — log ends here, process gone):

WARNING! THIS WILL CRASH, DUMPING SEMANTIC INFORMATION...
LivenessState::TraverseGCDescriptor(0x7241bd4b90, ...) class: ..., ::PlayerLevelStatsData
  with val: 0x72b12c14f0 (klass: 0x72b12c10f0), idx: 5
KLASS PTR: 0x72b12c10f0
Attempting HasParentUnsafe(0x72b12c10f0, 0xb40000725696f000)...
<SIGSEGV, fault addr 0x00fffffffffffff8, thread AssetGarbageCol>

After (same input, same repro):

WARNING! THIS WILL CRASH, DUMPING SEMANTIC INFORMATION...
LivenessState::TraverseGCDescriptor(0x7241bd4b90, ...) class: ..., ::PlayerLevelStatsData
  with val: 0x72b12c14f0 (klass: 0x72b12c10f0), idx: 5
has_references: false
obj_traverse_count: 198897 / generic_obj_traverse_count: 139591
KLASS PTR: 0x72b12c10f0
Class 0x72b12c10f0 of the offending instance is not a valid Il2CppClass,
  skipping the parent check and class dump
Continuing without crashing

Testing

The equivalent change on version/v0_18_4 (same code, in register.cpp before it moved to liveness.cpp) was built and tested on a Quest 2 running Beat Saber 1.40.8, using a test mod that reproduces the condition in memory — it builds a PlayerLevelStatsData whose _levelID points at an invalid object and then runs Resources.UnloadUnusedAssets():

  • stock 0.18.4 — crashes, every run
  • patched — survives 4/4 runs, with the full diagnostic present each time

I can share that repro mod, and a PC-only version that replays the hook against the real Il2CppClass layout under qemu-aarch64 and reproduces the same fault address with no headset needed, if either would be useful.

One caveat: I could not build master locally (it needs NDK 29 and beatsaber-hook 8, which I don't have set up). The change is the same shape as the 0.18.4 one that does build and pass, but CI is the real check here.

Not addressed

  • What corrupts the value in the first place. Something frees PlayerLevelStatsData._levelID while the record stays live; that cause is still unknown. This only stops the reporting path being fatal.
  • The filter offset. Deliberately untouched — the guard makes a wrong or unexpected filter non-fatal without asserting what the right one is.

The same fix against version/v0_18_4 is in PR #28. That's the last beatsaber-hook 6.x line and the only one that runs on Beat Saber 1.40.8, which is where this is currently hitting users.

TraverseGCDescriptor's diagnostic fires when a traversed value looks
corrupt, and then dereferences that value's class pointer - via
HasParentUnsafe, which reads klass->typeHierarchyDepth and indexes
klass->typeHierarchy, and via logAll. Both sites only null-check, so when
the class pointer is genuinely bad the report becomes a SIGSEGV on the
AssetGarbageCollector thread and the log ends at "Attempting
HasParentUnsafe(...)" with nothing after it.

0.20.1 fixed where the filter class is read from, which removed a source
of false positives, but the dereference of the suspect class is unguarded
either way.

Observed on 0.18.4 / Beat Saber 1.40.8, where it accounted for 18 of 40
crashes in one user's tombstones, faulting at 0xfffffffffffffff8 - the
address produced by klass->typeHierarchy[parent->typeHierarchyDepth - 1]
when typeHierarchy is NULL and the depth byte reads 0. The game's own code
tolerates the same values; only this diagnostic dies on them.

Check both class pointers before dereferencing, and log which one looked
wrong when skipping. Everything already safe to dump is unchanged.
@Treyo1928

Copy link
Copy Markdown
Author

This is separate from PR #26. That fixed where the filter class is read from, which removed false positives. This is about the dereference of GET_CLASS(val) itself.

val can't be null here — line 204 already skips nulls. The problem is that val->klass is non-null but not a valid Il2CppClass, so both existing guards pass:

if (filterClass) { ... HasParentUnsafe(GET_CLASS(val), filterClass); }
if (GET_CLASS(val)) { custom_types::logAll(GET_CLASS(val)); }

They're null checks, and the pointer isn't null — it's garbage. HasParentUnsafe then reads klass->typeHierarchyDepth and indexes klass->typeHierarchy on it.

That's the case the diagnostic exists to report, so it fires correctly and then dies partway through the dump. From the logs of the user I was helping:

LivenessState::TraverseGCDescriptor(0x75c6d4c5a0, 0xb4000074677faec0),
  with val: 0x75c6cd9210 (klass: 0x79141ea340), idx: 5
has_references: false
Attempting HasParentUnsafe(0x79141ea340, 0xb4000075c87ac220)...

The log ends there, and the tombstone shows a fault at 0x00fffffffffffff8 — which is typeHierarchy[-1] with a NULL hierarchy and a zero depth byte.

So a correct filter offset doesn't prevent this. #26 and this one are both needed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant