dotnet-memshell: three .NET memory shells at three distinct insertion positions
Three .NET memory shells at three different insertion positions - single-file payloads, armed by one deserialization, invisible to unmarked traffic.
Why these are different
Most public .NET memory shells (module, handler, route, VirtualPathProvider) live inside the HTTP request pipeline at already-known positions. dotnet-memshell spreads across three different layers instead: the pipeline event layer (with pool broadcast), the HTTP-to-WebSocket upgrade point (connection takeover - the .NET port of the WebSocket memory-shell idea), and an already-registered Remoting channel (container-style endpoint registration).
All three ride the target's existing endpoints: no new port, no web.config change, no file on disk.
The three shells
| # | Shell | Insertion position | Trigger | Minimum privilege |
|---|---|---|---|---|
| 1 | HttpModuleShell |
HttpApplication module-event pipeline |
any request with an MSH-Cmd: <cmd> header |
default app-pool identity |
| 2 | WsTakeoverShell | HTTP-to-WebSocket upgrade transition | handshake with subprotocol msh
|
app-pool identity + integrated pipeline + WS feature |
| 3 | RemotingUriShell | AppDomain-global URI table of an existing Remoting channel | remoting call to <channel>/msh
|
app-pool identity + business hosts Remoting |
HTTP.sys / IIS
+- ASP.NET request pipeline
+- module events (BeginRequest...) <- #1 HttpModuleShell
+- routing / VPP / handler / endpoint (known families)
+- upgrade point (HTTP->WS handshake) <- #2 WsTakeoverShell
in-process protocol stacks
+- existing Remoting server channel <- #3 RemotingUriShell
What makes them different
| Property | dotnet-memshell |
|---|---|
| New listening port | none - #1/#2 ride the site's 80/443, #3 rides the business channel |
web.config / registry / URLACL changes |
none (contrast: HttpListener shells need netsh urlacl, an admin action) |
| Files dropped | none - payloads are single .cs files compiled into the deserialization, assembly loaded from bytes (Location empty) |
| Effect on unmarked traffic | none - every trigger checks a magic marker first and returns immediately |
| Pool coverage |
HttpApplicationFactory._freeList broadcast - arming only the serving instance misses most requests |
| Graceful degradation | #2 on non-integrated pools catches and passes through (the handshake request still returns 200) |
Call chains
#1 HttpModuleShell
delivery deserialization -> E()
+- HttpContext.Current.ApplicationInstance (serving instance)
+- HttpApplicationFactory._theApplicationFactory._freeList (pool broadcast)
per instance:
+- integrated: GetModuleContainer(<module>)
| -> new SyncEventExecutionStep(app, OnRequest) [reflection]
| -> ModuleContainer.AddEvent(BeginRequest, false, step)
+- classic: ApplicationStepManager._execSteps append [append-only]
trigger: request -> BeginRequest -> step -> OnRequest
+- no MSH-Cmd header -> return (business continues untouched)
+- header -> cmd.exe /c -> Response.Write -> End
#2 WsTakeoverShell
same anchor; handler is a strict no-op unless handshake + msh
GET + Upgrade: websocket + Sec-WebSocket-Protocol: msh
-> IsWebSocketRequest check -> AcceptWebSocketRequest(Duplex)
-> connection upgraded, request pipeline terminates, duplex stream owned by the loop
-> Receive(cmd) -> exec -> Send(output) ... until Close frame
#3 RemotingUriShell
E()
+- AppDomain.AssemblyResolve <- bridges byte-loaded assembly (formatter resolves
| well-known types by assembly NAME)
+- guard: an IChannelReceiver already exists (business hosts remoting)
+- RegisterWellKnownServiceType(E.Svc, "msh", Singleton)
trigger: remoting client -> tcp://host:<business-port>/msh -> Svc.Exec(cmd)
Notes
- Payloads are delivered as single
.cssource files, compiled into the deserialization payload; the assembly is loaded from bytes, soAssembly.Locationis empty. - Each trigger checks a magic marker before doing anything, so unmarked business traffic is untouched.
- For .NET Framework 4.5+.
Top comments (0)