Skip to content

security: zero-click cache deletion via HOMEBREW_CACHE env + pre-seeded ScheduleConfig #48

@gladiator9797

Description

@gladiator9797

Summary

Three independent weaknesses compose into a zero-click data-loss chain:

  1. ScanEngine.scanBrewCache spawns brew --cache with Process() without sanitising env. brew honours HOMEBREW_CACHE → attacker-controlled output flows into brewCachePathsremoveItem.
  2. CleaningEngine.isSafeToDelete allow-lists ~/Downloads, ~/Documents, ~/Desktop as whole-subtree deletable roots, so an attacker-chosen target like \$HOME/Documents passes.
  3. SchedulerService.init loads PureMac.ScheduleConfig from UserDefaults before onboarding; AppState.init calls scheduler.start() unconditionally. A pre-written plist (defaults write com.puremac.app …) with autoClean=true, nextRunDate=<past> triggers cleanAll within 60s of the next launch.

Location

  • PureMac/Services/ScanEngine.swift 279-338
  • PureMac/Services/CleaningEngine.swift 111-139
  • PureMac/Services/SchedulerService.swift 13-21
  • PureMac/ViewModels/AppState.swift 71-79

Evidence (runtime, macOS 26.4)

export HOMEBREW_CACHE=\$HOME/Documents
\$ /opt/homebrew/bin/brew --cache
/Users/victim/Documents          ← attacker wins

# + pre-seeded ScheduleConfig.autoClean=true, nextRunDate=-1s
# → scheduler ticks in ≤60s → scanBrewCache emits CleanableItem path=\$HOME/Documents
# → isSafeToDelete(\$HOME/Documents) passes (Documents in allow-list)
# → removeItem wipes \$HOME/Documents

Full lab transcript: all three prereqs (launchctl setenv, defaults write <bundle>, direct plist write) work from any user-UID shell with no elevation.

Attacker model

Any prior same-UID foothold (malicious brew formula post-install, rogue LaunchAgent, 30s at an unlocked Mac). No admin/root needed.

Fix

`scanBrewCache` — strip HOMEBREW_* env and validate the returned path:

var sanitized = ProcessInfo.processInfo.environment
for key in sanitized.keys where key.hasPrefix(\"HOMEBREW_\") { sanitized.removeValue(forKey: key) }
task.environment = sanitized
// ... after reading output:
let knownRoots = [
    \"\\(home)/Library/Caches/Homebrew\",
    \"/opt/homebrew/Library/Caches\",
    \"/usr/local/Homebrew/Library/Caches\",
    \"/Library/Caches/Homebrew\",
]
let normalized = normalizePath(output)
guard knownRoots.contains(where: { normalized == \$0 || normalized.hasPrefix(\$0 + \"/\") }) else {
    Logger.shared.log(\"Refusing suspicious brew cache path: \\(output)\", level: .warning); break
}

isSafeToDelete — drop user-data roots from whole-subtree allow-list:

-            \"\\(home)/Downloads\",
-            \"\\(home)/Documents\",
-            \"\\(home)/Desktop\",

(scanLargeFiles emits per-file items; those still cleanable via explicit per-item authorisation once the above allow-list is fixed with trailing-slash per related issue.)

SchedulerService.init + AppState.init — gate scheduler on onboarding and sanitise stale nextRunDate:

// SchedulerService.init, after decoding saved config:
var sanitized = saved
if let next = sanitized.nextRunDate, next.timeIntervalSinceNow < 60 {
    sanitized.nextRunDate = nil; sanitized.lastRunDate = nil
}
self.config = sanitized

// AppState.init, replace scheduler.start():
if UserDefaults.standard.bool(forKey: \"PureMac.OnboardingComplete\") {
    scheduler.start()
}

Test

  • Set HOMEBREW_CACHE=\$HOME/Documents then Smart Scan → Brew Cache empty, log shows "Refusing suspicious brew cache path".
  • defaults write com.puremac.app PureMac.ScheduleConfig with past nextRunDate → scheduler does NOT fire on next launch.
  • Fresh install + attacker-seeded config → scheduler stays stopped until onboarding completes.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions