From c9c788fbcc645d14730880c6efe45badc710ad4b Mon Sep 17 00:00:00 2001 From: Richard Markiewicz Date: Wed, 9 Sep 2026 13:55:58 -0400 Subject: [PATCH 1/2] fix: preserve message flags when decoding NOW_EXEC_RUN_MSG `NowExecRunMsg::decode_from_body` discarded the header flags and rebuilt them from payload content, making it the only exec message that did not preserve flags on decode. Every other exec message seeds its flags from `from_bits_retain(header.flags)`. Any flag on NOW_EXEC_RUN_MSG other than DIRECTORY_SET was therefore silently dropped by the decoder, so a server could not observe an option the client had set. DIRECTORY_SET is still derived from the payload rather than trusted from the header, because peers older than v1.1 omit the directory field entirely. No test accompanies this on its own: NOW_EXEC_RUN_MSG has no second flag to assert against yet. Coverage arrives with the first one added. Co-Authored-By: Claude Opus 5 (1M context) --- protocols/rust/now-proto-pdu/src/exec/run.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/protocols/rust/now-proto-pdu/src/exec/run.rs b/protocols/rust/now-proto-pdu/src/exec/run.rs index eb3d50b..fdecf8d 100644 --- a/protocols/rust/now-proto-pdu/src/exec/run.rs +++ b/protocols/rust/now-proto-pdu/src/exec/run.rs @@ -102,23 +102,29 @@ impl<'a> NowExecRunMsg<'a> { Self::FIXED_PART_SIZE + self.command.size() + self.directory.size() } - pub(super) fn decode_from_body(_header: NowHeader, src: &mut ReadCursor<'a>) -> DecodeResult { + pub(super) fn decode_from_body(header: NowHeader, src: &mut ReadCursor<'a>) -> DecodeResult { ensure_fixed_part_size!(in: src); - let mut flags = NowExecRunFlags::empty(); + // Flags are preserved as sent, otherwise every flag other than DIRECTORY_SET would be + // silently dropped on decode. + let mut flags = NowExecRunFlags::from_bits_retain(header.flags); let session_id = src.read_u32(); let command: NowVarStr<'_> = NowVarStr::decode(src)?; - // Directory field has been added in v1.1. + // Directory field has been added in v1.1, so DIRECTORY_SET is derived from the payload + // instead of being trusted from the header. let directory = if !src.is_empty() { let directory = NowVarStr::decode(src)?; - if !directory.is_empty() { + if directory.is_empty() { + flags.remove(NowExecRunFlags::DIRECTORY_SET); + } else { flags |= NowExecRunFlags::DIRECTORY_SET; } directory } else { + flags.remove(NowExecRunFlags::DIRECTORY_SET); NowVarStr::default() }; From 454f2141b70f18cd3bb7d160487da81064e4a281 Mon Sep 17 00:00:00 2001 From: Richard Markiewicz Date: Wed, 9 Sep 2026 13:56:28 -0400 Subject: [PATCH 2/2] feat: add elevated execution support to exec messages Updates NOW-PROTO to v1.7, adding the ability for a client to request that an exec session run with elevated privileges. - Add `NOW_EXEC_FLAG_*_ELEVATED` to the run, process, shell, batch, winps and pwsh exec messages. The flag expresses intent only and never names a mechanism, so the server remains free to change how it elevates. It raises the privileges of the identity that would otherwise run the command and carries no credentials; running a command as a different user is a separate concern for a separate field. - Add `NOW_CAP_EXEC_ELEVATE_SHELL`, advertised by servers that elevate through the platform shell (consent prompt, no stdio redirection). It is the only elevation capability defined: a mechanism with different observable properties is expected to arrive as an additional capability rather than by redefining this one, so a client always learns what to expect before it sends. - Refuse an elevated request in `NowClient` unless the negotiated capset advertises elevation. A server older than 1.7 does not know the flag and would execute the command without elevation, and the caller has no way to detect that, so the request is rejected locally rather than downgraded silently. - Specify the mechanism-dependent semantics in a new "Elevated Execution" section: how elevation is requested, what it does and does not mean, that a server must fail rather than silently execute unelevated, that capability negotiation is the only reliable signal because older servers ignore the flag, and what the shell mechanism implies for stdio, result reporting and abort. - Add `NOW_EXEC_FLAG_BATCH_NO_EXIT` (`cmd /K` rather than `/C`), so an elevated batch session, which has no stdio, can still leave its console open for the user to read. Must be ignored when the session redirects stdio. Exposed on `ExecBatchParams` alongside the existing winps and pwsh `NoExit` options. The round-trip tests added here are also the first coverage of the RUN decoder fix in the preceding commit: ELEVATED is the first NOW_EXEC_RUN_MSG flag that would have been dropped by it. Co-Authored-By: Claude Opus 5 (1M context) --- protocols/docs/NOW-spec.md | 64 ++++++- .../Devolutions.NowClient/src/AExecParams.cs | 6 + .../src/ExecBatchParams.cs | 31 ++++ .../src/ExecProcessParams.cs | 17 +- .../src/ExecPwshParams.cs | 15 ++ .../src/ExecRunParams.cs | 15 ++ .../src/ExecShellParams.cs | 15 ++ .../src/ExecWinPsParams.cs | 15 ++ .../Devolutions.NowClient/src/NowClient.cs | 30 ++++ .../src/MsgChannel.cs | 6 +- .../src/NowExecStyles.cs | 158 ++++++++++++++++++ .../src/Capabilities/NowCapabilityExec.cs | 11 +- .../src/Messages/NowMsgExecBatch.cs | 42 ++++- .../src/Messages/NowMsgExecProcess.cs | 23 ++- .../src/Messages/NowMsgExecPwsh.cs | 15 ++ .../src/Messages/NowMsgExecRun.cs | 27 ++- .../src/Messages/NowMsgExecShell.cs | 23 ++- .../src/Messages/NowMsgExecWinPs.cs | 15 ++ .../src/NowProtoVersion.cs | 2 +- .../rust/now-proto-pdu/src/channel/capset.rs | 8 +- .../rust/now-proto-pdu/src/exec/batch.rs | 32 ++++ .../rust/now-proto-pdu/src/exec/process.rs | 16 ++ protocols/rust/now-proto-pdu/src/exec/pwsh.rs | 10 ++ protocols/rust/now-proto-pdu/src/exec/run.rs | 16 ++ .../rust/now-proto-pdu/src/exec/shell.rs | 16 ++ .../rust/now-proto-pdu/src/exec/win_ps.rs | 16 ++ .../tests/proto/channel.rs | 4 +- .../now-proto-testsuite/tests/proto/exec.rs | 148 ++++++++++++++++ 28 files changed, 777 insertions(+), 19 deletions(-) diff --git a/protocols/docs/NOW-spec.md b/protocols/docs/NOW-spec.md index 3f57c56..98d2436 100644 --- a/protocols/docs/NOW-spec.md +++ b/protocols/docs/NOW-spec.md @@ -3,7 +3,7 @@ TOC is generated in [Obsidian](obsidian.md) via [TOC plugin](https://github.com/hipstersmoothie/obsidian-plugin-toc) --> -# NOW-PROTO 1.6 +# NOW-PROTO 1.7 - [Messages](#messages) - [Transport](#transport) - [Message Syntax](#message-syntax) @@ -46,6 +46,7 @@ TOC is generated in [Obsidian](obsidian.md) via - [NOW_EXEC_BATCH_MSG](#now_exec_batch_msg) - [NOW_EXEC_WINPS_MSG](#now_exec_winps_msg) - [NOW_EXEC_PWSH_MSG](#now_exec_pwsh_msg) + - [Elevated Execution](#elevated-execution) - [RDM Messages](#rdm-messages) - [NOW_RDM_MSG](#now_rdm_msg) - [NOW_RDM_CAPABILITIES_MSG](#now_rdm_capabilities_msg) @@ -328,6 +329,7 @@ increment major version; Protocol implementations with different major version a | NOW_CAP_EXEC_STYLE_PWSH
0x0020 | PowerShell 7 (.ps1) execution style. | | NOW_CAP_EXEC_UNICODE_CONSOLE
0x0040 | Host supports encoding control flags (RAW_ENCODING, UNICODE_CONSOLE, and ENCODING_UTF8). | | NOW_CAP_EXEC_IO_REDIRECTION
0x1000 | Set if host implements exec session IO redirection. | +| NOW_CAP_EXEC_ELEVATE_SHELL
0x0080 | Set if host can elevate an exec session using the platform shell. Elevation may prompt the interactive user for consent, and IO redirection is unavailable for elevated sessions. See [Elevated Execution](#elevated-execution). | @@ -956,6 +958,7 @@ packet-beta | Flag | Meaning | |----------------------------------------|---------------------------| | NOW_EXEC_FLAG_RUN_DIRECTORY_SET
0x0001 | `directory` field contains non-default value. | +| NOW_EXEC_FLAG_RUN_ELEVATED
0x0002 | Execute the command with elevated privileges. The elevation mechanism is selected by the server and advertised in `execCapset`; see [Elevated Execution](#elevated-execution). | **sessionId (4 bytes)**: A 32-bit unsigned integer containing a unique remote execution session id. @@ -993,6 +996,7 @@ packet-beta | NOW_EXEC_FLAG_PROCESS_PARAMETERS_SET
0x0001 | `parameters` field contains non-default value. | | NOW_EXEC_FLAG_PROCESS_DIRECTORY_SET
0x0002 | `directory` field contains non-default value.| | NOW_EXEC_FLAG_PROCESS_ENCODING_UTF8
0x0004 | Enables OEM-to-UTF-8 transcoding for stdin, stdout, and stderr. Without this flag, data streams are passed through as raw bytes without encoding conversion. | +| NOW_EXEC_FLAG_PROCESS_ELEVATED
0x0008 | Execute the command with elevated privileges. The elevation mechanism is selected by the server and advertised in `execCapset`; see [Elevated Execution](#elevated-execution). | | NOW_EXEC_FLAG_PROCESS_IO_REDIRECTION
0x1000 | Enable stdio (stdout, stderr, stdin) redirection. | | NOW_EXEC_FLAG_PROCESS_DETACHED
0x8000 | Detached mode: the process is started without tracking execution or sending back output. | @@ -1035,6 +1039,7 @@ packet-beta |----------------------------------------|---------------------------| | NOW_EXEC_FLAG_SHELL_SHELL_SET
0x0001 | `shell` field contains non-default value. | | NOW_EXEC_FLAG_SHELL_DIRECTORY_SET
0x0002 | `directory` field contains non-default value. | +| NOW_EXEC_FLAG_SHELL_ELEVATED
0x0008 | Execute the command with elevated privileges. The elevation mechanism is selected by the server and advertised in `execCapset`; see [Elevated Execution](#elevated-execution). | | NOW_EXEC_FLAG_SHELL_IO_REDIRECTION
0x1000 | Enable stdio (stdout, stderr, stdin) redirection. | | NOW_EXEC_FLAG_SHELL_DETACHED
0x8000 | Detached mode: the shell is started without tracking execution or sending back output. | @@ -1077,6 +1082,8 @@ packet-beta | NOW_EXEC_FLAG_BATCH_DIRECTORY_SET
0x0001 | `directory` field contains non-default value. | | NOW_EXEC_FLAG_BATCH_RAW_ENCODING
0x0002 | Disables the default OEM-to-UTF-8 transcoding: data streams are passed through as raw bytes without any encoding conversion. | | NOW_EXEC_FLAG_BATCH_UNICODE_CONSOLE
0x0004 | Enables Unicode console: agent injects `@chcp 65001 > nul` and writes the script in BOM-less UTF-8. Implies UTF-8 stdout/stderr streams. | +| NOW_EXEC_FLAG_BATCH_ELEVATED
0x0008 | Execute the command with elevated privileges. The elevation mechanism is selected by the server and advertised in `execCapset`; see [Elevated Execution](#elevated-execution). | +| NOW_EXEC_FLAG_BATCH_NO_EXIT
0x0010 | Keeps the command interpreter running after the batch file completes (`cmd /K` rather than `/C`). MUST be ignored when the session redirects stdio, where the hidden interpreter would never exit. | | NOW_EXEC_FLAG_BATCH_IO_REDIRECTION
0x1000 | Enable stdio (stdout, stderr, stdin) redirection. | | NOW_EXEC_FLAG_BATCH_DETACHED
0x8000 | Detached mode: the batch is started without tracking execution or sending back output. | @@ -1132,6 +1139,7 @@ packet-beta | NOW_EXEC_FLAG_PS_DIRECTORY_SET
0x0100 | `directory` field contains non-default value and specifies command working directory | | NOW_EXEC_FLAG_PS_RAW_ENCODING
0x0200 | Disables the default OEM-to-UTF-8 transcoding: data streams are passed through as raw bytes without any encoding conversion. | | NOW_EXEC_FLAG_PS_UNICODE_CONSOLE
0x0400 | Enables Unicode console: agent injects `$OutputEncoding = [Console]::InputEncoding = [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new()` at script start. Implies stdin/stdout/stderr streams are UTF-8. | +| NOW_EXEC_FLAG_PS_ELEVATED
0x0800 | Execute the command with elevated privileges. The elevation mechanism is selected by the server and advertised in `execCapset`; see [Elevated Execution](#elevated-execution). | | NOW_EXEC_FLAG_PS_IO_REDIRECTION
0x1000 | Enable stdio (stdout, stderr, stdin) redirection. | | NOW_EXEC_FLAG_PS_SERVER_MODE
0x2000 | Run PowerShell in server mode. | | NOW_EXEC_FLAG_PS_DETACHED
0x8000 | Detached mode: PowerShell is started without tracking execution or sending back output. | @@ -1196,6 +1204,56 @@ packet-beta **configurationName (variable)**: A NOW_VARSTR structure, same as with NOW_EXEC_WINPS_MSG. +#### Elevated Execution + +Any exec message may request elevated execution by setting its `NOW_EXEC_FLAG_*_ELEVATED` flag. +The flag expresses *intent* only: it never names a mechanism, and the client does not choose one. + +`ELEVATED` raises the privileges of the identity that would otherwise run the command, which is the +session user. It does not select a different identity, and it carries no credentials. Running a +command as another user is a separate concern and would be expressed by a separate field. + +**Requesting elevation.** A client SHOULD NOT set an `ELEVATED` flag unless the server advertised +at least one `NOW_CAP_EXEC_ELEVATE_*` capability. A server that receives an `ELEVATED` flag without +having advertised any elevation capability MUST fail the session with `NOW_CODE_NOT_IMPLEMENTED`. +A server predating this version ignores the flag entirely and executes without elevation, so a +client MUST NOT treat the absence of a failure as evidence that elevation took place: capability +negotiation is the only reliable signal, and a client is expected to refuse the request locally +when no elevation capability was advertised. +A server MUST NOT execute a command without elevation after being asked to elevate it: silently +downgrading the request denies the client any way to detect that privileges were not granted. + +**Mechanism selection.** The server selects the mechanism and advertises it in `execCapset`. Only +`NOW_CAP_EXEC_ELEVATE_SHELL` is defined at present. A mechanism with different observable +properties, such as one that raises no consent prompt or preserves stdio redirection, is expected to +be advertised as an additional capability rather than by changing the meaning of an existing one, so +a client always learns what to expect before it sends a request. + +**Capability scope.** An elevation capability states that the server implements elevation, not that +every execution style it advertises can be elevated. A server MAY support elevation for some styles +and not others, and MUST fail a request for a style it cannot elevate rather than executing that +request without elevation. A client MUST therefore be prepared for a per-style failure even when an +elevation capability was advertised. + +**Semantics under NOW_CAP_EXEC_ELEVATE_SHELL.** The server elevates through the platform shell, +which on Windows raises a consent prompt on the interactive user's desktop: + +- A client SHOULD omit `NOW_EXEC_FLAG_*_IO_REDIRECTION` when requesting elevation, and MUST NOT + assume the server honours it. The server MUST NOT open stdio channels for the session unless it + advertises a capability stating that elevated sessions keep their stdio. +- Unless the request uses `NOW_EXEC_RUN_MSG`, which is never tracked, or sets + `NOW_EXEC_FLAG_*_DETACHED`, which promises no tracking, the server SHOULD still track the + session and report the real exit code in `NOW_EXEC_RESULT_MSG`, so a client can distinguish + success from failure without stdio. +- `NOW_EXEC_ABORT_MSG` and `NOW_EXEC_CANCEL_REQ_MSG` MAY fail with `NOW_CODE_NOT_IMPLEMENTED`, + because the elevated child runs at a higher integrity level than the server's session process. +- Elevation MAY require interactive consent, so the request MAY fail when no user is present to + grant it, or when the user declines. + +**Failure reporting.** A server that cannot grant elevation MUST report it distinctly rather than +succeeding: the account has no elevated token available, the user declined consent, or elevation is +not implemented for the requested exec style. + ### RDM Messages #### NOW_RDM_MSG @@ -1484,3 +1542,7 @@ packet-beta - Add `NOW_EXEC_FLAG_PROCESS_ENCODING_UTF8` flag for process exec commands. - Add `NOW_EXEC_FLAG_*_UNICODE_CONSOLE` flags for batch (cmd), winps, and pwsh exec commands. - Add `NOW_CAP_EXEC_UNICODE_CONSOLE` capability flag. +- 1.7 + - Add `NOW_EXEC_FLAG_*_ELEVATED` flags for run, process, shell, batch, winps, and pwsh exec messages. + - Add `NOW_CAP_EXEC_ELEVATE_SHELL` capability flag. + - Add `NOW_EXEC_FLAG_BATCH_NO_EXIT` flag for batch exec commands. diff --git a/protocols/dotnet/Devolutions.NowClient/src/AExecParams.cs b/protocols/dotnet/Devolutions.NowClient/src/AExecParams.cs index 8127622..6c5b085 100644 --- a/protocols/dotnet/Devolutions.NowClient/src/AExecParams.cs +++ b/protocols/dotnet/Devolutions.NowClient/src/AExecParams.cs @@ -42,6 +42,12 @@ public StartedHandler OnStarted } } + /// + /// Whether the caller asked for elevated execution. Inspected by + /// so a request cannot be sent to a server that never advertised an elevation capability. + /// + internal bool IsElevated { get; set; } + internal ExecSession ToExecSession(uint sessionId, ChannelWriter commandWriter) { return new ExecSession( diff --git a/protocols/dotnet/Devolutions.NowClient/src/ExecBatchParams.cs b/protocols/dotnet/Devolutions.NowClient/src/ExecBatchParams.cs index 0963aa4..1617b9a 100644 --- a/protocols/dotnet/Devolutions.NowClient/src/ExecBatchParams.cs +++ b/protocols/dotnet/Devolutions.NowClient/src/ExecBatchParams.cs @@ -54,6 +54,26 @@ public ExecBatchParams UnicodeConsole(bool enable = true) return this; } + /// + /// Keep the command interpreter open after the batch file completes (`cmd /K` rather than + /// `/C`). Ignored by the server when the session redirects stdio. + /// + public ExecBatchParams NoExit(bool enable = true) + { + _noExit = enable; + return this; + } + + /// + /// Execute with elevated privileges. Requires the host to advertise an elevation + /// capability; under shell-based elevation the session has no IO redirection. + /// + public ExecBatchParams Elevated(bool enable = true) + { + IsElevated = enable; + return this; + } + internal NowMsgExecBatch ToNowMessage(uint sessionId) { var builder = new NowMsgExecBatch.Builder(sessionId, command); @@ -83,6 +103,16 @@ internal NowMsgExecBatch ToNowMessage(uint sessionId) builder.EnableUnicodeConsole(); } + if (IsElevated) + { + builder.EnableElevated(); + } + + if (_noExit) + { + builder.EnableNoExit(); + } + return builder.Build(); } @@ -91,5 +121,6 @@ internal NowMsgExecBatch ToNowMessage(uint sessionId) private bool _detached = false; private bool _rawEncoding = false; private bool _unicodeConsole = false; + private bool _noExit = false; } } \ No newline at end of file diff --git a/protocols/dotnet/Devolutions.NowClient/src/ExecProcessParams.cs b/protocols/dotnet/Devolutions.NowClient/src/ExecProcessParams.cs index 2a5f57b..b556efb 100644 --- a/protocols/dotnet/Devolutions.NowClient/src/ExecProcessParams.cs +++ b/protocols/dotnet/Devolutions.NowClient/src/ExecProcessParams.cs @@ -1,4 +1,4 @@ -using Devolutions.NowProto.Messages; +using Devolutions.NowProto.Messages; namespace Devolutions.NowClient { @@ -54,6 +54,16 @@ public ExecProcessParams EncodingUtf8(bool enable = true) return this; } + /// + /// Execute with elevated privileges. Requires the host to advertise an elevation + /// capability; under shell-based elevation the session has no IO redirection. + /// + public ExecProcessParams Elevated(bool enable = true) + { + IsElevated = enable; + return this; + } + internal NowMsgExecProcess ToNowMessage(uint sessionId) { var builder = new NowMsgExecProcess.Builder(sessionId, filename); @@ -83,6 +93,11 @@ internal NowMsgExecProcess ToNowMessage(uint sessionId) builder.EnableEncodingUtf8(); } + if (IsElevated) + { + builder.EnableElevated(); + } + return builder.Build(); } diff --git a/protocols/dotnet/Devolutions.NowClient/src/ExecPwshParams.cs b/protocols/dotnet/Devolutions.NowClient/src/ExecPwshParams.cs index adb2b8f..aefaf3b 100644 --- a/protocols/dotnet/Devolutions.NowClient/src/ExecPwshParams.cs +++ b/protocols/dotnet/Devolutions.NowClient/src/ExecPwshParams.cs @@ -127,6 +127,16 @@ public ExecPwshParams UnicodeConsole(bool enable = true) return this; } + /// + /// Execute with elevated privileges. Requires the host to advertise an elevation + /// capability; under shell-based elevation the session has no IO redirection. + /// + public ExecPwshParams Elevated(bool enable = true) + { + IsElevated = enable; + return this; + } + internal NowMsgExecPwsh ToNowMessage(uint sessionId) { var builder = _serverMode @@ -193,6 +203,11 @@ internal NowMsgExecPwsh ToNowMessage(uint sessionId) builder.EnableUnicodeConsole(); } + if (IsElevated) + { + builder.EnableElevated(); + } + return builder.Build(); } diff --git a/protocols/dotnet/Devolutions.NowClient/src/ExecRunParams.cs b/protocols/dotnet/Devolutions.NowClient/src/ExecRunParams.cs index d312101..3e313af 100644 --- a/protocols/dotnet/Devolutions.NowClient/src/ExecRunParams.cs +++ b/protocols/dotnet/Devolutions.NowClient/src/ExecRunParams.cs @@ -18,6 +18,16 @@ public ExecRunParams Directory(string directory) return this; } + /// + /// Execute with elevated privileges. Requires the host to advertise an elevation + /// capability; under shell-based elevation the session has no IO redirection. + /// + public ExecRunParams Elevated(bool enable = true) + { + IsElevated = enable; + return this; + } + internal NowMsgExecRun ToNowMessage(uint sessionId) { var builder = new NowMsgExecRun.Builder(sessionId, command); @@ -27,6 +37,11 @@ internal NowMsgExecRun ToNowMessage(uint sessionId) builder.Directory(_directory); } + if (IsElevated) + { + builder.EnableElevated(); + } + return builder.Build(); } diff --git a/protocols/dotnet/Devolutions.NowClient/src/ExecShellParams.cs b/protocols/dotnet/Devolutions.NowClient/src/ExecShellParams.cs index 2c01438..e7ec7a3 100644 --- a/protocols/dotnet/Devolutions.NowClient/src/ExecShellParams.cs +++ b/protocols/dotnet/Devolutions.NowClient/src/ExecShellParams.cs @@ -46,6 +46,16 @@ public ExecShellParams Detached(bool enable = true) return this; } + /// + /// Execute with elevated privileges. Requires the host to advertise an elevation + /// capability; under shell-based elevation the session has no IO redirection. + /// + public ExecShellParams Elevated(bool enable = true) + { + IsElevated = enable; + return this; + } + internal NowMsgExecShell ToNowMessage(uint sessionId) { var builder = new NowMsgExecShell.Builder(sessionId, command); @@ -70,6 +80,11 @@ internal NowMsgExecShell ToNowMessage(uint sessionId) builder.EnableDetached(); } + if (IsElevated) + { + builder.EnableElevated(); + } + return builder.Build(); } diff --git a/protocols/dotnet/Devolutions.NowClient/src/ExecWinPsParams.cs b/protocols/dotnet/Devolutions.NowClient/src/ExecWinPsParams.cs index b11acf3..ea5753a 100644 --- a/protocols/dotnet/Devolutions.NowClient/src/ExecWinPsParams.cs +++ b/protocols/dotnet/Devolutions.NowClient/src/ExecWinPsParams.cs @@ -128,6 +128,16 @@ public ExecWinPsParams UnicodeConsole(bool enable = true) return this; } + /// + /// Execute with elevated privileges. Requires the host to advertise an elevation + /// capability; under shell-based elevation the session has no IO redirection. + /// + public ExecWinPsParams Elevated(bool enable = true) + { + IsElevated = enable; + return this; + } + internal NowMsgExecWinPs ToNowMessage(uint sessionId) { var builder = _serverMode @@ -194,6 +204,11 @@ internal NowMsgExecWinPs ToNowMessage(uint sessionId) builder.EnableUnicodeConsole(); } + if (IsElevated) + { + builder.EnableElevated(); + } + return builder.Build(); } diff --git a/protocols/dotnet/Devolutions.NowClient/src/NowClient.cs b/protocols/dotnet/Devolutions.NowClient/src/NowClient.cs index 0834b1a..c5943de 100644 --- a/protocols/dotnet/Devolutions.NowClient/src/NowClient.cs +++ b/protocols/dotnet/Devolutions.NowClient/src/NowClient.cs @@ -269,6 +269,8 @@ public async Task ExecRun(ExecRunParams execParams) ThrowCapabilitiesError("Run execution style"); } + ThrowIfElevationUnsupported(execParams); + var sessionId = _nextExecSessionId++; var message = execParams.ToNowMessage(sessionId); var command = new CommandExecRun(message); @@ -289,6 +291,8 @@ public async Task ExecProcess(ExecProcessParams execParams) ThrowCapabilitiesError("Process execution style"); } + ThrowIfElevationUnsupported(execParams); + var sessionId = _nextExecSessionId++; var message = execParams.ToNowMessage(sessionId); var execSession = execParams.ToExecSession(sessionId, _commandWriter); @@ -312,6 +316,8 @@ public async Task ExecShell(ExecShellParams execParams) ThrowCapabilitiesError("Shell execution style"); } + ThrowIfElevationUnsupported(execParams); + var sessionId = _nextExecSessionId++; var message = execParams.ToNowMessage(sessionId); var execSession = execParams.ToExecSession(sessionId, _commandWriter); @@ -335,6 +341,8 @@ public async Task ExecBatch(ExecBatchParams execParams) ThrowCapabilitiesError("Batch execution style"); } + ThrowIfElevationUnsupported(execParams); + var sessionId = _nextExecSessionId++; var message = execParams.ToNowMessage(sessionId); var execSession = execParams.ToExecSession(sessionId, _commandWriter); @@ -358,6 +366,8 @@ public async Task ExecWinPs(ExecWinPsParams execParams) ThrowCapabilitiesError("Windows PowerShell execution style"); } + ThrowIfElevationUnsupported(execParams); + var sessionId = _nextExecSessionId++; var message = execParams.ToNowMessage(sessionId); var execSession = execParams.ToExecSession(sessionId, _commandWriter); @@ -381,6 +391,8 @@ public async Task ExecPwsh(ExecPwshParams execParams) ThrowCapabilitiesError("Pwsh execution style"); } + ThrowIfElevationUnsupported(execParams); + var sessionId = _nextExecSessionId++; var message = execParams.ToNowMessage(sessionId); var execSession = execParams.ToExecSession(sessionId, _commandWriter); @@ -601,6 +613,24 @@ private async Task EnsureRdmCapabilitiesSent() } } + /// + /// Refuse an elevated request unless the negotiated capset advertises a way to satisfy it. + /// A server older than NOW-PROTO 1.7 does not know the ELEVATED flag and would run the + /// command without elevation, which the caller has no way to detect. + /// + private void ThrowIfElevationUnsupported(AExecParams execParams) + { + if (!execParams.IsElevated) + { + return; + } + + if (!Capabilities.ExecCapset.HasFlag(NowCapabilityExec.ElevateShell)) + { + ThrowCapabilitiesError("Elevated execution"); + } + } + private static void ThrowCapabilitiesError(string capability) { throw new NowClientException($"{capability} is not supported by server."); diff --git a/protocols/dotnet/Devolutions.NowProto.Tests/src/MsgChannel.cs b/protocols/dotnet/Devolutions.NowProto.Tests/src/MsgChannel.cs index d8eac66..8fc1b61 100644 --- a/protocols/dotnet/Devolutions.NowProto.Tests/src/MsgChannel.cs +++ b/protocols/dotnet/Devolutions.NowProto.Tests/src/MsgChannel.cs @@ -1,4 +1,4 @@ -using Devolutions.NowProto.Capabilities; +using Devolutions.NowProto.Capabilities; namespace Devolutions.NowProto.Tests { @@ -16,7 +16,7 @@ void Capset() var encoded = new byte[] { - 0x0E, 0x00, 0x00, 0x00, 0x10, 0x01, 0x01, 0x00, 0x01, 0x00, 0x06, 0x00, 0x01, 0x00, 0x04, 0x00, 0x05, 0x00, 0x2C, 0x01, 0x00, 0x00 + 0x0E, 0x00, 0x00, 0x00, 0x10, 0x01, 0x01, 0x00, 0x01, 0x00, 0x07, 0x00, 0x01, 0x00, 0x04, 0x00, 0x05, 0x00, 0x2C, 0x01, 0x00, 0x00 }; var decoded = NowTest.MessageRoundtrip(msg, encoded); @@ -37,7 +37,7 @@ public void CapsetSimple() var encoded = new byte[] { - 0x0E, 0x00, 0x00, 0x00, 0x10, 0x01, 0x00, 0x00, 0x01, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 + 0x0E, 0x00, 0x00, 0x00, 0x10, 0x01, 0x00, 0x00, 0x01, 0x00, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }; var decoded = NowTest.MessageRoundtrip(msg, encoded); diff --git a/protocols/dotnet/Devolutions.NowProto.Tests/src/NowExecStyles.cs b/protocols/dotnet/Devolutions.NowProto.Tests/src/NowExecStyles.cs index 1f70503..292935e 100644 --- a/protocols/dotnet/Devolutions.NowProto.Tests/src/NowExecStyles.cs +++ b/protocols/dotnet/Devolutions.NowProto.Tests/src/NowExecStyles.cs @@ -278,5 +278,163 @@ public void PwshSimple() Assert.False(decoded.NonInteractive); Assert.False(decoded.NoExit); } + + // Elevation flag round-trips for every exec style. Byte arrays are the same wire encodings + // asserted by the Rust test suite, which keeps both implementations in lockstep. + + [Fact] + public void RunElevated() + { + var msg = new NowMsgExecRun.Builder(0x12345678, "a") + .EnableElevated() + .Build(); + + var encoded = new byte[] + { + 0x09, 0x00, 0x00, 0x00, 0x13, 0x10, 0x02, 0x00, 0x78, 0x56, + 0x34, 0x12, 0x01, 0x61, 0x00, 0x00, 0x00 + }; + + var decoded = NowTest.MessageRoundtrip(msg, encoded); + + Assert.True(decoded.Elevated); + } + + [Fact] + public void ProcessElevated() + { + var msg = new NowMsgExecProcess.Builder(0x12345678, "a") + .EnableElevated() + .Build(); + + var encoded = new byte[] + { + 0x0B, 0x00, 0x00, 0x00, 0x13, 0x11, 0x08, 0x00, 0x78, 0x56, + 0x34, 0x12, 0x01, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00 + }; + + var decoded = NowTest.MessageRoundtrip(msg, encoded); + + Assert.True(decoded.Elevated); + } + + [Fact] + public void ShellElevated() + { + var msg = new NowMsgExecShell.Builder(0x12345678, "a") + .EnableElevated() + .Build(); + + var encoded = new byte[] + { + 0x0B, 0x00, 0x00, 0x00, 0x13, 0x12, 0x08, 0x00, 0x78, 0x56, + 0x34, 0x12, 0x01, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00 + }; + + var decoded = NowTest.MessageRoundtrip(msg, encoded); + + Assert.True(decoded.Elevated); + } + + [Fact] + public void BatchElevatedNoExit() + { + var msg = new NowMsgExecBatch.Builder(0x12345678, "a") + .EnableElevated() + .EnableNoExit() + .Build(); + + var encoded = new byte[] + { + 0x09, 0x00, 0x00, 0x00, 0x13, 0x13, 0x18, 0x00, 0x78, 0x56, + 0x34, 0x12, 0x01, 0x61, 0x00, 0x00, 0x00 + }; + + var decoded = NowTest.MessageRoundtrip(msg, encoded); + + Assert.True(decoded.Elevated); + Assert.True(decoded.NoExit); + } + + [Fact] + public void WinPsElevated() + { + var msg = new NowMsgExecWinPs.Builder(0x12345678, "a") + .EnableElevated() + .Build(); + + var encoded = new byte[] + { + 0x0D, 0x00, 0x00, 0x00, 0x13, 0x14, 0x00, 0x08, 0x78, 0x56, + 0x34, 0x12, 0x01, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00 + }; + + var decoded = NowTest.MessageRoundtrip(msg, encoded); + + Assert.True(decoded.Elevated); + } + + [Fact] + public void PwshElevated() + { + var msg = new NowMsgExecPwsh.Builder(0x12345678, "a") + .EnableElevated() + .Build(); + + var encoded = new byte[] + { + 0x0D, 0x00, 0x00, 0x00, 0x13, 0x15, 0x00, 0x08, 0x78, 0x56, + 0x34, 0x12, 0x01, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00 + }; + + var decoded = NowTest.MessageRoundtrip(msg, encoded); + + Assert.True(decoded.Elevated); + } + + + // ELEVATED and NO_EXIT are asserted independently as well as together: the combined 0x0018 + // mask alone would stay green if the two constants were swapped. + + [Fact] + public void BatchElevatedOnly() + { + var msg = new NowMsgExecBatch.Builder(0x12345678, "a") + .EnableElevated() + .Build(); + + var encoded = new byte[] + { + 0x09, 0x00, 0x00, 0x00, 0x13, 0x13, 0x08, 0x00, 0x78, 0x56, + 0x34, 0x12, 0x01, 0x61, 0x00, 0x00, 0x00 + }; + + var decoded = NowTest.MessageRoundtrip(msg, encoded); + + Assert.True(decoded.Elevated); + Assert.False(decoded.NoExit); + } + + [Fact] + public void BatchNoExitOnly() + { + var msg = new NowMsgExecBatch.Builder(0x12345678, "a") + .EnableNoExit() + .Build(); + + var encoded = new byte[] + { + 0x09, 0x00, 0x00, 0x00, 0x13, 0x13, 0x10, 0x00, 0x78, 0x56, + 0x34, 0x12, 0x01, 0x61, 0x00, 0x00, 0x00 + }; + + var decoded = NowTest.MessageRoundtrip(msg, encoded); + + Assert.True(decoded.NoExit); + Assert.False(decoded.Elevated); + } + } } \ No newline at end of file diff --git a/protocols/dotnet/Devolutions.NowProto/src/Capabilities/NowCapabilityExec.cs b/protocols/dotnet/Devolutions.NowProto/src/Capabilities/NowCapabilityExec.cs index c2cde9b..e75ae5e 100644 --- a/protocols/dotnet/Devolutions.NowProto/src/Capabilities/NowCapabilityExec.cs +++ b/protocols/dotnet/Devolutions.NowProto/src/Capabilities/NowCapabilityExec.cs @@ -62,6 +62,15 @@ public enum NowCapabilityExec : ushort /// UnicodeConsole = 0x0040, - All = Run | Process | Shell | Batch | WinPs | Pwsh | IoRedirection | UnicodeConsole, + /// + /// Set if host can elevate an exec session using the platform shell. Elevation may prompt + /// the interactive user for consent, and IO redirection is unavailable for elevated + /// sessions. + /// + /// NOW-PROTO: NOW_CAP_EXEC_ELEVATE_SHELL + /// + ElevateShell = 0x0080, + + All = Run | Process | Shell | Batch | WinPs | Pwsh | IoRedirection | UnicodeConsole | ElevateShell, } } \ No newline at end of file diff --git a/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecBatch.cs b/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecBatch.cs index 060d64c..1ac5b83 100644 --- a/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecBatch.cs +++ b/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecBatch.cs @@ -23,6 +23,8 @@ public class NowMsgExecBatch : INowSerialize, INowDeserialize (Directory != null ? MsgFlags.DirectorySet : 0) | (RawEncoding ? MsgFlags.RawEncoding : 0) | (UnicodeConsole ? MsgFlags.UnicodeConsole : 0) | + (Elevated ? MsgFlags.Elevated : 0) | + (NoExit ? MsgFlags.NoExit : 0) | (IoRedirection ? MsgFlags.IoRedirection : 0) | (Detached ? MsgFlags.Detached : 0) ); @@ -54,6 +56,8 @@ static NowMsgExecBatch INowDeserialize.Deserialize(ushort flags msgFlags.HasFlag(MsgFlags.DirectorySet) ? directory : null, msgFlags.HasFlag(MsgFlags.RawEncoding), msgFlags.HasFlag(MsgFlags.UnicodeConsole), + msgFlags.HasFlag(MsgFlags.Elevated), + msgFlags.HasFlag(MsgFlags.NoExit), msgFlags.HasFlag(MsgFlags.IoRedirection), msgFlags.HasFlag(MsgFlags.Detached) ); @@ -87,6 +91,22 @@ private enum MsgFlags : ushort /// UnicodeConsole = 0x0004, + /// + /// Execute the command with elevated privileges. The elevation mechanism is chosen by + /// the host and advertised in execCapset. + /// + /// NOW-PROTO: NOW_EXEC_FLAG_BATCH_ELEVATED + /// + Elevated = 0x0008, + + /// + /// Keeps the command interpreter running after the batch file completes (cmd /K rather + /// than /C). Ignored when the session redirects stdio. + /// + /// NOW-PROTO: NOW_EXEC_FLAG_BATCH_NO_EXIT + /// + NoExit = 0x0010, + /// /// Enable stdio (stdout, stderr, stdin) redirection. /// @@ -129,6 +149,18 @@ public Builder EnableUnicodeConsole() return this; } + public Builder EnableElevated() + { + _elevated = true; + return this; + } + + public Builder EnableNoExit() + { + _noExit = true; + return this; + } + public Builder EnableDetached() { _detached = true; @@ -137,7 +169,7 @@ public Builder EnableDetached() public NowMsgExecBatch Build() { - return new NowMsgExecBatch(_sessionId, _filename, _directory, _rawEncoding, _unicodeConsole, _ioRedirection, _detached); + return new NowMsgExecBatch(_sessionId, _filename, _directory, _rawEncoding, _unicodeConsole, _elevated, _noExit, _ioRedirection, _detached); } private readonly uint _sessionId = sessionId; @@ -145,17 +177,21 @@ public NowMsgExecBatch Build() private string? _directory = null; private bool _rawEncoding = false; private bool _unicodeConsole = false; + private bool _elevated = false; + private bool _noExit = false; private bool _ioRedirection = false; private bool _detached = false; } - internal NowMsgExecBatch(uint sessionId, string filename, string? directory, bool rawEncoding, bool unicodeConsole, bool ioRedirection, bool detached) + internal NowMsgExecBatch(uint sessionId, string filename, string? directory, bool rawEncoding, bool unicodeConsole, bool elevated, bool noExit, bool ioRedirection, bool detached) { SessionId = sessionId; Filename = filename; Directory = directory; RawEncoding = rawEncoding; UnicodeConsole = unicodeConsole; + Elevated = elevated; + NoExit = noExit; IoRedirection = ioRedirection; Detached = detached; } @@ -165,6 +201,8 @@ internal NowMsgExecBatch(uint sessionId, string filename, string? directory, boo public string? Directory { get; } public bool RawEncoding { get; } public bool UnicodeConsole { get; } + public bool Elevated { get; } + public bool NoExit { get; } public bool IoRedirection { get; } public bool Detached { get; } } diff --git a/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecProcess.cs b/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecProcess.cs index 39aef3f..e8331f6 100644 --- a/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecProcess.cs +++ b/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecProcess.cs @@ -23,6 +23,7 @@ public class NowMsgExecProcess : INowSerialize, INowDeserialize.Deserialize(ushort f msgFlags.HasFlag(MsgFlags.ParametersSet) ? parameters : null, msgFlags.HasFlag(MsgFlags.DirectorySet) ? directory : null, msgFlags.HasFlag(MsgFlags.EncodingUtf8), + msgFlags.HasFlag(MsgFlags.Elevated), msgFlags.HasFlag(MsgFlags.IoRedirection), msgFlags.HasFlag(MsgFlags.Detached) ); @@ -89,6 +91,14 @@ private enum MsgFlags : ushort /// EncodingUtf8 = 0x0004, + /// + /// Execute the command with elevated privileges. The elevation mechanism is chosen by + /// the host and advertised in execCapset. + /// + /// NOW-PROTO: NOW_EXEC_FLAG_PROCESS_ELEVATED + /// + Elevated = 0x0008, + /// /// Enable stdio (stdout, stderr, stdin) redirection. /// @@ -132,6 +142,12 @@ public Builder EnableEncodingUtf8() return this; } + public Builder EnableElevated() + { + _elevated = true; + return this; + } + public Builder EnableDetached() { _detached = true; @@ -140,7 +156,7 @@ public Builder EnableDetached() public NowMsgExecProcess Build() { - return new NowMsgExecProcess(_sessionId, _filename, _parameters, _directory, _encodingUtf8, _ioRedirection, _detached); + return new NowMsgExecProcess(_sessionId, _filename, _parameters, _directory, _encodingUtf8, _elevated, _ioRedirection, _detached); } private readonly uint _sessionId = sessionId; @@ -148,17 +164,19 @@ public NowMsgExecProcess Build() private string? _parameters = null; private string? _directory = null; private bool _encodingUtf8 = false; + private bool _elevated = false; private bool _ioRedirection = false; private bool _detached = false; } - internal NowMsgExecProcess(uint sessionId, string filename, string? parameters, string? directory, bool encodingUtf8, bool ioRedirection, bool detached) + internal NowMsgExecProcess(uint sessionId, string filename, string? parameters, string? directory, bool encodingUtf8, bool elevated, bool ioRedirection, bool detached) { SessionId = sessionId; Filename = filename; Parameters = parameters; Directory = directory; EncodingUtf8 = encodingUtf8; + Elevated = elevated; IoRedirection = ioRedirection; Detached = detached; } @@ -168,6 +186,7 @@ internal NowMsgExecProcess(uint sessionId, string filename, string? parameters, public string? Parameters { get; } public string? Directory { get; } public bool EncodingUtf8 { get; } + public bool Elevated { get; } public bool IoRedirection { get; } public bool Detached { get; } } diff --git a/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecPwsh.cs b/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecPwsh.cs index 734d848..4cad84e 100644 --- a/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecPwsh.cs +++ b/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecPwsh.cs @@ -102,6 +102,14 @@ private enum MsgFlags /// UnicodeConsole = 0x0400, + /// + /// Execute the command with elevated privileges. The elevation mechanism is chosen by + /// the host and advertised in execCapset. + /// + /// NOW-PROTO: NOW_EXEC_FLAG_PS_ELEVATED + /// + Elevated = 0x0800, + /// /// Enable stdio (stdout, stderr, stdin) redirection. /// @@ -218,6 +226,12 @@ public Builder EnableUnicodeConsole() return this; } + public Builder EnableElevated() + { + _flags |= MsgFlags.Elevated; + return this; + } + public Builder EnableDetached() { _flags |= MsgFlags.Detached; @@ -329,6 +343,7 @@ public ApartmentStateKind? ApartmentState public bool IoRedirection => _flags.HasFlag(MsgFlags.IoRedirection); public bool RawEncoding => _flags.HasFlag(MsgFlags.RawEncoding); public bool UnicodeConsole => _flags.HasFlag(MsgFlags.UnicodeConsole); + public bool Elevated => _flags.HasFlag(MsgFlags.Elevated); public bool ServerMode => _flags.HasFlag(MsgFlags.ServerMode); public bool Detached => _flags.HasFlag(MsgFlags.Detached); diff --git a/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecRun.cs b/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecRun.cs index 610cd2d..fa90fda 100644 --- a/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecRun.cs +++ b/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecRun.cs @@ -23,7 +23,8 @@ public class NowMsgExecRun : INowSerialize, INowDeserialize // -- INowSerialize -- ushort INowSerialize.Flags => (ushort)( - !string.IsNullOrEmpty(Directory) ? MsgFlags.DirectorySet : 0 + (!string.IsNullOrEmpty(Directory) ? MsgFlags.DirectorySet : 0) | + (Elevated ? MsgFlags.Elevated : 0) ); uint INowSerialize.BodySize => FixedPartSize @@ -60,7 +61,8 @@ NowReadCursor cursor { SessionId = sessionId, Command = command, - Directory = directory + Directory = directory, + Elevated = ((MsgFlags)flags).HasFlag(MsgFlags.Elevated) }; } @@ -71,6 +73,7 @@ public NowMsgExecRun(uint sessionId, string command) SessionId = sessionId; Command = command; Directory = null; + Elevated = false; } private NowMsgExecRun() @@ -78,6 +81,7 @@ private NowMsgExecRun() SessionId = 0; Command = string.Empty; Directory = null; + Elevated = false; } [Flags] @@ -89,6 +93,14 @@ private enum MsgFlags : ushort /// NOW-PROTO: NOW_EXEC_FLAG_RUN_DIRECTORY_SET /// DirectorySet = 0x0001, + + /// + /// Execute the command with elevated privileges. The elevation mechanism is chosen by + /// the host and advertised in execCapset. + /// + /// NOW-PROTO: NOW_EXEC_FLAG_RUN_ELEVATED + /// + Elevated = 0x0002, } private const uint FixedPartSize = 4; // u32 SessionId @@ -101,21 +113,30 @@ public Builder Directory(string directory) return this; } + public Builder EnableElevated() + { + _elevated = true; + return this; + } + public NowMsgExecRun Build() { return new NowMsgExecRun { SessionId = sessionId, Command = command, - Directory = _directory + Directory = _directory, + Elevated = _elevated }; } private string? _directory = null; + private bool _elevated = false; } public uint SessionId { get; private init; } public string Command { get; private init; } public string? Directory { get; private init; } + public bool Elevated { get; private init; } } } \ No newline at end of file diff --git a/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecShell.cs b/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecShell.cs index 19b2c77..b75f90b 100644 --- a/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecShell.cs +++ b/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecShell.cs @@ -22,6 +22,7 @@ public class NowMsgExecShell : INowSerialize, INowDeserialize ushort INowSerialize.Flags => (ushort)( (Shell != null ? MsgFlags.ShellSet : 0) | (Directory != null ? MsgFlags.DirectorySet : 0) + | (Elevated ? MsgFlags.Elevated : 0) | (IoRedirection ? MsgFlags.IoRedirection : 0) | (Detached ? MsgFlags.Detached : 0) ); @@ -56,6 +57,7 @@ static NowMsgExecShell INowDeserialize.Deserialize(ushort flags filename, msgFlags.HasFlag(MsgFlags.ShellSet) ? parameters : null, msgFlags.HasFlag(MsgFlags.DirectorySet) ? directory : null, + msgFlags.HasFlag(MsgFlags.Elevated), msgFlags.HasFlag(MsgFlags.IoRedirection), msgFlags.HasFlag(MsgFlags.Detached) ); @@ -80,6 +82,14 @@ private enum MsgFlags : ushort /// DirectorySet = 0x0002, + /// + /// Execute the command with elevated privileges. The elevation mechanism is chosen by + /// the host and advertised in execCapset. + /// + /// NOW-PROTO: NOW_EXEC_FLAG_SHELL_ELEVATED + /// + Elevated = 0x0008, + /// /// Enable stdio (stdout, stderr, stdin) redirection. /// @@ -117,6 +127,12 @@ public Builder EnableIoRedirection() return this; } + public Builder EnableElevated() + { + _elevated = true; + return this; + } + public Builder EnableDetached() { _detached = true; @@ -125,23 +141,25 @@ public Builder EnableDetached() public NowMsgExecShell Build() { - return new NowMsgExecShell(_sessionId, _filename, _shell, _directory, _ioRedirection, _detached); + return new NowMsgExecShell(_sessionId, _filename, _shell, _directory, _elevated, _ioRedirection, _detached); } private readonly uint _sessionId = sessionId; private readonly string _filename = filename; private string? _shell = null; private string? _directory = null; + private bool _elevated = false; private bool _ioRedirection = false; private bool _detached = false; } - internal NowMsgExecShell(uint sessionId, string filename, string? shell, string? directory, bool ioRedirection, bool detached) + internal NowMsgExecShell(uint sessionId, string filename, string? shell, string? directory, bool elevated, bool ioRedirection, bool detached) { SessionId = sessionId; Filename = filename; Shell = shell; Directory = directory; + Elevated = elevated; IoRedirection = ioRedirection; Detached = detached; } @@ -150,6 +168,7 @@ internal NowMsgExecShell(uint sessionId, string filename, string? shell, string? public string Filename { get; } public string? Shell { get; } public string? Directory { get; } + public bool Elevated { get; } public bool IoRedirection { get; } public bool Detached { get; } } diff --git a/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecWinPs.cs b/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecWinPs.cs index 7e15d10..767cba0 100644 --- a/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecWinPs.cs +++ b/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecWinPs.cs @@ -102,6 +102,14 @@ private enum MsgFlags /// UnicodeConsole = 0x0400, + /// + /// Execute the command with elevated privileges. The elevation mechanism is chosen by + /// the host and advertised in execCapset. + /// + /// NOW-PROTO: NOW_EXEC_FLAG_PS_ELEVATED + /// + Elevated = 0x0800, + /// /// Enable stdio (stdout, stderr, stdin) redirection. /// @@ -220,6 +228,12 @@ public Builder EnableUnicodeConsole() return this; } + public Builder EnableElevated() + { + _flags |= MsgFlags.Elevated; + return this; + } + public Builder EnableDetached() { _flags |= MsgFlags.Detached; @@ -331,6 +345,7 @@ public ApartmentStateKind? ApartmentState public bool IoRedirection => _flags.HasFlag(MsgFlags.IoRedirection); public bool RawEncoding => _flags.HasFlag(MsgFlags.RawEncoding); public bool UnicodeConsole => _flags.HasFlag(MsgFlags.UnicodeConsole); + public bool Elevated => _flags.HasFlag(MsgFlags.Elevated); public bool ServerMode => _flags.HasFlag(MsgFlags.ServerMode); public bool Detached => _flags.HasFlag(MsgFlags.Detached); diff --git a/protocols/dotnet/Devolutions.NowProto/src/NowProtoVersion.cs b/protocols/dotnet/Devolutions.NowProto/src/NowProtoVersion.cs index aee9009..50c236d 100644 --- a/protocols/dotnet/Devolutions.NowProto/src/NowProtoVersion.cs +++ b/protocols/dotnet/Devolutions.NowProto/src/NowProtoVersion.cs @@ -2,7 +2,7 @@ { public readonly record struct NowProtoVersion(ushort Major, ushort Minor) : IComparable { - public static NowProtoVersion Current => new(1, 6); + public static NowProtoVersion Current => new(1, 7); // -- IComparable -- public int CompareTo(NowProtoVersion other) diff --git a/protocols/rust/now-proto-pdu/src/channel/capset.rs b/protocols/rust/now-proto-pdu/src/channel/capset.rs index 79472a8..b69f76a 100644 --- a/protocols/rust/now-proto-pdu/src/channel/capset.rs +++ b/protocols/rust/now-proto-pdu/src/channel/capset.rs @@ -93,6 +93,12 @@ bitflags! { /// /// NOW-PROTO: NOW_CAP_EXEC_UNICODE_CONSOLE const UNICODE_CONSOLE = 0x0040; + /// Set if host can elevate an exec session using the platform shell. Elevation may prompt + /// the interactive user for consent, and IO redirection is unavailable for elevated + /// sessions. + /// + /// NOW-PROTO: NOW_CAP_EXEC_ELEVATE_SHELL + const ELEVATE_SHELL = 0x0080; } } @@ -106,7 +112,7 @@ pub struct NowProtoVersion { impl NowProtoVersion { /// Represents the current version of the NOW protocol implemented by the library. - pub const CURRENT: Self = Self { major: 1, minor: 6 }; + pub const CURRENT: Self = Self { major: 1, minor: 7 }; /// Returns `true` if this version supports the encoding control exec flags /// (`NOW_EXEC_FLAG_*_RAW_ENCODING`, `NOW_EXEC_FLAG_*_UNICODE_CONSOLE`, and `NOW_EXEC_FLAG_PROCESS_ENCODING_UTF8`). diff --git a/protocols/rust/now-proto-pdu/src/exec/batch.rs b/protocols/rust/now-proto-pdu/src/exec/batch.rs index d5c8ffb..d889e61 100644 --- a/protocols/rust/now-proto-pdu/src/exec/batch.rs +++ b/protocols/rust/now-proto-pdu/src/exec/batch.rs @@ -27,6 +27,18 @@ bitflags! { /// /// NOW-PROTO: NOW_EXEC_FLAG_BATCH_UNICODE_CONSOLE const UNICODE_CONSOLE = 0x0004; + /// Execute the command with elevated privileges. The elevation mechanism is chosen by the + /// host and advertised in `execCapset`; a host that cannot elevate fails the request + /// instead of silently executing without elevation. + /// + /// NOW-PROTO: NOW_EXEC_FLAG_BATCH_ELEVATED + const ELEVATED = 0x0008; + /// Keeps the command interpreter running after the batch file completes (`cmd /K` + /// rather than `/C`). MUST be ignored when the session redirects stdio, where the hidden + /// interpreter would never exit. + /// + /// NOW-PROTO: NOW_EXEC_FLAG_BATCH_NO_EXIT + const NO_EXIT = 0x0010; /// Enable stdio (stdout, stderr, stdin) redirection. /// /// NOW-PROTO: NOW_EXEC_FLAG_BATCH_IO_REDIRECTION @@ -110,6 +122,26 @@ impl<'a> NowExecBatchMsg<'a> { self.flags.contains(NowExecBatchFlags::UNICODE_CONSOLE) } + #[must_use] + pub fn with_elevated(mut self) -> Self { + self.flags |= NowExecBatchFlags::ELEVATED; + self + } + + pub fn is_elevated(&self) -> bool { + self.flags.contains(NowExecBatchFlags::ELEVATED) + } + + #[must_use] + pub fn with_no_exit(mut self) -> Self { + self.flags |= NowExecBatchFlags::NO_EXIT; + self + } + + pub fn is_no_exit(&self) -> bool { + self.flags.contains(NowExecBatchFlags::NO_EXIT) + } + #[must_use] pub fn with_io_redirection(mut self) -> Self { self.flags |= NowExecBatchFlags::IO_REDIRECTION; diff --git a/protocols/rust/now-proto-pdu/src/exec/process.rs b/protocols/rust/now-proto-pdu/src/exec/process.rs index c9afb2f..ed515b3 100644 --- a/protocols/rust/now-proto-pdu/src/exec/process.rs +++ b/protocols/rust/now-proto-pdu/src/exec/process.rs @@ -29,6 +29,12 @@ bitflags! { /// /// NOW-PROTO: NOW_EXEC_FLAG_PROCESS_ENCODING_UTF8 const ENCODING_UTF8 = 0x0004; + /// Execute the command with elevated privileges. The elevation mechanism is chosen by the + /// host and advertised in `execCapset`; a host that cannot elevate fails the request + /// instead of silently executing without elevation. + /// + /// NOW-PROTO: NOW_EXEC_FLAG_PROCESS_ELEVATED + const ELEVATED = 0x0008; /// Enable stdio (stdout, stderr, stdin) redirection. /// @@ -106,6 +112,16 @@ impl<'a> NowExecProcessMsg<'a> { Ok(self) } + #[must_use] + pub fn with_elevated(mut self) -> Self { + self.flags |= NowExecProcessFlags::ELEVATED; + self + } + + pub fn is_elevated(&self) -> bool { + self.flags.contains(NowExecProcessFlags::ELEVATED) + } + #[must_use] pub fn with_io_redirection(mut self) -> Self { self.flags |= NowExecProcessFlags::IO_REDIRECTION; diff --git a/protocols/rust/now-proto-pdu/src/exec/pwsh.rs b/protocols/rust/now-proto-pdu/src/exec/pwsh.rs index e2daf0c..6461299 100644 --- a/protocols/rust/now-proto-pdu/src/exec/pwsh.rs +++ b/protocols/rust/now-proto-pdu/src/exec/pwsh.rs @@ -189,6 +189,16 @@ impl<'a> NowExecPwshMsg<'a> { self.flags.contains(NowExecWinPsFlags::UNICODE_CONSOLE) } + #[must_use] + pub fn with_elevated(mut self) -> Self { + self.flags |= NowExecWinPsFlags::ELEVATED; + self + } + + pub fn is_elevated(&self) -> bool { + self.flags.contains(NowExecWinPsFlags::ELEVATED) + } + #[must_use] pub fn with_detached(mut self) -> Self { self.flags |= NowExecWinPsFlags::DETACHED; diff --git a/protocols/rust/now-proto-pdu/src/exec/run.rs b/protocols/rust/now-proto-pdu/src/exec/run.rs index fdecf8d..912ba4f 100644 --- a/protocols/rust/now-proto-pdu/src/exec/run.rs +++ b/protocols/rust/now-proto-pdu/src/exec/run.rs @@ -16,6 +16,12 @@ bitflags! { /// /// NOW-PROTO: NOW_EXEC_FLAG_RUN_DIRECTORY_SET const DIRECTORY_SET = 0x0001; + /// Execute the command with elevated privileges. The elevation mechanism is chosen by the + /// host and advertised in `execCapset`; a host that cannot elevate fails the request + /// instead of silently executing without elevation. + /// + /// NOW-PROTO: NOW_EXEC_FLAG_RUN_ELEVATED + const ELEVATED = 0x0002; } } @@ -74,6 +80,16 @@ impl<'a> NowExecRunMsg<'a> { Ok(self) } + #[must_use] + pub fn with_elevated(mut self) -> Self { + self.flags |= NowExecRunFlags::ELEVATED; + self + } + + pub fn is_elevated(&self) -> bool { + self.flags.contains(NowExecRunFlags::ELEVATED) + } + pub fn session_id(&self) -> u32 { self.session_id } diff --git a/protocols/rust/now-proto-pdu/src/exec/shell.rs b/protocols/rust/now-proto-pdu/src/exec/shell.rs index b14622a..47d67b9 100644 --- a/protocols/rust/now-proto-pdu/src/exec/shell.rs +++ b/protocols/rust/now-proto-pdu/src/exec/shell.rs @@ -21,6 +21,12 @@ bitflags! { /// /// NOW-PROTO: NOW_EXEC_FLAG_SHELL_DIRECTORY_SET const DIRECTORY_SET = 0x0002; + /// Execute the command with elevated privileges. The elevation mechanism is chosen by the + /// host and advertised in `execCapset`; a host that cannot elevate fails the request + /// instead of silently executing without elevation. + /// + /// NOW-PROTO: NOW_EXEC_FLAG_SHELL_ELEVATED + const ELEVATED = 0x0008; /// Enable stdio (stdout, stderr, stdin) redirection. /// @@ -133,6 +139,16 @@ impl<'a> NowExecShellMsg<'a> { } } + #[must_use] + pub fn with_elevated(mut self) -> Self { + self.flags |= NowExecShellFlags::ELEVATED; + self + } + + pub fn is_elevated(&self) -> bool { + self.flags.contains(NowExecShellFlags::ELEVATED) + } + #[must_use] pub fn with_io_redirection(mut self) -> Self { self.flags |= NowExecShellFlags::IO_REDIRECTION; diff --git a/protocols/rust/now-proto-pdu/src/exec/win_ps.rs b/protocols/rust/now-proto-pdu/src/exec/win_ps.rs index aa975b0..065351b 100644 --- a/protocols/rust/now-proto-pdu/src/exec/win_ps.rs +++ b/protocols/rust/now-proto-pdu/src/exec/win_ps.rs @@ -64,6 +64,12 @@ bitflags! { /// /// NOW-PROTO: NOW_EXEC_FLAG_PS_UNICODE_CONSOLE const UNICODE_CONSOLE = 0x0400; + /// Execute the command with elevated privileges. The elevation mechanism is chosen by the + /// host and advertised in `execCapset`; a host that cannot elevate fails the request + /// instead of silently executing without elevation. + /// + /// NOW-PROTO: NOW_EXEC_FLAG_PS_ELEVATED + const ELEVATED = 0x0800; /// Enable stdio (stdout, stderr, stdin) redirection. /// @@ -296,6 +302,16 @@ impl<'a> NowExecWinPsMsg<'a> { self.flags.contains(NowExecWinPsFlags::UNICODE_CONSOLE) } + #[must_use] + pub fn with_elevated(mut self) -> Self { + self.flags |= NowExecWinPsFlags::ELEVATED; + self + } + + pub fn is_elevated(&self) -> bool { + self.flags.contains(NowExecWinPsFlags::ELEVATED) + } + #[must_use] pub fn with_detached(mut self) -> Self { self.flags |= NowExecWinPsFlags::DETACHED; diff --git a/protocols/rust/now-proto-testsuite/tests/proto/channel.rs b/protocols/rust/now-proto-testsuite/tests/proto/channel.rs index 01269e4..55608f0 100644 --- a/protocols/rust/now-proto-testsuite/tests/proto/channel.rs +++ b/protocols/rust/now-proto-testsuite/tests/proto/channel.rs @@ -15,7 +15,7 @@ fn roundtrip_channel_capset() { let decoded = now_msg_roundtrip( msg, - expect!["[0E, 00, 00, 00, 10, 01, 01, 00, 01, 00, 06, 00, 01, 00, 04, 00, 05, 00, 2C, 01, 00, 00]"], + expect!["[0E, 00, 00, 00, 10, 01, 01, 00, 01, 00, 07, 00, 01, 00, 04, 00, 05, 00, 2C, 01, 00, 00]"], ); let actual = match decoded { @@ -40,7 +40,7 @@ fn roundtrip_channel_capset_simple() { let decoded = now_msg_roundtrip( msg, - expect!["[0E, 00, 00, 00, 10, 01, 00, 00, 01, 00, 06, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00]"], + expect!["[0E, 00, 00, 00, 10, 01, 00, 00, 01, 00, 07, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00]"], ); let actual = match decoded { diff --git a/protocols/rust/now-proto-testsuite/tests/proto/exec.rs b/protocols/rust/now-proto-testsuite/tests/proto/exec.rs index 30b514c..aa88f20 100644 --- a/protocols/rust/now-proto-testsuite/tests/proto/exec.rs +++ b/protocols/rust/now-proto-testsuite/tests/proto/exec.rs @@ -444,3 +444,151 @@ fn roundtrip_exec_pwsh_simple() { assert!(actual.execution_policy().is_none()); assert!(actual.configuration_name().is_none()); } + +// Elevation flag round-trips for every exec style. The encoded flags field must carry the +// per-message ELEVATED bit and nothing else, so a host cannot mistake it for another option. + +#[test] +fn roundtrip_exec_run_elevated() { + let msg = NowExecRunMsg::new(0x12345678, "a").unwrap().with_elevated(); + + let decoded = now_msg_roundtrip( + msg, + expect!["[09, 00, 00, 00, 13, 10, 02, 00, 78, 56, 34, 12, 01, 61, 00, 00, 00]"], + ); + + let actual = match decoded { + NowMessage::Exec(NowExecMessage::Run(msg)) => msg, + _ => panic!("Expected NowExecRunMsg"), + }; + + assert!(actual.is_elevated()); +} + +#[test] +fn roundtrip_exec_process_elevated() { + let msg = NowExecProcessMsg::new(0x12345678, "a").unwrap().with_elevated(); + + let decoded = now_msg_roundtrip( + msg, + expect!["[0B, 00, 00, 00, 13, 11, 08, 00, 78, 56, 34, 12, 01, 61, 00, 00, 00, 00, 00]"], + ); + + let actual = match decoded { + NowMessage::Exec(NowExecMessage::Process(msg)) => msg, + _ => panic!("Expected NowExecProcessMsg"), + }; + + assert!(actual.is_elevated()); +} + +#[test] +fn roundtrip_exec_shell_elevated() { + let msg = NowExecShellMsg::new(0x12345678, "a").unwrap().with_elevated(); + + let decoded = now_msg_roundtrip( + msg, + expect!["[0B, 00, 00, 00, 13, 12, 08, 00, 78, 56, 34, 12, 01, 61, 00, 00, 00, 00, 00]"], + ); + + let actual = match decoded { + NowMessage::Exec(NowExecMessage::Shell(msg)) => msg, + _ => panic!("Expected NowExecShellMsg"), + }; + + assert!(actual.is_elevated()); +} + +#[test] +fn roundtrip_exec_batch_elevated_no_exit() { + let msg = NowExecBatchMsg::new(0x12345678, "a") + .unwrap() + .with_elevated() + .with_no_exit(); + + let decoded = now_msg_roundtrip( + msg, + expect!["[09, 00, 00, 00, 13, 13, 18, 00, 78, 56, 34, 12, 01, 61, 00, 00, 00]"], + ); + + let actual = match decoded { + NowMessage::Exec(NowExecMessage::Batch(msg)) => msg, + _ => panic!("Expected NowExecBatchMsg"), + }; + + assert!(actual.is_elevated()); + assert!(actual.is_no_exit()); +} + +#[test] +fn roundtrip_exec_winps_elevated() { + let msg = NowExecWinPsMsg::new(0x12345678, "a").unwrap().with_elevated(); + + let decoded = now_msg_roundtrip( + msg, + expect!["[0D, 00, 00, 00, 13, 14, 00, 08, 78, 56, 34, 12, 01, 61, 00, 00, 00, 00, 00, 00, 00]"], + ); + + let actual = match decoded { + NowMessage::Exec(NowExecMessage::WinPs(msg)) => msg, + _ => panic!("Expected NowExecWinPsMsg"), + }; + + assert!(actual.is_elevated()); +} + +#[test] +fn roundtrip_exec_pwsh_elevated() { + let msg = NowExecPwshMsg::new(0x12345678, "a").unwrap().with_elevated(); + + let decoded = now_msg_roundtrip( + msg, + expect!["[0D, 00, 00, 00, 13, 15, 00, 08, 78, 56, 34, 12, 01, 61, 00, 00, 00, 00, 00, 00, 00]"], + ); + + let actual = match decoded { + NowMessage::Exec(NowExecMessage::Pwsh(msg)) => msg, + _ => panic!("Expected NowExecPwshMsg"), + }; + + assert!(actual.is_elevated()); +} + +// ELEVATED and NO_EXIT are asserted independently as well as together: the combined 0x0018 mask +// alone would stay green if the two constants were swapped. + +#[test] +fn roundtrip_exec_batch_elevated_only() { + let msg = NowExecBatchMsg::new(0x12345678, "a").unwrap().with_elevated(); + + let decoded = now_msg_roundtrip( + msg, + expect!["[09, 00, 00, 00, 13, 13, 08, 00, 78, 56, 34, 12, 01, 61, 00, 00, 00]"], + ); + + let actual = match decoded { + NowMessage::Exec(NowExecMessage::Batch(msg)) => msg, + _ => panic!("Expected NowExecBatchMsg"), + }; + + assert!(actual.is_elevated()); + assert!(!actual.is_no_exit()); +} + +#[test] +fn roundtrip_exec_batch_no_exit_only() { + let msg = NowExecBatchMsg::new(0x12345678, "a").unwrap().with_no_exit(); + + let decoded = now_msg_roundtrip( + msg, + expect!["[09, 00, 00, 00, 13, 13, 10, 00, 78, 56, 34, 12, 01, 61, 00, 00, 00]"], + ); + + let actual = match decoded { + NowMessage::Exec(NowExecMessage::Batch(msg)) => msg, + _ => panic!("Expected NowExecBatchMsg"), + }; + + assert!(actual.is_no_exit()); + assert!(!actual.is_elevated()); +}