Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions Server/mods/deathmatch/README.sync-manipulation-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# Sync Manipulation Guard (On-Foot)

## Why this exists

Some cheats inject client-side Lua that forges **outgoing player puresync** (position and/or velocity) while the attacker is **on foot**.
The server would otherwise accept and relay that sync to other clients, where the remote attacker ped is simulated with impossible movement and violently collides with nearby vehicles and players.

Typical attack pattern:

- attacker stays on foot
- attacker forges `api.sync.velocity(...)` (or equivalent `setElementVelocity` path)
- optionally forges `api.sync.position(...)` in the same or following packets
- velocity is only sent in some packets (`bSyncingVelocity`); position is sent every packet
- other clients receive and simulate that remote ped movement
- nearby vehicles are physically launched

This guard rejects forged on-foot puresync at the **server packet read stage**, before state is updated or packets are relayed.

## What is fixed

### 1) On-foot velocity sanity check

For player pure sync, the server rejects packets with impossible on-foot velocity magnitude:

- setting: `player_max_sync_speed` (km/h)
- default: `360`
- `0` disables the check

### 2) On-foot position delta check

Cheats can omit the velocity field and forge position only. Every on-foot puresync packet is checked against a reference position:

- last accepted puresync position when available
- otherwise the server's current element position (no unchecked "first packet" window)

When the player has **valid vehicle contact** (`bHasContact` on a vehicle that passed the existing contact radius checks), the delta is measured in **contact-relative** coordinates instead of absolute world space. That avoids false positives when surfing on a legitimately fast vehicle, where absolute movement reflects vehicle speed rather than on-foot ped speed.

The packet is rejected if the chosen reference position could not have reached the new position in the elapsed time at `player_max_sync_speed` (with a small margin). When no prior baseline exists, elapsed time defaults to one pure-sync tick (`iPureSync`, typically 100 ms).

### 3) Non-finite position rejection (NaN/Inf)

Player and vehicle pure sync position fields are rejected when coordinates are non-finite.

### 4) Main thread and sim thread parity

Player puresync is validated in both paths:

- main thread: `CPlayerPuresyncPacket::Read`
- sim thread relay: `CSimPlayerPuresyncPacket::Read`

The sim thread reads the last accepted baseline from the real `CPlayer` before validating so both paths agree on accept/reject.

Rejected packets are neither applied to server state nor relayed to other players.

## Legitimate bypasses

- **Server-authorized teleport:** `SetTeleported(true)` (spawn, `setElementPosition`, etc.) skips movement checks for the next accepted packet and clears the movement baseline.
- **In vehicle (seated):** on-foot checks apply only when the player is on foot or exiting a vehicle; vehicle gameplay is not speed-capped by this feature.
- **Vehicle contact (surf / standing on roof):** position delta uses contact-relative coordinates so fast vehicles remain playable.

## Important scope note

This guard targets **on-foot manipulation used to launch other players' vehicles and players**.

It does **not** introduce a general vehicle top speed limiter:

- `vehicle_max_sync_speed` is not used
- legitimate high-speed vehicle gameplay (500–1000+ km/h) is unaffected

## Configuration

In `mtaserver.conf`:

```xml
<player_max_sync_speed>360</player_max_sync_speed>
```

Guidance:

- Lower value: stricter anti-manipulation, higher false-positive risk
- Higher value: more permissive, weaker protection
- `0`: disabled (not recommended on public servers)

## Residual limits (why not mathematically perfect)

- **Threshold model:** The server does not run GTA physics; it sanity-checks client sync against a max on-foot speed. A cheater who stays just under 360 km/h (and the 1.35× margin) could still nudge movement slightly — that is a fundamental trade-off vs. jetpack, knockback, and high-ping jitter.
- **Authorized teleports:** `SetTeleported(true)` must bypass checks or legitimate warps break.
- **Compromised client binary:** A patched client can send anything; the meaningful fix is server-side relay rejection, not trusting the client.
- **`player_max_sync_speed = 0`:** Disables all checks.

For the documented on-foot sync-forge launch exploit (R/E scripts, position-only forge, vehicle-exit reset tricks), every relay path now runs velocity + position delta validation before apply/relay.

## Non-goals

- Not a full anti-teleport system for all movement edge cases
- Does not replace broader anti-cheat layers
- Client-side clamping is optional defense-in-depth; server validation is the primary fix
3 changes: 3 additions & 0 deletions Server/mods/deathmatch/logic/CMainConfig.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1527,6 +1527,9 @@ const std::vector<SIntSetting>& CMainConfig::GetIntSettingList()
{true, true, 50, 130, 400, "unoccupied_vehicle_syncer_distance", &g_TickRateSettings.iUnoccupiedVehicleSyncerDistance, &CMainConfig::OnTickRateChange},
{true, true, 0, 30, 130, "vehicle_contact_sync_radius", &g_TickRateSettings.iVehicleContactSyncRadius, &CMainConfig::OnTickRateChange},
{true, true, 5, 100, 500, "player_teleport_alert", &g_TickRateSettings.playerTeleportAlert, &CMainConfig::OnTickRateChange},
// Highest believable synced speed (km/h) for on-foot players. Puresync packets above this are
// dropped to stop injected ped velocities being relayed (remote vehicle launch cheats). 0 = off.
{true, true, 0, 360, 100000, "player_max_sync_speed", &g_TickRateSettings.iPlayerMaxSyncSpeed, nullptr},
{false, false, 0, 1, 2, "compact_internal_databases", &m_iCompactInternalDatabases, NULL},
{true, true, 0, 1, 2, "minclientversion_auto_update", &m_iMinClientVersionAutoUpdate, NULL},
{true, true, 0, 0, 100, "server_logic_fps_limit", &m_iServerLogicFpsLimit, NULL},
Expand Down
6 changes: 3 additions & 3 deletions Server/mods/deathmatch/logic/CPed.h
Original file line number Diff line number Diff line change
Expand Up @@ -255,9 +255,9 @@ class CPed : public CElement
float GetGravity() { return m_fGravity; }
void SetGravity(float fGravity) { m_fGravity = fGravity; }

CVehicle* GetOccupiedVehicle() { return m_pVehicle; };
unsigned int GetOccupiedVehicleSeat() { return m_uiVehicleSeat; };
CVehicle* SetOccupiedVehicle(CVehicle* pVehicle, unsigned int uiSeat);
CVehicle* GetOccupiedVehicle() { return m_pVehicle; };
unsigned int GetOccupiedVehicleSeat() { return m_uiVehicleSeat; };
virtual CVehicle* SetOccupiedVehicle(CVehicle* pVehicle, unsigned int uiSeat);

unsigned int GetVehicleAction() { return m_uiVehicleAction; };
void SetVehicleAction(unsigned int uiAction);
Expand Down
43 changes: 43 additions & 0 deletions Server/mods/deathmatch/logic/CPlayer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1112,6 +1112,49 @@ void CPlayer::SetPosition(const CVector& vecPosition)
CElement::SetPosition(vecPosition);
}

void CPlayer::SetTeleported(bool state) noexcept
{
m_teleported = state;
// Authorized warps must not inherit a stale on-foot baseline from before the teleport.
if (state)
ResetPuresyncMovementGuard();
}

CVehicle* CPlayer::SetOccupiedVehicle(CVehicle* pVehicle, unsigned int uiSeat)
{
CVehicle* pResult = CPed::SetOccupiedVehicle(pVehicle, uiSeat);
// Vehicle puresync replaces on-foot puresync; drop the baseline so exiting the vehicle
// does not compare against a position from before the ride.
if (pVehicle)
ResetPuresyncMovementGuard();
return pResult;
}

void CPlayer::NoteAcceptedPuresyncPosition(const CVector& vecAbsolutePosition, CElement* pContactElement, const CVector& vecRelativePosition)
{
m_vecLastAcceptedPuresyncPosition = vecAbsolutePosition;
m_ullLastAcceptedPuresyncTick = GetTickCount64_();
m_bHasLastAcceptedPuresyncPosition = true;
m_teleported = false;

if (pContactElement && pContactElement->GetType() == CElement::VEHICLE)
{
m_LastAcceptedPuresyncContactElementID = pContactElement->GetID();
m_vecLastAcceptedPuresyncContactRelative = vecRelativePosition;
}
else
{
m_LastAcceptedPuresyncContactElementID = INVALID_ELEMENT_ID;
}
}

void CPlayer::ResetPuresyncMovementGuard() noexcept
{
m_bHasLastAcceptedPuresyncPosition = false;
m_ullLastAcceptedPuresyncTick = 0;
m_LastAcceptedPuresyncContactElementID = INVALID_ELEMENT_ID;
}

void CPlayer::SetPlayerStat(unsigned short usStat, float fValue)
{
m_pPlayerStatsPacket->Add(usStat, fValue);
Expand Down
19 changes: 18 additions & 1 deletion Server/mods/deathmatch/logic/CPlayer.h
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,18 @@ class CPlayer final : public CPed, public CClient
bool IsRedirecting() const noexcept { return m_bIsRedirecting; }

bool GetTeleported() const noexcept { return m_teleported; }
void SetTeleported(bool state) noexcept { m_teleported = state; }
void SetTeleported(bool state) noexcept;

CVehicle* SetOccupiedVehicle(CVehicle* pVehicle, unsigned int uiSeat) override;

// Anti-cheat: last accepted on-foot puresync position used to reject forged position-only sync.
bool HasLastAcceptedPuresyncPosition() const noexcept { return m_bHasLastAcceptedPuresyncPosition; }
const CVector& GetLastAcceptedPuresyncPosition() const noexcept { return m_vecLastAcceptedPuresyncPosition; }
unsigned long long GetLastAcceptedPuresyncTick() const noexcept { return m_ullLastAcceptedPuresyncTick; }
ElementID GetLastAcceptedPuresyncContactElementID() const noexcept { return m_LastAcceptedPuresyncContactElementID; }
const CVector& GetLastAcceptedPuresyncContactRelative() const noexcept { return m_vecLastAcceptedPuresyncContactRelative; }
void NoteAcceptedPuresyncPosition(const CVector& vecAbsolutePosition, CElement* pContactElement, const CVector& vecRelativePosition);
void ResetPuresyncMovementGuard() noexcept;

protected:
bool ReadSpecialData(const int iLine) override { return true; }
Expand Down Expand Up @@ -463,4 +474,10 @@ class CPlayer final : public CPed, public CClient
SString m_strQuitReasonForLog;

bool m_teleported = false;

bool m_bHasLastAcceptedPuresyncPosition = false;
CVector m_vecLastAcceptedPuresyncPosition;
unsigned long long m_ullLastAcceptedPuresyncTick = 0;
ElementID m_LastAcceptedPuresyncContactElementID = INVALID_ELEMENT_ID;
CVector m_vecLastAcceptedPuresyncContactRelative;
};
8 changes: 8 additions & 0 deletions Server/mods/deathmatch/logic/net/CSimPlayer.h
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,14 @@ class CSimPlayer
// Flag
bool m_bDoneFirstUpdate;

// Anti-cheat: mirror CPlayer puresync movement guard for sim-thread relay validation
bool m_bHasLastAcceptedPuresyncPosition = false;
CVector m_vecLastAcceptedPuresyncPosition;
unsigned long long m_ullLastAcceptedPuresyncTick = 0;
ElementID m_LastAcceptedPuresyncContactElementID = INVALID_ELEMENT_ID;
CVector m_vecLastAcceptedPuresyncContactRelative;
bool m_bTeleported = false;

// Interlink to CPlayer object
CPlayer* m_pRealPlayer;
};
23 changes: 20 additions & 3 deletions Server/mods/deathmatch/logic/net/CSimPlayerManager.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,12 @@ void CSimPlayerManager::UpdateSimPlayer(CPlayer* pPlayer)
pSim->m_ucOccupiedVehicleSeat = uiSeat <= 0xFF ? static_cast<unsigned char>(uiSeat) : 0xFF;
}
pSim->m_fWeaponRange = pPlayer->GetWeaponRangeFromSlot();
pSim->m_bTeleported = pPlayer->GetTeleported();
pSim->m_bHasLastAcceptedPuresyncPosition = pPlayer->HasLastAcceptedPuresyncPosition();
pSim->m_vecLastAcceptedPuresyncPosition = pPlayer->GetLastAcceptedPuresyncPosition();
pSim->m_ullLastAcceptedPuresyncTick = pPlayer->GetLastAcceptedPuresyncTick();
pSim->m_LastAcceptedPuresyncContactElementID = pPlayer->GetLastAcceptedPuresyncContactElementID();
pSim->m_vecLastAcceptedPuresyncContactRelative = pPlayer->GetLastAcceptedPuresyncContactRelative();
pSim->m_bVehicleHasHydraulics = pVehicle ? pVehicle->GetUpgrades()->HasUpgrade(1087) : false;
pSim->m_bVehicleIsPlaneOrHeli = pVehicle ? pVehicle->GetVehicleType() == VEHICLE_PLANE || pVehicle->GetVehicleType() == VEHICLE_HELI : false;
pSim->m_sharedControllerState.Copy(pPlayer->GetPad()->GetCurrentControllerState());
Expand Down Expand Up @@ -239,10 +245,21 @@ bool CSimPlayerManager::HandlePlayerPureSync(const NetServerPlayerID& Socket, Ne
// Check is good for player pure sync
if (pSourceSimPlayer && pSourceSimPlayer->IsJoined() && (!pSourceSimPlayer->m_bHasOccupiedVehicle || pSourceSimPlayer->m_bIsExitingVehicle))
{
// Keep sim-thread validation aligned with the main thread's last accepted puresync state.
if (CPlayer* pRealPlayer = pSourceSimPlayer->m_pRealPlayer)
{
pSourceSimPlayer->m_bHasLastAcceptedPuresyncPosition = pRealPlayer->HasLastAcceptedPuresyncPosition();
pSourceSimPlayer->m_vecLastAcceptedPuresyncPosition = pRealPlayer->GetLastAcceptedPuresyncPosition();
pSourceSimPlayer->m_ullLastAcceptedPuresyncTick = pRealPlayer->GetLastAcceptedPuresyncTick();
pSourceSimPlayer->m_LastAcceptedPuresyncContactElementID = pRealPlayer->GetLastAcceptedPuresyncContactElementID();
pSourceSimPlayer->m_vecLastAcceptedPuresyncContactRelative = pRealPlayer->GetLastAcceptedPuresyncContactRelative();
pSourceSimPlayer->m_bTeleported = pRealPlayer->GetTeleported();
}

// Read the incoming packet data
CSimPlayerPuresyncPacket* pPacket =
new CSimPlayerPuresyncPacket(pSourceSimPlayer->m_PlayerID, pSourceSimPlayer->m_usLatency, pSourceSimPlayer->m_ucSyncTimeContext,
pSourceSimPlayer->m_ucWeaponType, pSourceSimPlayer->m_fWeaponRange, pSourceSimPlayer->m_sharedControllerState);
CSimPlayerPuresyncPacket* pPacket = new CSimPlayerPuresyncPacket(
pSourceSimPlayer->m_PlayerID, pSourceSimPlayer->m_usLatency, pSourceSimPlayer->m_ucSyncTimeContext, pSourceSimPlayer->m_ucWeaponType,
pSourceSimPlayer->m_fWeaponRange, pSourceSimPlayer->m_sharedControllerState, pSourceSimPlayer);
if (pPacket->Read(*BitStream))
{
// Relay it to nearbyers
Expand Down
77 changes: 75 additions & 2 deletions Server/mods/deathmatch/logic/net/CSimPlayerPuresyncPacket.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,21 @@
#include "StdInc.h"
#include "SimHeaders.h"
#include "Utils.h"
#include "SyncPuresyncValidation.h"
#include "CWeaponNames.h"
#include "CTickRateSettings.h"
#include "CElementIDs.h"

CSimPlayerPuresyncPacket::CSimPlayerPuresyncPacket(ElementID PlayerID, ushort PlayerLatency, uchar PlayerSyncTimeContext, uchar PlayerGotWeaponType,
float WeaponRange, CControllerState& sharedControllerState)
float WeaponRange, CControllerState& sharedControllerState, CSimPlayer* pSimPlayer)

: m_PlayerID(PlayerID),
m_PlayerLatency(PlayerLatency),
m_PlayerSyncTimeContext(PlayerSyncTimeContext),
m_PlayerGotWeaponType(PlayerGotWeaponType),
m_WeaponRange(WeaponRange),
m_sharedControllerState(sharedControllerState)
m_sharedControllerState(sharedControllerState),
m_pSimPlayer(pSimPlayer)
{
}

Expand Down Expand Up @@ -62,8 +66,45 @@ bool CSimPlayerPuresyncPacket::Read(NetBitStreamInterface& BitStream)
SPositionSync position(false);
if (!BitStream.Read(&position))
return false;

// Reject NaN/Inf coordinates before relaying them to nearby players (SPositionSync only range-checks).
if (!position.data.vecPosition.IsValid())
return false;

m_Cache.Position = position.data.vecPosition;

CVector vecAbsolutePosition = position.data.vecPosition;
CElement* pContactElement = nullptr;
if (m_Cache.flags.data.bHasContact)
{
pContactElement = CElementIDs::GetElement(m_Cache.ContactElementID);
if (pContactElement && m_pSimPlayer && m_pSimPlayer->m_pRealPlayer)
{
CPlayer* pSourcePlayer = m_pSimPlayer->m_pRealPlayer;
int32_t radius = -1;

if (pContactElement->GetType() == CElement::VEHICLE)
{
if (((CVehicle*)pContactElement)->GetSyncer() != pSourcePlayer)
radius = g_TickRateSettings.iVehicleContactSyncRadius;
}

if (radius > -1 && (!IsPointNearPoint3D(pSourcePlayer->GetPosition(), pContactElement->GetPosition(), static_cast<float>(radius)) ||
pSourcePlayer->GetDimension() != pContactElement->GetDimension()))
{
pContactElement = nullptr;
vecAbsolutePosition = pSourcePlayer->GetPosition();
}
}
}

if (m_Cache.flags.data.bHasContact && pContactElement == nullptr && m_pSimPlayer && m_pSimPlayer->m_pRealPlayer)
vecAbsolutePosition = m_pSimPlayer->m_pRealPlayer->GetPosition();
else if (pContactElement)
vecAbsolutePosition += pContactElement->GetPosition();

m_Cache.AbsolutePosition = vecAbsolutePosition;

// Player rotation
SPedRotationSync rotation;
if (!BitStream.Read(&rotation))
Expand All @@ -76,9 +117,41 @@ bool CSimPlayerPuresyncPacket::Read(NetBitStreamInterface& BitStream)
SVelocitySync velocity;
if (!BitStream.Read(&velocity))
return false;

m_Cache.Velocity = velocity.data.vecVelocity;
}

if (m_pSimPlayer)
{
const bool bVehicleContact = pContactElement && pContactElement->GetType() == CElement::VEHICLE;
CVector vecServerContactRelative;
CElement* pServerContactElement = nullptr;
if (m_pSimPlayer->m_pRealPlayer)
{
m_pSimPlayer->m_pRealPlayer->GetContactPosition(vecServerContactRelative);
pServerContactElement = m_pSimPlayer->m_pRealPlayer->GetContactElement();
}

CVector vecReferencePosition;
CVector vecNewPosition;
ResolvePuresyncMovementDelta(bVehicleContact, m_Cache.flags.data.bHasContact ? m_Cache.ContactElementID : INVALID_ELEMENT_ID,
m_pSimPlayer->m_LastAcceptedPuresyncContactElementID, m_Cache.Position, vecAbsolutePosition,
m_pSimPlayer->m_vecLastAcceptedPuresyncContactRelative, m_pSimPlayer->m_vecLastAcceptedPuresyncPosition,
vecServerContactRelative, pServerContactElement, pContactElement, m_pSimPlayer->m_bHasLastAcceptedPuresyncPosition,
m_pSimPlayer->m_pRealPlayer ? m_pSimPlayer->m_pRealPlayer->GetPosition() : vecAbsolutePosition, vecReferencePosition,
vecNewPosition);

const unsigned long long ullElapsedMs =
m_pSimPlayer->m_bHasLastAcceptedPuresyncPosition ? GetTickCount64_() - m_pSimPlayer->m_ullLastAcceptedPuresyncTick : 0;

if (!IsPlayerPuresyncMovementAcceptable(vecReferencePosition, ullElapsedMs, g_TickRateSettings.iPureSync, vecNewPosition,
m_Cache.flags.data.bSyncingVelocity, m_Cache.Velocity, g_TickRateSettings.iPlayerMaxSyncSpeed,
m_pSimPlayer->m_bTeleported))
{
return false;
}
}

// Health ( stored with damage )
SPlayerHealthSync health;
if (!BitStream.Read(&health))
Expand Down
Loading
Loading