Hello! This is the second part of the three-part blog series explaining how to customize Sliver C2 by adding your own commands.
In this part I’m going to provide an overview of the communication model in Sliver C2, by examining the flow of function calls performed by the components when an operator launches a command.
In part 3, I’ll provide a tutorial showing how to create the first simple command from scratch in Sliver C2.
Communication model overview
As explained here, Sliver works in the following way:
Client-server communication
The services.proto file, by importing client.proto and sliver.proto, defines The gRPC messages and methods exchanged between client and server.
The client stores all the commands in client/command/sliver.go. All commands stored in the file will call functions defined under client/command/<command folder>/<command_name>.go. For example, sliver.go defines the command execute-assembly, that when typed in by the operator, invokes function ExecuteAssemblyCmd(), defined in client/command/exec/execute-assembly.go.
All these functions will end up calling gRPC functions defined in services.proto. For example, ExecuteAssemblyCmd() calls con.Rpc.ExecuteAssembly() defined in services.proto.
Actually, services_grpc.pb.go defines con.Rpc.ExecuteAssembly(). Protoc automatically builds the file services_grpc.pb.go starting from the definitions inside services.proto.
As I’ll outline later, we will only have to modify services.proto, sliver.proto, and client.proto and then rebuild services_grpc.pb.go by just running make pb , in order to add our custom commands.
These gRPC functions send protobuf messages to the server through gRPC, where server/rpc/rpc-*.go defines the corresponding server-side handlers for these gRPC functions. For example, server/rpc/rpc-tasks.go defines ExecuteAssembly(), the server-side function that handles the con.Rpc.ExecuteAssembly() request performed by the client.
These server functions handling the gRPC requests will perform the required processing and then send the response with the statement:
return resp, nil
For the ExecuteAssembly() handler function, this sends a protobuf message to the implant and then returns the response, as we will see in the following section.
Server-implant communication
The sliver.proto file defines the messages that are exchanged between server and implant. Server and implant exchange these messages not through gRPC but through HTTP/S, DNS depending on how the implant was generated, as previously mentioned.
Let’s inspect the function ExecuteAssembly() in order to explain what the server is going to do in this case:
// ExecuteAssembly - Execute a .NET assembly on the remote system in-memory (Windows only)
func (rpc *Server) ExecuteAssembly(ctx context.Context, req *sliverpb.ExecuteAssemblyReq) (*sliverpb.ExecuteAssembly, error) {
var session *core.Session
var beacon *models.Beacon
var err error
if !req.Request.Async {
session = core.Sessions.Get(req.Request.SessionID)
if session == nil {
return nil, ErrInvalidSessionID
}
} else {
beacon, err = db.BeaconByID(req.Request.BeaconID)
if err != nil {
tasksLog.Errorf("%s", err)
return nil, ErrDatabaseFailure
}
if beacon == nil {
return nil, ErrInvalidBeaconID
}
}
shellcode, err := generate.DonutFromAssembly(
req.Assembly,
req.IsDLL,
req.Arch,
req.Arguments,
req.Method,
req.ClassName,
req.AppDomain,
)
if err != nil {
tasksLog.Errorf("Execute assembly failed: %s", err)
return nil, err
}
resp := &sliverpb.ExecuteAssembly{Response: &commonpb.Response{}}
if req.InProcess {
tasksLog.Infof("Executing assembly in-process")
invokeInProcExecAssembly := &sliverpb.InvokeInProcExecuteAssemblyReq{
Data: req.Assembly,
Runtime: req.Runtime,
Arguments: strings.Split(req.Arguments, " "),
AmsiBypass: req.AmsiBypass,
EtwBypass: req.EtwBypass,
Request: req.Request,
}
err = rpc.GenericHandler(invokeInProcExecAssembly, resp)
} else {
invokeExecAssembly := &sliverpb.InvokeExecuteAssemblyReq{
Data: shellcode,
Process: req.Process,
Request: req.Request,
PPid: req.PPid,
ProcessArgs: req.ProcessArgs,
}
err = rpc.GenericHandler(invokeExecAssembly, resp)
}
if err != nil {
return nil, err
}
return resp, nil
}
The req parameter passed as input corresponds to the protobuf message ExecuteAssemblyReq, defined in sliver.proto, in the following way (commonpb.Request is defined in common.proto but I added it here for clarity):
common.proto ... ... // Request - Common fields used in all gRPC requests message Request { bool Async = 1; int64 Timeout = 2; string BeaconID = 8; string SessionID = 9; } ... ... --------------------------------------------------------------------- sliver.proto ... ... message ExecuteAssemblyReq { bytes Assembly = 1; string Arguments = 2; string Process = 3; bool IsDLL = 4; string Arch = 5; string ClassName = 6; string Method = 7; string AppDomain = 8; uint32 PPid = 10; repeated string ProcessArgs = 11; // In process specific fields bool InProcess = 12; string Runtime = 13; bool AmsiBypass = 14; bool EtwBypass = 15; commonpb.Request Request = 9; } ... ...
So what happens is that the handler performs the following actions:
- retrieve the implant ID through req.Request.SessionID/req.Request.BeaconID and check if it is valid.
- create the go struct sliverpb.ExecuteAssembly that corresponds to the ExecuteAssembly protobuf message defined in sliver.proto. This struct will contain the response from the implant.
- create the go struct sliverpb.InvokeInProcExecuteAssemblyReq that corresponds to the InvokeInProcExecuteAssemblyReq protobuf message defined in sliver.proto (we suppose the we executed execute-assembly in process, with the -i flag, otherwise the server will create ExecuteAssemblyReq). The implant will receive this struct as a request to process.
- call rpc.GenericHandler(), passing as input the request and the response created at steps 2 and 3.
- return resp, the response struct that the rpc.GenericHandler function populates with the response of the implant.
Here’s the body of function rpc.GenericHandler(), defined inside server/rpc/rpc.go:
// GenericHandler - Pass the request to the Sliver/Session func (rpc *Server) GenericHandler(req GenericRequest, resp GenericResponse) error { var err error request := req.GetRequest() if request == nil { return ErrMissingRequestField } if request.Async { err = rpc.asyncGenericHandler(req, resp) return err } // Sync request session := core.Sessions.Get(request.SessionID) if session == nil { return ErrInvalidSessionID } // Overwrite unused implant fields before re-serializing request.SessionID = "" request.BeaconID = "" reqData, err := proto.Marshal(req) if err != nil { return err } data, err := session.Request(sliverpb.MsgNumber(req), rpc.getTimeout(req), reqData) if err != nil { return err } err = proto.Unmarshal(data, resp) if err != nil { return err } return rpc.getError(resp) }
You may notice that the function retrieves the beacon/session implant, serializes the request, sends the serialized request to the implant, and finally returns the response from the implant inside the resp input parameter.
On the implant-side, the functions in charge of handling the requests coming from the server are defined inside handlers_<OS>.go and handlers.go. The file handlers.go contains handlers for tasks that can be executed on any OS, while handlers_<OS>.go contains handlers for tasks that can be executed only on a specific OS. For example, handlers_windows.go contains impersonateHandler() that implements the impersonate command that would be useful only with a Windows OS, while handlers.go contains dirListHandler() that implements the ls command that is useful with any OS.
Let’s inspect the inProcExecuteAssemblyHandler() defined in implant/sliver/handlers_windows.go:
func inProcExecuteAssemblyHandler(data []byte, resp RPCResponse) { execReq := &sliverpb.InvokeInProcExecuteAssemblyReq{} err := proto.Unmarshal(data, execReq) if err != nil { // {{if .Config.Debug}} log.Printf("error decoding message: %v", err) // {{end}} return } output, err := taskrunner.InProcExecuteAssembly(execReq.Data, execReq.Arguments, execReq.Runtime, execReq.AmsiBypass, execReq.EtwBypass) execAsm := &sliverpb.ExecuteAssembly{Output: []byte(output)} if err != nil { execAsm.Response = &commonpb.Response{ Err: err.Error(), } } data, err = proto.Marshal(execAsm) resp(data, err) }
It takes as input data, the serialized request, as an array of bytes, and resp corresponding to a callback.
The function unserializes data in the struct InvokeInProcExecuteAssemblyReq (this corresponds to the request sent by the server). Now the implant processes the request (the variable execReq) and calls taskrunner.InProcExecuteAssembly(), passing as input the parameters contained in execReq.
At this point, the implant performs the following operations:
- save the output inside a struct of type sliverpb.ExecuteAssembly, again defined in sliver.proto.
- serialize the struct.
- call the callback resp, passing as input the serialized struct.
The callback resp will end up returning the serialized struct to the server, as a response to the initial request, through the communication channel in use (that again could be HTTP/S, DNS, WireGuard, etc.).
At this point, on the server-side, the rpc.GenericHandler() function receives the response, and returns it to ExecuteAssembly(), which finally sends it back to the client through gRPC.
In the end, the client receives and processes the response, and prints data on screen for the operator.
By now you should have a basic understanding of the internals of Sliver. Be ready for the third and final part of this series!