Fix out of bounds access from uninitialized m_ActiveWeapon in the prediction - #207
Fix out of bounds access from uninitialized m_ActiveWeapon in the prediction#207yorushi-code wants to merge 3 commits into
Conversation
CCharacterCore::Reset() left m_ActiveWeapon and m_aWeapons untouched, so a
freshly constructed prediction character kept whatever garbage was in the heap
until CCharacter::Read() assigned a weapon.
Read() only assigns it when the snapshotted weapon is not WEAPON_NINJA, and the
server snaps WEAPON_NINJA without CHARACTERFLAG_WEAPON_NINJA for ninjajetpack
tees and for frozen tees on old clients. Such a tee never got a valid
m_ActiveWeapon, and the next CCharacter::Unfreeze() indexed
m_aWeapons[m_ActiveWeapon] (the >= 0 check has no upper bound), reading roughly
16 * garbage bytes past the object:
mov 0x408(%rdi),%eax ; m_Core.m_ActiveWeapon = 0x5105503e
test %eax,%eax
js ... ; only the >= 0 guard
shl $0x4,%rax ; * sizeof(CWeaponStat)
cmpb $0x0,0x418(%rdi,%rax,1) ; m_aWeapons[i].m_Got -> SIGSEGV
The server already sets m_ActiveWeapon = WEAPON_GUN right after Reset() in
CCharacter::Spawn(), the prediction never did.
The existing guards only checked m_ActiveWeapon >= 0, so any value past NUM_WEAPONS - 1 indexed m_aWeapons out of bounds - including an out of bounds write in CCharacter::Read(). CTuningParams::GetWeaponFireDelay() asserts on anything else than a real weapon, and the HUD indexed m_aSpriteWeaponCursors with the predicted weapon without an upper bound either. Add CCharacter::HasValidActiveWeapon() and use it in every place that indexes with m_ActiveWeapon, keeping the "-1 means no weapon" semantics unchanged.
6dfb323 to
466ecd5
Compare
|
For context: upstream fixed part of this differently. ddnet#12547 added That means you have two options: cherry-pick ddnet#12547, or take this, which fixes it in |
CCharacterCore::Reset() calls SetHookedPlayer(-1), which reads m_HookedPlayer
and m_Id before either has been assigned - the owner assigns them after
Reset(), both in CCharacter::Spawn() on the server and in the prediction
CCharacter constructor.
valgrind memcheck on a default initialized core, current master:
Conditional jump or move depends on uninitialised value(s)
at CCharacterCore::SetHookedPlayer(int) (gamecore.cpp:711)
by CCharacterCore::Reset() (gamecore.cpp:161)
Uninitialised value was created by a heap allocation
Three such reports, at gamecore.cpp:711 and :713. Nothing worse happens today
only because m_pWorld is still null at that point and short circuits the
branch that would do m_pWorld->m_apCharacters[m_HookedPlayer].
Initializing in the declaration rather than in Reset() keeps Reset()'s detach
logic intact for cores that really were hooked to someone.
|
Updated this PR: added a third commit and rewrote the description, since verifying the change upstream turned up more than I started with. Short version of what matters for TClient, given you will pull DDNet at some point: The crash is live here and not upstream. ddnet#12547 landed about a week ago and assigns There is a second issue that upstream has too. Three options, in the order I would rank them:
If you want this narrower, I can drop commit 2 (the bounds checks and the HUD clamp) and leave just the two initialization commits — say the word. |
|
Correction to what I wrote above, before you spend time on it. The root cause I gave for this crash is wrong, and I have retracted it upstream in ddnet#12653 (comment here). Short version: Going back to the coredump from the crash on this client: the object is intact — valid vtable, proximity radius What that means for this PR:
I would rather not have you merge this on a wrong premise, so treat my earlier "take this PR" recommendation as withdrawn. I am going to chase the actual corruption with a sanitizer build and come back with something that stands up. Happy to close this in the meantime if you prefer a clean queue. |
|
If a fix is available upstream, I would not merge a different fix down here |
Problem
The client segfaults in
CCharacter::Unfreeze()(prediction). Reproducible on a tee whose snapshotted weapon isWEAPON_NINJA:CCharacterCore::Reset()never initializesm_ActiveWeaponorm_aWeapons, so a freshly constructed prediction character keeps whatever was in the heap untilCCharacter::Read()assigns a weapon.Read()only assigns it when the snapshotted weapon is notWEAPON_NINJA— and the server snapsWEAPON_NINJAwithoutCHARACTERFLAG_WEAPON_NINJAfor ninjajetpack tees (m_ActiveWeaponis stillWEAPON_GUN, so the flag is not set) and for frozen tees on old clients. Such a character never gets a validm_ActiveWeapon, and the nextUnfreeze()indexesm_aWeaponsout of bounds.Note that address watchpoints cannot catch this: nothing writes the garbage, and
CGameWorld::CopyWorldre-news the prediction characters every tick.A second uninitialized read, found with valgrind
While verifying this upstream I ran memcheck on this tree as well.
Reset()also readsm_HookedPlayerandm_Idbefore anything assigns them —SetHookedPlayer(-1)atgamecore.cpp:156compares against both at:703and:705, and the owner assigns them afterReset():Three of those are
m_HookedPlayer/m_Id, the fourth is them_ActiveWeaponread above. Nothing worse happens today only becausem_pWorldis still null at that point and short circuits the branch that would evaluatem_pWorld->m_apCharacters[m_HookedPlayer]. After this PR the same probe reportsERROR SUMMARY: 0 errors from 0 contexts.Changes
Fix uninitialized m_ActiveWeapon crashing the prediction—Reset()setsm_ActiveWeapon = WEAPON_GUNand clearsm_aWeapons, the way the server already does by hand inCCharacter::Spawn().Bound-check m_ActiveWeapon before using it as an index— the guards only checked>= 0. AddsCCharacter::HasValidActiveWeapon()and uses it everywherem_ActiveWeaponindexesm_aWeapons, which also closes an out of bounds write inRead()and avoidsCTuningParams::GetWeaponFireDelay()'sdbg_assert_failed("invalid weapon").-1still means "no weapon".CHud::RenderCursor()gets the same% NUM_WEAPONSclamp the other cursor sites in that file already use.Give m_Id and m_HookedPlayer a defined initial value— default member initializers, fixing the reads above. Initializing in the declaration rather than insideReset()keepsReset()'s detach logic intact for cores that really were hooked to someone.Upstream status
Worth knowing before you next merge DDNet:
m_Core.m_ActiveWeapon = -1in the predictionCCharacterconstructor plus anelse if(m_Core.m_ActiveWeapon < 0)fallback inRead(). This tree predates that PR, which is why the crash reproduces here and not on current DDNet master.So a future DDNet merge will bring ddnet#12547 in on its own, and ddnet#12653 as well if it lands. Since these commits touch the same lines as the upstream ones, the merge should converge rather than conflict. If you would rather not carry a fork-local patch at all, the minimal alternative is cherry-picking ddnet#12547 — that stops the crash, but leaves
Reset()handing out undefinedm_ActiveWeaponand them_HookedPlayerread in place.Tests
New
src/test/gamecore_test.cppfills a core with garbage, callsReset(), and assertsm_ActiveWeaponis a valid index and no weapon is marked as got. It fails without change 1 and passes with it. Full suite 339/339. Running in daily play since 2026-08-15.The memcheck probe is a throwaway, not part of this PR; happy to attach it.
AI was used to draft the patch and this description. I reviewed all of it and verified the change independently — the unit test fails without the fix and passes with it, memcheck goes from 4 errors to 0, and the full suite passes.