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 Client/mods/deathmatch/logic/CClientGame.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -672,6 +672,7 @@ bool CClientGame::StartGame(const char* szNick, const char* szPassword, eServerT

// Store our nickname
m_strLocalNick.AssignLeft(szNick, MAX_PLAYER_NICK_LENGTH);
m_uiNextClientLuaEventSequence = 1;

// Are we connected?
if (g_pNet->IsConnected() || m_bIsPlayingBack)
Expand Down
4 changes: 4 additions & 0 deletions Client/mods/deathmatch/logic/CClientGame.h
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,9 @@ class CClientGame

uint GetFrameCount() { return m_uiFrameCount; }

uint32_t GetNextClientLuaEventSequence() const noexcept { return m_uiNextClientLuaEventSequence; }
void AdvanceClientLuaEventSequence() noexcept { ++m_uiNextClientLuaEventSequence; }

void HandleException(CExceptionInformation* pExceptionInformation);
static void HandleRadioNext(CControlFunctionBind*);
static void HandleRadioPrevious(CControlFunctionBind*);
Expand Down Expand Up @@ -856,6 +859,7 @@ class CClientGame

CElapsedTimeHD m_TimeSliceTimer;
uint m_uiFrameCount;
uint32_t m_uiNextClientLuaEventSequence = 1;

long long m_llLastTransgressionTime;
SString m_strLastDiagnosticStatus;
Expand Down
19 changes: 19 additions & 0 deletions Client/mods/deathmatch/logic/CEvents.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -149,10 +149,29 @@ void CEvents::PostEventPulse()

void CEvents::CancelEvent(bool bCancelled)
{
// During server->client remote events, only the handler's own VM may cancel the pulse.
if (m_iRemoteServerEventPulseDepth > 0 && m_pActiveEventHandlerLuaMain)
{
CLuaMain* pCaller = g_pClientGame->GetScriptDebugging()->GetTopLuaMain();
if (pCaller != m_pActiveEventHandlerLuaMain)
return;
}

m_bEventCancelled = bCancelled;
}

bool CEvents::WasEventCancelled()
{
return m_bWasEventCancelled;
}

void CEvents::PushRemoteServerEventPulse()
{
++m_iRemoteServerEventPulseDepth;
}

void CEvents::PopRemoteServerEventPulse()
{
if (m_iRemoteServerEventPulseDepth > 0)
--m_iRemoteServerEventPulseDepth;
}
9 changes: 9 additions & 0 deletions Client/mods/deathmatch/logic/CEvents.h
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,20 @@ class CEvents
void CancelEvent(bool bCancelled = true);
bool WasEventCancelled();

void PushRemoteServerEventPulse();
void PopRemoteServerEventPulse();
bool IsRemoteServerEventPulse() const noexcept { return m_iRemoteServerEventPulseDepth > 0; }

void SetActiveEventHandlerLuaMain(class CLuaMain* pLuaMain) noexcept { m_pActiveEventHandlerLuaMain = pLuaMain; }
CLuaMain* GetActiveEventHandlerLuaMain() const noexcept { return m_pActiveEventHandlerLuaMain; }

private:
void RemoveAllEvents();

CFastHashMap<SString, SEvent*> m_EventHashMap;
std::vector<int> m_CancelledList;
bool m_bEventCancelled;
bool m_bWasEventCancelled;
int m_iRemoteServerEventPulseDepth = 0;
class CLuaMain* m_pActiveEventHandlerLuaMain = nullptr;
};
4 changes: 4 additions & 0 deletions Client/mods/deathmatch/logic/CMapEventManager.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,8 @@ bool CMapEventManager::Call(const char* szName, const CLuaArguments& Arguments,
if (!g_pClientGame->GetDebugHookManager()->OnPreEventFunction(szName, Arguments, pSource, nullptr, pMapEvent))
continue;

g_pClientGame->GetEvents()->SetActiveEventHandlerLuaMain(luaMain);

// Store the current values of the globals
lua_getglobal(pState, "source");
CLuaArgument OldSource(pState, -1);
Expand Down Expand Up @@ -250,6 +252,8 @@ bool CMapEventManager::Call(const char* szName, const CLuaArguments& Arguments,
pMapEvent->Call(Arguments);
bCalled = true;

g_pClientGame->GetEvents()->SetActiveEventHandlerLuaMain(nullptr);

g_pClientGame->GetDebugHookManager()->OnPostEventFunction(szName, Arguments, pSource, nullptr, pMapEvent);

// Reset the globals on that VM
Expand Down
2 changes: 2 additions & 0 deletions Client/mods/deathmatch/logic/CPacketHandler.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -5038,7 +5038,9 @@ void CPacketHandler::Packet_LuaEvent(NetBitStreamInterface& bitStream)
CClientEntity* pEntity = CElementIDs::GetElement(EntityID);
if (pEntity)
{
g_pClientGame->GetEvents()->PushRemoteServerEventPulse();
pEntity->CallEvent(szName, Arguments, true);
g_pClientGame->GetEvents()->PopRemoteServerEventPulse();
}
}
else
Expand Down
33 changes: 33 additions & 0 deletions Client/mods/deathmatch/logic/CStaticFunctionDefinitions.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,27 @@

using std::list;

namespace
{
// Append a per-client monotonic sequence so the server can drop verbatim packet replays.
// No client-side secret is used: the client is open source, so any "signature" would be
// trivially reproducible by a recompiled cheat. The only real guarantee is the server-side
// expected-sequence state, which the client cannot observe or rewind.
bool AppendClientLuaEventSequence(NetBitStreamInterface& bitStream)
{
if (!bitStream.Can(eBitStreamVersion::ClientLuaEventSequence))
return true;

bitStream.Write(g_pClientGame->GetNextClientLuaEventSequence());
return true;
}

void CommitClientLuaEventSequence()
{
g_pClientGame->AdvanceClientLuaEventSequence();
}
}

static CLuaManager* m_pLuaManager;
static CEvents* m_pEvents;
static CCoreInterface* m_pCore;
Expand Down Expand Up @@ -192,8 +213,14 @@ bool CStaticFunctionDefinitions::TriggerServerEvent(const char* szName, CClientE
g_pNet->DeallocateNetBitStream(pBitStream);
return false;
}
if (!AppendClientLuaEventSequence(*pBitStream))
{
g_pNet->DeallocateNetBitStream(pBitStream);
return false;
}
g_pNet->SendPacket(PACKET_ID_LUA_EVENT, pBitStream, PACKET_PRIORITY_HIGH, PACKET_RELIABILITY_RELIABLE_ORDERED);
g_pNet->DeallocateNetBitStream(pBitStream);
CommitClientLuaEventSequence();

return true;
}
Expand Down Expand Up @@ -221,10 +248,16 @@ bool CStaticFunctionDefinitions::TriggerLatentServerEvent(const char* szName, CC
g_pNet->DeallocateNetBitStream(pBitStream);
return false;
}
if (!AppendClientLuaEventSequence(*pBitStream))
{
g_pNet->DeallocateNetBitStream(pBitStream);
return false;
}
g_pClientGame->GetLatentTransferManager()->AddSendBatchBegin(PACKET_ID_LUA_EVENT, pBitStream);
g_pClientGame->GetLatentTransferManager()->AddSend(0, pBitStream->Version(), iBandwidth, pLuaMain, usResourceNetId);
g_pClientGame->GetLatentTransferManager()->AddSendBatchEnd();
g_pNet->DeallocateNetBitStream(pBitStream);
CommitClientLuaEventSequence();

return true;
}
Expand Down
75 changes: 75 additions & 0 deletions Server/mods/deathmatch/README.event-sync-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Lua event sync guard (internal)

Hardens `PACKET_ID_LUA_EVENT` against verbatim replay and protects server→client
event delivery from Lua-level cancellation.

## Design constraint: the client is open source

MTA:SA's client is public. Any client-side check, key, hash or "signature" can be
read on GitHub and reproduced by a recompiled cheat, so **no security-through-obscurity
is used here**. The guarantees below rely only on:

1. **Server-side state the client cannot observe or rewind** (the expected sequence), and
2. **Server-side / logical decisions** (who may cancel an in-flight event).

A fully recompiled malicious client is out of scope — that is true for every check in an
open-source client and cannot be solved with client-side secrets.

## Threat model

| Vector | Mechanism | Guard | Open-source-proof? |
|--------|-----------|-------|--------------------|
| `ResendPacket` / packet filter | Resends a captured `PACKET_ID_LUA_EVENT` bitstream verbatim | The resent packet carries a stale sequence; server expects a higher one and drops it | Yes — expected value lives only on the server |
| `BlockedServerEventList` / `skip_call` on `TriggerServerEvent` | Drops selected client→server events before they leave the client | Not fixable server-side (packet never sent). Existing `max_player_triggered_events_per_interval` still applies to packets that do arrive | N/A |
| Injected script + `cancelEvent` | Cancels handlers for a server-triggered `triggerClientEvent` | During the remote pulse only the handler's own VM may cancel; foreign VMs are ignored | Yes — logical ownership check |
| `addDebugHook` preEvent `"skip"` | Blocks the whole client event dispatch | preEvent/preEventFunction debug hooks are ignored while dispatching server remote events | Yes — logical check |

## Anti-replay protocol

- Bitstream feature: `eBitStreamVersion::ClientLuaEventSequence`.
- The client appends a monotonically increasing `uint32` after the Lua arguments on each
`triggerServerEvent` / `triggerLatentServerEvent`.
- The server tracks the next expected sequence per player and rejects any packet whose
sequence is **below** it (monotonic, not strict-equality, so reliable-ordered and latent
events interleaving never drops a legitimate packet).
- Legitimate duplicate payloads stay valid — e.g. calling
`triggerServerEvent("test", localPlayer, "same")` twice sends two distinct sequences.
- A modern client (one whose bitstream version supports the feature) **must** include the
sequence; a packet without it was not produced by stock code and is rejected.

### What this does and does not stop

- **Stops:** dumb capture-and-resend (the cheat's `ResendPacket` path), which is the actual
observed attack.
- **Does not stop:** a cheat that bumps the sequence before resending. But that is no longer
a "replay" — it is indistinguishable from the script simply calling `triggerServerEvent`
again, which is already legal. So nothing is lost.

## Server→client cancellation guard

When the server delivers a remote Lua event (`CPacketHandler::Packet_LuaEvent`), the client
enters a "remote event pulse":

- `cancelEvent` is accepted only from the VM that registered the currently running handler,
so an injected resource cannot cancel another resource's server-triggered handler.
- `CDebugHookManager::OnPreEvent` / `OnPreEventFunction` ignore `"skip"` during the pulse,
so a debug-hook based blocker cannot suppress delivery.

This is a logical guard, not a secret, so being open source does not weaken it.

## Key files

- `Server/.../CPlayer::TryAcceptClientLuaEvent` — monotonic sequence gate
- `Server/.../CGame::Packet_LuaEvent` — validation gate
- `Client/.../CStaticFunctionDefinitions.cpp` — appends the sequence on send
- `Client/.../CPacketHandler::Packet_LuaEvent` — remote event pulse
- `Client/.../CEvents.cpp` — scoped `cancelEvent` ownership
- `Shared/.../CDebugHookManager.cpp` — skips debug-hook blocking during the remote pulse

## Testing

1. Build client + server with matching bitstream version.
2. `triggerServerEvent("test", localPlayer, "same")` twice → both reach the server.
3. Capture and resend a `PACKET_ID_LUA_EVENT` packet verbatim → server drops the replay.
4. Server `triggerClientEvent` to a handler while a foreign script calls `cancelEvent` → the
intended handler still runs.
12 changes: 12 additions & 0 deletions Server/mods/deathmatch/logic/CGame.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1416,6 +1416,7 @@ void CGame::InitialDataStream(CPlayer& Player)

// He's joined now
Player.SetJoined();
Player.ResetClientLuaEventSequence();
m_pPlayerManager->OnPlayerJoin(&Player);

// Console
Expand Down Expand Up @@ -2699,6 +2700,17 @@ void CGame::Packet_LuaEvent(CLuaEventPacket& Packet)
ElementID ElementID = Packet.GetElementID();
CLuaArguments* pArguments = Packet.GetArguments();

if (!pCaller)
return;

// Reject verbatim packet replays (e.g. captured-and-resent Lua events) before dispatching the event.
// A modern client must include the sequence; if it is missing the packet was not produced by stock code.
if (pCaller->CanBitStream(eBitStreamVersion::ClientLuaEventSequence) && !Packet.HasClientSequence())
return;

if (!pCaller->TryAcceptClientLuaEvent(Packet.HasClientSequence(), Packet.GetClientSequence()))
return;

// Grab the element
CElement* pElement = CElementIDs::GetElement(ElementID);
if (pElement)
Expand Down
22 changes: 22 additions & 0 deletions Server/mods/deathmatch/logic/CPlayer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1155,6 +1155,28 @@ CPlayer* GetDeletedMapKey(CPlayer**)
return (CPlayer*)2;
}

bool CPlayer::TryAcceptClientLuaEvent(bool bHasSequence, uint32_t uiSequence) noexcept
{
// Server-side anti-replay. The sequence is plain client state, but the *expected* value lives only on
// the server, so a captured packet resent verbatim (its sequence is stale) is rejected. Deliberately
// monotonic rather than strict-equality so reliable-ordered + latent event interleaving never drops a
// legitimate packet. This is the only guarantee we can give on an open-source client: no client-side
// secret can stop a recompiled cheat, so we avoid security-through-obscurity entirely.
if (!bHasSequence)
return true;

if (uiSequence < m_uiNextExpectedClientLuaEventSequence)
return false;

m_uiNextExpectedClientLuaEventSequence = uiSequence + 1;
return true;
}

void CPlayer::ResetClientLuaEventSequence() noexcept
{
m_uiNextExpectedClientLuaEventSequence = 1;
}

/////////////////////////////////////////////////////////////////
//
// CPlayerBitStream::CPlayerBitStream
Expand Down
5 changes: 5 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; }

bool TryAcceptClientLuaEvent(bool bHasSequence, uint32_t uiSequence) noexcept;
void ResetClientLuaEventSequence() noexcept;

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

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

bool m_teleported = false;

uint32_t m_uiNextExpectedClientLuaEventSequence = 1;
};
12 changes: 11 additions & 1 deletion Server/mods/deathmatch/logic/packets/CLuaEventPacket.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*
* Multi Theft Auto is available from https://www.multitheftauto.com/
*
*****************************************************************************/
****************************************************************************/

#include "StdInc.h"
#include "CLuaEventPacket.h"
Expand Down Expand Up @@ -38,6 +38,16 @@ bool CLuaEventPacket::Read(NetBitStreamInterface& BitStream)
return false;
m_pArguments = &m_ArgumentsStore;

// Optional monotonic sequence appended by the client (anti-replay; see README.event-sync-guard.md)
m_bHasClientSequence = false;
m_uiClientSequence = 0;
if (BitStream.Can(eBitStreamVersion::ClientLuaEventSequence) && BitStream.CanReadNumberOfBytes(4))
{
if (!BitStream.Read(m_uiClientSequence))
return false;
m_bHasClientSequence = true;
}

return true;
}
}
Expand Down
5 changes: 5 additions & 0 deletions Server/mods/deathmatch/logic/packets/CLuaEventPacket.h
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,14 @@ class CLuaEventPacket final : public CPacket
ElementID GetElementID() { return m_ElementID; }
CLuaArguments* GetArguments() { return m_pArguments; }

bool HasClientSequence() const noexcept { return m_bHasClientSequence; }
uint32_t GetClientSequence() const noexcept { return m_uiClientSequence; }

private:
SString m_strName;
ElementID m_ElementID;
CLuaArguments m_ArgumentsStore;
CLuaArguments* m_pArguments;
uint32_t m_uiClientSequence = 0;
bool m_bHasClientSequence = false;
};
11 changes: 11 additions & 0 deletions Shared/mods/deathmatch/logic/CDebugHookManager.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,12 @@ void CDebugHookManager::GetFunctionCallHookArguments(CLuaArguments& NewArguments
///////////////////////////////////////////////////////////////
bool CDebugHookManager::OnPreEvent(const char* szName, const CLuaArguments& Arguments, CElement* pSource, CPlayer* pCaller)
{
#ifdef MTA_CLIENT
// preEvent debug hooks can return "skip" and block server triggerClientEvent delivery.
if (g_pClientGame->GetEvents()->IsRemoteServerEventPulse())
return true;
#endif

if (m_PreEventHookList.empty())
return true;

Expand Down Expand Up @@ -433,6 +439,11 @@ void CDebugHookManager::GetEventCallHookArguments(CLuaArguments& NewArguments, c
///////////////////////////////////////////////////////////////
bool CDebugHookManager::OnPreEventFunction(const char* szName, const CLuaArguments& Arguments, CElement* pSource, CPlayer* pCaller, CMapEvent* pMapEvent)
{
#ifdef MTA_CLIENT
if (g_pClientGame->GetEvents()->IsRemoteServerEventPulse())
return true;
#endif

if (m_PreEventFunctionHookList.empty())
return true;

Expand Down
4 changes: 4 additions & 0 deletions Shared/sdk/net/bitstream.h
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ enum class eBitStreamVersion : unsigned short
// YYYY-MM-DD
// Name,

// 2026-06-30
// Monotonic sequence on client->server PACKET_ID_LUA_EVENT (replay guard)
ClientLuaEventSequence,

// This allows us to automatically increment the BitStreamVersion when things are added to this enum.
// Make sure you only add things above this comment.
Next,
Expand Down
Loading