Skip to content

fix(exploit): stop FixClientUI from racing ZR round end (#10) - #38

Closed
Rushaway wants to merge 2 commits into
masterfrom
fix/round-restart-exploit-10
Closed

Rushaway wants to merge 2 commits into
masterfrom
fix/round-restart-exploit-10

Conversation

@Rushaway

@Rushaway Rushaway commented Sep 4, 2026

Copy link
Copy Markdown
Member

Investigation — issue #10

Reported behaviour

On a ZR + TeamManager setup, players could "restart" the round by combining
thirdperson with a spec command. Root cause was never found, so PR #9
mitigated it by blocking the commands until a zombie has spawned.

Root cause

The trigger is FixClientUI():

stock void FixClientUI(int client)
{
    int currentTeam = GetClientTeam(client);
    ChangeClientTeam(client, CS_TEAM_SPECTATOR);   // <-- here
    ...
}

FixClientUI() runs from Event_PlayerDeath (ResetClient(client, true)) on
every non-infection death of a thirdperson/mirror user.

ChangeClientTeam() on CS:S goes through the mod's generic team-change
function
— per the SourceMod docs
it "will kill the player" and it re-enters CCSGameRules win-condition
checking. When the dying player is the last alive member of their team,
moving them to CS_TEAM_SPECTATOR makes the engine fire round_end at the
exact moment ZR is resolving its own round end (ZR ends a round when
"one team has zero valid living clients").
ZR sees the inconsistent/duplicated round-end and reacts by restarting the round.
The following CS_SwitchTeam(client, CS_TEAM_T) then drops the dead player onto
the zombie team, further corrupting ZR's round-end accounting / team balance.

Why thirdperson + spec specifically:

  • thirdperson/mirror is the gate — without g_bThirdPerson[client] /
    g_bMirror[client], Event_PlayerDeath returns early and FixClientUI() is
    never reached.
  • Spec (team change for alive players) + TeamManager let a player put
    themselves in the "I am the last one alive on my team" state on demand, then
    die in thirdperson to fire the doubled round_end.

This also explains issue #2 ("plugin goes crazy… slay, move to spec"): that is
ChangeClientTeam() killing the player and bouncing them through spectator.

Side finding (not fixed here)

The PR #9 mitigation lives under #if defined _zr_included, but neither the old
SourceKnight config nor the current ci.yml ever provided zombiereloaded.inc,
so that mitigation has never been compiled into a released .smx. Happy to
follow up with a separate PR that adds the include to CI and guards the block
with g_bZombieReloaded && g_bTeamManager (otherwise it would permanently block
the commands on non-TeamManager servers).

Changes

  • FixClientUI()
    • Bails out (local ClientFullUpdate() only) when g_bRoundEnding is set
      or the client's team has no other alive members — i.e. exactly the
      situations where touching teams collides with ZR's round end.
    • Uses CS_SwitchTeam() instead of ChangeClientTeam() for the HUD-refresh
      bounce: no kill, no team-change / win-condition game logic.
    • Null-guards FindConVar("zr_respawn").
  • MirrorOff() now also calls ClientFullUpdate() (parity with ThirdPersonOff()).
  • New g_bRoundEnding flag (round_end pre-hook, cleared on round_start).
  • sm_thirdperson_debug (0/1, default 0) — logs round_end, player_death
    and FixClientUI() decisions with per-team alive/total counts and which
    clients are still flagged, as requested in the issue.
  • Version 1.3.6 → 1.4.0.

Testing

Not runtime-tested (no ZR+TeamManager server on hand). Logic review + CI build.
Ideally verified on a staging ZR server with sm_thirdperson_debug 1:

  1. Become the last alive human while in !tp, die to non-claws damage → round
    should end normally (no restart), log shows FixClientUI: skipped.
  2. Normal mid-round death in !tp with living teammates → HUD still resets, no
    double death / slay.

🤖 Generated with Claude Code

The "restart round" exploit reported in #10 is triggered by FixClientUI():
it runs on every non-infection death of a thirdperson/mirror user and calls
ChangeClientTeam(client, CS_TEAM_SPECTATOR). On CS:S that native goes through
the mod's generic team-change path (it also kills the player) and re-enters
CCSGameRules win-condition checking. When the dying player is the last alive
member of their team, that fires a round_end the same moment ZR is resolving
its own round end - ZR reacts to the unexpected state by restarting the round.
The Spec plugin + TeamManager just let a player engineer the "last alive on my
team" condition on demand and then die in thirdperson.

Changes:
- FixClientUI() never touches teams while the round is ending or once the
  client's team has no other alive members; it only does a local
  ClientFullUpdate() in that case and lets ZR/TeamManager own team state.
- Replace ChangeClientTeam() with CS_SwitchTeam() for the HUD-refresh bounce:
  no kill, no team-change/win-condition game logic.
- Guard the FindConVar("zr_respawn") result against null.
- MirrorOff() now also calls ClientFullUpdate() (parity with ThirdPersonOff()).
- Add sm_thirdperson_debug (0/1): logs round_end, player_death and FixClientUI
  decisions with per-team alive counts, as requested in the issue.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings September 4, 2026 11:07

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

FixClientUI() currently performs a redundant extra ClientFullUpdate() after ResetClient() already triggers full updates via ThirdPersonOff()/MirrorOff(), which can unnecessarily amplify UI churn during player_death.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR addresses a round-restart exploit in the ThirdPerson SourceMod plugin by preventing FixClientUI() from triggering win-condition / round-end logic during sensitive timing (round end or last-alive situations), and adds diagnostics to help validate the behavior on ZR + TeamManager setups.

Changes:

  • Add g_bRoundEnding (set on round_end, cleared on round_start) and use it to gate UI refresh behavior.
  • Update FixClientUI() to avoid ChangeClientTeam() side effects by using CS_SwitchTeam() and skipping team juggling when it could race round end; add null-guard for zr_respawn.
  • Add sm_thirdperson_debug ConVar and debug logging for round_end, player_death, and FixClientUI() decisions; bump version to 1.4.0.
File summaries
File Description
addons/sourcemod/scripting/ThirdPerson.sp Introduces round-end gating + safer UI refresh flow in FixClientUI(), adds debug instrumentation and supporting helpers.
Review details

Suppressed comments (1)

addons/sourcemod/scripting/ThirdPerson.sp:354

  • ResetClient() already calls ThirdPersonOff() / MirrorOff() before FixClientUI(), and both of those functions already perform ClientFullUpdate() when the FullUpdate library is present. The unconditional ClientFullUpdate() at the end of FixClientUI() is therefore redundant and adds extra full updates during player_death, which can amplify UI churn and the behavior described in issue #2. Consider removing the trailing full update here and keeping it only in the early-return path where no team refresh occurs.

#if defined _FullUpdate_Included
	if (g_bFullUpdate)
		ClientFullUpdate(client);
#endif
}
  • Files reviewed: 1/1 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread addons/sourcemod/scripting/ThirdPerson.sp Outdated
CountTeam() walks all client slots; calling it twice (once for the
condition, once for the debug log) could report different values if team
state changed between the calls. Compute it once and reuse. Same for the
GetClientTeam() double call in the player_death debug log.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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.

2 participants