Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,20 @@ Selecting `Yes` adds MD5, SHA-1, SHA-256 and SHA-512 hashes to the entries in
device and can take a long time or cause the collector to stop on devices with
limited resources. The default `No` option only collects file metadata.

### Browser history

AndroidQF can optionally collect Chromium `History` databases from supported
browsers when the device already provides working root access through `su`.
AndroidQF does not root the device, stop browser processes, or copy the files
through shared storage. The default `No` option skips this collection.

When enabled, AndroidQF streams each database and any present `-wal` and `-shm`
sidecars directly to host-side staging before adding them under
`browser_history/` in the acquisition. A `browser_history/manifest.json` file
records the browser, package, profile, original device path, and archive path.
The currently supported packages are Chrome, Brave, Microsoft Edge, and
Samsung Internet.

### Unattended acquisitions

Every prompt can be answered ahead of time with a command-line flag. A flag that is not passed keeps prompting interactively as before.
Expand All @@ -226,11 +240,12 @@ Every prompt can be answered ahead of time with a command-line flag. A flag that
| `-remove-trusted` / `-r` | `yes`, `no` | [Removing apps signed with a trusted certificate](#removing-apps-signed-with-a-trusted-certificate), ignored with `-download none` |
| `-intrusion-logs` / `-i` | `yes`, `no` | [Intrusion Logs](#intrusion-logs) |
| `-hash-files` / `-H` | `yes`, `no` | [Hashing files on the device](#hashing-files-on-the-device) |
| `-browser-history` | `yes`, `no` | [Browser history](#browser-history) |

With `-non-interactive` (`-n`), androidqf never prompts: it fails before the acquisition starts if one of the flags above is missing, fails if multiple devices are attached and no `-serial` is given, and skips the final "Press Enter to finish". A fully unattended run looks like this:

```bash
androidqf -serial <serial> -backup none -download all -remove-trusted no -intrusion-logs no -hash-files no -non-interactive
androidqf -serial <serial> -backup none -download all -remove-trusted no -intrusion-logs no -hash-files no -browser-history no -non-interactive
```

> [!NOTE]
Expand Down
22 changes: 20 additions & 2 deletions acquisition/acquisition.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,12 +171,26 @@ func (a *Acquisition) Complete() error {
// entry. Encrypted acquisitions use encrypted temporary storage so plaintext is
// never staged on disk.
func (a *Acquisition) PullToZipStaged(remotePath, zipPath string) error {
return a.pullToZipStaged(remotePath, zipPath, false)
}

// PullRootToZipStaged validates a complete root-readable device pull before
// creating its ZIP entry.
func (a *Acquisition) PullRootToZipStaged(remotePath, zipPath string) error {
return a.pullToZipStaged(remotePath, zipPath, true)
}

func (a *Acquisition) pullToZipStaged(remotePath, zipPath string, root bool) error {
if err := a.validateStreamingMode(); err != nil {
return err
}

if a.ZipWriter.IsEncrypted() {
staged, err := a.StreamingPuller.PullToEncryptedTempFile(remotePath)
pull := a.StreamingPuller.PullToEncryptedTempFile
if root {
pull = a.StreamingPuller.PullRootToEncryptedTempFile
}
staged, err := pull(remotePath)
if err != nil {
return err
}
Expand All @@ -190,7 +204,11 @@ func (a *Acquisition) PullToZipStaged(remotePath, zipPath string) error {
return a.ZipWriter.CreateFileFromReader(zipPath, reader)
}

tempPath, err := a.StreamingPuller.PullToTempFile(remotePath)
pull := a.StreamingPuller.PullToTempFile
if root {
pull = a.StreamingPuller.PullRootToTempFile
}
tempPath, err := pull(remotePath)
if err != nil {
return err
}
Expand Down
45 changes: 44 additions & 1 deletion acquisition/streaming_buffer.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,17 @@ func (sp *StreamingPuller) PullToBuffer(remotePath string) (*StreamingBuffer, er

// PullToWriter pulls a file from device and streams it directly to a writer
func (sp *StreamingPuller) PullToWriter(remotePath string, writer io.Writer) error {
return sp.pullToWriter(remotePath, writer, false)
}

// PullRootToWriter pulls a file that is only readable as root and streams it
// directly to a writer. It requires an already-functional su binary and never
// attempts to alter the device's root state.
func (sp *StreamingPuller) PullRootToWriter(remotePath string, writer io.Writer) error {
return sp.pullToWriter(remotePath, writer, true)
}

func (sp *StreamingPuller) pullToWriter(remotePath string, writer io.Writer, root bool) error {
if remotePath == "" {
return fmt.Errorf("remote path cannot be empty")
}
Expand All @@ -174,6 +185,9 @@ func (sp *StreamingPuller) PullToWriter(remotePath string, writer io.Writer) err
}

args := []string{"exec-out", "cat", remotePath}
if root {
args = []string{"exec-out", "su", "-c", "cat -- " + shellQuote(remotePath)}
}
if sp.serial != "" {
args = append([]string{"-s", sp.serial}, args...)
}
Expand All @@ -189,16 +203,34 @@ func (sp *StreamingPuller) PullToWriter(remotePath string, writer io.Writer) err
return nil
}

func shellQuote(value string) string {
return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'"
}

// PullToTempFile pulls a file from the device into a temporary file and
// returns its path. The caller is responsible for removing the file.
func (sp *StreamingPuller) PullToTempFile(remotePath string) (string, error) {
return sp.pullToTempFile(remotePath, false)
}

// PullRootToTempFile stages a root-readable device file in a host temporary
// file. The caller is responsible for removing the returned path.
func (sp *StreamingPuller) PullRootToTempFile(remotePath string) (string, error) {
return sp.pullToTempFile(remotePath, true)
}

func (sp *StreamingPuller) pullToTempFile(remotePath string, root bool) (string, error) {
tempFile, err := os.CreateTemp("", "androidqf-pull-*")
if err != nil {
return "", fmt.Errorf("failed to create temporary file: %w", err)
}
tempPath := tempFile.Name()

if err := sp.PullToWriter(remotePath, tempFile); err != nil {
pull := sp.PullToWriter
if root {
pull = sp.PullRootToWriter
}
if err := pull(remotePath, tempFile); err != nil {
_ = tempFile.Close()
_ = os.Remove(tempPath)
return "", fmt.Errorf("failed to pull to temporary file: %w", err)
Expand All @@ -223,6 +255,17 @@ func (sp *StreamingPuller) PullToEncryptedTempFile(remotePath string) (*Encrypte
})
}

// PullRootToEncryptedTempFile stages a root-readable device file encrypted on
// the host. Plaintext is never written to host storage.
func (sp *StreamingPuller) PullRootToEncryptedTempFile(remotePath string) (*EncryptedTempFile, error) {
if remotePath == "" {
return nil, fmt.Errorf("remote path cannot be empty")
}
return createEncryptedTempFile(func(writer io.Writer) error {
return sp.PullRootToWriter(remotePath, writer)
})
}

func createEncryptedTempFile(writePlaintext func(io.Writer) error) (*EncryptedTempFile, error) {
if writePlaintext == nil {
return nil, fmt.Errorf("plaintext writer cannot be nil")
Expand Down
29 changes: 29 additions & 0 deletions acquisition/streaming_buffer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -188,3 +188,32 @@ func TestPullToZipStagedDoesNotCreateEntryForFailedPull(t *testing.T) {
}
}
}

func TestPullRootToWriterUsesSuAndQuotesPath(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell fixture is Unix-specific")
}

fakeADB := filepath.Join(t.TempDir(), "adb")
script := `#!/bin/sh
[ "$1" = "-s" ] || exit 2
[ "$2" = "serial-1" ] || exit 2
[ "$3" = "exec-out" ] || exit 2
[ "$4" = "su" ] || exit 2
[ "$5" = "-c" ] || exit 2
[ "$6" = "cat -- '/data/data/example'\"'\"'s/History'" ] || exit 2
printf 'history data'
`
if err := os.WriteFile(fakeADB, []byte(script), 0o700); err != nil {
t.Fatalf("WriteFile(fake adb) error = %v", err)
}

var output bytes.Buffer
puller := NewStreamingPuller(fakeADB, "serial-1", 1)
if err := puller.PullRootToWriter("/data/data/example's/History", &output); err != nil {
t.Fatalf("PullRootToWriter() error = %v", err)
}
if got := output.String(); got != "history data" {
t.Fatalf("output = %q, want history data", got)
}
}
32 changes: 32 additions & 0 deletions adb/adb.go
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,38 @@ func (a *ADB) Shell(cmd ...string) (string, error) {
return strings.TrimSpace(string(out)), nil
}

// RootShell executes a command through an already-functional su binary. It
// does not attempt to enable or install root access.
func (a *ADB) RootShell(command string) (string, error) {
if strings.TrimSpace(command) == "" {
return "", fmt.Errorf("root shell command cannot be empty")
}
return a.Shell("su", "-c", command)
}

// HasRoot reports whether su can execute commands as UID 0.
func (a *ADB) HasRoot() bool {
out, err := a.RootShell("id -u")
return err == nil && strings.TrimSpace(out) == "0"
}

// FileExistsAsRoot checks a fixed device path without exposing it to shell
// expansion.
func (a *ADB) FileExistsAsRoot(devicePath string) (bool, error) {
if devicePath == "" {
return false, fmt.Errorf("device path cannot be empty")
}
out, err := a.RootShell("if [ -f " + shellQuote(devicePath) + " ]; then printf 1; else printf 0; fi")
if err != nil {
return false, err
}
return strings.TrimSpace(out) == "1", nil
}

func shellQuote(value string) string {
return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'"
}

// Pull downloads a file from the device to a local path.
func (a *ADB) Pull(remotePath, localPath string) (string, error) {
out, err := a.Exec("pull", remotePath, localPath)
Expand Down
11 changes: 9 additions & 2 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ func errorOnDeviceSelection([]deviceMenuItem) (string, error) {
return "", fmt.Errorf("multiple devices detected, use -serial to select one")
}

func buildOptions(fast, nonInteractive bool, backup, download, removeTrusted, intrusionLogs, hashFiles, moduleFilter string) (*modules.Options, error) {
func buildOptions(fast, nonInteractive bool, backup, download, removeTrusted, intrusionLogs, hashFiles, browserHistory, moduleFilter string) (*modules.Options, error) {
opts := &modules.Options{Fast: fast, NonInteractive: nonInteractive}
var err error
if backup != "" {
Expand Down Expand Up @@ -150,6 +150,11 @@ func buildOptions(fast, nonInteractive bool, backup, download, removeTrusted, in
return nil, err
}
}
if browserHistory != "" {
if opts.BrowserHistory, err = modules.ParseBrowserHistoryOption(browserHistory); err != nil {
return nil, err
}
}
if err = modules.ValidateNonInteractive(opts, moduleFilter); err != nil {
return nil, err
}
Expand All @@ -171,6 +176,7 @@ func main() {
var removeTrustedFlag string
var intrusionLogsFlag string
var hashFilesFlag string
var browserHistoryFlag string
var nonInteractive bool

// Command line options
Expand Down Expand Up @@ -198,6 +204,7 @@ func main() {
flag.StringVar(&intrusionLogsFlag, "i", "", "Answer the Intrusion Logs prompt: yes or no (yes still requires taps on the device to download new logs)")
flag.StringVar(&hashFilesFlag, "hash-files", "", "Answer the on-device file hashing prompt: yes or no (resource-intensive)")
flag.StringVar(&hashFilesFlag, "H", "", "Answer the on-device file hashing prompt: yes or no (resource-intensive)")
flag.StringVar(&browserHistoryFlag, "browser-history", "", "Collect supported browser History databases when existing root access is available: yes or no")
flag.BoolVar(&nonInteractive, "non-interactive", false, "Never prompt: fail if a prompt would be reached without its flag and skip the final 'Press Enter'")
flag.BoolVar(&nonInteractive, "n", false, "Never prompt: fail if a prompt would be reached without its flag and skip the final 'Press Enter'")
flag.BoolVar(&version_flag, "version", false, "Show version")
Expand All @@ -221,7 +228,7 @@ func main() {
os.Exit(0)
}

opts, err := buildOptions(fast, nonInteractive, backupFlag, downloadFlag, removeTrustedFlag, intrusionLogsFlag, hashFilesFlag, module)
opts, err := buildOptions(fast, nonInteractive, backupFlag, downloadFlag, removeTrustedFlag, intrusionLogsFlag, hashFilesFlag, browserHistoryFlag, module)
if err != nil {
log.Fatal(err)
}
Expand Down
30 changes: 16 additions & 14 deletions main_options_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import (
)

func TestBuildOptionsNoFlagsKeepsInteractiveDefaults(t *testing.T) {
opts, err := buildOptions(false, false, "", "", "", "", "", "")
opts, err := buildOptions(false, false, "", "", "", "", "", "", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
Expand All @@ -19,19 +19,20 @@ func TestBuildOptionsNoFlagsKeepsInteractiveDefaults(t *testing.T) {

func TestBuildOptionsInvalidValueFails(t *testing.T) {
tests := []struct {
name string
backup, download, removeTrusted, intrusionLogs, hashFiles string
wantErr string
name string
backup, download, removeTrusted, intrusionLogs, hashFiles, browserHistory string
wantErr string
}{
{"backup", "maybe", "", "", "", "", "invalid -backup value"},
{"download", "", "some", "", "", "", "invalid -download value"},
{"remove-trusted", "", "", "nope", "", "", "invalid -remove-trusted value"},
{"intrusion-logs", "", "", "", "never", "", "invalid -intrusion-logs value"},
{"hash-files", "", "", "", "", "maybe", "invalid -hash-files value"},
{"backup", "maybe", "", "", "", "", "", "invalid -backup value"},
{"download", "", "some", "", "", "", "", "invalid -download value"},
{"remove-trusted", "", "", "nope", "", "", "", "invalid -remove-trusted value"},
{"intrusion-logs", "", "", "", "never", "", "", "invalid -intrusion-logs value"},
{"hash-files", "", "", "", "", "maybe", "", "invalid -hash-files value"},
{"browser-history", "", "", "", "", "", "maybe", "invalid -browser-history value"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := buildOptions(false, false, tt.backup, tt.download, tt.removeTrusted, tt.intrusionLogs, tt.hashFiles, "")
_, err := buildOptions(false, false, tt.backup, tt.download, tt.removeTrusted, tt.intrusionLogs, tt.hashFiles, tt.browserHistory, "")
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("err = %v, want containing %q", err, tt.wantErr)
}
Expand All @@ -40,28 +41,28 @@ func TestBuildOptionsInvalidValueFails(t *testing.T) {
}

func TestBuildOptionsNonInteractiveUnknownModuleFails(t *testing.T) {
_, err := buildOptions(false, true, "", "", "", "", "", "typo")
_, err := buildOptions(false, true, "", "", "", "", "", "", "typo")
if err == nil || !strings.Contains(err.Error(), "unknown -module value") {
t.Fatalf("err = %v, want unknown -module error", err)
}
}

func TestBuildOptionsNonInteractiveMissingFlagsFails(t *testing.T) {
_, err := buildOptions(false, true, "", "", "", "", "", "")
_, err := buildOptions(false, true, "", "", "", "", "", "", "")
if err == nil || !strings.Contains(err.Error(), "-non-interactive requires") {
t.Fatalf("err = %v, want missing flags error", err)
}
}

func TestBuildOptionsNonInteractiveModuleFilter(t *testing.T) {
_, err := buildOptions(false, true, "none", "", "", "", "", "backup")
_, err := buildOptions(false, true, "none", "", "", "", "", "", "backup")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}

func TestBuildOptionsFullInvocation(t *testing.T) {
opts, err := buildOptions(true, true, "sms", "all", "no", "no", "yes", "")
opts, err := buildOptions(true, true, "sms", "all", "no", "no", "yes", "yes", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
Expand All @@ -78,6 +79,7 @@ func TestBuildOptionsFullInvocation(t *testing.T) {
{opts.RemoveTrusted, modules.ParseRemoveTrustedOption, "no"},
{opts.IntrusionLogs, modules.ParseIntrusionLogsOption, "no"},
{opts.HashFiles, modules.ParseHashFilesOption, "yes"},
{opts.BrowserHistory, modules.ParseBrowserHistoryOption, "yes"},
} {
want, err := check.parse(check.token)
if err != nil {
Expand Down
Loading
Loading