diff --git a/protocols/docs/NOW-spec.md b/protocols/docs/NOW-spec.md index 3f57c56..b86059e 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) @@ -230,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 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. @@ -328,6 +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 redirection may be unavailable depending on the host's elevation mechanism. See [Elevated Execution](#elevated-execution). | @@ -924,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 @@ -956,6 +964,7 @@ packet-beta | Flag | Meaning | |----------------------------------------|---------------------------| | NOW_EXEC_FLAG_RUN_DIRECTORY_SET
0x0001 | `directory` field contains non-default value. | +| 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. @@ -994,6 +1003,7 @@ packet-beta | 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_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. @@ -1036,6 +1046,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_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. @@ -1077,7 +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_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. @@ -1134,6 +1147,7 @@ packet-beta | 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_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. @@ -1196,6 +1210,73 @@ packet-beta **configurationName (variable)**: A NOW_VARSTR structure, same as with NOW_EXEC_WINPS_MSG. +#### Elevated Execution + +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 +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. 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 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 | 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 +replace the NOW code, because a client needs a platform-independent signal. + ### RDM Messages #### NOW_RDM_MSG @@ -1484,3 +1565,9 @@ 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` (`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 new file mode 100644 index 0000000..c284d2b --- /dev/null +++ b/protocols/dotnet/Devolutions.NowClient.Tests/ElevationTests.cs @@ -0,0 +1,85 @@ +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 ElevatedBatchReachesThePeerAndTracksNoIoRedirectionFromStarted() + { + TestDriver.Run(() => + { + using var peer = new ClientPeer(); + var client = peer.Connect(); + 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.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(); + }); + } + + [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/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..6a2b12d 100644 --- a/protocols/dotnet/Devolutions.NowClient/src/ExecBatchParams.cs +++ b/protocols/dotnet/Devolutions.NowClient/src/ExecBatchParams.cs @@ -54,6 +54,29 @@ 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 the elevation + /// 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) + { + IsElevated = enable; + return this; + } + + internal bool IsNoExit => _noExit; + internal NowMsgExecBatch ToNowMessage(uint sessionId) { var builder = new NowMsgExecBatch.Builder(sessionId, command); @@ -83,6 +106,16 @@ internal NowMsgExecBatch ToNowMessage(uint sessionId) builder.EnableUnicodeConsole(); } + if (IsElevated) + { + builder.EnableElevated(); + } + + if (_noExit) + { + builder.EnableNoExit(); + } + return builder.Build(); } @@ -91,5 +124,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..05fa0f3 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,17 @@ public ExecProcessParams EncodingUtf8(bool enable = true) return this; } + /// + /// Execute with elevated privileges. Requires the host to advertise the elevation + /// 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) + { + IsElevated = enable; + return this; + } + internal NowMsgExecProcess ToNowMessage(uint sessionId) { var builder = new NowMsgExecProcess.Builder(sessionId, filename); @@ -83,6 +94,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..d21c0eb 100644 --- a/protocols/dotnet/Devolutions.NowClient/src/ExecPwshParams.cs +++ b/protocols/dotnet/Devolutions.NowClient/src/ExecPwshParams.cs @@ -127,6 +127,17 @@ public ExecPwshParams UnicodeConsole(bool enable = true) return this; } + /// + /// Execute with elevated privileges. Requires the host to advertise the elevation + /// 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) + { + IsElevated = enable; + return this; + } + internal NowMsgExecPwsh ToNowMessage(uint sessionId) { var builder = _serverMode @@ -193,6 +204,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..931fbbc 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 the elevation + /// capability. The host may prompt the interactive user for consent. + /// + 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/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 2c01438..1e51a72 100644 --- a/protocols/dotnet/Devolutions.NowClient/src/ExecShellParams.cs +++ b/protocols/dotnet/Devolutions.NowClient/src/ExecShellParams.cs @@ -46,6 +46,17 @@ public ExecShellParams Detached(bool enable = true) return this; } + /// + /// Execute with elevated privileges. Requires the host to advertise the elevation + /// 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) + { + IsElevated = enable; + return this; + } + internal NowMsgExecShell ToNowMessage(uint sessionId) { var builder = new NowMsgExecShell.Builder(sessionId, command); @@ -70,6 +81,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..a918a71 100644 --- a/protocols/dotnet/Devolutions.NowClient/src/ExecWinPsParams.cs +++ b/protocols/dotnet/Devolutions.NowClient/src/ExecWinPsParams.cs @@ -128,6 +128,17 @@ public ExecWinPsParams UnicodeConsole(bool enable = true) return this; } + /// + /// Execute with elevated privileges. Requires the host to advertise the elevation + /// 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) + { + IsElevated = enable; + return this; + } + internal NowMsgExecWinPs ToNowMessage(uint sessionId) { var builder = _serverMode @@ -194,6 +205,11 @@ internal NowMsgExecWinPs ToNowMessage(uint sessionId) builder.EnableUnicodeConsole(); } + if (IsElevated) + { + builder.EnableElevated(); + } + return builder.Build(); } 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/NowClient.cs b/protocols/dotnet/Devolutions.NowClient/src/NowClient.cs index c62913d..fbdf7a0 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,9 @@ public async Task ExecBatch(ExecBatchParams execParams) ThrowCapabilitiesError("Batch execution style"); } + ThrowIfElevationUnsupported(execParams); + ThrowIfBatchNoExitUnsupported(execParams); + var sessionId = _nextExecSessionId++; var message = execParams.ToNowMessage(sessionId); var execSession = execParams.ToExecSession(sessionId, _commandWriter, _execution, _lifetime.Token); @@ -601,6 +610,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 +637,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 +924,43 @@ private void ThrowIfRdmUnsupported() } } + /// + /// 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. + /// + private void ThrowIfElevationUnsupported(AExecParams execParams) + { + if (!execParams.IsElevated) + { + return; + } + + 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."); @@ -946,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.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/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/MsgExecGeneral.cs b/protocols/dotnet/Devolutions.NowProto.Tests/src/MsgExecGeneral.cs index 68be812..42ba050 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() { @@ -174,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 1f70503..b1f6d33 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, 0x00, 0x40, 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, 0x00, 0x40, 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, 0x00, 0x40, 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, 0x08, 0x40, 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, 0x40, 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, 0x40, 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 0x4008 + // 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, 0x00, 0x40, 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, 0x08, 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..ee2c00c 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 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. + /// + /// NOW-PROTO: NOW_CAP_EXEC_ELEVATION + /// + Elevation = 0x0080, + + All = Run | Process | Shell | Batch | WinPs | Pwsh | IoRedirection | UnicodeConsole | Elevation, } } \ No newline at end of file diff --git a/protocols/dotnet/Devolutions.NowProto/src/Exceptions/NowProtocolErrorCode.cs b/protocols/dotnet/Devolutions.NowProto/src/Exceptions/NowProtocolErrorCode.cs index 06769e5..d36e8a4 100644 --- a/protocols/dotnet/Devolutions.NowProto/src/Exceptions/NowProtocolErrorCode.cs +++ b/protocols/dotnet/Devolutions.NowProto/src/Exceptions/NowProtocolErrorCode.cs @@ -50,5 +50,21 @@ public enum NowProtocolErrorCode : ushort /// NOW-PROTO: NOW_CODE_PROTOCOL_VERSION /// ProtocolVersion = 0x0008, + /// + /// 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, elevation is disabled on the host, 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/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecBatch.cs b/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecBatch.cs index 060d64c..b6b3b2a 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,14 @@ private enum MsgFlags : ushort /// UnicodeConsole = 0x0004, + /// + /// 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 = 0x0008, + /// /// Enable stdio (stdout, stderr, stdin) redirection. /// @@ -94,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. /// @@ -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..b91a1e9 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) ); @@ -96,6 +98,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_PROCESS_ELEVATED + /// + Elevated = 0x4000, + /// /// Detached mode: the process is started without tracking execution or sending back output. /// @@ -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..d214183 100644 --- a/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecPwsh.cs +++ b/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecPwsh.cs @@ -116,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. /// @@ -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..f0976fb 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 = 0x4000, } 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..bb966f2 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) ); @@ -87,6 +89,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_SHELL_ELEVATED + /// + Elevated = 0x4000, + /// /// Detached mode: the shell is started without tracking execution or sending back output. /// @@ -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/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 7e15d10..7aeb094 100644 --- a/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecWinPs.cs +++ b/protocols/dotnet/Devolutions.NowProto/src/Messages/NowMsgExecWinPs.cs @@ -116,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. /// @@ -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-client/src/capabilities.rs b/protocols/rust/now-client/src/capabilities.rs index e92bb50..632af62 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 where elevation is disabled or unsupported) must + /// answer `false`. + /// 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) + } + 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..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)?; } @@ -1455,4 +1460,120 @@ 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_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( + &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).with_no_io_redirection(), + ) + .await; + let _ = result_receiver.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()); + let mut execution = match handle + .process( + ProcessRequest::new("process.exe") + .with_elevated() + .with_timeout(Duration::from_secs(5)), + ) + .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:?}"), + 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..0284341 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`]. + /// 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; + 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`]. + /// 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; + 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`]. + /// 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; + 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 capabilities.supports_io_redirection() { 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 capabilities.supports_io_redirection() { 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_elevation(capabilities, request.elevated)?; validate_common(detached, request.stdin.as_deref(), request.timeout)?; - validate_tracking(capabilities, detached)?; + validate_tracking(capabilities, detached, request.elevated, request.stdin.is_some())?; } 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 $capabilities.supports_io_redirection() { $message = $message.with_io_redirection(); } }; @@ -380,8 +443,20 @@ fn validate_common_request( ) -> Result<(), NowClientError> { validate_command(&request.command)?; validate_directory(request.directory.as_deref())?; + validate_elevation(capabilities, request.elevated)?; validate_common(detached, request.stdin.as_deref(), request.timeout)?; - validate_tracking(capabilities, detached) + validate_tracking(capabilities, detached, request.elevated, request.stdin.is_some()) +} + +/// 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, stdin: Option<&[u8]>, timeout: Option) -> Result<(), NowClientError> { @@ -403,14 +478,22 @@ fn validate_common(detached: bool, stdin: Option<&[u8]>, timeout: Option 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 && !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") } } - fn validate_command(command: &str) -> Result<(), NowClientError> { if command.trim().is_empty() { return Err(NowClientError::InvalidRequest("command must not be empty".to_owned())); diff --git a/protocols/rust/now-proto-pdu/src/channel/capset.rs b/protocols/rust/now-proto-pdu/src/channel/capset.rs index 79472a8..9edac8f 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 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. + /// + /// NOW-PROTO: NOW_CAP_EXEC_ELEVATION + const ELEVATION = 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/core/status.rs b/protocols/rust/now-proto-pdu/src/core/status.rs index cb2e142..26bd07a 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,18 @@ pub enum NowProtoError { /// /// NOW-PROTO: NOW_CODE_PROTOCOL_VERSION ProtocolVersion, + /// 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, elevation is disabled on the host, or the server cannot elevate the requested + /// execution style. + /// + /// NOW-PROTO: NOW_CODE_ELEVATION_UNAVAILABLE + ElevationUnavailable, /// Other error code. Other(u32), } @@ -98,6 +112,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 +130,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 +148,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-pdu/src/exec/batch.rs b/protocols/rust/now-proto-pdu/src/exec/batch.rs index d5c8ffb..19679e7 100644 --- a/protocols/rust/now-proto-pdu/src/exec/batch.rs +++ b/protocols/rust/now-proto-pdu/src/exec/batch.rs @@ -27,10 +27,22 @@ bitflags! { /// /// NOW-PROTO: NOW_EXEC_FLAG_BATCH_UNICODE_CONSOLE const UNICODE_CONSOLE = 0x0004; + /// 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 = 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 @@ -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..ededf0b 100644 --- a/protocols/rust/now-proto-pdu/src/exec/process.rs +++ b/protocols/rust/now-proto-pdu/src/exec/process.rs @@ -29,12 +29,18 @@ 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 = 0x4000; + /// Detached mode: the process is started without tracking execution or sending back output. /// /// NOW-PROTO: NOW_EXEC_FLAG_PROCESS_DETACHED @@ -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 eb3d50b..603f2f6 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 = 0x4000; } } @@ -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 } @@ -102,23 +118,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() }; diff --git a/protocols/rust/now-proto-pdu/src/exec/shell.rs b/protocols/rust/now-proto-pdu/src/exec/shell.rs index b14622a..644b0fc 100644 --- a/protocols/rust/now-proto-pdu/src/exec/shell.rs +++ b/protocols/rust/now-proto-pdu/src/exec/shell.rs @@ -21,12 +21,18 @@ 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 = 0x4000; + /// Detached mode: the shell is started without tracking execution or sending back output. /// /// NOW-PROTO: NOW_EXEC_FLAG_SHELL_DETACHED @@ -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/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 aa975b0..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,16 +64,20 @@ bitflags! { /// /// NOW-PROTO: NOW_EXEC_FLAG_PS_UNICODE_CONSOLE const UNICODE_CONSOLE = 0x0400; - /// 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. /// @@ -296,6 +300,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..b017f81 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(); @@ -163,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] @@ -444,3 +509,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 +// uniform 0x4000 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, 00, 40, 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, 00, 40, 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, 00, 40, 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, 08, 40, 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, 40, 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, 40, 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 0x4008 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, 00, 40, 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, 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_no_exit()); + assert!(!actual.is_elevated()); +}