diff --git a/README.md b/README.md index 3ac53a0..93954ce 100644 --- a/README.md +++ b/README.md @@ -50,8 +50,9 @@ These commands will generate binaries in a *build/* folder. ### Building for distribution packages without bundled assets -Distribution packages can opt out of embedding the bundled ADB and collector -binaries by building with the `unbundle` build tag: +Default builds always use the bundled ADB binary. Distribution packages can opt +out of embedding the bundled ADB and collector binaries by building with the +`unbundle` build tag: ```bash go build -tags unbundle -o build/ @@ -59,11 +60,18 @@ go build -tags unbundle -o build/ When this tag is enabled, androidqf expects: -- `adb` to be available from the system `PATH`. +- `adb` to be available from the system `PATH` with Android SDK Platform-Tools + `36.0.2` or newer. - collector binaries to be installed under `/usr/lib/androidqf/android-collector/` using the names expected by androidqf, such as `collector_arm` and `collector_arm64`. +Packagers can run the same ADB version check with: + +```bash +go test -tags unbundle ./adb +``` + Packagers may remove the bundled binary assets from `assets/` before building, but the `assets/` package directory and its Go source files must remain present. The `unbundle` build still imports the `assets` package, and the build will fail diff --git a/acquisition/acquisition.go b/acquisition/acquisition.go index 08cbda9..912e07c 100644 --- a/acquisition/acquisition.go +++ b/acquisition/acquisition.go @@ -19,7 +19,6 @@ import ( "github.com/botherder/go-savetime/hashes" "github.com/google/uuid" "github.com/mvt-project/androidqf/adb" - "github.com/mvt-project/androidqf/assets" "github.com/mvt-project/androidqf/log" "github.com/mvt-project/androidqf/utils" ) @@ -29,6 +28,7 @@ type Acquisition struct { UUID string `json:"uuid"` AndroidQFVersion string `json:"androidqf_version"` StoragePath string `json:"storage_path"` + BaseDir string `json:"base_dir"` Started time.Time `json:"started"` Completed time.Time `json:"completed"` Collector *adb.Collector `json:"collector"` @@ -55,6 +55,7 @@ func New(path string) (*Acquisition, error) { } else { acq.StoragePath = path } + acq.BaseDir = filepath.Dir(acq.StoragePath) // Check if the path exist stat, err := os.Stat(acq.StoragePath) if os.IsNotExist(err) { @@ -82,7 +83,7 @@ func New(path string) (*Acquisition, error) { acq.Collector = coll // Try to initialize encrypted streaming mode - encWriter, err := NewEncryptedZipWriter(acq.UUID) + encWriter, err := NewEncryptedZipWriter(acq.UUID, acq.BaseDir) if err != nil { // No key file or encryption setup failed, use normal mode log.Debug("Encrypted streaming not available, using normal mode") @@ -179,9 +180,9 @@ func (a *Acquisition) Complete() { a.Collector.Clean() } - // Stop ADB server before trying to remove extracted assets + // Stop ADB server, then clean up any temp directory used for bundled assets. adb.Client.KillServer() - assets.CleanAssets() + adb.Client.Cleanup() } func (a *Acquisition) GetSystemInformation() error { diff --git a/acquisition/encrypted_stream.go b/acquisition/encrypted_stream.go index 15122c8..708aa59 100644 --- a/acquisition/encrypted_stream.go +++ b/acquisition/encrypted_stream.go @@ -21,7 +21,6 @@ import ( "time" "filippo.io/age" - saveRuntime "github.com/botherder/go-savetime/runtime" "github.com/mvt-project/androidqf/log" ) @@ -54,9 +53,8 @@ func (hw *hashingWriter) Write(p []byte) (int, error) { } // NewEncryptedZipWriter creates a new encrypted zip writer if key.txt exists -func NewEncryptedZipWriter(uuid string) (*EncryptedZipWriter, error) { - cwd := saveRuntime.GetExecutableDirectory() - keyFilePath := filepath.Join(cwd, "key.txt") +func NewEncryptedZipWriter(uuid, baseDir string) (*EncryptedZipWriter, error) { + keyFilePath := filepath.Join(baseDir, "key.txt") // Check if key file exists if _, err := os.Stat(keyFilePath); os.IsNotExist(err) { @@ -79,7 +77,7 @@ func NewEncryptedZipWriter(uuid string) (*EncryptedZipWriter, error) { // Create output file encFileName := fmt.Sprintf("%s.zip.age", uuid) - outputPath := filepath.Join(cwd, encFileName) + outputPath := filepath.Join(baseDir, encFileName) file, err := os.OpenFile(outputPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) if err != nil { diff --git a/acquisition/secure.go b/acquisition/secure.go index 9c4b9ff..cf75215 100644 --- a/acquisition/secure.go +++ b/acquisition/secure.go @@ -14,7 +14,6 @@ import ( "strings" "filippo.io/age" - saveRuntime "github.com/botherder/go-savetime/runtime" "github.com/mvt-project/androidqf/log" ) @@ -44,9 +43,7 @@ func (a *Acquisition) StoreSecurely() error { return nil } - cwd := saveRuntime.GetExecutableDirectory() - - keyFilePath := filepath.Join(cwd, "key.txt") + keyFilePath := filepath.Join(a.BaseDir, "key.txt") if _, err := os.Stat(keyFilePath); os.IsNotExist(err) { return nil } @@ -54,7 +51,7 @@ func (a *Acquisition) StoreSecurely() error { log.Info("You provided an age public key, storing the acquisition securely.") zipFileName := fmt.Sprintf("%s.zip", a.UUID) - zipFilePath := filepath.Join(cwd, zipFileName) + zipFilePath := filepath.Join(a.BaseDir, zipFileName) log.Info("Compressing the acquisition folder. This might take a while...") @@ -83,7 +80,7 @@ func (a *Acquisition) StoreSecurely() error { defer zipFile.Close() encFileName := fmt.Sprintf("%s.age", zipFileName) - encFilePath := filepath.Join(cwd, encFileName) + encFilePath := filepath.Join(a.BaseDir, encFileName) encFile, err := os.OpenFile(encFilePath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600) if err != nil { return fmt.Errorf("unable to create encrypted file: %v", err) diff --git a/adb/adb.go b/adb/adb.go index 253cde7..922b4f9 100644 --- a/adb/adb.go +++ b/adb/adb.go @@ -8,6 +8,7 @@ package adb import ( "errors" "fmt" + "os" "os/exec" "strings" @@ -16,8 +17,18 @@ import ( ) type ADB struct { - ExePath string - Serial string + ExePath string + Serial string + TmpAssetsDir string +} + +// Cleanup removes the temporary directory used to store extracted adb assets, +// if one was created. It is a no-op when the system adb was used instead. +func (a *ADB) Cleanup() { + if a.TmpAssetsDir != "" { + os.RemoveAll(a.TmpAssetsDir) + a.TmpAssetsDir = "" + } } var Client *ADB diff --git a/adb/adb_darwin.go b/adb/adb_darwin.go index aff7a33..cb44cda 100644 --- a/adb/adb_darwin.go +++ b/adb/adb_darwin.go @@ -1,3 +1,5 @@ +//go:build !unbundle + // androidqf - Android Quick Forensics // Copyright (c) 2021-2022 Claudio Guarnieri. // Use of this software is governed by the MVT License 1.1 that can be found at @@ -6,25 +8,32 @@ package adb import ( - "os/exec" + "fmt" + "os" "path/filepath" - saveRuntime "github.com/botherder/go-savetime/runtime" "github.com/mvt-project/androidqf/assets" ) func (a *ADB) findExe() error { - err := assets.DeployAssets() + // Extract the bundled binary into a temp directory so we + // never try to write next to the executable (which may be /usr/bin or + // another read-only system path). + tmpDir, err := os.MkdirTemp("", "androidqf-adb-*") if err != nil { - return err + return fmt.Errorf("failed to create temp dir for adb: %v", err) } - adbPath, err := exec.LookPath("adb") - if err == nil { - a.ExePath = adbPath - return nil - } else { - a.ExePath = filepath.Join(saveRuntime.GetExecutableDirectory(), "adb") + if err := assets.DeployAssetsToDir(tmpDir); err != nil { + os.RemoveAll(tmpDir) + return fmt.Errorf("failed to deploy bundled adb: %v", err) + } + + a.ExePath = filepath.Join(tmpDir, "adb") + if err := validatePlatformToolsVersion(a.ExePath); err != nil { + os.RemoveAll(tmpDir) + return err } + a.TmpAssetsDir = tmpDir return nil } diff --git a/adb/adb_linux.go b/adb/adb_linux.go index 8d2a9e7..cb44cda 100644 --- a/adb/adb_linux.go +++ b/adb/adb_linux.go @@ -1,3 +1,5 @@ +//go:build !unbundle + // androidqf - Android Quick Forensics // Copyright (c) 2021-2022 Claudio Guarnieri. // Use of this software is governed by the MVT License 1.1 that can be found at @@ -6,24 +8,32 @@ package adb import ( - "os/exec" + "fmt" + "os" "path/filepath" - saveRuntime "github.com/botherder/go-savetime/runtime" "github.com/mvt-project/androidqf/assets" ) func (a *ADB) findExe() error { - err := assets.DeployAssets() + // Extract the bundled binary into a temp directory so we + // never try to write next to the executable (which may be /usr/bin or + // another read-only system path). + tmpDir, err := os.MkdirTemp("", "androidqf-adb-*") if err != nil { - return err + return fmt.Errorf("failed to create temp dir for adb: %v", err) } - adbPath, err := exec.LookPath("adb") - if err == nil { - a.ExePath = adbPath - } else { - a.ExePath = filepath.Join(saveRuntime.GetExecutableDirectory(), "adb") + if err := assets.DeployAssetsToDir(tmpDir); err != nil { + os.RemoveAll(tmpDir) + return fmt.Errorf("failed to deploy bundled adb: %v", err) + } + + a.ExePath = filepath.Join(tmpDir, "adb") + if err := validatePlatformToolsVersion(a.ExePath); err != nil { + os.RemoveAll(tmpDir) + return err } + a.TmpAssetsDir = tmpDir return nil } diff --git a/adb/adb_unbundle.go b/adb/adb_unbundle.go new file mode 100644 index 0000000..f4735c7 --- /dev/null +++ b/adb/adb_unbundle.go @@ -0,0 +1,35 @@ +//go:build unbundle + +// androidqf - Android Quick Forensics +// Copyright (c) 2021-2022 Claudio Guarnieri. +// Use of this software is governed by the MVT License 1.1 that can be found at +// https://license.mvt.re/1.1/ + +package adb + +import ( + "fmt" + "os/exec" + "runtime" +) + +func (a *ADB) findExe() error { + path, err := exec.LookPath(systemADBName()) + if err != nil { + return fmt.Errorf("unbundle builds require a package-maintained %s on PATH: %v", systemADBName(), err) + } + + if err := validatePlatformToolsVersion(path); err != nil { + return err + } + + a.ExePath = path + return nil +} + +func systemADBName() string { + if runtime.GOOS == "windows" { + return "adb.exe" + } + return "adb" +} diff --git a/adb/adb_windows.go b/adb/adb_windows.go index 3fc861f..8a39d45 100644 --- a/adb/adb_windows.go +++ b/adb/adb_windows.go @@ -1,3 +1,5 @@ +//go:build !unbundle + // androidqf - Android Quick Forensics // Copyright (c) 2021-2022 Claudio Guarnieri. // Use of this software is governed by the MVT License 1.1 that can be found at @@ -7,8 +9,8 @@ package adb import ( "errors" + "fmt" "os" - "os/exec" "path/filepath" "github.com/mvt-project/androidqf/assets" @@ -16,28 +18,32 @@ import ( ) func (a *ADB) findExe() error { - // TODO: only deploy assets when needed - err := assets.DeployAssets() + // Extract the bundled binary (and the required DLLs) into a temp directory + // so we never try to write next to the executable (which may be a read-only + // system path). + tmpDir, err := os.MkdirTemp("", "androidqf-adb-*") if err != nil { - return err + return fmt.Errorf("failed to create temp dir for adb: %v", err) + } + + if err := assets.DeployAssetsToDir(tmpDir); err != nil { + os.RemoveAll(tmpDir) + return fmt.Errorf("failed to deploy bundled adb: %v", err) } - adbPath, err := exec.LookPath("adb.exe") - if err == nil { - a.ExePath = adbPath - } else { - // Get path of the current directory - ex, err := os.Executable() - if err != nil { - return err - } - // Need full path to bypass go 1.19 restrictions about local path - a.ExePath = filepath.Join(filepath.Dir(ex), "adb.exe") - _, err = os.Stat(a.ExePath) - if err != nil { - log.Debugf("ADB doesn't exist at %s", a.ExePath) - return errors.New("Impossible to find ADB") - } + // Need full path to bypass Go 1.19+ restrictions about relative executable paths. + exePath := filepath.Join(tmpDir, "adb.exe") + if _, err := os.Stat(exePath); err != nil { + os.RemoveAll(tmpDir) + log.Debugf("ADB doesn't exist at %s", exePath) + return errors.New("impossible to find ADB") + } + + a.ExePath = exePath + if err := validatePlatformToolsVersion(a.ExePath); err != nil { + os.RemoveAll(tmpDir) + return err } + a.TmpAssetsDir = tmpDir return nil } diff --git a/adb/collector.go b/adb/collector.go index d8f6b0f..cd4fa74 100644 --- a/adb/collector.go +++ b/adb/collector.go @@ -13,9 +13,8 @@ import ( "path/filepath" "strings" - "github.com/mvt-project/androidqf/log" - "github.com/mvt-project/androidqf/assets" + "github.com/mvt-project/androidqf/log" ) type Collector struct { @@ -112,13 +111,32 @@ func (c *Collector) Install() error { } log.Debugf("Deploying collector binary '%s' for architecture '%s'.", collectorName, c.Architecture) - collectorBinary, err := assets.ReadCollectorFile(collectorName) - if err != nil { - // Somehow the file doesn't exist - return errors.New("couldn't find the collector binary") + + // If the caller has pointed us at a directory of pre-built collector + // binaries (e.g. a distro package placing them under + // /usr/lib/androidqf/android-collector/), use those in preference to the + // embedded assets. This lets packagers ship the collectors separately + // without patching the source, while portable-binary users get the + // embedded fallback automatically. + var collectorBinary []byte + if collectorDir := os.Getenv("ANDROIDQF_COLLECTOR_DIR"); collectorDir != "" { + data, readErr := os.ReadFile(filepath.Join(collectorDir, collectorName)) + if readErr == nil { + collectorBinary = data + log.Debugf("Using collector from ANDROIDQF_COLLECTOR_DIR: %s", collectorDir) + } else { + log.Debugf("ANDROIDQF_COLLECTOR_DIR set but could not read collector: %v — falling back to embedded", readErr) + } + } + if len(collectorBinary) == 0 { + var err error + collectorBinary, err = assets.ReadCollectorFile(collectorName) + if err != nil { + return errors.New("couldn't find the collector binary") + } } - collectorTemp, _ := os.CreateTemp("", "collector_") + collectorTemp, err := os.CreateTemp("", "collector_") if err != nil { return err } diff --git a/adb/version.go b/adb/version.go new file mode 100644 index 0000000..a4c3e8b --- /dev/null +++ b/adb/version.go @@ -0,0 +1,70 @@ +// androidqf - Android Quick Forensics +// Copyright (c) 2021-2022 Claudio Guarnieri. +// Use of this software is governed by the MVT License 1.1 that can be found at +// https://license.mvt.re/1.1/ + +package adb + +import ( + "fmt" + "os/exec" + "regexp" + "strconv" + "strings" +) + +// minimumPlatformToolsVersion is the oldest Android SDK Platform-Tools release +// within the supported one-year window as of 2026-05-28. +var minimumPlatformToolsVersion = platformToolsVersion{major: 36, minor: 0, patch: 2} + +var platformToolsVersionRE = regexp.MustCompile(`(?m)^Version\s+([0-9]+)\.([0-9]+)\.([0-9]+)(?:[-\s]|$)`) + +type platformToolsVersion struct { + major int + minor int + patch int +} + +func validatePlatformToolsVersion(adbPath string) error { + out, err := exec.Command(adbPath, "--version").CombinedOutput() + if err != nil { + return fmt.Errorf("failed to check adb platform-tools version: %v: %s", err, strings.TrimSpace(string(out))) + } + + version, err := parsePlatformToolsVersion(string(out)) + if err != nil { + return err + } + if !version.isAtLeast(minimumPlatformToolsVersion) { + return fmt.Errorf("adb platform-tools %s is too old; need %s or newer", version, minimumPlatformToolsVersion) + } + + return nil +} + +func parsePlatformToolsVersion(output string) (platformToolsVersion, error) { + match := platformToolsVersionRE.FindStringSubmatch(output) + if match == nil { + return platformToolsVersion{}, fmt.Errorf("failed to parse adb platform-tools version from adb --version output") + } + + major, _ := strconv.Atoi(match[1]) + minor, _ := strconv.Atoi(match[2]) + patch, _ := strconv.Atoi(match[3]) + + return platformToolsVersion{major: major, minor: minor, patch: patch}, nil +} + +func (v platformToolsVersion) isAtLeast(minimum platformToolsVersion) bool { + if v.major != minimum.major { + return v.major > minimum.major + } + if v.minor != minimum.minor { + return v.minor > minimum.minor + } + return v.patch >= minimum.patch +} + +func (v platformToolsVersion) String() string { + return fmt.Sprintf("%d.%d.%d", v.major, v.minor, v.patch) +} diff --git a/adb/version_test.go b/adb/version_test.go new file mode 100644 index 0000000..584a83f --- /dev/null +++ b/adb/version_test.go @@ -0,0 +1,65 @@ +// androidqf - Android Quick Forensics +// Copyright (c) 2021-2022 Claudio Guarnieri. +// Use of this software is governed by the MVT License 1.1 that can be found at +// https://license.mvt.re/1.1/ + +package adb + +import "testing" + +func TestParsePlatformToolsVersion(t *testing.T) { + output := `Android Debug Bridge version 1.0.41 +Version 36.0.2-14143358 +Installed as /usr/bin/adb +Running on Linux 6.18.15-1.qubes.fc41.x86_64 (x86_64) +` + + version, err := parsePlatformToolsVersion(output) + if err != nil { + t.Fatalf("parsePlatformToolsVersion returned error: %v", err) + } + + expected := platformToolsVersion{major: 36, minor: 0, patch: 2} + if version != expected { + t.Fatalf("expected %s, got %s", expected, version) + } +} + +func TestParsePlatformToolsVersionRejectsMalformedOutput(t *testing.T) { + _, err := parsePlatformToolsVersion("Android Debug Bridge version 1.0.41") + if err == nil { + t.Fatal("expected parsePlatformToolsVersion to reject output without a platform-tools version") + } +} + +func TestPlatformToolsVersionMinimum(t *testing.T) { + tests := []struct { + name string + version platformToolsVersion + supported bool + }{ + { + name: "previous one year cutoff release", + version: platformToolsVersion{major: 36, minor: 0, patch: 0}, + supported: false, + }, + { + name: "minimum supported release", + version: platformToolsVersion{major: 36, minor: 0, patch: 2}, + supported: true, + }, + { + name: "newer release", + version: platformToolsVersion{major: 37, minor: 0, patch: 0}, + supported: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := test.version.isAtLeast(minimumPlatformToolsVersion); got != test.supported { + t.Fatalf("expected supported=%v for %s, got %v", test.supported, test.version, got) + } + }) + } +} diff --git a/adb/version_unbundle_test.go b/adb/version_unbundle_test.go new file mode 100644 index 0000000..1bc54b6 --- /dev/null +++ b/adb/version_unbundle_test.go @@ -0,0 +1,24 @@ +//go:build unbundle + +// androidqf - Android Quick Forensics +// Copyright (c) 2021-2022 Claudio Guarnieri. +// Use of this software is governed by the MVT License 1.1 that can be found at +// https://license.mvt.re/1.1/ + +package adb + +import ( + "os/exec" + "testing" +) + +func TestUnbundleSystemADBVersion(t *testing.T) { + path, err := exec.LookPath(systemADBName()) + if err != nil { + t.Fatalf("unbundle builds require a package-maintained %s on PATH: %v", systemADBName(), err) + } + + if err := validatePlatformToolsVersion(path); err != nil { + t.Fatal(err) + } +} diff --git a/assets/assets_bundled.go b/assets/assets_bundled.go index 423bb2f..fadcc52 100644 --- a/assets/assets_bundled.go +++ b/assets/assets_bundled.go @@ -12,8 +12,6 @@ import ( "errors" "os" "path/filepath" - - saveRuntime "github.com/botherder/go-savetime/runtime" ) //go:embed collector_* @@ -29,55 +27,35 @@ func ReadCollectorFile(collectorName string) ([]byte, error) { return collector.ReadFile(collectorName) } -// DeployAssets is used to retrieve the embedded adb binaries and store them. -func DeployAssets() error { - cwd := saveRuntime.GetExecutableDirectory() - +// DeployAssetsToDir extracts the embedded adb binaries into the given directory. +// If a file already exists there it is silently skipped, so calling this +// function more than once (or concurrently) is safe. +func DeployAssetsToDir(dir string) error { for _, asset := range getAssets() { - assetPath := filepath.Join(cwd, asset.Name) + assetPath := filepath.Join(dir, asset.Name) - // If the file already exists, skip it. This avoids failing when adb - // is already deployed or in use by another process. + // Already present - skip without error. if _, err := os.Stat(assetPath); err == nil { continue } else if !os.IsNotExist(err) { - // Can't determine file existence (e.g., permission error); skip deploying this asset. + // Permission or other stat error - skip this asset rather than abort. continue } - // Try to create the asset file. If creation fails (for example because - // the file was created between the Stat and OpenFile calls, or because - // the file is locked by another process), skip the asset instead of failing. - assetFile, err := os.OpenFile(assetPath, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o755) + // O_EXCL ensures we don't clobber a file created between Stat and here. + f, err := os.OpenFile(assetPath, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o755) if err != nil { - // If the file exists now, just continue; otherwise skip this asset. if errors.Is(err, os.ErrExist) { continue } - // Could be locked or another transient error — do not fail the whole deployment. + // Transient error (e.g. locked) - skip rather than abort. continue } - // Write and close immediately (avoid defer in a loop). - _, err = assetFile.Write(asset.Data) - assetFile.Close() - if err != nil { - return err - } - } - - return nil -} - -// Remove assets from the local disk -func CleanAssets() error { - cwd := saveRuntime.GetExecutableDirectory() - - for _, asset := range getAssets() { - assetPath := filepath.Join(cwd, asset.Name) - err := os.Remove(assetPath) - if err != nil { - return err + _, writeErr := f.Write(asset.Data) + f.Close() + if writeErr != nil { + return writeErr } }