Skip to content

Local IPC, claim by claim ​

WIN-DLP-EPIC-023 asks to authenticate every local IPC channel and defend it against a hostile local process, because the service is privileged and the session process is not, and that boundary is the one a local attacker will push on.

It is the best-defended part of this product - 59 tests on the channel alone - and the defences are the right ones rather than a checklist. The audit's finding is not a hole in them: it is that the Windows half of those defences was tested only in a Windows CI job that has not yet passed on windows-latest (Windows CI status).

Implementation: agent/core/src/session_link.rs, with unix.rs and windows_impl.rs.


1. Identity comes from the kernel ​

The single most important property, and the one an implementation usually gets wrong by being polite.

ClaimProved by
The identity comes from the kernel and not from the peersession_link::unix::tests::the_identity_comes_from_the_kernel_and_not_from_the_peer
The claimed_user field is documented as advisorysession_link::tests::the_claimed_user_is_documented_as_advisory
An unresolvable uid is still an identitysession_link::unix::tests::an_unresolvable_uid_is_still_an_identity

The peer does send a claimed_user, and the agent compares it - not to trust it. On Unix, a failed peer-credential lookup refuses the connection, and a non-empty claim that contradicts the kernel's username is refused before a welcome. An unresolvable uid is retained as uid:<n>, which is still the kernel's answer. Windows applies the same fail-closed rule after pipe impersonation.

2. Who may connect at all ​

ClaimProved by
The socket lives with the agent's own state, not in /tmpsession_link::unix::tests::the_socket_lives_with_the_agents_own_state_not_in_tmp
It can be opened by the person whose session agent needs itsession_link::unix::tests::the_socket_can_be_opened_by_the_person_whose_session_agent_needs_it
A socket tightened beyond that is reported, not left looking like a bugsession_link::unix::tests::a_tightened_socket_is_reported_rather_than_left_to_look_like_a_bug
A stale socket from a killed run does not stop the next onesession_link::unix::tests::a_stale_socket_from_a_killed_run_does_not_stop_the_next_one
A mismatched Unix identity is refused before a welcomesession_link::service::tests::a_mismatched_session_claim_is_refused_before_welcome

/tmp is the wrong place for this and the test says so by name: a world-writable directory means a local process can sit on the path before the agent does.

On Windows, the same question is answered by a protected DACL and by FILE_FLAG_FIRST_PIPE_INSTANCE:

without it, anything that created a pipe of the same name earlier would own the name and the session agent would connect to it instead. With it, a second creator fails and the agent finds out rather than talking to an impostor.

…and the flag is deliberately not set on later instances, because by then the agent is itself the earlier creator. That distinction is the kind of detail this attack usually wins on.

ClaimProved by
The DACL names SYSTEM, administrators and interactive users, and nobody elsesession_link::pipe_acl_tests::the_dacl_names_the_system_administrators_and_interactive_users_and_nobody_else
No deny entry for administratorssame test
The pipe is in the local namespace, not reachable over the networksession_link::pipe_acl_tests::the_pipe_is_in_the_local_namespace
A second pipe instance accepts while the first session remains connectedsession_link::windows_impl::tests::a_second_instance_accepts_while_the_first_session_stays_connected
A notice or prompt reaches only the kernel-attributed OS session, and an unattributed event reaches nonesession_link::prompt_desk_tests::routing_never_guesses_a_console_session; a_prompt_and_notice_reach_only_the_os_session_the_event_names; an_event_without_a_session_is_sent_to_no_windows_session

The ACL and namespace assertions run everywhere (DLP-137). They were inside #[cfg(windows)] mod windows_impl, so they executed only in the windows-agent CI job, which has not yet passed on windows-latest (Windows CI status). They are assertions about a string: checking who may open a pipe needs no Windows. The constant and the tests moved to the portable module, for the same reason platform.rs gives about its own arms - the arm nobody runs is the arm this catches. The concurrent-instance test does exercise a real named pipe and therefore remains Windows-only.

The "no deny entry" assertion is worth reading twice. It is not an oversight that administrators are allowed: a channel an administrator cannot inspect or remove is indistinguishable from malware, which is the same principle the self-protection epic is built on.

3. What a connected peer may do ​

ClaimProved by
A peer that does not say hello first is droppedsession_link::service::tests::a_peer_that_does_not_say_hello_first_is_dropped
A peer that greets with neither kind is droppedsession_link::service::tests::a_peer_that_greets_with_neither_is_dropped
A session agent cannot reach the upload pathsession_link::service::tests::a_session_agent_cannot_reach_the_upload_path
On frozen Unix, a second session is still idle-onlysession_link::service::tests::a_second_session_agent_reports_idle_but_not_clipboard; an_idle_only_session_cannot_answer_the_primary_sessions_prompt (Unix-only)
Windows reports, prompts and receipts bind to the kernel OS session and current connection, never a retired readersession_link::windows_impl::tests::the_windows_server_binds_reports_answers_and_receipts_to_the_kernel_session
Another OS session cannot answer or cancel a prompt, or acknowledge its noticesession_link::prompt_desk_tests::a_prompt_and_notice_reach_only_the_os_session_the_event_names; session_link::notice_receipt_tests::a_receipt_cannot_cross_sessions_or_attachment_generations
One unresponsive Windows session does not silence anothersession_link::notice_receipt_tests::one_silent_session_does_not_silence_another_and_only_its_own_receipt_resets_it
A browser host and a session agent are served at the same timesession_link::service::tests::a_browser_host_and_a_session_agent_are_served_at_the_same_time
A clipboard is dropped when the tenant switched it offsession_link::service::tests::a_clipboard_is_dropped_when_the_tenant_switched_it_off

The third row is the one that matters most. Two kinds of peer share one socket, and a peer that greeted as a session agent and then sent a browser message would be reaching a path it never declared itself for - so the connection is dropped rather than served both ways. Role is decided at the greeting and does not change.

The sixth is the same instinct applied to policy: the switch is the tenant's, and a session agent that ignores its Welcome and reports a clipboard anyway is not trusted to honour it.

Idle reports are narrower still: the session agent sends only elapsed seconds, never input, window, process or text. The service keys the sample to the kernel-authenticated peer, expires it after 90 seconds without a refresh, and uses the most active live session when several are connected. A missing sample is unknown and therefore never a reason to pause a walk. Windows creates a named-pipe instance per connection and serves each on its own thread. Every authenticated Windows session may report its own clipboard and receives only its own prompts/notices. Reconnection retires that session's old questions and receipts; an old reader cannot answer a replacement's prompt or receipt, and an old disconnect cannot remove its route. The Unix agent retains its existing single-primary/idle-only behaviour. OS session IDs remain in memory, not logs. Queued clipboard reports also carry the originating connection generation: an OS session ID reused after logoff does not retarget an old warning to a new connection. a_queued_event_from_a_retired_connection_does_not_reach_its_replacement and a_reused_os_session_id_does_not_inherit_the_previous_connections_warning cover the delivery and suppression sides respectively.

4. What a hostile peer cannot do ​

ClaimProved by
A peer that never sends a newline cannot grow the agent until it diessession_link::unix::tests::a_peer_that_never_sends_a_newline_cannot_grow_the_agent_until_it_dies
A message is refused on length before it is parsedsession_link::tests::a_message_is_refused_on_length_before_it_is_parsed
Rubbish ends the connection rather than the agentsession_link::service::tests::rubbish_ends_the_connection_rather_than_the_agent
Rubbish is refused with a reason rather than panickingsession_link::tests::rubbish_is_refused_with_a_reason_rather_than_panicking
Content that is not base64 does not reach the scannersession_link::service::tests::content_that_is_not_base64_does_not_reach_the_scanner
A channel that could not be opened is not fatal, and says whysession_link::service::tests::a_channel_that_could_not_be_opened_is_not_fatal_and_says_why
An answer to a question nobody asked is droppedsession_link::prompt_desk_tests::an_answer_to_a_question_nobody_asked_is_dropped
A killed session agent cannot be mistaken for one that stayed silentsession_link::prompt_desk_tests::a_session_agent_killed_mid_question_cannot_ask_rather_than_no_answer
A refused connection is said, by class and never by content, at most one line in ten secondssession_link::tests::a_refused_connection_names_its_class_and_never_its_content, refused_connections_are_said_at_most_every_ten_seconds
Every refusal is counted, said or not, and the counts reach the consolesession_link::tests::refusals_are_counted_by_class_and_none_is_no_report; console endpoint-detail.test.tsx "refused session-channel connections"

Bounding before parsing is the difference between a limit and a denial-of-service: an unbounded read from an unprivileged local process is the cheapest attack on a privileged service there is.

A probe of the pipe used to leave no trace (DLP-1689): a connection that closed early, sent rubbish or opened with anything but a greeting was dropped without a log line. Each is now one rate-limited line naming the class and the peer's process and session, and since DLP-1706 every refusal, including a greeting that names another user and a peer Windows cannot identify, is counted from the agent's start and carried in the health report. The endpoint page shows "Session channel: N connections refused since the agent started" with the count for each reason. A restart starts the count again.

The last two are from DLP-113, and they are about a peer that is hostile by being absent: a compromised session agent can answer what it was asked and cannot conjure a question, and killing it produces "nobody could be asked" rather than silence that reads as consent.


5. What is absent ​

CapabilityState
A Windows pipe-squatting and impersonation reviewAbsent. DLP-197
A test that actually squats the pipeAbsent. DLP-215

FILE_FLAG_FIRST_PIPE_INSTANCE is the defence and it is applied; what has never happened is somebody creating that pipe first and watching the agent refuse to start rather than talk to them.

6. What is not covered ​

  • The pipe round-trip and concurrent-instance tests still need Windows, and legitimately: real kernel objects cannot be faked on Linux. Those tests stay in windows_impl and run in the windows-agent job.
  • The hostile-client cases run in CI, not on a managed fleet. DLP-215 has a standard user squat the pipe name and verifies that both service and session agent refuse it; a real enterprise endpoint stack has not been part of that exercise.
  • The browser host's allowlist still needs an external identity. Host registration and policy templates exist (DLP-236), but a signed, published extension id needs the organisation's store account and signing process (DLP-1203). only_the_allowlisted_extension_is_served proves the mechanism, not that external deployment.

Statements here describe what is built and tested; planned work is marked as planned.