Skip to content
Merged
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
41 changes: 40 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ Before launching androidqf you need to have the target Android device connected

Once USB debugging is enabled, you can proceed launching androidqf. It will first attempt to connect to the device over the USB bridge, which should result in the Android phone to prompt you to manually authorize the host keys. Make sure to authorize them, ideally permanently so that the prompt wouldn't appear again.

Now androidqf should be executing and creating an acquisition zip archive in your current working directory, or in the directory provided with `-output`. At some point in the execution, androidqf will prompt you some choices: these prompts will pause the acquisition until you provide a selection, so pay attention.
Now androidqf should be executing and creating an acquisition zip archive in your current working directory, or in the directory provided with `-output`. At some point in the execution, androidqf will prompt you some choices: these prompts will pause the acquisition until you provide a selection, so pay attention. Every prompt can also be answered ahead of time with a command-line flag, see [Unattended acquisitions](#unattended-acquisitions).

The following data can be extracted:

Expand Down Expand Up @@ -168,6 +168,23 @@ Would you like to download copies of all apps or only non-system ones?
| Only non-system packages | Don't download any packages listed in `adb pm list packages -s` |
| Do not download any | Don't download any packages |

### Removing apps signed with a trusted certificate

Unless you chose `Do not download any`, androidqf asks whether downloaded APKs signed with a trusted certificate (for example by Google) should be omitted to limit the size of the output archive:

```
Would you like to remove copies of apps signed with a trusted certificate to limit the size of the output archive?

? Remove:
▸ Yes
No
```

| Option | Explanation |
|--------|-------------|
| Yes | Downloaded APKs with a trusted signing certificate are omitted from the output archive |
| No | All downloaded APKs are kept |

### Intrusion Logs

```
Expand All @@ -183,6 +200,28 @@ Would you like to take the Intrusion Logs of the device?
| Yes | Intrusion Logs will be retrieved from the phone. |
| No | Intrusion Logs acquisition is skipped. |

### 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.

| Flag | Values | Prompt it answers |
|------|--------|-------------------|
| `-backup` / `-b` | `sms`, `all`, `none` | [Backup](#backup) |
| `-download` / `-d` | `all`, `non-system`, `none` | [Downloading copies of apps](#downloading-copies-of-apps) |
| `-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) |

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 -non-interactive
```

> [!NOTE]
> `adb backup` requires manually authorizing the backup on the device, which androidqf cannot bypass. With `-backup sms` or `-backup all` someone still needs to confirm the backup on the phone; only `-backup none` makes the backup step fully unattended.
>
> Downloading new Intrusion Logs also requires interacting with the device: with `-intrusion-logs yes`, someone still needs to tap "Access Logs" and "Download and Decrypt" on the phone. Use `-intrusion-logs no` for a fully unattended run.

## Encryption & Potential Threats

Carrying the androidqf acquisitions on an unencrypted drive might expose yourself, and even more so those you acquired data from, to significant risk. For example, you might be stopped at a problematic border and your androidqf drive could be seized. The raw data might not only expose the purpose of your trip, but it will also likely contain very sensitive data (for example list of applications installed, or even SMS messages).
Expand Down
14 changes: 14 additions & 0 deletions device_selection_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"errors"
"strings"
"testing"
"time"

Expand Down Expand Up @@ -125,6 +126,19 @@ func TestResolveADBSerialExplicitSerialDoesNotPrompt(t *testing.T) {
}
}

func TestResolveADBSerialNonInteractiveMultipleDevicesErrors(t *testing.T) {
serial, prompted, err := resolveADBSerial("", []adb.DeviceInfo{{Serial: "device-1"}, {Serial: "device-2"}}, errorOnDeviceSelection, nil)
if err == nil || !strings.Contains(err.Error(), "-serial") {
t.Fatalf("err = %v, want error suggesting -serial", err)
}
if serial != "" {
t.Fatalf("serial = %q, want empty", serial)
}
if !prompted {
t.Fatal("prompted = false, want true")
}
}

func TestBuildDeviceMenuItemsFallsBackForUnauthorizedDevice(t *testing.T) {
items := buildDeviceMenuItems([]adb.DeviceInfo{{Serial: "device-1", State: "unauthorized"}}, nil)
if len(items) != 1 {
Expand Down
71 changes: 67 additions & 4 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,39 @@ func resolveADBSerial(serial string, devices []adb.DeviceInfo, selectDevice func
return selectedSerial, true, err
}

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, moduleFilter string) (*modules.Options, error) {
opts := &modules.Options{Fast: fast, NonInteractive: nonInteractive}
var err error
if backup != "" {
if opts.Backup, err = modules.ParseBackupOption(backup); err != nil {
return nil, err
}
}
if download != "" {
if opts.Download, err = modules.ParseDownloadOption(download); err != nil {
return nil, err
}
}
if removeTrusted != "" {
if opts.RemoveTrusted, err = modules.ParseRemoveTrustedOption(removeTrusted); err != nil {
return nil, err
}
}
if intrusionLogs != "" {
if opts.IntrusionLogs, err = modules.ParseIntrusionLogsOption(intrusionLogs); err != nil {
return nil, err
}
}
if err = modules.ValidateNonInteractive(opts, moduleFilter); err != nil {
return nil, err
}
return opts, nil
}

func main() {
var err error
var verbose bool
Expand All @@ -128,6 +161,11 @@ func main() {
var output_folder string
var serial string
var tcpAddr string
var backupFlag string
var downloadFlag string
var removeTrustedFlag string
var intrusionLogsFlag string
var nonInteractive bool

// Command line options
flag.BoolVar(&verbose, "verbose", false, "Verbose mode")
Expand All @@ -144,6 +182,16 @@ func main() {
flag.StringVar(&serial, "s", "", "Phone serial number")
flag.StringVar(&tcpAddr, "connect", "", "Connect to device over network using ip:port")
flag.StringVar(&tcpAddr, "c", "", "Connect to device over network using ip:port")
flag.StringVar(&backupFlag, "backup", "", "Answer the backup prompt: sms, all or none (sms/all still require a tap on the device to authorize)")
flag.StringVar(&backupFlag, "b", "", "Answer the backup prompt: sms, all or none (sms/all still require a tap on the device to authorize)")
flag.StringVar(&downloadFlag, "download", "", "Answer the APK download prompt: all, non-system or none")
flag.StringVar(&downloadFlag, "d", "", "Answer the APK download prompt: all, non-system or none")
flag.StringVar(&removeTrustedFlag, "remove-trusted", "", "Answer the trusted-APK removal prompt: yes or no (ignored with -download none)")
flag.StringVar(&removeTrustedFlag, "r", "", "Answer the trusted-APK removal prompt: yes or no (ignored with -download none)")
flag.StringVar(&intrusionLogsFlag, "intrusion-logs", "", "Answer the Intrusion Logs prompt: yes or no (yes still requires taps on the device to download new logs)")
flag.StringVar(&intrusionLogsFlag, "i", "", "Answer the Intrusion Logs prompt: yes or no (yes still requires taps on the device to download new logs)")
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")

flag.Parse()
Expand All @@ -165,6 +213,11 @@ func main() {
os.Exit(0)
}

opts, err := buildOptions(fast, nonInteractive, backupFlag, downloadFlag, removeTrustedFlag, intrusionLogsFlag, module)
if err != nil {
log.Fatal(err)
}

log.Debug("Starting androidqf")
adb.Client, err = adb.New()
if err != nil {
Expand All @@ -186,15 +239,23 @@ func main() {
}
specificDeviceRequested := serial != ""

selectDevice := selectADBDeviceFromMenu
if nonInteractive {
selectDevice = errorOnDeviceSelection
}

// Initialization
for {
if serial == "" {
devices, err := adb.Client.DeviceInfos()
if err != nil {
log.Error(fmt.Sprintf("Error listing ADB devices: %s", err))
} else {
serial, _, err = resolveADBSerial(serial, devices, selectADBDeviceFromMenu, activeRunningExtractionsBySerial())
serial, _, err = resolveADBSerial(serial, devices, selectDevice, activeRunningExtractionsBySerial())
if err != nil {
if nonInteractive {
log.Fatal("Error selecting ADB device: ", err)
}
log.Error(fmt.Sprintf("Error selecting ADB device: %s", err))
time.Sleep(5 * time.Second)
continue
Expand Down Expand Up @@ -245,11 +306,11 @@ func main() {

mods := modules.List()
for _, mod := range mods {
if (module != "") && (module != mod.Name()) {
if !modules.ModuleEnabled(mod.Name(), module) {
continue
}

err = mod.Run(acq, fast)
err = mod.Run(acq, opts)
if err != nil {
log.Infof("ERROR: failed to run module %s: %v", mod.Name(), err)
}
Expand All @@ -265,5 +326,7 @@ func main() {
runningReleased = true
log.Info("Acquisition completed.")

systemPause()
if !nonInteractive {
systemPause()
}
}
88 changes: 88 additions & 0 deletions main_options_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package main

import (
"strings"
"testing"

"github.com/mvt-project/androidqf/modules"
)

func TestBuildOptionsNoFlagsKeepsInteractiveDefaults(t *testing.T) {
opts, err := buildOptions(false, false, "", "", "", "", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if *opts != (modules.Options{}) {
t.Fatalf("opts = %+v, want zero value", *opts)
}
}

func TestBuildOptionsInvalidValueFails(t *testing.T) {
tests := []struct {
name string
backup, download, removeTrusted, intrusionLogs 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"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := buildOptions(false, false, tt.backup, tt.download, tt.removeTrusted, tt.intrusionLogs, "")
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("err = %v, want containing %q", err, tt.wantErr)
}
})
}
}

func TestBuildOptionsNonInteractiveUnknownModuleFails(t *testing.T) {
_, 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, "", "", "", "", "")
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")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}

func TestBuildOptionsFullInvocation(t *testing.T) {
opts, err := buildOptions(true, true, "sms", "all", "no", "no", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !opts.Fast || !opts.NonInteractive {
t.Fatalf("opts = %+v, want Fast and NonInteractive set", *opts)
}
for _, check := range []struct {
got string
parse func(string) (string, error)
token string
}{
{opts.Backup, modules.ParseBackupOption, "sms"},
{opts.Download, modules.ParseDownloadOption, "all"},
{opts.RemoveTrusted, modules.ParseRemoveTrustedOption, "no"},
{opts.IntrusionLogs, modules.ParseIntrusionLogsOption, "no"},
} {
want, err := check.parse(check.token)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if check.got != want {
t.Fatalf("got %q, want %q", check.got, want)
}
}
}
28 changes: 22 additions & 6 deletions modules/backup.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ package modules

import (
"fmt"
"strings"

"github.com/manifoldco/promptui"
"github.com/mvt-project/androidqf/acquisition"
Expand All @@ -29,13 +30,28 @@ func (b *Backup) Name() string {
return "backup"
}

func (b *Backup) Run(acq *acquisition.Acquisition, fast bool) error {
log.Info("Would you like to take a backup of the device?")
promptBackup := promptui.Select{
Label: "Backup",
Items: []string{backupOnlySMS, backupEverything, backupNothing},
func ParseBackupOption(value string) (string, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "sms":
return backupOnlySMS, nil
case "all":
return backupEverything, nil
case "none":
return backupNothing, nil
}
_, backupOption, err := promptBackup.Run()
return "", fmt.Errorf("invalid -backup value %q (valid values: sms, all, none)", value)
}

func (b *Backup) Run(acq *acquisition.Acquisition, opts *Options) error {
backupOption, err := resolveOption(opts, opts.Backup, "-backup (sms, all, none)", func() (string, error) {
log.Info("Would you like to take a backup of the device?")
promptBackup := promptui.Select{
Label: "Backup",
Items: []string{backupOnlySMS, backupEverything, backupNothing},
}
_, selection, err := promptBackup.Run()
return selection, err
})
if err != nil {
return fmt.Errorf("failed to make selection for backup option: %v", err)
}
Expand Down
2 changes: 1 addition & 1 deletion modules/bugreport.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ func (b *Bugreport) Name() string {
return "bugreport"
}

func (b *Bugreport) Run(acq *acquisition.Acquisition, fast bool) error {
func (b *Bugreport) Run(acq *acquisition.Acquisition, opts *Options) error {
log.Info(
"Generating a bugreport for the device...",
)
Expand Down
2 changes: 1 addition & 1 deletion modules/dumpsys.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ func (d *Dumpsys) Name() string {
return "dumpsys"
}

func (d *Dumpsys) Run(acq *acquisition.Acquisition, fast bool) error {
func (d *Dumpsys) Run(acq *acquisition.Acquisition, opts *Options) error {
log.Info("Collecting device diagnostic information. This might take a while...")

out, err := adb.Client.Shell("dumpsys")
Expand Down
2 changes: 1 addition & 1 deletion modules/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ func (e *Environment) Name() string {
return "environment"
}

func (e *Environment) Run(acq *acquisition.Acquisition, fast bool) error {
func (e *Environment) Run(acq *acquisition.Acquisition, opts *Options) error {
log.Info("Collecting environment...")

out, err := adb.Client.Shell("env")
Expand Down
2 changes: 1 addition & 1 deletion modules/files.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ func (f *Files) Name() string {
return "files"
}

func (f *Files) Run(acq *acquisition.Acquisition, fast bool) error {
func (f *Files) Run(acq *acquisition.Acquisition, opts *Options) error {
log.Info("Collecting list of files... This might take a while...")
var fileFounds []string
var fileDetails []adb.FileInfo
Expand Down
2 changes: 1 addition & 1 deletion modules/getprop.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ func (g *GetProp) Name() string {
return "getprop"
}

func (g *GetProp) Run(acq *acquisition.Acquisition, fast bool) error {
func (g *GetProp) Run(acq *acquisition.Acquisition, opts *Options) error {
log.Info("Collecting device properties...")

out, err := adb.Client.Shell("getprop")
Expand Down
Loading
Loading