951 lines
30 KiB
Go
951 lines
30 KiB
Go
package main
|
|
|
|
// APK Auditor — static analysis of an APK (local file or installed package).
|
|
//
|
|
// Clean-room implementation. The feature concept (a tabbed APK static auditor:
|
|
// overview/findings/manifest/components/cert/explorer) is inspired by
|
|
// apkauditor.com by Sandeep Wawdane, but none of its code is used here — this
|
|
// engine is written from scratch in Go and shells out to the Android SDK
|
|
// build-tools (aapt2, apksigner) plus the JBR's keytool for the heavy parsing.
|
|
|
|
import (
|
|
"archive/zip"
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/ncruces/zenity"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Result types (JSON-tagged for the Wails frontend)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type APKAudit struct {
|
|
// Source
|
|
Source string `json:"source"` // "file" | "device"
|
|
Path string `json:"path"` // display path (remote path for device source)
|
|
LocalPath string `json:"localPath"` // on-disk APK to read entries from (Explorer/export)
|
|
FileName string `json:"fileName"`
|
|
FileSize int64 `json:"fileSize"`
|
|
SHA256 string `json:"sha256"`
|
|
|
|
// Metadata
|
|
PackageName string `json:"packageName"`
|
|
AppLabel string `json:"appLabel"`
|
|
VersionName string `json:"versionName"`
|
|
VersionCode string `json:"versionCode"`
|
|
MinSDK string `json:"minSdk"`
|
|
TargetSDK string `json:"targetSdk"`
|
|
CompileSDK string `json:"compileSdk"`
|
|
|
|
// Manifest-level flags
|
|
Debuggable bool `json:"debuggable"`
|
|
AllowBackup bool `json:"allowBackup"`
|
|
UsesCleartext bool `json:"usesCleartext"`
|
|
HasNSC bool `json:"hasNetworkSecurityConfig"`
|
|
|
|
Permissions []Permission `json:"permissions"`
|
|
Components []Component `json:"components"`
|
|
Cert APKCertInfo `json:"cert"`
|
|
Findings []Finding `json:"findings"`
|
|
Trackers []Tracker `json:"trackers"`
|
|
Files []APKFileEntry `json:"files"`
|
|
ManifestXML string `json:"manifestXml"`
|
|
|
|
// Scoring
|
|
Score int `json:"score"` // 0-100
|
|
Grade string `json:"grade"` // A-F
|
|
Counts map[string]int `json:"counts"` // severity -> count
|
|
|
|
noManifestMF bool // transient: no META-INF/MANIFEST.MF in the archive
|
|
}
|
|
|
|
type Permission struct {
|
|
Name string `json:"name"`
|
|
Dangerous bool `json:"dangerous"`
|
|
}
|
|
|
|
type Component struct {
|
|
Type string `json:"type"` // activity|service|receiver|provider
|
|
Name string `json:"name"`
|
|
Exported bool `json:"exported"`
|
|
ExportedImplicit bool `json:"exportedImplicit"`
|
|
Permission string `json:"permission"`
|
|
IntentFilters []string `json:"intentFilters"`
|
|
|
|
explicitExported bool // set when android:exported was present (not serialized)
|
|
}
|
|
|
|
type APKCertInfo struct {
|
|
Verified bool `json:"verified"`
|
|
Subject string `json:"subject"`
|
|
Issuer string `json:"issuer"`
|
|
SigAlgo string `json:"sigAlgo"`
|
|
Serial string `json:"serial"`
|
|
SHA256 string `json:"sha256"`
|
|
SHA1 string `json:"sha1"`
|
|
ValidFrom string `json:"validFrom"`
|
|
ValidTo string `json:"validTo"`
|
|
V1 bool `json:"v1"`
|
|
V2 bool `json:"v2"`
|
|
V3 bool `json:"v3"`
|
|
IsDebug bool `json:"isDebug"`
|
|
Expired bool `json:"expired"`
|
|
WeakAlgo bool `json:"weakAlgo"`
|
|
Error string `json:"error"`
|
|
}
|
|
|
|
type Finding struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
Severity string `json:"severity"` // critical|high|medium|low|info
|
|
Category string `json:"category"`
|
|
Description string `json:"description"`
|
|
CWE string `json:"cwe"`
|
|
Masvs string `json:"masvs"`
|
|
Confidence int `json:"confidence"`
|
|
Matches []FindingMatch `json:"matches"`
|
|
}
|
|
|
|
type FindingMatch struct {
|
|
File string `json:"file"`
|
|
Value string `json:"value"`
|
|
}
|
|
|
|
type Tracker struct {
|
|
Name string `json:"name"`
|
|
Category string `json:"category"`
|
|
Matches int `json:"matches"`
|
|
}
|
|
|
|
type APKFileEntry struct {
|
|
Path string `json:"path"`
|
|
Size int64 `json:"size"`
|
|
Compressed int64 `json:"compressed"`
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tunables
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const (
|
|
auditCommandTimeout = 90 * time.Second
|
|
maxDexBytes = 64 << 20 // skip a single dex larger than 64 MB
|
|
maxCandidates = 250000 // cap extracted strings scanned
|
|
maxMatchesPerRule = 25 // cap reported instances per finding
|
|
minStringLen = 6
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public API (auto-bound to the frontend via the single App bind)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// SelectAPKForAudit opens a native file picker filtered to APKs.
|
|
func (a *App) SelectAPKForAudit() (string, error) {
|
|
path, err := zenity.SelectFile(
|
|
zenity.Title("Select APK to audit"),
|
|
zenity.FileFilters{
|
|
{Name: "APK files", Patterns: []string{"*.apk"}, CaseFold: true},
|
|
{Name: "All files", Patterns: []string{"*"}},
|
|
},
|
|
)
|
|
if err == zenity.ErrCanceled {
|
|
return "", nil
|
|
}
|
|
return path, err
|
|
}
|
|
|
|
// AuditInstalledApp pulls the base APK of an installed package off the device
|
|
// into a temp file, audits it, then removes the temp copy.
|
|
func (a *App) AuditInstalledApp(packageName string) (APKAudit, error) {
|
|
if err := validatePackageName(packageName); err != nil {
|
|
return APKAudit{}, err
|
|
}
|
|
|
|
out, err := a.runAdbShell("pm", "path", packageName)
|
|
if err != nil {
|
|
return APKAudit{}, fmt.Errorf("could not locate package on device: %w", err)
|
|
}
|
|
|
|
var remote string
|
|
for _, line := range strings.Split(out, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
p := strings.TrimPrefix(line, "package:")
|
|
if strings.HasSuffix(p, "base.apk") {
|
|
remote = p
|
|
break
|
|
}
|
|
if remote == "" && strings.HasSuffix(p, ".apk") {
|
|
remote = p // fall back to the first apk if no base.apk
|
|
}
|
|
}
|
|
if remote == "" {
|
|
return APKAudit{}, fmt.Errorf("no APK path found for %s", packageName)
|
|
}
|
|
|
|
// Remove temps from earlier device audits, then keep this one on disk so
|
|
// the Explorer/export can read entries from it after the audit returns.
|
|
cleanStaleAuditTemps()
|
|
tmp := filepath.Join(os.TempDir(), "atk-audit-"+sanitizeFileToken(packageName)+".apk")
|
|
if _, err := a.runCommandTimeout(auditCommandTimeout, "adb", "pull", remote, tmp); err != nil {
|
|
return APKAudit{}, fmt.Errorf("failed to pull APK: %w", err)
|
|
}
|
|
|
|
audit, err := a.auditFile(tmp)
|
|
if err != nil {
|
|
os.Remove(tmp)
|
|
return audit, err
|
|
}
|
|
audit.Source = "device"
|
|
audit.Path = remote
|
|
audit.LocalPath = tmp
|
|
audit.FileName = packageName + " (base.apk)"
|
|
return audit, nil
|
|
}
|
|
|
|
// AuditAPK audits a local APK file path.
|
|
func (a *App) AuditAPK(path string) (APKAudit, error) {
|
|
if strings.TrimSpace(path) == "" {
|
|
return APKAudit{}, fmt.Errorf("no APK path provided")
|
|
}
|
|
if info, err := os.Stat(path); err != nil || info.IsDir() {
|
|
return APKAudit{}, fmt.Errorf("file not found: %s", path)
|
|
}
|
|
audit, err := a.auditFile(path)
|
|
if err != nil {
|
|
return audit, err
|
|
}
|
|
audit.Source = "file"
|
|
return audit, nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Core pipeline
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func (a *App) auditFile(path string) (APKAudit, error) {
|
|
audit := APKAudit{
|
|
Path: path,
|
|
LocalPath: path,
|
|
FileName: filepath.Base(path),
|
|
Counts: map[string]int{},
|
|
}
|
|
|
|
if info, err := os.Stat(path); err == nil {
|
|
audit.FileSize = info.Size()
|
|
}
|
|
if sum, err := fileSHA256(path); err == nil {
|
|
audit.SHA256 = sum
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), auditCommandTimeout)
|
|
defer cancel()
|
|
|
|
// 1. Manifest + metadata: aapt2 when available, else pure-Go fallback.
|
|
a.parseManifestHybrid(ctx, path, &audit)
|
|
|
|
// 2. Signing certificate: apksigner+keytool when available, else pure-Go.
|
|
a.parseCertHybrid(ctx, path, &audit)
|
|
|
|
// 3. ZIP walk: file tree + dex string extraction for code/secret/tracker rules.
|
|
a.scanArchive(path, &audit)
|
|
|
|
// 4. Manifest-derived findings.
|
|
a.deriveManifestFindings(&audit)
|
|
|
|
// 5. Score.
|
|
a.scoreAudit(&audit)
|
|
|
|
return audit, nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// aapt2: badging
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func (a *App) parseBadging(ctx context.Context, path string, audit *APKAudit) {
|
|
out, err := a.runBuildTool(ctx, "aapt2", "dump", "badging", path)
|
|
if err != nil || out == "" {
|
|
return
|
|
}
|
|
for _, line := range strings.Split(out, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
switch {
|
|
case strings.HasPrefix(line, "package:"):
|
|
audit.PackageName = badgingField(line, "name")
|
|
audit.VersionCode = badgingField(line, "versionCode")
|
|
audit.VersionName = badgingField(line, "versionName")
|
|
audit.CompileSDK = badgingField(line, "compileSdkVersion")
|
|
case strings.HasPrefix(line, "sdkVersion:"):
|
|
audit.MinSDK = strings.Trim(strings.TrimPrefix(line, "sdkVersion:"), "'")
|
|
case strings.HasPrefix(line, "targetSdkVersion:"):
|
|
audit.TargetSDK = strings.Trim(strings.TrimPrefix(line, "targetSdkVersion:"), "'")
|
|
case strings.HasPrefix(line, "application-label:"):
|
|
if audit.AppLabel == "" {
|
|
audit.AppLabel = strings.Trim(strings.TrimPrefix(line, "application-label:"), "'")
|
|
}
|
|
case strings.HasPrefix(line, "uses-permission:"):
|
|
name := badgingField(line, "name")
|
|
if name != "" {
|
|
audit.Permissions = append(audit.Permissions, Permission{
|
|
Name: name,
|
|
Dangerous: dangerousPermissions[name],
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// badgingField extracts key='value' from an aapt2 badging line.
|
|
func badgingField(line, key string) string {
|
|
marker := key + "='"
|
|
i := strings.Index(line, marker)
|
|
if i < 0 {
|
|
return ""
|
|
}
|
|
rest := line[i+len(marker):]
|
|
j := strings.Index(rest, "'")
|
|
if j < 0 {
|
|
return rest
|
|
}
|
|
return rest[:j]
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// aapt2: xmltree manifest parse (components, exported flags, intent filters,
|
|
// application flags) + a readable reconstruction for the Manifest tab.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func (a *App) parseManifestTree(ctx context.Context, path string, audit *APKAudit) {
|
|
out, err := a.runBuildTool(ctx, "aapt2", "dump", "xmltree", path, "--file", "AndroidManifest.xml")
|
|
if err != nil || out == "" {
|
|
return
|
|
}
|
|
audit.ManifestXML = out
|
|
|
|
// Frames store the component index (not a pointer) so appends to
|
|
// audit.Components can't leave us holding a stale pointer.
|
|
type frame struct {
|
|
indent int
|
|
name string
|
|
compIdx int // -1 when the element is not a component
|
|
}
|
|
var stack []frame
|
|
|
|
curComp := func() int {
|
|
for i := len(stack) - 1; i >= 0; i-- {
|
|
if stack[i].compIdx >= 0 {
|
|
return stack[i].compIdx
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
top := func() string {
|
|
if len(stack) == 0 {
|
|
return ""
|
|
}
|
|
return stack[len(stack)-1].name
|
|
}
|
|
|
|
for _, raw := range strings.Split(out, "\n") {
|
|
indent := countIndent(raw)
|
|
line := strings.TrimSpace(raw)
|
|
|
|
switch {
|
|
case strings.HasPrefix(line, "E:"):
|
|
for len(stack) > 0 && stack[len(stack)-1].indent >= indent {
|
|
stack = stack[:len(stack)-1]
|
|
}
|
|
elem := elementName(line)
|
|
switch elem {
|
|
case "activity", "activity-alias", "service", "receiver", "provider":
|
|
typ := elem
|
|
if typ == "activity-alias" {
|
|
typ = "activity"
|
|
}
|
|
audit.Components = append(audit.Components, Component{Type: typ})
|
|
stack = append(stack, frame{indent: indent, name: elem, compIdx: len(audit.Components) - 1})
|
|
case "intent-filter":
|
|
if ci := curComp(); ci >= 0 {
|
|
audit.Components[ci].IntentFilters = append(audit.Components[ci].IntentFilters, "")
|
|
}
|
|
stack = append(stack, frame{indent: indent, name: elem, compIdx: -1})
|
|
default:
|
|
stack = append(stack, frame{indent: indent, name: elem, compIdx: -1})
|
|
}
|
|
|
|
case strings.HasPrefix(line, "A:"):
|
|
attr, val := manifestAttr(line)
|
|
switch top() {
|
|
case "uses-sdk":
|
|
if attr == "minSdkVersion" && audit.MinSDK == "" {
|
|
audit.MinSDK = val
|
|
}
|
|
if attr == "targetSdkVersion" && audit.TargetSDK == "" {
|
|
audit.TargetSDK = val
|
|
}
|
|
case "application":
|
|
switch attr {
|
|
case "debuggable":
|
|
audit.Debuggable = isTrue(val)
|
|
case "allowBackup":
|
|
audit.AllowBackup = isTrue(val)
|
|
case "usesCleartextTraffic":
|
|
audit.UsesCleartext = isTrue(val)
|
|
case "networkSecurityConfig":
|
|
audit.HasNSC = true
|
|
}
|
|
case "activity", "activity-alias", "service", "receiver", "provider":
|
|
if ci := curComp(); ci >= 0 {
|
|
switch attr {
|
|
case "name":
|
|
audit.Components[ci].Name = val
|
|
case "exported":
|
|
audit.Components[ci].Exported = isTrue(val)
|
|
audit.Components[ci].explicitExported = true
|
|
case "permission":
|
|
audit.Components[ci].Permission = val
|
|
}
|
|
}
|
|
case "action", "category":
|
|
if attr == "name" {
|
|
if ci := curComp(); ci >= 0 && len(audit.Components[ci].IntentFilters) > 0 {
|
|
idx := len(audit.Components[ci].IntentFilters) - 1
|
|
sep := ""
|
|
if audit.Components[ci].IntentFilters[idx] != "" {
|
|
sep = ", "
|
|
}
|
|
audit.Components[ci].IntentFilters[idx] += sep + shortName(val)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Defaults the tree walk can't see: allowBackup defaults on when absent;
|
|
// cleartext defaults on for targetSdk < 28.
|
|
if !strings.Contains(out, "allowBackup") {
|
|
audit.AllowBackup = true
|
|
}
|
|
if !strings.Contains(out, "usesCleartextTraffic") {
|
|
if t := atoiSafe(audit.TargetSDK); t > 0 && t < 28 {
|
|
audit.UsesCleartext = true
|
|
}
|
|
}
|
|
|
|
// Implicit export: an intent-filter present with no explicit android:exported
|
|
// means the component is reachable by other apps (pre-Android 12 behaviour).
|
|
for i := range audit.Components {
|
|
c := &audit.Components[i]
|
|
if !c.Exported && !c.explicitExported && len(c.IntentFilters) > 0 {
|
|
c.ExportedImplicit = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Signing certificate
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// parseManifestHybrid uses aapt2 when present (reference parse), otherwise the
|
|
// pure-Go apkparser fallback. Both populate the same audit fields.
|
|
func (a *App) parseManifestHybrid(ctx context.Context, path string, audit *APKAudit) {
|
|
if a.hasBuildTool("aapt2") {
|
|
a.parseBadging(ctx, path, audit)
|
|
a.parseManifestTree(ctx, path, audit)
|
|
if audit.PackageName != "" {
|
|
return // aapt2 succeeded
|
|
}
|
|
}
|
|
parseManifestGo(path, audit)
|
|
}
|
|
|
|
// parseCertHybrid resolves the signing certificate. The pure-Go x509 path
|
|
// (apkverifier) always owns cert *identity* — subject/issuer/serial/validity/
|
|
// algorithm/fingerprints — because it is accurate, consistent across machines,
|
|
// and needs no JDK. When apksigner is available it additionally refines the
|
|
// authoritative per-scheme booleans (v1/v2/v3 reported independently, which
|
|
// apksigner does better than a single "highest scheme" number).
|
|
func (a *App) parseCertHybrid(ctx context.Context, path string, audit *APKAudit) {
|
|
parseCertGo(path, audit)
|
|
if a.hasBuildTool("apksigner") && findJBR() != "" {
|
|
a.refineSchemesApksigner(ctx, path, audit)
|
|
}
|
|
finalizeCert(audit)
|
|
}
|
|
|
|
// refineSchemesApksigner overlays apksigner's authoritative verification result
|
|
// (verified + independent v1/v2/v3 flags) onto the Go-parsed cert. It ignores
|
|
// the Play "Source Stamp" signer, which is not the app's signing certificate.
|
|
func (a *App) refineSchemesApksigner(ctx context.Context, path string, audit *APKAudit) {
|
|
out, _ := a.runBuildToolJava(ctx, "apksigner", "verify", "--verbose", path)
|
|
if out == "" {
|
|
return
|
|
}
|
|
var v1, v2, v3, verifies, sawScheme bool
|
|
for _, line := range strings.Split(out, "\n") {
|
|
l := strings.TrimSpace(line)
|
|
if strings.Contains(l, "Source Stamp") {
|
|
continue
|
|
}
|
|
switch {
|
|
case l == "Verifies":
|
|
verifies = true
|
|
case strings.HasPrefix(l, "Verified using v1 scheme"):
|
|
v1 = strings.HasSuffix(l, "true")
|
|
sawScheme = true
|
|
case strings.HasPrefix(l, "Verified using v2 scheme"):
|
|
v2 = strings.HasSuffix(l, "true")
|
|
sawScheme = true
|
|
case strings.Contains(l, "v3 scheme"), strings.Contains(l, "v3.1 scheme"), strings.Contains(l, "v3.2 scheme"):
|
|
if strings.HasPrefix(l, "Verified using") && strings.HasSuffix(l, "true") {
|
|
v3 = true
|
|
}
|
|
sawScheme = true
|
|
}
|
|
}
|
|
if sawScheme {
|
|
audit.Cert.Verified = verifies
|
|
audit.Cert.V1, audit.Cert.V2, audit.Cert.V3 = v1, v2, v3
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ZIP / DEX scanning
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func (a *App) scanArchive(path string, audit *APKAudit) {
|
|
zr, err := zip.OpenReader(path)
|
|
if err != nil {
|
|
audit.addFinding(Finding{
|
|
ID: "zip-open", Title: "APK archive could not be opened", Severity: "high",
|
|
Category: "code", Description: "The APK ZIP structure could not be read: " + err.Error(),
|
|
})
|
|
return
|
|
}
|
|
defer zr.Close()
|
|
|
|
hasManifestMF := false
|
|
candidates := make([]candidate, 0, 4096)
|
|
seen := make(map[string]struct{}, 4096)
|
|
trackerHits := map[string]int{}
|
|
|
|
for _, f := range zr.File {
|
|
audit.Files = append(audit.Files, APKFileEntry{
|
|
Path: f.Name,
|
|
Size: int64(f.UncompressedSize64),
|
|
Compressed: int64(f.CompressedSize64),
|
|
})
|
|
if f.Name == "META-INF/MANIFEST.MF" {
|
|
hasManifestMF = true
|
|
}
|
|
|
|
if strings.HasPrefix(f.Name, "classes") && strings.HasSuffix(f.Name, ".dex") {
|
|
if f.UncompressedSize64 > maxDexBytes {
|
|
continue
|
|
}
|
|
data := readZipEntry(f)
|
|
if data == nil {
|
|
continue
|
|
}
|
|
extractStrings(data, f.Name, &candidates, seen)
|
|
matchTrackers(data, trackerHits)
|
|
}
|
|
}
|
|
|
|
sort.Slice(audit.Files, func(i, j int) bool { return audit.Files[i].Path < audit.Files[j].Path })
|
|
|
|
// Tracker findings.
|
|
for name, n := range trackerHits {
|
|
audit.Trackers = append(audit.Trackers, Tracker{
|
|
Name: name, Category: trackerCategory[name], Matches: n,
|
|
})
|
|
}
|
|
sort.Slice(audit.Trackers, func(i, j int) bool { return audit.Trackers[i].Name < audit.Trackers[j].Name })
|
|
|
|
// Code-pattern + secret rules over extracted strings.
|
|
a.applyStringRules(candidates, audit)
|
|
|
|
// A missing JAR manifest only matters when the APK also fails to verify —
|
|
// v2/v3-only signed APKs legitimately have no META-INF/MANIFEST.MF.
|
|
audit.noManifestMF = !hasManifestMF
|
|
}
|
|
|
|
// candidate is one extracted printable string and where it came from.
|
|
type candidate struct {
|
|
val string
|
|
file string
|
|
}
|
|
|
|
// extractStrings pulls printable ASCII runs of length >= minStringLen out of a
|
|
// dex blob, de-duplicating globally, capped at maxCandidates.
|
|
func extractStrings(data []byte, file string, out *[]candidate, seen map[string]struct{}) {
|
|
var b strings.Builder
|
|
flush := func() {
|
|
if b.Len() >= minStringLen {
|
|
s := b.String()
|
|
if _, ok := seen[s]; !ok && len(*out) < maxCandidates {
|
|
seen[s] = struct{}{}
|
|
*out = append(*out, candidate{val: s, file: file})
|
|
}
|
|
}
|
|
b.Reset()
|
|
}
|
|
for _, c := range data {
|
|
if c >= 0x20 && c < 0x7f {
|
|
b.WriteByte(c)
|
|
} else {
|
|
flush()
|
|
}
|
|
if len(*out) >= maxCandidates {
|
|
return
|
|
}
|
|
}
|
|
flush()
|
|
}
|
|
|
|
func (a *App) applyStringRules(cands []candidate, audit *APKAudit) {
|
|
// Code/network/crypto/webview/storage rules: substring presence.
|
|
for _, rule := range codeRules {
|
|
var matches []FindingMatch
|
|
for _, c := range cands {
|
|
hit := false
|
|
for _, needle := range rule.needles {
|
|
if strings.Contains(c.val, needle) {
|
|
hit = true
|
|
break
|
|
}
|
|
}
|
|
if hit {
|
|
if len(matches) < maxMatchesPerRule {
|
|
matches = append(matches, FindingMatch{File: c.file, Value: truncate(c.val, 200)})
|
|
}
|
|
}
|
|
}
|
|
if len(matches) > 0 {
|
|
audit.addFinding(Finding{
|
|
ID: rule.id, Title: rule.title, Severity: rule.severity, Category: rule.category,
|
|
Description: rule.description, CWE: rule.cwe, Masvs: rule.masvs,
|
|
Confidence: rule.confidence, Matches: matches,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Secret rules: regex + Shannon-entropy gate to suppress noise.
|
|
for _, rule := range secretRules {
|
|
var matches []FindingMatch
|
|
for _, c := range cands {
|
|
for _, m := range rule.re.FindAllString(c.val, -1) {
|
|
if rule.entropyMin > 0 && shannonEntropy(m) < rule.entropyMin {
|
|
continue
|
|
}
|
|
if len(matches) < maxMatchesPerRule {
|
|
matches = append(matches, FindingMatch{File: c.file, Value: redactSecret(m)})
|
|
}
|
|
}
|
|
}
|
|
if len(matches) > 0 {
|
|
audit.addFinding(Finding{
|
|
ID: rule.id, Title: rule.title, Severity: rule.severity, Category: "secret",
|
|
Description: rule.description, CWE: "CWE-798", Masvs: "MASVS-STORAGE-1",
|
|
Confidence: rule.confidence, Matches: matches,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func matchTrackers(data []byte, hits map[string]int) {
|
|
s := string(data)
|
|
for name, sigs := range trackerSignatures {
|
|
for _, sig := range sigs {
|
|
if c := strings.Count(s, sig); c > 0 {
|
|
hits[name] += c
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Manifest-derived findings
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func (a *App) deriveManifestFindings(audit *APKAudit) {
|
|
if audit.Debuggable {
|
|
audit.addFinding(Finding{
|
|
ID: "manifest-debuggable", Title: "Application is debuggable", Severity: "high",
|
|
Category: "manifest", Confidence: 100, CWE: "CWE-489", Masvs: "MASVS-RESILIENCE-2",
|
|
Description: "android:debuggable=\"true\" lets anyone attach a debugger and inspect/modify the running app.",
|
|
})
|
|
}
|
|
if audit.AllowBackup {
|
|
audit.addFinding(Finding{
|
|
ID: "manifest-allowbackup", Title: "Backups allowed (allowBackup)", Severity: "medium",
|
|
Category: "manifest", Confidence: 90, CWE: "CWE-530", Masvs: "MASVS-STORAGE-2",
|
|
Description: "android:allowBackup is enabled (or defaulted on). App data can be extracted over adb with `adb backup`.",
|
|
})
|
|
}
|
|
if audit.UsesCleartext {
|
|
audit.addFinding(Finding{
|
|
ID: "manifest-cleartext", Title: "Cleartext HTTP traffic permitted", Severity: "medium",
|
|
Category: "network", Confidence: 85, CWE: "CWE-319", Masvs: "MASVS-NETWORK-1",
|
|
Description: "Cleartext (unencrypted HTTP) traffic is allowed, exposing data to network interception.",
|
|
})
|
|
}
|
|
if !audit.HasNSC {
|
|
audit.addFinding(Finding{
|
|
ID: "manifest-no-nsc", Title: "No Network Security Config", Severity: "low",
|
|
Category: "network", Confidence: 60, CWE: "CWE-295", Masvs: "MASVS-NETWORK-2",
|
|
Description: "No networkSecurityConfig is declared, so the app relies on platform defaults (no pinning, no per-domain cleartext rules).",
|
|
})
|
|
}
|
|
|
|
var exported []FindingMatch
|
|
for _, c := range audit.Components {
|
|
if (c.Exported || c.ExportedImplicit) && c.Permission == "" {
|
|
label := c.Type + ": " + shortName(c.Name)
|
|
if c.ExportedImplicit {
|
|
label += " (implicit)"
|
|
}
|
|
exported = append(exported, FindingMatch{Value: label})
|
|
}
|
|
}
|
|
if len(exported) > 0 {
|
|
if len(exported) > maxMatchesPerRule {
|
|
exported = exported[:maxMatchesPerRule]
|
|
}
|
|
audit.addFinding(Finding{
|
|
ID: "exported-components", Title: "Exported components without permission",
|
|
Severity: "medium", Category: "manifest", Confidence: 80, CWE: "CWE-926",
|
|
Masvs: "MASVS-PLATFORM-1",
|
|
Description: "These components are reachable by other apps and declare no protecting permission.",
|
|
Matches: exported,
|
|
})
|
|
}
|
|
|
|
// Signing-derived findings.
|
|
toolMissing := strings.Contains(audit.Cert.Error, "not found")
|
|
if !audit.Cert.Verified && !toolMissing {
|
|
desc := "The APK signature does not verify"
|
|
if audit.Cert.Error != "" {
|
|
desc += " (" + audit.Cert.Error + ")"
|
|
}
|
|
desc += ". It is unsigned or was repacked without re-signing, so it cannot be installed on a stock device and its integrity is unverifiable."
|
|
sev := "high"
|
|
if audit.noManifestMF {
|
|
sev = "critical"
|
|
}
|
|
audit.addFinding(Finding{
|
|
ID: "unsigned", Title: "APK is unsigned or fails verification", Severity: sev,
|
|
Category: "signing", Confidence: 95, CWE: "CWE-347", Masvs: "MASVS-CODE-1",
|
|
Description: desc,
|
|
})
|
|
}
|
|
if audit.Cert.IsDebug {
|
|
audit.addFinding(Finding{
|
|
ID: "cert-debug", Title: "Signed with a debug certificate", Severity: "high",
|
|
Category: "signing", Confidence: 95, CWE: "CWE-321", Masvs: "MASVS-CODE-1",
|
|
Description: "The APK is signed with the well-known Android debug key; anyone can forge a matching signature.",
|
|
})
|
|
}
|
|
if audit.Cert.Expired {
|
|
audit.addFinding(Finding{
|
|
ID: "cert-expired", Title: "Signing certificate is expired", Severity: "low",
|
|
Category: "signing", Confidence: 90, CWE: "CWE-298",
|
|
Description: "The signing certificate validity period has ended.",
|
|
})
|
|
}
|
|
if audit.Cert.WeakAlgo {
|
|
audit.addFinding(Finding{
|
|
ID: "cert-weak-algo", Title: "Weak signature algorithm", Severity: "medium",
|
|
Category: "signing", Confidence: 95, CWE: "CWE-327", Masvs: "MASVS-CRYPTO-1",
|
|
Description: "The certificate uses a weak signature algorithm (" + audit.Cert.SigAlgo + ").",
|
|
})
|
|
}
|
|
if audit.Cert.Verified && audit.Cert.V1 && !audit.Cert.V2 && !audit.Cert.V3 {
|
|
audit.addFinding(Finding{
|
|
ID: "cert-v1-only", Title: "v1-only signing (Janus exploit)", Severity: "medium",
|
|
Category: "signing", Confidence: 90, CWE: "CWE-347", Masvs: "MASVS-CODE-1",
|
|
Description: "Signed only with the v1 JAR scheme. On Android < 7.0 such APKs are vulnerable to the Janus exploit (CVE-2017-13156).",
|
|
})
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Scoring
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func (a *App) scoreAudit(audit *APKAudit) {
|
|
weights := map[string]int{"critical": 25, "high": 15, "medium": 8, "low": 3, "info": 0}
|
|
score := 100
|
|
for _, f := range audit.Findings {
|
|
audit.Counts[f.Severity]++
|
|
score -= weights[f.Severity]
|
|
}
|
|
if score < 0 {
|
|
score = 0
|
|
}
|
|
audit.Score = score
|
|
switch {
|
|
case score >= 90:
|
|
audit.Grade = "A"
|
|
case score >= 75:
|
|
audit.Grade = "B"
|
|
case score >= 60:
|
|
audit.Grade = "C"
|
|
case score >= 40:
|
|
audit.Grade = "D"
|
|
default:
|
|
audit.Grade = "F"
|
|
}
|
|
|
|
// stable severity-then-title ordering
|
|
order := map[string]int{"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}
|
|
sort.SliceStable(audit.Findings, func(i, j int) bool {
|
|
if order[audit.Findings[i].Severity] != order[audit.Findings[j].Severity] {
|
|
return order[audit.Findings[i].Severity] < order[audit.Findings[j].Severity]
|
|
}
|
|
return audit.Findings[i].Title < audit.Findings[j].Title
|
|
})
|
|
}
|
|
|
|
func (audit *APKAudit) addFinding(f Finding) {
|
|
if f.Confidence == 0 {
|
|
f.Confidence = 80
|
|
}
|
|
audit.Findings = append(audit.Findings, f)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Build-tool / java command runners
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// runBuildTool runs an SDK build-tool that does not need a JVM (aapt2).
|
|
func (a *App) runBuildTool(ctx context.Context, name string, args ...string) (string, error) {
|
|
bin, err := a.resolveBuildTool(name)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return runExternal(ctx, bin, nil, args...)
|
|
}
|
|
|
|
// runBuildToolJava runs an SDK build-tool that needs a JVM (apksigner).
|
|
func (a *App) runBuildToolJava(ctx context.Context, name string, args ...string) (string, error) {
|
|
bin, err := a.resolveBuildTool(name)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return runExternal(ctx, bin, a.javaEnv(), args...)
|
|
}
|
|
|
|
// javaEnv returns an environment with the JBR's java on PATH + JAVA_HOME set,
|
|
// so apksigner/keytool work even when no system JDK is installed.
|
|
func (a *App) javaEnv() []string {
|
|
jbr := findJBR()
|
|
if jbr == "" {
|
|
return nil
|
|
}
|
|
env := os.Environ()
|
|
env = append(env, "JAVA_HOME="+jbr)
|
|
env = append(env, "PATH="+filepath.Join(jbr, "bin")+string(os.PathListSeparator)+os.Getenv("PATH"))
|
|
return env
|
|
}
|
|
|
|
// resolveBuildTool finds an SDK build-tool, preferring PATH then the newest
|
|
// build-tools directory under known SDK roots.
|
|
func (a *App) resolveBuildTool(name string) (string, error) {
|
|
a.cacheMutex.RLock()
|
|
if c, ok := a.binaryCache["bt:"+name]; ok {
|
|
a.cacheMutex.RUnlock()
|
|
return c, nil
|
|
}
|
|
a.cacheMutex.RUnlock()
|
|
|
|
var candidates []string
|
|
if p := lookPath(name); p != "" {
|
|
candidates = append(candidates, p)
|
|
}
|
|
for _, bt := range buildToolsDirs() {
|
|
candidates = append(candidates, filepath.Join(bt, name))
|
|
}
|
|
for _, c := range candidates {
|
|
if info, err := os.Stat(c); err == nil && !info.IsDir() {
|
|
abs, _ := filepath.Abs(c)
|
|
a.cacheMutex.Lock()
|
|
a.binaryCache["bt:"+name] = abs
|
|
a.cacheMutex.Unlock()
|
|
return abs, nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("%s not found — install Android SDK build-tools (e.g. sdkmanager \"build-tools;37.0.0\")", name)
|
|
}
|
|
|
|
// buildToolsDirs returns build-tools version dirs, newest first, across SDK roots.
|
|
func buildToolsDirs() []string {
|
|
var roots []string
|
|
for _, env := range []string{"ANDROID_HOME", "ANDROID_SDK_ROOT"} {
|
|
if v := os.Getenv(env); v != "" {
|
|
roots = append(roots, v)
|
|
}
|
|
}
|
|
if home, err := os.UserHomeDir(); err == nil {
|
|
roots = append(roots,
|
|
filepath.Join(home, "Android", "Sdk"),
|
|
filepath.Join(home, "Library", "Android", "sdk"),
|
|
)
|
|
}
|
|
var dirs []string
|
|
for _, r := range roots {
|
|
bt := filepath.Join(r, "build-tools")
|
|
entries, err := os.ReadDir(bt)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
var versions []string
|
|
for _, e := range entries {
|
|
if e.IsDir() {
|
|
versions = append(versions, e.Name())
|
|
}
|
|
}
|
|
sort.Sort(sort.Reverse(sort.StringSlice(versions)))
|
|
for _, v := range versions {
|
|
dirs = append(dirs, filepath.Join(bt, v))
|
|
}
|
|
}
|
|
return dirs
|
|
}
|
|
|
|
// findJBR locates a JBR/JDK home (for apksigner/keytool). Prefers Android
|
|
// Studio's bundled JBR, matching the project's build recipe.
|
|
func findJBR() string {
|
|
if v := os.Getenv("JAVA_HOME"); v != "" {
|
|
if _, err := os.Stat(filepath.Join(v, "bin", "java")); err == nil {
|
|
return v
|
|
}
|
|
}
|
|
home, _ := os.UserHomeDir()
|
|
globs := []string{
|
|
filepath.Join(home, "Documents", "android-studio*", "android-studio", "jbr"),
|
|
filepath.Join(home, "android-studio", "jbr"),
|
|
"/opt/android-studio/jbr",
|
|
"/usr/lib/jvm/*/",
|
|
}
|
|
for _, g := range globs {
|
|
matches, _ := filepath.Glob(g)
|
|
for _, m := range matches {
|
|
if _, err := os.Stat(filepath.Join(m, "bin", "java")); err == nil {
|
|
return strings.TrimRight(m, "/")
|
|
}
|
|
}
|
|
}
|
|
if p := lookPath("java"); p != "" {
|
|
// java is .../bin/java → JAVA_HOME is two levels up
|
|
return filepath.Dir(filepath.Dir(p))
|
|
}
|
|
return ""
|
|
}
|