Skip to content

bugfix(contain): Add workaround for use-after-free bug that may crash the game#2747

Open
Caball009 wants to merge 6 commits into
TheSuperHackers:mainfrom
Caball009:fix_crash_removeFromContain
Open

bugfix(contain): Add workaround for use-after-free bug that may crash the game#2747
Caball009 wants to merge 6 commits into
TheSuperHackers:mainfrom
Caball009:fix_crash_removeFromContain

Conversation

@Caball009
Copy link
Copy Markdown

@Caball009 Caball009 commented May 25, 2026

This PR provides a retail specific workaround for use-after-free bug that could crash the game under very specific conditions (see issue and related PR).

The actual crash would happen when e.g. OpenContain::m_containList is accessed after it's been destructed. STLPort and MSVC implementations for std::list rely on dynamically allocated memory, so accessing the begin / end iterator after the destruction of the list is not just undefined behavior, it crashes the game. See minimal crash reproduction: https://godbolt.org/z/T9sEb4MsK

TODO:

  • Verify that resetting the contain pointer is not an alternative (not retail compatible).
  • Replicate in Generals.

@Caball009 Caball009 added Bug Something is not working right, typically is user facing Major Severity: Minor < Major < Critical < Blocker Gen Relates to Generals ZH Relates to Zero Hour Crash This is a crash, very bad labels May 25, 2026
@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 25, 2026

Greptile Summary

This PR renames m_xferContainedByID to m_containedByID and expands its role: under RETAIL_COMPATIBLE_CRC, the field is kept continuously in sync with the container's liveness so that onDestroy can safely detect whether m_containedBy has become a dangling pointer, without dereferencing it.

  • onContainedBy now sets m_containedByID to the container's ID (or INVALID_ID if the container is already destroyed) under RETAIL_COMPATIBLE_CRC, establishing the key invariant the workaround relies on.
  • onRemovedFrom clears m_containedByID to INVALID_ID, keeping the field in sync.
  • onDestroy checks m_containedByID == INVALID_ID as a signal that m_containedBy is dangling; if so, removeFromContain is skipped to prevent accessing a freed std::list; in the safe path, findObjectByID confirms the container is alive before calling removeFromContain.
  • xfer save path is now guarded with !RETAIL_COMPATIBLE_CRC so it does not overwrite the already-maintained m_containedByID with a stale re-derivation (a previously flagged issue, now fixed).

Confidence Score: 5/5

The change is safe to merge; it introduces a well-scoped invariant (INVALID_ID equals dangling pointer) that eliminates the crash-prone removeFromContain call without touching any other code paths.

All four modification sites cooperate correctly to maintain the new invariant. The previously flagged xfer-save re-derivation hole is now properly guarded. The workaround is limited to RETAIL_COMPATIBLE_CRC builds, leaving the non-retail path unchanged.

No files require special attention.

Important Files Changed

Filename Overview
GeneralsMD/Code/GameEngine/Source/GameLogic/Object/Object.cpp Core implementation of the workaround; onContainedBy, onRemovedFrom, onDestroy, and xfer all updated correctly; previously flagged xfer-save re-derivation issue is now guarded.
Generals/Code/GameEngine/Source/GameLogic/Object/Object.cpp Mirrors the GeneralsMD changes; all four modified functions are consistent with the Zero Hour copy.
GeneralsMD/Code/GameEngine/Include/GameLogic/Object.h Member renamed from m_xferContainedByID to m_containedByID with updated documentation comment reflecting its broadened purpose.
Generals/Code/GameEngine/Include/GameLogic/Object.h Same header rename as GeneralsMD; no logic changes.

Sequence Diagram

sequenceDiagram
    participant A as Object A (Container)
    participant B as Object B (Contained)
    participant GL as GameLogic
    participant OC as OpenContain (A's module)

    Note over A,B: Normal containment setup
    A->>B: onContainedBy(A)
    B->>B: "m_containedBy = A, m_containedByID = A.getID()"

    Note over A,OC: Container destruction begins (bug scenario)
    GL->>A: destroyObject(A)
    A->>OC: onDelete() — m_containList already freed!
    OC->>B: "onContainedBy(A) where A.isDestroyed()==true"
    B->>B: "m_containedBy = A (dangling!), m_containedByID = INVALID_ID"

    Note over B,GL: Contained object is also destroyed
    GL->>B: destroyObject(B)
    B->>B: onDestroy()
    B->>B: "m_containedBy != null — enter block"
    alt "m_containedByID == INVALID_ID (container already destroyed)"
        B->>B: DEBUG_CRASH / skip removeFromContain (safe)
    else ID valid (normal path)
        B->>GL: findObjectByID(m_containedByID)
        GL-->>B: returns m_containedBy (confirmed alive)
        B->>OC: removeFromContain(B) — safe
    end
Loading

Reviews (7): Last reviewed commit: "Replicated in Generals." | Re-trigger Greptile

Comment thread GeneralsMD/Code/GameEngine/Source/GameLogic/Object/Object.cpp Outdated
Comment thread GeneralsMD/Code/GameEngine/Source/GameLogic/Object/Object.cpp
@Caball009 Caball009 force-pushed the fix_crash_removeFromContain branch 2 times, most recently from 3c40642 to 69cfc9b Compare May 25, 2026 21:32
Comment thread GeneralsMD/Code/GameEngine/Source/GameLogic/Object/Object.cpp
Comment thread GeneralsMD/Code/GameEngine/Source/GameLogic/Object/Object.cpp Outdated
Comment thread GeneralsMD/Code/GameEngine/Source/GameLogic/Object/Object.cpp
@Caball009 Caball009 force-pushed the fix_crash_removeFromContain branch from da36906 to 1af8f31 Compare May 26, 2026 09:54
Comment thread GeneralsMD/Code/GameEngine/Source/GameLogic/Object/Object.cpp
@Caball009
Copy link
Copy Markdown
Author

Caball009 commented May 26, 2026

I just did a test in multiplayer with local instances and noticed 6 places where the game would access the container object after destruction. I noticed because the game would crash if the memory is overwritten on deallocation, as is the case with RTS_DEBUG enabled. We cannot fix that, though, because it wouldn't be retail compatible. The issue is probably also more widespread than just these 6 places.

The following 5 could be rewritten like so: T* contain = x->m_containedByID == INVALID_ID ? nullptr : x->get...();

Object *containedBy = obj->getContainedBy();

Object *containedBy = source->getContainedBy();

const ContainModuleInterface *theirContain = source->getContainedBy()->getContain();

const Object *containedBy = source->getContainedBy();

const Object *containedBy = getContainedBy();

An early return if m_containedByID == INVALID_ID in the following function would be needed:

const Object* Object::getEnclosingContainedBy() const
{
for (const Object* child = this, *container = getContainedBy(); container; child = container, container = container->getContainedBy())
{
ContainModuleInterface* containModule = container->getContain();
if (containModule && containModule->isEnclosingContainerFor(child))
return container;
}
return nullptr;
}

Comment thread GeneralsMD/Code/GameEngine/Source/GameLogic/Object/Object.cpp
Comment thread GeneralsMD/Code/GameEngine/Source/GameLogic/Object/Object.cpp Outdated
Comment thread GeneralsMD/Code/GameEngine/Source/GameLogic/Object/Object.cpp
Comment thread GeneralsMD/Code/GameEngine/Source/GameLogic/Object/Object.cpp Outdated
@xezon
Copy link
Copy Markdown

xezon commented May 27, 2026

I just did a test in multiplayer with local instances and noticed 6 places where the game would access the container object after destruction. I noticed because the game would crash if the memory is overwritten on deallocation, as is the case with RTS_DEBUG enabled. We cannot fix that, though, because it wouldn't be retail compatible. The issue is probably also more widespread than just these 6 places.

The following 5 could be rewritten like so: T* contain = x->m_containedByID == INVALID_ID ? nullptr : x->get...();

Is this for a follow up or what do we do with this?

@Caball009
Copy link
Copy Markdown
Author

Caball009 commented May 31, 2026

I just did a test in multiplayer with local instances and noticed 6 places where the game would access the container object after destruction. I noticed because the game would crash if the memory is overwritten on deallocation, as is the case with RTS_DEBUG enabled. We cannot fix that, though, because it wouldn't be retail compatible. The issue is probably also more widespread than just these 6 places.
The following 5 could be rewritten like so: T* contain = x->m_containedByID == INVALID_ID ? nullptr : x->get...();

Is this for a follow up or what do we do with this?

Nothing as far as I'm concerned. It's just an explicit acknowledgement that the 'damage' for this bug is so extensive that I don't see how it can be fixed with retail compatibility, but at least it shouldn't crash.

Copy link
Copy Markdown

@xezon xezon left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs replication

@Caball009 Caball009 force-pushed the fix_crash_removeFromContain branch 2 times, most recently from 7635f14 to 756de02 Compare June 1, 2026 20:45
Resolved merge conflicts in 'Object.cpp'.
@Caball009
Copy link
Copy Markdown
Author

Replicated in Generals.

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

Labels

Bug Something is not working right, typically is user facing Crash This is a crash, very bad Gen Relates to Generals Major Severity: Minor < Major < Critical < Blocker ZH Relates to Zero Hour

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Destroyed Troop Crawler from reinforcement pad may crash the game due to use-after-free bug

2 participants