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());
+}