fix(coop): base-defense use-after-free on dangling temp_ufo - #111
Merged
Conversation
Reported as "save crashes on month end". A coop (SEPARATE) base defense stashes the attacking retaliation UFO in the file-scope global `temp_ufo`, then DEFERS the battle across a host<->client handshake. The UFO is freed during that window, so GeoscapeState::startCoopMission dereferenced a dangling pointer -> access violation. The null-only guard `if (temp_ufo && ...)` cannot catch a freed-but-non-null pointer; the two field crash variants (memchr string-copy of getAlienRace / memmove struct-copy of getCraftStats) match a use-after-free with heap reuse. Snapshot the fields startCoopMission needs (coop flag, lon/lat, damage %, missionCustomDeploy, alienRace) at arm time in handleBaseDefense and read the snapshot; never dereference the UFO after the deferral. The one-shot `pending` flag also fixes the original's never-reset pointer. Host-side only (the client receives the streamed battle). SHARED path (immediate startCoopMission) reads the fresh snapshot unchanged. Regression test tools/coop_test/test_coop_basedef_temp_ufo_uaf.py (sanitized generic fixture, no player data): RED before this change, GREEN after; test_shared_base_defense.py still green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #106
Summary
Fixes a coop crash reported as "save crashes on month end" — a use-after-free
of the dangling global
temp_ufoin the coop base-defense flow.Root cause
GeoscapeState::handleBaseDefensestashes the attacking retaliation UFO in thefile-scope global
Ufo* temp_ufoand, for a SEPARATE coop campaign, defersthe base-defense battle across a host↔client handshake (
CoopState(77)→MAP_RESULT_HOST→setClientSoldiers→startCoopMission). The attacking UFO isfreed during that window, so
startCoopMissiondereferences a dangling pointer →0xC0000005.The guard
if (temp_ufo && …)is null-only and cannot detect a freed-but-non-nullpointer. The crash lands in
BattlescapeGeneratorwhile copying freed UFO fields —two variants seen in the report (a
memchrstring-copy ofgetAlienRace()and amemmovestruct-copy ofgetCraftStats()), the signature of a UAF with heap reuse.Main-thread stack:
A
~Ufochokepoint trace confirmed the armed UFO (the only UFO freed in the run) isdeleted between arm and consume — not via
deleteRetaliationMission.Fix
GeoscapeState.cpponly. Replace the rawUfo* temp_ufowith a small snapshot(
coopflag, lon/lat, damage %,missionCustomDeploy,alienRace) captured at armtime;
startCoopMissionreads the snapshot and never dereferences the UFO after thedeferral. A one-shot
pendingflag also fixes the original's never-reset pointer.Host-side only (the client receives the streamed battle). The SHARED path (immediate
startCoopMission) reads the fresh snapshot — behavior unchanged.Testing
tools/coop_test/test_coop_basedef_temp_ufo_uaf.py(with asanitized generic fixture, no player data): RED before this change
(host process AV, exit 2), GREEN after (base defense reaches the battle, exit 0).
Verified both directions against a pre-fix and post-fix build.
tools/coop_test/test_shared_base_defense.pystill fully green (the SHARED path thechange also touches).
Confirmed against the shipped binary
Beyond reproducing from the reporter's save, this was verified against the
byte-identical shipped binary and the player's actual crash dumps.
The shipped exe was taken from the public
openxcom-coop-2.0.0-x64.ziprelease asset(
TimeDateStamp 0x6A69AEDC,SizeOfImage 0x4A2F000— matching the crash logs). Alocal rebuild can't be RVA-matched (CI builds with VS 2026 on a since-updated
runner image), so the shipped exe was disassembled directly.
Frame #1 (
exe+0x445860/+0x445889) isGeoscapeState::startCoopMission, readstraight from the shipped code:
Fault
exe+0x25e96c:In the player's dump
crash_20260730_023200:Rdx = 0x31,ExceptionInformation = [0, 0x49]→
rdx+0x18 = 0x49, an exact match to the faulting address.rdx = 0x31is garbage —the freed
temp_ufo->getAlienRace()string. Thememchrvariant (5 of 6 dumps) is the sameUAF via
getDeployment(getCraftStats().missionCustomDeploy)hashing a freed string(
Rcx = 0x74732E0).Both freed fields —
alienRaceandcraftStats.missionCustomDeploy— are exactly what thisfix snapshots before the deferral.
🤖 Generated with Claude Code