Skip to content

fix(coop): base-defense use-after-free on dangling temp_ufo - #111

Merged
NonPolynomialTim merged 1 commit into
mainfrom
fix/coop-basedef-temp-ufo-uaf
Jul 31, 2026
Merged

fix(coop): base-defense use-after-free on dangling temp_ufo#111
NonPolynomialTim merged 1 commit into
mainfrom
fix/coop-basedef-temp-ufo-uaf

Conversation

@NonPolynomialTim

@NonPolynomialTim NonPolynomialTim commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Fixes #106

Summary

Fixes a coop crash reported as "save crashes on month end" — a use-after-free
of the dangling global temp_ufo in the coop base-defense flow.

Root cause

GeoscapeState::handleBaseDefense stashes the attacking retaliation UFO in the
file-scope global Ufo* temp_ufo and, for a SEPARATE coop campaign, defers
the base-defense battle across a host↔client handshake (CoopState(77)
MAP_RESULT_HOSTsetClientSoldiersstartCoopMission). The attacking UFO is
freed during that window, so startCoopMission dereferences a dangling pointer →
0xC0000005.

The guard if (temp_ufo && …) is null-only and cannot detect a freed-but-non-null
pointer. The crash lands in BattlescapeGenerator while copying freed UFO fields —
two variants seen in the report (a memchr string-copy of getAlienRace() and a
memmove struct-copy of getCraftStats()), the signature of a UAF with heap reuse.

Main-thread stack:

BattlescapeGenerator::setAlienRace   (BattlescapeGenerator.cpp:184)
GeoscapeState::startCoopMission      (GeoscapeState.cpp:589/591)
connectionTCP::onTCPMessage          (connectionTCP.cpp:9004)
connectionTCP::updateCoopTask        (connectionTCP.cpp:2229)
Game::run                            (Game.cpp:178)

A ~Ufo chokepoint trace confirmed the armed UFO (the only UFO freed in the run) is
deleted between arm and consume — not via deleteRetaliationMission.

Fix

GeoscapeState.cpp only. Replace the raw Ufo* temp_ufo with a small snapshot
(coop flag, lon/lat, damage %, missionCustomDeploy, alienRace) captured at arm
time; startCoopMission reads the snapshot and never dereferences the UFO after the
deferral. A one-shot pending flag 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

  • New regression tools/coop_test/test_coop_basedef_temp_ufo_uaf.py (with a
    sanitized 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.py still fully green (the SHARED path the
    change 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.zip release asset
(TimeDateStamp 0x6A69AEDC, SizeOfImage 0x4A2F000 — matching the crash logs). A
local 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) is GeoscapeState::startCoopMission, read
straight from the shipped code:

exe+0x4457B2  movups xmm0, [rip + &"STR_BASE_DEFENSE"]   ; setMissionType("STR_BASE_DEFENSE")
exe+0x44585B  call   0x6562C0  (rdx = [rax+0x78])        ; getDeployment(craftStats.missionCustomDeploy)
exe+0x44586B  call   0x25E940                            ; BattlescapeGenerator::setAlienCustomDeploy
exe+0x445884  call   0x25E960  -> ret 0x445889           ; BattlescapeGenerator::setAlienRace

Fault exe+0x25e96c:

cmp qword ptr [rdx+0x18], 0xf     ; std::string SSO/capacity check; rdx = the source string

In the player's dump crash_20260730_023200: Rdx = 0x31, ExceptionInformation = [0, 0x49]
rdx+0x18 = 0x49, an exact match to the faulting address. rdx = 0x31 is garbage —
the freed temp_ufo->getAlienRace() string. The memchr variant (5 of 6 dumps) is the same
UAF via getDeployment(getCraftStats().missionCustomDeploy) hashing a freed string
(Rcx = 0x74732E0).

Both freed fields — alienRace and craftStats.missionCustomDeploy — are exactly what this
fix snapshots before the deferral.

🤖 Generated with Claude Code

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>
@NonPolynomialTim
NonPolynomialTim merged commit 0b0daea into main Jul 31, 2026
10 checks passed
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.

Crash on month end

1 participant