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
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@

"powershell.codeFormatting.openBraceOnSameLine": true,
"powershell.codeFormatting.newLineAfterCloseBrace": false,
"C_Cpp.default.compilerPath": "c:\\Program Files\\Microsoft Visual Studio\\18\\Community\\VC\\Tools\\MSVC\\14.51.36231\\bin\\Hostx86\\x64\\cl.exe",
}
8 changes: 6 additions & 2 deletions Client/mods/deathmatch/logic/CClientPed.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1014,15 +1014,19 @@ void CClientPed::SetTargetTarget(unsigned long ulDelay, const CVector& vecSource
{
if (!m_bIsLocalPlayer)
{
if (!vecSource.IsValid() || !vecTarget.IsValid())
return;

m_ulBeginTarget = CClientTime::GetTime();
m_ulEndTarget = m_ulBeginTarget + ulDelay;
m_vecBeginSource = m_shotSyncData->m_vecShotOrigin;
m_vecBeginTarget = m_shotSyncData->m_vecShotTarget;
m_vecTargetSource = vecSource;
m_vecTargetTarget = vecTarget;

// Grab the radius of the target circle
float fRadius = DistanceBetweenPoints3D(m_vecTargetSource, m_vecTargetTarget);
const float fRadius = DistanceBetweenPoints3D(m_vecTargetSource, m_vecTargetTarget);
if (fRadius < 0.01f)
return;

// Grab the angle of the source vector and the angle of the target vector relative to the source vector that applies
m_vecBeginTargetAngle.fX = acos(Clamp(-1.0f, (m_vecBeginTarget.fX - m_vecBeginSource.fX) / fRadius, 1.0f));
Expand Down
10 changes: 10 additions & 0 deletions Client/mods/deathmatch/logic/CNetAPI.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
*****************************************************************************/

#include <StdInc.h>
#include <SyncBulletsyncValidation.h>
#include <net/SyncStructures.h>
#include <game/CWeapon.h>
#include <game/CWeaponStat.h>
Expand Down Expand Up @@ -2295,6 +2296,15 @@ void CNetAPI::ReadBulletsync(CClientPlayer* player, NetBitStreamInterface& strea
!end.IsValid())
return;

if (!SyncBulletsyncValidation::IsSyncedBulletSegmentNonDegenerate(start, end))
return;

CWeaponStat* pWeaponStat = g_pGame->GetWeaponStatManager()->GetOriginalWeaponStats(type);
const float fWeaponRange = pWeaponStat ? pWeaponStat->GetWeaponRange() : 0.0f;
if (!SyncBulletsyncValidation::IsSyncedBulletsyncGeometryAcceptable(player->GetPosition(), player->GetOccupiedVehicle() != nullptr, start, end,
fWeaponRange))
return;

std::uint8_t order = 0;
if (!stream.Read(order))
return;
Expand Down
47 changes: 47 additions & 0 deletions Server/mods/deathmatch/README.weapon-sync-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Weapon Sync Guard (Bullet Sync + Aim Sync)

## Why this exists

Cheats hook client `SendBulletSync` and can:

- send many bullet-sync packets per physical shot (`DamageDividier` loops)
- forge start/end vectors (including non-finite or extreme trajectories)
- bypass `onPlayerWeaponFire` on the server while still crashing remote clients

Root cause: the **sim thread** relays `PACKET_ID_PLAYER_BULLETSYNC` before the main thread runs full validation. Invalid packets were broadcast to nearby players even when `CBulletsyncPacket::Read()` later rejected them.

Weapon aim data in puresync/keysync can also crash remote clients when origin ≈ target (division by zero in `SetTargetTarget`).

## What is fixed

### 1) Player-relative geometry (`SyncBulletsyncValidation.h`)

Validation is derived from the shooter sync position and weapon stats:

- **Muzzle origin** within a physical envelope around the ped/vehicle sync point (5 m on foot, 25 m in vehicle)
- **Bullet segment** length ≤ weapon range (+ lag tolerance)
- **Impact point** reachable from shooter: `origin_offset + weapon_range`
- Non-finite vectors and degenerate segments rejected

### 2) Sim-thread parity (`CSimPlayerManager::HandleBulletSync`)

Sim relay runs the same checks as `CBulletsyncPacket::Read()` **before** broadcasting:

- spawned/alive player, weapon ownership + ammo
- player-relative geometry + damage validation
- per-player rate limit (25 packets / second)

### 3) Weapon aim validation (puresync + keysync)

Full aim sync rejected when origin/target would crash remote simulation. Applied on main and sim relay paths.

### 4) Client defense in depth

- `CNetAPI::ReadBulletsync` drops invalid geometry
- `CClientPed::SetTargetTarget` ignores invalid/degenerate aim data

## Residual limits

- Rate limit is per-second, not per-weapon fire rate from stats
- Compromised clients can still send sub-threshold values; server relay rejection is the meaningful fix
- Custom weapon bullet sync validates on main thread only (no sim fast path)
19 changes: 10 additions & 9 deletions Server/mods/deathmatch/logic/CGame.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

#include "StdInc.h"
#include "CGame.h"
#include "SyncBulletsyncValidation.h"

#ifdef WIN32
#include <ws2tcpip.h>
Expand Down Expand Up @@ -2559,19 +2560,13 @@ void CGame::Packet_Bulletsync(CBulletsyncPacket& packet)
if (player->GetWeaponTotalAmmo(slot) <= 0)
return;

// Note: Don't check ammo in clip here - it can be out of sync due to network timing
// The total ammo check above is sufficient

const auto stat = CWeaponStatManager::GetSkillStatIndex(packet.m_weapon);
const auto level = player->GetPlayerStat(stat);
auto* stats = g_pGame->GetWeaponStatManager()->GetWeaponStatsFromSkillLevel(packet.m_weapon, level);
const float fWeaponRange = stats->GetWeaponRange();

const float distanceSq = (packet.m_start - packet.m_end).LengthSquared();
const float range = stats->GetWeaponRange();
const float rangeSq = range * range;

const float maxRangeSq = rangeSq * 1.1f; // 10% tolerance for floating point
if (distanceSq > maxRangeSq)
if (!SyncBulletsyncValidation::IsSyncedBulletsyncPacketAcceptable(player->GetPosition(), player->GetOccupiedVehicle() != nullptr, packet.m_start,
packet.m_end, packet.m_damage, packet.m_zone, packet.m_damaged, fWeaponRange))
return;

CLuaArguments args;
Expand Down Expand Up @@ -2612,6 +2607,12 @@ void CGame::Packet_WeaponBulletsync(CCustomWeaponBulletSyncPacket& packet)
if (weapon->GetClipAmmo() <= 0)
return;

CWeaponStat* pWeaponStat = g_pGame->GetWeaponStatManager()->GetOriginalWeaponStats(weapon->GetWeaponType());
const float fWeaponRange = pWeaponStat ? pWeaponStat->GetWeaponRange() : 0.0f;
if (!SyncBulletsyncValidation::IsSyncedBulletsyncGeometryAcceptable(player->GetPosition(), player->GetOccupiedVehicle() != nullptr, packet.m_start,
packet.m_end, fWeaponRange))
return;

CLuaArguments args;
args.PushElement(player);

Expand Down
20 changes: 20 additions & 0 deletions Server/mods/deathmatch/logic/CPlayer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
*****************************************************************************/

#include "StdInc.h"
#include "SyncBulletsyncValidation.h"
#include "CPlayer.h"
#include "CElementRefManager.h"
#include "CGame.h"
Expand Down Expand Up @@ -1112,6 +1113,25 @@ void CPlayer::SetPosition(const CVector& vecPosition)
CElement::SetPosition(vecPosition);
}

bool CPlayer::TryAcceptBulletsync() noexcept
{
using namespace SyncBulletsyncValidation;

const unsigned long long ullNow = GetTickCount64_();

if (m_ullBulletsyncWindowStartMs == 0 || ullNow - m_ullBulletsyncWindowStartMs >= RATE_WINDOW_MS)
{
m_ullBulletsyncWindowStartMs = ullNow;
m_uiBulletsyncCountInWindow = 0;
}

if (m_uiBulletsyncCountInWindow >= MAX_PACKETS_PER_SECOND)
return false;

++m_uiBulletsyncCountInWindow;
return true;
}

void CPlayer::SetPlayerStat(unsigned short usStat, float fValue)
{
m_pPlayerStatsPacket->Add(usStat, fValue);
Expand Down
6 changes: 6 additions & 0 deletions Server/mods/deathmatch/logic/CPlayer.h
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,9 @@ class CPlayer final : public CPed, public CClient
bool GetTeleported() const noexcept { return m_teleported; }
void SetTeleported(bool state) noexcept { m_teleported = state; }

// Anti-cheat: cap forged bullet-sync floods (e.g. cheat loops SendBulletSync many times per shot).
bool TryAcceptBulletsync() noexcept;

protected:
bool ReadSpecialData(const int iLine) override { return true; }

Expand Down Expand Up @@ -463,4 +466,7 @@ class CPlayer final : public CPed, public CClient
SString m_strQuitReasonForLog;

bool m_teleported = false;

unsigned long long m_ullBulletsyncWindowStartMs = 0;
unsigned int m_uiBulletsyncCountInWindow = 0;
};
19 changes: 15 additions & 4 deletions Server/mods/deathmatch/logic/net/CSimBulletsyncPacket.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
#include "SimHeaders.h"
#include "CPickupManager.h"
#include "CWeaponStatManager.h"
#include "CElementIDs.h"
#include "SyncBulletsyncValidation.h"

CSimBulletsyncPacket::CSimBulletsyncPacket(ElementID id) : m_id(id)
{
Expand All @@ -27,17 +29,26 @@ bool CSimBulletsyncPacket::Read(NetBitStreamInterface& stream)
if (!stream.Read(reinterpret_cast<char*>(&m_cache.start), sizeof(CVector)) || !stream.Read(reinterpret_cast<char*>(&m_cache.end), sizeof(CVector)))
return false;

if (!m_cache.start.IsValid() || !m_cache.end.IsValid())
if (!SyncBulletsyncValidation::IsSyncedBulletSegmentNonDegenerate(m_cache.start, m_cache.end))
return false;

if (!stream.Read(m_cache.order))
return false;

m_cache.damage = 0.0f;
m_cache.zone = 0;
m_cache.damaged = INVALID_ELEMENT_ID;

if (stream.ReadBit())
{
stream.Read(m_cache.damage);
stream.Read(m_cache.zone);
stream.Read(m_cache.damaged);
if (!stream.Read(m_cache.damage) || !stream.Read(m_cache.zone) || !stream.Read(m_cache.damaged))
return false;

if (!SyncBulletsyncValidation::IsSyncedBulletDamageAcceptable(m_cache.damage, m_cache.zone, m_cache.damaged))
return false;

if (m_cache.damaged != INVALID_ELEMENT_ID && !CElementIDs::GetElement(m_cache.damaged))
return false;
}

return true;
Expand Down
38 changes: 36 additions & 2 deletions Server/mods/deathmatch/logic/net/CSimPlayerManager.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@

#include "StdInc.h"
#include "SimHeaders.h"
#include "SyncBulletsyncValidation.h"
#include "CWeaponNames.h"

//
// CSimPlayer object is created on CPlayer construction
Expand Down Expand Up @@ -343,7 +345,15 @@ bool CSimPlayerManager::HandleBulletSync(const NetServerPlayerID& socket, NetBit
LockSimSystem();

auto* player = Get(socket);
if (!player || !player->IsJoined())
if (!player || !player->IsJoined() || !player->m_pRealPlayer)
{
UnlockSimSystem();
return true;
}

CPlayer* pRealPlayer = player->m_pRealPlayer;

if (!pRealPlayer->IsSpawned() || pRealPlayer->IsDead())
{
UnlockSimSystem();
return true;
Expand All @@ -356,7 +366,31 @@ bool CSimPlayerManager::HandleBulletSync(const NetServerPlayerID& socket, NetBit
return true;
}

if (!player->m_pRealPlayer->HasWeaponType(packet->m_cache.weapon))
const auto weaponType = static_cast<std::uint8_t>(packet->m_cache.weapon);
if (!pRealPlayer->HasWeaponType(weaponType))
{
UnlockSimSystem();
return true;
}

const auto slot = CWeaponNames::GetSlotFromWeapon(weaponType);
if (pRealPlayer->GetWeaponTotalAmmo(slot) <= 0)
{
UnlockSimSystem();
return true;
}

const bool bInVehicle = pRealPlayer->GetOccupiedVehicle() != nullptr;
const float fWeaponRange = pRealPlayer->GetWeaponRangeFromSlot(slot);

if (!SyncBulletsyncValidation::IsSyncedBulletsyncPacketAcceptable(pRealPlayer->GetPosition(), bInVehicle, packet->m_cache.start, packet->m_cache.end,
packet->m_cache.damage, packet->m_cache.zone, packet->m_cache.damaged, fWeaponRange))
{
UnlockSimSystem();
return true;
}

if (!pRealPlayer->TryAcceptBulletsync())
{
UnlockSimSystem();
return true;
Expand Down
5 changes: 5 additions & 0 deletions Server/mods/deathmatch/logic/net/CSimPlayerPuresyncPacket.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
#include "StdInc.h"
#include "SimHeaders.h"
#include "Utils.h"
#include "SyncBulletsyncValidation.h"
#include "CWeaponNames.h"

CSimPlayerPuresyncPacket::CSimPlayerPuresyncPacket(ElementID PlayerID, ushort PlayerLatency, uchar PlayerSyncTimeContext, uchar PlayerGotWeaponType,
Expand Down Expand Up @@ -157,6 +158,10 @@ bool CSimPlayerPuresyncPacket::Read(NetBitStreamInterface& BitStream)
// Read the aim data only if he's shooting or aiming
if (sync.isFull())
{
if (!SyncBulletsyncValidation::IsSyncedWeaponAimAcceptable(m_Cache.Position, sync.data.vecOrigin, sync.data.vecTarget, m_WeaponRange,
false))
return false;

m_Cache.vecSniperSource = sync.data.vecOrigin;
m_Cache.vecTargetting = sync.data.vecTarget;
m_Cache.bIsAimFull = true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
#include "StdInc.h"
#include "SimHeaders.h"
#include "Utils.h"
#include "SyncBulletsyncValidation.h"
#include "CVehicleManager.h"
#include "CWeaponNames.h"

Expand Down Expand Up @@ -216,6 +217,11 @@ bool CSimVehiclePuresyncPacket::Read(NetBitStreamInterface& BitStream)
SWeaponAimSync aim(m_fPlayerGotWeaponRange, true);
if (!BitStream.Read(&aim))
return false;

if (!SyncBulletsyncValidation::IsSyncedWeaponAimAcceptable(m_Cache.PlrPosition, aim.data.vecOrigin, aim.data.vecTarget,
m_fPlayerGotWeaponRange, true))
return false;

m_Cache.fAimDirection = aim.data.fArm;
m_Cache.vecSniperSource = aim.data.vecOrigin;
m_Cache.vecTargetting = aim.data.vecTarget;
Expand Down
Loading