Spoofed InputTarget:Entity lets a modified client hijack any entity and Unbounded end_tick lets a client exhaust server memory#1476
Closed
mmannerm wants to merge 1 commit into
Closed
Conversation
f523f5b to
6a98d1a
Compare
… + bound end_tick lookahead Closes two related security gaps in the server-side input-message receive path: 1. Spoofed InputTarget::Entity lets a modified client hijack any entity. receive_input_message reads InputTarget::Entity(entity) from the wire and writes inputs to that entity's InputBuffer with no authorization check. A modified client (or one with a tampered prepare_input_message query) can put any entity id in the message, and the server applies the inputs to that entity's ActionState. The new client-side ControlledBy filter (cBournhonesque#1361) does not help here, since ControlledBy is not replicated to clients and a tampered client can skip the check anyway. 2. Unbounded end_tick lets a client exhaust server memory. InputBuffer::set_raw extends its internal VecDeque to fit any tick value, filling intermediate entries with SameAsPrecedent. A modified client sending end_tick = server_tick + 30_000 triggers a 30 000-entry allocation per message on the target entity's InputBuffer. Fixes both via two new pub(crate) helpers in lightyear_inputs/src/server.rs, both wired into receive_input_message BEFORE the rebroadcast and per-target-apply paths: - is_input_target_authorized(target_entity, controlled_by_remote): checks the target is in the peer's ControlledByRemote relationship-target list. Local clients (host-server) bypass since their inputs flow through in-process state. InputTarget::PreSpawned bypasses at the call site (hash-based identity is a pre-existing concern out of scope here). - is_input_within_lookahead(end_tick, server_tick): rejects messages where the delta is outside [-MAX_INPUT_PAST_TICKS, MAX_INPUT_LOOKAHEAD_TICKS] = [-256, 64]. Tick is now u32 (post cBournhonesque#1361), so Tick - Tick returns i32 via wrapping_diff. The +2^31 wrap-around vector is practically unreachable (>1 year at 64Hz), but the symmetric past bound is cheap belt-and-suspenders and the forward bound is what blocks the DOS. Wiring order matters: filtering pre-rebroadcast prevents the server from relaying forged inputs to other clients before the per-target apply loop drops them. Tests: - 6 unit tests in lightyear_inputs::server::patch_tests cover both helpers (authorization with None / legitimate / foreign target; forward + past lookahead bounds; wrap-around at low and high server-tick positions). - 3 integration tests in lightyear_tests/.../leafwing.rs: - test_input_message_with_spoofed_target_is_rejected: 2 clients, one entity per client, client 0 forges KeyJ to victim's local replica AND legitimate KeyA to its own. Asserts both halves: victim NOT pressed (defense) AND attacker IS pressed (non-overblocking). - test_input_message_with_only_spoofed_targets_filters_to_empty: exercises the empty-after-filter early-return branch. - test_input_message_with_huge_end_tick_does_not_allocate_unbounded_buffer: end-to-end via public MessageSender::send API. Builds an InputMessage with end_tick = server_tick + 30_000 and sends it from a client that legitimately owns the target entity. Asserts the server's InputBuffer has fewer than 1000 entries (without the fix it grows to ~30 000). Behavioural red: with the fix reverted, the first integration test fails with "Server applied client 0's forged input to client 1's entity"; the third fails with "DOS: server allocated 29998 entries". Behavior change: the server now requires an input-bearing entity to have ControlledBy { owner: <client peer entity> } so the client peer's ControlledByRemote includes it. Upstream tests that drove inputs through entities without ControlledBy needed updating: - lightyear_tests/src/client_server/input/leafwing.rs (test_leafwing_input_rebroadcast, test_rebroadcast_initializes_action_state_from_buffer) - lightyear_tests/src/client_server/input/bei.rs (test_input_broadcasting_prediction) - lightyear_tests/src/client_server/input/native.rs (test_remote_client_replicated_input, test_remote_client_predicted_input, test_input_broadcasting_prediction, test_input_custom_rebroadcast) - lightyear_tests/src/host_server/input/native.rs (test_remote_client_replicated_input, test_remote_client_predicted_input) - lightyear_tests/src/client_server/prediction/rollback.rs (setup_stepper_for_input_rollback helper covers test_input_rollback_always_mode, test_last_confirmed_input_multiple_clients, test_input_rollback_check_mode_earliest_mismatch, test_no_rollback_without_input_mismatches) The pre-existing test_server_just_pressed failure (input flow bug unrelated to authorization) is unchanged and not addressed by this patch. Companion PRs: cBournhonesque#1470 (sync floor), cBournhonesque#1471 (server pop wipe). Doc note for maintainer: book/src/concepts/advanced_replication/inputs.md should mention that input-bearing entities need ControlledBy for server acceptance under the new auth filter, and the doc comment on receive_input_message should reference is_input_target_authorized.
6a98d1a to
a0a29c2
Compare
Contributor
Author
|
Should be ready for review now |
Dastari
pushed a commit
to Dastari/lightyear
that referenced
this pull request
May 26, 2026
Carry cBournhonesque#1476 (upstream PR head a0a29c2). Reject forged InputTarget::Entity entries unless the target is in the sender peer's ControlledByRemote relationship target, with local clients exempt for host-server mode. Filter before rebroadcast so the server cannot relay forged input messages. Reject input messages whose end_tick is outside the configured server lookahead window. In this fork Tick is u16, so the helper bounds the wrapping diff to [-256, +64] ticks. Tests cover authorization helpers, lookahead bounds, spoofed target rejection, empty-after-filter messages, and huge end_tick allocation protection. Existing tests that send inputs through replicated entities were updated to add ControlledBy where required.
This was referenced Jun 23, 2026
Contributor
Author
|
Splitting this into two focused PRs, rebased onto current
Both are re-derived against current |
cBournhonesque
pushed a commit
that referenced
this pull request
Jun 24, 2026
A malicious client can send an `InputMessage` with a far-future `end_tick` (e.g. `server_tick + 30_000`). `InputBuffer::set_raw` / `extend_to_range` then extend the internal `VecDeque` to fit that tick, allocating one entry per intermediate tick — ~30k entries from a single message, repeatable across messages and connections (memory-exhaustion DoS). Reject input messages whose `end_tick` falls outside `[server_tick - 256, server_tick + 64]` before any buffer write, via a new `is_input_within_lookahead` helper in the server receive path. Legitimate clients run at most a few ticks ahead (typical input-delay is 0–3 ticks), so the window is generous. The symmetric past bound also closes a wrap-around case: `end_tick = server_tick + 2^31` makes `Tick - Tick` wrap to `i32::MIN`, which a forward-only `delta <= MAX` check would accept. Tests: - unit tests for the bound (forward, past, far-future, i32::MIN wrap); - an end-to-end regression test sending a forged message via the public `MessageSender::send::<InputChannel>` API and asserting the server's InputBuffer stays bounded (29998 entries without the fix → <1000 with). Split out from #1476 (the input-target authorization half follows in a separate PR). Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Here are two input validation bugs that I found during my tinkering and prompting Claude. Note, there is downstream impact related to
ControlledBynow being enforced. If you want the doc and examples updated as well, let me know. Or if you prefer more compact comments on the code.The bug — two related input-pipeline security gaps
1. Spoofed
InputTarget::Entitylets a modified client hijack any entitylightyear_inputs/src/server.rs::receive_input_messagereadsInputTarget::Entity(entity)from the wire and writes the inputs to that entity'sInputBufferwith no authorization check. Note that #1361's new client-sideControlledByfilter inprepare_input_messagedoes NOT close this gap on its own:ControlledByis server-side state and is never replicated to clients, so the client filter only kicks in when a user manually insertsControlledByon the local replica — and a tampered client trivially skips the check anyway. The receive path itself must enforce the boundary.The legitimate client-side
prepare_input_messagefilters byWith<InputMap<A>>, so a well-behaved client only emits inputs for entities it controls. A modified client bypasses this filter:SendEntityMapis populated bidirectionally —local_to_remotemaps the client's local id to the server-local id, marked "already mapped" so the receive-side mapper accepts the id as-is.InputMapon the foreign entity's local replica (or builds the message manually) and sendsInputTarget::Entity(victim_local_id).SendEntityMapremaps it tovictim_server_idwith the "already mapped" flag.receive_input_messageaccepts it, writes to victim'sInputBuffer.update_action_stateapplies it to victim'sActionState.fixed_update_statenext tick.Any server-side gameplay system running in
FixedUpdate(most game logic) now sees the attacker's input on the victim's entity.2. Unbounded
end_ticklets a client exhaust server memoryInputBuffer::set_raw(called transitively fromreceive_input_message) extends itsVecDequeto fit anytickvalue passed to it, filling intermediate entries withSameAsPrecedent:A modified client sending
end_tick = server_tick + 30_000triggers a 30 000-entry allocation per message; repeated across messages and connections, the server is memory-exhausted.The fix
Two
pub(crate)helpers inlightyear_inputs/src/server.rs, both wired intoreceive_input_messageBEFORE the rebroadcast and per-target-apply paths.is_input_target_authorizedThe natural authorization map is
ControlledByRemote— the relationship-target component on a peer's connection entity, auto-populated whenControlledBy { owner: peer }is added to a controlled entity. The helper checks list membership:Returns
falsewhen the peer has noControlledByRemoteat all (no controlled entities yet) or when the target isn't in the list.is_input_within_lookaheadPost-#1361,
Tickisu32andTick - Tickreturnsi32. The forward bound (MAX_INPUT_LOOKAHEAD_TICKS = 64) is what blocks the unbounded-allocation DOS — a forgedend_tick = server_tick + 30_000no longer fits, and the receive path drops the message beforeInputBuffer::set_rawcan extend the buffer. The symmetric past bound (-MAX_INPUT_PAST_TICKS = -256) is cheap belt-and-suspenders: in thei32regime, the wrap-around vector at+2^31requires the server to run ~1.06 years of continuous ticks at 64 Hz to reach the boundary, so it's practically unreachable, but the past bound is still useful for rejecting absurdly stale messages (256 ticks at 64 Hz ≈ 4 s of network lag — legitimate clients should not be that far behind).Wired into
receive_input_messageInputTarget::PreSpawned(hash)bypasses the authorization check at the call site. The hash is computed from spawn tick + sorted component net-IDs + optional user salt; a client that knows the convention CAN potentially forge a colliding hash for a constant user-salted PreSpawned. This is a pre-existing risk that pre-dates this patch and is out of scope here; a follow-up could extendPreSpawnedwith anowner: Entityor similar binding.Why filter pre-rebroadcast, not at the per-target apply step
If the auth check ran only inside the per-target apply loop, the
#[cfg(feature = "prediction")]rebroadcast block above it would forward forged inputs to all OTHER clients before the server dropped them locally. The server would effectively become a spam relay for forged input messages. Moving the check into aretainfilter before the rebroadcast closes that hole — both the rebroadcast and the per-target apply operate on the already-filtered message.Why
is_local()bypassHost-server mode runs a client in-process with the server. Local clients drive their inputs through the server's in-process state path and may not have a
ControlledByRemoterelationship populated the same way (since the existing early-return foris_local() && !rebroadcast_inputsalready short-circuits the common host-server path). Skipping the authorization filter foris_local()is consistent with that existing exemption and matches the trust model — a host client IS the server.Why
ControlledByrather thanHasAuthority/AuthorityPeerThe filter uses
ControlledByRemote(the relationship-target ofControlledBy), not the replication-authority components (HasAuthority/AuthorityPeer). These are two distinct concepts in lightyear:ControlledBy { owner }— semantic ownership. "This entity is owned by peer X for input/lifetime purposes." Set at spawn and rarely changes.HasAuthority/AuthorityPeer— dynamic simulation authority. "This peer currently simulates this entity's state and is allowed to send replication updates for it." Can transfer at runtime viaGiveAuthority/RequestAuthority.examples/distributed_authorityuses both, in the way they're designed for: player entities are spawned withControlledBy { owner: client }(inputs flow fromclient); ball entities useGiveAuthorityto transfer simulation authority between peers (motion driven by aWith<HasAuthority>filter, NOT by input messages). The two concepts intentionally decouple.For input authorization,
ControlledByis the right map: a peer's inputs apply to their owned entities, regardless of who's currently simulating which entity.Caveat for advanced use. If you have a use case where input authority itself needs to follow authority transfer (e.g. a vehicle multiple players take turns driving via inputs), you'll need to update
ControlledByalongside theGiveAuthoritycall. Most games don't need this —distributed_authoritydoes not — but the docstring onis_input_target_authorizedflags it so users hitting this pattern know what to update.Test — TDD-first: behavioural red on bare main, then green
The integration tests were written FIRST against bare
upstream/main(no fix) to confirm both vulnerabilities are reachable on the current tip, then the fix was applied to flip them green.Unit tests (
lightyear_inputs::server::patch_tests)Six tests covering both helpers:
The wrap-around tests cover the
i32::MAX-side boundary at both a low server tick and an upper-half-of-u32server tick to verify the symmetric bound catches thei32overflow at any server-tick position. Whilei32wrap-around is practically unreachable in real-world server uptime (~1 year at 64 Hz), the unit tests defend the invariant in case future changes alter the tick type or arithmetic.Integration tests (
lightyear_tests/src/client_server/input/leafwing.rs)Test 1 — spoofed-target attack. Sets up 2 clients, two server entities (one per client) with
ControlledBy. On client 0, it insertsInputMap<LeafwingInput1>on BOTH local replicas:KeyJ → Jumpon the victim's,KeyA → Jumpon the attacker's own. Holding both keys means the legitimateprepare_input_messagequery emits messages for both — one withtarget = victim_server_entity(the attack) and one withtarget = attacker_server_entity(legitimate). Two assertions:ActionState::pressed(&Jump)isfalse.ActionState::pressed(&Jump)istrue.The negative assertion catches the spoofed-target attack landing. The positive assertion catches an over-broad defense that drops all cross-client inputs.
Test 2 — empty-after-filter early-return. Sets up just the victim entity, no entity for client 0. Client 0 forges an input message targeting the victim. After server-side
retain,message.inputsis empty and the early-return branch fires. Covers a code path the first test never reaches (always leaves at least one authorized entry).Test 3 — end_tick DOS. End-to-end via public
MessageSender::send::<InputChannel>(no test-only API additions). Builds anInputMessage<LeafwingSequence<LeafwingInput1>>on the client side withend_tick = server_tick + 30_000, sends it from a client that legitimately owns the target entity. After the server's receive runs, asserts that the target'sInputBuffer::len()is under 1000 (without the fix, it grows to ~30 000). Models exactly what a modified client binary could do — there are no internal-API hooks.Behavioural red (bare upstream/main, no fix)
All three integration tests fail with explicit security-failure messages:
Green (with the fix)
Full suite — no regressions
cargo test -p lightyear_tests --lib -- --test-threads=1(serial run, avoids unrelated parallel-test flakiness): 124 passed, 1 failed. The single failure istest_server_just_pressed, a pre-existing upstream input-flow bug that also fails on bareupstream/main; not addressed by this patch.Safety / risk
Correctness. The authorization filter operates on
ControlledByRemotemembership, which is already lightyear's source of truth for which peer controls which entity. The semantics match the existingprepare_input_messagesend-side filter (With<InputMap<A>>) — a legitimate client only emits inputs for entities it hasInputMapon, and those entities are the ones markedControlledBy { owner: client }on the server. The receive-side filter just enforces what the send-side already does for honest clients.Past-tick rejection. Past-direction messages were previously handled harmlessly by
InputBuffer::set_raw's start-tick guard. The newMAX_INPUT_PAST_TICKS = 256bound is a security tightening — past messages > 256 ticks behind are now dropped at receive instead of being silently discarded byset_raw. 256 ticks at 64 Hz is ~4 seconds of network lag; legitimate clients should not be that far behind. If a use case needs a higher bound, the constant can be raised — happy to make it configurable viaServerInputConfigif reviewers prefer.Host-server mode. The
is_local()bypass mirrors the existing early-return at the top ofreceive_input_message. Host clients pass through both gates unchanged.Authority transfer. Documented in the helper docstring: callers must reinsert
ControlledBywhen transferring authority. No code change to the existing authority API.Performance. One additional component fetch per peer per tick (the
Option<&ControlledByRemote>query addition). Oneretainpass per receivedInputMessage. Both are negligible.Compatibility risk for other consumers. Zero — both helpers are
pub(crate).receive_input_messageis a private system; this PR changes its internal behaviour but doesn't alter any public API surface.Behavior change for downstream users
This is the deliberate, security-positive consequence of the patch:
That requirement was previously implicit (matched what
examples/distributed_authorityand other real games already do) but unenforced. Most existing code already conforms — anything that usesControlledByto express ownership at spawn is unaffected. The change bites tests and toy setups that drove inputs through entities withoutControlledBy.Test patches included in this PR for upstream tests that were previously relying on the missing enforcement:
lightyear_tests/src/client_server/input/leafwing.rs—
test_leafwing_input_rebroadcast,test_rebroadcast_initializes_action_state_from_bufferlightyear_tests/src/client_server/input/bei.rs—
test_input_broadcasting_predictionlightyear_tests/src/client_server/input/native.rs—
test_remote_client_replicated_input,test_remote_client_predicted_input,test_input_broadcasting_prediction,test_input_custom_rebroadcastlightyear_tests/src/host_server/input/native.rs—
test_remote_client_replicated_input,test_remote_client_predicted_inputlightyear_tests/src/client_server/prediction/rollback.rs—
setup_stepper_for_input_rollback(coverstest_input_rollback_always_mode,test_last_confirmed_input_multiple_clients,test_input_rollback_check_mode_earliest_mismatch,test_no_rollback_without_input_mismatches)Each patch is a small addition:
let client_of_N = stepper.client_of(N).id();before the spawn, plusControlledBy { owner: client_of_N, lifetime: Default::default() }in the spawn bundle. No semantic change to the tests' intent.Docs the maintainer may want to update (intentionally not in this PR — flagging for direction):
book/src/concepts/advanced_replication/inputs.md— should mention that input-bearing entities needControlledBy { owner: client }for server acceptance under the new auth filter.receive_input_message(orServerInputPlugin) could referenceis_input_target_authorizedso users tracing input flow find the gate.examples/*— spot-checked, the input-using examples already setControlledBy. No changes needed.Related
This is one of several PRs sharing root-cause analysis from a downstream project that hit the input-pipeline-trust class of bugs:
get_predictpath.end_tick.