fix(movement): reject server teleports to corrupted near-origin positions
Some checks failed
Build / Build (arm64) (push) Has been cancelled
Build / Build (x86-64) (push) Has been cancelled
Build / Build (macOS arm64) (push) Has been cancelled
Build / Build (windows-arm64) (push) Has been cancelled
Build / Build (windows-x86-64) (push) Has been cancelled
Security / CodeQL (C/C++) (push) Has been cancelled
Security / Semgrep (push) Has been cancelled
Security / Sanitizer Build (ASan/UBSan) (push) Has been cancelled

The server can persist a corrupted near-origin position on map 0 (from a
faulty area-trigger destination) across sessions. On re-login it sends the
bad position via LOGIN_VERIFY_WORLD; if the player walks into the offending
trigger again the server re-teleports there, and our heartbeats reinforce
the bad save — creating a permanent teleport loop.

Defenses added:
- handleTeleportAck rejects MSG_MOVE_TELEPORT to near-origin on map 0
  (no position update, no ACK, no world reload)
- applyPlayerTransportState rejects player UPDATE_OBJECT MOVEMENT blocks
  pushing the same bad position
- sendMovement blocks heartbeats originating from near-origin so the
  server cannot persist the bad save
- 10-second area-trigger cooldown after teleport / world entry / login
  (replaces the one-shot suppress flag that re-fired on jitter)
- Immediate STOP+HEARTBEAT after teleport ACK / WORLDPORT ACK / login
  to sync the real position with the server promptly
- CMSG_AREATRIGGER firing now logged at WARNING level for diagnosis
This commit is contained in:
Kelsi 2026-04-24 17:48:49 -07:00
parent f9f02569d6
commit d138269a35
5 changed files with 97 additions and 20 deletions

View file

@ -611,6 +611,14 @@ void EntityController::applyPlayerTransportState(const UpdateBlock& block,
const std::shared_ptr<Entity>& entity,
const glm::vec3& canonicalPos, float oCanonical,
bool updateMovementInfoPos) {
// Reject server-pushed position corrections to near-origin on map 0.
// The server stores a corrupted position from a faulty area-trigger
// destination; accepting it lets heartbeats reinforce the bad save.
auto positionIsBad = [&](float x, float y) {
return owner_.getCurrentMapId() == 0 &&
std::abs(x) < 1000.0f && std::abs(y) < 1000.0f;
};
if (block.onTransport) {
// Convert transport offset from server → canonical coordinates
glm::vec3 serverOffset(block.transportX, block.transportY, block.transportZ);
@ -623,18 +631,28 @@ void EntityController::applyPlayerTransportState(const UpdateBlock& block,
owner_.movementInfoRef().y = composed.y;
owner_.movementInfoRef().z = composed.z;
} else if (updateMovementInfoPos) {
owner_.movementInfoRef().x = canonicalPos.x;
owner_.movementInfoRef().y = canonicalPos.y;
owner_.movementInfoRef().z = canonicalPos.z;
if (positionIsBad(canonicalPos.x, canonicalPos.y)) {
LOG_WARNING("REJECTED player UPDATE_OBJECT to near-origin canonical=(",
canonicalPos.x, ", ", canonicalPos.y, ", ", canonicalPos.z, ")");
} else {
owner_.movementInfoRef().x = canonicalPos.x;
owner_.movementInfoRef().y = canonicalPos.y;
owner_.movementInfoRef().z = canonicalPos.z;
}
}
LOG_INFO("Player on transport: 0x", std::hex, owner_.playerTransportGuidRef(), std::dec,
" offset=(", owner_.playerTransportOffsetRef().x, ", ", owner_.playerTransportOffsetRef().y,
", ", owner_.playerTransportOffsetRef().z, ")");
} else {
if (updateMovementInfoPos) {
owner_.movementInfoRef().x = canonicalPos.x;
owner_.movementInfoRef().y = canonicalPos.y;
owner_.movementInfoRef().z = canonicalPos.z;
if (positionIsBad(canonicalPos.x, canonicalPos.y)) {
LOG_WARNING("REJECTED player UPDATE_OBJECT to near-origin canonical=(",
canonicalPos.x, ", ", canonicalPos.y, ", ", canonicalPos.z, ")");
} else {
owner_.movementInfoRef().x = canonicalPos.x;
owner_.movementInfoRef().y = canonicalPos.y;
owner_.movementInfoRef().z = canonicalPos.z;
}
}
// Don't clear client-side M2 transport boarding (trams) —
// the server doesn't know about client-detected transport attachment.