diff --git a/Client/mods/deathmatch/logic/CClientGame.cpp b/Client/mods/deathmatch/logic/CClientGame.cpp index 69edcc4337b..84c50e69378 100644 --- a/Client/mods/deathmatch/logic/CClientGame.cpp +++ b/Client/mods/deathmatch/logic/CClientGame.cpp @@ -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) diff --git a/Client/mods/deathmatch/logic/CClientGame.h b/Client/mods/deathmatch/logic/CClientGame.h index cf8048eb67c..856ba5b309d 100644 --- a/Client/mods/deathmatch/logic/CClientGame.h +++ b/Client/mods/deathmatch/logic/CClientGame.h @@ -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*); @@ -856,6 +859,7 @@ class CClientGame CElapsedTimeHD m_TimeSliceTimer; uint m_uiFrameCount; + uint32_t m_uiNextClientLuaEventSequence = 1; long long m_llLastTransgressionTime; SString m_strLastDiagnosticStatus; diff --git a/Client/mods/deathmatch/logic/CEvents.cpp b/Client/mods/deathmatch/logic/CEvents.cpp index 1a8ef75f6f8..79fb335bad0 100644 --- a/Client/mods/deathmatch/logic/CEvents.cpp +++ b/Client/mods/deathmatch/logic/CEvents.cpp @@ -149,6 +149,14 @@ 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; } @@ -156,3 +164,14 @@ bool CEvents::WasEventCancelled() { return m_bWasEventCancelled; } + +void CEvents::PushRemoteServerEventPulse() +{ + ++m_iRemoteServerEventPulseDepth; +} + +void CEvents::PopRemoteServerEventPulse() +{ + if (m_iRemoteServerEventPulseDepth > 0) + --m_iRemoteServerEventPulseDepth; +} diff --git a/Client/mods/deathmatch/logic/CEvents.h b/Client/mods/deathmatch/logic/CEvents.h index 23c4cb513a0..c1da99849b8 100644 --- a/Client/mods/deathmatch/logic/CEvents.h +++ b/Client/mods/deathmatch/logic/CEvents.h @@ -45,6 +45,13 @@ 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(); @@ -52,4 +59,6 @@ class CEvents std::vector m_CancelledList; bool m_bEventCancelled; bool m_bWasEventCancelled; + int m_iRemoteServerEventPulseDepth = 0; + class CLuaMain* m_pActiveEventHandlerLuaMain = nullptr; }; diff --git a/Client/mods/deathmatch/logic/CMapEventManager.cpp b/Client/mods/deathmatch/logic/CMapEventManager.cpp index fd8a47af26d..94ad1e5fe73 100644 --- a/Client/mods/deathmatch/logic/CMapEventManager.cpp +++ b/Client/mods/deathmatch/logic/CMapEventManager.cpp @@ -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); @@ -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 diff --git a/Client/mods/deathmatch/logic/CPacketHandler.cpp b/Client/mods/deathmatch/logic/CPacketHandler.cpp index 1fb1a3d8fa7..3056b188298 100644 --- a/Client/mods/deathmatch/logic/CPacketHandler.cpp +++ b/Client/mods/deathmatch/logic/CPacketHandler.cpp @@ -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 diff --git a/Client/mods/deathmatch/logic/CStaticFunctionDefinitions.cpp b/Client/mods/deathmatch/logic/CStaticFunctionDefinitions.cpp index dcd11a4e984..612db4658d1 100644 --- a/Client/mods/deathmatch/logic/CStaticFunctionDefinitions.cpp +++ b/Client/mods/deathmatch/logic/CStaticFunctionDefinitions.cpp @@ -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; @@ -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; } @@ -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; } diff --git a/Server/mods/deathmatch/README.event-sync-guard.md b/Server/mods/deathmatch/README.event-sync-guard.md new file mode 100644 index 00000000000..0b0e3a881b4 --- /dev/null +++ b/Server/mods/deathmatch/README.event-sync-guard.md @@ -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. diff --git a/Server/mods/deathmatch/logic/CGame.cpp b/Server/mods/deathmatch/logic/CGame.cpp index 4ce0091d5ee..df29b0c5d81 100644 --- a/Server/mods/deathmatch/logic/CGame.cpp +++ b/Server/mods/deathmatch/logic/CGame.cpp @@ -1416,6 +1416,7 @@ void CGame::InitialDataStream(CPlayer& Player) // He's joined now Player.SetJoined(); + Player.ResetClientLuaEventSequence(); m_pPlayerManager->OnPlayerJoin(&Player); // Console @@ -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) diff --git a/Server/mods/deathmatch/logic/CPlayer.cpp b/Server/mods/deathmatch/logic/CPlayer.cpp index 35641fe7c9b..42fff47bfd6 100644 --- a/Server/mods/deathmatch/logic/CPlayer.cpp +++ b/Server/mods/deathmatch/logic/CPlayer.cpp @@ -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 diff --git a/Server/mods/deathmatch/logic/CPlayer.h b/Server/mods/deathmatch/logic/CPlayer.h index b332f40eb12..078acd26204 100644 --- a/Server/mods/deathmatch/logic/CPlayer.h +++ b/Server/mods/deathmatch/logic/CPlayer.h @@ -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; } @@ -463,4 +466,6 @@ class CPlayer final : public CPed, public CClient SString m_strQuitReasonForLog; bool m_teleported = false; + + uint32_t m_uiNextExpectedClientLuaEventSequence = 1; }; diff --git a/Server/mods/deathmatch/logic/packets/CLuaEventPacket.cpp b/Server/mods/deathmatch/logic/packets/CLuaEventPacket.cpp index a3d3a9f11f6..517e3ad7c5e 100644 --- a/Server/mods/deathmatch/logic/packets/CLuaEventPacket.cpp +++ b/Server/mods/deathmatch/logic/packets/CLuaEventPacket.cpp @@ -7,7 +7,7 @@ * * Multi Theft Auto is available from https://www.multitheftauto.com/ * - *****************************************************************************/ + ****************************************************************************/ #include "StdInc.h" #include "CLuaEventPacket.h" @@ -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; } } diff --git a/Server/mods/deathmatch/logic/packets/CLuaEventPacket.h b/Server/mods/deathmatch/logic/packets/CLuaEventPacket.h index 909a845ac04..1a5c0a59b54 100644 --- a/Server/mods/deathmatch/logic/packets/CLuaEventPacket.h +++ b/Server/mods/deathmatch/logic/packets/CLuaEventPacket.h @@ -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; }; diff --git a/Shared/mods/deathmatch/logic/CDebugHookManager.cpp b/Shared/mods/deathmatch/logic/CDebugHookManager.cpp index 8e5798c35aa..cdd9d9a7b48 100644 --- a/Shared/mods/deathmatch/logic/CDebugHookManager.cpp +++ b/Shared/mods/deathmatch/logic/CDebugHookManager.cpp @@ -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; @@ -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; diff --git a/Shared/sdk/net/bitstream.h b/Shared/sdk/net/bitstream.h index 6b04aa6a305..01f1ecde541 100644 --- a/Shared/sdk/net/bitstream.h +++ b/Shared/sdk/net/bitstream.h @@ -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,