- New sidebar views: GSI Loader (DSU + permanent fastboot flash), APK Audit, Firmware, Intent Lab, Screen Mirror - New backend modules: backend_gsi.go, backend_apkaudit*.go, backend_firmware.go, backend_intent.go, backend_magisk.go, backend_applock.go, backend_scrcpy.go, backend_payload.go, backend_privacy.go, backend_overview.go, backend_bootinfo.go, backend_flasher.go, backend_transfer.go, backend_filehttp.go - App Lock / danger-gate infrastructure (DangerGate, LockGate, applock.ts) - Privileged uninstall via embedded Android helper dex (android-helper/) - nfpm packaging version bump to 1.2.0
430 lines
16 KiB
Go
430 lines
16 KiB
Go
package main
|
|
|
|
// Rule data and small helpers for the APK auditor. The rule set is authored
|
|
// from scratch (CWE / OWASP-MASVS taxonomy is public). Expand freely.
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"io"
|
|
"math"
|
|
"os"
|
|
"os/exec"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Rule tables
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type codeRule struct {
|
|
id, title, severity, category, description, cwe, masvs string
|
|
confidence int
|
|
needles []string
|
|
}
|
|
|
|
// codeRules match by substring presence in DEX-extracted strings (method/class
|
|
// names and string constants surface here).
|
|
var codeRules = []codeRule{
|
|
{
|
|
id: "crypto-weak-hash", title: "Weak hash algorithm (MD5/SHA-1)", severity: "medium",
|
|
category: "crypto", cwe: "CWE-327", masvs: "MASVS-CRYPTO-1", confidence: 55,
|
|
description: "References to MD5 or SHA-1, which are unsuitable for security-sensitive hashing.",
|
|
needles: []string{"MD5", "SHA-1", "SHA1"},
|
|
},
|
|
{
|
|
id: "crypto-ecb", title: "ECB cipher mode", severity: "high",
|
|
category: "crypto", cwe: "CWE-327", masvs: "MASVS-CRYPTO-1", confidence: 80,
|
|
description: "AES/DES in ECB mode leaks plaintext structure; use an authenticated mode (GCM).",
|
|
needles: []string{"AES/ECB", "DES/ECB", "/ECB/"},
|
|
},
|
|
{
|
|
id: "crypto-des-rc4", title: "Obsolete cipher (DES/RC4)", severity: "high",
|
|
category: "crypto", cwe: "CWE-327", masvs: "MASVS-CRYPTO-1", confidence: 70,
|
|
description: "DES/3DES/RC4 are broken or deprecated ciphers.",
|
|
needles: []string{"DES/", "DESede", "RC4", "ARCFOUR"},
|
|
},
|
|
{
|
|
id: "net-cleartext-url", title: "Hardcoded cleartext HTTP URL", severity: "low",
|
|
category: "network", cwe: "CWE-319", masvs: "MASVS-NETWORK-1", confidence: 50,
|
|
description: "Plain http:// endpoints found in code.",
|
|
needles: []string{"http://"},
|
|
},
|
|
{
|
|
id: "net-trustall", title: "Permissive TLS trust / hostname verifier", severity: "high",
|
|
category: "network", cwe: "CWE-295", masvs: "MASVS-NETWORK-2", confidence: 65,
|
|
description: "Custom TrustManager or hostname verifier bypass can disable certificate validation.",
|
|
needles: []string{"ALLOW_ALL_HOSTNAME_VERIFIER", "X509TrustManager", "checkServerTrusted", "setHostnameVerifier", "TrustAllCerts", "NullHostnameVerifier"},
|
|
},
|
|
{
|
|
id: "webview-js", title: "WebView JavaScript / bridge", severity: "medium",
|
|
category: "webview", cwe: "CWE-749", masvs: "MASVS-PLATFORM-2", confidence: 55,
|
|
description: "addJavascriptInterface / setJavaScriptEnabled exposes a JS↔native bridge; risky with untrusted content.",
|
|
needles: []string{"addJavascriptInterface", "setJavaScriptEnabled", "setAllowFileAccess", "setAllowUniversalAccessFromFileURLs"},
|
|
},
|
|
{
|
|
id: "storage-world", title: "World-readable/writable storage mode", severity: "high",
|
|
category: "storage", cwe: "CWE-276", masvs: "MASVS-STORAGE-2", confidence: 70,
|
|
description: "MODE_WORLD_READABLE/WRITABLE exposes private files to other apps.",
|
|
needles: []string{"MODE_WORLD_READABLE", "MODE_WORLD_WRITEABLE", "MODE_WORLD_WRITABLE"},
|
|
},
|
|
{
|
|
id: "storage-extsd", title: "External storage use", severity: "low",
|
|
category: "storage", cwe: "CWE-922", masvs: "MASVS-STORAGE-2", confidence: 40,
|
|
description: "Reads/writes to shared external storage, which other apps may access.",
|
|
needles: []string{"getExternalStorageDirectory", "getExternalStoragePublicDirectory"},
|
|
},
|
|
{
|
|
id: "code-runtime-exec", title: "Runtime command execution", severity: "medium",
|
|
category: "code", cwe: "CWE-78", masvs: "MASVS-CODE-4", confidence: 50,
|
|
description: "Runtime.exec / ProcessBuilder can run shell commands; dangerous with untrusted input.",
|
|
needles: []string{"Runtime;->exec", "Runtime.getRuntime", "ProcessBuilder"},
|
|
},
|
|
{
|
|
id: "code-dynamic-load", title: "Dynamic code loading", severity: "medium",
|
|
category: "code", cwe: "CWE-494", masvs: "MASVS-CODE-2", confidence: 55,
|
|
description: "DexClassLoader / loadClass can load code at runtime, complicating integrity guarantees.",
|
|
needles: []string{"DexClassLoader", "PathClassLoader", "loadDex", "System.load"},
|
|
},
|
|
{
|
|
id: "code-reflection", title: "Reflection", severity: "info",
|
|
category: "code", cwe: "CWE-470", masvs: "MASVS-CODE-2", confidence: 40,
|
|
description: "Heavy reflection usage; often benign but used to hide behaviour.",
|
|
needles: []string{"java.lang.reflect", "getDeclaredMethod", "setAccessible"},
|
|
},
|
|
{
|
|
id: "code-root-check", title: "Root / emulator detection strings", severity: "info",
|
|
category: "code", cwe: "", masvs: "MASVS-RESILIENCE-1", confidence: 45,
|
|
description: "References to su/Magisk/test-keys suggest root or emulator detection.",
|
|
needles: []string{"/system/bin/su", "/system/xbin/su", "Superuser", "magisk", "test-keys", "/sbin/su"},
|
|
},
|
|
{
|
|
id: "sql-raw", title: "Raw SQL query", severity: "low",
|
|
category: "storage", cwe: "CWE-89", masvs: "MASVS-CODE-4", confidence: 35,
|
|
description: "rawQuery/execSQL with concatenated input risks SQL injection.",
|
|
needles: []string{"rawQuery", "execSQL"},
|
|
},
|
|
}
|
|
|
|
type secretRule struct {
|
|
id, title, severity, description string
|
|
confidence int
|
|
entropyMin float64
|
|
re *regexp.Regexp
|
|
}
|
|
|
|
var secretRules = []secretRule{
|
|
{id: "secret-aws", title: "AWS access key ID", severity: "critical", confidence: 90,
|
|
description: "An AWS access key ID was found embedded in the code.",
|
|
re: regexp.MustCompile(`AKIA[0-9A-Z]{16}`)},
|
|
{id: "secret-google-api", title: "Google API key", severity: "high", confidence: 80,
|
|
description: "A Google API key (AIza...) was found.", entropyMin: 3.0,
|
|
re: regexp.MustCompile(`AIza[0-9A-Za-z_\-]{35}`)},
|
|
{id: "secret-stripe", title: "Stripe secret/live key", severity: "critical", confidence: 90,
|
|
description: "A Stripe live/secret key was found.",
|
|
re: regexp.MustCompile(`(?:sk|rk)_live_[0-9A-Za-z]{20,}`)},
|
|
{id: "secret-github", title: "GitHub token", severity: "critical", confidence: 90,
|
|
description: "A GitHub personal access / app token was found.",
|
|
re: regexp.MustCompile(`gh[posru]_[0-9A-Za-z]{36,}`)},
|
|
{id: "secret-slack", title: "Slack token", severity: "high", confidence: 85,
|
|
description: "A Slack token was found.",
|
|
re: regexp.MustCompile(`xox[baprs]-[0-9A-Za-z\-]{10,}`)},
|
|
{id: "secret-twilio", title: "Twilio account SID", severity: "high", confidence: 80,
|
|
description: "A Twilio account SID was found.",
|
|
re: regexp.MustCompile(`AC[0-9a-fA-F]{32}`)},
|
|
{id: "secret-jwt", title: "JSON Web Token", severity: "medium", confidence: 60,
|
|
description: "A JWT was found; may embed sensitive claims.", entropyMin: 3.5,
|
|
re: regexp.MustCompile(`eyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{4,}`)},
|
|
{id: "secret-pem", title: "Private key (PEM)", severity: "critical", confidence: 95,
|
|
description: "A PEM private-key header was found embedded in the APK.",
|
|
re: regexp.MustCompile(`-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----`)},
|
|
{id: "secret-firebase-db", title: "Firebase database URL", severity: "low", confidence: 60,
|
|
description: "A Firebase Realtime Database URL was found; check its rules are not public.",
|
|
re: regexp.MustCompile(`https://[a-z0-9\-]+\.firebaseio\.com`)},
|
|
}
|
|
|
|
// trackerSignatures maps an SDK name to DEX path fragments that identify it.
|
|
var trackerSignatures = map[string][]string{
|
|
"Google Firebase": {"com/google/firebase"},
|
|
"Google AdMob": {"com/google/android/gms/ads"},
|
|
"Google Analytics": {"com/google/android/gms/analytics", "com/google/analytics"},
|
|
"Google Crashlytics": {"com/google/firebase/crashlytics", "com/crashlytics"},
|
|
"Facebook SDK": {"com/facebook/"},
|
|
"Branch": {"io/branch/"},
|
|
"AppsFlyer": {"com/appsflyer"},
|
|
"Adjust": {"com/adjust/sdk"},
|
|
"Mixpanel": {"com/mixpanel"},
|
|
"Amplitude": {"com/amplitude"},
|
|
"Segment": {"com/segment/analytics"},
|
|
"Flurry": {"com/flurry"},
|
|
"OneSignal": {"com/onesignal"},
|
|
"Bugsnag": {"com/bugsnag"},
|
|
"Sentry": {"io/sentry/"},
|
|
"Unity Ads": {"com/unity3d/ads"},
|
|
"AppLovin": {"com/applovin"},
|
|
"ironSource": {"com/ironsource"},
|
|
"Tapjoy": {"com/tapjoy"},
|
|
"Chartboost": {"com/chartboost"},
|
|
"Vungle": {"com/vungle"},
|
|
"InMobi": {"com/inmobi"},
|
|
"MoPub": {"com/mopub"},
|
|
"Yandex Metrica": {"com/yandex/metrica"},
|
|
"Kochava": {"com/kochava"},
|
|
"Singular": {"com/singular/sdk"},
|
|
"Braze": {"com/appboy", "com/braze"},
|
|
"Localytics": {"com/localytics"},
|
|
"ComScore": {"com/comscore"},
|
|
"Tencent Bugly": {"com/tencent/bugly"},
|
|
"Umeng": {"com/umeng"},
|
|
}
|
|
|
|
var trackerCategory = map[string]string{
|
|
"Google Firebase": "Analytics", "Google AdMob": "Advertising", "Google Analytics": "Analytics",
|
|
"Google Crashlytics": "Crash reporting", "Facebook SDK": "Analytics", "Branch": "Attribution",
|
|
"AppsFlyer": "Attribution", "Adjust": "Attribution", "Mixpanel": "Analytics",
|
|
"Amplitude": "Analytics", "Segment": "Analytics", "Flurry": "Analytics",
|
|
"OneSignal": "Push/Analytics", "Bugsnag": "Crash reporting", "Sentry": "Crash reporting",
|
|
"Unity Ads": "Advertising", "AppLovin": "Advertising", "ironSource": "Advertising",
|
|
"Tapjoy": "Advertising", "Chartboost": "Advertising", "Vungle": "Advertising",
|
|
"InMobi": "Advertising", "MoPub": "Advertising", "Yandex Metrica": "Analytics",
|
|
"Kochava": "Attribution", "Singular": "Attribution", "Braze": "Marketing",
|
|
"Localytics": "Analytics", "ComScore": "Analytics", "Tencent Bugly": "Crash reporting",
|
|
"Umeng": "Analytics",
|
|
}
|
|
|
|
// dangerousPermissions is the runtime-permission set (Android dangerous group).
|
|
var dangerousPermissions = map[string]bool{
|
|
"android.permission.READ_CALENDAR": true, "android.permission.WRITE_CALENDAR": true,
|
|
"android.permission.CAMERA": true,
|
|
"android.permission.READ_CONTACTS": true, "android.permission.WRITE_CONTACTS": true,
|
|
"android.permission.GET_ACCOUNTS": true,
|
|
"android.permission.ACCESS_FINE_LOCATION": true, "android.permission.ACCESS_COARSE_LOCATION": true,
|
|
"android.permission.ACCESS_BACKGROUND_LOCATION": true,
|
|
"android.permission.RECORD_AUDIO": true,
|
|
"android.permission.READ_PHONE_STATE": true, "android.permission.READ_PHONE_NUMBERS": true,
|
|
"android.permission.CALL_PHONE": true, "android.permission.ANSWER_PHONE_CALLS": true,
|
|
"android.permission.READ_CALL_LOG": true, "android.permission.WRITE_CALL_LOG": true,
|
|
"android.permission.ADD_VOICEMAIL": true, "android.permission.USE_SIP": true,
|
|
"android.permission.BODY_SENSORS": true,
|
|
"android.permission.SEND_SMS": true, "android.permission.RECEIVE_SMS": true,
|
|
"android.permission.READ_SMS": true, "android.permission.RECEIVE_WAP_PUSH": true,
|
|
"android.permission.RECEIVE_MMS": true,
|
|
"android.permission.READ_EXTERNAL_STORAGE": true, "android.permission.WRITE_EXTERNAL_STORAGE": true,
|
|
"android.permission.READ_MEDIA_IMAGES": true, "android.permission.READ_MEDIA_VIDEO": true,
|
|
"android.permission.READ_MEDIA_AUDIO": true,
|
|
"android.permission.POST_NOTIFICATIONS": true,
|
|
"android.permission.BLUETOOTH_SCAN": true, "android.permission.BLUETOOTH_CONNECT": true,
|
|
"android.permission.BLUETOOTH_ADVERTISE": true,
|
|
"android.permission.ACTIVITY_RECOGNITION": true,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// runExternal executes a binary with optional custom env, returning trimmed stdout.
|
|
func runExternal(ctx context.Context, bin string, env []string, args ...string) (string, error) {
|
|
cmd := exec.CommandContext(ctx, bin, args...)
|
|
setCommandSysProcAttr(cmd)
|
|
if env != nil {
|
|
cmd.Env = env
|
|
}
|
|
var stdout, stderr bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
err := cmd.Run()
|
|
out := strings.TrimSpace(stdout.String())
|
|
if err != nil {
|
|
if out != "" {
|
|
return out, nil // some tools exit non-zero but print useful output (e.g. apksigner DOES NOT VERIFY)
|
|
}
|
|
msg := strings.TrimSpace(stderr.String())
|
|
if msg == "" {
|
|
msg = err.Error()
|
|
}
|
|
return "", &runError{msg}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
type runError struct{ msg string }
|
|
|
|
func (e *runError) Error() string { return e.msg }
|
|
|
|
func lookPath(name string) string {
|
|
if p, err := exec.LookPath(name); err == nil {
|
|
return p
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func fileSHA256(path string) (string, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer f.Close()
|
|
h := sha256.New()
|
|
if _, err := io.Copy(h, f); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(h.Sum(nil)), nil
|
|
}
|
|
|
|
func readZipEntry(f *zip.File) []byte {
|
|
rc, err := f.Open()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
defer rc.Close()
|
|
data, err := io.ReadAll(rc)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return data
|
|
}
|
|
|
|
// shannonEntropy returns the per-character Shannon entropy (bits) of s.
|
|
func shannonEntropy(s string) float64 {
|
|
if s == "" {
|
|
return 0
|
|
}
|
|
var freq [256]float64
|
|
for i := 0; i < len(s); i++ {
|
|
freq[s[i]]++
|
|
}
|
|
n := float64(len(s))
|
|
var h float64
|
|
for _, c := range freq {
|
|
if c == 0 {
|
|
continue
|
|
}
|
|
p := c / n
|
|
h -= p * math.Log2(p)
|
|
}
|
|
return h
|
|
}
|
|
|
|
func redactSecret(s string) string {
|
|
if len(s) <= 10 {
|
|
return s
|
|
}
|
|
return s[:6] + "…" + s[len(s)-4:]
|
|
}
|
|
|
|
func truncate(s string, n int) string {
|
|
if len(s) <= n {
|
|
return s
|
|
}
|
|
return s[:n] + "…"
|
|
}
|
|
|
|
func firstLine(s string) string {
|
|
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
|
return s[:i]
|
|
}
|
|
return s
|
|
}
|
|
|
|
func isTrue(v string) bool {
|
|
v = strings.TrimSpace(strings.ToLower(v))
|
|
return v == "true" || v == "0xffffffff" || v == "-1" || v == "1"
|
|
}
|
|
|
|
func atoiSafe(s string) int {
|
|
n, _ := strconv.Atoi(strings.TrimSpace(s))
|
|
return n
|
|
}
|
|
|
|
func sanitizeFileToken(s string) string {
|
|
return strings.Map(func(r rune) rune {
|
|
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '.' || r == '_' || r == '-' {
|
|
return r
|
|
}
|
|
return '_'
|
|
}, s)
|
|
}
|
|
|
|
func shortName(fqcn string) string {
|
|
if i := strings.LastIndex(fqcn, "."); i >= 0 && i < len(fqcn)-1 {
|
|
// keep a leading dot (relative names) readable
|
|
if strings.HasPrefix(fqcn, ".") {
|
|
return fqcn
|
|
}
|
|
return fqcn[i+1:]
|
|
}
|
|
return fqcn
|
|
}
|
|
|
|
// countIndent returns leading-space count of a line.
|
|
func countIndent(s string) int {
|
|
n := 0
|
|
for _, c := range s {
|
|
if c == ' ' {
|
|
n++
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
// elementName extracts the tag name from an "E: name (line=..)" xmltree line.
|
|
func elementName(line string) string {
|
|
line = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "E:"))
|
|
if i := strings.Index(line, " "); i >= 0 {
|
|
line = line[:i]
|
|
}
|
|
return strings.TrimSpace(line)
|
|
}
|
|
|
|
// manifestAttr parses an "A: ns:attr(0xhex)=value (Raw: ..)" xmltree line into
|
|
// a bare attribute name and a cleaned value.
|
|
func manifestAttr(line string) (string, string) {
|
|
line = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "A:"))
|
|
eq := strings.Index(line, "=")
|
|
if eq < 0 {
|
|
return "", ""
|
|
}
|
|
name := strings.TrimSpace(line[:eq])
|
|
val := strings.TrimSpace(line[eq+1:])
|
|
|
|
// strip "(0x...)" hex id from name and any namespace prefix
|
|
if p := strings.Index(name, "("); p >= 0 {
|
|
name = name[:p]
|
|
}
|
|
if c := strings.LastIndex(name, ":"); c >= 0 {
|
|
name = name[c+1:]
|
|
}
|
|
|
|
// prefer the Raw: "..." form when present
|
|
if r := strings.Index(val, "(Raw: \""); r >= 0 {
|
|
rest := val[r+len("(Raw: \""):]
|
|
if e := strings.Index(rest, "\""); e >= 0 {
|
|
return name, rest[:e]
|
|
}
|
|
}
|
|
val = strings.Trim(val, "\"")
|
|
return name, val
|
|
}
|
|
|
|
func parseCertTime(s string) (time.Time, bool) {
|
|
layouts := []string{
|
|
"Mon Jan 02 15:04:05 MST 2006",
|
|
"Mon Jan 2 15:04:05 MST 2006",
|
|
"Jan 2, 2006",
|
|
"2006-01-02",
|
|
}
|
|
for _, l := range layouts {
|
|
if t, err := time.Parse(l, strings.TrimSpace(s)); err == nil {
|
|
return t, true
|
|
}
|
|
}
|
|
return time.Time{}, false
|
|
}
|