diff --git a/.gitignore b/.gitignore index 5a64be2..5dac96d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ out/ +internal/guest/proxyd/modavd-proxyd internal/guest/proxyd/*.gz diff --git a/build.ps1 b/build.ps1 index 50fc273..f42e95a 100644 --- a/build.ps1 +++ b/build.ps1 @@ -1,7 +1,7 @@ # Build tools\modavd.exe reproducibly: the same source and Go version give the same bytes on any machine and path. # # pwsh -NoProfile -File build.ps1 # -> out\tools\modavd.exe, out\ModAVD.exe (both embed -# # internal\guest\proxyd\modavd-proxyd.gz, built first) +# # internal\guest\proxyd\modavd-proxyd, built first) # pwsh -NoProfile -File build.ps1 -Out -Go \bin\go.exe # pwsh -NoProfile -File build.ps1 -Test # go vet + go test first # @@ -41,6 +41,8 @@ try { if ($have -ne $want) { throw "Go version $have does not match go.mod toolchain $want ($Go)" } Push-Location $src try { + # A modavd-proxyd.gz from a build before the daemon went in uncompressed would be embedded too. + Remove-Item -LiteralPath (Join-Path $src 'internal\guest\proxyd\modavd-proxyd.gz') -Force -ErrorAction SilentlyContinue if ($Test) { & $Go vet ./... if ($LASTEXITCODE) { throw "go vet failed ($LASTEXITCODE)" } @@ -48,21 +50,13 @@ try { if ($LASTEXITCODE) { throw "go test failed ($LASTEXITCODE)" } } # The guest daemon of the per-app proxy (cmd/modavd-proxyd), a static linux/amd64 binary that both exes embed - # gzipped (internal/guest/proxyd, gitignored). The binary itself is built in a temp directory; gzipfile - # compresses it the same way on every machine. - $proxydTmp = Join-Path ([IO.Path]::GetTempPath()) ('modavd-proxyd-' + [guid]::NewGuid().ToString('N')) - New-Item -ItemType Directory $proxydTmp | Out-Null + # as is (internal/guest/proxyd, gitignored; not compressed, see the README there). try { $env:GOOS = 'linux' - & $Go build -trimpath -buildvcs=false '-ldflags=-s -w' -o (Join-Path $proxydTmp 'modavd-proxyd') ./cmd/modavd-proxyd + & $Go build -trimpath -buildvcs=false '-ldflags=-s -w' -o (Join-Path $src 'internal\guest\proxyd\modavd-proxyd') ./cmd/modavd-proxyd if ($LASTEXITCODE) { throw "go build modavd-proxyd failed ($LASTEXITCODE)" } - # gzipfile runs here: built for this machine - $env:GOOS = ''; $env:GOARCH = ''; $env:GOAMD64 = '' - & $Go run ./internal/tools/gzipfile (Join-Path $proxydTmp 'modavd-proxyd') (Join-Path $src 'internal\guest\proxyd\modavd-proxyd.gz') - if ($LASTEXITCODE) { throw "gzipfile failed ($LASTEXITCODE)" } } finally { - Remove-Item -LiteralPath $proxydTmp -Recurse -Force -ErrorAction SilentlyContinue - $env:GOOS = 'windows'; $env:GOARCH = 'amd64'; $env:GOAMD64 = 'v1' + $env:GOOS = 'windows' } # The package layout: \tools\modavd.exe and \ModAVD.exe. Never side by side: Windows names are # case-insensitive, so modavd.exe and ModAVD.exe in one directory are the same file. @@ -78,7 +72,7 @@ try { } finally { foreach ($k in $saved.Keys) { [Environment]::SetEnvironmentVariable($k, $saved[$k]) } } -foreach ($f in (Join-Path $src 'internal\guest\proxyd\modavd-proxyd.gz'), (Join-Path $Out 'tools\modavd.exe'), (Join-Path $Out 'ModAVD.exe')) { +foreach ($f in (Join-Path $src 'internal\guest\proxyd\modavd-proxyd'),(Join-Path $Out 'tools\modavd.exe'), (Join-Path $Out 'ModAVD.exe')) { $hash = (Get-FileHash -LiteralPath $f -Algorithm SHA256).Hash.ToLowerInvariant() Write-Host ("{0} {1} bytes sha256 {2} ({3})" -f $f, (Get-Item -LiteralPath $f).Length, $hash, $want) } diff --git a/internal/guest/guest.go b/internal/guest/guest.go index ea466f2..e872e30 100644 --- a/internal/guest/guest.go +++ b/internal/guest/guest.go @@ -5,13 +5,11 @@ package guest import ( "bytes" - "compress/gzip" "crypto/sha256" "embed" "encoding/hex" "errors" "fmt" - "io" "io/fs" "os" "path/filepath" @@ -32,17 +30,14 @@ const ProxyDaemonName = "modavd-proxyd" // ErrNoProxyDaemon: this modavd was built without build.ps1, so it carries no guest daemon. var ErrNoProxyDaemon = errors.New("这个 modavd 编的时候没带按应用走代理的客体程序(要用 build.ps1 编)") -// ProxyDaemon is cmd/modavd-proxyd for linux/amd64, as build.ps1 embedded it. +// ProxyDaemon is cmd/modavd-proxyd for linux/amd64, as build.ps1 embedded it. It is embedded as is, not compressed: +// a large compressed blob inside an exe is what antivirus models take for a dropper's payload. func ProxyDaemon() ([]byte, error) { - z, err := proxyd.ReadFile("proxyd/" + ProxyDaemonName + ".gz") + b, err := proxyd.ReadFile("proxyd/" + ProxyDaemonName) if err != nil { return nil, ErrNoProxyDaemon } - r, err := gzip.NewReader(bytes.NewReader(z)) - if err != nil { - return nil, err - } - return io.ReadAll(r) + return b, nil } // LocalProxyDaemon is LocalFile for the daemon. diff --git a/internal/guest/proxyd/README.md b/internal/guest/proxyd/README.md index f2e9eb9..8ec7a4c 100644 --- a/internal/guest/proxyd/README.md +++ b/internal/guest/proxyd/README.md @@ -1,4 +1,4 @@ -`build.ps1` puts `modavd-proxyd.gz` here (gitignored): `cmd/modavd-proxyd` built for linux/amd64 and gzipped by -`internal/tools/gzipfile`. `internal/guest` embeds this directory, so both exes carry the guest daemon of the -per-app proxy. A build without `build.ps1` (`go build`, `go test`) has none: `guest.ProxyDaemon` then returns -`ErrNoProxyDaemon`. +`build.ps1` puts `modavd-proxyd` here (gitignored): `cmd/modavd-proxyd` built for linux/amd64. `internal/guest` +embeds this directory, so both exes carry the guest daemon of the per-app proxy. It goes in uncompressed: a large +compressed blob inside an exe is what antivirus models take for a dropper's payload. A build without `build.ps1` +(`go build`, `go test`) has none: `guest.ProxyDaemon` then returns `ErrNoProxyDaemon`. diff --git a/internal/guest/proxyd_test.go b/internal/guest/proxyd_test.go index 9078e28..735aac0 100644 --- a/internal/guest/proxyd_test.go +++ b/internal/guest/proxyd_test.go @@ -3,14 +3,25 @@ package guest import ( "bytes" "errors" + "io/fs" "os" "testing" "modavd/internal/testutil" ) -// TestProxyDaemon: a build.ps1 build carries a linux ELF; any other has none, and says so. +// TestProxyDaemon: a build.ps1 build carries a linux ELF, uncompressed and alone (no .gz of an older build rides +// along); any other has none, and says so. func TestProxyDaemon(t *testing.T) { + entries, err := fs.ReadDir(proxyd, "proxyd") + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + if n := e.Name(); n != "README.md" && n != ProxyDaemonName { + t.Errorf("internal/guest/proxyd has %s: only README.md and %s are embedded", n, ProxyDaemonName) + } + } b, err := ProxyDaemon() if errors.Is(err, ErrNoProxyDaemon) { if _, err := LocalProxyDaemon(testutil.TempDir(t)); !errors.Is(err, ErrNoProxyDaemon) { diff --git a/internal/report/host_windows.go b/internal/report/host_windows.go index 2b38d76..55c3555 100644 --- a/internal/report/host_windows.go +++ b/internal/report/host_windows.go @@ -6,7 +6,6 @@ import ( "bytes" "context" _ "embed" - "encoding/base64" "encoding/binary" "encoding/json" "errors" @@ -134,19 +133,18 @@ func (h *winHost) part(ctx context.Context, letter, name string, v any) error { return nil } -// runCIM runs cim.ps1 for one part with Windows PowerShell 5.1 (hidden, no profile, stdin NUL) and parses its JSON -// line. +// runCIM runs cim.ps1 for one part with Windows PowerShell 5.1 (hidden, no profile) and parses its JSON line. +// +// The script goes in on stdin (-Command -), not as -EncodedCommand: a hidden PowerShell with a base64 command line is +// what malware does, and antivirus models weigh that string in the exe. Read from stdin, a statement runs once it is +// complete, so cim.ps1 stays ASCII (stdin is read in the console code page) and its multi-line statements are whole +// blocks; the extra newline at the end ends the last one. TestCIMScriptForStdin checks both. func runCIM(ctx context.Context, part, letter string) (map[string]json.RawMessage, error) { ps := filepath.Join(windowsDir(), "System32", "WindowsPowerShell", "v1.0", "powershell.exe") - u := utf16.Encode([]rune(cimScript)) - b := make([]byte, 2*len(u)) - for i, c := range u { - binary.LittleEndian.PutUint16(b[2*i:], c) - } ctx, cancel := context.WithTimeout(ctx, cimTimeout) defer cancel() - cmd := exec.CommandContext(ctx, ps, "-NoLogo", "-NoProfile", "-NonInteractive", "-InputFormat", "None", - "-EncodedCommand", base64.StdEncoding.EncodeToString(b)) + cmd := exec.CommandContext(ctx, ps, "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", "-") + cmd.Stdin = strings.NewReader(cimScript + "\n") cmd.Env = append(os.Environ(), "MODAVD_CIM_PARTS="+part, "MODAVD_CIM_LETTER="+letter) cmd.Dir = filepath.Dir(ps) var stdout, stderr bytes.Buffer diff --git a/internal/report/host_windows_test.go b/internal/report/host_windows_test.go index 4bc7c2c..f403b60 100644 --- a/internal/report/host_windows_test.go +++ b/internal/report/host_windows_test.go @@ -16,6 +16,31 @@ import ( "modavd/internal/win" ) +// TestCIMScriptForStdin: runCIM hands cim.ps1 to PowerShell on stdin, which reads it in the console code page, line by +// line; so the script is ASCII with LF line ends, and a real run gets to the last line (the JSON) with the part's answer. +func TestCIMScriptForStdin(t *testing.T) { + for i := 0; i < len(cimScript); i++ { + if c := cimScript[i]; c >= 0x80 || c == '\r' { + t.Fatalf("cim.ps1 has byte %#x at %d: it must be ASCII with LF line ends", c, i) + } + } + if strings.Contains(cimScript, "EncodedCommand") { + t.Fatal("cim.ps1 mentions EncodedCommand") + } + data, err := runCIM(context.Background(), "cs", "") + if err != nil { + t.Fatal(err) + } + if _, ok := data["cs"]; !ok { + if _, failed := data["csErr"]; !failed { + t.Fatalf("cim.ps1 answered %v, want cs or csErr", data) + } + } + if len(data) != 1 { + t.Fatalf("cim.ps1 answered %v, want only the part asked for", data) + } +} + // TestCIMPartsApart: every WMI class runs in a cim.ps1 process of its own, once, on first use: one that fails or // times out (here av) loses only its own rows, and a part nobody asks for never runs. func TestCIMPartsApart(t *testing.T) { diff --git a/internal/tools/gzipfile/main.go b/internal/tools/gzipfile/main.go deleted file mode 100644 index d51419b..0000000 --- a/internal/tools/gzipfile/main.go +++ /dev/null @@ -1,39 +0,0 @@ -// Command gzipfile compresses a file the same way on every machine with the same Go: no name and no time in the -// header, best compression. build.ps1 uses it for the guest daemon it embeds (internal/guest/proxyd). Neither exe -// imports it. -// -// go run ./internal/tools/gzipfile -package main - -import ( - "bytes" - "compress/gzip" - "fmt" - "os" -) - -func main() { - if len(os.Args) != 3 { - fmt.Fprintln(os.Stderr, "usage: gzipfile ") - os.Exit(2) - } - b, err := os.ReadFile(os.Args[1]) - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - var out bytes.Buffer - w, _ := gzip.NewWriterLevel(&out, gzip.BestCompression) - if _, err := w.Write(b); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - if err := w.Close(); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - if err := os.WriteFile(os.Args[2], out.Bytes(), 0o644); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -}