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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,10 +119,18 @@ The following data can be extracted:
| Copy of all installed APKs or of only those not marked as system apps. | ✅ | `apks/*` |
| Intrusion Logging logs. Contains private data such as navigation history. | ✅ | `intrusion_logs/*` |
| Installed Magisk module metadata and state markers, when existing root access is available. | ✅ | `magisk_modules/*` |
| Files in `/system/etc/init.d`, when existing root access is available. | | `init_scripts/*` |
| A list of files on the system, optionally including on-device hashes. | :white_check_mark: | `files.json` |
| A copy of the files available in temp folders. | | `tmp/*` |
| A bug report containing system and app-specific logs, with no private data included. | | `bugreport.zip` |

The `init_scripts` module checks for existing root access using `su` before
accessing `/system/etc/init.d`. It collects regular files recursively, including
hidden files, into `init_scripts/` while preserving their relative paths. Scripts
are never executed, and symlinks within the directory are not followed. Devices
without working root access or without this directory are skipped. Use
`-module init_scripts` to collect only these files.

Every acquisition also contains `acquisition.json`, `command.log` when log output
was produced, and `hashes.csv`. The hash list records the SHA-256 digest of each
preceding plaintext archive entry and does not include itself. Failed device
Expand Down
58 changes: 58 additions & 0 deletions modules/init_scripts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// Copyright (c) 2026 Claudio Guarnieri.
// Use of this source code is governed by the MVT License 1.1
// which can be found in the LICENSE file.

package modules

import (
"errors"
"fmt"
"path"
"strings"

"github.com/mvt-project/androidqf/acquisition"
"github.com/mvt-project/androidqf/adb"
"github.com/mvt-project/androidqf/log"
)

const initScriptsRoot = "/system/etc/init.d"

type InitScripts struct{}

func NewInitScripts() *InitScripts { return &InitScripts{} }

func (i *InitScripts) Name() string { return "init_scripts" }

func (i *InitScripts) Run(acq *acquisition.Acquisition, opts *Options) error {
if adb.Client == nil || !adb.Client.HasRoot() {
log.Info("Skipping init scripts: existing root access is unavailable.")
return nil
}

log.Info("Collecting init.d scripts...")
// Missing init.d directories are normal. Do not follow symlinks or execute
// scripts; enumerate regular files, including hidden files and subdirectories.
out, err := adb.Client.RootShell("if [ -d " + initScriptsRoot + " ]; then find " + initScriptsRoot + "/ -type f -print0; fi")
var collectionErr error
if err != nil {
collectionErr = fmt.Errorf("failed to list init scripts: %w", err)
}
for _, devicePath := range strings.Split(out, "\x00") {
if devicePath == "" {
continue
}
if err := opts.ContextOrBackground().Err(); err != nil {
return fmt.Errorf("%w: %v", ErrAcquisitionInterrupted, err)
}
rel, err := relativeDeviceChild(initScriptsRoot, devicePath)
if err != nil {
collectionErr = errors.Join(collectionErr, err)
continue
}
if err := acq.PullRootToZipStaged(devicePath, path.Join("init_scripts", rel)); err != nil {
log.Warningf("Unable to collect init script %s: %v", devicePath, err)
collectionErr = errors.Join(collectionErr, fmt.Errorf("%s: %w", devicePath, err))
}
}
return partialCollectionError(collectionErr)
}
118 changes: 118 additions & 0 deletions modules/init_scripts_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package modules

import (
"archive/zip"
"errors"
"io"
"os"
"path/filepath"
"runtime"
"strings"
"testing"

"github.com/mvt-project/androidqf/acquisition"
"github.com/mvt-project/androidqf/adb"
)

func TestInitScripts(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell fixture is Unix-specific")
}
for _, tc := range []struct {
name string
root string
listing string
partial bool
want map[string]string
}{
{name: "no root", root: "printf 2000"},
{name: "su denied", root: "exit 1"},
{name: "missing directory", root: "printf 0", listing: "exit 0"},
{name: "recursive files", root: "printf 0", listing: `printf '/system/etc/init.d/00 start\0/system/etc/init.d/nested/.hidden\0'`, want: map[string]string{"init_scripts/00 start": "first", "init_scripts/nested/.hidden": "second"}},
{name: "unsafe path", root: "printf 0", listing: `printf '/system/etc/init.d/../../secret\0'`, partial: true},
{name: "listing failure", root: "printf 0", listing: "exit 1", partial: true},
{name: "failed pull continues", root: "printf 0", listing: `printf '/system/etc/init.d/broken\0/system/etc/init.d/00 start\0'`, partial: true, want: map[string]string{"init_scripts/00 start": "first"}},
} {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
fakeADB := filepath.Join(dir, "adb")
calls := filepath.Join(dir, "calls")
script := "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '" + calls + "'\ncase \"$*\" in\n" +
"*\"id -u\"*) " + tc.root + " ;;\n" +
"*\"find /system/etc/init.d/\"*) " + tc.listing + " ;;\n" +
`*"cat --"*"00 start"*) printf first ;;
*"cat --"*"nested/.hidden"*) printf second ;;
*"cat --"*"broken"*) printf incomplete; exit 1 ;;
*) exit 1 ;;
esac
`
// A no-root case must never get past the root probe.
if tc.listing == "" {
script = strings.Replace(script, "*) ;;", "*) exit 99 ;;", 1)
}
if err := os.WriteFile(fakeADB, []byte(script), 0700); err != nil {
t.Fatal(err)
}
oldClient := adb.Client
adb.Client = &adb.ADB{ExePath: fakeADB}
t.Cleanup(func() { adb.Client = oldClient })
writer, err := acquisition.NewStreamingZipWriter("init-scripts", dir)
if err != nil {
t.Fatal(err)
}
acq := &acquisition.Acquisition{ZipWriter: writer, StreamingMode: true, StreamingPuller: acquisition.NewStreamingPuller(fakeADB, "", 1)}
err = NewInitScripts().Run(acq, &Options{})
if tc.partial {
if !errors.Is(err, ErrPartialCollection) {
t.Fatalf("expected partial collection, got %v", err)
}
} else if err != nil {
t.Fatal(err)
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
archive, err := zip.OpenReader(writer.GetOutputPath())
if err != nil {
t.Fatal(err)
}
defer archive.Close()
if len(archive.File) != len(tc.want) {
t.Fatalf("archive contains %d files, want %d", len(archive.File), len(tc.want))
}
for _, file := range archive.File {
reader, err := file.Open()
if err != nil {
t.Fatal(err)
}
content, err := io.ReadAll(reader)
reader.Close()
if err != nil {
t.Fatal(err)
}
want, ok := tc.want[file.Name]
if !ok || string(content) != want {
t.Fatalf("unexpected entry %q: %q", file.Name, content)
}
}
if tc.listing == "" {
data, err := os.ReadFile(calls)
if err != nil {
t.Fatal(err)
}
if strings.Count(string(data), "\n") != 1 || !strings.Contains(string(data), "id -u") {
t.Fatalf("commands ran without root: %s", data)
}
}
})
}
}

func TestInitScriptsWithoutADB(t *testing.T) {
oldClient := adb.Client
adb.Client = nil
defer func() { adb.Client = oldClient }()
if err := NewInitScripts().Run(nil, nil); err != nil {
t.Fatal(err)
}
}
1 change: 1 addition & 0 deletions modules/modules.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ func List() []Module {
NewFiles(),
NewBrowserHistory(),
NewMagiskModules(),
NewInitScripts(),
NewSettings(),
NewSELinux(),
NewEnvironment(),
Expand Down