Skip to content

Commit c142aa1

Browse files
authored
Use os.Root for device file pulls (#97)
* Use os.Root for device file pulls * Use Go 1.26.3 in workflows
1 parent c17d4b2 commit c142aa1

7 files changed

Lines changed: 288 additions & 31 deletions

File tree

.github/workflows/release.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ jobs:
2121
- name: Set up Go
2222
uses: actions/setup-go@v5
2323
with:
24-
go-version: "1.23"
24+
go-version: "1.26.3"
2525
cache: true
2626

2727
- name: Install cross-compilation tools

.github/workflows/staticcheck.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ jobs:
1111
fetch-depth: 1
1212
- uses: actions/setup-go@v5
1313
with:
14-
go-version: "1.23"
14+
go-version: "1.26.3"
1515
cache: true
1616
- name: download assets
1717
run: make download

go.mod

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
module github.com/mvt-project/androidqf
22

3-
go 1.23.0
4-
5-
toolchain go1.24.5
3+
go 1.26.3
64

75
require (
86
filippo.io/age v1.2.1
@@ -21,5 +19,4 @@ require (
2119
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
2220
golang.org/x/crypto v0.40.0 // indirect
2321
golang.org/x/sys v0.34.0 // indirect
24-
2522
)

modules/intrusion_logs.go

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"fmt"
1111
"os"
1212
"os/signal"
13+
"path"
1314
"path/filepath"
1415
"strings"
1516
"time"
@@ -249,16 +250,32 @@ func (m *IL) waitForNewFiles(
249250
}
250251

251252
func (m *IL) pullAll(acq *acquisition.Acquisition, deviceFiles []string) error {
253+
streaming := acq.StreamingMode && acq.EncryptedWriter != nil
254+
var localRoot *os.Root
255+
var puller *acquisition.StreamingPuller
256+
if !streaming {
257+
var err error
258+
localRoot, err = os.OpenRoot(m.ILPath)
259+
if err != nil {
260+
return fmt.Errorf("failed to open intrusion logs output root: %v", err)
261+
}
262+
defer localRoot.Close()
263+
puller = acquisition.NewStreamingPuller(adb.Client.ExePath, adb.Client.Serial, 100)
264+
}
265+
252266
for _, file := range deviceFiles {
253267
if file == m.DirOnDevice {
254268
continue
255269
}
256270

257-
rel := strings.TrimPrefix(file, m.DirOnDevice)
258-
rel = strings.TrimPrefix(rel, "/") // optional safety if DirOnDevice lacks trailing /
271+
rel, err := relativeDeviceChild(m.DirOnDevice, file)
272+
if err != nil {
273+
log.Errorf("Skipping IL file with unsafe path %s: %v\n", file, err)
274+
continue
275+
}
259276

260-
if acq.StreamingMode && acq.EncryptedWriter != nil {
261-
zipPath := fmt.Sprintf("intrusion_logs/%s", rel)
277+
if streaming {
278+
zipPath := path.Join("intrusion_logs", rel)
262279

263280
writer, err := acq.EncryptedWriter.CreateFile(zipPath)
264281
if err != nil {
@@ -274,16 +291,8 @@ func (m *IL) pullAll(acq *acquisition.Acquisition, deviceFiles []string) error {
274291

275292
log.Debugf("Streamed IL file %s directly to encrypted archive as %s", file, zipPath)
276293
} else {
277-
destPath := filepath.Join(m.ILPath, rel)
278-
279-
if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil {
280-
log.Errorf("Failed to create folders for IL file %s: %v\n", destPath, err)
281-
continue
282-
}
283-
284-
out, err := adb.Client.Pull(file, destPath)
285-
if err != nil {
286-
log.Errorf("Failed to pull IL file %s: %s\n", file, strings.TrimSpace(out))
294+
if err := streamDeviceChildToRoot(localRoot, puller, rel, file); err != nil {
295+
log.Errorf("Failed to pull IL file %s: %v\n", file, err)
287296
continue
288297
}
289298
}

modules/paths.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// Copyright (c) 2021-2026 Claudio Guarnieri.
2+
// Use of this software is governed by the MVT License 1.1 that can be found at
3+
// https://license.mvt.re/1.1/
4+
5+
package modules
6+
7+
import (
8+
"fmt"
9+
"io"
10+
"os"
11+
"path"
12+
"path/filepath"
13+
"strings"
14+
)
15+
16+
type pullToWriter interface {
17+
PullToWriter(remotePath string, writer io.Writer) error
18+
}
19+
20+
func relativeDeviceChild(deviceRoot, devicePath string) (string, error) {
21+
if deviceRoot == "" {
22+
return "", fmt.Errorf("device root cannot be empty")
23+
}
24+
if strings.ContainsRune(devicePath, 0) {
25+
return "", fmt.Errorf("unsafe device path %q", devicePath)
26+
}
27+
28+
root := path.Clean(deviceRoot)
29+
child := path.Clean(devicePath)
30+
if child == root {
31+
return "", fmt.Errorf("device path %q is the root path %q", devicePath, deviceRoot)
32+
}
33+
34+
rootPrefix := root
35+
if !strings.HasSuffix(rootPrefix, "/") {
36+
rootPrefix += "/"
37+
}
38+
if !strings.HasPrefix(child, rootPrefix) {
39+
return "", fmt.Errorf("device path %q is outside %q", devicePath, deviceRoot)
40+
}
41+
42+
rel := strings.TrimPrefix(child, rootPrefix)
43+
localRel := filepath.FromSlash(rel)
44+
if !filepath.IsLocal(localRel) {
45+
return "", fmt.Errorf("unsafe device path %q", devicePath)
46+
}
47+
48+
return rel, nil
49+
}
50+
51+
func createRootFile(root *os.Root, rel string) (*os.File, error) {
52+
localRel := filepath.FromSlash(rel)
53+
if !filepath.IsLocal(localRel) {
54+
return nil, fmt.Errorf("unsafe local path %q", rel)
55+
}
56+
57+
if err := root.MkdirAll(filepath.Dir(localRel), 0o755); err != nil {
58+
return nil, fmt.Errorf("failed to create destination folders for %q: %v", rel, err)
59+
}
60+
61+
file, err := root.OpenFile(localRel, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
62+
if err != nil {
63+
return nil, fmt.Errorf("failed to create destination file %q: %v", rel, err)
64+
}
65+
66+
return file, nil
67+
}
68+
69+
func streamDeviceChildToRoot(root *os.Root, puller pullToWriter, rel, devicePath string) error {
70+
file, err := createRootFile(root, rel)
71+
if err != nil {
72+
return err
73+
}
74+
defer file.Close()
75+
76+
if err := puller.PullToWriter(devicePath, file); err != nil {
77+
return err
78+
}
79+
80+
if err := file.Sync(); err != nil {
81+
return fmt.Errorf("failed to sync destination file %q: %v", rel, err)
82+
}
83+
84+
return nil
85+
}

modules/paths_test.go

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
// Copyright (c) 2021-2026 Claudio Guarnieri.
2+
// Use of this software is governed by the MVT License 1.1 that can be found at
3+
// https://license.mvt.re/1.1/
4+
5+
package modules
6+
7+
import (
8+
"errors"
9+
"os"
10+
"path/filepath"
11+
"runtime"
12+
"testing"
13+
)
14+
15+
func TestRelativeDeviceChild(t *testing.T) {
16+
tests := []struct {
17+
name string
18+
deviceRoot string
19+
devicePath string
20+
want string
21+
wantErr bool
22+
}{
23+
{
24+
name: "child with trailing slash root",
25+
deviceRoot: "/sdcard/Download/Intrusion Logging/",
26+
devicePath: "/sdcard/Download/Intrusion Logging/logs/file.txt",
27+
want: "logs/file.txt",
28+
},
29+
{
30+
name: "child without trailing slash root",
31+
deviceRoot: "/data/local/tmp",
32+
devicePath: "/data/local/tmp/file.txt",
33+
want: "file.txt",
34+
},
35+
{
36+
name: "sibling prefix rejected",
37+
deviceRoot: "/data/local/tmp",
38+
devicePath: "/data/local/tmp-evil/file.txt",
39+
wantErr: true,
40+
},
41+
{
42+
name: "parent traversal rejected",
43+
deviceRoot: "/data/local/tmp",
44+
devicePath: "/data/local/tmp/../../../host/path",
45+
wantErr: true,
46+
},
47+
{
48+
name: "cleaned child traversal rejected",
49+
deviceRoot: "/sdcard/Download/Intrusion Logging/",
50+
devicePath: "/sdcard/Download/Intrusion Logging/../Other/file.txt",
51+
wantErr: true,
52+
},
53+
{
54+
name: "non child rejected",
55+
deviceRoot: "/sdcard/Download/Intrusion Logging/",
56+
devicePath: "/sdcard/Download/Other/file.txt",
57+
wantErr: true,
58+
},
59+
{
60+
name: "root rejected",
61+
deviceRoot: "/data/local/tmp/",
62+
devicePath: "/data/local/tmp",
63+
wantErr: true,
64+
},
65+
}
66+
67+
for _, tt := range tests {
68+
t.Run(tt.name, func(t *testing.T) {
69+
got, err := relativeDeviceChild(tt.deviceRoot, tt.devicePath)
70+
if tt.wantErr {
71+
if err == nil {
72+
t.Fatalf("relativeDeviceChild() error = nil, want error")
73+
}
74+
return
75+
}
76+
if err != nil {
77+
t.Fatalf("relativeDeviceChild() error = %v", err)
78+
}
79+
if got != tt.want {
80+
t.Fatalf("relativeDeviceChild() = %q, want %q", got, tt.want)
81+
}
82+
})
83+
}
84+
}
85+
86+
func TestCreateRootFile(t *testing.T) {
87+
rootDir := t.TempDir()
88+
root, err := os.OpenRoot(rootDir)
89+
if err != nil {
90+
t.Fatalf("OpenRoot() error = %v", err)
91+
}
92+
defer root.Close()
93+
94+
file, err := createRootFile(root, "nested/file.txt")
95+
if err != nil {
96+
t.Fatalf("createRootFile() error = %v", err)
97+
}
98+
if _, err := file.WriteString("ok"); err != nil {
99+
t.Fatalf("WriteString() error = %v", err)
100+
}
101+
if err := file.Close(); err != nil {
102+
t.Fatalf("Close() error = %v", err)
103+
}
104+
105+
got, err := os.ReadFile(filepath.Join(rootDir, "nested", "file.txt"))
106+
if err != nil {
107+
t.Fatalf("ReadFile() error = %v", err)
108+
}
109+
if string(got) != "ok" {
110+
t.Fatalf("created file content = %q, want %q", got, "ok")
111+
}
112+
113+
file, err = createRootFile(root, "file.txt")
114+
if err != nil {
115+
t.Fatalf("createRootFile() root file error = %v", err)
116+
}
117+
if err := file.Close(); err != nil {
118+
t.Fatalf("Close() root file error = %v", err)
119+
}
120+
121+
if file, err := createRootFile(root, "../escape"); err == nil {
122+
file.Close()
123+
t.Fatal("createRootFile() error = nil, want lexical traversal rejection")
124+
}
125+
}
126+
127+
func TestCreateRootFileRejectsSymlinkEscape(t *testing.T) {
128+
if runtime.GOOS == "windows" {
129+
t.Skip("symlink creation requires extra privileges on Windows")
130+
}
131+
132+
rootDir := t.TempDir()
133+
outsideDir := t.TempDir()
134+
if err := os.Symlink(outsideDir, filepath.Join(rootDir, "escape")); err != nil {
135+
if errors.Is(err, os.ErrPermission) {
136+
t.Skipf("symlink creation not permitted: %v", err)
137+
}
138+
t.Fatalf("Symlink() error = %v", err)
139+
}
140+
141+
root, err := os.OpenRoot(rootDir)
142+
if err != nil {
143+
t.Fatalf("OpenRoot() error = %v", err)
144+
}
145+
defer root.Close()
146+
147+
if file, err := createRootFile(root, "escape/file.txt"); err == nil {
148+
file.Close()
149+
t.Fatal("createRootFile() error = nil, want symlink escape rejection")
150+
}
151+
}

0 commit comments

Comments
 (0)