From e2049bb92d7490c6a250742118f63872f3043aec Mon Sep 17 00:00:00 2001 From: Richard Markiewicz Date: Wed, 9 Sep 2026 13:55:58 -0400 Subject: [PATCH 1/6] 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 234bc0d44e324bb8979e14bfe68a7b8a32529159 Mon Sep 17 00:00:00 2001 From: Richard Markiewicz Date: Wed, 9 Sep 2026 13:56:28 -0400 Subject: [PATCH 2/6] 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 1b24675..986d7a5 100644 --- a/protocols/dotnet/Devolutions.NowClient/src/AExecParams.cs +++ b/protocols/dotnet/Devolutions.NowClient/src/AExecParams.cs @@ -43,6 +43,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, DedicatedExecution? execution, CancellationToken cancellationToken) 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 c62913d..282fb68 100644 --- a/protocols/dotnet/Devolutions.NowClient/src/NowClient.cs +++ b/protocols/dotnet/Devolutions.NowClient/src/NowClient.cs @@ -504,6 +504,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); @@ -526,6 +528,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, _execution, _lifetime.Token); @@ -551,6 +555,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, _execution, _lifetime.Token); @@ -576,6 +582,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, _execution, _lifetime.Token); @@ -601,6 +609,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, _execution, _lifetime.Token); @@ -626,6 +636,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, _execution, _lifetime.Token); @@ -911,6 +923,24 @@ private void ThrowIfRdmUnsupported() } } + /// + /// 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()); +} From 859344d491a4823824d5e314b81148bff8bb6e78 Mon Sep 17 00:00:00 2001 From: Richard Markiewicz Date: Wed, 21 Oct 2026 15:27:21 -0400 Subject: [PATCH 3/6] feat(protocols): add elevation status codes to NOW_STATUS A server that cannot grant elevation must report it in a form a client can act on without parsing platform error numbers, which mean nothing on POSIX and cannot be told apart from a future policy denial. `NOW_CODE_ELEVATION_DENIED` (0x0009) covers consent that was declined, expired, or denied by policy; `NOW_CODE_ELEVATION_UNAVAILABLE` (0x000A) covers an account with no elevated token or a style the server cannot elevate. Round-trip tests pin both languages to the same encoding. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- protocols/docs/NOW-spec.md | 2 + .../src/MsgExecGeneral.cs | 46 +++++++++++++++++ .../src/Exceptions/NowProtocolErrorCode.cs | 14 ++++++ .../src/Exceptions/NowProtocolException.cs | 2 + .../rust/now-proto-pdu/src/core/status.rs | 18 +++++++ .../now-proto-testsuite/tests/proto/exec.rs | 49 +++++++++++++++++++ 6 files changed, 131 insertions(+) diff --git a/protocols/docs/NOW-spec.md b/protocols/docs/NOW-spec.md index 98d2436..08ede47 100644 --- a/protocols/docs/NOW-spec.md +++ b/protocols/docs/NOW-spec.md @@ -231,6 +231,8 @@ For successful operation this field value is operation specific. | NOW_CODE_INTERNAL
0x0006 | Internal error. | | NOW_CODE_NOT_IMPLEMENTED
0x0007 | Operation is not implemented on current platform. | | NOW_CODE_PROTOCOL_VERSION
0x0008 | Incompatible protocol versions. | + | NOW_CODE_ELEVATION_DENIED
0x0009 | Elevation was requested and refused: the interactive user declined consent, the consent prompt expired, or policy denies elevation for the account. See [Elevated Execution](#elevated-execution). | + | NOW_CODE_ELEVATION_UNAVAILABLE
0x000A | Elevation was requested and is not possible for this request: the account has no elevated token, or the server cannot elevate the requested execution style. See [Elevated Execution](#elevated-execution). | - `NOW_STATUS_ERROR_KIND_WINAPI`: code contains standard WinAPI error. - `NOW_STATUS_ERROR_KIND_UNIX`: code contains standard UNIX error code. diff --git a/protocols/dotnet/Devolutions.NowProto.Tests/src/MsgExecGeneral.cs b/protocols/dotnet/Devolutions.NowProto.Tests/src/MsgExecGeneral.cs index 68be812..61c86bb 100644 --- a/protocols/dotnet/Devolutions.NowProto.Tests/src/MsgExecGeneral.cs +++ b/protocols/dotnet/Devolutions.NowProto.Tests/src/MsgExecGeneral.cs @@ -112,6 +112,52 @@ public void ResultError() Assert.Throws(() => msg.GetExitCodeOrThrow()); } + [Fact] + public void ResultElevationDenied() + { + var msg = NowMsgExecResult.Error( + 0x12345678, + new NowProtocolException(NowProtocolErrorCode.ElevationDenied) + ); + + var encoded = new byte[] + { + 0x12, 0x00, 0x00, 0x00, 0x13, 0x04, 0x00, 0x00, 0x78, 0x56, 0x34, 0x12, + 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x09, 0x00, 0x00, 0x00, + 0x00, 0x00 + }; + + var decoded = NowTest.MessageRoundtrip(msg, encoded); + + Assert.Equal(msg.SessionId, decoded.SessionId); + Assert.False(decoded.IsSuccess); + var exception = Assert.Throws(() => decoded.GetExitCodeOrThrow()); + Assert.Equal(NowProtocolErrorCode.ElevationDenied, exception.NowProtocolError); + } + + [Fact] + public void ResultElevationUnavailable() + { + var msg = NowMsgExecResult.Error( + 0x12345678, + new NowProtocolException(NowProtocolErrorCode.ElevationUnavailable) + ); + + var encoded = new byte[] + { + 0x12, 0x00, 0x00, 0x00, 0x13, 0x04, 0x00, 0x00, 0x78, 0x56, 0x34, 0x12, + 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x0A, 0x00, 0x00, 0x00, + 0x00, 0x00 + }; + + var decoded = NowTest.MessageRoundtrip(msg, encoded); + + Assert.Equal(msg.SessionId, decoded.SessionId); + Assert.False(decoded.IsSuccess); + var exception = Assert.Throws(() => decoded.GetExitCodeOrThrow()); + Assert.Equal(NowProtocolErrorCode.ElevationUnavailable, exception.NowProtocolError); + } + [Fact] public void Data() { diff --git a/protocols/dotnet/Devolutions.NowProto/src/Exceptions/NowProtocolErrorCode.cs b/protocols/dotnet/Devolutions.NowProto/src/Exceptions/NowProtocolErrorCode.cs index 06769e5..58c2550 100644 --- a/protocols/dotnet/Devolutions.NowProto/src/Exceptions/NowProtocolErrorCode.cs +++ b/protocols/dotnet/Devolutions.NowProto/src/Exceptions/NowProtocolErrorCode.cs @@ -50,5 +50,19 @@ public enum NowProtocolErrorCode : ushort /// NOW-PROTO: NOW_CODE_PROTOCOL_VERSION ///
ProtocolVersion = 0x0008, + /// + /// Elevation was requested and refused: the interactive user declined consent, the consent + /// prompt expired, or policy denies elevation for the account. + /// + /// NOW-PROTO: NOW_CODE_ELEVATION_DENIED + /// + ElevationDenied = 0x0009, + /// + /// Elevation was requested and is not possible for this request: the account has no + /// elevated token, or the server cannot elevate the requested execution style. + /// + /// NOW-PROTO: NOW_CODE_ELEVATION_UNAVAILABLE + /// + ElevationUnavailable = 0x000A, } } \ No newline at end of file diff --git a/protocols/dotnet/Devolutions.NowProto/src/Exceptions/NowProtocolException.cs b/protocols/dotnet/Devolutions.NowProto/src/Exceptions/NowProtocolException.cs index 3aab559..1895304 100644 --- a/protocols/dotnet/Devolutions.NowProto/src/Exceptions/NowProtocolException.cs +++ b/protocols/dotnet/Devolutions.NowProto/src/Exceptions/NowProtocolException.cs @@ -18,6 +18,8 @@ private static string GetDisplayMessage(NowProtocolErrorCode code) NowProtocolErrorCode.Internal => "Internal error.", NowProtocolErrorCode.NotImplemented => "Operation is not implemented.", NowProtocolErrorCode.ProtocolVersion => "Incompatible protocol versions.", + NowProtocolErrorCode.ElevationDenied => "Elevation was denied.", + NowProtocolErrorCode.ElevationUnavailable => "Elevation is unavailable.", _ => $"Unknown NOW-proto error (code={code})." }; } diff --git a/protocols/rust/now-proto-pdu/src/core/status.rs b/protocols/rust/now-proto-pdu/src/core/status.rs index cb2e142..5be8126 100644 --- a/protocols/rust/now-proto-pdu/src/core/status.rs +++ b/protocols/rust/now-proto-pdu/src/core/status.rs @@ -46,6 +46,8 @@ impl RawNowProtoError { const INTERNAL: Self = Self(0x0006); const NOT_IMPLEMENTED: Self = Self(0x0007); const PROTOCOL_VERSION: Self = Self(0x0008); + const ELEVATION_DENIED: Self = Self(0x0009); + const ELEVATION_UNAVAILABLE: Self = Self(0x000A); } /// `code` field value of `NOW_STATUS` message if `kind` is `NOW_STATUS_ERROR_KIND_NOW`. @@ -83,6 +85,16 @@ pub enum NowProtoError { /// /// NOW-PROTO: NOW_CODE_PROTOCOL_VERSION ProtocolVersion, + /// Elevation was requested and refused: the interactive user declined consent, the consent + /// prompt expired, or policy denies elevation for the account. + /// + /// NOW-PROTO: NOW_CODE_ELEVATION_DENIED + ElevationDenied, + /// Elevation was requested and is not possible for this request: the account has no elevated + /// token, or the server cannot elevate the requested execution style. + /// + /// NOW-PROTO: NOW_CODE_ELEVATION_UNAVAILABLE + ElevationUnavailable, /// Other error code. Other(u32), } @@ -98,6 +110,8 @@ impl From for NowProtoError { RawNowProtoError::INTERNAL => NowProtoError::Internal, RawNowProtoError::NOT_IMPLEMENTED => NowProtoError::NotImplemented, RawNowProtoError::PROTOCOL_VERSION => NowProtoError::ProtocolVersion, + RawNowProtoError::ELEVATION_DENIED => NowProtoError::ElevationDenied, + RawNowProtoError::ELEVATION_UNAVAILABLE => NowProtoError::ElevationUnavailable, RawNowProtoError(code) => NowProtoError::Other(code), } } @@ -114,6 +128,8 @@ impl From for RawNowProtoError { NowProtoError::Internal => RawNowProtoError::INTERNAL, NowProtoError::NotImplemented => RawNowProtoError::NOT_IMPLEMENTED, NowProtoError::ProtocolVersion => RawNowProtoError::PROTOCOL_VERSION, + NowProtoError::ElevationDenied => RawNowProtoError::ELEVATION_DENIED, + NowProtoError::ElevationUnavailable => RawNowProtoError::ELEVATION_UNAVAILABLE, NowProtoError::Other(code) => RawNowProtoError(code), } } @@ -130,6 +146,8 @@ impl fmt::Display for NowProtoError { NowProtoError::Internal => write!(f, "internal error"), NowProtoError::NotImplemented => write!(f, "operation is not implemented"), NowProtoError::ProtocolVersion => write!(f, "incompatible protocol versions"), + NowProtoError::ElevationDenied => write!(f, "elevation was denied"), + NowProtoError::ElevationUnavailable => write!(f, "elevation is unavailable"), NowProtoError::Other(code) => write!(f, "unknown error code {}", code), } } diff --git a/protocols/rust/now-proto-testsuite/tests/proto/exec.rs b/protocols/rust/now-proto-testsuite/tests/proto/exec.rs index aa88f20..d3ee080 100644 --- a/protocols/rust/now-proto-testsuite/tests/proto/exec.rs +++ b/protocols/rust/now-proto-testsuite/tests/proto/exec.rs @@ -115,6 +115,55 @@ fn roundtrip_exec_result_error() { ); } +#[test] +fn roundtrip_exec_result_elevation_denied() { + let msg = + NowExecResultMsg::new_error(0x12345678, NowStatusError::new_proto(NowProtoError::ElevationDenied)).unwrap(); + + let decoded = now_msg_roundtrip( + msg, + expect![ + "[12, 00, 00, 00, 13, 04, 00, 00, 78, 56, 34, 12, 00, 00, 00, 00, 01, 00, 01, 00, 09, 00, 00, 00, 00, 00]" + ], + ); + + let actual = match decoded { + NowMessage::Exec(NowExecMessage::Result(msg)) => msg, + _ => panic!("Expected NowExecResultMsg"), + }; + + assert_eq!( + actual.to_result().unwrap_err().kind(), + NowStatusErrorKind::Now(NowProtoError::ElevationDenied) + ); +} + +#[test] +fn roundtrip_exec_result_elevation_unavailable() { + let msg = NowExecResultMsg::new_error( + 0x12345678, + NowStatusError::new_proto(NowProtoError::ElevationUnavailable), + ) + .unwrap(); + + let decoded = now_msg_roundtrip( + msg, + expect![ + "[12, 00, 00, 00, 13, 04, 00, 00, 78, 56, 34, 12, 00, 00, 00, 00, 01, 00, 01, 00, 0A, 00, 00, 00, 00, 00]" + ], + ); + + let actual = match decoded { + NowMessage::Exec(NowExecMessage::Result(msg)) => msg, + _ => panic!("Expected NowExecResultMsg"), + }; + + assert_eq!( + actual.to_result().unwrap_err().kind(), + NowStatusErrorKind::Now(NowProtoError::ElevationUnavailable) + ); +} + #[test] fn roundtrip_exec_data() { let msg = NowExecDataMsg::new(0x12345678, NowExecDataStreamKind::Stdout, true, &[0x01, 0x02, 0x03]).unwrap(); From b4159cba6ecfe8480eb43a2a1ac3550bd9c9eae2 Mon Sep 17 00:00:00 2001 From: Richard Markiewicz Date: Wed, 21 Oct 2026 15:27:32 -0400 Subject: [PATCH 4/6] feat(protocols): define elevation by its observable properties Rename `NOW_CAP_EXEC_ELEVATE_SHELL` to `NOW_CAP_EXEC_ELEVATION`, following `IO_REDIRECTION` and `UNICODE_CONSOLE`, which name what the host supports rather than how. Clients need to answer two questions, "will it prompt" and "will I get stdio", and a mechanism-named bit forces them to keep a table of mechanisms as more arrive; a property-named baseline plus future bits that each relax one property keeps that logic flat. The spec section now states the baseline properties, when `STARTED` arrives relative to the consent wait so client timers do not fire on a slow user, that an elevated session sends no `EXEC_DATA`, which NOW status code each failure maps to, and that credential-prompt elevation runs the command as the supplied account. The .NET client also gates batch `NoExit` on 1.7, since a pre-1.7 peer would silently run `cmd /C`, and gains tests for both refusals and for the wire shape of an elevated request. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- protocols/docs/NOW-spec.md | 119 ++++++++++-------- .../ElevationTests.cs | 83 ++++++++++++ .../src/ExecBatchParams.cs | 7 +- .../src/ExecProcessParams.cs | 5 +- .../src/ExecPwshParams.cs | 5 +- .../src/ExecRunParams.cs | 5 +- .../src/ExecShellParams.cs | 5 +- .../src/ExecWinPsParams.cs | 5 +- .../Devolutions.NowClient/src/NowClient.cs | 25 +++- .../src/Capabilities/NowCapabilityExec.cs | 11 +- .../rust/now-proto-pdu/src/channel/capset.rs | 9 +- 11 files changed, 202 insertions(+), 77 deletions(-) create mode 100644 protocols/dotnet/Devolutions.NowClient.Tests/ElevationTests.cs diff --git a/protocols/docs/NOW-spec.md b/protocols/docs/NOW-spec.md index 08ede47..0851d4a 100644 --- a/protocols/docs/NOW-spec.md +++ b/protocols/docs/NOW-spec.md @@ -331,7 +331,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). | +| NOW_CAP_EXEC_ELEVATION
0x0080 | Set if host can run elevated exec sessions. Elevation may prompt the interactive user for consent, and stdio is not redirected for elevated sessions. See [Elevated Execution](#elevated-execution). | @@ -960,7 +960,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). | +| NOW_EXEC_FLAG_RUN_ELEVATED
0x0002 | Execute the command with elevated privileges. Requires `NOW_CAP_EXEC_ELEVATION`; see [Elevated Execution](#elevated-execution). | **sessionId (4 bytes)**: A 32-bit unsigned integer containing a unique remote execution session id. @@ -998,7 +998,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_ELEVATED
0x0008 | Execute the command with elevated privileges. Requires `NOW_CAP_EXEC_ELEVATION`; 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. | @@ -1041,7 +1041,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_ELEVATED
0x0008 | Execute the command with elevated privileges. Requires `NOW_CAP_EXEC_ELEVATION`; 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. | @@ -1084,7 +1084,7 @@ 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_ELEVATED
0x0008 | Execute the command with elevated privileges. Requires `NOW_CAP_EXEC_ELEVATION`; 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. | @@ -1141,7 +1141,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_ELEVATED
0x0800 | Execute the command with elevated privileges. Requires `NOW_CAP_EXEC_ELEVATION`; 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. | @@ -1208,53 +1208,67 @@ packet-beta #### 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. +Any exec request message may set its `NOW_EXEC_FLAG_*_ELEVATED` flag to ask that the command run +with elevated privileges. The flag expresses *intent* only: it names no mechanism, and the client +does not choose one. The server advertises what elevation costs the client in `execCapset`, so the +client knows what to expect before it sends the request. `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. +session user. It carries no credentials and lets the client select no identity. Where the platform +grants elevation only through credentials supplied at a prompt, as Windows does for a standard user, +the command runs as the account whose credentials were supplied, with that account's profile and +environment. Running a command as a caller-chosen user is a separate concern and would be expressed +by a separate field. + +**Requesting elevation.** A client MUST NOT set an `ELEVATED` flag unless the negotiated +`execCapset` contains `NOW_CAP_EXEC_ELEVATION`, and client libraries SHOULD refuse such a request +locally. A server predating this version ignores the flag and executes without elevation, with +nothing on the wire to show it, so capability negotiation is the only reliable signal. A server that +receives an `ELEVATED` flag without having advertised `NOW_CAP_EXEC_ELEVATION` MUST fail the session +with `NOW_CODE_NOT_IMPLEMENTED`. A server MUST NOT execute a command without elevation after being +asked to elevate it. + +**Capability scope.** `NOW_CAP_EXEC_ELEVATION` states that the server implements elevation, not +that every advertised execution style can be elevated. A server that cannot elevate the requested +style MUST fail the session with `NOW_CODE_ELEVATION_UNAVAILABLE` rather than execute it without +elevation, and a client MUST be prepared for that failure even when the capability was advertised. + +**Baseline semantics.** With `NOW_CAP_EXEC_ELEVATION` alone, the following properties hold; on +Windows they are those of a UAC prompt: + +- *Consent.* Elevation MAY require the interactive user to consent or supply credentials, and the + request fails when nobody is present to do so, the user declines, or the prompt expires. The + server sends `NOW_EXEC_STARTED_MSG` only after elevation is granted and the process has started, + so a client SHOULD allow for the consent wait before `STARTED`, for example by starting execution + timers at `STARTED` rather than at submission. +- *Standard streams.* The server does not redirect stdio for an elevated session and sends no + `NOW_EXEC_DATA_MSG` for it. A client SHOULD omit `NOW_EXEC_FLAG_*_IO_REDIRECTION`, and a server + MUST ignore it when it is set together with `ELEVATED`. Output stays on the host; + `NOW_EXEC_FLAG_BATCH_NO_EXIT` and `NOW_EXEC_FLAG_PS_NO_EXIT` keep the interpreter window open so + the user can read it. +- *Result.* Unless the request is `NOW_EXEC_RUN_MSG`, which is never tracked, or sets + `NOW_EXEC_FLAG_*_DETACHED`, which promises no tracking, the server SHOULD track the session and + report the real exit code in `NOW_EXEC_RESULT_MSG`, so a client can distinguish success from + failure without stdio. +- *Control.* `NOW_EXEC_ABORT_MSG` and `NOW_EXEC_CANCEL_REQ_MSG` MAY fail, because the elevated + process runs at a higher integrity level than the server's session process. A cancel failure is + reported in `NOW_EXEC_CANCEL_RSP_MSG`. Neither message can dismiss a pending consent prompt. + +A future capability that relaxes one of these properties, such as elevation without a prompt or +with stdio redirection, MUST be advertised as an additional `execCapset` flag whose definition +states which property it relaxes. The meaning of `NOW_CAP_EXEC_ELEVATION` does not change. + +**Failure reporting.** A server that cannot grant elevation MUST report it in +`NOW_EXEC_RESULT_MSG` with a `NOW_STATUS` of kind `NOW_STATUS_ERROR_KIND_NOW`: + +| Code | When | +|------|------| +| NOW_CODE_ELEVATION_DENIED | The interactive user declined consent, the prompt expired, or policy denies elevation for the account. | +| NOW_CODE_ELEVATION_UNAVAILABLE | The account has no elevated token, or the server cannot elevate the requested execution style. | +| NOW_CODE_NOT_IMPLEMENTED | `ELEVATED` was set although `NOW_CAP_EXEC_ELEVATION` was not negotiated. | + +The platform error, such as Win32 `ERROR_CANCELLED`, MAY be carried in `errorMessage` but MUST NOT +replace the NOW code, because a client needs a platform-independent signal. ### RDM Messages @@ -1546,5 +1560,6 @@ packet-beta - 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_CAP_EXEC_ELEVATION` capability flag. + - Add `NOW_CODE_ELEVATION_DENIED` and `NOW_CODE_ELEVATION_UNAVAILABLE` status codes. - Add `NOW_EXEC_FLAG_BATCH_NO_EXIT` flag for batch exec commands. diff --git a/protocols/dotnet/Devolutions.NowClient.Tests/ElevationTests.cs b/protocols/dotnet/Devolutions.NowClient.Tests/ElevationTests.cs new file mode 100644 index 0000000..88c07e9 --- /dev/null +++ b/protocols/dotnet/Devolutions.NowClient.Tests/ElevationTests.cs @@ -0,0 +1,83 @@ +namespace Devolutions.NowClient.Tests; + +public sealed class ElevationTests +{ + [Fact] + public void ElevatedRequestIsRefusedWhenThePeerDoesNotAdvertiseElevation() + { + TestDriver.Run(() => + { + using var peer = new ClientPeer(); + var connection = peer.BeginConnect(); + peer.Wire.Receive(); + peer.Wire.Send(new NowMsgChannelCapset.Builder() + .SystemCapset(NowCapabilitySystem.All) + .SessionCapset(NowCapabilitySession.All) + .ExecCapset(NowCapabilityExec.All & ~NowCapabilityExec.Elevation) + .Build()); + var client = peer.FinishConnect(connection); + Assert.False(client.Capabilities.ExecCapset.HasFlag(NowCapabilityExec.Elevation)); + + Assert.IsType(TestDriver.Failure(client.ExecRun(new ExecRunParams("run.exe").Elevated()))); + Assert.IsType(TestDriver.Failure(client.ExecProcess(new ExecProcessParams("process.exe").Elevated()))); + + // Nothing went on the wire, so a later supported command is not stuck behind a request the peer would downgrade. + TestDriver.Success(client.SessionLock()); + peer.Wire.Receive(); + TestDriver.Success(client.StopAsync()); + peer.AssertStopped(); + }); + } + + [Fact] + public void ElevatedBatchReachesThePeerWithElevatedAndNoExitButWithoutIoRedirection() + { + TestDriver.Run(() => + { + using var peer = new ClientPeer(); + var client = peer.Connect(); + var session = TestDriver.Success(client.ExecBatch(new ExecBatchParams("whoami").Elevated().NoExit())); + + var request = peer.Wire.Receive(); + Assert.Equal(session.SessionId, request.SessionId); + Assert.True(request.Elevated); + Assert.True(request.NoExit); + Assert.False(request.IoRedirection); + + peer.Wire.Send(NowMsgExecResult.Success(session.SessionId, 42)); + Assert.Equal(42u, TestDriver.Success(session.GetResult())); + TestDriver.Success(client.StopAsync()); + peer.AssertStopped(); + }); + } + + [Fact] + public void BatchNoExitIsRefusedOnAPeerOlderThan17() + { + TestDriver.Run(() => + { + using var peer = new ClientPeer(); + var connection = peer.BeginConnect(); + peer.Wire.Receive(); + // The builder pins the current version, so the peer's 1.6 is patched into the wire frame (header is 8 bytes). + var capset = WireTransport.Encode(ClientPeer.Capabilities()); + BitConverter.TryWriteBytes(capset.AsSpan(8), (ushort)1); + BitConverter.TryWriteBytes(capset.AsSpan(10), (ushort)6); + peer.Wire.NextRead().Complete(capset); + var client = peer.FinishConnect(connection); + Assert.Equal(new NowProtoVersion(1, 6), client.Capabilities.Version); + + Assert.IsType(TestDriver.Failure(client.ExecBatch(new ExecBatchParams("whoami").NoExit()))); + + // Without NoExit the same request is valid for a 1.6 peer. + var session = TestDriver.Success(client.ExecBatch(new ExecBatchParams("whoami"))); + var request = peer.Wire.Receive(); + Assert.Equal(session.SessionId, request.SessionId); + Assert.False(request.NoExit); + peer.Wire.Send(NowMsgExecResult.Success(session.SessionId, 0)); + Assert.Equal(0u, TestDriver.Success(session.GetResult())); + TestDriver.Success(client.StopAsync()); + peer.AssertStopped(); + }); + } +} \ No newline at end of file diff --git a/protocols/dotnet/Devolutions.NowClient/src/ExecBatchParams.cs b/protocols/dotnet/Devolutions.NowClient/src/ExecBatchParams.cs index 1617b9a..c7e0151 100644 --- a/protocols/dotnet/Devolutions.NowClient/src/ExecBatchParams.cs +++ b/protocols/dotnet/Devolutions.NowClient/src/ExecBatchParams.cs @@ -65,8 +65,9 @@ public ExecBatchParams NoExit(bool enable = true) } /// - /// Execute with elevated privileges. Requires the host to advertise an elevation - /// capability; under shell-based elevation the session has no IO redirection. + /// Execute with elevated privileges. Requires the host to advertise the elevation + /// capability. The host may prompt the interactive user for consent, and the session has + /// no IO redirection: output stays on the host. /// public ExecBatchParams Elevated(bool enable = true) { @@ -74,6 +75,8 @@ public ExecBatchParams Elevated(bool enable = true) return this; } + internal bool IsNoExit => _noExit; + internal NowMsgExecBatch ToNowMessage(uint sessionId) { var builder = new NowMsgExecBatch.Builder(sessionId, command); diff --git a/protocols/dotnet/Devolutions.NowClient/src/ExecProcessParams.cs b/protocols/dotnet/Devolutions.NowClient/src/ExecProcessParams.cs index b556efb..df2038c 100644 --- a/protocols/dotnet/Devolutions.NowClient/src/ExecProcessParams.cs +++ b/protocols/dotnet/Devolutions.NowClient/src/ExecProcessParams.cs @@ -55,8 +55,9 @@ public ExecProcessParams EncodingUtf8(bool enable = true) } /// - /// Execute with elevated privileges. Requires the host to advertise an elevation - /// capability; under shell-based elevation the session has no IO redirection. + /// Execute with elevated privileges. Requires the host to advertise the elevation + /// capability. The host may prompt the interactive user for consent, and the session has + /// no IO redirection: output stays on the host. /// public ExecProcessParams Elevated(bool enable = true) { diff --git a/protocols/dotnet/Devolutions.NowClient/src/ExecPwshParams.cs b/protocols/dotnet/Devolutions.NowClient/src/ExecPwshParams.cs index aefaf3b..3c936d2 100644 --- a/protocols/dotnet/Devolutions.NowClient/src/ExecPwshParams.cs +++ b/protocols/dotnet/Devolutions.NowClient/src/ExecPwshParams.cs @@ -128,8 +128,9 @@ public ExecPwshParams UnicodeConsole(bool enable = true) } /// - /// Execute with elevated privileges. Requires the host to advertise an elevation - /// capability; under shell-based elevation the session has no IO redirection. + /// Execute with elevated privileges. Requires the host to advertise the elevation + /// capability. The host may prompt the interactive user for consent, and the session has + /// no IO redirection: output stays on the host. /// public ExecPwshParams Elevated(bool enable = true) { diff --git a/protocols/dotnet/Devolutions.NowClient/src/ExecRunParams.cs b/protocols/dotnet/Devolutions.NowClient/src/ExecRunParams.cs index 3e313af..30273ba 100644 --- a/protocols/dotnet/Devolutions.NowClient/src/ExecRunParams.cs +++ b/protocols/dotnet/Devolutions.NowClient/src/ExecRunParams.cs @@ -19,8 +19,9 @@ public ExecRunParams Directory(string directory) } /// - /// Execute with elevated privileges. Requires the host to advertise an elevation - /// capability; under shell-based elevation the session has no IO redirection. + /// Execute with elevated privileges. Requires the host to advertise the elevation + /// capability. The host may prompt the interactive user for consent, and the session has + /// no IO redirection: output stays on the host. /// public ExecRunParams Elevated(bool enable = true) { diff --git a/protocols/dotnet/Devolutions.NowClient/src/ExecShellParams.cs b/protocols/dotnet/Devolutions.NowClient/src/ExecShellParams.cs index e7ec7a3..a258a03 100644 --- a/protocols/dotnet/Devolutions.NowClient/src/ExecShellParams.cs +++ b/protocols/dotnet/Devolutions.NowClient/src/ExecShellParams.cs @@ -47,8 +47,9 @@ public ExecShellParams Detached(bool enable = true) } /// - /// Execute with elevated privileges. Requires the host to advertise an elevation - /// capability; under shell-based elevation the session has no IO redirection. + /// Execute with elevated privileges. Requires the host to advertise the elevation + /// capability. The host may prompt the interactive user for consent, and the session has + /// no IO redirection: output stays on the host. /// public ExecShellParams Elevated(bool enable = true) { diff --git a/protocols/dotnet/Devolutions.NowClient/src/ExecWinPsParams.cs b/protocols/dotnet/Devolutions.NowClient/src/ExecWinPsParams.cs index ea5753a..26780ff 100644 --- a/protocols/dotnet/Devolutions.NowClient/src/ExecWinPsParams.cs +++ b/protocols/dotnet/Devolutions.NowClient/src/ExecWinPsParams.cs @@ -129,8 +129,9 @@ public ExecWinPsParams UnicodeConsole(bool enable = true) } /// - /// Execute with elevated privileges. Requires the host to advertise an elevation - /// capability; under shell-based elevation the session has no IO redirection. + /// Execute with elevated privileges. Requires the host to advertise the elevation + /// capability. The host may prompt the interactive user for consent, and the session has + /// no IO redirection: output stays on the host. /// public ExecWinPsParams Elevated(bool enable = true) { diff --git a/protocols/dotnet/Devolutions.NowClient/src/NowClient.cs b/protocols/dotnet/Devolutions.NowClient/src/NowClient.cs index 282fb68..fbdf7a0 100644 --- a/protocols/dotnet/Devolutions.NowClient/src/NowClient.cs +++ b/protocols/dotnet/Devolutions.NowClient/src/NowClient.cs @@ -583,6 +583,7 @@ public async Task ExecBatch(ExecBatchParams execParams) } ThrowIfElevationUnsupported(execParams); + ThrowIfBatchNoExitUnsupported(execParams); var sessionId = _nextExecSessionId++; var message = execParams.ToNowMessage(sessionId); @@ -924,7 +925,7 @@ private void ThrowIfRdmUnsupported() } /// - /// Refuse an elevated request unless the negotiated capset advertises a way to satisfy it. + /// Refuse an elevated request unless the negotiated capset advertises elevation. /// 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. /// @@ -935,12 +936,31 @@ private void ThrowIfElevationUnsupported(AExecParams execParams) return; } - if (!Capabilities.ExecCapset.HasFlag(NowCapabilityExec.ElevateShell)) + if (!Capabilities.ExecCapset.HasFlag(NowCapabilityExec.Elevation)) { ThrowCapabilitiesError("Elevated execution"); } } + /// + /// Refuse a batch NO_EXIT request to a server older than NOW-PROTO 1.7, which does not + /// know the flag and would run `cmd /C`, closing the window the caller asked to keep. + /// + private void ThrowIfBatchNoExitUnsupported(ExecBatchParams execParams) + { + if (!execParams.IsNoExit) + { + return; + } + + if (Capabilities.Version < MIN_BATCH_NO_EXIT_VERSION) + { + throw new NowClientException( + $"Batch NoExit requires NOW-proto server version {MIN_BATCH_NO_EXIT_VERSION.Major}.{MIN_BATCH_NO_EXIT_VERSION.Minor} or higher. " + + $"Current server version is {Capabilities.Version.Major}.{Capabilities.Version.Minor}."); + } + } + private static void ThrowCapabilitiesError(string capability) { throw new NowClientException($"{capability} is not supported by server."); @@ -976,6 +996,7 @@ ClientLifetime lifetime /// RDM features were introduced in version 1.3, so servers with version 1.2 and below are not supported. /// private static readonly NowProtoVersion MIN_RDM_ENABLED_VERSION = new(1, 3); + private static readonly NowProtoVersion MIN_BATCH_NO_EXIT_VERSION = new(1, 7); /// /// Check if the NOW-proto channel has been terminated. diff --git a/protocols/dotnet/Devolutions.NowProto/src/Capabilities/NowCapabilityExec.cs b/protocols/dotnet/Devolutions.NowProto/src/Capabilities/NowCapabilityExec.cs index e75ae5e..8ac4401 100644 --- a/protocols/dotnet/Devolutions.NowProto/src/Capabilities/NowCapabilityExec.cs +++ b/protocols/dotnet/Devolutions.NowProto/src/Capabilities/NowCapabilityExec.cs @@ -63,14 +63,13 @@ public enum NowCapabilityExec : ushort UnicodeConsole = 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. + /// Set if host can run elevated exec sessions. Elevation may prompt the interactive user + /// for consent, and stdio is not redirected for elevated sessions. /// - /// NOW-PROTO: NOW_CAP_EXEC_ELEVATE_SHELL + /// NOW-PROTO: NOW_CAP_EXEC_ELEVATION /// - ElevateShell = 0x0080, + Elevation = 0x0080, - All = Run | Process | Shell | Batch | WinPs | Pwsh | IoRedirection | UnicodeConsole | ElevateShell, + All = Run | Process | Shell | Batch | WinPs | Pwsh | IoRedirection | UnicodeConsole | Elevation, } } \ No newline at end of file diff --git a/protocols/rust/now-proto-pdu/src/channel/capset.rs b/protocols/rust/now-proto-pdu/src/channel/capset.rs index b69f76a..58b3c1d 100644 --- a/protocols/rust/now-proto-pdu/src/channel/capset.rs +++ b/protocols/rust/now-proto-pdu/src/channel/capset.rs @@ -93,12 +93,11 @@ 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. + /// Set if host can run elevated exec sessions. Elevation may prompt the interactive user + /// for consent, and stdio is not redirected for elevated sessions. /// - /// NOW-PROTO: NOW_CAP_EXEC_ELEVATE_SHELL - const ELEVATE_SHELL = 0x0080; + /// NOW-PROTO: NOW_CAP_EXEC_ELEVATION + const ELEVATION = 0x0080; } } From 749b09f68c2a5f1d8f85047d93c12f5f12935a91 Mon Sep 17 00:00:00 2001 From: Richard Markiewicz Date: Wed, 21 Oct 2026 15:27:39 -0400 Subject: [PATCH 5/6] feat(now-client): add elevated execution requests Every request type gains `with_elevated()`. The client refuses it unless the peer negotiated `NOW_CAP_EXEC_ELEVATION`, because a pre-1.7 peer ignores the flag and runs the command unelevated with nothing on the wire to show it. An elevated tracked request omits `IO_REDIRECTION` and rejects inline stdin and a timeout: it has no stdio, and its cancellation may fail, so a timeout could not be enforced. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- protocols/rust/now-client/src/capabilities.rs | 11 ++ protocols/rust/now-client/src/client.rs | 109 +++++++++++++++++ protocols/rust/now-client/src/config.rs | 3 +- protocols/rust/now-client/src/exec.rs | 115 ++++++++++++++++-- 4 files changed, 227 insertions(+), 11 deletions(-) diff --git a/protocols/rust/now-client/src/capabilities.rs b/protocols/rust/now-client/src/capabilities.rs index e92bb50..9d9df83 100644 --- a/protocols/rust/now-client/src/capabilities.rs +++ b/protocols/rust/now-client/src/capabilities.rs @@ -80,6 +80,17 @@ impl NowCapabilities { self.has(NowExecCapsetFlags::UNICODE_CONSOLE) && self.version().supports_exec_unicode_console() } + /// Returns whether elevated execution is available. + /// + /// Elevation is gated on the capability rather than the version alone, because a 1.7 peer + /// that cannot elevate (for example, one that has not shipped the host-side change) must + /// answer `false`. + /// Under this capability the host may prompt the interactive user for consent, and an + /// elevated session has no stdio redirection. + pub fn supports_elevation(&self) -> bool { + self.has(NowExecCapsetFlags::ELEVATION) && self.at_least(1, 7) + } + pub(crate) fn supports_detached(&self) -> bool { self.at_least(1, 4) } diff --git a/protocols/rust/now-client/src/client.rs b/protocols/rust/now-client/src/client.rs index 56a8366..8d5baaf 100644 --- a/protocols/rust/now-client/src/client.rs +++ b/protocols/rust/now-client/src/client.rs @@ -1455,4 +1455,113 @@ mod tests { Err(error) => panic!("test peer task failed: {error}"), } } + + #[tokio::test] + async fn elevated_request_is_refused_without_the_capability() { + let (client_stream, mut peer_stream) = tokio::io::duplex(1024); + let (release_sender, release_receiver) = tokio::sync::oneshot::channel(); + let peer = tokio::spawn(async move { + let _ = read_message(&mut peer_stream).await; + write_message( + &mut peer_stream, + capset( + NowExecCapsetFlags::STYLE_RUN + | NowExecCapsetFlags::STYLE_PROCESS + | NowExecCapsetFlags::IO_REDIRECTION, + ), + ) + .await; + // Hold the stream open so an unexpected write would surface as a readable frame. + let _ = release_receiver.await; + let mut probe = [0; 1]; + match tokio::time::timeout(Duration::from_millis(100), peer_stream.read(&mut probe)).await { + Ok(Ok(0)) | Err(_) => {} + Ok(Ok(_)) => panic!("refused elevated request must not reach the peer"), + Ok(Err(error)) => panic!("test peer read failed: {error}"), + } + }); + + let handle = match NowClient::connect(client_stream, NowClientConfig::default()).await { + Ok(handle) => handle, + Err(error) => panic!("handshake must succeed: {error}"), + }; + assert!(!handle.capabilities().supports_elevation()); + match handle.process(ProcessRequest::new("process.exe").with_elevated()).await { + Err(NowClientError::UnsupportedCapability("elevated execution")) => {} + Err(error) => panic!("unexpected error: {error}"), + Ok(_) => panic!("elevated request must be refused without the capability"), + } + match handle.run(RunRequest::new("run.exe").with_elevated()).await { + Err(NowClientError::UnsupportedCapability("elevated execution")) => {} + Err(error) => panic!("unexpected error: {error}"), + Ok(_) => panic!("elevated Run must be refused without the capability"), + } + let _ = release_sender.send(()); + drop(handle); + match peer.await { + Ok(()) => {} + Err(error) => panic!("test peer task failed: {error}"), + } + } + + #[tokio::test] + async fn elevated_request_omits_io_redirection_and_tracks_the_result() { + let (client_stream, mut peer_stream) = tokio::io::duplex(4096); + let peer = tokio::spawn(async move { + let _ = read_message(&mut peer_stream).await; + write_message( + &mut peer_stream, + capset( + NowExecCapsetFlags::STYLE_PROCESS + | NowExecCapsetFlags::IO_REDIRECTION + | NowExecCapsetFlags::ELEVATION, + ), + ) + .await; + + let session_id = match read_message(&mut peer_stream).await { + NowMessage::Exec(NowExecMessage::Process(message)) => { + assert!(message.is_elevated()); + assert!(!message.is_with_io_redirection()); + assert!(!message.is_detached()); + message.session_id() + } + message => panic!("expected elevated Process request, got {message:?}"), + }; + write_message(&mut peer_stream, NowExecStartedMsg::new(session_id)).await; + write_message(&mut peer_stream, NowExecResultMsg::new_success(session_id, 42)).await; + }); + + let handle = match NowClient::connect(client_stream, NowClientConfig::default()).await { + Ok(handle) => handle, + Err(error) => panic!("handshake must succeed: {error}"), + }; + assert!(handle.capabilities().supports_elevation()); + match handle + .process( + ProcessRequest::new("process.exe") + .with_elevated() + .with_timeout(Duration::from_secs(1)), + ) + .await + { + Err(NowClientError::InvalidRequest(_)) => {} + Err(error) => panic!("unexpected error: {error}"), + Ok(_) => panic!("elevated request must reject a timeout it cannot enforce"), + } + let mut execution = match handle.process(ProcessRequest::new("process.exe").with_elevated()).await { + Ok(execution) => execution, + Err(error) => panic!("elevated Process request must start: {error}"), + }; + assert_eq!(execution.next_event().await, Some(ExecutionEvent::Started)); + match execution.wait().await { + Ok(ExecutionStatus::Completed { exit_code: 42 }) => {} + Ok(status) => panic!("unexpected terminal status: {status:?}"), + Err(error) => panic!("elevated Process must complete: {error}"), + } + match peer.await { + Ok(()) => {} + Err(error) => panic!("test peer task failed: {error}"), + } + } } diff --git a/protocols/rust/now-client/src/config.rs b/protocols/rust/now-client/src/config.rs index 53d07f0..78028e4 100644 --- a/protocols/rust/now-client/src/config.rs +++ b/protocols/rust/now-client/src/config.rs @@ -31,7 +31,8 @@ impl Default for NowClientConfig { | NowExecCapsetFlags::STYLE_WINPS | NowExecCapsetFlags::STYLE_PWSH | NowExecCapsetFlags::IO_REDIRECTION - | NowExecCapsetFlags::UNICODE_CONSOLE; + | NowExecCapsetFlags::UNICODE_CONSOLE + | NowExecCapsetFlags::ELEVATION; let capset = NowChannelCapsetMsg::default().with_exec_capset(exec_capset); let client_capset = match capset.with_heartbeat_interval(Duration::from_secs(60)) { Ok(capset) => capset, diff --git a/protocols/rust/now-client/src/exec.rs b/protocols/rust/now-client/src/exec.rs index ac97b40..e64e9d0 100644 --- a/protocols/rust/now-client/src/exec.rs +++ b/protocols/rust/now-client/src/exec.rs @@ -14,6 +14,7 @@ struct CommonRequest { directory: Option, stdin: Option>, timeout: Option, + elevated: bool, } impl CommonRequest { @@ -23,6 +24,7 @@ impl CommonRequest { directory: None, stdin: None, timeout: None, + elevated: false, } } } @@ -32,6 +34,7 @@ impl CommonRequest { pub struct RunRequest { command: String, directory: Option, + elevated: bool, } impl RunRequest { @@ -40,6 +43,7 @@ impl RunRequest { Self { command: command.into(), directory: None, + elevated: false, } } @@ -49,6 +53,16 @@ impl RunRequest { self.directory = Some(directory.into()); self } + + /// Requests elevated privileges. + /// + /// Requires [`NowCapabilities::supports_elevation`]; the peer may prompt the interactive user + /// for consent before the command starts. + #[must_use] + pub fn with_elevated(mut self) -> Self { + self.elevated = true; + self + } } /// Request for Windows CreateProcess execution. @@ -59,6 +73,7 @@ pub struct ProcessRequest { directory: Option, stdin: Option>, timeout: Option, + elevated: bool, } impl ProcessRequest { @@ -70,6 +85,7 @@ impl ProcessRequest { directory: None, stdin: None, timeout: None, + elevated: false, } } @@ -100,6 +116,17 @@ impl ProcessRequest { self.timeout = Some(timeout); self } + + /// Requests elevated privileges. + /// + /// Requires [`NowCapabilities::supports_elevation`]. + /// An elevated session has no stdio and cannot be cancelled, so it rejects inline stdin and + /// a timeout; the peer may prompt the interactive user for consent before the process starts. + #[must_use] + pub fn with_elevated(mut self) -> Self { + self.elevated = true; + self + } } /// Request for a Windows Batch command. @@ -136,6 +163,17 @@ impl BatchRequest { self.common.timeout = Some(timeout); self } + + /// Requests elevated privileges. + /// + /// Requires [`NowCapabilities::supports_elevation`]. + /// An elevated session has no stdio and cannot be cancelled, so it rejects inline stdin and + /// a timeout; the peer may prompt the interactive user for consent before the process starts. + #[must_use] + pub fn with_elevated(mut self) -> Self { + self.common.elevated = true; + self + } } /// Request shared by Windows PowerShell and PowerShell 7. @@ -190,6 +228,17 @@ impl PowerShellRequest { self.non_interactive = true; self } + + /// Requests elevated privileges. + /// + /// Requires [`NowCapabilities::supports_elevation`]. + /// An elevated session has no stdio and cannot be cancelled, so it rejects inline stdin and + /// a timeout; the peer may prompt the interactive user for consent before the process starts. + #[must_use] + pub fn with_elevated(mut self) -> Self { + self.common.elevated = true; + self + } } /// Request for Windows PowerShell (`powershell.exe`). @@ -247,6 +296,9 @@ impl RequestSpec { if let Some(directory) = &request.directory { message = pdu(message.with_directory(directory.clone()))?; } + if request.elevated { + message = message.with_elevated(); + } Ok(EncodedRequest { message: message.into(), non_interactive: false, @@ -263,9 +315,12 @@ impl RequestSpec { if capabilities.supports_unicode_console() { message = message.with_encoding_utf8(); } + if request.elevated { + message = message.with_elevated(); + } if detached { message = message.with_detached(); - } else { + } else if !request.elevated { message = message.with_io_redirection(); } Ok(EncodedRequest { @@ -282,9 +337,12 @@ impl RequestSpec { if capabilities.supports_unicode_console() { message = message.with_raw_encoding().with_unicode_console(); } + if common.elevated { + message = message.with_elevated(); + } if detached { message = message.with_detached(); - } else { + } else if !common.elevated { message = message.with_io_redirection(); } Ok(EncodedRequest { @@ -303,14 +361,16 @@ impl RequestSpec { require(capabilities.supports_run(), "Run")?; validate_command(&request.command)?; validate_directory(request.directory.as_deref())?; + validate_elevation(capabilities, request.elevated)?; } Self::Process(request) => { require(capabilities.supports_process(), "Process")?; validate_filename(&request.filename)?; validate_optional(&request.parameters, "parameters")?; validate_directory(request.directory.as_deref())?; - validate_common(detached, request.stdin.as_deref(), request.timeout)?; - validate_tracking(capabilities, detached)?; + validate_elevation(capabilities, request.elevated)?; + validate_common(detached, request.elevated, request.stdin.as_deref(), request.timeout)?; + validate_tracking(capabilities, detached, request.elevated)?; } Self::Batch(request) => { require(capabilities.supports_batch(), "Batch")?; @@ -340,9 +400,12 @@ macro_rules! apply_power_shell_options { if $capabilities.supports_unicode_console() { $message = $message.with_raw_encoding().with_unicode_console(); } + if $common.elevated { + $message = $message.with_elevated(); + } if $detached { $message = $message.with_detached(); - } else { + } else if !$common.elevated { $message = $message.with_io_redirection(); } }; @@ -380,11 +443,28 @@ fn validate_common_request( ) -> Result<(), NowClientError> { validate_command(&request.command)?; validate_directory(request.directory.as_deref())?; - validate_common(detached, request.stdin.as_deref(), request.timeout)?; - validate_tracking(capabilities, detached) + validate_elevation(capabilities, request.elevated)?; + validate_common(detached, request.elevated, request.stdin.as_deref(), request.timeout)?; + validate_tracking(capabilities, detached, request.elevated) } -fn validate_common(detached: bool, stdin: Option<&[u8]>, timeout: Option) -> Result<(), NowClientError> { +/// Refuses an elevated request the peer cannot honour. +/// +/// A peer older than 1.7 ignores the `ELEVATED` flag and runs the command unelevated with nothing +/// on the wire to show it, so the capability is the only signal and the refusal happens here. +fn validate_elevation(capabilities: &NowCapabilities, elevated: bool) -> Result<(), NowClientError> { + if elevated { + require(capabilities.supports_elevation(), "elevated execution")?; + } + Ok(()) +} + +fn validate_common( + detached: bool, + elevated: bool, + stdin: Option<&[u8]>, + timeout: Option, +) -> Result<(), NowClientError> { if detached && stdin.is_some() { return Err(NowClientError::InvalidRequest( "detached execution cannot include inline stdin".to_owned(), @@ -395,6 +475,18 @@ fn validate_common(detached: bool, stdin: Option<&[u8]>, timeout: Option, timeout: Option Result<(), NowClientError> { +fn validate_tracking(capabilities: &NowCapabilities, detached: bool, elevated: bool) -> Result<(), NowClientError> { if detached { require(capabilities.supports_detached(), "detached execution") + } else if elevated { + // Tracked elevated sessions report their result without stdio, so they do not need + // I/O redirection; `validate_elevation` already required the elevation capability. + Ok(()) } else { require(capabilities.supports_io_redirection(), "I/O redirection") } } - fn validate_command(command: &str) -> Result<(), NowClientError> { if command.trim().is_empty() { return Err(NowClientError::InvalidRequest("command must not be empty".to_owned())); From 5562d541f50facd299224a5b72a9143f2559ba16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Wed, 28 Oct 2026 13:23:07 -0400 Subject: [PATCH 6/6] feat(protocols): unify elevated flag, allow best-effort stdio, and add started flag Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- protocols/docs/NOW-spec.md | 104 ++++++++++-------- .../ElevationTests.cs | 8 +- .../src/ExecBatchParams.cs | 4 +- .../src/ExecProcessParams.cs | 4 +- .../src/ExecPwshParams.cs | 4 +- .../src/ExecRunParams.cs | 3 +- .../Devolutions.NowClient/src/ExecSession.cs | 13 ++- .../src/ExecShellParams.cs | 4 +- .../src/ExecWinPsParams.cs | 4 +- .../src/IExecSessionHandler.cs | 2 +- .../src/Worker/WorkerCtx.cs | 2 +- .../src/MsgExecGeneral.cs | 18 +++ .../src/NowExecStyles.cs | 18 +-- .../src/Capabilities/NowCapabilityExec.cs | 3 +- .../src/Exceptions/NowProtocolErrorCode.cs | 8 +- .../src/Messages/NowMsgExecBatch.cs | 18 +-- .../src/Messages/NowMsgExecProcess.cs | 14 +-- .../src/Messages/NowMsgExecPwsh.cs | 16 +-- .../src/Messages/NowMsgExecRun.cs | 2 +- .../src/Messages/NowMsgExecShell.cs | 14 +-- .../src/Messages/NowMsgExecStarted.cs | 26 ++++- .../src/Messages/NowMsgExecWinPs.cs | 16 +-- protocols/rust/now-client/src/capabilities.rs | 6 +- protocols/rust/now-client/src/client.rs | 32 ++++-- protocols/rust/now-client/src/exec.rs | 58 ++++------ .../rust/now-proto-pdu/src/channel/capset.rs | 3 +- .../rust/now-proto-pdu/src/core/status.rs | 8 +- .../rust/now-proto-pdu/src/exec/batch.rs | 14 +-- .../rust/now-proto-pdu/src/exec/process.rs | 12 +- protocols/rust/now-proto-pdu/src/exec/run.rs | 2 +- .../rust/now-proto-pdu/src/exec/shell.rs | 12 +- .../rust/now-proto-pdu/src/exec/started.rs | 37 ++++++- .../rust/now-proto-pdu/src/exec/win_ps.rs | 14 +-- .../now-proto-testsuite/tests/proto/exec.rs | 36 ++++-- 34 files changed, 322 insertions(+), 217 deletions(-) diff --git a/protocols/docs/NOW-spec.md b/protocols/docs/NOW-spec.md index 0851d4a..b86059e 100644 --- a/protocols/docs/NOW-spec.md +++ b/protocols/docs/NOW-spec.md @@ -231,8 +231,8 @@ For successful operation this field value is operation specific. | NOW_CODE_INTERNAL
0x0006 | Internal error. | | NOW_CODE_NOT_IMPLEMENTED
0x0007 | Operation is not implemented on current platform. | | NOW_CODE_PROTOCOL_VERSION
0x0008 | Incompatible protocol versions. | - | NOW_CODE_ELEVATION_DENIED
0x0009 | Elevation was requested and refused: the interactive user declined consent, the consent prompt expired, or policy denies elevation for the account. See [Elevated Execution](#elevated-execution). | - | NOW_CODE_ELEVATION_UNAVAILABLE
0x000A | Elevation was requested and is not possible for this request: the account has no elevated token, or the server cannot elevate the requested execution style. See [Elevated Execution](#elevated-execution). | + | NOW_CODE_ELEVATION_DENIED
0x0009 | Elevation was requested and refused for this session: the interactive user declined consent, the consent prompt expired, or a host policy/rule denied elevation for the request. See [Elevated Execution](#elevated-execution). | + | NOW_CODE_ELEVATION_UNAVAILABLE
0x000A | Elevation was requested and is not possible for this request: the account has no elevated token, elevation is disabled on the host, or the server cannot elevate the requested execution style. See [Elevated Execution](#elevated-execution). | - `NOW_STATUS_ERROR_KIND_WINAPI`: code contains standard WinAPI error. - `NOW_STATUS_ERROR_KIND_UNIX`: code contains standard UNIX error code. @@ -331,7 +331,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_ELEVATION
0x0080 | Set if host can run elevated exec sessions. Elevation may prompt the interactive user for consent, and stdio is not redirected for elevated sessions. See [Elevated Execution](#elevated-execution). | +| NOW_CAP_EXEC_ELEVATION
0x0080 | Set if host can run elevated exec sessions. Elevation may prompt the interactive user for consent, and stdio redirection may be unavailable depending on the host's elevation mechanism. See [Elevated Execution](#elevated-execution). | @@ -928,10 +928,14 @@ packet-beta **msgClass (1 byte)**: The message class (NOW_EXEC_MSG_CLASS_ID). -**msgType (1 byte)**: The message type (NOW_EXEC_RESULT_MSG_ID). +**msgType (1 byte)**: The message type (NOW_EXEC_STARTED_MSG_ID). **msgFlags (2 bytes)**: The message flags. +| Flag | Meaning | +|------|---------| +| NOW_EXEC_FLAG_STARTED_NO_IO_REDIRECTION
0x0001 | Set when `NOW_EXEC_FLAG_*_IO_REDIRECTION` was requested, but the server could not redirect standard streams for this session (for example, because the elevation mechanism does not support pipe inheritance). | + **sessionId (4 bytes)**: A 32-bit unsigned integer containing a unique remote execution session id. #### NOW_EXEC_RUN_MSG @@ -960,7 +964,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. Requires `NOW_CAP_EXEC_ELEVATION`; see [Elevated Execution](#elevated-execution). | +| NOW_EXEC_FLAG_RUN_ELEVATED
0x4000 | Execute the command with elevated privileges. Requires `NOW_CAP_EXEC_ELEVATION`; see [Elevated Execution](#elevated-execution). | **sessionId (4 bytes)**: A 32-bit unsigned integer containing a unique remote execution session id. @@ -998,8 +1002,8 @@ 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. Requires `NOW_CAP_EXEC_ELEVATION`; see [Elevated Execution](#elevated-execution). | | NOW_EXEC_FLAG_PROCESS_IO_REDIRECTION
0x1000 | Enable stdio (stdout, stderr, stdin) redirection. | +| NOW_EXEC_FLAG_PROCESS_ELEVATED
0x4000 | Execute the command with elevated privileges. Requires `NOW_CAP_EXEC_ELEVATION`; see [Elevated Execution](#elevated-execution). | | NOW_EXEC_FLAG_PROCESS_DETACHED
0x8000 | Detached mode: the process is started without tracking execution or sending back output. | **Encoding behavior:** By default, process data streams are passed through as raw bytes without any encoding conversion, since no assumptions can be made about the encoding of an arbitrary process. @@ -1041,8 +1045,8 @@ 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. Requires `NOW_CAP_EXEC_ELEVATION`; see [Elevated Execution](#elevated-execution). | | NOW_EXEC_FLAG_SHELL_IO_REDIRECTION
0x1000 | Enable stdio (stdout, stderr, stdin) redirection. | +| NOW_EXEC_FLAG_SHELL_ELEVATED
0x4000 | Execute the command with elevated privileges. Requires `NOW_CAP_EXEC_ELEVATION`; see [Elevated Execution](#elevated-execution). | | NOW_EXEC_FLAG_SHELL_DETACHED
0x8000 | Detached mode: the shell is started without tracking execution or sending back output. | **sessionId (4 bytes)**: A 32-bit unsigned integer containing a unique remote execution session id. @@ -1084,9 +1088,9 @@ 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. Requires `NOW_CAP_EXEC_ELEVATION`; 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_NO_EXIT
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_EXEC_FLAG_BATCH_IO_REDIRECTION
0x1000 | Enable stdio (stdout, stderr, stdin) redirection. | +| NOW_EXEC_FLAG_BATCH_ELEVATED
0x4000 | Execute the command with elevated privileges. Requires `NOW_CAP_EXEC_ELEVATION`; see [Elevated Execution](#elevated-execution). | | NOW_EXEC_FLAG_BATCH_DETACHED
0x8000 | Detached mode: the batch is started without tracking execution or sending back output. | **Encoding behavior:** By default, batch (cmd.exe) output is transcoded from OEM encoding to UTF-8, since cmd.exe uses the system OEM code page. @@ -1141,9 +1145,9 @@ 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. Requires `NOW_CAP_EXEC_ELEVATION`; 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_ELEVATED
0x4000 | Execute the command with elevated privileges. Requires `NOW_CAP_EXEC_ELEVATION`; see [Elevated Execution](#elevated-execution). | | NOW_EXEC_FLAG_PS_DETACHED
0x8000 | Detached mode: PowerShell is started without tracking execution or sending back output. | **Encoding behavior (WinPs):** By default, Windows PowerShell (powershell.exe) output is transcoded from OEM encoding to UTF-8, since Windows PowerShell uses the system OEM code page for console output. @@ -1208,10 +1212,9 @@ packet-beta #### Elevated Execution -Any exec request message may set its `NOW_EXEC_FLAG_*_ELEVATED` flag to ask that the command run -with elevated privileges. The flag expresses *intent* only: it names no mechanism, and the client -does not choose one. The server advertises what elevation costs the client in `execCapset`, so the -client knows what to expect before it sends the request. +Any exec request message may set its `NOW_EXEC_FLAG_*_ELEVATED` (`0x4000`) flag to ask that the +command run with elevated privileges. The flag expresses *intent* only: it names no 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 carries no credentials and lets the client select no identity. Where the platform @@ -1223,48 +1226,52 @@ by a separate field. **Requesting elevation.** A client MUST NOT set an `ELEVATED` flag unless the negotiated `execCapset` contains `NOW_CAP_EXEC_ELEVATION`, and client libraries SHOULD refuse such a request locally. A server predating this version ignores the flag and executes without elevation, with -nothing on the wire to show it, so capability negotiation is the only reliable signal. A server that -receives an `ELEVATED` flag without having advertised `NOW_CAP_EXEC_ELEVATION` MUST fail the session -with `NOW_CODE_NOT_IMPLEMENTED`. A server MUST NOT execute a command without elevation after being -asked to elevate it. - -**Capability scope.** `NOW_CAP_EXEC_ELEVATION` states that the server implements elevation, not -that every advertised execution style can be elevated. A server that cannot elevate the requested -style MUST fail the session with `NOW_CODE_ELEVATION_UNAVAILABLE` rather than execute it without -elevation, and a client MUST be prepared for that failure even when the capability was advertised. - -**Baseline semantics.** With `NOW_CAP_EXEC_ELEVATION` alone, the following properties hold; on -Windows they are those of a UAC prompt: - -- *Consent.* Elevation MAY require the interactive user to consent or supply credentials, and the - request fails when nobody is present to do so, the user declines, or the prompt expires. The - server sends `NOW_EXEC_STARTED_MSG` only after elevation is granted and the process has started, - so a client SHOULD allow for the consent wait before `STARTED`, for example by starting execution - timers at `STARTED` rather than at submission. -- *Standard streams.* The server does not redirect stdio for an elevated session and sends no - `NOW_EXEC_DATA_MSG` for it. A client SHOULD omit `NOW_EXEC_FLAG_*_IO_REDIRECTION`, and a server - MUST ignore it when it is set together with `ELEVATED`. Output stays on the host; - `NOW_EXEC_FLAG_BATCH_NO_EXIT` and `NOW_EXEC_FLAG_PS_NO_EXIT` keep the interpreter window open so - the user can read it. +nothing on the wire to show it, so capability negotiation is the only reliable signal. If elevation +is globally disabled in the server's configuration, the server SHOULD omit `NOW_CAP_EXEC_ELEVATION` +from `execCapset`. A server that receives an `ELEVATED` flag without having advertised +`NOW_CAP_EXEC_ELEVATION` MUST fail the session with `NOW_CODE_NOT_IMPLEMENTED` (or +`NOW_CODE_ELEVATION_UNAVAILABLE`). A server MUST NOT execute a command without elevation after +being asked to elevate it. + +**Capability scope.** `NOW_CAP_EXEC_ELEVATION` states that the server can evaluate and perform +elevation requests, not that every advertised execution style or command can be elevated. A server +that cannot elevate the requested style MUST fail the session with `NOW_CODE_ELEVATION_UNAVAILABLE` +rather than execute it without elevation, and a client MUST be prepared for that failure even when +the capability was advertised. + +**Execution semantics.** Depending on how the server elevates the session (for example, via a UAC +consent prompt or via a privileged agent service/rule), the following properties apply: + +- *Consent.* Elevation MAY require the interactive user to consent or supply credentials, or MAY be + granted automatically by host policy/rules. When interactive consent is required, the request + fails if nobody is present to respond, the user declines, or the prompt expires. The server sends + `NOW_EXEC_STARTED_MSG` only after elevation is granted and the process has started, so a client + SHOULD allow for the consent wait before `STARTED`, for example by starting execution timers at + `STARTED` rather than at submission. +- *Standard streams.* `NOW_EXEC_FLAG_*_IO_REDIRECTION` is orthogonal to `ELEVATED`. A client MAY + request `IO_REDIRECTION` on an elevated session. The server SHOULD redirect stdio when its + elevation mechanism supports pipe inheritance (such as service- or policy-assisted elevation) and + MAY omit stdio redirection when the mechanism does not (such as Windows `ShellExecuteEx` UAC). + When `IO_REDIRECTION` was requested on the exec message but the server could not redirect stdio + for the session, the server MUST set `NOW_EXEC_FLAG_STARTED_NO_IO_REDIRECTION` (`0x0001`) on + `NOW_EXEC_STARTED_MSG` and send no `NOW_EXEC_DATA_MSG` for it. When stdio is not redirected, + output stays on the host; `NOW_EXEC_FLAG_BATCH_NO_EXIT` and `NOW_EXEC_FLAG_PS_NO_EXIT` keep the + interpreter window open so the user can read it. - *Result.* Unless the request is `NOW_EXEC_RUN_MSG`, which is never tracked, or sets `NOW_EXEC_FLAG_*_DETACHED`, which promises no tracking, the server SHOULD track the session and report the real exit code in `NOW_EXEC_RESULT_MSG`, so a client can distinguish success from - failure without stdio. -- *Control.* `NOW_EXEC_ABORT_MSG` and `NOW_EXEC_CANCEL_REQ_MSG` MAY fail, because the elevated - process runs at a higher integrity level than the server's session process. A cancel failure is - reported in `NOW_EXEC_CANCEL_RSP_MSG`. Neither message can dismiss a pending consent prompt. - -A future capability that relaxes one of these properties, such as elevation without a prompt or -with stdio redirection, MUST be advertised as an additional `execCapset` flag whose definition -states which property it relaxes. The meaning of `NOW_CAP_EXEC_ELEVATION` does not change. + failure even when stdio is not redirected. +- *Control.* `NOW_EXEC_ABORT_MSG` and `NOW_EXEC_CANCEL_REQ_MSG` MAY fail when the elevated process + runs at a higher integrity level than the server's session process. A cancel failure is reported + in `NOW_EXEC_CANCEL_RSP_MSG`. Neither message can dismiss a pending consent prompt. **Failure reporting.** A server that cannot grant elevation MUST report it in `NOW_EXEC_RESULT_MSG` with a `NOW_STATUS` of kind `NOW_STATUS_ERROR_KIND_NOW`: | Code | When | |------|------| -| NOW_CODE_ELEVATION_DENIED | The interactive user declined consent, the prompt expired, or policy denies elevation for the account. | -| NOW_CODE_ELEVATION_UNAVAILABLE | The account has no elevated token, or the server cannot elevate the requested execution style. | +| NOW_CODE_ELEVATION_DENIED | Elevation was refused for this session: the interactive user declined consent, the consent prompt expired, or a host policy/rule denied elevation for the request. | +| NOW_CODE_ELEVATION_UNAVAILABLE | Elevation is not possible for this request: the account has no elevated token, elevation is disabled on the host, or the server cannot elevate the requested execution style. | | NOW_CODE_NOT_IMPLEMENTED | `ELEVATED` was set although `NOW_CAP_EXEC_ELEVATION` was not negotiated. | The platform error, such as Win32 `ERROR_CANCELLED`, MAY be carried in `errorMessage` but MUST NOT @@ -1559,7 +1566,8 @@ packet-beta - 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_EXEC_FLAG_*_ELEVATED` (`0x4000`) flags for run, process, shell, batch, winps, and pwsh exec messages. - Add `NOW_CAP_EXEC_ELEVATION` capability flag. + - Add `NOW_EXEC_FLAG_STARTED_NO_IO_REDIRECTION` flag for `NOW_EXEC_STARTED_MSG`. - Add `NOW_CODE_ELEVATION_DENIED` and `NOW_CODE_ELEVATION_UNAVAILABLE` status codes. - Add `NOW_EXEC_FLAG_BATCH_NO_EXIT` flag for batch exec commands. diff --git a/protocols/dotnet/Devolutions.NowClient.Tests/ElevationTests.cs b/protocols/dotnet/Devolutions.NowClient.Tests/ElevationTests.cs index 88c07e9..c284d2b 100644 --- a/protocols/dotnet/Devolutions.NowClient.Tests/ElevationTests.cs +++ b/protocols/dotnet/Devolutions.NowClient.Tests/ElevationTests.cs @@ -30,22 +30,24 @@ public void ElevatedRequestIsRefusedWhenThePeerDoesNotAdvertiseElevation() } [Fact] - public void ElevatedBatchReachesThePeerWithElevatedAndNoExitButWithoutIoRedirection() + public void ElevatedBatchReachesThePeerAndTracksNoIoRedirectionFromStarted() { TestDriver.Run(() => { using var peer = new ClientPeer(); var client = peer.Connect(); - var session = TestDriver.Success(client.ExecBatch(new ExecBatchParams("whoami").Elevated().NoExit())); + var session = TestDriver.Success(client.ExecBatch(new ExecBatchParams("whoami").Elevated().NoExit().IoRedirection())); var request = peer.Wire.Receive(); Assert.Equal(session.SessionId, request.SessionId); Assert.True(request.Elevated); Assert.True(request.NoExit); - Assert.False(request.IoRedirection); + Assert.True(request.IoRedirection); + peer.Wire.Send(new NowMsgExecStarted(session.SessionId, noIoRedirection: true)); peer.Wire.Send(NowMsgExecResult.Success(session.SessionId, 42)); Assert.Equal(42u, TestDriver.Success(session.GetResult())); + Assert.True(session.NoIoRedirection); TestDriver.Success(client.StopAsync()); peer.AssertStopped(); }); diff --git a/protocols/dotnet/Devolutions.NowClient/src/ExecBatchParams.cs b/protocols/dotnet/Devolutions.NowClient/src/ExecBatchParams.cs index c7e0151..6a2b12d 100644 --- a/protocols/dotnet/Devolutions.NowClient/src/ExecBatchParams.cs +++ b/protocols/dotnet/Devolutions.NowClient/src/ExecBatchParams.cs @@ -66,8 +66,8 @@ public ExecBatchParams NoExit(bool enable = true) /// /// Execute with elevated privileges. Requires the host to advertise the elevation - /// capability. The host may prompt the interactive user for consent, and the session has - /// no IO redirection: output stays on the host. + /// capability. The host may prompt the interactive user for consent, and stdio redirection + /// is best-effort depending on the host's elevation mechanism. /// public ExecBatchParams Elevated(bool enable = true) { diff --git a/protocols/dotnet/Devolutions.NowClient/src/ExecProcessParams.cs b/protocols/dotnet/Devolutions.NowClient/src/ExecProcessParams.cs index df2038c..05fa0f3 100644 --- a/protocols/dotnet/Devolutions.NowClient/src/ExecProcessParams.cs +++ b/protocols/dotnet/Devolutions.NowClient/src/ExecProcessParams.cs @@ -56,8 +56,8 @@ public ExecProcessParams EncodingUtf8(bool enable = true) /// /// Execute with elevated privileges. Requires the host to advertise the elevation - /// capability. The host may prompt the interactive user for consent, and the session has - /// no IO redirection: output stays on the host. + /// capability. The host may prompt the interactive user for consent, and stdio redirection + /// is best-effort depending on the host's elevation mechanism. /// public ExecProcessParams Elevated(bool enable = true) { diff --git a/protocols/dotnet/Devolutions.NowClient/src/ExecPwshParams.cs b/protocols/dotnet/Devolutions.NowClient/src/ExecPwshParams.cs index 3c936d2..d21c0eb 100644 --- a/protocols/dotnet/Devolutions.NowClient/src/ExecPwshParams.cs +++ b/protocols/dotnet/Devolutions.NowClient/src/ExecPwshParams.cs @@ -129,8 +129,8 @@ public ExecPwshParams UnicodeConsole(bool enable = true) /// /// Execute with elevated privileges. Requires the host to advertise the elevation - /// capability. The host may prompt the interactive user for consent, and the session has - /// no IO redirection: output stays on the host. + /// capability. The host may prompt the interactive user for consent, and stdio redirection + /// is best-effort depending on the host's elevation mechanism. /// public ExecPwshParams Elevated(bool enable = true) { diff --git a/protocols/dotnet/Devolutions.NowClient/src/ExecRunParams.cs b/protocols/dotnet/Devolutions.NowClient/src/ExecRunParams.cs index 30273ba..931fbbc 100644 --- a/protocols/dotnet/Devolutions.NowClient/src/ExecRunParams.cs +++ b/protocols/dotnet/Devolutions.NowClient/src/ExecRunParams.cs @@ -20,8 +20,7 @@ public ExecRunParams Directory(string directory) /// /// Execute with elevated privileges. Requires the host to advertise the elevation - /// capability. The host may prompt the interactive user for consent, and the session has - /// no IO redirection: output stays on the host. + /// capability. The host may prompt the interactive user for consent. /// public ExecRunParams Elevated(bool enable = true) { diff --git a/protocols/dotnet/Devolutions.NowClient/src/ExecSession.cs b/protocols/dotnet/Devolutions.NowClient/src/ExecSession.cs index 96202ca..790044f 100644 --- a/protocols/dotnet/Devolutions.NowClient/src/ExecSession.cs +++ b/protocols/dotnet/Devolutions.NowClient/src/ExecSession.cs @@ -35,8 +35,13 @@ void IExecSessionHandler.HandleOutput(NowMsgExecData msg) } } - void IExecSessionHandler.HandleStarted() + void IExecSessionHandler.HandleStarted(NowMsgExecStarted msg) { + NoIoRedirection = msg.NoIoRedirection; + if (msg.NoIoRedirection) + { + _lastStdinSent = true; + } _onStarted?.Invoke(SessionId); } @@ -180,6 +185,12 @@ public async Task GetResult() ///
public uint SessionId { get; } + /// + /// True if the server reported in that requested I/O + /// redirection could not be established for this session. + /// + public bool NoIoRedirection { get; private set; } + private void ThrowIfExited() { if (_result != null || _aborted) diff --git a/protocols/dotnet/Devolutions.NowClient/src/ExecShellParams.cs b/protocols/dotnet/Devolutions.NowClient/src/ExecShellParams.cs index a258a03..1e51a72 100644 --- a/protocols/dotnet/Devolutions.NowClient/src/ExecShellParams.cs +++ b/protocols/dotnet/Devolutions.NowClient/src/ExecShellParams.cs @@ -48,8 +48,8 @@ public ExecShellParams Detached(bool enable = true) /// /// Execute with elevated privileges. Requires the host to advertise the elevation - /// capability. The host may prompt the interactive user for consent, and the session has - /// no IO redirection: output stays on the host. + /// capability. The host may prompt the interactive user for consent, and stdio redirection + /// is best-effort depending on the host's elevation mechanism. /// public ExecShellParams Elevated(bool enable = true) { diff --git a/protocols/dotnet/Devolutions.NowClient/src/ExecWinPsParams.cs b/protocols/dotnet/Devolutions.NowClient/src/ExecWinPsParams.cs index 26780ff..a918a71 100644 --- a/protocols/dotnet/Devolutions.NowClient/src/ExecWinPsParams.cs +++ b/protocols/dotnet/Devolutions.NowClient/src/ExecWinPsParams.cs @@ -130,8 +130,8 @@ public ExecWinPsParams UnicodeConsole(bool enable = true) /// /// Execute with elevated privileges. Requires the host to advertise the elevation - /// capability. The host may prompt the interactive user for consent, and the session has - /// no IO redirection: output stays on the host. + /// capability. The host may prompt the interactive user for consent, and stdio redirection + /// is best-effort depending on the host's elevation mechanism. /// public ExecWinPsParams Elevated(bool enable = true) { diff --git a/protocols/dotnet/Devolutions.NowClient/src/IExecSessionHandler.cs b/protocols/dotnet/Devolutions.NowClient/src/IExecSessionHandler.cs index 7c69534..e3f3c16 100644 --- a/protocols/dotnet/Devolutions.NowClient/src/IExecSessionHandler.cs +++ b/protocols/dotnet/Devolutions.NowClient/src/IExecSessionHandler.cs @@ -5,7 +5,7 @@ namespace Devolutions.NowClient internal interface IExecSessionHandler { void HandleOutput(NowMsgExecData msg); - void HandleStarted(); + void HandleStarted(NowMsgExecStarted msg); void HandleCancelRsp(NowMsgExecCancelRsp msg); void HandleResult(NowMsgExecResult msg); } diff --git a/protocols/dotnet/Devolutions.NowClient/src/Worker/WorkerCtx.cs b/protocols/dotnet/Devolutions.NowClient/src/Worker/WorkerCtx.cs index f6c5267..5e28434 100644 --- a/protocols/dotnet/Devolutions.NowClient/src/Worker/WorkerCtx.cs +++ b/protocols/dotnet/Devolutions.NowClient/src/Worker/WorkerCtx.cs @@ -296,7 +296,7 @@ private static void HandleExecMessage(NowMessage.NowMessageView message, WorkerC var decoded = message.Deserialize(); if (ctx.ExecSessionHandlers.TryGetValue(decoded.SessionId, out IExecSessionHandler? handler)) { - handler.HandleStarted(); + handler.HandleStarted(decoded); } else { diff --git a/protocols/dotnet/Devolutions.NowProto.Tests/src/MsgExecGeneral.cs b/protocols/dotnet/Devolutions.NowProto.Tests/src/MsgExecGeneral.cs index 61c86bb..42ba050 100644 --- a/protocols/dotnet/Devolutions.NowProto.Tests/src/MsgExecGeneral.cs +++ b/protocols/dotnet/Devolutions.NowProto.Tests/src/MsgExecGeneral.cs @@ -220,6 +220,24 @@ public void Started() var decoded = NowTest.MessageRoundtrip(msg, encoded); Assert.Equal(msg.SessionId, decoded.SessionId); + Assert.False(decoded.NoIoRedirection); + } + + [Fact] + public void StartedNoIoRedirection() + { + var msg = new NowMsgExecStarted(0x12345678, noIoRedirection: true); + + var encoded = new byte[] + { + 0x04, 0x00, 0x00, 0x00, 0x13, 0x06, 0x01, 0x00, 0x78, 0x56, + 0x34, 0x12, + }; + + var decoded = NowTest.MessageRoundtrip(msg, encoded); + + Assert.Equal(msg.SessionId, decoded.SessionId); + Assert.True(decoded.NoIoRedirection); } } } \ No newline at end of file diff --git a/protocols/dotnet/Devolutions.NowProto.Tests/src/NowExecStyles.cs b/protocols/dotnet/Devolutions.NowProto.Tests/src/NowExecStyles.cs index 292935e..b1f6d33 100644 --- a/protocols/dotnet/Devolutions.NowProto.Tests/src/NowExecStyles.cs +++ b/protocols/dotnet/Devolutions.NowProto.Tests/src/NowExecStyles.cs @@ -291,7 +291,7 @@ public void RunElevated() var encoded = new byte[] { - 0x09, 0x00, 0x00, 0x00, 0x13, 0x10, 0x02, 0x00, 0x78, 0x56, + 0x09, 0x00, 0x00, 0x00, 0x13, 0x10, 0x00, 0x40, 0x78, 0x56, 0x34, 0x12, 0x01, 0x61, 0x00, 0x00, 0x00 }; @@ -309,7 +309,7 @@ public void ProcessElevated() var encoded = new byte[] { - 0x0B, 0x00, 0x00, 0x00, 0x13, 0x11, 0x08, 0x00, 0x78, 0x56, + 0x0B, 0x00, 0x00, 0x00, 0x13, 0x11, 0x00, 0x40, 0x78, 0x56, 0x34, 0x12, 0x01, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00 }; @@ -327,7 +327,7 @@ public void ShellElevated() var encoded = new byte[] { - 0x0B, 0x00, 0x00, 0x00, 0x13, 0x12, 0x08, 0x00, 0x78, 0x56, + 0x0B, 0x00, 0x00, 0x00, 0x13, 0x12, 0x00, 0x40, 0x78, 0x56, 0x34, 0x12, 0x01, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00 }; @@ -346,7 +346,7 @@ public void BatchElevatedNoExit() var encoded = new byte[] { - 0x09, 0x00, 0x00, 0x00, 0x13, 0x13, 0x18, 0x00, 0x78, 0x56, + 0x09, 0x00, 0x00, 0x00, 0x13, 0x13, 0x08, 0x40, 0x78, 0x56, 0x34, 0x12, 0x01, 0x61, 0x00, 0x00, 0x00 }; @@ -365,7 +365,7 @@ public void WinPsElevated() var encoded = new byte[] { - 0x0D, 0x00, 0x00, 0x00, 0x13, 0x14, 0x00, 0x08, 0x78, 0x56, + 0x0D, 0x00, 0x00, 0x00, 0x13, 0x14, 0x00, 0x40, 0x78, 0x56, 0x34, 0x12, 0x01, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }; @@ -384,7 +384,7 @@ public void PwshElevated() var encoded = new byte[] { - 0x0D, 0x00, 0x00, 0x00, 0x13, 0x15, 0x00, 0x08, 0x78, 0x56, + 0x0D, 0x00, 0x00, 0x00, 0x13, 0x15, 0x00, 0x40, 0x78, 0x56, 0x34, 0x12, 0x01, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }; @@ -395,7 +395,7 @@ public void PwshElevated() } - // ELEVATED and NO_EXIT are asserted independently as well as together: the combined 0x0018 + // ELEVATED and NO_EXIT are asserted independently as well as together: the combined 0x4008 // mask alone would stay green if the two constants were swapped. [Fact] @@ -407,7 +407,7 @@ public void BatchElevatedOnly() var encoded = new byte[] { - 0x09, 0x00, 0x00, 0x00, 0x13, 0x13, 0x08, 0x00, 0x78, 0x56, + 0x09, 0x00, 0x00, 0x00, 0x13, 0x13, 0x00, 0x40, 0x78, 0x56, 0x34, 0x12, 0x01, 0x61, 0x00, 0x00, 0x00 }; @@ -426,7 +426,7 @@ public void BatchNoExitOnly() var encoded = new byte[] { - 0x09, 0x00, 0x00, 0x00, 0x13, 0x13, 0x10, 0x00, 0x78, 0x56, + 0x09, 0x00, 0x00, 0x00, 0x13, 0x13, 0x08, 0x00, 0x78, 0x56, 0x34, 0x12, 0x01, 0x61, 0x00, 0x00, 0x00 }; diff --git a/protocols/dotnet/Devolutions.NowProto/src/Capabilities/NowCapabilityExec.cs b/protocols/dotnet/Devolutions.NowProto/src/Capabilities/NowCapabilityExec.cs index 8ac4401..ee2c00c 100644 --- a/protocols/dotnet/Devolutions.NowProto/src/Capabilities/NowCapabilityExec.cs +++ b/protocols/dotnet/Devolutions.NowProto/src/Capabilities/NowCapabilityExec.cs @@ -64,7 +64,8 @@ public enum NowCapabilityExec : ushort /// /// Set if host can run elevated exec sessions. Elevation may prompt the interactive user - /// for consent, and stdio is not redirected for elevated sessions. + /// for consent, and stdio redirection may be unavailable depending on the host's elevation + /// mechanism. /// /// NOW-PROTO: NOW_CAP_EXEC_ELEVATION /// diff --git a/protocols/dotnet/Devolutions.NowProto/src/Exceptions/NowProtocolErrorCode.cs b/protocols/dotnet/Devolutions.NowProto/src/Exceptions/NowProtocolErrorCode.cs index 58c2550..d36e8a4 100644 --- a/protocols/dotnet/Devolutions.NowProto/src/Exceptions/NowProtocolErrorCode.cs +++ b/protocols/dotnet/Devolutions.NowProto/src/Exceptions/NowProtocolErrorCode.cs @@ -51,15 +51,17 @@ public enum NowProtocolErrorCode : ushort /// ProtocolVersion = 0x0008, /// - /// Elevation was requested and refused: the interactive user declined consent, the consent - /// prompt expired, or policy denies elevation for the account. + /// Elevation was requested and refused for this session: the interactive user declined + /// consent, the consent prompt expired, or a host policy/rule denied elevation for the + /// request. /// /// NOW-PROTO: NOW_CODE_ELEVATION_DENIED /// ElevationDenied = 0x0009, /// /// Elevation was requested and is not possible for this request: the account has no - /// elevated token, or the server cannot elevate the requested execution style. + /// elevated token, elevation is disabled on the host, or the server cannot elevate the + /// requested execution style. /// /// NOW-PROTO: NOW_CODE_ELEVATION_UNAVAILABLE /// diff --git a/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecBatch.cs b/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecBatch.cs index 1ac5b83..b6b3b2a 100644 --- a/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecBatch.cs +++ b/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecBatch.cs @@ -91,21 +91,13 @@ 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, + NoExit = 0x0008, /// /// Enable stdio (stdout, stderr, stdin) redirection. @@ -114,6 +106,14 @@ private enum MsgFlags : ushort /// IoRedirection = 0x1000, + /// + /// 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 = 0x4000, + /// /// Detached mode: the batch is started without tracking execution or sending back output. /// diff --git a/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecProcess.cs b/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecProcess.cs index e8331f6..b91a1e9 100644 --- a/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecProcess.cs +++ b/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecProcess.cs @@ -92,19 +92,19 @@ private enum MsgFlags : ushort EncodingUtf8 = 0x0004, /// - /// Execute the command with elevated privileges. The elevation mechanism is chosen by - /// the host and advertised in execCapset. + /// Enable stdio (stdout, stderr, stdin) redirection. /// - /// NOW-PROTO: NOW_EXEC_FLAG_PROCESS_ELEVATED + /// NOW-PROTO: NOW_EXEC_FLAG_PROCESS_IO_REDIRECTION /// - Elevated = 0x0008, + IoRedirection = 0x1000, /// - /// Enable stdio (stdout, stderr, stdin) redirection. + /// Execute the command with elevated privileges. The elevation mechanism is chosen by + /// the host and advertised in execCapset. /// - /// NOW-PROTO: NOW_EXEC_FLAG_PROCESS_IO_REDIRECTION + /// NOW-PROTO: NOW_EXEC_FLAG_PROCESS_ELEVATED /// - IoRedirection = 0x1000, + Elevated = 0x4000, /// /// Detached mode: the process is started without tracking execution or sending back output. diff --git a/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecPwsh.cs b/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecPwsh.cs index 4cad84e..d214183 100644 --- a/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecPwsh.cs +++ b/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecPwsh.cs @@ -102,14 +102,6 @@ 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. /// @@ -124,6 +116,14 @@ private enum MsgFlags /// ServerMode = 0x2000, + /// + /// 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 = 0x4000, + /// /// Detached mode: PowerShell is started without tracking execution or sending back output. /// diff --git a/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecRun.cs b/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecRun.cs index fa90fda..f0976fb 100644 --- a/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecRun.cs +++ b/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecRun.cs @@ -100,7 +100,7 @@ private enum MsgFlags : ushort /// /// NOW-PROTO: NOW_EXEC_FLAG_RUN_ELEVATED /// - Elevated = 0x0002, + Elevated = 0x4000, } private const uint FixedPartSize = 4; // u32 SessionId diff --git a/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecShell.cs b/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecShell.cs index b75f90b..bb966f2 100644 --- a/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecShell.cs +++ b/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecShell.cs @@ -83,19 +83,19 @@ private enum MsgFlags : ushort DirectorySet = 0x0002, /// - /// Execute the command with elevated privileges. The elevation mechanism is chosen by - /// the host and advertised in execCapset. + /// Enable stdio (stdout, stderr, stdin) redirection. /// - /// NOW-PROTO: NOW_EXEC_FLAG_SHELL_ELEVATED + /// NOW-PROTO: NOW_EXEC_FLAG_SHELL_IO_REDIRECTION /// - Elevated = 0x0008, + IoRedirection = 0x1000, /// - /// Enable stdio (stdout, stderr, stdin) redirection. + /// Execute the command with elevated privileges. The elevation mechanism is chosen by + /// the host and advertised in execCapset. /// - /// NOW-PROTO: NOW_EXEC_FLAG_SHELL_IO_REDIRECTION + /// NOW-PROTO: NOW_EXEC_FLAG_SHELL_ELEVATED /// - IoRedirection = 0x1000, + Elevated = 0x4000, /// /// Detached mode: the shell is started without tracking execution or sending back output. diff --git a/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecStarted.cs b/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecStarted.cs index 118becb..47c6514 100644 --- a/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecStarted.cs +++ b/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecStarted.cs @@ -1,4 +1,6 @@ -namespace Devolutions.NowProto.Messages +using System; + +namespace Devolutions.NowProto.Messages { /// /// The NOW_EXEC_STARTED_MSG message is sent by the server after the execution session has been @@ -6,7 +8,7 @@ /// /// NOW-PROTO: NOW_EXEC_STARTED_MSG /// - public class NowMsgExecStarted(uint sessionId) : INowSerialize, INowDeserialize + public class NowMsgExecStarted(uint sessionId, bool noIoRedirection = false) : INowSerialize, INowDeserialize { // -- INowMessage -- @@ -18,7 +20,7 @@ public class NowMsgExecStarted(uint sessionId) : INowSerialize, INowDeserialize< // -- INowSerialize -- - ushort INowSerialize.Flags => 0; + ushort INowSerialize.Flags => (ushort)(NoIoRedirection ? MsgFlags.NoIoRedirection : MsgFlags.None); uint INowSerialize.BodySize => FixedPartSize; void INowSerialize.SerializeBody(NowWriteCursor cursor) @@ -34,15 +36,31 @@ NowReadCursor cursor ) { cursor.EnsureEnoughBytes(FixedPartSize); + var msgFlags = (MsgFlags)flags; var sessionId = cursor.ReadUInt32Le(); - return new NowMsgExecStarted(sessionId); + return new NowMsgExecStarted(sessionId, msgFlags.HasFlag(MsgFlags.NoIoRedirection)); } // -- impl -- + [Flags] + private enum MsgFlags : ushort + { + None = 0x0000, + + /// + /// Set when NOW_EXEC_FLAG_*_IO_REDIRECTION was requested, but the server could not + /// redirect standard streams for this session. + /// + /// NOW-PROTO: NOW_EXEC_FLAG_STARTED_NO_IO_REDIRECTION + /// + NoIoRedirection = 0x0001, + } + private const uint FixedPartSize = 4 /* u32 sessionId */; public uint SessionId { get; } = sessionId; + public bool NoIoRedirection { get; } = noIoRedirection; } } \ No newline at end of file diff --git a/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecWinPs.cs b/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecWinPs.cs index 767cba0..7aeb094 100644 --- a/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecWinPs.cs +++ b/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecWinPs.cs @@ -102,14 +102,6 @@ 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. /// @@ -124,6 +116,14 @@ private enum MsgFlags /// ServerMode = 0x2000, + /// + /// 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 = 0x4000, + /// /// Detached mode: PowerShell is started without tracking execution or sending back output. /// diff --git a/protocols/rust/now-client/src/capabilities.rs b/protocols/rust/now-client/src/capabilities.rs index 9d9df83..632af62 100644 --- a/protocols/rust/now-client/src/capabilities.rs +++ b/protocols/rust/now-client/src/capabilities.rs @@ -83,10 +83,10 @@ impl NowCapabilities { /// Returns whether elevated execution is available. /// /// Elevation is gated on the capability rather than the version alone, because a 1.7 peer - /// that cannot elevate (for example, one that has not shipped the host-side change) must + /// that cannot elevate (for example, one where elevation is disabled or unsupported) must /// answer `false`. - /// Under this capability the host may prompt the interactive user for consent, and an - /// elevated session has no stdio redirection. + /// Under this capability the host may prompt the interactive user for consent, and stdio + /// redirection is best-effort depending on the host's elevation mechanism. pub fn supports_elevation(&self) -> bool { self.has(NowExecCapsetFlags::ELEVATION) && self.at_least(1, 7) } diff --git a/protocols/rust/now-client/src/client.rs b/protocols/rust/now-client/src/client.rs index 8d5baaf..d261745 100644 --- a/protocols/rust/now-client/src/client.rs +++ b/protocols/rust/now-client/src/client.rs @@ -499,6 +499,11 @@ where } NowMessage::Exec(NowExecMessage::Started(message)) => { let session_id = message.session_id(); + if message.is_no_io_redirection() { + if let Some(operation) = self.operations.get_mut(&session_id) { + operation.stdin_closed = true; + } + } if !self.discarded_run_sessions.contains(&session_id) { self.emit_event(session_id, ExecutionEvent::Started)?; } @@ -1505,8 +1510,9 @@ mod tests { } #[tokio::test] - async fn elevated_request_omits_io_redirection_and_tracks_the_result() { + async fn elevated_request_preserves_io_redirection_and_tracks_the_result() { let (client_stream, mut peer_stream) = tokio::io::duplex(4096); + let (result_sender, result_receiver) = tokio::sync::oneshot::channel(); let peer = tokio::spawn(async move { let _ = read_message(&mut peer_stream).await; write_message( @@ -1522,13 +1528,18 @@ mod tests { let session_id = match read_message(&mut peer_stream).await { NowMessage::Exec(NowExecMessage::Process(message)) => { assert!(message.is_elevated()); - assert!(!message.is_with_io_redirection()); + assert!(message.is_with_io_redirection()); assert!(!message.is_detached()); message.session_id() } message => panic!("expected elevated Process request, got {message:?}"), }; - write_message(&mut peer_stream, NowExecStartedMsg::new(session_id)).await; + write_message( + &mut peer_stream, + NowExecStartedMsg::new(session_id).with_no_io_redirection(), + ) + .await; + let _ = result_receiver.await; write_message(&mut peer_stream, NowExecResultMsg::new_success(session_id, 42)).await; }); @@ -1537,23 +1548,24 @@ mod tests { Err(error) => panic!("handshake must succeed: {error}"), }; assert!(handle.capabilities().supports_elevation()); - match handle + let mut execution = match handle .process( ProcessRequest::new("process.exe") .with_elevated() - .with_timeout(Duration::from_secs(1)), + .with_timeout(Duration::from_secs(5)), ) .await { - Err(NowClientError::InvalidRequest(_)) => {} - Err(error) => panic!("unexpected error: {error}"), - Ok(_) => panic!("elevated request must reject a timeout it cannot enforce"), - } - let mut execution = match handle.process(ProcessRequest::new("process.exe").with_elevated()).await { Ok(execution) => execution, Err(error) => panic!("elevated Process request must start: {error}"), }; assert_eq!(execution.next_event().await, Some(ExecutionEvent::Started)); + match execution.send_stdin(b"input".to_vec(), true).await { + Err(NowClientError::InvalidRequest(_)) => {} + Err(error) => panic!("unexpected error when sending stdin after NO_IO_REDIRECTION: {error}"), + Ok(()) => panic!("stdin must be closed when Started reports NO_IO_REDIRECTION"), + } + let _ = result_sender.send(()); match execution.wait().await { Ok(ExecutionStatus::Completed { exit_code: 42 }) => {} Ok(status) => panic!("unexpected terminal status: {status:?}"), diff --git a/protocols/rust/now-client/src/exec.rs b/protocols/rust/now-client/src/exec.rs index e64e9d0..0284341 100644 --- a/protocols/rust/now-client/src/exec.rs +++ b/protocols/rust/now-client/src/exec.rs @@ -120,8 +120,8 @@ impl ProcessRequest { /// Requests elevated privileges. /// /// Requires [`NowCapabilities::supports_elevation`]. - /// An elevated session has no stdio and cannot be cancelled, so it rejects inline stdin and - /// a timeout; the peer may prompt the interactive user for consent before the process starts. + /// The peer may prompt the interactive user for consent before the process starts, and stdio + /// redirection is best-effort depending on the host's elevation mechanism. #[must_use] pub fn with_elevated(mut self) -> Self { self.elevated = true; @@ -167,8 +167,8 @@ impl BatchRequest { /// Requests elevated privileges. /// /// Requires [`NowCapabilities::supports_elevation`]. - /// An elevated session has no stdio and cannot be cancelled, so it rejects inline stdin and - /// a timeout; the peer may prompt the interactive user for consent before the process starts. + /// The peer may prompt the interactive user for consent before the process starts, and stdio + /// redirection is best-effort depending on the host's elevation mechanism. #[must_use] pub fn with_elevated(mut self) -> Self { self.common.elevated = true; @@ -232,8 +232,8 @@ impl PowerShellRequest { /// Requests elevated privileges. /// /// Requires [`NowCapabilities::supports_elevation`]. - /// An elevated session has no stdio and cannot be cancelled, so it rejects inline stdin and - /// a timeout; the peer may prompt the interactive user for consent before the process starts. + /// The peer may prompt the interactive user for consent before the process starts, and stdio + /// redirection is best-effort depending on the host's elevation mechanism. #[must_use] pub fn with_elevated(mut self) -> Self { self.common.elevated = true; @@ -320,7 +320,7 @@ impl RequestSpec { } if detached { message = message.with_detached(); - } else if !request.elevated { + } else if capabilities.supports_io_redirection() { message = message.with_io_redirection(); } Ok(EncodedRequest { @@ -342,7 +342,7 @@ impl RequestSpec { } if detached { message = message.with_detached(); - } else if !common.elevated { + } else if capabilities.supports_io_redirection() { message = message.with_io_redirection(); } Ok(EncodedRequest { @@ -369,8 +369,8 @@ impl RequestSpec { validate_optional(&request.parameters, "parameters")?; validate_directory(request.directory.as_deref())?; validate_elevation(capabilities, request.elevated)?; - validate_common(detached, request.elevated, request.stdin.as_deref(), request.timeout)?; - validate_tracking(capabilities, detached, request.elevated)?; + validate_common(detached, request.stdin.as_deref(), request.timeout)?; + validate_tracking(capabilities, detached, request.elevated, request.stdin.is_some())?; } Self::Batch(request) => { require(capabilities.supports_batch(), "Batch")?; @@ -405,7 +405,7 @@ macro_rules! apply_power_shell_options { } if $detached { $message = $message.with_detached(); - } else if !$common.elevated { + } else if $capabilities.supports_io_redirection() { $message = $message.with_io_redirection(); } }; @@ -444,8 +444,8 @@ fn validate_common_request( validate_command(&request.command)?; validate_directory(request.directory.as_deref())?; validate_elevation(capabilities, request.elevated)?; - validate_common(detached, request.elevated, request.stdin.as_deref(), request.timeout)?; - validate_tracking(capabilities, detached, request.elevated) + validate_common(detached, request.stdin.as_deref(), request.timeout)?; + validate_tracking(capabilities, detached, request.elevated, request.stdin.is_some()) } /// Refuses an elevated request the peer cannot honour. @@ -459,12 +459,7 @@ fn validate_elevation(capabilities: &NowCapabilities, elevated: bool) -> Result< Ok(()) } -fn validate_common( - detached: bool, - elevated: bool, - stdin: Option<&[u8]>, - timeout: Option, -) -> Result<(), NowClientError> { +fn validate_common(detached: bool, stdin: Option<&[u8]>, timeout: Option) -> Result<(), NowClientError> { if detached && stdin.is_some() { return Err(NowClientError::InvalidRequest( "detached execution cannot include inline stdin".to_owned(), @@ -475,18 +470,6 @@ fn validate_common( "detached execution cannot include a timeout".to_owned(), )); } - // An elevated session has no stdio, and its cancellation may fail, so a timeout could not be - // enforced. - if elevated && stdin.is_some() { - return Err(NowClientError::InvalidRequest( - "elevated execution cannot include inline stdin".to_owned(), - )); - } - if elevated && timeout.is_some() { - return Err(NowClientError::InvalidRequest( - "elevated execution cannot include a timeout".to_owned(), - )); - } if timeout.is_some_and(|timeout| timeout.is_zero()) { return Err(NowClientError::InvalidRequest( "execution timeout must not be zero".to_owned(), @@ -495,12 +478,17 @@ fn validate_common( Ok(()) } -fn validate_tracking(capabilities: &NowCapabilities, detached: bool, elevated: bool) -> Result<(), NowClientError> { +fn validate_tracking( + capabilities: &NowCapabilities, + detached: bool, + elevated: bool, + has_stdin: bool, +) -> Result<(), NowClientError> { if detached { require(capabilities.supports_detached(), "detached execution") - } else if elevated { - // Tracked elevated sessions report their result without stdio, so they do not need - // I/O redirection; `validate_elevation` already required the elevation capability. + } else if elevated && !has_stdin { + // Tracked elevated sessions report their result even when the peer does not redirect + // stdio; when the peer does support I/O redirection, `IO_REDIRECTION` is set as usual. Ok(()) } else { require(capabilities.supports_io_redirection(), "I/O redirection") diff --git a/protocols/rust/now-proto-pdu/src/channel/capset.rs b/protocols/rust/now-proto-pdu/src/channel/capset.rs index 58b3c1d..9edac8f 100644 --- a/protocols/rust/now-proto-pdu/src/channel/capset.rs +++ b/protocols/rust/now-proto-pdu/src/channel/capset.rs @@ -94,7 +94,8 @@ bitflags! { /// NOW-PROTO: NOW_CAP_EXEC_UNICODE_CONSOLE const UNICODE_CONSOLE = 0x0040; /// Set if host can run elevated exec sessions. Elevation may prompt the interactive user - /// for consent, and stdio is not redirected for elevated sessions. + /// for consent, and stdio redirection may be unavailable depending on the host's elevation + /// mechanism. /// /// NOW-PROTO: NOW_CAP_EXEC_ELEVATION const ELEVATION = 0x0080; diff --git a/protocols/rust/now-proto-pdu/src/core/status.rs b/protocols/rust/now-proto-pdu/src/core/status.rs index 5be8126..26bd07a 100644 --- a/protocols/rust/now-proto-pdu/src/core/status.rs +++ b/protocols/rust/now-proto-pdu/src/core/status.rs @@ -85,13 +85,15 @@ pub enum NowProtoError { /// /// NOW-PROTO: NOW_CODE_PROTOCOL_VERSION ProtocolVersion, - /// Elevation was requested and refused: the interactive user declined consent, the consent - /// prompt expired, or policy denies elevation for the account. + /// Elevation was requested and refused for this session: the interactive user declined + /// consent, the consent prompt expired, or a host policy/rule denied elevation for the + /// request. /// /// NOW-PROTO: NOW_CODE_ELEVATION_DENIED ElevationDenied, /// Elevation was requested and is not possible for this request: the account has no elevated - /// token, or the server cannot elevate the requested execution style. + /// token, elevation is disabled on the host, or the server cannot elevate the requested + /// execution style. /// /// NOW-PROTO: NOW_CODE_ELEVATION_UNAVAILABLE ElevationUnavailable, diff --git a/protocols/rust/now-proto-pdu/src/exec/batch.rs b/protocols/rust/now-proto-pdu/src/exec/batch.rs index d889e61..19679e7 100644 --- a/protocols/rust/now-proto-pdu/src/exec/batch.rs +++ b/protocols/rust/now-proto-pdu/src/exec/batch.rs @@ -27,22 +27,22 @@ 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; + const NO_EXIT = 0x0008; /// Enable stdio (stdout, stderr, stdin) redirection. /// /// NOW-PROTO: NOW_EXEC_FLAG_BATCH_IO_REDIRECTION const IO_REDIRECTION = 0x1000; + /// 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 = 0x4000; /// Detached mode: the batch is started without tracking execution or sending back output. /// /// NOW-PROTO: NOW_EXEC_FLAG_BATCH_DETACHED diff --git a/protocols/rust/now-proto-pdu/src/exec/process.rs b/protocols/rust/now-proto-pdu/src/exec/process.rs index ed515b3..ededf0b 100644 --- a/protocols/rust/now-proto-pdu/src/exec/process.rs +++ b/protocols/rust/now-proto-pdu/src/exec/process.rs @@ -29,17 +29,17 @@ bitflags! { /// /// NOW-PROTO: NOW_EXEC_FLAG_PROCESS_ENCODING_UTF8 const ENCODING_UTF8 = 0x0004; + /// Enable stdio (stdout, stderr, stdin) redirection. + /// + /// NOW-PROTO: NOW_EXEC_FLAG_PROCESS_IO_REDIRECTION + const IO_REDIRECTION = 0x1000; + /// 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. - /// - /// NOW-PROTO: NOW_EXEC_FLAG_PROCESS_IO_REDIRECTION - const IO_REDIRECTION = 0x1000; + const ELEVATED = 0x4000; /// Detached mode: the process is started without tracking execution or sending back output. /// diff --git a/protocols/rust/now-proto-pdu/src/exec/run.rs b/protocols/rust/now-proto-pdu/src/exec/run.rs index 912ba4f..603f2f6 100644 --- a/protocols/rust/now-proto-pdu/src/exec/run.rs +++ b/protocols/rust/now-proto-pdu/src/exec/run.rs @@ -21,7 +21,7 @@ bitflags! { /// instead of silently executing without elevation. /// /// NOW-PROTO: NOW_EXEC_FLAG_RUN_ELEVATED - const ELEVATED = 0x0002; + const ELEVATED = 0x4000; } } diff --git a/protocols/rust/now-proto-pdu/src/exec/shell.rs b/protocols/rust/now-proto-pdu/src/exec/shell.rs index 47d67b9..644b0fc 100644 --- a/protocols/rust/now-proto-pdu/src/exec/shell.rs +++ b/protocols/rust/now-proto-pdu/src/exec/shell.rs @@ -21,17 +21,17 @@ bitflags! { /// /// NOW-PROTO: NOW_EXEC_FLAG_SHELL_DIRECTORY_SET const DIRECTORY_SET = 0x0002; + /// Enable stdio (stdout, stderr, stdin) redirection. + /// + /// NOW-PROTO: NOW_EXEC_FLAG_SHELL_IO_REDIRECTION + const IO_REDIRECTION = 0x1000; + /// 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. - /// - /// NOW-PROTO: NOW_EXEC_FLAG_SHELL_IO_REDIRECTION - const IO_REDIRECTION = 0x1000; + const ELEVATED = 0x4000; /// Detached mode: the shell is started without tracking execution or sending back output. /// diff --git a/protocols/rust/now-proto-pdu/src/exec/started.rs b/protocols/rust/now-proto-pdu/src/exec/started.rs index 0dadbea..9ccd09c 100644 --- a/protocols/rust/now-proto-pdu/src/exec/started.rs +++ b/protocols/rust/now-proto-pdu/src/exec/started.rs @@ -1,3 +1,4 @@ +use bitflags::bitflags; use ironrdp_core::{ cast_length, ensure_fixed_part_size, invalid_field_err, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, @@ -5,12 +6,26 @@ use ironrdp_core::{ use crate::{NowExecMessage, NowExecMsgKind, NowHeader, NowMessage, NowMessageClass}; +bitflags! { + /// Underlying flag type for `NOW_EXEC_STARTED_MSG`. + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] + pub struct NowExecStartedFlags: u16 { + /// Set when `NOW_EXEC_FLAG_*_IO_REDIRECTION` was requested, but the server could not + /// redirect standard streams for this session (for example, because the elevation + /// mechanism does not support pipe inheritance). + /// + /// NOW-PROTO: NOW_EXEC_FLAG_STARTED_NO_IO_REDIRECTION + const NO_IO_REDIRECTION = 0x0001; + } +} + /// The NOW_EXEC_STARTED_MSG message is sent by the server after the execution session has been /// successfully started. /// /// NOW-PROTO: NOW_EXEC_STARTED_MSG #[derive(Debug, Clone, PartialEq, Eq)] pub struct NowExecStartedMsg { + flags: NowExecStartedFlags, session_id: u32, } @@ -19,19 +34,33 @@ impl NowExecStartedMsg { const FIXED_PART_SIZE: usize = 4; pub fn new(session_id: u32) -> Self { - Self { session_id } + Self { + flags: NowExecStartedFlags::empty(), + session_id, + } + } + + #[must_use] + pub fn with_no_io_redirection(mut self) -> Self { + self.flags |= NowExecStartedFlags::NO_IO_REDIRECTION; + self + } + + pub fn is_no_io_redirection(&self) -> bool { + self.flags.contains(NowExecStartedFlags::NO_IO_REDIRECTION) } pub fn session_id(&self) -> u32 { self.session_id } - pub(super) fn decode_from_body(_header: NowHeader, src: &mut ReadCursor<'_>) -> DecodeResult { + pub(super) fn decode_from_body(header: NowHeader, src: &mut ReadCursor<'_>) -> DecodeResult { ensure_fixed_part_size!(in: src); + let flags = NowExecStartedFlags::from_bits_retain(header.flags); let session_id = src.read_u32(); - Ok(Self { session_id }) + Ok(Self { flags, session_id }) } } @@ -41,7 +70,7 @@ impl Encode for NowExecStartedMsg { size: cast_length!("size", Self::FIXED_PART_SIZE)?, class: NowMessageClass::EXEC, kind: NowExecMsgKind::STARTED.0, - flags: 0, + flags: self.flags.bits(), }; header.encode(dst)?; 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 065351b..67f388b 100644 --- a/protocols/rust/now-proto-pdu/src/exec/win_ps.rs +++ b/protocols/rust/now-proto-pdu/src/exec/win_ps.rs @@ -64,22 +64,20 @@ 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. /// /// NOW-PROTO: NOW_EXEC_FLAG_PS_IO_REDIRECTION const IO_REDIRECTION = 0x1000; - /// Run PowerShell in server mode. /// /// NOW-PROTO: NOW_EXEC_FLAG_PS_SERVER_MODE const SERVER_MODE = 0x2000; + /// 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 = 0x4000; /// Detached mode: PowerShell is started without tracking execution or sending back output. /// diff --git a/protocols/rust/now-proto-testsuite/tests/proto/exec.rs b/protocols/rust/now-proto-testsuite/tests/proto/exec.rs index d3ee080..b017f81 100644 --- a/protocols/rust/now-proto-testsuite/tests/proto/exec.rs +++ b/protocols/rust/now-proto-testsuite/tests/proto/exec.rs @@ -212,6 +212,22 @@ fn roundtrip_exec_started() { }; assert_eq!(actual.session_id(), 0x12345678); + assert!(!actual.is_no_io_redirection()); +} + +#[test] +fn roundtrip_exec_started_no_io_redirection() { + let msg = NowExecStartedMsg::new(0x12345678).with_no_io_redirection(); + + let decoded = now_msg_roundtrip(msg, expect!["[04, 00, 00, 00, 13, 06, 01, 00, 78, 56, 34, 12]"]); + + let actual = match decoded { + NowMessage::Exec(NowExecMessage::Started(msg)) => msg, + _ => panic!("Expected NowExecStartedMsg"), + }; + + assert_eq!(actual.session_id(), 0x12345678); + assert!(actual.is_no_io_redirection()); } #[test] @@ -495,7 +511,7 @@ fn roundtrip_exec_pwsh_simple() { } // 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. +// uniform 0x4000 ELEVATED bit and nothing else, so a host cannot mistake it for another option. #[test] fn roundtrip_exec_run_elevated() { @@ -503,7 +519,7 @@ fn roundtrip_exec_run_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]"], + expect!["[09, 00, 00, 00, 13, 10, 00, 40, 78, 56, 34, 12, 01, 61, 00, 00, 00]"], ); let actual = match decoded { @@ -520,7 +536,7 @@ fn roundtrip_exec_process_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]"], + expect!["[0B, 00, 00, 00, 13, 11, 00, 40, 78, 56, 34, 12, 01, 61, 00, 00, 00, 00, 00]"], ); let actual = match decoded { @@ -537,7 +553,7 @@ fn roundtrip_exec_shell_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]"], + expect!["[0B, 00, 00, 00, 13, 12, 00, 40, 78, 56, 34, 12, 01, 61, 00, 00, 00, 00, 00]"], ); let actual = match decoded { @@ -557,7 +573,7 @@ fn roundtrip_exec_batch_elevated_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]"], + expect!["[09, 00, 00, 00, 13, 13, 08, 40, 78, 56, 34, 12, 01, 61, 00, 00, 00]"], ); let actual = match decoded { @@ -575,7 +591,7 @@ fn roundtrip_exec_winps_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]"], + expect!["[0D, 00, 00, 00, 13, 14, 00, 40, 78, 56, 34, 12, 01, 61, 00, 00, 00, 00, 00, 00, 00]"], ); let actual = match decoded { @@ -592,7 +608,7 @@ fn roundtrip_exec_pwsh_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]"], + expect!["[0D, 00, 00, 00, 13, 15, 00, 40, 78, 56, 34, 12, 01, 61, 00, 00, 00, 00, 00, 00, 00]"], ); let actual = match decoded { @@ -603,7 +619,7 @@ fn roundtrip_exec_pwsh_elevated() { assert!(actual.is_elevated()); } -// ELEVATED and NO_EXIT are asserted independently as well as together: the combined 0x0018 mask +// ELEVATED and NO_EXIT are asserted independently as well as together: the combined 0x4008 mask // alone would stay green if the two constants were swapped. #[test] @@ -612,7 +628,7 @@ fn roundtrip_exec_batch_elevated_only() { let decoded = now_msg_roundtrip( msg, - expect!["[09, 00, 00, 00, 13, 13, 08, 00, 78, 56, 34, 12, 01, 61, 00, 00, 00]"], + expect!["[09, 00, 00, 00, 13, 13, 00, 40, 78, 56, 34, 12, 01, 61, 00, 00, 00]"], ); let actual = match decoded { @@ -630,7 +646,7 @@ fn roundtrip_exec_batch_no_exit_only() { let decoded = now_msg_roundtrip( msg, - expect!["[09, 00, 00, 00, 13, 13, 10, 00, 78, 56, 34, 12, 01, 61, 00, 00, 00]"], + expect!["[09, 00, 00, 00, 13, 13, 08, 00, 78, 56, 34, 12, 01, 61, 00, 00, 00]"], ); let actual = match decoded {