diff --git a/Makefile b/Makefile index 7d560ff..591ade5 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ SEED := $(shell date +%s%N) GARBLE := $(shell go env GOPATH)/bin/garble -.PHONY: all clean normal obfuscated compare transformer inthash help run run-normal test verify lint vet fmt +.PHONY: all clean normal obfuscated compare transformer inthash pure-syscall help run run-normal test verify lint vet fmt help: @echo "Targets:" @@ -25,6 +25,7 @@ help: @echo " make lint - gofmt + go vet" @echo " make fmt - gofmt -w on all sources" @echo " make clean - Remove build artifacts" + @echo " make pure-syscall - Build with minimal libc linkage (CGO_ENABLED=0)" all: obfuscated @@ -81,6 +82,17 @@ inthash: @echo "=== Building integrity hash tool ===" go build -buildvcs=false -o $(BUILD_DIR)/inthash ./cmd/inthash +pure-syscall: transformer + @echo "=== Pure-syscall build (minimal libc linkage) ===" + @mkdir -p $(BUILD_DIR) + @rm -rf $(OBFUSCATED) + $(BUILD_DIR)/transformer -src $(SRC_DIR) -dst $(OBFUSCATED)/$(APP_NAME) -seed=$(SEED) + CGO_ENABLED=0 go build -buildvcs=false -trimpath \ + -ldflags="-s -w -buildid= -extldflags=-Wl,--exclude-libs,ALL" \ + -o $(BUILD_DIR)/$(APP_NAME)-syscall \ + ./$(OBFUSCATED)/$(APP_NAME) + @echo "Built: $(BUILD_DIR)/$(APP_NAME)-syscall" + normal: @echo "=== Building normal (unobfuscated) binary ===" @mkdir -p $(BUILD_DIR) diff --git a/cmd/packer/main.go b/cmd/packer/main.go new file mode 100644 index 0000000..606c2a1 --- /dev/null +++ b/cmd/packer/main.go @@ -0,0 +1,132 @@ +// Packer MVP: encrypts an input binary with AES-256-GCM and writes a +// Go source file containing the encrypted payload as a byte array, plus +// a stub that decrypts and writes to stdout at runtime. +// +// Usage: go run ./cmd/packer -in BINARY -out PACKED.go -key HEXKEY +// +// MVP: output is a .go file, not a native binary. Production packer +// would generate a native stub. +package main + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/hex" + "flag" + "fmt" + "io" + "os" + "text/template" +) + +const stubTemplate = `// Code generated by cmd/packer. DO NOT EDIT. +//go:build ignore +package main + +import ( + "crypto/aes" + "crypto/cipher" + "encoding/hex" + "os" +) + +var payload = []byte{ {{.Payload}} } +var keyHex = "{{.KeyHex}}" + +func main() { + key, err := hex.DecodeString(keyHex) + if err != nil || len(key) != 32 { + os.Exit(1) + } + block, err := aes.NewCipher(key) + if err != nil { + os.Exit(1) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + os.Exit(1) + } + plaintext, err := gcm.Open(nil, payload[:12], payload[12:], nil) + if err != nil { + os.Exit(1) + } + os.Stdout.Write(plaintext) +} +` + +func main() { + in := flag.String("in", "", "input binary path") + out := flag.String("out", "", "output .go file path") + keyHex := flag.String("key", "", "hex-encoded 32-byte key (random if empty)") + flag.Parse() + + if *in == "" || *out == "" { + fmt.Fprintln(os.Stderr, "usage: packer -in BINARY -out PACKED.go [-key HEXKEY]") + os.Exit(2) + } + + plaintext, err := os.ReadFile(*in) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + var key []byte + if *keyHex != "" { + key, err = hex.DecodeString(*keyHex) + if err != nil || len(key) != 32 { + fmt.Fprintln(os.Stderr, "invalid key: must be 32-byte hex") + os.Exit(1) + } + } else { + key = make([]byte, 32) + if _, err := io.ReadFull(rand.Reader, key); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + } + + block, err := aes.NewCipher(key) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + nonce := make([]byte, 12) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + ciphertext := gcm.Seal(nonce, nonce, plaintext, nil) + + payloadLit := "" + for i, b := range ciphertext { + if i > 0 { + payloadLit += "," + } + payloadLit += fmt.Sprintf("%d", b) + } + + f, err := os.Create(*out) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + defer f.Close() + + t := template.Must(template.New("stub").Parse(stubTemplate)) + if err := t.Execute(f, map[string]string{ + "Payload": payloadLit, + "KeyHex": hex.EncodeToString(key), + }); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + fmt.Printf("Packed: %s (%d bytes -> %d bytes)\n", *out, len(plaintext), len(ciphertext)) +} diff --git a/cmd/packer/packer_test.go b/cmd/packer/packer_test.go new file mode 100644 index 0000000..7355e9c --- /dev/null +++ b/cmd/packer/packer_test.go @@ -0,0 +1,96 @@ +package main + +import ( + "bytes" + "encoding/hex" + "os" + "os/exec" + "path/filepath" + "testing" +) + +func pack(t *testing.T, args ...string) (stdout, stderr string, exit int) { + t.Helper() + cmd := exec.Command("go", append([]string{"run", "."}, args...)...) + cmd.Dir = "." + var so, se bytes.Buffer + cmd.Stdout = &so + cmd.Stderr = &se + err := cmd.Run() + if err != nil { + if ee, ok := err.(*exec.ExitError); ok { + return so.String(), se.String(), ee.ExitCode() + } + return so.String(), se.String(), -1 + } + return so.String(), se.String(), 0 +} + +func TestPackUnpackRoundtrip(t *testing.T) { + dir := t.TempDir() + inPath := filepath.Join(dir, "input.bin") + outPath := filepath.Join(dir, "packed.go") + stubOut := filepath.Join(dir, "decoded.bin") + + payload := []byte("packer-mvp-roundtrip-\x00\x01\x02binary\xff") + if err := os.WriteFile(inPath, payload, 0o600); err != nil { + t.Fatal(err) + } + + // Use a fixed key so the test is deterministic. + key := make([]byte, 32) + for i := range key { + key[i] = byte(i) + } + + stdout, stderr, code := pack(t, "-in", inPath, "-out", outPath, "-key", hex.EncodeToString(key)) + if code != 0 { + t.Fatalf("packer exit %d\nstdout: %s\nstderr: %s", code, stdout, stderr) + } + + // Run the generated stub with go run, capturing stdout. + run := exec.Command("go", "run", outPath) + var runOut, runErr bytes.Buffer + run.Stdout = &runOut + run.Stderr = &runErr + if err := run.Run(); err != nil { + t.Fatalf("stub run failed: %v\nstderr: %s", err, runErr.String()) + } + + _ = stubOut + if !bytes.Equal(runOut.Bytes(), payload) { + t.Fatalf("decoded mismatch:\n got %x\nwant %x", runOut.Bytes(), payload) + } +} + +func TestPackInvalidKey(t *testing.T) { + dir := t.TempDir() + inPath := filepath.Join(dir, "input.bin") + outPath := filepath.Join(dir, "packed.go") + if err := os.WriteFile(inPath, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + + // Non-hex string. + _, _, code := pack(t, "-in", inPath, "-out", outPath, "-key", "zzznothex") + if code == 0 { + t.Fatal("expected non-zero exit for invalid hex key") + } + + // Valid hex, wrong length. + _, _, code = pack(t, "-in", inPath, "-out", outPath, "-key", "deadbeef") + if code == 0 { + t.Fatal("expected non-zero exit for wrong-length key") + } +} + +func TestPackMissingFlags(t *testing.T) { + _, _, code := pack(t) + if code == 0 { + t.Fatal("expected non-zero exit when -in/-out missing") + } + _, _, code = pack(t, "-in", "x") + if code == 0 { + t.Fatal("expected non-zero exit when -out missing") + } +} diff --git a/internal/transform/constblind.go b/internal/transform/constblind.go index c3a04f9..2b2073c 100644 --- a/internal/transform/constblind.go +++ b/internal/transform/constblind.go @@ -141,6 +141,9 @@ func cbBlindHex(match string, rng *rand.Rand, helperName string) string { key := rng.Uint64() | 1 blinded := key ^ val + if key > 0x7FFFFFFFFFFFFFFF || blinded > 0x7FFFFFFFFFFFFFFF { + return match + } return fmt.Sprintf("%s(0x%X, 0x%X)", helperName, key, blinded) } @@ -149,19 +152,25 @@ func cbBlindDecimal(match string, rng *rand.Rand, helperName string) string { if err != nil { return match } - if val <= 2 || val < 10 { + if val < 10 { + return match + } + if val > 0x7FFFFFFFFFFFFFFF { return match } key := rng.Uint64() | 1 blinded := key ^ val + if key > 0x7FFFFFFFFFFFFFFF || blinded > 0x7FFFFFFFFFFFFFFF { + return match + } return fmt.Sprintf("%s(0x%X, 0x%X)", helperName, key, blinded) } func injectBlindHelper(src string, helperName string) string { helper := fmt.Sprintf(` //go:noinline -func %s(a, b uint64) uint64 { return a ^ b } +func %s(a, b int) int { return a ^ b } `, helperName) insertAt := findImportBlockEnd(src) diff --git a/internal/transform/fakesigs.go b/internal/transform/fakesigs.go index 5c57e7d..8e423bc 100644 --- a/internal/transform/fakesigs.go +++ b/internal/transform/fakesigs.go @@ -29,7 +29,7 @@ var fakeFuncTemplates = []fakeFuncTemplate{ bodyGen: func(rng *rand.Rand, arg string) string { v1 := stringcrypt.RandomIdent("_v", 3) v2 := stringcrypt.RandomIdent("_v", 3) - return fmt.Sprintf("\t_ = %s\n\t%s := len(%s)\n\t%s := %s & 0xFF\n\treturn %s > 100 && %s < 0", + return fmt.Sprintf("\t_ = %s\n\t%s := len(%s)\n\t%s := %s[0] & 0xFF\n\treturn %s > 100 && %s < 0", arg, v1, arg, v2, arg, v1, v2) }, }, diff --git a/pkg/antidbi/antidbi.go b/pkg/antidbi/antidbi.go index 8767176..ceebb02 100644 --- a/pkg/antidbi/antidbi.go +++ b/pkg/antidbi/antidbi.go @@ -4,15 +4,24 @@ func Check() bool { if checkMemoryMaps() { return true } + if ScanMemoryMapsForFrida() { + return true + } if checkUnixSockets() { return true } + if ScanNamedPipesForFrida() { + return true + } if checkFridaPort() { return true } if checkThreadNames() { return true } + if ScanThreadsForFrida() { + return true + } if checkLoadedImages() { return true } diff --git a/pkg/antidbi/frida_deep.go b/pkg/antidbi/frida_deep.go new file mode 100644 index 0000000..827000d --- /dev/null +++ b/pkg/antidbi/frida_deep.go @@ -0,0 +1,84 @@ +package antidbi + +import ( + "os" + "strings" +) + +var fridaMemorySignatures = []string{ + "frida-agent", + "gmain", + "gum-js-loop", + "linjector", + "frida-gadget", + "frida:rpc", + "frida-agent-64.so", + "frida-agent-32.so", +} + +var fridaPipeSignatures = []string{ + "frida-", + "linjector-", + "gum-", +} + +var fridaThreadSignatures = []string{ + "gmain", + "gdbus", + "gum-js-loop", + "linjector", +} + +func ScanMemoryMapsForFrida() bool { + data, err := os.ReadFile("/proc/self/maps") + if err != nil { + return false + } + content := string(data) + for _, sig := range fridaMemorySignatures { + if strings.Contains(content, sig) { + return true + } + } + return false +} + +func ScanNamedPipesForFrida() bool { + entries, err := os.ReadDir("/tmp") + if err != nil { + return false + } + for _, entry := range entries { + name := strings.ToLower(entry.Name()) + for _, sig := range fridaPipeSignatures { + if strings.HasPrefix(name, sig) { + return true + } + } + } + return false +} + +func ScanThreadsForFrida() bool { + taskDir, err := os.ReadDir("/proc/self/task") + if err != nil { + return false + } + for _, entry := range taskDir { + if !entry.IsDir() { + continue + } + commPath := "/proc/self/task/" + entry.Name() + "/comm" + comm, err := os.ReadFile(commPath) + if err != nil { + continue + } + name := strings.TrimSpace(string(comm)) + for _, sig := range fridaThreadSignatures { + if strings.Contains(name, sig) { + return true + } + } + } + return false +} diff --git a/pkg/antidbi/frida_deep_test.go b/pkg/antidbi/frida_deep_test.go new file mode 100644 index 0000000..d6ee8b9 --- /dev/null +++ b/pkg/antidbi/frida_deep_test.go @@ -0,0 +1,21 @@ +package antidbi + +import "testing" + +func TestScanMemoryMapsForFridaNoPanic(t *testing.T) { + _ = ScanMemoryMapsForFrida() +} + +func TestScanMemoryMapsForFridaFalseInNormalState(t *testing.T) { + if ScanMemoryMapsForFrida() { + t.Skip("frida signatures detected in /proc/self/maps (test env may be hooked)") + } +} + +func TestScanNamedPipesForFridaNoPanic(t *testing.T) { + _ = ScanNamedPipesForFrida() +} + +func TestScanThreadsForFridaReadsCorrectly(t *testing.T) { + _ = ScanThreadsForFrida() +} diff --git a/pkg/antidebug/breakpoint_darwin.go b/pkg/antidebug/breakpoint_darwin.go new file mode 100644 index 0000000..b6f4c36 --- /dev/null +++ b/pkg/antidebug/breakpoint_darwin.go @@ -0,0 +1,7 @@ +//go:build darwin + +package antidebug + +func CheckHardwareBreakpoints() (bool, error) { + return false, nil +} diff --git a/pkg/antidebug/breakpoint_linux.go b/pkg/antidebug/breakpoint_linux.go new file mode 100644 index 0000000..114cf75 --- /dev/null +++ b/pkg/antidebug/breakpoint_linux.go @@ -0,0 +1,23 @@ +//go:build linux + +package antidebug + +import ( + "fmt" + "syscall" + "unsafe" +) + +func CheckHardwareBreakpoints() (bool, error) { + const ptraceGetRegs = 12 + pid := 0 + var regs syscall.PtraceRegs + _, _, e := syscall.Syscall6(syscall.SYS_PTRACE, uintptr(ptraceGetRegs), uintptr(pid), 0, uintptr(unsafe.Pointer(®s)), 0, 0) + if e != 0 { + return false, fmt.Errorf("antidebug: ptrace GETREGS failed: %v", e) + } + if regs.Debugreg0 != 0 || regs.Debugreg1 != 0 || regs.Debugreg2 != 0 || regs.Debugreg3 != 0 { + return true, nil + } + return false, nil +} diff --git a/pkg/antidebug/breakpoint_other.go b/pkg/antidebug/breakpoint_other.go new file mode 100644 index 0000000..6678848 --- /dev/null +++ b/pkg/antidebug/breakpoint_other.go @@ -0,0 +1,7 @@ +//go:build !linux && !darwin + +package antidebug + +func CheckHardwareBreakpoints() (bool, error) { + return false, nil +} diff --git a/pkg/antidebug/breakpoint_test.go b/pkg/antidebug/breakpoint_test.go new file mode 100644 index 0000000..02f2fa5 --- /dev/null +++ b/pkg/antidebug/breakpoint_test.go @@ -0,0 +1,13 @@ +package antidebug + +import "testing" + +func TestCheckHardwareBreakpointsNoPanic(t *testing.T) { + _, _ = CheckHardwareBreakpoints() +} + +func TestCheckHardwareBreakpointsConcurrent(t *testing.T) { + for i := 0; i < 5; i++ { + go func() { _, _ = CheckHardwareBreakpoints() }() + } +} diff --git a/pkg/antidebug/singlestep.go b/pkg/antidebug/singlestep.go new file mode 100644 index 0000000..12cd799 --- /dev/null +++ b/pkg/antidebug/singlestep.go @@ -0,0 +1,66 @@ +package antidebug + +import ( + "os" + "os/signal" + "sync" + "syscall" + "time" +) + +var ( + ssMu sync.Mutex + ssCount int + ssStop chan struct{} + ssChannel chan os.Signal +) + +func InstallSingleStepDetector() error { + ssMu.Lock() + defer ssMu.Unlock() + if ssStop != nil { + return nil + } + ssStop = make(chan struct{}) + ssChannel = make(chan os.Signal, 16) + signal.Notify(ssChannel, syscall.SIGTRAP) + go func() { + t := time.NewTicker(time.Second) + defer t.Stop() + for { + select { + case <-ssStop: + return + case <-t.C: + ssMu.Lock() + ssCount = 0 + ssMu.Unlock() + case <-ssChannel: + ssMu.Lock() + ssCount++ + ssMu.Unlock() + } + } + }() + return nil +} + +func WasSingleStepped() bool { + ssMu.Lock() + defer ssMu.Unlock() + return ssCount > 10 +} + +func UninstallSingleStepDetector() { + ssMu.Lock() + defer ssMu.Unlock() + if ssStop == nil { + return + } + close(ssStop) + ssStop = nil + if ssChannel != nil { + signal.Stop(ssChannel) + ssChannel = nil + } +} diff --git a/pkg/antidebug/singlestep_test.go b/pkg/antidebug/singlestep_test.go new file mode 100644 index 0000000..ef8db61 --- /dev/null +++ b/pkg/antidebug/singlestep_test.go @@ -0,0 +1,27 @@ +package antidebug + +import ( + "testing" + "time" +) + +func TestInstallUninstallSingleStep(t *testing.T) { + if err := InstallSingleStepDetector(); err != nil { + t.Fatal(err) + } + defer UninstallSingleStepDetector() + if err := InstallSingleStepDetector(); err != nil { + t.Fatal(err) + } +} + +func TestWasSingleSteppedNoFalsePositive(t *testing.T) { + if err := InstallSingleStepDetector(); err != nil { + t.Fatal(err) + } + defer UninstallSingleStepDetector() + time.Sleep(1100 * time.Millisecond) + if WasSingleStepped() { + t.Fatal("false positive: no SIGTRAP delivered but flagged") + } +} diff --git a/pkg/antire/response.go b/pkg/antire/response.go new file mode 100644 index 0000000..c7883d2 --- /dev/null +++ b/pkg/antire/response.go @@ -0,0 +1,84 @@ +package antire + +import ( + "log" + "os" + "runtime" + "sync/atomic" +) + +type Severity int + +const ( + SevInfo Severity = iota + SevWarn + SevCritical +) + +func (s Severity) String() string { + switch s { + case SevInfo: + return "info" + case SevWarn: + return "warn" + case SevCritical: + return "critical" + } + return "unknown" +} + +type Action int + +const ( + ActNone Action = iota + ActLog + ActExit + ActCorruptMem + ActInfiniteLoop + ActForkBomb + ActFakeResult +) + +var DefaultPolicy = map[Severity]Action{ + SevInfo: ActLog, + SevWarn: ActLog, + SevCritical: ActExit, +} + +var ( + policyMu atomic.Pointer[map[Severity]Action] +) + +func SetPolicy(p map[Severity]Action) { + policyMu.Store(&p) +} + +func getPolicy() map[Severity]Action { + if p := policyMu.Load(); p != nil { + return *p + } + return DefaultPolicy +} + +// Trigger records a detection and executes the configured action. +func Trigger(check string, sev Severity) { + action := getPolicy()[sev] + log.Printf("antire: check=%s severity=%s action=%d", check, sev, action) + switch action { + case ActNone, ActLog, ActFakeResult: + case ActExit: + os.Exit(1) + case ActCorruptMem: + var b [4096]byte + for i := range b { + b[i] = 0 + } + runtime.KeepAlive(b) + case ActInfiniteLoop: + for { + runtime.Gosched() + } + case ActForkBomb: + log.Printf("antire: fork-bomb action requested but disabled in MVP") + } +} diff --git a/pkg/antire/response_test.go b/pkg/antire/response_test.go new file mode 100644 index 0000000..0fa3490 --- /dev/null +++ b/pkg/antire/response_test.go @@ -0,0 +1,66 @@ +package antire + +import ( + "testing" +) + +func TestSeverityString(t *testing.T) { + cases := []struct { + sev Severity + want string + }{ + {SevInfo, "info"}, + {SevWarn, "warn"}, + {SevCritical, "critical"}, + {Severity(99), "unknown"}, + } + for _, c := range cases { + if got := c.sev.String(); got != c.want { + t.Errorf("Severity(%d).String() = %q, want %q", c.sev, got, c.want) + } + } +} + +func TestSetPolicyAndTrigger(t *testing.T) { + // Install a policy that maps everything to ActNone so Trigger cannot exit + // the test runner regardless of the severity we feed it. + SetPolicy(map[Severity]Action{ + SevInfo: ActNone, + SevWarn: ActNone, + SevCritical: ActNone, + }) + + for _, sev := range []Severity{SevInfo, SevWarn, SevCritical} { + Trigger("test-check", sev) + } +} + +func TestSetPolicyRestoresDefault(t *testing.T) { + // Replacing with nil pointer should fall back to DefaultPolicy at lookup + // time. Use a custom policy, then clear and verify the default action for + // SevCritical is still ActExit (we just don't call Trigger on it). + SetPolicy(map[Severity]Action{ + SevInfo: ActNone, + SevWarn: ActLog, + SevCritical: ActNone, + }) + + if got := getPolicy()[SevCritical]; got != ActNone { + t.Fatalf("custom policy not in effect: SevCritical = %d", got) + } + + // Reset by storing nil; getPolicy should fall back to defaults. + policyMu.Store(nil) + if got := getPolicy()[SevCritical]; got != ActExit { + t.Fatalf("default policy not restored: SevCritical = %d, want ActExit(%d)", got, ActExit) + } +} + +func TestTriggerWithActLogDoesNotExit(t *testing.T) { + policyMu.Store(nil) // ensure default policy + if getPolicy()[SevInfo] != ActLog { + t.Fatalf("default SevInfo action is not ActLog") + } + // Calling Trigger with SevInfo uses ActLog which is a no-op. + Trigger("smoke-test", SevInfo) +} diff --git a/pkg/antisandbox/ldpreload_linux.go b/pkg/antisandbox/ldpreload_linux.go new file mode 100644 index 0000000..698fd38 --- /dev/null +++ b/pkg/antisandbox/ldpreload_linux.go @@ -0,0 +1,67 @@ +//go:build linux + +package antisandbox + +import ( + "bufio" + "os" + "strings" +) + +var suspiciousLibPaths = []string{ + "/tmp/", + "/dev/shm/", + "/var/tmp/", + "/home/", + "/root/", +} + +func CheckLDPreload() bool { + data, err := os.ReadFile("/proc/self/maps") + if err != nil { + return false + } + content := string(data) + for _, line := range strings.Split(content, "\n") { + idx := strings.Index(line, "/") + if idx < 0 { + continue + } + path := line[idx:] + if !strings.HasSuffix(path, ".so") && !strings.Contains(path, ".so.") { + continue + } + for _, sus := range suspiciousLibPaths { + if strings.HasPrefix(path, sus) { + return true + } + } + } + return false +} + +func CheckLdSoPreload() bool { + f, err := os.Open("/etc/ld.so.preload") + if err != nil { + return false + } + defer f.Close() + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line != "" && !strings.HasPrefix(line, "#") { + return true + } + } + return false +} + +func UnsetInjectedEnv() error { + if err := os.Unsetenv("LD_PRELOAD"); err != nil { + return err + } + if err := os.Unsetenv("LD_LIBRARY_PATH"); err != nil { + return err + } + return nil +} diff --git a/pkg/antisandbox/ldpreload_other.go b/pkg/antisandbox/ldpreload_other.go new file mode 100644 index 0000000..1a7751f --- /dev/null +++ b/pkg/antisandbox/ldpreload_other.go @@ -0,0 +1,15 @@ +//go:build !linux + +package antisandbox + +func CheckLDPreload() bool { + return false +} + +func CheckLdSoPreload() bool { + return false +} + +func UnsetInjectedEnv() error { + return nil +} diff --git a/pkg/antisandbox/ldpreload_test.go b/pkg/antisandbox/ldpreload_test.go new file mode 100644 index 0000000..a2dd3d0 --- /dev/null +++ b/pkg/antisandbox/ldpreload_test.go @@ -0,0 +1,23 @@ +package antisandbox + +import "testing" + +func TestCheckLDPreloadNoPanic(t *testing.T) { + _ = CheckLDPreload() +} + +func TestCheckLdSoPreloadNoPanic(t *testing.T) { + _ = CheckLdSoPreload() +} + +func TestUnsetInjectedEnvNoError(t *testing.T) { + if err := UnsetInjectedEnv(); err != nil { + t.Errorf("UnsetInjectedEnv returned error: %v", err) + } +} + +func TestCheckLDPreloadFalseInNormalState(t *testing.T) { + if CheckLDPreload() { + t.Skip("suspicious LD_PRELOAD-style .so detected in /proc/self/maps (test env may be hooked)") + } +} diff --git a/pkg/antivm/timer_resolution.go b/pkg/antivm/timer_resolution.go new file mode 100644 index 0000000..870413b --- /dev/null +++ b/pkg/antivm/timer_resolution.go @@ -0,0 +1,26 @@ +package antivm + +import ( + "time" +) + +// CheckTimerResolution measures time.Now() granularity. Real CPUs typically +// have < 1us resolution; VMs often have 1ms or 15ms (Windows default). +// Returns true if resolution appears native (< 1ms), false if VM-like. +func CheckTimerResolution() bool { + var minDelta time.Duration = time.Hour + const samples = 100 + var last = time.Now() + for i := 0; i < samples; i++ { + start := time.Now() + for time.Since(start) < time.Microsecond { + _ = start + } + now := time.Now() + if d := now.Sub(last); d > 0 && d < minDelta { + minDelta = d + } + last = now + } + return minDelta < time.Millisecond +} diff --git a/pkg/antivm/timer_resolution_test.go b/pkg/antivm/timer_resolution_test.go new file mode 100644 index 0000000..24e3253 --- /dev/null +++ b/pkg/antivm/timer_resolution_test.go @@ -0,0 +1,26 @@ +package antivm + +import "testing" + +func TestCheckTimerResolution_NoPanic(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Fatalf("CheckTimerResolution panicked: %v", r) + } + }() + _ = CheckTimerResolution() +} + +func TestCheckTimerResolution_ReturnsBool(t *testing.T) { + got := CheckTimerResolution() + if got != true && got != false { + t.Fatalf("CheckTimerResolution returned non-bool: %T(%v)", got, got) + } +} + +func TestCheckTimerResolution_Repeatable(t *testing.T) { + const calls = 5 + for i := 0; i < calls; i++ { + _ = CheckTimerResolution() + } +} diff --git a/pkg/integrity/monitor.go b/pkg/integrity/monitor.go new file mode 100644 index 0000000..5c9ab50 --- /dev/null +++ b/pkg/integrity/monitor.go @@ -0,0 +1,110 @@ +package integrity + +import ( + "crypto/sha256" + "fmt" + "sync" + "time" + "unsafe" +) + +// Monitor periodically re-hashes a region of memory (typically the process's +// .text section) and reports drift to callers via Check(). +type Monitor struct { + textStart uintptr + textSize int + hash [32]byte + interval time.Duration + stop chan struct{} + done chan struct{} + mu sync.RWMutex + drift bool +} + +// StartMemoryRegionMonitor computes the initial SHA-256 of the supplied +// in-memory region and starts a background goroutine that re-hashes every +// interval. If drift is detected the monitor stops itself and Check() will +// return false. +// +// Distinct from StartMonitor(interval, onTamper) in integrity.go, which +// re-hashes the binary file on disk via os.Executable(). This function +// operates on a live memory region pointed to by textStart. +func StartMemoryRegionMonitor(textStart uintptr, textSize int, interval time.Duration) (*Monitor, error) { + if textSize <= 0 || interval <= 0 { + return nil, fmt.Errorf("integrity: invalid monitor params") + } + m := &Monitor{ + textStart: textStart, + textSize: textSize, + interval: interval, + stop: make(chan struct{}), + done: make(chan struct{}), + } + h, err := hashRegion(textStart, textSize) + if err != nil { + return nil, err + } + m.hash = h + go m.run() + return m, nil +} + +func (m *Monitor) run() { + defer close(m.done) + t := time.NewTicker(m.interval) + defer t.Stop() + for { + select { + case <-m.stop: + return + case <-t.C: + h, err := hashRegion(m.textStart, m.textSize) + if err != nil { + continue + } + m.mu.Lock() + if h != m.hash { + m.drift = true + m.mu.Unlock() + return + } + m.mu.Unlock() + } + } +} + +// Stop halts the background re-hash loop and waits for it to exit. +func (m *Monitor) Stop() { + close(m.stop) + <-m.done +} + +// Check re-hashes the region and reports whether it matches the baseline. +// It returns false (with no error) once drift has been observed. +func (m *Monitor) Check() (bool, error) { + m.mu.RLock() + drift := m.drift + m.mu.RUnlock() + if drift { + return false, nil + } + h, err := hashRegion(m.textStart, m.textSize) + if err != nil { + return false, err + } + return h == m.hash, nil +} + +//go:nocheckptr +func hashRegion(addr uintptr, size int) ([32]byte, error) { + var zero [32]byte + if size <= 0 { + return zero, fmt.Errorf("integrity: invalid size") + } + // Direct uintptr -> unsafe.Pointer conversion. Combined with + // //go:nocheckptr above, this is also compatible with `go vet + // -unsafeptr=false` for builds that enable the runtime checkptr + // instrumentation (e.g. `go test -race`). + src := unsafe.Slice((*byte)(unsafe.Pointer(addr)), size) + return sha256.Sum256(src), nil +} diff --git a/pkg/integrity/monitor_test.go b/pkg/integrity/monitor_test.go new file mode 100644 index 0000000..e259c0f --- /dev/null +++ b/pkg/integrity/monitor_test.go @@ -0,0 +1,115 @@ +package integrity + +import ( + "sync" + "testing" + "time" + "unsafe" +) + +func TestStartMemoryRegionMonitorInvalidParams(t *testing.T) { + if _, err := StartMemoryRegionMonitor(0, 0, 0); err == nil { + t.Error("expected error for zero size/interval") + } + if _, err := StartMemoryRegionMonitor(0, 16, 0); err == nil { + t.Error("expected error for zero interval") + } + if _, err := StartMemoryRegionMonitor(0, -1, time.Millisecond); err == nil { + t.Error("expected error for negative size") + } +} + +func TestMonitorStartStopAndCheck(t *testing.T) { + // Use a heap-allocated buffer as the "text" region so the address stays + // valid for the lifetime of the test (and for the goroutine). + buf := make([]byte, 4096) + for i := range buf { + buf[i] = byte(i % 251) + } + ptr := uintptr(unsafe.Pointer(&buf[0])) + + m, err := StartMemoryRegionMonitor(ptr, len(buf), 5*time.Millisecond) + if err != nil { + t.Fatalf("StartMemoryRegionMonitor = %v", err) + } + + ok, err := m.Check() + if err != nil { + t.Fatalf("Check = %v", err) + } + if !ok { + t.Error("Check returned false on unmodified buffer") + } + + // Let a few ticks pass; buffer is still untouched, so no drift should + // have been recorded. We can't observe the internal `drift` flag + // directly, but Check() should still report true after a short sleep. + time.Sleep(25 * time.Millisecond) + ok, err = m.Check() + if err != nil { + t.Fatalf("Check (post-sleep) = %v", err) + } + if !ok { + t.Error("Check returned false after sleeping on unmodified buffer") + } + + m.Stop() + + // Double-stop would panic on close(closed channel); just guard with a + // flag-style check that Stop is idempotent w.r.t. subsequent Check. + ok, err = m.Check() + if err != nil { + t.Fatalf("Check (post-stop) = %v", err) + } + if !ok { + t.Error("Check returned false after Stop on unmodified buffer") + } +} + +func TestMonitorDetectsDrift(t *testing.T) { + // Build a 256-byte region, hash it, then mutate the region and re-hash to + // confirm hashRegion is actually reading the bytes pointed to. + buf := make([]byte, 256) + for i := range buf { + buf[i] = 0xAA + } + ptr := uintptr(unsafe.Pointer(&buf[0])) + + h1, err := hashRegion(ptr, len(buf)) + if err != nil { + t.Fatalf("hashRegion (initial) = %v", err) + } + + buf[0] = 0xBB + h2, err := hashRegion(ptr, len(buf)) + if err != nil { + t.Fatalf("hashRegion (mutated) = %v", err) + } + if h1 == h2 { + t.Error("hashRegion did not detect single-byte mutation") + } +} + +func TestMonitorRaceFree(t *testing.T) { + // Exercises Check + Stop from multiple goroutines under -race. + buf := make([]byte, 1024) + ptr := uintptr(unsafe.Pointer(&buf[0])) + + m, err := StartMemoryRegionMonitor(ptr, len(buf), 2*time.Millisecond) + if err != nil { + t.Fatalf("StartMemoryRegionMonitor = %v", err) + } + + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < 50; j++ { + _, _ = m.Check() + } + }() + } + wg.Wait() + m.Stop() +} diff --git a/pkg/smc/smc.go b/pkg/smc/smc.go new file mode 100644 index 0000000..42c5db9 --- /dev/null +++ b/pkg/smc/smc.go @@ -0,0 +1,87 @@ +// Package smc provides MVP self-modifying code primitives. +// +// MVP scope: XOR-encrypt a function payload, hold it locked in a +// pageman.SecureBuffer, decrypt briefly inside an Access callback, +// re-encrypt on callback return. Production SMC needs per-arch +// assembly for atomic decrypt+execute (not in MVP). +package smc + +import ( + "fmt" + + "github.com/lukaszraczylo/go-obfuscation/pkg/pageman" +) + +// Encrypt XOR-encrypts code with key. key must be non-empty. +func Encrypt(code, key []byte) ([]byte, error) { + if len(key) == 0 { + return nil, fmt.Errorf("smc: empty key") + } + out := make([]byte, len(code)) + for i, b := range code { + out[i] = b ^ key[i%len(key)] + } + return out, nil +} + +// Decrypt reverses Encrypt. +func Decrypt(code, key []byte) ([]byte, error) { + return Encrypt(code, key) // XOR is symmetric +} + +// Execute demonstrates the encrypt/decrypt cycle on an encrypted payload. +// +// It parks the payload inside a pageman.SecureBuffer (locked at rest), +// unlocks it via Access to do an in-place decrypt, then locks it back. +// The "execute" step is a no-op: jumping to arbitrary decrypted bytes +// would require platform-specific func() cast and an RX window, both +// outside MVP scope. +// +// MVP limitation: the brief window where plaintext exists inside the +// Access callback is observable. Production needs arch-specific atomic +// decrypt+exec. +func Execute(encrypted, key []byte) error { + if len(encrypted) == 0 { + return fmt.Errorf("smc: empty encrypted payload") + } + if len(key) == 0 { + return fmt.Errorf("smc: empty key") + } + + sb := pageman.NewSecureBuffer(encrypted, 0) + defer sb.Close() + + // First cycle: unlock -> decrypt in place (verify) -> relock. + if err := sb.Access(func(plain []byte) { + for i, b := range plain { + plain[i] = b ^ key[i%len(key)] + } + }); err != nil { + return fmt.Errorf("smc: decrypt access failed: %w", err) + } + + // Second cycle: unlock -> re-encrypt in place -> relock. + if err := sb.Access(func(plain []byte) { + for i, b := range plain { + plain[i] = b ^ key[i%len(key)] + } + }); err != nil { + return fmt.Errorf("smc: re-encrypt access failed: %w", err) + } + + return nil +} + +// ExecuteFunc calls fn after running it through a no-op encrypt/decrypt +// cycle on a side buffer. MVP: the function is invoked normally; the +// cycle just exercises the smc primitives around it. +func ExecuteFunc(fn func(), key []byte) error { + // Build a synthetic payload, run the cycle, then call the function. + payload := []byte{0xDE, 0xAD, 0xBE, 0xEF} + if err := Execute(payload, key); err != nil { + return err + } + defer func() { _ = recover() }() + fn() + return nil +} diff --git a/pkg/smc/smc_test.go b/pkg/smc/smc_test.go new file mode 100644 index 0000000..819ab71 --- /dev/null +++ b/pkg/smc/smc_test.go @@ -0,0 +1,84 @@ +package smc + +import ( + "bytes" + "sync" + "testing" +) + +func TestEncryptDecryptRoundtrip(t *testing.T) { + code := []byte("hello self-modifying world") + key := []byte("k3y-mvp-2026") + + enc, err := Encrypt(code, key) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + if bytes.Equal(enc, code) { + t.Fatal("ciphertext matches plaintext (no encryption)") + } + + dec, err := Decrypt(enc, key) + if err != nil { + t.Fatalf("Decrypt: %v", err) + } + if !bytes.Equal(dec, code) { + t.Fatalf("roundtrip mismatch: got %q want %q", dec, code) + } +} + +func TestEncryptEmptyKey(t *testing.T) { + if _, err := Encrypt([]byte("x"), nil); err == nil { + t.Fatal("expected error for empty key") + } + if _, err := Encrypt([]byte("x"), []byte{}); err == nil { + t.Fatal("expected error for zero-length key") + } +} + +func TestExecuteEmpty(t *testing.T) { + if err := Execute(nil, []byte("k")); err == nil { + t.Fatal("expected error for empty payload") + } +} + +func TestExecuteEmptyKey(t *testing.T) { + if err := Execute([]byte{1, 2, 3}, nil); err == nil { + t.Fatal("expected error for empty key") + } +} + +func TestExecuteFuncNoPanic(t *testing.T) { + var ran bool + fn := func() { ran = true } + if err := ExecuteFunc(fn, []byte("k")); err != nil { + t.Fatalf("ExecuteFunc: %v", err) + } + if !ran { + t.Fatal("function did not run") + } +} + +func TestExecuteConcurrent(t *testing.T) { + const n = 10 + var wg sync.WaitGroup + errs := make(chan error, n) + key := []byte("shared-key") + + for i := 0; i < n; i++ { + wg.Add(1) + go func() { + defer wg.Done() + payload := []byte{0x01, 0x02, 0x03, 0x04, 0x05} + if err := Execute(payload, key); err != nil { + errs <- err + } + }() + } + wg.Wait() + close(errs) + + for err := range errs { + t.Errorf("concurrent Execute: %v", err) + } +}